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.
- package/dist/types/api/dispatch.d.ts +2 -2
- package/dist/types/api/handlers/workflows.d.ts +2 -2
- package/dist/types/bindings/container-cleanup.d.ts +43 -0
- package/dist/types/bindings/container-docker.d.ts +24 -0
- package/dist/types/bindings/container.d.ts +4 -2
- package/dist/types/bindings/do-executor-worker.d.ts +73 -89
- package/dist/types/bindings/do-executor.d.ts +22 -1
- package/dist/types/bindings/do-worker-env.d.ts +16 -7
- package/dist/types/bindings/durable-object.d.ts +66 -4
- package/dist/types/bindings/queue.d.ts +4 -1
- package/dist/types/bindings/rpc-stub.d.ts +23 -1
- package/dist/types/bindings/scheduled.d.ts +13 -3
- package/dist/types/bindings/service-binding.d.ts +13 -4
- package/dist/types/bindings/static-assets.d.ts +1 -1
- package/dist/types/bindings/websocket-pair.d.ts +12 -3
- package/dist/types/bindings/workflow.d.ts +29 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/env.d.ts +6 -0
- package/dist/types/error-page-render.d.ts +6 -0
- package/dist/types/execution-context.d.ts +8 -0
- package/dist/types/generation-manager.d.ts +21 -2
- package/dist/types/generation.d.ts +16 -21
- package/dist/types/import-graph.d.ts +28 -0
- package/dist/types/lopata-config.d.ts +3 -3
- package/dist/types/plugin.d.ts +4 -1
- package/dist/types/rpc-validate.d.ts +9 -0
- package/dist/types/setup-globals.d.ts +5 -1
- package/dist/types/tracing/context.d.ts +21 -0
- package/dist/types/tracing/span.d.ts +21 -3
- package/dist/types/tracing/store.d.ts +17 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dist/types/vite-plugin/index.d.ts +8 -1
- package/dist/types/worker-registry.d.ts +11 -4
- package/dist/types/worker-thread/do-protocol.d.ts +256 -0
- package/dist/types/worker-thread/entry.d.ts +2 -0
- package/dist/types/worker-thread/execution-context.d.ts +22 -0
- package/dist/types/worker-thread/executor.d.ts +123 -0
- package/dist/types/worker-thread/protocol.d.ts +553 -0
- package/dist/types/worker-thread/remote-trace-store.d.ts +27 -0
- package/dist/types/worker-thread/rpc-client.d.ts +4 -0
- package/dist/types/worker-thread/rpc-shared.d.ts +138 -0
- package/dist/types/worker-thread/serialize.d.ts +12 -0
- package/dist/types/worker-thread/stream-shared.d.ts +147 -0
- package/dist/types/worker-thread/thread-env.d.ts +42 -0
- package/dist/types/worker-thread/wire-handlers.d.ts +16 -0
- package/dist/types/worker-thread/ws-bridge-shared.d.ts +163 -0
- package/package.json +1 -1
- package/src/api/handlers/containers.ts +2 -1
- package/src/api/handlers/workflows.ts +39 -34
- package/src/bindings/container-cleanup.ts +125 -0
- package/src/bindings/container-docker.ts +49 -34
- package/src/bindings/container.ts +24 -9
- package/src/bindings/do-executor-inprocess.ts +9 -5
- package/src/bindings/do-executor-worker.ts +386 -158
- package/src/bindings/do-executor.ts +23 -1
- package/src/bindings/do-worker-entry.ts +242 -60
- package/src/bindings/do-worker-env.ts +296 -11
- package/src/bindings/durable-object.ts +231 -27
- package/src/bindings/email.ts +6 -0
- package/src/bindings/queue.ts +11 -1
- package/src/bindings/rpc-stub.ts +79 -10
- package/src/bindings/scheduled.ts +37 -30
- package/src/bindings/service-binding.ts +96 -35
- package/src/bindings/static-assets.ts +3 -2
- package/src/bindings/websocket-pair.ts +19 -3
- package/src/bindings/workflow.ts +91 -0
- package/src/cli/dev.ts +106 -41
- package/src/config.ts +6 -3
- package/src/db.ts +1 -0
- package/src/env.ts +40 -21
- package/src/error-page-render.ts +21 -0
- package/src/execution-context.ts +13 -3
- package/src/generation-manager.ts +144 -143
- package/src/generation.ts +150 -306
- package/src/import-graph.ts +140 -0
- package/src/lopata-config.ts +3 -3
- package/src/plugin.ts +7 -0
- package/src/rpc-validate.ts +29 -0
- package/src/setup-globals.ts +6 -17
- package/src/testing/durable-object.ts +5 -3
- package/src/testing/index.ts +8 -4
- package/src/tracing/context.ts +28 -0
- package/src/tracing/span.ts +88 -56
- package/src/tracing/store.ts +41 -3
- package/src/virtual-modules.ts +2 -0
- package/src/vite-plugin/dev-server-plugin.ts +4 -0
- package/src/vite-plugin/index.ts +8 -1
- package/src/worker-registry.ts +15 -2
- package/src/worker-thread/do-protocol.ts +237 -0
- package/src/worker-thread/entry.ts +453 -0
- package/src/worker-thread/execution-context.ts +53 -0
- package/src/worker-thread/executor.ts +595 -0
- package/src/worker-thread/protocol.ts +552 -0
- package/src/worker-thread/remote-trace-store.ts +90 -0
- package/src/worker-thread/rpc-client.ts +5 -0
- package/src/worker-thread/rpc-shared.ts +503 -0
- package/src/worker-thread/serialize.ts +37 -0
- package/src/worker-thread/stream-shared.ts +414 -0
- package/src/worker-thread/thread-env.ts +350 -0
- package/src/worker-thread/wire-handlers.ts +80 -0
- package/src/worker-thread/ws-bridge-shared.ts +482 -0
- package/dist/types/bindings/do-websocket-bridge.d.ts +0 -60
- package/dist/types/module-cache.d.ts +0 -23
- package/src/bindings/do-websocket-bridge.ts +0 -79
- package/src/module-cache.ts +0 -58
- 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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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: (() =>
|
|
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: (() =>
|
|
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 () =>
|
|
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():
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
258
|
+
resolveP(createRpcFunctionStub(member as Function, target))
|
|
198
259
|
} else {
|
|
199
|
-
|
|
260
|
+
resolveP(wrapRpcReturnValue(member, prop))
|
|
200
261
|
}
|
|
201
262
|
} catch (e) {
|
|
202
|
-
|
|
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(
|
|
62
|
-
const url = new 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
|
-
|
|
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) {
|
package/src/bindings/workflow.ts
CHANGED
|
@@ -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
|