From ef15b395c1f2db88116c4495c518d42984bcafa2 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 3 Jul 2026 21:27:50 +0800 Subject: [PATCH] perf(lint): load the TypeScript compiler lazily in validateReactPageProps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @objectstack/lint sits on the kernel boot path, but the react-props gate (ADR-0081 Phase 2, #2482) only runs when a kind:'react' page is actually validated. The top-level `import ts from 'typescript'` made every boot parse the ~9 MB compiler (~70 ms+ warm, worse on container cold starts) and hard-crashed boot when a deployment pruned the package from the image (cloud's Docker pruner did; worked around in cloud#728). The compiler now loads on the first validated react-source page via a deferred createRequire (the bundling-safe pattern from driver-sqlite-wasm's knex-wasm-dialect); the public API stays synchronous and unchanged, and `typescript` stays a regular dependency. If the package is missing at call time, validation fails with an actionable error instead of killing boot. Guarded by lazy-typescript.test.ts at three levels (structural no-eager- import scan over src, child-process probes of both dist formats, in-process lazy-load behavior) — verified to go red when the eager import is reintroduced. An in-worker require.cache probe alone cannot catch it: vitest inlines static imports through its transform. Co-Authored-By: Claude Fable 5 --- .changeset/lint-lazy-typescript.md | 12 +++ packages/lint/src/lazy-typescript.test.ts | 98 +++++++++++++++++++ .../lint/src/validate-react-page-props.ts | 51 ++++++++-- packages/lint/tsconfig.json | 3 +- 4 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 .changeset/lint-lazy-typescript.md create mode 100644 packages/lint/src/lazy-typescript.test.ts diff --git a/.changeset/lint-lazy-typescript.md b/.changeset/lint-lazy-typescript.md new file mode 100644 index 0000000000..1aa8a75461 --- /dev/null +++ b/.changeset/lint-lazy-typescript.md @@ -0,0 +1,12 @@ +--- +'@objectstack/lint': patch +--- + +Load the TypeScript compiler lazily in `validateReactPageProps` instead of at module top level (ADR-0081 Phase 2 follow-up). + +`@objectstack/lint` sits on the kernel boot path, so the eager `import ts from 'typescript'` (framework#2482) made every boot parse the ~9 MB compiler (~70 ms+ on a warm laptop, worse on container cold starts) for a gate that only runs when a `kind:'react'` page is actually validated — a rare, trusted-tier case. It also hard-crashed boot in deployments that prune the package from the image (cloud's Docker pruner did exactly that; worked around in cloud#728). + +- The compiler now loads on the first validated react-source page, via a deferred `createRequire` (same bundling-safe pattern as driver-sqlite-wasm's knex-wasm-dialect); the public API stays synchronous and unchanged. +- Importing the package, and validating stacks with no react pages, no longer touches `typescript` at all — so images that prune it boot fine and only fail (with an actionable error naming the package and the fix) if a react-source page is actually validated. +- `typescript` remains a regular dependency of `@objectstack/lint`. +- Guarded by a three-level regression test (structural no-eager-import scan, child-process probes of both dist formats, in-process lazy-load behavior), verified to go red if the eager import is reintroduced. diff --git a/packages/lint/src/lazy-typescript.test.ts b/packages/lint/src/lazy-typescript.test.ts new file mode 100644 index 0000000000..dfeb75335a --- /dev/null +++ b/packages/lint/src/lazy-typescript.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Boot-path contract: importing @objectstack/lint must NOT load the TypeScript +// compiler. The package sits on the kernel boot path, and `typescript` is +// ~9 MB (and has been pruned from production images before — see +// validate-react-page-props.ts); it must load lazily, on the first validated +// `kind:'react'` page. +// +// Guarded at three levels because vitest inlines static imports through its +// transform (they never hit the native require cache), so an in-worker +// require.cache probe alone CANNOT catch a reintroduced eager import: +// 1. structural — no src file may eagerly `import ... from 'typescript'` +// (only `import type`, which erases at build); +// 2. built dist — child `node` processes import both dist formats and prove +// the compiler is absent until a react page is validated (skipped when +// dist has not been built); +// 3. behavioral — the lazy path really loads the compiler on demand and the +// gate still produces findings. +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { describe, it, expect } from 'vitest'; + +const srcDir = dirname(fileURLToPath(import.meta.url)); +const distDir = join(srcDir, '..', 'dist'); + +describe('lazy typescript loading (kernel boot-path contract)', () => { + it('no src file eagerly imports typescript (import type only)', () => { + // Static `import`/`export ... from 'typescript'` executes at module init in + // both dist formats; `import type` erases. Dynamic import()/createRequire + // inside functions are the sanctioned lazy forms and are not matched. + const eagerTsImport = + /^\s*(?:import\s+['"]typescript['"]|(?:import|export)\s+(?!type[\s{])[^;]*?from\s*['"]typescript['"]|import\s+\w+\s*=\s*require\(\s*['"]typescript['"]\s*\))/m; + const offenders = readdirSync(srcDir) + .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')) + .filter((f) => eagerTsImport.test(readFileSync(join(srcDir, f), 'utf8'))); + expect(offenders).toEqual([]); + }); + + const reactStack = `{ pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return ; }' }] }`; + const childBody = ` + const probe = require('node:module').createRequire(process.cwd() + '/probe.js'); + const tsLoaded = () => Object.keys(probe.cache ?? {}).some((p) => /[/\\\\]node_modules[/\\\\]typescript[/\\\\]/.test(p)); + const fail = (msg) => { console.error(msg); process.exit(1); }; + const check = (mod) => { + if (tsLoaded()) fail('typescript was loaded eagerly, at import time'); + const findings = mod.validateReactPageProps(${reactStack}); + if (!tsLoaded()) fail('typescript was not loaded by a react-page validation'); + if (!findings.some((f) => f.rule === 'react-prop-missing-required')) fail('gate produced no finding'); + console.log('OK'); + }; + `; + + it.skipIf(!existsSync(join(distDir, 'index.cjs')))('built CJS dist does not load typescript until a react page is validated', () => { + const out = execFileSync( + process.execPath, + ['-e', `${childBody}; check(require(${JSON.stringify(join(distDir, 'index.cjs'))}));`], + { encoding: 'utf8' }, + ); + expect(out).toContain('OK'); + }); + + it.skipIf(!existsSync(join(distDir, 'index.js')))('built ESM dist does not load typescript until a react page is validated', () => { + const out = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import { createRequire } from 'node:module'; + const require = createRequire(process.cwd() + '/probe.js'); + ${childBody}; + check(await import(${JSON.stringify(pathToFileURL(join(distDir, 'index.js')).href)}));`, + ], + { encoding: 'utf8' }, + ); + expect(out).toContain('OK'); + }); + + it('loads the compiler lazily in-process and the gate still works', async () => { + const req = createRequire(import.meta.url); + const tsLoaded = () => Object.keys(req.cache ?? {}).some((p) => /[/\\]node_modules[/\\]typescript[/\\]/.test(p)); + const { validateReactPageProps } = await import('./index.js'); + + // Stacks without a react-source page never touch the compiler. + expect(validateReactPageProps({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]); + expect(validateReactPageProps({ pages: [{ name: 'r', kind: 'react', source: ' ' }] })).toEqual([]); + expect(tsLoaded()).toBe(false); + + // The first react page with source pays the cost — and the gate still works. + const findings = validateReactPageProps({ + pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return ; }' }], + }); + expect(tsLoaded()).toBe(true); + expect(findings.some((f) => f.rule === 'react-prop-missing-required' && /objectName/.test(f.message))).toBe(true); + }); +}); diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index 51cae173c6..3683898e96 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -15,9 +15,44 @@ // unknown props (the contract's data props are a curated subset) — only the // likely typos, to keep false positives near zero. -import ts from 'typescript'; +import { createRequire } from 'node:module'; +import type ts from 'typescript'; import { REACT_BLOCKS } from '@objectstack/spec/ui'; +// The TypeScript compiler must NOT be imported at module top level: it is +// ~9 MB of CJS (~70 ms+ to parse, worse on container cold starts), and +// @objectstack/lint sits on the kernel boot path — while this gate only runs +// when a `kind:'react'` page is actually validated (rare, trusted tier). An +// eager import also hard-crashes boot in deployments that prune the package +// from the image (cloud's Docker pruner did exactly that). So the compiler is +// loaded lazily, on first use, and stays a regular dependency in package.json. +// Guarded by lazy-typescript.test.ts. +// +// `node:module` is a Node builtin, untouched by esbuild/tsup, so the static +// `createRequire` import survives bundling; the `createRequire(...)` call is +// deferred because `import.meta.url` is rewritten to an empty stub in the CJS +// build (same pattern as driver-sqlite-wasm's knex-wasm-dialect). +let cachedTs: typeof ts | null = null; +function loadTypeScript(): typeof ts { + if (cachedTs) return cachedTs; + const anchor = + typeof import.meta !== 'undefined' && import.meta.url + ? import.meta.url + : typeof __filename !== 'undefined' + ? __filename + : process.cwd() + '/'; + try { + cachedTs = createRequire(anchor)('typescript') as typeof ts; + } catch (err) { + throw new Error( + `@objectstack/lint: validating a kind:'react' page requires the "typescript" package, which could not be loaded ` + + `(${err instanceof Error ? err.message : String(err)}). It is a declared dependency of @objectstack/lint — ` + + `if this deployment prunes packages, keep "typescript" in the image; it is only loaded when a react-source page is validated.`, + ); + } + return cachedTs; +} + export type ReactPropSeverity = 'error' | 'warning'; export interface ReactPropFinding { @@ -82,23 +117,27 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { if (typeof source !== 'string' || source.trim() === '') continue; const name = String(page.name ?? `#${p}`); + // Outside the try below on purpose: a missing compiler must surface as an + // error, not be swallowed as "unparseable source". + const tsc = loadTypeScript(); + let sf: ts.SourceFile; try { - sf = ts.createSourceFile('page.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + sf = tsc.createSourceFile('page.tsx', source, tsc.ScriptTarget.Latest, true, tsc.ScriptKind.TSX); } catch { continue; // the syntax gate reports unparseable sources } const visit = (node: ts.Node): void => { - if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + if (tsc.isJsxOpeningElement(node) || tsc.isJsxSelfClosingElement(node)) { const tag = node.tagName.getText(sf); const block = BLOCKS.get(tag); if (block) { let hasSpread = false; const used = new Set(); for (const a of node.attributes.properties) { - if (ts.isJsxSpreadAttribute(a)) { hasSpread = true; continue; } - if (ts.isJsxAttribute(a)) used.add(a.name.getText(sf)); + if (tsc.isJsxSpreadAttribute(a)) { hasSpread = true; continue; } + if (tsc.isJsxAttribute(a)) used.add(a.name.getText(sf)); } const where = `page "${name}" › <${tag}>`; const path = `pages[${p}].source`; @@ -129,7 +168,7 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { } } } - ts.forEachChild(node, visit); + tsc.forEachChild(node, visit); }; visit(sf); } diff --git a/packages/lint/tsconfig.json b/packages/lint/tsconfig.json index 5e3095a8a4..1294309a21 100644 --- a/packages/lint/tsconfig.json +++ b/packages/lint/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]