NNO Docs
Guides

NNO Core Development Guide

Documentation for NNO Core Development Guide

This guide is for NNO core developers adding services, packages, and features to the monorepo. It covers the three most common extension points: Cloudflare Worker services, shared packages, and built-in feature packages.

For architectural context see the architecture overview. For contribution conventions (branching, commits, PR checklist) see CONTRIBUTING.md.


Monorepo Structure

nno-app-builder/
├── apps/console/        # React 19 frontend — Cloudflare Pages
├── features/            # @neutrino-io/feature-* pluggable UI packages
├── packages/            # @neutrino-io/core, sdk, ui-core, ui-auth, logger, cli
├── services/            # Hono.js Cloudflare Workers (iam, billing, registry, gateway, …)
├── tooling/             # Shared tsconfig, eslint-config, prettier-config, tailwind-config
└── docs/architecture/   # Architecture decision records (source of truth)

services/auth/ is a deploy template — it contains REPLACE_WITH_* placeholders and must never be deployed from this repo.


Adding a New Cloudflare Worker Service

1. Create the service directory

mkdir services/<name>
cd services/<name>

Pick a name that follows the nno-k3m9p2xw7q-<name> pattern for the deployed Worker (see Cloudflare Naming).

2. Create package.json

Name the package @neutrino-io/service-<name>. Mirror the billing service structure:

{
  "name": "@neutrino-io/service-<name>",
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "wrangler dev src/index.ts --port <port>",
    "deploy": "wrangler deploy src/index.ts",
    "deploy:staging": "wrangler deploy src/index.ts --env stg",
    "build": "tsc",
    "typecheck": "tsc --noEmit",
    "lint": "eslint",
    "test": "vitest run",
    "test:coverage": "vitest run --coverage"
  },
  "dependencies": {
    "@neutrino-io/logger": "workspace:*",
    "hono": "^4.10.2",
    "zod": "catalog:"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20250913.0",
    "@neutrino-io/eslint-config": "workspace:*",
    "@neutrino-io/tsconfig": "workspace:*",
    "typescript": "catalog:",
    "vitest": "^2.1.8",
    "wrangler": "^4.36.0"
  }
}

Each service port must be unique across the monorepo. Check existing services before picking one.

3. Configure wrangler.toml

Use billing's config as the template. Key conventions:

  • Worker name: nno-k3m9p2xw7q-<name> (prod) / nno-k3m9p2xw7q-<name>-stg (staging)
  • main: always src/index.ts
  • compatibility_flags: include ["nodejs_compat"]
  • Routes use custom_domain = true — hostname setup is documented in the DNS Operations Guide
name = "nno-k3m9p2xw7q-<name>"
main = "src/index.ts"
compatibility_date = "2024-09-13"
compatibility_flags = ["nodejs_compat"]

[vars]
PLATFORM_ID  = "k3m9p2xw7q"
SERVICE_NAME = "<name>"
ENVIRONMENT  = "prod"

[[d1_databases]]
binding       = "DB"
database_name = "nno-k3m9p2xw7q-<name>-db"
database_id   = "<uuid from wrangler d1 create>"
migrations_dir = "migrations"

[[routes]]
pattern     = "<name>.svc.nno.app"
custom_domain = true

# ── STG ──────────────────────────────────────────────────────────────────────
[env.stg]
name = "nno-k3m9p2xw7q-<name>-stg"

[env.stg.vars]
PLATFORM_ID  = "k3m9p2xw7q"
SERVICE_NAME = "<name>"
ENVIRONMENT  = "stg"

[[env.stg.d1_databases]]
binding       = "DB"
database_name = "nno-k3m9p2xw7q-<name>-db-stg"
database_id   = "<stg uuid>"

[[env.stg.routes]]
pattern     = "<name>.svc.stg.nno.app"
custom_domain = true

Document all secrets in a # SECRETS comment block at the bottom of the file (see billing's wrangler.toml for the pattern). Never commit actual secret values.

4. Create the initial Hono app

src/index.ts is the entrypoint. All services follow the same skeleton:

import { Hono } from "hono";
import { cors } from "hono/cors";
import { Logger, requestLogger } from "@neutrino-io/logger";
import type { Env } from "../env";

const app = new Hono<{ Bindings: Env }>();

app.use("*", requestLogger("<name>"));

app.use("*", async (c, next) => {
  const origins = c.env.CORS_ORIGINS
    ? c.env.CORS_ORIGINS.split(",").map((s) => s.trim())
    : [];
  return cors({ origin: origins })(c, next);
});

// Health check — always public, no auth
app.get("/health", (c) =>
  c.json({
    status: "ok",
    service: c.env.SERVICE_NAME,
    environment: c.env.ENVIRONMENT,
  }),
);

export default { fetch: app.fetch };

Define the Env type in a sibling env.ts file that matches your wrangler.toml bindings:

// env.ts
export interface Env {
  DB: D1Database;
  PLATFORM_ID: string;
  SERVICE_NAME: string;
  ENVIRONMENT: string;
  CORS_ORIGINS: string;
  AUTH_API_KEY: string;
}

Error envelope: all error responses must use \{ error: \{ code, message, requestId \} \}. Never return bare strings or non-enveloped errors.

Validation: parse every request body with Zod before touching it. Return 422 with details: parsed.error.flatten() on failure.

Auth: service-to-service calls use a Bearer AUTH_API_KEY header. See services/billing/src/index.ts for the requireApiKey middleware pattern, including the timing-safe comparison via WebCrypto HMAC.

5. Set up the D1 database

Create the database in Cloudflare:

# Create prod and stg databases, then paste the returned IDs into wrangler.toml
pnpm with-env wrangler d1 create nno-k3m9p2xw7q-<name>-db
pnpm with-env wrangler d1 create nno-k3m9p2xw7q-<name>-db-stg

6. Add D1 migrations

Create migrations/ in the service directory. Name files 0001_<description>.sql, 0002_<description>.sql, and so on. Each file is applied exactly once by Wrangler's migration runner.

-- migrations/0001_initial.sql
CREATE TABLE IF NOT EXISTS <table> (
  id         TEXT PRIMARY KEY,   -- NanoID 10 chars via generateId()
  created_at INTEGER NOT NULL,   -- Unix ms
  updated_at INTEGER NOT NULL
);

Apply migrations:

pnpm --filter @neutrino-io/service-<name> with-env wrangler d1 migrations apply nno-k3m9p2xw7q-<name>-db

Use raw SQL for all services except services/iam and services/registry, which use Drizzle ORM. See Database Conventions below.

7. Add to Turborepo

For most new services the global build and test tasks in turbo.json are sufficient — Turborepo discovers all package.json scripts automatically. Only add a named task entry if you need non-default dependency wiring (for example, services/auth has a custom build entry because it produces a dist/ artefact that other tasks depend on).

8. Register the hostname

After deploying, follow the DNS Operations Guide to wire up the custom domain in Cloudflare.


Adding a New Shared Package

Shared packages live under packages/ and are published to GitHub Packages as @neutrino-io/<name>.

1. Create the package directory and package.json

{
  "name": "@neutrino-io/<name>",
  "version": "0.1.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist/**"],
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch",
    "clean": "rm -rf dist",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@neutrino-io/tsconfig": "workspace:*",
    "tsup": "catalog:",
    "typescript": "catalog:"
  },
  "publishConfig": {
    "registry": "https://npm.pkg.github.com",
    "access": "restricted"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/neutrino-io/nno-app-builder.git",
    "directory": "packages/<name>"
  }
}

2. Create tsup.config.ts

import { defineConfig } from "tsup";

export default defineConfig({
  entry: { index: "src/index.ts" },
  format: ["esm", "cjs"],
  dts: true,
  outDir: "dist",
  clean: true,
  sourcemap: false,
});

Add additional entry points (like logger's metrics export) by extending the entry object and adding matching subpath exports in package.json.

3. Create tsconfig.json

{
  "extends": "@neutrino-io/tsconfig/base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

4. Export a clean public API

// src/index.ts
export { MyThing } from "./my-thing.js";
export type { MyThingOptions } from "./my-thing.js";

Keep the barrel lean — only export what downstream consumers need. Use .js extensions in imports (required for ESM in TypeScript projects targeting Node/Workers).

5. Add subpath exports if needed

If the package has logically separate entry points (like @neutrino-io/logger/metrics), add them to both exports in package.json and the entry map in tsup.config.ts.

6. Build and consume

Build in dependency order when working across the stack:

pnpm --filter @neutrino-io/core build
pnpm --filter @neutrino-io/sdk build
pnpm --filter @neutrino-io/<name> build

Reference it from other packages as "@neutrino-io/<name>": "workspace:*" in dependencies.

7. Publishing

Packages are versioned with Changesets. Never manually bump version in package.json.

# Describe your change
pnpm changeset

# Commit the generated .changeset file
git add .changeset && git commit -m "chore(<name>): add changeset"

Merging to main triggers CI, which opens a "Version Packages" PR or publishes directly to GitHub Packages.


Adding a Built-in Feature

Built-in features are React UI packages that the console shell auto-discovers at build time. They live under features/ and must satisfy two hard requirements: the "neutrino": \{"type": "feature"\} field in package.json, and a featureManifest export from src/index.ts.

1. Scaffold the package

features/<name>/
├── package.json
├── tsconfig.json
├── tsup.config.ts
└── src/
    ├── index.ts        # public barrel — must export featureManifest
    ├── manifest.ts     # featureManifest definition
    ├── feature.ts      # FeatureDefinition
    ├── hooks/
    ├── components/
    └── pages/

2. package.json — required fields

{
  "name": "@neutrino-io/feature-<name>",
  "version": "0.1.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist/**"],
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch",
    "clean": "rm -rf dist",
    "typecheck": "tsc --noEmit",
    "lint": "eslint"
  },
  "dependencies": {
    "@neutrino-io/sdk": "workspace:*",
    "@neutrino-io/ui-core": "workspace:*"
  },
  "devDependencies": {
    "@neutrino-io/eslint-config": "workspace:*",
    "@neutrino-io/tsconfig": "workspace:*",
    "@types/react": "catalog:react19",
    "@types/react-dom": "catalog:react19",
    "tsup": "catalog:",
    "typescript": "catalog:"
  },
  "peerDependencies": {
    "react": ">=19",
    "react-dom": ">=19"
  },
  "neutrino": {
    "type": "feature"
  }
}

The "neutrino": \{"type": "feature"\} field is what the Vite plugin scans for at build time. Without it the feature will not be registered.

3. Export featureManifest

// src/index.ts
export { featureManifest } from "./manifest.js";
export { myFeatureDefinition } from "./feature.js";

// hooks, components, pages …
// src/manifest.ts
import type { FeatureManifest } from "@neutrino-io/sdk";

export const featureManifest: FeatureManifest = {
  id: "<name>",
  displayName: "<Display Name>",
  // routes, nav items, permissions — see sdk types for the full shape
};

4. Auto-discovery

The Vite plugin resolves virtual:feature-registry at build time by scanning all installed @neutrino-io/feature-* packages for the neutrino.type === "feature" marker. No manual registration is needed — building the package and adding it as a workspace dependency in apps/console/package.json is sufficient.

For detailed guidance on hooks, state management, and component patterns within a feature package see the Feature Development Guide.


Database Conventions

ServiceApproach
services/iamDrizzle ORM
services/registryDrizzle ORM
All other servicesRaw Cloudflare D1 API

Use raw D1 for new services unless there is a specific reason to reach for Drizzle (complex schema, many relations, codegen benefit). The raw API keeps the Worker bundle small and avoids the Drizzle query-builder abstraction layer.

Raw D1 example:

const result = await c.env.DB.prepare(
  "SELECT * FROM subscriptions WHERE platform_id = ?1 LIMIT ?2"
)
  .bind(platformId, limit)
  .all<Subscription>();

Migration workflow:

# Create a new migration file
touch services/<name>/migrations/000N_<description>.sql

# Apply to staging
pnpm --filter @neutrino-io/service-<name> with-env-stg \
  wrangler d1 migrations apply nno-k3m9p2xw7q-<name>-db-stg --env stg

# Apply to production
pnpm --filter @neutrino-io/service-<name> with-env \
  wrangler d1 migrations apply nno-k3m9p2xw7q-<name>-db

Pagination: cursor-based only on all list endpoints. Offset pagination is not permitted. A typical cursor pattern:

const rows = await env.DB.prepare(
  "SELECT * FROM items WHERE id > ?1 ORDER BY id ASC LIMIT ?2"
)
  .bind(cursor ?? "", limit + 1)
  .all<Item>();
const hasMore = rows.results.length > limit;
const nextCursor = hasMore ? rows.results[limit - 1].id : null;

For schema and migration architecture rules see docs/architecture/README.md.


Testing Strategy

All services and packages use Vitest.

Where tests live: co-locate test files alongside source (my-module.test.ts) or group them under __tests__/. Both conventions exist in the codebase — be consistent within a given package.

Run tests:

# All packages (Turborepo orchestrated)
pnpm test

# Single package
pnpm --filter @neutrino-io/service-<name> test

# Watch mode
pnpm --filter @neutrino-io/service-<name> test:watch

# Coverage
pnpm --filter @neutrino-io/service-<name> test:coverage

What to test: unit-test pure functions and schema transformations directly. For Hono route handlers, use app.request() to exercise the full handler stack without deploying:

import { describe, it, expect } from "vitest";
import app from "../src/index";

describe("GET /health", () => {
  it("returns ok", async () => {
    const res = await app.request("/health");
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.status).toBe("ok");
  });
});

New features and bug fixes must include tests. Coverage targets and CI patterns are documented in .agents/rules/project/project-testing.md.


CI/CD Pipeline

Turborepo orchestrates the pipeline on every push. Tasks run in dependency order with output caching:

build (^build) -> typecheck -> lint -> test -> deploy (build + test)

The ^build dependency means a package will not be type-checked or tested until all of its upstream workspace dependencies have been built. This is why you must build packages in order when working locally across multiple packages.

On merge to main:

  1. Turborepo runs the full pipeline (build, typecheck, lint, test)
  2. Changed @neutrino-io/* packages are published to GitHub Packages if a Changeset is present
  3. Workers are deployed via wrangler deploy

Environment promotion: deploy to staging first, verify, then promote to production. The deploy:staging and deploy:production scripts in each service's package.json map to the stg and default (prod) Wrangler environments.


Code Quality Gates

All of the following must pass before opening a PR:

pnpm --filter <scope> typecheck   # TypeScript — zero errors
pnpm --filter <scope> lint        # ESLint — zero warnings
pnpm test                         # Vitest — all tests pass
pnpm build                        # Full Turborepo build

Auto-fix formatting:

pnpm --filter <scope> lint --fix
pnpm format

Pre-commit hooks (Husky + lint-staged) enforce formatting on staged files automatically.

For the full conventions — naming, branching, commit messages, PR checklist — see CONTRIBUTING.md.

On this page