Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/lint-lazy-typescript.md
Original file line number Diff line number Diff line change
@@ -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.
98 changes: 98 additions & 0 deletions packages/lint/src/lazy-typescript.test.ts
Original file line number Diff line number Diff line change
@@ -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 <ObjectForm mode="edit" />; }' }] }`;
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 <ObjectForm mode="edit" />; }' }],
});
expect(tsLoaded()).toBe(true);
expect(findings.some((f) => f.rule === 'react-prop-missing-required' && /objectName/.test(f.message))).toBe(true);
});
});
51 changes: 45 additions & 6 deletions packages/lint/src/validate-react-page-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string>();
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`;
Expand Down Expand Up @@ -129,7 +168,7 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] {
}
}
}
ts.forEachChild(node, visit);
tsc.forEachChild(node, visit);
};
visit(sf);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/lint/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down