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.
- package/dist/dashboard/{chunk-pqnphvm2.css → chunk-csyd2tq2.css} +27 -0
- package/dist/dashboard/{chunk-5nxa3jfc.js → chunk-yxzrcvyh.js} +364 -3
- package/dist/dashboard/index.html +1 -1
- package/package.json +5 -3
- package/src/api/handlers/generations.ts +19 -5
- package/src/api/types.ts +14 -0
- package/src/bindings/cache.ts +14 -8
- package/src/bindings/durable-object.ts +80 -21
- package/src/bindings/kv.ts +12 -8
- package/src/bindings/queue.ts +22 -12
- package/src/bindings/workflow.ts +332 -25
- package/src/env.ts +3 -2
- package/src/file-watcher.ts +59 -32
- package/src/generation-manager.ts +6 -1
- package/src/generation.ts +15 -3
- package/src/plugin.ts +2 -90
- package/src/setup-globals.ts +23 -21
- package/src/testing/clock.ts +26 -0
- package/src/testing/durable-object.ts +325 -0
- package/src/testing/env-builder.ts +126 -0
- package/src/testing/fetch-mock.ts +145 -0
- package/src/testing/index.ts +288 -0
- package/src/testing/setup.ts +68 -0
- package/src/testing/types.ts +68 -0
- package/src/testing/workflow.ts +323 -0
- package/src/tracing/store.ts +6 -0
- package/src/tracing/types.ts +1 -0
- package/src/virtual-modules.ts +99 -0
- package/src/vite-plugin/config-plugin.ts +2 -0
- package/src/vite-plugin/dev-server-plugin.ts +159 -56
package/src/file-watcher.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
11
|
-
private
|
|
12
|
-
private
|
|
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,
|
|
14
|
+
constructor(dir: string, onChange: () => void, pollIntervalMs = 500) {
|
|
15
15
|
this.dir = dir
|
|
16
16
|
this.onChange = onChange
|
|
17
|
-
this.
|
|
17
|
+
this.pollIntervalMs = pollIntervalMs
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
start(): void {
|
|
21
|
-
if (this.
|
|
22
|
-
this.
|
|
23
|
-
|
|
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.
|
|
31
|
-
this.
|
|
32
|
-
this.
|
|
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
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
|
52
|
-
this.debounceTimer = setTimeout(() => {
|
|
53
|
-
this.debounceTimer = null
|
|
56
|
+
if (changed) {
|
|
54
57
|
this.onChange()
|
|
55
|
-
}
|
|
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
|
|
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
|
})
|
package/src/setup-globals.ts
CHANGED
|
@@ -36,31 +36,33 @@ export function setupCloudflareGlobals() {
|
|
|
36
36
|
},
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
// Register global `caches` object (CacheStorage) with tracing
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
+
}
|