@webjsdev/server 0.8.49 → 0.8.50

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.49",
3
+ "version": "0.8.50",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/check.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  matchClosingBrace,
8
8
  parsePropEntries,
9
9
  } from './js-scan.js';
10
- import { buildModuleGraph, transitiveDeps } from './module-graph.js';
10
+ import { buildModuleGraph, transitiveDeps, resolveImport } from './module-graph.js';
11
11
  import { scanComponents } from './component-scanner.js';
12
12
  import { buildRouteTable } from './router.js';
13
13
  import { analyzeElision } from './component-elision.js';
@@ -144,6 +144,11 @@ export const RULES = [
144
144
  description:
145
145
  'Flags a template interpolation (`${...}`) placed as a child of a `<style>` or `<script>` element inside a COMPONENT `html` template. Raw-text elements are an SSR/client asymmetry trap: the server renderer emits the interpolated content, but the client renderer drops it (a raw-text hole is a `noop`, since the compile cache is keyed on the static strings), so the element renders correctly on the server and then wipes to empty on hydration. Scoped to components (files with a `WebComponent` class), which hydrate; pages and layouts render server-only and never hydrate, so a page interpolating a `css` result into a `<style>` is a legitimate pattern and is not flagged. In a component, author scoped CSS with `static styles` (shadow DOM) or a `css` template. Found dogfooding a tic-tac-toe app (#845): a `<style>${STYLE}</style>` painted at SSR then vanished on hydrate.',
146
146
  },
147
+ {
148
+ name: 'no-missing-local-import',
149
+ description:
150
+ 'Flags a NAMED value import of a symbol that a resolvable app-internal module does not export (`import { todos } from \'#db/schema.server.ts\'` when the schema no longer exports `todos`). The binding is `undefined` at runtime and crashes on first use, yet the rest of `webjs check` (elision-based, it does not compile types) misses it, so a schema swap that orphans a gallery module can pass `check` while `typecheck` is red. This is the runtime-crash class `check` exists to catch, filling that gap so an agent running only `check` cannot ship a broken import. Deliberately conservative to never false-positive on a valid app: it only inspects app-internal specifiers (a relative `./` path or a `#` alias) that resolve to a file in the app, only NAMED value imports (a `type` import, a default import, a namespace import, and any bare / `node:` / npm-package specifier are skipped), and it treats a module as UNKNOWABLE (never flags imports from it) when its exports are not fully enumerable: a `export * from ...` star re-export, a destructuring `export const { a } = ...`, or a multi-declarator `export const a = .., b = ..`. It reads names from `export function/class/const/let/var/type/interface/enum` and from `export { a, b as c }` / `export { x } from ...` clauses (the alias after `as` is the exported name). A re-export barrel therefore resolves correctly, and a `\'use server\'` action file exports its function names normally. Found dogfooding a tic-tac-toe app: dropping the example `todos` table left a gallery module importing it, green under `check` but red under `typecheck`.',
151
+ },
147
152
  ];
148
153
 
149
154
  /** Set of all known rule names for fast lookup. */
@@ -379,6 +384,112 @@ function findBrowserMemberUses(code) {
379
384
  return out;
380
385
  }
381
386
 
387
+ /**
388
+ * True if any `export const/let/var` declares more than one binding
389
+ * (`export const a = 1, b = 2`), which the single-name collector would
390
+ * under-count. Depth-aware: a comma inside an initializer (`f(a, b)`, `[a, b]`,
391
+ * `{ a, b }`) does not count, only a top-level comma before the statement end.
392
+ * Bailing the whole module on this errs toward a false negative (safe), never a
393
+ * false positive. Runs on the string/comment-redacted `scan`.
394
+ * @param {string} scan
395
+ * @returns {boolean}
396
+ */
397
+ function hasMultiDeclaratorExport(scan) {
398
+ const re = /\bexport\s+(?:const|let|var)\b/g;
399
+ let m;
400
+ while ((m = re.exec(scan))) {
401
+ let depth = 0; // () [] {} nesting
402
+ let angle = 0; // <> generics, tracked ONLY inside a type annotation
403
+ let seenEq = false; // passed this declarator's `=` (now in the initializer)
404
+ let inType = false; // inside a `: Type` annotation (before the `=`)
405
+ for (let i = m.index + m[0].length; i < scan.length; i++) {
406
+ const ch = scan[i];
407
+ if (ch === '(' || ch === '[' || ch === '{') { depth++; continue; }
408
+ if (ch === ')' || ch === ']' || ch === '}') { if (depth === 0) break; depth--; continue; }
409
+ if (depth !== 0) continue;
410
+ if (ch === ';') break;
411
+ // A plain `=` ends the type annotation and enters the initializer. `<=`,
412
+ // `>=`, `=>`, `==` are harmless here (seenEq only latches true).
413
+ if (ch === '=') { seenEq = true; inType = false; angle = 0; continue; }
414
+ // A `:` BEFORE the `=` opens the type annotation. A `:` after `=` (a
415
+ // ternary in the initializer) is NOT a type and must not suppress a real
416
+ // declarator comma.
417
+ if (ch === ':' && !seenEq) { inType = true; continue; }
418
+ // Inside a type annotation `<` / `>` are generic delimiters (a comparison
419
+ // only appears in the initializer, after `=`). Track their depth so a
420
+ // generic's comma (`Map<string, number>`) is skipped while a real
421
+ // declarator comma (`a: number, b: number`) still counts. A `>` that is
422
+ // part of a `=>` function-type arrow is not a generic close.
423
+ if (inType && ch === '<') { angle++; continue; }
424
+ if (inType && ch === '>' && scan[i - 1] !== '=') { if (angle > 0) angle--; continue; }
425
+ // A top-level comma starts a second declarator UNLESS it is a generic's
426
+ // comma inside a type annotation.
427
+ if (ch === ',' && !(inType && angle > 0)) return true;
428
+ }
429
+ }
430
+ return false;
431
+ }
432
+
433
+ /**
434
+ * Enumerate a module's provable named exports from its redacted `scan`, for the
435
+ * `no-missing-local-import` rule. Returns `null` when the export list is NOT
436
+ * fully enumerable (a `export *` star re-export, a destructuring export, or a
437
+ * multi-declarator `export const a = .., b = ..`), which tells the rule to treat
438
+ * the module as unknowable and flag nothing from it. Otherwise returns the set
439
+ * of exported names (the alias after `as` for a clause, and type/interface/enum
440
+ * names too, so a value import of a type is not falsely flagged; tsc owns that).
441
+ * @param {string} scan string/template/comment-redacted source
442
+ * @returns {Set<string>|null}
443
+ */
444
+ function enumerableExports(scan) {
445
+ // Unknowable export shapes: bail so the rule never false-positives.
446
+ if (/\bexport\s*\*/.test(scan)) return null; // export * from
447
+ if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(scan)) return null; // destructuring export
448
+ if (hasMultiDeclaratorExport(scan)) return null; // export const a=1, b=2
449
+ const names = new Set();
450
+ let m;
451
+ const collect = (re) => { while ((m = re.exec(scan))) names.add(m[1]); };
452
+ collect(/\bexport\s+(?:async\s+)?function\b\s*\*?\s*([A-Za-z_$][\w$]*)/g);
453
+ collect(/\bexport\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g);
454
+ collect(/\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g);
455
+ collect(/\bexport\s+(?:type|interface|enum|namespace)\s+([A-Za-z_$][\w$]*)/g);
456
+ // A named-default import (`import { default as Foo }`) is legal against an
457
+ // `export default` module, so record the `default` name when present.
458
+ if (/\bexport\s+default\b/.test(scan)) names.add('default');
459
+ // `export { a, b as c }` / `export type { ... }` / `export { x } from '...'`:
460
+ // the EXPORTED name is the alias after `as`, else the bare name.
461
+ const reClause = /\bexport\s+(?:type\s+)?\{([^}]*)\}/g;
462
+ while ((m = reClause.exec(scan))) {
463
+ for (let part of m[1].split(',')) {
464
+ part = part.trim().replace(/^type\s+/, '');
465
+ if (!part) continue;
466
+ const name = part.split(/\s+as\s+/).pop().trim();
467
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
468
+ }
469
+ }
470
+ return names;
471
+ }
472
+
473
+ /**
474
+ * The NAMED VALUE names a single `import { ... } from` clause pulls in, for the
475
+ * `no-missing-local-import` rule. Returns `null` for an `import type { ... }`
476
+ * (all type, skip), and `[]` for a default / namespace / side-effect import
477
+ * (nothing named to verify). A per-specifier inline `type` marker is dropped,
478
+ * and for `a as b` the IMPORTED name is `a` (what the target must export).
479
+ * @param {string} clause the text between `import` and `from`
480
+ * @returns {string[]|null}
481
+ */
482
+ function importedValueNames(clause) {
483
+ if (/^\s*type\b/.test(clause)) return null; // import type { ... }
484
+ const brace = clause.match(/\{([^}]*)\}/);
485
+ if (!brace) return []; // default / namespace only
486
+ return brace[1].split(',')
487
+ .map((s) => s.trim())
488
+ .filter((s) => s && !/^type\s/.test(s))
489
+ .map((s) => s.split(/\s+as\s+/)[0].trim())
490
+ .filter((n) => /^[A-Za-z_$][\w$]*$/.test(n));
491
+ }
492
+
382
493
  /**
383
494
  * Scan a WebJs app directory and report convention violations.
384
495
  *
@@ -1125,6 +1236,59 @@ export async function checkConventions(appDir) {
1125
1236
  // directly, so it is not elided and ships the server import.
1126
1237
  await checkServerImportInBrowserModule(appDir, violations);
1127
1238
 
1239
+ // --- Rule: no-missing-local-import ---
1240
+ // A named value import of a symbol an app-internal module does not export is a
1241
+ // runtime crash (the binding is undefined) that the elision-based checks miss,
1242
+ // so `check` can stay green while `typecheck` is red (a dropped schema table
1243
+ // orphaning a gallery module is the motivating dogfood case). Conservative by
1244
+ // construction: only app-internal specifiers resolving to a known app file,
1245
+ // only named value imports, and only when the target's exports are fully
1246
+ // enumerable (see enumerableExports / importedValueNames).
1247
+ {
1248
+ // Fully-blanked view (blankStrings=true): string AND template AND comment
1249
+ // bodies blank to spaces, position-preserving. The default `scan` keeps
1250
+ // plain-string bodies VERBATIM (so callers can read `register('tag')`),
1251
+ // which would let an `import`/`export` inside a string be matched, so use
1252
+ // the fully-blanked view for both the export map and the import scan. The
1253
+ // real specifier is read back from `content` at the same (length-preserved)
1254
+ // offset.
1255
+ const maskedByAbs = new Map();
1256
+ for (const f of files) maskedByAbs.set(f.abs, redactStringsAndTemplates(f.content, true));
1257
+ const exportsByAbs = new Map();
1258
+ for (const f of files) exportsByAbs.set(f.abs, enumerableExports(maskedByAbs.get(f.abs)));
1259
+ // `import\s+` excludes `import.meta` and a dynamic `import(`. The clause is
1260
+ // `[^'";]*?` so it cannot swallow a side-effect `import '...'` (its quote) or
1261
+ // bridge across a `;` into the next statement's `from`.
1262
+ const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
1263
+ for (const { abs, rel, content } of files) {
1264
+ const masked = maskedByAbs.get(abs);
1265
+ reImport.lastIndex = 0;
1266
+ let m;
1267
+ while ((m = reImport.exec(masked))) {
1268
+ const quote = m[2];
1269
+ const specStart = reImport.lastIndex; // just past the opening quote
1270
+ const specEnd = content.indexOf(quote, specStart);
1271
+ if (specEnd < 0) continue;
1272
+ const spec = content.slice(specStart, specEnd);
1273
+ if (!/^(?:\.|#)/.test(spec)) continue; // app-internal only (relative or #alias)
1274
+ const names = importedValueNames(m[1]);
1275
+ if (!names || names.length === 0) continue;
1276
+ const target = resolveImport(spec, abs, appDir);
1277
+ const exp = exportsByAbs.get(target);
1278
+ if (exp == null) continue; // not an app file, or exports not enumerable
1279
+ for (const name of names) {
1280
+ if (exp.has(name)) continue;
1281
+ violations.push({
1282
+ rule: 'no-missing-local-import',
1283
+ file: rel,
1284
+ message: `Imports \`${name}\` from \`${spec}\`, but that module does not export \`${name}\`. The binding is undefined at runtime and crashes on first use (often a renamed or removed export, e.g. a dropped schema table).`,
1285
+ fix: `Add \`${name}\` to \`${spec}\`, correct the imported name, or remove the import.`,
1286
+ });
1287
+ }
1288
+ }
1289
+ }
1290
+ }
1291
+
1128
1292
  return violations;
1129
1293
  }
1130
1294
 
package/src/ssr.js CHANGED
@@ -1528,6 +1528,7 @@ function wrapHead(opts) {
1528
1528
  <html lang="en">
1529
1529
  <head>
1530
1530
  <meta charset="utf-8">
1531
+ <style${n}>@layer webjs-host{:where([data-wj-host]){display:block}:where([data-wj-host][hidden]:not([hidden='until-found'])){display:none}}</style>
1531
1532
  ${opts.nonce ? `<meta name="csp-nonce" content="${escapeAttr(opts.nonce)}">` : ''}
1532
1533
  ${metaTags.join('\n')}
1533
1534
  <title>${escapeHtml(title)}</title>
package/src/vendor.js CHANGED
@@ -1176,9 +1176,16 @@ async function pruneOrphans(appDir, expected) {
1176
1176
  * instead. When the app has zero bare-specifier imports at all
1177
1177
  * (scanned source produced nothing), returns
1178
1178
  * `{ pins: [], pruned: [], downloaded: 0, noBareImports: true }`
1179
- * WITHOUT writing the pin file. Callers that need to surface a
1180
- * non-zero exit code key off `failed` or `noBareImports`; both
1181
- * are absent on the success path.
1179
+ * WITHOUT writing the pin file. When the scan DID find specifiers but
1180
+ * every one was dropped because no local version resolved (the package
1181
+ * is not installed under `node_modules`), returns
1182
+ * `{ pins: [], pruned: [], downloaded: 0, droppedUnresolvable: [...] }`
1183
+ * (also WITHOUT a pin file), so the caller can name the found-but-
1184
+ * uninstalled specifiers instead of claiming there were none. A
1185
+ * partial pin (some resolved, some dropped) still writes the file and
1186
+ * carries `droppedUnresolvable` alongside `pins`. Callers that need to
1187
+ * surface a non-zero exit code key off `failed`, `noBareImports`, or an
1188
+ * all-dropped `droppedUnresolvable`; all are absent on a clean success.
1182
1189
  *
1183
1190
  * The `from` option mirrors importmap-rails's `bin/importmap pin foo
1184
1191
  * --from jsdelivr`. Default `jspm` resolves to jspm.io; other values
@@ -1196,6 +1203,7 @@ async function pruneOrphans(appDir, expected) {
1196
1203
  * provider?: string,
1197
1204
  * failed?: boolean,
1198
1205
  * noBareImports?: boolean,
1206
+ * droppedUnresolvable?: string[],
1199
1207
  * attemptedInstalls?: string[],
1200
1208
  * }>}
1201
1209
  */
@@ -1224,12 +1232,23 @@ export async function pinAll(appDir, opts = {}) {
1224
1232
  * @type {Map<string, { pkg: string, version: string, subpath: string }>}
1225
1233
  */
1226
1234
  const partsByInstall = new Map();
1235
+ /**
1236
+ * Bare specifiers the scan FOUND but that were dropped because no local
1237
+ * version could be resolved (the package is not installed under the app's
1238
+ * `node_modules`). Kept distinct from "scan found nothing" so the CLI can
1239
+ * name them and point at the remedy instead of claiming there were none.
1240
+ * @type {string[]}
1241
+ */
1242
+ const droppedUnresolvable = [];
1227
1243
  for (const spec of bare) {
1228
1244
  if (BUILTIN.has(spec)) continue;
1229
1245
  const pkg = extractPackageName(spec);
1230
1246
  if (!pkg || BUILTIN.has(pkg) || FRAMEWORK_SERVER_ONLY.has(pkg)) continue;
1231
1247
  const version = getPackageVersion(pkg, appDir);
1232
- if (!version) continue;
1248
+ if (!version) {
1249
+ droppedUnresolvable.push(spec);
1250
+ continue;
1251
+ }
1233
1252
  const subpath = spec.slice(pkg.length);
1234
1253
  const install = `${pkg}@${version}${subpath}`;
1235
1254
  installs.push(install);
@@ -1350,12 +1369,22 @@ export async function pinAll(appDir, opts = {}) {
1350
1369
  // -imports filter, so the file exists but does nothing. The CLI
1351
1370
  // surfaces this as a clearer "no bare imports found" message.
1352
1371
  if (installs.length === 0) {
1372
+ // Distinguish the two empty-set causes the CLI must report differently:
1373
+ // the scan genuinely found nothing (noBareImports) vs it found specifiers
1374
+ // that were all dropped for a missing local version (droppedUnresolvable).
1375
+ if (droppedUnresolvable.length > 0) {
1376
+ return { pins, pruned: [], downloaded, droppedUnresolvable, provider: from };
1377
+ }
1353
1378
  return { pins, pruned: [], downloaded, noBareImports: true, provider: from };
1354
1379
  }
1355
1380
 
1356
1381
  await writePinFile(appDir, importmap, integrity, from);
1357
1382
  const pruned = await pruneOrphans(appDir, expected);
1358
- return { pins, pruned, downloaded, provider: from };
1383
+ // Some specifiers may have pinned while others were dropped for a missing
1384
+ // version; surface the dropped ones so a partial pin is not silent.
1385
+ return droppedUnresolvable.length > 0
1386
+ ? { pins, pruned, downloaded, provider: from, droppedUnresolvable }
1387
+ : { pins, pruned, downloaded, provider: from };
1359
1388
  }
1360
1389
 
1361
1390
  /**