@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.
- package/dist/components/supabase.d.ts +14 -0
- package/dist/components/supabase.js +323 -9
- package/package.json +1 -1
- package/src/components/supabase.ts +339 -9
|
@@ -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
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|