diff --git a/packages/cli/src/core/resources/function/config.ts b/packages/cli/src/core/resources/function/config.ts index 162336ff..e0b52be2 100644 --- a/packages/cli/src/core/resources/function/config.ts +++ b/packages/cli/src/core/resources/function/config.ts @@ -1,4 +1,4 @@ -import { basename, dirname, join, relative } from "node:path"; +import { basename, dirname, join, relative, resolve } from "node:path"; import { globby } from "globby"; import { ENTRY_FILE_GLOB, @@ -10,6 +10,7 @@ import { InvalidInputError, SchemaValidationError, } from "@/core/errors.js"; +import { collectReachableBackendFiles } from "@/core/resources/function/reachability.js"; import type { BackendFunction, FunctionConfig, @@ -32,7 +33,10 @@ async function readFunctionConfig(configPath: string): Promise { return result.data; } -async function readFunction(configPath: string): Promise { +async function readFunction( + configPath: string, + backendRoot: string, +): Promise { const config = await readFunctionConfig(configPath); const functionDir = dirname(configPath); const entryPath = join(functionDir, config.entry); @@ -51,10 +55,17 @@ async function readFunction(configPath: string): Promise { absolute: true, }); + const extraFiles = await collectReachableBackendFiles( + entryPath, + filePaths, + backendRoot, + ); + const allFilePaths = [...new Set([...filePaths, ...extraFiles])]; + const functionData: BackendFunction = { ...config, entryPath, - filePaths, + filePaths: allFilePaths, source: { type: "project" }, }; return functionData; @@ -84,8 +95,10 @@ export async function readAllFunctions( (entryFile) => !configFilesDirs.has(dirname(entryFile)), ); + const backendRoot = resolve(functionsDir, ".."); + const functionsFromConfig = await Promise.all( - configFiles.map((configPath) => readFunction(configPath)), + configFiles.map((configPath) => readFunction(configPath, backendRoot)), ); const functionsWithoutConfig = await Promise.all( @@ -96,6 +109,13 @@ export async function readAllFunctions( absolute: true, }); + const extraFiles = await collectReachableBackendFiles( + entryFile, + filePaths, + backendRoot, + ); + const allFilePaths = [...new Set([...filePaths, ...extraFiles])]; + const name = relative(functionsDir, functionDir).split(/[/\\]/).join("/"); if (!name) { throw new InvalidInputError( @@ -115,7 +135,7 @@ export async function readAllFunctions( name, entry, entryPath: entryFile, - filePaths, + filePaths: allFilePaths, source: { type: "project" }, }; return functionData; diff --git a/packages/cli/src/core/resources/function/reachability.ts b/packages/cli/src/core/resources/function/reachability.ts new file mode 100644 index 00000000..cbe3ced9 --- /dev/null +++ b/packages/cli/src/core/resources/function/reachability.ts @@ -0,0 +1,85 @@ +import { dirname, extname, relative, resolve } from "node:path"; +import { pathExists, readTextFile } from "@/core/utils/fs.js"; + +// Matches relative specifiers in static/dynamic imports, exports, and require calls. +const RELATIVE_IMPORT_RE = + /(?:from|import|require)\s*\(?['"`](\.\.?\/[^'"`\s]+)['"`]/g; + +async function resolveSpecifier( + specifier: string, + fromDir: string, +): Promise { + const base = resolve(fromDir, specifier); + const ext = extname(base); + + if (ext) { + // TypeScript projects import .js but ship .ts — try the swap first. + if (ext === ".js") { + const asTts = `${base.slice(0, -3)}.ts`; + if (await pathExists(asTts)) return asTts; + } + if (await pathExists(base)) return base; + return null; + } + + // No extension — try common TypeScript/JS extensions. + for (const candidate of [ + `${base}.ts`, + `${base}.js`, + `${base}.json`, + resolve(base, "index.ts"), + resolve(base, "index.js"), + ]) { + if (await pathExists(candidate)) return candidate; + } + + return null; +} + +/** + * Walk the import graph starting from `functionFilePaths` and return any + * additional files reachable via relative imports that live inside + * `backendRoot` but outside the original function directory. + */ +export async function collectReachableBackendFiles( + _entryPath: string, + functionFilePaths: string[], + backendRoot: string, +): Promise { + const visited = new Set(functionFilePaths); + const queue: string[] = [...functionFilePaths]; + const extra: string[] = []; + + while (queue.length > 0) { + const filePath = queue.shift()!; + + let content: string; + try { + content = await readTextFile(filePath); + } catch { + continue; + } + + const dir = dirname(filePath); + + for (const match of content.matchAll(RELATIVE_IMPORT_RE)) { + const specifier = match[1]; + + const resolved = await resolveSpecifier(specifier, dir); + if (!resolved) continue; + + // Only follow imports that stay inside backendRoot. + const rel = relative(backendRoot, resolved); + if (rel.startsWith("..")) continue; + + if (!visited.has(resolved)) { + visited.add(resolved); + queue.push(resolved); + extra.push(resolved); + } + } + } + + const functionSet = new Set(functionFilePaths); + return extra.filter((p) => !functionSet.has(p)); +} diff --git a/packages/cli/tests/core/function-config.spec.ts b/packages/cli/tests/core/function-config.spec.ts index 6a06ae2e..8d96ae1b 100644 --- a/packages/cli/tests/core/function-config.spec.ts +++ b/packages/cli/tests/core/function-config.spec.ts @@ -96,4 +96,45 @@ describe("readAllFunctions", () => { /Duplicate function name "same-name"/, ); }); + + it("includes shared files reachable via parent-escape relative imports", async () => { + const functionsDir = resolve( + FIXTURES_DIR, + "function-shared-imports/base44/functions", + ); + const result = await readAllFunctions(functionsDir); + + expect(result).toHaveLength(2); + + for (const fn of result) { + const hasShared = fn.filePaths.some((p) => + fwd(p).endsWith("shared/response.ts"), + ); + expect(hasShared, `${fn.name} should include shared/response.ts`).toBe( + true, + ); + } + }); + + it("shared file path stays outside functions dir (correct relative path for deploy)", async () => { + const functionsDir = resolve( + FIXTURES_DIR, + "function-shared-imports/base44/functions", + ); + const result = await readAllFunctions(functionsDir); + const greet = result.find((f) => f.name === "greet"); + expect(greet).toBeDefined(); + + const sharedPath = greet!.filePaths.find((p) => + fwd(p).endsWith("shared/response.ts"), + ); + expect(sharedPath).toBeDefined(); + + // deploy.ts uses relative(functionDir, filePath) — verify the expected relative path + const { dirname: pathDirname, relative: pathRelative } = await import( + "node:path" + ); + const rel = fwd(pathRelative(pathDirname(greet!.entryPath), sharedPath!)); + expect(rel).toBe("../../shared/response.ts"); + }); }); diff --git a/packages/cli/tests/fixtures/function-shared-imports/base44/functions/farewell/entry.ts b/packages/cli/tests/fixtures/function-shared-imports/base44/functions/farewell/entry.ts new file mode 100644 index 00000000..c0b8c020 --- /dev/null +++ b/packages/cli/tests/fixtures/function-shared-imports/base44/functions/farewell/entry.ts @@ -0,0 +1,6 @@ +import { ok } from "../../shared/response.ts"; + +Deno.serve(async (req: Request) => { + const { name } = await req.json(); + return ok(`Goodbye, ${name}!`); +}); diff --git a/packages/cli/tests/fixtures/function-shared-imports/base44/functions/greet/entry.ts b/packages/cli/tests/fixtures/function-shared-imports/base44/functions/greet/entry.ts new file mode 100644 index 00000000..10099e17 --- /dev/null +++ b/packages/cli/tests/fixtures/function-shared-imports/base44/functions/greet/entry.ts @@ -0,0 +1,6 @@ +import { ok } from "../../shared/response.ts"; + +Deno.serve(async (req: Request) => { + const { name } = await req.json(); + return ok(`Hello, ${name}!`); +}); diff --git a/packages/cli/tests/fixtures/function-shared-imports/base44/shared/response.ts b/packages/cli/tests/fixtures/function-shared-imports/base44/shared/response.ts new file mode 100644 index 00000000..9061f562 --- /dev/null +++ b/packages/cli/tests/fixtures/function-shared-imports/base44/shared/response.ts @@ -0,0 +1,2 @@ +export const ok = (data: unknown) => new Response(JSON.stringify({ ok: true, data })); +export const err = (msg: string) => new Response(JSON.stringify({ ok: false, error: msg }), { status: 500 });