lopata 0.5.2 → 0.7.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.
@@ -52,8 +52,53 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
52
52
  let currentModule: Record<string, unknown> | null = null
53
53
  // Serializes module reload — prevents concurrent wireClassRefs calls
54
54
  let reloadLock: Promise<void> | null = null
55
- // Files changed since last request — coalesced into a single invalidation batch
56
- let changedFiles: Set<string> = new Set()
55
+
56
+ /**
57
+ * Import the worker module through Vite's SSR runner and re-wire
58
+ * class refs when the module identity changes (HMR invalidation).
59
+ * Serialized via reloadLock to prevent concurrent wireClassRefs calls.
60
+ */
61
+ async function ensureWorkerModule(): Promise<Record<string, unknown>> {
62
+ const ssrEnv = server.environments[options.envName]
63
+ if (!ssrEnv || !('runner' in ssrEnv)) {
64
+ throw new Error(`SSR environment "${options.envName}" not found or has no runner`)
65
+ }
66
+
67
+ const entrypoint = resolve(server.config.root, config.main)
68
+
69
+ // Wait for any in-progress reload before importing
70
+ if (reloadLock) await reloadLock
71
+
72
+ const workerModule = await (ssrEnv as any).runner.import(entrypoint) as Record<string, unknown>
73
+
74
+ // Re-wire class refs when module changes (HMR invalidation)
75
+ if (workerModule !== currentModule) {
76
+ if (reloadLock) {
77
+ // Another request started reloading while we were importing — wait for it
78
+ await reloadLock
79
+ } else {
80
+ let resolveReload!: () => void
81
+ reloadLock = new Promise(r => {
82
+ resolveReload = r
83
+ })
84
+ try {
85
+ currentModule = workerModule
86
+ wireClassRefs(registry, workerModule, env, workerRegistry)
87
+ setGlobalEnv(env)
88
+ console.log('[lopata:vite] Worker module (re)loaded, classes wired')
89
+ } catch (err) {
90
+ // Reset so next request retries
91
+ currentModule = null
92
+ throw err
93
+ } finally {
94
+ reloadLock = null
95
+ resolveReload()
96
+ }
97
+ }
98
+ }
99
+
100
+ return currentModule ?? workerModule
101
+ }
57
102
 
58
103
  return {
59
104
  name: 'lopata:dev-server',
@@ -167,23 +212,7 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
167
212
  apiMod.setWorkerRegistry(workerRegistry)
168
213
  }
169
214
 
170
- // 5. Track SSR-relevant file changes.
171
- // Collect changed file paths; the next request will invalidate only
172
- // the affected modules (and their transitive importers) instead of
173
- // clearing the entire runner cache. HMR on the runner is disabled
174
- // (hmr: false in config-plugin) so there's no async race.
175
- server.watcher.on('change', (file) => {
176
- const ssrEnv = server.environments[options.envName]
177
- if (!ssrEnv) return
178
- const normalizedFile = file.replace(/\\/g, '/')
179
- const mods = ssrEnv.moduleGraph.getModulesByFile(normalizedFile)
180
- if (mods && mods.size > 0) {
181
- changedFiles.add(normalizedFile)
182
- currentModule = null
183
- }
184
- })
185
-
186
- // 6. Set up WebSocket trace streaming on httpServer
215
+ // 5. Set up WebSocket trace streaming on httpServer
187
216
  setupTraceWebSocket(server)
188
217
 
189
218
  // 6. Return middleware callback (post-middleware — runs after framework plugins)
@@ -232,63 +261,11 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
232
261
  }
233
262
 
234
263
  try {
235
- const ssrEnv = server.environments[options.envName]
236
- if (!ssrEnv || !('runner' in ssrEnv)) {
237
- console.error(`[lopata:vite] SSR environment "${options.envName}" not found or has no runner`)
238
- return next()
239
- }
240
-
241
- const entrypoint = resolve(server.config.root, config.main)
242
-
243
- // Wait for any in-progress reload before importing
244
- if (reloadLock) await reloadLock
245
-
246
- // Granular invalidation: only invalidate modules for changed
247
- // files and their transitive importers, instead of wiping the
248
- // entire runner cache. This preserves cached evaluations of
249
- // unchanged modules for faster re-evaluation.
250
- if (changedFiles.size > 0) {
251
- const files = changedFiles
252
- changedFiles = new Set()
253
- const runner = (ssrEnv as any).runner
254
- const count = invalidateChangedModules(runner.evaluatedModules, files)
255
- if (count > 0) {
256
- console.log(`[lopata:vite] Invalidated ${count} module(s) (${files.size} file(s) changed)`)
257
- }
258
- }
259
-
260
- const workerModule = await (ssrEnv as any).runner.import(entrypoint) as Record<string, unknown>
261
-
262
- // Re-wire class refs when module changes (HMR invalidation)
263
- if (workerModule !== currentModule) {
264
- if (reloadLock) {
265
- // Another request started reloading while we were importing — wait for it
266
- await reloadLock
267
- } else {
268
- let resolveReload!: () => void
269
- reloadLock = new Promise(r => {
270
- resolveReload = r
271
- })
272
- try {
273
- currentModule = workerModule
274
- wireClassRefs(registry, workerModule, env, workerRegistry)
275
- setGlobalEnv(env)
276
- console.log('[lopata:vite] Worker module (re)loaded, classes wired')
277
- } catch (err) {
278
- // Reset so next request retries
279
- currentModule = null
280
- throw err
281
- } finally {
282
- reloadLock = null
283
- resolveReload()
284
- }
285
- }
286
- }
264
+ const activeModule = await ensureWorkerModule()
287
265
 
288
266
  const request = nodeReqToRequest(req)
289
267
  const parsedUrl = new URL(request.url)
290
268
 
291
- const activeModule = currentModule ?? workerModule
292
269
  const handler = activeModule.default as Record<string, unknown>
293
270
  if (!handler || typeof handler.fetch !== 'function') {
294
271
  console.error('[lopata:vite] Worker module default export has no fetch() method')
@@ -350,98 +327,28 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
350
327
 
351
328
  // Dynamically import ws (available as Vite dependency)
352
329
  import('ws').then(({ WebSocketServer }) => {
353
- const wss = new WebSocketServer({ noServer: true })
330
+ const traceWss = new WebSocketServer({ noServer: true })
331
+ const workerWss = new WebSocketServer({ noServer: true })
354
332
 
355
333
  httpServer.on('upgrade', (req: IncomingMessage, socket: any, head: Buffer) => {
356
334
  const url = req.url ?? ''
357
- if (!url.startsWith('/__api/traces/ws')) return
358
-
359
- wss.handleUpgrade(req, socket, head, (ws: any) => {
360
- const store = getTraceStore()
361
- let filter: { path?: string; status?: string; attributeFilters?: Array<{ key: string; value: string; type: 'include' | 'exclude' }> } = {}
362
- let buffer: any[] = []
363
- const MAX_BUFFER = 1000
364
- const allowedTraces = new Set<string>()
365
- const excludedTraces = new Set<string>()
366
-
367
- function isRootSpanFiltered(span: { name: string; status: string; parentSpanId: string | null; attributes: Record<string, unknown> }): boolean {
368
- if (filter.status && filter.status !== 'all') {
369
- if (span.status !== 'unset' && span.status !== filter.status) return true
370
- }
371
- if (filter.path) {
372
- if (!matchGlob(span.name, filter.path)) return true
373
- }
374
- if (filter.attributeFilters && filter.attributeFilters.length > 0) {
375
- const attrs = span.attributes
376
- for (const af of filter.attributeFilters) {
377
- const val = attrs[af.key]
378
- const matches = val !== undefined && String(val).toLowerCase().includes(af.value.toLowerCase())
379
- if (af.type === 'include' && !matches) return true
380
- if (af.type === 'exclude' && matches) return true
381
- }
382
- }
383
- return false
384
- }
385
-
386
- const unsubscribe = store.subscribe((event: any) => {
387
- const traceId = event.type === 'span.event' ? event.event.traceId : event.span.traceId
388
- if ((event.type === 'span.start' || event.type === 'span.end') && event.span.parentSpanId === null) {
389
- if (isRootSpanFiltered(event.span)) {
390
- excludedTraces.add(traceId)
391
- allowedTraces.delete(traceId)
392
- return
393
- }
394
- excludedTraces.delete(traceId)
395
- allowedTraces.add(traceId)
396
- } else {
397
- if (excludedTraces.has(traceId)) return
398
- }
399
- if (buffer.length < MAX_BUFFER) {
400
- buffer.push(event)
401
- }
402
- })
403
-
404
- const interval = setInterval(() => {
405
- if (buffer.length > 0) {
406
- ws.send(JSON.stringify({ type: 'batch', events: buffer }))
407
- buffer = []
408
- }
409
- }, 500)
410
335
 
411
- // Parse filter from query params
412
- try {
413
- const reqUrl = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`)
414
- const statusParam = reqUrl.searchParams.get('status')
415
- const pathParam = reqUrl.searchParams.get('path')
416
- if (statusParam) filter.status = statusParam
417
- if (pathParam) filter.path = pathParam
418
- } catch {}
419
-
420
- let sinceMs = 15 * 60 * 1000
421
- const since = Date.now() - sinceMs
422
- const recent = store.getRecentTraces(since, 200, filter)
423
- ws.send(JSON.stringify({ type: 'initial', traces: recent }))
336
+ // Skip Vite HMR WebSocket — Vite uses sec-websocket-protocol
337
+ // "vite-hmr" / "vite-ping" to identify its connections
338
+ const wsProtocol = req.headers['sec-websocket-protocol']
339
+ if (wsProtocol === 'vite-hmr' || wsProtocol === 'vite-ping') return
424
340
 
425
- ws.on('message', (data: any) => {
426
- try {
427
- const msg = JSON.parse(typeof data === 'string' ? data : data.toString())
428
- if (msg.type === 'filter') {
429
- filter = { path: msg.path, status: msg.status, attributeFilters: msg.attributeFilters }
430
- if (msg.sinceMs !== undefined) sinceMs = msg.sinceMs
431
- allowedTraces.clear()
432
- excludedTraces.clear()
433
- const freshSince = sinceMs > 0 ? Date.now() - sinceMs : 0
434
- const freshTraces = store.getRecentTraces(freshSince, 200, filter)
435
- ws.send(JSON.stringify({ type: 'initial', traces: freshTraces }))
436
- }
437
- } catch {}
341
+ if (url.startsWith('/__api/traces/ws')) {
342
+ traceWss.handleUpgrade(req, socket, head, (ws: any) => {
343
+ handleTraceWebSocket(ws, req)
438
344
  })
345
+ return
346
+ }
439
347
 
440
- ws.on('close', () => {
441
- unsubscribe()
442
- clearInterval(interval)
443
- })
444
- })
348
+ // Worker WebSocket upgrade — bridge to CF WebSocketPair
349
+ if (req.headers.upgrade?.toLowerCase() === 'websocket') {
350
+ handleWorkerWebSocketUpgrade(workerWss, req, socket, head)
351
+ }
445
352
  })
446
353
 
447
354
  console.log('[lopata:vite] Dashboard: http://localhost:5173/__dashboard')
@@ -450,51 +357,165 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
450
357
  console.log('[lopata:vite] Dashboard available (trace streaming disabled — ws package not found)')
451
358
  })
452
359
  }
453
- }
454
360
 
455
- /**
456
- * Invalidate runner-evaluated modules for the given changed files and all
457
- * their transitive importers. Virtual modules (IDs starting with `\0`) are
458
- * skipped to preserve shimmed CF modules (see commit 75b1736).
459
- *
460
- * Returns the number of modules invalidated.
461
- */
462
- function invalidateChangedModules(
463
- evaluatedModules: { getModulesByFile(file: string): Iterable<{ id: string; importers: Set<any> }> | undefined; invalidateModule(node: any): void },
464
- changedFiles: Set<string>,
465
- ): number {
466
- const toInvalidate = new Set<{ id: string; importers: Set<any> }>()
467
-
468
- for (const file of changedFiles) {
469
- const nodes = evaluatedModules.getModulesByFile(file)
470
- if (!nodes) continue
471
- for (const node of nodes) {
472
- collectTransitiveImporters(node, toInvalidate)
361
+ function handleTraceWebSocket(ws: any, req: IncomingMessage) {
362
+ const store = getTraceStore()
363
+ let filter: { path?: string; status?: string; attributeFilters?: Array<{ key: string; value: string; type: 'include' | 'exclude' }> } = {}
364
+ let buffer: any[] = []
365
+ const MAX_BUFFER = 1000
366
+ const allowedTraces = new Set<string>()
367
+ const excludedTraces = new Set<string>()
368
+
369
+ function isRootSpanFiltered(span: { name: string; status: string; parentSpanId: string | null; attributes: Record<string, unknown> }): boolean {
370
+ if (filter.status && filter.status !== 'all') {
371
+ if (span.status !== 'unset' && span.status !== filter.status) return true
372
+ }
373
+ if (filter.path) {
374
+ if (!matchGlob(span.name, filter.path)) return true
375
+ }
376
+ if (filter.attributeFilters && filter.attributeFilters.length > 0) {
377
+ const attrs = span.attributes
378
+ for (const af of filter.attributeFilters) {
379
+ const val = attrs[af.key]
380
+ const matches = val !== undefined && String(val).toLowerCase().includes(af.value.toLowerCase())
381
+ if (af.type === 'include' && !matches) return true
382
+ if (af.type === 'exclude' && matches) return true
383
+ }
384
+ }
385
+ return false
473
386
  }
474
- }
475
387
 
476
- for (const node of toInvalidate) {
477
- evaluatedModules.invalidateModule(node)
388
+ const unsubscribe = store.subscribe((event: any) => {
389
+ const traceId = event.type === 'span.event' ? event.event.traceId : event.span.traceId
390
+ if ((event.type === 'span.start' || event.type === 'span.end') && event.span.parentSpanId === null) {
391
+ if (isRootSpanFiltered(event.span)) {
392
+ excludedTraces.add(traceId)
393
+ allowedTraces.delete(traceId)
394
+ return
395
+ }
396
+ excludedTraces.delete(traceId)
397
+ allowedTraces.add(traceId)
398
+ } else {
399
+ if (excludedTraces.has(traceId)) return
400
+ }
401
+ if (buffer.length < MAX_BUFFER) {
402
+ buffer.push(event)
403
+ }
404
+ })
405
+
406
+ const interval = setInterval(() => {
407
+ if (buffer.length > 0) {
408
+ ws.send(JSON.stringify({ type: 'batch', events: buffer }))
409
+ buffer = []
410
+ }
411
+ }, 500)
412
+
413
+ // Parse filter from query params
414
+ try {
415
+ const reqUrl = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`)
416
+ const statusParam = reqUrl.searchParams.get('status')
417
+ const pathParam = reqUrl.searchParams.get('path')
418
+ if (statusParam) filter.status = statusParam
419
+ if (pathParam) filter.path = pathParam
420
+ } catch {}
421
+
422
+ let sinceMs = 15 * 60 * 1000
423
+ const since = Date.now() - sinceMs
424
+ const recent = store.getRecentTraces(since, 200, filter)
425
+ ws.send(JSON.stringify({ type: 'initial', traces: recent }))
426
+
427
+ ws.on('message', (data: any) => {
428
+ try {
429
+ const msg = JSON.parse(typeof data === 'string' ? data : data.toString())
430
+ if (msg.type === 'filter') {
431
+ filter = { path: msg.path, status: msg.status, attributeFilters: msg.attributeFilters }
432
+ if (msg.sinceMs !== undefined) sinceMs = msg.sinceMs
433
+ allowedTraces.clear()
434
+ excludedTraces.clear()
435
+ const freshSince = sinceMs > 0 ? Date.now() - sinceMs : 0
436
+ const freshTraces = store.getRecentTraces(freshSince, 200, filter)
437
+ ws.send(JSON.stringify({ type: 'initial', traces: freshTraces }))
438
+ }
439
+ } catch {}
440
+ })
441
+
442
+ ws.on('close', () => {
443
+ unsubscribe()
444
+ clearInterval(interval)
445
+ })
478
446
  }
479
447
 
480
- return toInvalidate.size
481
- }
448
+ async function handleWorkerWebSocketUpgrade(wss: any, req: IncomingMessage, socket: any, head: Buffer) {
449
+ try {
450
+ const { CFWebSocket } = await import('../bindings/websocket-pair.ts')
482
451
 
483
- /**
484
- * Collect `node` and all its transitive importers into `result`.
485
- * Skips virtual modules (IDs starting with `\0`) — these are CF module shims
486
- * that must not be invalidated or traversed further.
487
- */
488
- function collectTransitiveImporters(
489
- node: { id: string; importers: Set<any> },
490
- result: Set<{ id: string; importers: Set<any> }>,
491
- ): void {
492
- if (result.has(node)) return
493
- // Skip virtual modules CF shims must stay cached
494
- if (node.id.startsWith('\0')) return
495
- result.add(node)
496
- for (const importer of node.importers) {
497
- collectTransitiveImporters(importer, result)
452
+ const activeModule = await ensureWorkerModule()
453
+ const handler = activeModule.default as Record<string, unknown>
454
+ if (!handler || typeof handler.fetch !== 'function') {
455
+ socket.destroy()
456
+ return
457
+ }
458
+
459
+ const request = nodeReqToRequest(req)
460
+ const ctx = new ExecutionContext()
461
+ const response = await runWithExecutionContext(ctx, async () => {
462
+ return (handler.fetch as Function).call(handler, request, env, ctx) as Response
463
+ })
464
+
465
+ const cfSocket = (response as Response & { webSocket?: InstanceType<typeof CFWebSocket> }).webSocket
466
+ if (response.status !== 101 || !cfSocket || !(cfSocket instanceof CFWebSocket)) {
467
+ socket.destroy()
468
+ return
469
+ }
470
+
471
+ // Complete the upgrade and bridge
472
+ wss.handleUpgrade(req, socket, head, (ws: any) => {
473
+ // CF → real WS
474
+ cfSocket.addEventListener('message', (ev: Event) => {
475
+ const msgData = (ev as MessageEvent).data
476
+ try {
477
+ ws.send(msgData)
478
+ } catch {}
479
+ })
480
+ cfSocket.addEventListener('close', (ev: Event) => {
481
+ const ce = ev as CloseEvent
482
+ try {
483
+ ws.close(ce.code, ce.reason)
484
+ } catch {}
485
+ })
486
+ // Accept the client side so events from server.send() are dispatched
487
+ cfSocket.accept()
488
+
489
+ // Real WS → CF
490
+ ws.on('message', (data: Buffer, isBinary: boolean) => {
491
+ const msgData = isBinary
492
+ ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer
493
+ : data.toString('utf-8')
494
+ const evt = { type: 'message' as const, data: msgData }
495
+ if (cfSocket._peer?._accepted) {
496
+ cfSocket._peer._dispatchWSEvent(evt)
497
+ } else if (cfSocket._peer) {
498
+ cfSocket._peer._eventQueue.push(evt)
499
+ }
500
+ })
501
+
502
+ ws.on('close', (code: number, reason: Buffer) => {
503
+ if (cfSocket._peer && cfSocket._peer.readyState !== 3) {
504
+ const evt = { type: 'close' as const, code: code ?? 1000, reason: reason?.toString('utf-8') ?? '', wasClean: true }
505
+ if (cfSocket._peer._accepted) {
506
+ cfSocket._peer._dispatchWSEvent(evt)
507
+ } else {
508
+ cfSocket._peer._eventQueue.push(evt)
509
+ }
510
+ cfSocket._peer.readyState = 3
511
+ }
512
+ cfSocket.readyState = 3
513
+ })
514
+ })
515
+ } catch (err) {
516
+ console.error('[lopata:vite] Worker WebSocket upgrade failed:', err)
517
+ socket.destroy()
518
+ }
498
519
  }
499
520
  }
500
521
 
@@ -3,13 +3,11 @@ import type { Plugin } from 'vite'
3
3
  let initialized = false
4
4
 
5
5
  /**
6
- * Sets up global Cloudflare-compatible APIs in the Bun process:
7
- * caches, HTMLRewriter, WebSocketPair, IdentityTransformStream, FixedLengthStream,
8
- * navigator.userAgent, scheduler.wait(), crypto extensions.
6
+ * Sets up global Cloudflare-compatible APIs in the Bun process.
9
7
  *
10
8
  * Runs once on configureServer (before middleware), idempotent.
11
- * Imports are lazy because the plugin is externalized by Vite's config bundler —
12
- * dynamic imports run at dev server startup time through Bun's native loader.
9
+ * Uses dynamic import because the plugin file is externalized by Vite's config bundler —
10
+ * the import runs at dev server startup time through Bun's native loader.
13
11
  */
14
12
  export function globalsPlugin(): Plugin {
15
13
  return {
@@ -19,80 +17,8 @@ export function globalsPlugin(): Plugin {
19
17
  if (initialized) return
20
18
  initialized = true
21
19
 
22
- const { SqliteCacheStorage } = await import('../bindings/cache.ts')
23
- const { HTMLRewriter } = await import('../bindings/html-rewriter.ts')
24
- const { WebSocketPair } = await import('../bindings/websocket-pair.ts')
25
- const { IdentityTransformStream, FixedLengthStream } = await import('../bindings/cf-streams.ts')
26
- const { patchGlobalCrypto } = await import('../bindings/crypto-extras.ts')
27
- const { getDatabase } = await import('../db.ts')
28
- const { instrumentBinding } = await import('../tracing/instrument.ts')
29
-
30
- // Global caches (CacheStorage)
31
- const cacheMethods = ['match', 'put', 'delete']
32
- const rawCacheStorage = new SqliteCacheStorage(getDatabase())
33
- rawCacheStorage.default = instrumentBinding(rawCacheStorage.default, {
34
- type: 'cache',
35
- name: 'default',
36
- methods: cacheMethods,
37
- }) as typeof rawCacheStorage.default
38
-
39
- const originalOpen = rawCacheStorage.open.bind(rawCacheStorage)
40
- rawCacheStorage.open = async (cacheName: string) => {
41
- const cache = await originalOpen(cacheName)
42
- return instrumentBinding(cache, {
43
- type: 'cache',
44
- name: cacheName,
45
- methods: cacheMethods,
46
- })
47
- }
48
-
49
- Object.defineProperty(globalThis, 'caches', {
50
- value: rawCacheStorage,
51
- writable: false,
52
- configurable: true,
53
- })
54
-
55
- Object.defineProperty(globalThis, 'HTMLRewriter', {
56
- value: HTMLRewriter,
57
- writable: false,
58
- configurable: true,
59
- })
60
-
61
- Object.defineProperty(globalThis, 'WebSocketPair', {
62
- value: WebSocketPair,
63
- writable: false,
64
- configurable: true,
65
- })
66
-
67
- Object.defineProperty(globalThis, 'IdentityTransformStream', {
68
- value: IdentityTransformStream,
69
- writable: false,
70
- configurable: true,
71
- })
72
-
73
- Object.defineProperty(globalThis, 'FixedLengthStream', {
74
- value: FixedLengthStream,
75
- writable: false,
76
- configurable: true,
77
- })
78
-
79
- patchGlobalCrypto()
80
-
81
- Object.defineProperty(globalThis.navigator, 'userAgent', {
82
- value: 'Cloudflare-Workers',
83
- writable: false,
84
- configurable: true,
85
- })
86
-
87
- Object.defineProperty(globalThis, 'scheduler', {
88
- value: {
89
- wait(ms: number): Promise<void> {
90
- return new Promise((resolve) => setTimeout(resolve, ms))
91
- },
92
- },
93
- writable: false,
94
- configurable: true,
95
- })
20
+ const { setupCloudflareGlobals } = await import('../setup-globals.ts')
21
+ setupCloudflareGlobals()
96
22
  },
97
23
  }
98
24
  }