1tube 0.1.35 → 0.1.36

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.35",
3
+ "version": "0.1.36",
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,307 @@
1
+ /**
2
+ * Vercel (Node / Fluid) bundle wrapper.
3
+ *
4
+ * Unlike the workerd wrapper (which installs a `__edgeFunctionRegistry` and
5
+ * exports a workerd `default { fetch(request, env, ctx) }`), the Vercel target
6
+ * runs each function as a standalone Vercel Node Function. The goals:
7
+ *
8
+ * 1. **Stay on the shared handler's standalone path.** The host project's
9
+ * `_shared/handler.ts` checks for `globalThis.__edgeFunctionRegistry`. We
10
+ * deliberately do NOT install it, so `serve()` falls through to
11
+ * `getStandaloneServe()` -> `globalThis.Deno.serve(...)`. The banner
12
+ * installs a `Deno.serve` shim that simply captures the fetch handler.
13
+ * This keeps auth (JWT verification, service-role), CORS preflight,
14
+ * claims checks, Supabase context construction, and route dispatch exactly
15
+ * where they already live — no per-function changes required.
16
+ *
17
+ * 2. **`Deno.env` -> `process.env`.** User code reads env via `Deno.env.get`.
18
+ * On Vercel Node there is no `Deno`, so the banner installs a `Deno.env`
19
+ * shim backed by `process.env`.
20
+ *
21
+ * 3. **`EdgeRuntime.waitUntil`.** Supabase Edge Runtime exposes
22
+ * `EdgeRuntime.waitUntil()` for background work after the response is sent.
23
+ * On Vercel the equivalent is provided through the `@vercel/request-context`
24
+ * global (which is precisely what `@vercel/functions`' `waitUntil()` reads).
25
+ * The banner shims `globalThis.EdgeRuntime.waitUntil` onto it, and falls
26
+ * back to locally draining pending promises before the handler resolves
27
+ * when no platform context is present (e.g. local execution / tests).
28
+ *
29
+ * 4. **Node request/response bridge.** The footer exports a Vercel Node
30
+ * handler `(req, res)` that converts the Node request into a Web `Request`
31
+ * (preserving the raw body stream for uploads), invokes the captured fetch
32
+ * handler, and streams the Web `Response` back to the Node `res` (so SSE /
33
+ * NDJSON / AI token streaming keep flowing incrementally).
34
+ *
35
+ * The banner intentionally avoids the workerd console-capture marker: on Vercel
36
+ * `console.*` is already routed to the function's logs.
37
+ */
38
+
39
+ /**
40
+ * Node builtin module names. Imports of these (bare or `node:`-prefixed) are
41
+ * marked external for the Vercel target so the real Node runtime provides them
42
+ * instead of bundling Deno's polyfills. Subpath imports (e.g. `stream/web`,
43
+ * `fs/promises`) are matched by their head segment.
44
+ */
45
+ export const NODE_BUILTIN_MODULES: readonly string[] = [
46
+ "assert",
47
+ "async_hooks",
48
+ "buffer",
49
+ "child_process",
50
+ "cluster",
51
+ "console",
52
+ "constants",
53
+ "crypto",
54
+ "dgram",
55
+ "diagnostics_channel",
56
+ "dns",
57
+ "domain",
58
+ "events",
59
+ "fs",
60
+ "http",
61
+ "http2",
62
+ "https",
63
+ "inspector",
64
+ "module",
65
+ "net",
66
+ "os",
67
+ "path",
68
+ "perf_hooks",
69
+ "process",
70
+ "punycode",
71
+ "querystring",
72
+ "readline",
73
+ "repl",
74
+ "stream",
75
+ "string_decoder",
76
+ "sys",
77
+ "timers",
78
+ "tls",
79
+ "trace_events",
80
+ "tty",
81
+ "url",
82
+ "util",
83
+ "v8",
84
+ "vm",
85
+ "wasi",
86
+ "worker_threads",
87
+ "zlib",
88
+ ];
89
+
90
+ /**
91
+ * Banner injected at the top of every Vercel bundle. Runs before the user
92
+ * entrypoint import so top-level `Deno.env.get(...)` / `serve(...)` calls find
93
+ * the shims installed. The captured handler lives in module scope; the footer
94
+ * exports the Vercel Node handler that dispatches to it.
95
+ */
96
+ export const VERCEL_BANNER = `// AUTO-GENERATED by 1tube vercel bundler — do not edit
97
+ import { Readable as __1tubeReadable } from "node:stream";
98
+
99
+ let __1tubeCapturedHandler;
100
+ let __1tubeCapturedOpts = { public: false };
101
+ const __1tubePending = [];
102
+
103
+ // --- Deno.env / Deno.serve shim ------------------------------------------
104
+ const __1tubeProcessEnv = () => (globalThis.process && globalThis.process.env) || {};
105
+ const __1tubeDenoEnv = {
106
+ get(key) {
107
+ const v = __1tubeProcessEnv()[key];
108
+ return typeof v === "string" ? v : undefined;
109
+ },
110
+ set(key, value) {
111
+ __1tubeProcessEnv()[key] = String(value);
112
+ },
113
+ delete(key) {
114
+ delete __1tubeProcessEnv()[key];
115
+ },
116
+ has(key) {
117
+ return typeof __1tubeProcessEnv()[key] === "string";
118
+ },
119
+ toObject() {
120
+ const out = {};
121
+ const env = __1tubeProcessEnv();
122
+ for (const k of Object.keys(env)) {
123
+ if (typeof env[k] === "string") out[k] = env[k];
124
+ }
125
+ return out;
126
+ },
127
+ };
128
+
129
+ // Captures the fetch handler passed to Deno.serve(). Deno.serve supports
130
+ // serve(handler), serve(options, handler) and serve({ handler }); we accept
131
+ // all three shapes. Returns a Deno.HttpServer-like object so callers that
132
+ // inspect the return value don't crash.
133
+ function __1tubeCaptureServe(arg1, arg2) {
134
+ const handler = typeof arg1 === "function"
135
+ ? arg1
136
+ : (typeof arg2 === "function"
137
+ ? arg2
138
+ : (arg1 && typeof arg1.handler === "function" ? arg1.handler : undefined));
139
+ if (typeof handler === "function") __1tubeCapturedHandler = handler;
140
+ return {
141
+ finished: Promise.resolve(),
142
+ shutdown() { return Promise.resolve(); },
143
+ ref() {},
144
+ unref() {},
145
+ addr: { transport: "tcp", hostname: "0.0.0.0", port: 0 },
146
+ };
147
+ }
148
+
149
+ if (typeof globalThis.Deno === "undefined") {
150
+ // Pure Node (Vercel): install the full shim.
151
+ Object.defineProperty(globalThis, "Deno", {
152
+ value: { env: __1tubeDenoEnv, serve: __1tubeCaptureServe },
153
+ writable: true,
154
+ configurable: true,
155
+ enumerable: false,
156
+ });
157
+ } else {
158
+ // Host Deno (1tube's own tests): override only serve so the handler is
159
+ // captured instead of binding a real port; leave the rest of Deno intact.
160
+ try {
161
+ globalThis.Deno.serve = __1tubeCaptureServe;
162
+ } catch (_e) { /* serve not writable — capture unavailable in this realm */ }
163
+ }
164
+
165
+ // --- EdgeRuntime.waitUntil shim ------------------------------------------
166
+ function __1tubeRequestContext() {
167
+ try {
168
+ const rc = globalThis[Symbol.for("@vercel/request-context")];
169
+ return rc && typeof rc.get === "function" ? rc.get() : undefined;
170
+ } catch (_e) {
171
+ return undefined;
172
+ }
173
+ }
174
+ function __1tubeHasPlatformWaitUntil() {
175
+ const ctx = __1tubeRequestContext();
176
+ return !!(ctx && typeof ctx.waitUntil === "function");
177
+ }
178
+ function __1tubePlatformWaitUntil(promise) {
179
+ const ctx = __1tubeRequestContext();
180
+ if (ctx && typeof ctx.waitUntil === "function") {
181
+ try {
182
+ ctx.waitUntil(promise);
183
+ return true;
184
+ } catch (_e) { /* fall through to local draining */ }
185
+ }
186
+ return false;
187
+ }
188
+ if (!globalThis.EdgeRuntime || typeof globalThis.EdgeRuntime.waitUntil !== "function") {
189
+ const edgeRuntime = (globalThis.EdgeRuntime && typeof globalThis.EdgeRuntime === "object")
190
+ ? globalThis.EdgeRuntime
191
+ : {};
192
+ edgeRuntime.waitUntil = (promise) => {
193
+ const p = Promise.resolve(promise);
194
+ __1tubePending.push(p.catch(() => {}));
195
+ __1tubePlatformWaitUntil(p);
196
+ return p;
197
+ };
198
+ try {
199
+ globalThis.EdgeRuntime = edgeRuntime;
200
+ } catch (_e) { /* read-only EdgeRuntime — waitUntil shim unavailable */ }
201
+ }
202
+ `;
203
+
204
+ /**
205
+ * Footer injected at the bottom of every Vercel bundle. Exports the Vercel
206
+ * Node handler. By the time Vercel invokes it the entrypoint import has run, so
207
+ * `__1tubeCapturedHandler` is populated.
208
+ */
209
+ export const VERCEL_FOOTER = `
210
+ function __1tubeHeadersFromNode(req) {
211
+ const headers = new Headers();
212
+ const raw = req.headers || {};
213
+ for (const key of Object.keys(raw)) {
214
+ const value = raw[key];
215
+ if (Array.isArray(value)) {
216
+ for (const v of value) headers.append(key, String(v));
217
+ } else if (value !== undefined && value !== null) {
218
+ headers.set(key, String(value));
219
+ }
220
+ }
221
+ return headers;
222
+ }
223
+
224
+ function __1tubeToWebRequest(req) {
225
+ const headers = __1tubeHeadersFromNode(req);
226
+ const xfproto = headers.get("x-forwarded-proto");
227
+ const proto = (xfproto ? xfproto.split(",")[0] : "https").trim() || "https";
228
+ const host = headers.get("x-forwarded-host") || headers.get("host") || "localhost";
229
+ const path = req.url || "/";
230
+ const url = /^https?:\\/\\//.test(path) ? path : (proto + "://" + host + path);
231
+ const method = (req.method || "GET").toUpperCase();
232
+ const init = { method, headers };
233
+ if (method !== "GET" && method !== "HEAD") {
234
+ // Stream the raw request body through untouched — multipart uploads and
235
+ // proxied request streams must not be buffered/parsed here.
236
+ init.body = __1tubeReadable.toWeb(req);
237
+ init.duplex = "half";
238
+ }
239
+ return new Request(url, init);
240
+ }
241
+
242
+ async function __1tubeWriteWebResponse(res, response) {
243
+ res.statusCode = response.status;
244
+ const setCookie = typeof response.headers.getSetCookie === "function"
245
+ ? response.headers.getSetCookie()
246
+ : [];
247
+ response.headers.forEach((value, key) => {
248
+ if (key.toLowerCase() === "set-cookie") return;
249
+ try { res.setHeader(key, value); } catch (_e) { /* ignore invalid header */ }
250
+ });
251
+ if (setCookie.length > 0) {
252
+ try { res.setHeader("set-cookie", setCookie); } catch (_e) { /* ignore */ }
253
+ }
254
+ if (!response.body) {
255
+ res.end();
256
+ return;
257
+ }
258
+ const reader = response.body.getReader();
259
+ try {
260
+ while (true) {
261
+ const { done, value } = await reader.read();
262
+ if (done) break;
263
+ if (value && value.length > 0) {
264
+ const ok = res.write(value);
265
+ if (ok === false) {
266
+ await new Promise((resolve) => res.once("drain", resolve));
267
+ }
268
+ }
269
+ }
270
+ } finally {
271
+ res.end();
272
+ }
273
+ }
274
+
275
+ export default async function handler(req, res) {
276
+ try {
277
+ if (typeof __1tubeCapturedHandler !== "function") {
278
+ res.statusCode = 500;
279
+ res.setHeader("content-type", "application/json");
280
+ res.end(JSON.stringify({ error: "function did not register a handler" }));
281
+ return;
282
+ }
283
+ const request = __1tubeToWebRequest(req);
284
+ const response = await __1tubeCapturedHandler(request);
285
+ await __1tubeWriteWebResponse(res, response);
286
+ } catch (err) {
287
+ const message = err instanceof Error ? err.message : String(err);
288
+ try {
289
+ console.error(err instanceof Error ? (err.stack || message) : message);
290
+ } catch (_e) { /* logging must never throw */ }
291
+ if (!res.headersSent) {
292
+ res.statusCode = 500;
293
+ res.setHeader("content-type", "application/json");
294
+ res.end(JSON.stringify({ error: message }));
295
+ } else {
296
+ try { res.end(); } catch (_e) { /* already ended */ }
297
+ }
298
+ } finally {
299
+ // When no platform waitUntil is available (local execution / tests), drain
300
+ // background work before resolving so it isn't dropped.
301
+ if (!__1tubeHasPlatformWaitUntil() && __1tubePending.length > 0) {
302
+ const pending = __1tubePending.splice(0, __1tubePending.length);
303
+ await Promise.allSettled(pending);
304
+ }
305
+ }
306
+ }
307
+ `;
@@ -44,6 +44,20 @@ import {
44
44
  SHARED_RUNTIME_URL_ENV,
45
45
  type SharedRuntimeModule,
46
46
  } from "./shared-runtime.ts";
47
+ import {
48
+ NODE_BUILTIN_MODULES,
49
+ VERCEL_BANNER,
50
+ VERCEL_FOOTER,
51
+ } from "../vercel/wrapper.ts";
52
+
53
+ /**
54
+ * Which runtime the chunked bundler emits for. `workerd` (default) wraps each
55
+ * function as a workerd `default { fetch }` registered through
56
+ * `__edgeFunctionRegistry`; `vercel` wraps it as a Vercel Node handler that
57
+ * captures `Deno.serve` and bridges Node req/res to Web fetch. See
58
+ * `../vercel/wrapper.ts` for the Vercel banner/footer.
59
+ */
60
+ export type BundleTarget = "workerd" | "vercel";
47
61
 
48
62
  /**
49
63
  * Banner injected at the top of every bundle. Runs before any user code so
@@ -485,14 +499,19 @@ function toPosix(path: string): string {
485
499
  return path.replace(/\\/g, "/");
486
500
  }
487
501
 
488
- function entryProxySource(entrypoint: string): string {
489
- return `${BANNER}
502
+ function entryProxySource(entrypoint: string, target: BundleTarget): string {
503
+ const banner = target === "vercel" ? VERCEL_BANNER : BANNER;
504
+ const footer = target === "vercel" ? VERCEL_FOOTER : FOOTER;
505
+ return `${banner}
490
506
  await import(${JSON.stringify(pathToFileURL(entrypoint).href)});
491
- ${FOOTER}
507
+ ${footer}
492
508
  `;
493
509
  }
494
510
 
495
- function entryProxyPlugin(inputs: readonly BundleInput[]): esbuild.Plugin {
511
+ function entryProxyPlugin(
512
+ inputs: readonly BundleInput[],
513
+ target: BundleTarget,
514
+ ): esbuild.Plugin {
496
515
  const byName = new Map(inputs.map((input) => [input.name, input]));
497
516
  return {
498
517
  name: "1tube-entry-proxy",
@@ -513,7 +532,7 @@ function entryProxyPlugin(inputs: readonly BundleInput[]): esbuild.Plugin {
513
532
  );
514
533
  }
515
534
  return {
516
- contents: entryProxySource(input.entrypoint),
535
+ contents: entryProxySource(input.entrypoint, target),
517
536
  loader: "js",
518
537
  resolveDir: dirname(input.entrypoint),
519
538
  };
@@ -523,6 +542,40 @@ function entryProxyPlugin(inputs: readonly BundleInput[]): esbuild.Plugin {
523
542
  };
524
543
  }
525
544
 
545
+ const NODE_BUILTIN_SET = new Set<string>(NODE_BUILTIN_MODULES);
546
+
547
+ function isNodeBuiltinBare(spec: string): boolean {
548
+ const slash = spec.indexOf("/");
549
+ const head = slash === -1 ? spec : spec.slice(0, slash);
550
+ return NODE_BUILTIN_SET.has(head);
551
+ }
552
+
553
+ /**
554
+ * For the Vercel (Node) target, leave Node builtins to the host runtime
555
+ * instead of bundling Deno's polyfills for them. `node:`-prefixed specifiers
556
+ * are always external; bare builtins (`crypto`, `stream`, `stream/web`, ...)
557
+ * are rewritten to their `node:` form and externalized. Non-builtins fall
558
+ * through to the deno loader for npm/jsr resolution. Registered before the
559
+ * deno plugin so it wins for these specifiers.
560
+ */
561
+ function nodeBuiltinsExternalPlugin(): esbuild.Plugin {
562
+ return {
563
+ name: "1tube-node-builtins-external",
564
+ setup(build) {
565
+ build.onResolve({ filter: /^node:/ }, (args) => ({
566
+ path: args.path,
567
+ external: true,
568
+ }));
569
+ build.onResolve({ filter: /^[A-Za-z][A-Za-z0-9._/-]*$/ }, (args) => {
570
+ if (isNodeBuiltinBare(args.path)) {
571
+ return { path: `node:${args.path}`, external: true };
572
+ }
573
+ return null;
574
+ });
575
+ },
576
+ };
577
+ }
578
+
526
579
  async function patchOutputFile(path: string): Promise<void> {
527
580
  const emitted = await Deno.readTextFile(path);
528
581
  const patched = patchCjsDefaultInterop(emitted);
@@ -692,14 +745,21 @@ export function createBundler(opts: BundlerOptions): Bundler {
692
745
  export async function bundleAllChunked(
693
746
  opts: BundlerOptions & {
694
747
  inputs: BundleInput[];
748
+ /** Runtime to emit for. Defaults to `workerd`. */
749
+ target?: BundleTarget;
695
750
  },
696
751
  ): Promise<ChunkedBundleAllResult> {
697
752
  await ensureDir(opts.outDir);
698
753
  const start = performance.now();
754
+ const target: BundleTarget = opts.target ?? "workerd";
755
+ const isVercel = target === "vercel";
699
756
  const sourcemap = opts.sourcemap ?? "linked";
700
757
  const sharedModules = opts.sharedModules ?? [];
701
758
  const plugins = [
702
- entryProxyPlugin(opts.inputs),
759
+ // Node builtins must be externalized before the deno loader gets a
760
+ // chance to substitute its polyfills for the Vercel/Node target.
761
+ ...(isVercel ? [nodeBuiltinsExternalPlugin()] : []),
762
+ entryProxyPlugin(opts.inputs, target),
703
763
  ...(sharedModules.length > 0
704
764
  ? [sharedModulesExternalPlugin(sharedModules)]
705
765
  : []),
@@ -722,8 +782,13 @@ export async function bundleAllChunked(
722
782
  splitting: true,
723
783
  format: "esm",
724
784
  target: "es2022",
725
- platform: "neutral",
726
- conditions: ["worker", "browser", "import", "default"],
785
+ // Vercel runs on Node, so resolve the node conditions and let the
786
+ // host runtime provide builtins; workerd uses the leaner
787
+ // worker/browser conditions with nodejs_compat polyfills.
788
+ platform: isVercel ? "node" : "neutral",
789
+ conditions: isVercel
790
+ ? ["node", "import", "default"]
791
+ : ["worker", "browser", "import", "default"],
727
792
  sourcemap,
728
793
  minify: opts.minify ?? false,
729
794
  treeShaking: true,
@@ -737,7 +802,7 @@ export async function bundleAllChunked(
737
802
  });
738
803
  } catch (err) {
739
804
  const cause = err instanceof Error ? err.message : String(err);
740
- throw new Error(`workerd chunked bundle failed\n${cause}`, {
805
+ throw new Error(`${target} chunked bundle failed\n${cause}`, {
741
806
  cause: err instanceof Error ? err : undefined,
742
807
  });
743
808
  }
package/src/cli/build.ts CHANGED
@@ -419,6 +419,35 @@ export async function build(opts: BuildOptions): Promise<BuildResult> {
419
419
  * --workerd-shared path repeatable; module to run in gateway shared runtime
420
420
  */
421
421
  export async function runBuild(args: string[]): Promise<number> {
422
+ // --target selects the backend artifact. Default `workerd` (dist/ +
423
+ // manifest.json); `--target vercel` delegates to the Vercel Build Output
424
+ // API builder, which owns its own flags (--path-prefix, --runtime,
425
+ // --max-duration, …). Pre-scanned here so we route before parsing any
426
+ // workerd-specific flags.
427
+ let target = "workerd";
428
+ const rest: string[] = [];
429
+ for (let i = 0; i < args.length; i++) {
430
+ const a = args[i];
431
+ if ((a === "--target" || a === "-t") && args[i + 1]) {
432
+ target = args[++i];
433
+ } else if (a.startsWith("--target=")) {
434
+ target = a.slice("--target=".length);
435
+ } else {
436
+ rest.push(a);
437
+ }
438
+ }
439
+ if (target === "vercel") {
440
+ const { runVercelBuild } = await import("./vercel-build.ts");
441
+ return await runVercelBuild(rest);
442
+ }
443
+ if (target !== "workerd") {
444
+ console.error(
445
+ `[1tube build] unknown --target: ${target} (expected "workerd" or "vercel")`,
446
+ );
447
+ return 2;
448
+ }
449
+ args = rest;
450
+
422
451
  let functionsDir = Deno.env.get("FUNCTIONS_PATH") || "./supabase/functions";
423
452
  let outDir = "./dist";
424
453
  let only: string[] | undefined;
@@ -713,6 +742,8 @@ export async function runBuild(args: string[]): Promise<number> {
713
742
  const BUILD_USAGE = `Usage: 1tube build [options]
714
743
 
715
744
  Options:
745
+ -t, --target NAME Build target: workerd (default) | vercel
746
+ For vercel options: 1tube build --target vercel --help
716
747
  -f, --functions <path> Function source dir (default: ./supabase/functions)
717
748
  -o, --out <path> Output directory (default: ./dist)
718
749
  --only A,B,C Build only the named subset
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Shared Deno-config resolution for the build commands.
3
+ *
4
+ * esbuild's Deno loader needs the host project's `deno.json[c]` to apply the
5
+ * import map. Both `1tube build` (workerd) and `1tube vercel-build` resolve it
6
+ * the same way, in priority order:
7
+ *
8
+ * 1. explicit `--config <path>` → must exist (hard error otherwise)
9
+ * 2. explicit `--no-config` → skip the import map entirely
10
+ * 3. auto-detect → cwd/deno.json[c] then
11
+ * <functions-dir>/deno.json[c]
12
+ *
13
+ * The functions-dir fallback is what makes this work for hosts like
14
+ * sciobot-next: a Vite/Bun project whose only `deno.json` lives next to the
15
+ * edge functions (Supabase layout), not at repo root.
16
+ */
17
+
18
+ import { isAbsolute, resolve as resolvePath } from "node:path";
19
+
20
+ export interface ResolveDenoConfigOptions {
21
+ /** Function source dir (absolute or relative to cwd). */
22
+ functionsDir: string;
23
+ /**
24
+ * Explicit `--config` value. `undefined` = auto-detect; `""` = `--no-config`
25
+ * (skip the import map); otherwise an explicit path that must exist.
26
+ */
27
+ configArg?: string | undefined;
28
+ /** Sink for human-facing detection messages (no prefix). */
29
+ onMessage?: (message: string) => void;
30
+ }
31
+
32
+ export interface ResolveDenoConfigResult {
33
+ /** Resolved absolute config path, or undefined when none applies. */
34
+ configPath?: string;
35
+ /** Set when an explicit `--config` path was unreadable. */
36
+ error?: string;
37
+ }
38
+
39
+ export async function resolveDenoConfigPath(
40
+ opts: ResolveDenoConfigOptions,
41
+ ): Promise<ResolveDenoConfigResult> {
42
+ const { functionsDir, configArg, onMessage } = opts;
43
+
44
+ if (configArg !== undefined) {
45
+ if (configArg === "") return {};
46
+ const abs = isAbsolute(configArg)
47
+ ? configArg
48
+ : resolvePath(Deno.cwd(), configArg);
49
+ try {
50
+ const stat = await Deno.stat(abs);
51
+ if (!stat.isFile) throw new Error("not a regular file");
52
+ return { configPath: abs };
53
+ } catch (err) {
54
+ return {
55
+ error: `--config path not readable: ${abs} (${(err as Error).message})`,
56
+ };
57
+ }
58
+ }
59
+
60
+ const fnDirAbs = isAbsolute(functionsDir)
61
+ ? functionsDir
62
+ : resolvePath(Deno.cwd(), functionsDir);
63
+ const candidates = [
64
+ `${Deno.cwd()}/deno.json`,
65
+ `${Deno.cwd()}/deno.jsonc`,
66
+ `${fnDirAbs}/deno.json`,
67
+ `${fnDirAbs}/deno.jsonc`,
68
+ ];
69
+ for (const abs of candidates) {
70
+ try {
71
+ const stat = await Deno.stat(abs);
72
+ if (stat.isFile) {
73
+ onMessage?.(`using deno config: ${abs}`);
74
+ return { configPath: abs };
75
+ }
76
+ } catch { /* not found, try next */ }
77
+ }
78
+ onMessage?.(
79
+ `no deno.json / deno.jsonc at ${Deno.cwd()} or ${fnDirAbs} — bundling ` +
80
+ `without an import map. Pass --config <path> if you want one.`,
81
+ );
82
+ return {};
83
+ }
@@ -0,0 +1,497 @@
1
+ /**
2
+ * `1tube vercel-build` — compile edge functions into Vercel Build Output API
3
+ * function artifacts.
4
+ *
5
+ * Unlike `1tube build` (which produces a workerd `dist/` + `manifest.json`),
6
+ * this command emits one Vercel Node Function per edge function, laid out for
7
+ * the Build Output API so `vercel deploy --prebuilt` ships them as-is:
8
+ *
9
+ * .vercel/output/
10
+ * functions/
11
+ * functions/v1/<name>.func/
12
+ * .vc-config.json — runtime + handler + streaming/maxDuration
13
+ * package.json — { "type": "module" } so .js is treated ESM
14
+ * functions/<name>.js — esbuild entry (the Vercel Node handler)
15
+ * chunks/<hash>.js — shared ESM chunks (copied per function)
16
+ *
17
+ * A function placed at `functions/functions/v1/<name>.func` is served by Vercel
18
+ * at the route `/functions/v1/<name>` — matching the path shape the frontend
19
+ * already calls (see sciobot's `API_CONFIG`). No per-function source changes are
20
+ * required: the Vercel bundle wrapper captures `Deno.serve` and bridges Node
21
+ * req/res to the Web fetch handler (see `../backends/vercel/wrapper.ts`).
22
+ *
23
+ * This command MERGES into an existing `.vercel/output` (e.g. one produced by
24
+ * `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.
27
+ *
28
+ * Each function is fully standalone — there is no gateway shared runtime on
29
+ * Vercel, so modules like `_shared/profile-cache.ts` are bundled inline rather
30
+ * than turned into RPC stubs.
31
+ */
32
+
33
+ import { ensureDir } from "jsr:@std/fs@^1/ensure-dir";
34
+ import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
35
+ import {
36
+ bundleAllChunked,
37
+ type ChunkedBundleAllResult,
38
+ discoverEntrypoints,
39
+ disposeBundlerResources,
40
+ } from "../backends/workerd/bundler.ts";
41
+ import { loadManifest } from "../manifest.ts";
42
+ import { resolveDenoConfigPath } from "./deno-config.ts";
43
+
44
+ /** Default Vercel managed Node runtime for emitted functions. */
45
+ const DEFAULT_RUNTIME = "nodejs24.x";
46
+ /** Default route prefix; matches sciobot's `/functions/v1/<name>` calls. */
47
+ const DEFAULT_PATH_PREFIX = "functions/v1";
48
+ /** Default function timeout (seconds) when no `1tube.json` timeoutMs is set. */
49
+ const DEFAULT_MAX_DURATION = 300;
50
+ /** Upper bound for maxDuration (seconds). Fluid/Pro allows long durations. */
51
+ const DEFAULT_MAX_DURATION_CAP = 800;
52
+
53
+ export interface VercelBuildOptions {
54
+ functionsDir: string;
55
+ /** Build Output API root, typically `.vercel/output`. */
56
+ outDir: string;
57
+ /** Path to host project's deno.json (for esbuild's Deno loader import map). */
58
+ configPath?: string;
59
+ /** Restrict the build to a subset of function names. */
60
+ only?: readonly string[];
61
+ /** Sourcemap mode. Defaults to "linked". */
62
+ sourcemap?: boolean | "linked" | "inline";
63
+ /** Minify bundles. Defaults to false. */
64
+ minify?: boolean;
65
+ /** Route prefix under `functions/`. Defaults to "functions/v1". */
66
+ pathPrefix?: string;
67
+ /** Vercel runtime identifier. Defaults to "nodejs24.x". */
68
+ runtime?: string;
69
+ /** Fallback maxDuration (seconds) when a function has no timeoutMs. */
70
+ defaultMaxDuration?: number;
71
+ /** Hard cap (seconds) applied to any derived maxDuration. */
72
+ maxDurationCap?: number;
73
+ /** Progress callback for CLI/status reporting. */
74
+ onProgress?: (event: VercelBuildProgress) => void;
75
+ }
76
+
77
+ export type VercelBuildProgress =
78
+ | { phase: "bundle-start"; functions: number }
79
+ | {
80
+ phase: "bundle-complete";
81
+ functions: number;
82
+ chunks: number;
83
+ durationMs: number;
84
+ }
85
+ | {
86
+ phase: "emit-function";
87
+ name: string;
88
+ route: string;
89
+ bytes: number;
90
+ maxDuration: number;
91
+ };
92
+
93
+ export interface VercelFunctionEntry {
94
+ /** Logical function name. */
95
+ name: string;
96
+ /** Public route, e.g. `functions/v1/hello`. */
97
+ route: string;
98
+ /** Absolute path to the emitted `<name>.func` directory. */
99
+ funcDir: string;
100
+ /** Handler path relative to `funcDir` (POSIX), e.g. `functions/hello.js`. */
101
+ handler: string;
102
+ /** All module files copied into the func dir (POSIX, entry first). */
103
+ moduleFiles: string[];
104
+ /** Entry bundle byte size. */
105
+ byteLength: number;
106
+ /** Derived Vercel maxDuration (seconds). */
107
+ maxDuration: number;
108
+ }
109
+
110
+ export interface VercelBuildResult {
111
+ /** Build Output API root. */
112
+ outDir: string;
113
+ /** Absolute path to `<outDir>/functions/<pathPrefix>`. */
114
+ functionsRoot: string;
115
+ /** Runtime baked into each `.vc-config.json`. */
116
+ runtime: string;
117
+ /** Effective route prefix. */
118
+ pathPrefix: string;
119
+ /** Emitted functions, sorted by name. */
120
+ functions: VercelFunctionEntry[];
121
+ durationMs: number;
122
+ }
123
+
124
+ /** `.vc-config.json` shape for a Build Output API Node function. */
125
+ interface VcConfig {
126
+ runtime: string;
127
+ handler: string;
128
+ launcherType: "Nodejs";
129
+ shouldAddHelpers: boolean;
130
+ shouldAddSourcemapSupport: boolean;
131
+ supportsResponseStreaming: boolean;
132
+ maxDuration: number;
133
+ }
134
+
135
+ async function removeDirIfExists(path: string): Promise<void> {
136
+ try {
137
+ await Deno.remove(path, { recursive: true });
138
+ } catch (err) {
139
+ if (!(err instanceof Deno.errors.NotFound)) throw err;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Convert a per-function `timeoutMs` into a Vercel maxDuration (seconds),
145
+ * clamped to `[1, cap]`. Falls back to `def` when no timeout is configured.
146
+ */
147
+ function clampMaxDuration(
148
+ timeoutMs: number | undefined,
149
+ def: number,
150
+ cap: number,
151
+ ): number {
152
+ const base = typeof timeoutMs === "number" && timeoutMs > 0
153
+ ? Math.ceil(timeoutMs / 1000)
154
+ : def;
155
+ return Math.max(1, Math.min(cap, base));
156
+ }
157
+
158
+ function normalizePrefix(prefix: string): string {
159
+ return prefix.replace(/^[/\\]+/, "").replace(/[/\\]+$/, "").replace(
160
+ /\\/g,
161
+ "/",
162
+ );
163
+ }
164
+
165
+ async function buildVercelOnce(
166
+ opts: VercelBuildOptions,
167
+ ): Promise<VercelBuildResult> {
168
+ const startedAt = performance.now();
169
+ const cwd = Deno.cwd();
170
+ const functionsDir = isAbsolute(opts.functionsDir)
171
+ ? opts.functionsDir
172
+ : resolvePath(cwd, opts.functionsDir);
173
+ const outDir = isAbsolute(opts.outDir)
174
+ ? opts.outDir
175
+ : resolvePath(cwd, opts.outDir);
176
+ const pathPrefix = normalizePrefix(opts.pathPrefix ?? DEFAULT_PATH_PREFIX);
177
+ const runtime = opts.runtime ?? DEFAULT_RUNTIME;
178
+ const sourcemap = opts.sourcemap ?? "linked";
179
+ const defaultMaxDuration = opts.defaultMaxDuration ?? DEFAULT_MAX_DURATION;
180
+ const maxDurationCap = opts.maxDurationCap ?? DEFAULT_MAX_DURATION_CAP;
181
+
182
+ let inputs = await discoverEntrypoints(functionsDir);
183
+ if (opts.only && opts.only.length > 0) {
184
+ const allow = new Set(opts.only);
185
+ inputs = inputs.filter((i) => allow.has(i.name));
186
+ }
187
+ if (inputs.length === 0) {
188
+ throw new Error(
189
+ `1tube vercel-build: no functions matched under ${functionsDir}` +
190
+ (opts.only ? ` (only=${JSON.stringify(opts.only)})` : ""),
191
+ );
192
+ }
193
+
194
+ // Bundle into a throwaway staging dir, then distribute each function's
195
+ // module files into its own `.func` directory. Staging keeps the chunk
196
+ // graph intact (shared chunks are emitted once) while letting us copy the
197
+ // reachable set per function so each `.func` is self-contained.
198
+ const stageDir = await Deno.makeTempDir({ prefix: "1tube-vercel-" });
199
+ try {
200
+ opts.onProgress?.({ phase: "bundle-start", functions: inputs.length });
201
+ const bundleStart = performance.now();
202
+ const chunked: ChunkedBundleAllResult = await bundleAllChunked({
203
+ inputs,
204
+ outDir: stageDir,
205
+ target: "vercel",
206
+ ...(opts.configPath ? { configPath: opts.configPath } : {}),
207
+ sourcemap,
208
+ minify: opts.minify ?? false,
209
+ });
210
+ opts.onProgress?.({
211
+ phase: "bundle-complete",
212
+ functions: chunked.functions.length,
213
+ chunks: chunked.chunks.length,
214
+ durationMs: performance.now() - bundleStart,
215
+ });
216
+
217
+ const functionsRoot = join(outDir, "functions", ...pathPrefix.split("/"));
218
+ const entries: VercelFunctionEntry[] = [];
219
+
220
+ for (const fn of chunked.functions) {
221
+ const funcDir = join(functionsRoot, `${fn.name}.func`);
222
+ // Replace any stale artifact so removed chunks don't linger.
223
+ await removeDirIfExists(funcDir);
224
+
225
+ for (const moduleFile of fn.moduleFiles) {
226
+ const src = join(stageDir, moduleFile);
227
+ const dest = join(funcDir, moduleFile);
228
+ await ensureDir(dirname(dest));
229
+ await Deno.copyFile(src, dest);
230
+ if (sourcemap === "linked") {
231
+ try {
232
+ await Deno.copyFile(`${src}.map`, `${dest}.map`);
233
+ } catch (err) {
234
+ if (!(err instanceof Deno.errors.NotFound)) throw err;
235
+ }
236
+ }
237
+ }
238
+
239
+ const handler = fn.moduleFiles[0];
240
+ const manifest = await loadManifest(functionsDir, fn.name);
241
+ const maxDuration = clampMaxDuration(
242
+ manifest.timeoutMs,
243
+ defaultMaxDuration,
244
+ maxDurationCap,
245
+ );
246
+
247
+ const vcConfig: VcConfig = {
248
+ runtime,
249
+ handler,
250
+ launcherType: "Nodejs",
251
+ shouldAddHelpers: false,
252
+ shouldAddSourcemapSupport: sourcemap === "linked",
253
+ supportsResponseStreaming: true,
254
+ maxDuration,
255
+ };
256
+ await Deno.writeTextFile(
257
+ join(funcDir, ".vc-config.json"),
258
+ JSON.stringify(vcConfig, null, 2) + "\n",
259
+ );
260
+ // package.json with type:module so Vercel's Node launcher loads the
261
+ // emitted `.js` files as ESM (esbuild output uses import/export).
262
+ await Deno.writeTextFile(
263
+ join(funcDir, "package.json"),
264
+ JSON.stringify({ type: "module" }, null, 2) + "\n",
265
+ );
266
+
267
+ const route = `${pathPrefix}/${fn.name}`;
268
+ entries.push({
269
+ name: fn.name,
270
+ route,
271
+ funcDir,
272
+ handler,
273
+ moduleFiles: [...fn.moduleFiles],
274
+ byteLength: fn.byteLength,
275
+ maxDuration,
276
+ });
277
+ opts.onProgress?.({
278
+ phase: "emit-function",
279
+ name: fn.name,
280
+ route,
281
+ bytes: fn.byteLength,
282
+ maxDuration,
283
+ });
284
+ }
285
+
286
+ entries.sort((a, b) => a.name.localeCompare(b.name));
287
+ return {
288
+ outDir,
289
+ functionsRoot,
290
+ runtime,
291
+ pathPrefix,
292
+ functions: entries,
293
+ durationMs: performance.now() - startedAt,
294
+ };
295
+ } finally {
296
+ await removeDirIfExists(stageDir);
297
+ }
298
+ }
299
+
300
+ export async function buildVercel(
301
+ opts: VercelBuildOptions,
302
+ ): Promise<VercelBuildResult> {
303
+ try {
304
+ return await buildVercelOnce(opts);
305
+ } finally {
306
+ // Always release esbuild's worker subprocess — on Windows it otherwise
307
+ // lingers and blocks deletion of the staging dir / output.
308
+ await disposeBundlerResources().catch(() => {});
309
+ }
310
+ }
311
+
312
+ /**
313
+ * CLI entry point. Returns a process exit code (does not call `Deno.exit`).
314
+ *
315
+ * Recognised flags (all optional):
316
+ * --functions <path> function source dirs (default ./supabase/functions)
317
+ * --out <path> Build Output API root (default ./.vercel/output)
318
+ * --only A,B,C build a subset of functions
319
+ * --sourcemap none|linked|inline (default: linked)
320
+ * --minify minify bundles
321
+ * --path-prefix <p> route prefix under functions/ (default functions/v1)
322
+ * --runtime <id> Vercel runtime (default nodejs24.x)
323
+ * --max-duration <s> fallback maxDuration seconds (default 300)
324
+ * --max-duration-cap <s> hard cap for maxDuration (default 800)
325
+ * --config <path> explicit deno.json path
326
+ * --no-config skip the import map
327
+ */
328
+ export async function runVercelBuild(args: string[]): Promise<number> {
329
+ let functionsDir = Deno.env.get("FUNCTIONS_PATH") || "./supabase/functions";
330
+ let outDir = "./.vercel/output";
331
+ let only: string[] | undefined;
332
+ let sourcemap: boolean | "linked" | "inline" = "linked";
333
+ let minify = false;
334
+ let pathPrefix = DEFAULT_PATH_PREFIX;
335
+ let runtime = DEFAULT_RUNTIME;
336
+ let defaultMaxDuration = DEFAULT_MAX_DURATION;
337
+ let maxDurationCap = DEFAULT_MAX_DURATION_CAP;
338
+ let configPathArg: string | undefined;
339
+
340
+ for (let i = 0; i < args.length; i++) {
341
+ const a = args[i];
342
+ if ((a === "--functions" || a === "-f") && args[i + 1]) {
343
+ functionsDir = args[++i];
344
+ } else if ((a === "--out" || a === "-o") && args[i + 1]) {
345
+ outDir = args[++i];
346
+ } else if (a === "--only" && args[i + 1]) {
347
+ only = args[++i].split(",").map((s) => s.trim()).filter((s) =>
348
+ s.length > 0
349
+ );
350
+ } else if (a === "--sourcemap" && args[i + 1]) {
351
+ const v = args[++i];
352
+ if (v === "none" || v === "false" || v === "off") sourcemap = false;
353
+ else if (v === "inline") sourcemap = "inline";
354
+ else if (v === "linked" || v === "true" || v === "on") {
355
+ sourcemap = "linked";
356
+ } else {
357
+ console.error(`[1tube vercel-build] unknown --sourcemap value: ${v}`);
358
+ return 2;
359
+ }
360
+ } else if (a === "--minify") {
361
+ minify = true;
362
+ } else if (a === "--path-prefix" && args[i + 1]) {
363
+ pathPrefix = args[++i];
364
+ } else if (a.startsWith("--path-prefix=")) {
365
+ pathPrefix = a.slice("--path-prefix=".length);
366
+ } else if (a === "--runtime" && args[i + 1]) {
367
+ runtime = args[++i];
368
+ } else if (a.startsWith("--runtime=")) {
369
+ runtime = a.slice("--runtime=".length);
370
+ } else if (a === "--max-duration" && args[i + 1]) {
371
+ const n = parseInt(args[++i], 10);
372
+ if (!Number.isFinite(n) || n < 1) {
373
+ console.error(
374
+ `[1tube vercel-build] --max-duration must be a positive integer`,
375
+ );
376
+ return 2;
377
+ }
378
+ defaultMaxDuration = n;
379
+ } else if (a === "--max-duration-cap" && args[i + 1]) {
380
+ const n = parseInt(args[++i], 10);
381
+ if (!Number.isFinite(n) || n < 1) {
382
+ console.error(
383
+ `[1tube vercel-build] --max-duration-cap must be a positive integer`,
384
+ );
385
+ return 2;
386
+ }
387
+ maxDurationCap = n;
388
+ } else if (a === "--config" && args[i + 1]) {
389
+ configPathArg = args[++i];
390
+ } else if (a.startsWith("--config=")) {
391
+ configPathArg = a.slice("--config=".length);
392
+ } else if (a === "--no-config") {
393
+ configPathArg = "";
394
+ } else if (a === "--help" || a === "-h") {
395
+ console.log(VERCEL_BUILD_USAGE);
396
+ return 0;
397
+ } else {
398
+ console.error(
399
+ `[1tube vercel-build] unknown argument: ${a}\n${VERCEL_BUILD_USAGE}`,
400
+ );
401
+ return 2;
402
+ }
403
+ }
404
+
405
+ const configResult = await resolveDenoConfigPath({
406
+ functionsDir,
407
+ configArg: configPathArg,
408
+ onMessage: (m) => console.log(`[1tube vercel-build] ${m}`),
409
+ });
410
+ if (configResult.error) {
411
+ console.error(`[1tube vercel-build] ${configResult.error}`);
412
+ return 2;
413
+ }
414
+ const configPath = configResult.configPath;
415
+
416
+ console.log(
417
+ `[1tube vercel-build] bundling functions from ${functionsDir} → ${outDir} ` +
418
+ `(runtime ${runtime}, route /${normalizePrefix(pathPrefix)}/<name>)`,
419
+ );
420
+ const fmt = (n: number) => {
421
+ if (n < 1024) return `${n}B`;
422
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
423
+ return `${(n / (1024 * 1024)).toFixed(2)}MB`;
424
+ };
425
+
426
+ try {
427
+ const result = await buildVercel({
428
+ functionsDir,
429
+ outDir,
430
+ ...(configPath ? { configPath } : {}),
431
+ ...(only ? { only } : {}),
432
+ sourcemap,
433
+ minify,
434
+ pathPrefix,
435
+ runtime,
436
+ defaultMaxDuration,
437
+ maxDurationCap,
438
+ onProgress(event) {
439
+ if (event.phase === "bundle-start") {
440
+ console.log(
441
+ `[1tube vercel-build] bundling ${event.functions} function(s) for Vercel Node…`,
442
+ );
443
+ } else if (event.phase === "bundle-complete") {
444
+ console.log(
445
+ `[1tube vercel-build] bundled ${event.functions} function(s) and ${event.chunks} chunk(s) in ${
446
+ event.durationMs.toFixed(0)
447
+ }ms`,
448
+ );
449
+ } else if (event.phase === "emit-function") {
450
+ console.log(
451
+ `[1tube vercel-build] /${event.route} → ${
452
+ fmt(event.bytes)
453
+ } (maxDuration ${event.maxDuration}s)`,
454
+ );
455
+ }
456
+ },
457
+ });
458
+
459
+ const totalBytes = result.functions.reduce((acc, f) => acc + f.byteLength, 0);
460
+ console.log(
461
+ `[1tube vercel-build] emitted ${result.functions.length} function(s) ` +
462
+ `(${fmt(totalBytes)}) into ${result.functionsRoot} in ${
463
+ result.durationMs.toFixed(0)
464
+ }ms`,
465
+ );
466
+ console.log(
467
+ `[1tube vercel-build] deploy with: vercel deploy --prebuilt`,
468
+ );
469
+ return 0;
470
+ } catch (err) {
471
+ const msg = err instanceof Error ? err.message : String(err);
472
+ console.error(`[1tube vercel-build] FAILED: ${msg}`);
473
+ return 1;
474
+ }
475
+ }
476
+
477
+ const VERCEL_BUILD_USAGE = `Usage: 1tube build --target vercel [options]
478
+
479
+ Emits Vercel Build Output API function artifacts under <out>/functions/<prefix>/.
480
+ Merges into an existing .vercel/output (e.g. from \`vercel build\`).
481
+
482
+ Options:
483
+ -f, --functions <path> Function source dir (default: ./supabase/functions)
484
+ -o, --out <path> Build Output root (default: ./.vercel/output)
485
+ --only A,B,C Build only the named subset
486
+ --sourcemap MODE none | linked (default) | inline
487
+ --minify Minify bundles
488
+ --path-prefix <p> Route prefix under functions/ (default: functions/v1)
489
+ --runtime <id> Vercel runtime (default: nodejs24.x)
490
+ --max-duration <s> Fallback maxDuration seconds when no 1tube.json
491
+ timeoutMs is set (default: 300)
492
+ --max-duration-cap <s> Hard cap for maxDuration seconds (default: 800)
493
+ --config <path> Explicit deno.json path for the import map.
494
+ Default: auto-detect cwd/deno.json[c] then
495
+ <functions-dir>/deno.json[c] (Supabase layout).
496
+ --no-config Skip the import map entirely.
497
+ -h, --help Show this help`;
package/src/cli.ts CHANGED
@@ -36,7 +36,7 @@ if (arg0 === "build") {
36
36
 
37
37
  Commands:
38
38
  serve Run the gateway (default when no command given)
39
- build Bundle functions into a sealed artifact for prebuilt deploys
39
+ build Bundle functions for a target (--target workerd|vercel)
40
40
  package Build and/or wrap dist/ into a signed .1tube firmware payload
41
41
  --version Print version and exit
42
42
  --help Print this help