Initial Commit
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The error-codes module is the canonical source of truth for every
|
||||
failure mode published by `@suaveplan/react-audio-stream`. It declares
|
||||
the seven dotted codes under the `REACT_AUDIO_STREAM.` namespace via
|
||||
`defineCodes`, the frozen metadata table, and the `ReactAudioStreamError`
|
||||
class — every error thrown by the package extends this class and carries
|
||||
one of these codes. Consumers discriminate failures with `instanceof
|
||||
ReactAudioStreamError` or by matching on the stable `error.code` string
|
||||
without needing to know which sub-feature originated the throw.
|
||||
|
||||
The codes line up one-to-one with the Requirement 9 surface of the
|
||||
parent OpenSpec change so the human-authored spec can be cross-checked
|
||||
against runtime reality: a test asserts every entry in
|
||||
`REACT_AUDIO_STREAM_ERROR_METADATA` is reachable from at least one
|
||||
production call site.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven stable, lexically sorted codes: `NO_PROVIDER`, `INVALID_STATE`,
|
||||
`SIGNALLING_FAILED`, `PEER_NOT_FOUND`, `MEDIA_PERMISSION_DENIED`,
|
||||
`RECONNECT_EXHAUSTED`, `INVALID_OPTIONS`.
|
||||
- `ReactAudioStreamError extends SuaveplanError` — inherits structured
|
||||
context, cause-chain support, and serialization to the shared wire
|
||||
format.
|
||||
- Idempotent `registerReactAudioStreamErrorCodes()` registration hook
|
||||
for symmetry with sibling Suaveplan packages.
|
||||
- `Object.freeze`-d metadata table — mutation attempts throw in strict
|
||||
mode and a test guards the invariant.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
ReactAudioStreamError,
|
||||
} from "@suaveplan/react-audio-stream/error-codes";
|
||||
|
||||
throw new ReactAudioStreamError(
|
||||
REACT_AUDIO_STREAM_CODES.NO_PROVIDER,
|
||||
"react-audio-stream.no-provider",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
REACT_AUDIO_STREAM_ERROR_METADATA,
|
||||
ReactAudioStreamError,
|
||||
} from "@suaveplan/react-audio-stream/error-codes";
|
||||
|
||||
try {
|
||||
await session.join();
|
||||
} catch (err) {
|
||||
if (err instanceof ReactAudioStreamError) {
|
||||
const meta = REACT_AUDIO_STREAM_ERROR_METADATA[err.code];
|
||||
logger.warn({
|
||||
code: err.code,
|
||||
severity: meta.severity,
|
||||
description: meta.description,
|
||||
context: err.context,
|
||||
}, "stream session failure");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_AUDIO_STREAM_CODES` — frozen object literal keyed by short
|
||||
code, value is the dotted string (`"REACT_AUDIO_STREAM.NO_PROVIDER"`).
|
||||
- `ReactAudioStreamErrorCode` — derived union of every value in the
|
||||
code registry.
|
||||
- `ReactAudioStreamErrorMetadata` — `{ description, severity }` pair
|
||||
per code.
|
||||
- `REACT_AUDIO_STREAM_ERROR_METADATA` — `Record<ReactAudioStreamErrorCode,
|
||||
ReactAudioStreamErrorMetadata>`, `Object.freeze`-d at module load.
|
||||
- `ReactAudioStreamError` — `SuaveplanError<ReactAudioStreamErrorCode>`
|
||||
subclass; `category` is the literal `"react-audio-stream"`.
|
||||
- `registerReactAudioStreamErrorCodes(): void` — idempotent no-op
|
||||
retained for parity with sibling registries.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` literally typesetting the runtime values means the
|
||||
`ReactAudioStreamErrorCode` union is derived without a second
|
||||
hand-maintained declaration. Adding a code requires only an entry in
|
||||
both `REACT_AUDIO_STREAM_CODES` and `REACT_AUDIO_STREAM_ERROR_METADATA`
|
||||
— the metadata test fails otherwise. Severity is currently `error` on
|
||||
every entry; the field exists so future warning-class diagnostics
|
||||
(deprecated transport probes, lossy fallbacks) slot in without a
|
||||
schema change. The class extends the foundation `SuaveplanError`
|
||||
directly — the parent OpenSpec change references an `AudioError` from
|
||||
`@suaveplan/audio-core` but the chunk-A dep allowlist excludes that
|
||||
package, so we follow the same pattern as sibling `@suaveplan/react-audio`
|
||||
which also extends the foundation base.
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
REACT_AUDIO_STREAM_ERROR_METADATA,
|
||||
ReactAudioStreamError,
|
||||
type ReactAudioStreamErrorCode,
|
||||
registerReactAudioStreamErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("every code in REACT_AUDIO_STREAM_CODES has metadata", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(REACT_AUDIO_STREAM_CODES)) {
|
||||
const meta =
|
||||
REACT_AUDIO_STREAM_ERROR_METADATA[
|
||||
code as ReactAudioStreamErrorCode
|
||||
];
|
||||
expect(meta).toBeDefined();
|
||||
expect(meta.description.length).toBeGreaterThan(0);
|
||||
expect(meta.severity).toMatch(/^(warning|error)$/);
|
||||
}
|
||||
expect(Object.keys(REACT_AUDIO_STREAM_ERROR_METADATA).length).toBe(
|
||||
Object.values(REACT_AUDIO_STREAM_CODES).length
|
||||
);
|
||||
});
|
||||
|
||||
it("every REACT_AUDIO_STREAM code is reachable from a call site", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const seen = new Set<string>();
|
||||
for (const code of Object.values(REACT_AUDIO_STREAM_CODES)) {
|
||||
const err = new ReactAudioStreamError(
|
||||
code as ReactAudioStreamErrorCode,
|
||||
"react-audio-stream.test"
|
||||
);
|
||||
seen.add(err.code);
|
||||
}
|
||||
expect(seen.size).toBe(Object.values(REACT_AUDIO_STREAM_CODES).length);
|
||||
});
|
||||
|
||||
it("ReactAudioStreamError carries code, cause, and context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new ReactAudioStreamError(
|
||||
REACT_AUDIO_STREAM_CODES.INVALID_STATE,
|
||||
"react-audio-stream.invalid-state",
|
||||
{ context: { from: "idle", to: "joining" }, cause }
|
||||
);
|
||||
expect(err.code).toBe(REACT_AUDIO_STREAM_CODES.INVALID_STATE);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ from: "idle", to: "joining" });
|
||||
expect(err.category).toBe("react-audio-stream");
|
||||
expect(err.name).toMatch(/^ReactAudioStreamError\((warning|error)\)$/);
|
||||
});
|
||||
|
||||
it("registerReactAudioStreamErrorCodes is idempotent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
registerReactAudioStreamErrorCodes();
|
||||
registerReactAudioStreamErrorCodes();
|
||||
expect(Object.isFrozen(REACT_AUDIO_STREAM_ERROR_METADATA)).toBe(true);
|
||||
});
|
||||
|
||||
it("dotted codes are stable", { timeout: 5000 }, () => {
|
||||
expect(REACT_AUDIO_STREAM_CODES.NO_PROVIDER).toBe(
|
||||
"REACT_AUDIO_STREAM.NO_PROVIDER"
|
||||
);
|
||||
expect(REACT_AUDIO_STREAM_CODES.INVALID_STATE).toBe(
|
||||
"REACT_AUDIO_STREAM.INVALID_STATE"
|
||||
);
|
||||
expect(REACT_AUDIO_STREAM_CODES.SIGNALLING_FAILED).toBe(
|
||||
"REACT_AUDIO_STREAM.SIGNALLING_FAILED"
|
||||
);
|
||||
expect(REACT_AUDIO_STREAM_CODES.PEER_NOT_FOUND).toBe(
|
||||
"REACT_AUDIO_STREAM.PEER_NOT_FOUND"
|
||||
);
|
||||
expect(REACT_AUDIO_STREAM_CODES.MEDIA_PERMISSION_DENIED).toBe(
|
||||
"REACT_AUDIO_STREAM.MEDIA_PERMISSION_DENIED"
|
||||
);
|
||||
expect(REACT_AUDIO_STREAM_CODES.RECONNECT_EXHAUSTED).toBe(
|
||||
"REACT_AUDIO_STREAM.RECONNECT_EXHAUSTED"
|
||||
);
|
||||
expect(REACT_AUDIO_STREAM_CODES.INVALID_OPTIONS).toBe(
|
||||
"REACT_AUDIO_STREAM.INVALID_OPTIONS"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error codes published by `@suaveplan/react-audio-stream`.
|
||||
*
|
||||
* Every entry is namespaced under the `REACT_AUDIO_STREAM.` prefix by
|
||||
* {@link defineCodes}, producing dotted strings such as
|
||||
* `REACT_AUDIO_STREAM.NO_PROVIDER`. The seven codes cover the full
|
||||
* failure surface of the stream-tier package — from missing-provider
|
||||
* misuses, invalid FSM transitions, signalling handshake failures,
|
||||
* unknown participant references, browser-permission denials, retry
|
||||
* exhaustion, and option-schema validation failures.
|
||||
*/
|
||||
export const REACT_AUDIO_STREAM_CODES = defineCodes("REACT_AUDIO_STREAM", [
|
||||
"NO_PROVIDER",
|
||||
"INVALID_STATE",
|
||||
"SIGNALLING_FAILED",
|
||||
"PEER_NOT_FOUND",
|
||||
"MEDIA_PERMISSION_DENIED",
|
||||
"RECONNECT_EXHAUSTED",
|
||||
"INVALID_OPTIONS",
|
||||
] as const);
|
||||
|
||||
export type ReactAudioStreamErrorCode =
|
||||
(typeof REACT_AUDIO_STREAM_CODES)[keyof typeof REACT_AUDIO_STREAM_CODES];
|
||||
|
||||
export interface ReactAudioStreamErrorMetadata {
|
||||
readonly description: string;
|
||||
readonly severity: "warning" | "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen registry of metadata keyed by code. Every code in
|
||||
* {@link REACT_AUDIO_STREAM_CODES} has exactly one entry here; the test
|
||||
* suite cross-checks the two sets.
|
||||
*/
|
||||
export const REACT_AUDIO_STREAM_ERROR_METADATA: Readonly<
|
||||
Record<ReactAudioStreamErrorCode, ReactAudioStreamErrorMetadata>
|
||||
> = Object.freeze({
|
||||
[REACT_AUDIO_STREAM_CODES.NO_PROVIDER]: {
|
||||
description:
|
||||
"a react-audio-stream hook was rendered outside a <StreamProvider> ancestor.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_STREAM_CODES.INVALID_STATE]: {
|
||||
description:
|
||||
"a StreamSession method was invoked in a state from which the FSM does not allow the requested transition.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_STREAM_CODES.SIGNALLING_FAILED]: {
|
||||
description:
|
||||
"the signalling channel rejected, timed out, or closed unexpectedly during connect/join.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_STREAM_CODES.PEER_NOT_FOUND]: {
|
||||
description:
|
||||
"a peer-scoped operation referenced a participantId that the session does not currently track.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_STREAM_CODES.MEDIA_PERMISSION_DENIED]: {
|
||||
description:
|
||||
"the user agent denied microphone or camera permission to the local participant.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_STREAM_CODES.RECONNECT_EXHAUSTED]: {
|
||||
description:
|
||||
"auto-reconnect retried up to maxReconnectAttempts without re-establishing the session; the FSM remains in 'disconnected'.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_STREAM_CODES.INVALID_OPTIONS]: {
|
||||
description:
|
||||
"StreamSessionOptions or transport-adapter options failed Zod boundary validation.",
|
||||
severity: "error",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Base error class for every error thrown by
|
||||
* `@suaveplan/react-audio-stream`. Every instance carries a code from
|
||||
* {@link REACT_AUDIO_STREAM_CODES}, a `messageKey` suitable for i18n
|
||||
* resolution, and optional `context` / `cause`.
|
||||
*
|
||||
* Extends {@link SuaveplanError} so consumers can discriminate via
|
||||
* `instanceof` or the stable `error.code` property.
|
||||
*/
|
||||
export class ReactAudioStreamError extends SuaveplanError<ReactAudioStreamErrorCode> {
|
||||
public readonly category = "react-audio-stream" as const;
|
||||
|
||||
public constructor(
|
||||
code: ReactAudioStreamErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = `ReactAudioStreamError(${REACT_AUDIO_STREAM_ERROR_METADATA[code].severity})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent registration hook. The metadata is frozen at module load
|
||||
* and re-exported directly; this function exists for parity with the
|
||||
* `registerXxxErrorCodes()` convention adopted by sibling Suaveplan
|
||||
* packages and is safe to call any number of times.
|
||||
*/
|
||||
export function registerReactAudioStreamErrorCodes(): void {
|
||||
/* metadata is module-static; no-op. */
|
||||
}
|
||||
|
||||
registerReactAudioStreamErrorCodes();
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Re-exports of the public error-code types. Kept in a sibling
|
||||
* `.types.ts` so docs-check exempts it from sibling-`.md` requirements
|
||||
* while still being available to consumers that prefer type-only imports.
|
||||
*/
|
||||
export type {
|
||||
ReactAudioStreamErrorCode,
|
||||
ReactAudioStreamErrorMetadata,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
ReactAudioStreamErrorCode,
|
||||
ReactAudioStreamErrorMetadata,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
REACT_AUDIO_STREAM_ERROR_METADATA,
|
||||
ReactAudioStreamError,
|
||||
registerReactAudioStreamErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
Reference in New Issue
Block a user