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
@@ -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
+ }
@@ -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 isolation mode:
19
- * - "dev" (default) all DO instances run in-process (fast, shared memory, hot reload)
20
- * - "isolated" each DO instance runs in a separate Bun Worker thread (faithful to CF production)
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
 
@@ -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
+ }
@@ -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, and __lopata userland tracing API.
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 // ─── Userland tracing API ────────────────────────────────────────────
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
- // Accept the client side (simulates what Bun.serve does)
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). */
@@ -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 ((resolver: () => { workerModule: Record<string, unknown>; env: Record<string, unknown> }) => void) | undefined
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
- entry.namespace.destroy()
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()
@@ -7,6 +7,14 @@ export interface FetchStackRef {
7
7
  current: Error | null
8
8
  }
9
9
 
10
+ /** Mutable subrequest counter shared across all spans in the same trace.
11
+ * Scopes the binding subrequest budget to a single top-level request, the
12
+ * way Cloudflare resets the budget per incoming request: a fresh ref is
13
+ * minted at the root span and inherited by every child span. */
14
+ export interface SubrequestCounterRef {
15
+ count: number
16
+ }
17
+
10
18
  export interface SpanContext {
11
19
  traceId: string
12
20
  spanId: string
@@ -16,6 +24,8 @@ export interface SpanContext {
16
24
  * call still contains the user's code frames — we stitch it onto caught
17
25
  * errors. */
18
26
  fetchStack: FetchStackRef
27
+ /** Per-top-level-request subrequest counter, shared across the trace. */
28
+ subrequests: SubrequestCounterRef
19
29
  }
20
30
 
21
31
  const storage = new AsyncLocalStorage<SpanContext>()
@@ -28,6 +38,24 @@ export function runWithContext<T>(ctx: SpanContext, fn: () => T): T {
28
38
  return storage.run(ctx, fn)
29
39
  }
30
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
+
31
59
  export function generateId(byteCount = 8): string {
32
60
  const bytes = new Uint8Array(byteCount)
33
61
  crypto.getRandomValues(bytes)
@@ -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
- export async function startSpan<T>(opts: SpanOptions, fn: () => T | Promise<T>): Promise<T> {
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,23 +55,33 @@ 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
41
61
  // fetch call-site stacks captured in sub-spans are visible in the root
42
62
  // span's error handler.
43
63
  const fetchStack = parent?.fetchStack ?? { current: null }
64
+ // Subrequest budget is per top-level request: a root span (no parent) mints
65
+ // a fresh counter; child spans inherit it. This resets the budget on each
66
+ // incoming request, matching Cloudflare — instead of leaking across the
67
+ // whole dev-server lifetime.
68
+ const subrequests = parent?.subrequests ?? { count: 0 }
69
+
70
+ const handle: SpanHandle = {
71
+ setAttribute(key, value) {
72
+ if (value === undefined) return
73
+ store.updateAttributes(spanId, { [key]: value })
74
+ },
75
+ isTraced: true,
76
+ }
44
77
 
45
- try {
46
- const result = await runWithContext({ traceId, spanId, fetchStack }, () => fn())
47
- if (result instanceof Response && result.status >= 500) {
48
- store.setSpanStatus(spanId, 'error', `HTTP ${result.status}`)
49
- }
78
+ const succeed = (result: unknown): void => {
79
+ opts.onSuccess?.(result, spanId, store)
50
80
  const currentStatus = store.getSpanStatus(spanId)
51
81
  store.endSpan(spanId, Date.now(), currentStatus === 'error' ? 'error' : 'ok')
52
- return result
53
- } catch (err) {
82
+ }
83
+
84
+ const fail = (err: unknown): void => {
54
85
  const message = err instanceof Error ? err.message : String(err)
55
86
  store.endSpan(spanId, Date.now(), 'error', message)
56
87
  store.addEvent({
@@ -62,61 +93,62 @@ export async function startSpan<T>(opts: SpanOptions, fn: () => T | Promise<T>):
62
93
  message,
63
94
  attributes: err instanceof Error ? { stack: err.stack } : {},
64
95
  })
65
- throw err
66
96
  }
67
- }
68
-
69
- /** Synchronous variant of startSpan for instrumenting non-async APIs (e.g. DO
70
- * state.storage.sql.exec is sync). Inserts the span before fn() runs and ends
71
- * it after, mirroring the async version's status/exception handling. fn is
72
- * invoked inside runWithContext so any spans it creates nest correctly. */
73
- export function startSyncSpan<T>(opts: SpanOptions, fn: () => T): T {
74
- const store = getTraceStore()
75
- const parent = opts.newTrace ? undefined : getActiveContext()
76
-
77
- const spanId = generateId()
78
- const traceId = parent?.traceId ?? generateTraceId()
79
- const parentSpanId = parent?.spanId ?? null
80
-
81
- const span: SpanData = {
82
- spanId,
83
- traceId,
84
- parentSpanId,
85
- name: opts.name,
86
- kind: opts.kind ?? 'internal',
87
- status: 'unset',
88
- statusMessage: null,
89
- startTime: Date.now(),
90
- endTime: null,
91
- durationMs: null,
92
- attributes: opts.attributes ?? {},
93
- workerName: opts.workerName ?? null,
94
- }
95
-
96
- store.insertSpan(span)
97
- const fetchStack = parent?.fetchStack ?? { current: null }
98
97
 
99
98
  try {
100
- const result = runWithContext({ traceId, spanId, fetchStack }, fn)
101
- const currentStatus = store.getSpanStatus(spanId)
102
- store.endSpan(spanId, Date.now(), currentStatus === 'error' ? 'error' : 'ok')
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)
103
113
  return result
104
114
  } catch (err) {
105
- const message = err instanceof Error ? err.message : String(err)
106
- store.endSpan(spanId, Date.now(), 'error', message)
107
- store.addEvent({
108
- spanId,
109
- traceId,
110
- timestamp: Date.now(),
111
- name: 'exception',
112
- level: 'error',
113
- message,
114
- attributes: err instanceof Error ? { stack: err.stack } : {},
115
- })
115
+ fail(err)
116
116
  throw err
117
117
  }
118
118
  }
119
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
+
120
152
  export function setSpanStatus(status: 'ok' | 'error', message?: string): void {
121
153
  const ctx = getActiveContext()
122
154
  if (!ctx) return
@@ -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
- JSON.stringify(span.attributes),
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(JSON.stringify(attrs), spanId)
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
- JSON.stringify(event.attributes),
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
+ }
@@ -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,
@@ -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
- /** Auxiliary workers loaded via native Bun import (not through Vite). */
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