From 2f564db18874ee11bce10926fe43e88d2f0f26da Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 27 May 2025 20:45:56 -0700 Subject: [PATCH 1/6] WIP --- src/deploy/functions/runtimes/index.ts | 2 + src/deploy/functions/runtimes/node/index.ts | 71 ++++++++++++++++++++- src/emulator/functionsEmulator.ts | 39 ++++++++--- src/emulator/functionsEmulatorRuntime.ts | 13 +++- 4 files changed, 112 insertions(+), 13 deletions(-) diff --git a/src/deploy/functions/runtimes/index.ts b/src/deploy/functions/runtimes/index.ts index cb3aa66d1ab..207495d969e 100644 --- a/src/deploy/functions/runtimes/index.ts +++ b/src/deploy/functions/runtimes/index.ts @@ -67,6 +67,8 @@ export interface DelegateContext { // Absolute path of the source directory. sourceDir: string; runtime?: supported.Runtime; + // Whether this delegate is being created for the emulator + isEmulator?: boolean; } type Factory = (context: DelegateContext) => Promise; diff --git a/src/deploy/functions/runtimes/node/index.ts b/src/deploy/functions/runtimes/node/index.ts index 2649e59821e..9802164d95d 100644 --- a/src/deploy/functions/runtimes/node/index.ts +++ b/src/deploy/functions/runtimes/node/index.ts @@ -25,6 +25,37 @@ import * as versioning from "./versioning"; import * as parseTriggers from "./parseTriggers"; import { fileExistsSync } from "../../../../fsutils"; +/** + * Get the TypeScript source entry point for a functions directory. + * Used by the emulator to load TypeScript directly with tsx. + */ +export function getTypeScriptEntryPoint(functionsDir: string): string | null { + // Check if this is a TypeScript project + const tsconfigPath = path.join(functionsDir, "tsconfig.json"); + if (!fileExistsSync(tsconfigPath)) { + return null; + } + + try { + // Resolve the actual main file path + const mainPath = require.resolve(functionsDir); + // Transform compiled JS path to TypeScript source + // e.g., /path/to/project/lib/index.js -> /path/to/project/src/index.ts + const tsSourcePath = mainPath + .replace(/\/lib\//, '/src/') + .replace(/\.js$/, '.ts'); + + // Check if TypeScript source exists + if (fileExistsSync(tsSourcePath)) { + return tsSourcePath; + } + } catch (e) { + // Failed to resolve, return null + } + + return null; +} + // The versions of the Firebase Functions SDK that added support for the container contract. const MIN_FUNCTIONS_SDK_VERSION = "3.20.0"; @@ -54,7 +85,7 @@ export async function tryCreateDelegate(context: DelegateContext): Promise 1 && typeof this.args.debugPort === "number") { throw new FirebaseError( "Cannot debug on a single port with multiple codebases. " + - "Use --inspect-functions=true to assign dynamic ports to each codebase", + "Use --inspect-functions=true to assign dynamic ports to each codebase", ); } this.args.disabledRuntimeFeatures = this.args.disabledRuntimeFeatures || {}; @@ -529,6 +530,7 @@ export class FunctionsEmulator implements EmulatorInstance { projectDir: this.args.projectDir, sourceDir: emulatableBackend.functionsDir, runtime: emulatableBackend.runtime, + isEmulator: true, }; const runtimeDelegate = await runtimes.getRuntimeDelegate(runtimeDelegateContext); logger.debug(`Validating ${runtimeDelegate.language} source`); @@ -1522,9 +1524,9 @@ export class FunctionsEmulator implements EmulatorInstance { "ERROR", "functions", "Unable to access secret environment variables from Google Cloud Secret Manager. " + - "Make sure the credential used for the Functions Emulator have access " + - `or provide override values in ${secretPath}:\n\t` + - errs.join("\n\t"), + "Make sure the credential used for the Functions Emulator have access " + + `or provide override values in ${secretPath}:\n\t` + + errs.join("\n\t"), ); } @@ -1566,7 +1568,7 @@ export class FunctionsEmulator implements EmulatorInstance { "SUCCESS", "functions", `Using debug port ${port} for functions codebase ${backend.codebase}. ` + - "You may need to add manually add this port to your inspector.", + "You may need to add manually add this port to your inspector.", ); } } @@ -1586,8 +1588,8 @@ export class FunctionsEmulator implements EmulatorInstance { "WARN_ONCE", "functions", "Detected yarn@2 with PnP. " + - "Cloud Functions for Firebase requires a node_modules folder to work correctly and is therefore incompatible with PnP. " + - "See https://yarnpkg.com/getting-started/migration#step-by-step for more information.", + "Cloud Functions for Firebase requires a node_modules folder to work correctly and is therefore incompatible with PnP. " + + "See https://yarnpkg.com/getting-started/migration#step-by-step for more information.", ); } @@ -1595,11 +1597,26 @@ export class FunctionsEmulator implements EmulatorInstance { if (!bin) { throw new Error( `No binary associated with ${backend.functionsDir}. ` + - "Make sure function runtime is configured correctly in firebase.json.", + "Make sure function runtime is configured correctly in firebase.json.", ); } const socketPath = getTemporarySocketPath(); + + // Check if we're using tsx and get TypeScript entry point + let functionsEntryPoint: string | undefined; + if (backend.bin && backend.bin.includes("tsx")) { + const tsEntryPoint = getTypeScriptEntryPoint(backend.functionsDir); + if (tsEntryPoint) { + functionsEntryPoint = tsEntryPoint; + this.logger.logLabeled( + "DEBUG", + "functions", + `Using TypeScript source from ${tsEntryPoint}`, + ); + } + } + const childProcess = spawn(bin, args, { cwd: backend.functionsDir, env: { @@ -1608,6 +1625,8 @@ export class FunctionsEmulator implements EmulatorInstance { ...process.env, ...envs, PORT: socketPath, + // Pass the resolved entry point if we found one + ...(functionsEntryPoint ? { FUNCTIONS_ENTRY_POINT: functionsEntryPoint } : {}), }, stdio: ["pipe", "pipe", "pipe", "ipc"], }); diff --git a/src/emulator/functionsEmulatorRuntime.ts b/src/emulator/functionsEmulatorRuntime.ts index 8720c3b08ab..0575c1c1f3e 100644 --- a/src/emulator/functionsEmulatorRuntime.ts +++ b/src/emulator/functionsEmulatorRuntime.ts @@ -906,15 +906,24 @@ async function initializeRuntime(): Promise { async function loadTriggers(): Promise { let triggerModule; + + // Check if we have an override entry point (e.g., for TypeScript with tsx) + const entryPoint = process.env.FUNCTIONS_ENTRY_POINT; + try { - triggerModule = require(process.cwd()); + if (entryPoint) { + logDebug(`Loading functions from specified entry point: ${entryPoint}`); + triggerModule = require(entryPoint); + } else { + triggerModule = require(process.cwd()); + } } catch (err: any) { if (err.code !== "ERR_REQUIRE_ESM") { // Try to run diagnostics to see what could've gone wrong before rethrowing the error. await moduleResolutionDetective(err); throw err; } - const modulePath = require.resolve(process.cwd()); + const modulePath = entryPoint || require.resolve(process.cwd()); // Resolve module path to file:// URL. Required for windows support. const moduleURL = pathToFileURL(modulePath).href; triggerModule = await dynamicImport(moduleURL); From 4e56d7896abc476a00942972275befcf353d9e3c Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 28 May 2025 17:15:20 -0700 Subject: [PATCH 2/6] Use FUNCTION_SOURCE instead for functions-framework compatibility --- src/deploy/functions/runtimes/node/index.ts | 22 ++++++------ src/emulator/functionsEmulator.ts | 38 +++++++++------------ src/emulator/functionsEmulatorRuntime.ts | 6 ++-- 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/src/deploy/functions/runtimes/node/index.ts b/src/deploy/functions/runtimes/node/index.ts index 9802164d95d..11326d422ed 100644 --- a/src/deploy/functions/runtimes/node/index.ts +++ b/src/deploy/functions/runtimes/node/index.ts @@ -41,10 +41,8 @@ export function getTypeScriptEntryPoint(functionsDir: string): string | null { const mainPath = require.resolve(functionsDir); // Transform compiled JS path to TypeScript source // e.g., /path/to/project/lib/index.js -> /path/to/project/src/index.ts - const tsSourcePath = mainPath - .replace(/\/lib\//, '/src/') - .replace(/\.js$/, '.ts'); - + const tsSourcePath = mainPath.replace(/\/lib\//, "/src/").replace(/\.js$/, ".ts"); + // Check if TypeScript source exists if (fileExistsSync(tsSourcePath)) { return tsSourcePath; @@ -52,7 +50,7 @@ export function getTypeScriptEntryPoint(functionsDir: string): string | null { } catch (e) { // Failed to resolve, return null } - + return null; } @@ -85,7 +83,13 @@ export async function tryCreateDelegate(context: DelegateContext): Promise 1 && typeof this.args.debugPort === "number") { throw new FirebaseError( "Cannot debug on a single port with multiple codebases. " + - "Use --inspect-functions=true to assign dynamic ports to each codebase", + "Use --inspect-functions=true to assign dynamic ports to each codebase", ); } this.args.disabledRuntimeFeatures = this.args.disabledRuntimeFeatures || {}; @@ -1524,9 +1524,9 @@ export class FunctionsEmulator implements EmulatorInstance { "ERROR", "functions", "Unable to access secret environment variables from Google Cloud Secret Manager. " + - "Make sure the credential used for the Functions Emulator have access " + - `or provide override values in ${secretPath}:\n\t` + - errs.join("\n\t"), + "Make sure the credential used for the Functions Emulator have access " + + `or provide override values in ${secretPath}:\n\t` + + errs.join("\n\t"), ); } @@ -1568,7 +1568,7 @@ export class FunctionsEmulator implements EmulatorInstance { "SUCCESS", "functions", `Using debug port ${port} for functions codebase ${backend.codebase}. ` + - "You may need to add manually add this port to your inspector.", + "You may need to add manually add this port to your inspector.", ); } } @@ -1588,8 +1588,8 @@ export class FunctionsEmulator implements EmulatorInstance { "WARN_ONCE", "functions", "Detected yarn@2 with PnP. " + - "Cloud Functions for Firebase requires a node_modules folder to work correctly and is therefore incompatible with PnP. " + - "See https://yarnpkg.com/getting-started/migration#step-by-step for more information.", + "Cloud Functions for Firebase requires a node_modules folder to work correctly and is therefore incompatible with PnP. " + + "See https://yarnpkg.com/getting-started/migration#step-by-step for more information.", ); } @@ -1597,26 +1597,22 @@ export class FunctionsEmulator implements EmulatorInstance { if (!bin) { throw new Error( `No binary associated with ${backend.functionsDir}. ` + - "Make sure function runtime is configured correctly in firebase.json.", + "Make sure function runtime is configured correctly in firebase.json.", ); } const socketPath = getTemporarySocketPath(); - + // Check if we're using tsx and get TypeScript entry point - let functionsEntryPoint: string | undefined; + let overrideFunctionSource: string | undefined; if (backend.bin && backend.bin.includes("tsx")) { const tsEntryPoint = getTypeScriptEntryPoint(backend.functionsDir); if (tsEntryPoint) { - functionsEntryPoint = tsEntryPoint; - this.logger.logLabeled( - "DEBUG", - "functions", - `Using TypeScript source from ${tsEntryPoint}`, - ); + overrideFunctionSource = tsEntryPoint; + this.logger.logLabeled("DEBUG", "functions", `Using TypeScript source in ${tsEntryPoint}`); } } - + const childProcess = spawn(bin, args, { cwd: backend.functionsDir, env: { @@ -1625,8 +1621,8 @@ export class FunctionsEmulator implements EmulatorInstance { ...process.env, ...envs, PORT: socketPath, - // Pass the resolved entry point if we found one - ...(functionsEntryPoint ? { FUNCTIONS_ENTRY_POINT: functionsEntryPoint } : {}), + // Overried the entry point we have any + ...(overrideFunctionSource ? { FUNCTIONS_SOURCE: overrideFunctionSource } : {}), }, stdio: ["pipe", "pipe", "pipe", "ipc"], }); diff --git a/src/emulator/functionsEmulatorRuntime.ts b/src/emulator/functionsEmulatorRuntime.ts index 0575c1c1f3e..a6a2a4fc1ab 100644 --- a/src/emulator/functionsEmulatorRuntime.ts +++ b/src/emulator/functionsEmulatorRuntime.ts @@ -906,10 +906,10 @@ async function initializeRuntime(): Promise { async function loadTriggers(): Promise { let triggerModule; - + // Check if we have an override entry point (e.g., for TypeScript with tsx) - const entryPoint = process.env.FUNCTIONS_ENTRY_POINT; - + const entryPoint = process.env.FUNCTIONS_SOURCE; + try { if (entryPoint) { logDebug(`Loading functions from specified entry point: ${entryPoint}`); From 9a5559f29e0cc2a0fd044346883d2c06597a95a1 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 28 May 2025 17:22:03 -0700 Subject: [PATCH 3/6] Add tsx to TS templates. --- templates/init/functions/typescript/package.lint.json | 3 ++- templates/init/functions/typescript/package.nolint.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/templates/init/functions/typescript/package.lint.json b/templates/init/functions/typescript/package.lint.json index c2d4f5493e6..57deecb2036 100644 --- a/templates/init/functions/typescript/package.lint.json +++ b/templates/init/functions/typescript/package.lint.json @@ -25,7 +25,8 @@ "eslint-config-google": "^0.14.0", "eslint-plugin-import": "^2.25.4", "firebase-functions-test": "^3.1.0", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "tsx": "^4.19.4" }, "private": true } diff --git a/templates/init/functions/typescript/package.nolint.json b/templates/init/functions/typescript/package.nolint.json index 066161c31cd..5ce934823b2 100644 --- a/templates/init/functions/typescript/package.nolint.json +++ b/templates/init/functions/typescript/package.nolint.json @@ -19,7 +19,8 @@ }, "devDependencies": { "typescript": "^5.7.3", - "firebase-functions-test": "^3.1.0" + "firebase-functions-test": "^3.1.0", + "tsx": "^4.19.4" }, "private": true } From 6bdc36c04c19b1dffe111485c0a46666d51149ce Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 28 May 2025 17:45:03 -0700 Subject: [PATCH 4/6] Add changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f0e1e973fc..30c6f1f9742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,3 +3,4 @@ - Fixed crash when starting the App Hosting emulator in certain applications (#8624) - Fixed issue where, with `webframeworks` enabled, `firebase init hosting` re-prompts users for source. (#8587) - Update typescript version in functions template to avoid build issue with @google-cloud/storage depedency (#8194) +- Added support for loading TypeScript functions using tsx in the Functions Emulator. (#8663) From 0bf32f610be2aa19d47652b4c59085b28e03664e Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 28 May 2025 19:21:44 -0700 Subject: [PATCH 5/6] Update src/emulator/functionsEmulator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/emulator/functionsEmulator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 455d4ce3d08..d7908c8bfa7 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -1621,7 +1621,7 @@ export class FunctionsEmulator implements EmulatorInstance { ...process.env, ...envs, PORT: socketPath, - // Overried the entry point we have any + // Override the entry point if we have any ...(overrideFunctionSource ? { FUNCTIONS_SOURCE: overrideFunctionSource } : {}), }, stdio: ["pipe", "pipe", "pipe", "ipc"], From bfe715021cb482aceeabb308bb5ef8239ad8df03 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Thu, 29 May 2025 10:38:11 -0700 Subject: [PATCH 6/6] Nits. --- src/deploy/functions/runtimes/node/index.ts | 11 ++++------- src/emulator/functionsEmulator.ts | 4 ++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/deploy/functions/runtimes/node/index.ts b/src/deploy/functions/runtimes/node/index.ts index 11326d422ed..8414a7451f0 100644 --- a/src/deploy/functions/runtimes/node/index.ts +++ b/src/deploy/functions/runtimes/node/index.ts @@ -30,25 +30,23 @@ import { fileExistsSync } from "../../../../fsutils"; * Used by the emulator to load TypeScript directly with tsx. */ export function getTypeScriptEntryPoint(functionsDir: string): string | null { - // Check if this is a TypeScript project const tsconfigPath = path.join(functionsDir, "tsconfig.json"); if (!fileExistsSync(tsconfigPath)) { return null; } try { - // Resolve the actual main file path const mainPath = require.resolve(functionsDir); // Transform compiled JS path to TypeScript source // e.g., /path/to/project/lib/index.js -> /path/to/project/src/index.ts const tsSourcePath = mainPath.replace(/\/lib\//, "/src/").replace(/\.js$/, ".ts"); - // Check if TypeScript source exists if (fileExistsSync(tsSourcePath)) { return tsSourcePath; } } catch (e) { - // Failed to resolve, return null + logger.debug("Failed to resolve TS entrypoint", e); + // Fail-safe and fallback to assuming JS codebase. } return null; @@ -133,13 +131,12 @@ export class Delegate { private getTsxPath(): string | null { try { - // Try to find tsx in the project's node_modules const tsxPath = require.resolve("tsx/cli", { paths: [this.sourceDir] }); return tsxPath; } catch (e) { - // tsx not found - return null; + // tsx not found. fail-safe } + return null; } getNodeBinary(): string { diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index d7908c8bfa7..7bb137983dc 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -54,7 +54,7 @@ import { functionIdsAreValid } from "../deploy/functions/validate"; import { Extension, ExtensionSpec, ExtensionVersion } from "../extensions/types"; import { accessSecretVersion } from "../gcp/secretManager"; import * as runtimes from "../deploy/functions/runtimes"; -import { getTypeScriptEntryPoint } from "../deploy/functions/runtimes/node"; +import * as node from "../deploy/functions/runtimes/node"; import * as backend from "../deploy/functions/backend"; import * as functionsEnv from "../functions/env"; import { AUTH_BLOCKING_EVENTS, BEFORE_CREATE_EVENT } from "../functions/events/v1"; @@ -1606,7 +1606,7 @@ export class FunctionsEmulator implements EmulatorInstance { // Check if we're using tsx and get TypeScript entry point let overrideFunctionSource: string | undefined; if (backend.bin && backend.bin.includes("tsx")) { - const tsEntryPoint = getTypeScriptEntryPoint(backend.functionsDir); + const tsEntryPoint = node.getTypeScriptEntryPoint(backend.functionsDir); if (tsEntryPoint) { overrideFunctionSource = tsEntryPoint; this.logger.logLabeled("DEBUG", "functions", `Using TypeScript source in ${tsEntryPoint}`);