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
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import type { InProcessExecutor } from '../bindings/do-executor-inprocess'
|
|
2
|
+
import type { DurableObjectNamespaceImpl, SqliteDurableObjectStorage } from '../bindings/durable-object'
|
|
3
|
+
import type { SqlStorage } from '../bindings/durable-object'
|
|
4
|
+
import type { CFWebSocket } from '../bindings/websocket-pair'
|
|
5
|
+
|
|
6
|
+
export class TestDurableObjectStorage {
|
|
7
|
+
private storage: SqliteDurableObjectStorage
|
|
8
|
+
|
|
9
|
+
constructor(storage: SqliteDurableObjectStorage) {
|
|
10
|
+
this.storage = storage
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async get<T = unknown>(key: string): Promise<T | undefined>
|
|
14
|
+
async get<T = unknown>(keys: string[]): Promise<Map<string, T>>
|
|
15
|
+
async get<T = unknown>(keyOrKeys: string | string[]): Promise<T | undefined | Map<string, T>> {
|
|
16
|
+
return this.storage.get(keyOrKeys as any)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async list(
|
|
20
|
+
options?: { prefix?: string; start?: string; startAfter?: string; end?: string; limit?: number; reverse?: boolean },
|
|
21
|
+
): Promise<Map<string, unknown>> {
|
|
22
|
+
return this.storage.list(options)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async put(key: string, value: unknown): Promise<void>
|
|
26
|
+
async put(entries: Record<string, unknown>): Promise<void>
|
|
27
|
+
async put(keyOrEntries: string | Record<string, unknown>, value?: unknown): Promise<void> {
|
|
28
|
+
if (typeof keyOrEntries === 'string') {
|
|
29
|
+
return this.storage.put(keyOrEntries, value)
|
|
30
|
+
}
|
|
31
|
+
return this.storage.put(keyOrEntries)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async delete(key: string): Promise<boolean>
|
|
35
|
+
async delete(keys: string[]): Promise<number>
|
|
36
|
+
async delete(keyOrKeys: string | string[]): Promise<boolean | number> {
|
|
37
|
+
return this.storage.delete(keyOrKeys as any)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async deleteAll(): Promise<void> {
|
|
41
|
+
return this.storage.deleteAll()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async getAlarm(): Promise<number | null> {
|
|
45
|
+
return this.storage.getAlarm()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async setAlarm(scheduledTime: number | Date): Promise<void> {
|
|
49
|
+
return this.storage.setAlarm(scheduledTime)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async deleteAlarm(): Promise<void> {
|
|
53
|
+
return this.storage.deleteAlarm()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- TestWebSocket ---
|
|
58
|
+
|
|
59
|
+
export class TestWebSocket {
|
|
60
|
+
readonly raw: CFWebSocket
|
|
61
|
+
private _messages: (string | ArrayBuffer)[] = []
|
|
62
|
+
private _messageWaiters: { resolve: (msg: string | ArrayBuffer) => void; reject: (err: Error) => void }[] = []
|
|
63
|
+
private _closeWaiters: { resolve: (ev: { code: number; reason: string }) => void; reject: (err: Error) => void }[] = []
|
|
64
|
+
private _closed = false
|
|
65
|
+
private _closeData: { code: number; reason: string } | null = null
|
|
66
|
+
|
|
67
|
+
constructor(ws: CFWebSocket) {
|
|
68
|
+
this.raw = ws
|
|
69
|
+
|
|
70
|
+
ws.addEventListener('message', (event: Event) => {
|
|
71
|
+
const data = (event as MessageEvent).data as string | ArrayBuffer
|
|
72
|
+
const waiter = this._messageWaiters.shift()
|
|
73
|
+
if (waiter) {
|
|
74
|
+
waiter.resolve(data)
|
|
75
|
+
} else {
|
|
76
|
+
this._messages.push(data)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
ws.addEventListener('close', (event: Event) => {
|
|
81
|
+
this._closed = true
|
|
82
|
+
const ce = event as CloseEvent
|
|
83
|
+
const closeData = { code: ce.code, reason: ce.reason }
|
|
84
|
+
this._closeData = closeData
|
|
85
|
+
// Reject all pending message waiters
|
|
86
|
+
for (const w of this._messageWaiters) {
|
|
87
|
+
w.reject(new Error('WebSocket closed'))
|
|
88
|
+
}
|
|
89
|
+
this._messageWaiters = []
|
|
90
|
+
// Resolve close waiters
|
|
91
|
+
for (const w of this._closeWaiters) {
|
|
92
|
+
w.resolve(closeData)
|
|
93
|
+
}
|
|
94
|
+
this._closeWaiters = []
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get readyState(): number {
|
|
99
|
+
return this.raw.readyState
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** All received messages so far. */
|
|
103
|
+
get messages(): ReadonlyArray<string | ArrayBuffer> {
|
|
104
|
+
return this._messages
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Send data to the DO via the WebSocket. */
|
|
108
|
+
send(data: string | ArrayBuffer | ArrayBufferView): void {
|
|
109
|
+
this.raw.send(data)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Wait for the next message from the DO. */
|
|
113
|
+
waitForMessage(timeout = 5000): Promise<string | ArrayBuffer> {
|
|
114
|
+
// Check queue first
|
|
115
|
+
const queued = this._messages.shift()
|
|
116
|
+
if (queued !== undefined) return Promise.resolve(queued)
|
|
117
|
+
|
|
118
|
+
if (this._closed) return Promise.reject(new Error('WebSocket closed'))
|
|
119
|
+
|
|
120
|
+
return new Promise<string | ArrayBuffer>((resolve, reject) => {
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
const idx = this._messageWaiters.findIndex(w => w.resolve === resolve)
|
|
123
|
+
if (idx !== -1) this._messageWaiters.splice(idx, 1)
|
|
124
|
+
reject(new Error(`waitForMessage timed out after ${timeout}ms`))
|
|
125
|
+
}, timeout)
|
|
126
|
+
|
|
127
|
+
this._messageWaiters.push({
|
|
128
|
+
resolve: (msg) => {
|
|
129
|
+
clearTimeout(timer)
|
|
130
|
+
resolve(msg)
|
|
131
|
+
},
|
|
132
|
+
reject: (err) => {
|
|
133
|
+
clearTimeout(timer)
|
|
134
|
+
reject(err)
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Wait for the WebSocket to close. */
|
|
141
|
+
waitForClose(timeout = 5000): Promise<{ code: number; reason: string }> {
|
|
142
|
+
if (this._closed) {
|
|
143
|
+
return Promise.resolve(this._closeData ?? { code: 1000, reason: '' })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return new Promise<{ code: number; reason: string }>((resolve, reject) => {
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
const idx = this._closeWaiters.findIndex(w => w.resolve === resolve)
|
|
149
|
+
if (idx !== -1) this._closeWaiters.splice(idx, 1)
|
|
150
|
+
reject(new Error(`waitForClose timed out after ${timeout}ms`))
|
|
151
|
+
}, timeout)
|
|
152
|
+
|
|
153
|
+
this._closeWaiters.push({
|
|
154
|
+
resolve: (ev) => {
|
|
155
|
+
clearTimeout(timer)
|
|
156
|
+
resolve(ev)
|
|
157
|
+
},
|
|
158
|
+
reject: (err) => {
|
|
159
|
+
clearTimeout(timer)
|
|
160
|
+
reject(err)
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Close the WebSocket from the client side. */
|
|
167
|
+
close(code?: number, reason?: string): void {
|
|
168
|
+
this.raw.close(code, reason)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Read the server-side WebSocket's serialized attachment. */
|
|
172
|
+
deserializeAttachment(): unknown {
|
|
173
|
+
const peer = this.raw._peer
|
|
174
|
+
if (!peer) return null
|
|
175
|
+
return peer.deserializeAttachment()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Write to the server-side WebSocket's serialized attachment. */
|
|
179
|
+
serializeAttachment(value: unknown): void {
|
|
180
|
+
const peer = this.raw._peer
|
|
181
|
+
if (!peer) throw new Error('No peer WebSocket')
|
|
182
|
+
peer.serializeAttachment(value)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- TestDurableObjectHandle ---
|
|
187
|
+
|
|
188
|
+
export class TestDurableObjectHandle {
|
|
189
|
+
private namespace: DurableObjectNamespaceImpl
|
|
190
|
+
private idStr: string
|
|
191
|
+
private _stub: unknown | null = null
|
|
192
|
+
|
|
193
|
+
constructor(namespace: DurableObjectNamespaceImpl, idStr: string) {
|
|
194
|
+
this.namespace = namespace
|
|
195
|
+
this.idStr = idStr
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The DO instance ID. */
|
|
199
|
+
get id(): string {
|
|
200
|
+
return this.idStr
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** RPC proxy stub. Forces executor creation on first access. */
|
|
204
|
+
get stub(): any {
|
|
205
|
+
if (!this._stub) {
|
|
206
|
+
const doId = this.namespace.idFromString(this.idStr)
|
|
207
|
+
this._stub = this.namespace.get(doId)
|
|
208
|
+
}
|
|
209
|
+
return this._stub
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private getExecutor(): InProcessExecutor {
|
|
213
|
+
// Ensure executor exists by accessing the stub
|
|
214
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
215
|
+
this.stub
|
|
216
|
+
const executor = this.namespace._getExecutor(this.idStr)
|
|
217
|
+
if (!executor) throw new Error(`Durable Object executor not found for ${this.idStr}`)
|
|
218
|
+
return executor as InProcessExecutor
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Access KV-style storage for this DO instance. */
|
|
222
|
+
get storage(): TestDurableObjectStorage {
|
|
223
|
+
const executor = this.getExecutor()
|
|
224
|
+
return new TestDurableObjectStorage(executor._rawState.storage)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Access SQL storage for this DO instance. */
|
|
228
|
+
get sql(): SqlStorage {
|
|
229
|
+
const executor = this.getExecutor()
|
|
230
|
+
return executor._rawState.storage.sql
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Get the scheduled alarm time, or null. */
|
|
234
|
+
async getAlarm(): Promise<number | null> {
|
|
235
|
+
const executor = this.getExecutor()
|
|
236
|
+
return executor._rawState.storage.getAlarm()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Trigger the alarm handler immediately. */
|
|
240
|
+
async triggerAlarm(): Promise<void> {
|
|
241
|
+
return this.namespace.triggerAlarm(this.idStr)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Cancel a scheduled alarm without firing it. */
|
|
245
|
+
async cancelAlarm(): Promise<void> {
|
|
246
|
+
this.namespace.cancelAlarm(this.idStr)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Delete this DO instance and all its data. */
|
|
250
|
+
async delete(): Promise<void> {
|
|
251
|
+
this.namespace.deleteInstance(this.idStr)
|
|
252
|
+
this._stub = null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Connect a WebSocket to this DO, simulating the upgrade handshake. */
|
|
256
|
+
async connectWebSocket(options?: {
|
|
257
|
+
path?: string
|
|
258
|
+
headers?: Record<string, string>
|
|
259
|
+
}): Promise<TestWebSocket> {
|
|
260
|
+
const executor = this.getExecutor()
|
|
261
|
+
const path = options?.path ?? '/'
|
|
262
|
+
const url = `http://localhost${path}`
|
|
263
|
+
const headers = new Headers(options?.headers)
|
|
264
|
+
headers.set('upgrade', 'websocket')
|
|
265
|
+
|
|
266
|
+
const request = new Request(url, { headers })
|
|
267
|
+
const response = await executor.executeFetch(request)
|
|
268
|
+
|
|
269
|
+
// Extract the server-side WebSocket from the response
|
|
270
|
+
const serverWs = (response as any).webSocket as CFWebSocket | undefined
|
|
271
|
+
if (!serverWs) {
|
|
272
|
+
throw new Error('DO fetch handler did not return a WebSocket upgrade response (no response.webSocket)')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Accept the client side (simulates what Bun.serve does)
|
|
276
|
+
serverWs.accept()
|
|
277
|
+
|
|
278
|
+
return new TestWebSocket(serverWs)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Get all accepted WebSockets for this DO instance (via DurableObjectState). */
|
|
282
|
+
getWebSockets(tag?: string): WebSocket[] {
|
|
283
|
+
const executor = this.getExecutor()
|
|
284
|
+
return executor._rawState.getWebSockets(tag)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Get tags for a specific WebSocket. */
|
|
288
|
+
getTags(ws: WebSocket): string[] {
|
|
289
|
+
const executor = this.getExecutor()
|
|
290
|
+
return executor._rawState.getTags(ws)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export class TestDurableObjectNamespace {
|
|
295
|
+
private namespace: DurableObjectNamespaceImpl
|
|
296
|
+
private handles: TestDurableObjectHandle[] = []
|
|
297
|
+
|
|
298
|
+
constructor(namespace: DurableObjectNamespaceImpl) {
|
|
299
|
+
this.namespace = namespace
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Get a DO handle by name (uses idFromName). */
|
|
303
|
+
get(name: string): TestDurableObjectHandle {
|
|
304
|
+
const doId = this.namespace.idFromName(name)
|
|
305
|
+
const handle = new TestDurableObjectHandle(this.namespace, doId.toString())
|
|
306
|
+
this.handles.push(handle)
|
|
307
|
+
return handle
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Get a DO handle by raw ID string. */
|
|
311
|
+
getById(idStr: string): TestDurableObjectHandle {
|
|
312
|
+
const handle = new TestDurableObjectHandle(this.namespace, idStr)
|
|
313
|
+
this.handles.push(handle)
|
|
314
|
+
return handle
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** List all instance IDs in this namespace. */
|
|
318
|
+
listIds(): string[] {
|
|
319
|
+
return this.namespace._listInstanceIds()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
dispose(): void {
|
|
323
|
+
this.handles = []
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite'
|
|
2
|
+
import { mkdtempSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { LocalD1Database } from '../bindings/d1'
|
|
6
|
+
import type { DurableObjectNamespaceImpl } from '../bindings/durable-object'
|
|
7
|
+
import { SqliteKVNamespace } from '../bindings/kv'
|
|
8
|
+
import { SqliteQueueProducer } from '../bindings/queue'
|
|
9
|
+
import { FileR2Bucket } from '../bindings/r2'
|
|
10
|
+
import { createServiceBinding } from '../bindings/service-binding'
|
|
11
|
+
import type { SqliteWorkflowBinding } from '../bindings/workflow'
|
|
12
|
+
import type { WranglerConfig } from '../config'
|
|
13
|
+
import { runMigrations } from '../db'
|
|
14
|
+
import type { Clock } from './clock'
|
|
15
|
+
import type { BindingSpec } from './types'
|
|
16
|
+
|
|
17
|
+
interface ServiceBindingEntry {
|
|
18
|
+
bindingName: string
|
|
19
|
+
serviceName: string
|
|
20
|
+
entrypoint?: string
|
|
21
|
+
proxy: Record<string, unknown>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TestClassRegistry {
|
|
25
|
+
durableObjects: { bindingName: string; className: string; namespace: DurableObjectNamespaceImpl }[]
|
|
26
|
+
workflows: { bindingName: string; className: string; binding: SqliteWorkflowBinding }[]
|
|
27
|
+
serviceBindings: ServiceBindingEntry[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BuiltTestEnv {
|
|
31
|
+
db: Database
|
|
32
|
+
env: Record<string, unknown>
|
|
33
|
+
registry: TestClassRegistry
|
|
34
|
+
tmpDirs: string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildTestEnv(
|
|
38
|
+
bindings: Record<string, BindingSpec> | undefined,
|
|
39
|
+
vars: Record<string, string> | undefined,
|
|
40
|
+
clock?: Clock,
|
|
41
|
+
): BuiltTestEnv {
|
|
42
|
+
const db = new Database(':memory:')
|
|
43
|
+
runMigrations(db)
|
|
44
|
+
|
|
45
|
+
const env: Record<string, unknown> = {}
|
|
46
|
+
const registry: TestClassRegistry = { durableObjects: [], workflows: [], serviceBindings: [] }
|
|
47
|
+
const tmpDirs: string[] = []
|
|
48
|
+
|
|
49
|
+
if (vars) {
|
|
50
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
51
|
+
env[key] = value
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!bindings) {
|
|
56
|
+
return { db, env, registry, tmpDirs }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const [bindingName, spec] of Object.entries(bindings)) {
|
|
60
|
+
if (spec === 'kv') {
|
|
61
|
+
env[bindingName] = new SqliteKVNamespace(db, bindingName, undefined, clock)
|
|
62
|
+
} else if (spec === 'r2') {
|
|
63
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'lopata-test-r2-'))
|
|
64
|
+
tmpDirs.push(tmpDir)
|
|
65
|
+
env[bindingName] = new FileR2Bucket(db, bindingName, tmpDir)
|
|
66
|
+
} else if (spec === 'd1') {
|
|
67
|
+
env[bindingName] = new LocalD1Database(new Database(':memory:'))
|
|
68
|
+
} else if (spec === 'queue') {
|
|
69
|
+
env[bindingName] = new SqliteQueueProducer(db, bindingName, 0, undefined, clock)
|
|
70
|
+
} else if (typeof spec === 'object') {
|
|
71
|
+
if (spec.type === 'durable-object') {
|
|
72
|
+
// Lazy import to avoid pulling in the whole DO module at parse time
|
|
73
|
+
const { DurableObjectNamespaceImpl } = require('../bindings/durable-object')
|
|
74
|
+
const namespace = new DurableObjectNamespaceImpl(db, spec.className, undefined, { evictionTimeoutMs: 0 }, undefined, clock)
|
|
75
|
+
env[bindingName] = namespace
|
|
76
|
+
registry.durableObjects.push({ bindingName, className: spec.className, namespace })
|
|
77
|
+
} else if (spec.type === 'workflow') {
|
|
78
|
+
const { SqliteWorkflowBinding } = require('../bindings/workflow')
|
|
79
|
+
const binding = new SqliteWorkflowBinding(db, bindingName, spec.className, undefined, clock)
|
|
80
|
+
env[bindingName] = binding
|
|
81
|
+
registry.workflows.push({ bindingName, className: spec.className, binding })
|
|
82
|
+
} else if (spec.type === 'service') {
|
|
83
|
+
const proxy = createServiceBinding(spec.service, spec.entrypoint)
|
|
84
|
+
env[bindingName] = proxy
|
|
85
|
+
registry.serviceBindings.push({
|
|
86
|
+
bindingName,
|
|
87
|
+
serviceName: spec.service,
|
|
88
|
+
entrypoint: spec.entrypoint,
|
|
89
|
+
proxy,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { db, env, registry, tmpDirs }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Translate a WranglerConfig into a flat BindingSpec map + vars */
|
|
99
|
+
export function configToBindings(config: WranglerConfig): { bindings: Record<string, BindingSpec>; vars: Record<string, string> } {
|
|
100
|
+
const bindings: Record<string, BindingSpec> = {}
|
|
101
|
+
const vars: Record<string, string> = { ...config.vars }
|
|
102
|
+
|
|
103
|
+
for (const kv of config.kv_namespaces ?? []) {
|
|
104
|
+
bindings[kv.binding] = 'kv'
|
|
105
|
+
}
|
|
106
|
+
for (const r2 of config.r2_buckets ?? []) {
|
|
107
|
+
bindings[r2.binding] = 'r2'
|
|
108
|
+
}
|
|
109
|
+
for (const d1 of config.d1_databases ?? []) {
|
|
110
|
+
bindings[d1.binding] = 'd1'
|
|
111
|
+
}
|
|
112
|
+
for (const producer of config.queues?.producers ?? []) {
|
|
113
|
+
bindings[producer.binding] = 'queue'
|
|
114
|
+
}
|
|
115
|
+
for (const doBinding of config.durable_objects?.bindings ?? []) {
|
|
116
|
+
bindings[doBinding.name] = { type: 'durable-object', className: doBinding.class_name }
|
|
117
|
+
}
|
|
118
|
+
for (const wf of config.workflows ?? []) {
|
|
119
|
+
bindings[wf.binding] = { type: 'workflow', className: wf.class_name }
|
|
120
|
+
}
|
|
121
|
+
for (const svc of config.services ?? []) {
|
|
122
|
+
bindings[svc.binding] = { type: 'service', service: svc.service, entrypoint: svc.entrypoint }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { bindings, vars }
|
|
126
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
|
|
3
|
+
export interface FetchCall {
|
|
4
|
+
request: Request
|
|
5
|
+
url: string
|
|
6
|
+
method: string
|
|
7
|
+
response: Response | null
|
|
8
|
+
mocked: boolean
|
|
9
|
+
timestamp: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type MatchFn = (req: Request) => boolean
|
|
13
|
+
type HandlerFn = (req: Request) => Response | Promise<Response>
|
|
14
|
+
|
|
15
|
+
interface MockRoute {
|
|
16
|
+
match: MatchFn
|
|
17
|
+
handler: HandlerFn
|
|
18
|
+
method?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const fetchMockStorage = new AsyncLocalStorage<FetchMock>()
|
|
22
|
+
|
|
23
|
+
export function getActiveFetchMock(): FetchMock | undefined {
|
|
24
|
+
return fetchMockStorage.getStore()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function runWithFetchMock<T>(mock: FetchMock, fn: () => T): T {
|
|
28
|
+
return fetchMockStorage.run(mock, fn)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toHandlerFn(handler: Response | HandlerFn): HandlerFn {
|
|
32
|
+
if (typeof handler === 'function') return handler
|
|
33
|
+
return () => (handler as any).clone()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class FetchMock {
|
|
37
|
+
private routes: MockRoute[] = []
|
|
38
|
+
private _calls: FetchCall[] = []
|
|
39
|
+
private _passthrough = true
|
|
40
|
+
|
|
41
|
+
/** Add a mock route. Unmatched requests will throw unless passthrough() is called. */
|
|
42
|
+
on(match: string | RegExp | ((req: Request) => boolean), handler: Response | HandlerFn): this {
|
|
43
|
+
const matchFn = this.toMatchFn(match)
|
|
44
|
+
this.routes.push({ match: matchFn, handler: toHandlerFn(handler) })
|
|
45
|
+
this._passthrough = false
|
|
46
|
+
return this
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Add a mock route for GET requests only. */
|
|
50
|
+
onGet(match: string | RegExp | ((req: Request) => boolean), handler: Response | HandlerFn): this {
|
|
51
|
+
const matchFn = this.toMatchFn(match)
|
|
52
|
+
this.routes.push({ match: matchFn, handler: toHandlerFn(handler), method: 'GET' })
|
|
53
|
+
this._passthrough = false
|
|
54
|
+
return this
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Add a mock route for POST requests only. */
|
|
58
|
+
onPost(match: string | RegExp | ((req: Request) => boolean), handler: Response | HandlerFn): this {
|
|
59
|
+
const matchFn = this.toMatchFn(match)
|
|
60
|
+
this.routes.push({ match: matchFn, handler: toHandlerFn(handler), method: 'POST' })
|
|
61
|
+
this._passthrough = false
|
|
62
|
+
return this
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Allow unmatched requests to pass through to the real network. */
|
|
66
|
+
passthrough(): this {
|
|
67
|
+
this._passthrough = true
|
|
68
|
+
return this
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** All recorded fetch calls. */
|
|
72
|
+
get calls(): readonly FetchCall[] {
|
|
73
|
+
return this._calls
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Get recorded calls, optionally filtered by URL match. */
|
|
77
|
+
getCalls(match?: string | RegExp | ((req: Request) => boolean)): FetchCall[] {
|
|
78
|
+
if (!match) return [...this._calls]
|
|
79
|
+
const matchFn = this.toMatchFn(match)
|
|
80
|
+
return this._calls.filter(c => matchFn(c.request))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Reset all routes and recorded calls. */
|
|
84
|
+
reset(): void {
|
|
85
|
+
this.routes = []
|
|
86
|
+
this._calls = []
|
|
87
|
+
this._passthrough = true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @internal Handle a fetch request from the intercepted globalThis.fetch */
|
|
91
|
+
async _handle(request: Request): Promise<{ response: Response; mocked: boolean } | null> {
|
|
92
|
+
for (const route of this.routes) {
|
|
93
|
+
if (route.method && request.method !== route.method) continue
|
|
94
|
+
if (route.match(request)) {
|
|
95
|
+
const response = await route.handler(request)
|
|
96
|
+
const call: FetchCall = {
|
|
97
|
+
request,
|
|
98
|
+
url: request.url,
|
|
99
|
+
method: request.method,
|
|
100
|
+
response,
|
|
101
|
+
mocked: true,
|
|
102
|
+
timestamp: Date.now(),
|
|
103
|
+
}
|
|
104
|
+
this._calls.push(call)
|
|
105
|
+
return { response, mocked: true }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// No route matched
|
|
110
|
+
if (this._passthrough) {
|
|
111
|
+
return null // let the original fetch handle it
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Strict mode — no route matched and passthrough is disabled
|
|
115
|
+
const call: FetchCall = {
|
|
116
|
+
request,
|
|
117
|
+
url: request.url,
|
|
118
|
+
method: request.method,
|
|
119
|
+
response: null,
|
|
120
|
+
mocked: false,
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
}
|
|
123
|
+
this._calls.push(call)
|
|
124
|
+
throw new Error(`FetchMock: no route matched for ${request.method} ${request.url} (passthrough disabled)`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @internal Record a passthrough call */
|
|
128
|
+
_recordPassthrough(request: Request, response: Response): void {
|
|
129
|
+
this._calls.push({
|
|
130
|
+
request,
|
|
131
|
+
url: request.url,
|
|
132
|
+
method: request.method,
|
|
133
|
+
response,
|
|
134
|
+
mocked: false,
|
|
135
|
+
timestamp: Date.now(),
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private toMatchFn(match: string | RegExp | ((req: Request) => boolean)): MatchFn {
|
|
140
|
+
if (typeof match === 'function') return match
|
|
141
|
+
if (match instanceof RegExp) return (req) => match.test(req.url)
|
|
142
|
+
// String: prefix match
|
|
143
|
+
return (req) => req.url.startsWith(match)
|
|
144
|
+
}
|
|
145
|
+
}
|