Skip to content

Add support for loading TypeScript functions using tsx in the Functions Emulator #8663

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 2 additions & 0 deletions src/deploy/functions/runtimes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeDelegate | undefined>;
Expand Down
73 changes: 72 additions & 1 deletion src/deploy/functions/runtimes/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,35 @@ 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";

Expand Down Expand Up @@ -54,7 +83,13 @@ export async function tryCreateDelegate(context: DelegateContext): Promise<Deleg
throw new FirebaseError(`Unexpected runtime ${runtime}`);
}

return new Delegate(context.projectId, context.projectDir, context.sourceDir, runtime);
return new Delegate(
context.projectId,
context.projectDir,
context.sourceDir,
runtime,
context.isEmulator,
);
}

// TODO(inlined): Consider moving contents in parseRuntimeAndValidateSDK and validate around.
Expand All @@ -69,6 +104,7 @@ export class Delegate {
private readonly projectDir: string,
private readonly sourceDir: string,
public readonly runtime: supported.Runtime,
private readonly isEmulator: boolean = false,
) {}

// Using a caching interface because we (may/will) eventually depend on the SDK version
Expand All @@ -90,6 +126,22 @@ export class Delegate {
return this._bin;
}

private isTypeScriptProject(): boolean {
const tsconfigPath = path.join(this.sourceDir, "tsconfig.json");
return fileExistsSync(tsconfigPath);
}

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;
}
}

getNodeBinary(): string {
const requestedVersion = semver.coerce(this.runtime);
if (!requestedVersion) {
Expand All @@ -99,6 +151,25 @@ export class Delegate {
}
const hostVersion = process.versions.node;

// Check if this is a TypeScript project and tsx is available (only in emulator)
if (this.isEmulator && this.isTypeScriptProject()) {
const tsxPath = this.getTsxPath();
if (tsxPath) {
logLabeledSuccess(
"functions",
"TypeScript project detected. Using tsx for automatic TypeScript compilation.",
);
return tsxPath;
} else {
logLabeledWarning(
"functions",
"TypeScript project detected but tsx is not installed. " +
"Consider running 'npm install --save-dev tsx' for automatic TypeScript compilation. " +
"Falling back to standard node runtime.",
);
}
}

const localNodePath = path.join(this.sourceDir, "node_modules/node");
const localNodeVersion = versioning.findModuleVersion("node", localNodePath);

Expand Down
15 changes: 15 additions & 0 deletions src/emulator/functionsEmulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +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 backend from "../deploy/functions/backend";
import * as functionsEnv from "../functions/env";
import { AUTH_BLOCKING_EVENTS, BEFORE_CREATE_EVENT } from "../functions/events/v1";
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -1600,6 +1602,17 @@ export class FunctionsEmulator implements EmulatorInstance {
}

const socketPath = getTemporarySocketPath();

// 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);
if (tsEntryPoint) {
overrideFunctionSource = tsEntryPoint;
this.logger.logLabeled("DEBUG", "functions", `Using TypeScript source in ${tsEntryPoint}`);
}
}

const childProcess = spawn(bin, args, {
cwd: backend.functionsDir,
env: {
Expand All @@ -1608,6 +1621,8 @@ export class FunctionsEmulator implements EmulatorInstance {
...process.env,
...envs,
PORT: socketPath,
// Overried the entry point we have any
...(overrideFunctionSource ? { FUNCTIONS_SOURCE: overrideFunctionSource } : {}),
},
stdio: ["pipe", "pipe", "pipe", "ipc"],
});
Expand Down
13 changes: 11 additions & 2 deletions src/emulator/functionsEmulatorRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,15 +906,24 @@ async function initializeRuntime(): Promise<void> {

async function loadTriggers(): Promise<any> {
let triggerModule;

// Check if we have an override entry point (e.g., for TypeScript with tsx)
const entryPoint = process.env.FUNCTIONS_SOURCE;

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);
Expand Down
3 changes: 2 additions & 1 deletion templates/init/functions/typescript/package.lint.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion templates/init/functions/typescript/package.nolint.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading