---
# System prepended metadata

title: Firebase Function Monorepo Build Guide - Using pnpm + tsup

---

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