Skip to content
Merged
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
17 changes: 7 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -411,13 +411,13 @@
"lib/v2/providers/https"
],
"ai": [
"lib/v2/providers/ai"
"lib/v2/providers/ai/index"
Comment thread
shettyvarun268 marked this conversation as resolved.
],
"ai/types/gemini/v1beta": [
"lib/v2/providers/ai/types/gemini/v1beta"
"lib/v2/providers/ai/types/gemini/v1beta/index"
],
"ai/types/vertex/v1beta1": [
"lib/v2/providers/ai/types/vertex/v1beta1"
"lib/v2/providers/ai/types/vertex/v1beta1/index"
],
"pubsub": [
"lib/v2/providers/pubsub"
Expand Down Expand Up @@ -491,9 +491,6 @@
"v2/alerts/performance": [
"lib/v2/providers/alerts/performance"
],
"v2/base": [
"lib/v2/base"
],
"v2/database": [
"lib/v2/providers/database"
],
Expand All @@ -513,13 +510,13 @@
"lib/v2/providers/https"
],
"v2/ai": [
"lib/v2/providers/ai"
"lib/v2/providers/ai/index"
],
"v2/ai/types/gemini/v1beta": [
"lib/v2/providers/ai/types/gemini/v1beta"
"lib/v2/providers/ai/types/gemini/v1beta/index"
],
"v2/ai/types/vertex/v1beta1": [
"lib/v2/providers/ai/types/vertex/v1beta1"
"lib/v2/providers/ai/types/vertex/v1beta1/index"
],
"v2/pubsub": [
"lib/v2/providers/pubsub"
Expand Down Expand Up @@ -565,7 +562,7 @@
"docgen:v2:toc": "ts-node docgen/toc.ts --input docgen/v2 --output docgen/v2/markdown/toc --path /docs/reference/functions/2nd-gen/node",
"docgen:v2:gen": "api-documenter-fire markdown -i docgen/v2 -o docgen/v2/markdown --project functions && npm run docgen:v2:toc",
"docgen:v2": "npm run build && npm run docgen:v2:extract && npm run docgen:v2:gen",
"build": "tsdown && tsc -p tsconfig.release.json",
"build": "tsdown && tsc -p tsconfig.release.json && tsc -p tsconfig.dts.json",
"build:pack": "rm -rf lib && npm install && npm run build && npm pack",
"build:watch": "npm run build -- -w",
"format": "npm run format:ts && npm run format:other",
Expand Down
23 changes: 22 additions & 1 deletion scripts/test-packaging.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ set -eux

# Argument 1: Path to a pre-built tarball.
# If not provided, the script will run 'npm run build' and 'npm pack' locally.
PREBUILT_TARBALL="$1"
PREBUILT_TARBALL="${1:-}"

# Setup cleanup
WORK_DIR=$(mktemp -d)
Expand All @@ -27,6 +27,7 @@ if [ -n "$PREBUILT_TARBALL" ]; then
else
echo "Building project..."
cd "$SCRIPT_DIR/.."
rm -rf lib
npm run build

echo "Packing project..."
Expand All @@ -44,4 +45,24 @@ echo "Running verification script..."
cp "$SCRIPT_DIR/verify-exports.mjs" .
node verify-exports.mjs

echo "Installing TypeScript consumer dependencies..."
TS_VERSION=$(node -p 'require("./node_modules/firebase-functions/package.json").devDependencies?.typescript || ""')
NODE_TYPES_VERSION=$(node -p 'require("./node_modules/firebase-functions/package.json").devDependencies?.["@types/node"] || ""')
GRAPHQL_VERSION=$(node -p 'require("./node_modules/firebase-functions/package.json").peerDependencies?.graphql || ""')
if [ -z "$TS_VERSION" ] || [ -z "$NODE_TYPES_VERSION" ] || [ -z "$GRAPHQL_VERSION" ]; then
echo "❌ Failed to resolve typescript, @types/node, or graphql version from package.json"
exit 1
fi
npm install --no-audit --no-fund "typescript@$TS_VERSION" "@types/node@$NODE_TYPES_VERSION"

echo "Running TypeScript declaration verification (core entrypoints)..."
cp "$SCRIPT_DIR/verify-types.mjs" .
node verify-types.mjs

Comment thread
shettyvarun268 marked this conversation as resolved.
echo "Installing optional peer dependencies for GraphQL type verification..."
npm install --no-audit --no-fund "graphql@$GRAPHQL_VERSION"

echo "Running TypeScript declaration verification (optional-peer entrypoints)..."
node verify-types.mjs --only-optional-peers

popd > /dev/null
231 changes: 231 additions & 0 deletions scripts/verify-types.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';

/**
* Verifies that the packed `firebase-functions` npm package provides valid,
* complete TypeScript declaration (`.d.ts`) files for downstream consumers.
*
* This script runs in two passes from `scripts/test-packaging.sh`:
* - Pass 1 (`node verify-types.mjs`):
* 1. Validates `package.json` metadata (`exports` vs `typesVersions` parity and file existence).
* 2. Compiles all core entrypoints when optional peer dependencies (`graphql`) are absent.
* - Pass 2 (`node verify-types.mjs --only-optional-peers`):
* Compiles entrypoints that require optional peer dependencies (`dataconnect/graphql`)
* after `graphql` is installed.
*/

const OPTIONAL_PEER_ENTRYPOINTS = new Set([
Comment thread
shettyvarun268 marked this conversation as resolved.
'firebase-functions/dataconnect/graphql',
'firebase-functions/v2/dataconnect/graphql',
]);

const onlyOptionalPeers = process.argv.includes('--only-optional-peers');

// Read the package.json of the INSTALLED package inside node_modules to verify what was packed
const pkgRoot = path.resolve(process.cwd(), 'node_modules/firebase-functions');
const pkgPath = path.join(pkgRoot, 'package.json');
if (!fs.existsSync(pkgPath)) {
console.error(`❌ Could not find installed package at ${pkgPath}`);
process.exit(1);
}

const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const exportsMap = pkg.exports || {};
const typesVersionsMap = pkg.typesVersions?.['*'] || {};
const exportKeys = Object.keys(exportsMap);

let hasError = false;

// Step 1: Verify `package.json` metadata parity between `exports` (used by modern Node/ESM
// resolvers) and `typesVersions` (used by legacy CommonJS `--moduleResolution node` projects).
if (!onlyOptionalPeers) {
console.log('\n--- Verifying package.json exports & typesVersions parity ---');

const rootTypes = (pkg.types || '').replace(/^\.\//, '');
const rootExportTypes = (exportsMap['.']?.types || '').replace(/^\.\//, '');
if (!rootTypes || rootTypes !== rootExportTypes) {
console.error(
`❌ Root "types" (${pkg.types}) does not match exports["."].types (${exportsMap['.']?.types})`
);
hasError = true;
}

for (const [exportSubpath, targets] of Object.entries(exportsMap)) {
if (exportSubpath.endsWith('.json')) {
continue;
}

if (typeof targets === 'object' && targets !== null) {
// Node.js and TypeScript require "types" to be the first key in each export target object.
const conditionKeys = Object.keys(targets);
if (conditionKeys[0] !== 'types') {
console.error(
`❌ exports["${exportSubpath}"] must list "types" as its first condition (got "${conditionKeys[0]}")`
);
hasError = true;
}

// Ensure every file path referenced by `exports` was actually included in the packed tarball.
for (const [condition, relPath] of Object.entries(targets)) {
const fullPath = path.resolve(pkgRoot, relPath);
if (!fs.existsSync(fullPath)) {
console.error(
`❌ Missing packed file for exports["${exportSubpath}"].${condition}: ${relPath}`
);
hasError = true;
}
}
}

if (exportSubpath === '.') {
continue;
}

const subpath = exportSubpath.replace(/^\.\//, '');
const expectedTypesVersion = (targets.types || '').replace(/^\.\//, '').replace(/\.d\.ts$/, '');
const actualTypesVersion = typesVersionsMap[subpath]?.[0];

if (!actualTypesVersion) {
console.error(
`❌ Missing typesVersions["*"]["${subpath}"] entry (expected "${expectedTypesVersion}")`
);
hasError = true;
} else if (actualTypesVersion !== expectedTypesVersion) {
console.error(
`❌ Mismatched typesVersions["*"]["${subpath}"]: got "${actualTypesVersion}", expected "${expectedTypesVersion}"`
);
hasError = true;
}
}

// Ensure `typesVersions` does not retain dead/stale entries that no longer exist in `exports`.
for (const typesVersionKey of Object.keys(typesVersionsMap)) {
if (typesVersionKey === '*') {
continue;
}
if (!exportsMap[`./${typesVersionKey}`]) {
console.error(
`❌ Stale typesVersions["*"]["${typesVersionKey}"] entry has no matching "./${typesVersionKey}" in exports`
);
hasError = true;
}
}

if (!hasError) {
console.log('✅ package.json exports and typesVersions are in sync and all target files exist.');
} else {
console.error('❌ package.json exports and typesVersions parity check failed.');
}
}

// Step 2: Generate a synthetic consumer source file importing the target public entrypoints.
const entryPoints = exportKeys.filter((key) => !key.endsWith('.json'));

const importLines = [];
for (const [i, exportSubpath] of entryPoints.entries()) {
const importPath =
exportSubpath === '.'
? 'firebase-functions'
: `firebase-functions/${exportSubpath.replace(/^\.\//, '')}`;
const isOptionalPeer = OPTIONAL_PEER_ENTRYPOINTS.has(importPath);
if (onlyOptionalPeers ? !isOptionalPeer : isOptionalPeer) {
continue;
}
importLines.push(`import * as m${i} from '${importPath}';`);
}
Comment thread
shettyvarun268 marked this conversation as resolved.

const tscPath = path.resolve(process.cwd(), 'node_modules/typescript/bin/tsc');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: Add upfront existence guard for tsc binary

If typescript fails to install or is hoisted unexpectedly, execFileSync throws a generic child process error. An upfront guard mirroring the pkgPath check on line 28 makes the diagnostic immediate and clear:

Suggested change
const tscPath = path.resolve(process.cwd(), 'node_modules/typescript/bin/tsc');
const tscPath = path.resolve(process.cwd(), "node_modules/typescript/bin/tsc");
if (!fs.existsSync(tscPath)) {
console.error(`❌ Could not find TypeScript compiler binary at ${tscPath}`);
process.exit(1);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is maybe getting too edge casey? AI does like to handle everything under the sun. Not sure though... we have had npm issues before.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this. Just 4 lines and helps with an early exit without printing long stack traces

if (!fs.existsSync(tscPath)) {
console.error(`❌ Could not find TypeScript compiler binary at ${tscPath}`);
process.exit(1);
}

// Step 3: Run `tsc --noEmit --skipLibCheck false --strict` across all 3 real-world module
// resolution modes so every `.d.ts` file and internal import is type-checked:
// - `.ts` with `--moduleResolution node`: exercises `typesVersions` (legacy CJS projects)
// - `.mts` with `--moduleResolution nodenext`: exercises `exports[...].import` (ESM projects)
// - `.cts` with `--moduleResolution nodenext`: exercises `exports[...].require` (modern CJS projects)
const configs = [
{
label: 'CJS Legacy (moduleResolution: node / typesVersions)',
file: 'verify-types.ts',
args: [
'--noEmit',
'--skipLibCheck',
'false',
'--strict',
'--esModuleInterop',
'--module',
'commonjs',
'--moduleResolution',
'node',
'--target',
'es2022',
],
},
{
label: 'ESM (moduleResolution: nodenext / exports.import)',
file: 'verify-types.mts',
args: [
'--noEmit',
'--skipLibCheck',
'false',
'--strict',
'--module',
'nodenext',
'--moduleResolution',
'nodenext',
'--target',
'es2022',
],
},
{
label: 'CJS Modern (moduleResolution: nodenext / exports.require)',
file: 'verify-types.cts',
args: [
'--noEmit',
'--skipLibCheck',
'false',
'--strict',
'--module',
'nodenext',
'--moduleResolution',
'nodenext',
'--target',
'es2022',
],
},
Comment thread
shettyvarun268 marked this conversation as resolved.
];

const scopeLabel = onlyOptionalPeers ? 'optional-peer entry points' : 'core entry points';
console.log(`\n--- Verifying TypeScript Declarations (${importLines.length} ${scopeLabel}) ---`);

for (const { label, file, args } of configs) {
const consumerFile = path.resolve(process.cwd(), file);
fs.writeFileSync(consumerFile, `${importLines.join('\n')}\n`);
try {
execFileSync(process.execPath, [tscPath, file, ...args], { stdio: 'inherit' });
console.log(`✅ TypeScript check passed: ${label}`);
} catch (_err) {
console.error(`❌ TypeScript check failed: ${label}`);
hasError = true;
} finally {
if (fs.existsSync(consumerFile)) {
fs.unlinkSync(consumerFile);
}
}
}

if (hasError) {
console.error(
'\n❌ TypeScript declaration verification failed.\n' +
'Check for:\n' +
' 1. Public .d.ts files importing types marked @internal (stripped by tsconfig.release.json)\n' +
' 2. Missing, stale, or mismatched "types" / "typesVersions" paths in package.json\n' +
' 3. Public signatures leaking types from devDependencies or unguarded optional peerDependencies'
);
process.exit(1);
}

console.log(`\n✨ All ${scopeLabel} verified successfully!`);
14 changes: 14 additions & 0 deletions tsconfig.dts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
// Validates that emitted declaration files in `lib/` compile cleanly against each other
Comment thread
shettyvarun268 marked this conversation as resolved.
// without dangling imports (e.g., from `@internal` declarations stripped by tsconfig.release.json).
"compilerOptions": {
"noEmit": true,
"skipLibCheck": false,
"lib": ["es2022", "dom"],
"module": "commonjs",
"target": "es2022",
"esModuleInterop": true,
"typeRoots": ["./node_modules/@types"]
},
"include": ["lib/**/*.d.ts"]
}
Loading