@webjsdev/server 0.8.40 → 0.8.42
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.js +1 -0
- package/package.json +1 -1
- package/src/actions.js +1 -1
- package/src/check.js +134 -18
- package/src/component-elision.js +67 -18
- package/src/dev.js +19 -3
- package/src/module-graph.js +18 -0
- package/src/testing.d.ts +10 -0
- package/src/testing.js +35 -0
package/index.js
CHANGED
package/package.json
CHANGED
package/src/actions.js
CHANGED
|
@@ -267,7 +267,7 @@ export function serveServerOnlyStub(relPath) {
|
|
|
267
267
|
`This file is server-only (a .server.{js,ts} file with no 'use server' directive). ` +
|
|
268
268
|
`Either add 'use server' at the top of the file to expose its exports as RPC, ` +
|
|
269
269
|
`or wrap the server-only logic in a separate *.server.{js,ts} action and import that instead. ` +
|
|
270
|
-
`Docs: https://docs.webjs.
|
|
270
|
+
`Docs: https://docs.webjs.dev/docs/server-actions`;
|
|
271
271
|
return `// webjs: server-only module stub for ${relPath} (no 'use server' directive)
|
|
272
272
|
throw new Error(${JSON.stringify(msg)});
|
|
273
273
|
`;
|
package/src/check.js
CHANGED
|
@@ -82,7 +82,7 @@ export const RULES = [
|
|
|
82
82
|
{
|
|
83
83
|
name: 'reactive-props-no-class-field',
|
|
84
84
|
description:
|
|
85
|
-
'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field
|
|
85
|
+
'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field declaration (`count = 0`, `count: number = 0`, `count!: number`, or `count?: number`) in the class body. Under modern class-field semantics (including `erasableSyntaxOnly: true`) every class-field declaration compiles to Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default by assigning in the constructor after super().',
|
|
86
86
|
},
|
|
87
87
|
{
|
|
88
88
|
name: 'array-prop-uses-array-type',
|
|
@@ -134,6 +134,11 @@ export const RULES = [
|
|
|
134
134
|
description:
|
|
135
135
|
'The one sentinel-based check, and the deliberate exception to the "objectively broken code" framing of the other rules: it flags scaffold example content that has not been replaced, so a delivered app contains only what the user intended and never leftover scaffold code. `webjs create` marks its example/demo files (the example homepage in app/page.ts, the example app chrome in app/layout.ts) with a one-line marker comment carrying the literal token `webjs-scaffold-placeholder`. The rule scans raw source (the marker lives in a comment, which the redacted scan view would hide) and fails for every file that still carries the token. A freshly scaffolded app fails this rule BY DESIGN until its placeholders are addressed: replace the example content, or deliberately keep it, and in either case delete the marker line. No finished app legitimately ships a literal remove-me marker, so this still qualifies as a check rather than a convention. The marker is acknowledge-and-remove, so keeping a piece is a one-line deletion rather than a forced rewrite.',
|
|
136
136
|
},
|
|
137
|
+
{
|
|
138
|
+
name: 'no-redirect-in-api-route',
|
|
139
|
+
description:
|
|
140
|
+
'API route handlers (`route.{js,ts}`) must NOT call `redirect()` from `@webjsdev/core`. That function throws a control-flow signal designed for the SSR page renderer; in a route handler it goes uncaught and produces a 500. Use `Response.redirect(url, 303)` for external redirects or return a 3xx Response directly. Page functions, layouts, and server actions may still use `redirect()` (caught by the SSR pipeline).',
|
|
141
|
+
},
|
|
137
142
|
];
|
|
138
143
|
|
|
139
144
|
/** Set of all known rule names for fast lookup. */
|
|
@@ -268,12 +273,20 @@ function findFieldInitializers(classBody, props) {
|
|
|
268
273
|
let j = lineStart;
|
|
269
274
|
while (j < classBody.length && classBody[j] !== '\n') j++;
|
|
270
275
|
const line = classBody.slice(lineStart, j);
|
|
271
|
-
// Match
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
276
|
+
// Match class-field declarations. Two shapes:
|
|
277
|
+
// 1. With initializer: `count = 0`, `count: number = 0`, `public count = 0`
|
|
278
|
+
// 2. Type-only (no initializer): `count!: number`, `count?: number`, `count: number`
|
|
279
|
+
// Both compile to Object.defineProperty after super() under modern class-field
|
|
280
|
+
// semantics, clobbering the reactive accessor.
|
|
281
|
+
// The initializer regex: optional modifier, identifier, optional type, then `=`.
|
|
282
|
+
const initRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*(?::\s*[^=;]+)?\s*=\s*[^=>]/;
|
|
283
|
+
// The type-only regex: optional modifier, identifier, then `!:` or `?:` or `:` with a type.
|
|
284
|
+
const typeOnlyRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*[!?]?\s*:\s*\S/;
|
|
285
|
+
const initM = initRe.exec(line);
|
|
286
|
+
const typeM = typeOnlyRe.exec(line);
|
|
287
|
+
// Prefer the initializer match; if neither, skip.
|
|
288
|
+
const name = initM ? initM[2] : (typeM ? typeM[2] : null);
|
|
289
|
+
if (name) {
|
|
277
290
|
// `declare`, `static`, and `this.` patterns shouldn't reach here
|
|
278
291
|
// (declare/static start with their keyword, this.x has the dot in
|
|
279
292
|
// the regex group), but guard against matching keywords as names:
|
|
@@ -459,8 +472,11 @@ export async function checkConventions(appDir) {
|
|
|
459
472
|
|
|
460
473
|
// --- Rule: reactive-props-no-class-field ---
|
|
461
474
|
// A reactive property declared via the factory must not also carry a plain
|
|
462
|
-
// class-field initializer
|
|
463
|
-
//
|
|
475
|
+
// class-field declaration (initializer OR type-only): under modern class-field
|
|
476
|
+
// semantics (including `erasableSyntaxOnly: true`) every class-field declaration
|
|
477
|
+
// compiles to Object.defineProperty after super() and clobbers the framework's
|
|
478
|
+
// reactive accessor (silent broken re-renders). Catches `count = 0`,
|
|
479
|
+
// `count: number = 0`, `count!: number`, and `count?: number`.
|
|
464
480
|
{
|
|
465
481
|
for (const { rel, scan } of files) {
|
|
466
482
|
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
@@ -470,8 +486,8 @@ export async function checkConventions(appDir) {
|
|
|
470
486
|
violations.push({
|
|
471
487
|
rule: 'reactive-props-no-class-field',
|
|
472
488
|
file: rel,
|
|
473
|
-
message: `Reactive prop \`${bad}\` uses a class-field initializer; this clobbers the framework's reactive accessor under modern class-field semantics.`,
|
|
474
|
-
fix: `
|
|
489
|
+
message: `Reactive prop \`${bad}\` uses a class-field declaration (initializer or type-only); this clobbers the framework's reactive accessor under modern class-field semantics.`,
|
|
490
|
+
fix: `Delete the class-field declaration and set the default by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
|
|
475
491
|
});
|
|
476
492
|
}
|
|
477
493
|
}
|
|
@@ -557,6 +573,67 @@ export async function checkConventions(appDir) {
|
|
|
557
573
|
}
|
|
558
574
|
}
|
|
559
575
|
|
|
576
|
+
// --- Rule: no-redirect-in-api-route ---
|
|
577
|
+
// `redirect()` from `@webjsdev/core` throws a control-flow signal designed
|
|
578
|
+
// for the SSR page renderer. In a `route.ts` API handler it goes uncaught
|
|
579
|
+
// and produces a 500. API handlers must use `Response.redirect(url, 303)`
|
|
580
|
+
// instead. Page functions, layouts, and server actions may still use
|
|
581
|
+
// `redirect()` (caught by the SSR / action pipeline).
|
|
582
|
+
{
|
|
583
|
+
const ROUTE_FILE = /(?:^|\/)route\.m?[jt]s$/;
|
|
584
|
+
for (const { rel, scan } of files) {
|
|
585
|
+
if (!ROUTE_FILE.test(rel)) continue;
|
|
586
|
+
// `redirect` reaches the route file in one of two statically-visible ways:
|
|
587
|
+
// 1. A NAMED import from `@webjsdev/core` (possibly aliased):
|
|
588
|
+
// `import { redirect }`, `import { redirect as r }`, `import { …, redirect, … }`.
|
|
589
|
+
// 2. A NAMESPACE import then a member call:
|
|
590
|
+
// `import * as core from '@webjsdev/core'` then `core.redirect(...)`.
|
|
591
|
+
// The named case flags a bare `redirect(` call; the namespace case flags
|
|
592
|
+
// `<ns>.redirect(`. `Response.redirect(` and any other `obj.redirect(` are
|
|
593
|
+
// the standard API and stay fine. A `redirect()` thrown inside a
|
|
594
|
+
// '`use server`' action the route calls DIRECTLY (an uncaught 500) needs
|
|
595
|
+
// cross-file analysis and is left to the AST rework (#753).
|
|
596
|
+
const namedM = /\bimport\s+\{[^}]*\bredirect\b(?:\s+as\s+(\w+))?\s*[^}]*\}\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
|
|
597
|
+
const nsM = /\bimport\s+\*\s+as\s+(\w+)\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
|
|
598
|
+
// A file can carry BOTH a named `redirect` import AND a namespace import,
|
|
599
|
+
// so check every matcher independently (not mutually exclusive): a named
|
|
600
|
+
// import means a bare `<localName>(`, a namespace import means
|
|
601
|
+
// `<ns>.redirect(`. The `member` flag distinguishes the two so a bare
|
|
602
|
+
// named call can screen out `Response.redirect(` / `obj.redirect(`.
|
|
603
|
+
/** @type {Array<{ re: RegExp, member: boolean }>} */
|
|
604
|
+
const matchers = [];
|
|
605
|
+
if (namedM) {
|
|
606
|
+
const localName = namedM[1] || 'redirect';
|
|
607
|
+
matchers.push({ re: new RegExp(`(?<!\\.)\\b${localName}\\s*\\(`, 'g'), member: false });
|
|
608
|
+
}
|
|
609
|
+
if (nsM) {
|
|
610
|
+
matchers.push({ re: new RegExp(`\\b${nsM[1]}\\.redirect\\s*\\(`, 'g'), member: true });
|
|
611
|
+
}
|
|
612
|
+
let flagged = false;
|
|
613
|
+
for (const { re, member } of matchers) {
|
|
614
|
+
if (flagged) break;
|
|
615
|
+
let m;
|
|
616
|
+
while ((m = re.exec(scan)) !== null) {
|
|
617
|
+
if (!member) {
|
|
618
|
+
// Screen out `Response.redirect(` / `someObj.redirect(` sharing the
|
|
619
|
+
// local name: a preceding member-access dot means it is not the import.
|
|
620
|
+
const before = scan.slice(Math.max(0, m.index - 20), m.index);
|
|
621
|
+
if (/\w\.$/.test(before)) continue;
|
|
622
|
+
}
|
|
623
|
+
violations.push({
|
|
624
|
+
rule: 'no-redirect-in-api-route',
|
|
625
|
+
file: rel,
|
|
626
|
+
message:
|
|
627
|
+
`\`redirect()\` from \`@webjsdev/core\` throws a control-flow signal for the SSR page renderer; in a \`route.ts\` handler it goes uncaught and returns a 500.`,
|
|
628
|
+
fix: `Use \`Response.redirect(url, 303)\` for external redirects, or return a 3xx Response directly. The \`redirect()\` sentinel is only valid in page functions, layouts, and server actions (where the SSR pipeline catches it).`,
|
|
629
|
+
});
|
|
630
|
+
flagged = true; // one violation per file is enough
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
560
637
|
// --- Rule: no-server-env-in-components ---
|
|
561
638
|
// Catches `process.env.X` reads in component files where X is not a
|
|
562
639
|
// WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
|
|
@@ -1002,6 +1079,41 @@ export async function checkConventions(appDir) {
|
|
|
1002
1079
|
return violations;
|
|
1003
1080
|
}
|
|
1004
1081
|
|
|
1082
|
+
/**
|
|
1083
|
+
* BFS the module graph for the shortest import path from `from` to `to`,
|
|
1084
|
+
* returning every hop `[from, ..., to]` so the `no-server-import-in-browser-module`
|
|
1085
|
+
* message can print the FULL chain instead of an opaque `… ->` truncation (#804).
|
|
1086
|
+
* Falls back to `[from, to]` if no path is found (defensive; the caller only
|
|
1087
|
+
* calls this once `to` is known reachable).
|
|
1088
|
+
*
|
|
1089
|
+
* @param {Map<string, Set<string>>} graph
|
|
1090
|
+
* @param {string} from
|
|
1091
|
+
* @param {string} to
|
|
1092
|
+
* @returns {string[]}
|
|
1093
|
+
*/
|
|
1094
|
+
function findImportChain(graph, from, to) {
|
|
1095
|
+
const prev = new Map();
|
|
1096
|
+
const seen = new Set([from]);
|
|
1097
|
+
const queue = [from];
|
|
1098
|
+
while (queue.length) {
|
|
1099
|
+
const cur = queue.shift();
|
|
1100
|
+
const deps = graph.get(cur);
|
|
1101
|
+
if (!deps) continue;
|
|
1102
|
+
for (const dep of deps) {
|
|
1103
|
+
if (seen.has(dep)) continue;
|
|
1104
|
+
seen.add(dep);
|
|
1105
|
+
prev.set(dep, cur);
|
|
1106
|
+
if (dep === to) {
|
|
1107
|
+
const path = [];
|
|
1108
|
+
for (let n = to; n !== undefined; n = prev.get(n)) path.unshift(n);
|
|
1109
|
+
return path;
|
|
1110
|
+
}
|
|
1111
|
+
queue.push(dep);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return [from, to];
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1005
1117
|
/**
|
|
1006
1118
|
* Implements `no-server-import-in-browser-module`. Factored into its own
|
|
1007
1119
|
* function (rather than an inline block) because it does the heavier
|
|
@@ -1164,11 +1276,12 @@ async function checkServerImportInBrowserModule(appDir, violations) {
|
|
|
1164
1276
|
// module, the chain is just the two; otherwise show one intermediate hop
|
|
1165
1277
|
// so the diagnostic points at where the edge enters (the full path is
|
|
1166
1278
|
// recoverable from the graph, but one hop is enough to locate it).
|
|
1167
|
-
const
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1279
|
+
const chainFiles = findImportChain(moduleGraph, file, serverDep);
|
|
1280
|
+
const chain = chainFiles.map((f) => relative(appDir, f)).join(' -> ');
|
|
1281
|
+
// If the edge into the server file comes from a types-shaped module, the
|
|
1282
|
+
// idiomatic fix is to relocate that type to a browser-safe typedef (#804).
|
|
1283
|
+
const importer = chainFiles.length >= 2 ? chainFiles[chainFiles.length - 2] : null;
|
|
1284
|
+
const viaTypesModule = importer && /(^|\/)types(\.m?[jt]s$|\/)/.test(relative(appDir, importer));
|
|
1172
1285
|
|
|
1173
1286
|
// The "register the component in a layout so it elides again" remedy only
|
|
1174
1287
|
// applies to a page / layout, which CAN elide (it became browser-bound by
|
|
@@ -1176,9 +1289,12 @@ async function checkServerImportInBrowserModule(appDir, violations) {
|
|
|
1176
1289
|
// ship and are never elided, so offering them an "elides again" fix is
|
|
1177
1290
|
// wrong. Branch the fix text on whether the kind can elide.
|
|
1178
1291
|
const canElide = kind === 'page' || kind === 'layout';
|
|
1292
|
+
const typesHint = viaTypesModule
|
|
1293
|
+
? `The edge enters via a types-shaped module (${relative(appDir, importer)}); if it re-exports a runtime VALUE from a \`.server.{ts,js}\` file, relocate that to a browser-safe typedef (a plain \`interface\` / JSDoc, or an \`import type\` which the stripper erases) so the type is shared without pinning the module. `
|
|
1294
|
+
: '';
|
|
1179
1295
|
const fixText = canElide
|
|
1180
|
-
?
|
|
1181
|
-
:
|
|
1296
|
+
? `${typesHint}Keep the server call off this browser-shipped ${kind}. Options: (1) gate the route in \`middleware.ts\` (runs server-side, never ships); (2) move the server-only call behind a \`'use server'\` action in a \`.server.{ts,js}\` file and call it as an RPC; or (3) if this ${kind} only became browser-bound because it imports a component to register, register that component in a \`layout.{ts,js}\` instead so the ${kind} elides again and its server import is stripped.`
|
|
1297
|
+
: `${typesHint}Keep the server call off this browser-shipped ${kind} (it always ships and is never elided). Options: (1) gate the route in \`middleware.ts\` (runs server-side, never ships); or (2) move the server-only call behind a \`'use server'\` action in a \`.server.{ts,js}\` file and call it as an RPC.`;
|
|
1182
1298
|
|
|
1183
1299
|
violations.push({
|
|
1184
1300
|
rule: 'no-server-import-in-browser-module',
|
package/src/component-elision.js
CHANGED
|
@@ -126,6 +126,35 @@ export const CLIENT_LIFECYCLE_HOOKS = [
|
|
|
126
126
|
*/
|
|
127
127
|
export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'requestUpdate'];
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Static class fields whose declaration (to a non-`false` value) marks a
|
|
131
|
+
* component interactive (must ship). Kept as ONE registry so a new
|
|
132
|
+
* interactivity-relevant static convention is added in a single place; the
|
|
133
|
+
* per-class analysis loops over it. Unlike prototype methods (guarded by
|
|
134
|
+
* `lifecycle-coverage.test.js` via prototype introspection), there is no
|
|
135
|
+
* enumerable runtime source of "all static conventions", so the contract is
|
|
136
|
+
* a documented one: a new interactivity static field MUST be added here AND
|
|
137
|
+
* to the lifecycle table in agent-docs/components.md. `sigil-coverage.test.js`
|
|
138
|
+
* asserts each entry is honoured as a ship signal.
|
|
139
|
+
*
|
|
140
|
+
* - `shadow`: Declarative Shadow DOM attaches ONLY during HTML parsing, so a
|
|
141
|
+
* component that arrives via a client DOM insertion (a soft-nav swap, a
|
|
142
|
+
* streamed <webjs-suspense> boundary's replaceWith) would never re-attach
|
|
143
|
+
* its shadow root if elided. Context-free, so any shadow component ships.
|
|
144
|
+
* - `refresh`: `static refresh = true` is the explicit opt-in to ship a bare
|
|
145
|
+
* async component so its stale-while-revalidate refresh runs after SSR;
|
|
146
|
+
* eliding drops that on-load re-fetch (moot for request-stable data).
|
|
147
|
+
*
|
|
148
|
+
* @type {readonly string[]}
|
|
149
|
+
*/
|
|
150
|
+
export const INTERACTIVITY_STATIC_FIELDS = ['shadow', 'refresh'];
|
|
151
|
+
|
|
152
|
+
/** Why each INTERACTIVITY_STATIC_FIELDS entry forces a ship (analyser reason). */
|
|
153
|
+
const STATIC_FIELD_REASONS = {
|
|
154
|
+
shadow: 'declares static shadow (DSD must re-attach on a client swap)',
|
|
155
|
+
refresh: 'declares static refresh = true (keeps the on-load re-fetch)',
|
|
156
|
+
};
|
|
157
|
+
|
|
129
158
|
/**
|
|
130
159
|
* A bare `async render()` is NOT a standalone ship signal (#474). Its SSR
|
|
131
160
|
* pass bakes the resolved data into the first paint, so a light-DOM async
|
|
@@ -144,8 +173,37 @@ export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'reques
|
|
|
144
173
|
* re-fetch that eliding would drop).
|
|
145
174
|
*/
|
|
146
175
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Template binding-prefix classification, the anchor for the elision drift
|
|
178
|
+
* guard. Every prefix core's renderers recognise (core's `BINDING_PREFIXES`)
|
|
179
|
+
* is one of two kinds:
|
|
180
|
+
* - SSR_DROPPED_PREFIXES: a CLIENT-BEHAVIOUR ship signal. The binding drops
|
|
181
|
+
* at SSR and is wired only after hydration (`@event`), so a component whose
|
|
182
|
+
* only interactivity is such a binding MUST ship.
|
|
183
|
+
* - ROUND_TRIP_PREFIXES: an SSR-SAFE binding that survives into the served
|
|
184
|
+
* HTML (`.prop` via `data-webjs-prop-*`, `?bool` as a boolean attribute),
|
|
185
|
+
* so it is NOT a ship signal on its own and stays elidable.
|
|
186
|
+
* `sigil-coverage.test.js` asserts these two lists PARTITION `BINDING_PREFIXES`
|
|
187
|
+
* exactly (union equal, disjoint), so a new sigil cannot be added to the
|
|
188
|
+
* renderers without being classified here. That closes the one interactivity
|
|
189
|
+
* surface the prototype-introspection guard (`lifecycle-coverage.test.js`)
|
|
190
|
+
* cannot see, because a sigil is template syntax, not a method or an export.
|
|
191
|
+
*/
|
|
192
|
+
export const SSR_DROPPED_PREFIXES = ['@'];
|
|
193
|
+
export const ROUND_TRIP_PREFIXES = ['.', '?'];
|
|
194
|
+
|
|
195
|
+
/** Escape a single char for safe inclusion in a regex character class. */
|
|
196
|
+
const escapeForCharClass = (c) => c.replace(/[\\\]^-]/g, '\\$&');
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Match a client-behaviour binding (`@event=${...}`), a ship signal. The
|
|
200
|
+
* prefix set is DERIVED from SSR_DROPPED_PREFIXES (not hardcoded), so a new
|
|
201
|
+
* client-behaviour sigil added to core is detected here automatically once it
|
|
202
|
+
* is classified above. (unquoted per invariant 4)
|
|
203
|
+
*/
|
|
204
|
+
const EVENT_BINDING_RE = new RegExp(
|
|
205
|
+
`[${SSR_DROPPED_PREFIXES.map(escapeForCharClass).join('')}][A-Za-z][\\w-]*\\s*=\\s*\\$\\{`,
|
|
206
|
+
);
|
|
149
207
|
|
|
150
208
|
/** Match a `.onclick=${...}` (native event-handler property) binding. */
|
|
151
209
|
const EVENT_PROP_RE = /\.on[a-z]+\s*=\s*\$\{/;
|
|
@@ -505,22 +563,13 @@ export function analyzeComponentSource(src) {
|
|
|
505
563
|
}
|
|
506
564
|
|
|
507
565
|
for (const { body, factoryArg } of bodies) {
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if (declaresStaticTrue(body, 'shadow')) {
|
|
516
|
-
return { interactive: true, reason: 'declares static shadow (DSD must re-attach on a client swap)' };
|
|
517
|
-
}
|
|
518
|
-
// `static refresh = true` is the explicit opt-in to ship a bare async
|
|
519
|
-
// component so its stale-while-revalidate refresh runs ~after SSR; eliding
|
|
520
|
-
// a bare async component drops that on-load re-fetch (moot for request-
|
|
521
|
-
// stable data, the default), so an author who wants fresh-on-load opts in.
|
|
522
|
-
if (declaresStaticTrue(body, 'refresh')) {
|
|
523
|
-
return { interactive: true, reason: 'declares static refresh = true (keeps the on-load re-fetch)' };
|
|
566
|
+
// Interactivity-signal static conventions (`static shadow` / `static
|
|
567
|
+
// refresh = true`), driven by the INTERACTIVITY_STATIC_FIELDS registry so a
|
|
568
|
+
// new convention is added in one place (see its doc for why each ships).
|
|
569
|
+
for (const field of INTERACTIVITY_STATIC_FIELDS) {
|
|
570
|
+
if (declaresStaticTrue(body, field)) {
|
|
571
|
+
return { interactive: true, reason: STATIC_FIELD_REASONS[field] };
|
|
572
|
+
}
|
|
524
573
|
}
|
|
525
574
|
for (const hook of CLIENT_LIFECYCLE_HOOKS) {
|
|
526
575
|
// A client lifecycle hook as a method (`hook(`) OR as an arrow class
|
package/src/dev.js
CHANGED
|
@@ -686,6 +686,14 @@ export async function createRequestHandler(opts) {
|
|
|
686
686
|
middleware: null,
|
|
687
687
|
bodyLimits,
|
|
688
688
|
logger,
|
|
689
|
+
// Test-mode serve gate (#806). Set ONLY by the browser-test harness
|
|
690
|
+
// (`@webjsdev/server/testing`'s `createBrowserTestHandler`), NEVER by
|
|
691
|
+
// `webjs dev` / `webjs start`. When true the source-serve gate is relaxed
|
|
692
|
+
// so ANY app file under appDir is servable (a component a browser test
|
|
693
|
+
// imports is not route-reachable, so it is absent from `browserBoundFiles`
|
|
694
|
+
// and would 404). The `.server.*` guardrail (source -> RPC/throw stub) and
|
|
695
|
+
// the core / vendor serving are unchanged, so no server source is exposed.
|
|
696
|
+
testMode: opts.testMode === true,
|
|
689
697
|
moduleGraph: null,
|
|
690
698
|
elidableComponents: new Set(),
|
|
691
699
|
inertRouteModules: new Set(),
|
|
@@ -1864,8 +1872,16 @@ async function handleCore(req, ctx) {
|
|
|
1864
1872
|
// files (.server.{js,ts}) get a stub via the guardrail below; they
|
|
1865
1873
|
// ARE included in browserBoundFiles because client code imports
|
|
1866
1874
|
// them by path (the import rewrites to an RPC stub at request time).
|
|
1867
|
-
|
|
1868
|
-
|
|
1875
|
+
// In test mode any app file is servable (see the `state.testMode` note
|
|
1876
|
+
// above); otherwise the file must be in the browser-bound module graph.
|
|
1877
|
+
const inGraph = state.testMode || (state.browserBoundFiles && state.browserBoundFiles.has(abs));
|
|
1878
|
+
// Containment: `abs` must be appDir itself or genuinely UNDER it. The
|
|
1879
|
+
// trailing `sep` (matching the public-asset branch) stops a `..` path that
|
|
1880
|
+
// resolves to a sibling sharing the appDir name-prefix (`/x/app` ->
|
|
1881
|
+
// `/x/app-secrets/...`) from passing in test mode, where graph membership
|
|
1882
|
+
// is not the gate.
|
|
1883
|
+
const underAppDir = abs === appDir || abs.startsWith(appDir + sep);
|
|
1884
|
+
if (underAppDir && inGraph && (await exists(abs))) {
|
|
1869
1885
|
// Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
|
|
1870
1886
|
// MUST NEVER be served as source to the browser. The extension is
|
|
1871
1887
|
// the path-level boundary; we re-verify it on every request (not
|
|
@@ -2353,7 +2369,7 @@ async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDevError)
|
|
|
2353
2369
|
`Replace enum / namespace / parameter-property / legacy-decorator / ` +
|
|
2354
2370
|
`import = require constructs with their erasable equivalents. ` +
|
|
2355
2371
|
`Run \`webjs check\` for guidance (no-non-erasable-typescript rule). ` +
|
|
2356
|
-
`Docs: https://docs.webjs.
|
|
2372
|
+
`Docs: https://docs.webjs.dev/docs/typescript`
|
|
2357
2373
|
// Prod: terse, no path leak, no Node-message leak (Node's
|
|
2358
2374
|
// message can include source snippets). Operators get the
|
|
2359
2375
|
// detail in server logs above.
|
package/src/module-graph.js
CHANGED
|
@@ -447,6 +447,24 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
|
|
|
447
447
|
// position is blanked in the mask, the match lives inside a literal
|
|
448
448
|
// and is not a real import edge.
|
|
449
449
|
if (masked[m.index] === ' ') continue;
|
|
450
|
+
// A statement-leading `import type { X }` / `import type * as X` /
|
|
451
|
+
// `import type Foo` (default type import) / `export type { X }` /
|
|
452
|
+
// `export type *` is fully erased by the TS stripper, so it never becomes
|
|
453
|
+
// a browser fetch or a runtime edge. Skip it, so a type-only import of a
|
|
454
|
+
// `.server.ts` (a common, correct way to share a derived row type) is not
|
|
455
|
+
// mistaken for a runtime dependency by the gate / elision / the
|
|
456
|
+
// no-server-import check. Two carve-outs keep this from over-dropping:
|
|
457
|
+
// - `import type from '...'` is a VALUE default import whose binding is
|
|
458
|
+
// literally named `type` (the stripper keeps it), so `type` directly
|
|
459
|
+
// followed by `from` is NOT type-only.
|
|
460
|
+
// - a MIXED `import { type A, b }` does NOT lead with `type`, so it is
|
|
461
|
+
// kept (its `b` binding is a real runtime edge).
|
|
462
|
+
// #805
|
|
463
|
+
const lead = m[0];
|
|
464
|
+
const typeOnly =
|
|
465
|
+
/^(?:import|export)\s+type\s*[{*]/.test(lead) ||
|
|
466
|
+
(/^import\s+type\s+[A-Za-z_$]/.test(lead) && !/^import\s+type\s+from\b/.test(lead));
|
|
467
|
+
if (typeOnly) continue;
|
|
450
468
|
const spec = m[1];
|
|
451
469
|
// Guard a match whose `from '<spec>'` tail reaches INTO a blanked literal:
|
|
452
470
|
// EXPORT_FROM_RE's lazy `[^'";]+?` can span a template body to a `from`
|
package/src/testing.d.ts
CHANGED
|
@@ -103,3 +103,13 @@ export declare function rawActionRequest(
|
|
|
103
103
|
contentType?: string;
|
|
104
104
|
},
|
|
105
105
|
): Promise<Response>;
|
|
106
|
+
|
|
107
|
+
/** Browser-test harness (#806): a webjs handler a web-test-runner config
|
|
108
|
+
* proxies module requests to, so a browser test can import a real `.ts`
|
|
109
|
+
* component that imports a `'use server'` action. `importmapHtml()` is the
|
|
110
|
+
* `<script type="importmap">` to inject into the test page (call after warmup). */
|
|
111
|
+
export declare function createBrowserTestHandler(appDir: string): Promise<{
|
|
112
|
+
handle: Handle;
|
|
113
|
+
warmup: () => Promise<void>;
|
|
114
|
+
importmapHtml: () => string;
|
|
115
|
+
}>;
|
package/src/testing.js
CHANGED
|
@@ -348,3 +348,38 @@ export async function rawActionRequest(app, serverFilePath, fnName, args = [], o
|
|
|
348
348
|
|
|
349
349
|
return testRequest(handle, endpoint, { method: 'POST', headers, body });
|
|
350
350
|
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Browser-test harness (#806): build a webjs handler that a `web-test-runner`
|
|
354
|
+
* config can proxy module requests to, so a `test/**/browser/*.test.js` file
|
|
355
|
+
* can import a real `.ts` component that imports a `'use server'` action and it
|
|
356
|
+
* loads + hydrates in a real browser. Plain WTR serves raw TS with no webjs
|
|
357
|
+
* transform, so the component (and its `#`-alias / `.server.ts` imports) never
|
|
358
|
+
* resolves; this handler runs the SAME dev pipeline (TS strip, `.server.ts` ->
|
|
359
|
+
* RPC stub, `#` alias, `/__webjs/core/*`, the importmap) as `webjs dev`.
|
|
360
|
+
*
|
|
361
|
+
* Uses `testMode` so ANY app file under appDir is servable (a component a test
|
|
362
|
+
* imports is not route-reachable, so it is absent from the browser-bound graph
|
|
363
|
+
* the normal serve gate requires). The `.server.*` source guardrail is
|
|
364
|
+
* unchanged, so no server source is exposed. `importmapHtml()` returns the
|
|
365
|
+
* `<script type="importmap">` to inject into the test page (call after warmup,
|
|
366
|
+
* so the map is final).
|
|
367
|
+
*
|
|
368
|
+
* The dev handler + importmap builder are LAZILY imported so importing the rest
|
|
369
|
+
* of `@webjsdev/server/testing` stays light (they pull the full server + the
|
|
370
|
+
* `ws` WebSocket subsystem).
|
|
371
|
+
*
|
|
372
|
+
* @param {string} appDir the webjs app root (usually `process.cwd()`)
|
|
373
|
+
* @returns {Promise<{ handle: Handle, warmup: () => Promise<void>, importmapHtml: () => string }>}
|
|
374
|
+
*/
|
|
375
|
+
export async function createBrowserTestHandler(appDir) {
|
|
376
|
+
const { createRequestHandler } = await import('./dev.js');
|
|
377
|
+
const { importMapTag } = await import('./importmap.js');
|
|
378
|
+
const app = await createRequestHandler({ appDir, dev: true, testMode: true });
|
|
379
|
+
if (app.warmup) await app.warmup();
|
|
380
|
+
return {
|
|
381
|
+
handle: app.handle,
|
|
382
|
+
warmup: app.warmup || (async () => {}),
|
|
383
|
+
importmapHtml: () => importMapTag(),
|
|
384
|
+
};
|
|
385
|
+
}
|