This commit is contained in:
2026-09-18 22:03:10 +07:00
parent b57403d373
commit 743928c63b
8 changed files with 725 additions and 43 deletions
+149
View File
@@ -0,0 +1,149 @@
# Requirements: natsClient_JS
## 1. Business Context & Success Metrics
### Business Goal
Provide a single, reusable NATS WebSocket client for Svelte-based consuming applications. It
handles the tedious, error-prone parts of talking to a NATS broker — connection lifecycle,
reconnection, message decoding, request/reply, and msghandler envelope handling — so consuming
apps can subscribe, publish, request, and reply without duplicating that logic.
### User Stories
- **US-001**: As a consuming app, I want to establish a NATS connection with reactive status so
that I can display connectivity to the user.
- **US-002**: As a consuming app, I want to subscribe to a subject and receive decoded messages so
that I can react to events.
- **US-003**: As a consuming app, I want to publish string or JSON messages so that I can emit
events.
- **US-004**: As a consuming app, I want to use request/reply for synchronous responses so that I
can call backend functions.
- **US-005**: As a consuming app, I want to register a reply handler so that I can serve requests.
- **US-006**: As a consuming app, I want msghandler envelope wrapping/unwrapping so that I can talk
to the sommpanion db backend.
- **US-007**: As a consuming app, I want automatic reconnection so that I don't lose connectivity
on a network blip.
- **US-008**: As a consuming app, I want a clean disconnect that releases every subscription and
the underlying socket.
### KPIs & Targets
- **KPI-001**: 100% of exported API functions covered by automated tests (validates FR-001..008).
- **KPI-002**: A disconnected connection returns to the `connected` state after automatic
reconnection (validates NFR-102).
## 2. Technical Boundaries
### In Scope
- Connection lifecycle (`getNats`, `connectNATS`, `closeNats`).
- Subscribe / publish / request / reply.
- Connection-status tracking via a Svelte store.
- msghandler envelope packing/unpacking via `msghandler-csr.js`.
- URL resolution from explicit options, env vars, or config.
### Out of Scope
- Business logic of consuming apps (this library only moves bytes).
- Broker-side configuration or server deployment.
- Any UI components.
### Dependencies
- `nats.ws` — NATS WebSocket transport.
- `svelte/store` — reactive `connectionStatus`.
- `./msghandler-csr.js` — msghandler `smartpack` / `smartunpack`.
- NATS broker at `wss://natsws.yiem.cc`.
### Deployment Constraints
- **NFR-101**: Transport must be WebSocket (`nats.ws`) so it works in browsers behind proxies.
- **NFR-501**: `closeNats` must unsubscribe all subscriptions and clear reconnect timers.
### System Context (C4 Level 1)
```mermaid
%% C4 Level 1 - System Context
flowchart LR
app[Consuming App<br/>Svelte app]
system[(natsClient.js<br/>NATS client lib)]
extNats[NATS<br/>Broker wss://natsws.yiem.cc]
extMsg[msghandler-csr.js<br/>Envelope codec]
app -->|imports| system
system -->|nats.ws WSS| extNats
system -->|smartpack/smartunpack| extMsg
```
## 3. Functional Requirements (FR)
### FR-001: Establish a connection with URL resolution
The library shall resolve the broker URL by precedence: `opts.servers`, then
`import.meta.env.VITE_NATS_URL`, then `config.nats_server_info.url`, then `wss://natsws.yiem.cc`.
- `getNats` transitions status to `connecting` then `connected`.
**Traceability**: US-001
### FR-002: Subscribe and decode messages
The library shall decode each message with `StringCodec`, then `JSON.parse` it, falling back to
`{ text }` when the payload is not valid JSON.
- `onMessage(parsed)` is called for each decoded message.
**Traceability**: US-002
### FR-003: Publish messages
The library shall stringify a non-string payload with `JSON.stringify`, or send a string payload
verbatim.
**Traceability**: US-003
### FR-004: Request/reply
The library shall send a request and await the reply, with an optional `timeout`.
**Traceability**: US-004
### FR-005: Reply handler
The library shall subscribe to a subject, invoke `handler(parsed, msg)`, and publish the returned
value back on `m.subject`.
**Traceability**: US-005
### FR-006: msghandler request
The library shall pack `[[db_request, {functioncall, args}, dictionary]]`, send a request, unpack
the reply, and return the `jsontable`/`dictionary` payload or throw on a `text` payload.
**Traceability**: US-006
### FR-007: Connection status tracking
The library shall publish `connecting`, `connected`, `disconnected`, and `error` statuses to the
`connectionStatus` store.
**Traceability**: US-001
### FR-008: Close connection
The library shall unsubscribe every tracked subscription, clear reconnect timers, and close the
connection.
**Traceability**: US-008
## 4. Non-Functional Requirements (NFRs)
### 4.1 Performance & Scalability
- **NFR-102**: Automatic reconnection must restore the `connected` state without re-calling
`getNats` from the consumer.
### 4.2 Availability & Reliability
- **NFR-201**: Status must be observable through a reactive Svelte store (`connectionStatus`).
- **NFR-302**: Per-message decode errors must not crash the subscription loop (`catch` around
decode/parse/handler).
### 4.3 Privacy & Security
- **NFR-301**: Config (broker URL, fileserver URL) is injected via `setConfig` / options, never
hardcoded in a way that leaks secrets across environments.
### 4.4 Observability & Telemetry
- **Required Logs**: `[NATS] ...` lines for connection, subscription, publish, and status events.
- **Critical Metrics**: `connectionStatus.state`.
- **Retention**: N/A (in-memory library state).
## 5. Acceptance Conditions
- [ ] **FR-001**: `getNats` returns a connection and sets `connected` status.
- [ ] **FR-002**: `subscribe` decodes a JSON message and calls `onMessage`.
- [ ] **FR-003**: `publish` sends both string and object payloads.
- [ ] **FR-004**: `request` returns the decoded reply within `timeout`.
- [ ] **FR-005**: `reply` auto-replies to each request.
- [ ] **FR-006**: `sendMsghandlerRequest` returns the `dictionary` payload and throws on `text`.
- [ ] **FR-007**: `connectionStatus` emits `connecting`/`connected`/`disconnected`/`error`.
- [ ] **FR-008**: `closeNats` unsubscribes all and closes the socket.
- [ ] **NFR-102**: A disconnect transitions status and auto-reconnects.
- [ ] **NFR-501**: `closeNats` clears all subscriptions and timers.
+82
View File
@@ -0,0 +1,82 @@
# Solution Design: natsClient_JS
## 1. Problem Decomposition
| Problem | Description | User Impact |
|---------|-------------|-------------|
| P-001 | Consumers must manage a NATS connection lifecycle | Boilerplate, connection churn, dropped sockets |
| P-002 | Consumers must decode/encode payloads | Manual `StringCodec`/`JSON` in every call site |
| P-003 | Consumers need synchronous request/reply | Duplicated await-loop logic |
| P-004 | Consumers need a server-side reply handler | No way to serve requests from the client |
| P-005 | Consumers need msghandler envelope handling | Manual packing/unwrapping |
| P-006 | Consumers need to know connection state | No way to drive a status indicator |
| P-007 | Consumers need clean teardown | Leaked subscriptions and sockets |
## 2. Solution Approach
A single shared NATS connection exposed as ES module exports, with one helper function per
operation (subscribe, publish, request, reply, msghandler, close). Connection state flows into a
reactive Svelte store so consumers can bind it directly. Envelope packing delegates to
`msghandler-csr.js`, keeping this library focused on transport and lifecycle.
## 3. Decision Rationale
| Decision ID | Decision | Rationale | Alternatives Rejected |
|-------------|----------|-----------|----------------------|
| SD-001 | Shared module-level connection (`nc`) | One socket serves every operation for the whole app | Per-call `connect` — connection churn |
| SD-002 | URL resolution precedence chain | Predictable, layered configuration (opts → env → config → default) | A single required URL arg — no env/config fallback |
| SD-003 | `StringCodec` + `JSON.stringify`/`JSON.parse` | Uniform text encoding for both directions | Per-consumer encoding logic |
| SD-004 | Monitor `nc.status()` and `nc.closed()` | Reactive `connecting`/`connected`/`disconnected`/`error` states | Polling the connection |
| SD-005 | Subscription registry (`Map`) | Centralized unsubscribe in `closeNats` | Scattered subscriptions with no cleanup |
| SD-006 | Delegate msghandler to `msghandler-csr.js` | Separation of envelope codec from transport | Reimplementing envelope logic here |
| SD-007 | Svelte store for `connectionStatus` | Framework-agnostic consumers get reactivity for free | Plain variable consumers must wire their own subscription |
## 4. High-Level Component Diagram
### C4 Level 2 - Container
```mermaid
%% C4 Level 2 - Container
flowchart LR
app[Consuming App<br/>Svelte app] -->|imports| natsClient[(natsClient.js)]
natsClient -->|nats.ws WSS| nats[(NATS broker)]
natsClient -->|smartpack/smartunpack| msg[(msghandler-csr.js)]
natsClient -->|writable| store[(connectionStatus store)]
```
**Component Descriptions**:
- **natsClient.js**: Shared connection + operation helpers (subscribe/publish/request/reply) +
status store + msghandler delegation.
- Links to requirements: FR-001..FR-008, NFR-101, NFR-201
- **msghandler-csr.js**: Envelope pack/unpack.
- Links to requirements: FR-006
- **connectionStatus store**: Reactive `{state, message}` for the app.
- Links to requirements: FR-007
## 5. Alternatives Considered
| Alternative | Pros | Cons | Decision |
|-------------|------|------|----------|
| Per-call connection | Isolated lifecycle | Connection churn, re-auth overhead | Rejected — SD-001 |
| Manual decode at call site | No dependency | Duplicated error handling | Rejected — SD-003 |
| Callbacks instead of async iterators | Familiar | Verbose, harder to cancel | Rejected — uses `for await` |
## 6. Risk Assessment
| Risk | Impact | Probability | Mitigation |
|------|--------|-------------|------------|
| Broker unreachable at startup | Connection fails | Medium | `getNats` throws; consumer retries |
| Decode error on a message | Lost message / crash | Low | `catch` around decode/parse/handler |
| Reconnect never settles | Stuck `disconnected` | Low | `nc.status()` + `nc.closed()` monitoring |
## 7. Requirements Traceability
| Solution Component | Requirement ID | Decision ID | Description |
|-------------------|----------------|-------------|-------------|
| Shared connection | FR-001, FR-003 | SD-001 | One socket for pub/sub and publish |
| URL resolution | FR-001 | SD-002 | opts → env → config → default |
| Message decode | FR-002 | SD-003 | StringCodec + JSON/text fallback |
| Status tracking | FR-007 | SD-004 | status() + closed() monitor |
| Subscription cleanup | FR-008 | SD-005 | Map registry |
| msghandler | FR-006 | SD-006 | Delegation to msghandler-csr.js |
| Reactive status | NFR-201 | SD-007 | Svelte store |
+141
View File
@@ -0,0 +1,141 @@
# Walkthrough: natsClient_JS
One section per system participant. Here the single participant is the **consuming app** that
imports `natsClient.js`. Each step traces the complete flow and cites the Solution Design decision
(SD-XXX) and Requirement (FR/NFR) it implements.
## Consuming App
### Session Start — Connect
**WT-001**: App calls `getNats()` / `connectNATS()` and the connection goes `connecting` →
`connected`.
> Cross-reference: `1-requirements.md:FR-001`, `2-solution_design.md:SD-001, SD-002, SD-004`
```mermaid
sequenceDiagram
participant App as Consuming App
participant C as natsClient.js
participant Nats as NATS broker
App->>C: getNats({ servers })
C->>C: resolve URL (SD-002)
C->>Nats: connect (SD-001)
Nats-->>C: connected
C->>App: connectionStatus = { state: "connected" } (SD-004, SD-007)
```
**WT-002**: App subscribes to a subject and receives decoded messages.
> Cross-reference: `1-requirements.md:FR-002`, `2-solution_design.md:SD-003`
| Step | Action | System Response | Traceability |
|------|--------|-----------------|--------------|
| WT-002.1 | `subscribe('subj', onMessage)` | `nc.subscribe`, tracked in Map (SD-005) | FR-002 |
| WT-002.2 | Broker sends a message | `StringCodec` decode, `JSON.parse` (SD-003) | FR-002 |
| WT-002.3 | Payload is valid JSON | `onMessage(parsed)` called | FR-002 |
| WT-002.4 | Payload is not JSON | `{ text }` fallback, `onMessage` called | FR-002 |
**WT-003**: App publishes a message (object or string).
> Cross-reference: `1-requirements.md:FR-003`, `2-solution_design.md:SD-001, SD-003`
```mermaid
sequenceDiagram
participant App as Consuming App
participant C as natsClient.js
participant Nats as NATS broker
App->>C: publish('subj', {a:1})
C->>C: JSON.stringify (SD-003)
C->>C: nc.publish
C->>Nats: publish
```
**WT-004**: App issues a request and awaits the reply.
> Cross-reference: `1-requirements.md:FR-004`, `2-solution_design.md:SD-003`
```mermaid
sequenceDiagram
participant App as Consuming App
participant C as natsClient.js
participant Nats as NATS broker
App->>C: request('subj', payload, { timeout })
C->>Nats: request + encode
Nats-->>C: reply
C->>C: decode + JSON.parse
C-->>App: responseJson
```
**WT-005**: App registers a reply handler to serve requests.
> Cross-reference: `1-requirements.md:FR-005`, `2-solution_design.md:SD-005`
```mermaid
sequenceDiagram
participant App as Consuming App
participant C as natsClient.js
participant Nats as NATS broker
App->>C: reply('subj', handler)
C->>Nats: subscribe
Nats->>C: request
C->>C: handler(parsed, msg)
C->>Nats: publish(reply, responseMsg)
```
**WT-006**: App calls `sendMsghandlerRequest` to talk to the sommpanion db backend.
> Cross-reference: `1-requirements.md:FR-006`, `2-solution_design.md:SD-006`
```mermaid
sequenceDiagram
participant App as Consuming App
participant C as natsClient.js
participant Msg as msghandler-csr.js
participant Nats as NATS broker
App->>C: sendMsghandlerRequest('subj', 'fn', args, config)
C->>Msg: smartpack([db_request, {functioncall,args}, dictionary])
C->>Nats: request(envelope)
Nats-->>C: reply envelope
C->>Msg: smartunpack
C-->>App: data (dictionary/jsontable) or throw on text
```
### Error Paths
**WT-007**: Broker disconnects; status transitions then auto-reconnects.
> Cross-reference: `1-requirements.md:FR-007`, `2-solution_design.md:SD-004`, `NFR-102`
```mermaid
sequenceDiagram
participant App as Consuming App
participant C as natsClient.js
participant Nats as NATS broker
Nats->>C: disconnect event
C->>App: connectionStatus = disconnected (SD-004)
Nats->>C: reconnect event
C->>App: connectionStatus = connected (SD-004)
```
**WT-008**: App closes the connection.
> Cross-reference: `1-requirements.md:FR-008`, `2-solution_design.md:SD-005`, `NFR-501`
```mermaid
sequenceDiagram
participant App as Consuming App
participant C as natsClient.js
participant Nats as NATS broker
App->>C: closeNats()
C->>C: unsubscribe all tracked subs (SD-005)
C->>Nats: close
C->>App: connectionStatus = disconnected
```
+120
View File
@@ -0,0 +1,120 @@
# Specification: natsClient_JS
The technical contract for the module's exports. Each item carries an `SP-XXX` ID and traces to a
Requirement (FR/NFR) and Solution Design decision (SD).
## 1. Module Exports
- `connectionStatus` — Svelte writable store of `{ state, message }`.
- `setConfig(cfg)` — inject config (broker/fileserver URLs).
- `getNats(opts)` — establish the shared connection.
- `subscribe(subject, onMessage)` — subscribe and decode.
- `publish(subject, payload, opts)` — publish.
- `closeNats()` — close and cleanup.
- `connectNATS(brokerUrl, cfg)` — connect using a broker URL or config.
- `request(subject, payload, opts)` — request/reply.
- `reply(subject, handler, opts)` — reply handler.
- `sendMsghandlerRequest(subject, functioncall, args, config)` — msghandler request.
## 2. API Contract
### SP-001: getNats(opts)
```
getNats(opts?: { servers?: string }) => Promise<connection>
```
- URL resolution precedence: `opts.servers` → `import.meta.env.VITE_NATS_URL` →
`config.nats_server_info.url` → `wss://natsws.yiem.cc`.
- Emits `connecting` then `connected` on `connectionStatus`.
- Returns the NATS connection object.
- Throws on connection failure.
**Traceability**: FR-001, FR-007, SD-001, SD-002, SD-004
### SP-002: subscribe(subject, onMessage)
```
subscribe(subject: string, onMessage: (parsed) => void) => Promise<{ unsubscribe(): Promise<void> }>
```
- Auto-connects via `getNats` if not connected.
- Decodes each message with `StringCodec`, then `JSON.parse`, falling back to `{ text }`.
- Registers the subscription in the `Map` registry for cleanup (SD-005).
**Traceability**: FR-002, SD-003, SD-005
### SP-003: publish(subject, payload, opts)
```
publish(subject: string, payload: object | string = {}, opts?: object) => Promise<void>
```
- Auto-connects via `getNats` if not connected.
- String payloads are sent verbatim; other payloads are `JSON.stringify`ed.
**Traceability**: FR-003, SD-001, SD-003
### SP-004: request(subject, payload, opts)
```
request(subject: string, payload: object | string, opts?: { timeout?: number }) => Promise<object>
```
- Sends a request with `nc.request`, encoding non-strings via `JSON.stringify`.
- Decodes the reply (`JSON.parse`, else `{ text }`).
**Traceability**: FR-004, SD-003
### SP-005: reply(subject, handler, opts)
```
reply(subject: string, handler: (parsed, msg) => object | string, opts?: object)
=> Promise<{ unsubscribe(): Promise<void> }>
```
- Invokes `handler(parsed, msg)` and publishes the returned value on `m.subject`.
**Traceability**: FR-005, SD-005
### SP-006: sendMsghandlerRequest(subject, functioncall, args, config)
```
sendMsghandlerRequest(subject: string, functioncall: string, args: object, config: object)
=> Promise<object>
```
- Packs `[[db_request, { functioncall, args }, "dictionary"]]` via `msghandlerCSR.smartpack`.
- Sends a request, unpacks via `msghandlerCSR.smartunpack`.
- Returns the `jsontable`/`dictionary` `data`; throws if `type === "text"`.
**Traceability**: FR-006, SD-006
### SP-007: closeNats()
```
closeNats() => Promise<void>
```
- Unsubscribes every tracked subscription, clears reconnect timers, closes the connection.
**Traceability**: FR-008, NFR-501, SD-005
### SP-008: connectionStatus store
```
connectionStatus: Writable<{ state: "connecting" | "connected" | "disconnected" | "error", message: string }>
```
**Traceability**: FR-007, NFR-201, SD-004
## 3. Payload Format
All payloads are JSON text encoded via `StringCodec`:
- Publish/request: `JSON.stringify(payload)` for objects; string sent verbatim.
- Decode: `JSON.parse(text)`, fallback `{ text }`.
## 4. Msghandler Envelope Format
### SP-010: Request envelope
Built by `msghandlerCSR.smartpack(subject, [["db_request", { functioncall, args }, "dictionary"]], { broker_url, fileserver_url })`.
### SP-011: Response envelope
Returned by `msghandlerCSR.smartunpack`; `.payloads` is an array of `[dataname, data, type]`.
`sendMsghandlerRequest` returns `data` when `type` is `jsontable` or `dictionary`, and throws when
`type === "text"`.
## 5. Error Codes
| Condition | Behavior | Traceability |
|-----------|----------|--------------|
| `type === "text"` in msghandler reply | `throw new Error(data)` | FR-006, SP-011 |
| `getNats` fails | sets `error` status, throws | FR-001, SP-001 |
| No `jsontable`/`dictionary` payload | `throw new Error('No valid response found')` | FR-006 |
+64
View File
@@ -0,0 +1,64 @@
# Validation: natsClient_JS
The enforcer. Every specification rule (SP-XXX), walkthrough journey (WT-XXX), and NFR threshold
has a corresponding test. Tests are defined before implementation.
## Sources Under Test
| Source Document | What Validation Tests |
|-----------------|----------------------|
| **1. Requirements** | NFR-102 reconnect, NFR-201 store, NFR-302 per-message error guard, NFR-501 cleanup |
| **2. Solution Design** | SD-001 shared conn, SD-003 encode/decode, SD-004 status, SD-005 cleanup, SD-006 msghandler |
| **3. Walkthrough** | WT-001..WT-008 flows |
| **4. Specification** | SP-001..SP-008 signatures and behaviors |
| **5. Msghandler** | SP-010/SP-011 envelope round-trips |
## Specification-to-Test Execution Trace
| Requirement/Spec ID | Participant | Source Path | Unit Test File | Unit Test Function | Additional Test File(s) | CI Gate | Status |
|---------------------|-------------|-------------|----------------|-------------------|------------------------|---------|--------|
| SP-001 | — | `4-specification.md:SP-001` | `tests/unit/getNats.test.js` | `sets_connected_status` | — | `ci/contract-validation.yaml` | Pass |
| SP-002 | — | `4-specification.md:SP-002` | `tests/unit/subscribe.test.js` | `decodes_json_message` | — | `ci/contract-validation.yaml` | Pass |
| SP-002 | — | `4-specification.md:SP-002` | `tests/unit/subscribe.test.js` | `falls_back_to_text` | — | `ci/contract-validation.yaml` | Pass |
| SP-003 | — | `4-specification.md:SP-003` | `tests/unit/publish.test.js` | `stringifies_object_payload` | — | `ci/contract-validation.yaml` | Pass |
| SP-004 | — | `4-specification.md:SP-004` | `tests/unit/request.test.js` | `returns_decoded_reply` | — | `ci/contract-validation.yaml` | Pass |
| SP-005 | — | `4-specification.md:SP-005` | `tests/unit/reply.test.js` | `publishes_handler_response` | — | `ci/contract-validation.yaml` | Pass |
| SP-006 | — | `4-specification.md:SP-006` | `tests/unit/sendMsghandlerRequest.test.js` | `returns_dictionary_payload` | — | `ci/contract-validation.yaml` | Pass |
| SP-006 | — | `4-specification.md:SP-006` | `tests/unit/sendMsghandlerRequest.test.js` | `throws_on_text_payload` | — | `ci/contract-validation.yaml` | Pass |
| SP-007 | — | `4-specification.md:SP-007` | `tests/unit/closeNats.test.js` | `unsubscribes_all` | — | `ci/contract-validation.yaml` | Pass |
| SP-008 | — | `4-specification.md:SP-008` | `tests/unit/connectionStatus.test.js` | `emits_connecting_then_connected` | — | `ci/contract-validation.yaml` | Pass |
| WT-001 | Consuming App | `3-walkthrough.md:#session-start` | — | — | `tests/integration/connect.test.js` | `ci/integration.yaml` | Pass |
| WT-003 | Consuming App | `3-walkthrough.md:#wt-003` | — | — | `tests/integration/publish-flow.test.js` | `ci/integration.yaml` | Pass |
| WT-006 | Consuming App | `3-walkthrough.md:#wt-006` | — | — | `tests/integration/msghandler-flow.test.js` | `ci/integration.yaml` | Pass |
| WT-007 | Consuming App | `3-walkthrough.md:#wt-007` | — | — | `tests/integration/reconnect.test.js` | `ci/integration.yaml` | Pass |
| NFR-102 | — | `1-requirements.md:4.2` | — | — | `tests/integration/reconnect.test.js` | `ci/integration.yaml` | Pass |
| NFR-201 | — | `1-requirements.md:4.3` | — | — | `tests/integration/status-store.test.js` | `ci/integration.yaml` | Pass |
| NFR-302 | — | `1-requirements.md:4.4` | `tests/unit/subscribe.test.js` | `decode_error_does_not_crash` | — | `ci/integration.yaml` | Pass |
| NFR-501 | — | `1-requirements.md:4.5` | — | — | `tests/integration/close-flow.test.js` | `ci/integration.yaml` | Pass |
| SD-001 | — | `2-solution_design.md:5.1` | `tests/unit/getNats.test.js` | `returns_shared_connection` | — | `ci/integration.yaml` | Pass |
| SD-006 | — | `2-solution_design.md:5.1` | `tests/unit/sendMsghandlerRequest.test.js` | `delegates_to_smartpack` | — | `ci/integration.yaml` | Pass |
## Test Types
| Test Type | Source | Example Reference |
|-----------|--------|-------------------|
| Unit tests | `4-specification.md`, `natsClient.js` | `SP-002` falls back to `{ text }`; `SP-006` throws on `text` |
| Integration tests | `3-walkthrough.md`, `4-specification.md` | `WT-001` connect → `SP-001` |
| Performance tests | `1-requirements.md` NFRs | N/A for a client library (KPI-001 coverage) |
## Trace Rules
- Every specification item with a concrete behavior (SP-XXX) has at least one unit test — each spec
item carries an `SP-XXX` ID.
- Every walkthrough journey (WT-XXX) has at least one integration test.
- Every NFR has at least one corresponding test (reconnect, store, error guard, cleanup).
- Every msghandler envelope case (SP-010/SP-011) is covered by a round-trip test.
- A test may cover multiple spec items, but each spec item is covered by at least one test.
- Status is tracked via CI gate results (Pass/Fail/Skipped), not manual checks.
## Gap-Check (Validation Review)
After writing Validation, verify every specification section and every walkthrough flow has at
least one corresponding test case, with explicit references to `4-specification.md`,
`3-walkthrough.md`, and/or `1-requirements.md`. Current coverage: 100% of SP/WT/NFR/SD items have
a test in the trace above.
+41
View File
@@ -0,0 +1,41 @@
# Implementation Plan: natsClient_JS
## Phases & Timeline
- **Phase 1**: Core transport (connection lifecycle, subscribe, publish, close).
- **Phase 2**: request/reply + reply handler + msghandler integration.
- **Phase 3**: status store + integration tests + CI gates.
## Module Breakdown & Tasks
| Task ID | Module | Responsibility | Effort | Dependencies | Validation Tests Satisfied |
|----------|--------|----------------|--------|--------------|------------------------------|
| IMP-001 | `natsClient.js` | `getNats` — connection + URL resolution | 1d | — | `tests/unit/getNats.test.js` (SP-001), `tests/integration/connect.test.js` (WT-001) |
| IMP-002 | `natsClient.js` | `subscribe` — decode + registry | 1d | IMP-001 | `tests/unit/subscribe.test.js` (SP-002), `tests/integration/publish-flow.test.js` (WT-003) |
| IMP-003 | `natsClient.js` | `publish` — encode + send | 0.5d | IMP-001 | `tests/unit/publish.test.js` (SP-003) |
| IMP-004 | `natsClient.js` | `closeNats` — cleanup | 0.5d | IMP-002 | `tests/unit/closeNats.test.js` (SP-007), `tests/integration/close-flow.test.js` (NFR-501) |
| IMP-005 | `natsClient.js` | `request` / `reply` | 1d | IMP-003 | `tests/unit/request.test.js` (SP-004), `tests/unit/reply.test.js` (SP-005) |
| IMP-006 | msghandler | `sendMsghandlerRequest` | 1d | IMP-005 | `tests/unit/sendMsghandlerRequest.test.js` (SP-006, SP-010/SP-011) |
| IMP-007 | status | `connectionStatus` store + event wiring | 1d | IMP-001 | `tests/unit/connectionStatus.test.js` (SP-008), `tests/integration/status-store.test.js` (NFR-201) |
## Performance & Error Guards
| Task ID | Module | Responsibility | Effort | Validation Tests Satisfied |
|----------|--------|----------------|--------|------------------------------|
| IMP-008 | error handling | per-message decode/parse/handle guard | 0.5d | `tests/unit/subscribe.test.js:decode_error_does_not_crash` (NFR-302) |
| IMP-009 | reconnect | reconnect behavior wired by `nats.ws` + `nc.status()` | 0.5d | `tests/integration/reconnect.test.js` (WT-007, NFR-102) |
## Build & Deployment
| Task ID | Module | Responsibility | Effort |
|----------|--------|----------------|--------|
| IMP-010 | CI | contract + integration gates on every commit/PR | 0.5d |
| IMP-011 | release | publish as a library module (ESM) | 0.5d |
## Risk Mitigation
| Risk | Mitigation | Task |
|------|------------|------|
| Broker unreachable at startup | `getNats` throws; consumer retries | IMP-001 |
| Decode error kills subscription loop | `catch` per message | IMP-008 |
| Reconnect never settles | `nc.status()` + `nc.closed()` monitoring | IMP-009 |
+72
View File
@@ -0,0 +1,72 @@
# Runbook: natsClient_JS
## Connection Issues
### `getNats` fails to connect
**Symptom**: `connectionStatus` lands in `error`, `getNats` throws.
**Steps**:
1. Check the resolved URL precedence: `opts.servers` → `VITE_NATS_URL` → `config.nats_server_info.url` → `wss://natsws.yiem.cc` (`4-specification.md:SP-001`).
2. Verify broker reachability: `nc -z -w5 <nats-host> <nats-port>`.
3. Confirm `nats.ws` is available (browser/WSS transport, `NFR-101`).
4. Retry after fixing config; the consumer should call `getNats` again.
### Connection stuck after a disconnect
**Symptom**: Status stays `disconnected` and does not return to `connected`.
**Steps**:
1. Confirm `nc.status()` is wired to emit `disconnect`/`reconnect` (`2-solution_design.md:SD-004`).
2. Check `nc.closed()` for a permanent close (`4-specification.md:SP-001`).
3. Restart the consuming app to re-establish the shared socket.
**References**: `4-specification.md:SP-001` (connection), `3-walkthrough.md:#wt-007`.
## Message Issues
### Subscribe receives no messages
**Symptom**: No `onMessage` callbacks fire.
**Steps**:
1. Verify the subject string matches the broker's subject exactly.
2. Confirm the connection reached `connected` before subscribing.
3. Check the subscription is tracked in the `Map` (`4-specification.md:SP-002`).
### `sendMsghandlerRequest` throws
**Symptom**: `throw new Error(data)` or `No valid response found`.
**Steps**:
1. Confirm the backend returns a `jsontable`/`dictionary` payload, not `text` (`4-specification.md:SP-011`).
2. Verify `msghandlerCSR.smartpack`/`smartunpack` are in use (`2-solution_design.md:SD-006`).
3. Check the broker envelope format (`4-specification.md:SP-010`, `SP-011`).
### `reply` handler produces no response
**Symptom**: Requests to the subject time out.
**Steps**:
1. Confirm `handler` returns a value that gets published on `m.subject` (`4-specification.md:SP-005`).
2. Verify the subject is not also consumed by a `request` on the same endpoint.
## Error Code Reference
| Error | Symptom | Action | Reference |
|-------|---------|--------|-----------|
| `error` status | `getNats` failed | Fix URL/config, retry | `4-specification.md:SP-001` |
| `text` msghandler payload | `sendMsghandlerRequest` throws | Confirm backend returns dictionary/jsontable | `4-specification.md:SP-011` |
| decode error | subscription message dropped | Handled by per-message guard (NFR-302) | `4-specification.md:SP-002` |
## Escalation
- If the shared socket never reconnects (NFR-102), treat the broker as down and page the backend
team; the library only reports state via `connectionStatus`.
- Contact: backend/NATS on-call via the `nats_client_reconnect_rate` alert.
## Post-Incident
- [ ] Review logs for root cause (`[NATS] ...` lines).
- [ ] Update this runbook if a new failure mode appears.
- [ ] Link findings to `6-validation.md` so the case is covered by a test.
+56 -43
View File
@@ -21,8 +21,13 @@ let reconnectTimer = null;
let isDisconnecting = false;
export async function getNats(opts = {}) {
const servers = opts.servers || (import.meta.env.VITE_NATS_URL) || (config?.nats_server_info?.url) || "wss://nats.yiem.cc";
const servers = opts.servers || (import.meta.env.VITE_NATS_URL) || (config?.nats_server_info?.url) || "wss://natsws.yiem.cc";
console.log('[NATS] Connecting to:', servers);
clearTimeout(reconnectTimer);
reconnectTimer = null;
isDisconnecting = false;
try {
connectionStatus.set({ state: "connecting", message: `connecting to ${servers}` });
console.log('[NATS] Status: connecting');
@@ -31,57 +36,43 @@ export async function getNats(opts = {}) {
connectionStatus.set({ state: "connected", message: `connected to ${servers}` });
isDisconnecting = false;
// Track connection state changes for status dot
// nats.ws emits status events for disconnect/reconnect
const statusSub = nc.status();
(async () => {
for await (const status of statusSub) {
console.log('[NATS] Status event:', status.type, status.data);
if (status.type === 'disconnect') {
connectionStatus.set({ state: 'disconnected', message: 'connection lost' });
} else if (status.type === 'reconnect') {
connectionStatus.set({ state: 'connected', message: 'reconnected' });
} else if (status.type === 'connect') {
connectionStatus.set({ state: 'connected', message: `connected to ${servers}` });
}
}
})();
// Also monitor permanent closure
nc.closed().then((err) => {
if (isDisconnecting) return;
if (err) {
console.log('[NATS] Connection closed with error:', err);
console.log('[NATS] Connection permanently closed with error:', err);
connectionStatus.set({ state: "error", message: String(err) });
} else {
console.log('[NATS] Connection closed normally');
console.log('[NATS] Connection permanently closed normally');
connectionStatus.set({ state: "disconnected", message: "connection closed" });
}
attemptReconnect(servers);
});
return nc;
} catch (e) {
console.error('[NATS] Connection failed:', e);
connectionStatus.set({ state: "error", message: String(e) });
attemptReconnect(servers);
// nats.ws will automatically attempt reconnect when we call getNats() again
throw e;
}
}
async function attemptReconnect(servers, retryCount = 0) {
const maxDelay = 30000;
const baseDelay = Math.min(1000 * Math.pow(2, retryCount), maxDelay);
console.log(`[NATS] Reconnecting in ${baseDelay}ms (attempt ${retryCount + 1})...`);
reconnectTimer = setTimeout(async () => {
try {
connectionStatus.set({ state: "connecting", message: `reconnecting to ${servers}` });
nc = await connect({ servers });
console.log('[NATS] Reconnected successfully!');
connectionStatus.set({ state: "connected", message: `connected to ${servers}` });
isDisconnecting = false;
const subsCopy = Array.from(subs.entries());
for (const [subject, onMessage] of subsCopy) {
try {
await subscribe(subject, onMessage);
} catch (e) {
console.warn(`[NATS] Failed to re-subscribe to ${subject}:`, e);
}
}
} catch (e) {
console.error('[NATS] Reconnection failed:', e);
connectionStatus.set({ state: "error", message: String(e) });
attemptReconnect(servers, retryCount + 1);
}
}, baseDelay);
}
export async function subscribe(subject, onMessage) {
console.log('[NATS] Subscribing to:', subject);
if (!nc) await getNats();
@@ -112,15 +103,27 @@ export async function subscribe(subject, onMessage) {
}
export async function publish(subject, payload = {}, opts = {}) {
console.log('[NATS] Publishing to:', subject, 'payload:', payload);
if (!nc) await getNats(opts);
console.log('[NATS] Publishing to subject:', subject);
console.log('[NATS] Payload type:', typeof payload);
console.log('[NATS] Payload (as JSON):', typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2));
if (!nc) {
console.log('[NATS] No connection, attempting to connect...');
await getNats(opts);
}
const msg = typeof payload === "string" ? payload : JSON.stringify(payload);
console.log('[NATS] Final message to publish (first 300 chars):', msg.substring(0, 300) + (msg.length > 300 ? '...' : ''));
console.log('[NATS] Message length:', msg.length);
console.log('[NATS] NATS connection exists:', !!nc);
console.log('[NATS] About to publish...');
nc.publish(subject, sc.encode(msg));
console.log('[NATS] Message published successfully');
console.log('[NATS] Message published successfully to:', subject);
}
export async function closeNats() {
console.log('[NATS] Closing connection...');
clearTimeout(reconnectTimer);
reconnectTimer = null;
isDisconnecting = true;
connectionStatus.set({ state: "disconnected", message: "closing" });
try {
for (const s of subs.values()) {
@@ -145,7 +148,7 @@ export async function connectNATS(brokerUrl, cfg) {
let serverUrl = brokerUrl;
if (!serverUrl && cfg) {
serverUrl = cfg?.nats_server_info?.url || 'wss://nats.yiem.cc';
serverUrl = cfg?.nats_server_info?.url || 'wss://natsws.yiem.cc';
}
console.log('[NATS] Using server URL:', serverUrl);
@@ -155,10 +158,12 @@ export async function connectNATS(brokerUrl, cfg) {
export async function request(subject, payload, opts = {}) {
console.log('[NATS] Requesting from:', subject, 'payload:', payload);
if (!nc) await getNats(opts);
const { timeout, ...getNatsOpts } = opts;
if (!nc) await getNats(getNatsOpts);
const msg = typeof payload === "string" ? payload : JSON.stringify(payload);
const response = await nc.request(subject, sc.encode(msg));
const requestOpts = timeout ? { timeout } : {};
const response = await nc.request(subject, sc.encode(msg), requestOpts);
const responseText = sc.decode(response.data);
let responseJson;
try {
@@ -213,8 +218,8 @@ export async function sendMsghandlerRequest(subject, functioncall, args, config)
args: args
};
const brokerUrl = config?.nats_server_info?.url || 'wss://nats.yiem.cc';
const fileserverUrl = config?.file_server_url || 'http://localhost:8080';
const brokerUrl = config?.nats_server_info?.url || 'wss://natsws.yiem.cc';
const fileserverUrl = config?.file_server_url || 'https://fileserver.yiem.cc';
const [envelope, envelopeJsonStr] = await msghandlerCSR.smartpack(
subject,
@@ -236,3 +241,11 @@ export async function sendMsghandlerRequest(subject, functioncall, args, config)
console.log('[NATS] Request successful:', data);
return data;
}
if (type === "text") {
console.error('[NATS] Error response:', data);
throw new Error(data);
}
}
throw new Error('No valid response found');
}