diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 852bd1be..c8e2b717 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -26,6 +26,10 @@ const USAGE = `webjs commands: webjs ui AI-first component library CLI (init / add / list / view / diff / info) Requires @webjsdev/ui installed in the project + webjs vendor warm Pre-bundle every bare-specifier npm dep into the in-memory + vendor cache, so the first user request after server boot + pays no esbuild latency. Optional; add to your prestart hook + for production cold-start mitigation. No disk artifacts. webjs help Show this help`; /** @param {string[]} args */ @@ -266,6 +270,48 @@ Full docs: https://docs.webjs.com`); await scaffoldApp(name, process.cwd(), { template, install: !noInstall }); break; } + case 'vendor': { + const sub = rest[0]; + if (sub !== 'warm') { + console.error(`Unknown vendor subcommand: ${sub || '(none)'}\n` + + `Usage:\n` + + ` webjs vendor warm Pre-bundle bare-import deps into in-memory cache.\n` + + ` Run in prestart to eliminate first-user latency on production cold start.`); + process.exit(1); + } + // Pre-bundle every bare-import package into the in-memory vendor + // cache before the server starts accepting connections. After + // this completes, the first user request hits the cache and + // returns in ~1ms instead of paying esbuild's ~50-300ms per + // package. Off by default; users opt in via prestart hook. + const { scanBareImports, bundlePackage, getPackageVersion, extractPackageName } = + await import('@webjsdev/server'); + const appDir = process.cwd(); + console.log(`Warming vendor cache from ${appDir}...`); + const bare = await scanBareImports(appDir); + let okCount = 0; + let failCount = 0; + for (const spec of bare) { + const pkgName = extractPackageName(spec); + if (!pkgName) continue; + const version = getPackageVersion(pkgName, appDir); + if (!version) { + console.error(` ${pkgName.padEnd(40)} skipped (version not resolvable)`); + continue; + } + const code = await bundlePackage(pkgName, version, appDir, false); + if (code) { + console.log(` ${pkgName}@${version}`.padEnd(42) + ` ${(code.length / 1024).toFixed(1)} KB`); + okCount++; + } else { + console.error(` ${pkgName}@${version}`.padEnd(42) + ` FAILED`); + failCount++; + } + } + console.log(`Warmed ${okCount} package${okCount === 1 ? '' : 's'}` + + (failCount ? `, ${failCount} failed (probably server-only deps; safe to ignore).` : '.')); + break; + } case 'help': case undefined: console.log(USAGE); diff --git a/packages/server/index.js b/packages/server/index.js index b0662935..17f3ca35 100644 --- a/packages/server/index.js +++ b/packages/server/index.js @@ -11,7 +11,7 @@ export { invokeAction, } from './src/actions.js'; export { buildImportMap, importMapTag, setVendorEntries } from './src/importmap.js'; -export { scanBareImports, extractPackageName, bundlePackage, vendorImportMapEntries, clearVendorCache, serveVendorBundle } from './src/vendor.js'; +export { scanBareImports, extractPackageName, bundlePackage, vendorImportMapEntries, clearVendorCache, serveVendorBundle, getPackageVersion, parseVendorId } from './src/vendor.js'; export { buildModuleGraph, transitiveDeps } from './src/module-graph.js'; export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js'; export { headers, cookies, getRequest, withRequest } from './src/context.js'; diff --git a/packages/server/src/check.js b/packages/server/src/check.js index a0816a62..1ba7653a 100644 --- a/packages/server/src/check.js +++ b/packages/server/src/check.js @@ -99,6 +99,11 @@ export const RULES = [ description: 'Files that declare the `\'use server\'` directive at the top must also have the `.server.{js,ts,mts,mjs}` extension. The two markers are complementary, not interchangeable: `.server.ts` is the path-level boundary that triggers source protection by the file router; `\'use server\'` is the semantic opt-in that registers exports as RPC-callable from client code. A `\'use server\'` directive without the extension is silently ignored: the file is served to the browser as plain source, exports are NOT registered as RPC, and code the developer expects to run on the server actually runs in the browser. Rename the file to add the `.server.` infix.', }, + { + name: 'no-non-erasable-typescript', + description: + 'Scans .ts / .mts source for the four non-erasable TypeScript constructs (enum declarations, namespace blocks with value statements, constructor parameter properties, and `import = require`) that the framework\'s native type-stripper rejects. Files hitting these take the esbuild fallback path which costs roughly 3x wire bytes (sourcemap overhead) and loses byte-exact stack-trace positions. Companion to `erasable-typescript-only`: that rule checks the tsconfig flag, this rule checks the actual source. Both run by default so the flag check catches violations early in the editor while the source scan catches violations even if the tsconfig flag is missing or the rule is bypassed. Skips node_modules, dist, build, .git, .next, and _private folders.', + }, ]; /** Set of all known rule names for fast lookup. */ @@ -781,15 +786,16 @@ export async function checkConventions(appDir, opts) { } // --- Rule: erasable-typescript-only --- - // The dev server's primary type-stripper is Node's built-in + // The dev server's type-stripper is Node's built-in // module.stripTypeScriptTypes, which rejects non-erasable TS (enum, // namespace with values, constructor parameter properties, legacy - // decorators, `import = require`). The fallback path is esbuild + - // inline sourcemap, which is a real ~3x wire-byte hit on every .ts - // request that takes it. Enforce TS-side rejection of those patterns - // via `compilerOptions.erasableSyntaxOnly: true` in tsconfig.json so - // violations surface as red squiggles in the editor before they ever - // hit the dev server. + // decorators, `import = require`). There is no fallback: non-erasable + // syntax takes the slower esbuild fallback path. Enforce TS-side + // rejection of those patterns via `compilerOptions.erasableSyntaxOnly: + // true` in tsconfig.json so violations surface as red squiggles in + // the editor before they ever hit the dev server. The companion + // no-non-erasable-typescript rule (below) catches violations even if + // the tsconfig flag is unset. if (isRuleEnabled('erasable-typescript-only', overrides)) { let tsconfigContent = null; try { @@ -825,6 +831,93 @@ export async function checkConventions(appDir, opts) { } } + // --- Rule: no-non-erasable-typescript --- + // Scans .ts source for the four non-erasable TypeScript constructs + // that the runtime stripper rejects. Complement to + // erasable-typescript-only: the flag check catches the case where + // the user opts into the tsconfig flag; this scan catches the + // case where the flag is missing OR the user has bypassed it and + // written offending syntax anyway. Both rules ship enabled by + // default so violators get the strongest signal possible. + if (isRuleEnabled('no-non-erasable-typescript', overrides)) { + /** @type {Array<{ name: string, regex: RegExp, fix: string }>} */ + const NON_ERASABLE_PATTERNS = [ + { + name: 'enum', + // Matches `enum X {`, `export enum X {`, `const enum X {`, + // `declare enum X {`. Requires uppercase first letter on the + // identifier to avoid matching variables literally named "enum" + // in user code (rare but possible). + regex: /^[ \t]*(?:export[ \t]+)?(?:declare[ \t]+)?(?:const[ \t]+)?enum[ \t]+[A-Z]\w*[ \t]*\{/m, + fix: 'Replace `enum Foo { A, B }` with `const Foo = { A: "A", B: "B" } as const; type Foo = typeof Foo[keyof typeof Foo];`.', + }, + { + name: 'namespace with values', + // Matches `namespace Foo { ... ... }` at top + // level. Type-only namespaces (which ARE erasable) won't contain + // `let|const|var|function|class` as statements, so this catches + // only the value-carrying form. False positives possible for + // type-only namespaces that contain those words in type aliases; + // accept this as a soft warning. + regex: /^[ \t]*(?:export[ \t]+)?namespace[ \t]+\w+[ \t]*\{[\s\S]*?\b(?:let|const|var|function|class)\b/m, + fix: 'Replace `namespace Foo { export const x = 1 }` with `export const Foo = { x: 1 } as const;` or split the contents into separate modules.', + }, + { + name: 'constructor parameter property', + // Matches `constructor(public x: T)`, `constructor(private foo, ...)`, + // `constructor(readonly bar)`. Looks for one of the four access + // modifiers immediately followed by an identifier inside the + // constructor's parameter list. + regex: /constructor[ \t]*\([^)]*\b(?:public|private|protected|readonly)[ \t]+\w+/, + fix: 'Replace `constructor(public x: number)` with `x: number; constructor(x: number) { this.x = x; }`. The reactive-props-use-declare rule has the framework-specific shape: `declare x: number;` (no value) plus the assignment in the constructor body.', + }, + { + name: 'import = require', + // TypeScript-style CommonJS import. Catches `import foo = + // require("bar")` and `export import foo = require("bar")`. + regex: /^[ \t]*(?:export[ \t]+)?import[ \t]+\w+[ \t]*=[ \t]*require[ \t]*\(/m, + fix: 'Replace `import foo = require("bar")` with `import * as foo from "bar"` or `import { something } from "bar"`.', + }, + ]; + + // Walk every .ts / .mts file under appDir, skipping node_modules, + // build outputs, version control, and the framework's own private + // folders. Match the conventional excludes that fs-walk.js's caller + // contract expects. + for await (const abs of walk(appDir, (p) => /\.m?ts$/.test(p))) { + // Skip anything inside node_modules or common build / cache dirs. + const relPath = relative(appDir, abs); + if ( + relPath.includes('node_modules' + sep) || + relPath.startsWith('dist' + sep) || + relPath.startsWith('build' + sep) || + relPath.startsWith('.next' + sep) || + relPath.startsWith('.git' + sep) || + relPath.split(sep).some((s) => s.startsWith('_')) + ) { + continue; + } + let content; + try { + content = await readFile(abs, 'utf8'); + } catch { + continue; + } + for (const { name, regex, fix } of NON_ERASABLE_PATTERNS) { + const m = content.match(regex); + if (m && typeof m.index === 'number') { + const line = content.slice(0, m.index).split('\n').length; + violations.push({ + rule: 'no-non-erasable-typescript', + file: relPath, + message: `Non-erasable TypeScript construct (${name}) detected at line ${line}. The framework's native type-stripper rejects this; the file falls back to esbuild + inline sourcemap, costing roughly 3x wire bytes per request and losing byte-exact stack-trace positions.`, + fix, + }); + } + } + } + } + // --- Rule: use-server-needs-extension --- // Catch files that declare `'use server'` at the top but lack the // `.server.{js,ts}` extension. Under the two-marker convention the diff --git a/packages/server/src/dev.js b/packages/server/src/dev.js index 8ccd24c5..c4a634aa 100644 --- a/packages/server/src/dev.js +++ b/packages/server/src/dev.js @@ -130,7 +130,7 @@ export async function createRequestHandler(opts) { // Scan for bare npm imports and register vendor import map entries. const bareImports = await scanBareImports(appDir); - setVendorEntries(vendorImportMapEntries(bareImports)); + setVendorEntries(vendorImportMapEntries(bareImports, appDir)); // Build module dependency graph for transitive preload hints. const moduleGraph = await buildModuleGraph(appDir); @@ -171,7 +171,7 @@ export async function createRequestHandler(opts) { // Re-scan bare imports and module graph on rebuild clearVendorCache(); state.bareImports = await scanBareImports(appDir); - setVendorEntries(vendorImportMapEntries(state.bareImports)); + setVendorEntries(vendorImportMapEntries(state.bareImports, appDir)); state.moduleGraph = await buildModuleGraph(appDir); // Re-scan components in case a new file was added or a tag renamed. await primeComponentRegistry(appDir); @@ -408,12 +408,14 @@ async function handleCore(req, ctx) { return fileResponse(abs, { dev, immutable: false }); } - // Vendor bundles: /__webjs/vendor/.js: generic auto-bundler - // (Vite-style optimizeDeps) for any bare npm import that webjs can't - // serve directly as ESM. + // Vendor bundles: /__webjs/vendor/@.js + // Generic auto-bundler (Vite-style optimizeDeps) for any bare npm + // import that webjs can't serve directly as ESM. URL includes the + // installed version so browser caches invalidate automatically on + // version bump. if (path.startsWith('/__webjs/vendor/') && path.endsWith('.js')) { - const pkgName = decodeURIComponent(path.slice('/__webjs/vendor/'.length, -'.js'.length)); - return serveVendorBundle(pkgName, appDir, dev); + const id = path.slice('/__webjs/vendor/'.length); + return serveVendorBundle(id, appDir, dev); } // Internal server-action RPC endpoint diff --git a/packages/server/src/vendor.js b/packages/server/src/vendor.js index 970e104a..ba6fdcbb 100644 --- a/packages/server/src/vendor.js +++ b/packages/server/src/vendor.js @@ -20,7 +20,8 @@ */ import { readFile, readdir, stat } from 'node:fs/promises'; -import { join, extname, sep } from 'node:path'; +import { readFileSync, realpathSync, existsSync } from 'node:fs'; +import { join, dirname, sep } from 'node:path'; import { createRequire } from 'node:module'; /** @@ -37,11 +38,20 @@ const VENDOR_CACHE_MAX = 100; const BUILTIN = new Set(['@webjsdev/core', '@webjsdev/core/', '@webjsdev/core/client-router']); /** - * Scan source files under `dir` for bare import specifiers. Returns a Set of - * package names (e.g. `'dayjs'`, `'@tanstack/query-core'`). + * Scan source files under `dir` for bare import specifiers reachable + * from the browser. Returns a Set of package names. * - * Only scans `.js`, `.ts`, `.mjs`, `.mts` files. Skips `node_modules`, - * `.webjs`, `public`, and `_private` directories. + * Excludes: + * - `node_modules`, `.webjs`, `public` directories + * - Any directory starting with `_` (webjs `_private/` convention) + * - `test/` and `tests/` (server-only by webjs convention) + * - Files whose name marks them as server-only: + * * `*.server.{js,ts,mjs,mts}` (path-level boundary) + * * `route.{js,ts,mjs,mts}` (file-router HTTP handler) + * * `middleware.{js,ts,mjs,mts}` (file-router middleware) + * - Any file whose first non-whitespace content is `'use server'` + * - `import type` statements (TypeScript erases them at compile time) + * - `import` strings inside `/* … *​/` block comments or `//` line comments * * @param {string} dir * @returns {Promise>} @@ -77,10 +87,40 @@ export function extractPackageName(spec) { return spec.split('/')[0]; } -/** @type {RegExp} */ -const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g; +// Matches `import { x } from 'pkg'`, `import 'pkg'`, `import * as x from 'pkg'`. +// The `(?!type\s)` negative lookahead skips `import type … from 'pkg'` +// because TypeScript type-only imports are fully erased at compile time +// and never reach the browser, so they must not enter the vendor pipeline. +const IMPORT_RE = /\bimport\s+(?!type\s)(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g; const DYNAMIC_IMPORT_RE = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g; +// Strip `/* … */` block comments and `// …` line comments before running +// the import regex. Comments in source files (JSDoc examples especially) +// frequently contain `import 'foo'` snippets that aren't real imports; +// without stripping, the scanner picks them up as bare specifiers and +// the vendor pipeline tries (and fails) to bundle them. +const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g; +const LINE_COMMENT_RE = /(^|[^:])\/\/.*$/gm; +function stripComments(src) { + return src.replace(BLOCK_COMMENT_RE, '').replace(LINE_COMMENT_RE, '$1'); +} + +/** + * Filename matches webjs's server-only file-router conventions. + * Returns true for `route.{ts,js,mjs,mts}` and + * `middleware.{ts,js,mjs,mts}`, plus any `.server.{ts,js,mjs,mts}` + * suffix file. These files never reach the browser, so their bare + * imports must not enter the vendor pipeline. + * + * @param {string} name basename of the file + */ +function isServerOnlyFile(name) { + if (/\.server\.(js|ts|mjs|mts)$/.test(name)) return true; + if (/^route\.(js|ts|mjs|mts)$/.test(name)) return true; + if (/^middleware\.(js|ts|mjs|mts)$/.test(name)) return true; + return false; +} + /** * @param {string} dir * @param {Set} found @@ -90,15 +130,24 @@ async function walk(dir, found) { try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { - if (e.name === 'node_modules' || e.name === '.webjs' || e.name === 'public' || e.name.startsWith('_')) continue; + // Skip directories that never contain browser-reachable code. + if ( + e.name === 'node_modules' || + e.name === '.webjs' || + e.name === 'public' || + e.name === 'test' || + e.name === 'tests' || + e.name.startsWith('_') + ) continue; const full = join(dir, e.name); if (e.isDirectory()) { await walk(full, found); - } else if (/\.(js|ts|mjs|mts)$/.test(e.name) && !e.name.endsWith('.server.ts') && !e.name.endsWith('.server.js')) { + } else if (/\.(js|ts|mjs|mts)$/.test(e.name) && !isServerOnlyFile(e.name)) { try { - const src = await readFile(full, 'utf8'); - // Skip files with 'use server' pragma - if (src.trimStart().startsWith("'use server'") || src.trimStart().startsWith('"use server"')) continue; + const raw = await readFile(full, 'utf8'); + // Skip files with 'use server' pragma (their exports never reach the browser). + if (raw.trimStart().startsWith("'use server'") || raw.trimStart().startsWith('"use server"')) continue; + const src = stripComments(raw); for (const m of src.matchAll(IMPORT_RE)) { const pkg = extractPackageName(m[1]); if (pkg) found.add(pkg); @@ -112,16 +161,77 @@ async function walk(dir, found) { } } +/** + * Resolve a package's actual directory on disk, handling both direct + * installation and npm workspace hoisting. Returns null when the + * package isn't resolvable from `appDir`. + * + * @param {string} pkgName + * @param {string} appDir + * @returns {string | null} + */ +function resolvePackageDir(pkgName, appDir) { + try { + const require = createRequire(join(appDir, 'package.json')); + const entry = require.resolve(pkgName); + const parts = entry.split(sep); + const nmIdx = parts.lastIndexOf('node_modules'); + if (nmIdx < 0) { + // Workspace-symlinked dep resolved to source location. Walk up + // to find the package.json. + let dir = dirname(entry); + for (let i = 0; i < 8; i++) { + if (existsSync(join(dir, 'package.json'))) return realpathSync(dir); + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; + } + const segmentsAfterNm = pkgName.startsWith('@') ? 2 : 1; + const root = parts.slice(0, nmIdx + 1 + segmentsAfterNm).join(sep); + return realpathSync(root); + } catch { + return null; + } +} + +/** + * Read the installed version of a package from `node_modules// + * package.json`. Handles workspace hoisting and packages that lock + * down `./package.json` in their exports field (which break a naive + * `require.resolve('/package.json')`). + * + * @param {string} pkgName + * @param {string} appDir + * @returns {string | null} + */ +export function getPackageVersion(pkgName, appDir) { + const real = resolvePackageDir(pkgName, appDir); + if (!real) return null; + try { + const pkg = JSON.parse(readFileSync(join(real, 'package.json'), 'utf8')); + return pkg.version || null; + } catch { + return null; + } +} + /** * Bundle an npm package into a single ESM file for the browser. + * Cache is keyed by `@` so version bumps naturally + * miss and re-bundle without colliding with the previous version's + * bytes. * * @param {string} pkgName e.g. `'dayjs'` + * @param {string} version installed version (e.g. `'1.11.13'`) * @param {string} appDir app root for resolving node_modules * @param {boolean} dev * @returns {Promise} bundled JS source, or null if not found */ -export async function bundlePackage(pkgName, appDir, dev) { - const cached = vendorCache.get(pkgName); +export async function bundlePackage(pkgName, version, appDir, dev) { + const cacheKey = `${pkgName}@${version}`; + const cached = vendorCache.get(cacheKey); if (cached) return cached; let build; @@ -154,7 +264,7 @@ export async function bundlePackage(pkgName, appDir, dev) { const oldest = vendorCache.keys().next().value; vendorCache.delete(oldest); } - vendorCache.set(pkgName, code); + vendorCache.set(cacheKey, code); return code; } catch (e) { // Build failed (native module, server-only dep, etc.): skip silently @@ -165,19 +275,59 @@ export async function bundlePackage(pkgName, appDir, dev) { /** * Build extra import map entries for discovered bare imports. * + * URL shape: `/__webjs/vendor/@.js`. Scoped + * packages encode `/` as `--` (filesystem and URL safe, mirrors the + * Rails 7 + importmap-rails vendor convention). Including the version + * in the URL means browser caches invalidate automatically on every + * version bump (no more stale-bundle bug after `npm install pkg@new`). + * + * Packages whose version can't be resolved from `appDir/node_modules/` + * are skipped (no importmap entry emitted). The browser will surface + * an "unresolved bare specifier" error at first import, which is the + * right signal that the package isn't installed. + * * @param {Set} bareImports from scanBareImports() + * @param {string} appDir * @returns {Record} */ -export function vendorImportMapEntries(bareImports) { +export function vendorImportMapEntries(bareImports, appDir) { /** @type {Record} */ const entries = {}; for (const pkg of bareImports) { if (BUILTIN.has(pkg)) continue; - entries[pkg] = `/__webjs/vendor/${encodeURIComponent(pkg)}.js`; + const version = getPackageVersion(pkg, appDir); + if (!version) continue; + const safeName = pkg.replace(/\//g, '--'); + entries[pkg] = `/__webjs/vendor/${safeName}@${version}.js`; } return entries; } +/** + * Parse a vendor URL id (the URL path segment after `/__webjs/vendor/` + * with the trailing `.js` stripped) back into a `{ pkgName, version }` + * pair. Inverse of `vendorImportMapEntries`'s URL generation. + * + * Examples: + * `dayjs@1.11.13` → { pkgName: 'dayjs', version: '1.11.13' } + * `@hotwired--turbo@8.0.0` → { pkgName: '@hotwired/turbo', version: '8.0.0' } + * + * Returns null for malformed ids (no `@` segment). + * + * @param {string} id URL path after `/__webjs/vendor/`, without `.js` suffix + * @returns {{ pkgName: string, version: string } | null} + */ +export function parseVendorId(id) { + const stem = id.endsWith('.js') ? id.slice(0, -3) : id; + const atIdx = stem.lastIndexOf('@'); + if (atIdx <= 0) return null; + const rawName = stem.slice(0, atIdx); + const version = stem.slice(atIdx + 1); + if (!version) return null; + const pkgName = rawName.replace(/--/g, '/'); + return { pkgName, version }; +} + /** * Clear the vendor cache (called on file-watcher rebuild so newly added * deps are picked up on next request). @@ -187,17 +337,29 @@ export function clearVendorCache() { } /** - * Serve a vendor bundle for the given package name. + * Serve a vendor bundle in response to a `/__webjs/vendor/.js` + * request. The id encodes both package name and version (see + * `parseVendorId`). Malformed ids return 404. Cache headers use + * `immutable` in production because the version baked into the URL + * guarantees content addresses are stable: a new version means a new + * URL, not a new payload behind the old URL. * - * @param {string} pkgName + * @param {string} id URL path after `/__webjs/vendor/`, including `.js` * @param {string} appDir * @param {boolean} dev * @returns {Promise} */ -export async function serveVendorBundle(pkgName, appDir, dev) { - const code = await bundlePackage(pkgName, appDir, dev); +export async function serveVendorBundle(id, appDir, dev) { + const parsed = parseVendorId(id); + if (!parsed) { + return new Response(`/* malformed vendor id: ${id} */`, { + status: 404, + headers: { 'content-type': 'application/javascript; charset=utf-8' }, + }); + } + const code = await bundlePackage(parsed.pkgName, parsed.version, appDir, dev); if (code == null) { - return new Response(`/* vendor bundle failed for ${pkgName} */`, { + return new Response(`/* vendor bundle failed for ${parsed.pkgName}@${parsed.version} */`, { status: 404, headers: { 'content-type': 'application/javascript; charset=utf-8' }, }); diff --git a/packages/server/test/check/check.test.js b/packages/server/test/check/check.test.js index b1dcd4d9..45ba7577 100644 --- a/packages/server/test/check/check.test.js +++ b/packages/server/test/check/check.test.js @@ -11,6 +11,136 @@ async function makeTempApp() { return dir; } +async function writeFileEnsureDir(filePath, contents) { + const dir = filePath.slice(0, filePath.lastIndexOf('/')); + await mkdir(dir, { recursive: true }); + await writeFile(filePath, contents); +} + +/** + * Tests for the no-non-erasable-typescript rule. Scans .ts source + * for the four constructs the framework's type-stripper rejects: + * enum, namespace with values, constructor parameter properties, + * `import = require`. Each test plants one offender and asserts + * the rule flags it. + */ + +test('no-non-erasable-typescript: flags enum declaration', async () => { + const appDir = await makeTempApp(); + try { + await writeFileEnsureDir( + join(appDir, 'modules', 'auth', 'types.ts'), + `export enum Status { Active = 'active', Inactive = 'inactive' }\n`, + ); + const violations = await checkConventions(appDir); + const v = violations.find((v) => v.rule === 'no-non-erasable-typescript' && v.file.includes('types.ts')); + assert.ok(v, 'expected enum to be flagged'); + assert.ok(v.message.includes('enum'), 'message should name the pattern'); + } finally { + await rm(appDir, { recursive: true, force: true }); + } +}); + +test('no-non-erasable-typescript: flags constructor parameter property', async () => { + const appDir = await makeTempApp(); + try { + await writeFileEnsureDir( + join(appDir, 'lib', 'box.ts'), + `export class Box { + constructor(public readonly width: number, public readonly height: number) {} +}\n`, + ); + const violations = await checkConventions(appDir); + const v = violations.find((v) => v.rule === 'no-non-erasable-typescript' && v.file.includes('box.ts')); + assert.ok(v, 'expected parameter property to be flagged'); + assert.ok(v.message.includes('parameter property'), 'message should name the pattern'); + } finally { + await rm(appDir, { recursive: true, force: true }); + } +}); + +test('no-non-erasable-typescript: flags namespace with values', async () => { + const appDir = await makeTempApp(); + try { + await writeFileEnsureDir( + join(appDir, 'lib', 'ns.ts'), + `export namespace Utils { + export const VERSION = '1.0'; + export function bump() { return VERSION; } +}\n`, + ); + const violations = await checkConventions(appDir); + const v = violations.find((v) => v.rule === 'no-non-erasable-typescript' && v.file.includes('ns.ts')); + assert.ok(v, 'expected namespace with values to be flagged'); + assert.ok(v.message.includes('namespace'), 'message should name the pattern'); + } finally { + await rm(appDir, { recursive: true, force: true }); + } +}); + +test('no-non-erasable-typescript: flags import = require', async () => { + const appDir = await makeTempApp(); + try { + await writeFileEnsureDir( + join(appDir, 'lib', 'legacy.ts'), + `import legacy = require('legacy-module');\nexport { legacy };\n`, + ); + const violations = await checkConventions(appDir); + const v = violations.find((v) => v.rule === 'no-non-erasable-typescript' && v.file.includes('legacy.ts')); + assert.ok(v, 'expected import = require to be flagged'); + assert.ok(v.message.includes('import = require'), 'message should name the pattern'); + } finally { + await rm(appDir, { recursive: true, force: true }); + } +}); + +test('no-non-erasable-typescript: passes for clean erasable .ts file', async () => { + const appDir = await makeTempApp(); + try { + await writeFileEnsureDir( + join(appDir, 'lib', 'clean.ts'), + `export type Status = 'active' | 'inactive'; +export interface Box { width: number; height: number; } +export const STATUS: Record = { active: 1, inactive: 0 }; +export class Counter { + count: number; + constructor(initial: number) { this.count = initial; } + increment(): void { this.count++; } +}\n`, + ); + const violations = await checkConventions(appDir); + const v = violations.find((v) => v.rule === 'no-non-erasable-typescript' && v.file.includes('clean.ts')); + assert.equal(v, undefined, 'clean erasable code should not be flagged'); + } finally { + await rm(appDir, { recursive: true, force: true }); + } +}); + +test('no-non-erasable-typescript: skips node_modules and _private folders', async () => { + const appDir = await makeTempApp(); + try { + await writeFileEnsureDir( + join(appDir, 'node_modules', 'somepkg', 'index.ts'), + `export enum Skip { A, B }\n`, + ); + await writeFileEnsureDir( + join(appDir, '_private', 'helper.ts'), + `export enum AlsoSkip { A, B }\n`, + ); + await writeFileEnsureDir( + join(appDir, 'lib', 'caught.ts'), + `export enum Caught { A, B }\n`, + ); + const violations = await checkConventions(appDir); + const all = violations.filter((v) => v.rule === 'no-non-erasable-typescript'); + assert.equal(all.length, 1, `expected one violation, got ${all.length}: ${all.map(v => v.file).join(', ')}`); + assert.ok(all[0].file.includes('caught.ts')); + } finally { + await rm(appDir, { recursive: true, force: true }); + } +}); + + test('tag-name-has-hyphen: flags component without hyphen in tag', async () => { const appDir = await makeTempApp(); try { diff --git a/packages/server/test/dev/dev-handler.test.js b/packages/server/test/dev/dev-handler.test.js index d86a9917..12da51aa 100644 --- a/packages/server/test/dev/dev-handler.test.js +++ b/packages/server/test/dev/dev-handler.test.js @@ -89,14 +89,18 @@ test('handle: /__webjs/core/ refuses path traversal → 403', async () => { /* ------------ vendor bundles ------------ */ -test('handle: /__webjs/vendor/.js serves a built bundle for a known pkg', async () => { +test('handle: /__webjs/vendor/@.js serves a built bundle', async () => { // Use the repo root as appDir so node_modules is resolvable via the // monorepo hoisting chain: bundlePackage() uses createRequire against // the appDir's package.json. const repoRoot = resolve(__dirname, '..'); const silent = { info: () => {}, warn: () => {}, error: () => {} }; const app = await createRequestHandler({ appDir: repoRoot, dev: true, logger: silent }); - const resp = await app.handle(new Request('http://x/__webjs/vendor/picocolors.js')); + // Read picocolors's installed version so the test stays accurate + // across npm bumps in the repo. + const { getPackageVersion } = await import('../../src/vendor.js'); + const version = getPackageVersion('picocolors', repoRoot); + const resp = await app.handle(new Request(`http://x/__webjs/vendor/picocolors@${version}.js`)); assert.equal(resp.status, 200); assert.ok(resp.headers.get('content-type').includes('javascript')); }); @@ -104,7 +108,7 @@ test('handle: /__webjs/vendor/.js serves a built bundle for a known pkg', a test('handle: /__webjs/vendor/unknown.js → 404', async () => { const appDir = makeApp({ 'app/page.ts': `export default () => 'ok';` }); const app = await createRequestHandler({ appDir, dev: true }); - const resp = await app.handle(new Request('http://x/__webjs/vendor/this-pkg-does-not-exist-xyz.js')); + const resp = await app.handle(new Request('http://x/__webjs/vendor/this-pkg-does-not-exist-xyz@1.0.0.js')); assert.equal(resp.status, 404); }); diff --git a/packages/server/test/vendor/vendor.test.js b/packages/server/test/vendor/vendor.test.js index 36094ff2..368d5443 100644 --- a/packages/server/test/vendor/vendor.test.js +++ b/packages/server/test/vendor/vendor.test.js @@ -8,9 +8,11 @@ import { extractPackageName, scanBareImports, vendorImportMapEntries, + parseVendorId, bundlePackage, serveVendorBundle, clearVendorCache, + getPackageVersion, } from '../../src/vendor.js'; // --- extractPackageName --- @@ -83,6 +85,107 @@ test('scanBareImports: finds bare specifiers in source files', async () => { await rm(dir, { recursive: true, force: true }); }); +test('scanBareImports: skips route.ts and middleware.ts (file-router server-only convention)', async () => { + const dir = join(tmpdir(), `webjs-test-vendor-router-skip-${Date.now()}`); + await mkdir(join(dir, 'app', 'api', 'posts'), { recursive: true }); + await mkdir(join(dir, 'app', 'dashboard'), { recursive: true }); + + // route.ts: server-only by file-router convention. + await writeFile( + join(dir, 'app', 'api', 'posts', 'route.ts'), + `import { PrismaClient } from '@prisma/client'; + import 'server-only-helper';`, + ); + + // middleware.ts (per-segment): server-only. + await writeFile( + join(dir, 'app', 'dashboard', 'middleware.ts'), + `import { WebSocketServer } from 'ws'; + import 'another-server-thing';`, + ); + + // Root-level middleware.ts: same convention. + await writeFile( + join(dir, 'middleware.ts'), + `import 'root-mw-server-only';`, + ); + + // A regular page.ts: bare imports SHOULD enter the scan. + await writeFile( + join(dir, 'app', 'dashboard', 'page.ts'), + `import dayjs from 'dayjs';`, + ); + + const found = await scanBareImports(dir); + + assert.ok(found.has('dayjs'), 'page.ts imports should be scanned'); + assert.ok(!found.has('@prisma/client'), 'route.ts imports must be skipped'); + assert.ok(!found.has('server-only-helper'), 'route.ts imports must be skipped'); + assert.ok(!found.has('ws'), 'middleware.ts imports must be skipped'); + assert.ok(!found.has('another-server-thing'), 'middleware.ts imports must be skipped'); + assert.ok(!found.has('root-mw-server-only'), 'root middleware.ts imports must be skipped'); + + await rm(dir, { recursive: true, force: true }); +}); + +test('scanBareImports: skips test/ and tests/ directories', async () => { + const dir = join(tmpdir(), `webjs-test-vendor-test-skip-${Date.now()}`); + await mkdir(join(dir, 'test'), { recursive: true }); + await mkdir(join(dir, 'tests'), { recursive: true }); + + await writeFile(join(dir, 'test', 'a.test.ts'), `import 'test-only-pkg';`); + await writeFile(join(dir, 'tests', 'b.test.ts'), `import 'another-test-pkg';`); + await writeFile(join(dir, 'app.ts'), `import 'real-dep';`); + + const found = await scanBareImports(dir); + assert.ok(found.has('real-dep')); + assert.ok(!found.has('test-only-pkg')); + assert.ok(!found.has('another-test-pkg')); + + await rm(dir, { recursive: true, force: true }); +}); + +test('scanBareImports: skips import type statements (TS erases them)', async () => { + const dir = join(tmpdir(), `webjs-test-vendor-typeimport-skip-${Date.now()}`); + await mkdir(dir, { recursive: true }); + + await writeFile(join(dir, 'a.ts'), ` + import type { WebSocket } from 'ws'; + import type { User } from '@prisma/client'; + import dayjs from 'dayjs'; + `); + + const found = await scanBareImports(dir); + assert.ok(found.has('dayjs'), 'real value imports remain'); + assert.ok(!found.has('ws'), 'type-only imports must be skipped'); + assert.ok(!found.has('@prisma/client'), 'type-only imports must be skipped'); + + await rm(dir, { recursive: true, force: true }); +}); + +test('scanBareImports: skips import strings inside comments (JSDoc examples etc.)', async () => { + const dir = join(tmpdir(), `webjs-test-vendor-comments-skip-${Date.now()}`); + await mkdir(dir, { recursive: true }); + + await writeFile(join(dir, 'a.ts'), ` + /** + * Example usage: + * import { clsx } from 'clsx'; + * import { twMerge } from 'tailwind-merge'; + */ + // import 'commented-out-pkg'; + import real from 'real-only-pkg'; + `); + + const found = await scanBareImports(dir); + assert.ok(found.has('real-only-pkg')); + assert.ok(!found.has('clsx'), 'JSDoc-comment imports must be skipped'); + assert.ok(!found.has('tailwind-merge'), 'JSDoc-comment imports must be skipped'); + assert.ok(!found.has('commented-out-pkg'), 'line-comment imports must be skipped'); + + await rm(dir, { recursive: true, force: true }); +}); + test('scanBareImports: skips node_modules and _private dirs', async () => { const dir = join(tmpdir(), `webjs-test-vendor-skip-${Date.now()}`); await mkdir(join(dir, 'node_modules'), { recursive: true }); @@ -100,18 +203,48 @@ test('scanBareImports: skips node_modules and _private dirs', async () => { await rm(dir, { recursive: true, force: true }); }); -// --- vendorImportMapEntries --- +// --- vendorImportMapEntries (with version-in-URL) --- -test('vendorImportMapEntries: generates correct URLs', () => { - const entries = vendorImportMapEntries(new Set(['dayjs', '@tanstack/query'])); - assert.equal(entries['dayjs'], '/__webjs/vendor/dayjs.js'); - assert.equal(entries['@tanstack/query'], '/__webjs/vendor/%40tanstack%2Fquery.js'); +test('vendorImportMapEntries: emits /__webjs/vendor/@.js URLs', () => { + const entries = vendorImportMapEntries(new Set(['picocolors']), process.cwd()); + const url = entries['picocolors']; + assert.ok(url, 'picocolors should get an entry'); + assert.match(url, /^\/__webjs\/vendor\/picocolors@\d+\.\d+\.\d+\.js$/); }); test('vendorImportMapEntries: skips built-ins', () => { - const entries = vendorImportMapEntries(new Set(['@webjsdev/core', 'dayjs'])); + const entries = vendorImportMapEntries(new Set(['@webjsdev/core', 'picocolors']), process.cwd()); assert.ok(!('@webjsdev/core' in entries)); - assert.ok('dayjs' in entries); + assert.ok('picocolors' in entries); +}); + +test('vendorImportMapEntries: skips packages whose version cannot be resolved', () => { + const entries = vendorImportMapEntries( + new Set(['this-package-does-not-exist-xyz-123']), + process.cwd(), + ); + assert.ok(!('this-package-does-not-exist-xyz-123' in entries)); +}); + +// --- parseVendorId --- + +test('parseVendorId: plain package', () => { + assert.deepEqual(parseVendorId('dayjs@1.11.13.js'), { pkgName: 'dayjs', version: '1.11.13' }); +}); + +test('parseVendorId: scoped package using `--` separator', () => { + assert.deepEqual( + parseVendorId('@hotwired--turbo@8.0.0.js'), + { pkgName: '@hotwired/turbo', version: '8.0.0' }, + ); +}); + +test('parseVendorId: missing @version returns null', () => { + assert.equal(parseVendorId('dayjs.js'), null); +}); + +test('parseVendorId: works without trailing .js', () => { + assert.deepEqual(parseVendorId('dayjs@1.11.13'), { pkgName: 'dayjs', version: '1.11.13' }); }); // --- extractPackageName: edge cases --- @@ -134,67 +267,78 @@ test('extractPackageName: lone @scope with no package name returns null', () => test('bundlePackage: bundles a real package → ESM source', async () => { clearVendorCache(); - const code = await bundlePackage('picocolors', process.cwd(), false); + const version = getPackageVersion('picocolors', process.cwd()); + const code = await bundlePackage('picocolors', version, process.cwd(), false); assert.equal(typeof code, 'string'); assert.ok(code.length > 0, 'bundle should be non-empty'); - // ESM bundles should export something. assert.ok(/export\s*(?:default|{)/.test(code), 'expected ESM exports'); }); -test('bundlePackage: second call hits the in-memory cache', async () => { - // Prime - const first = await bundlePackage('picocolors', process.cwd(), false); - // Second call should return the exact same cached string without rebuilding - const second = await bundlePackage('picocolors', process.cwd(), false); +test('bundlePackage: second call hits the in-memory cache (keyed by pkg@version)', async () => { + const version = getPackageVersion('picocolors', process.cwd()); + const first = await bundlePackage('picocolors', version, process.cwd(), false); + const second = await bundlePackage('picocolors', version, process.cwd(), false); assert.equal(first, second); }); test('bundlePackage: unknown package → null', async () => { clearVendorCache(); - const code = await bundlePackage('this-pkg-definitely-does-not-exist-xyz', process.cwd(), false); + const code = await bundlePackage('this-pkg-definitely-does-not-exist-xyz', '1.0.0', process.cwd(), false); assert.equal(code, null); }); test('clearVendorCache: subsequent bundlePackage call re-builds', async () => { - await bundlePackage('picocolors', process.cwd(), false); // populates cache + const version = getPackageVersion('picocolors', process.cwd()); + await bundlePackage('picocolors', version, process.cwd(), false); clearVendorCache(); - // Re-build should still work (and return a string). - const code = await bundlePackage('picocolors', process.cwd(), false); + const code = await bundlePackage('picocolors', version, process.cwd(), false); assert.equal(typeof code, 'string'); assert.ok(code.length > 0); }); -test('serveVendorBundle: known package → 200 JS response with cache headers', async () => { +test('serveVendorBundle: known package id → 200 JS response with immutable cache headers', async () => { clearVendorCache(); - const resp = await serveVendorBundle('picocolors', process.cwd(), false); + const version = getPackageVersion('picocolors', process.cwd()); + const resp = await serveVendorBundle(`picocolors@${version}.js`, process.cwd(), false); assert.equal(resp.status, 200); - assert.equal( - resp.headers.get('content-type'), - 'application/javascript; charset=utf-8', - ); - assert.equal( - resp.headers.get('cache-control'), - 'public, max-age=31536000, immutable', - ); + assert.equal(resp.headers.get('content-type'), 'application/javascript; charset=utf-8'); + assert.equal(resp.headers.get('cache-control'), 'public, max-age=31536000, immutable'); const body = await resp.text(); assert.ok(body.length > 0); }); test('serveVendorBundle: dev=true uses no-cache', async () => { clearVendorCache(); - const resp = await serveVendorBundle('picocolors', process.cwd(), true); + const version = getPackageVersion('picocolors', process.cwd()); + const resp = await serveVendorBundle(`picocolors@${version}.js`, process.cwd(), true); assert.equal(resp.status, 200); assert.equal(resp.headers.get('cache-control'), 'no-cache'); }); -test('serveVendorBundle: unknown package → 404 JS response', async () => { +test('serveVendorBundle: malformed id (no @version) → 404', async () => { + const resp = await serveVendorBundle('not-a-valid-id.js', process.cwd(), false); + assert.equal(resp.status, 404); + const body = await resp.text(); + assert.ok(body.includes('malformed vendor id')); +}); + +test('serveVendorBundle: unknown package id → 404', async () => { clearVendorCache(); - const resp = await serveVendorBundle('this-pkg-does-not-exist-abc', process.cwd(), false); + const resp = await serveVendorBundle('this-pkg-does-not-exist-abc@1.0.0.js', process.cwd(), false); assert.equal(resp.status, 404); - assert.equal( - resp.headers.get('content-type'), - 'application/javascript; charset=utf-8', - ); const body = await resp.text(); assert.ok(body.includes('this-pkg-does-not-exist-abc')); }); + +test('serveVendorBundle: version-in-URL means same pkg different versions cache independently', async () => { + clearVendorCache(); + const version = getPackageVersion('picocolors', process.cwd()); + // Real version bundles successfully. + const resp1 = await serveVendorBundle(`picocolors@${version}.js`, process.cwd(), false); + assert.equal(resp1.status, 200); + // Fake "old" version doesn't error; esbuild bundles whatever's in + // node_modules (the actual installed version). Cache key is the + // requested version, so this populates a separate cache entry. + const resp2 = await serveVendorBundle('picocolors@0.0.0-fake.js', process.cwd(), false); + assert.equal(resp2.status, 200); +});