lopata 0.18.3 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/types/api/dispatch.d.ts +2 -2
  2. package/dist/types/api/handlers/workflows.d.ts +2 -2
  3. package/dist/types/bindings/container-cleanup.d.ts +43 -0
  4. package/dist/types/bindings/container-docker.d.ts +24 -0
  5. package/dist/types/bindings/container.d.ts +4 -2
  6. package/dist/types/bindings/do-executor-worker.d.ts +73 -89
  7. package/dist/types/bindings/do-executor.d.ts +22 -1
  8. package/dist/types/bindings/do-worker-env.d.ts +16 -7
  9. package/dist/types/bindings/durable-object.d.ts +66 -4
  10. package/dist/types/bindings/queue.d.ts +4 -1
  11. package/dist/types/bindings/rpc-stub.d.ts +23 -1
  12. package/dist/types/bindings/scheduled.d.ts +13 -3
  13. package/dist/types/bindings/service-binding.d.ts +13 -4
  14. package/dist/types/bindings/static-assets.d.ts +1 -1
  15. package/dist/types/bindings/websocket-pair.d.ts +12 -3
  16. package/dist/types/bindings/workflow.d.ts +29 -0
  17. package/dist/types/config.d.ts +2 -0
  18. package/dist/types/env.d.ts +6 -0
  19. package/dist/types/error-page-render.d.ts +6 -0
  20. package/dist/types/execution-context.d.ts +8 -0
  21. package/dist/types/generation-manager.d.ts +21 -2
  22. package/dist/types/generation.d.ts +16 -21
  23. package/dist/types/import-graph.d.ts +28 -0
  24. package/dist/types/lopata-config.d.ts +3 -3
  25. package/dist/types/plugin.d.ts +4 -1
  26. package/dist/types/rpc-validate.d.ts +9 -0
  27. package/dist/types/setup-globals.d.ts +5 -1
  28. package/dist/types/tracing/context.d.ts +21 -0
  29. package/dist/types/tracing/span.d.ts +21 -3
  30. package/dist/types/tracing/store.d.ts +17 -0
  31. package/dist/types/tsconfig.tsbuildinfo +1 -1
  32. package/dist/types/vite-plugin/index.d.ts +8 -1
  33. package/dist/types/worker-registry.d.ts +11 -4
  34. package/dist/types/worker-thread/do-protocol.d.ts +256 -0
  35. package/dist/types/worker-thread/entry.d.ts +2 -0
  36. package/dist/types/worker-thread/execution-context.d.ts +22 -0
  37. package/dist/types/worker-thread/executor.d.ts +123 -0
  38. package/dist/types/worker-thread/protocol.d.ts +553 -0
  39. package/dist/types/worker-thread/remote-trace-store.d.ts +27 -0
  40. package/dist/types/worker-thread/rpc-client.d.ts +4 -0
  41. package/dist/types/worker-thread/rpc-shared.d.ts +138 -0
  42. package/dist/types/worker-thread/serialize.d.ts +12 -0
  43. package/dist/types/worker-thread/stream-shared.d.ts +147 -0
  44. package/dist/types/worker-thread/thread-env.d.ts +42 -0
  45. package/dist/types/worker-thread/wire-handlers.d.ts +16 -0
  46. package/dist/types/worker-thread/ws-bridge-shared.d.ts +163 -0
  47. package/package.json +1 -1
  48. package/src/api/handlers/containers.ts +2 -1
  49. package/src/api/handlers/workflows.ts +39 -34
  50. package/src/bindings/container-cleanup.ts +125 -0
  51. package/src/bindings/container-docker.ts +49 -34
  52. package/src/bindings/container.ts +24 -9
  53. package/src/bindings/do-executor-inprocess.ts +9 -5
  54. package/src/bindings/do-executor-worker.ts +386 -158
  55. package/src/bindings/do-executor.ts +23 -1
  56. package/src/bindings/do-worker-entry.ts +242 -60
  57. package/src/bindings/do-worker-env.ts +296 -11
  58. package/src/bindings/durable-object.ts +231 -27
  59. package/src/bindings/email.ts +6 -0
  60. package/src/bindings/queue.ts +11 -1
  61. package/src/bindings/rpc-stub.ts +79 -10
  62. package/src/bindings/scheduled.ts +37 -30
  63. package/src/bindings/service-binding.ts +96 -35
  64. package/src/bindings/static-assets.ts +3 -2
  65. package/src/bindings/websocket-pair.ts +19 -3
  66. package/src/bindings/workflow.ts +91 -0
  67. package/src/cli/dev.ts +106 -41
  68. package/src/config.ts +6 -3
  69. package/src/db.ts +1 -0
  70. package/src/env.ts +40 -21
  71. package/src/error-page-render.ts +21 -0
  72. package/src/execution-context.ts +13 -3
  73. package/src/generation-manager.ts +144 -143
  74. package/src/generation.ts +150 -306
  75. package/src/import-graph.ts +140 -0
  76. package/src/lopata-config.ts +3 -3
  77. package/src/plugin.ts +7 -0
  78. package/src/rpc-validate.ts +29 -0
  79. package/src/setup-globals.ts +6 -17
  80. package/src/testing/durable-object.ts +5 -3
  81. package/src/testing/index.ts +8 -4
  82. package/src/tracing/context.ts +28 -0
  83. package/src/tracing/span.ts +88 -56
  84. package/src/tracing/store.ts +41 -3
  85. package/src/virtual-modules.ts +2 -0
  86. package/src/vite-plugin/dev-server-plugin.ts +4 -0
  87. package/src/vite-plugin/index.ts +8 -1
  88. package/src/worker-registry.ts +15 -2
  89. package/src/worker-thread/do-protocol.ts +237 -0
  90. package/src/worker-thread/entry.ts +453 -0
  91. package/src/worker-thread/execution-context.ts +53 -0
  92. package/src/worker-thread/executor.ts +595 -0
  93. package/src/worker-thread/protocol.ts +552 -0
  94. package/src/worker-thread/remote-trace-store.ts +90 -0
  95. package/src/worker-thread/rpc-client.ts +5 -0
  96. package/src/worker-thread/rpc-shared.ts +503 -0
  97. package/src/worker-thread/serialize.ts +37 -0
  98. package/src/worker-thread/stream-shared.ts +414 -0
  99. package/src/worker-thread/thread-env.ts +350 -0
  100. package/src/worker-thread/wire-handlers.ts +80 -0
  101. package/src/worker-thread/ws-bridge-shared.ts +482 -0
  102. package/dist/types/bindings/do-websocket-bridge.d.ts +0 -60
  103. package/dist/types/module-cache.d.ts +0 -23
  104. package/src/bindings/do-websocket-bridge.ts +0 -79
  105. package/src/module-cache.ts +0 -58
  106. package/src/tracing/global.d.ts +0 -50
@@ -1,4 +1,3 @@
1
- import { ExecutionContext } from '../execution-context'
2
1
  import { persistError, startSpan } from '../tracing/span'
3
2
 
4
3
  export interface ScheduledController {
@@ -269,39 +268,47 @@ export function createScheduledController(cron: string, scheduledTime: number):
269
268
  }
270
269
  }
271
270
 
272
- type ScheduledHandler = (controller: ScheduledController, env: Record<string, unknown>, ctx: ExecutionContext) => Promise<void>
273
-
274
- export function startCronScheduler(
271
+ /**
272
+ * Generic cron timer. `invoke` is called with the matched expression and the
273
+ * tick time; the caller decides how to dispatch (in-process handler vs RPC).
274
+ */
275
+ export function startCronTimer(
275
276
  crons: string[],
276
- handler: ScheduledHandler,
277
- env: Record<string, unknown>,
277
+ invoke: (cronExpr: string, now: Date) => Promise<unknown>,
278
278
  workerName?: string,
279
+ /**
280
+ * Per-cron last-fired-minute dedup state, keyed by cron expression. Pass a map
281
+ * OWNED BY THE GENERATION MANAGER (not per-generation) so it survives reloads:
282
+ * a fresh per-generation map would start empty and re-fire a cron for a minute
283
+ * the previous generation already handled — for `* * * * *` that's nearly every
284
+ * save. Defaults to a local map for standalone callers/tests.
285
+ */
286
+ lastFiredMinute: Map<string, number> = new Map(),
279
287
  ): NodeJS.Timer {
280
288
  const parsed = crons.map(parseCron)
281
289
 
282
- // Check every 60 seconds, aligned to the start of each minute
283
- const interval = setInterval(() => {
290
+ // Poll a few times per minute and fire each cron at most once per matching
291
+ // wall-clock minute. `setInterval` is NOT minute-aligned (it fires relative
292
+ // to creation, and HMR reload resets that phase), so a once-per-60s check
293
+ // could drift past a matching minute or land on it twice. Polling at 15s +
294
+ // per-minute dedup makes firing robust to that drift.
295
+ return setInterval(() => {
284
296
  const now = new Date()
285
- for (const cron of parsed) {
286
- if (cronMatchesDate(cron, now)) {
287
- const controller = createScheduledController(cron.expression, now.getTime())
288
- const ctx = new ExecutionContext()
289
- console.log(`[lopata] Cron triggered: ${cron.expression}`)
290
- startSpan({
291
- name: 'scheduled',
292
- kind: 'server',
293
- attributes: { cron: cron.expression },
294
- workerName,
295
- }, async () => {
296
- await handler(controller, env, ctx)
297
- await ctx._awaitAll()
298
- }).catch((err) => {
299
- console.error(`[lopata] Scheduled handler error (${cron.expression}):`, err)
300
- persistError(err, 'scheduled', workerName)
301
- })
302
- }
303
- }
304
- }, 60_000)
305
-
306
- return interval
297
+ const minuteKey = Math.floor(now.getTime() / 60_000)
298
+ parsed.forEach((cron) => {
299
+ if (!cronMatchesDate(cron, now)) return
300
+ if (lastFiredMinute.get(cron.expression) === minuteKey) return
301
+ lastFiredMinute.set(cron.expression, minuteKey)
302
+ console.log(`[lopata] Cron triggered: ${cron.expression}`)
303
+ startSpan({
304
+ name: 'scheduled',
305
+ kind: 'server',
306
+ attributes: { cron: cron.expression },
307
+ workerName,
308
+ }, () => invoke(cron.expression, now)).catch((err) => {
309
+ console.error(`[lopata] Scheduled handler error (${cron.expression}):`, err)
310
+ persistError(err, 'scheduled', workerName)
311
+ })
312
+ })
313
+ }, 15_000)
307
314
  }
@@ -11,6 +11,7 @@
11
11
  import { ExecutionContext } from '../execution-context'
12
12
  import { warnInvalidRpcArgs } from '../rpc-validate'
13
13
  import { getActiveContext, runWithContext } from '../tracing/context'
14
+ import type { ResolvedTarget } from '../worker-registry'
14
15
  import { createRpcFunctionStub, NON_RPC_PROPS, wrapRpcReturnValue } from './rpc-stub'
15
16
 
16
17
  type WorkerModule = Record<string, unknown>
@@ -30,8 +31,41 @@ const SERVICE_BINDING_DEFAULTS: Required<ServiceBindingLimits> = {
30
31
  // Internal properties that should be forwarded to the ServiceBinding instance
31
32
  const INTERNAL_PROPS = new Set(['_wire', 'isWired', '_subrequestCount'])
32
33
 
34
+ /** Error thrown by `connect()` (both in-process and worker-thread paths). */
35
+ export function serviceBindingConnectError(name: string): Error {
36
+ return new Error(`Service binding "${name}": connect() (TCP sockets) is not supported in local dev mode`)
37
+ }
38
+
39
+ /**
40
+ * Resolve the call target for a service binding RPC (`fetch` or method):
41
+ * a named entrypoint class, an unnamed default class, or the default object.
42
+ *
43
+ * Used by `ServiceBinding._getTarget` (in-process) and the worker-thread's
44
+ * `invokeEntrypointRpc`. Single source of truth so the in-process and
45
+ * thread-mode paths can't drift.
46
+ */
47
+ export function resolveEntrypointTarget(
48
+ workerModule: Record<string, unknown>,
49
+ entrypoint: string | undefined,
50
+ ctx: unknown,
51
+ env: unknown,
52
+ ): Record<string, unknown> {
53
+ if (entrypoint) {
54
+ const cls = workerModule[entrypoint]
55
+ if (typeof cls !== 'function') {
56
+ throw new Error(`Entrypoint "${entrypoint}" not exported from worker module`)
57
+ }
58
+ return new (cls as new(ctx: unknown, env: unknown) => Record<string, unknown>)(ctx, env)
59
+ }
60
+ const def = workerModule.default
61
+ if (typeof def === 'function' && def.prototype) {
62
+ return new (def as new(ctx: unknown, env: unknown) => Record<string, unknown>)(ctx, env)
63
+ }
64
+ return def as Record<string, unknown>
65
+ }
66
+
33
67
  export class ServiceBinding {
34
- private _resolver: (() => { workerModule: Record<string, unknown>; env: Record<string, unknown> }) | null = null
68
+ private _resolver: (() => ResolvedTarget) | null = null
35
69
  private _entrypoint: string | undefined
36
70
  private _serviceName: string
37
71
  private _limits: Required<ServiceBindingLimits>
@@ -47,17 +81,17 @@ export class ServiceBinding {
47
81
  }
48
82
 
49
83
  _wire(
50
- resolverOrModule: (() => { workerModule: Record<string, unknown>; env: Record<string, unknown> }) | Record<string, unknown>,
84
+ resolverOrModule: (() => ResolvedTarget) | Record<string, unknown>,
51
85
  env?: Record<string, unknown>,
52
86
  ): void {
53
87
  if (typeof resolverOrModule === 'function' && env === undefined) {
54
88
  // New API: resolver function
55
- this._resolver = resolverOrModule as () => { workerModule: Record<string, unknown>; env: Record<string, unknown> }
89
+ this._resolver = resolverOrModule as () => ResolvedTarget
56
90
  } else {
57
91
  // Legacy API: _wire(workerModule, env)
58
92
  const workerModule = resolverOrModule as Record<string, unknown>
59
93
  const capturedEnv = env!
60
- this._resolver = () => ({ workerModule, env: capturedEnv })
94
+ this._resolver = () => ({ kind: 'in-process', workerModule, env: capturedEnv })
61
95
  }
62
96
  }
63
97
 
@@ -65,7 +99,7 @@ export class ServiceBinding {
65
99
  return this._resolver !== null
66
100
  }
67
101
 
68
- private _resolve(): { workerModule: Record<string, unknown>; env: Record<string, unknown> } {
102
+ private _resolve(): ResolvedTarget {
69
103
  if (!this._resolver) {
70
104
  throw new Error(`Service binding "${this._serviceName}" is not wired — target worker not loaded`)
71
105
  }
@@ -73,8 +107,15 @@ export class ServiceBinding {
73
107
  }
74
108
 
75
109
  private _checkSubrequestLimit(): void {
76
- this._subrequestCount++
77
- if (this._subrequestCount > this._limits.maxSubrequests) {
110
+ // Prefer the per-top-level-request counter on the active span context so
111
+ // the budget resets each incoming request (Cloudflare semantics). Fall
112
+ // back to the per-binding counter only when there is no request context
113
+ // (direct or programmatic use, e.g. tests) — otherwise the count would
114
+ // leak across the whole dev-server lifetime and eventually 500 every
115
+ // asset request that goes through a service binding.
116
+ const requestCounter = getActiveContext()?.subrequests
117
+ const count = requestCounter ? ++requestCounter.count : ++this._subrequestCount
118
+ if (count > this._limits.maxSubrequests) {
78
119
  throw new Error(
79
120
  `Service binding "${this._serviceName}": subrequest limit exceeded (max ${this._limits.maxSubrequests})`,
80
121
  )
@@ -82,39 +123,37 @@ export class ServiceBinding {
82
123
  }
83
124
 
84
125
  private _getTarget(ctx?: ExecutionContext): Record<string, unknown> {
85
- const { workerModule, env } = this._resolve()
86
- const execCtx = ctx ?? new ExecutionContext(this._props)
87
- if (this._entrypoint) {
88
- const cls = workerModule[this._entrypoint] as (new(...args: unknown[]) => Record<string, unknown>) | undefined
89
- if (!cls) {
90
- throw new Error(`Entrypoint "${this._entrypoint}" not exported from worker module`)
91
- }
92
- return new cls(execCtx, env)
93
- }
94
- // Default export: could be class-based or object-based
95
- const def = workerModule.default
96
- if (typeof def === 'function' && def.prototype && typeof def.prototype.fetch === 'function') {
97
- return new (def as new(ctx: ExecutionContext, env: unknown) => Record<string, unknown>)(execCtx, env)
126
+ const resolved = this._resolve()
127
+ if (resolved.kind !== 'in-process') {
128
+ throw new Error(
129
+ `Service binding "${this._serviceName}": in-process resolve attempted but the target worker runs in thread isolation — calls must route through the thread executor`,
130
+ )
98
131
  }
99
- return def as Record<string, unknown>
132
+ const execCtx = ctx ?? new ExecutionContext(this._props)
133
+ return resolveEntrypointTarget(resolved.workerModule, this._entrypoint, execCtx, resolved.env)
100
134
  }
101
135
 
102
136
  async fetch(input: Request | string | URL, init?: RequestInit): Promise<Response> {
137
+ const url = input instanceof URL ? input.toString() : input
138
+ const request = typeof url === 'string' ? new Request(url, init) : url
139
+
140
+ // Resolve first so a missing target throws the real error instead of
141
+ // burning a slot in the per-request subrequest budget on every failed call.
142
+ const resolved = this._resolve()
103
143
  this._checkSubrequestLimit()
144
+ if (resolved.kind === 'thread') {
145
+ return resolved.executor.executeFetch(request, this._props)
146
+ }
147
+
104
148
  const execCtx = new ExecutionContext(this._props)
105
149
  const target = this._getTarget(execCtx)
106
150
  if (!target?.fetch || typeof target.fetch !== 'function') {
107
151
  throw new Error(`Service binding "${this._serviceName}" target has no fetch() handler`)
108
152
  }
109
- const url = input instanceof URL ? input.toString() : input
110
- const request = typeof url === 'string' ? new Request(url, init) : url
111
- // Class-based entrypoints receive (request) — env/ctx via constructor
112
- // Object-based entrypoints receive (request, env, ctx)
113
- const { workerModule, env } = this._resolve()
153
+ const { workerModule, env } = resolved
114
154
  const def = workerModule.default
115
155
  const isClass = this._entrypoint || (typeof def === 'function' && def.prototype?.fetch)
116
156
 
117
- // Propagate trace context to target worker so child spans link correctly
118
157
  const parentCtx = getActiveContext()
119
158
  const doCall = async () => {
120
159
  const response = isClass
@@ -131,9 +170,7 @@ export class ServiceBinding {
131
170
  }
132
171
 
133
172
  connect(_address: string | { hostname: string; port: number }): never {
134
- throw new Error(
135
- `Service binding "${this._serviceName}": connect() (TCP sockets) is not supported in local dev mode`,
136
- )
173
+ throw serviceBindingConnectError(this._serviceName)
137
174
  }
138
175
 
139
176
  toProxy(): Record<string, unknown> {
@@ -166,8 +203,15 @@ export class ServiceBinding {
166
203
  // If called as a function → RPC method call (always returns Promise)
167
204
  // If awaited → RPC property read (returns Promise of the property value)
168
205
  const rpcCallable = (...args: unknown[]) => {
169
- self._checkSubrequestLimit()
170
206
  warnInvalidRpcArgs(args, prop)
207
+ // Resolve first so a missing target throws the real error before
208
+ // the budget moves.
209
+ const resolved = self._resolve()
210
+ self._checkSubrequestLimit()
211
+ if (resolved.kind === 'thread') {
212
+ return resolved.executor.executeEntrypointRpc(self._entrypoint, prop, args, self._props)
213
+ .then((r) => wrapRpcReturnValue(r, prop))
214
+ }
171
215
  const target = self._getTarget()
172
216
  const member = target[prop]
173
217
  if (typeof member !== 'function') {
@@ -188,18 +232,35 @@ export class ServiceBinding {
188
232
  onFulfilled?: ((value: unknown) => unknown) | null,
189
233
  onRejected?: ((reason: unknown) => unknown) | null,
190
234
  ) => {
235
+ // Resolve before incrementing so a missing target doesn't burn budget.
236
+ const resolved = self._resolve()
191
237
  self._checkSubrequestLimit()
192
- const promise = new Promise<unknown>((resolve, reject) => {
238
+ if (resolved.kind === 'thread') {
239
+ const executor = resolved.executor
240
+ const entrypoint = self._entrypoint
241
+ const props = self._props
242
+ const promise = executor.executeEntrypointPropertyGet(entrypoint, prop, props).then((result) => {
243
+ if (result.kind === 'function') {
244
+ // Property is a function on the entrypoint — hand back a function-stub
245
+ // that RPCs through to the worker thread on each call.
246
+ const remoteFn = (...callArgs: unknown[]) => executor.executeEntrypointRpc(entrypoint, prop, callArgs, props)
247
+ return createRpcFunctionStub(remoteFn, undefined)
248
+ }
249
+ return wrapRpcReturnValue(result.value, prop)
250
+ })
251
+ return promise.then(onFulfilled, onRejected)
252
+ }
253
+ const promise = new Promise<unknown>((resolveP, rejectP) => {
193
254
  try {
194
255
  const target = self._getTarget()
195
256
  const member = target[prop]
196
257
  if (typeof member === 'function') {
197
- resolve(createRpcFunctionStub(member as Function, target))
258
+ resolveP(createRpcFunctionStub(member as Function, target))
198
259
  } else {
199
- resolve(wrapRpcReturnValue(member, prop))
260
+ resolveP(wrapRpcReturnValue(member, prop))
200
261
  }
201
262
  } catch (e) {
202
- reject(e)
263
+ rejectP(e)
203
264
  }
204
265
  })
205
266
  return promise.then(onFulfilled, onRejected)
@@ -58,8 +58,9 @@ export class StaticAssets {
58
58
  this.limits = { ...STATIC_ASSETS_LIMITS_DEFAULTS, ...limits }
59
59
  }
60
60
 
61
- async fetch(request: Request): Promise<Response> {
62
- const url = new URL(request.url)
61
+ async fetch(input: Request | string | URL): Promise<Response> {
62
+ const url = input instanceof Request ? new URL(input.url) : new URL(input)
63
+ const request = input instanceof Request ? input : undefined
63
64
  let pathname = decodeURIComponent(url.pathname)
64
65
 
65
66
  // Check _redirects rules first (highest precedence)
@@ -6,9 +6,9 @@
6
6
  * Events are buffered until accept() is called.
7
7
  */
8
8
 
9
- type WSEventType = 'message' | 'close' | 'error' | 'open'
9
+ export type WSEventType = 'message' | 'close' | 'error' | 'open'
10
10
 
11
- interface WSEvent {
11
+ export interface WSEvent {
12
12
  type: WSEventType
13
13
  data?: string | ArrayBuffer
14
14
  code?: number
@@ -16,6 +16,9 @@ interface WSEvent {
16
16
  wasClean?: boolean
17
17
  }
18
18
 
19
+ /** Response with optional CF `webSocket` property — used by upgrade flows. */
20
+ export type ResponseWithWebSocket = Response & { webSocket?: CFWebSocket }
21
+
19
22
  const CONNECTING = 0
20
23
  const OPEN = 1
21
24
  const CLOSING = 2
@@ -68,7 +71,10 @@ export class CFWebSocket extends EventTarget {
68
71
  accept(): void {
69
72
  if (this._accepted) return
70
73
  this._accepted = true
71
- this.readyState = OPEN
74
+ // Don't re-open a socket that was already closed before accept() (e.g. a
75
+ // bridged peer whose close was buffered ahead of registration). The queued
76
+ // close event below still dispatches to listeners.
77
+ if (this.readyState !== CLOSED) this.readyState = OPEN
72
78
 
73
79
  // Flush queued events
74
80
  const queue = this._eventQueue
@@ -134,6 +140,16 @@ export class CFWebSocket extends EventTarget {
134
140
  }
135
141
  }
136
142
 
143
+ /**
144
+ * Deliver `evt` to local listeners if the peer is already `accept()`ed;
145
+ * otherwise queue it for replay. Centralises the "dispatch or queue" branch
146
+ * that every bridge implementation otherwise inlines.
147
+ */
148
+ dispatchOrQueue(evt: WSEvent): void {
149
+ if (this._accepted) this._dispatchWSEvent(evt)
150
+ else this._eventQueue.push(evt)
151
+ }
152
+
137
153
  /** @internal */
138
154
  _dispatchWSEvent(evt: WSEvent): void {
139
155
  switch (evt.type) {
@@ -3,6 +3,7 @@ import type { Clock } from '../testing/clock'
3
3
  import { realClock } from '../testing/clock'
4
4
  import { getActiveContext } from '../tracing/context'
5
5
  import { addSpanEvent, persistError, startSpan } from '../tracing/span'
6
+ import type { WorkflowControlOp, WorkflowControlResult } from '../worker-thread/protocol'
6
7
 
7
8
  // --- Limits ---
8
9
 
@@ -720,6 +721,27 @@ export function parseDuration(duration: string | number): number {
720
721
 
721
722
  // --- Base class ---
722
723
 
724
+ /**
725
+ * Wire a workflow class from the user module onto a `SqliteWorkflowBinding`.
726
+ * Both the in-process `wireClassRefs` and the worker-thread entry use this
727
+ * — keeps the lookup-throw-setClass-resumeInterrupted contract in one place.
728
+ */
729
+ export function wireWorkflowClass(
730
+ binding: SqliteWorkflowBinding,
731
+ className: string,
732
+ workerModule: Record<string, unknown>,
733
+ env: Record<string, unknown>,
734
+ ): void {
735
+ const cls = workerModule[className]
736
+ if (!cls) throw new Error(`Workflow class "${className}" not exported from worker module`)
737
+ binding._setClass(cls as new(ctx: unknown, env: unknown) => WorkflowEntrypointBase, env)
738
+ // NOTE: resumeInterrupted() is deliberately NOT called here. Resuming during
739
+ // worker init would re-execute running/waiting instances while the previous
740
+ // generation's worker (terminated only after drain) is still running them —
741
+ // duplicate side effects. Main drives resume via a `resumeInterrupted` control
742
+ // op once the old generation's worker is disposed (see GenerationManager).
743
+ }
744
+
723
745
  export class WorkflowEntrypointBase {
724
746
  ctx: { env: unknown; waitUntil(p: Promise<unknown>): void }
725
747
  env: unknown
@@ -880,6 +902,13 @@ export class SqliteWorkflowBinding {
880
902
  private counter = 0
881
903
  private limits: Required<WorkflowLimits>
882
904
  private clock: Clock
905
+ /**
906
+ * Thread-mode router for dashboard control ops. The real state machine lives
907
+ * in the worker thread, so when this is set `executeControl` forwards there
908
+ * instead of running against this (hollow) main-side binding. `null`/unset =
909
+ * in-process: run locally. Installed by `GenerationManager` on each reload.
910
+ */
911
+ private _threadRouter?: (op: WorkflowControlOp) => Promise<WorkflowControlResult>
883
912
 
884
913
  constructor(db: Database, workflowName: string, className: string, limits?: WorkflowLimits, clock?: Clock) {
885
914
  this.db = db
@@ -1071,6 +1100,68 @@ export class SqliteWorkflowBinding {
1071
1100
  return new SqliteWorkflowInstance(this.db, id, this)
1072
1101
  }
1073
1102
 
1103
+ /**
1104
+ * Install the thread-mode router (see {@link _threadRouter}). Called by
1105
+ * `GenerationManager` on each reload so the dashboard's control ops reach the
1106
+ * live worker-side binding. Mirrors the DO namespace's `_setExternalClass`.
1107
+ */
1108
+ _setThreadRouter(router: (op: WorkflowControlOp) => Promise<WorkflowControlResult>): void {
1109
+ this._threadRouter = router
1110
+ }
1111
+
1112
+ /**
1113
+ * Execute a dashboard control operation. In thread mode the real state
1114
+ * machine (abort controllers, event waiters, sleep resolvers, the wired
1115
+ * class) lives in the worker, so when a thread router is installed this
1116
+ * forwards there; otherwise (in-process / worker-side binding) it runs
1117
+ * locally. Mutating ops resolve to `{ kind: 'ok' }`; `create` reports the new
1118
+ * id; the introspection reads report their value.
1119
+ */
1120
+ async executeControl(op: WorkflowControlOp): Promise<WorkflowControlResult> {
1121
+ if (this._threadRouter) return this._threadRouter(op)
1122
+ switch (op.kind) {
1123
+ case 'create': {
1124
+ const instance = await this.create({ id: op.id, params: op.params })
1125
+ return { kind: 'create', id: instance.id }
1126
+ }
1127
+ case 'status': {
1128
+ return { kind: 'status', value: await (await this.get(op.instanceId)).status() }
1129
+ }
1130
+ case 'resumeInterrupted': {
1131
+ this.resumeInterrupted()
1132
+ return { kind: 'ok' }
1133
+ }
1134
+ case 'terminate': {
1135
+ await (await this.get(op.instanceId)).terminate()
1136
+ return { kind: 'ok' }
1137
+ }
1138
+ case 'pause': {
1139
+ await (await this.get(op.instanceId)).pause()
1140
+ return { kind: 'ok' }
1141
+ }
1142
+ case 'resume': {
1143
+ await (await this.get(op.instanceId)).resume()
1144
+ return { kind: 'ok' }
1145
+ }
1146
+ case 'restart': {
1147
+ await (await this.get(op.instanceId)).restart(op.fromStep ? { fromStep: op.fromStep } : undefined)
1148
+ return { kind: 'ok' }
1149
+ }
1150
+ case 'skipSleep': {
1151
+ await (await this.get(op.instanceId)).skipSleep()
1152
+ return { kind: 'ok' }
1153
+ }
1154
+ case 'sendEvent': {
1155
+ await (await this.get(op.instanceId)).sendEvent({ type: op.eventType, payload: op.payload })
1156
+ return { kind: 'ok' }
1157
+ }
1158
+ case 'isSleeping':
1159
+ return { kind: 'isSleeping', value: isInstanceSleeping(op.instanceId) }
1160
+ case 'waitingEventTypes':
1161
+ return { kind: 'waitingEventTypes', value: getWaitingEventTypes(op.instanceId) }
1162
+ }
1163
+ }
1164
+
1074
1165
  /** Resume any workflow instances that were running/waiting when the process last exited. */
1075
1166
  resumeInterrupted(): void {
1076
1167
  if (!this._class) return