Optimizing a Deno-Based Monorepo with Node.js Services
TL;DR: When a single moon run trigger:dev command builds 13 packages instead of 5, something's wrong. Here's how we diagnosed over-broad Moon dependencies, traced npm transitive dependencies, and achieved a 62% reduction in build time.
The Problem
Running moon run trigger:dev to start our Trigger.dev background job worker was taking forever. Not because Trigger.dev is slow—but because Moon was building packages we didn't need.
$ moon run trigger:dev
core:build-npm | [dnt] Transforming...
orpc-contracts:build-npm | [dnt] Building project...
analysis-definitions:build-npm | [dnt] Running npm install...
auth:build-npm | [dnt] Type checking ESM...
# ... 13 packages later ...
trigger:dev | Trigger.dev (4.1.2)
Wait. Trigger.dev is supposed to be a pure orchestration layer. It calls the API via oRPC—it doesn't need auth, analysis-definitions, or core. Why is Moon building all these packages?
The Environment
Our setup is a polyglot monorepo orchestrated by Moon:
- Deno packages: Core business logic, oRPC contracts, API client (
packages/*) - Node.js services: Next.js frontend, Trigger.dev worker (
apps/*,services/*) - Python services: Modal compute functions (
services/modal) - dnt builds: Deno packages compile to npm-compatible packages for Node.js consumption
The architecture principle is clear: Trigger.dev = orchestration only. It should call the API, not import business logic directly.
Trigger.dev tasks
└── oRPC API calls (via @indicia/api-client)
└── API server handles all business logic
So why was it building 13 packages?
The Investigation
Step 1: Trace the Moon Dependency Chain
First, look at services/background/trigger/moon.yml:
tasks:
sync-deps:
command: "deno"
args: ["run", "-A", "../../../scripts/sync-to-trigger.ts"]
deps:
- "^:build-npm" # 👈 THE CULPRIT
dev:
deps:
- "~:sync-deps"
The ^:build-npm syntax means "run the build-npm task for all packages in the workspace that have it."
That's 13 packages. Most of which Trigger.dev never uses.
Step 2: What Does Trigger Actually Import?
Grep the source code:
$ grep -r "from ['\"]@indicia/" services/background/trigger/src/
Direct imports found:
@indicia/api-client- oRPC client factory@indicia/orpc-contracts- Type definitions and schemas@indicia/platform-utilities- Utility functions likenow()
That's 3 packages, not 13.
Step 3: The Trap—Transitive npm Dependencies
But wait. This is where the key insight emerged. Looking at packages/api-client/npm/package.json—the dnt-generated npm package, not the Deno source—revealed hidden dependencies:
{
"dependencies": {
"@orpc/client": "^1.11.1",
"@orpc/contract": "^1.11.1",
"@indicia/core-types": "file:/.../packages/core-types/npm",
"@indicia/orpc-contracts": "file:/.../packages/orpc-contracts/npm",
"@indicia/orpc-server": "file:/.../packages/orpc-server/npm"
}
}
The file: protocol references create runtime dependencies. Even though Trigger source code doesn't import core-types or orpc-server directly, they must exist in node_modules for the api-client package to resolve at runtime.
Critical insight: Deno imports ≠ npm dependencies. What matters for Node.js runtime is the generated npm package.json, not the Deno import map. You must trace dependencies in the compiled packages, not just the source.
Step 4: Multiple Sources of Truth (All Inconsistent)
The investigation revealed four different lists of "required" packages:
| Source | Packages Listed | |--------|-----------------| | Actual imports | api-client, orpc-contracts, platform-utilities (3) | | package.json deps | analysis-definitions, api-client, auth, conversation-core, conversation-service, orpc-contracts, platform-utilities (7) | | sync-to-trigger.ts | core, core-types, auth, orpc-contracts, logger, monitoring, analysis-definitions, analysis-sdk, orpc-server (9) | | trigger.config.ts external | auth, api-client, core (+subpaths), orpc-contracts, platform-utilities, analysis-definitions (7+) |
No two lists matched. Some packages were dead dependencies, others were missing transitive deps.
The Fix
Step 1: Determine the Minimal Package Set
Trace the complete dependency graph:
Trigger.dev tasks
├── @indicia/api-client (direct import)
│ ├── @indicia/core-types (transitive - REQUIRED)
│ ├── @indicia/orpc-contracts (transitive - already direct)
│ └── @indicia/orpc-server (transitive - REQUIRED)
├── @indicia/orpc-contracts (direct import)
└── @indicia/platform-utilities (direct import)
Minimal set: 5 packages (api-client, orpc-contracts, platform-utilities, core-types, orpc-server)
Step 2: Update sync-to-trigger.ts
// Before: 9 packages (many unused)
const packages = [
"core", "core-types", "auth", "orpc-contracts",
"logger", "monitoring", "analysis-definitions",
"analysis-sdk", "orpc-server"
];
// After: 5 packages (exact match)
const packages = [
"api-client", // Direct import
"orpc-contracts", // Direct import + transitive
"platform-utilities",// Direct import
"core-types", // Transitive from api-client
"orpc-server", // Transitive from api-client
];
Step 3: Update Moon Dependencies
# Before: Build ALL packages
sync-deps:
deps:
- "^:build-npm"
# After: Build only required packages
sync-deps:
deps:
- "api-client:build-npm"
- "orpc-contracts:build-npm"
- "platform-utilities:build-npm"
- "core-types:build-npm"
- "orpc-server:build-npm"
Step 4: Clean trigger.config.ts Externals
// Before: Legacy deps that aren't used
external: [
"@indicia/auth",
"@indicia/core",
"@indicia/core/src/ai/gemini",
"@indicia/core/ai/gemini",
// ... 8 more unused entries
]
// After: Exact match to actual needs
external: [
"googleapis", "cheerio", "html-to-text",
"@indicia/api-client",
"@indicia/orpc-contracts",
"@indicia/platform-utilities",
"@indicia/core-types",
"@indicia/orpc-server",
]
Step 5: Clean package.json
// Removed dead dependencies:
// - @indicia/analysis-definitions (not imported)
// - @indicia/auth (not imported)
// - @indicia/conversation-core (not imported)
// - @indicia/conversation-service (not imported)
Step 6: Fix the Build Task (Found During Code Review)
After the initial fix, a code review revealed another issue: the build task had redundant dependencies that duplicated what sync-deps already built:
# Before: Redundant - sync-deps already builds these
build:
deps:
- "~:sync-deps"
- "analysis-definitions:build-npm" # Redundant AND wrong
- "api-client:build-npm" # Redundant
- "auth:build-npm" # Redundant AND wrong
- "core:build-npm" # Redundant AND wrong
- "orpc-contracts:build-npm" # Redundant
# After: sync-deps handles everything
build:
deps:
- "~:sync-deps" # Builds and syncs all needed packages
inputs:
- "src/**/*"
- "trigger.config.ts"
- "package.json"
- "tsconfig.json"
# Note: npm package changes are tracked via sync-deps task dependencies
Lesson learned: Always review for redundant dependencies after optimizing. The initial fix was incomplete.
What We Tried That Didn't Work
Tracking npm Packages as Inputs
We initially tried to track npm package.json files for cache invalidation:
# ❌ This doesn't work
inputs:
- "../../../packages/api-client/npm/package.json"
Moon doesn't support ../ in input paths. Input paths must be within the project directory. The workaround is to rely on task dependencies—since sync-deps depends on the build-npm tasks, Moon's dependency tracking handles cache invalidation automatically.
The Results
Build Scope Reduction
| Metric | Before | After | Improvement | |--------|--------|-------|-------------| | Packages built | 13 | 5 | 62% reduction | | build task deps | sync-deps + 5 extra | sync-deps only | 100% waste eliminated |
Verified build time (with caching):
Tasks: 10 completed (8 cached)
Time: 17s 280ms
Moon Task Graph (Verified)
Using Moon's task graph visualizer:
moon task-graph trigger:build
trigger:build
└── trigger:sync-deps
├── orpc-contracts:build-npm
├── api-client:build-npm → api-client:typecheck
├── core-types:build-npm → core-types:typecheck, core-types:test
├── platform-utilities:build-npm
└── orpc-server:build-npm
Clean. Minimal. Correct.
Key Insights
1. Wildcards Are Dangerous
The ^:build-npm syntax is convenient but dangerous. It builds everything, not what you need. Prefer explicit dependencies:
# 🚫 Avoid
deps:
- "^:build-npm"
# ✅ Prefer
deps:
- "api-client:build-npm"
- "orpc-contracts:build-npm"
2. Deno Imports ≠ npm Dependencies
When using dnt to build Deno packages for Node.js consumption, you have two dependency graphs:
- Deno graph: Import statements in source code
- npm graph:
file:references in generated package.json
The npm graph is what matters at runtime. Always trace transitive dependencies in the npm packages (the npm/package.json files), not the Deno source.
3. Multiple Sources of Truth = Tech Debt
We had four places defining "required packages":
- Actual source imports
- package.json dependencies
- sync script package list
- trigger.config.ts externals
All four should match. When they drift, you get either build failures or wasted build time.
4. Code Review Catches What Automation Misses
The build task redundancy (Step 6) was discovered during code review, not during the initial optimization. Automated tools helped identify the wildcard issue, but human review caught the secondary problem.
5. Verify with Visualization
Moon's task graph visualizer is invaluable:
moon task-graph trigger:build
If your graph looks like a hairball, you have dependency problems.
The Broader Pattern
This optimization applies whenever you have:
- A polyglot monorepo (Deno + Node.js, or any mixed runtime)
- dnt or similar bridges that compile packages for cross-runtime use
- Services that should be "thin" (orchestrators, API gateways, etc.)
The pattern:
- Audit actual imports - What does the source code actually use?
- Trace npm transitives - What do the compiled packages require?
- Align all sources - package.json, build deps, externals, sync scripts
- Replace wildcards - Use explicit deps instead of
^:patterns - Verify with visualization - Trust but verify the dependency graph
- Review for redundancy - Check that you didn't create duplicate dependencies
Conclusion
A 62% reduction in packages built from one optimization session. The fix wasn't complex—it was mostly removing things. The hard part was understanding the multiple layers of dependency management in a polyglot monorepo.
The lesson: explicit is better than implicit. Wildcards like ^:build-npm are convenient shortcuts that accumulate tech debt. When your dev server builds 13 packages instead of 5, that debt comes due.
Addendum: Why Deno Instead of Just Node.js?
Given the complexity we just dealt with, this is a fair question.
Why Deno (The Benefits)
1. TypeScript-first
- No tsconfig.json, no build step for development
- Just write
.tsfiles and run them - Type checking built into the runtime
2. Modern defaults
- ES modules only (no CommonJS confusion)
- Web-standard APIs (fetch, URL, crypto)
- Top-level await
3. Built-in tooling
deno fmt- formatterdeno lint- linterdeno test- test runner- No prettier, eslint, jest config sprawl
4. Security model
- Explicit permissions (
--allow-read,--allow-net) - Sandboxed by default
5. URL imports / import maps
- No node_modules in Deno packages
- Cleaner dependency management
The Tradeoff We Just Experienced
The complexity exists because of the bridge:
Deno packages ──(dnt)──► npm packages ──► Node.js services
If everything was Node.js, there'd be no build-npm step, no sync script, no dual dependency graphs.
Is It Worth It?
For this codebase, arguably yes because:
- Core business logic (
packages/*) benefits most from Deno's DX - Node.js is only used where ecosystem requires it (Next.js, Trigger.dev)
- The build complexity is isolated to the boundary
The honest tradeoff:
| Deno | Node.js | |------|---------| | Better DX for core packages | Simpler deployment | | No runtime type checking cost | No dnt bridge needed | | Cleaner imports | Larger ecosystem | | More config for cross-runtime | Less config overall |
The Alternative
You could absolutely run everything on Node.js with TypeScript. The main losses would be:
- Need
tsxorts-nodefor dev - More config files (tsconfig, eslint, prettier, jest)
- CommonJS/ESM friction in Node.js ecosystem
The main gain: eliminate the entire dnt pipeline we just optimized.
Bottom line: Deno is a DX bet. If most development happens in core packages, the cleaner Deno experience pays off. If you're constantly fighting the dnt bridge, reconsider.
Deno Deploy for Production
For the API layer (Hono), Deno Deploy is a natural fit:
- V8 isolates - Lightweight, fast cold starts
- Edge deployment - Global distribution
- Native TypeScript - No build step
- No dnt bridge needed - Runs Deno directly
Since the API is already Deno (apps/api with Hono), deploying to Deno Deploy is straightforward—no cross-runtime complexity.
Is Deno More Secure in Production?
Honest answer: marginally, and it depends.
Deno's Security Model
# Development - permissions matter
deno run --allow-net --allow-env server.ts
# Production (Deno Deploy) - permissions are implicit
# Your code runs with what it needs
The Reality in Production
| Scenario | Deno | Node.js | |----------|------|---------| | Dev machine | ✅ Sandboxed by default | ❌ Full access | | Production container | ~Same | ~Same | | Deno Deploy / Vercel | V8 isolates | V8 isolates (similar) | | Supply chain attack | Slightly better* | More exposed |
*Deno's lockfile and URL imports give marginally better supply chain visibility, but neither is immune.
Where Deno Actually Helps
- Development-time accidents - Can't accidentally read
/etc/passwdwithout--allow-read - Dependency auditing - URL imports make it clearer what you're pulling in
- No node_modules - Smaller attack surface (no nested dependency hell)
Where It Doesn't Matter
- In Deno Deploy - Your code runs with permissions granted
- In containers - Both are equally sandboxed by the container runtime
- Application-level vulns - SQL injection, XSS, etc. are your code's problem regardless of runtime
Security Bottom Line
Deno's permission model is defense in depth, not a silver bullet. It catches mistakes during development and makes you think about what access your code needs. In production:
- Deno Deploy: Security comes from V8 isolate architecture, not permissions
- Self-hosted: Security comes from your container/VM isolation
The bigger security win from Deno is the simpler dependency graph (no node_modules sprawl) making audits easier, not the runtime permission model.
The "Thin Wrapper" Architecture Validation
The optimization we did reinforces the architectural principle. Trigger.dev went from importing 13 packages to 5—and those 5 are just the API client and its transitive deps. That's the architecture working as intended:
Trigger.dev (thin) → API client → API server (thick, Deno)
If Trigger.dev needed @indicia/core directly, the dnt bridge cost would be higher and the architecture would be leaking.
The Service/Runtime Matrix
The brochure site uses Fresh specifically to avoid the dnt bridge entirely. This is a concrete example of "use Deno where you can, Node.js where you must":
| Service | Runtime | Why | |---------|---------|-----| | API | Deno (Deploy) | Core logic, no bridge needed | | Brochure | Deno (Fresh) | Static site, no bridge needed | | Tier1 App | Node (Next.js) | Vercel ecosystem, needs bridge | | Background | Node (Trigger.dev) | No Deno equivalent, needs bridge |
The dnt cost is paid only where ecosystem forces it.
Risk to Monitor
The api-client npm package currently has transitive deps on core-types and orpc-server via file: references. If those grow to depend on more packages, the "thin wrapper" could slowly thicken.
Recommendation: Periodic audits of packages/*/npm/package.json to ensure the bridge stays minimal. If you see the transitive dependency count growing, that's a sign the architecture is drifting.
This article documents work done on the Indicia Platform, a Deno-first monorepo using Moon for orchestration, dnt for cross-runtime builds, and Trigger.dev for background job processing.