lopata 0.18.4 → 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 +12 -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 +87 -33
- 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 +18 -0
- package/src/tracing/span.ts +83 -57
- 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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Import-graph watching for the dev server.
|
|
6
|
+
*
|
|
7
|
+
* The naive watcher polls a single directory (`dirname(config.main)`), which
|
|
8
|
+
* misses edits to code the worker imports from OUTSIDE that directory — e.g. an
|
|
9
|
+
* entry at `workers/app.ts` that imports handlers from `../app/**`. Because the
|
|
10
|
+
* worker-thread executor re-imports the WHOLE module graph on every reload, the
|
|
11
|
+
* only thing missing is the trigger: we just need to watch the files the worker
|
|
12
|
+
* actually depends on.
|
|
13
|
+
*
|
|
14
|
+
* We resolve that set statically with Bun's transpiler (`scanImports`) +
|
|
15
|
+
* `Bun.resolveSync`, following only project-local files (under `baseDir`, never
|
|
16
|
+
* `node_modules`). This needs no internal runtime registry (`globalThis.Loader`
|
|
17
|
+
* is gone in current Bun) and no bundler step.
|
|
18
|
+
*
|
|
19
|
+
* Caveat: a static scan cannot see fully-dynamic `import(variable)` targets. In
|
|
20
|
+
* practice worker entrypoints import statically; a dynamic target only fails to
|
|
21
|
+
* auto-reload until it is reached from a statically-imported file.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Extensions we parse for further imports. Others (e.g. .json) are watched but not scanned. */
|
|
25
|
+
const SCANNABLE = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Walk the worker's transitive import graph from `entry`, returning the set of
|
|
29
|
+
* absolute project-source paths it depends on (entry included). Anything that
|
|
30
|
+
* resolves outside `baseDir` or into `node_modules` is treated as external and
|
|
31
|
+
* not followed.
|
|
32
|
+
*/
|
|
33
|
+
export function collectImportGraph(entry: string, baseDir: string): Set<string> {
|
|
34
|
+
const seen = new Set<string>()
|
|
35
|
+
const transpiler = new Bun.Transpiler({ loader: 'tsx' })
|
|
36
|
+
const root = baseDir.endsWith(path.sep) ? baseDir : baseDir + path.sep
|
|
37
|
+
const stack = [path.resolve(entry)]
|
|
38
|
+
|
|
39
|
+
while (stack.length > 0) {
|
|
40
|
+
const file = stack.pop()!
|
|
41
|
+
if (seen.has(file)) continue
|
|
42
|
+
seen.add(file)
|
|
43
|
+
if (!SCANNABLE.has(path.extname(file))) continue
|
|
44
|
+
|
|
45
|
+
let code: string
|
|
46
|
+
try {
|
|
47
|
+
code = readFileSync(file, 'utf8')
|
|
48
|
+
} catch {
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
let imports: { path: string }[]
|
|
52
|
+
try {
|
|
53
|
+
imports = transpiler.scanImports(code)
|
|
54
|
+
} catch {
|
|
55
|
+
continue // unparseable file — skip its imports, still watch the file itself
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const dir = path.dirname(file)
|
|
59
|
+
for (const imp of imports) {
|
|
60
|
+
let resolved: string
|
|
61
|
+
try {
|
|
62
|
+
resolved = Bun.resolveSync(imp.path, dir)
|
|
63
|
+
} catch {
|
|
64
|
+
continue // bare/builtin/unresolvable (node:*, external pkg) — not project source
|
|
65
|
+
}
|
|
66
|
+
// Follow only project-local files; never descend into dependencies.
|
|
67
|
+
if (!resolved.startsWith(root) || resolved.includes(`${path.sep}node_modules${path.sep}`)) continue
|
|
68
|
+
if (!seen.has(resolved)) stack.push(resolved)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return seen
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Polls the worker's import graph for changes (mtime-based) and fires `onChange`.
|
|
76
|
+
* After a reload the caller should `rescan()` so newly-added imports start being
|
|
77
|
+
* watched (and deleted files stop being watched).
|
|
78
|
+
*/
|
|
79
|
+
export class ImportGraphWatcher {
|
|
80
|
+
private mtimes = new Map<string, number>()
|
|
81
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
private entry: string,
|
|
85
|
+
private baseDir: string,
|
|
86
|
+
private onChange: () => void,
|
|
87
|
+
private pollIntervalMs = 500,
|
|
88
|
+
) {}
|
|
89
|
+
|
|
90
|
+
start(): void {
|
|
91
|
+
if (this.timer) return
|
|
92
|
+
this.rescan()
|
|
93
|
+
this.timer = setInterval(() => this.poll(), this.pollIntervalMs)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
stop(): void {
|
|
97
|
+
if (this.timer) {
|
|
98
|
+
clearInterval(this.timer)
|
|
99
|
+
this.timer = null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Recompute the watched set from the current import graph. */
|
|
104
|
+
rescan(): void {
|
|
105
|
+
const next = new Map<string, number>()
|
|
106
|
+
for (const file of collectImportGraph(this.entry, this.baseDir)) {
|
|
107
|
+
try {
|
|
108
|
+
next.set(file, statSync(file).mtimeMs)
|
|
109
|
+
} catch {
|
|
110
|
+
// vanished between scan and stat — drop it
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
this.mtimes = next
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Number of files currently watched (for the startup log line). */
|
|
117
|
+
get size(): number {
|
|
118
|
+
return this.mtimes.size
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private poll(): void {
|
|
122
|
+
let changed = false
|
|
123
|
+
for (const [file, prev] of this.mtimes) {
|
|
124
|
+
let mtime: number
|
|
125
|
+
try {
|
|
126
|
+
mtime = statSync(file).mtimeMs
|
|
127
|
+
} catch {
|
|
128
|
+
// deleted — a real change; refreshed on the next rescan()
|
|
129
|
+
this.mtimes.delete(file)
|
|
130
|
+
changed = true
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (mtime !== prev) {
|
|
134
|
+
this.mtimes.set(file, mtime)
|
|
135
|
+
changed = true
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (changed) this.onChange()
|
|
139
|
+
}
|
|
140
|
+
}
|
package/src/lopata-config.ts
CHANGED
|
@@ -15,9 +15,9 @@ export interface LopataConfig {
|
|
|
15
15
|
/** Enable real cron scheduling based on wrangler triggers.crons (default: false) */
|
|
16
16
|
cron?: boolean
|
|
17
17
|
/**
|
|
18
|
-
* DO
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* @deprecated Has no effect. Workers and DO instances always run in their own
|
|
19
|
+
* Bun Worker thread now; the in-process path was removed. Kept only so existing
|
|
20
|
+
* configs don't fail to parse — see the deprecation warning in `cli/dev.ts`.
|
|
21
21
|
*/
|
|
22
22
|
isolation?: 'dev' | 'isolated'
|
|
23
23
|
/** AI SQL generation config for the D1 console */
|
package/src/plugin.ts
CHANGED
|
@@ -22,6 +22,13 @@ Object.defineProperty(globalThis, 'addEventListener', {
|
|
|
22
22
|
}) /** @internal Get the registered service worker fetch handler */
|
|
23
23
|
;(globalThis as any).__lopata_sw_handlers = _serviceWorkerHandlers
|
|
24
24
|
|
|
25
|
+
/** The fetch handler registered via the legacy `addEventListener('fetch', …)`
|
|
26
|
+
* service-worker syntax, if any. The worker-thread runtime falls back to this
|
|
27
|
+
* when the module has no `export default { fetch }`. */
|
|
28
|
+
export function getServiceWorkerFetchHandler(): ((event: unknown) => void) | undefined {
|
|
29
|
+
return _serviceWorkerHandlers.fetch
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
// ─── Console instrumentation ─────────────────────────────────────────
|
|
26
33
|
// Captures console.log/info/warn/error/debug as span events when inside a trace context.
|
|
27
34
|
|
package/src/rpc-validate.ts
CHANGED
|
@@ -158,3 +158,32 @@ export function warnInvalidRpcReturn(value: unknown, methodName: string): void {
|
|
|
158
158
|
console.warn(`[lopata] RPC ${methodName}() return value warning: ${msg}`)
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
const RPC_TARGET_BRAND = Symbol.for('lopata.RpcTarget')
|
|
163
|
+
|
|
164
|
+
function crossThreadUnsupportedKind(v: unknown): string | null {
|
|
165
|
+
if (typeof v === 'function') return 'a callback/function'
|
|
166
|
+
if (v && typeof v === 'object' && (v as Record<symbol, unknown>)[RPC_TARGET_BRAND] === true) return 'an RpcTarget'
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Thread-mode pre-flight for RPC args. Functions and RpcTarget instances are
|
|
172
|
+
* VALID on real Cloudflare (they cross as callback / target stubs) and pass
|
|
173
|
+
* {@link validateRpcValue}, but they can't be structured-cloned across lopata's
|
|
174
|
+
* worker-thread boundary — `postMessage` throws an opaque DataCloneError with no
|
|
175
|
+
* guidance. Warn clearly (top-level args; the common `stub.method(cb)` case) so
|
|
176
|
+
* the dev knows why the call is about to fail.
|
|
177
|
+
*/
|
|
178
|
+
export function warnCrossThreadRpcArgs(args: unknown[], methodName: string): void {
|
|
179
|
+
for (let i = 0; i < args.length; i++) {
|
|
180
|
+
const kind = crossThreadUnsupportedKind(args[i])
|
|
181
|
+
if (kind) {
|
|
182
|
+
console.warn(
|
|
183
|
+
`[lopata] RPC ${methodName}() arg${i} is ${kind} — these can't cross lopata's worker-thread `
|
|
184
|
+
+ `boundary (they work as stubs on real Cloudflare). Pass plain data instead; the call will `
|
|
185
|
+
+ `otherwise fail with a DataCloneError.`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/setup-globals.ts
CHANGED
|
@@ -6,7 +6,6 @@ import { HTMLRewriter } from './bindings/html-rewriter'
|
|
|
6
6
|
import { WebSocketPair } from './bindings/websocket-pair'
|
|
7
7
|
import { getDatabase } from './db'
|
|
8
8
|
import { instrumentBinding } from './tracing/instrument'
|
|
9
|
-
import { addSpanEvent, setSpanAttribute, startSpan } from './tracing/span'
|
|
10
9
|
|
|
11
10
|
let initialized = false
|
|
12
11
|
|
|
@@ -14,27 +13,17 @@ let initialized = false
|
|
|
14
13
|
* Sets up global Cloudflare-compatible APIs:
|
|
15
14
|
* caches, HTMLRewriter, WebSocketPair, IdentityTransformStream, FixedLengthStream,
|
|
16
15
|
* navigator.userAgent, navigator.language, performance.timeOrigin, scheduler.wait(),
|
|
17
|
-
* crypto extensions
|
|
16
|
+
* and crypto extensions.
|
|
17
|
+
*
|
|
18
|
+
* Custom user spans are provided via the Cloudflare-native `tracing.enterSpan`
|
|
19
|
+
* API exported from `cloudflare:workers` (see src/virtual-modules.ts), not a
|
|
20
|
+
* Lopata-specific global.
|
|
18
21
|
*
|
|
19
22
|
* Idempotent — safe to call multiple times.
|
|
20
23
|
*/
|
|
21
24
|
export function setupCloudflareGlobals() {
|
|
22
25
|
if (initialized) return
|
|
23
|
-
initialized = true
|
|
24
|
-
// Exposes a lightweight global that user code can call to create custom
|
|
25
|
-
// spans visible in the Lopata dashboard. In production (without Lopata)
|
|
26
|
-
// the global is simply absent, so the user's thin wrapper becomes a no-op.
|
|
27
|
-
;(globalThis as any).__lopata = {
|
|
28
|
-
trace<T>(name: string, attrsOrFn: Record<string, unknown> | (() => T | Promise<T>), maybeFn?: () => T | Promise<T>): Promise<T> {
|
|
29
|
-
const fn = typeof attrsOrFn === 'function' ? attrsOrFn : maybeFn!
|
|
30
|
-
const attributes = typeof attrsOrFn === 'function' ? undefined : attrsOrFn
|
|
31
|
-
return startSpan({ name, attributes }, fn)
|
|
32
|
-
},
|
|
33
|
-
setAttribute: setSpanAttribute,
|
|
34
|
-
addEvent(name: string, message?: string, attrs?: Record<string, unknown>): void {
|
|
35
|
-
addSpanEvent(name, 'info', message ?? '', attrs)
|
|
36
|
-
},
|
|
37
|
-
}
|
|
26
|
+
initialized = true
|
|
38
27
|
|
|
39
28
|
// Register global `caches` object (CacheStorage) with tracing.
|
|
40
29
|
// Lazy: only creates the SqliteCacheStorage (and its getDatabase() call) on first access.
|
|
@@ -272,10 +272,12 @@ export class TestDurableObjectHandle {
|
|
|
272
272
|
throw new Error('DO fetch handler did not return a WebSocket upgrade response (no response.webSocket)')
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
//
|
|
275
|
+
// Build the TestWebSocket FIRST so its message/close listeners are
|
|
276
|
+
// registered before `accept()` flushes any events the DO already queued
|
|
277
|
+
// (e.g. `server.send(...)` issued before the response was returned).
|
|
278
|
+
const tw = new TestWebSocket(serverWs)
|
|
276
279
|
serverWs.accept()
|
|
277
|
-
|
|
278
|
-
return new TestWebSocket(serverWs)
|
|
280
|
+
return tw
|
|
279
281
|
}
|
|
280
282
|
|
|
281
283
|
/** Get all accepted WebSockets for this DO instance (via DurableObjectState). */
|
package/src/testing/index.ts
CHANGED
|
@@ -101,11 +101,13 @@ export async function createTestEnv<Env = Record<string, unknown>>(options: Test
|
|
|
101
101
|
entry.binding.resumeInterrupted()
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
// Wire service bindings — self-referencing
|
|
104
|
+
// Wire service bindings — self-referencing (always in-process for tests)
|
|
105
105
|
for (const entry of registry.serviceBindings) {
|
|
106
|
-
const wire = entry.proxy._wire as
|
|
106
|
+
const wire = entry.proxy._wire as
|
|
107
|
+
| ((resolver: () => { kind: 'in-process'; workerModule: Record<string, unknown>; env: Record<string, unknown> }) => void)
|
|
108
|
+
| undefined
|
|
107
109
|
if (wire) {
|
|
108
|
-
wire(() => ({ workerModule, env }))
|
|
110
|
+
wire(() => ({ kind: 'in-process', workerModule, env }))
|
|
109
111
|
}
|
|
110
112
|
}
|
|
111
113
|
|
|
@@ -254,7 +256,9 @@ export async function createTestEnv<Env = Record<string, unknown>>(options: Test
|
|
|
254
256
|
for (const tw of testWorkflows) tw.dispose()
|
|
255
257
|
for (const td of testDOs) td.dispose()
|
|
256
258
|
for (const entry of registry.durableObjects) {
|
|
257
|
-
|
|
259
|
+
// force: final teardown — dispose every executor and leave no eviction
|
|
260
|
+
// timer running past db.close() below.
|
|
261
|
+
entry.namespace.destroy({ force: true })
|
|
258
262
|
}
|
|
259
263
|
for (const entry of registry.workflows) {
|
|
260
264
|
entry.binding.abortRunning()
|
package/src/tracing/context.ts
CHANGED
|
@@ -38,6 +38,24 @@ export function runWithContext<T>(ctx: SpanContext, fn: () => T): T {
|
|
|
38
38
|
return storage.run(ctx, fn)
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Adopt a parent (traceId + spanId) sent across an isolate boundary — refs
|
|
43
|
+
* (`fetchStack`, `subrequests`) can't cross postMessage, so we re-seed empty
|
|
44
|
+
* ones and let sub-spans created on this side share them via `getActiveContext`.
|
|
45
|
+
* Note: the per-request subrequest budget resets on each side of the boundary;
|
|
46
|
+
* a nested service-binding hop into another thread effectively gets a fresh
|
|
47
|
+
* budget. Acceptable for dev; a real fix would round-trip the count.
|
|
48
|
+
*/
|
|
49
|
+
export function runWithParentContext<T>(parent: { traceId: string; spanId: string } | undefined, fn: () => T): T {
|
|
50
|
+
if (!parent) return fn()
|
|
51
|
+
return storage.run({
|
|
52
|
+
traceId: parent.traceId,
|
|
53
|
+
spanId: parent.spanId,
|
|
54
|
+
fetchStack: { current: null },
|
|
55
|
+
subrequests: { count: 0 },
|
|
56
|
+
}, fn)
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
export function generateId(byteCount = 8): string {
|
|
42
60
|
const bytes = new Uint8Array(byteCount)
|
|
43
61
|
crypto.getRandomValues(bytes)
|
package/src/tracing/span.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { generateId, generateTraceId, getActiveContext, runWithContext } from './context'
|
|
2
2
|
import { buildErrorFrames } from './frames'
|
|
3
|
-
import { getTraceStore } from './store'
|
|
3
|
+
import { getTraceStore, type TraceStore } from './store'
|
|
4
4
|
import type { SpanData } from './types'
|
|
5
5
|
|
|
6
6
|
export interface SpanOptions {
|
|
@@ -12,7 +12,28 @@ export interface SpanOptions {
|
|
|
12
12
|
newTrace?: boolean
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/** Handle passed to span callbacks, mirroring Cloudflare's custom-span Span object. */
|
|
16
|
+
export interface SpanHandle {
|
|
17
|
+
/** Set an attribute on the span. Passing `undefined` is a no-op, matching Cloudflare (allows optional chaining). */
|
|
18
|
+
setAttribute(key: string, value: string | number | boolean | undefined): void
|
|
19
|
+
/** Whether this invocation is being traced. Always true in lopata's dev runtime. */
|
|
20
|
+
readonly isTraced: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SpanRunOptions extends SpanOptions {
|
|
24
|
+
/** Internal hook: inspect a successful result before the span's final status is computed,
|
|
25
|
+
* so it can flag a failure (used by startSpan to mark an HTTP 5xx Response errored). */
|
|
26
|
+
onSuccess?: (result: unknown, spanId: string, store: TraceStore) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Core span runner shared by startSpan / startSyncSpan / enterSpan. Creates a child span of
|
|
31
|
+
* the active context (or a new root when `newTrace`), runs `fn` inside it, and ends the span
|
|
32
|
+
* when `fn` returns or — if it returned a thenable — when that promise settles. The result is
|
|
33
|
+
* returned as-is: synchronous callbacks stay synchronous, async ones return the promise. On a
|
|
34
|
+
* thrown/rejected error the span is marked errored and an `exception` event is recorded.
|
|
35
|
+
*/
|
|
36
|
+
function runInSpan<T>(opts: SpanRunOptions, fn: (span: SpanHandle) => T): T {
|
|
16
37
|
const store = getTraceStore()
|
|
17
38
|
const parent = opts.newTrace ? undefined : getActiveContext()
|
|
18
39
|
|
|
@@ -34,7 +55,6 @@ export async function startSpan<T>(opts: SpanOptions, fn: () => T | Promise<T>):
|
|
|
34
55
|
attributes: opts.attributes ?? {},
|
|
35
56
|
workerName: opts.workerName ?? null,
|
|
36
57
|
}
|
|
37
|
-
|
|
38
58
|
store.insertSpan(span)
|
|
39
59
|
|
|
40
60
|
// Share fetchStack ref across all spans in the same trace so that
|
|
@@ -47,15 +67,21 @@ export async function startSpan<T>(opts: SpanOptions, fn: () => T | Promise<T>):
|
|
|
47
67
|
// whole dev-server lifetime.
|
|
48
68
|
const subrequests = parent?.subrequests ?? { count: 0 }
|
|
49
69
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
store.
|
|
54
|
-
}
|
|
70
|
+
const handle: SpanHandle = {
|
|
71
|
+
setAttribute(key, value) {
|
|
72
|
+
if (value === undefined) return
|
|
73
|
+
store.updateAttributes(spanId, { [key]: value })
|
|
74
|
+
},
|
|
75
|
+
isTraced: true,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const succeed = (result: unknown): void => {
|
|
79
|
+
opts.onSuccess?.(result, spanId, store)
|
|
55
80
|
const currentStatus = store.getSpanStatus(spanId)
|
|
56
81
|
store.endSpan(spanId, Date.now(), currentStatus === 'error' ? 'error' : 'ok')
|
|
57
|
-
|
|
58
|
-
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const fail = (err: unknown): void => {
|
|
59
85
|
const message = err instanceof Error ? err.message : String(err)
|
|
60
86
|
store.endSpan(spanId, Date.now(), 'error', message)
|
|
61
87
|
store.addEvent({
|
|
@@ -67,62 +93,62 @@ export async function startSpan<T>(opts: SpanOptions, fn: () => T | Promise<T>):
|
|
|
67
93
|
message,
|
|
68
94
|
attributes: err instanceof Error ? { stack: err.stack } : {},
|
|
69
95
|
})
|
|
70
|
-
throw err
|
|
71
96
|
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Synchronous variant of startSpan for instrumenting non-async APIs (e.g. DO
|
|
75
|
-
* state.storage.sql.exec is sync). Inserts the span before fn() runs and ends
|
|
76
|
-
* it after, mirroring the async version's status/exception handling. fn is
|
|
77
|
-
* invoked inside runWithContext so any spans it creates nest correctly. */
|
|
78
|
-
export function startSyncSpan<T>(opts: SpanOptions, fn: () => T): T {
|
|
79
|
-
const store = getTraceStore()
|
|
80
|
-
const parent = opts.newTrace ? undefined : getActiveContext()
|
|
81
|
-
|
|
82
|
-
const spanId = generateId()
|
|
83
|
-
const traceId = parent?.traceId ?? generateTraceId()
|
|
84
|
-
const parentSpanId = parent?.spanId ?? null
|
|
85
|
-
|
|
86
|
-
const span: SpanData = {
|
|
87
|
-
spanId,
|
|
88
|
-
traceId,
|
|
89
|
-
parentSpanId,
|
|
90
|
-
name: opts.name,
|
|
91
|
-
kind: opts.kind ?? 'internal',
|
|
92
|
-
status: 'unset',
|
|
93
|
-
statusMessage: null,
|
|
94
|
-
startTime: Date.now(),
|
|
95
|
-
endTime: null,
|
|
96
|
-
durationMs: null,
|
|
97
|
-
attributes: opts.attributes ?? {},
|
|
98
|
-
workerName: opts.workerName ?? null,
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
store.insertSpan(span)
|
|
102
|
-
const fetchStack = parent?.fetchStack ?? { current: null }
|
|
103
|
-
const subrequests = parent?.subrequests ?? { count: 0 }
|
|
104
97
|
|
|
105
98
|
try {
|
|
106
|
-
const result = runWithContext({ traceId, spanId, fetchStack, subrequests }, fn)
|
|
107
|
-
|
|
108
|
-
|
|
99
|
+
const result = runWithContext({ traceId, spanId, fetchStack, subrequests }, () => fn(handle))
|
|
100
|
+
if (result != null && typeof (result as { then?: unknown }).then === 'function') {
|
|
101
|
+
return (result as unknown as Promise<unknown>).then(
|
|
102
|
+
value => {
|
|
103
|
+
succeed(value)
|
|
104
|
+
return value
|
|
105
|
+
},
|
|
106
|
+
err => {
|
|
107
|
+
fail(err)
|
|
108
|
+
throw err
|
|
109
|
+
},
|
|
110
|
+
) as T
|
|
111
|
+
}
|
|
112
|
+
succeed(result)
|
|
109
113
|
return result
|
|
110
114
|
} catch (err) {
|
|
111
|
-
|
|
112
|
-
store.endSpan(spanId, Date.now(), 'error', message)
|
|
113
|
-
store.addEvent({
|
|
114
|
-
spanId,
|
|
115
|
-
traceId,
|
|
116
|
-
timestamp: Date.now(),
|
|
117
|
-
name: 'exception',
|
|
118
|
-
level: 'error',
|
|
119
|
-
message,
|
|
120
|
-
attributes: err instanceof Error ? { stack: err.stack } : {},
|
|
121
|
-
})
|
|
115
|
+
fail(err)
|
|
122
116
|
throw err
|
|
123
117
|
}
|
|
124
118
|
}
|
|
125
119
|
|
|
120
|
+
/** Flags a span errored when the handler returned an HTTP 5xx Response. */
|
|
121
|
+
function flagServerErrorStatus(result: unknown, spanId: string, store: TraceStore): void {
|
|
122
|
+
if (result instanceof Response && result.status >= 500) {
|
|
123
|
+
store.setSpanStatus(spanId, 'error', `HTTP ${result.status}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function startSpan<T>(opts: SpanOptions, fn: () => T | Promise<T>): Promise<T> {
|
|
128
|
+
return runInSpan<T | Promise<T>>({ ...opts, onSuccess: flagServerErrorStatus }, () => fn())
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Synchronous variant of startSpan for instrumenting non-async APIs (e.g. DO
|
|
132
|
+
* state.storage.sql.exec is sync). The span ends as soon as fn returns; fn runs
|
|
133
|
+
* inside the span context so any spans it creates nest correctly. */
|
|
134
|
+
export function startSyncSpan<T>(opts: SpanOptions, fn: () => T): T {
|
|
135
|
+
return runInSpan<T>(opts, () => fn())
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Cloudflare-compatible custom span API (`tracing.enterSpan` from `cloudflare:workers`, also
|
|
140
|
+
* `ctx.tracing`). Runs `fn` inside a child span of the active trace context, passing a span
|
|
141
|
+
* handle for `setAttribute` / `isTraced`. The callback's value is returned as-is (sync stays
|
|
142
|
+
* sync, async returns the promise) and the span auto-ends when the callback returns or its
|
|
143
|
+
* returned promise settles — matching Cloudflare's semantics.
|
|
144
|
+
*/
|
|
145
|
+
export function enterSpan<T>(name: string, fn: (span: SpanHandle) => T): T {
|
|
146
|
+
return runInSpan<T>({ name }, fn)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Cloudflare-compatible `tracing` namespace exported from `cloudflare:workers` and exposed as `ctx.tracing`. */
|
|
150
|
+
export const tracing = { enterSpan }
|
|
151
|
+
|
|
126
152
|
export function setSpanStatus(status: 'ok' | 'error', message?: string): void {
|
|
127
153
|
const ctx = getActiveContext()
|
|
128
154
|
if (!ctx) return
|
package/src/tracing/store.ts
CHANGED
|
@@ -4,6 +4,26 @@ import type { SpanData, SpanEventData, TraceDetail, TraceEvent, TraceSummary } f
|
|
|
4
4
|
|
|
5
5
|
type Listener = (event: TraceEvent) => void
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* `JSON.stringify` for user-controlled span attributes / event payloads.
|
|
9
|
+
* Plain `JSON.stringify` THROWS on a `BigInt` and on a circular reference —
|
|
10
|
+
* both trivially reachable from user code (`setAttribute('x', 1n)`, logging a
|
|
11
|
+
* request/response object). In thread mode these writes run on the main thread
|
|
12
|
+
* (inside `worker.onmessage`), so an unguarded throw would take down the whole
|
|
13
|
+
* dev server. A dropped/coerced value is always preferable to a crash here.
|
|
14
|
+
*/
|
|
15
|
+
export function safeStringify(value: unknown): string {
|
|
16
|
+
const seen = new WeakSet<object>()
|
|
17
|
+
return JSON.stringify(value, (_key, val) => {
|
|
18
|
+
if (typeof val === 'bigint') return val.toString()
|
|
19
|
+
if (typeof val === 'object' && val !== null) {
|
|
20
|
+
if (seen.has(val)) return '[Circular]'
|
|
21
|
+
seen.add(val)
|
|
22
|
+
}
|
|
23
|
+
return val
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
const TRACE_CAP = 10_000
|
|
8
28
|
const PRUNE_BATCH = 100
|
|
9
29
|
const STALE_SPAN_TTL_MS = 10 * 60 * 1000 // 10 minutes
|
|
@@ -80,7 +100,7 @@ export class TraceStore {
|
|
|
80
100
|
span.startTime,
|
|
81
101
|
span.endTime,
|
|
82
102
|
span.durationMs,
|
|
83
|
-
|
|
103
|
+
safeStringify(span.attributes),
|
|
84
104
|
span.workerName,
|
|
85
105
|
)
|
|
86
106
|
this.broadcast({ type: 'span.start', span })
|
|
@@ -113,7 +133,7 @@ export class TraceStore {
|
|
|
113
133
|
}
|
|
114
134
|
|
|
115
135
|
updateAttributes(spanId: string, attrs: Record<string, unknown>): void {
|
|
116
|
-
this.updateAttributesStmt.run(
|
|
136
|
+
this.updateAttributesStmt.run(safeStringify(attrs), spanId)
|
|
117
137
|
}
|
|
118
138
|
|
|
119
139
|
addEvent(event: Omit<SpanEventData, 'id'>): void {
|
|
@@ -124,7 +144,7 @@ export class TraceStore {
|
|
|
124
144
|
event.name,
|
|
125
145
|
event.level,
|
|
126
146
|
event.message,
|
|
127
|
-
|
|
147
|
+
safeStringify(event.attributes),
|
|
128
148
|
)
|
|
129
149
|
const id = this.db.prepare<{ id: number }, []>('SELECT last_insert_rowid() as id').get()?.id
|
|
130
150
|
this.broadcast({ type: 'span.event', event: { ...event, id } })
|
|
@@ -589,10 +609,28 @@ function parseCursor(cursor?: string): { time: number; id: string } {
|
|
|
589
609
|
}
|
|
590
610
|
|
|
591
611
|
let defaultStore: TraceStore | null = null
|
|
612
|
+
let overrideStore: TraceStore | null = null
|
|
592
613
|
|
|
593
614
|
export function getTraceStore(): TraceStore {
|
|
615
|
+
if (overrideStore) return overrideStore
|
|
594
616
|
if (!defaultStore) {
|
|
595
617
|
defaultStore = new TraceStore()
|
|
596
618
|
}
|
|
597
619
|
return defaultStore
|
|
598
620
|
}
|
|
621
|
+
|
|
622
|
+
/** Override the process-wide default store. Used by tests to inject an in-memory DB. */
|
|
623
|
+
export function setTraceStore(store: TraceStore | null): void {
|
|
624
|
+
defaultStore = store
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Swap the process-local trace store. Used by the worker-thread runtime to
|
|
629
|
+
* install a forwarding store. **Do not call from main** — that mutes the
|
|
630
|
+
* dashboard subscribers attached to the real store.
|
|
631
|
+
*/
|
|
632
|
+
export function setTraceStoreOverride(
|
|
633
|
+
store: Pick<TraceStore, 'insertSpan' | 'endSpan' | 'setSpanStatus' | 'getSpanStatus' | 'updateAttributes' | 'addEvent' | 'insertError'> | null,
|
|
634
|
+
): void {
|
|
635
|
+
overrideStore = store as TraceStore | null
|
|
636
|
+
}
|
package/src/virtual-modules.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { WebSocketPair } from './bindings/websocket-pair'
|
|
|
7
7
|
import { NonRetryableError, WorkflowEntrypointBase } from './bindings/workflow'
|
|
8
8
|
import { globalEnv } from './env'
|
|
9
9
|
import { getActiveExecutionContext } from './execution-context'
|
|
10
|
+
import { tracing } from './tracing/span'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Registers virtual modules for `cloudflare:workers`, `cloudflare:workflows`,
|
|
@@ -43,6 +44,7 @@ export function registerVirtualModules(build: { module: (name: string, fn: () =>
|
|
|
43
44
|
ctx.waitUntil(promise)
|
|
44
45
|
}
|
|
45
46
|
},
|
|
47
|
+
tracing,
|
|
46
48
|
},
|
|
47
49
|
loader: 'object',
|
|
48
50
|
}
|
|
@@ -389,6 +389,10 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
389
389
|
auxConfigs.set(workerDef.configPath, { config: auxConfig, name: workerName })
|
|
390
390
|
console.log(`[lopata:vite] Auxiliary worker: ${workerName}`)
|
|
391
391
|
|
|
392
|
+
// Aux workers run in their own Bun Worker thread (GenerationManager's
|
|
393
|
+
// universal model) loaded via native Bun import — NOT through Vite's
|
|
394
|
+
// SSR/transform pipeline like the main worker. See `auxiliaryWorkers`
|
|
395
|
+
// in index.ts: author them as plain Bun-resolvable modules.
|
|
392
396
|
const auxManager = new GenerationManager(auxConfig, auxBaseDir, {
|
|
393
397
|
workerName,
|
|
394
398
|
workerRegistry,
|
package/src/vite-plugin/index.ts
CHANGED
|
@@ -12,7 +12,14 @@ export interface LopataPluginConfig {
|
|
|
12
12
|
viteEnvironment?: { name?: string }
|
|
13
13
|
/** Host patterns that route to the main worker (takes priority over wildcard auxiliary hosts). */
|
|
14
14
|
hosts?: string[]
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Auxiliary workers. Unlike the main worker (which runs in-process via Vite
|
|
17
|
+
* SSR), each aux worker runs in its own Bun Worker thread and is loaded via
|
|
18
|
+
* native Bun import — it does NOT go through Vite's transform pipeline
|
|
19
|
+
* (aliases, `import.meta.glob`, env replacement, plugins). Intended for
|
|
20
|
+
* workers reached over a service binding (APIs, queue/cron consumers) that
|
|
21
|
+
* don't rely on Vite transforms; author them as plain Bun-resolvable modules.
|
|
22
|
+
*/
|
|
16
23
|
auxiliaryWorkers?: { configPath: string; name?: string; hosts?: string[] }[]
|
|
17
24
|
}
|
|
18
25
|
|
package/src/worker-registry.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { GenerationManager } from './generation-manager'
|
|
2
|
+
import type { WorkerThreadExecutor } from './worker-thread/executor'
|
|
3
|
+
|
|
4
|
+
export type ResolvedTarget =
|
|
5
|
+
| { kind: 'thread'; env: Record<string, unknown>; executor: WorkerThreadExecutor }
|
|
6
|
+
| { kind: 'in-process'; env: Record<string, unknown>; workerModule: Record<string, unknown> }
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* Central registry holding all worker GenerationManagers, keyed by worker name.
|
|
@@ -33,7 +38,7 @@ export class WorkerRegistry {
|
|
|
33
38
|
* Lazily resolve a target worker's module and env from its active generation.
|
|
34
39
|
* Called on each service binding invocation so hot-reloaded workers are picked up.
|
|
35
40
|
*/
|
|
36
|
-
resolveTarget(workerName: string):
|
|
41
|
+
resolveTarget(workerName: string): ResolvedTarget {
|
|
37
42
|
const manager = this.managers.get(workerName)
|
|
38
43
|
if (!manager) {
|
|
39
44
|
throw new Error(`Worker "${workerName}" is not registered in the worker registry`)
|
|
@@ -42,7 +47,15 @@ export class WorkerRegistry {
|
|
|
42
47
|
if (!gen) {
|
|
43
48
|
throw new Error(`Worker "${workerName}" has no active generation (failed to load?)`)
|
|
44
49
|
}
|
|
45
|
-
|
|
50
|
+
const threadExecutor = (gen as unknown as { threadExecutor?: WorkerThreadExecutor }).threadExecutor
|
|
51
|
+
if (threadExecutor) {
|
|
52
|
+
return { kind: 'thread', env: gen.env, executor: threadExecutor }
|
|
53
|
+
}
|
|
54
|
+
const workerModule = (gen as unknown as { workerModule?: Record<string, unknown> }).workerModule
|
|
55
|
+
if (!workerModule) {
|
|
56
|
+
throw new Error(`Worker "${workerName}" generation has neither a thread executor nor a workerModule`)
|
|
57
|
+
}
|
|
58
|
+
return { kind: 'in-process', env: gen.env, workerModule }
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
/** List all registered managers (for dashboard) */
|