@webjsdev/core 0.7.45 → 0.7.46
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/dist/webjs-core-browser.js +4 -4
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +22 -21
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -0
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/asset-url.d.ts +3 -0
- package/src/asset-url.js +99 -0
- package/src/component.js +68 -2
- package/src/form-action.js +163 -0
- package/src/render-client.js +30 -2
- package/src/render-server.js +449 -11
- package/src/router-client.js +14 -2
package/index-browser.js
CHANGED
|
@@ -30,6 +30,7 @@ export { render } from './src/render-client.js';
|
|
|
30
30
|
export { escapeText, escapeAttr } from './src/escape.js';
|
|
31
31
|
export { notFound, redirect, forbidden, unauthorized, isNotFound, isRedirect, isForbidden, isUnauthorized } from './src/nav.js';
|
|
32
32
|
export { cspNonce } from './src/csp-nonce.js';
|
|
33
|
+
export { asset } from './src/asset-url.js';
|
|
33
34
|
export { repeat, isRepeat } from './src/repeat.js';
|
|
34
35
|
export { Suspense, isSuspense } from './src/suspense.js';
|
|
35
36
|
export { connectWS } from './src/websocket-client.js';
|
package/index.d.ts
CHANGED
|
@@ -104,6 +104,7 @@ export { Task, TaskStatus } from './src/task.js';
|
|
|
104
104
|
// src/*.d.ts (#388 fixed the index.d.ts drift from index.js).
|
|
105
105
|
export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
|
|
106
106
|
export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
|
|
107
|
+
export { asset, setAssetUrlProvider } from './src/asset-url.js';
|
|
107
108
|
export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
|
|
108
109
|
export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
|
|
109
110
|
// Client action-abort plumbing (#492): a superseded async render aborts its
|
package/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { render } from './src/render-client.js';
|
|
|
15
15
|
export { escapeText, escapeAttr } from './src/escape.js';
|
|
16
16
|
export { notFound, redirect, forbidden, unauthorized, isNotFound, isRedirect, isForbidden, isUnauthorized } from './src/nav.js';
|
|
17
17
|
export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
|
|
18
|
+
export { asset, setAssetUrlProvider } from './src/asset-url.js';
|
|
18
19
|
export { repeat, isRepeat } from './src/repeat.js';
|
|
19
20
|
export { Suspense, isSuspense } from './src/suspense.js';
|
|
20
21
|
export { connectWS } from './src/websocket-client.js';
|
package/package.json
CHANGED
package/src/asset-url.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a `public/` asset url to its content-fingerprinted form, so a
|
|
3
|
+
* deploy that changes the file changes the url and no cache can serve the
|
|
4
|
+
* previous bytes.
|
|
5
|
+
*
|
|
6
|
+
* import { html, asset } from '@webjsdev/core';
|
|
7
|
+
* html`<link rel="stylesheet" href=${asset('/public/app.css')}>`
|
|
8
|
+
*
|
|
9
|
+
* On the server, `@webjsdev/server` installs a provider that appends
|
|
10
|
+
* `?v=<content-hash>`; the framework then serves a `?v=`-carrying request
|
|
11
|
+
* `immutable` for a year instead of the short fallback. On the browser there
|
|
12
|
+
* is no provider and the path is returned UNCHANGED.
|
|
13
|
+
*
|
|
14
|
+
* USE IT IN A PAGE, LAYOUT, OR METADATA ROUTE, not inside a component that
|
|
15
|
+
* ships to the browser. Those modules render only on the server, so the
|
|
16
|
+
* fingerprinted url is the one the browser sees and keeps. A component is
|
|
17
|
+
* different: hydration is a full client RE-RENDER (`render-client.js` drops
|
|
18
|
+
* the hydrate marker and `createInstance` replaces the SSR'd children), so
|
|
19
|
+
* the client value overwrites the server one. With no provider in the
|
|
20
|
+
* browser, `asset()` returns the bare path there, so an `<img src=${asset(
|
|
21
|
+
* '/public/logo.svg')}>` inside a shipping component paints the hashed url
|
|
22
|
+
* at SSR and then swaps it for the un-hashed one on upgrade, fetching the
|
|
23
|
+
* same bytes twice and leaving the short-lived url in the DOM. It still
|
|
24
|
+
* WORKS (the path is always valid), which is why this is a convention rather
|
|
25
|
+
* than a `webjs check` rule, but it silently forfeits the caching the call
|
|
26
|
+
* was for. Import the asset url into a layout, or accept the plain path.
|
|
27
|
+
*
|
|
28
|
+
* The sibling `cspNonce()` carries the same server/browser asymmetry and the
|
|
29
|
+
* same scoping, for the same hydration reason.
|
|
30
|
+
*
|
|
31
|
+
* Call it INSIDE the render function, not at module scope. A depth-0 call is a
|
|
32
|
+
* module-scope side effect, which the elision analyser reads as client work, so
|
|
33
|
+
* hoisting `const CSS = asset('/public/app.css')` pins the whole page or layout
|
|
34
|
+
* into the browser bundle for no benefit. Resolver ordering is not the issue
|
|
35
|
+
* (the server installs its resolver at boot, before any app module loads).
|
|
36
|
+
*
|
|
37
|
+
* Under `webjs.basePath`, write the prefix yourself: `asset('/app/public/x.css')`.
|
|
38
|
+
* The framework only base-path-prefixes urls IT emits, so an author-written
|
|
39
|
+
* url is already your responsibility (an un-prefixed one 404s under a
|
|
40
|
+
* sub-path deploy with or without this helper). `asset()` strips the prefix
|
|
41
|
+
* to find the file and keeps it on the url it returns.
|
|
42
|
+
*
|
|
43
|
+
* Mark files that change only with a DEPLOY. The hash is memoized for the
|
|
44
|
+
* process lifetime, and prod never rebuilds, so a `public/` file rewritten in
|
|
45
|
+
* place while the server runs keeps its old url while being served `immutable`
|
|
46
|
+
* for a year. A build artifact (a compiled stylesheet, a bundled script, a
|
|
47
|
+
* committed image) is exactly right; a runtime-written upload is not, and
|
|
48
|
+
* should keep its plain path (or carry its own version in the filename).
|
|
49
|
+
*
|
|
50
|
+
* Why this is opt-in rather than automatic. An earlier attempt (#1196)
|
|
51
|
+
* rewrote asset urls by matching the assembled HTML, and two deep-review
|
|
52
|
+
* rounds found six major defects, five of them the same bug: at that layer
|
|
53
|
+
* framework output and author data are indistinguishable, so the matcher kept
|
|
54
|
+
* touching things it did not own (a custom element's reactive prop, a
|
|
55
|
+
* rendered code sample, a `rel=preload` hint whose real request comes from
|
|
56
|
+
* CSS, a data-driven `src` pointing at `/.env`). Marking the url at the point
|
|
57
|
+
* the author writes it makes the meaning unambiguous, so the blast radius is
|
|
58
|
+
* exactly the set of urls someone deliberately marked and nothing else in the
|
|
59
|
+
* document is ever read or rewritten.
|
|
60
|
+
*
|
|
61
|
+
* Scope note: only a `public/` path is fingerprinted, matching what the
|
|
62
|
+
* static-asset route will actually serve. Anything else is returned
|
|
63
|
+
* unchanged rather than guessed at.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/** @type {((path: string) => string) | null} */
|
|
67
|
+
let _provider = null;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Internal: server-only wiring. `@webjsdev/server` calls this once at load
|
|
71
|
+
* time to install the real resolver. Browser builds never call it, so
|
|
72
|
+
* `asset()` stays an identity function there.
|
|
73
|
+
*
|
|
74
|
+
* @param {(path: string) => string} fn
|
|
75
|
+
* @returns {void}
|
|
76
|
+
*/
|
|
77
|
+
export function setAssetUrlProvider(fn) {
|
|
78
|
+
_provider = fn;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The runtime function. Returns the fingerprinted url on the server, or the
|
|
83
|
+
* path unchanged when there is no provider (browser), when fingerprinting is
|
|
84
|
+
* off (dev, so dev output stays byte-identical), or when the file cannot be
|
|
85
|
+
* resolved. Every failure mode degrades to the plain path, never to a broken
|
|
86
|
+
* one.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} path a root-absolute same-origin path, e.g. `/public/app.css`
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function asset(path) {
|
|
92
|
+
if (typeof path !== 'string' || !path) return path;
|
|
93
|
+
if (!_provider) return path;
|
|
94
|
+
try {
|
|
95
|
+
return _provider(path) || path;
|
|
96
|
+
} catch {
|
|
97
|
+
return path;
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/component.js
CHANGED
|
@@ -99,6 +99,25 @@ function defaultHasChanged(a, b) {
|
|
|
99
99
|
return a !== b;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* `String(v)` that cannot itself throw.
|
|
104
|
+
*
|
|
105
|
+
* A rejection reason is whatever the author rejected with, and not every value
|
|
106
|
+
* converts to a primitive: `Object.create(null)` has no `toString`, and a
|
|
107
|
+
* revoked Proxy throws on any operation. Coercing one in an error-reporting
|
|
108
|
+
* path turned a reportable failure into a second, unreported one.
|
|
109
|
+
*
|
|
110
|
+
* @param {unknown} v
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
function safeString(v) {
|
|
114
|
+
try {
|
|
115
|
+
return String(v);
|
|
116
|
+
} catch {
|
|
117
|
+
return Object.prototype.toString.call(v);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
102
121
|
/**
|
|
103
122
|
* A minimal base for HTML Custom Elements that mirrors Lit's ergonomics
|
|
104
123
|
* while staying JSDoc-only and no-build.
|
|
@@ -1176,7 +1195,7 @@ class WebComponentBase extends Base {
|
|
|
1176
1195
|
// (shouldUpdate=false) running during the fetch does NOT resolve
|
|
1177
1196
|
// updateComplete early: the pending commit owns the resolution.
|
|
1178
1197
|
this.__pendingAsyncCommits = (this.__pendingAsyncCommits || 0) + 1;
|
|
1179
|
-
|
|
1198
|
+
const settle = () => {
|
|
1180
1199
|
// Always decrement first, even when superseded, so the in-flight
|
|
1181
1200
|
// count never leaks (a superseded cycle returns below without
|
|
1182
1201
|
// committing, but it is no longer pending).
|
|
@@ -1189,6 +1208,37 @@ class WebComponentBase extends Base {
|
|
|
1189
1208
|
}
|
|
1190
1209
|
// --- 7-8 + updateComplete ---
|
|
1191
1210
|
this._postCommit(changedProperties);
|
|
1211
|
+
};
|
|
1212
|
+
// A rejection has to settle the cycle too, or the counter stays >= 1
|
|
1213
|
+
// forever: that both wedges `updateComplete` for this instance and
|
|
1214
|
+
// disables the `!this.__pendingAsyncCommits` escape below for every
|
|
1215
|
+
// later non-committing cycle. `_commitAsync` is written not to reject,
|
|
1216
|
+
// but it is not the only thenable that reaches here: `update()` is a
|
|
1217
|
+
// documented override point and may return one, so the handler belongs
|
|
1218
|
+
// at this site rather than only at the one implementation of it.
|
|
1219
|
+
pendingCommit.then(settle, (error) => {
|
|
1220
|
+
// The token check gates the DOM write, NOT the release. A superseded
|
|
1221
|
+
// cycle rejecting late (a discarded fetch, or the #492 abort of its own
|
|
1222
|
+
// in-flight action surfacing as an AbortError) must not commit its
|
|
1223
|
+
// error state over the newer render that already replaced it, which is
|
|
1224
|
+
// the same rule both handlers inside _commitAsync follow. `settle()`
|
|
1225
|
+
// still runs either way: it does its own token check before touching
|
|
1226
|
+
// anything, and the decrement it owns has to happen regardless or the
|
|
1227
|
+
// counter wedges exactly as it did before this handler existed.
|
|
1228
|
+
// `finally`, not a trailing statement: the release must survive
|
|
1229
|
+
// anything the reporting path throws. `String(error)` is evaluated in
|
|
1230
|
+
// the argument expression, OUTSIDE _handleRenderError's own try/catch,
|
|
1231
|
+
// so a rejection reason that cannot be coerced (a null-prototype
|
|
1232
|
+
// object, a revoked Proxy) threw a TypeError straight out of this
|
|
1233
|
+
// handler and skipped the decrement, wedging the counter in exactly
|
|
1234
|
+
// the way this handler exists to prevent.
|
|
1235
|
+
try {
|
|
1236
|
+
if (token === this.__renderToken) {
|
|
1237
|
+
this._handleRenderError(error instanceof Error ? error : new Error(safeString(error)));
|
|
1238
|
+
}
|
|
1239
|
+
} finally {
|
|
1240
|
+
settle();
|
|
1241
|
+
}
|
|
1192
1242
|
});
|
|
1193
1243
|
return;
|
|
1194
1244
|
}
|
|
@@ -1399,7 +1449,23 @@ class WebComponentBase extends Base {
|
|
|
1399
1449
|
return Promise.resolve(pending).then(
|
|
1400
1450
|
(tpl) => {
|
|
1401
1451
|
if (token !== this.__renderToken) return; // superseded by a newer render
|
|
1402
|
-
|
|
1452
|
+
// The COMMIT throws too, not just the fetch, and a sibling rejection
|
|
1453
|
+
// handler cannot see it: `.then(onFulfil, onRejected)` never routes
|
|
1454
|
+
// onFulfil's own throw to onRejected. Left uncaught, the returned
|
|
1455
|
+
// promise rejected and _performRender's `.then` had no rejection
|
|
1456
|
+
// handler, so the error escaped as an unhandled rejection,
|
|
1457
|
+
// renderError() never ran, __pendingAsyncCommits never decremented
|
|
1458
|
+
// (wedging it >= 1 forever, which also disables the non-committing
|
|
1459
|
+
// cycle's _resolveUpdate escape), and updateComplete never settled.
|
|
1460
|
+
// The sync path routes an identical throw to the same boundary, so
|
|
1461
|
+
// this is what makes the two paths agree.
|
|
1462
|
+
try {
|
|
1463
|
+
clientRender(tpl, this._renderRoot);
|
|
1464
|
+
} catch (commitError) {
|
|
1465
|
+
this._handleRenderError(
|
|
1466
|
+
commitError instanceof Error ? commitError : new Error(String(commitError)),
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1403
1469
|
},
|
|
1404
1470
|
(error) => {
|
|
1405
1471
|
if (token !== this.__renderToken) return;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { isBindingPrefix } from './binding-prefixes.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Form-action attribute guard (#1154).
|
|
5
|
+
*
|
|
6
|
+
* Both SSR state machines and the client renderer import from here so the
|
|
7
|
+
* rule cannot drift between them. Each renderer commits attribute, boolean and
|
|
8
|
+
* property holes on separate branches, so the rule has more call sites than it
|
|
9
|
+
* looks like from any one file, and they are easy to change one at a time.
|
|
10
|
+
* That is why the predicate lives here rather than at each site.
|
|
11
|
+
*
|
|
12
|
+
* Two entry points, and the difference between them is the point:
|
|
13
|
+
* `assertNotFunctionActionAttr` is name-based, because an ATTRIBUTE is
|
|
14
|
+
* stringified into the markup on whatever tag it appears.
|
|
15
|
+
* `assertNotFunctionReflectedActionProp` is name-and-tag-based, because a
|
|
16
|
+
* PROPERTY only reaches the markup where the DOM reflects it. Using the first
|
|
17
|
+
* on the property path refused `<div .action=${fn}>`, which never leaked.
|
|
18
|
+
*
|
|
19
|
+
* The second SSR machine (`streamTemplate`) is reached only through
|
|
20
|
+
* `renderToStream(v, { ssr: false })`, which no page render uses: the server
|
|
21
|
+
* renders every page, Suspense included, via `renderToString`. Guarding it is
|
|
22
|
+
* about the public API surface rather than a live page leak, so do not infer
|
|
23
|
+
* from a bug there that pages were affected.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Refuse to stringify a function interpolated into a form-action attribute.
|
|
28
|
+
*
|
|
29
|
+
* At SSR a `'use server'` import is the REAL server function (the RPC stub
|
|
30
|
+
* only exists in the browser), and a plain attribute hole commits via
|
|
31
|
+
* `String(val)`, so without this guard the action's SOURCE, secrets included,
|
|
32
|
+
* is serialized into the served HTML. The client renderer guards the same
|
|
33
|
+
* commit so a client re-render cannot write the source into the live DOM
|
|
34
|
+
* through a template binding. It does NOT cover property REFLECTION: a prop
|
|
35
|
+
* declared `reflect: true` writes `String(value)` to its attribute from the
|
|
36
|
+
* setter, which is not a commit site and is not reachable from here.
|
|
37
|
+
*
|
|
38
|
+
* Name-based on purpose (`action`, plus `formaction`, the submit-button
|
|
39
|
+
* override, which leaks identically): a function stringified under these
|
|
40
|
+
* names is never useful on any tag, and other attributes keep today's
|
|
41
|
+
* stringify behaviour so the claim stays narrow.
|
|
42
|
+
*
|
|
43
|
+
* @param {unknown} val the hole's resolved value
|
|
44
|
+
* @param {string} attrName the attribute being committed (any case)
|
|
45
|
+
* @param {string} [tag] lowercased owner tag, for the error message
|
|
46
|
+
*/
|
|
47
|
+
export function assertNotFunctionActionAttr(val, attrName, tag) {
|
|
48
|
+
if (!carriesFunction(val)) return;
|
|
49
|
+
if (!isFormActionAttr(attrName)) return;
|
|
50
|
+
throw new Error(formActionError(attrName, tag));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Refuse a function in a `.prop` binding, but ONLY where that property is a
|
|
55
|
+
* reflected IDL attribute and therefore really does write the source out.
|
|
56
|
+
*
|
|
57
|
+
* The attribute form leaks on any tag, because an attribute is stringified
|
|
58
|
+
* into the HTML wherever it appears. A PROPERTY is different: it leaks only
|
|
59
|
+
* when the DOM reflects it back to an attribute, and that is per element.
|
|
60
|
+
* `action` reflects on `<form>`; `formAction` reflects on `<button>` and
|
|
61
|
+
* `<input>`. Anywhere else (`<div .action=${fn}>`, `<li .action=${fn}>`, and
|
|
62
|
+
* `.action` on a `<button>`) the property is a plain expando: nothing is
|
|
63
|
+
* stringified and nothing reaches the markup.
|
|
64
|
+
*
|
|
65
|
+
* Gating on "is not a custom element" instead refused all of those, which is a
|
|
66
|
+
* false positive on a binding WebJs supports and that never leaked. A guard
|
|
67
|
+
* that turns a working delegated-command pattern (`<button .action=${() =>
|
|
68
|
+
* save(row)}>`) into a render error is not a narrower claim, it is a wrong one.
|
|
69
|
+
*
|
|
70
|
+
* A custom element is excluded for the same reason rather than a different
|
|
71
|
+
* one: it has no IDL reflection, so its `.action` is an author-defined
|
|
72
|
+
* property and a function is a legitimate value. Note the separate path that
|
|
73
|
+
* DOES write the source there, a prop declared `reflect: true`, which runs in
|
|
74
|
+
* the setter and never reaches any commit site.
|
|
75
|
+
*
|
|
76
|
+
* @param {unknown} val the hole's resolved value
|
|
77
|
+
* @param {string} propName the property being assigned (any case)
|
|
78
|
+
* @param {string} [tag] lowercased owner tag
|
|
79
|
+
*/
|
|
80
|
+
export function assertNotFunctionReflectedActionProp(val, propName, tag) {
|
|
81
|
+
if (!reflectsAsFormAction(propName, tag)) return;
|
|
82
|
+
assertNotFunctionActionAttr(val, propName, tag);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Is `propName` an IDL attribute that `tag` reflects to a content attribute?
|
|
87
|
+
* @param {string} propName
|
|
88
|
+
* @param {string} [tag]
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
function reflectsAsFormAction(propName, tag) {
|
|
92
|
+
let name = String(propName).toLowerCase();
|
|
93
|
+
if (isBindingPrefix(name[0])) name = name.slice(1);
|
|
94
|
+
const owner = String(tag || '').toLowerCase();
|
|
95
|
+
if (name === 'action') return owner === 'form';
|
|
96
|
+
if (name === 'formaction') return owner === 'button' || owner === 'input';
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Does stringifying this value expose a function's source?
|
|
102
|
+
*
|
|
103
|
+
* The commit sites do `String(val)`, and `Array.prototype.toString` stringifies
|
|
104
|
+
* each element through `String()` too, so `action=${[serverAction]}` leaks
|
|
105
|
+
* exactly as `action=${serverAction}` does. Recursive because nested arrays
|
|
106
|
+
* join the same way.
|
|
107
|
+
*
|
|
108
|
+
* Tracks visited arrays because `Array.prototype.join` has a cycle guard and
|
|
109
|
+
* this has to match it. A self-referential array stringifies to `''` rather
|
|
110
|
+
* than recursing forever, so a naive walk would turn a render that used to
|
|
111
|
+
* succeed into a stack overflow. Refusing to leak must not become a new way
|
|
112
|
+
* to crash.
|
|
113
|
+
*
|
|
114
|
+
* Deliberately NOT a check on the stringified result: sniffing the output for
|
|
115
|
+
* something function-shaped would misfire on a legitimate URL, and the value's
|
|
116
|
+
* shape is the thing actually being claimed. An object with a hand-written
|
|
117
|
+
* `toString` that returns a function's source is out of scope; that is
|
|
118
|
+
* deliberate exfiltration, not the accident this guards.
|
|
119
|
+
*
|
|
120
|
+
* @param {unknown} val
|
|
121
|
+
* @param {Set<unknown>} [seen]
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
function carriesFunction(val, seen) {
|
|
125
|
+
if (typeof val === 'function') return true;
|
|
126
|
+
if (!Array.isArray(val)) return false;
|
|
127
|
+
const visited = seen || new Set();
|
|
128
|
+
if (visited.has(val)) return false;
|
|
129
|
+
visited.add(val);
|
|
130
|
+
return val.some((v) => carriesFunction(v, visited));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Is this the name of a form-action attribute?
|
|
135
|
+
*
|
|
136
|
+
* Strips a leading binding sigil before comparing. The SSR state machines
|
|
137
|
+
* accumulate the AUTHORED name, and only the unquoted `after-eq` branch splits
|
|
138
|
+
* the prefix off, so a quoted hole arrives here as `.action` / `?action` /
|
|
139
|
+
* `@action` with the sigil still attached. Comparing the raw name let every one
|
|
140
|
+
* of those through, which is the same leak wearing a different hat: the
|
|
141
|
+
* renderer treats a quoted binding hole as a plain attribute and stringifies it.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} attrName
|
|
144
|
+
* @returns {boolean}
|
|
145
|
+
*/
|
|
146
|
+
function isFormActionAttr(attrName) {
|
|
147
|
+
let name = String(attrName).toLowerCase();
|
|
148
|
+
if (isBindingPrefix(name[0])) name = name.slice(1);
|
|
149
|
+
return name === 'action' || name === 'formaction';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The refusal message. Deliberately never echoes the function's source, which
|
|
154
|
+
* is the very thing being withheld.
|
|
155
|
+
* @param {string} attrName
|
|
156
|
+
* @param {string} [tag]
|
|
157
|
+
*/
|
|
158
|
+
function formActionError(attrName, tag) {
|
|
159
|
+
return `[webjs] a function was interpolated into ${String(attrName).toLowerCase()}= `
|
|
160
|
+
+ `on <${tag || 'form'}>. Stringifying a function into HTML would leak its `
|
|
161
|
+
+ `source (a 'use server' action's body included) to every visitor, so this `
|
|
162
|
+
+ `is refused. Pass a URL string instead.`;
|
|
163
|
+
}
|
package/src/render-client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isTemplate, MARKER } from './html.js';
|
|
2
2
|
import { BINDING_PREFIXES, isBindingPrefix } from './binding-prefixes.js';
|
|
3
3
|
import { escapeAttr } from './escape.js';
|
|
4
|
+
import { assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp } from './form-action.js';
|
|
4
5
|
import { isRepeat } from './repeat.js';
|
|
5
6
|
import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
|
|
6
7
|
import { Signal } from './signal.js';
|
|
@@ -671,17 +672,41 @@ function applyPart(part, value, _prev, allValues) {
|
|
|
671
672
|
break;
|
|
672
673
|
case 'attr': {
|
|
673
674
|
if (value == null || value === false) part.el.removeAttribute(part.name);
|
|
674
|
-
else
|
|
675
|
+
else {
|
|
676
|
+
// #1154: refuse to stringify a function into action=/formaction=
|
|
677
|
+
// (mirrors the SSR guard, so a client re-render cannot write a
|
|
678
|
+
// server action's source into the live DOM).
|
|
679
|
+
assertNotFunctionActionAttr(value, part.name, part.el.localName);
|
|
680
|
+
part.el.setAttribute(part.name, String(value));
|
|
681
|
+
}
|
|
675
682
|
break;
|
|
676
683
|
}
|
|
677
684
|
case 'prop':
|
|
685
|
+
// `.action=${fn}` is a leak too, not just the attribute form, but only
|
|
686
|
+
// where the property is a REFLECTED IDL attribute: assigning a function
|
|
687
|
+
// to `form.action` (or `.formAction` on a button/input) stringifies it
|
|
688
|
+
// into that element's own content attribute in a real browser. On any
|
|
689
|
+
// other native tag it is a plain expando that reflects nothing, and on a
|
|
690
|
+
// custom element it is an author-defined property, so a function is
|
|
691
|
+
// legitimate in both. The helper owns that distinction.
|
|
692
|
+
//
|
|
693
|
+
// Not covered here, because it is not a commit: a custom element's prop
|
|
694
|
+
// declared `reflect: true` writes String(value) from its own setter.
|
|
695
|
+
assertNotFunctionReflectedActionProp(value, part.name, part.el.localName);
|
|
678
696
|
/** @type any */ (part.el)[part.name] = value;
|
|
679
697
|
break;
|
|
680
698
|
case 'bool':
|
|
699
|
+
// #1154: never leaked (a boolean binding stringifies nothing), but
|
|
700
|
+
// `?action=${fn}` is meaningless in every case, and refusing it keeps
|
|
701
|
+
// this renderer agreeing with both SSR machines.
|
|
702
|
+
assertNotFunctionActionAttr(value, part.name, part.el.localName);
|
|
681
703
|
if (value) part.el.setAttribute(part.name, '');
|
|
682
704
|
else part.el.removeAttribute(part.name);
|
|
683
705
|
break;
|
|
684
706
|
case 'event':
|
|
707
|
+
// NOT guarded, deliberately. An event binding never stringifies its
|
|
708
|
+
// value, and a function is the legitimate thing to pass one, so
|
|
709
|
+
// `<my-el @action=${handler}>` has to keep working.
|
|
685
710
|
part.handler = typeof value === 'function' ? /** @type any */ (value) : null;
|
|
686
711
|
break;
|
|
687
712
|
case 'element':
|
|
@@ -692,7 +717,10 @@ function applyPart(part, value, _prev, allValues) {
|
|
|
692
717
|
const mp = /** @type {{ statics: string[], group: number[] }} */ (/** @type any */ (part));
|
|
693
718
|
let val = mp.statics[0];
|
|
694
719
|
for (let j = 0; j < mp.group.length; j++) {
|
|
695
|
-
|
|
720
|
+
const piece = allValues ? allValues[mp.group[j]] : value;
|
|
721
|
+
// #1154: same function guard for each piece of a mixed attribute.
|
|
722
|
+
assertNotFunctionActionAttr(piece, part.name, part.el.localName);
|
|
723
|
+
val += String(piece ?? '');
|
|
696
724
|
val += mp.statics[j + 1] || '';
|
|
697
725
|
}
|
|
698
726
|
part.el.setAttribute(part.name, val);
|