@vielzeug/codex 3.1.1 → 26.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/data/catalog.json +166 -84
- package/data/llms-full.txt +3492 -1583
- package/data/llms.txt +9 -7
- package/data/manifest.json +1 -1
- package/data/packages/arsenal.json +40 -16
- package/data/packages/assay.json +12 -20
- package/data/packages/clockwork.json +31 -28
- package/data/packages/codex.json +5 -5
- package/data/packages/coins.json +4 -4
- package/data/packages/conduit.json +33 -34
- package/data/packages/courier.json +18 -17
- package/data/packages/dnd.json +11 -11
- package/data/packages/familiar.json +27 -6
- package/data/packages/flux.json +11 -8
- package/data/packages/focus.json +17 -7
- package/data/packages/forge.json +4 -4
- package/data/packages/gesture.json +11 -5
- package/data/packages/herald.json +16 -17
- package/data/packages/illusionist.json +5 -5
- package/data/packages/keymap.json +16 -12
- package/data/packages/ledger.json +26 -26
- package/data/packages/lingua.json +4 -4
- package/data/packages/mesh.json +30 -32
- package/data/packages/necromancer.json +7 -6
- package/data/packages/orbit.json +11 -10
- package/data/packages/ore.json +6 -5
- package/data/packages/postmaster.json +26 -26
- package/data/packages/prism.json +50 -42
- package/data/packages/pulse.json +34 -34
- package/data/packages/refine.json +7 -5
- package/data/packages/ripple.json +4 -4
- package/data/packages/rune.json +9 -9
- package/data/packages/sandbox.json +4 -4
- package/data/packages/scout.json +17 -11
- package/data/packages/scroll.json +14 -14
- package/data/packages/sentinel.json +26 -15
- package/data/packages/sigil.json +6 -6
- package/data/packages/sourcerer.json +4 -4
- package/data/packages/spell.json +31 -36
- package/data/packages/tandem.json +29 -0
- package/data/packages/tavern.json +23 -0
- package/data/packages/tempo.json +45 -43
- package/data/packages/vault.json +15 -25
- package/data/packages/ward.json +27 -30
- package/data/packages/wayfinder.json +34 -27
- package/data/refine.json +987 -134
- package/data/search.json +341 -306
- package/dist/http.js +2 -2
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.js +3 -4
- package/dist/index.js.map +1 -1
- package/dist/refine.d.ts +1 -1
- package/dist/refine.js +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/tools/refine.js +1 -1
- package/package.json +5 -12
- package/skills/vielzeug/SKILL.md +57 -55
- package/dist/advanced.d.ts +0 -12
- package/dist/advanced.js +0 -13
- package/dist/advanced.js.map +0 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"apiSource": "export { catalogKeys } from './catalog';\nexport {\n LinguaDisposedError,\n LinguaError,\n LinguaInvalidCatalogError,\n LinguaInvalidLocaleError,\n LinguaInvalidPluralCountError,\n LinguaInvalidStateError,\n LinguaMissingCatalogError,\n LinguaMissingKeyError,\n LinguaMissingValueError,\n} from './errors';\nexport { createI18n, type I18n, type I18nSnapshot } from './i18n';\nexport { createTranslator, type Translator } from './translator';\nexport type {\n Catalog,\n CatalogNode,\n Catalogs,\n I18nOptions,\n Locale,\n MessageKey,\n MissingHandler,\n MissingInfo,\n MissingStrategy,\n Part,\n PluralCategory,\n PluralKey,\n PluralMessage,\n PluralOptions,\n SubscribeOptions,\n TextKey,\n TextPart,\n TranslateOptions,\n TranslationState,\n TranslatorOptions,\n ValuePart,\n Values,\n} from './types';\n",
|
|
3
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Lingua
|
|
5
|
-
"api": "---\ntitle: Lingua — API Reference\ndescription: Complete API reference for @vielzeug/lingua.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createTranslator()` | Compile one immutable locale catalog | Sync | No fallback locales |\n| `createI18n()` | Create mutable locale and catalog store | Sync | Provide catalogs, serialized state, or a loader |\n| `createFormatter()` | Create a cached Intl facade from `/format` | Sync | Pass a locale getter for dynamic locale changes |\n| `catalogKeys()` | Enumerate message keys as dotted paths | Sync | Accepts i18n instance (current locale) or raw catalog; traverse subtrees for group-scoped keys |\n| `validateCatalog()` | Check explicit plural forms from `/validate` | Sync | Import from subpath |\n| `compareCatalogs()` | Compare key parity across locales from `/validate` | Sync | First locale is the base; import from subpath |\n| `LinguaError` | Base class for Lingua errors | Sync | Use `instanceof LinguaError` for broad narrowing |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/lingua` | Translation factories, state types, and Lingua errors |\n| `@vielzeug/lingua/format` | Standalone cached Intl formatter factory |\n| `@vielzeug/lingua/validate` | `validateCatalog()`, `compareCatalogs()`, and `ValidationIssue` |\n\n## Translation Factories\n\n### createTranslator\n\n```ts\nfunction createTranslator<C extends Catalog>(catalog: C, options?: TranslatorOptions): Translator<C>;\n```\n\nCompiles one catalog and returns an immutable fixed-locale translator. Locale defaults to `en` and controls plural selection and diagnostics.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `catalog` | `C` | One catalog containing only messages and grouping objects |\n| `options` | `TranslatorOptions` | Locale and missing-message strategy |\n\n**Returns:** `Translator<C>`.\n\n**Example:**\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst translator = createTranslator(\n { save: 'Enregistrer' },\n { locale: 'fr' },\n);\n\ntranslator.translate('save');\n```\n\n| Method | Signature | Returns |\n| --- | --- | --- |\n| `translate` | `(textKey, options?)` or `(pluralKey, { count, ordinal?, values? })` | Rendered string |\n| `translateDynamic` | `(key, options?)` | Rendered string for runtime key |\n| `parts` | `(textKey, { values })` or `(pluralKey, { count, ordinal?, values? })` | Typed discriminated parts |\n| `partsDynamic` | `(key, options)` | Parts for runtime key |\n| `locale` | `Locale` | Resolved active locale |\n\n---\n\n### createI18n\n\n```ts\nfunction createI18n<C extends Catalog>(options: I18nOptions<C>): I18n<C>;\n```\n\nCreates catalog store, current locale state, and immutable translator snapshots.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options.catalogs` | `Catalogs<C>` | Eager locale catalogs; mutually exclusive with `state` |\n| `options.locale` | `Locale` | Initial locale; defaults to serialized locale, then `en` |\n| `options.loadCatalog` | `(locale) => Promise<C> \\| C` | Optional loader for catalogs not supplied eagerly |\n| `options.fallback` | `Locale \\| readonly Locale[]` | Fallback locales loaded with the selected locale |\n| `options.missing` | `MissingStrategy` | Missing-key/value strategy |\n| `options.state` | `TranslationState<C>` | Eager serialized state; mutually exclusive with `catalogs` |\n\nProvide `catalogs`, `state`, or `loadCatalog`. You may combine eager `catalogs` or hydrated `state` with `loadCatalog` for later locales.\n\n**Returns:** `I18n<C>`, with every `Translator<C>` method plus lifecycle methods.\n\n**Example:**\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: { en: { title: 'Home' } },\n locale: 'en',\n});\ni18n.translate('title');\n```\n\n| Method or property | Signature | Returns |\n| --- | --- | --- |\n| `translate` | Translator method | Rendered string |\n| `parts` | Translator method | Typed discriminated parts |\n| `load` | `({ locale? })` | `Promise<void>` after the locale and configured fallbacks load |\n| `setLocale` | `(locale)` | `Promise<void>` after an atomic loaded-locale commit |\n| `isLoaded` | `({ locale? })` | `boolean` |\n| `getSnapshot` | `()` | `I18nSnapshot<C>` |\n| `subscribe` | `(listener, { immediate?, signal? })` | Unsubscribe function |\n| `serialize` | `()` | Loader-free `TranslationState<C>` |\n| `dispose` | `()` | `void` |\n| `locale` | `Locale` | Current canonical locale |\n| `disposed` | `boolean` | Disposal state |\n| `disposalSignal` | `AbortSignal` | Aborts on disposal |\n| `[Symbol.dispose]` | `()` | Delegates to `dispose()` |\n\n---\n\n## Catalog Utilities\n\n### catalogKeys\n\n```ts\nfunction catalogKeys<C extends Catalog>(source: I18n<C> | C): ReadonlyArray<MessageKey<C>>;\n```\n\nEnumerates every message key as a dotted path. Traverses nested grouping objects and explicit `{ plural: ... }` messages, producing the same paths that `TextKey<C>` represents at the type level. Pass an `I18n` instance to read from its current locale catalog; pass a raw catalog object to enumerate directly.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `source` | `I18n<C> \\| C` | I18n instance (uses current locale) or raw catalog object |\n\n**Returns:** `ReadonlyArray<MessageKey<C>>` — dotted paths to every text and plural message.\n\n```ts\nimport { catalogKeys, createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: { en: { nav: { home: 'Home', settings: 'Settings' } } },\n locale: 'en',\n});\n\nconst allKeys = catalogKeys(i18n); // ['nav.home', 'nav.settings']\nconst navKeys = catalogKeys(i18n.serialize().catalogs.en.nav); // ['home', 'settings']\n```\n\n---\n\n## Intl formatting\n\n### createFormatter\n\n```ts\nimport { createFormatter } from '@vielzeug/lingua/format';\n\nconst format = createFormatter(() => i18n.locale);\nformat.number(1_234.5);\nformat.currency(19.99, 'USD');\nformat.date(new Date(), { dateStyle: 'medium' });\n```\n\nThe formatter caches bounded `Intl` instances and resolves a locale getter for every call.\n\n## Validation\n\n### validateCatalog\n\n```ts\nfunction validateCatalog(catalog: Catalog, locale: Locale): ValidationIssue[];\n```\n\nValidates explicit plural messages against locale plural categories after catalog structural validation.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `catalog` | `Catalog` | Explicit catalog to validate |\n| `locale` | `Locale` | BCP 47 locale tag |\n\n**Returns:** `ValidationIssue[]`.\n\n**Example:**\n\n```ts\nimport { validateCatalog } from '@vielzeug/lingua/validate';\n\nvalidateCatalog({ inbox: { plural: { one: 'One message' } } }, 'en');\n```\n\n### compareCatalogs\n\n```ts\nfunction compareCatalogs<C extends Catalog>(catalogs: Catalogs<C>): CatalogComparison;\n```\n\nCompares key sets across locales. First locale is the base — reports keys missing in each target and keys present in targets but absent from base. Validates each catalog structurally.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `catalogs` | `Catalogs<C>` | Locale-keyed catalogs to compare |\n\n**Returns:** `CatalogComparison` with `missing` and `extra` arrays.\n\n```ts\nimport { compareCatalogs } from '@vielzeug/lingua/validate';\n\nconst result = compareCatalogs({\n en: { greeting: 'Hello', farewell: 'Goodbye' },\n de: { greeting: 'Hallo' },\n});\n// { missing: [{ key: 'farewell', locale: 'de' }], extra: [] }\n```\n\n## Types\n\n```ts\ntype Locale = string;\ntype PluralCategory = Intl.LDMLPluralRule;\ntype PluralMessage = { readonly plural: Partial<Record<PluralCategory, string>> };\ntype CatalogNode = Catalog | PluralMessage | string;\ntype Catalog = { readonly [key: string]: CatalogNode };\ntype Catalogs<C extends Catalog = Catalog> = Record<Locale, C>;\n\ntype TextPart = { readonly type: 'text'; readonly value: string };\ntype ValuePart<V> = { readonly type: 'value'; readonly value: V };\ntype Part<V> = TextPart | ValuePart<V>;\n\ntype MissingInfo = { readonly key: string; readonly locale: Locale; readonly name?: string };\ntype MissingHandler = (info: MissingInfo) => string;\ntype MissingStrategy = 'throw' | 'key' | MissingHandler;\n\ntype TranslatorOptions = { readonly locale?: Locale; readonly missing?: MissingStrategy };\n\ntype I18nOptions<C extends Catalog = Catalog> = {\n readonly fallback?: Locale | readonly Locale[];\n readonly locale?: Locale;\n readonly missing?: MissingStrategy;\n} & (\n | { readonly catalogs: Catalogs<C>; readonly loadCatalog?: CatalogLoader<C>; readonly state?: never }\n | { readonly catalogs?: never; readonly loadCatalog?: CatalogLoader<C>; readonly state: TranslationState<C> }\n | { readonly catalogs?: never; readonly loadCatalog: CatalogLoader<C>; readonly state?: never }\n);\n\ntype CatalogLoader<C extends Catalog> = (locale: Locale) => Promise<C> | C;\n\ntype TranslationState<C extends Catalog = Catalog> = {\n readonly catalogs: Catalogs<C>;\n readonly locale: Locale;\n readonly version: 4;\n};\n\ntype I18nSnapshot<C extends Catalog = Catalog> = {\n readonly locale: Locale;\n readonly revision: number;\n readonly translator: Translator<C>;\n};\n\ntype I18n<C extends Catalog = Catalog> = Translator<C> & {\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n getSnapshot(): I18nSnapshot<C>;\n isLoaded(options?: { locale?: Locale }): boolean;\n load(options?: { locale?: Locale }): Promise<void>;\n serialize(): TranslationState<C>;\n setLocale(locale: Locale): Promise<void>;\n subscribe(listener: (snapshot: I18nSnapshot<C>) => void, options?: SubscribeOptions): () => void;\n [Symbol.dispose](): void;\n};\n\ntype Translator<C extends Catalog = Catalog> = {\n readonly locale: Locale;\n parts<V>(key: TextKey<C>, options: TranslateOptions & { values: Record<string, V> }): Array<Part<V>>;\n parts<V>(key: PluralKey<C>, options: PluralOptions & { values?: Record<string, V> }): Array<Part<number | V>>;\n partsDynamic<V>(\n key: string,\n options: (TranslateOptions | PluralOptions) & { values?: Record<string, V> },\n ): Array<Part<number | V>>;\n translate(key: TextKey<C>, options?: TranslateOptions): string;\n translate(key: PluralKey<C>, options: PluralOptions): string;\n translateDynamic(key: string, options?: TranslateOptions | PluralOptions): string;\n};\n```\n\n```ts\ntype Values = Record<string, unknown>;\ntype TranslateOptions = { values?: Values };\ntype PluralOptions = TranslateOptions & { count: number; ordinal?: boolean };\ntype SubscribeOptions = { immediate?: boolean; signal?: AbortSignal };\n\ntype MessageKey<\n C,\n Prefix extends string = '',\n Depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = Depth extends readonly [unknown, ...infer Rest]\n ? C extends string | PluralMessage\n ? Prefix\n : C extends Catalog\n ? {\n [K in string & keyof C]: MessageKey<C[K], Prefix extends '' ? K : `${Prefix}.${K}`, Rest>;\n }[string & keyof C]\n : never\n : never;\n\ntype TextKey<\n C,\n Prefix extends string = '',\n Depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = Depth extends readonly [unknown, ...infer Rest]\n ? C extends string\n ? Prefix\n : C extends Catalog\n ? {\n [K in string & keyof C]: TextKey<C[K], Prefix extends '' ? K : `${Prefix}.${K}`, Rest>;\n }[string & keyof C]\n : never\n : never;\n\ntype PluralKey<\n C,\n Prefix extends string = '',\n Depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = Depth extends readonly [unknown, ...infer Rest]\n ? C extends PluralMessage\n ? Prefix\n : C extends Catalog\n ? {\n [K in string & keyof C]: PluralKey<C[K], Prefix extends '' ? K : `${Prefix}.${K}`, Rest>;\n }[string & keyof C]\n : never\n : never;\n\ntype ValidationIssue = { key: string; locale: Locale; missing: Intl.LDMLPluralRule };\ntype CatalogComparison = {\n readonly missing: ReadonlyArray<{ key: string; locale: Locale }>;\n readonly extra: ReadonlyArray<{ key: string; locale: Locale }>;\n};\n```\n\n## Errors\n\n| Error | Trigger |\n| --- | --- |\n| `LinguaDisposedError` | State mutation or subscription after `dispose()` |\n| `LinguaInvalidCatalogError` | Invalid catalog node or reserved key |\n| `LinguaInvalidLocaleError` | Invalid BCP 47 locale tag |\n| `LinguaInvalidPluralCountError` | Non-finite plural count |\n| `LinguaInvalidStateError` | Unsupported serialized state version |\n| `LinguaMissingCatalogError` | No `loadCatalog` function or catalog source unavailable |\n| `LinguaMissingKeyError` | Missing translation key when `missing: 'throw'` |\n| `LinguaMissingValueError` | Missing interpolation value when `missing: 'throw'` |\n",
|
|
6
|
-
"usage": "---\ntitle: Lingua — Usage Guide\ndescription: Translate explicit catalogs, load lazy locales, and connect locale snapshots to UI state.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate an i18n instance with eager locale catalogs. Strings are text messages; plural messages use `{ plural: ... }`.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: {\n en: {\n greeting: 'Hello, {name}!',\n inbox: { plural: { one: 'One message', other: '{count} messages' } },\n },\n },\n locale: 'en',\n});\n\nconsole.log(i18n.translate('greeting', { values: { name: 'Ada' } }));\nconsole.log(i18n.translate('inbox', { count: 3 }));\n```\n\nCall `dispose()` when the instance belongs to a temporary request, test, or route owner.\n\n## Define Explicit Catalogs\n\nUse nested objects only to group keys. A plural message always has `plural`, so regular objects containing `one` or `other` remain groups.\n\n```ts\nconst catalog = {\n account: {\n greeting: 'Hello, {name}!',\n unread: { plural: { one: 'One unread message', other: '{count} unread messages' } },\n },\n};\n```\n\nUse `{ values }` for text replacements. Pass `count` at top level for plural selection; Lingua injects it into selected template.\n\nCatalogs contain strings, grouping objects, and explicit `{ plural: ... }` messages only. Keep application data outside catalog, then translate display labels while constructing it.\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst messages = {\n status: { blocked: 'Blocked', done: 'Done', inProgress: 'In progress' },\n};\nconst statusDefinitions = [\n { labelKey: 'status.inProgress', value: 'in-progress' },\n { labelKey: 'status.blocked', value: 'blocked' },\n { labelKey: 'status.done', value: 'done' },\n] as const;\nconst translator = createTranslator(messages, { locale: 'en' });\nconst statusOptions = statusDefinitions.map(({ labelKey, value }) => ({ label: translator.translate(labelKey), value }));\n```\n\n## Missing Keys and Values\n\nMissing keys and values do not silently echo placeholders by default — Lingua warns in development and returns the key or `{name}`. Control failure behaviour with the `missing` option:\n\n| `missing` | Behaviour |\n| --- | --- |\n| `undefined` (default) | Warn in development; return key / `{name}` |\n| `'key'` | Return key / `{name}` silently |\n| `'throw'` | Throw `LinguaMissingKeyError` / `LinguaMissingValueError` |\n| `handler` | Call `({ key, locale, name? }) => string` |\n\n```ts\nconst translator = createTranslator(messages, { locale: 'en', missing: 'throw' });\n```\n\n## Enumerate Catalog Keys\n\nUse `catalogKeys()` to derive key arrays from the catalog itself instead of maintaining a parallel list that can go stale. It traverses nested grouping objects and explicit `{ plural: ... }` messages, returning the same dotted paths that `MessageKey<C>` represents at the type level.\n\nPass an `I18n` instance to enumerate keys from its current locale catalog without specifying a locale explicitly.\n\n```ts\nimport { catalogKeys, createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: {\n en: {\n greeting: 'Hello, {name}!',\n inbox: { plural: { one: 'One message', other: '{count} messages' } },\n nav: { home: 'Home', settings: 'Settings' },\n },\n },\n locale: 'en',\n});\n\nconst allKeys = catalogKeys(i18n);\n// ['greeting', 'inbox', 'nav.home', 'nav.settings']\n```\n\nPass a raw catalog object to enumerate keys directly. Call `catalogKeys()` on a nested subtree to get exactly the keys in that group — no filtering, no casts.\n\n```ts\nimport { catalogKeys } from '@vielzeug/lingua';\n\nconst messages = {\n nav: { home: 'Home', settings: 'Settings' },\n} as const;\n\nconst allKeys = catalogKeys(messages);\n// ['nav.home', 'nav.settings']\n\nconst navKeys = catalogKeys(messages.nav);\n// ['home', 'settings']\n```\n\n## Render Framework Content\n\nUse `parts()` when replacements are framework nodes, links, or other values that must not be stringified. `parts()` returns a discriminated union: `{ type: 'text', value: string }` for literal text and `{ type: 'value', value: V }` for interpolated values.\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst translator = createTranslator({ error: 'Try {retry} or {support}.' }, { locale: 'en' });\n\nconst retry = { href: '/retry', label: 'retry' };\nconst support = { href: '/support', label: 'support' };\n\nconst result = translator.parts('error', { values: { retry, support } });\n// [\n// { type: 'text', value: 'Try ' },\n// { type: 'value', value: { href: '/retry', label: 'retry' } },\n// { type: 'text', value: ' or ' },\n// { type: 'value', value: { href: '/support', label: 'support' } },\n// { type: 'text', value: '.' },\n// ]\n```\n\nRender returned array with framework fragment or list primitive. Give UI values consumer-owned keys before passing them to `parts()`; Lingua preserves value identity and never clones or mutates them.\n\n## Use a Fixed-Locale Translator\n\nUse `createTranslator()` when one catalog and locale stay fixed for the translator's lifetime. It defaults locale to `en`; pass `locale` when plural rules or diagnostics need another locale. Lingua snapshots catalog messages during construction. Do not mutate source catalog objects afterward.\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst translator = createTranslator(\n { save: 'Enregistrer' },\n { locale: 'fr' },\n);\n\nconsole.log(translator.translate('save'));\n```\n\n## Load Catalogs and Switch Locales\n\nProvide eager catalogs for translations needed immediately and a `loadCatalog` function for other locales. `setLocale()` loads the selected locale and configured fallbacks before it commits the change.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: { en: { navigation: { settings: 'Settings' } } },\n fallback: 'en',\n locale: 'en',\n loadCatalog: (locale) => import(`./locales/${locale}.ts`).then((m) => m.default),\n});\n\nawait i18n.setLocale('fr');\nconsole.log(i18n.translate('navigation.settings'));\n```\n\nUse loader-only configuration when every locale is lazy, and call `load()` before the first translation. Concurrent loads for the same locale share work. A failed or superseded locale selection never changes the current snapshot.\n\n## Subscribe to Immutable Snapshots\n\nSubscribe when UI state must change with locale or loaded active/fallback catalog. Every callback receives a snapshot containing the translator for that revision.\n\n```ts\nconst unsubscribe = i18n.subscribe(\n ({ locale, translator }) => {\n console.log(locale, translator.translate('navigation.settings'));\n },\n { immediate: true },\n);\n\nunsubscribe();\n```\n\nPass `{ signal }` when an `AbortController` owns subscription lifetime.\n\n## SSR State\n\nSerialize resolved catalogs on the server, then hydrate a client instance from the same payload via the `state` option. `getSnapshot()` stays referentially stable until the store revision changes, so use the same hydrated instance throughout initial client render.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst serverI18n = createI18n({\n catalogs: { en: { title: 'Server title' } },\n locale: 'en',\n});\n\nconst state = serverI18n.serialize();\nconst clientI18n = createI18n({ state });\n\nconsole.log(clientI18n.translate('title'));\nserverI18n.dispose();\nclientI18n.dispose();\n```\n\nState contains raw loaded catalogs and the active locale. It never contains loader functions. Pass an explicit `locale` only when the client should override the serialized locale.\n\n## Validation\n\nImport catalog validation from the dedicated subpath to keep translation state focused.\n\n```ts\nimport { compareCatalogs, validateCatalog } from '@vielzeug/lingua/validate';\n\nconst catalog = { inbox: { plural: { one: 'One message', other: '{count} messages' } } };\n\nconsole.log(validateCatalog(catalog, 'en'));\n```\n\nUse `compareCatalogs()` to catch missing or extra keys across locales — the most common i18n defect. First locale is the base.\n\n```ts\nimport { compareCatalogs } from '@vielzeug/lingua/validate';\n\nconst result = compareCatalogs({\n en: { greeting: 'Hello', farewell: 'Goodbye' },\n de: { greeting: 'Hallo' },\n});\n// { missing: [{ key: 'farewell', locale: 'de' }], extra: [] }\n```\n\n## Framework Integration\n\nPass stable `getSnapshot()` and `subscribe()` methods to framework state primitives. For SSR, create client instance from the same serialized state used by the server before calling `useSyncExternalStore`.\n\n::: code-group\n\n```ts [React]\nimport { useSyncExternalStore } from 'react';\n\nimport type { I18n } from '@vielzeug/lingua';\n\nexport function useTranslator(i18n: I18n) {\n const snapshot = useSyncExternalStore(i18n.subscribe, i18n.getSnapshot, i18n.getSnapshot);\n\n return snapshot.translator;\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, shallowRef } from 'vue';\n\nimport type { I18n } from '@vielzeug/lingua';\n\nexport function useTranslator(i18n: I18n) {\n const snapshot = shallowRef(i18n.getSnapshot());\n const unsubscribe = i18n.subscribe((next) => {\n snapshot.value = next;\n });\n\n onUnmounted(unsubscribe);\n return snapshot;\n}\n```\n\n```ts [Svelte]\nimport { readable } from 'svelte/store';\n\nimport type { I18n } from '@vielzeug/lingua';\n\nexport function translatorStore(i18n: I18n) {\n return readable(i18n.getSnapshot().translator, (set) => i18n.subscribe(({ translator }) => set(translator)));\n}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nBridge Lingua's structural snapshot directly when templates need reactive locale reads.\n\n```ts\nimport { createRipple } from '@vielzeug/ripple';\n\nconst ripple = createRipple();\nconst i18nState = ripple.fromSubscribable(i18n);\nexport const locale = ripple.computed(() => i18nState.value.locale);\n\nexport const disposeLocaleBridge = () => ripple.dispose();\n```\n\nUse Courier loaders when locale catalogs come from HTTP rather than bundled modules; pass each loader to `loadCatalog`.\n\n## Best Practices\n\n- Define plural messages with `{ plural: ... }` and no sibling metadata.\n- Keep arrays and application metadata outside catalogs.\n- Treat source catalog objects as immutable after construction.\n- Use `translateDynamic()` only for runtime-generated keys.\n- Provide eager catalogs for translations required during initial render.\n- Give UI values keys before passing them to `parts()`.\n- Keep loader functions out of SSR payloads.\n- Dispose temporary instances after requests, tests, and route lifetimes.\n- Use `missing: 'throw'` in development to catch missing keys and values early.\n",
|
|
7
|
-
"examples": "---\ntitle: Lingua
|
|
4
|
+
"index": "---\ntitle: 'Lingua: Explicit localization for TypeScript'\ndescription: Framework-neutral locale catalogs, typed translations, and explicit plural messages.\npackage: lingua\ncategory: i18n\nkeywords: [internationalization, translations, pluralization, locale, i18n, catalog-loading]\nrelated: [ripple, wayfinder, courier]\nexports: [createI18n, createTranslator, LinguaError, LinguaDisposedError, LinguaInvalidCatalogError, LinguaInvalidLocaleError, LinguaInvalidPluralCountError, LinguaInvalidStateError, LinguaMissingCatalogError, LinguaMissingKeyError, LinguaMissingValueError]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"lingua\" />\n\n## Why Lingua?\n\nLingua separates immutable translation from mutable locale state. Use `createTranslator` for one fixed-locale catalog, or `createI18n` when locale changes at runtime.\n\n```ts\n// Before\nconst message = catalogs[locale]?.inbox?.[count === 1 ? 'one' : 'other'] ?? 'inbox';\n\n// After\nconst output = i18n.translate('inbox', { count });\n```\n\n| Feature | Lingua | i18next | FormatJS |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"lingua\" type=\"size\" /> | Varies by selected modules | Varies by selected modules |\n| Zero runtime dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> |\n| Explicit plural catalog nodes | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Convention/config dependent | ICU-message dependent |\n| Static and lazy locale catalogs | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Plugin/config dependent | Application-defined |\n| Immutable locale snapshots | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Application-defined | Application-defined |\n\n<div class=\"decision-callout\">\n\n**Use Lingua when** you need a compact TypeScript runtime with explicit catalog structure, deterministic fallback, and framework-neutral subscriptions.\n\n**Consider i18next or FormatJS when** you need their plugin ecosystems, message extraction pipelines, or framework-specific integrations.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/lingua\n```\n\n```sh [npm]\nnpm install @vielzeug/lingua\n```\n\n```sh [yarn]\nyarn add @vielzeug/lingua\n```\n\n:::\n\n## Quick Start\n\nCreate an i18n instance with an eager default catalog and a loader for other locales.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst en = { inbox: { plural: { one: 'One message', other: '{count} messages' } } };\nconst i18n = createI18n({\n catalogs: { en },\n locale: 'en',\n loadCatalog: (locale) => import(`./locales/${locale}.ts`).then((m) => m.default),\n});\n\ntry {\n console.log(i18n.translate('inbox', { count: 3 }));\n await i18n.setLocale('de');\n console.log(i18n.translate('inbox', { count: 1 }));\n} finally {\n i18n.dispose();\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createTranslator()` compiles one immutable fixed-locale catalog.\n- `createI18n()` manages eager catalogs, locale changes, and optional lazy loading.\n- `translate()` renders text and plural messages through explicit catalog nodes.\n- `translateDynamic()` makes runtime-key lookup explicit.\n- `parts()` returns typed discriminated parts for framework content.\n- `load()` deduplicates loading and resolves the active locale plus configured fallbacks.\n- `getSnapshot()` and `subscribe()` expose immutable translator revisions.\n- `serialize()` and `state` option transfer resolved SSR catalogs.\n- `missing: 'throw' | 'key' | handler` controls failure behaviour.\n- `validateCatalog()` remains an isolated subpath tool.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Ripple](../ripple/index.md) adapts Lingua snapshots into reactive application state.\n- [Courier](../courier/index.md) can fetch locale catalogs before passing them to Lingua loaders.\n- [Wayfinder](../wayfinder/index.md) can drive locale selection from route state.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: 'Lingua: API Reference'\ndescription: Complete API reference for @vielzeug/lingua.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createTranslator()` | Compile one immutable locale catalog | Sync | No fallback locales |\n| `createI18n()` | Create mutable locale and catalog store | Sync | Provide catalogs, serialized state, or a loader |\n| `createFormatter()` | Create a cached Intl facade from `/format` | Sync | Pass a locale getter for dynamic locale changes |\n| `createReactiveI18n()` | Mirror an i18n instance into ripple readables from `/ripple` | Sync | Optional `@vielzeug/ripple` peer; import from subpath |\n| `catalogKeys()` | Enumerate message keys as dotted paths | Sync | Takes a catalog (or subtree) only; for an i18n instance pass `state.catalogs[state.locale]` |\n| `validateCatalog()` | Check explicit plural forms from `/validate` | Sync | Import from subpath |\n| `compareCatalogs()` | Compare key parity across locales from `/validate` | Sync | First locale is the base; import from subpath |\n| `LinguaError` | Base class for Lingua errors | Sync | Use `instanceof LinguaError` for broad narrowing |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/lingua` | Translation factories, state types, and Lingua errors |\n| `@vielzeug/lingua/format` | Standalone cached Intl formatter factory |\n| `@vielzeug/lingua/ripple` | `createReactiveI18n()`: reactive locale/translate bridge (optional `@vielzeug/ripple` peer) |\n| `@vielzeug/lingua/validate` | `validateCatalog()`, `compareCatalogs()`, and `ValidationIssue` |\n\n## Translation Factories\n\n### createTranslator\n\n```ts\nfunction createTranslator<C extends Catalog>(catalog: C, options?: TranslatorOptions): Translator<C>;\n```\n\nCompiles one catalog and returns an immutable fixed-locale translator. Locale defaults to `en` and controls plural selection and diagnostics.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `catalog` | `C` | One catalog containing only messages and grouping objects |\n| `options` | `TranslatorOptions` | Locale and missing-message strategy |\n\n**Returns:** `Translator<C>`.\n\n**Example:**\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst translator = createTranslator(\n { save: 'Enregistrer' },\n { locale: 'fr' },\n);\n\ntranslator.translate('save');\n```\n\n| Method | Signature | Returns |\n| --- | --- | --- |\n| `translate` | `(textKey, options?)` or `(pluralKey, { count, ordinal?, values? })` | Rendered string |\n| `translateDynamic` | `(key, options?)` | Rendered string for runtime key |\n| `parts` | `(textKey, { values })` or `(pluralKey, { count, ordinal?, values? })` | Typed discriminated parts |\n| `partsDynamic` | `(key, options)` | Parts for runtime key |\n| `locale` | `Locale` | Resolved active locale |\n\n---\n\n### createI18n\n\n```ts\nfunction createI18n<C extends Catalog>(options: I18nOptions<C>): I18n<C>;\n```\n\nCreates catalog store, current locale state, and immutable translator snapshots.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options.catalogs` | `Catalogs<C>` | Eager locale catalogs; mutually exclusive with `state` |\n| `options.locale` | `Locale` | Initial locale; defaults to serialized locale, then `en` |\n| `options.loadCatalog` | `(locale) => Promise<C> \\| C` | Optional loader for catalogs not supplied eagerly |\n| `options.fallback` | `Locale \\| readonly Locale[]` | Fallback locales loaded with the selected locale |\n| `options.missing` | `MissingStrategy` | Missing-key/value strategy |\n| `options.state` | `TranslationState<C>` | Eager serialized state; mutually exclusive with `catalogs` |\n\nProvide `catalogs`, `state`, or `loadCatalog`. You may combine eager `catalogs` or hydrated `state` with `loadCatalog` for later locales.\n\n**Returns:** `I18n<C>`, with every `Translator<C>` method plus lifecycle methods.\n\n**Example:**\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: { en: { title: 'Home' } },\n locale: 'en',\n});\ni18n.translate('title');\n```\n\n| Method or property | Signature | Returns |\n| --- | --- | --- |\n| `translate` | Translator method | Rendered string |\n| `parts` | Translator method | Typed discriminated parts |\n| `load` | `({ locale? })` | `Promise<void>` after the locale and configured fallbacks load |\n| `setLocale` | `(locale)` | `Promise<void>` after an atomic loaded-locale commit |\n| `isLoaded` | `({ locale? })` | `boolean` |\n| `getSnapshot` | `()` | `I18nSnapshot<C>` |\n| `subscribe` | `(listener, { immediate?, signal? })` | Unsubscribe function |\n| `serialize` | `()` | Loader-free `TranslationState<C>` |\n| `dispose` | `()` | `void` |\n| `locale` | `Locale` | Current canonical locale |\n| `disposed` | `boolean` | Disposal state |\n| `disposalSignal` | `AbortSignal` | Aborts on disposal |\n| `[Symbol.dispose]` | `()` | Delegates to `dispose()` |\n\n---\n\n## Reactive bridge (`@vielzeug/lingua/ripple`)\n\n### createReactiveI18n\n\n```ts\nfunction createReactiveI18n<C extends Catalog>(i18n: I18n<C>): ReactiveI18n<C>;\n```\n\nBridges a lingua instance into `@vielzeug/ripple` so translated strings are reactive. `i18n.translate()` is not ripple-reactive on its own: reading it inside a `computed()` computes once and never re-runs, since it registers no tracked dependency. Both translate methods here first read the reactive `locale` readable, so any binding built on them re-evaluates the instant `setLocale()` resolves.\n\n`@vielzeug/ripple` is an optional peer dependency: install it only when you use this subpath.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\nimport { createReactiveI18n } from '@vielzeug/lingua/ripple';\nimport { computed } from '@vielzeug/ripple';\n\nconst reactive = createReactiveI18n(createI18n({ catalogs, locale: 'en' }));\n\nconst heading = computed(() => reactive.translate('title')); // re-computes on setLocale()\nawait reactive.i18n.setLocale('fr');\n```\n\n| Property or method | Signature | Returns |\n| --- | --- | --- |\n| `i18n` | `I18n<C>` | The underlying instance for imperative work (`setLocale`, `load`, disposal) |\n| `locale` | `Readable<Locale>` | Reactive locale: reading it inside a computed/template registers the dependency |\n| `translate` | `(key, options?)` | Rendered string; re-evaluates whenever the locale changes |\n| `translateDynamic` | `(key, options?)` | Same, for runtime-assembled keys |\n\n---\n\n## Catalog Utilities\n\n### catalogKeys\n\n```ts\nfunction catalogKeys<C extends Catalog>(catalog: C): ReadonlyArray<MessageKey<C>>;\n```\n\nEnumerates every message key as a dotted path. Traverses nested grouping objects and explicit `{ plural: ... }` messages, producing the same paths that `TextKey<C>` represents at the type level. Pass any catalog object or subtree; for an `I18n` instance, read its current locale catalog first.\n\n| Parameter | Type | Description |\n| --------- | ------------------- | ----------------------------------------- |\n| `catalog` | `C extends Catalog` | Raw catalog object or any catalog subtree |\n\n**Returns:** `ReadonlyArray<MessageKey<C>>`: dotted paths to every text and plural message.\n\n```ts\nimport { catalogKeys, createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: { en: { nav: { home: 'Home', settings: 'Settings' } } },\n locale: 'en',\n});\n\nconst state = i18n.serialize();\nconst allKeys = catalogKeys(state.catalogs[state.locale]); // ['nav.home', 'nav.settings']\nconst navKeys = catalogKeys(state.catalogs.en.nav); // ['home', 'settings']\n```\n\n---\n\n## Intl formatting\n\n### createFormatter\n\n```ts\nimport { createFormatter } from '@vielzeug/lingua/format';\n\nconst format = createFormatter(() => i18n.locale);\nformat.number(1_234.5);\nformat.currency(19.99, 'USD');\nformat.date(new Date(), { dateStyle: 'medium' });\n```\n\nThe formatter caches bounded `Intl` instances and resolves a locale getter for every call.\n\n## Validation\n\n### validateCatalog\n\n```ts\nfunction validateCatalog(catalog: Catalog, locale: Locale): ValidationIssue[];\n```\n\nValidates explicit plural messages against locale plural categories after catalog structural validation.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `catalog` | `Catalog` | Explicit catalog to validate |\n| `locale` | `Locale` | BCP 47 locale tag |\n\n**Returns:** `ValidationIssue[]`.\n\n**Example:**\n\n```ts\nimport { validateCatalog } from '@vielzeug/lingua/validate';\n\nvalidateCatalog({ inbox: { plural: { one: 'One message' } } }, 'en');\n```\n\n### compareCatalogs\n\n```ts\nfunction compareCatalogs<C extends Catalog>(catalogs: Catalogs<C>): CatalogComparison;\n```\n\nCompares key sets across locales. First locale is the base: reports keys missing in each target and keys present in targets but absent from base. Validates each catalog structurally.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `catalogs` | `Catalogs<C>` | Locale-keyed catalogs to compare |\n\n**Returns:** `CatalogComparison` with `missing` and `extra` arrays.\n\n```ts\nimport { compareCatalogs } from '@vielzeug/lingua/validate';\n\nconst result = compareCatalogs({\n en: { greeting: 'Hello', farewell: 'Goodbye' },\n de: { greeting: 'Hallo' },\n});\n// { missing: [{ key: 'farewell', locale: 'de' }], extra: [] }\n```\n\n## Types\n\n```ts\ntype Locale = string;\ntype PluralCategory = Intl.LDMLPluralRule;\ntype PluralMessage = { readonly plural: Partial<Record<PluralCategory, string>> };\ntype CatalogNode = Catalog | PluralMessage | string;\ntype Catalog = { readonly [key: string]: CatalogNode };\ntype Catalogs<C extends Catalog = Catalog> = Record<Locale, C>;\n\ntype TextPart = { readonly type: 'text'; readonly value: string };\ntype ValuePart<V> = { readonly type: 'value'; readonly value: V };\ntype Part<V> = TextPart | ValuePart<V>;\n\ntype MissingInfo = { readonly key: string; readonly locale: Locale; readonly name?: string };\ntype MissingHandler = (info: MissingInfo) => string;\ntype MissingStrategy = 'throw' | 'key' | MissingHandler;\n\ntype TranslatorOptions = { readonly locale?: Locale; readonly missing?: MissingStrategy };\n\ntype I18nOptions<C extends Catalog = Catalog> = {\n readonly fallback?: Locale | readonly Locale[];\n readonly locale?: Locale;\n readonly missing?: MissingStrategy;\n} & (\n | { readonly catalogs: Catalogs<C>; readonly loadCatalog?: CatalogLoader<C>; readonly state?: never }\n | { readonly catalogs?: never; readonly loadCatalog?: CatalogLoader<C>; readonly state: TranslationState<C> }\n | { readonly catalogs?: never; readonly loadCatalog: CatalogLoader<C>; readonly state?: never }\n);\n\ntype CatalogLoader<C extends Catalog> = (locale: Locale) => Promise<C> | C;\n\ntype TranslationState<C extends Catalog = Catalog> = {\n readonly catalogs: Catalogs<C>;\n readonly locale: Locale;\n readonly version: 1;\n};\n\ntype I18nSnapshot<C extends Catalog = Catalog> = {\n readonly locale: Locale;\n readonly revision: number;\n readonly translator: Translator<C>;\n};\n\ntype I18n<C extends Catalog = Catalog> = Translator<C> & {\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n getSnapshot(): I18nSnapshot<C>;\n isLoaded(options?: { locale?: Locale }): boolean;\n load(options?: { locale?: Locale }): Promise<void>;\n serialize(): TranslationState<C>;\n setLocale(locale: Locale): Promise<void>;\n subscribe(listener: (snapshot: I18nSnapshot<C>) => void, options?: SubscribeOptions): () => void;\n [Symbol.dispose](): void;\n};\n\ntype Translator<C extends Catalog = Catalog> = {\n readonly locale: Locale;\n parts<V>(key: TextKey<C>, options: TranslateOptions & { values: Record<string, V> }): Array<Part<V>>;\n parts<V>(key: PluralKey<C>, options: PluralOptions & { values?: Record<string, V> }): Array<Part<number | V>>;\n partsDynamic<V>(\n key: string,\n options: (TranslateOptions | PluralOptions) & { values?: Record<string, V> },\n ): Array<Part<number | V>>;\n translate(key: TextKey<C>, options?: TranslateOptions): string;\n translate(key: PluralKey<C>, options: PluralOptions): string;\n translateDynamic(key: string, options?: TranslateOptions | PluralOptions): string;\n};\n```\n\n```ts\ntype Values = Record<string, unknown>;\ntype TranslateOptions = { values?: Values };\ntype PluralOptions = TranslateOptions & { count: number; ordinal?: boolean };\ntype SubscribeOptions = { immediate?: boolean; signal?: AbortSignal };\n\ntype MessageKey<\n C,\n Prefix extends string = '',\n Depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = Depth extends readonly [unknown, ...infer Rest]\n ? C extends string | PluralMessage\n ? Prefix\n : C extends Catalog\n ? {\n [K in string & keyof C]: MessageKey<C[K], Prefix extends '' ? K : `${Prefix}.${K}`, Rest>;\n }[string & keyof C]\n : never\n : never;\n\ntype TextKey<\n C,\n Prefix extends string = '',\n Depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = Depth extends readonly [unknown, ...infer Rest]\n ? C extends string\n ? Prefix\n : C extends Catalog\n ? {\n [K in string & keyof C]: TextKey<C[K], Prefix extends '' ? K : `${Prefix}.${K}`, Rest>;\n }[string & keyof C]\n : never\n : never;\n\ntype PluralKey<\n C,\n Prefix extends string = '',\n Depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = Depth extends readonly [unknown, ...infer Rest]\n ? C extends PluralMessage\n ? Prefix\n : C extends Catalog\n ? {\n [K in string & keyof C]: PluralKey<C[K], Prefix extends '' ? K : `${Prefix}.${K}`, Rest>;\n }[string & keyof C]\n : never\n : never;\n\ntype ValidationIssue = { key: string; locale: Locale; missing: Intl.LDMLPluralRule };\ntype CatalogComparison = {\n readonly missing: ReadonlyArray<{ key: string; locale: Locale }>;\n readonly extra: ReadonlyArray<{ key: string; locale: Locale }>;\n};\n```\n\n## Errors\n\n| Error | Trigger |\n| --- | --- |\n| `LinguaDisposedError` | State mutation or subscription after `dispose()` |\n| `LinguaInvalidCatalogError` | Invalid catalog node or reserved key |\n| `LinguaInvalidLocaleError` | Invalid BCP 47 locale tag |\n| `LinguaInvalidPluralCountError` | Non-finite plural count |\n| `LinguaInvalidStateError` | Unsupported serialized state version |\n| `LinguaMissingCatalogError` | No `loadCatalog` function or catalog source unavailable |\n| `LinguaMissingKeyError` | Missing translation key when `missing: 'throw'` |\n| `LinguaMissingValueError` | Missing interpolation value when `missing: 'throw'` |\n",
|
|
6
|
+
"usage": "---\ntitle: 'Lingua: Usage Guide'\ndescription: Translate explicit catalogs, load lazy locales, and connect locale snapshots to UI state.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate an i18n instance with eager locale catalogs. Strings are text messages; plural messages use `{ plural: ... }`.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: {\n en: {\n greeting: 'Hello, {name}!',\n inbox: { plural: { one: 'One message', other: '{count} messages' } },\n },\n },\n locale: 'en',\n});\n\nconsole.log(i18n.translate('greeting', { values: { name: 'Ada' } }));\nconsole.log(i18n.translate('inbox', { count: 3 }));\n```\n\nCall `dispose()` when the instance belongs to a temporary request, test, or route owner.\n\n## Define Explicit Catalogs\n\nUse nested objects only to group keys. A plural message always has `plural`, so regular objects containing `one` or `other` remain groups.\n\n```ts\nconst catalog = {\n account: {\n greeting: 'Hello, {name}!',\n unread: { plural: { one: 'One unread message', other: '{count} unread messages' } },\n },\n};\n```\n\nUse `{ values }` for text replacements. Pass `count` at top level for plural selection; Lingua injects it into selected template.\n\nCatalogs contain strings, grouping objects, and explicit `{ plural: ... }` messages only. Keep application data outside catalog, then translate display labels while constructing it.\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst messages = {\n status: { blocked: 'Blocked', done: 'Done', inProgress: 'In progress' },\n};\nconst statusDefinitions = [\n { labelKey: 'status.inProgress', value: 'in-progress' },\n { labelKey: 'status.blocked', value: 'blocked' },\n { labelKey: 'status.done', value: 'done' },\n] as const;\nconst translator = createTranslator(messages, { locale: 'en' });\nconst statusOptions = statusDefinitions.map(({ labelKey, value }) => ({ label: translator.translate(labelKey), value }));\n```\n\n## Missing Keys and Values\n\nMissing keys and values do not silently echo placeholders by default: Lingua warns in development and returns the key or `{name}`. Control failure behaviour with the `missing` option:\n\n| `missing` | Behaviour |\n| --- | --- |\n| `undefined` (default) | Warn in development; return key / `{name}` |\n| `'key'` | Return key / `{name}` silently |\n| `'throw'` | Throw `LinguaMissingKeyError` / `LinguaMissingValueError` |\n| `handler` | Call `({ key, locale, name? }) => string` |\n\n```ts\nconst translator = createTranslator(messages, { locale: 'en', missing: 'throw' });\n```\n\n## Enumerate Catalog Keys\n\nUse `catalogKeys()` to derive key arrays from the catalog itself instead of maintaining a parallel list that can go stale. It traverses nested grouping objects and explicit `{ plural: ... }` messages, returning the same dotted paths that `MessageKey<C>` represents at the type level.\n\nFor an `I18n` instance, read its current locale catalog first.\n\n```ts\nimport { catalogKeys, createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: {\n en: {\n greeting: 'Hello, {name}!',\n inbox: { plural: { one: 'One message', other: '{count} messages' } },\n nav: { home: 'Home', settings: 'Settings' },\n },\n },\n locale: 'en',\n});\n\nconst state = i18n.serialize();\nconst allKeys = catalogKeys(state.catalogs[state.locale]);\n// ['greeting', 'inbox', 'nav.home', 'nav.settings']\n```\n\nPass a raw catalog object to enumerate keys directly. Call `catalogKeys()` on a nested subtree to get exactly the keys in that group: no filtering, no casts.\n\n```ts\nimport { catalogKeys } from '@vielzeug/lingua';\n\nconst messages = {\n nav: { home: 'Home', settings: 'Settings' },\n} as const;\n\nconst allKeys = catalogKeys(messages);\n// ['nav.home', 'nav.settings']\n\nconst navKeys = catalogKeys(messages.nav);\n// ['home', 'settings']\n```\n\n## Render Framework Content\n\nUse `parts()` when replacements are framework nodes, links, or other values that must not be stringified. `parts()` returns a discriminated union: `{ type: 'text', value: string }` for literal text and `{ type: 'value', value: V }` for interpolated values.\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst translator = createTranslator({ error: 'Try {retry} or {support}.' }, { locale: 'en' });\n\nconst retry = { href: '/retry', label: 'retry' };\nconst support = { href: '/support', label: 'support' };\n\nconst result = translator.parts('error', { values: { retry, support } });\n// [\n// { type: 'text', value: 'Try ' },\n// { type: 'value', value: { href: '/retry', label: 'retry' } },\n// { type: 'text', value: ' or ' },\n// { type: 'value', value: { href: '/support', label: 'support' } },\n// { type: 'text', value: '.' },\n// ]\n```\n\nRender returned array with framework fragment or list primitive. Give UI values consumer-owned keys before passing them to `parts()`; Lingua preserves value identity and never clones or mutates them.\n\n## Use a Fixed-Locale Translator\n\nUse `createTranslator()` when one catalog and locale stay fixed for the translator's lifetime. It defaults locale to `en`; pass `locale` when plural rules or diagnostics need another locale. Lingua snapshots catalog messages during construction. Do not mutate source catalog objects afterward.\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst translator = createTranslator(\n { save: 'Enregistrer' },\n { locale: 'fr' },\n);\n\nconsole.log(translator.translate('save'));\n```\n\n## Load Catalogs and Switch Locales\n\nProvide eager catalogs for translations needed immediately and a `loadCatalog` function for other locales. `setLocale()` loads the selected locale and configured fallbacks before it commits the change.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: { en: { navigation: { settings: 'Settings' } } },\n fallback: 'en',\n locale: 'en',\n loadCatalog: (locale) => import(`./locales/${locale}.ts`).then((m) => m.default),\n});\n\nawait i18n.setLocale('fr');\nconsole.log(i18n.translate('navigation.settings'));\n```\n\nUse loader-only configuration when every locale is lazy, and call `load()` before the first translation. Concurrent loads for the same locale share work. A failed or superseded locale selection never changes the current snapshot.\n\n## Subscribe to Immutable Snapshots\n\nSubscribe when UI state must change with locale or loaded active/fallback catalog. Every callback receives a snapshot containing the translator for that revision.\n\n```ts\nconst unsubscribe = i18n.subscribe(\n ({ locale, translator }) => {\n console.log(locale, translator.translate('navigation.settings'));\n },\n { immediate: true },\n);\n\nunsubscribe();\n```\n\nPass `{ signal }` when an `AbortController` owns subscription lifetime.\n\n## SSR State\n\nSerialize resolved catalogs on the server, then hydrate a client instance from the same payload via the `state` option. `getSnapshot()` stays referentially stable until the store revision changes, so use the same hydrated instance throughout initial client render.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst serverI18n = createI18n({\n catalogs: { en: { title: 'Server title' } },\n locale: 'en',\n});\n\nconst state = serverI18n.serialize();\nconst clientI18n = createI18n({ state });\n\nconsole.log(clientI18n.translate('title'));\nserverI18n.dispose();\nclientI18n.dispose();\n```\n\nState contains raw loaded catalogs and the active locale. It never contains loader functions. Pass an explicit `locale` only when the client should override the serialized locale.\n\n## Validation\n\nImport catalog validation from the dedicated subpath to keep translation state focused.\n\n```ts\nimport { compareCatalogs, validateCatalog } from '@vielzeug/lingua/validate';\n\nconst catalog = { inbox: { plural: { one: 'One message', other: '{count} messages' } } };\n\nconsole.log(validateCatalog(catalog, 'en'));\n```\n\nUse `compareCatalogs()` to catch missing or extra keys across locales: the most common i18n defect. First locale is the base.\n\n```ts\nimport { compareCatalogs } from '@vielzeug/lingua/validate';\n\nconst result = compareCatalogs({\n en: { greeting: 'Hello', farewell: 'Goodbye' },\n de: { greeting: 'Hallo' },\n});\n// { missing: [{ key: 'farewell', locale: 'de' }], extra: [] }\n```\n\n## Framework Integration\n\nPass stable `getSnapshot()` and `subscribe()` methods to framework state primitives. For SSR, create client instance from the same serialized state used by the server before calling `useSyncExternalStore`.\n\n::: code-group\n\n```ts [React]\nimport { useSyncExternalStore } from 'react';\n\nimport type { I18n } from '@vielzeug/lingua';\n\nexport function useTranslator(i18n: I18n) {\n const snapshot = useSyncExternalStore(i18n.subscribe, i18n.getSnapshot, i18n.getSnapshot);\n\n return snapshot.translator;\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, shallowRef } from 'vue';\n\nimport type { I18n } from '@vielzeug/lingua';\n\nexport function useTranslator(i18n: I18n) {\n const snapshot = shallowRef(i18n.getSnapshot());\n const unsubscribe = i18n.subscribe((next) => {\n snapshot.value = next;\n });\n\n onUnmounted(unsubscribe);\n return snapshot;\n}\n```\n\n```ts [Svelte]\nimport { readable } from 'svelte/store';\n\nimport type { I18n } from '@vielzeug/lingua';\n\nexport function translatorStore(i18n: I18n) {\n return readable(i18n.getSnapshot().translator, (set) => i18n.subscribe(({ translator }) => set(translator)));\n}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nBridge Lingua's structural snapshot directly when templates need reactive locale reads.\n\n```ts\nimport { createRipple } from '@vielzeug/ripple';\n\nconst ripple = createRipple();\nconst i18nState = ripple.fromSubscribable(i18n);\nexport const locale = ripple.computed(() => i18nState.value.locale);\n\nexport const disposeLocaleBridge = () => ripple.dispose();\n```\n\nUse Courier loaders when locale catalogs come from HTTP rather than bundled modules; pass each loader to `loadCatalog`.\n\n## Best Practices\n\n- Define plural messages with `{ plural: ... }` and no sibling metadata.\n- Keep arrays and application metadata outside catalogs.\n- Treat source catalog objects as immutable after construction.\n- Use `translateDynamic()` only for runtime-generated keys.\n- Provide eager catalogs for translations required during initial render.\n- Give UI values keys before passing them to `parts()`.\n- Keep loader functions out of SSR payloads.\n- Dispose temporary instances after requests, tests, and route lifetimes.\n- Use `missing: 'throw'` in development to catch missing keys and values early.\n",
|
|
7
|
+
"examples": "---\ntitle: 'Lingua: Examples'\ndescription: Focused examples for explicit catalogs and locale resources.\n---\n\n- [Static Translator](./examples/static-translator.md)\n- [Lazy Locale Catalog](./examples/feature-resources.md)\n- [SSR Hydration](./examples/ssr-hydration.md)\n"
|
|
8
8
|
},
|
|
9
9
|
"examples": [
|
|
10
10
|
{
|
package/data/packages/mesh.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
|
-
"apiSource": "// Public root surface of @vielzeug/mesh. Every public export goes through this file.\nexport { meshCodec } from './codec';\nexport {\n MeshConnectionError,\n MeshDisposedError,\n MeshError,\n MeshPairingError,\n MeshPayloadError,\n MeshTimeoutError,\n MeshUnsupportedError,\n} from './errors';\nexport { createMeshGuest } from './guest';\nexport { createMeshHost } from './host';\nexport {
|
|
2
|
+
"apiSource": "// Public root surface of @vielzeug/mesh. Every public export goes through this file.\nexport { meshCodec } from './codec';\nexport {\n MeshConnectionError,\n MeshDisposedError,\n MeshError,\n MeshPairingError,\n MeshPayloadError,\n MeshTimeoutError,\n MeshUnsupportedError,\n} from './errors';\nexport { createMeshGuest } from './guest';\nexport { createMeshHost } from './host';\nexport { meshQrCodec } from './qr-codec';\nexport type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';\n",
|
|
3
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Mesh
|
|
5
|
-
"api": "---\ntitle: Mesh — API Reference\ndescription: Public API of @vielzeug/mesh.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution | Common gotcha |\n| --- | --- | --- | --- |\n| `createMeshHost` | Host-authoritative session node; pairs one `RTCPeerConnection` per guest | Sync | `createInvitation`/`acceptAnswer` are async; calls after `dispose()` throw `MeshDisposedError` |\n| `createMeshGuest` | Guest node that pairs with one host | Sync | Second `acceptInvitation` throws `MeshPairingError` |\n| `meshCodec` | base64url encode/decode for pairing payloads | Sync | `decode` throws `MeshPairingError` on malformed text or wrong `v` |\n| `meshQrCodec` | deflate-raw + base64url codec for QR-sized payloads | Async | `encode` throws `MeshUnsupportedError` without `CompressionStream` |\n| `MeshProtocol` | Declares the `toHost`/`toGuest` message maps | — | Extend it; the maps themselves stay plain records |\n| `MeshError` | Base class for all mesh errors | — | `instanceof MeshError` catches every mesh-originated error |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/mesh` | Complete public Mesh API |\n\n## Factory Functions\n\n### createMeshHost()\n\n```ts\nfunction createMeshHost<P extends MeshProtocol>(options?: MeshHostOptions): MeshHost<P>;\n```\n\nReturns a host node in status `'idle'`. Creating it never touches WebRTC — the first `createInvitation()` does.\n\n#### Options (`MeshHostOptions`)\n\n| Option | Type | Default | Purpose |\n| --- | --- | --- | --- |\n| `iceServers` | `readonly RTCIceServer[]` | `[]` | Passed to the peer connection; mesh ships no STUN/TURN. |\n| `channel` | `MeshChannelOptions` | `{ ordered: true, label: 'mesh' }` | Data-channel init mapped to `RTCDataChannelInit`. |\n| `maxMessageBytes` | `number` | `65_536` | Serialized message cap, both directions. |\n| `invitationTtlMs` | `number` | `300_000` | Invitation validity window. |\n| `iceGatheringTimeoutMs` | `number` | `5_000` | ICE gathering cap; also bounds the channel-open wait. |\n| `serialize` / `deserialize` | functions | `JSON.stringify` / `JSON.parse` | Message envelope serialization. |\n| `rtc` | `MeshRtcFactory` | `globalThis.RTCPeerConnection` | Injection point for tests and non-browser runtimes. |\n| `clock` | `() => number` | `Date.now` | TTLs and message timestamps. |\n| `random` | `RandomSource` | `crypto.getRandomValues` | Ids and secrets. |\n| `signal` | `AbortSignal` | — | Disposes the node on abort. |\n| `approvePeer` | `(peer: MeshPeerInfo) => boolean \\| Promise<boolean>` | approve | Runs after proof verification; `false` rejects with `MeshPairingError`. |\n\n#### Example\n\n```ts\nimport { createMeshHost, meshCodec } from '@vielzeug/mesh';\n\nconst host = createMeshHost<AppProtocol>({ approvePeer: (peer) => peer.name !== undefined });\nconst invitation = await host.createInvitation();\n```\n\n---\n\n### createMeshGuest()\n\n```ts\nfunction createMeshGuest<P extends MeshProtocol>(options?: MeshGuestOptions): MeshGuest<P>;\n```\n\nReturns a guest node in status `'idle'`. `MeshGuestOptions` is `MeshOptions` — the shared option table above without `approvePeer`.\n\n#### Example\n\n```ts\nimport { createMeshGuest, meshCodec } from '@vielzeug/mesh';\n\nconst guest = createMeshGuest<AppProtocol>();\nconst answer = await guest.acceptInvitation(meshCodec.decode(invitationText), { name: 'Sam' });\n```\n\n## Host Surface\n\n`MeshHost<P>` extends `MeshNode`.\n\n| Member | Signature | Purpose |\n| --- | --- | --- |\n| `createInvitation` | `(meta?: { name?: string }) => Promise<MeshInvitation>` | Single-use invitation for one guest; repeatable — one per guest. |\n| `acceptAnswer` | `(answer: MeshAnswer) => Promise<MeshPeer>` | Verifies proof + approval, then resolves once the channel opens. Rejects `MeshPairingError` (unknown/expired/duplicate session, proof mismatch, refusal) or `MeshTimeoutError`. |\n| `peers` | `ReadonlyMap<string, MeshPeer>` | Live peer inventory keyed by peer id. |\n| `send` | `<K extends keyof P['toGuest'] & string>(peerId: string, type: K, payload: P['toGuest'][K]) => void` | Typed unicast; throws `MeshConnectionError` for unknown/unconnected peers, `MeshPayloadError` over the cap. |\n| `broadcast` | `<K extends keyof P['toGuest'] & string>(type: K, payload: P['toGuest'][K], options?: { except?: readonly string[] }) => void` | Sends to every connected peer not in `except`. |\n| `on` | `<K extends keyof P['toHost'] & string>(type: K, handler: (message: MeshInbound<P['toHost'][K]>) => void) => Unsubscribe` | Typed inbound subscription from any guest. |\n| `onPeer` | `(handler: (event: MeshPeerEvent) => void) => Unsubscribe` | `'joined'` / `'left'` / `'status-change'` per peer. |\n| `kick` | `(peerId: string, reason?: string) => void` | Disconnects a peer; the guest observes `'disconnected'`. |\n\n## Guest Surface\n\n`MeshGuest<P>` extends `MeshNode`.\n\n| Member | Signature | Purpose |\n| --- | --- | --- |\n| `acceptInvitation` | `(invitation: MeshInvitation, meta?: { name?: string }) => Promise<MeshAnswer>` | Consumes an invitation; the returned answer goes back to the host out-of-band. Throws `MeshPairingError` on expired/malformed input or when already paired. |\n| `host` | `MeshPeer \\| null` | The host peer once pairing started; its `id` is the invitation's `sessionId`. |\n| `send` | `<K extends keyof P['toHost'] & string>(type: K, payload: P['toHost'][K]) => void` | Typed send to the host; throws `MeshConnectionError` when unpaired. |\n| `on` | `<K extends keyof P['toGuest'] & string>(type: K, handler: (message: MeshInbound<P['toGuest'][K]>) => void) => Unsubscribe` | Typed inbound subscription from the host. |\n\n## Shared Surface\n\n`MeshNode` members common to host and guest.\n\n| Member | Signature | Purpose |\n| --- | --- | --- |\n| `id` | `string` | This node's id. |\n| `status` | `MeshStatus` | Current lifecycle state. |\n| `disposed` | `boolean` | Whether the node is permanently disposed. |\n| `disposalSignal` | `AbortSignal` | Aborted by `dispose()`. |\n| `dispose` | `() => void` | Idempotent teardown: closes every connection, aborts `disposalSignal`, emits `'dispose'`, detaches tappers. |\n| `[Symbol.dispose]` | `() => void` | Enables `using` declarations. |\n| `tap` | `(handler: (event: MeshEvent) => void, options?: { signal?: AbortSignal }) => Unsubscribe` | Observability stream; handler errors are swallowed. |\n\n## Codec\n\n```ts\nconst meshCodec: {\n encode(payload: MeshInvitation | MeshAnswer): string;\n decode(text: string): MeshInvitation | MeshAnswer;\n};\n```\n\nCompact JSON → base64url for pairing payloads, kept pure so other encodings can slot in later. `decode` validates shape and `v`, throwing `MeshPairingError` on malformed text, wrong version, or unrecognized payload.\n\n```ts\nconst text = meshCodec.encode(await host.createInvitation());\nconst answer = await guest.acceptInvitation(meshCodec.decode(text));\nawait host.acceptAnswer(meshCodec.decode(meshCodec.encode(answer)));\n```\n\n### `meshQrCodec`\n\n```ts\nconst meshQrCodec: {\n encode(payload: MeshInvitation | MeshAnswer): Promise<string>;\n decode(text: string): Promise<MeshInvitation | MeshAnswer>;\n};\n```\n\nQR-oriented variant: deflate-raw compresses the JSON payload, then base64url-encodes it with an `mq1.` prefix. `decode` accepts both `mq1.*` and plain `meshCodec` output, so camera scans and paste fallbacks share one path.\n\n```ts\nconst invitationText = await meshQrCodec.encode(await host.createInvitation()); // \"mq1.…\"\nconst answer = await host.acceptAnswer(await meshQrCodec.decode(scannedText));\n```\n\n`encode` throws `MeshUnsupportedError` where `CompressionStream` is missing; `decode` throws `MeshPairingError` on corrupt base64/deflate/JSON and `MeshUnsupportedError` where `DecompressionStream` is missing.\n\n## Types\n\n### Protocol\n\n```ts\ninterface MeshProtocol {\n readonly toHost: MeshMessageMap; // messages a guest may send\n readonly toGuest: MeshMessageMap; // messages the host may send\n}\n\ntype MeshMessageMap = Record<string, unknown>;\n```\n\n### Pairing payloads\n\n```ts\ninterface MeshInvitation {\n readonly v: 1;\n readonly sessionId: string; // 128-bit, base64url\n readonly secret: string; // 256-bit, base64url\n readonly expiresAt: number; // epoch ms\n readonly sdp: string; // host offer, candidates inlined\n}\n\ninterface MeshAnswer {\n readonly v: 1;\n readonly sessionId: string;\n readonly proof: string; // HMAC-SHA-256 of the answer SDP keyed by secret\n readonly peer: { readonly id: string; readonly name?: string };\n readonly sdp: string;\n}\n```\n\n### Peers, status, messages\n\n```ts\ntype MeshStatus = 'idle' | 'pairing' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'disposed';\n\ninterface MeshPeerInfo {\n readonly id: string;\n readonly name?: string;\n}\n\ninterface MeshPeer extends MeshPeerInfo {\n readonly status: MeshStatus;\n readonly role: 'host' | 'guest';\n}\n\ntype MeshPeerEvent =\n | { readonly type: 'joined'; readonly peer: MeshPeer }\n | { readonly type: 'left'; readonly peer: MeshPeer; readonly reason?: string }\n | { readonly type: 'status-change'; readonly peer: MeshPeer };\n\ninterface MeshInbound<T> {\n readonly peerId: string;\n readonly messageId: string;\n readonly sentAt: number;\n readonly payload: T;\n}\n\ntype Unsubscribe = () => void;\n```\n\n### Events\n\n```ts\ntype MeshEvent =\n | { readonly type: 'status-change'; readonly peerId: string | null; readonly status: MeshStatus }\n | { readonly type: 'invitation-created' | 'invitation-expired'; readonly sessionId: string }\n | { readonly type: 'peer-approved' | 'peer-rejected' | 'peer-joined' | 'peer-left'; readonly peerId: string; readonly reason?: string }\n | { readonly type: 'message-sent' | 'message-received'; readonly peerId: string; readonly messageType: string; readonly bytes: number }\n | { readonly type: 'message-rejected'; readonly peerId: string; readonly reason: 'too-large' | 'duplicate' | 'malformed' | 'unknown-type' }\n | { readonly type: 'ice-state'; readonly peerId: string; readonly state: string }\n | { readonly type: 'security-downgrade'; readonly reason: string }\n | { readonly type: 'error'; readonly error: MeshError }\n | { readonly type: 'dispose' };\n```\n\n`peerId: null` on `'status-change'` marks a node-level transition; a string marks a per-peer one.\n\n### Options\n\n```ts\ninterface MeshChannelOptions {\n readonly ordered?: boolean;\n readonly maxRetransmits?: number;\n readonly label?: string;\n}\n\ninterface MeshOptions {\n readonly iceServers?: readonly RTCIceServer[];\n readonly channel?: MeshChannelOptions;\n readonly maxMessageBytes?: number;\n readonly invitationTtlMs?: number;\n readonly iceGatheringTimeoutMs?: number;\n readonly serialize?: (value: unknown) => string;\n readonly deserialize?: (text: string) => unknown;\n readonly rtc?: MeshRtcFactory;\n readonly clock?: () => number;\n readonly random?: RandomSource;\n readonly signal?: AbortSignal;\n}\n\ninterface MeshHostOptions extends MeshOptions {\n readonly approvePeer?: (peer: MeshPeerInfo) => boolean | Promise<boolean>;\n}\n\ntype MeshGuestOptions = MeshOptions;\n```\n\n### WebRTC injection\n\nMinimal structural subsets — the surface mesh actually calls, no more. Implement them over `wrtc` or a test double to run outside a browser.\n\n```ts\ninterface MeshRtcFactory {\n createPeerConnection(config: RTCConfiguration): RTCPeerConnectionLike;\n}\n\ninterface RTCPeerConnectionLike {\n readonly connectionState: string;\n readonly iceConnectionState: string;\n readonly iceGatheringState: string;\n readonly localDescription: RTCSessionDescriptionLike | null;\n createDataChannel(label: string, init?: { ordered?: boolean; maxRetransmits?: number }): RTCDataChannelLike;\n createOffer(): Promise<RTCSessionDescriptionLike>;\n createAnswer(): Promise<RTCSessionDescriptionLike>;\n setLocalDescription(description: RTCSessionDescriptionLike): Promise<void>;\n setRemoteDescription(description: RTCSessionDescriptionLike): Promise<void>;\n close(): void;\n addEventListener(type: 'datachannel', listener: (event: { readonly channel: RTCDataChannelLike }) => void): void;\n addEventListener(type: 'icegatheringstatechange' | 'iceconnectionstatechange' | 'connectionstatechange', listener: () => void): void;\n addEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n removeEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n}\n\ninterface RTCDataChannelLike {\n readonly readyState: string;\n send(data: string): void;\n close(): void;\n addEventListener(type: 'open' | 'close' | 'error', listener: () => void): void;\n addEventListener(type: 'message', listener: (event: { readonly data: string }) => void): void;\n addEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n removeEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n}\n\ninterface RTCSessionDescriptionLike {\n readonly type?: 'offer' | 'answer' | 'pranswer' | 'rollback';\n readonly sdp?: string;\n}\n\ninterface MeshRtcEvent {\n readonly data?: unknown;\n readonly channel?: RTCDataChannelLike;\n}\n```\n\n## Errors\n\n| Class | Thrown when | Notable properties |\n| --- | --- | --- |\n| `MeshError` | Base class — never thrown directly | `instanceof MeshError` catches all mesh errors |\n| `MeshPairingError` | Malformed/expired/unknown invitation or answer, proof mismatch, duplicate answer, `approvePeer` refusal, codec `decode` failure | — |\n| `MeshConnectionError` | ICE/DTLS failure, channel closed, send to unknown/unconnected peer, offer/answer creation failure | `peerId: string \\| null` |\n| `MeshPayloadError` | Outbound message over `maxMessageBytes` or unserializable | — |\n| `MeshTimeoutError` | Channel-open wait exceeded | — |\n| `MeshDisposedError` | Any method called after `dispose()` | — |\n| `MeshUnsupportedError` | No `RTCPeerConnection` in the environment and no `rtc` injected — raised at first use, never at import | — |\n",
|
|
6
|
-
"usage": "---\ntitle: Mesh — Usage Guide\ndescription: How to use @vielzeug/mesh.\n---\n\n[[toc]]\n\n## Basic Usage\n\nDeclare the protocol once, create a host and a guest, and exchange the pairing payloads out-of-band. `RTCPeerConnection` requires a secure context (`https:` or `localhost`).\n\n```ts\nimport { createMeshGuest, createMeshHost, meshCodec, type MeshProtocol } from '@vielzeug/mesh';\n\ninterface AppProtocol extends MeshProtocol {\n toHost: { note: string };\n toGuest: { ack: { ok: boolean } };\n}\n\nconst host = createMeshHost<AppProtocol>();\nconst guest = createMeshGuest<AppProtocol>();\n\n// Host produces an invitation; the guest turns it into an answer.\nconst invitationText = meshCodec.encode(await host.createInvitation());\nconst answerText = meshCodec.encode(await guest.acceptInvitation(meshCodec.decode(invitationText)));\n\n// The host resolves once the data channel is open.\nawait host.acceptAnswer(meshCodec.decode(answerText));\n\nguest.send('note', 'hello');\nhost.on('note', (m) => host.send(m.peerId, 'ack', { ok: true }));\n```\n\n## Pairing Flow\n\nPairing is a two-leg manual exchange. Each leg is a base64url string produced by `meshCodec.encode` and consumed by `meshCodec.decode`.\n\n```ts\n// Host device — one invitation per prospective guest\nconst invitation = await host.createInvitation();\nconst text = meshCodec.encode(invitation);\n```\n\nDeliver `text` any way you can: a textarea the user copies, or `navigator.share` on mobile:\n\n```ts\nif (navigator.share) {\n await navigator.share({ text, title: 'Join my session' });\n}\n```\n\nThe guest decodes it and returns an answer the same way:\n\n```ts\nconst answer = await guest.acceptInvitation(meshCodec.decode(text), { name: 'Sam' });\nconst answerText = meshCodec.encode(answer); // back to the host\nconst peer = await host.acceptAnswer(meshCodec.decode(answerText));\n```\n\nRules that shape the flow:\n\n- An invitation is **single-use** — call `createInvitation()` once per guest.\n- It **expires** at `expiresAt` (default `invitationTtlMs: 300_000`). Expired invitations are refused on both sides and cleaned up on the host.\n- The answer carries a **proof** derived from the invitation's secret, so a swapped or forged answer is rejected before any connection attempt.\n- `approvePeer` runs after proof verification — return `false` to refuse a guest with `MeshPairingError` and a `'peer-rejected'` tap.\n\n```ts\nconst host = createMeshHost<AppProtocol>({\n approvePeer: (peer) => allowlist.has(peer.id),\n});\n```\n\nOn the guest, the host's `peerId` is the invitation's `sessionId` — the invitation carries no other host identity. On the host, a guest's `peerId` is the id the guest generated for its answer.\n\n### Pairing over QR\n\n`meshQrCodec` is an async codec that compresses pairing payloads (deflate-raw + base64url, `mq1.` prefix) so they fit a QR code comfortably. `decode` accepts both the compressed `mq1.*` form and plain `meshCodec` output — paste fallback and camera scanning work through one code path.\n\n```ts\nimport { meshQrCodec } from '@vielzeug/mesh';\nimport { encodeQr, toSvg } from '@vielzeug/sigil';\n\n// Async — CompressionStream-based. Throws MeshUnsupportedError where streams are missing.\nconst invitationText = await meshQrCodec.encode(await host.createInvitation());\ndocument.querySelector('#qr').innerHTML = toSvg(encodeQr(invitationText));\n\nconst answer = await host.acceptAnswer(await meshQrCodec.decode(scannedAnswerText));\n```\n\n`encode` throws `MeshUnsupportedError` where `CompressionStream`/`DecompressionStream` are unavailable — keep the plain `meshCodec` paste flow as a fallback. The [sigil pairing recipe](../sigil/examples/pair-two-devices.md) shows the full two-device flow, and `ore-qr-code`/`ore-qr-scanner` (Refine) wrap the rendering and camera sides.\n\n## Sending and Receiving\n\n`send`/`on`/`broadcast` are typed from the protocol. Outbound messages serialize through `serialize` (default `JSON.stringify`) into a versioned envelope with id, type, timestamp, and payload.\n\n```ts\nhost.send(peerId, 'ack', { ok: true }); // one guest\nhost.broadcast('ack', { ok: true }, { except: [peerId] }); // all but one\nguest.send('note', 'from the guest');\n```\n\nInbound messages arrive as `MeshInbound<T>` — `{ peerId, messageId, sentAt, payload }`. Rules:\n\n- Payloads over `maxMessageBytes` (default 65 536) throw `MeshPayloadError` outbound; oversized inbound frames are dropped and tapped as `'message-rejected'` with reason `'too-large'`.\n- Duplicate message ids (e.g. after a retry) are dropped and tapped as `'duplicate'`.\n- A type with no registered `on` handler is tapped as `'unknown-type'` — never thrown.\n- `send` to an unknown or unconnected peer throws `MeshConnectionError`.\n\n## Peer Lifecycle\n\nNode `status` moves through `idle → pairing → connecting → connected`, then `disconnected`, `failed`, or `disposed`. Watch transitions — and everything else — through `tap`:\n\n```ts\nhost.tap((event) => {\n if (event.type === 'status-change' && event.peerId === null) console.log('node:', event.status);\n if (event.type === 'peer-left') console.log('left:', event.peerId, event.reason);\n});\n```\n\nThe host gets structured peer events through `onPeer` and the live inventory through `peers`:\n\n```ts\nhost.onPeer(({ type, peer }) => console.log(type, peer.id, peer.status));\nhost.kick(peerId, 'bye'); // guest observes 'disconnected'\n```\n\n`peerId` on a `status-change` event distinguishes node-level (`null`) from per-peer transitions. Per-peer events also flow through `tap` so a single subscription observes the whole session.\n\nDispose nodes deterministically — `dispose()` is idempotent, aborts `disposalSignal`, emits a final `'dispose'` event, and detaches all tappers. Passing an `AbortSignal` option disposes on abort; `using` works too.\n\n## Timeouts and Limits\n\n| Option | Default | Meaning |\n| --- | --- | --- |\n| `invitationTtlMs` | `300_000` | How long an unanswered invitation stays valid. |\n| `iceGatheringTimeoutMs` | `5_000` | Caps ICE gathering — on timeout pairing proceeds with the candidates gathered so far. Also bounds the channel-open wait in `acceptAnswer` and the guest's never-opened-channel guard. |\n| `maxMessageBytes` | `65_536` | Serialized message cap, enforced in both directions. |\n\n`acceptAnswer` rejects with `MeshTimeoutError` when the channel never opens; the guest independently marks itself `'failed'` and taps an `'error'` event. Inject `clock` to drive TTLs deterministically in tests.\n\n## Security Model\n\nMesh reduces the manual-pairing attack surface but does not remove it:\n\n- Every invitation carries a random 256-bit **secret**; the answer must present a proof derived from it (HMAC-SHA-256 via `SubtleCrypto`, falling back to a non-cryptographic hash with a `'security-downgrade'` tap).\n- Invitations are **single-use and time-boxed**, so a leaked invite is only useful briefly.\n- `approvePeer` is the host's admission hook — use it for a name check, a shared passphrase confirmed out-of-band, or an allowlist.\n- `maxMessageBytes` caps every inbound frame; malformed frames are dropped, never dispatched.\n- **Roles are app-level.** A guest can send any declared `toHost` type with any payload — validate guest state on the host as if it came from a form. Never trust `peer.name` as an identity; it is self-declared.\n\n## Testing\n\nEvery WebRTC object comes from the injectable `rtc` factory — tests pair nodes fully in memory, with no browser. Model a fake on the package's own `src/__tests__/_fixtures.ts`: two peer connections wired through token SDP, with knobs for latency, drops, ICE failure, and stalled open.\n\n```ts\nconst host = createMeshHost<AppProtocol>({ rtc: fake.rtc, clock: () => now });\nconst guest = createMeshGuest<AppProtocol>({ rtc: fake.rtc });\n```\n\nThe [REPL examples](/repl) include a runnable in-memory pairing you can copy as a starting point.\n\n## Framework Integration\n\nA node is a plain object — own it in a composable/store and dispose on teardown.\n\n::: code-group\n\n```ts [React]\nimport { useEffect, useRef } from 'react';\nimport { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';\n\nexport function useMeshHost<P extends MeshProtocol>() {\n const ref = useRef<ReturnType<typeof createMeshHost<P>>>(null);\n if (!ref.current) ref.current = createMeshHost<P>();\n useEffect(() => () => ref.current?.dispose(), []);\n return ref.current;\n}\n```\n\n```ts [Vue]\nimport { onUnmounted } from 'vue';\nimport { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';\n\nexport function useMeshHost<P extends MeshProtocol>() {\n const host = createMeshHost<P>();\n onUnmounted(() => host.dispose());\n return host;\n}\n```\n\n```ts [Svelte]\nimport { onDestroy } from 'svelte';\nimport { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';\n\nexport function createHost<P extends MeshProtocol>() {\n const host = createMeshHost<P>();\n onDestroy(() => host.dispose());\n return host;\n}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nMesh builds on `@vielzeug/arsenal` for random ids, secrets, and the fallback proof hash — no extra wiring needed. Pair it with `herald` when you want mesh messages to feed an in-process event bus:\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\nconst bus = createBus<{ 'guest-note': string }>();\nhost.on('note', (m) => bus.emit('guest-note', m.payload));\n```\n\n## Best Practices\n\n- Pair over `meshCodec` strings, never raw SDP — the transport restores the CRLF line endings that copy/paste mangles.\n- Create **one invitation per guest**; never reuse or forward an invitation.\n- Keep `invitationTtlMs` short for physical-proximity pairing and always gate unknown guests through `approvePeer`.\n- Subscribe `on` handlers **before** `acceptAnswer` resolves so early messages are not tapped as `'unknown-type'`.\n- Validate every guest payload on the host — treat `toHost` messages as untrusted input.\n- `dispose()` both nodes on teardown; pass `signal` to bind disposal to a controller you already own.\n- Inject `rtc` and `clock` in tests instead of mocking globals.\n- Plan re-pairing, not reconnection: after a reload, create a fresh invitation and let the app re-associate the stable `peerId`.\n",
|
|
7
|
-
"examples": "---\ntitle: Mesh
|
|
4
|
+
"index": "---\ntitle: 'Mesh: Backendless peer-to-peer sessions'\ndescription: \"Backendless peer-to-peer session transport over WebRTC data channels with manual pairing and host-authoritative star topology\"\npackage: mesh\ncategory: webrtc\nkeywords: [webrtc, p2p, peer-to-peer, datachannel, lan, pairing, backendless, signaling-free]\nexports: [createMeshHost, createMeshGuest, meshCodec, MeshProtocol]\nrelated: [pulse, arsenal, herald]\nenvironments: [browser, node]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"mesh\" />\n\n## Why Mesh?\n\nBrowsers cannot discover peers on a LAN, so sharing live state between nearby devices has always meant running a server. Mesh pairs devices out-of-band: copy/paste, `navigator.share`, or QR via `meshQrCodec`, and then moves typed messages directly over a WebRTC data channel with no backend involved.\n\n```ts\n// Before\nconst pc = new RTCPeerConnection();\nconst dc = pc.createDataChannel('sync');\nawait pc.setLocalDescription(await pc.createOffer());\n// …wait for ICE, hand the SDP to the other device, wire expiry, timeouts,\n// message framing, and dedupe yourself: per guest\n\n// After\nconst host = createMeshHost<AppProtocol>();\nconst invitation = await host.createInvitation();\nshareText(meshCodec.encode(invitation)); // copy/paste or navigator.share\nconst peer = await host.acceptAnswer(meshCodec.decode(answerText));\nhost.send(peer.id, 'snapshot', { rev: 1 });\n```\n\n| Feature | Mesh | `pulse` (WebSocket) | Raw `RTCPeerConnection` |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"mesh\" type=\"size\" /> | <PackageInfo package=\"pulse\" type=\"size\" /> | 0 B |\n| Runtime dependencies | `@vielzeug/arsenal` | <ore-icon name=\"check\" size=\"16\"></ore-icon> none | <ore-icon name=\"check\" size=\"16\"></ore-icon> none |\n| Works without a server | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Typed message protocol | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Multi-peer sessions | Star, host-authoritative | Server rooms | Manual per-peer wiring |\n| Pairing safety | Proof + TTL + approval hook | Server auth | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Mesh when** a small group of devices on one network must exchange live state and you cannot, or do not want to: run a server.\n\n**Consider `pulse` when** you have a backend anyway, need internet-wide reach, or want server-managed rooms and presence.\n\n</div>\n\n## Limitations\n\nMesh is deliberately small. It does **not** provide:\n\n- Automatic LAN discovery: every pairing is an explicit out-of-band exchange.\n- Guest-to-guest relay: guests only ever talk to the host.\n- Reconnection across page reloads: a reload destroys the `RTCPeerConnection`; the consumer re-pairs and re-associates the stable `peerId`.\n- CRDTs or conflict resolution: payload semantics are yours.\n- TURN: there is no guarantee peers connect off-LAN.\n- Persistence, or any UI.\n\nTwo operational caveats matter in practice: the host page must stay open for the session to live, and client-isolated Wi-Fi, VPNs, or strict firewalls can block even same-LAN pairing.\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/mesh\n```\n\n```sh [npm]\nnpm install @vielzeug/mesh\n```\n\n```sh [yarn]\nyarn add @vielzeug/mesh\n```\n\n:::\n\n## Quick Start\n\nPair one host and one guest, then exchange typed messages. Pairing requires a secure context (`https:` or `localhost`) for `RTCPeerConnection`.\n\n```ts\nimport { createMeshGuest, createMeshHost, meshCodec, type MeshProtocol } from '@vielzeug/mesh';\n\n// The consumer declares both directions of the conversation.\ninterface AppProtocol extends MeshProtocol {\n toHost: { note: string };\n toGuest: { ack: { ok: boolean } };\n}\n\n// Host side\nconst host = createMeshHost<AppProtocol>();\nhost.on('note', (message) => host.send(message.peerId, 'ack', { ok: true }));\n\nconst invitation = await host.createInvitation();\nconst invitationText = meshCodec.encode(invitation); // hand to the guest\n\n// Guest side (other device)\nconst guest = createMeshGuest<AppProtocol>();\nconst answer = await guest.acceptInvitation(meshCodec.decode(invitationText));\nconst answerText = meshCodec.encode(answer); // hand back to the host\n\n// Host accepts the answer; resolves once the channel is open\ntry {\n await host.acceptAnswer(meshCodec.decode(answerText));\n guest.send('note', 'hello over WebRTC');\n} finally {\n host.dispose();\n guest.dispose();\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **`createMeshHost`**: host-authoritative star sessions; one `RTCPeerConnection` per guest.\n- **`createMeshGuest`**: single-host guest node with its own lifecycle.\n- **`meshCodec`**: compact base64url encoding for pairing payloads; pure and replaceable.\n- **Typed protocol**: declare `toHost`/`toGuest` maps once; `send`, `on`, and `broadcast` stay type-checked.\n- **Manual pairing**: invitation/answer exchange over copy/paste or `navigator.share`; proof of possession, TTL, and an `approvePeer` hook.\n- **`tap` observability**: status transitions, ICE state, byte counts, and rejections without affecting behavior.\n- **`peers` + `kick`**: host-side peer inventory and removal; join/leave arrive as `tap` events carrying the live peer.\n- **Injectable `rtc`**: every WebRTC object comes from a factory, so tests run fully in memory; `@vielzeug/mesh/testing` ships the in-memory fake.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Pulse](/pulse/): typed WebSocket sessions when a server is available.\n- [Arsenal](/arsenal/): the utilities Mesh builds on for ids, randomness, and hashing.\n- [Herald](/herald/): in-process event bus for wiring mesh messages into your app.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: 'Mesh: API Reference'\ndescription: Public API of @vielzeug/mesh.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution | Common gotcha |\n| --- | --- | --- | --- |\n| `createMeshHost` | Host-authoritative session node; pairs one `RTCPeerConnection` per guest | Sync | `createInvitation`/`acceptAnswer` are async; calls after `dispose()` throw `MeshDisposedError` |\n| `createMeshGuest` | Guest node that pairs with one host | Sync | Second `acceptInvitation` throws `MeshPairingError` |\n| `meshCodec` | base64url encode/decode for pairing payloads | Sync | `decode` throws `MeshPairingError` on malformed text or wrong `v` |\n| `meshQrCodec` | deflate-raw + base45 codec for QR-sized payloads | Async | Falls back to plain `meshCodec` output where `CompressionStream` is missing |\n| `MeshProtocol` | Declares the `toHost`/`toGuest` message maps | N/A | Extend it; the maps themselves stay plain records |\n| `MeshError` | Base class for all mesh errors | N/A | `instanceof MeshError` catches every mesh-originated error |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/mesh` | Complete public Mesh API |\n\n## Factory Functions\n\n### createMeshHost()\n\n```ts\nfunction createMeshHost<P extends MeshProtocol>(options?: MeshHostOptions): MeshHost<P>;\n```\n\nReturns a host node in status `'idle'`. Creating it never touches WebRTC: the first `createInvitation()` does.\n\n#### Options (`MeshHostOptions`)\n\n| Option | Type | Default | Purpose |\n| --- | --- | --- | --- |\n| `iceServers` | `readonly RTCIceServer[]` | `[]` | Passed to the peer connection; mesh ships no STUN/TURN. |\n| `channel` | `MeshChannelOptions` | `{ ordered: true, label: 'mesh' }` | Data-channel init mapped to `RTCDataChannelInit`. |\n| `maxMessageBytes` | `number` | `65_536` | Serialized message cap, both directions. |\n| `invitationTtlMs` | `number` | `300_000` | Invitation validity window. |\n| `iceGatheringTimeoutMs` | `number` | `5_000` | ICE gathering cap; pairing proceeds with gathered candidates on timeout. |\n| `channelOpenTimeoutMs` | `number` | `60_000` | Channel-open wait in `acceptAnswer` and on the guest: generous to cover the human carry-back of the answer. |\n| `rtc` | `MeshRtcFactory` | `globalThis.RTCPeerConnection` | Injection point for tests and non-browser runtimes. |\n| `clock` | `() => number` | `Date.now` | TTLs and message timestamps. |\n| `random` | `RandomSource` | `crypto.getRandomValues` | Ids and secrets. |\n| `signal` | `AbortSignal` | N/A | Disposes the node on abort. |\n| `approvePeer` | `(peer: MeshPeerInfo) => boolean \\| Promise<boolean>` | approve | Runs after proof verification; `false` rejects with `MeshPairingError`. |\n\n#### Example\n\n```ts\nimport { createMeshHost, meshCodec } from '@vielzeug/mesh';\n\nconst host = createMeshHost<AppProtocol>({ approvePeer: (peer) => peer.name !== undefined });\nconst invitation = await host.createInvitation();\n```\n\n---\n\n### createMeshGuest()\n\n```ts\nfunction createMeshGuest<P extends MeshProtocol>(options?: MeshGuestOptions): MeshGuest<P>;\n```\n\nReturns a guest node in status `'idle'`. `MeshGuestOptions` is `MeshOptions`: the shared option table above without `approvePeer`.\n\n#### Example\n\n```ts\nimport { createMeshGuest, meshCodec } from '@vielzeug/mesh';\n\nconst guest = createMeshGuest<AppProtocol>();\nconst answer = await guest.acceptInvitation(meshCodec.decode(invitationText), { name: 'Sam' });\n```\n\n## Host Surface\n\n`MeshHost<P>` extends `MeshNode`.\n\n| Member | Signature | Purpose |\n| --- | --- | --- |\n| `createInvitation` | `(meta?: { name?: string }) => Promise<MeshInvitation>` | Single-use invitation for one guest; repeatable: one per guest. `meta.name` becomes this host's peer name on the guest side. |\n| `acceptAnswer` | `(answer: MeshAnswer) => Promise<MeshPeer>` | Verifies proof + approval, then resolves once the channel opens. Rejects `MeshPairingError` (unknown/expired/duplicate session, duplicate peer id, proof mismatch, refusal) or `MeshTimeoutError`. |\n| `peers` | `ReadonlyMap<string, MeshPeer>` | Live peer inventory keyed by peer id. |\n| `send` | `<K extends keyof P['toGuest'] & string>(peerId: string, type: K, payload: P['toGuest'][K]) => void` | Typed unicast; throws `MeshConnectionError` for unknown/unconnected peers, `MeshPayloadError` over the cap. |\n| `broadcast` | `<K extends keyof P['toGuest'] & string>(type: K, payload: P['toGuest'][K], options?: { except?: readonly string[] }) => void` | Sends to every connected peer not in `except`. |\n| `on` | `<K extends keyof P['toHost'] & string>(type: K, handler: (message: MeshInbound<P['toHost'][K]>) => void) => Unsubscribe` | Typed inbound subscription from any guest. |\n| `kick` | `(peerId: string, reason?: string) => void` | Disconnects a peer; the guest observes `'disconnected'`. |\n\n## Guest Surface\n\n`MeshGuest<P>` extends `MeshNode`.\n\n| Member | Signature | Purpose |\n| --- | --- | --- |\n| `acceptInvitation` | `(invitation: MeshInvitation, meta?: { name?: string }) => Promise<MeshAnswer>` | Consumes an invitation; the returned answer goes back to the host out-of-band. Throws `MeshPairingError` on expired/malformed input or while a live host peer exists; after the host peer fails or disconnects, a fresh invitation re-pairs on the same node with listeners intact. |\n| `host` | `MeshPeer \\| null` | The host peer once pairing started; its `id` is the invitation's `sessionId`. |\n| `send` | `<K extends keyof P['toHost'] & string>(type: K, payload: P['toHost'][K]) => void` | Typed send to the host; throws `MeshConnectionError` when unpaired. |\n| `on` | `<K extends keyof P['toGuest'] & string>(type: K, handler: (message: MeshInbound<P['toGuest'][K]>) => void) => Unsubscribe` | Typed inbound subscription from the host. |\n\n## Shared Surface\n\n`MeshNode` members common to host and guest.\n\n| Member | Signature | Purpose |\n| --- | --- | --- |\n| `status` | `MeshStatus` | Current lifecycle state. |\n| `disposed` | `boolean` | Whether the node is permanently disposed. |\n| `disposalSignal` | `AbortSignal` | Aborted by `dispose()`. |\n| `dispose` | `() => void` | Idempotent teardown: closes every connection, aborts `disposalSignal`, emits `'dispose'`, detaches tappers. |\n| `[Symbol.dispose]` | `() => void` | Enables `using` declarations. |\n| `tap` | `(handler: (event: MeshEvent) => void, options?: { signal?: AbortSignal }) => Unsubscribe` | Observability stream; handler errors are swallowed. |\n\n## Codec\n\n```ts\nconst meshCodec: {\n encode(payload: MeshInvitation | MeshAnswer): string;\n decode(text: string): MeshInvitation | MeshAnswer;\n};\n```\n\nCompact JSON → base64url for pairing payloads, kept pure so other encodings can slot in later. `decode` validates shape and `v`, throwing `MeshPairingError` on malformed text, wrong version, or unrecognized payload.\n\n```ts\nconst text = meshCodec.encode(await host.createInvitation());\nconst answer = await guest.acceptInvitation(meshCodec.decode(text));\nawait host.acceptAnswer(meshCodec.decode(meshCodec.encode(answer)));\n```\n\n### `meshQrCodec`\n\n```ts\nconst meshQrCodec: {\n encode(payload: MeshInvitation | MeshAnswer): Promise<string>;\n decode(text: string): Promise<MeshInvitation | MeshAnswer>;\n};\n```\n\nQR-oriented variant: deflate-raw compresses the JSON payload, then base45-encodes it with an `mq2.` prefix. `encode` falls back to plain `meshCodec` output where `CompressionStream` is unavailable, so it never fails on a missing capability. `decode` accepts both `mq2.*` and plain `meshCodec` output, so camera scans and paste fallbacks share one path.\n\n```ts\nconst invitationText = await meshQrCodec.encode(await host.createInvitation()); // \"mq2.…\"\nconst answer = await host.acceptAnswer(await meshQrCodec.decode(scannedText));\n```\n\n`encode` never throws for a missing capability: it falls back to plain `meshCodec` output where `CompressionStream` is unavailable. `decode` throws `MeshPairingError` on corrupt base45/deflate/JSON and `MeshUnsupportedError` only when the text is `mq2.`-compressed but `DecompressionStream` is missing.\n\n## Types\n\n### Protocol\n\n```ts\ninterface MeshProtocol {\n readonly toHost: MeshMessageMap; // messages a guest may send\n readonly toGuest: MeshMessageMap; // messages the host may send\n}\n\ntype MeshMessageMap = Record<string, unknown>;\n```\n\n### Pairing payloads\n\n```ts\ninterface MeshInvitation {\n readonly v: 1;\n readonly sessionId: string; // 128-bit, base64url\n readonly secret: string; // 256-bit, base64url\n readonly expiresAt: number; // epoch ms\n readonly hostName?: string; // host display name for the guest's peer list\n readonly sdp: string; // host offer, candidates inlined\n}\n\ninterface MeshAnswer {\n readonly v: 1;\n readonly sessionId: string;\n readonly proof: string; // HMAC-SHA-256 of the answer SDP keyed by secret\n readonly peer: { readonly id: string; readonly name?: string };\n readonly sdp: string;\n}\n```\n\n### Peers, status, messages\n\n```ts\ntype MeshStatus = 'idle' | 'pairing' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'disposed';\n\ntype MeshPeerStatus = 'connecting' | 'connected' | 'disconnected' | 'failed';\n\ninterface MeshPeerInfo {\n readonly id: string;\n readonly name?: string;\n}\n\ninterface MeshPeer extends MeshPeerInfo {\n readonly status: MeshPeerStatus;\n readonly role: 'host' | 'guest';\n}\n\ninterface MeshInbound<T> {\n readonly peerId: string;\n readonly messageId: string;\n readonly sentAt: number;\n readonly payload: T;\n}\n\ntype Unsubscribe = () => void;\n```\n\n### Events\n\n```ts\ntype MeshEvent =\n | { readonly type: 'status-change'; readonly status: MeshStatus }\n | { readonly type: 'peer-status-change'; readonly peerId: string; readonly status: MeshPeerStatus }\n | { readonly type: 'invitation-created' | 'invitation-expired'; readonly sessionId: string }\n | { readonly type: 'peer-approved' | 'peer-rejected'; readonly peerId: string }\n | { readonly type: 'peer-joined'; readonly peer: MeshPeer }\n | { readonly type: 'peer-left'; readonly peer: MeshPeer; readonly reason?: string }\n | { readonly type: 'message-sent' | 'message-received'; readonly peerId: string; readonly messageType: string; readonly bytes: number }\n | { readonly type: 'message-rejected'; readonly peerId: string; readonly reason: 'too-large' | 'duplicate' | 'malformed' | 'unknown-type' }\n | { readonly type: 'ice-state'; readonly peerId: string; readonly state: string }\n | { readonly type: 'error'; readonly error: MeshError }\n | { readonly type: 'dispose' };\n```\n\n`status-change` reports node-level transitions; `peer-status-change` reports one peer's transition and always names a peer id that exists in `peers` (or `guest.host`).\n\n### Options\n\n```ts\ninterface MeshChannelOptions {\n readonly ordered?: boolean;\n readonly maxRetransmits?: number;\n readonly label?: string;\n}\n\ninterface MeshOptions {\n readonly iceServers?: readonly RTCIceServer[];\n readonly channel?: MeshChannelOptions;\n readonly maxMessageBytes?: number;\n readonly invitationTtlMs?: number;\n readonly iceGatheringTimeoutMs?: number;\n readonly rtc?: MeshRtcFactory;\n readonly clock?: () => number;\n readonly random?: RandomSource;\n readonly signal?: AbortSignal;\n}\n\ninterface MeshHostOptions extends MeshOptions {\n readonly approvePeer?: (peer: MeshPeerInfo) => boolean | Promise<boolean>;\n}\n\ntype MeshGuestOptions = MeshOptions;\n```\n\n### WebRTC injection\n\nMinimal structural subsets: the surface mesh actually calls, no more. Implement them over `wrtc` or a test double to run outside a browser.\n\n```ts\ninterface MeshRtcFactory {\n createPeerConnection(config: RTCConfiguration): RTCPeerConnectionLike;\n}\n\ninterface RTCPeerConnectionLike {\n readonly connectionState: string;\n readonly iceConnectionState: string;\n readonly iceGatheringState: string;\n readonly localDescription: RTCSessionDescriptionLike | null;\n createDataChannel(label: string, init?: { ordered?: boolean; maxRetransmits?: number }): RTCDataChannelLike;\n createOffer(): Promise<RTCSessionDescriptionLike>;\n createAnswer(): Promise<RTCSessionDescriptionLike>;\n setLocalDescription(description: RTCSessionDescriptionLike): Promise<void>;\n setRemoteDescription(description: RTCSessionDescriptionLike): Promise<void>;\n close(): void;\n addEventListener(type: 'datachannel', listener: (event: { readonly channel: RTCDataChannelLike }) => void): void;\n addEventListener(type: 'icegatheringstatechange' | 'iceconnectionstatechange' | 'connectionstatechange', listener: () => void): void;\n addEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n removeEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n}\n\ninterface RTCDataChannelLike {\n readonly readyState: string;\n send(data: string): void;\n close(): void;\n addEventListener(type: 'open' | 'close' | 'error', listener: () => void): void;\n addEventListener(type: 'message', listener: (event: { readonly data: string }) => void): void;\n addEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n removeEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n}\n\ninterface RTCSessionDescriptionLike {\n readonly type?: 'offer' | 'answer' | 'pranswer' | 'rollback';\n readonly sdp?: string;\n}\n\ninterface MeshRtcEvent {\n readonly data?: unknown;\n readonly channel?: RTCDataChannelLike;\n}\n```\n\n## Errors\n\n| Class | Thrown when | Notable properties |\n| --- | --- | --- |\n| `MeshError` | Base class: never thrown directly | `instanceof MeshError` catches all mesh errors |\n| `MeshPairingError` | Malformed/expired/unknown invitation or answer, proof mismatch, duplicate answer, `approvePeer` refusal, codec `decode` failure | N/A |\n| `MeshConnectionError` | ICE/DTLS failure, channel closed, send to unknown/unconnected peer, offer/answer creation failure | `peerId: string \\| null` |\n| `MeshPayloadError` | Outbound message over `maxMessageBytes` or unserializable | N/A |\n| `MeshTimeoutError` | Channel-open wait exceeded | N/A |\n| `MeshDisposedError` | Any method called after `dispose()` | N/A |\n| `MeshUnsupportedError` | No `RTCPeerConnection` in the environment and no `rtc` injected (raised at first use, never at import), or `meshQrCodec.decode` given `mq2.` text without `DecompressionStream` | N/A |\n",
|
|
6
|
+
"usage": "---\ntitle: 'Mesh: Usage Guide'\ndescription: How to use @vielzeug/mesh.\n---\n\n[[toc]]\n\n## Basic Usage\n\nDeclare the protocol once, create a host and a guest, and exchange the pairing payloads out-of-band. `RTCPeerConnection` requires a secure context (`https:` or `localhost`).\n\n```ts\nimport { createMeshGuest, createMeshHost, meshCodec, type MeshProtocol } from '@vielzeug/mesh';\n\ninterface AppProtocol extends MeshProtocol {\n toHost: { note: string };\n toGuest: { ack: { ok: boolean } };\n}\n\nconst host = createMeshHost<AppProtocol>();\nconst guest = createMeshGuest<AppProtocol>();\n\n// Host produces an invitation; the guest turns it into an answer.\nconst invitationText = meshCodec.encode(await host.createInvitation());\nconst answerText = meshCodec.encode(await guest.acceptInvitation(meshCodec.decode(invitationText)));\n\n// The host resolves once the data channel is open.\nawait host.acceptAnswer(meshCodec.decode(answerText));\n\nguest.send('note', 'hello');\nhost.on('note', (m) => host.send(m.peerId, 'ack', { ok: true }));\n```\n\n## Pairing Flow\n\nPairing is a two-leg manual exchange. Each leg is a base64url string produced by `meshCodec.encode` and consumed by `meshCodec.decode`.\n\n```ts\n// Host device: one invitation per prospective guest\nconst invitation = await host.createInvitation();\nconst text = meshCodec.encode(invitation);\n```\n\nDeliver `text` any way you can: a textarea the user copies, or `navigator.share` on mobile:\n\n```ts\nif (navigator.share) {\n await navigator.share({ text, title: 'Join my session' });\n}\n```\n\nThe guest decodes it and returns an answer the same way:\n\n```ts\nconst answer = await guest.acceptInvitation(meshCodec.decode(text), { name: 'Sam' });\nconst answerText = meshCodec.encode(answer); // back to the host\nconst peer = await host.acceptAnswer(meshCodec.decode(answerText));\n```\n\nRules that shape the flow:\n\n- An invitation is **single-use**: call `createInvitation()` once per guest.\n- It **expires** at `expiresAt` (default `invitationTtlMs: 300_000`). Expired invitations are refused on both sides and cleaned up on the host.\n- The answer carries a **proof** derived from the invitation's secret, so a swapped or forged answer is rejected before any connection attempt.\n- `approvePeer` runs after proof verification: return `false` to refuse a guest with `MeshPairingError` and a `'peer-rejected'` tap.\n\n```ts\nconst host = createMeshHost<AppProtocol>({\n approvePeer: (peer) => allowlist.has(peer.id),\n});\n```\n\nOn the guest, the host's `peerId` is the invitation's `sessionId`: the invitation carries no other host identity. On the host, a guest's `peerId` is the id the guest generated for its answer.\n\n### Pairing over QR\n\n`meshQrCodec` is an async codec that compresses pairing payloads (deflate-raw + base45, `mq2.` prefix) so they fit a QR code comfortably. `decode` accepts both the compressed `mq2.*` form and plain `meshCodec` output: paste fallback and camera scanning work through one code path.\n\n```ts\nimport { meshQrCodec } from '@vielzeug/mesh';\nimport { encodeQr, toSvg } from '@vielzeug/sigil';\n\n// Async: deflate + base45 through CompressionStream, with a plain-codec\n// fallback where the stream API is missing.\nconst invitationText = await meshQrCodec.encode(await host.createInvitation());\ndocument.querySelector('#qr').innerHTML = toSvg(encodeQr(invitationText));\n\nconst answer = await host.acceptAnswer(await meshQrCodec.decode(scannedAnswerText));\n```\n\n`encode` falls back to plain `meshCodec` output where `CompressionStream` is unavailable, so it never fails on a missing capability: check for the `mq2.` prefix if you need to know which form you got. `decode` accepts both forms interchangeably and throws `MeshUnsupportedError` only when the text is `mq2.`-compressed but `DecompressionStream` is missing. The [sigil pairing recipe](../sigil/examples/pair-two-devices.md) shows the full two-device flow, and `ore-qr-code`/`ore-qr-scanner` (Refine) wrap the rendering and camera sides.\n\n## Sending and Receiving\n\n`send`/`on`/`broadcast` are typed from the protocol. Outbound messages serialize through `JSON.stringify` into a versioned envelope with id, type, timestamp, and payload.\n\n```ts\nhost.send(peerId, 'ack', { ok: true }); // one guest\nhost.broadcast('ack', { ok: true }, { except: [peerId] }); // all but one\nguest.send('note', 'from the guest');\n```\n\nInbound messages arrive as `MeshInbound<T>`: `{ peerId, messageId, sentAt, payload }`. Rules:\n\n- Payloads over `maxMessageBytes` (default 65 536) throw `MeshPayloadError` outbound; oversized inbound frames are dropped and tapped as `'message-rejected'` with reason `'too-large'`.\n- Duplicate message ids (e.g. after a retry) are dropped and tapped as `'duplicate'`.\n- A type with no registered `on` handler is tapped as `'unknown-type'`: never thrown.\n- `send` to an unknown or unconnected peer throws `MeshConnectionError`.\n\n## Peer Lifecycle\n\nNode `status` moves through `idle → pairing → connecting → connected`, then `disconnected`, `failed`, or `disposed`. Watch transitions, and everything else: through `tap`:\n\n```ts\nhost.tap((event) => {\n if (event.type === 'status-change') console.log('node:', event.status);\n if (event.type === 'peer-status-change') console.log('peer:', event.peerId, event.status);\n if (event.type === 'peer-left') console.log('left:', event.peer.id, event.reason);\n});\n```\n\n`peer-joined` and `peer-left` carry the live `MeshPeer`; the inventory itself lives on `peers`:\n\n```ts\nhost.tap((event) => {\n if (event.type === 'peer-joined') console.log('peers now:', [...host.peers.keys()]);\n});\nhost.kick(peerId, 'bye'); // guest observes 'disconnected'\n```\n\n`status-change` reports node-level transitions; `peer-status-change` reports a single peer moving between `'connecting' | 'connected' | 'disconnected' | 'failed'`. A guest whose host peer failed or disconnected can re-pair on the same node: call `acceptInvitation` again with a fresh invitation and existing `on`/`tap` listeners keep working.\n\nDispose nodes deterministically: `dispose()` is idempotent, aborts `disposalSignal`, emits a final `'dispose'` event, and detaches all tappers. Passing an `AbortSignal` option disposes on abort; `using` works too.\n\n## Timeouts and Limits\n\n| Option | Default | Meaning |\n| --- | --- | --- |\n| `invitationTtlMs` | `300_000` | How long an unanswered invitation stays valid. |\n| `iceGatheringTimeoutMs` | `5_000` | Caps ICE gathering: on timeout pairing proceeds with the candidates gathered so far. |\n| `channelOpenTimeoutMs` | `60_000` | How long `acceptAnswer` and the guest wait for the data channel to open: deliberately generous because it covers the human carry-back of the answer. |\n| `maxMessageBytes` | `65_536` | Serialized message cap, enforced in both directions. |\n\n`acceptAnswer` rejects with `MeshTimeoutError` when the channel never opens; the guest independently marks itself `'failed'` and taps an `'error'` event. Inject `clock` to drive TTLs deterministically in tests.\n\n## Security Model\n\nMesh reduces the manual-pairing attack surface but does not remove it:\n\n- Every invitation carries a random 256-bit **secret**; the answer must present a proof derived from it (HMAC-SHA-256 via `SubtleCrypto`). Pairing requires a secure context: without `SubtleCrypto` both ends throw `MeshUnsupportedError` rather than fall back to a forgeable hash.\n- Invitations are **single-use and time-boxed**, so a leaked invite is only useful briefly.\n- `approvePeer` is the host's admission hook: use it for a name check, a shared passphrase confirmed out-of-band, or an allowlist.\n- `maxMessageBytes` caps every inbound frame; malformed frames are dropped, never dispatched.\n- **Roles are app-level.** A guest can send any declared `toHost` type with any payload: validate guest state on the host as if it came from a form. Never trust `peer.name` as an identity; it is self-declared.\n\n## Testing\n\nEvery WebRTC object comes from the injectable `rtc` factory: tests pair nodes fully in memory, with no browser. `@vielzeug/mesh/testing` ships the in-memory fake the package's own tests use: two peer connections wired through token SDP, with knobs for latency, drops, ICE failure, stalled gathering, and a never-opening channel.\n\n```ts\nimport { createFakeRtc } from '@vielzeug/mesh/testing';\n\nconst fx = createFakeRtc();\nconst host = createMeshHost<AppProtocol>({ rtc: fx.rtc, clock: () => now });\nconst guest = createMeshGuest<AppProtocol>({ rtc: fx.rtc });\n```\n\nThe [REPL examples](/repl) include a runnable in-memory pairing you can copy as a starting point.\n\n## Framework Integration\n\nA node is a plain object: own it in a composable/store and dispose on teardown.\n\n::: code-group\n\n```ts [React]\nimport { useEffect, useRef } from 'react';\nimport { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';\n\nexport function useMeshHost<P extends MeshProtocol>() {\n const ref = useRef<ReturnType<typeof createMeshHost<P>>>(null);\n if (!ref.current) ref.current = createMeshHost<P>();\n useEffect(() => () => ref.current?.dispose(), []);\n return ref.current;\n}\n```\n\n```ts [Vue]\nimport { onUnmounted } from 'vue';\nimport { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';\n\nexport function useMeshHost<P extends MeshProtocol>() {\n const host = createMeshHost<P>();\n onUnmounted(() => host.dispose());\n return host;\n}\n```\n\n```ts [Svelte]\nimport { onDestroy } from 'svelte';\nimport { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';\n\nexport function createHost<P extends MeshProtocol>() {\n const host = createMeshHost<P>();\n onDestroy(() => host.dispose());\n return host;\n}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nMesh builds on `@vielzeug/arsenal` for random ids, secrets, and base64url encoding: no extra wiring needed. Pair it with `herald` when you want mesh messages to feed an in-process event bus:\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\nconst bus = createBus<{ 'guest-note': string }>();\nhost.on('note', (m) => bus.emit('guest-note', m.payload));\n```\n\n## Best Practices\n\n- Pair over `meshCodec` strings, never raw SDP: the transport restores the CRLF line endings that copy/paste mangles.\n- Create **one invitation per guest**; never reuse or forward an invitation.\n- Keep `invitationTtlMs` short for physical-proximity pairing and always gate unknown guests through `approvePeer`.\n- Subscribe `on` handlers **before** `acceptAnswer` resolves so early messages are not tapped as `'unknown-type'`.\n- Validate every guest payload on the host: treat `toHost` messages as untrusted input.\n- `dispose()` both nodes on teardown; pass `signal` to bind disposal to a controller you already own.\n- Inject `rtc` and `clock` in tests instead of mocking globals.\n- Plan re-pairing, not reconnection: after a reload, create a fresh invitation and let the app re-associate the stable `peerId`.\n",
|
|
7
|
+
"examples": "---\ntitle: 'Mesh: Examples'\ndescription: Practical examples and recipes for @vielzeug/mesh.\n---\n\n## Examples\n\n- [Pair Two Devices](./examples/pair-two-devices.md)\n- [Host-Authoritative Command Loop](./examples/host-authoritative-command-loop.md)\n"
|
|
8
8
|
},
|
|
9
9
|
"examples": [
|
|
10
10
|
{
|
|
11
11
|
"id": "pairing",
|
|
12
|
-
"code": "import { createMeshGuest, createMeshHost, meshCodec } from '@vielzeug/mesh'\n\n// In-memory WebRTC fake
|
|
12
|
+
"code": "import { createMeshGuest, createMeshHost, meshCodec } from '@vielzeug/mesh'\n\n// In-memory WebRTC fake: the same 'rtc' injection point the package's own\n// tests use. In a real app, omit 'rtc' and globalThis.RTCPeerConnection is used.\nconst fakeRtc = (() => {\n const links = new Map()\n const bag = () => {\n const ls = new Map()\n return {\n addEventListener: (t, f) => ls.set(t, [...(ls.get(t) ?? []), f]),\n removeEventListener: (t, f) => ls.set(t, (ls.get(t) ?? []).filter((x) => x !== f)),\n emit: (t, e = {}) => (ls.get(t) ?? []).forEach((f) => f(e)),\n }\n }\n const channel = () =>\n Object.assign(bag(), {\n readyState: 'connecting',\n remote: null,\n send(data) {\n if (this.remote?.readyState === 'open') queueMicrotask(() => this.remote.emit('message', { data }))\n },\n open() {\n this.readyState = 'open'\n this.emit('open')\n },\n close() {\n if (this.readyState === 'closed') return\n this.readyState = 'closed'\n this.emit('close')\n this.remote?.close()\n },\n })\n const pc = (token) =>\n Object.assign(bag(), {\n connectionState: 'new',\n iceConnectionState: 'new',\n iceGatheringState: 'new',\n localDescription: null,\n createDataChannel() {\n this.dc = channel()\n return this.dc\n },\n createOffer() {\n links.set(token, { host: this })\n return Promise.resolve({ sdp: 'offer:' + token, type: 'offer' })\n },\n createAnswer() {\n const link = [...links.values()].find((l) => l.guest === this)\n return Promise.resolve({ sdp: 'answer:' + link.hostToken + ':' + token, type: 'answer' })\n },\n setLocalDescription(d) {\n this.localDescription = d\n this.iceGatheringState = 'complete'\n return Promise.resolve()\n },\n setRemoteDescription(d) {\n if (d.type === 'offer') {\n // sdp is CRLF-normalized like a real description: trim the token\n const hostToken = d.sdp.split(':')[1].trim()\n const link = links.get(hostToken)\n link.guest = this\n link.hostToken = hostToken\n return Promise.resolve()\n }\n const hostToken = d.sdp.replace('answer:', '').split(':')[0].trim()\n const link = links.get(hostToken)\n const guestChannel = channel()\n const hostChannel = link.host.dc\n hostChannel.remote = guestChannel\n guestChannel.remote = hostChannel\n link.guest.emit('datachannel', { channel: guestChannel })\n queueMicrotask(() => {\n hostChannel.open()\n guestChannel.open()\n })\n return Promise.resolve()\n },\n close() {\n this.dc?.close()\n },\n })\n let n = 0\n return { createPeerConnection: () => pc('pc' + ++n) }\n})()\n\nconst host = createMeshHost({ rtc: fakeRtc })\nconst guest = createMeshGuest({ rtc: fakeRtc })\n\nguest.on('ack', (m) => console.log('guest got ack:', m.payload.ok, 'from', m.peerId))\nhost.on('note', (m) => {\n console.log('host got note:', m.payload)\n host.send(m.peerId, 'ack', { ok: true })\n})\n\n// The out-of-band legs are plain strings: copy/paste or navigator.share\nconst invitationText = meshCodec.encode(await host.createInvitation())\nconsole.log('invitation bytes:', invitationText.length)\n\nconst answerText = meshCodec.encode(await guest.acceptInvitation(meshCodec.decode(invitationText)))\nconst peer = await host.acceptAnswer(meshCodec.decode(answerText))\n\nconsole.log('paired: host sees peer', peer.id, '| status:', host.status, '/', guest.status)\n\nguest.send('note', 'hello over the fake channel')\nawait new Promise((r) => setTimeout(r, 0))\n\nhost.dispose()\nguest.dispose()\nconsole.log('disposed:', host.status, '/', guest.status)",
|
|
13
13
|
"name": "Pairing & Messaging"
|
|
14
14
|
}
|
|
15
15
|
],
|
|
@@ -24,31 +24,29 @@
|
|
|
24
24
|
"MeshUnsupportedError": "export {\n MeshConnectionError,\n MeshDisposedError,\n MeshError,\n MeshPairingError,\n MeshPayloadError,\n MeshTimeoutError,\n MeshUnsupportedError,\n} from './errors';",
|
|
25
25
|
"createMeshGuest": "export { createMeshGuest } from './guest';",
|
|
26
26
|
"createMeshHost": "export { createMeshHost } from './host';",
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"RTCSessionDescriptionLike": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshCodec,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerEvent,\n MeshPeerInfo,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
52
|
-
"Unsubscribe": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshCodec,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerEvent,\n MeshPeerInfo,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';"
|
|
27
|
+
"meshQrCodec": "export { meshQrCodec } from './qr-codec';",
|
|
28
|
+
"MeshAnswer": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
29
|
+
"MeshChannelOptions": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
30
|
+
"MeshEvent": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
31
|
+
"MeshGuest": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
32
|
+
"MeshGuestOptions": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
33
|
+
"MeshHost": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
34
|
+
"MeshHostOptions": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
35
|
+
"MeshInbound": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
36
|
+
"MeshInvitation": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
37
|
+
"MeshMessageMap": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
38
|
+
"MeshNode": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
39
|
+
"MeshOptions": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
40
|
+
"MeshPeer": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
41
|
+
"MeshPeerInfo": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
42
|
+
"MeshPeerStatus": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
43
|
+
"MeshProtocol": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
44
|
+
"MeshRtcEvent": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
45
|
+
"MeshRtcFactory": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
46
|
+
"MeshStatus": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
47
|
+
"RTCDataChannelLike": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
48
|
+
"RTCPeerConnectionLike": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
49
|
+
"RTCSessionDescriptionLike": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';",
|
|
50
|
+
"Unsubscribe": "export type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';"
|
|
53
51
|
}
|
|
54
52
|
}
|