From 5827bec889f4a87b8360ce8010fb8df108a7ea9f Mon Sep 17 00:00:00 2001 From: Erick Xavier Date: Wed, 15 Jul 2026 18:58:02 -0300 Subject: [PATCH] docs/NOJS-270-NOJS-277: document SSE directive in SKILL.md and references Add SSE (Server-Sent Events) directive documentation for the v1.20.0 release. SKILL.md gets a cheat-sheet section with attribute table and live-feed example. New references/directives/sse.md covers the full attribute API, $sse connection state, data parsing, reactive URLs, authentication limitations, connection limits, and disposal semantics. --- nojs/SKILL.md | 34 +++ nojs/references/directives/sse.md | 369 ++++++++++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 nojs/references/directives/sse.md diff --git a/nojs/SKILL.md b/nojs/SKILL.md index 58787f6..8e7ffea 100644 --- a/nojs/SKILL.md +++ b/nojs/SKILL.md @@ -239,6 +239,39 @@ URLs support interpolation: `get="/users/{userId}"`. Reactive expressions in URL ``` +### SSE (Server-Sent Events) + +`sse="/url"` opens a persistent EventSource connection and binds incoming messages to the reactive context. Streaming counterpart to `get` -- where `get` does a one-shot fetch, `sse` holds an open connection for server-pushed data. + +| Directive | Syntax | Description | +|-----------|--------|-------------| +| `sse` | `sse="/endpoint"` | EventSource URL (supports reactive `{var}` interpolation) | +| `as` | `as="varName"` | Context variable for incoming data (default `"data"`) | +| `sse-event` | `sse-event="eventName"` | Named SSE event (default `"message"`; named events ignore default messages) | +| `sse-insert` | `sse-insert="append"` | Insert mode: `replace` (default), `append`, `prepend` | +| `sse-limit` | `sse-limit="50"` | Array cap for append/prepend (oldest items dropped) | +| `sse-credentials` | `sse-credentials` | Set `withCredentials: true` on the EventSource | +| `into` | `into="storeName"` | Dual-write to a global store | +| `error` | `error="tplId"` | Template shown on terminal close only (not during auto-reconnect) | +| `then` | `then="expr"` | Expression per message (`$event` = parsed data) | + +**Connection state:** `$sse.connecting`, `$sse.open`, `$sse.error` -- reactive booleans reflecting the EventSource lifecycle. Use with `show`/`hide`/`if` to compose status indicators. + +**Data parsing:** JSON.parse with raw-string fallback. **Auth limitation:** EventSource sends no custom headers -- use query tokens or cookies (`sse-credentials`). **No loading template** -- compose with `$sse.connecting`/`$sse.open` instead. + +```html + +
+ Connecting... + Live + +
+ +``` + ### Routing `route="/path"` (define route or navigate link), `route="*"` (404 catch-all), `route-view` (outlet), `route-view="name"` (named outlet), `route-view src="pages/"` (file-based routing). @@ -642,6 +675,7 @@ All paths relative to `nojs/references/`: | [directives/events.md](references/directives/events.md) | on:*, modifiers, lifecycle hooks, $event, $el | | [directives/head-seo.md](references/directives/head-seo.md) | page-title, page-description, page-canonical, page-jsonld | | [directives/http.md](references/directives/http.md) | get, post, put, patch, delete, query, pagination, caching | +| [directives/sse.md](references/directives/sse.md) | sse, sse-event, sse-insert, sse-limit, sse-credentials, $sse state | | [directives/i18n.md](references/directives/i18n.md) | t, t-html, i18n-ns, locale setup, pluralization | | [directives/loops.md](references/directives/loops.md) | foreach/each/for, filter, sort, key, loop vars | | [directives/routing.md](references/directives/routing.md) | route, route-view, guards, named outlets, file-based | diff --git a/nojs/references/directives/sse.md b/nojs/references/directives/sse.md new file mode 100644 index 0000000..a73d895 --- /dev/null +++ b/nojs/references/directives/sse.md @@ -0,0 +1,369 @@ +# SSE (Server-Sent Events) Directive + +Declarative Server-Sent Events via the native `EventSource` API. Priority 1. + +## Contents + +- [Overview](#overview) +- [Attribute API](#attribute-api) + - [sse](#sse) -- EventSource URL + - [as](#as) -- context variable name for incoming data + - [sse-event](#sse-event) -- named SSE event to listen for + - [sse-insert](#sse-insert) -- insert mode (replace, append, prepend) + - [sse-limit](#sse-limit) -- array length cap for append/prepend + - [sse-credentials](#sse-credentials) -- enable withCredentials + - [into](#into) -- write data to a global store + - [error](#error) -- error template (terminal close only) + - [then](#then) -- expression on each message +- [Connection State ($sse)](#connection-state-sse) +- [Data Parsing](#data-parsing) +- [Reactive URL](#reactive-url) +- [Authentication](#authentication) +- [Connection Limits](#connection-limits) +- [Disposal and Cleanup](#disposal-and-cleanup) + +--- + +## Overview + +The `sse` directive opens a persistent EventSource connection to a server endpoint and binds incoming messages to the element's reactive context. It is the streaming counterpart to `get` -- where `get` performs a one-shot request-response cycle, `sse` holds an open connection and receives server-pushed messages in real time. + +Key characteristics: + +- **Native EventSource** -- uses the browser's built-in `EventSource` API. No polyfills, no external dependencies. +- **Automatic reconnection** -- browsers reconnect automatically when the connection drops. The directive distinguishes between auto-reconnect (non-terminal) and permanent close (terminal). +- **Reactive context integration** -- incoming data is set on the element's context via `$set()`, triggering automatic UI updates for all bound children. +- **Consistent API** -- shares `as`, `into`, `error`, and `then` attributes with the HTTP directives. + +--- + +## Attribute API + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `sse` | string | (required) | URL for the EventSource. Supports reactive interpolation (`sse="/feed/{userId}"`). | +| `as` | string | `"data"` | Context variable name for incoming data. | +| `sse-event` | string | `"message"` | Named SSE event to listen for. When set, default `message` events are ignored. | +| `sse-insert` | `"replace"` \| `"append"` \| `"prepend"` | `"replace"` | How incoming messages update the context variable. | +| `sse-limit` | number | (none) | Maximum array length in append/prepend mode. Oldest items dropped when exceeded. | +| `sse-credentials` | boolean (presence) | `false` | Sets `withCredentials: true` on the EventSource for cross-origin cookie sending. | +| `into` | string | (none) | Write data to a named global store in addition to local context (dual-write). | +| `error` | string | (none) | Template ID to render when the connection permanently closes (`readyState === CLOSED`). NOT rendered during auto-reconnect. | +| `then` | string | (none) | Expression evaluated on each received message. The parsed message is available as `$event`. | + +### `sse` + +Open a Server-Sent Events connection to the specified URL. + +**Syntax:** `` + +The directive creates an `EventSource` for the URL and listens for incoming messages. The connection opens immediately when the element is processed and closes automatically when the element is removed from the DOM. + +URLs support `{variable}` interpolation with reactive re-connection (see [Reactive URL](#reactive-url)). + +```html +
+ +
+``` + +### `as` + +Name for incoming data in the element's reactive context. + +**Syntax:** `` + +Default: `"data"` (consistent with the `get` directive). In replace mode, the variable holds the latest parsed message. In append/prepend mode, the variable is an array of parsed messages. + +```html +
+ : +
+``` + +### `sse-event` + +Listen for a specific named SSE event instead of the default `message` event. + +**Syntax:** `` + +When `sse-event` is set, the directive listens ONLY for that named event. Default `message` events (those sent by the server without an `event:` field) are ignored. + +```html + +
+ +
+``` + +Without `sse-event`, the directive listens on the default `"message"` event (all messages without an `event:` field, plus messages with `event: message`). + +### `sse-insert` + +Control how incoming messages update the context variable. + +**Syntax:** `` + +| Value | Behavior | +|-------|----------| +| `"replace"` (default) | Each message replaces the previous value. Context variable holds a single value. | +| `"append"` | Messages accumulate in an array. New messages are pushed to the end. If `sse-limit` is set and the array exceeds it, the oldest item (front) is removed. | +| `"prepend"` | Messages accumulate in an array. New messages are inserted at the front. If `sse-limit` is set and the array exceeds it, the oldest item (end) is removed. | + +The context variable is initialized as an empty array `[]` when the insert mode is `append` or `prepend`. + +```html + +
    +
  • +
+``` + +**Warnings:** The directive issues `_warn()` in two situations: + +- `sse-limit` is set but `sse-insert` is absent or `"replace"` -- the limit has no effect without an array mode. +- `sse-insert` is `"append"` or `"prepend"` but `sse-limit` is NOT set -- unbounded memory growth risk on long-lived streams. + +### `sse-limit` + +Cap the array length in append/prepend mode. + +**Syntax:** `` + +When the array exceeds this limit after a new message is added: + +- **append mode:** the oldest item is removed from the front (`shift()`). +- **prepend mode:** the oldest item is removed from the end (`pop()`). + +Has no effect in replace mode (a warning is issued). + +### `sse-credentials` + +Enable `withCredentials` on the EventSource for cross-origin requests. + +**Syntax:** `` + +Boolean attribute (presence = `true`). When set, cookies and HTTP authentication are sent with the EventSource request to the cross-origin server. Required for cross-origin SSE endpoints that authenticate via cookies. + +### `into` + +Write incoming data to a named global store in addition to the local context. + +**Syntax:** `` + +Dual-write: the data is set both on the element's local context (`ctx.$set(asKey, value)`) and on the named store (`_stores[storeName].$set(asKey, value)`), then store watchers are notified. This allows other components anywhere in the page to reactively consume the same live data. + +The store is created automatically if it does not already exist. + +```html + +
+ +
+ + +
Latest:
+``` + +### `error` + +Template ID to render when the EventSource connection permanently closes. + +**Syntax:** `` + +The error template is rendered ONLY when the EventSource `error` event fires AND `readyState === EventSource.CLOSED` (terminal failure). It is NOT rendered during browser auto-reconnect attempts (`readyState === EventSource.CONNECTING`). + +When triggered, the element's children are disposed (Safety Rule 1) and replaced with the cloned template. The template receives a context with a variable (default name from template's `var` attribute, or `"err"`) containing `{ message: "SSE connection closed" }`. + +```html +
+ +
+ + +``` + +**No loading template:** Unlike the HTTP directives, `sse` does not support a `loading` attribute. SSE connections are persistent -- there is no clear "loading finished" moment. Use `$sse.connecting` and `$sse.open` to compose loading/status indicators instead: + +```html +
+

Connecting...

+

+

Connection lost

+
+``` + +### `then` + +Expression evaluated each time a message is received. + +**Syntax:** `` + +The parsed message data is available in the expression scope as `$event`. Executed via `_execStatement()` against the element's context. + +```html +
+ notifications received +

+
+``` + +Errors in the `then` expression are caught and logged via `_warn()` without disrupting the SSE connection. + +--- + +## Connection State ($sse) + +The directive exposes a `$sse` reactive object on the element's context with three boolean properties reflecting the connection lifecycle: + +| Property | Type | Description | +|----------|------|-------------| +| `$sse.connecting` | boolean | `true` while the EventSource is in the `CONNECTING` state (initial connection or auto-reconnecting). | +| `$sse.open` | boolean | `true` when the EventSource connection is open and receiving messages. | +| `$sse.error` | boolean | `true` when the connection has permanently closed (`readyState === CLOSED`). | + +### State transitions + +| Event | `connecting` | `open` | `error` | +|-------|:---:|:---:|:---:| +| EventSource created | `true` | `false` | `false` | +| `open` event fires | `false` | `true` | `false` | +| `error` with `readyState === CONNECTING` (auto-reconnect) | `true` | `false` | `false` | +| `error` with `readyState === CLOSED` (terminal) | `false` | `false` | `true` | + +`$sse` is scoped to the element's context. Nested SSE elements maintain independent `$sse` state. Non-SSE elements do not have `$sse` in their context. + +```html +
+ Connecting... + Live + Disconnected + +
+

+
+
+``` + +--- + +## Data Parsing + +Each incoming SSE message's `data` field is parsed as follows: + +1. Attempt `JSON.parse(event.data)`. +2. On parse failure, use the raw string value as-is. + +The parsed value is then assigned to the context variable named by `as`, following the insert mode rules. + +```html + + +
+ : +
+ + + +
+

+
+``` + +--- + +## Reactive URL + +URLs containing `{variable}` interpolation expressions are reactive. When a referenced variable changes, the directive: + +1. Closes the existing EventSource connection. +2. Resets accumulated data (in append/prepend mode, the array resets to `[]`). +3. Opens a new EventSource with the resolved URL. + +If the resolved URL is identical to the current one, no reconnection occurs. + +The directive watches all ancestor contexts in the parent chain, plus global reactive sources (`$store`, `$route`, `$i18n`) if referenced in the URL. This uses the same ancestor-walk watcher pattern as the HTTP directives. + +```html +
+ + + +
+

+
+
+``` + +--- + +## Authentication + +**Browser limitation:** The native `EventSource` API does NOT support custom request headers. You cannot send `Authorization: Bearer ` or any custom header with an EventSource connection. This is a browser API constraint, not a NoJS limitation. + +**NoJS interceptors do not apply.** `NoJS.config({ interceptors })` hooks into `fetch()` requests only. EventSource connections bypass the fetch pipeline entirely. + +### Workarounds + +| Method | How | Example | +|--------|-----|---------| +| **Query-string token** | Pass the token in the URL. Simple but less secure (token visible in logs/history). | `sse="/api/stream?token={$store.auth.token}"` | +| **Cookies** | Authenticate via a cookie-setting endpoint first, then use `sse-credentials` for cross-origin. | `sse="/api/stream" sse-credentials` | +| **Cookie-setting auth endpoint** | POST credentials to an auth endpoint that sets an HttpOnly cookie, then open the SSE connection. | See example below | + +```html + +
+
+ + +
+ +
+

+
+
+``` + +--- + +## Connection Limits + +Browsers limit HTTP/1.1 to **6 concurrent connections per origin**. Each `sse` element holds one persistent connection for as long as it is in the DOM. + +The directive tracks active connections per origin. When 6 or more connections exist to the same origin, a warning is issued via `_warn()`: + +> SSE: 6 connections to https://example.com. Browsers limit HTTP/1.1 to 6 concurrent connections per origin. Consider HTTP/2 or reducing open streams. + +**Mitigations:** + +- **HTTP/2:** multiplexes all connections over a single TCP connection, effectively removing the limit. +- **Combine streams:** multiplex multiple event types on a single SSE endpoint and use `sse-event` to filter on the client. +- **Conditional connections:** use `if` to gate SSE elements so connections only open when needed. + +```html + +
+

+
+``` + +--- + +## Disposal and Cleanup + +The directive follows all NoJS safety rules for resource cleanup: + +- **Safety Rule 1 (Disposal before clearing DOM):** When the error template is rendered, all children are disposed via `_disposeChildren(el)` before `el.innerHTML = ""`. +- **Safety Rule 2 (Event listener cleanup):** `eventSource.close()` is registered via `_onDispose()` immediately after EventSource creation. +- **Disconnected element guard:** Every message handler checks `el.isConnected` before processing. If the element has been removed, the EventSource is closed and the message is ignored. +- **Watcher cleanup:** All `$watch()` subscriptions for reactive URLs are unsubscribed via `_onDispose()`. +- **Connection tracking cleanup:** The origin connection set is cleaned up on close/dispose. +- **Gated directive:** Registered with `gated: true`, so the connection does not open for elements behind a falsy `if` gate. Gate deactivation triggers disposal and closes the connection.