1tube 0.1.34 → 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/README.md CHANGED
@@ -120,7 +120,12 @@ Deferred boot (default whenever `--dev` or `--hmr` is on) flips the order:
120
120
  the gateway starts serving right away. Time-to-first-request drops from
121
121
  "sum of all worker spawns" to effectively zero.
122
122
  2. Workers spawn in the background with the usual bounded concurrency
123
- (`--deno-worker-concurrency`, default 8).
123
+ (`--deno-worker-concurrency`, default 8), **most-recently-used first**:
124
+ the queue is ordered by each function's last invocation time from the
125
+ invocation log store, so the functions you were actually hitting before
126
+ the restart are warm within the first few seconds and the long tail of
127
+ rarely-called functions loads last. First run (empty store) falls back
128
+ to name order.
124
129
  3. A request for a function whose Worker isn't ready yet **bumps it to the
125
130
  front of the queue** and spawns it immediately, out-of-band. If it becomes
126
131
  ready within the grace window (`--warmup-grace-ms`, default 250 ms) the
@@ -190,6 +195,15 @@ little as possible:
190
195
  `X-1tube-Stale: 1` (CORS-exposed) so tooling can tell the difference.
191
196
  - **Targeted re-discovery.** Reloading one function stats only that function's
192
197
  dir instead of re-scanning the whole functions root.
198
+ - **Local-only dep graphs.** The module graph that maps file changes to
199
+ affected functions stops at the local-file boundary: `npm:`/`jsr:`/`https:`
200
+ subtrees are marked external instead of being fetched and parsed. Only
201
+ `file://` modules can fire watcher events, so nothing is lost — and graph
202
+ builds for npm-heavy functions drop from seconds to milliseconds.
203
+ - **One batch graph per deferred boot.** With shared modules active, deferred
204
+ boot builds a single module graph for all functions in the background
205
+ instead of letting every spawn crawl its own (which re-parsed the same
206
+ `_shared` files once per function).
193
207
 
194
208
  Notes:
195
209
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.34",
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",
@@ -164,6 +164,38 @@ function resolveAgainst(target: string, base: string): string {
164
164
  return new URL(target, baseUrl).href;
165
165
  }
166
166
 
167
+ /**
168
+ * Local-only module loader for `createGraph`.
169
+ *
170
+ * The graph exists solely to map fs-watcher events to affected
171
+ * functions, and only `file://` modules can ever fire those events.
172
+ * deno_graph's DEFAULT loader, however, follows every specifier —
173
+ * reading npm/jsr packages and even `fetch()`ing `https:` imports —
174
+ * which made big functions pay multi-second graph builds for data we
175
+ * throw away. Marking every non-`file:` specifier as external stops
176
+ * the crawl at the local-file boundary: remote subtrees are never
177
+ * read, parsed, or fetched.
178
+ */
179
+ async function loadLocalOnly(
180
+ specifier: string,
181
+ ): Promise<
182
+ | { kind: "module"; specifier: string; content: Uint8Array }
183
+ | { kind: "external"; specifier: string }
184
+ | undefined
185
+ > {
186
+ if (!specifier.startsWith("file://")) {
187
+ return { kind: "external", specifier };
188
+ }
189
+ try {
190
+ const content = await Deno.readFile(fileURLToPath(specifier));
191
+ return { kind: "module", specifier, content };
192
+ } catch {
193
+ // Missing file mid-edit: report "not found" and let deno_graph
194
+ // record a load error for this module instead of throwing.
195
+ return undefined;
196
+ }
197
+ }
198
+
167
199
  export function createDepGraph(options?: DepGraphOptions): DepGraph {
168
200
  const perName = new Map<string, Set<string>>();
169
201
  const reverse = new Map<string, Set<string>>();
@@ -247,7 +279,10 @@ export function createDepGraph(options?: DepGraphOptions): DepGraph {
247
279
  const entry = normalize(entryFileUrl);
248
280
  let graph;
249
281
  try {
250
- graph = await createGraph([entry], resolve ? { resolve } : undefined);
282
+ graph = await createGraph([entry], {
283
+ load: loadLocalOnly,
284
+ ...(resolve ? { resolve } : {}),
285
+ });
251
286
  } catch {
252
287
  // A save mid-flush can leave a syntactically broken file on
253
288
  // disk; deno_graph throws. Drop the graph for `name` so the
@@ -281,7 +316,10 @@ export function createDepGraph(options?: DepGraphOptions): DepGraph {
281
316
  try {
282
317
  graph = await createGraph(
283
318
  normalized.map((e) => e.entry),
284
- resolve ? { resolve } : undefined,
319
+ {
320
+ load: loadLocalOnly,
321
+ ...(resolve ? { resolve } : {}),
322
+ },
285
323
  );
286
324
  } catch {
287
325
  for (const e of normalized) replaceGraph(e.name, minimalGraph(e.entry));
@@ -93,6 +93,14 @@ export interface DenoWorkerHostOptions {
93
93
  captureConsole?: boolean;
94
94
  /** Sink for captured console lines + worker runtime errors. */
95
95
  onConsole?: (event: WorkerConsoleEvent) => void;
96
+ /**
97
+ * Last-dispatch timestamp per function from a PREVIOUS run (e.g. the
98
+ * invocation log store). Seeds the MRU ordering used by deferred
99
+ * boot and HMR respawns, so the functions the developer was hitting
100
+ * before a gateway restart warm up first instead of alphabetically.
101
+ * Fresh in-process dispatches overwrite the seed as traffic arrives.
102
+ */
103
+ initialRecency?: ReadonlyMap<string, number>;
96
104
  }
97
105
 
98
106
  export interface ReloadSummary {
@@ -595,7 +603,7 @@ export function createDenoWorkerHost(
595
603
  /** Spawns started out-of-band by prioritize(), above the lane bound. */
596
604
  const outOfBandSpawns = new Set<Promise<void>>();
597
605
  /** Last gateway dispatch per function — drives MRU reload ordering. */
598
- const lastDispatch = new Map<string, number>();
606
+ const lastDispatch = new Map<string, number>(opts.initialRecency ?? []);
599
607
  /**
600
608
  * Per-name spawn serialization. Deferred boot and HMR reload can both
601
609
  * want to spawn the same function concurrently (e.g. an edit lands
@@ -955,6 +963,13 @@ export function createDenoWorkerHost(
955
963
  loaded: string[];
956
964
  errors: Array<{ name: string; error: string }>;
957
965
  nextIndex: number;
966
+ /**
967
+ * Resolves when the boot-wide batch dep-graph (shared-rewrite mode
968
+ * only) has landed. Spawns wait on it instead of each paying a
969
+ * full per-function graph crawl — one batch graph parses every
970
+ * `_shared` file once instead of once per function.
971
+ */
972
+ batchGraphReady: Promise<void> | null;
958
973
  }
959
974
  let deferredCtx: DeferredCtx | null = null;
960
975
 
@@ -967,7 +982,11 @@ export function createDenoWorkerHost(
967
982
  const index = ++ctx.nextIndex;
968
983
  ctx.startOpts.onSpawnStart?.({ index, total: ctx.total, name: cand.name });
969
984
  const t0 = performance.now();
970
- const r = await runSerialized(cand.name, () => spawnAndRegister(cand));
985
+ if (ctx.batchGraphReady) await ctx.batchGraphReady;
986
+ const r = await runSerialized(
987
+ cand.name,
988
+ () => spawnAndRegister(cand, { graphReady: ctx.batchGraphReady !== null }),
989
+ );
971
990
  const durationMs = performance.now() - t0;
972
991
  if ("error" in r) {
973
992
  ctx.errors.push({ name: cand.name, error: r.error });
@@ -1003,12 +1022,44 @@ export function createDenoWorkerHost(
1003
1022
  startOpts: StartOptions = {},
1004
1023
  ): Promise<DeferredStart> {
1005
1024
  const candidates = await discoverCandidates(opts.functionsDir);
1025
+ // MRU-first warm-up: the background queue boots the functions the
1026
+ // developer was hitting most recently (seeded from the invocation
1027
+ // log store across restarts) before the ones nobody calls. Ties —
1028
+ // including everything on a first run — keep name order.
1029
+ candidates.sort((a, b) =>
1030
+ (lastDispatch.get(b.name) ?? 0) - (lastDispatch.get(a.name) ?? 0) ||
1031
+ a.name.localeCompare(b.name)
1032
+ );
1033
+ // Shared-rewrite mode needs each function's file list BEFORE its
1034
+ // worker spawns. Build ONE batch graph in the background (the
1035
+ // gateway still starts serving immediately) rather than letting
1036
+ // every spawn crawl its own graph — the per-function crawls
1037
+ // re-parse the same shared files over and over.
1038
+ const useSharedRewrite = Boolean(
1039
+ rewriteCache && sharedRuntime && sharedRuntime.list().length > 0,
1040
+ );
1041
+ let batchGraphReady: Promise<void> | null = null;
1042
+ if (useSharedRewrite && candidates.length > 0) {
1043
+ const graphStart = performance.now();
1044
+ batchGraphReady = depGraph
1045
+ .refreshMany(
1046
+ candidates.map((c) => ({ name: c.name, entryFileUrl: c.entryUrl })),
1047
+ )
1048
+ .then(() => {
1049
+ startOpts.onBatchGraph?.({
1050
+ total: candidates.length,
1051
+ durationMs: performance.now() - graphStart,
1052
+ });
1053
+ })
1054
+ .catch(() => {});
1055
+ }
1006
1056
  const ctx: DeferredCtx = {
1007
1057
  total: candidates.length,
1008
1058
  startOpts,
1009
1059
  loaded: [],
1010
1060
  errors: [],
1011
1061
  nextIndex: 0,
1062
+ batchGraphReady,
1012
1063
  };
1013
1064
  deferredCtx = ctx;
1014
1065
  const items: ClaimItem[] = [];
@@ -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
package/src/logs/query.ts CHANGED
@@ -195,6 +195,12 @@ export interface LogQuery {
195
195
  logsSince(afterId: number, limit?: number): LogRecord[];
196
196
  /** Distinct function names seen in the store (for filter dropdowns). */
197
197
  functionNames(): string[];
198
+ /**
199
+ * Most recent invocation timestamp per function, across the whole
200
+ * retention window. Seeds the Deno worker host's MRU boot ordering so
201
+ * deferred boot warms the functions the developer actually uses first.
202
+ */
203
+ lastDispatchByFunction(): Map<string, number>;
198
204
  }
199
205
 
200
206
  export function createLogQuery(db: LogDb): LogQuery {
@@ -368,5 +374,23 @@ export function createLogQuery(db: LogDb): LogQuery {
368
374
  return rows.map((r) => r.function_name);
369
375
  }
370
376
 
371
- return { queryInvocations, getInvocation, searchLogs, logsSince, functionNames };
377
+ function lastDispatchByFunction(): Map<string, number> {
378
+ // Satisfied entirely by idx_invocations_fn_ts (fn, ts) — one index
379
+ // scan, no table reads.
380
+ const rows = raw.prepare(
381
+ `SELECT function_name, MAX(ts_ms) AS last_ts FROM invocations GROUP BY function_name`,
382
+ ).all() as unknown as Array<{ function_name: string; last_ts: number }>;
383
+ const out = new Map<string, number>();
384
+ for (const r of rows) out.set(r.function_name, Number(r.last_ts));
385
+ return out;
386
+ }
387
+
388
+ return {
389
+ queryInvocations,
390
+ getInvocation,
391
+ searchLogs,
392
+ logsSince,
393
+ functionNames,
394
+ lastDispatchByFunction,
395
+ };
372
396
  }
package/src/server.ts CHANGED
@@ -1329,6 +1329,18 @@ if (opts.backend === "workerd") {
1329
1329
  );
1330
1330
  }
1331
1331
 
1332
+ // MRU warm-up seed: the invocation log store remembers which
1333
+ // functions were hit before the restart; deferred boot warms those
1334
+ // first instead of going alphabetically.
1335
+ let bootRecency: Map<string, number> | undefined;
1336
+ if (logDbHandle) {
1337
+ try {
1338
+ bootRecency = createLogQuery(logDbHandle).lastDispatchByFunction();
1339
+ } catch {
1340
+ // Best-effort: a broken store only costs the smarter boot order.
1341
+ }
1342
+ }
1343
+
1332
1344
  denoWorkerHost = createDenoWorkerHost({
1333
1345
  functionsDir: resolvedFunctionsPath,
1334
1346
  registry,
@@ -1351,6 +1363,9 @@ if (opts.backend === "workerd") {
1351
1363
  ...(denoImportMapOptions ?? {}),
1352
1364
  ...(denoSharedRuntime ? { sharedRuntime: denoSharedRuntime } : {}),
1353
1365
  ...(denoRewriteCache ? { rewriteCache: denoRewriteCache } : {}),
1366
+ ...(bootRecency && bootRecency.size > 0
1367
+ ? { initialRecency: bootRecency }
1368
+ : {}),
1354
1369
  });
1355
1370
 
1356
1371
  // Boot progress: append-only `[i/N] ✓ name (123ms)` per worker plus