Signals
Event-driven suspension and resumption of durable processes
Signals are typed events that suspend a durable process until they're emitted. They enable long-running workflows to wait for external events like webhook callbacks, user actions, or scheduled triggers. Signals are always defined inside services - this is the only supported pattern.
Defining Signals in Services
Signals are defined as properties on services using createSignal. This keeps related signals grouped with their domain logic and makes them injectable into processes:
import { createService } from '@justscale/core'
import { createSignal } from '@justscale/process'
/**
* Signals are ALWAYS defined inside services.
* They can be in a dedicated signals service or alongside domain logic.
*/
export const AuthSignals = createService({
inject: {},
factory: () => ({
// Signal: User clicked email verification link
emailVerified: createSignal<[userId: string], { token: string }>(
'auth.email.verified',
['userId']
),
// Signal: User requested to resend verification email
resendVerification: createSignal<[userId: string]>(
'auth.email.resend_requested',
['userId']
),
// Signal: User submitted new password via reset link
passwordResetVerified: createSignal<[email: string], { token: string; newPassword: string }>(
'auth.password.reset_verified',
['email']
),
}),
})Signal Type Parameters
Each createSignal call takes type parameters:
- Identity tuple - Parameters for routing signals to the correct process instance
- Payload type - Data carried by the signal (optional, defaults to void)
import { createService } from '@justscale/core'
import { createSignal } from '@justscale/process'
export const OrderSignals = createService({
inject: {},
factory: () => ({
// Single identity param, with payload
paymentReceived: createSignal<[orderId: string], { txId: string; amount: number }>(
'orders.payment_received',
['orderId']
),
// Single identity param, no payload (void)
delivered: createSignal<[orderId: string]>(
'orders.delivered',
['orderId']
),
// Multiple identity params
itemReturned: createSignal<[orderId: string, itemId: string], { reason: string }>(
'orders.item_returned',
['orderId', 'itemId']
),
}),
})Signals with Domain Services
You can also define signals alongside regular service methods. This works well when signals are closely tied to domain operations:
import { createService } from '@justscale/core'
import { createSignal } from '@justscale/process'
export const OrderService = createService({
inject: { db: Database },
factory: ({ db }) => ({
// Regular service methods
findById: (id: string) => db.orders.findById(id),
create: (data: OrderInput) => db.orders.create(data),
// Signals mixed with domain logic
paymentReceived: createSignal<[orderId: string], { txId: string }>(
'orders.payment_received',
['orderId']
),
shipped: createSignal<[orderId: string], { trackingNumber: string }>(
'orders.shipped',
['orderId']
),
cancelled: createSignal<[orderId: string], { reason: string }>(
'orders.cancelled',
['orderId']
),
}),
})Waiting for Signals
Inside a process handler, use signal() to suspend until a signal is emitted. The process state is persisted, and execution resumes when the signal arrives:
import { createProcess, signal } from '@justscale/process'
import { OrderService, ShippingService } from './services'
export const FulfillmentProcess = createProcess({
path: '/order/:orderId/fulfillment',
inject: { orders: OrderService, shipping: ShippingService },
async handler({ orders, shipping }, [orderId]) {
// Wait for payment - process suspends here
const payment = await signal(orders.paymentReceived)
// payment is typed as { txId: string }
// Dispatch shipment
await shipping.dispatch(orderId)
// Wait for delivery - process suspends again
await signal(orders.delivered)
// Process completes
return { status: 'delivered', paymentTxId: payment.txId }
},
})Signal Payload Access
The awaited signal returns its payload, fully typed based on the signal definition:
async handler({ documents }, [docId]) {
// Wait for approval signal
const approval = await signal(documents.approved)
// approval is typed as:
// { approvedBy: string; comments: string[]; signature: string }
console.log(`Approved by ${approval.approvedBy}`)
console.log(`Comments: ${approval.comments.join(', ')}`)
// Use the signature for next steps
await notarize(docId, approval.signature)
}Emitting Signals
Emit signals by calling them like functions. Provide identity parameters first, then the payload (if the signal has one):
import { OrderService, DocumentService } from './services'
// Get service instances (from DI container or injected)
const orders: OrderService = container.resolve(OrderService)
const docs: DocumentService = container.resolve(DocumentService)
// Emit signal without payload
await orders.delivered('order-123')
// Emit signal with payload
await orders.paymentReceived('order-123', {
txId: 'tx_abc123',
})
// Emit with multiple identity params
await orders.itemReturned('order-123', 'item-456', {
reason: 'Damaged in shipping',
})
// Emit complex payload
await docs.approved('doc-789', {
approvedBy: 'user-123',
comments: ['Looks good', 'Minor typo on page 3'],
signature: 'base64-signature-data',
})Common Emission Patterns
Signals are typically emitted from webhook handlers, API endpoints, or background jobs:
import { createController } from '@justscale/core'
import { Post } from '@justscale/http'
import { OrderService } from './services'
export const StripeWebhookController = createController('/webhooks/stripe', {
inject: { orders: OrderService },
routes: ({ orders }) => ({
paymentIntent: Post('/payment-intent')
.handle(async ({ body, res }) => {
const event = body
switch (event.type) {
case 'payment_intent.succeeded':
// Emit signal - resumes waiting processes
await orders.paymentReceived(event.data.orderId, {
txId: event.data.transactionId,
})
break
case 'payment_intent.payment_failed':
await orders.paymentFailed(event.data.orderId, {
reason: event.data.failureReason,
})
break
}
res.json({ received: true })
}),
}),
})Signal Routing
When a signal is emitted, the runtime matches it to waiting processes using identity parameters. Only processes with matching identity receive the signal:
// Process 1: waiting for order-123
await FulfillmentProcess(['order-123'])
// internally waits on: signal(orders.paymentReceived)
// Process 2: waiting for order-456
await FulfillmentProcess(['order-456'])
// internally waits on: signal(orders.paymentReceived)
// Emit for order-123 only
await orders.paymentReceived('order-123', { txId: 'tx_abc' })
// Only Process 1 resumes, Process 2 keeps waiting
// Emit for order-456
await orders.paymentReceived('order-456', { txId: 'tx_def' })
// Now Process 2 resumesMulti-Param Routing
For signals with multiple identity parameters, all must match:
// Signal with two identity params
const itemReturned = createSignal<[orderId: string, itemId: string], { reason: string }>(
'orders.item_returned',
['orderId', 'itemId']
)
// Process waiting for specific order + item
await ReturnProcess(['order-123', 'item-456'])
// This matches:
await itemReturned('order-123', 'item-456', { reason: 'Defective' })
// This does NOT match (different itemId):
await itemReturned('order-123', 'item-789', { reason: 'Wrong size' })Signal vs Direct Await
The signal() wrapper is required for the compiler to recognize suspension points. Directly awaiting a signal property won't work:
async handler({ orders }, [orderId]) {
// CORRECT: Use signal() wrapper
const payment = await signal(orders.paymentReceived)
// WRONG: Direct await throws at runtime
// const payment = await orders.paymentReceived // Error!
// WRONG: Storing signal in variable
// const sig = signal(orders.paymentReceived) // Error!
// const payment = await sig
// CORRECT: Always await signal() directly
const shipped = await signal(orders.shipped)
}Type Safety
Signals are fully type-safe. TypeScript validates:
- Identity parameter types when emitting
- Payload type when emitting and receiving
- Signal name inference for debugging
const paymentReceived = createSignal<[orderId: string], { txId: string }>(
'payments.received',
['orderId']
)
// Type errors:
// Wrong identity type
await paymentReceived(123, { txId: 'tx' })
// ^^^ Error: Expected string
// Missing payload property
await paymentReceived('order-123', {})
// ^^ Error: Missing 'txId'
// Wrong payload type
await paymentReceived('order-123', { txId: 123 })
// ^^^ Error: Expected string
// Extra payload property (optional depending on strictness)
await paymentReceived('order-123', { txId: 'tx', extra: true })
// In process handler:
const payment = await signal(paymentReceived)
payment.txId // string
payment.amount // Error: Property 'amount' does not existBest Practices
- Descriptive signal names - Use domain.action format like 'orders.shipped'
- Group signals in services - Keep signals with related domain logic
- Use labeled identity params - Makes routing logic clear
- Keep payloads serializable - Stick to primitives, arrays, and plain objects
- Document signal contracts - Other systems (webhooks) depend on them
- Consider idempotency - Emitting the same signal twice is safe (no duplicate resumes)