@specific.dev/spectest 0.64.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,6 +698,17 @@ 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
+
701
712
  /** One isolate per function, reused across requests (the runtime pools by
702
713
  * servicePath). The idle timeout is a day, not upstream's five minutes: a
703
714
  * test environment is a snapshot lineage whose forks inherit these
@@ -708,32 +719,268 @@ function workerFor(name: string) {
708
719
  return EdgeRuntime.userWorkers.create({
709
720
  servicePath: FUNCTIONS_DIR + "/" + name,
710
721
  memoryLimitMb: 256,
711
- workerTimeoutMs: 24 * 60 * 60 * 1000,
722
+ workerTimeoutMs: WORKER_TIMEOUT_MS,
712
723
  noModuleCache: false,
713
724
  importMapPath: null,
714
725
  envVars: Object.entries(Deno.env.toObject()),
715
726
  });
716
727
  }
717
728
 
718
- /** Boot every function's isolate now, without calling it. Creating the
719
- * worker loads the module graph and runs the module's top level — for a
720
- * function that is its \`Deno.serve(handler)\` registration, no business
721
- * logic. Called once at environment setup, before the warm snapshot, so
722
- * the isolates are shared, clean pages of the snapshot rather than a boot
723
- * in every test that first touches a function. */
724
- async function warmAll(): Promise<{ warmed: string[]; failed: Record<string, string> }> {
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[]> {
725
741
  const names: string[] = [];
726
742
  for await (const entry of Deno.readDir(FUNCTIONS_DIR)) {
727
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")) {
728
832
  try {
729
- await Deno.stat(FUNCTIONS_DIR + "/" + entry.name + "/index.ts");
730
- names.push(entry.name);
731
- } catch {
732
- // not a function directory
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);
733
836
  }
837
+ if (typeof cfg.importMap === "string") return "functions/deno.json names an importMap";
734
838
  }
735
- // All at once: a project can have dozens of functions, and each boot is
736
- // mostly module loading — the isolates come up concurrently.
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.
737
984
  const results = await Promise.allSettled(names.map((name) => workerFor(name)));
738
985
  const warmed: string[] = [];
739
986
  const failed: Record<string, string> = {};
@@ -741,7 +988,7 @@ async function warmAll(): Promise<{ warmed: string[]; failed: Record<string, str
741
988
  if (r.status === "fulfilled") warmed.push(names[i]);
742
989
  else failed[names[i]] = String(r.reason);
743
990
  });
744
- return { warmed: warmed.sort(), failed };
991
+ return { mode: "per-function", warmed: warmed.sort(), failed };
745
992
  }
746
993
 
747
994
  Deno.serve(async (req: Request) => {
@@ -766,7 +1013,7 @@ Deno.serve(async (req: Request) => {
766
1013
 
767
1014
  const servicePath = FUNCTIONS_DIR + "/" + name;
768
1015
  try {
769
- const worker = await workerFor(name);
1016
+ const worker = (await sharedWorker()) ?? (await workerFor(name));
770
1017
  return await worker.fetch(req);
771
1018
  } catch (e) {
772
1019
  // A missing directory lands here too, so say which function was asked
@@ -1482,6 +1729,9 @@ export function supabase(opts = {}) {
1482
1729
  JWT_SECRET: jwtSecret,
1483
1730
  // Where the router looks for `<name>/`, inside the repo mount.
1484
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",
1485
1735
  // The default a function inherits when the config says nothing:
1486
1736
  // reject an unauthenticated call, as hosted Supabase does.
1487
1737
  VERIFY_JWT: "true",
@@ -1521,13 +1771,16 @@ export function supabase(opts = {}) {
1521
1771
  // fork of it. Warm, the isolates are clean shared pages of the
1522
1772
  // snapshot and a test's diff is what its requests mutate.
1523
1773
  setup: async () => {
1774
+ const t0 = Date.now();
1524
1775
  const res = await fetch(`http://${g.key("functions")}:9000/_spectest/warm`);
1525
1776
  if (!res.ok) {
1526
1777
  console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
1527
1778
  return;
1528
1779
  }
1529
- const { warmed, failed } = (await res.json());
1530
- console.log(`supabase(): warmed ${warmed.length} edge function${warmed.length === 1 ? "" : "s"}${warmed.length ? ` (${warmed.join(", ")})` : ""}.`);
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(", ")})` : ""}.`);
1531
1784
  for (const [name, err] of Object.entries(failed)) {
1532
1785
  console.warn(`supabase(): edge function "${name}" failed to boot at warm-up — it will be retried on first call: ${err}`);
1533
1786
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.64.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,6 +1055,17 @@ 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
+
1046
1069
  /** One isolate per function, reused across requests (the runtime pools by
1047
1070
  * servicePath). The idle timeout is a day, not upstream's five minutes: a
1048
1071
  * test environment is a snapshot lineage whose forks inherit these
@@ -1053,32 +1076,268 @@ function workerFor(name: string) {
1053
1076
  return EdgeRuntime.userWorkers.create({
1054
1077
  servicePath: FUNCTIONS_DIR + "/" + name,
1055
1078
  memoryLimitMb: 256,
1056
- workerTimeoutMs: 24 * 60 * 60 * 1000,
1079
+ workerTimeoutMs: WORKER_TIMEOUT_MS,
1057
1080
  noModuleCache: false,
1058
1081
  importMapPath: null,
1059
1082
  envVars: Object.entries(Deno.env.toObject()),
1060
1083
  });
1061
1084
  }
1062
1085
 
1063
- /** Boot every function's isolate now, without calling it. Creating the
1064
- * worker loads the module graph and runs the module's top level — for a
1065
- * function that is its \`Deno.serve(handler)\` registration, no business
1066
- * logic. Called once at environment setup, before the warm snapshot, so
1067
- * the isolates are shared, clean pages of the snapshot rather than a boot
1068
- * in every test that first touches a function. */
1069
- async function warmAll(): Promise<{ warmed: string[]; failed: Record<string, string> }> {
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[]> {
1070
1098
  const names: string[] = [];
1071
1099
  for await (const entry of Deno.readDir(FUNCTIONS_DIR)) {
1072
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")) {
1073
1189
  try {
1074
- await Deno.stat(FUNCTIONS_DIR + "/" + entry.name + "/index.ts");
1075
- names.push(entry.name);
1076
- } catch {
1077
- // not a function directory
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);
1078
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
+ })();
1079
1305
  }
1080
- // All at once: a project can have dozens of functions, and each boot is
1081
- // mostly module loading — the isolates come up concurrently.
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.
1082
1341
  const results = await Promise.allSettled(names.map((name) => workerFor(name)));
1083
1342
  const warmed: string[] = [];
1084
1343
  const failed: Record<string, string> = {};
@@ -1086,7 +1345,7 @@ async function warmAll(): Promise<{ warmed: string[]; failed: Record<string, str
1086
1345
  if (r.status === "fulfilled") warmed.push(names[i]);
1087
1346
  else failed[names[i]] = String(r.reason);
1088
1347
  });
1089
- return { warmed: warmed.sort(), failed };
1348
+ return { mode: "per-function", warmed: warmed.sort(), failed };
1090
1349
  }
1091
1350
 
1092
1351
  Deno.serve(async (req: Request) => {
@@ -1111,7 +1370,7 @@ Deno.serve(async (req: Request) => {
1111
1370
 
1112
1371
  const servicePath = FUNCTIONS_DIR + "/" + name;
1113
1372
  try {
1114
- const worker = await workerFor(name);
1373
+ const worker = (await sharedWorker()) ?? (await workerFor(name));
1115
1374
  return await worker.fetch(req);
1116
1375
  } catch (e) {
1117
1376
  // A missing directory lands here too, so say which function was asked
@@ -1983,6 +2242,9 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1983
2242
  JWT_SECRET: jwtSecret,
1984
2243
  // Where the router looks for `<name>/`, inside the repo mount.
1985
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",
1986
2248
  // The default a function inherits when the config says nothing:
1987
2249
  // reject an unauthenticated call, as hosted Supabase does.
1988
2250
  VERIFY_JWT: "true",
@@ -2025,13 +2287,20 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2025
2287
  // fork of it. Warm, the isolates are clean shared pages of the
2026
2288
  // snapshot and a test's diff is what its requests mutate.
2027
2289
  setup: async () => {
2290
+ const t0 = Date.now();
2028
2291
  const res = await fetch(`http://${g.key("functions")}:9000/_spectest/warm`);
2029
2292
  if (!res.ok) {
2030
2293
  console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
2031
2294
  return;
2032
2295
  }
2033
- const { warmed, failed } = (await res.json()) as { warmed: string[]; failed: Record<string, string> };
2034
- console.log(`supabase(): warmed ${warmed.length} edge function${warmed.length === 1 ? "" : "s"}${warmed.length ? ` (${warmed.join(", ")})` : ""}.`);
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(", ")})` : ""}.`);
2035
2304
  for (const [name, err] of Object.entries(failed)) {
2036
2305
  console.warn(`supabase(): edge function "${name}" failed to boot at warm-up — it will be retried on first call: ${err}`);
2037
2306
  }