lopata 0.7.0 → 0.8.2

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.
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http'
2
2
  import { dirname, resolve } from 'node:path'
3
3
  import type { Plugin, ViteDevServer } from 'vite'
4
+ import { FileWatcher } from '../file-watcher.ts'
4
5
 
5
6
  interface DevServerPluginOptions {
6
7
  configPath?: string
@@ -52,6 +53,11 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
52
53
  let currentModule: Record<string, unknown> | null = null
53
54
  // Serializes module reload — prevents concurrent wireClassRefs calls
54
55
  let reloadLock: Promise<void> | null = null
56
+ // Generation counter — increments on each module reload for tracing
57
+ let currentGenerationId = 0
58
+ // Track generation records for dashboard visibility
59
+ const viteGenerations = new Map<number, { id: number; createdAt: number; state: 'active' | 'stopped' }>()
60
+ const genActiveRequests = new Map<number, number>()
55
61
 
56
62
  /**
57
63
  * Import the worker module through Vite's SSR runner and re-wire
@@ -81,15 +87,39 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
81
87
  reloadLock = new Promise(r => {
82
88
  resolveReload = r
83
89
  })
90
+ const previousModule = currentModule
91
+ const previousGenId = currentGenerationId
84
92
  try {
85
93
  currentModule = workerModule
86
- wireClassRefs(registry, workerModule, env, workerRegistry)
94
+ // Track generation lifecycle
95
+ if (viteGenerations.has(previousGenId)) {
96
+ viteGenerations.get(previousGenId)!.state = 'stopped'
97
+ }
98
+ currentGenerationId++
99
+ viteGenerations.set(currentGenerationId, { id: currentGenerationId, createdAt: Date.now(), state: 'active' })
100
+ wireClassRefs(registry, workerModule, env, workerRegistry, currentGenerationId)
87
101
  setGlobalEnv(env)
88
- console.log('[lopata:vite] Worker module (re)loaded, classes wired')
102
+ console.log(`[lopata:vite] Worker module (re)loaded, classes wired (generation ${currentGenerationId})`)
103
+ // Schedule cleanup of old generation after successful reload
104
+ if (viteGenerations.has(previousGenId)) {
105
+ setTimeout(() => viteGenerations.delete(previousGenId), 60_000)
106
+ }
89
107
  } catch (err) {
90
- // Reset so next request retries
91
- currentModule = null
92
- throw err
108
+ // Revert generation tracking
109
+ viteGenerations.delete(currentGenerationId)
110
+ currentGenerationId = previousGenId
111
+ if (viteGenerations.has(previousGenId)) {
112
+ viteGenerations.get(previousGenId)!.state = 'active'
113
+ }
114
+ if (previousModule) {
115
+ // Serve old module while Vite module graph settles (e.g. DO class not yet re-exported)
116
+ currentModule = previousModule
117
+ console.warn('[lopata:vite] Module reload failed, serving previous version:', err instanceof Error ? err.message : err)
118
+ } else {
119
+ // First load — no fallback
120
+ currentModule = null
121
+ throw err
122
+ }
93
123
  } finally {
94
124
  reloadLock = null
95
125
  resolveReload()
@@ -103,6 +133,14 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
103
133
  return {
104
134
  name: 'lopata:dev-server',
105
135
 
136
+ transform(code, id) {
137
+ if (!config) return
138
+ if (this.environment?.name !== options.envName) return
139
+ const entrypoint = resolve(server.config.root, config.main)
140
+ if (id !== entrypoint) return
141
+ return code + '\nif (import.meta.hot) { import.meta.hot.accept() }\n'
142
+ },
143
+
106
144
  async configureServer(viteServer: ViteDevServer) {
107
145
  server = viteServer
108
146
  const projectRoot = server.config.root
@@ -167,6 +205,62 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
167
205
  // 3. Set up API context
168
206
  apiMod.setDashboardConfig(config)
169
207
 
208
+ // 3b. Create generation tracking adapter for dashboard
209
+ const mainAdapter = {
210
+ config,
211
+ gracePeriodMs: 0,
212
+ get active() {
213
+ return currentModule ? { workerModule: currentModule, env, registry } : null
214
+ },
215
+ list() {
216
+ return Array.from(viteGenerations.values()).map(g => ({
217
+ id: g.id,
218
+ state: g.state,
219
+ createdAt: g.createdAt,
220
+ activeRequests: genActiveRequests.get(g.id) ?? 0,
221
+ workerName: config.name,
222
+ durableObjects: g.state === 'active'
223
+ ? registry.durableObjects.map((entry: any) => {
224
+ const executors = entry.namespace._listActiveExecutors()
225
+ return {
226
+ namespace: entry.className,
227
+ activeInstances: executors.length,
228
+ totalWebSockets: executors.reduce((sum: number, e: any) => sum + e.wsCount, 0),
229
+ }
230
+ })
231
+ : undefined,
232
+ }))
233
+ },
234
+ get(id: number) {
235
+ const record = viteGenerations.get(id)
236
+ if (!record) return null
237
+ return {
238
+ getInfo() {
239
+ return {
240
+ id: record.id,
241
+ state: record.state,
242
+ createdAt: record.createdAt,
243
+ activeRequests: genActiveRequests.get(record.id) ?? 0,
244
+ workerName: config.name,
245
+ }
246
+ },
247
+ registry,
248
+ }
249
+ },
250
+ reload() {
251
+ return Promise.reject(new Error('Main worker uses Vite HMR — save a file to trigger reload'))
252
+ },
253
+ stop(id: number) {
254
+ const record = viteGenerations.get(id)
255
+ if (record) {
256
+ record.state = 'stopped'
257
+ setTimeout(() => viteGenerations.delete(id), 60_000)
258
+ }
259
+ },
260
+ setGracePeriod() {},
261
+ }
262
+ apiMod.setGenerationManager(mainAdapter as any)
263
+
170
264
  // 4. Set up auxiliary workers (if configured)
171
265
  if (options.auxiliaryWorkers && options.auxiliaryWorkers.length > 0) {
172
266
  await import('../plugin.ts')
@@ -175,17 +269,6 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
175
269
  const { GenerationManager } = await import('../generation-manager.ts')
176
270
 
177
271
  workerRegistry = new WorkerRegistry()
178
-
179
- const mainAdapter = {
180
- config,
181
- gracePeriodMs: 0,
182
- get active() {
183
- return currentModule ? { workerModule: currentModule, env, registry } : null
184
- },
185
- list() {
186
- return []
187
- },
188
- }
189
272
  workerRegistry.register(config.name, mainAdapter as any, true)
190
273
 
191
274
  for (const workerDef of options.auxiliaryWorkers) {
@@ -207,6 +290,18 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
207
290
  } catch (err) {
208
291
  console.error(`[lopata:vite] Failed to load auxiliary worker "${auxConfig.name}":`, err)
209
292
  }
293
+
294
+ // File watcher for aux worker reload
295
+ const auxSrcDir = dirname(resolve(auxBaseDir, auxConfig.main))
296
+ const auxWatcher = new FileWatcher(auxSrcDir, () => {
297
+ auxManager.reload().then(gen => {
298
+ console.log(`[lopata:vite] Auxiliary worker "${auxConfig.name}" reloaded → generation ${gen.id}`)
299
+ }).catch(err => {
300
+ console.error(`[lopata:vite] Reload failed for "${auxConfig.name}":`, err)
301
+ })
302
+ })
303
+ auxWatcher.start()
304
+ console.log(`[lopata:vite] Watching ${auxSrcDir} for changes (${auxConfig.name})`)
210
305
  }
211
306
 
212
307
  apiMod.setWorkerRegistry(workerRegistry)
@@ -262,53 +357,61 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
262
357
 
263
358
  try {
264
359
  const activeModule = await ensureWorkerModule()
360
+ const genId = currentGenerationId
361
+ genActiveRequests.set(genId, (genActiveRequests.get(genId) ?? 0) + 1)
265
362
 
266
- const request = nodeReqToRequest(req)
267
- const parsedUrl = new URL(request.url)
363
+ try {
364
+ const request = nodeReqToRequest(req)
365
+ const parsedUrl = new URL(request.url)
268
366
 
269
- const handler = activeModule.default as Record<string, unknown>
270
- if (!handler || typeof handler.fetch !== 'function') {
271
- console.error('[lopata:vite] Worker module default export has no fetch() method')
272
- return next()
273
- }
367
+ const handler = activeModule.default as Record<string, unknown>
368
+ if (!handler || typeof handler.fetch !== 'function') {
369
+ console.error('[lopata:vite] Worker module default export has no fetch() method')
370
+ return next()
371
+ }
274
372
 
275
- // Capture caller stack before entering the worker (for async stack stitching)
276
- const callerStack = new Error()
277
-
278
- const ctx = new ExecutionContext()
279
- const response = await (startSpan as Function)({
280
- name: `${request.method} ${parsedUrl.pathname}`,
281
- kind: 'server',
282
- attributes: { 'http.method': request.method, 'http.url': request.url },
283
- }, () =>
284
- runWithExecutionContext(ctx, async () => {
285
- try {
286
- const resp = await (handler.fetch as Function).call(handler, request, env, ctx) as Response
287
- ;(setSpanAttribute as Function)('http.status_code', resp.status)
288
-
289
- // Intercept React Router error boundary responses with lopata error page
290
- const routeError = (globalThis as any).__lopata_routeError
291
- delete (globalThis as any).__lopata_routeError
292
- if (routeError) {
293
- if (routeError instanceof Error) {
294
- stitchAsyncStack(routeError, callerStack)
373
+ // Capture caller stack before entering the worker (for async stack stitching)
374
+ const callerStack = new Error()
375
+
376
+ const ctx = new ExecutionContext()
377
+ const response = await (startSpan as Function)({
378
+ name: `${request.method} ${parsedUrl.pathname}`,
379
+ kind: 'server',
380
+ attributes: { 'http.method': request.method, 'http.url': request.url, 'lopata.generation_id': genId },
381
+ }, () =>
382
+ runWithExecutionContext(ctx, async () => {
383
+ try {
384
+ const resp = await (handler.fetch as Function).call(handler, request, env, ctx) as Response
385
+ ;(setSpanAttribute as Function)('http.status_code', resp.status)
386
+
387
+ // Intercept React Router error boundary responses with lopata error page
388
+ const routeError = (globalThis as any).__lopata_routeError
389
+ delete (globalThis as any).__lopata_routeError
390
+ if (routeError) {
391
+ if (routeError instanceof Error) {
392
+ stitchAsyncStack(routeError, callerStack)
393
+ }
394
+ console.error('[lopata:vite] Route error:\n' + (routeError instanceof Error ? routeError.stack : String(routeError)))
395
+ return (renderErrorPage as Function)(routeError, request, env, config)
295
396
  }
296
- console.error('[lopata:vite] Route error:\n' + (routeError instanceof Error ? routeError.stack : String(routeError)))
297
- return (renderErrorPage as Function)(routeError, request, env, config)
298
- }
299
397
 
300
- ctx._awaitAll().catch(() => {})
301
- return resp
302
- } catch (err) {
303
- if (err instanceof Error) {
304
- stitchAsyncStack(err, callerStack)
398
+ ctx._awaitAll().catch(() => {})
399
+ return resp
400
+ } catch (err) {
401
+ if (err instanceof Error) {
402
+ stitchAsyncStack(err, callerStack)
403
+ }
404
+ console.error('[lopata:vite] Request error:\n' + (err instanceof Error ? err.stack : String(err)))
405
+ return (renderErrorPage as Function)(err, request, env, config)
305
406
  }
306
- console.error('[lopata:vite] Request error:\n' + (err instanceof Error ? err.stack : String(err)))
307
- return (renderErrorPage as Function)(err, request, env, config)
308
- }
309
- })) as Response
407
+ })) as Response
310
408
 
311
- writeResponse(response, res)
409
+ writeResponse(response, res)
410
+ } finally {
411
+ const count = genActiveRequests.get(genId) ?? 1
412
+ if (count <= 1) genActiveRequests.delete(genId)
413
+ else genActiveRequests.set(genId, count - 1)
414
+ }
312
415
  } catch (err) {
313
416
  console.error('[lopata:vite] Request error:', err)
314
417
  if (!res.headersSent) {