@webjsdev/server 0.8.64 → 0.8.65

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.64",
3
+ "version": "0.8.65",
4
4
  "type": "module",
5
5
  "description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
6
6
  "main": "index.js",
@@ -45,6 +45,7 @@ import {
45
45
  redactToPlaceholders,
46
46
  } from './js-scan.js';
47
47
  import { transitiveDeps, expandImportAlias } from './module-graph.js';
48
+ import { ensureStripper } from './ts-strip.js';
48
49
 
49
50
  /**
50
51
  * Named imports from a `@webjsdev/core` specifier that imply the
@@ -267,6 +268,23 @@ const INSTANCEOF_RE = /\binstanceof\s+([A-Z][A-Za-z0-9_$]*)/g;
267
268
  * `customElements.define(...)` legitimately uses it and must not force ship). */
268
269
  const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\b/;
269
270
 
271
+ /**
272
+ * Constructors that produce inert DATA with no side effect, so a module-scope
273
+ * `export const X = new Set([...])` (a lookup table, a compiled RegExp, a
274
+ * parsed URL) is not client work and must not pin an importing page/layout
275
+ * (#623). Any constructor NOT in this set (`new WebSocket()`, `new Worker()`,
276
+ * `new EventSource()`, `new Audio()`) IS a side effect and still ships.
277
+ */
278
+ const PURE_DATA_CONSTRUCTORS = new Set([
279
+ 'Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'Array', 'Object',
280
+ 'Number', 'String', 'Boolean', 'BigInt', 'Symbol',
281
+ 'Error', 'TypeError', 'RangeError', 'SyntaxError',
282
+ 'URL', 'URLSearchParams', 'ArrayBuffer', 'DataView',
283
+ 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
284
+ 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array',
285
+ 'BigInt64Array', 'BigUint64Array',
286
+ ]);
287
+
270
288
  /**
271
289
  * Module-scope client work, detected by an ALLOWLIST of safe top-level forms
272
290
  * rather than a denylist of browser globals. A module that runs ANY code when
@@ -285,13 +303,22 @@ const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|
285
303
  *
286
304
  * Scans the redacted copy (strings / templates / comments blanked, regex
287
305
  * literals and nested `${...}` interpolation tracked by the lexer) so template
288
- * prose and JSDoc / TS type annotations cannot trip it; quoted-string bodies,
289
- * which redaction keeps verbatim for other rules, are blanked here too so a
290
- * string like `"foo()"` or `"{"` is not read as a call and cannot unbalance
291
- * the brace scan. The unbalanced-brace and unterminated-string fallbacks below
292
- * are defense in depth: with the lexer tracking regex literals, neither should
293
- * trigger on valid code, but if either does the module ships rather than risk
294
- * hiding client work.
306
+ * prose and JSDoc cannot trip it; quoted-string bodies, which redaction keeps
307
+ * verbatim for other rules, are blanked here too so a string like `"foo()"` or
308
+ * `"{"` is not read as a call and cannot unbalance the brace scan. The
309
+ * unbalanced-brace and unterminated-string fallbacks below are defense in
310
+ * depth: with the lexer tracking regex literals, neither should trigger on
311
+ * valid code, but if either does the module ships rather than risk hiding
312
+ * client work.
313
+ *
314
+ * PASS TYPE-ERASED SOURCE for a TypeScript module. Redaction blanks comments
315
+ * and literals, NOT type syntax, and TS annotations are call-shaped often
316
+ * enough to matter: `readonly (readonly [number, number, number])[]` is an
317
+ * identifier immediately followed by `(`, so a module holding nothing but a
318
+ * typed data literal reads as running code at module scope (#1423). The
319
+ * analyser erases types before it calls this (`eraseTypesForScan`), so the
320
+ * request path is covered; a DIRECT caller handing it `.ts` source as authored
321
+ * is opting into that false positive.
295
322
  *
296
323
  * Over-detection is safe (a top-level arrow whose body calls something, or a
297
324
  * pure top-level helper call, only ships). The accepted residual misses, all
@@ -299,25 +326,10 @@ const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|
299
326
  * top-level object / array initializer or a destructuring default, and a
300
327
  * side-effecting tagged-template hole evaluated at module scope.
301
328
  *
302
- * @param {string} src raw module source
303
- */
304
- /**
305
- * Constructors that produce inert DATA with no side effect, so a module-scope
306
- * `export const X = new Set([...])` (a lookup table, a compiled RegExp, a
307
- * parsed URL) is not client work and must not pin an importing page/layout
308
- * (#623). Any constructor NOT in this set (`new WebSocket()`, `new Worker()`,
309
- * `new EventSource()`, `new Audio()`) IS a side effect and still ships.
329
+ * @param {string} src module source, type-erased if it came from a TypeScript file
330
+ * @param {string[]} [literals] the redaction's literal bodies, when already computed
331
+ * @returns {boolean}
310
332
  */
311
- const PURE_DATA_CONSTRUCTORS = new Set([
312
- 'Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'Array', 'Object',
313
- 'Number', 'String', 'Boolean', 'BigInt', 'Symbol',
314
- 'Error', 'TypeError', 'RangeError', 'SyntaxError',
315
- 'URL', 'URLSearchParams', 'ArrayBuffer', 'DataView',
316
- 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
317
- 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array',
318
- 'BigInt64Array', 'BigUint64Array',
319
- ]);
320
-
321
333
  export function hasModuleScopeSideEffect(src, literals) {
322
334
  let redacted = src;
323
335
  if (!literals) {
@@ -871,6 +883,131 @@ export function extractRenderedTags(src) {
871
883
  return tags;
872
884
  }
873
885
 
886
+ /**
887
+ * The extensions that can carry TypeScript type syntax INTO this analysis.
888
+ *
889
+ * Derive it from what actually reaches `allFiles`, which is seeded from three
890
+ * places with three different filters: the component set from `scanComponents`
891
+ * (`/\.m?[jt]sx?$/`), the route modules from the router (`js|mjs|ts|mts`), and
892
+ * the module graph from its walker (`/\.(js|ts|mjs|mts)$/`). The COMPONENT set
893
+ * is the widest and it does not pass through the other two, so a `.tsx`
894
+ * component arrives here whatever the walker and the router admit. Hence
895
+ * `.tsx`, even though nothing in WebJs serves one: leaving it out is how the
896
+ * #1423 false positive comes back for exactly the file class no other filter
897
+ * would have let through. `.cts` is absent because no filter admits it.
898
+ *
899
+ * The rule to hold, if any of those three filters moves: this set is every
900
+ * TypeScript-carrying extension in their UNION. Narrowing it to the servable
901
+ * set instead is a change that looks tighter and silently un-erases files.
902
+ */
903
+ const TS_SOURCE_RE = /\.m?tsx?$/;
904
+
905
+ /**
906
+ * Per-file memo of {@link eraseTypesForScan}, holding the source it was derived
907
+ * from so a hit is proven rather than assumed. One entry per file, so it is
908
+ * bounded by the app's file count and needs no eviction policy; a deleted file
909
+ * leaves one dead entry until the process ends, which is cheaper than tracking
910
+ * liveness for it.
911
+ *
912
+ * @type {Map<string, { src: string, out: string }>}
913
+ */
914
+ const STRIP_CACHE = new Map();
915
+
916
+ /**
917
+ * Return `src` with its TYPE syntax erased, so every scan below reads the code
918
+ * that actually RUNS rather than the code as authored (#1423).
919
+ *
920
+ * The scans are lexical heuristics, and TypeScript annotations are syntax the
921
+ * heuristics were never designed for: `readonly (readonly [number, number,
922
+ * number])[]` is an identifier immediately followed by `(`, which the
923
+ * top-level-call matcher in `hasModuleScopeSideEffect` reads as a call, so a
924
+ * module holding nothing but a typed data literal was classified as running
925
+ * code at module scope and pinned every route module that imported it. Type
926
+ * syntax is erased before anything runs, so it can never be a runtime signal,
927
+ * and the only sound reading of it is none at all.
928
+ *
929
+ * This uses the framework's own stripper rather than a lexical annotation
930
+ * matcher: it is the same erasure the browser is served, so the analyser and
931
+ * the runtime agree by construction, where a hand-rolled matcher would meet
932
+ * generics, `as`, `satisfies`, and conditional types and get some of them
933
+ * wrong. It is position-preserving whitespace replacement, so every offset,
934
+ * line, and column the other scans depend on is unchanged.
935
+ *
936
+ * A non-TypeScript file is returned untouched (there is nothing to erase, and
937
+ * running a TS parser over one buys only new ways to fail). A PER-FILE strip
938
+ * failure also returns the source untouched, which is the pre-#1423 behaviour
939
+ * and therefore the conservative direction: the scans then over-detect at
940
+ * worst. Two things take that path, both silent because both are one file's
941
+ * problem. A non-erasable module, which invariant 10 forbids and `webjs check`
942
+ * catches at edit time, so it is a broken app rather than a supported one. And
943
+ * a `.tsx` holding real JSX, which the stripper parses as non-JSX TypeScript
944
+ * and rejects, so a JSX component is scanned as authored while a `.tsx` that is
945
+ * only TypeScript is erased normally.
946
+ *
947
+ * A missing stripper BACKEND is the failure that does not stay silent, and it
948
+ * is why the backend is resolved once by the caller rather than per file. It
949
+ * means no TypeScript in the app can be erased (a runtime with neither the
950
+ * built-in nor `amaro`), so every module is scanned as authored and the #1423
951
+ * verdict comes back app-wide. Swallowing that alongside a syntax rejection
952
+ * would make an app-wide regression indistinguishable from one broken file, so
953
+ * `resolveScanStripper` warns once and this returns the source untouched.
954
+ *
955
+ * Results are memoized per file and validated against the exact source, since
956
+ * this runs on every dev rebuild and stripping costs roughly 50x the read it
957
+ * sits beside. Validating on content rather than mtime means a same-mtime
958
+ * rewrite cannot serve a stale strip.
959
+ *
960
+ * @param {import('./ts-strip.js').Stripper | null} stripper resolved once per run, null when unavailable
961
+ * @param {string} file absolute path, the cache key and the extension source
962
+ * @param {string} src
963
+ * @returns {string}
964
+ */
965
+ function eraseTypesForScan(stripper, file, src) {
966
+ if (!stripper || !TS_SOURCE_RE.test(file)) return src;
967
+ const hit = STRIP_CACHE.get(file);
968
+ if (hit && hit.src === src) return hit.out;
969
+ let out;
970
+ try {
971
+ out = stripper.fn(src);
972
+ } catch {
973
+ out = src;
974
+ }
975
+ STRIP_CACHE.set(file, { src, out });
976
+ return out;
977
+ }
978
+
979
+ /** Whether {@link resolveScanStripper} has already reported a missing backend. */
980
+ let warnedNoStripper = false;
981
+
982
+ /**
983
+ * Resolve the TypeScript stripper backend once for a whole analysis run, or
984
+ * `null` when this runtime has none.
985
+ *
986
+ * `analyzeElision` runs outside the dev request handler too (`webjs check`,
987
+ * `webjs elision`), and `ensureStripper` is called on the dev path only, so
988
+ * this is where the no-backend case surfaces for the other entry points. It
989
+ * warns once per process rather than per file: the condition is a property of
990
+ * the runtime, so repeating it once per module would bury it.
991
+ *
992
+ * @returns {Promise<import('./ts-strip.js').Stripper | null>}
993
+ */
994
+ async function resolveScanStripper() {
995
+ try {
996
+ return await ensureStripper();
997
+ } catch (e) {
998
+ if (!warnedNoStripper) {
999
+ warnedNoStripper = true;
1000
+ console.warn(
1001
+ '[webjs] elision analysis could not resolve a TypeScript stripper, so type syntax ' +
1002
+ 'is not erased before the scans read a module. A type annotation can then read as ' +
1003
+ 'module-scope work, which ships modules that would otherwise be elided. Underlying: ' +
1004
+ (e && e.message ? e.message : String(e)),
1005
+ );
1006
+ }
1007
+ return null;
1008
+ }
1009
+ }
1010
+
874
1011
  /**
875
1012
  * Compute the set of component FILES whose browser download can be
876
1013
  * elided. A file is elidable only when every component it defines is
@@ -978,6 +1115,11 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
978
1115
  for (const v of vs) if (!appDir || v.startsWith(appDir)) allFiles.add(v);
979
1116
  }
980
1117
 
1118
+ // Resolve the stripper ONCE for the run: the backend is a property of the
1119
+ // runtime, not of a file, and the no-backend case must be reported rather
1120
+ // than swallowed per module (see `eraseTypesForScan`).
1121
+ const scanStripper = await resolveScanStripper();
1122
+
981
1123
  for (const file of allFiles) {
982
1124
  if (SERVER_FILE_RE.test(file)) { serverFiles.add(file); continue; }
983
1125
  let src;
@@ -989,6 +1131,11 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
989
1131
  continue;
990
1132
  }
991
1133
  if (typeof src !== 'string') continue;
1134
+ // Erase type syntax first, so every scan below reads the code that RUNS
1135
+ // (#1423). This is the single point where the analyser reads a file, so
1136
+ // doing it here covers the module-scope, template, import, and component
1137
+ // scans at once.
1138
+ src = eraseTypesForScan(scanStripper, file, src);
992
1139
  // Mask comments once for every signal scan below (#179): a `<tag>`, an
993
1140
  // `@event`, a browser global, an `import`, or a `whenDefined` written in a
994
1141
  // comment must not register as a real signal. String and template content