Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions packages/cli/bin/webjs.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const USAGE = `webjs commands:
webjs ui <subcmd> 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 */
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
107 changes: 100 additions & 7 deletions packages/server/src/check.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 { ... <value statement> ... }` 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
Expand Down
16 changes: 9 additions & 7 deletions packages/server/src/dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -408,12 +408,14 @@ async function handleCore(req, ctx) {
return fileResponse(abs, { dev, immutable: false });
}

// Vendor bundles: /__webjs/vendor/<pkg>.js: generic auto-bundler
// (Vite-style optimizeDeps) for any bare npm import that webjs can't
// serve directly as ESM.
// Vendor bundles: /__webjs/vendor/<pkg>@<version>.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
Expand Down
Loading