@webjsdev/core 0.7.45 → 0.7.47

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
@@ -30,6 +30,11 @@ 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';
34
+ // Form actions (#1155). The field name is shared with the server dispatcher;
35
+ // `setFormActionResolver` is server-only wiring and is stripped here, because
36
+ // a browser stub carries its own identity and needs no resolver.
37
+ export { FORM_ACTION_FIELD, FORM_ACTION_ID_KEY } from './src/form-action.js';
33
38
  export { repeat, isRepeat } from './src/repeat.js';
34
39
  export { Suspense, isSuspense } from './src/suspense.js';
35
40
  export { connectWS } from './src/websocket-client.js';
package/index.d.ts CHANGED
@@ -52,6 +52,8 @@ export type {
52
52
  WebjsRedirectRule,
53
53
  WebjsTrailingSlash,
54
54
  WebjsCspConfig,
55
+ WebjsDoctorConfig,
56
+ WebjsDoctorSeverity,
55
57
  } from './src/webjs-config.d.ts';
56
58
 
57
59
  // Compile-time serializability typing for server actions (#488): the opt-in
@@ -104,6 +106,8 @@ export { Task, TaskStatus } from './src/task.js';
104
106
  // src/*.d.ts (#388 fixed the index.d.ts drift from index.js).
105
107
  export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
106
108
  export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
109
+ export { asset, setAssetUrlProvider } from './src/asset-url.js';
110
+ export { FORM_ACTION_FIELD, FORM_ACTION_ID_KEY, setFormActionResolver } from './src/form-action.js';
107
111
  export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
108
112
  export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
109
113
  // Client action-abort plumbing (#492): a superseded async render aborts its
package/index.js CHANGED
@@ -15,6 +15,11 @@ 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';
19
+ // Form actions (#1155). `FORM_ACTION_FIELD` is the hidden field both renderers
20
+ // emit and the server's form dispatcher reads; `setFormActionResolver` is the
21
+ // server-only wiring that maps a real action function back to its identity.
22
+ export { FORM_ACTION_FIELD, FORM_ACTION_ID_KEY, setFormActionResolver } from './src/form-action.js';
18
23
  export { repeat, isRepeat } from './src/repeat.js';
19
24
  export { Suspense, isSuspense } from './src/suspense.js';
20
25
  export { connectWS } from './src/websocket-client.js';
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.45",
3
+ "version": "0.7.47",
4
4
  "type": "module",
5
- "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
5
+ "description": "The runtime for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Ships the html and css template tags, the WebComponent base class, signals, directives, and the isomorphic renderers.",
6
6
  "types": "./index.d.ts",
7
7
  "exports": {
8
8
  ".": {
@@ -0,0 +1,3 @@
1
+ /** Content-hashed `public/` asset urls (#1194). Server-side resolver; the browser bundle drops setAssetUrlProvider and `asset` returns its argument. */
2
+ export function setAssetUrlProvider(fn: (path: string) => string): void;
3
+ export function asset(path: string): string;
@@ -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
@@ -1,5 +1,6 @@
1
1
  import { render as clientRender } from './render-client.js';
2
2
  import { setActiveActionSignal } from './action-abort-client.js';
3
+ import { carriesFunction } from './form-action.js';
3
4
  import { isCSS, adoptStyles } from './css.js';
4
5
  import { register, tagOf } from './registry.js';
5
6
  import { parse as deserializeProp } from './serialize.js';
@@ -99,6 +100,63 @@ function defaultHasChanged(a, b) {
99
100
  return a !== b;
100
101
  }
101
102
 
103
+ /**
104
+ * `String(v)` that cannot itself throw.
105
+ *
106
+ * A rejection reason is whatever the author rejected with, and not every value
107
+ * converts to a primitive: `Object.create(null)` has no `toString`, and a
108
+ * revoked Proxy throws on any operation. Coercing one in an error-reporting
109
+ * path turned a reportable failure into a second, unreported one.
110
+ *
111
+ * @param {unknown} v
112
+ * @returns {string}
113
+ */
114
+ function safeString(v) {
115
+ try {
116
+ return String(v);
117
+ } catch {
118
+ return Object.prototype.toString.call(v);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Warn that a function value was dropped rather than reflected (#1169).
124
+ *
125
+ * A silently missing attribute is its own confusion, so say what went and why.
126
+ * The message deliberately does NOT include the value: printing the source is
127
+ * the leak this guard exists to prevent, and a server log is not always a
128
+ * private place.
129
+ *
130
+ * UNCONDITIONAL, matching the `.prop=${fn}` unserializable-value drop in
131
+ * `render-server.js`, which is the sibling path this guard mirrors. Two
132
+ * reasons not to gate it on a dev flag. It fires only on a genuine mistake (a
133
+ * function is never a meaningful attribute value), so there is no volume to
134
+ * suppress, and reflection runs per assignment rather than per frame.
135
+ *
136
+ * More decisively, a `NODE_ENV` gate does not survive the dist build.
137
+ * `scripts/build-framework-dist.js` runs esbuild with `platform: 'browser'`
138
+ * and `minify: true`, which substitutes `process.env.NODE_ENV` with
139
+ * `"production"` and folds the check to a constant. The SSR half of the
140
+ * warning would then be unreachable in every published build, which is how
141
+ * every installed app runs, and SSR is the half that matters most, since that
142
+ * is where the leaked source reached visitors.
143
+ *
144
+ * @param {{ constructor: unknown, tagName?: string }} host
145
+ * @param {string} propName
146
+ * @param {string} attrName
147
+ */
148
+ function warnFunctionReflection(host, propName, attrName) {
149
+ if (typeof console === 'undefined' || !console.warn) return;
150
+ const tag = tagOf(/** @type any */ (host.constructor)) || host.tagName?.toLowerCase() || 'unknown';
151
+ console.warn(
152
+ `[webjs] reflect:true property "${propName}" on <${tag}> holds a function `
153
+ + `(or an array carrying one), which has no HTML attribute representation. `
154
+ + `Removing "${attrName}" instead of stringifying it (a stringified function `
155
+ + `writes its source into the page, so a server action's body would ship to `
156
+ + `the browser). Pass a string, or drop reflect on this property.`
157
+ );
158
+ }
159
+
102
160
  /**
103
161
  * A minimal base for HTML Custom Elements that mirrors Lit's ergonomics
104
162
  * while staying JSDoc-only and no-build.
@@ -666,13 +724,43 @@ class WebComponentBase extends Base {
666
724
  const serialized = decl.converter.toAttribute(value, decl.type);
667
725
  if (serialized == null) this.removeAttribute(attrName);
668
726
  else this.setAttribute(attrName, serialized);
727
+ } else if (typeof value === 'function') {
728
+ // #1169: the value IS a function, and no branch below has a
729
+ // meaningful serialization for one. `String(fn)` is the function's
730
+ // SOURCE, so a reflected `'use server'` action would ship its whole
731
+ // body, closure secrets included, to every visitor, and
732
+ // `JSON.stringify(fn)` is `undefined`, which `setAttribute` writes as
733
+ // the literal string "undefined". Treat it like `null` and remove the
734
+ // attribute, matching what the `.prop=${fn}` SSR binding already does
735
+ // for an unserializable value.
736
+ //
737
+ // Placed AFTER the converter branch on purpose: a custom
738
+ // `toAttribute` is author-controlled, so its author has taken
739
+ // responsibility for serializing whatever they are handed.
740
+ this.removeAttribute(attrName);
741
+ warnFunctionReflection(this, propName, attrName);
669
742
  } else if (decl.type === Boolean) {
670
743
  if (value) this.setAttribute(attrName, '');
671
744
  else this.removeAttribute(attrName);
672
745
  } else if (value == null) {
673
746
  this.removeAttribute(attrName);
674
747
  } else if (decl.type === Object || decl.type === Array) {
748
+ // A JSON-typed prop CARRYING a function is safe and stays whole:
749
+ // `JSON.stringify` drops a function to `null` in an array and omits
750
+ // the key in an object, so `[1, 2, fn]` serializes to `[1,2,null]`
751
+ // with no source and no data loss. Refusing here would discard the
752
+ // `1` and the `2` on a path that never leaked.
675
753
  this.setAttribute(attrName, JSON.stringify(value));
754
+ } else if (carriesFunction(value)) {
755
+ // The string fall-through is the one place a CARRIED function still
756
+ // leaks. `String([fn])` is `Array.prototype.join`, which runs
757
+ // `String()` on every element, so `[serverAction]` writes the same
758
+ // source `serverAction` does. Hence the recursive predicate the
759
+ // form-action guard already uses, rather than a bare `typeof`, but
760
+ // scoped to this branch rather than applied above, where JSON
761
+ // handles the same shape losslessly.
762
+ this.removeAttribute(attrName);
763
+ warnFunctionReflection(this, propName, attrName);
676
764
  } else {
677
765
  this.setAttribute(attrName, String(value));
678
766
  }
@@ -1176,7 +1264,7 @@ class WebComponentBase extends Base {
1176
1264
  // (shouldUpdate=false) running during the fetch does NOT resolve
1177
1265
  // updateComplete early: the pending commit owns the resolution.
1178
1266
  this.__pendingAsyncCommits = (this.__pendingAsyncCommits || 0) + 1;
1179
- pendingCommit.then(() => {
1267
+ const settle = () => {
1180
1268
  // Always decrement first, even when superseded, so the in-flight
1181
1269
  // count never leaks (a superseded cycle returns below without
1182
1270
  // committing, but it is no longer pending).
@@ -1189,6 +1277,37 @@ class WebComponentBase extends Base {
1189
1277
  }
1190
1278
  // --- 7-8 + updateComplete ---
1191
1279
  this._postCommit(changedProperties);
1280
+ };
1281
+ // A rejection has to settle the cycle too, or the counter stays >= 1
1282
+ // forever: that both wedges `updateComplete` for this instance and
1283
+ // disables the `!this.__pendingAsyncCommits` escape below for every
1284
+ // later non-committing cycle. `_commitAsync` is written not to reject,
1285
+ // but it is not the only thenable that reaches here: `update()` is a
1286
+ // documented override point and may return one, so the handler belongs
1287
+ // at this site rather than only at the one implementation of it.
1288
+ pendingCommit.then(settle, (error) => {
1289
+ // The token check gates the DOM write, NOT the release. A superseded
1290
+ // cycle rejecting late (a discarded fetch, or the #492 abort of its own
1291
+ // in-flight action surfacing as an AbortError) must not commit its
1292
+ // error state over the newer render that already replaced it, which is
1293
+ // the same rule both handlers inside _commitAsync follow. `settle()`
1294
+ // still runs either way: it does its own token check before touching
1295
+ // anything, and the decrement it owns has to happen regardless or the
1296
+ // counter wedges exactly as it did before this handler existed.
1297
+ // `finally`, not a trailing statement: the release must survive
1298
+ // anything the reporting path throws. `String(error)` is evaluated in
1299
+ // the argument expression, OUTSIDE _handleRenderError's own try/catch,
1300
+ // so a rejection reason that cannot be coerced (a null-prototype
1301
+ // object, a revoked Proxy) threw a TypeError straight out of this
1302
+ // handler and skipped the decrement, wedging the counter in exactly
1303
+ // the way this handler exists to prevent.
1304
+ try {
1305
+ if (token === this.__renderToken) {
1306
+ this._handleRenderError(error instanceof Error ? error : new Error(safeString(error)));
1307
+ }
1308
+ } finally {
1309
+ settle();
1310
+ }
1192
1311
  });
1193
1312
  return;
1194
1313
  }
@@ -1399,7 +1518,23 @@ class WebComponentBase extends Base {
1399
1518
  return Promise.resolve(pending).then(
1400
1519
  (tpl) => {
1401
1520
  if (token !== this.__renderToken) return; // superseded by a newer render
1402
- clientRender(tpl, this._renderRoot);
1521
+ // The COMMIT throws too, not just the fetch, and a sibling rejection
1522
+ // handler cannot see it: `.then(onFulfil, onRejected)` never routes
1523
+ // onFulfil's own throw to onRejected. Left uncaught, the returned
1524
+ // promise rejected and _performRender's `.then` had no rejection
1525
+ // handler, so the error escaped as an unhandled rejection,
1526
+ // renderError() never ran, __pendingAsyncCommits never decremented
1527
+ // (wedging it >= 1 forever, which also disables the non-committing
1528
+ // cycle's _resolveUpdate escape), and updateComplete never settled.
1529
+ // The sync path routes an identical throw to the same boundary, so
1530
+ // this is what makes the two paths agree.
1531
+ try {
1532
+ clientRender(tpl, this._renderRoot);
1533
+ } catch (commitError) {
1534
+ this._handleRenderError(
1535
+ commitError instanceof Error ? commitError : new Error(String(commitError)),
1536
+ );
1537
+ }
1403
1538
  },
1404
1539
  (error) => {
1405
1540
  if (token !== this.__renderToken) return;
@@ -0,0 +1,16 @@
1
+ /** Form actions (#1155): `<form action=${importedAction}>` and its wire. */
2
+
3
+ /** The hidden field carrying a bound action's identity into the submission. */
4
+ export const FORM_ACTION_FIELD: '__webjs_action';
5
+
6
+ /** The property a generated client RPC stub carries its own identity on. */
7
+ export const FORM_ACTION_ID_KEY: '$$webjsAction';
8
+
9
+ /**
10
+ * Server-only wiring: maps a REAL server-action function back to its
11
+ * `<hash>/<fn>` identity. `@webjsdev/server` installs it at boot; the browser
12
+ * bundle drops it, because a stub carries its identity on itself.
13
+ */
14
+ export function setFormActionResolver(
15
+ fn: (fn: Function) => string | null | Promise<string | null>,
16
+ ): void;