From 48b795ccabd9c746d645ce6e8b1678bdb0af1ecd Mon Sep 17 00:00:00 2001 From: Guy Ofeck Date: Thu, 9 Jul 2026 17:19:21 +0300 Subject: [PATCH 1/5] fix(functions): include shared modules reachable via parent-escape imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Functions that import shared utilities via ../../shared/... were only uploading files inside their own subfolder; the server bundler would reject the missing imports at deploy time. Walk the import graph from each function's file set and collect any additional files inside base44/ (backendRoot) that are reachable via relative specifiers but outside the function directory. The new files are appended to filePaths before deploy — loadFunctionCode's existing relative(functionDir, filePath) then produces the correct ../../shared/… paths in the payload. Closes #562 Co-Authored-By: Claude Sonnet 4.6 --- .../cli/src/core/resources/function/config.ts | 30 +++++-- .../core/resources/function/reachability.ts | 85 +++++++++++++++++++ .../cli/tests/core/function-config.spec.ts | 41 +++++++++ .../base44/functions/farewell/entry.ts | 6 ++ .../base44/functions/greet/entry.ts | 6 ++ .../base44/shared/response.ts | 2 + 6 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/core/resources/function/reachability.ts create mode 100644 packages/cli/tests/fixtures/function-shared-imports/base44/functions/farewell/entry.ts create mode 100644 packages/cli/tests/fixtures/function-shared-imports/base44/functions/greet/entry.ts create mode 100644 packages/cli/tests/fixtures/function-shared-imports/base44/shared/response.ts 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 }); From 5f5b5514e205ae001980e266da4d53638c0c4571 Mon Sep 17 00:00:00 2001 From: Guy Ofeck Date: Sun, 12 Jul 2026 11:09:10 +0300 Subject: [PATCH 2/5] fix(functions): block deploy when imports escape base44/ with clear error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reachability.ts: track out-of-bounds imports (relative specifiers that resolve to a real file but escape past backendRoot) and return them alongside the extra files in a structured ReachabilityResult - schema.ts: add outOfBoundsImports field to BackendFunctionSchema - config.ts: populate outOfBoundsImports from reachability results - deploy command: error and exit before uploading if any function has out-of-bounds imports — error message mirrors the apper bundler's own "Relative imports can't reach outside the function" language - tests: add fixture (outside/secret.ts) and two new cases verifying the file is excluded from filePaths and that the specifier is reported Co-Authored-By: Claude Sonnet 4.6 --- .../cli/src/cli/commands/functions/deploy.ts | 14 ++++++++ .../cli/src/core/resources/function/config.ts | 11 +++--- .../core/resources/function/reachability.ts | 28 ++++++++++++--- .../cli/src/core/resources/function/schema.ts | 6 ++++ .../cli/tests/core/function-config.spec.ts | 35 +++++++++++++++++++ .../base44/functions/greet/entry.ts | 3 +- .../function-shared-imports/outside/secret.ts | 1 + 7 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 packages/cli/tests/fixtures/function-shared-imports/outside/secret.ts diff --git a/packages/cli/src/cli/commands/functions/deploy.ts b/packages/cli/src/cli/commands/functions/deploy.ts index e282e663..e720fc98 100644 --- a/packages/cli/src/cli/commands/functions/deploy.ts +++ b/packages/cli/src/cli/commands/functions/deploy.ts @@ -82,6 +82,20 @@ async function deployFunctionsAction( `Found ${toDeploy.length} ${toDeploy.length === 1 ? "function" : "functions"} to deploy`, ); + for (const fn of toDeploy) { + if (fn.outOfBoundsImports && fn.outOfBoundsImports.length > 0) { + for (const { importer, specifier } of fn.outOfBoundsImports) { + const rel = importer.includes(fn.name) + ? importer.slice(importer.lastIndexOf(fn.name)) + : importer; + log.error( + `[${fn.name}] Cannot import "${specifier}" in ${rel}: the file is outside base44/ and cannot be uploaded. Move it to base44/shared/ to share it across functions, or use an npm:/jsr: specifier for external packages.`, + ); + } + throw new CLIExitError(1); + } + } + let completed = 0; const total = toDeploy.length; diff --git a/packages/cli/src/core/resources/function/config.ts b/packages/cli/src/core/resources/function/config.ts index e0b52be2..d5cfca09 100644 --- a/packages/cli/src/core/resources/function/config.ts +++ b/packages/cli/src/core/resources/function/config.ts @@ -10,6 +10,7 @@ import { InvalidInputError, SchemaValidationError, } from "@/core/errors.js"; +import type { OutOfBoundsImport } from "@/core/resources/function/reachability.js"; import { collectReachableBackendFiles } from "@/core/resources/function/reachability.js"; import type { BackendFunction, @@ -55,17 +56,18 @@ async function readFunction( absolute: true, }); - const extraFiles = await collectReachableBackendFiles( + const { extra, outOfBounds } = await collectReachableBackendFiles( entryPath, filePaths, backendRoot, ); - const allFilePaths = [...new Set([...filePaths, ...extraFiles])]; + const allFilePaths = [...new Set([...filePaths, ...extra])]; const functionData: BackendFunction = { ...config, entryPath, filePaths: allFilePaths, + outOfBoundsImports: outOfBounds, source: { type: "project" }, }; return functionData; @@ -109,12 +111,12 @@ export async function readAllFunctions( absolute: true, }); - const extraFiles = await collectReachableBackendFiles( + const { extra, outOfBounds } = await collectReachableBackendFiles( entryFile, filePaths, backendRoot, ); - const allFilePaths = [...new Set([...filePaths, ...extraFiles])]; + const allFilePaths = [...new Set([...filePaths, ...extra])]; const name = relative(functionsDir, functionDir).split(/[/\\]/).join("/"); if (!name) { @@ -136,6 +138,7 @@ export async function readAllFunctions( entry, entryPath: entryFile, filePaths: allFilePaths, + outOfBoundsImports: outOfBounds, 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 index cbe3ced9..5f6a7ed7 100644 --- a/packages/cli/src/core/resources/function/reachability.ts +++ b/packages/cli/src/core/resources/function/reachability.ts @@ -36,19 +36,34 @@ async function resolveSpecifier( return null; } +export interface OutOfBoundsImport { + importer: string; + specifier: string; +} + +export interface ReachabilityResult { + extra: string[]; + outOfBounds: OutOfBoundsImport[]; +} + /** * 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. + * + * Also returns out-of-bounds imports — relative specifiers that resolved to a + * real file but escaped past `backendRoot`. These will be missing at bundle + * time and will cause a runtime error; callers should warn the user. */ export async function collectReachableBackendFiles( _entryPath: string, functionFilePaths: string[], backendRoot: string, -): Promise { +): Promise { const visited = new Set(functionFilePaths); const queue: string[] = [...functionFilePaths]; const extra: string[] = []; + const outOfBounds: OutOfBoundsImport[] = []; while (queue.length > 0) { const filePath = queue.shift()!; @@ -68,9 +83,11 @@ export async function collectReachableBackendFiles( 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 (rel.startsWith("..")) { + outOfBounds.push({ importer: filePath, specifier }); + continue; + } if (!visited.has(resolved)) { visited.add(resolved); @@ -81,5 +98,8 @@ export async function collectReachableBackendFiles( } const functionSet = new Set(functionFilePaths); - return extra.filter((p) => !functionSet.has(p)); + return { + extra: extra.filter((p) => !functionSet.has(p)), + outOfBounds, + }; } diff --git a/packages/cli/src/core/resources/function/schema.ts b/packages/cli/src/core/resources/function/schema.ts index 5a175742..678e97b8 100644 --- a/packages/cli/src/core/resources/function/schema.ts +++ b/packages/cli/src/core/resources/function/schema.ts @@ -144,9 +144,15 @@ export const FunctionConfigSchema = z.object({ automations: z.array(AutomationSchema).optional(), }); +const OutOfBoundsImportSchema = z.object({ + importer: z.string(), + specifier: z.string(), +}); + const BackendFunctionSchema = FunctionConfigSchema.extend({ entryPath: z.string().min(1, "Entry path cannot be empty"), filePaths: z.array(z.string()).min(1, "Function must have at least one file"), + outOfBoundsImports: z.array(OutOfBoundsImportSchema).optional().default([]), source: ResourceSourceSchema, }); diff --git a/packages/cli/tests/core/function-config.spec.ts b/packages/cli/tests/core/function-config.spec.ts index 8d96ae1b..bf797aec 100644 --- a/packages/cli/tests/core/function-config.spec.ts +++ b/packages/cli/tests/core/function-config.spec.ts @@ -137,4 +137,39 @@ describe("readAllFunctions", () => { const rel = fwd(pathRelative(pathDirname(greet!.entryPath), sharedPath!)); expect(rel).toBe("../../shared/response.ts"); }); + + it("does not include files outside base44/ even when imported", 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(); + + // greet/entry.ts imports from ../../../outside/secret.ts which is outside base44/ + const hasOutside = greet!.filePaths.some((p) => + fwd(p).includes("outside/secret.ts"), + ); + expect(hasOutside, "files outside base44/ must not be collected").toBe( + false, + ); + }); + + it("reports out-of-bounds imports so the CLI can block deployment", 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(); + + // greet/entry.ts imports ../../../outside/secret.ts which escapes base44/ + const oob = greet!.outOfBoundsImports ?? []; + expect(oob.length).toBeGreaterThan(0); + const match = oob.find((o) => o.specifier.includes("outside/secret.ts")); + expect(match, "should report the out-of-bounds specifier").toBeDefined(); + expect(fwd(match!.importer)).toContain("greet/entry.ts"); + }); }); 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 index 10099e17..0c04bfba 100644 --- 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 @@ -1,6 +1,7 @@ import { ok } from "../../shared/response.ts"; +import { SECRET } from "../../../outside/secret.ts"; Deno.serve(async (req: Request) => { const { name } = await req.json(); - return ok(`Hello, ${name}!`); + return ok(`Hello, ${name}! ${SECRET}`); }); diff --git a/packages/cli/tests/fixtures/function-shared-imports/outside/secret.ts b/packages/cli/tests/fixtures/function-shared-imports/outside/secret.ts new file mode 100644 index 00000000..285e95a8 --- /dev/null +++ b/packages/cli/tests/fixtures/function-shared-imports/outside/secret.ts @@ -0,0 +1 @@ +export const SECRET = "should-never-be-uploaded"; From 15337a2ed2d935d00f119fc988d08469ac576120 Mon Sep 17 00:00:00 2001 From: Guy Ofeck Date: Sun, 12 Jul 2026 11:11:05 +0300 Subject: [PATCH 3/5] fix(functions): clarify Deno runtime in out-of-bounds import error message Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/cli/commands/functions/deploy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/cli/commands/functions/deploy.ts b/packages/cli/src/cli/commands/functions/deploy.ts index e720fc98..e0c4b198 100644 --- a/packages/cli/src/cli/commands/functions/deploy.ts +++ b/packages/cli/src/cli/commands/functions/deploy.ts @@ -89,7 +89,7 @@ async function deployFunctionsAction( ? importer.slice(importer.lastIndexOf(fn.name)) : importer; log.error( - `[${fn.name}] Cannot import "${specifier}" in ${rel}: the file is outside base44/ and cannot be uploaded. Move it to base44/shared/ to share it across functions, or use an npm:/jsr: specifier for external packages.`, + `[${fn.name}] Cannot import "${specifier}" in ${rel}: the file is outside base44/ and cannot be uploaded. Move it to base44/shared/ to share it across functions. Functions run on Deno — use npm: or jsr: specifiers (not package.json) for external packages.`, ); } throw new CLIExitError(1); From 80c87f65836008ec85bbb4a4de4af93a79d29ba3 Mon Sep 17 00:00:00 2001 From: Guy Ofeck Date: Sun, 12 Jul 2026 11:28:03 +0300 Subject: [PATCH 4/5] test(functions): cover sibling imports and transitive shared reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add hello-sibling fixture (entry.ts → ./util.ts → ../../shared/response.ts) and two assertions: sibling util.ts is included via glob, and shared/response.ts is reachable transitively through the sibling. Co-Authored-By: Claude Sonnet 4.6 --- .../cli/tests/core/function-config.spec.ts | 20 ++++++++++++++++++- .../base44/functions/hello-sibling/entry.ts | 6 ++++++ .../base44/functions/hello-sibling/util.ts | 5 +++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 packages/cli/tests/fixtures/function-shared-imports/base44/functions/hello-sibling/entry.ts create mode 100644 packages/cli/tests/fixtures/function-shared-imports/base44/functions/hello-sibling/util.ts diff --git a/packages/cli/tests/core/function-config.spec.ts b/packages/cli/tests/core/function-config.spec.ts index bf797aec..f43d4d83 100644 --- a/packages/cli/tests/core/function-config.spec.ts +++ b/packages/cli/tests/core/function-config.spec.ts @@ -104,7 +104,7 @@ describe("readAllFunctions", () => { ); const result = await readAllFunctions(functionsDir); - expect(result).toHaveLength(2); + expect(result).toHaveLength(3); // greet, farewell, hello-sibling for (const fn of result) { const hasShared = fn.filePaths.some((p) => @@ -116,6 +116,24 @@ describe("readAllFunctions", () => { } }); + it("includes sibling files and transitively reaches shared through them", async () => { + const functionsDir = resolve( + FIXTURES_DIR, + "function-shared-imports/base44/functions", + ); + const result = await readAllFunctions(functionsDir); + const fn = result.find((f) => f.name === "hello-sibling"); + expect(fn).toBeDefined(); + + // util.ts is a same-dir sibling picked up by the glob + const hasUtil = fn!.filePaths.some((p) => fwd(p).endsWith("hello-sibling/util.ts")); + expect(hasUtil, "sibling util.ts should be included").toBe(true); + + // shared/response.ts is reachable transitively through util.ts + const hasShared = fn!.filePaths.some((p) => fwd(p).endsWith("shared/response.ts")); + expect(hasShared, "shared/response.ts should be reachable via sibling").toBe(true); + }); + it("shared file path stays outside functions dir (correct relative path for deploy)", async () => { const functionsDir = resolve( FIXTURES_DIR, diff --git a/packages/cli/tests/fixtures/function-shared-imports/base44/functions/hello-sibling/entry.ts b/packages/cli/tests/fixtures/function-shared-imports/base44/functions/hello-sibling/entry.ts new file mode 100644 index 00000000..4dd59fb8 --- /dev/null +++ b/packages/cli/tests/fixtures/function-shared-imports/base44/functions/hello-sibling/entry.ts @@ -0,0 +1,6 @@ +import { greet } from "./util.ts"; + +Deno.serve(async (req: Request) => { + const { name } = await req.json(); + return Response.json({ message: greet(name) }); +}); diff --git a/packages/cli/tests/fixtures/function-shared-imports/base44/functions/hello-sibling/util.ts b/packages/cli/tests/fixtures/function-shared-imports/base44/functions/hello-sibling/util.ts new file mode 100644 index 00000000..6c89b53e --- /dev/null +++ b/packages/cli/tests/fixtures/function-shared-imports/base44/functions/hello-sibling/util.ts @@ -0,0 +1,5 @@ +import { ok } from "../../shared/response.ts"; + +export function greet(name: string): Response { + return ok(`Hello, ${name}!`); +} From 3b495663e76a2255423f72ff14b28c61c5057f88 Mon Sep 17 00:00:00 2001 From: Guy Ofeck Date: Sun, 12 Jul 2026 11:43:21 +0300 Subject: [PATCH 5/5] test(functions): verify .js-extension imports resolve to .ts files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add farewell-js-ext fixture that imports "../../shared/response.js" and assert response.ts is found — covering the TS project convention where .js specifiers reference .ts source files. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/tests/core/function-config.spec.ts | 16 +++++++++++++++- .../base44/functions/farewell-js-ext/entry.ts | 6 ++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 packages/cli/tests/fixtures/function-shared-imports/base44/functions/farewell-js-ext/entry.ts diff --git a/packages/cli/tests/core/function-config.spec.ts b/packages/cli/tests/core/function-config.spec.ts index f43d4d83..c34f0aeb 100644 --- a/packages/cli/tests/core/function-config.spec.ts +++ b/packages/cli/tests/core/function-config.spec.ts @@ -104,7 +104,7 @@ describe("readAllFunctions", () => { ); const result = await readAllFunctions(functionsDir); - expect(result).toHaveLength(3); // greet, farewell, hello-sibling + expect(result).toHaveLength(4); // greet, farewell, hello-sibling, farewell-js-ext for (const fn of result) { const hasShared = fn.filePaths.some((p) => @@ -116,6 +116,20 @@ describe("readAllFunctions", () => { } }); + it("resolves .js-extension imports to .ts files (TS project convention)", async () => { + const functionsDir = resolve( + FIXTURES_DIR, + "function-shared-imports/base44/functions", + ); + const result = await readAllFunctions(functionsDir); + const fn = result.find((f) => f.name === "farewell-js-ext"); + expect(fn).toBeDefined(); + + // entry.ts imports "../../shared/response.js" — should resolve to response.ts + const hasShared = fn!.filePaths.some((p) => fwd(p).endsWith("shared/response.ts")); + expect(hasShared, "shared/response.ts should be found via .js-extension import").toBe(true); + }); + it("includes sibling files and transitively reaches shared through them", async () => { const functionsDir = resolve( FIXTURES_DIR, diff --git a/packages/cli/tests/fixtures/function-shared-imports/base44/functions/farewell-js-ext/entry.ts b/packages/cli/tests/fixtures/function-shared-imports/base44/functions/farewell-js-ext/entry.ts new file mode 100644 index 00000000..bff63b8c --- /dev/null +++ b/packages/cli/tests/fixtures/function-shared-imports/base44/functions/farewell-js-ext/entry.ts @@ -0,0 +1,6 @@ +import { ok } from "../../shared/response.js"; + +Deno.serve(async (req: Request) => { + const { name } = await req.json(); + return ok(`Farewell, ${name}!`); +});