# Firebase Function Monorepo Build Guide - Using pnpm + tsup ## File Structure Preview ```text ├── Makefile ├── apps │ └── test │ ├── Makefile │ ├── firebase-debug.log │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── firebase.json ├── libs │ └── logger │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── tsconfig.base.json ``` --- ## Implementation Steps ### 1: Basic Skeleton ```bash git init pnpm init ``` #### Create pnpm-workspace.yaml ```yaml packages: - "libs/*" - "apps/*" ``` #### Create tsconfig.base.json ```json { "compilerOptions": { "module": "commonjs", "target": "ES2020", "lib": ["ES2020"], "strict": true, "noImplicitReturns": true, "noUnusedLocals": true, "skipLibCheck": true, "esModuleInterop": true } } ``` #### Firebase Initialization ```bash firebase init functions # Select TypeScript # Select No ESLint # Select No Overwrite (if asked) ``` #### Move Directory ```bash mv functions apps/test ``` #### Modify firebase.json Please modify the content as follows: ```diff { "functions": [ { - "source": "functions", + "source": "apps/test/dist", - "codebase": "default", + "codebase": "test", } ] } ``` ### 2: Create libs/logger ```bash # cd libs/logger pnpm init pnpm add pino pnpm add -D typescript @types/node ``` #### 2-1. Modify package.json Modify the package name to match the Scope naming. ```diff - "name": "logger", + "name": "@mymonorepo/logger", ``` #### 2-2. Create tsconfig.json We use configuration inheritance to keep things clean. ```json { "extends": "../../tsconfig.base.json", "compilerOptions": { "declaration": true, "declarationMap": true, "outDir": "dist", "rootDir": "src" }, "include": ["src"], "exclude": ["node_modules", "dist"] } ``` _Note: Ensure you have a `tsconfig.base.json` in the root directory with common settings._ #### 2-3. Create src/index.ts ```typescript import pino from "pino" const pinoInstance = pino({ level: "info", base: { service: "mymonorepo" }, formatters: { level: label => { return { severity: label.toUpperCase() } }, }, }) export const logger = { info: (msg: string, data?: object) => pinoInstance.info(data, msg), warn: (msg: string, data?: object) => pinoInstance.warn(data, msg), error: (msg: string, err?: any) => pinoInstance.error({ err }, msg), } ``` ### 3: Perfecting apps/test #### 3.1 Install Dependencies ```bash # cd project root pnpm add -D -w tsup typescript rimraf # cd apps/test # (if pnpm init has not been executed) # pnpm init pnpm add -D @types/node pnpm add @mymonorepo/logger --workspace ``` #### 3.2 Create `tsup.config.ts` This is the core bundling logic, responsible for generating `dist/package.json`. ```typescript import { defineConfig, Options } from "tsup" import { writeFile, symlink, unlink, copyFile, access, readFile, } from "fs/promises" import { existsSync } from "fs" import path from "node:path" const CWD = __dirname const DIST_DIR = path.join(CWD, "dist") const FIREBASE_FUNCTIONS_DEFAULT_VERSION = "^12.7.0" const FIREBASE_ADMIN_DEFAULT_VERSION = "^3.4.1" const createPackageJson = async () => { const fileContent = await readFile(path.join(CWD, "package.json"), "utf8") const pkg = JSON.parse(fileContent) as any const dependencies = pkg.dependencies || {} const functionsVersion = typeof dependencies["firebase-functions"] === "string" && dependencies["firebase-functions"].length > 0 ? dependencies["firebase-functions"] : FIREBASE_FUNCTIONS_DEFAULT_VERSION const adminVersion = typeof dependencies["firebase-admin"] === "string" && dependencies["firebase-admin"].length > 0 ? dependencies["firebase-admin"] : FIREBASE_ADMIN_DEFAULT_VERSION const cloudDeps = { "firebase-functions": functionsVersion, "firebase-admin": adminVersion, // If your project has other required runtime dependencies, please add them here: // "other-dependency": dependencies["other-dependency"], } const packageJson = JSON.stringify( { name: pkg.name, main: "index.js", engines: { node: "20" }, type: "commonjs", dependencies: cloudDeps, }, null, 2 ) const pathSavedTo = path.join(DIST_DIR, "package.json") console.log(`📦 [tsup] Saved deployment package.json to ${pathSavedTo}`) await writeFile(pathSavedTo, packageJson) } const symlinkNodeModules = async () => { const target = path.join(CWD, "node_modules") const link = path.join(DIST_DIR, "node_modules") try { await access(target) } catch (e) { console.warn( "⚠️ [tsup] Target node_modules not found. Skipping symlink." ) return } try { if (existsSync(link)) await unlink(link) await symlink(target, link, "junction") console.log( "🔗 [tsup] Symlink created: dist/node_modules -> ../node_modules" ) } catch (error) { console.warn( "⚠️ [tsup] Failed to create symlink (Expected if deployment handles dependencies):", (error as Error).message ) } } const copyEnvToDist = async () => { const envFiles = [".env", ".env.local"] let copiedCount = 0 for (const filename of envFiles) { const sourcePath = path.join(CWD, filename) const destPath = path.join(DIST_DIR, filename) try { await access(sourcePath) await copyFile(sourcePath, destPath) console.log(`📋 [tsup] Copied ${filename} to dist/`) copiedCount++ } catch (error) {} } if (copiedCount !== 0) return console.log( "⚠️ [tsup] No .env files found/copied. (If using Firebase config, ignore this warning)" ) } export default defineConfig((options: Options) => ({ entry: ["src/index.ts"], format: ["cjs"], minify: !options.watch, clean: false, outDir: "dist", sourcemap: true, external: ["firebase-functions", "firebase-admin"], onSuccess: async () => { await Promise.all([ createPackageJson(), copyEnvToDist(), symlinkNodeModules(), ]) console.log("✅ [tsup] Build and deployment preparation completed.") }, })) ``` #### 3.3 Modify `package.json` The key is to add the `build` command and adjust settings. ```diff { - "name": "functions", + "name": "@mymonorepo/test", - "main": "lib/index.js", + "main": "dist/index.js", - "engines": { "node": "22" }, + "engines": { "node": "20" }, ``` #### 3.4 Modify `tsconfig.json` The focus is on setting `paths` and including `tsup.config.ts`. ```json { "extends": "../../tsconfig.base.json", "compilerOptions": { "moduleResolution": "node", "outDir": "dist", "sourceMap": true, "resolveJsonModule": true, "baseUrl": ".", "paths": { "@mymonorepo/logger": ["../../libs/logger/src"] } }, "include": ["src", "tsup.config.ts"], "exclude": ["node_modules", "dist"] } ``` > **Explanation:** > > 1. **`include: ["tsup.config.ts"]`**: This is to prevent errors when opening tsup.config.ts due to missing `@types/node`. > 2. **`exclude: ["node_modules", "dist"]`**: Prevents the compiler from recursively checking huge dependency packages or re-compiling output files, significantly improving IDE response speed and compilation performance. This should be set for any project. > 3. **`paths: { "@mymonorepo/logger": [...] }`**: This includes the referenced package in the compilation. > 4. **`lib": ["ES2020"]`**: (Inherited from base) Explicit declaration avoids default behavior changes during future TypeScript version upgrades and ensures no browser-specific APIs (like DOM) are accidentally introduced in the Node.js environment. > 5. **`"compileOnSave": true`**: This is a feature of older editors (like Atom or old VSCode) that automatically runs tsc on save. In our architecture, we use tsup --watch for real-time compilation, so keeping this option might cause duplicate compilation or wasted resources. It is recommended to remove it to keep things clean. --- ### 4. Makefile #### 4-1. Create `apps/test/Makefile` ```makefile .PHONY: install build watch serve deploy clean .ONESHELL: install: @echo "📦 Installing dependencies from root..." @cd ../.. && pnpm install build: @echo "🔨 Building function..." @pnpm exec tsup @echo "✅ Build complete." watch: @echo "🔨 Building function..." @pnpm exec tsup --watch @echo "✅ Build complete." serve: @echo "🚀 Starting emulator..." @firebase emulators:start --only functions deploy: @echo "☁️ Deploying to Firebase..." @firebase deploy --only functions clean: @echo "🧹 Cleaning dist..." @pnpm exec rimraf dist ``` ### 5. Register HTTP Trigger #### Create `apps/test/src/index.ts` ```typescript import { setGlobalOptions } from "firebase-functions/v2" import { onRequest } from "firebase-functions/v2/https" import { logger } from "@mymonorepo/logger" setGlobalOptions({ maxInstances: 10 }) export const helloWorld = onRequest((request, response) => { logger.info("hello world", { structuredData: true, target: "Moriarty", evidence_count: 5, }) response.json({ status: "ok", message: "Hello from Monorepo! Check your terminal for Pino logs.", timestamp: new Date().toISOString(), }) }) ```