redweb-client 0.2.0 → 0.3.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ - Accept typed socket commands emitted by Redweb's server TSX controls on the page's custom route, reusing existing form feedback and DOM reconciliation. Requires Redweb 0.15.0 for socket-bound pages; ordinary Live HTML remains compatible with Redweb 0.14.0.
6
+ - Add optional `request(..., { responseType })` filtering so intermediate correlated events do not complete a request before its terminal reply. Correlated protocol errors still reject immediately; omitting the option retains existing behavior.
7
+ - Preserve explicitly bound JSON payloads, including `null`, and merge form fields over bound object payloads. Commands retain cancellation, deadlines and no-replay behavior.
8
+ - Rebuild distribution files automatically before packing/publishing, and include these release notes in the package.
9
+
10
+ Prepared for manual publication. No new runtime dependencies.
package/README.md CHANGED
@@ -2,17 +2,31 @@
2
2
 
3
3
  Browser-first WebSocket client for [Redweb](https://redweb.magnisolution.com/). It adds connection lifecycle, typed message subscriptions, request correlation, bounded queuing, opt-in reconnection, and versioned Redweb protocol envelopes without adding runtime dependencies.
4
4
 
5
- ## Unreleased lifecycle hardening
5
+ ## Connection lifecycle
6
6
 
7
- The development branch fixes connection attempts continuing after a synchronous state observer calls `close()` or `dispose()`. Connection attempts are identified across callback boundaries; a reentrant `connect()` shares an existing attempt, and an explicit replacement is not cleared by an older attempt finishing. Cancelled transport-factory results are closed after their lifecycle listeners are attached. These changes are not yet published in npm version 0.1.0.
7
+ Connection attempts stop when a synchronous state observer calls `close()` or `dispose()`. Attempts are identified across callback boundaries; a reentrant `connect()` shares an existing attempt, and an explicit replacement is not cleared by an older attempt finishing. Cancelled transport-factory results are closed after their lifecycle listeners are attached.
8
8
 
9
9
  Integration regressions use real WebSocket connections and timers for observer-driven cancellation, upgrade-refusal retries, queued delivery, cancellation without replay, and rejected sends after disposal. Unit tests use isolated transport fixtures and explicitly simulated stale scheduling; integration tests do not mock transports. The complete verification command and its coverage scope are described below.
10
10
 
11
- ## Unreleased Live HTML integration
11
+ ## Live HTML integration
12
+
13
+ ### Typed socket-page commands (0.3.0)
14
+
15
+ Version 0.3.0 accepts server-emitted command bindings on
16
+ existing Live HTML controls. It sends the actual `join`/`move` message type on
17
+ the page's custom route and reuses pending/error feedback and DOM reconciliation.
18
+ No application browser module is necessary. Socket-bound pages require Redweb
19
+ 0.15.0; the earlier client 0.2.0 does not support these command bindings.
20
+
21
+ Direct request users may pass `{ responseType: 'redweb:result' }` to wait for a
22
+ specific terminal reply. Intermediate correlated events still reach subscribers
23
+ but do not settle that request. Correlated protocol errors reject immediately.
24
+ Omitting `responseType` preserves existing request behavior. Requests retain their
25
+ deadline/cancellation and no-replay guarantees.
12
26
 
13
27
  The optional `redweb-client/live-html` entry owns reactive DOM updates, keyed
14
28
  reconciliation, delegated actions/forms, feedback and connection status for pages
15
- rendered by the matching Redweb development branch. The root import stays
29
+ rendered by Redweb. The root import stays
16
30
  socket-only. Redweb serves the optional self-contained module and automatically
17
31
  calls `mountLivePage()`; ordinary applications need no additional browser glue.
18
32
 
@@ -34,22 +48,18 @@ again shares the same owner; a disposed page cannot later reset a submitted draf
34
48
  For local development with sibling repositories, run `npm run build` and
35
49
  `npm link --ignore-scripts` here, then `npm link redweb-client --no-save --ignore-scripts`
36
50
  in Redweb. Rebuild here after source changes; no repacking is needed. The link is
37
- not saved in either lockfile. Published 0.1.0 does not include this entry; client
38
- publication and Redweb dependency/lockfile updates remain release prerequisites.
51
+ not saved in either lockfile. The optional entry was introduced in 0.2.0;
52
+ socket-bound page commands and terminal-response filtering are added in 0.3.0.
39
53
 
40
- Redweb's native-browser gate checks the server-side counter, multi-user chat,
41
- dashboard and forms using real HTTP/WebSockets. Its rendering coverage verifier
42
- measures all bundled Live HTML modules separately from transport and currently
43
- covers 426 statements, 262 branches, 64 functions and 351 lines (100% each).
44
- This is emitted-frontend coverage, not complete original-source/client-package or
45
- cross-browser certification. Separate V8 measurements are not substituted for
46
- the complete original-source gate below; the full release gate is not approved.
54
+ Redweb's headed-browser gates check the server-side counter, multi-user chat,
55
+ dashboard, forms and socket-bound multiplayer tutorial using real HTTP/WebSockets.
56
+ These are not cross-browser certification or a guarantee about application security.
47
57
 
48
58
  From the linked Redweb repository, `npm run verify:client:source-coverage` runs
49
- all 77 client tests both plain and instrumented, then native browser tests with
59
+ the complete client test inventory both plain and instrumented, then headed browser tests with
50
60
  the same original-source coverage maps. Plain browser bundles must match this
51
61
  client's `dist` output. It retains separate Node/browser results and currently
52
- covers all 791 statements, 521 branches, 125 functions and 659 lines (100% each).
62
+ covers all 800 statements, 543 branches, 125 functions and 667 lines (100% each).
53
63
  The same gate runs through the default `npm test` command. This metric does not
54
64
  replace V8 coverage or independently count every optional-chain short circuit.
55
65
 
@@ -60,6 +60,8 @@ interface RedwebClientOptions {
60
60
  random?: () => number;
61
61
  }
62
62
  interface RequestOptions extends ProtocolMetadata {
63
+ /** Ignore intermediate correlated events; protocol errors still reject immediately. */
64
+ responseType?: string;
63
65
  timeoutMs?: number;
64
66
  signal?: AbortSignal;
65
67
  }
@@ -60,6 +60,8 @@ interface RedwebClientOptions {
60
60
  random?: () => number;
61
61
  }
62
62
  interface RequestOptions extends ProtocolMetadata {
63
+ /** Ignore intermediate correlated events; protocol errors still reject immediately. */
64
+ responseType?: string;
63
65
  timeoutMs?: number;
64
66
  signal?: AbortSignal;
65
67
  }
package/dist/index.cjs CHANGED
@@ -368,6 +368,9 @@ var RedwebClient = class {
368
368
  const requestId = options.requestId ?? this.createRequestId();
369
369
  const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
370
370
  assertNonNegativeInteger(timeoutMs, "timeoutMs");
371
+ if (options.responseType !== void 0 && (typeof options.responseType !== "string" || !options.responseType || options.responseType.length > 256)) {
372
+ return Promise.reject(new TypeError("responseType must be a non-empty string of at most 256 characters."));
373
+ }
371
374
  if (options.signal?.aborted) return Promise.reject(new DOMException("The request was aborted.", "AbortError"));
372
375
  if (this.pending.has(requestId)) return Promise.reject(new Error(`A Redweb request with id "${requestId}" is already pending.`));
373
376
  return new Promise((resolve, reject) => {
@@ -375,7 +378,7 @@ var RedwebClient = class {
375
378
  this.finishPending(requestId);
376
379
  reject(new Error(`Redweb request timed out after ${timeoutMs}ms.`));
377
380
  }, timeoutMs);
378
- const pending = { resolve, reject, timer };
381
+ const pending = { resolve, reject, timer, responseType: options.responseType };
379
382
  if (options.signal) {
380
383
  pending.signal = options.signal;
381
384
  pending.abort = () => {
@@ -482,7 +485,7 @@ var RedwebClient = class {
482
485
  const requestId = message.requestId;
483
486
  if (typeof requestId === "string") {
484
487
  const pending = this.pending.get(requestId);
485
- if (pending) {
488
+ if (pending && (pending.responseType === void 0 || message.type === pending.responseType || message.type === "error" && "error" in message)) {
486
489
  this.finishPending(requestId);
487
490
  if (message.type === "error" && "error" in message) pending.reject(new RedwebProtocolError(message));
488
491
  else pending.resolve(message);
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { P as ProtocolErrorEnvelope, a as ProtocolMetadata, R as RedwebMessage, b as ProtocolEnvelope } from './client-Dq8oKJwh.cjs';
2
- export { B as BinaryListener, C as ClientState, c as CloseListener, E as ErrorListener, L as LegacyErrorMessage, M as MessageListener, d as ReconnectOptions, e as RedwebClient, f as RedwebClientOptions, g as RequestOptions, S as SocketEventLike, h as StateListener, W as WaitOptions, i as WebSocketLike } from './client-Dq8oKJwh.cjs';
1
+ import { P as ProtocolErrorEnvelope, a as ProtocolMetadata, R as RedwebMessage, b as ProtocolEnvelope } from './client-D35g1hrL.cjs';
2
+ export { B as BinaryListener, C as ClientState, c as CloseListener, E as ErrorListener, L as LegacyErrorMessage, M as MessageListener, d as ReconnectOptions, e as RedwebClient, f as RedwebClientOptions, g as RequestOptions, S as SocketEventLike, h as StateListener, W as WaitOptions, i as WebSocketLike } from './client-D35g1hrL.cjs';
3
3
 
4
4
  declare const ERROR_CODES: Readonly<{
5
5
  readonly INVALID_MESSAGE: "INVALID_MESSAGE";
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { P as ProtocolErrorEnvelope, a as ProtocolMetadata, R as RedwebMessage, b as ProtocolEnvelope } from './client-Dq8oKJwh.js';
2
- export { B as BinaryListener, C as ClientState, c as CloseListener, E as ErrorListener, L as LegacyErrorMessage, M as MessageListener, d as ReconnectOptions, e as RedwebClient, f as RedwebClientOptions, g as RequestOptions, S as SocketEventLike, h as StateListener, W as WaitOptions, i as WebSocketLike } from './client-Dq8oKJwh.js';
1
+ import { P as ProtocolErrorEnvelope, a as ProtocolMetadata, R as RedwebMessage, b as ProtocolEnvelope } from './client-D35g1hrL.js';
2
+ export { B as BinaryListener, C as ClientState, c as CloseListener, E as ErrorListener, L as LegacyErrorMessage, M as MessageListener, d as ReconnectOptions, e as RedwebClient, f as RedwebClientOptions, g as RequestOptions, S as SocketEventLike, h as StateListener, W as WaitOptions, i as WebSocketLike } from './client-D35g1hrL.js';
3
3
 
4
4
  declare const ERROR_CODES: Readonly<{
5
5
  readonly INVALID_MESSAGE: "INVALID_MESSAGE";
package/dist/index.js CHANGED
@@ -336,6 +336,9 @@ var RedwebClient = class {
336
336
  const requestId = options.requestId ?? this.createRequestId();
337
337
  const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
338
338
  assertNonNegativeInteger(timeoutMs, "timeoutMs");
339
+ if (options.responseType !== void 0 && (typeof options.responseType !== "string" || !options.responseType || options.responseType.length > 256)) {
340
+ return Promise.reject(new TypeError("responseType must be a non-empty string of at most 256 characters."));
341
+ }
339
342
  if (options.signal?.aborted) return Promise.reject(new DOMException("The request was aborted.", "AbortError"));
340
343
  if (this.pending.has(requestId)) return Promise.reject(new Error(`A Redweb request with id "${requestId}" is already pending.`));
341
344
  return new Promise((resolve, reject) => {
@@ -343,7 +346,7 @@ var RedwebClient = class {
343
346
  this.finishPending(requestId);
344
347
  reject(new Error(`Redweb request timed out after ${timeoutMs}ms.`));
345
348
  }, timeoutMs);
346
- const pending = { resolve, reject, timer };
349
+ const pending = { resolve, reject, timer, responseType: options.responseType };
347
350
  if (options.signal) {
348
351
  pending.signal = options.signal;
349
352
  pending.abort = () => {
@@ -450,7 +453,7 @@ var RedwebClient = class {
450
453
  const requestId = message.requestId;
451
454
  if (typeof requestId === "string") {
452
455
  const pending = this.pending.get(requestId);
453
- if (pending) {
456
+ if (pending && (pending.responseType === void 0 || message.type === pending.responseType || message.type === "error" && "error" in message)) {
454
457
  this.finishPending(requestId);
455
458
  if (message.type === "error" && "error" in message) pending.reject(new RedwebProtocolError(message));
456
459
  else pending.resolve(message);
@@ -363,6 +363,9 @@ var RedwebClient = class {
363
363
  const requestId = options.requestId ?? this.createRequestId();
364
364
  const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
365
365
  assertNonNegativeInteger(timeoutMs, "timeoutMs");
366
+ if (options.responseType !== void 0 && (typeof options.responseType !== "string" || !options.responseType || options.responseType.length > 256)) {
367
+ return Promise.reject(new TypeError("responseType must be a non-empty string of at most 256 characters."));
368
+ }
366
369
  if (options.signal?.aborted) return Promise.reject(new DOMException("The request was aborted.", "AbortError"));
367
370
  if (this.pending.has(requestId)) return Promise.reject(new Error(`A Redweb request with id "${requestId}" is already pending.`));
368
371
  return new Promise((resolve, reject) => {
@@ -370,7 +373,7 @@ var RedwebClient = class {
370
373
  this.finishPending(requestId);
371
374
  reject(new Error(`Redweb request timed out after ${timeoutMs}ms.`));
372
375
  }, timeoutMs);
373
- const pending = { resolve, reject, timer };
376
+ const pending = { resolve, reject, timer, responseType: options.responseType };
374
377
  if (options.signal) {
375
378
  pending.signal = options.signal;
376
379
  pending.abort = () => {
@@ -477,7 +480,7 @@ var RedwebClient = class {
477
480
  const requestId = message.requestId;
478
481
  if (typeof requestId === "string") {
479
482
  const pending = this.pending.get(requestId);
480
- if (pending) {
483
+ if (pending && (pending.responseType === void 0 || message.type === pending.responseType || message.type === "error" && "error" in message)) {
481
484
  this.finishPending(requestId);
482
485
  if (message.type === "error" && "error" in message) pending.reject(new RedwebProtocolError(message));
483
486
  else pending.resolve(message);
@@ -768,7 +771,7 @@ var ActionFeedback = class {
768
771
 
769
772
  // src/live-html/feedback.js
770
773
  function createFeedback({ componentOf, report, stateKey, clientNodes, client, listen, active }) {
771
- const bindingOf = (source) => JSON.stringify([componentOf(source), source.getAttribute("rw-submit"), source.getAttribute("rw-click")]);
774
+ const bindingOf = (source) => JSON.stringify([componentOf(source), source.getAttribute("rw-submit"), source.getAttribute("rw-click"), source.getAttribute("data-rw-command")]);
772
775
  const revisions = /* @__PURE__ */ new WeakMap();
773
776
  const feedbackNodes = /* @__PURE__ */ new WeakMap();
774
777
  const feedbackSources = /* @__PURE__ */ new WeakMap();
@@ -855,6 +858,13 @@ function createFeedback({ componentOf, report, stateKey, clientNodes, client, li
855
858
  };
856
859
  const performAction = (source, payload, completed) => feedback.run(source, () => {
857
860
  if (client.state !== "open") throw Object.assign(new Error("Action was not sent while disconnected."), { code: "ACTION_OFFLINE" });
861
+ const command = source.getAttribute("data-rw-command");
862
+ if (command !== null) {
863
+ const binding = JSON.parse(command);
864
+ if (typeof binding.type !== "string" || !binding.type || binding.type.startsWith("redweb:")) throw new Error("Invalid socket action binding.");
865
+ const input = payload.args.length ? { ...binding.payload, ...payload.args[0] } : Object.hasOwn(binding, "payload") ? binding.payload : {};
866
+ return client.request(binding.type, input, { responseType: "redweb:result" });
867
+ }
858
868
  return client.request("redweb:html", payload);
859
869
  }).then((success) => {
860
870
  if (success && active()) completed?.();
@@ -1,4 +1,4 @@
1
- import { e as RedwebClient } from './client-Dq8oKJwh.cjs';
1
+ import { e as RedwebClient } from './client-D35g1hrL.cjs';
2
2
 
3
3
  /** One browser document's Live HTML transport and delegated event ownership. */
4
4
  interface LivePageConnection {
@@ -1,4 +1,4 @@
1
- import { e as RedwebClient } from './client-Dq8oKJwh.js';
1
+ import { e as RedwebClient } from './client-D35g1hrL.js';
2
2
 
3
3
  /** One browser document's Live HTML transport and delegated event ownership. */
4
4
  interface LivePageConnection {
package/dist/live-html.js CHANGED
@@ -336,6 +336,9 @@ var RedwebClient = class {
336
336
  const requestId = options.requestId ?? this.createRequestId();
337
337
  const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
338
338
  assertNonNegativeInteger(timeoutMs, "timeoutMs");
339
+ if (options.responseType !== void 0 && (typeof options.responseType !== "string" || !options.responseType || options.responseType.length > 256)) {
340
+ return Promise.reject(new TypeError("responseType must be a non-empty string of at most 256 characters."));
341
+ }
339
342
  if (options.signal?.aborted) return Promise.reject(new DOMException("The request was aborted.", "AbortError"));
340
343
  if (this.pending.has(requestId)) return Promise.reject(new Error(`A Redweb request with id "${requestId}" is already pending.`));
341
344
  return new Promise((resolve, reject) => {
@@ -343,7 +346,7 @@ var RedwebClient = class {
343
346
  this.finishPending(requestId);
344
347
  reject(new Error(`Redweb request timed out after ${timeoutMs}ms.`));
345
348
  }, timeoutMs);
346
- const pending = { resolve, reject, timer };
349
+ const pending = { resolve, reject, timer, responseType: options.responseType };
347
350
  if (options.signal) {
348
351
  pending.signal = options.signal;
349
352
  pending.abort = () => {
@@ -450,7 +453,7 @@ var RedwebClient = class {
450
453
  const requestId = message.requestId;
451
454
  if (typeof requestId === "string") {
452
455
  const pending = this.pending.get(requestId);
453
- if (pending) {
456
+ if (pending && (pending.responseType === void 0 || message.type === pending.responseType || message.type === "error" && "error" in message)) {
454
457
  this.finishPending(requestId);
455
458
  if (message.type === "error" && "error" in message) pending.reject(new RedwebProtocolError(message));
456
459
  else pending.resolve(message);
@@ -741,7 +744,7 @@ var ActionFeedback = class {
741
744
 
742
745
  // src/live-html/feedback.js
743
746
  function createFeedback({ componentOf, report, stateKey, clientNodes, client, listen, active }) {
744
- const bindingOf = (source) => JSON.stringify([componentOf(source), source.getAttribute("rw-submit"), source.getAttribute("rw-click")]);
747
+ const bindingOf = (source) => JSON.stringify([componentOf(source), source.getAttribute("rw-submit"), source.getAttribute("rw-click"), source.getAttribute("data-rw-command")]);
745
748
  const revisions = /* @__PURE__ */ new WeakMap();
746
749
  const feedbackNodes = /* @__PURE__ */ new WeakMap();
747
750
  const feedbackSources = /* @__PURE__ */ new WeakMap();
@@ -828,6 +831,13 @@ function createFeedback({ componentOf, report, stateKey, clientNodes, client, li
828
831
  };
829
832
  const performAction = (source, payload, completed) => feedback.run(source, () => {
830
833
  if (client.state !== "open") throw Object.assign(new Error("Action was not sent while disconnected."), { code: "ACTION_OFFLINE" });
834
+ const command = source.getAttribute("data-rw-command");
835
+ if (command !== null) {
836
+ const binding = JSON.parse(command);
837
+ if (typeof binding.type !== "string" || !binding.type || binding.type.startsWith("redweb:")) throw new Error("Invalid socket action binding.");
838
+ const input = payload.args.length ? { ...binding.payload, ...payload.args[0] } : Object.hasOwn(binding, "payload") ? binding.payload : {};
839
+ return client.request(binding.type, input, { responseType: "redweb:result" });
840
+ }
831
841
  return client.request("redweb:html", payload);
832
842
  }).then((success) => {
833
843
  if (success && active()) completed?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redweb-client",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Browser-first WebSocket client for Redweb",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -25,10 +25,12 @@
25
25
  "files": [
26
26
  "dist",
27
27
  "README.md",
28
+ "CHANGELOG.md",
28
29
  "LICENSE"
29
30
  ],
30
31
  "sideEffects": false,
31
32
  "scripts": {
33
+ "prepack": "npm run build 1>&2",
32
34
  "build": "tsup src/index.ts src/live-html.ts --format esm,cjs --dts --clean --splitting false",
33
35
  "typecheck": "tsc --noEmit",
34
36
  "test": "npm run check",