12 KiB
@suaveplan/i18n-backend-fs-multifile — Node fs/promises multi-file translation backend
Package: @suaveplan/i18n-backend-fs-multifile (NEW, server tier)
Status: PROPOSED
Type: feature (new server-tier package; verbatim port from monolith)
Date: 2026-05-05
Wave: 5
Design doc: openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md
Path: packages/i18n/i18n-backend-fs-multifile/
Why
The Wave 5 backend split lifts each Backend implementation from the @suaveplan/i18n monolith. The multi-file-fs backend is the canonical server-tier source — it reads <root>/<locale>/<namespace>.<ext> (nested), <root>/<locale>.<namespace>.<ext> (flat), or a caller-supplied glob pattern from the local filesystem via node:fs/promises. It optionally watches the root directory via fs.watch and emits loaded events through the catalog event bus for HMR / Suspense refresh. It auto-detects yaml, json5, and (planned) gettext-parser peer parsers via dynamic import() so consumers can pick a serialisation format without forcing a hard install on JSON-only deployments.
Tier split rationale (server-only, NOT universal)
This is the only Wave 5 backend that is not universal-tier. Three reasons compel a server-only tier:
- Hard
node:fs/promisesimport. The default#getFs()path performsawait import("node:fs/promises")which fails at module evaluation in browsers and rejects withERR_MODULE_NOT_FOUNDin non-Node runtimes that do not expose Node built-ins (Cloudflare Workers, Deno without--allow-read). - Universal-tier consumers must not pull
node:fs. Even with tree-shaking, leaving this backend in the universal tier would break browser bundlers that resolvenode:schemes at build time. Splitting it into a server-only package eliminates the risk. - Path-traversal surface is server-shaped. The backend resolves
<root>/<locale>/<namespace>.<ext>against a host filesystem; the security model is "trust the catalog config", which only makes sense in server / build-time contexts. Browser-tier consumers should use@suaveplan/i18n-backend-httpor@suaveplan/i18n-backend-storageagainstOPFSStorageinstead.
The package.json SHALL declare "engines": { "node": ">=20" } and SHALL NOT declare a "browser" field. A leading source comment in src/backend.ts SHALL state explicitly: // Server-tier package — uses node:fs/promises. Browser consumers: use @suaveplan/i18n-backend-http or @suaveplan/i18n-backend-storage.
The Backend interface itself moves to @suaveplan/types/i18n in Wave 1. @suaveplan/i18n-backend-fs-multifile implements Backend from that location and does NOT depend on @suaveplan/i18n core. Runtime atoms come from @suaveplan/i18n-shared.
What changes
New package: @suaveplan/i18n-backend-fs-multifile
- ADDED:
packages/i18n/i18n-backend-fs-multifile/package scaffold (package.json, tsconfig.json, vite.config.ts, README.md).package.jsondeclares"engines": { "node": ">=20" }and omits"browser"field. - ADDED:
src/backend.ts—MultiFileFsBackendclass implementingBackendfrom@suaveplan/types/i18n. Source:packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.tsL67-L284. Capabilities:canRead: true,canReadMulti: false,canSave: true,canTrackModification: true,canWatchfollows thewatchoption. - ADDED:
src/backend.types.ts—MultiFileFsBackendOptions,MultiFileFsLayout,MultiFileFsLike. Source: monolith L15-L51. - ADDED:
src/backend.schemas.ts—MultiFileFsBackendOptionsSchemaZod schema. Source: monolithmulti-file-fs-backend.schemas.ts(15 lines). - ADDED:
src/factory.ts—multiFileFsBackend(options: MultiFileFsBackendOptions): MultiFileFsBackendfactory. Source: monolith L403-L407. - ADDED:
src/path-resolver.ts—resolvePath(root, layout, extension, globPattern, locale, namespace): stringextracted from monolith#resolvePathfor tree-shaking + isolated testing. Source: monolith L190-L205. - ADDED:
src/payload-codec.ts—parsePayload,parseJson,parseWithPeer,encodePayloadlifted as standalone helpers. Source: monolith L313-L400. - ADDED:
src/fs-error-classifier.ts—classifyFsError(err, path)lifted; mapsENOENT/ENOTDIRtoBACKEND_LOCALE_NOT_AVAILABLEand other errno codes toBACKEND_READ_FAILED. Source: monolith L297-L311. - ADDED:
src/observability.ts— emitsi18n.backend.fs_multifile.read_duration_ms,i18n.backend.fs_multifile.read_total{outcome,layout},i18n.backend.fs_multifile.read_errors_total{code,layout},i18n.backend.fs_multifile.write_duration_ms,i18n.backend.fs_multifile.write_total{outcome,layout}. - ADDED:
src/backend.test.ts,src/backend.spec-conformance.test.ts,src/factory.test.ts,src/path-resolver.test.ts,src/payload-codec.test.ts,src/fs-error-classifier.test.ts,src/index.test.ts— Vitest suites verbatim-ported from monolith plus new spec-conformance suites. - ADDED:
src/backend.md,src/factory.md,src/path-resolver.md,src/payload-codec.md,src/fs-error-classifier.md— co-located docs. - ADDED:
src/index.ts— explicit named re-exports.
Subpath exports
.— root barrel./backend—MultiFileFsBackendclass only./factory—multiFileFsBackendfactory only./path-resolver—resolvePathhelper only./payload-codec—parsePayload,parseJson,parseWithPeer,encodePayloadonly./fs-error-classifier—classifyFsErroronly
Dependencies
@suaveplan/i18n-shared workspace:^— forI18N_CODES,I18nError@suaveplan/types workspace:^— forBackend,BackendCapabilities,BackendInitContext,TranslationRecordfrom@suaveplan/types/i18n@suaveplan/error workspace:^@opentelemetry/api catalog:observabilitytslib catalog:buildzod catalog:forms— forMultiFileFsBackendOptionsSchema- No dep on
@suaveplan/i18ncore. No dep on any other backend package. No dep onyaml/json5(auto-detected via dynamicimport(); consumers install the peer they need; the backend surfaces a clearBACKEND_READ_FAILEDwith the missing-peer name when the peer is unavailable).
Tier
Server-only. Declares "engines": { "node": ">=20" }. Omits "browser" field. Source files carry a leading tier-marker comment.
Out of scope
- Removing the multi-file-fs backend from
packages/i18n/i18n/src/core/backend/multi-file-fs/— happens in Wave 6. - Bundling
yaml/json5as direct deps — explicit non-goal; peer-detection keeps the package lean and avoids forcing a parser choice on JSON-only consumers. - Browser-tier compatibility — explicit non-goal (see tier split rationale); browser consumers use
@suaveplan/i18n-backend-httpor@suaveplan/i18n-backend-storage. - Atomic file writes / lock files — out of scope; consumers who need atomic semantics use
StorageBackendagainst an atomicAbstractStorage. - Recursive directory watch —
fs.watchsemantics are platform-dependent; the watcher emits events for the root directory only.
Source mapping
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
MultiFileFsBackend class + capabilities matrix |
packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.ts |
L67-L109 | Verbatim port; rewire imports |
init (sets context, optionally starts watch) |
same file | L111-L116 | Verbatim port |
dispose (aborts watcher) |
same file | L118-L121 | Verbatim port |
read (resolve → readFile → parsePayload → outcome telemetry) |
same file | L123-L152 | Verbatim port |
save |
same file | L154-L174 | Verbatim port |
lastModified |
same file | L176-L188 | Verbatim port |
#resolvePath (lifted to src/path-resolver.ts) |
same file | L190-L205 | Extracted |
#ensureDir |
same file | L207-L212 | Inline private method |
#getFs (dynamic node:fs/promises import) |
same file | L214-L240 | Verbatim port |
#startWatch / #runWatchLoop / #parseEventPath |
same file | L242-L283 | Verbatim port |
joinPath |
same file | L286-L295 | Inline helper |
classifyFsError (lifted to src/fs-error-classifier.ts) |
same file | L297-L311 | Extracted |
parsePayload, parseJson, parseWithPeer (lifted to src/payload-codec.ts) |
same file | L313-L386 | Extracted |
encodePayload (lifted to src/payload-codec.ts) |
same file | L388-L400 | Extracted |
MultiFileFsBackendOptions, MultiFileFsLayout, MultiFileFsLike types |
same file | L15-L51 | Verbatim port to backend.types.ts |
multiFileFsBackend factory |
same file | L403-L407 | Lifted to factory.ts |
MultiFileFsBackendOptionsSchema |
packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.schemas.ts |
full file (15 lines) | Verbatim port |
recordRead / recordWrite instrumentation pattern |
packages/i18n/i18n/src/observability/backend-metrics.ts |
full file | Reimplemented inline; metric names re-prefixed i18n.backend.fs_multifile.; layout attribute added |
Requirements with no source line
| Requirement | Justification |
|---|---|
Subpath exports ./backend, ./factory, ./path-resolver, ./payload-codec, ./fs-error-classifier |
Tree-shaking and reuse — codecs and resolvers are reusable in custom backends |
AbortSignal parameter on read / save |
Cancellation parity across backends |
| Server-tier marker (engines.node, no browser field, leading source comment) | Explicit tier declaration so universal-tier consumers cannot accidentally pull this package |
Peer-detection contract (BACKEND_READ_FAILED with peer name) |
Documented behaviour from monolith — split formalises it |
Verification approach
Each requirement carries a **Verified by:** line in the spec delta. Concrete test files:
packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts— verbatim port of monolithmulti-file-fs-backend.test.ts(556 lines); coversnested,flat,globlayouts;readhit / miss / parse-failure;saveround-trip;lastModified;init/disposelifecycle; injectedMultiFileFsLikeso tests do not touch real disk; YAML / JSON5 peer-detection happy path + missing-peer pathpackages/i18n/i18n-backend-fs-multifile/src/backend.spec-conformance.test.ts— every spec requirement mapped 1:1 to anit(...)titlepackages/i18n/i18n-backend-fs-multifile/src/factory.test.ts—multiFileFsBackend()returnsMultiFileFsBackendinstance; defaultname === "multi-file-fs"packages/i18n/i18n-backend-fs-multifile/src/path-resolver.test.ts— every layout produces the documented path shape;globsubstitutes{locale}/{namespace}packages/i18n/i18n-backend-fs-multifile/src/payload-codec.test.ts— JSON parse + reject array/null; YAML peer-detect happy path with stub; missing-peer path →BACKEND_READ_FAILEDwith peer name in context; unknown extension →BACKEND_READ_FAILEDpackages/i18n/i18n-backend-fs-multifile/src/fs-error-classifier.test.ts—ENOENT/ENOTDIR→BACKEND_LOCALE_NOT_AVAILABLE;EACCES→BACKEND_READ_FAILEDpackages/i18n/i18n-backend-fs-multifile/src/index.test.ts— every documented subpath resolves;package.jsondeclaresengines.node; nobrowserfield
Impact
- New server-tier package — joins the i18n family under
packages/i18n/. - Eliminates the universal-tier-leakage risk that the monolith carries today.
- Universal-tier root barrel of
@suaveplan/i18n(Wave 6) does NOT re-export this package — server-tier consumers import directly via@suaveplan/i18n-backend-fs-multifile. MANIFEST.mdregenerated;CLAUDE.mdpackage listing updated with a server-tier marker.