@specific.dev/spectest 0.63.0 → 0.66.0

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.
@@ -33,6 +33,20 @@ export interface SupabaseOptions {
33
33
  storage?: boolean;
34
34
  /** Include Realtime (`<name>-realtime`). Default `true`. */
35
35
  realtime?: boolean;
36
+ /**
37
+ * Edge functions. `isolation` picks how they run on the edge runtime:
38
+ * `"shared"` (default) loads every function into **one** isolate, so a
39
+ * shared module graph (`_shared/`, npm packages) is loaded once rather than
40
+ * once per function — measured on a 51-function project as 230 MB against
41
+ * 3.6 GB. Hosted Supabase runs one isolate per function, and the one thing
42
+ * that differs is module-level state: a singleton in `_shared` is one
43
+ * object for all functions here, one per function there. `"per-function"`
44
+ * runs it the hosted way. Either way the runtime, the request each
45
+ * function sees and the per-function `verify_jwt` are the same.
46
+ */
47
+ functions?: {
48
+ isolation?: "shared" | "per-function";
49
+ };
36
50
  /**
37
51
  * Also serve the gateway over **HTTPS** at `https://<hostname>` via the
38
52
  * daemon's CA-trusted TLS reverse proxy (in addition to the plain
@@ -698,12 +698,307 @@ async function validJwt(token: string): Promise<boolean> {
698
698
  }
699
699
  }
700
700
 
701
+ /** How the functions run: every function in one isolate (the default), or
702
+ * one isolate per function as hosted Supabase does — see sharedWorker. */
703
+ const ISOLATION = Deno.env.get("FUNCTIONS_ISOLATION") === "per-function" ? "per-function" : "shared";
704
+ /** Where the generated entry of the shared isolate lives — outside the repo
705
+ * mount, which is read-only and the user's checkout, and NOT under /tmp:
706
+ * the runtime gives its workers an in-memory /tmp, so a file the main
707
+ * worker writes there does not exist for the user worker's bootstrap
708
+ * ("could not find an appropriate entrypoint"). */
709
+ const SHARED_DIR = "/home/deno/spectest-functions";
710
+ const WORKER_TIMEOUT_MS = 24 * 60 * 60 * 1000;
711
+
712
+ /** One isolate per function, reused across requests (the runtime pools by
713
+ * servicePath). The idle timeout is a day, not upstream's five minutes: a
714
+ * test environment is a snapshot lineage whose forks inherit these
715
+ * isolates, and an isolate reaped for idleness is rebooted by the next
716
+ * test that calls the function — a boot's worth of dirtied memory in that
717
+ * test's own snapshot diff. */
718
+ function workerFor(name: string) {
719
+ return EdgeRuntime.userWorkers.create({
720
+ servicePath: FUNCTIONS_DIR + "/" + name,
721
+ memoryLimitMb: 256,
722
+ workerTimeoutMs: WORKER_TIMEOUT_MS,
723
+ noModuleCache: false,
724
+ importMapPath: null,
725
+ envVars: Object.entries(Deno.env.toObject()),
726
+ });
727
+ }
728
+
729
+ async function exists(path: string): Promise<boolean> {
730
+ try {
731
+ await Deno.stat(path);
732
+ return true;
733
+ } catch {
734
+ return false;
735
+ }
736
+ }
737
+
738
+ /** The function directories: every child of FUNCTIONS_DIR with an index.ts,
739
+ * minus the conventional private ones. */
740
+ async function functionNames(): Promise<string[]> {
741
+ const names: string[] = [];
742
+ for await (const entry of Deno.readDir(FUNCTIONS_DIR)) {
743
+ if (!entry.isDirectory || entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
744
+ if (await exists(FUNCTIONS_DIR + "/" + entry.name + "/index.ts")) names.push(entry.name);
745
+ }
746
+ return names.sort();
747
+ }
748
+
749
+ /** Every "https://deno.land/std@…/http/server.ts" a source under the
750
+ * functions directory imports — the legacy serve() each of them has to be
751
+ * redirected to the shim in the shared isolate. */
752
+ async function stdServerUrls(dir: string, out: Set<string>): Promise<void> {
753
+ for await (const e of Deno.readDir(dir)) {
754
+ const p = dir + "/" + e.name;
755
+ if (e.isDirectory) {
756
+ if (e.name !== "node_modules" && !e.name.startsWith(".")) await stdServerUrls(p, out);
757
+ continue;
758
+ }
759
+ if (!/\.(ts|tsx|js|mjs|jsx)$/.test(e.name)) continue;
760
+ const src = await Deno.readTextFile(p);
761
+ for (const m of src.matchAll(/https:\/\/deno\.land\/std@[0-9.]+\/http\/server\.ts/g)) out.add(m[0]);
762
+ }
763
+ }
764
+
765
+ // ── The shared isolate ────────────────────────────────────────────────────
766
+ //
767
+ // The runtime's own model is one isolate per function, each loading its own
768
+ // copy of the module graph. On a project with dozens of functions sharing a
769
+ // \`_shared/\` tree that is dozens of copies of the same graph — measured at
770
+ // ~90 MB and ~1 s per function, against 18 MB for an empty isolate — and in
771
+ // an 8 GB guest that memory comes straight out of the page cache every test
772
+ // needs. So by default every function is loaded into ONE user worker: a
773
+ // generated entry module imports each function's index.ts in turn, a prelude
774
+ // (imported first, so it runs first) has replaced \`Deno.serve\` with a
775
+ // registration that records the handler under the function whose module is
776
+ // calling, the std \`serve()\` the older functions use is redirected to the
777
+ // same registration by an import map, and the worker's own server routes a
778
+ // request to the handler of the function its path names. The runtime, the
779
+ // request a function receives and the per-function JWT check are unchanged;
780
+ // what differs from hosted Supabase is that module-level state is one
781
+ // object for every function rather than one per function.
782
+ //
783
+ // It stands in only where it can be exact. A function with its own
784
+ // deno.json / import map (per-function resolution the one config cannot
785
+ // express), a functions/deno.jsonc (comments; not merged) or a config that
786
+ // names an importMap keep the per-function isolates, and so does a shared
787
+ // isolate that fails to boot — with the reason in the log, since a boot
788
+ // failure names the module at fault.
789
+
790
+ interface SharedSpec {
791
+ names: string[];
792
+ reason: string | null;
793
+ }
794
+ let sharedSpec: Promise<SharedSpec> | null = null;
795
+
796
+ /** Why the shared isolate cannot stand in for per-function ones, or null. */
797
+ async function sharedBlocker(names: string[]): Promise<string | null> {
798
+ for (const n of names) {
799
+ for (const f of ["deno.json", "deno.jsonc", "import_map.json"]) {
800
+ if (await exists(FUNCTIONS_DIR + "/" + n + "/" + f)) return "function \"" + n + "\" has its own " + f;
801
+ }
802
+ }
803
+ if (await exists(FUNCTIONS_DIR + "/deno.jsonc")) return "functions/deno.jsonc is not merged (comments)";
804
+ return null;
805
+ }
806
+
807
+ /** POSIX-relative path from directory \`from\` to \`to\`. The entry imports the
808
+ * functions relatively: an absolute file specifier is refused by the
809
+ * runtime's module bundler ("Module not found"), a relative one resolves. */
810
+ function relativePath(from: string, to: string): string {
811
+ const a = from.split("/").filter(Boolean);
812
+ const b = to.split("/").filter(Boolean);
813
+ let i = 0;
814
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
815
+ const up = a.slice(i).map(() => "..");
816
+ const rel = [...up, ...b.slice(i)].join("/");
817
+ return rel.startsWith(".") ? rel : "./" + rel;
818
+ }
819
+
820
+ function reString(s: string): string {
821
+ return s.replace(/[.*+?^$()|[\]\\\/{}]/g, "\\$&");
822
+ }
823
+
824
+ /** Write SHARED_DIR: the entry, the prelude, the std shim and the config. */
825
+ async function writeShared(names: string[]): Promise<string | null> {
826
+ await Deno.mkdir(SHARED_DIR, { recursive: true });
827
+
828
+ // Config: the project's functions/deno.json (its imports are what every
829
+ // function resolves against) plus the std shim redirections.
830
+ let cfg: Record<string, unknown> = {};
831
+ if (await exists(FUNCTIONS_DIR + "/deno.json")) {
832
+ try {
833
+ cfg = JSON.parse(await Deno.readTextFile(FUNCTIONS_DIR + "/deno.json"));
834
+ } catch (e) {
835
+ return "functions/deno.json could not be parsed: " + String(e);
836
+ }
837
+ if (typeof cfg.importMap === "string") return "functions/deno.json names an importMap";
838
+ }
839
+ const urls = new Set<string>();
840
+ await stdServerUrls(FUNCTIONS_DIR, urls);
841
+ const imports: Record<string, string> = { ...((cfg.imports as Record<string, string>) ?? {}) };
842
+ for (const u of urls) imports[u] = "./std-http-server.ts";
843
+ await Deno.writeTextFile(
844
+ SHARED_DIR + "/deno.json",
845
+ JSON.stringify({ ...cfg, lock: false, imports }, null, 2),
846
+ );
847
+
848
+ // The registration. Which function is calling comes from the stack: the
849
+ // first frame under FUNCTIONS_DIR whose directory is a function.
850
+ const known = JSON.stringify(names);
851
+ const prelude = [
852
+ "// spectest-generated. Do not edit.",
853
+ "const g = globalThis as any;",
854
+ "g.__spectestHandlers = {};",
855
+ "const known = new Set(" + known + ");",
856
+ "const frame = new RegExp(\"" + reString(FUNCTIONS_DIR) + "/([^/]+)/\", \"g\");",
857
+ "// serve()'s onError is part of the contract: a project maps its thrown",
858
+ "// errors to responses there, and without it every throw is a bare 500.",
859
+ "g.__spectestRegister = (handler: unknown, onError?: unknown) => {",
860
+ " if (typeof handler !== \"function\") throw new TypeError(\"spectest: serve() without a handler function\");",
861
+ " const stack = new Error().stack ?? \"\";",
862
+ " let name: string | null = null;",
863
+ " for (const m of stack.matchAll(frame)) { if (known.has(m[1])) { name = m[1]; break; } }",
864
+ " if (name === null) throw new Error(\"spectest: could not tell which function registered a handler; the call must come from under \" + JSON.stringify(\"" + FUNCTIONS_DIR + "\"));",
865
+ " g.__spectestHandlers[name] = { handler, onError: typeof onError === \"function\" ? onError : null };",
866
+ "};",
867
+ "g.__spectestRealServe = Deno.serve.bind(Deno);",
868
+ "const listener = { finished: new Promise<void>(() => {}), addr: { transport: \"tcp\", hostname: \"0.0.0.0\", port: 8000 }, shutdown: async () => {}, ref() {}, unref() {} };",
869
+ "(Deno as any).serve = (a: any, b: any) => {",
870
+ " const h = typeof a === \"function\" ? a : typeof b === \"function\" ? b : a?.handler ?? a?.fetch;",
871
+ " const opts = typeof a === \"function\" ? b : a;",
872
+ " g.__spectestRegister(h, opts?.onError);",
873
+ " return listener;",
874
+ "};",
875
+ "",
876
+ ].join("\n");
877
+ await Deno.writeTextFile(SHARED_DIR + "/prelude.ts", prelude);
878
+
879
+ // std's legacy serve(handler, options): the same registration.
880
+ await Deno.writeTextFile(
881
+ SHARED_DIR + "/std-http-server.ts",
882
+ [
883
+ "// spectest-generated stand-in for https://deno.land/std/http/server.ts. Do not edit.",
884
+ "export function serve(handler: unknown, options?: { onError?: unknown }): Promise<void> {",
885
+ " (globalThis as any).__spectestRegister(handler, options?.onError);",
886
+ " return new Promise(() => {});",
887
+ "}",
888
+ "export type ConnInfo = { localAddr: Deno.Addr; remoteAddr: Deno.Addr };",
889
+ "export type Handler = (request: Request, connInfo: ConnInfo) => Response | Promise<Response>;",
890
+ "export type ServeInit = Record<string, unknown>;",
891
+ "",
892
+ ].join("\n"),
893
+ );
894
+
895
+ // The entry: the prelude first, then every function, then the router. A
896
+ // function written the declarative way (export default { fetch }) never
897
+ // calls serve, so its default export is picked up here.
898
+ const lines = ["// spectest-generated entry of the shared functions isolate. Do not edit.", "import \"./prelude.ts\";"];
899
+ const fnRel = relativePath(SHARED_DIR, FUNCTIONS_DIR);
900
+ names.forEach((n, i) => lines.push("import * as f" + i + " from " + JSON.stringify(fnRel + "/" + n + "/index.ts") + ";"));
901
+ lines.push("const mods: Record<string, any> = {");
902
+ names.forEach((n, i) => lines.push(" " + JSON.stringify(n) + ": f" + i + ","));
903
+ lines.push("};");
904
+ lines.push(
905
+ "const g = globalThis as any;",
906
+ "for (const [n, m] of Object.entries(mods)) {",
907
+ " const d = m.default;",
908
+ " if (!g.__spectestHandlers[n] && d && typeof d.fetch === \"function\") g.__spectestHandlers[n] = { handler: d.fetch.bind(d), onError: null };",
909
+ "}",
910
+ "const addr = { transport: \"tcp\", hostname: \"127.0.0.1\", port: 0 };",
911
+ "g.__spectestRealServe(async (req: Request) => {",
912
+ " const name = new URL(req.url).pathname.split(\"/\")[1] ?? \"\";",
913
+ " const h = g.__spectestHandlers[name];",
914
+ " if (!h) return new Response(JSON.stringify({ msg: \"function \" + name + \" registered no handler\" }), { status: 500, headers: { \"content-type\": \"application/json\" } });",
915
+ " try {",
916
+ " return await h.handler(req, { remoteAddr: addr, localAddr: addr, completed: Promise.resolve() });",
917
+ " } catch (e) {",
918
+ " if (h.onError) return await h.onError(e);",
919
+ " console.error(e);",
920
+ " return new Response(\"Internal Server Error\", { status: 500 });",
921
+ " }",
922
+ "});",
923
+ "",
924
+ );
925
+ await Deno.writeTextFile(SHARED_DIR + "/index.ts", lines.join("\n"));
926
+ return null;
927
+ }
928
+
929
+ /** The shared isolate (pooled by the runtime under SHARED_DIR), or null when
930
+ * the functions run one isolate each — decided once, with the reason logged. */
931
+ async function sharedWorker(): Promise<any | null> {
932
+ if (ISOLATION !== "shared") return null;
933
+ if (!sharedSpec) {
934
+ sharedSpec = (async () => {
935
+ const names = await functionNames();
936
+ let reason = names.length === 0 ? "no functions" : await sharedBlocker(names);
937
+ if (reason === null) reason = await writeShared(names);
938
+ if (reason === null) {
939
+ try {
940
+ await createShared();
941
+ } catch (e) {
942
+ reason = "the shared isolate failed to boot: " + String(e);
943
+ }
944
+ }
945
+ if (reason !== null) console.warn("spectest: functions run one isolate each — " + reason);
946
+ return { names, reason };
947
+ })();
948
+ }
949
+ const spec = await sharedSpec;
950
+ return spec.reason === null ? createShared() : null;
951
+ }
952
+
953
+ function createShared() {
954
+ return EdgeRuntime.userWorkers.create({
955
+ servicePath: SHARED_DIR,
956
+ // A cap on the one isolate that holds every function, not a reservation.
957
+ memoryLimitMb: 2048,
958
+ workerTimeoutMs: WORKER_TIMEOUT_MS,
959
+ // The supervisor counts CPU time over the worker's LIFETIME (soft limit:
960
+ // retire, hard limit: terminate and cancel in-flight requests with
961
+ // "request has been cancelled by supervisor"). One isolate serving every
962
+ // function for a day of tests spends what 51 short-lived ones never
963
+ // would, so its budget is the day itself. Per-function isolates keep the
964
+ // runtime's defaults.
965
+ cpuTimeSoftLimitMs: WORKER_TIMEOUT_MS,
966
+ cpuTimeHardLimitMs: WORKER_TIMEOUT_MS,
967
+ noModuleCache: false,
968
+ importMapPath: null,
969
+ envVars: Object.entries(Deno.env.toObject()),
970
+ });
971
+ }
972
+
973
+ /** Boot the functions now, without calling them: the shared isolate, or —
974
+ * where it cannot stand in — every function's own. Loading a function
975
+ * runs its module top level, which for a function is its serve()
976
+ * registration, no business logic. Called once at environment setup,
977
+ * before the warm snapshot, so the isolates are shared, clean pages of the
978
+ * snapshot rather than a boot in every test that first touches a function. */
979
+ async function warmAll(): Promise<{ mode: "shared" | "per-function"; warmed: string[]; failed: Record<string, string> }> {
980
+ const names = await functionNames();
981
+ if ((await sharedWorker()) !== null) return { mode: "shared", warmed: names, failed: {} };
982
+ // All at once: each boot is mostly module loading, and the isolates come
983
+ // up concurrently.
984
+ const results = await Promise.allSettled(names.map((name) => workerFor(name)));
985
+ const warmed: string[] = [];
986
+ const failed: Record<string, string> = {};
987
+ results.forEach((r, i) => {
988
+ if (r.status === "fulfilled") warmed.push(names[i]);
989
+ else failed[names[i]] = String(r.reason);
990
+ });
991
+ return { mode: "per-function", warmed: warmed.sort(), failed };
992
+ }
993
+
701
994
  Deno.serve(async (req: Request) => {
702
995
  const { pathname } = new URL(req.url);
703
996
 
704
997
  // Ready probe — answered before any auth check, so bring-up never needs a
705
998
  // token, and never treated as a function name.
706
999
  if (pathname === "/_spectest/health") return new Response("ok");
1000
+ // Setup-time warm-up (see warmAll); never treated as a function name.
1001
+ if (pathname === "/_spectest/warm") return json(await warmAll());
707
1002
 
708
1003
  const name = pathname.split("/")[1] ?? "";
709
1004
  if (name === "") return json({ msg: "missing function name in request" }, 400);
@@ -718,14 +1013,7 @@ Deno.serve(async (req: Request) => {
718
1013
 
719
1014
  const servicePath = FUNCTIONS_DIR + "/" + name;
720
1015
  try {
721
- const worker = await EdgeRuntime.userWorkers.create({
722
- servicePath,
723
- memoryLimitMb: 256,
724
- workerTimeoutMs: 5 * 60 * 1000,
725
- noModuleCache: false,
726
- importMapPath: null,
727
- envVars: Object.entries(Deno.env.toObject()),
728
- });
1016
+ const worker = (await sharedWorker()) ?? (await workerFor(name));
729
1017
  return await worker.fetch(req);
730
1018
  } catch (e) {
731
1019
  // A missing directory lands here too, so say which function was asked
@@ -1416,7 +1704,7 @@ export function supabase(opts = {}) {
1416
1704
  }
1417
1705
  // ── functions (edge runtime) ────────────────────────────────────
1418
1706
  if (withFunctions) {
1419
- parts.functions = {
1707
+ const functionsDef = {
1420
1708
  // Built rather than pulled, so the functions' remote imports are
1421
1709
  // fetched by the host builder and cached into the image — see
1422
1710
  // `functionsDockerfile`.
@@ -1441,6 +1729,9 @@ export function supabase(opts = {}) {
1441
1729
  JWT_SECRET: jwtSecret,
1442
1730
  // Where the router looks for `<name>/`, inside the repo mount.
1443
1731
  FUNCTIONS_DIR: fnServePath,
1732
+ // One isolate for all functions (default) or one each — see the
1733
+ // `functions` option and the router's shared-isolate notes.
1734
+ FUNCTIONS_ISOLATION: opts.functions?.isolation ?? "shared",
1444
1735
  // The default a function inherits when the config says nothing:
1445
1736
  // reject an unauthenticated call, as hosted Supabase does.
1446
1737
  VERIFY_JWT: "true",
@@ -1472,7 +1763,30 @@ export function supabase(opts = {}) {
1472
1763
  path: "/_spectest/health",
1473
1764
  timeoutSecs: 120,
1474
1765
  },
1766
+ // Boot every function's isolate before the warm snapshot is
1767
+ // taken (see warmAll in the router). Measured on a real project
1768
+ // (Harmony, 2026-08-28): a test that first touched a few
1769
+ // functions dirtied ~1 GB booting them — memory that then had to
1770
+ // be written into that test's snapshot diff and paid for by every
1771
+ // fork of it. Warm, the isolates are clean shared pages of the
1772
+ // snapshot and a test's diff is what its requests mutate.
1773
+ setup: async () => {
1774
+ const t0 = Date.now();
1775
+ const res = await fetch(`http://${g.key("functions")}:9000/_spectest/warm`);
1776
+ if (!res.ok) {
1777
+ console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
1778
+ return;
1779
+ }
1780
+ const { mode, warmed, failed } = (await res.json());
1781
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
1782
+ const how = mode === "shared" ? "in one isolate" : "one isolate each (see the functions service log for why)";
1783
+ console.log(`supabase(): warmed ${warmed.length} edge function${warmed.length === 1 ? "" : "s"} ${how} in ${secs}s${warmed.length ? ` (${warmed.join(", ")})` : ""}.`);
1784
+ for (const [name, err] of Object.entries(failed)) {
1785
+ console.warn(`supabase(): edge function "${name}" failed to boot at warm-up — it will be retried on first call: ${err}`);
1786
+ }
1787
+ },
1475
1788
  };
1789
+ parts.functions = functionsDef;
1476
1790
  }
1477
1791
  // ── kong (gateway / primary) — no explicit dependsOn: the group
1478
1792
  // expansion makes the primary depend on every member ──────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.63.0",
3
+ "version": "0.66.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -235,6 +235,18 @@ export interface SupabaseOptions {
235
235
  storage?: boolean;
236
236
  /** Include Realtime (`<name>-realtime`). Default `true`. */
237
237
  realtime?: boolean;
238
+ /**
239
+ * Edge functions. `isolation` picks how they run on the edge runtime:
240
+ * `"shared"` (default) loads every function into **one** isolate, so a
241
+ * shared module graph (`_shared/`, npm packages) is loaded once rather than
242
+ * once per function — measured on a 51-function project as 230 MB against
243
+ * 3.6 GB. Hosted Supabase runs one isolate per function, and the one thing
244
+ * that differs is module-level state: a singleton in `_shared` is one
245
+ * object for all functions here, one per function there. `"per-function"`
246
+ * runs it the hosted way. Either way the runtime, the request each
247
+ * function sees and the per-function `verify_jwt` are the same.
248
+ */
249
+ functions?: { isolation?: "shared" | "per-function" };
238
250
 
239
251
  /**
240
252
  * Also serve the gateway over **HTTPS** at `https://<hostname>` via the
@@ -1043,12 +1055,307 @@ async function validJwt(token: string): Promise<boolean> {
1043
1055
  }
1044
1056
  }
1045
1057
 
1058
+ /** How the functions run: every function in one isolate (the default), or
1059
+ * one isolate per function as hosted Supabase does — see sharedWorker. */
1060
+ const ISOLATION = Deno.env.get("FUNCTIONS_ISOLATION") === "per-function" ? "per-function" : "shared";
1061
+ /** Where the generated entry of the shared isolate lives — outside the repo
1062
+ * mount, which is read-only and the user's checkout, and NOT under /tmp:
1063
+ * the runtime gives its workers an in-memory /tmp, so a file the main
1064
+ * worker writes there does not exist for the user worker's bootstrap
1065
+ * ("could not find an appropriate entrypoint"). */
1066
+ const SHARED_DIR = "/home/deno/spectest-functions";
1067
+ const WORKER_TIMEOUT_MS = 24 * 60 * 60 * 1000;
1068
+
1069
+ /** One isolate per function, reused across requests (the runtime pools by
1070
+ * servicePath). The idle timeout is a day, not upstream's five minutes: a
1071
+ * test environment is a snapshot lineage whose forks inherit these
1072
+ * isolates, and an isolate reaped for idleness is rebooted by the next
1073
+ * test that calls the function — a boot's worth of dirtied memory in that
1074
+ * test's own snapshot diff. */
1075
+ function workerFor(name: string) {
1076
+ return EdgeRuntime.userWorkers.create({
1077
+ servicePath: FUNCTIONS_DIR + "/" + name,
1078
+ memoryLimitMb: 256,
1079
+ workerTimeoutMs: WORKER_TIMEOUT_MS,
1080
+ noModuleCache: false,
1081
+ importMapPath: null,
1082
+ envVars: Object.entries(Deno.env.toObject()),
1083
+ });
1084
+ }
1085
+
1086
+ async function exists(path: string): Promise<boolean> {
1087
+ try {
1088
+ await Deno.stat(path);
1089
+ return true;
1090
+ } catch {
1091
+ return false;
1092
+ }
1093
+ }
1094
+
1095
+ /** The function directories: every child of FUNCTIONS_DIR with an index.ts,
1096
+ * minus the conventional private ones. */
1097
+ async function functionNames(): Promise<string[]> {
1098
+ const names: string[] = [];
1099
+ for await (const entry of Deno.readDir(FUNCTIONS_DIR)) {
1100
+ if (!entry.isDirectory || entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
1101
+ if (await exists(FUNCTIONS_DIR + "/" + entry.name + "/index.ts")) names.push(entry.name);
1102
+ }
1103
+ return names.sort();
1104
+ }
1105
+
1106
+ /** Every "https://deno.land/std@…/http/server.ts" a source under the
1107
+ * functions directory imports — the legacy serve() each of them has to be
1108
+ * redirected to the shim in the shared isolate. */
1109
+ async function stdServerUrls(dir: string, out: Set<string>): Promise<void> {
1110
+ for await (const e of Deno.readDir(dir)) {
1111
+ const p = dir + "/" + e.name;
1112
+ if (e.isDirectory) {
1113
+ if (e.name !== "node_modules" && !e.name.startsWith(".")) await stdServerUrls(p, out);
1114
+ continue;
1115
+ }
1116
+ if (!/\.(ts|tsx|js|mjs|jsx)$/.test(e.name)) continue;
1117
+ const src = await Deno.readTextFile(p);
1118
+ for (const m of src.matchAll(/https:\/\/deno\.land\/std@[0-9.]+\/http\/server\.ts/g)) out.add(m[0]);
1119
+ }
1120
+ }
1121
+
1122
+ // ── The shared isolate ────────────────────────────────────────────────────
1123
+ //
1124
+ // The runtime's own model is one isolate per function, each loading its own
1125
+ // copy of the module graph. On a project with dozens of functions sharing a
1126
+ // \`_shared/\` tree that is dozens of copies of the same graph — measured at
1127
+ // ~90 MB and ~1 s per function, against 18 MB for an empty isolate — and in
1128
+ // an 8 GB guest that memory comes straight out of the page cache every test
1129
+ // needs. So by default every function is loaded into ONE user worker: a
1130
+ // generated entry module imports each function's index.ts in turn, a prelude
1131
+ // (imported first, so it runs first) has replaced \`Deno.serve\` with a
1132
+ // registration that records the handler under the function whose module is
1133
+ // calling, the std \`serve()\` the older functions use is redirected to the
1134
+ // same registration by an import map, and the worker's own server routes a
1135
+ // request to the handler of the function its path names. The runtime, the
1136
+ // request a function receives and the per-function JWT check are unchanged;
1137
+ // what differs from hosted Supabase is that module-level state is one
1138
+ // object for every function rather than one per function.
1139
+ //
1140
+ // It stands in only where it can be exact. A function with its own
1141
+ // deno.json / import map (per-function resolution the one config cannot
1142
+ // express), a functions/deno.jsonc (comments; not merged) or a config that
1143
+ // names an importMap keep the per-function isolates, and so does a shared
1144
+ // isolate that fails to boot — with the reason in the log, since a boot
1145
+ // failure names the module at fault.
1146
+
1147
+ interface SharedSpec {
1148
+ names: string[];
1149
+ reason: string | null;
1150
+ }
1151
+ let sharedSpec: Promise<SharedSpec> | null = null;
1152
+
1153
+ /** Why the shared isolate cannot stand in for per-function ones, or null. */
1154
+ async function sharedBlocker(names: string[]): Promise<string | null> {
1155
+ for (const n of names) {
1156
+ for (const f of ["deno.json", "deno.jsonc", "import_map.json"]) {
1157
+ if (await exists(FUNCTIONS_DIR + "/" + n + "/" + f)) return "function \"" + n + "\" has its own " + f;
1158
+ }
1159
+ }
1160
+ if (await exists(FUNCTIONS_DIR + "/deno.jsonc")) return "functions/deno.jsonc is not merged (comments)";
1161
+ return null;
1162
+ }
1163
+
1164
+ /** POSIX-relative path from directory \`from\` to \`to\`. The entry imports the
1165
+ * functions relatively: an absolute file specifier is refused by the
1166
+ * runtime's module bundler ("Module not found"), a relative one resolves. */
1167
+ function relativePath(from: string, to: string): string {
1168
+ const a = from.split("/").filter(Boolean);
1169
+ const b = to.split("/").filter(Boolean);
1170
+ let i = 0;
1171
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
1172
+ const up = a.slice(i).map(() => "..");
1173
+ const rel = [...up, ...b.slice(i)].join("/");
1174
+ return rel.startsWith(".") ? rel : "./" + rel;
1175
+ }
1176
+
1177
+ function reString(s: string): string {
1178
+ return s.replace(/[.*+?^$()|[\]\\\/{}]/g, "\\$&");
1179
+ }
1180
+
1181
+ /** Write SHARED_DIR: the entry, the prelude, the std shim and the config. */
1182
+ async function writeShared(names: string[]): Promise<string | null> {
1183
+ await Deno.mkdir(SHARED_DIR, { recursive: true });
1184
+
1185
+ // Config: the project's functions/deno.json (its imports are what every
1186
+ // function resolves against) plus the std shim redirections.
1187
+ let cfg: Record<string, unknown> = {};
1188
+ if (await exists(FUNCTIONS_DIR + "/deno.json")) {
1189
+ try {
1190
+ cfg = JSON.parse(await Deno.readTextFile(FUNCTIONS_DIR + "/deno.json"));
1191
+ } catch (e) {
1192
+ return "functions/deno.json could not be parsed: " + String(e);
1193
+ }
1194
+ if (typeof cfg.importMap === "string") return "functions/deno.json names an importMap";
1195
+ }
1196
+ const urls = new Set<string>();
1197
+ await stdServerUrls(FUNCTIONS_DIR, urls);
1198
+ const imports: Record<string, string> = { ...((cfg.imports as Record<string, string>) ?? {}) };
1199
+ for (const u of urls) imports[u] = "./std-http-server.ts";
1200
+ await Deno.writeTextFile(
1201
+ SHARED_DIR + "/deno.json",
1202
+ JSON.stringify({ ...cfg, lock: false, imports }, null, 2),
1203
+ );
1204
+
1205
+ // The registration. Which function is calling comes from the stack: the
1206
+ // first frame under FUNCTIONS_DIR whose directory is a function.
1207
+ const known = JSON.stringify(names);
1208
+ const prelude = [
1209
+ "// spectest-generated. Do not edit.",
1210
+ "const g = globalThis as any;",
1211
+ "g.__spectestHandlers = {};",
1212
+ "const known = new Set(" + known + ");",
1213
+ "const frame = new RegExp(\"" + reString(FUNCTIONS_DIR) + "/([^/]+)/\", \"g\");",
1214
+ "// serve()'s onError is part of the contract: a project maps its thrown",
1215
+ "// errors to responses there, and without it every throw is a bare 500.",
1216
+ "g.__spectestRegister = (handler: unknown, onError?: unknown) => {",
1217
+ " if (typeof handler !== \"function\") throw new TypeError(\"spectest: serve() without a handler function\");",
1218
+ " const stack = new Error().stack ?? \"\";",
1219
+ " let name: string | null = null;",
1220
+ " for (const m of stack.matchAll(frame)) { if (known.has(m[1])) { name = m[1]; break; } }",
1221
+ " if (name === null) throw new Error(\"spectest: could not tell which function registered a handler; the call must come from under \" + JSON.stringify(\"" + FUNCTIONS_DIR + "\"));",
1222
+ " g.__spectestHandlers[name] = { handler, onError: typeof onError === \"function\" ? onError : null };",
1223
+ "};",
1224
+ "g.__spectestRealServe = Deno.serve.bind(Deno);",
1225
+ "const listener = { finished: new Promise<void>(() => {}), addr: { transport: \"tcp\", hostname: \"0.0.0.0\", port: 8000 }, shutdown: async () => {}, ref() {}, unref() {} };",
1226
+ "(Deno as any).serve = (a: any, b: any) => {",
1227
+ " const h = typeof a === \"function\" ? a : typeof b === \"function\" ? b : a?.handler ?? a?.fetch;",
1228
+ " const opts = typeof a === \"function\" ? b : a;",
1229
+ " g.__spectestRegister(h, opts?.onError);",
1230
+ " return listener;",
1231
+ "};",
1232
+ "",
1233
+ ].join("\n");
1234
+ await Deno.writeTextFile(SHARED_DIR + "/prelude.ts", prelude);
1235
+
1236
+ // std's legacy serve(handler, options): the same registration.
1237
+ await Deno.writeTextFile(
1238
+ SHARED_DIR + "/std-http-server.ts",
1239
+ [
1240
+ "// spectest-generated stand-in for https://deno.land/std/http/server.ts. Do not edit.",
1241
+ "export function serve(handler: unknown, options?: { onError?: unknown }): Promise<void> {",
1242
+ " (globalThis as any).__spectestRegister(handler, options?.onError);",
1243
+ " return new Promise(() => {});",
1244
+ "}",
1245
+ "export type ConnInfo = { localAddr: Deno.Addr; remoteAddr: Deno.Addr };",
1246
+ "export type Handler = (request: Request, connInfo: ConnInfo) => Response | Promise<Response>;",
1247
+ "export type ServeInit = Record<string, unknown>;",
1248
+ "",
1249
+ ].join("\n"),
1250
+ );
1251
+
1252
+ // The entry: the prelude first, then every function, then the router. A
1253
+ // function written the declarative way (export default { fetch }) never
1254
+ // calls serve, so its default export is picked up here.
1255
+ const lines = ["// spectest-generated entry of the shared functions isolate. Do not edit.", "import \"./prelude.ts\";"];
1256
+ const fnRel = relativePath(SHARED_DIR, FUNCTIONS_DIR);
1257
+ names.forEach((n, i) => lines.push("import * as f" + i + " from " + JSON.stringify(fnRel + "/" + n + "/index.ts") + ";"));
1258
+ lines.push("const mods: Record<string, any> = {");
1259
+ names.forEach((n, i) => lines.push(" " + JSON.stringify(n) + ": f" + i + ","));
1260
+ lines.push("};");
1261
+ lines.push(
1262
+ "const g = globalThis as any;",
1263
+ "for (const [n, m] of Object.entries(mods)) {",
1264
+ " const d = m.default;",
1265
+ " if (!g.__spectestHandlers[n] && d && typeof d.fetch === \"function\") g.__spectestHandlers[n] = { handler: d.fetch.bind(d), onError: null };",
1266
+ "}",
1267
+ "const addr = { transport: \"tcp\", hostname: \"127.0.0.1\", port: 0 };",
1268
+ "g.__spectestRealServe(async (req: Request) => {",
1269
+ " const name = new URL(req.url).pathname.split(\"/\")[1] ?? \"\";",
1270
+ " const h = g.__spectestHandlers[name];",
1271
+ " if (!h) return new Response(JSON.stringify({ msg: \"function \" + name + \" registered no handler\" }), { status: 500, headers: { \"content-type\": \"application/json\" } });",
1272
+ " try {",
1273
+ " return await h.handler(req, { remoteAddr: addr, localAddr: addr, completed: Promise.resolve() });",
1274
+ " } catch (e) {",
1275
+ " if (h.onError) return await h.onError(e);",
1276
+ " console.error(e);",
1277
+ " return new Response(\"Internal Server Error\", { status: 500 });",
1278
+ " }",
1279
+ "});",
1280
+ "",
1281
+ );
1282
+ await Deno.writeTextFile(SHARED_DIR + "/index.ts", lines.join("\n"));
1283
+ return null;
1284
+ }
1285
+
1286
+ /** The shared isolate (pooled by the runtime under SHARED_DIR), or null when
1287
+ * the functions run one isolate each — decided once, with the reason logged. */
1288
+ async function sharedWorker(): Promise<any | null> {
1289
+ if (ISOLATION !== "shared") return null;
1290
+ if (!sharedSpec) {
1291
+ sharedSpec = (async () => {
1292
+ const names = await functionNames();
1293
+ let reason = names.length === 0 ? "no functions" : await sharedBlocker(names);
1294
+ if (reason === null) reason = await writeShared(names);
1295
+ if (reason === null) {
1296
+ try {
1297
+ await createShared();
1298
+ } catch (e) {
1299
+ reason = "the shared isolate failed to boot: " + String(e);
1300
+ }
1301
+ }
1302
+ if (reason !== null) console.warn("spectest: functions run one isolate each — " + reason);
1303
+ return { names, reason };
1304
+ })();
1305
+ }
1306
+ const spec = await sharedSpec;
1307
+ return spec.reason === null ? createShared() : null;
1308
+ }
1309
+
1310
+ function createShared() {
1311
+ return EdgeRuntime.userWorkers.create({
1312
+ servicePath: SHARED_DIR,
1313
+ // A cap on the one isolate that holds every function, not a reservation.
1314
+ memoryLimitMb: 2048,
1315
+ workerTimeoutMs: WORKER_TIMEOUT_MS,
1316
+ // The supervisor counts CPU time over the worker's LIFETIME (soft limit:
1317
+ // retire, hard limit: terminate and cancel in-flight requests with
1318
+ // "request has been cancelled by supervisor"). One isolate serving every
1319
+ // function for a day of tests spends what 51 short-lived ones never
1320
+ // would, so its budget is the day itself. Per-function isolates keep the
1321
+ // runtime's defaults.
1322
+ cpuTimeSoftLimitMs: WORKER_TIMEOUT_MS,
1323
+ cpuTimeHardLimitMs: WORKER_TIMEOUT_MS,
1324
+ noModuleCache: false,
1325
+ importMapPath: null,
1326
+ envVars: Object.entries(Deno.env.toObject()),
1327
+ });
1328
+ }
1329
+
1330
+ /** Boot the functions now, without calling them: the shared isolate, or —
1331
+ * where it cannot stand in — every function's own. Loading a function
1332
+ * runs its module top level, which for a function is its serve()
1333
+ * registration, no business logic. Called once at environment setup,
1334
+ * before the warm snapshot, so the isolates are shared, clean pages of the
1335
+ * snapshot rather than a boot in every test that first touches a function. */
1336
+ async function warmAll(): Promise<{ mode: "shared" | "per-function"; warmed: string[]; failed: Record<string, string> }> {
1337
+ const names = await functionNames();
1338
+ if ((await sharedWorker()) !== null) return { mode: "shared", warmed: names, failed: {} };
1339
+ // All at once: each boot is mostly module loading, and the isolates come
1340
+ // up concurrently.
1341
+ const results = await Promise.allSettled(names.map((name) => workerFor(name)));
1342
+ const warmed: string[] = [];
1343
+ const failed: Record<string, string> = {};
1344
+ results.forEach((r, i) => {
1345
+ if (r.status === "fulfilled") warmed.push(names[i]);
1346
+ else failed[names[i]] = String(r.reason);
1347
+ });
1348
+ return { mode: "per-function", warmed: warmed.sort(), failed };
1349
+ }
1350
+
1046
1351
  Deno.serve(async (req: Request) => {
1047
1352
  const { pathname } = new URL(req.url);
1048
1353
 
1049
1354
  // Ready probe — answered before any auth check, so bring-up never needs a
1050
1355
  // token, and never treated as a function name.
1051
1356
  if (pathname === "/_spectest/health") return new Response("ok");
1357
+ // Setup-time warm-up (see warmAll); never treated as a function name.
1358
+ if (pathname === "/_spectest/warm") return json(await warmAll());
1052
1359
 
1053
1360
  const name = pathname.split("/")[1] ?? "";
1054
1361
  if (name === "") return json({ msg: "missing function name in request" }, 400);
@@ -1063,14 +1370,7 @@ Deno.serve(async (req: Request) => {
1063
1370
 
1064
1371
  const servicePath = FUNCTIONS_DIR + "/" + name;
1065
1372
  try {
1066
- const worker = await EdgeRuntime.userWorkers.create({
1067
- servicePath,
1068
- memoryLimitMb: 256,
1069
- workerTimeoutMs: 5 * 60 * 1000,
1070
- noModuleCache: false,
1071
- importMapPath: null,
1072
- envVars: Object.entries(Deno.env.toObject()),
1073
- });
1373
+ const worker = (await sharedWorker()) ?? (await workerFor(name));
1074
1374
  return await worker.fetch(req);
1075
1375
  } catch (e) {
1076
1376
  // A missing directory lands here too, so say which function was asked
@@ -1917,7 +2217,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1917
2217
 
1918
2218
  // ── functions (edge runtime) ────────────────────────────────────
1919
2219
  if (withFunctions) {
1920
- parts.functions = {
2220
+ const functionsDef: ServiceDefinition = {
1921
2221
  // Built rather than pulled, so the functions' remote imports are
1922
2222
  // fetched by the host builder and cached into the image — see
1923
2223
  // `functionsDockerfile`.
@@ -1942,6 +2242,9 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1942
2242
  JWT_SECRET: jwtSecret,
1943
2243
  // Where the router looks for `<name>/`, inside the repo mount.
1944
2244
  FUNCTIONS_DIR: fnServePath,
2245
+ // One isolate for all functions (default) or one each — see the
2246
+ // `functions` option and the router's shared-isolate notes.
2247
+ FUNCTIONS_ISOLATION: opts.functions?.isolation ?? "shared",
1945
2248
  // The default a function inherits when the config says nothing:
1946
2249
  // reject an unauthenticated call, as hosted Supabase does.
1947
2250
  VERIFY_JWT: "true",
@@ -1976,7 +2279,34 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1976
2279
  path: "/_spectest/health",
1977
2280
  timeoutSecs: 120,
1978
2281
  },
2282
+ // Boot every function's isolate before the warm snapshot is
2283
+ // taken (see warmAll in the router). Measured on a real project
2284
+ // (Harmony, 2026-08-28): a test that first touched a few
2285
+ // functions dirtied ~1 GB booting them — memory that then had to
2286
+ // be written into that test's snapshot diff and paid for by every
2287
+ // fork of it. Warm, the isolates are clean shared pages of the
2288
+ // snapshot and a test's diff is what its requests mutate.
2289
+ setup: async () => {
2290
+ const t0 = Date.now();
2291
+ const res = await fetch(`http://${g.key("functions")}:9000/_spectest/warm`);
2292
+ if (!res.ok) {
2293
+ console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
2294
+ return;
2295
+ }
2296
+ const { mode, warmed, failed } = (await res.json()) as {
2297
+ mode: "shared" | "per-function";
2298
+ warmed: string[];
2299
+ failed: Record<string, string>;
2300
+ };
2301
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
2302
+ const how = mode === "shared" ? "in one isolate" : "one isolate each (see the functions service log for why)";
2303
+ console.log(`supabase(): warmed ${warmed.length} edge function${warmed.length === 1 ? "" : "s"} ${how} in ${secs}s${warmed.length ? ` (${warmed.join(", ")})` : ""}.`);
2304
+ for (const [name, err] of Object.entries(failed)) {
2305
+ console.warn(`supabase(): edge function "${name}" failed to boot at warm-up — it will be retried on first call: ${err}`);
2306
+ }
2307
+ },
1979
2308
  };
2309
+ parts.functions = functionsDef;
1980
2310
  }
1981
2311
 
1982
2312
  // ── kong (gateway / primary) — no explicit dependsOn: the group