Process Compiler
How durable processes are transformed at build time
The process compiler transforms your async handler functions into switch-based state machines at build time. This enables processes to suspend, persist their state, and resume from exactly where they left off - while keeping the generated code readable and debuggable.
Compilation Pipeline
When you run the build, createProcess() calls are transformed through several stages:
- Detection - Find all
createProcess()calls in source files - Analysis - Parse the handler to find suspension points (signals, delays, races)
- Step extraction - Each suspension point becomes a separate case in the switch
- Variable rewriting - Local variables become
state.vars.xxxlookups - Rehydration insertion -
usingdeclarations are re-fetched at each step - Code generation - Output the compiled
__createSwitchProcesscall
Before and After
Here's what the compiler does to your code:
import { createProcess, signal, race, delay } from '@justscale/process'
import { UserService, NotificationService, AuthSignals } from './services'
export const forgotPassword = createProcess({
path: '/auth/forgot-password/:email',
inject: { users: UserService, notifications: NotificationService, signals: AuthSignals },
async handler({ users, notifications, signals }, [email]) {
// 'using' marks values for rehydration on resume
using user = await users.findByEmail(email)
if (!user) {
return { success: true, message: 'If account exists, email sent.' }
}
const resetToken = generateToken()
await notifications.sendPasswordResetEmail(email, resetToken)
// Race between signal and timeout - suspends here
const r = race()
switch (true) {
case signal(r, signals.passwordResetVerified):
await users.updatePassword(user.id, r.newPassword)
return { success: true, message: 'Password reset.' }
case delay.minutes(r, 15):
return { success: false, error: 'timeout' }
}
},
})The State Machine
Instead of opcodes, the compiler generates a while(true) + switch(step) state machine. Each suspension point becomes a separate case:
- Case 0 - Entry point, runs until first suspension
- Case 1, 2, ... - Resume points after each signal/timer
Return Tuple
The execute function returns [status, value]:
// Status 0: Process completed with result
__r[0] = 0
__r[1] = { success: true }
break main_loop
// Status 1: Process suspended, waiting for signal/timer
__r[0] = 1
__r[1] = { race: state.vars.__raceBranches }
break main_loop
// The executor uses this to:
// - status 0: Mark process as 'completed', resolve handle.wait()
// - status 1: Mark process as 'suspended', subscribe to signals/timersRehydration
Variables declared with using are non-serializable (like database entities with methods). The compiler ensures they're re-fetched at the start of each case:
// Source code:
using user = await users.findByEmail(email)
// In EVERY case that uses 'user':
case 0: {
user = await services.users.findByEmail(state.vars.email)
__dispose[__dispose_i++] = user // Track for cleanup
// ... rest of case
}
case 1: {
// Re-fetch on resume!
user = await services.users.findByEmail(state.vars.email)
__dispose[__dispose_i++] = user
// ... rest of case
}This ensures the user object is fresh after suspension, not stale serialized data.
Race Handling
The race() pattern compiles to branch setup and suspension:
// Source:
const r = race()
switch (true) {
case signal(r, signals.passwordResetVerified):
return { verified: true, token: r.token }
case delay.minutes(r, 15):
return { expired: true }
}
// Compiled:
state.vars.__raceBranches = [
{ id: "signals.passwordResetVerified", signal: "...", resumeStep: 1 },
{ id: "__timer__", timer: { minutes: 15 }, resumeStep: 2 }
]
__r[0] = 1
__r[1] = { race: state.vars.__raceBranches }
break main_loop
// When signal fires, executor sets:
state.vars.__raceResult = payload
state.step = 1 // or 2 for timer
// Then re-runs execute()Variable Storage
Local variables that cross suspension points are stored in state.vars:
// Source:
const resetToken = generateToken()
const expiresAt = new Date(Date.now() + 15 * 60 * 1000)
// After suspension, access via state.vars:
state.vars.resetToken = generateToken()
state.vars.expiresAt = new Date(Date.now() + 15 * 60 * 1000)
// Variables NOT crossing suspensions stay as locals:
const localTemp = compute() // Not stored in stateMetadata
The compiled process includes metadata for debugging and versioning:
{
// Unique process identifier
id: "auth_forgot-password__email",
// Route pattern
path: "/auth/forgot-password/:email",
// Version hash - changes when suspension structure changes
version: "v_9b6066ad",
// Maps step IDs to case numbers (for debugging)
stepMap: {
"entry_4311c3e9": 0,
"branch_747c6f71": 1,
"branch_c496ea1b": 2
},
// Maps cases to source line numbers (for stack traces)
sourceMap: {
0: [11, 26], // Entry spans lines 11-26
1: [21, 21], // Signal branch at line 21
2: [24, 24] // Timeout branch at line 24
},
// Signal definitions for runtime subscription
signals: {
"signals.passwordResetVerified": {
identity: [],
payloadType: "{ token: string; newPassword: string }"
}
}
}Dispose Pattern
The compiler tracks using declarations and calls Symbol.dispose on cleanup:
// Track disposables
const __dispose = [undefined]
let __dispose_i = 0
// When 'using' value is assigned
user = await services.users.findByEmail(state.vars.email)
__dispose[__dispose_i++] = user
// After main_loop exits (completion or suspension)
while (__dispose_i > 0) {
__dispose?.[--__dispose_i]?.[Symbol.dispose]?.()
}Compile-Time Errors
The compiler detects patterns that can't be safely suspended:
// ERROR: try/catch around suspension
try {
await signal(orders.paid) // Can't serialize exception state
} catch (e) { }
// ERROR: for-of loop with suspension
for (const item of items) {
await signal(item.processed) // Iterator not serializable
}
// ERROR: Promise.all with signals
await Promise.all([signal(a), signal(b)]) // Use race() instead
// ERROR: Storing signal in variable
const sig = signal(orders.paid) // Must await directly
await sigDelay Syntax
The compiler supports a cleaner delay syntax:
// New syntax - method on delay
case delay.minutes(r, 15):
case delay.hours(r, 24):
case delay.days(r, 7):
case delay.seconds(r, 30):
// Also works standalone
await delay.hours(24)
await delay.minutes(5)Version Hashing
The version hash changes when the suspension structure changes:
version: "v_9b6066ad" // Hash of suspension structure
// Hash CHANGES when:
// - Signal names change
// - Suspension points added/removed
// - Race branches change
// - Step order changes
// Hash does NOT change when:
// - Code inside cases changes (logic, comments)
// - Variable names change
// - Non-suspending code changesVersion mismatches are detected at resume time, enabling migration strategies for in-flight processes.
Runtime Execution
At runtime, the executor manages the state machine:
- Calls
execute(ctx)with current state and services - On
[0, result]- marks process complete, resolveshandle.wait() - On
[1, {race: branches}]- persists state, subscribes to signals/timers - When signal fires - sets
state.stepto resume step, re-runsexecute() - Uses
sourceMapto provide meaningful stack traces