1tube 0.1.43 → 0.1.44

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.43",
3
+ "version": "0.1.44",
4
4
  "private": false,
5
5
  "description": "Self-hosted Supabase Edge Functions gateway for Deno, plus an ESLint plugin for consumers",
6
6
  "license": "MIT",
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn } from "node:child_process";
4
- import { existsSync } from "node:fs";
4
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
5
5
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
6
6
  import process from "node:process";
7
7
  import { fileURLToPath } from "node:url";
@@ -55,8 +55,27 @@ const userSuppliedConfig = denoPassthrough.some((a) =>
55
55
  a.startsWith("--config=") || a.startsWith("-c=")
56
56
  );
57
57
 
58
+ // Managed lockfile
59
+ // ----------------
60
+ // A lockfile is what makes a big functions project boot fast: without one
61
+ // (`--no-lock`) Deno re-solves the entire npm/jsr version graph from
62
+ // scratch on every start, and a 100-function project pays that as a
63
+ // multi-second floor every boot. People reach for `--no-lock` to dodge
64
+ // "the lock went stale when I bumped a dep" pain — but in dev Deno's
65
+ // lockfile is ADDITIVE and non-frozen: a changed dependency is appended
66
+ // without error. So the right default is a lockfile we own, not none.
67
+ //
68
+ // We point `--lock` at `.1tube/deno.lock` — a gateway-owned scratch path
69
+ // (already gitignored alongside `.1tube/logs.db`) so it never adds git
70
+ // churn and never collides with a project's own committed `deno.lock`.
71
+ // We never pass `--frozen`, so dep changes self-heal. `1TUBE_REFRESH_LOCK=1`
72
+ // drops the managed lock for a clean rebuild. Any explicit `--lock`,
73
+ // `--no-lock`, or `--frozen` the user passes wins — we don't second-guess.
74
+ const managedLock = resolveManagedLock(denoPassthrough);
75
+
58
76
  const denoArgs = ["run", "--quiet", "-A"];
59
77
  if (!userSuppliedConfig) denoArgs.push("--config", denoConfig);
78
+ if (managedLock) denoArgs.push("--lock", managedLock);
60
79
  denoArgs.push(...denoPassthrough, cliEntrypoint, ...forwardedTubeArgs);
61
80
 
62
81
  // Deno 2.9 enables a 24h "minimum dependency age" by default, which makes
@@ -78,6 +97,9 @@ if (
78
97
  ) {
79
98
  childEnv.NPM_CONFIG_MIN_RELEASE_AGE = "0";
80
99
  }
100
+ // Let the gateway print which lockfile is in effect (it can't read its own
101
+ // process's `--lock` flag back out of Deno).
102
+ if (managedLock) childEnv.ONETUBE_LOCK = managedLock;
81
103
 
82
104
  // Resolve the real deno binary up front. Node ≥18.20 refuses to spawn a
83
105
  // `.cmd`/`.bat` shim without a shell, but spawning a real `.exe` (or a
@@ -142,6 +164,60 @@ child.on("exit", (code, signal) => {
142
164
  process.exit(code ?? 1);
143
165
  });
144
166
 
167
+ /**
168
+ * Decide whether to inject a 1tube-managed lockfile, and where.
169
+ *
170
+ * Returns the absolute lock path to pass to `--lock`, or null when the
171
+ * caller already made an explicit locking choice (`--lock`, `--no-lock`,
172
+ * or `--frozen`) — in which case we stay out of the way entirely.
173
+ *
174
+ * The managed lock lives at `<cwd>/.1tube/deno.lock`: a scratch path that
175
+ * 1tube already owns and gitignores, so enabling it adds no git churn and
176
+ * never clashes with a project's own committed `deno.lock`.
177
+ *
178
+ * @param {string[]} passthrough Raw deno flags being forwarded to `deno run`.
179
+ * @returns {string | null}
180
+ */
181
+ function resolveManagedLock(passthrough) {
182
+ const explicit = passthrough.some((a) =>
183
+ a === "--lock" || a.startsWith("--lock=") ||
184
+ a === "--no-lock" || a.startsWith("--no-lock=") ||
185
+ a === "--frozen" || a.startsWith("--frozen=")
186
+ );
187
+ if (explicit) return null;
188
+
189
+ const dir = join(process.cwd(), ".1tube");
190
+ const lockPath = join(dir, "deno.lock");
191
+ try {
192
+ mkdirSync(dir, { recursive: true });
193
+ } catch {
194
+ // If we can't create the scratch dir, fall back to no managed lock
195
+ // rather than crash the launcher.
196
+ return null;
197
+ }
198
+
199
+ // `1TUBE_REFRESH_LOCK=1` forces a clean rebuild by dropping the managed
200
+ // lock; Deno then re-derives it from the current deps on this boot.
201
+ if (truthy(process.env.ONETUBE_REFRESH_LOCK ?? process.env["1TUBE_REFRESH_LOCK"])) {
202
+ try {
203
+ rmSync(lockPath, { force: true });
204
+ } catch {
205
+ // Best-effort.
206
+ }
207
+ }
208
+ return lockPath;
209
+ }
210
+
211
+ /**
212
+ * Loose truthiness for env-var flags: "1", "true", "yes", "on" (any case).
213
+ *
214
+ * @param {string | undefined} v
215
+ * @returns {boolean}
216
+ */
217
+ function truthy(v) {
218
+ return v !== undefined && /^(1|true|yes|on)$/i.test(v.trim());
219
+ }
220
+
145
221
  /**
146
222
  * Split a flag string into argv tokens, honoring single/double quotes so
147
223
  * values with spaces (`--env-file="my env.env"`) survive. Good enough for
@@ -398,7 +398,9 @@ export function createDenoHotReloader(
398
398
  }`;
399
399
  log(`[1tube] HMR ${reason}`);
400
400
  try {
401
- const summary = await opts.host.reload(affected, reason);
401
+ const summary = await opts.host.reload(affected, reason, {
402
+ changedPaths: paths,
403
+ });
402
404
  const parts: string[] = [];
403
405
  if (summary.reloaded.length > 0) {
404
406
  parts.push(`reloaded=${summary.reloaded.join(",")}`);
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Colocated worker-side entry point (dev) for the Deno backend.
3
+ *
4
+ * Where {@link ./worker-entry.ts} runs ONE function per Worker, this
5
+ * bootstrap hosts EVERY function in a single isolate. The point is
6
+ * dependency sharing: a 100-function project where most functions import
7
+ * the same heavy npm graph (`ai`, `@supabase/supabase-js`, `@google/genai`,
8
+ * `zod`, �) pays to compile + evaluate that graph exactly once here,
9
+ * instead of once per function across 100 isolates. On a real project that
10
+ * collapses warm-boot from ~per-function-sum/cores down to roughly the
11
+ * one-time cost of the union graph (measured ~6s vs ~18s).
12
+ *
13
+ * Trade-off: functions share globals and a crash takes the whole isolate
14
+ * down. That's why this mode is dev-only � production keeps isolate-per-
15
+ * function (or workerd) for real isolation.
16
+ *
17
+ * Lifecycle:
18
+ *
19
+ * host ? init : { functions: [{name, entryUrl, manifest}], � }
20
+ * Imported SERIALLY so each function's top-level
21
+ * `serve()` registration can be attributed to the
22
+ * right name (parallel imports would interleave the
23
+ * single global registry stub). The first import pays
24
+ * the shared-graph cost; the rest are cache hits, so
25
+ * serial is nearly as fast as parallel but correct.
26
+ * Streams `fn_ready` / `fn_error` per function, then
27
+ * `all_done`.
28
+ *
29
+ * host ? dispatch : { id, name, � } ? look up the captured handler by
30
+ * name and run it (per-request AsyncLocalStorage so
31
+ * concurrent invocations attribute console output).
32
+ *
33
+ * host ? reload : { functions: [...] } ? re-import each entry with a
34
+ * `?v=` cache-bust so only the edited module (and its
35
+ * changed local deps) re-evaluates; unchanged npm
36
+ * modules stay warm in the isolate. Updates the
37
+ * handler map and streams `fn_ready` / `fn_error`.
38
+ *
39
+ * host ? remove : { names: [...] } ? drop handlers for deleted fns.
40
+ */
41
+
42
+ /// <reference lib="deno.worker" />
43
+
44
+ import { AsyncLocalStorage } from "node:async_hooks";
45
+ import type { FunctionManifest } from "../../manifest.ts";
46
+ import type { AuthContext } from "../../registry.ts";
47
+
48
+ interface FnSpec {
49
+ name: string;
50
+ entryUrl: string;
51
+ manifest: FunctionManifest;
52
+ }
53
+
54
+ interface InitMessage {
55
+ type: "init";
56
+ functions: FnSpec[];
57
+ captureConsole?: boolean;
58
+ /** Per-import hard cap (ms) so one function blocking at top-level can't
59
+ * stall the whole boot. */
60
+ importTimeoutMs?: number;
61
+ }
62
+
63
+ interface DispatchMessage {
64
+ type: "dispatch";
65
+ id: number;
66
+ name: string;
67
+ url: string;
68
+ method: string;
69
+ headers: Array<[string, string]>;
70
+ body: ReadableStream<Uint8Array> | null;
71
+ auth: AuthContext | null;
72
+ invocationId?: string;
73
+ }
74
+
75
+ interface ReloadMessage {
76
+ type: "reload";
77
+ functions: FnSpec[];
78
+ }
79
+
80
+ interface RemoveMessage {
81
+ type: "remove";
82
+ names: string[];
83
+ }
84
+
85
+ type HostMessage =
86
+ | InitMessage
87
+ | DispatchMessage
88
+ | ReloadMessage
89
+ | RemoveMessage;
90
+
91
+ type Handler = (
92
+ req: Request,
93
+ auth?: AuthContext,
94
+ ) => Response | Promise<Response>;
95
+
96
+ interface RegistryStub {
97
+ register(
98
+ handler: Handler,
99
+ opts: { public: boolean; timeoutMs?: number },
100
+ ): void;
101
+ }
102
+
103
+ interface CapturedHandler {
104
+ handler: Handler;
105
+ isPublic: boolean;
106
+ timeoutMs?: number;
107
+ manifest: FunctionManifest;
108
+ }
109
+
110
+ const handlers = new Map<string, CapturedHandler>();
111
+
112
+ // Set immediately before each import so the registry stub can attribute a
113
+ // `serve()` call (and any boot-time console output) to the right function.
114
+ let importing: { name: string; captured: CapturedHandler | null } | null = null;
115
+
116
+ const invocationContext = new AsyncLocalStorage<
117
+ { invocationId?: string; name: string }
118
+ >();
119
+
120
+ let consoleCaptureOn = false;
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Console capture (mirrors worker-entry.ts, but tags every line with the
124
+ // owning function so the host can attribute output in a shared isolate).
125
+ // ---------------------------------------------------------------------------
126
+
127
+ type ConsoleLevel = "debug" | "log" | "info" | "warn" | "error";
128
+ const CONSOLE_LEVELS: ConsoleLevel[] = ["debug", "log", "info", "warn", "error"];
129
+ const MAX_CONSOLE_MESSAGE = 8 * 1024;
130
+
131
+ function formatConsoleArg(arg: unknown): string {
132
+ if (typeof arg === "string") return arg;
133
+ try {
134
+ return Deno.inspect(arg, {
135
+ depth: 4,
136
+ colors: false,
137
+ strAbbreviateSize: 4096,
138
+ });
139
+ } catch {
140
+ try {
141
+ return String(arg);
142
+ } catch {
143
+ return "[unprintable]";
144
+ }
145
+ }
146
+ }
147
+
148
+ function currentFunctionName(): string {
149
+ return invocationContext.getStore()?.name ?? importing?.name ?? "<colocated>";
150
+ }
151
+
152
+ function installConsoleCapture(): void {
153
+ if (consoleCaptureOn) return;
154
+ consoleCaptureOn = true;
155
+ for (const level of CONSOLE_LEVELS) {
156
+ const original = console[level].bind(console);
157
+ console[level] = (...args: unknown[]) => {
158
+ original(...args);
159
+ try {
160
+ let message = args.map(formatConsoleArg).join(" ");
161
+ if (message.length > MAX_CONSOLE_MESSAGE) {
162
+ message = message.slice(0, MAX_CONSOLE_MESSAGE) + "�";
163
+ }
164
+ postMessage({
165
+ type: "console",
166
+ name: currentFunctionName(),
167
+ level,
168
+ message,
169
+ tsMs: Date.now(),
170
+ invocationId: invocationContext.getStore()?.invocationId ?? null,
171
+ });
172
+ } catch {
173
+ // postMessage can throw during teardown; dropping a captured line
174
+ // is acceptable, breaking user code is not.
175
+ }
176
+ };
177
+ }
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Registry stub: function top-level `serve()` lands here. We attribute the
182
+ // registration to whichever function is currently importing.
183
+ // ---------------------------------------------------------------------------
184
+
185
+ const stub: RegistryStub = {
186
+ register(handler, opts) {
187
+ if (!importing) return; // serve() called outside an import window � ignore
188
+ if (importing.captured) {
189
+ console.warn(
190
+ `[1tube] "${importing.name}" called serve() twice; using the last handler`,
191
+ );
192
+ }
193
+ importing.captured = {
194
+ handler,
195
+ isPublic: opts.public,
196
+ timeoutMs: opts.timeoutMs,
197
+ manifest: { runtime: "deno" } as unknown as FunctionManifest,
198
+ };
199
+ },
200
+ };
201
+
202
+ (globalThis as { __edgeFunctionRegistry?: RegistryStub })
203
+ .__edgeFunctionRegistry = stub;
204
+
205
+ // Shared-module RPC is unnecessary in colocated mode: a shared module like
206
+ // `_shared/profile-cache.ts` is imported once in THIS isolate, so its side
207
+ // effects already run exactly once. Provide a no-op so any leftover stub
208
+ // reference doesn't throw.
209
+ (globalThis as { __1tube_call_shared?: unknown }).__1tube_call_shared =
210
+ async () => {
211
+ throw new Error(
212
+ "shared-module RPC is not used in colocated dev mode (module runs in-isolate)",
213
+ );
214
+ };
215
+
216
+ self.addEventListener("unhandledrejection", (e) => {
217
+ e.preventDefault();
218
+ const err = (e as PromiseRejectionEvent).reason;
219
+ postMessage({
220
+ type: "unhandledrejection",
221
+ name: currentFunctionName(),
222
+ message: err instanceof Error ? err.message : String(err),
223
+ stack: err instanceof Error ? err.stack : undefined,
224
+ invocationId: invocationContext.getStore()?.invocationId ?? null,
225
+ });
226
+ });
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Import + (re)registration
230
+ // ---------------------------------------------------------------------------
231
+
232
+ function importWithTimeout(url: string, timeoutMs: number): Promise<void> {
233
+ return Promise.race([
234
+ import(url).then(() => {}),
235
+ new Promise<void>((_, reject) =>
236
+ setTimeout(
237
+ () => reject(new Error(`import timed out after ${timeoutMs}ms`)),
238
+ timeoutMs,
239
+ )
240
+ ),
241
+ ]);
242
+ }
243
+
244
+ /**
245
+ * Import one function entry and capture its handler. `bust` appends a
246
+ * cache-busting query so reloads re-evaluate the edited module instead of
247
+ * reusing the isolate's cached copy.
248
+ */
249
+ async function loadFunction(
250
+ fn: FnSpec,
251
+ importTimeoutMs: number,
252
+ bust: boolean,
253
+ ): Promise<void> {
254
+ const t0 = performance.now();
255
+ importing = { name: fn.name, captured: null };
256
+ const url = bust
257
+ ? `${fn.entryUrl}${fn.entryUrl.includes("?") ? "&" : "?"}v=${Date.now()}-${
258
+ Math.random().toString(36).slice(2, 8)
259
+ }`
260
+ : fn.entryUrl;
261
+ try {
262
+ await importWithTimeout(url, importTimeoutMs);
263
+ } catch (err) {
264
+ importing = null;
265
+ postMessage({
266
+ type: "fn_error",
267
+ name: fn.name,
268
+ ms: performance.now() - t0,
269
+ message: err instanceof Error ? err.message : String(err),
270
+ stack: err instanceof Error ? err.stack : undefined,
271
+ });
272
+ return;
273
+ }
274
+ const captured = importing.captured;
275
+ importing = null;
276
+ if (!captured) {
277
+ postMessage({
278
+ type: "fn_error",
279
+ name: fn.name,
280
+ ms: performance.now() - t0,
281
+ message: `Function "${fn.name}" loaded without calling serve()`,
282
+ });
283
+ return;
284
+ }
285
+ captured.manifest = fn.manifest;
286
+ handlers.set(fn.name, captured);
287
+ postMessage({
288
+ type: "fn_ready",
289
+ name: fn.name,
290
+ ms: performance.now() - t0,
291
+ isPublic: captured.isPublic,
292
+ timeoutMs: captured.timeoutMs,
293
+ manifest: fn.manifest,
294
+ });
295
+ }
296
+
297
+ async function runDispatch(msg: DispatchMessage): Promise<void> {
298
+ const captured = handlers.get(msg.name);
299
+ if (!captured) {
300
+ postMessage({
301
+ type: "response_error",
302
+ id: msg.id,
303
+ message: `Function "${msg.name}" is not loaded`,
304
+ });
305
+ return;
306
+ }
307
+ const headers = new Headers(msg.headers);
308
+ const init: RequestInit = { method: msg.method, headers };
309
+ if (msg.body !== null) {
310
+ init.body = msg.body;
311
+ (init as unknown as { duplex: "half" }).duplex = "half";
312
+ }
313
+ const req = new Request(msg.url, init);
314
+ let resp: Response;
315
+ try {
316
+ const run = () =>
317
+ Promise.resolve(
318
+ captured.isPublic
319
+ ? captured.handler(req)
320
+ : captured.handler(req, msg.auth ?? undefined),
321
+ );
322
+ resp = await invocationContext.run(
323
+ { invocationId: msg.invocationId, name: msg.name },
324
+ run,
325
+ );
326
+ } catch (err) {
327
+ postMessage({
328
+ type: "response_error",
329
+ id: msg.id,
330
+ message: err instanceof Error ? err.message : String(err),
331
+ stack: err instanceof Error ? err.stack : undefined,
332
+ });
333
+ return;
334
+ }
335
+ const respHeaders: Array<[string, string]> = [];
336
+ resp.headers.forEach((v, k) => respHeaders.push([k, v]));
337
+ const body = resp.body;
338
+ postMessage(
339
+ {
340
+ type: "response",
341
+ id: msg.id,
342
+ status: resp.status,
343
+ headers: respHeaders,
344
+ body,
345
+ },
346
+ body ? [body as unknown as Transferable] : [],
347
+ );
348
+ }
349
+
350
+ let importTimeoutMs = 30_000;
351
+
352
+ self.onmessage = (ev: MessageEvent<HostMessage>) => {
353
+ const msg = ev.data;
354
+ switch (msg.type) {
355
+ case "init": {
356
+ if (msg.captureConsole) installConsoleCapture();
357
+ importTimeoutMs = msg.importTimeoutMs ?? 30_000;
358
+ void (async () => {
359
+ // Serial: the first import warms the shared graph, the rest are
360
+ // cache hits. Serial keeps `serve()` attribution unambiguous.
361
+ for (const fn of msg.functions) {
362
+ await loadFunction(fn, importTimeoutMs, false);
363
+ }
364
+ postMessage({ type: "all_done" });
365
+ })();
366
+ return;
367
+ }
368
+ case "dispatch": {
369
+ void runDispatch(msg);
370
+ return;
371
+ }
372
+ case "reload": {
373
+ void (async () => {
374
+ for (const fn of msg.functions) {
375
+ await loadFunction(fn, importTimeoutMs, true);
376
+ }
377
+ postMessage({ type: "reload_done" });
378
+ })();
379
+ return;
380
+ }
381
+ case "remove": {
382
+ for (const name of msg.names) handlers.delete(name);
383
+ return;
384
+ }
385
+ }
386
+ };