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.
- package/dist/types/bindings/ai-search.d.ts +44 -0
- package/dist/types/bindings/analytics-engine-sql.d.ts +107 -0
- package/dist/types/bindings/artifacts-git-http.d.ts +19 -0
- package/dist/types/bindings/artifacts.d.ts +108 -0
- package/dist/types/bindings/flagship.d.ts +37 -0
- package/dist/types/bindings/vpc-network.d.ts +22 -0
- package/dist/types/bindings/worker-loader-entry.d.ts +71 -0
- package/dist/types/bindings/worker-loader.d.ts +81 -0
- package/dist/types/config.d.ts +23 -0
- package/dist/types/env.d.ts +3 -1
- package/dist/types/generation-manager.d.ts +6 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dist/types/vite-plugin/dev-server-plugin.d.ts +8 -0
- package/dist/types/worker-thread/executor.d.ts +2 -0
- package/dist/types/worker-thread/protocol.d.ts +2 -0
- package/dist/types/worker-thread/thread-env.d.ts +3 -1
- package/package.json +1 -1
- package/src/bindings/ai-search.ts +185 -0
- package/src/bindings/analytics-engine-sql.ts +1138 -0
- package/src/bindings/artifacts-git-http.ts +249 -0
- package/src/bindings/artifacts.ts +453 -0
- package/src/bindings/do-worker-env.ts +4 -0
- package/src/bindings/flagship.ts +117 -0
- package/src/bindings/vpc-network.ts +40 -0
- package/src/bindings/worker-loader-entry.ts +143 -0
- package/src/bindings/worker-loader.ts +325 -0
- package/src/cli/dev.ts +25 -3
- package/src/config.ts +20 -0
- package/src/db.ts +54 -0
- package/src/env.ts +71 -0
- package/src/generation-manager.ts +5 -1
- package/src/generation.ts +20 -3
- package/src/plugin.ts +37 -0
- package/src/vite-plugin/dev-server-plugin.ts +18 -2
- package/src/worker-thread/entry.ts +1 -0
- package/src/worker-thread/executor.ts +3 -0
- package/src/worker-thread/protocol.ts +2 -0
- 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
|
+
}
|