1tube 0.1.39 → 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.39",
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
 
@@ -47,8 +57,13 @@ import { resolveDenoConfigPath } from "./deno-config.ts";
47
57
  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
- /** Default function timeout (seconds) when no `1tube.json` timeoutMs is set. */
51
- const DEFAULT_MAX_DURATION = 300;
60
+ /**
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.
65
+ */
66
+ const DEFAULT_MAX_DURATION = 800;
52
67
  /** Upper bound for maxDuration (seconds). Fluid/Pro allows long durations. */
53
68
  const DEFAULT_MAX_DURATION_CAP = 800;
54
69
 
@@ -70,7 +85,10 @@ export interface VercelBuildOptions {
70
85
  pathPrefix?: string;
71
86
  /** Vercel runtime identifier. Defaults to "nodejs24.x". */
72
87
  runtime?: string;
73
- /** 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
+ */
74
92
  defaultMaxDuration?: number;
75
93
  /** Hard cap (seconds) applied to any derived maxDuration. */
76
94
  maxDurationCap?: number;
@@ -92,7 +110,14 @@ export type VercelBuildProgress =
92
110
  route: string;
93
111
  bytes: number;
94
112
  maxDuration: number;
95
- };
113
+ }
114
+ | {
115
+ phase: "config-merge";
116
+ configPath: string;
117
+ routes: number;
118
+ created: boolean;
119
+ }
120
+ | { phase: "capture-warning"; message: string };
96
121
 
97
122
  export interface VercelFunctionEntry {
98
123
  /** Logical function name. */
@@ -122,6 +147,10 @@ export interface VercelBuildResult {
122
147
  pathPrefix: string;
123
148
  /** Emitted functions, sorted by name. */
124
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;
125
154
  durationMs: number;
126
155
  }
127
156
 
@@ -166,6 +195,116 @@ function normalizePrefix(prefix: string): string {
166
195
  );
167
196
  }
168
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
+
169
308
  async function buildVercelOnce(
170
309
  opts: VercelBuildOptions,
171
310
  ): Promise<VercelBuildResult> {
@@ -219,6 +358,15 @@ async function buildVercelOnce(
219
358
  durationMs: performance.now() - bundleStart,
220
359
  });
221
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
+
222
370
  const functionsRoot = join(outDir, "functions", ...pathPrefix.split("/"));
223
371
  const entries: VercelFunctionEntry[] = [];
224
372
 
@@ -243,8 +391,11 @@ async function buildVercelOnce(
243
391
 
244
392
  const handler = fn.moduleFiles[0];
245
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;
246
397
  const maxDuration = clampMaxDuration(
247
- manifest.timeoutMs,
398
+ declaredTimeoutMs ?? manifest.timeoutMs,
248
399
  defaultMaxDuration,
249
400
  maxDurationCap,
250
401
  );
@@ -289,12 +440,31 @@ async function buildVercelOnce(
289
440
  }
290
441
 
291
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
+
292
460
  return {
293
461
  outDir,
294
462
  functionsRoot,
295
463
  runtime,
296
464
  pathPrefix,
297
465
  functions: entries,
466
+ configPath,
467
+ subpathRoutes: merge.routeCount,
298
468
  durationMs: performance.now() - startedAt,
299
469
  };
300
470
  } finally {
@@ -325,7 +495,7 @@ export async function buildVercel(
325
495
  * --minify minify bundles
326
496
  * --path-prefix <p> route prefix under functions/ (default functions/v1)
327
497
  * --runtime <id> Vercel runtime (default nodejs24.x)
328
- * --max-duration <s> fallback maxDuration seconds (default 300)
498
+ * --max-duration <s> fallback maxDuration seconds (default 800)
329
499
  * --max-duration-cap <s> hard cap for maxDuration (default 800)
330
500
  * --config <path> explicit deno.json path
331
501
  * --no-config skip the import map
@@ -463,6 +633,14 @@ export async function runVercelBuild(args: string[]): Promise<number> {
463
633
  fmt(event.bytes)
464
634
  } (maxDuration ${event.maxDuration}s)`,
465
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}`);
466
644
  }
467
645
  },
468
646
  });
@@ -500,8 +678,9 @@ Options:
500
678
  (default: ${DEFAULT_ESBUILD_TARGET})
501
679
  --path-prefix <p> Route prefix under functions/ (default: functions/v1)
502
680
  --runtime <id> Vercel runtime (default: nodejs24.x)
503
- --max-duration <s> Fallback maxDuration seconds when no 1tube.json
504
- timeoutMs is set (default: 300)
681
+ --max-duration <s> Fallback maxDuration seconds when a function
682
+ declares no timeoutMs via serve()/1tube.json
683
+ (default: 800)
505
684
  --max-duration-cap <s> Hard cap for maxDuration seconds (default: 800)
506
685
  --config <path> Explicit deno.json path for the import map.
507
686
  Default: auto-detect cwd/deno.json[c] then