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:

auth-signals.service.tsTypeScript
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)
order-signals.service.tsTypeScript
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:

order-service.tsTypeScript
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:

fulfillment-process.tsTypeScript
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:

payload-example.tsTypeScript
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):

emit-examples.tsTypeScript
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:

webhook-controller.tsTypeScript
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:

routing-example.tsTypeScript
// 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 resumes

Multi-Param Routing

For signals with multiple identity parameters, all must match:

multi-param-routing.tsTypeScript
// 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:

signal-usage.tsTypeScript
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
type-safety.tsTypeScript
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 exist

Best 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)