Skip to content

everything-dev

A consolidated product package for building Module Federation apps with oRPC APIs.

extends-config

237 linesSource

How the authored config's extends chains work (bos.app.ts preferred; legacy bos.config.json supported), deep merge semantics, resolved config lifecycle, env-specific extends, and canonical field ordering. Use when debugging extends inheritance, configuring per-environment parents, understanding what dev writes vs publish writes, or reasoning about config merging.

Config form: the authored config is bos.app.ts (canonical, preferred when both exist). A legacy bos.config.json is still supported for older children. Where this doc says bos.config.json for the local authored file, read "the authored config". The published artifact on FastKV keeps the key name bos.config.json.

extends & Config Merging

extends Field

The extends field in the authored config (bos.app.ts, or legacy bos.config.json) specifies a parent config to inherit from. Supports two forms:

String (all environments use same parent)

json
{ "extends": "bos://dev.everything.near/everything.dev" }

Object (per-environment parent)

json
{
  "extends": {
    "development": "bos://dev.everything.near/everything.dev",
    "production": "bos://dev.everything.near/everything.dev",
    "staging": "bos://staging.everything.near/everything.dev"
  }
}

Fallback chain: requested env → production → first defined value.

platform pin (ADR 0034)

The root-level platform field pins the framework release this runtime tracks:

json
{ "platform": "2.0.0-rc.14" }
  • Exact string in v1 — no ranges. Resolution picks exactly one platform release manifest (the root's published platform.json); a pin that no published manifest satisfies fails loud. The slot is a pointer: a root republish overwrites it, so a stale pin survives only while the fresh .bos/platform-manifest.json cache (24h TTL) stands in — past that, resolution fails loud and bos upgrade (the re-pin pathway) is the repair (#546).
  • Absent → latest via the extends chain (nearest ancestor publishing a manifest wins). Sync/upgrade resolve it explicitly — they read the authored config, not just the merged result.
  • It is a scalar for merge purposes: child overrides parent, parent inherited when child omits.
  • The field is on sync's managed-key list, so it survives template personalization.
  • Everything else derives from the resolved manifest: the upgrade catalog, the image ref (ref@v<version>), the release-scoped sync source, and the compat contract. Offline dev caches the resolved manifest at .bos/platform-manifest.json (gitignored memo).
  • Inspect with bos platform manifest (--check adds the compat gate); bos upgrade --check is the manual drift alarm against the latest release.

Deep Merge Semantics

Uses defu (with createDefu for custom merge rules):

Field typeMerge behavior
Scalars (account, domain, repository)Child overrides parent; parent inherited when child omits
app.api.shared, app.auth.shared, plugins.*.shared dep entriesDeep merged — child overrides specific keys, parent deps preserved
pluginsDeep merged — child overrides per-key, parent plugins preserved unless removed
secrets arraysUnioned (deduplicated)
routes arraysChild replaces parent
variablesDeep merged per-key

Null Sentinel Removal

Set a plugin to null to explicitly remove an inherited plugin:

json
{
  "plugins": {
    "template": null
  }
}

Plugin UI Opt-Out

A plugin entry that extends another app (extends: "bos://acct/app#plugins.<id>") also attaches that app's app.ui as the plugin's ui. Set ui: false on the entry to take only the plugin and drop the attached ui:

ts
plugins: {
  nostr: { extends: "bos://nearbuilding.near/nostr.nearbuilders.org#plugins.nostr", ui: false },
}

Fixed-Core Tenant Mode

For shared-host tenant setups, extends is both the config inheritance mechanism and the lineage edge between runtimes.

Example:

json
{
  "extends": "bos://pingpayio.near/pingpay.io",
  "account": "pizza.pingpayio.near",
  "domain": "pizza.com"
}

Use this mental model:

  • extends says which runtime this one remixes or inherits from
  • account is the tenant namespace root for this runtime when it is served as a base app
  • domain is the public ingress for this runtime
  • a runtime can be a child in lineage and still become a new tenant root on its own domain

Tenant permissions come from the DB, not env vars. The host fetches the tenant bindings map from the API's GET /tenants/bindings endpoint (cached for 30s), and the tenant record's allow flags gate what may be overridden and whether SSR is permitted.

Design target for request mapping:

  • pizza.com -> base runtime bos://pizza.pingpayio.near/pizza.com
  • chicago.pizza.com -> bos://chicago.pizza.pingpayio.near/pizza.com

Current implementation note:

  • the host still applies a single request-scoped tenant overlay on top of one process-wide base runtime
  • nested label routing and account-relative tenant derivation are the intended direction, but not the complete runtime behavior today

What the tenant may override today

In fixed-core mode, the host keeps the server core from the active base runtime and only applies request-scoped UI-facing overrides from the child config:

  • app.ui
  • existing plugins.<id>.ui
  • existing plugins.<id>.sidebar

The tenant config must extend the base BOS runtime. Tenant API/auth overrides and dynamic new plugin IDs are not part of this mode.

SSR behavior

Tenant SSR is gated separately from inheritance:

  • a tenant may SSR only when its record has allow_ssr set
  • tenants without allow_ssr fall back to client rendering

Resolved Config: .bos/bos.resolved-config.json

Generated by: bos dev, bos build, syncResolvedSharedDeps() Gitignored: Yes (inside .bos/)

When bos dev or bos build runs:

  1. The full extends chain is resolved in memory
  2. The merged config is written to .bos/bos.resolved-config.json
  3. The authored config is NOT modified during dev

Structure:

json
{
  "_resolved": {
    "env": "development",
    "resolvedAt": "2026-05-11T...",
    "extendsChain": ["bos://dev.everything.near/everything.dev"]
  },
  "account": "me.near",
  "domain": "my.dev",
    "app": { ... },
    "plugins": { ... }
  }

Build configs read resolved config first

All build configs (ui/rsbuild.config.ts, host/rsbuild.config.ts, api/rspack.config.js, plugins/*/rspack.config.js) try .bos/bos.resolved-config.json first, falling back to the authored config.

The _resolved metadata is stripped before use.

When the authored config IS written

CommandWrites the authored config?Why
bos devNoUses resolved config
bos buildNoUses resolved config
bos publish --deployYesSnapshot moment — pins production URLs + versions
bos plugin publishYesRecords production URL + integrity
bos plugin add/removeYesChanges project's own plugin list
bos upgradeYesStamps/moves the platform pin (ADR 0034)

Remote host mode (bos->catalog)

When host is remote, syncResolvedSharedDeps() reads versions from the authored config and writes them into package.json catalog. No resolved config is written — the remote host reads the published config directly.

_resolved.resolvedAt

The resolvedAt timestamp in .bos/bos.resolved-config.json is metadata for debugging only. It is not used for staleness detection — the CLI always re-resolves on bos dev / bos build.

Circular Extends Detection

If the extends chain contains a cycle (e.g., A→B→A), the resolver detects it during config loading and throws an error listing the cycle path. This prevents infinite recursion during merge.

Detection occurs in resolveExtendsRef() → mergeBosConfigWithExtends(). The extends chain is tracked as a set of visited BOS refs; a duplicate visit triggers the error.

Canonical Field Ordering

BOS_CONFIG_ORDER enforces consistent key order:

  1. extends — always first
  2. account
  3. domain
  4. title
  5. description
  6. testnet
  7. staging
  8. repository
  9. publish
  10. platform
  11. ci
  12. app
  13. plugins
  14. rolledBackFrom

Unknown keys go after known keys. rebuildOrderedConfig() is applied before every write.

API

From packages/everything-dev/src/config.ts:

  • writeResolvedConfig(configDir, config, env, extendsChain?) — writes .bos/bos.resolved-config.json
  • loadResolvedConfig(configDir) — reads resolved config, returns BosConfig | null
  • resolveBosConfigPath(configDir) — returns resolved config path if exists, else the authored config
  • readBosConfigForBuild(configDir) — reads resolved config stripping _resolved, falls back to the authored config

From packages/everything-dev/src/services/resolution/merge.ts:

  • mergeBosConfigWithExtends(parent, child) — deep merge for extends chain
  • mergeBosConfigWithTemplate(local, template) — merge for sync (local wins)
  • resolveExtendsRef(extendsField, env) — resolve string|object extends for a given env
  • rebuildOrderedConfig(config) — enforce canonical ordering
  • BOS_CONFIG_ORDER — ordered field names

The tasks you will actually be given

"Remove a plugin I inherited." Set it to null in the authored config's plugins — the null sentinel deletes it from the merged result. Deleting the entry entirely just inherits the parent's.

"Use another app's plugin without its website." Set ui: false on the plugins.<id> entry — the extended app's app.ui is otherwise attached as the plugin ui and loaded on every page.

"Different parent for staging." Object-form extends keyed by env (development / staging / production); the fallback chain is requested env → production → first defined value.

"Why doesn't the runtime see my config edit?" Dev reads .bos/bos.resolved-config.json (regenerated on every bos dev); production reads FastKV. The authored config is only the publish input — bos publish is the write.

What comes back when it refuses

worddo
CircularExtendsError — "Circular extends detected: A -> B -> A"stop — the extends chain loops; restructure so no runtime extends itself transitively
Circular dependency detected among: … dependsOnbuild-order cycle in the build train — fix the dependsOn declarations in the authored config
inherited plugin still present after "removing" itdeep merge keeps parent plugins unless nulled — you need the explicit null sentinel, keyed exactly as the parent's plugin key
your section is missing from .bos/bos.resolved-config.jsonordering never drops keys — if it's absent, the authored config or its parent didn't define it; check the extends chain with bos config --full