1tube 0.1.21 → 0.1.23
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/deno.json +1 -0
- package/deno.lock +41 -0
- package/package.json +1 -1
- package/src/backends/deno/dep-graph.ts +233 -0
- package/src/backends/deno/hot-reloader.ts +304 -0
- package/src/backends/deno/worker-entry.ts +202 -0
- package/src/backends/deno/worker-host.ts +466 -0
- package/src/discovery.ts +10 -31
- package/src/registry.ts +55 -2
- package/src/server.ts +230 -577
- package/src/deno-hmr-snapshot.ts +0 -159
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-side entry point used by the Deno backend.
|
|
3
|
+
*
|
|
4
|
+
* One Worker per function. The Worker's lifecycle:
|
|
5
|
+
*
|
|
6
|
+
* host → init : { entryUrl, manifest, name }
|
|
7
|
+
* Worker installs the registry stub, dynamically
|
|
8
|
+
* imports the function entry. The entry's top-level
|
|
9
|
+
* `serve()` call lands in our stub, which posts back
|
|
10
|
+
* `{ type: "ready", isPublic, timeoutMs, manifest }`.
|
|
11
|
+
*
|
|
12
|
+
* host → dispatch : { id, url, method, headers, body, auth }
|
|
13
|
+
* Body comes through as a transferred ReadableStream
|
|
14
|
+
* or null. We rebuild a Request and call the captured
|
|
15
|
+
* handler. The Response (status + headers + body) is
|
|
16
|
+
* posted back, body transferred.
|
|
17
|
+
*
|
|
18
|
+
* host ← error : handler threw, or unhandledrejection inside the
|
|
19
|
+
* function code. The host forwards to the supervisor.
|
|
20
|
+
*
|
|
21
|
+
* HMR: the host calls `worker.terminate()`. Termination drops Deno's
|
|
22
|
+
* module cache for everything in the worker's graph, so the next
|
|
23
|
+
* spawned Worker imports the entry from a clean slate. No filesystem
|
|
24
|
+
* snapshots, no `?v=` cache-bust hacks.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/// <reference lib="deno.worker" />
|
|
28
|
+
|
|
29
|
+
import type { FunctionManifest } from "../../manifest.ts";
|
|
30
|
+
import type { AuthContext } from "../../registry.ts";
|
|
31
|
+
|
|
32
|
+
interface InitMessage {
|
|
33
|
+
type: "init";
|
|
34
|
+
name: string;
|
|
35
|
+
entryUrl: string;
|
|
36
|
+
manifest: FunctionManifest;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface DispatchMessage {
|
|
40
|
+
type: "dispatch";
|
|
41
|
+
id: number;
|
|
42
|
+
url: string;
|
|
43
|
+
method: string;
|
|
44
|
+
headers: Array<[string, string]>;
|
|
45
|
+
body: ReadableStream<Uint8Array> | null;
|
|
46
|
+
auth: AuthContext | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type HostMessage = InitMessage | DispatchMessage;
|
|
50
|
+
|
|
51
|
+
type Handler = (req: Request, auth?: AuthContext) => Response | Promise<Response>;
|
|
52
|
+
|
|
53
|
+
interface RegistryStub {
|
|
54
|
+
register(handler: Handler, opts: { public: boolean; timeoutMs?: number }): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface CapturedHandler {
|
|
58
|
+
handler: Handler;
|
|
59
|
+
isPublic: boolean;
|
|
60
|
+
timeoutMs?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let captured: CapturedHandler | null = null;
|
|
64
|
+
let functionName = "<unknown>";
|
|
65
|
+
let manifest: FunctionManifest | null = null;
|
|
66
|
+
let initDone = false;
|
|
67
|
+
|
|
68
|
+
const stub: RegistryStub = {
|
|
69
|
+
register(handler, opts) {
|
|
70
|
+
if (captured) {
|
|
71
|
+
// The function called serve() twice. Last write wins, matching the
|
|
72
|
+
// in-process registry's behaviour. Surface a warning so authors notice
|
|
73
|
+
// the bug.
|
|
74
|
+
console.warn(`[1tube] "${functionName}" called serve() twice; using the last handler`);
|
|
75
|
+
}
|
|
76
|
+
captured = {
|
|
77
|
+
handler,
|
|
78
|
+
isPublic: opts.public,
|
|
79
|
+
timeoutMs: opts.timeoutMs,
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
(globalThis as { __edgeFunctionRegistry?: RegistryStub }).__edgeFunctionRegistry =
|
|
85
|
+
stub;
|
|
86
|
+
|
|
87
|
+
self.addEventListener("unhandledrejection", (e) => {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
const err = (e as PromiseRejectionEvent).reason;
|
|
90
|
+
postMessage({
|
|
91
|
+
type: "unhandledrejection",
|
|
92
|
+
name: functionName,
|
|
93
|
+
message: err instanceof Error ? err.message : String(err),
|
|
94
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
self.addEventListener("error", (e) => {
|
|
99
|
+
const err = (e as ErrorEvent).error ?? (e as ErrorEvent).message;
|
|
100
|
+
postMessage({
|
|
101
|
+
type: "error_event",
|
|
102
|
+
name: functionName,
|
|
103
|
+
message: err instanceof Error ? err.message : String(err),
|
|
104
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
async function runDispatch(msg: DispatchMessage): Promise<void> {
|
|
109
|
+
if (!captured) {
|
|
110
|
+
postMessage({
|
|
111
|
+
type: "response_error",
|
|
112
|
+
id: msg.id,
|
|
113
|
+
message: `Function "${functionName}" did not call serve() before dispatch`,
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const headers = new Headers(msg.headers);
|
|
118
|
+
const init: RequestInit = {
|
|
119
|
+
method: msg.method,
|
|
120
|
+
headers,
|
|
121
|
+
};
|
|
122
|
+
if (msg.body !== null) {
|
|
123
|
+
init.body = msg.body;
|
|
124
|
+
// Half-duplex marker required by Deno when constructing a Request with
|
|
125
|
+
// a streaming body.
|
|
126
|
+
(init as unknown as { duplex: "half" }).duplex = "half";
|
|
127
|
+
}
|
|
128
|
+
const req = new Request(msg.url, init);
|
|
129
|
+
let resp: Response;
|
|
130
|
+
try {
|
|
131
|
+
resp = await Promise.resolve(
|
|
132
|
+
captured.isPublic
|
|
133
|
+
? captured.handler(req)
|
|
134
|
+
: captured.handler(req, msg.auth ?? undefined),
|
|
135
|
+
);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
postMessage({
|
|
138
|
+
type: "response_error",
|
|
139
|
+
id: msg.id,
|
|
140
|
+
message: err instanceof Error ? err.message : String(err),
|
|
141
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const respHeaders: Array<[string, string]> = [];
|
|
146
|
+
resp.headers.forEach((v, k) => respHeaders.push([k, v]));
|
|
147
|
+
const body = resp.body;
|
|
148
|
+
// We need to send `body` (a ReadableStream) over postMessage. Transfer it
|
|
149
|
+
// explicitly so the host owns it; that also bypasses structured-clone
|
|
150
|
+
// limitations on Response objects.
|
|
151
|
+
postMessage(
|
|
152
|
+
{
|
|
153
|
+
type: "response",
|
|
154
|
+
id: msg.id,
|
|
155
|
+
status: resp.status,
|
|
156
|
+
headers: respHeaders,
|
|
157
|
+
body,
|
|
158
|
+
},
|
|
159
|
+
body ? [body as unknown as Transferable] : [],
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
self.onmessage = (ev: MessageEvent<HostMessage>) => {
|
|
164
|
+
const msg = ev.data;
|
|
165
|
+
if (msg.type === "init") {
|
|
166
|
+
if (initDone) return;
|
|
167
|
+
initDone = true;
|
|
168
|
+
functionName = msg.name;
|
|
169
|
+
manifest = msg.manifest;
|
|
170
|
+
void (async () => {
|
|
171
|
+
try {
|
|
172
|
+
await import(msg.entryUrl);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
postMessage({
|
|
175
|
+
type: "init_error",
|
|
176
|
+
message: err instanceof Error ? err.message : String(err),
|
|
177
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (!captured) {
|
|
182
|
+
postMessage({
|
|
183
|
+
type: "init_error",
|
|
184
|
+
message: `Function "${functionName}" loaded without calling serve()`,
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
postMessage({
|
|
189
|
+
type: "ready",
|
|
190
|
+
name: functionName,
|
|
191
|
+
isPublic: captured.isPublic,
|
|
192
|
+
timeoutMs: captured.timeoutMs,
|
|
193
|
+
manifest,
|
|
194
|
+
});
|
|
195
|
+
})();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (msg.type === "dispatch") {
|
|
199
|
+
void runDispatch(msg);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-per-function execution host for the Deno backend.
|
|
3
|
+
*
|
|
4
|
+
* Each function lives in its own Web Worker. HMR is just
|
|
5
|
+
*
|
|
6
|
+
* handle.terminate(); handle = spawnWorker(...);
|
|
7
|
+
*
|
|
8
|
+
* which drops every cached module in that worker's import graph at
|
|
9
|
+
* once. No filesystem snapshots, no `?v=` cache-bust hacks, no
|
|
10
|
+
* cross-function state leakage through globals.
|
|
11
|
+
*
|
|
12
|
+
* The host owns:
|
|
13
|
+
* - one Worker per function, addressable by name
|
|
14
|
+
* - in-flight request bookkeeping (correlate by numeric id)
|
|
15
|
+
* - per-function dep-graphs (for the hot reloader's affected set)
|
|
16
|
+
* - a single Deno.watchFs loop with a debounced flush
|
|
17
|
+
*
|
|
18
|
+
* The dispatch path is:
|
|
19
|
+
*
|
|
20
|
+
* gateway → handle.dispatch(req, auth) → postMessage to worker
|
|
21
|
+
* ↓
|
|
22
|
+
* worker rebuilds Request
|
|
23
|
+
* ↓
|
|
24
|
+
* handler(req, auth?)
|
|
25
|
+
* ↓
|
|
26
|
+
* postMessage(response)
|
|
27
|
+
* ↓
|
|
28
|
+
* gateway ← reconstructed Response
|
|
29
|
+
*
|
|
30
|
+
* Request and Response bodies are transferred as `ReadableStream`
|
|
31
|
+
* via the postMessage transfer list, so even multi-megabyte uploads
|
|
32
|
+
* don't get serialized through structured clone.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { pathToFileURL } from "node:url";
|
|
37
|
+
import { defaultManifest, type FunctionManifest, loadManifest } from "../../manifest.ts";
|
|
38
|
+
import type {
|
|
39
|
+
AuthContext,
|
|
40
|
+
FunctionRegistry,
|
|
41
|
+
WorkerFunctionHandle,
|
|
42
|
+
} from "../../registry.ts";
|
|
43
|
+
import type { FunctionSupervisor } from "../../supervisor.ts";
|
|
44
|
+
import { createDepGraph, type DepGraph } from "./dep-graph.ts";
|
|
45
|
+
|
|
46
|
+
export type DenoWorkerHandle = WorkerFunctionHandle;
|
|
47
|
+
|
|
48
|
+
export interface DenoWorkerHostOptions {
|
|
49
|
+
functionsDir: string;
|
|
50
|
+
registry: FunctionRegistry;
|
|
51
|
+
supervisor: FunctionSupervisor;
|
|
52
|
+
/** Debounce window in ms for batched fs change events. Defaults to 200ms. */
|
|
53
|
+
debounceMs?: number;
|
|
54
|
+
/** Max concurrent worker spawns at boot/reload. Defaults to 8. */
|
|
55
|
+
concurrency?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Parsed `imports` from the host project's deno.json. Forwarded to the
|
|
58
|
+
* dep-graph so import-map aliases resolve to the right file URLs. Optional.
|
|
59
|
+
*/
|
|
60
|
+
importMap?: Readonly<Record<string, string>>;
|
|
61
|
+
/** Absolute path to the deno.json the import map came from. Required when `importMap` is set. */
|
|
62
|
+
importMapBase?: string;
|
|
63
|
+
/** Hook fired on every reload completion (for tests / observability). */
|
|
64
|
+
onReloaded?: (summary: ReloadSummary) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ReloadSummary {
|
|
68
|
+
reason: string;
|
|
69
|
+
reloaded: string[];
|
|
70
|
+
added: string[];
|
|
71
|
+
removed: string[];
|
|
72
|
+
errors: Array<{ name: string; error: string }>;
|
|
73
|
+
durationMs: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface PendingRequest {
|
|
77
|
+
resolve: (resp: Response) => void;
|
|
78
|
+
reject: (err: unknown) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface InternalHandle extends WorkerFunctionHandle {
|
|
82
|
+
readonly worker: Worker;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* URL of the worker bootstrap module. Captured once so tests can override it
|
|
87
|
+
* via the option bag if they need to point at a fake.
|
|
88
|
+
*/
|
|
89
|
+
const DEFAULT_WORKER_ENTRY = new URL("./worker-entry.ts", import.meta.url).href;
|
|
90
|
+
|
|
91
|
+
interface SpawnOpts {
|
|
92
|
+
name: string;
|
|
93
|
+
entryUrl: string;
|
|
94
|
+
manifest: FunctionManifest;
|
|
95
|
+
workerEntry?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Forwarded to FunctionSupervisor when the worker reports an
|
|
98
|
+
* unhandled rejection. Lets the host attribute orphan rejections
|
|
99
|
+
* without going through AsyncLocalStorage.
|
|
100
|
+
*/
|
|
101
|
+
onUnhandledRejection?: (msg: string) => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function spawnWorker(opts: SpawnOpts): Promise<InternalHandle> {
|
|
105
|
+
return new Promise<InternalHandle>((resolve, reject) => {
|
|
106
|
+
const worker = new Worker(
|
|
107
|
+
opts.workerEntry ?? DEFAULT_WORKER_ENTRY,
|
|
108
|
+
{ type: "module", name: `1tube-fn-${opts.name}` },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const pending = new Map<number, PendingRequest>();
|
|
112
|
+
let nextId = 1;
|
|
113
|
+
let booted = false;
|
|
114
|
+
|
|
115
|
+
const finishBoot = (handle: InternalHandle) => {
|
|
116
|
+
booted = true;
|
|
117
|
+
resolve(handle);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const failBoot = (err: Error) => {
|
|
121
|
+
if (booted) return;
|
|
122
|
+
try {
|
|
123
|
+
worker.terminate();
|
|
124
|
+
} catch { /* */ }
|
|
125
|
+
reject(err);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
worker.onerror = (e) => {
|
|
129
|
+
// Boot-time errors surface here before the worker module finishes
|
|
130
|
+
// executing (e.g. type errors). After boot, we route per-request
|
|
131
|
+
// failures through `response_error` instead.
|
|
132
|
+
const message = e.message || "Worker error";
|
|
133
|
+
if (!booted) {
|
|
134
|
+
failBoot(new Error(`Worker for "${opts.name}" failed to start: ${message}`));
|
|
135
|
+
} else {
|
|
136
|
+
// Drain any in-flight callers — the worker is dead.
|
|
137
|
+
for (const p of pending.values()) p.reject(new Error(message));
|
|
138
|
+
pending.clear();
|
|
139
|
+
}
|
|
140
|
+
e.preventDefault?.();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
worker.onmessage = (ev) => {
|
|
144
|
+
const m = ev.data as { type: string; [k: string]: unknown };
|
|
145
|
+
if (m.type === "ready") {
|
|
146
|
+
const isPublic = Boolean(m.isPublic);
|
|
147
|
+
const timeoutMs = typeof m.timeoutMs === "number" ? m.timeoutMs : undefined;
|
|
148
|
+
const manifest = (m.manifest as FunctionManifest) ?? opts.manifest;
|
|
149
|
+
|
|
150
|
+
const handle: InternalHandle = {
|
|
151
|
+
name: opts.name,
|
|
152
|
+
manifest,
|
|
153
|
+
isPublic,
|
|
154
|
+
timeoutMs,
|
|
155
|
+
worker,
|
|
156
|
+
async dispatch(req, auth, signal) {
|
|
157
|
+
if (signal.aborted) {
|
|
158
|
+
throw new DOMException("Aborted", "AbortError");
|
|
159
|
+
}
|
|
160
|
+
const id = nextId++;
|
|
161
|
+
const headers: Array<[string, string]> = [];
|
|
162
|
+
req.headers.forEach((v, k) => headers.push([k, v]));
|
|
163
|
+
const body = req.body;
|
|
164
|
+
const transferList: Transferable[] = body
|
|
165
|
+
? [body as unknown as Transferable]
|
|
166
|
+
: [];
|
|
167
|
+
const promise = new Promise<Response>((res, rej) => {
|
|
168
|
+
pending.set(id, { resolve: res, reject: rej });
|
|
169
|
+
});
|
|
170
|
+
const onAbort = () => {
|
|
171
|
+
const p = pending.get(id);
|
|
172
|
+
if (p) {
|
|
173
|
+
pending.delete(id);
|
|
174
|
+
p.reject(new DOMException("Aborted", "AbortError"));
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
178
|
+
try {
|
|
179
|
+
worker.postMessage(
|
|
180
|
+
{
|
|
181
|
+
type: "dispatch",
|
|
182
|
+
id,
|
|
183
|
+
url: req.url,
|
|
184
|
+
method: req.method,
|
|
185
|
+
headers,
|
|
186
|
+
body,
|
|
187
|
+
auth,
|
|
188
|
+
},
|
|
189
|
+
transferList,
|
|
190
|
+
);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
pending.delete(id);
|
|
193
|
+
signal.removeEventListener("abort", onAbort);
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
return await promise;
|
|
198
|
+
} finally {
|
|
199
|
+
signal.removeEventListener("abort", onAbort);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
async terminate() {
|
|
203
|
+
try {
|
|
204
|
+
worker.terminate();
|
|
205
|
+
} catch { /* already dead */ }
|
|
206
|
+
for (const p of pending.values()) {
|
|
207
|
+
p.reject(new Error(`Worker for "${opts.name}" terminated`));
|
|
208
|
+
}
|
|
209
|
+
pending.clear();
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
finishBoot(handle);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (m.type === "init_error") {
|
|
217
|
+
failBoot(new Error(String(m.message ?? "init failed")));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (m.type === "response") {
|
|
222
|
+
const id = m.id as number;
|
|
223
|
+
const p = pending.get(id);
|
|
224
|
+
if (!p) return;
|
|
225
|
+
pending.delete(id);
|
|
226
|
+
const headers = new Headers(m.headers as Array<[string, string]>);
|
|
227
|
+
const status = m.status as number;
|
|
228
|
+
const body = (m.body as ReadableStream<Uint8Array> | null) ?? null;
|
|
229
|
+
// 1xx and 204/205/304 forbid a body; suppress to avoid the
|
|
230
|
+
// Response constructor throwing.
|
|
231
|
+
const wantsBody = status >= 200 && status !== 204 && status !== 205 &&
|
|
232
|
+
status !== 304;
|
|
233
|
+
p.resolve(new Response(wantsBody ? body : null, { status, headers }));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (m.type === "response_error") {
|
|
238
|
+
const id = m.id as number;
|
|
239
|
+
const p = pending.get(id);
|
|
240
|
+
if (!p) return;
|
|
241
|
+
pending.delete(id);
|
|
242
|
+
const err = new Error(String(m.message ?? "handler error"));
|
|
243
|
+
if (m.stack) (err as Error).stack = String(m.stack);
|
|
244
|
+
p.reject(err);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (m.type === "unhandledrejection" || m.type === "error_event") {
|
|
249
|
+
opts.onUnhandledRejection?.(String(m.message ?? "unknown"));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Kick off init; the worker will reply with `ready` once the
|
|
255
|
+
// function module's top-level serve() landed in the registry stub.
|
|
256
|
+
worker.postMessage({
|
|
257
|
+
type: "init",
|
|
258
|
+
name: opts.name,
|
|
259
|
+
entryUrl: opts.entryUrl,
|
|
260
|
+
manifest: opts.manifest,
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export interface DenoWorkerHost {
|
|
266
|
+
start(): Promise<{ loaded: string[]; errors: Array<{ name: string; error: string }> }>;
|
|
267
|
+
reload(names: ReadonlySet<string> | "all", reason?: string): Promise<ReloadSummary>;
|
|
268
|
+
stop(): Promise<void>;
|
|
269
|
+
/** Currently registered function names (loaded + booting). */
|
|
270
|
+
list(): string[];
|
|
271
|
+
/** Internal: dep-graph accessor for the hot reloader. */
|
|
272
|
+
readonly depGraph: DepGraph;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface DiscoveredCandidate {
|
|
276
|
+
name: string;
|
|
277
|
+
entryUrl: string;
|
|
278
|
+
manifest: FunctionManifest;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function discoverCandidates(
|
|
282
|
+
functionsDir: string,
|
|
283
|
+
): Promise<DiscoveredCandidate[]> {
|
|
284
|
+
const resolved = await Deno.realPath(functionsDir);
|
|
285
|
+
const out: DiscoveredCandidate[] = [];
|
|
286
|
+
for await (const entry of Deno.readDir(resolved)) {
|
|
287
|
+
if (!entry.isDirectory) continue;
|
|
288
|
+
if (entry.name.startsWith("_") || entry.name.endsWith("_shared")) continue;
|
|
289
|
+
const indexPath = join(resolved, entry.name, "index.ts");
|
|
290
|
+
try {
|
|
291
|
+
await Deno.stat(indexPath);
|
|
292
|
+
} catch {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const manifest = await loadManifest(resolved, entry.name).catch(() =>
|
|
296
|
+
defaultManifest()
|
|
297
|
+
);
|
|
298
|
+
out.push({
|
|
299
|
+
name: entry.name,
|
|
300
|
+
entryUrl: pathToFileURL(indexPath).href,
|
|
301
|
+
manifest,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function createDenoWorkerHost(opts: DenoWorkerHostOptions): DenoWorkerHost {
|
|
309
|
+
const handles = new Map<string, InternalHandle>();
|
|
310
|
+
const depGraph = createDepGraph(
|
|
311
|
+
opts.importMap && opts.importMapBase
|
|
312
|
+
? { importMap: opts.importMap, importMapBase: opts.importMapBase }
|
|
313
|
+
: undefined,
|
|
314
|
+
);
|
|
315
|
+
const concurrency = Math.max(1, opts.concurrency ?? 8);
|
|
316
|
+
|
|
317
|
+
async function spawnAndRegister(
|
|
318
|
+
cand: DiscoveredCandidate,
|
|
319
|
+
): Promise<{ handle: InternalHandle } | { error: string }> {
|
|
320
|
+
try {
|
|
321
|
+
const handle = await spawnWorker({
|
|
322
|
+
name: cand.name,
|
|
323
|
+
entryUrl: cand.entryUrl,
|
|
324
|
+
manifest: cand.manifest,
|
|
325
|
+
onUnhandledRejection: (msg) => {
|
|
326
|
+
// Count orphan rejections against the function so the breaker
|
|
327
|
+
// can trip on fire-and-forget code that consistently leaks.
|
|
328
|
+
opts.supervisor.record(cand.name, true);
|
|
329
|
+
console.error(`[1tube] Unhandled rejection in "${cand.name}": ${msg}`);
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
// Update the registry + supervisor in one step so dispatch + admit
|
|
333
|
+
// see consistent state.
|
|
334
|
+
const old = handles.get(cand.name);
|
|
335
|
+
handles.set(cand.name, handle);
|
|
336
|
+
opts.registry.setWorkerHandle(cand.name, handle);
|
|
337
|
+
opts.supervisor.setManifest(cand.name, handle.manifest);
|
|
338
|
+
if (old) {
|
|
339
|
+
await old.terminate().catch(() => {});
|
|
340
|
+
}
|
|
341
|
+
await depGraph.refresh(cand.name, cand.entryUrl);
|
|
342
|
+
return { handle };
|
|
343
|
+
} catch (err) {
|
|
344
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function runBounded<T>(
|
|
349
|
+
items: T[],
|
|
350
|
+
fn: (item: T) => Promise<void>,
|
|
351
|
+
): Promise<void> {
|
|
352
|
+
let cursor = 0;
|
|
353
|
+
const workers = Array.from(
|
|
354
|
+
{ length: Math.min(concurrency, items.length) },
|
|
355
|
+
async () => {
|
|
356
|
+
while (true) {
|
|
357
|
+
const i = cursor++;
|
|
358
|
+
if (i >= items.length) return;
|
|
359
|
+
await fn(items[i]);
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
await Promise.all(workers);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function start() {
|
|
367
|
+
const candidates = await discoverCandidates(opts.functionsDir);
|
|
368
|
+
const loaded: string[] = [];
|
|
369
|
+
const errors: Array<{ name: string; error: string }> = [];
|
|
370
|
+
await runBounded(candidates, async (cand) => {
|
|
371
|
+
const r = await spawnAndRegister(cand);
|
|
372
|
+
if ("error" in r) {
|
|
373
|
+
errors.push({ name: cand.name, error: r.error });
|
|
374
|
+
} else {
|
|
375
|
+
loaded.push(cand.name);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
return { loaded, errors };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function reload(
|
|
382
|
+
names: ReadonlySet<string> | "all",
|
|
383
|
+
reason = "fs change",
|
|
384
|
+
): Promise<ReloadSummary> {
|
|
385
|
+
const start = performance.now();
|
|
386
|
+
const candidates = await discoverCandidates(opts.functionsDir);
|
|
387
|
+
const candByName = new Map(candidates.map((c) => [c.name, c]));
|
|
388
|
+
|
|
389
|
+
const targetNames = names === "all"
|
|
390
|
+
? new Set(candidates.map((c) => c.name))
|
|
391
|
+
: new Set(names);
|
|
392
|
+
|
|
393
|
+
// Detect deletions: names we currently know about that no longer
|
|
394
|
+
// have an index.ts on disk.
|
|
395
|
+
const removed: string[] = [];
|
|
396
|
+
if (names === "all") {
|
|
397
|
+
for (const old of handles.keys()) {
|
|
398
|
+
if (!candByName.has(old)) removed.push(old);
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
for (const n of targetNames) {
|
|
402
|
+
if (!candByName.has(n) && handles.has(n)) removed.push(n);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
for (const n of removed) {
|
|
407
|
+
const h = handles.get(n);
|
|
408
|
+
if (h) await h.terminate().catch(() => {});
|
|
409
|
+
handles.delete(n);
|
|
410
|
+
opts.registry.clearWorkerHandle(n);
|
|
411
|
+
opts.supervisor.forget(n);
|
|
412
|
+
depGraph.forget(n);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const reloaded: string[] = [];
|
|
416
|
+
const added: string[] = [];
|
|
417
|
+
const errors: Array<{ name: string; error: string }> = [];
|
|
418
|
+
|
|
419
|
+
const toSpawn: DiscoveredCandidate[] = [];
|
|
420
|
+
for (const n of targetNames) {
|
|
421
|
+
const c = candByName.get(n);
|
|
422
|
+
if (!c) continue;
|
|
423
|
+
toSpawn.push(c);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
await runBounded(toSpawn, async (cand) => {
|
|
427
|
+
const wasKnown = handles.has(cand.name);
|
|
428
|
+
const r = await spawnAndRegister(cand);
|
|
429
|
+
if ("error" in r) {
|
|
430
|
+
errors.push({ name: cand.name, error: r.error });
|
|
431
|
+
// Reset stats for the failing function so the breaker doesn't keep
|
|
432
|
+
// counting against the dead worker.
|
|
433
|
+
opts.supervisor.reset(cand.name);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (wasKnown) reloaded.push(cand.name);
|
|
437
|
+
else added.push(cand.name);
|
|
438
|
+
opts.supervisor.reset(cand.name);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const summary: ReloadSummary = {
|
|
442
|
+
reason,
|
|
443
|
+
reloaded: reloaded.sort(),
|
|
444
|
+
added: added.sort(),
|
|
445
|
+
removed: removed.sort(),
|
|
446
|
+
errors,
|
|
447
|
+
durationMs: performance.now() - start,
|
|
448
|
+
};
|
|
449
|
+
opts.onReloaded?.(summary);
|
|
450
|
+
return summary;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function stop() {
|
|
454
|
+
const all = [...handles.values()];
|
|
455
|
+
handles.clear();
|
|
456
|
+
await Promise.all(all.map((h) => h.terminate().catch(() => {})));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
start,
|
|
461
|
+
reload,
|
|
462
|
+
stop,
|
|
463
|
+
list: () => [...handles.keys()].sort(),
|
|
464
|
+
depGraph,
|
|
465
|
+
};
|
|
466
|
+
}
|