@specific.dev/spectest 0.87.0 → 0.88.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.
Files changed (42) hide show
  1. package/dist/browser.d.ts +7 -0
  2. package/dist/browser.js +102 -10
  3. package/dist/components/aws.d.ts +8 -7
  4. package/dist/components/aws.js +6 -0
  5. package/dist/components/electron-chrome.d.ts +1 -0
  6. package/dist/components/electron-chrome.js +76 -0
  7. package/dist/components/electron.d.ts +46 -0
  8. package/dist/components/electron.js +99 -0
  9. package/dist/components/index.d.ts +1 -0
  10. package/dist/components/index.js +1 -0
  11. package/dist/components/supabase-functions-reach.d.ts +18 -0
  12. package/dist/components/supabase-functions-reach.js +155 -0
  13. package/dist/components/supabase.js +33 -5
  14. package/dist/daemon.js +34 -2
  15. package/dist/desktop-recorder.d.ts +1 -0
  16. package/dist/desktop-recorder.js +30 -0
  17. package/dist/desktop.d.ts +42 -0
  18. package/dist/desktop.js +10 -0
  19. package/dist/harness/reach.d.ts +4 -0
  20. package/dist/harness/reach.js +2 -2
  21. package/dist/index.d.ts +26 -0
  22. package/dist/index.js +5 -0
  23. package/dist/recorder.d.ts +2 -0
  24. package/package.json +1 -1
  25. package/src/browser.ts +106 -10
  26. package/src/components/aws.ts +6 -0
  27. package/src/components/electron-chrome.ts +76 -0
  28. package/src/components/electron.test.ts +38 -0
  29. package/src/components/electron.ts +115 -0
  30. package/src/components/index.ts +1 -0
  31. package/src/components/supabase-functions-reach.test.ts +90 -0
  32. package/src/components/supabase-functions-reach.ts +153 -0
  33. package/src/components/supabase.ts +33 -5
  34. package/src/daemon.ts +43 -6
  35. package/src/desktop-recorder.test.ts +55 -0
  36. package/src/desktop-recorder.ts +30 -0
  37. package/src/desktop.ts +43 -0
  38. package/src/harness/reach.test.ts +12 -0
  39. package/src/harness/reach.ts +6 -2
  40. package/src/index.ts +33 -0
  41. package/src/inputs.test.ts +20 -0
  42. package/src/recorder.ts +2 -0
@@ -0,0 +1,76 @@
1
+ // Runs before the application's main entry, like Playwright's Electron loader.
2
+ // Observe constructor options; do not replace the app entry, preload, IPC, or OS.
3
+ export const ELECTRON_CHROME_HOOK = String.raw`
4
+ const electron = require('electron');
5
+ const NativeBrowserWindow = electron.BrowserWindow;
6
+ const WrappedBrowserWindow = new Proxy(NativeBrowserWindow, {
7
+ construct(Target, args, NewTarget) {
8
+ const win = Reflect.construct(Target, args, NewTarget);
9
+ const options = args[0] || {};
10
+ const chrome = {
11
+ titleBarStyle: options.titleBarStyle || 'default',
12
+ frame: options.frame !== false,
13
+ trafficLightPosition: options.trafficLightPosition || null,
14
+ titleBarOverlay: options.titleBarOverlay || false,
15
+ roundedCorners: options.roundedCorners !== false,
16
+ buttonVisibility: null,
17
+ };
18
+ const publish = () => {
19
+ if (win.isDestroyed() || win.webContents.isDestroyed()) return;
20
+ void win.webContents.executeJavaScript(
21
+ 'window.__spectestElectronChrome = ' + JSON.stringify(chrome)
22
+ ).catch(() => {}); // A document can be replaced while this is in flight.
23
+ };
24
+ win.webContents.on('dom-ready', publish);
25
+ // Capture supported native setters too, without adding macOS APIs on Linux.
26
+ for (const [method, key] of [
27
+ ['setWindowButtonVisibility', 'buttonVisibility'],
28
+ ['setWindowButtonPosition', 'trafficLightPosition'],
29
+ ['setTitleBarOverlay', 'titleBarOverlay'],
30
+ ]) {
31
+ if (typeof win[method] !== 'function') continue;
32
+ const original = win[method];
33
+ win[method] = function(value) {
34
+ const result = original.call(this, value);
35
+ chrome[key] = value;
36
+ publish();
37
+ return result;
38
+ };
39
+ }
40
+ return win;
41
+ },
42
+ });
43
+ // Electron's own exports are non-configurable. Wrap module resolution instead
44
+ // of modifying them; all other exports retain their original descriptors.
45
+ const Module = require('node:module');
46
+ const originalLoad = Module._load;
47
+ const facades = new Map();
48
+ Module._load = function(request, ...args) {
49
+ const exports = originalLoad.call(this, request, ...args);
50
+ if (request !== 'electron' && request !== 'electron/main') return exports;
51
+ if (!facades.has(exports)) {
52
+ const descriptors = Object.getOwnPropertyDescriptors(exports);
53
+ descriptors.BrowserWindow = { value: WrappedBrowserWindow, enumerable: true };
54
+ facades.set(exports, Object.defineProperties({}, descriptors));
55
+ }
56
+ return facades.get(exports);
57
+ };
58
+ // Electron's native ESM translator bypasses Module._load. Route ESM imports
59
+ // through the same facade using Node's synchronous loader hooks when available.
60
+ if (typeof Module.registerHooks === 'function') {
61
+ const source = 'import { createRequire } from "node:module";\n' +
62
+ 'const electron = createRequire(' + JSON.stringify(__filename) + ')("electron");\n' +
63
+ 'export default electron;\n' + Object.keys(electron)
64
+ .filter(key => /^[A-Za-z_$][\w$]*$/.test(key))
65
+ .map(key => 'export const ' + key + ' = electron.' + key + ';').join('\n');
66
+ const url = 'data:text/javascript,' + encodeURIComponent(source);
67
+ Module.registerHooks({
68
+ resolve(specifier, context, next) {
69
+ if ((specifier === 'electron' || specifier === 'electron/main') && context.conditions.includes('import')) {
70
+ return { url, shortCircuit: true };
71
+ }
72
+ return next(specifier, context);
73
+ },
74
+ });
75
+ }
76
+ `;
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { electron } from './electron.js';
6
+ import { desktopApp, isDesktopApp } from '../desktop.js';
7
+
8
+ function fixture(run: (directory: string) => void) {
9
+ const directory = mkdtempSync(join(tmpdir(), 'spectest-electron-'));
10
+ try {
11
+ writeFileSync(join(directory, 'package.json'), JSON.stringify({ devDependencies: { electron: '43.2.0' } }));
12
+ run(directory);
13
+ } finally { rmSync(directory, { recursive: true, force: true }); }
14
+ }
15
+
16
+ describe('Electron component contract', () => {
17
+ test('rejects apps without Electron before starting an expensive build', () => fixture(appDir => {
18
+ writeFileSync(join(appDir, 'package.json'), '{}');
19
+ expect(() => electron({ appDir })).toThrow('declare electron');
20
+ }));
21
+ test('honors locked installation, custom build commands, and app environment', () => fixture(appDir => {
22
+ writeFileSync(join(appDir, 'package-lock.json'), '{}');
23
+ const service = electron({ appDir, buildCommand: 'npm run compile', env: { API_URL: 'http://backend:4320' } });
24
+ expect(service.image).toMatchObject({ context: appDir });
25
+ expect((service.image as { content: string }).content).toContain('RUN npm ci\n');
26
+ expect((service.image as { content: string }).content).toContain('RUN npm run compile\n');
27
+ expect(service.env?.API_URL).toBe('http://backend:4320');
28
+ }));
29
+ test('rejects malformed image tags and multiline Dockerfile injection', () => fixture(appDir => {
30
+ expect(() => electron({ appDir, nodeVersion: '24\nRUN false' })).toThrow('image tag');
31
+ expect(() => electron({ appDir, buildCommand: 'npm run build\nUSER other' })).toThrow('single-line');
32
+ }));
33
+ test('desktop identity cannot be mistaken for a URL or browser/mobile handle', () => {
34
+ expect(isDesktopApp(desktopApp('http://app.internal:9223', 'app'))).toBe(true);
35
+ expect(isDesktopApp({ url: 'http://app.internal:8081' })).toBe(false);
36
+ expect(isDesktopApp(null)).toBe(false);
37
+ });
38
+ });
@@ -0,0 +1,115 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import type { ServiceDefinition } from "../index.js";
3
+ import { resolveProjectPath } from "../project-files.js";
4
+ import { desktopApp, type DesktopApp } from "../desktop.js";
5
+ import { ELECTRON_CHROME_HOOK } from "./electron-chrome.js";
6
+
7
+ export interface ElectronOptions {
8
+ /** Project-relative app directory; must contain package.json and Electron. */
9
+ appDir?: string;
10
+ /** Debian Node image tag, default 24-bookworm-slim. */
11
+ nodeVersion?: string;
12
+ /** Build the main/preload/renderer output; default npm run build. */
13
+ buildCommand?: string;
14
+ /** Dependency install command; defaults to npm ci when a lockfile exists. */
15
+ installCommand?: string;
16
+ /** App entry relative to /app, default . (package.json main). */
17
+ entry?: string;
18
+ env?: Record<string, string>;
19
+ dependsOn?: string[];
20
+ }
21
+
22
+ // Test-only debugger proxy: Electron binds its CDP socket to loopback. Forward
23
+ // HTTP and WebSocket traffic from the VM to that socket, normalizing Host as
24
+ // Chromium requires. It exposes no app-specific actions or state endpoints.
25
+ export const ELECTRON_LAUNCHER = String.raw`
26
+ import http from 'node:http';
27
+ import { spawn } from 'node:child_process';
28
+ import { createRequire } from 'node:module';
29
+ const require = createRequire('/app/package.json');
30
+ const child = spawn(require('electron'), [
31
+ ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
32
+ '--disable-dev-shm-usage', '--remote-debugging-port=9222',
33
+ '-r', '/spectest-electron-chrome.cjs',
34
+ process.env.SPECTEST_ELECTRON_ENTRY || '.',
35
+ ], { cwd: '/app', stdio: 'inherit', env: process.env });
36
+ const upstream = req => ({
37
+ host: '127.0.0.1', port: 9222, path: req.url, method: req.method,
38
+ headers: { ...req.headers, host: '127.0.0.1:9222' },
39
+ });
40
+ const server = http.createServer((req, res) => {
41
+ const proxy = http.request(upstream(req), response => {
42
+ res.writeHead(response.statusCode, response.headers);
43
+ response.pipe(res);
44
+ });
45
+ proxy.on('error', () => { res.writeHead(503); res.end('Electron debugger starting'); });
46
+ req.pipe(proxy);
47
+ });
48
+ server.on('upgrade', (req, socket, head) => {
49
+ const proxy = http.request(upstream(req));
50
+ proxy.on('upgrade', (response, remote, remoteHead) => {
51
+ socket.write('HTTP/1.1 101 Switching Protocols\r\n' +
52
+ Object.entries(response.headers).map(([k, v]) => k + ': ' + v).join('\r\n') + '\r\n\r\n');
53
+ if (remoteHead.length) socket.write(remoteHead);
54
+ if (head.length) remote.write(head);
55
+ socket.pipe(remote); remote.pipe(socket);
56
+ socket.on('error', () => remote.destroy());
57
+ remote.on('error', () => socket.destroy());
58
+ socket.on('close', () => remote.destroy());
59
+ remote.on('close', () => socket.destroy());
60
+ });
61
+ proxy.on('response', () => socket.destroy());
62
+ proxy.on('error', () => socket.destroy());
63
+ proxy.end();
64
+ });
65
+ server.listen(9223, '0.0.0.0');
66
+ child.on('error', error => { console.error(error); process.exit(1); });
67
+ child.on('exit', code => process.exit(code ?? 1));
68
+ for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal));
69
+ `;
70
+
71
+ /** Real Linux Electron under Xvfb, with a generated image and graphical replay.
72
+ * Like expo(), returns an app handle for the matching context method.
73
+ * Requires Linux-compatible application dependencies and binaries. */
74
+ export function electron(options: ElectronOptions = {}) {
75
+ const appDir = options.appDir ?? ".";
76
+ const manifest = resolveProjectPath(`${appDir}/package.json`);
77
+ if (!existsSync(manifest)) throw new Error(`electron(): no package.json in ${appDir}`);
78
+ const pkg = JSON.parse(readFileSync(manifest, "utf8"));
79
+ if (!pkg.dependencies?.electron && !pkg.devDependencies?.electron) {
80
+ throw new Error("electron(): declare electron in the app's dependencies or devDependencies");
81
+ }
82
+ const locked = existsSync(resolveProjectPath(`${appDir}/package-lock.json`));
83
+ const node = options.nodeVersion ?? "24-bookworm-slim";
84
+ if (!/^[\w.-]+$/.test(node)) throw new Error("electron(): invalid Node image tag");
85
+ const install = options.installCommand ?? (locked ? "npm ci" : "npm install");
86
+ const build = options.buildCommand ?? "npm run build";
87
+ if ([install, build].some(command => /[\r\n]/.test(command))) {
88
+ throw new Error("electron(): build/install commands must be single-line shell commands");
89
+ }
90
+ return {
91
+ image: {
92
+ type: "dockerfile",
93
+ context: appDir,
94
+ exclude: ["node_modules", "**/node_modules", "out", "test-results", ".data"],
95
+ content: `FROM node:${node}
96
+ RUN apt-get update && apt-get install -y --no-install-recommends xvfb xauth libgtk-3-0 libnss3 libgbm1 libasound2 fonts-liberation ca-certificates && rm -rf /var/lib/apt/lists/*
97
+ WORKDIR /app
98
+ COPY . .
99
+ RUN ${install}
100
+ RUN node node_modules/electron/install.js
101
+ RUN ${build}
102
+ `,
103
+ },
104
+ files: [
105
+ { path: "/spectest-electron.mjs", content: ELECTRON_LAUNCHER },
106
+ { path: "/spectest-electron-chrome.cjs", content: ELECTRON_CHROME_HOOK },
107
+ ],
108
+ command: "xvfb-run -a node /spectest-electron.mjs",
109
+ env: { ...options.env, SPECTEST_ELECTRON_ENTRY: options.entry ?? "." },
110
+ dependsOn: options.dependsOn,
111
+ ports: [9223],
112
+ readyCheck: { type: "http", port: 9223, path: "/json/version", timeoutSecs: 60 },
113
+ helpers: ({ name }: { name: string }) => desktopApp(`http://${name}.internal:9223`, name),
114
+ } satisfies ServiceDefinition<DesktopApp>;
115
+ }
@@ -97,3 +97,4 @@ export {
97
97
  type CassetteResponse,
98
98
  type CassetteState,
99
99
  } from "./replayFake.js";
100
+ export { electron, type ElectronOptions } from "./electron.js";
@@ -0,0 +1,90 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { excludeAllBut, functionsReach, importMapLocalTargets, importSpecifiers } from "./supabase-functions-reach.js";
7
+
8
+ let root: string;
9
+
10
+ function file(rel: string, text: string): void {
11
+ mkdirSync(join(root, rel, ".."), { recursive: true });
12
+ writeFileSync(join(root, rel), text);
13
+ }
14
+
15
+ beforeAll(() => {
16
+ root = mkdtempSync(join(tmpdir(), "sb-fn-reach-"));
17
+ file("supabase/functions/hello/index.ts", 'import { greet } from "../../../packages/shared/src/greet.ts";\nimport "npm:zod";\nDeno.serve(() => new Response(greet()));\n');
18
+ file("supabase/functions/bye/index.ts", 'import { db } from "@db/client.ts";\nexport default 1;\n');
19
+ file("supabase/functions/deno.json", JSON.stringify({ imports: { "@db/": "../../lib/db/", "zod": "npm:zod" } }));
20
+ file("packages/shared/src/greet.ts", 'import { fmt } from "./fmt.ts";\nimport { util } from "../../util/mod.ts";\nexport const greet = () => fmt(util());\n');
21
+ file("packages/shared/src/fmt.ts", "export const fmt = (s: string) => s;\n");
22
+ file("packages/util/mod.ts", "export const util = () => 'x';\n");
23
+ file("lib/db/client.ts", "export const db = 1;\n");
24
+ file("docs/guide.md", "# not code\n");
25
+ file("site/app/page.tsx", 'import x from "../../packages/shared/src/greet.ts";\n');
26
+ });
27
+
28
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
29
+
30
+ describe("functionsReach", () => {
31
+ test("follows relative imports and local import-map entries transitively, into directories", () => {
32
+ expect(functionsReach(root, "supabase/functions")).toEqual(["lib/db", "packages/shared/src", "packages/util", "supabase/functions"]);
33
+ });
34
+ test("a self-contained functions directory is the whole answer; a missing one is nothing", () => {
35
+ file("alone/functions/f/index.ts", 'import "https://deno.land/std/http/server.ts";\nimport("./helper.ts");\n');
36
+ expect(functionsReach(root, "alone/functions")).toEqual(["alone/functions"]);
37
+ expect(functionsReach(root, "nowhere/functions")).toEqual([]);
38
+ });
39
+ test("an import that leaves the project is ignored", () => {
40
+ file("escape/functions/f/index.ts", 'import "../../../../../../etc/passwd";\n');
41
+ expect(functionsReach(root, "escape/functions")).toEqual(["escape/functions"]);
42
+ });
43
+ test("the exclude list drops everything but the answer", () => {
44
+ expect(excludeAllBut(["lib/db", "supabase/functions"])).toEqual([
45
+ "**",
46
+ "!lib/db",
47
+ "!lib/db/**",
48
+ "!supabase/functions",
49
+ "!supabase/functions/**",
50
+ "**/node_modules/**",
51
+ "**/.git/**",
52
+ "**/.spectest/**",
53
+ "**/__pycache__/**",
54
+ "**/.venv/**",
55
+ ]);
56
+ expect(excludeAllBut(["."])).toEqual([]);
57
+ });
58
+ });
59
+
60
+ describe("parsers", () => {
61
+ test("import specifiers of every shape", () => {
62
+ const t = 'import a from "./a.ts";\nexport * from "../b.ts";\nimport "./side.ts";\nconst m = await import("./dyn.ts");\nimport type { T } from "npm:x";\n';
63
+ expect(importSpecifiers(t)).toEqual(["./a.ts", "../b.ts", "./side.ts", "./dyn.ts", "npm:x"]);
64
+ });
65
+ test("import map local targets", () => {
66
+ expect(importMapLocalTargets('{"imports":{"@a/":"../../a/","b":"npm:b","c":"/c/mod.ts"}}')).toEqual(["../../a/", "/c/mod.ts"]);
67
+ expect(importMapLocalTargets("not json")).toEqual([]);
68
+ });
69
+ });
70
+
71
+ describe("supabase() wiring", () => {
72
+ test("the functions image context is the module graph, and the root mount is reach-exempt", async () => {
73
+ const prev = process.env.SPECTEST_WORKSPACE;
74
+ process.env.SPECTEST_WORKSPACE = root;
75
+ try {
76
+ const { supabase } = await import("./supabase.js");
77
+ const stack = supabase({ dir: "supabase" });
78
+ const parts = stack.group.services({ name: "sb", key: (part: string) => (part === "gateway" ? "sb" : `sb-${part}`) });
79
+ const fn = parts.functions as { image: { context?: string; exclude?: readonly string[] }; volumes?: readonly { source?: string; reachExempt?: boolean }[] };
80
+ expect(fn.image.context).toBe(".");
81
+ expect(fn.image.exclude?.slice(0, 9)).toEqual(["**", "!lib/db", "!lib/db/**", "!packages/shared/src", "!packages/shared/src/**", "!packages/util", "!packages/util/**", "!supabase/functions", "!supabase/functions/**"]);
82
+ expect(fn.image.exclude?.at(-1)).toBe("**/.venv/**");
83
+ expect(fn.volumes?.[0]).toMatchObject({ source: root, reachExempt: true });
84
+ expect((parts.db as { inputs?: readonly string[] }).inputs).toEqual(["supabase"]);
85
+ } finally {
86
+ if (prev === undefined) delete process.env.SPECTEST_WORKSPACE;
87
+ else process.env.SPECTEST_WORKSPACE = prev;
88
+ }
89
+ });
90
+ });
@@ -0,0 +1,153 @@
1
+ // What the edge functions can load from the project — the pure part.
2
+ //
3
+ // The edge runtime serves each function from the project root, mounted
4
+ // whole so that a monorepo function's `../../packages/shared/mod.ts`
5
+ // resolves where it sits. Whole is right for the runtime and wrong for
6
+ // the reach: with the root in a build context and a volume, every file of
7
+ // the project could "reach" the functions, and a change to `docs/` could
8
+ // never skip a run. So the component works out the **module graph** the
9
+ // functions can load, statically, and hands the build context and the
10
+ // reach exactly that: the functions directory, and every directory a
11
+ // relative import or a local import-map entry leads to, followed
12
+ // transitively. Directories rather than files, so a sibling module a
13
+ // dynamic `import()` picks at run time comes along.
14
+ //
15
+ // What it cannot see: a computed specifier that leaves the directories
16
+ // found (`import(\`../../${x}/mod.ts\`)`), and a `Deno.readTextFile` of a
17
+ // path outside them. Both are rare in a function, and both are the same
18
+ // class of hole as a test reading the workspace through `node:fs`. A tree
19
+ // too large to walk within the budget is answered with the whole project,
20
+ // the safe side.
21
+
22
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
23
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
24
+
25
+ /** Files the walk parses for imports. */
26
+ const SOURCE_EXT = /\.(?:[mc]?[jt]sx?)$/;
27
+ /** Names never walked into. */
28
+ const SKIP = new Set(["node_modules", ".git", ".spectest", "dist", "build", ".next"]);
29
+ /** The walk gives up past this many files and answers with the root. */
30
+ const FILE_BUDGET = 20_000;
31
+
32
+ const IMPORT_RE =
33
+ /\b(?:import|export)\b[^;'"`]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]|\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
34
+
35
+ /** Specifiers a source text imports, as written. */
36
+ export function importSpecifiers(text: string): string[] {
37
+ const out: string[] = [];
38
+ for (const m of text.matchAll(IMPORT_RE)) out.push(m[1] ?? m[2] ?? m[3] ?? "");
39
+ return out.filter((s) => s.length > 0);
40
+ }
41
+
42
+ /** Local targets an import map (`deno.json` `imports`, `import_map.json`)
43
+ * names, as written: relative values, and root-relative `/…` ones. */
44
+ export function importMapLocalTargets(text: string): string[] {
45
+ let parsed: unknown;
46
+ try {
47
+ parsed = JSON.parse(text);
48
+ } catch {
49
+ return [];
50
+ }
51
+ const imports = (parsed as { imports?: unknown })?.imports;
52
+ if (typeof imports !== "object" || imports === null) return [];
53
+ return Object.values(imports as Record<string, unknown>).filter(
54
+ (v): v is string => typeof v === "string" && (v.startsWith("./") || v.startsWith("../") || v.startsWith("/")),
55
+ );
56
+ }
57
+
58
+ function toPosix(p: string): string {
59
+ return sep === "/" ? p : p.split(sep).join("/");
60
+ }
61
+
62
+ /**
63
+ * The directories, project-relative and sorted, that the functions under
64
+ * `fnRelPath` can load modules from: `fnRelPath` itself, and every
65
+ * directory a relative import or a local import-map entry leads to,
66
+ * transitively. `["."]` when the walk runs out of budget. A directory
67
+ * nested under another in the answer is dropped.
68
+ */
69
+ export function functionsReach(root: string, fnRelPath: string): string[] {
70
+ const rootAbs = resolve(root);
71
+ const dirs = new Set<string>();
72
+ const queue: string[] = [resolve(rootAbs, fnRelPath)];
73
+ let budget = FILE_BUDGET;
74
+
75
+ const inRoot = (p: string): boolean => p === rootAbs || p.startsWith(rootAbs + sep);
76
+ const enqueue = (target: string): void => {
77
+ if (!inRoot(target) || !existsSync(target)) return;
78
+ let st;
79
+ try {
80
+ st = statSync(target);
81
+ } catch {
82
+ return;
83
+ }
84
+ const dir = st.isDirectory() ? target : dirname(target);
85
+ if (![...dirs].some((d) => dir === d || dir.startsWith(d + sep))) queue.push(dir);
86
+ };
87
+ const follow = (fromDir: string, spec: string): void => {
88
+ if (spec.startsWith("./") || spec.startsWith("../")) enqueue(resolve(fromDir, spec));
89
+ else if (spec.startsWith("/")) enqueue(resolve(rootAbs, spec.slice(1)));
90
+ };
91
+
92
+ while (queue.length > 0) {
93
+ const dir = queue.shift()!;
94
+ if ([...dirs].some((d) => dir === d || dir.startsWith(d + sep))) continue;
95
+ if (!existsSync(dir)) continue;
96
+ dirs.add(dir);
97
+ // Every file below it, parsed for what it imports.
98
+ const stack: string[] = [dir];
99
+ while (stack.length > 0) {
100
+ const d = stack.pop()!;
101
+ let entries;
102
+ try {
103
+ entries = readdirSync(d, { withFileTypes: true });
104
+ } catch {
105
+ continue;
106
+ }
107
+ for (const e of entries) {
108
+ const p = join(d, e.name);
109
+ if (e.isDirectory()) {
110
+ if (!SKIP.has(e.name)) stack.push(p);
111
+ continue;
112
+ }
113
+ if (!e.isFile()) continue;
114
+ if (--budget < 0) return ["."];
115
+ if (e.name === "deno.json" || e.name === "import_map.json") {
116
+ for (const t of importMapLocalTargets(safeRead(p))) follow(d, t.endsWith("/") ? t.slice(0, -1) : t);
117
+ continue;
118
+ }
119
+ if (!SOURCE_EXT.test(e.name)) continue;
120
+ for (const spec of importSpecifiers(safeRead(p))) follow(d, spec);
121
+ }
122
+ }
123
+ }
124
+
125
+ // Minimal, project-relative, sorted.
126
+ const rel = [...dirs].map((d) => (d === rootAbs ? "." : toPosix(relative(rootAbs, d))));
127
+ const minimal = rel.filter((d) => !rel.some((o) => o !== d && (o === "." || d.startsWith(o + "/"))));
128
+ return [...new Set(minimal)].sort();
129
+ }
130
+
131
+ function safeRead(p: string): string {
132
+ try {
133
+ return readFileSync(p, "utf8");
134
+ } catch {
135
+ return "";
136
+ }
137
+ }
138
+
139
+ /** The `exclude` list that narrows a root build context to `dirs`: drop
140
+ * everything, then re-include each directory and its subtree, then drop
141
+ * again what no context should carry (a re-included `packages/shared`
142
+ * would otherwise bring its `node_modules` along). */
143
+ export function excludeAllBut(dirs: readonly string[]): string[] {
144
+ if (dirs.includes(".")) return [];
145
+ return ["**", ...dirs.flatMap((d) => [`!${d}`, `!${d}/**`]), ...REEXCLUDE];
146
+ }
147
+
148
+ /** Re-excluded after the re-includes: the names the default ignore drops. */
149
+ const REEXCLUDE = ["**/node_modules/**", "**/.git/**", "**/.spectest/**", "**/__pycache__/**", "**/.venv/**"];
150
+
151
+ // Keep `isAbsolute` referenced for callers that pass absolute fnRelPath by
152
+ // mistake: the walk resolves it against the root either way.
153
+ void isAbsolute;
@@ -51,6 +51,7 @@
51
51
 
52
52
  import { createHmac } from "node:crypto";
53
53
  import { existsSync, readFileSync } from "node:fs";
54
+ import { excludeAllBut, functionsReach } from "./supabase-functions-reach.js";
54
55
  import { readdir, stat } from "node:fs/promises";
55
56
  import { dirname, join } from "node:path";
56
57
 
@@ -166,6 +167,9 @@ function providerCredentials(
166
167
  * cache rides into the image as a layer — so at run time the worker resolves
167
168
  * those URLs locally and never reaches for the network.
168
169
  *
170
+ * The context is the module graph the functions can load (the functions
171
+ * directory and what its imports lead to, `functionsReach`), copied whole
172
+ * so a monorepo function's `../../packages/…` resolves at bundle time too.
169
173
  * One synthetic entrypoint imports every function, so the whole set is one
170
174
  * graph rather than one per function. Bundling is best-effort: a function that
171
175
  * fails to bundle (a type error, an import that only resolves at run time)
@@ -175,13 +179,13 @@ function providerCredentials(
175
179
  function functionsDockerfile(fnRelPath: string): string {
176
180
  return `FROM ${IMAGES.functions}
177
181
  ENV DENO_DIR=${DENO_DIR}
178
- COPY ${fnRelPath} /warm/functions
182
+ COPY . /warm/repo
179
183
  RUN set -eu; \
180
184
  : > /warm/all.ts; \
181
- for d in /warm/functions/*/; do \
185
+ for d in /warm/repo/${fnRelPath}/*/; do \
182
186
  [ -f "$d/index.ts" ] || continue; \
183
187
  n=$(basename "$d"); \
184
- printf 'import "./functions/%s/index.ts";\\n' "$n" >> /warm/all.ts; \
188
+ printf 'import "./repo/${fnRelPath}/%s/index.ts";\\n' "$n" >> /warm/all.ts; \
185
189
  done; \
186
190
  edge-runtime bundle --entrypoint /warm/all.ts --output /tmp/warm.eszip --quiet || true; \
187
191
  rm -rf /tmp/warm.eszip /warm
@@ -1684,6 +1688,10 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1684
1688
  ? `${REPO_MOUNT}/${fnDir.slice(root.length + 1)}`
1685
1689
  : `${REPO_MOUNT}/supabase/functions`;
1686
1690
  const migrationsPath = inDir("migrations");
1691
+ // The directories the functions can load modules from: the functions
1692
+ // directory and what its imports lead to (`supabase-functions-reach.ts`).
1693
+ // The build context and the reach are narrowed to exactly these.
1694
+ const fnReachDirs = withFunctions ? functionsReach(root, fnRelPath) : [];
1687
1695
 
1688
1696
  // ── config file (config.toml / config.json) ───────────────────────────
1689
1697
  // The CLI's own project config, mapped onto the stack. Read at build time:
@@ -1890,8 +1898,20 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1890
1898
  const parts: ServicesMap = {};
1891
1899
 
1892
1900
  // ── db ──────────────────────────────────────────────────────────
1901
+ // What the stack reads from the project: the Supabase folder
1902
+ // (config.toml, migrations, seed, functions) and the mail templates,
1903
+ // which may sit anywhere in the repo. Declared as inputs so a change
1904
+ // to any of them is never taken as one that can affect no test; the
1905
+ // migrations volume carries the folder too, but only when there is
1906
+ // a migrations directory, and a config-only project must count as
1907
+ // well.
1908
+ const inputs: string[] = [
1909
+ ...(opts.dir !== undefined && !opts.dir.startsWith("/") ? [opts.dir] : []),
1910
+ ...templateBodies.map(([, spec]) => spec.path).filter((t) => !t.startsWith("/")),
1911
+ ];
1893
1912
  const dbDef: ServiceDefinition = {
1894
1913
  image: { type: "registry", reference: opts.db?.image ?? IMAGES.db },
1914
+ ...(inputs.length > 0 ? { inputs } : {}),
1895
1915
  // `args` (not `command`): keep the image's docker-entrypoint.sh so it
1896
1916
  // runs initdb + its baked init-scripts before starting.
1897
1917
  args: [
@@ -2211,6 +2231,12 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2211
2231
  image: {
2212
2232
  type: "dockerfile",
2213
2233
  content: functionsDockerfile(fnRelPath),
2234
+ // The root, narrowed to the module graph: only the functions
2235
+ // directory and what its imports lead to enter the context, so
2236
+ // a change anywhere else neither uploads nor counts as reaching
2237
+ // the functions.
2238
+ context: ".",
2239
+ exclude: excludeAllBut(fnReachDirs),
2214
2240
  },
2215
2241
  // The **project root**, mounted with the functions directory in its
2216
2242
  // real position underneath. Mounting only `supabase/functions`
@@ -2219,8 +2245,10 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2219
2245
  // (`../../../../packages/…`), and that only resolves if the
2220
2246
  // directory sits where it sits in the repo. Read-only: the runtime
2221
2247
  // only reads sources, and a function writing into its own directory
2222
- // would be writing into the user's checkout.
2223
- volumes: [{ source: projectRootPath(), target: REPO_MOUNT, readOnly: true }],
2248
+ // would be writing into the user's checkout. For the reach the
2249
+ // mount is exempt: what the functions can load is the module graph
2250
+ // above, which the build context already states.
2251
+ volumes: [{ source: projectRootPath(), target: REPO_MOUNT, readOnly: true, reachExempt: true }],
2224
2252
  // Our router (see FUNCTIONS_MAIN_TS) sits outside that mount, so
2225
2253
  // the project never has to carry a `main/` function of ours.
2226
2254
  files: [{ path: "/home/deno/main/index.ts", content: FUNCTIONS_MAIN_TS }],