@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.
- package/dist/components/supabase.d.ts +14 -0
- package/dist/components/supabase.js +271 -18
- package/package.json +1 -1
- package/src/components/supabase.ts +287 -18
|
@@ -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:
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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.
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
736
|
-
|
|
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
|
-
|
|
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
|
@@ -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:
|
|
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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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.
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
-
|
|
1081
|
-
|
|
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 {
|
|
2034
|
-
|
|
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
|
}
|