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
package/src/cli/dev.ts CHANGED
@@ -6,6 +6,13 @@ process.on('unhandledRejection', (reason) => {
6
6
  console.error('[lopata] Unhandled promise rejection:', reason)
7
7
  })
8
8
 
9
+ // Last-resort guard so a synchronous throw on main (e.g. a worker message
10
+ // handler choking on a non-serializable user value) logs instead of killing the
11
+ // whole dev server — every worker, the dashboard and the file watcher with it.
12
+ process.on('uncaughtException', (err) => {
13
+ console.error('[lopata] Uncaught exception (dev server kept alive):', err)
14
+ })
15
+
9
16
  import '../plugin'
10
17
  import path from 'node:path'
11
18
  import {
@@ -17,14 +24,16 @@ import {
17
24
  setRouteDispatcher,
18
25
  setWorkerRegistry,
19
26
  } from '../api'
27
+ import { reapOrphanContainers } from '../bindings/container-cleanup'
20
28
  import { QueuePullConsumer } from '../bindings/queue'
21
29
  import type { AckRequest, PullRequest } from '../bindings/queue'
22
30
  import { CFWebSocket } from '../bindings/websocket-pair'
23
- import { autoLoadConfig, loadConfig } from '../config'
31
+ import { autoLoadConfig, findConfigPath, loadConfig } from '../config'
24
32
  import { handleDashboardRequest } from '../dashboard-serve'
25
33
  import { getDatabase } from '../db'
26
34
  import { FileWatcher } from '../file-watcher'
27
35
  import { GenerationManager } from '../generation-manager'
36
+ import { ImportGraphWatcher } from '../import-graph'
28
37
  import { loadLopataConfig } from '../lopata-config'
29
38
  import { addCfProperty } from '../request-cf'
30
39
  import { extractHostname, RouteDispatcher } from '../route-matcher'
@@ -55,7 +64,14 @@ export async function run(ctx: CliContext, args: string[]) {
55
64
  const portFlag = values.port
56
65
 
57
66
  const baseDir = process.cwd()
58
- const watchers: FileWatcher[] = []
67
+ const watchers: { stop(): void }[] = []
68
+
69
+ // Reap orphan containers left behind by previous lopata runs that crashed
70
+ // or were `kill -9`'d before the exit handler fired. Silently does nothing
71
+ // if docker isn't installed or nothing matches the label filter.
72
+ reapOrphanContainers().then(count => {
73
+ if (count > 0) console.log(`[lopata] Reaped ${count} orphan container(s) from previous run(s)`)
74
+ }).catch(() => {})
59
75
 
60
76
  // Try to load lopata.config.ts for multi-worker mode
61
77
  const lopataConfig = await loadLopataConfig(baseDir)
@@ -69,16 +85,22 @@ export async function run(ctx: CliContext, args: string[]) {
69
85
  console.log('[lopata] Multi-worker mode (lopata.config.ts found)')
70
86
  setLopataConfig(lopataConfig)
71
87
 
72
- // Create executor factory based on isolation mode
73
- let executorFactory: import('../bindings/do-executor').DOExecutorFactory | undefined
74
- if (lopataConfig.isolation === 'isolated') {
75
- const { WorkerExecutorFactory } = await import('../bindings/do-executor-worker')
76
- executorFactory = new WorkerExecutorFactory()
77
- console.log('[lopata] DO isolation: isolated (Worker threads)')
78
- } else if (lopataConfig.isolation && lopataConfig.isolation !== 'dev') {
79
- console.warn(`[lopata] Unknown isolation mode "${lopataConfig.isolation}", using "dev"`)
88
+ if (lopataConfig.isolation) {
89
+ if (lopataConfig.isolation === 'dev' || lopataConfig.isolation === 'isolated') {
90
+ console.warn(
91
+ `[lopata] "isolation" is deprecated and has no effect — workers always run in their own Bun Worker thread. Remove it from lopata.config.ts.`,
92
+ )
93
+ } else {
94
+ console.warn(`[lopata] Unknown isolation mode "${lopataConfig.isolation}" has no effect`)
95
+ }
80
96
  }
81
-
97
+ // DOs always run in worker threads now — main thread can't host the user
98
+ // classes since the worker entry imports user code in its own thread.
99
+ // Each manager gets its OWN factory instance: the factory carries the
100
+ // owning worker's module/config path (set via configure() on every
101
+ // reload). A single shared instance would be clobbered by the
102
+ // last-reloaded worker, so aux-worker DOs would spawn with the wrong
103
+ // worker's module — see generation-manager._doReload().
82
104
  registry = new WorkerRegistry()
83
105
 
84
106
  // Load main worker config
@@ -92,7 +114,7 @@ export async function run(ctx: CliContext, args: string[]) {
92
114
  workerRegistry: registry,
93
115
  isMain: true,
94
116
  cron: lopataConfig.cron,
95
- executorFactory,
117
+ executorFactory: await makeExecutorFactory(),
96
118
  configPath: lopataConfig.main,
97
119
  browserConfig: lopataConfig.browser,
98
120
  })
@@ -111,7 +133,7 @@ export async function run(ctx: CliContext, args: string[]) {
111
133
  workerRegistry: registry,
112
134
  isMain: false,
113
135
  cron: lopataConfig.cron,
114
- executorFactory,
136
+ executorFactory: await makeExecutorFactory(),
115
137
  configPath: workerDef.config,
116
138
  })
117
139
  registry.register(workerDef.name, auxManager)
@@ -124,11 +146,15 @@ export async function run(ctx: CliContext, args: string[]) {
124
146
  console.error(`[lopata] Failed to load auxiliary worker "${workerDef.name}":`, err)
125
147
  }
126
148
 
127
- // File watcher for aux worker
128
- const auxSrcDir = path.dirname(path.resolve(auxBaseDir, auxConfig.main))
129
- const auxWatcher = new FileWatcher(auxSrcDir, () => {
149
+ // Import-graph watcher for the aux worker — follows its transitive
150
+ // imports (matching single-worker mode), not just the entry's directory.
151
+ // `watchExtra` remains the answer for non-import assets / shared dirs.
152
+ const auxEntry = path.resolve(auxBaseDir, auxConfig.main)
153
+ const auxWatcher: ImportGraphWatcher = new ImportGraphWatcher(auxEntry, auxBaseDir, () => {
154
+ const onSettled = () => auxWatcher.rescan()
130
155
  auxManager.reload().then(async gen => {
131
- console.log(`[lopata] Auxiliary worker "${workerDef.name}" reloaded → generation ${gen.id}`)
156
+ onSettled()
157
+ console.log(`[lopata] Auxiliary worker "${workerDef.name}" reloaded → generation ${gen.id} (watching ${auxWatcher.size} files)`)
132
158
  // Re-read config and update routes in case routes changed
133
159
  if (routeDispatcher) {
134
160
  try {
@@ -138,13 +164,14 @@ export async function run(ctx: CliContext, args: string[]) {
138
164
  console.warn(`[lopata] Failed to re-read config for "${workerDef.name}" routes:`, err)
139
165
  }
140
166
  }
141
- }).catch(err => {
167
+ }, err => {
168
+ onSettled()
142
169
  console.error(`[lopata] Reload failed for "${workerDef.name}":`, err)
143
170
  })
144
171
  })
145
172
  auxWatcher.start()
146
173
  watchers.push(auxWatcher)
147
- console.log(`[lopata] Watching ${auxSrcDir} for changes (${workerDef.name})`)
174
+ console.log(`[lopata] Watching ${auxWatcher.size} imported files for changes (${workerDef.name})`)
148
175
  }
149
176
 
150
177
  // Load main worker after aux workers
@@ -205,15 +232,24 @@ export async function run(ctx: CliContext, args: string[]) {
205
232
  setWorkerRegistry(registry)
206
233
  setRouteDispatcher(routeDispatcher)
207
234
 
208
- // File watcher for main worker
209
- const mainSrcDir = path.dirname(path.resolve(mainBaseDir, mainConfig.main))
235
+ // Import-graph watcher for the main worker (follows its transitive imports).
236
+ const mainEntry = path.resolve(mainBaseDir, mainConfig.main)
210
237
  const reloadMain = makeReloadCallback(mainManager, 'Main worker reloaded')
211
- const mainWatcher = new FileWatcher(mainSrcDir, reloadMain)
238
+ const mainWatcher: ImportGraphWatcher = new ImportGraphWatcher(mainEntry, mainBaseDir, () => {
239
+ mainManager.reload().then(gen => {
240
+ mainWatcher.rescan()
241
+ console.log(`[lopata] Main worker reloaded → generation ${gen.id} (watching ${mainWatcher.size} files)`)
242
+ }, err => {
243
+ mainWatcher.rescan()
244
+ console.error('[lopata] Reload failed:', err)
245
+ })
246
+ })
212
247
  mainWatcher.start()
213
248
  watchers.push(mainWatcher)
214
- console.log(`[lopata] Watching ${mainSrcDir} for changes (main)`)
249
+ console.log(`[lopata] Watching ${mainWatcher.size} imported files for changes (main)`)
215
250
 
216
- // Extra directories that should also trigger a main reload (e.g. shared monorepo packages)
251
+ // Extra directories that should also trigger a main reload (e.g. shared
252
+ // monorepo packages / non-import assets the static scan won't follow).
217
253
  for (const extraDir of lopataConfig.watchExtra ?? []) {
218
254
  const extraWatcher = new FileWatcher(extraDir, reloadMain)
219
255
  extraWatcher.start()
@@ -221,22 +257,52 @@ export async function run(ctx: CliContext, args: string[]) {
221
257
  console.log(`[lopata] Watching ${extraDir} for changes (extra)`)
222
258
  }
223
259
  } else {
224
- // ─── Single-worker mode (current behavior) ────────────────────
260
+ // ─── Single-worker mode ────────────────────────────────────────
225
261
  const config = await autoLoadConfig(baseDir, envFlag)
226
262
  console.log(`[lopata] Loaded config: ${config.name}${envFlag ? ` (env: ${envFlag})` : ''}`)
227
263
  setDashboardConfig(config)
228
264
 
229
- manager = new GenerationManager(config, baseDir)
265
+ const executorFactory = await makeExecutorFactory()
266
+ // Even in single-worker mode we need a registry so service bindings that
267
+ // reference the worker by name (self-bindings) resolve to its own
268
+ // thread executor instead of an empty `workerModule`.
269
+ registry = new WorkerRegistry()
270
+ manager = new GenerationManager(config, baseDir, {
271
+ workerName: config.name,
272
+ workerRegistry: registry,
273
+ isMain: true,
274
+ executorFactory,
275
+ configPath: findConfigPath(baseDir),
276
+ })
277
+ registry.register(config.name, manager, true)
230
278
  const firstGen = await manager.reload()
231
279
  console.log(`[lopata] Generation ${firstGen.id} loaded`)
232
280
  setGenerationManager(manager)
281
+ setWorkerRegistry(registry)
233
282
 
234
- // File watcher — watch the source directory
235
- const srcDir = path.dirname(path.resolve(baseDir, config.main))
236
- const watcher = new FileWatcher(srcDir, makeReloadCallback(manager, 'Reloaded'))
283
+ // File watcher — follow the worker's actual import graph, not just the
284
+ // entry's directory. This catches edits to code imported from elsewhere in
285
+ // the project (e.g. an entry at `workers/app.ts` importing `../app/**`),
286
+ // which a dir-only watcher would miss. A reload re-imports the whole graph,
287
+ // so after each one we rescan to pick up newly-added imports.
288
+ const entry = path.resolve(baseDir, config.main)
289
+ const watcher = new ImportGraphWatcher(entry, baseDir, () => {
290
+ manager.reload().then(gen => {
291
+ watcher.rescan()
292
+ console.log(`[lopata] Reloaded → generation ${gen.id} (watching ${watcher.size} files)`)
293
+ }, err => {
294
+ // Rescan even on a FAILED reload: a newly-added import whose first
295
+ // build failed (it referenced a file with a syntax error), or a
296
+ // delete-then-recreate, must still enter the watch set — otherwise
297
+ // fixing that file produces no event and the dev server stays broken
298
+ // until an already-watched file is re-touched.
299
+ watcher.rescan()
300
+ console.error('[lopata] Reload failed:', err)
301
+ })
302
+ })
237
303
  watcher.start()
238
304
  watchers.push(watcher)
239
- console.log(`[lopata] Watching ${srcDir} for changes`)
305
+ console.log(`[lopata] Watching ${watcher.size} imported files for changes`)
240
306
  }
241
307
 
242
308
  // Start server — one Bun.serve(), delegates to active generation
@@ -451,11 +517,10 @@ export async function run(ctx: CliContext, args: string[]) {
451
517
  }
452
518
 
453
519
  const cfSocket = (data as { cfSocket: CFWebSocket }).cfSocket
454
- if (cfSocket._peer?._accepted) {
455
- cfSocket._peer._dispatchWSEvent({ type: 'message', data: typeof message === 'string' ? message : message.buffer as ArrayBuffer })
456
- } else if (cfSocket._peer) {
457
- cfSocket._peer._eventQueue.push({ type: 'message', data: typeof message === 'string' ? message : message.buffer as ArrayBuffer })
458
- }
520
+ cfSocket._peer?.dispatchOrQueue({
521
+ type: 'message',
522
+ data: typeof message === 'string' ? message : message.buffer as ArrayBuffer,
523
+ })
459
524
  },
460
525
  close(ws, code, reason) {
461
526
  const data = ws.data as unknown as Record<string, unknown>
@@ -470,12 +535,7 @@ export async function run(ctx: CliContext, args: string[]) {
470
535
 
471
536
  const cfSocket = (data as { cfSocket: CFWebSocket }).cfSocket
472
537
  if (cfSocket._peer && cfSocket._peer.readyState !== 3 /* CLOSED */) {
473
- const evt = { type: 'close' as const, code: code ?? 1000, reason: reason ?? '', wasClean: true }
474
- if (cfSocket._peer._accepted) {
475
- cfSocket._peer._dispatchWSEvent(evt)
476
- } else {
477
- cfSocket._peer._eventQueue.push(evt)
478
- }
538
+ cfSocket._peer.dispatchOrQueue({ type: 'close', code: code ?? 1000, reason: reason ?? '', wasClean: true })
479
539
  cfSocket._peer.readyState = 3
480
540
  }
481
541
  cfSocket.readyState = 3
@@ -521,6 +581,11 @@ function resolveWorkerParam(url: URL, registry: WorkerRegistry | undefined, fall
521
581
  return target
522
582
  }
523
583
 
584
+ async function makeExecutorFactory(): Promise<import('../bindings/do-executor').DOExecutorFactory> {
585
+ const { WorkerExecutorFactory } = await import('../bindings/do-executor-worker')
586
+ return new WorkerExecutorFactory()
587
+ }
588
+
524
589
  function matchGlob(text: string, pattern: string): boolean {
525
590
  // Placeholder approach: protect ** before escaping special chars
526
591
  const regex = pattern
package/src/config.ts CHANGED
@@ -93,12 +93,15 @@ export async function loadConfig(path: string, envName?: string): Promise<Wrangl
93
93
  * Auto-detect config file in a directory. Tries wrangler.jsonc, wrangler.json, wrangler.toml.
94
94
  */
95
95
  export async function autoLoadConfig(baseDir: string, envName?: string): Promise<WranglerConfig> {
96
+ return loadConfig(findConfigPath(baseDir), envName)
97
+ }
98
+
99
+ /** Resolve the wrangler config path under `baseDir` (jsonc | json | toml). */
100
+ export function findConfigPath(baseDir: string): string {
96
101
  const candidates = ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']
97
102
  for (const name of candidates) {
98
103
  const fullPath = join(baseDir, name)
99
- if (existsSync(fullPath)) {
100
- return loadConfig(fullPath, envName)
101
- }
104
+ if (existsSync(fullPath)) return fullPath
102
105
  }
103
106
  throw new Error(`No wrangler config found in ${baseDir} (tried: ${candidates.join(', ')})`)
104
107
  }
package/src/db.ts CHANGED
@@ -20,6 +20,7 @@ export function getDatabase(): Database {
20
20
 
21
21
  instance = new Database(DB_PATH, { create: true })
22
22
  instance.run('PRAGMA journal_mode=WAL')
23
+ instance.run('PRAGMA busy_timeout=5000')
23
24
  runMigrations(instance)
24
25
  return instance
25
26
  }
package/src/env.ts CHANGED
@@ -4,6 +4,7 @@ import { AiBinding } from './bindings/ai'
4
4
  import { SqliteAnalyticsEngine } from './bindings/analytics-engine'
5
5
  import { BrowserBinding } from './bindings/browser'
6
6
  import { ContainerBase } from './bindings/container'
7
+ import { containerLabels, registerContainer, unregisterContainer } from './bindings/container-cleanup'
7
8
  import { DockerManager } from './bindings/container-docker'
8
9
  import { openD1Database } from './bindings/d1'
9
10
  import type { DOExecutorFactory } from './bindings/do-executor'
@@ -17,11 +18,11 @@ import { QueueConsumer, SqliteQueueProducer } from './bindings/queue'
17
18
  import { FileR2Bucket } from './bindings/r2'
18
19
  import { createServiceBinding } from './bindings/service-binding'
19
20
  import { StaticAssets } from './bindings/static-assets'
20
- import { SqliteWorkflowBinding } from './bindings/workflow'
21
+ import { SqliteWorkflowBinding, wireWorkflowClass } from './bindings/workflow'
21
22
  import type { WranglerConfig } from './config'
22
23
  import { getDatabase, getDataDir } from './db'
23
24
  import { instrumentBinding, instrumentD1, instrumentDONamespace, instrumentServiceBinding } from './tracing/instrument'
24
- import type { WorkerRegistry } from './worker-registry'
25
+ import type { ResolvedTarget, WorkerRegistry } from './worker-registry'
25
26
 
26
27
  /**
27
28
  * Global reference to the built env object. Used by cloudflare:workers `env` export.
@@ -313,10 +314,14 @@ export function buildEnv(
313
314
  console.log(`[lopata] Container: ${container.class_name} (reusing DO binding, image: ${container.image})`)
314
315
  }
315
316
  } else {
316
- // Create a new DO namespace for this container
317
+ // Reuse the existing namespace across reloads (like the DO-binding branch
318
+ // above) — constructing a fresh one would orphan the old namespace while a
319
+ // new executor opens the same do-sql file for the same id, breaking DO
320
+ // single-threading.
317
321
  const bindingName = container.name ?? container.class_name
318
322
  console.log(`[lopata] Container: ${bindingName} -> ${container.class_name} (image: ${container.image})`)
319
- const namespace = new DurableObjectNamespaceImpl(db, container.class_name, getDataDir(), undefined, executorFactory)
323
+ const existingNs = existingNamespaces?.get(container.class_name)
324
+ const namespace = existingNs ?? new DurableObjectNamespaceImpl(db, container.class_name, getDataDir(), undefined, executorFactory)
320
325
  env[bindingName] = instrumentDONamespace(namespace, container.class_name)
321
326
  registry.durableObjects.push({
322
327
  bindingName,
@@ -386,15 +391,17 @@ export function wireClassRefs(
386
391
  }
387
392
 
388
393
  for (const entry of registry.workflows) {
389
- const cls = workerModule[entry.className]
390
- if (!cls) throw new Error(`Workflow class "${entry.className}" not exported from worker module`)
391
- entry.binding._setClass(cls as any, env)
392
- entry.binding.resumeInterrupted()
394
+ wireWorkflowClass(entry.binding, entry.className, workerModule, env)
393
395
  console.log(`[lopata] Wired Workflow class: ${entry.className}`)
394
396
  }
395
397
 
396
- // Wire container configs onto namespaces
397
- const dockerManager = new DockerManager()
398
+ // Wire container configs onto namespaces. Main-side path goes through the
399
+ // cleanup registry directly (no postMessage needed — this *is* main).
400
+ const dockerManager = new DockerManager({
401
+ onRegister: registerContainer,
402
+ onRemove: unregisterContainer,
403
+ labels: containerLabels(),
404
+ })
398
405
  for (const entry of registry.containers) {
399
406
  entry.namespace._setContainerConfig({
400
407
  className: entry.className,
@@ -405,18 +412,30 @@ export function wireClassRefs(
405
412
  console.log(`[lopata] Wired container config: ${entry.className} (image: ${entry.image})`)
406
413
  }
407
414
 
408
- // Wire service bindings
415
+ wireServiceBindings(registry, workerModule, env, workerRegistry)
416
+ }
417
+
418
+ /**
419
+ * Service-binding wiring extracted so thread-mode generations (which skip
420
+ * `wireClassRefs` entirely — DO/Workflow classes live in the worker) can
421
+ * still resolve cross-worker fetches through the registry.
422
+ */
423
+ export function wireServiceBindings(
424
+ registry: ClassRegistry,
425
+ workerModule: Record<string, unknown>,
426
+ env: Record<string, unknown>,
427
+ workerRegistry?: WorkerRegistry,
428
+ ) {
409
429
  for (const entry of registry.serviceBindings) {
410
- const wire = entry.proxy._wire as ((resolver: () => { workerModule: Record<string, unknown>; env: Record<string, unknown> }) => void) | undefined
411
- if (wire) {
412
- if (workerRegistry) {
413
- // Resolve through registry (handles both self-ref and cross-worker)
414
- wire(() => workerRegistry.resolveTarget(entry.serviceName))
415
- } else {
416
- // Backward compat: self-reference
417
- wire(() => ({ workerModule, env }))
418
- }
419
- console.log(`[lopata] Wired service binding: ${entry.bindingName} -> ${entry.serviceName}${entry.entrypoint ? ` (${entry.entrypoint})` : ''}`)
430
+ const wire = entry.proxy._wire as ((resolver: () => ResolvedTarget) => void) | undefined
431
+ if (!wire) continue
432
+ if (workerRegistry) {
433
+ // Resolve through registry (handles both self-ref and cross-worker)
434
+ wire(() => workerRegistry.resolveTarget(entry.serviceName))
435
+ } else {
436
+ // Backward compat: self-reference, in-process
437
+ wire(() => ({ kind: 'in-process', workerModule, env }))
420
438
  }
439
+ console.log(`[lopata] Wired service binding: ${entry.bindingName} -> ${entry.serviceName}${entry.entrypoint ? ` (${entry.entrypoint})` : ''}`)
421
440
  }
422
441
  }
@@ -150,6 +150,27 @@ function extractBindings(config: WranglerConfig): { name: string; type: string }
150
150
 
151
151
  // ─── Public API ──────────────────────────────────────────────────────────
152
152
 
153
+ /**
154
+ * Stitch a captured caller stack onto a short async error so dev-time stacks
155
+ * show where the failing call originated. Mutates `err.stack` in place; no-op
156
+ * if either side is missing, already stitched, or `err.stack` looks deep.
157
+ */
158
+ export function stitchAsyncStack(err: Error, callerError: Error | null): void {
159
+ if (!callerError) return
160
+ if (!err.stack || !callerError.stack) return
161
+ if (err.stack.includes('--- async ---')) return
162
+
163
+ const errFrames = err.stack.split('\n').filter(l => l.trim().startsWith('at '))
164
+ const looksShort = errFrames.length <= 5 || err.stack.includes('processTicksAndRejections')
165
+ if (!looksShort) return
166
+
167
+ const callerLines = callerError.stack.split('\n').slice(1)
168
+ const filtered = callerLines.filter(l => !l.includes('/lopata/src/'))
169
+ if (filtered.length === 0) return
170
+
171
+ err.stack += '\n --- async ---\n' + filtered.join('\n')
172
+ }
173
+
153
174
  export async function renderErrorPage(
154
175
  error: unknown,
155
176
  request: Request,
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { tracing } from './tracing/span'
2
3
 
3
4
  const storage = new AsyncLocalStorage<ExecutionContext>()
4
5
 
@@ -10,18 +11,27 @@ export function runWithExecutionContext<T>(ctx: ExecutionContext, fn: () => T):
10
11
  return storage.run(ctx, fn)
11
12
  }
12
13
 
14
+ /** Swallow + log a `waitUntil` rejection. Single source of truth for the log string.
15
+ * Coerces non-thenables (CF tolerates `ctx.waitUntil(undefined)`) — a synchronous
16
+ * throw here would strand the wait-until-settle accounting on the worker side. */
17
+ export function logIfRejected(promise: Promise<unknown>): Promise<unknown> {
18
+ return Promise.resolve(promise).catch(err => {
19
+ console.error('[lopata] waitUntil promise rejected:', err)
20
+ })
21
+ }
22
+
13
23
  export class ExecutionContext {
14
24
  private _promises: Promise<unknown>[] = []
15
25
  readonly props: Record<string, unknown>
26
+ /** Cloudflare-compatible custom span API: `ctx.tracing.enterSpan(...)`. */
27
+ readonly tracing = tracing
16
28
 
17
29
  constructor(props?: Record<string, unknown>) {
18
30
  this.props = props ?? {}
19
31
  }
20
32
 
21
33
  waitUntil(promise: Promise<unknown>): void {
22
- this._promises.push(promise.catch(err => {
23
- console.error('[lopata] waitUntil promise rejected:', err)
24
- }))
34
+ this._promises.push(logIfRejected(promise))
25
35
  }
26
36
 
27
37
  passThroughOnException(): void {