lopata 0.19.2 → 0.20.1

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.
Files changed (38) hide show
  1. package/dist/types/bindings/ai-search.d.ts +44 -0
  2. package/dist/types/bindings/analytics-engine-sql.d.ts +107 -0
  3. package/dist/types/bindings/artifacts-git-http.d.ts +19 -0
  4. package/dist/types/bindings/artifacts.d.ts +108 -0
  5. package/dist/types/bindings/flagship.d.ts +37 -0
  6. package/dist/types/bindings/vpc-network.d.ts +22 -0
  7. package/dist/types/bindings/worker-loader-entry.d.ts +71 -0
  8. package/dist/types/bindings/worker-loader.d.ts +81 -0
  9. package/dist/types/config.d.ts +23 -0
  10. package/dist/types/env.d.ts +3 -1
  11. package/dist/types/generation-manager.d.ts +6 -0
  12. package/dist/types/tsconfig.tsbuildinfo +1 -1
  13. package/dist/types/vite-plugin/dev-server-plugin.d.ts +8 -0
  14. package/dist/types/worker-thread/executor.d.ts +2 -0
  15. package/dist/types/worker-thread/protocol.d.ts +2 -0
  16. package/dist/types/worker-thread/thread-env.d.ts +3 -1
  17. package/package.json +1 -1
  18. package/src/bindings/ai-search.ts +185 -0
  19. package/src/bindings/analytics-engine-sql.ts +1138 -0
  20. package/src/bindings/artifacts-git-http.ts +249 -0
  21. package/src/bindings/artifacts.ts +453 -0
  22. package/src/bindings/do-worker-env.ts +4 -0
  23. package/src/bindings/flagship.ts +117 -0
  24. package/src/bindings/vpc-network.ts +40 -0
  25. package/src/bindings/worker-loader-entry.ts +143 -0
  26. package/src/bindings/worker-loader.ts +325 -0
  27. package/src/cli/dev.ts +25 -3
  28. package/src/config.ts +20 -0
  29. package/src/db.ts +54 -0
  30. package/src/env.ts +71 -0
  31. package/src/generation-manager.ts +5 -1
  32. package/src/generation.ts +20 -3
  33. package/src/plugin.ts +37 -0
  34. package/src/vite-plugin/dev-server-plugin.ts +18 -2
  35. package/src/worker-thread/entry.ts +1 -0
  36. package/src/worker-thread/executor.ts +3 -0
  37. package/src/worker-thread/protocol.ts +2 -0
  38. package/src/worker-thread/thread-env.ts +72 -1
@@ -0,0 +1,117 @@
1
+ import type { Database } from 'bun:sqlite'
2
+
3
+ export type FlagType = 'boolean' | 'string' | 'number' | 'object'
4
+
5
+ export interface FlagDetails<T> {
6
+ value: T
7
+ variant?: string
8
+ reason: 'STATIC' | 'DEFAULT' | 'ERROR' | 'TARGETING_MATCH'
9
+ errorCode?: string
10
+ }
11
+
12
+ type EvaluationContext = Record<string, unknown> | undefined
13
+
14
+ interface FlagRow {
15
+ type: string
16
+ value: string
17
+ variant: string | null
18
+ }
19
+
20
+ /**
21
+ * Local implementation of the Cloudflare Flagship feature-flag binding.
22
+ * Flag values are stored in SQLite (`flagship_flags` table). When a flag is
23
+ * not found, the caller's `defaultValue` is returned with reason `DEFAULT`.
24
+ * Local dev does not implement targeting rules — the `context` argument is
25
+ * accepted for API compatibility but ignored during evaluation.
26
+ */
27
+ export class FlagshipBinding {
28
+ private db: Database
29
+ private appId: string
30
+
31
+ constructor(db: Database, appId: string) {
32
+ this.db = db
33
+ this.appId = appId
34
+ }
35
+
36
+ async getBooleanValue(key: string, defaultValue: boolean, context?: EvaluationContext): Promise<boolean> {
37
+ return (await this.getBooleanValueDetails(key, defaultValue, context)).value
38
+ }
39
+
40
+ async getStringValue(key: string, defaultValue: string, context?: EvaluationContext): Promise<string> {
41
+ return (await this.getStringValueDetails(key, defaultValue, context)).value
42
+ }
43
+
44
+ async getNumberValue(key: string, defaultValue: number, context?: EvaluationContext): Promise<number> {
45
+ return (await this.getNumberValueDetails(key, defaultValue, context)).value
46
+ }
47
+
48
+ async getObjectValue<T>(key: string, defaultValue: T, context?: EvaluationContext): Promise<T> {
49
+ return (await this.getObjectValueDetails(key, defaultValue, context)).value
50
+ }
51
+
52
+ async getBooleanValueDetails(key: string, defaultValue: boolean, _context?: EvaluationContext): Promise<FlagDetails<boolean>> {
53
+ return this.evaluate('boolean', key, defaultValue, parseBoolean)
54
+ }
55
+
56
+ async getStringValueDetails(key: string, defaultValue: string, _context?: EvaluationContext): Promise<FlagDetails<string>> {
57
+ return this.evaluate('string', key, defaultValue, (v) => v)
58
+ }
59
+
60
+ async getNumberValueDetails(key: string, defaultValue: number, _context?: EvaluationContext): Promise<FlagDetails<number>> {
61
+ return this.evaluate('number', key, defaultValue, (v) => Number(v))
62
+ }
63
+
64
+ async getObjectValueDetails<T>(key: string, defaultValue: T, _context?: EvaluationContext): Promise<FlagDetails<T>> {
65
+ return this.evaluate('object', key, defaultValue, (v) => JSON.parse(v) as T)
66
+ }
67
+
68
+ private evaluate<T>(expectedType: FlagType, key: string, defaultValue: T, parse: (raw: string) => T): FlagDetails<T> {
69
+ const row = this.db
70
+ .query<FlagRow, [string, string]>('SELECT type, value, variant FROM flagship_flags WHERE app_id = ? AND flag_key = ?')
71
+ .get(this.appId, key)
72
+ if (!row) {
73
+ return { value: defaultValue, reason: 'DEFAULT' }
74
+ }
75
+ if (row.type !== expectedType) {
76
+ return { value: defaultValue, reason: 'ERROR', errorCode: 'TYPE_MISMATCH' }
77
+ }
78
+ try {
79
+ const parsed = parse(row.value)
80
+ return { value: parsed, variant: row.variant ?? undefined, reason: 'STATIC' }
81
+ } catch {
82
+ return { value: defaultValue, reason: 'ERROR', errorCode: 'PARSE_ERROR' }
83
+ }
84
+ }
85
+ }
86
+
87
+ function parseBoolean(raw: string): boolean {
88
+ const v = raw.toLowerCase()
89
+ if (v === 'true' || v === '1') return true
90
+ if (v === 'false' || v === '0') return false
91
+ throw new Error(`invalid boolean flag value: ${raw}`)
92
+ }
93
+
94
+ /**
95
+ * Programmatic helper to seed / override a flag value in the local store.
96
+ * Used by tests and (later) the dashboard.
97
+ */
98
+ export function setFlagValue(
99
+ db: Database,
100
+ appId: string,
101
+ key: string,
102
+ type: FlagType,
103
+ value: unknown,
104
+ variant?: string,
105
+ ): void {
106
+ const raw = type === 'object' ? JSON.stringify(value) : String(value)
107
+ db.run(
108
+ `INSERT INTO flagship_flags (app_id, flag_key, type, value, variant, updated_at)
109
+ VALUES (?, ?, ?, ?, ?, ?)
110
+ ON CONFLICT(app_id, flag_key) DO UPDATE SET type = excluded.type, value = excluded.value, variant = excluded.variant, updated_at = excluded.updated_at`,
111
+ [appId, key, type, raw, variant ?? null, Date.now()],
112
+ )
113
+ }
114
+
115
+ export function deleteFlag(db: Database, appId: string, key: string): void {
116
+ db.run('DELETE FROM flagship_flags WHERE app_id = ? AND flag_key = ?', [appId, key])
117
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Local implementation of the Cloudflare VPC Networks binding (`vpc_networks`).
3
+ *
4
+ * On Cloudflare, this binding exposes a Fetcher-like `fetch()` method that
5
+ * routes the request through the bound tunnel or Cloudflare Mesh. The URL
6
+ * passed to `fetch()` determines the destination host/port.
7
+ *
8
+ * In local dev there is no tunnel overlay network, so we pass the request
9
+ * straight to the host system's stack — the caller is expected to arrange
10
+ * for the destination to be reachable locally (e.g. a dev service on a
11
+ * known port, or a VPN/WireGuard tunnel on the host).
12
+ */
13
+
14
+ export interface VpcNetworkConfig {
15
+ networkId: string
16
+ bindingName: string
17
+ }
18
+
19
+ export class VpcNetworkBinding {
20
+ private config: VpcNetworkConfig
21
+
22
+ constructor(config: VpcNetworkConfig) {
23
+ this.config = config
24
+ }
25
+
26
+ get networkId(): string {
27
+ return this.config.networkId
28
+ }
29
+
30
+ async fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
31
+ const request = input instanceof Request ? new Request(input, init) : new Request(input.toString(), init)
32
+ const url = new URL(request.url)
33
+ if (!url.host) {
34
+ throw new Error(
35
+ `VPC Network binding "${this.config.bindingName}" requires an absolute URL (with host), got: ${request.url}`,
36
+ )
37
+ }
38
+ return fetch(request)
39
+ }
40
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Worker-thread entry for the `worker_loaders` binding.
3
+ *
4
+ * The main thread spawns this file as a Worker, sends an init message with
5
+ * the path to the dynamically-written main module + a serializable `env`
6
+ * object, then dispatches RPC commands. Each command invokes either the
7
+ * default export's `fetch()` (HTTP), `scheduled()` (cron), or a named method
8
+ * on a named entrypoint class.
9
+ *
10
+ * This is "fuzzy" isolation — the worker thread has its own heap and module
11
+ * graph but shares the same process, so `fetch()` / `connect()` / filesystem
12
+ * access behave identically to the parent.
13
+ */
14
+
15
+ declare var self: Worker
16
+
17
+ export interface LoaderInitMessage {
18
+ type: 'init'
19
+ mainModulePath: string
20
+ env: unknown
21
+ globalOutbound: 'allow' | 'block'
22
+ }
23
+
24
+ export type LoaderCommand =
25
+ | { type: 'fetch'; entrypoint?: string; url: string; method: string; headers: [string, string][]; body: ArrayBuffer | null }
26
+ | { type: 'scheduled'; entrypoint?: string; cron: string; scheduledTime: number }
27
+ | { type: 'rpc-call'; entrypoint?: string; method: string; args: unknown[] }
28
+
29
+ export type LoaderResult =
30
+ | { type: 'fetch'; status: number; statusText: string; headers: [string, string][]; body: ArrayBuffer | null }
31
+ | { type: 'scheduled' }
32
+ | { type: 'rpc-call'; value: unknown }
33
+ | { type: 'error'; message: string; stack?: string; name?: string }
34
+
35
+ export type MainToWorker = { type: 'init'; data: LoaderInitMessage } | { type: 'command'; id: number; command: LoaderCommand }
36
+ export type WorkerToMain = { type: 'need-init' } | { type: 'ready' } | { type: 'result'; id: number; result: LoaderResult }
37
+
38
+ let loadedModule: Record<string, unknown> | null = null
39
+ let loaderEnv: unknown = {}
40
+ let networkBlocked = false
41
+
42
+ self.onmessage = async (event: MessageEvent<MainToWorker>) => {
43
+ const msg = event.data
44
+ if (msg.type === 'init') {
45
+ try {
46
+ await init(msg.data)
47
+ self.postMessage({ type: 'ready' } satisfies WorkerToMain)
48
+ } catch (err) {
49
+ const error = err instanceof Error ? err : new Error(String(err))
50
+ self.postMessage(
51
+ {
52
+ type: 'result',
53
+ id: -1,
54
+ result: { type: 'error', message: `Worker init failed: ${error.message}`, stack: error.stack, name: error.name },
55
+ } satisfies WorkerToMain,
56
+ )
57
+ }
58
+ return
59
+ }
60
+ if (msg.type === 'command') {
61
+ const result = await handleCommand(msg.command)
62
+ self.postMessage({ type: 'result', id: msg.id, result } satisfies WorkerToMain)
63
+ }
64
+ }
65
+
66
+ self.postMessage({ type: 'need-init' } satisfies WorkerToMain)
67
+
68
+ async function init(data: LoaderInitMessage): Promise<void> {
69
+ loaderEnv = data.env ?? {}
70
+ networkBlocked = data.globalOutbound === 'block'
71
+ if (networkBlocked) {
72
+ const originalFetch = globalThis.fetch
73
+ globalThis.fetch = ((() => {
74
+ throw new Error('Dynamic Worker has globalOutbound=null — fetch() is blocked')
75
+ }) as unknown) as typeof globalThis.fetch // Keep reference so any test that stubs fetch is still observable
76
+ ;(globalThis as unknown as { __lopata_original_fetch?: typeof originalFetch }).__lopata_original_fetch = originalFetch
77
+ }
78
+ loadedModule = await import(data.mainModulePath) as Record<string, unknown>
79
+ }
80
+
81
+ function resolveEntrypoint(name?: string): Record<string, unknown> {
82
+ if (!loadedModule) throw new Error('Worker not initialized')
83
+ if (name) {
84
+ const exp = loadedModule[name]
85
+ if (!exp) throw new Error(`Entrypoint "${name}" not exported from main module`)
86
+ // If the export is a class, instantiate it (WorkerEntrypoint-style)
87
+ if (typeof exp === 'function') {
88
+ const ctx = { waitUntil() {}, passThroughOnException() {} }
89
+ try {
90
+ return new (exp as new(ctx: unknown, env: unknown) => Record<string, unknown>)(ctx, loaderEnv)
91
+ } catch {
92
+ return exp as unknown as Record<string, unknown>
93
+ }
94
+ }
95
+ return exp as Record<string, unknown>
96
+ }
97
+ const def = loadedModule.default
98
+ if (!def) throw new Error('Main module has no default export')
99
+ return def as Record<string, unknown>
100
+ }
101
+
102
+ async function handleCommand(cmd: LoaderCommand): Promise<LoaderResult> {
103
+ try {
104
+ const entrypoint = resolveEntrypoint(cmd.entrypoint)
105
+ if (cmd.type === 'fetch') {
106
+ const handler = entrypoint.fetch as ((req: Request, env: unknown, ctx: unknown) => Response | Promise<Response>) | undefined
107
+ if (typeof handler !== 'function') throw new Error('Entrypoint has no fetch() handler')
108
+ const request = new Request(cmd.url, {
109
+ method: cmd.method,
110
+ headers: cmd.headers,
111
+ body: cmd.body ? cmd.body : null,
112
+ })
113
+ const ctx = { waitUntil(_p: Promise<unknown>) {}, passThroughOnException() {} }
114
+ const response = await handler.call(entrypoint, request, loaderEnv, ctx)
115
+ const buf = await response.arrayBuffer()
116
+ return {
117
+ type: 'fetch',
118
+ status: response.status,
119
+ statusText: response.statusText,
120
+ headers: Array.from(response.headers.entries()),
121
+ body: buf.byteLength ? buf : null,
122
+ }
123
+ }
124
+ if (cmd.type === 'scheduled') {
125
+ const handler = entrypoint.scheduled as ((event: unknown, env: unknown, ctx: unknown) => unknown) | undefined
126
+ if (typeof handler !== 'function') throw new Error('Entrypoint has no scheduled() handler')
127
+ const event = { cron: cmd.cron, scheduledTime: cmd.scheduledTime, type: 'scheduled' }
128
+ const ctx = { waitUntil(_p: Promise<unknown>) {}, passThroughOnException() {} }
129
+ await handler.call(entrypoint, event, loaderEnv, ctx)
130
+ return { type: 'scheduled' }
131
+ }
132
+ if (cmd.type === 'rpc-call') {
133
+ const fn = entrypoint[cmd.method]
134
+ if (typeof fn !== 'function') throw new Error(`Method "${cmd.method}" is not a function on entrypoint`)
135
+ const value = await (fn as (...args: unknown[]) => unknown).apply(entrypoint, cmd.args)
136
+ return { type: 'rpc-call', value }
137
+ }
138
+ throw new Error(`Unknown command type: ${(cmd as { type: string }).type}`)
139
+ } catch (err) {
140
+ const error = err instanceof Error ? err : new Error(String(err))
141
+ return { type: 'error', message: error.message, stack: error.stack, name: error.name }
142
+ }
143
+ }
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Local implementation of the Cloudflare `worker_loaders` binding.
3
+ *
4
+ * Exposes `env.LOADER.load(code)` / `env.LOADER.get(id, callback)`. Each load
5
+ * spawns a Bun Worker thread (see `worker-loader-entry.ts`) that runs the
6
+ * supplied code with its own module graph. Isolation is "fuzzy" — worker
7
+ * threads share the process with the parent but have separate heaps.
8
+ *
9
+ * Modules are written to `.lopata/worker-loader/<stub-id>/` before the Worker
10
+ * imports the entry point. This keeps the implementation simple at the cost
11
+ * of not supporting languages Bun can't execute directly (CF's `py`, Rust,
12
+ * etc. are rejected in v1).
13
+ */
14
+
15
+ import { randomUUIDv7 } from 'bun'
16
+ import { mkdirSync, rmSync } from 'node:fs'
17
+ import { dirname, join, resolve } from 'node:path'
18
+ import type { LoaderCommand, LoaderInitMessage, LoaderResult, MainToWorker, WorkerToMain } from './worker-loader-entry'
19
+
20
+ export type WorkerCodeModule =
21
+ | string
22
+ | { js: string }
23
+ | { cjs: string }
24
+ | { text: string }
25
+ | { data: ArrayBuffer }
26
+ | { json: unknown }
27
+
28
+ export interface WorkerCode {
29
+ compatibilityDate: string
30
+ compatibilityFlags?: string[]
31
+ mainModule: string
32
+ modules: Record<string, WorkerCodeModule>
33
+ env?: Record<string, unknown>
34
+ globalOutbound?: unknown | null
35
+ allowExperimental?: boolean
36
+ tails?: unknown[]
37
+ limits?: { cpuMs?: number; subRequests?: number }
38
+ }
39
+
40
+ const WORKER_ENTRY_PATH = resolve(dirname(new URL(import.meta.url).pathname), 'worker-loader-entry.ts')
41
+
42
+ interface PendingCommand {
43
+ resolve: (result: LoaderResult) => void
44
+ reject: (err: Error) => void
45
+ }
46
+
47
+ type CodeSource = WorkerCode | (() => WorkerCode | Promise<WorkerCode>)
48
+
49
+ export class WorkerStub {
50
+ private readonly id: string
51
+ private readonly codeSource: CodeSource
52
+ private _resolvedCode: WorkerCode | null = null
53
+ private readonly workDir: string
54
+ private _worker: Worker | null = null
55
+ private _ready: Promise<void> | null = null
56
+ private _pending = new Map<number, PendingCommand>()
57
+ private _nextCmdId = 1
58
+ private _disposed = false
59
+ private _disposeTimer: Timer | null = null
60
+
61
+ constructor(id: string, code: CodeSource, baseDir: string) {
62
+ this.id = id
63
+ this.codeSource = code
64
+ this.workDir = join(baseDir, id)
65
+ if (typeof code !== 'function') {
66
+ this._resolvedCode = code
67
+ }
68
+ }
69
+
70
+ private async _resolveCode(): Promise<WorkerCode> {
71
+ if (this._resolvedCode) return this._resolvedCode
72
+ const code = await (this.codeSource as () => WorkerCode | Promise<WorkerCode>)()
73
+ validateCode(code)
74
+ this._resolvedCode = code
75
+ return code
76
+ }
77
+
78
+ private async _ensureReady(): Promise<void> {
79
+ if (this._ready) return this._ready
80
+ this._ready = this._boot()
81
+ return this._ready
82
+ }
83
+
84
+ private async _boot(): Promise<void> {
85
+ const code = await this._resolveCode()
86
+ mkdirSync(this.workDir, { recursive: true })
87
+ const mainPath = await writeModules(this.workDir, code.modules, code.mainModule)
88
+
89
+ const worker = new Worker(WORKER_ENTRY_PATH)
90
+ this._worker = worker
91
+
92
+ let readyResolve: () => void
93
+ let readyReject: (err: Error) => void
94
+ const ready = new Promise<void>((res, rej) => {
95
+ readyResolve = res
96
+ readyReject = rej
97
+ })
98
+
99
+ worker.onmessage = (event: MessageEvent<WorkerToMain>) => {
100
+ const msg = event.data
101
+ if (msg.type === 'need-init') {
102
+ const init: LoaderInitMessage = {
103
+ type: 'init',
104
+ mainModulePath: mainPath,
105
+ env: sanitizeEnv(code.env),
106
+ globalOutbound: code.globalOutbound === null ? 'block' : 'allow',
107
+ }
108
+ worker.postMessage({ type: 'init', data: init } satisfies MainToWorker)
109
+ return
110
+ }
111
+ if (msg.type === 'ready') {
112
+ readyResolve()
113
+ return
114
+ }
115
+ if (msg.type === 'result') {
116
+ if (msg.id === -1 && msg.result.type === 'error') {
117
+ readyReject(new Error(msg.result.message))
118
+ return
119
+ }
120
+ const pending = this._pending.get(msg.id)
121
+ if (!pending) return
122
+ this._pending.delete(msg.id)
123
+ if (msg.result.type === 'error') {
124
+ const err = new Error(msg.result.message)
125
+ if (msg.result.stack) err.stack = msg.result.stack
126
+ if (msg.result.name) err.name = msg.result.name
127
+ pending.reject(err)
128
+ } else {
129
+ pending.resolve(msg.result)
130
+ }
131
+ }
132
+ }
133
+
134
+ worker.onerror = (err: ErrorEvent) => {
135
+ readyReject(new Error(err.message || 'Worker error'))
136
+ for (const pending of this._pending.values()) {
137
+ pending.reject(new Error(err.message || 'Worker error'))
138
+ }
139
+ this._pending.clear()
140
+ }
141
+
142
+ await ready
143
+ }
144
+
145
+ private _send(command: LoaderCommand): Promise<LoaderResult> {
146
+ if (this._disposed) throw new Error('WorkerStub has been disposed')
147
+ const id = this._nextCmdId++
148
+ return new Promise((resolve, reject) => {
149
+ this._pending.set(id, { resolve, reject })
150
+ this._ensureReady()
151
+ .then(() => {
152
+ this._worker!.postMessage({ type: 'command', id, command } satisfies MainToWorker)
153
+ })
154
+ .catch(err => {
155
+ this._pending.delete(id)
156
+ reject(err)
157
+ })
158
+ })
159
+ }
160
+
161
+ getEntrypoint(name?: string): EntrypointProxy {
162
+ return createEntrypointProxy(this, name)
163
+ }
164
+
165
+ /** @internal */
166
+ async _fetch(entrypoint: string | undefined, request: Request): Promise<Response> {
167
+ const body = request.body ? await request.arrayBuffer() : null
168
+ const result = await this._send({
169
+ type: 'fetch',
170
+ entrypoint,
171
+ url: request.url,
172
+ method: request.method,
173
+ headers: Array.from(request.headers.entries()),
174
+ body,
175
+ })
176
+ if (result.type !== 'fetch') throw new Error(`Unexpected result type: ${result.type}`)
177
+ return new Response(result.body, {
178
+ status: result.status,
179
+ statusText: result.statusText,
180
+ headers: result.headers,
181
+ })
182
+ }
183
+
184
+ /** @internal */
185
+ async _scheduled(entrypoint: string | undefined, cron: string, scheduledTime: number): Promise<void> {
186
+ const result = await this._send({ type: 'scheduled', entrypoint, cron, scheduledTime })
187
+ if (result.type !== 'scheduled') throw new Error(`Unexpected result type: ${result.type}`)
188
+ }
189
+
190
+ /** @internal */
191
+ async _rpcCall(entrypoint: string | undefined, method: string, args: unknown[]): Promise<unknown> {
192
+ const result = await this._send({ type: 'rpc-call', entrypoint, method, args })
193
+ if (result.type !== 'rpc-call') throw new Error(`Unexpected result type: ${result.type}`)
194
+ return result.value
195
+ }
196
+
197
+ dispose(): void {
198
+ if (this._disposed) return
199
+ this._disposed = true
200
+ if (this._disposeTimer) clearTimeout(this._disposeTimer)
201
+ if (this._worker) {
202
+ try {
203
+ this._worker.terminate()
204
+ } catch {}
205
+ }
206
+ try {
207
+ rmSync(this.workDir, { recursive: true, force: true })
208
+ } catch {}
209
+ }
210
+ }
211
+
212
+ export interface EntrypointProxy {
213
+ fetch(request: Request | string | URL, init?: RequestInit): Promise<Response>
214
+ scheduled(event: { cron: string; scheduledTime?: number }): Promise<void>
215
+ [method: string]: unknown
216
+ }
217
+
218
+ function createEntrypointProxy(stub: WorkerStub, entrypoint?: string): EntrypointProxy {
219
+ const target = Object.create(null) as EntrypointProxy
220
+ return new Proxy(target, {
221
+ get(_t, prop: string | symbol) {
222
+ if (typeof prop !== 'string') return undefined
223
+ if (prop === 'fetch') {
224
+ return async (input: Request | string | URL, init?: RequestInit) => {
225
+ const request = input instanceof Request ? new Request(input, init) : new Request(input.toString(), init)
226
+ return stub._fetch(entrypoint, request)
227
+ }
228
+ }
229
+ if (prop === 'scheduled') {
230
+ return async (event: { cron?: string; scheduledTime?: number }) => {
231
+ return stub._scheduled(entrypoint, event.cron ?? '* * * * *', event.scheduledTime ?? Date.now())
232
+ }
233
+ }
234
+ if (prop === 'then' || prop === 'catch' || prop === 'finally') return undefined
235
+ return (...args: unknown[]) => stub._rpcCall(entrypoint, prop, args)
236
+ },
237
+ }) as EntrypointProxy
238
+ }
239
+
240
+ export class WorkerLoaderBinding {
241
+ private readonly baseDir: string
242
+ private readonly cache = new Map<string, WorkerStub>()
243
+
244
+ constructor(baseDir: string) {
245
+ this.baseDir = baseDir
246
+ mkdirSync(this.baseDir, { recursive: true })
247
+ }
248
+
249
+ load(code: WorkerCode): WorkerStub {
250
+ validateCode(code)
251
+ const id = randomUUIDv7()
252
+ return new WorkerStub(id, code, this.baseDir)
253
+ }
254
+
255
+ get(id: string, getCodeCallback: () => Promise<WorkerCode> | WorkerCode): WorkerStub {
256
+ const existing = this.cache.get(id)
257
+ if (existing) return existing
258
+ // Defer invoking the callback until the stub is first used — matches
259
+ // Cloudflare's behavior (get() returns synchronously, code resolution
260
+ // happens lazily on first fetch/rpc call).
261
+ const stub = new WorkerStub(id, getCodeCallback, this.baseDir)
262
+ this.cache.set(id, stub)
263
+ return stub
264
+ }
265
+
266
+ disposeAll(): void {
267
+ for (const stub of this.cache.values()) stub.dispose()
268
+ this.cache.clear()
269
+ }
270
+ }
271
+
272
+ function validateCode(code: WorkerCode): void {
273
+ if (!code.compatibilityDate) throw new Error('WorkerCode.compatibilityDate is required')
274
+ if (!code.mainModule) throw new Error('WorkerCode.mainModule is required')
275
+ if (!code.modules || typeof code.modules !== 'object') throw new Error('WorkerCode.modules must be an object')
276
+ if (!(code.mainModule in code.modules)) throw new Error(`mainModule "${code.mainModule}" not present in modules map`)
277
+ }
278
+
279
+ function sanitizeEnv(env: unknown): unknown {
280
+ if (env == null) return {}
281
+ try {
282
+ // Drop values that can't be structured-cloned (functions, classes, etc.)
283
+ return JSON.parse(JSON.stringify(env))
284
+ } catch {
285
+ return {}
286
+ }
287
+ }
288
+
289
+ async function writeModules(
290
+ workDir: string,
291
+ modules: Record<string, WorkerCodeModule>,
292
+ mainModule: string,
293
+ ): Promise<string> {
294
+ let mainPath = ''
295
+ for (const [name, content] of Object.entries(modules)) {
296
+ const filePath = join(workDir, name)
297
+ mkdirSync(dirname(filePath), { recursive: true })
298
+ const resolved = resolveModuleContent(name, content)
299
+ if (resolved.kind === 'binary') {
300
+ await Bun.write(filePath, resolved.body)
301
+ } else {
302
+ await Bun.write(filePath, resolved.body)
303
+ }
304
+ if (name === mainModule) mainPath = filePath
305
+ }
306
+ if (!mainPath) throw new Error(`mainModule "${mainModule}" could not be written`)
307
+ return mainPath
308
+ }
309
+
310
+ type ResolvedModule = { kind: 'text'; body: string } | { kind: 'binary'; body: ArrayBuffer }
311
+
312
+ function resolveModuleContent(name: string, mod: WorkerCodeModule): ResolvedModule {
313
+ if (typeof mod === 'string') return { kind: 'text', body: mod }
314
+ if ('js' in mod) return { kind: 'text', body: mod.js }
315
+ if ('cjs' in mod) return { kind: 'text', body: mod.cjs }
316
+ if ('text' in mod) return { kind: 'text', body: mod.text }
317
+ if ('json' in mod) {
318
+ // Write raw JSON so the `.json` suffix triggers Bun's built-in JSON import; for
319
+ // non-.json names, fall back to an ES module default export.
320
+ if (name.endsWith('.json')) return { kind: 'text', body: JSON.stringify(mod.json) }
321
+ return { kind: 'text', body: `export default ${JSON.stringify(mod.json)}` }
322
+ }
323
+ if ('data' in mod) return { kind: 'binary', body: mod.data }
324
+ throw new Error(`Unsupported module type for "${name}": ${JSON.stringify(Object.keys(mod))}`)
325
+ }