1tube 0.1.40 → 0.1.41

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.40",
3
+ "version": "0.1.41",
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",
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Build-time capture of each function's declared `serve()` options for the
3
+ * Vercel target.
4
+ *
5
+ * On Vercel each function runs standalone, so `_shared/handler.ts` takes the
6
+ * `getStandaloneServe()` path and the gateway registry is never installed —
7
+ * which means a `serve({ timeoutMs })` value would otherwise be invisible to the
8
+ * static build that bakes `maxDuration` into `.vc-config.json`.
9
+ *
10
+ * Rather than parse source or require a side-car `1tube.json`, we read the value
11
+ * the function ACTUALLY declares, through the same contract the runtime uses:
12
+ * `_shared/handler.ts` calls `globalThis.__edgeFunctionRegistry.register(handler,
13
+ * { public, timeoutMs })` at import time whenever that global exists. We install
14
+ * a capturing stub, import each entrypoint once, and record what it registered.
15
+ *
16
+ * Cost & safety:
17
+ * - Runs ONLY at build time — the deployed function pays nothing.
18
+ * - Imports each entrypoint a single time; Deno caches the shared module graph
19
+ * (`handler.ts`, providers, …) across functions, so the marginal cost per
20
+ * function is just its own top-level `serve(...)` call.
21
+ * - The registry path returns before binding any server, so importing does not
22
+ * start listeners or do per-request work.
23
+ * - Resilient: a function that throws at import (e.g. a stray top-level env
24
+ * read) is skipped with a warning; the caller falls back to its manifest /
25
+ * the global default.
26
+ */
27
+
28
+ import { pathToFileURL } from "node:url";
29
+ import type { BundleInput } from "../../bundler/core.ts";
30
+
31
+ /** What a function declared on its `serve({ ... })` call. */
32
+ export interface DeclaredServeConfig {
33
+ public: boolean;
34
+ /** Per-function wall-clock budget (ms). Undefined = no explicit timeout. */
35
+ timeoutMs?: number;
36
+ }
37
+
38
+ interface RegistryStub {
39
+ register(
40
+ handler: unknown,
41
+ opts?: { public?: boolean; timeoutMs?: number },
42
+ ): void;
43
+ }
44
+
45
+ const REGISTRY_KEY = "__edgeFunctionRegistry";
46
+
47
+ /**
48
+ * Minimal stand-in for `Deno.serve`'s return value so importing an entrypoint
49
+ * that calls `Deno.serve` directly (instead of the registry `serve()` wrapper)
50
+ * does not bind a real port during the build.
51
+ */
52
+ function fakeHttpServer(): unknown {
53
+ return {
54
+ finished: Promise.resolve(),
55
+ shutdown: () => Promise.resolve(),
56
+ ref() {},
57
+ unref() {},
58
+ addr: { transport: "tcp", hostname: "0.0.0.0", port: 0 },
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Import every entrypoint with a stub registry installed and collect the
64
+ * `serve()` options each one registers. Returns a `name → config` map; functions
65
+ * that don't register (or fail to import) are simply absent.
66
+ */
67
+ export async function captureDeclaredServeConfigs(
68
+ inputs: readonly BundleInput[],
69
+ onWarn: (message: string) => void = (m) => console.warn(m),
70
+ ): Promise<Map<string, DeclaredServeConfig>> {
71
+ const out = new Map<string, DeclaredServeConfig>();
72
+ if (inputs.length === 0) return out;
73
+
74
+ const globalAny = globalThis as Record<string, unknown>;
75
+ const previousRegistry = globalAny[REGISTRY_KEY];
76
+
77
+ let current: DeclaredServeConfig | undefined;
78
+ const stub: RegistryStub = {
79
+ register(_handler, opts) {
80
+ current = { public: !!opts?.public, timeoutMs: opts?.timeoutMs };
81
+ },
82
+ };
83
+ globalAny[REGISTRY_KEY] = stub;
84
+
85
+ // The registry path never calls Deno.serve, but neutralise it anyway so a raw
86
+ // `Deno.serve(...)` entrypoint can't bind a port (or leak a listener) during
87
+ // the sweep. Snapshot/restore around the whole pass.
88
+ const denoNs = (globalThis as { Deno?: { serve?: unknown } }).Deno;
89
+ const hadServe = !!denoNs && "serve" in denoNs;
90
+ const previousServe = denoNs?.serve;
91
+ if (denoNs) denoNs.serve = fakeHttpServer as unknown as typeof denoNs.serve;
92
+
93
+ try {
94
+ for (const input of inputs) {
95
+ current = undefined;
96
+ try {
97
+ await import(pathToFileURL(input.entrypoint).href);
98
+ } catch (err) {
99
+ onWarn(
100
+ `could not load ${input.name} to read its serve() timeout; ` +
101
+ `falling back to manifest/default (${
102
+ err instanceof Error ? err.message : String(err)
103
+ })`,
104
+ );
105
+ continue;
106
+ }
107
+ if (current) out.set(input.name, current);
108
+ }
109
+ } finally {
110
+ if (previousRegistry === undefined) delete globalAny[REGISTRY_KEY];
111
+ else globalAny[REGISTRY_KEY] = previousRegistry;
112
+ if (denoNs) {
113
+ if (hadServe) {
114
+ denoNs.serve = previousServe as typeof denoNs.serve;
115
+ } else {
116
+ delete denoNs.serve;
117
+ }
118
+ }
119
+ }
120
+
121
+ return out;
122
+ }
@@ -20,10 +20,19 @@
20
20
  * required: the Vercel bundle wrapper captures `Deno.serve` and bridges Node
21
21
  * req/res to the Web fetch handler (see `../backends/vercel/wrapper.ts`).
22
22
  *
23
+ * Vercel's filesystem routing matches a `.func` only on its EXACT path, so
24
+ * `/functions/v1/rooms/<id>` would 404. Supabase's edge-runtime instead routes
25
+ * on the first path segment and forwards the whole request to the function, so
26
+ * path-based routers handle their own sub-paths. To reproduce that, this command
27
+ * also merges a per-function "main"-phase rewrite into `config.json` mapping
28
+ * `/<prefix>/<name>/<rest>` back to the bare function path (see
29
+ * {@link mergeSubpathRoutes}).
30
+ *
23
31
  * This command MERGES into an existing `.vercel/output` (e.g. one produced by
24
32
  * `vercel build` for the static frontend). It only creates/replaces the
25
- * specific `.func` directories; it never wipes the output root, so the
26
- * frontend's `config.json` and static assets are left untouched.
33
+ * specific `.func` directories and additively edits `config.json`'s `routes`
34
+ * (preserving the frontend's existing routes and static assets); it never wipes
35
+ * the output root.
27
36
  *
28
37
  * Each function is fully standalone — there is no gateway shared runtime on
29
38
  * Vercel, so modules like `_shared/profile-cache.ts` are bundled inline rather
@@ -40,6 +49,7 @@ import {
40
49
  disposeBundlerResources,
41
50
  } from "../bundler/core.ts";
42
51
  import { VERCEL_PROFILE } from "../backends/vercel/bundle-profile.ts";
52
+ import { captureDeclaredServeConfigs } from "../backends/vercel/capture-config.ts";
43
53
  import { loadManifest } from "../manifest.ts";
44
54
  import { resolveDenoConfigPath } from "./deno-config.ts";
45
55
 
@@ -48,9 +58,10 @@ const DEFAULT_RUNTIME = "nodejs24.x";
48
58
  /** Default route prefix; matches sciobot's `/functions/v1/<name>` calls. */
49
59
  const DEFAULT_PATH_PREFIX = "functions/v1";
50
60
  /**
51
- * Default function timeout (seconds) when no `1tube.json` timeoutMs is set.
52
- * Defaults to the cap so long-running AI pipelines aren't silently truncated
53
- * unless a function opts into a shorter timeout via its manifest.
61
+ * Default function timeout (seconds) when a function declares no timeout via
62
+ * `serve({ timeoutMs })` (or a `1tube.json` manifest). Defaults to the cap so
63
+ * long-running AI pipelines aren't silently truncated unless a function opts
64
+ * into a shorter timeout.
54
65
  */
55
66
  const DEFAULT_MAX_DURATION = 800;
56
67
  /** Upper bound for maxDuration (seconds). Fluid/Pro allows long durations. */
@@ -74,7 +85,10 @@ export interface VercelBuildOptions {
74
85
  pathPrefix?: string;
75
86
  /** Vercel runtime identifier. Defaults to "nodejs24.x". */
76
87
  runtime?: string;
77
- /** Fallback maxDuration (seconds) when a function has no timeoutMs. */
88
+ /**
89
+ * Fallback maxDuration (seconds) when a function declares no `timeoutMs`
90
+ * (via `serve({ timeoutMs })` or `1tube.json`).
91
+ */
78
92
  defaultMaxDuration?: number;
79
93
  /** Hard cap (seconds) applied to any derived maxDuration. */
80
94
  maxDurationCap?: number;
@@ -96,7 +110,14 @@ export type VercelBuildProgress =
96
110
  route: string;
97
111
  bytes: number;
98
112
  maxDuration: number;
99
- };
113
+ }
114
+ | {
115
+ phase: "config-merge";
116
+ configPath: string;
117
+ routes: number;
118
+ created: boolean;
119
+ }
120
+ | { phase: "capture-warning"; message: string };
100
121
 
101
122
  export interface VercelFunctionEntry {
102
123
  /** Logical function name. */
@@ -126,6 +147,10 @@ export interface VercelBuildResult {
126
147
  pathPrefix: string;
127
148
  /** Emitted functions, sorted by name. */
128
149
  functions: VercelFunctionEntry[];
150
+ /** Absolute path to the Build Output API `config.json` that was merged. */
151
+ configPath: string;
152
+ /** Number of per-function sub-path routes written into `config.json`. */
153
+ subpathRoutes: number;
129
154
  durationMs: number;
130
155
  }
131
156
 
@@ -170,6 +195,116 @@ function normalizePrefix(prefix: string): string {
170
195
  );
171
196
  }
172
197
 
198
+ /** Escape a string for safe literal use inside a regular expression. */
199
+ function escapeRegExp(input: string): string {
200
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
201
+ }
202
+
203
+ /** Minimal shape of a Build Output API route entry we read or write. */
204
+ interface VercelRoute {
205
+ handle?: string;
206
+ src?: string;
207
+ dest?: string;
208
+ [key: string]: unknown;
209
+ }
210
+
211
+ /** Minimal shape of `.vercel/output/config.json` (Build Output API v3). */
212
+ interface VercelOutputConfig {
213
+ version?: number;
214
+ routes?: VercelRoute[];
215
+ [key: string]: unknown;
216
+ }
217
+
218
+ /**
219
+ * Add, per function, a "main"-phase rewrite mapping `/<prefix>/<name>/<rest>`
220
+ * to the bare function path `/<prefix>/<name>` so a single Vercel Node Function
221
+ * also serves all of its sub-paths.
222
+ *
223
+ * Why this is needed: Vercel's filesystem routing matches a `.func` only on its
224
+ * exact path, so `/<prefix>/rooms/<id>` would 404. Supabase's edge-runtime
225
+ * instead routes on the first path segment and forwards the whole request to the
226
+ * function (`worker.fetch(req)`) — the behaviour path-based routers such as
227
+ * sciobot's `rooms` handler rely on. A *regular* serverless function receives
228
+ * the UNMODIFIED original URL through a rewrite (only prerender functions get
229
+ * the rewritten path), so the function still sees the sub-path in
230
+ * `url.pathname`.
231
+ *
232
+ * The routes go in the main phase (before the first `handle` marker) so each
233
+ * rewrite is immediately followed by the filesystem lookup that resolves the
234
+ * `.func`. The exact path (`/<prefix>/<name>`) is intentionally NOT matched — it
235
+ * already resolves via filesystem routing — keeping the change purely additive.
236
+ *
237
+ * Existing routes are preserved; only this builder's own previously-written
238
+ * sub-path routes (for the functions being built now) are replaced, so repeated
239
+ * and `--only` builds stay idempotent.
240
+ */
241
+ async function mergeSubpathRoutes(
242
+ outDir: string,
243
+ pathPrefix: string,
244
+ functionNames: readonly string[],
245
+ ): Promise<{ created: boolean; routeCount: number }> {
246
+ const configPath = join(outDir, "config.json");
247
+
248
+ let config: VercelOutputConfig | null = null;
249
+ try {
250
+ config = JSON.parse(
251
+ await Deno.readTextFile(configPath),
252
+ ) as VercelOutputConfig;
253
+ } catch (err) {
254
+ if (!(err instanceof Deno.errors.NotFound)) throw err;
255
+ }
256
+ const created = config === null;
257
+ if (config === null) config = { version: 3, routes: [] };
258
+ if (typeof config.version !== "number") config.version = 3;
259
+
260
+ const existing = Array.isArray(config.routes) ? config.routes : [];
261
+
262
+ const prefixRe = escapeRegExp(pathPrefix);
263
+ const destPrefix = `/${pathPrefix}/`;
264
+ const builtNames = new Set(functionNames);
265
+ // The exact route a function `name` maps to. Used for BOTH emit and cleanup
266
+ // so detection can never drift from generation (e.g. a prefix with regex
267
+ // metacharacters is escaped identically in both).
268
+ const routeFor = (name: string): Required<Pick<VercelRoute, "src" | "dest">> => ({
269
+ src: `^/${prefixRe}/${escapeRegExp(name)}/.*$`,
270
+ dest: `${destPrefix}${name}`,
271
+ });
272
+
273
+ // Drop our own previously-written routes for the functions being built now: a
274
+ // route is ours iff its dest is the bare function path AND its src is exactly
275
+ // what we would emit for that function. Gating on `builtNames` leaves routes
276
+ // for other functions intact, so `--only` (incremental) builds are safe.
277
+ const isOwnRoute = (r: VercelRoute): boolean => {
278
+ if (r.handle !== undefined || typeof r.dest !== "string") return false;
279
+ if (!r.dest.startsWith(destPrefix)) return false;
280
+ const name = r.dest.slice(destPrefix.length);
281
+ return builtNames.has(name) && r.src === routeFor(name).src;
282
+ };
283
+ const cleaned = existing.filter((r) => !isOwnRoute(r));
284
+
285
+ const subpathRoutes: VercelRoute[] = [...builtNames]
286
+ .sort((a, b) => a.localeCompare(b))
287
+ .map((name) => routeFor(name));
288
+
289
+ // Place the routes in the main phase — before the first `handle` marker — so
290
+ // each rewrite is immediately resolved against the filesystem (the `.func`).
291
+ const markerIdx = cleaned.findIndex((r) => typeof r.handle === "string");
292
+ config.routes = markerIdx === -1
293
+ ? [...cleaned, ...subpathRoutes]
294
+ : [
295
+ ...cleaned.slice(0, markerIdx),
296
+ ...subpathRoutes,
297
+ ...cleaned.slice(markerIdx),
298
+ ];
299
+
300
+ await ensureDir(outDir);
301
+ await Deno.writeTextFile(
302
+ configPath,
303
+ JSON.stringify(config, null, 2) + "\n",
304
+ );
305
+ return { created, routeCount: subpathRoutes.length };
306
+ }
307
+
173
308
  async function buildVercelOnce(
174
309
  opts: VercelBuildOptions,
175
310
  ): Promise<VercelBuildResult> {
@@ -223,6 +358,15 @@ async function buildVercelOnce(
223
358
  durationMs: performance.now() - bundleStart,
224
359
  });
225
360
 
361
+ // Read each function's declared `serve({ timeoutMs })` so it can drive
362
+ // `maxDuration` without a side-car manifest. Done after bundling so the
363
+ // entrypoints' module graph is already warm in Deno's cache; the deployed
364
+ // function is unaffected (see capture-config.ts).
365
+ const declaredConfigs = await captureDeclaredServeConfigs(
366
+ inputs,
367
+ (m) => opts.onProgress?.({ phase: "capture-warning", message: m }),
368
+ );
369
+
226
370
  const functionsRoot = join(outDir, "functions", ...pathPrefix.split("/"));
227
371
  const entries: VercelFunctionEntry[] = [];
228
372
 
@@ -247,8 +391,11 @@ async function buildVercelOnce(
247
391
 
248
392
  const handler = fn.moduleFiles[0];
249
393
  const manifest = await loadManifest(functionsDir, fn.name);
394
+ // A `serve({ timeoutMs })` declaration wins over the manifest, matching
395
+ // the runtime precedence in `_shared/handler.ts`.
396
+ const declaredTimeoutMs = declaredConfigs.get(fn.name)?.timeoutMs;
250
397
  const maxDuration = clampMaxDuration(
251
- manifest.timeoutMs,
398
+ declaredTimeoutMs ?? manifest.timeoutMs,
252
399
  defaultMaxDuration,
253
400
  maxDurationCap,
254
401
  );
@@ -293,12 +440,31 @@ async function buildVercelOnce(
293
440
  }
294
441
 
295
442
  entries.sort((a, b) => a.name.localeCompare(b.name));
443
+
444
+ // Make every function also serve its sub-paths (e.g. /<prefix>/rooms/<id>),
445
+ // matching Supabase edge-runtime's first-segment routing. Done after the
446
+ // `.func` dirs exist so the rewrites resolve to real functions.
447
+ const configPath = join(outDir, "config.json");
448
+ const merge = await mergeSubpathRoutes(
449
+ outDir,
450
+ pathPrefix,
451
+ entries.map((e) => e.name),
452
+ );
453
+ opts.onProgress?.({
454
+ phase: "config-merge",
455
+ configPath,
456
+ routes: merge.routeCount,
457
+ created: merge.created,
458
+ });
459
+
296
460
  return {
297
461
  outDir,
298
462
  functionsRoot,
299
463
  runtime,
300
464
  pathPrefix,
301
465
  functions: entries,
466
+ configPath,
467
+ subpathRoutes: merge.routeCount,
302
468
  durationMs: performance.now() - startedAt,
303
469
  };
304
470
  } finally {
@@ -467,6 +633,14 @@ export async function runVercelBuild(args: string[]): Promise<number> {
467
633
  fmt(event.bytes)
468
634
  } (maxDuration ${event.maxDuration}s)`,
469
635
  );
636
+ } else if (event.phase === "config-merge") {
637
+ console.log(
638
+ `[1tube vercel-build] ${
639
+ event.created ? "wrote" : "merged"
640
+ } ${event.routes} sub-path route(s) into ${event.configPath}`,
641
+ );
642
+ } else if (event.phase === "capture-warning") {
643
+ console.warn(`[1tube vercel-build] ${event.message}`);
470
644
  }
471
645
  },
472
646
  });
@@ -504,8 +678,9 @@ Options:
504
678
  (default: ${DEFAULT_ESBUILD_TARGET})
505
679
  --path-prefix <p> Route prefix under functions/ (default: functions/v1)
506
680
  --runtime <id> Vercel runtime (default: nodejs24.x)
507
- --max-duration <s> Fallback maxDuration seconds when no 1tube.json
508
- timeoutMs is set (default: 800)
681
+ --max-duration <s> Fallback maxDuration seconds when a function
682
+ declares no timeoutMs via serve()/1tube.json
683
+ (default: 800)
509
684
  --max-duration-cap <s> Hard cap for maxDuration seconds (default: 800)
510
685
  --config <path> Explicit deno.json path for the import map.
511
686
  Default: auto-detect cwd/deno.json[c] then