@dom-expressions/runtime 0.50.0-next.20 → 0.50.0-next.21

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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # dom-expressions
2
2
 
3
+ ## 0.50.0-next.21
4
+
5
+ ### Patch Changes
6
+
7
+ - 8e54049: Expose reusable Seroval serializer primitives from the runtime's serializer module. `createSerializer(options)` builds a streaming serializer preconfigured with the default web plugin set (custom plugins compose ahead of the defaults and can shadow them) and the ~ES2017 feature policy, targeting a caller-provided `globalIdentifier`. The SSR-specific configuration (pinned `_$HY.r` global) moves to `createHydrationSerializer(options)`, which `renderToString`/`renderToStream` now use internally — hydration output is unchanged. `DEFAULT_WEB_PLUGINS` and `resolveSerializerPlugins(customPlugins)` are also exported, the module's type declarations are corrected to match the implementation (they previously declared a nonexistent default export), and the render options' `plugins` typing is tightened from `any[]` to `SerializerPlugin[]`.
8
+
9
+ The module also gains an isomorphic JSON codec for RPC-style transports (e.g. server functions): `serializeJSON(value, { onParse, onDone, onError, ... })` streams a value as `SerovalNode` chunks (async values continue streaming as they resolve), and `createJSONDeserializer(options)` returns its decoding counterpart with cross-chunk reference state. Both share the web plugin resolution, and default to a transport-hardened policy (RegExp disabled, parse depth capped at 64) that can be overridden per peer. Wire framing of chunks is intentionally left to the transport. This is groundwork for sharing one serialization configuration between SSR hydration and server function transports.
10
+
11
+ - e717d06: Fix streamed fragment swaps leaving fallback content behind when the fallback contains unrelated comment markers.
12
+ - 2c4ab6b: Raise the seroval / seroval-plugins peer dependency floor from `~1.5.0` to `~1.5.4`: seroval 1.5.3 and earlier are affected by a security issue fixed in 1.5.4.
13
+
3
14
  ## 0.50.0-next.20
4
15
 
5
16
  ### Patch Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dom-expressions/runtime",
3
3
  "description": "A Fine-Grained Runtime for Performant DOM Rendering",
4
- "version": "0.50.0-next.20",
4
+ "version": "0.50.0-next.21",
5
5
  "author": "Ryan Carniato",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -19,14 +19,14 @@
19
19
  },
20
20
  "peerDependencies": {
21
21
  "csstype": "^3.0",
22
- "seroval": "~1.5.0",
23
- "seroval-plugins": "~1.5.0"
22
+ "seroval": "~1.5.4",
23
+ "seroval-plugins": "~1.5.4"
24
24
  },
25
25
  "devDependencies": {
26
26
  "csstype": "^3.1",
27
- "seroval": "~1.5.0",
28
- "seroval-plugins": "~1.5.0",
29
- "@dom-expressions/babel-plugin-jsx": "0.50.0-next.20"
27
+ "seroval": "~1.5.5",
28
+ "seroval-plugins": "~1.5.5",
29
+ "@dom-expressions/babel-plugin-jsx": "0.50.0-next.21"
30
30
  },
31
31
  "scripts": {
32
32
  "test": "jest --no-cache",
@@ -1,3 +1,97 @@
1
- import { Serializer } from "seroval";
1
+ import { Plugin, Serializer, SerovalNode } from "seroval";
2
2
 
3
- export default function createSerializer(onData: (value: string) => void): Serializer;
3
+ export type { SerovalNode };
4
+
5
+ /** A Seroval plugin usable with the web serializers. */
6
+ export type SerializerPlugin = Plugin<any, any>;
7
+
8
+ /**
9
+ * Baseline plugin set for serializing web-platform values (AbortSignal,
10
+ * Event, FormData, Headers, ReadableStream, Request, Response, URL, ...).
11
+ */
12
+ export const DEFAULT_WEB_PLUGINS: readonly SerializerPlugin[];
13
+
14
+ /**
15
+ * Composes custom plugins with `DEFAULT_WEB_PLUGINS`. Custom plugins come
16
+ * first so they can shadow a default for values both would match. Returns a
17
+ * fresh array; the defaults are never mutated.
18
+ */
19
+ export function resolveSerializerPlugins(customPlugins?: SerializerPlugin[]): SerializerPlugin[];
20
+
21
+ export interface WebSerializerOptions {
22
+ /** Name of the global object the emitted scripts write resolved values into. */
23
+ globalIdentifier: string;
24
+ scopeId?: string;
25
+ /**
26
+ * Seroval feature bitflags to exclude from output. Defaults to disabling
27
+ * post-ES2017 features (AggregateError, BigInt typed arrays).
28
+ */
29
+ disabledFeatures?: number;
30
+ /** Extra plugins, composed ahead of `DEFAULT_WEB_PLUGINS`. */
31
+ plugins?: SerializerPlugin[];
32
+ onData: (result: string) => void;
33
+ onError?: (error: unknown) => void;
34
+ onDone?: () => void;
35
+ }
36
+
37
+ /**
38
+ * Creates a streaming Seroval serializer preconfigured with the web plugin
39
+ * set and the default feature policy.
40
+ */
41
+ export function createSerializer(options: WebSerializerOptions): Serializer;
42
+
43
+ export type HydrationSerializerOptions = Omit<
44
+ WebSerializerOptions,
45
+ "globalIdentifier" | "disabledFeatures"
46
+ >;
47
+
48
+ /**
49
+ * Serializer for SSR hydration output. Pins the hydration global (`_$HY.r`)
50
+ * and feature policy — only the wiring options (callbacks, scope, extra
51
+ * plugins) are configurable.
52
+ */
53
+ export function createHydrationSerializer(options: HydrationSerializerOptions): Serializer;
54
+
55
+ /** Returns the cross-reference bootstrap script for a render scope. */
56
+ export function getLocalHeaderScript(id?: string): string;
57
+
58
+ // ---- JSON codec (server function transports) ----
59
+
60
+ export interface JSONCodecOptions {
61
+ /** Extra plugins, composed ahead of `DEFAULT_WEB_PLUGINS`. Must match on both peers. */
62
+ plugins?: SerializerPlugin[];
63
+ /**
64
+ * Seroval feature bitflags to exclude. Defaults to disabling `RegExp`
65
+ * (payloads may come from an untrusted peer). Must match on both peers.
66
+ */
67
+ disabledFeatures?: number;
68
+ /** Maximum parse/deserialize depth. Defaults to 64. Must match on both peers. */
69
+ depthLimit?: number;
70
+ }
71
+
72
+ export interface JSONSerializeOptions extends JSONCodecOptions {
73
+ /**
74
+ * Receives each serialized node; `initial` is true for the first chunk
75
+ * (the source value itself). Async values produce additional chunks as
76
+ * they resolve.
77
+ */
78
+ onParse: (node: SerovalNode, initial: boolean) => void;
79
+ onError?: (error: unknown) => void;
80
+ /** Fires once all async values have settled. */
81
+ onDone?: () => void;
82
+ }
83
+
84
+ /**
85
+ * Serializes `value` as SerovalNode chunks delivered through `onParse`.
86
+ * Wire framing of the nodes is the transport's concern. Returns a cancel
87
+ * function that aborts pending async serialization.
88
+ */
89
+ export function serializeJSON(value: unknown, options: JSONSerializeOptions): () => void;
90
+
91
+ /**
92
+ * Creates the decoding counterpart of `serializeJSON`. Cross-references
93
+ * between chunks resolve through state shared across calls, so all chunks
94
+ * from one stream must go through the same deserializer instance. The first
95
+ * chunk's return value is the decoded source value.
96
+ */
97
+ export function createJSONDeserializer(options?: JSONCodecOptions): <T>(node: SerovalNode) => T;
package/src/serializer.js CHANGED
@@ -1,4 +1,10 @@
1
- import { Feature, Serializer, getCrossReferenceHeader } from "seroval";
1
+ import {
2
+ Feature,
3
+ Serializer,
4
+ fromCrossJSON,
5
+ getCrossReferenceHeader,
6
+ toCrossJSONStream
7
+ } from "seroval";
2
8
  import {
3
9
  AbortSignalPlugin,
4
10
  CustomEventPlugin,
@@ -13,36 +19,69 @@ import {
13
19
  URLSearchParamsPlugin
14
20
  } from "seroval-plugins/web";
15
21
 
16
- const ES2017FLAG =
17
- Feature.AggregateError | // ES2021
18
- Feature.BigIntTypedArray; // ES2020;
19
-
20
- const GLOBAL_IDENTIFIER = "_$HY.r"; // TODO this is a pending name
21
-
22
- export function createSerializer({ onData, onDone, scopeId, onError, plugins: customPlugins }) {
23
- const defaultPlugins = [
24
- AbortSignalPlugin,
25
- // BlobPlugin,
26
- CustomEventPlugin,
27
- DOMExceptionPlugin,
28
- EventPlugin,
29
- // FilePlugin,
30
- FormDataPlugin,
31
- HeadersPlugin,
32
- ReadableStreamPlugin,
33
- RequestPlugin,
34
- ResponsePlugin,
35
- URLSearchParamsPlugin,
36
- URLPlugin
37
- ];
38
-
39
- const allPlugins = customPlugins ? [...customPlugins, ...defaultPlugins] : defaultPlugins;
22
+ // Features excluded from emitted scripts so output stays runnable on ~ES2017
23
+ // targets (AggregateError is ES2021, BigInt typed arrays are ES2020).
24
+ const DEFAULT_DISABLED_FEATURES = Feature.AggregateError | Feature.BigIntTypedArray;
25
+
26
+ // Part of the hydration wire protocol since the streaming serializer landed
27
+ // (#275): the bootstrap from `generateHydrationScript` creates it and the
28
+ // client runtime reads resolved values out of it. Terse on purpose — it ships
29
+ // in every SSR payload.
30
+ const HYDRATION_GLOBAL = "_$HY.r";
31
+
32
+ /**
33
+ * Baseline plugin set for serializing web-platform values. Shared by the
34
+ * hydration serializer and any consumer building its own serializer (e.g.
35
+ * server function transports).
36
+ */
37
+ export const DEFAULT_WEB_PLUGINS = Object.freeze([
38
+ AbortSignalPlugin,
39
+ // BlobPlugin,
40
+ CustomEventPlugin,
41
+ DOMExceptionPlugin,
42
+ EventPlugin,
43
+ // FilePlugin,
44
+ FormDataPlugin,
45
+ HeadersPlugin,
46
+ ReadableStreamPlugin,
47
+ RequestPlugin,
48
+ ResponsePlugin,
49
+ URLSearchParamsPlugin,
50
+ URLPlugin
51
+ ]);
52
+
53
+ /**
54
+ * Composes user plugins with the default web set. Custom plugins come first
55
+ * so they can shadow a default for values both would match.
56
+ */
57
+ export function resolveSerializerPlugins(customPlugins) {
58
+ return customPlugins ? [...customPlugins, ...DEFAULT_WEB_PLUGINS] : [...DEFAULT_WEB_PLUGINS];
59
+ }
40
60
 
61
+ /**
62
+ * Creates a streaming Seroval serializer preconfigured with the web plugin
63
+ * set and the default feature policy. `globalIdentifier` is required and
64
+ * names the object the emitted scripts write resolved values into.
65
+ */
66
+ export function createSerializer(options) {
41
67
  return new Serializer({
68
+ ...options,
69
+ plugins: resolveSerializerPlugins(options.plugins),
70
+ disabledFeatures:
71
+ options.disabledFeatures === undefined ? DEFAULT_DISABLED_FEATURES : options.disabledFeatures
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Serializer for SSR hydration output. Pins the hydration global (`_$HY.r`)
77
+ * and feature policy — only the wiring options (callbacks, scope, extra
78
+ * plugins) are configurable.
79
+ */
80
+ export function createHydrationSerializer({ onData, onDone, scopeId, onError, plugins }) {
81
+ return createSerializer({
42
82
  scopeId,
43
- plugins: allPlugins,
44
- globalIdentifier: GLOBAL_IDENTIFIER,
45
- disabledFeatures: ES2017FLAG,
83
+ plugins,
84
+ globalIdentifier: HYDRATION_GLOBAL,
46
85
  onData,
47
86
  onDone,
48
87
  onError
@@ -52,3 +91,58 @@ export function createSerializer({ onData, onDone, scopeId, onError, plugins: cu
52
91
  export function getLocalHeaderScript(id) {
53
92
  return getCrossReferenceHeader(id) + ";";
54
93
  }
94
+
95
+ // ---- JSON codec (server function transports) ----
96
+ //
97
+ // Unlike hydration output (executable JS targeting a global), the JSON codec
98
+ // streams SerovalNode values that a peer decodes without eval. Framing the
99
+ // nodes on the wire (chunk delimiting, HTTP plumbing) is the transport's
100
+ // concern; this layer only guarantees both sides agree on plugins and
101
+ // feature policy.
102
+
103
+ // Codec payloads may come from an untrusted peer, so the defaults protect
104
+ // the decoding side: RegExp is disabled (ReDoS via deserialized patterns)
105
+ // and parse depth is capped well below Seroval's own limit.
106
+ const JSON_CODEC_DISABLED_FEATURES = Feature.RegExp;
107
+ const JSON_CODEC_DEPTH_LIMIT = 64;
108
+
109
+ // Single source of truth for codec defaults — encode and decode must agree
110
+ // on plugins and feature policy or payloads won't roundtrip.
111
+ function resolveCodecOptions({ plugins, disabledFeatures, depthLimit } = {}) {
112
+ return {
113
+ plugins: resolveSerializerPlugins(plugins),
114
+ disabledFeatures:
115
+ disabledFeatures === undefined ? JSON_CODEC_DISABLED_FEATURES : disabledFeatures,
116
+ depthLimit: depthLimit === undefined ? JSON_CODEC_DEPTH_LIMIT : depthLimit
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Serializes `value` as SerovalNode chunks delivered through
122
+ * `onParse(node, initial)`. Async values (promises, streams) produce
123
+ * additional chunks as they resolve; `onDone` fires when everything has
124
+ * settled. Returns a cancel function that aborts any pending async
125
+ * serialization.
126
+ */
127
+ export function serializeJSON(value, { onParse, onDone, onError, ...codecOptions }) {
128
+ return toCrossJSONStream(value, {
129
+ onParse,
130
+ onDone,
131
+ onError,
132
+ ...resolveCodecOptions(codecOptions)
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Creates the decoding counterpart of `serializeJSON`. The returned function
138
+ * deserializes one SerovalNode chunk at a time; cross-references between
139
+ * chunks resolve through a map shared across calls, so all chunks from one
140
+ * stream must go through the same deserializer instance.
141
+ */
142
+ export function createJSONDeserializer(options) {
143
+ const refs = new Map();
144
+ const resolved = resolveCodecOptions(options);
145
+ return function deserializeJSONChunk(node) {
146
+ return fromCrossJSON(node, { refs, ...resolved });
147
+ };
148
+ }
package/src/server.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { JSX } from "./jsx.js";
2
+ import { SerializerPlugin } from "./serializer.js";
2
3
  export const DOMWithState: Record<string, Record<string, 1 | 2>>;
3
4
  export const ChildProperties: Set<string>;
4
5
  export const DelegatedEvents: Set<string>;
@@ -62,7 +63,7 @@ export function renderToString<T>(
62
63
  nonce?: string;
63
64
  renderId?: string;
64
65
  noScripts?: boolean;
65
- plugins?: any[];
66
+ plugins?: SerializerPlugin[];
66
67
  manifest?: AssetManifest | AssetResolver | AssetResolverFn;
67
68
  onError?: (err: any) => void;
68
69
  }
@@ -75,7 +76,7 @@ export function renderToStringAsync<T>(
75
76
  nonce?: string;
76
77
  renderId?: string;
77
78
  noScripts?: boolean;
78
- plugins?: any[];
79
+ plugins?: SerializerPlugin[];
79
80
  manifest?: AssetManifest | AssetResolver | AssetResolverFn;
80
81
  onError?: (err: any) => void;
81
82
  }
@@ -86,7 +87,7 @@ export function renderToStream<T>(
86
87
  nonce?: string;
87
88
  renderId?: string;
88
89
  noScripts?: boolean;
89
- plugins?: any[];
90
+ plugins?: SerializerPlugin[];
90
91
  manifest?: AssetManifest | AssetResolver | AssetResolverFn;
91
92
  onCompleteShell?: (info: { write: (v: string) => void }) => void;
92
93
  onCompleteAll?: (info: { write: (v: string) => void }) => void;
package/src/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ChildProperties } from "./constants";
2
2
  import { sharedConfig, root, ssrHandleError, getOwner, runWithOwner } from "rxcore";
3
- import { createSerializer, getLocalHeaderScript } from "./serializer";
3
+ import { createHydrationSerializer, getLocalHeaderScript } from "./serializer";
4
4
 
5
5
  // `mergeProps` comes from the framework like the client/universal entries —
6
6
  // prop-merge semantics (function sources, precedence) belong to the reactive
@@ -195,12 +195,12 @@ const VOID_ELEMENTS =
195
195
  // - $dfc(id): style completion callback; reveals when the fragment/group is unblocked.
196
196
  // - $dfg(id): group-style gate check; reveals a waiting group once all style counts hit zero.
197
197
  // - $dfj(ids): reveal a group in registration order, waiting if any member still has pending styles.
198
- const REPLACE_SCRIPT = `function $df(e,n,o,t){if(!(n=document.getElementById(e)))return 0;if(!(o=document.getElementById("pl-"+e)))return(_$HY.dq=_$HY.dq||{})[e]=1,0;for(;o&&8!==o.nodeType&&o.nodeValue!=="pl-"+e;)t=o.nextSibling,o.remove(),o=t;_$HY.done?o.remove():o.replaceWith(n.content),n.remove(),_$HY.fe(e),$dfd();return 1}function $dfl(e,o,n){if(!(o=document.getElementById("pl-"+e)))return(_$HY.dlq=_$HY.dlq||{})[e]=1,0;if(o._$fl)return 1;for(n=o.nextSibling;n;){if(8===n.nodeType&&n.nodeValue==="pl-"+e){o.parentNode&&o.parentNode.insertBefore(o.content.cloneNode(!0),n),o._$fl=1,$dfd();return 1}n=n.nextSibling}return 0}function $dflj(e,i){for(i=0;i<e.length;i++)$dfl(e[i])}function $dfd(e,i){if(e=_$HY.dq){_$HY.dq=0;for(i in e)$df(i)}if(e=_$HY.dlq){_$HY.dlq=0;for(i in e)$dfl(i)}}function $dfs(e,c,d){(_$HY.sc=_$HY.sc||{})[e]=c,d&&((_$HY.sd=_$HY.sd||{})[e]=1)}function $dfg(e,g,i,k){if(!(g=_$HY.sg&&_$HY.sg[e]))return;for(i=0;i<g.length;i++)if(_$HY.sc&&_$HY.sc[g[i]]>0)return;for(i=0;i<g.length;i++)k=g[i],delete _$HY.sg[k],$df(k)}function $dfc(e){if(--_$HY.sc[e]<=0){delete _$HY.sc[e],_$HY.sg&&_$HY.sg[e]?$dfg(e):!(_$HY.sd&&_$HY.sd[e])&&$df(e);_$HY.sd&&delete _$HY.sd[e]}}function $dfj(e,i,n){for(i=0;i<e.length;i++)if(_$HY.sc&&_$HY.sc[e[i]]>0){for(n=0;n<e.length;n++)(_$HY.sg=_$HY.sg||{})[e[n]]=e;return}for(i=0;i<e.length;i++)$df(e[i])}`;
198
+ const REPLACE_SCRIPT = `function $df(e,n,o,t){if(!(n=document.getElementById(e)))return 0;if(!(o=document.getElementById("pl-"+e)))return(_$HY.dq=_$HY.dq||{})[e]=1,0;for(;o&&(8!==o.nodeType||o.nodeValue!=="pl-"+e);)t=o.nextSibling,o.remove(),o=t;_$HY.done?o.remove():o.replaceWith(n.content),n.remove(),_$HY.fe(e),$dfd();return 1}function $dfl(e,o,n){if(!(o=document.getElementById("pl-"+e)))return(_$HY.dlq=_$HY.dlq||{})[e]=1,0;if(o._$fl)return 1;for(n=o.nextSibling;n;){if(8===n.nodeType&&n.nodeValue==="pl-"+e){o.parentNode&&o.parentNode.insertBefore(o.content.cloneNode(!0),n),o._$fl=1,$dfd();return 1}n=n.nextSibling}return 0}function $dflj(e,i){for(i=0;i<e.length;i++)$dfl(e[i])}function $dfd(e,i){if(e=_$HY.dq){_$HY.dq=0;for(i in e)$df(i)}if(e=_$HY.dlq){_$HY.dlq=0;for(i in e)$dfl(i)}}function $dfs(e,c,d){(_$HY.sc=_$HY.sc||{})[e]=c,d&&((_$HY.sd=_$HY.sd||{})[e]=1)}function $dfg(e,g,i,k){if(!(g=_$HY.sg&&_$HY.sg[e]))return;for(i=0;i<g.length;i++)if(_$HY.sc&&_$HY.sc[g[i]]>0)return;for(i=0;i<g.length;i++)k=g[i],delete _$HY.sg[k],$df(k)}function $dfc(e){if(--_$HY.sc[e]<=0){delete _$HY.sc[e],_$HY.sg&&_$HY.sg[e]?$dfg(e):!(_$HY.sd&&_$HY.sd[e])&&$df(e);_$HY.sd&&delete _$HY.sd[e]}}function $dfj(e,i,n){for(i=0;i<e.length;i++)if(_$HY.sc&&_$HY.sc[e[i]]>0){for(n=0;n<e.length;n++)(_$HY.sg=_$HY.sg||{})[e[n]]=e;return}for(i=0;i<e.length;i++)$df(e[i])}`;
199
199
 
200
200
  export function renderToString(code, options = {}) {
201
201
  const { renderId = "", nonce, noScripts, manifest } = options;
202
202
  let scripts = "";
203
- const serializer = createSerializer({
203
+ const serializer = createHydrationSerializer({
204
204
  scopeId: renderId,
205
205
  plugins: options.plugins,
206
206
  onData(script) {
@@ -299,7 +299,7 @@ export function renderToStream(code, options = {}) {
299
299
  completed = true;
300
300
  if (firstFlushed) dispose();
301
301
  };
302
- const serializer = createSerializer({
302
+ const serializer = createHydrationSerializer({
303
303
  scopeId: options.renderId,
304
304
  plugins: options.plugins,
305
305
  onData: pushTask,