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,4 +1,4 @@
1
- import { type FSWatcher, watch } from 'node:fs'
1
+ import { readdirSync, statSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
 
4
4
  const WATCH_EXTENSIONS = new Set(['.ts', '.js', '.tsx', '.jsx', '.json'])
@@ -7,51 +7,78 @@ const IGNORE_DIRS = new Set(['.lopata', 'node_modules', '.git'])
7
7
  export class FileWatcher {
8
8
  private dir: string
9
9
  private onChange: () => void
10
- private debounceMs: number
11
- private watcher: FSWatcher | null = null
12
- private debounceTimer: ReturnType<typeof setTimeout> | null = null
10
+ private pollIntervalMs: number
11
+ private pollTimer: ReturnType<typeof setInterval> | null = null
12
+ private mtimeMap = new Map<string, number>()
13
13
 
14
- constructor(dir: string, onChange: () => void, debounceMs = 150) {
14
+ constructor(dir: string, onChange: () => void, pollIntervalMs = 500) {
15
15
  this.dir = dir
16
16
  this.onChange = onChange
17
- this.debounceMs = debounceMs
17
+ this.pollIntervalMs = pollIntervalMs
18
18
  }
19
19
 
20
20
  start(): void {
21
- if (this.watcher) return
22
- this.watcher = watch(this.dir, { recursive: true }, (_event, filename) => {
23
- if (!filename) return
24
- if (!this.shouldWatch(filename)) return
25
- this.scheduleChange()
26
- })
21
+ if (this.pollTimer) return
22
+ this.scanFiles(this.dir, this.mtimeMap)
23
+ this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs)
27
24
  }
28
25
 
29
26
  stop(): void {
30
- if (this.watcher) {
31
- this.watcher.close()
32
- this.watcher = null
33
- }
34
- if (this.debounceTimer) {
35
- clearTimeout(this.debounceTimer)
36
- this.debounceTimer = null
27
+ if (this.pollTimer) {
28
+ clearInterval(this.pollTimer)
29
+ this.pollTimer = null
37
30
  }
38
31
  }
39
32
 
40
- private shouldWatch(filename: string): boolean {
41
- const ext = path.extname(filename)
42
- if (!WATCH_EXTENSIONS.has(ext)) return false
43
- const parts = filename.split(path.sep)
44
- for (const part of parts) {
45
- if (IGNORE_DIRS.has(part)) return false
33
+ private poll(): void {
34
+ const currentFiles = new Map<string, number>()
35
+ this.scanFiles(this.dir, currentFiles)
36
+
37
+ let changed = false
38
+ for (const [file, mtime] of currentFiles) {
39
+ const prev = this.mtimeMap.get(file)
40
+ if (prev === undefined || prev !== mtime) {
41
+ changed = true
42
+ break
43
+ }
46
44
  }
47
- return true
48
- }
45
+ if (!changed) {
46
+ for (const file of this.mtimeMap.keys()) {
47
+ if (!currentFiles.has(file)) {
48
+ changed = true
49
+ break
50
+ }
51
+ }
52
+ }
53
+
54
+ this.mtimeMap = currentFiles
49
55
 
50
- private scheduleChange(): void {
51
- if (this.debounceTimer) clearTimeout(this.debounceTimer)
52
- this.debounceTimer = setTimeout(() => {
53
- this.debounceTimer = null
56
+ if (changed) {
54
57
  this.onChange()
55
- }, this.debounceMs)
58
+ }
59
+ }
60
+
61
+ private scanFiles(dir: string, result: Map<string, number>): void {
62
+ let entries: string[]
63
+ try {
64
+ entries = readdirSync(dir)
65
+ } catch {
66
+ return
67
+ }
68
+ for (const entry of entries) {
69
+ if (IGNORE_DIRS.has(entry)) continue
70
+ const fullPath = path.join(dir, entry)
71
+ let stat: ReturnType<typeof statSync>
72
+ try {
73
+ stat = statSync(fullPath)
74
+ } catch {
75
+ continue
76
+ }
77
+ if (stat.isDirectory()) {
78
+ this.scanFiles(fullPath, result)
79
+ } else if (WATCH_EXTENSIONS.has(path.extname(entry))) {
80
+ result.set(fullPath, stat.mtimeMs)
81
+ }
82
+ }
56
83
  }
57
84
  }
@@ -165,7 +165,7 @@ export class GenerationManager {
165
165
  const workerModule = await import(`${this.workerPath}?v=${Date.now()}`)
166
166
 
167
167
  // 5. Wire DO and Workflow class references
168
- wireClassRefs(registry, workerModule, env, this.workerRegistry)
168
+ wireClassRefs(registry, workerModule, env, this.workerRegistry, this.nextGenId)
169
169
 
170
170
  // 5. Validate default export (or service worker fetch handler)
171
171
  const defaultExport = workerModule.default
@@ -251,6 +251,11 @@ export class GenerationManager {
251
251
  this.gracePeriodMs = ms
252
252
  }
253
253
 
254
+ /** Get a specific generation by ID */
255
+ get(genId: number): Generation | null {
256
+ return this.generations.get(genId) ?? null
257
+ }
258
+
254
259
  /** List all generations for dashboard */
255
260
  list(): GenerationInfo[] {
256
261
  return Array.from(this.generations.values()).map(g => g.getInfo())
package/src/generation.ts CHANGED
@@ -36,6 +36,8 @@ export interface GenerationInfo {
36
36
  state: GenerationState
37
37
  createdAt: number
38
38
  activeRequests: number
39
+ workerName?: string
40
+ durableObjects?: { namespace: string; activeInstances: number; totalWebSockets: number }[]
39
41
  }
40
42
 
41
43
  export class Generation {
@@ -220,7 +222,7 @@ export class Generation {
220
222
  return await startSpan({
221
223
  name: `${request.method} ${url.pathname}`,
222
224
  kind: 'server',
223
- attributes: { 'http.method': request.method, 'http.url': request.url },
225
+ attributes: { 'http.method': request.method, 'http.url': request.url, 'lopata.generation_id': this.id },
224
226
  workerName: this.workerName,
225
227
  }, wrappedHandler)
226
228
  } finally {
@@ -234,7 +236,7 @@ export class Generation {
234
236
  return startSpan({
235
237
  name: 'scheduled',
236
238
  kind: 'server',
237
- attributes: { cron: cronExpr },
239
+ attributes: { cron: cronExpr, 'lopata.generation_id': this.id },
238
240
  workerName: this.workerName,
239
241
  }, () =>
240
242
  runWithExecutionContext(ctx, async () => {
@@ -274,7 +276,7 @@ export class Generation {
274
276
  return startSpan({
275
277
  name: 'email',
276
278
  kind: 'server',
277
- attributes: { 'email.from': from, 'email.to': to },
279
+ attributes: { 'email.from': from, 'email.to': to, 'lopata.generation_id': this.id },
278
280
  workerName: this.workerName,
279
281
  }, () =>
280
282
  runWithExecutionContext(ctx, async () => {
@@ -401,11 +403,21 @@ export class Generation {
401
403
 
402
404
  /** Get info for dashboard */
403
405
  getInfo(): GenerationInfo {
406
+ const durableObjects = this.registry.durableObjects.map(entry => {
407
+ const executors = entry.namespace._listActiveExecutors()
408
+ return {
409
+ namespace: entry.className,
410
+ activeInstances: executors.length,
411
+ totalWebSockets: executors.reduce((sum, e) => sum + e.wsCount, 0),
412
+ }
413
+ })
404
414
  return {
405
415
  id: this.id,
406
416
  state: this.state,
407
417
  createdAt: this.createdAt,
408
418
  activeRequests: this.activeRequests,
419
+ workerName: this.workerName,
420
+ durableObjects,
409
421
  }
410
422
  }
411
423
  }
package/src/plugin.ts CHANGED
@@ -1,16 +1,9 @@
1
1
  import { plugin } from 'bun'
2
- import type { BrowserBinding } from './bindings/browser'
3
- import { ContainerBase, getContainer, getRandom } from './bindings/container'
4
- import { DurableObjectBase, WebSocketRequestResponsePair } from './bindings/durable-object'
5
- import { EmailMessage } from './bindings/email'
6
2
  import type { ImageTransformOptions, OutputOptions } from './bindings/images'
7
- import { WebSocketPair } from './bindings/websocket-pair'
8
- import { NonRetryableError, WorkflowEntrypointBase } from './bindings/workflow'
9
- import { globalEnv } from './env'
10
- import { getActiveExecutionContext } from './execution-context'
11
3
  import { setupCloudflareGlobals } from './setup-globals'
12
4
  import { getActiveContext } from './tracing/context'
13
5
  import { addSpanEvent, persistError, setSpanAttribute, startSpan } from './tracing/span'
6
+ import { registerVirtualModules } from './virtual-modules'
14
7
 
15
8
  setupCloudflareGlobals()
16
9
 
@@ -217,87 +210,6 @@ globalThis.fetch = ((input: any, init?: any): Promise<Response> => {
217
210
  plugin({
218
211
  name: 'cloudflare-workers-shim',
219
212
  setup(build) {
220
- build.module('cloudflare:workers', () => {
221
- // Use a getter so `env` always returns the latest built env object
222
- return {
223
- exports: {
224
- DurableObject: DurableObjectBase,
225
- WorkflowEntrypoint: WorkflowEntrypointBase,
226
- WorkerEntrypoint: class WorkerEntrypoint {
227
- protected ctx: unknown
228
- protected env: unknown
229
- constructor(ctx: unknown, env: unknown) {
230
- this.ctx = ctx
231
- this.env = env
232
- ;(this as any)[Symbol.for('lopata.RpcTarget')] = true
233
- }
234
- },
235
- WebSocketRequestResponsePair,
236
- WebSocketPair,
237
- RpcTarget: class RpcTarget {
238
- constructor() {
239
- ;(this as any)[Symbol.for('lopata.RpcTarget')] = true
240
- }
241
- },
242
- env: globalEnv,
243
- waitUntil(promise: Promise<unknown>): void {
244
- const ctx = getActiveExecutionContext()
245
- if (ctx) {
246
- ctx.waitUntil(promise)
247
- }
248
- },
249
- },
250
- loader: 'object',
251
- }
252
- })
253
-
254
- build.module('@cloudflare/containers', () => {
255
- return {
256
- exports: {
257
- Container: ContainerBase,
258
- getContainer,
259
- getRandom,
260
- switchPort(request: Request, port: number): Request {
261
- const headers = new Headers(request.headers)
262
- headers.set('cf-container-target-port', port.toString())
263
- return new Request(request, { headers })
264
- },
265
- loadBalance: getRandom,
266
- },
267
- loader: 'object',
268
- }
269
- })
270
-
271
- build.module('cloudflare:email', () => {
272
- return {
273
- exports: {
274
- EmailMessage,
275
- },
276
- loader: 'object',
277
- }
278
- })
279
-
280
- build.module('cloudflare:workflows', () => {
281
- return {
282
- exports: {
283
- NonRetryableError,
284
- },
285
- loader: 'object',
286
- }
287
- })
288
-
289
- build.module('@cloudflare/puppeteer', () => {
290
- return {
291
- exports: {
292
- default: {
293
- launch: (endpoint: BrowserBinding, opts?: { keep_alive?: number }) => endpoint.launch(opts),
294
- connect: (endpoint: BrowserBinding, sessionId: string) => endpoint.connect(sessionId),
295
- sessions: (endpoint: BrowserBinding) => endpoint.sessions(),
296
- },
297
- ActiveSession: {} as any, // type-only re-export placeholder
298
- },
299
- loader: 'object',
300
- }
301
- })
213
+ registerVirtualModules(build)
302
214
  },
303
215
  })
@@ -36,31 +36,33 @@ export function setupCloudflareGlobals() {
36
36
  },
37
37
  }
38
38
 
39
- // Register global `caches` object (CacheStorage) with tracing
40
- const rawCacheStorage = new SqliteCacheStorage(getDatabase())
39
+ // Register global `caches` object (CacheStorage) with tracing.
40
+ // Lazy: only creates the SqliteCacheStorage (and its getDatabase() call) on first access.
41
+ let _cacheStorage: SqliteCacheStorage | null = null
41
42
  const cacheMethods = ['match', 'put', 'delete']
42
-
43
- // Instrument the default cache
44
- rawCacheStorage.default = instrumentBinding(rawCacheStorage.default, {
45
- type: 'cache',
46
- name: 'default',
47
- methods: cacheMethods,
48
- }) as typeof rawCacheStorage.default
49
-
50
- // Wrap open() to return instrumented caches
51
- const originalOpen = rawCacheStorage.open.bind(rawCacheStorage)
52
- rawCacheStorage.open = async (cacheName: string) => {
53
- const cache = await originalOpen(cacheName)
54
- return instrumentBinding(cache, {
55
- type: 'cache',
56
- name: cacheName,
57
- methods: cacheMethods,
58
- })
43
+ function getCacheStorage(): SqliteCacheStorage {
44
+ if (!_cacheStorage) {
45
+ _cacheStorage = new SqliteCacheStorage(getDatabase())
46
+ _cacheStorage.default = instrumentBinding(_cacheStorage.default, {
47
+ type: 'cache',
48
+ name: 'default',
49
+ methods: cacheMethods,
50
+ }) as typeof _cacheStorage.default
51
+ const originalOpen = _cacheStorage.open.bind(_cacheStorage)
52
+ _cacheStorage.open = async (cacheName: string) => {
53
+ const cache = await originalOpen(cacheName)
54
+ return instrumentBinding(cache, {
55
+ type: 'cache',
56
+ name: cacheName,
57
+ methods: cacheMethods,
58
+ })
59
+ }
60
+ }
61
+ return _cacheStorage
59
62
  }
60
63
 
61
64
  Object.defineProperty(globalThis, 'caches', {
62
- value: rawCacheStorage,
63
- writable: false,
65
+ get: () => getCacheStorage(),
64
66
  configurable: true,
65
67
  })
66
68
 
@@ -0,0 +1,26 @@
1
+ export interface Clock {
2
+ now(): number
3
+ }
4
+
5
+ export const realClock: Clock = { now: () => Date.now() }
6
+
7
+ export class TestClock implements Clock {
8
+ private _now: number
9
+
10
+ constructor(startTime?: number | Date) {
11
+ this._now = startTime instanceof Date ? startTime.getTime() : (startTime ?? Date.now())
12
+ }
13
+
14
+ now(): number {
15
+ return this._now
16
+ }
17
+
18
+ advance(ms: number): void {
19
+ if (ms < 0) throw new Error('Cannot advance clock by negative amount')
20
+ this._now += ms
21
+ }
22
+
23
+ set(time: number | Date): void {
24
+ this._now = time instanceof Date ? time.getTime() : time
25
+ }
26
+ }