Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions packages/cli/src/core/resources/function/config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,6 +10,7 @@ import {
InvalidInputError,
SchemaValidationError,
} from "@/core/errors.js";
import { collectReachableBackendFiles } from "@/core/resources/function/reachability.js";
import type {
BackendFunction,
FunctionConfig,
Expand All @@ -32,7 +33,10 @@ async function readFunctionConfig(configPath: string): Promise<FunctionConfig> {
return result.data;
}

async function readFunction(configPath: string): Promise<BackendFunction> {
async function readFunction(
configPath: string,
backendRoot: string,
): Promise<BackendFunction> {
const config = await readFunctionConfig(configPath);
const functionDir = dirname(configPath);
const entryPath = join(functionDir, config.entry);
Expand All @@ -51,10 +55,17 @@ async function readFunction(configPath: string): Promise<BackendFunction> {
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;
Expand Down Expand Up @@ -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(
Expand 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(
Expand All @@ -115,7 +135,7 @@ export async function readAllFunctions(
name,
entry,
entryPath: entryFile,
filePaths,
filePaths: allFilePaths,
source: { type: "project" },
};
return functionData;
Expand Down
85 changes: 85 additions & 0 deletions packages/cli/src/core/resources/function/reachability.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<string[]> {
const visited = new Set<string>(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));
}
41 changes: 41 additions & 0 deletions packages/cli/tests/core/function-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ok } from "../../shared/response.ts";

Deno.serve(async (req: Request) => {
const { name } = await req.json();
return ok(`Goodbye, ${name}!`);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ok } from "../../shared/response.ts";

Deno.serve(async (req: Request) => {
const { name } = await req.json();
return ok(`Hello, ${name}!`);
});
Original file line number Diff line number Diff line change
@@ -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 });
Loading