Creating a New Package
This guide covers how to add a new @rudderjs/* package to the monorepo. Follow these conventions so every package stays consistent, testable, and publishable.
Before you start: should this be a new package?
Apply the tight-coupling checklist from Architecture.md first. A new package is justified when:
- It has an adapter boundary (e.g., different databases, queues, cloud providers)
- It has a portability boundary (Node.js-only vs edge-compatible)
- It is independently useful without the rest of Rudder
- It would be optional for most apps
If the code is always deployed alongside an existing package and has no meaningful standalone behaviour, merge it instead.
Scaffold the package
cd packages
mkdir my-feature
cd my-featurepackage.json
{
"name": "@rudderjs/my-feature",
"version": "1.0.0",
"description": "One-line description.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": ["dist", "boost"],
"rudderjs": {
"provider": "MyFeatureProvider",
"stage": "feature",
"depends": ["@rudderjs/log"]
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"dev": "tsc -p tsconfig.build.json --watch",
"typecheck": "tsc --noEmit",
"test": "tsc -p tsconfig.test.json && node --test dist-test/index.test.js"
},
"dependencies": {},
"peerDependencies": {},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.4.0",
"tsx": "^4.0.0"
}
}The rudderjs field registers your package for provider auto-discovery: apps that call defaultProviders() boot your provider automatically once it's installed. Omit the field for pure utility packages that ship no service provider.
Dependency rules:
| Use | When |
|---|---|
dependencies |
Always needed at runtime |
peerDependencies |
Packages that @rudderjs/core itself loads at runtime (@rudderjs/router, @rudderjs/server-hono), or to keep a runtime-agnostic main entry |
devDependencies |
Build-time only: types, test utilities |
optionalDependencies |
Heavy drivers the user opts into (ioredis, @aws-sdk/client-s3) |
Most packages put
@rudderjs/coreindependenciesand import it directly: they sit downstream of core, so there's no cycle. The exception is a package that core itself loads at runtime, namely@rudderjs/routerand@rudderjs/server-hono. Those must usepeerDependenciesonly and resolve core lazily withresolveOptionalPeer('@rudderjs/core'), otherwise the DI container forms a circular dependency. A few packages also keep core as a peer for other reasons: a runtime-agnostic main entry (@rudderjs/ai) or to avoid bundling core into a build plugin (@rudderjs/vite).
TypeScript setup (three-config split)
Every package uses three tsconfig files so the editor, build, and test tasks each get the right settings.
tsconfig.json: editor / type-checking
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src", "src/**/*.test.ts"]
}tsconfig.build.json: production build
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}tsconfig.test.json: test compilation
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist-test",
"rootDir": "src"
},
"include": ["src"]
}Source conventions
File layout
packages/my-feature/
├── src/
│ ├── index.ts # public API: re-exports only, no logic here
│ ├── MyFeature.ts # main implementation
│ └── index.test.ts # tests (same directory, same rootDir)
├── tsconfig.json
├── tsconfig.build.json
├── tsconfig.test.json
└── package.jsonModule system
- All imports use
.jsextensions (NodeNext resolution):import { helper } from './helper.js' // ✓ import { helper } from './helper' // ✗ - Top-level
awaitis fine; all packages are ESM. - No CommonJS (
require,module.exports).
Strict TypeScript
All packages inherit tsconfig.base.json which enables:
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": truenoUncheckedIndexedAccess means array reads return T | undefined. Use non-null assertion (!) only when you have verified the index is in bounds, or use optional chaining.
Service Provider pattern
If your package needs to boot with the application, export a named ServiceProvider subclass. It reads its own config from the config system, so the framework can construct it with no arguments, which is what makes it auto-discoverable.
// src/index.ts
import { ServiceProvider, config } from '@rudderjs/core'
export interface MyFeatureConfig {
option: string
}
export class MyFeatureProvider extends ServiceProvider {
register(): void {}
async boot(): Promise<void> {
const cfg = config<MyFeatureConfig>('my-feature')
this.app.instance('my-feature', new MyFeature(cfg))
}
}The class name must match the provider value in your package.json rudderjs field.
Auto-discovery
Apps call defaultProviders() and receive every installed @rudderjs/* provider in stage and dependency order, read from each package's rudderjs manifest field, so a contributor never edits the app's providers.ts to wire in a new package. Declare it:
"rudderjs": {
"provider": "MyFeatureProvider",
"stage": "feature",
"depends": ["@rudderjs/log"]
}| Field | Purpose |
|---|---|
provider |
Name of the exported ServiceProvider subclass to instantiate |
stage |
Boot bucket, in order: foundation, infrastructure, feature, monitoring |
depends |
Other @rudderjs/* packages whose providers must boot first (resolved within the stage) |
providerSubpath |
Load the class from a subpath (e.g. "./server") instead of the main entry. Use when the main entry must stay runtime-agnostic (browser, RN, edge) |
autoDiscover |
Set false to opt out of discovery when the package needs explicit positioning or has boot side effects |
The manifest is baked into bootstrap/cache/providers.json and self-heals in dev; for bundled or serverless deploys re-bake it at build time with pnpm rudder providers:discover. See Service Providers for the full lifecycle.
Manual registration (opt-out)
Apps that don't use defaultProviders(), or a package set to autoDiscover: false, register explicitly. If your package takes per-app config as an argument rather than from a config key, also export a factory:
export function myFeature(config: MyFeatureConfig): typeof ServiceProvider {
return class extends ServiceProvider {
async boot() { this.app.instance('my-feature', new MyFeature(config)) }
}
}// app's providers.ts
import { myFeature } from '@rudderjs/my-feature'
export default [myFeature({ option: 'value' })]Testing
Use Node.js built-in node:test: no Jest, no Vitest.
// src/index.test.ts
import { describe, it, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { MyFeature } from './MyFeature.js'
describe('@rudderjs/my-feature', () => {
describe('MyFeature', () => {
it('does the thing', () => {
const f = new MyFeature({ option: 'test' })
assert.equal(f.result(), 'expected')
})
it('throws on invalid input', () => {
assert.throws(() => new MyFeature({ option: '' }), /invalid/)
})
})
})Testing rules:
- Always read the source before writing tests; test actual behaviour, not assumptions.
- One top-level
describeper file named after the package ('@rudderjs/my-feature'). This preventsnode:testconcurrent describe interference. - No mocking of internal modules; test real behaviour. Mock only external I/O (network, filesystem) when unavoidable.
- Run with
pnpm testfrom the package directory.
Optional peer resolution
When your package optionally integrates with another Rudder package, resolve it at runtime rather than importing it statically:
import { resolveOptionalPeer } from '@rudderjs/support'
// In a method, not at module level:
async function getOrm() {
const orm = await resolveOptionalPeer('@rudderjs/orm')
if (!orm) throw new Error('@rudderjs/orm is required for this feature')
return orm
}This avoids bundling packages the user may not have installed and prevents circular imports.
Bundled translations & overrides
If your package ships its own UI strings (UI chrome, buttons, toasts, error messages, anything the end user reads), follow the bundled defaults + JSON overrides convention so apps can localize your package without forking it.
1. Ship bundled defaults as TypeScript
Bundled translations are the canonical, type-safe schema. Keep them in src/i18n/:
// src/i18n/en.ts
export const en = {
signOut: 'Sign out',
search: 'Search :label…',
noResultsTitle: 'No results',
// …
}
export type MyPackageI18n = typeof en// src/i18n/ar.ts
import type { MyPackageI18n } from './en.js'
export const ar: MyPackageI18n = {
signOut: 'تسجيل الخروج',
search: 'بحث :label…',
noResultsTitle: 'لا توجد نتائج',
// …
}Add at least en (acts as the universal fallback). Keep the schema flat unless you have a real reason to nest; it's easier to override.
2. Provide a sync resolver with override support
// src/i18n/index.ts
import { en } from './en.js'
import { ar } from './ar.js'
import type { MyPackageI18n } from './en.js'
export type { MyPackageI18n }
const NAMESPACE = 'my-package' // matches lang/<locale>/my-package.json
const translations: Record<string, MyPackageI18n> = { en, ar }
const mergedCache = new Map<string, MyPackageI18n>()
export function getMyPackageI18n(locale: string): MyPackageI18n {
const cached = mergedCache.get(locale)
if (cached) return cached
const base = locale.split('-')[0] ?? locale
const bundled = translations[locale] ?? translations[base] ?? en
const override = getOverride(locale) ?? getOverride(base)
const merged = override ? deepMerge(bundled, override) : bundled
mergedCache.set(locale, merged)
return merged
}
function getOverride(locale: string): Partial<MyPackageI18n> | undefined {
const g = globalThis as Record<string, unknown>
const cache = g['__rudderjs_localization_cache__'] as Map<string, unknown> | undefined
const data = cache?.get(`${locale}:${NAMESPACE}`) as Partial<MyPackageI18n> | undefined
return data && Object.keys(data).length > 0 ? data : undefined
}
// deepMerge implementation, see packages/panels/src/i18n/index.ts
/** @internal: for tests + HMR */
export function _clearI18nCache(): void { mergedCache.clear() }The resolver must be sync because UI render paths (React components, schema resolvers) can't await. The merge happens once per locale, then it's cached.
3. Preload at boot from your service provider
getMyPackageI18n() is sync, so the override file has to be in @rudderjs/localization's cache before the first render. Preload it from your provider's boot():
// src/MyPackageServiceProvider.ts
import { ServiceProvider } from '@rudderjs/core'
import { _clearI18nCache } from './i18n/index.js'
async function preloadTranslations(): Promise<void> {
try {
const loc = await import('@rudderjs/localization') as {
preloadNamespace?: (locale: string, namespace: string) => Promise<void>
LocalizationRegistry?: { getConfig(): { locale: string; fallback: string } }
}
if (!loc.preloadNamespace || !loc.LocalizationRegistry) return
const { locale, fallback } = loc.LocalizationRegistry.getConfig()
await loc.preloadNamespace(locale, 'my-package')
if (fallback && fallback !== locale) {
await loc.preloadNamespace(fallback, 'my-package')
}
// Drop anything merged before the override landed in cache.
_clearI18nCache()
} catch {
// @rudderjs/localization not installed; fall back to bundled defaults.
}
}
export class MyPackageServiceProvider extends ServiceProvider {
async boot(): Promise<void> {
await preloadTranslations()
// …rest of your boot logic
}
}The dynamic import + try/catch makes @rudderjs/localization an optional peer dependency: your package still works standalone.
4. Declare the optional peer
// package.json
{
"peerDependencies": {
"@rudderjs/localization": "workspace:^"
},
"peerDependenciesMeta": {
"@rudderjs/localization": { "optional": true }
},
"devDependencies": {
"@rudderjs/localization": "workspace:*"
}
}workspace:^ on the peer dep publishes as a ^X.Y.Z range so consumer apps install compatible minors without spurious major bumps; the devDep stays workspace:* for in-monorepo type resolution.
The devDep is required so TypeScript can resolve types in the workspace.
5. Publish a starter override file
Create an empty lang/en/my-package.json in your package and register it as publishable so users can scaffold it via the CLI:
// In your service provider's register()
const langDir = new URL('../lang/en', import.meta.url).pathname
this.publishes([
{ from: langDir, to: 'lang/en', tag: 'my-package-translations' },
])Add "lang" to your package.json files array so the directory ships to npm. Users then run:
pnpm rudder vendor:publish --tag=my-package-translations6. Serialize the merged i18n to the client: never recompute
This is the gotcha that bites every time. @rudderjs/localization reads files via node:fs/promises, so its cache only exists on the server. If your React components call getMyPackageI18n(locale) themselves on the client, they'll get bundled defaults (the cache is empty there) and overwrite the SSR'd HTML during hydration.
Always pass the merged i18n object down through the page-data layer:
// Server: in your page +data.ts or meta route
const i18n = getMyPackageI18n(locale)
return { i18n, locale, /* … */ }
// Client: consume from props, do not recompute
function MyProvider({ i18n, children }: { i18n: MyPackageI18n; children: ReactNode }) {
return <Context.Provider value={i18n}>{children}</Context.Provider>
}If you have both a "navigation" meta and a "full" meta shape (like Panel.toNavigationMeta() vs toMeta()), make sure the layout's data source includes i18n; otherwise the client falls back to bundled defaults silently.
7. Document override keys
Point users at your bundled en.ts as the canonical key list; that's the source of truth.
8. Naming conventions
| Concept | Convention |
|---|---|
| Override file | lang/<locale>/<package-short-name>.json |
| Localization namespace | <package-short-name> (matches the file basename) |
| Vendor publish tag | <package-short-name>-translations |
| Bundled defaults | src/i18n/<locale>.ts, schema in en.ts |
| Resolver function | get<PackageName>I18n(locale) |
Pick a short, distinct name so multiple packages don't collide on the same override file (e.g. @your-org/panels → panels, @your-org/billing → billing).
Exports checklist
src/index.ts should export everything a user needs, and nothing internal:
// ✓ Export the public class
export { MyFeature } from './MyFeature.js'
// ✓ Export the factory function (ServiceProvider pattern)
export { myFeature } from './provider.js'
// ✓ Export config and result types
export type { MyFeatureConfig, MyFeatureResult } from './types.js'
// ✗ Do not export internal helpers, test utilities, or private classesAdd to the monorepo
- Add the package to
pnpm-workspace.yamlif not using the globpackages/*. - Run
pnpm installfrom the root to link workspace dependencies. - Run
pnpm buildfrom the root before using it in the playground. - Add an entry to
docs/packages/index.mdand create a doc page.
Publishing
# From the repo root: creates a changeset describing what changed
pnpm changeset
# Bump versions and update CHANGELOGs
pnpm changeset:version
# Build + publish all changed packages
pnpm releaseFor a one-off publish of a single package:
cd packages/my-feature
pnpm publish --access public --no-git-checksnpm requires browser passkey auth; press Enter when prompted.