# 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(),
})
})
```