Skip to content

NOJS-271: implement sse directive#284

Merged
ErickXavier merged 1 commit into
feat/NOJS-270from
feat/NOJS-270-NOJS-271
Jul 15, 2026
Merged

NOJS-271: implement sse directive#284
ErickXavier merged 1 commit into
feat/NOJS-270from
feat/NOJS-270-NOJS-271

Conversation

@ErickXavier

Copy link
Copy Markdown
Collaborator

What changed

Implements the sse directive for declarative Server-Sent Events in NoJS Core, enabling real-time data binding via the browser-native EventSource API with zero JavaScript.

Context

  • Initiative: NOJS-270 — SSE directive EPIC
  • Task: NOJS-271 — Implement sse directive (core source)

Changes

Core directive (src/directives/sse.js — NEW, ~185 lines)

  • Registered via registerDirective("sse", { priority: 1, gated: true, init }) — same tier as get/post
  • Attribute API: sse (URL), as (var name, default "data"), sse-event (named event), sse-insert (replace|append|prepend), sse-limit (array cap), sse-credentials (withCredentials), into (store dual-write), error (template on terminal close), then (per-message expression with $event)
  • Message parsing: JSON.parse with raw-string fallback
  • Insert modes: replace (scalar assign), append/prepend (array accumulation with shift/pop on limit)
  • Reactive URL: ancestor-watch pattern from http.js for {expr} interpolation; close+reopen on URL change
  • Connection tracking: module-level Map<origin, Set<EventSource>>; warns at 6 concurrent connections per origin (HTTP/1.1 limit)
  • $sse state: { connecting, open, error } — connecting on init/reconnect, open on onopen, error only on terminal close (readyState === CLOSED)
  • Safety rules: _onDispose for EventSource.close(), el.isConnected guards in all handlers, _disposeChildren before innerHTML swap for error templates, watcher cleanup via _onDispose

Context (src/context.js)

  • Added case "$sse": return target.$sse || null; after $form in the $-prefix switch

Module entry (src/index.js)

  • Side-effect import of ./directives/sse.js (after http.js)
  • Added "sse" to _RESERVED_GLOBAL_NAMES

Build output (dist/)

  • Rebuilt IIFE bundle with sse directive included

Verification evidence

  • Build: node build.js clean
  • Bundle delta: +2,814 bytes (2.75 KB) — within 3 KB budget
  • Unit tests: 2,387 passed, 0 failed (44 suites)
  • Runtime smoke test: verified in Chrome via MCP DevTools against a local SSE server:
    • Replace mode: last message value bound correctly
    • Append + limit: array capped, oldest items dropped
    • Named events (sse-event): only named events processed
    • Plain text fallback: non-JSON strings bound as-is
    • then expression: counter incremented per message
    • $sse state: show="$sse.connecting" toggles correctly

How to test

  1. Build: node build.js
  2. Unit tests: npx jest --no-coverage
  3. Runtime: create a simple SSE endpoint and an HTML page with sse="/endpoint" attributes loading dist/iife/no.js

Checklist

  • Code follows project conventions (private _ prefix, _warn/_log only, no console.log)
  • Unit tests added/updated (T2 — @qa scope)
  • Runtime verification performed (app booted, feature tested in browser)
  • No JavaScript errors in browser console
  • No server errors
  • Bundle delta within budget (2.75 KB < 3 KB)

Files modified

  • src/directives/sse.js (NEW)
  • src/context.js
  • src/index.js
  • dist/iife/no.js
  • dist/iife/no.js.map

NOJS-270 / NOJS-271

New directive `sse` enables declarative real-time data binding via the
browser-native EventSource API. Registered at priority 1 (same tier as
get/post), gated for if-conditional support.

Attribute API: sse (url), as (var name), sse-event (named event),
sse-insert (replace|append|prepend), sse-limit (array cap),
sse-credentials (withCredentials), into (store dual-write),
error (template on terminal close), then (per-message expression).

Reactive $sse state object ({connecting, open, error}) exposed on the
element context via a new switch case in context.js. Per-origin
connection tracking warns at the HTTP/1.1 6-connection limit. Reactive
URL interpolation follows the ancestor-watch pattern from http.js.

All mandatory safety rules enforced: disposal via _onDispose,
el.isConnected guards, watcher cleanup, dispose-before-innerHTML.

Bundle delta: +2,814 bytes (2.75 KB), within 3 KB budget.

@ErickXavier ErickXavier left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA Review -- @qa

Verdict: APPROVED -- Clean, spec-compliant implementation of the SSE directive. One should-fix and three nits; none are blockers.

Code Verification

  • Quality: Follows project conventions exactly. Code style is indistinguishable from http.js. Imports only what is needed from globals.js, evaluate.js, dom.js, registry.js. No console.log (uses _warn). Private prefix convention followed. ~185 lines, well-structured.
  • Security: No new XSS surface -- SSE data is assigned to reactive context variables, never injected as HTML. URL handling goes through _interpolate (safe). Expression evaluation uses _execStatement (CSP-safe, no eval/Function). Store writes use the existing dual-write pattern. Prototype pollution safe via _FORBIDDEN_CTX_KEYS in the proxy set trap. No credential leakage.
  • Performance: O(1) replace, O(1) amortized append/prepend with limit. Zero overhead when no sse attribute present.
  • Edge cases: See Issues section below.

Safety Rule Compliance (all 12 checked)

  1. Disposal before DOM clear (Rule 1): _disposeChildren(el) before innerHTML="" in _renderError (line 117-118). PASS.
  2. Event listener cleanup (Rule 2): es.close() registered via _onDispose immediately after creation (line 146-149). EventSource close() kills all listeners per WHATWG spec. PASS (with caveat in S1).
  3. Watcher unsubscribe (Rule 3): All $watch returns registered via _onDispose (line 233). Store/route/i18n watcher deletes registered (lines 242, 247, 252). PASS.
  4. Timer guards (Rule 4): No timers; el.isConnected guard in all handlers (lines 152, 157, 168). PASS.
  5. Safety-net timeouts (Rule 5): N/A.
  6. HTML sanitization (Rule 6): No HTML injection of SSE data. PASS.
  7. Expression identifier resolution (Rule 7): Uses _execStatement, allow-list only. PASS.
  8. Expression error handling (Rule 8): try-catch around _execStatement with _warn (lines 201-207). Better than http.js which has no try-catch on its then. PASS.
  9. Cloned elements (Rule 9): Template clone via _cloneTemplate + processTree. PASS.
  10. Storage persistence (Rule 10): N/A.
  11. Plugin security (Rule 11): Core directive, frozen after init via registerDirective. PASS.
  12. Credential handling (Rule 12): N/A (EventSource has no custom headers).

Registration & Wiring

  • Priority 1, gated: true -- matches spec (FR-1). PASS.
  • Side-effect import in src/index.js alongside http.js -- correct pattern. PASS.
  • "sse" added to _RESERVED_GLOBAL_NAMES in index.js. PASS.
  • context.js: single switch case $sse after $form (line 184), matches $form pattern exactly. PASS.

Bundle Delta

  • Baseline (feat/NOJS-270): 133,513 bytes
  • With SSE: 136,327 bytes
  • Delta: +2,814 bytes (2.75 KB) -- within 3 KB budget. PASS.

Existing Test Suite

  • 44 suites, 2,387 passed, 0 failed. PASS.

Runtime Verification

  • App booted: Yes -- local SSE test server + dist/iife/no.js loaded in Chrome.
  • Feature tested in browser: Yes, via Chrome DevTools MCP.
  • Complete flows exercised:
    1. Replace mode (JSON): ticker data bound and updated correctly (price: 47, symbol: NOJS). $sse.open displayed. PASS.
    2. Append + sse-limit=3: 5 messages sent, array capped at 3 (last 3 kept). foreach rendered correctly. PASS.
    3. Named events (sse-event): only price-update events bound; default messages ignored. Value: 300. PASS.
    4. Plain text fallback: non-JSON data bound as raw string. Type confirmed as "string". PASS.
    5. Error template: on terminal close (readyState === CLOSED), error template rendered ("Connection lost - SSE closed"). $sse = {connecting: false, open: false, error: true}. During auto-reconnect, error template NOT displayed (correct). PASS.
    6. then expression: _execStatement called correctly per message. Data binding works. PASS.
    7. 6-connection warning: console shows [No.JS] SSE: 6 connections to http://localhost:4567... -- correct threshold, correct message. PASS.
    8. $sse state transitions: connecting -> open observed; connecting -> error observed on terminal close. PASS.
  • Console errors: None from NoJS. Only expected network errors from deliberately-dropped SSE connections.

Issues Found

S1 (SHOULD-FIX) -- _onDispose inside _openConnection may not register on reactive URL changes -- src/directives/sse.js:146

_onDispose at line 146 captures the local es variable and registers cleanup with _currentEl. During init, _currentEl is set correctly. But when _openConnection is called from onAncestorChange (reactive URL change at line 225), _currentEl may be null or pointing to a different element. In that case, _onDispose silently does nothing (globals.js:279 checks if (_currentEl)), and the new EventSource's close() is never registered for disposal.

Impact: If the element is removed after a URL change but before the next SSE event, the latest EventSource leaks until the el.isConnected guard fires on the next event.

Fix: Add _onDispose(_closeConnection) once during init (~line 88, after defining _closeConnection), when _currentEl is guaranteed to be set. Since _closeConnection references the closure variable _es, it will always close the current connection at disposal time. The _onDispose inside _openConnection can remain as belt-and-suspenders (it works correctly on the initial call).

N1 (NIT) -- No debug logging for SSE lifecycle

http.js uses _log() for debugging. SSE has zero debug output. Adding _log("SSE: connecting to", resolvedUrl) on open and _log("SSE: message received") on message would be consistent and useful.

N2 (NIT) -- Negative/non-numeric sse-limit silently ignored -- src/directives/sse.js:60

sse-limit="-1" or sse-limit="abc" silently bypass limit logic. Consider _warn() for isNaN(limit) || limit < 0.

N3 (NIT) -- PRD/ADR discrepancy on $sse.error during auto-reconnect -- src/directives/sse.js:163

PRD US-7 specifies $sse.error: true when auto-reconnecting. ADR-003 D2 specifies error: false. Implementation follows ADR (correct decision). Recommend documenting the override on the EPIC.

Verdict

APPROVED. S1 is a real but bounded resource-leak risk mitigated by el.isConnected guards. It should be addressed before v1.20.0 release but does not block merging into the feature branch. N1-N3 are quality improvements for follow-up.

@ErickXavier ErickXavier merged commit 6698c16 into feat/NOJS-270 Jul 15, 2026
@ErickXavier ErickXavier deleted the feat/NOJS-270-NOJS-271 branch July 15, 2026 21:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant