@webjsdev/core 0.7.18 → 0.7.20
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/README.md +1 -1
- package/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +16 -16
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +4 -4
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +11 -5
- package/index.d.ts +18 -1
- package/index.js +11 -1
- package/package.json +1 -1
- package/src/action-abort-client.d.ts +3 -0
- package/src/action-abort-client.js +39 -0
- package/src/action-cache-client.d.ts +6 -0
- package/src/action-cache-client.js +103 -0
- package/src/action-stream.d.ts +12 -0
- package/src/action-stream.js +92 -0
- package/src/component.js +13 -0
- package/src/serializable.d.ts +104 -0
- package/src/serialize.js +32 -2
- package/src/expose.js +0 -180
package/index-browser.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Mirror of `./index.js` but with strictly server-only exports
|
|
5
5
|
* removed so the browser bundle never drags `src/render-server.js`
|
|
6
|
-
* (~1100 lines)
|
|
6
|
+
* (~1100 lines) over the wire.
|
|
7
7
|
*
|
|
8
8
|
* Stripped:
|
|
9
9
|
* - `renderToString`, `renderToStream` (server SSR pipeline; reach
|
|
@@ -11,10 +11,6 @@
|
|
|
11
11
|
* - `setCspNonceProvider` (server-side wiring called by
|
|
12
12
|
* `@webjsdev/server`'s context module; `cspNonce()` stays
|
|
13
13
|
* because layouts can call it).
|
|
14
|
-
* - `expose`, `getExposed` (live inside `.server.{js,ts}` files;
|
|
15
|
-
* framework rewrites the import to an RPC stub on the client
|
|
16
|
-
* side, so the real module never legitimately reaches the
|
|
17
|
-
* browser).
|
|
18
14
|
*
|
|
19
15
|
* The framework's own SSR pipeline runs on Node and resolves the
|
|
20
16
|
* package via the package.json `"default"` condition, which still
|
|
@@ -55,6 +51,16 @@ export { optimistic } from './src/optimistic.js';
|
|
|
55
51
|
// SSR action-seed consumer (#472): the generated RPC stub reads a seed on its
|
|
56
52
|
// first call so async-render hydration does not re-fetch the SSR'd data.
|
|
57
53
|
export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
|
|
54
|
+
// Client tag-cache coordinator for HTTP-verb actions (#488): tag-based
|
|
55
|
+
// browser-cache eviction after a mutation. Inert server-side.
|
|
56
|
+
export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
|
|
57
|
+
// Client action-abort plumbing (#492): a superseded async render aborts its
|
|
58
|
+
// in-flight action fetches. Inert server-side.
|
|
59
|
+
export { setActiveActionSignal, activeActionSignal } from './src/action-abort-client.js';
|
|
60
|
+
// Streaming RPC wire protocol (#489): the byte framing the client stub decodes.
|
|
61
|
+
export {
|
|
62
|
+
STREAM_CONTENT_TYPE, FRAME_CHUNK, FRAME_END, FRAME_ERROR, encodeFrame, createFrameDecoder,
|
|
63
|
+
} from './src/action-stream.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
|
@@ -54,6 +54,16 @@ export type {
|
|
|
54
54
|
WebjsCspConfig,
|
|
55
55
|
} from './src/webjs-config.d.ts';
|
|
56
56
|
|
|
57
|
+
// Compile-time serializability typing for server actions (#488): the opt-in
|
|
58
|
+
// guard that makes a non-serializable action arg / return a type error.
|
|
59
|
+
export type {
|
|
60
|
+
Serializable,
|
|
61
|
+
SerializableArgs,
|
|
62
|
+
SerializableResult,
|
|
63
|
+
SerializableActionFn,
|
|
64
|
+
NonSerializable,
|
|
65
|
+
} from './src/serializable.d.ts';
|
|
66
|
+
|
|
57
67
|
export { html, isTemplate, MARKER } from './src/html.js';
|
|
58
68
|
export { css, isCSS, adoptStyles, stylesToString } from './src/css.js';
|
|
59
69
|
export { register, lookup, lookupModuleUrl, isLazy, allTags, primeModuleUrl, tagOf } from './src/registry.js';
|
|
@@ -61,7 +71,6 @@ export { renderToString, renderToStream } from './src/render-server.js';
|
|
|
61
71
|
export { render } from './src/render-client.js';
|
|
62
72
|
export { escapeText, escapeAttr } from './src/escape.js';
|
|
63
73
|
export { notFound, redirect, isNotFound, isRedirect } from './src/nav.js';
|
|
64
|
-
export { expose, getExposed, validateInput } from './src/expose.js';
|
|
65
74
|
export { repeat, isRepeat } from './src/repeat.js';
|
|
66
75
|
export { Suspense, isSuspense } from './src/suspense.js';
|
|
67
76
|
export { connectWS } from './src/websocket-client.js';
|
|
@@ -92,6 +101,14 @@ export { Task, TaskStatus } from './src/task.js';
|
|
|
92
101
|
export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
|
|
93
102
|
export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
|
|
94
103
|
export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
|
|
104
|
+
export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
|
|
105
|
+
// Client action-abort plumbing (#492): a superseded async render aborts its
|
|
106
|
+
// in-flight action fetches. Inert server-side.
|
|
107
|
+
export { setActiveActionSignal, activeActionSignal } from './src/action-abort-client.js';
|
|
108
|
+
// Streaming RPC wire protocol (#489).
|
|
109
|
+
export {
|
|
110
|
+
STREAM_CONTENT_TYPE, FRAME_CHUNK, FRAME_END, FRAME_ERROR, encodeFrame, createFrameDecoder,
|
|
111
|
+
} from './src/action-stream.js';
|
|
95
112
|
export { stringify, parse, serialize, deserialize } from './src/serialize.js';
|
|
96
113
|
export { WebjsFrame } from './src/webjs-frame.js';
|
|
97
114
|
export { WebjsStream, renderStream } from './src/webjs-stream.js';
|
package/index.js
CHANGED
|
@@ -15,7 +15,6 @@ export { render } from './src/render-client.js';
|
|
|
15
15
|
export { escapeText, escapeAttr } from './src/escape.js';
|
|
16
16
|
export { notFound, redirect, isNotFound, isRedirect } from './src/nav.js';
|
|
17
17
|
export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
|
|
18
|
-
export { expose, getExposed, validateInput } from './src/expose.js';
|
|
19
18
|
export { repeat, isRepeat } from './src/repeat.js';
|
|
20
19
|
export { Suspense, isSuspense } from './src/suspense.js';
|
|
21
20
|
export { connectWS } from './src/websocket-client.js';
|
|
@@ -37,6 +36,17 @@ export { optimistic } from './src/optimistic.js';
|
|
|
37
36
|
// SSR action-seed consumer (#472): the generated RPC stub reads a seed on its
|
|
38
37
|
// first call so async-render hydration does not re-fetch. Inert server-side.
|
|
39
38
|
export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
|
|
39
|
+
// Client tag-cache coordinator for HTTP-verb actions (#488): tag-based
|
|
40
|
+
// browser-cache eviction after a mutation. Inert server-side.
|
|
41
|
+
export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
|
|
42
|
+
// Client action-abort plumbing (#492): a superseded async render aborts its
|
|
43
|
+
// in-flight action fetches. Inert server-side.
|
|
44
|
+
export { setActiveActionSignal, activeActionSignal } from './src/action-abort-client.js';
|
|
45
|
+
// Streaming RPC wire protocol (#489): the byte framing the server encodes a
|
|
46
|
+
// streamed action result with and the client stub decodes. Pure byte ops.
|
|
47
|
+
export {
|
|
48
|
+
STREAM_CONTENT_TYPE, FRAME_CHUNK, FRAME_END, FRAME_ERROR, encodeFrame, createFrameDecoder,
|
|
49
|
+
} from './src/action-stream.js';
|
|
40
50
|
|
|
41
51
|
// Directives, also available via '@webjsdev/core/directives'. The full
|
|
42
52
|
// lit-html-parity set is re-exported so the bare specifier exposes the same
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Streaming RPC wire protocol (#489). See action-stream.js. */
|
|
2
|
+
|
|
3
|
+
export declare const STREAM_CONTENT_TYPE: string;
|
|
4
|
+
export declare const FRAME_CHUNK: number;
|
|
5
|
+
export declare const FRAME_END: number;
|
|
6
|
+
export declare const FRAME_ERROR: number;
|
|
7
|
+
|
|
8
|
+
export declare function encodeFrame(type: number, payload?: Uint8Array): Uint8Array;
|
|
9
|
+
|
|
10
|
+
export declare function createFrameDecoder(): {
|
|
11
|
+
push(chunk: Uint8Array): Array<{ type: number; payload: Uint8Array }>;
|
|
12
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming RPC wire protocol (#489), shared by the server (which frames an
|
|
3
|
+
* action's streamed result) and the client RPC stub (which decodes it). It lives
|
|
4
|
+
* in core because both sides need the EXACT same byte format; the server imports
|
|
5
|
+
* `encodeFrame` and the stub imports `createFrameDecoder` from `@webjsdev/core`.
|
|
6
|
+
*
|
|
7
|
+
* An action that returns a `ReadableStream` / async iterable / async generator
|
|
8
|
+
* streams its chunks over a single RPC response (one fetch, not multiplexed, so
|
|
9
|
+
* no stream-id is needed). The response carries `STREAM_CONTENT_TYPE`, and its
|
|
10
|
+
* body is a sequence of length-prefixed frames:
|
|
11
|
+
*
|
|
12
|
+
* ┌────────┬──────────────┬──────────────────────┐
|
|
13
|
+
* │ type:1 │ length:4 BE │ payload: length bytes│
|
|
14
|
+
* └────────┴──────────────┴──────────────────────┘
|
|
15
|
+
*
|
|
16
|
+
* - CHUNK (1): payload is one serialized chunk value (the same wire format the
|
|
17
|
+
* RPC envelope uses, so a rich value round-trips). The client deserializes it
|
|
18
|
+
* and yields it from the async iterable the stub returns.
|
|
19
|
+
* - END (2): length 0, no payload. Clean completion; the iterable returns.
|
|
20
|
+
* - ERROR (3): payload is the (author-controlled) error message string. The
|
|
21
|
+
* stream errored after the first byte, so it cannot change the HTTP status;
|
|
22
|
+
* the client throws this message from the iterable instead.
|
|
23
|
+
*
|
|
24
|
+
* Pure byte manipulation, no DOM and no `node:*` deps, so it is safe in the
|
|
25
|
+
* browser bundle and on the server alike.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The RPC content type for a framed streamed result. */
|
|
29
|
+
export const STREAM_CONTENT_TYPE = 'application/vnd.webjs+stream';
|
|
30
|
+
|
|
31
|
+
/** Frame type: one serialized chunk value. */
|
|
32
|
+
export const FRAME_CHUNK = 1;
|
|
33
|
+
/** Frame type: clean end of stream (empty payload). */
|
|
34
|
+
export const FRAME_END = 2;
|
|
35
|
+
/** Frame type: the stream errored mid-flight (payload is the message). */
|
|
36
|
+
export const FRAME_ERROR = 3;
|
|
37
|
+
|
|
38
|
+
const EMPTY = new Uint8Array(0);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Frame a payload as `[type:1][length:4 BE][payload]`. `payload` may be omitted
|
|
42
|
+
* for a zero-length frame (END).
|
|
43
|
+
* @param {number} type one of FRAME_CHUNK / FRAME_END / FRAME_ERROR
|
|
44
|
+
* @param {Uint8Array} [payload]
|
|
45
|
+
* @returns {Uint8Array}
|
|
46
|
+
*/
|
|
47
|
+
export function encodeFrame(type, payload) {
|
|
48
|
+
const body = payload || EMPTY;
|
|
49
|
+
const len = body.length;
|
|
50
|
+
const out = new Uint8Array(5 + len);
|
|
51
|
+
out[0] = type & 0xff;
|
|
52
|
+
out[1] = (len >>> 24) & 0xff;
|
|
53
|
+
out[2] = (len >>> 16) & 0xff;
|
|
54
|
+
out[3] = (len >>> 8) & 0xff;
|
|
55
|
+
out[4] = len & 0xff;
|
|
56
|
+
if (len) out.set(body, 5);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A stateful decoder that turns a stream of arbitrary byte chunks back into
|
|
62
|
+
* whole frames, buffering across chunk boundaries (a frame may be split over two
|
|
63
|
+
* network reads, or two frames may arrive in one read). `push(chunk)` returns the
|
|
64
|
+
* array of complete frames it could parse from the accumulated buffer so far;
|
|
65
|
+
* partial trailing bytes stay buffered for the next `push`.
|
|
66
|
+
* @returns {{ push(chunk: Uint8Array): Array<{ type: number, payload: Uint8Array }> }}
|
|
67
|
+
*/
|
|
68
|
+
export function createFrameDecoder() {
|
|
69
|
+
let buf = EMPTY;
|
|
70
|
+
return {
|
|
71
|
+
push(chunk) {
|
|
72
|
+
if (chunk && chunk.length) {
|
|
73
|
+
const next = new Uint8Array(buf.length + chunk.length);
|
|
74
|
+
next.set(buf, 0);
|
|
75
|
+
next.set(chunk, buf.length);
|
|
76
|
+
buf = next;
|
|
77
|
+
}
|
|
78
|
+
const frames = [];
|
|
79
|
+
// A frame needs at least the 5-byte header before its length is known.
|
|
80
|
+
while (buf.length >= 5) {
|
|
81
|
+
const type = buf[0];
|
|
82
|
+
const len = ((buf[1] << 24) | (buf[2] << 16) | (buf[3] << 8) | buf[4]) >>> 0;
|
|
83
|
+
if (buf.length < 5 + len) break; // payload not fully arrived yet
|
|
84
|
+
// Copy the payload out so it does not alias the (soon-reassigned) buffer.
|
|
85
|
+
const payload = buf.slice(5, 5 + len);
|
|
86
|
+
frames.push({ type, payload });
|
|
87
|
+
buf = buf.slice(5 + len);
|
|
88
|
+
}
|
|
89
|
+
return frames;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
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) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-time serializability typing for server actions (#488).
|
|
3
|
+
*
|
|
4
|
+
* A `'use server'` action's arguments and result cross the RPC wire through the
|
|
5
|
+
* webjs serializer, which round-trips a SPECIFIC set of rich types (`Date`,
|
|
6
|
+
* `Map`, `Set`, `BigInt`, `Error`, `RegExp`, `URL`, typed arrays, `ArrayBuffer`,
|
|
7
|
+
* `Blob`, `File`, `FormData`, registered `Symbol`s, plain objects / arrays, and
|
|
8
|
+
* cycles). It does NOT round-trip a FUNCTION or a class instance's METHODS: a
|
|
9
|
+
* function silently vanishes from the wire, and a class instance arrives as a
|
|
10
|
+
* plain object with its prototype (so its methods) gone. That is a runtime
|
|
11
|
+
* surprise the author never asked for.
|
|
12
|
+
*
|
|
13
|
+
* `Serializable<T>` makes it a TYPE error instead. It maps a fully serializable
|
|
14
|
+
* `T` to itself, and a `T` that carries a function (a method, a callback prop)
|
|
15
|
+
* to a branded `NonSerializable<...>` marker, so assigning the offending value
|
|
16
|
+
* fails to typecheck with a message that names the problem. `SerializableArgs`
|
|
17
|
+
* / `SerializableResult` apply it across an action's parameter tuple and its
|
|
18
|
+
* (possibly promised) return.
|
|
19
|
+
*
|
|
20
|
+
* This is OPT-IN by design: webjs actions stay plain `export async function`s
|
|
21
|
+
* with no wrapper (the framework rewrites the client import to an RPC stub at
|
|
22
|
+
* runtime, it does not wrap the authored function). An author who wants the
|
|
23
|
+
* guard annotates the action with `SerializableActionFn`, e.g.
|
|
24
|
+
*
|
|
25
|
+
* import type { SerializableActionFn } from '@webjsdev/core';
|
|
26
|
+
* export const getUser: SerializableActionFn<(id: number) => Promise<User>> =
|
|
27
|
+
* async (id) => db.user.find(id);
|
|
28
|
+
*
|
|
29
|
+
* If `User` (or any arg) is not serializable, the annotation is a compile-time
|
|
30
|
+
* error pointing at the offending member. Types only, erased at runtime, zero
|
|
31
|
+
* cost.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** A branded marker a non-serializable position resolves to, so the error names it. */
|
|
35
|
+
export type NonSerializable<Reason extends string = 'this value is not serializable over the webjs RPC wire'> = {
|
|
36
|
+
readonly __webjsNonSerializable: Reason;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Primitives the wire carries verbatim. */
|
|
40
|
+
type SerializablePrimitive = string | number | boolean | bigint | null | undefined;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Built-in rich types the webjs serializer round-trips as themselves (their
|
|
44
|
+
* identity / methods survive because the serializer reconstructs the instance).
|
|
45
|
+
* `Map` / `Set` recurse into their members via the top-level mapper.
|
|
46
|
+
*/
|
|
47
|
+
type SerializableBuiltin =
|
|
48
|
+
| Date
|
|
49
|
+
| RegExp
|
|
50
|
+
| URL
|
|
51
|
+
| Error
|
|
52
|
+
| ArrayBuffer
|
|
53
|
+
| Int8Array | Uint8Array | Uint8ClampedArray
|
|
54
|
+
| Int16Array | Uint16Array
|
|
55
|
+
| Int32Array | Uint32Array
|
|
56
|
+
| Float32Array | Float64Array
|
|
57
|
+
| BigInt64Array | BigUint64Array
|
|
58
|
+
| Blob
|
|
59
|
+
| File
|
|
60
|
+
| FormData;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Map `T` to itself when it is fully serializable, else to a `NonSerializable`
|
|
64
|
+
* marker at the offending position. A function (a method or a callback-valued
|
|
65
|
+
* property) is the canonical non-serializable case. Objects and arrays recurse;
|
|
66
|
+
* `Map` / `Set` recurse into their type parameters.
|
|
67
|
+
*/
|
|
68
|
+
export type Serializable<T> =
|
|
69
|
+
T extends SerializablePrimitive ? T :
|
|
70
|
+
// A function never survives the wire (it just disappears).
|
|
71
|
+
T extends (...args: any[]) => any ? NonSerializable<'a function is not serializable over the webjs RPC wire'> :
|
|
72
|
+
T extends SerializableBuiltin ? T :
|
|
73
|
+
T extends Map<infer K, infer V> ? Map<Serializable<K>, Serializable<V>> :
|
|
74
|
+
T extends Set<infer U> ? Set<Serializable<U>> :
|
|
75
|
+
T extends Promise<infer U> ? Promise<Serializable<U>> :
|
|
76
|
+
T extends ReadonlyArray<infer U> ? (T extends Array<infer _W> ? Array<Serializable<U>> : ReadonlyArray<Serializable<U>>) :
|
|
77
|
+
T extends object ? { [K in keyof T]: Serializable<T[K]> } :
|
|
78
|
+
T;
|
|
79
|
+
|
|
80
|
+
/** Apply {@link Serializable} across an action's parameter tuple. */
|
|
81
|
+
export type SerializableArgs<A extends readonly unknown[]> = {
|
|
82
|
+
[K in keyof A]: Serializable<A[K]>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Apply {@link Serializable} to an action's return, unwrapping a `Promise` so an
|
|
87
|
+
* async action is checked against its resolved value (the wire carries the
|
|
88
|
+
* resolved value, never the promise).
|
|
89
|
+
*/
|
|
90
|
+
export type SerializableResult<R> =
|
|
91
|
+
R extends Promise<infer U> ? Promise<Serializable<U>> : Serializable<R>;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The opt-in annotation type for a server action: it preserves the function's
|
|
95
|
+
* exact call signature while constraining every argument and the result to be
|
|
96
|
+
* serializable. A non-serializable arg or return makes the annotation a
|
|
97
|
+
* compile-time error.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* export const getUser: SerializableActionFn<(id: number) => Promise<User>> =
|
|
101
|
+
* async (id) => db.user.find(id);
|
|
102
|
+
*/
|
|
103
|
+
export type SerializableActionFn<F extends (...args: any[]) => any> =
|
|
104
|
+
(...args: SerializableArgs<Parameters<F>>) => SerializableResult<ReturnType<F>>;
|
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
|
|
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
|
|
347
|
+
safeAssign(out, realKey, decode(v[k], ctx));
|
|
318
348
|
}
|
|
319
349
|
return out;
|
|
320
350
|
}
|