@specific.dev/spectest 0.63.0 → 0.64.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.
@@ -698,12 +698,60 @@ async function validJwt(token: string): Promise<boolean> {
698
698
  }
699
699
  }
700
700
 
701
+ /** One isolate per function, reused across requests (the runtime pools by
702
+ * servicePath). The idle timeout is a day, not upstream's five minutes: a
703
+ * test environment is a snapshot lineage whose forks inherit these
704
+ * isolates, and an isolate reaped for idleness is rebooted by the next
705
+ * test that calls the function — a boot's worth of dirtied memory in that
706
+ * test's own snapshot diff. */
707
+ function workerFor(name: string) {
708
+ return EdgeRuntime.userWorkers.create({
709
+ servicePath: FUNCTIONS_DIR + "/" + name,
710
+ memoryLimitMb: 256,
711
+ workerTimeoutMs: 24 * 60 * 60 * 1000,
712
+ noModuleCache: false,
713
+ importMapPath: null,
714
+ envVars: Object.entries(Deno.env.toObject()),
715
+ });
716
+ }
717
+
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> }> {
725
+ const names: string[] = [];
726
+ for await (const entry of Deno.readDir(FUNCTIONS_DIR)) {
727
+ if (!entry.isDirectory || entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
728
+ try {
729
+ await Deno.stat(FUNCTIONS_DIR + "/" + entry.name + "/index.ts");
730
+ names.push(entry.name);
731
+ } catch {
732
+ // not a function directory
733
+ }
734
+ }
735
+ // All at once: a project can have dozens of functions, and each boot is
736
+ // mostly module loading — the isolates come up concurrently.
737
+ const results = await Promise.allSettled(names.map((name) => workerFor(name)));
738
+ const warmed: string[] = [];
739
+ const failed: Record<string, string> = {};
740
+ results.forEach((r, i) => {
741
+ if (r.status === "fulfilled") warmed.push(names[i]);
742
+ else failed[names[i]] = String(r.reason);
743
+ });
744
+ return { warmed: warmed.sort(), failed };
745
+ }
746
+
701
747
  Deno.serve(async (req: Request) => {
702
748
  const { pathname } = new URL(req.url);
703
749
 
704
750
  // Ready probe — answered before any auth check, so bring-up never needs a
705
751
  // token, and never treated as a function name.
706
752
  if (pathname === "/_spectest/health") return new Response("ok");
753
+ // Setup-time warm-up (see warmAll); never treated as a function name.
754
+ if (pathname === "/_spectest/warm") return json(await warmAll());
707
755
 
708
756
  const name = pathname.split("/")[1] ?? "";
709
757
  if (name === "") return json({ msg: "missing function name in request" }, 400);
@@ -718,14 +766,7 @@ Deno.serve(async (req: Request) => {
718
766
 
719
767
  const servicePath = FUNCTIONS_DIR + "/" + name;
720
768
  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
- });
769
+ const worker = await workerFor(name);
729
770
  return await worker.fetch(req);
730
771
  } catch (e) {
731
772
  // A missing directory lands here too, so say which function was asked
@@ -1416,7 +1457,7 @@ export function supabase(opts = {}) {
1416
1457
  }
1417
1458
  // ── functions (edge runtime) ────────────────────────────────────
1418
1459
  if (withFunctions) {
1419
- parts.functions = {
1460
+ const functionsDef = {
1420
1461
  // Built rather than pulled, so the functions' remote imports are
1421
1462
  // fetched by the host builder and cached into the image — see
1422
1463
  // `functionsDockerfile`.
@@ -1472,7 +1513,27 @@ export function supabase(opts = {}) {
1472
1513
  path: "/_spectest/health",
1473
1514
  timeoutSecs: 120,
1474
1515
  },
1516
+ // Boot every function's isolate before the warm snapshot is
1517
+ // taken (see warmAll in the router). Measured on a real project
1518
+ // (Harmony, 2026-08-28): a test that first touched a few
1519
+ // functions dirtied ~1 GB booting them — memory that then had to
1520
+ // be written into that test's snapshot diff and paid for by every
1521
+ // fork of it. Warm, the isolates are clean shared pages of the
1522
+ // snapshot and a test's diff is what its requests mutate.
1523
+ setup: async () => {
1524
+ const res = await fetch(`http://${g.key("functions")}:9000/_spectest/warm`);
1525
+ if (!res.ok) {
1526
+ console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
1527
+ return;
1528
+ }
1529
+ const { warmed, failed } = (await res.json());
1530
+ console.log(`supabase(): warmed ${warmed.length} edge function${warmed.length === 1 ? "" : "s"}${warmed.length ? ` (${warmed.join(", ")})` : ""}.`);
1531
+ for (const [name, err] of Object.entries(failed)) {
1532
+ console.warn(`supabase(): edge function "${name}" failed to boot at warm-up — it will be retried on first call: ${err}`);
1533
+ }
1534
+ },
1475
1535
  };
1536
+ parts.functions = functionsDef;
1476
1537
  }
1477
1538
  // ── kong (gateway / primary) — no explicit dependsOn: the group
1478
1539
  // 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.64.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1043,12 +1043,60 @@ async function validJwt(token: string): Promise<boolean> {
1043
1043
  }
1044
1044
  }
1045
1045
 
1046
+ /** One isolate per function, reused across requests (the runtime pools by
1047
+ * servicePath). The idle timeout is a day, not upstream's five minutes: a
1048
+ * test environment is a snapshot lineage whose forks inherit these
1049
+ * isolates, and an isolate reaped for idleness is rebooted by the next
1050
+ * test that calls the function — a boot's worth of dirtied memory in that
1051
+ * test's own snapshot diff. */
1052
+ function workerFor(name: string) {
1053
+ return EdgeRuntime.userWorkers.create({
1054
+ servicePath: FUNCTIONS_DIR + "/" + name,
1055
+ memoryLimitMb: 256,
1056
+ workerTimeoutMs: 24 * 60 * 60 * 1000,
1057
+ noModuleCache: false,
1058
+ importMapPath: null,
1059
+ envVars: Object.entries(Deno.env.toObject()),
1060
+ });
1061
+ }
1062
+
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> }> {
1070
+ const names: string[] = [];
1071
+ for await (const entry of Deno.readDir(FUNCTIONS_DIR)) {
1072
+ if (!entry.isDirectory || entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
1073
+ try {
1074
+ await Deno.stat(FUNCTIONS_DIR + "/" + entry.name + "/index.ts");
1075
+ names.push(entry.name);
1076
+ } catch {
1077
+ // not a function directory
1078
+ }
1079
+ }
1080
+ // All at once: a project can have dozens of functions, and each boot is
1081
+ // mostly module loading — the isolates come up concurrently.
1082
+ const results = await Promise.allSettled(names.map((name) => workerFor(name)));
1083
+ const warmed: string[] = [];
1084
+ const failed: Record<string, string> = {};
1085
+ results.forEach((r, i) => {
1086
+ if (r.status === "fulfilled") warmed.push(names[i]);
1087
+ else failed[names[i]] = String(r.reason);
1088
+ });
1089
+ return { warmed: warmed.sort(), failed };
1090
+ }
1091
+
1046
1092
  Deno.serve(async (req: Request) => {
1047
1093
  const { pathname } = new URL(req.url);
1048
1094
 
1049
1095
  // Ready probe — answered before any auth check, so bring-up never needs a
1050
1096
  // token, and never treated as a function name.
1051
1097
  if (pathname === "/_spectest/health") return new Response("ok");
1098
+ // Setup-time warm-up (see warmAll); never treated as a function name.
1099
+ if (pathname === "/_spectest/warm") return json(await warmAll());
1052
1100
 
1053
1101
  const name = pathname.split("/")[1] ?? "";
1054
1102
  if (name === "") return json({ msg: "missing function name in request" }, 400);
@@ -1063,14 +1111,7 @@ Deno.serve(async (req: Request) => {
1063
1111
 
1064
1112
  const servicePath = FUNCTIONS_DIR + "/" + name;
1065
1113
  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
- });
1114
+ const worker = await workerFor(name);
1074
1115
  return await worker.fetch(req);
1075
1116
  } catch (e) {
1076
1117
  // A missing directory lands here too, so say which function was asked
@@ -1917,7 +1958,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1917
1958
 
1918
1959
  // ── functions (edge runtime) ────────────────────────────────────
1919
1960
  if (withFunctions) {
1920
- parts.functions = {
1961
+ const functionsDef: ServiceDefinition = {
1921
1962
  // Built rather than pulled, so the functions' remote imports are
1922
1963
  // fetched by the host builder and cached into the image — see
1923
1964
  // `functionsDockerfile`.
@@ -1976,7 +2017,27 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1976
2017
  path: "/_spectest/health",
1977
2018
  timeoutSecs: 120,
1978
2019
  },
2020
+ // Boot every function's isolate before the warm snapshot is
2021
+ // taken (see warmAll in the router). Measured on a real project
2022
+ // (Harmony, 2026-08-28): a test that first touched a few
2023
+ // functions dirtied ~1 GB booting them — memory that then had to
2024
+ // be written into that test's snapshot diff and paid for by every
2025
+ // fork of it. Warm, the isolates are clean shared pages of the
2026
+ // snapshot and a test's diff is what its requests mutate.
2027
+ setup: async () => {
2028
+ const res = await fetch(`http://${g.key("functions")}:9000/_spectest/warm`);
2029
+ if (!res.ok) {
2030
+ console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
2031
+ return;
2032
+ }
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(", ")})` : ""}.`);
2035
+ for (const [name, err] of Object.entries(failed)) {
2036
+ console.warn(`supabase(): edge function "${name}" failed to boot at warm-up — it will be retried on first call: ${err}`);
2037
+ }
2038
+ },
1979
2039
  };
2040
+ parts.functions = functionsDef;
1980
2041
  }
1981
2042
 
1982
2043
  // ── kong (gateway / primary) — no explicit dependsOn: the group