@webjsdev/core 0.7.18 → 0.7.19

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/index-browser.js CHANGED
@@ -55,6 +55,12 @@ export { optimistic } from './src/optimistic.js';
55
55
  // SSR action-seed consumer (#472): the generated RPC stub reads a seed on its
56
56
  // first call so async-render hydration does not re-fetch the SSR'd data.
57
57
  export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
58
+ // Client tag-cache coordinator for HTTP-verb actions (#488): tag-based
59
+ // browser-cache eviction after a mutation. Inert server-side.
60
+ export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
61
+ // Client action-abort plumbing (#492): a superseded async render aborts its
62
+ // in-flight action fetches. Inert server-side.
63
+ export { setActiveActionSignal, activeActionSignal } from './src/action-abort-client.js';
58
64
 
59
65
  // Directives, also available via '@webjsdev/core/directives'. The full
60
66
  // lit-html-parity set is re-exported here so the dist browser bundle (which
package/index.d.ts CHANGED
@@ -92,6 +92,10 @@ export { Task, TaskStatus } from './src/task.js';
92
92
  export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
93
93
  export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
94
94
  export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
95
+ export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
96
+ // Client action-abort plumbing (#492): a superseded async render aborts its
97
+ // in-flight action fetches. Inert server-side.
98
+ export { setActiveActionSignal, activeActionSignal } from './src/action-abort-client.js';
95
99
  export { stringify, parse, serialize, deserialize } from './src/serialize.js';
96
100
  export { WebjsFrame } from './src/webjs-frame.js';
97
101
  export { WebjsStream, renderStream } from './src/webjs-stream.js';
package/index.js CHANGED
@@ -37,6 +37,12 @@ export { optimistic } from './src/optimistic.js';
37
37
  // SSR action-seed consumer (#472): the generated RPC stub reads a seed on its
38
38
  // first call so async-render hydration does not re-fetch. Inert server-side.
39
39
  export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
40
+ // Client tag-cache coordinator for HTTP-verb actions (#488): tag-based
41
+ // browser-cache eviction after a mutation. Inert server-side.
42
+ export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
43
+ // Client action-abort plumbing (#492): a superseded async render aborts its
44
+ // in-flight action fetches. Inert server-side.
45
+ export { setActiveActionSignal, activeActionSignal } from './src/action-abort-client.js';
40
46
 
41
47
  // Directives, also available via '@webjsdev/core/directives'. The full
42
48
  // lit-html-parity set is re-exported so the bare specifier exposes the same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.18",
3
+ "version": "0.7.19",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -0,0 +1,3 @@
1
+ /** Client action-abort plumbing (#492). Inert server-side. */
2
+ export function setActiveActionSignal(signal: AbortSignal | null): void;
3
+ export function activeActionSignal(): AbortSignal | undefined;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Client-side action abort plumbing (#492).
3
+ *
4
+ * An `async render()` that supersedes an in-flight one should ABORT the previous
5
+ * render's action fetches, not just drop their results. The component sets a
6
+ * fresh AbortController's signal as the "active" signal right before each render
7
+ * (`setActiveActionSignal`), and the generated RPC stub binds every `fetch` to
8
+ * `activeActionSignal()`. When the next render supersedes, the component aborts
9
+ * the previous controller, cancelling those fetches.
10
+ *
11
+ * Best-effort by design: the stub reads the active signal SYNCHRONOUSLY when the
12
+ * action is called, so it binds for an action invoked in the synchronous portion
13
+ * of `render()` (the common `const u = await getUser(id)` first statement). An
14
+ * action invoked after a later `await` may see no active signal; it then simply
15
+ * runs to completion and its result is dropped by the existing render-token
16
+ * guard, exactly as before this feature. Inert server-side (never set there).
17
+ */
18
+
19
+ /** The AbortSignal the currently-rendering component bound, or null. */
20
+ let active = null;
21
+
22
+ /**
23
+ * Set (or clear with null) the signal the generated RPC stub binds its fetches
24
+ * to. The component sets this around the synchronous portion of `render()`.
25
+ * @param {AbortSignal | null} signal
26
+ */
27
+ export function setActiveActionSignal(signal) {
28
+ active = signal || null;
29
+ }
30
+
31
+ /**
32
+ * The active abort signal a stub should bind a fetch to, or undefined when no
33
+ * render is in flight (an action called from an event handler is not bound, so
34
+ * a re-render never aborts a user-triggered mutation).
35
+ * @returns {AbortSignal | undefined}
36
+ */
37
+ export function activeActionSignal() {
38
+ return active || undefined;
39
+ }
@@ -0,0 +1,6 @@
1
+ /** Client tag-cache coordinator for HTTP-verb server actions (#488). Inert server-side. */
2
+ export function markStale(tags: string[]): void;
3
+ export function fetchMark(): number;
4
+ export function registerKeyTags(key: string, tags: string[], since?: number): void;
5
+ export function consumeStale(key: string): boolean;
6
+ export function parseTagHeader(value: string | null): string[];
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Client-side tag cache coordinator for HTTP-verb server actions (#488).
3
+ *
4
+ * A GET action is cached by the BROWSER HTTP cache (its `Cache-Control` +
5
+ * ETag). After a mutation, that cached entry would still be served within its
6
+ * `max-age`, so a tag-based eviction is layered on top: the server tells the
7
+ * client which tags a GET belongs to (the `X-Webjs-Tags` response header) and
8
+ * which tags a mutation invalidated (`X-Webjs-Invalidate`). When a later GET's
9
+ * tags were invalidated AFTER that key's last fetch STARTED, the stub re-fetches
10
+ * with `cache: 'no-cache'` (a conditional revalidation that bypasses the stale
11
+ * browser-cache entry and returns fresh data, since the ETag changed).
12
+ *
13
+ * Mechanism: a monotonic CLOCK, bumped on every invalidation. Each tag records
14
+ * the clock at its last invalidation; each GET records the clock value SAMPLED
15
+ * JUST BEFORE its fetch was dispatched. A read needs a bypass when any of its
16
+ * tags was invalidated at a clock value greater than the key's sampled value.
17
+ * Sampling before the fetch (not when the response lands) is what closes the
18
+ * race where a mutation commits while a read is in flight: such a mutation has
19
+ * a higher clock than the read's sample, so the next read still bypasses.
20
+ *
21
+ * A thin coordinator, NOT a data store (the data lives in the browser HTTP
22
+ * cache, on-thesis: no bespoke client query cache). Inert server-side.
23
+ */
24
+
25
+ /** Monotonic logical clock; bumped once per `markStale` call. */
26
+ let clock = 0;
27
+
28
+ /** `tag -> clock value at its last invalidation`. */
29
+ const tagInvalidatedAt = new Map();
30
+
31
+ /** `key -> { tags, since }` where `since` is the clock sampled before its fetch. */
32
+ const keyInfo = new Map();
33
+
34
+ /** Bound `keyInfo` growth for a long-lived session (FIFO-evict the oldest). */
35
+ const MAX_KEYS = 1000;
36
+
37
+ /**
38
+ * Mark tags invalidated (called after a mutation reports `X-Webjs-Invalidate`):
39
+ * advance the clock and stamp each tag with it.
40
+ * @param {string[]} tags
41
+ */
42
+ export function markStale(tags) {
43
+ if (!Array.isArray(tags) || !tags.length) return;
44
+ clock += 1;
45
+ for (const t of tags) if (t) tagInvalidatedAt.set(t, clock);
46
+ }
47
+
48
+ /**
49
+ * Sample the clock just before dispatching a GET fetch. The value is passed to
50
+ * `registerKeyTags` so an invalidation that lands WHILE the fetch is in flight
51
+ * (a higher clock than this sample) is detected on the next read.
52
+ * @returns {number}
53
+ */
54
+ export function fetchMark() {
55
+ return clock;
56
+ }
57
+
58
+ /**
59
+ * Record the tags a GET response declared for its key, stamped with the clock
60
+ * value sampled before the fetch (`since`).
61
+ * @param {string} key the GET cache key (`stringify(args)`)
62
+ * @param {string[]} tags
63
+ * @param {number} [since] the `fetchMark()` sampled before the fetch
64
+ */
65
+ export function registerKeyTags(key, tags, since) {
66
+ if (!Array.isArray(tags) || !tags.length) return;
67
+ // Re-insert to move the key to the newest position (LRU-ish), then cap.
68
+ keyInfo.delete(key);
69
+ keyInfo.set(key, { tags: tags.slice(), since: typeof since === 'number' ? since : clock });
70
+ if (keyInfo.size > MAX_KEYS) {
71
+ const oldest = keyInfo.keys().next().value;
72
+ if (oldest !== undefined) keyInfo.delete(oldest);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Whether a GET for `key` should bypass the browser cache (re-fetch with
78
+ * `cache: 'no-cache'`) because one of its tags was invalidated AFTER this key's
79
+ * last fetch started. An unrecorded key (never fetched, e.g. resolved from the
80
+ * SSR seed) returns false: its browser-cache entry does not exist yet, so a
81
+ * normal fetch already returns fresh data.
82
+ * @param {string} key
83
+ * @returns {boolean}
84
+ */
85
+ export function consumeStale(key) {
86
+ const info = keyInfo.get(key);
87
+ if (!info) return false;
88
+ for (const t of info.tags) if ((tagInvalidatedAt.get(t) || 0) > info.since) return true;
89
+ return false;
90
+ }
91
+
92
+ /** Parse a comma-separated tag header into a trimmed, non-empty list. */
93
+ export function parseTagHeader(value) {
94
+ if (!value) return [];
95
+ return value.split(',').map((s) => s.trim()).filter(Boolean);
96
+ }
97
+
98
+ /** Test seam: clear all tag state. */
99
+ export function __resetActionCache() {
100
+ clock = 0;
101
+ tagInvalidatedAt.clear();
102
+ keyInfo.clear();
103
+ }
package/src/component.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { render as clientRender } from './render-client.js';
2
+ import { setActiveActionSignal } from './action-abort-client.js';
2
3
  import { isCSS, adoptStyles } from './css.js';
3
4
  import { register, tagOf } from './registry.js';
4
5
  import { parse as deserializeProp } from './serialize.js';
@@ -1015,11 +1016,23 @@ export class WebComponent extends Base {
1015
1016
  // by-sync case (#469); a shouldUpdate=false cycle never reaches here,
1016
1017
  // so it does not invalidate an in-flight async render.
1017
1018
  this.__renderToken = (this.__renderToken || 0) + 1;
1019
+ // Abort the superseded render's in-flight action fetches (#492), start a
1020
+ // fresh controller for this render, and bind it as the active signal so
1021
+ // the RPC stub ties its fetches to this render. A superseded fetch's
1022
+ // AbortError is dropped by the render-token guard in _commitAsync.
1023
+ if (this.__renderAbort) this.__renderAbort.abort();
1024
+ this.__renderAbort = typeof AbortController !== 'undefined' ? new AbortController() : null;
1025
+ setActiveActionSignal(this.__renderAbort ? this.__renderAbort.signal : null);
1018
1026
  try {
1019
1027
  const r = this.update(changedProperties);
1020
1028
  if (r && typeof r.then === 'function') pendingCommit = r;
1021
1029
  } catch (error) {
1022
1030
  this._handleRenderError(/** @type {Error} */ (error));
1031
+ } finally {
1032
+ // The synchronous portion of render() has run; actions invoked there
1033
+ // already captured the signal. Clear it so a later event handler is
1034
+ // not bound to a stale render's controller.
1035
+ setActiveActionSignal(null);
1023
1036
  }
1024
1037
 
1025
1038
  if (!pendingCommit) {
package/src/serialize.js CHANGED
@@ -51,6 +51,36 @@
51
51
  const TAG = '_$wj';
52
52
  const ID_KEY = '_id';
53
53
 
54
+ /**
55
+ * Keys that, assigned with bracket notation, mutate the target instead of
56
+ * adding an own data property: `out['__proto__'] = x` invokes the prototype
57
+ * setter (a prototype-pollution vector on decode, reachable because
58
+ * `JSON.parse` makes a wire `"__proto__"` an OWN key; and on encode it silently
59
+ * DROPS a legitimate own `__proto__` data property and corrupts the
60
+ * accumulator's prototype). `constructor` / `prototype` shadow in ways
61
+ * downstream code can be confused by.
62
+ */
63
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
64
+
65
+ /**
66
+ * Assign `value` onto a plain accumulator under `key`. A normal key is a plain
67
+ * assignment (the hot path); an unsafe key is written as an ordinary own
68
+ * enumerable data property via `defineProperty`, so the value survives as data
69
+ * (round-trippable, since JSON includes an own enumerable `__proto__`) and the
70
+ * prototype chain is never touched. Used on BOTH the encode and decode object
71
+ * loops so `serialize` / `deserialize` stay inverses on these keys.
72
+ * @param {Record<string, unknown>} out
73
+ * @param {string} key
74
+ * @param {unknown} value
75
+ */
76
+ function safeAssign(out, key, value) {
77
+ if (UNSAFE_KEYS.has(key)) {
78
+ Object.defineProperty(out, key, { value, writable: true, enumerable: true, configurable: true });
79
+ } else {
80
+ out[key] = value;
81
+ }
82
+ }
83
+
54
84
  /* ----------------------------------------------------------------- *
55
85
  * Public API *
56
86
  * ----------------------------------------------------------------- */
@@ -225,7 +255,7 @@ function encodeOne(v, ctx) {
225
255
  for (const k in v) {
226
256
  if (!Object.prototype.hasOwnProperty.call(v, k)) continue;
227
257
  const escaped = isReservedKey(k) ? '_' + k : k;
228
- out[escaped] = encodeOne(v[k], ctx);
258
+ safeAssign(out, escaped, encodeOne(v[k], ctx));
229
259
  }
230
260
  }
231
261
 
@@ -314,7 +344,7 @@ function decode(v, ctx) {
314
344
  if (!Object.prototype.hasOwnProperty.call(v, k)) continue;
315
345
  if (k === ID_KEY) continue;
316
346
  const realKey = isEscapedReservedKey(k) ? k.slice(1) : k;
317
- out[realKey] = decode(v[k], ctx);
347
+ safeAssign(out, realKey, decode(v[k], ctx));
318
348
  }
319
349
  return out;
320
350
  }