diff --git a/.changepacks/changepack_log_bun_plugin_build.json b/.changepacks/changepack_log_bun_plugin_build.json new file mode 100644 index 000000000..a461c80bf --- /dev/null +++ b/.changepacks/changepack_log_bun_plugin_build.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/bun-plugin/package.json": "Patch" + }, + "note": "Bun plugin works with Bun.build: `DevupUI()` (from `@devup-ui/bun-plugin/register`) serves the generated stylesheet to the bundler's CSS loader once every other module is loaded, so the build emits a CSS output with the styles of the whole bundle instead of an empty module; class names are short there and readable under the runtime (`debug` option). Files importing the packages Devup UI takes the place of (`@emotion/react`, `@emotion/styled`, `styled-components`, `@vanilla-extract/css`), `@stylexjs/stylex`, or a subpath or re-export of these or `@devup-ui/react`, are compiled instead of skipped", + "date": "2026-09-30T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_coordinator_port_isolation.json b/.changepacks/changepack_log_coordinator_port_isolation.json new file mode 100644 index 000000000..667a75271 --- /dev/null +++ b/.changepacks/changepack_log_coordinator_port_isolation.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/next-plugin/package.json": "Patch" + }, + "note": "Isolate the coordinator port file per owning process when another live next dev/build holds it, record the owner pid, and report port, pid and recovery when the coordinator is missing or unreachable", + "date": "2026-10-02T12:41:12.3693972Z" +} diff --git a/.changepacks/changepack_log_css_snapshot.json b/.changepacks/changepack_log_css_snapshot.json new file mode 100644 index 000000000..a1c52eeec --- /dev/null +++ b/.changepacks/changepack_log_css_snapshot.json @@ -0,0 +1,9 @@ +{ + "changes": { + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch" + }, + "note": "Webpack and Rsbuild builds hold every style in their stylesheets: a stylesheet module is built on its first import, so styles extracted later (the global styles of files webpack or Rsbuild reached late, often outside `src`) were missing from the output. The files the entries reach under `src` are now extracted before the build, the shared base on disk is kept current, and when a stylesheet still changed during the build it is compiled once more, writing only the complete result. Rsbuild serves each stylesheet from the current sheet instead of a module-wide string kept only when it grew, which left edits that shortened the CSS and later builds in the same process stale. plugin-utils adds computeReachableFiles", + "date": "2026-09-30T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_deterministic_numbering.json b/.changepacks/changepack_log_deterministic_numbering.json new file mode 100644 index 000000000..053739d3d --- /dev/null +++ b/.changepacks/changepack_log_deterministic_numbering.json @@ -0,0 +1,13 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "Class names and file numbers no longer depend on the order files are processed in. Every plugin walks the files it can extract (the project's source and the included packages) before the first transform and numbers them in path order (a file collapsed into a bucket is numbered as the bucket, and only files that mention the package, StyleX or an aliased package take numbers) through the new seedFileMap, so identical builds give identical class prefixes, CSS file names and output hashes; in development, files that appear later are numbered after the existing ones. Styles are given class names in a fixed order, not the order of a hash set. The exported class, file and canonical maps are canonical JSON (sorted keys). Stylesheet evaluation (vanilla-extract) refuses Math.random(), Date.now(), new Date() and Date(), which differ between builds, with a build error; fixed dates such as new Date(0) are unchanged. resetBuildState starts a build from its own options: the prefix, atom hoisting, routes, buckets, names, numbers and styles an earlier build in the process left are cleared, and the prefix is set on every setup even when it is absent. Engine snapshots from concurrent transforms are written one after another per file, so an older snapshot is never written last. File lists are ordered by code point rather than the locale", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_next_coordinator.json b/.changepacks/changepack_log_next_coordinator.json new file mode 100644 index 000000000..d2fb289db --- /dev/null +++ b/.changepacks/changepack_log_next_coordinator.json @@ -0,0 +1,8 @@ +{ + "changes": { + "packages/next-plugin/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch" + }, + "note": "Isolate Next.js extraction engines and coordinator sessions, deterministically plan root and src App/Pages routes with modern source extensions and absolute output exclusions, watch inherited themes and removals, and persist atomic checkpoints. Prepare reached MDX source generations with the installed compiler, shared prepared resolution, immutable plans and complete reported dependency evidence. Refresh affected sources, validate inputs before CSS finalization and keep private restart proofs. Adopt ordinary inputs, completion plans and watch paths in one durable transaction with synchronous publication, exact predecessor compensation and quarantine on failed restoration; preserve legacy callers and cancellation. Capture dev CSS inside the mutation queue and validate production inputs at every publication. Unsupported extensions, pending or conflicting input ownership, stale bytes, failed compensation and unproven bindings fail located. Production re-extraction still accepts only identical rendered CSS; full MDX lifecycle and native receipt wiring remain integration requirements.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_plugin_core.json b/.changepacks/changepack_log_plugin_core.json new file mode 100644 index 000000000..e74f874ac --- /dev/null +++ b/.changepacks/changepack_log_plugin_core.json @@ -0,0 +1,13 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch" + }, + "note": "Use bundler project roots and active export conditions for build-time module resolution, resolve inherited tsconfig paths and dotted module specifiers, transform modern modules and compiled MDX, match included packages literally, and refresh empty theme declarations. Skip unrequested source maps, surface resolver faults before stylesheet mutation, and report WASM panic locations. Malformed or cyclic configuration, resolver failures, requested graph/prewarm failures and disabled Rsbuild shared splitting now stop builds with context; optional numbering fallback warns about its deterministic impact. Bun defaults to compact names and retains imported build-time dependency watch edges.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_plugin_core_ci.json b/.changepacks/changepack_log_plugin_core_ci.json new file mode 100644 index 000000000..398fa5756 --- /dev/null +++ b/.changepacks/changepack_log_plugin_core_ci.json @@ -0,0 +1,10 @@ +{ + "changes": { + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch" + }, + "note": "Keep raw MDX out of static import graphs unless the caller compiles MDX before extraction, preserving Next production prewarm behavior while MDX-aware plugins opt in. Normalize Webpack inline-loader entry resources before filesystem lookup and skip virtual or pitch-only entries without swallowing real I/O failures. Isolate watched-config regression diagnostics while preserving the prior theme on invalid configuration. No new build-error category is introduced.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_plugin_core_mdx.json b/.changepacks/changepack_log_plugin_core_mdx.json new file mode 100644 index 000000000..da179669f --- /dev/null +++ b/.changepacks/changepack_log_plugin_core_mdx.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Extract compiled MDX with its real source filename, including JSX-preserving alias imports and exact static computations. Raw or invalid MDX and unsupported extraction extensions now produce located build errors instead of untransformed styling placeholders.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/bindings/devup-ui-wasm/README.md b/bindings/devup-ui-wasm/README.md index 613206dd7..e2167ef6c 100644 --- a/bindings/devup-ui-wasm/README.md +++ b/bindings/devup-ui-wasm/README.md @@ -1,85 +1,117 @@ -
+# @devup-ui/wasm -

wasm-pack-template

+Build-time CSS extraction bindings used by the Devup UI bundler plugins. -A template for kick starting a Rust and WebAssembly project using wasm-pack. +## Extraction boundaries -

- Build Status -

+`codeExtract` generates a source map. `codeExtractWithoutSourceMap` skips map +generation, including when a module resolver is installed; its `map` getter is +`undefined`. Both functions return transformed source and watched dependencies. -

- Tutorial - | - Chat -

+`setModuleResolver` accepts a synchronous `(specifier, importer)` callback. +Return `{ path: string, code: string }` for a resolved module, or +`{ ignored: true }` for an explicitly ignored native alias. An ignored result +has no source/path and never reads `path`, `code` or `sourceType` getters. +`null` and `undefined` mean ordinary unresolved imports. Thrown exceptions, property-getter +and reflection failures, and missing/non-string fields instead fail extraction: -Built with 🦀🕸 by The Rust and WebAssembly Working Group - -
- -## About +```text +/src/App.tsx:1:1: module resolver cannot use `./tokens` at build time: original cause +``` -[**📚 Read this template tutorial! 📚**][template-docs] +The importer includes transitive importers. The resolver contract does not +provide an import span, so this boundary reports `1:1` rather than guessing a +position from source text. The first resolver fault takes precedence over the +extractor's unresolved-module fallback and is checked **before** sheet updates. +The previous stylesheet contribution therefore survives a failed re-extraction. +Class/file numbering performed inside extraction is not rolled back. -This template is designed for compiling Rust libraries into WebAssembly and -publishing the resulting package to NPM. +Ignored imports are empty CommonJS modules, not renamed or virtual files. Missing +named values are `undefined`, defaults follow the existing CommonJS interop, and +namespace/require values are empty objects. No ignored file becomes a watched +dependency or is sent through the parser. The `ignored` discriminator must be +`true` or absent; malformed discriminators retain the same located resolver-fault +priority and previous-sheet preservation as malformed file results. -Be sure to check out [other `wasm-pack` tutorials online][tutorials] for other -templates and usages of `wasm-pack`. +## Panic diagnostics -[tutorials]: https://rustwasm.github.io/docs/wasm-pack/tutorials/index.html -[template-docs]: https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/index.html +WASM initialization installs this binding's own `std::panic::set_hook`, with no +additional dependency or feature flag. It reports Rust's panic payload and +`file:line:column` location to `console.error` before the trap. A failing host +console does not replace the original panic. This is diagnostic reporting, not +panic recovery: with `panic=abort`, a panic still traps and the instance should +be discarded. Normal resolver/configuration failures use fallible JS errors. -## 🚴 Usage +Owned binding code does not unwrap user input or explicitly panic. Sheet mutex +poisoning is recovered, and no resolver callback is called while holding a sheet +lock or a thread-local resolver borrow. Panics originating in downstream crates +remain outside this binding's recovery contract. -### 🐑 Use `cargo generate` to Clone this Template +## Verification -[Learn more about `cargo generate` here.](https://github.com/ashleygwilliams/cargo-generate) +From the repository root: -``` -cargo generate --git https://github.com/rustwasm/wasm-pack-template.git --name my-project -cd my-project +```sh +cargo test -p devup-ui-wasm boundary_ -- --nocapture ``` -### 🛠️ Build with `wasm-pack build` +After rebuilding `pkg` with this package's `bun run build`, run from the +repository root: -``` -wasm-pack build +```sh +node bindings/devup-ui-wasm/boundary-regressions.mjs +node bindings/devup-ui-wasm/ignored-alias-regressions.mjs ``` -### 🔬 Test in Headless Browsers with `wasm-pack test` +This standalone runner loads the real generated JS and WASM, outside Bun's +coverage mocks. It exercises both extraction exports, map skipping with and +without resolution, unresolved null/undefined, valid resolved dependencies, +callback exceptions, getters, proxy reflection, malformed fields and hostile +exception objects. Every fault case verifies the previous sheet is unchanged. +Native tests cover every branch of the internal fault-precedence helper. -``` -wasm-pack test --headless --firefox -``` +## Explicit Compiler Source Type -### 🎁 Publish to NPM with `wasm-pack publish` +Both extraction functions accept an optional ninth argument after +`import_aliases`: `sourceType?: 'compiled-mdx'`. Use it for project-compiled MDX +under a custom extension, without changing the real filename: +```ts +const output = codeExtract( + 'src/page.mdown', compiledCode, '@devup-ui/react', 'df', + true, false, false, {}, 'compiled-mdx', +) ``` -wasm-pack publish -``` - -## 🔋 Batteries Included -- [`wasm-bindgen`](https://github.com/rustwasm/wasm-bindgen) for communicating - between WebAssembly and JavaScript. -- [`console_error_panic_hook`](https://github.com/rustwasm/console_error_panic_hook) - for logging panic messages to the developer console. -- `LICENSE-APACHE` and `LICENSE-MIT`: most Rust projects are licensed this way, so these are included for you +The value means JavaScript with JSX, not TypeScript or raw Markdown. Omission +keeps filename-derived parsing, including compiled JavaScript/JSX for `.md` and +`.mdx`. Unknown extensions without an override fail with a located error even +when the source has no styling import. Invalid source-type values also fail +before stylesheet publication. -## License +Resolved modules may return `{ path, code, sourceType: 'compiled-mdx' }`. The +child's own type reaches imported constants, reexports, stylesheet evaluation +and stripping caches; it never inherits the importing file's override. This +includes an ordinary TSX file importing a constant after JSX in compiled `.md` +or `.mdown`. Parser/source-type failures preserve the previous stylesheet. +Existing imported-value coordinate limitations remain owned by #757. -Licensed under either of +After rebuilding the bindings, run the real project-compiler/WASM matrix: -- Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) -- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) +```sh +node bindings/devup-ui-wasm/source-type-regressions.mjs +``` -at your option. +The same JS runner also uses the installed `@mdx-js/mdx` compiler, resolved through +`@mdx-js/loader` in `apps/landing` (no added dependency). Both default `_jsx` +output and `jsx: true` output are extracted under real `.mdx` and `.md` filenames. +It checks CSS, the relevance gate, source-map filename identity and rejection of +raw Markdown. Compiled MDX is parsed as JSX JavaScript, not TSX. Its source-map +coordinates refer to compiler output; bundler integrations must compose input +maps to recover Markdown positions, or identify diagnostics as compiled MDX. -### Contribution +Focused native MDX verification: -Unless you explicitly state otherwise, any contribution intentionally -submitted for inclusion in the work by you, as defined in the Apache-2.0 -license, shall be dual licensed as above, without any additional terms or -conditions. +```sh +cargo test -p extractor mdx_ -- --nocapture +``` diff --git a/bindings/devup-ui-wasm/boundary-regressions.mjs b/bindings/devup-ui-wasm/boundary-regressions.mjs new file mode 100644 index 000000000..ac30a81a9 --- /dev/null +++ b/bindings/devup-ui-wasm/boundary-regressions.mjs @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) +const wasm = require('./pkg/index.js') +const filename = '/src/boundary.tsx' +const source = `import { Box } from '@devup-ui/react'; +import { PRIMARY } from './tokens'; +export const view = ;` +const extract = (method, code = source) => + wasm[method](filename, code, '@devup-ui/react', 'df', true, false, false, {}) + +const loaderPath = require.resolve('@mdx-js/loader', { + paths: [fileURLToPath(new URL('../../apps/landing/', import.meta.url))], +}) +const compilerPath = createRequire(loaderPath).resolve('@mdx-js/mdx') +const { compile } = await import(pathToFileURL(compilerPath).href) +const markdown = `import { Box } from '@devup-ui/react' + +# Heading + +Hello +` +for (const jsx of [false, true]) { + const compiled = String( + await compile({ path: '/src/document.mdx', value: markdown }, { jsx }), + ) + for (const extension of ['mdx', 'md']) { + for (const method of ['codeExtract', 'codeExtractWithoutSourceMap']) { + wasm.resetBuildState() + const path = `/src/document.${extension}` + assert.equal(wasm.hasDevupUI(path, compiled, '@devup-ui/react'), true) + const output = wasm[method]( + path, + compiled, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + assert.match(output.code, /className/) + assert.doesNotMatch(output.code, /(?:_jsx\(\s*Box\b| + wasm.codeExtract( + path, + markdown, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ), + (error) => { + assert.ok(error instanceof Error) + assert.ok(error.message.startsWith(`${path}:`)) + assert.match(error.message, /:\d+:\d+:/) + return true + }, + ) +} +console.info( + 'Real MDX compiler regressions passed (default JSX calls and jsx:true).', +) + +for (const jsx of [false, true]) { + const markdown = + "import { css } from '@emotion/react'\n\nexport const cls = css({ color: 'red' })\n\n
\n" + const compiled = String( + await compile({ path: '/src/alias.mdx', value: markdown }, { jsx }), + ) + wasm.resetBuildState() + const output = wasm.codeExtract( + '/src/alias.mdx', + compiled, + '@devup-ui/react', + 'df', + true, + false, + false, + { '@emotion/react': null }, + ) + assert.doesNotMatch(output.code, /@emotion\/react/) + assert.match(wasm.getCss(undefined, false), /color:red/) + output.free() +} + +for (const method of ['codeExtract', 'codeExtractWithoutSourceMap']) { + wasm.resetBuildState() + const direct = extract( + method, + "import { Box } from '@devup-ui/react'; export const view = ;", + ) + if (method === 'codeExtractWithoutSourceMap') + assert.equal(direct.map, undefined) + else assert.equal(typeof direct.map, 'string') + direct.free() + for (const result of [null, undefined]) { + wasm.resetBuildState() + wasm.setModuleResolver(() => result) + const output = extract(method) + assert.match(output.code, /--/) + if (method === 'codeExtractWithoutSourceMap') + assert.equal(output.map, undefined) + else assert.equal(typeof output.map, 'string') + output.free() + } + + wasm.resetBuildState() + wasm.setModuleResolver(() => ({ + path: '/src/tokens.ts', + code: "export const PRIMARY = 'blue';", + })) + const resolved = extract(method) + assert.deepEqual(resolved.dependencies, ['/src/tokens.ts']) + assert.doesNotMatch(resolved.code, /--/) + if (method === 'codeExtractWithoutSourceMap') + assert.equal(resolved.map, undefined) + resolved.free() + + const faults = [ + [ + () => { + throw new Error('callback failure') + }, + /callback failure/, + ], + [ + () => { + throw 'string failure' + }, + /string failure/, + ], + [ + () => ({ + get path() { + throw new Error('path getter failure') + }, + }), + /path getter failure/, + ], + [ + () => ({ + path: '/src/tokens.ts', + get code() { + throw new Error('code getter failure') + }, + }), + /code getter failure/, + ], + [ + () => + new Proxy( + {}, + { + get() { + throw new Error('reflection failure') + }, + }, + ), + /reflection failure/, + ], + [() => 7, /Reflect|reflect|object/i], + [() => ({}), /path.*must be a string/], + [() => ({ path: 7, code: '' }), /path.*must be a string/], + [() => ({ path: '/src/tokens.ts', code: null }), /code.*must be a string/], + [() => ({ path: '/src/tokens.ts', code: 7 }), /code.*must be a string/], + [ + () => { + throw new Proxy( + {}, + { + get() { + throw new Error('hostile exception') + }, + }, + ) + }, + /unprintable JavaScript exception/, + ], + ] + for (const [resolver, cause] of faults) { + wasm.resetBuildState() + const initial = extract( + method, + "import { Box } from '@devup-ui/react'; export const view = ;", + ) + initial.free() + const before = wasm.exportSheet() + wasm.setModuleResolver(resolver) + assert.throws( + () => extract(method), + (error) => { + assert.ok(error instanceof Error) + assert.match( + error.message, + /^\/src\/boundary\.tsx:1:1: module resolver cannot use `\.\/tokens` at build time: /, + ) + assert.match(error.message, cause) + return true + }, + ) + assert.equal(wasm.exportSheet(), before) + } +} +wasm.resetBuildState() +console.info( + 'WASM boundary regressions passed (real generated JS/WASM, no mocks).', +) diff --git a/bindings/devup-ui-wasm/ignored-alias-regressions.mjs b/bindings/devup-ui-wasm/ignored-alias-regressions.mjs new file mode 100644 index 000000000..76b50fa45 --- /dev/null +++ b/bindings/devup-ui-wasm/ignored-alias-regressions.mjs @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { createModuleResolver } from '../../packages/plugin-utils/dist/index.mjs' +import { verifyIgnoredSemantics } from './ignored-semantics-regressions.mjs' + +const wasm = createRequire(import.meta.url)('./pkg/index.js') +const filename = '/src/ignored.tsx' +const root = mkdtempSync(join(tmpdir(), 'devup-ignored-')) +const file = (name, code) => { + const path = join(root, name) + writeFileSync(path, code) + return path +} +const barrel = file( + 'barrel.js', + "export {default as def, missing as value} from 'empty'; export * as ns from 'empty'; export * from 'empty';", +) +const cjs = file( + 'cjs.js', + "const ns = require('empty'); const {missing} = require('empty'); exports.value = missing; exports.other = ns.absent;", +) +const whole = file('whole.js', "module.exports = require('empty');") +const real = file('real.js', 'module.exports = {};') +const shared = createModuleResolver({ + cwd: root, + alias: { empty: false, barrel, cjs, whole, real }, +}) +try { + for (const method of ['codeExtract', 'codeExtractWithoutSourceMap']) { + verifyIgnoredSemantics(wasm, method, shared) + wasm.resetBuildState() + wasm.setModuleResolver(() => ({ ignored: true })) + const output = wasm[method]( + filename, + "import { Box } from '@devup-ui/react'; import { missing } from 'empty'; export const view = ", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + assert.deepEqual(output.dependencies, []) + assert.doesNotMatch(output.code, /--/) + assert.doesNotMatch(wasm.getCss(undefined, false), /color:/) + output.free() + for (const binding of [ + "import * as ns from 'empty'; const value = ns.missing;", + "import def from 'empty'; const value = def.missing;", + "import {value} from 'barrel';", + "import {ns} from 'barrel'; const value = ns.missing;", + "import {value} from 'cjs';", + "import {other as value} from 'cjs';", + "import def from 'whole'; const value = def.missing;", + ]) { + wasm.resetBuildState() + wasm.setModuleResolver(shared) + const resolved = wasm[method]( + filename, + `import {Box} from '@devup-ui/react'; ${binding} export const view = ;`, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + assert.doesNotMatch(resolved.code, /--/) + assert.doesNotMatch(wasm.getCss(undefined, false), /color:/) + if (method === 'codeExtractWithoutSourceMap') + assert.equal(resolved.map, undefined) + else assert.equal(typeof resolved.map, 'string') + resolved.free() + } + wasm.resetBuildState() + wasm.setModuleResolver(shared) + const evaluated = wasm[method]( + '/src/ignored.css.ts', + "import {style} from '@devup-ui/react'; import def, {missing} from 'empty'; import * as ns from 'empty'; import {ns as indirect, def as indirectDefault, value} from 'barrel'; function color() { if (missing !== undefined || value !== undefined || Object.keys(ns).length || Object.keys(def).length || Object.keys(indirect).length || Object.keys(indirectDefault).length) throw new Error('bad interop'); return 'red'; } export const cls = style({color: color()});", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + assert.match(wasm.getCss(undefined, false), /color:red/) + assert.deepEqual(evaluated.dependencies, [barrel]) + evaluated.free() + + wasm.resetBuildState() + const ignored = { + ignored: true, + get path() { + throw new Error('must not read path') + }, + get code() { + throw new Error('must not read code') + }, + get sourceType() { + throw new Error('must not read sourceType') + }, + } + wasm.setModuleResolver(() => ignored) + const bypassed = wasm[method]( + filename, + "import {Box} from '@devup-ui/react'; import {missing} from 'empty'; export const view = ;", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + assert.deepEqual(bypassed.dependencies, []) + bypassed.free() + + for (const result of [ + true, + false, + 1, + 'ignored', + { ignored: false }, + { ignored: null }, + { ignored: 1 }, + { ignored: 'true' }, + { + get ignored() { + throw new Error('ignored getter first') + }, + }, + new Proxy( + {}, + { + get() { + throw new Error('ignored proxy first') + }, + }, + ), + ]) { + wasm.resetBuildState() + wasm.setModuleResolver(undefined) + const initial = wasm[method]( + filename, + "import {Box} from '@devup-ui/react'; export const view = ;", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + initial.free() + const sheet = wasm.exportSheet() + let calls = 0 + wasm.setModuleResolver(() => { + calls += 1 + return result + }) + assert.throws( + () => + wasm[method]( + filename, + "import {Box} from '@devup-ui/react'; import {missing} from 'empty'; import {other} from 'later'; export const view = ;", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ), + /module resolver cannot use `empty` at build time/, + ) + assert.equal(calls, 1) + assert.equal(wasm.exportSheet(), sheet) + } + + wasm.resetBuildState() + wasm.setModuleResolver(shared) + const previous = wasm[method]( + filename, + "import {Box} from '@devup-ui/react'; export const view = ;", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + previous.free() + const sheet = wasm.exportSheet() + assert.throws( + () => + wasm[method]( + filename, + "import {css} from '@devup-ui/react'; import def from 'empty'; def.color = 'red'; export const cls = css(def);", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ), + /changed/, + ) + assert.equal(wasm.exportSheet(), sheet) + } + mkdirSync(join(root, 'src')) + const request = 'private-next-instrumentation-client-user' + const next = createModuleResolver({ + cwd: root, + alias: { + [request]: [ + join(root, 'src/instrumentation-client'), + join(root, 'instrumentation-client'), + 'private-next-empty-module', + ], + 'private-next-empty-module': false, + }, + }) + assert.deepEqual(next(request, join(root, 'entry.js')), { ignored: true }) + const instrumentation = file( + 'instrumentation-client.js', + "export const value = 'blue';", + ) + assert.equal(next(request, join(root, 'entry.js')).path, instrumentation) +} finally { + wasm.setModuleResolver(undefined) + rmSync(root, { recursive: true, force: true }) +} +console.info('Ignored alias actual WASM regressions passed.') diff --git a/bindings/devup-ui-wasm/ignored-semantics-regressions.mjs b/bindings/devup-ui-wasm/ignored-semantics-regressions.mjs new file mode 100644 index 000000000..62e89c7bb --- /dev/null +++ b/bindings/devup-ui-wasm/ignored-semantics-regressions.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' + +export function verifyIgnoredSemantics(wasm, method, resolver) { + const extract = (filename, code) => { + wasm.resetBuildState() + wasm.setModuleResolver(resolver) + const output = wasm[method]( + filename, + code, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + const result = { + code: output.code, + css: wasm.getCss(undefined, false), + dependencies: output.dependencies, + } + output.free() + return result + } + for (const key of [ + 'constructor', + 'toString', + '__proto__', + 'hasOwnProperty', + ]) { + const named = extract( + '/src/named.css.ts', + `import {style} from '@devup-ui/react'; import {${key} as value} from 'empty'; export const cls = style({color: typeof value === 'undefined' ? 'red' : 'blue'});`, + ) + assert.match(named.css, /color:red/, `${method}: named ${key}`) + assert.doesNotMatch(named.css, /color:blue/) + assert.deepEqual(named.dependencies, []) + } + for (const binding of [ + "import {missing as value} from 'empty';", + "import def from 'empty'; const value = def.missing;", + "import * as ns from 'empty'; const value = ns.missing;", + "import {value} from 'barrel';", + "import def from 'whole'; const value = def.missing;", + 'const record = {}; const value = record.missing;', + ]) { + const shadowed = extract( + '/src/shadowed.tsx', + `import {Box} from '@devup-ui/react'; ${binding} export const View = (undefined, read) => ;`, + ) + const argument = /read\(([^)]+)\)/.exec(shadowed.code)?.[1] + assert(argument, shadowed.code) + const selected = new Function( + 'undefined', + 'read', + 'value', + `return read(${argument});`, + )('blue', (value) => value ?? 'red', undefined) + assert.equal(selected, 'red', `${method}: ${binding}`) + } + for (const binding of [ + "import def from 'real';", + 'const def = {};', + "import def from 'empty';", + "import * as def from 'empty';", + "import def from 'whole';", + ]) { + for (const [key, expected] of [ + ['constructor', 'Object'], + ['toString', 'Object.prototype.toString'], + ]) { + const real = extract( + '/src/real.tsx', + `import {Box} from '@devup-ui/react'; ${binding} export const view = ;`, + ) + const condition = /className=\{([^?]+)\?/.exec(real.code)?.[1] + assert(condition, real.code) + assert.equal( + new Function('def', `return ${condition};`)({}), + true, + `${method}: ${binding} ${key}`, + ) + } + } + for (const binding of [ + "import def from 'empty';", + "import * as def from 'empty';", + ]) { + const mutated = extract( + '/src/mutated.tsx', + `import {Box} from '@devup-ui/react'; ${binding} def.color = 'blue'; export const view = ;`, + ) + assert.match(mutated.code, /def\.color/) + assert.match(mutated.css, /color:var\(/) + assert.deepEqual(mutated.dependencies, []) + } +} diff --git a/bindings/devup-ui-wasm/jsx-dev-regressions.mjs b/bindings/devup-ui-wasm/jsx-dev-regressions.mjs new file mode 100644 index 000000000..e77913a3b --- /dev/null +++ b/bindings/devup-ui-wasm/jsx-dev-regressions.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const project = createRequire( + new URL('../../apps/landing/package.json', import.meta.url), +) +const compiler = createRequire(project.resolve('@mdx-js/loader')) +const { compile } = await import( + pathToFileURL(compiler.resolve('@mdx-js/mdx')).href +) +const { parse } = compiler('acorn') +const wasm = createRequire(import.meta.url)('./pkg/index.js') +const markdown = + 'import { Box } from "@devup-ui/react"\n\n\n' +let passed = 0 +let failed = 0 + +function structure(value) { + return JSON.parse( + JSON.stringify(value, (key, item) => + ['start', 'end', 'loc', 'raw'].includes(key) ? undefined : item, + ), + ) +} + +function calls(code) { + const found = [] + function visit(node) { + if (!node || typeof node !== 'object') return + if (node.type === 'CallExpression' && node.callee.name === '_jsxDEV') + found.push(node) + for (const value of Object.values(node)) { + if (Array.isArray(value)) value.forEach(visit) + else visit(value) + } + } + visit(parse(code, { ecmaVersion: 'latest', sourceType: 'module' })) + return found +} + +const root = mkdtempSync(join(tmpdir(), 'devup-jsxdev-')) +try { + for (const extension of ['mdx', 'md', 'mdown']) { + const filename = join(root, `development.${extension}`).replaceAll( + '\\', + '/', + ) + writeFileSync(filename, markdown) + const code = String( + await compile( + { path: filename, value: readFileSync(filename, 'utf8') }, + { development: true, jsx: false, format: 'mdx' }, + ), + ) + const before = calls(code) + const componentCalls = before.flatMap((call, index) => + call.arguments[0].name === 'Box' ? [index] : [], + ) + assert.equal(componentCalls.length, 2) + assert.match(code, /react\/jsx-dev-runtime/) + for (const method of ['codeExtract', 'codeExtractWithoutSourceMap']) { + wasm.resetBuildState() + try { + const output = wasm[method]( + filename, + code, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + extension === 'mdown' ? 'compiled-mdx' : undefined, + ) + try { + const after = calls(output.code) + assert.equal(after.length, before.length) + for (const [index, original] of before.entries()) { + assert.equal(after[index].arguments.length, 6) + assert.deepEqual( + structure(after[index].callee), + structure(original.callee), + ) + assert.deepEqual( + structure(after[index].arguments.slice(2)), + structure(original.arguments.slice(2)), + ) + } + assert.match(wasm.getCss(undefined, false), /background:tomato/) + assert.match(wasm.getCss(undefined, false), /padding:8px/) + assert.doesNotMatch(output.code, /(?:\n' +const aliasMarkdown = + 'import { css } from "@emotion/react"\n\nexport const cls = css({color: "blue"})\n\n
\n' +for (const jsx of [false, true]) { + const code = String( + await compile( + { path: '/src/page.mdown', value: markdown }, + { jsx, format: 'mdx' }, + ), + ) + const alias = String( + await compile( + { path: '/src/alias.mdown', value: aliasMarkdown }, + { jsx, format: 'mdx' }, + ), + ) + for (const method of methods) { + for (const extension of ['md', 'mdx', 'mdown', 'ts']) { + const filename = `/src/page.${extension}` + check(`${method}:${jsx}:${extension}`, () => { + const output = extract( + method, + filename, + code, + ['mdown', 'ts'].includes(extension) ? 'compiled-mdx' : undefined, + ) + try { + assert.match(wasm.getCss(undefined, false), /background:tomato/) + assert.doesNotMatch(output.code, /(?: { + const output = extract( + method, + '/src/alias.mdown', + alias, + 'compiled-mdx', + { '@emotion/react': null }, + ) + try { + assert.match(wasm.getCss(undefined, false), /color:blue/) + } finally { + output.free() + } + }) + for (const reexport of [false, true]) { + check(`${method}:${jsx}:imported:${reexport}`, () => { + wasm.setModuleResolver((specifier) => + specifier === './entry' && reexport + ? { + path: '/src/reexport.js', + code: "export {PRIMARY} from './tokens';", + } + : { + path: '/src/tokens.mdown', + code: `${code}\nexport const PRIMARY = 'blue';`, + sourceType: 'compiled-mdx', + }, + ) + const output = extract( + method, + '/src/App.tsx', + 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./entry"; export const view = ;', + ) + try { + assert.match(wasm.getCss(undefined, false), /color:blue/) + assert.doesNotMatch(wasm.getCss(undefined, false), /var\(--/) + assert.ok(output.dependencies.includes('/src/tokens.mdown')) + } finally { + output.free() + } + }) + } + } +} +for (const method of methods) { + const stylesheetModule = String( + await compile( + { path: '/src/value.mdown', value: '# Heading' }, + { jsx: true, format: 'mdx' }, + ), + ) + check(`${method}:stylesheet-import`, () => { + wasm.setModuleResolver(() => ({ + path: '/src/value.mdown', + code: `${stylesheetModule}\nexport const PRIMARY = 'blue';`, + sourceType: 'compiled-mdx', + })) + const output = extract( + method, + '/src/theme.css.ts', + 'import {style} from "@vanilla-extract/css"; import {PRIMARY} from "./value"; export const cls = style({color: PRIMARY});', + undefined, + { '@vanilla-extract/css': null }, + ) + try { + assert.match(wasm.getCss(undefined, false), /color:blue/) + } finally { + output.free() + } + }) + for (const value of ['invalid', '', 7, {}, true]) { + check(`${method}:invalid-root:${JSON.stringify(value)}`, () => { + const previous = extract( + method, + '/src/plain.js', + 'import {css} from "@devup-ui/react"; export const cls = css({color: "green"});', + ) + previous.free() + const sheet = wasm.exportSheet() + assert.throws( + () => + extract(method, '/src/plain.js', 'export const answer = 42;', value), + /\/src\/plain\.js:1:1:.*source.*type/i, + ) + assert.equal(wasm.exportSheet(), sheet) + }) + } + check(`${method}:unknown-fastpath`, () => { + assert.throws( + () => extract(method, '/src/plain.mdown', 'export const answer = 42;'), + /\/src\/plain\.mdown:1:1:.*Unknown file extension/, + ) + }) + for (const value of ['invalid', 7]) { + check(`${method}:invalid-child:${value}`, () => { + const initial = extract( + method, + '/src/App.tsx', + 'import {Box} from "@devup-ui/react"; export const view = ;', + ) + initial.free() + const sheet = wasm.exportSheet() + wasm.setModuleResolver(() => ({ + path: '/src/token.js', + code: "export const PRIMARY = 'blue';", + sourceType: value, + })) + assert.throws( + () => + extract( + method, + '/src/App.tsx', + 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./token"; export const view = ;', + ), + /\/src\/App\.tsx:1:1:.*source.*type/i, + ) + assert.equal(wasm.exportSheet(), sheet) + }) + } + check(`${method}:getter`, () => { + wasm.setModuleResolver(() => ({ + path: '/src/token.js', + code: "export const PRIMARY = 'blue';", + get sourceType() { + throw new Error('sourceType getter fault') + }, + })) + assert.throws( + () => + extract( + method, + '/src/App.tsx', + 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./token"; export const view = ;', + ), + /\/src\/App\.tsx:1:1:.*sourceType getter fault/, + ) + }) + check(`${method}:root-mode-not-inherited`, () => { + wasm.setModuleResolver(() => ({ + path: '/src/token.ts', + code: "export enum Colors { PRIMARY = 'blue' }", + })) + const output = extract( + method, + '/src/App.mdown', + 'import {Box} from "@devup-ui/react"; import {Colors} from "./token"; export const view = ;', + 'compiled-mdx', + ) + try { + assert.match(wasm.getCss(undefined, false), /color:blue/) + } finally { + output.free() + } + }) + check(`${method}:unknown-child`, () => { + wasm.setModuleResolver(() => ({ + path: '/src/token.custom', + code: "export const PRIMARY = 'blue';", + })) + assert.throws( + () => + extract( + method, + '/src/App.tsx', + 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./token"; export const view = ;', + ), + /\/src\/token\.custom:1:1:.*Unknown file extension/, + ) + }) +} +wasm.resetBuildState() +console.info(JSON.stringify({ passed })) diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 0a2347ac2..4c819f749 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -2,10 +2,11 @@ use css::class_map::{set_class_map, with_class_map}; use css::file_map::{ canonical, is_global, set_canonical_map, set_file_map, with_canonical_map, with_file_map, }; +#[cfg(test)] +use extractor::extract; use extractor::extract_style::extract_style_value::ExtractStyleValue; use extractor::{ - ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, extract, extract_with_modules, - extract_without_source_map, has_devup_ui, + ExtractOption, ImportAlias, ModuleResolution, ModuleResolver, ResolvedModule, has_devup_ui, }; use rustc_hash::FxHashSet; use sheet::StyleSheet; @@ -14,6 +15,8 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::Display; use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; +#[cfg(test)] +mod source_type_tests; static GLOBAL_STYLE_SHEET: LazyLock> = LazyLock::new(|| Mutex::new(StyleSheet::default())); @@ -53,6 +56,52 @@ fn js_error(message: impl Display) -> JsValue { js_sys::Error::new(&message.to_string()).into() } +#[cfg(all(target_arch = "wasm32", not(tarpaulin_include)))] +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console, js_name = error, catch)] + fn report_panic(message: &str) -> Result<(), JsValue>; +} + +#[cfg(not(tarpaulin_include))] +fn resolver_cause(value: &JsValue) -> String { + if let Some(message) = value.as_string() { + return message; + } + if let Ok(message) = js_sys::Reflect::get(value, &"message".into()) + && let Some(message) = message.as_string() + { + return message; + } + match js_sys::JSON::stringify(value) { + Ok(text) => text.as_string().unwrap_or_else(|| "undefined".to_string()), + Err(_) => "".to_string(), + } +} + +/// Report the panic payload and Rust file:line:column before an aborting WASM trap. +#[cfg(all(target_arch = "wasm32", not(tarpaulin_include)))] +#[wasm_bindgen(start)] +pub fn install_panic_hook() { + std::panic::set_hook(Box::new(|info| { + // A broken host console must not replace the original panic with a JS exception. + match report_panic(&info.to_string()) { + Ok(()) | Err(_) => {} + } + })); +} + +/// Prefer a resolver failure over extraction's unresolved-module fallback. +fn checked_extraction( + extracted: Result, + fault: Option<&RefCell>>, +) -> Result { + if let Some(message) = fault.and_then(|fault| fault.borrow_mut().take()) { + return Err(message); + } + extracted.map_err(|error| error.to_string()) +} + #[wasm_bindgen] pub struct Output { code: String, @@ -243,7 +292,14 @@ pub fn export_sheet() -> Result { /// Internal function to export class map as JSON string (testable without `JsValue`) pub fn export_class_map_internal() -> Result { - with_class_map(serde_json::to_string).map_err(|e| e.to_string()) + with_class_map(|map| { + let sorted: BTreeMap<&String, BTreeMap<&String, &usize>> = map + .iter() + .map(|(file, classes)| (file, classes.iter().collect())) + .collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importClassMap")] @@ -261,7 +317,11 @@ pub fn export_class_map() -> Result { /// Internal function to export file map as JSON string (testable without `JsValue`) pub fn export_file_map_internal() -> Result { - with_file_map(serde_json::to_string).map_err(|e| e.to_string()) + with_file_map(|map| { + let sorted: BTreeMap<&String, &usize> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importFileMap")] @@ -284,7 +344,11 @@ pub fn import_canonical_map_internal(map: HashMap) { /// Internal function to export the canonical map as JSON string (testable without `JsValue`) pub fn export_canonical_map_internal() -> Result { - with_canonical_map(serde_json::to_string).map_err(|e| e.to_string()) + with_canonical_map(|map| { + let sorted: BTreeMap<&String, &String> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importCanonicalMap")] @@ -300,6 +364,34 @@ pub fn export_canonical_map() -> Result { export_canonical_map_internal().map_err(js_error) } +/// Number every file in `files` now, in path order, keeping numbers files +/// already hold, so class prefixes depend on the paths and not on the order +/// workers reach files in. +#[wasm_bindgen(js_name = "seedFileMap")] +pub fn seed_file_map(files: Vec) { + css::file_map::seed_file_numbers(&files); +} + +/// Forget everything one build left in the engine. +/// +/// That is names, numbers, styles, buckets, routes, the prefix, atom hoisting +/// and the module resolver, so the next build starts from its own options +/// alone. Theme, shorthands and debug mode are set by every build, and stay. +pub fn reset_build_state_internal() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::file_routes::set_file_routes(HashMap::new()); + css::atom_hoist::set_atom_hoist(None); + css::set_prefix(None); + with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); + MODULE_RESOLVER.with_borrow_mut(|current| *current = None); +} + +#[wasm_bindgen(js_name = "resetBuildState")] +pub fn reset_build_state() { + reset_build_state_internal(); +} /// Set the atom-level hoist threshold. /// /// When set to `Some(n)`, a style atom whose content is used by `>= n` distinct @@ -345,6 +437,7 @@ pub fn code_extract_internal( import_main_css_in_code: bool, import_main_css_in_css: bool, import_aliases: HashMap, + source_type: Option, ) -> Result { code_extract_internal_impl( filename, @@ -357,6 +450,8 @@ pub fn code_extract_internal( import_aliases, SourceMapMode::Generate, None, + None, + source_type, ) } @@ -370,6 +465,7 @@ pub fn code_extract_without_source_map_internal( import_main_css_in_code: bool, import_main_css_in_css: bool, import_aliases: HashMap, + source_type: Option, ) -> Result { code_extract_internal_impl( filename, @@ -382,6 +478,8 @@ pub fn code_extract_without_source_map_internal( import_aliases, SourceMapMode::Skip, None, + None, + source_type, ) } @@ -397,6 +495,7 @@ pub fn code_extract_with_modules_internal( import_main_css_in_code: bool, import_main_css_in_css: bool, import_aliases: HashMap, + source_type: Option, resolver: &ModuleResolver, ) -> Result { code_extract_internal_impl( @@ -410,6 +509,8 @@ pub fn code_extract_with_modules_internal( import_aliases, SourceMapMode::Generate, Some(resolver), + None, + source_type, ) } @@ -425,6 +526,8 @@ fn code_extract_internal_impl( import_aliases: HashMap, source_map: SourceMapMode, resolver: Option<&ModuleResolver>, + resolver_fault: Option<&RefCell>>, + source_type: Option, ) -> Result { let option = ExtractOption { package: package.to_string(), @@ -433,19 +536,16 @@ fn code_extract_internal_impl( import_main_css: import_main_css_in_code, import_aliases, }; - let extracted = match (resolver, source_map) { - (Some(resolver), mode) => extract_with_modules( - filename, - code, - option, - matches!(mode, SourceMapMode::Generate), - resolver, - ), - (None, SourceMapMode::Generate) => extract(filename, code, option), - (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option), - }; + let extracted = extractor::extract_with_source_type( + filename, + code, + option, + matches!(source_map, SourceMapMode::Generate), + resolver, + source_type, + ); - match extracted { + match checked_extraction(extracted, resolver_fault) { Ok(output) => Ok(Output::new( output.code, output.styles, @@ -456,7 +556,7 @@ fn code_extract_internal_impl( import_main_css_in_css, output.dependencies, )), - Err(error) => Err(error.to_string()), + Err(error) => Err(error), } } @@ -475,19 +575,53 @@ fn call_module_resolver( resolver: &js_sys::Function, specifier: &str, importer: &str, -) -> Option { +) -> Result, String> { let module = resolver .call2(&JsValue::NULL, &specifier.into(), &importer.into()) - .ok()?; + .map_err(|error| resolver_cause(&error))?; + if module.is_null() || module.is_undefined() { + return Ok(None); + } + if !module.is_object() { + return Err("resolver result must be an object".to_string()); + } + let ignored = js_sys::Reflect::get(&module, &"ignored".into()).map_err(|error| { + format!( + "reading resolver field `ignored`: {}", + resolver_cause(&error) + ) + })?; + if !ignored.is_undefined() { + return match ignored.as_bool() { + Some(true) => Ok(Some(ModuleResolution::Ignored)), + Some(false) | None => { + Err("resolver field `ignored` must be true or undefined".to_string()) + } + }; + } let field = |name: &str| { js_sys::Reflect::get(&module, &name.into()) - .ok()? + .map_err(|error| { + format!( + "reading resolver field `{name}`: {}", + resolver_cause(&error) + ) + })? .as_string() + .ok_or_else(|| format!("resolver field `{name}` must be a string")) }; - Some(ResolvedModule { + Ok(Some(ModuleResolution::Resolved(ResolvedModule { path: field("path")?, code: field("code")?, - }) + source_type: source_type_from_js( + js_sys::Reflect::get(&module, &"sourceType".into()).map_err(|error| { + format!( + "reading resolver field `sourceType`: {}", + resolver_cause(&error) + ) + })?, + )?, + }))) } /// Extract with the resolver set by `setModuleResolver`, if any @@ -503,10 +637,29 @@ fn code_extract_js( import_main_css_in_css: bool, import_aliases: JsValue, source_map: SourceMapMode, + source_type: JsValue, ) -> Result { + let source_type = source_type_from_js(source_type) + .map_err(|error| js_error(format!("{filename}:1:1: {error}")))?; let import_aliases = import_aliases_from_js(import_aliases)?; + let fault = std::rc::Rc::new(RefCell::new(None)); + let resolver_fault = std::rc::Rc::clone(&fault); let resolver = MODULE_RESOLVER.with_borrow(Clone::clone).map(|resolver| { - move |specifier: &str, importer: &str| call_module_resolver(&resolver, specifier, importer) + move |specifier: &str, importer: &str| { + if resolver_fault.borrow().is_some() { + return None; + } + match call_module_resolver(&resolver, specifier, importer) { + Ok(module) => module, + Err(cause) => { + // ModuleResolver carries no source span, including for transitive imports. + *resolver_fault.borrow_mut() = Some(format!( + "{importer}:1:1: module resolver cannot use `{specifier}` at build time: {cause}" + )); + None + } + } + } }); code_extract_internal_impl( filename, @@ -521,10 +674,23 @@ fn code_extract_js( resolver .as_ref() .map(|resolver| resolver as &ModuleResolver), + Some(&fault), + source_type, ) .map_err(js_error) } +#[cfg(not(tarpaulin_include))] +fn source_type_from_js(value: JsValue) -> Result, String> { + if value.is_undefined() || value.is_null() { + return extractor::parse_source_type(None); + } + let value = value.as_string().ok_or_else(|| { + "source type cannot use a non-string at build time: expected `compiled-mdx`".to_string() + })?; + extractor::parse_source_type(Some(&value)) +} + #[cfg(not(tarpaulin_include))] fn import_aliases_from_js( import_aliases: JsValue, @@ -544,7 +710,7 @@ fn import_aliases_from_js( } #[cfg(not(tarpaulin_include))] -#[wasm_bindgen(js_name = "codeExtract")] +#[wasm_bindgen(js_name = "codeExtract", skip_typescript)] #[allow(clippy::too_many_arguments)] pub fn code_extract( filename: &str, @@ -555,6 +721,7 @@ pub fn code_extract( import_main_css_in_code: bool, import_main_css_in_css: bool, import_aliases: JsValue, + source_type: JsValue, ) -> Result { code_extract_js( filename, @@ -566,11 +733,12 @@ pub fn code_extract( import_main_css_in_css, import_aliases, SourceMapMode::Generate, + source_type, ) } #[cfg(not(tarpaulin_include))] -#[wasm_bindgen(js_name = "codeExtractWithoutSourceMap")] +#[wasm_bindgen(js_name = "codeExtractWithoutSourceMap", skip_typescript)] #[allow(clippy::too_many_arguments)] pub fn code_extract_without_source_map( filename: &str, @@ -581,6 +749,7 @@ pub fn code_extract_without_source_map( import_main_css_in_code: bool, import_main_css_in_css: bool, import_aliases: JsValue, + source_type: JsValue, ) -> Result { code_extract_js( filename, @@ -591,10 +760,17 @@ pub fn code_extract_without_source_map( import_main_css_in_code, import_main_css_in_css, import_aliases, - SourceMapMode::Generate, + SourceMapMode::Skip, + source_type, ) } +#[wasm_bindgen(typescript_custom_section)] +const EXTRACTION_TYPES: &str = r#" +export function codeExtract(filename: string, code: string, package: string, css_dir: string, single_css: boolean, import_main_css_in_code: boolean, import_main_css_in_css: boolean, import_aliases: Record, sourceType?: 'compiled-mdx'): Output; +export function codeExtractWithoutSourceMap(filename: string, code: string, package: string, css_dir: string, single_css: boolean, import_main_css_in_code: boolean, import_main_css_in_css: boolean, import_aliases: Record, sourceType?: 'compiled-mdx'): Output; +"#; + /// Internal function to register theme (testable without `JsValue`) pub fn register_theme_internal(theme: sheet::theme::Theme) { with_style_sheet_mut(|sheet| sheet.set_theme(theme)); @@ -689,6 +865,53 @@ mod tests { ct } + #[rstest] + #[case(Ok(42), false, None, Ok(42))] + #[case(Err("extract failed"), false, None, Err("extract failed"))] + #[case(Ok(42), true, None, Ok(42))] + #[case(Ok(42), true, Some("resolver failed"), Err("resolver failed"))] + #[case( + Err("extract failed"), + true, + Some("resolver failed"), + Err("resolver failed") + )] + fn boundary_fault_takes_precedence_when_resolver_failed( + #[case] extracted: Result, + #[case] has_resolver: bool, + #[case] message: Option<&str>, + #[case] expected: Result, + ) { + let fault = has_resolver.then(|| RefCell::new(message.map(str::to_string))); + let result = checked_extraction(extracted, fault.as_ref()); + assert_eq!(result, expected.map_err(str::to_string)); + } + + #[test] + #[serial] + fn boundary_sheet_stays_unchanged_when_resolver_failed() { + reset_build_state_internal(); + let before = export_sheet_internal().unwrap(); + let fault = RefCell::new(Some("resolver failed".to_string())); + let result = code_extract_internal_impl( + "boundary.tsx", + "import { Box } from '@devup-ui/react'; export const view = ;", + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + SourceMapMode::Skip, + None, + Some(&fault), + None, + ); + assert_eq!(result.err(), Some("resolver failed".to_string())); + assert_eq!(export_sheet_internal().unwrap(), before); + reset_build_state_internal(); + } + #[test] #[serial] fn atom_hoist_splits_global_and_private() { @@ -726,6 +949,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); code_extract_internal( @@ -737,6 +961,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); @@ -871,6 +1096,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); } @@ -921,6 +1147,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); } @@ -1023,6 +1250,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); } @@ -1183,9 +1411,12 @@ mod tests { *sheet = StyleSheet::default(); } let resolver = |specifier: &str, _: &str| { - (specifier == "./tokens").then(|| ResolvedModule { - path: "/src/tokens.ts".to_string(), - code: "export const PRIMARY = 'red'".to_string(), + (specifier == "./tokens").then(|| { + ModuleResolution::Resolved(ResolvedModule { + source_type: None, + path: "/src/tokens.ts".to_string(), + code: "export const PRIMARY = 'red'".to_string(), + }) }) }; { @@ -1198,6 +1429,7 @@ mod tests { false, false, HashMap::new(), + None, &resolver, ) .unwrap(); @@ -1709,6 +1941,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); } @@ -1929,6 +2162,60 @@ mod tests { assert!(json_str.starts_with('{') || json_str.starts_with("[]")); } + #[test] + #[serial] + fn test_exported_maps_are_canonical_json() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::class_map::set_class_map(HashMap::from([ + ( + "b.tsx".to_string(), + HashMap::from([("z".to_string(), 1), ("a".to_string(), 0)]), + ), + ("a.tsx".to_string(), HashMap::from([("k".to_string(), 0)])), + ])); + assert_eq!( + export_class_map_internal().unwrap(), + r#"{"a.tsx":{"k":0},"b.tsx":{"a":0,"z":1}}"# + ); + seed_file_map(vec!["b.tsx".to_string(), "a.tsx".to_string()]); + assert_eq!( + export_file_map_internal().unwrap(), + r#"{"a.tsx":0,"b.tsx":1}"# + ); + import_canonical_map_internal(HashMap::from([ + ("y".to_string(), "b".to_string()), + ("x".to_string(), "a".to_string()), + ])); + assert_eq!( + export_canonical_map_internal().unwrap(), + r#"{"x":"a","y":"b"}"# + ); + reset_build_state(); + assert_eq!(export_class_map_internal().unwrap(), "{}"); + assert_eq!(export_file_map_internal().unwrap(), "{}"); + assert_eq!(export_canonical_map_internal().unwrap(), "{}"); + assert_eq!(get_prefix(), None); + } + + #[test] + #[serial] + fn test_numbers_do_not_depend_on_the_order_files_are_seen() { + let files = ["src/b.tsx", "src/a.tsx", "src/c.tsx"]; + let mut maps = Vec::new(); + for order in [[0, 1, 2], [2, 1, 0], [1, 2, 0]] { + reset_build_state_internal(); + seed_file_map(files.iter().map(ToString::to_string).collect()); + for index in order { + let _ = css::file_map::get_file_num_by_filename(files[index]); + } + maps.push(export_file_map_internal().unwrap()); + } + assert_eq!(maps[0], r#"{"src/a.tsx":0,"src/b.tsx":1,"src/c.tsx":2}"#); + assert_eq!(maps[0], maps[1]); + assert_eq!(maps[0], maps[2]); + } #[test] #[serial] fn test_code_extract_internal_success() { @@ -1947,6 +2234,7 @@ mod tests { false, false, HashMap::new(), + None, ); assert!(result.is_ok()); @@ -1971,6 +2259,7 @@ mod tests { false, false, HashMap::new(), + None, ); assert!(result.is_ok()); @@ -1996,6 +2285,7 @@ mod tests { false, false, HashMap::new(), + None, ); assert!(result.is_err()); diff --git a/bindings/devup-ui-wasm/src/source_type_tests.rs b/bindings/devup-ui-wasm/src/source_type_tests.rs new file mode 100644 index 000000000..d38d3a7b9 --- /dev/null +++ b/bindings/devup-ui-wasm/src/source_type_tests.rs @@ -0,0 +1,100 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn source_type_extracts_compiled_mdx_when_native_variants_use_real_filename() -> Result<(), String> +{ + reset_build_state_internal(); + let code = "import {Box} from '@devup-ui/react'; export const view = ;"; + let mode = Some(extractor::ExtractSourceType::CompiledMdx); + let mapped = code_extract_internal( + "page.mdown", + code, + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + mode, + )?; + assert!(mapped.map().is_some_and(|map| map.contains("page.mdown"))); + let unmapped = code_extract_without_source_map_internal( + "page.mdown", + code, + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + mode, + )?; + assert_eq!(unmapped.map(), None); + let resolver = |_: &str, _: &str| { + Some(ModuleResolution::Resolved(ResolvedModule { + path: "tokens.mdown".to_string(), + code: "export function view() {return
;} export const PRIMARY = 'blue';" + .to_string(), + source_type: mode, + })) + }; + let imported = code_extract_with_modules_internal( + "page.mdown", + "import {Box} from '@devup-ui/react'; import {PRIMARY} from './tokens'; export const view = ;", + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + mode, + &resolver, + )?; + assert_eq!(imported.dependencies(), ["tokens.mdown"]); + assert!(!with_style_sheet(|sheet| sheet.create_css(None, false)).contains("var(--")); + reset_build_state_internal(); + Ok(()) +} + +#[test] +#[serial] +fn source_type_preserves_sheet_when_unknown_child_validation_fails() -> Result<(), String> { + reset_build_state_internal(); + code_extract_internal( + "page.tsx", + "import {Box} from '@devup-ui/react'; export const view = ;", + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + None, + )?; + let before = export_sheet_internal()?; + let resolver = |_: &str, _: &str| { + Some(ModuleResolution::Resolved(ResolvedModule { + path: "tokens.custom".to_string(), + code: "export const PRIMARY = 'red';".to_string(), + source_type: None, + })) + }; + let result = code_extract_with_modules_internal( + "page.tsx", + "import {Box} from '@devup-ui/react'; import {PRIMARY} from './tokens'; export const view = ;", + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + None, + &resolver, + ); + assert!(result.is_err_and(|error| error.starts_with("tokens.custom:1:1:"))); + assert_eq!(export_sheet_internal()?, before); + reset_build_state_internal(); + Ok(()) +} diff --git a/bun.lock b/bun.lock index a6f7b10d1..906ce66d6 100644 --- a/bun.lock +++ b/bun.lock @@ -489,11 +489,11 @@ }, "bindings/devup-ui-wasm": { "name": "@devup-ui/wasm", - "version": "1.0.82", + "version": "1.0.83", }, "packages/bun-plugin": { "name": "@devup-ui/bun-plugin", - "version": "1.0.21", + "version": "1.0.22", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -508,7 +508,7 @@ }, "packages/components": { "name": "@devup-ui/components", - "version": "0.1.59", + "version": "0.1.60", "dependencies": { "@devup-ui/react": "workspace:^", "clsx": "^2.1", @@ -535,7 +535,7 @@ }, "packages/eslint-plugin": { "name": "@devup-ui/eslint-plugin", - "version": "1.0.21", + "version": "1.0.22", "dependencies": { "@typescript-eslint/utils": "^8.70", "typescript-eslint": "^8.70", @@ -552,7 +552,7 @@ }, "packages/next-plugin": { "name": "@devup-ui/next-plugin", - "version": "1.0.89", + "version": "1.0.90", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -570,7 +570,7 @@ }, "packages/plugin-utils": { "name": "@devup-ui/plugin-utils", - "version": "1.0.16", + "version": "1.0.17", "devDependencies": { "typescript": "^7.0.2", }, @@ -611,7 +611,7 @@ }, "packages/rsbuild-plugin": { "name": "@devup-ui/rsbuild-plugin", - "version": "1.0.66", + "version": "1.0.67", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -627,7 +627,7 @@ }, "packages/vite-plugin": { "name": "@devup-ui/vite-plugin", - "version": "1.0.72", + "version": "1.0.73", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -642,7 +642,7 @@ }, "packages/webpack-plugin": { "name": "@devup-ui/webpack-plugin", - "version": "1.0.70", + "version": "1.0.71", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", diff --git a/bun.preload.ts b/bun.preload.ts new file mode 100644 index 000000000..4d90997be --- /dev/null +++ b/bun.preload.ts @@ -0,0 +1,3 @@ +import { register } from './packages/bun-plugin/src/register' + +await register({ debug: true }) diff --git a/bunfig.toml b/bunfig.toml index 1241b1841..67ea09046 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,11 +1,8 @@ [test] root = "packages" -preload = ["./packages/bun-plugin/src/index.ts", "./bun.setup.ts", "bun-test-env-dom"] +preload = ["./bun.preload.ts", "./bun.setup.ts", "bun-test-env-dom"] coverage = true coverageReporter = ["text", "lcov"] coverageThreshold = { line = 1.0, function = 1.0, statement = 1.0 } coverageSkipTestFiles = true -# pkg/dist are generated artifacts. bun-plugin/src/plugin.ts registers a Bun -# plugin at import time; testing its private hooks would require exporting -# internals that are not part of the public API. -coveragePathIgnorePatterns = ["**/pkg/**", "**/dist/**", "**/bun-plugin/src/plugin.ts"] +coveragePathIgnorePatterns = ["**/pkg/**", "**/dist/**"] diff --git a/libs/css/src/file_map.rs b/libs/css/src/file_map.rs index 2a8c86a94..f0a20a939 100644 --- a/libs/css/src/file_map.rs +++ b/libs/css/src/file_map.rs @@ -93,6 +93,19 @@ pub fn get_file_num_by_filename(filename: &str) -> usize { }) } +/// Give every file in `files` a number now, in path order. +/// +/// Numbers files already hold are kept, and a file collapsed into a bucket is numbered as the bucket. The numbers files get then depend on the paths alone, +/// not on which file a worker reaches first; files that appear later (in +/// development) are numbered after the existing ones. +pub fn seed_file_numbers(files: &[String]) { + let mut sorted: Vec = files.iter().map(|file| canonical(file)).collect(); + sorted.sort_unstable(); + sorted.dedup(); + for file in sorted { + let _ = get_file_num_by_filename(&file); + } +} #[must_use] pub fn get_filename_by_file_num(file_num: usize) -> String { with_file_map(|map| { @@ -196,6 +209,47 @@ mod tests { use super::*; + #[test] + #[serial] + fn test_seed_file_numbers_numbers_buckets_not_their_members() { + reset_file_map(); + set_canonical_map(std::collections::HashMap::from([( + "x.tsx".to_string(), + "a.tsx".to_string(), + )])); + seed_file_numbers(&[ + "x.tsx".to_string(), + "b.tsx".to_string(), + "a.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("b.tsx"), 1); + assert_eq!(get_file_map().len(), 2); + reset_canonical_map(); + reset_file_map(); + } + + #[test] + #[serial] + fn test_seed_file_numbers_is_independent_of_order() { + reset_file_map(); + seed_file_numbers(&[ + "b.tsx".to_string(), + "a.tsx".to_string(), + "b.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("b.tsx"), 1); + seed_file_numbers(&[ + "c.tsx".to_string(), + "0.tsx".to_string(), + "a.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("0.tsx"), 2); + assert_eq!(get_file_num_by_filename("c.tsx"), 3); + reset_file_map(); + } #[test] #[serial] fn test_set_and_get_file_map() { diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 1e8bdd7e3..db8d5be59 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -15,7 +15,7 @@ use oxc_ast::ast::{ use oxc_ast_visit::{Visit, walk}; use oxc_parser::Parser; use oxc_semantic::{Scoping, SemanticBuilder}; -use oxc_span::{GetSpan, SourceType, Span}; +use oxc_span::{GetSpan, Span}; use oxc_syntax::operator::LogicalOperator; use oxc_syntax::reference::ReferenceId; use oxc_syntax::symbol::SymbolId; @@ -62,9 +62,10 @@ pub(crate) fn has_build_time_values( filename, &option.package, &option.import_aliases, + crate::parser_source_type(filename).unwrap_or_default(), ); let allocator = Allocator::default(); - let Some(mut program) = parse(&allocator, filename, &code) else { + let Some(mut program) = parse(&allocator, filename, &code, None) else { return false; }; let inlined = crate::imported_constants::inline_constants( @@ -130,8 +131,13 @@ const UNCERTAIN_MEMBERS: [&str; 9] = [ "toString", ]; -fn parse<'a>(allocator: &'a Allocator, filename: &str, code: &'a str) -> Option> { - let source_type = SourceType::from_path(filename).ok()?; +fn parse<'a>( + allocator: &'a Allocator, + filename: &str, + code: &'a str, + mode: Option, +) -> Option> { + let source_type = crate::source_type::parser_type(filename, mode).ok()?; let parsed = Parser::new(allocator, code, source_type).parse(); (!parsed.fatal_error).then_some(parsed.program) } @@ -916,12 +922,13 @@ pub(crate) fn evaluate( option: &ExtractOption, resolver: Option<&ModuleResolver>, unknown: &crate::imported_constants::Unknown, + mode: Option, ) -> Option<( String, Vec, BTreeSet, )> { - let (mut values, dependencies) = compute(code, filename, option, resolver, unknown)?; + let (mut values, dependencies) = compute(code, filename, option, resolver, unknown, mode)?; values.sort_unstable_by_key(|(span, _)| span.start); let mut result = String::with_capacity(code.len()); let mut edits = Vec::with_capacity(values.len()); @@ -968,13 +975,14 @@ fn compute( option: &ExtractOption, resolver: Option<&ModuleResolver>, unknown: &crate::imported_constants::Unknown, + mode: Option, ) -> Option<(Vec, BTreeSet)> { use std::fmt::Write; use boa_engine::{Context, JsObject, Source}; let allocator = Allocator::default(); - let program = parse(&allocator, filename, code)?; + let program = parse(&allocator, filename, code, mode)?; let is_style = |source: &str| { source.starts_with(option.package.as_str()) || option.import_aliases.contains_key(source) }; @@ -1059,7 +1067,8 @@ fn compute( .collect(); let _ = writeln!(module, "[{}];", names.join(", ")); - let script = crate::vanilla_extract::strip_typescript(&module, filename); + let script = + crate::vanilla_extract::strip_typescript_with_type(&module, filename, mode).ok()?; let mut context = Context::default(); context .runtime_limits_mut() diff --git a/libs/extractor/src/ignored_loader_tests.rs b/libs/extractor/src/ignored_loader_tests.rs new file mode 100644 index 000000000..89303f3ef --- /dev/null +++ b/libs/extractor/src/ignored_loader_tests.rs @@ -0,0 +1,68 @@ +use super::*; + +#[test] +fn ignored_loader_when_imported_or_reexported_has_empty_namespace() -> Result<(), String> { + let resolver = |specifier: &str, _: &str| { + match specifier { + "empty" => Some(ModuleResolution::Ignored), + "barrel" => Some(ModuleResolution::Resolved(crate::ResolvedModule { + path: "/src/barrel.js".to_string(), + code: "export {default as def, missing as value, __exports__ as hidden} from 'empty'; export * as ns from 'empty'; export * from 'empty';".to_string(), + source_type: None, + })), + "cjs" => Some(ModuleResolution::Resolved(crate::ResolvedModule { + path: "/src/cjs.js".to_string(), + code: "const first = require('empty'); const second = require('empty'); const {missing} = require('empty'); module.exports = {ok: first === second && Object.keys(first).length === 0 && first.missing === undefined && missing === undefined};".to_string(), + source_type: None, + })), + _ => None, + } + }; + let option = ExtractOption::default(); + let mut loader = ModuleLoader::new(Some(&resolver), &option); + let script = module_script( + "import def, {missing, __exports__ as hidden} from 'empty'; import * as ns from 'empty'; import * as barrel from 'barrel'; import cjs from 'cjs'; const ok = missing === undefined && hidden === undefined && Object.keys(def).length === 0 && Object.keys(ns).length === 0 && barrel.value === undefined && barrel.hidden === undefined && Object.keys(barrel.ns).length === 0 && Object.keys(barrel.def).length === 0 && Object.keys(barrel).length === 4 && cjs.ok; ok;", + "/src/entry.js", + &mut loader, + true, + )?; + let value = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&format!( + "{}{}", + loader.prelude(), + script.body + ))) + .map_err(|error| error.to_string())?; + assert_eq!(value.as_boolean(), Some(true)); + assert_eq!( + loader.dependencies, + BTreeSet::from(["/src/barrel.js".to_string(), "/src/cjs.js".to_string()]) + ); + assert_eq!(loader.kept_imports, Vec::::new()); + Ok(()) +} + +#[test] +fn ignored_named_when_key_is_inherited_is_undefined() -> Result<(), String> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + let option = ExtractOption::default(); + for key in ["constructor", "toString", "__proto__", "hasOwnProperty"] { + let mut loader = ModuleLoader::new(Some(&resolver), &option); + let script = module_script( + &format!("import {{ {key} as value }} from 'empty'; typeof value === 'undefined';"), + "/src/entry.js", + &mut loader, + true, + )?; + let value = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&format!( + "{}{}", + loader.prelude(), + script.body + ))) + .map_err(|error| error.to_string())?; + assert_eq!(value.as_boolean(), Some(true), "{key}"); + assert_eq!(loader.dependencies, BTreeSet::new()); + } + Ok(()) +} diff --git a/libs/extractor/src/ignored_module_tests.rs b/libs/extractor/src/ignored_module_tests.rs new file mode 100644 index 000000000..84c1193fd --- /dev/null +++ b/libs/extractor/src/ignored_module_tests.rs @@ -0,0 +1,254 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn ignored_default_when_serialized_is_an_ordinary_empty_object() -> Result<(), String> { + let filename = "/src/ignored-literal.ts"; + let allocator = Allocator::default(); + let parsed = Parser::new( + &allocator, + "import ignored from 'empty';", + SourceType::from_path(filename).map_err(|error| error.to_string())?, + ) + .parse(); + assert_eq!(parsed.diagnostics.errors().count(), 0); + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + let option = ExtractOption::default(); + let check = + imported_constants::ChangeCheck::new(&parsed.program, filename, &option, Some(&resolver)); + let literal = check + .known("ignored") + .ok_or_else(|| "Ignored default import must have a known value".to_string())?; + let value = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&format!( + "const value = {literal}; Object.keys(value).length === 0 && Object.getPrototypeOf(value) === Object.prototype;" + ))) + .map_err(|error| error.to_string())?; + assert_eq!(value.as_boolean(), Some(true)); + Ok(()) +} + +#[test] +#[serial] +fn ignored_object_when_spread_into_style_preserves_static_color() -> Result<(), Box> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + let output = extract_with_modules( + "/src/ignored-spread.ts", + "import {css} from '@devup-ui/react'; import ignored from 'empty'; const input = {...ignored, color: 'red'}; export const cls = css(input);", + ExtractOption::default(), + false, + &resolver, + )?; + assert_eq!(output.styles.len(), 1); + assert!(output.styles.iter().all(|style| matches!( + style, ExtractStyleValue::Static(style) if style.property == "color" && style.value == "red" + ))); + assert!(!output.code.contains("css("), "{}", output.code); + assert_eq!(output.dependencies, Vec::::new()); + Ok(()) +} + +#[test] +#[serial] +fn ignored_object_when_used_as_style_extracts_empty_static_rules() -> Result<(), Box> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + let output = extract_with_modules( + "/src/ignored-style.ts", + "import {css} from '@devup-ui/react'; import ignored from 'empty'; export const cls = css(ignored);", + ExtractOption::default(), + false, + &resolver, + )?; + assert_eq!(output.styles.len(), 0, "{:?}", output.styles); + assert!(!output.code.contains("css("), "{}", output.code); + assert!(!output.code.contains("--"), "{}", output.code); + assert_eq!(output.dependencies, Vec::::new()); + Ok(()) +} + +#[test] +#[serial] +fn ignored_named_values_when_resolved_have_no_dynamic_style_or_dependency() +-> Result<(), Box> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + for binding in [ + "import { missing as value } from 'empty';", + "import * as ns from 'empty'; const value = ns.missing;", + "import def from 'empty'; const value = def.missing;", + ] { + let output = extract_with_modules( + "/src/ignored.tsx", + &format!( + "import {{Box}} from '@devup-ui/react'; {binding} export const view = ;" + ), + ExtractOption::default(), + false, + &resolver, + )?; + assert_eq!(output.dependencies, Vec::::new()); + assert!(!output.code.contains("--"), "{}", output.code); + } + Ok(()) +} + +#[test] +#[serial] +fn ignored_stylesheet_bindings_when_evaluated_are_empty_commonjs() -> Result<(), Box> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + let output = extract_with_modules( + "/src/ignored.css.ts", + "import {style} from '@devup-ui/react'; import def, {missing} from 'empty'; import * as ns from 'empty'; function color() { if (missing !== undefined || Object.keys(ns).length || Object.keys(def).length) throw new Error('bad interop'); return 'red'; } export const cls = style({color: color()});", + ExtractOption::default(), + false, + &resolver, + )?; + assert_eq!(output.dependencies, Vec::::new()); + assert!(!output.code.contains("style(")); + assert!(!output.code.contains("--")); + Ok(()) +} + +#[test] +#[serial] +fn ignored_constants_when_required_or_reexported_remain_known() -> Result<(), Box> { + let resolver = |specifier: &str, _: &str| { + match specifier { + "empty" => Some(ModuleResolution::Ignored), + "barrel" => Some(ModuleResolution::Resolved(ResolvedModule { + path: "/src/barrel.js".to_string(), + code: "export {missing as value} from 'empty'; export * as ns from 'empty'; export * from 'empty';".to_string(), + source_type: None, + })), + "cjs" => Some(ModuleResolution::Resolved(ResolvedModule { + path: "/src/cjs.js".to_string(), + code: "module.exports = require('empty');".to_string(), + source_type: None, + })), + "cjsProps" => Some(ModuleResolution::Resolved(ResolvedModule { + path: "/src/cjsProps.js".to_string(), + code: "const {missing} = require('empty'); exports.value = missing; exports.other = require('empty').absent;".to_string(), + source_type: None, + })), + _ => None, + } + }; + for (binding, dependencies) in [ + ("import {value} from 'barrel';", vec!["/src/barrel.js"]), + ( + "import {ns} from 'barrel'; const value = ns.missing;", + vec!["/src/barrel.js"], + ), + ( + "import def from 'cjs'; const value = def.missing;", + vec!["/src/cjs.js"], + ), + ("import {value} from 'cjsProps';", vec!["/src/cjsProps.js"]), + ( + "import {other as value} from 'cjsProps';", + vec!["/src/cjsProps.js"], + ), + ] { + let output = extract_with_modules( + "/src/entry.tsx", + &format!( + "import {{Box}} from '@devup-ui/react'; {binding} export const view = ;" + ), + ExtractOption::default(), + false, + &resolver, + )?; + assert_eq!(output.dependencies, dependencies); + assert!(!output.code.contains("--"), "{}", output.code); + } + Ok(()) +} + +#[test] +#[serial] +fn ignored_object_when_mutated_is_not_inlined_as_empty() -> Result<(), Box> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + for binding in ["import def from 'empty';", "import * as def from 'empty';"] { + let output = extract_with_modules( + "/src/mutated.tsx", + &format!( + "import {{Box}} from '@devup-ui/react'; {binding} def.color = 'blue'; export const view = ;" + ), + ExtractOption::default(), + false, + &resolver, + )?; + assert!(output.code.contains("--")); + assert_eq!(output.dependencies, Vec::::new()); + } + Ok(()) +} + +#[test] +#[serial] +fn real_commonjs_when_empty_keeps_inherited_reads() -> Result<(), Box> { + let resolver = |_: &str, _: &str| { + Some(ModuleResolution::Resolved(ResolvedModule { + path: "/src/tokens.js".to_string(), + code: "module.exports = {};".to_string(), + source_type: None, + })) + }; + let output = extract_with_modules( + "/src/real.tsx", + "import {Box} from '@devup-ui/react'; import def from 'tokens'; export const view = ;", + ExtractOption::default(), + false, + &resolver, + )?; + assert!(output.code.contains("def.constructor"), "{}", output.code); + assert_eq!(output.dependencies, vec!["/src/tokens.js"]); + Ok(()) +} + +#[test] +#[serial] +fn ignored_missing_when_undefined_is_shadowed_keeps_primitive_argument() +-> Result<(), Box> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + let output = extract_with_modules( + "/src/shadowed.tsx", + "import {Box} from '@devup-ui/react'; import {missing} from 'empty'; export const View = (undefined, read) => ;", + ExtractOption::default(), + false, + &resolver, + )?; + assert!(output.code.contains("read(void 0)"), "{}", output.code); + assert_eq!(output.dependencies, Vec::::new()); + Ok(()) +} + +#[test] +#[serial] +fn unresolved_require_when_dynamic_or_shadowed_is_not_an_ignored_constant() +-> Result<(), Box> { + for code in [ + "module.exports = require(name);", + "module.exports = require('missing');", + "const require = () => ({}); module.exports = require('empty');", + ] { + let resolver = |specifier: &str, _: &str| match specifier { + "tokens" => Some(ModuleResolution::Resolved(ResolvedModule { + path: "/src/tokens.js".to_string(), + code: code.to_string(), + source_type: None, + })), + "empty" => Some(ModuleResolution::Ignored), + _ => None, + }; + let output = extract_with_modules( + "/src/unresolved.tsx", + "import {Box} from '@devup-ui/react'; import value from 'tokens'; export const view = ;", + ExtractOption::default(), + false, + &resolver, + )?; + assert!(output.code.contains("--")); + } + Ok(()) +} diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 158d0f420..e8fb47d20 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -22,7 +22,7 @@ use oxc_ast_visit::{ walk::{walk_call_expression, walk_jsx_opening_element}, }; use oxc_parser::Parser; -use oxc_span::{GetSpan, SourceType}; +use oxc_span::GetSpan; use std::borrow::Cow; use std::collections::HashMap; @@ -226,7 +226,14 @@ pub fn transform_import_aliases<'a>( package: &str, import_aliases: &HashMap, ) -> Cow<'a, str> { - transform_import_aliases_with_edits(code, filename, package, import_aliases).0 + transform_import_aliases_with_edits( + code, + filename, + package, + import_aliases, + crate::parser_source_type(filename).unwrap_or_default(), + ) + .0 } /// A replacement of `code[start..end]` by text of `length` bytes @@ -249,6 +256,7 @@ pub fn transform_import_aliases_with_edits<'a>( filename: &str, package: &str, import_aliases: &HashMap, + source_type: oxc_span::SourceType, ) -> (Cow<'a, str>, Vec) { // Quick check: if no aliases match, return original code if import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias)) { @@ -256,7 +264,6 @@ pub fn transform_import_aliases_with_edits<'a>( } let allocator = Allocator::default(); - let source_type = SourceType::from_path(filename).unwrap_or_default(); // Parse the code let parser_ret = Parser::new(&allocator, code, source_type).parse(); @@ -566,7 +573,7 @@ mod tests { use super::*; use insta::assert_snapshot; use oxc_ast::builder::AstBuilder; - use oxc_span::SPAN; + use oxc_span::{SPAN, SourceType}; fn emotion_alias() -> HashMap { let mut aliases = HashMap::new(); diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index a600d1deb..a0501cee8 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -15,14 +15,20 @@ use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; use oxc_parser::Parser; use oxc_semantic::{Scoping, SemanticBuilder}; -use oxc_span::{SPAN, SourceType}; +use oxc_span::SPAN; +#[cfg(test)] +#[path = "imported_source_type_tests.rs"] +mod source_type_tests; use oxc_syntax::number::NumberBase; use oxc_syntax::operator::BinaryOperator; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; use crate::stylex::StylexFunction; -use crate::{ExtractOption, ModuleResolver}; +use crate::{ExtractOption, ModuleResolution, ModuleResolver}; + +mod exports; +use exports::Exports; #[derive(Clone, Debug)] enum Constant { @@ -38,6 +44,8 @@ enum Constant { Object(Rc>), /// An object literal every property of which is known, in source order Record(Rc>), + /// An ignored module's empty `CommonJS` object, with ordinary prototype reads + IgnoredObject, Array(Rc>), /// `StyleX` custom properties by key, which read as their `var()` Vars(Rc>), @@ -53,7 +61,10 @@ enum Constant { impl Constant { /// Whether code can change what it holds const fn is_mutable(&self) -> bool { - matches!(self, Self::Object(_) | Self::Record(_) | Self::Array(_)) + matches!( + self, + Self::Object(_) | Self::Record(_) | Self::IgnoredObject | Self::Array(_) + ) } /// Whether this value or one it holds is a function @@ -74,7 +85,8 @@ impl Constant { Self::Number(number) => Some(crate::utils::js_number_string(*number)), Self::Null => Some("null".to_string()), Self::Bool(value) => Some(value.to_string()), - Self::Undefined => Some("undefined".to_string()), + Self::Undefined => Some("(void 0)".to_string()), + Self::IgnoredObject => Some("({})".to_string()), Self::Record(entries) => { let mut properties = Vec::with_capacity(entries.len()); for (key, value) in entries.iter() { @@ -479,7 +491,11 @@ pub(crate) fn inline_constants<'a>( } (scoping, reads_math) }; - inlined.dependencies = modules.exports.into_keys().collect(); + inlined.dependencies = modules + .exports + .into_keys() + .map(|(path, _, _)| path) + .collect(); if !symbols.is_empty() || reads_math { Inline { ast_builder, @@ -554,7 +570,12 @@ impl<'p, 'a, 'r> ChangeCheck<'p, 'a, 'r> { /// The modules read for the values of imports pub(crate) fn dependencies(&self) -> BTreeSet { - self.modules.borrow().exports.keys().cloned().collect() + self.modules + .borrow() + .exports + .keys() + .map(|(path, _, _)| path.clone()) + .collect() } pub(crate) fn is_changed(&self, name: &str) -> bool { @@ -715,35 +736,42 @@ pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { /// The constant exports of the modules read, by path struct Modules<'r> { - resolver: Option<&'r ModuleResolver>, + resolver: Option<&'r ModuleResolver<'r>>, option: &'r ExtractOption, - exports: FxHashMap>>, + exports: FxHashMap>>, loading: Vec, } impl Modules<'_> { - fn exports( - &mut self, - specifier: &str, - importer: &str, - ) -> Option>> { - let module = (self.resolver?)(specifier, importer)?; - if let Some(exports) = self.exports.get(&module.path) { - return Some(exports.clone()); + fn exports(&mut self, specifier: &str, importer: &str) -> Option { + let module = match (self.resolver?)(specifier, importer)? { + ModuleResolution::Ignored => return Some(Exports::Ignored), + ModuleResolution::Resolved(module) => module, + }; + let key = (module.path.clone(), module.code.clone(), module.source_type); + if let Some(exports) = self.exports.get(&key) { + return Some(Exports::Partial(exports.clone())); } if self.loading.contains(&module.path) { return None; } self.loading.push(module.path.clone()); - let exports = Rc::new(self.read(&module.path, &module.code)); + let exports = Rc::new(self.read(&module.path, &module.code, module.source_type)); self.loading.pop(); - self.exports.insert(module.path, exports.clone()); - Some(exports) + self.exports.insert(key, exports.clone()); + Some(Exports::Partial(exports)) } - fn read(&mut self, path: &str, code: &str) -> FxHashMap { + fn read( + &mut self, + path: &str, + code: &str, + mode: Option, + ) -> FxHashMap { let allocator = Allocator::default(); - let source_type = SourceType::from_path(path).unwrap_or_else(|_| SourceType::ts()); + let Ok(source_type) = crate::source_type::parser_type(path, mode) else { + return FxHashMap::default(); + }; let program = Parser::new(&allocator, code, source_type).parse().program; let mut scope = ModuleScope::new(path, &program, Some(code)); let mut exports = FxHashMap::default(); @@ -785,11 +813,8 @@ impl Modules<'_> { Statement::ExportFromDeclaration(export) => { if let Some(from) = self.exports(&export.source.value, path) { for specifier in &export.specifiers { - if let Some(constant) = from.get(specifier.local.name().as_str()) { - exports.insert( - specifier.exported.name().to_string(), - constant.clone(), - ); + if let Some(constant) = from.named(specifier.local.name().as_str()) { + exports.insert(specifier.exported.name().to_string(), constant); } } } @@ -798,10 +823,10 @@ impl Modules<'_> { if let Some(from) = self.exports(&export.source.value, path) { match &export.exported { Some(exported) => { - exports.insert(exported.name().to_string(), Constant::Object(from)); + exports.insert(exported.name().to_string(), from.namespace()); } None => { - for (name, constant) in from.iter() { + for (name, constant) in from.entries() { if name != "default" { exports .entry(name.clone()) @@ -1072,8 +1097,9 @@ impl<'p, 'a> ModuleScope<'p, 'a> { self.uses = Some(uses.clone()); uses }; - let namespace = matches!(self.imports.get(name), Some((_, Imported::Namespace))); let value = self.lookup_raw(modules, name); + let namespace = matches!(self.imports.get(name), Some((_, Imported::Namespace))) + && !matches!(value, Some(Constant::IgnoredObject)); let mut change = None; for found in uses.get(name).into_iter().flatten() { change = match found { @@ -1251,8 +1277,8 @@ impl<'p, 'a> ModuleScope<'p, 'a> { let (source, imported) = self.imports.get(name)?; let exports = modules.exports(source, self.path)?; match imported { - Imported::Named(export) => exports.get(export).cloned(), - Imported::Namespace => Some(Constant::Object(exports)), + Imported::Named(export) => exports.named(export), + Imported::Namespace => Some(exports.namespace()), } } @@ -1456,6 +1482,18 @@ impl<'p, 'a> ModuleScope<'p, 'a> { member.property.name.as_str(), ), Expression::CallExpression(call) => match &call.callee { + Expression::Identifier(callee) + if callee.name == "require" && !self.binds("require") => + { + let [Argument::StringLiteral(source)] = call.arguments.as_slice() else { + return None; + }; + Some( + modules + .exports(source.value.as_str(), self.path)? + .namespace(), + ) + } Expression::StaticMemberExpression(callee) if self.is_global_math(&callee.object) => { @@ -1509,12 +1547,15 @@ impl<'p, 'a> ModuleScope<'p, 'a> { (key, value) } ObjectPropertyKind::SpreadProperty(spread) => { - if let Some(Constant::Record(spread)) = self.evaluate(modules, &spread.argument) - { - for (key, value) in spread.iter() { - set_entry(&mut entries, key.clone(), value.clone()); + match self.evaluate(modules, &spread.argument) { + Some(Constant::IgnoredObject) => continue, + Some(Constant::Record(spread)) => { + for (key, value) in spread.iter() { + set_entry(&mut entries, key.clone(), value.clone()); + } + continue; } - continue; + _ => {} } (None, None) } @@ -1570,6 +1611,25 @@ fn member_of(object: &Constant, key: &str) -> Option { .iter() .find(|(name, _)| name == key) .map(|(_, value)| value.clone()), + Constant::IgnoredObject + if !matches!( + key, + "constructor" + | "toString" + | "toLocaleString" + | "valueOf" + | "hasOwnProperty" + | "isPrototypeOf" + | "propertyIsEnumerable" + | "__proto__" + | "__defineGetter__" + | "__defineSetter__" + | "__lookupGetter__" + | "__lookupSetter__" + ) => + { + Some(Constant::Undefined) + } Constant::Array(values) => key .parse::() .ok() @@ -1854,6 +1914,17 @@ impl<'a> Inline<'_, 'a> { )), Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), + Constant::Undefined => Some(Expression::new_unary_expression( + SPAN, + oxc_syntax::operator::UnaryOperator::Void, + Expression::new_numeric_literal(SPAN, 0.0, None, NumberBase::Decimal, builder), + builder, + )), + Constant::IgnoredObject if self.objects => Some(Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(builder), + builder, + )), Constant::Record(entries) if self.objects => { let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); for (key, value) in entries.iter() { @@ -1941,7 +2012,9 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_call_expression(&mut self, call: &mut oxc_ast::ast::CallExpression<'a>) { - self.visit_expression(&mut call.callee); + if !matches!(self.constant(&call.callee), Some(Constant::Undefined)) { + self.visit_expression(&mut call.callee); + } let objects = self.apis.reads(&call.callee); let styles = is_style_root(self.style_roots, &call.callee); self.reading_styles(styles, |inline| { diff --git a/libs/extractor/src/imported_constants/exports.rs b/libs/extractor/src/imported_constants/exports.rs new file mode 100644 index 000000000..12aa76779 --- /dev/null +++ b/libs/extractor/src/imported_constants/exports.rs @@ -0,0 +1,34 @@ +use super::{Constant, FxHashMap, Rc}; + +pub(super) enum Exports { + Partial(Rc>), + Ignored, +} + +impl Exports { + pub(super) fn named(&self, name: &str) -> Option { + match self { + Self::Partial(exports) => exports.get(name).cloned(), + Self::Ignored => Some(if name == "default" { + Constant::IgnoredObject + } else { + Constant::Undefined + }), + } + } + + pub(super) fn namespace(self) -> Constant { + match self { + Self::Partial(exports) => Constant::Object(exports), + Self::Ignored => Constant::IgnoredObject, + } + } + + pub(super) fn entries(&self) -> impl Iterator { + let entries = match self { + Self::Partial(exports) => Some(exports), + Self::Ignored => None, + }; + entries.into_iter().flat_map(|exports| exports.iter()) + } +} diff --git a/libs/extractor/src/imported_source_type_tests.rs b/libs/extractor/src/imported_source_type_tests.rs new file mode 100644 index 000000000..9aace6f85 --- /dev/null +++ b/libs/extractor/src/imported_source_type_tests.rs @@ -0,0 +1,18 @@ +use super::*; + +#[test] +fn source_type_returns_no_constants_when_an_unvalidated_internal_path_is_unknown() { + let option = ExtractOption::default(); + let mut modules = Modules { + resolver: None, + option: &option, + exports: FxHashMap::default(), + loading: Vec::new(), + }; + assert_eq!( + modules + .read("token.custom", "export const PRIMARY = 'red';", None) + .len(), + 0 + ); +} diff --git a/libs/extractor/src/jsx_dev_tests.rs b/libs/extractor/src/jsx_dev_tests.rs new file mode 100644 index 000000000..c40130373 --- /dev/null +++ b/libs/extractor/src/jsx_dev_tests.rs @@ -0,0 +1,266 @@ +use crate::{ExtractOption, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_ast::ast::CallExpression; +use oxc_ast_visit::{Visit, walk}; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType}; +use rstest::rstest; +use serial_test::serial; +use std::error::Error; + +fn normalized(source: &str) -> String { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.errors().count(), 0); + Codegen::new().build(&parsed.program).code +} + +#[derive(Default)] +struct Calls<'s> { + source: &'s str, + calls: Vec<(String, Vec)>, +} + +impl<'a> Visit<'a> for Calls<'_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + let callee = call.callee.span().source_text(self.source); + if callee == "jsxDEV" || callee == "dev" || callee.ends_with(".jsxDEV") { + self.calls.push(( + normalized(&format!("({callee});")), + call.arguments + .iter() + .map(|arg| normalized(&format!("({});", arg.span().source_text(self.source)))) + .collect(), + )); + } + walk::walk_call_expression(self, call); + } +} + +fn calls(source: &str) -> Vec<(String, Vec)> { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.errors().count(), 0); + let mut visitor = Calls { + source, + ..Calls::default() + }; + visitor.visit_program(&parsed.program); + visitor.calls +} + +#[rstest] +#[case( + "import {jsxDEV} from 'react/jsx-dev-runtime'; import {Box} from '@devup-ui/react';", + "jsxDEV", + "Box" +)] +#[case( + "import {jsxDEV as dev} from 'react/jsx-dev-runtime'; import {Box as Card} from '@devup-ui/react';", + "dev", + "Card" +)] +#[case( + "import * as runtime from 'react/jsx-dev-runtime'; import * as UI from '@devup-ui/react';", + "runtime.jsxDEV", + "UI.Box" +)] +#[case( + "const runtime = require('react/jsx-dev-runtime'); const UI = require('@devup-ui/react');", + "runtime.jsxDEV", + "UI.Box" +)] +#[case( + "const {jsxDEV} = require('react/jsx-dev-runtime'); const {Box} = require('@devup-ui/react');", + "jsxDEV", + "Box" +)] +#[case( + "const {jsxDEV: dev} = require('react/jsx-dev-runtime'); const {Box} = require('@devup-ui/react');", + "dev", + "Box" +)] +#[serial] +fn jsx_dev_extracts_when_runtime_origin_is_known( + #[case] imports: &str, + #[case] callee: &str, + #[case] component: &str, +) -> Result<(), Box> { + reset_class_map(); + reset_file_map(); + let source = format!( + "{imports} export const view = {callee}({component}, {{as: 'section', bg: 'tomato', color: color, id: 'card', props: {{title: 'hello'}}}}, key(), true, {{fileName: 'actual.mdx', lineNumber: 7, columnNumber: 3}}, this);" + ); + let before = calls(&source); + + let output = extract("/src/page.mdx", &source, ExtractOption::default())?; + + let after = calls(&output.code); + assert_eq!(after.len(), 1); + assert_eq!(after[0].0, before[0].0); + assert_eq!(after[0].1.len(), 6); + assert_eq!(after[0].1[0], normalized("('section');")); + assert_eq!(after[0].1[2..], before[0].1[2..]); + assert!(after[0].1[1].contains("className")); + assert!(after[0].1[1].contains("title: \"hello\"")); + assert!(after[0].1[1].contains("id: \"card\"")); + assert!(after[0].1[1].contains("color")); + assert!(after[0].1[1].contains("--")); + assert!(!after[0].1[1].contains("bg:")); + assert_ne!(output.styles.len(), 0); + Ok(()) +} + +#[rstest] +#[case( + "import * as prod from 'react/jsx-runtime'; import * as other from 'react/jsx-runtime'; import * as runtime from 'react/jsx-dev-runtime'; import {Box} from '@devup-ui/react';" +)] +#[case( + "const prod = require('react/jsx-runtime'); const runtime = require('react/jsx-dev-runtime'); const other = require('react/jsx-runtime'); const {Box} = require('@devup-ui/react');" +)] +#[serial] +fn jsx_dev_extracts_when_production_and_development_objects_coexist( + #[case] imports: &str, +) -> Result<(), Box> { + reset_class_map(); + reset_file_map(); + let source = format!( + "{imports} export const view = [prod.jsx(Box, {{bg: 'red'}}), runtime.jsxDEV(Box, {{bg: 'blue'}}, void 0, false, source, self), other.jsxs(Box, {{color: 'green'}})];" + ); + + let output = extract("/src/mixed.js", &source, ExtractOption::default())?; + + assert_eq!(output.code.matches("className:").count(), 3); + assert!(!output.code.contains("(Box")); + assert_eq!(calls(&output.code)[0].1[2..], calls(&source)[0].1[2..]); + Ok(()) +} + +#[rstest] +#[case("...readProps(), bg: 'red'")] +#[case( + "as: active ? 'article' : 'section', styleOrder: active ? 1 : 2, bg: color, style: {opacity: 0.5}, styleVars: {'--custom': color}, props: {title: 'nested'}" +)] +#[serial] +fn jsx_dev_preserves_metadata_when_props_wrap_or_children_nest( + #[case] props: &str, +) -> Result<(), Box> { + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{jsxDEV as dev}} from 'react/jsx-dev-runtime'; import {{Box}} from '@devup-ui/react'; export const view = dev(Box, {{{props}, children: dev(Box, {{p: 2}}, childKey, false, childSource, childSelf)}}, parentKey, true, parentSource, this);" + ); + let before = calls(&source); + + let output = extract("/src/nested.js", &source, ExtractOption::default())?; + + let after = calls(&output.code); + assert_eq!(after.len(), 2); + for (old, new) in before.iter().zip(&after) { + assert_eq!(new.0, old.0); + assert_eq!(new.1.len(), 6); + assert_eq!(new.1[2..], old.1[2..]); + assert!(new.1[1].contains("className")); + } + assert_eq!( + output.code.matches("readProps()").count(), + usize::from(props.contains("readProps")) + ); + assert!(!output.code.contains("dev(Box")); + Ok(()) +} + +#[rstest] +#[case("import {jsxDEV} from 'unrelated'; jsxDEV('div', {bg: 'red'}, key, false, source, self);")] +#[case("import * as unrelated from 'unrelated'; unrelated.jsxDEV('div', {bg: 'red'});")] +#[case( + "import {jsxDEV} from 'react/jsx-dev-runtime'; jsxDEV('div', {bg: 'red'}, key, false, source, self);" +)] +#[case( + "import {jsxDEV} from 'react/jsx-dev-runtime'; jsxDEV(); jsxDEV(...args); jsxDEV('div'); jsxDEV('div', ...args);" +)] +#[case("import runtime from 'react/jsx-dev-runtime'; runtime.jsxDEV('div', {bg: 'red'});")] +#[case( + "import {jsxDEV} from 'react/jsx-dev-runtime'; import * as UI from '@devup-ui/react'; jsxDEV(UI.Unknown, {bg: 'red'});" +)] +#[case( + "const runtime = require('react/jsx-dev-runtime'); const UI = require('@devup-ui/react'); runtime.jsxDEV(UI.Unknown, {bg: 'red'});" +)] +#[serial] +fn jsx_dev_keeps_calls_when_origin_component_or_arguments_are_ineligible( + #[case] control: &str, +) -> Result<(), Box> { + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{css}} from '@devup-ui/react'; export const cls = css({{color: 'blue'}}); {control}" + ); + + let output = extract("/src/control.js", &source, ExtractOption::default())?; + + assert!(output.code.contains(&normalized(control))); + assert!(!output.code.contains("className")); + Ok(()) +} + +#[rstest] +#[case("jsxDEV(Box)", "react/jsx-dev-runtime")] +#[case("jsxDEV(Box, ...args)", "react/jsx-dev-runtime")] +#[case("other.jsxDEV(Box, {})", "react/jsx-dev-runtime")] +#[case("jsxDEV(Box, {})", "unrelated")] +#[serial] +fn jsx_dev_keeps_runtime_read_diagnostics_when_call_is_ineligible( + #[case] call: &str, + #[case] runtime: &str, +) { + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{jsxDEV}} from '{runtime}'; import {{Box}} from '@devup-ui/react'; {call};" + ); + + let result = extract("/src/control.js", &source, ExtractOption::default()); + + assert!(result.is_err_and(|error| { + let message = error.to_string(); + message.contains("Box") && message.contains("runtime") + })); +} + +#[test] +#[serial] +fn jsx_dev_namespace_lookup_keeps_production_parity() -> Result<(), Box> { + reset_class_map(); + reset_file_map(); + let source = "import {jsx} from 'react/jsx-runtime'; import * as UI from '@devup-ui/react'; export const view = jsx(UI.Box, {bg: 'red'});"; + + let output = extract("/src/production.js", source, ExtractOption::default())?; + + assert!(output.code.contains("jsx(\"div\"")); + assert!(output.code.contains("className")); + assert_ne!(output.styles.len(), 0); + Ok(()) +} + +#[rstest] +#[case("jsx", "react/jsx-runtime")] +#[case("jsxDEV", "react/jsx-dev-runtime")] +#[serial] +fn jsx_dev_keeps_existing_callee_limits( + #[case] method: &str, + #[case] runtime: &str, + #[values("(0, helper)(Box, {})", "runtime['CALL'](Box, {})", "rebound(Box, {})")] call: &str, +) { + reset_class_map(); + reset_file_map(); + let call = call.replace("CALL", method); + let source = format!( + "import {{{method} as helper}} from '{runtime}'; import * as runtime from '{runtime}'; import {{Box}} from '@devup-ui/react'; const rebound = helper; {call};" + ); + + let result = extract("/src/limits.js", &source, ExtractOption::default()); + + assert!(result.is_err_and(|error| error.to_string().contains("Box"))); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 96c6ca430..4fbc9ddf1 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -8,6 +8,8 @@ mod gen_class_name; mod gen_style; mod import_alias_visit; mod imported_constants; +#[cfg(test)] +mod jsx_dev_tests; mod module_loader; mod mutations; mod prop_modify_utils; @@ -219,11 +221,25 @@ pub struct ExtractOutput { pub struct ResolvedModule { pub path: String, pub code: String, + pub source_type: Option, +} + +/// A native file resolution or a successful alias ignore without source. +pub enum ModuleResolution { + Resolved(ResolvedModule), + Ignored, } +mod source_type; +pub use source_type::{ExtractSourceType, parse_source_type}; +#[cfg(test)] +mod ignored_module_tests; +#[cfg(test)] +mod source_type_tests; + /// Resolves `(specifier, importer)` the way the bundler does; `None` when it /// cannot -pub type ModuleResolver = dyn Fn(&str, &str) -> Option; +pub type ModuleResolver<'a> = dyn Fn(&str, &str) -> Option + 'a; #[derive(Clone)] pub struct ExtractOption { @@ -274,6 +290,26 @@ pub fn extract_with_modules( extract_with_source_map(filename, code, option, source_map, Some(resolver)) } +/// Compiled Markdown is JavaScript with optional JSX, never TypeScript. +fn parser_source_type(filename: &str) -> Result { + if is_compiled_mdx(filename) { + return Ok(SourceType::jsx()); + } + SourceType::from_path(filename).map_err(|error| { + format!("{filename}:1:1: source parser cannot use `{filename}` at build time: {error}") + }) +} + +/// Whether a compiler supplied JavaScript for a Markdown source filename. +fn is_compiled_mdx(filename: &str) -> bool { + std::path::Path::new(filename) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("mdx") || extension.eq_ignore_ascii_case("md") + }) +} + fn extract_with_source_map( filename: &str, code: &str, @@ -281,7 +317,47 @@ fn extract_with_source_map( source_map: bool, resolver: Option<&ModuleResolver>, ) -> Result> { - extract_source(filename, code, None, option, source_map, resolver) + extract_with_source_type(filename, code, option, source_map, resolver, None) +} + +/// Extract compiler output under its real filename with an explicit language. +pub fn extract_with_source_type( + filename: &str, + code: &str, + option: ExtractOption, + source_map: bool, + resolver: Option<&ModuleResolver>, + source_type: Option, +) -> Result> { + let fault = std::cell::RefCell::new(None); + let validated = |specifier: &str, importer: &str| { + if fault.borrow().is_some() { + return None; + } + let module = resolver?(specifier, importer)?; + let validation = match &module { + ModuleResolution::Resolved(module) => source_type::validate_module(module), + ModuleResolution::Ignored => Ok(()), + }; + if let Err(error) = validation { + *fault.borrow_mut() = Some(error); + return None; + } + Some(module) + }; + let output = extract_source( + filename, + code, + None, + option, + source_map, + resolver.map(|_| &validated as &ModuleResolver), + source_type, + ); + match fault.into_inner() { + Some(error) => Err(error.into()), + None => output, + } } /// `evaluated` is the source `code` was computed from, with the layers of @@ -293,7 +369,9 @@ fn extract_source( option: ExtractOption, source_map: bool, resolver: Option<&ModuleResolver>, + source_mode: Option, ) -> Result> { + let source_type = source_type::parser_type(filename, source_mode)?; // Step 1: Transform import aliases // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` // e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` @@ -302,13 +380,38 @@ fn extract_source( filename, &option.package, &option.import_aliases, + source_type, ); // Step 2: Check if code contains the target package (after transformation) let has_relevant_import = transformed_code.contains(option.package.as_str()) || transformed_code.contains(STYLEX_PACKAGE); + let compiled_mdx = source_mode.is_some() || is_compiled_mdx(filename); if !has_relevant_import { + // Raw Markdown must not pass through merely because it has no styling import. + if compiled_mdx { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, code, source_type).parse(); + if let Some(error) = parsed.diagnostics.errors().next() { + let offset = error + .labels + .first() + .map_or(0, oxc_span::LabeledSpan::offset); + return Err(located_errors( + filename, + code, + &[], + vec![( + offset, + format!( + "source parser cannot use raw or invalid MDX at build time: {error}" + ), + )], + ) + .into()); + } + } // skip if not using package return Ok(ExtractOutput { styles: FxHashSet::default(), @@ -326,7 +429,13 @@ fn extract_source( // otherwise the untouched `transformed_code` is parsed directly (no copy). let processed_code: Option = if utils::is_vanilla_extract_file(filename) { // Use transformed code (with imports already pointing to @devup-ui/react) - match vanilla_extract::execute_stylesheet(&transformed_code, filename, &option, resolver) { + match vanilla_extract::execute_stylesheet_with_type( + &transformed_code, + filename, + &option, + resolver, + source_mode, + ) { Ok((collected, imports)) => { dependencies = imports.dependencies; // Keyframes names are generated, so extract the referenced ones @@ -344,6 +453,7 @@ fn extract_source( ), &option, &referenced, + source_mode, )? }; let code = vanilla_extract::collected_styles_to_code_with_keyframes( @@ -396,7 +506,6 @@ fn extract_source( let code_to_parse = processed_code.as_deref().unwrap_or(&transformed_code); - let source_type = SourceType::from_path(filename)?; let (bucket, global, css_file) = resolve_css_target(filename, &option); let import_main = option.import_main_css && !global; // Presize to the exact final length (1 target + optional main-css entry) and @@ -412,8 +521,25 @@ fn extract_source( let ParserReturn { mut program, // AST fatal_error, // Parser encountered an error it couldn't recover from + diagnostics, .. } = Parser::new(&allocator, code_to_parse, source_type).parse(); + if compiled_mdx && let Some(error) = diagnostics.errors().next() { + let offset = error + .labels + .first() + .map_or(0, oxc_span::LabeledSpan::offset); + return Err(located_errors( + filename, + code, + &[alias_edits.as_slice()], + vec![( + offset, + format!("source parser cannot use raw or invalid MDX at build time: {error}"), + )], + ) + .into()); + } if fatal_error { return Err("Parser panicked".into()); } @@ -456,6 +582,7 @@ fn extract_source( &option, resolver, &inlined.unknown, + source_mode, ) { let mut output = extract_source( @@ -465,6 +592,7 @@ fn extract_source( option, source_map, resolver, + source_mode, )?; let mut files: std::collections::BTreeSet = output.dependencies.into_iter().collect(); @@ -640,8 +768,9 @@ fn extract_class_map_from_code( partial_code: &str, option: &ExtractOption, style_names: &FxHashSet, + source_mode: Option, ) -> Result, Box> { - let source_type = SourceType::from_path(filename)?; + let source_type = source_type::parser_type(filename, source_mode)?; let (bucket, global, css_file) = resolve_css_target(filename, option); let css_files = vec![css_file]; let allocator = Allocator::default(); @@ -707,7 +836,7 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool { return false; } - let source_type = match SourceType::from_path(filename) { + let source_type = match parser_source_type(filename) { Ok(st) => st, Err(_) => return false, }; @@ -716,10 +845,11 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool { let ParserReturn { program, fatal_error, + diagnostics, .. } = Parser::new(&allocator, code, source_type).parse(); - if fatal_error { + if fatal_error || (is_compiled_mdx(filename) && diagnostics.has_errors()) { return false; } @@ -749,6 +879,153 @@ mod tests { use rstest::rstest; use serial_test::serial; + #[rstest] + #[case("page.js")] + #[case("page.jsx")] + #[case("page.mjs")] + #[case("page.cjs")] + #[case("page.ts")] + #[case("page.tsx")] + #[case("page.mts")] + #[case("page.cts")] + fn mdx_source_type_keeps_other_extensions(#[case] filename: &str) { + assert_eq!( + parser_source_type(filename), + SourceType::from_path(filename).map_err(|error| error.to_string()) + ); + } + + #[rstest] + #[case("page.mdx")] + #[case("page.md")] + #[serial] + fn mdx_compiler_jsx_calls_extract_with_real_filename(#[case] filename: &str) { + reset_class_map(); + reset_file_map(); + let source = "import {jsx as _jsx} from 'react/jsx-runtime';\nimport {Box} from '@devup-ui/react';\nfunction _createMdxContent(props) { return _jsx(Box, {bg: 'tomato', children: 'hello'}); }\nexport default function MDXContent(props = {}) { return _createMdxContent(props); }"; + let output = extract(filename, source, ExtractOption::default()).unwrap(); + assert_ne!(output.styles.len(), 0); + assert!(output.code.contains("className")); + assert!(!output.code.contains("_jsx(Box")); + let map: serde_json::Value = serde_json::from_str(&output.map.unwrap()).unwrap(); + assert_eq!(map["sources"], serde_json::json!([filename])); + } + + #[rstest] + #[case("page.mdx")] + #[case("page.md")] + #[serial] + fn mdx_compiler_preserved_jsx_extracts(#[case] filename: &str) { + reset_class_map(); + reset_file_map(); + let source = "/*@jsxRuntime automatic*/\n/*@jsxImportSource react*/\nimport {Box} from '@devup-ui/react';\nfunction _createMdxContent(props) { return hello; }\nexport default function MDXContent(props = {}) { return _createMdxContent(props); }"; + let output = + extract_without_source_map(filename, source, ExtractOption::default()).unwrap(); + assert_ne!(output.styles.len(), 0); + assert!(output.code.contains("className")); + assert!(!output.code.contains("")] + #[case("page.md", "import {Box} from '@devup-ui/react';\n# Heading")] + #[case( + "page.mdx", + "import {Box} from '@devup-ui/react';\nconst color: string = 'red';\nconst view = ;" + )] + #[serial] + fn mdx_raw_or_typescript_input_fails_with_location( + #[case] filename: &str, + #[case] source: &str, + ) { + reset_class_map(); + reset_file_map(); + let error = extract(filename, source, ExtractOption::default()) + .err() + .unwrap() + .to_string(); + assert!(error.starts_with(&format!("{filename}:")), "{error}"); + assert!(error.contains("at build time"), "{error}"); + assert!(!has_devup_ui(filename, source, "@devup-ui/react")); + } + + #[test] + #[serial] + fn mdx_compiled_without_devup_keeps_source() { + let source = "export default function MDXContent() { return

Hello

; }"; + let output = extract("page.mdx", source, ExtractOption::default()).unwrap(); + assert_eq!(output.code, source); + assert_eq!(output.styles.len(), 0); + assert_eq!(output.css_file, None); + } + + #[test] + #[serial] + fn mdx_relevant_import_gate_accepts_compiled_jsx() { + let source = "import {Box} from '@devup-ui/react'; export default () => ;"; + assert!(has_devup_ui("page.mdx", source, "@devup-ui/react")); + } + + #[test] + #[serial] + fn mdx_generated_class_map_uses_jsx_source_type() { + reset_class_map(); + reset_file_map(); + let names = FxHashSet::from_iter(["card".to_string()]); + let map = extract_class_map_from_code( + "page.mdx", + "import {css} from '@devup-ui/react'; export const card = css({bg: 'red'});", + &ExtractOption::default(), + &names, + None, + ) + .unwrap(); + assert!(map.contains_key("card")); + } + + #[test] + #[serial] + fn mdx_unknown_extension_error_is_located() { + let source = "import {Box} from '@devup-ui/react'; const view = ;"; + let error = extract("page.unknown", source, ExtractOption::default()) + .err() + .unwrap() + .to_string(); + assert!(error.starts_with("page.unknown:1:1:"), "{error}"); + } + #[derive(Debug)] #[allow(dead_code)] struct ToBTreeSet { @@ -4779,7 +5056,7 @@ import clsx from 'clsx' ) .unwrap_err() .to_string() - .starts_with("Unknown file extension") + .starts_with("test.wrong:1:1: source parser cannot use `test.wrong` at build time: Unknown file extension") ); reset_class_map(); @@ -13631,6 +13908,30 @@ globalCss({ assert!(!output.code.contains("css("), "{}", output.code); } + #[test] + #[serial] + fn test_stylesheets_read_nothing_that_differs_between_builds() { + for (read, fails) in [ + ("Math.random()", true), + ("Date.now()", true), + ("new Date()", true), + ("Date()", true), + ("new Date(0).getTime()", false), + ("Date.UTC(2020, 0, 1)", false), + ] { + reset_class_map(); + reset_file_map(); + let result = extract( + "when.css.ts", + &format!( + "import {{ style }} from '@devup-ui/react';\nconst n = {read};\nexport const a = style({{ opacity: String(n) }});" + ), + ExtractOption::default(), + ); + assert_eq!(result.is_err(), fails, "{read}"); + } + } + #[test] #[serial] fn test_vanilla_extract_execution_fallback() { @@ -14355,6 +14656,7 @@ export const card = style({ import_aliases: HashMap::new(), }, &style_names, + None, ) .unwrap(); @@ -18082,7 +18384,7 @@ export const d = style([cond && base]);", fn memory_resolver( files: &'static [(&'static str, &'static str)], - ) -> impl Fn(&str, &str) -> Option { + ) -> impl Fn(&str, &str) -> Option { move |specifier, importer| { let directory = importer .rsplit_once('/') @@ -18095,9 +18397,12 @@ export const d = style([cond && base]);", .iter() .any(|extension| format!("{path}{extension}") == *file) }) - .map(|(file, code)| ResolvedModule { - path: (*file).to_string(), - code: (*code).to_string(), + .map(|(file, code)| { + ModuleResolution::Resolved(ResolvedModule { + source_type: None, + path: (*file).to_string(), + code: (*code).to_string(), + }) }) } } diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index 4082ff395..de274e408 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -14,13 +14,31 @@ use oxc_span::{GetSpan, SourceType}; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; -use crate::{ExtractOption, ModuleResolver, utils::is_vanilla_extract_file}; +use crate::{ExtractOption, ModuleResolution, ModuleResolver, utils::is_vanilla_extract_file}; +#[cfg(test)] +#[path = "ignored_loader_tests.rs"] +mod ignored_loader_tests; /// The object the package's API is bound to while a stylesheet runs pub(crate) const PACKAGE_BINDING: &str = "__vanilla_extract__"; /// A console whose calls do nothing, as what a module logs while it runs /// changes no value +/// Run before a stylesheet: what differs between builds fails the evaluation, +/// so a stylesheet never gives different rules for the same source. `new +/// Date(0)` and other fixed dates stay. +pub(crate) const DETERMINISM: &str = r#"(() => { + const refuse = (name) => () => { throw new Error(`${name} gives a different value each build: a stylesheet cannot read it`); }; + Math.random = refuse("Math.random"); + const RealDate = Date; + globalThis.Date = new Proxy(RealDate, { + construct: (target, args, newTarget) => args.length === 0 ? refuse("new Date()")() : Reflect.construct(target, args, newTarget), + apply: refuse("Date()"), + get: (target, key, receiver) => key === "now" ? refuse("Date.now") : Reflect.get(target, key, receiver), + }); +})(); +"#; + pub(crate) const CONSOLE: &str = "if (typeof console === \"undefined\") globalThis.console = new Proxy({}, { get: () => () => undefined });\n"; /// Loop iterations an evaluation may run before it fails instead of hanging @@ -72,11 +90,14 @@ fn is_evaluating(filename: &str) -> bool { /// Loads what a stylesheet imports, each module once, as JavaScript that /// defines the module's exports object ahead of the stylesheet pub(crate) struct ModuleLoader<'r> { - resolver: Option<&'r ModuleResolver>, + resolver: Option<&'r ModuleResolver<'r>>, option: &'r ExtractOption, /// Definitions of the loaded modules, each after the ones it uses definitions: Vec, - loaded: FxHashMap, + loaded: FxHashMap, + ignored: FxHashMap<(String, String), String>, + ignored_bindings: FxHashSet, + helper_emitted: bool, /// `(path, name)` of the modules being defined, outermost first loading: Vec<(String, String)>, /// Names of modules imported before they finished evaluating, whose @@ -97,6 +118,9 @@ impl<'r> ModuleLoader<'r> { option, definitions: Vec::new(), loaded: FxHashMap::default(), + ignored: FxHashMap::default(), + ignored_bindings: FxHashSet::default(), + helper_emitted: false, loading: Vec::new(), pending: FxHashSet::default(), next_module: 0, @@ -121,13 +145,31 @@ impl<'r> ModuleLoader<'r> { let resolver = self .resolver .ok_or_else(|| format!("Cannot load '{specifier}' without a module resolver"))?; - let module = resolver(specifier, importer) - .ok_or_else(|| format!("Cannot resolve '{specifier}' from '{importer}'"))?; + let module = match resolver(specifier, importer) + .ok_or_else(|| format!("Cannot resolve '{specifier}' from '{importer}'"))? + { + ModuleResolution::Resolved(module) => module, + ModuleResolution::Ignored => { + let key = (importer.to_string(), specifier.to_string()); + if let Some(name) = self.ignored.get(&key) { + return Ok(name.clone()); + } + let name = format!("__module_{}__", self.next_module); + self.next_module += 1; + self.definitions.push(format!( + "const {name}$ = {{}};\nconst {name} = {{ default: {name}$, __exports__: {name}$ }};\n" + )); + self.ignored.insert(key, name.clone()); + self.ignored_bindings.insert(name.clone()); + return Ok(name); + } + }; + let key = (module.path.clone(), module.code.clone(), module.source_type); let stylesheet = is_vanilla_extract_file(&module.path); if direct && stylesheet { self.keep_import(specifier); } - if let Some(name) = self.loaded.get(&module.path) { + if let Some(name) = self.loaded.get(&key) { return Ok(name.clone()); } if let Some((_, name)) = self.loading.iter().find(|(path, _)| *path == module.path) { @@ -139,8 +181,9 @@ impl<'r> ModuleLoader<'r> { self.next_module += 1; // Created before the modules it imports, so a cycle among them can // reach it; reading it before it starts evaluating is an error - if self.definitions.is_empty() { + if !self.helper_emitted { self.definitions.push(MODULE_HELPER.to_string()); + self.helper_emitted = true; } self.definitions.push(format!( "const {name}$ = __module__({:?});\nconst {name} = {name}$.module;\n", @@ -150,15 +193,15 @@ impl<'r> ModuleLoader<'r> { // The stylesheet importing it is evaluated on its own, so it never // starts here self.pending.insert(name.clone()); - self.loaded.insert(module.path, name.clone()); + self.loaded.insert(key, name.clone()); return Ok(name); } self.dependencies.insert(module.path.clone()); self.loading.push((module.path.clone(), name.clone())); let defined = self.define(&name, module, stylesheet, resolver); - let (path, _) = self.loading.pop().unwrap_or_default(); + self.loading.pop(); defined?; - self.loaded.insert(path, name.clone()); + self.loaded.insert(key, name.clone()); Ok(name) } @@ -172,12 +215,13 @@ impl<'r> ModuleLoader<'r> { let code = if stylesheet { // Extracted the way the bundler extracts it, so the names it // exports are the ones its own CSS uses - let output = crate::extract_with_source_map( + let output = crate::extract_with_source_type( &module.path, &module.code, self.option.clone(), false, Some(resolver), + module.source_type, ) .map_err(|error| error.to_string())?; self.dependencies.extend(output.dependencies); @@ -185,7 +229,11 @@ impl<'r> ModuleLoader<'r> { } else { module.code }; - let script = crate::vanilla_extract::strip_typescript(&code, &module.path); + let script = crate::vanilla_extract::strip_typescript_with_type( + &code, + &module.path, + module.source_type, + )?; let module_script = module_script(&script, &module.path, self, false)?; // Live bindings: a read before the binding is initialized fails as it // does in an ES module @@ -222,6 +270,28 @@ impl<'r> ModuleLoader<'r> { } } +impl ModuleLoader<'_> { + fn named(&self, module: &str, name: &str) -> String { + if name != "default" && self.ignored_bindings.contains(module) { + return "void 0".to_string(); + } + let object = if name == "default" { + module.to_string() + } else { + self.namespace(module) + }; + format!("{object}[{name:?}]") + } + + fn namespace(&self, module: &str) -> String { + if self.ignored_bindings.contains(module) { + format!("{module}.__exports__") + } else { + module.to_string() + } + } +} + /// A module turned into a script: imports bound to the loaded modules, export /// keywords dropped pub(crate) struct ModuleScript { @@ -368,14 +438,32 @@ pub(crate) fn module_script( let mut named = Vec::new(); for specifier in import.specifiers.iter().flatten() { match specifier { - ImportDeclarationSpecifier::ImportSpecifier(specifier) => named.push( - format!("{:?}: {}", specifier.imported.name(), specifier.local.name), - ), + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + if loader.ignored_bindings.contains(module) { + let _ = writeln!( + body, + "const {} = {};", + specifier.local.name, + loader.named(module, specifier.imported.name().as_str()) + ); + } else { + named.push(format!( + "{:?}: {}", + specifier.imported.name(), + specifier.local.name + )); + } + } ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => { named.push(format!("\"default\": {}", specifier.local.name)); } ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { - let _ = writeln!(body, "const {} = {module};", specifier.local.name); + let _ = writeln!( + body, + "const {} = {};", + specifier.local.name, + loader.namespace(module) + ); } } } @@ -404,7 +492,7 @@ pub(crate) fn module_script( for specifier in &export.specifiers { exports.push(( export_name(&specifier.exported), - format!("{module}[{:?}]", export_name(&specifier.local)), + loader.named(&module, &export_name(&specifier.local)), )); } } @@ -430,8 +518,10 @@ pub(crate) fn module_script( Statement::ExportAllDeclaration(export) => { let module = loader.load(export.source.value.as_str(), filename, entry)?; match &export.exported { - Some(exported) => exports.push((export_name(exported), module)), - None => spreads.push(module), + Some(exported) => { + exports.push((export_name(exported), loader.namespace(&module))); + } + None => spreads.push(loader.namespace(&module)), } } statement => { diff --git a/libs/extractor/src/source_type.rs b/libs/extractor/src/source_type.rs new file mode 100644 index 000000000..fb1e90d28 --- /dev/null +++ b/libs/extractor/src/source_type.rs @@ -0,0 +1,132 @@ +use oxc_span::SourceType; + +/// The language of compiler output, independent of its original filename. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ExtractSourceType { + /// JavaScript with optional JSX, never TypeScript or raw Markdown. + CompiledMdx, +} + +pub(crate) type ModuleCacheKey = (String, String, Option); +pub(crate) type PreparedFileKey = (String, Option); + +/// Parse a supplied source-type literal without accepting unknown modes. +pub fn parse_source_type(value: Option<&str>) -> Result, String> { + match value { + None => Ok(None), + Some("compiled-mdx") => Ok(Some(ExtractSourceType::CompiledMdx)), + Some(value) => Err(format!( + "source type cannot use `{value}` at build time: expected `compiled-mdx`" + )), + } +} + +pub(crate) fn parser_type( + filename: &str, + mode: Option, +) -> Result { + match mode { + Some(ExtractSourceType::CompiledMdx) => Ok(SourceType::jsx()), + None => crate::parser_source_type(filename), + } +} + +pub(crate) fn validate_module(module: &crate::ResolvedModule) -> Result<(), String> { + let source_type = parser_type(&module.path, module.source_type)?; + if module.source_type.is_some() || crate::is_compiled_mdx(&module.path) { + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, &module.code, source_type).parse(); + if let Some(error) = parsed.diagnostics.errors().next() { + let offset = error + .labels + .first() + .map_or(0, oxc_span::LabeledSpan::offset); + return Err(crate::located_errors( + &module.path, + &module.code, + &[], + vec![( + offset, + format!("source parser cannot use raw or invalid MDX at build time: {error}"), + )], + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_modes_when_optional_literal_is_supplied() { + assert_eq!(parse_source_type(None), Ok(None)); + assert_eq!( + parse_source_type(Some("compiled-mdx")), + Ok(Some(ExtractSourceType::CompiledMdx)) + ); + for value in ["", "tsx", "invalid"] { + assert!( + parse_source_type(Some(value)) + .is_err_and(|error| error.contains("expected `compiled-mdx`")) + ); + } + } + + #[test] + fn overrides_language_when_real_extension_is_custom_or_typescript() { + for filename in ["page.mdown", "page.ts"] { + assert_eq!( + parser_type(filename, Some(ExtractSourceType::CompiledMdx)), + Ok(SourceType::jsx()) + ); + } + assert_eq!( + parser_type("page.ts", None), + SourceType::from_path("page.ts").map_err(|error| error.to_string()) + ); + assert!( + parser_type("page.mdown", None) + .is_err_and(|error| error.starts_with("page.mdown:1:1:")) + ); + } + + #[test] + fn retains_exports_when_strip_cache_changes_language() -> Result<(), String> { + let code = "export function view() { return
; } export const PRIMARY = 'red';"; + let filename = "mode-cache.ts"; + let ordinary = crate::vanilla_extract::strip_typescript_with_type(code, filename, None)?; + let compiled = crate::vanilla_extract::strip_typescript_with_type( + code, + filename, + Some(ExtractSourceType::CompiledMdx), + )?; + assert!(compiled.contains("PRIMARY")); + assert_ne!(compiled, ordinary); + assert_eq!( + crate::vanilla_extract::strip_typescript_with_type( + code, + filename, + Some(ExtractSourceType::CompiledMdx) + )?, + compiled + ); + assert!( + crate::vanilla_extract::strip_typescript_with_type(code, "bad.custom", None).is_err() + ); + Ok(()) + } + + #[test] + fn rejects_markdown_when_resolved_module_claims_compiled_output() { + let module = crate::ResolvedModule { + path: "page.mdown".to_string(), + code: "# Heading".to_string(), + source_type: Some(ExtractSourceType::CompiledMdx), + }; + assert!(validate_module(&module).is_err_and( + |error| error.starts_with("page.mdown:1:") && error.contains("raw or invalid MDX") + )); + } +} diff --git a/libs/extractor/src/source_type_tests.rs b/libs/extractor/src/source_type_tests.rs new file mode 100644 index 000000000..5624b9ba3 --- /dev/null +++ b/libs/extractor/src/source_type_tests.rs @@ -0,0 +1,142 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn extracts_real_filename_when_compiled_mode_is_supplied() -> Result<(), Box> { + let code = "import {Box} from '@devup-ui/react'; export const view = ;"; + let output = extract_with_source_type( + "page.mdown", + code, + ExtractOption::default(), + true, + None, + Some(ExtractSourceType::CompiledMdx), + )?; + assert!(output.map.is_some_and(|map| map.contains("page.mdown"))); + assert!(!output.code.contains(" Result<(), Box> { + let resolver = |_: &str, _: &str| { + Some(ModuleResolution::Resolved(ResolvedModule { + path: "token.ts".to_string(), + code: "export enum Colors { PRIMARY = 'red' }".to_string(), + source_type: None, + })) + }; + let output = extract_with_source_type( + "page.mdown", + "import {Box} from '@devup-ui/react'; import {Colors} from './token'; export const view = ;", + ExtractOption::default(), + false, + Some(&resolver), + Some(ExtractSourceType::CompiledMdx), + )?; + assert!(!output.code.contains("--")); + assert_eq!(output.dependencies, ["token.ts"]); + Ok(()) +} + +#[test] +#[serial] +fn rejects_child_extension_when_constants_would_fall_back() { + let resolver = |_: &str, _: &str| { + Some(ModuleResolution::Resolved(ResolvedModule { + path: "token.custom".to_string(), + code: "export const PRIMARY = 'red';".to_string(), + source_type: None, + })) + }; + let result = extract_with_source_type( + "page.tsx", + "import {Box} from '@devup-ui/react'; import {PRIMARY} from './token'; import {OTHER} from './other'; export const view = ;", + ExtractOption::default(), + false, + Some(&resolver), + None, + ); + assert!(result.is_err_and(|error| error.to_string().starts_with("token.custom:1:1:"))); +} + +#[test] +#[serial] +fn evaluates_stylesheet_when_imported_compiled_jsx_has_trailing_constants() +-> Result<(), Box> { + let resolver = |_: &str, _: &str| { + Some(ModuleResolution::Resolved(ResolvedModule { + path: "token.mdown".to_string(), + code: "export function view() { return
; } export const PRIMARY = 'blue';" + .to_string(), + source_type: Some(ExtractSourceType::CompiledMdx), + })) + }; + let output = extract_with_source_type( + "theme.css.ts", + "import {css} from '@devup-ui/react'; import {PRIMARY} from './token'; export const cls = css({color: PRIMARY});", + ExtractOption::default(), + false, + Some(&resolver), + None, + )?; + assert!(!output.code.contains("--")); + assert_eq!(output.dependencies, ["token.mdown"]); + Ok(()) +} + +#[test] +#[serial] +fn evaluates_generated_values_when_root_keeps_custom_filename() -> Result<(), Box> { + let output = extract_with_source_type( + "page.mdown", + "import {css} from '@devup-ui/react'; const twice = (x) => x * 2; export const cls = css({width: twice(10)});", + ExtractOption::default(), + false, + None, + Some(ExtractSourceType::CompiledMdx), + )?; + assert!(!output.code.contains("css(")); + assert!(!output.code.contains("--")); + Ok(()) +} + +#[test] +#[serial] +fn reads_commonjs_exports_when_compiled_jsx_precedes_assignment() -> Result<(), Box> { + let resolver = |_: &str, _: &str| { + Some(ModuleResolution::Resolved(ResolvedModule { + path: "tokens.mdown".to_string(), + code: "function view() { return
; } exports.PRIMARY = 'blue';".to_string(), + source_type: Some(ExtractSourceType::CompiledMdx), + })) + }; + let output = extract_with_source_type( + "page.tsx", + "import {Box} from '@devup-ui/react'; import {PRIMARY} from './tokens'; export const view = ;", + ExtractOption::default(), + false, + Some(&resolver), + None, + )?; + assert!(!output.code.contains("--")); + assert_eq!(output.dependencies, ["tokens.mdown"]); + Ok(()) +} + +#[test] +#[serial] +fn generates_class_names_when_explicit_mode_keeps_custom_filename() -> Result<(), Box> { + let names = FxHashSet::from_iter(["card".to_string()]); + let classes = extract_class_map_from_code( + "page.mdown", + "import {css} from '@devup-ui/react'; export const card = css({color:'blue'});", + &ExtractOption::default(), + &names, + Some(ExtractSourceType::CompiledMdx), + )?; + assert!(classes.contains_key("card")); + Ok(()) +} diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 9e736b09a..b4cb7ab4a 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -233,16 +233,29 @@ fn execute_vanilla_extract( /// Execute vanilla-extract style file and collect styles, loading what it /// imports through `resolver` +#[cfg(test)] pub fn execute_stylesheet( code: &str, filename: &str, option: &crate::ExtractOption, resolver: Option<&crate::ModuleResolver>, ) -> Result<(CollectedStyles, StylesheetImports), String> { + execute_stylesheet_with_type(code, filename, option, resolver, None) +} + +pub(crate) fn execute_stylesheet_with_type( + code: &str, + filename: &str, + option: &crate::ExtractOption, + resolver: Option<&crate::ModuleResolver>, + mode: Option, +) -> Result<(CollectedStyles, StylesheetImports), String> { + let source_type = crate::source_type::parser_type(filename, mode)?; + let cache_key = (filename.to_string(), mode); let _evaluating = Evaluating::enter(filename); let mut loader = ModuleLoader::new(resolver, option); let script = module_script( - &strip_typescript(code, filename), + &strip_typescript_with_type(code, filename, mode)?, filename, &mut loader, true, @@ -256,7 +269,7 @@ pub fn execute_stylesheet( let imported = crate::module_loader::evaluating_import(); if imported && let Some(collected) = IMPORTED_RUNS.with_borrow(|runs| { - runs.get(filename) + runs.get(&cache_key) .filter(|(num, source, text, _)| *num == file_num && source == code && *text == run) .map(|(.., collected)| collected.clone()) }) @@ -276,6 +289,9 @@ pub fn execute_stylesheet( context .eval(Source::from_bytes(crate::module_loader::CONSOLE)) .map_err(|e| format!("JS execution error: {e}"))?; + context + .eval(Source::from_bytes(crate::module_loader::DETERMINISM)) + .map_err(|e| format!("JS execution error: {e}"))?; context .eval(Source::from_bytes(run.as_bytes())) @@ -284,14 +300,14 @@ pub fn execute_stylesheet( let mut collected = std::mem::take(&mut collector.borrow_mut().styles); name_entries( &mut collected, - &top_level_bindings(code), + &top_level_bindings_with_type(code, source_type), &mut context, file_num, ); if imported { IMPORTED_RUNS.with_borrow_mut(|runs| { runs.insert( - filename.to_string(), + cache_key, (file_num, code.to_string(), run, collected.clone()), ); }); @@ -299,14 +315,18 @@ pub fn execute_stylesheet( Ok((collected, imports)) } +type ImportedRuns = + FxHashMap; +type StrippedSources = FxHashMap; + thread_local! { /// What running each stylesheet other evaluations import last collected, /// with its file number, source and the script run: every module importing /// it reuses one run of the same script - static IMPORTED_RUNS: RefCell> = + static IMPORTED_RUNS: RefCell = RefCell::default(); /// The TypeScript each file last had stripped, with its source - static STRIPPED: RefCell> = RefCell::default(); + static STRIPPED: RefCell = RefCell::default(); } /// A name a top-level variable declaration of the stylesheet binds @@ -317,11 +337,9 @@ struct Binding { init: Option, } -fn top_level_bindings(code: &str) -> Vec { +fn top_level_bindings_with_type(code: &str, source_type: SourceType) -> Vec { let allocator = Allocator::default(); - let program = Parser::new(&allocator, code, SourceType::ts()) - .parse() - .program; + let program = Parser::new(&allocator, code, source_type).parse().program; let mut bindings = Vec::new(); for statement in &program.body { let (declaration, exported) = match statement { @@ -557,25 +575,35 @@ fn replace_placeholders( /// Convert TypeScript to JavaScript using Oxc Transformer /// `code` without its TypeScript, stripped once for as long as the file holds /// it: the modules evaluations import are stripped once, not per evaluation +#[cfg(test)] pub(crate) fn strip_typescript(code: &str, filename: &str) -> String { + strip_typescript_with_type(code, filename, None).unwrap_or_default() +} + +pub(crate) fn strip_typescript_with_type( + code: &str, + filename: &str, + mode: Option, +) -> Result { + let source_type = crate::source_type::parser_type(filename, mode)?; + let key = (filename.to_string(), mode); if let Some(stripped) = STRIPPED.with_borrow(|stripped| { stripped - .get(filename) + .get(&key) .filter(|(source, _)| source == code) .map(|(_, stripped)| stripped.clone()) }) { - return stripped; + return Ok(stripped); } - let stripped = strip(code, filename); + let stripped = strip(code, source_type); STRIPPED.with_borrow_mut(|entries| { - entries.insert(filename.to_string(), (code.to_string(), stripped.clone())); + entries.insert(key, (code.to_string(), stripped.clone())); }); - stripped + Ok(stripped) } -fn strip(code: &str, filename: &str) -> String { +fn strip(code: &str, source_type: SourceType) -> String { let allocator = Allocator::default(); - let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::ts()); let mut program = Parser::new(&allocator, code, source_type).parse().program; let scoping = SemanticBuilder::new() .with_enum_eval(true) @@ -1470,9 +1498,12 @@ mod tests { path.trim_end_matches(".ts") == specifier.trim_start_matches('.').trim_end_matches(".ts") }) - .map(|(path, code)| crate::ResolvedModule { - path: (*path).to_string(), - code: (*code).to_string(), + .map(|(path, code)| { + crate::ModuleResolution::Resolved(crate::ResolvedModule { + source_type: None, + path: (*path).to_string(), + code: (*code).to_string(), + }) }) }; execute_stylesheet( @@ -1514,11 +1545,11 @@ mod tests { cycle_error( "import { b } from './b'\nexport const x = b", &[ - ("/b", "import { c } from './c'\nexport const b = c"), - ("/c", "import { b } from './b'\nexport const c = b"), + ("/b.ts", "import { c } from './c'\nexport const b = c"), + ("/c.ts", "import { b } from './b'\nexport const c = b"), ] ) - .contains("Cannot access 'b' of '/b'") + .contains("Cannot access 'b' of '/b.ts'") ); assert!( cycle_error( @@ -1539,11 +1570,11 @@ mod tests { "import { style } from '@devup-ui/react'\nimport { both, reexported } from './b'\nimport { d, withA } from './d.css'\nexport const x = style([d, { color: both, background: reexported }])\nexport const y = typeof withA", &[ ( - "/b", + "/b.ts", "import { c, lazy } from './c'\nexport const b = 'blue'\nexport const both = c()\nexport { lazy as reexported }" ), ( - "/c", + "/c.ts", "import bDefault, { b } from './b'\nimport * as all from './b'\nexport const c = () => b + all.b.length\nexport const lazyDefault = () => bDefault\nexport const lazy = 'red'\nexport { b as again }" ), ("/a.css.ts", ""), diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 93846123e..7ca23a245 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -123,7 +123,7 @@ pub struct DevupVisitor<'a> { import_object: Option, jsx_imports: FxHashMap, util_imports: FxHashMap>, - jsx_object: Option, + jsx_objects: FxHashSet, package: String, /// Entry the rewritten imports of absorbed third-party APIs land on. Its specifiers /// register exactly like the main package's so those calls still compile away. @@ -314,7 +314,7 @@ impl<'a> DevupVisitor<'a> { styles: FxHashSet::default(), errors: Vec::new(), import_object: None, - jsx_object: None, + jsx_objects: FxHashSet::default(), util_imports: FxHashMap::default(), split_filename, styled_imports: FxHashSet::default(), @@ -1921,31 +1921,38 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { fn visit_call_expression(&mut self, it: &mut CallExpression<'a>) { let jsx = if let Expression::Identifier(ident) = &it.callee { self.jsx_imports.get(ident.name.as_str()).cloned() - } else if let Some(name) = &self.jsx_object - && let Expression::StaticMemberExpression(member) = &it.callee + } else if let Expression::StaticMemberExpression(member) = &it.callee && let Expression::Identifier(ident) = &member.object - && name == ident.name.as_str() + && self.jsx_objects.contains(ident.name.as_str()) { Some(member.property.name.to_string()) } else { None }; if let Some(j) = jsx - && (j == "jsx" || j == "jsxs") + && (j == "jsx" || j == "jsxs" || j == "jsxDEV") && let Some(expr) = it.arguments.first().and_then(|arg| arg.as_expression()) { let element_kind = if let Expression::Identifier(ident) = expr { self.imports.get(ident.name.as_str()).cloned() } else if let Expression::StaticMemberExpression(member) = expr && let Expression::Identifier(ident) = &member.object - && self.import_object.as_deref() == Some(ident.name.as_str()) { - member - .property - .name - .as_str() - .parse::() - .ok() + self.imports + .get(&format!("{}.{}", ident.name, member.property.name)) + .cloned() + .or_else(|| { + (self.import_object.as_deref() == Some(ident.name.as_str())) + .then(|| { + member + .property + .name + .as_str() + .parse::() + .ok() + }) + .flatten() + }) } else { None }; @@ -2120,9 +2127,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { (&call.callee, &call.arguments[0]) && ident.name == "require" { - if arg.value == "react/jsx-runtime" { + if arg.value == "react/jsx-runtime" || arg.value == "react/jsx-dev-runtime" { if let BindingPattern::BindingIdentifier(ident) = &it.id { - self.jsx_object = Some(ident.name.to_string()); + self.jsx_objects.insert(ident.name.to_string()); } else if let BindingPattern::ObjectPattern(object) = &it.id { for prop in &object.properties { if let Some(name) = get_string_by_property_key(&prop.key) @@ -2236,13 +2243,20 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } fn visit_import_declaration(&mut self, it: &mut ImportDeclaration<'a>) { if it.source.value != self.package - && it.source.value == "react/jsx-runtime" + && (it.source.value == "react/jsx-runtime" + || it.source.value == "react/jsx-dev-runtime") && let Some(specifiers) = &it.specifiers { for specifier in specifiers { - if let ImportSpecifier(import) = specifier { - self.jsx_imports - .insert(import.local.to_string(), import.imported.to_string()); + match specifier { + ImportSpecifier(import) => { + self.jsx_imports + .insert(import.local.to_string(), import.imported.to_string()); + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(import) => { + self.jsx_objects.insert(import.local.name.to_string()); + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => {} } } } else if (it.source.value == self.package diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 9a989df8f..fb99cef61 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -502,7 +502,11 @@ impl StyleSheet { Some(filename) }; let bucket_scope = if single_css { None } else { Some(filename) }; - for style in styles { + // Names are handed out in the order styles are first seen, so the + // set is walked in a fixed order, not the hash order. + let mut ordered: Vec<&ExtractStyleValue> = styles.iter().collect(); + ordered.sort_unstable(); + for style in ordered { match style { // A conditional `typography` preset: its class is the atom, and the // preset's declarations are emitted under the atom's selector. @@ -2909,6 +2913,44 @@ mod tests { assert_debug_snapshot!(sheet.create_css(None, true).split("*/").nth(1).unwrap()); } + #[test] + #[serial] + fn test_class_names_do_not_depend_on_the_order_styles_are_inserted() { + use extractor::extract_style::extract_static_style::ExtractStaticStyle; + use extractor::extract_style::extract_style_value::ExtractStyleValue; + + let values: Vec = (0..40) + .map(|index| { + ExtractStyleValue::Static(ExtractStaticStyle::new( + ["color", "margin", "padding", "width"][index % 4], + &format!("{index}px"), + 0, + None, + )) + }) + .collect(); + let mut outputs = Vec::new(); + for reverse in [false, true] { + css::class_map::reset_class_map(); + let mut styles = FxHashSet::default(); + if reverse { + values.iter().rev().for_each(|value| { + styles.insert(value.clone()); + }); + } else { + values.iter().for_each(|value| { + styles.insert(value.clone()); + }); + } + let mut sheet = StyleSheet::default(); + sheet.update_styles(&styles, "index.tsx", true); + outputs.push(( + sheet.create_css(None, true), + css::class_map::get_class_map(), + )); + } + assert_eq!(outputs[0], outputs[1]); + } #[test] #[serial] fn test_update_styles_with_typography() { diff --git a/packages/bun-plugin/README.md b/packages/bun-plugin/README.md index c23855214..8f4064ae6 100644 --- a/packages/bun-plugin/README.md +++ b/packages/bun-plugin/README.md @@ -40,9 +40,91 @@ Add the zero-config entry to Bun's preload list: preload = ["@devup-ui/bun-plugin"] ``` -## Custom Shorthands +## Bundling with `Bun.build` + +```ts +import { DevupUI } from '@devup-ui/bun-plugin/register' + +await Bun.build({ + entrypoints: ['./src/index.tsx'], + outdir: './dist', + plugins: [DevupUI()], +}) +``` + +The build emits the stylesheet as a CSS output holding the styles of every +module in the bundle. Imports of the packages Devup UI takes the place of +(`@emotion/react`, `@emotion/styled`, `styled-components`, +`@vanilla-extract/css`) and of `@stylexjs/stylex` are compiled too. + +Class names are short by default in both `Bun.build` and the runtime. Pass +`debug: true` for readable names (including snapshot tests). + +The plugin transforms loaded `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, +`.mjs`, and `.cjs` modules, including libraries that import Devup UI, its +compatibility aliases, or StyleX. Unlike the other bundler plugins, Bun does +not require an `include` list when bundling loaded uncompiled libraries. +Under the runtime, use `include: ['your-library']` for uncompiled dependencies; +`@devup-ui/components` is included automatically. Runtime candidates are +discovered at setup so ordinary token/CommonJS modules retain Bun's native +watch/loading behavior; restart Bun after adding a new styling module or +changing `include`. Precompiled libraries must still publish their extracted +CSS for consumers to import. + +`mdxExtensions` defaults to `['.mdx']`; `.md` and custom literal extensions such +as `.mdown` are opt-in. Devup compiles every selected file asynchronously using +the project's own `@mdx-js/mdx`, with explicit MDX format, before extraction. +Install that compiler even for plain selected MDX: missing compilation is a +located installation error, not a silent handoff to another plugin. The same +list controls loading, graph discovery and numbering. Compiled JavaScript/JSX +keeps its real filename. Extractor errors use the compiler's source map when +available, otherwise their locations are labeled `in compiled MDX`. + +`root` defaults to `Bun.build`'s root, or the runtime's working directory. +Relative `devupFile` (default `devup.json`), `distDir` (default `df`) and +`sourceDirs` resolve against that root. Default numbering roots are the existing +`src` and `app` directories; use `sourceDirs` for other layouts. Malformed or +cyclic theme configuration stops initialization with a file-located error. +Optional file-number seeding failures emit one warning including the cause. -To configure custom shorthands, preload a local module instead of the +Build-time imported modules remain explicit side-effect imports in Bun's graph +so runtime watch mode can reload their importers. `Bun.build` takes CSS only +after extraction completes. Runtime imports synchronously publish changed CSS +to disk; identical revisions skip writes, without delaying read-after-import. + +## MDX Ownership And Public API Limits + +In `Bun.build`, selected MDX entries and selected files reached by literal +imports in modules Devup processes must go through Devup's own loader. A file +claimed first by another plugin is a located ownership error naming its target +and extension. Let Devup compile that extension, or remove it from +`mdxExtensions`. CSS finalization checks after `defer()`; an independent +`onEnd` check covers builds without stylesheet imports. Watch rebuilds that +execute these build callbacks receive the same check. + +Bun does not expose loader chaining or another plugin's final module/resolve +target. A module loaded by another plugin under an extension Devup does not own +is invisible, including definitely used Devup exports. Such placeholders may +throw `Cannot run on the runtime` when rendered. Add the extension to +`mdxExtensions` and let Devup's project compiler load it instead of a separate +MDX plugin. Devup does not reject an indistinguishable harmless module or claim +to inspect another loader's final code. + +Whole namespace/require-result uses and dynamic imports do not prove which +export is used. They remain opaque rather than being conservatively rejected. +Runtime helpers and unused imports are not compile-time-use violations. + +The whole-build ownership guarantee does not apply to `Bun.plugin` runtime +registration: that API has no build `onEnd`/`defer` finalization point. Runtime +loading still compiles files Devup owns. An `onEnd` rejection can occur after +output files are written, even with `throw: false`; publish build artifacts only +after successful completion. Ownership diagnostics use the real importer +`:1:1` as an honest file-level fallback because this callback has no import +span; a structured CSS-load diagnostic may identify the virtual stylesheet. + +## Custom Shorthands + +To configure custom shorthands, preload a local module instead of the zero-config entry: ```toml diff --git a/packages/bun-plugin/__regression__/conditional.bun.ts b/packages/bun-plugin/__regression__/conditional.bun.ts new file mode 100644 index 000000000..77e135f45 --- /dev/null +++ b/packages/bun-plugin/__regression__/conditional.bun.ts @@ -0,0 +1,186 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +const entry = resolve(import.meta.dir, '../src/register.ts').replaceAll( + '\\', + '/', +) +const wasm = resolve( + import.meta.dir, + '../../../bindings/devup-ui-wasm/pkg/index.js', +).replaceAll('\\', '/') +const scratch = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'bun-conditions', +) +mkdirSync(scratch, { recursive: true }) + +function run(script: string) { + const root = realpathSync.native(mkdtempSync(join(scratch, 'conditional-'))) + try { + // Given: declaration order makes accidentally enabling require observable. + const branches = { + require: '811px', + custom: '821px', + browser: '823px', + bun: '827px', + node: '829px', + import: '839px', + module: '853px', + default: '857px', + } + const exports = Object.fromEntries( + Object.keys(branches).map((name) => [name, `./${name}.js`]), + ) + const files = { + 'bunfig.toml': '', + 'package.json': '{"type":"module"}', + 'wrong.js': "export const width = '863px'", + 'node_modules/conditional/package.json': JSON.stringify({ + type: 'module', + exports: { + '.': exports, + './node-first': { + node: './node.js', + bun: './bun.js', + default: './default.js', + }, + './import-only': { + require: './require.js', + import: './import.js', + default: './default.js', + }, + './module-first': { + module: './module.js', + import: './import.js', + default: './default.js', + }, + './fallback': { default: './default.js' }, + }, + }), + ...Object.fromEntries( + Object.entries(branches).map(([name, width]) => [ + `node_modules/conditional/${name}.js`, + `export const width = '${width}'`, + ]), + ), + } + for (const [name, contents] of Object.entries(files)) { + mkdirSync(resolve(root, name, '..'), { recursive: true }) + writeFileSync(join(root, name), contents) + } + writeFileSync( + join(root, 'check.ts'), + `import { expect } from 'bun:test'; import { DevupUI, register } from ${JSON.stringify(entry)}; import { setModuleResolver } from ${JSON.stringify(wasm)};\n${script}`, + ) + const result = Bun.spawnSync([process.execPath, 'check.ts'], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + }) + expect({ + exitCode: result.exitCode, + output: result.stdout.toString() + result.stderr.toString(), + }).toEqual({ exitCode: 0, output: '' }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +const builds = [ + { config: {}, specifier: 'conditional', width: '823px' }, + { config: { target: 'browser' }, specifier: 'conditional', width: '823px' }, + { config: { target: 'bun' }, specifier: 'conditional', width: '827px' }, + { config: { target: 'node' }, specifier: 'conditional', width: '829px' }, + { + config: { conditions: ['custom'] }, + specifier: 'conditional', + width: '821px', + }, + { + config: { target: 'node', conditions: 'custom' }, + specifier: 'conditional', + width: '821px', + }, + { + config: { target: 'bun' }, + specifier: 'conditional/node-first', + width: '829px', + }, + { config: {}, specifier: 'conditional/import-only', width: '839px' }, + { config: {}, specifier: 'conditional/module-first', width: '853px' }, + { + config: { conditions: [] }, + specifier: 'conditional/module-first', + width: '853px', + }, + { + config: { conditions: ['custom'] }, + specifier: 'conditional/module-first', + width: '853px', + }, + { config: {}, specifier: 'conditional/fallback', width: '857px' }, +] as const + +it.each([...builds])( + 'matches real Bun.build export selection for $specifier with $config', + ({ config, specifier, width }) => { + run(` + const config = ${JSON.stringify(config)}; + await Bun.write('oracle.ts', "export { width } from '${specifier}'"); + const oracle = await Bun.build({ ...config, entrypoints: ['./oracle.ts'] }); + expect(oracle.success, String(oracle.logs)).toBe(true); + const js = oracle.outputs.find(output => output.path.endsWith('.js')); + const actual = await import('data:text/javascript;base64,' + Buffer.from(await js.text()).toString('base64')); + expect(actual.width).toBe('${width}'); + // Vanilla Extract evaluates imports through its CommonJS evaluator, but + // these requests originated as ESM imports, not source require() calls. + await Bun.write('styled.ts', "import { style } from '@vanilla-extract/css'; import { width } from '${specifier}'; export const cls = style({ width });"); + // When: a later engine user replaces the shared resolver after setup. + const result = await Bun.build({ ...config, entrypoints: ['./styled.ts'], plugins: [DevupUI(), { name: 'other-engine', setup() { setModuleResolver(() => ({ path: process.cwd() + '/wrong.js', code: "export const width = '863px'" })); } }] }); + expect(result.success, String(result.logs)).toBe(true); + // Then: extracted compile-time CSS agrees with the actual bundler branch. + const css = result.outputs.find(output => output.path.endsWith('.css')); + expect(await css.text()).toContain(actual.width); + expect(await css.text()).not.toContain('863px'); + `) + }, +) + +it.each([ + 'conditional', + 'conditional/node-first', + 'conditional/import-only', + 'conditional/module-first', +])( + 'matches native runtime imports after a foreign resolver override for %s', + (specifier) => { + run(` + // Given: Bun runtime has bun/node conditions; the fixture also has browser. + const actual = await import('${specifier}'); + expect(require('conditional/import-only').width).toBe('811px'); + await Bun.write('styled.ts', "import { css } from '@devup-ui/react'; import { width } from '${specifier}'; export const cls = css({ width });"); + await register(); + setModuleResolver(() => ({ path: process.cwd() + '/wrong.js', code: "export const width = '863px'" })); + // When: the styling module is imported through the real runtime plugin. + const { cls } = await import('./styled.ts'); + // Then: synchronous disk CSS agrees with native resolution, not the override. + expect(cls).toBeTruthy(); + const css = await Bun.file('df/devup-ui/devup-ui.css').text(); + expect(css).toContain(actual.width); + expect(css).not.toContain('863px'); + `) + }, +) diff --git a/packages/bun-plugin/__regression__/css-emit.bun.ts b/packages/bun-plugin/__regression__/css-emit.bun.ts index 7637396be..7a39652cd 100644 --- a/packages/bun-plugin/__regression__/css-emit.bun.ts +++ b/packages/bun-plugin/__regression__/css-emit.bun.ts @@ -69,6 +69,97 @@ await import(cssPath) }, ) +const registerEntry = resolve(import.meta.dir, '..', 'dist', 'register.mjs') + +function run(cwd: string, script: string) { + writeFileSync(join(cwd, 'bunfig.toml'), '') + writeFileSync(join(cwd, 'check.ts'), script) + const result = Bun.spawnSync([process.execPath, 'run', 'check.ts'], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, + }) + expect( + result.exitCode, + result.stdout.toString() + result.stderr.toString(), + ).toBe(0) +} + +it('bundles the styles of every module into the Bun.build stylesheet', () => { + const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) + try { + const widths = Array.from({ length: 8 }, (_, i) => 301 + i) + for (const width of widths) { + writeFileSync( + join(cwd, `fixture-${width}.ts`), + `import { css } from '@devup-ui/react' +export const cls = css({ width: '${width}px' }) +`, + ) + } + writeFileSync( + join(cwd, 'entry.ts'), + widths + .map((width) => `export { cls as c${width} } from './fixture-${width}'`) + .join('\n'), + ) + run( + cwd, + `import { expect } from 'bun:test' +import { DevupUI } from ${JSON.stringify(registerEntry.replaceAll('\\', '/'))} + +const result = await Bun.build({ + entrypoints: ['./entry.ts'], + outdir: './out', + plugins: [DevupUI()], +}) +expect(result.success).toBe(true) +const stylesheets = result.outputs.filter((output) => output.path.endsWith('.css')) +expect(stylesheets).toHaveLength(1) +const css = (await stylesheets[0].text()).replace(/\\s+/g, '') +for (const width of ${JSON.stringify(widths)}) expect(css).toContain('width:' + width + 'px') +`, + ) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) + +it('compiles the packages Devup UI takes the place of, and StyleX', () => { + const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) + try { + writeFileSync( + join(cwd, 'emotion.ts'), + `import { css } from '@emotion/react' +export const cls = css({ width: '201px' }) +`, + ) + writeFileSync( + join(cwd, 'stylex.ts'), + `import * as stylex from '@stylexjs/stylex' +export const styles = stylex.create({ box: { width: '203px' } }) +`, + ) + run( + cwd, + `import { readFileSync } from 'node:fs' +import { expect } from 'bun:test' + +await import(${JSON.stringify(pluginEntry.replaceAll('\\', '/'))}) +const emotion = await import('./emotion.ts') +const stylex = await import('./stylex.ts') +expect(emotion.cls).toBeTruthy() +expect(stylex.styles).toBeTruthy() +const css = readFileSync('./df/devup-ui/devup-ui.css', 'utf-8') +for (const width of [201, 203]) expect(css).toContain('width:' + width + 'px') +`, + ) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) + it('emits vanilla-extract styles through the WASM engine', () => { const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) try { diff --git a/packages/bun-plugin/__regression__/modern.bun.ts b/packages/bun-plugin/__regression__/modern.bun.ts new file mode 100644 index 000000000..4d6f65f7c --- /dev/null +++ b/packages/bun-plugin/__regression__/modern.bun.ts @@ -0,0 +1,211 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +const entry = resolve(import.meta.dir, '../src/register.ts').replaceAll( + '\\', + '/', +) +const scratch = join(tmpdir(), 'opencode', 'workers', 'w20-plugins-core', 'bun') +mkdirSync(scratch, { recursive: true }) + +function run( + script: string, + files: Readonly>, + compiler = false, +) { + const root = realpathSync.native(mkdtempSync(join(scratch, 'modern-'))) + try { + writeFileSync(join(root, 'bunfig.toml'), '') + if (compiler) { + const host = createRequire( + resolve(import.meta.dir, '../../../apps/landing/package.json'), + ) + const compilerPath = createRequire( + host.resolve('@mdx-js/loader'), + ).resolve('@mdx-js/mdx') + mkdirSync(join(root, 'node_modules/@mdx-js'), { recursive: true }) + symlinkSync( + dirname(compilerPath), + join(root, 'node_modules/@mdx-js/mdx'), + 'junction', + ) + } + for (const [name, contents] of Object.entries(files)) { + mkdirSync(resolve(root, name, '..'), { recursive: true }) + writeFileSync(join(root, name), contents) + } + writeFileSync( + join(root, 'check.ts'), + `import { expect } from 'bun:test'; import { DevupUI, register } from ${JSON.stringify(entry)};\n${script}`, + ) + const result = Bun.spawnSync([process.execPath, 'check.ts'], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + }) + expect( + result.exitCode, + result.stdout.toString() + result.stderr.toString(), + ).toBe(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +it.each(['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs'])( + 'extracts real runtime and bundled source when its extension is %s', + (extension) => { + run( + ` + await register(); + const { cls } = await import('./local.${extension}'); + expect(cls).toBeTruthy(); + const disk = await Bun.file('df/devup-ui/devup-ui.css').text(); + expect(disk).toContain('width:719px'); + expect(cls).not.toContain('width'); + const result = await Bun.build({ entrypoints: ['./local.${extension}'], plugins: [DevupUI()] }); + expect(result.success, String(result.logs)).toBe(true); + const css = result.outputs.find(output => output.path.endsWith('.css')); + expect(await css.text()).toContain('719px'); + `, + { + [`local.${extension}`]: + "import { css } from '@devup-ui/react'; export const cls = css({ width: '719px' })", + }, + ) + }, +) + +it('uses project paths when invoked from a parent directory', () => { + run( + ` + const result = await Bun.build({ root: './project', entrypoints: ['./project/app/page.ts'], plugins: [DevupUI({ distDir: 'generated' })] }); + expect(result.success, String(result.logs)).toBe(true); + expect(await Bun.file('project/generated/theme.d.ts').exists()).toBe(true); + expect(await Bun.file('generated/theme.d.ts').exists()).toBe(false); + const css = result.outputs.find(output => output.path.endsWith('.css')); + expect(await css.text()).toContain('#abcdef'); + `, + { + 'project/devup.json': + '{"theme":{"colors":{"default":{"primary":"#abcdef"}}}}', + 'project/app/page.ts': + "import { css } from '@devup-ui/react'; export const cls = css({ width: '731px' })", + }, + ) +}) + +it.each(['{', '{"extends":["./devup.json"]}'])( + 'fails initialization with a located diagnostic when configuration is invalid: %s', + (config) => { + run( + ` + let failure; + try { await Bun.build({ entrypoints: ['./entry.ts'], plugins: [DevupUI()] }); } catch (cause) { failure = String(cause); } + expect(failure).toContain('devup.json'); + `, + { 'devup.json': config, 'entry.ts': 'export const value = 1' }, + ) + }, +) + +it('reports styling MDX without a compiler instead of extracting raw syntax', () => { + run( + ` + let failure; + try { await Bun.build({ entrypoints: ['./page.mdx'], plugins: [DevupUI()] }); } catch (cause) { failure = cause instanceof AggregateError ? cause.errors.map(String).join('\\n') : String(cause); } + expect(failure).toContain('page.mdx:1:1'); + expect(failure).toContain('@mdx-js/mdx'); + `, + { + 'node_modules/@mdx-js/mdx/package.json': '{"exports":"./missing.js"}', + 'page.mdx': + "import { Box } from '@devup-ui/react'\n\n# heading\n\n", + }, + ) +}) + +it('compiles real MDX before extraction using the project compiler', () => { + run( + ` + const result = await Bun.build({ entrypoints: ['./page.mdx'], external: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime'], plugins: [DevupUI()] }); + expect(result.success, String(result.logs)).toBe(true); + const css = result.outputs.find(output => output.path.endsWith('.css')); + expect(await css.text()).toContain('743px'); + const js = result.outputs.find(output => output.path.endsWith('.js')); + const code = await js.text(); + expect(code).toContain('heading'); + expect(code).not.toContain('@devup-ui/react'); + `, + { + 'page.mdx': + "import { Box } from '@devup-ui/react'\n\n# heading\n\n", + }, + true, + ) +}) + +it('labels compiled MDX locations when extraction fails without a compiler source map', () => { + run( + ` + let failure; + try { await Bun.build({ entrypoints: ['./page.mdx'], external: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime'], plugins: [DevupUI()] }); } + catch (cause) { failure = cause instanceof AggregateError ? cause.errors.map(String).join('\\n') : String(cause); } + expect(failure).toContain('page.mdx:'); + expect(failure).toContain('(in compiled MDX)'); + `, + { + 'page.mdx': + "import { css } from '@devup-ui/react'\n\nexport const cls = css({ width: unknownWidth })\n\n# heading", + }, + true, + ) +}) + +it('restores explicitly configured readable names when another engine user changes debug', () => { + const wasm = resolve( + import.meta.dir, + '../../../bindings/devup-ui-wasm/pkg/index.js', + ).replaceAll('\\', '/') + run( + ` + await register({ debug: true }); + const { setDebug } = await import(${JSON.stringify(wasm)}); + setDebug(false); + const { cls } = await import('./local.ts'); + expect(cls).toContain('width'); + `, + { + 'local.ts': + "import { css } from '@devup-ui/react'; export const cls = css({ width: '761px' })", + }, + ) +}) + +it('transforms an included uncompiled runtime library', () => { + run( + ` + await register({ include: ['library'] }); + const { cls } = await import('library'); + expect(cls).toBeTruthy(); + expect(await Bun.file('df/devup-ui/devup-ui.css').text()).toContain('width:769px'); + `, + { + 'node_modules/library/package.json': + '{"type":"module","exports":"./index.mjs"}', + 'node_modules/library/index.mjs': + "import { css } from '@devup-ui/react'; export const cls = css({ width: '769px' })", + }, + ) +}) diff --git a/packages/bun-plugin/__regression__/watch.bun.ts b/packages/bun-plugin/__regression__/watch.bun.ts new file mode 100644 index 000000000..d3a4fb4eb --- /dev/null +++ b/packages/bun-plugin/__regression__/watch.bun.ts @@ -0,0 +1,76 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +it('reextracts an importer when only its build-time token changes under normal Bun watch caching', async () => { + const scratch = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'bun', + ) + mkdirSync(scratch, { recursive: true }) + const root = realpathSync.native(mkdtempSync(join(scratch, 'watch-'))) + const plugin = resolve(import.meta.dir, '../src/register.ts').replaceAll( + '\\', + '/', + ) + writeFileSync(join(root, 'bunfig.toml'), '') + writeFileSync(join(root, 'tokens.ts'), "export const width = '751px'") + writeFileSync( + join(root, 'style.css.ts'), + "import { style } from '@devup-ui/react'; import { width } from './tokens'; export const cls = style({ width })", + ) + writeFileSync( + join(root, 'check.ts'), + `import { register } from ${JSON.stringify(plugin)}; +await register({ debug: true }); +const { cls } = await import('./style.css.ts'); +console.log('WATCH_RESULT:' + JSON.stringify({ cls, css: await Bun.file('df/devup-ui/devup-ui.css').text() }));`, + ) + const child = Bun.spawn([process.execPath, '--watch', 'check.ts'], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + }) + const errors = new Response(child.stderr).text() + const timer = setTimeout(() => child.kill(), 15000) + const results: string[] = [] + try { + let pending = '' + for await (const chunk of child.stdout) { + pending += new TextDecoder().decode(chunk) + const lines = pending.split('\n') + pending = lines.pop() ?? '' + for (const line of lines) { + if (!line.startsWith('WATCH_RESULT:')) continue + results.push(line) + if (results.length === 1) + writeFileSync(join(root, 'tokens.ts'), "export const width = '757px'") + } + if (results.length >= 2) break + } + child.kill() + expect(results, (await errors) + results.join('\n')).toHaveLength(2) + expect(results[0]).toContain('width:751px') + expect(results[1]).toContain('width:757px') + expect(results[0]).toMatch(/"cls":"[^"]*751px/) + expect(results[1]).toMatch(/"cls":"[^"]*757px/) + expect(results[1]).not.toContain('width:751px') + expect(results[1]).not.toBe(results[0]) + } finally { + clearTimeout(timer) + child.kill() + await child.exited + rmSync(root, { recursive: true, force: true }) + } +}, 20000) diff --git a/packages/bun-plugin/src/__tests__/callback-fixture.test.ts b/packages/bun-plugin/src/__tests__/callback-fixture.test.ts new file mode 100644 index 000000000..b03b35573 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/callback-fixture.test.ts @@ -0,0 +1,108 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { loadDevupConfig } from '@devup-ui/plugin-utils' +import { + registerShorthands, + registerTheme, + resetBuildState, + setDebug, + setPrefix, +} from '@devup-ui/wasm' +import type { + OnEndCallback, + OnLoadCallback, + OnResolveCallback, + PluginConstraints, +} from 'bun' + +import { DevupUI } from '../devup-plugin' + +type Builder = Parameters['setup']>[0] + +export function fixture() { + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-bun-callback-')), + ) + const loads: { constraints: PluginConstraints; callback: OnLoadCallback }[] = + [] + const resolves: { + constraints: PluginConstraints + callback: OnResolveCallback + }[] = [] + const ends: OnEndCallback[] = [] + let success = true + const builder = { + onLoad(constraints, callback) { + loads.push({ constraints, callback }) + return this + }, + onResolve(constraints, callback) { + resolves.push({ constraints, callback }) + return this + }, + onEnd(callback) { + ends.push(callback) + return this + }, + } satisfies Builder + return { + root, + builder, + loads, + resolves, + write(name: string, contents: string) { + const path = join(root, name) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, contents) + return path + }, + async setup( + options: Parameters[0] = {}, + config?: Builder['config'], + ) { + await DevupUI({ root, ...options }).setup({ + ...builder, + ...(config ? { config } : {}), + }) + }, + async load(path: string, namespace = 'file', defer = async () => {}) { + const hook = loads.find( + ({ constraints }) => + (constraints.namespace ?? 'file') === namespace && + constraints.filter.test(path), + ) + if (!hook) throw new Error(`No load hook for ${namespace}:${path}`) + try { + return await hook.callback({ path, namespace, loader: 'tsx', defer }) + } catch (error) { + success = false + throw error + } + }, + async cleanup() { + for (const end of ends) await end({ outputs: [], logs: [], success }) + resetBuildState() + registerShorthands({}) + setPrefix(null) + registerTheme( + (await loadDevupConfig(join(process.cwd(), 'devup.json'))).theme ?? {}, + ) + setDebug(true) + rmSync(root, { recursive: true, force: true }) + }, + } +} + +export function sourceContents(result: Awaited>) { + if (!result || !('contents' in result) || typeof result.contents !== 'string') + throw new TypeError('Expected source contents') + return result.contents +} diff --git a/packages/bun-plugin/src/__tests__/mdx-ownership.test.ts b/packages/bun-plugin/src/__tests__/mdx-ownership.test.ts new file mode 100644 index 000000000..7db453379 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/mdx-ownership.test.ts @@ -0,0 +1,89 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import { getCss } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' + +import { DevupUI } from '../devup-plugin' + +const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) + +it.each(['.mdx', '.mdown'])( + 'compiles owned %s using the project compiler', + async (extension) => { + const root = mkdtempSync(join(tmpdir(), 'devup-owned-bun-')) + try { + const path = join(root, `page${extension}`) + const compilerPath = createRequire( + landing.resolve('@mdx-js/loader'), + ).resolve('@mdx-js/mdx') + mkdirSync(join(root, 'node_modules/@mdx-js'), { recursive: true }) + symlinkSync( + dirname(compilerPath), + join(root, 'node_modules/@mdx-js/mdx'), + 'junction', + ) + writeFileSync( + path, + 'import {Box} from "@devup-ui/react"\n\n', + ) + const result = await Bun.build({ + entrypoints: [path], + root, + plugins: [DevupUI({ root, mdxExtensions: ['.mdx', '.mdown'] })], + external: ['react', 'react/jsx-runtime'], + }) + expect(result.success).toBe(true) + expect(getCss(null, false)).toContain('red') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 30_000, +) + +it.each(['entry', 'import'])( + 'rejects a stolen owned %s without stylesheet imports', + async (kind) => { + const root = mkdtempSync(join(tmpdir(), 'devup-stolen-bun-')) + try { + const mdx = join(root, 'page.mdown') + const entry = join(root, 'entry.ts') + writeFileSync(mdx, '# Plain') + writeFileSync( + entry, + 'import value from "./page.mdown"; console.log(value)', + ) + const action = Bun.build({ + entrypoints: [kind === 'entry' ? mdx : entry], + root, + plugins: [ + { + name: 'stealer', + setup(builder) { + builder.onLoad({ filter: /\.mdown$/ }, () => ({ + contents: 'export default 1', + loader: 'js', + })) + }, + }, + DevupUI({ root, mdxExtensions: ['.mdown'] }), + ], + }) + await expect(action).rejects.toThrow('let Devup compile') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 30_000, +) diff --git a/packages/bun-plugin/src/__tests__/mdx.test.ts b/packages/bun-plugin/src/__tests__/mdx.test.ts new file mode 100644 index 000000000..9da06fe83 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/mdx.test.ts @@ -0,0 +1,78 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { compileMdx } from '../mdx' + +const scratch = join(tmpdir(), 'opencode', 'workers', 'w20-plugins-core', 'bun') +mkdirSync(scratch, { recursive: true }) + +it('compiles original MDX when the project has the real compiler', async () => { + const root = mkdtempSync(join(scratch, 'mdx-')) + try { + const host = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), + ) + const compilerPath = createRequire(host.resolve('@mdx-js/loader')).resolve( + '@mdx-js/mdx', + ) + mkdirSync(join(root, 'node_modules/@mdx-js'), { recursive: true }) + symlinkSync( + dirname(compilerPath), + join(root, 'node_modules/@mdx-js/mdx'), + 'junction', + ) + const compiled = await compileMdx( + root, + join(root, 'page.mdx'), + '# title\n\n
', + ) + expect(compiled?.value).toContain('
') + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 20000) + +it.each(['unrelated', 'styling', 'invalid compiler'])( + 'handles compiler boundaries when the project has %s MDX', + async (scenario) => { + const root = mkdtempSync(join(scratch, 'mdx-')) + try { + const pkg = join(root, 'node_modules/@mdx-js/mdx') + mkdirSync(pkg, { recursive: true }) + writeFileSync( + join(pkg, 'package.json'), + JSON.stringify({ + type: 'module', + exports: + scenario === 'invalid compiler' ? './index.js' : './missing.js', + }), + ) + writeFileSync(join(pkg, 'index.js'), 'export const unrelated = true') + const action = () => + compileMdx( + root, + join(root, 'page.mdx'), + scenario === 'styling' + ? "import { Box } from '@devup-ui/react'" + : '# title', + ) + if (scenario === 'unrelated') expect(await action()).toBeUndefined() + else + await expect(action()).rejects.toThrow( + scenario === 'styling' ? 'page.mdx:1:1' : 'compile()', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, +) diff --git a/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts b/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts new file mode 100644 index 000000000..a85ad5d7f --- /dev/null +++ b/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts @@ -0,0 +1,224 @@ +import { symlinkSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { fixture, sourceContents } from './callback-fixture.test' + +it('warns once and still extracts real files when optional numbering fails', async () => { + const f = fixture() + const path = f.write( + 'src/style.ts', + "import { css } from '@devup-ui/react'; export const cls = css({ width: '751px' })", + ) + const cause = new Error('optional seed failure') + const seed = spyOn(wasm, 'seedFileMap').mockImplementationOnce(() => { + throw cause + }) + const warning = spyOn(console, 'warn').mockImplementation(() => {}) + try { + await f.setup({}, { entrypoints: [path], plugins: [] }) + expect(warning).toHaveBeenCalledTimes(1) + expect(warning.mock.calls[0]?.[1]).toEqual({ + root: f.root, + phase: 'seed', + impact: 'arrival-order file IDs', + cause, + }) + expect(sourceContents(await f.load(path))).not.toContain('@devup-ui/react') + expect(wasm.getCss(null, false)).toContain('751px') + } finally { + seed.mockRestore() + warning.mockRestore() + await f.cleanup() + } +}) + +it('propagates non-Error numbering faults rather than converting them to warnings', async () => { + const f = fixture() + f.write( + 'src/style.ts', + "import { css } from '@devup-ui/react'; export const cls = css({ width: '757px' })", + ) + const cause = { code: 'seed-fault' } + const seed = spyOn(wasm, 'seedFileMap').mockImplementationOnce(() => { + throw cause + }) + try { + await expect(f.setup()).rejects.toBe(cause) + } finally { + seed.mockRestore() + await f.cleanup() + } +}) + +it.each(['unrelated', 'styling'])( + 'handles %s MDX without a project compiler', + async (scenario) => { + const f = fixture() + f.write( + 'node_modules/@mdx-js/mdx/package.json', + '{"exports":"./missing.js"}', + ) + const path = f.write( + 'page.mdx', + scenario === 'styling' + ? "import { Box } from '@devup-ui/react'\n\n" + : '# title', + ) + try { + await f.setup({}, { entrypoints: [path], plugins: [] }) + await expect(f.load(path)).rejects.toThrow('page.mdx:1:1') + } finally { + await f.cleanup() + } + }, +) + +it.each(['plain', 'styling', 'invalid'])( + 'compiles real %s MDX with the project compiler before scanning/extraction', + async (scenario) => { + const f = fixture() + const host = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), + ) + const compiler = createRequire(host.resolve('@mdx-js/loader')).resolve( + '@mdx-js/mdx', + ) + f.write('node_modules/@mdx-js/placeholder', '') + symlinkSync( + dirname(compiler), + join(f.root, 'node_modules/@mdx-js/mdx'), + 'junction', + ) + const path = f.write( + 'src/page.mdx', + { + plain: '# heading\n\n
', + styling: + "import { Box } from '@devup-ui/react'\n\n# heading\n\n", + invalid: + "import { css } from '@devup-ui/react'\n\nexport const cls = css({ width: unknownWidth })\n\n# heading", + }[scenario], + ) + try { + await f.setup({}, { entrypoints: [path], plugins: [] }) + if (scenario === 'invalid') + await expect(f.load(path)).rejects.toThrow('(in compiled MDX)') + else { + const result = await f.load(path) + expect(result?.loader).toBe('jsx') + expect(sourceContents(result)).toContain('heading') + if (scenario === 'styling') { + expect(sourceContents(result)).not.toContain('@devup-ui/react') + expect(wasm.getCss(null, false)).toContain('761px') + } + } + } finally { + await f.cleanup() + } + }, + 20000, +) + +it('uses compiler source maps for located extraction errors', async () => { + const f = fixture() + f.write( + 'node_modules/@mdx-js/mdx/package.json', + '{"type":"module","exports":"./index.js"}', + ) + f.write( + 'node_modules/@mdx-js/mdx/index.js', + `export async function compile(input) { + return { value: "import { css } from '@devup-ui/react'; export const cls = css({ width: unknownWidth })", map: { version: 3, sources: [input.path], names: [], mappings: 'AAIA' } } + }`, + ) + const path = f.write('page.mdx', '# title') + try { + await f.setup({}, { entrypoints: [path], plugins: [] }) + await expect(f.load(path)).rejects.toThrow(`${path}:5:1`) + } finally { + await f.cleanup() + } +}) + +it.each([ + { target: 'browser', conditions: ['custom'], width: '773px' }, + { target: 'node', conditions: 'custom', width: '773px' }, + { target: 'node', conditions: [], width: '787px' }, + { target: 'bun', width: '797px' }, +] as const)( + 'selects compile-time conditional exports using $target and $conditions', + async (config) => { + const f = fixture() + f.write( + 'node_modules/conditional/package.json', + JSON.stringify({ + type: 'module', + exports: { + custom: './custom.js', + bun: './bun.js', + node: './node.js', + browser: './browser.js', + }, + }), + ) + for (const [branch, width] of Object.entries({ + custom: '773px', + node: '787px', + bun: '797px', + browser: '809px', + })) + f.write( + `node_modules/conditional/${branch}.js`, + `export const width = '${width}'`, + ) + const path = f.write( + 'style.ts', + "import { css } from '@devup-ui/react'; import { width } from 'conditional'; export const cls = css({ width })", + ) + try { + await f.setup( + {}, + { + entrypoints: [path], + plugins: [], + target: config.target, + ...(config.conditions !== undefined + ? { + conditions: + typeof config.conditions === 'string' + ? config.conditions + : [...config.conditions], + } + : {}), + }, + ) + await f.load(path) + expect(wasm.getCss(null, false)).toContain(config.width) + } finally { + await f.cleanup() + } + }, +) + +it('includes configured libraries in the native-path runtime filter', async () => { + const f = fixture() + f.write( + 'node_modules/library/package.json', + '{"type":"module","exports":"./index.mjs"}', + ) + const path = f.write( + 'node_modules/library/index.mjs', + "import { css } from '@devup-ui/react'; export const cls = css({ width: '811px' })", + ) + try { + await f.setup({ include: ['library'] }) + expect(sourceContents(await f.load(path))).not.toContain('@devup-ui/react') + expect(wasm.getCss(null, false)).toContain('811px') + } finally { + await f.cleanup() + } +}) diff --git a/packages/bun-plugin/src/__tests__/plugin.test.ts b/packages/bun-plugin/src/__tests__/plugin.test.ts index e93d67418..5e33170ce 100644 --- a/packages/bun-plugin/src/__tests__/plugin.test.ts +++ b/packages/bun-plugin/src/__tests__/plugin.test.ts @@ -1,199 +1,201 @@ -import * as fs from 'node:fs' -import * as fsPromises from 'node:fs/promises' +import { readFileSync, statSync, utimesSync } from 'node:fs' import { join } from 'node:path' -import * as wasm from '@devup-ui/wasm' -import { - afterEach, - beforeEach, - describe, - expect, - it, - mock, - spyOn, -} from 'bun:test' +import { getCss, setDebug, setModuleResolver } from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' -let getDefaultThemeSpy: ReturnType -let existsSyncSpy: ReturnType -let readFileSpy: ReturnType -let writeFileSpy: ReturnType -let mkdirSpy: ReturnType -let registerThemeSpy: ReturnType -let getThemeInterfaceSpy: ReturnType -let getCssSpy: ReturnType -let consoleErrorSpy: ReturnType -let setDebugSpy: ReturnType -let hasDevupUISpy: ReturnType -let codeExtractSpy: ReturnType +import { cssNamespace } from '../css-id' +import { fixture, sourceContents } from './callback-fixture.test' -type CodeExtractResult = ReturnType - -function createCodeExtractResult(): CodeExtractResult { - return { - code: 'code', - css: '', - cssFile: null, - map: null, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - } as unknown as CodeExtractResult -} - -beforeEach(() => { - getDefaultThemeSpy = spyOn(wasm, 'getDefaultTheme').mockReturnValue('default') - existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) - readFileSpy = spyOn(fsPromises, 'readFile').mockResolvedValue('{}') - writeFileSpy = spyOn(fsPromises, 'writeFile').mockResolvedValue(undefined) - mkdirSpy = spyOn(fsPromises, 'mkdir').mockResolvedValue(undefined) - registerThemeSpy = spyOn(wasm, 'registerTheme').mockReturnValue(undefined) - getThemeInterfaceSpy = spyOn(wasm, 'getThemeInterface').mockReturnValue('') - getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('css') - consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}) - setDebugSpy = spyOn(wasm, 'setDebug').mockReturnValue(undefined) - hasDevupUISpy = spyOn(wasm, 'hasDevupUI').mockReturnValue(false) - codeExtractSpy = spyOn(wasm, 'codeExtract').mockReturnValue( - createCodeExtractResult(), +it('writes theme declarations and CSS when setup creates project directories', async () => { + const f = fixture() + f.write( + 'devup.json', + '{"theme":{"colors":{"default":{"primary":"#abcdef"}}}}', ) + try { + await f.setup({ shorthands: { insetX: ['left', 'right'] } }) + expect(readFileSync(join(f.root, 'df/theme.d.ts'), 'utf8')).toContain( + 'primary', + ) + expect(readFileSync(join(f.root, 'df/theme.d.ts'), 'utf8')).toContain( + 'insetX', + ) + expect(readFileSync(join(f.root, 'df/compat.d.ts'), 'utf8')).toContain( + '@devup-ui/react/compat/emotion', + ) + expect(readFileSync(join(f.root, 'df/.gitignore'), 'utf8')).toBe('*') + expect( + readFileSync( + join(f.root, 'df/devup-ui/devup-ui.css'), + 'utf8', + ).toLowerCase(), + ).toContain('#abcdef') + } finally { + await f.cleanup() + } }) -afterEach(() => { - getDefaultThemeSpy.mockRestore() - existsSyncSpy.mockRestore() - readFileSpy.mockRestore() - writeFileSpy.mockRestore() - mkdirSpy.mockRestore() - registerThemeSpy.mockRestore() - getThemeInterfaceSpy.mockRestore() - getCssSpy.mockRestore() - consoleErrorSpy.mockRestore() - setDebugSpy.mockRestore() - hasDevupUISpy.mockRestore() - codeExtractSpy.mockRestore() +it('overwrites stale declarations when configuration has no theme and directories exist', async () => { + const f = fixture() + f.write('devup.json', '{}') + f.write('df/theme.d.ts', 'stale-theme') + f.write('df/devup-ui/devup-ui.css', 'stale-css') + try { + await f.setup() + expect(readFileSync(join(f.root, 'df/theme.d.ts'), 'utf8')).not.toContain( + 'stale-theme', + ) + expect( + readFileSync(join(f.root, 'df/devup-ui/devup-ui.css'), 'utf8'), + ).not.toContain('stale-css') + } finally { + await f.cleanup() + } }) -describe('getDevupDefine', () => { - it('should return define object with theme', async () => { - getDefaultThemeSpy.mockReturnValue('dark') - expect(getDefaultThemeSpy()).toBe('dark') - }) - - it('should return empty object when no theme', async () => { - getDefaultThemeSpy.mockReturnValue(undefined) - expect(getDefaultThemeSpy()).toBe(undefined) - }) +it('publishes changed CSS synchronously but leaves identical revisions untouched', async () => { + const f = fixture() + const path = f.write( + 'src/style.ts', + "import { css } from '@devup-ui/react'; export const cls = css({ width: '719px' })", + ) + const tokens = f.write('src/tokens.ts', "export const width = '727px'") + const plain = f.write('src/plain.ts', 'export const value = 1') + try { + await f.setup() + const result = await f.load(path) + expect(sourceContents(result)).not.toContain('@devup-ui/react') + const cssPath = join(f.root, 'df/devup-ui/devup-ui.css') + expect(readFileSync(cssPath, 'utf8')).toContain('width:719px') + utimesSync(cssPath, 1, 1) + const before = statSync(cssPath).mtimeMs + await f.load(path) + expect(statSync(cssPath).mtimeMs).toBe(before) + expect( + f.loads + .find((h) => !h.constraints.namespace) + ?.constraints.filter.test(tokens), + ).toBe(false) + expect( + f.loads + .find((h) => !h.constraints.namespace) + ?.constraints.filter.test(plain), + ).toBe(false) + expect( + await f.load('devup-ui.css', cssNamespace, () => { + throw new Error('Runtime must not defer') + }), + ).toEqual({ contents: '', loader: 'js' }) + } finally { + await f.cleanup() + } }) -describe('writeDataFiles behavior', () => { - it('should register theme from devup.json when it exists', async () => { - readFileSpy.mockResolvedValue('{"theme": {"colors": {"primary": "#000"}}}') - getThemeInterfaceSpy.mockReturnValue('interface CustomColors {}') - - // Simulate writeDataFiles behavior without exporting private plugin helpers. - const content = '{"theme": {"colors": {"primary": "#000"}}}' - const parsed = JSON.parse(content) - registerThemeSpy(parsed?.['theme'] ?? {}) - - expect(registerThemeSpy).toHaveBeenCalledWith({ - colors: { primary: '#000' }, - }) - }) - - it('should write theme.d.ts when interfaceCode is returned', async () => { - getThemeInterfaceSpy.mockReturnValue('interface CustomColors {}') +it('preserves compile-time dependency imports and restores debug/resolver before extraction', async () => { + const f = fixture() + f.write('src/tokens.ts', "export const width = '733px'") + const path = f.write( + 'src/style.ts', + "import { style } from '@vanilla-extract/css'; import { width } from './tokens'; export const cls = style({ width })", + ) + try { + await f.setup({ debug: true }) + setDebug(false) + setModuleResolver(() => ({ + path: join(f.root, 'wrong.ts'), + code: "export const width = '999px'", + })) + const code = sourceContents(await f.load(path)) + expect(code).toContain('733px') + expect(code).toContain('tokens.ts') + expect(getCss(null, false)).toContain('width:733px') + expect(getCss(null, false)).not.toContain('999px') + } finally { + await f.cleanup() + } +}) - const interfaceCode = getThemeInterfaceSpy( - '@devup-ui/react', - 'CustomColors', - 'DevupThemeTypography', - 'CustomLength', - 'CustomShadows', - 'DevupTheme', +it('defers bundled CSS until source callbacks complete and does not publish it to disk', async () => { + const f = fixture() + const path = f.write( + 'style.ts', + "import { css } from '@devup-ui/react'; export const cls = css({ width: '739px' })", + ) + try { + await f.setup({}, { entrypoints: [path], root: f.root, plugins: [] }) + const initial = readFileSync( + join(f.root, 'df/devup-ui/devup-ui.css'), + 'utf8', ) - - if (interfaceCode) { - await writeFileSpy(join('df', 'theme.d.ts'), interfaceCode, 'utf-8') - } - - expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), - 'interface CustomColors {}', - 'utf-8', + const defer = spyOn( + { + async run() { + await f.load(path) + }, + }, + 'run', ) - }) - - it('should register empty theme when devup.json does not exist', async () => { - existsSyncSpy.mockReturnValue(false) - - // Simulate the missing config branch. - const content = undefined - if (!content) { - registerThemeSpy({}) - } - - expect(registerThemeSpy).toHaveBeenCalledWith({}) - }) - - it('should handle error and register empty theme on catch', async () => { - existsSyncSpy.mockImplementation((path: string) => path === 'devup.json') - readFileSpy.mockRejectedValue(new Error('Read error')) - - try { - await readFileSpy('devup.json', 'utf-8') - } catch (error) { - consoleErrorSpy(error) - registerThemeSpy({}) - } - - expect(consoleErrorSpy).toHaveBeenCalled() - expect(registerThemeSpy).toHaveBeenCalledWith({}) - }) - - it('should handle JSON without theme key', async () => { - existsSyncSpy.mockImplementation((path: string) => path === 'devup.json') - - const content = '{"otherKey": "value"}' - const parsed = JSON.parse(content) - registerThemeSpy(parsed?.['theme'] ?? {}) - - expect(registerThemeSpy).toHaveBeenCalledWith({}) - }) - - it('should create css directory when it does not exist', async () => { - existsSyncSpy.mockReturnValue(false) - - // Simulate the directory creation branch without exposing internals. - if (!existsSyncSpy('df/devup-ui')) { - await mkdirSpy('df/devup-ui', { recursive: true }) - } - - expect(mkdirSpy).toHaveBeenCalledWith('df/devup-ui', { recursive: true }) - }) + const css = await f.load('devup-ui.css', cssNamespace, defer) + expect(defer).toHaveBeenCalledTimes(1) + expect(sourceContents(css)).toContain('739px') + expect(readFileSync(join(f.root, 'df/devup-ui/devup-ui.css'), 'utf8')).toBe( + initial, + ) + const resolve = f.resolves[0] + if (!resolve) throw new Error('Expected CSS resolver') + expect( + await resolve.callback({ + path: './df/devup-ui/devup-ui.css', + importer: path, + namespace: 'file', + resolveDir: f.root, + kind: 'import-statement', + }), + ).toEqual({ path: 'devup-ui.css', namespace: cssNamespace }) + } finally { + await f.cleanup() + } }) -describe('plugin preload coverage', () => { - // The plugin is preloaded via bunfig.toml, which covers the main execution paths - // These tests verify the plugin's behavior through its side effects - - it('should have called setDebug during initialization', () => { - // Plugin calls setDebug(true) in setup - // This is covered by the preload - expect(setDebugSpy).toBeDefined() - }) - - it('should have registered theme during initialization', () => { - // Plugin calls registerTheme during writeDataFiles - // This is covered by the preload - expect(registerThemeSpy).toBeDefined() - }) +it('passes ordinary modules through the bundler source callback', async () => { + const f = fixture() + const path = f.write('plain.mts', 'export const value = 1') + try { + await f.setup({}, { entrypoints: [path], plugins: [] }) + expect(await f.load(path)).toEqual({ + contents: 'export const value = 1', + loader: 'ts', + }) + } finally { + await f.cleanup() + } +}) - it('should resolve devup-ui css path', async () => { - // This import triggers the onResolve callback for CSS files +it.each(['{', '{"extends":["./devup.json"]}'])( + 'rejects malformed configuration %s instead of silently registering an empty theme', + async (config) => { + const f = fixture() + f.write('devup.json', config) try { - await import('df/devup-ui/devup-ui.css') - } catch { - // File may not exist, but the resolver still runs + await expect(f.setup()).rejects.toThrow(join(f.root, 'devup.json')) + } finally { + await f.cleanup() } - expect(true).toBe(true) - }) + }, +) + +it('propagates source read and extraction errors', async () => { + const f = fixture() + const path = f.write( + 'bad.ts', + "import { css } from '@devup-ui/react'; export const cls = css({ width: unknownWidth })", + ) + try { + await f.setup({}, { entrypoints: [path], plugins: [] }) + await expect(f.load(join(f.root, 'missing.ts'))).rejects.toThrow('ENOENT') + await expect(f.load(path)).rejects.toThrow('bad.ts:') + } finally { + await f.cleanup() + } }) diff --git a/packages/bun-plugin/src/__tests__/source.test.ts b/packages/bun-plugin/src/__tests__/source.test.ts new file mode 100644 index 000000000..ef0c8a3c1 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/source.test.ts @@ -0,0 +1,105 @@ +import { join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { + importsCompiledPackage, + mentionsCompiledPackage, + preserveDependencies, + runtimeSourceFilter, + sourceLoader, +} from '../source' + +it.each(['ts', 'mts', 'cts', 'tsx', 'js', 'mjs', 'cjs', 'jsx'])( + 'chooses the Bun loader when the source extension is %s', + (extension) => { + const filename = `source.${extension.toUpperCase()}` + const expected = extension.endsWith('tsx') + ? 'tsx' + : extension.endsWith('ts') + ? 'ts' + : extension === 'jsx' + ? 'jsx' + : 'js' + const loader = sourceLoader(filename) + expect(loader).toBe(expected) + }, +) + +it.each(['@devup-ui/react', '@emotion/react/jsx-runtime', '@stylexjs/stylex'])( + 'recognizes a real import when its package is %s', + (name) => { + const source = `import * as styles from '${name}'; export { styles }` + const found = importsCompiledPackage(source, 'js') + expect(found).toBe(true) + }, +) + +it.each(['js', 'jsx', 'ts', 'tsx'] as const)( + 'ignores text-only package mentions when using %s', + (loader) => { + const source = '// @devup-ui/react\nexport const value = 1' + const found = importsCompiledPackage(source, loader) + expect(found).toBe(false) + }, +) + +it('leaves syntax errors to Bun when the scanner rejects source', () => { + const found = importsCompiledPackage('import {', 'ts') + expect(found).toBe(false) +}) + +it('propagates unexpected scanner failures rather than treating them as syntax errors', () => { + const failure = Symbol('scanner failure') + const scanner = spyOn( + Bun.Transpiler.prototype, + 'scanImports', + ).mockImplementation(() => { + throw failure + }) + let received: unknown + try { + importsCompiledPackage('export const value = 1', 'ts') + } catch (cause) { + if (cause !== failure) throw cause + received = cause + } finally { + scanner.mockRestore() + } + expect(received).toBe(failure) +}) + +it('distinguishes styling MDX when a compiler is missing', () => { + const results = ['# hello', "import { Box } from '@devup-ui/react'"].map( + mentionsCompiledPackage, + ) + expect(results).toEqual([false, true]) +}) + +it('preserves unique dependency edges when extraction erased the imports', () => { + const filename = join(process.cwd(), 'src', 'entry.ts') + const dependency = join(process.cwd(), 'tokens.ts') + const code = preserveDependencies('export const cls = "a"', filename, [ + dependency, + dependency, + join(process.cwd(), 'src', 'local.ts'), + ]) + expect( + new Bun.Transpiler({ loader: 'ts' }) + .scanImports(code) + .map(({ path }) => path), + ).toEqual(['../tokens.ts', './local.ts']) +}) + +it('retains source when no dependencies were extracted', () => { + const code = preserveDependencies('export const value = 1', 'entry.ts', []) + expect(code).toBe('export const value = 1') +}) + +it('claims only candidate files when building a runtime filter', () => { + const filter = runtimeSourceFilter(['C:\\source\\foo.bar.ts']) + expect(filter.test('C:\\source\\foo.bar.ts')).toBe(true) + expect(filter.test('C:/source/foo.bar.ts')).toBe(true) + expect(filter.test('C:/source/fooXbar.ts')).toBe(false) + expect(runtimeSourceFilter([]).test('anything.ts')).toBe(false) +}) diff --git a/packages/bun-plugin/src/devup-plugin.ts b/packages/bun-plugin/src/devup-plugin.ts new file mode 100644 index 000000000..02dd62485 --- /dev/null +++ b/packages/bun-plugin/src/devup-plugin.ts @@ -0,0 +1,274 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' + +import { + beginBuild, + collectNumberedFiles, + createCompatTypes, + createModuleResolver, + createThemeInterfaceArgs, + type CustomShorthands, + isMdxSource, + listSourceFiles, + loadDevupConfig, + mdxSourceFilter, + normalizeMdxExtensions, + type PreparedSource, + resolveProjectPaths, + resolveSourceDirs, + seedFileNumbers, + selectedSourceFilter, +} from '@devup-ui/plugin-utils' +import { + getCss, + getThemeInterface, + registerShorthands, + registerTheme, + resetBuildState, + seedFileMap, + setDebug, + setModuleResolver, + setPrefix, +} from '@devup-ui/wasm' +import { type BunPlugin, type PluginBuilder } from 'bun' + +import { cssNamespace, resolveCssId } from './css-id' +import { createMdxOwnership } from './mdx-ownership' +import { + compiledPackages, + importAliases, + libPackage, + mentionsCompiledPackage, + runtimeSourceFilter, +} from './source' +import { loadSourceFile, type SourceProject } from './source-load' + +type PluginHooks = { + readonly config?: PluginBuilder['config'] + readonly onEnd?: (...args: Parameters) => void + readonly onLoad: (...args: Parameters) => void + readonly onResolve: (...args: Parameters) => void +} + +export interface DevupUIBunPluginOptions { + shorthands?: CustomShorthands + /** Resolved against Bun.build's root, or cwd in the runtime. */ + root?: string + devupFile?: string + distDir?: string + sourceDirs?: string | string[] + /** Uncompiled dependencies to transform under the runtime. */ + include?: string[] + /** Readable class names. Defaults to false in both runtime and builds. */ + debug?: boolean + mdxExtensions?: readonly string[] +} + +type Project = ReturnType & SourceProject + +async function writeDataFiles({ devupFile, distDir, cssDir }: Project) { + const config = await loadDevupConfig(devupFile) + registerTheme(config.theme ?? {}) + + // Generate theme interface after registration (always write, even if empty) + await writeFile( + join(distDir, 'theme.d.ts'), + getThemeInterface(...createThemeInterfaceArgs(libPackage)), + 'utf-8', + ) + + if (!existsSync(cssDir)) { + await mkdir(cssDir, { recursive: true }) + } + await writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') +} + +async function initialize(project: Project, options: DevupUIBunPluginOptions) { + const { root, distDir } = project + registerShorthands(options.shorthands ?? {}) + setPrefix(null) + setModuleResolver(project.resolver) + // Number every source file in path order, so class prefixes do not depend + // on the order Bun loads files in + try { + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: resolveSourceDirs(root, options.sourceDirs), + includeMdx: project.mdxExtensions, + cwd: root, + include: options.include, + needles: compiledPackages, + toId: (path) => path, + }), + ) + } catch (cause) { + if (!(cause instanceof Error)) throw cause + console.warn('[devup-ui] File numbering fallback', { + root, + phase: 'seed', + impact: 'arrival-order file IDs', + cause, + }) + } + if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) + await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') + await writeFile( + join(distDir, 'compat.d.ts'), + createCompatTypes(importAliases), + 'utf-8', + ) + await writeDataFiles(project) +} + +/** + * The Devup UI plugin, for `Bun.build` (`plugins: [DevupUI()]`) as well as the + * Bun runtime ({@link register}). + */ +export function DevupUI(options: DevupUIBunPluginOptions = {}) { + return { + name: 'devup-ui', + + async setup(build: PluginHooks) { + // `Bun.build` hands its config to plugins; the runtime has none + const bundling = build.config !== undefined + const root = resolve(options.root ?? build.config?.root ?? process.cwd()) + const targetConditions = { + browser: ['browser'], + bun: ['bun', 'node'], + node: ['node'], + } as const + const customConditions = + typeof build.config?.conditions === 'string' + ? [build.config.conditions] + : (build.config?.conditions ?? []) + const mdxExtensions = normalizeMdxExtensions(options.mdxExtensions) + const conditions = [ + ...targetConditions[ + build.config?.target ?? (bundling ? 'browser' : 'bun') + ], + 'import', + ...(bundling ? ['module'] : []), + ...customConditions, + ] + const prepared = new Map() + const ownership = createMdxOwnership({ + root, + extensions: mdxExtensions, + conditions, + entries: build.config?.entrypoints ?? [], + }) + const project: Project = { + ...resolveProjectPaths(root, options), + root, + debug: options.debug ?? false, + mdxExtensions, + prepared, + ownership, + resolver: createModuleResolver({ + cwd: root, + // Extracted ESM imports stay import requests even in the vanilla + // CommonJS evaluator. The callback does not expose a request kind. + conditions, + includeMdx: mdxExtensions, + prepareSource: (filename) => prepared.get(filename), + }), + } + // A build starts from its own options, not from what an earlier build in + // this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + build.onEnd?.((result) => { + try { + if (result.success) ownership.validate() + } finally { + endBuild() + } + }) + await initialize(project, options) + setDebug(project.debug) + let writtenCss = getCss(null, false) + // Native token modules must keep Bun's filesystem watch/CJS loading path. + const sourceFilter = bundling + ? selectedSourceFilter(mdxExtensions) + : new RegExp( + `${ + runtimeSourceFilter([ + ...listSourceFiles( + root, + [ + 'target', + 'dist', + basename(project.distDir), + '.git', + 'coverage', + ], + { includeMdx: mdxExtensions }, + ).filter( + (file) => + isMdxSource(file, mdxExtensions) || + mentionsCompiledPackage(readFileSync(file, 'utf-8')), + ), + ...collectNumberedFiles({ + roots: resolveSourceDirs(root, options.sourceDirs), + includeMdx: mdxExtensions, + cwd: root, + include: ['@devup-ui/components', ...(options.include ?? [])], + needles: compiledPackages, + toId: (path) => path, + }), + ]).source + }|${mdxSourceFilter(mdxExtensions).source}|\\.(?:test|spec)\\.[mc]?[jt]sx?$`, + 'i', + ) + + // Resolve devup-ui CSS files onto a path-free virtual id, so nothing + // derived from this checkout's cwd can be baked into Bun's shared, + // content-keyed transpiler cache. See ./css-id. + build.onResolve( + { filter: /devup-ui(-\d+)?\.css$/ }, + ({ path, importer }) => + resolveCssId(path, importer, basename(project.distDir)), + ) + + // The bundler takes the stylesheet once every other module is loaded, + // so it holds the styles of all of them. The Bun runtime has no CSS + // loader (`onLoad` only accepts the script/data loaders), so there the + // injected import resolves to an empty module. + build.onLoad( + { filter: /.*/, namespace: cssNamespace }, + async ({ defer }) => { + if (!bundling) return { contents: '', loader: 'js' } + await defer() + ownership.validate() + return { contents: getCss(null, false), loader: 'css' } + }, + ) + + // Load source files from packages directory (file namespace) + build.onLoad( + { + filter: sourceFilter, + }, + ({ path }) => { + return loadSourceFile(path, project).then((result) => { + if (isMdxSource(path, mdxExtensions)) ownership.loaded(path) + if (!bundling && result) { + const css = getCss(null, false) + // Read-after-import is synchronous; only identical revisions coalesce. + if (css !== writtenCss) { + writeFileSync( + join(project.cssDir, 'devup-ui.css'), + css, + 'utf-8', + ) + writtenCss = css + } + } + return result + }) + }, + ) + }, + } satisfies BunPlugin +} diff --git a/packages/bun-plugin/src/mdx-ownership.ts b/packages/bun-plugin/src/mdx-ownership.ts new file mode 100644 index 000000000..bfcf0e84d --- /dev/null +++ b/packages/bun-plugin/src/mdx-ownership.ts @@ -0,0 +1,67 @@ +import { extname, resolve } from 'node:path' + +import { + createModuleResolver, + isMdxSource, + scanImports, +} from '@devup-ui/plugin-utils' + +function key(filename: string): string { + const path = resolve(filename).replaceAll('\\', '/') + return process.platform === 'win32' ? path.toLowerCase() : path +} + +export class MdxOwnershipError extends Error { + readonly name = 'MdxOwnershipError' + constructor( + readonly importer: string, + readonly target: string, + ) { + super( + `${importer}:1:1: owned MDX module '${target}' under extension '${extname(target)}' was loaded outside Devup UI; let Devup compile its extension, or remove that extension from mdxExtensions`, + ) + } +} + +export function createMdxOwnership(options: { + readonly root: string + readonly extensions: readonly string[] + readonly conditions: readonly string[] + readonly entries: readonly string[] +}) { + const resolver = createModuleResolver({ + cwd: options.root, + conditions: options.conditions, + includeMdx: options.extensions, + prepareSource: () => '', + }) + const reached = new Map< + string, + { readonly importer: string; readonly target: string } + >() + const successful = new Set() + function reach(target: string, importer: string) { + if (isMdxSource(target, options.extensions) && !reached.has(key(target))) + reached.set(key(target), { target, importer }) + } + for (const entry of options.entries) { + const target = resolve(options.root, entry) + reach(target, target) + } + return { + observe(filename: string, contents: string, typescript: boolean) { + for (const reference of scanImports(contents, true, typescript)) { + const target = resolver(reference.specifier, filename) + if (target?.path !== undefined) reach(target.path, filename) + } + }, + loaded(filename: string) { + successful.add(key(filename)) + }, + validate() { + for (const [path, value] of reached) + if (!successful.has(path)) + throw new MdxOwnershipError(value.importer, value.target) + }, + } +} diff --git a/packages/bun-plugin/src/mdx.ts b/packages/bun-plugin/src/mdx.ts new file mode 100644 index 000000000..015f643e6 --- /dev/null +++ b/packages/bun-plugin/src/mdx.ts @@ -0,0 +1,64 @@ +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { mentionsCompiledPackage } from './source' + +type CompiledMdx = { readonly value: string; readonly map?: unknown } +type Compiler = { + readonly compile: ( + input: { readonly value: string; readonly path: string }, + options: { readonly jsx: true; readonly format: 'mdx' }, + ) => Promise +} + +export class MdxCompilerError extends Error { + constructor(filename: string, cause: unknown) { + super( + `${filename}:1:1: MDX using Devup UI requires @mdx-js/mdx; install @mdx-js/mdx in the project`, + { cause }, + ) + this.name = 'MdxCompilerError' + } +} + +function isCompiler(value: unknown): value is Compiler { + return ( + typeof value === 'object' && + value !== null && + 'compile' in value && + typeof value.compile === 'function' + ) +} + +export async function compileMdx( + root: string, + filename: string, + contents: string, +): Promise { + const require = createRequire(join(root, 'package.json')) + let compilerPath: string + try { + compilerPath = require.resolve('@mdx-js/mdx') + } catch (cause) { + if ( + !(cause instanceof Error) || + !('code' in cause) || + cause.code !== 'MODULE_NOT_FOUND' + ) + throw cause + if (mentionsCompiledPackage(contents)) + throw new MdxCompilerError(filename, cause) + return undefined + } + // Host resolution is deliberate: the optional project compiler is not bundled. + const compiler: unknown = await import(pathToFileURL(compilerPath).href) + if (!isCompiler(compiler)) + throw new TypeError( + `${filename}:1:1: @mdx-js/mdx does not export compile()`, + ) + return compiler.compile( + { value: contents, path: filename }, + { jsx: true, format: 'mdx' }, + ) +} diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 52591c9ee..dec7ace58 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -1,151 +1,11 @@ -import { existsSync, writeFileSync } from 'node:fs' -import { mkdir, writeFile } from 'node:fs/promises' -import { dirname, join, relative, resolve } from 'node:path' - -import { - createCompatTypes, - createModuleResolver, - createThemeInterfaceArgs, - type CustomShorthands, - loadDevupConfig, - mergeImportAliases, -} from '@devup-ui/plugin-utils' -import { - codeExtract, - getCss, - getThemeInterface, - hasDevupUI, - registerShorthands, - registerTheme, - setDebug, - setModuleResolver, -} from '@devup-ui/wasm' import { plugin } from 'bun' -import { cssDirName, cssNamespace, resolveCssId } from './css-id' - -const libPackage = '@devup-ui/react' -const devupFile = 'devup.json' -const distDir = 'df' -const cssDir = resolve(distDir, cssDirName) -const singleCss = true -const importAliases = mergeImportAliases() - -export interface DevupUIBunPluginOptions { - shorthands?: CustomShorthands -} - -async function writeDataFiles() { - let theme = {} - try { - const config = await loadDevupConfig(devupFile) - theme = config.theme ?? {} - } catch { - // Error reading devup.json, use empty theme - } - registerTheme(theme) - - // Generate theme interface after registration (always write, even if empty) - await writeFile( - join(distDir, 'theme.d.ts'), - getThemeInterface(...createThemeInterfaceArgs(libPackage)), - 'utf-8', - ) - - if (!existsSync(cssDir)) { - await mkdir(cssDir, { recursive: true }) - } - await writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') -} - -async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { - registerShorthands(shorthands ?? {}) - setModuleResolver(createModuleResolver()) - if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) - await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') - await writeFile( - join(distDir, 'compat.d.ts'), - createCompatTypes(importAliases), - 'utf-8', - ) - await writeDataFiles() -} +import { DevupUI, type DevupUIBunPluginOptions } from './devup-plugin' -// Devup UI is a preprocessor: the stylesheet is a build artifact consumed by a -// bundler, and Bun's runtime has no CSS loader (`onLoad` only accepts the -// script/data loaders). The injected import exists so bundlers pick the -// stylesheet up, so under the Bun runtime it resolves to an empty module. -function loadCssModule() { - return { contents: '', loader: 'js' as const } -} - -async function loadSourceFile(filePath: string) { - const loader: 'tsx' | 'ts' | 'jsx' | 'js' = filePath.endsWith('.tsx') - ? 'tsx' - : filePath.endsWith('.ts') - ? 'ts' - : filePath.endsWith('.jsx') - ? 'jsx' - : 'js' - const contents = await Bun.file(filePath).text() - - if (hasDevupUI(filePath, contents, libPackage)) { - const code = codeExtract( - filePath, - contents, - libPackage, - relative(dirname(filePath), cssDir).replaceAll('\\', '/'), - singleCss, - true, - false, - importAliases, - ) - // singleCss stores every extracted style in the base sheet. Finish the - // write before returning the injected import; synchronous writes also keep - // concurrent source loads from overwriting a newer sheet with an older one. - writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') - return { contents: code.code, loader } - } - return { contents, loader } -} - -// Registers the Bun plugin. Returns the promise produced by `plugin()` (its -// `setup` is async), so callers MUST `await` it. Bun's preload mechanism waits -// for an awaited module evaluation to settle; awaiting this guarantees the -// `onLoad` hook is installed before any source file is loaded. Without the -// await, preload-driven `bun test` users race the async setup and load sources -// against the @devup-ui/react runtime stubs (throwing "Cannot run on the -// runtime"). +// Await async setup before runtime imports, including Bun preload evaluation. function register(options: DevupUIBunPluginOptions = {}) { - return plugin({ - name: 'devup-ui', - - async setup(build) { - await initialize(options) - setDebug(true) - - // Resolve devup-ui CSS files onto a path-free virtual id, so nothing - // derived from this checkout's cwd can be baked into Bun's shared, - // content-keyed transpiler cache. See ./css-id. - build.onResolve( - { filter: /devup-ui(-\d+)?\.css$/ }, - ({ path, importer }) => resolveCssId(path, importer, distDir), - ) - - // Serve the virtual stylesheet resolved above - build.onLoad({ filter: /.*/, namespace: cssNamespace }, () => - loadCssModule(), - ) - - // Load source files from packages directory (file namespace) - build.onLoad( - { - filter: /\.(?:tsx?|jsx|mjs)$|[\\/]@devup-ui[\\/].*\.js$/, - }, - ({ path }) => loadSourceFile(path), - ) - }, - }) + return plugin(DevupUI(options)) } -export { plugin, register } +export type { DevupUIBunPluginOptions } from './devup-plugin' +export { DevupUI, plugin, register } diff --git a/packages/bun-plugin/src/register.ts b/packages/bun-plugin/src/register.ts index eb32ee74c..3f4240ff6 100644 --- a/packages/bun-plugin/src/register.ts +++ b/packages/bun-plugin/src/register.ts @@ -1,2 +1,2 @@ export type { DevupUIBunPluginOptions } from './plugin' -export { register } from './plugin' +export { DevupUI, register } from './plugin' diff --git a/packages/bun-plugin/src/source-load.ts b/packages/bun-plugin/src/source-load.ts new file mode 100644 index 000000000..7773121cc --- /dev/null +++ b/packages/bun-plugin/src/source-load.ts @@ -0,0 +1,77 @@ +import { dirname, relative } from 'node:path' + +import { + type createModuleResolver, + isMdxSource, + type PreparedSource, + remapMdxError, +} from '@devup-ui/plugin-utils' +import { codeExtract, setDebug, setModuleResolver } from '@devup-ui/wasm' + +import { compileMdx, MdxCompilerError } from './mdx' +import type { createMdxOwnership } from './mdx-ownership' +import { + importAliases, + importsCompiledPackage, + libPackage, + preserveDependencies, + sourceLoader, +} from './source' + +export interface SourceProject { + readonly root: string + readonly cssDir: string + readonly debug: boolean + readonly resolver: ReturnType + readonly mdxExtensions: readonly string[] + readonly prepared: Map + readonly ownership: ReturnType +} + +export async function loadSourceFile(filePath: string, project: SourceProject) { + const original = await Bun.file(filePath).text() + const selectedMdx = isMdxSource(filePath, project.mdxExtensions) + const mdx = selectedMdx + ? await compileMdx(project.root, filePath, original) + : undefined + if (selectedMdx && !mdx) throw new MdxCompilerError(filePath, undefined) + const loader = mdx ? 'jsx' : sourceLoader(filePath) + const contents = mdx?.value ?? original + if (mdx) + project.prepared.set(filePath, { + code: contents, + map: mdx.map, + sourceType: 'compiled-mdx', + }) + project.ownership.observe( + filePath, + contents, + !mdx && ['ts', 'tsx'].includes(loader), + ) + if (importsCompiledPackage(contents, loader)) { + setDebug(project.debug) + setModuleResolver(project.resolver) + try { + const code = codeExtract( + filePath, + contents, + libPackage, + relative(dirname(filePath), project.cssDir).replaceAll('\\', '/'), + true, + true, + false, + importAliases, + ...(mdx ? (['compiled-mdx'] as const) : ([] as const)), + ) + return { + contents: preserveDependencies(code.code, filePath, code.dependencies), + loader, + } + } catch (cause) { + const error = project.resolver.remapError(cause) + if (mdx) throw remapMdxError(error, filePath, mdx.map) + throw error + } + } + return { contents, loader } +} diff --git a/packages/bun-plugin/src/source.ts b/packages/bun-plugin/src/source.ts new file mode 100644 index 000000000..caf35e7a5 --- /dev/null +++ b/packages/bun-plugin/src/source.ts @@ -0,0 +1,69 @@ +import { dirname, relative } from 'node:path' + +import { mergeImportAliases } from '@devup-ui/plugin-utils' + +export type SourceLoader = 'tsx' | 'ts' | 'jsx' | 'js' +export const importAliases = mergeImportAliases() +export const libPackage = '@devup-ui/react' +export const compiledPackages = [ + libPackage, + '@stylexjs/stylex', + ...Object.keys(importAliases), +] +const scanners = new Map() + +export function sourceLoader(filename: string): SourceLoader { + if (/\.tsx$/i.test(filename)) return 'tsx' + if (/\.[mc]?ts$/i.test(filename)) return 'ts' + if (/\.jsx$/i.test(filename)) return 'jsx' + return 'js' +} + +export function mentionsCompiledPackage(contents: string): boolean { + return compiledPackages.some((name) => contents.includes(name)) +} + +/** The scanner avoids extracting package names that only appear in comments. */ +export function importsCompiledPackage( + contents: string, + loader: SourceLoader, +): boolean { + let scanner = scanners.get(loader) + if (!scanner) { + scanner = new Bun.Transpiler({ loader }) + scanners.set(loader, scanner) + } + try { + return scanner + .scanImports(contents) + .some(({ path }) => + compiledPackages.some( + (name) => path === name || path.startsWith(`${name}/`), + ), + ) + } catch (cause) { + // Leave invalid source to Bun's own located syntax diagnostic. + if (cause instanceof Error) return false + throw cause + } +} + +/** Keep erased build-time imports in Bun's module graph, including watch mode. */ +export function preserveDependencies( + code: string, + filename: string, + dependencies: readonly string[], +): string { + const imports = [...new Set(dependencies)].map((dependency) => { + const path = relative(dirname(filename), dependency).replaceAll('\\', '/') + return `import ${JSON.stringify(path.startsWith('.') ? path : `./${path}`)};` + }) + return [...imports, code].join('\n') +} + +export function runtimeSourceFilter(files: readonly string[]): RegExp { + const pattern = files.length + ? `^(?:${files.map((file) => file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('\\\\', '[\\\\/]')).join('|')})$` + : '$^' + return new RegExp(pattern) +} diff --git a/packages/next-plugin/README.md b/packages/next-plugin/README.md index facbc7247..c4cad81d8 100644 --- a/packages/next-plugin/README.md +++ b/packages/next-plugin/README.md @@ -205,6 +205,26 @@ custom `distDir`) to `include`. ``` +## Turbopack: deterministic names and isolated sessions + +Before Turbopack can request a stylesheet, the plugin extracts the files the +app's routes compile, in path order, in development and in production. Class +names and file numbers therefore follow the paths, not the order Turbopack +schedules its loaders in. In development, files that appear later are numbered +after the existing ones, and the names of the last session are kept across +restarts. + +Files nothing compiles are not extracted (they add no CSS and cannot fail the +build). Set `prewarmAll: true` to extract the whole source tree instead, for +sources the import graph cannot connect. + +Every `DevupUI()` call captures the project root and applies every option +explicitly, so two apps in one process (or two concurrent `next dev` / +`next build` runs) never share an engine, theme, endpoint or state. Each +process owns `/.devup//sessions/-/`; the +development checkpoint `/.devup//snapshot.json` is replaced +atomically. + ## Turbopack build profiling Set `DEVUP_UI_PROFILE=1` for an opt-in, structured timing log during a diff --git a/packages/next-plugin/package.json b/packages/next-plugin/package.json index fdf7051cd..4356dc114 100644 --- a/packages/next-plugin/package.json +++ b/packages/next-plugin/package.json @@ -22,7 +22,7 @@ "version": "1.0.90", "scripts": { "lint": "eslint", - "build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external && bun build --target node src/loader.ts --production --env=disable --outfile dist/loader.cjs --format cjs --packages external && bun build --target node src/loader.ts --production --env=disable --outfile dist/loader.mjs --format esm --packages external && bun build --target node src/css-loader.ts --production --env=disable --outfile dist/css-loader.cjs --format cjs --packages external && bun build --target node src/css-loader.ts --production --env=disable --outfile dist/css-loader.mjs --format esm --packages external" + "build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external && bun build --target node src/loader.ts --production --env=disable --outfile dist/loader.cjs --format cjs --packages external && bun build --target node src/loader.ts --production --env=disable --outfile dist/loader.mjs --format esm --packages external && bun build --target node src/css-loader.ts --production --env=disable --outfile dist/css-loader.cjs --format cjs --packages external && bun build --target node src/css-loader.ts --production --env=disable --outfile dist/css-loader.mjs --format esm --packages external && bun build --target node src/final-config-adapter-entry.ts --production --env=disable --outfile dist/final-config-adapter-entry.cjs --format cjs --packages external && bun build --target node src/final-config-adapter-entry.ts --production --env=disable --outfile dist/final-config-adapter-entry.mjs --format esm --packages external && bun build --target node src/mdx-prewarm-loader.ts --production --env=disable --outfile dist/mdx-prewarm-loader.js --format esm --packages external" }, "publishConfig": { "access": "public" @@ -44,6 +44,15 @@ "types": "./dist/loader.d.ts", "import": "./dist/loader.mjs", "require": "./dist/loader.cjs" + }, + "./final-config-adapter-entry": { + "types": "./dist/final-config-adapter-entry.d.ts", + "import": "./dist/final-config-adapter-entry.mjs", + "require": "./dist/final-config-adapter-entry.cjs" + }, + "./mdx-prewarm-loader": { + "types": "./dist/mdx-prewarm-loader.d.ts", + "import": "./dist/mdx-prewarm-loader.js" } }, "files": [ diff --git a/packages/next-plugin/src/__tests__/adapter-capability.test.ts b/packages/next-plugin/src/__tests__/adapter-capability.test.ts new file mode 100644 index 000000000..02e3424f7 --- /dev/null +++ b/packages/next-plugin/src/__tests__/adapter-capability.test.ts @@ -0,0 +1,107 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' + +import { describe, expect, it } from 'bun:test' + +import { + detectFinalConfigAdapter, + supportsFinalConfigAdapter, +} from '../adapter-capability' +import { adapterFixture } from './adapter-fixture' + +const landing = resolve(import.meta.dir, '../../../../apps/landing') +const require = createRequire(join(landing, 'package.json')) +const schema: unknown = require('next/dist/server/config-schema') +const source = readFileSync(require.resolve('next/dist/server/config'), 'utf8') + +describe('Next final-config capability', () => { + it('admits the actual installed schema and awaited lifecycle', () => { + // Given / When / Then: the project's installed Next, not a version fixture. + expect(detectFinalConfigAdapter(landing)).toBe(true) + }) + + it.each([ + null, + {}, + { configSchema: null }, + { configSchema: {} }, + { configSchema: { safeParse: () => null } }, + { configSchema: { safeParse: () => ({ success: false }) } }, + { configSchema: { safeParse: () => ({ success: true, data: {} }) } }, + { + configSchema: { + safeParse: () => ({ success: true, data: { adapterPath: 1 } }), + }, + }, + { + configSchema: { + safeParse: () => ({ + success: true, + data: { experimental: { adapterPath: 'probe' } }, + }), + }, + }, + ])('rejects old/partial schema %j', (partial) => { + // Given: absent top-level admission. When / Then: reject even with modern hook code. + expect(supportsFinalConfigAdapter(partial, source)).toBe(false) + }) + + it.each([ + '', + source.replace( + 'async function applyModifyConfig', + 'function applyModifyConfig', + ), + source.replace( + 'if (config.adapterPath)', + 'if (config.experimental.adapterPath)', + ), + source.replace( + "typeof adapterMod.modifyConfig === 'function'", + "typeof adapterMod.modifyConfig === 'object'", + ), + source.replace( + 'config = await adapterMod.modifyConfig', + 'config = adapterMod.modifyConfig', + ), + source.replaceAll('return config;', 'return {};'), + source.replaceAll( + 'finalizeConfig(await applyModifyConfig(', + 'finalizeConfig(applyModifyConfig(', + ), + `// ${source.replaceAll('\n', '\n// ')}`, + ])('rejects partial lifecycle %#', (partial) => { + // Given: schema admission alone. When / Then: require awaited hook and finalization. + expect(supportsFinalConfigAdapter(schema, partial)).toBe(false) + }) + + it('falls back when Next is not installed at the captured project', () => { + const fixture = adapterFixture() + try { + expect(detectFinalConfigAdapter(fixture.session.projectDir)).toBe(false) + } finally { + fixture.close() + } + }) + + it('does not hide a broken installed schema module', () => { + const fixture = adapterFixture() + try { + const server = join( + fixture.session.projectDir, + 'node_modules/next/dist/server', + ) + mkdirSync(server, { recursive: true }) + writeFileSync( + join(server, 'config-schema.js'), + 'throw new SyntaxError("broken schema")', + ) + expect(() => + detectFinalConfigAdapter(fixture.session.projectDir), + ).toThrow(SyntaxError) + } finally { + fixture.close() + } + }) +}) diff --git a/packages/next-plugin/src/__tests__/adapter-fixture.ts b/packages/next-plugin/src/__tests__/adapter-fixture.ts new file mode 100644 index 000000000..4b711fc00 --- /dev/null +++ b/packages/next-plugin/src/__tests__/adapter-fixture.ts @@ -0,0 +1,54 @@ +import { randomUUID } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import type { NextConfig } from 'next' + +import type { FinalConfigFinalizer } from '../final-config-adapter' +import { installFinalConfigAdapter } from '../final-config-adapter' +import { createFinalConfigAdapter } from '../final-config-adapter-entry' + +export function adapterFixture() { + const projectDir = mkdtempSync(join(tmpdir(), 'devup-adapter-')) + const token = randomUUID() + writeFileSync(join(projectDir, 'shipped-entry.cjs'), 'module.exports = {};\n') + const session = { + token, + projectDir, + sessionDir: projectDir, + configFile: join(projectDir, 'next.config.mjs'), + } + const context = { + phase: 'phase-production-build', + nextVersion: 'fixture', + projectDir, + } + const releases: (() => void)[] = [] + return { + session, + context, + caller: (source: string, extension = 'cjs') => { + const path = join(projectDir, `caller-${randomUUID()}.${extension}`) + writeFileSync(path, source) + return path + }, + install: ( + config: NextConfig, + finalize: FinalConfigFinalizer, + timeoutMs = 45000, + ) => { + const installed = installFinalConfigAdapter(config, session, { + entryPath: join(projectDir, 'shipped-entry.cjs'), + finalize, + timeoutMs, + }) + releases.push(installed.release) + return { ...installed, adapter: createFinalConfigAdapter(token) } + }, + close: () => { + for (const release of releases) release() + rmSync(projectDir, { recursive: true, force: true }) + }, + } +} diff --git a/packages/next-plugin/src/__tests__/adapter-next-capture.cjs b/packages/next-plugin/src/__tests__/adapter-next-capture.cjs new file mode 100644 index 000000000..72d28f7a3 --- /dev/null +++ b/packages/next-plugin/src/__tests__/adapter-next-capture.cjs @@ -0,0 +1,219 @@ +const assert = require('node:assert/strict') +const { randomUUID } = require('node:crypto') +const { mkdirSync, mkdtempSync, rmSync, writeFileSync } = require('node:fs') +const { createRequire } = require('node:module') +const { join } = require('node:path') + +const [root, artifacts] = process.argv.slice(2) +const landing = createRequire(join(root, 'apps/landing/package.json')) +const loadConfig = landing('next/dist/server/config').default +const { Bundler } = landing('next/dist/lib/bundler') +const isolated = require(join(artifacts, 'installer-reload.cjs')) +const stateKey = Symbol.for('@devup-ui/test/final-adapter-capture') + +async function capture(bundler, order, mode) { + const projectDir = mkdtempSync(join(artifacts, 'project-')) + const state = { + events: [], + releases: [], + callerContext: undefined, + finalizerContext: undefined, + remarkPlugin: () => {}, + gate: Promise.withResolvers(), + entered: Promise.withResolvers(), + } + globalThis[stateKey] = state + const callerPath = join(projectDir, 'caller.cjs') + const replacementPath = join(projectDir, 'replacement.cjs') + const configFile = join(projectDir, 'next.config.js') + const session = { + projectDir, + sessionDir: projectDir, + token: randomUUID(), + configFile, + } + const entryPath = join(artifacts, 'entry.cjs') + const turbo = bundler === Bundler.Turbopack + if (turbo) process.env.TURBOPACK = '1' + else delete process.env.TURBOPACK + mkdirSync(join(projectDir, 'public')) + writeFileSync( + replacementPath, + `module.exports = { onBuildComplete() { globalThis[Symbol.for('@devup-ui/test/final-adapter-capture')].events.push('replacement-complete') } };`, + ) + writeFileSync( + callerPath, + `module.exports = { + async modifyConfig(config, ctx) { + const state = globalThis[Symbol.for('@devup-ui/test/final-adapter-capture')]; + require('node:assert/strict').equal(config.adapterPath, ${JSON.stringify(callerPath)}); + state.events.push('caller-start'); state.callerContext = ctx; + await Promise.resolve(); + state.events.push('caller-end'); + const result = { ...config, assetPrefix: '/caller-returned', env: { preserved: 'caller' } }; + ${mode === 'replace' ? `result.adapterPath = ${JSON.stringify(replacementPath)};` : mode === 'remove' ? 'delete result.adapterPath;' : ''} + return result; + }, + onBuildComplete() { globalThis[Symbol.for('@devup-ui/test/final-adapter-capture')].events.push('caller-complete') } + };`, + ) + writeFileSync( + configFile, + `module.exports = async function(phase) { + const state = globalThis[Symbol.for('@devup-ui/test/final-adapter-capture')]; + const { installFinalConfigAdapter } = require(${JSON.stringify(join(artifacts, 'installer.cjs'))}); + const withMDX = require(${JSON.stringify(landing.resolve('@next/mdx'))})({ + extension: /\\.mdx?$/, options: { remarkPlugins: [state.remarkPlugin], development: false } + }); + await Promise.resolve(); state.events.push('config-return'); + const base = { + adapterPath: ${JSON.stringify(callerPath)}, pageExtensions: ['md', 'mdx', 'tsx'], + compiler: { runAfterProductionCompile: async () => { state.events.push('user-after-compile') } }, + webpack(config) { state.events.push('user-webpack'); return { ...config, retained: 'user-return' } } + }; + const wrap = config => { + const installed = installFinalConfigAdapter(config, ${JSON.stringify(session)}, { + entryPath: ${JSON.stringify(entryPath)}, + async finalize(effective, ctx, signal) { + state.finalizerContext = ctx; state.events.push('finalizer'); + require('node:assert/strict').equal(effective.assetPrefix, '/caller-returned'); + if (${turbo}) { + const rules = effective.turbopack.rules['{*,next-mdx-rule}']; + require('node:assert/strict').equal(rules[0].loaders[0].options.remarkPlugins[0], state.remarkPlugin); + state.entered.resolve(); await state.gate.promise; signal.throwIfAborted(); + } else { + const previous = effective.webpack; + effective = { ...effective, webpack(config, options) { + const result = previous(config, options); + state.events.push('webpack-bind'); + result.beforeCompile = async () => { state.events.push('before-compile'); await state.gate.promise }; + return result; + }}; + } + return effective; + } + }); + state.releases.push(installed.release); return installed.config; + }; + return ${order === 'devup-outer' ? 'wrap(withMDX(base))' : 'withMDX(wrap(base))'}; + };`, + ) + try { + let settled = false + const pending = loadConfig('phase-production-build', projectDir, { + bundler, + }) + void pending.then( + () => { + settled = true + }, + () => { + settled = true + }, + ) + if (turbo) { + await Promise.race([state.entered.promise, pending]) + assert.equal(settled, false) + state.gate.resolve() + } + const config = await pending + assert.equal(state.finalizerContext, state.callerContext) + assert.deepEqual(Object.keys(state.callerContext).sort(), [ + 'nextVersion', + 'phase', + 'projectDir', + ]) + assert.equal(state.callerContext.projectDir, projectDir) + assert.equal(state.callerContext.phase, 'phase-production-build') + assert.equal( + state.callerContext.nextVersion, + landing('next/package.json').version, + ) + assert.deepEqual(state.events, [ + 'config-return', + 'caller-start', + 'caller-end', + 'finalizer', + ]) + assert.equal( + config.adapterPath, + mode === 'replace' + ? replacementPath + : mode === 'keep' + ? callerPath + : undefined, + ) + assert.equal(config.env.preserved, 'caller') + if (!turbo) { + const webpack = config.webpack( + { module: { rules: [] }, resolve: { alias: {} } }, + { + defaultLoaders: { + babel: { loader: 'actual-next-babel-placeholder' }, + }, + }, + ) + assert.equal(webpack.retained, 'user-return') + assert.equal( + webpack.module.rules[0].use[1].options.remarkPlugins[0], + state.remarkPlugin, + ) + state.gate.resolve() + await webpack.beforeCompile() + assert.deepEqual(state.events.slice(4), [ + 'user-webpack', + 'webpack-bind', + 'before-compile', + ]) + } + await config.compiler.runAfterProductionCompile({ distDir: projectDir }) + assert.equal(state.events.at(-1), 'user-after-compile') + if (config.adapterPath) { + require(config.adapterPath).onBuildComplete() + assert.equal( + state.events.at(-1), + mode === 'replace' ? 'replacement-complete' : 'caller-complete', + ) + } + assert.throws( + () => + isolated.installFinalConfigAdapter({}, session, { + entryPath, + finalize: (value) => value, + }), + /token already owned/, + ) + return { + bundler: turbo ? 'turbo' : 'webpack', + order, + mode, + events: state.events, + restored: config.adapterPath ?? null, + } + } finally { + for (const release of state.releases) release() + delete globalThis[stateKey] + rmSync(projectDir, { recursive: true, force: true }) + } +} + +async function main() { + const captures = [] + for (const bundler of [Bundler.Turbopack, Bundler.Webpack]) { + for (const order of ['devup-outer', 'mdx-outer']) { + for (const mode of ['keep', 'replace', 'remove']) { + captures.push(await capture(bundler, order, mode)) + } + } + } + const output = + JSON.stringify({ next: landing('next/package.json').version, captures }) + + '\n' + writeFileSync(join(artifacts, 'capture.json'), output) + process.stdout.write(output) +} + +main().catch((cause) => { + console.error(cause) + process.exitCode = 1 +}) diff --git a/packages/next-plugin/src/__tests__/adapter-next.test.ts b/packages/next-plugin/src/__tests__/adapter-next.test.ts new file mode 100644 index 000000000..e4f946e11 --- /dev/null +++ b/packages/next-plugin/src/__tests__/adapter-next.test.ts @@ -0,0 +1,50 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +it('captures installed Next finalization for both MDX wrapper orders and bundlers across isolated modules', async () => { + // Given: separate bundles reproduce Next config-module isolation and a shipped CJS entry. + const root = resolve(import.meta.dir, '../../../..') + const artifacts = mkdtempSync(join(tmpdir(), 'devup-adapter-next-')) + try { + for (const [entry, outfile] of [ + ['final-config-adapter.ts', 'installer.cjs'], + ['final-config-adapter.ts', 'installer-reload.cjs'], + ['final-config-adapter-entry.ts', 'entry.cjs'], + ] as const) { + const build = await Bun.build({ + entrypoints: [resolve(import.meta.dir, '..', entry)], + outdir: artifacts, + naming: outfile, + format: 'cjs', + target: 'node', + packages: 'external', + }) + expect(build.success).toBe(true) + } + // When: actual Next loads async config functions, applies the adapter, then finalizes. + const capture = spawnSync( + 'node', + [join(import.meta.dir, 'adapter-next-capture.cjs'), root, artifacts], + { encoding: 'utf8', timeout: 45000 }, + ) + // Then: the native source/config assertions and 12-case matrix all succeeded. + expect(capture.stderr).toBe('') + expect(capture.status).toBe(0) + const result: unknown = JSON.parse(capture.stdout) + if ( + typeof result !== 'object' || + result === null || + !('captures' in result) || + !Array.isArray(result.captures) + ) { + throw new TypeError('capture must return the tested matrix') + } + expect(result.captures).toHaveLength(12) + } finally { + rmSync(artifacts, { recursive: true, force: true }) + } +}, 60000) diff --git a/packages/next-plugin/src/__tests__/build-error.test.ts b/packages/next-plugin/src/__tests__/build-error.test.ts new file mode 100644 index 000000000..6dc56c555 --- /dev/null +++ b/packages/next-plugin/src/__tests__/build-error.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'bun:test' + +import { locatedError } from '../build-error' + +const fields = { + file: '/app/src/page.tsx', + what: 'devup-ui prewarm', + code: 'prewarm', + needs: 'a readable file', +} + +describe('locatedError', () => { + it('puts the location, the construct and what is needed in the message', () => { + const cause = new Error('boom') + + const error = locatedError({ ...fields, cause }) + + expect(error.message).toBe( + '/app/src/page.tsx:1:1: devup-ui prewarm cannot use `prewarm` at build time: boom; needs a readable file', + ) + expect(error.cause).toBe(cause) + }) + + it('describes a cause that is not an Error', () => { + expect(locatedError({ ...fields, cause: 'plain' }).message).toContain( + 'at build time: plain; needs', + ) + }) + + it.each([ + '/app/src/a.tsx:3:7: Box cannot use `x` at build time: y', + String.raw`C:\app\a.tsx:12:1: Box cannot use ...`, + ])('keeps a cause that already says where it is: %s', (message) => { + const cause = new Error(message) + + expect(locatedError({ ...fields, cause })).toBe(cause) + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-adoption-admission.test.ts b/packages/next-plugin/src/__tests__/coordinator-adoption-admission.test.ts new file mode 100644 index 000000000..52e6c05aa --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-adoption-admission.test.ts @@ -0,0 +1,112 @@ +import { readFileSync } from 'node:fs' + +import { afterEach, expect, it } from 'bun:test' + +import { createCore } from '../coordinator-core' +import { parseExtractRequest } from '../coordinator-http' +import { createInput } from '../coordinator-ledger' +import type { ReplayPreparation } from '../coordinator-options' +import { createTestApp, failure, removeTestApps } from './coordinator-app' +import { + box, + cssQuery, + generation, + prewarmed, +} from './coordinator-prepared-fixture' + +afterEach(removeTestApps) + +it('rejects unmatched initiating bytes before a refreshed generation is durably adopted', async () => { + // Given a refreshed disk-first generation but an initiating request with different bytes. + const app = createTestApp() + const input = createInput( + app.root, + parseExtractRequest(app.post('provider.tsx', box('green'))), + [], + ) + const next = { ...generation([]), ordinaryInputs: [input] } + const prepared = prewarmed(app, { + generation: { ...generation([]), ordinaryInputs: [] }, + prepareReplay: async ({ generation: current }) => + current.ordinaryInputs?.length ? current : next, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + const before = readFileSync(prepared.options.stateFile ?? '', 'utf8') + try { + // When unproved initiating bytes request a different ordinary replacement. + const error = await failure( + core.extract(parseExtractRequest(app.post('provider.tsx', box('red')))), + ) + // Then refresh is not committed as an independent partial transaction. + expect(error).toBeInstanceOf(Error) + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe(before) + expect((await core.css(cssQuery)).css).toContain('background:green') + } finally { + core.close() + } +}) + +it('discards ordinary extraction failure after allocation without changing the serving shell', async () => { + // Given a complete candidate with a valid earlier file and an invalid later one. + const app = createTestApp() + const inputs = ['a.tsx', 'z.tsx'].map((filename) => + createInput( + app.root, + parseExtractRequest( + app.post( + filename, + filename === 'a.tsx' + ? box('green') + : "import { css } from '@devup-ui/react'; export const x = css(foo())", + ), + ), + [], + ), + ) + const next = { ...generation([]), ordinaryInputs: inputs } + const prepared = prewarmed(app, { + generation: { ...generation([]), ordinaryInputs: [] }, + prepareReplay: async () => next, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + const before = readFileSync(prepared.options.stateFile ?? '', 'utf8') + try { + // When extraction fails after the candidate has allocated earlier styles. + const error = await failure(core.css(cssQuery)) + // Then live allocator and disk remain predecessor-exact. + expect(error).toBeInstanceOf(Error) + expect(prepared.wasm.exportClassMap()).toBe('{}') + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe(before) + } finally { + core.close() + } +}) + +it('retires a request-local extraction observer when preparation settles', async () => { + // Given a provider retaining the observer past its preparation call. + const app = createTestApp() + let observer: ReplayPreparation['observeExtraction'] + const prepared = prewarmed(app, { + generation: generation([]), + prepareReplay: async ({ generation: current, observeExtraction }) => { + observer = observeExtraction + return current + }, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + await core.reconcile() + if (observer === undefined) throw new Error('missing observer fixture') + const expired = observer + try { + // When provider work tries observing outside the owning call. + const run = () => expired([], () => undefined) + // Then the retired call cannot allocate another candidate. + expect(run).toThrow() + expect(prepared.extractions).toEqual([]) + } finally { + core.close() + } +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-adoption-boundaries.test.ts b/packages/next-plugin/src/__tests__/coordinator-adoption-boundaries.test.ts new file mode 100644 index 000000000..9648461ec --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-adoption-boundaries.test.ts @@ -0,0 +1,163 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import * as fsp from 'node:fs/promises' + +import { afterEach, expect, it, spyOn } from 'bun:test' + +import { createCore } from '../coordinator-core' +import { parseExtractRequest } from '../coordinator-http' +import { createInput } from '../coordinator-ledger' +import { CompensationError } from '../coordinator-transaction' +import { createTestApp, failure, removeTestApps } from './coordinator-app' +import { + box, + compiledSource, + cssQuery, + generation, + prewarmed, +} from './coordinator-prepared-fixture' + +afterEach(removeTestApps) + +it('refuses duplicate compiled candidates while retaining the serving predecessor', async () => { + // Given a valid predecessor and two competing compiled replacements. + const app = createTestApp() + const old = compiledSource(app, { + filename: 'provider.tsx', + code: box('red'), + }) + const candidate = compiledSource(app, { + filename: 'provider.tsx', + code: box('green'), + }) + let duplicates = true + const prepared = prewarmed(app, { + generation: { ...generation([old]), ordinaryInputs: [] }, + prepareReplay: async ({ generation: current }) => + duplicates + ? { ...generation([candidate, candidate]), ordinaryInputs: [] } + : current, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + const state = readFileSync(prepared.options.stateFile ?? '', 'utf8') + const revision = readFileSync(prepared.options.revisionFile ?? '', 'utf8') + const allocations = prepared.wasm.exportClassMap() + try { + // When refresh attempts ambiguous compiled ownership. + const error = await failure(core.reconcile()) + duplicates = false + // Then no engine, checkpoint, revision or serving CSS changes. + expect(error).toBeInstanceOf(Error) + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe(state) + expect(readFileSync(prepared.options.revisionFile ?? '', 'utf8')).toBe( + revision, + ) + expect(prepared.wasm.exportClassMap()).toBe(allocations) + expect((await core.css(cssQuery)).css).toContain('background:red') + expect((await core.css(cssQuery)).css).not.toContain('background:green') + } finally { + core.close() + } +}) + +it.each([false, true])( + 'restores checkpoint absence after failed adoption (failed removal: %s)', + async (removalFails) => { + // Given an opted-in owner whose predecessor checkpoint is absent. + const app = createTestApp() + const input = createInput( + app.root, + parseExtractRequest(app.post('provider.tsx', box('green'))), + [], + ) + let candidate = true + const prepared = prewarmed(app, { + generation: { ...generation([]), ordinaryInputs: [] }, + prepareReplay: async ({ generation: current }) => + candidate ? { ...generation([]), ordinaryInputs: [input] } : current, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + const stateFile = prepared.options.stateFile ?? '' + const revisionFile = prepared.options.revisionFile ?? '' + rmSync(stateFile) + const revision = readFileSync(revisionFile, 'utf8') + const rename = fsp.rename + const rm = fsp.rm + const publicationFailure = new Error('controlled revision failure') + const removalFailure = new Error('controlled compensation removal failure') + const renameSpy = spyOn(fsp, 'rename').mockImplementation((from, to) => { + if (to === revisionFile) return Promise.reject(publicationFailure) + return rename(from, to) + }) + const rmSpy = spyOn(fsp, 'rm').mockImplementation((path, options) => { + if (removalFails && path === stateFile) + return Promise.reject(removalFailure) + return rm(path, options) + }) + try { + // When revision publication fails after creating a candidate checkpoint. + const error = await failure(core.css(cssQuery)) + candidate = false + // Then absence is compensated, or failed compensation quarantines the owner. + expect(readFileSync(revisionFile, 'utf8')).toBe(revision) + if (removalFails) { + expect(error).toBeInstanceOf(CompensationError) + if (!(error instanceof CompensationError)) + throw new Error('expected failed compensation') + expect(error.cause).toBeInstanceOf(AggregateError) + if (!(error.cause instanceof AggregateError)) + throw new Error('expected original IO failures') + expect(error.cause.errors).toEqual([publicationFailure, removalFailure]) + expect(await failure(core.css(cssQuery))).toBe(error) + expect(await failure(core.flush())).toBe(error) + } else { + expect(error).toBe(publicationFailure) + expect(existsSync(stateFile)).toBe(false) + expect((await core.css(cssQuery)).css).not.toContain('background:green') + await core.flush() + } + } finally { + rmSpy.mockRestore() + renameSpy.mockRestore() + core.close() + } + }, +) + +it('refuses adoption when the predecessor checkpoint cannot be read', async () => { + // Given an opted-in owner with a directory obstructing its checkpoint. + const app = createTestApp() + const input = createInput( + app.root, + parseExtractRequest(app.post('provider.tsx', box('green'))), + [], + ) + let candidate = true + const prepared = prewarmed(app, { + generation: { ...generation([]), ordinaryInputs: [] }, + prepareReplay: async ({ generation: current }) => + candidate ? { ...generation([]), ordinaryInputs: [input] } : current, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + const stateFile = prepared.options.stateFile ?? '' + const revisionFile = prepared.options.revisionFile ?? '' + rmSync(stateFile) + mkdirSync(stateFile) + const sentinel = app.write('df/snapshot.json/predecessor', 'keep me') + const revision = readFileSync(revisionFile, 'utf8') + try { + // When the transaction cannot capture the exact predecessor. + const error = await failure(core.css(cssQuery)) + candidate = false + // Then it propagates the IO error before writes and retains the serving shell. + expect(error).toBeInstanceOf(Error) + expect(readFileSync(sentinel, 'utf8')).toBe('keep me') + expect(readFileSync(revisionFile, 'utf8')).toBe(revision) + expect((await core.css(cssQuery)).css).not.toContain('background:green') + await core.flush() + } finally { + core.close() + } +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-adoption-checkpoint.test.ts b/packages/next-plugin/src/__tests__/coordinator-adoption-checkpoint.test.ts new file mode 100644 index 000000000..01f2e5d2e --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-adoption-checkpoint.test.ts @@ -0,0 +1,91 @@ +import { afterEach, expect, it } from 'bun:test' + +import { + captureCoordinatorState, + CoordinatorStateError, + readCoordinatorState, + restoreCoordinatorState, + writeCoordinatorState, +} from '../state' +import { createTestApp, removeTestApps } from './coordinator-app' +import { box } from './coordinator-prepared-fixture' + +afterEach(removeTestApps) + +it('roundtrips a nonempty completion projection with the extracted checkpoint', async () => { + // Given real extracted CSS and a completion plan with a numbered bucket. + const app = createTestApp() + const wasm = app.engine() + wasm + .codeExtract( + 'provider.tsx', + box('blue'), + '@devup-ui/react', + './df', + false, + false, + true, + {}, + ) + .free() + const plan = { + canonicalMap: { 'provider.tsx': 'route.tsx', 'global.tsx': '@global' }, + expectedBaseFiles: ['provider.tsx', 'global.tsx'], + } + const snapshot = captureCoordinatorState({ + wasm, + optionsKey: 'completion-key', + project: app.root, + revision: 8, + inputs: [], + plan, + }) + const path = app.write('checkpoint.json', '') + // When the complete checkpoint crosses the disk read/write boundary. + await writeCoordinatorState(path, snapshot) + const restored = readCoordinatorState(path, 'completion-key') + // Then both projection members and actual engine CSS survive validation. + expect(restored?.plan).toEqual(plan) + if (restored === undefined) throw new Error('missing persisted checkpoint') + const fresh = app.engine() + restoreCoordinatorState(fresh, restored) + expect(fresh.getCss(0, false)).toContain('background:blue') +}) + +it.each( + [ + null, + [], + 'plan', + {}, + { canonicalMap: {} }, + { expectedBaseFiles: [] }, + { canonicalMap: null, expectedBaseFiles: [] }, + { canonicalMap: [], expectedBaseFiles: [] }, + { canonicalMap: 'bucket', expectedBaseFiles: [] }, + { canonicalMap: { 'provider.tsx': 1 }, expectedBaseFiles: [] }, + { + canonicalMap: { 'provider.tsx': 'route.tsx' }, + expectedBaseFiles: 'file', + }, + { canonicalMap: { 'provider.tsx': 'route.tsx' }, expectedBaseFiles: [1] }, + ].map((plan) => ({ plan })), +)('rejects an invalid persisted completion projection: %j', ({ plan }) => { + // Given otherwise valid persisted engine state with an untrusted projection. + const app = createTestApp() + const snapshot = captureCoordinatorState({ + wasm: app.engine(), + optionsKey: 'completion-key', + project: app.root, + revision: 8, + inputs: [], + }) + const path = app.write( + 'checkpoint.json', + JSON.stringify({ ...snapshot, plan }), + ) + // When reading even for a different options key. + const read = () => readCoordinatorState(path, 'different-key') + // Then the whole corrupt checkpoint rejects rather than becoming a cold start. + expect(read).toThrow(CoordinatorStateError) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-adoption-transaction.test.ts b/packages/next-plugin/src/__tests__/coordinator-adoption-transaction.test.ts new file mode 100644 index 000000000..7a6db4657 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-adoption-transaction.test.ts @@ -0,0 +1,193 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import * as fsp from 'node:fs/promises' + +import { afterEach, expect, it, spyOn } from 'bun:test' + +import { createCore } from '../coordinator-core' +import { parseExtractRequest } from '../coordinator-http' +import { createInput } from '../coordinator-ledger' +import { CompensationError } from '../coordinator-transaction' +import { readCoordinatorState } from '../state' +import { createTestApp, failure, removeTestApps } from './coordinator-app' +import { + box, + cssQuery, + generation, + prewarmed, +} from './coordinator-prepared-fixture' + +afterEach(removeTestApps) + +it('rejects overlapping ordinary and compiled ownership without publication', async () => { + // Given a provider attempting to classify one input as ordinary and compiled. + const app = createTestApp() + const input = createInput( + app.root, + parseExtractRequest(app.post('provider.tsx', box('red'))), + [], + ) + const next = { + ...generation([ + { + input, + evidence: { + compilerFingerprint: 'fixture', + fileFingerprints: {}, + contextFingerprints: {}, + missingDependencies: [], + }, + }, + ]), + ordinaryInputs: [input], + } + const prepared = prewarmed(app, { + generation: { ...generation([]), ordinaryInputs: [] }, + prepareReplay: async () => next, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + const before = readFileSync(prepared.options.stateFile ?? '', 'utf8') + // When refresh tries adopting conflicting ownership. + const error = await failure(core.reconcile()) + // Then the error blocks both CSS and durable publication. + expect(error).toBeInstanceOf(Error) + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe(before) + core.close() +}) + +it.each(['stateFile', 'revisionFile'])( + 'restores the predecessor after ordinary adoption %s failure and allows corrected retry', + async (destination) => { + // Given an opted-in owner and a candidate containing new ordinary styles. + const app = createTestApp() + const input = createInput( + app.root, + parseExtractRequest(app.post('provider.tsx', box('green'))), + [], + ) + const next = { + ...generation([]), + ordinaryInputs: [input], + plan: { canonicalMap: {}, expectedBaseFiles: ['provider.tsx'] }, + } + const prepared = prewarmed(app, { + generation: { ...generation([]), ordinaryInputs: [] }, + prepareReplay: async ({ generation: current }) => + current.ordinaryInputs?.length ? current : next, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + const before = readFileSync(prepared.options.stateFile ?? '', 'utf8') + const revision = readFileSync(prepared.options.revisionFile ?? '', 'utf8') + const rename = fsp.rename + let blocked = true + const spy = spyOn(fsp, 'rename').mockImplementation(async (from, to) => { + if (blocked && to === prepared.options[destination]) + throw new Error('controlled candidate write failure') + return rename(from, to) + }) + try { + // When candidate durability fails before adoption. + const error = await failure(core.css(cssQuery)) + // Then old ledger/revision/plan remain exact and corrected retry commits once. + expect(error).toBeInstanceOf(Error) + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe( + before, + ) + expect(readFileSync(prepared.options.revisionFile ?? '', 'utf8')).toBe( + revision, + ) + expect(core.watchInputs?.()).toEqual([]) + blocked = false + expect((await core.css(cssQuery)).css).toContain('background:green') + expect( + readCoordinatorState(prepared.options.stateFile ?? '', '')?.revision, + ).toBe(8) + expect( + readCoordinatorState(prepared.options.stateFile ?? '', '')?.plan, + ).toEqual(next.plan) + } finally { + spy.mockRestore() + core.close() + } + }, +) + +it('restores prior revision absence when cancellation follows revision publication', async () => { + // Given an opted-in owner with no predecessor revision marker. + const app = createTestApp() + const next = { + ...generation([]), + ordinaryInputs: [ + createInput( + app.root, + parseExtractRequest(app.post('provider.tsx', box('green'))), + [], + ), + ], + } + const prepared = prewarmed(app, { + generation: { ...generation([]), ordinaryInputs: [] }, + prepareReplay: async () => next, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + const before = readFileSync(prepared.options.stateFile ?? '', 'utf8') + rmSync(prepared.options.revisionFile ?? '') + const rename = fsp.rename + const spy = spyOn(fsp, 'rename').mockImplementation(async (from, to) => { + await rename(from, to) + if (to === prepared.options.revisionFile) core.close() + }) + try { + // When close wins immediately after revision rename. + const error = await failure(core.css(cssQuery)) + await core.flush() + // Then checkpoint bytes and marker absence are restored exactly. + expect(error).toBeInstanceOf(Error) + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe(before) + expect(existsSync(prepared.options.revisionFile ?? '')).toBe(false) + } finally { + spy.mockRestore() + } +}) + +it('quarantines publication when predecessor compensation fails', async () => { + // Given a revision failure followed by a checkpoint-compensation failure. + const app = createTestApp() + const next = { + ...generation([]), + ordinaryInputs: [ + createInput( + app.root, + parseExtractRequest(app.post('provider.tsx', box('green'))), + [], + ), + ], + } + const prepared = prewarmed(app, { + generation: { ...generation([]), ordinaryInputs: [] }, + prepareReplay: async () => next, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + const rename = fsp.rename + let stateWrites = 0 + const spy = spyOn(fsp, 'rename').mockImplementation(async (from, to) => { + if (to === prepared.options.stateFile && ++stateWrites > 1) + throw new Error('compensation failed') + if (to === prepared.options.revisionFile) throw new Error('revision failed') + return rename(from, to) + }) + try { + // When required compensation cannot restore the durable predecessor. + const error = await failure(core.css(cssQuery)) + // Then further publication/drain fails rather than asserting restoration. + expect(error).toBeInstanceOf(CompensationError) + expect(await failure(core.css(cssQuery))).toBe(error) + expect(await failure(core.flush())).toBe(error) + } finally { + spy.mockRestore() + core.close() + } +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-adoption-watches.test.ts b/packages/next-plugin/src/__tests__/coordinator-adoption-watches.test.ts new file mode 100644 index 000000000..2b7171fea --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-adoption-watches.test.ts @@ -0,0 +1,68 @@ +import { join } from 'node:path' + +import { afterEach, expect, it } from 'bun:test' + +import { createCore } from '../coordinator-core' +import { watchSources } from '../coordinator-watch' +import { createTestApp, failure, removeTestApps } from './coordinator-app' +import { cssQuery, generation, prewarmed } from './coordinator-prepared-fixture' + +afterEach(removeTestApps) + +it.each(['file', 'build', 'context', 'missing'])( + 'delivers the actual changed child for an adopted outside-root %s input', + async (kind) => { + // Given a real watcher with dynamically adopted reported inputs outside src. + const app = createTestApp() + const file = + kind === 'context' + ? 'outside/context/child.json' + : 'outside/nested/data.json' + if (kind === 'missing') + app.write('outside/nested/existing.json', 'existing parent') + if (kind !== 'missing') app.write(file, 'before') + const input = join(app.root, kind === 'context' ? 'outside/context' : file) + const event = Promise.withResolvers() + const watcher = watchSources({ + roots: [join(app.root, 'src')], + debounceMs: 5, + onChange: (paths) => { + if (paths?.includes(join(app.root, file))) event.resolve(paths) + }, + onError: (error) => event.reject(error), + }) + watcher.replaceInputs?.([input]) + try { + // When child bytes change without changing a context listing, or a missing input appears. + app.write(file, 'after') + const paths = await event.promise + // Then the retained absolute child path is available to selective refresh. + expect(paths).toContain(join(app.root, file)) + } finally { + watcher.close() + } + }, +) + +it('keeps an omitted planned input pending despite a complete-empty prewarm declaration', async () => { + // Given an adopted plan declaring a required file that was never extracted. + const app = createTestApp() + const initial = { + ...generation([]), + plan: { canonicalMap: {}, expectedBaseFiles: ['missing.tsx'] }, + } + const prepared = prewarmed(app, { generation: initial }) + const core = createCore( + { ...prepared.options, watch: false, maxWaitMs: 10 }, + app.root, + ) + await core.startup() + try { + // When production requests the base sheet. + const error = await failure(core.css({ ...cssQuery, fileNum: undefined })) + // Then completion is not inferred from receipt/plan metadata. + expect(error).toBeInstanceOf(Error) + } finally { + core.close() + } +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-adoption.test.ts b/packages/next-plugin/src/__tests__/coordinator-adoption.test.ts new file mode 100644 index 000000000..41f3e5234 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-adoption.test.ts @@ -0,0 +1,248 @@ +import { readFileSync } from 'node:fs' +import * as fsp from 'node:fs/promises' +import { join } from 'node:path' + +import { afterEach, expect, it, spyOn } from 'bun:test' + +import { createCore } from '../coordinator-core' +import { immutableGeneration } from '../coordinator-generation' +import { parseExtractRequest } from '../coordinator-http' +import { createInput, stampFile } from '../coordinator-ledger' +import type { ReplayExtractionReport } from '../coordinator-options' +import { readCoordinatorState } from '../state' +import { createTestApp, failure, removeTestApps } from './coordinator-app' +import { + box, + cssQuery, + generation, + prewarmed, +} from './coordinator-prepared-fixture' + +afterEach(removeTestApps) + +it('retains detached ordinary plan and watch metadata when copying a generation', () => { + // Given mutable provider data and one resolver identity. + const app = createTestApp() + const input = createInput( + app.root, + parseExtractRequest(app.post('provider.tsx', box('blue'))), + [], + ) + const source = { + ...generation([]), + ordinaryInputs: [input], + plan: { + canonicalMap: { 'provider.tsx': '@global' }, + expectedBaseFiles: ['provider.tsx'], + }, + watchInputs: [join(app.root, 'outside.json')], + } + // When Core copies data and the provider mutates its containers. + const copied = immutableGeneration(source) + source.ordinaryInputs.splice(0) + source.plan.expectedBaseFiles.splice(0) + source.watchInputs.splice(0) + // Then retained metadata is immutable without changing the resolver identity. + expect(copied.ordinaryInputs).toEqual([input]) + expect(copied.plan?.expectedBaseFiles).toEqual(['provider.tsx']) + expect(copied.watchInputs).toEqual([join(app.root, 'outside.json')]) + expect(Object.isFrozen(copied.plan?.canonicalMap)).toBe(true) + expect(copied.configureWasm).toBe(source.configureWasm) +}) + +it('adopts new ordinary bytes before POST and makes cache-zero resend a durable no-op', async () => { + // Given a new proved disk-first generation and no output-cache capacity. + const app = createTestApp() + app.write('provider.tsx', box('blue')) + const input = createInput( + app.root, + parseExtractRequest(app.post('provider.tsx')), + [], + ) + const next = { + ...generation([]), + ordinaryInputs: [input], + watchInputs: [input.resourcePath], + } + const prepared = prewarmed(app, { + generation: { ...generation([]), ordinaryInputs: [] }, + prepareReplay: async ({ generation: current }) => + current.ordinaryInputs?.length ? current : next, + }) + const core = createCore({ ...prepared.options, cacheMaxEntries: 0 }, app.root) + await core.startup() + // When CSS prepares the provider before the identical native resend. + const css = await core.css(cssQuery) + const checkpoint = readFileSync(prepared.options.stateFile ?? '', 'utf8') + const response = await core.extract( + parseExtractRequest(app.post('provider.tsx')), + ) + // Then actual CSS/output exists and resend makes no new durable revision. + expect(css.css).toContain('background:blue') + expect(response.code).toContain('className') + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe( + checkpoint, + ) + expect(core.watchInputs?.()).toEqual([input.resourcePath]) + core.close() +}) + +it('keeps omitted ordinary survivors when replacement overlays changed bytes', async () => { + // Given ordinary ownership separate from the compiled set. + const app = createTestApp() + const old = createInput( + app.root, + parseExtractRequest(app.post('provider.tsx', box('red'))), + [], + ) + const survivor = createInput( + app.root, + parseExtractRequest(app.post('survivor.tsx', box('blue'))), + [], + ) + const replacement = { ...old, source: box('green') } + const next = { ...generation([]), ordinaryInputs: [replacement] } + const prepared = prewarmed(app, { + ordinaryInputs: [old, survivor], + generation: { ...generation([]), ordinaryInputs: [old, survivor] }, + prepareReplay: async ({ generation: current }) => + current.ordinaryInputs?.[0]?.source === box('green') ? current : next, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + // When a complete ordinary reach projection omits a still-existing survivor. + await core.reconcile() + // Then omission is not pruning, and replacement is durable. + expect( + readCoordinatorState(prepared.options.stateFile ?? '', '')?.inputs.map( + ({ source }) => source, + ), + ).toEqual([box('green'), box('blue')]) + core.close() +}) + +it('compensates checkpoint and revision when cancellation wins after candidate IO', async () => { + // Given an opted-in candidate paused at atomic state rename. + const app = createTestApp() + const next = { + ...generation([]), + ordinaryInputs: [ + createInput( + app.root, + parseExtractRequest(app.post('provider.tsx', box('green'))), + [], + ), + ], + } + const prepared = prewarmed(app, { + generation: { ...generation([]), ordinaryInputs: [] }, + prepareReplay: async () => next, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + const state = readFileSync(prepared.options.stateFile ?? '', 'utf8') + const revision = readFileSync(prepared.options.revisionFile ?? '', 'utf8') + const entered = Promise.withResolvers() + const released = Promise.withResolvers() + const rename = fsp.rename + let paused = false + const spy = spyOn(fsp, 'rename').mockImplementation(async (from, to) => { + if (to === prepared.options.stateFile && !paused) { + paused = true + entered.resolve() + await released.promise + } + return rename(from, to) + }) + try { + const refreshing = failure(core.css(cssQuery)) + await entered.promise + // When close wins before candidate publication. + core.close() + released.resolve() + const error = await refreshing + await core.flush() + // Then compensation joins IO and restores both exact durable predecessors. + expect(error).toBeInstanceOf(Error) + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe(state) + expect(readFileSync(prepared.options.revisionFile ?? '', 'utf8')).toBe( + revision, + ) + } finally { + spy.mockRestore() + } +}) + +it('validates the adopted production generation at every CSS publication', async () => { + // Given a prepared nonwatch owner whose reported input changes after initial publication. + const app = createTestApp() + const dependency = app.write('outside.json', 'prepared input') + const fingerprint = stampFile(dependency) + const prepared = prewarmed(app, { + generation: { ...generation([]), watchInputs: [dependency] }, + }) + const provider = prepared.options.preparedSources + if (provider === undefined) throw new Error('missing fixture provider') + const observed: boolean[] = [] + const core = createCore( + { + ...prepared.options, + watch: false, + preparedSources: { + ...provider, + validateForCssFinalization(current) { + observed.push(Object.isFrozen(current)) + if (stampFile(dependency) !== fingerprint) + throw new Error('outside.json:1:1: changed reported input') + }, + }, + }, + app.root, + ) + await core.startup() + await core.css(cssQuery) + app.write('outside.json', 'changed input') + // When a second production publication tries to capture CSS. + const error = await failure(core.css(cssQuery)) + // Then freshness rejects even after sealing, without starting dev preparation. + expect(error).toBeInstanceOf(Error) + expect(observed).toEqual([true, true]) + core.close() +}) + +it('observes complete inputs on isolated engines with the supplied resolver', async () => { + // Given request-local observation with a resolver different from the live one. + const app = createTestApp() + const input = createInput( + app.root, + parseExtractRequest(app.post('provider.tsx', box('green'))), + [], + ) + let reports: readonly ReplayExtractionReport[] = [] + const prepared = prewarmed(app, { + generation: { ...generation([]), ordinaryInputs: [] }, + prepareReplay: async ({ + generation: current, + observeExtraction, + changedPaths, + }) => { + if (observeExtraction === undefined) throw new Error('missing observer') + expect(changedPaths).toEqual([join(app.root, 'changed.json')]) + reports = observeExtraction([input], (wasm) => { + expect(wasm).not.toBe(prepared.wasm) + }) + expect(prepared.wasm.getCss(0, false)).not.toContain('background:green') + return current + }, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + // When refresh observes a complete candidate using its own configurer. + await core.reconcile([join(app.root, 'changed.json')]) + // Then observation did not mutate live state or revision. + expect(reports).toEqual([{ filename: 'provider.tsx', dependencies: [] }]) + expect( + readCoordinatorState(prepared.options.stateFile ?? '', '')?.revision, + ).toBe(7) + core.close() +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-app.ts b/packages/next-plugin/src/__tests__/coordinator-app.ts new file mode 100644 index 000000000..d056d145c --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-app.ts @@ -0,0 +1,184 @@ +import { randomUUID } from 'node:crypto' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { request } from 'node:http' +import { dirname, join, resolve } from 'node:path' + +import type { CoordinatorOptions } from '../coordinator' +import { + type CoordinatorIdentity, + type CoordinatorPortInfo, + parsePortFile, +} from '../coordinator-port' +import { createWasm, type DevupWasm } from '../wasm' + +const pluginRoot = join(import.meta.dir, '..', '..') +let runRoot: string | undefined + +export interface TestApp { + readonly root: string + readonly portFile: string + readonly identity: CoordinatorIdentity + /** Write a file under the app root and return its absolute path. */ + write(file: string, content: string): string + /** What a loader posts for `file`. */ + post(file: string, code?: string): string + engine(): DevupWasm + /** Options for a dev (watch) or production coordinator of this app. */ + options(overrides?: Partial): CoordinatorOptions +} + +/** A throwaway project inside the package, so the real WASM resolves from it. */ +export function createTestApp(): TestApp { + // One directory per test process, so concurrent runs never delete each other's apps + runRoot ??= mkdtempSync(join(pluginRoot, '.tmp-coordinator-')) + const root = mkdtempSync(join(runRoot, 'app-')) + writeFileSync(join(root, 'package.json'), '{}') + const app: TestApp = { + root, + portFile: join(root, 'df', 'coordinator.port'), + identity: { project: resolve(root), token: randomUUID() }, + write(file, content) { + const path = join(root, file) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) + return path + }, + post(file, code = readFileSync(join(root, file), 'utf-8')) { + return JSON.stringify({ + filename: file, + code, + resourcePath: join(root, file), + }) + }, + engine: () => createWasm(root), + options: (overrides = {}) => ({ + wasm: createWasm(root), + package: '@devup-ui/react', + cssDir: join(root, 'df', 'devup-ui'), + singleCss: false, + importAliases: {}, + coordinatorPortFile: join(root, 'df', 'coordinator.port'), + canonicalMap: {}, + projectRoot: root, + identity: app.identity, + createEngine: () => createWasm(root), + ...overrides, + }), + } + mkdirSync(join(root, 'df'), { recursive: true }) + return app +} + +/** The error a promise rejects with, or undefined when it resolves. */ +export async function failure(promise: Promise): Promise { + try { + await promise + } catch (error) { + return error + } + return undefined +} + +/** An engine that records every extraction it runs, as `map:file` or `nomap:file`. */ +export function instrument( + engine: DevupWasm, + extractions: string[] = [], +): { engine: DevupWasm; extractions: string[] } { + return { + extractions, + engine: { + ...engine, + codeExtract(filename, ...rest) { + extractions.push(`map:${filename}`) + return engine.codeExtract(filename, ...rest) + }, + codeExtractWithoutSourceMap(filename, ...rest) { + extractions.push(`nomap:${filename}`) + return engine.codeExtractWithoutSourceMap(filename, ...rest) + }, + }, + } +} + +export function removeTestApps(): void { + if (runRoot !== undefined) rmSync(runRoot, { recursive: true, force: true }) + runRoot = undefined +} + +export interface Reply { + readonly status: number + readonly body: string + readonly headers: Record +} + +export function http( + port: number, + method: string, + path: string, + options: { body?: string; headers?: Record } = {}, +): Promise { + return new Promise((resolveReply, reject) => { + const req = request( + { + hostname: '127.0.0.1', + port, + path, + method, + agent: false, + headers: options.headers, + }, + (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => + resolveReply({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString('utf-8'), + headers: res.headers, + }), + ) + }, + ) + req.on('error', reject) + req.end(options.body) + }) +} + +export function ownership( + identity: CoordinatorIdentity, +): Record { + return { + 'x-devup-project': encodeURIComponent(identity.project), + 'x-devup-token': identity.token, + } +} + +/** A client for a published coordinator that sends the right headers. */ +export function connect(portFile: string, identity: CoordinatorIdentity) { + const info: CoordinatorPortInfo = parsePortFile( + readFileSync(portFile, 'utf-8'), + ) + const headers = ownership(identity) + return { + info, + get: (path: string) => http(info.port, 'GET', path, { headers }), + post: (path: string, body: string) => + http(info.port, 'POST', path, { body, headers }), + } +} + +/** Poll until `probe` returns a value; the test's own timeout bounds it. */ +export async function eventually( + probe: () => T | undefined | Promise, +): Promise { + const value = await probe() + if (value !== undefined) return value + await new Promise((done) => setTimeout(done, 25)) + return eventually(probe) +} diff --git a/packages/next-plugin/src/__tests__/coordinator-barrier-fixture.ts b/packages/next-plugin/src/__tests__/coordinator-barrier-fixture.ts new file mode 100644 index 000000000..6c0815f74 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-barrier-fixture.ts @@ -0,0 +1,46 @@ +import { IncomingMessage, Server } from 'node:http' + +import { spyOn } from 'bun:test' + +import { type CoordinatorOptions, startCoordinator } from '../coordinator' +import { createTestApp } from './coordinator-app' + +export function barrier() { + const app = createTestApp() + const pending = Promise.withResolvers() + const entered = Promise.withResolvers() + const handle = startCoordinator({ + projectRoot: app.root, + identity: app.identity, + coordinatorPortFile: app.portFile, + prepare(signal) { + entered.resolve(signal) + return pending.promise + }, + }) + return { app, pending, entered: entered.promise, handle } +} + +export function acceptedRequests(count: number) { + const accepted = Promise.withResolvers() + const emit = Server.prototype.emit + let received = 0 + const spy = spyOn(Server.prototype, 'emit').mockImplementation(function ( + this: Server, + event: string | symbol, + ...args: unknown[] + ) { + const result: boolean = Reflect.apply(emit, this, [event, ...args]) + const req = args[0] + if ( + event === 'request' && + req instanceof IncomingMessage && + (req.url?.startsWith('/css') || req.url === '/extract') + ) { + received += 1 + if (received === count) accepted.resolve() + } + return result + }) + return { accepted: accepted.promise, restore: () => spy.mockRestore() } +} diff --git a/packages/next-plugin/src/__tests__/coordinator-barrier-shutdown.test.ts b/packages/next-plugin/src/__tests__/coordinator-barrier-shutdown.test.ts new file mode 100644 index 000000000..beecc595f --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-barrier-shutdown.test.ts @@ -0,0 +1,239 @@ +import { existsSync } from 'node:fs' +import * as fsp from 'node:fs/promises' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +import { resetCoordinator, startCoordinator } from '../coordinator' +import { CoordinatorShutdownError } from '../coordinator-completion' +import { readCoordinatorState } from '../state' +import { + connect, + createTestApp, + failure, + instrument, + removeTestApps, +} from './coordinator-app' +import { acceptedRequests, barrier } from './coordinator-barrier-fixture' + +afterEach(() => { + resetCoordinator() + removeTestApps() +}) + +describe('deferred cancellation and durability', () => { + it.each(['close', 'drain'] as const)( + '%s wakes accepted work and prevents late preparation writes', + async (operation) => { + // Given accepted requests waiting on an unresolved preparation callback. + const { app, pending, entered, handle } = barrier() + const { engine, extractions } = instrument(app.engine()) + const stateFile = join(app.root, 'df', 'state.json') + await handle.ready + const signal = await entered + const client = connect(app.portFile, app.identity) + const observed = acceptedRequests(2) + try { + const extract = failure( + client.post('/extract', app.post('a.tsx', 'export const a = 1')), + ) + const css = failure(client.get('/css?waitForIdle=true')) + const prepared = failure(handle.prepared) + await observed.accepted + + // When the owner closes or drains before compilation completes. + await handle[operation]() + + // Then waiters wake and late completion cannot construct or write Core. + expect(signal.aborted).toBe(true) + expect(await prepared).toBeInstanceOf(CoordinatorShutdownError) + await Promise.all([extract, css]) + pending.resolve( + app.options({ + wasm: engine, + stateFile, + revisionFile: join(app.root, 'df', 'revision'), + watch: true, + }), + ) + await pending.promise + await Promise.resolve() + expect(extractions).toEqual([]) + expect(existsSync(stateFile)).toBe(false) + expect(existsSync(join(app.root, 'df', 'revision'))).toBe(false) + expect(existsSync(app.portFile)).toBe(false) + } finally { + observed.restore() + } + }, + ) + + it('observes late compiler rejection after cancellation', async () => { + // Given a compiler promise that ignores cancellation. + const { pending, entered, handle } = barrier() + await handle.ready + await entered + const prepared = failure(handle.prepared) + + // When drain cancels ownership before the compiler rejects. + await handle.drain() + pending.reject(new Error('late compiler failure')) + + // Then preparation remains cancelled without an unhandled rejection. + expect(await prepared).toBeInstanceOf(CoordinatorShutdownError) + await Promise.resolve() + }) + + it('does not invoke preparation when closed while ownership transport opens', async () => { + // Given a deferred coordinator whose listen callback has not run. + const app = createTestApp() + let calls = 0 + const handle = startCoordinator({ + projectRoot: app.root, + identity: app.identity, + coordinatorPortFile: app.portFile, + prepare: async () => { + calls += 1 + return app.options() + }, + }) + + // When close wins transport publication. + handle.close() + await handle.ready + + // Then no preparation callback or endpoint publication occurs. + expect(calls).toBe(0) + expect(await failure(handle.prepared)).toBeInstanceOf( + CoordinatorShutdownError, + ) + expect(existsSync(app.portFile)).toBe(false) + }) + + it('drains startup persistence before owner cleanup and suppresses late watches', async () => { + // Given startup persistence already accepted but blocked in its rename. + const { app, pending, handle } = barrier() + const stateFile = join(app.root, 'df', 'state.json') + const writing = Promise.withResolvers() + const release = Promise.withResolvers() + const rename = fsp.rename + const spy = spyOn(fsp, 'rename').mockImplementation(async (...args) => { + writing.resolve() + await release.promise + return rename(...args) + }) + try { + await handle.ready + pending.resolve( + app.options({ stateFile, watch: true, sourceRoots: ['src'] }), + ) + await writing.promise + let drained = false + + // When drain cancels preparation during that write. + const drain = handle.drain().then(() => { + drained = true + }) + await Promise.resolve() + + // Then cleanup waits for durability rather than abandoning the write. + expect(drained).toBe(false) + expect(existsSync(app.portFile)).toBe(true) + release.resolve() + await drain + expect(readCoordinatorState(stateFile, '')?.inputs).toEqual([]) + expect(existsSync(app.portFile)).toBe(false) + } finally { + release.resolve() + spy.mockRestore() + } + }) + + it('drains an accepted extraction after preparation without abandoning its snapshot', async () => { + // Given a prepared coordinator and an accepted extraction write. + const { app, pending, handle } = barrier() + const stateFile = join(app.root, 'df', 'state.json') + pending.resolve(app.options({ stateFile })) + await handle.ready + await handle.prepared + const client = connect(app.portFile, app.identity) + const writing = Promise.withResolvers() + const release = Promise.withResolvers() + const rename = fsp.rename + const spy = spyOn(fsp, 'rename').mockImplementation(async (...args) => { + writing.resolve() + await release.promise + return rename(...args) + }) + try { + const extraction = client.post( + '/extract', + app.post('a.tsx', 'export const a = 1'), + ) + await writing.promise + + // When drain starts while the extraction rename is blocked. + const drain = handle.drain() + expect((await client.get('/health')).status).toBe(503) + release.resolve() + + // Then accepted work remains durable before owner cleanup. + expect((await extraction).status).toBe(200) + await drain + expect( + readCoordinatorState(stateFile, '')?.inputs.map( + (input) => input.filename, + ), + ).toEqual(['a.tsx']) + expect(existsSync(app.portFile)).toBe(false) + } finally { + release.resolve() + spy.mockRestore() + } + }) + + it('joins an in-progress startup write even when preparation times out', async () => { + // Given startup persistence blocked beyond the preparation deadline. + const app = createTestApp() + const stateFile = join(app.root, 'df', 'state.json') + const writing = Promise.withResolvers() + const release = Promise.withResolvers() + const rename = fsp.rename + const spy = spyOn(fsp, 'rename').mockImplementation(async (...args) => { + writing.resolve() + await release.promise + return rename(...args) + }) + const handle = startCoordinator({ + projectRoot: app.root, + identity: app.identity, + coordinatorPortFile: app.portFile, + maxPrepareMs: 10, + prepare: async () => app.options({ stateFile }), + }) + try { + await handle.ready + await writing.promise + const error = await failure(handle.prepared) + let drained = false + + // When failed preparation is drained while its startup write is active. + const drain = failure(handle.drain()).then((result) => { + drained = true + return result + }) + await Promise.resolve() + + // Then the failure propagates only after write draining and owner cleanup. + expect(drained).toBe(false) + expect(existsSync(app.portFile)).toBe(true) + release.resolve() + expect(await drain).toBe(error) + expect(existsSync(app.portFile)).toBe(false) + expect(readCoordinatorState(stateFile, '')?.inputs).toEqual([]) + } finally { + release.resolve() + spy.mockRestore() + } + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-barrier.test.ts b/packages/next-plugin/src/__tests__/coordinator-barrier.test.ts new file mode 100644 index 000000000..9f26eb860 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-barrier.test.ts @@ -0,0 +1,254 @@ +import * as fs from 'node:fs' +import * as fsp from 'node:fs/promises' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +import { resetCoordinator, startCoordinator } from '../coordinator' +import { readCoordinatorState } from '../state' +import { + connect, + failure, + http, + instrument, + removeTestApps, +} from './coordinator-app' +import { acceptedRequests, barrier } from './coordinator-barrier-fixture' + +const source = + 'import { Box } from "@devup-ui/react"; export const A = ' + +afterEach(() => { + resetCoordinator() + removeTestApps() +}) + +describe('deferred ownership transport', () => { + it('answers authenticated health before preparation without snapshots or watches', async () => { + // Given pending preparation with durable output and real watches. + const { app, pending, entered, handle } = barrier() + const watching = spyOn(fs, 'watch') + const stateFile = join(app.root, 'df', 'snapshot.json') + const revisionFile = join(app.root, 'df', 'revision') + try { + await handle.ready + await entered + const client = connect(app.portFile, app.identity) + + // When the ownership transport receives a health request. + const health = await client.get('/health') + + // Then ownership is usable before Core side effects begin. + expect(health.status).toBe(200) + expect((await http(client.info.port, 'GET', '/health')).status).toBe(403) + expect(fs.existsSync(stateFile)).toBe(false) + expect(fs.existsSync(revisionFile)).toBe(false) + expect(watching).not.toHaveBeenCalled() + app.write('src/a.tsx', source) + pending.resolve( + app.options({ + watch: true, + sourceRoots: ['src'], + stateFile, + revisionFile, + }), + ) + await handle.prepared + expect(readCoordinatorState(stateFile, '')?.inputs).toEqual([]) + expect(fs.existsSync(revisionFile)).toBe(true) + expect(watching).toHaveBeenCalled() + } finally { + watching.mockRestore() + } + }) + + it('holds accepted extraction and CSS until complete options and startup arrive', async () => { + // Given two accepted work requests and a deliberately unresolved compiler. + const { app, pending, handle } = barrier() + const { engine, extractions } = instrument(app.engine()) + app.write('src/a.tsx', source) + await handle.ready + const client = connect(app.portFile, app.identity) + const observed = acceptedRequests(2) + let replies = 0 + try { + const extracted = client + .post('/extract', app.post('src/a.tsx')) + .then((reply) => { + replies += 1 + return reply + }) + const css = client + .get('/css?fileNum=0&waitForIdle=true') + .then((reply) => { + replies += 1 + return reply + }) + await observed.accepted + await client.get('/health') + + // When complete options are withheld and subsequently supplied. + expect(extractions).toEqual([]) + expect(replies).toBe(0) + pending.resolve( + app.options({ wasm: engine, expectedBaseFiles: ['src/a.tsx'] }), + ) + + // Then only the prepared engine serves extraction and complete CSS. + expect((await extracted).status).toBe(200) + expect((await css).body).toContain('background:red') + expect(extractions).toEqual(['map:src/a.tsx']) + } finally { + observed.restore() + } + }) + + it.each([false, true])( + 'retains a located preparation failure at live dev/prod endpoints (watch=%s)', + async (watch) => { + // Given accepted dev or production requests behind pending preparation. + const { app, pending, handle } = barrier() + const options = app.options({ watch }) + const error = new Error('src/page.mdx:4:7: required compiler failed') + await handle.ready + const client = connect(app.portFile, app.identity) + const observed = acceptedRequests(2) + const failed = failure(handle.prepared) + try { + const extract = client.post('/extract', app.post('src/a.tsx', source)) + const css = client.get('/css?waitForIdle=true') + await observed.accepted + + // When the configured compiler rejects with a located cause. + pending.reject(error) + + // Then live endpoints and drain retain that exact cause. + expect(await failed).toBe(error) + for (const reply of [ + await extract, + await css, + await client.get('/css'), + ]) { + expect(reply.status).toBe(500) + expect(JSON.parse(reply.body).error).toBe(error.message) + } + expect((await client.get('/health')).status).toBe(200) + expect(await failure(handle.drain())).toBe(error) + expect(fs.existsSync(options.coordinatorPortFile)).toBe(false) + } finally { + observed.restore() + } + }, + ) + + it('keeps an unrelated app usable while sharing one pending owner across handles', async () => { + // Given shared pending ownership and a separately prepared app. + const { app, pending, handle } = barrier() + const second = startCoordinator({ + projectRoot: app.root, + identity: app.identity, + coordinatorPortFile: app.portFile, + prepare: () => { + throw new Error('duplicate preparation') + }, + }) + const other = barrier() + other.pending.resolve(other.app.options({ watch: true })) + await Promise.all([handle.ready, other.handle.ready, other.handle.prepared]) + const client = connect(app.portFile, app.identity) + + // When only one shared reference is released. + handle.close() + const otherReply = await connect( + other.app.portFile, + other.app.identity, + ).post('/extract', other.app.post('a.tsx', source)) + + // Then neither app loses ownership and preparation remains shared. + expect(otherReply.status).toBe(200) + expect((await client.get('/health')).status).toBe(200) + expect(handle.prepared).toBe(second.prepared) + pending.resolve(app.options({ prewarmedFiles: [] })) + await second.prepared + await second.drain() + expect(fs.existsSync(app.portFile)).toBe(false) + }) + + it('gates work and watches behind the actual startup snapshot', async () => { + // Given complete options whose real startup rename is held back. + const { app, pending, handle } = barrier() + const stateFile = join(app.root, 'df', 'snapshot.json') + const started = Promise.withResolvers() + const release = Promise.withResolvers() + const rename = fsp.rename + const writing = spyOn(fsp, 'rename').mockImplementation(async (...args) => { + started.resolve() + await release.promise + return rename(...args) + }) + const watching = spyOn(fs, 'watch') + const { engine, extractions } = instrument(app.engine()) + try { + await handle.ready + pending.resolve( + app.options({ + wasm: engine, + watch: true, + sourceRoots: ['src'], + stateFile, + }), + ) + await started.promise + const client = connect(app.portFile, app.identity) + const observed = acceptedRequests(1) + try { + const extraction = client.post('/extract', app.post('a.tsx', source)) + await observed.accepted + + // When extraction arrives before startup durability. + expect(extractions).toEqual([]) + expect(watching).not.toHaveBeenCalled() + release.resolve() + + // Then work and watches begin only after the write is released. + expect((await extraction).status).toBe(200) + await handle.prepared + expect(watching).toHaveBeenCalled() + } finally { + observed.restore() + } + } finally { + release.resolve() + writing.mockRestore() + watching.mockRestore() + } + }) + + it('retains a startup failure at the published endpoint', async () => { + // Given a published transport with an unwritable snapshot destination. + const { app, pending, handle } = barrier() + const stateFile = join(app.root, 'df', 'state.json') + fs.mkdirSync(stateFile) + await handle.ready + const client = connect(app.portFile, app.identity) + + // When complete options trigger the actual startup write. + pending.resolve(app.options({ stateFile })) + const error = await failure(handle.prepared) + + // Then ownership stays live and every work route exposes the startup cause. + if (!(error instanceof Error)) throw error + expect(error.message).toContain(`${stateFile}:1:1:`) + expect( + JSON.parse((await client.get('/css?waitForIdle=true')).body).error, + ).toBe(error.message) + expect( + JSON.parse( + (await client.post('/extract', app.post('a.tsx', source))).body, + ).error, + ).toBe(error.message) + expect((await client.get('/health')).status).toBe(200) + expect(await failure(handle.drain())).toBeInstanceOf(Error) + expect(fs.existsSync(app.portFile)).toBe(false) + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-client.test.ts b/packages/next-plugin/src/__tests__/coordinator-client.test.ts new file mode 100644 index 000000000..cc9fbf7dd --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-client.test.ts @@ -0,0 +1,254 @@ +import { randomUUID } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { + CoordinatorRequestError, + requestCoordinator, +} from '../coordinator-client' +import { + formatPortFile, + parsePortFile, + publishPortFile, +} from '../coordinator-port' + +const servers: Server[] = [] +const dirs: string[] = [] +function fixture(prefix = 'devup-client-') { + const dir = mkdtempSync(join(tmpdir(), prefix)) + dirs.push(dir) + return { + portFile: join(dir, 'endpoint'), + identity: { project: dir, token: randomUUID() }, + resourcePath: join(dir, 'App.tsx'), + path: '/css', + timeoutMs: 250, + } +} +async function listen( + handler: (req: IncomingMessage, res: ServerResponse) => void, +) { + const server = createServer(handler) + servers.push(server) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('Expected TCP server') + return { server, port: address.port } +} +async function healthy( + options: ReturnType, + handler: (req: IncomingMessage, res: ServerResponse) => void, +) { + const endpoint = await listen((req, res) => { + expect(req.headers['x-devup-project']).toBe( + encodeURIComponent(options.identity.project), + ) + expect(req.headers['x-devup-token']).toBe(options.identity.token) + if (req.url === '/health') + res.end(formatPortFile(endpoint.port, process.pid, options.identity)) + else handler(req, res) + }) + publishPortFile( + options.portFile, + parsePortFile(descriptor(endpoint.port, options)), + ) + return endpoint +} +function descriptor(port: number, options: ReturnType) { + return formatPortFile(port, process.pid, options.identity) +} +afterEach(async () => { + for (const server of servers.splice(0)) { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + for (const dir of dirs.splice(0)) + rmSync(dir, { recursive: true, force: true }) +}) + +describe('deadline coordinator transport', () => { + it('uses separate endpoints and encoded headers for each app including Unicode roots', async () => { + const first = fixture('테스트-app-'), + second = fixture() + await healthy(first, (_req, res) => res.end('FIRST')) + await healthy(second, (_req, res) => res.end('SECOND')) + expect(await requestCoordinator(first)).toBe('FIRST') + expect(await requestCoordinator(second)).toBe('SECOND') + }) + it('rereads a replaced endpoint on the next operation', async () => { + const options = fixture() + await healthy(options, (_req, res) => res.end('OLD')) + expect(await requestCoordinator(options)).toBe('OLD') + await healthy(options, (_req, res) => res.end('NEW')) + expect(await requestCoordinator(options)).toBe('NEW') + }) + it('recovers within the same invocation after a stale closed port', async () => { + const options = fixture() + const stale = await healthy(options, (_req, res) => res.end('OLD')) + await new Promise((resolve) => stale.server.close(() => resolve())) + const pending = requestCoordinator({ + ...options, + method: 'POST' as const, + path: '/extract', + body: '{}', + }) + await healthy(options, (_req, res) => res.end('RECOVERED')) + expect(await pending).toBe('RECOVERED') + }) + it('waits for startup publication within the same deadline', async () => { + const options = fixture() + const pending = requestCoordinator(options) + await healthy(options, (_req, res) => res.end('STARTED')) + expect(await pending).toBe('STARTED') + }) + it.each(['health', 'response', 'stream', 'missing'])( + 'bounds %s hangs and reports the source location', + async (phase) => { + const options = { ...fixture(), timeoutMs: 35 } + if (phase === 'health') { + const endpoint = await listen(() => {}) + writeFileSync(options.portFile, descriptor(endpoint.port, options)) + } else if (phase !== 'missing') + await healthy(options, (_req, res) => { + if (phase === 'stream') res.write('partial') + }) + const start = performance.now() + await expect(requestCoordinator(options)).rejects.toThrow( + `${options.resourcePath}:1:1:`, + ) + expect(performance.now() - start).toBeLessThan(1000) + }, + ) + it.each(['project', 'token'])( + 'rejects a cross-wired %s before dispatch', + async (field) => { + const options = fixture() + let dispatched = 0 + await healthy(options, (_req, res) => { + dispatched++ + res.end('WRONG') + }) + const identity = + field === 'project' + ? { + ...options.identity, + project: join(options.identity.project, 'other'), + } + : { ...options.identity, token: randomUUID() } + await expect( + requestCoordinator({ ...options, identity }), + ).rejects.toThrow('identity mismatch') + expect(dispatched).toBe(0) + }, + ) + it.each(['port', 'pid', 'project', 'token'] as const)( + 'rejects mismatched health %s', + async (field) => { + const options = fixture() + const endpoint = await listen((_req, res) => { + const info = parsePortFile(descriptor(endpoint.port, options)) + res.end( + JSON.stringify({ + ...info, + [field]: { + port: 1, + pid: process.pid + 1, + project: join(info.project, 'other'), + token: randomUUID(), + }[field], + }), + ) + }) + writeFileSync(options.portFile, descriptor(endpoint.port, options)) + await expect(requestCoordinator(options)).rejects.toThrow( + 'health ownership mismatch', + ) + }, + ) + it('pins descriptor identity when optional expected identity is absent', async () => { + const options = fixture() + await healthy(options, (_req, res) => res.end('CSS')) + const operation = { ...options, identity: undefined, timeoutMs: undefined } + expect(await requestCoordinator(operation)).toBe('CSS') + }) + it('does not retry ambiguous POST response loss', async () => { + const options = fixture() + let posts = 0 + await healthy(options, (req) => { + posts++ + req.socket.destroy() + }) + await expect( + requestCoordinator({ + ...options, + method: 'POST', + path: '/extract', + body: '{}', + }), + ).rejects.toBeInstanceOf(CoordinatorRequestError) + expect(posts).toBe(1) + }) + it('retries GET response loss safely', async () => { + const options = fixture() + let gets = 0 + await healthy(options, (req, res) => { + gets++ + if (gets === 1) req.socket.destroy() + else res.end('RETRIED') + }) + expect(await requestCoordinator(options)).toBe('RETRIED') + expect(gets).toBe(2) + }) + it('rejects a partially received POST response exactly once', async () => { + const options = fixture() + let posts = 0 + await healthy(options, (_req, res) => { + posts++ + res.write('partial') + res.socket?.destroy() + }) + await expect( + requestCoordinator({ ...options, method: 'POST', body: '{}' }), + ).rejects.toThrow() + expect(posts).toBe(1) + }) + it('preserves missing filename details from a CSS failure body', async () => { + const options = fixture() + await healthy(options, (_req, res) => { + res.writeHead(503) + res.end(JSON.stringify({ error: 'Missing app/page.tsx' })) + }) + await expect(requestCoordinator(options)).rejects.toThrow( + 'Missing app/page.tsx', + ) + }) + it.each(['{', ' '.repeat(4097)])( + 'rejects malformed health metadata', + async (body) => { + const options = fixture() + const endpoint = await listen((_req, res) => res.end(body)) + writeFileSync(options.portFile, descriptor(endpoint.port, options)) + await expect(requestCoordinator(options)).rejects.toThrow() + }, + ) + it('reports malformed endpoint metadata without dispatch', async () => { + const options = fixture() + writeFileSync(options.portFile, 'broken') + await expect(requestCoordinator(options)).rejects.toThrow(options.portFile) + }) + it('rejects already exhausted deadlines without network traffic', async () => { + const options = fixture() + await expect( + requestCoordinator({ ...options, timeoutMs: 0 }), + ).rejects.toThrow() + expect(new CoordinatorRequestError(options, 'reason').message).toContain( + 'reason', + ) + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-dev.test.ts b/packages/next-plugin/src/__tests__/coordinator-dev.test.ts new file mode 100644 index 000000000..629361803 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-dev.test.ts @@ -0,0 +1,558 @@ +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +import { + type CoordinatorOptions, + flushCoordinatorWrites, + resetCoordinator, + startCoordinator, +} from '../coordinator' +import { readCoordinatorState } from '../state' +import type { DevupWasm } from '../wasm' +import { + connect, + createTestApp, + eventually, + instrument, + removeTestApps, + type TestApp, +} from './coordinator-app' + +const box = (bg: string) => + `import { Box } from '@devup-ui/react'\nexport const C = () => \n` +const globalBox = (bg: string, margin: number) => + `import { Box, globalCss } from '@devup-ui/react'\nglobalCss({ body: { margin: ${margin} } })\nexport const G = () => \n` + +const stateOf = (app: TestApp) => join(app.root, 'df', 'state.json') +const revisionOf = (app: TestApp) => join(app.root, 'df', 'revision') +const revision = (app: TestApp) => + Number(readFileSync(revisionOf(app), 'utf-8')) + +function dev(app: TestApp, extra: Partial = {}) { + return app.options({ + watch: true, + stateFile: stateOf(app), + revisionFile: revisionOf(app), + sourceRoots: [], + ...extra, + }) +} + +async function started(app: TestApp, extra: Partial = {}) { + const handle = startCoordinator(dev(app, extra)) + await handle.ready + return { handle, client: connect(app.portFile, app.identity) } +} + +afterEach(() => { + resetCoordinator() + removeTestApps() +}) + +describe('removing and renaming sources', () => { + it('purges a deleted file, global CSS included, without renumbering the rest', async () => { + const app = createTestApp() + const a = app.write('src/a.tsx', globalBox('red', 7)) + app.write('src/b.tsx', box('blue')) + const { client } = await started(app) + await client.post('/extract', app.post('src/a.tsx')) + const b = JSON.parse( + (await client.post('/extract', app.post('src/b.tsx'))).body, + ) + const before = revision(app) + expect((await client.get('/css')).body).toContain('margin:28px') + + rmSync(a) + const base = (await client.get('/css')).body + const afterB = JSON.parse( + (await client.post('/extract', app.post('src/b.tsx'))).body, + ) + const c = JSON.parse( + (await client.post('/extract', app.post('src/c.tsx', box('green')))).body, + ) + await flushCoordinatorWrites() + + expect(base).not.toContain('margin:28px') + expect((await client.get('/css?fileNum=0')).body).not.toContain( + 'background:red', + ) + expect((await client.get('/css?fileNum=1')).body).toContain( + 'background:blue', + ) + expect(afterB.code).toBe(b.code) + expect(c.cssFile).toEndWith('devup-ui-2.css') + expect(revision(app)).toBeGreaterThan(before) + const snapshot = readCoordinatorState(stateOf(app), '') + expect(snapshot?.inputs.map((input) => input.filename)).toEqual([ + 'src/b.tsx', + 'src/c.tsx', + ]) + expect(Object.keys(snapshot?.fileMap ?? {}).sort()).toEqual([ + 'src/a.tsx', + 'src/b.tsx', + 'src/c.tsx', + ]) + }) + + it('purges the old name of a renamed file and serves the new one', async () => { + const app = createTestApp() + const old = app.write('src/old.tsx', box('red')) + const { client } = await started(app) + await client.post('/extract', app.post('src/old.tsx')) + + renameSync(old, join(app.root, 'src', 'renamed.tsx')) + const renamed = JSON.parse( + (await client.post('/extract', app.post('src/renamed.tsx', box('blue')))) + .body, + ) + + expect(renamed.cssFile).toEndWith('devup-ui-1.css') + expect((await client.get('/css?fileNum=0')).body).not.toContain( + 'background:red', + ) + expect((await client.get('/css?fileNum=1')).body).toContain( + 'background:blue', + ) + }) + + it('makes its replacement engine for the project itself unless told how', async () => { + const app = createTestApp() + const a = app.write('src/a.tsx', box('red')) + app.write('src/b.tsx', box('blue')) + const { client } = await started(app, { createEngine: undefined }) + await client.post('/extract', app.post('src/a.tsx')) + await client.post('/extract', app.post('src/b.tsx')) + + rmSync(a) + + expect((await client.get('/css?fileNum=1')).body).toContain( + 'background:blue', + ) + expect((await client.get('/css?fileNum=0')).body).not.toContain( + 'background:red', + ) + }) + + it('keeps sources that never had a file on disk', async () => { + const app = createTestApp() + const a = app.write('src/a.tsx', box('red')) + const { client } = await started(app) + await client.post('/extract', app.post('src/a.tsx')) + await client.post('/extract', app.post('src/virtual.tsx', box('pink'))) + + rmSync(a) + + expect((await client.get('/css?fileNum=1')).body).toContain( + 'background:pink', + ) + expect((await client.get('/css?fileNum=0')).body).not.toContain( + 'background:red', + ) + }) + + it('rebuilds from the file watcher alone and leaves the revision alone when nothing changed', async () => { + const app = createTestApp() + const a = app.write('src/a.tsx', globalBox('red', 7)) + const { client } = await started(app, { + sourceRoots: [join(app.root, 'src')], + }) + await client.post('/extract', app.post('src/a.tsx')) + const before = revision(app) + await client.post('/extract', app.post('src/a.tsx')) + await flushCoordinatorWrites() + expect(revision(app)).toBe(before) + + rmSync(a) + await eventually(() => (revision(app) > before ? true : undefined)) + + expect((await client.get('/css')).body).not.toContain('margin:28px') + }) + + it('reports a rebuild the watcher could not do instead of swallowing it', async () => { + const app = createTestApp() + const a = app.write('src/a.tsx', box('red')) + const errors = spyOn(console, 'error').mockImplementation(() => undefined) + try { + const { client } = await started(app, { + sourceRoots: [join(app.root, 'src')], + createEngine: () => { + throw new Error('no engine today') + }, + }) + await client.post('/extract', app.post('src/a.tsx')) + + rmSync(a) + + const message = await eventually(() => + errors.mock.calls + .map((call) => String(call[1])) + .find((text) => text.includes('no engine today')), + ) + expect(message).toContain('no engine today') + expect(errors.mock.calls[0]?.[0]).toBe('[devup-ui]') + } finally { + errors.mockRestore() + } + }) + + it('keeps serving the old state when the engine cannot be rebuilt, and recovers', async () => { + const app = createTestApp() + const a = app.write('src/a.tsx', box('red')) + app.write('src/b.tsx', box('blue')) + const wasm = app.engine() + let next = wasm + const { client } = await started(app, { wasm, createEngine: () => next }) + await client.post('/extract', app.post('src/a.tsx')) + await client.post('/extract', app.post('src/b.tsx')) + + rmSync(a) + const failed = await client.get('/css?fileNum=1') + next = app.engine() + const recovered = await client.get('/css?fileNum=1') + + expect(failed.status).toBe(500) + expect(JSON.parse(failed.body).error).toContain( + 'createEngine returned the engine in service', + ) + expect(recovered.status).toBe(200) + expect(recovered.body).toContain('background:blue') + }) + + it('fails the request that replays a broken input, naming the file, and recovers when it is re-sent', async () => { + const app = createTestApp() + const gone = app.write('src/gone.tsx', box('red')) + app.write('src/dep.tsx', box('blue')) + const { client } = await started(app, { + createEngine: () => { + const engine = app.engine() + return { + ...engine, + codeExtract: (filename, code, ...rest) => { + if (code.includes('blue')) throw new Error('dependency vanished') + return engine.codeExtract(filename, code, ...rest) + }, + } + }, + }) + await client.post('/extract', app.post('src/gone.tsx')) + await client.post('/extract', app.post('src/dep.tsx')) + + rmSync(gone) + const failed = await client.get('/css') + const resent = await client.post( + '/extract', + app.post('src/dep.tsx', box('teal')), + ) + + expect(failed.status).toBe(500) + expect(JSON.parse(failed.body).error).toMatch( + /^src\/dep\.tsx:1:1: devup-ui coordinator cannot rebuild the CSS state after a source or theme change: dependency vanished\. Fix: /, + ) + expect(resent.status).toBe(200) + expect((await client.get('/css?fileNum=1')).body).toContain( + 'background:teal', + ) + }) + + it('does not put a rebuilt engine in service until its state is on disk', async () => { + const app = createTestApp() + const a = app.write('src/a.tsx', box('red')) + app.write('src/b.tsx', box('blue')) + let engines = 0 + const { client } = await started(app, { + createEngine: () => { + engines += 1 + return app.engine() + }, + }) + await client.post('/extract', app.post('src/a.tsx')) + await client.post('/extract', app.post('src/b.tsx')) + await flushCoordinatorWrites() + rmSync(stateOf(app)) + mkdirSync(stateOf(app)) + + rmSync(a) + const blocked = await client.get('/css?fileNum=1') + const stillBlocked = await client.get('/css?fileNum=1') + rmSync(stateOf(app), { recursive: true }) + const recovered = await client.get('/css?fileNum=0') + + expect(blocked.status).toBe(500) + expect(stillBlocked.status).toBe(500) + expect(recovered.status).toBe(200) + expect(recovered.body).not.toContain('background:red') + expect(engines).toBe(3) + }) +}) + +describe('resuming from the checkpoint', () => { + it('replays only the files that still exist and keeps every number handed out', async () => { + const app = createTestApp() + const a = app.write('src/a.tsx', globalBox('red', 7)) + app.write('src/b.tsx', box('blue')) + const first = await started(app) + await first.client.post('/extract', app.post('src/a.tsx')) + await first.client.post('/extract', app.post('src/b.tsx')) + await first.handle.drain() + rmSync(a) + + const second = await started(app) + const css = await second.client.get('/css') + const fresh = JSON.parse( + ( + await second.client.post( + '/extract', + app.post('src/c.tsx', box('green')), + ) + ).body, + ) + + expect(css.body).not.toContain('margin:28px') + expect((await second.client.get('/css?fileNum=1')).body).toContain( + 'background:blue', + ) + expect(fresh.cssFile).toEndWith('devup-ui-2.css') + }) + + it('leaves out a file that changed while it was down; its loader sends it again', async () => { + const app = createTestApp() + const a = app.write('src/a.tsx', box('red')) + app.write('src/b.tsx', box('blue')) + const first = await started(app) + await first.client.post('/extract', app.post('src/a.tsx')) + await first.client.post('/extract', app.post('src/b.tsx')) + await first.handle.drain() + writeFileSync(a, box('orange')) + + const second = await started(app) + + expect((await second.client.get('/css?fileNum=0')).body).not.toContain( + 'background', + ) + expect((await second.client.get('/css?fileNum=1')).body).toContain( + 'background:blue', + ) + const resent = JSON.parse( + (await second.client.post('/extract', app.post('src/a.tsx'))).body, + ) + expect(resent.cssFile).toEndWith('devup-ui-0.css') + expect((await second.client.get('/css?fileNum=0')).body).toContain( + 'background:orange', + ) + }) + + it('starts cold when the checkpoint belongs to other options', async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + const first = await started(app, { optionsKey: 'one' }) + await first.client.post('/extract', app.post('src/a.tsx')) + await first.handle.drain() + + const second = await started(app, { optionsKey: 'two' }) + const fresh = JSON.parse( + (await second.client.post('/extract', app.post('src/b.tsx', box('blue')))) + .body, + ) + + expect((await second.client.get('/css?fileNum=0')).body).not.toContain( + 'background:red', + ) + expect(fresh.cssFile).toEndWith('devup-ui-0.css') + }) + + it('refuses a corrupt checkpoint at startup with a located error', () => { + const app = createTestApp() + writeFileSync(stateOf(app), '{"version":1}') + + expect(() => startCoordinator(dev(app))).toThrow( + /state\.json:1:1: devup-ui coordinator checkpoint cannot use `snapshot` at build time: is corrupt: expected optionsKey string\. Fix: delete this file/, + ) + expect(existsSync(app.portFile)).toBe(false) + }) + + it('refuses an unreadable theme chain at startup', () => { + const app = createTestApp() + app.write('devup.json', '{nope') + + expect(() => + startCoordinator(dev(app, { devupFile: 'devup.json' })), + ).toThrow(/devup\.json:1:1: devup config cannot use `JSON` at build time/) + }) +}) + +describe('theme changes', () => { + const themed = (color: string) => ({ + theme: { colors: { default: { primary: color } } }, + }) + + it('applies the current theme when devup.json or what it extends changes', async () => { + const app = createTestApp() + app.write('base.json', JSON.stringify(themed('#f00'))) + app.write('devup.json', JSON.stringify({ extends: ['./base.json'] })) + app.write( + 'src/a.tsx', + 'import { Box } from \'@devup-ui/react\'\nexport const A = () => \n', + ) + const wasm = app.engine() + wasm.registerTheme(themed('#f00').theme) + const configured: DevupWasm[] = [] + const { client } = await started(app, { + wasm, + devupFile: 'devup.json', + configureWasm: (engine) => configured.push(engine), + }) + + const reply = JSON.parse( + (await client.post('/extract', app.post('src/a.tsx'))).body, + ) + const first = (await client.get('/css')).body + writeFileSync(join(app.root, 'base.json'), JSON.stringify(themed('#00f'))) + const second = (await client.get('/css')).body + const again = JSON.parse( + (await client.post('/extract', app.post('src/a.tsx'))).body, + ) + + expect(reply.dependencies).toEqual([ + join(app.root, 'devup.json'), + join(app.root, 'base.json'), + ]) + expect(first).toContain('--primary:#F00') + expect(second).toContain('--primary:#00F') + expect(second).not.toContain('#F00') + expect(again.code).toBe(reply.code) + expect(configured).toHaveLength(1) + expect(configured[0]).not.toBe(wasm) + }) + + it('refuses to serve while the theme chain is unreadable, then recovers', async () => { + const app = createTestApp() + const config = app.write('devup.json', JSON.stringify(themed('#f00'))) + const { client } = await started(app, { devupFile: 'devup.json' }) + + writeFileSync(config, '{nope') + const broken = await client.get('/css') + writeFileSync(config, JSON.stringify(themed('#0f0'))) + const fixed = await client.get('/css') + + expect(broken.status).toBe(500) + expect(JSON.parse(broken.body).error).toContain( + 'devup.json:1:1: devup config cannot use `JSON` at build time', + ) + expect(fixed.status).toBe(200) + }) +}) + +describe('identical transforms', () => { + async function counted( + app: TestApp, + extra: Partial = {}, + ) { + const { engine, extractions } = instrument(app.engine()) + const { client } = await started(app, { + wasm: engine, + createEngine: () => instrument(app.engine(), extractions).engine, + ...extra, + }) + return { client, extractions } + } + + it("runs the engine once for the same source from Next's server and client graphs", async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + const { client, extractions } = await counted(app) + + const replies = await Promise.all([ + client.post('/extract', app.post('src/a.tsx')), + client.post('/extract', app.post('src/a.tsx')), + ]) + await client.post('/extract', app.post('src/a.tsx')) + + expect(replies[0]?.body).toBe(replies[1]?.body) + expect(extractions).toEqual(['map:src/a.tsx']) + }) + + it('runs the engine again once the source differs from what it holds, even if it was seen before', async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + const { client, extractions } = await counted(app) + + await client.post('/extract', app.post('src/a.tsx')) + await client.post('/extract', app.post('src/a.tsx', box('blue'))) + await client.post('/extract', app.post('src/a.tsx')) + + expect(extractions).toHaveLength(3) + expect((await client.get('/css?fileNum=0')).body).toContain( + 'background:red', + ) + }) + + it('validates what the source imported before reusing its output', async () => { + const app = createTestApp() + const tokens = app.write('src/tokens.ts', "export const color = 'red'\n") + app.write( + 'src/dep.tsx', + "import { Box } from '@devup-ui/react'\nimport { color } from './tokens'\nexport const D = () => \n", + ) + const { client, extractions } = await counted(app) + const first = JSON.parse( + (await client.post('/extract', app.post('src/dep.tsx'))).body, + ) + + writeFileSync(tokens, "export const color = 'red'\n") + await client.post('/extract', app.post('src/dep.tsx')) + const unchanged = extractions.length + writeFileSync(tokens, "export const color = 'blue'\n") + await client.post('/extract', app.post('src/dep.tsx')) + + expect(first.dependencies).toEqual(['src/tokens.ts']) + expect(unchanged).toBe(1) + expect(extractions).toHaveLength(2) + expect((await client.get('/css?fileNum=0')).body).toContain( + 'background:blue', + ) + }) + + it('bounds the cache and re-extracts what it dropped', async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + app.write('src/b.tsx', box('blue')) + const { client, extractions } = await counted(app, { cacheMaxEntries: 1 }) + + await client.post('/extract', app.post('src/a.tsx')) + await client.post('/extract', app.post('src/b.tsx')) + await client.post('/extract', app.post('src/b.tsx')) + await client.post('/extract', app.post('src/a.tsx')) + + expect(extractions).toEqual([ + 'map:src/a.tsx', + 'map:src/b.tsx', + 'map:src/a.tsx', + ]) + }) + + it('starts over after a rebuild because the new engine holds nothing yet', async () => { + const app = createTestApp() + const gone = app.write('src/gone.tsx', box('red')) + app.write('src/a.tsx', box('blue')) + const { client, extractions } = await counted(app) + await client.post('/extract', app.post('src/gone.tsx')) + await client.post('/extract', app.post('src/a.tsx')) + + rmSync(gone) + await client.get('/css') + await client.post('/extract', app.post('src/a.tsx')) + + expect( + extractions.filter((entry) => entry === 'map:src/a.tsx'), + ).toHaveLength(3) + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-http.test.ts b/packages/next-plugin/src/__tests__/coordinator-http.test.ts new file mode 100644 index 000000000..cc36cd5f7 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-http.test.ts @@ -0,0 +1,123 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { IncomingMessage } from 'node:http' +import { Socket } from 'node:net' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { startCoordinator } from '../coordinator' +import { requestCoordinator } from '../coordinator-client' +import { assertOwnership, HttpError } from '../coordinator-http' +import { parsePortFile } from '../coordinator-port' +import { + createTestApp, + http, + ownership, + removeTestApps, +} from './coordinator-app' + +function unicodeCoordinator() { + const app = createTestApp() + const root = join(app.root, '테스트-app') + mkdirSync(root) + const identity = { ...app.identity, project: root } + const handle = startCoordinator( + app.options({ projectRoot: root, identity, singleCss: true, watch: true }), + ) + const operation = { + portFile: app.portFile, + identity, + resourcePath: join(root, 'App.tsx'), + path: '/css', + timeoutMs: 2000, + } + return { handle, operation } +} + +afterEach(removeTestApps) + +describe('encoded coordinator HTTP ownership', () => { + it('extracts native classes matching CSS when the real coordinator root is Unicode', async () => { + const { handle, operation } = unicodeCoordinator() + try { + await handle.ready + const code = + 'import { Box } from "@devup-ui/react"; export const App = ' + writeFileSync(operation.resourcePath, code) + + const extracted = await requestCoordinator({ + ...operation, + path: '/extract', + method: 'POST', + body: JSON.stringify({ + filename: 'App.tsx', + resourcePath: operation.resourcePath, + code, + }), + }) + const css = await requestCoordinator(operation) + + expect(extracted).toContain(' { + const { handle, operation } = unicodeCoordinator() + try { + await handle.ready + + const health = await requestCoordinator({ ...operation, path: '/health' }) + + expect(parsePortFile(health).project).toBe(operation.identity.project) + } finally { + await handle.drain() + } + }) + it.each(['project', 'token', 'double-encoded project'] as const)( + 'rejects %s ownership when the real coordinator root is Unicode', + async (field) => { + const { handle, operation } = unicodeCoordinator() + try { + await handle.ready + const endpoint = parsePortFile( + await requestCoordinator({ ...operation, path: '/health' }), + ) + const headers = { + ...ownership(operation.identity), + ...{ + project: { + 'x-devup-project': encodeURIComponent( + join(operation.identity.project, 'other'), + ), + }, + token: { 'x-devup-token': 'wrong-token' }, + 'double-encoded project': { + 'x-devup-project': encodeURIComponent( + encodeURIComponent(operation.identity.project), + ), + }, + }[field], + } + + const reply = await http(endpoint.port, 'GET', '/css', { headers }) + + expect(reply.status).toBe(403) + } finally { + await handle.drain() + } + }, + ) + it('rejects raw project headers instead of accepting a legacy fallback', () => { + const req = new IncomingMessage(new Socket()) + const identity = { project: '/app', token: 'token' } + req.headers = { 'x-devup-project': '/app', 'x-devup-token': 'token' } + + expect(() => assertOwnership(req, '/css', identity, true)).toThrow( + HttpError, + ) + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-port.test.ts b/packages/next-plugin/src/__tests__/coordinator-port.test.ts new file mode 100644 index 000000000..94a15e59a --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-port.test.ts @@ -0,0 +1,187 @@ +import { randomUUID } from 'node:crypto' +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +import { + foreignLiveOwner, + formatPortFile, + isConnectionError, + isProcessAlive, + missingPortFileError, + parsePortFile, + publishPortFile, + readOwner, + removeOwnPortFile, + removeStalePortFile, + resolveCoordinatorPortFile, + unreachableCoordinatorError, +} from '../coordinator-port' + +const dirs: string[] = [] +const identity = { project: resolve('.'), token: randomUUID() } +const deadPid = 2 ** 31 - 2 +function fixture() { + const dir = fs.mkdtempSync(join(tmpdir(), 'devup-port-')) + dirs.push(dir) + return join(dir, 'coordinator.port') +} +afterEach(() => { + for (const dir of dirs.splice(0)) + fs.rmSync(dir, { recursive: true, force: true }) +}) + +describe('coordinator descriptors', () => { + it('roundtrips strict ownership when formatted', () => { + const text = formatPortFile(4321, 99, identity) + expect(parsePortFile(text)).toEqual({ + version: 1, + port: 4321, + pid: 99, + ...identity, + }) + expect(parsePortFile(formatPortFile(1)).pid).toBe(process.pid) + }) + it.each([ + { version: 2 }, + { port: 0 }, + { port: 65536 }, + { port: 1.5 }, + { port: '1' }, + { pid: 0 }, + { pid: 1.5 }, + { pid: '1' }, + { project: '' }, + { project: '.' }, + { project: `${resolve('.')}/..` }, + { token: 'bad' }, + ])('rejects malformed ownership %j', (override) => { + expect(() => + parsePortFile( + JSON.stringify({ + version: 1, + port: 1, + pid: 1, + ...identity, + ...override, + }), + ), + ).toThrow() + }) + it.each([ + 'null', + '[]', + '{}', + '1234', + '{', + ' '.repeat(4097), + JSON.stringify({ + version: 1, + port: 1, + pid: 1, + ...identity, + padding: '한'.repeat(1400), + }), + ])('rejects invalid or oversized metadata', (text) => { + expect(() => parsePortFile(text)).toThrow() + }) + it('atomically replaces a descriptor when published', () => { + const file = fixture() + fs.writeFileSync(file, 'old') + const info = parsePortFile(formatPortFile(1234, process.pid, identity)) + publishPortFile(file, info) + expect(readOwner(file)).toEqual(info) + expect(fs.readdirSync(join(file, '..'))).toEqual(['coordinator.port']) + }) + it('retains previous ownership and cleans temporary files when rename fails', () => { + const file = fixture() + fs.writeFileSync(file, 'old') + const rename = spyOn(fs, 'renameSync').mockImplementation(() => { + throw new Error('rename failed') + }) + try { + expect(() => + publishPortFile(file, parsePortFile(formatPortFile(1))), + ).toThrow('rename failed') + } finally { + rename.mockRestore() + } + expect(fs.readFileSync(file, 'utf-8')).toBe('old') + expect(fs.readdirSync(join(file, '..'))).toEqual(['coordinator.port']) + }) + it('preserves foreign and malformed files when removing own ownership', () => { + const file = fixture() + fs.writeFileSync(file, formatPortFile(1, process.ppid, identity)) + removeOwnPortFile(file, identity) + removeStalePortFile(file) + expect(foreignLiveOwner(file)).toBe(process.ppid) + expect(resolveCoordinatorPortFile(join(file, '..'))).toBe( + join(file, '..', `coordinator.${process.pid}.port`), + ) + fs.writeFileSync(file, 'broken') + removeOwnPortFile(file) + expect(fs.readFileSync(file, 'utf-8')).toBe('broken') + }) + it('removes only the current matching identity when closing', () => { + const file = fixture() + fs.writeFileSync(file, formatPortFile(1, process.pid, identity)) + removeOwnPortFile(file, { ...identity, token: randomUUID() }) + expect(readOwner(file)?.token).toBe(identity.token) + removeOwnPortFile(file, { ...identity, project: resolve('other') }) + expect(readOwner(file)?.project).toBe(identity.project) + removeOwnPortFile(file, identity) + expect(readOwner(file)).toBeUndefined() + removeStalePortFile(file) + }) + it('uses shared paths when ownership is absent, dead or ours', () => { + const file = fixture() + expect(resolveCoordinatorPortFile(join(file, '..'))).toBe(file) + for (const pid of [deadPid, process.pid]) { + fs.writeFileSync(file, formatPortFile(1, pid)) + expect(resolveCoordinatorPortFile(join(file, '..'))).toBe(file) + } + removeStalePortFile(file) + expect(readOwner(file)).toBeUndefined() + }) + it('treats permission-denied process probes as alive', () => { + const kill = spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + }) + try { + expect(isProcessAlive(99)).toBe(true) + } finally { + kill.mockRestore() + } + }) + it('reports endpoint ownership and recovery for unreachable endpoints', () => { + const file = fixture() + expect(missingPortFileError(file).message).toContain(file) + expect(unreachableCoordinatorError(file, 'failed').message).toContain( + 'unknown owner', + ) + for (const pid of [deadPid, process.pid]) { + fs.writeFileSync(file, formatPortFile(4321, pid)) + expect( + unreachableCoordinatorError(file, new Error('failed')).message, + ).toContain(`owner pid ${pid}`) + } + }) + it('recognizes connection codes without untyped assertions', () => { + for (const error of [ + { code: 'ECONNREFUSED' }, + { errors: [{ code: 'ETIMEDOUT' }] }, + ]) + expect(isConnectionError(error)).toBe(true) + for (const error of [ + null, + undefined, + 'oops', + {}, + { errors: [] }, + new Error('oops'), + ]) + expect(isConnectionError(error)).toBe(false) + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-preparation.test.ts b/packages/next-plugin/src/__tests__/coordinator-preparation.test.ts new file mode 100644 index 000000000..8ed5a6771 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-preparation.test.ts @@ -0,0 +1,188 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +import { CoordinatorShutdownError } from '../coordinator-completion' +import type { CoordinatorOptions } from '../coordinator-options' +import { + createPreparation, + reportBackgroundError, +} from '../coordinator-preparation' +import { createTestApp, failure, removeTestApps } from './coordinator-app' + +afterEach(removeTestApps) + +describe('preparation outcomes', () => { + it('starts once and retains the ready Core for prepared callers', async () => { + // Given complete prepared options and a pending startup outcome. + const app = createTestApp() + const preparation = createPreparation(app.options(), app.identity) + expect(preparation.state).toEqual({ status: 'pending' }) + + // When startup is requested twice. + preparation.start() + preparation.start() + const core = await preparation.wait() + + // Then both callers receive the same Core and close gates later work. + expect(preparation.state).toEqual({ status: 'ready', core }) + expect(await preparation.wait()).toBe(core) + preparation.close() + preparation.close() + expect(await failure(preparation.wait())).toBeInstanceOf( + CoordinatorShutdownError, + ) + await preparation.flush() + }) + + it('settles cancellation before startup and never invokes the callback', async () => { + // Given preparation that has not started. + const app = createTestApp() + let calls = 0 + const preparation = createPreparation( + { + projectRoot: app.root, + identity: app.identity, + coordinatorPortFile: app.portFile, + prepare: async () => { + calls += 1 + return app.options() + }, + }, + app.identity, + ) + + // When cancellation wins startup. + preparation.close() + preparation.start() + + // Then waiters receive cancellation without invoking the callback. + expect(preparation.state.status).toBe('cancelled') + expect(await failure(preparation.wait())).toBeInstanceOf( + CoordinatorShutdownError, + ) + expect(calls).toBe(0) + await preparation.flush() + }) + + it('normalizes a non-Error rejection into the retained failed outcome', async () => { + // Given a compiler callback rejecting with a string. + const app = createTestApp() + const preparation = createPreparation( + { + projectRoot: app.root, + identity: app.identity, + coordinatorPortFile: app.portFile, + prepare: () => Promise.reject('compiler stopped'), + }, + app.identity, + ) + + // When preparation runs that callback. + preparation.start() + const error = await failure(preparation.wait()) + + // Then failure is retained as a typed Error, including after close. + if (!(error instanceof Error)) throw error + expect(error).toBeInstanceOf(Error) + expect(String(error)).toContain('compiler stopped') + expect(preparation.state).toEqual({ status: 'failed', error }) + preparation.close() + expect(await failure(preparation.wait())).toBe(error) + }) + + it.each(['projectRoot', 'coordinatorPortFile', 'identity'] as const)( + 'rejects changed %s before constructing or writing Core', + async (field) => { + // Given prepared options replacing one ownership field. + const app = createTestApp() + const stateFile = join(app.root, 'df', 'state.json') + const complete = app.options({ stateFile }) + const changed = { + projectRoot: { ...complete, projectRoot: join(app.root, 'foreign') }, + coordinatorPortFile: { + ...complete, + coordinatorPortFile: join(app.root, 'foreign'), + }, + identity: { + ...complete, + identity: { ...app.identity, token: 'foreign' }, + }, + }[field] + const preparation = createPreparation( + { + projectRoot: app.root, + identity: app.identity, + coordinatorPortFile: app.portFile, + prepare: async () => changed, + }, + app.identity, + ) + + // When preparation receives those options. + preparation.start() + const error = await failure(preparation.wait()) + + // Then ownership validation fails before Core writes. + expect(String(error)).toContain( + 'prepared options changed transport ownership', + ) + expect(existsSync(stateFile)).toBe(false) + preparation.close() + }, + ) + + it('bounds a hung preparation and prevents late construction after its deadline', async () => { + // Given a callback that ignores its bounded cancellation signal. + const app = createTestApp() + const pending = Promise.withResolvers() + const stateFile = join(app.root, 'df', 'state.json') + const entered = Promise.withResolvers() + const preparation = createPreparation( + { + projectRoot: app.root, + identity: app.identity, + coordinatorPortFile: app.portFile, + maxPrepareMs: 1, + prepare: (signal) => { + entered.resolve(signal) + return pending.promise + }, + }, + app.identity, + ) + + // When its preparation deadline expires. + preparation.start() + const error = await failure(preparation.wait()) + + // Then the retained deadline failure also prevents late Core construction. + if (!(error instanceof Error)) throw error + expect(preparation.state).toEqual({ status: 'failed', error }) + expect((await entered.promise).aborted).toBe(true) + expect(String(error)).toContain('preparation exceeded its time budget') + pending.resolve(app.options({ stateFile })) + await pending.promise + await preparation.flush() + expect(existsSync(stateFile)).toBe(false) + preparation.close() + }) + + it('reports non-Error background failures without dropping their diagnostic', () => { + // Given a non-Error diagnostic at the reporting boundary. + const reporting = spyOn(console, 'error') + try { + // When it is reported through the existing project logger. + reportBackgroundError('compiler diagnostic') + + // Then the diagnostic reaches that logger unchanged. + expect(reporting).toHaveBeenCalledWith( + '[devup-ui]', + 'compiler diagnostic', + ) + } finally { + reporting.mockRestore() + } + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-prepared-cancel.test.ts b/packages/next-plugin/src/__tests__/coordinator-prepared-cancel.test.ts new file mode 100644 index 000000000..841a617e4 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-prepared-cancel.test.ts @@ -0,0 +1,166 @@ +import { existsSync, readFileSync } from 'node:fs' +import * as fsp from 'node:fs/promises' +import { join } from 'node:path' + +import { afterEach, expect, it, spyOn } from 'bun:test' + +import { CoordinatorShutdownError } from '../coordinator-completion' +import { createCore } from '../coordinator-core' +import type { PreparedSourceGeneration } from '../coordinator-options' +import { readCoordinatorState } from '../state' +import { createTestApp, failure, removeTestApps } from './coordinator-app' +import { + box, + compiledSource, + cssQuery, + generation, + preparedCore, + prewarmed, +} from './coordinator-prepared-fixture' + +afterEach(removeTestApps) + +it('joins a late generation after owner cancellation without replay or publication', async () => { + // Given a required refresh whose controlled provider ignores cancellation until released. + const app = createTestApp() + app.write('src/page.mdx', '# old') + const entered = Promise.withResolvers() + const pending = Promise.withResolvers() + const prepared = await preparedCore(app, { + generation: generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('red') }), + ]), + prepareReplay: ({ signal }) => { + entered.resolve(signal) + return pending.promise + }, + }) + const before = readFileSync(prepared.options.stateFile ?? '', 'utf8') + const refreshing = failure(prepared.core.css(cssQuery)) + const signal = await entered.promise + let joined = false + + // When the owner closes before the late provider resolves. + prepared.core.close() + const flush = prepared.core.flush().then(() => { + joined = true + }) + await Promise.resolve() + expect(joined).toBe(false) + pending.resolve( + generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('green') }), + ]), + ) + + // Then shutdown joins the provider, but late compiled bytes never initialize a candidate or publish. + expect(await refreshing).toBeInstanceOf(CoordinatorShutdownError) + await flush + expect(signal.aborted).toBe(true) + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe(before) + expect(prepared.extractions).toEqual(['map:src/page.mdx']) +}) + +it('prevents prepared startup writes when the owner closes before its queued initialization', async () => { + // Given a fully prepared engine but no started Core mutation. + const app = createTestApp() + const prepared = prewarmed(app, { generation: generation([]) }) + const core = createCore(prepared.options, app.root) + + // When close wins the startup mutation queue. + const startup = failure(core.startup()) + core.close() + + // Then late initialization is rejected and cannot create a checkpoint. + expect(await startup).toBeInstanceOf(CoordinatorShutdownError) + await core.flush() + expect(existsSync(prepared.options.stateFile ?? '')).toBe(false) +}) + +it('drains writes already accepted before cancellation', async () => { + // Given an accepted ordinary extraction paused at the real atomic rename boundary. + const app = createTestApp() + const prepared = await preparedCore(app, { generation: generation([]) }) + const entered = Promise.withResolvers() + const released = Promise.withResolvers() + const rename = fsp.rename + const observed = spyOn(fsp, 'rename').mockImplementation(async (from, to) => { + if (to === prepared.options.stateFile) { + entered.resolve() + await released.promise + } + return rename(from, to) + }) + try { + const accepted = prepared.core.extract({ + filename: 'ordinary.tsx', + resourcePath: join(app.root, 'ordinary.tsx'), + code: box('blue'), + }) + await entered.promise + let drained = false + + // When the owner closes while the already accepted write is pending. + prepared.core.close() + const flush = prepared.core.flush().then(() => { + drained = true + }) + await Promise.resolve() + expect(drained).toBe(false) + released.resolve() + + // Then accepted writes still finish and drain records their exact input. + expect((await accepted).code).toContain('className') + await flush + expect( + readCoordinatorState(prepared.options.stateFile ?? '', '')?.inputs[0] + ?.source, + ).toBe(box('blue')) + } finally { + observed.mockRestore() + } +}) + +it('cancels live publication while an already submitted candidate write drains', async () => { + // Given a candidate replay paused at its accepted checkpoint write. + const app = createTestApp() + app.write('src/page.mdx', '# raw') + const next = generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('green') }), + ]) + const prepared = await preparedCore(app, { + generation: generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('red') }), + ]), + prepareReplay: async () => next, + }) + const entered = Promise.withResolvers() + const released = Promise.withResolvers() + const rename = fsp.rename + const observed = spyOn(fsp, 'rename').mockImplementation(async (from, to) => { + if (to === prepared.options.stateFile) { + entered.resolve() + await released.promise + } + return rename(from, to) + }) + try { + const refreshing = failure(prepared.core.css(cssQuery)) + await entered.promise + + // When cancellation wins candidate publication, but not its already accepted disk write. + prepared.core.close() + released.resolve() + + // Then the write drains, the request rejects, and the original serving engine is unchanged. + expect(await refreshing).toBeInstanceOf(CoordinatorShutdownError) + await prepared.core.flush() + expect(prepared.wasm.getCss(0, false)).toContain('background:red') + expect( + readCoordinatorState(prepared.options.stateFile ?? '', '')?.inputs[0] + ?.source, + ).toBe(box('green')) + } finally { + observed.mockRestore() + } +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-prepared-durability.test.ts b/packages/next-plugin/src/__tests__/coordinator-prepared-durability.test.ts new file mode 100644 index 000000000..17849cb9f --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-prepared-durability.test.ts @@ -0,0 +1,163 @@ +import { readFileSync } from 'node:fs' +import * as fsp from 'node:fs/promises' + +import { afterEach, expect, it, spyOn } from 'bun:test' + +import { + type CoordinatorInstance, + createInstance, +} from '../coordinator-instance' +import type { PreparedSourceGeneration } from '../coordinator-options' +import { readCoordinatorState } from '../state' +import { + connect, + createTestApp, + failure, + removeTestApps, +} from './coordinator-app' +import { + box, + compiledSource, + cssQuery, + generation, + preparedCore, + prewarmed, +} from './coordinator-prepared-fixture' + +const instances: CoordinatorInstance[] = [] +afterEach(() => { + for (const instance of instances.splice(0)) instance.close() + removeTestApps() +}) + +it.each(['stateFile', 'revisionFile'] as const)( + 'keeps prior durable/live state on %s candidate failure and retries with one revision', + async (destination) => { + // Given a durable prepared app and an unrelated live HTTP app. + const app = createTestApp() + app.write('src/page.mdx', '# old') + const seen: string[] = [] + const staged: { generation?: PreparedSourceGeneration } = {} + const prepared = prewarmed(app, { + generation: generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('red') }), + ]), + prepareReplay: async ({ generation: current }) => { + seen.push(current.sources[0]?.input.source ?? '') + return current.sources[0]?.input.source === box('green') + ? current + : (staged.generation ?? current) + }, + }) + const instance = createInstance(prepared.options) + instances.push(instance) + await instance.ready + const client = connect(app.portFile, app.identity) + const other = createTestApp() + const otherInstance = createInstance(other.options({ watch: true })) + instances.push(otherInstance) + await otherInstance.ready + const otherClient = connect(other.portFile, other.identity) + await otherClient.post('/extract', other.post('ordinary.tsx', box('blue'))) + const stateFile = prepared.options.stateFile ?? '' + const revisionFile = prepared.options.revisionFile ?? '' + const checkpoint = readFileSync(stateFile, 'utf8') + const revision = readFileSync(revisionFile, 'utf8') + const target = prepared.options[destination] + const rename = fsp.rename + let blocked = true + const observed = spyOn(fsp, 'rename').mockImplementation( + async (from, to) => { + if (to === target && blocked) + throw new Error('controlled candidate durability failure') + return rename(from, to) + }, + ) + app.write('src/page.mdx', '# new') + staged.generation = generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('green') }), + ]) + try { + // When required replay encounters repeatable disk/revision publication failures. + const failed = await client.get('/css?fileNum=0') + const failedAgain = await client.get('/css?fileNum=0') + + // Then neither late CSS success nor partial live publication can escape the failure. + expect(failed.status).toBe(500) + expect(failedAgain.status).toBe(500) + expect(readFileSync(stateFile, 'utf8')).toBe(checkpoint) + expect(readFileSync(revisionFile, 'utf8')).toBe(revision) + expect(prepared.wasm.getCss(0, false)).toContain('background:red') + expect(await failure(instance.flush())).toBeInstanceOf(Error) + expect((await otherClient.get('/css?fileNum=0')).body).toContain( + 'background:blue', + ) + blocked = false + const recovered = await client.get('/css?fileNum=0') + expect(recovered.status).toBe(200) + expect(recovered.body).toContain('background:green') + expect(readCoordinatorState(stateFile, '')?.revision).toBe(8) + expect(readFileSync(revisionFile, 'utf8')).toBe('8') + expect(seen).toEqual([box('red'), box('red'), box('red')]) + await instance.drain() + await otherInstance.drain() + } finally { + observed.mockRestore() + } + }, +) + +it.each(['css', 'extract', 'watch'] as const)( + 'blocks %s on required preparation failure while durable state stays recoverable', + async (operation) => { + // Given a provider retaining the original located compiler failure. + const app = createTestApp() + app.write('src/page.mdx', '# old') + const cause = new Error( + 'src/page.mdx:4:7: controlled required preparation failure', + ) + let blocked = true + const staged: { generation?: PreparedSourceGeneration } = {} + const prepared = await preparedCore(app, { + generation: generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('red') }), + ]), + prepareReplay: async ({ generation: current }) => { + if (blocked) throw cause + return current.sources[0]?.input.source === box('green') + ? current + : (staged.generation ?? current) + }, + }) + const before = readFileSync(prepared.options.stateFile ?? '', 'utf8') + app.write('src/page.mdx', '# new') + staged.generation = generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('green') }), + ]) + + // When a queued CSS/extract/watch operation needs that failing refresh. + const pending = + operation === 'css' + ? prepared.core.css(cssQuery) + : operation === 'watch' + ? prepared.core.reconcile() + : prepared.core.extract({ + filename: 'ordinary.tsx', + resourcePath: `${app.root}/ordinary.tsx`, + code: box('purple'), + }) + + // Then the original failure blocks work without destroying the durable predecessor; correction recovers. + expect(await failure(pending)).toBe(cause) + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe(before) + expect(prepared.wasm.getCss(0, false)).toContain('background:red') + blocked = false + expect((await prepared.core.css(cssQuery)).css).toContain( + 'background:green', + ) + expect( + readCoordinatorState(prepared.options.stateFile ?? '', '')?.revision, + ).toBe(8) + prepared.core.close() + }, +) diff --git a/packages/next-plugin/src/__tests__/coordinator-prepared-fixture.ts b/packages/next-plugin/src/__tests__/coordinator-prepared-fixture.ts new file mode 100644 index 000000000..bbbc0cfcb --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-prepared-fixture.ts @@ -0,0 +1,141 @@ +import { join } from 'node:path' + +import { createCore } from '../coordinator-core' +import { extractInput } from '../coordinator-engine' +import { createInput, orderInputs, stampFile } from '../coordinator-ledger' +import type { + CoordinatorOptions, + PreparedSource, + PreparedSourceGeneration, + PreparedSources, + PrewarmedOutput, +} from '../coordinator-options' +import { + type CoordinatorInput, + type CoordinatorSnapshot, + importAllocatorState, +} from '../state' +import type { TestApp } from './coordinator-app' +import { instrument } from './coordinator-app' + +export const box = (color: string) => + `import { Box } from '@devup-ui/react'; export const Page = () => ` + +export const cssQuery = { + fileNum: 0, + importMainCss: false, + wait: true, +} as const + +export function compiledSource( + app: TestApp, + file: { + readonly filename: string + readonly code: string + readonly dependencies?: readonly string[] + }, +): PreparedSource { + const resourcePath = join(app.root, file.filename) + const input = createInput( + app.root, + { ...file, resourcePath }, + file.dependencies ?? [], + ) + return { + input, + evidence: { + compilerFingerprint: 'controlled-provider-v1', + fileFingerprints: { + [resourcePath]: stampFile(resourcePath), + ...input.stamps, + }, + contextFingerprints: {}, + missingDependencies: [], + map: '{"version":3,"sources":["page.mdx"],"names":[],"mappings":""}', + }, + } +} + +export function generation( + sources: readonly PreparedSource[], +): PreparedSourceGeneration { + return { + sources, + configureWasm(wasm) { + // An exact controlled resolver fixture, not a graph/compiler implementation. + wasm.setModuleResolver((specifier: string) => { + const input = sources.find( + ({ input }) => + specifier === './values.mdx' && input.filename === 'src/values.mdx', + )?.input + return input === undefined + ? undefined + : { path: input.filename, code: input.source } + }) + }, + } +} + +export interface PreparedFixture { + readonly generation: PreparedSourceGeneration + readonly ordinaryInputs?: readonly CoordinatorInput[] + readonly checkpoint?: CoordinatorSnapshot + readonly revision?: number + readonly prepareReplay?: PreparedSources['prepareReplay'] +} + +export function prewarmed(app: TestApp, fixture: PreparedFixture) { + const extractions: string[] = [] + const wasm = instrument(app.engine(), extractions).engine + if (fixture.checkpoint !== undefined) + importAllocatorState(wasm, fixture.checkpoint) + fixture.generation.configureWasm(wasm) + const ordinaryInputs = fixture.ordinaryInputs ?? [] + const inputs = orderInputs([ + ...ordinaryInputs, + ...fixture.generation.sources.map(({ input }) => input), + ]) + const outputs = new Map() + const base = app.options({ wasm }) + for (const input of inputs) { + const output = extractInput( + wasm, + { + package: base.package, + cssDir: base.cssDir, + singleCss: base.singleCss, + sourceMap: true, + importAliases: {}, + }, + input, + ) + outputs.set(input.filename, { ...output, source: input.source }) + } + const options: CoordinatorOptions = { + ...base, + watch: true, + stateFile: join(app.root, 'df', 'snapshot.json'), + revisionFile: join(app.root, 'df', 'revision'), + sourceRoots: [], + prewarmedFiles: inputs.map((input) => input.filename), + prewarmedOutputs: outputs, + createEngine: () => instrument(app.engine(), extractions).engine, + preparedSources: { + initial: { + ordinaryInputs, + generation: fixture.generation, + revision: fixture.revision ?? 7, + }, + prepareReplay: + fixture.prepareReplay ?? (async ({ generation }) => generation), + }, + } + return { options, wasm, extractions } +} + +export async function preparedCore(app: TestApp, fixture: PreparedFixture) { + const prepared = prewarmed(app, fixture) + const core = createCore(prepared.options, app.root) + await core.startup() + return { ...prepared, core } +} diff --git a/packages/next-plugin/src/__tests__/coordinator-prepared-generation.test.ts b/packages/next-plugin/src/__tests__/coordinator-prepared-generation.test.ts new file mode 100644 index 000000000..098618d18 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-prepared-generation.test.ts @@ -0,0 +1,225 @@ +import { join } from 'node:path' + +import { afterEach, expect, it } from 'bun:test' + +import { createCore } from '../coordinator-core' +import { createInput } from '../coordinator-ledger' +import type { PreparedSourceGeneration } from '../coordinator-options' +import { readCoordinatorState } from '../state' +import { createTestApp, removeTestApps } from './coordinator-app' +import { + box, + compiledSource, + cssQuery, + generation, + preparedCore, + prewarmed, +} from './coordinator-prepared-fixture' + +afterEach(removeTestApps) + +it('adopts a complete-empty refresh while retaining ordinary inputs', async () => { + // Given a compiled source that the provider no longer reaches and an ordinary survivor. + const app = createTestApp() + app.write('src/page.mdx', '# raw') + const ordinary = createInput( + app.root, + { + filename: 'src/ordinary.tsx', + resourcePath: join(app.root, 'src/ordinary.tsx'), + code: box('blue'), + }, + [], + ) + const prepared = await preparedCore(app, { + generation: generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('red') }), + ]), + ordinaryInputs: [ordinary], + prepareReplay: async ({ generation: current }) => + current.sources.length === 0 ? current : generation([]), + }) + + // When a complete-empty prepared generation is reconciled. + await prepared.core.reconcile() + + // Then old compiled inputs/styles are removed, while ordinary bytes/numbers survive. + const snapshot = readCoordinatorState(prepared.options.stateFile ?? '', '') + expect(snapshot?.inputs.map((input) => input.filename)).toEqual([ + 'src/ordinary.tsx', + ]) + expect( + (await prepared.core.css({ ...cssQuery, fileNum: 1 })).css, + ).not.toContain('background:red') + expect((await prepared.core.css(cssQuery)).css).toContain('background:blue') + expect(snapshot?.revision).toBe(8) + prepared.core.close() +}) + +it('appends newly prepared source numbers without renumbering an earlier path', async () => { + // Given a dev allocator that already numbered a later-sorted source. + const app = createTestApp() + app.write('src/z.mdx', '# existing') + const existing = compiledSource(app, { + filename: 'src/z.mdx', + code: box('red'), + }) + const staged: { generation?: PreparedSourceGeneration } = {} + const prepared = await preparedCore(app, { + generation: generation([existing]), + prepareReplay: async ({ generation: current }) => + current.sources.length === 2 ? current : (staged.generation ?? current), + }) + app.write('src/a.mdx', '# new') + staged.generation = generation([ + compiledSource(app, { filename: 'src/a.mdx', code: box('green') }), + existing, + ]) + + // When the newly reachable, earlier-sorted path enters prepared replay. + await prepared.core.reconcile() + + // Then existing numbers remain and the new source appends to the allocator. + const snapshot = readCoordinatorState(prepared.options.stateFile ?? '', '') + expect(snapshot?.fileMap).toEqual({ 'src/z.mdx': 0, 'src/a.mdx': 1 }) + expect((await prepared.core.css(cssQuery)).css).toContain('background:red') + expect((await prepared.core.css({ ...cssQuery, fileNum: 1 })).css).toContain( + 'background:green', + ) + prepared.core.close() +}) + +it('snapshots immutable prepared evidence instead of retaining mutable provider record data', async () => { + // Given provider records backed by mutable input arrays/records before Core adoption. + const app = createTestApp() + app.write('src/page.mdx', '# raw') + const original = compiledSource(app, { + filename: 'src/page.mdx', + code: box('red'), + }) + const input = { ...original.input } + const sources = [{ ...original, input }] + const observed: boolean[] = [] + const prepared = prewarmed(app, { + generation: generation(sources), + prepareReplay: async ({ generation: current }) => { + const source = current.sources[0] + observed.push( + Object.isFrozen(current), + Object.isFrozen(current.sources), + Object.isFrozen(source?.input), + Object.isFrozen(source?.input.stamps), + Object.isFrozen(source?.evidence.fileFingerprints), + Object.isFrozen(source?.evidence.contextFingerprints), + Object.isFrozen(source?.evidence.missingDependencies), + ) + return current + }, + }) + const core = createCore(prepared.options, app.root) + input.source = box('green') + sources.splice(0) + + // When startup commits and CSS joins the immutable provider generation. + await core.startup() + const css = await core.css(cssQuery) + + // Then mutation of the original data cannot rewrite the adopted input/evidence. + expect(css.css).toContain('background:red') + expect( + readCoordinatorState(prepared.options.stateFile ?? '', '')?.inputs[0] + ?.source, + ).toBe(box('red')) + expect(observed).toEqual([true, true, true, true, true, true, true]) + core.close() +}) + +it('discards stale prewarmed cache entries outside the supplied complete inputs', async () => { + // Given fresh prepared bytes and a stale/foreign initial output cache. + const app = createTestApp() + app.write('src/page.mdx', '# raw') + const prepared = prewarmed(app, { + generation: generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('green') }), + ]), + }) + const outputs = prepared.options.prewarmedOutputs + if (outputs === undefined) throw new Error('fixture outputs missing') + outputs.set('ghost.tsx', { + source: box('red'), + code: 'stale-cache-code', + updatedBaseStyle: false, + }) + outputs.set('src/page.mdx', { + source: box('red'), + code: 'stale-cache-code', + updatedBaseStyle: false, + }) + const core = createCore(prepared.options, app.root) + await core.startup() + + // When the actual compiled source asks for its transform. + const response = await core.extract({ + filename: 'src/page.mdx', + resourcePath: join(app.root, 'src/page.mdx'), + code: box('green'), + }) + + // Then only the complete prepared inputs are durable and stale cached output is never returned. + expect(response.code).not.toBe('stale-cache-code') + expect(response.code).toContain('className') + expect( + readCoordinatorState(prepared.options.stateFile ?? '', '')?.inputs.map( + (input) => input.filename, + ), + ).toEqual(['src/page.mdx']) + core.close() +}) + +it('serializes generation preparation with competing extract work', async () => { + // Given a delayed provider and a second operation queued behind its candidate. + const app = createTestApp() + app.write('src/page.mdx', '# raw') + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + let calls = 0 + const prepared = await preparedCore(app, { + generation: generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('red') }), + ]), + prepareReplay: ({ generation: current }) => { + calls += 1 + if (calls === 1) { + entered.resolve() + return release.promise + } + return Promise.resolve(current) + }, + }) + const css = prepared.core.css(cssQuery) + await entered.promise + const extracting = prepared.core.extract({ + filename: 'ordinary.tsx', + resourcePath: join(app.root, 'ordinary.tsx'), + code: box('blue'), + }) + await Promise.resolve() + expect(calls).toBe(1) + expect(prepared.extractions).toEqual(['map:src/page.mdx']) + + // When the first queued generation finishes preparation. + release.resolve( + generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('green') }), + ]), + ) + + // Then the competing request runs only after fresh replay is durable. + expect((await css).css).toContain('background:green') + expect((await extracting).cssFile).toEndWith('devup-ui-1.css') + expect(calls).toBe(2) + expect( + readCoordinatorState(prepared.options.stateFile ?? '', '')?.revision, + ).toBe(9) + prepared.core.close() +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-prepared-refresh.test.ts b/packages/next-plugin/src/__tests__/coordinator-prepared-refresh.test.ts new file mode 100644 index 000000000..4ca9a9947 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-prepared-refresh.test.ts @@ -0,0 +1,224 @@ +import { readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' + +import { afterEach, expect, it } from 'bun:test' + +import { createCore } from '../coordinator-core' +import { + type CoordinatorInstance, + createInstance, +} from '../coordinator-instance' +import { createInput, stampFile } from '../coordinator-ledger' +import type { PreparedSourceGeneration } from '../coordinator-options' +import { readCoordinatorState } from '../state' +import { + connect, + createTestApp, + failure, + removeTestApps, +} from './coordinator-app' +import { + box, + compiledSource, + cssQuery, + generation, + preparedCore, + prewarmed, +} from './coordinator-prepared-fixture' + +const instances: CoordinatorInstance[] = [] +afterEach(() => { + for (const instance of instances.splice(0)) instance.close() + removeTestApps() +}) + +it.each(['css', 'theme', 'delete', 'watch', 'extract'] as const)( + 'refreshes only dirty compiled inputs before %s work, without native MDX resend', + async (operation) => { + // Given controlled compiler outputs for two MDX files and accepted ordinary sources. + const app = createTestApp() + app.write('src/page.mdx', '# initial raw') + app.write('src/other.mdx', '# untouched raw') + const dependency = app.write('compiler-dependency.txt', 'initial') + const theme = app.write( + 'devup.json', + JSON.stringify({ theme: { colors: { default: { primary: '#f00' } } } }), + ) + const gone = app.write('src/gone.tsx', box('teal')) + const ordinary = ['gone', 'keeper'].map((name) => + createInput( + app.root, + { + filename: `src/${name}.tsx`, + resourcePath: join(app.root, `src/${name}.tsx`), + code: box(name === 'gone' ? 'teal' : 'purple'), + }, + [], + ), + ) + const page = compiledSource(app, { + filename: 'src/page.mdx', + code: box('red'), + dependencies: [dependency], + }) + const other = compiledSource(app, { + filename: 'src/other.mdx', + code: box('pink'), + }) + let staged = page + const refreshed: string[] = [] + const prepared = prewarmed(app, { + generation: generation([page, other]), + ordinaryInputs: ordinary, + prepareReplay: async ({ generation: current }) => { + const dirty = current.sources.filter(({ evidence }) => + Object.entries(evidence.fileFingerprints).some( + ([path, stamp]) => stampFile(path) !== stamp, + ), + ) + if (dirty.length === 0) return current + refreshed.push(...dirty.map(({ input }) => input.filename)) + return generation( + current.sources.map((source) => + source.input.filename === staged.input.filename ? staged : source, + ), + ) + }, + }) + const core = createCore({ ...prepared.options, devupFile: theme }, app.root) + await core.startup() + app.write('src/page.mdx', '# edited raw') + app.write('compiler-dependency.txt', 'edited') + staged = compiledSource(app, { + filename: 'src/page.mdx', + code: box('green'), + dependencies: [dependency], + }) + if (operation === 'theme') + app.write( + 'devup.json', + JSON.stringify({ theme: { colors: { default: { primary: '#00f' } } } }), + ) + if (operation === 'delete') rmSync(gone) + + // When CSS/extraction/watcher reconciliation is requested before the native loader resends MDX. + if (operation === 'watch') await core.reconcile() + else if (operation === 'extract') + await core.extract({ + filename: 'src/new.tsx', + resourcePath: join(app.root, 'src/new.tsx'), + code: box('orange'), + }) + else await core.css({ ...cssQuery, fileNum: 3 }) + + // Then replay uses the fresh bytes, preserves unrelated inputs, and commits one refresh revision. + const snapshot = readCoordinatorState(prepared.options.stateFile ?? '', '') + expect( + snapshot?.inputs.find((input) => input.filename === 'src/page.mdx') + ?.source, + ).toBe(box('green')) + expect( + snapshot?.inputs.find((input) => input.filename === 'src/other.mdx') + ?.source, + ).toBe(box('pink')) + expect( + snapshot?.inputs.find((input) => input.filename === 'src/keeper.tsx') + ?.source, + ).toBe(box('purple')) + expect((await core.css({ ...cssQuery, fileNum: 3 })).css).toContain( + 'background:green', + ) + expect(refreshed).toEqual(['src/page.mdx']) + expect(snapshot?.revision).toBe(operation === 'extract' ? 9 : 8) + if (operation === 'theme') + expect( + (await core.css({ ...cssQuery, fileNum: undefined })).css, + ).toContain('--primary:#00F') + if (operation === 'delete') + expect( + snapshot?.inputs.some((input) => input.filename === 'src/gone.tsx'), + ).toBe(false) + core.close() + }, +) + +it('blocks a native resend from an older compiled generation instead of replacing fresh replay', async () => { + // Given a refreshed durable generation while the native loader still holds old bytes. + const app = createTestApp() + app.write('src/page.mdx', '# raw') + const staged: { generation?: PreparedSourceGeneration } = {} + const prepared = await preparedCore(app, { + generation: generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('red') }), + ]), + prepareReplay: async ({ generation }) => + generation.sources[0]?.input.source === box('green') + ? generation + : (staged.generation ?? generation), + }) + staged.generation = generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('green') }), + ]) + await prepared.core.css(cssQuery) + const before = readFileSync(prepared.options.stateFile ?? '', 'utf8') + + // When a late native request sends the previously accepted bytes. + const error = await failure( + prepared.core.extract({ + filename: 'src/page.mdx', + resourcePath: join(app.root, 'src/page.mdx'), + code: box('red'), + }), + ) + + // Then stale bytes are rejected and the fresh durable input survives. + expect(String(error)).toContain('src/page.mdx:1:1:') + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe(before) + expect((await prepared.core.css(cssQuery)).css).toContain('background:green') + prepared.core.close() +}) + +it('refreshes dirty MDX through a real source watcher without any CSS or extract request', async () => { + // Given a running HTTP coordinator whose watcher will enter a controlled provider. + const app = createTestApp() + app.write('src/page.mdx', '# old') + const entered = Promise.withResolvers() + const old = compiledSource(app, { + filename: 'src/page.mdx', + code: box('red'), + }) + const staged: { generation?: PreparedSourceGeneration } = {} + const prepared = prewarmed(app, { + generation: generation([old]), + prepareReplay: async ({ generation }) => { + entered.resolve() + return generation.sources[0]?.input.source === box('green') + ? generation + : (staged.generation ?? generation) + }, + }) + const instance = createInstance({ + ...prepared.options, + sourceRoots: [join(app.root, 'src')], + }) + instances.push(instance) + await instance.ready + staged.generation = generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('green') }), + ]) + + // When only a source filesystem event occurs. + app.write('src/page.mdx', '# new') + await entered.promise + await instance.flush() + + // Then the watcher alone committed fresh compiled bytes before a native resend. + expect( + readCoordinatorState(prepared.options.stateFile ?? '', '')?.inputs[0] + ?.source, + ).toBe(box('green')) + expect( + (await connect(app.portFile, app.identity).get('/css?fileNum=0')).body, + ).toContain('background:green') + await instance.drain() +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-prepared-resolver.test.ts b/packages/next-plugin/src/__tests__/coordinator-prepared-resolver.test.ts new file mode 100644 index 000000000..204bbadbe --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-prepared-resolver.test.ts @@ -0,0 +1,82 @@ +import { join } from 'node:path' + +import { afterEach, expect, it } from 'bun:test' + +import { createInput } from '../coordinator-ledger' +import type { PreparedSourceGeneration } from '../coordinator-options' +import { readCoordinatorState } from '../state' +import { createTestApp, removeTestApps } from './coordinator-app' +import { + compiledSource, + cssQuery, + generation, + preparedCore, +} from './coordinator-prepared-fixture' + +afterEach(removeTestApps) + +it.each(['css', 'extract'] as const)( + 'uses the refreshed generation resolver for imported compiled MDX values during %s', + async (operation) => { + // Given unchanged Markdown whose controlled compilation depends on an edited plugin input. + const app = createTestApp() + app.write('src/values.mdx', '# not JavaScript') + const dependency = app.write('plugin-input.txt', 'old') + const code = + 'import { Box } from "@devup-ui/react"; import { color } from "./values.mdx"; export const A = ' + const ordinary = createInput( + app.root, + { + filename: 'src/page.tsx', + resourcePath: join(app.root, 'src/page.tsx'), + code, + }, + ['src/values.mdx'], + ) + const old = compiledSource(app, { + filename: 'src/values.mdx', + code: 'export const color = "red"', + dependencies: [dependency], + }) + const staged: { generation?: PreparedSourceGeneration } = {} + const prepared = await preparedCore(app, { + generation: generation([old]), + ordinaryInputs: [ordinary], + prepareReplay: async ({ generation }) => + generation.sources[0]?.input.source === 'export const color = "green"' + ? generation + : (staged.generation ?? generation), + }) + app.write('plugin-input.txt', 'new') + staged.generation = generation([ + compiledSource(app, { + filename: 'src/values.mdx', + code: 'export const color = "green"', + dependencies: [dependency], + }), + ]) + + // When replay or an ordinary importing extraction runs before native MDX delivery. + if (operation === 'css') await prepared.core.css(cssQuery) + else + await prepared.core.extract({ + filename: 'src/new.tsx', + resourcePath: join(app.root, 'src/new.tsx'), + code, + }) + + // Then both replay and importing requests read compiled values from the generation, never raw Markdown. + expect((await prepared.core.css(cssQuery)).css).toContain( + 'background:green', + ) + expect((await prepared.core.css(cssQuery)).css).not.toContain( + 'background:red', + ) + expect( + readCoordinatorState(prepared.options.stateFile ?? '', '')?.inputs.find( + (input) => input.filename === 'src/values.mdx', + )?.source, + ).toBe('export const color = "green"') + prepared.core.close() + }, +) diff --git a/packages/next-plugin/src/__tests__/coordinator-prepared-startup.test.ts b/packages/next-plugin/src/__tests__/coordinator-prepared-startup.test.ts new file mode 100644 index 000000000..fbbd97702 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-prepared-startup.test.ts @@ -0,0 +1,200 @@ +import { readFileSync } from 'node:fs' +import * as fsp from 'node:fs/promises' +import { join } from 'node:path' + +import { afterEach, expect, it, spyOn } from 'bun:test' + +import { createCore } from '../coordinator-core' +import { + type CoordinatorInstance, + createInstance, +} from '../coordinator-instance' +import { createInput } from '../coordinator-ledger' +import { readCoordinatorState } from '../state' +import { connect, createTestApp, removeTestApps } from './coordinator-app' +import { + box, + compiledSource, + cssQuery, + generation, + preparedCore, + prewarmed, +} from './coordinator-prepared-fixture' + +const instances: CoordinatorInstance[] = [] +afterEach(() => { + for (const instance of instances.splice(0)) instance.close() + removeTestApps() +}) + +it.each([false, true])( + 'adopts fresh complete prepared inputs (empty=%s) instead of unchanged backing checkpoint', + async (empty) => { + // Given a durable compiled checkpoint whose raw Markdown is unchanged. + const app = createTestApp() + app.write('src/page.mdx', '# unchanged Markdown') + const first = await preparedCore(app, { + generation: generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('red') }), + ]), + revision: 3, + }) + await first.core.flush() + first.core.close() + const checkpoint = readCoordinatorState(first.options.stateFile ?? '', '') + if (checkpoint === undefined) throw new Error('fixture checkpoint missing') + const next = prewarmed(app, { + checkpoint, + revision: 4, + generation: generation( + empty + ? [] + : [ + compiledSource(app, { + filename: 'src/page.mdx', + code: box('green'), + }), + ], + ), + }) + let writes = 0 + const rename = fsp.rename + const observed = spyOn(fsp, 'rename').mockImplementation( + async (from, to) => { + if (to === next.options.stateFile) writes += 1 + return rename(from, to) + }, + ) + try { + // When a real HTTP coordinator starts from the complete prepared engine. + const instance = createInstance(next.options) + instances.push(instance) + await instance.ready + const reply = await connect(app.portFile, app.identity).get( + '/css?fileNum=0', + ) + + // Then the first stylesheet/checkpoint are fresh, with one startup write and no replay. + expect(reply.status).toBe(200) + expect(reply.body).not.toContain('background:red') + if (!empty) expect(reply.body).toContain('background:green') + const current = readCoordinatorState(next.options.stateFile ?? '', '') + expect(current?.revision).toBe(4) + expect(current?.inputs.map((input) => input.source)).toEqual( + empty ? [] : [box('green')], + ) + expect(current?.fileMap).toEqual(checkpoint.fileMap) + expect(writes).toBe(1) + expect(next.extractions).toEqual(empty ? [] : ['map:src/page.mdx']) + const later = await connect(app.portFile, app.identity).post( + '/extract', + app.post('src/z.tsx', box('blue')), + ) + expect(JSON.parse(later.body).cssFile).toEndWith('devup-ui-1.css') + await instance.drain() + } finally { + observed.mockRestore() + } + }, +) + +it('preserves sorted production prewarm and seals without invoking development preparation', async () => { + // Given a complete prepared generation in reverse input order. + const app = createTestApp() + const sources = ['z', 'a'].map((name) => { + app.write(`src/${name}.mdx`, '# raw Markdown') + return compiledSource(app, { + filename: `src/${name}.mdx`, + code: box(name === 'a' ? 'red' : 'blue'), + }) + }) + const prepared = prewarmed(app, { + generation: generation(sources), + prepareReplay: async () => { + throw new Error('development provider must not run') + }, + }) + const core = createCore({ ...prepared.options, watch: false }, app.root) + await core.startup() + + // When production requests its complete stylesheet and re-extracts the same prepared bytes. + await core.css({ ...cssQuery, fileNum: undefined }) + const output = await core.extract({ + filename: 'src/a.mdx', + resourcePath: join(app.root, 'src/a.mdx'), + code: box('red'), + }) + + // Then the initial path ordering/production cache remains unchanged. + expect(prepared.extractions).toEqual(['map:src/a.mdx', 'map:src/z.mdx']) + expect(output.cssFile).toEndWith('devup-ui-0.css') + await core.flush() + core.close() +}) + +it('does not trust persisted compiled Markdown in standalone checkpoint replay', async () => { + // Given a checkpoint containing ordinary JS and compiled .md/.mdx with unchanged raw backing. + const app = createTestApp() + const sources = ['a.md', 'b.mdx'].map((filename) => { + app.write(filename, '# raw Markdown') + return compiledSource(app, { filename, code: box('red') }) + }) + app.write('ordinary.tsx', box('blue')) + const ordinary = createInput( + app.root, + { + filename: 'ordinary.tsx', + resourcePath: join(app.root, 'ordinary.tsx'), + code: box('blue'), + }, + [], + ) + const first = await preparedCore(app, { + generation: generation(sources), + ordinaryInputs: [ordinary], + }) + await first.core.flush() + first.core.close() + const standalone = createCore( + { ...first.options, wasm: app.engine(), preparedSources: undefined }, + app.root, + ) + + // When the old standalone checkpoint path starts without fresh compiled proof. + await standalone.startup() + + // Then ordinary inputs still replay, while neither Markdown source is authorized by its raw stamp. + expect( + readCoordinatorState(first.options.stateFile ?? '', '')?.inputs.map( + (input) => input.filename, + ), + ).toEqual(['ordinary.tsx']) + expect((await standalone.css({ ...cssQuery, fileNum: 2 })).css).toContain( + 'background:blue', + ) + standalone.close() +}) + +it('keeps provider preparation-time stamps instead of restamping prewarmed compiled bytes', async () => { + // Given a prewarmed source captured before a compiler dependency changed. + const app = createTestApp() + app.write('src/page.mdx', '# raw') + const dep = app.write('compiler-input.txt', 'old') + const source = compiledSource(app, { + filename: 'src/page.mdx', + code: box('red'), + dependencies: [dep], + }) + const prepared = prewarmed(app, { generation: generation([source]) }) + app.write('compiler-input.txt', 'new') + + // When Core adopts and commits the supplied complete generation. + const core = createCore(prepared.options, app.root) + await core.startup() + + // Then the durable source retains the old preparation evidence, not a false fresh stamp. + const snapshot = readCoordinatorState(prepared.options.stateFile ?? '', '') + expect(snapshot?.inputs[0]?.stamps).toEqual(source.input.stamps) + expect(readFileSync(dep, 'utf8')).toBe('new') + core.close() +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-process.test.ts b/packages/next-plugin/src/__tests__/coordinator-process.test.ts new file mode 100644 index 000000000..36d825a17 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-process.test.ts @@ -0,0 +1,131 @@ +import { type ChildProcess, spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import { request } from 'node:http' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { + connect, + createTestApp, + failure, + ownership, + removeTestApps, + type TestApp, +} from './coordinator-app' + +const fixture = join(import.meta.dir, 'fixtures', 'coordinator-process.ts') +const children: ChildProcess[] = [] + +function launch(app: TestApp, mode = ''): Promise { + const child = spawn( + process.execPath, + [fixture, app.root, app.identity.token, mode], + { + stdio: ['pipe', 'pipe', 'inherit'], + }, + ) + children.push(child) + return new Promise((resolve, reject) => { + child.stdout?.on('data', (chunk: Buffer) => { + if (chunk.toString().includes('ready')) resolve(child) + }) + child.on('exit', (code) => + reject(new Error(`coordinator process exited: ${code}`)), + ) + }) +} + +function stopped(child: ChildProcess): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve() + return + } + child.once('exit', () => resolve()) + child.kill() + }) +} + +const box = (bg: string) => + `import { Box } from '@devup-ui/react'\nexport const C = () => \n` + +afterEach(async () => { + await Promise.all(children.splice(0).map(stopped)) + removeTestApps() +}) + +describe('apps in separate processes', () => { + it('exits after synchronous close while an extraction body is incomplete', async () => { + const app = createTestApp() + const child = await launch(app, 'shutdown') + const client = connect(app.portFile, app.identity) + const connected = Promise.withResolvers() + const failed = Promise.withResolvers() + const exited = Promise.withResolvers() + child.once('exit', exited.resolve) + const req = request({ + hostname: '127.0.0.1', + port: client.info.port, + path: '/extract', + method: 'POST', + agent: false, + headers: { ...ownership(app.identity), 'content-length': '100000' }, + }) + req.on('error', failed.resolve) + req.on('socket', (socket) => socket.once('connect', connected.resolve)) + req.write('{') + await connected.promise + + child.stdin?.end('close') + + expect(await failed.promise).toBeInstanceOf(Error) + expect(await exited.promise).toBe(0) + expect(existsSync(app.portFile)).toBe(false) + }, 3000) + + it('each serve their own engine and survive the other one dying', async () => { + const first = createTestApp() + const second = createTestApp() + first.write('src/a.tsx', box('red')) + second.write('src/a.tsx', box('blue')) + const [firstProcess, secondProcess] = await Promise.all([ + launch(first), + launch(second), + ]) + const one = connect(first.portFile, first.identity) + const two = connect(second.portFile, { ...second.identity }) + + await one.post('/extract', first.post('src/a.tsx')) + await two.post('/extract', second.post('src/a.tsx')) + const firstCss = (await one.get('/css?fileNum=0')).body + const secondCss = (await two.get('/css?fileNum=0')).body + await stopped(firstProcess) + + expect(one.info.pid).toBe(firstProcess.pid ?? -1) + expect(two.info.pid).toBe(secondProcess.pid ?? -1) + expect(one.info.pid).not.toBe(process.pid) + expect(one.info.port).not.toBe(two.info.port) + expect(firstCss).toContain('background:red') + expect(firstCss).not.toContain('background:blue') + expect(secondCss).toContain('background:blue') + expect(secondCss).not.toContain('background:red') + expect((await two.get('/health')).status).toBe(200) + expect(await failure(one.get('/health'))).toBeInstanceOf(Error) + expect(existsSync(second.portFile)).toBe(true) + }) + + it("refuses a client that holds another app's token", async () => { + const app = createTestApp() + await launch(app) + const stranger = connect(app.portFile, { + project: app.identity.project, + token: randomUUID(), + }) + + const reply = await stranger.get('/health') + + expect(reply.status).toBe(403) + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-production.test.ts b/packages/next-plugin/src/__tests__/coordinator-production.test.ts new file mode 100644 index 000000000..2a43dfe31 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-production.test.ts @@ -0,0 +1,348 @@ +import { mkdirSync, rmSync } from 'node:fs' +import * as fsp from 'node:fs/promises' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' + +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +import { + type CoordinatorOptions, + resetCoordinator, + startCoordinator, + takeExtractOutput, +} from '../coordinator' +import { readCoordinatorState } from '../state' +import { + connect, + createTestApp, + instrument, + removeTestApps, + type TestApp, +} from './coordinator-app' + +const box = (bg: string) => + `import { Box } from '@devup-ui/react'\nexport const C = () => \n` +const bad = + "import { css } from '@devup-ui/react'\nexport const x = css(foo())\n" + +const WAIT = '/css?fileNum=0&importMainCss=true&waitForIdle=true' +const BASE = '/css?waitForIdle=true' + +async function started(app: TestApp, extra: Partial = {}) { + const handle = startCoordinator(app.options(extra)) + await handle.ready + return { handle, client: connect(app.portFile, app.identity) } +} + +afterEach(() => { + resetCoordinator() + removeTestApps() +}) + +describe('production completeness', () => { + it('serves a prewarmed stylesheet, even an empty one, without any loader request', async () => { + const app = createTestApp() + const { client } = await started(app, { prewarmedFiles: [] }) + + const reply = await client.get(BASE) + + expect(reply.status).toBe(200) + expect(reply.headers['x-devup-css-policy']).toBe('production-complete') + expect(reply.body).toContain('devup-ui') + }) + + it('refuses to guess completeness when there is no plan', async () => { + const app = createTestApp() + const { client } = await started(app) + + const reply = await client.get(BASE) + + expect(reply.status).toBe(500) + expect(JSON.parse(reply.body).error).toMatch( + /^devup-ui\.css:1:1: devup-ui coordinator cannot serve a complete production stylesheet: neither prewarmedFiles nor expectedBaseFiles/, + ) + }) + + it('refuses a production stylesheet request that does not wait', async () => { + const app = createTestApp() + const { client } = await started(app, { prewarmedFiles: [] }) + + const reply = await client.get('/css') + + expect(reply.status).toBe(400) + expect(JSON.parse(reply.body).error).toContain( + 'request /css with waitForIdle=true', + ) + }) + + it('names the missing files instead of serving partial CSS after the bounded wait', async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + const { client } = await started(app, { + expectedBaseFiles: ['src/a.tsx', 'src/b.tsx'], + maxWaitMs: 100, + }) + await client.post('/extract', app.post('src/a.tsx')) + + const reply = await client.get(BASE) + + expect(reply.status).toBe(500) + const body = JSON.parse(reply.body) + expect(body.missing).toEqual(['src/b.tsx']) + expect(body.error).toStartWith( + 'src/b.tsx:1:1: devup-ui coordinator cannot use `the base stylesheet` at build time:', + ) + expect(body.error).toContain('never extracted: src/b.tsx') + }) + + it('completes a waiting request when the last planned file arrives', async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + app.write('src/b.tsx', box('blue')) + const { client } = await started(app, { + expectedBaseFiles: ['src/a.tsx', 'src/b.tsx'], + }) + await client.post('/extract', app.post('src/a.tsx')) + + const waiting = client.get(BASE) + await delay(100) + await client.post('/extract', app.post('src/b.tsx')) + + expect((await waiting).status).toBe(200) + expect((await client.get(WAIT)).body).toContain('background:red') + }) + + it('fails at once for a planned file whose extraction failed, until it extracts', async () => { + const app = createTestApp() + app.write('src/a.tsx', bad) + const { client } = await started(app, { expectedBaseFiles: ['src/a.tsx'] }) + + const waiting = client.get(BASE) + await delay(50) + const extracted = await client.post('/extract', app.post('src/a.tsx')) + const failed = await waiting + const again = await client.get(BASE) + const fixed = await client.post( + '/extract', + app.post('src/a.tsx', box('red')), + ) + + expect(extracted.status).toBe(500) + for (const reply of [failed, again]) { + expect(reply.status).toBe(500) + expect(JSON.parse(reply.body).failed).toEqual(['src/a.tsx']) + expect(JSON.parse(reply.body).error).toContain( + 'extraction failed for src/a.tsx', + ) + } + expect(fixed.status).toBe(200) + expect((await client.get(BASE)).status).toBe(200) + }) + + it('waits for every member of a collapsed bucket, and reads `@global` and unknown numbers as base', async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + app.write('src/b.tsx', box('blue')) + const { client } = await started(app, { + canonicalMap: { + 'src/a.tsx': 'src/a.tsx', + 'src/b.tsx': 'src/a.tsx', + 'src/g.tsx': '@global', + }, + prewarmedFiles: [], + maxWaitMs: 2000, + }) + await client.post('/extract', app.post('src/a.tsx')) + + const waiting = client.get(WAIT) + await delay(100) + await client.post('/extract', app.post('src/b.tsx')) + + expect((await waiting).status).toBe(200) + expect((await client.get('/css?fileNum=99&waitForIdle=true')).status).toBe( + 200, + ) + }) + + it('names the members of a bucket that never completes', async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + const { client } = await started(app, { + canonicalMap: { 'src/a.tsx': 'src/a.tsx', 'src/b.tsx': 'src/a.tsx' }, + maxWaitMs: 100, + }) + await client.post('/extract', app.post('src/a.tsx')) + + const reply = await client.get(WAIT) + + expect(reply.status).toBe(500) + expect(JSON.parse(reply.body).missing).toEqual(['src/b.tsx']) + }) + + it('serves a bucket whose only member was prewarmed', async () => { + const app = createTestApp() + const source = box('red') + const engine = app.engine() + const output = takeExtractOutput( + engine.codeExtract( + 'src/a.tsx', + source, + '@devup-ui/react', + './df', + false, + false, + true, + {}, + ), + ) + const { client } = await started(app, { + wasm: engine, + prewarmedFiles: ['src/a.tsx'], + prewarmedOutputs: new Map([['src/a.tsx', { ...output, source }]]), + }) + + const reply = await client.get(WAIT) + + expect(reply.status).toBe(200) + expect(reply.body).toContain('background:red') + }) +}) + +describe('after the stylesheet was served', () => { + async function sealed() { + const app = createTestApp() + const source = box('red') + app.write('src/a.tsx', source) + const prewarm = app.engine() + const prewarmed = takeExtractOutput( + prewarm.codeExtract( + 'src/a.tsx', + source, + '@devup-ui/react', + './df', + false, + false, + true, + {}, + ), + ) + const { engine, extractions } = instrument(prewarm) + const stateFile = join(app.root, 'df', 'state.json') + const { client } = await started(app, { + wasm: engine, + createEngine: () => instrument(app.engine(), extractions).engine, + stateFile, + expectedBaseFiles: ['src/a.tsx'], + prewarmedFiles: ['src/a.tsx'], + prewarmedOutputs: new Map([['src/a.tsx', { ...prewarmed, source }]]), + }) + return { app, client, extractions, stateFile } + } + + it('takes unplanned files until then', async () => { + const { app, client } = await sealed() + + const early = await client.post( + '/extract', + app.post('src/c.tsx', box('green')), + ) + + expect(early.status).toBe(200) + }) + + it('returns exact prewarm outputs without running the engine', async () => { + const { app, client, extractions } = await sealed() + await client.get(WAIT) + + const exact = await client.post('/extract', app.post('src/a.tsx')) + + expect(exact.status).toBe(200) + expect(extractions).toEqual([]) + }) + + it('allows a changed source that produces the same CSS', async () => { + const { app, client, extractions } = await sealed() + await client.get(WAIT) + + const same = await client.post( + '/extract', + app.post('src/a.tsx', `${box('red')}// touched\n`), + ) + + expect(same.status).toBe(200) + expect(extractions).toEqual(['map:src/a.tsx']) + }) + + it('rejects unplanned or changed styles, naming the file and the fix', async () => { + const { app, client } = await sealed() + await client.get(WAIT) + + const unplanned = await client.post( + '/extract', + app.post('src/c.tsx', box('green')), + ) + const changed = await client.post( + '/extract', + app.post('src/a.tsx', box('blue')), + ) + + for (const reply of [unplanned, changed]) { + expect(reply.status).toBe(500) + } + expect(JSON.parse(unplanned.body).error).toMatch( + /^src\/c\.tsx:1:1: devup-ui coordinator cannot change styles after the production stylesheet was served: .+ Fix: include it in expectedBaseFiles/, + ) + }) +}) + +describe('persistence acknowledgement', () => { + it('does not acknowledge an extraction whose state could not be written, and retries on repeat', async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + const stateFile = join(app.root, 'df', 'state.json') + const { client } = await started(app, { + stateFile, + expectedBaseFiles: ['src/a.tsx'], + }) + rmSync(stateFile) + mkdirSync(stateFile) + + const failed = await client.post('/extract', app.post('src/a.tsx')) + const incomplete = await client.get(BASE) + rmSync(stateFile, { recursive: true }) + const repeated = await client.post('/extract', app.post('src/a.tsx')) + + expect(failed.status).toBe(500) + expect(incomplete.status).toBe(500) + expect(JSON.parse(incomplete.body).failed).toEqual(['src/a.tsx']) + expect(repeated.status).toBe(200) + expect(readCoordinatorState(stateFile, '')?.inputs).toHaveLength(1) + expect((await client.get(BASE)).status).toBe(200) + }) + + it('holds back every identical request until the shared write has landed', async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + const stateFile = join(app.root, 'df', 'state.json') + const { client } = await started(app, { stateFile }) + const original = fsp.rename + const spy = spyOn(fsp, 'rename').mockImplementation(async (...args) => { + await delay(250) + return original(...args) + }) + + try { + const startedAt = performance.now() + const replies = await Promise.all([ + client.post('/extract', app.post('src/a.tsx')), + client.post('/extract', app.post('src/a.tsx')), + ]) + + expect(replies.map((reply) => reply.status)).toEqual([200, 200]) + expect(performance.now() - startedAt).toBeGreaterThanOrEqual(200) + } finally { + spy.mockRestore() + } + expect(readCoordinatorState(stateFile, '')?.inputs).toHaveLength(1) + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-publication.test.ts b/packages/next-plugin/src/__tests__/coordinator-publication.test.ts new file mode 100644 index 000000000..a4e619df9 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-publication.test.ts @@ -0,0 +1,199 @@ +import { readFileSync } from 'node:fs' +import * as fsp from 'node:fs/promises' + +import { afterEach, expect, it, spyOn } from 'bun:test' + +import { CoordinatorShutdownError } from '../coordinator-completion' +import { createCore } from '../coordinator-core' +import { createReplay } from '../coordinator-replay' +import { CompensationError } from '../coordinator-transaction' +import { createTestApp, failure, removeTestApps } from './coordinator-app' +import { + box, + compiledSource, + generation, + prewarmed, +} from './coordinator-prepared-fixture' + +afterEach(removeTestApps) + +function fixture() { + const app = createTestApp() + app.write('src/page.mdx', '# raw') + const initial = { + ...generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('red') }), + ]), + ordinaryInputs: [], + plan: { canonicalMap: {}, expectedBaseFiles: ['src/page.mdx'] }, + watchInputs: [app.root], + } + const next = { + ...generation([ + compiledSource(app, { filename: 'src/page.mdx', code: box('green') }), + ]), + ordinaryInputs: [], + plan: { + canonicalMap: {}, + expectedBaseFiles: ['src/page.mdx', 'missing.tsx'], + }, + watchInputs: [app.root + '/next'], + } + const prepared = prewarmed(app, { + generation: initial, + prepareReplay: async () => next, + }) + return { app, initial, next, prepared } +} + +it('acknowledges an atomically published candidate when close wins the commit caller continuation', async () => { + // Given the actual strong commit wrapped at the reproduced continuation seam. + const { app, next, prepared } = fixture() + let watches: readonly string[] = [] + const replay = createReplay(prepared.options, app.root, (inputs) => { + watches = inputs + }) + await replay.startup() + const commit = replay.persistence.commitCandidate + const observed = spyOn( + replay.persistence, + 'commitCandidate', + ).mockImplementation(async (snapshot, previous, control) => { + await commit(snapshot, previous, control) + replay.close() + }) + try { + // When the owner closes after joined commit acknowledgement, before its caller resumes. + await replay.reconcile() + // Then the operation succeeds with candidate CSS and all candidate metadata, never rejected split state. + expect(replay.live.engine.getCss(0, false)).toContain('background:green') + expect(replay.snapshot().revision).toBe(8) + expect(replay.snapshot().plan).toEqual(next.plan) + expect(replay.ledger.list()[0]?.source).toBe(box('green')) + expect(replay.live.generation?.configureWasm).toBe(next.configureWasm) + expect(watches).toEqual(next.watchInputs) + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe( + JSON.stringify(replay.snapshot()), + ) + expect(readFileSync(prepared.options.revisionFile ?? '', 'utf8')).toBe('8') + } finally { + observed.mockRestore() + replay.close() + replay.plan.close() + } +}) + +it.each(['throw', 'close'] as const)( + 'retains exact predecessor when the synchronous watch publication callback causes %s', + async (mode) => { + // Given a real callback that can fail or revoke the owner before publication. + const { app, initial, next, prepared } = fixture() + let watches = initial.watchInputs + let blocked = true + const cause = new Error('controlled watch publication failure') + const replay = createReplay(prepared.options, app.root, (inputs) => { + watches = [...inputs] + if (blocked && inputs[0] === next.watchInputs[0]) { + if (mode === 'throw') throw cause + replay.close() + } + }) + await replay.startup() + const before = replay.snapshot() + const output = replay.ledger.lookup('src/page.mdx', box('red')) + const state = readFileSync(prepared.options.stateFile ?? '', 'utf8') + const revision = readFileSync(prepared.options.revisionFile ?? '', 'utf8') + try { + // When the callback fails after durable writes but before all-state publication. + const error = await failure(replay.reconcile()) + // Then compensation preserves bytes, allocation, plan, generation and cached output exactly. + if (mode === 'throw') expect(error).toBe(cause) + else expect(error).toBeInstanceOf(CoordinatorShutdownError) + expect(replay.snapshot()).toEqual(before) + expect(replay.live.generation?.configureWasm).toBe(initial.configureWasm) + expect(replay.live.engine).toBe(prepared.wasm) + expect(replay.ledger.lookup('src/page.mdx', box('red'))).toBe(output) + expect(watches).toEqual(initial.watchInputs) + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe(state) + expect(readFileSync(prepared.options.revisionFile ?? '', 'utf8')).toBe( + revision, + ) + await replay.plan.wait(undefined) + await replay.persistence.drain() + blocked = false + if (mode === 'throw') { + await replay.reconcile() + expect(replay.live.revision).toBe(8) + } + } finally { + replay.close() + replay.plan.close() + } + }, +) + +it('quarantines Core when a watch listener fails both publication and predecessor restoration', async () => { + // Given the public listener seam whose external state cannot be restored. + const { app, prepared } = fixture() + const core = createCore(prepared.options, app.root) + await core.startup() + const state = readFileSync(prepared.options.stateFile ?? '', 'utf8') + const revision = readFileSync(prepared.options.revisionFile ?? '', 'utf8') + const cause = new Error('watch update failed') + const compensation = new Error('watch restore failed') + let calls = 0 + core.onWatchInputs?.(() => { + throw ++calls === 1 ? cause : compensation + }) + try { + // When watch publication and rollback both fail. + const error = await failure(core.reconcile()) + // Then disk is compensated and no subsequent operation can publish an uncertain owner. + expect(error).toBeInstanceOf(CompensationError) + if ( + !(error instanceof CompensationError) || + !(error.cause instanceof AggregateError) + ) + throw new Error('missing compensation causes') + expect(error.cause.errors).toEqual([cause, compensation]) + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe(state) + expect(readFileSync(prepared.options.revisionFile ?? '', 'utf8')).toBe( + revision, + ) + expect(await failure(core.reconcile())).toBe(error) + expect(await failure(core.flush())).toBe(error) + } finally { + core.close() + } +}) + +it('keeps live predecessor when cancellation wins the final revision write', async () => { + // Given a candidate whose real revision rename revokes its lease. + const { app, prepared } = fixture() + const replay = createReplay(prepared.options, app.root) + await replay.startup() + const before = replay.snapshot() + const state = readFileSync(prepared.options.stateFile ?? '', 'utf8') + const revision = readFileSync(prepared.options.revisionFile ?? '', 'utf8') + const rename = fsp.rename + const observed = spyOn(fsp, 'rename').mockImplementation(async (from, to) => { + await rename(from, to) + if (to === prepared.options.revisionFile) replay.close() + }) + try { + // When cancellation wins before the synchronous publication point. + expect(await failure(replay.reconcile())).toBeInstanceOf( + CoordinatorShutdownError, + ) + // Then both IO and live state remain the exact predecessor. + expect(replay.snapshot()).toEqual(before) + expect(readFileSync(prepared.options.stateFile ?? '', 'utf8')).toBe(state) + expect(readFileSync(prepared.options.revisionFile ?? '', 'utf8')).toBe( + revision, + ) + } finally { + observed.mockRestore() + replay.close() + replay.plan.close() + } +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-resolution-ordinary.test.ts b/packages/next-plugin/src/__tests__/coordinator-resolution-ordinary.test.ts new file mode 100644 index 000000000..694976261 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-resolution-ordinary.test.ts @@ -0,0 +1,114 @@ +import { afterEach, expect, it } from 'bun:test' + +import { createCore } from '../coordinator-core' +import { createTestApp, removeTestApps } from './coordinator-app' + +afterEach(removeTestApps) + +it('replays ordinary static CSS when a previously missing preferred candidate appears', async () => { + // Given + const app = createTestApp() + app.write( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { palette: ['outside/earlier', 'outside/red'] }, + }, + }), + ) + app.write('outside/red.js', 'export const color = "red";') + const code = + 'import {Box} from "@devup-ui/react"; import {color} from "palette"; export const Page=()=> ' + const resourcePath = app.write('app/page.jsx', code) + const core = createCore( + app.options({ watch: true, singleCss: true, sourceRoots: [] }), + app.root, + ) + const request = { filename: 'app/page.jsx', resourcePath, code } + try { + await core.startup() + await core.extract(request) + expect( + ( + await core.css({ + fileNum: undefined, + importMainCss: false, + wait: false, + }) + ).css, + ).toContain('color:red') + // When + app.write('outside/earlier.js', 'export const color = "blue";') + await core.extract(request) + const result = await core.css({ + fileNum: undefined, + importMainCss: false, + wait: false, + }) + // Then + expect(result.css).toContain('color:blue') + expect(result.css).not.toContain('color:red') + } finally { + await core.flush() + core.close() + } +}) + +it('keeps the ordinary live engine and proof when replay watch adoption fails', async () => { + // Given + const app = createTestApp() + app.write( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { palette: ['outside/earlier', 'outside/red'] }, + }, + }), + ) + app.write('outside/red.js', 'export const color = "red";') + const code = + 'import {Box} from "@devup-ui/react"; import {color} from "palette"; export const Page=()=> ' + const resourcePath = app.write('app/page.jsx', code) + const wasm = app.engine() + const core = createCore( + app.options({ wasm, watch: true, singleCss: true }), + app.root, + ) + try { + await core.startup() + await core.extract({ filename: 'app/page.jsx', resourcePath, code }) + if (!core.watchInputs || !core.onWatchInputs) + throw new TypeError('Core has no watch input API') + const previous = core.watchInputs() + let refusals = 1 + const stop = core.onWatchInputs(() => { + if (refusals > 0) { + refusals -= 1 + throw new Error('watch adoption refused') + } + }) + app.write('outside/earlier.js', 'export const color = "blue";') + // When + await expect(core.reconcile()).rejects.toThrow('watch adoption refused') + // Then + expect(wasm.getCss(null, false)).toContain('color:red') + expect(wasm.getCss(null, false)).not.toContain('color:blue') + expect(core.watchInputs()).toEqual(previous) + await core.reconcile() + expect( + ( + await core.css({ + fileNum: undefined, + importMainCss: false, + wait: false, + }) + ).css, + ).toContain('color:blue') + stop() + } finally { + await core.flush() + core.close() + } +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-sealed-boundaries.test.ts b/packages/next-plugin/src/__tests__/coordinator-sealed-boundaries.test.ts new file mode 100644 index 000000000..219868e0f --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-sealed-boundaries.test.ts @@ -0,0 +1,201 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { createCore } from '../coordinator-core' +import type { CoordinatorOptions } from '../coordinator-options' +import { readCoordinatorState } from '../state' +import { createTestApp, removeTestApps } from './coordinator-app' + +const box = (color: string) => + `import { Box } from '@devup-ui/react'; export const C = () => ;` +const query = { wait: true, importMainCss: false } + +function fixture(extra: Partial = {}) { + const app = createTestApp() + const stateFile = join(app.root, 'df/state.json') + const wasm = extra.wasm ?? app.engine() + extra.configureWasm?.(wasm) + const core = createCore( + app.options({ wasm, stateFile, prewarmedFiles: [], ...extra }), + app.root, + ) + const extract = (filename: string, code: string) => + core.extract({ filename, code, resourcePath: app.write(filename, code) }) + return { app, core, wasm, stateFile, extract } +} + +afterEach(removeTestApps) + +describe('sealed candidate boundaries', () => { + it('restores the frozen theme after sheet import', async () => { + // Given: theme-bearing CSS and no output cache. + const theme = { colors: { default: { primary: 'red' } } } + const f = fixture({ + cacheMaxEntries: 0, + configureWasm: (wasm) => wasm.registerTheme(theme), + }) + await f.extract('src/a.tsx', box('$primary')) + const before = await f.core.css(query) + expect(before.css).toContain('--primary') + f.app.write( + 'devup.json', + JSON.stringify({ theme: { colors: { default: { primary: 'blue' } } } }), + ) + + // When: production re-extracts against its frozen configuration, not new disk config. + await f.extract('src/a.tsx', `${box('$primary')} // touched`) + + // Then: theme CSS remains exactly the served version. + expect(await f.core.css(query)).toEqual(before) + }) + + it.each(['alias', 'restore mismatch', 'factory throw'])( + 'rejects invalid candidate factory: %s', + async (kind) => { + // Given: a factory that cannot provide an equivalent isolated engine. + const app = createTestApp() + const wasm = app.engine() + const f = fixture({ + wasm, + createEngine: () => { + switch (kind) { + case 'alias': + return wasm + case 'restore mismatch': { + const engine = app.engine() + return { ...engine, importSheet: () => undefined } + } + case 'factory throw': + throw new Error('factory unavailable') + default: + throw new Error(`Unexpected fixture ${kind}`) + } + }, + }) + await f.extract('src/a.tsx', box('red')) + const before = await f.core.css({ ...query, fileNum: 0 }) + const state = readFileSync(f.stateFile, 'utf8') + + // When: a cache miss needs a candidate. + await expect( + f.extract('src/a.tsx', `${box('red')} // touch`), + ).rejects.toThrow(/^src\/a\.tsx:1:1:/) + + // Then: neither live sheet nor checkpoint changes. + expect(wasm.getCss(0, false)).toBe(before.css) + expect(readFileSync(f.stateFile, 'utf8')).toBe(state) + }, + ) + + it('allows existing shared-bucket atoms without a CSS revision', async () => { + // Given: both sources belong to the same served private bucket. + const canonicalMap = { 'src/a.tsx': 'src/a.tsx', 'src/b.tsx': 'src/a.tsx' } + const f = fixture({ + canonicalMap, + configureWasm: (wasm) => wasm.importCanonicalMap(canonicalMap), + }) + await f.extract('src/a.tsx', box('red')) + await f.extract('src/b.tsx', 'export const b = 1') + const before = await f.core.css({ ...query, fileNum: 0 }) + const revision = readCoordinatorState(f.stateFile, '')?.revision + + // When: the sibling reuses an atom already in that bucket. + const result = await f.extract('src/b.tsx', box('red')) + + // Then: its emitted selectors already exist and no CSS update is published. + expect(result.updatedBaseStyle).toBe(false) + expect(await f.core.css({ ...query, fileNum: 0 })).toEqual(before) + expect(readCoordinatorState(f.stateFile, '')?.revision).toBe(revision) + }) + + it('rejects an existing value in a new private bucket', async () => { + // Given: the atom exists only in bucket zero, not base or an unknown bucket. + const f = fixture() + await f.extract('src/a.tsx', box('red')) + await f.core.css({ ...query, fileNum: 0 }) + const state = readFileSync(f.stateFile, 'utf8') + + // When: a new bucket wants the same value under its own missing selector. + await expect(f.extract('src/new.tsx', box('red'))).rejects.toThrow( + 'change styles after the production stylesheet was served', + ) + + // Then: no new number or selector becomes live. + expect(readFileSync(f.stateFile, 'utf8')).toBe(state) + expect(f.wasm.exportFileMap()).toBe('{"src/a.tsx":0}') + }) + + it('retains historical atoms, keyframes and allocator tombstones without replay', async () => { + // Given: retained CSS that the latest ledger source no longer describes. + const f = fixture({ cacheMaxEntries: 0, sourceMap: false }) + f.wasm.importFileMap({ 'src/deleted.tsx': 7 }) + await f.extract( + 'src/a.tsx', + `${box('red')} import { keyframes } from '@devup-ui/react'; export const spin = keyframes({ from: { opacity: 0 }, to: { opacity: 1 } });`, + ) + await f.extract('src/a.tsx', 'export const a = 1') + const maps = readCoordinatorState(f.stateFile, '') + const fileNum = Number(maps?.fileMap['src/a.tsx']) + const before = await f.core.css({ ...query, fileNum, importMainCss: true }) + expect(before.css).toContain('@keyframes') + expect(before.css).toContain('color:red') + + // When: an evicted source is re-extracted transactionally. + await f.extract('src/a.tsx', 'export const a = 1 // touched') + + // Then: retained historical CSS and numbering survive unchanged. + expect( + await f.core.css({ ...query, fileNum, importMainCss: true }), + ).toEqual(before) + expect(readCoordinatorState(f.stateFile, '')?.classMap).toEqual( + maps?.classMap, + ) + expect(readCoordinatorState(f.stateFile, '')?.fileMap).toEqual( + maps?.fileMap, + ) + }) + + it('serializes seal and rendering behind an already queued extraction', async () => { + // Given: a complete plan, before any stylesheet is served. + const f = fixture() + await f.extract('src/a.tsx', box('red')) + + // When: extraction queues before the first CSS request. + const extracting = f.extract('src/late.tsx', box('blue')) + const serving = f.core.css({ ...query, fileNum: 1 }) + await extracting + + // Then: the producer completes before sealing, rather than being rejected mid-queue. + expect((await serving).css).toContain('color:blue') + }) + + it.each(['red', 'blue'])( + 'invalidates dependency cache when imported color remains/becomes %s', + async (color) => { + // Given: a dependency-bearing extraction and its cached output. + const f = fixture() + f.app.write('src/tokens.ts', "export const color = 'red'") + const source = + "import { Box } from '@devup-ui/react'; import { color } from './tokens'; export const C = () => ;" + const first = await f.extract('src/a.tsx', source) + expect(first.dependencies).toEqual(['src/tokens.ts']) + const before = await f.core.css({ ...query, fileNum: 0 }) + f.app.write('src/tokens.ts', `export const color = '${color}' // touched`) + + // When: the same request must re-evaluate changed dependency bytes. + const extracting = f.extract('src/a.tsx', source) + + // Then: exact CSS equality, rather than cache identity, decides acceptance. + if (color === 'red') { + expect((await extracting).updatedBaseStyle).toBe(false) + expect(await f.core.css({ ...query, fileNum: 0 })).toEqual(before) + } else { + await expect(extracting).rejects.toThrow( + 'change styles after the production stylesheet was served', + ) + } + }, + ) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-sealed.test.ts b/packages/next-plugin/src/__tests__/coordinator-sealed.test.ts new file mode 100644 index 000000000..c7d79545b --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-sealed.test.ts @@ -0,0 +1,207 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { createCore } from '../coordinator-core' +import type { CoordinatorOptions } from '../coordinator-options' +import { readCoordinatorState } from '../state' +import { createTestApp, removeTestApps } from './coordinator-app' + +const global = (color: string) => + `import { globalCss } from '@devup-ui/react'; globalCss({ body: { color: '${color}' } });` +const box = (color: string) => + `import { Box } from '@devup-ui/react'; export const C = () => ;` +const query = { wait: true, importMainCss: false } + +async function fixture(extra: Partial = {}) { + const app = createTestApp() + const stateFile = join(app.root, 'df/state.json') + const revisionFile = join(app.root, 'df/revision') + const wasm = app.engine() + const core = createCore( + app.options({ + wasm, + stateFile, + revisionFile, + prewarmedFiles: [], + ...extra, + }), + app.root, + ) + await core.startup() + const extract = (filename: string, code: string) => + core.extract({ filename, code, resourcePath: app.write(filename, code) }) + return { app, core, wasm, extract, stateFile, revisionFile } +} + +afterEach(removeTestApps) + +describe('transactional sealed extraction with real WASM', () => { + it.each([0, 1])( + 'accepts touched global CSS after cache eviction at capacity %i', + async (cacheMaxEntries) => { + // Given: a served sheet and an evicted output. + const f = await fixture({ cacheMaxEntries }) + await f.extract('src/a.tsx', global('red')) + await f.extract('src/b.tsx', 'export const b = 1') + const before = await f.core.css(query) + const revision = readFileSync(f.revisionFile, 'utf8') + + // When: the source is touched without changing its global style. + const result = await f.extract('src/a.tsx', `${global('red')} // touched`) + + // Then: the rendered sheet and CSS revision are unchanged. + expect(result.updatedBaseStyle).toBe(false) + expect(await f.core.css(query)).toEqual(before) + expect(readFileSync(f.revisionFile, 'utf8')).toBe(revision) + expect( + readCoordinatorState(f.stateFile, '')?.inputs.find( + (input) => input.filename === 'src/a.tsx', + )?.source, + ).toEndWith('// touched') + }, + ) + + it.each([ + ['ordinary atom', box('tan')], + ['global same-length value', global('tan')], + [ + 'import', + "import { globalCss } from '@devup-ui/react'; globalCss({ imports: ['https://example.test/new.css'] });", + ], + [ + 'font face', + "import { globalCss } from '@devup-ui/react'; globalCss({ fontFaces: [{ fontFamily: 'New', src: 'local(New)' }] });", + ], + ])('rejects changed %s without altering live state', async (_kind, code) => { + // Given: live CSS, allocator maps, ledger and durable revision. + const f = await fixture() + await f.extract('src/a.tsx', `${box('red')} ${global('red')}`) + const before = await f.core.css(query) + const state = readFileSync(f.stateFile, 'utf8') + const maps = [ + f.wasm.exportSheet(), + f.wasm.exportClassMap(), + f.wasm.exportFileMap(), + ] + const revision = readFileSync(f.revisionFile, 'utf8') + + // When: a candidate introduces CSS absent from the served sheet. + await expect(f.extract('src/a.tsx', code)).rejects.toThrow( + /^src\/a\.tsx:1:1:.*Fix: include it in expectedBaseFiles/, + ) + + // Then: rejection preserves every live and durable state component. + expect([ + f.wasm.exportSheet(), + f.wasm.exportClassMap(), + f.wasm.exportFileMap(), + ]).toEqual(maps) + expect(readFileSync(f.stateFile, 'utf8')).toBe(state) + expect(readFileSync(f.revisionFile, 'utf8')).toBe(revision) + await f.extract('src/a.tsx', `${box('red')} ${global('red')}`) + expect(await f.core.css(query)).toEqual(before) + }) + + it('accepts evicted byte-identical global source', async () => { + // Given: no cached output survives the initial extraction. + const f = await fixture({ cacheMaxEntries: 0 }) + await f.extract('src/a.tsx', global('red')) + const before = await f.core.css(query) + + // When: the exact same source must be extracted again. + const result = await f.extract('src/a.tsx', global('red')) + + // Then: an engine dirty flag does not imply a CSS change. + expect(result.updatedBaseStyle).toBe(false) + expect(await f.core.css(query)).toEqual(before) + }) + + it.each([ + ['imports', "imports: ['https://example.test/new.css']"], + ['fontFaces', "fontFaces: [{ fontFamily: 'New', src: 'local(New)' }]"], + ])( + 'rejects new %s even without ordinary-atom output', + async (_kind, rules) => { + // Given: an ordinary private atom and no existing global CSS. + const f = await fixture() + await f.extract('src/a.tsx', box('red')) + const before = await f.core.css(query) + const state = readFileSync(f.stateFile, 'utf8') + + // When: only an import or font face changes the rendered sheet. + await expect( + f.extract( + 'src/new.tsx', + `import { globalCss } from '@devup-ui/react'; globalCss({ ${rules} });`, + ), + ).rejects.toThrow( + 'change styles after the production stylesheet was served', + ) + + // Then: rendered CSS, maps and checkpoint still describe the served sheet. + expect(await f.core.css(query)).toEqual(before) + expect(readFileSync(f.stateFile, 'utf8')).toBe(state) + }, + ) + + it('compares global CSS content rather than equal string lengths', async () => { + // Given: red and tan render equally long but different global styles. + const f = await fixture() + await f.extract('src/a.tsx', global('red')) + const before = await f.core.css(query) + const probe = f.app.engine() + probe + .codeExtract( + 'src/a.tsx', + global('tan'), + '@devup-ui/react', + './df', + false, + false, + true, + {}, + ) + .free() + expect(probe.getCss(undefined, false).length).toBe(before.css.length) + expect(probe.getCss(undefined, false)).not.toBe(before.css) + + // When: a same-length replacement attempts to cross the seal. + await expect(f.extract('src/a.tsx', global('tan'))).rejects.toThrow( + 'change styles after the production stylesheet was served', + ) + + // Then: the served value remains red. + expect(f.wasm.getCss(undefined, false)).toBe(before.css) + }) + + it('discards a throwing candidate even after it allocates names and CSS', async () => { + // Given: the factory supplies a real engine whose extraction throws after mutation. + const app = createTestApp() + const f = await fixture({ + createEngine: () => { + const engine = app.engine() + return { + ...engine, + codeExtract(...args) { + engine.codeExtract(...args).free() + throw new Error('candidate failed after allocation') + }, + } + }, + }) + await f.extract('src/a.tsx', box('red')) + const before = await f.core.css(query) + const state = readFileSync(f.stateFile, 'utf8') + + // When: extraction fails after having mutated the isolated candidate. + await expect(f.extract('src/new.tsx', box('blue'))).rejects.toThrow( + 'src/new.tsx:1:1:', + ) + + // Then: neither live CSS nor its checkpoint incorporates that allocation. + expect(await f.core.css(query)).toEqual(before) + expect(readFileSync(f.stateFile, 'utf8')).toBe(state) + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-shutdown.test.ts b/packages/next-plugin/src/__tests__/coordinator-shutdown.test.ts new file mode 100644 index 000000000..70fe7afab --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-shutdown.test.ts @@ -0,0 +1,225 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { request, Server } from 'node:http' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +import { + CoordinatorShutdownError, + createCompletionTracker, +} from '../coordinator-completion' +import { + type CoordinatorInstance, + createInstance, +} from '../coordinator-instance' +import { readCoordinatorState } from '../state' +import { + connect, + createTestApp, + failure, + ownership, + removeTestApps, +} from './coordinator-app' + +const instances: CoordinatorInstance[] = [] + +afterEach(() => { + for (const instance of instances.splice(0)) instance.close() + removeTestApps() +}) + +describe('shutdown cancellation', () => { + it('rejects pending and future waits and clears their timers when closed', async () => { + const tracker = createCompletionTracker(60_000) + const clear = spyOn(globalThis, 'clearTimeout') + const waiting = failure(tracker.wait('devup-ui.css', ['src/a.tsx'])) + + tracker.close() + + expect(await waiting).toBeInstanceOf(CoordinatorShutdownError) + expect(clear).toHaveBeenCalledTimes(1) + clear.mockRestore() + expect(await failure(tracker.wait('devup-ui.css', []))).toBeInstanceOf( + CoordinatorShutdownError, + ) + tracker.close() + }) + + it('closes an active CSS socket without waiting for the completion timeout', async () => { + const app = createTestApp() + const instance = createInstance( + app.options({ expectedBaseFiles: ['src/a.tsx'] }), + ) + instances.push(instance) + await instance.ready + const client = connect(app.portFile, app.identity) + const registered = Promise.withResolvers() + const emit = Server.prototype.emit + const observed = spyOn(Server.prototype, 'emit').mockImplementation( + function (this: Server, event: string | symbol, ...args: unknown[]) { + const result: boolean = Reflect.apply(emit, this, [event, ...args]) + if (event === 'request') registered.resolve() + return result + }, + ) + const waiting = failure(client.get('/css?waitForIdle=true')) + try { + await registered.promise + + instance.close() + + expect(await waiting).toBeInstanceOf(Error) + await instance.flush() + expect(existsSync(app.portFile)).toBe(false) + } finally { + observed.mockRestore() + } + }) + + it.each(['close', 'drain'] as const)( + '%s cancels a request still sending its body', + async (operation) => { + const app = createTestApp() + const instance = createInstance(app.options()) + instances.push(instance) + await instance.ready + const client = connect(app.portFile, app.identity) + const received = Promise.withResolvers() + const emit = Server.prototype.emit + const observed = spyOn(Server.prototype, 'emit').mockImplementation( + function (this: Server, event: string | symbol, ...args: unknown[]) { + const result: boolean = Reflect.apply(emit, this, [event, ...args]) + if (event === 'request') received.resolve() + return result + }, + ) + const failed = Promise.withResolvers() + const req = request({ + hostname: '127.0.0.1', + port: client.info.port, + path: '/extract', + method: 'POST', + agent: false, + headers: { ...ownership(app.identity), 'content-length': '100000' }, + }) + req.on('error', failed.resolve) + req.write('{') + await received.promise + observed.mockRestore() + + const closing = instance[operation]() + + expect(await failed.promise).toBeInstanceOf(Error) + await closing + await instance.flush() + expect(existsSync(app.portFile)).toBe(false) + }, + ) + + it('leaves a replacement endpoint and token intact when the old instance closes', async () => { + const app = createTestApp() + const old = createInstance(app.options()) + instances.push(old) + await old.ready + const replacementIdentity = { ...app.identity, token: randomUUID() } + const replacement = createInstance( + app.options({ identity: replacementIdentity }), + ) + instances.push(replacement) + await replacement.ready + const descriptor = readFileSync(app.portFile, 'utf-8') + + old.close() + + expect(readFileSync(app.portFile, 'utf-8')).toBe(descriptor) + expect( + (await connect(app.portFile, replacementIdentity).get('/health')).status, + ).toBe(200) + expect( + (await connect(app.portFile, app.identity).get('/health')).status, + ).toBe(403) + }) +}) + +describe('failed durability', () => { + it('restores durability with a later full capture including the failed input', async () => { + const app = createTestApp() + const stateFile = join(app.root, 'df', 'snapshot.json') + const instance = createInstance(app.options({ stateFile })) + instances.push(instance) + await instance.ready + const client = connect(app.portFile, app.identity) + rmSync(stateFile) + mkdirSync(stateFile) + expect( + (await client.post('/extract', app.post('a.tsx', 'export const a = 1'))) + .status, + ).toBe(500) + expect(await failure(instance.flush())).toBeInstanceOf(Error) + rmSync(stateFile, { recursive: true }) + + const recovered = await client.post( + '/extract', + app.post('b.tsx', 'export const b = 2'), + ) + + expect(recovered.status).toBe(200) + await instance.drain() + expect( + readCoordinatorState(stateFile, '')?.inputs.map((input) => input.source), + ).toEqual(['export const a = 1', 'export const b = 2']) + }) + + it('rejects drain when the accepted snapshot could not be committed', async () => { + const app = createTestApp() + const stateFile = join(app.root, 'df', 'snapshot.json') + const instance = createInstance(app.options({ stateFile })) + instances.push(instance) + await instance.ready + rmSync(stateFile) + mkdirSync(stateFile) + const client = connect(app.portFile, app.identity) + const reply = await client.post( + '/extract', + app.post('a.tsx', 'export const a = 1'), + ) + expect(reply.status).toBe(500) + + const error = await failure(instance.drain()) + + expect(String(error)).toContain(`${stateFile}:1:1:`) + expect(String(error)).toContain('cannot use `snapshot` at build time:') + expect(existsSync(app.portFile)).toBe(false) + }) + + it('retries a full capture after revision publication fails', async () => { + const app = createTestApp() + const stateFile = join(app.root, 'df', 'snapshot.json') + const revisionFile = join(app.root, 'df', 'revision') + const instance = createInstance(app.options({ stateFile, revisionFile })) + instances.push(instance) + await instance.ready + rmSync(revisionFile) + mkdirSync(revisionFile) + const client = connect(app.portFile, app.identity) + const source = + 'import { Box } from "@devup-ui/react"; export const A = ' + expect( + (await client.post('/extract', app.post('a.tsx', source))).status, + ).toBe(500) + expect(String(await failure(instance.flush()))).toContain( + `${revisionFile}:1:1:`, + ) + rmSync(revisionFile, { recursive: true }) + + const retry = await client.post('/extract', app.post('a.tsx', source)) + + expect(retry.status).toBe(200) + await instance.flush() + expect( + readCoordinatorState(stateFile, '')?.inputs.map((input) => input.source), + ).toEqual([source]) + expect(readFileSync(revisionFile, 'utf-8')).toBe('1') + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-watch-errors.test.ts b/packages/next-plugin/src/__tests__/coordinator-watch-errors.test.ts new file mode 100644 index 000000000..b03ad0900 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-watch-errors.test.ts @@ -0,0 +1,232 @@ +import * as fs from 'node:fs' +import { join } from 'node:path' +import * as nodeTimers from 'node:timers' +import { setTimeout as delay } from 'node:timers/promises' + +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +import { watchSources } from '../coordinator-watch' +import { createTestApp, eventually, removeTestApps } from './coordinator-app' + +afterEach(removeTestApps) + +describe('watcher filesystem boundaries', () => { + it.each(['ENOENT', 'EACCES'])( + 'handles %s while attaching and recovers on a parent hint', + async (code) => { + const app = createTestApp() + const src = join(app.root, 'src') + fs.mkdirSync(src) + const original = fs.watch + let injected = false + const watch = spyOn(fs, 'watch').mockImplementation( + (...args: unknown[]) => { + if (args[0] === src && !injected) { + injected = true + throw Object.assign(new Error('attachment failed'), { code }) + } + const handle: fs.FSWatcher = Reflect.apply(original, fs, args) + return handle + }, + ) + const errors: Error[] = [] + let changes = 0 + try { + const watcher = watchSources({ + roots: [src], + debounceMs: 10, + onChange: () => { + changes += 1 + }, + onError: (error) => errors.push(error), + }) + try { + fs.renameSync(src, join(app.root, 'old')) + fs.mkdirSync(src) + await eventually(() => (changes > 0 ? true : undefined)) + const before = changes + app.write('src/deep/page.tsx', 'page') + await eventually(() => (changes > before ? true : undefined)) + expect(errors.map((error) => error.message)).toEqual( + code === 'ENOENT' ? [] : ['attachment failed'], + ) + } finally { + watcher.close() + } + } finally { + watch.mockRestore() + } + }, + ) + + it.each(['ENOENT', 'EACCES'])( + 'handles %s from stat without hiding other filesystem errors', + (code) => { + const app = createTestApp() + const original = fs.statSync + const stat = spyOn(fs, 'statSync').mockImplementation( + (...args: unknown[]) => { + if (args[0] === app.root) + throw Object.assign(new Error('stat failed'), { code }) + return Reflect.apply(original, fs, args) + }, + ) + const errors: Error[] = [] + try { + const watcher = watchSources({ + roots: [join(app.root, 'src')], + debounceMs: 10, + onChange: () => undefined, + onError: (error) => errors.push(error), + }) + watcher.close() + expect(errors.length).toBe(code === 'ENOENT' ? 0 : 2) + expect(errors.every((error) => error.message === 'stat failed')).toBe( + true, + ) + } finally { + stat.mockRestore() + } + }, + ) + + it('rethrows non-Error boundary failures', () => { + const failure = { code: 'invalid fs adapter' } + const stat = spyOn(fs, 'statSync').mockImplementation(() => { + throw failure + }) + try { + expect.assertions(1) + try { + watchSources({ + roots: ['src'], + debounceMs: 10, + onChange: () => undefined, + onError: () => undefined, + }) + } catch (error) { + expect(error).toBe(failure) + } + } finally { + stat.mockRestore() + } + }) + + it('deduplicates hints, unreferences handles and cancels queued and late callbacks on close', async () => { + const app = createTestApp() + const original = fs.watch + const handles: { + handle: fs.FSWatcher + close: ReturnType + unref: ReturnType + change(filename: string | null): void + }[] = [] + const paths: unknown[] = [] + const seed = nodeTimers.setTimeout(() => undefined, 60000) + const prototype: Pick = Object.getPrototypeOf(seed) + nodeTimers.clearTimeout(seed) + const originalUnref = prototype.unref + const timers: ReturnType[] = [] + const timeout = spyOn(prototype, 'unref').mockImplementation(function ( + this: NodeJS.Timeout, + ) { + timers.push(this) + return originalUnref.call(this) + }) + const watch = spyOn(fs, 'watch').mockImplementation( + (...args: unknown[]) => { + const handle: fs.FSWatcher = Reflect.apply(original, fs, args) + const listener = args[2] + if (typeof listener !== 'function') + throw new Error('Expected watch listener') + paths.push(args[0]) + handles.push({ + handle, + close: spyOn(handle, 'close'), + unref: spyOn(handle, 'unref'), + change: (filename) => + Reflect.apply(listener, handle, ['rename', filename]), + }) + return handle + }, + ) + let changes = 0 + const errors: Error[] = [] + try { + const watcher = watchSources({ + roots: ['src', 'app', 'pages', 'src'].map((root) => + join(app.root, root), + ), + debounceMs: 10, + onChange: () => { + changes += 1 + }, + onError: (error) => errors.push(error), + }) + expect(paths.filter((path) => path === app.root)).toHaveLength(1) + for (const { change, unref } of handles) { + expect(unref).toHaveBeenCalledTimes(1) + change('unrelated') + change(null) + } + watcher.close() + watcher.close() + expect(timers.length).toBeGreaterThan(0) + expect(timers.every((timer) => !timer.hasRef())).toBe(true) + for (const { handle, close, change } of handles) { + expect(close).toHaveBeenCalledTimes(1) + change(null) + handle.emit('error', new Error('late error')) + } + await delay(40) + expect(changes).toBe(0) + expect(errors).toEqual([]) + } finally { + watch.mockRestore() + timeout.mockRestore() + } + }) + + it('recovers an ENOENT watcher and ignores errors from replaced inode handles', async () => { + const app = createTestApp() + const src = join(app.root, 'src') + fs.mkdirSync(src) + const original = fs.watch + const roots: fs.FSWatcher[] = [] + const watch = spyOn(fs, 'watch').mockImplementation( + (...args: unknown[]) => { + const handle: fs.FSWatcher = Reflect.apply(original, fs, args) + if (args[0] === src) roots.push(handle) + return handle + }, + ) + let changes = 0 + const errors: Error[] = [] + try { + const watcher = watchSources({ + roots: [src], + debounceMs: 10, + onChange: () => { + changes += 1 + }, + onError: (error) => errors.push(error), + }) + try { + roots[0]?.emit( + 'error', + Object.assign(new Error('gone'), { code: 'ENOENT' }), + ) + await eventually(() => (changes > 0 ? true : undefined)) + roots[0]?.emit('error', new Error('stale error')) + const before = changes + app.write('src/deep/new.tsx', 'new') + await eventually(() => (changes > before ? true : undefined)) + expect(errors).toEqual([]) + } finally { + watcher.close() + } + } finally { + watch.mockRestore() + } + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-watch-lifecycle.test.ts b/packages/next-plugin/src/__tests__/coordinator-watch-lifecycle.test.ts new file mode 100644 index 000000000..4d0d2b0e0 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-watch-lifecycle.test.ts @@ -0,0 +1,192 @@ +import * as fs from 'node:fs' +import { dirname, join } from 'node:path' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { createCore } from '../coordinator-core' +import type { Core } from '../coordinator-options' +import { type SourceWatcher, watchSources } from '../coordinator-watch' +import { + createTestApp, + eventually, + failure, + removeTestApps, +} from './coordinator-app' + +afterEach(removeTestApps) + +const source = + 'import { Box } from "@devup-ui/react"; export const A = ' + +async function closeWatcher(watcher: SourceWatcher, core: Core): Promise { + watcher.close() + await core.flush() +} + +async function observed(probe: () => boolean, stage: string): Promise { + const deadline = Date.now() + 1000 + await eventually(() => { + if (probe()) return true + if (Date.now() > deadline) + throw new Error(`Missing filesystem event: ${stage}`) + return undefined + }) +} + +describe('source root lifecycle without HTTP', () => { + it.each( + ['src', 'app', 'pages', 'nested/src/app'].flatMap((root) => + ['delete', 'rename'].map((action) => [root, action]), + ), + )( + 'reconciles extracted files after missing %s is created then %s', + async (root, action) => { + const app = createTestApp() + const core = createCore(app.options({ watch: true }), app.root) + await core.startup() + let changes = 0 + const errors: Error[] = [] + const watcher = watchSources({ + roots: [join(app.root, root)], + debounceMs: 10, + onChange: () => { + changes += 1 + void core.reconcile().catch((error: Error) => errors.push(error)) + }, + onError: (error) => errors.push(error), + }) + try { + const filename = app.write(`${root}/page.tsx`, source) + await eventually(() => (changes > 0 ? true : undefined)) + await core.extract({ filename, resourcePath: filename, code: source }) + const query = { fileNum: 0, importMainCss: true, wait: false } + expect((await core.css(query)).css).toContain('background:red') + + const before = changes + if (action === 'delete') fs.rmSync(filename) + else fs.renameSync(join(app.root, root), join(app.root, 'moved')) + await eventually(async () => { + const css = (await core.css(query)).css + return changes > before && !css.includes('background:red') + ? true + : undefined + }) + expect(errors).toEqual([]) + } finally { + await closeWatcher(watcher, core) + } + }, + ) + + it('removes fixtures after queued reconciliation when the test body fails', async () => { + // Given a real core with durable output and a source watcher. + const app = createTestApp() + const runRoot = dirname(app.root) + const core = createCore( + app.options({ + watch: true, + revisionFile: join(app.root, 'df', 'revision'), + }), + app.root, + ) + await core.startup() + const filename = app.write('src/page.tsx', source) + await core.extract({ filename, resourcePath: filename, code: source }) + const watcher = watchSources({ + roots: [join(app.root, 'src')], + debounceMs: 10, + onChange: () => undefined, + onError: (error) => { + throw error + }, + }) + const bodyError = new Error('fixture body failed') + fs.rmSync(filename) + const reconciliation = core.reconcile() + try { + // When failure reaches the same finally cleanup as the lifecycle tests. + const error = await failure( + (async () => { + try { + throw bodyError + } finally { + await closeWatcher(watcher, core) + removeTestApps() + } + })(), + ) + await reconciliation + + // Then the failure propagates and no queued write recreates the fixture. + expect(error).toBe(bodyError) + expect(fs.existsSync(runRoot)).toBe(false) + } finally { + watcher.close() + await core.flush() + fs.rmSync(runRoot, { recursive: true, force: true }) + } + }) + + it('reattaches after rapid replacements and observes deletion from the new root', async () => { + const app = createTestApp() + const root = join(app.root, 'src') + fs.mkdirSync(root) + let changes = 0 + const errors: Error[] = [] + const watcher = watchSources({ + roots: [root], + debounceMs: 10, + onChange: () => { + changes += 1 + }, + onError: (error) => errors.push(error), + }) + try { + fs.renameSync(root, join(app.root, 'old')) + fs.mkdirSync(root) + fs.renameSync(root, join(app.root, 'also-old')) + const filename = app.write('src/deep/new.tsx', source) + await eventually(() => (changes > 0 ? true : undefined)) + const before = changes + + fs.rmSync(filename) + await eventually(() => (changes > before ? true : undefined)) + + expect(errors).toEqual([]) + } finally { + watcher.close() + } + }) + + it('moves ancestor hints as nested missing directories appear and are replaced', async () => { + const app = createTestApp() + let changes = 0 + const errors: Error[] = [] + const watcher = watchSources({ + roots: [join(app.root, 'nested', 'src', 'app')], + debounceMs: 10, + onChange: () => { + changes += 1 + }, + onError: (error) => errors.push(error), + }) + try { + fs.mkdirSync(join(app.root, 'nested')) + await observed(() => changes > 0, 'intermediate creation') + const before = changes + app.write('nested/src/app/page.tsx', source) + await observed(() => changes > before, 'nested root creation') + fs.rmSync(join(app.root, 'nested'), { recursive: true }) + app.write('nested/src/app/deep/new.tsx', source) + const replaced = changes + await observed(() => changes > replaced, 'ancestor replacement') + + const attached = changes + fs.rmSync(join(app.root, 'nested', 'src', 'app'), { recursive: true }) + await observed(() => changes > attached, 'replacement root deletion') + expect(errors).toEqual([]) + } finally { + watcher.close() + } + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator-watch.test.ts b/packages/next-plugin/src/__tests__/coordinator-watch.test.ts new file mode 100644 index 000000000..0e38bd0bc --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-watch.test.ts @@ -0,0 +1,176 @@ +import * as fs from 'node:fs' +import { join, sep } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' + +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +import { watchSources } from '../coordinator-watch' +import { createTestApp, eventually, removeTestApps } from './coordinator-app' + +afterEach(removeTestApps) + +describe('watchSources', () => { + it('keeps an absent descendant watch usable when its prefix becomes a file', async () => { + // Given + const app = createTestApp() + const prefix = join(app.root, 'earlier.js') + const probe = join(prefix, 'nested', 'index.cjs') + const original = fs.statSync + const stat = spyOn(fs, 'statSync').mockImplementation( + (...args: unknown[]) => { + const result = Reflect.apply(original, fs, args) + // Windows returns undefined where POSIX throws for a file prefix. + if ( + process.platform === 'win32' && + result === undefined && + typeof args[0] === 'string' && + args[0].startsWith(prefix + sep) && + original(prefix, { throwIfNoEntry: false })?.isFile() + ) + throw Object.assign(new Error('not a directory'), { code: 'ENOTDIR' }) + return result + }, + ) + const errors: Error[] = [] + const event = Promise.withResolvers() + const watcher = watchSources({ + roots: [probe], + debounceMs: 5, + onChange: () => event.resolve(), + onError: (error) => errors.push(error), + }) + const timer = setTimeout( + () => event.reject(new Error('No watch event')), + 1000, + ) + try { + // When + app.write('earlier.js', 'export const color = "blue"') + await event.promise + // Then + expect(errors).toEqual([]) + } finally { + clearTimeout(timer) + watcher.close() + stat.mockRestore() + } + }) + + it.each(['src', 'app', 'pages', 'nested/src/app'])( + 'notices creation of missing %s', + async (root) => { + const app = createTestApp() + let changes = 0 + const watcher = watchSources({ + roots: [join(app.root, root)], + debounceMs: 10, + onChange: () => { + changes += 1 + }, + onError: (error) => { + throw error + }, + }) + try { + app.write(`${root}/page.tsx`, 'page') + await eventually(() => (changes > 0 ? true : undefined)) + expect(changes).toBeGreaterThan(0) + } finally { + watcher.close() + } + }, + 1000, + ) + it('reports one change for a burst of file events', async () => { + const app = createTestApp() + const src = join(app.root, 'src') + fs.mkdirSync(src) + let changes = 0 + const watcher = watchSources({ + roots: [src], + debounceMs: 80, + onChange: () => { + changes += 1 + }, + onError: () => undefined, + }) + + for (const name of ['a', 'b', 'c']) app.write(`src/${name}.tsx`, name) + await eventually(() => (changes > 0 ? true : undefined)) + await delay(250) + watcher.close() + + expect(changes).toBe(1) + }) + + it('sees changes inside nested directories', async () => { + const app = createTestApp() + fs.mkdirSync(join(app.root, 'src', 'deep'), { recursive: true }) + let changes = 0 + const watcher = watchSources({ + roots: [join(app.root, 'src')], + debounceMs: 10, + onChange: () => { + changes += 1 + }, + onError: () => undefined, + }) + + app.write('src/deep/a.tsx', 'a') + await eventually(() => (changes > 0 ? true : undefined)) + watcher.close() + }) + + it('stops reporting once closed even with missing roots', async () => { + const app = createTestApp() + const src = join(app.root, 'src') + fs.mkdirSync(src) + let changes = 0 + const watcher = watchSources({ + roots: [join(app.root, 'missing'), src], + debounceMs: 10, + onChange: () => { + changes += 1 + }, + onError: () => undefined, + }) + + app.write('src/a.tsx', 'a') + watcher.close() + watcher.close() + await delay(150) + + expect(changes).toBe(0) + }) + + it('forwards watcher errors', () => { + const app = createTestApp() + const src = join(app.root, 'src') + fs.mkdirSync(src) + const original = fs.watch + const created: fs.FSWatcher[] = [] + const watch = spyOn(fs, 'watch').mockImplementation( + (...args: unknown[]) => { + const watcher: fs.FSWatcher = Reflect.apply(original, fs, args) + created.push(watcher) + return watcher + }, + ) + const errors: Error[] = [] + + try { + const watcher = watchSources({ + roots: [src], + debounceMs: 10, + onChange: () => undefined, + onError: (error) => errors.push(error), + }) + created[0]?.emit('error', new Error('watch failed')) + watcher.close() + } finally { + watch.mockRestore() + } + + expect(errors.map((error) => error.message)).toEqual(['watch failed']) + }) +}) diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index 72d812adb..673262e89 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -1,1635 +1,434 @@ -import * as fs from 'node:fs' -import { request } from 'node:http' +import { existsSync, readFileSync } from 'node:fs' +import * as fsp from 'node:fs/promises' +import { Server } from 'node:http' import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' -import * as wasm from '@devup-ui/wasm' -import { - afterEach, - beforeEach, - describe, - expect, - it, - mock, - spyOn, -} from 'bun:test' +import { afterEach, describe, expect, it, spyOn } from 'bun:test' import { - type CoordinatorOptions, flushCoordinatorWrites, resetCoordinator, startCoordinator, } from '../coordinator' - -let codeExtractSpy: ReturnType -let codeExtractWithoutSourceMapSpy: ReturnType -let getCssSpy: ReturnType -let exportSheetSpy: ReturnType -let exportClassMapSpy: ReturnType -let exportFileMapSpy: ReturnType -let writeFileSpy: ReturnType -let writeFileSyncSpy: ReturnType - -const tmpDir = join(process.cwd(), '.tmp-coordinator-test') - -function makeOptions( - overrides: Partial = {}, -): CoordinatorOptions { - return { - wasm, - package: '@devup-ui/react', - cssDir: join(tmpDir, 'css'), - singleCss: false, - sheetFile: join(tmpDir, 'sheet.json'), - classMapFile: join(tmpDir, 'classMap.json'), - fileMapFile: join(tmpDir, 'fileMap.json'), - importAliases: {}, - coordinatorPortFile: join(tmpDir, 'coordinator.port'), - canonicalMap: {}, - ...overrides, - } -} - -function httpRequest( - port: number, - method: string, - path: string, - body?: string, -): Promise<{ status: number; body: string }> { - return new Promise((resolve, reject) => { - const req = request( - { - hostname: '127.0.0.1', - port, - path, - method, - headers: body ? { 'Content-Type': 'application/json' } : undefined, - }, - (res) => { - const chunks: Buffer[] = [] - res.on('data', (chunk: Buffer) => chunks.push(chunk)) - res.on('end', () => { - resolve({ - status: res.statusCode ?? 0, - body: Buffer.concat(chunks).toString('utf-8'), - }) - }) - }, - ) - req.on('error', reject) - if (body) req.write(body) - req.end() - }) -} - -beforeEach(() => { - codeExtractSpy = spyOn(wasm, 'codeExtract') - codeExtractWithoutSourceMapSpy = spyOn(wasm, 'codeExtractWithoutSourceMap') - getCssSpy = spyOn(wasm, 'getCss') - exportSheetSpy = spyOn(wasm, 'exportSheet') - exportClassMapSpy = spyOn(wasm, 'exportClassMap') - exportFileMapSpy = spyOn(wasm, 'exportFileMap') - writeFileSpy = spyOn(fs, 'writeFile').mockImplementation( - (_path: any, _data: any, _encOrCb: any, maybeCb?: any) => { - const cb = typeof _encOrCb === 'function' ? _encOrCb : maybeCb - if (cb) cb(null) - }, - ) - writeFileSyncSpy = spyOn(fs, 'writeFileSync').mockReturnValue(undefined) -}) +import { parsePortFile } from '../coordinator-port' +import { readCoordinatorState } from '../state' +import { + connect, + createTestApp, + eventually, + failure, + http, + instrument, + ownership, + removeTestApps, +} from './coordinator-app' + +const box = (bg: string) => + `import { Box } from '@devup-ui/react'\nexport const C = () => \n` afterEach(() => { resetCoordinator() - codeExtractSpy.mockRestore() - codeExtractWithoutSourceMapSpy.mockRestore() - getCssSpy.mockRestore() - exportSheetSpy.mockRestore() - exportClassMapSpy.mockRestore() - exportFileMapSpy.mockRestore() - writeFileSpy.mockRestore() - writeFileSyncSpy.mockRestore() + removeTestApps() }) -describe('coordinator', () => { - it('should start and respond to /health', async () => { - const options = makeOptions() - const coordinator = startCoordinator(options) +describe('endpoint', () => { + it('publishes its identity and answers health with the same descriptor', async () => { + const app = createTestApp() + const handle = startCoordinator(app.options()) + await handle.ready - // Wait for server to start and write port file - await new Promise((r) => setTimeout(r, 100)) + const client = connect(app.portFile, app.identity) + const health = await client.get('/health') - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - const res = await httpRequest(port, 'GET', '/health') - expect(res.status).toBe(200) - expect(res.body).toBe('ok') - - coordinator.close() + expect(health.status).toBe(200) + expect(parsePortFile(health.body)).toEqual(client.info) + expect(client.info).toMatchObject({ ...app.identity, pid: process.pid }) + handle.close() + expect(existsSync(app.portFile)).toBe(false) }) - it('should handle /extract endpoint', async () => { - const extractOutput = { - code: 'transformed code', - map: '{"version":3}', - cssFile: 'devup-ui-1.css', - updatedBaseStyle: true, - dependencies: ['src/tokens.ts'], - free: mock(), - [Symbol.dispose]: mock(), - } - codeExtractSpy.mockReturnValue(extractOutput) - getCssSpy.mockImplementation( - (fileNum: number | null, _importMainCss: boolean) => { - if (fileNum === null) return 'base-css' - return `file-css-${fileNum}` - }, - ) - exportSheetSpy.mockReturnValue('sheet-json') - exportClassMapSpy.mockReturnValue('classmap-json') - exportFileMapSpy.mockReturnValue('filemap-json') - - const options = makeOptions() - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/App.tsx', - code: 'const x = ', - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - }), - ) - - expect(res.status).toBe(200) - const data = JSON.parse(res.body) - expect(data.code).toBe('transformed code') - expect(data.map).toBe('{"version":3}') - expect(data.cssFile).toBe('devup-ui-1.css') - expect(data.updatedBaseStyle).toBe(true) - expect(data.dependencies).toEqual(['src/tokens.ts']) - - // Verify WASM was called - expect(codeExtractSpy).toHaveBeenCalledTimes(1) - expect(extractOutput.free).toHaveBeenCalledTimes(1) + it('extracts through its own engine and rewrites per-file CSS imports', async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + const handle = startCoordinator(app.options()) + await handle.ready + const client = connect(app.portFile, app.identity) - // Verify files were written (base CSS + per-file CSS + sheet + classmap + filemap) - expect(writeFileSpy).toHaveBeenCalledTimes(5) + const reply = await client.post('/extract', app.post('src/a.tsx')) - coordinator.close() + expect(reply.status).toBe(200) + const data = JSON.parse(reply.body) + expect(data.code).toContain('devup-ui.css?fileNum=0') + expect(data.cssFile).toEndWith('devup-ui-0.css') + expect(JSON.parse(data.map).version).toBe(3) + expect(data.dependencies).toEqual([]) }) - it('skips source-map generation when requested', async () => { - const extractOutput = { - code: 'transformed code', - map: undefined, - cssFile: undefined, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - } - codeExtractWithoutSourceMapSpy.mockReturnValue(extractOutput) - const coordinator = startCoordinator(makeOptions({ sourceMap: false })) - await new Promise((r) => setTimeout(r, 100)) - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - - const res = await httpRequest( - parseInt(portStr), - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/App.tsx', - code: 'const x = ', - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - }), + it('leaves single-CSS imports alone and skips source maps when asked', async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + const { engine, extractions } = instrument(app.engine()) + const handle = startCoordinator( + app.options({ wasm: engine, singleCss: true, sourceMap: false }), ) + await handle.ready + const client = connect(app.portFile, app.identity) - expect(res.status).toBe(200) - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(1) - expect(codeExtractSpy).not.toHaveBeenCalled() - expect(extractOutput.free).toHaveBeenCalledTimes(1) - coordinator.close() - }) - - it('reuses byte-identical singleCss prewarm output', async () => { - const source = 'const x = ' - const options = makeOptions({ - singleCss: true, - prewarmedOutputs: new Map([ - [ - 'src/App.tsx', - { - code: 'transformed prewarm code', - cssFile: 'devup-ui.css', - map: '{"version":3}', - source, - updatedBaseStyle: true, - }, - ], - ]), - }) - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/App.tsx', - code: source, - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - }), + const data = JSON.parse( + (await client.post('/extract', app.post('src/a.tsx'))).body, ) - expect(res.status).toBe(200) - expect(JSON.parse(res.body)).toMatchObject({ - code: 'transformed prewarm code', - map: '{"version":3}', - cssFile: 'devup-ui.css', - updatedBaseStyle: true, - }) - expect(codeExtractSpy).not.toHaveBeenCalled() - expect(writeFileSpy).not.toHaveBeenCalled() - - coordinator.close() + expect(data.code).not.toContain('fileNum') + expect(extractions).toEqual(['nomap:src/a.tsx']) }) - it('reuses and rewrites byte-identical per-file prewarm output', async () => { - const source = 'const x = ' - getCssSpy.mockReturnValue('prewarmed bucket css') - const coordinator = startCoordinator( - makeOptions({ - prewarmedFiles: ['src/App.tsx'], - prewarmedOutputs: new Map([ - [ - 'src/App.tsx', - { - code: 'import "./df/devup-ui-79.css";\nconst x = 1', - cssFile: 'devup-ui-79.css', - map: '{"version":3}', - source, - updatedBaseStyle: false, - }, - ], - ]), - }), - ) + it('serves the current CSS in development and labels the policy', async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + const handle = startCoordinator(app.options({ watch: true })) + await handle.ready + const client = connect(app.portFile, app.identity) + await client.post('/extract', app.post('src/a.tsx')) - await new Promise((resolve) => setTimeout(resolve, 100)) - const port = parseInt( - (writeFileSyncSpy.mock.calls[0] as [string, string])[1], - ) - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/App.tsx', - code: source, - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - }), - ) + const bucket = await client.get('/css?fileNum=0&importMainCss=true') - expect(res.status).toBe(200) - expect(JSON.parse(res.body)).toMatchObject({ - code: 'import "./df/devup-ui.css?fileNum=79";\nconst x = 1', - cssFile: 'devup-ui-79.css', - map: '{"version":3}', - updatedBaseStyle: false, - }) - expect(codeExtractSpy).not.toHaveBeenCalled() - expect(writeFileSpy).not.toHaveBeenCalled() - - const css = await httpRequest( - port, - 'GET', - '/css?fileNum=79&importMainCss=true&waitForIdle=true', - ) - expect(css).toEqual({ status: 200, body: 'prewarmed bucket css' }) - expect(getCssSpy).toHaveBeenCalledWith(79, true) - - coordinator.close() + expect(bucket.status).toBe(200) + expect(bucket.headers['content-type']).toBe('text/css') + expect(bucket.headers['x-devup-css-policy']).toBe('dev-current') + expect(bucket.body).toContain('background:red') + expect(bucket.body).toContain('@import') + expect((await client.get('/css')).body).not.toContain('background:red') }) - it('profiles both base CSS serialization paths separately from writes', async () => { - const originalProfile = process.env.DEVUP_UI_PROFILE - process.env.DEVUP_UI_PROFILE = '1' - const infoSpy = spyOn(console, 'info').mockImplementation(() => {}) - codeExtractSpy.mockReturnValue({ - code: 'transformed code', - map: undefined, - css: 'collected css', - cssFile: 'devup-ui-1.css', - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - }) - getCssSpy.mockImplementation((fileNum: number | null) => - fileNum === null ? 'base-css' : `file-css-${fileNum}`, - ) - exportSheetSpy.mockReturnValue('sheet-json') - exportClassMapSpy.mockReturnValue('classmap-json') - exportFileMapSpy.mockReturnValue('filemap-json') - - const coordinator = startCoordinator(makeOptions()) - try { - await new Promise((resolve) => setTimeout(resolve, 100)) - const port = parseInt( - (writeFileSyncSpy.mock.calls[0] as [string, string])[1], - ) - - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/profile.tsx', - code: 'const profile = true', - resourcePath: join(process.cwd(), 'src', 'profile.tsx'), - }), - ) - - expect(res.status).toBe(200) - const message = infoSpy.mock.calls - .map(([value]) => value) - .find( - (value): value is string => - typeof value === 'string' && - value.startsWith( - '[devup-ui:profile] {"phase":"coordinator.extract"', - ), - ) - if (message === undefined) throw new Error('missing extract profile') - const profile = JSON.parse( - message.slice('[devup-ui:profile] '.length), - ) as Record + it('answers unknown routes with 404', async () => { + const app = createTestApp() + const handle = startCoordinator(app.options()) + await handle.ready - expect(profile).toMatchObject({ - cacheHit: false, - classMapSnapshotBytes: Buffer.byteLength('classmap-json'), - cssSnapshotBytes: expect.any(Number), - fileMapSnapshotBytes: Buffer.byteLength('filemap-json'), - phase: 'coordinator.extract', - scheduledWrites: 5, - sheetSnapshotBytes: Buffer.byteLength('sheet-json'), - sourceBytes: Buffer.byteLength('const profile = true'), - }) - expect(profile.classMapSnapshotMs).toBeTypeOf('number') - expect(profile.cssSnapshotMs).toBeTypeOf('number') - expect(profile.fileMapSnapshotMs).toBeTypeOf('number') - expect(profile.sheetSnapshotMs).toBeTypeOf('number') + const reply = await connect(app.portFile, app.identity).get('/nope') - codeExtractSpy.mockReturnValue({ - code: 'transformed base code', - map: undefined, - css: 'collected base css', - cssFile: 'devup-ui-2.css', - updatedBaseStyle: true, - free: mock(), - [Symbol.dispose]: mock(), - }) - const baseRes = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/profile-base.tsx', - code: 'const profileBase = true', - resourcePath: join(process.cwd(), 'src', 'profile-base.tsx'), - }), - ) - - expect(baseRes.status).toBe(200) - const baseMessage = infoSpy.mock.calls - .map(([value]) => value) - .find( - (value): value is string => - typeof value === 'string' && - value.includes('"filename":"src/profile-base.tsx"'), - ) - if (baseMessage === undefined) { - throw new Error('missing base CSS extract profile') - } - const baseProfile = JSON.parse( - baseMessage.slice('[devup-ui:profile] '.length), - ) as Record - - expect(baseProfile).toMatchObject({ - cacheHit: false, - cssSnapshotBytes: expect.any(Number), - filename: 'src/profile-base.tsx', - phase: 'coordinator.extract', - scheduledWrites: 5, - }) - expect(baseProfile.cssSnapshotMs).toBeTypeOf('number') - } finally { - coordinator.close() - infoSpy.mockRestore() - if (originalProfile === undefined) { - delete process.env.DEVUP_UI_PROFILE - } else { - process.env.DEVUP_UI_PROFILE = originalProfile - } - } + expect(reply).toMatchObject({ status: 404, body: 'Not Found' }) }) - it('should rewrite per-file CSS imports when singleCss=false', async () => { - codeExtractSpy.mockReturnValue({ - code: 'import "./../../df/devup-ui/devup-ui-79.css";\nimport "./../../df/devup-ui/devup-ui-3.css";\nconst x = 1;', - map: '{"version":3}', - cssFile: 'devup-ui-79.css', - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - }) - getCssSpy.mockReturnValue('file-css') - exportSheetSpy.mockReturnValue('{}') - exportClassMapSpy.mockReturnValue('{}') - exportFileMapSpy.mockReturnValue('{}') - - const options = makeOptions({ singleCss: false }) - const coordinator = startCoordinator(options) + it('rejects malformed requests with located errors', async () => { + const app = createTestApp() + const handle = startCoordinator(app.options()) + await handle.ready + const client = connect(app.portFile, app.identity) - await new Promise((r) => setTimeout(r, 100)) + const notJson = await client.post('/extract', '{nope') + const wrongShape = await client.post('/extract', '{"filename":1}') + const badFileNum = await client.get('/css?fileNum=x1') - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/App.tsx', - code: 'const x = ', - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - }), + expect(notJson.status).toBe(400) + expect(JSON.parse(notJson.body).error).toStartWith('/extract:1:1:') + expect(wrongShape.status).toBe(400) + expect(JSON.parse(wrongShape.body).error).toContain( + 'filename, code and resourcePath must all be strings', ) - - expect(res.status).toBe(200) - const data = JSON.parse(res.body) - // Verify imports were rewritten from devup-ui-N.css to devup-ui.css?fileNum=N - expect(data.code).toContain('devup-ui.css?fileNum=79') - expect(data.code).toContain('devup-ui.css?fileNum=3') - expect(data.code).not.toContain('devup-ui-79.css') - expect(data.code).not.toContain('devup-ui-3.css') - - coordinator.close() + expect(badFileNum.status).toBe(400) + expect(JSON.parse(badFileNum.body).error).toStartWith('/css:1:1:') }) - it('should NOT rewrite CSS imports when singleCss=true', async () => { - codeExtractSpy.mockReturnValue({ - code: 'import "./../../df/devup-ui/devup-ui.css";\nconst x = 1;', - map: undefined, - cssFile: 'devup-ui.css', - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - }) - getCssSpy.mockReturnValue('all-styles') - exportSheetSpy.mockReturnValue('{}') - exportClassMapSpy.mockReturnValue('{}') - exportFileMapSpy.mockReturnValue('{}') - - const options = makeOptions({ singleCss: true }) - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/App.tsx', - code: 'const x = ', - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - }), + it('reports build errors with their position, adding one when the engine has none', async () => { + const app = createTestApp() + app.write( + 'src/dyn.tsx', + "import { css } from '@devup-ui/react'\nexport const x = css(foo())\n", ) - - expect(res.status).toBe(200) - const data = JSON.parse(res.body) - // singleCss=true: no rewriting should happen, devup-ui.css stays as-is - expect(data.code).toContain('devup-ui.css') - expect(data.code).not.toContain('?fileNum=') - - coordinator.close() - }) - - it('should handle /extract in singleCss mode (cssFile is devup-ui.css)', async () => { - codeExtractSpy.mockReturnValue({ - code: 'single css code', - map: undefined, - cssFile: 'devup-ui.css', - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - }) - getCssSpy.mockReturnValue('all-styles') - exportSheetSpy.mockReturnValue('sheet-json') - exportClassMapSpy.mockReturnValue('classmap-json') - exportFileMapSpy.mockReturnValue('filemap-json') - - const options = makeOptions({ singleCss: true }) - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/App.tsx', - code: 'const x = ', - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - }), + app.write( + 'src/broken.tsx', + 'import { Box } from \'@devup-ui/react\'\nexport const A = () => { - codeExtractSpy.mockReturnValue({ - code: 'no style code', - map: undefined, - cssFile: undefined, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - }) - - const options = makeOptions() - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/plain.ts', - code: 'const x = 1', - resourcePath: join(process.cwd(), 'src', 'plain.ts'), - }), - ) - - expect(res.status).toBe(200) - const data = JSON.parse(res.body) - expect(data.code).toBe('no style code') - expect(data.cssFile).toBeUndefined() - - // No file writes expected (no CSS, no updatedBaseStyle) - expect(writeFileSpy).not.toHaveBeenCalled() - - coordinator.close() - }) - - it('should handle /extract errors', async () => { - codeExtractSpy.mockImplementation(() => { - throw new Error('extraction failed') - }) - - const options = makeOptions() - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/bad.tsx', - code: 'invalid', - resourcePath: join(process.cwd(), 'src', 'bad.tsx'), - }), - ) - - expect(res.status).toBe(500) - const data = JSON.parse(res.body) - expect(data.error).toBe('extraction failed') - - coordinator.close() - }) - - it('should handle /css endpoint', async () => { - getCssSpy.mockReturnValue('css-content') - - const options = makeOptions() - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - const res = await httpRequest( - port, - 'GET', - '/css?fileNum=3&importMainCss=true', - ) - - expect(res.status).toBe(200) - expect(res.body).toBe('css-content') - expect(getCssSpy).toHaveBeenCalledWith(3, true) - - coordinator.close() - }) - - it('should handle /css endpoint without fileNum', async () => { - getCssSpy.mockReturnValue('base-css') - - const options = makeOptions() - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - const res = await httpRequest(port, 'GET', '/css?importMainCss=false') - - expect(res.status).toBe(200) - expect(res.body).toBe('base-css') - expect(getCssSpy).toHaveBeenCalledWith(null, false) - - coordinator.close() - }) - - it('should handle /css with waitForIdle after extractions complete', async () => { - codeExtractSpy.mockReturnValue({ - code: 'code', - map: undefined, - cssFile: 'devup-ui.css', - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - }) - getCssSpy.mockReturnValue('complete-css') - exportSheetSpy.mockReturnValue('{}') - exportClassMapSpy.mockReturnValue('{}') - exportFileMapSpy.mockReturnValue('{}') - - const options = makeOptions() - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - // Do an extraction first so totalExtractions > 0 - await httpRequest( - port, - 'POST', + const positioned = await client.post('/extract', app.post('src/dyn.tsx')) + const unpositioned = await client.post( '/extract', - JSON.stringify({ - filename: 'src/A.tsx', - code: 'code', - resourcePath: join(process.cwd(), 'src', 'A.tsx'), - }), + app.post('src/broken.tsx'), ) - // Now request CSS with waitForIdle — should resolve after idle threshold - const res = await httpRequest( - port, - 'GET', - '/css?importMainCss=false&waitForIdle=true', + expect(positioned.status).toBe(500) + expect(JSON.parse(positioned.body).error).toStartWith( + 'src/dyn.tsx:2:18: Cannot compose `foo()` at build time', ) - - expect(res.status).toBe(200) - expect(res.body).toBe('complete-css') - - coordinator.close() - }) - - it('should handle /css with waitForIdle timeout when no extractions happen', async () => { - getCssSpy.mockReturnValue('timeout-css') - - const options = makeOptions() - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - // Mock Date.now to simulate time passing beyond MAX_WAIT_MS (60s). - let callCount = 0 - const dateNowSpy = spyOn(Date, 'now').mockImplementation(() => { - callCount++ - // First call is `const start = Date.now()` — return 0 - // Subsequent calls return past MAX_WAIT_MS threshold - if (callCount <= 1) return 0 - return 61_000 - }) - - // Request CSS with waitForIdle=true but no extractions ever happen - // (totalExtractions === 0), so it should timeout and return CSS anyway - const res = await httpRequest( - port, - 'GET', - '/css?importMainCss=false&waitForIdle=true', + expect(unpositioned.status).toBe(500) + expect(JSON.parse(unpositioned.body).error).toMatch( + /^src\/broken\.tsx:1:1: devup-ui coordinator cannot extract styles: .+ Fix: /, ) - - expect(res.status).toBe(200) - expect(res.body).toBe('timeout-css') - - dateNowSpy.mockRestore() - coordinator.close() - }) - - it('should return 404 for unknown routes', async () => { - const options = makeOptions() - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - const res = await httpRequest(port, 'GET', '/unknown') - - expect(res.status).toBe(404) - expect(res.body).toBe('Not Found') - - coordinator.close() - }) - - it('should write port file on startup', async () => { - const options = makeOptions() - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - expect(writeFileSyncSpy).toHaveBeenCalledWith( - options.coordinatorPortFile, - expect.any(String), - 'utf-8', - ) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - expect(port).toBeGreaterThan(0) - - coordinator.close() - }) - - it('should close cleanly', async () => { - const options = makeOptions() - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - coordinator.close() - - // Server should be closed - double close should be safe - coordinator.close() - }) - - it('replaces an existing coordinator without retaining its server', async () => { - const options = makeOptions() - const first = startCoordinator(options) - await new Promise((resolve) => setTimeout(resolve, 100)) - const firstPort = parseInt( - (writeFileSyncSpy.mock.calls.at(-1) as [string, string])[1], - ) - - const second = startCoordinator(options) - await new Promise((resolve) => setTimeout(resolve, 100)) - const secondPort = parseInt( - (writeFileSyncSpy.mock.calls.at(-1) as [string, string])[1], - ) - - let firstClosed = false - try { - await httpRequest(firstPort, 'GET', '/health') - } catch { - firstClosed = true - } - expect(firstClosed).toBe(true) - - // Closing the superseded handle must not close the replacement server. - first.close() - const res = await httpRequest(secondPort, 'GET', '/health') - expect(res).toEqual({ status: 200, body: 'ok' }) - - second.close() }) +}) - it('should touch devup-ui.css to invalidate Turbopack cache when singleCss=false and new CSS collected', async () => { - codeExtractSpy.mockReturnValue({ - code: 'import "./../../df/devup-ui/devup-ui-5.css";\nconst x = 1;', - map: undefined, - cssFile: 'devup-ui-5.css', - updatedBaseStyle: false, - css: '.a{color:yellow}', - free: mock(), - [Symbol.dispose]: mock(), +describe('ownership', () => { + it('requires matching project and token headers once an identity is given', async () => { + const app = createTestApp() + const handle = startCoordinator(app.options()) + await handle.ready + const { info } = connect(app.portFile, app.identity) + const right = ownership(app.identity) + + const none = await http(info.port, 'GET', '/health') + const onlyProject = await http(info.port, 'GET', '/health', { + headers: { 'x-devup-project': right['x-devup-project'] }, }) - getCssSpy.mockImplementation( - (fileNum: number | null, _importMainCss: boolean) => { - if (fileNum === null) return 'base-css' - return `file-css-${fileNum}` + const wrongToken = await http(info.port, 'GET', '/health', { + headers: { + ...right, + 'x-devup-token': '00000000-0000-4000-8000-000000000000', }, - ) - exportSheetSpy.mockReturnValue('{}') - exportClassMapSpy.mockReturnValue('{}') - exportFileMapSpy.mockReturnValue('{}') - - const options = makeOptions({ singleCss: false }) - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/App.tsx', - code: 'const x = ', - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - }), - ) - - expect(res.status).toBe(200) - - // 5 writes: per-file CSS + sheet + classmap + filemap + devup-ui.css invalidation - expect(writeFileSpy).toHaveBeenCalledTimes(5) - - // Verify devup-ui.css was written to trigger Turbopack invalidation - const devupUiCssWrite = writeFileSpy.mock.calls.find( - (call: unknown[]) => - typeof call[0] === 'string' && call[0].endsWith('devup-ui.css'), - ) - expect(devupUiCssWrite).toBeTruthy() - // Content should include base CSS + timestamp nonce - const content = devupUiCssWrite![1] as string - expect(content).toContain('base-css') - expect(content).toMatch(/\/\* \d+ \*\//) - - coordinator.close() - }) - - it('should NOT touch devup-ui.css when singleCss=false but no new CSS collected', async () => { - codeExtractSpy.mockReturnValue({ - code: 'const x = 1;', - map: undefined, - cssFile: 'devup-ui-5.css', - updatedBaseStyle: false, - css: undefined, // no new CSS collected - free: mock(), - [Symbol.dispose]: mock(), }) - getCssSpy.mockReturnValue('file-css') - exportSheetSpy.mockReturnValue('{}') - exportClassMapSpy.mockReturnValue('{}') - exportFileMapSpy.mockReturnValue('{}') - - const options = makeOptions({ singleCss: false }) - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/App.tsx', - code: 'const x = 1', - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - }), - ) - - // 4 writes: per-file CSS + sheet + classmap + filemap (NO devup-ui.css touch) - expect(writeFileSpy).toHaveBeenCalledTimes(4) - - // Verify NO devup-ui.css write - const devupUiCssWrite = writeFileSpy.mock.calls.find( - (call: unknown[]) => - typeof call[0] === 'string' && call[0].endsWith('devup-ui.css'), - ) - expect(devupUiCssWrite).toBeUndefined() - - coordinator.close() - }) - - it('should NOT touch devup-ui.css for singleCss=true (not needed)', async () => { - codeExtractSpy.mockReturnValue({ - code: 'const x = 1;', - map: undefined, - cssFile: 'devup-ui.css', - updatedBaseStyle: false, - css: '.a{color:yellow}', - free: mock(), - [Symbol.dispose]: mock(), - }) - getCssSpy.mockReturnValue('all-styles') - exportSheetSpy.mockReturnValue('{}') - exportClassMapSpy.mockReturnValue('{}') - exportFileMapSpy.mockReturnValue('{}') - - const options = makeOptions({ singleCss: true }) - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/App.tsx', - code: 'const x = ', - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - }), - ) - - // 4 writes: CSS file (devup-ui.css via cssFile) + sheet + classmap + filemap - // NO additional devup-ui.css invalidation write (singleCss=true doesn't need it) - expect(writeFileSpy).toHaveBeenCalledTimes(4) - - coordinator.close() - }) - - it('should be reset via resetCoordinator while server is active', async () => { - const options = makeOptions() - startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - // resetCoordinator should close the active server - resetCoordinator() - - // Calling again should be safe (server is already null) - resetCoordinator() - }) - - it('should coalesce duplicate writes to the same path within one /extract handler', async () => { - // When a single /extract invocation triggers multiple writes to the same - // path (singleCss + updatedBaseStyle=true → both the base-CSS write and - // the cssFile write target `devup-ui.css`), the second write must be - // collapsed by the latest-wins serializer: the first chained run sees the - // *latest* content and writes it once, the second chained run finds - // `latestContent` already consumed and resolves as a no-op. - codeExtractSpy.mockReturnValue({ - code: 'single css code', - map: undefined, - cssFile: 'devup-ui.css', - updatedBaseStyle: true, - free: mock(), - [Symbol.dispose]: mock(), + const wrongProject = await http(info.port, 'POST', '/extract', { + headers: { ...right, 'x-devup-project': join(app.root, 'other') }, + body: app.post('src/a.tsx', box('red')), }) - getCssSpy.mockReturnValue('all-styles') - exportSheetSpy.mockReturnValue('sheet-json') - exportClassMapSpy.mockReturnValue('classmap-json') - exportFileMapSpy.mockReturnValue('filemap-json') - - const options = makeOptions({ singleCss: true }) - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) + const accepted = await http(info.port, 'GET', '/health', { headers: right }) - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/App.tsx', - code: 'const x = ', - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - }), - ) - - expect(res.status).toBe(200) - - // Both safeWrite calls target `devup-ui.css`. Coalescing means exactly - // one physical writeFile call is made for that path; the sheet/classMap/ - // fileMap writes (3 more) all go to distinct paths. - const devupUiCssWrites = writeFileSpy.mock.calls.filter((call) => - String(call[0]).endsWith('devup-ui.css'), - ) - expect(devupUiCssWrites.length).toBe(1) - - coordinator.close() + for (const refused of [none, onlyProject, wrongToken, wrongProject]) { + expect(refused.status).toBe(403) + expect(JSON.parse(refused.body).error).toContain( + ':1:1: devup-ui coordinator', + ) + } + expect(accepted.status).toBe(200) }) - it('should expose flushCoordinatorWrites to drain queued writes', async () => { - // The exported helper must return a settled promise even when no writes - // are pending (idle coordinator), so build orchestration can safely await - // it without risk of hanging. - await expect(flushCoordinatorWrites()).resolves.toBeUndefined() + it('lets header-less direct callers in only when no identity was given', async () => { + const app = createTestApp() + const handle = startCoordinator(app.options({ identity: undefined })) + await handle.ready + const port = parsePortFile(readFileSync(app.portFile, 'utf-8')).port - // After triggering a real /extract, awaiting the helper must wait for all - // queued writes (chained per path) to settle. We assert the spy has been - // invoked by the time the helper resolves. - codeExtractSpy.mockReturnValue({ - code: 'code', - map: undefined, - cssFile: 'devup-ui-7.css', - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), + const plain = await http(port, 'GET', '/health') + const foreign = await http(port, 'GET', '/health', { + headers: ownership({ project: app.root, token: 'someone-else' }), }) - getCssSpy.mockReturnValue('per-file-css') - exportSheetSpy.mockReturnValue('sheet-json') - exportClassMapSpy.mockReturnValue('classmap-json') - exportFileMapSpy.mockReturnValue('filemap-json') - - const options = makeOptions() - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/App.tsx', - code: 'const x = ', - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - }), - ) - - await expect(flushCoordinatorWrites()).resolves.toBeUndefined() - expect(writeFileSpy.mock.calls.length).toBeGreaterThan(0) - - coordinator.close() + expect(plain.status).toBe(200) + expect(foreign.status).toBe(403) }) +}) - it('should continue chained writes after a previous write fails (chain error recovery)', async () => { - // The serializer must not let one failed write poison every subsequent - // write for that path. We force the first writeFile to fail, then verify - // the second extraction's writes still happen for that same path. - codeExtractSpy.mockReturnValue({ - code: 'code', - map: undefined, - cssFile: 'devup-ui.css', - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - }) - getCssSpy.mockReturnValue('css-content') - exportSheetSpy.mockReturnValue('sheet-json') - exportClassMapSpy.mockReturnValue('classmap-json') - exportFileMapSpy.mockReturnValue('filemap-json') - - // Re-install writeFile spy with controlled failure: any write to the - // devup-ui.css path errors out on the *first* invocation only. - writeFileSpy.mockRestore() - let devupCssCallCount = 0 - writeFileSpy = spyOn(fs, 'writeFile').mockImplementation( - (_path: any, _data: any, _encOrCb: any, maybeCb?: any) => { - const cb = typeof _encOrCb === 'function' ? _encOrCb : maybeCb - if (cb) { - if (String(_path).endsWith('devup-ui.css')) { - devupCssCallCount++ - if (devupCssCallCount === 1) { - cb(new Error('simulated disk error')) - return - } - } - cb(null) - } - }, - ) - - const options = makeOptions({ singleCss: true }) - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - // First /extract: triggers a write to devup-ui.css that we make fail. - // The coordinator will respond with 500 (await Promise.all([..., failingWrite]) - // rejects), but the chain itself must NOT be poisoned. - const firstRes = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/A.tsx', - code: 'const x = ', - resourcePath: join(process.cwd(), 'src', 'A.tsx'), - }), - ) - expect(firstRes.status).toBe(500) +describe('instances', () => { + it('lets two apps in one process run side by side with their own engines', async () => { + const first = createTestApp() + const second = createTestApp() + first.write('src/a.tsx', box('red')) + second.write('src/a.tsx', box('blue')) + const firstHandle = startCoordinator(first.options({ watch: true })) + const secondHandle = startCoordinator(second.options({ watch: true })) + await Promise.all([firstHandle.ready, secondHandle.ready]) + const one = connect(first.portFile, first.identity) + const two = connect(second.portFile, second.identity) + await one.post('/extract', first.post('src/a.tsx')) + await two.post('/extract', second.post('src/a.tsx')) + + secondHandle.close() + + expect(one.info.port).not.toBe(two.info.port) + expect((await one.get('/css?fileNum=0')).body).toContain('background:red') + expect((await one.get('/health')).status).toBe(200) + expect(existsSync(second.portFile)).toBe(false) + expect(await failure(two.get('/health'))).toBeInstanceOf(Error) + expect(existsSync(first.portFile)).toBe(true) + }) + + it('shares one instance between handles of the same identity until the last is released', async () => { + const app = createTestApp() + const first = startCoordinator(app.options()) + const second = startCoordinator(app.options()) + await first.ready + const client = connect(app.portFile, app.identity) - // Second /extract for the same path must SUCCEED — the `.catch(() => {})` - // chain-survival branch is what makes this work. - const secondRes = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/B.tsx', - code: 'const y = ', - resourcePath: join(process.cwd(), 'src', 'B.tsx'), - }), - ) - expect(secondRes.status).toBe(200) - - // We must have observed at least 2 attempts on the devup-ui.css path: - // the first (failed) and the second (succeeded). - expect(devupCssCallCount).toBeGreaterThanOrEqual(2) + first.close() + first.close() + expect((await client.get('/health')).status).toBe(200) + await second.drain() - coordinator.close() + expect(first.ready).toBe(second.ready) + expect(await failure(client.get('/health'))).toBeInstanceOf(Error) + expect(existsSync(app.portFile)).toBe(false) }) - it('should release the pending-extract slot when readBody throws before promotion', async () => { - // If JSON.parse on the request body throws, the handler must still tear - // down its `pendingExtractStarts` reservation (rather than the active - // counter) so waitForIdle is not left waiting forever for a phantom - // extraction. We verify by sending a malformed body, then proving the - // coordinator still processes a follow-up extraction normally. - codeExtractSpy.mockReturnValue({ - code: 'code', - map: undefined, - cssFile: 'devup-ui-1.css', - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - }) - getCssSpy.mockReturnValue('per-file-css') - exportSheetSpy.mockReturnValue('sheet-json') - exportClassMapSpy.mockReturnValue('classmap-json') - exportFileMapSpy.mockReturnValue('filemap-json') - - const options = makeOptions() - const coordinator = startCoordinator(options) - - await new Promise((r) => setTimeout(r, 100)) - - const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] - const port = parseInt(portStr) - - // Send an invalid body so JSON.parse throws BEFORE activeExtractions is - // incremented. The handler must still respond 500 cleanly. - const badRes = await httpRequest(port, 'POST', '/extract', 'not-json') - expect(badRes.status).toBe(500) - const errorPayload = JSON.parse(badRes.body) as { error: string } - expect(typeof errorPayload.error).toBe('string') - - // A subsequent well-formed extraction must succeed. If the pending-slot - // bookkeeping was wrong (decrementing activeExtractions instead of - // pendingExtractStarts in finally), internal counters would drift negative - // — that would not crash this request but is asserted by the next test - // case via waitForIdle behaviour. - const goodRes = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/App.tsx', - code: 'const x = ', - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - }), - ) - expect(goodRes.status).toBe(200) - const okPayload = JSON.parse(goodRes.body) as { code: string } - expect(okPayload.code).toBe('code') - - coordinator.close() - }) -}) + it('refuses to share an endpoint with an unrelated app instead of closing it', async () => { + const app = createTestApp() + const owner = startCoordinator(app.options()) + await owner.ready + const client = connect(app.portFile, app.identity) -describe('coordinator per-bucket completion', () => { - function extractResult(cssFile: string) { - return { - code: 'code', - map: undefined, - cssFile, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), + const stranger = { + project: app.identity.project, + token: '11111111-1111-4111-8111-111111111111', } - } - async function startAndGetPort(options: CoordinatorOptions) { - const coordinator = startCoordinator(options) - await new Promise((r) => setTimeout(r, 100)) - const port = parseInt( - (writeFileSyncSpy.mock.calls[0] as [string, string])[1], - ) - return { coordinator, port } - } - - function extract(port: number, filename: string) { - return httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename, - code: 'c', - resourcePath: join(process.cwd(), filename), + expect(() => startCoordinator(app.options({ identity: stranger }))).toThrow( + /coordinator\.port:1:1: devup-ui coordinator cannot start: another coordinator/, + ) + expect(() => + startCoordinator(app.options({ identity: undefined })), + ).toThrow('cannot start') + expect((await client.get('/health')).status).toBe(200) + }) + + it('does not let a closed coordinator delete the port file of its replacement', async () => { + const app = createTestApp() + const old = startCoordinator(app.options()) + await old.ready + old.close() + const replacement = startCoordinator( + app.options({ + identity: { + ...app.identity, + token: '22222222-2222-4222-8222-222222222222', + }, }), ) - } + await replacement.ready - // T0: idleThresholdMs option is honored by the base-css idle wait. - it('honors idleThresholdMs for the base-css idle wait (fast when small)', async () => { - codeExtractSpy.mockReturnValue(extractResult('devup-ui.css')) - getCssSpy.mockReturnValue('base-css') - const { coordinator, port } = await startAndGetPort( - makeOptions({ idleThresholdMs: 50 }), - ) - await extract(port, 'src/A.tsx') - - const t0 = Date.now() - const res = await httpRequest( - port, - 'GET', - '/css?importMainCss=false&waitForIdle=true', - ) - const elapsed = Date.now() - t0 + old.close() - expect(res.status).toBe(200) - // 50ms threshold -> resolves fast; the previous hardcoded 2500ms idle - // would push elapsed past 1500ms. - expect(elapsed).toBeLessThan(1500) - - coordinator.close() + expect(existsSync(app.portFile)).toBe(true) + expect(readFileSync(app.portFile, 'utf-8')).toContain('22222222') }) +}) - // T1: a collapsed bucket's CSS is not served until ALL its members are - // extracted (the race that flaked landing e2e on slow CI). - it('waits for all bucket members before serving a collapsed chunk', async () => { - codeExtractSpy.mockReturnValue(extractResult('devup-ui-1.css')) - getCssSpy.mockReturnValue('bucket-css') - // m1, m2 collapse into bucket.tsx; g is @global (must NOT be awaited). - const canonicalMap = { - 'src/m1.tsx': 'src/bucket.tsx', - 'src/m2.tsx': 'src/bucket.tsx', - 'src/g.tsx': '@global', - } - const { coordinator, port } = await startAndGetPort( - makeOptions({ canonicalMap, idleThresholdMs: 100 }), - ) - // Extract ONLY the bucket root; m1, m2 still pending. - await extract(port, 'src/bucket.tsx') - getCssSpy.mockClear() - - // Request the bucket chunk; it must NOT resolve while m1/m2 are missing, - // even though the (small) idle threshold has elapsed. - let resolved = false - const cssPromise = httpRequest( - port, - 'GET', - '/css?fileNum=1&importMainCss=true&waitForIdle=true', - ).then((r) => { - resolved = true - return r +describe('lifecycle', () => { + it('does not publish an endpoint when closed before it listens', async () => { + const app = createTestApp() + const handle = startCoordinator(app.options()) + + handle.close() + await handle.ready + await delay(50) + + expect(existsSync(app.portFile)).toBe(false) + }) + + it('does not publish an endpoint when closed while the socket opens', async () => { + const app = createTestApp() + const handle = startCoordinator(app.options()) + const listen = Server.prototype.listen + const spy = spyOn(Server.prototype, 'listen').mockImplementation(function ( + this: Server, + ...args: unknown[] + ) { + const result: Server = Reflect.apply(listen, this, args) + handle.close() + return result }) - await new Promise((r) => setTimeout(r, 300)) - expect(resolved).toBe(false) - expect(getCssSpy).not.toHaveBeenCalled() - // Extract the remaining members -> the bucket is now complete. - await extract(port, 'src/m1.tsx') - await extract(port, 'src/m2.tsx') - const res = await cssPromise - expect(res.status).toBe(200) - expect(getCssSpy).toHaveBeenCalledWith(1, true) - - coordinator.close() - }) - - // T2: a non-collapsed (singleton) bucket serves as soon as its own file is - // extracted, without waiting out the idle threshold. - it('serves a singleton bucket promptly without an idle wait', async () => { - codeExtractSpy.mockReturnValue(extractResult('devup-ui-1.css')) - getCssSpy.mockReturnValue('css') - const { coordinator, port } = await startAndGetPort( - makeOptions({ canonicalMap: {}, idleThresholdMs: 2000 }), - ) - await extract(port, 'src/f.tsx') - - const t0 = Date.now() - const res = await httpRequest( - port, - 'GET', - '/css?fileNum=1&importMainCss=true&waitForIdle=true', - ) - const elapsed = Date.now() - t0 - - expect(res.status).toBe(200) - // Members = {f} (already extracted) -> immediate; the 2000ms idle threshold - // would otherwise dominate on the old idle-only path. - expect(elapsed).toBeLessThan(1000) - expect(getCssSpy).toHaveBeenCalledWith(1, true) - - coordinator.close() - }) - - // T3: a bucket member that never arrives -> the per-bucket wait fails open - // after maxWaitMs (serves whatever exists) instead of hanging the build. - it('fails open and serves partial CSS when a bucket member never extracts', async () => { - codeExtractSpy.mockReturnValue(extractResult('devup-ui-1.css')) - getCssSpy.mockReturnValue('partial-css') - const warnSpy = spyOn(console, 'warn').mockReturnValue(undefined) - // m1 is a member of the bucket but is never POSTed to /extract. - const canonicalMap = { 'src/m1.tsx': 'src/bucket.tsx' } - const { coordinator, port } = await startAndGetPort( - makeOptions({ canonicalMap, idleThresholdMs: 100, maxWaitMs: 150 }), - ) - // Extract only the bucket root; src/m1.tsx stays missing forever. - await extract(port, 'src/bucket.tsx') - - const t0 = Date.now() - const res = await httpRequest( - port, - 'GET', - '/css?fileNum=1&importMainCss=true&waitForIdle=true', - ) - const elapsed = Date.now() - t0 - - // Resolves via the hard timeout (fail open) rather than hanging. - expect(res.status).toBe(200) - expect(res.body).toBe('partial-css') - expect(elapsed).toBeGreaterThanOrEqual(150) - expect(warnSpy).toHaveBeenCalled() - expect(getCssSpy).toHaveBeenCalledWith(1, true) - - warnSpy.mockRestore() - coordinator.close() - }) - - // T4: base css resolves DETERMINISTICALLY once every route-reachable runtime - // file (expectedBaseFiles) is extracted — NOT after an idle gap. Proven by a - // large idleThresholdMs that would dominate if the idle path were taken. - it('serves base css as soon as all expectedBaseFiles are extracted (no idle wait)', async () => { - codeExtractSpy.mockReturnValue(extractResult('devup-ui.css')) - getCssSpy.mockReturnValue('base-css') - const { coordinator, port } = await startAndGetPort( - makeOptions({ - expectedBaseFiles: ['src/a.tsx', 'src/b.tsx'], - idleThresholdMs: 5000, + await handle.ready + const listens = spy.mock.calls.length + spy.mockRestore() + await delay(50) + + expect(listens).toBe(1) + expect(existsSync(app.portFile)).toBe(false) + }) + + it('fails ready, and drain, when the engine cannot be rebuilt at startup', async () => { + const app = createTestApp() + const stateFile = join(app.root, 'df', 'state.json') + const first = startCoordinator(app.options({ watch: true, stateFile })) + await first.drain() + const second = startCoordinator( + app.options({ + watch: true, + stateFile, + createEngine: () => { + throw new Error('engine unavailable') + }, }), ) - await extract(port, 'src/a.tsx') - await extract(port, 'src/b.tsx') - const t0 = Date.now() - const res = await httpRequest( - port, - 'GET', - '/css?importMainCss=false&waitForIdle=true', + expect(String(await failure(second.ready))).toContain('engine unavailable') + expect(String(await failure(second.drain()))).toContain( + 'engine unavailable', ) - const elapsed = Date.now() - t0 - - expect(res.status).toBe(200) - expect(res.body).toBe('base-css') - // Both expected files extracted -> immediate; the 5000ms idle threshold is - // never consulted on the deterministic path. - expect(elapsed).toBeLessThan(1000) - - coordinator.close() + expect(existsSync(app.portFile)).toBe(false) }) - it('serves complete production CSS from the prewarmed sheet before late loaders run', async () => { - codeExtractSpy.mockReturnValue(extractResult('devup-ui-1.css')) - getCssSpy.mockReturnValue('prewarmed-css') - const canonicalMap = { 'src/late.tsx': 'src/page.tsx' } - const { coordinator, port } = await startAndGetPort( - makeOptions({ - canonicalMap, - expectedBaseFiles: ['src/page.tsx', 'src/late.tsx'], - prewarmedFiles: ['src/page.tsx', 'src/late.tsx'], - quietMs: 5000, - }), - ) - - // The first loader POST establishes the file-number -> bucket mapping. - // The late member has not POSTed, but its atoms already exist because the - // plugin synchronously prewarmed it before starting the coordinator. - await extract(port, 'src/page.tsx') - expect(codeExtractSpy).toHaveBeenCalledTimes(1) + it('drain waits for an accepted write, refuses new requests, then closes', async () => { + const app = createTestApp() + app.write('src/a.tsx', box('red')) + const stateFile = join(app.root, 'df', 'state.json') + const handle = startCoordinator(app.options({ stateFile })) + await handle.ready + const client = connect(app.portFile, app.identity) + const original = fsp.rename + let renaming = 0 + const spy = spyOn(fsp, 'rename').mockImplementation(async (...args) => { + renaming += 1 + await delay(300) + return original(...args) + }) - const t0 = Date.now() - const [bucketCss, baseCss] = await Promise.all([ - httpRequest( - port, - 'GET', - '/css?fileNum=1&importMainCss=true&waitForIdle=true', + try { + const extract = client.post('/extract', app.post('src/a.tsx')) + await eventually(() => (renaming > 0 ? true : undefined)) + let drained = false + const drain = handle.drain().then(() => { + drained = true + }) + const refused = await client.get('/health') + + await delay(50) + expect(drained).toBe(false) + expect(refused.status).toBe(503) + expect(JSON.parse(refused.body).error).toContain('shutting down') + await drain + expect((await extract).status).toBe(200) + } finally { + spy.mockRestore() + } + expect( + readCoordinatorState(stateFile, '')?.inputs.map( + (input) => input.filename, ), - httpRequest(port, 'GET', '/css?waitForIdle=true'), - ]) - const elapsed = Date.now() - t0 - - expect(bucketCss.body).toBe('prewarmed-css') - expect(baseCss.body).toBe('prewarmed-css') - expect(elapsed).toBeLessThan(1000) - // If the prewarmed files did not seed completion, both requests would wait - // for src/late.tsx (or the five-second quiet fallback). - expect(codeExtractSpy).toHaveBeenCalledTimes(1) - - coordinator.close() - }) - - it('serves prewarmed singleCss before any source loader runs', async () => { - getCssSpy.mockReturnValue('prewarmed-single-css') - const { coordinator, port } = await startAndGetPort( - makeOptions({ - singleCss: true, - expectedBaseFiles: ['src/page.tsx', 'src/late.tsx'], - prewarmedFiles: ['src/page.tsx', 'src/late.tsx'], - quietMs: 5000, - }), - ) - - const t0 = Date.now() - const res = await httpRequest(port, 'GET', '/css?waitForIdle=true') - - expect(res.status).toBe(200) - expect(res.body).toBe('prewarmed-single-css') - expect(Date.now() - t0).toBeLessThan(1000) - expect(codeExtractSpy).not.toHaveBeenCalled() - - coordinator.close() - }) - - // T5: the deterministic wait blocks base css until a still-missing - // expectedBaseFile arrives — even after the idle threshold elapses with - // nothing in flight. This is exactly the gap-between-waves case the old idle - // heuristic resolved too early (dropping late files' styles). - it('blocks base css until a missing expectedBaseFile is extracted', async () => { - codeExtractSpy.mockReturnValue(extractResult('devup-ui.css')) - getCssSpy.mockReturnValue('base-css') - const { coordinator, port } = await startAndGetPort( - makeOptions({ - expectedBaseFiles: ['src/a.tsx', 'src/late.tsx'], - idleThresholdMs: 50, - }), - ) - await extract(port, 'src/a.tsx') - - let resolved = false - const cssPromise = httpRequest( - port, - 'GET', - '/css?importMainCss=false&waitForIdle=true', - ).then((r) => { - resolved = true - return r + ).toEqual(['src/a.tsx']) + expect(existsSync(app.portFile)).toBe(false) + }) + + it('flushCoordinatorWrites waits for every live coordinator', async () => { + const apps = [createTestApp(), createTestApp()] + const states = apps.map((app) => join(app.root, 'df', 'state.json')) + for (const app of apps) app.write('src/a.tsx', box('red')) + const handles = apps.map((app, i) => + startCoordinator(app.options({ stateFile: states[i] })), + ) + await Promise.all(handles.map((handle) => handle.ready)) + const original = fsp.rename + const spy = spyOn(fsp, 'rename').mockImplementation(async (...args) => { + await delay(200) + return original(...args) }) - // Idle threshold (50ms) elapses and nothing is in flight, yet src/late.tsx - // is still missing -> must NOT resolve. - await new Promise((r) => setTimeout(r, 300)) - expect(resolved).toBe(false) - - await extract(port, 'src/late.tsx') - const res = await cssPromise - expect(res.status).toBe(200) - expect(res.body).toBe('base-css') - coordinator.close() - }) - - // T7: legacy callers that do not prewarm still fail open after the quiet - // window when a graph member never reports, rather than hanging forever. - // Production plugin builds take the deterministic prewarmed path instead. - it('serves a bucket via the quiet exit when a member is never compiled', async () => { - codeExtractSpy.mockReturnValue(extractResult('devup-ui-1.css')) - getCssSpy.mockReturnValue('bucket-css') - const infoSpy = spyOn(console, 'info').mockReturnValue(undefined) - const warnSpy = spyOn(console, 'warn').mockReturnValue(undefined) - const canonicalMap = { 'src/phantom.tsx': 'src/bucket.tsx' } - const { coordinator, port } = await startAndGetPort( - makeOptions({ canonicalMap, quietMs: 100, maxWaitMs: 10_000 }), - ) - await extract(port, 'src/bucket.tsx') - - const t0 = Date.now() - const res = await httpRequest( - port, - 'GET', - '/css?fileNum=1&importMainCss=true&waitForIdle=true', - ) - const elapsed = Date.now() - t0 - - expect(res.status).toBe(200) - expect(res.body).toBe('bucket-css') - // Resolved by the quiet exit (~100ms), NOT the 10s wall-clock backstop. - expect(elapsed).toBeLessThan(5000) - expect(infoSpy).toHaveBeenCalled() - expect(warnSpy).not.toHaveBeenCalled() - - infoSpy.mockRestore() - warnSpy.mockRestore() - coordinator.close() - }) - - // T8: the same legacy quiet fallback applies to an expected base file when - // the caller did not seed prewarmed completion state. - it('serves base css via the quiet exit when an expectedBaseFile is never compiled', async () => { - codeExtractSpy.mockReturnValue(extractResult('devup-ui.css')) - getCssSpy.mockReturnValue('base-css') - const { coordinator, port } = await startAndGetPort( - makeOptions({ - expectedBaseFiles: ['src/a.tsx', 'src/phantom.tsx'], - quietMs: 100, - maxWaitMs: 10_000, - }), - ) - await extract(port, 'src/a.tsx') - - const t0 = Date.now() - const res = await httpRequest( - port, - 'GET', - '/css?importMainCss=false&waitForIdle=true', - ) - const elapsed = Date.now() - t0 - - expect(res.status).toBe(200) - expect(res.body).toBe('base-css') - // Quiet exit (~100ms), not the 10s backstop. - expect(elapsed).toBeLessThan(5000) - - coordinator.close() + try { + const posts = apps.map((app) => + connect(app.portFile, app.identity).post( + '/extract', + app.post('src/a.tsx'), + ), + ) + await delay(50) + await flushCoordinatorWrites() + expect(await Promise.all(posts)).toHaveLength(2) + } finally { + spy.mockRestore() + } + for (const state of states) { + expect(readCoordinatorState(state, '')?.inputs).toHaveLength(1) + } }) - // T6: a phantom expectedBaseFile that never extracts fails open via the - // dormant maxWaitMs backstop instead of hanging the build forever. - it('fails open on a phantom expectedBaseFile via maxWaitMs', async () => { - codeExtractSpy.mockReturnValue(extractResult('devup-ui.css')) - getCssSpy.mockReturnValue('base-css') - const { coordinator, port } = await startAndGetPort( - makeOptions({ - expectedBaseFiles: ['src/a.tsx', 'src/phantom.tsx'], - maxWaitMs: 150, - }), - ) - await extract(port, 'src/a.tsx') + it('resetCoordinator stops every live coordinator', async () => { + const apps = [createTestApp(), createTestApp()] + await Promise.all(apps.map((app) => startCoordinator(app.options()).ready)) - const t0 = Date.now() - const res = await httpRequest( - port, - 'GET', - '/css?importMainCss=false&waitForIdle=true', - ) - const elapsed = Date.now() - t0 - - expect(res.status).toBe(200) - expect(res.body).toBe('base-css') - expect(elapsed).toBeGreaterThanOrEqual(150) + resetCoordinator() - coordinator.close() + for (const app of apps) expect(existsSync(app.portFile)).toBe(false) }) }) diff --git a/packages/next-plugin/src/__tests__/css-loader.test.ts b/packages/next-plugin/src/__tests__/css-loader.test.ts index 1ff6f7187..19ac763ac 100644 --- a/packages/next-plugin/src/__tests__/css-loader.test.ts +++ b/packages/next-plugin/src/__tests__/css-loader.test.ts @@ -1,11 +1,13 @@ +import { randomUUID } from 'node:crypto' import * as fs from 'node:fs' -import * as http from 'node:http' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import * as wasm from '@devup-ui/wasm' import { - afterAll, afterEach, - beforeAll, + beforeEach, describe, expect, it, @@ -13,438 +15,210 @@ import { spyOn, } from 'bun:test' -import devupUICssLoader, { resetInit, setWasmForTesting } from '../css-loader' +import { formatPortFile } from '../coordinator-port' +import type { DevupUICssLoaderOptions } from '../css-loader' +import loader, { resetInit, setWasmForTesting } from '../css-loader' -type CssLoaderThis = ThisParameterType - -let getCssSpy: ReturnType -let registerThemeSpy: ReturnType -let importSheetSpy: ReturnType -let importClassMapSpy: ReturnType -let importFileMapSpy: ReturnType -let existsSyncSpy: ReturnType -let readFileSyncSpy: ReturnType - -const defaultOptions = { +const defaults = { watch: false, - sheetFile: 'sheet.json', - classMapFile: 'classMap.json', - fileMapFile: 'fileMap.json', + sheetFile: 'sheet', + classMapFile: 'classes', + fileMapFile: 'files', themeFile: 'devup.json', - theme: {}, defaultSheet: {}, defaultClassMap: {}, defaultFileMap: {}, } - -beforeAll(() => { - getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('get css') - registerThemeSpy = spyOn(wasm, 'registerTheme').mockReturnValue(undefined) - importSheetSpy = spyOn(wasm, 'importSheet').mockReturnValue(undefined) - importClassMapSpy = spyOn(wasm, 'importClassMap').mockReturnValue(undefined) - importFileMapSpy = spyOn(wasm, 'importFileMap').mockReturnValue(undefined) - existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) - readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue('{}') - setWasmForTesting(wasm) -}) - -afterEach(() => { - resetInit() - getCssSpy.mockClear() - registerThemeSpy.mockClear() - importSheetSpy.mockClear() - importClassMapSpy.mockClear() - importFileMapSpy.mockClear() - existsSyncSpy.mockClear() - readFileSyncSpy.mockClear() -}) - -afterAll(() => { - getCssSpy.mockRestore() - registerThemeSpy.mockRestore() - importSheetSpy.mockRestore() - importClassMapSpy.mockRestore() - importFileMapSpy.mockRestore() - existsSyncSpy.mockRestore() - readFileSyncSpy.mockRestore() -}) - -describe('devupUICssLoader', () => { - it('should return css on no watch', () => { - const callback = mock() - const addContextDependency = mock() - devupUICssLoader.bind({ - callback, - addContextDependency, - resourcePath: 'devup-ui.css', - getOptions: () => ({ ...defaultOptions, watch: false }), - } as unknown as CssLoaderThis)(Buffer.from('data'), '') - expect(callback).toHaveBeenCalledWith( - null, - Buffer.from('data'), - '', - undefined, +function invoke( + options: Partial, + resourcePath = 'devup-ui.css', + resourceQuery?: string, +) { + const addDependency = mock(), + callback = mock() + const result = new Promise((resolve, reject) => { + callback.mockImplementation((error: Error | null, content: unknown) => { + if (error) reject(error) + else resolve(content) + }) + Reflect.apply( + loader, + { + getOptions: () => ({ ...defaults, ...options }), + resourcePath, + resourceQuery, + addDependency, + callback, + async: () => callback, + }, + [Buffer.from('disk'), 'map', 'meta'], ) - // Should initialize on first call - expect(importFileMapSpy).toHaveBeenCalledTimes(1) - expect(importClassMapSpy).toHaveBeenCalledTimes(1) - expect(importSheetSpy).toHaveBeenCalledTimes(1) - expect(registerThemeSpy).toHaveBeenCalledTimes(1) }) + return { result, addDependency, callback } +} - it('should return _compiler hit css on watch', () => { - const callback = mock() - const addContextDependency = mock() - devupUICssLoader.bind({ - callback, - addContextDependency, - getOptions: () => ({ ...defaultOptions, watch: true }), - resourcePath: 'devup-ui.css', - } as unknown as CssLoaderThis)(Buffer.from('data'), '') - expect(callback).toHaveBeenCalledTimes(1) - expect(getCssSpy).toHaveBeenCalledTimes(1) - getCssSpy.mockClear() - devupUICssLoader.bind({ - callback, - addContextDependency, - getOptions: () => ({ ...defaultOptions, watch: true }), - resourcePath: 'devup-ui.css', - } as unknown as CssLoaderThis)(Buffer.from('data'), '') - - expect(getCssSpy).toHaveBeenCalledTimes(1) - - getCssSpy.mockClear() - - devupUICssLoader.bind({ - callback, - addContextDependency, - getOptions: () => ({ ...defaultOptions, watch: true }), - resourcePath: 'devup-ui-10.css', - } as unknown as CssLoaderThis)(Buffer.from(''), '') - - expect(getCssSpy).toHaveBeenCalledTimes(1) +describe('local CSS loading', () => { + let spies: ReturnType[] + let exists: ReturnType, read: ReturnType + beforeEach(() => { + resetInit() + setWasmForTesting(wasm) + exists = spyOn(fs, 'existsSync').mockReturnValue(false) + read = spyOn(fs, 'readFileSync').mockReturnValue('{}') + spies = [exists, read, spyOn(wasm, 'getCss').mockReturnValue('live')] + for (const key of [ + 'importSheet', + 'importClassMap', + 'importFileMap', + 'registerTheme', + ] as const) + spies.push(spyOn(wasm, key).mockImplementation(() => {})) }) - - it('should read files from disk in watch mode when files exist', () => { - existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockReturnValue(JSON.stringify({ theme: { color: 'red' } })) - - const callback = mock() - const addContextDependency = mock() - devupUICssLoader.bind({ - callback, - addContextDependency, - getOptions: () => ({ ...defaultOptions, watch: true }), - resourcePath: 'devup-ui.css', - } as unknown as CssLoaderThis)(Buffer.from('data'), '') - - // Should read files from disk - expect(existsSyncSpy).toHaveBeenCalledTimes(4) - expect(readFileSyncSpy).toHaveBeenCalledTimes(4) - expect(importSheetSpy).toHaveBeenCalledTimes(1) - expect(importClassMapSpy).toHaveBeenCalledTimes(1) - expect(importFileMapSpy).toHaveBeenCalledTimes(1) - expect(registerThemeSpy).toHaveBeenCalledWith({ color: 'red' }) + afterEach(() => { + for (const spy of spies) spy.mockRestore() + setWasmForTesting(undefined) }) - - it('should handle missing theme in devup.json', () => { - existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockReturnValue(JSON.stringify({})) - - const callback = mock() - const addContextDependency = mock() - devupUICssLoader.bind({ - callback, - addContextDependency, - getOptions: () => ({ ...defaultOptions, watch: true }), - resourcePath: 'devup-ui.css', - } as unknown as CssLoaderThis)(Buffer.from('data'), '') - - // Should call registerTheme with empty object when theme is missing - expect(registerThemeSpy).toHaveBeenCalledWith({}) - }) - - it('should fetch CSS from coordinator in coordinator mode', async () => { - // Start a mock coordinator server - const server = http.createServer((_req, res) => { - res.writeHead(200, { 'Content-Type': 'text/css' }) - res.end('.a{color:red}') - }) - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) - const port = (server.address() as { port: number }).port - const portFile = `test-coordinator-${port}.port` - - existsSyncSpy.mockImplementation((p: unknown) => p === portFile) - readFileSyncSpy.mockImplementation((p: unknown) => - p === portFile ? String(port) : '{}', + it('returns build source and initializes defaults once', async () => { + const run = invoke({}) + expect(await run.result).toEqual(Buffer.from('disk')) + expect(run.callback).toHaveBeenCalledWith( + null, + Buffer.from('disk'), + 'map', + 'meta', ) - - const result = await new Promise((resolve, reject) => { - const asyncCallback = mock((err: Error | null, content?: string) => { - if (err) reject(err) - else resolve(content!) - }) - devupUICssLoader.bind({ - async: () => asyncCallback, - addContextDependency: mock(), - getOptions: () => ({ - ...defaultOptions, - watch: true, - coordinatorPortFile: portFile, - }), - resourcePath: 'devup-ui-1.css', - } as unknown as CssLoaderThis)( - Buffer.from('stale content'), - 'existing-map', - 'meta', - ) - }) - - expect(result).toBe('.a{color:red}') - - // NO WASM functions should be called - expect(getCssSpy).not.toHaveBeenCalled() - expect(importSheetSpy).not.toHaveBeenCalled() - expect(importClassMapSpy).not.toHaveBeenCalled() - expect(importFileMapSpy).not.toHaveBeenCalled() - expect(registerThemeSpy).not.toHaveBeenCalled() - - server.close() + await invoke({}).result + expect(wasm.importSheet).toHaveBeenCalledTimes(1) }) - - it('should fetch CSS from coordinator for devup-ui.css (singleCss)', async () => { - const server = http.createServer((req, res) => { - const url = new URL(req.url ?? '/', `http://${req.headers.host}`) - expect(url.searchParams.get('importMainCss')).toBe('false') - res.writeHead(200, { 'Content-Type': 'text/css' }) - res.end('.full{display:flex}') - }) - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) - const port = (server.address() as { port: number }).port - const portFile = `test-coordinator-${port}.port` - - existsSyncSpy.mockImplementation((p: unknown) => p === portFile) - readFileSyncSpy.mockImplementation((p: unknown) => - p === portFile ? String(port) : '{}', - ) - - const result = await new Promise((resolve, reject) => { - const asyncCallback = mock((err: Error | null, content?: string) => { - if (err) reject(err) - else resolve(content!) - }) - devupUICssLoader.bind({ - async: () => asyncCallback, - addContextDependency: mock(), - getOptions: () => ({ - ...defaultOptions, - watch: false, - coordinatorPortFile: portFile, - }), - resourcePath: 'devup-ui.css', - } as unknown as CssLoaderThis)(Buffer.from('stale'), '', '') - }) - - expect(result).toBe('.full{display:flex}') - expect(getCssSpy).not.toHaveBeenCalled() - expect(importSheetSpy).not.toHaveBeenCalled() - - server.close() + it('reads live CSS with missing watch snapshots', async () => { + expect(await invoke({ watch: true }).result).toBe('live') + expect(wasm.importSheet).not.toHaveBeenCalled() }) - - it('should fetch CSS from coordinator with ?fileNum query in resourcePath', async () => { - const server = http.createServer((req, res) => { - const url = new URL(req.url ?? '/', `http://${req.headers.host}`) - // Should receive fileNum=79 and importMainCss=true - expect(url.searchParams.get('fileNum')).toBe('79') - expect(url.searchParams.get('importMainCss')).toBe('true') - res.writeHead(200, { 'Content-Type': 'text/css' }) - res.end('.file79{color:blue}') - }) - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) - const port = (server.address() as { port: number }).port - const portFile = `test-coordinator-query-${port}.port` - - existsSyncSpy.mockImplementation((p: unknown) => p === portFile) - readFileSyncSpy.mockImplementation((p: unknown) => - p === portFile ? String(port) : '{}', - ) - - const result = await new Promise((resolve, reject) => { - const asyncCallback = mock((err: Error | null, content?: string) => { - if (err) reject(err) - else resolve(content!) - }) - devupUICssLoader.bind({ - async: () => asyncCallback, - addContextDependency: mock(), - getOptions: () => ({ - ...defaultOptions, - watch: true, - coordinatorPortFile: portFile, - }), - // Turbopack embeds query in resourcePath - resourcePath: '/path/to/df/devup-ui/devup-ui.css?fileNum=79', - } as unknown as CssLoaderThis)( - Buffer.from('stale content'), - 'existing-map', - 'meta', + it.each(['{}', '{"theme":{"color":"red"}}'])( + 'restores parsed watch snapshots and theme', + async (content) => { + exists.mockReturnValue(true) + read.mockReturnValue(content) + expect(await invoke({ watch: true }).result).toBe('live') + expect(wasm.registerTheme).toHaveBeenCalledWith( + JSON.parse(content).theme ?? {}, ) - }) - - expect(result).toBe('.file79{color:blue}') - expect(getCssSpy).not.toHaveBeenCalled() - - server.close() + }, + ) + it('does not restore a partial snapshot after parse failure', async () => { + exists.mockReturnValue(true) + read.mockImplementation((file: unknown) => + file === 'files' ? 'broken' : '{}', + ) + await expect(invoke({ watch: true }).result).rejects.toThrow() + expect(wasm.importSheet).not.toHaveBeenCalled() }) +}) - it('should fetch CSS from coordinator with ?fileNum in resourceQuery', async () => { - const server = http.createServer((req, res) => { - const url = new URL(req.url ?? '/', `http://${req.headers.host}`) - expect(url.searchParams.get('fileNum')).toBe('3') - expect(url.searchParams.get('importMainCss')).toBe('true') - res.writeHead(200, { 'Content-Type': 'text/css' }) - res.end('.file3{color:green}') +describe('coordinator CSS loading', () => { + let dir: string, portFile: string + let server: ReturnType + let status = 200, + content = 'live', + query = '' + beforeEach(async () => { + dir = fs.mkdtempSync(join(tmpdir(), 'devup-css-loader-')) + portFile = join(dir, 'endpoint') + status = 200 + content = 'live' + query = '' + const identity = { project: dir, token: randomUUID() } + server = createServer((req, res) => { + if (req.url === '/health') + res.end(formatPortFile(port, process.pid, identity)) + else { + query = req.url ?? '' + res.writeHead(status) + res.end(content) + } }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) - const port = (server.address() as { port: number }).port - const portFile = `test-coordinator-rq-${port}.port` - - existsSyncSpy.mockImplementation((p: unknown) => p === portFile) - readFileSyncSpy.mockImplementation((p: unknown) => - p === portFile ? String(port) : '{}', + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('Expected TCP server') + const port = address.port + fs.writeFileSync(portFile, formatPortFile(port, process.pid, identity)) + fs.writeFileSync( + join(dir, 'devup.json'), + '{"extends":["base.json","missing.json"]}', ) - - const result = await new Promise((resolve, reject) => { - const asyncCallback = mock((err: Error | null, content?: string) => { - if (err) reject(err) - else resolve(content!) - }) - devupUICssLoader.bind({ - async: () => asyncCallback, - addContextDependency: mock(), - getOptions: () => ({ - ...defaultOptions, - watch: false, - coordinatorPortFile: portFile, - }), - // resourcePath without query, query in separate property - resourcePath: '/path/to/df/devup-ui/devup-ui.css', - resourceQuery: '?fileNum=3', - } as unknown as CssLoaderThis)(Buffer.from('stale'), '', '') - }) - - expect(result).toBe('.file3{color:green}') - expect(getCssSpy).not.toHaveBeenCalled() - - server.close() + fs.writeFileSync(join(dir, 'base.json'), '{}') }) - - it('should error when coordinator port file never appears', async () => { - existsSyncSpy.mockReturnValue(false) - - const error = await new Promise((resolve) => { - const asyncCallback = mock((err: Error | null) => { - if (err) resolve(err) - }) - devupUICssLoader.bind({ - async: () => asyncCallback, - addContextDependency: mock(), - getOptions: () => ({ - ...defaultOptions, - coordinatorPortFile: 'nonexistent.port', - }), - resourcePath: 'devup-ui.css', - } as unknown as CssLoaderThis)(Buffer.from(''), '', '') - }) - - expect(error.message).toBe('Coordinator port file not found') + afterEach(async () => { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + fs.rmSync(dir, { recursive: true, force: true }) }) - - it('should error when coordinator returns non-200 status', async () => { - const server = http.createServer((_req, res) => { - res.writeHead(500, { 'Content-Type': 'text/plain' }) - res.end('Internal Server Error') - }) - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) - const port = (server.address() as { port: number }).port - const portFile = `test-coordinator-err-${port}.port` - - existsSyncSpy.mockImplementation((p: unknown) => p === portFile) - readFileSyncSpy.mockImplementation((p: unknown) => - p === portFile ? String(port) : '{}', + function run( + extra: Partial = {}, + path?: string, + resourceQuery?: string, + ) { + return invoke( + { + coordinatorPortFile: portFile, + projectRoot: dir, + revisionFile: 'revision', + requestTimeoutMs: 1000, + ...extra, + }, + path, + resourceQuery, ) - - const error = await new Promise((resolve) => { - const asyncCallback = mock((err: Error | null) => { - if (err) resolve(err) - }) - devupUICssLoader.bind({ - async: () => asyncCallback, - addContextDependency: mock(), - getOptions: () => ({ - ...defaultOptions, - coordinatorPortFile: portFile, - }), - resourcePath: 'devup-ui.css', - } as unknown as CssLoaderThis)(Buffer.from(''), '', '') - }) - - expect(error.message).toBe('Coordinator CSS error: 500') - server.close() + } + it('watches endpoint, revision and full config chain, including missing files', async () => { + const request = run() + expect(await request.result).toBe('live') + for (const file of [ + 'endpoint', + 'revision', + 'devup.json', + 'base.json', + 'missing.json', + ]) + expect(request.addDependency).toHaveBeenCalledWith(join(dir, file)) + expect( + new URL(query, 'http://localhost').searchParams.get('waitForIdle'), + ).toBe('true') }) - - it('should error when readCoordinatorPort throws', async () => { - const portFile = `test-coordinator-throw.port` - - existsSyncSpy.mockImplementation((p: unknown) => p === portFile) - readFileSyncSpy.mockImplementation((p: unknown) => { - if (p === portFile) throw new Error('EACCES: permission denied') - return '{}' - }) - - const error = await new Promise((resolve) => { - const asyncCallback = mock((err: Error | null) => { - if (err) resolve(err) - }) - devupUICssLoader.bind({ - async: () => asyncCallback, - addContextDependency: mock(), - getOptions: () => ({ - ...defaultOptions, - coordinatorPortFile: portFile, - }), - resourcePath: 'devup-ui.css', - } as unknown as CssLoaderThis)(Buffer.from(''), '', '') - }) - - expect(error.message).toBe('EACCES: permission denied') + it.each([ + ['devup-ui.css?fileNum=79', undefined, '79'], + ['devup-ui.css', '?fileNum=3', '3'], + ])( + 'preserves per-file query information %s', + async (path, resourceQuery, expected) => { + expect( + await run( + { + watch: true, + themeFiles: ['explicit.json'], + revisionFile: undefined, + }, + path, + resourceQuery, + ).result, + ).toBe('live') + const params = new URL(query, 'http://localhost').searchParams + expect(params.get('fileNum')).toBe(expected) + expect(params.get('importMainCss')).toBe('true') + expect(params.has('waitForIdle')).toBe(false) + }, + ) + it('propagates missing filename details from coordinator errors once', async () => { + status = 503 + content = '{"error":"Missing app/page.tsx"}' + const request = run() + await expect(request.result).rejects.toThrow('Missing app/page.tsx') + expect(request.callback).toHaveBeenCalledTimes(1) }) - - it('should error when coordinator connection fails', async () => { - const portFile = `test-coordinator-conn-err.port` - - existsSyncSpy.mockImplementation((p: unknown) => p === portFile) - // Use a port that nothing listens on - readFileSyncSpy.mockImplementation((p: unknown) => - p === portFile ? '19999' : '{}', - ) - - const error = await new Promise((resolve) => { - const asyncCallback = mock((err: Error | null) => { - if (err) resolve(err) - }) - devupUICssLoader.bind({ - async: () => asyncCallback, - addContextDependency: mock(), - getOptions: () => ({ - ...defaultOptions, - coordinatorPortFile: portFile, - }), - resourcePath: 'devup-ui.css', - } as unknown as CssLoaderThis)(Buffer.from(''), '', '') - }) - - expect(error).toBeInstanceOf(Error) + it('returns located errors for invalid config before HTTP dispatch', async () => { + fs.writeFileSync(join(dir, 'devup.json'), 'broken') + await expect(run().result).rejects.toThrow('devup-ui.css:1:1:') + expect(query).toBe('') }) }) diff --git a/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts new file mode 100644 index 000000000..b18a334c8 --- /dev/null +++ b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts @@ -0,0 +1,145 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' + +import { collectNumberedFiles, seedFileNumbers } from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' + +const sources: Record = { + 'src/a.tsx': `import { Box } from '@devup-ui/react'\nexport const A = () => `, + 'src/b.tsx': `import { Box } from '@devup-ui/react'\nexport const B = () => `, + 'src/nested/c.tsx': `import { Box } from '@devup-ui/react'\nexport const C = () => `, + 'src/nested/d.tsx': `import { Box, Flex } from '@devup-ui/react'\nexport const D = () => `, + 'node_modules/@acme/ui/e.tsx': `import { Box } from '@devup-ui/react'\nexport const E = () => `, +} + +/** How each plugin names the files it extracts */ +const schemes: Record (path: string) => string> = { + 'vite (absolute, posix)': () => (path) => path.replaceAll('\\', '/'), + 'webpack and next (relative to cwd)': (root) => (path) => + relative(root, path).replaceAll('\\', '/'), + 'rsbuild and bun (absolute, as found)': () => (path) => path, +} + +describe('class and file numbers do not depend on the order files are seen', () => { + let root: string + const original = process.cwd() + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'devup-ui-numbering-order-')) + for (const [path, code] of Object.entries(sources)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), code) + } + process.chdir(root) + }) + + afterAll(() => { + wasm.resetBuildState() + process.chdir(original) + rmSync(root, { recursive: true, force: true }) + }) + + function build( + scheme: (path: string) => string, + order: string[], + parallel: boolean, + ) { + wasm.resetBuildState() + seedFileNumbers( + wasm, + collectNumberedFiles({ + roots: [join(root, 'src')], + include: ['@acme/ui'], + needles: ['@devup-ui/react'], + cwd: root, + toId: scheme, + }), + ) + const extract = (file: string) => { + const output = wasm.codeExtractWithoutSourceMap( + scheme(join(root, file)), + readFileSync(join(root, file), 'utf-8'), + '@devup-ui/react', + '../css', + false, + false, + true, + {}, + ) + return { file, code: output.code, cssFile: output.cssFile } + } + const outputs = parallel + ? Promise.all(order.map(async (file) => extract(file))) + : Promise.resolve(order.map(extract)) + return outputs.then((results) => { + const byFile = Object.fromEntries( + results + .sort((a, b) => a.file.localeCompare(b.file)) + .map((r) => [r.file, r]), + ) + const fileMap = JSON.parse(wasm.exportFileMap()) as Record + const sheets = Object.values(fileMap) + .sort((a, b) => a - b) + .map((number) => wasm.getCss(number, false)) + return { + byFile, + fileMap: wasm.exportFileMap(), + classMap: wasm.exportClassMap(), + sheets, + base: wasm.getCss(null, false), + } + }) + } + + const files = Object.keys(sources) + const orders = [ + files, + [...files].reverse(), + [files[2], files[4], files[0], files[3], files[1]], + ] + + it.each(Object.entries(schemes))( + 'gives the same output for any order, one at a time or at once: %s', + async (_name, makeScheme) => { + const scheme = makeScheme(root) + const results = [] + for (const order of orders) { + results.push(await build(scheme, order, false)) + results.push(await build(scheme, order, true)) + } + for (const result of results.slice(1)) { + expect(result.fileMap).toBe(results[0].fileMap) + expect(result.byFile).toEqual(results[0].byFile) + expect(result.sheets).toEqual(results[0].sheets) + expect(result.base).toBe(results[0].base) + } + const numbers = Object.values( + JSON.parse(results[0].fileMap) as Record, + ).sort((a, b) => a - b) + expect(numbers).toEqual([0, 1, 2, 3, 4]) + }, + ) + + it('numbers the files in path order, and files that appear later after them', () => { + wasm.resetBuildState() + seedFileNumbers(wasm, ['b.tsx', 'a.tsx']) + seedFileNumbers(wasm, ['0.tsx', 'a.tsx', 'c.tsx']) + expect(JSON.parse(wasm.exportFileMap())).toEqual({ + 'a.tsx': 0, + 'b.tsx': 1, + '0.tsx': 2, + 'c.tsx': 3, + }) + expect(wasm.exportFileMap()).toBe( + '{"0.tsx":2,"a.tsx":0,"b.tsx":1,"c.tsx":3}', + ) + }) +}) diff --git a/packages/next-plugin/src/__tests__/dev-state.test.ts b/packages/next-plugin/src/__tests__/dev-state.test.ts new file mode 100644 index 000000000..b4476b286 --- /dev/null +++ b/packages/next-plugin/src/__tests__/dev-state.test.ts @@ -0,0 +1,262 @@ +import { createHash } from 'node:crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +import { persistInitialState, resumeEngine } from '../dev-state' +import { runPrewarm } from '../prewarm-run' +import { createAppContext, createSession } from '../session' +import { + captureCoordinatorState, + readCoordinatorState, + writeCoordinatorStateSync, +} from '../state' +import { createWasm, type DevupWasm } from '../wasm' +import { box, installProjectHooks, makeProject } from './project' + +installProjectHooks() + +const originalEnv = { ...process.env } +afterEach(() => { + process.env = { ...originalEnv } +}) + +function setup( + phase: 'development' | 'production', + files: Record = {}, +) { + process.env.NODE_ENV = phase + process.chdir(makeProject(files)) + const context = createAppContext({}, {}) + const session = createSession(context) + const engines: DevupWasm[] = [] + const createEngine = () => { + const engine = createWasm(context.root) + engines.push(engine) + return engine + } + return { context, session, createEngine, engines } +} + +function checkpoint( + fixture: ReturnType, + patch: Record = {}, + revision = 7, +) { + const source = createWasm(fixture.context.root) + runPrewarm({ + context: fixture.context, + engine: source, + files: ['src/z.tsx', 'src/a.tsx'], + collectMs: undefined, + }) + const snapshot = { + ...captureCoordinatorState({ + wasm: source, + optionsKey: fixture.context.appKey, + project: fixture.context.root, + revision, + inputs: [], + }), + ...patch, + } + mkdirSync(dirname(fixture.session.stateFile), { recursive: true }) + writeFileSync(fixture.session.stateFile, JSON.stringify(snapshot)) + return source +} + +const files = { + 'src/a.tsx': box('bg="red"'), + 'src/z.tsx': box('bg="blue"'), +} + +describe('resumeEngine', () => { + it('starts production from a fresh engine and never reads old state', () => { + const fixture = setup('production', files) + checkpoint(fixture) + + const resumed = resumeEngine(fixture) + + expect(fixture.engines).toEqual([resumed.engine]) + expect(resumed.revision).toBe(0) + expect(JSON.parse(resumed.engine.exportFileMap())).toEqual({}) + }) + + it('starts development cold when there is no checkpoint', () => { + const fixture = setup('development', files) + + const resumed = resumeEngine(fixture) + + expect(fixture.engines).toEqual([resumed.engine]) + expect(resumed.revision).toBe(0) + expect(JSON.parse(resumed.engine.exportFileMap())).toEqual({}) + }) + + it('starts cold when the checkpoint belongs to other options', () => { + const fixture = setup('development', files) + checkpoint(fixture, { optionsKey: 'another-app' }) + + const resumed = resumeEngine(fixture) + + expect(resumed.revision).toBe(0) + expect(JSON.parse(resumed.engine.exportFileMap())).toEqual({}) + }) + + it('keeps the names and numbers of the last session but not its styles', () => { + const fixture = setup('development', files) + const previous = checkpoint(fixture) + + const resumed = resumeEngine(fixture) + + expect(fixture.engines).toEqual([resumed.engine]) + expect(resumed.revision).toBe(7) + expect(JSON.parse(resumed.engine.exportFileMap())).toEqual( + JSON.parse(previous.exportFileMap()), + ) + expect(JSON.parse(resumed.engine.exportClassMap())).toEqual( + JSON.parse(previous.exportClassMap()), + ) + expect(resumed.engine.getCss(undefined, false)).not.toContain('background') + expect(resumed.engine.getCss(0, false)).not.toContain('background') + }) + + it('discards a corrupt checkpoint with a located warning', () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + const fixture = setup('development', files) + mkdirSync(dirname(fixture.session.stateFile), { recursive: true }) + writeFileSync(fixture.session.stateFile, '{ truncated') + + try { + const resumed = resumeEngine(fixture) + + expect(resumed.revision).toBe(0) + expect(fixture.engines).toHaveLength(2) + expect(resumed.engine).toBe(fixture.engines[1]) + const message = String(warn.mock.calls[0]?.[0]) + expect(message).toContain(`${fixture.session.stateFile}:1:1:`) + expect(message).toContain('Starting cold') + } finally { + warn.mockRestore() + } + }) + + it('never publishes an engine whose import failed part-way', () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + const fixture = setup('development', files) + checkpoint(fixture, { fileMap: { 'src/a.tsx': 'not-a-number' } }) + + try { + const resumed = resumeEngine(fixture) + + const [touched, replacement] = fixture.engines + expect(resumed.engine).toBe(replacement!) + expect(resumed.engine).not.toBe(touched!) + expect(JSON.parse(touched!.exportClassMap())).not.toEqual({}) + expect(JSON.parse(resumed.engine.exportClassMap())).toEqual({}) + expect(JSON.parse(resumed.engine.exportFileMap())).toEqual({}) + expect(resumed.revision).toBe(7) + expect(warn).toHaveBeenCalledTimes(1) + } finally { + warn.mockRestore() + } + }) +}) + +describe('persistInitialState', () => { + it('commits the prewarmed state and the inputs that produced it as one checkpoint', () => { + const fixture = setup('development', { + 'src/page.tsx': `import { Box } from '@devup-ui/react'\nimport { color } from './color'\nexport const C = () => `, + 'src/color.ts': 'export const color = "purple"', + 'src/plain.tsx': box('p={1}'), + }) + const { engine } = resumeEngine(fixture) + const prewarm = runPrewarm({ + context: fixture.context, + engine, + files: ['src/page.tsx', 'src/plain.tsx'], + collectMs: undefined, + }) + + persistInitialState({ + context: fixture.context, + session: fixture.session, + engine, + revision: 3, + prewarm, + }) + + const snapshot = readCoordinatorState( + fixture.session.stateFile, + fixture.context.appKey, + ) + const sha1 = (path: string) => + createHash('sha1').update(readFileSync(path)).digest('hex') + const colorPath = join(fixture.context.root, 'src/color.ts') + expect(snapshot).toMatchObject({ + version: 1, + optionsKey: fixture.context.appKey, + project: fixture.context.root, + revision: 3, + }) + expect(snapshot?.fileMap).toEqual(JSON.parse(engine.exportFileMap())) + expect(snapshot?.classMap).toEqual(JSON.parse(engine.exportClassMap())) + expect(JSON.stringify(snapshot?.sheet)).toContain('purple') + expect(snapshot?.inputs).toEqual([ + { + filename: 'src/page.tsx', + resourcePath: join(fixture.context.root, 'src/page.tsx'), + source: readFileSync( + join(fixture.context.root, 'src/page.tsx'), + 'utf-8', + ), + dependencies: ['src/color.ts'], + stamps: { [colorPath]: sha1(colorPath) }, + backing: sha1(join(fixture.context.root, 'src/page.tsx')), + }, + { + filename: 'src/plain.tsx', + resourcePath: join(fixture.context.root, 'src/plain.tsx'), + source: box('p={1}'), + dependencies: [], + stamps: {}, + backing: sha1(join(fixture.context.root, 'src/plain.tsx')), + }, + ]) + }) + + it('replaces an existing checkpoint atomically', () => { + const fixture = setup('development', files) + const previous = checkpoint(fixture) + const { engine } = resumeEngine(fixture) + const prewarm = runPrewarm({ + context: fixture.context, + engine, + files: ['src/a.tsx'], + collectMs: undefined, + }) + writeCoordinatorStateSync( + fixture.session.stateFile, + captureCoordinatorState({ + wasm: previous, + optionsKey: 'stale', + project: 'x', + revision: 1, + inputs: [], + }), + ) + + persistInitialState({ + context: fixture.context, + session: fixture.session, + engine, + revision: 8, + prewarm, + }) + + expect( + readCoordinatorState(fixture.session.stateFile, fixture.context.appKey) + ?.revision, + ).toBe(8) + }) +}) diff --git a/packages/next-plugin/src/__tests__/effective-context.test.ts b/packages/next-plugin/src/__tests__/effective-context.test.ts new file mode 100644 index 000000000..d8bc895ec --- /dev/null +++ b/packages/next-plugin/src/__tests__/effective-context.test.ts @@ -0,0 +1,102 @@ +import { resolve } from 'node:path' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { bindEffectiveContext, createSetupShell } from '../effective-context' +import { createAppContext, createSession } from '../session' +import { installProjectHooks, makeProject } from './project' + +installProjectHooks() +const originalEnv = { ...process.env } +afterEach(() => { + process.env = { ...originalEnv } +}) + +describe('late effective Next settings', () => { + it('uses the real build phase instead of development NODE_ENV for debug prerender', () => { + // Given + process.chdir(makeProject()) + process.env.NODE_ENV = 'development' + const shell = createSetupShell(createAppContext({}, {})) + // When + const bound = bindEffectiveContext(shell, {}, 'production') + // Then + expect(bound.context.phase).toBe('production') + expect(bound.context.watch).toBe(false) + expect(bound.context.sourceMap).toBe(false) + expect(bound.context.appKey).toBe(shell.appKey) + }) + + it('keeps shell ownership independent of provisional late settings', () => { + // Given + process.chdir(makeProject()) + process.env.NODE_ENV = 'production' + const first = createAppContext({}, { prefix: 'app' }) + const second = createAppContext( + { + pageExtensions: ['mdx'], + productionBrowserSourceMaps: true, + distDir: 'build', + }, + { prefix: 'app' }, + ) + // When + const left = createSetupShell(first) + const right = createSetupShell(second) + // Then + expect(left.appKey).toBe(right.appKey) + expect(left.appDir).toBe(right.appDir) + expect( + createSetupShell(createAppContext({}, { prefix: 'other' })).appKey, + ).not.toBe(left.appKey) + }) + + it('binds final settings against the captured root without moving its endpoint', () => { + // Given + const root = makeProject() + process.chdir(root) + process.env.NODE_ENV = 'production' + const shell = createSetupShell(createAppContext({}, {})) + const session = createSession(shell) + const pageExtensions = ['mdx', 'mts'] + process.chdir(makeProject()) + process.env.NODE_ENV = 'development' + // When + const bound = bindEffectiveContext(shell, { + distDir: 'build', + pageExtensions, + productionBrowserSourceMaps: true, + }) + pageExtensions.push('tsx') + // Then + expect(bound.context.root).toBe(root) + expect(bound.context.nextDistDir).toBe(resolve(root, 'build')) + expect(bound.context.sourceMap).toBe(true) + expect(bound.context.watch).toBe(false) + expect(bound.context.pageExtensions).toEqual(['mdx', 'mts']) + expect(bound.context.appKey).toBe(shell.appKey) + expect(bound.context.appDir).toBe(shell.appDir) + expect(session.endpointFile).toContain(shell.appDir) + expect(shell.nextDistDir).toBe(resolve(root, '.next')) + expect(Object.isFrozen(bound.context)).toBe(true) + }) + + it('keys allocator compatibility by final settings while preserving dev maps', () => { + // Given + process.chdir(makeProject()) + process.env.NODE_ENV = 'development' + const shell = createSetupShell(createAppContext({}, {})) + // When + const baseline = bindEffectiveContext(shell, {}) + const changed = bindEffectiveContext(shell, { + pageExtensions: ['mdx'], + productionBrowserSourceMaps: false, + }) + // Then + expect(baseline.context.sourceMap).toBe(true) + expect(changed.context.sourceMap).toBe(true) + expect(changed.optionsKey).not.toBe(baseline.optionsKey) + expect(bindEffectiveContext(shell, {}).optionsKey).toBe(baseline.optionsKey) + expect(baseline.context.pageExtensions).toEqual(['jsx', 'js', 'tsx', 'ts']) + }) +}) diff --git a/packages/next-plugin/src/__tests__/engine-config.test.ts b/packages/next-plugin/src/__tests__/engine-config.test.ts new file mode 100644 index 000000000..81ec03309 --- /dev/null +++ b/packages/next-plugin/src/__tests__/engine-config.test.ts @@ -0,0 +1,115 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { createEngineConfigurer } from '../engine-config' +import { createAppContext } from '../session' +import { createWasm } from '../wasm' +import { box, installProjectHooks, makeProject } from './project' + +installProjectHooks() + +const originalEnv = { ...process.env } +afterEach(() => { + process.env = { ...originalEnv } +}) + +const plan = { canonicalMap: {}, fileRoutes: {}, atomThreshold: null } + +function context(options = {}) { + process.chdir(makeProject()) + return createAppContext({}, options) +} + +describe('createEngineConfigurer', () => { + it('sets every option on a fresh engine', () => { + const app = context({ + debug: true, + prefix: 'du-', + shorthands: { insetX: ['left', 'right'] }, + }) + const engine = createWasm(app.root) + + createEngineConfigurer(app, { + theme: { colors: { default: { primary: 'red' } } }, + plan: { + canonicalMap: { 'a.tsx': 'b.tsx' }, + fileRoutes: { 'b.tsx': [0, 1] }, + atomThreshold: 2, + }, + })(engine) + + expect(engine.isDebug()).toBe(true) + expect(engine.getPrefix()).toBe('du-') + expect(engine.getCss(undefined, false)).toContain('--primary:red') + expect(JSON.parse(engine.exportCanonicalMap())).toEqual({ + 'a.tsx': 'b.tsx', + }) + using output = engine.codeExtract( + 'b.tsx', + box('insetX={1}'), + '@devup-ui/react', + './css', + false, + false, + true, + {}, + ) + expect(output.code).toContain('du-') + expect(engine.getCss(undefined, false)).toContain('left:') + }) + + it('overwrites what an earlier configuration left with the defaults', () => { + const app = context() + const engine = createWasm(app.root) + engine.setDebug(true) + engine.setPrefix('old-') + engine.registerShorthands({ insetX: ['left', 'right'] }) + engine.registerTheme({ colors: { default: { primary: 'red' } } }) + engine.importCanonicalMap({ 'a.tsx': 'b.tsx' }) + engine.importFileRoutes({ 'b.tsx': [0, 1] }) + engine.setAtomHoist(2) + + createEngineConfigurer(app, { theme: {}, plan })(engine) + + expect(engine.isDebug()).toBe(false) + expect(engine.getPrefix()).toBeUndefined() + expect(engine.getCss(undefined, false)).not.toContain('--primary') + expect(JSON.parse(engine.exportCanonicalMap())).toEqual({}) + using output = engine.codeExtract( + 'b.tsx', + box('insetX={1}'), + '@devup-ui/react', + './css', + false, + false, + true, + {}, + ) + expect(output.code).not.toContain('old-') + expect(engine.getCss(0, false)).not.toContain('left:') + }) + + it('resolves imports against the captured root after the working directory changes', () => { + const app = context() + writeFileSync(join(app.root, 'color.ts'), 'export const color = "purple"') + const engine = createWasm(app.root) + createEngineConfigurer(app, { theme: {}, plan })(engine) + process.chdir(makeProject()) + + using output = engine.codeExtract( + 'page.tsx', + `import { Box } from '@devup-ui/react'\nimport { color } from './color'\nexport const C = () => `, + '@devup-ui/react', + './css', + false, + false, + true, + {}, + ) + + expect(output.dependencies).toEqual(['color.ts']) + expect(engine.getCss(0, false)).toContain('background:purple') + }) +}) diff --git a/packages/next-plugin/src/__tests__/entries.test.ts b/packages/next-plugin/src/__tests__/entries.test.ts new file mode 100644 index 000000000..f7d7eabe1 --- /dev/null +++ b/packages/next-plugin/src/__tests__/entries.test.ts @@ -0,0 +1,312 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { collectNextEntries } from '../entries' + +let root: string +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'devup-next-entries-'))) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +function files(paths: readonly string[]): string[] { + return paths.map((path) => { + const absolute = join(root, path) + mkdirSync(dirname(absolute), { recursive: true }) + writeFileSync(absolute, 'export default function Entry() {}') + return absolute + }) +} + +describe('collectNextEntries', () => { + it('selects root routers when both root and src routers exist', () => { + // Given + const input = files([ + 'app/page.tsx', + 'pages/index.js', + 'src/app/page.tsx', + 'src/pages/index.js', + ]) + // When + const entries = collectNextEntries({ root, files: input }) + // Then + expect(entries).toEqual(input.slice(0, 2).sort()) + }) + + it('uses filesystem presence even when winning routers have no graph files', () => { + // Given + const input = files(['src/app/page.tsx', 'src/pages/index.tsx']) + mkdirSync(join(root, 'app')) + mkdirSync(join(root, 'pages')) + // When + const entries = collectNextEntries({ root, files: input }) + // Then + expect(entries).toEqual([]) + }) + + it('selects src routers when root routers are absent', () => { + // Given + const input = files([ + 'src/app/page.jsx', + 'src/pages/index.ts', + 'src/utils/page.tsx', + ]) + // When + const entries = collectNextEntries({ root, files: input }) + // Then + expect(entries).toEqual(input.slice(0, 2).sort()) + }) + + it('selects ancestor shells without selecting dead sibling shells or utilities', () => { + // Given + const selected = files([ + 'app/layout.tsx', + 'app/global-error.tsx', + 'app/(shop)/layout.tsx', + 'app/(shop)/products/[id]/page.tsx', + 'app/(shop)/template.tsx', + 'app/(shop)/default.tsx', + 'app/(shop)/loading.tsx', + 'app/(shop)/error.tsx', + 'app/(shop)/not-found.tsx', + 'app/(shop)/forbidden.tsx', + 'app/(shop)/unauthorized.tsx', + ]) + const dead = files([ + 'app/dead/layout.tsx', + 'app/dead/loading.tsx', + 'app/utils.tsx', + 'src/dead.tsx', + ]) + // When + const entries = collectNextEntries({ root, files: [...dead, ...selected] }) + // Then + expect(entries).toEqual(selected.sort()) + }) + + it('keeps route groups, intercepted routes and parallel fallbacks but excludes private subtrees', () => { + // Given + const selected = files([ + 'app/(public)/page.tsx', + 'app/(public)/@modal/default.tsx', + 'app/(public)/@modal/layout.tsx', + 'app/(public)/@modal/(.)photo/page.tsx', + 'app/(public)/@sidebar/default.tsx', + 'app/(public)/@sidebar/loading.tsx', + 'app/%5Fpublic/page.tsx', + ]) + const dead = files([ + 'app/_private/page.tsx', + 'app/_private/nested/route.ts', + 'app/(public)/@modal/_private/default.tsx', + 'app/dead/@slot/default.tsx', + ]) + // When + const entries = collectNextEntries({ root, files: [...selected, ...dead] }) + // Then + expect(entries).toEqual(selected.sort()) + }) + + it('selects API route handlers without unrelated render shells', () => { + // Given + const input = files([ + 'app/api/[...slug]/route.ts', + 'app/api/layout.tsx', + 'app/api/error.tsx', + ]) + // When + const entries = collectNextEntries({ root, files: input }) + // Then + expect(entries).toEqual(input.slice(0, 1)) + }) + + it('selects nested fallback-only slots regardless of graph ordering', () => { + // Given + const selected = files([ + 'app/@outer/@inner/default.tsx', + 'app/@outer/default.tsx', + 'app/page.tsx', + ]) + // When + const entries = collectNextEntries({ root, files: selected }) + // Then + expect(entries).toEqual([...selected].sort()) + }) + + it('selects Pages routes, API routes and root shell modules without App private-folder rules', () => { + // Given + const selected = files([ + 'pages/index.tsx', + 'pages/blog/index.jsx', + 'pages/[id].js', + 'pages/[[...slug]].ts', + 'pages/api/[...path].ts', + 'pages/_app.tsx', + 'pages/_document.tsx', + 'pages/_error.tsx', + 'pages/404.tsx', + 'pages/500.tsx', + 'pages/_public/index.tsx', + ]) + const dead = files([ + 'components/_app.tsx', + 'pages/readme.md', + 'pages/image.png', + ]) + // When + const entries = collectNextEntries({ root, files: [...dead, ...selected] }) + // Then + expect(entries).toEqual(selected.sort()) + }) + + it('accepts configured modern, MDX and compound custom suffixes instead of defaults', () => { + // Given + const selected = files([ + 'app/page.mdx', + 'app/layout.page.tsx', + 'app/api/route.mts', + 'pages/index.cts', + 'pages/api/data.cjs', + 'pages/_app.page.tsx', + 'pages/guide.custom+suffix', + ]) + const dead = files([ + 'app/dead/page.tsx', + 'app/page.mdx.bak', + 'pages/plain.tsx', + 'pages/fake.customXsuffix', + ]) + // When + const entries = collectNextEntries({ + root, + files: [...selected, ...dead], + pageExtensions: ['mts', 'cts', 'cjs', 'mdx', 'page.tsx', 'custom+suffix'], + }) + // Then + expect(entries).toEqual(selected.sort()) + }) + + it('selects special metadata modules and root not-found UI without treating metadata utilities as routes', () => { + // Given + const selected = files([ + 'app/robots.ts', + 'app/manifest.ts', + 'app/blog/sitemap.ts', + 'app/icon.tsx', + 'app/blog/apple-icon2.tsx', + 'app/blog/opengraph-image.tsx', + 'app/blog/twitter-image1.tsx', + 'app/not-found.tsx', + 'app/global-not-found.tsx', + 'app/layout.tsx', + 'app/global-error.tsx', + ]) + const dead = files([ + 'app/blog/robots.ts', + 'app/blog/manifest.ts', + 'app/icon12.tsx', + 'app/_private/sitemap.ts', + 'app/blog/icon-helper.tsx', + ]) + // When + const entries = collectNextEntries({ root, files: [...dead, ...selected] }) + // Then + expect(entries).toEqual(selected.sort()) + }) + + it('selects top-level runtime conventions beside the active routers only', () => { + // Given + const selected = files([ + 'src/app/page.tsx', + 'src/proxy.ts', + 'src/middleware.ts', + 'src/instrumentation.ts', + 'src/instrumentation-client.ts', + ]) + const dead = files([ + 'proxy.ts', + 'src/utils/instrumentation.ts', + 'next.config.ts', + ]) + // When + const entries = collectNextEntries({ root, files: [...selected, ...dead] }) + // Then + expect(entries).toEqual(selected.sort()) + }) + + it('returns unique absolute paths in deterministic order without mutating graph input', () => { + // Given + const input = files([ + 'app/z/page.js', + 'app/A/page.jsx', + 'app/a/page.ts', + 'app/b/page.tsx', + ]) + const original = Object.freeze([...input, ...input]) + // When + const entries = collectNextEntries({ root, files: original }) + // Then + expect(entries).toEqual([...input].sort()) + }) + + it('returns no entries when no routers exist even if graph paths resemble routes', () => { + // Given + const input = files([ + 'src/components/page.tsx', + 'app-extra/page.tsx', + 'pages-extra/index.tsx', + ]) + // When + const entries = collectNextEntries({ root, files: input }) + // Then + expect(entries).toEqual([]) + }) + + it('returns no entries when graph input is empty or only dead app sources exist', () => { + // Given + const input = files([ + 'app/dead/layout.tsx', + 'app/component.tsx', + 'src/unused/page.tsx', + ]) + // When + const entries = collectNextEntries({ root, files: input }) + // Then + expect(entries).toEqual([]) + }) + + it('does not synthesize entries outside the supplied graph file list', () => { + // Given + files(['app/page.tsx', 'app/layout.tsx']) + // When + const entries = collectNextEntries({ root, files: [] }) + // Then + expect(entries).toEqual([]) + }) + + it('honors an empty configured extension list', () => { + // Given + const input = files([ + 'app/page.tsx', + 'pages/index.tsx', + 'instrumentation.ts', + ]) + // When + const entries = collectNextEntries({ + root, + files: input, + pageExtensions: [], + }) + // Then + expect(entries).toEqual([]) + }) +}) diff --git a/packages/next-plugin/src/__tests__/final-config-adapter.test.ts b/packages/next-plugin/src/__tests__/final-config-adapter.test.ts new file mode 100644 index 000000000..f5b1da67d --- /dev/null +++ b/packages/next-plugin/src/__tests__/final-config-adapter.test.ts @@ -0,0 +1,188 @@ +import { createRequire } from 'node:module' +import { join } from 'node:path' + +import { describe, expect, it } from 'bun:test' + +import { adapterFixture } from './adapter-fixture' + +describe('caller final-config composition', () => { + it.each(['replace', 'remove', 'keep'])( + 'uses the async caller return and restores its %s path', + async (mode) => { + const fixture = adapterFixture() + try { + const callerPath = + fixture.caller(`let calls = 0; module.exports = { async modifyConfig(config, ctx) { + calls++; + if (!config.adapterPath.includes('caller-')) throw new Error('fake path'); + await new Promise(resolve => setImmediate(resolve)); + const output = { ...config, env: { caller: ctx.phase + ':' + ctx.projectDir, calls: String(calls), contextKeys: Object.keys(ctx).sort().join(',') }, assetPrefix: '/returned' }; + ${mode === 'replace' ? "output.adapterPath = './replacement.cjs';" : mode === 'remove' ? 'delete output.adapterPath;' : ''} + return output; + }};`) + let calls = 0 + const installed = fixture.install( + { adapterPath: callerPath, assetPrefix: '/input' }, + (effective) => { + calls++ + expect(effective.assetPrefix).toBe('/returned') + return { ...effective, trailingSlash: true } + }, + ) + const result = await installed.adapter.modifyConfig( + installed.config, + fixture.context, + ) + expect(result.adapterPath).toBe( + mode === 'replace' + ? './replacement.cjs' + : mode === 'keep' + ? callerPath + : undefined, + ) + expect(result.env?.caller).toBe( + `${fixture.context.phase}:${fixture.context.projectDir}`, + ) + expect(result.env?.calls).toBe('1') + expect(result.env?.contextKeys).toBe('nextVersion,phase,projectDir') + expect(calls).toBe(1) + } finally { + fixture.close() + } + }, + ) + + it('retains a caller with only a build-complete hook', async () => { + const fixture = adapterFixture() + try { + const callerPath = fixture.caller( + 'module.exports = { onBuildComplete() {} };', + ) + const installed = fixture.install( + { adapterPath: callerPath }, + (config) => config, + ) + expect( + ( + await installed.adapter.modifyConfig( + installed.config, + fixture.context, + ) + ).adapterPath, + ).toBe(callerPath) + const caller: unknown = createRequire( + join(fixture.session.projectDir, 'package.json'), + )(callerPath) + if ( + typeof caller !== 'object' || + caller === null || + !('onBuildComplete' in caller) || + typeof caller.onBuildComplete !== 'function' + ) { + throw new TypeError('fixture must expose onBuildComplete') + } + caller.onBuildComplete() + } finally { + fixture.close() + } + }) + + it('resolves an ESM named adapter without a default export', async () => { + const fixture = adapterFixture() + try { + const callerPath = fixture.caller( + 'export function modifyConfig(config) { return { ...config, trailingSlash: true } }', + 'mjs', + ) + const installed = fixture.install( + { adapterPath: callerPath }, + (config) => config, + ) + expect( + ( + await installed.adapter.modifyConfig( + installed.config, + fixture.context, + ) + ).trailingSlash, + ).toBe(true) + } finally { + fixture.close() + } + }) + + it('preserves a callable CommonJS adapter with a modifyConfig method', async () => { + const fixture = adapterFixture() + try { + const callerPath = fixture.caller( + 'function adapter() {} adapter.modifyConfig = c => ({ ...c, trailingSlash: true }); module.exports = adapter;', + ) + const installed = fixture.install( + { adapterPath: callerPath }, + (config) => config, + ) + expect( + ( + await installed.adapter.modifyConfig( + installed.config, + fixture.context, + ) + ).trailingSlash, + ).toBe(true) + const caller: unknown = createRequire( + join(fixture.session.projectDir, 'package.json'), + )(callerPath) + if (typeof caller !== 'function') + throw new TypeError('fixture must be callable') + caller() + } finally { + fixture.close() + } + }) + + it('preserves an environment deployment adapter when no caller path is supplied', async () => { + const fixture = adapterFixture() + const previous = process.env.NEXT_ADAPTER_PATH + try { + const callerPath = fixture.caller( + 'module.exports = { modifyConfig(c) { return { ...c, assetPrefix: "/platform" } } };', + ) + process.env.NEXT_ADAPTER_PATH = callerPath + const installed = fixture.install({}, (config) => config) + const result = await installed.adapter.modifyConfig( + installed.config, + fixture.context, + ) + expect(result.adapterPath).toBe(callerPath) + expect(result.assetPrefix).toBe('/platform') + } finally { + if (previous === undefined) delete process.env.NEXT_ADAPTER_PATH + else process.env.NEXT_ADAPTER_PATH = previous + fixture.close() + } + }) + + it('preserves the legacy caller path without letting migration bypass the temporary adapter', async () => { + const fixture = adapterFixture() + try { + const callerPath = fixture.caller( + 'module.exports = { modifyConfig(c) { return { ...c, trailingSlash: true } } };', + ) + const original = { + adapterPath: '/unused.cjs', + experimental: { cpus: 2, adapterPath: callerPath }, + } + const installed = fixture.install(original, (config) => config) + expect(installed.config.experimental).toEqual({ cpus: 2 }) + const result = await installed.adapter.modifyConfig( + installed.config, + fixture.context, + ) + expect(result.adapterPath).toBe(callerPath) + expect(result.trailingSlash).toBe(true) + expect(original.experimental.adapterPath).toBe(callerPath) + } finally { + fixture.close() + } + }) +}) diff --git a/packages/next-plugin/src/__tests__/final-config-failure.test.ts b/packages/next-plugin/src/__tests__/final-config-failure.test.ts new file mode 100644 index 000000000..9a45584ee --- /dev/null +++ b/packages/next-plugin/src/__tests__/final-config-failure.test.ts @@ -0,0 +1,199 @@ +import { expect, it } from 'bun:test' + +import { FinalConfigAdapterError } from '../final-config-adapter' +import { adapterFixture } from './adapter-fixture' + +it.each([ + 'throw new Error("caller failure")', + 'throw "non-error caller failure"', + 'return null', + 'return []', + 'return { adapterPath: 42 }', +])( + 'locates invalid/failed caller hook %# without running the finalizer', + async (body) => { + const fixture = adapterFixture() + try { + const callerPath = fixture.caller( + `module.exports = { modifyConfig() { ${body} } };`, + ) + let finalized = false + const installed = fixture.install( + { adapterPath: callerPath }, + (config) => { + finalized = true + return config + }, + ) + const failure = await installed.adapter + .modifyConfig(installed.config, fixture.context) + .catch((cause: unknown) => cause) + expect(failure).toBeInstanceOf(FinalConfigAdapterError) + expect(failure).toMatchObject({ + context: fixture.context, + location: fixture.session.configFile, + stage: 'caller modifyConfig', + }) + expect(finalized).toBe(false) + } finally { + fixture.close() + } + }, +) + +it('locates caller import failure at the captured config file', async () => { + const fixture = adapterFixture() + try { + const installed = fixture.install( + { adapterPath: './missing.cjs' }, + (config) => config, + ) + await expect( + installed.adapter.modifyConfig(installed.config, fixture.context), + ).rejects.toMatchObject({ + location: fixture.session.configFile, + context: fixture.context, + stage: 'caller modifyConfig', + }) + } finally { + fixture.close() + } +}) + +it('retains the original located preparation failure as cause', async () => { + const fixture = adapterFixture() + const original = new SyntaxError( + 'content/page.mdx:3:4: required compiler failed', + ) + try { + const installed = fixture.install({}, () => { + throw original + }) + await expect( + installed.adapter.modifyConfig(installed.config, fixture.context), + ).rejects.toMatchObject({ + cause: original, + context: fixture.context, + stage: 'finalizer', + }) + } finally { + fixture.close() + } +}) + +it('rejects a null caller export rather than bypassing its failure', async () => { + const fixture = adapterFixture() + try { + const callerPath = fixture.caller('module.exports = null;') + const installed = fixture.install( + { adapterPath: callerPath }, + (config) => config, + ) + await expect( + installed.adapter.modifyConfig(installed.config, fixture.context), + ).rejects.toMatchObject({ + location: fixture.session.configFile, + stage: 'caller modifyConfig', + }) + } finally { + fixture.close() + } +}) + +it('bounds a hung caller before invoking the finalizer', async () => { + const fixture = adapterFixture() + let finalized = false + try { + const callerPath = fixture.caller( + 'module.exports = { modifyConfig() { return new Promise(() => {}) } };', + ) + const installed = fixture.install( + { adapterPath: callerPath }, + (config) => { + finalized = true + return config + }, + 5, + ) + await expect( + installed.adapter.modifyConfig(installed.config, fixture.context), + ).rejects.toMatchObject({ stage: 'caller modifyConfig' }) + expect(finalized).toBe(false) + } finally { + fixture.close() + } +}) + +it('bounds a hung finalizer and prevents late completion from publishing', async () => { + const fixture = adapterFixture() + const gate = Promise.withResolvers() + const aborted = Promise.withResolvers() + let signal: AbortSignal | undefined + try { + const installed = fixture.install( + {}, + async (config, _context, activeSignal) => { + signal = activeSignal + activeSignal.addEventListener('abort', () => aborted.resolve(), { + once: true, + }) + await gate.promise + return config + }, + 5, + ) + const result = installed.adapter.modifyConfig( + installed.config, + fixture.context, + ) + await expect(result).rejects.toMatchObject({ + context: fixture.context, + stage: 'finalizer', + }) + await aborted.promise + expect(signal?.aborted).toBe(true) + gate.resolve() + } finally { + fixture.close() + } +}) + +it('release cancels a pending finalizer without affecting another token', async () => { + const fixture = adapterFixture() + const other = adapterFixture() + const entered = Promise.withResolvers() + const gate = Promise.withResolvers() + try { + const installed = fixture.install({}, async (config) => { + entered.resolve() + await gate.promise + return config + }) + const otherInstalled = other.install({}, (config) => ({ + ...config, + trailingSlash: true, + })) + const pending = installed.adapter.modifyConfig( + installed.config, + fixture.context, + ) + await entered.promise + installed.release() + await expect(pending).rejects.toMatchObject({ + stage: 'session release', + context: fixture.context, + }) + expect( + ( + await otherInstalled.adapter.modifyConfig( + otherInstalled.config, + other.context, + ) + ).trailingSlash, + ).toBe(true) + gate.resolve() + } finally { + fixture.close() + other.close() + } +}) diff --git a/packages/next-plugin/src/__tests__/final-config-first-cause.test.ts b/packages/next-plugin/src/__tests__/final-config-first-cause.test.ts new file mode 100644 index 000000000..91635d457 --- /dev/null +++ b/packages/next-plugin/src/__tests__/final-config-first-cause.test.ts @@ -0,0 +1,52 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { retainSession, type SessionOwner } from '../lifecycle' +import { MdxCompileError } from '../mdx-prepare' +import { createAppContext, createSession } from '../session' +import { adapterFixture } from './adapter-fixture' + +it('preserves the located preparation cause when owner cleanup releases its active adapter', async () => { + // Given: a real final-config registration and its owner share a pending preparation. + const fixture = adapterFixture() + const ready = Promise.withResolvers() + const cause = new MdxCompileError( + join(fixture.session.projectDir, 'page.mdx'), + Object.assign(new SyntaxError('configured compiler failed'), { + place: { start: { line: 4, column: 7 } }, + }), + ) + const installed = fixture.install({}, async () => { + const owner = await ready.promise + owner.close(cause) + throw cause + }) + const context = createAppContext({}, {}, fixture.session.projectDir) + const owner = retainSession({ + session: createSession(context), + coordinator: { + ready: Promise.resolve(), + close() {}, + drain: () => Promise.resolve(), + }, + releaseAdapter: (reason) => installed.release(reason), + }) + ready.resolve(owner) + try { + // When: preparation fails and synchronously closes its owner before returning the cause. + const operation = installed.adapter.modifyConfig( + installed.config, + fixture.context, + ) + // Then: the adapter reports the original MDX location/cause, not session cancellation. + await expect(operation).rejects.toMatchObject({ + stage: 'finalizer', + location: fixture.session.configFile, + cause, + }) + } finally { + owner.close() + fixture.close() + } +}) diff --git a/packages/next-plugin/src/__tests__/final-config-registration.test.ts b/packages/next-plugin/src/__tests__/final-config-registration.test.ts new file mode 100644 index 000000000..fd57f77e4 --- /dev/null +++ b/packages/next-plugin/src/__tests__/final-config-registration.test.ts @@ -0,0 +1,144 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { describe, expect, it } from 'bun:test' +import type { NextConfig } from 'next' + +import { + FinalConfigAdapterError, + installFinalConfigAdapter, +} from '../final-config-adapter' +import { createFinalConfigAdapter } from '../final-config-adapter-entry' +import { adapterFixture } from './adapter-fixture' + +describe('final-config registration lifecycle', () => { + it('awaits a controlled slow finalizer without changing prior hooks or props', async () => { + // Given: a real pending finalizer and existing config hooks. + const fixture = adapterFixture() + const entered = Promise.withResolvers() + const gate = Promise.withResolvers() + const webpack = () => ({}) + const hook = async () => {} + const config: NextConfig = { + webpack, + compiler: { runAfterProductionCompile: hook }, + env: { retained: 'yes' }, + } + try { + const installed = fixture.install( + config, + async (effective, context, signal) => { + expect(context).toBe(fixture.context) + expect(signal.aborted).toBe(false) + entered.resolve() + await gate.promise + return { ...effective, pageExtensions: ['mdx', 'tsx'] } + }, + ) + // When: start the hook; prove it remains pending at the explicit gate. + let settled = false + const pending = installed.adapter.modifyConfig( + installed.config, + fixture.context, + ) + void pending.then(() => { + settled = true + }) + await entered.promise + expect(settled).toBe(false) + gate.resolve() + const result = await pending + // Then: original config surface survives, with no temporary deployment adapter. + expect(result).toEqual({ ...config, pageExtensions: ['mdx', 'tsx'] }) + expect(result.webpack).toBe(webpack) + expect(result.compiler?.runAfterProductionCompile).toBe(hook) + expect(config.adapterPath).toBeUndefined() + expect( + readFileSync(installed.config.adapterPath ?? '', 'utf8'), + ).not.toContain('runAfterProductionCompile') + } finally { + fixture.close() + } + }) + + it('rejects released or foreign-project tokens', async () => { + const fixture = adapterFixture() + try { + const installed = fixture.install({}, (config) => config) + await expect( + installed.adapter.modifyConfig(installed.config, { + ...fixture.context, + projectDir: '/', + }), + ).rejects.toMatchObject({ stage: 'project ownership' }) + installed.release() + installed.release() + await expect( + createFinalConfigAdapter(fixture.session.token).modifyConfig( + {}, + fixture.context, + ), + ).rejects.toMatchObject({ stage: 'registry lookup' }) + const complete = { + ...installed.config, + configFile: fixture.session.configFile, + } + await expect( + installed.adapter.modifyConfig(complete, fixture.context), + ).rejects.toMatchObject({ + stage: 'registry lookup', + location: fixture.session.configFile, + }) + } finally { + fixture.close() + } + }) + + it('rejects an invalid legacy path before generating an adapter', () => { + const fixture = adapterFixture() + try { + const original = { experimental: { cpus: 2, adapterPath: 1 } } + expect(() => fixture.install(original, (config) => config)).toThrow( + FinalConfigAdapterError, + ) + } finally { + fixture.close() + } + }) + + it('rejects duplicate ownership and invalid budgets without overwriting the owner', () => { + const fixture = adapterFixture() + try { + fixture.install({}, (config) => config) + expect(() => fixture.install({}, (config) => config)).toThrow( + FinalConfigAdapterError, + ) + expect(() => fixture.install({}, (config) => config, 45001)).toThrow( + FinalConfigAdapterError, + ) + } finally { + fixture.close() + } + }) + + it('locates a temporary adapter write failure', () => { + const fixture = adapterFixture() + try { + expect(() => + installFinalConfigAdapter( + {}, + { + ...fixture.session, + sessionDir: fixture.session.projectDir + '/absent', + }, + { + entryPath: join(fixture.session.projectDir, 'shipped-entry.cjs'), + finalize: (config) => config, + }, + ), + ).toThrow(FinalConfigAdapterError) + } finally { + fixture.close() + } + }) +}) diff --git a/packages/next-plugin/src/__tests__/fixtures/coordinator-process.ts b/packages/next-plugin/src/__tests__/fixtures/coordinator-process.ts new file mode 100644 index 000000000..4fd3ce67a --- /dev/null +++ b/packages/next-plugin/src/__tests__/fixtures/coordinator-process.ts @@ -0,0 +1,31 @@ +import { join, resolve } from 'node:path' + +import { startCoordinator } from '../../coordinator' +import { createWasm } from '../../wasm' + +const [root = '', token = '', mode = ''] = process.argv.slice(2) + +const handle = startCoordinator({ + wasm: createWasm(root), + package: '@devup-ui/react', + cssDir: join(root, 'df', 'devup-ui'), + singleCss: false, + importAliases: {}, + coordinatorPortFile: join(root, 'df', 'coordinator.port'), + canonicalMap: {}, + projectRoot: root, + identity: { project: resolve(root), token }, + watch: mode !== 'shutdown', + expectedBaseFiles: mode === 'shutdown' ? ['src/missing.tsx'] : undefined, +}) +await handle.ready +// The endpoint never keeps a process alive on its own +const keepAlive = setInterval(() => undefined, 1000) +if (mode === 'shutdown') { + process.stdin.once('data', () => { + handle.close() + clearInterval(keepAlive) + process.stdin.pause() + }) +} +console.info('ready') diff --git a/packages/next-plugin/src/__tests__/ignored-prewarm.test.ts b/packages/next-plugin/src/__tests__/ignored-prewarm.test.ts new file mode 100644 index 000000000..e1f32ad9c --- /dev/null +++ b/packages/next-plugin/src/__tests__/ignored-prewarm.test.ts @@ -0,0 +1,93 @@ +import { join } from 'node:path' + +import type { StaticImportGraph } from '@devup-ui/plugin-utils' +import { expect, it } from 'bun:test' + +import { collectPrewarmFiles } from '../prewarm' +import { runPrewarm } from '../prewarm-run' +import { createAppContext } from '../session' +import { createWasm, withModuleResolver } from '../wasm' +import { installProjectHooks, makeProject } from './project' + +installProjectHooks() + +it.each(['package', 'relative', 'esm-sibling'] as const)( + 'omits ignored %s outcomes before reading or prewarming a resource', + (kind) => { + // Given a real included package, poisoned ignored bytes and a preparation observer. + const root = makeProject({ + 'src/page.tsx': '', + 'node_modules/@acme/ui/package.json': JSON.stringify({ + name: '@acme/ui', + exports: './index.cjs', + }), + 'node_modules/@acme/ui/index.cjs': 'import "./ignored.ts"', + 'node_modules/@acme/ui/index.mjs': 'import "./ignored.ts"', + 'node_modules/@acme/ui/ignored.ts': 'import "./unresolvable.ts"', + }) + const page = join(root, 'src/page.tsx') + const graph: StaticImportGraph = { + files: [page], + fileSet: new Set([page]), + staticImports: new Map(), + staticImporters: new Map(), + dynamicImports: new Map(), + dynamicTargets: new Set(), + externalImports: new Map([[page, new Set(['@acme/ui'])]]), + } + const ignoredRequest = { + package: '@acme/ui', + relative: './ignored.ts', + 'esm-sibling': join(root, 'node_modules/@acme/ui/index.mjs'), + }[kind] + const prepared: string[] = [] + process.chdir(root) + // When collection uses the shared false-alias resolver. + const files = collectPrewarmFiles({ + root, + graph, + expectedBaseFiles: ['src/page.tsx'], + libPackage: '@devup-ui/react', + include: ['@acme/ui'], + prewarmAll: false, + resolver: { + alias: { [ignoredRequest]: false }, + prepareSource: (filename) => { + prepared.push(filename) + return undefined + }, + }, + }) + // Then no ignored path enters the extraction/numbering set or source preparation. + const expectedFiles = { + package: ['src/page.tsx'], + relative: ['node_modules/@acme/ui/index.mjs', 'src/page.tsx'], + 'esm-sibling': ['src/page.tsx'], + }[kind] + expect(files).toEqual(expectedFiles) + expect(prepared).toEqual( + { + package: [], + relative: [ + join(root, 'node_modules/@acme/ui/index.cjs'), + join(root, 'node_modules/@acme/ui/index.mjs'), + ], + 'esm-sibling': [join(root, 'node_modules/@acme/ui/index.cjs')], + }[kind], + ) + const engine = withModuleResolver(createWasm(root), root, { + alias: { [ignoredRequest]: false }, + }) + engine.seedFileMap(files) + const output = runPrewarm({ + context: createAppContext({}, {}), + engine, + files, + collectMs: undefined, + }) + expect(output.files).toEqual(expectedFiles) + expect(Object.keys(JSON.parse(engine.exportFileMap())).sort()).toEqual( + expectedFiles, + ) + }, +) diff --git a/packages/next-plugin/src/__tests__/join-resolution-inputs.ts b/packages/next-plugin/src/__tests__/join-resolution-inputs.ts new file mode 100644 index 000000000..7317d10f8 --- /dev/null +++ b/packages/next-plugin/src/__tests__/join-resolution-inputs.ts @@ -0,0 +1,33 @@ +import { join } from 'node:path' + +import type { + ImportRequestOutcome, + ResolutionInputs, +} from '@devup-ui/plugin-utils' +import { expect } from 'bun:test' + +// Join tests require fixture-owned evidence; exhaustive producer probes live upstream. +export function expectedJoinInputs( + root: string, + files: readonly string[], + missing: readonly string[] = [], +): ResolutionInputs { + return expect.objectContaining({ + fileDependencies: expect.arrayContaining([...files]), + missingDependencies: expect.arrayContaining( + ['tsconfig.json', ...missing].map((path) => join(root, path)), + ), + }) +} + +export function expectedJoinResolution( + root: string, + files: readonly [string, ...string[]], + missing: readonly string[] = [], +): ImportRequestOutcome { + return { + kind: 'resolved', + path: files[0], + inputs: expectedJoinInputs(root, files, missing), + } +} diff --git a/packages/next-plugin/src/__tests__/legacy-loader-output.test.ts b/packages/next-plugin/src/__tests__/legacy-loader-output.test.ts new file mode 100644 index 000000000..13a811c2b --- /dev/null +++ b/packages/next-plugin/src/__tests__/legacy-loader-output.test.ts @@ -0,0 +1,85 @@ +import { join } from 'node:path' + +import { afterEach, expect, it, mock, spyOn } from 'bun:test' + +import { resetInit, setWasmForTesting } from '../loader' +import { createWasm, withModuleResolver } from '../wasm' +import { invoke } from './loader-fixture' +import { box, installProjectHooks, makeProject } from './project' + +installProjectHooks() +afterEach(() => { + mock.restore() + setWasmForTesting(undefined) +}) + +it.each([false, true])( + 'releases real legacy-loader outputs even when map parsing fails=%s', + async (invalidMap) => { + // Given + const root = makeProject() + const engine = createWasm(root) + const source = box('bg="red"') + const initialSheet = JSON.parse(engine.exportSheet()) + const nativeExtract = engine.codeExtract + let released = 0 + spyOn(engine, 'codeExtract').mockImplementation((...args) => { + const output = nativeExtract(...args) + const free = output.free.bind(output) + spyOn(output, 'free').mockImplementation(() => { + released += 1 + free() + }) + if (invalidMap) + Object.defineProperty(output, 'map', { value: 'invalid JSON' }) + return output + }) + setWasmForTesting(engine) + resetInit() + // When + const run = invoke( + { + projectRoot: root, + package: '@devup-ui/react', + cssDir: join(root, 'styles'), + defaultSheet: initialSheet, + theme: {}, + }, + join(root, 'page.tsx'), + source, + ) + // Then + if (invalidMap) await expect(run.result).rejects.toBeInstanceOf(SyntaxError) + else expect((await run.result).code).not.toContain(' { + // Given + const root = makeProject({ 'page.mdx': '# raw Markdown' }) + const filename = join(root, 'page.mdx') + const engine = createWasm(root) + const source = `import { css } from '@devup-ui/react'\nconst v = Math.random()\nexport const c = css({ bg: v })` + withModuleResolver(engine, root, { + prepareSource: () => ({ + code: source, + map: { version: 3, sources: [filename], mappings: ';;AAKA', names: [] }, + }), + }) + setWasmForTesting(engine) + resetInit() + // When + const run = invoke( + { + projectRoot: root, + package: '@devup-ui/react', + defaultSheet: JSON.parse(engine.exportSheet()), + theme: {}, + }, + filename, + source, + ) + // Then + await expect(run.result).rejects.toThrow(`${filename}:6:1:`) +}) diff --git a/packages/next-plugin/src/__tests__/lifecycle-owner-listeners.test.ts b/packages/next-plugin/src/__tests__/lifecycle-owner-listeners.test.ts new file mode 100644 index 000000000..58bb6f25e --- /dev/null +++ b/packages/next-plugin/src/__tests__/lifecycle-owner-listeners.test.ts @@ -0,0 +1,68 @@ +import { mkdirSync } from 'node:fs' + +import { expect, it } from 'bun:test' + +import { retainSession } from '../lifecycle' +import { createAppContext, createSession } from '../session' +import { installProjectHooks, makeProject } from './project' + +installProjectHooks() + +function owner() { + process.chdir(makeProject()) + const session = createSession(createAppContext({}, {})) + mkdirSync(session.sessionDir, { recursive: true }) + let closed = 0 + const retained = retainSession({ + session, + coordinator: { + ready: Promise.resolve(), + close() { + closed += 1 + }, + drain: () => Promise.resolve(), + }, + }) + return { retained, closed: () => closed } +} + +it('releases only its own process callbacks when an owner closes explicitly', async () => { + // Given: a foreign callback and a drained owner with two owned callbacks. + process.on('exit', Date.now) + const beforeExit = process.listeners('beforeExit') + const exit = process.listeners('exit') + const input = owner() + await input.retained.drain() + try { + // When: the session closes before process shutdown. + input.retained.close() + // Then: neither owned callback retains the released session; foreign callbacks survive. + expect(process.listeners('beforeExit')).toEqual(beforeExit) + expect(process.listeners('exit')).toEqual(exit) + expect(input.closed()).toBe(1) + } finally { + input.retained.close() + process.off('exit', Date.now) + } +}) + +it('leaves a second live owner registered when another owner closes', () => { + // Given: two independently owned callbacks in the real process emitter. + const first = owner() + const second = owner() + const beforeExit = process.listenerCount('beforeExit') + const exit = process.listenerCount('exit') + try { + // When: only the first owner closes, twice. + first.retained.close() + first.retained.close() + // Then: precisely its two listeners are gone and the other coordinator stays open. + expect(process.listenerCount('beforeExit')).toBe(beforeExit - 1) + expect(process.listenerCount('exit')).toBe(exit - 1) + expect(first.closed()).toBe(1) + expect(second.closed()).toBe(0) + } finally { + first.retained.close() + second.retained.close() + } +}) diff --git a/packages/next-plugin/src/__tests__/lifecycle-owner.test.ts b/packages/next-plugin/src/__tests__/lifecycle-owner.test.ts new file mode 100644 index 000000000..d5833b4d0 --- /dev/null +++ b/packages/next-plugin/src/__tests__/lifecycle-owner.test.ts @@ -0,0 +1,108 @@ +import { existsSync, mkdirSync } from 'node:fs' + +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test' +import type { NextConfig } from 'next' + +import { + findSessionOwner, + installAfterCompileDrain, + retainSession, + type SessionOwner, + SessionOwnershipError, +} from '../lifecycle' +import { createAppContext, createSession } from '../session' +import { installProjectHooks, makeProject } from './project' + +installProjectHooks() +let once: ReturnType +let owners: SessionOwner[] = [] +beforeEach(() => { + once = spyOn(process, 'once').mockImplementation(() => process) +}) +afterEach(() => { + for (const owner of owners) owner.close() + owners = [] + once.mockRestore() +}) + +function setup() { + process.chdir(makeProject()) + const session = createSession(createAppContext({}, {})) + mkdirSync(session.sessionDir, { recursive: true }) + const pending = Promise.withResolvers() + const coordinator = { + ready: Promise.resolve(), + prepared: pending.promise, + close: mock(() => {}), + drain: mock(() => pending.promise), + } + const releaseAdapter = mock(() => {}) + const fields = { session, coordinator, releaseAdapter } + const owner = retainSession(fields) + owners.push(owner) + return { ...fields, owner, pending } +} + +describe('session token owner', () => { + it('rejects a second owner without replacing the live handle', () => { + // Given: one token already owns a pending coordinator. + const fields = setup() + // When: a second config module tries to retain that token. + expect(() => retainSession(fields)).toThrow(SessionOwnershipError) + // Then: the first owner and its pending barrier remain authoritative. + expect(findSessionOwner(fields.session.token)).toBe(fields.owner) + expect(fields.coordinator.close).not.toHaveBeenCalled() + }) + + it('releases the adapter once while draining accepted pending work', async () => { + // Given: preparation still owns accepted work. + const { owner, pending, session, releaseAdapter, coordinator } = setup() + // When: ownership drains and the accepted work settles. + const drained = owner.drain() + expect(findSessionOwner(session.token)).toBeUndefined() + expect(existsSync(session.sessionDir)).toBe(true) + pending.resolve() + await drained + owner.close() + // Then: cleanup never releases a second adapter or closes early. + expect(releaseAdapter).toHaveBeenCalledTimes(1) + expect(coordinator.drain).toHaveBeenCalledTimes(1) + expect(coordinator.close).toHaveBeenCalledTimes(1) + }) + + it('reserves a released token against stale exit callbacks', () => { + // Given: an old process callback still owns its directory after release. + const fields = setup() + fields.owner.close() + // When: another module tries to reacquire the old token. + expect(() => retainSession(fields)).toThrow(SessionOwnershipError) + // Then: the released token cannot designate a new directory owner. + expect(findSessionOwner(fields.session.token)).toBeUndefined() + expect(fields.releaseAdapter).toHaveBeenCalledTimes(1) + }) + + it('preserves the caller hook without draining a closed owner captured by an earlier config', async () => { + // Given: a registered production hook whose owner later closes. + const { owner, coordinator, session } = setup() + const caller = mock(async () => {}) + const config: NextConfig = { + compiler: { runAfterProductionCompile: caller }, + } + installAfterCompileDrain(config, session.token) + owner.close() + // When: the old config invokes its saved hook. + const metadata = { projectDir: '/p', distDir: '.next' } + await config.compiler?.runAfterProductionCompile?.(metadata) + // Then: only the caller hook runs, with its genuine metadata. + expect(coordinator.drain).not.toHaveBeenCalled() + expect(caller).toHaveBeenCalledWith(metadata) + }) +}) diff --git a/packages/next-plugin/src/__tests__/lifecycle.test.ts b/packages/next-plugin/src/__tests__/lifecycle.test.ts new file mode 100644 index 000000000..ac6c7a3cc --- /dev/null +++ b/packages/next-plugin/src/__tests__/lifecycle.test.ts @@ -0,0 +1,229 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test' +import type { NextConfig } from 'next' + +import type { CoordinatorHandle } from '../coordinator-options' +import { installAfterCompileDrain, retainSession } from '../lifecycle' +import { createAppContext, createSession } from '../session' +import { installProjectHooks, makeProject } from './project' + +installProjectHooks() + +type Handler = () => unknown + +let handlers: Record +let onceSpy: ReturnType +let errorSpy: ReturnType + +beforeEach(() => { + handlers = {} + onceSpy = spyOn(process, 'once').mockImplementation((( + event: string, + handler: Handler, + ) => { + handlers[event] = handler + return process + }) as never) + errorSpy = spyOn(console, 'error').mockImplementation(() => {}) +}) +afterEach(() => { + onceSpy.mockRestore() + errorSpy.mockRestore() +}) + +function setup() { + process.chdir(makeProject()) + const session = createSession(createAppContext({}, {})) + mkdirSync(session.sessionDir, { recursive: true }) + writeFileSync(session.revisionFile, '0') + const order: string[] = [] + const handle: { + -readonly [K in keyof CoordinatorHandle]: CoordinatorHandle[K] + } = { + ready: Promise.resolve(), + close: mock(() => void order.push('close')), + drain: mock(async () => { + await Promise.resolve() + order.push('drain') + }), + } + return { session, handle, order } +} + +describe('retainSession', () => { + it('drains before closing when the event loop is empty', async () => { + const { session, handle, order } = setup() + retainSession({ session, coordinator: handle }) + expect(Object.keys(handlers).sort()).toEqual(['beforeExit', 'exit']) + expect(handle.close).not.toHaveBeenCalled() + + await handlers.beforeExit!() + + expect(order).toEqual(['drain', 'close']) + expect(existsSync(session.sessionDir)).toBe(false) + }) + + it('closes synchronously on exit and never twice', async () => { + const { session, handle } = setup() + retainSession({ session, coordinator: handle }) + + handlers.exit!() + handlers.exit!() + await handlers.beforeExit!() + + expect(handle.close).toHaveBeenCalledTimes(1) + expect(handle.drain).toHaveBeenCalledTimes(1) + expect(existsSync(session.sessionDir)).toBe(false) + }) + + it('does not wait for anything on exit', () => { + const { session, handle } = setup() + retainSession({ session, coordinator: handle }) + + const returned = handlers.exit!() + + expect(returned).toBeUndefined() + expect(handle.drain).not.toHaveBeenCalled() + }) + + it('reports a failed drain with its location and still closes', async () => { + const { session, handle } = setup() + handle.drain = mock(async () => { + throw new Error('disk full') + }) + retainSession({ session, coordinator: handle }) + + await handlers.beforeExit!() + + expect(errorSpy).toHaveBeenCalledWith( + `${session.sessionDir}:1:1: devup-ui cannot use \`the final state write\` at build time: disk full; needs a writable distDir.`, + ) + expect(handle.close).toHaveBeenCalledTimes(1) + }) + + it('reports a coordinator that never started listening', async () => { + const { session, handle } = setup() + handle.ready = Promise.reject(new Error('EADDRINUSE')) + retainSession({ session, coordinator: handle }) + + await handle.ready.catch(() => undefined) + await Promise.resolve() + + expect(errorSpy).toHaveBeenCalledWith( + `${session.endpointFile}:1:1: devup-ui coordinator cannot use \`a loopback listener\` at build time: EADDRINUSE; needs permission to listen on 127.0.0.1 and to write its endpoint file.`, + ) + }) + + it('describes a failure that is not an Error', async () => { + const { session, handle } = setup() + handle.drain = mock(() => Promise.reject('plain')) + retainSession({ session, coordinator: handle }) + + await handlers.beforeExit!() + + expect(String(errorSpy.mock.calls[0]?.[0])).toContain( + 'at build time: plain;', + ) + }) + + it('does not touch the directory of another session', async () => { + const first = setup() + const second = setup() + retainSession({ session: first.session, coordinator: first.handle }) + const firstHandlers = { ...handlers } + retainSession({ session: second.session, coordinator: second.handle }) + + firstHandlers.exit!() + + expect(first.handle.close).toHaveBeenCalledTimes(1) + expect(second.handle.close).not.toHaveBeenCalled() + expect(existsSync(second.session.sessionDir)).toBe(true) + expect(existsSync(join(second.session.sessionDir, 'revision'))).toBe(true) + }) +}) + +describe('installAfterCompileDrain', () => { + it('drains the coordinator of the session before the user hook runs', async () => { + const { session, handle, order } = setup() + retainSession({ session, coordinator: handle }) + const metadata = { projectDir: '/p', distDir: '.next' } + const user = mock(async () => { + order.push('user') + }) + const config: NextConfig = { + compiler: { removeConsole: true, runAfterProductionCompile: user }, + } + + installAfterCompileDrain(config, session.token) + await config.compiler?.runAfterProductionCompile?.(metadata) + + expect(order).toEqual(['drain', 'user']) + expect(user).toHaveBeenCalledWith(metadata) + expect(config.compiler?.removeConsole).toBe(true) + expect(handle.close).not.toHaveBeenCalled() + }) + + it('works without a user hook or compiler options', async () => { + const { session, handle } = setup() + retainSession({ session, coordinator: handle }) + const config: NextConfig = {} + + installAfterCompileDrain(config, session.token) + await config.compiler?.runAfterProductionCompile?.({ + projectDir: '/p', + distDir: '.next', + }) + + expect(handle.drain).toHaveBeenCalledTimes(1) + }) + + it('does nothing for a session that is not in this process or is over', async () => { + const { session, handle } = setup() + retainSession({ session, coordinator: handle }) + handlers.exit!() + const user = mock(async () => {}) + const config: NextConfig = { compiler: { runAfterProductionCompile: user } } + + installAfterCompileDrain(config, session.token) + await config.compiler?.runAfterProductionCompile?.({ + projectDir: '/p', + distDir: '.next', + }) + installAfterCompileDrain(config, 'unknown-session') + await config.compiler?.runAfterProductionCompile?.({ + projectDir: '/p', + distDir: '.next', + }) + + expect(handle.drain).not.toHaveBeenCalled() + expect(user).toHaveBeenCalledTimes(2) + }) + + it('lets a failed drain fail the build', async () => { + const { session, handle } = setup() + handle.drain = mock(async () => { + throw new Error('write failed') + }) + retainSession({ session, coordinator: handle }) + const config: NextConfig = {} + + installAfterCompileDrain(config, session.token) + + await expect( + config.compiler?.runAfterProductionCompile?.({ + projectDir: '/p', + distDir: '.next', + }), + ).rejects.toThrow('write failed') + }) +}) diff --git a/packages/next-plugin/src/__tests__/loader-fixture.ts b/packages/next-plugin/src/__tests__/loader-fixture.ts new file mode 100644 index 000000000..7a62f5885 --- /dev/null +++ b/packages/next-plugin/src/__tests__/loader-fixture.ts @@ -0,0 +1,62 @@ +import * as fs from 'node:fs' +import { resolve } from 'node:path' + +import { mock } from 'bun:test' + +import loader, { type DevupUILoaderOptions } from '../loader' + +const defaults = { + package: 'package', + cssDir: 'cssDir', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: false, + singleCss: true, + defaultSheet: {}, + defaultClassMap: {}, + defaultFileMap: {}, +} + +export function invoke( + options: Partial = {}, + resourcePath = resolve('App.tsx'), + source = 'source', +) { + const addDependency = mock() + const addMissingDependency = mock() + const addContextDependency = mock() + const callback = mock() + const result = new Promise<{ + readonly code?: string | undefined + readonly map?: string | null | undefined + }>((resolve, reject) => { + callback.mockImplementation( + (error: Error | null, code?: string, map?: string | null) => { + if (error) reject(error) + else resolve({ code, map }) + }, + ) + Reflect.apply( + loader, + { + getOptions: () => ({ ...defaults, ...options }), + resourcePath, + fs, + addDependency, + addMissingDependency, + addContextDependency, + async: () => callback, + }, + [Buffer.from(source)], + ) + }) + return { + result, + callback, + addDependency, + addMissingDependency, + addContextDependency, + } +} diff --git a/packages/next-plugin/src/__tests__/loader-missing-context.test.ts b/packages/next-plugin/src/__tests__/loader-missing-context.test.ts new file mode 100644 index 000000000..c9a140871 --- /dev/null +++ b/packages/next-plugin/src/__tests__/loader-missing-context.test.ts @@ -0,0 +1,181 @@ +import * as fs from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { registerLoaderMissingDependencies } from '../loader-resolution-watch' +import { MdxFreshnessError } from '../mdx-source-freshness' + +it('tracks exact absent probes without recursively subscribing their ancestors', async () => { + // Given + const root = mkdtempSync(join(tmpdir(), 'devup-next-missing-')) + const outside = join(root, 'outside') + mkdirSync(outside) + writeFileSync(join(outside, 'file'), '') + const paths = [ + join(root, 'tsconfig.json'), + join(outside, 'earlier.js'), + join(outside, 'absent', 'index.ts'), + join(outside, 'file', 'deep', 'nested', 'index.cjs'), + ] + const missing: string[] = [] + const contexts: string[] = [] + const read = spyOn(fs, 'readFile') + const context = { + _compiler: undefined, + fs, + resourcePath: join(root, 'app/page.jsx'), + addMissingDependency: (path: string) => missing.push(path), + addContextDependency: (path: string) => contexts.push(path), + } + try { + // When + await registerLoaderMissingDependencies(context, paths) + // Then + expect(missing).toEqual(paths) + expect(read.mock.calls.map(([path]) => path)).toEqual(paths) + expect(contexts).toEqual([]) + } finally { + read.mockRestore() + rmSync(root, { recursive: true, force: true }) + } +}) + +it.each(['ENOENT', 'ENOTDIR', 'EACCES', 'EIO'])( + 'preserves the exact-read %s outcome instead of probing ancestors', + async (code) => { + // Given + const failure = Object.assign(new Error('exact read failed'), { code }) + const read = spyOn(fs, 'readFile').mockImplementation( + Object.assign( + (...args: unknown[]) => { + const callback = args.at(-1) + if (typeof callback !== 'function') + throw new TypeError('Missing callback') + queueMicrotask(() => Reflect.apply(callback, undefined, [failure])) + }, + { __promisify__: fs.readFile.__promisify__ }, + ), + ) + const context = { + _compiler: undefined, + fs, + resourcePath: 'page.jsx', + addMissingDependency: () => undefined, + } + try { + // When + const result = registerLoaderMissingDependencies(context, [ + join('outside', 'missing', 'index.cjs'), + ]) + // Then + if (code === 'ENOENT' || code === 'ENOTDIR') + await expect(result).resolves.toBeUndefined() + else await expect(result).rejects.toBe(failure) + expect(read).toHaveBeenCalled() + } finally { + read.mockRestore() + } + }, +) + +it('refuses stale delivery when an observed missing probe is now readable', async () => { + // Given + const root = mkdtempSync(join(tmpdir(), 'devup-next-readable-')) + const path = join(root, 'earlier.js') + writeFileSync(path, 'export const color = "blue"') + const context = { + _compiler: undefined, + fs, + resourcePath: join(root, 'page.jsx'), + addMissingDependency: () => undefined, + } + try { + // When + const result = registerLoaderMissingDependencies(context, [path]) + // Then + await expect(result).rejects.toBeInstanceOf(MdxFreshnessError) + await expect(result).rejects.toMatchObject({ + filename: context.resourcePath, + input: { kind: 'missing', path }, + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('waits for the receiving filesystem callback before settling missing registration', async () => { + // Given + let complete: (() => void) | undefined + const read = spyOn(fs, 'readFile').mockImplementation( + Object.assign( + (...args: unknown[]) => { + const callback = args.at(-1) + if (typeof callback !== 'function') + throw new TypeError('Missing callback') + complete = () => + Reflect.apply(callback, undefined, [ + Object.assign(new Error('absent'), { code: 'ENOENT' }), + ]) + }, + { __promisify__: fs.readFile.__promisify__ }, + ), + ) + let settled = false + const context = { + _compiler: undefined, + fs, + resourcePath: 'page.jsx', + addMissingDependency: () => undefined, + } + try { + // When + const result = Promise.resolve( + registerLoaderMissingDependencies(context, ['absent.js']), + ).then(() => { + settled = true + }) + await Promise.resolve() + // Then + expect(settled).toBe(false) + if (!complete) throw new TypeError('No exact read requested') + complete() + await result + expect(settled).toBe(true) + } finally { + read.mockRestore() + } +}) + +it('refuses an unavailable tracked filesystem without a context fallback', async () => { + // Given + const context = { + _compiler: undefined, + fs, + resourcePath: 'page.jsx', + addMissingDependency: () => undefined, + } + Object.defineProperty(context, 'fs', { value: undefined }) + // When / Then + await expect( + registerLoaderMissingDependencies(context, ['absent.js']), + ).rejects.toBeInstanceOf(TypeError) +}) + +it('does not require filesystem capabilities when no missing input was observed', async () => { + // Given + const missing: string[] = [] + const context = { + _compiler: undefined, + fs, + resourcePath: 'page.jsx', + addMissingDependency: (path: string) => missing.push(path), + } + Object.defineProperty(context, 'fs', { value: undefined }) + // When + await registerLoaderMissingDependencies(context, []) + // Then + expect(missing).toEqual([]) +}) diff --git a/packages/next-plugin/src/__tests__/loader-resolution.test.ts b/packages/next-plugin/src/__tests__/loader-resolution.test.ts new file mode 100644 index 000000000..97d60014c --- /dev/null +++ b/packages/next-plugin/src/__tests__/loader-resolution.test.ts @@ -0,0 +1,139 @@ +import { randomUUID } from 'node:crypto' +import { readFile } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' + +import { resolutionWatchPath } from '@devup-ui/plugin-utils' +import { expect, it } from 'bun:test' +import type webpack from 'webpack' + +import { startCoordinator } from '../coordinator' +import { extractInput } from '../coordinator-engine' +import loader, { type DevupUILoaderOptions } from '../loader' +import { createWasm } from '../wasm' +import { paletteFixture } from './mdx-resolution-fixture' + +it.each([undefined, true, false])( + 'registers real HTTP extraction categories with native symlinks=%s', + async (symlinks) => { + // Given + const f = paletteFixture() + const native: { readonly webpack: typeof webpack } = createRequire( + join(f.root, 'package.json'), + )('next/dist/compiled/webpack/webpack') + const compiler = native.webpack({ + mode: 'development', + context: f.root, + resolve: { symlinks }, + }) + const generation = await f.manager.prepare(f.signal) + const context = f.binding.effectiveAppContext + const wasm = createWasm(f.root) + generation.configureWasm(wasm) + const settings = { + package: context.libPackage, + cssDir: context.cssDir, + singleCss: true, + sourceMap: false, + importAliases: {}, + } + for (const input of generation.inputs) extractInput(wasm, settings, input) + const identity = { project: f.root, token: randomUUID() } + const portFile = join(f.root, 'coordinator.port') + const handle = startCoordinator({ + wasm, + ...settings, + projectRoot: f.root, + coordinatorPortFile: portFile, + identity, + canonicalMap: {}, + preparedSources: { + initial: { + generation, + ordinaryInputs: generation.ordinaryInputs, + revision: 1, + }, + prepareReplay: (request) => f.manager.refresh(request), + }, + }) + try { + await handle.ready + const input = generation.sources[0]?.input + if (!input) throw new TypeError('Missing prepared source') + // When + const files: string[] = [] + const missing: string[] = [] + const contexts: string[] = [] + const reads: { + readonly path: string + readonly code: string | undefined + }[] = [] + const result = Promise.withResolvers() + const options = { + ...settings, + projectRoot: f.root, + coordinatorPortFile: portFile, + coordinatorIdentity: identity, + sourceType: 'compiled-mdx', + watch: true, + sheetFile: '', + classMapFile: '', + fileMapFile: '', + themeFile: '', + themeFiles: [], + defaultSheet: {}, + defaultClassMap: {}, + defaultFileMap: {}, + } satisfies DevupUILoaderOptions + Reflect.apply( + loader, + { + _compiler: compiler, + fs: { + readFile: ( + path: string, + callback: ( + error: NodeJS.ErrnoException | null, + data?: Buffer, + ) => void, + ) => + readFile(path, (error, data) => { + reads.push({ path, code: error?.code }) + callback(error, data) + }), + }, + getOptions: () => options, + resourcePath: input.resourcePath, + addDependency: (path: string) => files.push(path), + addMissingDependency: (path: string) => missing.push(path), + addContextDependency: (path: string) => contexts.push(path), + async: () => (error: Error | null) => + error ? result.reject(error) : result.resolve(), + }, + [Buffer.from(input.source)], + ) + await result.promise + // Then + const transport = (path: string) => + typeof compiler.options.resolve?.symlinks === 'boolean' + ? resolutionWatchPath( + path, + compiler.options.resolve?.symlinks === false, + ) + : path + expect(files).toContain(transport(f.manifest)) + expect(missing).toContain(transport(join(f.root, 'tsconfig.json'))) + expect(files).not.toContain(transport(join(f.root, 'tsconfig.json'))) + expect(contexts).toEqual([]) + expect(reads).toContainEqual({ + path: transport(join(f.root, 'tsconfig.json')), + code: 'ENOENT', + }) + } finally { + await handle.drain() + await new Promise((resolve, reject) => + compiler.close((error) => (error ? reject(error) : resolve())), + ) + } + }, +) diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index 3f4df0644..d0d9b9e53 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -1,6 +1,8 @@ +import { randomUUID } from 'node:crypto' import * as fs from 'node:fs' import * as fsPromises from 'node:fs/promises' -import * as http from 'node:http' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import * as wasm from '@devup-ui/wasm' @@ -14,1038 +16,246 @@ import { spyOn, } from 'bun:test' -import type { DevupUILoaderOptions } from '../loader' -import devupUILoader, { resetInit, setWasmForTesting } from '../loader' - -type LoaderThis = ThisParameterType - -interface TestLoaderContext { - getOptions: () => Partial - async: ReturnType - resourcePath: string - addDependency?: ReturnType -} - -interface TestClientRequest { - on: ReturnType - write: ReturnType - end: ReturnType -} - -function asLoaderContext(context: TestLoaderContext): LoaderThis { - return context as unknown as LoaderThis -} - -function asClientRequest(request: TestClientRequest): http.ClientRequest { - return request as unknown as http.ClientRequest -} - -let existsSyncSpy: ReturnType -let readFileSyncSpy: ReturnType -let writeFileSpy: ReturnType -let codeExtractSpy: ReturnType -let exportClassMapSpy: ReturnType -let exportFileMapSpy: ReturnType -let exportSheetSpy: ReturnType -let getCssSpy: ReturnType -let importClassMapSpy: ReturnType -let importFileMapSpy: ReturnType -let importSheetSpy: ReturnType -let registerThemeSpy: ReturnType -let dateNowSpy: ReturnType - -beforeEach(() => { - resetInit() - existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) - readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue('{}') - writeFileSpy = spyOn(fsPromises, 'writeFile').mockResolvedValue(undefined) - codeExtractSpy = spyOn(wasm, 'codeExtract') - exportClassMapSpy = spyOn(wasm, 'exportClassMap') - exportFileMapSpy = spyOn(wasm, 'exportFileMap') - exportSheetSpy = spyOn(wasm, 'exportSheet') - getCssSpy = spyOn(wasm, 'getCss') - importClassMapSpy = spyOn(wasm, 'importClassMap').mockImplementation(() => {}) - importFileMapSpy = spyOn(wasm, 'importFileMap').mockImplementation(() => {}) - importSheetSpy = spyOn(wasm, 'importSheet').mockImplementation(() => {}) - registerThemeSpy = spyOn(wasm, 'registerTheme').mockImplementation(() => {}) - setWasmForTesting(wasm) - dateNowSpy = spyOn(Date, 'now').mockReturnValue(0) -}) - -afterEach(() => { - existsSyncSpy.mockRestore() - readFileSyncSpy.mockRestore() - writeFileSpy.mockRestore() - codeExtractSpy.mockRestore() - exportClassMapSpy.mockRestore() - exportFileMapSpy.mockRestore() - exportSheetSpy.mockRestore() - getCssSpy.mockRestore() - importClassMapSpy.mockRestore() - importFileMapSpy.mockRestore() - importSheetSpy.mockRestore() - registerThemeSpy.mockRestore() - dateNowSpy.mockRestore() -}) - -const waitFor = async (fn: () => void, timeout = 1000) => { - const start = performance.now() - while (performance.now() - start < timeout) { - try { - fn() - return - } catch { - await new Promise((r) => setTimeout(r, 10)) - } - } - fn() -} - -describe('devupUILoader', () => { - // Test BUILD mode init (lines 68-73) - it('should use default maps in non-watch mode on init', async () => { - const asyncCallback = mock() - const defaultClassMap = { test: 'classMap' } - const defaultFileMap = { test: 'fileMap' } - const defaultSheet = { test: 'sheet' } - const theme = { colors: { primary: '#000' } } - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssFile', - watch: false, - singleCss: true, - theme, - defaultClassMap, - defaultFileMap, - defaultSheet, - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: 'nowatch-init.tsx', - addDependency: mock(), - } - - codeExtractSpy.mockReturnValue({ - code: 'code', +import { formatPortFile } from '../coordinator-port' +import { resetInit, setWasmForTesting } from '../loader' +import { invoke } from './loader-fixture' + +describe('local source extraction', () => { + let spies: ReturnType[] = [] + let extract: ReturnType, + exists: ReturnType, + read: ReturnType, + write: ReturnType + beforeEach(() => { + resetInit() + setWasmForTesting(wasm) + exists = spyOn(fs, 'existsSync').mockReturnValue(false) + read = spyOn(fs, 'readFileSync').mockReturnValue('{}') + write = spyOn(fsPromises, 'writeFile').mockResolvedValue(undefined) + extract = spyOn(wasm, 'codeExtract').mockReturnValue({ + code: 'compiled', css: undefined, - free: mock(), - map: undefined, cssFile: undefined, + map: undefined, updatedBaseStyle: false, - dependencies: ['src/tokens.ts'], + dependencies: ['tokens.ts'], + free: mock(), [Symbol.dispose]: mock(), }) - - devupUILoader.bind(asLoaderContext(t))( - Buffer.from('code'), - 'nowatch-init.tsx', - ) - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith(null, 'code', null) - }) - - // Verify non-watch init was executed (lines 68-73) - expect(importFileMapSpy).toHaveBeenCalledWith(defaultFileMap) - expect(importClassMapSpy).toHaveBeenCalledWith(defaultClassMap) - expect(importSheetSpy).toHaveBeenCalledWith(defaultSheet) - expect(registerThemeSpy).toHaveBeenCalledWith(theme) - expect(t.addDependency).toHaveBeenCalledWith(resolve('src/tokens.ts')) + spies = [exists, read, write, extract] + for (const key of [ + 'importSheet', + 'importClassMap', + 'importFileMap', + 'registerTheme', + ] as const) + spies.push(spyOn(wasm, key).mockImplementation(() => {})) + for (const key of [ + 'exportSheet', + 'exportClassMap', + 'exportFileMap', + 'getCss', + ] as const) + spies.push(spyOn(wasm, key).mockReturnValue('state')) }) - - // Test WATCH mode init (lines 55-67) + CSS writing (lines 94-111) - it('should initialize watch mode and write css files', async () => { - existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockReturnValue( - '{"theme": {"colors": {"primary": "#fff"}}}', + afterEach(() => { + for (const spy of spies) spy.mockRestore() + setWasmForTesting(undefined) + }) + it('extracts with project-relative ids and dependency paths in build mode', async () => { + const projectRoot = resolve('project') + const run = invoke({ projectRoot }, join(projectRoot, 'App.tsx')) + expect(await run.result).toEqual({ code: 'compiled', map: null }) + expect(extract.mock.calls[0]?.[0]).toBe('App.tsx') + expect(run.addDependency).toHaveBeenCalledWith( + join(projectRoot, 'tokens.ts'), ) - exportSheetSpy.mockReturnValue('sheet') - exportClassMapSpy.mockReturnValue('classMap') - exportFileMapSpy.mockReturnValue('fileMap') - getCssSpy.mockReturnValue('base-css') - - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssDir', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - watch: true, - singleCss: true, - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: 'watch-init.tsx', - addDependency: mock(), - } - - codeExtractSpy.mockReturnValue({ - code: 'code', - css: 'css', - free: mock(), + expect(write).not.toHaveBeenCalled() + }) + it('initializes defaults once across local operations', async () => { + await invoke().result + await invoke().result + expect(wasm.importSheet).toHaveBeenCalledTimes(1) + expect(wasm.registerTheme).toHaveBeenCalledWith(undefined) + }) + it('writes updated sheet and per-file state in watch mode', async () => { + exists.mockReturnValue(true) + read.mockReturnValue('{"theme":{"colors":{}}}') + extract.mockReturnValue({ + code: 'compiled', map: '{}', cssFile: 'devup-ui-1.css', updatedBaseStyle: true, - [Symbol.dispose]: mock(), - }) - - devupUILoader.bind(asLoaderContext(t))( - Buffer.from('code'), - 'watch-init.tsx', - ) - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith(null, 'code', '{}') - }) - - // Verify watch mode init was executed (lines 55-67) - expect(existsSyncSpy).toHaveBeenCalledWith('sheetFile') - expect(existsSyncSpy).toHaveBeenCalledWith('classMapFile') - expect(existsSyncSpy).toHaveBeenCalledWith('fileMapFile') - expect(existsSyncSpy).toHaveBeenCalledWith('themeFile') - expect(registerThemeSpy).toHaveBeenCalledWith({ - colors: { primary: '#fff' }, + free: mock(), }) - - // Verify updatedBaseStyle && watch branch (lines 94-99) - expect(writeFileSpy).toHaveBeenCalledWith( + const run = invoke({ watch: true }) + expect(await run.result).toEqual({ code: 'compiled', map: '{}' }) + expect(write).toHaveBeenCalledWith( join('cssDir', 'devup-ui.css'), - 'base-css', + 'state', 'utf-8', ) - - // Verify cssFile && watch branch (lines 100-111) - expect(writeFileSpy).toHaveBeenCalledWith( - join('cssDir', 'devup-ui-1.css'), - '/* watch-init.tsx 0 */', + expect(write).toHaveBeenCalledWith('sheetFile', 'state') + expect(run.addDependency).toHaveBeenCalledWith('themeFile') + expect(wasm.registerTheme).toHaveBeenCalledWith({ colors: {} }) + }) + it('registers an empty theme when config has no theme', async () => { + exists.mockReturnValue(true) + await invoke({ watch: true, cssDir: resolve('.') }).result + expect(wasm.registerTheme).toHaveBeenCalledWith({}) + }) + it('does not import partial snapshots when a later snapshot cannot parse', async () => { + exists.mockReturnValue(true) + read.mockImplementation((file: unknown) => + file === 'fileMapFile' ? 'broken' : '{}', ) - expect(writeFileSpy).toHaveBeenCalledWith('sheetFile', 'sheet') - expect(writeFileSpy).toHaveBeenCalledWith('classMapFile', 'classMap') - expect(writeFileSpy).toHaveBeenCalledWith('fileMapFile', 'fileMap') + await expect(invoke({ watch: true }).result).rejects.toThrow() + expect(wasm.importSheet).not.toHaveBeenCalled() + expect(wasm.importClassMap).not.toHaveBeenCalled() }) - - it('should extract code without css in watch mode', async () => { - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssFile', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - watch: true, - singleCss: true, - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: 'index.tsx', - addDependency: mock(), - } - codeExtractSpy.mockReturnValue({ - code: 'code', - css: undefined, - free: mock(), - map: undefined, - cssFile: undefined, + it('extracts with missing snapshot files in watch mode', async () => { + expect((await invoke({ watch: true }).result).code).toBe('compiled') + expect(wasm.importSheet).not.toHaveBeenCalled() + }) + it.each([new Error('extraction failed'), 'extraction failed'])( + 'propagates extraction failure', + async (error) => { + extract.mockImplementation(() => { + throw error + }) + await expect(invoke().result).rejects.toThrow('extraction failed') + }, + ) + it('propagates write failure to the callback exactly once', async () => { + extract.mockReturnValue({ + code: 'compiled', + cssFile: 'file.css', updatedBaseStyle: false, - [Symbol.dispose]: mock(), - }) - devupUILoader.bind(asLoaderContext(t))(Buffer.from('code'), 'index.tsx') - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith(null, 'code', null) + free: mock(), }) + write.mockRejectedValue(new Error('write failed')) + const run = invoke({ watch: true }) + await expect(run.result).rejects.toThrow('write failed') + expect(run.callback).toHaveBeenCalledTimes(1) }) + it('rejects malformed source maps rather than returning success', async () => { + extract.mockReturnValue({ code: 'compiled', map: 'broken', free: mock() }) + await expect(invoke().result).rejects.toThrow() + }) +}) - it('should extract code without css in build mode', async () => { - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssFile', - watch: false, - singleCss: true, - defaultClassMap: {}, - defaultFileMap: {}, - defaultSheet: {}, - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: 'index.tsx', - addDependency: mock(), - } - codeExtractSpy.mockReturnValue({ - code: 'code', - css: undefined, - free: mock(), - map: undefined, - cssFile: undefined, - updatedBaseStyle: false, - [Symbol.dispose]: mock(), +describe('coordinator source extraction', () => { + let dir: string + let server: ReturnType + let response = '{}' + let status = 200 + let payload = '' + beforeEach(async () => { + dir = fs.mkdtempSync(join(tmpdir(), 'devup-loader-')) + response = '{}' + status = 200 + payload = '' + const identity = { project: dir, token: randomUUID() } + server = createServer((req, res) => { + if (req.url === '/health') + res.end(formatPortFile(port, process.pid, identity)) + else { + req.on('data', (chunk: Buffer) => { + payload += chunk.toString() + }) + req.on('end', () => { + res.writeHead(status) + res.end(response) + }) + } }) - devupUILoader.bind(asLoaderContext(t))(Buffer.from('code'), 'index.tsx') - - expect(codeExtractSpy).toHaveBeenCalledWith( - 'index.tsx', - 'code', - 'package', - './cssFile', - true, - false, - true, - {}, + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('Expected TCP server') + const port = address.port + fs.writeFileSync( + join(dir, 'endpoint'), + formatPortFile(port, process.pid, identity), ) - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith(null, 'code', null) - }) + fs.writeFileSync( + join(dir, 'themeFile'), + JSON.stringify({ extends: ['base.json', 'missing.json'] }), + ) + fs.writeFileSync(join(dir, 'base.json'), '{}') }) - - it('should handle error in build mode', async () => { - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssFile', - watch: false, - singleCss: true, - defaultClassMap: {}, - defaultFileMap: {}, - defaultSheet: {}, - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: 'index.tsx', - addDependency: mock(), - } - codeExtractSpy.mockImplementation(() => { - throw new Error('error') - }) - devupUILoader.bind(asLoaderContext(t))(Buffer.from('code'), 'index.tsx') - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith(new Error('error')) - }) + afterEach(async () => { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + fs.rmSync(dir, { recursive: true, force: true }) }) - - it('should handle error in watch mode', async () => { - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssFile', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - watch: true, - singleCss: true, - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: 'error-test.tsx', - addDependency: mock(), - } - - codeExtractSpy.mockImplementation(() => { - throw new Error('extraction error') - }) - - devupUILoader.bind(asLoaderContext(t))( - Buffer.from('code'), - 'error-test.tsx', + function run(extra: Partial = {}) { + return invoke( + { + coordinatorPortFile: join(dir, 'endpoint'), + projectRoot: dir, + requestTimeoutMs: 1000, + revisionFile: 'revision', + ...extra, + }, + join(dir, 'src', 'App.tsx'), ) - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith(expect.any(Error)) + } + it('returns code/map and watches config inheritance, missing config, revision and imported tokens', async () => { + response = JSON.stringify({ + code: 'compiled', + map: '{"version":3}', + dependencies: ['tokens.ts', 1], + }) + const request = run() + expect(await request.result).toEqual({ + code: 'compiled', + map: '{"version":3}', }) + expect(JSON.parse(payload).filename).toBe('src/App.tsx') + for (const file of [ + 'endpoint', + 'revision', + 'themeFile', + 'base.json', + 'missing.json', + 'tokens.ts', + ]) + expect(request.addDependency).toHaveBeenCalledWith(join(dir, file)) }) - - it('should propagate css write failures in watch mode', async () => { - const asyncCallback = mock() - const writeError = new Error('write failed') - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssDir', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - watch: true, - singleCss: true, - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: 'write-error.tsx', - addDependency: mock(), - } - writeFileSpy.mockRejectedValueOnce(writeError) - codeExtractSpy.mockReturnValue({ - code: 'code', - css: 'css', - free: mock(), - map: '{}', - cssFile: 'devup-ui-1.css', - updatedBaseStyle: false, - [Symbol.dispose]: mock(), + it('uses explicit theme dependency files when supplied', async () => { + response = '{"code":"compiled"}' + const request = run({ + themeFiles: ['explicit.json'], + revisionFile: undefined, }) - - devupUILoader.bind(asLoaderContext(t))( - Buffer.from('code'), - 'write-error.tsx', + expect(await request.result).toEqual({ code: 'compiled', map: null }) + expect(request.addDependency).toHaveBeenCalledWith( + join(dir, 'explicit.json'), ) - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith(writeError) - }) - expect(asyncCallback).not.toHaveBeenCalledWith(null, 'code', {}) }) - - it('should use correct relative css path', async () => { - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: './foo', - watch: false, - singleCss: true, - defaultClassMap: {}, - defaultFileMap: {}, - defaultSheet: {}, - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: './foo/index.tsx', - addDependency: mock(), - } - codeExtractSpy.mockReturnValue({ - code: 'code', - css: 'css', - free: mock(), - map: undefined, - cssFile: 'cssFile', - updatedBaseStyle: false, - [Symbol.dispose]: mock(), - }) - devupUILoader.bind(asLoaderContext(t))( - Buffer.from('code'), - '/foo/index.tsx', + it.each([ + 'null', + '{}', + '{"code":1}', + '{', + '{"code":"compiled","map":"broken"}', + ])('rejects malformed extraction data %s with location', async (body) => { + response = body + const request = run() + await expect(request.result).rejects.toThrow( + `${join(dir, 'src', 'App.tsx')}:1:1:`, ) - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith(null, 'code', null) - }) + expect(request.callback).toHaveBeenCalledTimes(1) }) - - it('should not write css files in build mode even with cssFile', async () => { - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssFile', - watch: false, - singleCss: true, - defaultClassMap: {}, - defaultFileMap: {}, - defaultSheet: {}, - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: 'index.tsx', - addDependency: mock(), - } - codeExtractSpy.mockReturnValue({ - code: 'code', - css: 'css', - free: mock(), - map: '{}', - cssFile: 'cssFile', - updatedBaseStyle: true, - [Symbol.dispose]: mock(), - }) - devupUILoader.bind(asLoaderContext(t))(Buffer.from('code'), 'index.tsx') - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith(null, 'code', '{}') - }) - // In build mode (watch=false), no CSS files should be written - expect(writeFileSpy).not.toHaveBeenCalled() + it('propagates server build-time error details', async () => { + status = 500 + response = '{"error":"tokens.ts:3:5: failed"}' + await expect(run().result).rejects.toThrow('tokens.ts:3:5: failed') }) - - describe('coordinator mode', () => { - it('should delegate to coordinator via HTTP when coordinatorPortFile exists', async () => { - existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockReturnValue('12345') - - const responseBody = JSON.stringify({ - code: 'coordinator code', - map: '{"version":3}', - cssFile: 'devup-ui-1.css', - updatedBaseStyle: true, - dependencies: ['src/tokens.ts', 1], - }) - - const requestSpy = spyOn(http, 'request').mockImplementation( - (_options: any, callback?: any) => { - // Simulate a response - const fakeRes = { - statusCode: 200, - on: mock((event: string, handler: (...args: unknown[]) => void) => { - if (event === 'data') { - handler(Buffer.from(responseBody)) - } - if (event === 'end') { - handler() - } - return fakeRes - }), - } - if (callback) callback(fakeRes) - return asClientRequest({ - on: mock(() => ({})), - write: mock(), - end: mock(), - }) - }, - ) - - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssDir', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - watch: true, - singleCss: true, - coordinatorPortFile: 'coordinator.port', - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - addDependency: mock(), - } - - devupUILoader.bind(asLoaderContext(t))( - Buffer.from('source code'), - 'src/App.tsx', - ) - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith( - null, - 'coordinator code', - '{"version":3}', - ) - }) - - expect(t.addDependency).toHaveBeenCalledWith(resolve('src/tokens.ts')) - expect(t.addDependency).not.toHaveBeenCalledWith(resolve('1')) - - // Verify HTTP request was made - expect(requestSpy).toHaveBeenCalledTimes(1) - const reqOptions = requestSpy.mock.calls[0]![0] as Record - expect(reqOptions.hostname).toBe('127.0.0.1') - expect(reqOptions.port).toBe(12345) - expect(reqOptions.path).toBe('/extract') - expect(reqOptions.method).toBe('POST') - - // Verify NO WASM functions were called - expect(codeExtractSpy).not.toHaveBeenCalled() - - requestSpy.mockRestore() - }) - - it('should handle coordinator HTTP error', async () => { - existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockReturnValue('12345') - - const requestSpy = spyOn(http, 'request').mockImplementation( - (_options: any, _callback?: any) => { - const fakeReq = { - on: mock((event: string, handler: (...args: unknown[]) => void) => { - if (event === 'error') { - // Trigger error asynchronously - setTimeout(() => handler(new Error('connection refused')), 0) - } - return fakeReq - }), - write: mock(), - end: mock(), - } - return asClientRequest(fakeReq) - }, - ) - - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssDir', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - watch: true, - singleCss: true, - coordinatorPortFile: 'coordinator.port', - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - addDependency: mock(), - } - - devupUILoader.bind(asLoaderContext(t))( - Buffer.from('source code'), - 'src/App.tsx', - ) - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith(expect.any(Error)) - }) - - requestSpy.mockRestore() - }) - - it('should handle coordinator non-200 response', async () => { - existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockReturnValue('12345') - - const responseBody = JSON.stringify({ - error: 'extraction failed on server', - }) - - const requestSpy = spyOn(http, 'request').mockImplementation( - (_options: any, callback?: any) => { - const fakeRes = { - statusCode: 500, - on: mock((event: string, handler: (...args: unknown[]) => void) => { - if (event === 'data') handler(Buffer.from(responseBody)) - if (event === 'end') handler() - return fakeRes - }), - } - if (callback) callback(fakeRes) - return asClientRequest({ - on: mock(() => ({})), - write: mock(), - end: mock(), - }) - }, - ) - - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssDir', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - watch: true, - singleCss: true, - coordinatorPortFile: 'coordinator.port', - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - addDependency: mock(), - } - - devupUILoader.bind(asLoaderContext(t))( - Buffer.from('source code'), - 'src/App.tsx', - ) - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith( - new Error('extraction failed on server'), - ) - }) - - requestSpy.mockRestore() - }) - - it('should handle non-object coordinator response', async () => { - existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockReturnValue('12345') - - const requestSpy = spyOn(http, 'request').mockImplementation( - (_options: any, callback?: any) => { - const fakeRes = { - statusCode: 200, - on: mock((event: string, handler: (...args: unknown[]) => void) => { - if (event === 'data') handler(Buffer.from('null')) - if (event === 'end') handler() - return fakeRes - }), - } - if (callback) callback(fakeRes) - return asClientRequest({ - on: mock(() => ({})), - write: mock(), - end: mock(), - }) - }, - ) - - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssDir', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - watch: true, - singleCss: true, - coordinatorPortFile: 'coordinator.port', - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - addDependency: mock(), - } - - devupUILoader.bind(asLoaderContext(t))( - Buffer.from('source code'), - 'src/App.tsx', - ) - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith( - new Error('Coordinator response missing code'), - ) - }) - - requestSpy.mockRestore() - }) - - it('should handle coordinator response without code', async () => { - existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockReturnValue('12345') - - const requestSpy = spyOn(http, 'request').mockImplementation( - (_options: any, callback?: any) => { - const fakeRes = { - statusCode: 200, - on: mock((event: string, handler: (...args: unknown[]) => void) => { - if (event === 'data') - handler(Buffer.from(JSON.stringify({ map: '{}' }))) - if (event === 'end') handler() - return fakeRes - }), - } - if (callback) callback(fakeRes) - return asClientRequest({ - on: mock(() => ({})), - write: mock(), - end: mock(), - }) - }, - ) - - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssDir', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - watch: true, - singleCss: true, - coordinatorPortFile: 'coordinator.port', - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - addDependency: mock(), - } - - devupUILoader.bind(asLoaderContext(t))( - Buffer.from('source code'), - 'src/App.tsx', - ) - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith( - new Error('Coordinator response missing code'), - ) - }) - - requestSpy.mockRestore() - }) - - it('should handle malformed coordinator response', async () => { - existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockReturnValue('12345') - - const requestSpy = spyOn(http, 'request').mockImplementation( - (_options: any, callback?: any) => { - const fakeRes = { - statusCode: 200, - on: mock((event: string, handler: (...args: unknown[]) => void) => { - if (event === 'data') handler(Buffer.from('not json')) - if (event === 'end') handler() - return fakeRes - }), - } - if (callback) callback(fakeRes) - return asClientRequest({ - on: mock(() => ({})), - write: mock(), - end: mock(), - }) - }, - ) - - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssDir', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - watch: true, - singleCss: true, - coordinatorPortFile: 'coordinator.port', - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - addDependency: mock(), - } - - devupUILoader.bind(asLoaderContext(t))( - Buffer.from('source code'), - 'src/App.tsx', - ) - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith(expect.any(Error)) - }) - - requestSpy.mockRestore() - }) - - it('should retry and error when coordinatorPortFile never appears', async () => { - // existsSync always returns false — port file never appears - existsSyncSpy.mockReturnValue(false) - - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssDir', - watch: true, - singleCss: true, - coordinatorPortFile: 'nonexistent.port', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: 'fallback.tsx', - addDependency: mock(), - } - - devupUILoader.bind(asLoaderContext(t))( - Buffer.from('code'), - 'fallback.tsx', - ) - - // Retries 20 times × 50ms = 1s max, then calls back with error - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith( - new Error('Coordinator port file not found'), - ) - }, 3000) - - // WASM should NOT be used — coordinator mode does not fall back - expect(codeExtractSpy).not.toHaveBeenCalled() - }) - - it('should retry and succeed when coordinatorPortFile appears after delay', async () => { - // First few calls: port file doesn't exist, then it appears - let callCount = 0 - existsSyncSpy.mockImplementation((path: string) => { - if (path === 'coordinator.port') { - callCount++ - return callCount > 3 // Appears on 4th check - } - return false - }) - readFileSyncSpy.mockReturnValue('12345') - - const responseBody = JSON.stringify({ - code: 'coordinator code', - map: undefined, - cssFile: undefined, - updatedBaseStyle: false, - }) - - const requestSpy = spyOn(http, 'request').mockImplementation( - (_options: any, callback?: any) => { - const fakeRes = { - statusCode: 200, - on: mock((event: string, handler: (...args: unknown[]) => void) => { - if (event === 'data') handler(Buffer.from(responseBody)) - if (event === 'end') handler() - return fakeRes - }), - } - if (callback) callback(fakeRes) - return asClientRequest({ - on: mock(() => ({})), - write: mock(), - end: mock(), - }) - }, - ) - - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssDir', - watch: true, - singleCss: true, - coordinatorPortFile: 'coordinator.port', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - addDependency: mock(), - } - - devupUILoader.bind(asLoaderContext(t))(Buffer.from('code'), 'src/App.tsx') - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith( - null, - 'coordinator code', - null, - ) - }) - - expect(requestSpy).toHaveBeenCalledTimes(1) - requestSpy.mockRestore() - }) - - it('should handle error when reading coordinator port file fails', async () => { - existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockImplementation((path: string) => { - if (path === 'coordinator.port') { - throw new Error('EACCES: permission denied') - } - return '{}' - }) - - const asyncCallback = mock() - const t = { - getOptions: () => ({ - package: 'package', - cssDir: 'cssDir', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - watch: true, - singleCss: true, - coordinatorPortFile: 'coordinator.port', - }), - async: mock().mockReturnValue(asyncCallback), - resourcePath: join(process.cwd(), 'src', 'App.tsx'), - addDependency: mock(), - } - - devupUILoader.bind(asLoaderContext(t))( - Buffer.from('source code'), - 'src/App.tsx', - ) - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith( - new Error('EACCES: permission denied'), - ) - }) - }) - - it('should cache the coordinator port', async () => { - existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockReturnValue('54321') - - const responseBody = JSON.stringify({ - code: 'code', - map: undefined, - cssFile: undefined, - updatedBaseStyle: false, - }) - - const requestSpy = spyOn(http, 'request').mockImplementation( - (_options: any, callback?: any) => { - const fakeRes = { - statusCode: 200, - on: mock((event: string, handler: (...args: unknown[]) => void) => { - if (event === 'data') handler(Buffer.from(responseBody)) - if (event === 'end') handler() - return fakeRes - }), - } - if (callback) callback(fakeRes) - return asClientRequest({ - on: mock(() => ({})), - write: mock(), - end: mock(), - }) - }, - ) - - const makeContext = () => ({ - getOptions: () => ({ - package: 'package', - cssDir: 'cssDir', - sheetFile: 'sheetFile', - classMapFile: 'classMapFile', - fileMapFile: 'fileMapFile', - themeFile: 'themeFile', - watch: true, - singleCss: true, - coordinatorPortFile: 'coordinator.port', - }), - async: mock().mockReturnValue(mock()), - resourcePath: join(process.cwd(), 'src', 'test.tsx'), - addDependency: mock(), - }) - - // First call reads port from file - devupUILoader.bind(asLoaderContext(makeContext()))( - Buffer.from('code'), - 'test.tsx', - ) - await waitFor(() => { - expect(requestSpy).toHaveBeenCalledTimes(1) - }) - - // Second call should use cached port (readFileSync called only once for port) - devupUILoader.bind(asLoaderContext(makeContext()))( - Buffer.from('code'), - 'test.tsx', - ) - await waitFor(() => { - expect(requestSpy).toHaveBeenCalledTimes(2) - }) - - // readFileSync should only be called once for the port file - // (existsSync is called each time, but readFileSync for port file only once due to caching) - const portReads = readFileSyncSpy.mock.calls.filter( - (call: unknown[]) => call[0] === 'coordinator.port', - ) - expect(portReads.length).toBe(1) - - requestSpy.mockRestore() - }) + it('reports invalid config with a located coordinator error before dispatch', async () => { + fs.writeFileSync(join(dir, 'themeFile'), 'broken') + await expect(run().result).rejects.toThrow(`${join(dir, 'themeFile')}:1:1:`) + expect(payload).toBe('') }) }) diff --git a/packages/next-plugin/src/__tests__/mdx-binding-fixture.ts b/packages/next-plugin/src/__tests__/mdx-binding-fixture.ts new file mode 100644 index 000000000..4924b759a --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-binding-fixture.ts @@ -0,0 +1,43 @@ +import { + captureMdxBindingReceipt, + type MdxNativeLoaderFacts, +} from '../mdx-binding' + +export const bindingOwner = { + ownerID: 'run/evaluation/token', + configFile: 'C:/app/next.config.mjs', +} as const +export const bindingInput = { + owner: bindingOwner, + evaluation: { + evaluationID: 'evaluation', + configFile: bindingOwner.configFile, + projectDir: 'C:/app', + phase: 'phase-production-build', + isolateID: 'isolate', + }, + delivery: { + sourceMap: false, + layer: undefined, + isServer: false, + compilerName: 'client', + }, +} as const + +export function bindingReceipt( + options: unknown, + facts: Partial = {}, +) { + return captureMdxBindingReceipt({ + ...bindingInput, + loaders: [ + { + resolvedPath: '/native/compiler.js', + packageVersion: '1.0', + verifiedFileHash: 'verified-fixture-hash', + options, + ...facts, + }, + ], + }) +} diff --git a/packages/next-plugin/src/__tests__/mdx-binding-native.test.ts b/packages/next-plugin/src/__tests__/mdx-binding-native.test.ts new file mode 100644 index 000000000..a54cddfcb --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-binding-native.test.ts @@ -0,0 +1,26 @@ +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +it('preserves exact installed MDX compiler and fresh WASM class/CSS bytes with maps on and off', () => { + // Given + const workspace = resolve(import.meta.dir, '../../../..') + const driver = join(import.meta.dir, 'mdx-binding-native.ts') + // When + const result = Bun.spawnSync([process.execPath, 'run', driver, workspace], { + cwd: workspace, + env: { ...process.env, NODE_ENV: 'test' }, + timeout: 30_000, + }) + // Then + expect(result.exitCode, result.stderr.toString()).toBe(0) + const output: unknown = JSON.parse(result.stdout.toString()) + if (typeof output !== 'object' || output === null) + throw new TypeError('invalid native fixture output') + expect(output).toMatchObject({ + parity: true, + compilerVersion: '3.1.1', + identity: { packageVersion: '16.3.6' }, + rejectedPath: 'loaders[0].options.remarkPlugins[0]', + }) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-binding-native.ts b/packages/next-plugin/src/__tests__/mdx-binding-native.ts new file mode 100644 index 000000000..d02078aad --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-binding-native.ts @@ -0,0 +1,256 @@ +import { createHash } from 'node:crypto' +import { + mkdtempSync, + readFile, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { + captureMdxBindingReceipt, + createMdxBinding, + MdxBindingError, +} from '../mdx-binding' +import { createWasm } from '../wasm' + +type Runner = ( + input: { + readonly resource: string + readonly loaders: readonly { + readonly loader: string + readonly options: object + readonly ident: string + }[] + readonly readResource: typeof readFile + readonly context: { + readonly sourceMap: boolean + readonly _compiler: object + readonly getOptions: (this: { readonly query: unknown }) => unknown + } + }, + callback: (error: unknown, result: unknown) => void, +) => void + +function isRunner(value: unknown): value is Runner { + return typeof value === 'function' +} +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} +function packageVersion(path: string): string { + const metadata: unknown = JSON.parse(readFileSync(path, 'utf8')) + if (!isRecord(metadata) || typeof metadata.version !== 'string') + throw new TypeError('missing installed package version') + return metadata.version +} + +const workspace = process.argv[2] +if (!workspace) throw new TypeError('workspace argument required') +const installed = createRequire(join(workspace, 'apps/landing/package.json')) +const loader = installed.resolve('@next/mdx/mdx-js-loader') +const compiler = createRequire(loader).resolve('@mdx-js/loader') +const compilerPackage = join(dirname(compiler), 'package.json') +const native: unknown = installed( + 'next/dist/compiled/loader-runner/LoaderRunner.js', +) +if (!isRecord(native) || !isRunner(native.runLoaders)) + throw new TypeError('installed runner unavailable') +const runLoaders = native.runLoaders +const root = mkdtempSync(join(tmpdir(), 'devup-mdx-binding-')) +try { + const filename = join(root, 'page.mdx') + const plugin = join(root, 'remark.cjs') + writeFileSync( + plugin, + 'module.exports = function(options) { options.visits += 1; return function() {} }', + ) + writeFileSync( + filename, + `import { Box, css, globalCss } from '@devup-ui/react' + +export const accent = css({ color: 'purple' }) + +export const globalStyle = globalCss({ body: { margin: 0 } }) + +# Native maps parity + + +`, + ) + const identity = { + resolvedPath: loader, + packageVersion: packageVersion(join(dirname(loader), 'package.json')), + verifiedFileHash: createHash('sha256') + .update(readFileSync(loader)) + .digest('hex'), + } + if ( + identity.packageVersion !== '16.3.6' || + packageVersion(compilerPackage) !== '3.1.1' || + identity.verifiedFileHash !== + '2ba24dda327d48e82b4fbcd31f4b5521ebd76e9cdbeb591fd42a0a10beb2465a' + ) + throw new TypeError('exact installed MDX compiler changed') + const ownerFacts = { + ownerID: 'native/maps-parity', + configFile: join(root, 'next.config.mjs'), + } + const binding = createMdxBinding(ownerFacts) + const outputs: { + readonly engine: ReturnType + readonly sourceMap: boolean + readonly compilerCode: string + readonly code: string + readonly css: string + readonly classes: string + readonly compilerMap: boolean + readonly wasmMap: boolean + readonly visits: number + }[] = [] + let original: ReturnType | undefined + for (const sourceMap of [false, true]) { + const shared = { visits: 0 } + const options = { jsx: true, remarkPlugins: [[plugin, shared]] } + const receipt = captureMdxBindingReceipt({ + owner: ownerFacts, + evaluation: { + evaluationID: 'native-evaluation', + configFile: ownerFacts.configFile, + projectDir: root, + phase: 'phase-production-build', + isolateID: 'native-driver', + }, + delivery: { + sourceMap, + layer: sourceMap ? 'rsc' : undefined, + isServer: sourceMap, + compilerName: sourceMap ? 'server' : 'client', + }, + loaders: [{ ...identity, options }], + }) + binding.accept(receipt) + original ??= receipt + const result = await new Promise((resolve, reject) => + runLoaders( + { + resource: filename, + loaders: [{ loader, options, ident: 'binding-native' }], + readResource: readFile, + context: { + sourceMap, + _compiler: {}, + getOptions() { + return this.query + }, + }, + }, + (error, output) => (error ? reject(error) : resolve(output)), + ), + ) + if (!isRecord(result) || !Array.isArray(result.result)) + throw new TypeError('invalid native loader output') + const source: unknown = result.result[0] + const map: unknown = result.result[1] + if (typeof source !== 'string' && !Buffer.isBuffer(source)) + throw new TypeError('missing real compiler source') + const code = source.toString() + const engine = createWasm(workspace) + engine.setPrefix('binding-') + engine.seedFileMap([filename]) + engine.importCanonicalMap({}) + engine.importClassMap({}) + const extract = sourceMap + ? engine.codeExtract + : engine.codeExtractWithoutSourceMap + const output = extract( + filename, + code, + '@devup-ui/react', + './df', + true, + false, + false, + {}, + ) + try { + outputs.push({ + engine, + sourceMap, + compilerCode: code, + code: output.code, + css: engine.getCss(null, false), + classes: engine.exportClassMap(), + compilerMap: map !== undefined, + wasmMap: output.map !== undefined, + visits: shared.visits, + }) + } finally { + output.free() + } + } + const [off, on] = outputs + if ( + !off || + !on || + off.compilerCode !== on.compilerCode || + off.code !== on.code || + off.css !== on.css || + off.classes !== on.classes || + !off.css.includes('background:red') || + !off.css.includes('background:blue') || + !off.css.includes('color:green') || + !off.css.includes('color:purple') || + !off.css.includes('body{margin:0}') || + off.compilerMap || + !on.compilerMap || + off.wasmMap || + !on.wasmMap || + off.visits !== 1 || + on.visits !== 1 + ) + throw new TypeError('real compiler/WASM maps parity failed') + const before = binding.state() + if (!original) throw new TypeError('missing original native receipt') + const changed = captureMdxBindingReceipt({ + ...original, + loaders: [ + { ...identity, options: { jsx: true, remarkPlugins: [() => undefined] } }, + ], + }) + let cause: unknown + try { + binding.accept(changed) + } catch (error) { + if (!(error instanceof MdxBindingError)) throw error + cause = error + } + if ( + !(cause instanceof MdxBindingError) || + binding.state() !== before || + on.engine.getCss(null, false) !== on.css || + on.engine.exportClassMap() !== on.classes + ) + throw new TypeError('negative binding published state') + console.info( + JSON.stringify({ + identity, + compilerVersion: packageVersion(compilerPackage), + parity: true, + rejectedPath: cause.path, + css: on.css, + classes: on.classes, + outputs: outputs.map(({ sourceMap, compilerMap, wasmMap, visits }) => ({ + sourceMap, + compilerMap, + wasmMap, + visits, + })), + }), + ) +} finally { + rmSync(root, { recursive: true, force: true }) +} diff --git a/packages/next-plugin/src/__tests__/mdx-binding-order.test.ts b/packages/next-plugin/src/__tests__/mdx-binding-order.test.ts new file mode 100644 index 000000000..d1040e649 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-binding-order.test.ts @@ -0,0 +1,118 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createMdxBinding, MdxBindingError } from '../mdx-binding' +import { type MdxPipeline } from '../mdx-pipeline' +import { compileMdx, createMdxDeadline } from '../mdx-prepare' +import { createWasm } from '../wasm' +import { bindingOwner, bindingReceipt } from './mdx-binding-fixture' + +const root = resolve(import.meta.dir, '../../../../apps/landing') +const installed = createRequire(join(root, 'package.json')) + +function plugin(options: Readonly>) { + return (tree: unknown) => { + if ( + typeof tree !== 'object' || + tree === null || + !('children' in tree) || + !Array.isArray(tree.children) + ) + throw new TypeError('Expected a native MDX tree') + const color = Object.keys(options)[0] + if (color === undefined) throw new TypeError('Expected a configured color') + for (const value of tree.children) { + const node: unknown = value + if ( + typeof node === 'object' && + node !== null && + 'type' in node && + node.type === 'mdxJsxFlowElement' && + 'name' in node && + node.name === 'Box' && + 'attributes' in node && + Array.isArray(node.attributes) + ) + node.attributes.push({ + type: 'mdxJsxAttribute', + name: 'bg', + value: color, + }) + } + } +} + +async function css( + filename: string, + options: Readonly>, +) { + const pipeline: MdxPipeline = { + bundler: 'webpack', + ruleKey: 'order-proof', + conditions: [], + aliases: {}, + loaders: [ + { + loader: installed.resolve('@next/mdx/mdx-js-loader'), + options: { jsx: true, remarkPlugins: [[plugin, options]] }, + }, + ], + } + const compiled = await compileMdx({ + root, + filename, + pipeline, + signal: new AbortController().signal, + deadline: createMdxDeadline(), + }) + const engine = createWasm(root) + engine.seedFileMap([filename]) + const output = engine.codeExtractWithoutSourceMap( + filename, + compiled.source, + '@devup-ui/react', + './styles', + true, + false, + true, + {}, + ) + try { + return engine.getCss(undefined, false) + } finally { + output.free() + } +} + +it('rejects a structurally equal option map whose native key order produces different CSS', async () => { + // Given + const directory = mkdtempSync(join(tmpdir(), 'devup-binding-order-')) + const filename = join(directory, 'page.mdx') + writeFileSync( + filename, + 'import {Box} from "@devup-ui/react"\n\norder', + ) + const left = { red: 1, blue: 1 } + const right = { blue: 1, red: 1 } + const owner = createMdxBinding(bindingOwner) + owner.accept(bindingReceipt({ remarkPlugins: [[plugin, left]] })) + const before = owner.state() + try { + const first = await css(filename, left) + const second = await css(filename, right) + expect(first).toContain('background:red') + expect(second).toContain('background:blue') + // When + const rebind = () => + owner.accept(bindingReceipt({ remarkPlugins: [[plugin, right]] })) + // Then + expect(rebind).toThrow(MdxBindingError) + expect(owner.state()).toBe(before) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/next-plugin/src/__tests__/mdx-binding-value.test.ts b/packages/next-plugin/src/__tests__/mdx-binding-value.test.ts new file mode 100644 index 000000000..ca48d6a57 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-binding-value.test.ts @@ -0,0 +1,214 @@ +import { expect, it } from 'bun:test' + +import { bindingReceipt } from './mdx-binding-fixture' + +it.each([ + [1, 2, 'loaders[0].options'], + [NaN, NaN, undefined], + [0, -0, 'loaders[0].options'], + [1n, 1n, undefined], + [undefined, null, 'loaders[0].options'], + [{ x: 1 }, 1, 'loaders[0].options'], + [1, { x: 1 }, 'loaders[0].options'], + [[1], { 0: 1, length: 1 }, 'loaders[0].options'], + [{ x: 1 }, {}, 'loaders[0].options.x'], + [{}, { x: 1 }, 'loaders[0].options.x'], + [[1], [1, 2], 'loaders[0].options.length'], + [ + { nested: { first: 1, second: 2 } }, + { nested: { first: 2, second: 3 } }, + 'loaders[0].options.nested.first', + ], +] as const)( + 'compares primitive and shell differences %#', + (left, right, path) => { + // Given + const original = bindingReceipt(left) + // When + const difference = original.difference(bindingReceipt(right)) + // Then + expect(difference?.path).toBe(path) + }, +) + +it('accepts null-prototype structural copies preserving option key order', () => { + // Given + const left = bindingReceipt({ first: 1, second: 2 }) + const right: Record = Object.create(null) + right.first = 1 + right.second = 2 + // When + const difference = left.difference(bindingReceipt(right)) + // Then + expect(difference).toBeUndefined() +}) + +it('rejects option key order differences that a native plugin can observe', () => { + // Given + const left = bindingReceipt({ red: 1, blue: 1 }) + // When + const difference = left.difference(bindingReceipt({ blue: 1, red: 1 })) + // Then + expect(difference?.path).toBe('loaders[0].options') +}) + +it('compares nonplain objects by identity rather than equal contents', () => { + // Given + const date = new Date(0) + const left = bindingReceipt({ date }) + // When + const difference = left.difference(bindingReceipt({ date: new Date(0) })) + // Then + expect(difference?.path).toBe('loaders[0].options.date') + expect(left.difference(bindingReceipt({ date }))).toBeUndefined() +}) + +it('retains function identity even when JSON projections are equal', () => { + // Given + const left = { plugin: () => undefined } + const right = { plugin: () => undefined } + expect(JSON.stringify(left)).toBe(JSON.stringify(right)) + // When + const difference = bindingReceipt(left).difference(bindingReceipt(right)) + // Then + expect(difference?.path).toBe('loaders[0].options.plugin') +}) + +it('compares symbols by identity and reports symbol property paths', () => { + // Given + const key = Symbol('plugin') + const left = bindingReceipt({ [key]: Symbol.for('value') }) + // When + const difference = left.difference(bindingReceipt({ [key]: Symbol('value') })) + // Then + expect(difference?.path).toBe('loaders[0].options[Symbol(plugin)]') + expect( + left.difference(bindingReceipt({ [key]: Symbol.for('value') })), + ).toBeUndefined() +}) + +it('compares cyclic copies without requiring shared shell identity', () => { + // Given + const left: Record = { value: 1 } + left.self = left + const right: Record = { value: 1 } + right.self = right + const receipt = bindingReceipt(left) + // When + const difference = receipt.difference(bindingReceipt(right)) + // Then + expect(difference).toBeUndefined() + expect(receipt.difference(bindingReceipt(left))).toBeUndefined() +}) + +it('finds a differing leaf past a cyclic back edge', () => { + // Given + const left: Record = {} + const right: Record = {} + left.self = left + right.self = right + left.value = 1 + right.value = 2 + // When + const difference = bindingReceipt(left).difference(bindingReceipt(right)) + // Then + expect(difference?.path).toBe('loaders[0].options.value') +}) + +it('accepts shared-versus-copied containers while detecting a second-pair difference', () => { + // Given + const shared = { value: 1 } + const left = bindingReceipt([shared, shared]) + // When + const difference = left.difference( + bindingReceipt([{ value: 1 }, { value: 2 }]), + ) + // Then + expect(difference?.path).toBe('loaders[0].options[1].value') + expect( + left.difference(bindingReceipt([{ value: 1 }, { value: 1 }])), + ).toBeUndefined() +}) + +it('snapshots accessor descriptors without invoking user getters', () => { + // Given + let calls = 0 + const get = () => { + calls += 1 + return 1 + } + const set = () => undefined + const left = Object.defineProperty({}, 'plugin', { + get, + set, + configurable: true, + }) + const right = Object.defineProperty({}, 'plugin', { + get, + set, + configurable: true, + }) + // When + const difference = bindingReceipt(left).difference(bindingReceipt(right)) + // Then + expect(difference).toBeUndefined() + expect(calls).toBe(0) +}) + +it.each(['get', 'set'] as const)( + 'rejects different %s accessor identity without calling it', + (key) => { + // Given + const accessor = () => { + throw new TypeError('must not call') + } + const left = Object.defineProperty({}, 'plugin', { [key]: accessor }) + const right = Object.defineProperty({}, 'plugin', { + [key]: () => undefined, + }) + // When + const difference = bindingReceipt(left).difference(bindingReceipt(right)) + // Then + expect(difference?.path).toBe('loaders[0].options.plugin') + }, +) + +it.each([ + [{ value: 1 }, { get: () => 1 }], + [{ get: () => 1 }, { value: 1 }], + [{ value: 1, enumerable: true }, { value: 1 }], +])('compares descriptor boundary differences %#', (left, right) => { + // Given + const original = Object.defineProperty({}, 'option', left) + const receiving = Object.defineProperty({}, 'option', right) + // When + const difference = bindingReceipt(original).difference( + bindingReceipt(receiving), + ) + // Then + expect(difference?.path).toBe('loaders[0].options.option') +}) + +it('handles a disappearing proxy descriptor without reading its value', () => { + // Given + const options = new Proxy( + { gone: 1 }, + { getOwnPropertyDescriptor: () => undefined }, + ) + // When + const difference = bindingReceipt(options).difference(bindingReceipt({})) + // Then + expect(difference).toBeUndefined() +}) + +it('accepts frozen structural shells copied into mutable config containers', () => { + // Given + const plugin = () => undefined + const options = Object.freeze({ plugins: Object.freeze([plugin]) }) + // When + const difference = bindingReceipt(options).difference( + bindingReceipt({ plugins: [plugin] }), + ) + // Then + expect(difference).toBeUndefined() +}) diff --git a/packages/next-plugin/src/__tests__/mdx-binding.test.ts b/packages/next-plugin/src/__tests__/mdx-binding.test.ts new file mode 100644 index 000000000..a93d42e43 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-binding.test.ts @@ -0,0 +1,213 @@ +import { expect, it } from 'bun:test' + +import { + captureMdxBindingReceipt, + createMdxBinding, + MdxBindingError, +} from '../mdx-binding' +import { MdxBindingDifference } from '../mdx-binding-value' +import { + bindingInput, + bindingOwner, + bindingReceipt, +} from './mdx-binding-fixture' + +it('accepts the receiving pipeline when owner is unbound', () => { + // Given + const owner = createMdxBinding(bindingOwner) + const receiving = bindingReceipt({ remarkPlugins: [() => undefined] }) + expect(owner.state().kind).toBe('unbound') + // When + const accepted = owner.accept(receiving) + // Then + expect(accepted).toBe(receiving) + expect(owner.state()).toEqual({ kind: 'bound', receipt: receiving }) +}) + +it('accepts structural config copies with the same live plugin leaves', () => { + // Given + const plugin = () => undefined + const object = new URL('https://example.com') + const owner = createMdxBinding(bindingOwner) + const bound = owner.accept( + bindingReceipt({ + remarkPlugins: [ + plugin, + [plugin, { nested: [1, null, true, undefined, object] }], + ], + }), + ) + // When + const accepted = owner.accept( + bindingReceipt({ + remarkPlugins: [ + plugin, + [plugin, { nested: [1, null, true, undefined, object] }], + ], + }), + ) + // Then + expect(accepted).toBe(bound) +}) + +it('rejects a recreated phase closure at the first tuple leaf without publishing', () => { + // Given + const plugin = () => undefined + const owner = createMdxBinding(bindingOwner) + owner.accept(bindingReceipt({ remarkPlugins: [plugin, [plugin, {}]] })) + const before = owner.state() + const other = bindingReceipt({ + remarkPlugins: [plugin, [() => undefined, {}]], + }) + // When + let rejected: unknown + try { + owner.accept(other) + } catch (error) { + if (!(error instanceof MdxBindingError)) throw error + rejected = error + } + // Then + expect(rejected).toBeInstanceOf(MdxBindingError) + if (!(rejected instanceof MdxBindingError)) throw rejected + expect(rejected.owner).toEqual(bindingOwner) + expect(rejected.path).toBe('loaders[0].options.remarkPlugins[1][0]') + expect(rejected.cause).toBeInstanceOf(MdxBindingDifference) + expect(rejected.message).toContain(`${bindingOwner.configFile}:1:1:`) + expect(owner.state()).toBe(before) +}) + +it.each(['resolvedPath', 'packageVersion', 'verifiedFileHash'] as const)( + 'compares native module %s by value', + (key) => { + // Given + const owner = createMdxBinding(bindingOwner) + owner.accept(bindingReceipt({})) + const changed = bindingReceipt({}, { [key]: 'changed' }) + // When + const action = () => owner.accept(changed) + // Then + expect(action).toThrow(MdxBindingError) + expect(bindingReceipt({}).difference(changed)?.path).toBe( + `loaders[0].${key}`, + ) + }, +) + +it.each(['sourceMap', 'layer', 'isServer', 'compilerName'])( + 'retains arbitrary user option %s as semantic', + (key) => { + // Given + const owner = createMdxBinding(bindingOwner) + owner.accept(bindingReceipt({ [key]: 'first' })) + // When + const action = () => owner.accept(bindingReceipt({ [key]: 'second' })) + // Then + expect(action).toThrow(MdxBindingError) + }, +) + +it('accepts client/server delivery differences of one config evaluation', () => { + // Given + const owner = createMdxBinding(bindingOwner) + const original = bindingReceipt({}) + owner.accept(original) + const server = captureMdxBindingReceipt({ + ...bindingInput, + loaders: original.loaders, + delivery: { + sourceMap: true, + layer: 'rsc', + isServer: true, + compilerName: 'server', + }, + }) + // When + const accepted = owner.accept(server) + // Then + expect(accepted).toBe(original) + expect(server.delivery).toEqual({ + sourceMap: true, + layer: 'rsc', + isServer: true, + compilerName: 'server', + }) +}) + +it('keeps original config comparison separate from native shared option mutations', () => { + // Given + const plugin = () => undefined + const shared = { count: 0 } + const tuple: unknown[] = ['named-plugin', shared] + const options = { remarkPlugins: [tuple] } + const receipt = bindingReceipt(options) + const sameOriginal = bindingReceipt({ + remarkPlugins: [['named-plugin', { count: 0 }]], + }) + const owner = createMdxBinding(bindingOwner) + owner.accept(receipt) + // When native execution mutates shared options and wrapper-owned tuple shells. + shared.count += 1 + tuple[0] = plugin + const accepted = owner.accept(sameOriginal) + // Then + expect(accepted).toBe(receipt) + expect(receipt.loaders[0]?.options).toBe(options) + expect(shared.count).toBe(1) + expect(receipt.difference(bindingReceipt(options))?.path).toBe( + 'loaders[0].options.remarkPlugins[0][0]', + ) +}) + +it('does not share bound state between local owner values', () => { + // Given + const first = createMdxBinding(bindingOwner) + first.accept(bindingReceipt({ plugin: () => undefined })) + const second = createMdxBinding(bindingOwner) + const receiving = bindingReceipt({ plugin: () => undefined }) + // When + const accepted = second.accept(receiving) + // Then + expect(accepted).toBe(receiving) +}) + +it('retains native loader order and opaque options for every pre-extraction step', () => { + // Given + const first = bindingReceipt({ raw: true }).loaders[0] + const second = bindingReceipt('?raw', { + resolvedPath: '/native/raw.js', + }).loaders[0] + if (!first || !second) throw new TypeError('missing fixture loaders') + const receipt = captureMdxBindingReceipt({ + ...bindingInput, + loaders: [first, second], + }) + const swapped = captureMdxBindingReceipt({ + ...bindingInput, + loaders: [second, first], + }) + // When + const difference = receipt.difference(swapped) + // Then + expect(difference?.path).toBe('loaders[0].resolvedPath') + expect(receipt.loaders[1]?.options).toBe('?raw') + expect(receipt.owner).toEqual(bindingOwner) + expect(receipt.evaluation).toEqual(bindingInput.evaluation) +}) + +it.each([false, true])( + 'cannot revive a released owner when initially bound=%s', + (bound) => { + // Given + const owner = createMdxBinding(bindingOwner) + const receipt = bindingReceipt({}) + if (bound) owner.accept(receipt) + owner.release() + owner.release() + // When + const action = () => owner.accept(receipt) + // Then + expect(action).toThrow(MdxBindingError) + expect(owner.state()).toEqual({ kind: 'released' }) + }, +) diff --git a/packages/next-plugin/src/__tests__/mdx-boundary-cache.test.ts b/packages/next-plugin/src/__tests__/mdx-boundary-cache.test.ts new file mode 100644 index 000000000..9d6e8c656 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-boundary-cache.test.ts @@ -0,0 +1,128 @@ +import { writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { compileMdx } from '../mdx-prepare' +import { + type MdxPreparationContext, + mdxPrewarmCacheOwner, +} from '../mdx-prewarm-boundary' +import { fixture, type RemarkTree } from './mdx-boundary-fixture.test' + +it('retains cyclic raw options and genuine context when a custom raw loader runs', async () => { + // Given + const request = fixture('@mdx-js/loader') + const observations: unknown[] = [] + const configured: { self?: unknown; observe: (value: unknown) => void } = { + observe: (value) => observations.push(value), + } + configured.self = configured + const raw = join(request.root, 'raw.cjs') + writeFileSync( + raw, + 'module.exports = function() { const options = this.getOptions(); for (const value of [options, options.self, this._compiler, this.mode, this.sourceMap]) options.observe(value); return "# raw identity" }', + ) + const pipeline = { + ...request.pipeline, + loaders: [ + ...request.pipeline.loaders, + { loader: raw, options: configured, ident: 'caller-cyclic-raw' }, + ], + } + // When + const output = await compileMdx({ ...request, pipeline }) + // Then + expect(observations).toEqual([ + configured, + configured, + request.context.compiler, + 'production', + true, + ]) + expect(observations[0]).toBe(configured) + expect(observations[1]).toBe(configured) + expect(observations[2]).toBe(request.context.compiler) + expect(output.source).toContain('raw identity') +}) + +function plugin(text: string) { + return () => (tree: RemarkTree) => { + const node = tree.children[0]?.children[0] + if (node) node.value = text + } +} +it.each([true, false])( + 'isolates colliding function plugins when compiler and resource match and shared owner is %s', + async (sharedOwner) => { + // Given + const a = fixture('@mdx-js/loader', { remarkPlugins: [plugin('app-a')] }) + const b = fixture('@mdx-js/loader', { remarkPlugins: [plugin('app-b')] }) + const first = await compileMdx(a) + // When + const second = await compileMdx({ + ...b, + filename: a.filename, + context: sharedOwner + ? a.context + : { ...b.context, compiler: a.context.compiler }, + }) + // Then + expect(first.source).toContain('app-a') + expect(second.source).toContain('app-b') + }, +) + +it('uses updated live functions when the pipeline generation changes', async () => { + // Given + const options = { remarkPlugins: [plugin('old-generation')] } + const request = fixture('@mdx-js/loader', options) + await compileMdx(request) + options.remarkPlugins[0] = plugin('new-generation') + // When + const output = await compileMdx({ + ...request, + context: { ...request.context, generation: {} }, + }) + // Then + expect(output.source).toContain('new-generation') +}) + +it('retains cache owners only when every semantic scope matches', () => { + // Given + const a = fixture('@mdx-js/loader') + const b = fixture('@mdx-js/loader') + const directory = dirname(a.filename) + const owner = mdxPrewarmCacheOwner(a.context, a.pipeline, directory) + // When / Then + expect(mdxPrewarmCacheOwner(a.context, a.pipeline, directory)).toBe(owner) + for (const context of [ + { ...a.context, owner: {} }, + { ...a.context, generation: {} }, + { ...a.context, compiler: {} }, + { ...a.context, mode: 'development' }, + { ...a.context, sourceMap: false }, + ] satisfies readonly MdxPreparationContext[]) + expect(mdxPrewarmCacheOwner(context, a.pipeline, directory)).not.toBe(owner) + expect(mdxPrewarmCacheOwner(a.context, b.pipeline, directory)).not.toBe(owner) + expect( + mdxPrewarmCacheOwner(a.context, a.pipeline, dirname(b.filename)), + ).not.toBe(owner) +}) + +it.each([true, false])( + 'retains genuine sourceMap %s and development mode in compiler output', + async (sourceMap) => { + // Given + const request = fixture('@mdx-js/loader') + await compileMdx(request) + // When + const output = await compileMdx({ + ...request, + context: { ...request.context, mode: 'development', sourceMap }, + }) + // Then + expect(output.source).toContain('jsx-dev-runtime') + expect(output.map !== undefined).toBe(sourceMap) + }, +) diff --git a/packages/next-plugin/src/__tests__/mdx-boundary-failures.test.ts b/packages/next-plugin/src/__tests__/mdx-boundary-failures.test.ts new file mode 100644 index 000000000..cdc1095f4 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-boundary-failures.test.ts @@ -0,0 +1,111 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { compileMdx, MdxCompileError } from '../mdx-prepare' +import { fixture } from './mdx-boundary-fixture.test' + +const failures = [ + { + name: 'cycle', + options: () => { + const cyclic: { self?: unknown } = {} + cyclic.self = cyclic + return { remarkPlugins: [[() => undefined, cyclic]] } + }, + source: '# original', + line: 1, + }, + { + name: 'processor construction', + options: () => ({ + remarkPlugins: [ + () => { + throw new Error('processor construction') + }, + ], + }), + source: '# original', + line: 1, + }, + { + name: 'throwing plugin', + options: () => ({ + remarkPlugins: [ + () => () => { + throw new Error('transformer failure') + }, + ], + }), + source: '# original', + line: 1, + }, + { + name: 'missing plugin', + options: () => ({ remarkPlugins: ['missing-boundary-plugin'] }), + source: '# original', + line: 1, + }, + { name: 'syntax', options: () => ({}), source: '# title\n\n + failures.map((failure) => ({ compiler, ...failure })), + ), +)( + 'locates $name with $compiler without unhandled rejections', + async ({ compiler, options, source, line }) => { + // Given + const request = fixture(compiler, options()) + writeFileSync(request.filename, source) + const unhandled: unknown[] = [] + const listener = (error: unknown) => { + unhandled.push(error) + } + process.on('unhandledRejection', listener) + try { + // When + const error = await compileMdx(request).catch((cause: unknown) => cause) + // Then + expect(error).toBeInstanceOf(MdxCompileError) + if (!(error instanceof MdxCompileError)) + throw new TypeError('expected compile error') + expect(error.filename).toBe(request.filename) + expect(error.cause).toBeInstanceOf(Error) + expect(error.message).toContain(`${request.filename}:${line}:`) + await new Promise((done) => setImmediate(done)) + expect(unhandled).toEqual([]) + } finally { + process.off('unhandledRejection', listener) + } + }, +) + +it.each([ + { directory: '@next/mdx', entry: 'mdx-js-loader.js', version: '16.3.7' }, + { directory: '@next/mdx', entry: 'mdx-js-loader.js', version: '16.3.6' }, + { directory: '@mdx-js/loader', entry: 'index.cjs', version: '3.1.2' }, + { directory: '@mdx-js/loader', entry: 'index.cjs', version: '3.1.1' }, +])( + 'keeps unknown wrappers original for $directory version $version', + async ({ directory: relative, entry, version }) => { + // Given + const request = fixture('@mdx-js/loader') + const directory = join(request.root, relative) + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'package.json'), JSON.stringify({ version })) + const loader = join(directory, entry) + writeFileSync( + loader, + 'module.exports = function() { return "original unknown wrapper" }', + ) + // When + const output = await compileMdx({ + ...request, + pipeline: { ...request.pipeline, loaders: [{ loader }] }, + }) + // Then + expect(output.source).toBe('original unknown wrapper') + }, +) diff --git a/packages/next-plugin/src/__tests__/mdx-boundary-fixture.test.ts b/packages/next-plugin/src/__tests__/mdx-boundary-fixture.test.ts new file mode 100644 index 000000000..2560dc0d4 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-boundary-fixture.test.ts @@ -0,0 +1,96 @@ +import { + mkdtempSync, + readFile, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { afterEach } from 'bun:test' + +import { + composeMdxRules, + isMdxRecord, + type MdxLoader, + requireMdxPipeline, +} from '../mdx-pipeline' +import { createMdxDeadline } from '../mdx-prepare' + +const landing = resolve(import.meta.dir, '../../../../apps/landing') +const installed = createRequire(join(landing, 'package.json')) +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }) +}) +export function fixture(compiler: string, options: MdxLoader['options'] = {}) { + const root = mkdtempSync(join(tmpdir(), 'devup-mdx-boundary-')) + roots.push(root) + symlinkSync( + join(landing, 'node_modules'), + join(root, 'node_modules'), + 'junction', + ) + const filename = join(root, 'page.mdx') + writeFileSync(filename, '# original') + const composed = composeMdxRules( + { + bundler: 'webpack', + rules: [{ use: [{ loader: installed.resolve(compiler), options }] }], + aliases: {}, + }, + { loader: 'devup' }, + ) + return { + root, + filename, + pipeline: requireMdxPipeline(filename, composed.pipelines[0]), + signal: new AbortController().signal, + deadline: createMdxDeadline(), + context: { + owner: {}, + generation: {}, + compiler: {}, + mode: 'production' as const, + sourceMap: true, + }, + } +} +export async function originalCompile(request: ReturnType) { + const runner: unknown = installed( + 'next/dist/compiled/loader-runner/LoaderRunner.js', + ) + if (!isMdxRecord(runner) || typeof runner.runLoaders !== 'function') + throw new TypeError('invalid installed runner') + const runLoaders = runner.runLoaders + const result = await new Promise((resolveOutput, reject) => + runLoaders( + { + resource: request.filename, + loaders: request.pipeline.loaders, + readResource: readFile, + context: { + sourceMap: request.context.sourceMap, + mode: request.context.mode, + _compiler: {}, + getOptions(this: { query: unknown }) { + return this.query + }, + }, + }, + (error: unknown, result: unknown) => + error ? reject(error) : resolveOutput(result), + ), + ) + if (!isMdxRecord(result) || !Array.isArray(result.result)) + throw new TypeError('invalid actual runner output') + const [source, map] = result.result + if (typeof source !== 'string' && !Buffer.isBuffer(source)) + throw new TypeError('invalid actual source') + return { source: source.toString(), map } +} + +export type RemarkTree = { children: { children: { value: string }[] }[] } diff --git a/packages/next-plugin/src/__tests__/mdx-boundary-parity.test.ts b/packages/next-plugin/src/__tests__/mdx-boundary-parity.test.ts new file mode 100644 index 000000000..815ea2aa9 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-boundary-parity.test.ts @@ -0,0 +1,125 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { compileMdx } from '../mdx-prepare' +import { + fixture, + originalCompile, + type RemarkTree, +} from './mdx-boundary-fixture.test' + +it.each([true, false])( + 'is byte equal to Next with all plugin forms when jsx is %s', + async (jsx) => { + // Given + const request = fixture('@next/mdx/mdx-js-loader') + const named = join(request.root, 'named.cjs') + const tuple = join(request.root, 'tuple.cjs') + writeFileSync( + named, + 'module.exports = function() { return function(tree) { tree.children[0].children[0].value += " named" } }', + ) + writeFileSync( + tuple, + 'module.exports = function(options) { return function(tree) { tree.children[0].children[0].value += options.text } }', + ) + const functionPlugin = () => (tree: RemarkTree) => { + const text = tree.children[0]?.children[0] + if (text) text.value += ' function' + } + const options = { + jsx, + providerImportSource: 'next-mdx-import-source-file', + remarkPlugins: [functionPlugin, named, [tuple, { text: ' tuple' }]], + } + const configured = { + ...request, + pipeline: { + ...request.pipeline, + loaders: request.pipeline.loaders.map((loader) => ({ + ...loader, + options, + })), + }, + } + const nativeOptions = { + ...options, + remarkPlugins: [functionPlugin, named, [tuple, { text: ' tuple' }]], + } + const expected = await originalCompile({ + ...request, + pipeline: { + ...request.pipeline, + loaders: request.pipeline.loaders.map((loader) => ({ + ...loader, + options: nativeOptions, + })), + }, + }) + // When + const output = await compileMdx(configured) + // Then + expect(output.source).toBe(expected.source) + expect(output.map).toEqual(expected.map) + expect(output.source).toContain('function named tuple') + expect(output.source).toContain('next-mdx-import-source-file') + }, +) + +it.each([true, false])( + 'is byte equal to the direct original chain when jsx is %s', + async (jsx) => { + // Given + const plugin = () => (tree: RemarkTree) => { + const text = tree.children[0]?.children[0] + if (text) text.value = 'direct function output' + } + const request = fixture('@mdx-js/loader', { + jsx, + remarkPlugins: [plugin], + providerImportSource: 'provider', + }) + const expected = await originalCompile(request) + // When + const output = await compileMdx(request) + // Then + expect(output.source).toBe(expected.source) + expect(output.map).toEqual(expected.map) + expect(output.source).toContain('direct function output') + }, +) + +it('uses resource-directory named plugins when identical names exist in different folders', async () => { + // Given + const request = fixture('@next/mdx/mdx-js-loader', { + remarkPlugins: ['context-plugin'], + }) + const parent = join(request.root, 'content') + const nested = join(parent, 'nested') + for (const directory of [parent, nested]) { + const module = join(directory, 'node_modules/context-plugin') + mkdirSync(module, { recursive: true }) + writeFileSync(join(module, 'package.json'), '{"main":"index.cjs"}') + writeFileSync( + join(module, 'index.cjs'), + 'module.exports = function() { return function(tree) { tree.children[0].children[0].value = "wrong parent" } }', + ) + } + const plugin = join(nested, 'node_modules/context-plugin/index.cjs') + writeFileSync( + plugin, + 'module.exports = function() { return function(tree) { tree.children[0].children[0].value = "resource-directory" } }', + ) + const filename = join(nested, 'page.mdx') + writeFileSync(filename, '# original') + const configured = { ...request, filename } + const expected = await originalCompile(configured) + // When + const output = await compileMdx(configured) + // Then + expect(output.source).toBe(expected.source) + expect(output.source).toContain('resource-directory') + expect(output.dependencies).toContain(plugin) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-normal-capture.test.ts b/packages/next-plugin/src/__tests__/mdx-normal-capture.test.ts new file mode 100644 index 000000000..9c3f87702 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-normal-capture.test.ts @@ -0,0 +1,82 @@ +import { expect, it } from 'bun:test' + +import { + installMdxNormalCapture, + type MdxNormalSeam, +} from '../mdx-normal-capture' +import { compileMdx } from '../mdx-prepare' +import { fullFixture } from './mdx-own-slot-fixture' + +const seam: MdxNormalSeam = { + ruleKey: 'boundary-unit', + compilerPath: 'compiler', + compilerIndex: 0, + loaderCount: 1, + bridge: false, +} + +it.each( + [ + undefined, + [], + [undefined], + [{}], + [Object.freeze({ normal: undefined })], + ].map((value) => ({ value })), +)( + 'rejects unsupported assignment facts without publishing for $value', + ({ value }) => { + // Given: these are structural boundary negatives, not native-invocation certificates. + const context = {} + installMdxNormalCapture(context, seam) + // When / Then + expect(() => Reflect.set(context, 'loaders', value)).toThrow() + }, +) + +it('rejects a missing callable normal when an assigned loader export has no normal', () => { + // Given + const context = {} + const slot = { normal: undefined } + installMdxNormalCapture(context, seam) + Reflect.set(context, 'loaders', [slot]) + // When / Then + expect(() => Reflect.set(slot, 'normal', undefined)).toThrow() +}) + +it('locates deadline and callback errors even before a runner assigns loader objects', () => { + // Given + const capture = installMdxNormalCapture({}, seam) + // When / Then + expect(capture.timeoutError()).toMatchObject({ loader: 'compiler' }) + expect(() => capture.accept('boundary failure', undefined)).toThrow() + expect(() => capture.accept(null, undefined)).toThrow() +}) + +it('rejects an unknown original compiler pitch that bypasses its normal', async () => { + // Given + const request = fullFixture('return source') + const directory = join(request.root, '@next/mdx') + mkdirSync(directory, { recursive: true }) + const loader = join(directory, 'mdx-js-loader.js') + writeFileSync( + loader, + `module.exports = { default: require(${JSON.stringify(join(import.meta.dir, 'mdx-own-slot-normals.cjs'))}).compiler, pitch: function() { return "bypassed" } }`, + ) + const pipeline = { ...request.pipeline, loaders: [{ loader }] } + // When / Then + await expect( + compileMdx({ + root: request.root, + filename: request.filename, + signal: request.signal, + deadline: request.deadline, + context: request.context, + pipeline, + }), + ).rejects.toMatchObject({ + cause: { fact: 'exactly one compiler execution (pitch bypass)' }, + }) +}) +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' diff --git a/packages/next-plugin/src/__tests__/mdx-options-hash.test.ts b/packages/next-plugin/src/__tests__/mdx-options-hash.test.ts new file mode 100644 index 000000000..f4a61c09c --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-options-hash.test.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { runInNewContext } from 'node:vm' + +import { expect, it } from 'bun:test' + +import { createMdxOptionsInstance } from '../mdx-options-instance' +import { isMdxRecord } from '../mdx-pipeline' +import { compileMdx } from '../mdx-prepare' +import { prepareMdxPlugins } from '../mdx-prepare-plugins' +import { + fixture, + originalCompile, + type RemarkTree, +} from './mdx-boundary-fixture.test' + +const installed = createRequire( + join(resolve(import.meta.dir, '../../../../apps/landing'), 'package.json'), +) +const mdx = installed.resolve('@mdx-js/loader') +const source = readFileSync(join(mdx, '../lib/index.js'), 'utf8') +const hash: unknown = runInNewContext( + `(${source.slice(source.indexOf('function getOptionsHash(')).trim()})`, + { createHash, JSON, Object }, +) +if (typeof hash !== 'function') + throw new TypeError('installed hash unavailable') + +it('keeps the installed processor hash unchanged when approved webpack shells are copied', async () => { + // Given + const named = createRequire(mdx).resolve('remark-parse') + const pluginOptions = { enabled: true } + const options = { + jsx: true, + remarkPlugins: [[named, pluginOptions]], + other: pluginOptions, + } + const request = fixture('@next/mdx/mdx-js-loader', options) + const copied = createMdxOptionsInstance().loadersFor(request.pipeline)[0] + ?.options + if (!isMdxRecord(copied)) throw new TypeError('missing copied options') + const wrapper = installed.resolve('@next/mdx/mdx-js-loader') + const native = await prepareMdxPlugins( + { ...options, remarkPlugins: [[named, pluginOptions]] }, + request.root, + wrapper, + ) + // When + const prepared = await prepareMdxPlugins(copied, request.root, wrapper) + // Then + expect(hash(prepared.options)).toBe(hash(native.options)) + expect(hash(copied)).toBe(hash({ ...copied })) + expect(options.remarkPlugins[0]?.[0]).toBe(named) +}) + +it('reuses the installed processor when another preparation instance changes only container identities', async () => { + // Given + const observations: unknown[] = [] + const options = { text: 'same processor' } + const plugin = (received: typeof options) => { + observations.push(received) + return (tree: RemarkTree) => { + const text = tree.children[0]?.children[0] + if (text) text.value = received.text + } + } + const request = fixture('@next/mdx/mdx-js-loader', { + remarkPlugins: [[plugin, options]], + }) + await compileMdx({ ...request, optionsInstance: createMdxOptionsInstance() }) + // When + const output = await compileMdx({ + ...request, + optionsInstance: createMdxOptionsInstance(), + }) + // Then + expect(observations).toEqual([options]) + expect(observations[0]).toBe(options) + expect(output.source).toContain('same processor') +}) + +it('passes accumulated plugin-option state to the native webpack chain after preparation', async () => { + // Given + const options = { count: 0 } + const plugin = (received: typeof options) => (tree: RemarkTree) => { + received.count += 1 + const text = tree.children[0]?.children[0] + if (text) text.value = `native-visit-${received.count}` + } + const request = fixture('@next/mdx/mdx-js-loader', { + remarkPlugins: [[plugin, options]], + }) + await compileMdx({ ...request, optionsInstance: createMdxOptionsInstance() }) + // When + const output = await originalCompile(request) + // Then + expect(output.source).toContain('native-visit-2') + expect(options.count).toBe(2) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-options-instance.test.ts b/packages/next-plugin/src/__tests__/mdx-options-instance.test.ts new file mode 100644 index 000000000..9ff1403ca --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-options-instance.test.ts @@ -0,0 +1,230 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createMdxOptionsInstance } from '../mdx-options-instance' +import { isMdxRecord, type MdxLoader } from '../mdx-pipeline' +import { compileMdx } from '../mdx-prepare' +import { + fixture, + originalCompile, + type RemarkTree, +} from './mdx-boundary-fixture.test' + +it.each(['webpack', 'turbo'] as const)( + 'keeps config containers and serialized bytes unchanged across files with %s', + async (bundler) => { + // Given + const request = fixture('@next/mdx/mdx-js-loader') + const named = join(request.root, 'named.cjs') + const tuplePlugin = join(request.root, 'tuple.cjs') + writeFileSync( + named, + 'module.exports = function(options) { return function(tree) { tree.children[0].children[0].value += options ? options.text : " named" } }', + ) + writeFileSync( + tuplePlugin, + 'module.exports = function(options) { return function(tree) { tree.children[0].children[0].value += options.text } }', + ) + const pluginOptions = { text: ' tuple', nested: { enabled: true } } + const tuple = [tuplePlugin, pluginOptions] + const array = [named, tuple] + const options = { + jsx: true, + remarkPlugins: array, + rehypePlugins: [], + recmaPlugins: [], + other: { stable: true }, + } + const references = { + rehype: options.rehypePlugins, + recma: options.recmaPlugins, + other: options.other, + nested: pluginOptions.nested, + } + const loader = { + loader: request.pipeline.loaders[0]?.loader ?? '', + options, + } + const config = { + turbopack: { rules: { '*.mdx': { loaders: [loader], as: '*.tsx' } } }, + } + const bytes = JSON.stringify(config) + const snapshot: unknown = JSON.parse(bytes) + const pipeline = { ...request.pipeline, bundler, loaders: [loader] } + const optionsInstance = createMdxOptionsInstance() + const second = join(request.root, 'second.mdx') + writeFileSync(second, '# second') + // When + const firstOutput = await compileMdx({ + ...request, + pipeline, + optionsInstance, + }) + const secondOutput = await compileMdx({ + ...request, + filename: second, + pipeline, + optionsInstance, + }) + // Then + expect(firstOutput.source).toContain('original named tuple') + expect(secondOutput.source).toContain('second named tuple') + expect(snapshot).toEqual(config) + expect(JSON.stringify(config)).toBe(bytes) + expect(config.turbopack.rules['*.mdx'].loaders[0]).toBe(loader) + expect(loader.options).toBe(options) + expect(options.remarkPlugins).toBe(array) + expect(array[1]).toBe(tuple) + expect(tuple[0]).toBe(tuplePlugin) + expect(tuple[1]).toBe(pluginOptions) + expect(options.rehypePlugins).toBe(references.rehype) + expect(options.recmaPlugins).toBe(references.recma) + expect(options.other).toBe(references.other) + expect(pluginOptions.nested).toBe(references.nested) + const instance = optionsInstance.loadersFor(pipeline) + expect(optionsInstance.loadersFor(pipeline)).toBe(instance) + const instanceOptions = instance[0]?.options + if ( + !isMdxRecord(instanceOptions) || + !Array.isArray(instanceOptions.remarkPlugins) + ) + throw new TypeError('missing instance options') + expect(instanceOptions).not.toBe(options) + expect(instanceOptions.remarkPlugins).not.toBe(array) + expect(instanceOptions.remarkPlugins[1]).not.toBe(tuple) + expect(typeof instanceOptions.remarkPlugins[1][0]).toBe('function') + const freshOptions = + createMdxOptionsInstance().loadersFor(pipeline)[0]?.options + if ( + !isMdxRecord(freshOptions) || + !Array.isArray(freshOptions.remarkPlugins) + ) + throw new TypeError('missing fresh options') + expect(freshOptions.remarkPlugins[1][0]).toBe(tuplePlugin) + expect(freshOptions).not.toBe(instanceOptions) + }, +) + +it('copies only webpack compiler shells when other values and raw loader options are shared', () => { + // Given + const callback = () => undefined + const shared = { callback } + const tuple = [callback, shared] + const options = { + remarkPlugins: [callback, tuple], + rehypePlugins: [tuple], + recmaPlugins: [tuple], + other: shared, + callback, + } + const request = fixture('@next/mdx/mdx-js-loader', options) + const raw = { loader: 'raw', options: shared } + const pipeline = { + ...request.pipeline, + loaders: [...request.pipeline.loaders, raw], + } + // When + const loaders = createMdxOptionsInstance().loadersFor(pipeline) + // Then + const got = loaders[0]?.options + if (!isMdxRecord(got)) throw new TypeError('missing compiler options') + expect(got.other).toBe(shared) + expect(got.callback).toBe(callback) + expect(loaders[1]).toBe(raw) + for (const key of ['remarkPlugins', 'rehypePlugins', 'recmaPlugins']) { + const plugins: unknown = got[key] + if (!Array.isArray(plugins)) throw new TypeError('missing plugin array') + const copiedTuple = plugins.find(Array.isArray) + expect(copiedTuple).not.toBe(tuple) + expect(copiedTuple?.[0]).toBe(callback) + expect(copiedTuple?.[1]).toBe(shared) + } +}) + +it('retains plugin-owned option mutations when webpack preparation processes more files', async () => { + // Given + const options = { count: 0 } + const observations: unknown[] = [] + const plugin = (received: typeof options) => { + observations.push(received) + return (tree: RemarkTree) => { + received.count += 1 + const text = tree.children[0]?.children[0] + if (text) text.value = `visit-${received.count}` + } + } + const request = fixture('@next/mdx/mdx-js-loader', { + remarkPlugins: [[plugin, options]], + }) + const optionsInstance = createMdxOptionsInstance() + await compileMdx({ ...request, optionsInstance }) + // When + const output = await compileMdx({ ...request, optionsInstance }) + // Then + expect(output.source).toContain('visit-2') + expect(options.count).toBe(2) + expect(observations.every((value) => value === options)).toBe(true) +}) + +it.each( + (['webpack', 'turbo'] as const).flatMap((bundler) => + [true, false].map((jsx) => ({ bundler, jsx })), + ), +)( + 'is byte equal to the real original chain with strings and tuples for $bundler and JSX $jsx', + async ({ bundler, jsx }) => { + // Given + const request = fixture('@next/mdx/mdx-js-loader') + const named = join(request.root, 'named.cjs') + writeFileSync( + named, + 'module.exports = function(options) { return function(tree) { tree.children[0].children[0].value += options ? options.text : " named" } }', + ) + const tuple = join(request.root, 'tuple.cjs') + writeFileSync( + tuple, + 'module.exports = function(options) { return function(tree) { tree.children[0].children[0].value += options.text } }', + ) + const options = { + jsx, + providerImportSource: 'provider', + remarkPlugins: [named, [tuple, { text: ' tuple' }]], + } + const configured = { + ...request, + pipeline: { + ...request.pipeline, + bundler, + loaders: request.pipeline.loaders.map((loader) => ({ + ...loader, + options, + })), + }, + } + const nativeOptions: unknown = JSON.parse(JSON.stringify(options)) + if (!isMdxRecord(nativeOptions)) + throw new TypeError('invalid native options') + const expected = await originalCompile({ + ...request, + pipeline: { + ...request.pipeline, + loaders: request.pipeline.loaders.map((loader): MdxLoader => ({ + ...loader, + options: nativeOptions, + })), + }, + }) + // When + const output = await compileMdx({ + ...configured, + optionsInstance: createMdxOptionsInstance(), + }) + // Then + expect(output.source).toBe(expected.source) + expect(output.map).toEqual(expected.map) + expect(output.source).toContain('original named tuple') + expect(options.remarkPlugins[1]).toEqual([tuple, { text: ' tuple' }]) + }, +) diff --git a/packages/next-plugin/src/__tests__/mdx-options-turbo.test.ts b/packages/next-plugin/src/__tests__/mdx-options-turbo.test.ts new file mode 100644 index 000000000..cb1dbb71c --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-options-turbo.test.ts @@ -0,0 +1,156 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { runInNewContext } from 'node:vm' + +import { expect, it } from 'bun:test' + +import { + createMdxOptionsInstance, + MdxTurboOptionsError, +} from '../mdx-options-instance' +import { isMdxRecord, type MdxLoader } from '../mdx-pipeline' +import { compileMdx, MdxCompileError } from '../mdx-prepare' +import { fixture } from './mdx-boundary-fixture.test' + +const installed = createRequire( + join(resolve(import.meta.dir, '../../../../apps/landing'), 'package.json'), +) +const source = readFileSync(installed.resolve('next/dist/build/swc'), 'utf8') +const start = source.indexOf('function serializeTurbopackRules(') +const end = source.indexOf('function napiEntrypointsToRawEntrypoints(', start) +const nativeSerialize: unknown = runInNewContext( + `(${source.slice(start, end).trim()})`, + { require: installed, JSON, Error }, +) +if (typeof nativeSerialize !== 'function') + throw new TypeError('installed serializer unavailable') + +const nonJson = [ + { label: 'function', value: () => undefined }, + { label: 'undefined', value: undefined }, + { label: 'symbol', value: Symbol('option') }, + { label: 'nan', value: NaN }, + { label: 'infinity', value: Infinity }, + { label: 'negative zero', value: -0 }, + { label: 'date', value: new Date(0) }, + { label: 'regexp', value: /option/ }, + { label: 'map', value: new Map([['key', 'value']]) }, + { label: 'set', value: new Set(['value']) }, + { label: 'bigint', value: 1n }, + { label: 'sparse array', value: Array(1) }, +] as const + +it.each([...nonJson])( + 'rejects $label just as installed Next serialization does', + async ({ value }) => { + // Given + const request = fixture('@next/mdx/mdx-js-loader', { value }) + const pipeline = { ...request.pipeline, bundler: 'turbo' as const } + // When + const failure = await compileMdx({ ...request, pipeline }).catch( + (cause: unknown) => cause, + ) + let nativeFailure: unknown + try { + nativeSerialize({ [pipeline.ruleKey]: { loaders: pipeline.loaders } }) + } catch (cause) { + if (!(cause instanceof Error)) throw cause + nativeFailure = cause + } + // Then + expect(failure).toBeInstanceOf(MdxCompileError) + if ( + !(failure instanceof MdxCompileError) || + !isMdxRecord(nativeFailure) || + typeof nativeFailure.message !== 'string' + ) + throw new TypeError('expected located native rejection') + expect(failure.filename).toBe(request.filename) + expect(failure.cause instanceof Error ? failure.cause.message : '').toBe( + nativeFailure.message, + ) + expect(request.pipeline.loaders[0]?.options).toEqual({ value }) + }, +) + +it('rejects cycles with the same serialization cause before invoking a Turbo wrapper', async () => { + // Given + const cycle: { self?: unknown } = {} + cycle.self = cycle + const request = fixture('@next/mdx/mdx-js-loader', cycle) + // When + const error = await compileMdx({ + ...request, + pipeline: { ...request.pipeline, bundler: 'turbo' }, + }).catch((cause: unknown) => cause) + // Then + expect(error).toBeInstanceOf(MdxCompileError) + if (!(error instanceof MdxCompileError)) + throw new TypeError('expected located failure') + expect(error.cause).toBeInstanceOf(TypeError) + expect(cycle.self).toBe(cycle) +}) + +it('breaks shared JSON aliases in the worker instance while keeping the caller identities', () => { + // Given + const shared = { enabled: true } + const options = { z: shared, a: shared, values: [1, null, 'text', false] } + const request = fixture('@next/mdx/mdx-js-loader', options) + const pipeline = { ...request.pipeline, bundler: 'turbo' as const } + const nativeRules: unknown = nativeSerialize({ + [pipeline.ruleKey]: { loaders: pipeline.loaders }, + }) + const nativeWire: unknown = JSON.parse(JSON.stringify(nativeRules)) + // When + const instance = createMdxOptionsInstance().loadersFor(pipeline)[0]?.options + // Then + if (!isMdxRecord(instance) || !isMdxRecord(nativeWire)) + throw new TypeError('missing wire options') + expect(instance).toEqual(options) + expect(instance.z).not.toBe(instance.a) + expect(instance.z).not.toBe(shared) + expect(options.z).toBe(shared) + expect(options.a).toBe(shared) + expect(Object.keys(instance)).toEqual(['z', 'a', 'values']) + expect(nativeWire[pipeline.ruleKey]).toEqual({ + loaders: [{ loader: pipeline.loaders[0]?.loader, options: instance }], + }) +}) + +it('uses the native empty options map when a Turbo loader has no configured options', () => { + // Given + const request = fixture('@next/mdx/mdx-js-loader') + const loader = { loader: request.pipeline.loaders[0]?.loader ?? '' } + const pipeline = { + ...request.pipeline, + bundler: 'turbo' as const, + loaders: [loader], + } + // When + const instance = createMdxOptionsInstance().loadersFor(pipeline) + // Then + expect(instance).toEqual([{ ...loader, options: {} }]) + expect(loader).toEqual({ loader: loader.loader }) +}) + +it('rejects string option maps at the native Turbo boundary rather than using webpack query parsing', () => { + // Given + const request = fixture('@next/mdx/mdx-js-loader') + const loader: MdxLoader = { + loader: request.pipeline.loaders[0]?.loader ?? '', + options: 'jsx=true', + } + const pipeline = { + ...request.pipeline, + bundler: 'turbo' as const, + loaders: [loader], + } + // When / Then + expect(() => createMdxOptionsInstance().loadersFor(pipeline)).toThrow( + MdxTurboOptionsError, + ) + expect(nativeSerialize({ '*': { loaders: [loader] } })).toEqual({ + '*': { loaders: [loader] }, + }) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-options-unknown.test.ts b/packages/next-plugin/src/__tests__/mdx-options-unknown.test.ts new file mode 100644 index 000000000..dcc39ce3b --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-options-unknown.test.ts @@ -0,0 +1,49 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createMdxOptionsInstance } from '../mdx-options-instance' +import { compileMdx } from '../mdx-prepare' +import { fixture } from './mdx-boundary-fixture.test' + +it.each(['16.3.6', '16.3.7'])( + 'detaches mutable shells even when an original wrapper is unrecognized at version %s', + async (version) => { + // Given + const request = fixture('@next/mdx/mdx-js-loader') + const directory = join(request.root, '@next/mdx') + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'package.json'), JSON.stringify({ version })) + const loader = join(directory, 'mdx-js-loader.js') + writeFileSync( + loader, + 'module.exports = function() { const options = this.getOptions(); options.remarkPlugins[0][0] = "resolved"; options.remarkPlugins.push("added"); options.other.count++; return "original wrapper " + options.other.count }', + ) + const shared = { count: 0 } + const tuple = ['configured', shared] + const plugins = [tuple] + const options = { remarkPlugins: plugins, other: shared } + const pipeline = { ...request.pipeline, loaders: [{ loader, options }] } + // When + const output = await compileMdx({ ...request, pipeline }) + // Then + expect(output.source).toBe('original wrapper 1') + expect(options.remarkPlugins).toBe(plugins) + expect(plugins).toEqual([['configured', shared]]) + expect(plugins[0]).toBe(tuple) + expect(shared.count).toBe(1) + expect(options.other).toBe(shared) + }, +) + +it('keeps malformed mapped values and unrelated properties unchanged without adding defaults', () => { + // Given + const options = { remarkPlugins: 'malformed', other: {} } + const request = fixture('@next/mdx/mdx-js-loader', options) + // When + const instance = createMdxOptionsInstance().loadersFor(request.pipeline) + // Then + expect(instance[0]?.options).toEqual(options) + expect(instance[0]?.options).not.toBe(options) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-own-slot-cancellation.test.ts b/packages/next-plugin/src/__tests__/mdx-own-slot-cancellation.test.ts new file mode 100644 index 000000000..1c0884920 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-own-slot-cancellation.test.ts @@ -0,0 +1,83 @@ +import { expect, it } from 'bun:test' + +import { compileMdx, createMdxDeadline, MdxCompileError } from '../mdx-prepare' +import { fullFixture } from './mdx-own-slot-fixture' + +it('publishes nothing when an async full invocation exceeds its bounded deadline', async () => { + // Given + const request = fullFixture('this.async()') + // When / Then + await expect( + compileMdx({ ...request, deadline: createMdxDeadline(30_000, 20) }), + ).rejects.toMatchObject({ filename: request.filename }) +}) + +it('ignores late full-invocation callbacks when caller cancellation wins', async () => { + // Given + const started = + Promise.withResolvers<(error: unknown, source: unknown) => void>() + const controller = new AbortController() + const request = fullFixture('this.getOptions().started(this.async())') + const [own, compiler, raw] = request.invocation.loaders + if (!own || !compiler || !raw) throw new TypeError('missing original slots') + const invocation = { + ...request.invocation, + loaders: [own, compiler, { ...raw, options: { started: started.resolve } }], + } + const pending = compileMdx({ + ...request, + invocation, + signal: controller.signal, + }) + const callback = await started.promise + // When + controller.abort(new Error('cancelled actual invocation')) + callback(null, Buffer.from('# late callback')) + // Then + await expect(pending).rejects.toMatchObject({ filename: request.filename }) +}) + +it('publishes nothing when cancellation occurs after the private stop but before result construction', async () => { + // Given + const controller = new AbortController() + const request = fullFixture( + 'const abort = this.getOptions().abort; this.callback(null, source, map); abort()', + ) + const [own, compiler, raw] = request.invocation.loaders + if (!own || !compiler || !raw) throw new TypeError('missing original slots') + const reason = new Error('cancelled before publication') + const invocation = { + ...request.invocation, + compilerIndex: 2, + loaders: [ + own, + { ...raw, options: { abort: () => controller.abort(reason) } }, + compiler, + ], + } + // When + const error = await compileMdx({ + ...request, + invocation, + signal: controller.signal, + }).catch((cause: unknown) => cause) + // Then + if (!(error instanceof MdxCompileError)) + throw new TypeError('expected cancellation failure') + expect(error.cause).toBe(reason) +}) + +it('keeps the cancellation cause when a full invocation is already aborted', async () => { + // Given + const request = fullFixture('throw new Error("must not run")') + const reason = new Error('pre-aborted full invocation') + // When + const error = await compileMdx({ + ...request, + signal: AbortSignal.abort(reason), + }).catch((cause: unknown) => cause) + // Then + if (!(error instanceof MdxCompileError)) + throw new TypeError('expected cancellation failure') + expect(error.cause).toBe(reason) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-own-slot-control.ts b/packages/next-plugin/src/__tests__/mdx-own-slot-control.ts new file mode 100644 index 000000000..ae000c2da --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-own-slot-control.ts @@ -0,0 +1,67 @@ +import { strict as assert } from 'node:assert' +import { readFile } from 'node:fs' + +import type { MdxInvocation } from '../mdx-invocation' +import { isMdxRecord } from '../mdx-pipeline' +import type { RunLoaders } from '../mdx-prepare-runner' + +type Normal = (this: unknown, ...args: readonly unknown[]) => unknown +function isNormal(value: unknown): value is Normal { + return typeof value === 'function' +} + +export async function nativeOwnControl( + invocation: MdxInvocation, + runner: { + readonly resource: string + readonly context: object + readonly runLoaders: RunLoaders + }, + ownNormal: Normal, +) { + let assigned: unknown + let nativeInput: readonly unknown[] | undefined + let compiles = 0 + Object.defineProperty(runner.context, 'loaders', { + enumerable: true, + configurable: true, + get: () => assigned, + set(value: unknown) { + assert.ok(Array.isArray(value)) + assigned = value + for (const index of [invocation.ownIndex, invocation.compilerIndex]) { + const slot: unknown = value[index] + assert.ok(isMdxRecord(slot)) + let normal: unknown + Object.defineProperty(slot, 'normal', { + enumerable: true, + configurable: true, + get: () => normal, + set(original: unknown) { + assert.ok(isNormal(original)) + if (index === invocation.ownIndex) assert.equal(original, ownNormal) + normal = function (this: unknown, ...args: readonly unknown[]) { + if (index === invocation.ownIndex) nativeInput = args + else compiles++ + return Reflect.apply(original, this, args) + } + }, + }) + } + }, + }) + await new Promise((done, reject) => + runner.runLoaders( + { + resource: runner.resource, + loaders: invocation.loaders, + context: runner.context, + readResource: readFile, + }, + (error) => (error ? reject(error) : done()), + ), + ) + assert.equal(compiles, 1) + assert.ok(nativeInput) + return nativeInput +} diff --git a/packages/next-plugin/src/__tests__/mdx-own-slot-errors.test.ts b/packages/next-plugin/src/__tests__/mdx-own-slot-errors.test.ts new file mode 100644 index 000000000..b8b469106 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-own-slot-errors.test.ts @@ -0,0 +1,182 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { compileMdx, MdxCompileError } from '../mdx-prepare' +import { fullFixture } from './mdx-own-slot-fixture' + +it.each([ + { + body: 'if (!this._compilation) throw new Error("missing native _compilation"); return source', + pitch: '', + }, + { body: 'throw new Error("MDX invocation-private capture stop")', pitch: '' }, +])( + 'publishes nothing when an actual raw loader bypasses or rejects ($body)', + async ({ body, pitch }) => { + // Given + const request = fullFixture(body, pitch) + // When / Then + await expect(compileMdx(request)).rejects.toBeInstanceOf(MdxCompileError) + }, +) + +it('rejects compiler bypass when an intermediate pitch reaches the own slot without compilation', async () => { + // Given + const request = fullFixture('return source', 'return "# pitch bypass"') + const [own, compiler, raw] = request.invocation.loaders + if (!own || !compiler || !raw) throw new TypeError('missing original slots') + const invocation = { + ...request.invocation, + compilerIndex: 2, + loaders: [own, raw, compiler], + } + // When / Then + await expect(compileMdx({ ...request, invocation })).rejects.toMatchObject({ + cause: { fact: 'exactly one compiler before Devup normal capture' }, + }) +}) + +it('rejects at the original runner callback when a downstream pitch bypasses Devup capture', async () => { + // Given + const request = fullFixture('return source') + const downstream = join(request.root, 'downstream.cjs') + writeFileSync( + downstream, + `module.exports = { default: require(${JSON.stringify(join(import.meta.dir, 'mdx-own-slot-normals.cjs'))}).default, pitch: function() { return "bypass" } }`, + ) + const invocation = { + ...request.invocation, + loaders: [downstream, ...request.invocation.loaders], + ownIndex: 1, + compilerIndex: 2, + } + // When / Then + await expect(compileMdx({ ...request, invocation })).rejects.toMatchObject({ + filename: request.filename, + cause: { + fact: 'private-stop runner callback after Devup capture (pitch bypass)', + }, + }) +}) + +it.each([ + { ownIndex: -1, compilerIndex: 1 }, + { ownIndex: 1, compilerIndex: 0 }, + { ownIndex: 0, compilerIndex: 0.5 }, + { ownIndex: 0, compilerIndex: 99 }, +])( + 'rejects missing actual index facts for $ownIndex / $compilerIndex', + async (indices) => { + // Given + const request = fullFixture('return source') + // When / Then + await expect( + compileMdx({ + ...request, + invocation: { ...request.invocation, ...indices }, + }), + ).rejects.toMatchObject({ + cause: { fact: 'original compiler/own slot facts' }, + }) + }, +) + +it.each(['raw-unresolved', 'second-compiler'])( + 'rejects incomplete original loader facts for %s', + async (extra) => { + // Given + const request = fullFixture('return source') + const compiler = request.invocation.loaders[1] + if (!compiler) throw new TypeError('missing installed compiler') + const invocation = { + ...request.invocation, + loaders: [ + ...request.invocation.loaders, + extra === 'raw-unresolved' ? 'raw' : compiler, + ], + } + // When / Then + await expect(compileMdx({ ...request, invocation })).rejects.toBeInstanceOf( + MdxCompileError, + ) + }, +) + +it('refuses direct calls into the own normal when the runner has not reached its original slot', async () => { + // Given + const request = fullFixture('this.loaders[0].normal.call(this, source, map)') + const [own, compiler, raw] = request.invocation.loaders + if (!own || !compiler || !raw) throw new TypeError('missing original slots') + // When / Then + await expect( + compileMdx({ + ...request, + invocation: { + ...request.invocation, + compilerIndex: 2, + loaders: [own, raw, compiler], + }, + }), + ).rejects.toMatchObject({ + cause: { fact: 'original runner normal position 0' }, + }) +}) + +it.each(['.md', '.mdown'])( + 'keeps original %s resources and JSX output without renaming', + async (extension) => { + // Given + const request = fullFixture('return source') + const filename = join(request.root, 'unchanged' + extension) + writeFileSync(filename, '# original') + // When + const output = await compileMdx({ + ...request, + filename, + invocation: { ...request.invocation, resource: filename }, + }) + // Then + expect(output.filename).toBe(filename) + expect(output.source).toContain('<_components.h1>') + }, +) + +it('rejects a full invocation whose actual resource belongs to another source', async () => { + // Given + const request = fullFixture('return source') + const invocation = { + ...request.invocation, + resource: request.filename + '.foreign?original=query#fragment', + } + // When / Then + await expect(compileMdx({ ...request, invocation })).rejects.toMatchObject({ + cause: { fact: 'original resource path for the prepared source' }, + }) +}) + +it.each([ + 'this.loaderIndex = 2; this.loaders[2].normal.call(this, source, map)', + 'this.loaderIndex = 0; this.loaders[0].normalExecuted = true; try { this.loaders[0].normal.call(this, source, map) } catch (error) { throw new Error(error.message) }', + 'this.loaderIndex = 0; this.loaders[0].normalExecuted = true; try { this.loaders[0].normal.call(this, source, map) } catch (error) { if (!(error instanceof Error)) throw error } this.loaders[0].normal.call(this, source, map)', +])( + 'rejects extra executions or a forged stop callback even when a raw loader manipulates execution flags (%s)', + async (body) => { + // Given + const request = fullFixture(body) + const [own, compiler, raw] = request.invocation.loaders + if (!own || !compiler || !raw) throw new TypeError('missing original slots') + // When / Then + await expect( + compileMdx({ + ...request, + invocation: { + ...request.invocation, + compilerIndex: 2, + loaders: [own, raw, compiler], + }, + }), + ).rejects.toBeInstanceOf(MdxCompileError) + }, +) diff --git a/packages/next-plugin/src/__tests__/mdx-own-slot-fixture.ts b/packages/next-plugin/src/__tests__/mdx-own-slot-fixture.ts new file mode 100644 index 000000000..e4731609e --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-own-slot-fixture.ts @@ -0,0 +1,29 @@ +import { writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { fixture } from './mdx-boundary-fixture.test' + +export function fullFixture(body: string, pitch = '') { + const request = fixture('@mdx-js/loader', { jsx: true }) + const compiler = request.pipeline.loaders[0] + if (!compiler) throw new TypeError('missing installed compiler') + const raw = join(request.root, 'raw.cjs') + const normal = + body === 'return source' + ? `require(${JSON.stringify(resolve(import.meta.dir, 'mdx-own-slot-normals.cjs'))}).default` + : `function(source, map) { ${body} }` + writeFileSync( + raw, + `module.exports = { default: ${normal}, raw: true, pitch: function() { ${pitch} } }`, + ) + const own = { loader: resolve(import.meta.dir, '../../dist/loader.cjs') } + return { + ...request, + invocation: { + resource: request.filename, + ownIndex: 0, + compilerIndex: 1, + loaders: [own, compiler, { loader: raw }] as const, + }, + } +} diff --git a/packages/next-plugin/src/__tests__/mdx-own-slot-forms.test.ts b/packages/next-plugin/src/__tests__/mdx-own-slot-forms.test.ts new file mode 100644 index 000000000..eb907c6dc --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-own-slot-forms.test.ts @@ -0,0 +1,161 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { expect, it } from 'bun:test' + +import type { MdxInvocationLoader } from '../mdx-invocation' +import { + createMdxOptionsInstance, + MdxTurboOptionsError, +} from '../mdx-options-instance' +import { isMdxRecord } from '../mdx-pipeline' +import { compileMdx } from '../mdx-prepare' +import { fullFixture } from './mdx-own-slot-fixture' + +it.each(['string-request', 'module-string-options', 'caller-options-ident'])( + 'retains native descriptor form %s through full capture', + async (form) => { + // Given + const request = fullFixture('return source') + const raw = join(request.root, 'form.mjs') + writeFileSync( + raw, + `export const observations = []; export const raw = true; export default function(source, map) { + this.callback(null, source, map) + }; export function pitch() { + observations.push(this.loaders[this.loaderIndex].request) + }`, + ) + const rawModule: unknown = await import(pathToFileURL(raw).href) + if (!isMdxRecord(rawModule) || !Array.isArray(rawModule.observations)) + throw new TypeError('missing module observations') + const descriptor: MdxInvocationLoader = + form === 'string-request' + ? raw + '?caller=value#fragment' + : { + loader: raw, + type: 'module', + fragment: '#fragment', + options: + form === 'module-string-options' + ? 'caller=value' + : { caller: 'value', ident: 'caller-ident' }, + } + const own = request.invocation.loaders[0] + const compiler = request.invocation.loaders[1] + if (!own || !compiler) throw new TypeError('missing original slots') + // When + const output = await compileMdx({ + ...request, + invocation: { + ...request.invocation, + loaders: [own, compiler, descriptor], + }, + }) + // Then + expect(output.source).toContain('original') + expect(rawModule.observations).toEqual([ + raw + + (form === 'caller-options-ident' ? '??caller-ident' : '?caller=value') + + '#fragment', + ]) + }, +) + +it('applies exact Turbo serialization when the same invocation is reused under another pipeline', () => { + // Given + const request = fullFixture('return source') + const [own, compiler, raw] = request.invocation.loaders + if (!own || !compiler || !raw) throw new TypeError('missing original slots') + const callback = () => undefined + const invocation = { + ...request.invocation, + loaders: [own, compiler, { ...raw, options: { callback } }], + } + const instance = createMdxOptionsInstance() + instance.invocationLoadersFor(request.pipeline, invocation) + // When / Then + expect(() => + instance.invocationLoadersFor( + { ...request.pipeline, bundler: 'turbo' }, + invocation, + ), + ).toThrow(MdxTurboOptionsError) +}) + +it('preserves full Turbo string requests and descriptor metadata while applying native JSON options', () => { + // Given + const request = fullFixture('return source') + const [own, compiler, raw] = request.invocation.loaders + if (!own || !compiler || !raw) throw new TypeError('missing original slots') + const invocation = { + ...request.invocation, + loaders: [ + own, + { ...compiler, ident: 'caller', fragment: '#fragment', type: 'commonjs' }, + raw.loader + '?original=query#raw', + ], + } + // When + const loaders = createMdxOptionsInstance().invocationLoadersFor( + { ...request.pipeline, bundler: 'turbo' }, + invocation, + ) + // Then + expect(loaders[1]).toEqual(invocation.loaders[1]) + expect(loaders[2]).toBe(invocation.loaders[2]) +}) + +it.each(['webpack', 'turbo'] as const)( + 'reuses only run-local full-invocation options for %s', + (bundler) => { + // Given + const request = fullFixture('return source') + const pipeline = { ...request.pipeline, bundler } + const instance = createMdxOptionsInstance() + // When + const first = instance.invocationLoadersFor(pipeline, request.invocation) + // Then + expect(instance.invocationLoadersFor(pipeline, request.invocation)).toBe( + first, + ) + expect( + createMdxOptionsInstance().invocationLoadersFor( + pipeline, + request.invocation, + ), + ).not.toBe(first) + }, +) + +it('detaches a compiler options root once per run while preserving each original descriptor shell', () => { + // Given + const request = fullFixture('return source') + const compiler = request.invocation.loaders[1] + if (!compiler) throw new TypeError('missing original compiler') + const invocation = { + ...request.invocation, + loaders: request.invocation.loaders.map((loader, index) => + index === 1 ? { ...loader, fragment: '#resource-local' } : loader, + ), + } + const instance = createMdxOptionsInstance() + const first = instance.invocationLoadersFor( + request.pipeline, + request.invocation, + )[1] + // When + const second = instance.invocationLoadersFor(request.pipeline, invocation)[1] + // Then + if ( + !first || + typeof first === 'string' || + !second || + typeof second === 'string' + ) + throw new TypeError('missing compiler option shells') + expect(first.options).toBe(second.options) + expect(first.options).not.toBe(compiler.options) + expect(second.fragment).toBe('#resource-local') +}) diff --git a/packages/next-plugin/src/__tests__/mdx-own-slot-native-controls.test.ts b/packages/next-plugin/src/__tests__/mdx-own-slot-native-controls.test.ts new file mode 100644 index 000000000..b98f89695 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-own-slot-native-controls.test.ts @@ -0,0 +1,49 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { fixture, originalCompile } from './mdx-boundary-fixture.test' + +it.each([ + { normal: 'downstream', message: 'downstream normal executed' }, + { normal: 'compiler', message: 'must not run' }, +])( + 'fails at the $normal guard when the native normal chain reaches it', + async ({ normal, message }) => { + // Given: an original installed compiler followed by the same guarded normal. + const request = fixture('@mdx-js/loader', { jsx: true }) + const guard = join(request.root, 'guard.cjs') + writeFileSync( + guard, + `module.exports = require(${JSON.stringify(join(import.meta.dir, 'mdx-own-slot-normals.cjs'))})[${JSON.stringify(normal)}]`, + ) + // When / Then: the real runner must propagate the guard's failure, not publish bytes. + await expect( + originalCompile({ + ...request, + pipeline: { + ...request.pipeline, + loaders: [{ loader: guard }, ...request.pipeline.loaders], + }, + }), + ).rejects.toThrow(message) + }, +) + +it('preserves raw resource bytes when the native runner executes the shared pass-through normal', async () => { + // Given + const request = fixture('@mdx-js/loader') + const raw = join(request.root, 'raw.cjs') + writeFileSync( + raw, + `module.exports = { default: require(${JSON.stringify(join(import.meta.dir, 'mdx-own-slot-normals.cjs'))}).default, raw: true }`, + ) + // When + const output = await originalCompile({ + ...request, + pipeline: { ...request.pipeline, loaders: [{ loader: raw }] }, + }) + // Then + expect(output).toEqual({ source: '# original', map: undefined }) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-own-slot-normals.cjs b/packages/next-plugin/src/__tests__/mdx-own-slot-normals.cjs new file mode 100644 index 000000000..e20c66ecc --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-own-slot-normals.cjs @@ -0,0 +1,9 @@ +exports.default = function (source) { + return source +} +exports.downstream = function () { + throw new Error('downstream normal executed') +} +exports.compiler = function () { + throw new Error('must not run') +} diff --git a/packages/next-plugin/src/__tests__/mdx-own-slot-parity.test.ts b/packages/next-plugin/src/__tests__/mdx-own-slot-parity.test.ts new file mode 100644 index 000000000..3ecef6b39 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-own-slot-parity.test.ts @@ -0,0 +1,15 @@ +import { resolve } from 'node:path' + +import { it } from 'bun:test' + +import { ownSlotParity } from './mdx-own-slot-parity' + +it.each( + ['@next/mdx/mdx-js-loader', '@mdx-js/loader'].flatMap((compiler) => + [false, true].map((sourceMap) => ({ compiler, sourceMap })), + ), +)( + 'matches the actual Devup normal entry with $compiler and maps $sourceMap', + async ({ compiler, sourceMap }) => + ownSlotParity(resolve(import.meta.dir, '../../../..'), compiler, sourceMap), +) diff --git a/packages/next-plugin/src/__tests__/mdx-own-slot-parity.ts b/packages/next-plugin/src/__tests__/mdx-own-slot-parity.ts new file mode 100644 index 000000000..0794df165 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-own-slot-parity.ts @@ -0,0 +1,223 @@ +import { strict as assert } from 'node:assert' +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { createMdxOptionsInstance } from '../mdx-options-instance' +import { isMdxRecord, type MdxPipeline } from '../mdx-pipeline' +import { compileMdx, createMdxDeadline } from '../mdx-prepare' +import { isRunLoaders } from '../mdx-prepare-runner' +import { nativeOwnControl } from './mdx-own-slot-control' + +type Normal = (this: unknown, ...args: readonly unknown[]) => unknown +function isNormal(value: unknown): value is Normal { + return typeof value === 'function' +} + +export async function ownSlotParity( + workspace: string, + compilerName: string, + sourceMap: boolean, +) { + const installed = createRequire(join(workspace, 'apps/landing/package.json')) + const pluginRequire = createRequire( + join(workspace, 'packages/next-plugin/package.json'), + ) + const runner: unknown = installed( + 'next/dist/compiled/loader-runner/LoaderRunner.js', + ) + if (!isMdxRecord(runner) || !isRunLoaders(runner.runLoaders)) + throw new TypeError('missing installed runner') + const runLoaders = runner.runLoaders + const root = mkdtempSync(join(tmpdir(), 'devup-own-slot-parity-')) + try { + symlinkSync( + join(workspace, 'apps/landing/node_modules'), + join(root, 'node_modules'), + 'junction', + ) + const filename = join(root, 'original.mdown') + writeFileSync(filename, '# original') + const events: unknown[] = [] + const rawOptions: { self?: unknown; observe: (value: unknown) => void } = { + observe: (value) => events.push(value), + } + rawOptions.self = rawOptions + const raw = join(root, 'raw.cjs') + writeFileSync( + raw, + `function observe(c, phase, source) { + c.getOptions().observe({phase, buffer: Buffer.isBuffer(source), index: c.loaderIndex, + normal: c.loaders[c.loaderIndex].normal, pitchFunction: c.loaders[c.loaderIndex].pitch, + rootContext: c.rootContext, context: c.context, resourcePath: c.resourcePath, sourceMap: c.sourceMap, mode: c.mode, compiler: c._compiler, + request: c.request, current: c.currentRequest, previous: c.previousRequest, remaining: c.remainingRequest, + resource: c.resource, query: c.query, data: c.data, + loaders: c.loaders.map(x => ({path:x.path, query:x.query, fragment:x.fragment, options:x.options, + ident:x.ident, type:x.type, request:x.request, raw:x.raw, pitch:x.pitchExecuted, normal:x.normalExecuted}))}) + } + module.exports = function(source, map) { + observe(this, 'normal', source); const done = this.async() + this.addDependency(this.resourcePath + '.raw') + queueMicrotask(() => done(null, source, map)) + }; module.exports.raw = true + module.exports.pitch = function(remaining, previous, data) { + if(remaining !== this.remainingRequest || previous !== this.previousRequest || data !== this.data) throw new Error('pitch divergence') + observe(this, 'pitch') + }`, + ) + const downstream = join(root, 'downstream.cjs') + writeFileSync( + downstream, + 'module.exports = function(source, map) { this.getOptions().observe("downstream-normal"); this.callback(null, source, map) }; module.exports.pitch = function() { this.getOptions().observe("downstream-pitch") }', + ) + const own = join(workspace, 'packages/next-plugin/dist/loader.cjs') + const ownModule: unknown = pluginRequire(own) + if (!isMdxRecord(ownModule) || !isNormal(ownModule.default)) + throw new TypeError('missing actual Devup entry') + const nativeOwn = ownModule.default + const wasm: unknown = pluginRequire('@devup-ui/wasm') + const state = (key: string): unknown => { + if (!isMdxRecord(wasm) || !isNormal(wasm[key])) + throw new TypeError('missing WASM state export') + const json: unknown = Reflect.apply(wasm[key], wasm, []) + if (typeof json !== 'string') throw new TypeError('invalid WASM state') + return JSON.parse(json) + } + const ownOptions = { + package: '@devup-ui/react', + cssDir: root, + sheetFile: join(root, 'sheet.json'), + classMapFile: join(root, 'classes.json'), + fileMapFile: join(root, 'files.json'), + themeFile: join(root, 'theme.json'), + watch: false, + singleCss: true, + projectRoot: workspace, + theme: {}, + sourceType: 'compiled-mdx', + defaultSheet: state('exportSheet'), + defaultClassMap: state('exportClassMap'), + defaultFileMap: state('exportFileMap'), + } + const plugin = join(root, 'remark.cjs') + writeFileSync( + plugin, + 'module.exports = function(options) { return function(tree) { options.observe(); tree.children[0].children[0].value = options.text } }', + ) + const leaf = Object.freeze({ + text: 'configured tuple', + observe: () => events.push('compiler-normal'), + }) + const pluginFunction: unknown = installed(plugin) + assert.ok(isNormal(pluginFunction)) + const pluginEntry = + compilerName === '@mdx-js/loader' ? pluginFunction : plugin + const tuple = Object.freeze([pluginEntry, leaf]) + const plugins = Object.freeze([tuple]) + const compiler = Object.freeze({ + loader: installed.resolve(compilerName), + options: Object.freeze({ jsx: true, remarkPlugins: plugins }), + fragment: '#compiler', + type: 'commonjs', + }) + const pipeline: MdxPipeline = { + bundler: 'webpack', + ruleKey: 'actual-fixture', + loaders: [compiler], + conditions: [], + aliases: {}, + } + const invocation = { + resource: filename + '?original=query#resource', + ownIndex: 1, + compilerIndex: 3, + loaders: Object.freeze([ + { + loader: downstream, + options: rawOptions, + ident: 'caller-downstream', + fragment: '#downstream', + }, + { + loader: own, + options: ownOptions, + ident: 'caller-own', + fragment: '#own', + }, + { + loader: raw, + options: rawOptions, + ident: 'caller-post', + fragment: '#post', + }, + compiler, + { + loader: raw, + options: rawOptions, + ident: 'caller-pre', + fragment: '#pre', + }, + ]), + } + const request = { + root, + filename, + pipeline, + invocation, + signal: new AbortController().signal, + deadline: createMdxDeadline(), + context: { + owner: {}, + generation: {}, + compiler: {}, + sourceMap, + mode: 'production' as const, + }, + } + // Given: a native control with the same descriptors, detached approved compiler shells. + const controlLoaders = createMdxOptionsInstance().invocationLoadersFor( + pipeline, + invocation, + ) + const context = { + rootContext: root, + sourceMap, + mode: 'production', + _compiler: request.context.compiler, + getOptions(this: { readonly query: unknown }) { + return this.query + }, + } + const nativeInput = await nativeOwnControl( + { ...invocation, loaders: controlLoaders }, + { resource: invocation.resource, context, runLoaders }, + nativeOwn, + ) + const nativeEvents = events.splice(0) + assert.equal(nativeEvents.pop(), 'downstream-normal') + // When: production preparation runs the original slots through the installed runner. + const output = await compileMdx(request) + // Then: bytes/maps and raw/pitch requests match actual Devup-entry input, with no downstream normal. + assert.equal(output.source, String(nativeInput[0])) + assert.deepEqual(output.map, nativeInput[1]) + assert.equal(output.map !== undefined, sourceMap) + assert.deepEqual(events, nativeEvents) + assert.equal( + events.filter((event) => event === 'compiler-normal').length, + 1, + ) + assert.equal(output.dependencies.includes(filename + '.raw'), true) + assert.equal(tuple[0], pluginEntry) + assert.equal(compiler.options.remarkPlugins, plugins) + const options = createMdxOptionsInstance().invocationLoadersFor( + pipeline, + invocation, + ) + assert.notEqual(options[3], compiler) + assert.equal(options[2], invocation.loaders[2]) + assert.equal(output.filename, filename) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} diff --git a/packages/next-plugin/src/__tests__/mdx-own-slot.test.ts b/packages/next-plugin/src/__tests__/mdx-own-slot.test.ts new file mode 100644 index 000000000..f70962e12 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-own-slot.test.ts @@ -0,0 +1,81 @@ +import { writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { compileMdx } from '../mdx-prepare' +import { fixture } from './mdx-boundary-fixture.test' + +it('retains original compiler requests when a supplied segment observes descriptors', async () => { + // Given: this is a supplied segment, not a full native invocation certificate. + const request = fixture('@mdx-js/loader', { jsx: true }) + const compiler = request.pipeline.loaders[0] + if (!compiler) throw new TypeError('missing compiler') + const observations: unknown[] = [] + const raw = join(request.root, 'raw.cjs') + writeFileSync( + raw, + `module.exports = function(source) { + this.getOptions().observe(this.loaders.map(x => ({ path: x.path, query: x.query, ident: x.ident }))) + return source + }`, + ) + const pipeline = { + ...request.pipeline, + loaders: [ + compiler, + { + loader: raw, + options: { observe: (value: unknown) => observations.push(value) }, + ident: 'caller-raw', + }, + ], + } + // When + const output = await compileMdx({ ...request, pipeline }) + // Then + expect(output.source).toContain('original') + expect(observations).toEqual([ + [ + { path: compiler.loader, query: '?{"jsx":true}', ident: undefined }, + { path: raw, query: '??caller-raw', ident: 'caller-raw' }, + ], + ]) +}) + +it('captures post-compiler bytes at the existing Devup slot when full descriptors are supplied', async () => { + // Given + const request = fixture('@mdx-js/loader', { jsx: true }) + const compiler = request.pipeline.loaders[0] + if (!compiler) throw new TypeError('missing compiler') + const raw = join(request.root, 'post.cjs') + writeFileSync( + raw, + `module.exports = function(source, map) { + const done = this.async(); this.addDependency(this.resourcePath + '.post') + queueMicrotask(() => done(null, Buffer.from(source + '\\n// post-compiler'), map)) + }; module.exports.raw = true`, + ) + const downstream = join(request.root, 'downstream.cjs') + writeFileSync( + downstream, + `module.exports = require(${JSON.stringify(resolve(import.meta.dir, 'mdx-own-slot-normals.cjs'))}).downstream`, + ) + const invocation = { + resource: request.filename, + loaders: [ + downstream, + { loader: resolve(import.meta.dir, '../../dist/loader.cjs') }, + { loader: raw, fragment: '#post', type: 'commonjs' }, + compiler, + ], + ownIndex: 1, + compilerIndex: 3, + } + // When + const output = await compileMdx({ ...request, invocation }) + // Then + expect(output.source).toEndWith('// post-compiler') + expect(output.dependencies).toContain(request.filename + '.post') + expect(output.map).toBeDefined() +}) diff --git a/packages/next-plugin/src/__tests__/mdx-pipeline.test.ts b/packages/next-plugin/src/__tests__/mdx-pipeline.test.ts new file mode 100644 index 000000000..6adb76939 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-pipeline.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'bun:test' + +import { + composeMdxRules, + MdxPipelineError, + requireMdxPipeline, +} from '../mdx-pipeline' + +const compiler = { loader: '@next/mdx/mdx-js-loader', options: { jsx: true } } +const extraction = { loader: '/devup/extract.cjs', options: { token: 'app' } } + +describe('MDX pipeline composition', () => { + it('preserves arbitrary Turbo keys and unrelated entries when composing', () => { + // Given + const unrelated = { loaders: ['image-loader'], as: '*.js' } + const condition = { all: [{ path: /\.mdx$/ }, 'browser'] } + const aliases = { provider: ['src/provider', 'fallback'] } + const rule = { condition, loaders: [compiler, 'raw-loader'], as: '*.tsx' } + const rules = { arbitrary: [unrelated, rule], '*.png': unrelated } + // When + const result = composeMdxRules( + { bundler: 'turbo', rules, aliases }, + extraction, + ) + // Then + expect(result.rules).toEqual({ + arbitrary: [ + unrelated, + { ...rule, loaders: [extraction, compiler, 'raw-loader'] }, + ], + '*.png': unrelated, + }) + expect(result.pipelines[0]).toMatchObject({ + loaders: [compiler, { loader: 'raw-loader' }], + aliases, + }) + expect(result.pipelines[0]?.conditions).toEqual([rule]) + expect(rule.loaders).toEqual([compiler, 'raw-loader']) + }) + + it('composes nested webpack chains before the compiler when downstream loaders exist', () => { + // Given + const rule = { + test: /\.mdx$/, + use: ['next-swc-loader', extraction, compiler, 'raw'], + } + const parent = { issuer: /page/, oneOf: [rule, { use: ['css'] }] } + // When + const result = composeMdxRules( + { bundler: 'webpack', rules: [parent], aliases: {} }, + extraction, + ) + // Then + expect(result.rules).toEqual([parent]) + expect(result.pipelines[0]?.loaders).toEqual([compiler, { loader: 'raw' }]) + expect(result.pipelines[0]?.conditions).toEqual([parent, rule]) + }) + + it('reports the actual required module when its chain is unavailable', () => { + // Given + const filename = '/app/content/page.mdx' + // When / Then + expect(() => requireMdxPipeline(filename, undefined)).toThrow( + `${filename}:1:1:`, + ) + }) + + it('inserts extraction once when webpack has nested rules and no previous extractor', () => { + // Given + const rule = { + use: ['next-flight-loader', 'next-swc-loader', compiler], + include: '/app', + } + // When + const result = composeMdxRules( + { bundler: 'webpack', rules: [{ rules: [rule] }, false], aliases: {} }, + extraction, + ) + // Then + expect(result.rules).toEqual([ + { + rules: [ + { + ...rule, + use: [ + 'next-flight-loader', + 'next-swc-loader', + extraction, + compiler, + ], + }, + ], + }, + false, + ]) + expect( + requireMdxPipeline('/real.mdx', result.pipelines[0]).loaders, + ).toEqual([compiler]) + }) + + it.each([ + [compiler, { loader: 'raw', options: 3 }], + [compiler, { loader: 'raw', ident: 3 }], + [compiler, false], + [compiler, 'next-swc-loader'], + [compiler, 'next-flight-loader'], + [compiler, compiler], + ])( + 'blocks required malformed or downstream compiler segments when chain is %j', + (...loaders) => { + // Given + const rule = { loaders } + // When + const result = composeMdxRules( + { bundler: 'turbo', rules: { '*': rule }, aliases: {} }, + extraction, + ) + // Then + expect(result.rules).toEqual({ '*': rule }) + expect(() => + requireMdxPipeline('/real.mdx', result.pipelines[0]), + ).toThrow(MdxPipelineError) + }, + ) + + it('leaves unrecognized and non-array rules alone when no verified compiler exists', () => { + // Given + const rules = { + '*': [ + { loaders: ['@next/mdx/mdx-rs-loader'] }, + { loaders: [{ loader: 'other', options: '?key=value', ident: 'id' }] }, + 'ignore', + ], + '*.md': { loaders: 'dynamic' }, + } + // When + const result = composeMdxRules( + { bundler: 'turbo', rules, aliases: {} }, + extraction, + ) + // Then + expect(result).toEqual({ rules, pipelines: [] }) + }) + + it('recognizes direct MDX loader requests when compiler options use strings', () => { + // Given + const compiler = { + loader: '@mdx-js/loader', + options: '?jsx=true', + ident: 'configured', + } + // When + const result = composeMdxRules( + { + bundler: 'turbo', + rules: { '*': { loaders: [compiler] } }, + aliases: {}, + }, + extraction, + ) + // Then + expect(result.pipelines[0]?.loaders).toEqual([compiler]) + }) + + it('relocates duplicate extraction loaders when they were inserted before raw compilation', () => { + // Given + const rule = { loaders: [extraction, compiler, extraction, 'raw'] } + // When + const result = composeMdxRules( + { bundler: 'turbo', rules: { '*': rule }, aliases: {} }, + extraction, + ) + // Then + expect(result.rules).toEqual({ + '*': { loaders: [extraction, compiler, 'raw'] }, + }) + expect(result.pipelines[0]?.loaders).toEqual([compiler, { loader: 'raw' }]) + }) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-prepare-errors.test.ts b/packages/next-plugin/src/__tests__/mdx-prepare-errors.test.ts new file mode 100644 index 000000000..af9c963ff --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-prepare-errors.test.ts @@ -0,0 +1,224 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { afterEach, expect, it } from 'bun:test' + +import { + composeMdxRules, + type MdxLoader, + requireMdxPipeline, +} from '../mdx-pipeline' +import { compileMdx, createMdxDeadline, MdxCompileError } from '../mdx-prepare' + +const installedRoot = resolve(import.meta.dir, '../../../../apps/landing') +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }) +}) + +function fixture(body: string, options?: MdxLoader['options']) { + const root = mkdtempSync(join(tmpdir(), 'devup-mdx-errors-')) + roots.push(root) + symlinkSync( + join(installedRoot, 'node_modules'), + join(root, 'node_modules'), + 'junction', + ) + const directory = join(root, '@next/mdx') + mkdirSync(directory, { recursive: true }) + const loader = join(directory, 'mdx-js-loader.js') + writeFileSync(loader, `module.exports = function(source) { ${body} }`) + const filename = join(root, 'actual.mdx') + writeFileSync(filename, '# source') + const result = composeMdxRules( + { + bundler: 'webpack', + rules: [ + { use: [{ loader, ...(options === undefined ? {} : { options }) }] }, + ], + aliases: {}, + }, + { loader: 'devup' }, + ) + return { + root, + filename, + pipeline: requireMdxPipeline(filename, result.pipelines[0]), + signal: new AbortController().signal, + deadline: createMdxDeadline(), + } +} + +it('returns maps and dependency sets when an asynchronous compiler registers them', async () => { + // Given + const request = fixture( + `const done = this.async(); this.addDependency(this.resourcePath + '.dep'); this.addContextDependency(this.context); this.addMissingDependency(this.resourcePath + '.missing'); queueMicrotask(() => done(null, Buffer.from(this.resourcePath), { version: 3, sources: [this.resourcePath] }))`, + ) + // When + const output = await compileMdx(request) + // Then + expect(output.source).toBe(request.filename) + expect(output.map).toEqual({ version: 3, sources: [request.filename] }) + expect(output.dependencies).toEqual([ + request.filename, + `${request.filename}.dep`, + ]) + expect(output.contextDependencies).toEqual([request.root]) + expect(output.missingDependencies).toEqual([`${request.filename}.missing`]) +}) + +it.each(['key=configured', '{"key":"configured"}'])( + 'parses actual string options when options are %s', + async (options) => { + // Given + const request = fixture('return this.getOptions().key', options) + // When + const output = await compileMdx(request) + // Then + expect(output.source).toBe('configured') + }, +) + +it.each([ + 'throw Object.assign(new Error("bad compiler"), { place: { start: { line: 4, column: 7 } } })', + 'throw Object.assign(new Error("bad compiler"), { place: { line: 4, column: 7 } })', +])('retains compiler location when the loader reports %s', async (body) => { + // Given + const request = fixture(body) + // When / Then + await expect(compileMdx(request)).rejects.toMatchObject({ + filename: request.filename, + line: 4, + column: 7, + }) +}) + +it.each([ + 'this.callback(null, undefined)', + 'this.callback(null, "compiled", 9)', + 'this.addDependency(9); return "compiled"', + 'this.emitError(new Error("emitted")); return "compiled"', + 'throw "compiler rejected"', +])( + 'blocks invalid compilation results when the loader executes %s', + async (body) => { + // Given + const request = fixture(body) + // When / Then + await expect(compileMdx(request)).rejects.toBeInstanceOf(MdxCompileError) + }, +) + +it('fails before compilation when the shared preparation deadline has expired', async () => { + // Given + const request = fixture('throw new Error("must not execute")') + // When / Then + await expect( + compileMdx({ ...request, deadline: createMdxDeadline(-1) }), + ).rejects.toBeInstanceOf(MdxCompileError) +}) + +it('fails before compilation when cancellation is already requested', async () => { + // Given + const request = fixture('throw new Error("must not execute")') + // When / Then + await expect( + compileMdx({ ...request, signal: AbortSignal.abort('cancelled') }), + ).rejects.toBeInstanceOf(MdxCompileError) +}) + +it.each([ + [30_000, 1], + [1, 10_000], +])( + 'bounds hanging async chains when the budgets are %j', + async (totalMs, individualMs) => { + // Given + const request = fixture('this.async()') + // When / Then + await expect( + compileMdx({ + ...request, + deadline: createMdxDeadline(totalMs, individualMs), + }), + ).rejects.toBeInstanceOf(MdxCompileError) + }, +) + +it('ignores late callbacks when cancellation settles an active chain', async () => { + // Given + const started = + Promise.withResolvers<(error: Error | null, source: string) => void>() + const controller = new AbortController() + const request = fixture('this.getOptions().started(this.async())', { + started: started.resolve, + }) + const pending = compileMdx({ ...request, signal: controller.signal }) + const callback = await started.promise + // When + controller.abort('cancelled') + callback(null, 'late compiled source') + // Then + await expect(pending).rejects.toBeInstanceOf(MdxCompileError) +}) + +it('ignores late callbacks when the individual compiler deadline expires', async () => { + // Given + const started = + Promise.withResolvers<(error: Error | null, source: string) => void>() + const request = fixture('this.getOptions().started(this.async())', { + started: started.resolve, + }) + const pending = compileMdx({ + ...request, + deadline: createMdxDeadline(30_000, 100), + }) + const callback = await started.promise + // When / Then + await expect(pending).rejects.toBeInstanceOf(MdxCompileError) + callback(null, 'late compiled source') +}) + +it('rejects resolution failures when a configured loader no longer exists', async () => { + // Given + const request = fixture('return source') + rmSync(request.pipeline.loaders[0]?.loader ?? '', { force: true }) + // When / Then + await expect(compileMdx(request)).rejects.toBeInstanceOf(MdxCompileError) +}) + +it('rejects an unavailable project runner when the project has no Next installation', async () => { + // Given + const request = fixture('return source') + const root = mkdtempSync(join(tmpdir(), 'devup-mdx-no-next-')) + roots.push(root) + // When / Then + await expect(compileMdx({ ...request, root })).rejects.toBeInstanceOf( + MdxCompileError, + ) +}) + +it('rejects an invalid installed runner export when its entry is not callable', async () => { + // Given + const request = fixture('return source') + const root = mkdtempSync(join(tmpdir(), 'devup-mdx-invalid-next-')) + roots.push(root) + const directory = join(root, 'node_modules/next/dist/compiled/loader-runner') + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(directory, 'LoaderRunner.js'), + 'module.exports = { runLoaders: null }', + ) + // When / Then + await expect(compileMdx({ ...request, root })).rejects.toBeInstanceOf( + MdxCompileError, + ) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-prepare.test.ts b/packages/next-plugin/src/__tests__/mdx-prepare.test.ts new file mode 100644 index 000000000..d26130fbb --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-prepare.test.ts @@ -0,0 +1,246 @@ +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { afterEach, expect, it } from 'bun:test' + +import { composeMdxRules, requireMdxPipeline } from '../mdx-pipeline' +import { compileMdx, createMdxDeadline } from '../mdx-prepare' + +const installedRoot = resolve(import.meta.dir, '../../../../apps/landing') +const installed = createRequire(join(installedRoot, 'package.json')) +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }) +}) + +function project(source = '# original') { + const root = mkdtempSync(join(tmpdir(), 'devup-mdx-chain-')) + roots.push(root) + symlinkSync( + join(installedRoot, 'node_modules'), + join(root, 'node_modules'), + 'junction', + ) + const filename = join(root, 'real page.mdx') + writeFileSync(filename, source) + return { root, filename } +} + +it.each([true, false])( + 'uses the actual JS compiler options when jsx is %s', + async (jsx) => { + // Given + const { root, filename } = project() + const remark = join(root, 'remark.cjs') + writeFileSync( + remark, + `module.exports = function(options) { return function(tree) { tree.children[0].children[0].value = options.text } }`, + ) + const rehype = join(root, 'rehype.cjs') + writeFileSync( + rehype, + `module.exports = function(options) { return function(tree) { tree.children[0].tagName = options.tag } }`, + ) + const options = { + jsx, + remarkPlugins: [[remark, { text: 'configured plugin' }]], + rehypePlugins: [[rehype, { tag: 'h2' }]], + providerImportSource: 'custom-provider', + } + const result = composeMdxRules( + { + bundler: 'turbo', + rules: { + '*': { + loaders: [ + { loader: installed.resolve('@next/mdx/mdx-js-loader'), options }, + ], + as: '*.tsx', + }, + }, + aliases: {}, + }, + { loader: 'devup' }, + ) + // When + const output = await compileMdx({ + root, + filename, + pipeline: requireMdxPipeline(filename, result.pipelines[0]), + signal: new AbortController().signal, + deadline: createMdxDeadline(), + }) + // Then + expect(output.filename).toBe(filename) + expect(output.source).toContain('configured plugin') + expect(output.source).toContain('custom-provider') + expect(output.source).toContain('h2') + expect(output.source.includes('<')).toBe(jsx) + expect(output.dependencies).toContain(filename) + expect(output.dependencies).toContain(remark) + expect(output.dependencies).toContain(rehype) + expect(options.remarkPlugins[0]?.[0]).toBe(remark) + }, +) + +it('runs only the pre-extraction segment when actual MDX has raw async loaders to its right', async () => { + // Given + const { root, filename } = project() + const raw = join(root, 'raw.cjs') + const downstream = join(root, 'downstream.cjs') + writeFileSync( + raw, + `module.exports = function(source) { const done = this.async(); this.addDependency(this.resourcePath + '.raw'); queueMicrotask(() => done(null, '# raw async ' + this.resourcePath)) }`, + ) + writeFileSync( + downstream, + `module.exports = function() { throw new Error('downstream must not run') }`, + ) + const result = composeMdxRules( + { + bundler: 'webpack', + rules: [ + { + test: /\.mdx$/, + use: [ + downstream, + { + loader: installed.resolve('@next/mdx/mdx-js-loader'), + options: { jsx: true }, + }, + raw, + ], + }, + ], + aliases: { provider: './provider.tsx' }, + }, + { loader: 'devup' }, + ) + // When + const output = await compileMdx({ + root, + filename, + pipeline: requireMdxPipeline(filename, result.pipelines[0]), + signal: new AbortController().signal, + deadline: createMdxDeadline(), + }) + // Then + expect(output.source).toContain('raw async') + expect(output.source).toContain('real page.mdx') + expect(output.dependencies).toContain(`${filename}.raw`) +}) + +it.each(['@next/mdx/mdx-js-loader', '@mdx-js/loader'])( + 'preserves shared and function option references with %s', + async (compiler) => { + // Given + const { root, filename } = project() + const shared = { text: 'identity output' } + const callback = () => shared.text + const configured = { left: shared, right: shared, callback } + const observations: unknown[] = [] + type Options = typeof configured + const remark = (options: Options) => { + observations.push(options, options.left, options.right, options.callback) + return (tree: { children: { children: { value: string }[] }[] }) => { + const text = tree.children[0]?.children[0] + if (text) text.value = options.callback() + } + } + const composed = composeMdxRules( + { + bundler: 'webpack', + rules: [ + { + use: [ + { + loader: installed.resolve(compiler), + options: { remarkPlugins: [[remark, configured]] }, + }, + ], + }, + ], + aliases: {}, + }, + { loader: 'devup' }, + ) + // When + const output = await compileMdx({ + root, + filename, + pipeline: requireMdxPipeline(filename, composed.pipelines[0]), + signal: new AbortController().signal, + deadline: createMdxDeadline(), + }) + // Then + expect(observations).toEqual([configured, shared, shared, callback]) + expect(observations[0]).toBe(configured) + expect(observations[1]).toBe(shared) + expect(observations[2]).toBe(shared) + expect(observations[3]).toBe(callback) + expect(output.source).toContain('identity output') + }, +) + +it('retains the real module and compiler location when installed MDX rejects syntax', async () => { + // Given + const { root, filename } = project('# hello\n\n { + // Given + const { root, filename } = project() + const result = composeMdxRules( + { + bundler: 'turbo', + rules: { + '*': { + loaders: [ + { loader: installed.resolve('@next/mdx/mdx-js-loader'), options }, + ], + }, + }, + aliases: {}, + }, + { loader: 'devup' }, + ) + // When / Then + await expect( + compileMdx({ + root, + filename, + pipeline: requireMdxPipeline(filename, result.pipelines[0]), + signal: new AbortController().signal, + deadline: createMdxDeadline(), + }), + ).rejects.toMatchObject({ filename }) + }, +) diff --git a/packages/next-plugin/src/__tests__/mdx-resolution-config.test.ts b/packages/next-plugin/src/__tests__/mdx-resolution-config.test.ts new file mode 100644 index 000000000..fb0b70cf4 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-resolution-config.test.ts @@ -0,0 +1,144 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createMdxSourceManager } from '../mdx-source-generation' +import { createWasm } from '../wasm' +import { paletteFixture } from './mdx-resolution-fixture' +import { sourceFixture, styledMdx } from './mdx-source-fixture' + +it.each(['absent', 'inherited'] as const)( + 'refreshes aliases when the actual %s config input changes', + async (kind) => { + // Given + const f = paletteFixture() + const config = (color: string) => + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { palette: [`node_modules/palette/${color}.js`] }, + }, + }) + if (kind === 'inherited') { + f.write('tsconfig.json', '{"extends":"./base.json"}') + f.write('base.json', config('red')) + } + const first = await f.manager.prepare(f.signal) + const target = kind === 'absent' ? 'tsconfig.json' : 'base.json' + expect(first.watchInputs).toContain(join(f.root, target)) + f.write(target, config('blue')) + // When + const next = await f.manager.refresh({ + generation: first, + signal: f.signal, + }) + // Then + expect(f.counts()).toBe(2) + expect(f.css(next)).toContain('color:blue') + expect(f.css(next)).not.toContain('color:red') + }, +) + +it('refreshes when an earlier actual tsconfig paths candidate appears', async () => { + // Given + const f = paletteFixture() + f.write( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + palette: ['outside/earlier.js', 'node_modules/palette/red.js'], + }, + }, + }), + ) + const first = await f.manager.prepare(f.signal) + f.write('outside/earlier.js', 'export const color = "blue"') + // When + const next = await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(first.watchInputs).toContain(join(f.root, 'outside/earlier.js')) + expect(f.css(next)).toContain('color:blue') + expect(f.counts()).toBe(2) +}) + +it('rejects ordinary-only stale resolution proof at production finalization', async () => { + // Given + const f = paletteFixture( + { + 'app/page.tsx': `import { css } from '@devup-ui/react'; import { color } from 'palette'; export const style = css({color})`, + }, + false, + ) + const first = await f.manager.prepare(f.signal) + f.select('blue') + // When / Then + expect(() => f.manager.validateForCssFinalization(first)).toThrow(f.manifest) +}) + +it('does not reuse a legacy cache lacking resolution proof', async () => { + // Given + const f = paletteFixture() + const first = await f.manager.prepare(f.signal) + const legacy: unknown = JSON.parse(f.manager.restartCache(first)) + if (!Array.isArray(legacy)) throw new TypeError('Expected serialized entries') + for (const entry of legacy) { + if (typeof entry !== 'object' || entry === null) + throw new TypeError('Expected serialized entry') + Reflect.deleteProperty(entry, 'resolutionProofVersion') + } + // When + await createMdxSourceManager(f.binding, JSON.stringify(legacy)).prepare( + f.signal, + ) + // Then + expect(f.counts()).toBe(2) +}) + +it('keeps committed proof immutable when its configurer is installed on later engines', async () => { + // Given + const f = paletteFixture() + const first = await f.manager.prepare(f.signal) + const proof = first.resolutionInputs + const fingerprint = proof.find( + (input) => input.path === f.manifest, + )?.fingerprint + f.select('blue') + // When + first.configureWasm(createWasm(f.root)) + first.configureWasm(createWasm(f.root)) + const next = await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(first.resolutionInputs).toBe(proof) + expect( + first.resolutionInputs.find((input) => input.path === f.manifest) + ?.fingerprint, + ).toBe(fingerprint) + expect( + next.resolutionInputs.find((input) => input.path === f.manifest) + ?.fingerprint, + ).not.toBe(fingerprint) + expect(f.css(next)).toContain('color:blue') +}) + +it('retains actual unresolved package probes without inventing resolved request inputs', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': `import { unused } from 'not-installed'\n\n${styledMdx}`, + }) + // When + const generation = await f.manager.prepare(f.signal) + // Then + const request = generation.plan.graph.requests?.find( + (request) => request.specifier === 'not-installed', + ) + expect(request?.outcome).toEqual({ + kind: 'external', + request: 'not-installed', + }) + expect(generation.watchInputs).toContain( + join(f.root, 'node_modules/not-installed/package.json'), + ) + expect(f.css(generation)).toContain('background:red') +}) diff --git a/packages/next-plugin/src/__tests__/mdx-resolution-coordinator-watch.test.ts b/packages/next-plugin/src/__tests__/mdx-resolution-coordinator-watch.test.ts new file mode 100644 index 000000000..78c814c17 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-resolution-coordinator-watch.test.ts @@ -0,0 +1,100 @@ +import { randomUUID } from 'node:crypto' +import { dirname, join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { startCoordinator } from '../coordinator' +import { extractInput } from '../coordinator-engine' +import { createMdxSourceManager } from '../mdx-source-generation' +import { createWasm } from '../wasm' +import { connect } from './coordinator-app' +import { paletteFixture } from './mdx-resolution-fixture' + +it.each(['manifest', 'missing'] as const)( + 'publishes hot blue CSS after a real coordinator %s watch event', + async (kind) => { + // Given + const f = paletteFixture() + const earlier = join(f.root, 'outside/earlier.js') + const manager = + kind === 'manifest' + ? f.manager + : createMdxSourceManager({ + ...f.binding, + aliases: { + ...f.binding.aliases, + palette$: [earlier, join(f.root, 'node_modules/palette/red.js')], + }, + }) + const generation = await manager.prepare(f.signal) + const context = f.binding.effectiveAppContext + const wasm = createWasm(f.root) + generation.configureWasm(wasm) + const settings = { + package: context.libPackage, + cssDir: context.cssDir, + singleCss: true, + sourceMap: false, + importAliases: {}, + } + for (const input of generation.inputs) extractInput(wasm, settings, input) + const identity = { project: f.root, token: randomUUID() } + const portFile = join(f.root, 'coordinator.port') + const target = kind === 'manifest' ? f.manifest : earlier + const event = Promise.withResolvers() + const handle = startCoordinator({ + wasm, + ...settings, + projectRoot: f.root, + coordinatorPortFile: portFile, + identity, + canonicalMap: {}, + watch: true, + sourceRoots: [join(f.root, 'app')], + createEngine: () => createWasm(f.root), + preparedSources: { + initial: { + generation, + ordinaryInputs: generation.ordinaryInputs, + revision: 1, + }, + async prepareReplay(request) { + try { + const next = await manager.refresh(request) + if ( + request.changedPaths?.some( + (path) => path === target || path === dirname(target), + ) + ) + event.resolve() + return next + } catch (cause) { + event.reject(cause) + throw cause + } + }, + }, + }) + const timer = setTimeout( + () => + event.reject(new Error('Coordinator resolution watch did not refresh')), + 4000, + ) + try { + await handle.prepared + // When + if (kind === 'manifest') f.select('blue') + else f.write('outside/earlier.js', 'export const color = "blue"') + await event.promise + const css = await connect(portFile, identity).get('/css') + // Then + expect(css.status).toBe(200) + expect(css.body).toContain('color:blue') + expect(css.body).not.toContain('color:red') + expect(f.counts()).toBe(2) + } finally { + clearTimeout(timer) + await handle.drain() + } + }, +) diff --git a/packages/next-plugin/src/__tests__/mdx-resolution-fixture.ts b/packages/next-plugin/src/__tests__/mdx-resolution-fixture.ts new file mode 100644 index 000000000..f5d787607 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-resolution-fixture.ts @@ -0,0 +1,49 @@ +import { mkdirSync, symlinkSync, unlinkSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { sourceFixture } from './mdx-source-fixture' + +export const paletteMdx = `import { css } from '@devup-ui/react' + +import { color } from 'palette' + +export const style = css({color}) + +# Palette` + +export function paletteFixture( + files: Record = { 'app/page.mdx': paletteMdx }, + development = true, +) { + const f = sourceFixture(files, {}, development) + unlinkSync(join(f.root, 'node_modules')) + mkdirSync(join(f.root, 'node_modules')) + const installed = resolve( + import.meta.dir, + '../../../../apps/landing/node_modules', + ) + for (const name of ['react', 'next', '@devup-ui']) + symlinkSync( + join(installed, name), + join(f.root, 'node_modules', name), + 'junction', + ) + function select(color: string) { + return f.write( + 'node_modules/palette/package.json', + JSON.stringify({ + name: 'palette', + version: '1.0.0', + exports: `./${color}.js`, + }), + ) + } + select('red') + f.write('node_modules/palette/red.js', 'export const color = "red"') + f.write('node_modules/palette/blue.js', 'export const color = "blue"') + return { + ...f, + select, + manifest: join(f.root, 'node_modules/palette/package.json'), + } +} diff --git a/packages/next-plugin/src/__tests__/mdx-resolution-packaged.test.ts b/packages/next-plugin/src/__tests__/mdx-resolution-packaged.test.ts new file mode 100644 index 000000000..3c2571bc3 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-resolution-packaged.test.ts @@ -0,0 +1,229 @@ +import { randomUUID } from 'node:crypto' +import { readFileSync, renameSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { startCoordinator } from '../coordinator' +import { extractInput } from '../coordinator-engine' +import type { PreparedSourceGeneration } from '../coordinator-options' +import { createMdxSourceManager } from '../mdx-source-generation' +import { createWasm } from '../wasm' +import { connect } from './coordinator-app' +import { paletteFixture } from './mdx-resolution-fixture' + +function packagedFixture(development = true) { + const f = paletteFixture(undefined, development) + f.write('tsconfig.json', '{"extends":"preset"}') + for (const color of ['red', 'blue'] as const) { + f.write( + `node_modules/preset/${color}.json`, + JSON.stringify({ + compilerOptions: { paths: { palette: [`./${color}.js`] } }, + }), + ) + f.write( + `node_modules/preset/${color}.js`, + `export const color = ${JSON.stringify(color)}`, + ) + } + const manifest = join(f.root, 'node_modules/preset/package.json') + function select(color: 'red' | 'blue') { + const replacement = f.write( + 'node_modules/preset/package.next.json', + JSON.stringify({ exports: `./${color}.json` }), + ) + renameSync(replacement, manifest) + } + select('red') + return { ...f, manifest, select } +} + +function fileProof(generation: PreparedSourceGeneration, path: string) { + const proof = generation.resolutionInputs?.find( + (input) => input.path === path && input.kind === 'file', + ) + if (proof === undefined) throw new TypeError(`Missing file proof: ${path}`) + return proof +} + +it('replaces packaged config selection through a coordinator watch before HTTP CSS', async () => { + // Given + const f = packagedFixture() + const first = await f.manager.prepare(f.signal) + const pid = process.pid + const red = join(f.root, 'node_modules/preset/red.json') + const blue = join(f.root, 'node_modules/preset/blue.json') + const originalProof = first.resolutionInputs + const originalBytes = JSON.stringify(originalProof) + const manifestFingerprint = fileProof(first, f.manifest).fingerprint + fileProof(first, red) + expect(first.watchInputs).toContain(f.manifest) + expect(first.watchInputs).toContain(red) + const unchanged: readonly Readonly<{ path: string; bytes: string }>[] = [ + 'tsconfig.json', + 'app/page.mdx', + 'provider.tsx', + 'node_modules/preset/red.json', + 'node_modules/preset/blue.json', + 'node_modules/preset/red.js', + 'node_modules/preset/blue.js', + ].map((path) => ({ + path: join(f.root, path), + bytes: readFileSync(join(f.root, path), 'utf-8'), + })) + const context = f.binding.effectiveAppContext + const wasm = createWasm(f.root) + first.configureWasm(wasm) + const settings = { + package: context.libPackage, + cssDir: context.cssDir, + singleCss: true, + sourceMap: false, + importAliases: {}, + } + for (const input of first.inputs) extractInput(wasm, settings, input) + const identity = { project: f.root, token: randomUUID() } + const portFile = join(f.root, 'coordinator.port') + const event = Promise.withResolvers<{ + readonly generation: PreparedSourceGeneration + readonly changedPaths: readonly string[] + }>() + let observedConfigurer = first.configureWasm + const handle = startCoordinator({ + wasm, + ...settings, + projectRoot: f.root, + coordinatorPortFile: portFile, + identity, + canonicalMap: {}, + watch: true, + sourceRoots: [join(f.root, 'app')], + createEngine: () => createWasm(f.root), + preparedSources: { + initial: { + generation: first, + ordinaryInputs: first.ordinaryInputs, + revision: 1, + }, + async prepareReplay(request) { + try { + observedConfigurer = request.generation.configureWasm + const generation = await f.manager.refresh(request) + const changedPaths = request.changedPaths ?? [] + if ( + changedPaths.some( + (path) => path === f.manifest || path === dirname(f.manifest), + ) + ) + event.resolve({ generation, changedPaths }) + return generation + } catch (cause) { + event.reject(cause) + throw cause + } + }, + }, + }) + let timer: ReturnType | undefined + try { + await Promise.all([handle.ready, handle.prepared]) + const client = connect(portFile, identity) + const initial = await client.get('/css') + expect(initial.status).toBe(200) + expect(initial.body).toContain('color:red') + expect(initial.body).not.toContain('color:blue') + expect(initial.body).not.toContain('color:var(') + expect(f.counts()).toBe(1) + timer = setTimeout( + () => event.reject(new TypeError('Packaged watch did not refresh')), + 4000, + ) + // When + f.select('blue') + // Then: the watcher must finish preparation before HTTP can reconcile. + const watched = await event.promise + clearTimeout(timer) + expect( + watched.changedPaths.some( + (path) => path === f.manifest || path === dirname(f.manifest), + ), + ).toBe(true) + expect(watched.generation.configureWasm).not.toBe(first.configureWasm) + expect(fileProof(watched.generation, f.manifest).fingerprint).not.toBe( + manifestFingerprint, + ) + fileProof(watched.generation, blue) + expect(watched.generation.watchInputs).toContain(f.manifest) + expect(watched.generation.watchInputs).toContain(blue) + const hot = await client.get('/css') + expect(hot.status).toBe(200) + expect(hot.body).toContain('color:blue') + expect(hot.body).not.toContain('color:red') + expect(hot.body).not.toContain('color:var(') + expect(observedConfigurer).toBe(watched.generation.configureWasm) + expect(process.pid).toBe(pid) + expect(connect(portFile, identity).info).toEqual(client.info) + expect(f.counts()).toBe(2) + expect(first.resolutionInputs).toBe(originalProof) + expect(JSON.stringify(first.resolutionInputs)).toBe(originalBytes) + for (const input of unchanged) + expect(readFileSync(input.path, 'utf-8')).toBe(input.bytes) + } finally { + clearTimeout(timer) + await handle.drain() + } + const cold = await createMdxSourceManager(f.binding).prepare(f.signal) + const coldCss = f.css(cold) + expect(coldCss).toContain('color:blue') + expect(coldCss).not.toContain('color:red') + fileProof(cold, f.manifest) + fileProof(cold, blue) + expect(process.pid).toBe(pid) +}) + +it('rejects a selecting manifest change at production CSS finalization', async () => { + // Given + const f = packagedFixture(false) + const first = await f.manager.prepare(f.signal) + fileProof(first, f.manifest) + expect(f.css(first)).toContain('color:red') + f.select('blue') + // When / Then + expect(() => f.manager.validateForCssFinalization(first)).toThrow(f.manifest) +}) + +it('retains an independent root generation when another packaged manifest changes', async () => { + // Given + const changed = packagedFixture() + const control = packagedFixture() + const changedFirst = await changed.manager.prepare(changed.signal) + const first = await control.manager.prepare(control.signal) + const filename = join(control.root, 'app/page.mdx') + const prepared = first.compiled[filename]?.prepared + if (prepared === undefined) + throw new TypeError('Control MDX was not prepared') + expect(control.counts()).toBe(1) + const proof = JSON.stringify(first.resolutionInputs) + // When + changed.select('blue') + // Then + const changedNext = await changed.manager.refresh({ + generation: changedFirst, + signal: changed.signal, + changedPaths: [changed.manifest], + }) + expect(changed.css(changedNext)).toContain('color:blue') + const next = await control.manager.refresh({ + generation: first, + signal: control.signal, + changedPaths: [changed.manifest], + }) + expect(next).toBe(first) + expect(next.compiled[filename]?.prepared).toBe(prepared) + expect(control.counts()).toBe(1) + expect(JSON.stringify(next.resolutionInputs)).toBe(proof) + const css = control.css(next) + expect(css).toContain('color:red') + expect(css).not.toContain('color:blue') +}) diff --git a/packages/next-plugin/src/__tests__/mdx-resolution-proof.test.ts b/packages/next-plugin/src/__tests__/mdx-resolution-proof.test.ts new file mode 100644 index 000000000..ea8e80ca6 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-resolution-proof.test.ts @@ -0,0 +1,148 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { takeExtractOutput } from '../coordinator-engine' +import { parseCoordinatorResponse } from '../loader-response' +import { + readCompilerRestartReports, + readResolutionRestartProof, +} from '../mdx-resolution-cache' +import { + createResolutionProof, + verifyResolutionProof, +} from '../mdx-resolution-proof' +import { + createWasm, + extractWithModuleResolver, + withModuleResolver, +} from '../wasm' +import { paletteFixture } from './mdx-resolution-fixture' + +it('captures callback failures without changing the producer error identity', () => { + // Given + const f = paletteFixture() + const proof = createResolutionProof(f.root) + // When + expect(() => + proof.observe({ + fileDependencies: [join(f.root, 'unreadable.js')], + missingDependencies: [], + }), + ).not.toThrow() + // Then + expect(() => proof.snapshot()).toThrow('reported file is not readable') +}) + +it('rejects a missing observation that is already present instead of stamping it fresh', () => { + // Given + const f = paletteFixture() + const proof = createResolutionProof(f.root) + // When + proof.observe({ fileDependencies: [], missingDependencies: [f.manifest] }) + // Then + expect(() => proof.snapshot()).toThrow('observed missing input appeared') +}) + +it('retires observation collectors without changing their captured proof', () => { + // Given + const f = paletteFixture() + const proof = createResolutionProof(f.root) + proof.observe({ fileDependencies: [f.manifest], missingDependencies: [] }) + const captured = proof.snapshot() + proof.retire() + // When + proof.observe({ + fileDependencies: [join(f.root, 'unreadable.js')], + missingDependencies: [], + }) + f.select('blue') + // Then + expect(proof.snapshot()).toEqual(captured) + expect(() => verifyResolutionProof(f.root, captured)).toThrow(f.manifest) +}) + +it('retires a failed actual extraction scope when a callback races the manifest', async () => { + // Given + const f = paletteFixture() + const generation = await f.manager.prepare(f.signal) + const engine = createWasm(f.root) + generation.configureWasm(engine) + let racing = true + withModuleResolver(engine, f.root, { + ...generation.resolver, + onResolutionInputs(inputs) { + if (racing && inputs.fileDependencies.includes(f.manifest)) + f.select('blue') + }, + }) + const input = generation.sources[0]?.input + if (!input) throw new TypeError('Missing prepared route') + const extract = () => + extractWithModuleResolver(engine, false, [ + input.filename, + input.source, + '@devup-ui/react', + './df', + true, + false, + false, + {}, + ]) + // When / Then + expect(extract).toThrow(f.manifest) + racing = false + const output = takeExtractOutput(extract()) + expect( + output.resolutionInputs?.some((input) => input.path === f.manifest), + ).toBe(true) + expect(engine.getCss(null, false)).toContain('color:blue') +}) + +it.each([null, {}, { fileDependencies: [1], missingDependencies: [] }])( + 'rejects malformed resolution transport %j', + (resolutionInputs) => { + // Given / When / Then + expect(() => + parseCoordinatorResponse( + JSON.stringify({ code: 'compiled', resolutionInputs }), + ), + ).toThrow(TypeError) + }, +) + +it.each([ + null, + [{}], + [ + { + kind: 'missing', + path: '/candidate', + loader: 'devup/resolution', + fingerprint: 'present', + mtime: 0, + }, + ], +])('rejects an incomplete restart resolution proof %j', (value) => { + // Given / When / Then + expect(() => readResolutionRestartProof(value)).toThrow(TypeError) +}) + +it('keeps absent compiler reports distinct from invalid compiler reports', () => { + // Given / When / Then + expect(readCompilerRestartReports(undefined)).toEqual([]) + expect(() => readCompilerRestartReports(null)).toThrow(TypeError) +}) + +it('orders resolution proof by code points instead of host collation', () => { + // Given + const f = paletteFixture() + const upper = f.write('outside/Z.ts', 'export {}') + const lower = f.write('outside/a.ts', 'export {}') + const proof = createResolutionProof(f.root) + proof.observe({ fileDependencies: [lower, upper], missingDependencies: [] }) + // When + const paths = proof.snapshot().map((input) => input.path) + // Then + expect(paths).toEqual([upper, lower]) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-resolution-refresh.test.ts b/packages/next-plugin/src/__tests__/mdx-resolution-refresh.test.ts new file mode 100644 index 000000000..ac37fb79d --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-resolution-refresh.test.ts @@ -0,0 +1,124 @@ +import { utimesSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createCore } from '../coordinator-core' +import { extractInput } from '../coordinator-engine' +import { createMdxSourceManager } from '../mdx-source-generation' +import { createWasm } from '../wasm' +import { paletteFixture, paletteMdx } from './mdx-resolution-fixture' +import { styledMdx } from './mdx-source-fixture' + +it('replays real core CSS when only exports selects a new styling module', async () => { + // Given + const f = paletteFixture() + const generation = await f.manager.prepare(f.signal) + const context = f.binding.effectiveAppContext + const engine = createWasm(context.root) + generation.configureWasm(engine) + const settings = { + package: context.libPackage, + cssDir: context.cssDir, + singleCss: true, + sourceMap: false, + importAliases: {}, + } + for (const input of generation.inputs) extractInput(engine, settings, input) + const core = createCore( + { + wasm: engine, + ...settings, + projectRoot: f.root, + coordinatorPortFile: join(f.root, 'port'), + canonicalMap: {}, + watch: true, + createEngine: () => createWasm(context.root), + preparedSources: { + initial: { + ordinaryInputs: generation.ordinaryInputs, + generation, + revision: 1, + }, + prepareReplay: (request) => f.manager.refresh(request), + }, + }, + f.root, + ) + try { + await core.startup() + expect( + (await core.css({ importMainCss: false, wait: false })).css, + ).toContain('color:red') + // When + f.select('blue') + const after = await core.css({ importMainCss: false, wait: false }) + const cold = await f.manager.prepare(f.signal) + // Then + expect(generation.watchInputs).toContain(f.manifest) + expect(f.css(cold)).toContain('color:blue') + expect(after.css).toContain('color:blue') + expect(after.css).not.toContain('color:red') + } finally { + core.close() + await core.flush() + } +}) + +it('compiles only the route whose extraction consulted changed exports', async () => { + // Given + const f = paletteFixture({ + 'app/a/page.mdx': paletteMdx, + 'app/b/page.mdx': styledMdx, + }) + const first = await f.manager.prepare(f.signal) + f.select('blue') + // When + const next = await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(f.counts()).toBe(3) + expect(next.compiled[join(f.root, 'app/b/page.mdx')]?.prepared).toBe( + first.compiled[join(f.root, 'app/b/page.mdx')]?.prepared, + ) + expect(f.css(next)).toContain('color:blue') +}) + +it('reuses consulted metadata with unchanged bytes despite a timestamp change', async () => { + // Given + const f = paletteFixture() + const first = await f.manager.prepare(f.signal) + const calls = f.extractionCalls() + utimesSync(f.manifest, new Date(), new Date()) + // When + const next = await f.manager.refresh({ + generation: first, + signal: f.signal, + changedPaths: [f.manifest], + }) + // Then + expect(next).toBe(first) + expect(f.counts()).toBe(1) + expect(f.extractionCalls()).toBe(calls) +}) + +it('rejects consulted metadata changes before production CSS finalization', async () => { + // Given + const f = paletteFixture(undefined, false) + const first = await f.manager.prepare(f.signal) + f.select('blue') + // When / Then + expect(() => f.manager.validateForCssFinalization(first)).toThrow(f.manifest) +}) + +it('recompiles a stale serialized resolution proof after restart', async () => { + // Given + const f = paletteFixture() + const first = await f.manager.prepare(f.signal) + const cache = f.manager.restartCache(first) + f.select('blue') + // When + const next = await createMdxSourceManager(f.binding, cache).prepare(f.signal) + // Then + expect(f.counts()).toBe(2) + expect(f.css(next)).toContain('color:blue') +}) diff --git a/packages/next-plugin/src/__tests__/mdx-resolution-watch.test.ts b/packages/next-plugin/src/__tests__/mdx-resolution-watch.test.ts new file mode 100644 index 000000000..8d8d44a62 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-resolution-watch.test.ts @@ -0,0 +1,148 @@ +import { dirname, join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { watchSources } from '../coordinator-watch' +import { createMdxSourceManager } from '../mdx-source-generation' +import { paletteFixture, paletteMdx } from './mdx-resolution-fixture' +import { sourceFixture, styledMdx } from './mdx-source-fixture' + +it.each(['manifest', 'missing'] as const)( + 'refreshes CSS after an actual adopted %s watch event', + async (kind) => { + // Given + const f = paletteFixture() + const earlier = join(f.root, 'outside/earlier.js') + const manager = + kind === 'manifest' + ? f.manager + : createMdxSourceManager({ + ...f.binding, + aliases: { + ...f.binding.aliases, + palette$: [earlier, join(f.root, 'node_modules/palette/red.js')], + }, + }) + const first = await manager.prepare(f.signal) + const target = kind === 'manifest' ? f.manifest : earlier + expect(first.watchInputs).toContain(target) + const event = Promise.withResolvers() + const watcher = watchSources({ + roots: [], + debounceMs: 5, + onChange: (paths) => { + if (paths?.some((path) => path === target || path === dirname(target))) + event.resolve(paths) + }, + onError: (error) => event.reject(error), + }) + watcher.replaceInputs?.(first.watchInputs) + const timer = setTimeout( + () => + event.reject( + new Error('Resolution watch did not deliver the changed input'), + ), + 3000, + ) + try { + // When + if (kind === 'manifest') f.select('blue') + else f.write('outside/earlier.js', 'export const color = "blue"') + const changedPaths = await event.promise + const next = await manager.refresh({ + generation: first, + signal: f.signal, + changedPaths, + }) + // Then + expect(next).not.toBe(first) + expect(f.counts()).toBe(2) + expect(f.css(next)).toContain('color:blue') + expect(f.css(next)).not.toContain('color:red') + } finally { + clearTimeout(timer) + watcher.close() + } + }, +) + +it('invalidates ordinary styling metadata with zero compiled MDX inputs', async () => { + // Given + const f = paletteFixture({ + 'app/page.tsx': `import { css } from '@devup-ui/react'; import { color } from 'palette'; export const style = css({color}); export default function Page(){return
}`, + }) + const first = await f.manager.prepare(f.signal) + expect(first.sources).toEqual([]) + f.select('blue') + // When + const next = await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(next).not.toBe(first) + expect(f.css(next)).toContain('color:blue') + expect(f.counts()).toBe(0) +}) + +it('rejects a mid-observation manifest race without mutating predecessor proof', async () => { + // Given + const f = paletteFixture() + let racing = false + const manager = createMdxSourceManager({ + ...f.binding, + async extractDependencies(view, signal) { + const reports = await f.binding.extractDependencies(view, signal) + if (racing) f.select('blue') + return reports + }, + }) + const first = await manager.prepare(f.signal) + const proof = JSON.stringify(first.resolutionInputs) + f.write('app/page.mdx', paletteMdx + '\n\nChanged') + racing = true + // When / Then + await expect( + manager.refresh({ generation: first, signal: f.signal }), + ).rejects.toThrow(f.manifest) + expect(JSON.stringify(first.resolutionInputs)).toBe(proof) + expect(first.sources[0]?.input.source).not.toContain('Changed') +}) + +it('rejects a compiler-only dependency change during extraction independently of resolution reports', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': styledMdx, + 'app/data.json': '{"color":"red"}', + }) + const manager = createMdxSourceManager({ + ...f.binding, + async extractDependencies(view, signal) { + const reports = await f.binding.extractDependencies(view, signal) + f.write('app/data.json', '{"color":"blue"}') + return reports + }, + }) + // When / Then + await expect(manager.prepare(f.signal)).rejects.toThrow( + join(f.root, 'app/data.json'), + ) +}) + +it('rejects a newly reported ordinary dependency race when an extraction provider supplies no resolver proof', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': styledMdx, + 'df/plain.ts': 'export const value = 1', + }) + let reports = 0 + const manager = createMdxSourceManager({ + ...f.binding, + async extractDependencies() { + reports += 1 + if (reports === 2) f.write('df/plain.ts', 'export const value = 2') + return [{ filename: 'app/page.mdx', dependencies: ['df/plain.ts'] }] + }, + }) + // When / Then + await expect(manager.prepare(f.signal)).rejects.toThrow( + 'ordinary input changed during extraction', + ) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-source-boundaries.test.ts b/packages/next-plugin/src/__tests__/mdx-source-boundaries.test.ts new file mode 100644 index 000000000..aa9e58091 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-boundaries.test.ts @@ -0,0 +1,256 @@ +import { mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' + +import { createModuleResolver } from '@devup-ui/plugin-utils' +import { expect, it } from 'bun:test' + +import { createMdxDeadline } from '../mdx-prepare' +import { withMdxSourceControl } from '../mdx-source-control' +import { + captureMdxFreshness, + changedMdxInput, + createMdxTimestampAccuracy, + fingerprintMdxInput, + MdxFreshnessError, +} from '../mdx-source-freshness' +import { createMdxSourceManager } from '../mdx-source-generation' +import { runMdxSourcePreparation } from '../mdx-source-run' +import { sourceFixture, styledMdx } from './mdx-source-fixture' + +it('rejects expiry when selection has not yet supplied a pipeline', async () => { + // Given + const signal = new AbortController().signal + // When / Then + await expect( + withMdxSourceControl( + { filename: '/project/page.mdx', signal, deadline: createMdxDeadline(1) }, + async () => new Promise(() => {}), + ), + ).rejects.toBeInstanceOf(MdxFreshnessError) +}) + +it('propagates cancellation when async binding ignores its signal', async () => { + // Given + const controller = new AbortController() + const started = Promise.withResolvers() + const pending = withMdxSourceControl( + { filename: '/project/page.mdx', signal: controller.signal }, + async () => { + started.resolve() + return new Promise(() => {}) + }, + ) + await started.promise + // When + controller.abort(new Error('cancel binding')) + // Then + await expect(pending).rejects.toThrow('cancel binding') +}) + +it('rejects an expired shared budget before touching the configured compiler', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + // When / Then + await expect( + runMdxSourcePreparation(f.binding, { + signal: f.signal, + candidates: new Map(), + dirty: new Set(), + deadline: createMdxDeadline(-1), + }), + ).rejects.toBeInstanceOf(MdxFreshnessError) + expect(f.counts()).toBe(0) +}) + +it('rejects an excluded MDX import at its actual extraction importer', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': `import { css } from '@devup-ui/react'\n\nimport { color } from '../df/value.mdx'\n\nexport const style = css({color})\n\n# Test`, + 'df/value.mdx': `export const color = 'blue'`, + }) + // When / Then + await expect(f.manager.prepare(f.signal)).rejects.toThrow('df/value.mdx') +}) + +it('returns a pending expectation rather than reading a native-only resolver dependency', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': `import { css } from '@devup-ui/react'\n\nimport { color } from '../df/value'\n\nexport const style = css({color})\n\n# Test`, + 'df/value.ts': 'this source is invalid until upstream loader transforms it', + }) + const filename = join(f.root, 'df/value.ts') + const manager = createMdxSourceManager({ + ...f.binding, + ordinaryEligibility: (path) => + path === filename + ? { + kind: 'native-required', + expectation: { filename, reason: 'native-transform', proof: {} }, + } + : { kind: 'disk-first' }, + }) + // When + const generation = await manager.prepare(f.signal) + // Then + expect(generation.pendingOrdinary.map((item) => item.filename)).toEqual([ + filename, + ]) + const resolver = createModuleResolver({ cwd: f.root, ...generation.resolver }) + expect(() => resolver('../df/value', 'app/page.mdx')).toThrow( + 'native upstream bytes', + ) +}) + +it('reports the actual reporting loader when it supplies a relative dependency', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + f.write( + 'reporting-raw.cjs', + `module.exports = function(source) { this.addBuildDependency('relative.json'); return source }`, + ) + // When / Then + await expect(f.manager.prepare(f.signal)).rejects.toThrow( + `relative.json reported by ${join(f.root, 'reporting-raw.cjs')}`, + ) +}) + +it('retains the native dependency alias identity while recording its reported file', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': styledMdx, + 'app/page.mdx.alias': '{}', + }) + f.write( + 'reporting-raw.cjs', + `module.exports = function(source) { if (this.dependency !== this.addDependency) throw new Error('native alias identity changed'); this.dependency(this.resourcePath + '.alias'); require(this.getOptions().plugin).setContext(this); return source }`, + ) + // When + const generation = await f.manager.prepare(f.signal) + // Then + expect(generation.watchInputs).toContain(join(f.root, 'app/page.mdx.alias')) +}) + +it('allows corrected retry after a required compiler failure without mutating old sources', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const first = await f.manager.prepare(f.signal) + f.write('app/page.mdx', ' { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }, {}, true) + const path = f.write( + '../outside-' + f.root.split(/[\\/]/).at(-1) + '.json', + '{"color":"blue"}', + ) + try { + f.pluginOptions.data = path + const first = await f.manager.prepare(f.signal) + f.write(path, '{"color":"green"}') + // When + const next = await f.manager.refresh({ + generation: first, + signal: f.signal, + }) + // Then + expect(first.watchInputs).toContain(path) + expect(f.counts()).toBe(2) + expect(f.css(next)).toContain('color:green') + } finally { + rmSync(path) + } +}) + +it('treats missing and unreadable reported inputs as changes rather than reusing cache', () => { + // Given + const f = sourceFixture({ 'data.json': '{}' }) + const path = join(f.root, 'data.json') + const input = fingerprintMdxInput(path, { + kind: 'build', + path, + loader: 'reporter', + }) + rmSync(path) + // When / Then + expect(changedMdxInput(path, [input])).toBe(input) + expect(() => + fingerprintMdxInput(path, { kind: 'context', path, loader: 'reporter' }), + ).toThrow(path) + expect(() => + fingerprintMdxInput(path, { + kind: 'file', + path: 'relative', + loader: 'reporter', + }), + ).toThrow('not absolute') + expect(() => + fingerprintMdxInput(path, { + kind: 'file', + path: join(f.root, '\u0000invalid'), + loader: 'reporter', + }), + ).toThrow('reporter') +}) + +it('refuses to stamp a reported missing path that appeared during compile', () => { + // Given + const f = sourceFixture({ 'present.json': '{}' }) + const filename = join(f.root, 'page.mdx') + // When / Then + expect(() => + captureMdxFreshness( + { + filename, + source: '', + map: undefined, + dependencies: [], + buildDependencies: [], + contextDependencies: [], + missingDependencies: [join(f.root, 'present.json')], + dependencyReports: [], + }, + { + development: false, + startedAt: 0, + accuracy: createMdxTimestampAccuracy(), + }, + ), + ).toThrow('appeared during compilation') +}) + +it('does not reuse a cache when a reported directory has been deleted', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + mkdirSync(join(f.root, 'app/reported')) + const first = await f.manager.prepare(f.signal) + rmSync(join(f.root, 'app/reported'), { recursive: true }) + // When + await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(f.counts()).toBe(2) +}) + +it('rejects generations from another manager without accepting stale ownership', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const first = await f.manager.prepare(f.signal) + const other = createMdxSourceManager(f.binding) + // When / Then + expect(() => other.restartCache(first)).toThrow('does not belong') + await expect( + other.refresh({ generation: first, signal: f.signal }), + ).rejects.toThrow('does not belong') + await expect( + f.manager.prepare(AbortSignal.abort(new Error('cancelled'))), + ).rejects.toThrow('cancelled') +}) diff --git a/packages/next-plugin/src/__tests__/mdx-source-certification.test.ts b/packages/next-plugin/src/__tests__/mdx-source-certification.test.ts new file mode 100644 index 000000000..6c7ac3825 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-certification.test.ts @@ -0,0 +1,77 @@ +import { join } from 'node:path' + +import { createModuleResolver } from '@devup-ui/plugin-utils' +import { expect, it } from 'bun:test' + +import { immutableGeneration } from '../coordinator-generation' +import { createMdxSourceManager } from '../mdx-source-generation' +import { MdxNativeInputPendingError } from '../mdx-source-pending' +import { sourceFixture, styledMdx } from './mdx-source-fixture' + +it('blocks finalization when extraction reports a new native-required input without reading it', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': styledMdx, + 'df/native.ts': 'invalid until the upstream loader runs', + }) + const filename = join(f.root, 'df/native.ts') + const expectation = { filename, reason: 'upstream-loader', proof: {} } + const manager = createMdxSourceManager({ + ...f.binding, + ordinaryEligibility: (path) => + path === filename + ? { kind: 'native-required', expectation } + : { kind: 'disk-first' }, + async extractDependencies(view, signal) { + const reports = await f.binding.extractDependencies(view, signal) + return reports.map((report) => + report.filename === 'provider.tsx' + ? { ...report, dependencies: [filename] } + : report, + ) + }, + }) + // When + const generation = await manager.prepare(f.signal) + // Then + expect(() => + manager.validateForCssFinalization(immutableGeneration(generation)), + ).toThrow(MdxNativeInputPendingError) + expect(generation.pendingOrdinary).toEqual([expectation]) + expect(generation.watchInputs).toContain(filename) + expect( + generation.ordinaryInputs.some((input) => input.resourcePath === filename), + ).toBe(false) + expect(Object.isFrozen(generation.pendingOrdinary)).toBe(true) +}) + +it('blocks finalization when the published resolver discovers a later native-required input', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': styledMdx, + 'df/native.ts': 'invalid until the upstream loader runs', + }) + const filename = join(f.root, 'df/native.ts') + const manager = createMdxSourceManager({ + ...f.binding, + ordinaryEligibility: (path) => + path === filename + ? { + kind: 'native-required', + expectation: { filename, reason: 'upstream-loader', proof: {} }, + } + : { kind: 'disk-first' }, + }) + const generation = await manager.prepare(f.signal) + const supplied = immutableGeneration(generation) + const snapshot = generation.pendingOrdinary + const resolver = createModuleResolver({ cwd: f.root, ...generation.resolver }) + expect(() => resolver('../df/native', 'app/page.mdx')).toThrow(filename) + // When / Then + expect(() => manager.validateForCssFinalization(supplied)).toThrow( + MdxNativeInputPendingError, + ) + expect(generation.pendingOrdinary).toBe(snapshot) + expect(snapshot).toEqual([]) + expect(Object.isFrozen(snapshot)).toBe(true) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-source-development.test.ts b/packages/next-plugin/src/__tests__/mdx-source-development.test.ts new file mode 100644 index 000000000..1a4c1072c --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-development.test.ts @@ -0,0 +1,86 @@ +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createMdxSourceManager } from '../mdx-source-generation' +import { createWasm, extractWithModuleResolver } from '../wasm' +import { sourceFixture } from './mdx-source-fixture' + +it.each([ + ['md', true, true, 'react/jsx-dev-runtime', '_jsxDEV'], + ['md', true, false, 'react/jsx-dev-runtime', '_jsxDEV'], + ['mdx', true, true, 'react/jsx-dev-runtime', '_jsxDEV'], + ['mdx', true, false, 'react/jsx-dev-runtime', '_jsxDEV'], + ['mdx', false, true, 'react/jsx-runtime', '_jsx'], +] as const)( + 'extracts installed automatic-runtime .%s when development=%s and sourceMap=%s', + async (extension, development, sourceMap, runtime, call) => { + // Given: caller-authored options precede preparation, not a compiler override. + const filename = `app/page.${extension}` + const f = sourceFixture( + { + [filename]: `import { Box } from '@devup-ui/react' + +# Development styles + + +`, + }, + {}, + development, + ) + f.compilerOptions.jsx = false + const manager = createMdxSourceManager({ + ...f.binding, + async selectPipeline(path, signal) { + const selected = await f.binding.selectPipeline(path, signal) + return selected + ? { ...selected, context: { ...selected.context, sourceMap } } + : undefined + }, + }) + + // When: preparation executes the installed chain before real WASM extraction. + const generation = await manager.prepare(f.signal) + const engine = createWasm(resolve(import.meta.dir, '../../../..')) + generation.configureWasm(engine) + + // Then: one resource compile, original IDs, actual runtime calls and static CSS. + expect(f.counts()).toBe(1) + expect(generation.pendingOrdinary).toEqual([]) + expect(Object.keys(generation.compiled)).toEqual([join(f.root, filename)]) + expect(generation.sources.map(({ input }) => input.filename)).toEqual([ + filename, + ]) + for (const { input } of generation.sources) { + expect(input.source).toContain(runtime) + expect(input.source).toContain(`${call}(Box,`) + const prepared = generation.compiled[join(f.root, filename)]?.prepared + expect(prepared?.filename).toBe(join(f.root, filename)) + if (sourceMap) expect(prepared?.map).toBeDefined() + else expect(prepared?.map).toBeUndefined() + + using output = extractWithModuleResolver(engine, sourceMap, [ + input.filename, + input.source, + '@devup-ui/react', + './df', + true, + false, + false, + {}, + ]) + expect(output.code).toContain(`${call}("div",`) + expect(output.code).toContain('className:') + expect(output.code).not.toContain('@devup-ui/react') + expect(output.code).not.toMatch(/\bBox\b/) + expect(output.code).not.toMatch(/\b(?:bg|p|_hover|style):/) + if (sourceMap) expect(output.map).toBeDefined() + else expect(output.map).toBeUndefined() + } + expect(engine.getCss(null, false)).toContain('background:red') + expect(engine.getCss(null, false)).toContain('padding:16px') + expect(engine.getCss(null, false)).toMatch(/:hover\{background:blue\}/) + expect(f.counts()).toBe(1) + }, +) diff --git a/packages/next-plugin/src/__tests__/mdx-source-fixture.ts b/packages/next-plugin/src/__tests__/mdx-source-fixture.ts new file mode 100644 index 000000000..e31ad4d40 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-fixture.ts @@ -0,0 +1,224 @@ +import { createHash } from 'node:crypto' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import { afterEach } from 'bun:test' + +import { composeMdxRules, requireMdxPipeline } from '../mdx-pipeline' +import { createMdxSourceManager } from '../mdx-source-generation' +import type { MdxBuildBinding, MdxExtractionView } from '../mdx-source-types' +import { createAppContext } from '../session' +import { + createWasm, + extractWithModuleResolver, + withModuleResolver, +} from '../wasm' +import { extractionResolutionProof } from '../wasm-resolution-proof' +import { + releaseSourceReporter, + type ReporterOptions, + sourceReporter, +} from './mdx-source-reporter' + +const workspace = resolve(import.meta.dir, '../../../..') +const installedRoot = join(workspace, 'apps/landing') +const installed = createRequire(join(installedRoot, 'package.json')) +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + releaseSourceReporter(root) + rmSync(root, { recursive: true, force: true }) + } +}) + +export function sourceFixture( + files: Record, + overrides: Partial = {}, + development = false, +) { + const root = mkdtempSync(join(tmpdir(), 'devup-ui-mdx-source-')) + roots.push(root) + symlinkSync( + join(installedRoot, 'node_modules'), + join(root, 'node_modules'), + 'junction', + ) + function write(file: string, source: string, old = true) { + const path = resolve(root, file) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, source) + if (old) + utimesSync(path, new Date(1_700_000_000_000), new Date(1_700_000_000_000)) + return path + } + write('package.json', '{}') + for (const [file, source] of Object.entries(files)) write(file, source) + const reporter = sourceReporter(root) + const reporterFile = resolve(import.meta.dir, 'mdx-source-reporter.ts') + const reporterRequest = `require(${JSON.stringify(reporterFile)}).sourceReporter(${JSON.stringify(root)})` + const plugin = write( + 'reporting-remark.cjs', + `module.exports = ${reporterRequest}.remark`, + ) + const raw = write( + 'reporting-raw.cjs', + `module.exports = ${reporterRequest}.raw`, + ) + const pluginOptions: ReporterOptions = {} + const compilerOptions = { + jsx: true, + format: 'mdx', + providerImportSource: 'provider', + remarkPlugins: [[plugin, pluginOptions]], + } + const composed = composeMdxRules( + { + bundler: 'webpack', + rules: [ + { + use: [ + { + loader: installed.resolve('@next/mdx/mdx-js-loader'), + options: compilerOptions, + }, + { loader: raw, options: { plugin } }, + ], + }, + ], + aliases: {}, + }, + { loader: 'devup' }, + ) + const pipeline = requireMdxPipeline(root, composed.pipelines[0]) + const cwd = process.cwd() + const env = process.env.NODE_ENV + process.chdir(root) + process.env.NODE_ENV = development ? 'development' : 'production' + const context = createAppContext( + { pageExtensions: ['tsx', 'mdx', 'md'] }, + { singleCss: true }, + ) + process.chdir(cwd) + process.env.NODE_ENV = env + const aliases = { + provider$: write( + 'provider.tsx', + 'export function useMDXComponents() { return {} }', + ), + } + const owner = {} + let extractionCalls = 0 + function configure( + engine: ReturnType, + view: MdxExtractionView, + ) { + withModuleResolver(engine, root, view.resolver) + engine.importCanonicalMap(view.plan.canonicalMap) + engine.seedFileMap([...view.plan.seedFiles]) + } + const binding: MdxBuildBinding = Object.freeze({ + effectiveAppContext: context, + extensions: ['.md', '.mdx'], + aliases, + conditions: ['import', 'node'], + configFile: join(root, 'next.config.mjs'), + async selectPipeline() { + return { + pipeline, + context: { + owner, + generation: owner, + compiler: owner, + sourceMap: true, + mode: development ? 'development' : 'production', + }, + identity: pipeline.loaders.map((loader) => ({ + path: loader.loader, + hash: createHash('sha256') + .update(readFileSync(loader.loader)) + .digest('hex'), + options: loader.options, + })), + } + }, + ordinaryEligibility: () => ({ kind: 'disk-first' }), + configureWasm: configure, + async extractDependencies(view, signal) { + extractionCalls += 1 + signal.throwIfAborted() + const engine = createWasm(workspace) + configure(engine, view) + return view.inputs.map((input) => { + const output = extractWithModuleResolver(engine, false, [ + input.filename, + input.source, + '@devup-ui/react', + './df', + true, + false, + false, + {}, + ]) + try { + return { + filename: input.filename, + dependencies: [...output.dependencies], + resolutionInputs: extractionResolutionProof(output), + } + } finally { + output.free() + } + }) + }, + ...overrides, + }) + return { + root, + write, + binding, + pipeline, + compilerOptions, + pluginOptions, + manager: createMdxSourceManager(binding), + signal: new AbortController().signal, + extractionCalls: () => extractionCalls, + counts: reporter.counts, + css( + generation: Awaited< + ReturnType['prepare']> + >, + ) { + const engine = createWasm(workspace) + generation.configureWasm(engine) + for (const input of generation.inputs) + extractWithModuleResolver(engine, false, [ + input.filename, + input.source, + '@devup-ui/react', + './df', + true, + false, + false, + {}, + ]).free() + return engine.getCss(null, false) + }, + } +} + +export const styledMdx = `import { Box, css } from '@devup-ui/react' + +# Test + + +` diff --git a/packages/next-plugin/src/__tests__/mdx-source-generation.test.ts b/packages/next-plugin/src/__tests__/mdx-source-generation.test.ts new file mode 100644 index 000000000..daa7838f8 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-generation.test.ts @@ -0,0 +1,205 @@ +import { join } from 'node:path' + +import { createModuleResolver } from '@devup-ui/plugin-utils' +import { expect, it } from 'bun:test' + +import { createMdxSourceManager } from '../mdx-source-generation' +import { sourceFixture, styledMdx } from './mdx-source-fixture' + +it('prepares only reached MDX when an unreachable compiler-invalid file exists', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': styledMdx, + 'dead.mdx': ' { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx, 'dead.mdx': ' { + // Given + const f = sourceFixture({ + 'app/page.mdx': '# No source styles', + 'app/data.json': '{"color":"orange"}', + }) + // When + const generation = await f.manager.prepare(f.signal) + // Then + expect(generation.plan.seedFiles).toContain('app/page.mdx') + expect(f.css(generation)).toContain('color:orange') +}) + +it('closes compiler-injected provider and MDX edges through shared graph iterations', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': styledMdx, + 'nested.mdx': styledMdx, + }) + f.write( + 'provider.tsx', + `import './nested.mdx'; import { Box } from '@devup-ui/react'; export function useMDXComponents() { return { h1: () => } }`, + ) + // When + const generation = await f.manager.prepare(f.signal) + // Then + expect(f.counts()).toBe(2) + expect(generation.plan.expectedBaseFiles).toEqual( + expect.arrayContaining(['app/page.mdx', 'nested.mdx', 'provider.tsx']), + ) + expect(f.css(generation)).toContain('padding:12px') +}) + +it('numbers compiler-injected style sources and preserves ordinary imports under output basenames', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': `import { css } from '@devup-ui/react'\n\nimport { value } from '../src/build/value'\n\nexport const style = css({background: value})\n\n# Test`, + 'app/data.json': '{"color":"green"}', + 'src/build/value.ts': "export const value = 'yellow'", + 'src/df/source.ts': 'export const source = 1', + 'df/broken.mdx': ' { + // Given + const f = sourceFixture({ + 'app/page.tsx': `import { css } from '@devup-ui/react'; import { color } from './value.md'; export const style = css({color})`, + 'app/value.md': `export const color = 'blue'\n\n# Value`, + }) + f.compilerOptions.jsx = false + // When + const generation = await f.manager.prepare(f.signal) + // Then + expect(generation.sources[0]?.input.filename).toBe('app/value.md') + expect(f.css(generation)).toContain('color:blue') +}) + +it('imports JSX-preserving compiled Markdown under its original identity without changing native options', async () => { + // Given + const f = sourceFixture({ + 'app/page.tsx': `import { css } from '@devup-ui/react'; import { color } from './value.md'; export const style = css({color})`, + 'app/value.md': `export const color = 'blue'\n\n# Value`, + }) + // When + const generation = await f.manager.prepare(f.signal) + // Then + expect(f.compilerOptions.jsx).toBe(true) + expect(generation.sources[0]?.input.filename).toBe('app/value.md') + expect(generation.sources[0]?.input.source).toMatch(/<[_A-Za-z]/) + expect(f.css(generation)).toContain('color:blue') +}) + +it('returns native-only ordinary expectations without waiting for future loader bytes', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const proof = {} + const manager = createMdxSourceManager({ + ...f.binding, + ordinaryEligibility: (filename) => + filename === join(f.root, 'provider.tsx') + ? { + kind: 'native-required', + expectation: { filename, reason: 'upstream-loader', proof }, + } + : { kind: 'disk-first' }, + }) + // When + const generation = await manager.prepare(f.signal) + // Then + expect(generation.pendingOrdinary).toHaveLength(1) + expect( + generation.ordinaryInputs.some( + (input) => input.filename === 'provider.tsx', + ), + ).toBe(false) + expect(f.extractionCalls()).toBe(0) + expect(generation.sources).toHaveLength(1) + expect(() => manager.validateForCssFinalization(generation)).toThrow( + 'genuine pre-Devup loader input', + ) +}) + +it('lets the shared resolver locate unmatched-rule Markdown rather than reading it raw', async () => { + // Given + const f = sourceFixture( + { + 'app/page.tsx': `import { color } from './value.mdx'; export default ()=>color`, + 'app/value.mdx': `export const color = 'blue'`, + }, + { selectPipeline: async () => undefined }, + ) + // When / Then + await expect(f.manager.prepare(f.signal)).rejects.toThrow( + `${join(f.root, 'app/page.tsx')}:1:1: module source preparation`, + ) +}) + +it('never serves excluded compiled records while ordinary output dependencies still resolve', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': styledMdx, + 'df/value.ts': 'export const value = 4', + 'df/value.mdx': 'export const value = 9', + }) + const generation = await f.manager.prepare(f.signal) + const resolver = createModuleResolver({ cwd: f.root, ...generation.resolver }) + // When / Then + expect(resolver('../df/value.ts', 'app/page.mdx')?.code).toContain('4') + expect(() => resolver('../df/value.mdx', 'app/page.mdx')).toThrow( + 'Markdown source has no prepared JavaScript', + ) +}) + +it('accepts other configured extensions with the merged explicit source type API', async () => { + // Given + const f = sourceFixture( + { + 'app/page.tsx': `import './content.mdown'; export default ()=>null`, + 'app/content.mdown': styledMdx, + }, + { extensions: ['.mdown'] }, + ) + // When + const generation = await f.manager.prepare(f.signal) + // Then + expect(generation.sources[0]?.input.filename).toBe('app/content.mdown') + expect(f.css(generation)).toContain('background:red') +}) diff --git a/packages/next-plugin/src/__tests__/mdx-source-options.test.ts b/packages/next-plugin/src/__tests__/mdx-source-options.test.ts new file mode 100644 index 000000000..046431b16 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-options.test.ts @@ -0,0 +1,43 @@ +import { createRequire } from 'node:module' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { sourceFixture, styledMdx } from './mdx-source-fixture' + +function observations(root: string): readonly unknown[] { + const raw: unknown = createRequire(join(root, 'package.json'))( + join(root, 'reporting-raw.cjs'), + ) + if (typeof raw !== 'function' || !('seen' in raw) || !Array.isArray(raw.seen)) + throw new TypeError('Missing native options observations') + return raw.seen +} + +it('reuses one actual native options instance across stable pipelines in a preparation run', async () => { + // Given + const f = sourceFixture({ + 'app/a/page.mdx': styledMdx, + 'app/b/page.mdx': styledMdx, + }) + // When + await f.manager.prepare(f.signal) + const seen = observations(f.root) + // Then + expect(seen).toHaveLength(2) + expect(seen[0]).toBe(seen[1]) +}) + +it('creates a new actual options instance for a changed preparation run', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const first = await f.manager.prepare(f.signal) + const original = observations(f.root)[0] + f.write('app/page.mdx', styledMdx.replace('red', 'green')) + // When + await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + const seen = observations(f.root) + expect(seen).toHaveLength(2) + expect(seen[1]).not.toBe(original) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-source-plan-refresh.test.ts b/packages/next-plugin/src/__tests__/mdx-source-plan-refresh.test.ts new file mode 100644 index 000000000..46493747a --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-plan-refresh.test.ts @@ -0,0 +1,89 @@ +import { rmSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createMdxSourceManager } from '../mdx-source-generation' +import { sourceFixture } from './mdx-source-fixture' + +it.each(['addition', 'removal'] as const)( + 'replaces the numbering plan when an unreachable styled source changes by %s', + async (change) => { + // Given + const source = `import { Box } from '@devup-ui/react'; export const card = ` + const f = sourceFixture({ + 'app/page.tsx': 'export default () => null', + ...(change === 'removal' ? { 'unused.tsx': source } : {}), + }) + const first = await f.manager.prepare(f.signal) + switch (change) { + case 'addition': + f.write('unused.tsx', source) + break + case 'removal': + rmSync(join(f.root, 'unused.tsx')) + break + default: + change satisfies never + } + // When + const next = await f.manager.refresh({ + generation: first, + signal: f.signal, + }) + // Then + expect(next.plan.seedFiles.includes('unused.tsx')).toBe( + change === 'addition', + ) + expect(next).not.toBe(first) + expect(next.plan.expectedBaseFiles).toEqual(first.plan.expectedBaseFiles) + expect(Object.isFrozen(next.plan.seedFiles)).toBe(true) + }, +) + +it('replaces canonical buckets when an unreachable importer changes collapse without changing route inputs', async () => { + // Given + const f = sourceFixture({ + 'app/page.tsx': `import { value } from '../shared'; export default () => value`, + 'shared.ts': 'export const value = 1', + }) + const first = await f.manager.prepare(f.signal) + f.write('unused.ts', `import { value } from './shared'; export { value }`) + // When + const next = await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(next.plan.canonicalMap).not.toEqual(first.plan.canonicalMap) + expect(next.plan.expectedBaseFiles).toEqual(first.plan.expectedBaseFiles) + expect(next).not.toBe(first) +}) + +it.each(['routes', 'threshold'] as const)( + 'replaces the hoist plan when its %s change without changing source bytes', + async (field) => { + // Given + const f = sourceFixture({ + 'app/a/page.tsx': `import '../../shared'; export default () => null`, + 'app/b/page.tsx': `import '../../shared'; export default () => null`, + 'shared.tsx': `import { Box } from '@devup-ui/react'; export const card = `, + }) + let threshold = field === 'routes' ? undefined : 2 + const manager = createMdxSourceManager({ + ...f.binding, + effectiveAppContext: { + ...f.binding.effectiveAppContext, + get atomHoist() { + return threshold + }, + }, + }) + const first = await manager.prepare(f.signal) + threshold = field === 'routes' ? 2 : 3 + // When + const next = await manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(next.plan.atomThreshold).toBe(threshold) + expect(next).not.toBe(first) + expect(next.plan.expectedBaseFiles).toEqual(first.plan.expectedBaseFiles) + expect(next.plan.fileRoutes['shared.tsx']).toEqual([0, 1]) + }, +) diff --git a/packages/next-plugin/src/__tests__/mdx-source-portability.test.ts b/packages/next-plugin/src/__tests__/mdx-source-portability.test.ts new file mode 100644 index 000000000..23a1cb26c --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-portability.test.ts @@ -0,0 +1,17 @@ +import { tmpdir } from 'node:os' +import { dirname } from 'node:path' + +import { expect, it } from 'bun:test' + +import { sourceFixture } from './mdx-source-fixture' + +it('creates compiler fixtures under the platform temporary directory', () => { + // Given the installed compiler fixture and the current platform's temporary root. + const root = tmpdir() + + // When the fixture creates its isolated project. + const fixture = sourceFixture({ 'app/page.tsx': 'export default () => null' }) + + // Then it is independent of a particular user's Windows workspace. + expect(dirname(fixture.root)).toBe(root) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-source-race.test.ts b/packages/next-plugin/src/__tests__/mdx-source-race.test.ts new file mode 100644 index 000000000..d0e7348a1 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-race.test.ts @@ -0,0 +1,90 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createMdxSourceManager } from '../mdx-source-generation' +import { sourceFixture, styledMdx } from './mdx-source-fixture' + +it.each(['app/page.mdx', 'provider.tsx'])( + 'rejects when %s changes after real extraction but before delivery', + async (path) => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const manager = createMdxSourceManager({ + ...f.binding, + async extractDependencies(view, signal) { + const reports = await f.binding.extractDependencies(view, signal) + f.write(path, 'export const changed = true') + return reports + }, + }) + // When / Then + await expect(manager.prepare(f.signal)).rejects.toThrow(join(f.root, path)) + }, +) + +it('rejects changed ordinary route bytes that are not owned by an MDX importer', async () => { + // Given + const f = sourceFixture({ + 'app/page.tsx': `import { Box } from '@devup-ui/react'; export default ()=> `, + }) + const manager = createMdxSourceManager({ + ...f.binding, + async extractDependencies(view, signal) { + const reports = await f.binding.extractDependencies(view, signal) + f.write('app/page.tsx', 'export default ()=>null') + return reports + }, + }) + // When / Then + await expect(manager.prepare(f.signal)).rejects.toThrow( + join(f.root, 'app/page.tsx'), + ) +}) + +it('fingerprints and watches ordinary data dependencies without classifying them as compiled MDX', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': styledMdx, + 'df/data.json': '{"color":"blue"}', + }) + const path = join(f.root, 'df/data.json') + const manager = createMdxSourceManager({ + ...f.binding, + async extractDependencies(view, signal) { + const reports = await f.binding.extractDependencies(view, signal) + return reports.map((report) => + report.filename === 'provider.tsx' + ? { ...report, dependencies: [path] } + : report, + ) + }, + }) + // When + const generation = await manager.prepare(f.signal) + // Then + expect(generation.watchInputs).toContain(path) + expect(generation.sources).toHaveLength(1) + expect( + generation.ordinaryInputs.find((input) => input.filename === 'provider.tsx') + ?.stamps[path], + ).toBeDefined() +}) + +it('keeps directory listings sensitive to names and entry kinds', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': styledMdx, + 'app/reported/a': 'a', + 'app/reported/b': 'b', + }) + const first = await f.manager.prepare(f.signal) + f.write('app/reported/c', 'c') + // When + const next = await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(f.counts()).toBe(2) + expect( + next.compiled[join(f.root, 'app/page.mdx')]?.prepared.source, + ).toContain('a,b,c') +}) diff --git a/packages/next-plugin/src/__tests__/mdx-source-refresh.test.ts b/packages/next-plugin/src/__tests__/mdx-source-refresh.test.ts new file mode 100644 index 000000000..19dd3f18c --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-refresh.test.ts @@ -0,0 +1,217 @@ +import { mkdirSync, utimesSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { MdxFreshnessError } from '../mdx-source-freshness' +import { + affectedMdxSources, + createMdxSourceManager, +} from '../mdx-source-generation' +import { sourceFixture, styledMdx } from './mdx-source-fixture' + +it('changes CSS and compiles once when a real remark plugin reports changed data', async () => { + // Given + const f = sourceFixture( + { 'app/page.mdx': styledMdx, 'app/data.json': '{"color":"purple"}' }, + {}, + true, + ) + const first = await f.manager.prepare(f.signal) + f.write('app/data.json', '{"color":"green"}') + // When + const next = await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(f.counts()).toBe(2) + expect(f.css(next)).toContain('color:green') + expect(f.css(first)).toContain('color:purple') +}) + +it('reuses unchanged generation without compilation or extraction callbacks', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }, {}, true) + const first = await f.manager.prepare(f.signal) + const calls = f.extractionCalls() + // When + const next = await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(next).toBe(first) + expect(f.counts()).toBe(1) + expect(f.extractionCalls()).toBe(calls) +}) + +it('refreshes only affected modules when one of two route resources changes', async () => { + // Given + const f = sourceFixture( + { 'app/a/page.mdx': styledMdx, 'app/b/page.mdx': styledMdx }, + {}, + true, + ) + const first = await f.manager.prepare(f.signal) + f.write('app/a/page.mdx', styledMdx.replace('red', 'blue')) + // When + const next = await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(f.counts()).toBe(3) + expect(next.compiled[join(f.root, 'app/b/page.mdx')]?.prepared).toBe( + first.compiled[join(f.root, 'app/b/page.mdx')]?.prepared, + ) + expect(f.css(next)).toContain('background:blue') +}) + +it.each(['directory', 'missing', 'build'] as const)( + 'reprepares when a reported %s input changes', + async (kind) => { + // Given + const f = sourceFixture( + { 'app/page.mdx': styledMdx, 'app/build.json': '{}' }, + {}, + true, + ) + const directory = join(f.root, 'app/reported') + mkdirSync(directory) + utimesSync( + directory, + new Date(1_700_000_000_000), + new Date(1_700_000_000_000), + ) + const first = await f.manager.prepare(f.signal) + const changed = + kind === 'directory' + ? f.write('app/reported/new.txt', 'new') + : kind === 'missing' + ? f.write('app/optional.json', '{}') + : f.write('app/build.json', '{"changed":true}') + if (kind === 'directory') + utimesSync( + directory, + new Date(1_700_000_000_000), + new Date(1_700_000_000_000), + ) + // When + const next = await f.manager.refresh({ + generation: first, + signal: f.signal, + }) + // Then + expect(f.counts()).toBe(2) + expect(next).not.toBe(first) + expect(affectedMdxSources(first, changed)).toEqual([ + join(f.root, 'app/page.mdx'), + ]) + }, +) + +it('rejects a dev result when a reported input is modified during its single compile', async () => { + // Given + const f = sourceFixture( + { 'app/page.mdx': styledMdx, 'app/data.json': '{"color":"green"}' }, + {}, + true, + ) + f.pluginOptions.before = async () => { + const input = f.write('app/data.json', '{"color":"blue"}', false) + // NTFS write timestamps can lag the compile clock by a Windows clock tick. + const changedAt = new Date() + utimesSync(input, changedAt, changedAt) + } + // When / Then + await expect(f.manager.prepare(f.signal)).rejects.toBeInstanceOf( + MdxFreshnessError, + ) + expect(f.counts()).toBe(1) +}) + +it('accepts a pregenerated production input but rejects changes before CSS finalization', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': styledMdx, + 'app/data.json': '{"color":"green"}', + }) + f.write('app/data.json', '{"color":"blue"}', false) + const first = await f.manager.prepare(f.signal) + f.manager.validateForCssFinalization(first) + f.write('app/data.json', '{"color":"purple"}') + // When / Then + expect(() => f.manager.validateForCssFinalization(first)).toThrow( + join(f.root, 'app/data.json'), + ) +}) + +it('reprepares an MDX importer when an ordinary resolver dependency changes outside discovery outputs', async () => { + // Given + const f = sourceFixture( + { + 'app/page.mdx': `import { css } from '@devup-ui/react'\n\nimport { color } from '../df/value'\n\nexport const style = css({color})\n\n# Test`, + 'df/value.ts': "export const color = 'blue'", + }, + {}, + true, + ) + const first = await f.manager.prepare(f.signal) + f.write('df/value.ts', "export const color = 'green'") + // When + const next = await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(f.counts()).toBe(2) + expect(first.watchInputs).toContain(join(f.root, 'df/value.ts')) + expect( + next.ordinaryInputs.some((input) => input.filename === 'df/value.ts'), + ).toBe(true) + expect(f.css(next)).toContain('color:green') +}) + +it('validates every reported input after a serialized private cache restart', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': styledMdx, + 'app/data.json': '{"color":"blue"}', + 'app/build.json': '{}', + }) + const first = await f.manager.prepare(f.signal) + const saved = f.manager.restartCache(first) + const restarted = createMdxSourceManager(f.binding, saved) + await restarted.prepare(f.signal) + expect(f.counts()).toBe(1) + f.write('app/build.json', '{"updated":true}') + // When + const next = await createMdxSourceManager(f.binding, saved).prepare(f.signal) + // Then + expect(f.counts()).toBe(2) + expect(f.css(next)).toContain('color:blue') +}) + +it('invalidates function-bearing pipeline identity instead of serializing it across restart', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + f.pluginOptions.before = async () => {} + const first = await f.manager.prepare(f.signal) + const saved = f.manager.restartCache(first) + // When + await createMdxSourceManager(f.binding, saved).prepare(f.signal) + // Then + expect(JSON.parse(saved)).toEqual([]) + expect(f.counts()).toBe(2) +}) + +it('does not replace a predecessor when cancellation retires an active compilation', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const first = await f.manager.prepare(f.signal) + const started = Promise.withResolvers() + const release = Promise.withResolvers() + f.pluginOptions.before = async () => { + started.resolve() + await release.promise + } + f.write('app/page.mdx', styledMdx.replace('red', 'green')) + const abort = new AbortController() + const pending = f.manager.refresh({ generation: first, signal: abort.signal }) + await started.promise + // When + abort.abort(new Error('retired')) + release.resolve() + // Then + await expect(pending).rejects.toThrow('retired') + expect(first.sources[0]?.input.source).toContain('red') +}) diff --git a/packages/next-plugin/src/__tests__/mdx-source-replay-api.test.ts b/packages/next-plugin/src/__tests__/mdx-source-replay-api.test.ts new file mode 100644 index 000000000..48eba9163 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-replay-api.test.ts @@ -0,0 +1,46 @@ +import { expect, it } from 'bun:test' + +import { immutableGeneration } from '../coordinator-generation' +import { sourceFixture, styledMdx } from './mdx-source-fixture' + +it('returns the supplied Core copy unchanged when all generation inputs are fresh', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const original = await f.manager.prepare(f.signal) + const supplied = immutableGeneration(original) + // When + const next = await f.manager.refresh({ + generation: supplied, + signal: f.signal, + }) + // Then + expect(next).toBe(supplied) + expect(f.counts()).toBe(1) +}) + +it('refreshes a Core copy to a complete new generation through the existing public replay type', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const original = await f.manager.prepare(f.signal) + const supplied = immutableGeneration(original) + f.write('app/page.mdx', styledMdx.replace('red', 'blue')) + // When + const next = await f.manager.refresh({ + generation: supplied, + signal: f.signal, + }) + // Then + expect(next.sources[0]?.input.source).toContain('blue') + expect(f.counts()).toBe(2) +}) + +it('rejects changed source bytes even when a supplied copy reuses the owned configurer', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const original = await f.manager.prepare(f.signal) + const supplied = { ...immutableGeneration(original), sources: [] } + // When / Then + await expect( + f.manager.refresh({ generation: supplied, signal: f.signal }), + ).rejects.toThrow('does not belong') +}) diff --git a/packages/next-plugin/src/__tests__/mdx-source-reporter-style.ts b/packages/next-plugin/src/__tests__/mdx-source-reporter-style.ts new file mode 100644 index 000000000..4fc1f7f63 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-reporter-style.ts @@ -0,0 +1,85 @@ +export type ReporterNode = { + readonly type: string + readonly value?: string + readonly children?: readonly ReporterNode[] + readonly data?: { readonly estree: object } +} + +export type ReporterTree = { readonly children: ReporterNode[] } + +export function appendReportedStyle(tree: ReporterTree, color: unknown): void { + if ( + !tree.children.some( + (child) => child.type === 'mdxjsEsm' && child.value?.includes('css'), + ) + ) + tree.children.unshift({ + type: 'mdxjsEsm', + value: '', + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ImportDeclaration', + source: { type: 'Literal', value: '@devup-ui/react' }, + specifiers: [ + { + type: 'ImportSpecifier', + local: { type: 'Identifier', name: 'css' }, + imported: { type: 'Identifier', name: 'css' }, + }, + ], + }, + ], + }, + }, + }) + tree.children.push({ + type: 'mdxjsEsm', + value: '', + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ExportNamedDeclaration', + specifiers: [], + declaration: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'injectedStyle' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'css' }, + arguments: [ + { + type: 'ObjectExpression', + properties: [ + { + type: 'Property', + kind: 'init', + computed: false, + method: false, + shorthand: false, + key: { type: 'Identifier', name: 'color' }, + value: { type: 'Literal', value: color }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }) +} diff --git a/packages/next-plugin/src/__tests__/mdx-source-reporter.test.ts b/packages/next-plugin/src/__tests__/mdx-source-reporter.test.ts new file mode 100644 index 000000000..840ddb8b9 --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-reporter.test.ts @@ -0,0 +1,116 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { + compileMdx, + createMdxDeadline, + createMdxOptionsInstance, +} from '../mdx-prepare' +import { sourceFixture, styledMdx } from './mdx-source-fixture' + +it('rejects reporting when the native raw loader never supplied a resource context', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const filename = join(f.root, 'app/page.mdx') + const pipeline = { ...f.pipeline, loaders: f.pipeline.loaders.slice(0, 1) } + // When + const pending = compileMdx({ + root: f.root, + filename, + pipeline, + signal: f.signal, + deadline: createMdxDeadline(), + }) + // Then + await expect(pending).rejects.toMatchObject({ + cause: { resource: filename }, + }) + expect(f.counts()).toBe(1) +}) + +it.each([false, true])( + 'keeps overlapping reporter dependencies resource-local when fixtures are separate: %s', + async (separate) => { + // Given + const left = sourceFixture({ + 'app/a/page.mdx': styledMdx, + 'app/a/data.json': '{"color":"blue"}', + 'app/a/reported/left.txt': 'left', + 'app/a/build.json': '{}', + }) + const right = separate ? sourceFixture({}) : left + right.write('app/b/page.mdx', styledMdx) + right.write('app/b/data.json', '{"color":"orange"}') + right.write('app/b/reported/right.txt', 'right') + right.write('app/b/optional.json', '{}') + right.write('app/b/build.json', '{}') + const a = join(left.root, 'app/a/page.mdx') + const b = join(right.root, 'app/b/page.mdx') + const started = Promise.withResolvers() + const release = Promise.withResolvers() + left.pluginOptions.before = async (filename) => { + if (filename === a) { + started.resolve() + await release.promise + } + } + const optionsInstance = createMdxOptionsInstance() + const deadline = createMdxDeadline() + + // When: the second native compilation completes while the first plugin awaits. + const pending = compileMdx({ + root: left.root, + filename: a, + pipeline: left.pipeline, + signal: left.signal, + deadline, + optionsInstance, + }) + const [first, outcome] = await Promise.all([ + pending, + (async () => { + try { + await Promise.race([started.promise, pending]) + return await compileMdx({ + root: right.root, + filename: b, + pipeline: right.pipeline, + signal: right.signal, + deadline, + optionsInstance, + }) + } finally { + release.resolve() + } + })(), + ]) + + // Then: each result owns only its resource-directory reports and one compile. + expect(first.dependencies).toContain(join(left.root, 'app/a/data.json')) + expect(outcome.dependencies).toContain(join(right.root, 'app/b/data.json')) + expect(outcome.dependencies).not.toContain( + join(left.root, 'app/a/data.json'), + ) + expect(first.contextDependencies).toEqual([ + join(left.root, 'app/a/reported'), + ]) + expect(outcome.contextDependencies).toEqual([ + join(right.root, 'app/b/reported'), + ]) + expect(first.missingDependencies).toEqual([ + join(left.root, 'app/a/optional.json'), + ]) + expect(outcome.dependencies).toContain( + join(right.root, 'app/b/optional.json'), + ) + expect(first.buildDependencies).toContain( + join(left.root, 'app/a/build.json'), + ) + expect(outcome.buildDependencies).toContain( + join(right.root, 'app/b/build.json'), + ) + expect(left.counts()).toBe(separate ? 1 : 2) + if (separate) expect(right.counts()).toBe(1) + }, +) diff --git a/packages/next-plugin/src/__tests__/mdx-source-reporter.ts b/packages/next-plugin/src/__tests__/mdx-source-reporter.ts new file mode 100644 index 000000000..ea98c824b --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-reporter.ts @@ -0,0 +1,111 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import type { LoaderContext } from 'webpack' + +import { + appendReportedStyle, + type ReporterTree, +} from './mdx-source-reporter-style' + +type ReporterContext = Pick< + LoaderContext>, + | 'resourcePath' + | 'loaders' + | 'addDependency' + | 'addContextDependency' + | 'addMissingDependency' + | 'addBuildDependency' +> + +export type ReporterOptions = { + before?: (filename: string) => Promise + data?: string +} + +type ReporterRemark = (( + options: ReporterOptions, +) => (tree: ReporterTree, file: { readonly path: string }) => Promise) & { + readonly setContext: (context: ReporterContext) => void +} + +type SourceReporter = { + readonly remark: ReporterRemark + readonly raw: (( + this: ReporterContext, + source: string | Buffer, + ) => string | Buffer) & { readonly seen: unknown[] } + readonly counts: () => number +} + +class ReporterContextError extends Error { + constructor(readonly resource: string) { + super(`Missing reporting loader context for ${resource}`) + } +} + +const reporters = new Map() + +export function sourceReporter(root: string): SourceReporter { + const existing = reporters.get(root) + if (existing) return existing + const contexts = new Map() + const counts = new Map() + const seen: unknown[] = [] + const setContext = (context: ReporterContext) => { + contexts.set(context.resourcePath, context) + } + function remark(options: ReporterOptions) { + return async (tree: ReporterTree, file: { readonly path: string }) => { + counts.set(file.path, (counts.get(file.path) ?? 0) + 1) + const context = contexts.get(file.path) + if (!context) throw new ReporterContextError(file.path) + await options.before?.(file.path) + const directory = dirname(file.path) + const data = options.data || join(directory, 'data.json') + if (existsSync(data)) { + context.addDependency(data) + const value: unknown = JSON.parse(readFileSync(data, 'utf8')) + appendReportedStyle( + tree, + typeof value === 'object' && value !== null && 'color' in value + ? value.color + : undefined, + ) + } + const reported = join(directory, 'reported') + if (existsSync(reported)) { + context.addContextDependency(reported) + tree.children.push({ + type: 'paragraph', + children: [{ type: 'text', value: readdirSync(reported).join(',') }], + }) + } + const missing = join(directory, 'optional.json') + if (existsSync(missing)) context.addDependency(missing) + else context.addMissingDependency(missing) + const build = join(directory, 'build.json') + if (existsSync(build)) context.addBuildDependency(build) + } + } + function raw(this: ReporterContext, source: string | Buffer) { + seen.push(this.loaders[0]?.options) + setContext(this) + return source + } + const reporter: SourceReporter = { + remark: Object.assign(remark, { setContext }), + raw: Object.assign(raw, { seen }), + counts: () => { + let total = 0 + for (const count of counts.values()) total += count + return total + }, + } + reporters.set(root, reporter) + return reporter +} + +export function releaseSourceReporter(root: string): void { + reporters.delete(root) +} diff --git a/packages/next-plugin/src/__tests__/mdx-source-requests.test.ts b/packages/next-plugin/src/__tests__/mdx-source-requests.test.ts new file mode 100644 index 000000000..90259ad3d --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-requests.test.ts @@ -0,0 +1,225 @@ +import { join } from 'node:path' + +import { buildStaticImportGraph } from '@devup-ui/plugin-utils' +import { expect, it } from 'bun:test' + +import { createMdxSourceManager } from '../mdx-source-generation' +import { immutableMdxPlan } from '../mdx-source-immutable' +import { expectedJoinInputs } from './join-resolution-inputs' +import { sourceFixture, styledMdx } from './mdx-source-fixture' + +it('retains request correlations when real generations swap the same target set', async () => { + // Given + const f = sourceFixture({ + 'app/red.ts': "export const value = 'red'", + 'app/blue.ts': "export const value = 'blue'", + }) + const source = (first: string, second: string) => + `import { css } from '@devup-ui/react'; import {value as a} from '${first}'; import {value as b} from '${second}'; import '${first}'; export const cls = css({color:a, background:b})` + const page = f.write('app/page.tsx', source('./red', './blue')) + const before = await f.manager.prepare(f.signal) + f.write('app/page.tsx', source('./blue', './red')) + // When + const after = await f.manager.prepare(f.signal) + // Then + expect([...(after.plan.graph.staticImports.get(page) ?? [])].sort()).toEqual( + [...(before.plan.graph.staticImports.get(page) ?? [])].sort(), + ) + expect( + before.plan.graph.requests + ?.filter((edge) => edge.importer === page) + .map((edge) => edge.specifier), + ).toEqual(['@devup-ui/react', './red', './blue', './red']) + expect( + after.plan.graph.requests + ?.filter((edge) => edge.importer === page) + .map((edge) => edge.specifier), + ).toEqual(['@devup-ui/react', './blue', './red', './blue']) + expect(f.css(before)).toContain('color:red') + expect(f.css(after)).toContain('color:blue') +}) + +it('transports the exact compiled producer ledger and map through immutable delivery', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': `import './leaf'\n\n${styledMdx}`, + 'app/leaf.ts': 'export {}', + }) + const generation = await f.manager.prepare(f.signal) + const graph = await buildStaticImportGraph(f.root, undefined, { + cwd: f.root, + exclude: ['df', '.next'], + includeMdx: f.binding.extensions, + alias: f.binding.aliases, + conditions: f.binding.conditions, + prepareSource: generation.cacheReader, + }) + // When + const plan = immutableMdxPlan( + { + ...generation.plan, + graph, + seedFiles: [...generation.plan.seedFiles], + expectedBaseFiles: [...generation.plan.expectedBaseFiles], + canonicalMap: { ...generation.plan.canonicalMap }, + fileRoutes: {}, + }, + graph, + ) + // Then + expect(plan.graph.requests).toBe(graph.requests) + expect(generation.plan.graph.requests).toEqual(graph.requests) + const edge = plan.graph.requests?.find( + (request) => + request.importer === join(f.root, 'app/page.mdx') && + request.specifier === './leaf', + ) + expect(edge).toMatchObject({ + source: 'compiled', + kind: 'static-import', + request: './leaf', + outcome: { + kind: 'resolved', + path: join(f.root, 'app/leaf.ts'), + inputs: expectedJoinInputs( + f.root, + [join(f.root, 'app/leaf.ts')], + ['app/leaf'], + ), + }, + }) + expect(edge?.map).toBe( + generation.compiled[join(f.root, 'app/page.mdx')]?.prepared.map, + ) +}) + +it('retains absent legacy evidence instead of inventing an empty scanned ledger', () => { + // Given + const graph = { + files: [], + fileSet: new Set(), + staticImports: new Map>(), + staticImporters: new Map>(), + dynamicImports: new Map>(), + dynamicTargets: new Set(), + } + const sourcePlan = { + graph, + seedFiles: [], + expectedBaseFiles: [], + canonicalMap: {}, + fileRoutes: {}, + atomThreshold: null, + } + // When + const plan = immutableMdxPlan(sourcePlan, graph) + // Then + expect(Object.hasOwn(plan.graph, 'requests')).toBe(false) + expect(plan.graph.requests).toBeUndefined() +}) + +it('preserves raw spelling, decoded request and duplicate source coordinates', async () => { + // Given + const source = String.raw`import './\x6ceaf'; export * from './leaf'; import('./leaf'); require('./leaf');` + const f = sourceFixture({ + 'app/page.tsx': source, + 'app/leaf.ts': 'export {}', + }) + // When + const generation = await f.manager.prepare(f.signal) + // Then + expect( + generation.plan.graph.requests?.filter( + (edge) => edge.importer === join(f.root, 'app/page.tsx'), + ), + ).toEqual( + ( + [ + ['static-import', './\\x6ceaf', 7], + ['re-export', './leaf', 34], + ['literal-dynamic-import', './leaf', 51], + ['literal-require', './leaf', 70], + ] as const + ).map(([kind, request, offset]) => ({ + importer: join(f.root, 'app/page.tsx'), + kind, + request, + specifier: './leaf', + position: { offset, line: 1, column: offset + 1 }, + source: 'source', + outcome: { + kind: 'resolved', + path: join(f.root, 'app/leaf.ts'), + inputs: expectedJoinInputs( + f.root, + [join(f.root, 'app/leaf.ts')], + ['app/leaf'], + ), + }, + })), + ) +}) + +it('delivers descriptor aliases through prepared graph and WASM under original filenames', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': + "import {css} from '@devup-ui/react'\n\nimport {value} from 'value'\n\nexport const cls = css({color:value})\n\n# Heading", + }) + const value = f.write('outside/value.ts', "export const value = 'blue'") + const manager = createMdxSourceManager({ + ...f.binding, + aliases: [ + { + name: 'provider', + alias: join(f.root, 'provider.tsx'), + onlyModule: true, + }, + { name: 'value', alias: [join(f.root, 'absent'), value] }, + { name: 'value', alias: false }, + ], + }) + // When + const generation = await manager.prepare(f.signal) + // Then + expect(generation.plan.graph.fileSet.has(value)).toBe(true) + expect(generation.resolver.prepareSource).toBe(generation.cacheReader) + expect( + generation.plan.graph.requests?.find((edge) => edge.specifier === 'value') + ?.outcome, + ).toEqual({ + kind: 'resolved', + path: value, + inputs: expectedJoinInputs( + f.root, + [value], + ['absent', 'absent/package.json', 'absent/index.ts'], + ), + }) + expect(f.css(generation)).toContain('color:blue') +}) + +it('retains distinct non-file outcomes when the producer skips graph targets', async () => { + // Given + const f = sourceFixture( + { + 'app/page.tsx': + "import './missing'; import 'external'; import 'ignored'; import '../df/blocked';", + 'df/blocked.ts': 'invalid unvisited output', + }, + { aliases: { ignored: false } }, + ) + // When + const generation = await f.manager.prepare(f.signal) + // Then + expect( + generation.plan.graph.requests + ?.filter((edge) => edge.importer === join(f.root, 'app/page.tsx')) + .map((edge) => edge.outcome), + ).toEqual([ + { kind: 'unresolved' }, + { kind: 'external', request: 'external' }, + { kind: 'ignored' }, + { kind: 'excluded', entry: join(f.root, 'df') }, + ]) +}) diff --git a/packages/next-plugin/src/__tests__/mdx-source-restart.test.ts b/packages/next-plugin/src/__tests__/mdx-source-restart.test.ts new file mode 100644 index 000000000..46608d62c --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-restart.test.ts @@ -0,0 +1,159 @@ +import { mkdirSync, utimesSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { isMdxRecord } from '../mdx-pipeline' +import { importMdxRestartCache } from '../mdx-source-cache' +import { createMdxSourceManager } from '../mdx-source-generation' +import { sourceFixture, styledMdx } from './mdx-source-fixture' + +it.each(['resource', 'file', 'context', 'missing'] as const)( + 'invalidates persisted compiled bytes when the %s input changes', + async (kind) => { + // Given + const f = sourceFixture({ + 'app/page.mdx': styledMdx, + 'app/data.json': '{"color":"blue"}', + }) + const directory = join(f.root, 'app/reported') + mkdirSync(directory) + const first = await f.manager.prepare(f.signal) + const saved = f.manager.restartCache(first) + switch (kind) { + case 'resource': + f.write('app/page.mdx', styledMdx.replace('red', 'yellow')) + break + case 'file': + f.write('app/data.json', '{"color":"green"}') + break + case 'context': + f.write('app/reported/added', 'entry') + break + case 'missing': + f.write('app/optional.json', '{}') + break + default: + kind satisfies never + } + // When + await createMdxSourceManager(f.binding, saved).prepare(f.signal) + // Then + expect(f.counts()).toBe(2) + }, +) + +it('refreshes an imported ordinary constant after cache restart even outside graph discovery', async () => { + // Given + const f = sourceFixture({ + 'app/page.mdx': `import { css } from '@devup-ui/react'\n\nimport { color } from '../df/value'\n\nexport const style = css({color})\n\n# Test`, + 'df/value.ts': "export const color = 'blue'", + }) + const first = await f.manager.prepare(f.signal) + const saved = f.manager.restartCache(first) + f.write('df/value.ts', "export const color = 'green'") + // When + const next = await createMdxSourceManager(f.binding, saved).prepare(f.signal) + // Then + expect(f.counts()).toBe(2) + expect(f.css(next)).toContain('color:green') +}) + +it('reprepares MDX when a compiler-injected provider changes, including after restart', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }, {}, true) + const first = await f.manager.prepare(f.signal) + const saved = f.manager.restartCache(first) + f.write( + 'provider.tsx', + `import { Box } from '@devup-ui/react'; export function useMDXComponents() { return { h1: () => } }`, + ) + const refreshed = await f.manager.refresh({ + generation: first, + signal: f.signal, + }) + expect(f.counts()).toBe(2) + f.write( + 'provider.tsx', + `import { Box } from '@devup-ui/react'; export function useMDXComponents() { return { h1: () => } }`, + ) + // When + const next = await createMdxSourceManager(f.binding, saved).prepare(f.signal) + // Then + expect(f.counts()).toBe(3) + expect(f.css(next)).toContain('padding:24px') + expect(f.css(refreshed)).toContain('padding:20px') +}) + +it('forces re-preparation on directory child watch events even when entry names stay unchanged', async () => { + // Given + const f = sourceFixture( + { 'app/page.mdx': styledMdx, 'app/reported/existing': 'before' }, + {}, + true, + ) + const directory = join(f.root, 'app/reported') + utimesSync( + directory, + new Date(1_700_000_000_000), + new Date(1_700_000_000_000), + ) + const first = await f.manager.prepare(f.signal) + const path = f.write('app/reported/existing', 'after') + // When + await f.manager.refresh({ + generation: first, + signal: f.signal, + changedPaths: [path], + }) + // Then + expect(f.counts()).toBe(2) +}) + +it('prunes old compiled MDX from the current-plan reader when its import is dropped', async () => { + // Given + const f = sourceFixture({ + 'app/page.tsx': `import './child.mdx'; export default ()=>null`, + 'app/child.mdx': styledMdx, + }) + const first = await f.manager.prepare(f.signal) + f.write('app/page.tsx', 'export default ()=>null') + // When + const next = await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(next.sources).toEqual([]) + expect(next.cacheReader(join(f.root, 'app/child.mdx'))).toBeUndefined() + expect(first.cacheReader(join(f.root, 'app/child.mdx'))).toBeDefined() +}) + +it('reprepares compiled importers when another reached MDX resource changes', async () => { + // Given + const f = sourceFixture( + { + 'app/page.mdx': `import './child.mdx'\n\n${styledMdx}`, + 'app/child.mdx': styledMdx, + }, + {}, + true, + ) + const first = await f.manager.prepare(f.signal) + f.write('app/child.mdx', styledMdx.replace('red', 'blue')) + // When + await f.manager.refresh({ generation: first, signal: f.signal }) + // Then + expect(f.counts()).toBe(4) +}) + +it('rejects a partial persisted proof even when its pipeline and compiled source are intact', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const first = await f.manager.prepare(f.signal) + const data: unknown = JSON.parse(f.manager.restartCache(first)) + if (!Array.isArray(data)) throw new TypeError('Missing restart fixture') + const entry: unknown = data[0] + if (!isMdxRecord(entry)) throw new TypeError('Missing restart entry') + // When / Then + expect(() => + importMdxRestartCache(JSON.stringify([{ ...entry, inputs: [] }])), + ).toThrow('Incomplete MDX restart input proof') +}) diff --git a/packages/next-plugin/src/__tests__/mdx-source-values.test.ts b/packages/next-plugin/src/__tests__/mdx-source-values.test.ts new file mode 100644 index 000000000..88385ff7e --- /dev/null +++ b/packages/next-plugin/src/__tests__/mdx-source-values.test.ts @@ -0,0 +1,227 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { + captureMdxCacheIdentity, + compatibleMdxCache, + exportMdxRestartCache, + importMdxRestartCache, +} from '../mdx-source-cache' +import { createMdxTimestampAccuracy } from '../mdx-source-freshness' +import { createMdxSourceManager } from '../mdx-source-generation' +import { immutableMdxMap } from '../mdx-source-immutable' +import { overlayMdxOrdinaryInputs } from '../mdx-source-inputs' +import { sourceFixture, styledMdx } from './mdx-source-fixture' + +it.each([ + [2000, 2000], + [1000, 1000], + [100, 100], + [10, 10], + [1, 1], +])( + 'narrows installed-webpack timestamp accuracy for timestamp %s', + (mtime, margin) => { + // Given / When / Then + expect(createMdxTimestampAccuracy().apply(mtime)).toBe(margin) + }, +) + +it('provides immutable graph query views without publishing mutable Map or Set instances', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const generation = await f.manager.prepare(f.signal) + const graph = generation.plan.graph + // When / Then + expect('set' in graph.staticImports).toBe(false) + expect('add' in graph.fileSet).toBe(false) + expect(graph.fileSet.size).toBe(graph.files.length) + expect([...graph.fileSet]).toHaveLength(graph.files.length) + expect([...graph.fileSet.keys()]).toEqual([...graph.fileSet.values()]) + expect([...graph.fileSet.entries()]).toHaveLength(graph.fileSet.size) + const visited: string[] = [] + graph.fileSet.forEach((file) => visited.push(file)) + expect(visited).toHaveLength(graph.fileSet.size) + expect(graph.fileSet.union(new Set(['extra'])).has('extra')).toBe(true) + expect(graph.fileSet.intersection(new Set(['extra'])).size).toBe(0) + expect(graph.fileSet.difference(new Set()).size).toBe(graph.fileSet.size) + expect( + graph.fileSet.symmetricDifference(new Set(['extra'])).has('extra'), + ).toBe(true) + expect(graph.fileSet.isSubsetOf(new Set(graph.files))).toBe(true) + expect(graph.fileSet.isSupersetOf(new Set())).toBe(true) + expect(graph.fileSet.isDisjointFrom(new Set(['extra']))).toBe(true) + expect(graph.staticImports.size).toBe(graph.files.length) + expect(graph.staticImports.has(graph.files[0] ?? '')).toBe(true) + expect( + graph.staticImports + .get(join(f.root, 'app/page.mdx')) + ?.has(join(f.root, 'provider.tsx')), + ).toBe(true) + expect([...graph.staticImports.keys()]).toHaveLength(graph.staticImports.size) + expect([...graph.staticImports.values()]).toHaveLength( + graph.staticImports.size, + ) + let count = 0 + graph.staticImports.forEach(() => { + count += 1 + }) + expect(count).toBe(graph.staticImports.size) + expect([...graph.staticImports]).toHaveLength(graph.staticImports.size) + expect([...graph.staticImports.entries()]).toHaveLength( + graph.staticImports.size, + ) + expect( + Object.isFrozen( + generation.compiled[join(f.root, 'app/page.mdx')]?.prepared.map, + ), + ).toBe(true) +}) + +it('copies compiler maps without decoding them or freezing the producer object', () => { + // Given + const map = { version: 3, sources: ['original.mdx'], nested: { empty: null } } + // When + const copy = immutableMdxMap(map) + // Then + expect(copy).toEqual(map) + expect(Object.isFrozen(map)).toBe(false) + expect(immutableMdxMap('map')).toBe('map') + expect(immutableMdxMap(undefined)).toBeUndefined() +}) + +it('freezes route-hoist arrays from the real shared prepared graph', async () => { + // Given + const f = sourceFixture({ + 'app/a/page.mdx': `import '../../shared'\n\n${styledMdx}`, + 'app/b/page.mdx': `import '../../shared'\n\n${styledMdx}`, + 'shared.tsx': `import { Box } from '@devup-ui/react'; export const Shared = ()=> `, + }) + const manager = createMdxSourceManager({ + ...f.binding, + effectiveAppContext: { ...f.binding.effectiveAppContext, atomHoist: 2 }, + }) + // When + const generation = await manager.prepare(f.signal) + // Then + expect(generation.plan.fileRoutes['shared.tsx']).toEqual([0, 1]) + expect(Object.isFrozen(generation.plan.fileRoutes['shared.tsx'])).toBe(true) +}) + +it('overlays additions on an ordinary ledger without MDX-specific reach pruning', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const generation = await f.manager.prepare(f.signal) + const provider = generation.ordinaryInputs.find( + (input) => input.filename === 'provider.tsx', + ) + if (!provider) throw new TypeError('Missing ordinary provider') + // When + const inputs = overlayMdxOrdinaryInputs(generation.ordinaryInputs, [ + { ...provider, source: 'replacement' }, + ]) + // Then + expect( + inputs.find((input) => input.filename === provider.filename)?.source, + ).toBe('replacement') + expect(inputs).toHaveLength(generation.ordinaryInputs.length) + expect(overlayMdxOrdinaryInputs(inputs, [])).toHaveLength(inputs.length) +}) + +it.each([ + () => {}, + new Date(), + Symbol('leaf'), + NaN, + { value: undefined }, + Object.defineProperty({}, 'getter', { get: () => 1 }), + new Array(2), +])( + 'invalidates nonportable identity across restart without executing getters', + async (identity) => { + // Given + const f = sourceFixture({}) + const selection = { + pipeline: f.pipeline, + context: { owner: {}, generation: {} }, + identity, + } + // When + const captured = captureMdxCacheIdentity(selection) + // Then + expect(captured.portable).toBeUndefined() + expect( + exportMdxRestartCache( + new Map([ + [ + 'module', + { + ...captured, + prepared: { + filename: 'module', + source: '', + map: undefined, + dependencies: [], + contextDependencies: [], + missingDependencies: [], + buildDependencies: [], + dependencyReports: [], + }, + inputs: [], + }, + ], + ]), + ), + ).toBe('[]') + }, +) + +it('preserves structural identity while separating map delivery cache dimensions', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': styledMdx }) + const selection = await f.binding.selectPipeline('module', f.signal) + if (!selection) throw new TypeError('Missing selected pipeline') + const captured = captureMdxCacheIdentity(selection) + const entry = { + ...captured, + prepared: { + filename: 'module', + source: '', + map: undefined, + dependencies: [], + contextDependencies: [], + missingDependencies: [], + buildDependencies: [], + dependencyReports: [], + }, + inputs: [], + } + // When / Then + expect( + compatibleMdxCache(entry, { + ...selection, + identity: structuredClone(selection.identity), + }), + ).toBe(true) + expect( + compatibleMdxCache(entry, { + ...selection, + context: { ...selection.context, sourceMap: false }, + }), + ).toBe(false) + expect( + compatibleMdxCache(entry, { ...selection, identity: ['changed'] }), + ).toBe(false) +}) + +it.each([ + '{}', + '[{}]', + '[{"filename":"m","prepared":{"filename":"m","source":9},"inputs":[]}]', + '[{"filename":"m","identity":{},"prepared":{"filename":"m","source":"","dependencies":[9]},"inputs":[]}]', + '[{"filename":"m","identity":{},"prepared":{"filename":"m","source":""},"inputs":[{}]}]', +])('rejects malformed private restart data when payload is %s', (payload) => { + // Given / When / Then + expect(() => importMdxRestartCache(payload)).toThrow() +}) diff --git a/packages/next-plugin/src/__tests__/namespace-cleanup.test.ts b/packages/next-plugin/src/__tests__/namespace-cleanup.test.ts new file mode 100644 index 000000000..02ff889b4 --- /dev/null +++ b/packages/next-plugin/src/__tests__/namespace-cleanup.test.ts @@ -0,0 +1,73 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it } from 'bun:test' + +describe('loader namespace cleanup across real coordinator suites', () => { + it.each( + [ + [ + 'loader', + 'css-loader', + 'coordinator-dev', + 'coordinator-watch-lifecycle', + ], + [ + 'css-loader', + 'loader', + 'coordinator-watch-lifecycle', + 'coordinator-dev', + ], + [ + 'loader', + 'coordinator-dev', + 'css-loader', + 'coordinator-watch-lifecycle', + ], + [ + 'css-loader', + 'coordinator-watch-lifecycle', + 'loader', + 'coordinator-dev', + ], + ].map((order) => ({ order })), + )( + 'keeps real rebuilds isolated when suites run in order %j', + ({ order }) => { + // Given one subprocess with explicit suite scopes, not CLI file discovery. + const dir = mkdtempSync(join(tmpdir(), 'devup-namespace-order-')) + try { + const fixture = join(dir, 'ordered.test.ts') + writeFileSync(join(dir, 'bunfig.toml'), '[test]\ncoverage = false\n') + writeFileSync( + fixture, + "import { describe } from 'bun:test'\n" + + order + .map( + (file) => + `describe(${JSON.stringify(file)}, () => { require(${JSON.stringify(join(import.meta.dir, `${file}.test.ts`))}) })`, + ) + .join('\n'), + ) + + // When the existing loader and real rebuild/watcher tests share a realm. + const result = spawnSync(process.execPath, ['test', fixture], { + cwd: dir, + encoding: 'utf8', + timeout: 20000, + }) + + // Then every original assertion passes, including the factory guard. + if (result.status !== 0) { + console.error(result.stdout, result.stderr, result.error) + } + expect(result.status).toBe(0) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }, + 30000, + ) +}) diff --git a/packages/next-plugin/src/__tests__/next-invocation-fixture.ts b/packages/next-plugin/src/__tests__/next-invocation-fixture.ts new file mode 100644 index 000000000..23509567a --- /dev/null +++ b/packages/next-plugin/src/__tests__/next-invocation-fixture.ts @@ -0,0 +1,31 @@ +import { join } from 'node:path' + +import type { NextInvocationInput } from '../next-invocation-types' + +export const installedRoot = join(import.meta.dir, 'fixture-next') +export const projectDir = join(import.meta.dir, 'fixture-project') +export const installedPath = (path: string): string => join(installedRoot, path) + +export function invocation(): NextInvocationInput { + return { + required: true, + stage: 'adapter', + installed: { version: '16.3.6', packageDir: installedRoot }, + evaluation: { + phase: 'phase-production-build', + projectDir, + expectedProjectDir: projectDir, + configFile: join(projectDir, 'next.config.mjs'), + }, + runtime: { + entryPath: installedPath('dist/bin/next'), + argv: ['node', installedPath('dist/bin/next'), 'build'], + isMainThread: true, + hasIpc: false, + loadedModules: [installedPath('dist/cli/next-build.js')], + env: { TURBOPACK: 'auto' }, + }, + build: { projectDir, mode: 'default' }, + config: { webpackBuildWorker: undefined, hasWebpack: true }, + } +} diff --git a/packages/next-plugin/src/__tests__/next-invocation-workers.test.ts b/packages/next-plugin/src/__tests__/next-invocation-workers.test.ts new file mode 100644 index 000000000..45e40fda7 --- /dev/null +++ b/packages/next-plugin/src/__tests__/next-invocation-workers.test.ts @@ -0,0 +1,231 @@ +import { expect, it } from 'bun:test' + +import { classifyNextInvocation, NextInvocationError } from '../next-invocation' +import { installedPath, invocation } from './next-invocation-fixture' + +it.each([true, false])( + 'receives dev child with Turbo=%s, including restart', + (turbo) => { + // Given: Next-forked start-server, private markers, IPC and actual dev phase. + const input = invocation() + // When: classify the current child; restart has these same facts in a NEW isolate. + const result = classifyNextInvocation({ + ...input, + evaluation: { ...input.evaluation, phase: 'phase-development-server' }, + runtime: { + ...input.runtime, + entryPath: installedPath('dist/server/lib/start-server.js'), + hasIpc: true, + env: { + __NEXT_DEV_SERVER: '1', + NEXT_PRIVATE_WORKER: '1', + ...(turbo ? { TURBOPACK: 'auto' } : {}), + }, + }, + }) + // Then: no predecessor owner or PID is part of the result. + expect(result).toEqual({ + kind: 'receive', + authority: turbo ? 'adapter' : 'webpack-beforeCompile', + isolation: 'current-isolate', + }) + }, +) + +it.each([ + { env: { __NEXT_DEV_SERVER: '1' }, hasIpc: true, isMainThread: true }, + { env: { NEXT_PRIVATE_WORKER: '1' }, hasIpc: true, isMainThread: true }, + { + env: { __NEXT_DEV_SERVER: '1', NEXT_PRIVATE_WORKER: '1' }, + hasIpc: false, + isMainThread: true, + }, + { + env: { __NEXT_DEV_SERVER: '1', NEXT_PRIVATE_WORKER: '1' }, + hasIpc: true, + isMainThread: false, + }, +] as const)('rejects incomplete dev provenance %#', (runtime) => { + // Given: exact entrypoint but missing Next producer facts. + const input = invocation() + // When / Then: phase or private markers alone cannot receive. + expect(() => + classifyNextInvocation({ + ...input, + evaluation: { ...input.evaluation, phase: 'phase-development-server' }, + runtime: { + ...input.runtime, + ...runtime, + entryPath: installedPath('dist/server/lib/start-server.js'), + }, + }), + ).toThrow(NextInvocationError) +}) + +it('leaves telemetry inert despite inherited dev flags', () => { + // Given: same phase/env as dev child, DIFFERENT installed bootstrap. + const input = invocation() + // When / Then: telemetry cannot prepare or rebind. + expect( + classifyNextInvocation({ + ...input, + evaluation: { ...input.evaluation, phase: 'phase-development-server' }, + runtime: { + ...input.runtime, + entryPath: installedPath('dist/telemetry/detached-flush.js'), + env: { + __NEXT_DEV_SERVER: '1', + NEXT_PRIVATE_WORKER: '1', + TURBOPACK: 'auto', + }, + }, + }), + ).toEqual({ kind: 'inert', reason: 'telemetry' }) +}) + +it.each([ + ['threadChild.js', false, 'turbopack-build', 'adapter'], + ['processChild.js', true, 'webpack-build', 'webpack-beforeCompile'], +] as const)( + 'receives exact native Jest %s compilation isolate', + (bootstrap, isMainThread, impl, authority) => { + // Given: actual Jest bootstrap and IPC-required implementation loaded in this isolate. + const input = invocation() + // When: compilation marker producers match the loaded target. + const result = classifyNextInvocation({ + ...input, + runtime: { + ...input.runtime, + entryPath: installedPath(`dist/compiled/jest-worker/${bootstrap}`), + isMainThread, + hasIpc: isMainThread, + env: { NEXT_PRIVATE_BUILD_WORKER: '1', IS_NEXT_WORKER: 'true' }, + loadedModules: [installedPath(`dist/build/${impl}/impl.js`)], + }, + }) + // Then: native worker receives, never parent-global/PID reuse. + expect(result).toEqual({ + kind: 'receive', + authority, + isolation: 'current-isolate', + }) + }, +) + +it.each(['threadChild.js', 'processChild.js'])( + 'leaves static/export %s inert even with inherited compile marker', + (bootstrap) => { + // Given: static worker bootstrap lacks compilation target identity. + const input = invocation() + // When / Then: supplied nextConfig does not become compilation authority. + expect( + classifyNextInvocation({ + ...input, + runtime: { + ...input.runtime, + entryPath: installedPath(`dist/compiled/jest-worker/${bootstrap}`), + isMainThread: bootstrap === 'processChild.js', + hasIpc: true, + env: { NEXT_PRIVATE_BUILD_WORKER: '1', IS_NEXT_WORKER: 'true' }, + loadedModules: [installedPath('dist/build/worker.js')], + }, + }), + ).toEqual({ kind: 'inert', reason: 'supplied-config' }) + }, +) + +it.each([ + [ + 'threadChild.js', + true, + true, + { NEXT_PRIVATE_BUILD_WORKER: '1', IS_NEXT_WORKER: 'true' }, + ['dist/build/turbopack-build/impl.js'], + ], + [ + 'processChild.js', + true, + false, + { NEXT_PRIVATE_BUILD_WORKER: '1', IS_NEXT_WORKER: 'true' }, + ['dist/build/webpack-build/impl.js'], + ], + [ + 'threadChild.js', + false, + false, + { IS_NEXT_WORKER: 'true' }, + ['dist/build/turbopack-build/impl.js'], + ], + [ + 'threadChild.js', + false, + false, + { NEXT_PRIVATE_BUILD_WORKER: '1' }, + ['dist/build/turbopack-build/impl.js'], + ], + [ + 'threadChild.js', + false, + false, + { NEXT_PRIVATE_BUILD_WORKER: '1', IS_NEXT_WORKER: 'true' }, + [], + ], + [ + 'threadChild.js', + false, + false, + { NEXT_PRIVATE_BUILD_WORKER: '1', IS_NEXT_WORKER: 'true' }, + ['dist/build/webpack-build/impl.js'], + ], + [ + 'processChild.js', + true, + true, + { NEXT_PRIVATE_BUILD_WORKER: '1', IS_NEXT_WORKER: 'true' }, + ['dist/build/turbopack-build/impl.js'], + ], + [ + 'threadChild.js', + false, + false, + { NEXT_PRIVATE_BUILD_WORKER: '1', IS_NEXT_WORKER: 'true' }, + ['dist/build/turbopack-build/impl.js', 'dist/build/webpack-build/impl.js'], + ], +] as const)( + 'rejects inconsistent Jest compilation proof %#', + (bootstrap, isMainThread, hasIpc, env, targets) => { + // Given: marker-only/target-only/wrong-isolate facts cannot certify a receiving worker. + const input = invocation() + // When / Then: unknown required worker invocation fails located. + expect(() => + classifyNextInvocation({ + ...input, + runtime: { + ...input.runtime, + entryPath: installedPath(`dist/compiled/jest-worker/${bootstrap}`), + isMainThread, + hasIpc, + env, + loadedModules: targets.map(installedPath), + }, + }), + ).toThrow(NextInvocationError) + }, +) + +it('rejects programmatic dev even when Next sets __NEXT_DEV_SERVER', () => { + // Given: NextCustomServer.prepare sets the dev flag but not stock fork identity. + const input = invocation() + // When / Then: getRequestHandler cannot guess a receiving Turbo config evaluation. + expect(() => + classifyNextInvocation({ + ...input, + evaluation: { ...input.evaluation, phase: 'phase-development-server' }, + runtime: { + ...input.runtime, + entryPath: '/custom/server.js', + env: { __NEXT_DEV_SERVER: '1', TURBOPACK: 'auto' }, + }, + }), + ).toThrow(NextInvocationError) +}) diff --git a/packages/next-plugin/src/__tests__/next-invocation.test.ts b/packages/next-plugin/src/__tests__/next-invocation.test.ts new file mode 100644 index 000000000..2542fec0c --- /dev/null +++ b/packages/next-plugin/src/__tests__/next-invocation.test.ts @@ -0,0 +1,234 @@ +import { expect, it } from 'bun:test' + +import { classifyNextInvocation, NextInvocationError } from '../next-invocation' +import { + installedPath, + invocation, + projectDir, +} from './next-invocation-fixture' + +it.each(['16.3.0', '16.3.6', '16.3.99'])( + 'hands off Turbo on supported patch %s', + (version) => { + // Given: stock build bootstrap, final config and actual phase/project. + const input = invocation() + // When: the supported minor receives another patch. + const result = classifyNextInvocation({ + ...input, + installed: { ...input.installed, version }, + }) + // Then: no hash/patch gate or prepared parent. + expect(result).toEqual({ + kind: 'handoff', + target: 'turbopack-build-thread', + prepared: false, + }) + }, +) + +it.each(['16.2.9', '16.4.0', '17.0.0', '16.3.6-canary.1', 'broken'])( + 'locates required unsupported version %s', + (version) => { + // Given: MDX authority is required on an unverified minor/release. + const input = invocation() + // When / Then: fail with installed version, supported range, config and fix. + expect(() => + classifyNextInvocation({ + ...input, + installed: { ...input.installed, version }, + }), + ).toThrow(NextInvocationError) + expect(() => + classifyNextInvocation({ + ...input, + installed: { ...input.installed, version }, + }), + ).toThrow(`${input.evaluation.configFile}:1:1:`) + expect(() => + classifyNextInvocation({ + ...input, + installed: { ...input.installed, version }, + }), + ).toThrow(`installed Next ${version}; supported 16.3.x`) + }, +) + +it('receives Turbo in main when explicit worker override differs from default', () => { + // Given: default worker ON; explicit override OFF. + const input = invocation() + // When: evaluate final adapter with actual environment override. + const result = classifyNextInvocation({ + ...input, + runtime: { + ...input.runtime, + env: { TURBOPACK: 'auto', NEXT_TURBOPACK_USE_WORKER: '0' }, + }, + }) + // Then: main, not an intent-only parent, receives. + expect(result).toEqual({ + kind: 'receive', + authority: 'adapter', + isolation: 'current-isolate', + }) +}) + +it.each([ + [undefined, false, 'handoff'], + [undefined, true, 'receive'], + [true, true, 'handoff'], + [false, false, 'receive'], +] as const)( + 'uses final webpack choice %s / callback %s', + (webpackBuildWorker, hasWebpack, kind) => { + // Given: explicit settings differ from the callback-dependent fallback. + const input = invocation() + // When: classify stock webpack build final config. + const result = classifyNextInvocation({ + ...input, + runtime: { ...input.runtime, env: {} }, + config: { webpackBuildWorker, hasWebpack }, + }) + // Then: receiving webpack is still gated by REAL beforeCompile, not this adapter. + expect(result.kind).toBe(kind) + expect(result).toEqual( + kind === 'receive' + ? { + kind, + authority: 'webpack-beforeCompile', + isolation: 'current-isolate', + } + : { kind, target: 'webpack-build-process', prepared: false }, + ) + }, +) + +it.each(['generate', 'generate-env'])('leaves build mode %s inert', (mode) => { + // Given: stock build has no compilation in this mode. + const input = invocation() + // When / Then: no receiving owner or prepared parent. + expect( + classifyNextInvocation({ ...input, build: { projectDir, mode } }), + ).toEqual({ kind: 'inert', reason: 'non-compiling' }) +}) + +it.each(['phase-production-server', 'phase-export', 'phase-production-build'])( + 'leaves non-MDX unknown programmatic %s unchanged', + (phase) => { + // Given: no lifecycle MDX, unknown version and arbitrary custom bootstrap. + const input = invocation() + // When: bypass role policy explicitly. + const result = classifyNextInvocation({ + ...input, + required: false, + installed: { ...input.installed, version: '12.0.1' }, + evaluation: { ...input.evaluation, phase }, + runtime: { ...input.runtime, entryPath: '/custom/server.js' }, + }) + // Then: ordinary prior behavior is untouched. + expect(result).toEqual({ kind: 'inert', reason: 'unneeded' }) + }, +) + +it.each(['wrapper', 'cache', 'supplied-config'] as const)( + 'never binds new closures at %s stage', + (stage) => { + // Given: raw phase-function diagnostic, cached render load or supplied export config. + const input = invocation() + // When / Then: these are not final adapter/compiler evaluations, even with MDX. + expect(classifyNextInvocation({ ...input, stage })).toEqual({ + kind: 'inert', + reason: stage, + }) + }, +) + +it('uses native compiler authority for programmatic webpack without stock proof', () => { + // Given: Main has a REAL beforeCompile/NMF invocation, not a synthetic adapter flag. + const input = invocation() + // When / Then: native gate is independently authoritative; no CLI/minor floor there. + expect( + classifyNextInvocation({ + ...input, + stage: 'webpack-beforeCompile', + installed: { ...input.installed, version: '15.1.0' }, + runtime: { ...input.runtime, entryPath: '/custom/server.js' }, + }), + ).toEqual({ + kind: 'receive', + authority: 'webpack-beforeCompile', + isolation: 'current-isolate', + }) +}) + +it.each(['phase-production-server', 'phase-export'])( + 'serves artifacts without binding in %s', + (phase) => { + // Given: Next serve/export does not compile. + const input = invocation() + // When / Then: adapter remains inert. + expect( + classifyNextInvocation({ + ...input, + evaluation: { ...input.evaluation, phase }, + }), + ).toEqual({ kind: 'inert', reason: 'non-compiling' }) + }, +) + +it.each([ + { entryPath: '/custom/server.js' }, + { entryPath: undefined }, + { isMainThread: false }, + { loadedModules: [] }, + { argv: ['node', installedPath('dist/bin/next'), 'dev'] }, + { env: { NEXT_RSPACK: '1' } }, +] as const)('rejects required unproven main bootstrap %#', (override) => { + // Given: phase/NODE_ENV/private flags cannot establish a stock invocation. + const input = invocation() + // When / Then: required unknown invocation has actionable config error. + expect(() => + classifyNextInvocation({ + ...input, + runtime: { ...input.runtime, ...override }, + }), + ).toThrow( + 'use the qualified Next CLI on 16.3.x, or webpack with its real beforeCompile gate', + ) +}) + +it.each([ + undefined, + { projectDir: '/other', mode: 'default' }, + { projectDir, mode: 'unknown' }, +])('rejects unproven native build context %#', (build) => { + // Given: missing, cross-project or unknown mode facts. + const input = invocation() + // When / Then: never infer compilation from phase alone. + expect(() => classifyNextInvocation({ ...input, build })).toThrow( + NextInvocationError, + ) +}) + +it('rejects cross-project adapter facts before receiving', () => { + // Given: a reentrant load for another project. + const input = invocation() + // When / Then: no cross-project owner decision. + expect(() => + classifyNextInvocation({ + ...input, + evaluation: { ...input.evaluation, projectDir: '/other' }, + }), + ).toThrow(NextInvocationError) +}) + +it('rejects unknown phase instead of assuming development', () => { + // Given: required authority under an unknown phase. + const input = invocation() + // When / Then: fail closed. + expect(() => + classifyNextInvocation({ + ...input, + evaluation: { ...input.evaluation, phase: 'unknown' }, + }), + ).toThrow(NextInvocationError) +}) diff --git a/packages/next-plugin/src/__tests__/next-output-exclusion.test.ts b/packages/next-plugin/src/__tests__/next-output-exclusion.test.ts new file mode 100644 index 000000000..1d90465c3 --- /dev/null +++ b/packages/next-plugin/src/__tests__/next-output-exclusion.test.ts @@ -0,0 +1,37 @@ +import { join } from 'node:path' + +import { afterEach, expect, it } from 'bun:test' + +import { planSources } from '../plan' +import { createAppContext } from '../session' +import { box, installProjectHooks, makeProject } from './project' + +installProjectHooks() +const environment = { ...process.env } +afterEach(() => { + process.env = { ...environment } +}) + +it('excludes actual outputs without excluding same-named source folders', () => { + // Given + process.env.NODE_ENV = 'production' + const root = makeProject({ + 'app/page.tsx': + "export { C } from '../src/build/Card'; export { D } from '../src/df/Card'", + 'src/build/Card.tsx': box('color="green"'), + 'src/df/Card.tsx': box('color="blue"').replace('const C', 'const D'), + 'build/app/page.tsx': box('color="generated"'), + 'df/app/page.tsx': box('color="generated"'), + }) + process.chdir(root) + const context = createAppContext({ distDir: 'build' }, {}) + // When + const plan = planSources(context) + // Then + expect(plan.graph?.files).toEqual([ + join(root, 'app/page.tsx'), + join(root, 'src/build/Card.tsx'), + join(root, 'src/df/Card.tsx'), + ]) + expect(plan.seedFiles).toEqual(['src/build/Card.tsx', 'src/df/Card.tsx']) +}) diff --git a/packages/next-plugin/src/__tests__/plan.test.ts b/packages/next-plugin/src/__tests__/plan.test.ts new file mode 100644 index 000000000..10c4d9b76 --- /dev/null +++ b/packages/next-plugin/src/__tests__/plan.test.ts @@ -0,0 +1,219 @@ +import { join } from 'node:path' + +import * as pluginUtils from '@devup-ui/plugin-utils' +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +import { planSources, recoverPlanning } from '../plan' +import { createAppContext } from '../session' +import { box, installProjectHooks, makeProject } from './project' + +installProjectHooks() + +const originalEnv = { ...process.env } +afterEach(() => { + process.env = { ...originalEnv } +}) + +function app( + files: Record, + options = {}, + phase: 'development' | 'production' = 'production', +) { + process.env.NODE_ENV = phase + process.chdir(makeProject(files)) + return createAppContext({}, options) +} + +const page = (name: string) => box(`bg="${name}"`) + +describe('planSources', () => { + it('plans the compiled closure, the buckets and the numbering from the graph', () => { + const context = app({ + 'src/app/page.tsx': `import { Card } from '../components/Card'\n${page('red')}`, + 'src/app/layout.tsx': page('white'), + 'src/components/Card.tsx': box('p={4}'), + 'src/unused/dead.tsx': page('blue'), + 'src/plain.ts': 'export const x = 1', + }) + + const plan = planSources(context) + + expect(plan.expectedBaseFiles).toEqual([ + 'src/app/layout.tsx', + 'src/app/page.tsx', + 'src/components/Card.tsx', + ]) + expect(plan.canonicalMap).toEqual({ + 'src/components/Card.tsx': 'src/app/page.tsx', + }) + expect(plan.seedFiles).toEqual([ + 'src/app/layout.tsx', + 'src/app/page.tsx', + 'src/components/Card.tsx', + 'src/unused/dead.tsx', + ]) + expect(plan.graph?.files).toHaveLength(5) + expect(plan.fileRoutes).toEqual({}) + expect(plan.atomThreshold).toBeNull() + }) + + it('numbers root-level and nested app and pages files too', () => { + const context = app({ + 'app/page.tsx': page('red'), + 'pages/about.tsx': page('blue'), + 'src/pages/x.tsx': page('green'), + }) + + const plan = planSources(context) + + expect(plan.seedFiles).toEqual([ + 'app/page.tsx', + 'pages/about.tsx', + 'src/pages/x.tsx', + ]) + expect(plan.expectedBaseFiles).toEqual(['app/page.tsx', 'pages/about.tsx']) + expect(plan.graph?.files).toHaveLength(3) + }) + + it('plans atom hoisting when two routes exist', () => { + const shared = `import { Shared } from '../../shared'\n` + const context = app( + { + 'src/app/a/page.tsx': shared + page('red'), + 'src/app/b/page.tsx': shared + page('blue'), + 'src/shared.tsx': box('p={4}'), + }, + { atomHoist: 2 }, + ) + + const plan = planSources(context) + + expect(plan.atomThreshold).toBe(2) + expect(plan.fileRoutes).toMatchObject({ 'src/shared.tsx': [0, 1] }) + }) + + it('says so when atom hoisting has fewer than two routes', () => { + const info = spyOn(console, 'info').mockImplementation(() => {}) + const context = app({ 'src/app/page.tsx': page('red') }, { atomHoist: 2 }) + + try { + const plan = planSources(context) + + expect(plan.atomThreshold).toBeNull() + expect(plan.fileRoutes).toEqual({}) + expect(info).toHaveBeenCalledWith( + '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op.', + ) + } finally { + info.mockRestore() + } + }) + + it('skips the file-level hoist in atom mode and uses it otherwise', () => { + const build = spyOn(pluginUtils, 'buildCanonicalMap') + try { + process.env.DEVUP_HOIST_V = '2' + planSources(app({ 'src/app/page.tsx': page('red') }, {})) + planSources(app({ 'src/app/page.tsx': page('red') }, { atomHoist: 2 })) + + expect(build.mock.calls.map(([options]) => options.hoistV)).toEqual([ + 2, + undefined, + ]) + } finally { + build.mockRestore() + } + }) + + it('fails a production build with the location of a graph failure', () => { + const context = app({ src: 'not a directory' }) + + expect(() => planSources(context)).toThrow( + `${join(context.root, 'src')}:1:1: devup-ui import graph cannot use \`buildStaticImportGraph\` at build time:`, + ) + }) + + it('fails a production build with the location of a numbering failure', () => { + const context = app({ 'src/app/page.tsx': page('red') }) + const collect = spyOn( + pluginUtils, + 'collectNumberedFiles', + ).mockImplementation(() => { + throw new Error('scan boom') + }) + + try { + expect(() => planSources(context)).toThrow( + `${context.root}:1:1: devup-ui class numbering cannot use \`collectNumberedFiles\` at build time: scan boom; needs`, + ) + } finally { + collect.mockRestore() + } + }) + + it('warns in development, names what is lost and continues', () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + const context = app({ src: 'not a directory' }, {}, 'development') + + try { + const plan = planSources(context) + + expect(plan).toMatchObject({ + graph: undefined, + canonicalMap: {}, + expectedBaseFiles: [], + fileRoutes: {}, + atomThreshold: null, + seedFiles: [], + }) + const messages = warn.mock.calls.map(([message]) => String(message)) + expect(messages).toHaveLength(2) + expect(messages[0]).toContain( + `${join(context.root, 'src')}:1:1: devup-ui import graph cannot use`, + ) + expect(messages[0]).toContain( + 'Not guaranteed for this session: single-importer collapse, atom hoisting and the deterministic completion set.', + ) + expect(messages[1]).toContain( + 'Not guaranteed for this session: path-ordered class and file numbers.', + ) + } finally { + warn.mockRestore() + } + }) +}) + +describe('recoverPlanning', () => { + const fields = { + file: '/p/src', + what: 'devup-ui prewarm', + code: 'work', + needs: 'it to work', + lost: 'a guarantee', + fallback: 'fallback', + } + + it('returns the work when it succeeds', () => { + expect( + recoverPlanning({ + ...fields, + context: app({}), + work: () => 'done', + }), + ).toBe('done') + }) + + it('passes an error that is already located through unchanged', () => { + const located = new Error('/p/a.tsx:2:3: Box cannot use `x` at build time') + + expect(() => + recoverPlanning({ + ...fields, + context: app({}), + work: () => { + throw located + }, + }), + ).toThrow(located) + }) +}) diff --git a/packages/next-plugin/src/__tests__/plugin-coordinator.test.ts b/packages/next-plugin/src/__tests__/plugin-coordinator.test.ts new file mode 100644 index 000000000..c701175f2 --- /dev/null +++ b/packages/next-plugin/src/__tests__/plugin-coordinator.test.ts @@ -0,0 +1,162 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' + +import { describe, expect, it } from 'bun:test' +import type { NextConfig } from 'next' + +import { requestCoordinator } from '../coordinator-client' +import type { CoordinatorIdentity } from '../coordinator-port' +import { DevupUI } from '../plugin' +import { readCoordinatorState } from '../state' +import { box, installProjectHooks, makeProject } from './project' +import { installTurboHarness } from './turbo-harness' + +installProjectHooks() +const harness = installTurboHarness() + +interface LoaderOptions { + coordinatorPortFile: string + coordinatorIdentity: CoordinatorIdentity + revisionFile?: string +} + +function loaderOptions(config: NextConfig): LoaderOptions { + const serialized = JSON.parse(JSON.stringify(config.turbopack?.rules)) + return serialized['*.{tsx,ts,jsx,js,mjs,mts,cts,cjs}'].loaders[0].options +} + +function ask( + options: LoaderOptions, + path: string, + extra: { method?: 'GET' | 'POST'; body?: string } = {}, +) { + return requestCoordinator({ + portFile: options.coordinatorPortFile, + identity: options.coordinatorIdentity, + resourcePath: path, + path, + timeoutMs: 20_000, + ...extra, + }) +} + +async function extract(options: LoaderOptions, root: string, file: string) { + const resourcePath = join(root, file) + return JSON.parse( + await ask(options, '/extract', { + method: 'POST', + body: JSON.stringify({ + filename: file, + code: readFileSync(resourcePath, 'utf-8'), + resourcePath, + }), + }), + ) as { code: string } +} + +describe('the plugin with the real coordinator', () => { + it('serves a complete production stylesheet and drains after the compile', async () => { + harness.startRealCoordinators() + const root = makeProject({ + 'src/app/page.tsx': box('bg="red"'), + 'src/app/b/page.tsx': box('bg="blue"'), + 'src/dead/never.tsx': box('bg="green"'), + }) + process.chdir(root) + const config = DevupUI({}, { singleCss: true }) + const options = loaderOptions(config) + + const css = await ask(options, '/css?importMainCss=false&waitForIdle=true') + const code = await extract(options, root, 'src/app/page.tsx') + const again = await ask( + options, + '/css?importMainCss=false&waitForIdle=true', + ) + + expect(css).toContain('background:red') + expect(css).toContain('background:blue') + expect(css).not.toContain('background:green') + expect(again).toBe(css) + expect(code.code).toContain('devup-ui.css') + expect(existsSync(options.coordinatorPortFile)).toBe(true) + + await config.compiler?.runAfterProductionCompile?.({ + projectDir: root, + distDir: '.next', + }) + + expect(existsSync(options.coordinatorPortFile)).toBe(false) + harness.handlers.exit![0]!() + expect(existsSync(join(options.coordinatorPortFile, '..'))).toBe(false) + }) + + it('refuses a request that carries another app’s identity', async () => { + harness.startRealCoordinators() + process.chdir(makeProject({ 'src/app/page.tsx': box('bg="red"') })) + const config = DevupUI({}, { singleCss: true }) + const options = loaderOptions(config) + await ask(options, '/css?importMainCss=false&waitForIdle=true') + + await expect( + ask( + { + ...options, + coordinatorIdentity: { + ...options.coordinatorIdentity, + token: '00000000-0000-4000-8000-000000000000', + }, + }, + '/css?importMainCss=false&waitForIdle=true', + ), + ).rejects.toThrow('identity mismatch') + + harness.handlers.exit![0]!() + }) + + it('keeps names across a development restart and drops what was deleted', async () => { + harness.startRealCoordinators() + process.env.NODE_ENV = 'development' + const root = makeProject({ + 'src/app/a/page.tsx': box('bg="red"'), + 'src/app/b/page.tsx': box('bg="blue"'), + }) + process.chdir(root) + const first = loaderOptions(DevupUI({}, { singleCss: true })) + const firstCss = await ask(first, '/css?importMainCss=false') + const [firstExit] = harness.handlers.exit! + await harness.handlers.beforeExit![0]!() + firstExit!() + + rmSync(join(root, 'src/app/b/page.tsx')) + writeFileSync(join(root, 'src/app/c.tsx'), box('bg="green"')) + const second = loaderOptions(DevupUI({}, { singleCss: true })) + const secondCss = await ask(second, '/css?importMainCss=false') + await extract(second, root, 'src/app/c.tsx') + const laterCss = await ask(second, '/css?importMainCss=false') + await harness.handlers.beforeExit![1]!() + harness.handlers.exit![1]!() + + expect(firstCss).toContain('background:red') + expect(firstCss).toContain('background:blue') + expect(secondCss).toContain('background:red') + expect(secondCss).not.toContain('background:blue') + expect(secondCss).not.toContain('background:green') + expect(laterCss).toContain('background:green') + expect(second.coordinatorPortFile).not.toBe(first.coordinatorPortFile) + expect(existsSync(first.coordinatorPortFile)).toBe(false) + const appDir = dirname(dirname(dirname(second.coordinatorPortFile))) + const snapshot = readCoordinatorState( + join(appDir, 'snapshot.json'), + basename(appDir), + ) + expect(snapshot?.fileMap).toEqual({ + 'src/app/a/page.tsx': 0, + 'src/app/b/page.tsx': 1, + 'src/app/c.tsx': 2, + }) + expect(snapshot?.inputs.map((input) => input.filename)).toEqual([ + 'src/app/a/page.tsx', + 'src/app/c.tsx', + ]) + }) +}) diff --git a/packages/next-plugin/src/__tests__/plugin-determinism.test.ts b/packages/next-plugin/src/__tests__/plugin-determinism.test.ts new file mode 100644 index 000000000..849925ca0 --- /dev/null +++ b/packages/next-plugin/src/__tests__/plugin-determinism.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'bun:test' + +import type { CoordinatorOptions } from '../coordinator-options' +import { DevupUI } from '../plugin' +import { createWasm, type DevupWasm } from '../wasm' +import { installProjectHooks, makeProject } from './project' +import { installTurboHarness } from './turbo-harness' + +installProjectHooks() +const harness = installTurboHarness() + +const page = (body: string) => + `import { Box, keyframes } from '@devup-ui/react'\n${body}\n` + +const routes: Record = { + 'src/app/a/page.tsx': page( + 'const spin = keyframes({ from: { opacity: 0 }, to: { opacity: 1 } })\nexport default () => ', + ), + 'src/app/b/page.tsx': page( + 'const move = keyframes({ from: { top: 0 }, to: { top: 10 } })\nexport default () => ', + ), + 'src/app/c/page.tsx': page( + 'export default () => ', + ), +} + +const scenarios = [ + { name: 'per-file css', options: {} }, + { name: 'single css', options: { singleCss: true } }, + { name: 'atom hoisting', options: { atomHoist: 2 } }, + { + name: 'atom hoisting with a single css', + options: { atomHoist: 2, singleCss: true }, + }, +] + +function reversed(files: Record): Record { + return Object.fromEntries(Object.entries(files).reverse()) +} + +function request(start: CoordinatorOptions, order: string[]) { + const codes: Record = {} + for (const filename of order) { + const { source } = start.prewarmedOutputs!.get(filename)! + const cssDir = './df/devup-ui' + using output = start.wasm.codeExtract( + filename, + source, + start.package, + cssDir, + start.singleCss, + false, + true, + start.importAliases, + ) + codes[filename] = output.code + } + return codes +} + +function state(engine: DevupWasm) { + const files = JSON.parse(engine.exportFileMap()) as Record + return { + classMap: engine.exportClassMap(), + fileMap: engine.exportFileMap(), + base: engine.getCss(undefined, false), + buckets: Object.values(files) + .sort((a, b) => a - b) + .map((number) => engine.getCss(number, false)), + } +} + +function fresh(order: string[]) { + const engine = createWasm(makeProject()) + for (const filename of order) { + using output = engine.codeExtract( + filename, + routes[filename]!, + '@devup-ui/react', + './df/devup-ui', + false, + false, + true, + {}, + ) + expect(output.code).not.toBe('') + } + return engine.exportClassMap() +} + +describe('prewarm makes names independent of the order requests arrive in', () => { + const names = Object.keys(routes) + + it('proves the order matters when nothing is prewarmed', () => { + expect(fresh(names)).not.toBe(fresh([...names].reverse())) + }) + + describe.each(['development', 'production'] as const)('%s', (phase) => { + it.each(scenarios)( + 'gives the same classes, files and sheets for any request order: $name', + ({ options }) => { + process.env.NODE_ENV = phase + process.chdir(makeProject(routes)) + DevupUI({}, options) + process.chdir(makeProject(reversed(routes))) + DevupUI({}, options) + const [forward, backward] = harness.starts + + const prewarmed = state(forward!.wasm) + const forwardCodes = request(forward!, names) + const backwardCodes = request(backward!, [...names].reverse()) + + expect(state(backward!.wasm)).toEqual(prewarmed) + expect(state(forward!.wasm)).toEqual(prewarmed) + expect(backwardCodes).toEqual(forwardCodes) + for (const filename of names) { + expect(backward!.prewarmedOutputs!.get(filename)!.code).toBe( + forward!.prewarmedOutputs!.get(filename)!.code, + ) + } + }, + ) + }) + + it('numbers the files by path and the later ones after them in development', () => { + process.env.NODE_ENV = 'development' + const root = makeProject(routes) + process.chdir(root) + DevupUI({}, {}) + const [start] = harness.starts + + expect(JSON.parse(start!.wasm.exportFileMap())).toEqual({ + 'src/app/a/page.tsx': 0, + 'src/app/b/page.tsx': 1, + 'src/app/c/page.tsx': 2, + }) + using later = start!.wasm.codeExtract( + 'src/app/aaa.tsx', + page('export const X = () => '), + '@devup-ui/react', + './df/devup-ui', + false, + false, + true, + {}, + ) + expect(later.code).not.toBe('') + expect(JSON.parse(start!.wasm.exportFileMap())).toMatchObject({ + 'src/app/aaa.tsx': 3, + }) + }) + + it('numbers root-level app and pages files in path order too', () => { + process.chdir( + makeProject({ + 'pages/z.tsx': routes['src/app/c/page.tsx']!, + 'app/page.tsx': routes['src/app/b/page.tsx']!, + 'src/app/a/page.tsx': routes['src/app/a/page.tsx']!, + }), + ) + + DevupUI({}, {}) + + expect(JSON.parse(harness.starts[0]!.wasm.exportFileMap())).toEqual({ + 'app/page.tsx': 0, + 'pages/z.tsx': 1, + 'src/app/a/page.tsx': 2, + }) + }) +}) diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 8bc599b7b..ac92c5d92 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -1,1228 +1,767 @@ -import * as fs from 'node:fs' -import { join, resolve } from 'node:path' - -import type { StaticImportGraph } from '@devup-ui/plugin-utils' -import * as importGraphModule from '@devup-ui/plugin-utils' -import * as wasm from '@devup-ui/wasm' -import * as webpackPluginModule from '@devup-ui/webpack-plugin' -import { - afterEach, - beforeEach, - describe, - expect, - it, - mock, - spyOn, -} from 'bun:test' - -import * as coordinatorModule from '../coordinator' -import { - DevupUI, - reloadTurboSetupModuleForTesting, - resetTurboSetupCacheForTesting, -} from '../plugin' -import { setWasmForTesting, setWebpackPluginForTesting } from '../wasm' - -type CodeExtractResult = ReturnType -type NextWebpackConfig = Parameters< - NonNullable['webpack']> ->[0] -type NextWebpackContext = Parameters< - NonNullable['webpack']> ->[1] - -function createWebpackConfig(): NextWebpackConfig { - return { plugins: [] } as unknown as NextWebpackConfig -} - -function createWebpackContext( - overrides: Partial = {}, -): NextWebpackContext { - return { buildId: 'tmpBuildId', ...overrides } as NextWebpackContext -} - -function setNodeEnv(value: string): void { - process.env.NODE_ENV = value -} - -function createCodeExtractResult(contents: string): CodeExtractResult { - return { - css: '', - code: contents, - cssFile: '', - map: undefined, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - } as unknown as CodeExtractResult -} - -function createSingleFileGraph(filename: string): StaticImportGraph { - return { - files: [filename], - fileSet: new Set([filename]), - staticImports: new Map([[filename, new Set()]]), - staticImporters: new Map([[filename, new Set()]]), - dynamicTargets: new Set(), - dynamicImports: new Map([[filename, new Set()]]), - externalImports: new Map([[filename, new Set()]]), - } -} - -let existsSyncSpy: ReturnType -let mkdirSyncSpy: ReturnType -let readFileSyncSpy: ReturnType -let writeFileSyncSpy: ReturnType -let unlinkSyncSpy: ReturnType -let getDefaultThemeSpy: ReturnType -let getThemeInterfaceSpy: ReturnType -let setPrefixSpy: ReturnType -let registerThemeSpy: ReturnType -let getCssSpy: ReturnType -let importSheetSpy: ReturnType -let importClassMapSpy: ReturnType -let importFileMapSpy: ReturnType -let exportSheetSpy: ReturnType -let exportClassMapSpy: ReturnType -let exportFileMapSpy: ReturnType -let codeExtractSpy: ReturnType -let codeExtractWithoutSourceMapSpy: ReturnType -let devupUIWebpackPluginSpy: ReturnType -let startCoordinatorSpy: ReturnType - -let originalEnv: NodeJS.ProcessEnv -let originalFetch: typeof global.fetch -let originalDebugPort: number - -beforeEach(() => { - resetTurboSetupCacheForTesting() - existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) - mkdirSyncSpy = spyOn(fs, 'mkdirSync').mockReturnValue(undefined) - readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue('{}') - writeFileSyncSpy = spyOn(fs, 'writeFileSync').mockReturnValue(undefined) - unlinkSyncSpy = spyOn(fs, 'unlinkSync').mockReturnValue(undefined) - getDefaultThemeSpy = spyOn(wasm, 'getDefaultTheme').mockReturnValue(undefined) - getThemeInterfaceSpy = spyOn(wasm, 'getThemeInterface').mockReturnValue('') - setPrefixSpy = spyOn(wasm, 'setPrefix').mockReturnValue(undefined) - registerThemeSpy = spyOn(wasm, 'registerTheme').mockReturnValue(undefined) - getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('') - importSheetSpy = spyOn(wasm, 'importSheet').mockReturnValue(undefined) - importClassMapSpy = spyOn(wasm, 'importClassMap').mockReturnValue(undefined) - importFileMapSpy = spyOn(wasm, 'importFileMap').mockReturnValue(undefined) - exportSheetSpy = spyOn(wasm, 'exportSheet').mockReturnValue( - JSON.stringify({ - css: {}, - font_faces: {}, - global_css_files: [], - imports: {}, - keyframes: {}, - properties: {}, - }), - ) - exportClassMapSpy = spyOn(wasm, 'exportClassMap').mockReturnValue( - JSON.stringify({}), - ) - exportFileMapSpy = spyOn(wasm, 'exportFileMap').mockReturnValue( - JSON.stringify({}), - ) - codeExtractSpy = spyOn(wasm, 'codeExtract').mockImplementation( - (_path: string, contents: string) => createCodeExtractResult(contents), - ) - codeExtractWithoutSourceMapSpy = spyOn( - wasm, - 'codeExtractWithoutSourceMap', - ).mockImplementation((_path: string, contents: string) => - createCodeExtractResult(contents), - ) - devupUIWebpackPluginSpy = spyOn( - webpackPluginModule, - 'DevupUIWebpackPlugin', - ).mockImplementation(mock() as never) - startCoordinatorSpy = spyOn( - coordinatorModule, - 'startCoordinator', - ).mockReturnValue({ close: mock() as () => void }) - setWasmForTesting(wasm) - setWebpackPluginForTesting(webpackPluginModule) - - originalEnv = { ...process.env } - originalFetch = global.fetch - originalDebugPort = process.debugPort - global.fetch = mock(() => Promise.resolve({} as Response)) -}) - -afterEach(() => { - resetTurboSetupCacheForTesting() - setWasmForTesting(undefined) - setWebpackPluginForTesting(undefined) - process.env = originalEnv - global.fetch = originalFetch - process.debugPort = originalDebugPort - existsSyncSpy.mockRestore() - mkdirSyncSpy.mockRestore() - readFileSyncSpy.mockRestore() - writeFileSyncSpy.mockRestore() - unlinkSyncSpy.mockRestore() - getDefaultThemeSpy.mockRestore() - getThemeInterfaceSpy.mockRestore() - setPrefixSpy.mockRestore() - registerThemeSpy.mockRestore() - getCssSpy.mockRestore() - importSheetSpy.mockRestore() - importClassMapSpy.mockRestore() - importFileMapSpy.mockRestore() - exportSheetSpy.mockRestore() - exportClassMapSpy.mockRestore() - exportFileMapSpy.mockRestore() - codeExtractSpy.mockRestore() - codeExtractWithoutSourceMapSpy.mockRestore() - devupUIWebpackPluginSpy.mockRestore() - startCoordinatorSpy.mockRestore() -}) - -describe('DevupUINextPlugin', () => { - describe('webpack', () => { - it('should apply webpack plugin', async () => { - const ret = DevupUI({}) - - ret.webpack!(createWebpackConfig(), createWebpackContext()) - - expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ - cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), - }) - }) - - it('should apply webpack plugin with dev', async () => { - const ret = DevupUI({}) - - ret.webpack!(createWebpackConfig(), createWebpackContext({ dev: true })) - - expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ - cssDir: resolve('df', 'devup-ui_tmpBuildId'), - watch: true, - }) - }) - - it('should apply webpack plugin with config', async () => { - const ret = DevupUI( - {}, - { - package: 'new-package', - }, - ) - - ret.webpack!(createWebpackConfig(), createWebpackContext()) - - expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ - package: 'new-package', - cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), - }) - }) - - it('should apply webpack plugin with webpack obj', async () => { - const webpack = mock() - const ret = DevupUI( - { - webpack, - }, - { - package: 'new-package', - }, - ) - - ret.webpack!(createWebpackConfig(), createWebpackContext()) - - expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ - package: 'new-package', - cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), - }) - expect(webpack).toHaveBeenCalled() - }) - }) - describe('turbo', () => { - it('reuses production setup across Next config module reloads', () => { - setNodeEnv('production') - process.env.TURBOPACK = '1' - process.env.DEVUP_UI_PROFILE = '1' - getDefaultThemeSpy.mockReturnValue('dark') - const filename = resolve('src/app/page.tsx') - const graphSpy = spyOn( - importGraphModule, - 'buildStaticImportGraph', - ).mockReturnValue(createSingleFileGraph(filename)) - const compiledSpy = spyOn( - importGraphModule, - 'computeCompiledFiles', - ).mockReturnValue(['src/app/page.tsx']) - const profileSpy = spyOn(console, 'info').mockImplementation(() => {}) - const handoffFile = join('df', 'setup.bin') - let handoff: Parameters[1] | undefined - writeFileSyncSpy.mockImplementation((path, data) => { - if (String(path) === handoffFile) handoff = data - }) - readFileSyncSpy.mockImplementation((path) => { - if (String(path) === handoffFile && handoff !== undefined) { - return handoff as never - } - return '{}' as never - }) - unlinkSyncSpy.mockImplementation((path) => { - if (String(path) === handoffFile) throw new Error('cleanup failed') - }) - - try { - const first = DevupUI({}, { singleCss: true }) - reloadTurboSetupModuleForTesting() - const second = DevupUI( - { env: { EXISTING: 'value' } }, - { singleCss: true }, - ) - - expect(second.turbopack?.rules).toEqual(first.turbopack?.rules) - expect(second.env).toEqual({ - DEVUP_UI_DEFAULT_THEME: 'dark', - EXISTING: 'value', - }) - expect(graphSpy).toHaveBeenCalledTimes(1) - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(1) - expect(startCoordinatorSpy).toHaveBeenCalledTimes(1) - expect(unlinkSyncSpy).toHaveBeenCalledWith(handoffFile) - expect(profileSpy).toHaveBeenCalledWith( - expect.stringContaining('"cacheHit":true'), - ) - } finally { - profileSpy.mockRestore() - compiledSpy.mockRestore() - graphSpy.mockRestore() - } - }) - - it('falls back when a production setup handoff is not reusable', () => { - setNodeEnv('production') - process.env.TURBOPACK = '1' - const filename = resolve('src/app/page.tsx') - const graphSpy = spyOn( - importGraphModule, - 'buildStaticImportGraph', - ).mockReturnValue(createSingleFileGraph(filename)) - const compiledSpy = spyOn( - importGraphModule, - 'computeCompiledFiles', - ).mockReturnValue(['src/app/page.tsx']) - const handoffFile = join('df', 'setup.bin') - let failHandoffWrite = false - let handoff: Parameters[1] | undefined - writeFileSyncSpy.mockImplementation((path, data) => { - if (String(path) !== handoffFile) return - if (failHandoffWrite) throw new Error('write failed') - handoff = data - }) - readFileSyncSpy.mockImplementation((path) => { - if (String(path) === handoffFile && handoff !== undefined) { - return handoff as never - } - return '{}' as never - }) - - try { - DevupUI({}) - // The same module instance must not consume its own handoff. - DevupUI({}) - reloadTurboSetupModuleForTesting() - handoff = Buffer.from('invalid handoff') - failHandoffWrite = true - - // A corrupt read and a failed replacement write both fall back to the - // complete setup without leaking a token into another config load. - expect(() => DevupUI({})).not.toThrow() - expect(graphSpy).toHaveBeenCalledTimes(3) - expect(process.env.DEVUP_UI_TURBO_SETUP_TOKEN).toBeUndefined() - } finally { - compiledSpy.mockRestore() - graphSpy.mockRestore() - } - }) - - it('should apply turbo config', async () => { - process.env.TURBOPACK = '1' - existsSyncSpy - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - const ret = DevupUI({}) - - expect(ret).toEqual({ - turbopack: { - rules: { - './df/devup-ui/*.css': [ - { - loader: '@devup-ui/next-plugin/css-loader', - options: { - watch: false, - coordinatorPortFile: join('df', 'coordinator.port'), - sheetFile: join('df', 'sheet.json'), - classMapFile: join('df', 'classMap.json'), - fileMapFile: join('df', 'fileMap.json'), - themeFile: 'devup.json', - theme: {}, - defaultClassMap: {}, - defaultFileMap: {}, - defaultSheet: { - css: {}, - font_faces: {}, - global_css_files: [], - imports: {}, - keyframes: {}, - properties: {}, - }, - }, - }, - ], - '*.{tsx,ts,jsx,js,mjs}': { - loaders: [ - { - loader: '@devup-ui/next-plugin/loader', - options: { - package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), - coordinatorPortFile: join('df', 'coordinator.port'), - sheetFile: join('df', 'sheet.json'), - classMapFile: join('df', 'classMap.json'), - fileMapFile: join('df', 'fileMap.json'), - themeFile: 'devup.json', - watch: false, - singleCss: false, - theme: {}, - defaultClassMap: {}, - defaultFileMap: {}, - importAliases: { - '@emotion/react': null, - '@emotion/styled': 'styled', - '@vanilla-extract/css': null, - 'styled-components': 'styled', - }, - defaultSheet: { - css: {}, - font_faces: {}, - global_css_files: [], - imports: {}, - keyframes: {}, - properties: {}, - }, - }, - }, - ], - condition: { - not: { - path: new RegExp( - `(node_modules(?!.*(${['@devup-ui', '@devup-editor'] - .join('|') - .replaceAll( - '/', - '[\\/\\\\_]', - )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, - ), - }, - }, - }, - }, - }, - }) - }) - it('should apply turbo config with create df', async () => { - process.env.TURBOPACK = '1' - existsSyncSpy.mockReturnValue(false) - mkdirSyncSpy.mockReturnValue('') - writeFileSyncSpy.mockReturnValue(undefined) - const ret = DevupUI({}) - - expect(ret).toEqual({ - turbopack: { - rules: { - './df/devup-ui/*.css': [ - { - loader: '@devup-ui/next-plugin/css-loader', - options: { - watch: false, - coordinatorPortFile: join('df', 'coordinator.port'), - sheetFile: join('df', 'sheet.json'), - classMapFile: join('df', 'classMap.json'), - fileMapFile: join('df', 'fileMap.json'), - themeFile: 'devup.json', - theme: {}, - defaultClassMap: {}, - defaultFileMap: {}, - defaultSheet: { - css: {}, - font_faces: {}, - global_css_files: [], - imports: {}, - keyframes: {}, - properties: {}, - }, - }, - }, - ], - '*.{tsx,ts,jsx,js,mjs}': { - condition: { - not: { - path: new RegExp( - `(node_modules(?!.*(${['@devup-ui', '@devup-editor'] - .join('|') - .replaceAll( - '/', - '[\\/\\\\_]', - )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, - ), - }, - }, - loaders: [ - { - loader: '@devup-ui/next-plugin/loader', - options: { - package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), - coordinatorPortFile: join('df', 'coordinator.port'), - sheetFile: join('df', 'sheet.json'), - classMapFile: join('df', 'classMap.json'), - fileMapFile: join('df', 'fileMap.json'), - importAliases: { - '@emotion/react': null, - '@emotion/styled': 'styled', - '@vanilla-extract/css': null, - 'styled-components': 'styled', - }, - watch: false, - singleCss: false, - theme: {}, - defaultClassMap: {}, - defaultFileMap: {}, - defaultSheet: { - css: {}, - font_faces: {}, - global_css_files: [], - imports: {}, - keyframes: {}, - properties: {}, - }, - themeFile: 'devup.json', - }, - }, - ], - }, - }, - }, - }) - expect(mkdirSyncSpy).toHaveBeenCalledWith('df', { - recursive: true, - }) - expect(writeFileSyncSpy).toHaveBeenCalledWith( - join('df', '.gitignore'), - '*', - ) - }) - it('should apply turbo config with exists df and devup.json', async () => { - process.env.TURBOPACK = '1' - existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockReturnValue(JSON.stringify({ theme: 'theme' })) - mkdirSyncSpy.mockReturnValue('') - writeFileSyncSpy.mockReturnValue(undefined) - const ret = DevupUI({}) - - expect(ret).toEqual({ - turbopack: { - rules: { - './df/devup-ui/*.css': [ - { - loader: '@devup-ui/next-plugin/css-loader', - options: { - watch: false, - coordinatorPortFile: join('df', 'coordinator.port'), - sheetFile: join('df', 'sheet.json'), - classMapFile: join('df', 'classMap.json'), - fileMapFile: join('df', 'fileMap.json'), - themeFile: 'devup.json', - theme: 'theme', - defaultClassMap: {}, - defaultFileMap: {}, - defaultSheet: { - css: {}, - font_faces: {}, - global_css_files: [], - imports: {}, - keyframes: {}, - properties: {}, - }, - }, - }, - ], - '*.{tsx,ts,jsx,js,mjs}': { - condition: { - not: { - path: new RegExp( - `(node_modules(?!.*(${['@devup-ui', '@devup-editor'] - .join('|') - .replaceAll( - '/', - '[\\/\\\\_]', - )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, - ), - }, - }, - loaders: [ - { - loader: '@devup-ui/next-plugin/loader', - options: { - package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), - coordinatorPortFile: join('df', 'coordinator.port'), - sheetFile: join('df', 'sheet.json'), - classMapFile: join('df', 'classMap.json'), - fileMapFile: join('df', 'fileMap.json'), - watch: false, - singleCss: false, - theme: 'theme', - defaultClassMap: {}, - defaultFileMap: {}, - importAliases: { - '@emotion/react': null, - '@emotion/styled': 'styled', - '@vanilla-extract/css': null, - 'styled-components': 'styled', - }, - defaultSheet: { - css: {}, - font_faces: {}, - global_css_files: [], - imports: {}, - keyframes: {}, - properties: {}, - }, - themeFile: 'devup.json', - }, - }, - ], - }, - }, - }, - }) - // mkdirSync is NOT called when existsSync returns true - expect(mkdirSyncSpy).not.toHaveBeenCalled() - // gitignore is also NOT written when it exists - expect(writeFileSyncSpy).not.toHaveBeenCalledWith( - join('df', '.gitignore'), - '*', - ) - }) - it('should start coordinator even in production mode', () => { - setNodeEnv('production') - process.env.TURBOPACK = '1' - existsSyncSpy - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - const ret = DevupUI({}) - expect(ret).toEqual({ - turbopack: { - rules: expect.any(Object), - }, - }) - expect(startCoordinatorSpy).toHaveBeenCalledWith({ - wasm, - package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), - singleCss: false, - sheetFile: join('df', 'sheet.json'), - classMapFile: join('df', 'classMap.json'), - fileMapFile: join('df', 'fileMap.json'), - importAliases: { - '@emotion/react': null, - '@emotion/styled': 'styled', - '@vanilla-extract/css': null, - 'styled-components': 'styled', - }, - coordinatorPortFile: join('df', 'coordinator.port'), - canonicalMap: expect.any(Object), - expectedBaseFiles: expect.any(Array), - prewarmedFiles: expect.any(Array), - prewarmedOutputs: expect.any(Map), - sourceMap: false, - }) - }) - it('extracts vanilla-extract modules as written', () => { - setNodeEnv('production') - process.env.TURBOPACK = '1' - const filename = resolve('src/styles.css.ts') - const source = `import { style } from '@vanilla-extract/css' -export const box = style({ color: 'red' })` - const graphSpy = spyOn( - importGraphModule, - 'buildStaticImportGraph', - ).mockReturnValue(createSingleFileGraph(filename)) - const compiledSpy = spyOn( - importGraphModule, - 'computeCompiledFiles', - ).mockReturnValue(['src/styles.css.ts']) - readFileSyncSpy.mockImplementation((path: fs.PathOrFileDescriptor) => - String(path).endsWith('styles.css.ts') ? source : '{}', - ) - - try { - DevupUI({}) - - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( - 'src/styles.css.ts', - source, - '@devup-ui/react', - expect.any(String), - false, - false, - true, - expect.anything(), - ) - } finally { - graphSpy.mockRestore() - compiledSpy.mockRestore() - } - }) - it('keeps source maps when Next production browser source maps are enabled', () => { - setNodeEnv('production') - process.env.TURBOPACK = '1' - - DevupUI({ productionBrowserSourceMaps: true }) - - expect(startCoordinatorSpy).toHaveBeenCalledWith( - expect.objectContaining({ sourceMap: true }), - ) - }) - it('should create theme.d.ts file', async () => { - process.env.TURBOPACK = '1' - existsSyncSpy.mockReturnValue(true) - getThemeInterfaceSpy.mockReturnValue('interface code') - readFileSyncSpy.mockReturnValue(JSON.stringify({ theme: 'theme' })) - mkdirSyncSpy.mockReturnValue('') - writeFileSyncSpy.mockReturnValue(undefined) - DevupUI({}) - expect(writeFileSyncSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), - 'interface code', - ) - // mkdirSync is NOT called when existsSync returns true - expect(mkdirSyncSpy).not.toHaveBeenCalled() - }) - it('should set DEVUP_UI_DEFAULT_THEME when getDefaultTheme returns a value', async () => { - process.env.TURBOPACK = '1' - process.env.DEVUP_UI_DEFAULT_THEME = '' - existsSyncSpy - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - getDefaultThemeSpy.mockReturnValue('dark') - const config: any = {} - const ret = DevupUI(config) - - expect(process.env.DEVUP_UI_DEFAULT_THEME).toBe('dark') - expect(ret.env).toEqual({ - DEVUP_UI_DEFAULT_THEME: 'dark', - }) - expect(config.env).toEqual({ - DEVUP_UI_DEFAULT_THEME: 'dark', - }) - }) - it('should not set DEVUP_UI_DEFAULT_THEME when getDefaultTheme returns undefined', async () => { - process.env.TURBOPACK = '1' - process.env.DEVUP_UI_DEFAULT_THEME = '' - existsSyncSpy - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - getDefaultThemeSpy.mockReturnValue(undefined) - const config: any = {} - const ret = DevupUI(config) - - expect(process.env.DEVUP_UI_DEFAULT_THEME).toBe('') - expect(ret.env).toBeUndefined() - expect(config.env).toBeUndefined() - }) - it('should set DEVUP_UI_DEFAULT_THEME and preserve existing env vars', async () => { - process.env.TURBOPACK = '1' - process.env.DEVUP_UI_DEFAULT_THEME = '' - existsSyncSpy - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - getDefaultThemeSpy.mockReturnValue('light') - const config: any = { - env: { - CUSTOM_VAR: 'value', - }, - } - const ret = DevupUI(config) - - expect(process.env.DEVUP_UI_DEFAULT_THEME).toBe('light') - expect(ret.env).toEqual({ - CUSTOM_VAR: 'value', - DEVUP_UI_DEFAULT_THEME: 'light', - }) - expect(config.env).toEqual({ - CUSTOM_VAR: 'value', - DEVUP_UI_DEFAULT_THEME: 'light', - }) - }) - it('should call setPrefix when prefix option is provided', async () => { - process.env.TURBOPACK = '1' - existsSyncSpy - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - DevupUI({}, { prefix: 'my-prefix' }) - expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') - }) - it('should import previous session state on restart', () => { - process.env.TURBOPACK = '1' - existsSyncSpy - .mockReturnValueOnce(true) // distDir - .mockReturnValueOnce(true) // cssDir - .mockReturnValueOnce(true) // gitignoreFile - .mockReturnValueOnce(false) // devupFile in loadDevupConfigSync - - // Simulate previous session state files on disk - const prevSheet = { - css: { a: 'color:red' }, - font_faces: {}, - global_css_files: [], - imports: {}, - keyframes: {}, - properties: {}, - } - const prevClassMap = { a: 0 } - const prevFileMap = { 'src/App.tsx': 0 } - - readFileSyncSpy - .mockReturnValueOnce(JSON.stringify(prevSheet)) // sheetFile - .mockReturnValueOnce(JSON.stringify(prevClassMap)) // classMapFile - .mockReturnValueOnce(JSON.stringify(prevFileMap)) // fileMapFile - - DevupUI({}) - - // Verify previous state was imported before registerTheme - expect(importSheetSpy).toHaveBeenCalledWith(prevSheet) - expect(importClassMapSpy).toHaveBeenCalledWith(prevClassMap) - expect(importFileMapSpy).toHaveBeenCalledWith(prevFileMap) - expect(registerThemeSpy).toHaveBeenCalledWith({}) - - // Verify stale port file was deleted before starting coordinator - expect(unlinkSyncSpy).toHaveBeenCalledWith(join('df', 'coordinator.port')) - }) - it('should handle missing state files gracefully on first run', () => { - process.env.TURBOPACK = '1' - existsSyncSpy - .mockReturnValueOnce(false) // distDir — doesn't exist - .mockReturnValueOnce(false) // cssDir - .mockReturnValueOnce(false) // gitignoreFile - .mockReturnValueOnce(false) // devupFile - - // readFileSync throws for state files (they don't exist) - readFileSyncSpy.mockImplementation((path: string) => { - throw new Error(`ENOENT: no such file or directory, open '${path}'`) - }) - - // Should not throw — try-catch handles missing files - DevupUI({}) - - // importSheet should NOT have been called (readFileSync threw) - expect(importSheetSpy).not.toHaveBeenCalled() - expect(importClassMapSpy).not.toHaveBeenCalled() - expect(importFileMapSpy).not.toHaveBeenCalled() - - // registerTheme should still be called with empty theme - expect(registerThemeSpy).toHaveBeenCalledWith({}) - }) - it('should start coordinator in development mode', async () => { - process.env.TURBOPACK = '1' - setNodeEnv('development') - existsSyncSpy - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - writeFileSyncSpy.mockReturnValue(undefined) - - const closeMock = mock() as () => void - startCoordinatorSpy.mockReturnValue({ close: closeMock }) - - const exitHandlers: (() => void)[] = [] - const processOnSpy = spyOn(process, 'on').mockImplementation( - (event: string, handler: (...args: unknown[]) => void) => { - if (event === 'exit') exitHandlers.push(handler as () => void) - return process - }, - ) - - DevupUI({}) - - // Verify coordinator was started with correct options - expect(startCoordinatorSpy).toHaveBeenCalledWith({ - wasm, - package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), - singleCss: false, - sheetFile: join('df', 'sheet.json'), - classMapFile: join('df', 'classMap.json'), - fileMapFile: join('df', 'fileMap.json'), - importAliases: { - '@emotion/react': null, - '@emotion/styled': 'styled', - '@vanilla-extract/css': null, - 'styled-components': 'styled', - }, - coordinatorPortFile: join('df', 'coordinator.port'), - canonicalMap: expect.any(Object), - expectedBaseFiles: expect.any(Array), - prewarmedFiles: [], - prewarmedOutputs: expect.any(Map), - sourceMap: true, - }) - expect(codeExtractSpy).not.toHaveBeenCalled() - expect(codeExtractWithoutSourceMapSpy).not.toHaveBeenCalled() - - // Verify initial CSS file is written - expect(writeFileSyncSpy).toHaveBeenCalledWith( - join(resolve('df', 'devup-ui'), 'devup-ui.css'), - '', - ) - - // Verify exit handler was registered and calls coordinator.close() - expect(exitHandlers.length).toBeGreaterThan(0) - exitHandlers[0]!() - expect(closeMock).toHaveBeenCalledTimes(1) - - // Verify --inspect-brk env vars are NOT set - expect(process.env.TURBOPACK_DEBUG_JS).toBeUndefined() - expect(process.env.NODE_OPTIONS ?? '').not.toContain('--inspect-brk') - - processOnSpy.mockRestore() - }) - - it('hands the coordinator the full compiled-file set, not the static-only route map', () => { - process.env.TURBOPACK = '1' - process.env.DEVUP_UI_PROFILE = '1' - const profileSpy = spyOn(console, 'info').mockImplementation(() => {}) - // The base sheet must wait for lazily-loaded modules too, so - // expectedBaseFiles comes from computeCompiledFiles (static + dynamic - // edges) rather than computeFileRoutes (static edges only). Using the - // route map here served the sheet before any dynamic() module extracted. - const compiledSpy = spyOn( - importGraphModule, - 'computeCompiledFiles', - ).mockReturnValue(['src/app/page.tsx', 'src/lazy/panel.tsx']) - const routesSpy = spyOn( - importGraphModule, - 'computeFileRoutes', - ).mockReturnValue({ 'src/app/page.tsx': [0] }) - const events: string[] = [] - codeExtractWithoutSourceMapSpy.mockImplementation( - (filename: string, contents: string) => { - events.push(`extract:${filename}`) - return Object.assign(createCodeExtractResult(contents), { - dependencies: [`${filename}.tokens.ts`], - }) - }, - ) - startCoordinatorSpy.mockImplementation(() => { - events.push('startCoordinator') - return { close: mock() as () => void } - }) - try { - DevupUI({}) - - expect(startCoordinatorSpy).toHaveBeenCalledWith( - expect.objectContaining({ - expectedBaseFiles: ['src/app/page.tsx', 'src/lazy/panel.tsx'], - prewarmedFiles: ['src/app/page.tsx', 'src/lazy/panel.tsx'], - }), - ) - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(2) - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( - 'src/app/page.tsx', - '{}', - '@devup-ui/react', - expect.any(String), - false, - false, - true, - expect.anything(), - ) - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( - 'src/lazy/panel.tsx', - '{}', - '@devup-ui/react', - expect.any(String), - false, - false, - true, - expect.anything(), - ) - expect(events).toEqual([ - 'extract:src/app/page.tsx', - 'extract:src/lazy/panel.tsx', - 'startCoordinator', - ]) - const coordinatorOptions = startCoordinatorSpy.mock.calls[0]?.[0] - expect(coordinatorOptions?.prewarmedOutputs).toEqual( - new Map([ - [ - 'src/app/page.tsx', - expect.objectContaining({ - code: '{}', - source: '{}', - dependencies: ['src/app/page.tsx.tokens.ts'], - }), - ], - [ - 'src/lazy/panel.tsx', - expect.objectContaining({ - code: '{}', - source: '{}', - dependencies: ['src/lazy/panel.tsx.tokens.ts'], - }), - ], - ]), - ) - const profiles: Record[] = profileSpy.mock.calls - .map(([value]) => value) - .filter( - (value): value is string => - typeof value === 'string' && - value.startsWith('[devup-ui:profile] '), - ) - .map((value) => JSON.parse(value.slice('[devup-ui:profile] '.length))) - const prewarmProfile = profiles.find( - ({ phase }) => phase === 'next.prewarm', - ) - expect(prewarmProfile).toMatchObject({ - collectMs: expect.any(Number), - extractMs: expect.any(Number), - files: 2, - phase: 'next.prewarm', - readMs: expect.any(Number), - sourceBytes: Buffer.byteLength('{}') * 2, - }) - expect(profiles).toEqual( - expect.arrayContaining([ - expect.objectContaining({ phase: 'next.initialCss' }), - expect.objectContaining({ phase: 'next.stateSnapshot' }), - expect.objectContaining({ phase: 'next.setup' }), - ]), - ) - // the static-only route map is not consulted outside atom-hoist mode - expect(routesSpy).not.toHaveBeenCalled() - } finally { - profileSpy.mockRestore() - compiledSpy.mockRestore() - routesSpy.mockRestore() - } - }) - - it('prewarms source candidates hidden from the route closure', () => { - process.env.TURBOPACK = '1' - const page = resolve('src/app/page.tsx') - const templateTarget = resolve('src/demos/template-target.tsx') - const graphSpy = spyOn( - importGraphModule, - 'buildStaticImportGraph', - ).mockReturnValue({ - files: [page, templateTarget], - fileSet: new Set([page, templateTarget]), - staticImports: new Map([ - [page, new Set()], - [templateTarget, new Set()], - ]), - staticImporters: new Map([ - [page, new Set()], - [templateTarget, new Set()], - ]), - dynamicTargets: new Set(), - dynamicImports: new Map([ - [page, new Set()], - [templateTarget, new Set()], - ]), - externalImports: new Map([ - [page, new Set()], - [templateTarget, new Set()], - ]), - }) - const compiledSpy = spyOn( - importGraphModule, - 'computeCompiledFiles', - ).mockReturnValue(['src/app/page.tsx']) - try { - DevupUI({}) - - expect(startCoordinatorSpy).toHaveBeenCalledWith( - expect.objectContaining({ - expectedBaseFiles: ['src/app/page.tsx'], - prewarmedFiles: [ - 'src/app/page.tsx', - 'src/demos/template-target.tsx', - ], - }), - ) - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(2) - } finally { - graphSpy.mockRestore() - compiledSpy.mockRestore() - } - }) - - it('prewarms the same complete file set in singleCss mode', () => { - process.env.TURBOPACK = '1' - const compiledSpy = spyOn( - importGraphModule, - 'computeCompiledFiles', - ).mockReturnValue(['src/app/page.tsx', 'src/app/card.tsx']) - try { - DevupUI({}, { singleCss: true }) - - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(2) - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( - 'src/app/card.tsx', - '{}', - '@devup-ui/react', - expect.any(String), - true, - false, - true, - expect.anything(), - ) - expect(startCoordinatorSpy).toHaveBeenCalledWith( - expect.objectContaining({ - singleCss: true, - prewarmedFiles: ['src/app/card.tsx', 'src/app/page.tsx'], - }), - ) - } finally { - compiledSpy.mockRestore() - } - }) - - it('does not enable atom hoisting when atomHoist option is unset', () => { - process.env.TURBOPACK = '1' - const setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue( - undefined, - ) - const importFileRoutesSpy = spyOn( - wasm, - 'importFileRoutes', - ).mockReturnValue(undefined) - const importCanonicalMapSpy = spyOn( - wasm, - 'importCanonicalMap', - ).mockReturnValue(undefined) - try { - DevupUI({}) - expect(setAtomHoistSpy).not.toHaveBeenCalled() - expect(importFileRoutesSpy).not.toHaveBeenCalled() - // single-importer collapse still runs (it is the always-on default) - expect(importCanonicalMapSpy).toHaveBeenCalled() - } finally { - setAtomHoistSpy.mockRestore() - importFileRoutesSpy.mockRestore() - importCanonicalMapSpy.mockRestore() - } - }) - - it('composes atom hoisting WITH single-importer collapse when atomHoist is set', () => { - process.env.TURBOPACK = '1' - // 4 distinct leaf routes (ids 0..3); layout shared by all four. - const computeSpy = spyOn( - importGraphModule, - 'computeFileRoutes', - ).mockReturnValue({ - 'src/app/layout.tsx': [0, 1, 2, 3], - 'src/app/a/page.tsx': [0], - 'src/app/b/page.tsx': [1], - 'src/app/c/page.tsx': [2], - 'src/app/d/page.tsx': [3], - }) - const importFileRoutesSpy = spyOn( - wasm, - 'importFileRoutes', - ).mockReturnValue(undefined) - const setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue( - undefined, - ) - // Collapse and atom hoisting COMPOSE: importCanonicalMap must STILL run. - const importCanonicalMapSpy = spyOn( - wasm, - 'importCanonicalMap', - ).mockReturnValue(undefined) - try { - DevupUI({}, { atomHoist: 2 }) - // reach folded by bucket; mocked FS => empty canonical map => bucket==file - expect(importFileRoutesSpy).toHaveBeenCalledWith({ - 'src/app/layout.tsx': [0, 1, 2, 3], - 'src/app/a/page.tsx': [0], - 'src/app/b/page.tsx': [1], - 'src/app/c/page.tsx': [2], - 'src/app/d/page.tsx': [3], - }) - // direct threshold, clamped to >= 2 - expect(setAtomHoistSpy).toHaveBeenCalledWith(2) - // composition: collapse pre-pass also ran - expect(importCanonicalMapSpy).toHaveBeenCalled() - } finally { - computeSpy.mockRestore() - importFileRoutesSpy.mockRestore() - setAtomHoistSpy.mockRestore() - importCanonicalMapSpy.mockRestore() - } - }) - - it('clamps the atomHoist threshold to a minimum of 2', () => { - process.env.TURBOPACK = '1' - const computeSpy = spyOn( - importGraphModule, - 'computeFileRoutes', - ).mockReturnValue({ - 'src/app/a/page.tsx': [0], - 'src/app/b/page.tsx': [1], - }) - const setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue( - undefined, - ) - try { - DevupUI({}, { atomHoist: 1 }) - // max(2, 1) === 2 - expect(setAtomHoistSpy).toHaveBeenCalledWith(2) - } finally { - computeSpy.mockRestore() - setAtomHoistSpy.mockRestore() - } - }) - - it('keeps atom hoisting off when fewer than two routes exist', () => { - process.env.TURBOPACK = '1' - const computeSpy = spyOn( - importGraphModule, - 'computeFileRoutes', - ).mockReturnValue({ 'src/app/a/page.tsx': [0] }) - const setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue( - undefined, - ) - try { - DevupUI({}, { atomHoist: 2 }) - expect(setAtomHoistSpy).not.toHaveBeenCalled() - } finally { - computeSpy.mockRestore() - setAtomHoistSpy.mockRestore() - } - }) - }) -}) +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { + createCompatTypes, + createNodeModulesExcludeRegex, + mergeImportAliases, +} from '@devup-ui/plugin-utils' +import * as webpackPluginModule from '@devup-ui/webpack-plugin' +import { describe, expect, it, mock, spyOn } from 'bun:test' +import type { NextConfig } from 'next' + +import { DevupUI, reloadTurboSetupModuleForTesting } from '../plugin' +import { readCoordinatorState } from '../state' +import { setWebpackPluginForTesting } from '../wasm' +import { box, installProjectHooks, makeProject } from './project' +import { installTurboHarness } from './turbo-harness' + +installProjectHooks() +const harness = installTurboHarness() + +type NextWebpackConfig = Parameters< + NonNullable['webpack']> +>[0] +type NextWebpackContext = Parameters< + NonNullable['webpack']> +>[1] + +const SOURCE_RULE = '*.{tsx,ts,jsx,js,mjs,mts,cts,cjs}' +const page = box('bg="red"') + +function project(extra: Record = {}): string { + const root = makeProject({ 'src/app/page.tsx': page, ...extra }) + process.chdir(root) + return root +} + +function development(): void { + process.env.NODE_ENV = 'development' +} + +describe('webpack', () => { + function run( + options: Parameters[1], + context: Partial, + userWebpack?: ReturnType, + ) { + delete process.env.TURBOPACK + const spy = spyOn( + webpackPluginModule, + 'DevupUIWebpackPlugin', + ).mockImplementation(mock() as never) + setWebpackPluginForTesting(webpackPluginModule) + try { + const ret = DevupUI({ webpack: userWebpack }, options) + ret.webpack!( + { plugins: [] } as unknown as NextWebpackConfig, + { buildId: 'tmpBuildId', ...context } as NextWebpackContext, + ) + return spy + } finally { + setWebpackPluginForTesting(undefined) + } + } + + it('applies the webpack plugin with the production cache directory', () => { + const spy = run({}, {}) + + expect(spy).toHaveBeenCalledWith({ + cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + }) + spy.mockRestore() + }) + + it('applies the webpack plugin in dev', () => { + const spy = run({}, { dev: true }) + + expect(spy).toHaveBeenCalledWith({ + cssDir: resolve('df', 'devup-ui_tmpBuildId'), + watch: true, + }) + spy.mockRestore() + }) + + it('forwards the options and calls an existing webpack function', () => { + const webpack = mock() + + const spy = run({ package: 'new-package' }, {}, webpack) + + expect(spy).toHaveBeenCalledWith({ + package: 'new-package', + cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + }) + expect(webpack).toHaveBeenCalled() + spy.mockRestore() + }) +}) + +describe('turbopack setup', () => { + it('applies every option explicitly, with absolute paths and one identity', () => { + const root = project({ + 'cfg/devup.json': JSON.stringify({ + extends: ['./base.json'], + theme: { colors: { dark: { primary: '#000' } } }, + }), + 'cfg/base.json': JSON.stringify({ + theme: { colors: { light: { primary: '#fff' } } }, + }), + }) + const config: NextConfig = { env: { EXISTING: 'value' } } + + const result = DevupUI(config, { + package: '@devup-ui/react', + distDir: 'out', + cssDir: 'out/styles', + singleCss: true, + devupFile: 'cfg/devup.json', + include: ['@acme/ui'], + prefix: 'du-', + shorthands: { insetX: ['left', 'right'] }, + atomHoist: 2, + debug: true, + importAliases: { 'styled-components': false }, + }) + + expect(result).toBe(config) + const [start] = harness.starts + expect(start).toMatchObject({ + package: '@devup-ui/react', + cssDir: join(root, 'out', 'styles'), + singleCss: true, + projectRoot: root, + watch: false, + devupFile: join(root, 'cfg', 'devup.json'), + sourceRoots: ['src', 'app', 'pages'].map((dir) => join(root, dir)), + sourceMap: false, + importAliases: { + '@emotion/react': null, + '@emotion/styled': 'styled', + '@vanilla-extract/css': null, + }, + expectedBaseFiles: ['src/app/page.tsx'], + prewarmedFiles: ['src/app/page.tsx'], + }) + expect(start!.wasm.isDebug()).toBe(true) + expect(start!.wasm.getPrefix()).toBe('du-') + expect(start!.identity?.project).toBe(root) + expect(start!.coordinatorPortFile).toBe( + join( + root, + 'out', + '.devup', + start!.optionsKey!, + 'sessions', + `${process.pid}-${start!.identity?.token}`, + 'endpoint.json', + ), + ) + expect(result.env).toEqual({ + EXISTING: 'value', + DEVUP_UI_DEFAULT_THEME: 'light', + }) + expect(existsSync(join(root, 'out', 'styles', 'devup-ui.css'))).toBe(true) + }) + + it('applies the defaults explicitly too', () => { + project() + + DevupUI({}) + + const [start] = harness.starts + expect(start!.wasm.isDebug()).toBe(false) + expect(start!.wasm.getPrefix()).toBeUndefined() + expect(start).toMatchObject({ + package: '@devup-ui/react', + singleCss: false, + watch: false, + sourceMap: false, + canonicalMap: {}, + expectedBaseFiles: ['src/app/page.tsx'], + }) + expect(start!.stateFile).toBeUndefined() + expect(start!.revisionFile).toBeUndefined() + expect(start!.configureWasm).toBeFunction() + expect(start!.createEngine).toBeFunction() + expect(start!.createEngine!()).not.toBe(start!.wasm) + }) + + it('configures every engine it builds, including the rebuilds', () => { + project() + DevupUI({}, { debug: true, prefix: 'du-' }) + const [start] = harness.starts + + const fresh = start!.createEngine!() + start!.configureWasm!(fresh) + + expect(fresh.isDebug()).toBe(true) + expect(fresh.getPrefix()).toBe('du-') + }) + + it('hands the loaders one identity, endpoint and the config files', () => { + const root = project({ + 'devup.json': JSON.stringify({ extends: ['./base.json'] }), + 'base.json': JSON.stringify({ + theme: { colors: { default: { primary: '#fff' } } }, + }), + }) + + const result = DevupUI({}, { include: ['@acme/ui'] }) + + const [start] = harness.starts + const common = { + coordinatorPortFile: start!.coordinatorPortFile, + coordinatorIdentity: start!.identity, + projectRoot: root, + themeFiles: [join(root, 'devup.json'), join(root, 'base.json')], + requestTimeoutMs: 120_000, + watch: false, + themeFile: join(root, 'devup.json'), + theme: { colors: { default: { primary: '#fff' } } }, + defaultSheet: {}, + defaultClassMap: {}, + defaultFileMap: {}, + } + expect(result.turbopack?.rules).toEqual({ + './df/devup-ui/*.css': [ + { + loader: '@devup-ui/next-plugin/css-loader', + options: expect.objectContaining(common), + }, + ], + [SOURCE_RULE]: { + loaders: [ + { + loader: '@devup-ui/next-plugin/loader', + options: expect.objectContaining({ + ...common, + package: '@devup-ui/react', + cssDir: join(root, 'df', 'devup-ui'), + singleCss: false, + importAliases: { + '@emotion/react': null, + '@emotion/styled': 'styled', + '@vanilla-extract/css': null, + 'styled-components': 'styled', + }, + }), + }, + ], + condition: { + not: { + path: createNodeModulesExcludeRegex(['@acme/ui']), + }, + }, + }, + }) + expect(result.turbopack?.rules?.[SOURCE_RULE]).not.toHaveProperty( + 'loaders.0.options.revisionFile', + ) + }) + + it('keeps existing Turbopack rules', () => { + project() + + const result = DevupUI({ turbopack: { rules: { '*.svg': ['svgr'] } } }) + + expect(result.turbopack?.rules).toHaveProperty(['*.svg']) + expect(result.turbopack?.rules).toHaveProperty([SOURCE_RULE]) + }) + + it('matches every module extension Turbopack compiles', () => { + project() + const rule = Object.keys(DevupUI({}).turbopack?.rules ?? {}).find((key) => + key.startsWith('*.'), + ) + + expect(rule).toBe('*.{tsx,ts,jsx,js,mjs,mts,cts,cjs}') + }) + + it('starts development with a state file and a revision file', () => { + development() + const root = project() + const seen: { state: boolean; revision: boolean }[] = [] + harness.onStart = (options) => + seen.push({ + state: existsSync(options.stateFile!), + revision: existsSync(options.revisionFile!), + }) + + const result = DevupUI({}) + + const [start] = harness.starts + expect(start).toMatchObject({ + watch: true, + sourceMap: true, + stateFile: join( + root, + 'df', + '.devup', + start!.optionsKey!, + 'snapshot.json', + ), + revisionFile: join( + root, + 'df', + '.devup', + start!.optionsKey!, + 'sessions', + `${process.pid}-${start!.identity?.token}`, + 'revision', + ), + }) + expect(seen).toEqual([{ state: true, revision: false }]) + const loader = JSON.stringify(result.turbopack?.rules) + expect(loader).toContain('"watch":true') + expect(loader).toContain(JSON.stringify(start!.revisionFile).slice(1, -1)) + expect(result.compiler).toBeUndefined() + }) + + it('commits the initial development state before the coordinator can publish', () => { + development() + project({ 'src/app/b/page.tsx': box('bg="blue"') }) + let committed: ReturnType + harness.onStart = (options) => { + committed = readCoordinatorState(options.stateFile!, options.optionsKey!) + } + + DevupUI({}) + + expect(committed).toMatchObject({ + version: 1, + revision: 0, + fileMap: { 'src/app/b/page.tsx': 0, 'src/app/page.tsx': 1 }, + }) + expect(committed?.inputs.map((input) => input.filename)).toEqual([ + 'src/app/b/page.tsx', + 'src/app/page.tsx', + ]) + }) + + it('writes the generated files once and keeps the CSS placeholder content stable', () => { + const root = project({ + 'devup.json': JSON.stringify({ + theme: { colors: { default: { primary: '#fff' } } }, + }), + }) + + DevupUI({}) + DevupUI({}) + + const dist = join(root, 'df') + expect(readFileSync(join(dist, '.gitignore'), 'utf-8')).toBe('*') + expect(readFileSync(join(dist, 'compat.d.ts'), 'utf-8')).toBe( + createCompatTypes(mergeImportAliases()), + ) + expect(readFileSync(join(dist, 'theme.d.ts'), 'utf-8')).toContain('primary') + expect(readFileSync(join(dist, 'devup-ui', 'devup-ui.css'), 'utf-8')).toBe( + '', + ) + }) + + it('does not rewrite the CSS placeholder that Turbopack already tracks', () => { + const root = project() + DevupUI({}) + const placeholder = join(root, 'df', 'devup-ui', 'devup-ui.css') + writeFileSync(placeholder, '/* kept */') + + DevupUI({}) + + expect(readFileSync(placeholder, 'utf-8')).toBe('/* kept */') + }) + + it('writes no theme declaration for an app without a theme', () => { + const root = project() + + DevupUI({}) + + expect(existsSync(join(root, 'df', 'theme.d.ts'))).toBe(false) + }) + + it('names the file when the devup config cannot be read', () => { + const root = project({ 'devup.json': '{ not json' }) + + expect(() => DevupUI({})).toThrow( + `${join(root, 'devup.json')}:1:1: devup config cannot use \`JSON\` at build time`, + ) + expect(harness.starts).toHaveLength(0) + }) + + it('takes source maps from Next in production', () => { + project() + + DevupUI({ productionBrowserSourceMaps: true }) + DevupUI({}) + + expect(harness.starts.map((start) => start.sourceMap)).toEqual([ + true, + false, + ]) + }) + + it('registers the exit hooks that drain and close the coordinator', async () => { + project() + + DevupUI({}) + + expect(Object.keys(harness.handlers).sort()).toEqual(['beforeExit', 'exit']) + await harness.handlers.beforeExit![0]!() + expect(harness.handles[0]!.drain).toHaveBeenCalledTimes(1) + expect(harness.handles[0]!.close).toHaveBeenCalledTimes(1) + }) + + it('reports the setup profile when asked', () => { + project() + process.env.DEVUP_UI_PROFILE = '1' + const info = spyOn(console, 'info').mockImplementation(() => {}) + + try { + DevupUI({}, { singleCss: true }) + + const phases = info.mock.calls + .map(([line]) => String(line).replace('[devup-ui:profile] ', '')) + .map((line) => JSON.parse(line)) + expect(phases.map((entry) => entry.phase)).toEqual([ + 'next.graph', + 'next.prewarm', + 'next.setup', + ]) + expect(phases[2]).toMatchObject({ + cacheHit: false, + pid: process.pid, + prewarmedFiles: 1, + singleCss: true, + watch: false, + }) + } finally { + info.mockRestore() + } + }) +}) + +describe('turbopack apps in one process', () => { + it('gives an app without a theme the default theme, never another app’s', () => { + project({ + 'devup.json': JSON.stringify({ + theme: { + colors: { light: { primary: '#fff' }, dark: { primary: '#000' } }, + }, + }), + }) + const themed = DevupUI({}) + project() + const plain = DevupUI({}) + + expect(themed.env).toEqual({ DEVUP_UI_DEFAULT_THEME: 'light' }) + expect(plain.env).toEqual({ DEVUP_UI_DEFAULT_THEME: 'default' }) + expect(process.env.DEVUP_UI_DEFAULT_THEME).toBeUndefined() + }) + + it('keeps the prefix, theme, debug mode and state of two apps apart', () => { + project({ + 'devup.json': JSON.stringify({ + theme: { colors: { default: { primary: 'red' } } }, + }), + }) + DevupUI({}, { prefix: 'a-', debug: true }) + const rootB = project({ 'src/app/b/page.tsx': box('bg="blue"') }) + DevupUI({}) + + const [a, b] = harness.starts + expect(a!.wasm).not.toBe(b!.wasm) + expect(a!.wasm.getPrefix()).toBe('a-') + expect(b!.wasm.getPrefix()).toBeUndefined() + expect(a!.wasm.isDebug()).toBe(true) + expect(b!.wasm.isDebug()).toBe(false) + expect(a!.wasm.getCss(undefined, false)).toContain('--primary:red') + expect(b!.wasm.getCss(undefined, false)).not.toContain('--primary') + expect(JSON.parse(a!.wasm.exportFileMap())).toEqual({ + 'src/app/page.tsx': 0, + }) + expect(JSON.parse(b!.wasm.exportFileMap())).toEqual({ + 'src/app/b/page.tsx': 0, + 'src/app/page.tsx': 1, + }) + expect(a!.coordinatorPortFile).not.toBe(b!.coordinatorPortFile) + expect(a!.identity?.project).not.toBe(rootB) + }) + + it('resolves everything against the root the app was set up in', () => { + const rootA = project() + const rootB = project() + process.chdir(rootA) + + const result = DevupUI({}, { distDir: 'out', devupFile: 'config.json' }) + process.chdir(rootB) + + const [start] = harness.starts + expect(start).toMatchObject({ + cssDir: join(rootA, 'out', 'devup-ui'), + projectRoot: rootA, + devupFile: join(rootA, 'config.json'), + }) + expect(start!.coordinatorPortFile.startsWith(join(rootA, 'out'))).toBe(true) + expect(Object.keys(result.turbopack?.rules ?? {})[0]).toBe( + './out/devup-ui/*.css', + ) + expect(JSON.stringify(result.turbopack?.rules)).not.toContain( + JSON.stringify(rootB).slice(1, -1), + ) + }) + + it('gives every call of the same app its own session', () => { + project() + + DevupUI({}) + DevupUI({}) + + const [first, second] = harness.starts + expect(first!.coordinatorPortFile).not.toBe(second!.coordinatorPortFile) + expect(first!.identity?.token).not.toBe(second!.identity?.token) + expect(first!.optionsKey).toBe(second!.optionsKey) + expect(harness.handlers.exit).toHaveLength(2) + }) + + it('continues the names of the last development session and appends new files', () => { + development() + const root = project({ 'src/app/b/page.tsx': box('bg="blue"') }) + DevupUI({}) + writeFileSync(join(root, 'src/aaa.tsx'), box('bg="green"')) + + DevupUI({}) + + const [first, second] = harness.starts + expect(JSON.parse(first!.wasm.exportFileMap())).toEqual({ + 'src/app/b/page.tsx': 0, + 'src/app/page.tsx': 1, + }) + expect(JSON.parse(second!.wasm.exportFileMap())).toEqual({ + 'src/app/b/page.tsx': 0, + 'src/app/page.tsx': 1, + 'src/aaa.tsx': 2, + }) + }) +}) + +describe('turbopack production setup handoff', () => { + it('hands one setup to the second evaluation of the config', () => { + const root = project({ + 'devup.json': JSON.stringify({ + theme: { colors: { light: { primary: '#fff' } } }, + }), + }) + process.env.DEVUP_UI_PROFILE = '1' + const info = spyOn(console, 'info').mockImplementation(() => {}) + + try { + const first = DevupUI({}, { singleCss: true }) + reloadTurboSetupModuleForTesting() + const second = DevupUI( + { env: { EXISTING: 'value' } }, + { singleCss: true }, + ) + + expect(harness.starts).toHaveLength(1) + expect(second.turbopack?.rules).toEqual(first.turbopack?.rules) + expect(second.env).toEqual({ + DEVUP_UI_DEFAULT_THEME: 'light', + EXISTING: 'value', + }) + expect(JSON.stringify(second.turbopack?.rules)).toContain( + harness.starts[0]!.identity!.token, + ) + expect(JSON.stringify(second.turbopack?.rules)).toContain( + JSON.stringify(root).slice(1, -1), + ) + expect(info).toHaveBeenCalledWith( + expect.stringContaining('"cacheHit":true'), + ) + } finally { + info.mockRestore() + } + }) + + it('does not reuse a setup for other options, config contents or its own module', () => { + const root = project({ 'devup.json': '{}' }) + DevupUI({}, { singleCss: true }) + + DevupUI({}, { singleCss: true }) + expect(harness.starts).toHaveLength(2) + + reloadTurboSetupModuleForTesting() + DevupUI({}, { singleCss: false }) + expect(harness.starts).toHaveLength(3) + + reloadTurboSetupModuleForTesting() + writeFileSync(join(root, 'devup.json'), '{ "theme": {} }') + DevupUI({}, { singleCss: true }) + expect(harness.starts).toHaveLength(4) + + reloadTurboSetupModuleForTesting() + DevupUI({}, { singleCss: true, debug: true }) + expect(harness.starts).toHaveLength(5) + }) + + it('does not reuse a setup across projects', () => { + project() + DevupUI({}) + reloadTurboSetupModuleForTesting() + project() + + DevupUI({}) + + expect(harness.starts).toHaveLength(2) + }) + + it('never hands a setup over in development', () => { + development() + project() + DevupUI({}) + reloadTurboSetupModuleForTesting() + + DevupUI({}) + + expect(harness.starts).toHaveLength(2) + }) + + it('drains the first evaluation’s coordinator from either config', async () => { + project() + const user = mock(async () => {}) + const first = DevupUI({ compiler: { runAfterProductionCompile: user } }) + reloadTurboSetupModuleForTesting() + const second = DevupUI({}) + const metadata = { projectDir: '/p', distDir: '.next' } + + await second.compiler?.runAfterProductionCompile?.(metadata) + expect(harness.handles).toHaveLength(1) + expect(harness.handles[0]!.drain).toHaveBeenCalledTimes(1) + expect(harness.handles[0]!.close).not.toHaveBeenCalled() + + await first.compiler?.runAfterProductionCompile?.(metadata) + expect(harness.handles[0]!.drain).toHaveBeenCalledTimes(2) + expect(user).toHaveBeenCalledWith(metadata) + }) +}) + +describe('turbopack prewarm scope', () => { + const dynamic = `import { css } from '@devup-ui/react'\nconst v = Math.random()\nexport const c = css({ bg: v })` + + it('does not extract or fail on a dead file nothing compiles', () => { + project({ 'src/dead/broken.tsx': dynamic }) + + expect(() => DevupUI({})).not.toThrow() + + expect(harness.starts[0]!.prewarmedFiles).toEqual(['src/app/page.tsx']) + expect(harness.starts[0]!.expectedBaseFiles).toEqual(['src/app/page.tsx']) + }) + + it('fails a production build on a reachable file with its location', () => { + project({ + 'src/app/page.tsx': `import './broken'\n${page}`, + 'src/app/broken.tsx': dynamic, + }) + + expect(() => DevupUI({})).toThrow( + 'src/app/broken.tsx:3:18: `css()` cannot use `v` at build time', + ) + expect(harness.starts).toHaveLength(0) + }) + + it('prewarms the whole tree only on request', () => { + project({ 'src/dead/broken.tsx': dynamic }) + + expect(() => DevupUI({}, { prewarmAll: true })).toThrow( + 'src/dead/broken.tsx:3:18: `css()` cannot use `v` at build time', + ) + }) + + it('prewarms the packages the reached files import', () => { + project({ + 'src/app/page.tsx': `import 'design-system'\n${page}`, + 'node_modules/design-system/package.json': JSON.stringify({ + name: 'design-system', + main: './index.js', + }), + 'node_modules/design-system/index.js': 'export const x = 1', + }) + + DevupUI({}, { include: ['design-system'] }) + + expect(harness.starts[0]!.prewarmedFiles).toEqual([ + 'node_modules/design-system/index.js', + 'src/app/page.tsx', + ]) + }) + + it('is a finished, empty plan when the project has no compiled source', () => { + process.chdir(makeProject()) + + DevupUI({}) + + const [start] = harness.starts + expect(start!.prewarmedFiles).toEqual([]) + expect(start!.expectedBaseFiles).toEqual([]) + expect(start!.prewarmedOutputs?.size).toBe(0) + }) + + it('fails a production build with the location of a graph failure', () => { + const root = makeProject({ src: 'not a directory' }) + process.chdir(root) + + expect(() => DevupUI({})).toThrow( + `${join(root, 'src')}:1:1: devup-ui import graph cannot use \`buildStaticImportGraph\` at build time`, + ) + expect(harness.starts).toHaveLength(0) + }) + + it('warns in development, names the lost guarantee and still starts', () => { + development() + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + const root = makeProject({ src: 'not a directory' }) + process.chdir(root) + + try { + DevupUI({}) + + expect(harness.starts).toHaveLength(1) + expect(warn.mock.calls.map(([line]) => String(line))).toEqual([ + expect.stringContaining( + 'Not guaranteed for this session: single-importer', + ), + expect.stringContaining( + 'Not guaranteed for this session: path-ordered', + ), + ]) + expect(harness.starts[0]).toMatchObject({ + expectedBaseFiles: [], + prewarmedFiles: [], + }) + } finally { + warn.mockRestore() + } + }) + + it('reports the lost graph guarantees for an unreadable included package in development', () => { + development() + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + const root = project({ + 'src/app/page.tsx': `import 'design-system'\n${page}`, + 'node_modules/design-system/package.json': '{ not json', + }) + + try { + DevupUI({}, { include: ['design-system'] }) + + expect(harness.starts[0]!.prewarmedFiles).toEqual([]) + expect(harness.starts[0]!.expectedBaseFiles).toEqual([]) + expect(String(warn.mock.calls[0]?.[0])).toContain( + `${join(root, 'node_modules/design-system/package.json')}:1:1: Cannot load configuration:`, + ) + expect(String(warn.mock.calls[0]?.[0])).toContain( + 'Not guaranteed for this session: single-importer collapse, atom hoisting and the deterministic completion set', + ) + } finally { + warn.mockRestore() + } + }) +}) diff --git a/packages/next-plugin/src/__tests__/prepared-compiler-remap.test.ts b/packages/next-plugin/src/__tests__/prepared-compiler-remap.test.ts new file mode 100644 index 000000000..4284e8760 --- /dev/null +++ b/packages/next-plugin/src/__tests__/prepared-compiler-remap.test.ts @@ -0,0 +1,93 @@ +import { symlinkSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { extractRequest } from '../coordinator-engine' +import { composeMdxRules, requireMdxPipeline } from '../mdx-pipeline' +import { compileMdx, createMdxDeadline } from '../mdx-prepare' +import { runPrewarm } from '../prewarm-run' +import { createAppContext } from '../session' +import { createWasm, withModuleResolver } from '../wasm' +import { installProjectHooks, makeProject } from './project' + +installProjectHooks() +const installedRoot = resolve(import.meta.dir, '../../../../apps/landing') +const installed = createRequire(join(installedRoot, 'package.json')) + +it.each([ + { sourceMap: true, surface: 'request' }, + { sourceMap: false, surface: 'request' }, + { sourceMap: true, surface: 'prewarm' }, + { sourceMap: false, surface: 'prewarm' }, +] as const)( + 'remaps project compiler maps through $surface with maps=$sourceMap', + async ({ sourceMap, surface }) => { + // Given actual @next/mdx compilation with its original source map. + const filename = 'src/page.mdx' + const root = makeProject({ + [filename]: `# Intro\n\n# More\n\n# Next\n\nimport { css } from '@devup-ui/react'\n\nexport const c = css({ bg: Math.random() })\n`, + }) + symlinkSync( + join(installedRoot, 'node_modules/next'), + join(root, 'node_modules/next'), + 'junction', + ) + const resourcePath = join(root, filename) + const composition = composeMdxRules( + { + bundler: 'webpack', + rules: [ + { + test: /\.mdx$/, + use: [ + { + loader: installed.resolve('@next/mdx/mdx-js-loader'), + options: { jsx: true }, + }, + ], + }, + ], + aliases: {}, + }, + { loader: 'devup' }, + ) + const compiled = await compileMdx({ + root, + filename: resourcePath, + pipeline: requireMdxPipeline(resourcePath, composition.pipelines[0]), + signal: new AbortController().signal, + deadline: createMdxDeadline(), + context: { owner: {}, generation: {}, sourceMap: true }, + }) + process.chdir(root) + const context = { ...createAppContext({}, {}), sourceMap } + const engine = createWasm(root) + const prepared = { code: compiled.source, map: compiled.map } + withModuleResolver(engine, root, { prepareSource: () => prepared }) + // When both production extraction surfaces encounter the real WASM error. + const request = () => + extractRequest( + engine, + { + package: context.libPackage, + cssDir: context.cssDir, + singleCss: context.singleCss, + sourceMap, + importAliases: {}, + }, + { filename, resourcePath, code: compiled.source }, + ) + const prewarm = () => + runPrewarm({ + context, + engine, + files: [filename], + collectMs: undefined, + preparedInputs: new Map([[filename, prepared]]), + }) + // Then real compiler coordinates point to original line 9, not emitted JS. + expect({ request, prewarm }[surface]).toThrow(/page\.mdx:9:\d+: /) + }, +) diff --git a/packages/next-plugin/src/__tests__/prepared-extraction.test.ts b/packages/next-plugin/src/__tests__/prepared-extraction.test.ts new file mode 100644 index 000000000..b143a0f10 --- /dev/null +++ b/packages/next-plugin/src/__tests__/prepared-extraction.test.ts @@ -0,0 +1,209 @@ +import { join } from 'node:path' + +import { afterEach, expect, it, mock, spyOn } from 'bun:test' + +import { + buildEngine, + extractInput, + extractRequest, +} from '../coordinator-engine' +import { createEngineConfigurer } from '../engine-config' +import { createAppContext } from '../session' +import { exportAllocatorState } from '../state' +import { createWasm, withModuleResolver } from '../wasm' +import { box, installProjectHooks, makeProject } from './project' + +installProjectHooks() +afterEach(() => { + mock.restore() +}) + +const plan = { canonicalMap: {}, fileRoutes: {}, atomThreshold: null } +const invalid = `import { css } from '@devup-ui/react'\nconst v = Math.random()\nexport const c = css({ bg: v })` + +function fixture(sourceMap: boolean) { + const root = makeProject({ + 'src/page.mdx': '# raw', + 'src/value.mdx': '# raw', + }) + process.chdir(root) + const context = { ...createAppContext({}, {}), sourceMap } + const cache = new Map([ + [ + join(root, 'src/page.mdx'), + { + code: invalid, + map: { + version: 3, + sources: [join(root, 'src/page.mdx')], + mappings: ';;AAKA', + names: [], + }, + }, + ], + [ + join(root, 'src/value.mdx'), + { + code: 'export const color = "purple"', + map: { + version: 3, + sources: [join(root, 'src/value.mdx')], + mappings: 'AAOA', + names: [], + }, + }, + ], + ]) + const resolver = { + prepareSource: (file: string) => cache.get(file), + includeMdx: true, + alias: { value: join(root, 'src/value.mdx') }, + } + const configure = createEngineConfigurer(context, { + theme: {}, + plan, + resolver, + }) + const engine = createWasm(root) + configure(engine) + const settings = { + package: context.libPackage, + cssDir: context.cssDir, + singleCss: true, + sourceMap, + importAliases: {}, + } + const input = { + filename: 'src/page.mdx', + resourcePath: join(root, 'src/page.mdx'), + source: invalid, + } + return { root, cache, resolver, configure, engine, settings, input } +} + +it.each([true, false])( + 'remaps real WASM own-source errors before RPC location handling with maps=%s', + (sourceMap) => { + // Given prepared JavaScript under its actual Markdown filename. + const f = fixture(sourceMap) + // When the common request seam extracts an invalid static css value. + const action = () => + extractRequest(f.engine, f.settings, { ...f.input, code: f.input.source }) + // Then the compiler map locates the failure in Markdown. + expect(action).toThrow(`${join(f.root, 'src/page.mdx')}:6:1:`) + }, +) + +it.each([true, false])( + 'remaps imported extractor-format locations with maps=%s', + (sourceMap) => { + // Given a real resolver/WASM import has registered the imported map. + const f = fixture(sourceMap) + const source = `import { Box } from '@devup-ui/react'; import { color } from 'value'; export const C = ` + extractInput(f.engine, f.settings, { + ...f.input, + filename: 'src/main.tsx', + source, + }) + const cause = new Error('src/value.mdx:1:1: invalid imported value') + const method = sourceMap ? 'codeExtract' : 'codeExtractWithoutSourceMap' + spyOn(f.engine, method).mockImplementation(() => { + throw cause + }) + // When #757-style imported positions are emitted (synthetic). + const action = () => + extractInput(f.engine, f.settings, { + ...f.input, + source: box('bg="red"'), + }) + // Then the shared resolver translates the imported location. + expect(action).toThrow(`${join(f.root, 'src/value.mdx')}:8:1:`) + }, +) + +it.each([true, false])( + 'retains original errors for an ordinary engine with maps=%s', + (sourceMap) => { + // Given an ordinary configured engine and a non-Error WASM throw. + const f = fixture(sourceMap) + const cause = Object.freeze({ message: 'raw failure' }) + withModuleResolver(f.engine, f.root) + spyOn( + f.engine, + sourceMap ? 'codeExtract' : 'codeExtractWithoutSourceMap', + ).mockImplementation(() => { + throw cause + }) + // When extraction fails without a prepared hook. + try { + extractInput(f.engine, f.settings, f.input) + } catch (error) { + // Then the seam does not wrap or remap ordinary exceptions. + expect(error).toBe(cause) + return + } + throw new Error('Expected extraction failure') + }, +) + +it('keeps resolver maps across repeated configuration of the same engine', () => { + // Given imported maps registered through real extraction. + const f = fixture(false) + extractInput(f.engine, f.settings, { + ...f.input, + source: `import { Box } from '@devup-ui/react'; import { color } from 'value'; export const C = `, + }) + f.configure(f.engine) + spyOn(f.engine, 'codeExtractWithoutSourceMap').mockImplementation(() => { + throw new Error('src/value.mdx:1:1: failure') + }) + // When another extraction uses the retained configured engine. + const action = () => extractInput(f.engine, f.settings, f.input) + // Then configuration has not lost the imported map. + expect(action).toThrow(`${join(f.root, 'src/value.mdx')}:8:1:`) +}) + +it.each([true, false])( + 'configures replacement engines with prepared values and remaps rebuild errors with maps=%s', + (sourceMap) => { + // Given a generation configurer and allocator from the live engine. + const f = fixture(sourceMap) + const input = { ...f.input, dependencies: [], stamps: {}, backing: '' } + // When transactional replay builds a replacement engine. + const action = () => + buildEngine({ + createEngine: () => createWasm(f.root), + live: f.engine, + configure: f.configure, + allocator: exportAllocatorState(f.engine), + theme: undefined, + settings: f.settings, + inputs: [input], + }) + // Then rebuild reporting has already received the remapped error. + expect(action).toThrow(`${join(f.root, 'src/page.mdx')}:6:1:`) + }, +) + +it('uses new generation maps and labels prepared output without a map', () => { + // Given a new generation whose prepared source has no map. + const f = fixture(false) + withModuleResolver(f.engine, f.root, { prepareSource: () => invalid }) + // When direct extraction fails. + const action = () => extractInput(f.engine, f.settings, f.input) + // Then stale maps from the previous resolver are not used. + expect(action).toThrow('(in compiled output)') +}) + +it.each([undefined, Promise.resolve('')])( + 'blocks missing or asynchronous preparation before extraction', + (prepared) => { + // Given a cache reader that cannot synchronously provide Markdown. + const f = fixture(false) + withModuleResolver(f.engine, f.root, { prepareSource: () => prepared }) + // When extraction attempts to consume that required module. + const action = () => extractInput(f.engine, f.settings, f.input) + // Then no raw Markdown reaches WASM. + expect(action).toThrow('prepare Markdown before extraction') + }, +) diff --git a/packages/next-plugin/src/__tests__/prepared-prewarm.test.ts b/packages/next-plugin/src/__tests__/prepared-prewarm.test.ts new file mode 100644 index 000000000..951c2c2c7 --- /dev/null +++ b/packages/next-plugin/src/__tests__/prepared-prewarm.test.ts @@ -0,0 +1,142 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createEngineConfigurer } from '../engine-config' +import { type PreparedPrewarmInput, runPrewarm } from '../prewarm-run' +import { createAppContext } from '../session' +import { createWasm } from '../wasm' +import { box, installProjectHooks, makeProject } from './project' + +installProjectHooks() +const plan = { canonicalMap: {}, fileRoutes: {}, atomThreshold: null } + +it.each([true, false])( + 'prewarms exact prepared bytes and preserves compiler evidence with maps=%s', + (sourceMap) => { + // Given raw Markdown and complete compiled inputs with compiler evidence. + const root = makeProject({ + 'src/page.mdx': '# raw', + 'src/value.mdx': '# raw', + }) + process.chdir(root) + const context = { ...createAppContext({}, { singleCss: true }), sourceMap } + const engine = createWasm(root) + const map = { + version: 3, + sources: ['page.mdx'], + mappings: 'AAAA', + names: [], + } + const code = `import { Box } from '@devup-ui/react'; import { color } from './value.mdx'; export const C = ` + const preparedInputs = new Map([ + ['src/page.mdx', { code, map, dependencies: ['compiler-plugin.js'] }], + ['src/value.mdx', { code: 'export const color = "green"' }], + ]) + const cache = new Map( + [...preparedInputs].map(([file, input]) => [join(root, file), input]), + ) + createEngineConfigurer(context, { + theme: {}, + plan, + resolver: { prepareSource: (file) => cache.get(file) }, + })(engine) + // When prewarm extracts under the original resource name. + const result = runPrewarm({ + context, + engine, + files: ['src/page.mdx'], + collectMs: undefined, + preparedInputs, + }) + // Then compiled source, compiler evidence, dependencies and CSS survive. + expect(result.outputs.get('src/page.mdx')).toMatchObject({ + source: code, + dependencies: ['src/value.mdx', 'compiler-plugin.js'], + }) + expect(result.preparedInputs?.get('src/page.mdx')?.map).toBe(map) + expect(engine.getCss(undefined, false)).toContain('background:green') + }, +) + +it.each(['development', 'production'] as const)( + 'blocks incomplete preparation rather than reading raw Markdown in %s', + (phase) => { + // Given required preparation with no cached bytes for the selected file. + const root = makeProject({ 'src/page.mdx': '# raw' }) + process.chdir(root) + const context = { ...createAppContext({}, {}), phase } + const engine = createWasm(root) + // When prewarm consumes the incomplete generation. + const action = () => + runPrewarm({ + context, + engine, + files: ['src/page.mdx'], + collectMs: undefined, + preparedInputs: new Map(), + }) + // Then development and production both reject the located failure. + expect(action).toThrow(`${join(root, 'src/page.mdx')}:1:1:`) + }, +) + +it.each([true, false])( + 'blocks remapped preparation extraction failures even in development with maps=%s', + (sourceMap) => { + // Given required compiled JavaScript with a real WASM build-time failure. + const root = makeProject({ 'src/page.mdx': '# raw' }) + process.chdir(root) + const context = { + ...createAppContext({}, {}), + sourceMap, + phase: 'development' as const, + } + const engine = createWasm(root) + const code = `import { css } from '@devup-ui/react'\nconst v = Math.random()\nexport const c = css({ bg: v })` + const prepared = { + code, + map: { + version: 3, + sources: [join(root, 'src/page.mdx')], + mappings: ';;AAKA', + names: [], + }, + } + createEngineConfigurer(context, { + theme: {}, + plan, + resolver: { prepareSource: () => prepared }, + })(engine) + // When prewarm encounters that error. + const action = () => + runPrewarm({ + context, + engine, + files: ['src/page.mdx'], + collectMs: undefined, + preparedInputs: new Map([['src/page.mdx', prepared]]), + }) + // Then the blocking error is remapped before prewarm reports it. + expect(action).toThrow(`${join(root, 'src/page.mdx')}:6:1:`) + }, +) + +it('accepts empty prepared code without falling back to raw disk source', () => { + // Given valid empty prepared output over a styled raw file. + const root = makeProject({ 'src/page.tsx': box('bg="red"') }) + process.chdir(root) + const context = createAppContext({}, {}) + const engine = createWasm(root) + // When complete prepared prewarm runs. + const result = runPrewarm({ + context, + engine, + files: ['src/page.tsx'], + collectMs: undefined, + preparedInputs: new Map([['src/page.tsx', { code: '' }]]), + }) + // Then raw styles are not extracted. + expect(result.outputs.get('src/page.tsx')?.source).toBe('') + expect(engine.getCss(undefined, false)).not.toContain('background:red') +}) diff --git a/packages/next-plugin/src/__tests__/prepared-resolver-rpc.test.ts b/packages/next-plugin/src/__tests__/prepared-resolver-rpc.test.ts new file mode 100644 index 000000000..2b5a5069d --- /dev/null +++ b/packages/next-plugin/src/__tests__/prepared-resolver-rpc.test.ts @@ -0,0 +1,106 @@ +import { join } from 'node:path' + +import { afterEach, expect, it } from 'bun:test' + +import { resetCoordinator, startCoordinator } from '../coordinator' +import { extractSealed } from '../coordinator-sealed' +import { captureCoordinatorState } from '../state' +import { withModuleResolver } from '../wasm' +import { connect, createTestApp, removeTestApps } from './coordinator-app' + +afterEach(() => { + resetCoordinator() + removeTestApps() +}) + +it.each([true, false])( + 'delivers remapped real extraction failures over authenticated RPC with maps=%s', + async (sourceMap) => { + // Given a real HTTP coordinator and the synchronous prepared resolver. + const app = createTestApp() + const filename = 'src/page.mdx' + const path = app.write(filename, '# raw Markdown') + const source = `import { css } from '@devup-ui/react'\nconst v = Math.random()\nexport const c = css({ bg: v })` + const engine = app.engine() + withModuleResolver(engine, app.root, { + prepareSource: () => ({ + code: source, + map: { + version: 3, + sources: [path], + mappings: ';;AAKA', + names: [], + }, + }), + }) + const handle = startCoordinator(app.options({ wasm: engine, sourceMap })) + try { + await handle.ready + // When a loader delivers the compiled bytes under the real filename. + const reply = await connect(app.portFile, app.identity).post( + '/extract', + app.post(filename, source), + ) + // Then HTTP error reporting preserves the remapped location. + expect(reply.status).toBe(500) + expect(JSON.parse(reply.body)).toMatchObject({ + error: expect.stringContaining(`${join(app.root, filename)}:6:1:`), + }) + } finally { + await handle.drain() + } + }, +) + +it.each([true, false])( + 'remaps failures on isolated sealed candidates with maps=%s', + (sourceMap) => { + // Given a frozen production sheet and same-generation configuration. + const app = createTestApp() + const filename = 'src/page.mdx' + const path = app.write(filename, '# raw Markdown') + const source = `import { css } from '@devup-ui/react'\nconst v = Math.random()\nexport const c = css({ bg: v })` + const resolver = { + prepareSource: () => ({ + code: source, + map: { + version: 3, + sources: [path], + mappings: ';;AAKA', + names: [], + }, + }), + } + const live = app.engine() + withModuleResolver(live, app.root, resolver) + const snapshot = captureCoordinatorState({ + wasm: live, + optionsKey: 'sealed-prepared', + project: app.root, + revision: 1, + inputs: [], + }) + // When sealed re-extraction encounters a real build-time error. + const action = () => + extractSealed( + { + live, + createEngine: app.engine, + configure: (engine) => { + withModuleResolver(engine, app.root, resolver) + }, + settings: { + package: '@devup-ui/react', + cssDir: join(app.root, 'df'), + singleCss: true, + sourceMap, + importAliases: {}, + }, + }, + snapshot, + { filename, resourcePath: path, code: source }, + ) + // Then candidate error reporting uses the generation's source map. + expect(action).toThrow(`${path}:6:1:`) + }, +) diff --git a/packages/next-plugin/src/__tests__/prepared-resolver-settings.test.ts b/packages/next-plugin/src/__tests__/prepared-resolver-settings.test.ts new file mode 100644 index 000000000..bcca13717 --- /dev/null +++ b/packages/next-plugin/src/__tests__/prepared-resolver-settings.test.ts @@ -0,0 +1,60 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { extractInput } from '../coordinator-engine' +import { createEngineConfigurer } from '../engine-config' +import { createAppContext } from '../session' +import { createWasm } from '../wasm' +import { installProjectHooks, makeProject } from './project' + +installProjectHooks() + +it('forwards ordered aliases, native conditions and explicit Markdown selection', () => { + // Given an alias whose first candidate is absent and a conditional package. + const root = makeProject({ + 'src/page.tsx': '', + 'node_modules/values/package.json': JSON.stringify({ + name: 'values', + exports: { prepared: './value.mdown', default: './wrong.ts' }, + }), + 'node_modules/values/value.mdown': '# raw Markdown', + 'node_modules/values/wrong.ts': 'export const color = "red"', + }) + process.chdir(root) + const context = createAppContext({}, {}) + const engine = createWasm(root) + const preparedPath = join(root, 'node_modules/values/value.mdown') + createEngineConfigurer(context, { + theme: {}, + plan: { canonicalMap: {}, fileRoutes: {}, atomThreshold: null }, + resolver: { + includeMdx: ['.mdown'], + alias: { value: [join(root, 'missing.ts'), 'values'] }, + conditions: ['prepared'], + prepareSource: (file) => + file === preparedPath + ? { code: 'export const color = "green"', sourceType: 'compiled-mdx' } + : undefined, + }, + })(engine) + // When real WASM resolves the aliased value using generation settings. + const output = extractInput( + engine, + { + package: '@devup-ui/react', + cssDir: context.cssDir, + singleCss: true, + sourceMap: false, + importAliases: {}, + }, + { + filename: 'src/page.tsx', + resourcePath: join(root, 'src/page.tsx'), + source: `import { Box } from '@devup-ui/react'; import { color } from 'value'; export const C = `, + }, + ) + // Then the selected prepared candidate supplies both CSS and dependencies. + expect(engine.getCss(undefined, false)).toContain('background:green') + expect(output.dependencies).toEqual(['node_modules/values/value.mdown']) +}) diff --git a/packages/next-plugin/src/__tests__/prewarm-run.test.ts b/packages/next-plugin/src/__tests__/prewarm-run.test.ts new file mode 100644 index 000000000..266c6e71c --- /dev/null +++ b/packages/next-plugin/src/__tests__/prewarm-run.test.ts @@ -0,0 +1,210 @@ +import { join } from 'node:path' + +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +import { runPrewarm } from '../prewarm-run' +import { createAppContext } from '../session' +import { createWasm } from '../wasm' +import { box, installProjectHooks, makeProject } from './project' + +installProjectHooks() + +const originalEnv = { ...process.env } +afterEach(() => { + process.env = { ...originalEnv } +}) + +function setup( + files: Record, + options = {}, + phase: 'development' | 'production' = 'production', +) { + process.env.NODE_ENV = phase + process.chdir(makeProject(files)) + const context = createAppContext({}, options) + return { context, engine: createWasm(context.root) } +} + +const dynamic = `import { css } from '@devup-ui/react'\nconst v = Math.random()\nexport const c = css({ bg: v })` +const syntaxError = `import { Box } from '@devup-ui/react'\nconst x = ;` + +describe('runPrewarm', () => { + it('extracts in the given order and keeps what a later request can reuse', () => { + const { context, engine } = setup({ + 'src/a/one.tsx': box('bg="red"'), + 'src/b.tsx': box('bg="blue"'), + }) + + const result = runPrewarm({ + context, + engine, + files: ['src/b.tsx', 'src/a/one.tsx'], + collectMs: undefined, + }) + + expect(result.files).toEqual(['src/b.tsx', 'src/a/one.tsx']) + expect([...result.outputs.keys()]).toEqual(result.files) + expect(JSON.parse(engine.exportFileMap())).toEqual({ + 'src/b.tsx': 0, + 'src/a/one.tsx': 1, + }) + expect(result.outputs.get('src/b.tsx')).toMatchObject({ + source: box('bg="blue"'), + cssFile: './../df/devup-ui/devup-ui-0.css', + updatedBaseStyle: false, + }) + expect(result.outputs.get('src/a/one.tsx')?.cssFile).toBe( + './../../df/devup-ui/devup-ui-1.css', + ) + }) + + it('keeps source maps in development and drops them in production', () => { + const files = { 'src/a.tsx': box('bg="red"') } + const production = setup(files) + const development = setup(files, {}, 'development') + const withMap = spyOn(development.engine, 'codeExtract') + const withoutMap = spyOn(production.engine, 'codeExtractWithoutSourceMap') + const wrongForProduction = spyOn(production.engine, 'codeExtract') + const wrongForDevelopment = spyOn( + development.engine, + 'codeExtractWithoutSourceMap', + ) + + runPrewarm({ ...production, files: ['src/a.tsx'], collectMs: undefined }) + runPrewarm({ ...development, files: ['src/a.tsx'], collectMs: undefined }) + + expect(withoutMap).toHaveBeenCalledTimes(1) + expect(withMap).toHaveBeenCalledTimes(1) + expect(wrongForProduction).not.toHaveBeenCalled() + expect(wrongForDevelopment).not.toHaveBeenCalled() + }) + + it('extracts into the single stylesheet when asked to', () => { + const { context, engine } = setup( + { 'src/a.tsx': box('bg="red"') }, + { singleCss: true }, + ) + + const result = runPrewarm({ + context, + engine, + files: ['src/a.tsx'], + collectMs: undefined, + }) + + expect(result.outputs.get('src/a.tsx')?.cssFile).toBe( + './../df/devup-ui/devup-ui.css', + ) + expect(engine.getCss(undefined, false)).toContain('background:red') + }) + + it('fails a production build with the location of a file that cannot be extracted', () => { + const { context, engine } = setup({ + 'src/ok.tsx': box('bg="red"'), + 'src/broken.tsx': dynamic, + 'src/syntax.tsx': syntaxError, + }) + + expect(() => + runPrewarm({ + context, + engine, + files: ['src/ok.tsx', 'src/broken.tsx'], + collectMs: undefined, + }), + ).toThrow( + 'src/broken.tsx:3:18: `css()` cannot use `v` at build time: its values must be literals', + ) + expect(() => + runPrewarm({ + context, + engine, + files: ['src/syntax.tsx'], + collectMs: undefined, + }), + ).toThrow( + `${join(context.root, 'src/syntax.tsx')}:1:1: devup-ui prewarm cannot use \`src/syntax.tsx\` at build time: Parser panicked; needs a readable source file the extractor can compile`, + ) + }) + + it('reports a missing file with its location in production', () => { + const { context, engine } = setup({}) + + expect(() => + runPrewarm({ + context, + engine, + files: ['src/gone.tsx'], + collectMs: undefined, + }), + ).toThrow( + `${join(context.root, 'src/gone.tsx')}:1:1: devup-ui prewarm cannot use`, + ) + }) + + it('warns in development and skips the file that cannot be extracted', () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + const { context, engine } = setup( + { 'src/ok.tsx': box('bg="red"'), 'src/broken.tsx': dynamic }, + {}, + 'development', + ) + + try { + const result = runPrewarm({ + context, + engine, + files: ['src/broken.tsx', 'src/ok.tsx'], + collectMs: undefined, + }) + + expect(result.files).toEqual(['src/ok.tsx']) + expect(warn).toHaveBeenCalledTimes(1) + expect(String(warn.mock.calls[0]?.[0])).toContain( + 'src/broken.tsx:3:18: `css()` cannot use `v` at build time', + ) + expect(String(warn.mock.calls[0]?.[0])).toContain( + 'Skipped while prewarming; its styles are extracted when the bundler compiles it.', + ) + } finally { + warn.mockRestore() + } + }) + + it('is an empty, finished prewarm for an empty plan', () => { + const { context, engine } = setup({}) + + const result = runPrewarm({ + context, + engine, + files: [], + collectMs: undefined, + }) + + expect(result.files).toEqual([]) + expect(result.outputs.size).toBe(0) + }) + + it('reports its timings when profiling is on', () => { + process.env.DEVUP_UI_PROFILE = '1' + const info = spyOn(console, 'info').mockImplementation(() => {}) + const { context, engine } = setup({ 'src/a.tsx': box('bg="red"') }) + + try { + runPrewarm({ context, engine, files: ['src/a.tsx'], collectMs: 1.5 }) + + const profile = JSON.parse( + String(info.mock.calls[0]?.[0]).replace('[devup-ui:profile] ', ''), + ) + expect(profile).toMatchObject({ + phase: 'next.prewarm', + collectMs: 1.5, + files: 1, + sourceBytes: Buffer.byteLength(box('bg="red"')), + }) + expect(profile.extractMs).toBeGreaterThanOrEqual(0) + } finally { + info.mockRestore() + } + }) +}) diff --git a/packages/next-plugin/src/__tests__/prewarm.test.ts b/packages/next-plugin/src/__tests__/prewarm.test.ts index 9486a1a7c..8caa3e9ca 100644 --- a/packages/next-plugin/src/__tests__/prewarm.test.ts +++ b/packages/next-plugin/src/__tests__/prewarm.test.ts @@ -1,61 +1,119 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import type { StaticImportGraph } from '@devup-ui/plugin-utils' -import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { beforeEach, describe, expect, it } from 'bun:test' -import { collectProductionPrewarmFiles } from '../prewarm' +import { + collectPrewarmFiles, + type CollectPrewarmFilesOptions, + EXTRACTABLE_EXTENSION, +} from '../prewarm' +import { installProjectHooks, makeProject } from './project' -describe('collectProductionPrewarmFiles', () => { - let cwd: string +installProjectHooks() - beforeEach(() => { - cwd = mkdtempSync(join(tmpdir(), 'devup-ui-next-prewarm-')) - writeFileSync(join(cwd, 'package.json'), '{"private":true}') - }) +let root: string - afterEach(() => { - rmSync(cwd, { recursive: true, force: true }) - }) +beforeEach(() => { + root = makeProject() +}) - function writePackage( - name: string, - exports: Record | string, - files: Record, - ): void { - const packageDir = join(cwd, 'node_modules', ...name.split('/')) - mkdirSync(packageDir, { recursive: true }) - writeFileSync( - join(packageDir, 'package.json'), - JSON.stringify({ name, exports }), - ) - for (const [filename, contents] of Object.entries(files)) { - const path = join(packageDir, filename) - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, contents) - } +function write(path: string, contents = ''): void { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), contents) +} + +function writePackage( + name: string, + exports: Record | string, + files: Record, +): void { + write(`node_modules/${name}/package.json`, JSON.stringify({ name, exports })) + for (const [filename, contents] of Object.entries(files)) { + write(`node_modules/${name}/${filename}`, contents) } +} - function makeGraph(files: string[], specifiers: string[]): StaticImportGraph { - const source = files[0] ?? join(cwd, 'src/app/page.tsx') - return { - files, - fileSet: new Set(files), - staticImports: new Map(files.map((file) => [file, new Set()])), - staticImporters: new Map(files.map((file) => [file, new Set()])), - dynamicTargets: new Set(), - dynamicImports: new Map(files.map((file) => [file, new Set()])), - externalImports: new Map([[source, new Set(specifiers)]]), - } +function makeGraph( + imports: Record, + extra: string[] = [], +): StaticImportGraph { + const files = [...Object.keys(imports), ...extra].map((file) => + join(root, file), + ) + return { + files, + fileSet: new Set(files), + staticImports: new Map(files.map((file) => [file, new Set()])), + staticImporters: new Map(files.map((file) => [file, new Set()])), + dynamicTargets: new Set(), + dynamicImports: new Map(files.map((file) => [file, new Set()])), + externalImports: new Map( + Object.entries(imports).map(([file, specifiers]) => [ + join(root, file), + new Set(specifiers), + ]), + ), } +} - it('includes all source candidates and ESM entries accepted by the loader', () => { +function collect( + graph: StaticImportGraph, + overrides: Partial = {}, +): string[] { + return collectPrewarmFiles({ + root, + graph, + expectedBaseFiles: ['src/app/page.tsx'], + libPackage: '@devup-ui/react', + include: [], + prewarmAll: false, + ...overrides, + }) +} + +describe('collectPrewarmFiles', () => { + it('extracts the proven compiled closure and not the files nothing compiled', () => { + const graph = makeGraph({}, ['src/app/page.tsx', 'src/dead/broken.tsx']) + + expect(collect(graph)).toEqual(['src/app/page.tsx']) + }) + + it('follows only the packages the reached files import', () => { + writePackage('@acme/ui', './index.js', { 'index.js': '' }) + writePackage('@acme/dead', './index.js', { 'index.js': '' }) + const graph = makeGraph({ + 'src/app/page.tsx': ['@acme/ui'], + 'src/dead/broken.tsx': ['@acme/dead'], + }) + + expect(collect(graph, { include: ['@acme/ui', '@acme/dead'] })).toEqual([ + 'node_modules/@acme/ui/index.js', + 'src/app/page.tsx', + ]) + }) + + it('opts into the whole tree with prewarmAll', () => { + writePackage('@acme/dead', './index.js', { 'index.js': '' }) + const graph = makeGraph({ + 'src/app/page.tsx': [], + 'src/dead/broken.tsx': ['@acme/dead'], + }) + + expect( + collect(graph, { prewarmAll: true, include: ['@acme/dead'] }), + ).toEqual([ + 'node_modules/@acme/dead/index.js', + 'src/app/page.tsx', + 'src/dead/broken.tsx', + ]) + }) + + it('accepts the packages the loader would extract, with their ESM entries', () => { writePackage( '@devup-ui/reset-css', - { - '.': { import: './dist/index.mjs', require: './dist/index.cjs' }, - }, + { '.': { import: './dist/index.mjs', require: './dist/index.cjs' } }, { 'dist/index.cjs': '', 'dist/index.mjs': '' }, ) writePackage('@devup-editor/editor', './index.js', { 'index.js': '' }) @@ -66,15 +124,13 @@ describe('collectProductionPrewarmFiles', () => { ) writePackage('design-system', './index.js', { 'index.js': '' }) writePackage('@devup-ui/cjs-only', './index.cjs', { 'index.cjs': '' }) + writePackage('@devup-ui/types-only', './index.d.ts', { 'index.d.ts': '' }) writePackage('@devup-ui/data', './data.json', { 'data.json': '{}' }) + writePackage('react', './index.js', { 'index.js': '' }) - const page = join(cwd, 'src/app/page.tsx') - const templateTarget = join(cwd, 'src/demos/template-target.tsx') - const files = collectProductionPrewarmFiles({ - cwd, - graph: makeGraph( - [page, templateTarget], - [ + const files = collect( + makeGraph({ + 'src/app/page.tsx': [ '', '@broken', '#internal', @@ -85,38 +141,120 @@ describe('collectProductionPrewarmFiles', () => { '@acme/ui', 'design-system', '@devup-ui/cjs-only', + '@devup-ui/types-only', '@devup-ui/data', '@devup-ui/missing', ], - ), - expectedBaseFiles: ['src/app/page.tsx'], - libPackage: '@acme/ui', - include: ['design-system'], + }), + { libPackage: '@acme/ui', include: ['design-system'] }, + ) + + expect(files).toEqual([ + 'node_modules/@acme/ui/index.mjs', + 'node_modules/@devup-editor/editor/index.js', + 'node_modules/@devup-ui/cjs-only/index.cjs', + 'node_modules/@devup-ui/reset-css/dist/index.mjs', + 'node_modules/design-system/index.js', + 'src/app/page.tsx', + ]) + }) + + it('prefers the ESM sibling of a CommonJS entry', () => { + writePackage('@devup-ui/dual', './index.cjs', { + 'index.cjs': '', + 'index.mjs': '', }) - expect(files).toEqual( - [ - 'node_modules/@acme/ui/index.mjs', - 'node_modules/@devup-editor/editor/index.js', - 'node_modules/@devup-ui/reset-css/dist/index.mjs', - 'node_modules/design-system/index.js', - 'src/app/page.tsx', - 'src/demos/template-target.tsx', - ].sort(), - ) + expect( + collect(makeGraph({ 'src/app/page.tsx': ['@devup-ui/dual'] })), + ).toEqual(['node_modules/@devup-ui/dual/index.mjs', 'src/app/page.tsx']) }) - it('normalizes an absolute expected file without source or package imports', () => { - const page = join(cwd, 'src/app/page.tsx') + it('follows a package through its own imports, once each', () => { + writePackage('@acme/ui', './index.mjs', { + 'index.mjs': [ + "import './button.mjs'", + "export * from './nested'", + "import { x } from '@acme/shared'", + "import { y } from '@devup-ui/reset-css'", + "import react from 'react'", + "import data from './data.json'", + "const lazy = () => import('./lazy.mts')", + "const cjs = require('./legacy.cjs')", + "import './missing'", + ].join('\n'), + 'button.mjs': "import './index.mjs'\nimport './nested'", + 'nested/index.js': "export * from '../button.mjs'", + 'lazy.mts': '', + 'legacy.cjs': '', + 'data.json': '{}', + }) + writePackage('@acme/shared', './index.js', { 'index.js': '' }) + writePackage('@devup-ui/reset-css', './index.js', { 'index.js': '' }) + writePackage('react', './index.js', { 'index.js': '' }) expect( - collectProductionPrewarmFiles({ - cwd, - graph: makeGraph([], []), - expectedBaseFiles: [page], + collect(makeGraph({ 'src/app/page.tsx': ['@acme/ui'] }), { + include: ['@acme/ui', '@acme/shared'], + }), + ).toEqual([ + 'node_modules/@acme/shared/index.js', + 'node_modules/@acme/ui/button.mjs', + 'node_modules/@acme/ui/index.mjs', + 'node_modules/@acme/ui/lazy.mts', + 'node_modules/@acme/ui/legacy.cjs', + 'node_modules/@acme/ui/nested/index.js', + 'node_modules/@devup-ui/reset-css/index.js', + 'src/app/page.tsx', + ]) + }) + + it('preserves the manifest location when a package cannot be resolved', () => { + write('node_modules/@acme/ui/package.json', '{ not json') + const manifest = join(root, 'node_modules/@acme/ui/package.json') + + expect(() => + collect(makeGraph({ 'src/app/page.tsx': ['@acme/ui'] }), { + include: ['@acme/ui'], + }), + ).toThrow(`${manifest}:1:1: Cannot load configuration:`) + }) + + it('normalizes an absolute expected file and tolerates a graph without externals', () => { + const graph = makeGraph({}) + delete graph.externalImports + + expect( + collect(graph, { + expectedBaseFiles: [join(root, 'src/app/page.tsx')], libPackage: '@', - include: [], }), ).toEqual(['src/app/page.tsx']) }) + + it('is the same whatever order the closure is given in', () => { + const graph = makeGraph({}, ['src/b.tsx', 'src/a.tsx']) + + expect( + collect(graph, { expectedBaseFiles: ['src/b.tsx', 'src/a.tsx'] }), + ).toEqual(['src/a.tsx', 'src/b.tsx']) + }) +}) + +describe('EXTRACTABLE_EXTENSION', () => { + it.each([ + 'a.ts', + 'a.tsx', + 'a.js', + 'a.jsx', + 'a.mjs', + 'a.cjs', + 'a.mts', + 'a.cts', + ])('accepts %s', (name) => + expect(EXTRACTABLE_EXTENSION.test(name)).toBe(true), + ) + it.each(['a.json', 'a.css', 'a.mdx', 'a.node', 'a'])('rejects %s', (name) => + expect(EXTRACTABLE_EXTENSION.test(name)).toBe(false), + ) }) diff --git a/packages/next-plugin/src/__tests__/project.test.ts b/packages/next-plugin/src/__tests__/project.test.ts new file mode 100644 index 000000000..c73f1d8a8 --- /dev/null +++ b/packages/next-plugin/src/__tests__/project.test.ts @@ -0,0 +1,33 @@ +import { existsSync, unlinkSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { describe, expect, it } from 'bun:test' + +import { cleanupProjects, installProjectHooks, makeProject } from './project' + +installProjectHooks() + +describe('project fixtures', () => { + it('removes tracked projects without traversing their package links', () => { + const originalCwd = process.cwd() + const linked = makeProject({ 'src/fixture.ts': 'export const value = 1' }) + const unlinked = makeProject() + unlinkSync(join(unlinked, 'node_modules/@devup-ui/next-plugin')) + process.chdir(linked) + + cleanupProjects() + + expect(process.cwd()).toBe(originalCwd) + expect(existsSync(linked)).toBe(false) + expect(existsSync(unlinked)).toBe(false) + expect(existsSync(resolve(import.meta.dir, '../../package.json'))).toBe( + true, + ) + }) + + it('can clean an already-cleaned fixture registry', () => { + cleanupProjects() + + expect(cleanupProjects).not.toThrow() + }) +}) diff --git a/packages/next-plugin/src/__tests__/project.ts b/packages/next-plugin/src/__tests__/project.ts new file mode 100644 index 000000000..49b719d18 --- /dev/null +++ b/packages/next-plugin/src/__tests__/project.ts @@ -0,0 +1,67 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import { afterEach, beforeEach } from 'bun:test' + +import { setWasmForTesting } from '../wasm' + +const PLUGIN_DIR = resolve(import.meta.dir, '../..') +const created: string[] = [] +const originalCwd = process.cwd() + +export function cleanupProjects(): void { + process.chdir(originalCwd) + for (const root of created.splice(0)) { + const link = join(root, 'node_modules/@devup-ui/next-plugin') + // The link points at the real package: remove it as a link, never through it. + if (existsSync(link)) unlinkSync(link) + rmSync(root, { recursive: true, force: true }) + } +} + +process.once('exit', cleanupProjects) + +/** + * Every test file that creates projects runs against real engines and + * restores the working directory, whatever an earlier file left behind. + */ +export function installProjectHooks(): void { + beforeEach(() => setWasmForTesting(undefined)) + afterEach(() => { + setWasmForTesting(undefined) + process.chdir(originalCwd) + }) +} + +/** A throwaway project whose engines resolve the real `@devup-ui/wasm`. */ +export function makeProject(files: Record = {}): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'devup-next-'))) + created.push(root) + writeFileSync(join(root, 'package.json'), '{}') + const scope = join(root, 'node_modules/@devup-ui') + mkdirSync(scope, { recursive: true }) + symlinkSync( + PLUGIN_DIR, + join(scope, 'next-plugin'), + process.platform === 'win32' ? 'junction' : 'dir', + ) + for (const [path, content] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), content) + } + return root +} + +export function box(props: string): string { + return `import { Box } from '@devup-ui/react'\nexport const C = () => \n` +} diff --git a/packages/next-plugin/src/__tests__/router-planning.test.ts b/packages/next-plugin/src/__tests__/router-planning.test.ts new file mode 100644 index 000000000..856ed1c67 --- /dev/null +++ b/packages/next-plugin/src/__tests__/router-planning.test.ts @@ -0,0 +1,254 @@ +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { collectNextEntries } from '../entries' +import { planSources } from '../plan' +import { createAppContext } from '../session' +import { box, installProjectHooks, makeProject } from './project' + +installProjectHooks() +const originalEnv = { ...process.env } +afterEach(() => { + process.env = { ...originalEnv } +}) + +describe('Next router source planning', () => { + it.each(['app', 'src/app'])( + 'reaches shells and modern dynamic imports from %s', + (router) => { + // Given + process.env.NODE_ENV = 'production' + const selected = [ + `${router}/page.tsx`, + `${router}/layout.tsx`, + `${router}/template.tsx`, + `${router}/loading.tsx`, + `${router}/error.tsx`, + `${router}/not-found.tsx`, + `${router}/global-error.tsx`, + `${router}/forbidden.tsx`, + `${router}/unauthorized.tsx`, + `${router}/@slot/default.tsx`, + `${router}/@slot/loading.tsx`, + `${router}/api/route.ts`, + `${router}/robots.ts`, + 'src/lazy.mts', + 'src/dep.cts', + 'src/leaf.cjs', + ].sort() + process.chdir( + makeProject({ + ...Object.fromEntries( + selected.map((file) => [file, box('bg="red"')]), + ), + [`${router}/page.tsx`]: `export const lazy = () => import('${router === 'app' ? '../src' : '..'}/lazy.mts')`, + 'src/lazy.mts': "export { value } from './dep.cts'", + 'src/dep.cts': "export { value } from './leaf.cjs'", + 'src/leaf.cjs': 'export const value = 1', + [`${router}/dead/layout.tsx`]: box('bg="dead"'), + [`${router}/_private/page.tsx`]: box('bg="private"'), + }), + ) + const context = createAppContext({}, {}) + // When + const plan = planSources(context) + // Then + expect(plan.expectedBaseFiles).toEqual(selected) + }, + ) + + it.each(['pages', 'src/pages'])( + 'completes Pages routes and special shells in %s', + (router) => { + // Given + process.env.NODE_ENV = 'production' + const selected = [ + 'index.tsx', + '[id].tsx', + 'api/[...slug].ts', + '_app.tsx', + '_document.tsx', + '_error.tsx', + ].map((file) => `${router}/${file}`) + process.chdir( + makeProject( + Object.fromEntries(selected.map((file) => [file, box('p={2}')])), + ), + ) + const context = createAppContext({}, {}) + // When + const plan = planSources(context) + // Then + expect(plan.expectedBaseFiles).toEqual(selected.sort()) + }, + ) + + it.each(['app', 'src/app', 'pages', 'src/pages'])( + 'discovers runtime entries alongside %s', + (router) => { + // Given + process.env.NODE_ENV = 'production' + const prefix = router.startsWith('src/') ? 'src/' : '' + const selected = [ + 'proxy', + 'middleware', + 'instrumentation', + 'instrumentation-client', + ].map((name) => `${prefix}${name}.ts`) + selected.push( + `${router}/${router.endsWith('app') ? 'page' : 'index'}.tsx`, + ) + process.chdir( + makeProject( + Object.fromEntries(selected.map((file) => [file, box('p={2}')])), + ), + ) + const context = createAppContext({}, {}) + // When + const plan = planSources(context) + // Then + expect(plan.expectedBaseFiles).toEqual(selected.sort()) + expect(plan.seedFiles).toEqual(selected.sort()) + }, + ) + + it('uses captured compound page extensions and root after chdir', () => { + // Given + process.env.NODE_ENV = 'production' + const root = makeProject({ + 'app/page.page.tsx': + "export const lazy = () => import('../src/lazy.mts')", + 'app/layout.page.tsx': box('p={2}'), + 'app/ignored/page.tsx': box('p={2}'), + 'src/lazy.mts': box('p={2}'), + }) + process.chdir(root) + const context = createAppContext( + { pageExtensions: ['tsx', 'page.tsx'] }, + {}, + ) + process.chdir(makeProject({ 'src/app/page.tsx': box('bg="wrong"') })) + // When + const plan = planSources(context) + // Then + expect(plan.expectedBaseFiles).toEqual([ + 'app/ignored/page.tsx', + 'app/layout.page.tsx', + 'app/page.page.tsx', + 'src/lazy.mts', + ]) + }) + + it('opts raw MDX out of completion and numbering until compiled preparation exists', () => { + // Given + process.env.NODE_ENV = 'production' + process.chdir( + makeProject({ + 'src/app/page.tsx': box('p={2}'), + 'src/app/guide/page.mdx': + "import { Box } from '@devup-ui/react'\n# Raw markdown", + }), + ) + const context = createAppContext({ pageExtensions: ['tsx', 'mdx'] }, {}) + // When + const plan = planSources(context) + // Then + expect(plan.expectedBaseFiles).toEqual(['src/app/page.tsx']) + expect(plan.seedFiles).toEqual(['src/app/page.tsx']) + }) + + it('uses only effective extensions for entries while retaining imported ordinary modules', () => { + // Given + process.env.NODE_ENV = 'production' + process.chdir( + makeProject({ + 'app/page.mts': "export { C } from '../src/Card.tsx'", + 'app/api/route.cts': 'export const GET = () => 1', + 'pages/index.cjs': 'export default function Page() {}', + 'pages/ignored.tsx': box('p={2}'), + 'src/Card.tsx': box('p={2}'), + }), + ) + const context = createAppContext( + { pageExtensions: ['mts', 'cts', 'cjs'] }, + {}, + ) + // When + const plan = planSources(context) + // Then + expect(plan.expectedBaseFiles).toEqual([ + 'app/api/route.cts', + 'app/page.mts', + 'pages/index.cjs', + 'src/Card.tsx', + ]) + }) + + it('reaches included packages without making their dead modules completion entries', () => { + // Given + process.env.NODE_ENV = 'production' + process.chdir( + makeProject({ + 'app/page.tsx': "export { C } from '@acme/kit'", + 'node_modules/@acme/kit/package.json': JSON.stringify({ + main: 'index.mts', + }), + 'node_modules/@acme/kit/index.mts': "export { C } from './Card.tsx'", + 'node_modules/@acme/kit/Card.tsx': box('p={2}'), + 'node_modules/@acme/kit/dead.tsx': box('p={3}'), + }), + ) + const context = createAppContext({}, { include: ['@acme/kit'] }) + // When + const plan = planSources(context) + // Then + expect(plan.expectedBaseFiles).toEqual([ + 'app/page.tsx', + 'node_modules/@acme/kit/Card.tsx', + 'node_modules/@acme/kit/index.mts', + ]) + expect(plan.seedFiles).toEqual([ + 'node_modules/@acme/kit/Card.tsx', + 'node_modules/@acme/kit/dead.tsx', + ]) + }) + + it('keeps generated output out of graph discovery and numbering', () => { + // Given + process.env.NODE_ENV = 'production' + process.chdir( + makeProject({ + 'app/page.tsx': box('p={2}'), + 'df/app/page.tsx': box('p={3}'), + '.next/app/page.tsx': box('p={4}'), + '.git/app/page.tsx': box('p={5}'), + }), + ) + const context = createAppContext({}, {}) + // When + const plan = planSources(context) + // Then + expect(plan.graph?.files).toEqual([join(context.root, 'app/page.tsx')]) + expect(plan.seedFiles).toEqual(['app/page.tsx']) + }) + + it('matches the longest configured suffix regardless of extension ordering', () => { + // Given + const root = makeProject({ + 'app/page.page.tsx': '', + 'app/layout.page.tsx': '', + }) + const files = ['app/page.page.tsx', 'app/layout.page.tsx'].map((file) => + join(root, file), + ) + // When + const entries = collectNextEntries({ + root, + files, + pageExtensions: ['tsx', 'page.tsx'], + }) + // Then + expect(entries).toEqual([...files].sort()) + }) +}) diff --git a/packages/next-plugin/src/__tests__/session.test.ts b/packages/next-plugin/src/__tests__/session.test.ts new file mode 100644 index 000000000..67154d4c7 --- /dev/null +++ b/packages/next-plugin/src/__tests__/session.test.ts @@ -0,0 +1,278 @@ +import { mkdirSync } from 'node:fs' +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { + createAppContext, + createSession, + digest, + pruneDeadSessions, +} from '../session' +import { installProjectHooks, makeProject } from './project' + +installProjectHooks() + +const originalEnv = { ...process.env } + +beforeEach(() => { + delete process.env.DEVUP_HOIST_V + process.env.NODE_ENV = 'production' +}) +afterEach(() => { + process.env = { ...originalEnv } +}) + +describe('createAppContext', () => { + it('applies every default against the project root it was created in', () => { + const root = makeProject() + process.chdir(root) + + const context = createAppContext({}, {}) + + expect(context).toMatchObject({ + root, + phase: 'production', + watch: false, + libPackage: '@devup-ui/react', + distDir: join(root, 'df'), + cssDir: join(root, 'df', 'devup-ui'), + devupFile: join(root, 'devup.json'), + singleCss: false, + debug: false, + include: [], + prefix: null, + shorthands: {}, + atomHoist: undefined, + hoistV: undefined, + prewarmAll: false, + sourceMap: false, + sourceRoots: ['src', 'app', 'pages'].map((dir) => join(root, dir)), + appDir: join(root, 'df', '.devup', context.appKey), + }) + expect(context.importAliases).toEqual({ + '@emotion/react': null, + '@emotion/styled': 'styled', + '@vanilla-extract/css': null, + 'styled-components': 'styled', + }) + }) + + it('keeps its root after the working directory changes', () => { + const first = makeProject() + const second = makeProject() + process.chdir(first) + const context = createAppContext( + {}, + { distDir: 'out', devupFile: 'x.json' }, + ) + + process.chdir(second) + + expect(context.root).toBe(first) + expect(context.distDir).toBe(join(first, 'out')) + expect(context.devupFile).toBe(join(first, 'x.json')) + expect(createSession(context).stateFile).toBe( + join(first, 'out', '.devup', context.appKey, 'snapshot.json'), + ) + }) + + it('resolves given paths, options and the development phase', () => { + const root = makeProject() + process.chdir(root) + process.env.NODE_ENV = 'development' + + const context = createAppContext( + {}, + { + package: '@acme/ui', + distDir: 'dist-df', + cssDir: 'styles/generated', + devupFile: 'config/devup.json', + singleCss: true, + debug: true, + include: ['@acme/kit'], + prefix: 'du-', + shorthands: { insetX: ['left', 'right'] }, + prewarmAll: true, + importAliases: { 'styled-components': false }, + }, + ) + + expect(context).toMatchObject({ + phase: 'development', + watch: true, + libPackage: '@acme/ui', + distDir: join(root, 'dist-df'), + cssDir: join(root, 'styles', 'generated'), + devupFile: join(root, 'config', 'devup.json'), + singleCss: true, + debug: true, + include: ['@acme/kit'], + prefix: 'du-', + shorthands: { insetX: ['left', 'right'] }, + prewarmAll: true, + sourceMap: true, + }) + expect(context.importAliases).not.toHaveProperty('styled-components') + }) + + it('takes source maps from Next in production', () => { + process.chdir(makeProject()) + + expect( + createAppContext({ productionBrowserSourceMaps: true }, {}).sourceMap, + ).toBe(true) + expect( + createAppContext({ productionBrowserSourceMaps: false }, {}).sourceMap, + ).toBe(false) + }) + + it('captures page extensions independently of later config mutation', () => { + // Given + process.chdir(makeProject()) + const pageExtensions = ['page.tsx', 'mts'] + const config = { pageExtensions } + const baseline = createAppContext({}, {}) + // When + const context = createAppContext(config, {}) + pageExtensions.push('tsx') + // Then + expect(context.pageExtensions).toEqual(['page.tsx', 'mts']) + expect(Object.isFrozen(context.pageExtensions)).toBe(true) + expect(context.appKey).not.toBe(baseline.appKey) + expect(baseline.pageExtensions).toEqual(['jsx', 'js', 'tsx', 'ts']) + }) + + it.each([ + [2, 2], + [0, undefined], + [-1, undefined], + [Number.NaN, undefined], + [Number.POSITIVE_INFINITY, undefined], + [undefined, undefined], + ])('reads atomHoist %p as %p', (given, expected) => { + process.chdir(makeProject()) + process.env.DEVUP_HOIST_V = '3' + + const context = createAppContext({}, { atomHoist: given }) + + expect(context.atomHoist).toBe(expected) + expect(context.hoistV).toBe(expected === undefined ? 3 : undefined) + }) + + it('is immutable', () => { + process.chdir(makeProject()) + const context = createAppContext({}, { include: ['a'] }) + + expect(Object.isFrozen(context)).toBe(true) + expect(Object.isFrozen(context.include)).toBe(true) + expect(Object.isFrozen(context.shorthands)).toBe(true) + expect(Object.isFrozen(context.importAliases)).toBe(true) + expect(Object.isFrozen(context.sourceRoots)).toBe(true) + }) + + it('derives the app key from the project, phase and every option', () => { + const root = makeProject() + process.chdir(root) + const key = (options = {}, config = {}) => + createAppContext(config, options).appKey + const base = key() + + expect(key()).toBe(base) + expect(key({ shorthands: { a: ['b'], c: ['d'] } })).toBe( + key({ shorthands: { c: ['d'], a: ['b'] } }), + ) + for (const changed of [ + { debug: true }, + { prefix: 'p' }, + { singleCss: true }, + { include: ['x'] }, + { package: 'other' }, + { atomHoist: 2 }, + { prewarmAll: true }, + { distDir: 'other' }, + { cssDir: 'other' }, + { devupFile: 'other.json' }, + { importAliases: { 'styled-components': false } }, + { shorthands: { a: ['b'] } }, + ]) { + expect(key(changed)).not.toBe(base) + } + expect(key({}, { productionBrowserSourceMaps: true })).not.toBe(base) + process.env.DEVUP_HOIST_V = '2' + expect(key()).not.toBe(base) + process.env.NODE_ENV = 'development' + expect(key()).not.toBe(base) + process.chdir(makeProject()) + expect(key()).not.toBe(base) + }) +}) + +describe('createSession', () => { + it('gives every call its own token, endpoint and revision file', () => { + const root = makeProject() + process.chdir(root) + const context = createAppContext({}, {}) + + const first = createSession(context) + const second = createSession(context) + + expect(first.token).not.toBe(second.token) + expect(first.endpointFile).not.toBe(second.endpointFile) + expect(first.revisionFile).not.toBe(second.revisionFile) + expect(first.stateFile).toBe(second.stateFile) + expect(first).toMatchObject({ + identity: { project: root, token: first.token }, + sessionDir: join( + context.appDir, + 'sessions', + `${process.pid}-${first.token}`, + ), + stateFile: join(context.appDir, 'snapshot.json'), + }) + expect(first.endpointFile).toBe(join(first.sessionDir, 'endpoint.json')) + expect(first.revisionFile).toBe(join(first.sessionDir, 'revision')) + expect(Object.isFrozen(first)).toBe(true) + expect(Object.isFrozen(first.identity)).toBe(true) + }) +}) + +describe('pruneDeadSessions', () => { + it('removes the sessions of processes that are gone and keeps the rest', () => { + process.chdir(makeProject()) + const context = createAppContext({}, {}) + const sessions = join(context.appDir, 'sessions') + const alive = join(sessions, `${process.pid}-live`) + const dead = join(sessions, '2147483646-dead') + const unrelated = join(sessions, 'not-a-session') + for (const dir of [alive, dead, unrelated]) { + mkdirSync(dir, { recursive: true }) + } + + pruneDeadSessions(context) + + expect(existsSync(alive)).toBe(true) + expect(existsSync(unrelated)).toBe(true) + expect(existsSync(dead)).toBe(false) + }) + + it('does nothing when no session was ever made', () => { + process.chdir(makeProject()) + const context = createAppContext({}, {}) + + expect(() => pruneDeadSessions(context)).not.toThrow() + expect(existsSync(resolve(context.appDir))).toBe(false) + }) +}) + +describe('digest', () => { + it('ignores key order but not values', () => { + expect(digest({ a: 1, b: { c: 2, d: [3, { e: 4, f: 5 }] } })).toBe( + digest({ b: { d: [3, { f: 5, e: 4 }], c: 2 }, a: 1 }), + ) + expect(digest({ a: 1 })).not.toBe(digest({ a: 2 })) + expect(digest({ a: [1, 2] })).not.toBe(digest({ a: [2, 1] })) + }) +}) diff --git a/packages/next-plugin/src/__tests__/setup-handoff.test.ts b/packages/next-plugin/src/__tests__/setup-handoff.test.ts new file mode 100644 index 000000000..131371a82 --- /dev/null +++ b/packages/next-plugin/src/__tests__/setup-handoff.test.ts @@ -0,0 +1,222 @@ +import * as fs from 'node:fs' +import { join } from 'node:path' +import { serialize } from 'node:v8' + +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' + +import type { SessionOwner } from '../lifecycle' +import { type AppContext, createAppContext } from '../session' +import { + consumeSetupHandoff, + reloadSetupModuleForTesting, + resetSetupHandoffsForTesting, + type SetupHandoff, + storeSetupHandoff, +} from '../setup-handoff' +import { installProjectHooks, makeProject } from './project' +import { makeSetupOwner } from './setup-owner-fixture' + +installProjectHooks() + +const originalEnv = { ...process.env } +let sequence = 0 +let owners: SessionOwner[] = [] +let once: ReturnType + +beforeEach(() => { + sequence += 1 + once = spyOn(process, 'once').mockImplementation(() => process) + process.env = { ...process.env, NODE_ENV: 'production' } + resetSetupHandoffsForTesting() +}) +afterEach(() => { + for (const owner of owners) owner.close() + owners = [] + once.mockRestore() + resetSetupHandoffsForTesting() + process.env = { ...originalEnv } +}) + +function app(options = {}) { + return createAppContext({}, options) +} + +function handoff(key = 'key'): SetupHandoff { + return { + key, + defaultTheme: 'dark', + rules: { + '*.ts': { loaders: [], condition: { not: { path: /node_modules/ } } }, + }, + prewarmedFiles: 3, + sessionToken: `session-token-${sequence}`, + } +} + +function store(context: AppContext, setup: SetupHandoff): void { + owners.push(makeSetupOwner(context, setup)) + storeSetupHandoff(context, setup) +} + +describe('setup handoff', () => { + it('hands a setup to one other module instance, once', () => { + process.chdir(makeProject()) + const context = app() + const original = handoff() + store(context, original) + expect( + fs.existsSync(join(context.appDir, `handoff-${process.pid}.bin`)), + ).toBe(true) + + reloadSetupModuleForTesting() + const taken = consumeSetupHandoff(context, 'key') + + expect(taken).toMatchObject(handoff()) + expect(taken?.rules).toBe(original.rules) + expect(taken?.rules['*.ts']).toMatchObject({ + condition: { not: { path: /node_modules/ } }, + }) + expect( + fs.existsSync(join(context.appDir, `handoff-${process.pid}.bin`)), + ).toBe(false) + expect(consumeSetupHandoff(context, 'key')).toBeUndefined() + }) + + it('never lets a module instance take its own handoff', () => { + process.chdir(makeProject()) + const context = app() + store(context, handoff()) + + expect(consumeSetupHandoff(context, 'key')).toBeUndefined() + reloadSetupModuleForTesting() + expect(consumeSetupHandoff(context, 'key')).toBeDefined() + }) + + it('takes nothing for other options, config contents or another app', () => { + process.chdir(makeProject()) + const context = app() + const other = app({ singleCss: true }) + store(context, handoff()) + reloadSetupModuleForTesting() + + expect(consumeSetupHandoff(context, 'another key')).toBeUndefined() + expect(consumeSetupHandoff(other, 'key')).toBeUndefined() + expect(consumeSetupHandoff(context, 'key')).toBeDefined() + }) + + it('keeps the handoffs of two apps apart', () => { + process.chdir(makeProject()) + const first = app() + const second = app({ prefix: 'two-' }) + store(first, { ...handoff(), sessionToken: 'first' }) + store(second, { ...handoff(), sessionToken: 'second' }) + reloadSetupModuleForTesting() + + expect(consumeSetupHandoff(second, 'key')?.sessionToken).toBe('second') + expect(consumeSetupHandoff(first, 'key')?.sessionToken).toBe('first') + }) + + it('rejects a token that is not the one that was handed over', () => { + process.chdir(makeProject()) + const context = app() + store(context, handoff()) + reloadSetupModuleForTesting() + const name = `DEVUP_UI_SETUP_TOKEN_${context.appKey}` + const token = process.env[name] + process.env[name] = `${token}-forged` + + expect(consumeSetupHandoff(context, 'key')).toBeUndefined() + + process.env[name] = token + expect(consumeSetupHandoff(context, 'key')).toBeDefined() + }) + + it('takes nothing without a token or with an unreadable file', () => { + process.chdir(makeProject()) + const context = app() + expect(consumeSetupHandoff(context, 'key')).toBeUndefined() + + store(context, handoff()) + reloadSetupModuleForTesting() + const file = join(context.appDir, `handoff-${process.pid}.bin`) + fs.writeFileSync(file, 'not v8 data') + expect(consumeSetupHandoff(context, 'key')).toBeUndefined() + }) + + it('takes nothing when the stored value has no handoff shape', () => { + process.chdir(makeProject()) + const context = app() + store(context, handoff()) + reloadSetupModuleForTesting() + const file = join(context.appDir, `handoff-${process.pid}.bin`) + for (const value of [ + null, + 1, + { key: 'key' }, + { key: 'key', owner: 'o', token: 't' }, + ]) { + fs.writeFileSync(file, serialize(value)) + expect(consumeSetupHandoff(context, 'key')).toBeUndefined() + } + }) + + it('stays one-use when the file cannot be deleted and says so', () => { + process.chdir(makeProject()) + const context = app() + store(context, handoff()) + reloadSetupModuleForTesting() + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + const remove = spyOn(fs, 'rmSync').mockImplementation(() => { + throw new Error('EPERM') + }) + + try { + expect(consumeSetupHandoff(context, 'key')).toBeDefined() + expect(consumeSetupHandoff(context, 'key')).toBeUndefined() + expect(String(warn.mock.calls[0]?.[0])).toContain( + `handoff-${process.pid}.bin:1:1: devup-ui cannot use \`the setup handoff file\``, + ) + } finally { + remove.mockRestore() + warn.mockRestore() + } + }) + + it('leaves no token behind when the handoff cannot be written', () => { + // Given + const root = makeProject({ + 'df/.devup': 'a file where a directory belongs', + }) + process.chdir(root) + const context = app() + process.env[`DEVUP_UI_SETUP_TOKEN_${context.appKey}`] = 'stale-token' + + // When: writing a handoff needs no live owner; consuming a valid one does. + storeSetupHandoff(context, handoff()) + reloadSetupModuleForTesting() + + // Then + expect( + process.env[`DEVUP_UI_SETUP_TOKEN_${context.appKey}`], + ).toBeUndefined() + expect( + fs.existsSync(join(context.appDir, `handoff-${process.pid}.bin`)), + ).toBe(false) + expect(consumeSetupHandoff(context, 'key')).toBeUndefined() + }) + + it('forgets every handoff of the process on reset', () => { + process.chdir(makeProject()) + const context = app() + store(context, handoff()) + + resetSetupHandoffsForTesting() + + expect(consumeSetupHandoff(context, 'key')).toBeUndefined() + expect( + Object.keys(process.env).filter((name) => + name.startsWith('DEVUP_UI_SETUP_TOKEN_'), + ), + ).toEqual([]) + }) +}) diff --git a/packages/next-plugin/src/__tests__/setup-owner-fixture.ts b/packages/next-plugin/src/__tests__/setup-owner-fixture.ts new file mode 100644 index 000000000..3f20f49cf --- /dev/null +++ b/packages/next-plugin/src/__tests__/setup-owner-fixture.ts @@ -0,0 +1,21 @@ +import { retainSession } from '../lifecycle' +import { type AppContext, createSession } from '../session' +import type { SetupHandoff } from '../setup-handoff' +import { createWasm } from '../wasm' + +export function makeSetupOwner(context: AppContext, result: SetupHandoff) { + const session = createSession(context) + return retainSession({ + session: { + ...session, + token: result.sessionToken, + identity: { project: context.root, token: result.sessionToken }, + }, + coordinator: { + ready: Promise.resolve(), + close: () => {}, + drain: async () => {}, + }, + setup: { context, engine: createWasm(context.root), result }, + }) +} diff --git a/packages/next-plugin/src/__tests__/setup-owner.test.ts b/packages/next-plugin/src/__tests__/setup-owner.test.ts new file mode 100644 index 000000000..d93d77cee --- /dev/null +++ b/packages/next-plugin/src/__tests__/setup-owner.test.ts @@ -0,0 +1,147 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { deserialize, serialize } from 'node:v8' + +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' + +import { findSessionOwner, type SessionOwner } from '../lifecycle' +import { createAppContext, createSession } from '../session' +import { + consumeSetupHandoff, + reloadSetupModuleForTesting, + resetSetupHandoffsForTesting, + type SetupHandoff, + storeSetupHandoff, +} from '../setup-handoff' +import { installProjectHooks, makeProject } from './project' +import { makeSetupOwner } from './setup-owner-fixture' + +installProjectHooks() +let owners: SessionOwner[] = [] +let once: ReturnType + +beforeEach(() => { + once = spyOn(process, 'once').mockImplementation(() => process) + resetSetupHandoffsForTesting() +}) +afterEach(() => { + for (const owner of owners) owner.close() + owners = [] + once.mockRestore() + resetSetupHandoffsForTesting() +}) + +function setup() { + process.chdir(makeProject()) + const context = createAppContext({}, {}) + const result: SetupHandoff = { + key: 'key', + defaultTheme: undefined, + rules: {}, + prewarmedFiles: 0, + sessionToken: createSession(context).token, + } + const owner = makeSetupOwner(context, result) + owners.push(owner) + storeSetupHandoff(context, result) + reloadSetupModuleForTesting() + return { + context, + result, + owner, + file: join(context.appDir, `handoff-${process.pid}.bin`), + } +} + +describe('live setup ownership', () => { + it('keeps the engine and pending handle in the live owner when a descriptor is consumed', () => { + // Given: the owned engine and coordinator are not serializable transport values. + const { context, result, owner, file } = setup() + const stored: unknown = deserialize(readFileSync(file)) + const original = findSessionOwner(result.sessionToken) + // When: a reloaded module takes the descriptor. + const taken = consumeSetupHandoff(context, result.key) + // Then: the original live values survive, while the file contains only scalar descriptors. + expect(taken?.rules).toBe(result.rules) + expect(findSessionOwner(result.sessionToken)).toBe(original) + expect(original?.setup?.engine).toBe(owner.setup?.engine) + expect(original?.coordinator).toBe(owner.coordinator) + if (typeof stored !== 'object' || stored === null) { + throw new TypeError('handoff descriptor must be an object') + } + expect( + Object.values(stored).every((value) => typeof value === 'string'), + ).toBe(true) + }) + + it('rejects a descriptor after its owner is released', () => { + // Given: a valid descriptor whose owner has closed. + const { context, result, owner } = setup() + owner.close() + // When: another config evaluation consumes it. + const taken = consumeSetupHandoff(context, result.key) + // Then: it cannot point loaders at a closed coordinator. + expect(taken).toBeUndefined() + }) + + it('rejects a descriptor after a production drain without waiting for process exit', async () => { + // Given: the owner has completed its production drain. + const { context, result, owner } = setup() + await owner.drain() + // When: the config is reloaded before its old exit callback runs. + const taken = consumeSetupHandoff(context, result.key) + // Then: transport ownership cannot be resurrected by a remaining descriptor. + expect(taken).toBeUndefined() + }) + + it('rejects a descriptor for a missing owner', () => { + // Given: a valid descriptor with no matching live token. + const { context, result, file } = setup() + const stored: unknown = deserialize(readFileSync(file)) + if (typeof stored !== 'object' || stored === null) { + throw new TypeError('handoff descriptor must be an object') + } + writeFileSync(file, serialize({ ...stored, sessionToken: 'missing' })) + // When: the nonce-authenticated descriptor is consumed. + const taken = consumeSetupHandoff(context, result.key) + // Then: serialized data alone cannot create an owner or engine. + expect(taken).toBeUndefined() + }) + + it('rejects an owner from a different captured app even with matching serialized fields', () => { + // Given: a descriptor points at another live app with different options. + const { context, result, file } = setup() + const other = createAppContext({}, { prefix: 'other-' }) + const otherResult = { ...result, sessionToken: createSession(other).token } + owners.push(makeSetupOwner(other, otherResult)) + const stored: unknown = deserialize(readFileSync(file)) + if (typeof stored !== 'object' || stored === null) { + throw new TypeError('handoff descriptor must be an object') + } + writeFileSync( + file, + serialize({ ...stored, sessionToken: otherResult.sessionToken }), + ) + // When: the original app consumes its descriptor. + const taken = consumeSetupHandoff(context, result.key) + // Then: live ownership, not only the file's projection, decides reuse. + expect(taken).toBeUndefined() + }) + + it.each(['root', 'phase', 'appKey'])( + 'rejects a changed %s in the descriptor', + (field) => { + // Given: valid ownership but a corrupted captured-app field. + const { context, result, file } = setup() + const stored: unknown = deserialize(readFileSync(file)) + if (typeof stored !== 'object' || stored === null) { + throw new TypeError('handoff descriptor must be an object') + } + writeFileSync(file, serialize({ ...stored, [field]: 'changed' })) + // When: the descriptor is consumed. + const taken = consumeSetupHandoff(context, result.key) + // Then: it cannot reuse the captured app through stale serialized settings. + expect(taken).toBeUndefined() + }, + ) +}) diff --git a/packages/next-plugin/src/__tests__/shell-allocator.test.ts b/packages/next-plugin/src/__tests__/shell-allocator.test.ts new file mode 100644 index 000000000..5b5ed32be --- /dev/null +++ b/packages/next-plugin/src/__tests__/shell-allocator.test.ts @@ -0,0 +1,133 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +import { restoreShellAllocator } from '../dev-state' +import { createAppContext, createSession } from '../session' +import { captureCoordinatorState, writeCoordinatorStateSync } from '../state' +import { createWasm } from '../wasm' +import { installProjectHooks, makeProject } from './project' + +installProjectHooks() +const originalEnv = { ...process.env } +afterEach(() => { + process.env = { ...originalEnv } +}) + +function shell(development = true) { + process.env.NODE_ENV = development ? 'development' : 'production' + process.chdir(makeProject()) + const context = createAppContext({}, {}) + const session = createSession(context) + const engine = createWasm(context.root) + engine.registerTheme({ colors: { default: { text: 'red' } } }) + const baseline = captureCoordinatorState({ + wasm: engine, + project: context.root, + optionsKey: context.appKey, + revision: 9, + inputs: [], + }) + return { context, session, engine, baseline } +} + +describe('serving-shell allocator restoration', () => { + it('retains the sole serving engine and theme while restoring the previous numbers', () => { + // Given + const fixture = shell() + const snapshot = { + ...fixture.baseline, + classMap: { 'old.mdx': { red: 7 } }, + fileMap: { 'old.mdx': 5 }, + } + writeCoordinatorStateSync(fixture.session.stateFile, snapshot) + const sheet = fixture.engine.exportSheet() + // When + const revision = restoreShellAllocator(fixture) + // Then + expect(revision).toBe(9) + expect(JSON.parse(fixture.engine.exportClassMap())).toEqual( + snapshot.classMap, + ) + expect(JSON.parse(fixture.engine.exportFileMap())).toEqual(snapshot.fileMap) + expect(fixture.engine.exportSheet()).toBe(sheet) + }) + + it('restores both trusted maps after the checkpoint import fails part-way', () => { + // Given + const fixture = shell() + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + writeCoordinatorStateSync(fixture.session.stateFile, { + ...fixture.baseline, + classMap: { 'old.mdx': { stale: 7 } }, + fileMap: { 'old.mdx': 'invalid' }, + }) + const sheet = fixture.engine.exportSheet() + try { + // When + const revision = restoreShellAllocator(fixture) + // Then + expect(revision).toBe(9) + expect(JSON.parse(fixture.engine.exportClassMap())).toEqual( + fixture.baseline.classMap, + ) + expect(JSON.parse(fixture.engine.exportFileMap())).toEqual( + fixture.baseline.fileMap, + ) + expect(fixture.engine.exportSheet()).toBe(sheet) + expect(warn).toHaveBeenCalledTimes(1) + expect(String(warn.mock.calls[0]?.[0])).toContain( + `${fixture.session.stateFile}:1:1:`, + ) + } finally { + warn.mockRestore() + } + }) + + it('does not carry numbers across effective settings keys', () => { + // Given + const fixture = shell() + writeCoordinatorStateSync(fixture.session.stateFile, { + ...fixture.baseline, + classMap: { 'old.mdx': { stale: 7 } }, + fileMap: { 'old.mdx': 5 }, + }) + // When + const revision = restoreShellAllocator({ + ...fixture, + optionsKey: 'changed-final-settings', + }) + // Then + expect(revision).toBe(0) + expect(JSON.parse(fixture.engine.exportFileMap())).toEqual( + fixture.baseline.fileMap, + ) + }) + + it('starts production fresh even when the state file is corrupt', () => { + // Given + const fixture = shell(false) + mkdirSync(dirname(fixture.session.stateFile), { recursive: true }) + writeFileSync(fixture.session.stateFile, '{corrupt') + // When + const revision = restoreShellAllocator(fixture) + // Then + expect(revision).toBe(0) + expect(JSON.parse(fixture.engine.exportClassMap())).toEqual( + fixture.baseline.classMap, + ) + }) + + it('starts a missing development checkpoint with the shell allocator', () => { + // Given + const fixture = shell() + // When + const revision = restoreShellAllocator(fixture) + // Then + expect(revision).toBe(0) + expect(JSON.parse(fixture.engine.exportFileMap())).toEqual( + fixture.baseline.fileMap, + ) + }) +}) diff --git a/packages/next-plugin/src/__tests__/source-type-cache.test.ts b/packages/next-plugin/src/__tests__/source-type-cache.test.ts new file mode 100644 index 000000000..86b65dc02 --- /dev/null +++ b/packages/next-plugin/src/__tests__/source-type-cache.test.ts @@ -0,0 +1,129 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { immutableInput, preparedInput } from '../coordinator-generation' +import { parseExtractRequest } from '../coordinator-http' +import { createInputLedger } from '../coordinator-ledger' +import { sourceFixture } from './source-type-fixture' + +it('refuses a cached transform when identical bytes are requested under another mode', () => { + // Given + const ledger = createInputLedger(2) + const input = { + filename: 'value.mdown', + resourcePath: '/value.mdown', + source: 'export const color="red"', + sourceType: 'compiled-mdx' as const, + dependencies: [], + stamps: {}, + backing: '', + } + const output = { code: 'transformed', updatedBaseStyle: false } + ledger.accept(input, output) + // When + const ordinary = ledger.lookup(input.filename, input.source) + const compiled = ledger.lookup(input.filename, input.source, 'compiled-mdx') + // Then + expect(ordinary).toBeUndefined() + expect(compiled).toBe(output) +}) + +it('retains the mode when a staged generation replaces the transform cache', () => { + // Given + const ledger = createInputLedger(2) + const input = { + filename: 'value.mdown', + resourcePath: '/value.mdown', + source: 'export const color="red"', + sourceType: 'compiled-mdx' as const, + dependencies: [], + stamps: {}, + backing: '', + } + const output = { code: 'transformed', updatedBaseStyle: false } + // When + ledger.stage([immutableInput(input)], new Map([[input.filename, output]]))() + // Then + expect(ledger.lookup(input.filename, input.source)).toBeUndefined() + expect(ledger.lookup(input.filename, input.source, 'compiled-mdx')).toBe( + output, + ) +}) + +it('keeps the newest mode-keyed staged outputs when the cache reaches its bound', () => { + // Given + const ledger = createInputLedger(1) + const input = { + filename: 'first.mdown', + resourcePath: '/first.mdown', + source: 'export const x=1', + sourceType: 'compiled-mdx' as const, + dependencies: [], + stamps: {}, + backing: '', + } + const next = { ...input, filename: 'next.mdown' } + const output = { code: 'transformed', updatedBaseStyle: false } + // When + ledger.stage( + [input, next], + new Map([ + [input.filename, output], + [next.filename, output], + ]), + )() + // Then + expect( + ledger.lookup(input.filename, input.source, 'compiled-mdx'), + ).toBeUndefined() + expect(ledger.lookup(next.filename, next.source, 'compiled-mdx')).toBe(output) +}) + +it.each(['raw-mdx', null, false, {}, 1])( + 'rejects invalid request mode %j at the wire boundary', + (sourceType) => { + // Given + const body = JSON.stringify({ + filename: 'page.mdown', + resourcePath: '/page.mdown', + code: '# raw', + sourceType, + }) + // When / Then + expect(() => parseExtractRequest(body)).toThrow('/extract:1:1:') + }, +) + +it('retains compiled mode while default requests stay absent', () => { + // Given + const request = { + filename: 'page.mdown', + resourcePath: '/page.mdown', + code: 'export const value=1', + } + // When + const compiled = parseExtractRequest( + JSON.stringify({ ...request, sourceType: 'compiled-mdx' }), + ) + const ordinary = parseExtractRequest(JSON.stringify(request)) + // Then + expect(compiled.sourceType).toBe('compiled-mdx') + expect(ordinary).toEqual(request) +}) + +it('rejects native bytes that match a prepared source but lack its compiler mode', async () => { + // Given + const f = sourceFixture({ 'app/page.mdx': '# Test' }) + const generation = await f.manager.prepare(f.signal) + const input = generation.sources[0]?.input + if (!input) throw new Error('Expected prepared input') + // When / Then + expect(() => + preparedInput(generation, { + filename: input.filename, + resourcePath: join(f.root, input.filename), + code: input.source, + }), + ).toThrow('prepared generation') +}) diff --git a/packages/next-plugin/src/__tests__/source-type-fixture.ts b/packages/next-plugin/src/__tests__/source-type-fixture.ts new file mode 100644 index 000000000..e2a560e8a --- /dev/null +++ b/packages/next-plugin/src/__tests__/source-type-fixture.ts @@ -0,0 +1,165 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import { afterEach } from 'bun:test' + +import { extractInput } from '../coordinator-engine' +import { composeMdxRules, requireMdxPipeline } from '../mdx-pipeline' +import { createMdxSourceManager } from '../mdx-source-generation' +import type { MdxBuildBinding, MdxExtractionView } from '../mdx-source-types' +import { createAppContext } from '../session' +import { createWasm, withModuleResolver } from '../wasm' + +const workspace = resolve(import.meta.dir, '../../../..') +const installedRoot = join(workspace, 'apps/landing') +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }) +}) + +export function sourceFixture( + files: Readonly>, + overrides: Partial = {}, +) { + const root = mkdtempSync(join(tmpdir(), 'devup-source-type-')) + roots.push(root) + symlinkSync( + join(installedRoot, 'node_modules'), + join(root, 'node_modules'), + 'junction', + ) + function write(file: string, code: string) { + const path = join(root, file) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, code) + utimesSync(path, new Date(1_700_000_000_000), new Date(1_700_000_000_000)) + return path + } + write('package.json', '{}') + for (const [file, code] of Object.entries(files)) write(file, code) + const provider = write( + 'provider.jsx', + 'export function useMDXComponents() {return {}}', + ) + const counter = write( + 'counter.cjs', + 'function loader(source) {loader.count++; return source}; loader.count=0; module.exports=loader', + ) + const require = createRequire(join(root, 'package.json')) + const compilerOptions = { + jsx: true, + format: 'mdx', + providerImportSource: 'provider', + } + const pipeline = requireMdxPipeline( + root, + composeMdxRules( + { + bundler: 'webpack', + rules: [ + { + use: [ + { + loader: require.resolve('@next/mdx/mdx-js-loader'), + options: compilerOptions, + }, + counter, + ], + }, + ], + aliases: {}, + }, + { loader: 'devup' }, + ).pipelines[0], + ) + const context = { + ...createAppContext( + { pageExtensions: ['mdx', 'md', 'tsx', 'jsx'] }, + { singleCss: true }, + root, + ), + phase: 'production' as const, + watch: false, + } + const settings = { + package: context.libPackage, + cssDir: context.cssDir, + singleCss: true, + sourceMap: false, + importAliases: {}, + } + const owner = {} + function configure( + engine: ReturnType, + view: MdxExtractionView, + ) { + withModuleResolver(engine, root, view.resolver) + engine.importCanonicalMap(view.plan.canonicalMap) + engine.seedFileMap([...view.plan.seedFiles]) + } + const binding: MdxBuildBinding = { + effectiveAppContext: context, + extensions: ['.md', '.mdx'], + aliases: { provider$: provider }, + conditions: ['import', 'node'], + configFile: join(root, 'next.config.mjs'), + selectPipeline: async () => ({ + pipeline, + context: { owner, generation: owner, sourceMap: true }, + identity: pipeline.loaders, + }), + ordinaryEligibility: () => ({ kind: 'disk-first' }), + configureWasm: configure, + async extractDependencies(view, signal) { + signal.throwIfAborted() + const engine = createWasm(root) + configure(engine, view) + return view.inputs.map((input) => ({ + filename: input.filename, + dependencies: extractInput(engine, settings, input).dependencies ?? [], + })) + }, + ...overrides, + } + return { + root, + write, + binding, + compilerOptions, + manager: createMdxSourceManager(binding), + signal: new AbortController().signal, + counts() { + const module: unknown = require(counter) + if ( + typeof module !== 'function' || + !('count' in module) || + typeof module.count !== 'number' + ) + throw new Error('Missing compiler counter') + return module.count + }, + css( + generation: Awaited< + ReturnType['prepare']> + >, + ) { + const engine = createWasm(root) + generation.configureWasm(engine) + for (const input of generation.inputs) + extractInput(engine, settings, input) + return engine.getCss(null, false) + }, + } +} + +export const styledMdx = `import { Box } from '@devup-ui/react'\n\n# Test\n\n\n` diff --git a/packages/next-plugin/src/__tests__/source-type-mdx.test.ts b/packages/next-plugin/src/__tests__/source-type-mdx.test.ts new file mode 100644 index 000000000..1a08b368f --- /dev/null +++ b/packages/next-plugin/src/__tests__/source-type-mdx.test.ts @@ -0,0 +1,132 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { + createMdxSourceManager, + MdxSourceExtensionError, +} from '../mdx-source-generation' +import { createWasm, extractWithModuleResolver } from '../wasm' +import { sourceFixture, styledMdx } from './source-type-fixture' + +it('locates an uncertified extension predicate and explains the finite suffix fix', () => { + const predicate = '/\\.m(down)?$/' + const error = new MdxSourceExtensionError('/next.config.mjs', predicate) + + expect(error.name).toBe('MdxSourceExtensionError') + expect(error.configFile).toBe('/next.config.mjs') + expect(error.extension).toBe(predicate) + expect(error.message).toBe( + `/next.config.mjs:1:1: MDX extension predicate ${predicate} needs a certified finite anchored literal suffix condition; use a literal extension or finite extension alternation`, + ) +}) + +it('extracts real configured JSX-preserving mdown roots under original identities', async () => { + // Given + const f = sourceFixture( + { 'app/page.mdown': styledMdx }, + { extensions: ['.mdown'] }, + ) + const manager = createMdxSourceManager({ + ...f.binding, + effectiveAppContext: { + ...f.binding.effectiveAppContext, + pageExtensions: ['mdown', 'tsx'], + }, + }) + // When + const generation = await manager.prepare(f.signal) + // Then + expect(generation.sources[0]?.input.filename).toBe('app/page.mdown') + expect(generation.sources[0]?.input.sourceType).toBe('compiled-mdx') + expect(generation.cacheReader(join(f.root, 'app/page.mdown'))).toMatchObject({ + sourceType: 'compiled-mdx', + }) + expect(f.compilerOptions.jsx).toBe(true) + expect(f.css(generation)).toContain('background:red') +}) + +it('evaluates a configured mdown export through the original aliased importer', async () => { + // Given + const f = sourceFixture( + { + 'app/page.tsx': `import { css } from '@devup-ui/react'; import { color } from 'value'; export const style = css({color})`, + 'app/value.mdown': `export const color = 'blue'\n\n# Value`, + }, + { extensions: ['.mdown'] }, + ) + const manager = createMdxSourceManager({ + ...f.binding, + aliases: { ...f.binding.aliases, value$: join(f.root, 'app/value.mdown') }, + }) + // When + const generation = await manager.prepare(f.signal) + // Then + expect(generation.sources[0]?.input.filename).toBe('app/value.mdown') + expect( + generation.ordinaryInputs.every((input) => input.sourceType === undefined), + ).toBe(true) + expect(f.css(generation)).toContain('color:blue') +}) + +it('retains mode and original identity when real compiler output is restored from restart cache', async () => { + // Given + const f = sourceFixture( + { + 'app/page.tsx': `import './value.mdown'; export default ()=>null`, + 'app/value.mdown': styledMdx, + }, + { extensions: ['.mdown'] }, + ) + const first = await f.manager.prepare(f.signal) + const cached = createMdxSourceManager( + f.binding, + f.manager.restartCache(first), + ) + // When + const restored = await cached.prepare(f.signal) + // Then + expect(f.counts()).toBe(1) + expect(restored.cacheReader(join(f.root, 'app/value.mdown'))).toMatchObject({ + sourceType: 'compiled-mdx', + }) + expect(f.css(restored)).toContain('background:red') +}) + +it('locates unselected custom Markdown before it reaches WASM', async () => { + // Given + const f = sourceFixture( + { + 'app/page.tsx': `import './value.mdown'; export default ()=>null`, + 'app/value.mdown': '# Raw', + }, + { extensions: ['.mdown'], selectPipeline: async () => undefined }, + ) + // When / Then + await expect(f.manager.prepare(f.signal)).rejects.toThrow( + `${join(f.root, 'app/page.tsx')}:1:1: module source preparation`, + ) +}) + +it.each(['# Raw Markdown', 'export const color: string = "blue"'])( + 'rejects raw or TypeScript input claimed as compiled JavaScript JSX: %s', + (code) => { + // Given + const f = sourceFixture({}) + const engine = createWasm(f.root) + // When / Then + expect(() => + extractWithModuleResolver(engine, false, [ + 'value.mdown', + code, + '@devup-ui/react', + './df', + true, + false, + false, + {}, + 'compiled-mdx', + ]), + ).toThrow() + }, +) diff --git a/packages/next-plugin/src/__tests__/source-type-replay.test.ts b/packages/next-plugin/src/__tests__/source-type-replay.test.ts new file mode 100644 index 000000000..f12859520 --- /dev/null +++ b/packages/next-plugin/src/__tests__/source-type-replay.test.ts @@ -0,0 +1,138 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createCore } from '../coordinator-core' +import { extractInput } from '../coordinator-engine' +import { runPrewarm } from '../prewarm-run' +import { createWasm } from '../wasm' +import { sourceFixture, styledMdx } from './source-type-fixture' + +it.each([true, false])( + 'prewarms a real prepared mdown root with maps=%s', + async (sourceMap) => { + // Given + const f = sourceFixture( + { + 'app/page.tsx': `import './value.mdown'; export default ()=>null`, + 'app/value.mdown': styledMdx, + }, + { extensions: ['.mdown'] }, + ) + const generation = await f.manager.prepare(f.signal) + const engine = createWasm(f.root) + generation.configureWasm(engine) + const code = generation.cacheReader(join(f.root, 'app/value.mdown')) + if (!code || typeof code === 'string') + throw new Error('Missing compiled source') + // When + const result = runPrewarm({ + context: { ...f.binding.effectiveAppContext, sourceMap }, + engine, + files: ['app/value.mdown'], + collectMs: undefined, + preparedInputs: new Map([['app/value.mdown', code]]), + }) + // Then + expect(result.outputs.get('app/value.mdown')?.sourceType).toBe( + 'compiled-mdx', + ) + expect(engine.getCss(null, false)).toContain('background:red') + expect(result.outputs.get('app/value.mdown')?.map === undefined).toBe( + !sourceMap, + ) + }, +) + +it('refreshes real mdown compiler output through Core replay without accumulating old styles', async () => { + // Given + const f = sourceFixture( + { + 'app/page.tsx': `import './value.mdown'; export default ()=>null`, + 'app/value.mdown': styledMdx, + }, + { extensions: ['.mdown'] }, + ) + const generation = await f.manager.prepare(f.signal) + const context = f.binding.effectiveAppContext + const engine = createWasm(f.root) + generation.configureWasm(engine) + const settings = { + package: context.libPackage, + cssDir: context.cssDir, + singleCss: true, + sourceMap: true, + importAliases: {}, + } + for (const input of generation.inputs) extractInput(engine, settings, input) + const core = createCore( + { + wasm: engine, + ...settings, + projectRoot: f.root, + coordinatorPortFile: join(f.root, 'port'), + canonicalMap: {}, + watch: true, + sourceRoots: [], + createEngine: () => createWasm(f.root), + preparedSources: { + initial: { + ordinaryInputs: generation.ordinaryInputs, + generation, + revision: 1, + }, + prepareReplay: (request) => f.manager.refresh(request), + }, + }, + f.root, + ) + try { + await core.startup() + f.write('app/value.mdown', styledMdx.replace('red', 'blue')) + // When + const result = await core.css({ importMainCss: false, wait: false }) + // Then + expect(result.css).toContain('background:blue') + expect(result.css).not.toContain('background:red') + } finally { + core.close() + await core.flush() + } +}) + +it('does not reuse accepted ordinary TSX output when identical bytes change to compiled JavaScript mode', async () => { + // Given + const code = `import { Box } from '@devup-ui/react'; const color: string = 'red'; export const Page = () => ` + const f = sourceFixture({ 'app/page.tsx': code }) + const context = f.binding.effectiveAppContext + const core = createCore( + { + wasm: createWasm(f.root), + package: context.libPackage, + cssDir: context.cssDir, + singleCss: true, + importAliases: {}, + projectRoot: f.root, + coordinatorPortFile: join(f.root, 'port'), + canonicalMap: {}, + createEngine: () => createWasm(f.root), + }, + f.root, + ) + try { + await core.startup() + const request = { + filename: 'app/page.tsx', + resourcePath: join(f.root, 'app/page.tsx'), + code, + } + await core.extract(request) + // When / Then + await expect( + core.extract({ ...request, sourceType: 'compiled-mdx' }), + ).rejects.toThrow() + } finally { + core.close() + await core.flush() + } +}) diff --git a/packages/next-plugin/src/__tests__/source-type-request.test.ts b/packages/next-plugin/src/__tests__/source-type-request.test.ts new file mode 100644 index 000000000..1c92039d1 --- /dev/null +++ b/packages/next-plugin/src/__tests__/source-type-request.test.ts @@ -0,0 +1,124 @@ +import { randomUUID } from 'node:crypto' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { startCoordinator } from '../coordinator' +import { extractInput } from '../coordinator-engine' +import { captureCoordinatorState, readCoordinatorState } from '../state' +import { createWasm } from '../wasm' +import { connect } from './coordinator-app' +import { invoke } from './loader-fixture' +import { sourceFixture, styledMdx } from './source-type-fixture' + +it('delivers real prepared mdown through native loader options and authenticated HTTP', async () => { + // Given + const f = sourceFixture( + { + 'app/page.tsx': `import './value.mdown'; export default ()=>null`, + 'app/value.mdown': styledMdx, + }, + { extensions: ['.mdown'] }, + ) + const generation = await f.manager.prepare(f.signal) + const engine = createWasm(f.root) + generation.configureWasm(engine) + const context = f.binding.effectiveAppContext + const settings = { + package: context.libPackage, + cssDir: context.cssDir, + singleCss: true, + sourceMap: false, + importAliases: {}, + } + for (const input of generation.inputs) extractInput(engine, settings, input) + const identity = { project: f.root, token: randomUUID() } + const portFile = join(f.root, 'coordinator.port') + const handle = startCoordinator({ + wasm: engine, + ...settings, + projectRoot: f.root, + coordinatorPortFile: portFile, + identity, + canonicalMap: {}, + prewarmedFiles: generation.inputs.map((input) => input.filename), + preparedSources: { + initial: { + ordinaryInputs: generation.ordinaryInputs, + generation, + revision: 1, + }, + prepareReplay: async (request) => request.generation, + }, + }) + try { + await handle.ready + const input = generation.sources[0]?.input + if (!input) throw new Error('Missing compiled input') + // When + const delivered = await invoke( + { + projectRoot: f.root, + coordinatorPortFile: portFile, + coordinatorIdentity: identity, + sourceType: 'compiled-mdx', + }, + input.resourcePath, + input.source, + ).result + // Then + expect(delivered.code).not.toContain(' { + // Given + const f = sourceFixture({}) + const snapshot = captureCoordinatorState({ + wasm: createWasm(f.root), + optionsKey: 'owned', + project: f.root, + revision: 1, + inputs: [], + }) + const path = f.write( + 'snapshot.json', + JSON.stringify({ + ...snapshot, + inputs: [ + { + filename: 'value.mdown', + resourcePath: join(f.root, 'value.mdown'), + source: 'export const color="blue"', + sourceType, + dependencies: [], + stamps: {}, + backing: '', + }, + ], + }), + ) + // When / Then + switch (sourceType) { + case 'compiled-mdx': + expect(readCoordinatorState(path, 'owned')?.inputs[0]?.sourceType).toBe( + 'compiled-mdx', + ) + return + case 'raw-mdx': + expect(() => readCoordinatorState(path, 'owned')).toThrow( + `${path}:1:1:`, + ) + return + default: + sourceType satisfies never + } + }, +) diff --git a/packages/next-plugin/src/__tests__/state.test.ts b/packages/next-plugin/src/__tests__/state.test.ts new file mode 100644 index 000000000..e3a22e55a --- /dev/null +++ b/packages/next-plugin/src/__tests__/state.test.ts @@ -0,0 +1,265 @@ +import { mkdirSync, readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { + captureCoordinatorState, + type CoordinatorInput, + type CoordinatorSnapshot, + CoordinatorStateError, + createSnapshotCommitter, + readCoordinatorState, + restoreCoordinatorState, + writeCoordinatorState, + writeCoordinatorStateSync, +} from '../state' +import { createTestApp, failure, removeTestApps } from './coordinator-app' + +const input: CoordinatorInput = { + filename: 'src/a.tsx', + resourcePath: '/app/src/a.tsx', + source: 'const a = 1', + dependencies: ['src/tokens.ts'], + stamps: { '/app/src/tokens.ts': 'abc' }, + backing: 'def', +} + +const snapshot: CoordinatorSnapshot = { + version: 1, + optionsKey: 'key', + project: '/app', + revision: 3, + sheet: { properties: {} }, + classMap: { 'src/a.tsx': { k: 0 } }, + fileMap: { 'src/a.tsx': 0 }, + inputs: [input], +} + +afterEach(removeTestApps) + +function checkpoint(content: string) { + const app = createTestApp() + const path = app.write('state.json', content) + return path +} + +describe('readCoordinatorState', () => { + it('reads a complete checkpoint back unchanged', () => { + const path = checkpoint(JSON.stringify(snapshot)) + + expect(readCoordinatorState(path, 'key')).toEqual(snapshot) + }) + + it('treats a missing checkpoint and one for other options as a cold start', () => { + const path = checkpoint(JSON.stringify(snapshot)) + + expect( + readCoordinatorState(join(path, '..', 'missing.json'), 'key'), + ).toBeUndefined() + expect(readCoordinatorState(path, 'other')).toBeUndefined() + }) + + it('names the file when it is not JSON or cannot be read', () => { + const path = checkpoint('{nope') + const app = createTestApp() + + expect(() => readCoordinatorState(path, 'key')).toThrow( + new RegExp( + `${path.replaceAll('\\', '\\\\')}:1:1: devup-ui coordinator checkpoint cannot use .snapshot. at build time: is not readable JSON\\. Fix: delete this file`, + ), + ) + expect(() => readCoordinatorState(app.root, 'key')).toThrow( + CoordinatorStateError, + ) + }) + + const corrupt: [string, unknown][] = [ + ['an object', []], + ['version 1', { ...snapshot, version: 2 }], + ['optionsKey string', { ...snapshot, optionsKey: 1 }], + ['project string', { ...snapshot, project: null }], + ['non-negative integer revision', { ...snapshot, revision: -1 }], + ['non-negative integer revision', { ...snapshot, revision: 1.5 }], + ['sheet object', { ...snapshot, sheet: [] }], + ['classMap object', { ...snapshot, classMap: 'x' }], + ['fileMap object', { ...snapshot, fileMap: null }], + ['inputs array', { ...snapshot, inputs: {} }], + ['inputs array', { ...snapshot, inputs: [null] }], + ['inputs array', { ...snapshot, inputs: [{ ...input, filename: 1 }] }], + ['inputs array', { ...snapshot, inputs: [{ ...input, resourcePath: 1 }] }], + ['inputs array', { ...snapshot, inputs: [{ ...input, source: 1 }] }], + ['inputs array', { ...snapshot, inputs: [{ ...input, backing: 1 }] }], + [ + 'inputs array', + { ...snapshot, inputs: [{ ...input, dependencies: [1] }] }, + ], + [ + 'inputs array', + { ...snapshot, inputs: [{ ...input, dependencies: 'x' }] }, + ], + ['inputs array', { ...snapshot, inputs: [{ ...input, stamps: [] }] }], + ['inputs array', { ...snapshot, inputs: [{ ...input, stamps: { a: 1 } }] }], + ] + it.each(corrupt)( + 'rejects a checkpoint that is not %s as a whole', + (expected, value) => { + const path = checkpoint(JSON.stringify(value)) + + expect(() => readCoordinatorState(path, 'key')).toThrow( + `is corrupt: expected ${expected}`, + ) + }, + ) +}) + +describe('capturing, writing and restoring', () => { + it('moves the complete engine state into a fresh engine', () => { + const app = createTestApp() + const from = app.engine() + const source = `import { Box, globalCss } from '@devup-ui/react'\nglobalCss({ body: { margin: 1 } })\nexport const A = () => \n` + from + .codeExtract( + 'src/a.tsx', + source, + '@devup-ui/react', + './df', + false, + false, + true, + {}, + ) + .free() + + const captured = captureCoordinatorState({ + wasm: from, + optionsKey: 'k', + project: app.root, + revision: 5, + inputs: [input], + }) + const to = app.engine() + restoreCoordinatorState(to, JSON.parse(JSON.stringify(captured))) + + expect(captured).toMatchObject({ version: 1, optionsKey: 'k', revision: 5 }) + expect(to.getCss(null, false)).toBe(from.getCss(null, false)) + expect(to.getCss(0, true)).toBe(from.getCss(0, true)) + expect(to.exportClassMap()).toBe(from.exportClassMap()) + expect(to.exportFileMap()).toBe(from.exportFileMap()) + }) + + it('writes the checkpoint whole, never leaving a temporary file behind', async () => { + const app = createTestApp() + const path = join(app.root, 'nested', 'state.json') + + await writeCoordinatorState(path, snapshot) + + expect(JSON.parse(readFileSync(path, 'utf-8'))).toEqual(snapshot) + expect(readdirSync(join(app.root, 'nested'))).toEqual(['state.json']) + }) + + it('writes synchronously too, and cleans up when it cannot', () => { + const app = createTestApp() + const path = join(app.root, 'sync', 'state.json') + + writeCoordinatorStateSync(path, snapshot) + mkdirSync(join(app.root, 'blocked')) + + expect(JSON.parse(readFileSync(path, 'utf-8'))).toEqual(snapshot) + expect(() => + writeCoordinatorStateSync(join(app.root, 'blocked'), snapshot), + ).toThrow() + expect( + readdirSync(app.root).filter((name) => name.endsWith('.tmp')), + ).toEqual([]) + }) +}) + +describe('createSnapshotCommitter', () => { + const at = (revision: number): CoordinatorSnapshot => ({ + ...snapshot, + revision, + }) + + it('writes the newest capture once for callers that queued before the write began', async () => { + const written: number[] = [] + const captured: number[] = [] + const committer = createSnapshotCommitter(async (value) => { + written.push(value.revision) + }) + + await Promise.all( + [1, 2, 3].map((revision) => + committer.commit(() => { + captured.push(revision) + return at(revision) + }), + ), + ) + + expect(captured).toEqual([3]) + expect(written).toEqual([3]) + }) + + it('runs later commits as a following write, in order', async () => { + const written: number[] = [] + const committer = createSnapshotCommitter(async (value) => { + await delay(20) + written.push(value.revision) + }) + + const first = committer.commit(() => at(1)) + await delay(5) + const second = committer.commit(() => at(2)) + await Promise.all([first, second]) + await committer.drain() + + expect(written).toEqual([1, 2]) + }) + + it('fails exactly the callers of a failed write, and keeps writing afterwards', async () => { + let failing = true + const committer = createSnapshotCommitter(async () => { + await delay(5) + if (failing) throw new Error('disk full') + }) + + const doomed = committer.commit(() => at(1)) + const alsoDoomed = committer.commit(() => at(2)) + const errors = await Promise.all([failure(doomed), failure(alsoDoomed)]) + expect(String(await failure(committer.drain()))).toBe('Error: disk full') + failing = false + await committer.commit(() => at(3)) + await committer.drain() + + expect(errors.map(String)).toEqual(['Error: disk full', 'Error: disk full']) + }) + + it('fails a caller when the state cannot be captured', async () => { + const committer = createSnapshotCommitter(async () => undefined) + + const error = await failure( + committer.commit(() => { + throw new Error('engine gone') + }), + ) + expect(await failure(committer.drain())).toBe(error) + await committer.commit(() => at(1)) + + expect(String(error)).toBe('Error: engine gone') + }) + + it('drain resolves only when nothing is queued or being written', async () => { + let finished = false + const committer = createSnapshotCommitter(async () => { + await delay(50) + finished = true + }) + void committer.commit(() => at(1)) + + await committer.drain() + + expect(finished).toBe(true) + }) +}) diff --git a/packages/next-plugin/src/__tests__/turbo-harness.ts b/packages/next-plugin/src/__tests__/turbo-harness.ts new file mode 100644 index 000000000..162da8f87 --- /dev/null +++ b/packages/next-plugin/src/__tests__/turbo-harness.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, mock, spyOn } from 'bun:test' + +import * as coordinatorModule from '../coordinator' +import type { + CoordinatorHandle, + CoordinatorOptions, +} from '../coordinator-options' +import { resetTurboSetupCacheForTesting } from '../plugin' + +type Handler = () => unknown + +export interface TurboHarness { + /** The options of every coordinator the plugin started */ + readonly starts: CoordinatorOptions[] + /** The handle returned for each of them */ + readonly handles: CoordinatorHandle[] + /** The process-lifetime handlers the plugin registered, by event */ + readonly handlers: Record + /** Runs when a coordinator is started, before it publishes anything */ + onStart: ((options: CoordinatorOptions) => void) | undefined + /** Start the coordinators for real instead of recording them */ + startRealCoordinators(): void +} + +/** + * Run the plugin as Next does: Turbopack on, the coordinator recorded instead + * of started, and process-lifetime handlers captured instead of registered. + */ +export function installTurboHarness(): TurboHarness { + const spies: { + start?: ReturnType + once?: ReturnType + } = {} + const harness: TurboHarness = { + starts: [], + handles: [], + handlers: {}, + onStart: undefined, + startRealCoordinators: () => spies.start?.mockRestore(), + } + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + resetTurboSetupCacheForTesting() + originalEnv = { ...process.env } + process.env.TURBOPACK = '1' + process.env.NODE_ENV = 'production' + harness.starts.length = 0 + harness.handles.length = 0 + harness.onStart = undefined + for (const event of Object.keys(harness.handlers)) { + delete harness.handlers[event] + } + spies.start = spyOn( + coordinatorModule, + 'startCoordinator', + ).mockImplementation((options) => { + harness.onStart?.(options) + const handle: CoordinatorHandle = { + ready: Promise.resolve(), + close: mock(() => {}), + drain: mock(async () => {}), + } + harness.starts.push(options) + harness.handles.push(handle) + return handle + }) + spies.once = spyOn(process, 'once').mockImplementation((( + event: string, + handler: Handler, + ) => { + ;(harness.handlers[event] ??= []).push(handler) + return process + }) as never) + }) + afterEach(() => { + spies.start?.mockRestore() + spies.once?.mockRestore() + resetTurboSetupCacheForTesting() + process.env = originalEnv + }) + return harness +} diff --git a/packages/next-plugin/src/__tests__/turbo-source-filter.test.ts b/packages/next-plugin/src/__tests__/turbo-source-filter.test.ts new file mode 100644 index 000000000..8b35c3cf4 --- /dev/null +++ b/packages/next-plugin/src/__tests__/turbo-source-filter.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from 'bun:test' + +import { createAppContext, createSession } from '../session' +import { createTurboRules, SOURCE_RULE } from '../turbo-rules' + +it.each(['src/componentmdx.tsx', 'src/page.mdx.tsx'])( + 'extracts an ordinary source whose real filename is %s', + (filename) => { + // Given + const context = createAppContext({}, {}) + const rules = createTurboRules({ + context, + session: createSession(context), + themeFiles: [], + theme: {}, + }) + // When + const rule = rules[SOURCE_RULE] + const condition = rule && !Array.isArray(rule) ? rule.condition : undefined + if ( + !condition || + typeof condition !== 'object' || + !('not' in condition) || + typeof condition.not !== 'object' || + !('path' in condition.not) || + !(condition.not.path instanceof RegExp) + ) { + throw new TypeError('Expected a path exclusion condition') + } + // Then + expect(condition.not.path.test(filename)).toBe(false) + }, +) diff --git a/packages/next-plugin/src/__tests__/wasm-isolation.test.ts b/packages/next-plugin/src/__tests__/wasm-isolation.test.ts new file mode 100644 index 000000000..b64f1d6e0 --- /dev/null +++ b/packages/next-plugin/src/__tests__/wasm-isolation.test.ts @@ -0,0 +1,233 @@ +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { + createWasm, + type DevupWasm, + setWasmForTesting, + withModuleResolver, +} from '../wasm' + +const originalCwd = process.cwd() +const roots: string[] = [] + +function project(): string { + const root = mkdtempSync(join(tmpdir(), 'devup-next-wasm-')) + roots.push(root) + writeFileSync(join(root, 'package.json'), '{}') + return root +} + +function extract(engine: DevupWasm, filename: string, source: string) { + return engine.codeExtract( + filename, + source, + '@devup-ui/react', + 'df', + false, + false, + false, + {}, + ) +} + +beforeEach(() => setWasmForTesting(undefined)) +afterEach(() => { + process.chdir(originalCwd) + setWasmForTesting(undefined) +}) +afterAll(() => { + for (const root of roots) rmSync(root, { recursive: true, force: true }) +}) + +describe('real per-app WASM namespaces', () => { + it('keeps prefix and theme changes private when another app extracts', () => { + const a = createWasm() + const b = createWasm() + + a.setPrefix('app-a-') + a.registerTheme({ colors: { default: { primary: 'red' } } }) + using outputA = extract( + a, + 'page.tsx', + 'import { Box } from "@devup-ui/react"; const x = ', + ) + using outputB = extract( + b, + 'page.tsx', + 'import { Box } from "@devup-ui/react"; const x = ', + ) + + expect(a).not.toBe(b) + expect(a.Output).not.toBe(b.Output) + expect(outputA).toBeInstanceOf(a.Output) + expect(outputA).not.toBeInstanceOf(b.Output) + expect(outputA.code).toContain('app-a-') + expect(outputB.code).not.toContain('app-a-') + expect(a.getCss(undefined, false)).toContain('--primary:red') + expect(b.getCss(undefined, false)).not.toContain('--primary') + }) + + it('keeps canonical and number maps private when another app allocates names', () => { + const a = createWasm() + const b = createWasm() + const source = + 'import { Box } from "@devup-ui/react"; const x = ' + + a.importCanonicalMap({ 'page.tsx': 'canonical.tsx' }) + a.importFileMap({ 'canonical.tsx': 42 }) + a.importClassMap({ reserved: { atom: 99 } }) + using outputA = extract(a, 'page.tsx', source) + using outputB = extract(b, 'page.tsx', source) + + expect(JSON.parse(a.exportCanonicalMap())).toEqual({ + 'page.tsx': 'canonical.tsx', + }) + expect(JSON.parse(b.exportCanonicalMap())).toEqual({}) + expect(JSON.parse(a.exportFileMap())).toMatchObject({ 'canonical.tsx': 42 }) + expect(JSON.parse(a.exportClassMap())).toHaveProperty('reserved.atom', 99) + expect(JSON.parse(b.exportFileMap())).toEqual({ 'page.tsx': 0 }) + expect(JSON.parse(b.exportClassMap())).not.toHaveProperty('reserved') + expect(outputA.code).not.toBe(outputB.code) + expect(b.getCss(0, false)).toContain('background:red') + }) + + it('keeps route and hoist state private when apps use the same filenames', () => { + const a = createWasm() + const b = createWasm() + const source = + 'import { Box } from "@devup-ui/react"; const x = ' + a.setAtomHoist(2) + a.importFileRoutes({ 'a.tsx': [0], 'b.tsx': [1] }) + + extract(a, 'a.tsx', source).free() + extract(a, 'b.tsx', source).free() + extract(b, 'a.tsx', source).free() + extract(b, 'b.tsx', source).free() + + expect(a.getCss(undefined, false)).toContain('background:red') + expect(a.getCss(0, false)).not.toContain('background:red') + expect(b.getCss(undefined, false)).not.toContain('background:red') + expect(b.getCss(0, false)).toContain('background:red') + expect(b.getCss(1, false)).toContain('background:red') + }) + + it('captures explicit resolver roots and POSIX ids before cwd changes', () => { + const rootA = project() + const rootB = project() + mkdirSync(join(rootA, 'src')) + mkdirSync(join(rootB, 'src')) + writeFileSync(join(rootA, 'src/color.ts'), 'export const color = "red"') + writeFileSync(join(rootB, 'src/color.ts'), 'export const color = "blue"') + const a = withModuleResolver(createWasm(), rootA) + const b = withModuleResolver(createWasm(), rootB) + process.chdir(rootB) + const source = + 'import { Box } from "@devup-ui/react"; import { color } from "./color"; const x = ' + + using outputA = extract(a, 'src/page.tsx', source) + using outputB = extract(b, 'src/page.tsx', source) + + expect(outputA.dependencies).toEqual(['src/color.ts']) + expect(outputB.dependencies).toEqual(['src/color.ts']) + expect(a.getCss(0, false)).toContain('background:red') + expect(b.getCss(0, false)).toContain('background:blue') + }) + + it('keeps resolver replacement private when another app imports a constant', () => { + const a = createWasm() + const b = createWasm() + const source = + 'import { Box } from "@devup-ui/react"; import { color } from "./color"; const x = ' + a.setModuleResolver(() => ({ + path: 'color.ts', + code: 'export const color = "red"', + })) + b.setModuleResolver(() => ({ + path: 'color.ts', + code: 'export const color = "blue"', + })) + + a.setModuleResolver(() => ({ + path: 'color.ts', + code: 'export const color = "green"', + })) + extract(a, 'page.tsx', source).free() + extract(b, 'page.tsx', source).free() + + expect(a.getCss(0, false)).toContain('background:green') + expect(b.getCss(0, false)).toContain('background:blue') + }) + + it('returns same-realm errors when real WASM rejects invalid input', () => { + const engine = createWasm() + expect.assertions(1) + + try { + engine.importFileMap({ broken: 'not-a-number' }) + } catch (error) { + if (!(error instanceof Error)) throw error + expect(Object.getPrototypeOf(error)).toBe(Error.prototype) + } + }) + + it('uses physical-install entry, local require and dirname with an explicit root', () => { + const root = project() + const modules = join( + root, + 'node_modules/.bun/next-plugin/node_modules/@devup-ui', + ) + const plugin = join(modules, 'next-plugin') + const engineDir = join(modules, 'wasm') + const scope = join(root, 'node_modules/@devup-ui') + mkdirSync(plugin, { recursive: true }) + mkdirSync(engineDir, { recursive: true }) + mkdirSync(scope, { recursive: true }) + writeFileSync(join(plugin, 'package.json'), '{}') + writeFileSync(join(engineDir, 'package.json'), '{"main":"engine.cjs"}') + const realEntry = createRequire(import.meta.path).resolve('@devup-ui/wasm') + copyFileSync( + join(dirname(realEntry), 'index_bg.wasm'), + join(engineDir, 'index_bg.wasm'), + ) + writeFileSync(join(engineDir, 'prefix.cjs'), 'module.exports = "physical-"') + writeFileSync( + join(engineDir, 'engine.cjs'), + readFileSync(realEntry, 'utf8') + + '\nexports.setPrefix(require("./prefix.cjs") + require("path").basename(__filename));', + ) + writeFileSync(join(root, 'color.ts'), 'export const color = "purple"') + symlinkSync( + plugin, + join(scope, 'next-plugin'), + process.platform === 'win32' ? 'junction' : 'dir', + ) + + const a = createWasm(root) + const b = createWasm(root) + a.setPrefix('changed') + + expect(b.getPrefix()).toBe('physical-engine.cjs') + expect(a.getPrefix()).toBe('changed') + using output = extract( + b, + 'page.tsx', + 'import { Box } from "@devup-ui/react"; import { color } from "./color"; const x = ', + ) + expect(output.code).toContain('physical-engine.cjs') + expect(output.dependencies).toEqual(['color.ts']) + expect(b.getCss(0, false)).toContain('background:purple') + }) +}) diff --git a/packages/next-plugin/src/__tests__/wasm.test.ts b/packages/next-plugin/src/__tests__/wasm.test.ts index 172244aa8..9860dbcc1 100644 --- a/packages/next-plugin/src/__tests__/wasm.test.ts +++ b/packages/next-plugin/src/__tests__/wasm.test.ts @@ -14,6 +14,7 @@ import { afterAll, afterEach, beforeAll, + beforeEach, describe, expect, it, @@ -21,6 +22,7 @@ import { } from 'bun:test' import { + createWasm, loadWasm, loadWebpackPlugin, requireFromPlugin, @@ -36,6 +38,11 @@ beforeAll(() => { tempRoots = [] }) +beforeEach(() => { + setWasmForTesting(undefined) + setWebpackPluginForTesting(undefined) +}) + afterEach(() => { process.chdir(originalCwd) setWasmForTesting(undefined) @@ -55,8 +62,11 @@ describe('WASM loading', () => { specifier: string, importer: string, ) => { path: string } | undefined + const otherRoot = mkdtempSync(join(tmpdir(), 'devup-ui-resolver-cwd-')) + tempRoots.push(otherRoot) + process.chdir(otherRoot) expect(resolveModule('./wasm.test', import.meta.path)?.path).toBe( - relative(process.cwd(), import.meta.path).replaceAll('\\', '/'), + relative(originalCwd, import.meta.path).replaceAll('\\', '/'), ) const older = {} as typeof wasm expect(withModuleResolver(older)).toBe(older) @@ -65,6 +75,8 @@ describe('WASM loading', () => { it('uses an injected namespace in tests', () => { setWasmForTesting(wasm) expect(loadWasm()).toBe(wasm) + expect(createWasm()).toBe(wasm) + expect(createWasm('/unused-injected-root')).toBe(wasm) }) it('loads the package once', () => { diff --git a/packages/next-plugin/src/__tests__/webpack-alias-join.test.ts b/packages/next-plugin/src/__tests__/webpack-alias-join.test.ts new file mode 100644 index 000000000..216f9f716 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-alias-join.test.ts @@ -0,0 +1,275 @@ +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' + +import { + buildStaticImportGraph, + type CreateModuleResolverOptions, + importGraphFailureOf, +} from '@devup-ui/plugin-utils' +import { afterEach, beforeEach, expect, it } from 'bun:test' +import type { Configuration } from 'webpack' + +import { + createWasm, + extractWithModuleResolver, + withModuleResolver, +} from '../wasm' +import { webpackResourceResolver } from '../webpack-resource-delivery' +import { expectedJoinResolution } from './join-resolution-inputs' +import { mdxRule, withSelector } from './webpack-resource-fixture' + +const aliasFixture: () => { + readonly root: string + readonly entry: string + readonly file: (name: string, code?: string) => string + readonly dispose: () => void + readonly installed: ( + request: string, + options: CreateModuleResolverOptions & { + readonly mainFields?: readonly string[] + }, + ) => string | false +} = createRequire(import.meta.url)( + '../../../plugin-utils/src/__tests__/alias-array-fixture.ts', +).aliasFixture + +let fixture: ReturnType +beforeEach(() => { + fixture = aliasFixture() +}) +afterEach(() => fixture.dispose()) + +async function observe( + config: Configuration, + request = 'provider', + disabled = false, +) { + return withSelector( + { context: fixture.root, ...config, module: { rules: [mdxRule()] } }, + (selector, compiler, binding) => { + const settings = disabled + ? webpackResourceResolver({ + ...binding, + effectiveConfiguration: { + ...binding.effectiveConfiguration, + resolve: Object.defineProperty( + { ...binding.effectiveConfiguration.resolve }, + 'alias', + { value: false, enumerable: true }, + ), + }, + }) + : selector + const native = fixture.installed(request, { + alias: disabled ? false : (compiler.options.resolve.alias ?? {}), + mainFields: ['module', 'main'], + }) + const graph = buildStaticImportGraph('src', undefined, { + cwd: fixture.root, + alias: settings.aliases, + }) + const engine = createWasm(resolve(import.meta.dir, '../../../..')) + withModuleResolver(engine, fixture.root, { alias: settings.aliases }) + const output = extractWithModuleResolver(engine, false, [ + 'src/main.ts', + `import { css } from '@devup-ui/react'; import { value } from ${JSON.stringify(request)}; export const cls = css({color: value === undefined ? 'red' : value})`, + '@devup-ui/react', + './df', + true, + false, + false, + {}, + ]) + try { + return { + aliases: settings.aliases, + native, + outcome: graph.requests[0]?.outcome, + css: engine.getCss(null, false), + } + } finally { + output.free() + } + }, + ) +} + +it.each(['first', 'empty', 'self'])( + 'matches native duplicate descriptor selection when earlier entry is %s', + async (kind) => { + // Given + fixture.file('src/main.ts', "import 'provider'") + const blue = fixture.file('blue.js', "export const value = 'blue'") + const wrong = fixture.file('wrong.js', "export const value = 'orange'") + const alias = [ + { + name: 'provider', + alias: + kind === 'first' + ? [join(fixture.root, 'missing'), blue] + : kind === 'empty' + ? [] + : ['provider'], + }, + { name: 'provider', alias: kind === 'first' ? wrong : blue }, + ] + // When + const result = await observe({ resolve: { alias } }) + // Then + expect(result.native).toBe(blue) + expect(result.outcome).toEqual( + expectedJoinResolution( + fixture.root, + [blue], + kind === 'first' + ? ['missing', 'missing/package.json', 'missing/index.ts'] + : [], + ), + ) + expect(result.css).toContain('color:blue') + }, +) + +it.each([ + { name: 'provider$', request: 'provider$/child', onlyModule: false }, + { name: 'provider', request: 'provider', onlyModule: true }, +])( + 'matches native ignored descriptor semantics when request is $request', + async ({ name, request, onlyModule }) => { + // Given + fixture.file('src/main.ts', `import ${JSON.stringify(request)}`) + const alias = [{ name, alias: false as const, onlyModule }] + // When + const result = await observe({ resolve: { alias } }, request) + // Then + expect(result.native).toBe(false) + expect(result.outcome).toEqual({ kind: 'ignored' }) + expect(result.css).toContain('color:red') + }, +) + +it('matches native subpath fallback when an earlier descriptor is exact', async () => { + // Given + const request = 'provider/child' + fixture.file('src/main.ts', `import '${request}'`) + const blue = fixture.file('target/child.js', "export const value = 'blue'") + const alias = [ + { name: 'provider', alias: false as const, onlyModule: true }, + { name: 'provider', alias: dirname(blue) }, + ] + // When + const result = await observe({ resolve: { alias } }, request) + // Then + expect(result.native).toBe(blue) + expect(result.outcome).toEqual( + expectedJoinResolution( + fixture.root, + [blue], + ['target/child', 'target/child.ts'], + ), + ) + expect(result.css).toContain('color:blue') +}) + +it('preserves enhanced-resolve global false at the effective resolver seam', async () => { + // Given + fixture.file('src/main.ts', "import 'provider'") + fixture.file('node_modules/provider/package.json', '{"main":"index.js"}') + const blue = fixture.file( + 'node_modules/provider/index.js', + "export const value = 'blue'", + ) + // When + const result = await observe({}, 'provider', true) + // Then + expect(result.native).toBe(blue) + expect(result.aliases).toBe(false) + expect(result.outcome).toEqual( + expectedJoinResolution( + fixture.root, + [blue, join(fixture.root, 'node_modules/provider/package.json')], + ['src/node_modules/provider/package.json'], + ), + ) + expect(result.css).toContain('color:blue') +}) + +it.each(['module', 'main'])( + 'matches native directory fields when selected entry is %s', + async (field) => { + // Given + fixture.file('src/main.ts', "import 'provider'") + fixture.file( + 'target/package.json', + JSON.stringify({ + module: field === 'module' ? 'module.js' : 'absent.js', + main: 'main.js', + exports: './wrong.js', + }), + ) + const blue = fixture.file( + `target/${field}.js`, + "export const value = 'blue'", + ) + fixture.file('target/index.js', "export const value = 'orange'") + const alias = [{ name: 'provider', alias: join(fixture.root, 'target') }] + // When + const result = await observe({ + resolve: { alias, mainFields: ['module', 'main'] }, + }) + // Then + expect(result.native).toBe(blue) + expect(result.outcome).toEqual( + expectedJoinResolution( + fixture.root, + [blue, join(fixture.root, 'target/package.json')], + field === 'main' + ? ['target.ts', 'target/absent.js', 'target/absent.js/package.json'] + : ['target.ts'], + ), + ) + expect(result.css).toContain('color:blue') + }, +) + +it('keeps native rewriting failure terminal when a later duplicate would resolve', async () => { + // Given + const entry = fixture.file('src/main.ts', "import 'provider'") + const later = fixture.file('later.js', "export const value = 'blue'") + const alias = [ + { name: 'provider', alias: join(fixture.root, 'missing') }, + { name: 'provider', alias: later }, + ] + // When + const failure = await withSelector( + { + context: fixture.root, + resolve: { alias }, + module: { rules: [mdxRule()] }, + }, + (selector, compiler) => { + expect(() => + fixture.installed('provider', { + alias: compiler.options.resolve.alias ?? {}, + }), + ).toThrow() + try { + buildStaticImportGraph('src', undefined, { + cwd: fixture.root, + alias: selector.aliases, + }) + } catch (error) { + if (error instanceof Error) return error + throw error + } + throw new TypeError('Expected terminal alias failure') + }, + ) + // Then + expect(failure.name).toBe('ModuleAliasCandidatesError') + expect(importGraphFailureOf(failure)?.[0]).toMatchObject({ + importer: entry, + specifier: 'provider', + outcome: { kind: 'error', error: failure }, + }) +}) diff --git a/packages/next-plugin/src/__tests__/webpack-bridge-capture.cjs b/packages/next-plugin/src/__tests__/webpack-bridge-capture.cjs new file mode 100644 index 000000000..f5f7c55b4 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-bridge-capture.cjs @@ -0,0 +1,251 @@ +const assert = require('node:assert/strict') +const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = require('node:fs') +const { createRequire } = require('node:module') +const { join } = require('node:path') +const { run, close } = require('./webpack-bridge-compiler.cjs') + +const [repo, artifacts, runtimePath, sourceLoader, cssLoader] = + process.argv.slice(2) +const pluginRequire = createRequire( + join(repo, 'packages/next-plugin/package.json'), +) +const webpack = createRequire( + pluginRequire.resolve('@types/webpack/package.json'), +)('webpack') +const landingRequire = createRequire(join(repo, 'apps/landing/package.json')) +const runtime = require(runtimePath) +const root = mkdtempSync(join(artifacts, 'webpack-app-')) +const modules = [] +const extractions = [] +const css = [] +global.devupBridgeCapture = { modules, css } + +async function capture() { + const context = { + ...runtime.createAppContext({}, { singleCss: true }), + root, + watch: false, + cssDir: join(root, 'df/devup-ui'), + appDir: join(root, 'df/.devup'), + devupFile: join(root, 'devup.json'), + } + const session = runtime.createSession(context) + mkdirSync(session.sessionDir, { recursive: true }) + const preparation = Promise.withResolvers() + const binding = Promise.withResolvers() + const bothEntered = Promise.withResolvers() + const callbacks = [] + const compilations = [] + let engineCount = 0 + const createEngine = () => { + engineCount += 1 + return runtime.createWasm(repo) + } + const handle = runtime.startCoordinator({ + projectRoot: root, + identity: session.identity, + coordinatorPortFile: session.endpointFile, + prepare: () => preparation.promise, + }) + mkdirSync(context.cssDir, { recursive: true }) + writeFileSync(join(root, 'package.json'), '{}') + writeFileSync(context.devupFile, '{}') + writeFileSync( + join(context.cssDir, 'devup-ui.css'), + '/* generated placeholder */', + ) + writeFileSync( + join(root, 'entry.js'), + "import { css } from '@devup-ui/react'; import { blue } from './page.mdx'; import './df/devup-ui/devup-ui.css'; export const red = css({color:'red'}); export { blue };", + ) + writeFileSync( + join(root, 'page.mdx'), + "import { css } from '@devup-ui/react'\n\nexport const blue = css({color:'blue'})\n\n# MDX compiler proof\n", + ) + writeFileSync( + join(root, 'downstream.cjs'), + 'module.exports=function(source){global.devupBridgeCapture.modules.push({path:this.resourcePath,source});return source}', + ) + writeFileSync( + join(root, 'native-css.cjs'), + "module.exports=function(source){global.devupBridgeCapture.css.push(source);return 'module.exports='+JSON.stringify(source)}", + ) + const compose = runtime.createWebpackCoordinatorBridge({ + context, + session, + handle, + theme: {}, + themeFiles: [context.devupFile], + async prepare(request) { + assert.equal(request.context, context) + assert.equal(request.session, session) + assert.equal(request.handle, handle) + assert.equal(request.compiler.context, root) + assert.equal(request.compiler.options.mode, 'development') + assert.ok(request.compiler.resolverFactory.get('normal')) + assert.ok(request.params.normalModuleFactory) + assert.equal(request.pipelines.length, 1) + callbacks.push(request.compiler.name) + if (callbacks.length === 2) bothEntered.resolve() + await binding.promise + }, + }) + const base = { + context: root, + mode: 'development', + target: 'node', + cache: false, + entry: './entry.js', + devtool: false, + externals: ['react/jsx-runtime', 'react/jsx-dev-runtime'], + plugins: [ + { + apply(compiler) { + compiler.hooks.compile.tap('existing-hook', () => + compilations.push(compiler.name), + ) + }, + }, + ], + resolveLoader: { + alias: { + '@devup-ui/next-plugin/loader': sourceLoader, + '@devup-ui/next-plugin/css-loader': cssLoader, + }, + }, + module: { + rules: [ + { test: /\.js$/, use: [join(root, 'downstream.cjs')] }, + { + test: /\.mdx$/, + use: [ + join(root, 'downstream.cjs'), + { + loader: landingRequire.resolve('@next/mdx/mdx-js-loader'), + options: { jsx: false }, + }, + ], + }, + { test: /\.css$/, use: [join(root, 'native-css.cjs')] }, + ], + }, + } + const server = webpack( + compose({ + ...base, + name: 'server', + output: { path: join(root, 'server') }, + }), + ) + const client = webpack( + compose({ + ...base, + name: 'client', + output: { path: join(root, 'client') }, + }), + ) + try { + const builds = Promise.all([run(server), run(client)]) + await bothEntered.promise + assert.equal(compilations.length, 0) + assert.equal(modules.length, 0) + assert.equal(css.length, 0) + assert.equal(extractions.length, 0) + await handle.ready + binding.resolve() + await new Promise(setImmediate) + assert.equal(compilations.length, 0) + assert.equal(modules.length, 0) + assert.equal(css.length, 0) + const wasm = createEngine() + const engine = { + ...wasm, + codeExtractWithoutSourceMap(filename, ...args) { + extractions.push(filename) + return wasm.codeExtractWithoutSourceMap(filename, ...args) + }, + } + preparation.resolve({ + wasm: engine, + package: '@devup-ui/react', + cssDir: context.cssDir, + singleCss: true, + importAliases: {}, + canonicalMap: {}, + projectRoot: root, + identity: session.identity, + coordinatorPortFile: session.endpointFile, + expectedBaseFiles: ['entry.js', 'page.mdx'], + sourceMap: false, + createEngine, + }) + await builds + assert.deepEqual([...extractions].sort(), ['entry.js', 'page.mdx']) + assert.equal(modules.length, 4) + assert.ok(modules.every(({ source }) => !source.includes('css({'))) + assert.ok( + modules + .filter(({ path }) => path.endsWith('.mdx')) + .every(({ source }) => source.includes('MDX compiler proof')), + ) + assert.equal(css.length, 2) + assert.ok( + css.every( + (source) => + source.includes('color:red') && source.includes('color:blue'), + ), + ) + await close(server) + await run(client) + assert.deepEqual(callbacks, ['server', 'client', 'client']) + assert.deepEqual(compilations, callbacks) + const cause = new Error( + `${join(root, 'page.mdx')}:9:4: configured preparation failed`, + ) + const failedCompose = runtime.createWebpackCoordinatorBridge({ + context, + session, + handle, + theme: {}, + themeFiles: [], + prepare: async () => { + throw cause + }, + }) + const failed = webpack( + failedCompose({ ...base, output: { path: join(root, 'failed') } }), + ) + const count = modules.length + try { + await assert.rejects(run(failed), (error) => error === cause) + assert.equal(modules.length, count) + } finally { + await close(failed) + } + process.stdout.write( + JSON.stringify({ + controlledPending: true, + compiledMdx: true, + nativeCss: css.length, + engineCount, + extractions, + callbacks, + originalLocatedFailure: true, + childCloseRetainsOwner: true, + }), + ) + } finally { + await close(client) + await handle.drain() + } +} + +capture() + .finally(() => { + rmSync(root, { recursive: true, force: true }) + delete global.devupBridgeCapture + }) + .catch((error) => { + process.stderr.write(String(error.stack)) + process.exitCode = 1 + }) diff --git a/packages/next-plugin/src/__tests__/webpack-bridge-compiler.cjs b/packages/next-plugin/src/__tests__/webpack-bridge-compiler.cjs new file mode 100644 index 000000000..ff036bd59 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-bridge-compiler.cjs @@ -0,0 +1,18 @@ +function run(compiler) { + return new Promise((resolve, reject) => + compiler.run((error, stats) => { + if (error) reject(error) + else if (stats.hasErrors()) + reject(new Error(stats.toString({ all: false, errors: true }))) + else resolve(stats) + }), + ) +} + +function close(compiler) { + return new Promise((resolve, reject) => + compiler.close((error) => (error ? reject(error) : resolve())), + ) +} + +module.exports = { run, close } diff --git a/packages/next-plugin/src/__tests__/webpack-bridge-compiler.test.ts b/packages/next-plugin/src/__tests__/webpack-bridge-compiler.test.ts new file mode 100644 index 000000000..43f73e6dc --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-bridge-compiler.test.ts @@ -0,0 +1,63 @@ +import { createRequire } from 'node:module' + +import { expect, it } from 'bun:test' +import type { Compiler, Stats } from 'webpack' + +import { compiler, webpack } from './webpack-bridge-fixture' + +const adapter: { + readonly run: (compiler: Pick) => Promise + readonly close: (compiler: Pick) => Promise +} = createRequire(import.meta.url)('./webpack-bridge-compiler.cjs') + +it('returns real stats when the compiler completes without errors', async () => { + // Given + const instance = compiler({}) + const stats = new webpack.Stats( + new webpack.Compilation(instance, instance.newCompilationParams()), + ) + // When + const result = await adapter.run({ run: (callback) => callback(null, stats) }) + // Then + expect(result).toBe(stats) +}) + +it('retains the located cause when the compiler callback fails', async () => { + // Given + const cause = new Error('/app/page.mdx:2:1: failed') + // When / Then + await expect( + adapter.run({ run: (callback) => callback(cause) }), + ).rejects.toBe(cause) +}) + +it('rejects when webpack completes with module errors', async () => { + // Given + const instance = compiler({}) + const compilation = new webpack.Compilation( + instance, + instance.newCompilationParams(), + ) + compilation.errors.push(new webpack.WebpackError('module failure')) + const stats = new webpack.Stats(compilation) + // When / Then + await expect( + adapter.run({ run: (callback) => callback(null, stats) }), + ).rejects.toBeInstanceOf(Error) +}) + +it('settles when compiler close succeeds', async () => { + // Given / When + const result = await adapter.close({ close: (callback) => callback(null) }) + // Then + expect(result).toBeUndefined() +}) + +it('retains the cause when compiler close fails', async () => { + // Given + const cause = new Error('close failed') + // When / Then + await expect( + adapter.close({ close: (callback) => callback(cause) }), + ).rejects.toBe(cause) +}) diff --git a/packages/next-plugin/src/__tests__/webpack-bridge-fixture.ts b/packages/next-plugin/src/__tests__/webpack-bridge-fixture.ts new file mode 100644 index 000000000..0b1262437 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-bridge-fixture.ts @@ -0,0 +1,50 @@ +import { createRequire } from 'node:module' + +import { beforeAll, mock } from 'bun:test' +import type { Compiler, Configuration } from 'webpack' + +import type { PreparedCoordinatorHandle } from '../coordinator-options' +import { createAppContext, createSession } from '../session' +import { + createWebpackCoordinatorBridge, + type WebpackCoordinatorIntegration, +} from '../webpack-coordinator' + +const require = createRequire(import.meta.url) +export const webpack: typeof import('webpack') = createRequire( + require.resolve('@types/webpack/package.json'), +)('webpack') + +beforeAll(async () => { + const instance = compiler({}) + await new Promise((resolve, reject) => + instance.close((error) => (error ? reject(error) : resolve())), + ) +}, 30000) + +export function fixture( + overrides: Partial = {}, +) { + const context = createAppContext({}, { include: ['@included/ui'] }) + const session = createSession(context) + const handle: PreparedCoordinatorHandle = { + ready: Promise.resolve(), + prepared: Promise.resolve(), + close: mock(), + drain: mock().mockResolvedValue(undefined), + } + const integration: WebpackCoordinatorIntegration = { + context, + session, + handle, + themeFiles: [context.devupFile], + theme: { colors: { default: { primary: 'red' } } }, + prepare: mock().mockResolvedValue(undefined), + ...overrides, + } + return { integration, compose: createWebpackCoordinatorBridge(integration) } +} + +export function compiler(config: Configuration): Compiler { + return webpack({ mode: 'development', ...config }) +} diff --git a/packages/next-plugin/src/__tests__/webpack-coordinator-native.test.ts b/packages/next-plugin/src/__tests__/webpack-coordinator-native.test.ts new file mode 100644 index 000000000..a74d84c24 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-coordinator-native.test.ts @@ -0,0 +1,87 @@ +import { execFile } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' + +import { expect, it } from 'bun:test' + +const execute = promisify(execFile) + +it('blocks real webpack modules while preparation is pending and shares compiled MDX/source/CSS work', async () => { + // Given + const repo = resolve(import.meta.dir, '../../../..') + const source = resolve(import.meta.dir, '..') + const artifacts = mkdtempSync(join(tmpdir(), 'devup-webpack-proof-')) + const entry = join(artifacts, 'entry.ts') + writeFileSync( + entry, + [ + `export {createWebpackCoordinatorBridge} from ${JSON.stringify(join(source, 'webpack-coordinator.ts'))}`, + `export {startCoordinator} from ${JSON.stringify(join(source, 'coordinator.ts'))}`, + `export {createAppContext,createSession} from ${JSON.stringify(join(source, 'session.ts'))}`, + `export {createWasm} from ${JSON.stringify(join(source, 'wasm.ts'))}`, + ].join('\n'), + ) + const runtime = join(artifacts, 'runtime.cjs') + const loader = join(artifacts, 'loader.cjs') + const css = join(artifacts, 'css.cjs') + try { + for (const [input, output] of [ + [entry, runtime], + [join(source, 'loader.ts'), loader], + [join(source, 'css-loader.ts'), css], + ] as const) { + await execute( + process.execPath, + [ + 'build', + '--target', + 'node', + '--packages', + 'external', + '--format', + 'cjs', + input, + '--outfile', + output, + ], + { cwd: repo, timeout: 30000 }, + ) + } + // When + const { stdout } = await execute( + 'node', + [ + join(import.meta.dir, 'webpack-bridge-capture.cjs'), + repo, + artifacts, + runtime, + loader, + css, + ], + { + cwd: repo, + timeout: 30000, + env: { + ...process.env, + NODE_PATH: join(repo, 'packages/next-plugin/node_modules'), + }, + encoding: 'utf8', + }, + ) + // Then + expect(JSON.parse(stdout)).toEqual({ + controlledPending: true, + compiledMdx: true, + nativeCss: 3, + engineCount: 1, + extractions: ['entry.js', 'page.mdx'], + callbacks: ['server', 'client', 'client'], + originalLocatedFailure: true, + childCloseRetainsOwner: true, + }) + } finally { + rmSync(artifacts, { recursive: true, force: true }) + } +}, 60000) diff --git a/packages/next-plugin/src/__tests__/webpack-coordinator-readiness.test.ts b/packages/next-plugin/src/__tests__/webpack-coordinator-readiness.test.ts new file mode 100644 index 000000000..44c958ac5 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-coordinator-readiness.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from 'bun:test' + +import { compiler, fixture } from './webpack-bridge-fixture' + +it.each(['ready', 'prepared'] as const)( + 'retains the original located failure when coordinator %s rejects', + async (gate) => { + // Given + const cause = new Error('/app/page.mdx:3:8: preparation failed') + const pending = Promise.withResolvers() + const { integration } = fixture() + const { compose } = fixture({ + handle: { ...integration.handle, [gate]: pending.promise }, + }) + const instance = compiler(compose({})) + // When + const work = instance.hooks.beforeCompile.promise( + instance.newCompilationParams(), + ) + pending.reject(cause) + // Then + await expect(work).rejects.toBe(cause) + }, +) diff --git a/packages/next-plugin/src/__tests__/webpack-coordinator-rules.test.ts b/packages/next-plugin/src/__tests__/webpack-coordinator-rules.test.ts new file mode 100644 index 000000000..efd7a5339 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-coordinator-rules.test.ts @@ -0,0 +1,64 @@ +import { expect, it } from 'bun:test' +import type { Configuration } from 'webpack' + +import { composeWebpackMdxRules } from '../webpack-coordinator-rules' + +const extraction = { + loader: '@devup-ui/next-plugin/loader', + options: { token: 'same' }, +} +const mdx = { loader: '@next/mdx/mdx-js-loader', options: { jsx: false } } + +it('relocates duplicate extractors while retaining compiler options and unrelated functions', () => { + // Given + const customLoader = () => 'custom-loader' + const config: Configuration = { + resolve: { + alias: [ + { name: 'provider', alias: '/provider.js', onlyModule: true }, + { name: 'other', alias: false }, + ], + }, + module: { + rules: [ + null, + undefined, + '', + 0, + false, + { + test: /\.mdx$/, + use: [customLoader, extraction.loader, mdx, extraction, 'raw-loader'], + }, + { loader: 'other' }, + ], + }, + } + // When + const result = composeWebpackMdxRules(config, extraction) + // Then + expect(result.rules[5]).toEqual({ + test: /\.mdx$/, + use: [customLoader, extraction, mdx, 'raw-loader'], + }) + expect(result.pipelines[0]?.aliases).toEqual({ + provider$: '/provider.js', + other: false, + }) + expect(config.module?.rules?.[5]).toEqual({ + test: /\.mdx$/, + use: [customLoader, extraction.loader, mdx, extraction, 'raw-loader'], + }) +}) + +it('leaves malformed required chains for the lead preparation diagnostic', () => { + // Given + const rules = [{ use: [mdx, null] }, { use: [mdx, 'next-swc-loader'] }] + // When + const result = composeWebpackMdxRules({ module: { rules } }, extraction) + // Then + expect(result.rules).toEqual(rules) + expect( + result.pipelines.every((pipeline) => pipeline.issue !== undefined), + ).toBe(true) +}) diff --git a/packages/next-plugin/src/__tests__/webpack-coordinator.test.ts b/packages/next-plugin/src/__tests__/webpack-coordinator.test.ts new file mode 100644 index 000000000..d07a9aa26 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-coordinator.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'bun:test' +import type { Configuration } from 'webpack' + +import { createTurboRules } from '../turbo-rules' +import { compiler, fixture } from './webpack-bridge-fixture' + +describe('Next webpack coordinator bridge', () => { + it('preserves effective callback results and conditions when composing loaders', () => { + // Given + const { compose, integration } = fixture() + const options = { jsx: true, remarkPlugins: [() => {}] } + const unrelated = { test: /\.png$/, type: 'asset/resource' } + const effective: Configuration = { + name: 'returned-by-wrapper', + resolve: { alias: { provider: '/app/provider.js' } }, + module: { + parser: { javascript: { dynamicImportMode: 'lazy' } }, + rules: [ + false, + '...', + { + issuer: /page/, + rules: [ + { + oneOf: [ + unrelated, + { + test: /\.mdx$/, + include: '/app', + use: [ + 'next-flight-loader', + 'next-swc-loader', + { + loader: '@next/mdx/mdx-js-loader', + options, + }, + ], + }, + ], + }, + ], + }, + ], + }, + plugins: [{ apply() {} }], + } + // When + const result = compose(effective) + // Then + expect(result.name).toBe(effective.name) + expect(result.resolve).toBe(effective.resolve) + expect(result.module?.parser).toBe(effective.module?.parser) + expect(result.plugins?.[0]).toBe(effective.plugins?.[0]) + expect(result.module?.rules?.slice(0, 2)).toEqual([false, '...']) + expect(result.module?.rules?.[2]).toEqual({ + issuer: /page/, + rules: [ + { + oneOf: [ + unrelated, + { + test: /\.mdx$/, + include: '/app', + use: [ + 'next-flight-loader', + 'next-swc-loader', + { + loader: '@devup-ui/next-plugin/loader', + options: expect.objectContaining({ + coordinatorPortFile: integration.session.endpointFile, + coordinatorIdentity: integration.session.identity, + sourceType: 'compiled-mdx', + }), + }, + { loader: '@next/mdx/mdx-js-loader', options }, + ], + }, + ], + }, + ], + }) + expect(effective.module?.rules).toHaveLength(3) + }) + + it('uses captured Turbo options for ordinary sources and generated CSS', () => { + // Given + const { compose, integration } = fixture() + const turbo = createTurboRules(integration) + // When + const rules = compose({}).module?.rules + // Then + expect(rules).toEqual([ + { + test: expect.any(RegExp), + exclude: expect.any(RegExp), + enforce: 'pre', + use: [ + { + loader: '@devup-ui/next-plugin/loader', + options: expect.objectContaining({ + projectRoot: integration.context.root, + requestTimeoutMs: 120000, + coordinatorPortFile: integration.session.endpointFile, + importAliases: integration.context.importAliases, + }), + }, + ], + }, + { + test: /\.css$/, + include: integration.context.cssDir, + enforce: 'pre', + use: [ + { + loader: '@devup-ui/next-plugin/css-loader', + options: expect.objectContaining({ + coordinatorPortFile: integration.session.endpointFile, + }), + }, + ], + }, + ]) + expect(Object.values(turbo)).toHaveLength(2) + expect(rules?.[0]).toMatchObject({ + use: [ + { + options: expect.not.objectContaining({ + sourceType: expect.anything(), + }), + }, + ], + }) + }) + + it('awaits binding plus both readiness gates before compilation work', async () => { + // Given + const binding = Promise.withResolvers() + const transport = Promise.withResolvers() + const prepared = Promise.withResolvers() + const entered = Promise.withResolvers() + const { compose, integration } = fixture({ + handle: { + ready: transport.promise, + prepared: prepared.promise, + close() {}, + async drain() {}, + }, + async prepare(request) { + entered.resolve() + expect(request.compiler.context).toBe(integration.context.root) + expect(request.context).toBe(integration.context) + expect(request.session).toBe(integration.session) + expect(request.handle).toBe(integration.handle) + expect(request.params.normalModuleFactory).toBeDefined() + await binding.promise + }, + }) + const instance = compiler(compose({ context: integration.context.root })) + let completed = false + // When + const work = instance.hooks.beforeCompile + .promise(instance.newCompilationParams()) + .then(() => { + completed = true + }) + await entered.promise + expect(completed).toBe(false) + binding.resolve() + prepared.resolve() + await Promise.resolve() + expect(completed).toBe(false) + transport.resolve() + await work + // Then + expect(completed).toBe(true) + }) + + it('retains the original located failure when preparation rejects', async () => { + // Given + const cause = new Error('/app/page.mdx:7:3: required compilation failed') + const { compose } = fixture({ + prepare: async () => { + throw cause + }, + }) + const instance = compiler(compose({})) + // When / Then + await expect( + instance.hooks.beforeCompile.promise(instance.newCompilationParams()), + ).rejects.toBe(cause) + }) + + it('refreshes each server and client build without releasing their shared owner', async () => { + // Given + const requests: string[] = [] + const { compose, integration } = fixture({ + prepare: async ({ compiler, handle }) => { + expect(handle).toBe(integration.handle) + requests.push(compiler.name ?? '') + }, + }) + const server = compiler(compose({ name: 'server' })) + const client = compiler(compose({ name: 'client' })) + // When + await server.hooks.beforeCompile.promise(server.newCompilationParams()) + await client.hooks.beforeCompile.promise(client.newCompilationParams()) + await server.hooks.beforeCompile.promise(server.newCompilationParams()) + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) + await client.hooks.beforeCompile.promise(client.newCompilationParams()) + // Then + expect(requests).toEqual(['server', 'client', 'server', 'client']) + expect(integration.handle.close).not.toHaveBeenCalled() + expect(integration.handle.drain).not.toHaveBeenCalled() + }) +}) diff --git a/packages/next-plugin/src/__tests__/webpack-resource-boundaries.test.ts b/packages/next-plugin/src/__tests__/webpack-resource-boundaries.test.ts new file mode 100644 index 000000000..1396630bc --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-boundaries.test.ts @@ -0,0 +1,251 @@ +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'bun:test' +import type { Configuration } from 'webpack' + +import { + compareMdxBindingValues, + snapshotMdxBindingValue, +} from '../mdx-binding-value' +import { + nativeLoaders, + nativeRuleSet, + resourceFacts, +} from '../webpack-resource-native' +import { + extraction, + mdxRule, + source, + withSelector, +} from './webpack-resource-fixture' + +describe('native resource boundary proofs', () => { + it('admits an ordinary disk-first boundary only after async qualification', async () => { + // Given + const ordinary = source.replace(/\.mdx$/, '.tsx') + const config = { + module: { + rules: [{ test: /\.tsx$/, enforce: 'pre' as const, use: [extraction] }], + }, + } + // When + const result = await withSelector(config, async (selector) => ({ + initial: selector.ordinaryEligibility(ordinary), + qualified: await selector.qualifyOrdinary( + ordinary, + new AbortController().signal, + ), + delivered: selector.ordinaryEligibility(ordinary), + })) + // Then + expect(result.initial.kind).toBe('native-required') + expect(result.qualified.kind).toBe('disk-first') + expect(result.delivered).toBe(result.qualified) + }) + + it('keeps ordinary contextual input pending without issuing an attempt proof', async () => { + // Given + const ordinary = source.replace(/\.mdx$/, '.tsx') + const config = { + module: { + rules: [ + { + test: /\.tsx$/, + issuer: /hidden/, + enforce: 'pre' as const, + use: [extraction], + }, + ], + }, + } + // When + const result = await withSelector(config, (selector) => + selector.qualifyOrdinary(ordinary, new AbortController().signal), + ) + // Then + expect(result).toMatchObject({ + kind: 'native-required', + unknownFact: 'issuer', + }) + expect('proof' in result).toBe(false) + }) + + it('returns no pipeline when native resource selection has no MDX segment', async () => { + // Given / When + const result = await withSelector( + { module: { rules: [mdxRule()] } }, + async (selector) => + (await selector.selectPipeline( + source.replace(/\.mdx$/, '.tsx'), + new AbortController().signal, + )) === undefined, + ) + // Then + expect(result).toBe(true) + }) + + it('blocks opaque use callbacks without executing them with invented origin facts', async () => { + // Given + let calls = 0 + const config: Configuration = { + module: { + rules: [ + { + test: /\.mdx$/, + use: function loaderFactory() { + calls += 1 + return [] + }, + }, + mdxRule(), + ], + }, + } + // When / Then + await expect( + withSelector(config, (selector) => + selector.selectPipeline(source, new AbortController().signal), + ), + ).rejects.toMatchObject({ unknownFact: 'use(context)' }) + expect(calls).toBe(0) + }) + + it('blocks opaque resource predicates without treating them as resource-only proof', async () => { + // Given + const calls: string[] = [] + const config = { + module: { + rules: [ + { + ...mdxRule(), + test(resource: string) { + calls.push(resource) + return true + }, + }, + ], + }, + } + // When / Then + await expect( + withSelector(config, (selector) => + selector.selectPipeline(source, new AbortController().signal), + ), + ).rejects.toMatchObject({ unknownFact: 'test' }) + expect(calls).toEqual(['']) + }) + + it('rejects downstream pitches even though their normal transforms run after Devup', async () => { + // Given + const pitch = fileURLToPath( + new URL('./webpack-resource-pitch.cjs', import.meta.url), + ) + const config = { + module: { rules: [{ test: /\.mdx$/, use: [pitch] }, mdxRule()] }, + } + // When / Then + await expect( + withSelector(config, (selector) => + selector.selectPipeline(source, new AbortController().signal), + ), + ).rejects.toMatchObject({ unknownFact: 'pitch/source substitution' }) + }) + + it('retains original option comparison shells before later tuple mutation', async () => { + // Given + const options = { remarkPlugins: [['original-package', { visits: 0 }]] } + // When + const result = await withSelector( + { module: { rules: [mdxRule(options)] } }, + async (selector) => { + const selection = await selector.selectPipeline( + source, + new AbortController().signal, + ) + if (!selection) throw new TypeError('missing selection') + const before = snapshotMdxBindingValue(selection.loaders) + options.remarkPlugins[0]?.splice(0, 1, 'wrapper-artifact') + return { + original: compareMdxBindingValues(selection.identity, before), + changed: compareMdxBindingValues( + selection.identity, + snapshotMdxBindingValue(selection.loaders), + ), + } + }, + ) + // Then + expect(result.original).toBeUndefined() + expect(result.changed?.path).toBe('loaders[0].options.remarkPlugins[0][0]') + }) + + it('preserves each selected original option value when resources share a loader module', async () => { + // Given + const first = { jsx: true } + const second = { jsx: false } + const config = { + module: { + rules: [ + { ...mdxRule(first), test: /first\.mdx$/ }, + { ...mdxRule(second), test: /second\.mdx$/ }, + ], + }, + } + // When + const result = await withSelector(config, async (selector) => { + const a = await selector.selectPipeline( + source.replace('page.mdx', 'first.mdx'), + new AbortController().signal, + ) + const b = await selector.selectPipeline( + source.replace('page.mdx', 'second.mdx'), + new AbortController().signal, + ) + return { + a: a?.loaders[0]?.options, + b: b?.loaders[0]?.options, + watches: selector.watchInputs(), + } + }) + // Then + expect(result.a).toBe(first) + expect(result.b).toBe(second) + expect(result.watches.length).toBeGreaterThan(0) + }) + + it('uses native RuleSet selection when oneOf resource branches compete', async () => { + // Given + const config = { + module: { + rules: [ + { + oneOf: [ + { ...mdxRule({ jsx: false }), test: /never\.mdx$/ }, + mdxRule({ jsx: true }), + ], + }, + ], + }, + } + // When + const result = await withSelector( + config, + async (selector, _compiler, binding) => { + const selected = await selector.selectPipeline( + source, + new AbortController().signal, + ) + const actual = nativeLoaders( + nativeRuleSet(binding.params.normalModuleFactory).exec( + resourceFacts(binding, source), + ), + 'use', + ) + return { selected, actual } + }, + ) + // Then + expect(result.selected?.loaders[0]?.options).toEqual({ jsx: true }) + expect(result.actual.at(-1)?.options).toEqual({ jsx: true }) + }) +}) diff --git a/packages/next-plugin/src/__tests__/webpack-resource-bytes.test.ts b/packages/next-plugin/src/__tests__/webpack-resource-bytes.test.ts new file mode 100644 index 000000000..0ad12fd5f --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-bytes.test.ts @@ -0,0 +1,178 @@ +import { + mkdtempSync, + readFile, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'bun:test' + +import { isMdxRecord } from '../mdx-pipeline' +import { + compileMdx, + createMdxDeadline, + createMdxOptionsInstance, +} from '../mdx-prepare' +import { isRunLoaders } from '../mdx-prepare-runner' +import { createWasm, extractWithModuleResolver } from '../wasm' +import { mdxRule, withSelector } from './webpack-resource-fixture' + +const workspace = fileURLToPath(new URL('../../../../', import.meta.url)) +const installed = createRequire(join(workspace, 'apps/landing/package.json')) +const base = tmpdir() + +describe('selected native MDX input bytes', () => { + for (const { extension, sourceMap } of [ + { extension: '.md', sourceMap: false }, + { extension: '.md', sourceMap: true }, + { extension: '.mdx', sourceMap: false }, + { extension: '.mdx', sourceMap: true }, + { extension: '.mdown', sourceMap: false }, + { extension: '.mdown', sourceMap: true }, + ]) { + it(`matches the unsubstituted installed compiler for ${extension} when maps are ${sourceMap}`, async () => { + // Given + const root = mkdtempSync(join(base, 'w21e-resource-bytes-')) + const filename = join(root, `page${extension}`) + symlinkSync( + join(workspace, 'apps/landing/node_modules'), + join(root, 'node_modules'), + 'junction', + ) + const options = extension === '.mdown' ? { jsx: true, format: 'mdx' } : {} + writeFileSync( + filename, + `import { Box } from '@devup-ui/react'\n\n# Native bytes\n\n`, + ) + try { + const result = await withSelector( + { + context: root, + devtool: sourceMap ? 'source-map' : false, + module: { + rules: [{ ...mdxRule(options), test: /\.(md|mdx|mdown)$/ }], + }, + }, + async (selector, compiler) => { + const signal = new AbortController().signal + const selection = await selector.selectPipeline(filename, signal) + if (!selection) throw new TypeError('missing real selection') + const runner: unknown = installed( + 'next/dist/compiled/loader-runner/LoaderRunner.js', + ) + if (!isMdxRecord(runner) || !isRunLoaders(runner.runLoaders)) + throw new TypeError('native runner unavailable') + const runLoaders = runner.runLoaders + const native = await new Promise((resolve, reject) => + runLoaders( + { + resource: filename, + loaders: selection.pipeline.loaders, + readResource: readFile, + context: { + rootContext: compiler.context, + _compiler: compiler, + mode: selection.context.mode, + sourceMap: selection.context.sourceMap, + getOptions(this: { readonly query: unknown }) { + return this.query + }, + }, + }, + (error, output) => (error ? reject(error) : resolve(output)), + ), + ) + if (!isMdxRecord(native) || !Array.isArray(native.result)) + throw new TypeError('missing native bytes') + const nativeSource: unknown = native.result[0] + if ( + typeof nativeSource !== 'string' && + !Buffer.isBuffer(nativeSource) + ) + throw new TypeError('invalid native bytes') + // When + const prepared = await compileMdx({ + root, + filename, + pipeline: selection.pipeline, + context: selection.context, + signal, + deadline: createMdxDeadline(), + optionsInstance: createMdxOptionsInstance(), + }) + const sources = + extension === '.mdown' + ? [ + prepared.source, + ( + await compileMdx({ + root, + filename, + pipeline: selection.pipeline, + context: { + ...selection.context, + sourceMap: !sourceMap, + }, + signal, + deadline: createMdxDeadline(), + optionsInstance: createMdxOptionsInstance(), + }) + ).source, + ] + : [] + const css = sources.flatMap((code) => + [false, true].map((maps) => { + const engine = createWasm(root) + const output = extractWithModuleResolver(engine, maps, [ + filename, + code, + '@devup-ui/react', + './df', + true, + false, + false, + {}, + 'compiled-mdx', + ]) + try { + expect(output.code).not.toContain(' { + it('returns exact native bytes with the supplied realm signal', async () => { + // Given + const root = mkdtempSync(join(tmpdir(), 'w21f-resource-bytes-')) + const filename = join(root, 'loader.js') + const bytes = Buffer.from('export default "original"') + writeFileSync(filename, bytes) + try { + // When + const result = await readWebpackLoaderBytes( + filename, + new AbortController().signal, + ) + // Then + expect(result).toEqual(bytes) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('preserves pre-abort reason instead of attempting to open a missing file', async () => { + // Given + const controller = new AbortController() + const reason = new Error('cancelled before open') + controller.abort(reason) + // When / Then + await expect( + readWebpackLoaderBytes('missing-before-abort', controller.signal), + ).rejects.toBe(reason) + }) + + it('destroys an in-flight native read and preserves the caller abort reason', async () => { + // Given + const root = mkdtempSync(join(tmpdir(), 'w21f-resource-abort-')) + const filename = join(root, 'loader.js') + writeFileSync(filename, Buffer.alloc(1024 * 1024, 65)) + const controller = new AbortController() + const reason = new Error('cancelled during native read') + try { + // When + const result = readWebpackLoaderBytes(filename, controller.signal) + controller.abort(reason) + // Then + await expect(result).rejects.toBe(reason) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('propagates a native file error without converting it to cancellation', async () => { + // Given + const root = mkdtempSync(join(tmpdir(), 'w21f-resource-missing-')) + try { + // When / Then + await expect( + readWebpackLoaderBytes( + join(root, 'missing.js'), + new AbortController().signal, + ), + ).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('retries loader facts after the first caller cancels native resolution', async () => { + // Given + const result = await withSelector( + { module: { rules: [mdxRule()] } }, + async (_selector, compiler, binding) => { + const facts = createWebpackLoaderFacts(binding, 'next.config.mjs') + const controller = new AbortController() + const reason = new Error('cancelled native resolver result') + compiler.resolverFactory + .get('loader') + .hooks.result.tap('W21fCancelOnce', () => { + controller.abort(reason) + }) + await expect( + facts.resolveLoader({ loader: mdxLoader }, controller.signal), + ).rejects.toBe(reason) + // When + return facts.resolveLoader( + { loader: mdxLoader }, + new AbortController().signal, + ) + }, + ) + // Then + expect(result.facts.resolvedPath).toBe(mdxLoader) + }) + + it('does not publish ordinary disk-first eligibility when qualification is cancelled', async () => { + // Given + const filename = source.replace(/\.mdx$/, '.tsx') + const config = { + module: { + rules: [{ test: /\.tsx$/, enforce: 'pre' as const, use: [extraction] }], + }, + } + const result = await withSelector(config, async (selector) => { + const controller = new AbortController() + const reason = new Error('cancelled ordinary qualification') + // When + const pending = selector.qualifyOrdinary(filename, controller.signal) + controller.abort(reason) + // Then + await expect(pending).rejects.toBe(reason) + return selector.ordinaryEligibility(filename) + }) + expect(result.kind).toBe('native-required') + }) +}) diff --git a/packages/next-plugin/src/__tests__/webpack-resource-control.test.ts b/packages/next-plugin/src/__tests__/webpack-resource-control.test.ts new file mode 100644 index 000000000..c9ca47afa --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-control.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'bun:test' + +import { + extraction, + mdxRule, + source, + withSelector, +} from './webpack-resource-fixture' + +describe('native oneOf control dependence', () => { + it('rejects an unknown earlier branch even when that branch has no loader effect', async () => { + // Given + const config = { + module: { + rules: [ + { + oneOf: [{ issuer: /special/, type: 'javascript/auto' }, mdxRule()], + }, + ], + }, + } + // When / Then + await expect( + withSelector(config, (selector) => + selector.selectPipeline(source, new AbortController().signal), + ), + ).rejects.toMatchObject({ + unknownFact: 'issuer', + rulePosition: 'module.rules[0].oneOf[0]', + }) + }) + + it('keeps an ordinary boundary native-required when an earlier branch can hide Devup', async () => { + // Given + const ordinary = source.replace(/\.mdx$/, '.tsx') + const config = { + module: { + rules: [ + { + oneOf: [ + { resourceQuery: /special/, type: 'javascript/auto' }, + { test: /\.tsx$/, enforce: 'pre' as const, use: [extraction] }, + ], + }, + ], + }, + } + // When + const result = await withSelector(config, (selector) => + selector.qualifyOrdinary(ordinary, new AbortController().signal), + ) + // Then + expect(result).toMatchObject({ + kind: 'native-required', + unknownFact: 'resourceQuery', + }) + }) + + it('checks actual native ordinary selection when a known earlier branch hides Devup', async () => { + // Given + const ordinary = source.replace(/\.mdx$/, '.tsx') + const config = { + module: { + rules: [ + { + oneOf: [ + { test: /\.tsx$/, type: 'javascript/auto' }, + { test: /\.tsx$/, enforce: 'pre' as const, use: [extraction] }, + ], + }, + ], + }, + } + // When + const result = await withSelector(config, (selector) => + selector.qualifyOrdinary(ordinary, new AbortController().signal), + ) + // Then + expect(result).toMatchObject({ + kind: 'native-required', + unknownFact: 'selected first-input boundary', + }) + }) +}) diff --git a/packages/next-plugin/src/__tests__/webpack-resource-defaults.test.ts b/packages/next-plugin/src/__tests__/webpack-resource-defaults.test.ts new file mode 100644 index 000000000..df89149f0 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-defaults.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'bun:test' + +import { mdxRule, source, withSelector } from './webpack-resource-fixture' + +describe('receiving native default-rule input boundaries', () => { + it('rejects an origin-dependent upstream loader from normalized defaultRules', async () => { + // Given + const config = { + module: { + defaultRules: [ + { + test: /\.mdx$/, + issuer: /hidden/, + enforce: 'pre' as const, + use: ['unresolved-default-input-loader'], + }, + ], + rules: [mdxRule()], + }, + } + // When / Then + await expect( + withSelector(config, (selector) => + selector.selectPipeline(source, new AbortController().signal), + ), + ).rejects.toMatchObject({ + rulePosition: 'module.defaultRules[0]', + unknownFact: 'issuer', + }) + }) +}) diff --git a/packages/next-plugin/src/__tests__/webpack-resource-downstream.cjs b/packages/next-plugin/src/__tests__/webpack-resource-downstream.cjs new file mode 100644 index 000000000..5784e5bd0 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-downstream.cjs @@ -0,0 +1,4 @@ +/** @param {string | Buffer} source */ +module.exports = function downstream(source) { + return source +} diff --git a/packages/next-plugin/src/__tests__/webpack-resource-errors.test.ts b/packages/next-plugin/src/__tests__/webpack-resource-errors.test.ts new file mode 100644 index 000000000..3456ad81c --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-errors.test.ts @@ -0,0 +1,162 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it } from 'bun:test' +import type { Configuration } from 'webpack' + +import { resolveWebpackLoader } from '../webpack-mdx-resolution' +import { nativeRuleSet } from '../webpack-resource-native' +import { collectResourceRules } from '../webpack-resource-rules' +import { + extraction, + mdxRule, + source, + withSelector, +} from './webpack-resource-fixture' + +describe('resource boundary error contracts', () => { + it('refuses an unavailable receiving RuleSet rather than substituting a local matcher', () => { + // Given / When / Then + expect(() => nativeRuleSet({})).toThrow(TypeError) + }) + + it('rejects nonboolean native loader source-map overrides', async () => { + // Given + const config: Configuration = { + loader: { sourceMap: 'unproved' }, + module: { rules: [mdxRule()] }, + } + // When / Then + await expect(withSelector(config, () => true)).rejects.toBeInstanceOf( + TypeError, + ) + }) + + it('keeps custom resolver callouts outside resource-only certification', async () => { + // Given + const config: Configuration = { + resolveLoader: { plugins: [{ apply() {} }] }, + module: { rules: [mdxRule()] }, + } + // When / Then + await expect( + withSelector(config, (selector) => + selector.selectPipeline(source, new AbortController().signal), + ), + ).rejects.toMatchObject({ + rulePosition: 'webpack.resolveLoader.plugins', + unknownFact: 'loader resolver context', + }) + }) + + it('requires native ordinary input when no Devup pre boundary exists', async () => { + // Given + const ordinary = source.replace(/\.mdx$/, '.tsx') + // When + const result = await withSelector({ module: { rules: [] } }, (selector) => + selector.qualifyOrdinary(ordinary, new AbortController().signal), + ) + // Then + expect(result).toMatchObject({ + kind: 'native-required', + unknownFact: 'first-input boundary', + }) + }) + + it('keeps an ordinary downstream use factory outside disk-first admission', async () => { + // Given + const ordinary = source.replace(/\.mdx$/, '.tsx') + let calls = 0 + const config: Configuration = { + module: { + rules: [ + { test: /\.tsx$/, enforce: 'pre', use: [extraction] }, + { + test: /\.tsx$/, + use: function downstreamFactory() { + calls += 1 + return [] + }, + }, + ], + }, + } + // When + const result = await withSelector(config, (selector) => + selector.qualifyOrdinary(ordinary, new AbortController().signal), + ) + // Then + expect(result.kind).toBe('native-required') + expect(calls).toBe(0) + }) + + it('collects shorthand loader options through the native resource envelope', async () => { + // Given + const options = { original: true } + // When + const result = await withSelector( + { + module: { + rules: [{ test: /\.tsx$/, loader: 'ordinary-loader', options }], + }, + }, + (_selector, _compiler, binding) => collectResourceRules(binding), + ) + // Then + expect(result.find((rule) => rule.key === '0')?.loaders).toEqual([ + { loader: 'ordinary-loader', options }, + ]) + }) + + it('refuses loader files without native installed package-version facts', async () => { + // Given + const root = mkdtempSync(join(tmpdir(), 'w21f-resource-version-')) + const loader = join(root, 'loader.cjs') + writeFileSync(loader, 'module.exports = value => value') + try { + // When / Then + await expect( + withSelector( + { context: root, module: { rules: [] } }, + (_selector, _compiler, binding) => + resolveWebpackLoader( + binding, + { loader }, + new AbortController().signal, + ), + ), + ).rejects.toBeInstanceOf(TypeError) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('refuses a qualified pipeline when the real receiving factory selects another chain', async () => { + // Given + const config = { + module: { + rules: [ + { oneOf: [{ test: /\.mdx$/, type: 'javascript/auto' }, mdxRule()] }, + ], + }, + } + // When / Then + await expect( + withSelector(config, (selector) => + selector.selectPipeline(source, new AbortController().signal), + ), + ).rejects.toMatchObject({ unknownFact: 'selected native chain' }) + }) + + it('rejects inline loader requests instead of presenting them as disk-only input', async () => { + // Given + const filename = `inline-loader!${source}` + // When / Then + await expect( + withSelector({ module: { rules: [mdxRule()] } }, (selector) => + selector.selectPipeline(filename, new AbortController().signal), + ), + ).rejects.toMatchObject({ unknownFact: 'plain disk resource' }) + }) +}) diff --git a/packages/next-plugin/src/__tests__/webpack-resource-fixture.ts b/packages/next-plugin/src/__tests__/webpack-resource-fixture.ts new file mode 100644 index 000000000..1b64b254d --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-fixture.ts @@ -0,0 +1,74 @@ +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { Compiler, Configuration, RuleSetRule } from 'webpack' + +import { composeWebpackMdxRules } from '../webpack-coordinator-rules' +import type { WebpackResourceBinding } from '../webpack-resource-native' +import { createWebpackResourceSelector } from '../webpack-resource-selector' + +const workspace = fileURLToPath(new URL('../../../../', import.meta.url)) +const installed = createRequire(join(workspace, 'apps/landing/package.json')) +const bundle: { readonly webpack: typeof import('webpack') } = installed( + 'next/dist/compiled/webpack/webpack', +) +export const mdxLoader = installed.resolve('@next/mdx/mdx-js-loader') +export const extraction = '@devup-ui/next-plugin/loader' +export const source = join(workspace, 'w21e-resource-page.mdx') + +export function mdxRule( + options: Readonly> = {}, +): RuleSetRule { + return { test: /\.mdx$/, use: [{ loader: mdxLoader, options }] } +} + +export async function withSelector( + config: Configuration, + run: ( + selector: Awaited>, + compiler: Compiler, + binding: WebpackResourceBinding, + ) => Promise | T, +): Promise { + const composed = composeWebpackMdxRules(config, { loader: extraction }) + const effective: Configuration = { + ...config, + mode: config.mode ?? 'production', + optimization: { minimize: false }, + entry: {}, + module: { ...config.module, rules: composed.rules }, + } + const compiler = bundle.webpack(effective) + let result: T | undefined + compiler.hooks.beforeCompile.tapPromise( + 'W21eResourceFixture', + async (params) => { + const binding = { + compiler, + params, + effectiveConfiguration: compiler.options, + pipelines: composed.pipelines, + } + const selector = await createWebpackResourceSelector({ + binding, + configFile: join(workspace, 'next.config.mjs'), + owner: {}, + generation: {}, + }) + result = await run(selector, compiler, binding) + }, + ) + try { + await new Promise((resolve, reject) => + compiler.compile((error) => (error ? reject(error) : resolve())), + ) + if (result === undefined) + throw new TypeError('fixture callback must return a value') + return result + } finally { + await new Promise((resolve, reject) => + compiler.close((error) => (error ? reject(error) : resolve())), + ) + } +} diff --git a/packages/next-plugin/src/__tests__/webpack-resource-fixtures.test.ts b/packages/next-plugin/src/__tests__/webpack-resource-fixtures.test.ts new file mode 100644 index 000000000..b95a7ce9a --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-fixtures.test.ts @@ -0,0 +1,60 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { expect, it } from 'bun:test' + +import { isMdxRecord } from '../mdx-pipeline' +import { isRunLoaders, runMdxLoaders } from '../mdx-prepare-runner' + +const root = resolve(import.meta.dir, '../../../../apps/landing') +const installed = createRequire(join(root, 'package.json')) +const runner: unknown = installed( + 'next/dist/compiled/loader-runner/LoaderRunner.js', +) +if (!isMdxRecord(runner) || !isRunLoaders(runner.runLoaders)) + throw new TypeError('Installed loader runner unavailable') +const runLoaders = runner.runLoaders +const downstream = fileURLToPath( + new URL('./webpack-resource-downstream.cjs', import.meta.url), +) +const pitch = fileURLToPath( + new URL('./webpack-resource-pitch.cjs', import.meta.url), +) + +it('passes actual disk bytes through the downstream fixture when native normal execution runs', async () => { + // Given + const input = readFileSync(downstream, 'utf8') + // When + const output = await runMdxLoaders({ + root, + filename: downstream, + loaders: [{ loader: downstream }], + context: { owner: {}, generation: {} }, + steps: new Map(), + signal: new AbortController().signal, + timeoutMs: 1000, + runLoaders, + }) + // Then + expect(output.source).toBe(input) +}) + +it('substitutes before resource reads when the downstream pitch short-circuits native execution', async () => { + // Given: no resource exists, so normal disk input cannot produce this result. + const filename = join(import.meta.dir, 'unread-resource-for-pitch.js') + // When + const output = await runMdxLoaders({ + root, + filename, + loaders: [{ loader: downstream }, { loader: pitch }], + context: { owner: {}, generation: {} }, + steps: new Map(), + signal: new AbortController().signal, + timeoutMs: 1000, + runLoaders, + }) + // Then + expect(output.source).toBe('export default "replacement"') +}) diff --git a/packages/next-plugin/src/__tests__/webpack-resource-gates.test.ts b/packages/next-plugin/src/__tests__/webpack-resource-gates.test.ts new file mode 100644 index 000000000..4812fbd99 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-gates.test.ts @@ -0,0 +1,117 @@ +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'bun:test' +import type { Configuration } from 'webpack' + +import { + extraction, + mdxLoader, + mdxRule, + source, + withSelector, +} from './webpack-resource-fixture' + +describe('initial native preparation gates', () => { + it('keeps opaque external factories outside ordinary disk replay', async () => { + // Given + const ordinary = source.replace(/\.mdx$/, '.tsx') + const config: Configuration = { + externals: { opaque: 'opaque' }, + module: { + rules: [{ test: /\.tsx$/, enforce: 'pre', use: [extraction] }], + }, + } + // When + const result = await withSelector(config, (selector) => + selector.qualifyOrdinary(ordinary, new AbortController().signal), + ) + // Then + expect(result).toMatchObject({ + kind: 'native-required', + rulePosition: 'webpack.externals', + }) + }) + + it('accepts a nonmatching empty plugin slot and exact string alias values', async () => { + // Given + const config: Configuration = { + plugins: [false], + resolve: { alias: [{ name: 'provider', alias: 'first' }] }, + module: { rules: [mdxRule()] }, + } + // When + const result = await withSelector(config, (selector) => selector.aliases) + // Then + expect(result).toEqual([{ name: 'provider', alias: 'first' }]) + }) + + it('rejects a compiler whose native resolver substitutes an unrecognized compiler module', async () => { + // Given + const replacement = fileURLToPath( + new URL('./webpack-resource-downstream.cjs', import.meta.url), + ) + const config: Configuration = { + resolveLoader: { alias: { [mdxLoader]: replacement } }, + module: { rules: [mdxRule()] }, + } + // When / Then + await expect( + withSelector(config, (selector) => + selector.selectPipeline(source, new AbortController().signal), + ), + ).rejects.toMatchObject({ unknownFact: 'installed compiler semantics' }) + }) + + it('reuses a captured native pipeline across resources within the receiving factory', async () => { + // Given + const config = { module: { rules: [mdxRule({ jsx: true })] } } + // When + const result = await withSelector(config, async (selector) => { + const first = await selector.selectPipeline( + source, + new AbortController().signal, + ) + const second = await selector.selectPipeline( + source.replace('page.mdx', 'other.mdx'), + new AbortController().signal, + ) + return { first, second } + }) + // Then + expect(result.first?.pipeline).toBe(result.second?.pipeline) + expect(result.first?.identity).toBe(result.second?.identity) + }) + + it('admits configured custom MDX extensions with the original compiler options', async () => { + // Given + const filename = source.replace(/\.mdx$/, '.mdown') + const options = { jsx: true, format: 'mdx' } + const config = { + module: { rules: [{ ...mdxRule(options), test: /\.mdown$/ }] }, + } + // When + const selection = await withSelector(config, (selector) => + selector.selectPipeline(filename, new AbortController().signal), + ) + // Then + expect(selection?.loaders[0]?.options).toBe(options) + expect(selection?.pipeline.ruleKey).toBe('0') + expect(selection?.pipeline.loaders[0]?.loader).toBe(mdxLoader) + }) + + it('admits markdown through the original native MDX compiler without renaming its resource', async () => { + // Given + const filename = source.replace(/\.mdx$/, '.md') + const options = { jsx: true } + const config = { + module: { rules: [{ ...mdxRule(options), test: /\.md$/ }] }, + } + // When + const selection = await withSelector(config, (selector) => + selector.selectPipeline(filename, new AbortController().signal), + ) + // Then + expect(selection?.loaders[0]?.options).toBe(options) + expect(selection?.pipeline.ruleKey).toBe('0') + }) +}) diff --git a/packages/next-plugin/src/__tests__/webpack-resource-pitch.cjs b/packages/next-plugin/src/__tests__/webpack-resource-pitch.cjs new file mode 100644 index 000000000..a6f69fc2a --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-pitch.cjs @@ -0,0 +1,8 @@ +module.exports = Object.assign( + require('./webpack-resource-downstream.cjs').bind(null), + { + pitch() { + return 'export default "replacement"' + }, + }, +) diff --git a/packages/next-plugin/src/__tests__/webpack-resource-resolver.test.ts b/packages/next-plugin/src/__tests__/webpack-resource-resolver.test.ts new file mode 100644 index 000000000..919f671ed --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-resolver.test.ts @@ -0,0 +1,197 @@ +import { createModuleResolver } from '@devup-ui/plugin-utils' +import { describe, expect, it } from 'bun:test' +import type { Configuration } from 'webpack' + +import { webpackResourceResolver } from '../webpack-resource-delivery' +import { mdxLoader, mdxRule, withSelector } from './webpack-resource-fixture' + +describe('native resolver representation', () => { + it('resolves disabled aliases as ignored without manufacturing a module', async () => { + // Given + const config: Configuration = { + resolve: { alias: { provider: false } }, + module: { rules: [mdxRule()] }, + } + // When + const result = await withSelector(config, (selector, compiler) => + createModuleResolver({ cwd: compiler.context, alias: selector.aliases })( + 'provider', + 'page.tsx', + ), + ) + // Then + expect(result).toEqual({ ignored: true }) + }) + + it('preserves an exact ignored descriptor as false', async () => { + // Given + const config: Configuration = { + resolve: { + alias: [{ name: 'provider', onlyModule: true, alias: false }], + }, + module: { rules: [mdxRule()] }, + } + // When + const result = await withSelector(config, (selector, compiler) => ({ + aliases: selector.aliases, + resolution: createModuleResolver({ + cwd: compiler.context, + alias: selector.aliases, + })('provider', 'page.tsx'), + })) + // Then + expect(result).toEqual({ + aliases: [{ name: 'provider', onlyModule: true, alias: false }], + resolution: { ignored: true }, + }) + }) + + it.each([{ descriptor: true }, { descriptor: false }])( + 'retains injected shared mixed candidates without claiming native Factory acceptance %j', + async ({ descriptor }) => { + // Given + const candidates = ['missing-provider', false, mdxLoader] as const + const config: Configuration = { + module: { rules: [mdxRule()] }, + } + // When + const result = await withSelector(config, (_, compiler, binding) => { + const effectiveConfiguration = { + ...binding.effectiveConfiguration, + resolve: Object.defineProperty( + { ...binding.effectiveConfiguration.resolve }, + 'alias', + { + value: descriptor + ? [{ name: 'provider', onlyModule: true, alias: candidates }] + : { provider$: candidates }, + enumerable: true, + }, + ), + } + const settings = webpackResourceResolver({ + ...binding, + effectiveConfiguration, + }) + return { + aliases: settings.aliases, + resolution: createModuleResolver({ + cwd: compiler.context, + alias: settings.aliases, + })('provider', 'page.tsx'), + } + }) + // Then + expect(result).toEqual({ + aliases: descriptor + ? [{ name: 'provider', onlyModule: true, alias: candidates }] + : { provider$: candidates }, + resolution: { ignored: true }, + }) + }, + ) + + it('preserves unique alias descriptors and candidate order', async () => { + // Given + const config: Configuration = { + resolve: { + alias: [ + { name: 'provider', onlyModule: true, alias: ['first', 'second'] }, + ], + }, + module: { rules: [mdxRule()] }, + } + // When + const result = await withSelector(config, (selector) => selector.aliases) + // Then + expect(result).toEqual([ + { name: 'provider', onlyModule: true, alias: ['first', 'second'] }, + ]) + }) + + it('selects a reached false before a later duplicate descriptor', async () => { + // Given + const config: Configuration = { + resolve: { + alias: [ + { name: 'provider', alias: false }, + { name: 'provider', alias: 'second' }, + ], + }, + module: { rules: [mdxRule()] }, + } + // When + const result = await withSelector(config, (selector, compiler) => + createModuleResolver({ cwd: compiler.context, alias: selector.aliases })( + 'provider', + 'page.tsx', + ), + ) + // Then + expect(result).toEqual({ ignored: true }) + }) + + it('preserves string aliases without converting them into unordered candidates', async () => { + // Given + const config: Configuration = { + resolve: { alias: { provider: 'first' } }, + module: { rules: [mdxRule()] }, + } + // When + const result = await withSelector(config, (selector) => selector.aliases) + // Then + expect(result).toEqual({ provider: 'first' }) + }) + it('preserves unique aliases named like Object prototype properties', async () => { + // Given + const config: Configuration = { + resolve: { + alias: [ + { name: 'constructor', alias: 'first' }, + { name: '__proto__', alias: 'second' }, + ], + }, + module: { rules: [mdxRule()] }, + } + // When + const aliases = await withSelector(config, (selector) => selector.aliases) + // Then + expect(aliases).toEqual([ + { name: 'constructor', alias: 'first' }, + { name: '__proto__', alias: 'second' }, + ]) + }) + + it('detaches frozen descriptor shells and candidates without freezing caller state', async () => { + // Given + const candidates = [mdxLoader] + const descriptor = { name: 'provider', alias: candidates, onlyModule: true } + const alias = [descriptor] + // When + const result = await withSelector( + { resolve: { alias }, module: { rules: [mdxRule()] } }, + (selector, compiler) => { + candidates.splice(0, 1, 'missing') + descriptor.name = 'changed' + descriptor.onlyModule = false + alias.push({ name: 'provider', alias: ['later'], onlyModule: false }) + return { + resolution: createModuleResolver({ + cwd: compiler.context, + alias: selector.aliases, + })('provider', 'page.tsx'), + frozen: + Object.isFrozen(selector.aliases) && + Object.values(selector.aliases).every( + (entry) => Object.isFrozen(entry) && Object.isFrozen(entry.alias), + ), + } + }, + ) + // Then + expect(result.resolution?.path).toBe(mdxLoader) + expect(result.frozen).toBe(true) + expect(candidates).toEqual(['missing']) + expect(descriptor.name).toBe('changed') + }) +}) diff --git a/packages/next-plugin/src/__tests__/webpack-resource-selector.test.ts b/packages/next-plugin/src/__tests__/webpack-resource-selector.test.ts new file mode 100644 index 000000000..96d958d0f --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-selector.test.ts @@ -0,0 +1,174 @@ +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'bun:test' + +import { WebpackResourceError } from '../webpack-resource-error' +import { + extraction, + mdxRule, + source, + withSelector, +} from './webpack-resource-fixture' + +describe('native resource-invariant initial selection', () => { + it('retains the real receiving compiler when downstream layer conditions change', async () => { + // Given + const config = { + experiments: { layers: true }, + module: { + rules: [ + { + issuerLayer: 'rsc', + use: [ + fileURLToPath( + new URL('./webpack-resource-downstream.cjs', import.meta.url), + ), + ], + }, + { + issuer: /unobserved/, + resourceQuery: /unknown/, + dependency: 'url', + use: [], + }, + mdxRule({ jsx: true }), + ], + }, + } + // When + const result = await withSelector(config, async (selector, compiler) => { + const selection = await selector.selectPipeline( + source, + new AbortController().signal, + ) + return { compiler, selection } + }) + // Then + expect(result.selection?.context.compiler).toBe(result.compiler) + expect(result.selection?.pipeline.loaders).toHaveLength(1) + expect(result.selection?.loaders[0]?.options).toEqual({ jsx: true }) + }) + + it('rejects future source-reader callouts despite identical current native RuleSet effects', async () => { + // Given + const config = { + module: { rules: [mdxRule()] }, + plugins: [ + { + apply(compiler: import('webpack').Compiler) { + compiler.hooks.compilation.tap( + 'SourceReaderSubstitution', + (compilation) => { + compiler.webpack.NormalModule.getCompilationHooks(compilation) + .readResource.for(undefined) + .tapAsync('SourceReaderSubstitution', (_context, callback) => + callback(null, '# Different input'), + ) + }, + ) + }, + }, + ], + } + // When / Then + await expect( + withSelector(config, (selector) => + selector.selectPipeline(source, new AbortController().signal), + ), + ).rejects.toMatchObject({ + rulePosition: 'webpack.plugins[0]', + unknownFact: 'pitch/source-reader/factory substitution', + }) + }) + + it('rejects a contextual global pre-loader that can change MDX input', async () => { + // Given + const config = { + module: { + rules: [ + mdxRule(), + { + test: /\.mdx$/, + issuer: /special/, + enforce: 'pre' as const, + use: ['unresolved-input-loader'], + }, + ], + }, + } + // When / Then + await expect( + withSelector(config, (selector) => + selector.selectPipeline(source, new AbortController().signal), + ), + ).rejects.toMatchObject({ + name: 'WebpackResourceError', + rulePosition: 'module.rules[1]', + unknownFact: 'issuer', + }) + }) + + it('rejects a competing contextual MDX segment instead of selecting the first pipeline', async () => { + // Given + const conditional = { ...mdxRule({ jsx: true }), issuerLayer: 'rsc' } + // When / Then + await expect( + withSelector( + { module: { rules: [mdxRule(), conditional] } }, + (selector) => + selector.selectPipeline(source, new AbortController().signal), + ), + ).rejects.toBeInstanceOf(WebpackResourceError) + }) + + it('proves ordinary first-input from native order rather than a pre suffix', async () => { + // Given + const ordinary = source.replace(/\.mdx$/, '.tsx') + const config = { + module: { + rules: [ + { test: /\.tsx$/, enforce: 'pre' as const, use: [extraction] }, + { + test: /\.tsx$/, + issuer: /hidden/, + enforce: 'pre' as const, + use: ['unresolved-input-loader'], + }, + ], + }, + } + // When + const eligibility = await withSelector(config, (selector) => + selector.ordinaryEligibility(ordinary), + ) + // Then + expect(eligibility).toMatchObject({ + kind: 'native-required', + unknownFact: 'issuer', + }) + expect('proof' in eligibility).toBe(false) + }) + + it('admits an unrelated pre-loader without a local rule matcher', async () => { + // Given + const config = { + module: { + rules: [ + { + test: /\.png$/, + issuer: /hidden/, + enforce: 'pre' as const, + use: ['unresolved-input-loader'], + }, + mdxRule(), + ], + }, + } + // When + const selection = await withSelector(config, (selector) => + selector.selectPipeline(source, new AbortController().signal), + ) + // Then + expect(selection?.pipeline.loaders).toHaveLength(1) + }) +}) diff --git a/packages/next-plugin/src/__tests__/webpack-resource-stock-alias.test.ts b/packages/next-plugin/src/__tests__/webpack-resource-stock-alias.test.ts new file mode 100644 index 000000000..f20d64ac9 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-stock-alias.test.ts @@ -0,0 +1,77 @@ +import { execFile } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { createRequire, Module } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' + +import { expect, it } from 'bun:test' + +const workspace = resolve(import.meta.dir, '../../../..') +const installed = createRequire(join(workspace, 'apps/landing/package.json')) +const runNode = promisify(execFile) + +it.each([ + { router: 'app', dev: true }, + { router: 'app', dev: false }, + { router: 'pages', dev: true }, + { router: 'pages', dev: false }, +] as const)( + 'represents unchanged stock client aliases and ignores instrumentation fallback in %j', + async (scenario) => { + // Given an owned bundle of source helpers and untouched parent module hooks. + const originalRequire = Module.prototype.require + const originalResolve: unknown = Reflect.get(Module, '_resolveFilename') + const config: unknown = installed('next/dist/server/config-shared') + const mdx = installed.resolve('@next/mdx/mdx-js-loader') + const root = mkdtempSync(join(tmpdir(), 'devup-next-stock-worker-')) + try { + const bundle = await Bun.build({ + entrypoints: [ + join(import.meta.dir, 'webpack-resource-stock-worker.ts'), + ], + outdir: root, + target: 'node', + format: 'cjs', + naming: 'worker.cjs', + }) + expect(bundle.success).toBe(true) + // When installed Next runs only inside the bounded, non-detached Node child. + const child = await runNode( + 'node', + [ + join(root, 'worker.cjs'), + workspace, + scenario.router, + String(scenario.dev), + ], + { + cwd: workspace, + timeout: 55000, + killSignal: 'SIGKILL', + maxBuffer: 1024 * 1024, + }, + ) + // Then execFile has observed a successful close and the native proof receipt. + expect(child.stderr).toBe('') + const result: unknown = JSON.parse(child.stdout) + expect(result).toEqual({ + ...scenario, + runtime: 'node', + shared: { ignored: true }, + native: false, + prepared: [], + guard: 'webpack.externals', + }) + expect(Module.prototype.require).toBe(originalRequire) + expect(Reflect.get(Module, '_resolveFilename')).toBe(originalResolve) + expect(installed('next/dist/server/config-shared')).toBe(config) + expect(installed.resolve('@next/mdx/mdx-js-loader')).toBe(mdx) + const { createWasm } = await import('../wasm') + expect(createWasm(workspace)).toBeDefined() + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 60000, +) diff --git a/packages/next-plugin/src/__tests__/webpack-resource-stock-fixture.cjs b/packages/next-plugin/src/__tests__/webpack-resource-stock-fixture.cjs new file mode 100644 index 000000000..9bd8a722d --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-stock-fixture.cjs @@ -0,0 +1,60 @@ +const { mkdirSync, mkdtempSync, rmSync } = require('node:fs') +const { createRequire } = require('node:module') +const { tmpdir } = require('node:os') +const { join, resolve } = require('node:path') + +const workspace = resolve(__dirname, '../../../..') +const installed = createRequire(join(workspace, 'apps/landing/package.json')) +exports.withStockClient = async function withStockClient(scenario, run) { + // Next installs process-global require hooks; callers must own a Node realm. + const { default: getBaseWebpackConfig } = installed( + 'next/dist/build/webpack-config', + ) + const { defaultConfig } = installed('next/dist/server/config-shared') + const { trace } = installed('next/dist/trace') + const bundle = installed('next/dist/compiled/webpack/webpack') + const root = mkdtempSync(join(tmpdir(), 'devup-next-stock-alias-')) + try { + const directory = join(root, scenario.router) + mkdirSync(directory) + const original = await getBaseWebpackConfig(root, { + buildId: 'stock-alias-regression', + encryptionKey: 'stock-alias-regression', + config: { + ...defaultConfig, + experimental: { ...defaultConfig.experimental }, + }, + compilerType: 'client', + dev: scenario.dev, + entrypoints: {}, + deferredEntrypoints: {}, + pagesDir: scenario.router === 'pages' ? directory : undefined, + appDir: scenario.router === 'app' ? directory : undefined, + rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, + originalRewrites: [], + originalRedirects: [], + runWebpackSpan: trace('stock-alias-regression'), + supportedBrowsers: [], + previewProps: { + previewModeId: 'stock-alias-regression', + previewModeSigningKey: 'stock-alias-regression', + previewModeEncryptionKey: 'stock-alias-regression', + }, + }) + const compiler = bundle.webpack(original) + try { + return await run({ + compiler, + params: compiler.newCompilationParams(), + effectiveConfiguration: compiler.options, + pipelines: [], + }) + } finally { + await new Promise((resolve, reject) => + compiler.close((error) => (error ? reject(error) : resolve())), + ) + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +} diff --git a/packages/next-plugin/src/__tests__/webpack-resource-stock-worker.ts b/packages/next-plugin/src/__tests__/webpack-resource-stock-worker.ts new file mode 100644 index 000000000..4fb85b8b5 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-resource-stock-worker.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { join } from 'node:path' + +import type * as PluginUtils from '@devup-ui/plugin-utils' + +import { compilerResourceBoundary } from '../webpack-resource-compiler' +import { webpackResourceResolver } from '../webpack-resource-delivery' +import type { WebpackResourceBinding } from '../webpack-resource-native' + +const [workspace, router, dev] = process.argv.slice(2) +assert.ok(workspace) +assert.ok(router === 'app' || router === 'pages') +assert.ok(dev === 'true' || dev === 'false') +assert.equal(process.versions.bun, undefined) +const scenario = { router, dev: dev === 'true' } as const +const installed = createRequire( + join(workspace, 'packages/next-plugin/package.json'), +) +const { createModuleResolver }: typeof PluginUtils = installed( + '@devup-ui/plugin-utils', +) +const { + withStockClient, +}: { + readonly withStockClient: ( + scenario: { readonly router: 'app' | 'pages'; readonly dev: boolean }, + run: (binding: WebpackResourceBinding) => T | Promise, + ) => Promise +} = installed( + join( + workspace, + 'packages/next-plugin/src/__tests__/webpack-resource-stock-fixture.cjs', + ), +) + +async function proveStockAliases() { + // Given the installed Next config producer and its actual receiving NMF. + const result = await withStockClient(scenario, async (binding) => { + const original = binding.compiler.options.resolve.alias + const entries = Object.entries(original ?? {}).map( + ([key, value]): [string, unknown] => [ + key, + Array.isArray(value) ? [...value] : value, + ], + ) + // When the helper initializes a shared resolver without filtering stock entries. + const settings = webpackResourceResolver(binding) + const prepared: string[] = [] + const resolver = createModuleResolver({ + cwd: binding.compiler.context, + alias: settings.aliases, + conditions: settings.conditions, + prepareSource: (filename) => { + prepared.push(filename) + return undefined + }, + }) + const shared = resolver( + 'private-next-instrumentation-client-user', + join(binding.compiler.context, 'client.tsx'), + ) + const native = await new Promise( + (resolve, reject) => + binding.params.normalModuleFactory + .getResolver('normal') + .resolve( + {}, + binding.compiler.context, + 'private-next-instrumentation-client-user', + {}, + (error, value) => (error ? reject(error) : resolve(value)), + ), + ) + // Then every original assertion executes against native objects in this realm. + assert.deepEqual(Object.entries(settings.aliases), entries) + assert.equal(binding.compiler.options.resolve.alias, original) + assert.deepEqual(Object.entries(original ?? {}), entries) + assert.deepEqual(shared, { ignored: true }) + assert.equal(native, false) + assert.deepEqual(prepared, []) + const boundary = compilerResourceBoundary( + binding, + join(binding.compiler.context, 'page.mdx'), + ) + assert.equal(boundary?.rulePosition, 'webpack.externals') + return { + ...scenario, + runtime: 'node', + shared, + native, + prepared, + guard: boundary.rulePosition, + } + }) + process.stdout.write(JSON.stringify(result)) +} + +void proveStockAliases() diff --git a/packages/next-plugin/src/adapter-capability.ts b/packages/next-plugin/src/adapter-capability.ts new file mode 100644 index 000000000..56bd81f5d --- /dev/null +++ b/packages/next-plugin/src/adapter-capability.ts @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' + +/** Fail closed for unfamiliar Next implementations, without a version floor. */ +export function supportsFinalConfigAdapter( + schemaModule: unknown, + configSource: string, +): boolean { + if ( + typeof schemaModule !== 'object' || + schemaModule === null || + !('configSchema' in schemaModule) + ) + return false + const schema = schemaModule.configSchema + if ( + typeof schema !== 'object' || + schema === null || + !('safeParse' in schema) || + typeof schema.safeParse !== 'function' + ) + return false + const parsed: unknown = schema.safeParse({ + adapterPath: 'devup-capability-probe', + }) + if ( + typeof parsed !== 'object' || + parsed === null || + !('success' in parsed) || + parsed.success !== true || + !('data' in parsed) || + typeof parsed.data !== 'object' || + parsed.data === null || + !('adapterPath' in parsed.data) || + parsed.data.adapterPath !== 'devup-capability-probe' + ) + return false + + // Recognize the shipped CommonJS lifecycle, not a comment or version literal. + const source = configSource.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '') + const hook = source.match( + /async function applyModifyConfig\([^)]*\)\s*\{([\s\S]*?)\n\}/, + )?.[1] + return ( + hook !== undefined && + /if\s*\(config\.adapterPath\)/.test(hook) && + /typeof adapterMod\.modifyConfig === ['"]function['"]/.test(hook) && + /config\s*=\s*await adapterMod\.modifyConfig\(config,\s*\{/.test(hook) && + /return config\s*;/.test(hook) && + /finalizeConfig\(await applyModifyConfig\(/.test(source) + ) +} + +/** Resolve the project's installed Next; a missing/old API uses the legacy path. */ +export function detectFinalConfigAdapter(projectDir: string): boolean { + const require = createRequire(join(projectDir, 'package.json')) + try { + const schema: unknown = require('next/dist/server/config-schema') + const source = readFileSync( + require.resolve('next/dist/server/config'), + 'utf8', + ) + return supportsFinalConfigAdapter(schema, source) + } catch (cause) { + if ( + cause instanceof Error && + 'code' in cause && + (cause.code === 'MODULE_NOT_FOUND' || cause.code === 'ENOENT') + ) + return false + throw cause + } +} diff --git a/packages/next-plugin/src/build-error.ts b/packages/next-plugin/src/build-error.ts new file mode 100644 index 000000000..16875477e --- /dev/null +++ b/packages/next-plugin/src/build-error.ts @@ -0,0 +1,39 @@ +const LOCATED_MESSAGE = /^.+:\d+:\d+: / + +interface LocatedErrorFields { + /** Where the problem is: a source file or the directory being planned */ + file: string + /** What was being done, as a noun phrase: `devup-ui prewarm` */ + what: string + /** The construct that cannot be used: a specifier, `JSON`, `buildStaticImportGraph` */ + code: string + /** What the build needs for this to work */ + needs: string + cause: unknown +} + +function describe(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause) +} + +/** + * An error in the project's `file:line:col: cannot use `` at build + * time: ` form. A cause that already says where it is (an + * extraction error, for instance) is kept as it is, so a location is never + * written twice. + */ +export function locatedError({ + file, + what, + code, + needs, + cause, +}: LocatedErrorFields): Error { + if (cause instanceof Error && LOCATED_MESSAGE.test(cause.message)) { + return cause + } + return new Error( + `${file}:1:1: ${what} cannot use \`${code}\` at build time: ${describe(cause)}; needs ${needs}`, + { cause }, + ) +} diff --git a/packages/next-plugin/src/coordinator-client.ts b/packages/next-plugin/src/coordinator-client.ts new file mode 100644 index 000000000..e1a68e32a --- /dev/null +++ b/packages/next-plugin/src/coordinator-client.ts @@ -0,0 +1,179 @@ +import { readFileSync } from 'node:fs' +import { request } from 'node:http' +import { setTimeout as delay } from 'node:timers/promises' + +import type { + CoordinatorIdentity, + CoordinatorPortInfo, +} from './coordinator-port' +import { isConnectionError, parsePortFile } from './coordinator-port' + +export interface CoordinatorRequest { + readonly portFile: string + readonly identity?: CoordinatorIdentity + readonly resourcePath: string + readonly path: string + readonly method?: 'GET' | 'POST' + readonly body?: string + readonly timeoutMs?: number +} + +export class CoordinatorRequestError extends Error { + constructor( + readonly operation: CoordinatorRequest, + cause: unknown, + ) { + super( + `${operation.resourcePath}:1:1: devup-ui coordinator cannot use \`${operation.path.split('?')[0]}\` at build time: ${cause instanceof Error ? cause.message : String(cause)}; port file ${operation.portFile}. It needs a live coordinator with matching project/token ownership; restart the dev server or build.`, + ) + } +} + +class TransportFailure extends Error { + constructor( + readonly cause: Error, + readonly retryable: boolean, + ) { + super(cause.message) + } +} + +function exchange(options: { + readonly endpoint: CoordinatorPortInfo + readonly path: string + readonly method: 'GET' | 'POST' + readonly body?: string + readonly deadline: number +}): Promise { + if (performance.now() >= options.deadline) + return Promise.reject(new Error('Coordinator request deadline exceeded')) + return new Promise((resolve, reject) => { + let settled = false + let connected = false + const finish = (error?: Error, body?: string) => { + if (settled) return + settled = true + clearTimeout(timer) + if (error) reject(error) + else resolve(body ?? '') + } + const req = request( + { + hostname: '127.0.0.1', + port: options.endpoint.port, + path: options.path, + method: options.method, + agent: false, + headers: { + 'Content-Type': 'application/json', + 'x-devup-project': encodeURIComponent(options.endpoint.project), + 'x-devup-token': options.endpoint.token, + }, + }, + (res) => { + const chunks: Buffer[] = [] + let bytes = 0 + res.on('data', (chunk: Buffer) => { + bytes += chunk.length + if (options.path === '/health' && bytes > 4096) { + finish(new Error('Health descriptor exceeds 4096 bytes')) + res.destroy() + req.destroy() + return + } + chunks.push(chunk) + }) + const lost = (error: Error) => + finish( + new TransportFailure( + error, + options.method === 'GET' && isConnectionError(error), + ), + ) + res.on('error', lost) + res.on('aborted', () => + lost( + Object.assign(new Error('Coordinator response aborted'), { + code: 'ECONNRESET', + }), + ), + ) + res.on('end', () => { + const content = Buffer.concat(chunks).toString('utf-8') + if (res.statusCode !== 200) + finish(new Error(`HTTP ${res.statusCode}: ${content}`)) + else finish(undefined, content) + }) + }, + ) + const timer = setTimeout( + () => { + finish(new Error('Coordinator request deadline exceeded')) + req.destroy() + }, + Math.max(1, options.deadline - performance.now()), + ) + req.on('socket', (socket) => + socket.once('connect', () => { + connected = true + }), + ) + req.on('error', (error) => + finish( + new TransportFailure( + error, + isConnectionError(error) && (options.method === 'GET' || !connected), + ), + ), + ) + req.end(options.body) + }) +} + +export async function requestCoordinator( + options: CoordinatorRequest, +): Promise { + const deadline = + performance.now() + + (options.timeoutMs ?? (options.method === 'POST' ? 60000 : 75000)) + let reason: unknown = new Error( + 'Coordinator endpoint missing or deadline exceeded', + ) + let identity = options.identity + while (performance.now() < deadline) { + try { + const endpoint = parsePortFile(readFileSync(options.portFile, 'utf-8')) + identity ??= endpoint + if ( + endpoint.project !== identity.project || + endpoint.token !== identity.token + ) + throw new Error('Coordinator endpoint identity mismatch') + const health = parsePortFile( + await exchange({ endpoint, path: '/health', method: 'GET', deadline }), + ) + if ( + health.project !== endpoint.project || + health.token !== endpoint.token || + health.pid !== endpoint.pid || + health.port !== endpoint.port + ) + throw new Error('Coordinator health ownership mismatch') + return await exchange({ + endpoint, + path: options.path, + method: options.method ?? 'GET', + body: options.body, + deadline, + }) + } catch (error) { + reason = error + const missing = + error instanceof Error && 'code' in error && error.code === 'ENOENT' + if (!missing && !(error instanceof TransportFailure && error.retryable)) + throw new CoordinatorRequestError(options, error) + await delay(Math.max(0, Math.min(20, deadline - performance.now()))) + } + } + throw new CoordinatorRequestError(options, reason) +} diff --git a/packages/next-plugin/src/coordinator-completion.ts b/packages/next-plugin/src/coordinator-completion.ts new file mode 100644 index 000000000..6f2a5dac0 --- /dev/null +++ b/packages/next-plugin/src/coordinator-completion.ts @@ -0,0 +1,102 @@ +export class IncompleteCssError extends Error { + constructor( + readonly label: string, + readonly missing: readonly string[], + readonly failed: ReadonlyMap, + ) { + const first = failed.keys().next().value ?? missing[0] + const parts = [ + ...[...failed].map( + ([file, reason]) => `extraction failed for ${file}: ${reason}`, + ), + ...(missing.length > 0 ? [`never extracted: ${missing.join(', ')}`] : []), + ] + super( + `${first}:1:1: devup-ui coordinator cannot use \`${label}\` at build time: ${parts.join('; ')}. Fix: make every listed file transform through the devup-ui loader (or drop it from the planned set) and rerun; partial CSS is never served.`, + ) + } +} + +export class CoordinatorShutdownError extends Error { + constructor(readonly file: string) { + super( + `${file}:1:1: devup-ui coordinator cannot use \`request\` at build time: it is shutting down; restart the dev server or build.`, + ) + } +} + +export interface CompletionTracker { + close(): void + succeed(file: string): void + fail(file: string, message: string): void + forget(file: string): void + /** + * Resolves once every file succeeded. Rejects as soon as one has failed, or + * with the files still missing after `maxWaitMs`. + */ + wait(label: string, files: readonly string[]): Promise +} + +export function createCompletionTracker(maxWaitMs: number): CompletionTracker { + const done = new Set() + const failures = new Map() + const waiters = new Map<() => void, () => void>() + let closed = false + const settle = () => { + for (const waiter of waiters.keys()) waiter() + } + return { + close() { + closed = true + for (const cancel of waiters.values()) cancel() + }, + succeed(file) { + done.add(file) + failures.delete(file) + settle() + }, + fail(file, message) { + done.delete(file) + failures.set(file, message) + settle() + }, + forget(file) { + done.delete(file) + failures.delete(file) + }, + wait(label, files) { + if (closed) + return Promise.reject(new CoordinatorShutdownError('devup-ui.css')) + return new Promise((resolve, reject) => { + const check = (expired: boolean) => { + const failed = new Map( + files + .filter((file) => failures.has(file)) + .map((file) => [file, failures.get(file) ?? ''] as const), + ) + const missing = files.filter( + (file) => !done.has(file) && !failures.has(file), + ) + if (failed.size === 0 && missing.length === 0) { + finish() + resolve() + } else if (failed.size > 0 || expired) { + finish() + reject(new IncompleteCssError(label, missing, failed)) + } + } + const waiter = () => check(false) + const timer = setTimeout(() => check(true), maxWaitMs) + const finish = () => { + clearTimeout(timer) + waiters.delete(waiter) + } + waiters.set(waiter, () => { + finish() + reject(new CoordinatorShutdownError('devup-ui.css')) + }) + check(false) + }) + }, + } +} diff --git a/packages/next-plugin/src/coordinator-config.ts b/packages/next-plugin/src/coordinator-config.ts new file mode 100644 index 000000000..f8b231027 --- /dev/null +++ b/packages/next-plugin/src/coordinator-config.ts @@ -0,0 +1,26 @@ +import { + collectDevupConfigFiles, + loadDevupConfigSync, +} from '@devup-ui/plugin-utils' + +import { stampFile } from './coordinator-ledger' + +export interface ConfigState { + /** The config and everything it extends, root first (absolute) */ + readonly files: readonly string[] + /** Changes whenever any of those files changes, appears or disappears */ + readonly signature: string +} + +/** Read the config chain; an unreadable chain throws a located error. */ +export function readConfigState(devupFile: string): ConfigState { + const files = collectDevupConfigFiles(devupFile) + return { + files, + signature: JSON.stringify(files.map((file) => [file, stampFile(file)])), + } +} + +export function loadTheme(devupFile: string): object { + return loadDevupConfigSync(devupFile).theme ?? {} +} diff --git a/packages/next-plugin/src/coordinator-core.ts b/packages/next-plugin/src/coordinator-core.ts new file mode 100644 index 000000000..6d653f77f --- /dev/null +++ b/packages/next-plugin/src/coordinator-core.ts @@ -0,0 +1,200 @@ +import { + type ExtractOutputSnapshot, + extractRequest, + locatedError, + toExtractResponse, +} from './coordinator-engine' +import { assertAdoptedRequest, preparedInput } from './coordinator-generation' +import { type ExtractRequest, HttpError } from './coordinator-http' +import { createInput } from './coordinator-ledger' +import type { CoordinatorOptions, Core } from './coordinator-options' +import { createReplay } from './coordinator-replay' +import { extractSealed } from './coordinator-sealed' +import { elapsedMs, profileStart, reportProfile } from './profile' + +interface Accepted { + output: ExtractOutputSnapshot + committed: Promise + cacheHit: boolean +} + +/** + * Everything a coordinator knows, with no HTTP in it: the engine in service, + * the inputs it holds, completion tracking and persistence. Every change goes + * through one queue, so the engine is never mutated by two requests at once. + */ +export function createCore(options: CoordinatorOptions, project: string): Core { + let watchListener: ((inputs: readonly string[]) => void) | undefined + const replay = createReplay(options, project, (inputs) => + watchListener?.(inputs), + ) + const { + root, + settings, + createEngine, + configure, + ledger, + plan, + persistence, + live, + snapshot: liveSnapshot, + reconcile, + } = replay + const watch = options.watch ?? false + const watchInputs = () => [ + ...(live.generation?.watchInputs ?? []), + ...ledger.resolutionInputs().map((input) => input.path), + ] + let sealed = false + + let queue: Promise = Promise.resolve() + function mutate(task: () => Promise): Promise { + const run = queue.then(() => { + persistence.assertHealthy() + return task() + }) + // The caller sees a failure through `run`; the queue just keeps going. + queue = run.then( + () => undefined, + () => undefined, + ) + return run + } + + async function accept(request: ExtractRequest): Promise { + await reconcile(request) + const prepared = preparedInput(live.generation, request) + if (live.generation?.ordinaryInputs !== undefined) { + await persistence.drain() + assertAdoptedRequest(ledger.list(), request) + const output = + ledger.lookup(request.filename, request.code, request.sourceType) ?? + extractSealed( + { live: live.engine, createEngine, configure, settings }, + liveSnapshot(), + request, + ).output + return { output, committed: Promise.resolve(), cacheHit: true } + } + const cached = ledger.lookup( + request.filename, + request.code, + request.sourceType, + ) + if (cached) { + return { + output: cached, + committed: persistence.commitPending(liveSnapshot), + cacheHit: true, + } + } + const candidate = sealed + ? extractSealed( + { live: live.engine, createEngine, configure, settings }, + liveSnapshot(), + request, + ) + : undefined + const output = + candidate?.output ?? extractRequest(live.engine, settings, request) + // The engine reports whether this file's CSS or the base sheet changed + const changed = !sealed && (output.updatedBaseStyle || output.css != null) + const input = + prepared ?? createInput(root, request, output.dependencies ?? []) + if (candidate !== undefined) live.engine = candidate.engine + ledger.accept(input, output) + plan.note(request.filename, output.cssFile) + if (changed) live.revision += 1 + persistence.accept() + return { + output, + committed: persistence.commit(liveSnapshot), + cacheHit: false, + } + } + + return { + close() { + replay.close() + plan.close() + }, + async extract(request) { + const startedAt = profileStart() + try { + const accepted = await mutate(() => accept(request)) + await accepted.committed + if (watch) watchListener?.(watchInputs()) + plan.succeed(request.filename) + reportProfile('coordinator.extract', { + cacheHit: accepted.cacheHit, + durationMs: elapsedMs(startedAt), + filename: request.filename, + sourceBytes: + startedAt === undefined + ? undefined + : Buffer.byteLength(request.code), + }) + return toExtractResponse( + accepted.output, + settings.singleCss, + live.config?.files ?? [], + ) + } catch (error) { + if (live.generation?.ordinaryInputs === undefined) { + plan.fail( + request.filename, + error instanceof Error ? error.message : String(error), + ) + } + throw error + } + }, + async css({ fileNum, importMainCss, wait }) { + if (watch) { + return mutate(async () => { + await reconcile() + return { + css: live.engine.getCss(fileNum, importMainCss), + policy: 'dev-current', + } + }) + } + if (!wait) { + throw new HttpError( + 400, + locatedError( + 'devup-ui.css', + 'serve a production stylesheet without waiting for it', + 'a production build only gets a complete stylesheet by waiting', + 'request /css with waitForIdle=true.', + ).message, + ) + } + await plan.wait(fileNum) + return mutate(async () => { + if (live.generation !== undefined) { + options.preparedSources?.validateForCssFinalization?.(live.generation) + } + sealed = true + return { + css: live.engine.getCss(fileNum, importMainCss), + policy: 'production-complete', + } + }) + }, + startup: () => mutate(() => replay.startup()), + reconcile: (changedPaths) => + mutate(() => reconcile(undefined, changedPaths)), + watchInputs, + onWatchInputs(listener) { + watchListener = listener + return () => { + if (watchListener === listener) watchListener = undefined + } + }, + async flush() { + await queue + await persistence.drain() + }, + } +} diff --git a/packages/next-plugin/src/coordinator-engine.ts b/packages/next-plugin/src/coordinator-engine.ts new file mode 100644 index 000000000..ecc2cc3c7 --- /dev/null +++ b/packages/next-plugin/src/coordinator-engine.ts @@ -0,0 +1,208 @@ +import { dirname, relative } from 'node:path' + +import type { ResolutionInputs } from '@devup-ui/plugin-utils' + +import type { ExtractRequest } from './coordinator-http' +import type { PrewarmedOutput } from './coordinator-options' +import type { MdxInputFingerprint } from './mdx-source-freshness' +import { + type AllocatorState, + type CoordinatorInput, + importAllocatorState, +} from './state' +import { type DevupWasm, extractWithModuleResolver } from './wasm' +import { extractionResolutionProof } from './wasm-resolution-proof' + +export interface ExtractOutputSnapshot extends Omit { + css?: string + readonly resolutionInputs?: readonly MdxInputFingerprint[] +} + +/** Copy every WASM-backed getter once, then release its Rust allocation. */ +export function takeExtractOutput( + output: ReturnType, +): ExtractOutputSnapshot { + try { + const proof = extractionResolutionProof(output) + return { + code: output.code, + css: output.css, + cssFile: output.cssFile, + map: output.map, + updatedBaseStyle: output.updatedBaseStyle, + dependencies: output.dependencies, + ...(proof.length ? { resolutionInputs: proof } : {}), + } + } finally { + output.free() + } +} + +export interface ExtractResponse { + code: string + map?: string + cssFile?: string + updatedBaseStyle: boolean + dependencies: string[] + readonly resolutionInputs?: ResolutionInputs +} + +/** + * What the loader gets back. Per-file CSS imports become a query on the + * placeholder Turbopack can resolve (devup-ui-79.css -> devup-ui.css?fileNum=79); + * `watched` are extra files the loader must depend on (the theme chain). + */ +export function toExtractResponse( + output: ExtractOutputSnapshot, + singleCss: boolean, + watched: readonly string[], +): ExtractResponse { + return { + code: + !singleCss && output.code + ? output.code.replace(/devup-ui-(\d+)\.css/g, 'devup-ui.css?fileNum=$1') + : output.code, + map: output.map, + cssFile: output.cssFile, + updatedBaseStyle: output.updatedBaseStyle, + dependencies: [...(output.dependencies ?? []), ...watched], + ...(output.resolutionInputs?.length + ? { + resolutionInputs: Object.freeze({ + fileDependencies: Object.freeze( + output.resolutionInputs + .filter((input) => input.kind === 'file') + .map((input) => input.path), + ), + missingDependencies: Object.freeze( + output.resolutionInputs + .filter((input) => input.kind === 'missing') + .map((input) => input.path), + ), + }), + } + : {}), + } +} + +const LOCATED = /^.+:\d+:\d+: / + +/** + * An error that names the file (`file:1:1`), what cannot be built and the fix. + * Engine errors that already carry a position are kept as they are. + */ +export function locatedError( + file: string, + what: string, + reason: unknown, + fix: string, +): Error { + const detail = reason instanceof Error ? reason.message : String(reason) + return LOCATED.test(detail) + ? new Error(detail, { cause: reason }) + : new Error( + `${file}:1:1: devup-ui coordinator cannot ${what}: ${detail}. Fix: ${fix}`, + { cause: reason }, + ) +} + +export interface ExtractSettings { + readonly package: string + readonly cssDir: string + readonly singleCss: boolean + readonly sourceMap: boolean + readonly importAliases: Record +} + +export function extractInput( + wasm: DevupWasm, + settings: ExtractSettings, + input: Pick< + CoordinatorInput, + 'filename' | 'resourcePath' | 'source' | 'sourceType' + >, +): ExtractOutputSnapshot { + let relCssDir = relative( + dirname(input.resourcePath), + settings.cssDir, + ).replaceAll('\\', '/') + if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` + return takeExtractOutput( + extractWithModuleResolver(wasm, settings.sourceMap, [ + input.filename, + input.source, + settings.package, + relCssDir, + settings.singleCss, + false, + true, + settings.importAliases, + ...(input.sourceType ? ([input.sourceType] as const) : ([] as const)), + ]), + ) +} + +/** Extract a loader's request, failing with an error that names the file and the fix. */ +export function extractRequest( + wasm: DevupWasm, + settings: ExtractSettings, + { + code, + ...request + }: Pick, +): ExtractOutputSnapshot { + try { + return extractInput(wasm, settings, { ...request, source: code }) + } catch (error) { + throw locatedError( + request.filename, + 'extract styles', + error, + 'fix the reported source, or remove the Devup UI usage that cannot be resolved at build time.', + ) + } +} + +export interface RebuildPlan { + readonly createEngine: () => DevupWasm + /** The engine in service; the new one must not be it. */ + readonly live: DevupWasm + readonly configure: (wasm: DevupWasm) => void + /** Names and numbers to carry over, so survivors keep theirs. */ + readonly allocator: AllocatorState + readonly theme: object | undefined + readonly settings: ExtractSettings + readonly inputs: readonly CoordinatorInput[] + readonly outputs?: Map +} + +/** + * A fresh engine holding exactly `inputs`: the allocator state first (so + * deleted files leave their numbers behind as tombstones), then the plugin's + * configuration and theme, then every input replayed in order. + */ +export function buildEngine(plan: RebuildPlan): DevupWasm { + const fresh = plan.createEngine() + if (fresh === plan.live) { + throw new Error( + 'devup-ui coordinator cannot rebuild CSS state: createEngine returned the engine in service. Fix: createEngine must return a new, isolated engine on every call.', + ) + } + importAllocatorState(fresh, plan.allocator) + plan.configure(fresh) + if (plan.theme !== undefined) fresh.registerTheme(plan.theme) + for (const input of plan.inputs) { + try { + const output = extractInput(fresh, plan.settings, input) + plan.outputs?.set(input.filename, output) + } catch (error) { + throw locatedError( + input.filename, + 'rebuild the CSS state after a source or theme change', + error, + 'fix this file (or restore what it imports); the CSS is served from the live state until it builds again.', + ) + } + } + return fresh +} diff --git a/packages/next-plugin/src/coordinator-generation.ts b/packages/next-plugin/src/coordinator-generation.ts new file mode 100644 index 000000000..bfa17af72 --- /dev/null +++ b/packages/next-plugin/src/coordinator-generation.ts @@ -0,0 +1,150 @@ +import { locatedError } from './coordinator-engine' +import type { ExtractRequest } from './coordinator-http' +import type { PreparedSourceGeneration } from './coordinator-options' +import type { CoordinatorInput } from './state' + +export function immutableInput(input: CoordinatorInput): CoordinatorInput { + return Object.freeze({ + ...input, + dependencies: Object.freeze([...input.dependencies]), + stamps: Object.freeze({ ...input.stamps }), + }) +} + +/** Copy provider data once; resolver/configuration must capture the same immutable bytes. */ +export function immutableGeneration( + generation: PreparedSourceGeneration, +): PreparedSourceGeneration { + return Object.freeze({ + configureWasm: generation.configureWasm, + ...(generation.resolutionInputs === undefined + ? {} + : { + resolutionInputs: Object.freeze( + generation.resolutionInputs.map((input) => + Object.freeze({ ...input }), + ), + ), + }), + ...(generation.ordinaryInputs === undefined + ? {} + : { + ordinaryInputs: Object.freeze( + generation.ordinaryInputs.map(immutableInput), + ), + }), + ...(generation.plan === undefined + ? {} + : { + plan: Object.freeze({ + canonicalMap: Object.freeze({ ...generation.plan.canonicalMap }), + expectedBaseFiles: Object.freeze([ + ...generation.plan.expectedBaseFiles, + ]), + }), + }), + ...(generation.watchInputs === undefined + ? {} + : { + watchInputs: Object.freeze([...generation.watchInputs]), + }), + sources: Object.freeze( + generation.sources.map(({ input, evidence }) => + Object.freeze({ + input: immutableInput(input), + evidence: Object.freeze({ + ...evidence, + fileFingerprints: Object.freeze({ ...evidence.fileFingerprints }), + contextFingerprints: Object.freeze({ + ...evidence.contextFingerprints, + }), + missingDependencies: Object.freeze([ + ...evidence.missingDependencies, + ]), + }), + }), + ), + ), + }) +} + +export function overlayGeneration( + inputs: readonly CoordinatorInput[], + previous: PreparedSourceGeneration | undefined, + next: PreparedSourceGeneration, +): readonly CoordinatorInput[] { + const compiled = new Set(next.sources.map(({ input }) => input.filename)) + const formerlyCompiled = new Set( + previous?.sources.map(({ input }) => input.filename), + ) + const replacements = new Map() + for (const input of next.ordinaryInputs ?? []) { + if (compiled.has(input.filename) || replacements.has(input.filename)) { + throw locatedError( + input.filename, + 'adopt ordinary inputs', + 'duplicate or compiled ownership', + 'supply one ordinary input per filename, separate from compiled sources.', + ) + } + replacements.set(input.filename, input) + } + for (const { input } of next.sources) { + if (replacements.has(input.filename)) { + throw locatedError( + input.filename, + 'adopt compiled inputs', + 'duplicate ownership', + 'supply one compiled input per filename.', + ) + } + replacements.set(input.filename, input) + } + return [ + ...inputs.filter( + ({ filename }) => + !formerlyCompiled.has(filename) && !replacements.has(filename), + ), + ...replacements.values(), + ] +} + +export function assertAdoptedRequest( + inputs: readonly CoordinatorInput[], + request: ExtractRequest, +): void { + const input = inputs.find(({ filename }) => filename === request.filename) + if ( + input?.source !== request.code || + input.resourcePath !== request.resourcePath || + input.sourceType !== request.sourceType + ) { + throw locatedError( + request.filename, + 'admit ordinary native bytes', + 'no current generation input matches this request', + 'refresh the disk-first generation; upstream native bytes require the approved owner-held issuer protocol.', + ) + } +} + +export function preparedInput( + generation: PreparedSourceGeneration | undefined, + request: ExtractRequest, +): CoordinatorInput | undefined { + const input = generation?.sources.find( + (source) => source.input.filename === request.filename, + )?.input + if ( + input !== undefined && + (input.source !== request.code || input.sourceType !== request.sourceType) + ) { + throw locatedError( + request.filename, + 'accept compiled source from a different prepared generation', + 'the loader bytes differ from the current compiler-owned input', + 'refresh the native compiler input and retry; stale compiled bytes cannot replace prepared sources.', + ) + } + return input +} diff --git a/packages/next-plugin/src/coordinator-http.ts b/packages/next-plugin/src/coordinator-http.ts new file mode 100644 index 000000000..0062a7840 --- /dev/null +++ b/packages/next-plugin/src/coordinator-http.ts @@ -0,0 +1,130 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' + +import type { SourceType } from '@devup-ui/plugin-utils' + +import { locatedError } from './coordinator-engine' +import type { CoordinatorIdentity } from './coordinator-port' + +export class HttpError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message) + } +} + +export interface ExtractRequest { + readonly filename: string + readonly code: string + readonly resourcePath: string + readonly sourceType?: SourceType +} + +export function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => chunks.push(chunk)) + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))) + req.on('error', reject) + }) +} + +export function sendJson( + res: ServerResponse, + status: number, + body: unknown, +): void { + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(body)) +} + +/** + * Refuse a request that names another project or token. With `enforce`, the + * headers are required too; without it they are checked only when sent. + */ +export function assertOwnership( + req: IncomingMessage, + pathname: string, + identity: CoordinatorIdentity, + enforce: boolean, +): void { + const project = req.headers['x-devup-project'] + const token = req.headers['x-devup-token'] + const missing = project === undefined || token === undefined + const foreign = + (project !== undefined && + project !== encodeURIComponent(identity.project)) || + (token !== undefined && token !== identity.token) + if ((enforce && missing) || foreign) { + throw new HttpError( + 403, + `${pathname}:1:1: devup-ui coordinator cannot accept this request: it does not carry this build's project and token. Fix: read the port file the plugin published for this build and send its x-devup-project and x-devup-token headers.`, + ) + } +} + +export function parseExtractRequest(body: string): ExtractRequest { + let data: unknown + try { + data = JSON.parse(body) + } catch (cause) { + throw new HttpError( + 400, + locatedError( + '/extract', + 'read the request body', + cause, + 'POST JSON {filename, code, resourcePath}.', + ).message, + ) + } + if ( + typeof data !== 'object' || + data === null || + !('filename' in data) || + typeof data.filename !== 'string' || + !('code' in data) || + typeof data.code !== 'string' || + !('resourcePath' in data) || + typeof data.resourcePath !== 'string' || + ('sourceType' in data && + data.sourceType !== undefined && + data.sourceType !== 'compiled-mdx') + ) { + throw new HttpError( + 400, + locatedError( + '/extract', + 'read the request body', + 'filename, code and resourcePath must all be strings', + 'POST JSON {filename, code, resourcePath}.', + ).message, + ) + } + return { + filename: data.filename, + code: data.code, + resourcePath: data.resourcePath, + ...('sourceType' in data && data.sourceType === 'compiled-mdx' + ? { sourceType: data.sourceType } + : {}), + } +} + +/** The `fileNum` query value, or undefined when it is absent. */ +export function parseFileNum(value: string | null): number | undefined { + if (value === null) return undefined + if (!/^\d+$/.test(value)) { + throw new HttpError( + 400, + locatedError( + '/css', + 'read the request', + `fileNum "${value}" is not a number`, + 'request /css?fileNum=.', + ).message, + ) + } + return Number(value) +} diff --git a/packages/next-plugin/src/coordinator-instance.ts b/packages/next-plugin/src/coordinator-instance.ts new file mode 100644 index 000000000..396f7f6c5 --- /dev/null +++ b/packages/next-plugin/src/coordinator-instance.ts @@ -0,0 +1,237 @@ +import { randomUUID } from 'node:crypto' +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http' +import type { Socket } from 'node:net' +import { resolve } from 'node:path' + +import { + CoordinatorShutdownError, + IncompleteCssError, +} from './coordinator-completion' +import { + assertOwnership, + HttpError, + parseExtractRequest, + parseFileNum, + readBody, + sendJson, +} from './coordinator-http' +import type { CoordinatorStartOptions } from './coordinator-options' +import { + type CoordinatorIdentity, + formatPortFile, + publishPortFile, + removeOwnPortFile, +} from './coordinator-port' +import { + createPreparation, + reportBackgroundError, +} from './coordinator-preparation' + +export interface CoordinatorInstance { + readonly identity: CoordinatorIdentity + readonly ready: Promise + readonly prepared: Promise + /** Stop listening and watching now. Accepted writes may still be running. */ + close(): void + /** Refuse new requests, wait for accepted work and its writes, then close. */ + drain(): Promise + /** Wait for accepted work and its writes without closing. */ + flush(): Promise +} + +/** One app's coordinator: an HTTP endpoint over its own core and engine. */ +export function createInstance( + options: CoordinatorStartOptions, +): CoordinatorInstance { + const root = resolve(options.projectRoot ?? process.cwd()) + const portFile = resolve(root, options.coordinatorPortFile) + const identity = options.identity ?? { project: root, token: randomUUID() } + const enforce = options.identity !== undefined + const preparation = createPreparation(options, identity) + const prepared = preparation.wait().then(() => undefined) + void prepared.then(undefined, reportBackgroundError) + const inflight = new Set>() + const sockets = new Set() + const reading = new Set() + let draining = false + let closed = false + let port = 0 + + async function route( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ): Promise { + if (req.method === 'GET' && url.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(formatPortFile(port, process.pid, identity)) + } else if (req.method === 'GET' && url.pathname === '/css') { + const core = await preparation.wait() + const { css, policy } = await core.css({ + fileNum: parseFileNum(url.searchParams.get('fileNum')), + importMainCss: url.searchParams.get('importMainCss') === 'true', + wait: url.searchParams.get('waitForIdle') === 'true', + }) + res.writeHead(200, { + 'Content-Type': 'text/css', + 'x-devup-css-policy': policy, + }) + res.end(css) + } else if (req.method === 'POST' && url.pathname === '/extract') { + let body: string + reading.add(req) + try { + body = await readBody(req) + } finally { + reading.delete(req) + } + const request = parseExtractRequest(body) + const core = await preparation.wait() + sendJson(res, 200, await core.extract(request)) + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not Found') + } + } + + async function handle( + req: IncomingMessage, + res: ServerResponse, + ): Promise { + try { + const url = new URL(req.url ?? '/', 'http://127.0.0.1') + assertOwnership(req, url.pathname, identity, enforce) + if (draining) { + throw new HttpError( + 503, + new CoordinatorShutdownError(url.pathname).message, + ) + } + await route(req, res, url) + } catch (error) { + sendJson(res, error instanceof HttpError ? error.status : 500, { + error: error instanceof Error ? error.message : String(error), + ...(error instanceof IncompleteCssError + ? { missing: error.missing, failed: [...error.failed.keys()] } + : {}), + }) + } + } + + const server = createServer((req, res) => { + // `handle` answers every failure itself, so these promises never reject. + const task = handle(req, res) + inflight.add(task) + void task.finally(() => inflight.delete(task)) + }) + server.on('connection', (socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + }) + + function listen(): Promise { + return new Promise((resolveListen, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + resolveListen( + typeof address === 'object' && address !== null ? address.port : 0, + ) + }) + }) + } + + async function settle(): Promise { + while (inflight.size > 0) await Promise.all(inflight) + } + + const closedFirst = Promise.withResolvers() + + function close(): void { + if (closed) return + closed = true + draining = true + closedFirst.resolve(undefined) + preparation.close() + server.close() + server.closeAllConnections() + for (const socket of sockets) socket.destroy() + removeOwnPortFile(portFile, identity) + } + + const ready = (async () => { + if (!('prepare' in options)) { + preparation.start() + await Promise.race([prepared, closedFirst.promise]) + } + if (closed) return + // A server closed while its socket opens may never report listening + const listening = await Promise.race([listen(), closedFirst.promise]) + if (listening === undefined) { + server.close() + return + } + port = listening + server.unref() + publishPortFile(portFile, { + version: 1, + pid: process.pid, + port, + ...identity, + }) + preparation.start() + })() + + async function flush(): Promise { + try { + await settlePreparation() + } finally { + do { + await settle() + await preparation.flush() + } while (inflight.size > 0) + } + } + + async function settlePreparation(): Promise { + try { + await prepared + } catch (error) { + if (!(error instanceof CoordinatorShutdownError)) throw error + } + } + + return { + identity, + ready, + prepared, + close, + async drain() { + draining = true + preparation.stopWatching() + if ('prepare' in options) preparation.close() + for (const req of reading) + req.destroy(new CoordinatorShutdownError('/extract')) + try { + await ready + if (!('prepare' in options)) { + await prepared + preparation.close() + } + await settlePreparation() + } finally { + try { + await settle() + await preparation.flush() + } finally { + close() + } + } + }, + flush, + } +} diff --git a/packages/next-plugin/src/coordinator-ledger.ts b/packages/next-plugin/src/coordinator-ledger.ts new file mode 100644 index 000000000..5d9f96062 --- /dev/null +++ b/packages/next-plugin/src/coordinator-ledger.ts @@ -0,0 +1,211 @@ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import type { SourceType } from '@devup-ui/plugin-utils' + +import type { ExtractOutputSnapshot } from './coordinator-engine' +import type { ExtractRequest } from './coordinator-http' +import type { CoordinatorOptions } from './coordinator-options' +import type { ProductionPlan } from './coordinator-plan' +import { + changedMdxInput, + type MdxInputFingerprint, +} from './mdx-source-freshness' +import type { CoordinatorInput } from './state' + +function hash(content: string | Buffer): string { + return createHash('sha1').update(content).digest('hex') +} + +/** Content hash of a file, or '' when there is no such file. */ +export function stampFile(path: string): string { + return existsSync(path) ? hash(readFileSync(path)) : '' +} + +/** The ledger entry for a source, stamping the files it read as of now. */ +export function createInput( + root: string, + { + code, + ...request + }: Pick, + dependencies: readonly string[], +): CoordinatorInput { + return { + ...request, + source: code, + dependencies, + stamps: Object.fromEntries( + dependencies.map((dependency) => { + const path = resolve(root, dependency) + return [path, stampFile(path)] + }), + ), + backing: stampFile(request.resourcePath), + } +} + +/** The file the input came from was deleted or renamed (virtual inputs never are). */ +export function isGone(input: CoordinatorInput): boolean { + return input.backing !== '' && !existsSync(input.resourcePath) +} + +/** The file is still what the checkpoint accepted. */ +export function isCurrent(input: CoordinatorInput): boolean { + // Compiled Markdown needs a fresh provider generation, not a raw backing stamp. + return ( + input.sourceType === undefined && + !/\.mdx?$/i.test(input.resourcePath) && + (input.backing === '' || stampFile(input.resourcePath) === input.backing) + ) +} + +export function orderInputs( + inputs: readonly CoordinatorInput[], +): CoordinatorInput[] { + return [...inputs].sort((a, b) => + a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0, + ) +} + +export interface InputLedger { + /** Record an accepted source together with the output it produced. */ + accept(input: CoordinatorInput, output: ExtractOutputSnapshot): void + /** + * The output of an earlier identical transform, if the engine still holds + * exactly that source and nothing it read has changed since. + */ + lookup( + filename: string, + source: string, + sourceType?: SourceType, + ): ExtractOutputSnapshot | undefined + /** Live inputs in replay (path) order. */ + list(): CoordinatorInput[] + resolutionInputs(): readonly MdxInputFingerprint[] + resolutionChanged(): boolean + /** Swap the live inputs wholesale; cached outputs no longer apply. */ + replace(inputs: readonly CoordinatorInput[]): void + stage( + inputs: readonly CoordinatorInput[], + outputs: ReadonlyMap, + proofs?: ReadonlyMap, + ): () => void +} + +/** + * What the engine currently holds, per file. The inputs are the live accepted + * sources (needed to replay them); the outputs are a bounded cache of what + * they produced, so identical transforms from Next's server and client graphs + * do not run the engine twice. + */ +export function createInputLedger(maxOutputs: number): InputLedger { + let inputs = new Map() + let outputs = new Map() + let proofs = new Map() + const keyOf = (filename: string, source: string, sourceType?: SourceType) => + `${filename}\0${sourceType ?? ''}\0${hash(source)}` + return { + accept(input, output) { + proofs.set(input.filename, output.resolutionInputs ?? []) + inputs.set(input.filename, input) + const key = keyOf(input.filename, input.source, input.sourceType) + outputs.delete(key) + outputs.set(key, output) + for (const oldest of outputs.keys()) { + if (outputs.size <= maxOutputs) break + outputs.delete(oldest) + } + }, + lookup(filename, source, sourceType) { + const input = inputs.get(filename) + const fresh = + input?.source === source && + input.sourceType === sourceType && + Object.entries(input.stamps).every( + ([path, stamp]) => stampFile(path) === stamp, + ) + const key = keyOf(filename, source, sourceType) + const output = fresh ? outputs.get(key) : undefined + if (output) { + outputs.delete(key) + outputs.set(key, output) + } + return output + }, + list() { + return orderInputs([...inputs.values()]) + }, + resolutionInputs: () => [...proofs.values()].flat(), + resolutionChanged: () => + [...proofs].some( + ([filename, inputs]) => changedMdxInput(filename, inputs) !== undefined, + ), + replace(next) { + inputs = new Map(next.map((input) => [input.filename, input])) + outputs.clear() + proofs.clear() + }, + stage(next, extracted, observed = extracted) { + const stagedProofs = new Map( + [...observed].map( + ([filename, output]) => + [filename, output.resolutionInputs ?? []] as const, + ), + ) + const stagedInputs = new Map(next.map((input) => [input.filename, input])) + const stagedOutputs = new Map() + for (const input of next) { + const output = extracted.get(input.filename) + if (output !== undefined) + stagedOutputs.set( + keyOf(input.filename, input.source, input.sourceType), + output, + ) + } + for (const oldest of stagedOutputs.keys()) { + if (stagedOutputs.size <= maxOutputs) break + stagedOutputs.delete(oldest) + } + return () => { + inputs = stagedInputs + outputs = stagedOutputs + proofs = stagedProofs + } + }, + } +} + +export function seedPrewarmedOutputs( + ledger: InputLedger, + options: CoordinatorOptions, + plan: ProductionPlan, +): void { + const root = resolve(options.projectRoot ?? process.cwd()) + for (const [ + filename, + { source, sourceType, ...output }, + ] of options.prewarmedOutputs ?? []) { + const input = + options.preparedSources === undefined + ? createInput( + root, + { + filename, + code: source, + resourcePath: resolve(root, filename), + ...(sourceType === undefined ? {} : { sourceType }), + }, + output.dependencies ?? [], + ) + : ledger + .list() + .find( + (input) => input.filename === filename && input.source === source, + ) + if (input === undefined) continue + ledger.accept(input, output) + plan.note(filename, output.cssFile) + } +} diff --git a/packages/next-plugin/src/coordinator-observation.ts b/packages/next-plugin/src/coordinator-observation.ts new file mode 100644 index 000000000..4aa4c9261 --- /dev/null +++ b/packages/next-plugin/src/coordinator-observation.ts @@ -0,0 +1,64 @@ +import { + buildEngine, + type ExtractOutputSnapshot, + locatedError, + type RebuildPlan, +} from './coordinator-engine' +import { immutableInput } from './coordinator-generation' +import { orderInputs } from './coordinator-ledger' +import type { + PreparedSourceGeneration, + PreparedSources, + ReplayPreparation, +} from './coordinator-options' + +export function createReplayObserver( + plan: Omit, +): NonNullable { + return (inputs, configure) => { + const ordered = Object.freeze(orderInputs(inputs.map(immutableInput))) + const outputs = new Map() + buildEngine({ ...plan, inputs: ordered, configure, outputs }) + return Object.freeze( + ordered.map(({ filename }) => + Object.freeze({ + filename, + ...(outputs.get(filename)?.resolutionInputs === undefined + ? {} + : { resolutionInputs: outputs.get(filename)?.resolutionInputs }), + dependencies: Object.freeze([ + ...(outputs.get(filename)?.dependencies ?? []), + ]), + }), + ), + ) + } +} + +export async function prepareObservedReplay( + prepared: PreparedSources, + request: ReplayPreparation, + plan: Omit, +): Promise { + const observe = createReplayObserver(plan) + let active = true + try { + return await prepared.prepareReplay({ + ...request, + observeExtraction(inputs, configure) { + request.signal.throwIfAborted() + if (!active) { + throw locatedError( + 'devup-ui.css', + 'observe an expired replay', + 'preparation already settled', + 'observe complete inputs only during the owning preparation call.', + ) + } + return observe(inputs, configure) + }, + }) + } finally { + active = false + } +} diff --git a/packages/next-plugin/src/coordinator-options.ts b/packages/next-plugin/src/coordinator-options.ts new file mode 100644 index 000000000..cc6a6ac2c --- /dev/null +++ b/packages/next-plugin/src/coordinator-options.ts @@ -0,0 +1,220 @@ +import type { SourceType } from '@devup-ui/plugin-utils' + +import type { ExtractResponse } from './coordinator-engine' +import type { ExtractRequest } from './coordinator-http' +import type { CoordinatorIdentity } from './coordinator-port' +import type { MdxInputFingerprint } from './mdx-source-freshness' +import type { CoordinatorInput } from './state' +import type { DevupWasm } from './wasm' + +export interface CssQuery { + fileNum?: number + importMainCss: boolean + wait: boolean +} + +export interface CssResponse { + css: string + /** Which completeness guarantee the body carries. */ + policy: 'dev-current' | 'production-complete' +} + +/** An app's coordinator without HTTP: what the endpoint calls. */ +export interface Core { + close(): void + extract(request: ExtractRequest): Promise + css(query: CssQuery): Promise + /** Resume from the checkpoint and commit the first state. */ + startup(): Promise + /** Reconcile with disk outside any request (a watcher fired). */ + reconcile(changedPaths?: readonly string[]): Promise + watchInputs?(): readonly string[] + onWatchInputs?(listener: (inputs: readonly string[]) => void): () => void + /** Resolves once every accepted state is on disk. */ + flush(): Promise +} + +export interface PrewarmedOutput { + code: string + cssFile?: string + map?: string + source: string + readonly sourceType?: SourceType + updatedBaseStyle: boolean + /** Files the extraction read through the module resolver */ + dependencies?: string[] + readonly resolutionInputs?: readonly MdxInputFingerprint[] +} + +export interface PreparedSourceEvidence { + readonly compilerFingerprint: string + readonly fileFingerprints: Readonly> + readonly contextFingerprints: Readonly> + readonly missingDependencies: readonly string[] + readonly map?: string +} + +export interface PreparedSource { + /** Exact pre-Devup compiled JS/JSX under its real source identity. */ + readonly input: CoordinatorInput + readonly evidence: PreparedSourceEvidence +} + +export interface PreparedSourceGeneration { + readonly sources: readonly PreparedSource[] + readonly ordinaryInputs?: readonly CoordinatorInput[] + readonly plan?: GenerationPlan + readonly watchInputs?: readonly string[] + readonly resolutionInputs?: readonly MdxInputFingerprint[] + /** Captures this generation's configuration AND prepared-source resolver. */ + readonly configureWasm: (wasm: DevupWasm) => void +} + +export interface GenerationPlan { + readonly canonicalMap: Readonly> + readonly expectedBaseFiles: readonly string[] +} + +export interface ReplayExtractionReport { + readonly filename: string + readonly dependencies: readonly string[] + readonly resolutionInputs?: readonly MdxInputFingerprint[] +} + +export interface ReplayPreparation { + readonly generation: PreparedSourceGeneration + readonly signal: AbortSignal + readonly changedPaths?: readonly string[] + readonly observeExtraction?: ( + inputs: readonly CoordinatorInput[], + configureWasm: PreparedSourceGeneration['configureWasm'], + ) => readonly ReplayExtractionReport[] +} + +export interface PreparedSources { + readonly validateForCssFinalization?: ( + generation: PreparedSourceGeneration, + ) => void + readonly initial: { + /** Ordinary inputs; compiled inputs belong to generation.sources. */ + readonly ordinaryInputs: readonly CoordinatorInput[] + /** options.wasm already holds this complete generation, including empty. */ + readonly generation: PreparedSourceGeneration + /** Includes allocator restoration and fresh prewarm changes. */ + readonly revision: number + } + /** + * Runs inside the mutation queue before every dev CSS/extract/watch replay. + * Return the supplied generation only after proving compiler/dependency + * freshness; otherwise return a complete replacement, never stale bytes + * stamped after compilation. Failures must reject and block the operation. + * The provider owns compilation, dependency evidence and bounded cancellation. + */ + readonly prepareReplay: ( + request: ReplayPreparation, + ) => Promise +} + +export interface CoordinatorOptions { + /** The engine serving requests until a rebuild swaps in a fresh one. */ + wasm: DevupWasm + package: string + cssDir: string + singleCss: boolean + /** @deprecated Persistence is the single `stateFile`; ignored. */ + sheetFile?: string + /** @deprecated Persistence is the single `stateFile`; ignored. */ + classMapFile?: string + /** @deprecated Persistence is the single `stateFile`; ignored. */ + fileMapFile?: string + importAliases: Record + /** Where the endpoint is published; also the identity of this coordinator. */ + coordinatorPortFile: string + /** + * Canonical (single-importer collapse) map: cwd-relative POSIX source path -> + * its canonical bucket path (or the `@global` sentinel). Used to wait for ALL + * members of a shared CSS bucket before serving it. Empty when collapse is + * disabled. + */ + canonicalMap: Record + /** + * Route-reachable source graph closure (cwd-relative POSIX): the files the + * production base stylesheet needs before it can be served. + */ + expectedBaseFiles?: string[] + /** + * Files a production prewarm extracted successfully. Providing it, even as + * `[]`, declares the planned stylesheet complete once those files are in; it + * is what makes a production CSS request servable without any loader POST. + */ + prewarmedFiles?: string[] + /** + * Production outputs extracted before Turbopack starts. A loader that + * sends byte-identical source gets the output back without a second + * extraction; the engine already holds its styles. + */ + prewarmedOutputs?: Map + /** Generate transform source maps. Defaults to true. */ + sourceMap?: boolean + /** + * Bound (ms) on how long a production `/css` waits for the planned files. + * Past it the request fails naming what is missing. Defaults to 60000. + */ + maxWaitMs?: number + /** Directory relative paths resolve against. Defaults to `process.cwd()`. */ + projectRoot?: string + /** + * Ownership of this coordinator. When given, every request must carry the + * matching `x-devup-project` / `x-devup-token` headers. Standalone callers + * may omit it: a random identity is made and headers are optional. + */ + identity?: CoordinatorIdentity + /** Development mode: watch sources, reconcile deletions and config. */ + watch?: boolean + /** The one file the engine state and input manifest are committed to. */ + stateFile?: string + /** A checkpoint written for a different key is ignored (cold start). */ + optionsKey?: string + /** Rewritten with a new revision after every CSS change; loaders watch it. */ + revisionFile?: string + /** The `devup.json` whose theme (and `extends` chain) is applied. */ + devupFile?: string + /** Directories whose changes can mean a source was deleted or renamed. */ + sourceRoots?: string[] + /** Applied to every engine this coordinator builds (prefix, hoisting...). */ + configureWasm?: (wasm: DevupWasm) => void + /** Makes a fresh engine for a rebuild. Defaults to `createWasm`. */ + createEngine?: () => DevupWasm + /** Bound on cached extraction outputs. Defaults to 4096. */ + cacheMaxEntries?: number + /** Complete fresh startup plus the compiler-owned development refresh seam. */ + readonly preparedSources?: PreparedSources +} + +/** Ownership transport whose complete Core options will arrive asynchronously. */ +export interface DeferredCoordinatorOptions { + readonly projectRoot: string + readonly identity: CoordinatorIdentity + readonly coordinatorPortFile: string + readonly prepare: (signal: AbortSignal) => Promise + /** Defaults to 50000ms, reserving the normal 60000ms CSS completion budget. */ + readonly maxPrepareMs?: number +} + +export type CoordinatorStartOptions = + CoordinatorOptions | DeferredCoordinatorOptions + +export interface CoordinatorHandle { + /** Resolves once the endpoint is published (or the coordinator was closed first). */ + readonly ready: Promise + /** Complete preparation and Core startup; optional for existing handle mocks. */ + readonly prepared?: Promise + /** Release this handle; the last one stops listening. Synchronous, no flushing. */ + close(): void + /** Stop admitting requests, wait for accepted work and writes, then close. */ + drain(): Promise +} + +export interface PreparedCoordinatorHandle extends CoordinatorHandle { + readonly prepared: Promise +} diff --git a/packages/next-plugin/src/coordinator-persistence.ts b/packages/next-plugin/src/coordinator-persistence.ts new file mode 100644 index 000000000..17c77fe10 --- /dev/null +++ b/packages/next-plugin/src/coordinator-persistence.ts @@ -0,0 +1,134 @@ +import { writeFileAtomically } from '@devup-ui/plugin-utils' + +import { + type CommitControl, + commitTransaction, + CompensationError, +} from './coordinator-transaction' +import { + type CoordinatorSnapshot, + CoordinatorStateError, + createSnapshotCommitter, + writeCoordinatorState, +} from './state' + +export interface Persistence { + assertHealthy(): void + /** Note that a new state was accepted and is not on disk yet. */ + accept(): void + /** Commit the state `capture` returns, covering everything accepted so far. */ + commit(capture: () => CoordinatorSnapshot): Promise + /** A replay candidate is isolated until durable; restore its predecessor if revision publication fails. */ + commitCandidate( + snapshot: CoordinatorSnapshot, + previous: CoordinatorSnapshot, + control?: CommitControl, + ): Promise + /** + * Commit the same way, but only if something accepted is not on disk yet: + * a repeated source is acknowledged once what came before it is durable, + * including after a write that failed. + */ + commitPending(capture: () => CoordinatorSnapshot): Promise + /** Rejects if the latest complete capture could not be committed. */ + drain(): Promise +} + +/** + * Where accepted state goes: one checkpoint file, plus a revision file that + * changes only when the revision does (loaders watch it for new CSS). + * Without either file nothing is persisted and nothing is waited for. + */ +export function createPersistence(files: { + readonly stateFile?: string + readonly revisionFile?: string +}): Persistence { + const { stateFile, revisionFile } = files + let writtenRevision: number | undefined + const predecessors = new WeakMap() + const committer = createSnapshotCommitter(async (snapshot) => { + if (stateFile !== undefined) { + try { + await writeCoordinatorState(stateFile, snapshot) + } catch (cause) { + throw new CoordinatorStateError( + stateFile, + 'could not be committed; it needs a writable checkpoint destination', + cause, + ) + } + } + if (revisionFile !== undefined && snapshot.revision !== writtenRevision) { + try { + await writeFileAtomically(revisionFile, String(snapshot.revision)) + } catch (cause) { + const previous = predecessors.get(snapshot) + if (stateFile !== undefined && previous !== undefined) { + await writeCoordinatorState(stateFile, previous) + } + throw new CoordinatorStateError( + revisionFile, + 'could not publish the committed revision; it needs a writable revision destination', + cause, + ) + } + writtenRevision = snapshot.revision + } + }) + const enabled = stateFile !== undefined || revisionFile !== undefined + let accepted = 0 + let persisted = 0 + let quarantine: CompensationError | undefined + const persistence: Persistence = { + assertHealthy() { + if (quarantine !== undefined) throw quarantine + }, + accept() { + accepted += 1 + }, + commit(capture) { + const covers = accepted + return enabled + ? committer.commit(capture).then(() => { + persisted = Math.max(persisted, covers) + }) + : Promise.resolve() + }, + async commitCandidate(snapshot, previous, control) { + if (quarantine !== undefined) throw quarantine + if (control !== undefined) { + await committer.drain() + try { + await commitTransaction(files, snapshot, { + signal: control.signal, + publish() { + control.publish() + writtenRevision = snapshot.revision + persisted = accepted + }, + }) + } catch (error) { + if (error instanceof CompensationError) quarantine = error + throw error + } + return + } + predecessors.set(snapshot, previous) + try { + await persistence.commit(() => snapshot) + } finally { + predecessors.delete(snapshot) + } + }, + commitPending(capture) { + return accepted > persisted + ? persistence.commit(capture) + : Promise.resolve() + }, + async drain() { + persistence.assertHealthy() + await committer.drain() + }, + } + return persistence +} diff --git a/packages/next-plugin/src/coordinator-plan-codec.ts b/packages/next-plugin/src/coordinator-plan-codec.ts new file mode 100644 index 000000000..6185647c5 --- /dev/null +++ b/packages/next-plugin/src/coordinator-plan-codec.ts @@ -0,0 +1,15 @@ +export function isGenerationPlan(value: unknown): boolean { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + return false + if (!('canonicalMap' in value) || !('expectedBaseFiles' in value)) + return false + const map = value.canonicalMap + return ( + typeof map === 'object' && + map !== null && + !Array.isArray(map) && + Object.values(map).every((bucket) => typeof bucket === 'string') && + Array.isArray(value.expectedBaseFiles) && + value.expectedBaseFiles.every((file) => typeof file === 'string') + ) +} diff --git a/packages/next-plugin/src/coordinator-plan.ts b/packages/next-plugin/src/coordinator-plan.ts new file mode 100644 index 000000000..bc997aae1 --- /dev/null +++ b/packages/next-plugin/src/coordinator-plan.ts @@ -0,0 +1,127 @@ +import { getFileNumByFilename } from '@devup-ui/plugin-utils' + +import { createCompletionTracker } from './coordinator-completion' +import type { ExtractOutputSnapshot } from './coordinator-engine' +import { locatedError } from './coordinator-engine' +import { HttpError } from './coordinator-http' +import type { CoordinatorOptions, GenerationPlan } from './coordinator-options' + +export interface ProductionPlan { + close(): void + succeed(file: string): void + fail(file: string, message: string): void + forget(file: string): void + /** Record which bucket the CSS file an extraction produced belongs to. */ + note(filename: string, cssFile: string | undefined): void + /** Resolves once everything the stylesheet for `fileNum` needs was extracted. */ + wait(fileNum: number | undefined): Promise + stage( + projection: GenerationPlan, + outputs: ReadonlyMap, + ): () => void +} + +type PlanOptions = Pick< + CoordinatorOptions, + 'canonicalMap' | 'expectedBaseFiles' | 'maxWaitMs' | 'prewarmedFiles' +> + +/** Canonical bucket -> every file whose CSS the bucket's chunk holds. */ +function bucketMembers( + canonicalMap: Readonly>, +): Map> { + const members = new Map>() + for (const [member, bucket] of Object.entries(canonicalMap)) { + // `@global` files contribute to the base sheet, not a numbered bucket. + if (bucket === '@global') continue + // The bucket root is itself a member of its own chunk. + const set = members.get(bucket) ?? new Set([bucket]) + members.set(bucket, set.add(member)) + } + return members +} + +/** + * What a production stylesheet is waiting for. The base sheet needs every + * expected file; a bucket needs all of its members. A plan with neither + * `prewarmedFiles` nor `expectedBaseFiles` cannot say when the base sheet is + * complete, so asking for it is an error rather than a guess. + */ +export function createProductionPlan( + options: PlanOptions, + projection?: GenerationPlan, +): ProductionPlan { + let canonicalMap: Readonly> = + projection?.canonicalMap ?? options.canonicalMap + let expectedBase: readonly string[] = + projection?.expectedBaseFiles ?? options.expectedBaseFiles ?? [] + let planned = + projection !== undefined || + options.prewarmedFiles !== undefined || + expectedBase.length > 0 + let buckets = bucketMembers(canonicalMap) + let fileNumToBucket = new Map() + const completion = createCompletionTracker(options.maxWaitMs ?? 60_000) + for (const file of options.prewarmedFiles ?? []) completion.succeed(file) + const completed = new Set(options.prewarmedFiles ?? []) + function note(filename: string, cssFile: string | undefined): void { + const fileNum = cssFile ? getFileNumByFilename(cssFile) : null + if (fileNum !== null) + fileNumToBucket.set(fileNum, canonicalMap[filename] ?? filename) + } + return { + close: () => completion.close(), + succeed(file) { + completed.add(file) + completion.succeed(file) + }, + fail: (file, message) => completion.fail(file, message), + forget: (file) => completion.forget(file), + note, + stage(projection, outputs) { + const nextBuckets = bucketMembers(projection.canonicalMap) + const nextFileNums = new Map() + for (const [file, output] of outputs) { + const fileNum = output.cssFile + ? getFileNumByFilename(output.cssFile) + : null + if (fileNum !== null) + nextFileNums.set(fileNum, projection.canonicalMap[file] ?? file) + } + return () => { + canonicalMap = projection.canonicalMap + expectedBase = projection.expectedBaseFiles + planned = true + buckets = nextBuckets + fileNumToBucket = nextFileNums + for (const file of completed) completion.forget(file) + completed.clear() + for (const file of outputs.keys()) { + completed.add(file) + completion.succeed(file) + } + } + }, + wait(fileNum) { + const bucket = + fileNum === undefined ? undefined : fileNumToBucket.get(fileNum) + if (bucket !== undefined) { + return completion.wait(`bucket ${bucket}`, [ + ...(buckets.get(bucket) ?? [bucket]), + ]) + } + if (!planned) { + throw new HttpError( + 500, + locatedError( + 'devup-ui.css', + 'serve a complete production stylesheet', + 'neither prewarmedFiles nor expectedBaseFiles says which files it needs', + 'prewarm the planned files and pass prewarmedFiles (an empty list is an empty stylesheet), or pass expectedBaseFiles.', + ).message, + ) + } + return completion.wait('the base stylesheet', expectedBase) + }, + } +} diff --git a/packages/next-plugin/src/coordinator-port.ts b/packages/next-plugin/src/coordinator-port.ts new file mode 100644 index 000000000..6c907722d --- /dev/null +++ b/packages/next-plugin/src/coordinator-port.ts @@ -0,0 +1,218 @@ +import { randomUUID } from 'node:crypto' +import { + existsSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { isAbsolute, join, resolve } from 'node:path' + +/** Logical build identity, shared by endpoint publication and HTTP requests. */ +export interface CoordinatorIdentity { + readonly project: string + readonly token: string +} + +export interface CoordinatorPortInfo extends CoordinatorIdentity { + readonly version: 1 + readonly port: number + readonly pid: number +} + +export class InvalidCoordinatorPortError extends Error { + constructor() { + super( + 'Invalid coordinator descriptor: needs version 1, a valid port/pid, canonical project and UUID token (at most 4096 bytes)', + ) + } +} + +export function formatPortFile( + port: number, + pid = process.pid, + identity: CoordinatorIdentity = { + project: resolve(process.cwd()), + token: randomUUID(), + }, +): string { + const text = JSON.stringify({ version: 1, port, pid, ...identity }) + parsePortFile(text) + return text +} + +export function parsePortFile(text: string): CoordinatorPortInfo { + if (Buffer.byteLength(text) > 4096) throw new InvalidCoordinatorPortError() + const data: unknown = JSON.parse(text) + if ( + typeof data !== 'object' || + data === null || + !('version' in data) || + data.version !== 1 || + !('port' in data) || + typeof data.port !== 'number' || + !Number.isInteger(data.port) || + data.port < 1 || + data.port > 65535 || + !('pid' in data) || + typeof data.pid !== 'number' || + !Number.isSafeInteger(data.pid) || + data.pid <= 0 || + !('project' in data) || + typeof data.project !== 'string' || + !isAbsolute(data.project) || + resolve(data.project) !== data.project || + !('token' in data) || + typeof data.token !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + data.token, + ) + ) + throw new InvalidCoordinatorPortError() + return { + version: 1, + port: data.port, + pid: data.pid, + project: data.project, + token: data.token, + } +} + +export function publishPortFile(path: string, info: CoordinatorPortInfo): void { + const text = formatPortFile(info.port, info.pid, info) + const temporary = `${path}.${randomUUID()}.tmp` + try { + writeFileSync(temporary, text, { encoding: 'utf-8', flag: 'wx' }) + renameSync(temporary, path) + } finally { + if (existsSync(temporary)) unlinkSync(temporary) + } +} + +/** Whether a process exists. EPERM means it exists but is not ours. */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'EPERM' + ) + } +} + +export function readOwner(portFile: string): CoordinatorPortInfo | undefined { + try { + return parsePortFile(readFileSync(portFile, 'utf-8')) + } catch { + return undefined + } +} + +/** A live coordinator owned by some other process, if the file names one. */ +export function foreignLiveOwner(portFile: string): number | undefined { + const pid = readOwner(portFile)?.pid + return pid !== undefined && pid !== process.pid && isProcessAlive(pid) + ? pid + : undefined +} + +/** + * The port file this process should use in `distDir`. + * + * `next dev` and `next build` in one directory used to share one file: the + * second one deleted the first one's port and overwrote it, and the first's + * loaders then talked to a dead port or the wrong coordinator. The shared name + * is kept when nobody else holds it (so a cache keyed on loader options stays + * stable in the common case); when a different live process owns it, this + * process gets its own `coordinator..port`. + */ +export function resolveCoordinatorPortFile(distDir: string): string { + const shared = join(distDir, 'coordinator.port') + return foreignLiveOwner(shared) === undefined + ? shared + : join(distDir, `coordinator.${process.pid}.port`) +} + +/** Remove the file unless a different live process owns it. */ +export function removeStalePortFile(portFile: string): void { + if (foreignLiveOwner(portFile) !== undefined) return + try { + unlinkSync(portFile) + } catch { + // Nothing to remove (first run). + } +} + +/** Remove the file only if this process wrote it. */ +export function removeOwnPortFile( + portFile: string, + identity?: CoordinatorIdentity, +): void { + const owner = readOwner(portFile) + if ( + owner?.pid !== process.pid || + (identity && + (owner.project !== identity.project || owner.token !== identity.token)) + ) + return + try { + unlinkSync(portFile) + } catch { + // Already gone. + } +} + +const CONNECTION_CODES = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'ETIMEDOUT', + 'EPIPE', + 'EHOSTUNREACH', +]) + +export function isConnectionError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + if ( + 'code' in error && + typeof error.code === 'string' && + CONNECTION_CODES.has(error.code) + ) + return true + // An AggregateError from `localhost` resolution carries its codes inside. + const inner = 'errors' in error ? error.errors : undefined + return Array.isArray(inner) && inner.some(isConnectionError) +} + +/** The port file is missing: say whose it should be and how to recover. */ +export function missingPortFileError(portFile: string): Error { + return new Error( + `Coordinator port file not found: ${portFile}. ` + + 'The devup-ui coordinator for this build is not running (or exited). ' + + 'Restart the dev server or build; if another `next dev`/`next build` ' + + 'uses the same distDir, stop it or give this one its own distDir.', + ) +} + +/** A coordinator that was running is not answering. */ +export function unreachableCoordinatorError( + portFile: string, + cause: unknown, +): Error { + const info = existsSync(portFile) ? readOwner(portFile) : undefined + const owner = + info?.pid === undefined + ? 'unknown owner' + : `owner pid ${info.pid} (${isProcessAlive(info.pid) ? 'running' : 'not running'})` + const detail = cause instanceof Error ? cause.message : String(cause) + const next = + info?.pid !== undefined && !isProcessAlive(info.pid) + ? 'The coordinator process has exited: restart the dev server or build.' + : 'Another process may have replaced the coordinator: restart the dev server or build, and avoid running `next dev` and `next build` in one distDir at the same time.' + return new Error( + `Coordinator unreachable at 127.0.0.1:${info?.port ?? '?'} (${owner}, port file ${portFile}): ${detail}. ${next}`, + ) +} diff --git a/packages/next-plugin/src/coordinator-preparation.ts b/packages/next-plugin/src/coordinator-preparation.ts new file mode 100644 index 000000000..59e851bd2 --- /dev/null +++ b/packages/next-plugin/src/coordinator-preparation.ts @@ -0,0 +1,160 @@ +import { resolve } from 'node:path' + +import { CoordinatorShutdownError } from './coordinator-completion' +import { createCore } from './coordinator-core' +import { locatedError } from './coordinator-engine' +import type { CoordinatorStartOptions, Core } from './coordinator-options' +import type { CoordinatorIdentity } from './coordinator-port' +import { type SourceWatcher, watchSources } from './coordinator-watch' + +export type PreparationState = + | { readonly status: 'pending' } + | { readonly status: 'ready'; readonly core: Core } + | { readonly status: 'failed'; readonly error: Error } + | { readonly status: 'cancelled'; readonly error: CoordinatorShutdownError } + +export function reportBackgroundError(error: unknown): void { + console.error( + '[devup-ui]', + error instanceof Error ? error.message : String(error), + ) +} + +/** Owns the one Core construction, startup and post-startup source watches. */ +export function createPreparation( + options: CoordinatorStartOptions, + identity: CoordinatorIdentity, +) { + const controller = new AbortController() + const settled = + Promise.withResolvers>() + let state: PreparationState = { status: 'pending' } + let core = + 'prepare' in options ? undefined : createCore(options, identity.project) + let watcher: SourceWatcher | undefined + let releaseWatchListener: (() => void) | undefined + let watchingEnabled = true + let started = false + let timer: ReturnType | undefined + + function stopWatching(): void { + watchingEnabled = false + watcher?.close() + releaseWatchListener?.() + } + + function finish( + next: Exclude, + ): void { + if (controller.signal.aborted) return + clearTimeout(timer) + state = next + settled.resolve(next) + } + + async function run(): Promise { + const complete = + 'prepare' in options ? await options.prepare(controller.signal) : options + if (controller.signal.aborted) return + if ( + 'prepare' in options && + (resolve(complete.projectRoot ?? process.cwd()) !== + resolve(options.projectRoot) || + resolve(options.projectRoot, complete.coordinatorPortFile) !== + resolve(options.projectRoot, options.coordinatorPortFile) || + complete.identity?.project !== identity.project || + complete.identity.token !== identity.token) + ) { + throw locatedError( + options.coordinatorPortFile, + 'prepare a coordinator', + 'prepared options changed transport ownership', + 'preserve projectRoot, identity and coordinatorPortFile.', + ) + } + const active = core ?? createCore(complete, identity.project) + core = active + await active.startup() + if (controller.signal.aborted) return + if (complete.watch && watchingEnabled) { + watcher = watchSources({ + roots: (complete.sourceRoots ?? []).map((dir) => + resolve(complete.projectRoot ?? process.cwd(), dir), + ), + debounceMs: 50, + onChange: (changedPaths) => + void active.reconcile(changedPaths).catch(reportBackgroundError), + onError: reportBackgroundError, + }) + watcher.replaceInputs?.(active.watchInputs?.() ?? []) + releaseWatchListener = active.onWatchInputs?.((inputs) => + watcher?.replaceInputs?.(inputs), + ) + } + finish({ status: 'ready', core: active }) + } + + return { + get state(): PreparationState { + return state + }, + start(): void { + if (started || controller.signal.aborted) return + started = true + if ('prepare' in options) { + timer = setTimeout(() => { + const error = locatedError( + options.coordinatorPortFile, + 'prepare a coordinator', + 'session preparation exceeded its time budget', + 'finish required preparation before the loader request deadline.', + ) + finish({ status: 'failed', error }) + controller.abort(error) + stopWatching() + core?.close() + }, options.maxPrepareMs ?? 50_000) + timer.unref() + } + void run().then(undefined, (error: unknown) => + finish({ + status: 'failed', + error: error instanceof Error ? error : new Error(String(error)), + }), + ) + }, + async wait(): Promise { + const outcome = await settled.promise + switch (outcome.status) { + case 'ready': + controller.signal.throwIfAborted() + return outcome.core + case 'failed': + case 'cancelled': + throw outcome.error + } + }, + close(): PreparationState { + if (controller.signal.aborted) return state + const error = new CoordinatorShutdownError('preparation') + clearTimeout(timer) + controller.abort(error) + stopWatching() + core?.close() + switch (state.status) { + case 'pending': + state = { status: 'cancelled', error } + settled.resolve(state) + return state + case 'ready': + case 'failed': + case 'cancelled': + return state + } + }, + stopWatching, + async flush(): Promise { + await core?.flush() + }, + } +} diff --git a/packages/next-plugin/src/coordinator-publication.ts b/packages/next-plugin/src/coordinator-publication.ts new file mode 100644 index 000000000..dc4d56986 --- /dev/null +++ b/packages/next-plugin/src/coordinator-publication.ts @@ -0,0 +1,90 @@ +import type { readConfigState } from './coordinator-config' +import type { ExtractOutputSnapshot } from './coordinator-engine' +import type { InputLedger } from './coordinator-ledger' +import type { PreparedSourceGeneration } from './coordinator-options' +import type { ProductionPlan } from './coordinator-plan' +import { CompensationError } from './coordinator-transaction' +import type { CoordinatorInput } from './state' +import type { DevupWasm } from './wasm' + +interface LiveState { + engine: DevupWasm + revision: number + generation: PreparedSourceGeneration | undefined + config: ReturnType | undefined +} + +interface PublicationOwner { + readonly live: LiveState + readonly ledger: InputLedger + readonly plan: ProductionPlan + readonly signal: AbortSignal + readonly onWatchInputs?: (inputs: readonly string[]) => void +} + +interface PublicationCandidate { + readonly engine: DevupWasm + readonly revision: number + readonly generation: PreparedSourceGeneration | undefined + readonly config: LiveState['config'] + readonly survivors: readonly CoordinatorInput[] + readonly removed: readonly CoordinatorInput[] + readonly outputs: ReadonlyMap + readonly transactional: boolean +} + +export function stagePublication( + owner: PublicationOwner, + candidate: PublicationCandidate, +): () => void { + const { live, ledger, plan, signal, onWatchInputs } = owner + const publishLedger = ledger.stage( + candidate.survivors, + candidate.generation?.ordinaryInputs !== undefined + ? candidate.outputs + : new Map(), + candidate.outputs, + ) + const publishPlan = + candidate.generation?.plan === undefined + ? undefined + : plan.stage(candidate.generation.plan, candidate.outputs) + const previousWatches = [ + ...(live.generation?.watchInputs ?? []), + ...ledger.resolutionInputs().map((input) => input.path), + ] + const nextWatches = [ + ...(candidate.generation?.watchInputs ?? []), + ...[...candidate.outputs.values()].flatMap((output) => + (output.resolutionInputs ?? []).map((input) => input.path), + ), + ] + return () => { + if (candidate.transactional && onWatchInputs !== undefined) { + try { + onWatchInputs(nextWatches) + signal.throwIfAborted() + } catch (cause) { + try { + onWatchInputs(previousWatches) + } catch (compensation) { + throw new CompensationError( + 'replay publication', + 'failed watch compensation; this owner must restart', + new AggregateError([cause, compensation]), + ) + } + throw cause + } + } + signal.throwIfAborted() + live.engine = candidate.engine + live.revision = candidate.revision + live.generation = candidate.generation + publishLedger() + publishPlan?.() + for (const input of candidate.removed) plan.forget(input.filename) + live.config = candidate.config ?? live.config + if (!candidate.transactional) onWatchInputs?.(nextWatches) + } +} diff --git a/packages/next-plugin/src/coordinator-replay.ts b/packages/next-plugin/src/coordinator-replay.ts new file mode 100644 index 000000000..c048b87d3 --- /dev/null +++ b/packages/next-plugin/src/coordinator-replay.ts @@ -0,0 +1,254 @@ +import { resolve } from 'node:path' + +import { CoordinatorShutdownError } from './coordinator-completion' +import { loadTheme, readConfigState } from './coordinator-config' +import { + buildEngine, + type ExtractOutputSnapshot, + type ExtractSettings, +} from './coordinator-engine' +import { + assertAdoptedRequest, + immutableGeneration, + immutableInput, + overlayGeneration, +} from './coordinator-generation' +import type { ExtractRequest } from './coordinator-http' +import { + createInputLedger, + isGone, + orderInputs, + seedPrewarmedOutputs, +} from './coordinator-ledger' +import { prepareObservedReplay } from './coordinator-observation' +import type { + CoordinatorOptions, + PreparedSourceGeneration, +} from './coordinator-options' +import { createPersistence } from './coordinator-persistence' +import { createProductionPlan } from './coordinator-plan' +import { stagePublication } from './coordinator-publication' +import { createReplayStartup } from './coordinator-startup' +import { + type AllocatorState, + captureCoordinatorState, + type CoordinatorInput, + exportAllocatorState, + readCoordinatorState, +} from './state' +import { createWasm } from './wasm' + +interface RebuildRequest { + readonly survivors: readonly CoordinatorInput[] + readonly removed: readonly CoordinatorInput[] + readonly allocator?: AllocatorState + readonly generation?: PreparedSourceGeneration + readonly config?: ReturnType +} + +/** Replay state is mutated only by Core's mutation queue. */ +export function createReplay( + options: CoordinatorOptions, + project: string, + onWatchInputs?: (inputs: readonly string[]) => void, +) { + const root = resolve(options.projectRoot ?? process.cwd()) + const optionsKey = options.optionsKey ?? '' + const resolveFile = (file: string | undefined) => + file === undefined ? undefined : resolve(root, file) + const stateFile = resolveFile(options.stateFile) + const devupFile = resolveFile(options.devupFile) + const settings: ExtractSettings = { + package: options.package, + cssDir: options.cssDir, + singleCss: options.singleCss, + sourceMap: options.sourceMap ?? true, + importAliases: options.importAliases, + } + const createEngine = options.createEngine ?? (() => createWasm(root)) + const fallbackConfigure = options.configureWasm ?? (() => undefined) + const prepared = options.preparedSources + const controller = new AbortController() + const ledger = createInputLedger(options.cacheMaxEntries ?? 4096) + const plan = createProductionPlan(options, prepared?.initial.generation.plan) + const persistence = createPersistence({ + stateFile, + revisionFile: resolveFile(options.revisionFile), + }) + const checkpoint = + prepared === undefined && options.watch && stateFile !== undefined + ? readCoordinatorState(stateFile, optionsKey) + : undefined + const live = { + engine: options.wasm, + revision: prepared?.initial.revision ?? checkpoint?.revision ?? 0, + config: devupFile === undefined ? undefined : readConfigState(devupFile), + generation: + prepared === undefined + ? undefined + : immutableGeneration(prepared.initial.generation), + } + const configure = (wasm: typeof live.engine) => + (live.generation?.configureWasm ?? fallbackConfigure)(wasm) + const snapshot = () => + captureCoordinatorState({ + wasm: live.engine, + optionsKey, + project, + revision: live.revision, + inputs: ledger.list(), + ...(live.generation?.plan === undefined + ? {} + : { plan: live.generation.plan }), + }) + + if (prepared !== undefined) { + ledger.replace( + orderInputs([ + ...prepared.initial.ordinaryInputs.map(immutableInput), + ...prepared.initial.generation.sources.map(({ input }) => + immutableInput(input), + ), + ]), + ) + } + seedPrewarmedOutputs(ledger, options, plan) + + async function rebuild(request: RebuildRequest): Promise { + controller.signal.throwIfAborted() + const generation = request.generation ?? live.generation + const transactional = + generation?.ordinaryInputs !== undefined || + ledger.resolutionInputs().length > 0 + if (transactional) await persistence.drain() + const outputs = new Map() + const fresh = buildEngine({ + createEngine, + live: live.engine, + configure: generation?.configureWasm ?? fallbackConfigure, + allocator: request.allocator ?? exportAllocatorState(live.engine), + theme: devupFile === undefined ? undefined : loadTheme(devupFile), + settings, + inputs: request.survivors, + outputs, + }) + const revision = live.revision + 1 + const publish = stagePublication( + { live, ledger, plan, signal: controller.signal, onWatchInputs }, + { + engine: fresh, + revision, + generation, + config: request.config, + survivors: request.survivors, + removed: request.removed, + outputs, + transactional, + }, + ) + controller.signal.throwIfAborted() + await persistence.commitCandidate( + captureCoordinatorState({ + wasm: fresh, + optionsKey, + project, + revision, + inputs: request.survivors, + ...(generation?.plan === undefined ? {} : { plan: generation.plan }), + }), + snapshot(), + transactional ? { signal: controller.signal, publish } : undefined, + ) + if (!transactional) publish() + } + + async function reconcile( + superseding?: ExtractRequest, + changedPaths?: readonly string[], + ): Promise { + if (!options.watch) return + controller.signal.throwIfAborted() + let generation = live.generation + if (prepared !== undefined && generation !== undefined) { + const next = await prepareObservedReplay( + prepared, + { + generation, + signal: controller.signal, + ...(changedPaths === undefined + ? {} + : { changedPaths: Object.freeze([...changedPaths]) }), + }, + { + createEngine, + live: live.engine, + allocator: exportAllocatorState(live.engine), + theme: devupFile === undefined ? undefined : loadTheme(devupFile), + settings, + }, + ) + controller.signal.throwIfAborted() + if (next !== generation) generation = immutableGeneration(next) + } + const next = + devupFile === undefined ? undefined : readConfigState(devupFile) + const configChanged = next?.signature !== live.config?.signature + const generationChanged = generation !== live.generation + const nextOwned = new Set( + generation?.sources.map(({ input }) => input.filename), + ) + const inputs = generationChanged + ? orderInputs( + generation === undefined + ? ledger.list() + : overlayGeneration(ledger.list(), live.generation, generation), + ) + : ledger.list() + if (superseding !== undefined && generation?.ordinaryInputs !== undefined) + assertAdoptedRequest(inputs, superseding) + const filenames = new Set(inputs.map((input) => input.filename)) + const removed = [ + ...inputs.filter(isGone), + ...ledger.list().filter((input) => !filenames.has(input.filename)), + ] + if ( + removed.length === 0 && + !configChanged && + !generationChanged && + !ledger.resolutionChanged() + ) + return + const drop = new Set(removed.map((input) => input.filename)) + if ( + superseding !== undefined && + !nextOwned.has(superseding.filename) && + generation?.ordinaryInputs === undefined + ) + drop.add(superseding.filename) + await rebuild({ + survivors: inputs.filter((input) => !drop.has(input.filename)), + removed, + generation, + config: next, + }) + } + + return { + root, + settings, + createEngine, + configure, + ledger, + plan, + persistence, + live, + snapshot, + reconcile, + close: () => + controller.abort(new CoordinatorShutdownError('replay preparation')), + startup: createReplayStartup(checkpoint, controller.signal, { + commit: () => persistence.commit(snapshot), + rebuild, + }), + } +} diff --git a/packages/next-plugin/src/coordinator-sealed.ts b/packages/next-plugin/src/coordinator-sealed.ts new file mode 100644 index 000000000..5062bf1ca --- /dev/null +++ b/packages/next-plugin/src/coordinator-sealed.ts @@ -0,0 +1,85 @@ +import { + extractRequest, + locatedError, + type RebuildPlan, +} from './coordinator-engine' +import type { ExtractRequest } from './coordinator-http' +import { + type CoordinatorSnapshot, + type JsonObject, + restoreCoordinatorState, +} from './state' +import type { DevupWasm } from './wasm' + +type SealedPlan = Pick< + RebuildPlan, + 'live' | 'createEngine' | 'configure' | 'settings' +> + +function sameCss(live: DevupWasm, candidate: DevupWasm): boolean { + const liveMap: JsonObject = JSON.parse(live.exportFileMap()) + const candidateMap: JsonObject = JSON.parse(candidate.exportFileMap()) + const buckets = new Set([ + undefined, + ...Object.values(liveMap).filter((value) => typeof value === 'number'), + ...Object.values(candidateMap).filter((value) => typeof value === 'number'), + ]) + // New numbers used to fall back to base CSS; compare them on both engines too. + for (const fileNum of buckets) { + for (const importMainCss of [false, true]) { + if ( + live.getCss(fileNum, importMainCss) !== + candidate.getCss(fileNum, importMainCss) + ) + return false + } + } + return true +} + +export function extractSealed( + plan: SealedPlan, + snapshot: CoordinatorSnapshot, + request: ExtractRequest, +) { + try { + const candidate = plan.createEngine() + if (candidate === plan.live) { + throw locatedError( + request.filename, + 'validate production re-extraction', + 'createEngine returned the engine in service', + 'createEngine must return a new, isolated engine on every call.', + ) + } + // Restore the actual sheet, not a replay: ordinary atoms and keyframes retain history. + restoreCoordinatorState(candidate, snapshot) + // Sheet exports omit theme, so frozen configuration must follow restoration. + plan.configure(candidate) + if (!sameCss(plan.live, candidate)) { + throw locatedError( + request.filename, + 'validate production re-extraction', + 'the restored candidate does not render the live stylesheet', + 'createEngine and configureWasm must restore an isolated engine with the same frozen production configuration.', + ) + } + const output = extractRequest(candidate, plan.settings, request) + if (!sameCss(plan.live, candidate)) { + throw locatedError( + request.filename, + 'change styles after the production stylesheet was served', + 'its extraction produced CSS the served stylesheet lacks', + 'include it in expectedBaseFiles (or the prewarm) so its styles exist before CSS is served.', + ) + } + return { engine: candidate, output: { ...output, updatedBaseStyle: false } } + } catch (error) { + throw locatedError( + request.filename, + 'validate production re-extraction', + error, + 'fix the reported source or the isolated engine factory; the served stylesheet must remain unchanged.', + ) + } +} diff --git a/packages/next-plugin/src/coordinator-startup.ts b/packages/next-plugin/src/coordinator-startup.ts new file mode 100644 index 000000000..740404b1f --- /dev/null +++ b/packages/next-plugin/src/coordinator-startup.ts @@ -0,0 +1,35 @@ +import { isCurrent } from './coordinator-ledger' +import type { + AllocatorState, + CoordinatorInput, + readCoordinatorState, +} from './state' + +interface StartupReplay { + readonly survivors: readonly CoordinatorInput[] + readonly removed: readonly CoordinatorInput[] + readonly allocator: AllocatorState +} + +export function createReplayStartup( + checkpoint: ReturnType, + signal: AbortSignal, + owner: { + readonly commit: () => Promise + readonly rebuild: (request: StartupReplay) => Promise + }, +): () => Promise { + return async () => { + signal.throwIfAborted() + if (checkpoint === undefined) { + await owner.commit() + signal.throwIfAborted() + return + } + await owner.rebuild({ + survivors: checkpoint.inputs.filter(isCurrent), + removed: [], + allocator: checkpoint, + }) + } +} diff --git a/packages/next-plugin/src/coordinator-transaction.ts b/packages/next-plugin/src/coordinator-transaction.ts new file mode 100644 index 000000000..3027b0fb9 --- /dev/null +++ b/packages/next-plugin/src/coordinator-transaction.ts @@ -0,0 +1,74 @@ +import { readFile, rm } from 'node:fs/promises' + +import { writeFileAtomically } from '@devup-ui/plugin-utils' + +import { type CoordinatorSnapshot, CoordinatorStateError } from './state' + +export interface TransactionFiles { + readonly stateFile?: string + readonly revisionFile?: string +} + +export interface CommitControl { + readonly signal: AbortSignal + readonly publish: () => void +} + +async function readPrevious(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') + return undefined + throw error + } +} + +export class CompensationError extends CoordinatorStateError { + constructor(stateFile: string, reason: string, cause?: unknown) { + super(stateFile, reason, cause) + } +} + +export async function commitTransaction( + files: TransactionFiles, + snapshot: CoordinatorSnapshot, + control: CommitControl, +): Promise { + const { signal } = control + signal.throwIfAborted() + const before = new Map() + for (const path of [files.stateFile, files.revisionFile]) { + if (path !== undefined) before.set(path, await readPrevious(path)) + } + signal.throwIfAborted() + const written: string[] = [] + try { + for (const [path, value] of [ + [files.stateFile, JSON.stringify(snapshot)], + [files.revisionFile, String(snapshot.revision)], + ] as const) { + if (path === undefined) continue + await writeFileAtomically(path, value) + written.push(path) + signal.throwIfAborted() + } + signal.throwIfAborted() + control.publish() + } catch (cause) { + try { + for (const path of written.reverse()) { + const previous = before.get(path) + if (previous === undefined) await rm(path, { force: true }) + else await writeFileAtomically(path, previous) + } + } catch (compensation) { + throw new CompensationError( + files.stateFile ?? files.revisionFile ?? 'devup-ui.css', + 'failed predecessor compensation; this owner is quarantined and must restart', + new AggregateError([cause, compensation]), + ) + } + throw cause + } +} diff --git a/packages/next-plugin/src/coordinator-watch.ts b/packages/next-plugin/src/coordinator-watch.ts new file mode 100644 index 000000000..feb776472 --- /dev/null +++ b/packages/next-plugin/src/coordinator-watch.ts @@ -0,0 +1,155 @@ +import { type FSWatcher, type Stats, statSync, watch } from 'node:fs' +import { dirname, relative, resolve, sep } from 'node:path' +import { clearTimeout, setTimeout } from 'node:timers' + +export interface SourceWatcher { + close(): void + replaceInputs?(inputs: readonly string[]): void +} + +/** + * Recursive source watches are backed by nonrecursive ancestor hints so a + * missing or replaced source directory can acquire a fresh inode watch. + * No watcher or debounce timer keeps the process alive. + */ +export function watchSources(options: { + readonly roots: readonly string[] + readonly debounceMs: number + onChange(changedPaths?: readonly string[]): void + onError(error: Error): void +}): SourceWatcher { + const originalRoots = options.roots.map((root) => resolve(root)) + let roots = [...new Set(originalRoots)] + const changedPaths = new Set() + const watchers = new Map< + string, + { + readonly path: string + readonly watcher: FSWatcher + readonly identity: string + } + >() + let closed = false + let timer: ReturnType | undefined + const report = (error: unknown) => { + if (!(error instanceof Error)) throw error + if (!('code' in error && error.code === 'ENOENT')) options.onError(error) + } + const directory = (path: string): Stats | undefined => { + try { + const stat = statSync(path, { throwIfNoEntry: false }) + return stat?.isDirectory() ? stat : undefined + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOTDIR') + return undefined + report(error) + return undefined + } + } + // Include the nearest existing ancestor's parent to observe its replacement + // too, without recursively walking unrelated output or dependency trees. + const boundaries = () => + roots.map((root) => { + let path = dirname(root) + while (!directory(path) && dirname(path) !== path) path = dirname(path) + return dirname(path) + }) + const schedule = (changed?: string) => { + if (closed) return + if (changed !== undefined) changedPaths.add(changed) + clearTimeout(timer) + timer = setTimeout(() => { + refresh() + const paths = Object.freeze([...changedPaths].sort()) + changedPaths.clear() + options.onChange(paths) + }, options.debounceMs) + timer.unref() + } + const attach = (path: string, recursive: boolean) => { + const key = `${recursive}:${path}` + const stat = directory(path) + const identity = stat && `${stat.dev}:${stat.ino}:${stat.birthtimeMs}` + const previous = watchers.get(key) + if (previous?.identity === identity) return + previous?.watcher.close() + watchers.delete(key) + if (!identity) return + try { + const watcher = watch(path, { recursive }, (_event, filename) => { + if (closed || watchers.get(key)?.watcher !== watcher) return + const changed = + filename === null ? path : resolve(path, filename.toString()) + if ( + recursive || + changed === path || + roots.some((root) => { + const child = relative(path, root).split(sep)[0] + return resolve(path, child) === changed + }) + ) { + refresh() + schedule(changed) + } + }) + watcher.on('error', (error) => { + if (closed || watchers.get(key)?.watcher !== watcher) return + watcher.close() + watchers.delete(key) + report(error) + schedule() + }) + watcher.unref() + watchers.set(key, { path, watcher, identity }) + } catch (error) { + report(error) + } + } + const refresh = () => { + const limits = boundaries() + // Release the old subtree before attaching any replacement ancestor: some + // watch backends share handles between overlapping directory watches. + for (const [key, entry] of watchers) { + const stat = directory(entry.path) + if ( + stat && + entry.identity === `${stat.dev}:${stat.ino}:${stat.birthtimeMs}` + ) + continue + entry.watcher.close() + watchers.delete(key) + } + roots.forEach((root, index) => { + const ancestors: string[] = [] + let path = dirname(root) + while (true) { + ancestors.push(path) + if (path === limits[index] || dirname(path) === path) break + path = dirname(path) + } + for (const ancestor of ancestors.reverse()) attach(ancestor, false) + attach(root, true) + }) + } + refresh() + return { + replaceInputs(inputs) { + if (closed) return + roots = [ + ...new Set([ + ...originalRoots, + ...inputs.map((input) => resolve(input)), + ]), + ] + for (const { watcher } of watchers.values()) watcher.close() + watchers.clear() + refresh() + }, + close() { + closed = true + clearTimeout(timer) + for (const { watcher } of watchers.values()) watcher.close() + watchers.clear() + }, + } +} diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 17b9c7f54..b5a40f938 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -1,716 +1,98 @@ -import { unlinkSync, writeFile, writeFileSync } from 'node:fs' -import { createServer, type IncomingMessage, type Server } from 'node:http' -import { basename, dirname, join, relative } from 'node:path' - -import { getFileNumByFilename } from '@devup-ui/plugin-utils' - -import { elapsedMs, profileStart, reportProfile } from './profile' -import type { DevupWasm } from './wasm' - -export interface CoordinatorOptions { - wasm: DevupWasm - package: string - cssDir: string - singleCss: boolean - sheetFile: string - classMapFile: string - fileMapFile: string - importAliases: Record - coordinatorPortFile: string - /** - * Canonical (single-importer collapse) map: cwd-relative POSIX source path -> - * its canonical bucket path (or the `@global` sentinel). Used to wait for ALL - * members of a shared CSS bucket before serving it, instead of guessing - * completion from idle time. Empty when collapse is disabled. - */ - canonicalMap: Record - /** - * Route-reachable source graph closure (cwd-relative POSIX). Used both as the - * production prewarm target and as the deterministic base-css completion - * signal. It may include imports Turbopack later erases, but prewarming still - * extracts those files into the sheet. Empty when no routes are detected or - * the best-effort graph pass failed, in which case the legacy idle heuristic - * below is the fallback. - */ - expectedBaseFiles?: string[] - /** - * Files synchronously extracted before a production Turbopack build starts. - * They seed completion tracking because their atoms already exist in the - * shared WASM sheet even though their loaders have not POSTed `/extract` yet. - */ - prewarmedFiles?: string[] - /** - * Production outputs extracted before Turbopack starts. A loader that - * receives byte-identical source can return this result without a second - * WASM extraction; the shared sheet is already populated. - */ - prewarmedOutputs?: Map - /** Generate transform source maps. Defaults to true for existing callers. */ - sourceMap?: boolean - /** - * Idle threshold (ms) for the base-css `/css` wait. Defaults to 2500. - * FALLBACK ONLY — used when `expectedBaseFiles` is empty (no deterministic - * signal available). Exposed for tests; the plugin omits it. - */ - idleThresholdMs?: number - /** - * Legacy fail-open window (ms) used when completion was not prewarmed and a - * graph member never reports. A quiet window cannot prove that Turbopack is - * finished scheduling loaders, so the production plugin avoids depending on - * this heuristic by extracting its complete route closure up front. Defaults - * to 10000. Exposed for tests; the plugin omits it. - */ - quietMs?: number - /** - * Hard timeout (ms) for both the idle and per-bucket waits before failing - * open. Defaults to 60000. Exposed for tests; the plugin omits it. - */ - maxWaitMs?: number -} - -export interface PrewarmedOutput { - code: string - cssFile?: string - map?: string - source: string - updatedBaseStyle: boolean - /** Files the extraction read through the module resolver */ - dependencies?: string[] -} - -interface ExtractOutputSnapshot extends Omit { - css?: string +import { resolve } from 'node:path' + +import { + type CoordinatorInstance, + createInstance, +} from './coordinator-instance' +import type { + CoordinatorStartOptions, + PreparedCoordinatorHandle, +} from './coordinator-options' + +export { takeExtractOutput } from './coordinator-engine' +export type { + CoordinatorHandle, + CoordinatorOptions, + CoordinatorStartOptions, + DeferredCoordinatorOptions, + PreparedCoordinatorHandle, + PrewarmedOutput, +} from './coordinator-options' + +interface Entry { + readonly instance: CoordinatorInstance + refs: number } -/** Copy every WASM-backed getter once, then release its Rust allocation. */ -export function takeExtractOutput( - output: ReturnType, -): ExtractOutputSnapshot { - try { - return { - code: output.code, - css: output.css, - cssFile: output.cssFile, - map: output.map, - updatedBaseStyle: output.updatedBaseStyle, - dependencies: output.dependencies, - } - } finally { - output.free() +// The only module-level state: which endpoints have a live coordinator, so a +// config evaluated twice for the same app shares one and an unrelated app is +// never touched. Extraction state lives in each instance. +const registry = new Map() + +function entryFor(key: string, options: CoordinatorStartOptions): Entry { + const existing = registry.get(key) + if (existing === undefined) { + const entry = { instance: createInstance(options), refs: 0 } + registry.set(key, entry) + return entry } -} - -// Latest-Wins Coalescing Serializer. -// -// Multiple Turbopack workers may call /extract concurrently, each producing -// CSS for the same target file (especially `devup-ui.css` in singleCss mode -// where every file writes to it). Naive `writeFile` calls run in parallel via -// libuv's thread pool with no completion-order guarantees, so a stale snapshot -// can clobber a fresher one — leaving the on-disk CSS missing rules whose -// class names already landed in the JSX markup. -// -// `safeWrite` solves this with a per-path FIFO chain + content coalescing: -// 1. Each call records the latest content for the path (overwrites earlier). -// 2. The next disk write is chained after the previous one for the same -// path, guaranteeing serial execution in invocation order. -// 3. When the chained write actually runs, it pulls the most recent content -// (not the original captured value), so intermediate snapshots between -// enqueue-time and run-time are coalesced into a single write. -// -// Net effect: race becomes mathematically impossible (single-threaded JS + -// FIFO queue), and total disk IO drops dramatically because N stale snapshots -// for the same file are collapsed into 1 effective write. -const writeChain = new Map>() -const latestContent = new Map() - -function safeWrite(path: string, content: string): Promise { - // Always record the most recent content for this path so a queued write - // picks up the latest snapshot when it runs. - latestContent.set(path, content) - - // Swallow any prior error solely for chaining purposes — the actual caller - // that hit the error already saw it via the returned promise, but we must - // not let one failure poison every subsequent write for this path. - const prev = (writeChain.get(path) ?? Promise.resolve()).catch(() => {}) - - const next = prev.then( - () => - new Promise((resolve, reject) => { - const final = latestContent.get(path) - if (final === undefined) { - // An earlier chained run already consumed the latest content for - // this path; nothing new to write. Resolve as a no-op. - resolve() - return - } - latestContent.delete(path) - writeFile(path, final, 'utf-8', (err) => - err ? reject(err) : resolve(), - ) - }), - ) - - writeChain.set(path, next) - return next -} - -// Best-effort drain of every pending write. Used on coordinator close so the -// build process does not exit with stale files mid-flight. -function flushPendingWrites(): Promise { - return Promise.allSettled([...writeChain.values()]).then(() => undefined) -} - -function readBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = [] - req.on('data', (chunk: Buffer) => chunks.push(chunk)) - req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))) - req.on('error', reject) - }) -} - -let server: Server | null = null - -// Extraction tracking for waitForIdle. -// -// The CSS loader fetches the live sheet from `/css?waitForIdle=true`. In -// production builds the response of that call is what Turbopack bundles -// for the `devup-ui.css` module — there is no second chance. So waitForIdle -// must NOT resolve while there are still extractions in flight, or that -// have not yet started but will start soon. -// -// We track two complementary signals: -// * activeExtractions / lastCompletedAt → are extractions currently happening? -// * pendingExtractStarts → did anyone POST /extract that hasn't progressed -// to `activeExtractions++` yet (e.g. still inside `await readBody(req)`)? -// -// IDLE_THRESHOLD_MS is increased so an early `/css` request — triggered by -// the first .tsx loader resolving its import graph before the rest of the -// route's files are processed — cannot resolve in the gap between two -// extraction batches. Empirically the gap between Turbopack extraction -// "waves" in a 64-route landing build can exceed the previous 500ms, which -// caused the snapshot to capture only the early routes' styles. -let activeExtractions = 0 -let totalExtractions = 0 -let lastCompletedAt = 0 -let pendingExtractStarts = 0 -let idleThresholdMs = 2500 -let quietMs = 10_000 -let maxWaitMs = 60_000 - -// Legacy fail-open signal for callers that could not prewarm a deterministic -// file set. It only observes extraction traffic; it is NOT a Turbopack -// compilation-complete signal. Production builds seed `extractedFiles` with -// their prewarmed route closure and therefore do not depend on this path. -function bundlerQuiet(now: number): boolean { - return ( - totalExtractions > 0 && - activeExtractions === 0 && - pendingExtractStarts === 0 && - now - lastCompletedAt >= quietMs - ) -} - -function baseFilesComplete(): boolean { - // Deterministic: the base sheet is complete once every route-reachable file - // has been extracted by either the production prewarm or `/extract`. - if (expectedBaseFiles.size === 0) return false - for (const file of expectedBaseFiles) { - if (!extractedFiles.has(file)) return false - } - return true -} - -function waitForBase(): Promise { - const start = Date.now() - return new Promise((resolve) => { - const check = () => { - const now = Date.now() - if (now - start > maxWaitMs) { - // Last-resort backstop (see waitForBucket). Never fires on a healthy - // build: either every expected file extracts, or the idle fallback - // resolves first. - resolve() - return - } - // Primary, deterministic path. - if (baseFilesComplete()) { - resolve() - return - } - // Legacy fail-open for a caller that supplied expected files without - // prewarming them. The Next plugin's production path completes above. - if (expectedBaseFiles.size > 0 && bundlerQuiet(now)) { - resolve() - return - } - // Fallback ONLY when no deterministic signal exists (no routes detected / - // pre-pass failed -> expectedBaseFiles empty): the legacy idle heuristic. - if ( - expectedBaseFiles.size === 0 && - totalExtractions > 0 && - activeExtractions === 0 && - pendingExtractStarts === 0 && - now - lastCompletedAt >= idleThresholdMs - ) { - resolve() - return - } - setTimeout(check, 25) - } - check() - }) -} - -// Per-bucket completion tracking (deterministic replacement for waitForIdle on -// collapsed chunks). -// -// Single-importer collapse merges several source files into ONE shared CSS -// chunk (a "bucket"). That chunk is only complete once EVERY member file has -// been extracted. Turbopack, however, may request the chunk as soon as ONE -// member's import resolves. The old global idle heuristic guessed completion -// and dropped late members' atoms when extraction "waves" exceeded the idle -// threshold (flaky CI rendering). Instead we wait for the bucket's KNOWN -// members (from the canonical map) — no guessing, no extra extraction. -const extractedFiles = new Set() -const fileNumToBucket = new Map() -let bucketToMembers = new Map>() -let canonicalMapRef: Record = {} -// Route-reachable runtime files the base sheet must wait for (cwd-relative -// POSIX). When populated, base-css completion is deterministic; empty falls back -// to the idle heuristic. See CoordinatorOptions.expectedBaseFiles. -let expectedBaseFiles = new Set() - -function buildBucketToMembers( - canonicalMap: Record, -): Map> { - const map = new Map>() - for (const [member, bucket] of Object.entries(canonicalMap)) { - // `@global` files contribute to the base sheet, not a numbered bucket. - if (bucket === '@global') continue - let members = map.get(bucket) - if (!members) { - // The bucket root is itself a member of its own chunk. - members = new Set([bucket]) - map.set(bucket, members) - } - members.add(member) + const { project, token } = existing.instance.identity + if ( + options.identity?.project !== project || + options.identity.token !== token + ) { + throw new Error( + `${key}:1:1: devup-ui coordinator cannot start: another coordinator (project ${project}) already owns this endpoint. Fix: give each app its own distDir/port file, or pass the same identity to share one coordinator.`, + ) } - return map + return existing } -function waitForBucket(bucket: string): Promise { - const members = bucketToMembers.get(bucket) ?? new Set([bucket]) - const start = Date.now() - return new Promise((resolve) => { - const check = () => { - let allExtracted = true - for (const member of members) { - if (!extractedFiles.has(member)) { - allExtracted = false - break - } - } - if (allExtracted) { - resolve() - return - } - const now = Date.now() - // Legacy fail-open for a caller that did not seed the bucket through - // prewarming. Quiet time alone cannot distinguish an erased import from - // a later Turbopack extraction wave; production builds complete via the - // allExtracted branch above. - if (bundlerQuiet(now)) { - const missing = [...members].filter((m) => !extractedFiles.has(m)) - console.info( - `[devup-ui] coordinator: serving bucket "${bucket}" through the legacy quiet fallback; treating unreported member(s) as erased imports: ${missing.join(', ')}`, - ) - resolve() - return - } - if (now - start > maxWaitMs) { - // Last-resort backstop only — NOT the primary completion mechanism. - // Fires only when extraction traffic never pauses for `quietMs` - // within the whole window (a continuously busy build with a genuinely - // missing member). Turbopack exposes no compilation-complete hook, so - // a timer is the only available backstop against hanging forever. - const missing = [...members].filter((m) => !extractedFiles.has(m)) - console.warn( - `[devup-ui] coordinator: bucket "${bucket}" not complete after ${maxWaitMs}ms; serving partial CSS (missing: ${missing.join(', ')})`, - ) - resolve() - return - } - setTimeout(check, 25) - } - check() - }) -} - -export function startCoordinator(options: CoordinatorOptions): { - close: () => void -} { - // Next may evaluate its config more than once in the same process. Close the - // previous listener before replacing it so its HTTP server and request - // closure do not remain live for the rest of the build. - if (server) { - server.close() - server = null +/** + * Start (or join) the coordinator for `options.coordinatorPortFile`. Handles + * for the same endpoint and identity share one instance, which stops when the + * last handle is released. + */ +export function startCoordinator( + options: CoordinatorStartOptions, +): PreparedCoordinatorHandle { + const key = resolve( + options.projectRoot ?? process.cwd(), + options.coordinatorPortFile, + ) + const entry = entryFor(key, options) + entry.refs += 1 + let released = false + /** Whether this handle held the last reference. */ + function release(): boolean { + if (released) return false + released = true + entry.refs -= 1 + if (entry.refs > 0) return false + if (registry.get(key) === entry) registry.delete(key) + return true } - const { - wasm, - package: libPackage, - cssDir, - singleCss, - sheetFile, - classMapFile, - fileMapFile, - importAliases, - coordinatorPortFile, - } = options - const { - codeExtract, - codeExtractWithoutSourceMap, - exportClassMap, - exportFileMap, - exportSheet, - getCss, - } = wasm - const prewarmedOutputs = options.prewarmedOutputs ?? new Map() - const extract = - options.sourceMap === false ? codeExtractWithoutSourceMap : codeExtract - - idleThresholdMs = options.idleThresholdMs ?? 2500 - quietMs = options.quietMs ?? 10_000 - maxWaitMs = options.maxWaitMs ?? 60_000 - canonicalMapRef = options.canonicalMap - bucketToMembers = buildBucketToMembers(options.canonicalMap) - expectedBaseFiles = new Set(options.expectedBaseFiles ?? []) - extractedFiles.clear() - for (const file of options.prewarmedFiles ?? []) extractedFiles.add(file) - fileNumToBucket.clear() - - const coordinatorServer = createServer(async (req, res) => { - const url = new URL(req.url ?? '/', `http://${req.headers.host}`) - - if (req.method === 'GET' && url.pathname === '/health') { - res.writeHead(200, { 'Content-Type': 'text/plain' }) - res.end('ok') - return - } - - if (req.method === 'GET' && url.pathname === '/css') { - const cssStartedAt = profileStart() - const fileNumParam = url.searchParams.get('fileNum') - const importMainCss = url.searchParams.get('importMainCss') === 'true' - const shouldWait = url.searchParams.get('waitForIdle') === 'true' - const fileNum = fileNumParam != null ? parseInt(fileNumParam) : undefined - - if (shouldWait) { - if (fileNum != null && fileNumToBucket.has(fileNum)) { - // Deterministic: block until every member of this collapsed bucket - // has been extracted, then serve the complete chunk. - await waitForBucket(fileNumToBucket.get(fileNum)!) - } else { - // Base css (no fileNum) or a bucket no member has reported yet: - // wait for the deterministic route-reachable file set (idle fallback - // only when that set is unavailable). - await waitForBase() - } - } - - res.writeHead(200, { 'Content-Type': 'text/css' }) - res.end(getCss(fileNum ?? null, importMainCss)) - reportProfile('coordinator.css', { - durationMs: elapsedMs(cssStartedAt), - fileNum, - waitForIdle: shouldWait, - }) - return - } - - if (req.method === 'POST' && url.pathname === '/extract') { - const requestStartedAt = profileStart() - // Reserve a "start slot" before yielding on `await readBody`. Without - // this counter, `waitForIdle` could observe activeExtractions=0 in the - // window between the request hitting this handler and `activeExtractions++` - // below — making it falsely conclude the build is idle even though - // more extractions are imminent. - pendingExtractStarts++ - let promotedToActive = false - let extractedFilename: string | undefined - try { - const bodyStartedAt = profileStart() - const body = JSON.parse(await readBody(req)) - const bodyDurationMs = elapsedMs(bodyStartedAt) - activeExtractions++ - pendingExtractStarts-- - promotedToActive = true - const { filename, code, resourcePath } = body as { - filename: string - code: string - resourcePath: string - } - extractedFilename = filename - - let relCssDir = relative(dirname(resourcePath), cssDir).replaceAll( - '\\', - '/', - ) - if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - - // The production prewarm exists to make the CSS snapshot complete - // before Turbopack requests it. The generated CSS is already in that - // snapshot, so re-running WASM here is pure work in either CSS mode. - // Require exact source equality because Turbopack may hand a loader - // code modified by an earlier transform. - const prewarmed = prewarmedOutputs.get(filename) - const cacheHit = prewarmed?.source === code - const extractStartedAt = profileStart() - const result = cacheHit - ? prewarmed - : takeExtractOutput( - extract( - filename, - code, - libPackage, - relCssDir, - singleCss, - false, - true, - importAliases, - ), - ) - const extractDurationMs = elapsedMs(extractStartedAt) - - // When singleCss=false, rewrite per-file CSS imports so Turbopack can resolve them. - // Instead of importing "devup-ui-79.css" (which doesn't exist as a resolvable module), - // rewrite to "devup-ui.css?fileNum=79" — the placeholder file exists and the query - // makes each import a unique module for Turbopack. - let transformedCode = result.code - if (!singleCss && transformedCode) { - transformedCode = transformedCode.replace( - /devup-ui-(\d+)\.css/g, - 'devup-ui.css?fileNum=$1', - ) - } - - const snapshotStartedAt = profileStart() - let classMapSnapshotBytes: number | undefined - let classMapSnapshotMs: number | undefined - let cssSnapshotBytes: number | undefined - let cssSnapshotMs: number | undefined - let fileMapSnapshotBytes: number | undefined - let fileMapSnapshotMs: number | undefined - let sheetSnapshotBytes: number | undefined - let sheetSnapshotMs: number | undefined - const promises: Promise[] = [] - - if (!cacheHit && result.updatedBaseStyle) { - const cssStartedAt = - snapshotStartedAt === undefined ? undefined : performance.now() - const css = `${getCss(null, false)}\n/* ${Date.now()} */` - const cssDurationMs = - cssStartedAt === undefined ? undefined : elapsedMs(cssStartedAt) - if (cssDurationMs !== undefined) { - cssSnapshotMs = (cssSnapshotMs ?? 0) + cssDurationMs - cssSnapshotBytes = (cssSnapshotBytes ?? 0) + Buffer.byteLength(css) - } - promises.push(safeWrite(join(cssDir, 'devup-ui.css'), css)) - } - - if (result.cssFile) { - const fileNum = getFileNumByFilename(result.cssFile) - if (fileNum != null) { - // Record this bucket's fileNum -> canonical bucket path so /css can - // wait for the bucket's members before serving it. - fileNumToBucket.set(fileNum, canonicalMapRef[filename] ?? filename) - } - } - - if (!cacheHit && result.cssFile) { - const fileNum = getFileNumByFilename(result.cssFile) - const cssStartedAt = - snapshotStartedAt === undefined ? undefined : performance.now() - const css = getCss(fileNum, true) - const cssDurationMs = - cssStartedAt === undefined ? undefined : elapsedMs(cssStartedAt) - if (cssDurationMs !== undefined) { - cssSnapshotMs = (cssSnapshotMs ?? 0) + cssDurationMs - cssSnapshotBytes = (cssSnapshotBytes ?? 0) + Buffer.byteLength(css) - } - - const sheetStartedAt = - snapshotStartedAt === undefined ? undefined : performance.now() - const sheet = exportSheet() - sheetSnapshotMs = - sheetStartedAt === undefined ? undefined : elapsedMs(sheetStartedAt) - if (snapshotStartedAt !== undefined) { - sheetSnapshotBytes = Buffer.byteLength(sheet) - } - - const classMapStartedAt = - snapshotStartedAt === undefined ? undefined : performance.now() - const classMap = exportClassMap() - classMapSnapshotMs = - classMapStartedAt === undefined - ? undefined - : elapsedMs(classMapStartedAt) - if (snapshotStartedAt !== undefined) { - classMapSnapshotBytes = Buffer.byteLength(classMap) - } - - const fileMapStartedAt = - snapshotStartedAt === undefined ? undefined : performance.now() - const fileMap = exportFileMap() - fileMapSnapshotMs = - fileMapStartedAt === undefined - ? undefined - : elapsedMs(fileMapStartedAt) - if (snapshotStartedAt !== undefined) { - fileMapSnapshotBytes = Buffer.byteLength(fileMap) - } - promises.push( - safeWrite(join(cssDir, basename(result.cssFile)), css), - safeWrite(sheetFile, sheet), - safeWrite(classMapFile, classMap), - safeWrite(fileMapFile, fileMap), - ) - - // In non-singleCss mode, imports are rewritten from devup-ui-N.css to - // devup-ui.css?fileNum=N (line 142). Turbopack watches devup-ui.css for - // all these modules, but above we only write devup-ui-N.css. Without - // updating devup-ui.css, Turbopack never re-runs the css-loader and - // new CSS rules are invisible to the browser. - // When updatedBaseStyle is true, devup-ui.css is already written above. - if (!singleCss && !result.updatedBaseStyle && result.css != null) { - const cssStartedAt = - snapshotStartedAt === undefined ? undefined : performance.now() - const baseCss = `${getCss(null, false)}\n/* ${Date.now()} */` - const cssDurationMs = - cssStartedAt === undefined ? undefined : elapsedMs(cssStartedAt) - if (cssDurationMs !== undefined) { - cssSnapshotMs = (cssSnapshotMs ?? 0) + cssDurationMs - cssSnapshotBytes = - (cssSnapshotBytes ?? 0) + Buffer.byteLength(baseCss) - } - promises.push(safeWrite(join(cssDir, 'devup-ui.css'), baseCss)) - } - } - - const snapshotDurationMs = elapsedMs(snapshotStartedAt) - const writeStartedAt = profileStart() - await Promise.all(promises) - - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify({ - code: transformedCode, - map: result.map, - cssFile: result.cssFile, - updatedBaseStyle: result.updatedBaseStyle, - dependencies: result.dependencies, - }), - ) - reportProfile('coordinator.extract', { - bodyMs: bodyDurationMs, - cacheHit, - classMapSnapshotBytes, - classMapSnapshotMs, - cssSnapshotBytes, - cssSnapshotMs, - durationMs: elapsedMs(requestStartedAt), - extractMs: extractDurationMs, - fileMapSnapshotBytes, - fileMapSnapshotMs, - filename, - sheetSnapshotBytes, - sheetSnapshotMs, - sourceBytes: - requestStartedAt === undefined - ? undefined - : Buffer.byteLength(code), - scheduledWrites: promises.length, - snapshotMs: snapshotDurationMs, - writeMs: elapsedMs(writeStartedAt), - }) - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify({ - error: error instanceof Error ? error.message : String(error), - }), - ) - } finally { - if (promotedToActive) { - activeExtractions-- - } else { - // readBody/JSON.parse threw before we promoted to active, so the - // pending slot is still ours to release. - pendingExtractStarts-- - } - // Mark the file processed (success OR failure) so per-bucket waiters - // never hang on a file that errored — fail open, like the idle path. - if (extractedFilename != null) extractedFiles.add(extractedFilename) - totalExtractions++ - lastCompletedAt = Date.now() - } - return - } - - res.writeHead(404, { 'Content-Type': 'text/plain' }) - res.end('Not Found') - }) - - server = coordinatorServer - coordinatorServer.listen(0, '127.0.0.1', () => { - const addr = coordinatorServer.address() - if (addr && typeof addr !== 'string') { - writeFileSync(coordinatorPortFile, String(addr.port), 'utf-8') - } - }) - return { - close: () => { - // Fire-and-forget drain of any in-flight serialized writes so the - // last-written CSS reflects the final sheet state, even though - // `close` itself returns synchronously (it is invoked from - // `process.on('exit', ...)` where awaiting is not possible). - void flushPendingWrites() - coordinatorServer.close() - if (server === coordinatorServer) { - server = null - try { - unlinkSync(coordinatorPortFile) - } catch { - // ignore if already deleted - } - } + ready: entry.instance.ready, + prepared: entry.instance.prepared, + close() { + if (release()) entry.instance.close() + }, + async drain() { + if (release()) await entry.instance.drain() }, } } -/** @internal Wait for every pending serialized write to settle. */ -export const flushCoordinatorWrites = (): Promise => flushPendingWrites() +/** @internal Wait for every live coordinator's accepted writes to land. */ +export async function flushCoordinatorWrites(): Promise { + await Promise.all( + [...registry.values()].map(({ instance }) => instance.flush()), + ) +} -/** @internal Reset coordinator state for testing purposes only */ -export const resetCoordinator = () => { - if (server) { - server.close() - server = null - } - activeExtractions = 0 - totalExtractions = 0 - lastCompletedAt = 0 - pendingExtractStarts = 0 - idleThresholdMs = 2500 - quietMs = 10_000 - maxWaitMs = 60_000 - extractedFiles.clear() - fileNumToBucket.clear() - bucketToMembers = new Map() - canonicalMapRef = {} - expectedBaseFiles = new Set() - writeChain.clear() - latestContent.clear() +/** @internal Stop every live coordinator (tests). */ +export function resetCoordinator(): void { + for (const { instance } of registry.values()) instance.close() + registry.clear() } diff --git a/packages/next-plugin/src/css-loader.ts b/packages/next-plugin/src/css-loader.ts index 1b37059e8..eb00b3580 100644 --- a/packages/next-plugin/src/css-loader.ts +++ b/packages/next-plugin/src/css-loader.ts @@ -1,15 +1,27 @@ import { existsSync, readFileSync } from 'node:fs' -import { Agent, request } from 'node:http' +import { resolve } from 'node:path' -import { getFileNumByFilename } from '@devup-ui/plugin-utils' +import { + collectDevupConfigFiles, + getFileNumByFilename, +} from '@devup-ui/plugin-utils' import type { RawLoaderDefinitionFunction } from 'webpack' +import { + CoordinatorRequestError, + requestCoordinator, +} from './coordinator-client' +import type { CoordinatorIdentity } from './coordinator-port' import { loadWasm } from './wasm' export interface DevupUICssLoaderOptions { - // turbo watch: boolean coordinatorPortFile?: string + coordinatorIdentity?: CoordinatorIdentity + projectRoot?: string + revisionFile?: string + themeFiles?: string[] + requestTimeoutMs?: number sheetFile: string classMapFile: string fileMapFile: string @@ -21,55 +33,17 @@ export interface DevupUICssLoaderOptions { } let init = false -let cachedPort: number | null = null -const keepAliveAgent = new Agent({ keepAlive: true }) - -function readCoordinatorPort(portFile: string): number { - if (cachedPort !== null) return cachedPort - cachedPort = parseInt(readFileSync(portFile, 'utf-8').trim()) - return cachedPort -} - -function fetchCssFromCoordinator( - port: number, - fileNum: number | null, - importMainCss: boolean, - waitForIdle: boolean, - callback: (err: Error | null, css?: string) => void, -): void { - const params = new URLSearchParams() - if (fileNum != null) params.set('fileNum', String(fileNum)) - params.set('importMainCss', String(importMainCss)) - if (waitForIdle) params.set('waitForIdle', 'true') - const req = request( - { - hostname: '127.0.0.1', - port, - path: `/css?${params.toString()}`, - method: 'GET', - agent: keepAliveAgent, - }, - (res) => { - const chunks: Buffer[] = [] - res.on('data', (chunk: Buffer) => chunks.push(chunk)) - res.on('end', () => { - if (res.statusCode !== 200) { - callback(new Error(`Coordinator CSS error: ${res.statusCode}`)) - return - } - callback(null, Buffer.concat(chunks).toString('utf-8')) - }) - }, - ) - req.on('error', (err) => callback(err)) - req.end() -} const devupUICssLoader: RawLoaderDefinitionFunction = function (source, map, meta) { const { watch, coordinatorPortFile, + coordinatorIdentity, + projectRoot = process.cwd(), + revisionFile, + themeFiles, + requestTimeoutMs, sheetFile, classMapFile, fileMapFile, @@ -79,46 +53,40 @@ const devupUICssLoader: RawLoaderDefinitionFunction = defaultFileMap, defaultSheet, } = this.getOptions() - - // Coordinator mode: fetch live CSS from coordinator (disk content is stale) if (coordinatorPortFile) { const callback = this.async() - // Check both resourcePath and resourceQuery for ?fileNum=N - // Turbopack may embed query in resourcePath or provide it via resourceQuery - const resourceQuery = - (this as unknown as { resourceQuery?: string }).resourceQuery ?? '' - const pathWithQuery = this.resourcePath + resourceQuery - const fileNum = getFileNumByFilename(pathWithQuery) - const importMainCss = fileNum !== null - const tryFetch = (retries: number) => { - if (!existsSync(coordinatorPortFile)) { - if (retries > 0) { - setTimeout(() => tryFetch(retries - 1), 50) - return - } - callback(new Error('Coordinator port file not found')) - return - } - try { - const port = readCoordinatorPort(coordinatorPortFile) - fetchCssFromCoordinator( - port, - fileNum, - importMainCss, - !watch, - (err, css) => { - if (err) return callback(err) - callback(null, css) - }, - ) - } catch (error) { - callback(error as Error) + const fileNum = getFileNumByFilename( + this.resourcePath + (this.resourceQuery ?? ''), + ) + const params = new URLSearchParams({ + importMainCss: String(fileNum !== null), + }) + if (fileNum !== null) params.set('fileNum', String(fileNum)) + if (!watch) params.set('waitForIdle', 'true') + const operation = { + portFile: coordinatorPortFile, + identity: coordinatorIdentity, + resourcePath: this.resourcePath, + path: `/css?${params}`, + timeoutMs: requestTimeoutMs, + } + try { + this.addDependency(coordinatorPortFile) + if (revisionFile) this.addDependency(resolve(projectRoot, revisionFile)) + for (const file of themeFiles ?? + collectDevupConfigFiles(resolve(projectRoot, themeFile))) { + this.addDependency(resolve(projectRoot, file)) } + } catch (error) { + callback(new CoordinatorRequestError(operation, error)) + return } - tryFetch(20) + requestCoordinator(operation).then( + (css) => callback(null, css), + (error: Error) => callback(error), + ) return } - const { getCss, importClassMap, @@ -127,28 +95,31 @@ const devupUICssLoader: RawLoaderDefinitionFunction = registerTheme, } = loadWasm() if (!init) { - init = true if (watch) { - // restart loader issue - // loader should read files when they exist in watch mode - if (existsSync(sheetFile)) - importSheet(JSON.parse(readFileSync(sheetFile, 'utf-8'))) - if (existsSync(classMapFile)) - importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) - if (existsSync(fileMapFile)) - importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8'))) - if (existsSync(themeFile)) - registerTheme( - JSON.parse(readFileSync(themeFile, 'utf-8'))?.['theme'] ?? {}, - ) + const sheet = existsSync(sheetFile) + ? JSON.parse(readFileSync(sheetFile, 'utf-8')) + : undefined + const classes = existsSync(classMapFile) + ? JSON.parse(readFileSync(classMapFile, 'utf-8')) + : undefined + const files = existsSync(fileMapFile) + ? JSON.parse(readFileSync(fileMapFile, 'utf-8')) + : undefined + const config = existsSync(themeFile) + ? JSON.parse(readFileSync(themeFile, 'utf-8')) + : undefined + if (sheet !== undefined) importSheet(sheet) + if (classes !== undefined) importClassMap(classes) + if (files !== undefined) importFileMap(files) + if (config !== undefined) registerTheme(config?.theme ?? {}) } else { importFileMap(defaultFileMap) importClassMap(defaultClassMap) importSheet(defaultSheet) registerTheme(theme) } + init = true } - this.callback( null, !watch ? source : getCss(getFileNumByFilename(this.resourcePath), true), @@ -158,10 +129,7 @@ const devupUICssLoader: RawLoaderDefinitionFunction = } export default devupUICssLoader -/** @internal Reset init state for testing purposes only */ export const resetInit = () => { init = false - cachedPort = null } - export { setWasmForTesting } from './wasm' diff --git a/packages/next-plugin/src/dev-state.ts b/packages/next-plugin/src/dev-state.ts new file mode 100644 index 000000000..7a8865579 --- /dev/null +++ b/packages/next-plugin/src/dev-state.ts @@ -0,0 +1,148 @@ +import { resolve } from 'node:path' + +import { stampFile } from './coordinator-ledger' +import type { PrewarmedOutput } from './coordinator-options' +import type { PrewarmResult } from './prewarm-run' +import type { AppContext, AppSession } from './session' +import { + captureCoordinatorState, + type CoordinatorInput, + exportAllocatorState, + importAllocatorState, + readCoordinatorState, + writeCoordinatorStateSync, +} from './state' +import type { DevupWasm } from './wasm' + +export interface ResumedEngine { + /** An engine nothing else uses, carrying the last session's names and numbers */ + readonly engine: DevupWasm + readonly revision: number +} + +interface ResumeFields { + context: AppContext + session: AppSession + createEngine: () => DevupWasm +} + +function warnColdStart(file: string, cause: unknown): void { + const detail = cause instanceof Error ? cause.message : String(cause) + console.warn( + `[devup-ui] ${file}:1:1: devup-ui cannot use \`the previous session state\` at build time: ${detail}; needs a complete checkpoint. Starting cold: class names are numbered from the paths again instead of continuing the last session.`, + ) +} + +/** + * The engine a development session starts from. The names and numbers the last + * session handed out are kept (class names do not jump between restarts); its + * styles are not, because the current sources are replayed into a fresh sheet + * so nothing a deleted or no longer reachable file produced survives. + * + * A checkpoint that is corrupt, or that fails to import part-way, is never + * used: the engine it touched is dropped and a fresh one serves the session. + * Production always starts fresh. + */ +export function resumeEngine({ + context, + session, + createEngine, +}: ResumeFields): ResumedEngine { + const engine = createEngine() + if (!context.watch) return { engine, revision: 0 } + let revision = 0 + try { + const checkpoint = readCoordinatorState(session.stateFile, context.appKey) + if (checkpoint === undefined) return { engine, revision } + revision = checkpoint.revision + importAllocatorState(engine, checkpoint) + return { engine, revision } + } catch (cause) { + warnColdStart(session.stateFile, cause) + return { engine: createEngine(), revision } + } +} + +/** Restore names on the serving shell; a partial import restores its trusted maps. */ +export function restoreShellAllocator({ + context, + session, + engine, + optionsKey = context.appKey, +}: { + readonly context: AppContext + readonly session: AppSession + readonly engine: DevupWasm + readonly optionsKey?: string +}): number { + if (!context.watch) return 0 + const baseline = exportAllocatorState(engine) + let revision = 0 + try { + const checkpoint = readCoordinatorState(session.stateFile, optionsKey) + if (checkpoint === undefined) return revision + revision = checkpoint.revision + importAllocatorState(engine, checkpoint) + } catch (cause) { + importAllocatorState(engine, baseline) + if (!(cause instanceof Error)) throw cause + warnColdStart(session.stateFile, cause) + } + return revision +} + +function toInput( + context: AppContext, + filename: string, + output: PrewarmedOutput, +): CoordinatorInput { + const resourcePath = resolve(context.root, filename) + const dependencies = output.dependencies ?? [] + return { + filename, + resourcePath, + source: output.source, + dependencies, + stamps: Object.fromEntries( + dependencies.map((dependency) => { + const path = resolve(context.root, dependency) + return [path, stampFile(path)] + }), + ), + backing: stampFile(resourcePath), + } +} + +interface PersistFields { + context: AppContext + session: AppSession + engine: DevupWasm + revision: number + prewarm: PrewarmResult +} + +/** + * Commit the state after the prewarm, as one atomic file, before the + * coordinator publishes its endpoint: whoever resumes from it finds the names + * and the inputs that produced them together. + */ +export function persistInitialState({ + context, + session, + engine, + revision, + prewarm, +}: PersistFields): void { + writeCoordinatorStateSync( + session.stateFile, + captureCoordinatorState({ + wasm: engine, + optionsKey: context.appKey, + project: context.root, + revision, + inputs: [...prewarm.outputs].map(([filename, output]) => + toInput(context, filename, output), + ), + }), + ) +} diff --git a/packages/next-plugin/src/effective-context.ts b/packages/next-plugin/src/effective-context.ts new file mode 100644 index 000000000..0bb9c6481 --- /dev/null +++ b/packages/next-plugin/src/effective-context.ts @@ -0,0 +1,51 @@ +import { join, resolve } from 'node:path' + +import { + type AppConfigInput, + type AppContext, + digest, + type SetupPhase, +} from './session' + +const LATE_FIELDS = [ + 'appKey', + 'appDir', + 'sourceMap', + 'pageExtensions', + 'nextDistDir', +] as const + +/** Shell ownership is stable before the caller's final config settings exist. */ +export function createSetupShell(context: AppContext): AppContext { + const appKey = digest( + Object.fromEntries( + Object.entries(context).filter( + ([key]) => !LATE_FIELDS.some((field) => field === key), + ), + ), + ).slice(0, 16) + return Object.freeze({ + ...context, + appKey, + appDir: join(context.distDir, '.devup', appKey), + }) +} + +export function bindEffectiveContext( + shell: AppContext, + config: AppConfigInput, + phase: SetupPhase = shell.phase, +) { + const watch = phase === 'development' + const context: AppContext = Object.freeze({ + ...shell, + phase, + watch, + nextDistDir: resolve(shell.root, config.distDir ?? '.next'), + sourceMap: watch || config.productionBrowserSourceMaps === true, + pageExtensions: Object.freeze([ + ...(config.pageExtensions ?? ['jsx', 'js', 'tsx', 'ts']), + ]), + }) + return Object.freeze({ context, optionsKey: digest(context) }) +} diff --git a/packages/next-plugin/src/engine-config.ts b/packages/next-plugin/src/engine-config.ts new file mode 100644 index 000000000..5b747a90f --- /dev/null +++ b/packages/next-plugin/src/engine-config.ts @@ -0,0 +1,37 @@ +import type { SourcePlan } from './plan' +import type { AppContext } from './session' +import { + type DevupWasm, + type ModuleResolverSettings, + withModuleResolver, +} from './wasm' + +export interface EngineSettings { + /** The resolved `theme` of the devup config, `{}` when there is none */ + theme: object + plan: Pick + readonly resolver?: ModuleResolverSettings +} + +/** + * The function that gives an engine this app's configuration. It sets every + * option, the disabled and default ones too, so nothing a previous app (or a + * previous state of this one) left in an engine can survive it. + */ +export function createEngineConfigurer( + context: AppContext, + { theme, plan, resolver }: EngineSettings, +): (engine: DevupWasm) => void { + return (engine) => { + engine.setDebug(context.debug) + engine.setPrefix(context.prefix) + engine.registerShorthands(context.shorthands) + // importSheet replaces the whole sheet, theme included, so the theme is + // registered by every configuration, after any state import. + engine.registerTheme(theme) + engine.importCanonicalMap(plan.canonicalMap) + engine.importFileRoutes(plan.fileRoutes) + engine.setAtomHoist(plan.atomThreshold) + withModuleResolver(engine, context.root, resolver) + } +} diff --git a/packages/next-plugin/src/entries.ts b/packages/next-plugin/src/entries.ts new file mode 100644 index 000000000..b44de3a90 --- /dev/null +++ b/packages/next-plugin/src/entries.ts @@ -0,0 +1,100 @@ +import { existsSync } from 'node:fs' +import { basename, dirname, join, resolve, sep } from 'node:path' + +const DEFAULT_PAGE_EXTENSIONS = ['jsx', 'js', 'tsx', 'ts'] as const +const APP_SHELLS = new Set([ + 'layout', + 'template', + 'default', + 'loading', + 'error', + 'not-found', + 'global-error', + 'forbidden', + 'unauthorized', +]) +const RUNTIME_CONVENTIONS = new Set([ + 'middleware', + 'proxy', + 'instrumentation', + 'instrumentation-client', +]) + +/** + * Select explicit Next entry modules from the graph's absolute file list. + * Router presence, not graph contents, decides root versus src precedence. + * Import traversal belongs to computeReachableFiles, not this selector. + */ +export function collectNextEntries({ + root, + files, + pageExtensions = DEFAULT_PAGE_EXTENSIONS, +}: { + readonly root: string + readonly files: readonly string[] + readonly pageExtensions?: readonly string[] +}): string[] { + const projectRoot = resolve(root) + const [appDir, pagesDir] = ['app', 'pages'].map((router) => + [join(projectRoot, router), join(projectRoot, 'src', router)].find((dir) => + existsSync(dir), + ), + ) + const routerDir = pagesDir ?? appDir + if (!routerDir) return [] + const runtimeDir = dirname(routerDir) + const extensions = [...pageExtensions].sort((a, b) => b.length - a.length) + + const entries = new Set() + const appModules: { readonly file: string; readonly name: string }[] = [] + const renderDirs = new Set() + for (const input of files) { + const file = resolve(projectRoot, input) + const filename = basename(file) + const extension = extensions.find((ext) => filename.endsWith(`.${ext}`)) + if (extension === undefined) continue + const name = filename.slice(0, -(extension.length + 1)) + if (dirname(file) === runtimeDir && RUNTIME_CONVENTIONS.has(name)) { + entries.add(file) + } + if (pagesDir && file.startsWith(`${pagesDir}${sep}`)) entries.add(file) + if (!appDir || !file.startsWith(`${appDir}${sep}`)) continue + const segments = file.slice(appDir.length + 1).split(sep) + if (segments.slice(0, -1).some((segment) => segment.startsWith('_'))) + continue + appModules.push({ file, name }) + const rootModule = dirname(file) === appDir + const metadata = + (rootModule && (name === 'robots' || name === 'manifest')) || + /^(?:sitemap|(?:icon|apple-icon|opengraph-image|twitter-image)\d?)$/.test( + name, + ) + const renderEntry = + name === 'page' || + (rootModule && (name === 'not-found' || name === 'global-not-found')) + if (name === 'route' || metadata || renderEntry) entries.add(file) + if (!renderEntry) continue + for (let dir = dirname(file); ; dir = dirname(dir)) { + renderDirs.add(dir) + if (dir === appDir) break + } + } + + // A slot with only default UI is still rendered by its active parent layout. + // Dead sibling segments are not render ancestors and never become entries. + appModules.sort((a, b) => a.file.length - b.file.length) + for (const { file, name } of appModules) { + const dir = dirname(file) + if ( + name === 'default' && + basename(dir).startsWith('@') && + renderDirs.has(dirname(dir)) + ) { + renderDirs.add(dir) + } + } + for (const { file, name } of appModules) { + if (APP_SHELLS.has(name) && renderDirs.has(dirname(file))) entries.add(file) + } + return [...entries].sort() +} diff --git a/packages/next-plugin/src/final-config-adapter-entry.ts b/packages/next-plugin/src/final-config-adapter-entry.ts new file mode 100644 index 000000000..9c0960ae6 --- /dev/null +++ b/packages/next-plugin/src/final-config-adapter-entry.ts @@ -0,0 +1 @@ +export { createFinalConfigAdapter } from './final-config-runtime' diff --git a/packages/next-plugin/src/final-config-adapter.ts b/packages/next-plugin/src/final-config-adapter.ts new file mode 100644 index 000000000..976fe40db --- /dev/null +++ b/packages/next-plugin/src/final-config-adapter.ts @@ -0,0 +1,118 @@ +import { writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' + +import type { NextConfig } from 'next' + +import type { + FinalConfigContext, + FinalConfigFinalizer, +} from './final-config-registry' +import { + FinalConfigAdapterError, + finalConfigRecords, +} from './final-config-registry' + +export type { + FinalConfigContext, + FinalConfigFinalizer, +} from './final-config-registry' +export { FinalConfigAdapterError } from './final-config-registry' + +export interface FinalConfigAdapterSession { + readonly token: string + readonly projectDir: string + readonly sessionDir: string + readonly configFile: string +} + +export interface FinalConfigAdapterOptions { + readonly entryPath: string + readonly finalize: FinalConfigFinalizer + /** Shared total budget for caller import/hook and finalizer; reserve 60s for completion. */ + readonly timeoutMs?: number +} + +/** Synchronous config wrapping; the session owner must release after Next finishes. */ +export function installFinalConfigAdapter( + config: NextConfig, + session: FinalConfigAdapterSession, + options: FinalConfigAdapterOptions, +): { readonly config: NextConfig; readonly release: (cause?: Error) => void } { + const projectDir = resolve(session.projectDir) + const context = { phase: 'config-wrapper', nextVersion: '', projectDir } + const timeoutMs = options.timeoutMs ?? 45000 + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 45000) { + throw new FinalConfigAdapterError(context, session.configFile, { + stage: 'registration', + cause: 'timeoutMs must be greater than 0 and at most 45000', + }) + } + const records = finalConfigRecords() + if (records.has(session.token)) { + throw new FinalConfigAdapterError(context, session.configFile, { + stage: 'registration', + cause: `token already owned: ${session.token}`, + }) + } + const adapterPath = join( + resolve(session.sessionDir), + 'final-config-adapter.cjs', + ) + const wrapped = { ...config } + let callerPath = config.adapterPath ?? process.env.NEXT_ADAPTER_PATH + if (config.experimental && 'adapterPath' in config.experimental) { + const legacyPath = config.experimental.adapterPath + if (legacyPath !== undefined && typeof legacyPath !== 'string') { + throw new FinalConfigAdapterError(context, session.configFile, { + stage: 'registration', + cause: 'experimental.adapterPath must be a string or absent', + }) + } + callerPath = legacyPath + const experimental = { ...config.experimental } + Reflect.deleteProperty(experimental, 'adapterPath') + wrapped.experimental = experimental + } + const record = { + ...session, + projectDir, + adapterPath, + callerPath, + timeoutMs, + finalize: options.finalize, + controllers: new Map(), + } + try { + const entryPath = createRequire(join(projectDir, 'package.json')).resolve( + resolve(options.entryPath), + ) + writeFileSync( + adapterPath, + `module.exports = require(${JSON.stringify(entryPath)}).createFinalConfigAdapter(${JSON.stringify(session.token)});\n`, + { flag: 'wx' }, + ) + } catch (cause) { + throw new FinalConfigAdapterError(context, session.configFile, { + stage: 'adapter write', + cause, + }) + } + records.set(session.token, record) + return { + config: { ...wrapped, adapterPath }, + release: (cause) => { + if (records.get(session.token) !== record) return + records.delete(session.token) + for (const [controller, activeContext] of record.controllers) { + controller.abort( + cause ?? + new FinalConfigAdapterError(activeContext, session.configFile, { + stage: 'session release', + cause: `released token ${session.token}`, + }), + ) + } + }, + } +} diff --git a/packages/next-plugin/src/final-config-registry.ts b/packages/next-plugin/src/final-config-registry.ts new file mode 100644 index 000000000..3292898b9 --- /dev/null +++ b/packages/next-plugin/src/final-config-registry.ts @@ -0,0 +1,59 @@ +import type { NextConfig } from 'next' + +export interface FinalConfigContext { + readonly phase: string + readonly nextVersion: string + readonly projectDir: string +} + +/** Turbo awaits preparation here; webpack only registers its beforeCompile bridge. */ +export type FinalConfigFinalizer = ( + config: NextConfig, + context: FinalConfigContext, + signal: AbortSignal, +) => NextConfig | Promise + +export interface FinalConfigRegistration { + readonly token: string + readonly projectDir: string + readonly configFile: string + readonly adapterPath: string + readonly callerPath: string | undefined + readonly timeoutMs: number + readonly finalize: FinalConfigFinalizer +} + +export interface FinalConfigRecord extends FinalConfigRegistration { + readonly controllers: Map +} + +const FINALIZERS = Symbol.for('@devup-ui/next-plugin/final-config-finalizers') + +/** Bundled config reloads and the standalone CJS entry share live callbacks here. */ +export function finalConfigRecords(): Map { + const holder: typeof globalThis & { + [FINALIZERS]?: Map + } = globalThis + return (holder[FINALIZERS] ??= new Map()) +} + +export class FinalConfigAdapterError extends Error { + readonly name = 'FinalConfigAdapterError' + readonly stage: string + + constructor( + readonly context: FinalConfigContext, + readonly location: string, + options: ErrorOptions & { readonly stage: string }, + ) { + const detail = + options.cause instanceof Error + ? options.cause.message + : String(options.cause) + super( + `${location}:1:1: devup-ui cannot use \`Next final config\` at build time: ${options.stage} (${context.phase}, project ${context.projectDir}): ${detail}; needs a live session and a successful bounded caller adapter/finalizer.`, + options, + ) + this.stage = options.stage + } +} diff --git a/packages/next-plugin/src/final-config-runtime.ts b/packages/next-plugin/src/final-config-runtime.ts new file mode 100644 index 000000000..0df6b70c5 --- /dev/null +++ b/packages/next-plugin/src/final-config-runtime.ts @@ -0,0 +1,146 @@ +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import type { NextConfig } from 'next' + +import type { + FinalConfigContext, + FinalConfigRecord, +} from './final-config-registry' +import { + FinalConfigAdapterError, + finalConfigRecords, +} from './final-config-registry' + +function callerConfig(value: unknown): NextConfig { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('modifyConfig must return a configuration object') + } + if ( + 'adapterPath' in value && + value.adapterPath !== undefined && + typeof value.adapterPath !== 'string' + ) { + throw new TypeError('returned adapterPath must be a string or absent') + } + return value +} + +async function composeCaller( + config: NextConfig, + context: FinalConfigContext, + record: FinalConfigRecord, +): Promise { + const restored = { ...config } + delete restored.adapterPath + if (record.callerPath !== undefined) restored.adapterPath = record.callerPath + if (!record.callerPath) return restored + const require = createRequire(join(context.projectDir, 'package.json')) + const loaded: unknown = await import( + pathToFileURL(require.resolve(record.callerPath)).href + ) + const adapter = + typeof loaded === 'object' && loaded !== null && 'default' in loaded + ? loaded.default + : loaded + if (adapter === null || adapter === undefined) { + throw new TypeError('caller adapter must export an adapter') + } + if ( + (typeof adapter === 'object' || typeof adapter === 'function') && + 'modifyConfig' in adapter && + typeof adapter.modifyConfig === 'function' + ) { + return callerConfig(await adapter.modifyConfig(restored, context)) + } + return restored +} + +async function modifyFinalConfig( + token: string, + config: NextConfig, + context: FinalConfigContext, +): Promise { + const record = finalConfigRecords().get(token) + if (!record) { + const location = + 'configFile' in config && typeof config.configFile === 'string' + ? config.configFile + : join(context.projectDir, 'next.config.js') + throw new FinalConfigAdapterError(context, location, { + stage: 'registry lookup', + cause: `unknown or released token ${token}`, + }) + } + if (resolve(context.projectDir) !== record.projectDir) { + throw new FinalConfigAdapterError(context, record.configFile, { + stage: 'project ownership', + cause: `token belongs to ${record.projectDir}`, + }) + } + const controller = new AbortController() + record.controllers.set(controller, context) + let stage = 'caller modifyConfig' + let timer: ReturnType | undefined + let abort: (() => void) | undefined + const bounded = new Promise((_, reject) => { + abort = () => reject(controller.signal.reason) + controller.signal.addEventListener('abort', abort, { once: true }) + timer = setTimeout( + () => + controller.abort( + new FinalConfigAdapterError(context, record.configFile, { + stage, + cause: `exceeded ${record.timeoutMs}ms`, + }), + ), + record.timeoutMs, + ) + }) + const work = async (): Promise => { + const effective = await composeCaller(config, context, record) + controller.signal.throwIfAborted() + const callerPath = effective.adapterPath + stage = 'finalizer' + const finalized = await record.finalize( + effective, + context, + controller.signal, + ) + controller.signal.throwIfAborted() + const restored = { ...finalized } + delete restored.adapterPath + if (callerPath !== undefined) restored.adapterPath = callerPath + return restored + } + try { + return await Promise.race([work(), bounded]) + } catch (cause) { + if (cause instanceof Error && cause.name === 'FinalConfigAdapterError') + throw cause + throw new FinalConfigAdapterError(context, record.configFile, { + stage, + cause, + }) + } finally { + clearTimeout(timer) + if (abort) controller.signal.removeEventListener('abort', abort) + record.controllers.delete(controller) + } +} + +/** The generated adapter contains only this entry's path and its session token. */ +export function createFinalConfigAdapter(token: string): { + readonly name: string + readonly modifyConfig: ( + config: NextConfig, + context: FinalConfigContext, + ) => Promise +} { + return { + name: 'devup-ui-final-config', + modifyConfig: (config, context) => + modifyFinalConfig(token, config, context), + } +} diff --git a/packages/next-plugin/src/lifecycle.ts b/packages/next-plugin/src/lifecycle.ts new file mode 100644 index 000000000..8fce3a5e8 --- /dev/null +++ b/packages/next-plugin/src/lifecycle.ts @@ -0,0 +1,152 @@ +import { rmSync } from 'node:fs' + +import type { NextConfig } from 'next' + +import type { CoordinatorHandle } from './coordinator-options' +import type { AppContext, AppSession } from './session' +import type { SetupHandoff } from './setup-handoff' +import type { DevupWasm } from './wasm' + +export interface LiveSetup { + readonly context: AppContext + readonly engine: DevupWasm + readonly result: Omit +} + +export interface SessionOwner { + readonly session: AppSession + readonly coordinator: CoordinatorHandle + readonly setup?: LiveSetup + readonly drain: () => Promise + readonly afterCompile: () => Promise + readonly close: (cause?: Error) => void +} + +// A released token stays reserved: an old exit callback still owns its directory. +type Owners = Map + +const SESSIONS = Symbol.for('@devup-ui/next-plugin/sessions') + +/** + * Next evaluates the config more than once in a process, each time in a fresh + * module instance. The sessions that own a coordinator are therefore kept where + * every instance finds them: the later evaluation can drain the coordinator the + * first one started without being able to close a different app's. + */ +function sessions(): Owners { + const holder: typeof globalThis & { [SESSIONS]?: Owners } = globalThis + return (holder[SESSIONS] ??= new Map()) +} + +export function findSessionOwner(token: string): SessionOwner | undefined { + return sessions().get(token) +} + +export class SessionOwnershipError extends Error { + readonly name = 'SessionOwnershipError' + constructor(readonly endpointFile: string) { + super( + `${endpointFile}:1:1: devup-ui cannot use \`session ownership\` at build time: this session token already has an owner`, + ) + } +} + +function describe(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause) +} + +interface RetainFields { + readonly session: AppSession + readonly coordinator: CoordinatorHandle + readonly setup?: LiveSetup + readonly releaseAdapter?: (cause?: Error) => void +} + +/** + * Tie the coordinator's lifetime to the process. + * + * `beforeExit` (the event loop is empty, async work is still possible) awaits + * the drain, so every accepted write is on disk, and then closes. `exit` cannot + * wait for anything: it only closes synchronously, as a last resort. + */ +export function retainSession({ + session, + coordinator, + setup, + releaseAdapter, +}: RetainFields): SessionOwner { + if (sessions().has(session.token)) { + throw new SessionOwnershipError(session.endpointFile) + } + let closed = false + let released = false + const release = (cause?: Error): void => { + if (released) return + released = true + sessions().set(session.token, undefined) + releaseAdapter?.(cause) + } + const closeOnce = (cause?: Error | number): void => { + if (closed) return + closed = true + process.off('beforeExit', drainAndClose) + process.off('exit', closeOnce) + release(cause instanceof Error ? cause : undefined) + coordinator.close() + rmSync(session.sessionDir, { recursive: true, force: true }) + } + const drain = (): Promise => { + release() + return coordinator.drain() + } + const drainAndClose = async (): Promise => { + try { + await drain() + } catch (cause) { + console.error( + `${session.sessionDir}:1:1: devup-ui cannot use \`the final state write\` at build time: ${describe(cause)}; needs a writable distDir.`, + ) + } finally { + closeOnce() + } + } + + const owner: SessionOwner = Object.freeze({ + session, + coordinator, + ...(setup === undefined ? {} : { setup }), + drain, + afterCompile: () => (closed ? Promise.resolve() : drain()), + close: closeOnce, + }) + sessions().set(session.token, owner) + coordinator.ready.catch((cause: unknown) => { + console.error( + `${session.endpointFile}:1:1: devup-ui coordinator cannot use \`a loopback listener\` at build time: ${describe(cause)}; needs permission to listen on 127.0.0.1 and to write its endpoint file.`, + ) + }) + // Once: the drain schedules async work, which makes the loop run, and empty + // again, and Node would emit beforeExit a second time. + process.once('beforeExit', drainAndClose) + process.once('exit', closeOnce) + return owner +} + +/** + * Drain the coordinator in `runAfterProductionCompile`: every source loader has + * finished by then, so no request is cut short, and the accepted work is + * written before the type-check and static generation that follow. The user's + * own hook runs after the drain, with its result unchanged. + */ +export function installAfterCompileDrain( + config: NextConfig, + token: string, +): void { + config.compiler ??= {} + const afterCompile = findSessionOwner(token)?.afterCompile + const previous = config.compiler.runAfterProductionCompile + config.compiler.runAfterProductionCompile = async (metadata) => { + await afterCompile?.() + await previous?.(metadata) + } +} diff --git a/packages/next-plugin/src/loader-resolution-watch.ts b/packages/next-plugin/src/loader-resolution-watch.ts new file mode 100644 index 000000000..61e284e4e --- /dev/null +++ b/packages/next-plugin/src/loader-resolution-watch.ts @@ -0,0 +1,49 @@ +import { resolutionWatchPath } from '@devup-ui/plugin-utils' +import type { Compiler, LoaderContext } from 'webpack' + +import { MdxFreshnessError } from './mdx-source-freshness' + +export function loaderResolutionWatchPath( + path: string, + compiler: Compiler | undefined, +): string { + const symlinks = compiler?.options.resolve?.symlinks + return typeof symlinks === 'boolean' + ? resolutionWatchPath(path, symlinks === false) + : path +} + +export async function registerLoaderMissingDependencies( + context: Pick< + LoaderContext, + '_compiler' | 'addMissingDependency' | 'fs' | 'resourcePath' + >, + paths: readonly string[], +): Promise { + for (const path of paths) { + const missing = loaderResolutionWatchPath(path, context._compiler) + context.addMissingDependency(missing) + if (typeof context.fs?.readFile !== 'function') + throw new TypeError( + `Exact missing input tracking requires loader fs.readFile for ${missing}`, + ) + await new Promise((resolve, reject) => { + context.fs.readFile(missing, (error) => { + if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') resolve() + else if (error) reject(error) + else + reject( + new MdxFreshnessError( + context.resourcePath, + { + kind: 'missing', + path: missing, + loader: '@devup-ui/next-plugin/loader', + }, + 'observed missing resolution input became readable during delivery', + ), + ) + }) + }) + } +} diff --git a/packages/next-plugin/src/loader-response.ts b/packages/next-plugin/src/loader-response.ts new file mode 100644 index 000000000..2e95e3ddb --- /dev/null +++ b/packages/next-plugin/src/loader-response.ts @@ -0,0 +1,55 @@ +export function parseSourceMap(sourceMap: string | undefined): string | null { + if (!sourceMap) return null + JSON.parse(sourceMap) + return sourceMap +} + +export function parseCoordinatorResponse(content: string) { + const data: unknown = JSON.parse(content) + if ( + typeof data !== 'object' || + data === null || + !('code' in data) || + typeof data.code !== 'string' + ) + throw new Error('Coordinator response missing code') + const resolution: unknown = + 'resolutionInputs' in data + ? data.resolutionInputs + : { fileDependencies: [], missingDependencies: [] } + if ( + typeof resolution !== 'object' || + resolution === null || + !('fileDependencies' in resolution) || + !('missingDependencies' in resolution) + ) + throw new TypeError('Coordinator response has invalid resolution inputs') + const files = resolution.fileDependencies + const missing = resolution.missingDependencies + if ( + !Array.isArray(files) || + files.some((path: unknown) => typeof path !== 'string') || + !Array.isArray(missing) || + missing.some((path: unknown) => typeof path !== 'string') + ) + throw new TypeError('Coordinator response has invalid resolution paths') + return Object.freeze({ + code: data.code, + map: parseSourceMap( + 'map' in data && typeof data.map === 'string' ? data.map : undefined, + ), + dependencies: + 'dependencies' in data && Array.isArray(data.dependencies) + ? data.dependencies.filter( + (dependency): dependency is string => + typeof dependency === 'string', + ) + : [], + missingDependencies: Object.freeze( + missing.filter((path): path is string => typeof path === 'string'), + ), + fileDependencies: Object.freeze( + files.filter((path): path is string => typeof path === 'string'), + ), + }) +} diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index e11c789b4..8ac9d68b3 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -1,11 +1,30 @@ import { existsSync, readFileSync } from 'node:fs' import { writeFile } from 'node:fs/promises' -import { Agent, request } from 'node:http' import { basename, dirname, join, relative, resolve } from 'node:path' +import { + collectDevupConfigFiles, + createStateWriter, + type SourceType, +} from '@devup-ui/plugin-utils' import type { RawLoaderDefinitionFunction } from 'webpack' -import { loadWasm } from './wasm' +import { + CoordinatorRequestError, + requestCoordinator, +} from './coordinator-client' +import { takeExtractOutput } from './coordinator-engine' +import type { CoordinatorIdentity } from './coordinator-port' +import { + loaderResolutionWatchPath, + registerLoaderMissingDependencies, +} from './loader-resolution-watch' +import { parseCoordinatorResponse, parseSourceMap } from './loader-response' +import { extractWithModuleResolver, loadWasm } from './wasm' + +const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), +) export interface DevupUILoaderOptions { package: string @@ -17,112 +36,24 @@ export interface DevupUILoaderOptions { watch: boolean singleCss: boolean coordinatorPortFile?: string - // turbo + coordinatorIdentity?: CoordinatorIdentity + projectRoot?: string + revisionFile?: string + themeFiles?: string[] + requestTimeoutMs?: number theme?: object defaultSheet: object defaultClassMap: object defaultFileMap: object importAliases?: Record + readonly sourceType?: SourceType } let init = false -const cachedPorts = new Map() -const keepAliveAgent = new Agent({ keepAlive: true }) - -interface CoordinatorResponse { - code?: string - error?: string - map?: string - dependencies: string[] -} - function toLoaderError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } -function readCoordinatorPort(portFile: string): number { - const cachedPort = cachedPorts.get(portFile) - if (cachedPort !== undefined) return cachedPort - - const port = Number.parseInt(readFileSync(portFile, 'utf-8').trim(), 10) - cachedPorts.set(portFile, port) - return port -} - -function parseCoordinatorResponse(content: string): CoordinatorResponse { - const data: unknown = JSON.parse(content) - if (typeof data !== 'object' || data === null) { - return { dependencies: [] } - } - - const record = data as Record - return { - code: typeof record.code === 'string' ? record.code : undefined, - error: typeof record.error === 'string' ? record.error : undefined, - map: typeof record.map === 'string' ? record.map : undefined, - dependencies: Array.isArray(record.dependencies) - ? record.dependencies.filter( - (dependency): dependency is string => typeof dependency === 'string', - ) - : [], - } -} - -function parseSourceMap(sourceMap: string | undefined): string | null { - if (!sourceMap) return null - - JSON.parse(sourceMap) - return sourceMap -} - -function coordinatorExtract( - port: number, - body: string, - callback: ( - err: Error | null, - content?: string, - sourceMap?: string | null, - dependencies?: string[], - ) => void, -): void { - const req = request( - { - hostname: '127.0.0.1', - port, - path: '/extract', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - agent: keepAliveAgent, - }, - (res) => { - const chunks: Buffer[] = [] - res.on('data', (chunk: Buffer) => chunks.push(chunk)) - res.on('end', () => { - try { - const data = parseCoordinatorResponse( - Buffer.concat(chunks).toString('utf-8'), - ) - if (res.statusCode !== 200) { - callback(new Error(data.error ?? 'Coordinator error')) - return - } - if (data.code === undefined) { - callback(new Error('Coordinator response missing code')) - return - } - const sourceMap = parseSourceMap(data.map) - callback(null, data.code, sourceMap, data.dependencies) - } catch (e) { - callback(toLoaderError(e)) - } - }) - }, - ) - req.on('error', (err) => callback(err)) - req.write(body) - req.end() -} - const devupUILoader: RawLoaderDefinitionFunction = function (source) { const { @@ -135,67 +66,82 @@ const devupUILoader: RawLoaderDefinitionFunction = themeFile, singleCss, coordinatorPortFile, + coordinatorIdentity, + projectRoot = process.cwd(), + revisionFile, + themeFiles, + requestTimeoutMs, theme, defaultClassMap, defaultFileMap, defaultSheet, importAliases = {}, + sourceType, } = this.getOptions() - - // Coordinator mode: delegate to HTTP server + const callback = this.async() if (coordinatorPortFile) { - this.addDependency(coordinatorPortFile) - const callback = this.async() - const tryCoordinator = (retries: number) => { - if (!existsSync(coordinatorPortFile)) { - if (retries > 0) { - setTimeout(() => tryCoordinator(retries - 1), 50) - return - } - // Port file never appeared — fall through to error - callback(new Error('Coordinator port file not found')) - return - } - try { - const port = readCoordinatorPort(coordinatorPortFile) - // POSIX-normalize so the engine's bucket key matches the canonical map - // and FILE_ROUTES keys (both built with forward slashes). Without this, - // canonical collapse and atom hoisting silently no-op on Windows. - const relativePath = relative( - process.cwd(), - this.resourcePath, - ).replaceAll('\\', '/') - const body = JSON.stringify({ - filename: relativePath, - code: source.toString(), - resourcePath: this.resourcePath, - }) - coordinatorExtract( - port, - body, - (err, content, sourceMap, dependencies = []) => { - if (err) return callback(err) - for (const dependency of dependencies) { - this.addDependency(resolve(dependency)) - } - callback( - null, - content, - sourceMap as Parameters[2], - ) - }, - ) - } catch (error) { - callback(toLoaderError(error)) + const operation = { + portFile: coordinatorPortFile, + identity: coordinatorIdentity, + resourcePath: this.resourcePath, + path: '/extract', + method: 'POST' as const, + timeoutMs: requestTimeoutMs, + body: JSON.stringify({ + filename: relative(projectRoot, this.resourcePath).replaceAll( + '\\', + '/', + ), + code: source.toString(), + resourcePath: this.resourcePath, + ...(sourceType === undefined ? {} : { sourceType }), + }), + } + try { + this.addDependency(coordinatorPortFile) + if (revisionFile) this.addDependency(resolve(projectRoot, revisionFile)) + for (const file of themeFiles ?? + collectDevupConfigFiles(resolve(projectRoot, themeFile))) { + this.addDependency(resolve(projectRoot, file)) } + } catch (error) { + callback(new CoordinatorRequestError(operation, error)) + return } - tryCoordinator(20) // 20 retries × 50ms = 1s max wait + requestCoordinator(operation) + .then(async (content) => { + const data = parseCoordinatorResponse(content) + for (const dependency of data.dependencies) + this.addDependency(resolve(projectRoot, dependency)) + for (const dependency of data.fileDependencies) + this.addDependency( + loaderResolutionWatchPath( + resolve(projectRoot, dependency), + this._compiler, + ), + ) + await registerLoaderMissingDependencies( + this, + data.missingDependencies.map((dependency) => + resolve(projectRoot, dependency), + ), + ) + return data + }) + .then( + (data) => callback(null, data.code, data.map), + (error: unknown) => + callback( + error instanceof CoordinatorRequestError + ? error + : new CoordinatorRequestError(operation, error), + ), + ) return } - // Non-coordinator mode: local WASM extraction + const engine = loadWasm() const { - codeExtract, exportClassMap, exportFileMap, exportSheet, @@ -204,44 +150,42 @@ const devupUILoader: RawLoaderDefinitionFunction = importFileMap, importSheet, registerTheme, - } = loadWasm() + } = engine const promises: Promise[] = [] - if (!init) { - init = true - if (watch) { - this.addDependency(sheetFile) - this.addDependency(classMapFile) - this.addDependency(fileMapFile) - this.addDependency(themeFile) - // restart loader issue - // loader should read files when they exist in watch mode - if (existsSync(sheetFile)) - importSheet(JSON.parse(readFileSync(sheetFile, 'utf-8'))) - if (existsSync(classMapFile)) - importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) - if (existsSync(fileMapFile)) - importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8'))) - if (existsSync(themeFile)) - registerTheme( - JSON.parse(readFileSync(themeFile, 'utf-8'))?.theme ?? {}, - ) - } else { - importFileMap(defaultFileMap) - importClassMap(defaultClassMap) - importSheet(defaultSheet) - registerTheme(theme) - } - } - - const callback = this.async() try { + if (!init) { + if (watch) { + this.addDependency(sheetFile) + this.addDependency(classMapFile) + this.addDependency(fileMapFile) + this.addDependency(themeFile) + const sheet = existsSync(sheetFile) + ? JSON.parse(readFileSync(sheetFile, 'utf-8')) + : undefined + const classes = existsSync(classMapFile) + ? JSON.parse(readFileSync(classMapFile, 'utf-8')) + : undefined + const files = existsSync(fileMapFile) + ? JSON.parse(readFileSync(fileMapFile, 'utf-8')) + : undefined + const config = existsSync(themeFile) + ? JSON.parse(readFileSync(themeFile, 'utf-8')) + : undefined + if (sheet !== undefined) importSheet(sheet) + if (classes !== undefined) importClassMap(classes) + if (files !== undefined) importFileMap(files) + if (config !== undefined) registerTheme(config?.theme ?? {}) + } else { + importFileMap(defaultFileMap) + importClassMap(defaultClassMap) + importSheet(defaultSheet) + registerTheme(theme) + } + init = true + } const id = this.resourcePath let relCssDir = relative(dirname(id), cssDir).replaceAll('\\', '/') - - // POSIX-normalize (see coordinator-mode note above) so bucket keys match - // the canonical map / FILE_ROUTES on Windows. - const relativePath = relative(process.cwd(), id).replaceAll('\\', '/') - + const relativePath = relative(projectRoot, id).replaceAll('\\', '/') if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` const { code, @@ -249,53 +193,53 @@ const devupUILoader: RawLoaderDefinitionFunction = cssFile, updatedBaseStyle, dependencies = [], - } = codeExtract( - relativePath, - source.toString(), - libPackage, - relCssDir, - singleCss, - false, - true, - importAliases, + } = takeExtractOutput( + extractWithModuleResolver(engine, true, [ + relativePath, + source.toString(), + libPackage, + relCssDir, + singleCss, + false, + true, + importAliases, + ...(sourceType ? ([sourceType] as const) : ([] as const)), + ]), ) - for (const dependency of dependencies) { - this.addDependency(resolve(dependency)) - } + for (const dependency of dependencies) + this.addDependency(resolve(projectRoot, dependency)) const sourceMap = parseSourceMap(map) if (updatedBaseStyle && watch) { - // update base style promises.push( - writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'), + stateWriter.write( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ), ) } if (cssFile && watch) { - // don't write file when build promises.push( - writeFile( + stateWriter.write( join(cssDir, basename(cssFile)), `/* ${this.resourcePath} ${Date.now()} */`, ), - writeFile(sheetFile, exportSheet()), - writeFile(classMapFile, exportClassMap()), - writeFile(fileMapFile, exportFileMap()), + stateWriter.write(sheetFile, exportSheet()), + stateWriter.write(classMapFile, exportClassMap()), + stateWriter.write(fileMapFile, exportFileMap()), ) } Promise.all(promises).then( () => callback(null, code, sourceMap), - (error) => callback(toLoaderError(error)), + (error: unknown) => callback(toLoaderError(error)), ) } catch (error) { callback(toLoaderError(error)) } - return } export default devupUILoader -/** @internal Reset init state for testing purposes only */ export const resetInit = () => { init = false - cachedPorts.clear() } - export { setWasmForTesting } from './wasm' diff --git a/packages/next-plugin/src/mdx-binding-value.ts b/packages/next-plugin/src/mdx-binding-value.ts new file mode 100644 index 000000000..7fa7c3c4f --- /dev/null +++ b/packages/next-plugin/src/mdx-binding-value.ts @@ -0,0 +1,166 @@ +type Entry = { + readonly key: string | symbol + readonly enumerable: boolean | undefined +} & ( + | { + readonly kind: 'data' + readonly value: Value + } + | { readonly kind: 'accessor'; readonly get: unknown; readonly set: unknown } +) +type Value = + | { readonly kind: 'leaf'; readonly value: unknown } + | { + readonly kind: 'shell' + readonly array: boolean + readonly entries: readonly Entry[] + } + +export class MdxBindingDifference extends Error { + readonly name = 'MdxBindingDifference' + constructor( + readonly path: string, + readonly reason: string, + ) { + super(`${path}: ${reason}`) + } +} + +export function snapshotMdxBindingValue(input: unknown): Value { + const seen = new Map() + function snapshot(value: unknown): Value { + if (typeof value !== 'object' || value === null) + return { kind: 'leaf', value } + const array = Array.isArray(value) + const prototype: unknown = Object.getPrototypeOf(value) + if (!array && prototype !== Object.prototype && prototype !== null) + return { kind: 'leaf', value } + const existing = seen.get(value) + if (existing) return existing + // Register before descending: cyclic shells are structural, not recursion errors. + const entries: Entry[] = [] + const result: Value = { kind: 'shell', array, entries } + seen.set(value, result) + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor) continue + const flags = { + key, + enumerable: descriptor.enumerable, + } + if ('value' in descriptor) { + const child: unknown = descriptor.value + entries.push({ + ...flags, + kind: 'data', + value: snapshot(child), + }) + } else { + entries.push({ + ...flags, + kind: 'accessor', + get: descriptor.get, + set: descriptor.set, + }) + } + } + Object.freeze(entries) + return Object.freeze(result) + } + return snapshot(input) +} + +function keyPath(path: string, key: string | symbol): string { + if (typeof key === 'symbol') return `${path}[${String(key)}]` + return /^\d+$/.test(key) ? `${path}[${key}]` : `${path}.${key}` +} + +export function compareMdxBindingValues( + left: Value, + right: Value, +): MdxBindingDifference | undefined { + const pairs = new Map>() + function compare( + a: Value, + b: Value, + path: string, + ): MdxBindingDifference | undefined { + switch (a.kind) { + case 'leaf': + if (b.kind !== 'leaf') + return new MdxBindingDifference(path, 'value shape changed') + return Object.is(a.value, b.value) + ? undefined + : new MdxBindingDifference( + path, + 'value or live leaf identity changed', + ) + case 'shell': { + if (b.kind !== 'shell') + return new MdxBindingDifference(path, 'value shape changed') + if (a.array !== b.array) + return new MdxBindingDifference(path, 'array/object shape changed') + let matches = pairs.get(a) + if (matches?.has(b)) return + if (!matches) pairs.set(a, (matches = new Set())) + matches.add(b) + for (const entry of a.entries) { + const next = b.entries.find( + (candidate) => candidate.key === entry.key, + ) + const childPath = keyPath(path, entry.key) + if (!next) + return new MdxBindingDifference(childPath, 'property removed') + if (entry.enumerable !== next.enumerable) + return new MdxBindingDifference( + childPath, + 'property descriptor changed', + ) + switch (entry.kind) { + case 'accessor': + if (next.kind !== 'accessor') + return new MdxBindingDifference( + childPath, + 'property descriptor changed', + ) + if (entry.get !== next.get || entry.set !== next.set) + return new MdxBindingDifference( + childPath, + 'accessor identity changed', + ) + break + case 'data': { + if (next.kind !== 'data') + return new MdxBindingDifference( + childPath, + 'property descriptor changed', + ) + const difference = compare(entry.value, next.value, childPath) + if (difference) return difference + break + } + default: + return entry satisfies never + } + } + const added = b.entries.find( + (entry) => + !a.entries.some((candidate) => candidate.key === entry.key), + ) + if (added) + return new MdxBindingDifference( + keyPath(path, added.key), + 'property added', + ) + if ( + a.entries.some((entry, index) => entry.key !== b.entries[index]?.key) + ) + return new MdxBindingDifference(path, 'own-key order changed') + return + } + default: + return a satisfies never + } + } + return compare(left, right, 'loaders') +} diff --git a/packages/next-plugin/src/mdx-binding.ts b/packages/next-plugin/src/mdx-binding.ts new file mode 100644 index 000000000..07457993c --- /dev/null +++ b/packages/next-plugin/src/mdx-binding.ts @@ -0,0 +1,131 @@ +import { + compareMdxBindingValues, + MdxBindingDifference, + snapshotMdxBindingValue, +} from './mdx-binding-value' + +export type MdxNativeLoaderFacts = { + readonly resolvedPath: string + readonly packageVersion: string + readonly verifiedFileHash: string + readonly options: unknown +} +export type MdxBindingEvaluation = { + readonly evaluationID: string + readonly configFile: string + readonly projectDir: string + readonly phase: string + readonly isolateID: string +} +export type MdxBindingDelivery = { + readonly sourceMap: boolean + readonly layer: string | undefined + readonly isServer: boolean + readonly compilerName: string +} +export type MdxBindingInput = { + readonly owner: MdxBindingOwner + readonly loaders: readonly MdxNativeLoaderFacts[] + readonly evaluation: MdxBindingEvaluation + readonly delivery: MdxBindingDelivery +} + +/** Capture original native/config options before wrapper tuple mutation, not its artifacts. */ +export class MdxBindingReceipt { + readonly owner: MdxBindingOwner + readonly loaders: readonly MdxNativeLoaderFacts[] + readonly evaluation: MdxBindingEvaluation + readonly delivery: MdxBindingDelivery + readonly #snapshot: ReturnType + constructor(input: MdxBindingInput) { + this.owner = Object.freeze({ ...input.owner }) + this.loaders = Object.freeze( + input.loaders.map( + ({ resolvedPath, packageVersion, verifiedFileHash, options }) => + Object.freeze({ + resolvedPath, + packageVersion, + verifiedFileHash, + options, + }), + ), + ) + this.evaluation = Object.freeze({ ...input.evaluation }) + this.delivery = Object.freeze({ ...input.delivery }) + // Opaque native option values remain live; only comparison shells are detached. + this.#snapshot = snapshotMdxBindingValue(this.loaders) + Object.freeze(this) + } + difference(other: MdxBindingReceipt): MdxBindingDifference | undefined { + return compareMdxBindingValues(this.#snapshot, other.#snapshot) + } +} + +export function captureMdxBindingReceipt( + input: MdxBindingInput, +): MdxBindingReceipt { + return new MdxBindingReceipt(input) +} + +export type MdxBindingOwner = { + readonly ownerID: string + readonly configFile: string +} +export type MdxBindingState = + | { readonly kind: 'unbound' } + | { readonly kind: 'bound'; readonly receipt: MdxBindingReceipt } + | { readonly kind: 'released' } +export type MdxBinding = { + readonly state: () => MdxBindingState + readonly accept: (receipt: MdxBindingReceipt) => MdxBindingReceipt + readonly release: () => void +} + +export class MdxBindingError extends Error { + readonly name = 'MdxBindingError' + readonly path: string + constructor( + readonly owner: MdxBindingOwner, + cause: MdxBindingDifference, + ) { + super( + `${owner.configFile}:1:1: devup-ui cannot rebind owner ${owner.ownerID}; first difference ${cause.path}: ${cause.reason}; mixing prepared CSS/class allocation with another pre-extraction pipeline is unsafe; use the same pipeline within this owner/evaluation, e.g. a module-level plugin instead of recreating a phase-function closure for each compiler`, + { cause }, + ) + this.path = cause.path + } +} + +/** One value owned by SessionOwner; deliberately no global registry or lifecycle wiring. */ +export function createMdxBinding(owner: MdxBindingOwner): MdxBinding { + const identity = Object.freeze({ ...owner }) + let state: MdxBindingState = Object.freeze({ kind: 'unbound' }) + return Object.freeze({ + state: () => state, + accept(receipt: MdxBindingReceipt) { + switch (state.kind) { + case 'unbound': + state = Object.freeze({ kind: 'bound', receipt }) + return receipt + case 'bound': { + const difference = state.receipt.difference(receipt) + if (difference) throw new MdxBindingError(identity, difference) + return state.receipt + } + case 'released': + throw new MdxBindingError( + identity, + new MdxBindingDifference( + 'owner.state', + 'released owner cannot revive', + ), + ) + default: + return state satisfies never + } + }, + release() { + state = Object.freeze({ kind: 'released' }) + }, + }) +} diff --git a/packages/next-plugin/src/mdx-invocation.ts b/packages/next-plugin/src/mdx-invocation.ts new file mode 100644 index 000000000..60fa54570 --- /dev/null +++ b/packages/next-plugin/src/mdx-invocation.ts @@ -0,0 +1,95 @@ +import { isAbsolute } from 'node:path' + +import { isMdxCompiler, type MdxLoader, type MdxPipeline } from './mdx-pipeline' + +export type MdxInvocationLoader = MdxLoader | string + +// Only callers holding actual descriptor facts may supply this full invocation. +// MdxPipeline.loaders remains a supplied compiler suffix, without an own slot. +export type MdxInvocation = { + readonly resource: string + readonly loaders: readonly MdxInvocationLoader[] + readonly compilerIndex: number + readonly ownIndex: number +} + +export function mdxLoaderPath(loader: MdxInvocationLoader): string { + return ( + (typeof loader === 'string' ? loader : loader.loader).split(/[?#]/)[0] ?? '' + ) +} + +export class MdxInvocationError extends Error { + readonly name = 'MdxInvocationError' + constructor( + readonly ruleKey: string, + readonly loader: string, + readonly fact: string, + ) { + super( + `MDX rule "${ruleKey}" loader ${loader}: missing ${fact}; needs the actual full descriptors with original Devup/compiler indices and representable loader context`, + ) + } +} + +export class MdxLoaderExecutionError extends MdxInvocationError { + constructor( + ruleKey: string, + loader: string, + override readonly cause: unknown, + ) { + super( + ruleKey, + loader, + `loader completion/context: ${cause instanceof Error ? cause.message : String(cause)}`, + ) + } +} + +export function requireMdxInvocation( + pipeline: MdxPipeline, + invocation: MdxInvocation, + filename: string, +) { + const compiler = invocation.loaders[invocation.compilerIndex] + const own = invocation.loaders[invocation.ownIndex] + const expected = pipeline.loaders[0] + if ( + !Number.isInteger(invocation.compilerIndex) || + !Number.isInteger(invocation.ownIndex) || + invocation.ownIndex < 0 || + invocation.compilerIndex <= invocation.ownIndex || + !compiler || + !own || + !expected || + mdxLoaderPath(compiler) !== mdxLoaderPath(expected) + ) + throw new MdxInvocationError( + pipeline.ruleKey, + compiler ? mdxLoaderPath(compiler) : '', + 'original compiler/own slot facts', + ) + for (const loader of invocation.loaders) + if (!isAbsolute(mdxLoaderPath(loader))) + throw new MdxInvocationError( + pipeline.ruleKey, + mdxLoaderPath(loader), + 'resolved original loader path', + ) + if (mdxLoaderPath(invocation.resource) !== filename) + throw new MdxInvocationError( + pipeline.ruleKey, + mdxLoaderPath(compiler), + 'original resource path for the prepared source', + ) + if ( + invocation.loaders.filter((loader) => isMdxCompiler(mdxLoaderPath(loader))) + .length !== 1 + ) + throw new MdxInvocationError( + pipeline.ruleKey, + mdxLoaderPath(compiler), + 'exactly one configured MDX compiler', + ) + return invocation +} diff --git a/packages/next-plugin/src/mdx-normal-capture.ts b/packages/next-plugin/src/mdx-normal-capture.ts new file mode 100644 index 000000000..e2b554661 --- /dev/null +++ b/packages/next-plugin/src/mdx-normal-capture.ts @@ -0,0 +1,127 @@ +import { MdxInvocationError, MdxLoaderExecutionError } from './mdx-invocation' +import { isMdxRecord } from './mdx-pipeline' +import mdxPrewarmLoader from './mdx-prewarm-loader' + +export type MdxNormalSeam = { + readonly ruleKey: string + readonly compilerIndex: number + readonly compilerPath: string + readonly ownIndex?: number + readonly loaderCount: number + readonly bridge: boolean +} + +type Normal = (this: unknown, ...args: readonly unknown[]) => unknown +function isNormal(value: unknown): value is Normal { + return typeof value === 'function' +} + +export function installMdxNormalCapture(context: object, seam: MdxNormalSeam) { + let loaders: unknown + let compilerExecutions = 0 + let ownExecutions = 0 + let captured: readonly unknown[] | undefined + const stop = new Error('MDX invocation-private capture stop') + const failure = (fact: string) => + new MdxInvocationError(seam.ruleKey, seam.compilerPath, fact) + const currentPath = () => { + const index: unknown = Reflect.get(context, 'loaderIndex') + const slot: unknown = + Array.isArray(loaders) && typeof index === 'number' + ? loaders[index] + : undefined + return isMdxRecord(slot) && typeof slot.path === 'string' + ? slot.path + : seam.compilerPath + } + const rejectExecution = (error: unknown): never => { + if (error instanceof MdxInvocationError) throw error + throw new MdxLoaderExecutionError(seam.ruleKey, currentPath(), error) + } + Object.defineProperty(context, 'loaders', { + enumerable: true, + configurable: true, + get: () => loaders, + set(value: unknown) { + if (!Array.isArray(value) || value.length !== seam.loaderCount) + throw failure('original loader array') + loaders = value + for (const index of [seam.compilerIndex, seam.ownIndex]) { + if (index === undefined) continue + const slot: unknown = value[index] + if (!isMdxRecord(slot)) throw failure(`normal slot ${index}`) + const property = Object.getOwnPropertyDescriptor(slot, 'normal') + if ( + !property?.configurable || + !property.writable || + !property.enumerable + ) + throw failure(`writable existing normal slot ${index}`) + let normal: Normal | undefined + Object.defineProperty(slot, 'normal', { + enumerable: true, + configurable: true, + get: () => normal, + set(original: unknown) { + if (!isNormal(original)) + throw failure(`callable original normal slot ${index}`) + normal = function (this: unknown, ...args: readonly unknown[]) { + if ( + !isMdxRecord(this) || + this.loaderIndex !== index || + this.loaders !== loaders || + slot.normalExecuted !== true + ) + throw failure(`original runner normal position ${index}`) + if (index === seam.compilerIndex) { + compilerExecutions++ + if (compilerExecutions !== 1) + throw failure('exactly one compiler execution') + return Reflect.apply( + seam.bridge ? mdxPrewarmLoader : original, + this, + args, + ) + } + ownExecutions++ + if (compilerExecutions !== 1 || ownExecutions !== 1) + throw failure( + 'exactly one compiler before Devup normal capture', + ) + captured = args + throw stop + } + }, + }) + } + }, + }) + return { + timeoutError: () => + new MdxInvocationError( + seam.ruleKey, + currentPath(), + 'bounded original loader completion (timeout)', + ), + accept(error: unknown, result: unknown): unknown { + if (seam.ownIndex !== undefined) { + if ( + error === stop && + captured && + compilerExecutions === 1 && + ownExecutions === 1 && + isMdxRecord(result) + ) + return { ...result, result: captured } + if (error) rejectExecution(error) + throw failure( + 'private-stop runner callback after Devup capture (pitch bypass)', + ) + } + if (error) rejectExecution(error) + if (compilerExecutions !== 1) + throw failure('exactly one compiler execution (pitch bypass)') + return result + }, + } +} diff --git a/packages/next-plugin/src/mdx-options-instance.ts b/packages/next-plugin/src/mdx-options-instance.ts new file mode 100644 index 000000000..2e227da1e --- /dev/null +++ b/packages/next-plugin/src/mdx-options-instance.ts @@ -0,0 +1,123 @@ +import { isDeepStrictEqual } from 'node:util' + +import type { MdxInvocation, MdxInvocationLoader } from './mdx-invocation' +import { isMdxRecord, type MdxLoader, type MdxPipeline } from './mdx-pipeline' + +export type MdxOptionsInstance = { + readonly loadersFor: (pipeline: MdxPipeline) => readonly MdxLoader[] + readonly invocationLoadersFor: ( + pipeline: MdxPipeline, + invocation: MdxInvocation, + ) => readonly MdxInvocationLoader[] +} + +export class MdxTurboOptionsError extends TypeError { + readonly name = 'MdxTurboOptionsError' + constructor( + readonly loader: string, + readonly ruleKey: string, + readonly detail: string, + ) { + super(`loader ${loader} for match "${ruleKey}" ${detail}`) + } +} + +function webpackCompiler( + loader: MdxLoader, + instances: WeakMap>>, +): MdxLoader { + if (!isMdxRecord(loader.options)) return loader + const existing = instances.get(loader.options) + if (existing) return { ...loader, options: existing } + // Only the approved wrapper-mutated shells are detached. Plugin options stay shared. + const options = { ...loader.options } + for (const key of ['remarkPlugins', 'rehypePlugins', 'recmaPlugins']) { + const plugins: unknown = options[key] + if (Array.isArray(plugins)) + options[key] = plugins.map((plugin: unknown) => + Array.isArray(plugin) ? [...plugin] : plugin, + ) + } + instances.set(loader.options, options) + return { ...loader, options } +} + +function turboLoader(loader: MdxLoader, ruleKey: string): MdxLoader { + // Next 16.3.6 swc/index.js checkLoaderItem: non-JSON values are rejected, + // not normalized. Native WebpackLoaderItem has a JSON map and defaults to {}. + const serialized: unknown = JSON.parse(JSON.stringify(loader)) + if (!isMdxRecord(serialized) || !isDeepStrictEqual(loader, serialized)) + throw new MdxTurboOptionsError( + loader.loader, + ruleKey, + 'does not have serializable options. Ensure that options passed are plain JavaScript objects and values.', + ) + const options = serialized.options === undefined ? {} : serialized.options + if (!isMdxRecord(options)) + throw new MdxTurboOptionsError( + loader.loader, + ruleKey, + 'requires an object options map on the native side', + ) + // Native JSON -> Node pool JSON.parse -> transform forwards this object to getOptions. + return { loader: loader.loader, options } +} + +// Create once per preparation run, reuse for every file, discard before dev re-preparation. +export function createMdxOptionsInstance(): MdxOptionsInstance { + const instances = new WeakMap() + const webpackOptions = new WeakMap< + object, + Readonly> + >() + const invocations = new WeakMap< + MdxPipeline, + WeakMap + >() + return { + invocationLoadersFor(pipeline, invocation) { + let scoped = invocations.get(pipeline) + if (!scoped) invocations.set(pipeline, (scoped = new WeakMap())) + const existing = scoped.get(invocation) + if (existing) return existing + const loaders = invocation.loaders.map((loader, index) => { + switch (pipeline.bundler) { + case 'webpack': + return index === invocation.compilerIndex && + typeof loader !== 'string' + ? webpackCompiler(loader, webpackOptions) + : loader + case 'turbo': + return typeof loader === 'string' + ? loader + : { ...loader, ...turboLoader(loader, pipeline.ruleKey) } + default: + return pipeline.bundler satisfies never + } + }) + scoped.set(invocation, loaders) + return loaders + }, + loadersFor(pipeline) { + const existing = instances.get(pipeline) + if (existing) return existing + let loaders: readonly MdxLoader[] + switch (pipeline.bundler) { + case 'webpack': + loaders = pipeline.loaders.map((loader, index) => + index === 0 ? webpackCompiler(loader, webpackOptions) : loader, + ) + break + case 'turbo': + loaders = pipeline.loaders.map((loader) => + turboLoader(loader, pipeline.ruleKey), + ) + break + default: + return pipeline.bundler satisfies never + } + instances.set(pipeline, loaders) + return loaders + }, + } +} diff --git a/packages/next-plugin/src/mdx-pipeline.ts b/packages/next-plugin/src/mdx-pipeline.ts new file mode 100644 index 000000000..0736d9e06 --- /dev/null +++ b/packages/next-plugin/src/mdx-pipeline.ts @@ -0,0 +1,199 @@ +export type MdxRule = Readonly> +export type MdxLoader = { + readonly loader: string + readonly options?: string | Readonly> + readonly ident?: string + readonly fragment?: string + readonly type?: string +} +export type MdxPipeline = { + readonly bundler: 'turbo' | 'webpack' + readonly ruleKey: string + readonly conditions: readonly MdxRule[] + readonly aliases: Readonly> + readonly loaders: readonly MdxLoader[] + readonly issue?: string +} +export type MdxRuleSet = + | { + readonly bundler: 'turbo' + readonly rules: Readonly> + readonly aliases: Readonly> + } + | { + readonly bundler: 'webpack' + readonly rules: readonly unknown[] + readonly aliases: Readonly> + } + +export function isMdxRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function loaderDescriptor(value: unknown): MdxLoader | undefined { + if (typeof value === 'string') return { loader: value } + if (!isMdxRecord(value) || typeof value.loader !== 'string') return + if ( + value.options !== undefined && + typeof value.options !== 'string' && + !isMdxRecord(value.options) + ) + return + if (value.ident !== undefined && typeof value.ident !== 'string') return + if (value.fragment !== undefined && typeof value.fragment !== 'string') return + if (value.type !== undefined && typeof value.type !== 'string') return + return { + loader: value.loader, + ...(value.options === undefined ? {} : { options: value.options }), + ...(value.ident === undefined ? {} : { ident: value.ident }), + ...(value.fragment === undefined ? {} : { fragment: value.fragment }), + ...(value.type === undefined ? {} : { type: value.type }), + } +} + +export function isMdxCompiler(loader: string): boolean { + const path = (loader.split(/[?#]/)[0] ?? '').replaceAll('\\', '/') + return ( + /(?:^|\/)@next\/mdx\/mdx-js-loader(?:\.js)?$/.test(path) || + /(?:^|\/)@mdx-js\/loader(?:\/index\.(?:js|cjs)|\/lib\/index\.js)?$/.test( + path, + ) + ) +} + +export class MdxPipelineError extends Error { + readonly name = 'MdxPipelineError' + constructor( + readonly filename: string, + readonly detail: string, + ) { + super( + `${filename}:1:1: devup-ui MDX preparation cannot use \`${filename}\` at build time: ${detail}; needs DevupUI outermost or Next 16.2+ with a recognized configured MDX pipeline`, + ) + } +} + +export function requireMdxPipeline( + filename: string, + pipeline: MdxPipeline | undefined, +): MdxPipeline { + if (!pipeline || pipeline.issue || pipeline.loaders.length === 0) { + throw new MdxPipelineError( + filename, + pipeline?.issue ?? 'configured MDX compiler chain is unavailable', + ) + } + if (pipeline.loaders.slice(1).some((loader) => isMdxCompiler(loader.loader))) + throw new MdxPipelineError( + filename, + `rule "${pipeline.ruleKey}" contains multiple MDX compilers`, + ) + return pipeline +} + +export function composeMdxRules( + input: Extract, + extraction: MdxLoader, +): { rules: Record; pipelines: MdxPipeline[] } +export function composeMdxRules( + input: Extract, + extraction: MdxLoader, +): { rules: unknown[]; pipelines: MdxPipeline[] } +export function composeMdxRules( + input: MdxRuleSet, + extraction: MdxLoader, +): { rules: unknown[] | Record; pipelines: MdxPipeline[] } +export function composeMdxRules(input: MdxRuleSet, extraction: MdxLoader) { + const pipelines: MdxPipeline[] = [] + function compose( + rule: unknown, + key: string, + parents: readonly MdxRule[], + ): unknown { + if (!isMdxRecord(rule)) return rule + const conditions = [...parents, rule] + const member = input.bundler === 'turbo' ? 'loaders' : 'use' + const chain = rule[member] + let composed = rule + if (Array.isArray(chain)) { + const normalized = chain.filter( + (value) => loaderDescriptor(value)?.loader !== extraction.loader, + ) + const descriptors = normalized.map(loaderDescriptor) + const compilerIndex = descriptors.findIndex( + (loader) => loader && isMdxCompiler(loader.loader), + ) + if (compilerIndex >= 0) { + const segment = descriptors.slice(compilerIndex) + const loaders = segment.filter( + (loader): loader is MdxLoader => loader !== undefined, + ) + const forbidden = loaders.some((loader) => + /(?:@devup-ui|next-swc-loader|next-flight-loader)/.test( + loader.loader, + ), + ) + const issue = segment.includes(undefined) + ? 'malformed compiler loader options' + : forbidden + ? 'pre-extraction chain contains Devup, SWC or Flight' + : loaders.filter((loader) => isMdxCompiler(loader.loader)) + .length !== 1 + ? 'multiple MDX compilers in one chain' + : undefined + pipelines.push({ + bundler: input.bundler, + ruleKey: key, + conditions, + aliases: input.aliases, + loaders, + ...(issue ? { issue } : {}), + }) + if (!issue) { + composed = { + ...rule, + [member]: [ + ...normalized.slice(0, compilerIndex), + extraction, + ...normalized.slice(compilerIndex), + ], + } + } + } + } + for (const nested of ['rules', 'oneOf']) { + if (Array.isArray(rule[nested])) { + composed = { + ...composed, + [nested]: rule[nested].map((child, index) => + compose(child, `${key}.${nested}.${index}`, conditions), + ), + } + } + } + return composed + } + switch (input.bundler) { + case 'turbo': + return { + rules: Object.fromEntries( + Object.entries(input.rules).map(([key, value]) => [ + key, + Array.isArray(value) + ? value.map((rule, index) => compose(rule, `${key}.${index}`, [])) + : compose(value, key, []), + ]), + ), + pipelines, + } + case 'webpack': + return { + rules: input.rules.map((rule, index) => + compose(rule, String(index), []), + ), + pipelines, + } + default: + return input satisfies never + } +} diff --git a/packages/next-plugin/src/mdx-prepare-dependencies.ts b/packages/next-plugin/src/mdx-prepare-dependencies.ts new file mode 100644 index 000000000..3233d7dd0 --- /dev/null +++ b/packages/next-plugin/src/mdx-prepare-dependencies.ts @@ -0,0 +1,84 @@ +import { isAbsolute } from 'node:path' + +export type MdxDependencyKind = 'file' | 'context' | 'missing' | 'build' +export type MdxDependencyReport = { + readonly kind: MdxDependencyKind + readonly path: string + readonly loader: string +} + +export class MdxDependencyError extends Error { + readonly name = 'MdxDependencyError' + constructor( + readonly filename: string, + readonly dependency: unknown, + readonly loader: string, + ) { + super( + `${filename}:1:1: MDX dependency ${String(dependency)} reported by ${loader} needs an observable absolute path`, + ) + } +} + +/** Install before loader-runner assigns its methods and prevents extensions. */ +export function recordMdxDependencies(filename: string, context: object) { + const reports: MdxDependencyReport[] = [] + const build: string[] = [] + const fileAliases = new WeakMap< + (path: string) => void, + (path: string) => void + >() + function report(kind: MdxDependencyKind, path: unknown, receiver: unknown) { + let loader = 'loader-runner/resource' + if ( + typeof receiver === 'object' && + receiver !== null && + 'loaders' in receiver && + Array.isArray(receiver.loaders) && + 'loaderIndex' in receiver && + typeof receiver.loaderIndex === 'number' + ) { + const current: unknown = receiver.loaders[receiver.loaderIndex] + if ( + typeof current === 'object' && + current !== null && + 'path' in current && + typeof current.path === 'string' + ) + loader = current.path + } + if (typeof path !== 'string' || !isAbsolute(path)) + throw new MdxDependencyError(filename, path, loader) + reports.push(Object.freeze({ kind, path, loader })) + return path + } + for (const [method, kind] of [ + ['addDependency', 'file'], + ['dependency', 'file'], + ['addContextDependency', 'context'], + ['addMissingDependency', 'missing'], + ] as const) { + let assigned: ((path: string) => void) | undefined + Object.defineProperty(context, method, { + enumerable: true, + configurable: true, + get: () => assigned, + set(native: (path: string) => void) { + const existing = kind === 'file' ? fileAliases.get(native) : undefined + assigned = + existing ?? + function (this: unknown, path: string) { + native.call(this, report(kind, path, this)) + } + if (kind === 'file') fileAliases.set(native, assigned) + }, + }) + } + Object.defineProperty(context, 'addBuildDependency', { + enumerable: true, + value: function (this: unknown, path: string) { + build.push(report('build', path, this)) + }, + }) + return { reports, build } +} diff --git a/packages/next-plugin/src/mdx-prepare-plugins.ts b/packages/next-plugin/src/mdx-prepare-plugins.ts new file mode 100644 index 000000000..83d1f2437 --- /dev/null +++ b/packages/next-plugin/src/mdx-prepare-plugins.ts @@ -0,0 +1,53 @@ +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' + +// This is the verified @next/mdx 16.3.6 import protocol, not a resolver. +export async function prepareMdxPlugins( + options: Readonly>, + context: string, + wrapper: string, +) { + const wrapperRequire = createRequire(wrapper) + const dependencies: string[] = [] + async function importPlugin(plugin: unknown): Promise { + const specifier = Array.isArray(plugin) ? plugin[0] : plugin + if (typeof specifier !== 'string') return plugin + const path = wrapperRequire.resolve(specifier, { paths: [context] }) + const mod: unknown = await import( + process.platform === 'win32' ? pathToFileURL(path).href : path + ) + const imported = + typeof mod === 'object' && mod !== null && 'default' in mod + ? mod.default || mod + : mod + dependencies.push(path) + if (Array.isArray(plugin)) { + plugin[0] = imported + return plugin + } + return imported + } + const { + recmaPlugins = [], + rehypePlugins = [], + remarkPlugins = [], + ...rest + } = options + const updated = await Promise.all( + [recmaPlugins, rehypePlugins, remarkPlugins].map((plugins) => { + if (!Array.isArray(plugins)) + throw new TypeError('configured plugins must be an array') + return Promise.all(plugins.map(importPlugin)) + }), + ) + const [recma, rehype, remark] = updated + return { + options: { + ...rest, + recmaPlugins: recma, + rehypePlugins: rehype, + remarkPlugins: remark, + }, + dependencies, + } +} diff --git a/packages/next-plugin/src/mdx-prepare-result.ts b/packages/next-plugin/src/mdx-prepare-result.ts new file mode 100644 index 000000000..5586d1c0c --- /dev/null +++ b/packages/next-plugin/src/mdx-prepare-result.ts @@ -0,0 +1,48 @@ +import { isMdxRecord } from './mdx-pipeline' +import type { MdxDependencyReport } from './mdx-prepare-dependencies' + +export type PreparedMdx = { + readonly filename: string + readonly source: string + readonly map: string | Readonly> | undefined + readonly dependencies: readonly string[] + readonly contextDependencies: readonly string[] + readonly missingDependencies: readonly string[] + readonly buildDependencies: readonly string[] + readonly dependencyReports: readonly MdxDependencyReport[] +} + +function fileDependencies(value: unknown): string[] { + if (!Array.isArray(value) || value.some((file) => typeof file !== 'string')) + throw new TypeError('invalid loader dependency list') + return value.filter((file): file is string => typeof file === 'string') +} + +export function prepared( + filename: string, + value: unknown, + recorded: { + readonly build: readonly string[] + readonly reports: readonly MdxDependencyReport[] + } = { build: [], reports: [] }, +): PreparedMdx { + if (!isMdxRecord(value) || !Array.isArray(value.result)) + throw new TypeError('loader runner returned no compiled source') + const [source, map] = value.result + if (typeof source !== 'string' && !Buffer.isBuffer(source)) + throw new TypeError('MDX compiler returned no JavaScript source') + if (map != null && typeof map !== 'string' && !isMdxRecord(map)) + throw new TypeError('MDX compiler returned an invalid source map') + return { + filename, + source: source.toString(), + map: map ?? undefined, + dependencies: [ + ...new Set([filename, ...fileDependencies(value.fileDependencies)]), + ], + contextDependencies: fileDependencies(value.contextDependencies), + missingDependencies: fileDependencies(value.missingDependencies), + buildDependencies: [...new Set(recorded.build)], + dependencyReports: [...recorded.reports], + } +} diff --git a/packages/next-plugin/src/mdx-prepare-runner.ts b/packages/next-plugin/src/mdx-prepare-runner.ts new file mode 100644 index 000000000..85dc9ed23 --- /dev/null +++ b/packages/next-plugin/src/mdx-prepare-runner.ts @@ -0,0 +1,123 @@ +import { readFile } from 'node:fs' +import { parse } from 'node:querystring' + +import type { MdxInvocationLoader } from './mdx-invocation' +import { + installMdxNormalCapture, + type MdxNormalSeam, +} from './mdx-normal-capture' +import { recordMdxDependencies } from './mdx-prepare-dependencies' +import { prepared, type PreparedMdx } from './mdx-prepare-result' +import type { + MdxPreparationContext, + MdxPrewarmStep, +} from './mdx-prewarm-boundary' + +type RunnerOptions = { + readonly resource: string + readonly loaders: readonly MdxInvocationLoader[] + readonly context: object + readonly readResource: typeof readFile +} +export type RunLoaders = ( + options: RunnerOptions, + callback: (error: unknown, result: unknown) => void, +) => void +export function isRunLoaders(value: unknown): value is RunLoaders { + return typeof value === 'function' +} + +export function runMdxLoaders(request: { + readonly root: string + readonly filename: string + readonly resource?: string + readonly signal: AbortSignal + readonly timeoutMs: number + readonly loaders: readonly MdxInvocationLoader[] + readonly context: MdxPreparationContext + readonly steps: ReadonlyMap + readonly runLoaders: RunLoaders + readonly seam?: MdxNormalSeam +}): Promise { + const { + root, + filename, + signal, + timeoutMs, + loaders, + context, + steps, + runLoaders, + } = request + return new Promise((resolveOutput, reject) => { + let settled = false + function finish(error: unknown, result?: unknown) { + if (settled) return + settled = true + clearTimeout(timer) + signal.removeEventListener('abort', abort) + if (error) { + reject(error) + return + } + resolveOutput( + Promise.resolve().then(() => { + signal.throwIfAborted() + return prepared(filename, result, dependencies) + }), + ) + } + const abort = () => finish(signal.reason) + const timer = setTimeout( + () => finish(capture?.timeoutError() ?? 'MDX compiler timeout'), + timeoutMs, + ) + signal.addEventListener('abort', abort, { once: true }) + const loaderContext = { + rootContext: root, + ...(context.sourceMap === undefined + ? {} + : { sourceMap: context.sourceMap }), + ...(context.mode === undefined ? {} : { mode: context.mode }), + ...(context.compiler === undefined + ? {} + : { _compiler: context.compiler }), + devupMdxPrewarm: steps, + getOptions(this: { readonly query: unknown }) { + if (typeof this.query !== 'string') return this.query + const query = this.query.replace(/^\?/, '') + return query.startsWith('{') ? JSON.parse(query) : parse(query) + }, + emitError: (error: unknown) => finish(error), + } + const dependencies = recordMdxDependencies(filename, loaderContext) + const capture = request.seam + ? installMdxNormalCapture(loaderContext, request.seam) + : undefined + const callback = (error: unknown, result: unknown) => { + if (settled) return + if (!capture) { + finish(error, result) + return + } + try { + finish(null, capture.accept(error, result)) + } catch (cause) { + finish(cause) + } + } + async function start() { + if (settled) return + runLoaders( + { + resource: request.resource ?? filename, + loaders, + readResource: readFile, + context: loaderContext, + }, + callback, + ) + } + void start().catch(finish) + }) +} diff --git a/packages/next-plugin/src/mdx-prepare.ts b/packages/next-plugin/src/mdx-prepare.ts new file mode 100644 index 000000000..82ccca93b --- /dev/null +++ b/packages/next-plugin/src/mdx-prepare.ts @@ -0,0 +1,164 @@ +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, resolve } from 'node:path' + +import { + type MdxInvocation, + MdxLoaderExecutionError, + mdxLoaderPath, + requireMdxInvocation, +} from './mdx-invocation' +import { + createMdxOptionsInstance, + type MdxOptionsInstance, +} from './mdx-options-instance' +import { + isMdxRecord, + type MdxPipeline, + requireMdxPipeline, +} from './mdx-pipeline' +import type { PreparedMdx } from './mdx-prepare-result' +import { isRunLoaders, runMdxLoaders } from './mdx-prepare-runner' +import { + type MdxPreparationContext, + mdxPrewarmCacheOwner, + type MdxPrewarmStep, + recognizeMdxPrewarmStep, +} from './mdx-prewarm-boundary' + +export { + createMdxOptionsInstance, + type MdxOptionsInstance, +} from './mdx-options-instance' +export type { PreparedMdx } from './mdx-prepare-result' +export type { MdxPreparationContext } from './mdx-prewarm-boundary' + +export type MdxDeadline = { + readonly expiresAt: number + readonly individualMs: number +} +export type MdxCompileRequest = { + readonly root: string + readonly filename: string + readonly pipeline: MdxPipeline + readonly signal: AbortSignal + readonly deadline: MdxDeadline + readonly context?: MdxPreparationContext + readonly optionsInstance?: MdxOptionsInstance + readonly invocation?: MdxInvocation +} + +// 30s preparation + 60s completion leaves 30s of the 120s client budget. +export function createMdxDeadline( + totalMs = 30_000, + individualMs = 10_000, +): MdxDeadline { + return { + expiresAt: Date.now() + Math.min(totalMs, 30_000), + individualMs: Math.min(individualMs, 10_000), + } +} + +export class MdxCompileError extends Error { + readonly name = 'MdxCompileError' + readonly line: number + readonly column: number + constructor( + readonly filename: string, + cause: unknown, + pipeline?: MdxPipeline, + ) { + const original = + cause instanceof MdxLoaderExecutionError ? cause.cause : cause + const position = + isMdxRecord(original) && isMdxRecord(original.place) ? original.place : {} + const start = isMdxRecord(position.start) ? position.start : position + const line = typeof start.line === 'number' ? start.line : 1 + const column = typeof start.column === 'number' ? start.column : 1 + super( + `${filename}:${line}:${column}: devup-ui MDX preparation cannot use \`${filename}\` at build time: ${cause instanceof Error ? cause.message : String(cause)}; rule "${pipeline?.ruleKey ?? ''}" loader ${pipeline?.loaders[0]?.loader ?? ''}; needs the configured compiler to complete successfully with observable original descriptors and representable loader context`, + { cause: original }, + ) + this.line = line + this.column = column + } +} + +export async function compileMdx( + request: MdxCompileRequest, +): Promise { + const { root, filename, signal, deadline } = request + const pipeline = requireMdxPipeline(filename, request.pipeline) + const timeoutMs = Math.min( + deadline.individualMs, + 10_000, + deadline.expiresAt - Date.now(), + ) + if (signal.aborted) + throw new MdxCompileError(filename, signal.reason, pipeline) + if (timeoutMs <= 0) + throw new MdxCompileError( + filename, + 'MDX preparation deadline exceeded', + pipeline, + ) + try { + const projectRequire = createRequire(join(root, 'package.json')) + const runner: unknown = projectRequire( + 'next/dist/compiled/loader-runner/LoaderRunner.js', + ) + if (!isMdxRecord(runner) || !isRunLoaders(runner.runLoaders)) + throw new TypeError('project-installed Next loader runner is unavailable') + const runLoaders = runner.runLoaders + const devupMdxPrewarm = new Map() + const context = request.context ?? { owner: {}, generation: {} } + const instance = request.optionsInstance ?? createMdxOptionsInstance() + const invocation = + request.invocation && + requireMdxInvocation(pipeline, request.invocation, filename) + const loaders = invocation + ? instance.invocationLoadersFor(pipeline, invocation) + : instance.loadersFor(pipeline).map((loader) => { + const path = mdxLoaderPath(loader) + const file = projectRequire.resolve( + isAbsolute(path) + ? path + : path.startsWith('.') + ? resolve(root, path) + : path, + ) + return { ...loader, loader: file + loader.loader.slice(path.length) } + }) + const compilerIndex = invocation?.compilerIndex ?? 0 + const compiler = loaders[compilerIndex] + if (!compiler) throw new TypeError('configured MDX compiler is unavailable') + const compilerPath = mdxLoaderPath(compiler) + const step = recognizeMdxPrewarmStep(compilerPath) + if (step) + devupMdxPrewarm.set(compilerIndex, { + ...step, + compiler: mdxPrewarmCacheOwner(context, pipeline, dirname(filename)), + }) + return await runMdxLoaders({ + root, + filename, + ...(invocation ? { resource: invocation.resource } : {}), + signal, + timeoutMs, + loaders, + context, + steps: devupMdxPrewarm, + runLoaders, + seam: { + ruleKey: pipeline.ruleKey, + compilerIndex, + compilerPath, + loaderCount: loaders.length, + bridge: step !== undefined, + ...(invocation ? { ownIndex: invocation.ownIndex } : {}), + }, + }) + } catch (cause) { + if (cause instanceof MdxCompileError) throw cause + throw new MdxCompileError(filename, cause, pipeline) + } +} diff --git a/packages/next-plugin/src/mdx-prewarm-boundary.ts b/packages/next-plugin/src/mdx-prewarm-boundary.ts new file mode 100644 index 000000000..48f840ae3 --- /dev/null +++ b/packages/next-plugin/src/mdx-prewarm-boundary.ts @@ -0,0 +1,86 @@ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' + +import { isMdxRecord, type MdxPipeline } from './mdx-pipeline' + +export type MdxPreparationContext = { + readonly owner: object + readonly generation: object + readonly compiler?: object + readonly mode?: 'development' | 'production' | 'none' + readonly sourceMap?: boolean +} +export type MdxPrewarmStep = { + readonly module: string + readonly wrapper: string | undefined + readonly compiler: object +} + +const hashes = { + next: '2ba24dda327d48e82b4fbcd31f4b5521ebd76e9cdbeb591fd42a0a10beb2465a', + mdx: '7e5de6bbf8940aa20c341f033a6be961c247d72036d3795ff7b5cd9bc56ae20d', + esm: 'cacf67bb7cb2610bdb5c7a8cd580804a9dde2d6e69a630b05205a7972b1d9116', +} as const + +function verified(file: string, hash: string): boolean { + return ( + existsSync(file) && + createHash('sha256').update(readFileSync(file)).digest('hex') === hash + ) +} +function version(directory: string, expected: string): boolean { + const file = join(directory, 'package.json') + if (!existsSync(file)) return false + const metadata: unknown = JSON.parse(readFileSync(file, 'utf8')) + return isMdxRecord(metadata) && metadata.version === expected +} + +export function recognizeMdxPrewarmStep(file: string) { + const normalized = file.replaceAll('\\', '/') + let wrapper: string | undefined + let entry = file + if (normalized.endsWith('/@next/mdx/mdx-js-loader.js')) { + if (!version(dirname(file), '16.3.6') || !verified(file, hashes.next)) + return + wrapper = file + entry = createRequire(file).resolve('@mdx-js/loader') + } else if (!normalized.endsWith('/@mdx-js/loader/index.cjs')) return + const directory = dirname(entry) + const module = join(directory, 'lib/index.js') + if ( + !version(directory, '3.1.1') || + !verified(entry, hashes.mdx) || + !verified(module, hashes.esm) + ) + return + return { module, wrapper } +} + +// Each app/generation/pipeline/context gets an upstream compiler cache owner. +// Pipeline identity retains live function options; no function serialization. +const owners = new WeakMap< + object, + WeakMap>>> +>() +export function mdxPrewarmCacheOwner( + context: MdxPreparationContext, + pipeline: MdxPipeline, + directory: string, +): object { + let generations = owners.get(context.owner) + if (!generations) owners.set(context.owner, (generations = new WeakMap())) + let pipelines = generations.get(context.generation) + if (!pipelines) + generations.set(context.generation, (pipelines = new WeakMap())) + let compilers = pipelines.get(pipeline) + if (!compilers) pipelines.set(pipeline, (compilers = new WeakMap())) + const compiler = context.compiler ?? context.owner + let resources = compilers.get(compiler) + if (!resources) compilers.set(compiler, (resources = new Map())) + const key = JSON.stringify([directory, context.mode, context.sourceMap]) + let owner = resources.get(key) + if (!owner) resources.set(key, (owner = {})) + return owner +} diff --git a/packages/next-plugin/src/mdx-prewarm-loader.ts b/packages/next-plugin/src/mdx-prewarm-loader.ts new file mode 100644 index 000000000..69ab642be --- /dev/null +++ b/packages/next-plugin/src/mdx-prewarm-loader.ts @@ -0,0 +1,56 @@ +import { pathToFileURL } from 'node:url' + +import { isMdxRecord } from './mdx-pipeline' +import { prepareMdxPlugins } from './mdx-prepare-plugins' +import type { MdxPrewarmStep } from './mdx-prewarm-boundary' + +type Callback = (error?: unknown, source?: unknown, map?: unknown) => void +type Context = { + readonly loaderIndex: number + readonly context: string + readonly devupMdxPrewarm: ReadonlyMap + readonly getOptions: () => unknown + readonly async: () => Callback + readonly addDependency: (path: string) => void +} +type InstalledLoader = ( + this: Context, + source: string, + callback: Callback, +) => void +function isInstalledLoader(value: unknown): value is InstalledLoader { + return typeof value === 'function' +} + +// Preparation only. Native bundler rules never reference this entry. +export default function mdxPrewarmLoader(this: Context, source: string) { + const callback = this.async() + const run = async () => { + const step = this.devupMdxPrewarm.get(this.loaderIndex) + if (!step) throw new TypeError('MDX preparation bridge step is unavailable') + let options = this.getOptions() + if (step.wrapper) { + if (!isMdxRecord(options)) + throw new TypeError('Next MDX options must be an object') + const prepared = await prepareMdxPlugins( + options, + this.context, + step.wrapper, + ) + options = prepared.options + for (const path of prepared.dependencies) this.addDependency(path) + } + const mod: unknown = await import(pathToFileURL(step.module).href) + if (!isMdxRecord(mod) || !isInstalledLoader(mod.loader)) + throw new TypeError('installed MDX ESM loader is unavailable') + const proxy = new Proxy(this, { + get(target, property, receiver) { + if (property === 'getOptions') return () => options + if (property === '_compiler') return step.compiler + return Reflect.get(target, property, receiver) + }, + }) + mod.loader.call(proxy, source, callback) + } + void run().catch(callback) +} diff --git a/packages/next-plugin/src/mdx-resolution-cache.ts b/packages/next-plugin/src/mdx-resolution-cache.ts new file mode 100644 index 000000000..bb83f3963 --- /dev/null +++ b/packages/next-plugin/src/mdx-resolution-cache.ts @@ -0,0 +1,55 @@ +import { isMdxRecord } from './mdx-pipeline' +import type { MdxDependencyReport } from './mdx-prepare-dependencies' +import type { MdxInputFingerprint } from './mdx-source-freshness' + +function dependencyReport(value: unknown): MdxDependencyReport { + if ( + !isMdxRecord(value) || + (value.kind !== 'file' && + value.kind !== 'build' && + value.kind !== 'missing' && + value.kind !== 'context') || + typeof value.path !== 'string' || + typeof value.loader !== 'string' + ) + throw new TypeError('Invalid MDX restart dependency report') + return Object.freeze({ + kind: value.kind, + path: value.path, + loader: value.loader, + }) +} + +export function readResolutionRestartProof( + value: unknown, +): readonly MdxInputFingerprint[] { + if (!Array.isArray(value)) + throw new TypeError('Invalid MDX restart resolution proof') + return Object.freeze( + value.map((input: unknown) => { + const report = dependencyReport(input) + if ( + !isMdxRecord(input) || + (report.kind !== 'file' && report.kind !== 'missing') || + typeof input.fingerprint !== 'string' || + typeof input.mtime !== 'number' || + (report.kind === 'missing' && input.fingerprint !== 'absent') + ) + throw new TypeError('Invalid MDX restart resolution proof') + return Object.freeze({ + ...report, + fingerprint: input.fingerprint, + mtime: input.mtime, + }) + }), + ) +} + +export function readCompilerRestartReports( + value: unknown, +): readonly MdxDependencyReport[] { + if (value === undefined) return Object.freeze([]) + if (!Array.isArray(value)) + throw new TypeError('Invalid MDX restart compiler reports') + return Object.freeze(value.map(dependencyReport)) +} diff --git a/packages/next-plugin/src/mdx-resolution-delivery.ts b/packages/next-plugin/src/mdx-resolution-delivery.ts new file mode 100644 index 000000000..48d3316f2 --- /dev/null +++ b/packages/next-plugin/src/mdx-resolution-delivery.ts @@ -0,0 +1,78 @@ +import { resolve } from 'node:path' + +import { computeReachableFiles } from '@devup-ui/plugin-utils' + +import { + unionResolutionProof, + verifyResolutionProof, +} from './mdx-resolution-proof' +import type { MdxCacheEntry } from './mdx-source-cache' +import type { MdxInputFingerprint } from './mdx-source-freshness' +import type { runMdxSourcePreparation } from './mdx-source-run' +import type { MdxExtractionReport } from './mdx-source-types' + +export function deliverResolutionProof( + run: Awaited>, + root: string, + reports: readonly MdxExtractionReport[], +) { + const graphProof = run.resolution.snapshot() + const byKey = new Map( + graphProof.map((input) => [`${input.kind}:${input.path}`, input]), + ) + for (const [filename, entry] of run.entries) { + const reached = new Set( + computeReachableFiles({ + srcDir: root, + graph: run.graph, + entries: [filename], + }), + ) + reached.add(filename) + const requestInputs = (run.graph.requests ?? []).flatMap( + (request): readonly MdxInputFingerprint[] => { + if (!reached.has(request.importer)) return [] + switch (request.outcome.kind) { + case 'resolved': + return [ + ...request.outcome.inputs.fileDependencies.map( + (path) => `file:${path}`, + ), + ...request.outcome.inputs.missingDependencies.map( + (path) => `missing:${path}`, + ), + ].flatMap((key) => { + const input = byKey.get(key) + return input ? [input] : [] + }) + case 'external': + case 'ignored': + case 'excluded': + case 'unresolved': + case 'error': + return [] + } + }, + ) + const extraction = reports + .filter((report) => resolve(root, report.filename) === filename) + .flatMap((report) => report.resolutionInputs ?? []) + const proof = unionResolutionProof( + entry.resolutionInputs ?? [], + requestInputs, + extraction, + ) + verifyResolutionProof(filename, proof) + const next: MdxCacheEntry = Object.freeze({ + ...entry, + resolutionInputs: proof, + }) + run.entries.set(filename, next) + } + const proof = unionResolutionProof( + graphProof, + ...reports.map((report) => report.resolutionInputs ?? []), + ) + verifyResolutionProof(root, proof) + return proof +} diff --git a/packages/next-plugin/src/mdx-resolution-proof.ts b/packages/next-plugin/src/mdx-resolution-proof.ts new file mode 100644 index 000000000..82417ce47 --- /dev/null +++ b/packages/next-plugin/src/mdx-resolution-proof.ts @@ -0,0 +1,81 @@ +import type { ResolutionInputs } from '@devup-ui/plugin-utils' + +import { + changedMdxInput, + fingerprintMdxInput, + MdxFreshnessError, + type MdxInputFingerprint, +} from './mdx-source-freshness' + +export function createResolutionProof(filename: string) { + const inputs = new Map() + let failure: unknown + let active = true + function observe(observation: ResolutionInputs) { + if (!active) return + try { + for (const [kind, paths] of [ + ['file', observation.fileDependencies], + ['missing', observation.missingDependencies], + ] as const) + for (const path of paths) { + const key = `${kind}:${path}` + if (inputs.has(key)) continue + const input = fingerprintMdxInput(filename, { + kind, + path, + loader: 'devup/resolution', + }) + if (kind === 'missing' && input.fingerprint !== 'absent') + throw new MdxFreshnessError( + filename, + input, + 'observed missing input appeared during resolution', + ) + inputs.set(key, input) + } + } catch (cause) { + failure ??= cause + } + } + return Object.freeze({ + observe, + snapshot() { + if (failure !== undefined) throw failure + return Object.freeze( + [...inputs.values()].sort((a, b) => { + const first = `${a.kind}:${a.path}` + const second = `${b.kind}:${b.path}` + return Number(first > second) - Number(first < second) + }), + ) + }, + retire() { + active = false + }, + }) +} + +export function verifyResolutionProof( + filename: string, + inputs: readonly MdxInputFingerprint[], +) { + const changed = changedMdxInput(filename, inputs) + if (changed) + throw new MdxFreshnessError( + filename, + changed, + 'resolution input changed before publication', + ) +} + +export function unionResolutionProof( + ...proofs: readonly (readonly MdxInputFingerprint[])[] +) { + const inputs = new Map() + for (const proof of proofs) + for (const input of proof) + if (!inputs.has(`${input.kind}:${input.path}`)) + inputs.set(`${input.kind}:${input.path}`, Object.freeze({ ...input })) + return Object.freeze([...inputs.values()]) +} diff --git a/packages/next-plugin/src/mdx-source-affected.ts b/packages/next-plugin/src/mdx-source-affected.ts new file mode 100644 index 000000000..c3d846033 --- /dev/null +++ b/packages/next-plugin/src/mdx-source-affected.ts @@ -0,0 +1,25 @@ +import { isAbsolute, relative } from 'node:path' + +import type { MdxSourceGeneration } from './mdx-source-types' + +export function affectedMdxSources( + generation: MdxSourceGeneration, + path: string, +): readonly string[] { + return Object.freeze( + Object.entries(generation.compiled) + .filter(([, entry]) => + [...entry.inputs, ...(entry.resolutionInputs ?? [])].some((input) => { + if (input.path === path) return true + const child = relative(input.path, path) + return ( + input.kind === 'context' && + child.split(/[\\/]/)[0] !== '..' && + !isAbsolute(child) + ) + }), + ) + .map(([filename]) => filename) + .sort(), + ) +} diff --git a/packages/next-plugin/src/mdx-source-cache.ts b/packages/next-plugin/src/mdx-source-cache.ts new file mode 100644 index 000000000..5ea42ddd6 --- /dev/null +++ b/packages/next-plugin/src/mdx-source-cache.ts @@ -0,0 +1,225 @@ +import { randomUUID } from 'node:crypto' + +import { + compareMdxBindingValues, + snapshotMdxBindingValue, +} from './mdx-binding-value' +import { isMdxRecord } from './mdx-pipeline' +import { + readCompilerRestartReports, + readResolutionRestartProof, +} from './mdx-resolution-cache' +import { reportedMdxInputs, sourceHash } from './mdx-source-freshness' +import { immutableMdxMap } from './mdx-source-immutable' +import type { + MdxCompiledRecord, + MdxPipelineSelection, +} from './mdx-source-types' + +type PortableValue = + | null + | boolean + | number + | string + | readonly PortableValue[] + | { readonly [key: string]: PortableValue } +export type MdxCacheEntry = MdxCompiledRecord & { + readonly identity: ReturnType + readonly portable: PortableValue | undefined + readonly sourceMap: boolean | undefined + readonly mode: string | undefined + readonly compilerKey: string +} + +function portableValue( + value: unknown, + seen = new Set(), +): PortableValue | undefined { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value + if (typeof value === 'number') + return Number.isFinite(value) ? value : undefined + if (typeof value !== 'object') return undefined + const prototype: unknown = Object.getPrototypeOf(value) + if ( + (!Array.isArray(value) && + prototype !== Object.prototype && + prototype !== null) || + seen.has(value) + ) + return undefined + seen.add(value) + const result: Record = {} + for (const key of Reflect.ownKeys(value)) { + if (Array.isArray(value) && key === 'length') continue + if (typeof key !== 'string') return undefined + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor || !('value' in descriptor)) return undefined + const child: unknown = descriptor.value + const portable = portableValue(child, seen) + if (portable === undefined) return undefined + result[key] = portable + } + seen.delete(value) + if (Array.isArray(value) && Object.keys(result).length !== value.length) + return undefined + return Array.isArray(value) + ? Object.freeze(Object.values(result)) + : Object.freeze(result) +} + +export function captureMdxCacheIdentity(selection: MdxPipelineSelection) { + const portable = portableValue(selection.identity) + return { + identity: snapshotMdxBindingValue(selection.identity), + portable, + compilerKey: + portable === undefined + ? `live:${randomUUID()}` + : sourceHash(JSON.stringify(portable)), + sourceMap: selection.context.sourceMap, + mode: selection.context.mode, + } +} +export function compatibleMdxCache( + entry: MdxCacheEntry, + selection: MdxPipelineSelection, +): boolean { + return ( + entry.sourceMap === selection.context.sourceMap && + entry.mode === selection.context.mode && + compareMdxBindingValues( + entry.identity, + snapshotMdxBindingValue(selection.identity), + ) === undefined + ) +} + +export function exportMdxRestartCache( + entries: ReadonlyMap, +): string { + return JSON.stringify( + [...entries].flatMap(([filename, entry]) => + entry.portable === undefined + ? [] + : [ + { + filename, + identity: entry.portable, + sourceMap: entry.sourceMap, + mode: entry.mode, + prepared: entry.prepared, + inputs: entry.inputs, + resolutionInputs: entry.resolutionInputs, + resolutionProofVersion: 1, + }, + ], + ), + ) +} + +export function importMdxRestartCache( + serialized: string, +): ReadonlyMap { + const data: unknown = JSON.parse(serialized) + if (!Array.isArray(data)) throw new TypeError('Invalid MDX restart cache') + const entries = new Map() + for (const item of data) { + const value: unknown = item + if ( + !isMdxRecord(value) || + typeof value.filename !== 'string' || + !isMdxRecord(value.prepared) || + !Array.isArray(value.inputs) + ) + throw new TypeError('Invalid MDX restart entry') + const p = value.prepared + if ( + typeof p.source !== 'string' || + p.filename !== value.filename || + (p.map !== undefined && typeof p.map !== 'string' && !isMdxRecord(p.map)) + ) + throw new TypeError('Invalid MDX restart source') + function paths(input: unknown): readonly string[] { + if ( + !Array.isArray(input) || + input.some((path) => typeof path !== 'string') + ) + throw new TypeError('Invalid MDX restart paths') + return Object.freeze( + input.filter((path): path is string => typeof path === 'string'), + ) + } + const inputs = value.inputs.map((input: unknown) => { + if ( + !isMdxRecord(input) || + (input.kind !== 'file' && + input.kind !== 'build' && + input.kind !== 'missing' && + input.kind !== 'context') || + typeof input.path !== 'string' || + typeof input.loader !== 'string' || + typeof input.fingerprint !== 'string' || + typeof input.mtime !== 'number' + ) + throw new TypeError('Invalid MDX restart fingerprint') + return Object.freeze({ + kind: input.kind, + path: input.path, + loader: input.loader, + fingerprint: input.fingerprint, + mtime: input.mtime, + }) + }) + const resolutionInputs = + value.resolutionProofVersion === 1 + ? readResolutionRestartProof(value.resolutionInputs) + : undefined + const portable = portableValue(value.identity) + if ( + portable === undefined || + (value.sourceMap !== undefined && typeof value.sourceMap !== 'boolean') || + (value.mode !== undefined && typeof value.mode !== 'string') + ) + throw new TypeError('Invalid MDX restart identity') + const prepared = Object.freeze({ + filename: value.filename, + source: p.source, + map: immutableMdxMap(p.map), + dependencies: paths(p.dependencies), + contextDependencies: paths(p.contextDependencies), + missingDependencies: paths(p.missingDependencies), + buildDependencies: paths(p.buildDependencies), + dependencyReports: readCompilerRestartReports(p.dependencyReports), + }) + if ( + entries.has(value.filename) || + !prepared.dependencies.includes(value.filename) || + reportedMdxInputs(prepared).some( + (report) => + !inputs.some( + (input) => input.kind === report.kind && input.path === report.path, + ), + ) || + inputs.some( + (input) => input.kind === 'missing' && input.fingerprint !== 'absent', + ) + ) + throw new TypeError('Incomplete MDX restart input proof') + if (resolutionInputs === undefined) continue + entries.set( + value.filename, + Object.freeze({ + identity: snapshotMdxBindingValue(portable), + portable, + compilerKey: sourceHash(JSON.stringify(portable)), + sourceMap: value.sourceMap, + mode: value.mode, + prepared, + inputs: Object.freeze(inputs), + resolutionInputs: Object.freeze(resolutionInputs), + }), + ) + } + return entries +} diff --git a/packages/next-plugin/src/mdx-source-control.ts b/packages/next-plugin/src/mdx-source-control.ts new file mode 100644 index 000000000..01092c0ba --- /dev/null +++ b/packages/next-plugin/src/mdx-source-control.ts @@ -0,0 +1,41 @@ +import { createMdxDeadline, type MdxDeadline } from './mdx-prepare' +import { MdxFreshnessError } from './mdx-source-freshness' + +export async function withMdxSourceControl( + request: { + readonly filename: string + readonly signal: AbortSignal + readonly deadline?: MdxDeadline + }, + work: (signal: AbortSignal, deadline: MdxDeadline) => Promise, +): Promise { + request.signal.throwIfAborted() + const deadline = request.deadline ?? createMdxDeadline() + const controller = new AbortController() + const propagate = () => controller.abort(request.signal.reason) + request.signal.addEventListener('abort', propagate, { once: true }) + const timer = setTimeout( + () => + controller.abort( + new MdxFreshnessError( + request.filename, + { kind: 'file', path: request.filename, loader: 'generation' }, + 'preparation deadline exceeded', + ), + ), + Math.max(0, deadline.expiresAt - Date.now()), + ) + const aborted = Promise.withResolvers() + const reject = () => aborted.reject(controller.signal.reason) + controller.signal.addEventListener('abort', reject, { once: true }) + try { + return await Promise.race([ + work(controller.signal, deadline), + aborted.promise, + ]) + } finally { + clearTimeout(timer) + request.signal.removeEventListener('abort', propagate) + controller.signal.removeEventListener('abort', reject) + } +} diff --git a/packages/next-plugin/src/mdx-source-delivery.ts b/packages/next-plugin/src/mdx-source-delivery.ts new file mode 100644 index 000000000..9bd941771 --- /dev/null +++ b/packages/next-plugin/src/mdx-source-delivery.ts @@ -0,0 +1,247 @@ +import { resolve } from 'node:path' + +import { computeReachableFiles } from '@devup-ui/plugin-utils' + +import { deliverResolutionProof } from './mdx-resolution-delivery' +import type { MdxCacheEntry } from './mdx-source-cache' +import { + changedMdxInput, + fingerprintMdxInput, + MdxFreshnessError, +} from './mdx-source-freshness' +import { immutableMdxPlan } from './mdx-source-immutable' +import { + collectMdxOrdinaryInputs, + ordinaryExtractionEvidence, + sourceInputKey, +} from './mdx-source-inputs' +import type { runMdxSourcePreparation } from './mdx-source-run' +import type { + MdxBuildBinding, + MdxExtractionReport, + MdxExtractionView, + MdxSourceGeneration, +} from './mdx-source-types' +import type { DevupWasm } from './wasm' + +function compiledSources( + root: string, + entries: ReadonlyMap, +) { + return [...entries].map(([filename, entry]) => + Object.freeze({ + input: Object.freeze({ + filename: sourceInputKey(root, filename), + resourcePath: filename, + source: entry.prepared.source, + sourceType: 'compiled-mdx' as const, + dependencies: Object.freeze( + entry.inputs + .filter( + (input) => + (input.kind === 'file' || input.kind === 'build') && + input.path !== filename, + ) + .map((input) => sourceInputKey(root, input.path)), + ), + stamps: Object.freeze( + Object.fromEntries( + entry.inputs + .filter( + (input) => input.kind === 'file' || input.kind === 'build', + ) + .map((input) => [input.path, input.fingerprint]), + ), + ), + backing: + entry.inputs.find((input) => input.path === filename)?.fingerprint ?? + '', + }), + evidence: Object.freeze({ + compilerFingerprint: entry.compilerKey, + fileFingerprints: Object.freeze( + Object.fromEntries( + entry.inputs + .filter( + (input) => input.kind === 'file' || input.kind === 'build', + ) + .map((input) => [input.path, input.fingerprint]), + ), + ), + contextFingerprints: Object.freeze( + Object.fromEntries( + entry.inputs + .filter((input) => input.kind === 'context') + .map((input) => [input.path, input.fingerprint]), + ), + ), + missingDependencies: entry.prepared.missingDependencies, + ...(typeof entry.prepared.map === 'string' + ? { map: entry.prepared.map } + : {}), + }), + }), + ) +} + +export async function deliverMdxSourceGeneration( + binding: MdxBuildBinding, + run: Awaited>, + signal: AbortSignal, +) { + const { entries, planned, resolver, cacheReader } = run + const root = binding.effectiveAppContext.root + const plan = immutableMdxPlan(run.plan, run.graph) + let ordinary = collectMdxOrdinaryInputs(binding, [...planned]) + run.enableExtraction() + let reports: readonly MdxExtractionReport[] = [] + if (ordinary.pending.length === 0) { + for (;;) { + const view: MdxExtractionView = { + plan, + resolver, + inputs: Object.freeze([ + ...ordinary.ordinaryInputs, + ...compiledSources(root, entries).map((source) => source.input), + ]), + } + try { + reports = await binding.extractDependencies(view, signal) + } catch (cause) { + if (run.pending.size === 0) throw cause + if (!(cause instanceof Error)) throw cause + break + } + signal.throwIfAborted() + const dependencies = reports + .flatMap((report) => report.dependencies) + .map((file) => resolve(root, file)) + const newFiles = dependencies.filter((file) => !planned.has(file)) + for (const file of newFiles) planned.add(file) + if (newFiles.length === 0) break + ordinary = collectMdxOrdinaryInputs(binding, [...planned]) + if (ordinary.pending.length) break + } + } + for (const expectation of ordinary.pending) + run.pending.set(expectation.filename, expectation) + const resolutionInputs = deliverResolutionProof(run, root, reports) + for (const report of reports) { + const filename = resolve(root, report.filename) + const entry = entries.get(filename) + if (!entry) continue + const inputs = new Map( + entry.inputs.map((input) => [`${input.kind}:${input.path}`, input]), + ) + for (const dependency of report.dependencies) { + const path = resolve(root, dependency) + if (inputs.has(`file:${path}`)) continue + inputs.set( + `file:${path}`, + fingerprintMdxInput(filename, { + kind: 'file', + path, + loader: 'devup/extractor', + }), + ) + } + entries.set( + filename, + Object.freeze({ ...entry, inputs: Object.freeze([...inputs.values()]) }), + ) + } + for (const [filename, entry] of entries) { + const reached = new Set( + computeReachableFiles({ + srcDir: [...binding.effectiveAppContext.sourceRoots, root], + graph: run.graph, + entries: [filename], + }), + ) + const inputs = new Map( + entry.inputs.map((input) => [`${input.kind}:${input.path}`, input]), + ) + for (const input of ordinary.fingerprints) + if (reached.has(input.path) && !inputs.has(`file:${input.path}`)) + inputs.set(`file:${input.path}`, input) + entries.set( + filename, + Object.freeze({ ...entry, inputs: Object.freeze([...inputs.values()]) }), + ) + } + for (const [filename, entry] of entries) { + const changed = changedMdxInput(filename, entry.inputs) + if (changed) + throw new MdxFreshnessError( + filename, + changed, + 'input changed after preparation', + ) + } + const ordinaryChanged = changedMdxInput(root, ordinary.fingerprints) + if (ordinaryChanged) + throw new MdxFreshnessError( + root, + ordinaryChanged, + 'ordinary input changed during extraction', + ) + signal.throwIfAborted() + const ordinaryInputs = ordinaryExtractionEvidence(ordinary.ordinaryInputs, { + root, + reports, + fingerprints: [ + ...ordinary.fingerprints, + ...[...entries.values()].flatMap((entry) => entry.inputs), + ], + }) + const inputs = Object.freeze( + [ + ...ordinaryInputs, + ...compiledSources(root, entries).map((source) => source.input), + ].sort((a, b) => a.filename.localeCompare(b.filename)), + ) + const view = Object.freeze({ plan, resolver, inputs }) + const generation: MdxSourceGeneration = Object.freeze({ + ...view, + key: Object.freeze({}), + compiled: Object.freeze( + Object.fromEntries( + [...entries].map(([filename, entry]) => [ + filename, + Object.freeze({ + prepared: entry.prepared, + inputs: entry.inputs, + resolutionInputs: entry.resolutionInputs, + }), + ]), + ), + ), + sources: Object.freeze(compiledSources(root, entries)), + ordinaryInputs, + pendingOrdinary: Object.freeze([...run.pending.values()]), + cacheReader, + resolutionInputs, + watchInputs: Object.freeze( + [ + ...new Set([ + ...ordinary.fingerprints.map((input) => input.path), + ...resolutionInputs.map((input) => input.path), + ...run.pending.keys(), + ...[...entries.values()].flatMap((entry) => + entry.inputs.map((input) => input.path), + ), + ]), + ].sort(), + ), + extractionDependencies: Object.freeze( + reports.map((report) => + Object.freeze({ + ...report, + dependencies: Object.freeze([...report.dependencies]), + }), + ), + ), + configureWasm: (wasm: DevupWasm) => binding.configureWasm(wasm, view), + }) + return { generation, ordinary: ordinary.fingerprints } +} diff --git a/packages/next-plugin/src/mdx-source-freshness.ts b/packages/next-plugin/src/mdx-source-freshness.ts new file mode 100644 index 000000000..3b375b908 --- /dev/null +++ b/packages/next-plugin/src/mdx-source-freshness.ts @@ -0,0 +1,192 @@ +import { createHash } from 'node:crypto' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { isAbsolute } from 'node:path' + +import type { MdxDependencyReport } from './mdx-prepare-dependencies' +import type { PreparedMdx } from './mdx-prepare-result' + +export type MdxInputFingerprint = MdxDependencyReport & { + readonly fingerprint: string + readonly mtime: number +} + +export class MdxFreshnessError extends Error { + readonly name = 'MdxFreshnessError' + constructor( + readonly filename: string, + readonly input: MdxDependencyReport, + readonly detail: string, + ) { + super( + `${filename}:1:1: MDX preparation cannot use input \`${input.path}\` reported by ${input.loader}: ${detail}; needs re-preparation before CSS publication`, + ) + } +} + +export function sourceHash(source: string | Buffer): string { + return createHash('sha256').update(source).digest('hex') +} + +export function fingerprintMdxInput( + filename: string, + input: MdxDependencyReport, +): MdxInputFingerprint { + if (!isAbsolute(input.path)) + throw new MdxFreshnessError( + filename, + input, + 'dependency path is not absolute', + ) + try { + const stat = statSync(input.path, { throwIfNoEntry: false }) + switch (input.kind) { + case 'missing': + return Object.freeze({ + ...input, + fingerprint: stat ? 'present' : 'absent', + mtime: 0, + }) + case 'file': + case 'build': + if (!stat?.isFile()) + throw new MdxFreshnessError( + filename, + input, + 'reported file is not readable', + ) + return Object.freeze({ + ...input, + fingerprint: sourceHash(readFileSync(input.path)), + mtime: Number(stat.mtime), + }) + case 'context': { + if (!stat?.isDirectory()) + throw new MdxFreshnessError( + filename, + input, + 'reported directory is not readable', + ) + const entries = readdirSync(input.path, { withFileTypes: true }) + .map( + (entry) => + [ + entry.name, + entry.isFile() + ? 'file' + : entry.isDirectory() + ? 'directory' + : entry.isSymbolicLink() + ? 'symlink' + : 'other', + ] as const, + ) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + return Object.freeze({ + ...input, + fingerprint: sourceHash(JSON.stringify(entries)), + mtime: Number(stat.mtime), + }) + } + default: + return input.kind satisfies never + } + } catch (cause) { + if (cause instanceof MdxFreshnessError) throw cause + throw new MdxFreshnessError( + filename, + input, + cause instanceof Error ? cause.message : String(cause), + ) + } +} + +export function reportedMdxInputs( + output: PreparedMdx, +): readonly MdxDependencyReport[] { + const inputs = new Map() + for (const [kind, paths] of [ + ['file', output.dependencies], + ['context', output.contextDependencies], + ['missing', output.missingDependencies], + ['build', output.buildDependencies], + ] as const) { + for (const path of paths) + inputs.set( + `${kind}:${path}`, + Object.freeze({ + kind, + path, + loader: + output.dependencyReports.find( + (report) => report.kind === kind && report.path === path, + )?.loader ?? 'loader-runner/resource', + }), + ) + } + return Object.freeze([...inputs.values()]) +} + +export function createMdxTimestampAccuracy() { + let accuracy = 2000 + return { + apply(mtime: number) { + if (accuracy > 1 && mtime % 2 !== 0) accuracy = 1 + else if (accuracy > 10 && mtime % 20 !== 0) accuracy = 10 + else if (accuracy > 100 && mtime % 200 !== 0) accuracy = 100 + else if (accuracy > 1000 && mtime % 2000 !== 0) accuracy = 1000 + return accuracy + }, + } +} + +export function captureMdxFreshness( + output: PreparedMdx, + control: { + readonly startedAt: number + readonly development: boolean + readonly accuracy: ReturnType + }, +): readonly MdxInputFingerprint[] { + const inputs = reportedMdxInputs(output).map((input) => + fingerprintMdxInput(output.filename, input), + ) + for (const input of inputs) { + if (input.mtime) control.accuracy.apply(input.mtime) + if (input.kind === 'missing' && input.fingerprint !== 'absent') + throw new MdxFreshnessError( + output.filename, + input, + 'reported missing dependency appeared during compilation', + ) + } + if (control.development) { + const margin = control.accuracy.apply(0) + const changed = inputs.find( + (input) => + input.kind !== 'missing' && input.mtime >= control.startedAt - margin, + ) + if (changed) + throw new MdxFreshnessError( + output.filename, + changed, + 'input timestamp overlaps the compile interval', + ) + } + return Object.freeze(inputs) +} + +export function changedMdxInput( + filename: string, + inputs: readonly MdxInputFingerprint[], +): MdxInputFingerprint | undefined { + return inputs.find((input) => { + try { + return ( + fingerprintMdxInput(filename, input).fingerprint !== input.fingerprint + ) + } catch (cause) { + if (cause instanceof MdxFreshnessError) return true + throw cause + } + }) +} diff --git a/packages/next-plugin/src/mdx-source-generation.ts b/packages/next-plugin/src/mdx-source-generation.ts new file mode 100644 index 000000000..67ccc8534 --- /dev/null +++ b/packages/next-plugin/src/mdx-source-generation.ts @@ -0,0 +1,254 @@ +import { join } from 'node:path' +import { isDeepStrictEqual } from 'node:util' + +import { + computeReachableFiles, + type StaticImportGraph, +} from '@devup-ui/plugin-utils' + +import type { PreparedSourceGeneration } from './coordinator-options' +import { verifyResolutionProof } from './mdx-resolution-proof' +import { affectedMdxSources } from './mdx-source-affected' +import { + exportMdxRestartCache, + importMdxRestartCache, + type MdxCacheEntry, +} from './mdx-source-cache' +import { withMdxSourceControl } from './mdx-source-control' +import { deliverMdxSourceGeneration } from './mdx-source-delivery' +import { + changedMdxInput, + MdxFreshnessError, + type MdxInputFingerprint, +} from './mdx-source-freshness' +import { collectMdxOrdinaryInputs } from './mdx-source-inputs' +import { MdxNativeInputPendingError } from './mdx-source-pending' +import { runMdxSourcePreparation } from './mdx-source-run' +import type { + MdxBuildBinding, + MdxNativeExpectation, + MdxPreparationRun, + MdxSourceGeneration, +} from './mdx-source-types' + +export { affectedMdxSources } from './mdx-source-affected' +export type { + MdxBuildBinding, + MdxPipelineSelection, + MdxSourceGeneration, +} from './mdx-source-types' + +export class MdxSourceExtensionError extends Error { + readonly name = 'MdxSourceExtensionError' + constructor( + readonly configFile: string, + readonly extension: string, + ) { + super( + `${configFile}:1:1: MDX extension predicate ${extension} needs a certified finite anchored literal suffix condition; use a literal extension or finite extension alternation`, + ) + } +} + +export function createMdxSourceManager( + binding: MdxBuildBinding, + restartCache?: string, +) { + const root = binding.effectiveAppContext.root + const stored = + restartCache === undefined + ? new Map() + : importMdxRestartCache(restartCache) + const states = new WeakMap< + PreparedSourceGeneration['configureWasm'], + { + readonly generation: MdxSourceGeneration + readonly graph: StaticImportGraph + readonly entries: ReadonlyMap + readonly ordinary: readonly MdxInputFingerprint[] + readonly pending: ReadonlyMap + } + >() + async function prepare( + signal: AbortSignal, + previous?: MdxSourceGeneration, + control: MdxPreparationRun = {}, + ): Promise { + return withMdxSourceControl( + { filename: root, signal }, + async (signal, deadline) => { + const old = previous ? stateFor(previous) : undefined + const changes = control.changedPaths ?? [] + const dirty = new Set(changes) + if (previous && old) { + for (const input of previous.resolutionInputs) + if (dirty.has(input.path) && !changedMdxInput(root, [input])) + dirty.delete(input.path) + for (const input of old.ordinary) + if (changedMdxInput(root, [input])) dirty.add(input.path) + for (const [filename, entry] of old.entries) + if ( + changedMdxInput(filename, [ + ...entry.inputs, + ...(entry.resolutionInputs ?? []), + ]) + ) + dirty.add(filename) + for (const path of changes) + if (dirty.has(path)) + for (const filename of affectedMdxSources(previous, path)) + dirty.add(filename) + for (const filename of old.entries.keys()) { + if ( + computeReachableFiles({ + srcDir: [...binding.effectiveAppContext.sourceRoots, root], + tsconfigPath: join(root, 'tsconfig.json'), + graph: old.graph, + entries: [filename], + }).some((path) => dirty.has(path)) + ) + dirty.add(filename) + } + } + const run = await runMdxSourcePreparation(binding, { + signal, + candidates: old?.entries ?? stored, + dirty, + deadline, + }) + try { + verifyResolutionProof(root, run.resolution.snapshot()) + if ( + previous && + old && + run.entries.size === old.entries.size && + [...run.entries].every( + ([filename, entry]) => entry === old.entries.get(filename), + ) && + isDeepStrictEqual( + [ + run.plan.seedFiles, + run.plan.canonicalMap, + run.plan.fileRoutes, + run.plan.atomThreshold, + run.plan.expectedBaseFiles, + ], + [ + previous.plan.seedFiles, + previous.plan.canonicalMap, + previous.plan.fileRoutes, + previous.plan.atomThreshold, + previous.plan.expectedBaseFiles, + ], + ) + ) { + const ordinary = collectMdxOrdinaryInputs(binding, [ + ...run.planned, + ...old.ordinary.map((input) => input.path), + ]) + if ( + ordinary.pending.length === 0 && + !changedMdxInput(root, previous.resolutionInputs) && + !changedMdxInput(root, old.ordinary) && + JSON.stringify( + ordinary.ordinaryInputs.map((input) => [ + input.filename, + input.source, + ]), + ) === + JSON.stringify( + previous.ordinaryInputs.map((input) => [ + input.filename, + input.source, + ]), + ) + ) + return previous + } + const delivered = await deliverMdxSourceGeneration( + control.extractDependencies === undefined + ? binding + : { + ...binding, + extractDependencies: control.extractDependencies, + }, + run, + signal, + ) + states.set(delivered.generation.configureWasm, { + generation: delivered.generation, + graph: run.graph, + entries: run.entries, + ordinary: delivered.ordinary, + pending: run.pending, + }) + return delivered.generation + } finally { + run.resolution.retire() + } + }, + ) + } + function stateFor(generation: PreparedSourceGeneration) { + const state = states.get(generation.configureWasm) + if ( + !state || + !isDeepStrictEqual(state.generation.sources, generation.sources) + ) + throw new TypeError('Generation does not belong to this source manager') + return state + } + type RefreshRequest = + MdxPreparationRun & { + readonly generation: T + readonly signal: AbortSignal + readonly changedPaths?: readonly string[] + } + function refresh( + request: RefreshRequest, + ): Promise + function refresh( + request: RefreshRequest, + ): Promise + async function refresh( + request: RefreshRequest, + ): Promise { + const original = stateFor(request.generation).generation + const next = await prepare(request.signal, original, request) + return next === original ? request.generation : next + } + return Object.freeze({ + prepare: (signal: AbortSignal, control?: MdxPreparationRun) => + prepare(signal, undefined, control), + refresh, + validateForCssFinalization(generation: PreparedSourceGeneration) { + const state = stateFor(generation) + const pending = state.pending.values().next().value + if (pending) throw new MdxNativeInputPendingError(pending) + for (const [filename, entry] of state.entries) { + const changed = changedMdxInput(filename, [ + ...entry.inputs, + ...(entry.resolutionInputs ?? []), + ]) + if (changed) + throw new MdxFreshnessError( + filename, + changed, + 'input changed before CSS finalization', + ) + } + const changed = changedMdxInput(root, [ + ...state.ordinary, + ...state.generation.resolutionInputs, + ]) + if (changed) + throw new MdxFreshnessError( + root, + changed, + 'ordinary input changed before CSS finalization', + ) + }, + restartCache: (generation: PreparedSourceGeneration) => + exportMdxRestartCache(stateFor(generation).entries), + }) +} diff --git a/packages/next-plugin/src/mdx-source-immutable.ts b/packages/next-plugin/src/mdx-source-immutable.ts new file mode 100644 index 000000000..60219c3c4 --- /dev/null +++ b/packages/next-plugin/src/mdx-source-immutable.ts @@ -0,0 +1,137 @@ +import type { StaticImportGraph } from '@devup-ui/plugin-utils' + +import type { MdxSourcePlan } from './mdx-source-types' +import type { SourcePlan } from './plan' + +export function immutableMdxMap( + map: string | Readonly> | undefined, +) { + if (typeof map !== 'object') return map + const copy = structuredClone(map) + function freeze(value: unknown): void { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) + return + Object.freeze(value) + for (const child of Object.values(value)) freeze(child) + } + freeze(copy) + return copy +} + +class ImmutableMap implements ReadonlyMap { + readonly #map: Map + constructor(entries: Iterable) { + this.#map = new Map(entries) + Object.freeze(this) + } + get size() { + return this.#map.size + } + get(key: K) { + return this.#map.get(key) + } + has(key: K) { + return this.#map.has(key) + } + entries() { + return this.#map.entries() + } + keys() { + return this.#map.keys() + } + values() { + return this.#map.values() + } + [Symbol.iterator]() { + return this.entries() + } + forEach( + callback: (value: V, key: K, map: ReadonlyMap) => void, + thisArg?: unknown, + ) { + this.#map.forEach((value, key) => callback.call(thisArg, value, key, this)) + } +} +class ImmutableSet implements ReadonlySet { + readonly #set: Set + constructor(values: Iterable) { + this.#set = new Set(values) + Object.freeze(this) + } + get size() { + return this.#set.size + } + has(value: T) { + return this.#set.has(value) + } + entries() { + return this.#set.entries() + } + keys() { + return this.#set.keys() + } + values() { + return this.#set.values() + } + union(other: ReadonlySetLike) { + return this.#set.union(other) + } + intersection(other: ReadonlySetLike) { + return this.#set.intersection(other) + } + difference(other: ReadonlySetLike) { + return this.#set.difference(other) + } + symmetricDifference(other: ReadonlySetLike) { + return this.#set.symmetricDifference(other) + } + isSubsetOf(other: ReadonlySetLike) { + return this.#set.isSubsetOf(other) + } + isSupersetOf(other: ReadonlySetLike) { + return this.#set.isSupersetOf(other) + } + isDisjointFrom(other: ReadonlySetLike) { + return this.#set.isDisjointFrom(other) + } + [Symbol.iterator]() { + return this.values() + } + forEach( + callback: (value: T, key: T, set: ReadonlySet) => void, + thisArg?: unknown, + ) { + this.#set.forEach((value) => callback.call(thisArg, value, value, this)) + } +} +function edges(map: Map>) { + return new ImmutableMap( + [...map].map( + ([file, targets]) => [file, new ImmutableSet(targets)] as const, + ), + ) +} +export function immutableMdxPlan( + plan: SourcePlan, + graph: StaticImportGraph, +): MdxSourcePlan { + const fileRoutes = { ...plan.fileRoutes } + for (const routes of Object.values(fileRoutes)) Object.freeze(routes) + return Object.freeze({ + ...plan, + seedFiles: Object.freeze([...plan.seedFiles]), + expectedBaseFiles: Object.freeze([...plan.expectedBaseFiles]), + canonicalMap: Object.freeze({ ...plan.canonicalMap }), + fileRoutes: Object.freeze(fileRoutes), + graph: Object.freeze({ + ...(graph.requests === undefined ? {} : { requests: graph.requests }), + files: Object.freeze([...graph.files]), + fileSet: new ImmutableSet(graph.fileSet), + staticImports: edges(graph.staticImports), + staticImporters: edges(graph.staticImporters), + dynamicImports: edges(graph.dynamicImports), + dynamicTargets: new ImmutableSet(graph.dynamicTargets), + externalImports: edges(graph.externalImports ?? new Map()), + }), + }) +} diff --git a/packages/next-plugin/src/mdx-source-inputs.ts b/packages/next-plugin/src/mdx-source-inputs.ts new file mode 100644 index 000000000..2ace37379 --- /dev/null +++ b/packages/next-plugin/src/mdx-source-inputs.ts @@ -0,0 +1,140 @@ +import { readFileSync } from 'node:fs' +import { relative, resolve } from 'node:path' + +import { + fingerprintMdxInput, + type MdxInputFingerprint, + sourceHash, +} from './mdx-source-freshness' +import type { + MdxBuildBinding, + MdxExtractionReport, + MdxNativeExpectation, +} from './mdx-source-types' +import { EXTRACTABLE_EXTENSION } from './prewarm' +import type { CoordinatorInput } from './state' + +export function sourceInputKey(root: string, filename: string): string { + return relative(root, resolve(root, filename)).replaceAll('\\', '/') +} + +export function collectMdxOrdinaryInputs( + binding: MdxBuildBinding, + files: readonly string[], +) { + const ordinaryInputs: CoordinatorInput[] = [] + const pending: MdxNativeExpectation[] = [] + const fingerprints: MdxInputFingerprint[] = [] + for (const file of [ + ...new Set( + files.map((file) => resolve(binding.effectiveAppContext.root, file)), + ), + ].sort()) { + const filename = resolve(binding.effectiveAppContext.root, file) + if (!EXTRACTABLE_EXTENSION.test(filename)) { + if (!/\.mdx?$/i.test(filename)) + fingerprints.push( + fingerprintMdxInput(filename, { + kind: 'file', + path: filename, + loader: 'devup/extractor', + }), + ) + continue + } + const eligibility = binding.ordinaryEligibility(filename) + switch (eligibility.kind) { + case 'native-required': + pending.push(Object.freeze({ ...eligibility.expectation, filename })) + break + case 'disk-first': + { + const source = readFileSync(filename, 'utf8') + ordinaryInputs.push( + Object.freeze({ + filename: sourceInputKey( + binding.effectiveAppContext.root, + filename, + ), + resourcePath: filename, + source, + dependencies: Object.freeze([]), + stamps: Object.freeze({}), + backing: sourceHash(source), + }), + ) + const input = fingerprintMdxInput(filename, { + kind: 'file', + path: filename, + loader: 'native/disk-first', + }) + fingerprints.push( + Object.freeze({ ...input, fingerprint: sourceHash(source) }), + ) + } + break + default: + eligibility satisfies never + } + } + return { + ordinaryInputs: Object.freeze(ordinaryInputs), + pending: Object.freeze(pending), + fingerprints: Object.freeze(fingerprints), + } +} + +export function ordinaryExtractionEvidence( + inputs: readonly CoordinatorInput[], + proof: { + readonly root: string + readonly reports: readonly MdxExtractionReport[] + readonly fingerprints: readonly MdxInputFingerprint[] + }, +): readonly CoordinatorInput[] { + return Object.freeze( + inputs.map((input) => { + const dependencies = + proof.reports.find( + (report) => + sourceInputKey(proof.root, report.filename) === input.filename, + )?.dependencies ?? [] + const paths = new Set( + dependencies.map((path) => resolve(proof.root, path)), + ) + return Object.freeze({ + ...input, + dependencies: Object.freeze( + dependencies.map((path) => sourceInputKey(proof.root, path)), + ), + stamps: Object.freeze( + Object.fromEntries( + proof.fingerprints + .filter((stamp) => paths.has(stamp.path)) + .map((stamp) => [stamp.path, stamp.fingerprint]), + ), + ), + }) + }), + ) +} + +export function overlayMdxOrdinaryInputs( + previous: readonly CoordinatorInput[], + additions: readonly CoordinatorInput[], +): readonly CoordinatorInput[] { + return Object.freeze( + [ + ...new Map( + [...previous, ...additions].map((input) => [ + input.filename, + Object.freeze({ + ...input, + dependencies: Object.freeze([...input.dependencies]), + stamps: Object.freeze({ ...input.stamps }), + }), + ]), + ).values(), + ].sort((a, b) => a.filename.localeCompare(b.filename)), + ) +} diff --git a/packages/next-plugin/src/mdx-source-pending.ts b/packages/next-plugin/src/mdx-source-pending.ts new file mode 100644 index 000000000..ea3ab51e3 --- /dev/null +++ b/packages/next-plugin/src/mdx-source-pending.ts @@ -0,0 +1,10 @@ +import type { MdxNativeExpectation } from './mdx-source-types' + +export class MdxNativeInputPendingError extends Error { + readonly name = 'MdxNativeInputPendingError' + constructor(readonly expectation: MdxNativeExpectation) { + super( + `${expectation.filename}:1:1: ordinary source requires native upstream bytes (${expectation.reason}); needs the genuine pre-Devup loader input before CSS publication`, + ) + } +} diff --git a/packages/next-plugin/src/mdx-source-run.ts b/packages/next-plugin/src/mdx-source-run.ts new file mode 100644 index 000000000..1b8469040 --- /dev/null +++ b/packages/next-plugin/src/mdx-source-run.ts @@ -0,0 +1,236 @@ +import { join, resolve } from 'node:path' + +import { + buildStaticImportGraph, + computeReachableFiles, + createModuleResolver, +} from '@devup-ui/plugin-utils' + +import { requireMdxPipeline } from './mdx-pipeline' +import { + compileMdx, + createMdxOptionsInstance, + type MdxDeadline, +} from './mdx-prepare' +import { createResolutionProof } from './mdx-resolution-proof' +import { + captureMdxCacheIdentity, + compatibleMdxCache, + type MdxCacheEntry, +} from './mdx-source-cache' +import { + captureMdxFreshness, + changedMdxInput, + createMdxTimestampAccuracy, + MdxFreshnessError, +} from './mdx-source-freshness' +import { immutableMdxMap } from './mdx-source-immutable' +import { MdxNativeInputPendingError } from './mdx-source-pending' +import type { MdxBuildBinding, MdxNativeExpectation } from './mdx-source-types' +import { + planPreparedSources, + planSourceGraph, + sourceDiscoveryExcludes, +} from './plan' +import { collectPrewarmFiles, EXTRACTABLE_EXTENSION } from './prewarm' + +export async function runMdxSourcePreparation( + binding: MdxBuildBinding, + control: { + readonly signal: AbortSignal + readonly candidates: ReadonlyMap + readonly dirty: ReadonlySet + readonly deadline: MdxDeadline + }, +) { + const { signal, candidates, dirty, deadline } = control + signal.throwIfAborted() + const context = binding.effectiveAppContext + const roots = [...context.sourceRoots, context.root] + const tsconfig = join(context.root, 'tsconfig.json') + const optionsInstance = createMdxOptionsInstance() + const accuracy = createMdxTimestampAccuracy() + const entries = new Map() + const pending = new Map() + const resolution = createResolutionProof(context.root) + let extracting = false + const cacheReader = (filename: string) => { + const prepared = entries.get(resolve(filename))?.prepared + if (!prepared && extracting && EXTRACTABLE_EXTENSION.test(filename)) { + const eligibility = binding.ordinaryEligibility(filename) + switch (eligibility.kind) { + case 'native-required': + pending.set( + filename, + Object.freeze({ ...eligibility.expectation, filename }), + ) + throw new MdxNativeInputPendingError(eligibility.expectation) + case 'disk-first': + break + default: + eligibility satisfies never + } + } + return prepared + ? { + code: prepared.source, + sourceType: 'compiled-mdx' as const, + ...(prepared.map === undefined ? {} : { map: prepared.map }), + } + : undefined + } + const resolver = Object.freeze({ + prepareSource: cacheReader, + alias: binding.aliases, + conditions: binding.conditions, + includeMdx: binding.extensions, + onResolutionInputs: resolution.observe, + }) + const raw = buildStaticImportGraph(roots, tsconfig, { + cwd: context.root, + include: context.include, + exclude: sourceDiscoveryExcludes(context), + alias: binding.aliases, + conditions: binding.conditions, + includeMdx: binding.extensions, + onResolutionInputs: resolution.observe, + }) + let plan = { ...planSourceGraph(context, raw), seedFiles: [] as string[] } + for (;;) { + signal.throwIfAborted() + if (Date.now() >= deadline.expiresAt) + throw new MdxFreshnessError( + context.root, + { kind: 'file', path: context.root, loader: 'generation' }, + 'preparation deadline exceeded', + ) + const graph = plan.graph ?? raw + const files = collectPrewarmFiles({ + root: context.root, + graph, + expectedBaseFiles: plan.expectedBaseFiles, + libPackage: context.libPackage, + include: context.include, + prewarmAll: context.prewarmAll, + resolver, + }) + const reached = files + .map((file) => resolve(context.root, file)) + .filter((filename) => + binding.extensions.some((extension) => + filename.toLowerCase().endsWith(extension), + ), + ) + let added = false + for (const filename of reached) { + if (entries.has(filename)) continue + const selection = await binding.selectPipeline(filename, signal) + signal.throwIfAborted() + if (!selection) continue + const cached = candidates.get(filename) + const dependencyChanged = + dirty.has(filename) || + computeReachableFiles({ + srcDir: roots, + tsconfigPath: tsconfig, + graph, + entries: [filename], + }).some((path) => dirty.has(path)) + if ( + cached && + !dependencyChanged && + compatibleMdxCache(cached, selection) && + !changedMdxInput(filename, cached.inputs) && + !changedMdxInput(filename, cached.resolutionInputs ?? []) + ) + entries.set(filename, cached) + else { + const identity = captureMdxCacheIdentity(selection) + const startedAt = Date.now() + const prepared = await compileMdx({ + root: context.root, + filename, + pipeline: requireMdxPipeline(filename, selection.pipeline), + context: { ...selection.context, generation: optionsInstance }, + signal, + deadline, + optionsInstance, + }) + signal.throwIfAborted() + const inputs = captureMdxFreshness(prepared, { + startedAt, + development: context.watch, + accuracy, + }) + entries.set( + filename, + Object.freeze({ + ...identity, + prepared: Object.freeze({ + ...prepared, + map: immutableMdxMap(prepared.map), + dependencies: Object.freeze([...prepared.dependencies]), + contextDependencies: Object.freeze([ + ...prepared.contextDependencies, + ]), + missingDependencies: Object.freeze([ + ...prepared.missingDependencies, + ]), + buildDependencies: Object.freeze([...prepared.buildDependencies]), + dependencyReports: Object.freeze([...prepared.dependencyReports]), + }), + inputs, + }), + ) + } + added = true + } + plan = await planPreparedSources(context, { + extensions: binding.extensions, + aliases: binding.aliases, + conditions: binding.conditions, + cacheReader, + onResolutionInputs: resolution.observe, + }) + if (!added) break + } + const graph = plan.graph ?? raw + const files = collectPrewarmFiles({ + root: context.root, + graph, + expectedBaseFiles: plan.expectedBaseFiles, + libPackage: context.libPackage, + include: context.include, + prewarmAll: context.prewarmAll, + resolver, + }) + const planned = new Set(files.map((file) => resolve(context.root, file))) + for (const filename of entries.keys()) + if (!planned.has(filename)) entries.delete(filename) + const verify = createModuleResolver({ cwd: context.root, ...resolver }) + for (const filename of planned) { + if ( + binding.extensions.some((extension) => + filename.toLowerCase().endsWith(extension), + ) && + !entries.has(filename) + ) + verify( + filename, + [...(graph.staticImporters.get(filename) ?? [])][0] ?? filename, + ) + } + return { + entries, + cacheReader, + resolver, + graph, + plan, + planned, + pending, + resolution, + enableExtraction: () => { + extracting = true + }, + } +} diff --git a/packages/next-plugin/src/mdx-source-types.ts b/packages/next-plugin/src/mdx-source-types.ts new file mode 100644 index 000000000..47896d534 --- /dev/null +++ b/packages/next-plugin/src/mdx-source-types.ts @@ -0,0 +1,111 @@ +import type { + ModuleAliasOptions, + PreparedSource, + StaticImportGraph, +} from '@devup-ui/plugin-utils' + +import type { PreparedSourceGeneration } from './coordinator-options' +import type { MdxPipeline } from './mdx-pipeline' +import type { PreparedMdx } from './mdx-prepare-result' +import type { MdxPreparationContext } from './mdx-prewarm-boundary' +import type { MdxInputFingerprint } from './mdx-source-freshness' +import type { SourcePlan } from './plan' +import type { AppContext } from './session' +import type { CoordinatorInput } from './state' +import type { DevupWasm, ModuleResolverSettings } from './wasm' + +export type MdxPipelineSelection = { + readonly pipeline: MdxPipeline + readonly context: MdxPreparationContext + /** Original pre-execution resolved module/version/hash/options facts. */ + readonly identity: unknown +} +export type MdxNativeExpectation = { + readonly filename: string + readonly reason: string + readonly proof: object +} +export type MdxOrdinaryEligibility = + | { readonly kind: 'disk-first' } + | { + readonly kind: 'native-required' + readonly expectation: MdxNativeExpectation + } + +export type MdxSourcePlan = Omit< + SourcePlan, + 'graph' | 'seedFiles' | 'expectedBaseFiles' | 'fileRoutes' | 'canonicalMap' +> & { + readonly graph: Omit< + StaticImportGraph, + | 'files' + | 'fileSet' + | 'staticImports' + | 'staticImporters' + | 'dynamicImports' + | 'dynamicTargets' + | 'externalImports' + > & { + readonly files: readonly string[] + readonly fileSet: ReadonlySet + readonly staticImports: ReadonlyMap> + readonly staticImporters: ReadonlyMap> + readonly dynamicImports: ReadonlyMap> + readonly dynamicTargets: ReadonlySet + readonly externalImports: ReadonlyMap> + } + readonly seedFiles: readonly string[] + readonly expectedBaseFiles: readonly string[] + readonly fileRoutes: Readonly> + readonly canonicalMap: Readonly> +} + +export type MdxExtractionReport = { + readonly filename: string + readonly dependencies: readonly string[] + readonly resolutionInputs?: readonly MdxInputFingerprint[] +} +export type MdxExtractionView = { + readonly plan: MdxSourcePlan + readonly resolver: ModuleResolverSettings + readonly inputs: readonly CoordinatorInput[] +} +export type MdxBuildBinding = { + readonly effectiveAppContext: AppContext + readonly extensions: readonly string[] + readonly aliases: ModuleAliasOptions + readonly conditions: readonly string[] + readonly configFile: string + readonly selectPipeline: ( + filename: string, + signal: AbortSignal, + ) => Promise + readonly ordinaryEligibility: (filename: string) => MdxOrdinaryEligibility + readonly configureWasm: (wasm: DevupWasm, view: MdxExtractionView) => void + readonly extractDependencies: ( + view: MdxExtractionView, + signal: AbortSignal, + ) => Promise +} + +export type MdxPreparationRun = { + readonly changedPaths?: readonly string[] + readonly extractDependencies?: MdxBuildBinding['extractDependencies'] +} + +export type MdxCompiledRecord = { + readonly prepared: PreparedMdx + readonly inputs: readonly MdxInputFingerprint[] + readonly resolutionInputs?: readonly MdxInputFingerprint[] +} +export type MdxSourceGeneration = PreparedSourceGeneration & + MdxExtractionView & { + readonly key: object + readonly compiled: Readonly> + readonly ordinaryInputs: readonly CoordinatorInput[] + readonly pendingOrdinary: readonly MdxNativeExpectation[] + readonly cacheReader: (filename: string) => PreparedSource + readonly watchInputs: readonly string[] + readonly extractionDependencies: readonly MdxExtractionReport[] + readonly resolutionInputs: readonly MdxInputFingerprint[] + } diff --git a/packages/next-plugin/src/next-invocation-types.ts b/packages/next-plugin/src/next-invocation-types.ts new file mode 100644 index 000000000..d39f86985 --- /dev/null +++ b/packages/next-plugin/src/next-invocation-types.ts @@ -0,0 +1,66 @@ +export type NextInvocationDecision = + | { + readonly kind: 'receive' + readonly authority: 'adapter' | 'webpack-beforeCompile' + readonly isolation: 'current-isolate' + } + | { + readonly kind: 'handoff' + readonly target: 'turbopack-build-thread' | 'webpack-build-process' + readonly prepared: false + } + | { + readonly kind: 'inert' + readonly reason: + | 'unneeded' + | 'wrapper' + | 'cache' + | 'supplied-config' + | 'telemetry' + | 'non-compiling' + } + +export interface NextInvocationRuntime { + readonly entryPath: string | undefined + readonly argv: readonly string[] + readonly isMainThread: boolean + readonly hasIpc: boolean + readonly loadedModules: readonly string[] + readonly env: { + readonly TURBOPACK?: string + readonly NEXT_RSPACK?: string + readonly __NEXT_DEV_SERVER?: string + readonly NEXT_PRIVATE_WORKER?: string + readonly NEXT_PRIVATE_BUILD_WORKER?: string + readonly IS_NEXT_WORKER?: string + readonly NEXT_TURBOPACK_USE_WORKER?: string + } +} + +export interface NextInvocationInput { + readonly required: boolean + readonly stage: + | 'wrapper' + | 'adapter' + | 'cache' + | 'supplied-config' + | 'webpack-beforeCompile' + readonly installed: { + readonly version: string + readonly packageDir: string + } + readonly evaluation: { + readonly phase: string + readonly projectDir: string + readonly expectedProjectDir: string + readonly configFile: string + } + readonly runtime: NextInvocationRuntime + readonly build: + | { readonly projectDir: string; readonly mode: string | undefined } + | undefined + readonly config: { + readonly webpackBuildWorker: boolean | undefined + readonly hasWebpack: boolean + } +} diff --git a/packages/next-plugin/src/next-invocation.ts b/packages/next-plugin/src/next-invocation.ts new file mode 100644 index 000000000..52660956b --- /dev/null +++ b/packages/next-plugin/src/next-invocation.ts @@ -0,0 +1,174 @@ +import { join, resolve } from 'node:path' + +import type { + NextInvocationDecision, + NextInvocationInput, +} from './next-invocation-types' + +export type { + NextInvocationDecision, + NextInvocationInput, + NextInvocationRuntime, +} from './next-invocation-types' + +const SUPPORTED_RANGE = '16.3.x' +const ADAPTER_RECEIVE = { + kind: 'receive', + authority: 'adapter', + isolation: 'current-isolate', +} as const +const WEBPACK_RECEIVE = { + kind: 'receive', + authority: 'webpack-beforeCompile', + isolation: 'current-isolate', +} as const +const STAGES = { + wrapper: { kind: 'inert', reason: 'wrapper' }, + cache: { kind: 'inert', reason: 'cache' }, + 'supplied-config': { kind: 'inert', reason: 'supplied-config' }, + 'webpack-beforeCompile': WEBPACK_RECEIVE, + adapter: undefined, +} as const satisfies Record< + NextInvocationInput['stage'], + NextInvocationDecision | undefined +> + +export class NextInvocationError extends Error { + readonly installedVersion: string + readonly supportedRange = SUPPORTED_RANGE + readonly configFile: string + readonly reason: string + + constructor(input: NextInvocationInput, reason: string) { + super( + `${input.evaluation.configFile}:1:1: devup-ui MDX invocation cannot use ` + + `\`${input.evaluation.phase}\` at build time: installed Next ${input.installed.version}; ` + + `supported ${SUPPORTED_RANGE}; ${reason}; needs use the qualified Next CLI on ` + + `${SUPPORTED_RANGE}, or webpack with its real beforeCompile gate`, + ) + this.name = 'NextInvocationError' + this.installedVersion = input.installed.version + this.configFile = input.evaluation.configFile + this.reason = reason + } +} + +export function classifyNextInvocation( + input: NextInvocationInput, +): NextInvocationDecision { + if (!input.required) return { kind: 'inert', reason: 'unneeded' } + const stage = STAGES[input.stage] + if (stage) return stage + if (!/^16\.3\.(0|[1-9]\d*)$/.test(input.installed.version)) { + throw new NextInvocationError( + input, + 'unverified Next minor/release profile', + ) + } + const { evaluation, runtime, config } = input + if ( + resolve(evaluation.projectDir) !== resolve(evaluation.expectedProjectDir) + ) { + throw new NextInvocationError( + input, + 'adapter project differs from captured project', + ) + } + const root = resolve(input.installed.packageDir) + const entry = runtime.entryPath ? resolve(runtime.entryPath) : undefined + const installed = (path: string): string => join(root, path) + const modules = new Set(runtime.loadedModules.map((path) => resolve(path))) + if (entry === installed('dist/telemetry/detached-flush.js')) { + return { kind: 'inert', reason: 'telemetry' } + } + if ( + evaluation.phase === 'phase-production-server' || + evaluation.phase === 'phase-export' + ) { + return { kind: 'inert', reason: 'non-compiling' } + } + if (runtime.env.NEXT_RSPACK) { + throw new NextInvocationError( + input, + 'Rspack receiving profile is unverified', + ) + } + const receiving = runtime.env.TURBOPACK ? ADAPTER_RECEIVE : WEBPACK_RECEIVE + if (evaluation.phase === 'phase-development-server') { + if ( + entry === installed('dist/server/lib/start-server.js') && + runtime.isMainThread && + runtime.hasIpc && + runtime.env.__NEXT_DEV_SERVER === '1' && + runtime.env.NEXT_PRIVATE_WORKER === '1' + ) { + return receiving + } + throw new NextInvocationError( + input, + 'development invocation lacks stock CLI child proof', + ) + } + if (evaluation.phase !== 'phase-production-build') { + throw new NextInvocationError(input, 'unknown compilation phase') + } + const thread = entry === installed('dist/compiled/jest-worker/threadChild.js') + const child = entry === installed('dist/compiled/jest-worker/processChild.js') + if (thread || child) { + if (modules.has(installed('dist/build/worker.js'))) { + return { kind: 'inert', reason: 'supplied-config' } + } + const turbo = modules.has(installed('dist/build/turbopack-build/impl.js')) + const webpack = modules.has(installed('dist/build/webpack-build/impl.js')) + if ( + runtime.env.NEXT_PRIVATE_BUILD_WORKER === '1' && + runtime.env.IS_NEXT_WORKER === 'true' && + turbo !== webpack && + ((thread && !runtime.isMainThread && turbo) || + (child && runtime.isMainThread && runtime.hasIpc && webpack)) && + input.build && + resolve(input.build.projectDir) === resolve(evaluation.projectDir) + ) { + return turbo ? ADAPTER_RECEIVE : WEBPACK_RECEIVE + } + throw new NextInvocationError( + input, + 'Jest bootstrap/compilation target/marker proof is incomplete', + ) + } + if ( + entry !== installed('dist/bin/next') || + runtime.argv[2] !== 'build' || + !runtime.isMainThread || + !modules.has(installed('dist/cli/next-build.js')) || + !input.build || + resolve(input.build.projectDir) !== resolve(evaluation.projectDir) + ) { + throw new NextInvocationError( + input, + 'build invocation lacks stock CLI/context proof', + ) + } + if (input.build.mode === 'generate' || input.build.mode === 'generate-env') { + return { kind: 'inert', reason: 'non-compiling' } + } + if ( + input.build.mode !== undefined && + input.build.mode !== 'default' && + input.build.mode !== 'compile' + ) { + throw new NextInvocationError(input, 'unknown native build mode') + } + const worker = runtime.env.TURBOPACK + ? runtime.env.NEXT_TURBOPACK_USE_WORKER !== '0' + : (config.webpackBuildWorker ?? !config.hasWebpack) + return worker + ? { + kind: 'handoff', + target: runtime.env.TURBOPACK + ? 'turbopack-build-thread' + : 'webpack-build-process', + prepared: false, + } + : receiving +} diff --git a/packages/next-plugin/src/plan.ts b/packages/next-plugin/src/plan.ts new file mode 100644 index 000000000..09fc81765 --- /dev/null +++ b/packages/next-plugin/src/plan.ts @@ -0,0 +1,254 @@ +import { readFileSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' + +import type { + ModuleAliasOptions, + PreparedSource, + ResolutionInputObserver, +} from '@devup-ui/plugin-utils' +import { + buildCanonicalMap, + buildStaticImportGraph, + collectNumberedFiles, + computeFileRoutes, + computeReachableFiles, + extractedNeedles, + planAtomHoist, + type StaticImportGraph, +} from '@devup-ui/plugin-utils' + +import { locatedError } from './build-error' +import { collectNextEntries } from './entries' +import { EXTRACTABLE_EXTENSION } from './prewarm' +import { elapsedMs, profileStart, reportProfile } from './profile' +import type { AppContext } from './session' + +/** What the build knows about its sources before anything is extracted. */ +export interface SourcePlan { + readonly graph: StaticImportGraph | undefined + readonly canonicalMap: Record + /** cwd-relative POSIX files the bundler compiles; the completion signal */ + readonly expectedBaseFiles: string[] + /** canonical bucket -> route ids, only when atom hoisting is in effect */ + readonly fileRoutes: Record + readonly atomThreshold: number | null + /** every file that may take a number, in the names the plugin extracts */ + readonly seedFiles: string[] +} + +type RoutePlan = Omit + +const UNPLANNED: RoutePlan = { + graph: undefined, + canonicalMap: {}, + expectedBaseFiles: [], + fileRoutes: {}, + atomThreshold: null, +} + +interface RecoverFields { + context: AppContext + file: string + what: string + code: string + needs: string + /** What stops being guaranteed when the work fails */ + lost: string + fallback: T + work: () => T +} + +/** + * Run planning work whose failure takes a guarantee away. A production build + * fails with a located error; development warns once, naming what is lost, and + * continues with `fallback`. + */ +export function recoverPlanning({ + context, + file, + what, + code, + needs, + lost, + fallback, + work, +}: RecoverFields): T { + try { + return work() + } catch (cause) { + const error = locatedError({ file, what, code, needs, cause }) + if (context.phase === 'production') throw error + console.warn( + `[devup-ui] ${error.message}. Not guaranteed for this session: ${lost}.`, + ) + return fallback + } +} + +/** Plan a shared graph; prepared compiler inputs can use this same seam later. */ +export function planSourceGraph( + context: AppContext, + graph: StaticImportGraph, +): RoutePlan { + const startedAt = profileStart() + const srcDir = [...context.sourceRoots, context.root] + const tsconfigPath = join(context.root, 'tsconfig.json') + const cwd = context.root + const canonicalMap = buildCanonicalMap({ + srcDir, + tsconfigPath, + cwd, + // Atom hoisting owns the shared-chunk decision, so collapse runs without + // the file-level @global hoist in atom mode. + hoistV: context.hoistV, + graph, + }) + // Includes files only a dynamic import() reaches: the base sheet must wait + // for them too. + const expectedBaseFiles = computeReachableFiles({ + srcDir, + tsconfigPath, + entries: collectNextEntries({ + root: cwd, + files: graph.files, + pageExtensions: context.pageExtensions, + }), + graph, + }) + .map((file) => relative(cwd, file).replaceAll('\\', '/')) + .sort() + const hoist = + context.atomHoist === undefined + ? null + : planAtomHoist( + canonicalMap, + computeFileRoutes({ srcDir, tsconfigPath, cwd, graph }), + context.atomHoist, + ) + if (context.atomHoist !== undefined && !hoist) { + console.info( + '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op.', + ) + } + reportProfile('next.graph', { + durationMs: elapsedMs(startedAt), + files: graph.files.length, + expectedBaseFiles: expectedBaseFiles.length, + }) + return { + graph, + canonicalMap, + expectedBaseFiles, + fileRoutes: hoist?.reachByBucket ?? {}, + atomThreshold: hoist?.threshold ?? null, + } +} + +/** Plan the graph, the canonical buckets, atom hoisting and the numbering. */ +export function planSources(context: AppContext): SourcePlan { + const exclude = ['.git', context.nextDistDir, context.distDir] + const routes = recoverPlanning({ + context, + file: join(context.root, 'src'), + what: 'devup-ui import graph', + code: 'buildStaticImportGraph', + needs: 'readable source files and a parseable tsconfig.json', + lost: 'single-importer collapse, atom hoisting and the deterministic completion set', + fallback: UNPLANNED, + work: () => + planSourceGraph( + context, + buildStaticImportGraph( + [...context.sourceRoots, context.root], + join(context.root, 'tsconfig.json'), + { + cwd: context.root, + include: context.include, + exclude, + }, + ), + ), + }) + const seedFiles = recoverPlanning({ + context, + file: context.root, + what: 'devup-ui class numbering', + code: 'collectNumberedFiles', + needs: 'readable source directories and included packages', + lost: 'path-ordered class and file numbers', + fallback: [], + work: () => + collectNumberedFiles({ + roots: [...context.sourceRoots, context.root], + include: [...context.include], + cwd: context.root, + exclude, + needles: extractedNeedles(context.libPackage, context.importAliases), + toId: (path) => relative(context.root, path).replaceAll('\\', '/'), + }).filter( + (file) => + !routes.graph || + routes.graph.fileSet.has(resolve(context.root, file)) || + file.split('/').includes('node_modules'), + ), + }) + return { ...routes, seedFiles } +} + +export function sourceDiscoveryExcludes( + context: AppContext, +): readonly string[] { + return Object.freeze([ + '.git', + resolve(context.nextDistDir), + resolve(context.distDir), + ]) +} + +export async function planPreparedSources( + context: AppContext, + settings: { + readonly extensions: readonly string[] + readonly aliases: ModuleAliasOptions + readonly conditions: readonly string[] + readonly cacheReader: (filename: string) => PreparedSource + readonly onResolutionInputs?: ResolutionInputObserver + }, +): Promise { + const exclude = sourceDiscoveryExcludes(context) + const graph = await buildStaticImportGraph( + [...context.sourceRoots, context.root], + join(context.root, 'tsconfig.json'), + { + cwd: context.root, + include: context.include, + exclude, + includeMdx: settings.extensions, + alias: settings.aliases, + conditions: settings.conditions, + prepareSource: settings.cacheReader, + onResolutionInputs: settings.onResolutionInputs, + }, + ) + const routes = planSourceGraph(context, graph) + const numbered = collectNumberedFiles({ + roots: [...context.sourceRoots, context.root], + include: [...context.include], + cwd: context.root, + exclude, + includeMdx: settings.extensions, + toId: (path) => relative(context.root, path).replaceAll('\\', '/'), + }) + const needles = extractedNeedles(context.libPackage, context.importAliases) + const seedFiles = numbered.filter((file) => { + const source = settings.cacheReader(resolve(context.root, file)) + // Numbering uses the extracted text, including styles injected by the compiler. + const text = + (typeof source === 'string' ? source : source?.code) ?? + (EXTRACTABLE_EXTENSION.test(file) + ? readFileSync(resolve(context.root, file), 'utf8') + : undefined) + return text !== undefined && needles.some((needle) => text.includes(needle)) + }) + return { ...routes, seedFiles } +} diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 7905cc7d0..88f0ec267 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -1,640 +1,64 @@ -import { - existsSync, - mkdirSync, - readFileSync, - unlinkSync, - writeFileSync, -} from 'node:fs' -import { dirname, join, relative, resolve } from 'node:path' -import { deserialize, serialize } from 'node:v8' - -import { - buildCanonicalMap, - buildStaticImportGraph, - computeCompiledFiles, - computeFileRoutes, - createCompatTypes, - createNodeModulesExcludeRegex, - createThemeInterfaceArgs, - type DevupUIBasePluginOptions, - loadDevupConfigSync, - mergeImportAliases, - planAtomHoist, - type StaticImportGraph, -} from '@devup-ui/plugin-utils' -import { type NextConfig } from 'next' - -import { - type PrewarmedOutput, - startCoordinator, - takeExtractOutput, -} from './coordinator' -import { collectProductionPrewarmFiles } from './prewarm' -import { elapsedMs, profileStart, reportProfile } from './profile' -import { loadWasm, loadWebpackPlugin } from './wasm' - -/** Options accepted by the Next.js integration. */ -export type DevupUINextPluginOptions = Partial & { - /** Share atoms reached by at least this many routes. */ - atomHoist?: number -} - -type TurboRules = NonNullable['rules']> - -interface ProductionTurboSetupCache { - defaultTheme?: string - key: string - owner: string - prewarmedFiles: number - rules: TurboRules - token: string -} - -const productionTurboSetupTokenEnv = 'DEVUP_UI_TURBO_SETUP_TOKEN' -let productionTurboSetupOwner = `${performance.timeOrigin}-${Math.random()}` - -function consumeProductionTurboSetup( - key: string, - filename: string, -): ProductionTurboSetupCache | undefined { - const token = process.env[productionTurboSetupTokenEnv] - if (!token) return undefined - try { - const cached = deserialize(readFileSync(filename)) as - ProductionTurboSetupCache | undefined - if ( - !cached || - cached.key !== key || - cached.owner === productionTurboSetupOwner || - cached.token !== token - ) { - return undefined - } - delete process.env[productionTurboSetupTokenEnv] - try { - unlinkSync(filename) - } catch { - // The handoff is single-use even if cleanup fails. - } - return cached - } catch { - return undefined - } -} - -function storeProductionTurboSetup( - filename: string, - cache: Omit, -): void { - const token = `${process.pid}-${performance.now()}-${Math.random()}` - try { - writeFileSync( - filename, - serialize({ - ...cache, - owner: productionTurboSetupOwner, - token, - } satisfies ProductionTurboSetupCache), - ) - process.env[productionTurboSetupTokenEnv] = token - } catch { - delete process.env[productionTurboSetupTokenEnv] - } -} - -/** @internal Reproduce Next's isolated config-module reload in unit tests. */ -export function reloadTurboSetupModuleForTesting(): void { - productionTurboSetupOwner = `${performance.timeOrigin}-${Math.random()}` -} - -/** @internal Keep the process-global one-build handoff isolated between tests. */ -export function resetTurboSetupCacheForTesting(): void { - delete process.env[productionTurboSetupTokenEnv] - productionTurboSetupOwner = `${performance.timeOrigin}-${Math.random()}` -} - -/** - * Devup UI Next Plugin - * @param config - * @param options - * @constructor - */ -export function DevupUI( - config: NextConfig, - options: DevupUINextPluginOptions = {}, -): NextConfig { - const pluginStartedAt = profileStart() - const isTurbo = - process.env.TURBOPACK === '1' || process.env.TURBOPACK === 'auto' - // turbopack is now stable, TURBOPACK is set to auto without any flags - if (isTurbo) { - config ??= {} - config.turbopack ??= {} - config.turbopack.rules ??= {} - const { - package: libPackage = '@devup-ui/react', - distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), - singleCss = false, - devupFile = 'devup.json', - include = [], - prefix, - shorthands, - atomHoist, - importAliases: userImportAliases, - } = options - - const importAliases = mergeImportAliases(userImportAliases) - const watch = process.env.NODE_ENV === 'development' - const sourceMap = watch || config.productionBrowserSourceMaps === true - const sheetFile = join(distDir, 'sheet.json') - const classMapFile = join(distDir, 'classMap.json') - const fileMapFile = join(distDir, 'fileMap.json') - const canonicalMapFile = join(distDir, 'canonicalMap.json') - const gitignoreFile = join(distDir, '.gitignore') - const setupHandoffFile = join(distDir, 'setup.bin') - // Next loads next.config twice for a production Turbopack build in isolated - // module contexts, but both evaluations share the same process. Hand the - // completed first setup to exactly one different module instance so the - // second load does not rescan/reparse sources, re-extract them, or restart - // the coordinator. Calls from the same module instance never reuse it, - // which avoids turning this into a general cross-build cache. - const productionSetupKey = JSON.stringify({ - atomHoist, - cwd: process.cwd(), - cssDir, - devupFile, - distDir, - hoistV: process.env.DEVUP_HOIST_V, - importAliases, - include, - libPackage, - prefix, - shorthands, - singleCss, - sourceMap, - }) - const cachedSetup = watch - ? undefined - : consumeProductionTurboSetup(productionSetupKey, setupHandoffFile) - if (cachedSetup) { - if (cachedSetup.defaultTheme) { - process.env.DEVUP_UI_DEFAULT_THEME = cachedSetup.defaultTheme - config.env ??= {} - Object.assign(config.env, { - DEVUP_UI_DEFAULT_THEME: cachedSetup.defaultTheme, - }) - } - Object.assign(config.turbopack.rules, cachedSetup.rules) - reportProfile('next.setup', { - cacheHit: true, - durationMs: elapsedMs(pluginStartedAt), - pid: process.pid, - prewarmedFiles: cachedSetup.prewarmedFiles, - singleCss, - watch, - }) - return config - } - if (!existsSync(distDir)) - mkdirSync(distDir, { - recursive: true, - }) - if (!existsSync(cssDir)) - mkdirSync(cssDir, { - recursive: true, - }) - if (!existsSync(gitignoreFile)) writeFileSync(gitignoreFile, '*') - - const wasm = loadWasm() - const { - codeExtract, - codeExtractWithoutSourceMap, - exportClassMap, - exportFileMap, - exportSheet, - getCss, - getDefaultTheme, - getThemeInterface, - importCanonicalMap, - importClassMap, - importFileMap, - importFileRoutes, - importSheet, - registerShorthands, - registerTheme, - setAtomHoist, - setPrefix, - } = wasm - - registerShorthands(shorthands ?? {}) - - if (prefix) { - setPrefix(prefix) - } - - writeFileSync( - join(distDir, 'compat.d.ts'), - createCompatTypes(importAliases), - ) - // Import previous session state to handle Turbopack persistent cache. - // When the dev server restarts, Turbopack may skip re-running loaders for - // unchanged files. Without importing previous state, the coordinator's WASM - // starts empty and CSS for cached files would be missing. - try { - importSheet(JSON.parse(readFileSync(sheetFile, 'utf-8'))) - importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) - importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8'))) - } catch { - // No previous session state (first run) or corrupt files — start fresh - } - - const devupConfig = loadDevupConfigSync(devupFile) - - const theme: any = devupConfig.theme ?? {} - // Register current theme after importing previous state, - // since importSheet replaces the entire sheet including its theme. - registerTheme(theme) - const themeInterface = getThemeInterface( - ...createThemeInterfaceArgs(libPackage), - ) - if (themeInterface) { - writeFileSync(join(distDir, 'theme.d.ts'), themeInterface) - } - // disable turbo parallel - const excludeRegex = createNodeModulesExcludeRegex(include, '.mdx.[tj]sx?$') - - const coordinatorPortFile = join(distDir, 'coordinator.port') - - // Pre-pass: single-importer collapse ALWAYS runs (files with exactly one - // importer merge into that importer's bucket, so their identical atoms share - // one class). Atom-level hoisting COMPOSES on top: an atom reached by - // >= atomHoist distinct routes is emitted once into the shared devup-ui.css. - // - // The two compose because both are keyed by the canonical bucket: the engine - // keys property buckets by canonical(filename), and the route-reach map below - // is folded onto the SAME canonical bucket — so route_count_for_files() looks - // atoms up by bucket and the lookup hits. `atomHoist` must be configured - // BEFORE any extraction so atoms receive global (shared) class names; the - // coordinator shares this WASM instance, so it applies to every /extract. - const atomMode = - atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 - const extract = sourceMap ? codeExtract : codeExtractWithoutSourceMap - // Hoisted out of the try so the coordinator can receive it for per-bucket - // completion. Stays `{}` if the best-effort pre-pass fails. - let canonicalMap: Record = {} - // Every runtime file the bundler will compile (cwd-relative POSIX) — the - // deterministic base-css completion signal handed to the coordinator. Stays - // `[]` (idle fallback) when no routes are detected or the pre-pass fails. - let expectedBaseFiles: string[] = [] - let staticGraph: StaticImportGraph | undefined - const graphStartedAt = profileStart() - try { - const srcDir = resolve(process.cwd(), 'src') - const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') - const cwd = process.cwd() - // One scan+parse of the source tree, shared by all three consumers below. - const graph = buildStaticImportGraph(srcDir, tsconfigPath) - staticGraph = graph - // Atom hoisting owns the shared-chunk decision, so collapse runs WITHOUT - // the file-level @global hoist (DEVUP_HOIST_V) in atom mode. - const hoistV = atomMode - ? undefined - : process.env.DEVUP_HOIST_V - ? Number(process.env.DEVUP_HOIST_V) - : undefined - canonicalMap = buildCanonicalMap({ - srcDir, - tsconfigPath, - cwd, - hoistV, - graph, - }) - importCanonicalMap(canonicalMap) - writeFileSync(canonicalMapFile, JSON.stringify(canonicalMap)) - - // The base sheet must wait for every file the bundler compiles, INCLUDING - // the ones only a dynamic `import()` reaches. `computeFileRoutes` stops at - // static edges (correct for hoisting, where a lazy chunk is its own unit), - // so using its keys here resolved the wait before any `dynamic()` module - // had extracted and shipped a sheet missing all of their atoms. - expectedBaseFiles = computeCompiledFiles({ - srcDir, - tsconfigPath, - cwd, - graph, - }) - - if (atomMode) { - const fileRoutes = computeFileRoutes({ - srcDir, - tsconfigPath, - cwd, - graph, - }) - // Fold per-file route reach onto the canonical bucket so the keys match - // the engine's property bucket keys (canonical(filename)). - const plan = planAtomHoist(canonicalMap, fileRoutes, atomHoist) - if (plan) { - importFileRoutes(plan.reachByBucket) - setAtomHoist(plan.threshold) - } else { - console.info( - '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op.', - ) - } - } - reportProfile('next.graph', { - durationMs: elapsedMs(graphStartedAt), - files: staticGraph.files.length, - expectedBaseFiles: expectedBaseFiles.length, - }) - } catch { - // Pre-pass is best-effort; on failure canonical() is the identity (no - // merge) and atom hoisting stays off. - reportProfile('next.graph', { - durationMs: elapsedMs(graphStartedAt), - failed: true, - }) - } - - // Turbopack can request a CSS module before it has scheduled every source - // loader. Waiting for a quiet window is not a compilation-complete signal: - // a CSS request can itself hold up the next extraction wave. In one-shot - // builds, extract every source candidate plus accepted external package - // entries synchronously first. The wider set covers template imports, MDX - // dependencies and package-level globalCss (notably reset-css) that the - // route graph cannot represent. Loader-time extraction uses the same - // keys/options and is idempotent. - const prewarmedFiles: string[] = [] - const prewarmedOutputs = new Map() - if (!watch && staticGraph) { - const prewarmStartedAt = profileStart() - let prewarmExtractMs = 0 - let prewarmReadMs = 0 - let prewarmSourceBytes = 0 - const cwd = process.cwd() - const collectStartedAt = - prewarmStartedAt === undefined ? undefined : performance.now() - const prewarmFiles = collectProductionPrewarmFiles({ - cwd, - graph: staticGraph, - expectedBaseFiles, - libPackage, - include, - }) - const collectDurationMs = elapsedMs(collectStartedAt) - for (const filename of prewarmFiles) { - const resourcePath = resolve(cwd, filename) - const relCssDir = `./${relative( - dirname(resourcePath), - cssDir, - ).replaceAll('\\', '/')}` - const readStartedAt = - prewarmStartedAt === undefined ? undefined : performance.now() - const source = readFileSync(resourcePath, 'utf-8') - if (readStartedAt !== undefined) { - prewarmReadMs += performance.now() - readStartedAt - prewarmSourceBytes += Buffer.byteLength(source) - } - const extractStartedAt = - prewarmStartedAt === undefined ? undefined : performance.now() - const output = takeExtractOutput( - extract( - filename, - source, - libPackage, - relCssDir, - singleCss, - false, - true, - importAliases as unknown as Record, - ), - ) - if (extractStartedAt !== undefined) { - prewarmExtractMs += performance.now() - extractStartedAt - } - prewarmedOutputs.set(filename, { - code: output.code, - cssFile: output.cssFile, - map: output.map, - source, - updatedBaseStyle: output.updatedBaseStyle, - dependencies: output.dependencies, - }) - prewarmedFiles.push(filename) - } - reportProfile('next.prewarm', { - collectMs: collectDurationMs, - durationMs: elapsedMs(prewarmStartedAt), - extractMs: - prewarmStartedAt === undefined - ? undefined - : Number(prewarmExtractMs.toFixed(2)), - files: prewarmedFiles.length, - readMs: - prewarmStartedAt === undefined - ? undefined - : Number(prewarmReadMs.toFixed(2)), - sourceBytes: prewarmSourceBytes, - }) - } - - // create devup-ui.css file - const initialCssStartedAt = profileStart() - const initialCssSerializeStartedAt = - initialCssStartedAt === undefined ? undefined : performance.now() - const initialCss = getCss(null, false) - const initialCssSerializeMs = elapsedMs(initialCssSerializeStartedAt) - const initialCssWriteStartedAt = - initialCssStartedAt === undefined ? undefined : performance.now() - writeFileSync(join(cssDir, 'devup-ui.css'), initialCss) - reportProfile('next.initialCss', { - bytes: - initialCssStartedAt === undefined - ? undefined - : Buffer.byteLength(initialCss), - durationMs: elapsedMs(initialCssStartedAt), - serializeMs: initialCssSerializeMs, - writeMs: elapsedMs(initialCssWriteStartedAt), - }) - - // Delete stale port file from previous session so loaders don't connect - // to a dead coordinator port. The new coordinator writes a fresh port file - // once it starts listening. - try { - unlinkSync(coordinatorPortFile) - } catch { - // Port file doesn't exist (first run) — safe to ignore - } - - const coordinator = startCoordinator({ - wasm, - package: libPackage, - cssDir, - singleCss, - sheetFile, - classMapFile, - fileMapFile, - importAliases: importAliases as unknown as Record, - coordinatorPortFile, - canonicalMap, - expectedBaseFiles, - prewarmedFiles, - prewarmedOutputs, - sourceMap, - }) - - // Cleanup on exit - process.on('exit', () => { - coordinator.close() - }) - const stateSnapshotStartedAt = profileStart() - const sheetSerializeStartedAt = - stateSnapshotStartedAt === undefined ? undefined : performance.now() - const defaultSheetJson = exportSheet() - const sheetSerializeMs = elapsedMs(sheetSerializeStartedAt) - const sheetParseStartedAt = - stateSnapshotStartedAt === undefined ? undefined : performance.now() - const defaultSheet = JSON.parse(defaultSheetJson) - const sheetParseMs = elapsedMs(sheetParseStartedAt) - - const classMapSerializeStartedAt = - stateSnapshotStartedAt === undefined ? undefined : performance.now() - const defaultClassMapJson = exportClassMap() - const classMapSerializeMs = elapsedMs(classMapSerializeStartedAt) - const classMapParseStartedAt = - stateSnapshotStartedAt === undefined ? undefined : performance.now() - const defaultClassMap = JSON.parse(defaultClassMapJson) - const classMapParseMs = elapsedMs(classMapParseStartedAt) - - const fileMapSerializeStartedAt = - stateSnapshotStartedAt === undefined ? undefined : performance.now() - const defaultFileMapJson = exportFileMap() - const fileMapSerializeMs = elapsedMs(fileMapSerializeStartedAt) - const fileMapParseStartedAt = - stateSnapshotStartedAt === undefined ? undefined : performance.now() - const defaultFileMap = JSON.parse(defaultFileMapJson) - const fileMapParseMs = elapsedMs(fileMapParseStartedAt) - reportProfile('next.stateSnapshot', { - classMapBytes: - stateSnapshotStartedAt === undefined - ? undefined - : Buffer.byteLength(defaultClassMapJson), - classMapParseMs, - classMapSerializeMs, - durationMs: elapsedMs(stateSnapshotStartedAt), - fileMapBytes: - stateSnapshotStartedAt === undefined - ? undefined - : Buffer.byteLength(defaultFileMapJson), - fileMapParseMs, - fileMapSerializeMs, - sheetBytes: - stateSnapshotStartedAt === undefined - ? undefined - : Buffer.byteLength(defaultSheetJson), - sheetParseMs, - sheetSerializeMs, - }) - // for theme script - const defaultTheme = getDefaultTheme() - if (defaultTheme) { - process.env.DEVUP_UI_DEFAULT_THEME = defaultTheme - config.env ??= {} - Object.assign(config.env, { - DEVUP_UI_DEFAULT_THEME: defaultTheme, - }) - } - - const rules: NonNullable = { - [`./${relative(process.cwd(), cssDir).replaceAll('\\', '/')}/*.css`]: [ - { - loader: '@devup-ui/next-plugin/css-loader', - options: { - watch, - coordinatorPortFile, - sheetFile, - classMapFile, - fileMapFile, - themeFile: devupFile, - defaultSheet, - defaultClassMap, - defaultFileMap, - theme, - }, - }, - ], - // Must cover every extension the import-graph pre-pass lists AND the - // webpack rule matches (tsx|ts|jsx|js|mjs). Omitting one (e.g. jsx) - // means those files are never extracted: their Box/styled markup hits - // the runtime stubs ("Cannot run on the runtime") and the coordinator - // waits on graph members that never POST /extract. - '*.{tsx,ts,jsx,js,mjs}': { - loaders: [ - { - loader: '@devup-ui/next-plugin/loader', - options: { - package: libPackage, - cssDir, - coordinatorPortFile, - sheetFile, - classMapFile, - fileMapFile, - themeFile: devupFile, - defaultSheet, - defaultClassMap, - defaultFileMap, - watch, - singleCss, - // for turbopack, load theme is required on loader - theme, - importAliases: importAliases as unknown as Record, - }, - }, - ], - condition: { - not: { - path: excludeRegex, - }, - }, - }, - } - Object.assign(config.turbopack.rules, rules) - if (!watch) { - storeProductionTurboSetup(setupHandoffFile, { - defaultTheme, - key: productionSetupKey, - prewarmedFiles: prewarmedFiles.length, - rules, - }) - } - reportProfile('next.setup', { - cacheHit: false, - durationMs: elapsedMs(pluginStartedAt), - pid: process.pid, - prewarmedFiles: prewarmedFiles.length, - singleCss, - watch, - }) - return config - } - - const { webpack } = config - config.webpack = (config, _options) => { - const { DevupUIWebpackPlugin } = loadWebpackPlugin() - options.cssDir ??= resolve( - _options.dev ? (options.distDir ?? 'df') : '.next/cache', - `devup-ui_${_options.buildId}`, - ) - config.plugins.push( - new DevupUIWebpackPlugin({ - ...options, - watch: _options.dev, - }), - ) - if (typeof webpack === 'function') return webpack(config, _options) - return config - } - return config -} +import { resolve } from 'node:path' + +import type { DevupUIBasePluginOptions } from '@devup-ui/plugin-utils' +import type { NextConfig } from 'next' + +import { + reloadSetupModuleForTesting, + resetSetupHandoffsForTesting, +} from './setup-handoff' +import { setupTurbopack } from './turbo-setup' +import { loadWebpackPlugin } from './wasm' + +/** Options accepted by the Next.js integration. */ +export type DevupUINextPluginOptions = Partial & { + /** Share atoms reached by at least this many routes. */ + atomHoist?: number + /** + * Extract every source file of the graph before the build, not only the + * files the compiled routes reach. Needed for sources the graph cannot + * connect (template imports, MDX); costs CSS and time for dead files and + * fails the build on a dead file that cannot be extracted. + */ + prewarmAll?: boolean +} + +/** @internal Reproduce Next's isolated config-module reload in unit tests. */ +export const reloadTurboSetupModuleForTesting = reloadSetupModuleForTesting + +/** @internal Keep the process-wide one-use handoff isolated between tests. */ +export const resetTurboSetupCacheForTesting = resetSetupHandoffsForTesting + +/** + * Devup UI Next Plugin + * @param config + * @param options + * @constructor + */ +export function DevupUI( + config: NextConfig, + options: DevupUINextPluginOptions = {}, +): NextConfig { + // turbopack is now stable, TURBOPACK is set to auto without any flags + if (process.env.TURBOPACK === '1' || process.env.TURBOPACK === 'auto') { + return setupTurbopack(config, options) + } + + const { webpack } = config + config.webpack = (config, _options) => { + const { DevupUIWebpackPlugin } = loadWebpackPlugin() + options.cssDir ??= resolve( + _options.dev ? (options.distDir ?? 'df') : '.next/cache', + `devup-ui_${_options.buildId}`, + ) + config.plugins.push( + new DevupUIWebpackPlugin({ + ...options, + watch: _options.dev, + }), + ) + if (typeof webpack === 'function') return webpack(config, _options) + return config + } + return config +} diff --git a/packages/next-plugin/src/prewarm-run.ts b/packages/next-plugin/src/prewarm-run.ts new file mode 100644 index 000000000..43b0e5b6d --- /dev/null +++ b/packages/next-plugin/src/prewarm-run.ts @@ -0,0 +1,130 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import type { SourceType } from '@devup-ui/plugin-utils' + +import { locatedError } from './build-error' +import { extractInput } from './coordinator-engine' +import type { PrewarmedOutput } from './coordinator-options' +import { elapsedMs, profileStart, reportProfile } from './profile' +import type { AppContext } from './session' +import type { DevupWasm } from './wasm' + +export interface PrewarmResult { + /** The files that extracted, in extraction order */ + readonly files: string[] + readonly outputs: Map + readonly preparedInputs?: ReadonlyMap +} + +export interface PreparedPrewarmInput { + readonly code: string + readonly sourceType?: SourceType + readonly map?: unknown + readonly dependencies?: readonly string[] +} + +export interface RunPrewarmFields { + context: AppContext + engine: DevupWasm + /** cwd-relative POSIX names, already in extraction order */ + files: readonly string[] + /** Time spent choosing `files`, for the profile */ + collectMs: number | undefined + /** Complete pre-Devup inputs; supplying this makes preparation required in dev too. */ + readonly preparedInputs?: ReadonlyMap +} + +function extractOne( + { context, engine, preparedInputs }: RunPrewarmFields, + filename: string, +): PrewarmedOutput { + const resourcePath = resolve(context.root, filename) + const prepared = preparedInputs?.get(filename) + if (preparedInputs && prepared === undefined) { + throw new TypeError(`Required prepared source is missing: ${resourcePath}`) + } + const source = prepared?.code ?? readFileSync(resourcePath, 'utf-8') + const output = extractInput( + engine, + { + package: context.libPackage, + cssDir: context.cssDir, + singleCss: context.singleCss, + sourceMap: context.sourceMap, + importAliases: context.importAliases, + }, + { + filename, + resourcePath, + source, + ...(prepared?.sourceType === undefined + ? {} + : { sourceType: prepared.sourceType }), + }, + ) + return { + code: output.code, + cssFile: output.cssFile, + map: output.map, + source, + ...(prepared?.sourceType === undefined + ? {} + : { sourceType: prepared.sourceType }), + updatedBaseStyle: output.updatedBaseStyle, + dependencies: prepared + ? [...(output.dependencies ?? []), ...(prepared.dependencies ?? [])] + : output.dependencies, + } +} + +/** + * Extract `files` in the given order, before the bundler asks for anything, so + * class names and numbers follow the paths and not the order requests arrive + * in. A file that cannot be extracted fails a production build with its + * location; in development it is reported and skipped (the bundler hits the + * same error when it compiles the file). + */ +export function runPrewarm(fields: RunPrewarmFields): PrewarmResult { + const startedAt = profileStart() + const outputs = new Map() + let extractMs = 0 + let sourceBytes = 0 + for (const filename of fields.files) { + const extractStartedAt = performance.now() + try { + const output = extractOne(fields, filename) + outputs.set(filename, output) + sourceBytes += Buffer.byteLength(output.source) + } catch (cause) { + const error = locatedError({ + file: resolve(fields.context.root, filename), + what: 'devup-ui prewarm', + code: filename, + needs: 'a readable source file the extractor can compile', + cause, + }) + if (fields.preparedInputs || fields.context.phase === 'production') + throw error + console.warn( + `[devup-ui] ${error.message}. Skipped while prewarming; its styles are extracted when the bundler compiles it.`, + ) + } + extractMs += performance.now() - extractStartedAt + } + const files = [...outputs.keys()] + reportProfile('next.prewarm', { + collectMs: fields.collectMs, + durationMs: elapsedMs(startedAt), + extractMs: Number(extractMs.toFixed(2)), + files: files.length, + sourceBytes, + }) + return { + files, + outputs, + ...(fields.preparedInputs === undefined + ? {} + : { preparedInputs: fields.preparedInputs }), + } +} diff --git a/packages/next-plugin/src/prewarm.ts b/packages/next-plugin/src/prewarm.ts index 03354ff5c..99d5dc565 100644 --- a/packages/next-plugin/src/prewarm.ts +++ b/packages/next-plugin/src/prewarm.ts @@ -1,10 +1,23 @@ -import { existsSync } from 'node:fs' -import { createRequire } from 'node:module' -import { extname, join, relative, resolve } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { extname, relative, resolve } from 'node:path' -import type { StaticImportGraph } from '@devup-ui/plugin-utils' +import { + createModuleResolver, + type ModuleAliasOptions, + type ModuleResolution, + type PrepareSource, + type ResolutionInputObserver, + type ResolvedModule, + type StaticImportGraph, +} from '@devup-ui/plugin-utils' -const EXTRACTABLE_EXTENSION = /\.(?:tsx?|jsx?|mjs)$/ +import { locatedError } from './build-error' + +/** Every extension the Turbopack source rule and the prewarm accept. */ +export const EXTRACTABLE_EXTENSION = /\.[cm]?[jt]sx?$/ +const DECLARATION_FILE = /\.d\.[cm]?ts$/ +const IMPORT_SPECIFIER = + /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)(['"])([^'"\n]+)\1/g function packageNameFromSpecifier(specifier: string): string | undefined { if (specifier.startsWith('#') || specifier.startsWith('node:')) { @@ -20,7 +33,7 @@ function packageNameFromSpecifier(specifier: string): string | undefined { function isPrewarmPackage( packageName: string, libPackage: string, - include: string[], + include: readonly string[], ): boolean { const configuredPackage = packageNameFromSpecifier(libPackage) return ( @@ -39,65 +52,133 @@ function preferEsmFile(filename: string): string { return existsSync(esmFilename) ? esmFilename : filename } -function toKey(cwd: string, filename: string): string { - return relative(cwd, resolve(cwd, filename)).replaceAll('\\', '/') +function toKey(root: string, filename: string): string { + return relative(root, resolve(root, filename)).replaceAll('\\', '/') +} + +function isExtractable(filename: string): boolean { + return ( + EXTRACTABLE_EXTENSION.test(filename) && !DECLARATION_FILE.test(filename) + ) +} + +interface PackageWalk { + readonly root: string + readonly libPackage: string + readonly include: readonly string[] + readonly resolveModule: ( + specifier: string, + importer: string, + ) => ModuleResolution | undefined + readonly files: Set + readonly seen: Set +} + +function resolveLocated( + walk: PackageWalk, + specifier: string, + importer: string, +): ResolvedModule | undefined { + try { + const resolved = walk.resolveModule(specifier, importer) + if (resolved?.ignored === true) return undefined + return resolved + } catch (cause) { + throw locatedError({ + file: importer, + what: 'devup-ui prewarm', + code: specifier, + needs: 'a resolvable package with a readable manifest and entry', + cause, + }) + } +} + +function followSpecifier( + walk: PackageWalk, + specifier: string, + importer: string, +): void { + if (!specifier.startsWith('.')) { + const name = packageNameFromSpecifier(specifier) + if (!name || !isPrewarmPackage(name, walk.libPackage, walk.include)) return + } + const resolved = resolveLocated(walk, specifier, importer) + if (resolved) addPackageFile(walk, resolved) +} + +/** Add a package file and, through the resolver, what it imports in turn. */ +function addPackageFile(walk: PackageWalk, entry: ResolvedModule): void { + const filename = preferEsmFile(entry.path) + if (!isExtractable(filename) || walk.seen.has(filename)) return + const resolved = + filename === entry.path ? entry : walk.resolveModule(filename, filename) + if (resolved?.ignored === true) return + walk.seen.add(filename) + walk.files.add(toKey(walk.root, filename)) + const source = resolved?.code ?? readFileSync(filename, 'utf-8') + for (const [, , specifier] of source.matchAll(IMPORT_SPECIFIER)) { + followSpecifier(walk, specifier, filename) + } } -export interface CollectProductionPrewarmFilesOptions { - cwd: string +export interface CollectPrewarmFilesOptions { + root: string graph: StaticImportGraph - expectedBaseFiles: string[] + expectedBaseFiles: readonly string[] libPackage: string - include: string[] + include: readonly string[] + /** Also prewarm every source file of the graph, reachable or not */ + prewarmAll: boolean + readonly resolver?: { + readonly prepareSource?: PrepareSource + readonly alias?: ModuleAliasOptions + readonly conditions?: readonly string[] + readonly includeMdx?: readonly string[] + readonly onResolutionInputs?: ResolutionInputObserver + } } /** - * Build the deterministic production extraction set used before Turbopack can - * request its first CSS module. + * The deterministic extraction set that runs before the bundler can request + * its first stylesheet: the proven compiled closure, plus the packages that + * closure imports and that the loader would extract (`@devup-ui/*`, the + * configured package, `include`), followed through their own imports. * - * `computeCompiledFiles` is intentionally route-aware, but a bundler can also - * compile files hidden behind template imports / MDX and package entries that - * live outside `srcDir`. Prewarming every extractable source file plus the - * external package entries accepted by the loader makes the first snapshot - * independent of Turbopack scheduling. Per-file mode still only imports chunks - * that the bundler reaches; single-CSS mode intentionally contains the whole - * application stylesheet. + * A source file nothing compiled imports is not extracted: it adds no CSS and + * cannot fail the build. `prewarmAll` opts into the whole tree instead, for + * files the graph cannot connect (template imports, MDX). */ -export function collectProductionPrewarmFiles({ - cwd, +export function collectPrewarmFiles({ + root, graph, expectedBaseFiles, libPackage, include, -}: CollectProductionPrewarmFilesOptions): string[] { - const resolvedCwd = resolve(cwd) + prewarmAll, + resolver, +}: CollectPrewarmFilesOptions): string[] { + const resolvedRoot = resolve(root) const files = new Set( - expectedBaseFiles.map((filename) => toKey(resolvedCwd, filename)), + (prewarmAll + ? [...expectedBaseFiles, ...graph.files] + : expectedBaseFiles + ).map((filename) => toKey(resolvedRoot, filename)), ) - - for (const filename of graph.files) { - files.add(toKey(resolvedCwd, filename)) - } - - const externalSpecifiers = new Set() - for (const specifiers of graph.externalImports?.values() ?? []) { - for (const specifier of specifiers) externalSpecifiers.add(specifier) + const reached = new Set([...files].map((key) => resolve(resolvedRoot, key))) + const walk: PackageWalk = { + root: resolvedRoot, + libPackage, + include, + resolveModule: createModuleResolver({ cwd: resolvedRoot, ...resolver }), + files, + seen: new Set(), } - const requireFromProject = createRequire(join(resolvedCwd, 'package.json')) - for (const specifier of [...externalSpecifiers].sort()) { - const packageName = packageNameFromSpecifier(specifier) - if (!packageName || !isPrewarmPackage(packageName, libPackage, include)) { - continue - } - - try { - const filename = preferEsmFile(requireFromProject.resolve(specifier)) - if (!EXTRACTABLE_EXTENSION.test(filename)) continue - files.add(toKey(resolvedCwd, filename)) - } catch { - // Resolution is best-effort, matching the static graph pre-pass. The - // loader remains the fallback for packages resolved only by Turbopack. + for (const [importer, specifiers] of graph.externalImports ?? []) { + if (!reached.has(importer)) continue + for (const specifier of [...specifiers].sort()) { + followSpecifier(walk, specifier, importer) } } diff --git a/packages/next-plugin/src/session.ts b/packages/next-plugin/src/session.ts new file mode 100644 index 000000000..432aa5f86 --- /dev/null +++ b/packages/next-plugin/src/session.ts @@ -0,0 +1,172 @@ +import { createHash, randomUUID } from 'node:crypto' +import { existsSync, readdirSync, rmSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { + type CustomShorthands, + type ImportAliases, + mergeImportAliases, + type WasmImportAliases, +} from '@devup-ui/plugin-utils' + +import { type CoordinatorIdentity, isProcessAlive } from './coordinator-port' + +export type SetupPhase = 'development' | 'production' + +/** The options of the Next integration, before any default is applied. */ +export interface AppOptionsInput { + package?: string + distDir?: string + cssDir?: string + singleCss?: boolean + devupFile?: string + include?: string[] + prefix?: string + shorthands?: CustomShorthands + atomHoist?: number + debug?: boolean + importAliases?: ImportAliases + prewarmAll?: boolean +} + +/** What `next.config` says about the build, independent of the options. */ +export interface AppConfigInput { + productionBrowserSourceMaps?: boolean + readonly pageExtensions?: readonly string[] + readonly distDir?: string +} + +/** + * Everything one `DevupUI(config, options)` call decided, captured once: the + * project root, the phase and every option with its default applied. Nothing + * here reads `process.cwd()` or the environment again, so a later `chdir` or + * another app's setup cannot change what this app does. + */ +export interface AppContext { + readonly root: string + readonly phase: SetupPhase + readonly watch: boolean + readonly libPackage: string + readonly distDir: string + readonly nextDistDir: string + readonly cssDir: string + readonly devupFile: string + readonly singleCss: boolean + readonly debug: boolean + readonly include: readonly string[] + readonly prefix: string | null + readonly shorthands: Readonly + readonly atomHoist: number | undefined + readonly hoistV: number | undefined + readonly prewarmAll: boolean + readonly sourceMap: boolean + readonly pageExtensions: readonly string[] + readonly importAliases: Readonly + readonly sourceRoots: readonly string[] + readonly appKey: string + readonly appDir: string +} + +/** One running coordinator of one app: its own endpoint, revision and token. */ +export interface AppSession { + readonly token: string + readonly identity: CoordinatorIdentity + readonly sessionDir: string + readonly endpointFile: string + readonly revisionFile: string + readonly stateFile: string +} + +const SOURCE_DIRECTORIES = ['src', 'app', 'pages'] + +function stableStringify(value: unknown): string { + return JSON.stringify(value, (_key, item: unknown) => + typeof item === 'object' && item !== null && !Array.isArray(item) + ? Object.fromEntries( + Object.entries(item).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ) + : item, + ) +} + +/** A short stable digest of anything JSON can describe. */ +export function digest(value: unknown): string { + return createHash('sha256').update(stableStringify(value)).digest('hex') +} + +/** Resolve the options of one app against the project root it was set up in. */ +export function createAppContext( + config: AppConfigInput, + options: AppOptionsInput, + root = resolve(process.cwd()), +): AppContext { + const watch = process.env.NODE_ENV === 'development' + const distDir = resolve(root, options.distDir ?? 'df') + const atomHoist = + options.atomHoist !== undefined && + Number.isFinite(options.atomHoist) && + options.atomHoist > 0 + ? options.atomHoist + : undefined + const resolved = { + root, + phase: watch ? ('development' as const) : ('production' as const), + watch, + libPackage: options.package ?? '@devup-ui/react', + distDir, + nextDistDir: resolve(root, config.distDir ?? '.next'), + cssDir: resolve(root, options.cssDir ?? join(distDir, 'devup-ui')), + devupFile: resolve(root, options.devupFile ?? 'devup.json'), + singleCss: options.singleCss ?? false, + debug: options.debug ?? false, + include: Object.freeze([...(options.include ?? [])]), + prefix: options.prefix ?? null, + shorthands: Object.freeze({ ...options.shorthands }), + atomHoist, + hoistV: + atomHoist === undefined && process.env.DEVUP_HOIST_V + ? Number(process.env.DEVUP_HOIST_V) + : undefined, + prewarmAll: options.prewarmAll ?? false, + sourceMap: watch || config.productionBrowserSourceMaps === true, + pageExtensions: Object.freeze([ + ...(config.pageExtensions ?? ['jsx', 'js', 'tsx', 'ts']), + ]), + importAliases: Object.freeze(mergeImportAliases(options.importAliases)), + sourceRoots: Object.freeze( + SOURCE_DIRECTORIES.map((dir) => resolve(root, dir)), + ), + } + const appKey = digest(resolved).slice(0, 16) + return Object.freeze({ + ...resolved, + appKey, + appDir: join(distDir, '.devup', appKey), + }) +} + +/** A new session; every call gets its own endpoint, revision and token. */ +export function createSession(context: AppContext): AppSession { + const token = randomUUID() + const sessionDir = join(context.appDir, 'sessions', `${process.pid}-${token}`) + return Object.freeze({ + token, + identity: Object.freeze({ project: context.root, token }), + sessionDir, + endpointFile: join(sessionDir, 'endpoint.json'), + revisionFile: join(sessionDir, 'revision'), + stateFile: join(context.appDir, 'snapshot.json'), + }) +} + +/** Remove the session directories of processes that no longer run. */ +export function pruneDeadSessions(context: AppContext): void { + const sessions = join(context.appDir, 'sessions') + if (!existsSync(sessions)) return + for (const entry of readdirSync(sessions)) { + const pid = Number.parseInt(entry, 10) + if (Number.isSafeInteger(pid) && pid > 0 && !isProcessAlive(pid)) { + rmSync(join(sessions, entry), { recursive: true, force: true }) + } + } +} diff --git a/packages/next-plugin/src/setup-handoff.ts b/packages/next-plugin/src/setup-handoff.ts new file mode 100644 index 000000000..545ac5efa --- /dev/null +++ b/packages/next-plugin/src/setup-handoff.ts @@ -0,0 +1,166 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { deserialize, serialize } from 'node:v8' + +import type { NextConfig } from 'next' + +import { findSessionOwner } from './lifecycle' +import type { AppContext, SetupPhase } from './session' + +export type TurboRules = NonNullable< + NonNullable['rules'] +> + +/** What a second evaluation of `next.config` needs from the first one. */ +export interface SetupHandoff { + /** The app, its options and the content of its config files */ + readonly key: string + readonly defaultTheme: string | undefined + /** The loader rules, with the first evaluation's identity and endpoint */ + readonly rules: TurboRules + readonly prewarmedFiles: number + /** The session whose coordinator the first evaluation started */ + readonly sessionToken: string +} + +interface StoredHandoff { + readonly key: string + readonly owner: string + readonly nonce: string + readonly sessionToken: string + readonly root: string + readonly phase: SetupPhase + readonly appKey: string +} + +const TOKEN_ENV_PREFIX = 'DEVUP_UI_SETUP_TOKEN_' +let moduleOwner = randomUUID() + +function isStored(value: unknown): value is StoredHandoff { + return ( + typeof value === 'object' && + value !== null && + 'key' in value && + typeof value.key === 'string' && + 'owner' in value && + typeof value.owner === 'string' && + 'nonce' in value && + typeof value.nonce === 'string' && + 'sessionToken' in value && + typeof value.sessionToken === 'string' && + 'root' in value && + typeof value.root === 'string' && + 'phase' in value && + (value.phase === 'production' || value.phase === 'development') && + 'appKey' in value && + typeof value.appKey === 'string' + ) +} + +/** The file and variable a handoff of this app and process travels through. */ +function channel(context: AppContext): { file: string; env: string } { + return { + file: join(context.appDir, `handoff-${process.pid}.bin`), + env: `${TOKEN_ENV_PREFIX}${context.appKey}`, + } +} + +function readStored(file: string): StoredHandoff | undefined { + try { + const stored: unknown = deserialize(readFileSync(file)) + return isStored(stored) ? stored : undefined + } catch (cause) { + if (!(cause instanceof Error)) throw cause + return undefined + } +} + +function removeFile(file: string): void { + try { + rmSync(file, { force: true }) + } catch (cause) { + console.warn( + `[devup-ui] ${file}:1:1: devup-ui cannot use \`the setup handoff file\` at build time: ${String(cause)}; needs it to be deletable. The handoff stays one-use, the file is only left behind.`, + ) + } +} + +/** + * Take the setup another evaluation of `next.config` in this process left for + * this app: same project, phase, options and config contents, a different + * module instance, and not taken before. Anything else is left alone. + */ +export function consumeSetupHandoff( + context: AppContext, + key: string, +): SetupHandoff | undefined { + const { file, env } = channel(context) + const token = process.env[env] + if (!token) return undefined + const stored = readStored(file) + if ( + !stored || + stored.key !== key || + stored.owner === moduleOwner || + stored.nonce !== token || + stored.root !== context.root || + stored.phase !== context.phase || + stored.appKey !== context.appKey + ) { + return undefined + } + const live = findSessionOwner(stored.sessionToken)?.setup + if ( + !live || + live.context.root !== context.root || + live.context.phase !== context.phase || + live.context.appKey !== context.appKey + ) { + return undefined + } + delete process.env[env] + removeFile(file) + return { key, ...live.result } +} + +/** Leave this setup for the one other evaluation that may reuse it. */ +export function storeSetupHandoff( + context: AppContext, + handoff: SetupHandoff, +): void { + const { file, env } = channel(context) + const token = `${process.pid}-${randomUUID()}` + try { + mkdirSync(context.appDir, { recursive: true }) + writeFileSync( + file, + serialize({ + key: handoff.key, + owner: moduleOwner, + nonce: token, + sessionToken: handoff.sessionToken, + root: context.root, + phase: context.phase, + appKey: context.appKey, + } satisfies StoredHandoff), + ) + process.env[env] = token + } catch (cause) { + if (!(cause instanceof Error)) throw cause + delete process.env[env] + } +} + +/** @internal Reproduce Next's isolated config-module reload in unit tests. */ +export function reloadSetupModuleForTesting(): void { + moduleOwner = randomUUID() +} + +/** @internal Forget every handoff of this process between tests. */ +export function resetSetupHandoffsForTesting(): void { + for (const name of Object.keys(process.env)) { + if (name.startsWith(TOKEN_ENV_PREFIX)) delete process.env[name] + } + reloadSetupModuleForTesting() +} diff --git a/packages/next-plugin/src/state.ts b/packages/next-plugin/src/state.ts new file mode 100644 index 000000000..b8d423c6c --- /dev/null +++ b/packages/next-plugin/src/state.ts @@ -0,0 +1,273 @@ +import { randomUUID } from 'node:crypto' +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { dirname } from 'node:path' + +import { type SourceType, writeFileAtomically } from '@devup-ui/plugin-utils' + +import type { GenerationPlan } from './coordinator-options' +import { isGenerationPlan } from './coordinator-plan-codec' +import type { DevupWasm } from './wasm' + +export interface JsonObject { + readonly [key: string]: unknown +} + +/** One source the engine accepted, with what is needed to replay it. */ +export interface CoordinatorInput { + readonly filename: string + readonly resourcePath: string + readonly source: string + readonly sourceType?: SourceType + /** Files the extraction read through the module resolver (root-relative) */ + readonly dependencies: readonly string[] + /** Content hash of each dependency (absolute path) when it was accepted */ + readonly stamps: Readonly> + /** Content hash of the file on disk when accepted, '' when it had none */ + readonly backing: string +} + +/** Everything one coordinator needs to resume, committed as a single file. */ +export interface CoordinatorSnapshot { + readonly version: 1 + readonly optionsKey: string + readonly project: string + readonly revision: number + readonly sheet: JsonObject + readonly classMap: JsonObject + readonly fileMap: JsonObject + readonly inputs: readonly CoordinatorInput[] + readonly plan?: GenerationPlan +} + +export interface AllocatorState { + readonly classMap: JsonObject + readonly fileMap: JsonObject +} + +export class CoordinatorStateError extends Error { + constructor(stateFile: string, reason: string, cause?: unknown) { + super( + `${stateFile}:1:1: devup-ui coordinator checkpoint cannot use \`snapshot\` at build time: ${reason}. Fix: delete this file; the next dev/build run rebuilds the CSS state from the sources.`, + { cause }, + ) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string') +} + +function isInput(value: unknown): boolean { + return ( + isRecord(value) && + typeof value.filename === 'string' && + typeof value.resourcePath === 'string' && + typeof value.source === 'string' && + (value.sourceType === undefined || value.sourceType === 'compiled-mdx') && + typeof value.backing === 'string' && + isStringArray(value.dependencies) && + isRecord(value.stamps) && + Object.values(value.stamps).every((stamp) => typeof stamp === 'string') + ) +} + +const checks: readonly (readonly [string, (value: JsonObject) => boolean])[] = [ + ['version 1', (value) => value.version === 1], + ['optionsKey string', (value) => typeof value.optionsKey === 'string'], + ['project string', (value) => typeof value.project === 'string'], + [ + 'non-negative integer revision', + (value) => + Number.isSafeInteger(value.revision) && Number(value.revision) >= 0, + ], + ['sheet object', (value) => isRecord(value.sheet)], + ['classMap object', (value) => isRecord(value.classMap)], + ['fileMap object', (value) => isRecord(value.fileMap)], + [ + 'optional generation plan', + (value) => value.plan === undefined || isGenerationPlan(value.plan), + ], + [ + 'inputs array of {filename, resourcePath, source, dependencies, stamps, backing}', + (value) => Array.isArray(value.inputs) && value.inputs.every(isInput), + ], +] + +function assertSnapshot( + stateFile: string, + data: unknown, +): asserts data is CoordinatorSnapshot { + const expected = isRecord(data) + ? checks.find(([, valid]) => !valid(data))?.[0] + : 'an object' + if (expected !== undefined) { + throw new CoordinatorStateError( + stateFile, + `is corrupt: expected ${expected}`, + ) + } +} + +function parseCheckpoint(stateFile: string): CoordinatorSnapshot { + let data: unknown + try { + data = JSON.parse(readFileSync(stateFile, 'utf-8')) + } catch (cause) { + throw new CoordinatorStateError(stateFile, 'is not readable JSON', cause) + } + assertSnapshot(stateFile, data) + return data +} + +/** + * The checkpoint at `stateFile`, validated as a whole. A missing file or one + * written for other options is a cold start (`undefined`); anything else that + * is not a complete snapshot throws a located error. + */ +export function readCoordinatorState( + stateFile: string, + optionsKey: string, +): CoordinatorSnapshot | undefined { + if (!existsSync(stateFile)) return undefined + const snapshot = parseCheckpoint(stateFile) + return snapshot.optionsKey === optionsKey ? snapshot : undefined +} + +/** The names and numbers handed out so far; kept across rebuilds. */ +export function importAllocatorState( + wasm: DevupWasm, + allocator: AllocatorState, +): void { + wasm.importClassMap(allocator.classMap) + wasm.importFileMap(allocator.fileMap) +} + +export function exportAllocatorState(wasm: DevupWasm): AllocatorState { + return { + classMap: JSON.parse(wasm.exportClassMap()), + fileMap: JSON.parse(wasm.exportFileMap()), + } +} + +/** + * Load every part of a snapshot into an engine nobody is using yet. A failure + * part-way leaves that engine half restored, so the caller must discard it. + */ +export function restoreCoordinatorState( + wasm: DevupWasm, + snapshot: CoordinatorSnapshot, +): void { + wasm.importSheet(snapshot.sheet) + importAllocatorState(wasm, snapshot) +} + +export interface CaptureRequest { + readonly wasm: DevupWasm + readonly optionsKey: string + readonly project: string + readonly revision: number + readonly inputs: readonly CoordinatorInput[] + readonly plan?: GenerationPlan +} + +/** Export the engine's sheet and maps in one synchronous step. */ +export function captureCoordinatorState( + request: CaptureRequest, +): CoordinatorSnapshot { + const { wasm, ...rest } = request + return { + version: 1, + ...rest, + sheet: JSON.parse(wasm.exportSheet()), + ...exportAllocatorState(wasm), + } +} + +export function writeCoordinatorState( + stateFile: string, + snapshot: CoordinatorSnapshot, +): Promise { + return writeFileAtomically(stateFile, JSON.stringify(snapshot)) +} + +/** Synchronous variant for callers that cannot await (exit handlers). */ +export function writeCoordinatorStateSync( + stateFile: string, + snapshot: CoordinatorSnapshot, +): void { + const temporary = `${stateFile}.${process.pid}.${randomUUID()}.tmp` + mkdirSync(dirname(stateFile), { recursive: true }) + try { + writeFileSync(temporary, JSON.stringify(snapshot), 'utf-8') + renameSync(temporary, stateFile) + } catch (error) { + rmSync(temporary, { force: true }) + throw error + } +} + +export interface SnapshotCommitter { + /** + * Persist what `capture` returns. Calls made before a write starts share it + * and settle together, and `capture` runs only when the write starts, so the + * newest capture wins and every caller's state is in the file it waited for. + */ + commit(capture: () => CoordinatorSnapshot): Promise + /** Resolves once the latest complete capture is durable; rejects a failed write. */ + drain(): Promise +} + +interface Batch { + capture: () => CoordinatorSnapshot + readonly waiters: { resolve(): void; reject(error: unknown): void }[] +} + +export function createSnapshotCommitter( + write: (snapshot: CoordinatorSnapshot) => Promise, +): SnapshotCommitter { + let pending: Batch | undefined + let running: Promise | undefined + let failure: { readonly error: unknown } | undefined + async function run(): Promise { + // Yield first so `running` is assigned before this loop can finish + await Promise.resolve() + while (pending) { + const batch = pending + pending = undefined + try { + await write(batch.capture()) + failure = undefined + for (const waiter of batch.waiters) waiter.resolve() + } catch (error) { + failure = { error } + for (const waiter of batch.waiters) waiter.reject(error) + } + } + running = undefined + } + return { + commit(capture) { + return new Promise((resolve, reject) => { + pending ??= { capture, waiters: [] } + pending.capture = capture + pending.waiters.push({ resolve, reject }) + running ??= run() + }) + }, + async drain() { + while (running) await running + if (failure !== undefined) throw failure.error + }, + } +} diff --git a/packages/next-plugin/src/turbo-rules.ts b/packages/next-plugin/src/turbo-rules.ts new file mode 100644 index 000000000..06ba752b4 --- /dev/null +++ b/packages/next-plugin/src/turbo-rules.ts @@ -0,0 +1,97 @@ +import { join, relative } from 'node:path' + +import { createNodeModulesExcludeRegex } from '@devup-ui/plugin-utils' + +import type { AppContext, AppSession } from './session' +import type { TurboRules } from './setup-handoff' + +/** Longer than the coordinator's own production wait, which fails first. */ +export const REQUEST_TIMEOUT_MS = 120_000 + +/** Every ordinary source extension shared with graph discovery and prewarm. */ +export const SOURCE_RULE = '*.{tsx,ts,jsx,js,mjs,mts,cts,cjs}' + +/** + * Next types loader options as JSON without `null`, but `null` survives the + * hand-over and is how an alias means "named exports, one to one". + */ +function loaderAliases(context: AppContext): Record { + return { ...context.importAliases } as unknown as Record +} + +/** The theme as the plain JSON a loader option has to be. */ +function structuredJson(value: object): ReturnType { + return JSON.parse(JSON.stringify(value)) +} + +export interface RuleFields { + readonly context: AppContext + readonly session: AppSession + /** The devup config and everything it extends, absolute */ + readonly themeFiles: readonly string[] + readonly theme: object +} + +/** + * The loader rules of one app. Every path is absolute and every option comes + * from the captured context, so a rule means the same thing whatever the + * working directory is when Turbopack runs it. + */ +export function createCoordinatorLoaderOptions({ + context, + session, + themeFiles, + theme, +}: RuleFields) { + const shared = { + coordinatorPortFile: session.endpointFile, + coordinatorIdentity: { ...session.identity }, + projectRoot: context.root, + themeFiles: [...themeFiles], + requestTimeoutMs: REQUEST_TIMEOUT_MS, + watch: context.watch, + themeFile: context.devupFile, + theme: structuredJson(theme), + // The coordinator owns persistence; the loaders only fall back to these + // without one, and the plugin always starts one. + sheetFile: join(session.sessionDir, 'sheet.json'), + classMapFile: join(session.sessionDir, 'classMap.json'), + fileMapFile: join(session.sessionDir, 'fileMap.json'), + defaultSheet: {}, + defaultClassMap: {}, + defaultFileMap: {}, + ...(context.watch ? { revisionFile: session.revisionFile } : {}), + } + return { + shared, + source: { + ...shared, + package: context.libPackage, + cssDir: context.cssDir, + singleCss: context.singleCss, + importAliases: loaderAliases(context), + }, + } +} + +export function createTurboRules(fields: RuleFields): TurboRules { + const { context } = fields + const { shared, source } = createCoordinatorLoaderOptions(fields) + return { + [`./${relative(context.root, context.cssDir).replaceAll('\\', '/')}/*.css`]: + [{ loader: '@devup-ui/next-plugin/css-loader', options: shared }], + [SOURCE_RULE]: { + loaders: [ + { + loader: '@devup-ui/next-plugin/loader', + options: source, + }, + ], + condition: { + not: { + path: createNodeModulesExcludeRegex([...context.include]), + }, + }, + }, + } +} diff --git a/packages/next-plugin/src/turbo-setup.ts b/packages/next-plugin/src/turbo-setup.ts new file mode 100644 index 000000000..00a3ae200 --- /dev/null +++ b/packages/next-plugin/src/turbo-setup.ts @@ -0,0 +1,215 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { + collectDevupConfigFiles, + createCompatTypes, + createThemeInterfaceArgs, + loadDevupConfigSync, + seedFileNumbers, +} from '@devup-ui/plugin-utils' +import type { NextConfig } from 'next' + +import { startCoordinator } from './coordinator' +import { stampFile } from './coordinator-ledger' +import { persistInitialState, resumeEngine } from './dev-state' +import { createEngineConfigurer } from './engine-config' +import { installAfterCompileDrain, retainSession } from './lifecycle' +import { planSources, recoverPlanning, type SourcePlan } from './plan' +import { collectPrewarmFiles } from './prewarm' +import { type PrewarmResult, runPrewarm } from './prewarm-run' +import { elapsedMs, profileStart, reportProfile } from './profile' +import { + type AppContext, + type AppOptionsInput, + createAppContext, + createSession, + digest, + pruneDeadSessions, +} from './session' +import { + consumeSetupHandoff, + type SetupHandoff, + storeSetupHandoff, +} from './setup-handoff' +import { createTurboRules } from './turbo-rules' +import { createWasm, type DevupWasm } from './wasm' + +type SetupResult = Omit + +function prepareDirectories(context: AppContext): void { + mkdirSync(context.distDir, { recursive: true }) + mkdirSync(context.cssDir, { recursive: true }) + const gitignore = join(context.distDir, '.gitignore') + if (!existsSync(gitignore)) writeFileSync(gitignore, '*') + writeFileSync( + join(context.distDir, 'compat.d.ts'), + createCompatTypes({ ...context.importAliases }), + ) + // Turbopack resolves every generated stylesheet import to this file, and the + // CSS loader replaces its content with what the coordinator serves. The file + // itself never carries live CSS, so its content never changes. + const placeholder = join(context.cssDir, 'devup-ui.css') + if (!existsSync(placeholder)) writeFileSync(placeholder, '') +} + +function writeThemeTypes(context: AppContext, engine: DevupWasm): void { + const themeInterface = engine.getThemeInterface( + ...createThemeInterfaceArgs(context.libPackage), + ) + if (themeInterface) { + writeFileSync(join(context.distDir, 'theme.d.ts'), themeInterface) + } +} + +interface PrewarmFields { + context: AppContext + engine: DevupWasm + plan: SourcePlan +} + +/** Extract the compiled closure in path order, in development too. */ +function prewarm({ context, engine, plan }: PrewarmFields): PrewarmResult { + const { graph } = plan + if (!graph) return { files: [], outputs: new Map() } + const collectStartedAt = performance.now() + const files = recoverPlanning({ + context, + file: context.root, + what: 'devup-ui prewarm', + code: 'collectPrewarmFiles', + needs: 'resolvable included packages and readable sources', + lost: 'extracting the packages the app imports before the bundler asks', + fallback: plan.expectedBaseFiles, + work: () => + collectPrewarmFiles({ + root: context.root, + graph, + expectedBaseFiles: plan.expectedBaseFiles, + libPackage: context.libPackage, + include: context.include, + prewarmAll: context.prewarmAll, + }), + }) + return runPrewarm({ + context, + engine, + files, + collectMs: elapsedMs(collectStartedAt), + }) +} + +function buildSetup( + context: AppContext, + configFiles: readonly string[], +): SetupResult { + prepareDirectories(context) + const session = createSession(context) + mkdirSync(session.sessionDir, { recursive: true }) + pruneDeadSessions(context) + + const theme = loadDevupConfigSync(context.devupFile).theme ?? {} + const plan = planSources(context) + const configure = createEngineConfigurer(context, { theme, plan }) + const { engine, revision } = resumeEngine({ + context, + session, + createEngine: () => createWasm(context.root), + }) + // Everything is configured and numbered before the first extraction, so the + // names depend on the paths and not on the order requests arrive in. + configure(engine) + writeThemeTypes(context, engine) + seedFileNumbers(engine, plan.seedFiles) + const prewarmed = prewarm({ context, engine, plan }) + if (context.watch) { + persistInitialState({ + context, + session, + engine, + revision, + prewarm: prewarmed, + }) + } + + const coordinator = startCoordinator({ + wasm: engine, + createEngine: () => createWasm(context.root), + configureWasm: configure, + package: context.libPackage, + cssDir: context.cssDir, + singleCss: context.singleCss, + importAliases: context.importAliases, + coordinatorPortFile: session.endpointFile, + projectRoot: context.root, + identity: session.identity, + watch: context.watch, + optionsKey: context.appKey, + devupFile: context.devupFile, + sourceRoots: [...context.sourceRoots], + canonicalMap: plan.canonicalMap, + expectedBaseFiles: plan.expectedBaseFiles, + prewarmedFiles: prewarmed.files, + prewarmedOutputs: prewarmed.outputs, + sourceMap: context.sourceMap, + // A production build keeps nothing between runs and must not re-export the + // whole sheet for every extraction. + ...(context.watch + ? { stateFile: session.stateFile, revisionFile: session.revisionFile } + : {}), + }) + const setup: SetupResult = { + defaultTheme: engine.getDefaultTheme(), + rules: createTurboRules({ + context, + session, + themeFiles: configFiles, + theme, + }), + prewarmedFiles: prewarmed.files.length, + sessionToken: session.token, + } + retainSession({ + session, + coordinator, + setup: { context, engine, result: setup }, + }) + return setup +} + +/** Set up Turbopack for one app: its engine, coordinator and loader rules. */ +export function setupTurbopack( + config: NextConfig, + options: AppOptionsInput, +): NextConfig { + const startedAt = profileStart() + config ??= {} + config.turbopack ??= {} + config.turbopack.rules ??= {} + const context = createAppContext(config, options) + const configFiles = collectDevupConfigFiles(context.devupFile) + const key = digest({ + app: context.appKey, + config: configFiles.map((file) => [file, stampFile(file)]), + }) + const handed = context.watch ? undefined : consumeSetupHandoff(context, key) + const setup = handed ?? buildSetup(context, configFiles) + if (!handed && !context.watch) storeSetupHandoff(context, { key, ...setup }) + + config.env ??= {} + // Per app: an app without a theme must not inherit another app's. + Object.assign(config.env, { + DEVUP_UI_DEFAULT_THEME: setup.defaultTheme ?? 'default', + }) + Object.assign(config.turbopack.rules, setup.rules) + if (!context.watch) installAfterCompileDrain(config, setup.sessionToken) + reportProfile('next.setup', { + cacheHit: handed !== undefined, + durationMs: elapsedMs(startedAt), + pid: process.pid, + prewarmedFiles: setup.prewarmedFiles, + singleCss: context.singleCss, + watch: context.watch, + }) + return config +} diff --git a/packages/next-plugin/src/wasm-resolution-proof.ts b/packages/next-plugin/src/wasm-resolution-proof.ts new file mode 100644 index 000000000..99dbca1b2 --- /dev/null +++ b/packages/next-plugin/src/wasm-resolution-proof.ts @@ -0,0 +1,73 @@ +import type { ResolutionInputObserver } from '@devup-ui/plugin-utils' + +import { + createResolutionProof, + unionResolutionProof, + verifyResolutionProof, +} from './mdx-resolution-proof' +import type { MdxInputFingerprint } from './mdx-source-freshness' +import type { DevupWasm } from './wasm' + +type Collector = ReturnType +const engines = new WeakMap< + DevupWasm, + { + readonly setup: readonly MdxInputFingerprint[] + active?: Collector + } +>() +const outputs = new WeakMap() + +export function createEngineResolutionProof( + wasm: DevupWasm, + root: string, + observer?: ResolutionInputObserver, +) { + const setup = createResolutionProof(root) + let constructing = true + return { + observe: ((inputs) => { + if (constructing) setup.observe(inputs) + else engines.get(wasm)?.active?.observe(inputs) + if (constructing || engines.get(wasm)?.active) observer?.(inputs) + }) satisfies ResolutionInputObserver, + install() { + constructing = false + engines.set(wasm, { setup: setup.snapshot() }) + setup.retire() + }, + } +} + +export function withExtractionResolutionProof( + wasm: DevupWasm, + filename: string, + extract: () => T, +): T { + const state = engines.get(wasm) + if (!state) return extract() + const active = createResolutionProof(filename) + state.active = active + try { + verifyResolutionProof(filename, state.setup) + const output = extract() + try { + const proof = unionResolutionProof(state.setup, active.snapshot()) + verifyResolutionProof(filename, proof) + outputs.set(output, proof) + return output + } catch (cause) { + if ('free' in output && typeof output.free === 'function') output.free() + throw cause + } + } finally { + active.retire() + delete state.active + } +} + +export function extractionResolutionProof( + output: object, +): readonly MdxInputFingerprint[] { + return outputs.get(output) ?? Object.freeze([]) +} diff --git a/packages/next-plugin/src/wasm.ts b/packages/next-plugin/src/wasm.ts index 38446a31d..2cdeb55cc 100644 --- a/packages/next-plugin/src/wasm.ts +++ b/packages/next-plugin/src/wasm.ts @@ -1,12 +1,40 @@ -import { existsSync, realpathSync } from 'node:fs' +import { existsSync, readFileSync, realpathSync } from 'node:fs' import { createRequire } from 'node:module' -import { join, relative } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' +import { compileFunction } from 'node:vm' -import { createModuleResolver } from '@devup-ui/plugin-utils' +import { + createModuleResolver, + type ModuleAliasOptions, + type PrepareSource, + type ResolutionInputObserver, +} from '@devup-ui/plugin-utils' + +import { + createEngineResolutionProof, + withExtractionResolutionProof, +} from './wasm-resolution-proof' export type DevupWasm = typeof import('@devup-ui/wasm') export type DevupWebpackPlugin = typeof import('@devup-ui/webpack-plugin') +export interface ModuleResolverSettings { + readonly prepareSource?: PrepareSource + readonly alias?: ModuleAliasOptions + readonly includeMdx?: boolean | readonly string[] + readonly conditions?: readonly string[] + readonly onResolutionInputs?: ResolutionInputObserver +} + +const engineResolvers = new WeakMap< + DevupWasm, + { + readonly root: string + readonly settings: ModuleResolverSettings | undefined + readonly resolver: ReturnType + } +>() + let wasmForTesting: DevupWasm | undefined let webpackPluginForTesting: DevupWebpackPlugin | undefined let loadedWasm: DevupWasm | undefined @@ -14,37 +42,117 @@ let webpackPlugin: DevupWebpackPlugin | undefined /** @internal Resolve dependencies from the plugin's physical install location. */ export function requireFromPlugin(specifier: string): T { + return createPluginRequire(process.cwd())(specifier) as T +} + +function createPluginRequire(projectRoot: string): NodeRequire { const installedPackage = join( - process.cwd(), + projectRoot, 'node_modules/@devup-ui/next-plugin/package.json', ) const workspacePackage = join( - process.cwd(), + projectRoot, 'packages/next-plugin/package.json', ) const requireBase = existsSync(installedPackage) ? installedPackage : existsSync(workspacePackage) ? workspacePackage - : join(process.cwd(), 'package.json') - return createRequire(realpathSync(requireBase))(specifier) as T + : join(projectRoot, 'package.json') + return createRequire(realpathSync(requireBase)) } /** - * Resolve the imports of extracted files to the cwd-relative ids every Next + * Resolve the imports of extracted files to the root-relative ids every Next * extraction path passes, on engines new enough to load modules. */ -export function withModuleResolver(wasm: DevupWasm): DevupWasm { +export function withModuleResolver( + wasm: DevupWasm, + projectRoot = process.cwd(), + settings?: ModuleResolverSettings, +): DevupWasm { + const root = resolve(projectRoot) if ('setModuleResolver' in wasm) { - wasm.setModuleResolver( - createModuleResolver({ - toId: (path) => relative(process.cwd(), path).replaceAll('\\', '/'), - }), + const previous = engineResolvers.get(wasm) + if ( + settings !== undefined && + previous?.root === root && + previous.settings === settings + ) + return wasm + const proof = createEngineResolutionProof( + wasm, + root, + settings?.onResolutionInputs, ) + const resolver = createModuleResolver({ + ...settings, + onResolutionInputs: proof.observe, + cwd: root, + toId: (path) => relative(root, path).replaceAll('\\', '/'), + }) + wasm.setModuleResolver(resolver) + engineResolvers.set(wasm, { root, settings, resolver }) + proof.install() } return wasm } +/** Shared by requests, replay, sealed candidates, prewarm and legacy loaders. */ +export function extractWithModuleResolver( + wasm: DevupWasm, + sourceMap: boolean, + args: Parameters, +): ReturnType { + const configured = engineResolvers.get(wasm) + try { + return withExtractionResolutionProof( + wasm, + resolve(configured?.root ?? process.cwd(), args[0]), + () => { + // Register the request's own compiler map, not just imported module maps. + const prepared = configured?.settings?.prepareSource + ? configured.resolver(resolve(configured.root, args[0]), args[0]) + : undefined + const extract = sourceMap + ? wasm.codeExtract + : wasm.codeExtractWithoutSourceMap + const sourceType = args[8] ?? prepared?.sourceType + const extractionArgs: Parameters = [...args] + if (sourceType !== undefined) extractionArgs[8] = sourceType + return extract(...extractionArgs) + }, + ) + } catch (error) { + throw configured?.settings?.prepareSource + ? configured.resolver.remapError(error) + : error + } +} + +/** Evaluate a fresh bridge and WASM instance for one app, in the caller's realm. */ +export function createWasm(projectRoot = process.cwd()): DevupWasm { + if (wasmForTesting) return wasmForTesting + const root = resolve(projectRoot) + const filename = createPluginRequire(root).resolve('@devup-ui/wasm') + // Only the entry is private: its closure owns the bridge heap and Rust state. + const namespace: DevupWasm = Object.create(null) + const module = { exports: namespace } + compileFunction( + readFileSync(filename, 'utf8'), + ['exports', 'require', 'module', '__filename', '__dirname'], + { filename }, + ).call( + namespace, + namespace, + createRequire(filename), + module, + filename, + dirname(filename), + ) + return withModuleResolver(module.exports, root) +} + /** Load the extraction engine once for the lifetime of a Next config. */ export function loadWasm(): DevupWasm { if (wasmForTesting) return wasmForTesting diff --git a/packages/next-plugin/src/webpack-coordinator-rules.ts b/packages/next-plugin/src/webpack-coordinator-rules.ts new file mode 100644 index 000000000..96a2f6fa4 --- /dev/null +++ b/packages/next-plugin/src/webpack-coordinator-rules.ts @@ -0,0 +1,75 @@ +import type { Configuration, RuleSetRule } from 'webpack' + +import { composeMdxRules, type MdxLoader } from './mdx-pipeline' + +type NestedRule = NonNullable[number] + +/** Keep webpack's typed rule shell; only the approved MDX use segment changes. */ +export function composeWebpackMdxRules( + config: Configuration, + extraction: MdxLoader, +) { + const rules = config.module?.rules ?? [] + const alias = config.resolve?.alias ?? {} + const aliases = Array.isArray(alias) + ? Object.fromEntries( + alias.map(({ name, alias, onlyModule }) => [ + `${name}${onlyModule ? '$' : ''}`, + alias, + ]), + ) + : alias + const { pipelines } = composeMdxRules( + { bundler: 'webpack', rules, aliases }, + extraction, + ) + const byKey = new Map( + pipelines.map((pipeline) => [pipeline.ruleKey, pipeline]), + ) + function compose(rule: NestedRule, key: string): NestedRule { + if (!rule || typeof rule === 'string') return rule + const pipeline = byKey.get(key) + let result: RuleSetRule = rule + if (pipeline && !pipeline.issue && Array.isArray(rule.use)) { + const normalized = rule.use.filter((item) => + typeof item === 'string' + ? item !== extraction.loader + : typeof item === 'object' && item !== null + ? item.loader !== extraction.loader + : true, + ) + const index = normalized.length - pipeline.loaders.length + result = { + ...result, + use: [ + ...normalized.slice(0, index), + extraction, + ...normalized.slice(index), + ], + } + } + if (rule.rules) { + result = { + ...result, + rules: rule.rules.map((child, index) => + compose(child, `${key}.rules.${index}`), + ), + } + } + if (rule.oneOf) { + result = { + ...result, + oneOf: rule.oneOf.map((child, index) => + compose(child, `${key}.oneOf.${index}`), + ), + } + } + return result + } + return { + rules: rules.map((rule, index) => + rule === '...' ? rule : compose(rule, String(index)), + ), + pipelines, + } +} diff --git a/packages/next-plugin/src/webpack-coordinator.ts b/packages/next-plugin/src/webpack-coordinator.ts new file mode 100644 index 000000000..a00ce08e7 --- /dev/null +++ b/packages/next-plugin/src/webpack-coordinator.ts @@ -0,0 +1,90 @@ +import { + createNodeModulesExcludeRegex, + SOURCE_FILE_RE, +} from '@devup-ui/plugin-utils' +import type { Compiler, Configuration } from 'webpack' + +import type { PreparedCoordinatorHandle } from './coordinator-options' +import type { MdxPipeline } from './mdx-pipeline' +import type { AppContext, AppSession } from './session' +import { createCoordinatorLoaderOptions, type RuleFields } from './turbo-rules' +import { composeWebpackMdxRules } from './webpack-coordinator-rules' + +export interface WebpackPreparationBinding { + readonly compiler: Compiler + readonly params: Parameters< + Compiler['hooks']['beforeCompile']['callAsync'] + >[0] + readonly context: AppContext + readonly session: AppSession + readonly handle: PreparedCoordinatorHandle + readonly pipelines: readonly MdxPipeline[] + readonly effectiveConfiguration: Compiler['options'] +} + +export interface WebpackCoordinatorIntegration extends RuleFields { + readonly handle: PreparedCoordinatorHandle + /** Bind initial preparation or refresh changed inputs for each real build. */ + readonly prepare: (binding: WebpackPreparationBinding) => Promise +} + +/** Call only AFTER the effective user/wrapper webpack callback has returned. */ +export function createWebpackCoordinatorBridge( + integration: WebpackCoordinatorIntegration, +): (config: Configuration) => Configuration { + const { context, session, handle, prepare } = integration + const { shared, source } = createCoordinatorLoaderOptions(integration) + const extraction = { loader: '@devup-ui/next-plugin/loader', options: source } + const mdxExtraction = { + ...extraction, + options: { ...source, sourceType: 'compiled-mdx' }, + } + return (config) => { + const { rules, pipelines } = composeWebpackMdxRules(config, mdxExtraction) + return { + ...config, + module: { + ...config.module, + rules: [ + ...rules, + { + test: SOURCE_FILE_RE, + exclude: createNodeModulesExcludeRegex([...context.include]), + enforce: 'pre', + use: [extraction], + }, + { + test: /\.css$/, + include: context.cssDir, + enforce: 'pre', + use: [ + { loader: '@devup-ui/next-plugin/css-loader', options: shared }, + ], + }, + ], + }, + plugins: [ + ...(config.plugins ?? []), + { + apply(compiler: Compiler) { + compiler.hooks.beforeCompile.tapPromise( + 'DevupUINextCoordinator', + async (params) => { + await prepare({ + compiler, + params, + context, + session, + handle, + pipelines, + effectiveConfiguration: compiler.options, + }) + await Promise.all([handle.ready, handle.prepared]) + }, + ) + }, + }, + ], + } + } +} diff --git a/packages/next-plugin/src/webpack-mdx-bytes.ts b/packages/next-plugin/src/webpack-mdx-bytes.ts new file mode 100644 index 000000000..ebb360be8 --- /dev/null +++ b/packages/next-plugin/src/webpack-mdx-bytes.ts @@ -0,0 +1,29 @@ +import { createReadStream } from 'node:fs' + +/** Relay cancellation without handing a foreign-realm signal to native fs. */ +export async function readWebpackLoaderBytes( + filename: string, + signal: AbortSignal, +): Promise { + signal.throwIfAborted() + const stream = createReadStream(filename) + const abort = () => stream.destroy() + signal.addEventListener('abort', abort, { once: true }) + const chunks: Buffer[] = [] + try { + for await (const chunk of stream) { + signal.throwIfAborted() + if (!Buffer.isBuffer(chunk)) + throw new TypeError('native loader read returned non-buffer bytes') + chunks.push(chunk) + } + signal.throwIfAborted() + return Buffer.concat(chunks) + } catch (error) { + signal.throwIfAborted() + throw error + } finally { + signal.removeEventListener('abort', abort) + stream.destroy() + } +} diff --git a/packages/next-plugin/src/webpack-mdx-facts.ts b/packages/next-plugin/src/webpack-mdx-facts.ts new file mode 100644 index 000000000..581d3b666 --- /dev/null +++ b/packages/next-plugin/src/webpack-mdx-facts.ts @@ -0,0 +1,74 @@ +import { pathToFileURL } from 'node:url' + +import { isMdxRecord, type MdxLoader } from './mdx-pipeline' +import { resolveWebpackLoader } from './webpack-mdx-resolution' +import { WebpackResourceError } from './webpack-resource-error' +import type { WebpackResourceBinding } from './webpack-resource-native' +import { isDevupLoader } from './webpack-resource-qualification' + +export function createWebpackLoaderFacts( + binding: WebpackResourceBinding, + configFile: string, +) { + const watches = new Set() + const loaded = new Map< + string, + Awaited> + >() + async function resolveLoader(loader: MdxLoader, signal: AbortSignal) { + signal.throwIfAborted() + let result = loaded.get(loader.loader) + if (!result) { + result = await resolveWebpackLoader(binding, loader, signal) + signal.throwIfAborted() + loaded.set(loader.loader, result) + } + signal.throwIfAborted() + result.dependencies.forEach((dependency) => watches.add(dependency)) + return Object.freeze({ + ...result, + loader: Object.freeze({ ...loader, loader: result.facts.resolvedPath }), + facts: Object.freeze({ ...result.facts, options: loader.options }), + }) + } + return Object.freeze({ + resolveLoader, + watchInputs: () => Object.freeze([...watches]), + async downstreamSafe( + loaders: readonly MdxLoader[], + filename: string, + signal: AbortSignal, + ) { + for (const loader of loaders) { + if (isDevupLoader(loader)) continue + const result = await resolveLoader(loader, signal) + const module: unknown = await import( + pathToFileURL(result.facts.resolvedPath).href + ) + const entry: unknown = isMdxRecord(module) ? module.default : module + const pitch = + typeof entry === 'function' && 'pitch' in entry + ? entry.pitch + : isMdxRecord(module) + ? module.pitch + : undefined + if (pitch === undefined) continue + const swc = + result.facts.resolvedPath + .replaceAll('\\', '/') + .endsWith('/next/dist/build/webpack/loaders/next-swc-loader.js') && + result.facts.packageVersion === '16.3.6' && + result.facts.verifiedFileHash === + '6c1e45b146eae04f982b91e06170ab349e00686ebe021da48262218cbf95f604' + if (!swc) + throw new WebpackResourceError(configFile, { + filename, + rulePosition: 'module.rules', + test: '', + unknownFact: 'pitch/source substitution', + reason: `downstream loader ${result.facts.resolvedPath} has an unproved pitch`, + }) + } + }, + }) +} diff --git a/packages/next-plugin/src/webpack-mdx-resolution.ts b/packages/next-plugin/src/webpack-mdx-resolution.ts new file mode 100644 index 000000000..7b8411ada --- /dev/null +++ b/packages/next-plugin/src/webpack-mdx-resolution.ts @@ -0,0 +1,87 @@ +import { createHash } from 'node:crypto' + +import type { MdxNativeLoaderFacts } from './mdx-binding' +import { isMdxRecord, type MdxLoader } from './mdx-pipeline' +import { readWebpackLoaderBytes } from './webpack-mdx-bytes' +import type { WebpackResourceBinding } from './webpack-resource-native' + +type Resolver = { + readonly resolve: ( + info: Readonly>, + context: string, + request: string, + dependencies: { + readonly fileDependencies: Set + readonly missingDependencies: Set + readonly contextDependencies: Set + }, + callback: (error: unknown, file: unknown, data: unknown) => void, + ) => void +} +function isResolver(value: unknown): value is Resolver { + return isMdxRecord(value) && typeof value.resolve === 'function' +} + +export async function resolveWebpackLoader( + binding: WebpackResourceBinding, + loader: MdxLoader, + signal: AbortSignal, +): Promise<{ + readonly loader: MdxLoader + readonly facts: MdxNativeLoaderFacts + readonly dependencies: readonly string[] +}> { + signal.throwIfAborted() + const resolver: unknown = binding.compiler.resolverFactory.get('loader') + if (!isResolver(resolver)) + throw new TypeError('native loader resolver unavailable') + const dependencies = { + fileDependencies: new Set(), + missingDependencies: new Set(), + contextDependencies: new Set(), + } + const resolved = await new Promise<{ + readonly file: string + readonly version: string + }>((resolve, reject) => + resolver.resolve( + { compiler: binding.compiler.name }, + binding.compiler.context, + loader.loader, + dependencies, + (error, file, data) => { + if (error) return reject(error) + if ( + typeof file !== 'string' || + !isMdxRecord(data) || + !isMdxRecord(data.descriptionFileData) || + typeof data.descriptionFileData.version !== 'string' + ) + return reject( + new TypeError( + 'native loader resolution did not provide installed version facts', + ), + ) + resolve({ file, version: data.descriptionFileData.version }) + }, + ), + ) + signal.throwIfAborted() + const bytes = await readWebpackLoaderBytes(resolved.file, signal) + const facts = Object.freeze({ + resolvedPath: resolved.file, + packageVersion: resolved.version, + verifiedFileHash: createHash('sha256').update(bytes).digest('hex'), + options: loader.options, + }) + return Object.freeze({ + loader: Object.freeze({ ...loader, loader: resolved.file }), + facts, + dependencies: Object.freeze([ + ...dependencies.fileDependencies, + ...dependencies.missingDependencies, + ...dependencies.contextDependencies, + resolved.file, + ]), + }) +} diff --git a/packages/next-plugin/src/webpack-resource-compiler.ts b/packages/next-plugin/src/webpack-resource-compiler.ts new file mode 100644 index 000000000..ddfb29a44 --- /dev/null +++ b/packages/next-plugin/src/webpack-resource-compiler.ts @@ -0,0 +1,52 @@ +import { isAbsolute } from 'node:path' + +import type { WebpackResourceBoundary } from './webpack-resource-error' +import type { WebpackResourceBinding } from './webpack-resource-native' + +export function compilerResourceBoundary( + binding: WebpackResourceBinding, + filename: string, +): WebpackResourceBoundary | undefined { + if (!isAbsolute(filename) || /[!?#]/.test(filename)) + return Object.freeze({ + filename, + rulePosition: 'module.rules', + test: '', + unknownFact: 'plain disk resource', + reason: + 'inline requests, matchResource, schemes and query/fragment substitutions require native origin facts; use an absolute unsubstituted disk resource', + }) + const externals = binding.effectiveConfiguration.externals + if ( + externals !== undefined && + (!Array.isArray(externals) || externals.length > 0) + ) + return Object.freeze({ + filename, + rulePosition: 'webpack.externals', + test: '', + unknownFact: 'native factory substitution', + reason: + 'beforeCompile RuleSet does not prove the effects of external-module factorization installed for the later compilation', + }) + const plugins = binding.effectiveConfiguration.plugins ?? [] + if (plugins.length > 0) + return Object.freeze({ + filename, + rulePosition: 'webpack.plugins[0]', + test: '', + unknownFact: 'pitch/source-reader/factory substitution', + reason: + 'an opaque plugin can install module/source-reader hooks after beforeCompile; effective configuration carries no source-proven original Next callout identity', + }) + if (binding.effectiveConfiguration.resolveLoader?.plugins?.length) + return Object.freeze({ + filename, + rulePosition: 'webpack.resolveLoader.plugins', + test: '', + unknownFact: 'loader resolver context', + reason: + 'a custom loader resolver can use missing issuer/dependency facts', + }) + return +} diff --git a/packages/next-plugin/src/webpack-resource-delivery.ts b/packages/next-plugin/src/webpack-resource-delivery.ts new file mode 100644 index 000000000..7a4950a0b --- /dev/null +++ b/packages/next-plugin/src/webpack-resource-delivery.ts @@ -0,0 +1,77 @@ +import type { + ModuleAliasDescriptor, + ModuleAliasOptions, +} from '@devup-ui/plugin-utils' + +import { isMdxRecord } from './mdx-pipeline' +import type { WebpackResourceBinding } from './webpack-resource-native' + +export function webpackResourceDelivery(binding: WebpackResourceBinding) { + const options = binding.compiler.options + const devtool = options.devtool + const full = + typeof devtool === 'string' && + devtool.includes('source-map') && + (!devtool.includes('cheap') || devtool.includes('module')) + const loader: unknown = options.loader + const sourceMap = + isMdxRecord(loader) && loader.sourceMap !== undefined + ? loader.sourceMap + : full + const mode = + isMdxRecord(loader) && loader.mode !== undefined + ? loader.mode + : options.mode || 'production' + if ( + typeof sourceMap !== 'boolean' || + (mode !== 'production' && mode !== 'development' && mode !== 'none') + ) + throw new TypeError( + 'effective native loader map/mode override is unsupported', + ) + return Object.freeze({ sourceMap, mode }) +} + +function immutableAliasTarget(target: ModuleAliasDescriptor['alias']) { + return typeof target === 'object' ? Object.freeze([...target]) : target +} + +function immutableModuleAliases(alias: ModuleAliasOptions): ModuleAliasOptions { + if (alias === false) return false + return Array.isArray(alias) + ? Object.freeze( + alias.map((item: ModuleAliasDescriptor) => + Object.freeze({ ...item, alias: immutableAliasTarget(item.alias) }), + ), + ) + : Object.freeze( + Object.fromEntries( + Object.entries(alias).map(([key, value]) => [ + key, + immutableAliasTarget(value), + ]), + ), + ) +} + +export function webpackResourceResolver(binding: WebpackResourceBinding): { + readonly aliases: ModuleAliasOptions + readonly conditions: readonly string[] +} { + const alias = binding.effectiveConfiguration.resolve?.alias ?? {} + const aliases = immutableModuleAliases(alias) + const resolver = binding.compiler.resolverFactory.get('normal') + const conditions: unknown = resolver.options.conditionNames + if ( + !(conditions instanceof Set) || + ![...conditions].every((value: unknown) => typeof value === 'string') + ) + throw new TypeError('effective native resolver conditions are unavailable') + const values: string[] = [] + for (const value of conditions) + if (typeof value === 'string') values.push(value) + return Object.freeze({ + aliases, + conditions: Object.freeze(values), + }) +} diff --git a/packages/next-plugin/src/webpack-resource-envelope.ts b/packages/next-plugin/src/webpack-resource-envelope.ts new file mode 100644 index 000000000..d02c7a997 --- /dev/null +++ b/packages/next-plugin/src/webpack-resource-envelope.ts @@ -0,0 +1,84 @@ +import { isMdxRecord, type MdxRule } from './mdx-pipeline' +import { + compileResourceEnvelope, + type WebpackResourceBinding, +} from './webpack-resource-native' + +const resourceKeys = [ + 'test', + 'include', + 'exclude', + 'resource', + 'realResource', + 'compiler', +] as const +const unknownKeys = [ + 'issuer', + 'issuerLayer', + 'resourceQuery', + 'resourceFragment', + 'dependency', + 'scheme', + 'assert', + 'with', + 'descriptionData', + 'mimetype', +] as const + +function opaquePredicate(value: unknown): boolean { + if (typeof value === 'function') return true + if (value instanceof RegExp) + return ( + value.global || + value.sticky || + Object.getPrototypeOf(value) !== RegExp.prototype + ) + if (Array.isArray(value)) return value.some(opaquePredicate) + if (isMdxRecord(value)) return Object.values(value).some(opaquePredicate) + return false +} + +export function createResourceEnvelope( + binding: WebpackResourceBinding, + path: readonly MdxRule[], +) { + let envelope: MdxRule = { type: 'javascript/auto' } + for (const condition of [...path].reverse()) { + envelope = { + ...Object.fromEntries( + resourceKeys + .filter( + (name) => + condition[name] !== undefined && + !opaquePredicate(condition[name]), + ) + .map((name) => [name, condition[name]]), + ), + rules: [envelope], + } + } + return compileResourceEnvelope(binding, [envelope]) +} + +export function unknownResourceFact( + path: readonly MdxRule[], +): string | undefined { + return path.flatMap((item) => [ + ...unknownKeys.filter((name) => item[name] !== undefined), + ...resourceKeys.filter((name) => opaquePredicate(item[name])), + ])[0] +} + +export function resourceRuleLabel(key: string, rule: MdxRule) { + const defaults = key.startsWith('defaultRules.') + const path = defaults ? key.slice('defaultRules.'.length) : key + return { + position: `module.${defaults ? 'defaultRules' : 'rules'}[${path.split('.')[0]}]${path.replace(/^\d+/, '').replace(/\.(rules|oneOf)\.(\d+)/g, '.$1[$2]')}`, + test: + rule.test instanceof RegExp + ? String(rule.test) + : rule.test === undefined + ? '' + : '', + } +} diff --git a/packages/next-plugin/src/webpack-resource-error.ts b/packages/next-plugin/src/webpack-resource-error.ts new file mode 100644 index 000000000..0fa4657ce --- /dev/null +++ b/packages/next-plugin/src/webpack-resource-error.ts @@ -0,0 +1,23 @@ +export type WebpackResourceBoundary = { + readonly filename: string + readonly rulePosition: string + readonly test: string + readonly unknownFact: string + readonly reason: string +} + +export class WebpackResourceError extends Error { + readonly name = 'WebpackResourceError' + readonly rulePosition: string + readonly unknownFact: string + constructor( + readonly configFile: string, + readonly boundary: WebpackResourceBoundary, + ) { + super( + `${configFile}:1:1: devup-ui cannot certify initial input for \`${boundary.filename}\`: ${boundary.rulePosition} (test ${boundary.test}); unknown ${boundary.unknownFact}: ${boundary.reason}; make the input-producing rule resource-only and put Devup immediately before the recognized MDX compiler, remove source-replacing pitches/factories, or defer this boundary to a separately supported native-input protocol`, + ) + this.rulePosition = boundary.rulePosition + this.unknownFact = boundary.unknownFact + } +} diff --git a/packages/next-plugin/src/webpack-resource-native.ts b/packages/next-plugin/src/webpack-resource-native.ts new file mode 100644 index 000000000..16c528f9a --- /dev/null +++ b/packages/next-plugin/src/webpack-resource-native.ts @@ -0,0 +1,91 @@ +import type { Compiler } from 'webpack' + +import { isMdxRecord, type MdxLoader } from './mdx-pipeline' + +export type WebpackResourceBinding = { + readonly compiler: Compiler + readonly params: Parameters< + Compiler['hooks']['beforeCompile']['callAsync'] + >[0] + readonly effectiveConfiguration: Compiler['options'] + readonly pipelines: readonly import('./mdx-pipeline').MdxPipeline[] +} +export type NativeEffect = { + readonly type: string + readonly value: unknown +} +export type NativeRuleSet = { + readonly exec: ( + facts: Readonly>, + ) => readonly NativeEffect[] +} + +function isRuleSet(value: unknown): value is NativeRuleSet { + return isMdxRecord(value) && typeof value.exec === 'function' +} +export function nativeRuleSet(factory: object): NativeRuleSet { + if (!('ruleSet' in factory) || !isRuleSet(factory.ruleSet)) + throw new TypeError( + 'receiving native NormalModuleFactory RuleSet is unavailable', + ) + return factory.ruleSet +} + +export function resourceFacts( + binding: WebpackResourceBinding, + filename: string, +) { + return { + resource: filename, + realResource: filename, + compiler: binding.compiler.name, + } +} + +export function compileResourceEnvelope( + binding: WebpackResourceBinding, + rules: readonly Readonly>[], +): NativeRuleSet { + const actual = binding.params.normalModuleFactory + const options = binding.compiler.options + const factory: unknown = Reflect.construct(actual.constructor, [ + { + context: binding.compiler.context, + fs: binding.compiler.inputFileSystem, + resolverFactory: binding.compiler.resolverFactory, + options: { ...options.module, defaultRules: [], rules }, + associatedObjectForCache: binding.compiler.root, + layers: + 'layers' in options.experiments + ? options.experiments.layers + : undefined, + }, + ]) + if (!isMdxRecord(factory)) + throw new TypeError('native envelope factory is unavailable') + return nativeRuleSet(factory) +} + +export function nativeLoaders( + effects: readonly NativeEffect[], + type: string, +): readonly MdxLoader[] { + return effects + .filter((effect) => effect.type === type) + .map(({ value }) => { + if (!isMdxRecord(value) || typeof value.loader !== 'string') + throw new TypeError('native RuleSet returned an invalid loader effect') + const options = value.options + if ( + options !== undefined && + typeof options !== 'string' && + !isMdxRecord(options) + ) + throw new TypeError('native loader options are unsupported') + return { + loader: value.loader, + ...(options === undefined ? {} : { options }), + ...(typeof value.ident === 'string' ? { ident: value.ident } : {}), + } + }) +} diff --git a/packages/next-plugin/src/webpack-resource-qualification.ts b/packages/next-plugin/src/webpack-resource-qualification.ts new file mode 100644 index 000000000..42e301833 --- /dev/null +++ b/packages/next-plugin/src/webpack-resource-qualification.ts @@ -0,0 +1,202 @@ +import type { MdxLoader, MdxPipeline } from './mdx-pipeline' +import type { WebpackResourceBoundary } from './webpack-resource-error' +import { + resourceFacts, + type WebpackResourceBinding, +} from './webpack-resource-native' +import type { ResourceRule } from './webpack-resource-rules' + +export function isDevupLoader(loader: MdxLoader): boolean { + return /(?:^|[/\\])@devup-ui[/\\]next-plugin[/\\](?:dist[/\\])?loader(?:\.[cm]?[jt]s)?$/.test( + loader.loader, + ) +} + +export function reachableResourceRules( + binding: WebpackResourceBinding, + rules: readonly ResourceRule[], + filename: string, +): readonly ResourceRule[] { + const facts = resourceFacts(binding, filename) + return rules + .filter((rule) => rule.envelope.exec(facts).length > 0) + .map((rule) => { + const shadow = rule.controls.find( + (control) => + control.unknownFact && control.envelope.exec(facts).length > 0, + ) + return shadow ? Object.freeze({ ...rule, shadow }) : rule + }) +} + +export function resourceBoundary( + filename: string, + rule: Pick, + reason: string, +): WebpackResourceBoundary { + return Object.freeze({ + filename, + rulePosition: rule.position, + test: rule.test, + unknownFact: rule.unknownFact ?? 'input-producing loader semantics', + reason, + }) +} + +export function qualifyMdxRules( + filename: string, + reachable: readonly ResourceRule[], + pipelines: readonly MdxPipeline[], +): { + readonly pipeline?: MdxPipeline + readonly boundary?: WebpackResourceBoundary + readonly downstream: readonly MdxLoader[] +} { + const candidates = pipelines.filter( + (pipeline) => + pipeline.bundler === 'webpack' && + reachable.some((rule) => rule.key === pipeline.ruleKey), + ) + if (candidates.length === 0) return { downstream: [] } + const [pipeline] = candidates + if (!pipeline) throw new TypeError('native pipeline candidate disappeared') + const selectedIndex = reachable.findIndex( + (rule) => rule.key === pipeline.ruleKey, + ) + const selected = reachable[selectedIndex] + if (!selected) throw new TypeError('native rule candidate disappeared') + if (selected.shadow) + return { + boundary: resourceBoundary( + filename, + selected.shadow, + 'an earlier oneOf branch can suppress the input-producing MDX segment', + ), + downstream: [], + } + if ( + candidates.length > 1 || + selected.unknownFact || + selected.stage !== 'normal' || + pipeline.issue || + pipeline.loaders.length !== 1 + ) + return { + boundary: resourceBoundary( + filename, + candidates.length > 1 + ? (reachable.find( + (rule) => + rule.key !== pipeline.ruleKey && + candidates.some((item) => item.ruleKey === rule.key), + ) ?? selected) + : selected, + 'the resource can select a competing/contextual or unsupported pre-Devup MDX segment', + ), + downstream: [], + } + const downstream: MdxLoader[] = [] + for (const [index, rule] of reachable.entries()) { + if (rule.dynamic) + return { + boundary: { + ...resourceBoundary( + filename, + rule, + 'a loader factory can change input/options or pitch behavior', + ), + unknownFact: 'use(context)', + }, + downstream: [], + } + if ( + rule.stage === 'pre' || + (rule.stage === 'normal' && index > selectedIndex) + ) + return { + boundary: resourceBoundary( + filename, + rule, + 'an additional upstream segment can produce different bytes before Devup', + ), + downstream: [], + } + const devup = rule.loaders.findIndex(isDevupLoader) + downstream.push( + ...(index === selectedIndex + ? rule.loaders.slice(0, devup) + : rule.loaders), + ) + } + return { pipeline, downstream: Object.freeze(downstream) } +} + +export type WebpackOrdinaryEligibility = + | { readonly kind: 'disk-first' } + | ({ readonly kind: 'native-required' } & WebpackResourceBoundary) + +export function qualifyOrdinaryRules( + filename: string, + reachable: readonly ResourceRule[], +): { + readonly eligibility: WebpackOrdinaryEligibility + readonly downstream: readonly MdxLoader[] +} { + const pre = reachable.filter((rule) => rule.stage === 'pre') + const selectedIndex = pre.findLastIndex((rule) => + rule.loaders.some(isDevupLoader), + ) + const selected = pre[selectedIndex] + if (!selected) + return { + eligibility: { + kind: 'native-required', + filename, + rulePosition: 'module.rules', + test: '', + unknownFact: 'first-input boundary', + reason: 'no unconditional native Devup pre boundary is available', + }, + downstream: [], + } + const upstream = pre + .slice(selectedIndex + 1) + .find((rule) => rule.loaders.length || rule.dynamic) + const issue = + selected.shadow ?? + (selected.unknownFact || selected.dynamic ? selected : upstream) + const index = selected.loaders.findIndex(isDevupLoader) + if (issue || index !== selected.loaders.length - 1) + return { + eligibility: { + kind: 'native-required', + ...resourceBoundary( + filename, + issue ?? selected, + 'the first input can depend on another pre-loader or missing origin facts', + ), + }, + downstream: [], + } + const dynamic = reachable.find((rule) => rule.dynamic) + if (dynamic) + return { + eligibility: { + kind: 'native-required', + ...resourceBoundary( + filename, + dynamic, + 'an unobserved loader factory can replace disk input', + ), + }, + downstream: [], + } + return { + eligibility: Object.freeze({ kind: 'disk-first' }), + downstream: Object.freeze( + reachable.flatMap((rule) => + rule === selected ? rule.loaders.slice(0, index) : rule.loaders, + ), + ), + } +} diff --git a/packages/next-plugin/src/webpack-resource-rules.ts b/packages/next-plugin/src/webpack-resource-rules.ts new file mode 100644 index 000000000..9565b83d9 --- /dev/null +++ b/packages/next-plugin/src/webpack-resource-rules.ts @@ -0,0 +1,127 @@ +import { isMdxRecord, type MdxLoader, type MdxRule } from './mdx-pipeline' +import { + createResourceEnvelope, + resourceRuleLabel, + unknownResourceFact, +} from './webpack-resource-envelope' +import type { + NativeRuleSet, + WebpackResourceBinding, +} from './webpack-resource-native' + +export type ResourceControl = { + readonly position: string + readonly test: string + readonly unknownFact: string | undefined + readonly envelope: NativeRuleSet +} + +export type ResourceRule = { + readonly position: string + readonly key: string + readonly rules: readonly MdxRule[] + readonly rule: MdxRule + readonly envelope: NativeRuleSet + readonly loaders: readonly MdxLoader[] + readonly dynamic: boolean + readonly unknownFact: string | undefined + readonly stage: 'pre' | 'post' | 'normal' + readonly test: string + readonly controls: readonly ResourceControl[] + readonly shadow?: ResourceControl +} + +type RuleTrace = { + readonly key: string + readonly parents: readonly MdxRule[] + readonly controls: readonly ResourceControl[] +} + +function descriptors(rule: MdxRule): readonly MdxLoader[] | undefined { + const use = + rule.use ?? + (rule.loader === undefined + ? [] + : [{ loader: rule.loader, options: rule.options }]) + const values: readonly unknown[] = Array.isArray(use) ? use : [use] + const result: MdxLoader[] = [] + for (const value of values) { + if (typeof value === 'string') result.push({ loader: value }) + else if ( + isMdxRecord(value) && + typeof value.loader === 'string' && + (value.options === undefined || + typeof value.options === 'string' || + isMdxRecord(value.options)) + ) { + result.push({ + loader: value.loader, + ...(value.options === undefined ? {} : { options: value.options }), + }) + } else return + } + return result +} + +export function collectResourceRules( + binding: WebpackResourceBinding, +): readonly ResourceRule[] { + const result: ResourceRule[] = [] + function visit(rule: unknown, trace: RuleTrace) { + if (!isMdxRecord(rule)) return + const { key, parents, controls } = trace + const path = [...parents, rule] + if (rule.use !== undefined || rule.loader !== undefined) { + const loaders = descriptors(rule) + result.push( + Object.freeze({ + ...resourceRuleLabel(key, rule), + key, + rules: Object.freeze(path), + rule, + envelope: createResourceEnvelope(binding, path), + loaders: Object.freeze(loaders ?? []), + dynamic: loaders === undefined, + unknownFact: unknownResourceFact(path), + stage: + rule.enforce === 'pre' + ? 'pre' + : rule.enforce === 'post' + ? 'post' + : 'normal', + controls: Object.freeze([...controls]), + }), + ) + } + for (const member of ['rules', 'oneOf']) { + const children = rule[member] + const previous: ResourceControl[] = [] + if (Array.isArray(children)) + children.forEach((child: unknown, index: number) => { + const childKey = `${key}.${member}.${index}` + visit(child, { + key: childKey, + parents: path, + controls: + member === 'oneOf' ? [...controls, ...previous] : controls, + }) + if (member === 'oneOf' && isMdxRecord(child)) { + previous.push( + Object.freeze({ + ...resourceRuleLabel(childKey, child), + unknownFact: unknownResourceFact([...path, child]), + envelope: createResourceEnvelope(binding, [...path, child]), + }), + ) + } + }) + } + } + binding.effectiveConfiguration.module.defaultRules.forEach((rule, index) => + visit(rule, { key: `defaultRules.${index}`, parents: [], controls: [] }), + ) + binding.effectiveConfiguration.module.rules.forEach((rule, index) => + visit(rule, { key: String(index), parents: [], controls: [] }), + ) + return Object.freeze(result) +} diff --git a/packages/next-plugin/src/webpack-resource-selector.ts b/packages/next-plugin/src/webpack-resource-selector.ts new file mode 100644 index 000000000..f19f20971 --- /dev/null +++ b/packages/next-plugin/src/webpack-resource-selector.ts @@ -0,0 +1,224 @@ +import type { MdxNativeLoaderFacts } from './mdx-binding' +import { snapshotMdxBindingValue } from './mdx-binding-value' +import type { MdxLoader, MdxPipeline } from './mdx-pipeline' +import { + type MdxPreparationContext, + recognizeMdxPrewarmStep, +} from './mdx-prewarm-boundary' +import { createWebpackLoaderFacts } from './webpack-mdx-facts' +import { compilerResourceBoundary } from './webpack-resource-compiler' +import { + webpackResourceDelivery, + webpackResourceResolver, +} from './webpack-resource-delivery' +import { WebpackResourceError } from './webpack-resource-error' +import { + nativeLoaders, + nativeRuleSet, + resourceFacts, + type WebpackResourceBinding, +} from './webpack-resource-native' +import { + isDevupLoader, + qualifyMdxRules, + qualifyOrdinaryRules, + reachableResourceRules, + type WebpackOrdinaryEligibility, +} from './webpack-resource-qualification' +import { collectResourceRules } from './webpack-resource-rules' + +export type WebpackResourceSelection = { + readonly pipeline: MdxPipeline + readonly context: MdxPreparationContext + readonly identity: ReturnType + readonly loaders: readonly MdxNativeLoaderFacts[] +} +export type WebpackResourceSelectorInput = { + readonly binding: WebpackResourceBinding + readonly configFile: string + readonly owner: object + readonly generation: object +} + +export async function createWebpackResourceSelector( + input: WebpackResourceSelectorInput, +) { + const { binding, configFile } = input + const native = nativeRuleSet(binding.params.normalModuleFactory) + const rules = collectResourceRules(binding) + const delivery = webpackResourceDelivery(binding) + const resolver = webpackResourceResolver(binding) + const facts = createWebpackLoaderFacts(binding, configFile) + const prepared = new Map< + MdxPipeline, + { + readonly pipeline: MdxPipeline + readonly loaders: readonly MdxNativeLoaderFacts[] + readonly identity: ReturnType + } + >() + const ordinary = new Map() + async function qualifyDiskFirst( + filename: string, + downstream: readonly MdxLoader[], + signal: AbortSignal, + ): Promise { + const pre = nativeLoaders( + native.exec(resourceFacts(binding, filename)), + 'use-pre', + ) + const first = pre.at(-1) + if (!first || !isDevupLoader(first)) + return Object.freeze({ + kind: 'native-required', + filename, + rulePosition: 'module.rules', + test: '', + unknownFact: 'selected first-input boundary', + reason: + 'the actual receiving RuleSet does not put Devup at the first ordinary normal input position', + }) + const boundary = compilerResourceBoundary(binding, filename) + if (boundary) return Object.freeze({ kind: 'native-required', ...boundary }) + await facts.downstreamSafe(downstream, filename, signal) + signal.throwIfAborted() + return Object.freeze({ kind: 'disk-first' }) + } + return Object.freeze({ + ...resolver, + watchInputs: facts.watchInputs, + async selectPipeline( + filename: string, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted() + const reachable = reachableResourceRules(binding, rules, filename) + const qualification = qualifyMdxRules( + filename, + reachable, + binding.pipelines, + ) + if (qualification.boundary) + throw new WebpackResourceError(configFile, qualification.boundary) + if (!qualification.pipeline) return + const original = qualification.pipeline + const effects = native.exec(resourceFacts(binding, filename)) + const normal = nativeLoaders(effects, 'use') + const devup = normal.findLastIndex(isDevupLoader) + const upstream = normal.slice(devup + 1) + if ( + devup < 0 || + upstream.length !== original.loaders.length || + upstream.some( + (loader, index) => + loader.loader !== original.loaders[index]?.loader || + loader.options !== original.loaders[index]?.options, + ) + ) + throw new WebpackResourceError(configFile, { + filename, + rulePosition: original.ruleKey, + test: '', + unknownFact: 'selected native chain', + reason: + 'receiving RuleSet does not select the qualified original MDX segment', + }) + const compilerBoundary = compilerResourceBoundary(binding, filename) + if (compilerBoundary) + throw new WebpackResourceError(configFile, compilerBoundary) + let result = prepared.get(original) + if (!result) { + const outputs = await Promise.all( + upstream.map((loader) => facts.resolveLoader(loader, signal)), + ) + if ( + outputs.some( + (output) => !recognizeMdxPrewarmStep(output.facts.resolvedPath), + ) + ) + throw new WebpackResourceError(configFile, { + filename, + rulePosition: original.ruleKey, + test: '', + unknownFact: 'installed compiler semantics', + reason: + 'the exact installed preparation5b compiler is not recognized', + }) + const originalFacts = Object.freeze( + outputs.map((output) => output.facts).reverse(), + ) + result = Object.freeze({ + pipeline: Object.freeze({ + ...original, + loaders: Object.freeze(outputs.map((output) => output.loader)), + }), + loaders: originalFacts, + identity: snapshotMdxBindingValue(originalFacts), + }) + prepared.set(original, result) + } + await facts.downstreamSafe(qualification.downstream, filename, signal) + signal.throwIfAborted() + return Object.freeze({ + ...result, + context: Object.freeze({ + owner: input.owner, + generation: input.generation, + compiler: binding.compiler, + ...delivery, + }), + }) + }, + ordinaryEligibility(filename: string): WebpackOrdinaryEligibility { + const cached = ordinary.get(filename) + if (cached) return cached + const result = qualifyOrdinaryRules( + filename, + reachableResourceRules(binding, rules, filename), + ).eligibility + switch (result.kind) { + case 'native-required': + return result + case 'disk-first': + return Object.freeze({ + kind: 'native-required', + filename, + rulePosition: 'module.rules', + test: '', + unknownFact: 'unqualified first-input boundary', + reason: + 'await qualifyOrdinary before initial ordinary replay; this is not an attempt proof', + }) + default: + return result satisfies never + } + }, + async qualifyOrdinary( + filename: string, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted() + const qualification = qualifyOrdinaryRules( + filename, + reachableResourceRules(binding, rules, filename), + ) + switch (qualification.eligibility.kind) { + case 'native-required': + ordinary.set(filename, qualification.eligibility) + return qualification.eligibility + case 'disk-first': + break + default: + return qualification.eligibility satisfies never + } + const eligibility = await qualifyDiskFirst( + filename, + qualification.downstream, + signal, + ) + signal.throwIfAborted() + ordinary.set(filename, eligibility) + return eligibility + }, + }) +} diff --git a/packages/plugin-utils/README.md b/packages/plugin-utils/README.md new file mode 100644 index 000000000..41f74ba01 --- /dev/null +++ b/packages/plugin-utils/README.md @@ -0,0 +1,262 @@ +# @devup-ui/plugin-utils + +Shared build-time utilities for Devup UI plugins. These utilities do not execute +modules or add styling JavaScript to application output. + +## Source selection and exclusions + +`listSourceFiles`, `buildStaticImportGraph`, and `collectNumberedFiles` all default +to JavaScript/TypeScript only (`.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, +`.cjs`). Markdown is **off by default everywhere**. + +Their `includeMdx` option has type `boolean | readonly string[]`: + +- Omitted, `false`, or `[]`: no extra extensions. +- `true`: exactly `.mdx`, not `.md`. +- A list: exactly the supplied literal extensions, such as `['.md', '.mdx']` + or `['.mdown']`. Use normalized extension strings with their leading dot; + matching is case-insensitive. No extension patterns or regular expressions. + +```ts +import { + buildStaticImportGraph, + collectNumberedFiles, + listSourceFiles, +} from '@devup-ui/plugin-utils' + +const js = listSourceFiles('/project/src', ['generated']) +const docs = listSourceFiles('/project/src', ['generated'], { + includeMdx: ['.md', '.mdx', '.mdown'], +}) +const numbered = collectNumberedFiles({ + roots: ['/project/src'], + includeMdx: ['.mdown'], + exclude: ['/project/build/next', 'generated'], + needles: ['@devup-ui/react'], +}) +``` + +The two-argument scanner call retains its directory-exclusion argument; use the +third options argument to opt into Markdown. All three APIs use one exclusion +matcher: a bare directory name matches at any depth; an absolute directory path +matches only that directory and its descendants, not same-basename folders +elsewhere. Windows matching normalizes separators, drive letters and path case; +POSIX matching preserves case. Excluded roots and imported targets are skipped +before reading source, preparation or numbering needle checks. `node_modules` +and test/spec files remain absent from directory enumeration. + +Opt-in extensions also participate in extensionless graph resolution and route +recognition. Explicit existing file paths still resolve with +`createModuleResolver` without opting into their extension, but Markdown must be +prepared before its source can be returned. Numbering forwards +selection and exclusions to the scanner rather than reading then filtering. +The public module resolver retains its legacy extensionless `.mdx` probing; +the graph's path-only resolver probes extra extensions only when selected. + +## Synchronous and prepared-source graphs + +Without a `prepareSource` field, `buildStaticImportGraph` remains synchronous and +returns `StaticImportGraph`. Existing synchronous callers do not receive a +`StaticImportGraph | Promise` union. + +```ts +const graph = buildStaticImportGraph('src', undefined, { cwd: '/project' }) + +const prepared = await buildStaticImportGraph('src', undefined, { + cwd: '/project', + includeMdx: ['.mdx', '.mdown'], + alias: { 'mdx-provider$': '/project/mdx-components.js' }, + prepareSource: async (filename) => { + if (!filename.endsWith('.mdx') && !filename.endsWith('.mdown')) return undefined + const compiled = await compileProjectMarkdown(filename) + return { + code: String(compiled.value), + map: compiled.map, + sourceType: 'compiled-mdx', + } + }, +}) +``` + +Supplying **any** preparer, including a synchronous callback, returns +`Promise`. The exported `PrepareSource` receives each visited +real absolute filename once, including discovered providers and eligible +included-package dependencies. It returns a string, `{ code: string, map?: +unknown }`, `undefined`, or a Promise of those values. Only `undefined` reads raw +source; `''` is valid prepared code. Relative imports, keys and diagnostics keep +the actual filename. Prepared code bypasses the raw Markdown ESM-block filter, +so compiler-injected provider/remark/rehype imports can be discovered. Configure +the project compiler to interpret custom extensions as MDX where required. + +Prepared objects and resolved modules accept `sourceType?: 'compiled-mdx'`. +This explicitly selects JavaScript with JSX, including under `.mdown` or a TS +filename; ordinary filename-derived JS/TS grammar is unchanged when omitted. +The extraction functions receive this value as their optional ninth argument. +`.md` and `.mdx` retain their compiled-MDX extractor defaults; a custom extraction +extension without an explicit type is a located error, never a renamed-file +workaround. Invalid values or throwing type getters fail at the preparation +boundary with the real filename/importer and cause. The field follows prepared +resolver generations into WASM, including imported constants after JSX and +reexports, and caches include the selected type. + +The graph is an **import scanner, not a full syntax validator**. The caller's +compiler and bundler own validation of the same compiled output. No parser +dependency is installed by this package. **Every edge always comes from one +dependency-free scanner.** If optional `oxc-parser` is available, it supplies +syntax diagnostics only: its AST and `program` getter never determine edges. +Parser exceptions/diagnostics fail rather than falling back to raw source or +changing edge authority. Compiler failures retain the real filename and +original cause; setup/hook failures reject the Promise. Parser positions are +remapped using `remapMdxError` when a map is supplied; unmapped positions are +explicitly labeled `in compiled output`, not presented as Markdown coordinates. + +The lexer keeps real filename language context: comparisons/shifts are not JSX, +TSX generic arrows are not tags, and type-only imports are not runtime edges. +Prepared ordinary TS retains its TS grammar; explicit compiled-MDX output uses +JS/JSX grammar even under a TS filename. The lexer masks ordinary strings, outer +template text, comments, regular +expressions and JSX text/quoted attributes. Template `${...}` expressions, +including nested templates, and JSX brace/attribute expressions are real code: +literal `require('...')` adds static edges and literal `import('...')` adds dynamic +edges there just as in ordinary code. Normal imports, side-effect imports and +export-from declarations add static edges; type-only declarations/specifiers are +elided without dropping value bindings named `type`. Escaped specifiers are +decoded without evaluating source. Nonliteral/computed or concatenated arguments +such as `import(name)`, `require(name)` and `import('./' + name)` add no edge. +The scanner is not a full parser; builders retain fail-closed checks for modules +the bundler actually loads but the static graph misses. Raw opted-in Markdown +scans only ESM blocks, not code fences or prose. + +The checked-in differential gate is runnable from the repository root with +`bun test ./packages/plugin-utils/src/__tests__/parser-corpus.test.ts` (and runs +in the root suite). It compares complete graphs for every package `src` root and +`apps/landing/src`, including real landing docs compiled once using the project's +MDX compiler/provider options, with diagnostics disabled and injected. It asserts +one diagnostic invocation per source and zero AST getter reads; independent +targeted fixtures also assert expected literal edges and reject contradictory AST +dependencies. The optional Oxc package is not installed by this gate. + +## Synchronous prepared module resolver + +`createModuleResolver({ prepareSource })` retains its synchronous callable shape +for `setModuleResolver` and also exposes `resolver.remapError(error)`. The hook +receives the real absolute filename after native/alias resolution and before +`toId`; it returns the same string, `{ code, map? }`, or `undefined` values as the +graph hook. Empty code is valid. Ordinary JS/TS with `undefined` still reads raw +source. The resolver never awaits: **any thenable is rejected immediately**. +Async compilers must finish preparation into a caller-owned cache first, then +pass the same synchronous lookup hook to both graph and resolver. + +With or without a hook, `.mdx`, `.md`, and explicit `includeMdx` extensions cannot +reach WASM without prepared JavaScript. Missing preparation, thenables and hook +exceptions fail at the actual importer `:1:1` (the callback has no import span), +name the actual module and preserve the cause. Move imported values into JS/TS +when Markdown compilation is not available; this API does not add MDX support +to plugins that do not compile it. + +Callers supplying `prepareSource` **must route every error thrown by +`codeExtract` through `resolver.remapError`**, not just callback errors: + +```ts +const prepareSource = (filename: string) => compiledCache.get(filename) +const resolver = createModuleResolver({ cwd, toId, prepareSource }) +setModuleResolver(resolver) +try { + return codeExtract(filename, source, /* existing extraction arguments */) +} catch (error) { + throw resolver.remapError(error) +} +``` + +Mappings are resolver-instance-local, keyed by the exact id returned to WASM. +Each resolved generation replaces its prior code/map; ordinary raw JS/TS removes +its stale map. Keep the resolver for its build/compiler context, replace cache +entries when recompiling, and discard it when that context ends. Vite and Bun +create build-context resolvers; Webpack creates compiler/loader-context resolvers. +Bun supplies this hook for its own compiled MDX cache and wraps extraction +errors; Next's prepared-source integration on #752 also owns its preparation +and wrapping. Vite, Webpack and Rsbuild do not gain a separate project MDX +preparation cache from this shared API. + +Every exact prepared-id location in an error is remapped through `remapMdxError`; +other file locations are untouched. Missing/uncovered mappings explicitly say +`in compiled output` of the real file. Returned errors retain the original as +`cause`; passing the returned error to the same resolver again is idempotent. +The resolver cannot recover imported-file coordinates absent from the extractor +error: that mapping boundary belongs to #757. With #755 alone, the real imported +invalid-value case reports the importing TSX source position, which stays +untouched. Contract tests use synthetic extractor-format diagnostics and real +compiler maps; the imported-module end-to-end mapping check runs after #755 and +#757 are both on main, without merging either automatically. + +## Resolver aliases + +Both `createModuleResolver` and graph options accept an optional readonly +`alias: ModuleAliasOptions`. The existing `ModuleAliases` map type remains +`Readonly>`. +The option also accepts native `false` (disable aliases) or ordered descriptors: + +```ts +const alias = [ + { name: 'provider', alias: ['first-provider', false], onlyModule: true }, + { name: 'provider$', alias: false, onlyModule: false }, +] as const +``` + +Descriptors preserve declaration order and duplicate names; a literal `$` in +their name remains a prefix when `onlyModule` is false. In the map form, +`provider$` instead means the exact name `provider`. +Targets are strings, `false`, or ordered string/false candidate arrays: +absolute file/directory paths or package requests. They run before tsconfig +paths, baseUrl and package resolution. + +- `name$` matches only `name`; `name` matches `name` and `name/subpath`, not + `name-other`. +- Declaration order matters: the first **rewriting** match wins. Put a specific + key before an overlapping broad key. +- Each candidate undergoes full native resolution in order; the first resolving + candidate wins. A failed matched key never falls through to the original request + or a later key. All unresolved rewriting candidates produce an + importer-located error naming the key and every rewritten candidate tried. + Later descriptors with the same name do not bypass that failure. Empty arrays + and selfguard-only entries perform no rewrite, so they continue to the next + descriptor/key and then the raw request. Unaliased unresolved requests still + return `undefined`. +- Finite chains resolve; self-aliases are skipped; cycles produce an + importer-located failure. +- A reached `false` immediately resolves to `{ ignored: true }`, including when + reached through another string alias. String candidates re-enter the alias map; + a request equal to or below its target is not aliased again. Earlier resolving + candidates win, and newly created earlier files are visible on the next call. +- Package exports and active conditions are applied after rewriting. Graph + package eligibility uses the actual target package, not the alias name. +- Filesystem directory requests read their own `package.json` and try the shared + `module`, `main`, then index order after file/extension probes. These absolute + or relative directory paths follow native main-field resolution, not a bare + package's exports restriction. Recursive entries honor exclusions before IO; + malformed manifests and directory cycles are located errors. Installed Next's + absolute `@swc/helpers/_` prefix alias is covered with the real helper package. +- Export/active-target failures, configuration/I/O errors and cycles are fatal, + not ordinary misses that advance to the next candidate. Excluded candidates + are skipped before I/O; an entirely excluded list stays excluded, not external. +- Project-local aliased providers outside configured source roots can be + followed; excluded directories and excluded external packages remain excluded. + +Ignored results have no `path`, `code` or `sourceType`. `ModuleResolution` is +`ResolvedModule | IgnoredModule`; narrow the ignored result before reading file +fields. Neither the graph nor the evaluator scans, prepares, watches or numbers +a manufactured ignored file. Physically enumerated real source files retain +their normal numbering. Evaluated ignored imports use an empty CommonJS export +object: missing named values are `undefined`, the default follows existing +CommonJS interop, and namespace/require values are empty objects. + +This subset is tested against installed enhanced-resolve 5.25.1 with real files. +Its `AliasUtils` supports reached false candidates, but its public +`ResolverFactory` currently rejects mixed string/false arrays before that handler; +Webpack therefore stops with its own construction error for those arrays. +Rspack's native map-or-false format accepts mixed false arrays and has the same +terminal-rewrite/empty-array behavior. Webpack passes its native map or ordered +descriptors unchanged through graph, setup and loader resolver/cache; Rsbuild +passes the actual Rspack map-or-false form. Unsupported wildcard alias names fail +with an importer-located configuration error naming the key instead of being +silently dropped. diff --git a/packages/plugin-utils/src/__tests__/alias-array-fixture.ts b/packages/plugin-utils/src/__tests__/alias-array-fixture.ts new file mode 100644 index 000000000..d12585d05 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-array-fixture.ts @@ -0,0 +1,50 @@ +import * as fs from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import type { CreateModuleResolverOptions } from '../import-graph' + +const installedRequire = createRequire( + resolve( + 'node_modules/.bun/enhanced-resolve@5.25.1/node_modules/enhanced-resolve/package.json', + ), +) +const enhancedResolve = installedRequire('enhanced-resolve') + +export function aliasFixture() { + const parent = join(tmpdir(), 'opencode', 'workers', 'w20-plugins-core') + fs.mkdirSync(parent, { recursive: true }) + const root = fs.realpathSync.native( + fs.mkdtempSync(join(parent, 'alias-array-')), + ) + const file = (name: string, code = 'export {}') => { + const target = join(root, name) + fs.mkdirSync(dirname(target), { recursive: true }) + fs.writeFileSync(target, code) + return target + } + const entry = file('src/main.ts') + return { + root, + entry, + file, + dispose: () => fs.rmSync(root, { recursive: true, force: true }), + installed: ( + request: string, + options: CreateModuleResolverOptions & { + readonly mainFields?: readonly string[] + }, + ): string | false => { + const resolver = enhancedResolve.ResolverFactory.createResolver({ + fileSystem: fs, + useSyncFileSystemCalls: true, + extensions: ['.js', '.ts'], + alias: options.alias, + conditionNames: options.conditions ?? ['import', 'browser'], + mainFields: options.mainFields ?? ['main'], + }) + return resolver.resolveSync({}, dirname(entry), request) + }, + } +} diff --git a/packages/plugin-utils/src/__tests__/alias-array-package.test.ts b/packages/plugin-utils/src/__tests__/alias-array-package.test.ts new file mode 100644 index 000000000..6450aa73c --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-array-package.test.ts @@ -0,0 +1,178 @@ +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { aliasFixture } from './alias-array-fixture' + +let fixture: ReturnType +beforeEach(() => { + fixture = aliasFixture() +}) +afterEach(() => fixture.dispose()) + +it.each([ + { conditions: ['browser', 'import'] }, + { conditions: ['import'] }, + { conditions: [] }, +])( + 'resolves active conditions before selecting the package (%j)', + ({ conditions }: { readonly conditions: readonly string[] }) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + file('node_modules/missing-main/package.json', '{"main":"absent.js"}') + file( + 'node_modules/actual/package.json', + '{"exports":{"browser":"./browser.js","import":"./import.js","default":"./default.js"}}', + ) + const browser = file('node_modules/actual/browser.js', "import './leaf'") + const imported = file('node_modules/actual/import.js', "import './leaf'") + const fallback = file('node_modules/actual/default.js', "import './leaf'") + const leaf = file('node_modules/actual/leaf.js') + const alias = { provider: ['missing-main', 'actual'] } + const expected = conditions.includes('browser') + ? browser + : conditions.includes('import') + ? imported + : fallback + expect(installed('provider', { alias, conditions })).toBe(expected) + expect( + createModuleResolver({ cwd: root, alias, conditions })('provider', entry), + ).toEqual({ path: expected, code: "import './leaf'" }) + expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + conditions, + include: ['actual'], + }).files, + ).toEqual([entry, expected, leaf].sort()) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias, conditions }) + .files, + ).toEqual([entry]) + }, +) + +it.each(['blocked', 'condition', 'poison'])( + 'preserves fatal %s package errors instead of trying a later candidate', + (kind) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + const manifest = + kind === 'blocked' + ? '{"exports":{"./other":"./index.js"}}' + : kind === 'condition' + ? '{"exports":{"browser":"./absent.js","default":"./index.js"}}' + : '{' + const manifestFile = file('node_modules/first/package.json', manifest) + file('node_modules/first/index.js') + const alias = { provider: ['first', file('later.js')] } + expect(() => installed('provider', { alias })).toThrow() + const expected = + kind === 'poison' + ? manifestFile + : `${entry}:1:1: Module alias package first` + expect(() => + createModuleResolver({ cwd: root, alias, conditions: ['browser'] })( + 'provider', + entry, + ), + ).toThrow(expected) + expect(() => + buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + conditions: ['browser'], + }), + ).toThrow(expected) + }, +) + +it('keeps cycles fatal even with a resolving later candidate', () => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + const alias = { provider: ['nested', file('later.js')], nested: 'provider' } + expect(() => installed('provider', { alias })).toThrow() + expect(() => + createModuleResolver({ cwd: root, alias })('provider', entry), + ).toThrow(`${entry}:1:1: Module alias cycle`) + expect(() => + buildStaticImportGraph('src', undefined, { cwd: root, alias }), + ).toThrow(`${entry}:1:1: Module alias cycle`) +}) + +it('skips self candidates while retaining ordered overlapping keys', () => { + const { root, entry, file, installed } = fixture + const chosen = file('chosen.js') + file('src/main.ts', "import 'provider'") + const alias = { + provider: ['provider', chosen], + provider$: [file('later.js')], + } + expect(installed('provider', { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, chosen].sort()) +}) + +it('follows a selected absolute package candidate and its closure', () => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + file('node_modules/actual/package.json', '{"main":"index.js"}') + const chosen = file('node_modules/actual/index.js', "import './leaf'") + const leaf = file('node_modules/actual/leaf.js') + const alias = { provider: [join(root, 'missing'), chosen] } + expect(installed('provider', { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + include: ['actual'], + }).files, + ).toEqual([entry, chosen, leaf].sort()) +}) + +it.each([false, true])( + 'skips poisoned excluded candidates without external prewarming (allExcluded=%s)', + (allExcluded) => { + const { root, entry, file } = fixture + file('src/main.ts', "import 'provider'") + const poison = file('node_modules/poison/package.json', '{') + const chosen = file('local/provider.js', "import './leaf'") + const leaf = file('local/leaf.js') + const alias = { provider: ['poison', chosen] } + const exclude = allExcluded + ? [dirname(poison), dirname(chosen)] + : [dirname(poison)] + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + exclude, + }) + expect(graph.files).toEqual( + (allExcluded ? [entry] : [entry, chosen, leaf]).sort(), + ) + expect(graph.externalImports?.get(entry)).toEqual(new Set()) + }, +) + +it('keeps a mixed excluded and missing candidate list terminal', () => { + const { root, entry, file } = fixture + file('src/main.ts', "import 'provider'") + const poison = file('node_modules/poison/package.json', '{') + const alias = { provider: ['poison', join(root, 'missing')] } + expect(() => + buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + exclude: [dirname(poison)], + }), + ).toThrow(`${entry}:1:1: Module alias provider`) +}) diff --git a/packages/plugin-utils/src/__tests__/alias-array-prepared.test.ts b/packages/plugin-utils/src/__tests__/alias-array-prepared.test.ts new file mode 100644 index 000000000..2a70a7f9d --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-array-prepared.test.ts @@ -0,0 +1,72 @@ +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import * as wasm from '../../../../bindings/devup-ui-wasm/pkg' +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { aliasFixture } from './alias-array-fixture' + +const projectRequire = createRequire(resolve('apps/landing/package.json')) +const compilerRequire = createRequire(projectRequire.resolve('@mdx-js/loader')) +const { compile } = compilerRequire('@mdx-js/mdx') +let fixture: ReturnType +let debug: boolean +beforeEach(() => { + fixture = aliasFixture() + debug = wasm.isDebug() + wasm.resetBuildState() + wasm.setDebug(false) +}) +afterEach(() => { + wasm.setModuleResolver(undefined) + wasm.resetBuildState() + wasm.registerTheme({}) + wasm.setDebug(debug) + fixture.dispose() +}) + +it('prepares only the chosen real Markdown filename and extracts its imported constant', async () => { + const { root, entry, file } = fixture + const markdown = "export const PRIMARY = 'tomato'\n\n# Heading" + const chosen = file('provider.mdx', markdown) + const compiled = String(await compile({ value: markdown, path: chosen })) + const alias = { provider: [join(root, 'missing.mdx'), chosen] } + const calls: string[] = [] + const prepareSource = (filename: string) => { + calls.push(filename) + return filename === chosen ? compiled : undefined + } + const resolver = createModuleResolver({ cwd: root, alias, prepareSource }) + expect(resolver('provider', entry)).toEqual({ path: chosen, code: compiled }) + expect(calls).toEqual([chosen]) + const source = + "import {PRIMARY} from 'provider'; import {Box} from '@devup-ui/react'; export const view = ;" + file('src/main.ts', source) + calls.length = 0 + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + includeMdx: true, + prepareSource, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([chosen])) + expect(calls).toEqual([entry, chosen]) + wasm.setModuleResolver(resolver) + const output = wasm.codeExtract( + entry.replace(/\.ts$/, '.tsx'), + source, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + try { + expect(wasm.getCss(undefined, false)).toContain('color:tomato') + expect(output.code).not.toContain('--') + } finally { + output.free() + } +}) diff --git a/packages/plugin-utils/src/__tests__/alias-array.test.ts b/packages/plugin-utils/src/__tests__/alias-array.test.ts new file mode 100644 index 000000000..772f8f7b9 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-array.test.ts @@ -0,0 +1,129 @@ +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { aliasFixture } from './alias-array-fixture' + +let fixture: ReturnType +beforeEach(() => { + fixture = aliasFixture() +}) +afterEach(() => fixture.dispose()) + +it.each([false, true])( + 'selects the first fully resolving candidate when first exists=%s', + (firstExists) => { + const { root, entry, file, installed } = fixture + const first = join(root, 'first/index.js') + if (firstExists) file('first/index.js', "export const value = 'first'") + const second = file('second/index.js', "export const value = 'second'") + file('src/main.ts', "import 'provider'") + const alias = { provider: [dirname(first), dirname(second)] } + const expected = firstExists ? first : second + const resolved = createModuleResolver({ cwd: root, alias })( + 'provider', + entry, + ) + expect(resolved).toEqual({ + path: expected, + code: firstExists + ? "export const value = 'first'" + : "export const value = 'second'", + }) + expect(installed('provider', { alias })).toBe(expected) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, expected].sort()) + }, +) + +it.each(['provider', 'provider/feature'])( + 'applies exact and subpath arrays when request is %s', + (request) => { + const { root, entry, file, installed } = fixture + const exact = file('exact.js') + const feature = file('second/feature.js') + file('src/main.ts', `import '${request}'`) + const alias = { + provider$: [join(root, 'missing'), exact], + provider: [join(root, 'absent'), dirname(feature)], + } + const expected = request === 'provider' ? exact : feature + expect(installed(request, { alias })).toBe(expected) + expect( + createModuleResolver({ cwd: root, alias })(request, entry)?.path, + ).toBe(expected) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, expected].sort()) + }, +) + +it('reports all candidates at the real importer without original or lateral fallback', () => { + const { root, entry, file, installed } = fixture + file('node_modules/original/package.json', '{"main":"index.js"}') + file('node_modules/original/index.js') + const later = file('later.js') + file('src/main.ts', "import 'original'") + const candidates = [join(root, 'missing'), join(root, 'absent')] + const alias = { original: candidates, original$: [later] } + expect(() => installed('original', { alias })).toThrow() + const message = `${entry}:1:1: Module alias original cannot resolve candidates ${JSON.stringify(candidates)}` + expect(() => + createModuleResolver({ cwd: root, alias })('original', entry), + ).toThrow(message) + expect(() => + buildStaticImportGraph('src', undefined, { cwd: root, alias }), + ).toThrow(message) +}) + +it('continues to the next key when a matched candidate list is empty', () => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + const chosen = file('later.js') + const alias = { provider: [], provider$: chosen } + expect(installed('provider', { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, chosen].sort()) +}) + +it('tries another candidate after a complete nested alias miss', () => { + const { root, entry, file, installed } = fixture + const chosen = file('chosen.js') + file('src/main.ts', "import 'provider'") + const alias = { provider: ['nested', chosen], nested: join(root, 'missing') } + expect(installed('provider', { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, chosen].sort()) +}) + +it('does not match an exact array key against a package subpath', () => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider/feature'") + file( + 'node_modules/provider/package.json', + '{"exports":{"./feature":"./feature.js"}}', + ) + const chosen = file('node_modules/provider/feature.js') + const alias = { provider$: [file('wrong.js')] } + expect(installed('provider/feature', { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })('provider/feature', entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + include: ['provider'], + }).files, + ).toEqual([entry, chosen].sort()) +}) diff --git a/packages/plugin-utils/src/__tests__/alias-descriptors.test.ts b/packages/plugin-utils/src/__tests__/alias-descriptors.test.ts new file mode 100644 index 000000000..3e45d3f25 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-descriptors.test.ts @@ -0,0 +1,206 @@ +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { aliasFixture } from './alias-array-fixture' + +let fixture: ReturnType +beforeEach(() => { + fixture = aliasFixture() +}) +afterEach(() => fixture.dispose()) + +it.each([false, true])( + 'selects the first native descriptor success when earlier file exists=%s', + (exists) => { + const { root, entry, file, installed } = fixture + const first = join(root, 'first.js') + if (exists) file('first.js') + const second = file('second.js') + file('src/main.ts', "import 'provider'") + const alias = [ + { name: 'provider', alias: [first, second] }, + { name: 'provider', alias: false }, + ] as const + const expected = exists ? first : second + expect(installed('provider', { alias })).toBe(expected) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry)?.path, + ).toBe(expected) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, expected].sort()) + }, +) + +it.each([false, true])( + 'keeps a rewriting miss terminal despite a duplicate later ignored=%s', + (ignored) => { + const { root, entry, file, installed } = fixture + const missing = join(root, 'missing.js') + const later = file('later.js') + file('src/main.ts', "import 'provider'") + const alias = [ + { name: 'provider', alias: missing }, + { name: 'provider', alias: ignored ? false : later }, + ] as const + expect(() => installed('provider', { alias })).toThrow() + const message = `${entry}:1:1: Module alias provider cannot resolve candidates ${JSON.stringify([missing])}` + expect(() => + createModuleResolver({ cwd: root, alias })('provider', entry), + ).toThrow(message) + expect(() => + buildStaticImportGraph('src', undefined, { cwd: root, alias }), + ).toThrow(message) + }, +) + +it.each(['empty', 'self'])( + 'advances to a duplicate descriptor when the first is %s without a rewrite', + (kind) => { + const { root, entry, file, installed } = fixture + const chosen = file('chosen.js') + file('src/main.ts', "import 'provider'") + const alias = [ + { name: 'provider', alias: kind === 'empty' ? [] : ['provider'] }, + { name: 'provider', alias: chosen }, + ] as const + expect(installed('provider', { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, chosen].sort()) + }, +) + +it.each(['empty-map', 'empty-descriptor', 'self', 'below-target', 'disabled'])( + 'uses the raw real file when %s makes no rewrite', + (kind) => { + const { root, entry, file, installed } = fixture + file('node_modules/provider/package.json', '{"main":"index.js"}') + const request = kind === 'below-target' ? 'provider/child' : 'provider' + const chosen = file( + kind === 'below-target' + ? 'node_modules/provider/child.js' + : 'node_modules/provider/index.js', + ) + file('src/main.ts', `import '${request}'`) + const alias = + kind === 'empty-map' + ? { provider: [] } + : kind === 'disabled' + ? false + : [ + { + name: 'provider', + alias: kind === 'empty-descriptor' ? [] : 'provider', + }, + ] + expect(installed(request, { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })(request, entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + include: ['provider'], + }).files, + ).toEqual([entry, chosen].sort()) + }, +) + +it.each(['provider$', 'provider$/child'])( + 'keeps a literal dollar prefix when onlyModule=false and request=%s', + (request) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', `import '${request}'`) + const alias = [ + { name: 'provider$', alias: false, onlyModule: false }, + ] as const + expect(installed(request, { alias })).toBe(false) + expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual({ + ignored: true, + }) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry]) + }, +) + +it.each(['provider', 'provider/child'])( + 'matches onlyModule=true like map trailing dollar for %s', + (request) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', `import '${request}'`) + const chosen = file('target/child.js') + const alias = [ + { name: 'provider', alias: false, onlyModule: true }, + { name: 'provider', alias: dirname(chosen) }, + ] as const + const map = { provider$: false, provider: dirname(chosen) } as const + const expected = + request === 'provider' + ? { ignored: true } + : { path: chosen, code: 'export {}' } + expect(installed(request, { alias })).toBe( + request === 'provider' ? false : chosen, + ) + expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual( + expected, + ) + expect( + createModuleResolver({ cwd: root, alias: map })(request, entry), + ).toEqual(expected) + }, +) + +it('re-enters descriptors through finite chains to an ignored module', () => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + const alias = [ + { name: 'provider', alias: ['missing', 'empty'] }, + { name: 'empty', alias: false }, + ] as const + expect(installed('provider', { alias })).toBe(false) + expect(createModuleResolver({ cwd: root, alias })('provider', entry)).toEqual( + { ignored: true }, + ) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry]) +}) + +it.each([true, false])( + 'uses reached false candidate semantics while native Factory rejects mixed arrays; first false=%s', + (first) => { + const { root, entry, file, installed } = fixture + const chosen = file('chosen.js') + const alias = [ + { + name: 'provider', + alias: first ? [false, chosen] : [join(root, 'missing'), false], + }, + ] as const + expect(() => installed('provider', { alias })).toThrow(TypeError) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry), + ).toEqual({ ignored: true }) + }, +) + +it.each([false, true])( + 'reports unsupported wildcard alias names instead of dropping their native form; descriptor=%s', + (descriptor) => { + const { root, entry } = fixture + const alias = descriptor + ? ([{ name: 'provider/*', alias: false }] as const) + : ({ 'provider/*': false } as const) + expect(() => + createModuleResolver({ cwd: root, alias })('provider/child', entry), + ).toThrow(`${entry}:1:1: Module alias provider/* cannot use wildcard names`) + }, +) diff --git a/packages/plugin-utils/src/__tests__/alias-directory.test.ts b/packages/plugin-utils/src/__tests__/alias-directory.test.ts new file mode 100644 index 000000000..90cedd7d8 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-directory.test.ts @@ -0,0 +1,219 @@ +import { readFileSync, realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { ConfigLoadError } from '../load-config' +import { aliasFixture } from './alias-array-fixture' + +let fixture: ReturnType +beforeEach(() => { + fixture = aliasFixture() +}) +afterEach(() => fixture.dispose()) + +it.each([ + { conditions: ['import'] }, + { conditions: ['require'] }, + { conditions: [] }, +])( + 'reads the installed Next SWC absolute directory module regardless of conditions=%j', + ({ conditions }) => { + // Given the actual helper installation resolved from this benchmark's Next. + const { root, entry, file, installed } = fixture + const next = createRequire( + resolve('benchmark/next-devup-ui/package.json'), + ).resolve('next/package.json') + const helpers = dirname( + createRequire(next).resolve('@swc/helpers/package.json'), + ) + const request = '@swc/helpers/_/_interop_require_wildcard' + const alias = { '@swc/helpers/_': join(helpers, '_') } + const expected = realpathSync( + join(helpers, 'esm/_interop_require_wildcard.js'), + ) + file('src/main.ts', `import '${request}'`) + expect( + installed(request, { alias, conditions, mainFields: ['module', 'main'] }), + ).toBe(expected) + expect( + installed(request, { alias, conditions, mainFields: ['main'] }), + ).toBe(realpathSync(join(helpers, 'cjs/_interop_require_wildcard.cjs'))) + // When the shared resolver and graph follow the same real directory alias. + const selected = createModuleResolver({ cwd: root, alias, conditions })( + request, + entry, + ) + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + conditions, + include: ['@swc/helpers'], + }) + // Then both match native module-first resolution and the installed code. + expect(selected).toEqual({ + path: expected, + code: readFileSync(expected, 'utf-8'), + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([expected])) + expect(graph.files).toEqual([entry, expected].sort()) + }, +) + +it.each(['absolute', 'relative'])( + 'uses directory main fields before index and ignores exports when alias is %s', + (kind) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + file( + 'target/package.json', + JSON.stringify({ + module: './module.js', + main: './main.js', + exports: { import: './export.js', require: './absent.js' }, + }), + ) + const chosen = file('target/module.js', "import './leaf'") + file('target/main.js') + file('target/index.js') + file('target/export.js') + const leaf = file('target/leaf.js') + const alias = { + provider: kind === 'absolute' ? join(root, 'target') : '../target', + } + expect( + installed('provider', { alias, mainFields: ['module', 'main'] }), + ).toBe(chosen) + const selected = createModuleResolver({ cwd: root, alias })( + 'provider', + entry, + ) + const graph = buildStaticImportGraph('src', undefined, { cwd: root, alias }) + expect(selected).toEqual({ path: chosen, code: "import './leaf'" }) + expect(graph.files).toEqual([entry, chosen, leaf].sort()) + }, +) + +it.each(['main', 'index', 'self', 'nested', 'extension', 'file'])( + 'matches native directory entry fallback when selected target is %s', + (kind) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + file( + 'target/package.json', + JSON.stringify({ + module: + kind === 'self' ? '.' : kind === 'nested' ? './child' : './absent', + main: kind === 'index' || kind === 'self' ? './absent-main' : './main', + }), + ) + const chosen = file( + kind === 'file' + ? 'target.js' + : kind === 'nested' + ? 'target/child/index.js' + : kind === 'index' || kind === 'self' + ? 'target/index.js' + : 'target/main.js', + 'export const selected = 42', + ) + const alias = { provider: join(root, 'target') } + expect( + installed('provider', { alias, mainFields: ['module', 'main'] }), + ).toBe(chosen) + const selected = createModuleResolver({ cwd: root, alias })( + 'provider', + entry, + ) + expect(selected).toEqual({ + path: chosen, + code: 'export const selected = 42', + }) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, chosen].sort()) + }, +) + +it.each(['{', '[]', 'null'])( + 'keeps malformed directory manifests fatal before a later false (%s)', + (content) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + const manifest = file('target/package.json', content) + file('target/index.js') + const target = join(root, 'target') + if (content === '[]') { + expect(installed('provider', { alias: { provider: target } })).toBe( + join(target, 'index.js'), + ) + } else { + expect(() => + installed('provider', { alias: { provider: target } }), + ).toThrow() + } + const alias = { provider: [target, false] } + expect(() => + createModuleResolver({ cwd: root, alias })('provider', entry), + ).toThrow(ConfigLoadError) + expect(() => + buildStaticImportGraph('src', undefined, { cwd: root, alias }), + ).toThrow(`${manifest}:1:1: Cannot load configuration`) + }, +) + +it('keeps directory entry cycles fatal and located instead of reaching false', () => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + const manifest = file('target/package.json', '{"module":"./child"}') + file('target/child/package.json', '{"module":".."}') + const target = join(root, 'target') + expect(() => + installed('provider', { + alias: { provider: target }, + mainFields: ['module', 'main'], + }), + ).toThrow() + const alias = { provider: [target, false] } + expect(() => + createModuleResolver({ cwd: root, alias })('provider', entry), + ).toThrow(`${manifest}:1:1:`) + expect(() => + buildStaticImportGraph('src', undefined, { cwd: root, alias }), + ).toThrow(`${manifest}:1:1:`) +}) + +it.each([false, true])( + 'skips excluded poisoned directory manifests before IO (all excluded=%s)', + (all) => { + const { root, entry, file } = fixture + file('src/main.ts', "import 'provider'") + const poison = dirname(file('poison/package.json', '{')) + const chosen = file('target/entry.js') + const target = dirname(file('target/package.json', '{"main":"entry.js"}')) + const alias = { provider: [poison, target] } + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + exclude: all ? [poison, target] : [poison], + }) + expect(graph.files).toEqual((all ? [entry] : [entry, chosen]).sort()) + expect(graph.externalImports?.get(entry)).toEqual(new Set()) + }, +) + +it('excludes the selected main directory before reading its poisoned manifest', () => { + const { root, entry, file } = fixture + file('src/main.ts', "import 'provider'") + const target = dirname(file('target/package.json', '{"module":"../poison"}')) + const poison = dirname(file('poison/package.json', '{')) + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: target }, + exclude: [poison], + }) + expect(graph.files).toEqual([entry]) + expect(graph.externalImports?.get(entry)).toEqual(new Set()) +}) diff --git a/packages/plugin-utils/src/__tests__/compiled-guard.test.ts b/packages/plugin-utils/src/__tests__/compiled-guard.test.ts new file mode 100644 index 000000000..822453a72 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/compiled-guard.test.ts @@ -0,0 +1,187 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { + createCompileTimeClassifier, + isCompileTimeAlias, + UntransformedSourceError, +} from '../compiled-guard' +import { createDependencyGuard } from '../dependency-guard' +import { + isMdxSource, + mdxSourceFilter, + normalizeMdxExtensions, + selectedSourceFilter, +} from '../mdx-selection' + +it('normalizes literal selection and preserves exact source filtering', () => { + const extensions = normalizeMdxExtensions(['.MDOWN', '.mdx', '.mdown']) + expect(extensions).toEqual(['.mdown', '.mdx']) + expect(isMdxSource('page.MDOWN?query', extensions)).toBe(true) + expect(mdxSourceFilter(extensions).test('page.md')).toBe(false) + expect(selectedSourceFilter(extensions).test('page.mts')).toBe(true) + expect(mdxSourceFilter([]).test('page.mdx')).toBe(false) + expect(normalizeMdxExtensions()).toEqual(['.mdx']) + expect(() => normalizeMdxExtensions(['*.mdx'])).toThrow('mdxExtensions:1:1') +}) + +it('offers alias disabling only for an active compile-time compatibility alias', () => { + const reference = { + request: '@devup-ui/react', + ids: ['Box'], + line: 1, + column: 1, + } + expect( + new UntransformedSourceError('/page.mdown', reference).message, + ).not.toContain('importAliases') + expect( + new UntransformedSourceError( + '/page.mdown', + { ...reference, request: '@emotion/styled' }, + true, + ).message, + ).toContain('importAliases["@emotion/styled"]=false') +}) + +it('classifies only actual public surfaces instead of names or directory membership', () => { + const root = mkdtempSync(join(tmpdir(), 'devup-surface-')) + mkdirSync(join(root, 'dist/compat'), { recursive: true }) + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: '@devup-ui/react', + exports: { + '.': { types: './dist/index.d.ts', import: './dist/index.js' }, + './compat': './dist/compat/index.js', + './stylex': './dist/stylex.cjs', + './types': {}, + }, + }), + ) + try { + const classify = createCompileTimeClassifier('@devup-ui/react') + expect(classify(join(root, 'dist/index.js'), ['Box'])).toBe(true) + expect(classify(join(root, 'dist/index.js'), ['getTheme'])).toBe(false) + expect(classify(join(root, 'dist/index.js'), ['stylex', 'create'])).toBe( + true, + ) + expect(classify(join(root, 'dist/index.js'), ['stylex'])).toBe(false) + expect(classify(join(root, 'dist/compat/index.js'), ['Global'])).toBe(true) + expect( + classify(join(root, 'dist/compat/index.js'), ['ThemeProvider']), + ).toBe(false) + expect(classify(join(root, 'dist/stylex.cjs'), ['props'])).toBe(true) + expect(classify(join(root, 'dist/ordinary.js'), ['Box'])).toBe(false) + expect(classify(join(tmpdir(), 'Box.js'), ['Box'])).toBe(false) + mkdirSync(join(root, 'stylex')) + writeFileSync( + join(root, 'stylex/package.json'), + JSON.stringify({ + name: '@stylexjs/stylex', + exports: { '.': './index.js' }, + }), + ) + expect(classify(join(root, 'stylex/index.js'), ['default', 'create'])).toBe( + true, + ) + expect(classify(join(root, 'stylex/index.js'), ['default'])).toBe(false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it.each([ + ['@emotion/styled', ['default'], { '@emotion/styled': 'styled' }, true], + ['@emotion/react', ['CacheProvider'], { '@emotion/react': null }, false], + [ + 'styled-components', + ['ThemeProvider'], + { 'styled-components': 'styled' }, + false, + ], + ['@vanilla-extract/css', ['style'], { '@vanilla-extract/css': null }, true], + ['custom', ['Box'], { custom: null }, true], + ['custom', [], { custom: 'styled' }, false], + ['custom', ['default'], { custom: null }, false], + ['disabled', ['css'], {}, false], + ['@stylexjs/stylex', ['default', 'create'], {}, false], +])( + 'uses extractor-compatible alias mapping for %s/%s', + (request, ids, aliases, expected) => { + expect( + isCompileTimeAlias({ request, ids, line: 1, column: 1 }, aliases), + ).toBe(expected) + }, +) + +it('guards used dependency IDs without guarding unused imports, opaque namespaces or ordinary JS', () => { + const check = createDependencyGuard({ + package: '@devup-ui/react', + mdxExtensions: ['.mdx'], + importAliases: { alias: 'styled' }, + }) + const dependency = { + type: 'esm import specifier', + request: 'alias', + ids: ['default'], + loc: { start: { line: 3, column: 4 } }, + } + const modules = [ + { + type: 'javascript/auto', + resource: '/page.mdown', + dependencies: [ + dependency, + { ...dependency, ids: [] }, + { ...dependency, type: 'esm import' }, + null, + ], + }, + { + type: 'javascript/auto', + resource: '/node_modules/skipped.js', + dependencies: [dependency], + }, + { + type: 'asset/source', + resource: '/asset.blob', + dependencies: [dependency], + }, + { type: 'javascript/auto', dependencies: [dependency] }, + ] + const errors = check(modules, { graph: {}, target: () => undefined }) + expect(errors).toHaveLength(1) + expect(errors[0]?.message).toContain('/page.mdown:3:5:') +}) + +it('consumes public getIds and require-member names but ignores untyped or opaque IDs', () => { + const check = createDependencyGuard({ + package: '@devup-ui/react', + mdxExtensions: [], + importAliases: { alias: null }, + }) + const module = { + type: 'javascript/auto', + resource: '/page.unknown', + dependencies: [ + { + type: 'harmony import specifier', + request: 'alias', + getIds: () => ['css'], + }, + { type: 'cjs full require', request: 'alias', names: ['css'] }, + { type: 'cjs full require', request: 'alias', names: [123] }, + ], + } + expect( + check([module], { + graph: {}, + target: () => undefined, + forwarded: () => false, + }), + ).toHaveLength(2) +}) diff --git a/packages/plugin-utils/src/__tests__/config-errors.test.ts b/packages/plugin-utils/src/__tests__/config-errors.test.ts new file mode 100644 index 000000000..622aa2c1c --- /dev/null +++ b/packages/plugin-utils/src/__tests__/config-errors.test.ts @@ -0,0 +1,100 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { + ConfigLoadError, + loadDevupConfig, + loadDevupConfigSync, +} from '../load-config' + +const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared', +) +let root: string +beforeEach(() => { + mkdirSync(parent, { recursive: true }) + root = realpathSync(mkdtempSync(join(parent, 'config-'))) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +describe('located bounded config errors', () => { + it.each(['{', 'null', '[]', '{"extends":"base.json"}', '{"extends":[1]}'])( + 'rejects invalid config %s in both loaders', + async (content) => { + const file = join(root, 'devup.json') + writeFileSync(file, content) + expect(() => loadDevupConfigSync(file)).toThrow(ConfigLoadError) + await expect(loadDevupConfig(file)).rejects.toThrow(file) + }, + ) + it('reports a missing parent rather than silently clearing its theme', async () => { + const file = join(root, 'devup.json') + const missing = join(root, 'missing.json') + writeFileSync(file, JSON.stringify({ extends: ['./missing.json'] })) + expect(() => loadDevupConfigSync(file)).toThrow(missing) + await expect(loadDevupConfig(file)).rejects.toThrow(missing) + }) + it('reports directory read errors with the file and original cause', async () => { + try { + loadDevupConfigSync(root) + throw new Error('Expected a read failure') + } catch (error) { + expect(error).toBeInstanceOf(ConfigLoadError) + if (!(error instanceof ConfigLoadError)) throw error + expect(error.file).toBe(root) + expect(error.cause).toBeInstanceOf(Error) + } + await expect(loadDevupConfig(root)).rejects.toThrow(root) + }) + it('detects canonical inheritance cycles in both loaders', async () => { + const file = join(root, 'a.json') + writeFileSync(file, JSON.stringify({ extends: ['./b.json'] })) + writeFileSync( + join(root, 'b.json'), + JSON.stringify({ extends: ['./nested/../a.json'] }), + ) + mkdirSync(join(root, 'nested')) + expect(() => loadDevupConfigSync(file)).toThrow('a.json ->') + await expect(loadDevupConfig(file)).rejects.toThrow('a.json ->') + }) + it('allows repeated parents across separate branches of a diamond', async () => { + writeFileSync( + join(root, 'base.json'), + JSON.stringify({ theme: { colors: { default: { text: 'red' } } } }), + ) + writeFileSync( + join(root, 'left.json'), + JSON.stringify({ extends: ['./base.json'] }), + ) + writeFileSync( + join(root, 'right.json'), + JSON.stringify({ extends: ['./base.json'] }), + ) + const file = join(root, 'devup.json') + writeFileSync( + file, + JSON.stringify({ extends: ['./left.json', './right.json'] }), + ) + const expected = { theme: { colors: { default: { text: 'red' } } } } + expect(loadDevupConfigSync(file)).toEqual(expected) + expect(await loadDevupConfig(file)).toEqual(expected) + }) + it('returns an empty config for a missing top file in both loaders', async () => { + const file = join(root, 'missing.json') + expect(loadDevupConfigSync(file)).toEqual({}) + expect(await loadDevupConfig(file)).toEqual({}) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/directory-exclusion.test.ts b/packages/plugin-utils/src/__tests__/directory-exclusion.test.ts new file mode 100644 index 000000000..604a38c3d --- /dev/null +++ b/packages/plugin-utils/src/__tests__/directory-exclusion.test.ts @@ -0,0 +1,233 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, expect, it, spyOn } from 'bun:test' + +import { createDirectoryExclusion } from '../directory-exclusion' +import { buildStaticImportGraph, listSourceFiles } from '../import-graph' +import { collectNumberedFiles } from '../numbering' + +let root: string +beforeEach(() => { + const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared-api', + ) + fs.mkdirSync(parent, { recursive: true }) + root = fs.realpathSync.native(fs.mkdtempSync(join(parent, 'exclude-'))) +}) +afterEach(() => fs.rmSync(root, { recursive: true, force: true })) +function file(path: string, source = 'export {}'): string { + const target = join(root, path) + fs.mkdirSync(dirname(target), { recursive: true }) + fs.writeFileSync(target, source) + return target +} + +it('matches bare names at every depth and absolute directories only within their boundary', () => { + const match = createDirectoryExclusion( + ['generated', '/project/build/next'], + 'linux', + ) + expect(match('/project/src/nested/generated')).toBe(true) + expect(match('/project/build/next/deep')).toBe(true) + expect(match('/project/other/next')).toBe(false) + expect(match('/project/build/next-other')).toBe(false) + expect(match('/project/Generated')).toBe(false) +}) + +it('normalizes Windows separators, drive case and directory case but retains boundaries', () => { + const match = createDirectoryExclusion( + ['Generated', 'C:/Project/build/Next/'], + 'win32', + ) + expect(match('c:\\PROJECT\\BUILD\\next\\deep')).toBe(true) + expect(match('D:/project/deep/GENERATED')).toBe(true) + expect(match('C:/Project/other/Next')).toBe(false) + expect(match('c:/project/build/next-other')).toBe(false) + expect(createDirectoryExclusion(['C:/'], 'win32')('c:/project')).toBe(true) +}) + +it('keeps a same-basename source folder while never reading, parsing, preparing or numbering excluded source', async () => { + const entry = file( + 'src/main.ts', + "import '../build/next/poison'\nimport './next/value'\nimport './nested/generated/poison'\nimport 'provider'\nimport 'included'", + ) + const kept = file('src/next/value.ts') + const poison = file('build/next/poison.ts', "import 'invalid-manifest'") + file('src/nested/generated/poison.ts', "import 'invalid-manifest'") + file('node_modules/invalid-manifest/package.json', '{') + file('node_modules/included/package.json', '{"main":"index.js"}') + file('node_modules/included/index.js', "import 'invalid-manifest'") + const excluded = [ + dirname(poison), + 'generated', + join(root, 'node_modules/included'), + ] + const originalRead = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(target, thisArg, args) { + if ( + typeof args[0] === 'string' && + /poison|included[/\\]index/.test(args[0]) + ) + throw new Error('Excluded source read') + return Reflect.apply(target, thisArg, args) + }, + }), + ) + try { + const options = { + cwd: root, + exclude: excluded, + alias: { provider: poison }, + include: ['included'], + } + const raw = buildStaticImportGraph('src', undefined, options) + const visits: string[] = [] + const prepared = await buildStaticImportGraph('src', undefined, { + ...options, + prepareSource: (filename) => { + visits.push(filename) + return undefined + }, + }) + expect(raw.files).toEqual([entry, kept].sort()) + expect(prepared.files).toEqual(raw.files) + expect(visits.sort()).toEqual(raw.files) + expect( + collectNumberedFiles({ + roots: [join(root, 'src'), dirname(poison)], + include: ['included'], + cwd: root, + exclude: excluded, + needles: ['export'], + toId: (path) => path, + }), + ).toEqual([kept]) + expect(listSourceFiles(dirname(poison), excluded)).toEqual([]) + } finally { + read.mockRestore() + } +}) + +it('uses native Windows absolute-path normalization in a real scanner fixture', () => { + const excluded = file('build/next/poison.ts') + const kept = file('src/next/value.ts') + const path = dirname(excluded) + const exclude = + process.platform === 'win32' + ? path.toUpperCase().replaceAll('\\', '/') + : path + expect(listSourceFiles(root, [exclude])).toEqual([kept]) +}) + +it.each([ + 'package', + 'package-alias', + 'package-subpath', + 'package-subpath-alias', + 'absolute', + 'absolute-alias', +])( + 'never reads a poisoned excluded manifest or source when resolving %s', + async (kind) => { + // Given an included package whose excluded manifest cannot be parsed. + const manifest = file('node_modules/excluded/package.json', '{') + const subpath = kind.includes('subpath') + const poison = file( + `node_modules/excluded/${subpath ? 'blocked/' : ''}index.ts`, + "import 'invalid'", + ) + const request = kind.startsWith('absolute') + ? poison.replaceAll('\\', '/') + : `excluded${subpath ? '/blocked/index' : ''}` + const aliased = kind.endsWith('alias') + const entry = file( + 'src/main.ts', + `import '${aliased ? 'provider' : request}'`, + ) + const excluded = subpath ? dirname(poison) : dirname(manifest) + const alias: Readonly> = aliased + ? { provider: request } + : {} + const options = { + cwd: root, + include: ['excluded'], + exclude: [excluded], + alias, + } + const raw = buildStaticImportGraph(['src', excluded], undefined, options) + const match = createDirectoryExclusion([excluded]) + const originalRead = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(target, thisArg, args) { + if (typeof args[0] === 'string' && match(dirname(args[0]))) + throw new Error('Excluded manifest/source read') + return Reflect.apply(target, thisArg, args) + }, + }), + ) + try { + // When raw/prepared graphs and numbering follow the same excluded edge/root. + const visits: string[] = [] + const prepared = await buildStaticImportGraph( + ['src', excluded], + undefined, + { + ...options, + prepareSource: (filename) => { + visits.push(filename) + return undefined + }, + }, + ) + const numbered = collectNumberedFiles({ + ...options, + roots: [join(root, 'src'), excluded], + needles: ['import'], + toId: (path) => path, + }) + // Then no excluded source is visited, prewarmed or needle-read. + expect(raw.files).toEqual([entry]) + expect(prepared.files).toEqual([entry]) + expect(visits).toEqual([entry]) + expect(raw.externalImports?.get(entry)).toEqual(new Set()) + expect(prepared.externalImports?.get(entry)).toEqual(new Set()) + expect(numbered).toEqual([entry]) + } finally { + read.mockRestore() + } + }, +) + +it.each(['paths', 'baseUrl'])( + 'keeps excluded %s candidates terminal rather than prewarming a package fallback', + (kind) => { + const poison = file('blocked/index.ts') + const entry = file('src/main.ts', "import 'blocked'") + file('node_modules/blocked/package.json', '{') + file( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: + kind === 'paths' + ? { paths: { blocked: ['./blocked/index.ts'] } } + : { baseUrl: '.' }, + }), + ) + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + include: ['blocked'], + exclude: [dirname(poison)], + }) + expect(graph.files).toEqual([entry]) + expect(graph.externalImports?.get(entry)).toEqual(new Set()) + }, +) diff --git a/packages/plugin-utils/src/__tests__/graph-exclude.test.ts b/packages/plugin-utils/src/__tests__/graph-exclude.test.ts new file mode 100644 index 000000000..329095f72 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/graph-exclude.test.ts @@ -0,0 +1,83 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, listSourceFiles } from '../import-graph' +import { + GRAPH_SOURCE_FILE_RE, + MDX_FILE_RE, + POST_COMPILED_MDX_RE, + SOURCE_FILE_RE, +} from '../shared' + +const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'mdx-shared', +) +let root: string +beforeEach(() => { + mkdirSync(parent, { recursive: true }) + root = realpathSync(mkdtempSync(join(parent, 'exclude-'))) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +it('skips named directories at every depth without reintroducing imported files', () => { + const fixtures = { + 'src/z.ts': + "import './generated/a'\nimport('./nested/generated/b')\nimport './node_modules/local/c'\nimport './nested/A.MDX'", + 'src/generated/a.ts': 'a', + 'src/nested/generated/b.ts': 'b', + 'src/node_modules/local/c.ts': 'c', + 'src/nested/A.MDX': "import '../value.CTS'\n\n# Heading", + 'src/value.CTS': 'value', + 'src/nested/ignored.TEST.TS': 'test', + 'app/generated/d.ts': 'd', + 'app/a.mjs': 'a', + } + for (const [path, code] of Object.entries(fixtures)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), code) + } + const graph = buildStaticImportGraph(['src', 'app'], undefined, { + includeMdx: true, + cwd: root, + exclude: ['generated'], + }) + expect(graph.files).toEqual( + ['app/a.mjs', 'src/nested/A.MDX', 'src/value.CTS', 'src/z.ts'].map((path) => + join(root, path), + ), + ) + expect(graph.staticImports.get(join(root, 'src/nested/A.MDX'))).toEqual( + new Set([join(root, 'src/value.CTS')]), + ) + expect( + listSourceFiles(join(root, 'src'), ['generated'], { includeMdx: true }), + ).toEqual(graph.files.slice(1)) +}) + +it.each(['.mts', '.cts', '.mjs', '.cjs', '.tsx', '.jsx'])( + 'matches uppercase modern %s extensions without treating compiled MDX as raw MDX', + (extension) => { + expect(SOURCE_FILE_RE.test(`file${extension.toUpperCase()}`)).toBe(true) + expect(GRAPH_SOURCE_FILE_RE.test(`file${extension.toUpperCase()}`)).toBe( + true, + ) + expect( + POST_COMPILED_MDX_RE.test(`file.MDX${extension.toUpperCase()}`), + ).toBe(true) + expect(MDX_FILE_RE.test(`file.MDX${extension}`)).toBe(false) + expect(MDX_FILE_RE.test('file.MDX')).toBe(true) + }, +) diff --git a/packages/plugin-utils/src/__tests__/graph-modern.test.ts b/packages/plugin-utils/src/__tests__/graph-modern.test.ts new file mode 100644 index 000000000..ed9c8ac49 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/graph-modern.test.ts @@ -0,0 +1,416 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { + buildCanonicalMap, + buildStaticImportGraph, + computeCompiledFiles, + computeFileReach, + computeFileRoutes, + computeReachableFiles, + createModuleResolver, +} from '../import-graph' +import { SOURCE_EXTENSIONS } from '../shared' + +const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared', +) +let root: string +beforeEach(() => { + mkdirSync(parent, { recursive: true }) + root = realpathSync(mkdtempSync(join(parent, 'graph-'))) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +function fixture(path: string, source: string): string { + const file = join(root, path) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, source) + return file +} + +describe('multi-root source graph', () => { + it('resolves dotted specifiers and directories without replacing real asset extensions', () => { + const entry = fixture( + 'src/main.ts', + "import './a.generated'\nimport './b.config'\nimport './folder.with.dot'\nimport './c.css'", + ) + const generated = fixture( + 'src/a.generated.ts', + 'export const generated = 1', + ) + const config = fixture('src/b.config.tsx', 'export const config = 1') + const index = fixture( + 'src/folder.with.dot/index.ts', + 'export const index = 1', + ) + const css = fixture('src/c.css', 'body{color:red}') + fixture('src/c.css.ts', 'export const competing = 1') + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('./a.generated', entry)?.path).toBe(generated) + expect(resolver('./b.config', entry)?.path).toBe(config) + expect(resolver('./folder.with.dot', entry)?.path).toBe(index) + expect(resolver('./c.css', entry)?.path).toBe(css) + const graph = buildStaticImportGraph(join(root, 'src'), undefined, { + cwd: root, + }) + expect(graph.staticImports.get(entry)).toEqual( + new Set([generated, config, index]), + ) + expect(graph.files).not.toContain(css) + expect( + computeReachableFiles({ + srcDir: join(root, 'src'), + graph, + entries: [join(root, 'src/a.generated')], + }), + ).toEqual([generated]) + }) + + it('follows only eligible package closures when an entry uses the project root layout', () => { + const entry = fixture('entry.ts', "import 'included'\nimport 'excluded'") + fixture( + 'node_modules/included/package.json', + JSON.stringify({ main: 'index.js' }), + ) + const included = fixture( + 'node_modules/included/index.js', + "import './leaf.cjs'", + ) + const leaf = fixture( + 'node_modules/included/leaf.cjs', + 'export const value = 1', + ) + fixture( + 'node_modules/excluded/package.json', + JSON.stringify({ main: 'index.js' }), + ) + fixture('node_modules/excluded/index.js', 'export const value = 2') + const graph = buildStaticImportGraph(root, undefined, { + cwd: root, + include: ['included'], + }) + expect(graph.files).toEqual([entry, included, leaf].sort()) + expect(graph.staticImports.get(entry)).toEqual(new Set([included])) + expect(graph.externalImports?.get(entry)).toEqual(new Set(['excluded'])) + }) + + it('shares one graph across reach, route, compiled and collapse consumers', () => { + const entry = fixture( + 'app/page.mts', + "import '../src/value.cts'\nimport('included')", + ) + const value = fixture('src/value.cts', 'export const value = 1') + fixture( + 'node_modules/included/package.json', + JSON.stringify({ + exports: { browser: './browser.mjs', import: './server.js' }, + }), + ) + const library = fixture( + 'node_modules/included/browser.mjs', + "export { value } from './leaf.cjs'\nimport('other')", + ) + const leaf = fixture( + 'node_modules/included/leaf.cjs', + 'export const value = 2', + ) + fixture('node_modules/included/unreachable.ts', 'unused') + fixture('node_modules/included/server.js', 'server') + fixture( + 'node_modules/other/package.json', + JSON.stringify({ main: 'index.js' }), + ) + const other = fixture('node_modules/other/index.js', 'other') + const srcDir = ['src', 'app'] + const graph = buildStaticImportGraph(srcDir, undefined, { + cwd: root, + include: ['included'], + conditions: ['browser', 'import'], + }) + expect(graph.files).toEqual([entry, library, leaf, value].sort()) + expect(graph.externalImports?.get(library)).toEqual(new Set(['other'])) + expect(graph.fileSet.has(other)).toBe(false) + expect(computeReachableFiles({ srcDir, graph, entries: [entry] })).toEqual( + graph.files, + ) + const opts = { cwd: root, srcDir, graph } + expect(buildCanonicalMap(opts)['src/value.cts']).toBe('app/page.mts') + expect( + computeFileReach({ ...opts, entries: ['app/page.mts'] })['src/value.cts'], + ).toEqual([0]) + expect(computeFileRoutes(opts)['src/value.cts']).toEqual([0]) + expect(computeCompiledFiles(opts)).toEqual( + graph.files + .map((file) => file.slice(root.length + 1).replaceAll('\\', '/')) + .sort(), + ) + }) + it('follows reachable static included dependencies without enumerating their package', () => { + const entry = fixture('src/main.ts', "import '@scope/pkg'") + fixture( + 'node_modules/@scope/pkg/package.json', + JSON.stringify({ main: 'index.ts' }), + ) + const library = fixture( + 'node_modules/@scope/pkg/index.ts', + "import './nested/a'\nimport('included-two')", + ) + const nested = fixture( + 'node_modules/@scope/pkg/nested/a.ts', + 'export const a = 1', + ) + fixture( + 'node_modules/included-two/package.json', + JSON.stringify({ main: 'index.ts' }), + ) + const second = fixture( + 'node_modules/included-two/index.ts', + 'export const second = 1', + ) + fixture('node_modules/@scope/pkg/orphan.ts', 'orphan') + const graph = buildStaticImportGraph(join(root, 'src'), undefined, { + cwd: root, + include: ['@scope/pkg', 'included-two'], + }) + expect( + computeReachableFiles({ + srcDir: join(root, 'src'), + graph, + entries: [entry], + }), + ).toEqual([library, nested, second, entry].sort()) + expect( + buildCanonicalMap({ cwd: root, srcDir: join(root, 'src'), graph })[ + 'node_modules/@scope/pkg/nested/a.ts' + ], + ).toBe('src/main.ts') + }) + it.each(SOURCE_EXTENSIONS)( + 'discovers and resolves %s modules and index files', + (extension) => { + const entry = fixture( + `src/main${extension}`, + "import './value'\nimport './folder'", + ) + const value = fixture(`src/value${extension}`, 'export const value = 1') + const index = fixture( + `src/folder/index${extension}`, + 'export const index = 1', + ) + const graph = buildStaticImportGraph(join(root, 'src')) + expect(graph.staticImports.get(entry)).toEqual(new Set([value, index])) + const resolver = createModuleResolver({ cwd: root }) + expect(resolver(`./value${extension}`, entry)?.path).toBe(value) + }, + ) +}) + +describe('MDX ESM discovery', () => { + it.each(['src', '.'])( + 'excludes raw MDX roots and direct edges by default under %s', + (layout) => { + const entry = fixture( + 'src/main.ts', + "import './page.mdx'\nimport('./lazy.mdx')", + ) + const page = fixture( + 'src/page.mdx', + "import '../outside/provider.js'\n\n# Raw markdown", + ) + fixture('src/lazy.mdx', '# Lazy markdown') + fixture('outside/provider.js', 'export const value = 1') + const graph = buildStaticImportGraph(join(root, layout), undefined, { + cwd: root, + }) + expect(graph.files.filter((file) => file.endsWith('.mdx'))).toEqual([]) + expect(graph.staticImports.get(entry)).toEqual(new Set()) + expect(graph.dynamicImports.get(entry)).toEqual(new Set()) + expect(() => + createModuleResolver({ cwd: root })('./page.mdx', entry), + ).toThrow(page) + }, + ) + + it.each([false, true])( + 'follows included JS and MDX provider closures only with MDX opt-in %s', + (includeMdx) => { + const entry = fixture( + 'src/main.ts', + "import 'included'\nimport 'mdx-entry'", + ) + fixture( + 'node_modules/included/package.json', + JSON.stringify({ main: 'index.js' }), + ) + const library = fixture( + 'node_modules/included/index.js', + "import './page.mdx'", + ) + const page = fixture( + 'node_modules/included/page.mdx', + "import './provider.js'\n\n# Page", + ) + const provider = fixture( + 'node_modules/included/provider.js', + 'export const value = 1', + ) + fixture( + 'node_modules/mdx-entry/package.json', + JSON.stringify({ main: 'index.mdx' }), + ) + const mdxEntry = fixture( + 'node_modules/mdx-entry/index.mdx', + "import './provider.js'\n\n# Entry", + ) + const secondProvider = fixture( + 'node_modules/mdx-entry/provider.js', + 'export const value = 2', + ) + const graph = buildStaticImportGraph(join(root, 'src'), undefined, { + cwd: root, + include: ['included', 'mdx-entry'], + includeMdx, + }) + expect(graph.files).toEqual( + (includeMdx + ? [entry, library, page, provider, mdxEntry, secondProvider] + : [entry, library] + ).sort(), + ) + expect(graph.staticImports.get(library)).toEqual( + new Set(includeMdx ? [page] : []), + ) + }, + ) + + it('discovers real ESM blocks while ignoring markdown, code fences, indented code and comments', () => { + const entry = fixture( + 'app/page.mdx', + [ + '# Heading', + '', + "Prose about import './fake'", + '', + '```tsx', + "import './fake'", + '```', + '', + '~~~js', + "import './fake'", + '~~~', + '', + " import './fake'", + '', + '', + '', + '', + '', + 'import {', + ' value', + "} from '../src/value.mts'", + '', + "export { other } from '../src/other.cts'", + '', + "export const load = () => import('../src/lazy.cjs')", + '', + '
Content
', + ].join('\n'), + ) + const value = fixture('src/value.mts', 'export const value = 1') + const other = fixture('src/other.cts', 'export const other = 1') + const lazy = fixture('src/lazy.cjs', 'export const lazy = 1') + fixture('app/fake.ts', 'fake') + const srcDir = [join(root, 'app'), join(root, 'src')] + const graph = buildStaticImportGraph(srcDir, undefined, { + cwd: root, + includeMdx: true, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([value, other])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set([lazy])) + expect(computeCompiledFiles({ srcDir, cwd: root, graph })).toEqual([ + 'app/page.mdx', + 'src/lazy.cjs', + 'src/other.cts', + 'src/value.mts', + ]) + }) +}) + +describe('conditional package resolution', () => { + it('propagates invalid module path I/O errors instead of reporting an unresolved import', () => { + fixture('src/main.ts', 'export const main = 1') + expect(() => + createModuleResolver({ cwd: root })('./\0.ts', 'src/main.ts'), + ).toThrow() + }) + it('honors active conditions in declaration order rather than condition input order', () => { + fixture( + 'node_modules/pkg/package.json', + JSON.stringify({ + exports: { + browser: './browser.js', + import: './import.js', + default: './default.js', + }, + }), + ) + fixture('node_modules/pkg/browser.js', 'browser') + fixture('node_modules/pkg/import.js', 'import') + fixture('node_modules/pkg/default.js', 'default') + expect( + createModuleResolver({ cwd: root, conditions: ['import', 'browser'] })( + 'pkg', + 'src/main.ts', + )?.code, + ).toBe('browser') + expect( + createModuleResolver({ cwd: root, conditions: [] })('pkg', 'src/main.ts') + ?.code, + ).toBe('default') + }) + it('honors early default branches and null exclusions', () => { + fixture( + 'node_modules/pkg/package.json', + JSON.stringify({ + exports: { + '.': { default: './default.js', browser: './browser.js' }, + './blocked': { browser: null, default: './default.js' }, + './empty': [], + }, + }), + ) + fixture('node_modules/pkg/default.js', 'default') + const resolver = createModuleResolver({ + cwd: root, + conditions: ['browser'], + }) + expect(resolver('pkg', 'src/main.ts')?.code).toBe('default') + expect(resolver('pkg/blocked', 'src/main.ts')).toBeUndefined() + expect(resolver('pkg/empty', 'src/main.ts')).toBeUndefined() + }) + it.each(['{', 'null', '[]'])( + 'propagates invalid manifest %s with location and cause', + (content) => { + const file = fixture('node_modules/pkg/package.json', content) + expect(() => + createModuleResolver({ cwd: root })('pkg', 'src/main.ts'), + ).toThrow(file) + }, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/ignored-alias.test.ts b/packages/plugin-utils/src/__tests__/ignored-alias.test.ts new file mode 100644 index 000000000..69ab7e858 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/ignored-alias.test.ts @@ -0,0 +1,258 @@ +import { join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { ConfigLoadError } from '../load-config' +import { ModuleAliasPackageError, resolveModuleAlias } from '../module-alias' +import { collectNumberedFiles, seedFileNumbers } from '../numbering' +import { aliasFixture } from './alias-array-fixture' + +let fixture: ReturnType +beforeEach(() => { + fixture = aliasFixture() +}) +afterEach(() => fixture.dispose()) + +it.each(['provider', 'provider/feature'])( + 'ignores scalar false when request is %s', + (request) => { + const { root, entry, installed } = fixture + const alias = { provider: false } as const + expect(installed(request, { alias })).toBe(false) + expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual({ + ignored: true, + }) + }, +) + +it.each([false, true])( + 'selects ordered false candidates when earlier file exists=%s', + (exists) => { + const { root, entry, file } = fixture + const chosen = join(root, 'chosen.js') + if (exists) file('chosen.js') + const resolve = createModuleResolver({ + cwd: root, + alias: { provider: [chosen, false] }, + }) + expect(resolve('provider', entry)).toEqual( + exists ? { path: chosen, code: 'export {}' } : { ignored: true }, + ) + }, +) + +it('stops before later strings when false is first', () => { + const { root, entry, file } = fixture + const chosen = file('chosen.js') + expect( + createModuleResolver({ cwd: root, alias: { provider: [false, chosen] } })( + 'provider', + entry, + ), + ).toEqual({ ignored: true }) +}) + +it('does not claim public factory parity for mixed false arrays', () => { + const { root, installed } = fixture + expect(() => + installed('provider', { + alias: { provider: [join(root, 'missing'), false] }, + }), + ).toThrow(TypeError) +}) + +it('keeps exact false aliases from matching subpaths', () => { + const { root, entry, installed } = fixture + const alias = { provider$: false } as const + expect(installed('provider', { alias })).toBe(false) + expect( + createModuleResolver({ cwd: root, alias })('provider/feature', entry), + ).toBeUndefined() +}) + +it.each(['provider', 'provider/feature'])( + 're-enters scalar false chains for %s', + (request) => { + const { root, entry, installed } = fixture + const alias = { provider: 'nested', nested: 'empty', empty: false } as const + expect(installed(request, { alias })).toBe(false) + expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual({ + ignored: true, + }) + }, +) + +it.each(['provider', 'provider/feature'])( + 'reaches false after equal/below-target guards for %s', + (request) => { + const { root, entry } = fixture + expect( + createModuleResolver({ + cwd: root, + alias: { provider: ['provider', false] }, + })(request, entry), + ).toEqual({ ignored: true }) + }, +) + +it('distinguishes excluded false from ignored success after a missing candidate', () => { + const { entry } = fixture + expect( + resolveModuleAlias('provider', { + importer: entry, + alias: { provider: ['excluded', false] }, + resolveRequest: () => false, + }), + ).toEqual({ ignored: true }) +}) + +it('keeps fatal cycles before a later false candidate', () => { + const { root, entry } = fixture + expect(() => + createModuleResolver({ + cwd: root, + alias: { provider: ['nested', false], nested: 'provider' }, + })('provider', entry), + ).toThrow('Module alias cycle') +}) + +it('reaches false after a complete nested ordinary miss', () => { + const { root, entry } = fixture + expect( + createModuleResolver({ + cwd: root, + alias: { provider: ['nested', false], nested: join(root, 'missing') }, + })('provider', entry), + ).toEqual({ ignored: true }) +}) + +it.each(['exports', 'manifest'])( + 'preserves fatal %s faults before a false candidate', + (kind) => { + const { root, entry, file } = fixture + file( + 'node_modules/broken/package.json', + kind === 'exports' ? '{"exports":{"browser":"./missing.js"}}' : '{', + ) + expect(() => + createModuleResolver({ + cwd: root, + alias: { provider: ['broken', false] }, + })('provider', entry), + ).toThrow(kind === 'exports' ? ModuleAliasPackageError : ConfigLoadError) + }, +) + +it('keeps directory exclusion false separate from ordinary misses', () => { + const { entry } = fixture + expect( + resolveModuleAlias('provider', { + importer: entry, + alias: { provider: ['excluded'] }, + resolveRequest: () => false, + }), + ).toBe(false) + expect(() => + resolveModuleAlias('provider', { + importer: entry, + alias: { provider: ['missing', 'excluded'] }, + resolveRequest: (name) => (name === 'excluded' ? false : undefined), + }), + ).toThrow('cannot resolve candidates') +}) + +it('seeds only physically scanned source files when graph requests are ignored', () => { + const { root, entry, file } = fixture + const token = file('src/token.ts') + file('src/main.ts', "import 'provider';") + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: false }, + }) + const files = collectNumberedFiles({ + roots: [join(root, 'src')], + toId: (path) => path, + }) + const seeded: string[][] = [] + seedFileNumbers({ seedFileMap: (paths) => seeded.push(paths) }, files) + expect(graph.files).toEqual([entry, token].sort()) + expect(seeded).toEqual([[entry, token].sort()]) +}) + +it.each(['absent', 'root', 'src'])( + 'resolves stock-shaped instrumentation chain when file is %s', + (location) => { + const { root, entry, file, installed } = fixture + const request = 'private-next-instrumentation-client-user' + const alias = { + [request]: [ + join(root, 'src/instrumentation-client'), + join(root, 'instrumentation-client'), + 'private-next-empty-module', + ], + 'private-next-empty-module': false, + } as const + const expected = + location === 'absent' + ? undefined + : file(`${location === 'src' ? 'src/' : ''}instrumentation-client.js`) + expect(installed(request, { alias })).toBe(expected ?? false) + expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual( + expected ? { path: expected, code: 'export {}' } : { ignored: true }, + ) + }, +) + +it('retries earlier candidates after a previously ignored resolution', () => { + const { root, entry, file } = fixture + const resolve = createModuleResolver({ + cwd: root, + alias: { provider: [join(root, 'earlier'), 'empty'], empty: false }, + }) + expect(resolve('provider', entry)).toEqual({ ignored: true }) + const path = file('earlier.js') + expect(resolve('provider', entry)).toEqual({ path, code: 'export {}' }) +}) + +it('bypasses preparation and numbering IDs for ignored requests', () => { + const { root, entry } = fixture + const seen: string[] = [] + const resolve = createModuleResolver({ + cwd: root, + alias: { provider: false }, + prepareSource: (path) => { + seen.push(path) + return '' + }, + toId: (path) => { + seen.push(path) + return path + }, + }) + expect(resolve('provider', entry)).toEqual({ ignored: true }) + expect(seen).toEqual([]) +}) + +it.each([false, true])( + 'omits ignored static/dynamic/reexport edges when prepared=%s', + async (prepared) => { + const { root, entry, file } = fixture + file( + 'src/main.ts', + "import 'provider'; import('provider/feature'); export * from 'provider';", + ) + const options = { cwd: root, alias: { provider: false } } as const + const graph = prepared + ? await buildStaticImportGraph('src', undefined, { + ...options, + prepareSource: () => + "import 'provider'; import('provider/feature'); export * from 'provider';", + }) + : buildStaticImportGraph('src', undefined, options) + expect(graph.files).toEqual([entry]) + expect([...(graph.staticImports.get(entry) ?? [])]).toEqual([]) + expect([...(graph.dynamicImports.get(entry) ?? [])]).toEqual([]) + expect([...(graph.externalImports.get(entry) ?? [])]).toEqual([]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/import-mask.test.ts b/packages/plugin-utils/src/__tests__/import-mask.test.ts new file mode 100644 index 000000000..a74082d7a --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-mask.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from 'bun:test' + +import { maskImportText } from '../import-mask' + +it('masks escaped strings, nested templates and comments without consuming subsequent code', () => { + const code = [ + String.raw`const quote = "escaped \" import './fake'";`, + 'const value = `outer ${ {a: "}", b: `nested ${1}`} /* comment */ // comment', + '} import fake`;', + '/* multiline\n import fake */', + '// import fake', + "import './real'", + ].join('\n') + const masked = maskImportText(code, false) + expect(masked).not.toContain('fake') + expect(masked).toContain('import') + expect(masked.length).toBe(code.length) +}) + +it('masks regex quotes, escaped slashes and character classes', () => { + const code = String.raw`const value = /[/'"]import '\/fake'/gi; return /import "fake"/; import './real'` + expect(maskImportText(code, false)).not.toContain('fake') +}) + +it('masks JSX fragments, nesting, attributes and self-closing tags but keeps JavaScript expressions', () => { + const code = + 'const value = <>
import fake { {value: import("real")} }tail
; import "after"' + const masked = maskImportText(code, true) + expect(masked).not.toContain('fake') + expect(masked.match(/\bimport\b/g)).toHaveLength(2) +}) + +it('keeps TypeScript assertions and division as code rather than JSX or regex', () => { + const code = + 'const result = value / 2; const object = {x: 1}; import "real"' + expect(maskImportText(code, false)).toContain('value / 2') +}) + +it('handles JSX attribute expressions', () => { + const masked = maskImportText( + 'const node =
import fake
;', + true, + ) + expect(masked).toContain('import') + expect(masked).not.toContain('fake') +}) + +it('masks regex literals following control parentheses', () => { + expect( + maskImportText( + 'if (true) /import "fake"/.test(value); import "real"', + false, + ), + ).not.toContain('fake') +}) diff --git a/packages/plugin-utils/src/__tests__/import-scanner-boundary.test.ts b/packages/plugin-utils/src/__tests__/import-scanner-boundary.test.ts new file mode 100644 index 000000000..99ffec472 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-scanner-boundary.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' + +it.each( + ['function f() {}', 'class C {}'].flatMap((body) => + [ + 'const x = 1\n', + 'async // marker\n', + 'const x = call()\n', + 'const x = []\n', + ].map((prefix) => ({ + code: `${prefix}${body} /import("phantom")/.test(x); import("./real");`, + })), + ), +)('masks regex after an ASI declaration in $code', ({ code }) => { + // Given: valid syntax, checked without evaluating the fixture. + new Bun.Transpiler({ loader: 'js' }).transformSync(code) + // When: scan through the real lexical boundary. + const actual = scanImports(code, false) + // Then: regex text adds no phantom dependency. + expect(actual).toEqual([{ kind: 'dynamic', specifier: './real' }]) +}) + +it.each( + ['function f() {}', 'class C {}'].flatMap((body) => + [ + ['const value =\n', ';'], + ['const value = (\n', ');'], + ['const value = [\n', '];'], + ['const value = { field:\n', '};'], + ['const value = ok ?\n', ': 0;'], + ['call(0,\n', ');'], + ['function outer() { return ', '; }'], + ['function outer() { throw ', '; }'], + ['const value = void\n', ';'], + ['const value = typeof\n', ';'], + ['const value = delete\n', ';'], + ['const value = new\n', ';'], + ['const value = !\n', ';'], + ['const value = ~\n', ';'], + ['const value = +\n', ';'], + ['const value = -\n', ';'], + ].map(([prefix, suffix]) => ({ + code: `${prefix}${body} / import("./real")${suffix}`, + })), + ), +)('keeps literal imports in expression division in $code', ({ code }) => { + // Given: each expression prefix is real syntax, not a token-only example. + new Bun.Transpiler({ loader: 'js' }).transformSync(code) + // When: scan without executing the expression or resolving its import. + const actual = scanImports(code, false) + // Then: division retains the literal import dependency. + expect(actual).toEqual([{ kind: 'dynamic', specifier: './real' }]) +}) diff --git a/packages/plugin-utils/src/__tests__/import-scanner-jsx-boundary.test.ts b/packages/plugin-utils/src/__tests__/import-scanner-jsx-boundary.test.ts new file mode 100644 index 000000000..6d68cd7bd --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-scanner-jsx-boundary.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' +import { jsxBoundaryFixtures } from './jsx-boundary-fixtures' + +it.each([...jsxBoundaryFixtures])( + 'keeps independent edges when scanning $code', + ({ code, jsx, edges }) => { + // Given: syntax-checked source, never evaluated or used to derive expectations. + new Bun.Transpiler({ loader: jsx ? 'tsx' : 'ts' }).transformSync(code) + // When: scan the original source. + const actual = scanImports(code, jsx).map( + ({ kind, specifier }) => `${kind}:${specifier}`, + ) + // Then: only the fixture's independently named edges survive. + expect(actual).toEqual([...edges]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/import-scanner-jsx.test.ts b/packages/plugin-utils/src/__tests__/import-scanner-jsx.test.ts new file mode 100644 index 000000000..e78ae7910 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-scanner-jsx.test.ts @@ -0,0 +1,148 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' + +it.each([ + { + code: "const jsx =
{import('phantom') && import('./real')}
;", + edges: ['dynamic:./real'], + }, + { + code: "const jsx =
{(import('phantom')) && import('./real')}
;", + edges: ['dynamic:./real'], + }, + { + code: "const jsx =
{require('phantom') && require('./required')}
;", + edges: ['static:./required'], + }, + { + code: "const jsx =
{(require('phantom')) && require('./required')}
;", + edges: ['static:./required'], + }, + { + code: "const jsx = import('phantom')}>require('phantom'){import('./real')};", + edges: ['dynamic:./real'], + }, + { + code: "const jsx = require('phantom')}>import('phantom'){require('./required')};", + edges: ['static:./required'], + }, + { + code: "const jsx = && require('./required'))} label=\"import('phantom')\">require('phantom'){import('./real')};", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{ok ? import('phantom') : import('./real')}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{ok ? import('./real') : require('phantom')}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{({node: import('phantom'), leaf: import('./real')})}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{[import('phantom'), import('./real'), require('./required')]}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{call(require('phantom'), import('./real'), require('./required'))}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{<>import('phantom') && import('./real')}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{ && import('./real')}require('phantom'){require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{{import('phantom') && import('./real')}require('phantom') && require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx = import('phantom')}>require('phantom'){import('./real')}}>import('phantom'){require('./required')};", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx = } next={import('./real')} label='require("phantom")'/>; require('./required');", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx = import('phantom'), leaf: import('./real')}}>require('phantom'){require('./required')};", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{(() => { const node = import('phantom'); return import('./real') })()}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{(() => require('phantom'))() && import('./real')}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{`${import('phantom') && import('./real')} require('phantom')`}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{!import('phantom') && import('./real')}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "async function f() { return
{await import('phantom') && import('./real')}{require('./required')}
}", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "function* f() { return
{(yield require('phantom')) && import('./real')}{require('./required')}
}", + edges: ['dynamic:./real', 'static:./required'], + }, +])( + 'keeps only real nested JSX edges when scanning $code', + ({ code, edges }) => { + // Given: valid TSX, checked without evaluating its imports or expressions. + new Bun.Transpiler({ loader: 'tsx' }).transformSync(code) + // When: scan the original source, not the compiler output. + const actual = scanImports(code, true) + .map((edge) => `${edge.kind}:${edge.specifier}`) + .sort() + // Then: fixture-derived dependencies exclude every text/quoted phantom. + expect(actual).toEqual([...edges]) + }, +) + +it.each([ + '~', + '+', + '-', + 'void ', + 'typeof ', + 'delete ', + '0 + ', + '0 - ', + '0 * ', + '0 / ', + '0 % ', + '0 ** ', + '0 ^ ', + '0 & ', + '0 | ', + '0 << ', + '0 > ', + '0 < ', + '0 in ', + '0 instanceof ', +])('restores parent code when JSX follows expression prefix %s', (prefix) => { + // Given: valid unary/binary operand syntax surrounding nested JSX. + const code = `const jsx =
{${prefix}import('phantom') && import('./real')}{require('./required')}
;` + new Bun.Transpiler({ loader: 'tsx' }).transformSync(code) + // When: scan the original operand expression. + const actual = scanImports(code, true) + // Then: expression operators neither leak text nor hide following dependencies. + expect(actual).toEqual([ + { kind: 'dynamic', specifier: './real' }, + { kind: 'static', specifier: './required' }, + ]) +}) diff --git a/packages/plugin-utils/src/__tests__/import-scanner.test.ts b/packages/plugin-utils/src/__tests__/import-scanner.test.ts new file mode 100644 index 000000000..dab92e615 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-scanner.test.ts @@ -0,0 +1,147 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' + +it.each([ + { + code: 'export const result = `${import("./leaf")}`', + edges: ['dynamic:./leaf'], + }, + { + code: 'export const result = `${require("./leaf")}`', + edges: ['static:./leaf'], + }, + { + code: 'const s = `text ${ {a: `nested ${import("./a")}`, b: require("./b")} } tail import("fake")`', + edges: ['dynamic:./a', 'static:./b'], + }, + { + code: 'const s = tag`escaped \\` \\${import("fake")} ${ /[`"}]/.test("}") ? import("./a") : require("./b") } tail`', + edges: ['dynamic:./a', 'static:./b'], + }, + { + code: 'const s = `${ (() => { /* } ` */ // } `\n return import("./a") })() }`', + edges: ['dynamic:./a'], + }, + { + code: 'const s = `${
import("fake"){import("./a")}
}`', + edges: ['dynamic:./a', 'static:./b'], + }, + { + code: 'const a = 10 / import("./a"); const b = /[`"\']import("fake")/.test(a); const c = require("./b") / 2;', + edges: ['dynamic:./a', 'static:./b'], + }, + { + code: 'const x = {a: 1} / import("./a"); x++ / require("./b"); --x / import("./c");', + edges: ['dynamic:./a', 'dynamic:./c', 'static:./b'], + }, + { + code: 'if (x) { y() } /import("fake")/.test(x); while (x) /import("fake")/.test(x); import("./a")', + edges: ['dynamic:./a'], + }, + { + code: 'const x = "import(\\"fake\\")"; /* require("fake") */ // import("fake")\n const y = <>
require("fake")
{require("./b")};', + edges: ['static:./b'], + }, + { + code: 'export*from"./a"; export{x}from"./b"; export * as ns from "./c"; import"./d";', + edges: ['static:./a', 'static:./b', 'static:./c', 'static:./d'], + }, + { + code: 'import /*x*/ {x} /*y*/ from "./a"; require /*x*/ ( /*y*/ "./b" /*z*/ ); import("./c", {with:{type:"json"}});', + edges: ['dynamic:./c', 'static:./a', 'static:./b'], + }, + { + code: 'import(name); require(name); import("./a" + name); require("./b" + name); import(`./c`); object.require("fake"); object?.import("fake");', + edges: [], + }, + { + code: 'import(`${"fake"}`); require(`text ${"fake"}`); import(`text ${import("./a")}`);', + edges: ['dynamic:./a'], + }, + { + code: 'import type {A} from "fake"; export type*from"fake"; import{type A,type B,}from"fake"; export{type A}from"fake"; import {type A,b} from "./a"; import Default,{type A}from"./b"; import type from "./c"; import{type as renamed}from"./d";', + edges: ['static:./a', 'static:./b', 'static:./c', 'static:./d'], + }, + { + code: String.raw`import "./\x61"; export{x}from'./\u0062'; require('./\u{63}'); import("./quo\"te"); import('./quo\'te'); import('./back\\slash');`, + edges: [ + 'dynamic:./quo"te', + "dynamic:./quo'te", + 'dynamic:./back\\slash', + 'static:./a', + 'static:./b', + 'static:./c', + ].sort(), + }, + { + code: 'const s = `${ "}\\"" + /[}\\/`]/gi.test(x) + import("./a") } after`;', + edges: ['dynamic:./a'], + }, + { + code: 'const s = `${ x in /import("fake")/ ? import("./a") : "" }`;', + edges: ['dynamic:./a'], + }, + { + code: 'import from from "./a"; import {from} from "./b"; export {from as x} from "./c";', + edges: ['static:./a', 'static:./b', 'static:./c'], + }, + { + code: 'import type, {value} from "./a"; import type, * as values from "./b"; import {"string-name" as name} from "./c"; export {name as "string-name"} from "./d";', + edges: ['static:./a', 'static:./b', 'static:./c', 'static:./d'], + }, + { + code: 'const fn = function() {} / import("./a"); const C = class {} / require("./b");', + edges: ['dynamic:./a', 'static:./b'], + }, + { + code: 'try {} finally {} /import("fake")/.test(x); class C {} /require("fake")/.test(x); function f() {} /import("fake")/.test(x); import("./a");', + edges: ['dynamic:./a'], + }, + { + code: 'async function f() {} /import("fake")/.test(x); const fn = async function() {} / import("./a"); const g = function(a={x:1}, b=()=>{}) { return function() {} / require("./b") };', + edges: ['dynamic:./a', 'static:./b'], + }, + { + code: 'export async /* marker */ function f() {} /import("phantom")/.test(value); import("./real");', + edges: ['dynamic:./real'], + }, + { + code: 'const fn = async /*marker*/ function() {} / import("./real")', + edges: ['dynamic:./real'], + }, + { + code: 'async/*marker*//*next*/function f() {} /import("phantom")/.test(value); import("./real");', + edges: ['dynamic:./real'], + }, + { + code: 'const value = async /*marker*/ / import("./real") / 2; /*next*/ function f() {}', + edges: ['dynamic:./real'], + }, +])('discovers only fixture-derived edges in $code', ({ code, edges }) => { + // Given: independent expected edge sets, including text that resembles imports. + // When: scan without executing source or consulting a parser. + const actual = scanImports(code, true) + .map((edge) => `${edge.kind}:${edge.specifier}`) + .sort() + // Then: only literal dependencies in code are edges. + expect(actual).toEqual([...edges]) +}) + +it('preserves TypeScript angle assertions without interpreting them as JSX', () => { + const code = 'const value = input / 2; import("./leaf")' + expect(scanImports(code, false)).toEqual([ + { kind: 'dynamic', specifier: './leaf' }, + ]) +}) + +it('decodes escaped control characters and line continuations without evaluation', () => { + const code = String.raw`require('a\n\r\t\b\f\v\0\z'); import('a\ +b'); import('a\ +b');` + expect(scanImports(code, false)).toEqual([ + { kind: 'static', specifier: 'a\n\r\t\b\f\v\0z' }, + { kind: 'dynamic', specifier: 'ab' }, + { kind: 'dynamic', specifier: 'ab' }, + ]) +}) diff --git a/packages/plugin-utils/src/__tests__/import-type-fixtures.ts b/packages/plugin-utils/src/__tests__/import-type-fixtures.ts new file mode 100644 index 000000000..34be6b3ed --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-type-fixtures.ts @@ -0,0 +1,108 @@ +export const importTypeFixtures: readonly { + readonly code: string + readonly jsx: boolean + readonly edges: readonly string[] +}[] = [ + ...[ + "type X = typeof import('./types');", + "type X = import('./types').T;", + "export type X = typeof import('./types');", + "export type X = import('./types').T;", + "type X = {value: import('./types').T; loader: typeof import('./types')};", + "type X = T extends import('./types').T ? import('./types').U : never;", + "type X = import('./types').T | import('./other-types').T;", + "type X = (value: import('./types').T) => import('./types').U;", + "type X = `prefix${import('./types').T}`;", + "interface X {value: import('./types').T; loader: typeof import('./types')}", + "type X = typeof /* marker */ import /* next */ ('./types');", + "type X = typeof import('./types')\n", + ].map((prefix) => ({ + code: `${prefix}\nconst x = typeof import('./real'); require('./required');`, + jsx: false, + edges: ['dynamic:./real', 'static:./required'], + })), + ...[ + "const x: import('./types').T = import('./real');", + "const x: typeof import('./types') = typeof import('./real');", + "let x: Promise = import('./real');", + "const x: T extends U ? import('./types').T : never = import('./real');", + "const f = (x: () => import('./types').T = () => import('./real')) => import('./body');", + "const x = import('./real') as {fn: () => import('./types').T};", + "const x = import('./real') as (() => import('./types').T);", + "const x = f(import('./real'));", + "function f(x = import('./real')) {return import('./body')}", + "const f = (x = import('./real')) => import('./body');", + "const x = import('./real');", + "const x = a < import('./real');", + "class C extends mixin({value: import('./real')}) {f() {return import('./body')}}", + "type X = typeof import('./types')\nimport('./real');", + "type X = import('./types').T\nimport('./real');", + "function f(x: import('./types').T = import('./real')) {return import('./body')}", + "function f(x: import('./types').T = import('./real')): Promise {return import('./body')}", + "const f = (x: import('./types').T = import('./real')): Promise => import('./body');", + "function f({x}: import('./types').T = import('./real')) {return import('./body')}", + "class C {x: import('./types').T = import('./real'); f(x: import('./types').T) {return import('./body')}}", + "const x = import('./real') as import('./types').T;", + "const x = import('./real') satisfies import('./types').T;", + "const x = {type: typeof import('./real'), value: import('./body')};", + "const f = (x = typeof import('./real')) => import('./body');", + "const x = typeof import('./real');", + "const x = ok ? f() : typeof import('./real');", + "const x = ok ? f(import('./body')) : import('./real');", + "const x = ok ? f() : ((x = import('./real')) => x);", + "const as = (x) => x; const x = as(import('./real'));", + "const satisfies = (x) => x; const x = satisfies(import('./real'));", + "const x = {as: typeof import('./real')};", + "obj.class; const x = {value: import('./real')};", + "const x = {interface: import('./real'), const: import('./body')};", + "const x = import('./real') as import('./types').T && import('./body');", + "const x = import('./real') as number ? import('./body') : null;", + "const x = ok ? (f()) : import('./real');", + "const x = ok ? (value) : typeof import('./real');", + "const x = ok ? ((f(g()))) : import('./real');", + "const x = ok ? (f(import('./body'))) : import('./real');", + "const x = ok ? (value) : {loader: import('./real')};", + "const x = ok ? (value) : (() => import('./real'));", + "const x = ok ? (value) : (x) => import('./real');", + "const x = ok ? (value) : other ? (f()) : import('./real');", + "const x = ok ? (value) : typeof (import('./real'));", + "const x = ok ? (value) : (x = import('./real')): import('./types').T => x;", + "const x = ok ? f() : (x = import('./real')): import('./types').T => x;", + "const x = ok ? (value) : (): {loader: import('./types').T} => ({loader: import('./real')});", + "const x = {async f(): import('./types').T {return import('./real')}};", + "class C {async f(): import('./types').T {return import('./real')}}", + "const x = {async f(x: import('./types').T = import('./real')): Promise {return import('./body')}};", + "class C {static async f(x: import('./types').T = import('./real')): Promise {return import('./body')}}", + "const f = async (x: import('./types').T = import('./real')): Promise => import('./body');", + "async function f(x: import('./types').T = import('./real')): Promise {return import('./body')}", + "function f(): import('./types').T; function f() {return import('./real')}", + ].map((code) => ({ + code, + jsx: false, + edges: code.includes('f(import') + ? ['dynamic:./body', 'dynamic:./real'] + : code.includes('./body') + ? ['dynamic:./real', 'dynamic:./body'] + : ['dynamic:./real'], + })), + { + code: "type X = import('./types').T", + jsx: false, + edges: [], + }, + { + code: "type X = import('./types').T; const x = typeof import('./types');", + jsx: false, + edges: ['dynamic:./types'], + }, + { + code: "const x: import('./types').T =
{typeof import('./real')}import('phantom')
;", + jsx: true, + edges: ['dynamic:./real'], + }, + ...[ + "const x =
{ok ? (f()) : import('./real')}import('phantom')
;", + "const x =
{ok ? (value) : typeof import('./real')}
;", + "const x =
{ok ? ((f(g()))) : import('./real')}
;", + ].map((code) => ({ code, jsx: true, edges: ['dynamic:./real'] })), +] diff --git a/packages/plugin-utils/src/__tests__/import-type-graph.test.ts b/packages/plugin-utils/src/__tests__/import-type-graph.test.ts new file mode 100644 index 000000000..504bf497f --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-type-graph.test.ts @@ -0,0 +1,90 @@ +import { join } from 'node:path' + +import { expect, it, mock } from 'bun:test' + +import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph' +import { ConfigLoadError } from '../load-config' +import { importTypeFixtures } from './import-type-fixtures' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each( + importTypeFixtures + .filter(({ edges }) => !edges.includes('dynamic:./types')) + .flatMap((fixture) => + [false, true].map((prepared) => ({ ...fixture, prepared })), + ), +)( + 'ignores poisoned type modules with prepared=$prepared when scanning $code', + async ({ code, jsx, edges, prepared }) => { + // Given: real runtime leaves and a malformed manifest for type-only requests. + const source = code + .replaceAll('./types', 'phantom') + .replaceAll('./other-types', 'phantom') + const compiled = new Bun.Transpiler({ + loader: jsx ? 'tsx' : 'ts', + }).transformSync(source) + const entry = file(`src/main.${jsx ? 'tsx' : 'ts'}`, source) + for (const name of ['real', 'required', 'body']) file(`src/${name}.ts`) + file('node_modules/phantom/package.json', '{') + const programRead = mock(() => ({ + type: 'ImportExpression', + source: { value: 'phantom' }, + })) + const parseSync = mock(() => ({ + errors: [], + get program() { + return programRead() + }, + })) + const options = { + cwd: root, + prepareSource: (filename: string) => + prepared && filename === entry ? compiled : undefined, + } + // When: traverse the actual filesystem with diagnostics disabled and injected. + __setOxcParserForTest(false) + const disabled = await buildStaticImportGraph('src', undefined, options) + __setOxcParserForTest({ parseSync }) + const injected = await buildStaticImportGraph('src', undefined, options) + // Then: only fixture-derived runtime edges load; no type/AST phantom is resolved. + expect(injected).toEqual(disabled) + const kinds: readonly ('static' | 'dynamic')[] = ['static', 'dynamic'] + for (const kind of kinds) { + expect( + { static: injected.staticImports, dynamic: injected.dynamicImports }[ + kind + ].get(entry), + ).toEqual( + new Set( + edges + .filter((edge) => edge.startsWith(`${kind}:`)) + .map((edge) => + join(root, 'src', `${edge.slice(kind.length + 3)}.ts`), + ), + ), + ) + } + expect(injected.externalImports?.get(entry)).toEqual(new Set()) + expect(parseSync).toHaveBeenCalledTimes(injected.files.length) + expect(programRead).not.toHaveBeenCalled() + }, +) + +it('reaches the poisoned manifest when typeof import is genuinely runtime code', () => { + // Given: the same poisoned dependency, in both a type and a runtime expression. + file( + 'src/main.ts', + "type X = typeof import('phantom'); const x = typeof import('phantom');", + ) + file('node_modules/phantom/package.json', '{') + __setOxcParserForTest(false) + // When/Then: runtime dependency resolution must still reach the malformed manifest. + expect(() => buildStaticImportGraph('src', undefined, { cwd: root })).toThrow( + ConfigLoadError, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/import-type.test.ts b/packages/plugin-utils/src/__tests__/import-type.test.ts new file mode 100644 index 000000000..2a16b4d48 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-type.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' +import { importTypeFixtures } from './import-type-fixtures' + +it.each([...importTypeFixtures])( + 'elides only type imports when scanning $code', + ({ code, jsx, edges }) => { + // Given: syntax-valid source and independently named runtime dependencies. + new Bun.Transpiler({ loader: jsx ? 'tsx' : 'ts' }).transformSync(code) + // When: scan original source, never compiler output or evaluated modules. + const actual = scanImports(code, jsx).map( + ({ kind, specifier }) => `${kind}:${specifier}`, + ) + // Then: types disappear while runtime typeof-import and initializers survive. + expect(actual).toEqual([...edges]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/jsonc.test.ts b/packages/plugin-utils/src/__tests__/jsonc.test.ts new file mode 100644 index 000000000..1cbca0235 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/jsonc.test.ts @@ -0,0 +1,106 @@ +import { createRequire } from 'node:module' + +import { expect, it } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { parseJsonc } from '../jsonc' +import { ConfigLoadError } from '../load-config' +import { readPackageManifest } from '../packaged-config-inputs' +import { createPreparedFixture } from './prepared-graph-fixture' + +const ts: { + readonly version: string + parseConfigFileTextToJson( + file: string, + source: string, + ): { + readonly config?: unknown + readonly error?: unknown + } +} = createRequire(import.meta.url)('@typescript/typescript6') +const hexOverflow = `0x${'F'.repeat(400)}` +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each([ + ['0xff', 255], + ['-0xff', -255], + ['1_000', 1000], + ['-1_000', -1000], + ['0b10', 2], + ['0o10', 8], + [hexOverflow, Infinity], + [`-${hexOverflow}`, -Infinity], + ['1_0e999', Infinity], + ['-1_0e999', -Infinity], + ['1e999', Infinity], + ['-1e999', -Infinity], +] as const)( + 'retains numeric value and unrelated selection metadata for %s', + (literal, expected) => { + // Given valid JSONC numeric syntax and independently expected selecting/numeric fields. + expect(ts.version).toBe('6.0.3') + const source = `{"tsconfig":"blue.json","other":${literal}}` + const fields = { tsconfig: 'blue.json', other: expected } + // When the installed compiler and owned parser convert the same literal. + const oracle = ts.parseConfigFileTextToJson('package.json', source) + // Then neither drops metadata nor caps/nulls the exact numeric result. + expect(oracle.error).toBeUndefined() + expect(oracle.config).toEqual(fields) + expect(parseJsonc(source, true)).toEqual(fields) + }, +) + +it.each([hexOverflow, '1_0e999', '0xff', '1_000'])( + 'keeps ordinary module manifests strict for JSONC literal %s', + (literal) => { + // Given a real module manifest whose numeric spelling requires the config-only JSONC policy. + const manifest = file( + 'node_modules/preset/package.json', + `{"main":"blue.js","other":${literal}}`, + ) + file('node_modules/preset/blue.js', "export const color='blue'") + // When the ordinary module purpose encounters that non-JSON spelling. + expect(() => readPackageManifest(manifest, 'module')).toThrow( + ConfigLoadError, + ) + // Then the unchanged public module resolver retains the real manifest load failure. + expect(() => + createModuleResolver({ cwd: root })('preset', 'main.ts'), + ).toThrow(manifest) + }, +) + +it('preserves standard JSON decimal overflow in ordinary module manifests', () => { + // Given valid standard JSON with an unrelated overflow field and a real main module. + const manifest = file( + 'node_modules/preset/package.json', + '{"main":"blue.js","other":1e999}', + ) + const target = file( + 'node_modules/preset/blue.js', + "export const color='blue'", + ) + // When the unchanged strict JSON policy and module resolver read it. + const fields = readPackageManifest(manifest, 'module') + // Then standard JSON semantics retain Infinity and ordinary main selection. + expect(fields).toEqual({ main: 'blue.js', other: Infinity }) + expect(createModuleResolver({ cwd: root })('preset', 'main.ts')?.path).toBe( + target, + ) +}) + +it.each(['0x', '1__0'])( + 'keeps malformed numeric literal %s as a config-manifest parse miss', + (literal) => { + // Given malformed syntax beside a selecting field. + const source = `{"tsconfig":"blue.json","other":${literal}}` + const manifest = file('package.json', source) + // When the config-only parser and manifest boundary encounter the invalid literal. + expect(() => parseJsonc(source, true)).toThrow(SyntaxError) + // Then the established malformed-manifest fallback remains an empty record. + expect(readPackageManifest(manifest, 'tsconfig-extends')).toEqual({}) + }, +) diff --git a/packages/plugin-utils/src/__tests__/jsx-boundary-fixtures.ts b/packages/plugin-utils/src/__tests__/jsx-boundary-fixtures.ts new file mode 100644 index 000000000..c9b5ccfc3 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/jsx-boundary-fixtures.ts @@ -0,0 +1,79 @@ +export const jsxBoundaryFixtures: readonly { + readonly code: string + readonly jsx: boolean + readonly edges: readonly string[] +}[] = [ + ...[ + 'ab', + 'a<>b', + 'a>>>b', + 'a>>=b', + 'a>>>=b', + '1 <(x)', + 'f>(x)', + 'new Map()', + 'a[0] ({ + code: `const x = ${expression}; import('./real'); require('./required');`, + jsx: true, + edges: ['dynamic:./real', 'static:./required'], + })), + ...[ + '(x: T)=>x', + '(x: T)=>x', + '(x: T)=>x', + '>(x: T)=>x', + '(x: T): T=>x', + '(x = import("./inside"))=>x', + '(x: T)=>x', + '(x: T)=>x', + '(x: T)=>x', + '(x: T)=>x', + '(x: T)=>x', + '(x: T)=>x', + '(x: T)=>x', + ].map((expression) => ({ + code: `const fn = ${expression}; import('./real'); require('./required');`, + jsx: true, + edges: expression.includes('./inside') + ? ['dynamic:./inside', 'dynamic:./real', 'static:./required'] + : ['dynamic:./real', 'static:./required'], + })), + { + code: "const x = value; type X = Map>; import('./real'); require('./required');", + jsx: false, + edges: ['dynamic:./real', 'static:./required'], + }, + ...[ + { prefix: 'const x = ', suffix: ';' }, + { prefix: 'function f(){return ', suffix: '}' }, + { prefix: 'const x = (', suffix: ');' }, + { prefix: 'const x = ok ? ', suffix: ' : null;' }, + { prefix: 'const x = ok ? null : ', suffix: ';' }, + { prefix: 'const x = ok && ', suffix: ';' }, + { prefix: 'const x = ok || ', suffix: ';' }, + { prefix: 'const x = {node: ', suffix: '};' }, + { prefix: 'const x = [', suffix: '];' }, + { prefix: 'const x = () => ', suffix: ';' }, + { prefix: 'const x = 0 << ', suffix: ';' }, + { prefix: 'const x = 0 <= ', suffix: ';' }, + { prefix: 'const x = 0 === ', suffix: ';' }, + { prefix: 'const x = ok ?? ', suffix: ';' }, + { prefix: 'const x = 0 ** ', suffix: ';' }, + ].map(({ prefix, suffix }) => ({ + code: `${prefix}require('phantom'){require('./child')}${suffix} import('./real');`, + jsx: true, + edges: ['dynamic:./attribute', 'static:./child', 'dynamic:./real'], + })), + { + code: "const jsx = import('phantom'){import('./real')}; require('./required');", + jsx: true, + edges: ['dynamic:./real', 'static:./required'], + }, +] diff --git a/packages/plugin-utils/src/__tests__/jsx-boundary-graph.test.ts b/packages/plugin-utils/src/__tests__/jsx-boundary-graph.test.ts new file mode 100644 index 000000000..0848bde9b --- /dev/null +++ b/packages/plugin-utils/src/__tests__/jsx-boundary-graph.test.ts @@ -0,0 +1,63 @@ +import { join } from 'node:path' + +import { expect, it, mock } from 'bun:test' + +import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph' +import { jsxBoundaryFixtures } from './jsx-boundary-fixtures' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each([...jsxBoundaryFixtures])( + 'preserves expected filesystem graph edges when scanning $code', + async ({ code, jsx, edges }) => { + // Given: real leaves and a poisoned phantom dependency under a unique fixture root. + const entry = file(`src/main.${jsx ? 'tsx' : 'ts'}`, code) + for (const name of ['real', 'required', 'inside', 'attribute', 'child']) + file(`src/${name}.ts`) + file('node_modules/phantom/package.json', '{') + const programRead = mock(() => ({ + type: 'ImportDeclaration', + source: { value: 'phantom' }, + })) + const parseSync = mock(() => ({ + errors: [], + get program() { + return programRead() + }, + })) + const options = { + cwd: root, + prepareSource: (filename: string) => + jsx && filename === entry ? code : undefined, + } + // When: traverse the same prepared filesystem graph with diagnostics disabled and injected. + __setOxcParserForTest(false) + const disabled = await buildStaticImportGraph('src', undefined, options) + __setOxcParserForTest({ parseSync }) + const injected = await buildStaticImportGraph('src', undefined, options) + // Then: each expected edge resolves independently, with no AST reads or phantom resolution. + expect(injected).toEqual(disabled) + const kinds: readonly ('static' | 'dynamic')[] = ['static', 'dynamic'] + for (const kind of kinds) { + const expected = new Set( + edges + .filter((edge) => edge.startsWith(`${kind}:`)) + .map((edge) => + join(root, 'src', `${edge.slice(kind.length + 3)}.ts`), + ), + ) + expect( + { static: injected.staticImports, dynamic: injected.dynamicImports }[ + kind + ].get(entry), + ).toEqual(expected) + } + expect(injected.externalImports?.get(entry)).toEqual(new Set()) + expect(parseSync).toHaveBeenCalledTimes(injected.files.length) + expect(programRead).not.toHaveBeenCalled() + }, +) diff --git a/packages/plugin-utils/src/__tests__/load-config.test.ts b/packages/plugin-utils/src/__tests__/load-config.test.ts index bc3510ba7..8f5c1fdf0 100644 --- a/packages/plugin-utils/src/__tests__/load-config.test.ts +++ b/packages/plugin-utils/src/__tests__/load-config.test.ts @@ -91,12 +91,11 @@ describe('loadDevupConfigSync', () => { }) }) - it('should return empty object for invalid JSON', () => { + it('throws a located error for invalid JSON', () => { const configPath = join(testDir, 'invalid.json') writeFileSync(configPath, 'not valid json {{{') - const result = loadDevupConfigSync(configPath) - expect(result).toEqual({}) + expect(() => loadDevupConfigSync(configPath)).toThrow(configPath) }) it('should resolve extends', () => { diff --git a/packages/plugin-utils/src/__tests__/mdx-errors.test.ts b/packages/plugin-utils/src/__tests__/mdx-errors.test.ts new file mode 100644 index 000000000..3ba25cb46 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/mdx-errors.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'bun:test' + +import { remapMdxError } from '../index' + +const filename = 'C:\\docs\\page.mdx' +const map = { + version: 3, + sources: ['page.mdx', 'other.mdx'], + names: ['Box'], + mappings: ';EAIEA,KCEG;ADFH', +} + +describe('MDX error locations', () => { + it.each([map, JSON.stringify(map)])( + 'maps multiline Windows locations with real VLQ deltas', + (input) => { + const error = new Error( + `${filename}:2:3: first\n${filename}:2:9: second\n${filename}:3:1: third\nother.ts:2:3: unchanged\nnote ${filename}:2:3: unchanged`, + ) + const result = remapMdxError(error, filename, input) + expect(result.message).toBe( + 'page.mdx:5:3: first\nother.mdx:7:6: second\npage.mdx:5:3: third\nother.ts:2:3: unchanged\nnote C:\\docs\\page.mdx:2:3: unchanged', + ) + expect(result.cause).toBe(error) + }, + ) + it.each([undefined, null])( + 'labels compiled coordinates immediately after the column when no map exists', + (input) => { + expect( + remapMdxError( + `${filename}:2:3: failure\n${filename}:4:5 details`, + filename, + input, + ).message, + ).toBe( + `${filename}:2:3 (in compiled MDX): failure\n${filename}:4:5 (in compiled MDX) details`, + ) + }, + ) + it('does not change a message without a matching location', () => { + expect(remapMdxError(`${filename}:not a location`, filename).message).toBe( + `${filename}:not a location`, + ) + }) + it('labels gaps, before-first segments, unmapped segments and missing lines', () => { + const input = { + version: 3, + sources: ['page.mdx'], + mappings: ';EAAA,E;AAAA;;', + } + const text = [1, 2, 4, 8] + .map((line) => `${filename}:${line}:1: missing`) + .concat(`${filename}:2:5: gap`) + .join('\n') + expect(remapMdxError(text, filename, input).message).toBe( + text + .replace(/:(\d+):1:/g, ':$1:1 (in compiled MDX):') + .replace(':2:5:', ':2:5 (in compiled MDX):'), + ) + }) + it.each([ + ['', 'page.mdx', 'page.mdx'], + ['/docs', 'page.mdx', '/docs/page.mdx'], + ['C:\\docs', 'page.mdx', 'C:\\docs\\page.mdx'], + [ + 'https://example.com/docs/', + '../page.mdx', + 'https://example.com/page.mdx', + ], + ['/docs', '/absolute.mdx', '/absolute.mdx'], + ['/docs', 'C:\\absolute.mdx', 'C:\\absolute.mdx'], + ['/docs', 'webpack:///page.mdx', 'webpack:///page.mdx'], + ])('resolves sourceRoot %s and source %s', (sourceRoot, source, expected) => { + expect( + remapMdxError(`${filename}:1:1`, filename, { + version: 3, + mappings: 'AAAA', + sources: [source], + sourceRoot, + }).message, + ).toBe(`${expected}:1:1`) + }) + it('decodes multi-digit VLQ fields and chooses the preceding mapping without inventing column offsets', () => { + expect( + remapMdxError(`${filename}:1:30`, filename, { + version: 3, + sources: ['page.mdx'], + mappings: 'gBAgBgB', + }).message, + ).toBe('page.mdx:17:17') + }) + it('tracks name deltas across segments and preserves the original error cause chain', () => { + const cause = new TypeError('original') + const error = new Error(`${filename}:1:3`, { cause }) + const result = remapMdxError(error, filename, { + version: 3, + sources: ['page.mdx'], + names: ['a', 'b'], + mappings: 'AAAAC,EAAAD', + }) + expect(result.message).toBe('page.mdx:1:1') + expect(result.cause).toBe(error) + expect(error.cause).toBe(cause) + }) + it.each([ + '{', + 'null', + '[]', + '{}', + { version: 2, sections: [] }, + { ...map, version: 2 }, + { ...map, sources: [null] }, + { ...map, names: [1] }, + { ...map, sourceRoot: 1 }, + ...[ + '?', + 'g', + 'ggggggggggggA', + '////////////A', + 'AA', + 'AAA', + 'AAAAAA', + ',', + 'D', + 'EAAA,D', + 'ACAA', + 'ADAA', + 'AADA', + 'AAAD', + 'AAAAC', + 'AAAAD', + ].map((mappings) => ({ ...map, sources: ['page.mdx'], mappings })), + ])( + 'diagnoses invalid or unsupported maps with location and cause: %j', + (input) => { + let caught: unknown + try { + remapMdxError('failure', filename, input) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Error) + if (!(caught instanceof Error)) throw caught + expect(caught.message).toStartWith( + `${filename}:1:1: Invalid MDX source map:`, + ) + expect(caught.cause).toBeInstanceOf(Error) + }, + ) + it.each(['0:1', '1:0', '9007199254740992:1', '1:9007199254740992'])( + 'diagnoses invalid extractor coordinates %s', + (location) => { + expect(() => remapMdxError(`${filename}:${location}`, filename)).toThrow( + `${filename}:1:1:`, + ) + }, + ) + + it('remaps indexed maps across offsets, gaps and nested sections', () => { + const input = { + version: 3, + sections: [ + { + offset: { line: 0, column: 2 }, + map: { version: 3, sources: ['first.mdx'], mappings: 'AAAA;AACE' }, + }, + { + offset: { line: 1, column: 4 }, + map: { + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { + version: 3, + sources: ['second.mdx'], + mappings: 'AAGE;AACA', + }, + }, + ], + }, + }, + { offset: { line: 3, column: 0 }, map: { version: 3, sections: [] } }, + ], + } + expect( + remapMdxError( + `${filename}:1:3: first\n${filename}:2:5: second\n${filename}:3:1: nested\n${filename}:4:1: gap`, + filename, + JSON.stringify(input), + ).message, + ).toBe( + `first.mdx:1:1: first\nsecond.mdx:4:3: second\nsecond.mdx:5:3: nested\n${filename}:4:1 (in compiled MDX): gap`, + ) + }) + + it('accepts an empty indexed map and labels the unmapped compiled position', () => { + expect( + remapMdxError(`${filename}:1:1: missing`, filename, { + version: 3, + sections: [], + }).message, + ).toBe(`${filename}:1:1 (in compiled MDX): missing`) + }) + + it.each([ + { version: 3, sections: null }, + { version: 3, sections: [null] }, + { version: 3, sections: [{ offset: { line: -1, column: 0 } }] }, + { version: 3, sections: [{ offset: { line: '0', column: 0 } }] }, + { version: 3, sections: [{ offset: { line: 0, column: -1 } }] }, + { version: 3, sections: [{ offset: { line: 0, column: '0' } }] }, + { version: 3, sections: [{ offset: { line: 0, column: 0.5 } }] }, + { + version: 3, + sections: [0, 0].map(() => ({ + offset: { line: 0, column: 0 }, + map: { version: 3, sections: [] }, + })), + }, + { + version: 3, + sections: [1, 0].map((line) => ({ + offset: { line, column: 0 }, + map: { version: 3, sections: [] }, + })), + }, + { + version: 3, + sections: [ + { + offset: { line: 0, column: Number.MAX_SAFE_INTEGER }, + map: { version: 3, sources: ['page.mdx'], mappings: 'CAAA' }, + }, + ], + }, + ])( + 'diagnoses invalid indexed maps with the original filename: %j', + (input) => { + expect(() => remapMdxError(`${filename}:1:1`, filename, input)).toThrow( + `${filename}:1:1: Invalid MDX source map:`, + ) + }, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/module-alias.test.ts b/packages/plugin-utils/src/__tests__/module-alias.test.ts new file mode 100644 index 000000000..7c7a50d24 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/module-alias.test.ts @@ -0,0 +1,223 @@ +import * as fs from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' + +const installedRequire = createRequire( + resolve( + 'node_modules/.bun/enhanced-resolve@5.25.1/node_modules/enhanced-resolve/package.json', + ), +) +const enhancedResolve = installedRequire('enhanced-resolve') +let root: string +let entry: string +beforeEach(() => { + const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared-api', + ) + fs.mkdirSync(parent, { recursive: true }) + root = fs.realpathSync.native(fs.mkdtempSync(join(parent, 'alias-'))) + entry = file('src/main.ts') +}) +afterEach(() => fs.rmSync(root, { recursive: true, force: true })) +function file(path: string, source = 'export {}'): string { + const target = join(root, path) + fs.mkdirSync(dirname(target), { recursive: true }) + fs.writeFileSync(target, source) + return target +} + +function installed( + request: string, + alias: Readonly>, + conditions: readonly string[] = ['import', 'browser'], +): string | undefined { + const resolver = enhancedResolve.ResolverFactory.createResolver({ + fileSystem: fs, + useSyncFileSystemCalls: true, + extensions: ['.js'], + alias, + conditionNames: conditions, + }) + try { + return resolver.resolveSync({}, dirname(entry), request) || undefined + } catch (cause) { + if (cause instanceof Error && /Can't resolve/.test(cause.message)) + return undefined + throw cause + } +} + +it('matches installed resolver declaration order, exact keys and subpath boundaries', () => { + const broad = file('broad/index.js') + const broadSub = file('broad/feature.js') + const specific = file('specific.js') + const dir = dirname(broad) + const cases = [ + { + request: '@x/feature', + alias: { '@x': dir, '@x/feature': specific }, + expected: broadSub, + }, + { + request: '@x/feature', + alias: { '@x/feature': specific, '@x': dir }, + expected: specific, + }, + { + request: '@x', + alias: { '@x$': specific, '@x': dir }, + expected: specific, + }, + { + request: '@x/feature', + alias: { '@x$': specific, '@x': dir }, + expected: broadSub, + }, + { request: '@xyz', alias: { '@x': dir }, expected: undefined }, + { request: '@x', alias: { '@x': dir }, expected: broad }, + ] + for (const { request, alias, expected } of cases) { + expect(installed(request, alias)).toBe(expected) + expect( + createModuleResolver({ cwd: root, alias })(request, entry)?.path, + ).toBe(expected) + } +}) + +it.each([ + { conditions: ['browser', 'import'] }, + { conditions: ['import'] }, + { conditions: [] }, +])( + 'matches package exports conditions after alias rewrite (%j)', + ({ conditions }) => { + file( + 'node_modules/target/package.json', + JSON.stringify({ + exports: { + '.': { + browser: './browser.js', + import: './import.js', + default: './default.js', + }, + './feature': './feature.js', + }, + }), + ) + const browser = file('node_modules/target/browser.js') + const imported = file('node_modules/target/import.js') + const fallback = file('node_modules/target/default.js') + const feature = file('node_modules/target/feature.js') + const alias = { '@x': 'target' } + const expected = conditions.includes('browser') + ? browser + : conditions.includes('import') + ? imported + : fallback + expect(installed('@x', alias, conditions)).toBe(expected) + expect( + createModuleResolver({ cwd: root, alias, conditions })('@x', entry)?.path, + ).toBe(expected) + expect(installed('@x/feature', alias, conditions)).toBe(feature) + expect( + createModuleResolver({ cwd: root, alias, conditions })( + '@x/feature', + entry, + )?.path, + ).toBe(feature) + }, +) + +it('matches missing-target terminal failure even when the original and a later alias exist', () => { + file('node_modules/original/package.json', '{"main":"index.js"}') + file('node_modules/original/index.js') + const later = file('later.js') + const alias = { original: join(root, 'missing'), original$: later } + expect(installed('original', alias)).toBeUndefined() + expect(() => + createModuleResolver({ cwd: root, alias })('original', entry), + ).toThrow( + `${entry}:1:1: Module alias original cannot resolve candidates ${JSON.stringify([alias.original])}`, + ) +}) + +it('matches finite chains and self-alias skips', () => { + const target = file('target.js') + const alias = { '@a': '@a', '@a/feature': '@b', '@b': target } + expect(installed('@a/feature', alias)).toBe(target) + expect( + createModuleResolver({ cwd: root, alias })('@a/feature', entry)?.path, + ).toBe(target) +}) + +it('fails alias cycles at the real importer location', () => { + const alias = { '@a': '@b', '@b': '@a' } + expect(() => installed('@a', alias)).toThrow() + expect(() => createModuleResolver({ cwd: root, alias })('@a', entry)).toThrow( + `${entry}:1:1`, + ) +}) + +it('matches installed self-target subpath guards without an unbounded rewrite', () => { + file( + 'node_modules/pkg/package.json', + '{"exports":{"./feature":"./feature.js"}}', + ) + const feature = file('node_modules/pkg/feature.js') + const alias = { pkg: 'pkg/feature' } + expect(installed('pkg', alias)).toBe(feature) + expect(createModuleResolver({ cwd: root, alias })('pkg', entry)?.path).toBe( + feature, + ) +}) + +it('follows the actual aliased included-package closure rather than the alias name', () => { + fs.writeFileSync(entry, "import 'provider'") + file('node_modules/actual/package.json', '{"main":"index.js"}') + const provider = file( + 'node_modules/actual/index.js', + "import './nested/value.generated'", + ) + const leaf = file('node_modules/actual/nested/value.generated.mjs') + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: 'actual' }, + include: ['actual'], + }) + expect(graph.files).toEqual([entry, provider, leaf].sort()) + expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: 'actual' }, + }).files, + ).toEqual([entry]) +}) + +it('follows an absolute alias into the actual included package and honors its exclusions', () => { + fs.writeFileSync(entry, "import 'provider'") + file('node_modules/actual/package.json', '{"main":"index.js"}') + const provider = file( + 'node_modules/actual/index.js', + "import './nested/leaf'", + ) + const leaf = file('node_modules/actual/nested/leaf.mjs') + const options = { cwd: root, alias: { provider }, include: ['actual'] } + expect(buildStaticImportGraph('src', undefined, options).files).toEqual( + [entry, provider, leaf].sort(), + ) + expect( + buildStaticImportGraph('src', undefined, { + ...options, + exclude: [dirname(leaf)], + }).files, + ).toEqual([entry, provider].sort()) +}) diff --git a/packages/plugin-utils/src/__tests__/nested-jsx-graph.test.ts b/packages/plugin-utils/src/__tests__/nested-jsx-graph.test.ts new file mode 100644 index 000000000..f2d37040f --- /dev/null +++ b/packages/plugin-utils/src/__tests__/nested-jsx-graph.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' + +import { expect, it, mock } from 'bun:test' + +import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph' +import { ConfigLoadError } from '../load-config' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each( + [ + "const jsx =
{import('phantom') && import('./real')}{require('phantom') && require('./required')}
;", + "const jsx =
{(import('phantom')) && import('./real')}{(require('phantom')) && require('./required')}
;", + "const jsx = import('phantom')} next={require('phantom')}>require('phantom'){import('./real')}{require('./required')};", + ].flatMap((code) => + [false, true].flatMap((enabled) => + [false, true].map((prepared) => ({ code, enabled, prepared })), + ), + ), +)( + 'resolves only real nested JSX files with diagnostics=$enabled prepared=$prepared in $code', + async ({ code, enabled, prepared }) => { + // Given: independently created targets and a manifest that fails if resolved. + new Bun.Transpiler({ loader: 'tsx' }).transformSync(code) + const entry = file('src/main.tsx', code) + const real = file('src/real.ts', 'export const real = 1') + const required = file('src/required.ts', 'export const required = 2') + file('node_modules/phantom/package.json', '{') + const programRead = mock(() => ({ + type: 'ImportExpression', + source: { value: 'phantom' }, + })) + const parseSync = mock(() => ({ + errors: [], + get program() { + return programRead() + }, + })) + const loaded: string[] = [] + __setOxcParserForTest(enabled ? { parseSync } : false) + // When: traverse the real filesystem, never executing a fixture module. + const graph = prepared + ? await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: (filename: string) => { + loaded.push(filename) + return readFileSync(filename, 'utf8') + }, + }) + : buildStaticImportGraph('src', undefined, { cwd: root }) + // Then: real edges load real files; no textual/AST phantom reaches resolution. + expect(graph.dynamicImports.get(entry)).toEqual(new Set([real])) + expect(graph.staticImports.get(entry)).toEqual(new Set([required])) + expect(graph.staticImporters.get(required)).toEqual(new Set([entry])) + expect(graph.dynamicTargets).toEqual(new Set([real])) + expect(graph.files).toEqual([entry, real, required].sort()) + expect(graph.externalImports?.get(entry)).toEqual(new Set()) + expect(loaded.sort()).toEqual( + prepared ? [entry, real, required].sort() : [], + ) + expect(parseSync).toHaveBeenCalledTimes(enabled ? 3 : 0) + expect(programRead).not.toHaveBeenCalled() + }, +) + +it('rejects the poisoned manifest when an actual literal edge requests phantom', () => { + // Given: the same poison is reachable by a real dependency, not excluded. + file('src/main.tsx', "import('phantom')") + file('node_modules/phantom/package.json', '{') + __setOxcParserForTest(false) + // When/Then: resolution of that real edge must fail. + expect(() => buildStaticImportGraph('src', undefined, { cwd: root })).toThrow( + ConfigLoadError, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/numbering.test.ts b/packages/plugin-utils/src/__tests__/numbering.test.ts new file mode 100644 index 000000000..86d7d09df --- /dev/null +++ b/packages/plugin-utils/src/__tests__/numbering.test.ts @@ -0,0 +1,186 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test' + +import { listSourceFiles } from '../import-graph' +import { + collectNumberedFiles, + extractedNeedles, + seedFileNumbers, +} from '../numbering' + +describe('collectNumberedFiles', () => { + let root: string + + const write = (path: string) => { + fs.mkdirSync(dirname(join(root, path)), { recursive: true }) + fs.writeFileSync(join(root, path), 'export {}') + } + + beforeEach(() => { + root = fs.mkdtempSync(join(tmpdir(), 'devup-ui-numbering-')) + write('src/b.tsx') + write('src/a.tsx') + write('src/nested/c.ts') + write('src/readme.md') + write('node_modules/@acme/ui/Button.tsx') + write('node_modules/@acme/ui/node_modules/dep/skipped.tsx') + }) + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }) + }) + + it('lists the project source and the included packages in path order', () => { + const files = collectNumberedFiles({ + roots: [join(root, 'src'), join(root, 'missing')], + include: ['@acme/ui', 'not-installed'], + cwd: root, + }) + expect( + files.map((file) => file.slice(file.indexOf('devup-ui-numbering-'))), + ).toEqual( + [...files] + .sort() + .map((file) => file.slice(file.indexOf('devup-ui-numbering-'))), + ) + expect(files.filter((file) => file.includes('/src/'))).toHaveLength(3) + expect(files.some((file) => file.endsWith('/@acme/ui/Button.tsx'))).toBe( + true, + ) + expect(files.some((file) => file.includes('skipped'))).toBe(false) + }) + + it('numbers only the files that mention what the build extracts', () => { + fs.writeFileSync(join(root, 'src/a.tsx'), "import '@devup-ui/react'") + const files = collectNumberedFiles({ + roots: [join(root, 'src')], + cwd: root, + needles: extractedNeedles('@devup-ui/react', { + '@emotion/styled': 'styled', + }), + toId: (path) => path.slice(root.length).replaceAll('\\', '/'), + }) + expect(files).toEqual(['/src/a.tsx']) + expect(extractedNeedles('p', { a: 1, b: 2 })).toEqual([ + 'p', + '@stylexjs/stylex', + 'a', + 'b', + ]) + }) + it('does not depend on the order of the roots', () => { + const first = collectNumberedFiles({ + roots: [join(root, 'src'), join(root, 'node_modules/@acme/ui')], + cwd: root, + }) + const second = collectNumberedFiles({ + roots: [join(root, 'node_modules/@acme/ui'), join(root, 'src')], + cwd: root, + }) + expect(first).toEqual(second) + }) + + it.each([undefined, false, true])( + 'numbers project and included-package MDX only when includeMdx is true (%s)', + (includeMdx) => { + // Given source and package candidates with matching extraction needles. + write('src/docs/page.mdx') + write('node_modules/@acme/ui/Guide.mdx') + for (const path of [ + 'src/a.tsx', + 'src/docs/page.mdx', + 'node_modules/@acme/ui/Button.tsx', + 'node_modules/@acme/ui/Guide.mdx', + ]) { + fs.writeFileSync( + join(root, path), + "import '@devup-ui/react'\n# Raw MDX", + ) + } + + // When numbering uses the plugin's deterministic extraction IDs. + const files = collectNumberedFiles({ + roots: [join(root, 'src'), join(root, 'src/docs')], + include: ['@acme/ui'], + cwd: root, + needles: ['@devup-ui/react'], + ...(includeMdx === undefined ? {} : { includeMdx }), + toId: (path) => `id:${path.slice(root.length).replaceAll('\\', '/')}`, + }) + + // Then only explicit MDX extraction admits the raw MDX candidates. + expect(files).toEqual( + includeMdx + ? [ + 'id:/node_modules/@acme/ui/Button.tsx', + 'id:/node_modules/@acme/ui/Guide.mdx', + 'id:/src/a.tsx', + 'id:/src/docs/page.mdx', + ] + : ['id:/node_modules/@acme/ui/Button.tsx', 'id:/src/a.tsx'], + ) + }, + ) + + it('excludes raw MDX before reading source for extraction needles', () => { + // Given uncompiled MDX that remains visible to the standalone scanner. + write('src/page.mdx') + fs.writeFileSync( + join(root, 'src/page.mdx'), + "import '@devup-ui/react'\n# <", + ) + expect( + listSourceFiles(join(root, 'src'), [], { includeMdx: true }), + ).toContain(join(root, 'src/page.mdx')) + const read = spyOn(fs, 'readFileSync') + try { + // When a caller uses the default non-MDX extraction set. + const files = collectNumberedFiles({ + roots: [join(root, 'src')], + needles: ['@devup-ui/react'], + }) + + // Then no MDX source read occurs and no MDX ID is numbered. + expect(files).toEqual([]) + expect( + read.mock.calls.some(([path]) => path === join(root, 'src/page.mdx')), + ).toBe(false) + } finally { + read.mockRestore() + } + }) + + it('names files the way the plugin extracts them', () => { + const files = collectNumberedFiles({ + roots: [join(root, 'src')], + cwd: root, + toId: (path) => `id:${path.slice(root.length).replaceAll('\\', '/')}`, + }) + expect(files).toEqual([ + 'id:/src/a.tsx', + 'id:/src/b.tsx', + 'id:/src/nested/c.ts', + ]) + }) +}) + +describe('seedFileNumbers', () => { + it('seeds files, and nothing when there are none', () => { + const seedFileMap = mock() + seedFileNumbers({ seedFileMap }, []) + expect(seedFileMap).not.toHaveBeenCalled() + seedFileNumbers({ seedFileMap }, ['a']) + expect(seedFileMap).toHaveBeenCalledWith(['a']) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/oracle-scanner-fixtures.ts b/packages/plugin-utils/src/__tests__/oracle-scanner-fixtures.ts new file mode 100644 index 000000000..bcfb9e530 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/oracle-scanner-fixtures.ts @@ -0,0 +1,122 @@ +export const oracleScannerFixtures: readonly { + readonly code: string + readonly loader: 'js' | 'jsx' | 'ts' | 'tsx' + readonly edges: readonly string[] +}[] = [ + ...[ + "const x = a < import('./real') > (b);", + "const x = a < import('./real') > b;", + "const x = a < import('./real') > (import('./body'));", + "const x = a < import('./real') > (b) / 2;", + "const x = a < import('./real') > (b); const node =
import('phantom'){require('./required')}
;", + ].map((code) => ({ + code, + loader: code.includes('
') ? ('jsx' as const) : ('js' as const), + edges: code.includes('./required') + ? ['dynamic:./real', 'static:./required'] + : code.includes('./body') + ? ['dynamic:./real', 'dynamic:./body'] + : ['dynamic:./real'], + })), + ...[ + "type X = string\nfoo(import('./real'));", + "type X = string\nvoid import('./real');", + "type X = string\n(() => import('./real'))();", + "type X = import('./types').T\nfoo(import('./real'));", + "type X = {x: import('./types').T}\n(() => import('./real'))();", + "type X = string /* newline\n */ foo(import('./real'));", + "type X =\n import('./types').T\n | import('./types').U\nfoo(import('./real'));", + "type X = T\n & import('./types').T\nvoid import('./real');", + "type X = import('./types').T\n['x']\nfoo(import('./real'));", + "type X = T extends\n import('./types').T\n ? import('./types').U\n : import('./types').V\nfoo(import('./real'));", + "type X = (x: import('./types').T)\n => import('./types').U\nfoo(import('./real'));", + "type X = \n(x: T) => import('./types').U\nfoo(import('./real'));", + "type X = typeof import('./types')\nfoo(import('./real'));", + "type X = keyof\n import('./types').T\nvoid import('./real');", + "type X = new\n(x: import('./types').T) => import('./types').U\nfoo(import('./real'));", + "let x: import('./types').T\nfoo(import('./real'));", + "const x = value as import('./types').T\nvoid import('./real');", + "const x = foo! as import('./types').T; import('./real');", + "const x = foo! satisfies import('./types').T; import('./real');", + "const x = import('./real')! as import('./types').T;", + "const x = import('./real')! satisfies import('./types').T;", + "const x = !import('./real') as import('./types').T;", + "const x = 'text' as import('./types').T; import('./real');", + "const x = {} satisfies import('./types').T; import('./real');", + "const x = foo! / import('./real');", + "const x = !import('./real');", + "const x = import('./real') as import('./types').T;", + "declare function f(): import('./types').T; import('./real');", + "declare function f(): import('./types').T; import('./real');", + "declare function f>(x: import('./types').T): import('./types').T; import('./real');", + "declare function f(): import('./types').T\nimport('./real');", + "function f(): import('./types').T; function f() {return import('./real')}", + "function f(x: import('./types').T = import('./real')): import('./types').T {return import('./body')}", + "const f = (x = import('./real')): import('./types').T => import('./body');", + "const x = f(import('./real'));", + "const x = ok ? (f()) : import('./real');", + "const x = a < import('./real') > b;", + "const x = import('./real');", + "const x = value / import('./real');", + ].map((code) => ({ + code, + loader: 'ts' as const, + edges: code.includes('./body') + ? ['dynamic:./real', 'dynamic:./body'] + : ['dynamic:./real'], + })), + ...[ + "const x = foo! as import('./types').T;", + "const x = foo! satisfies import('./types').T;", + "declare function f(): import('./types').T;", + "declare function f>(): import('./types').T;", + "declare function f(): import('./types').T", + "const x = a < import('./types') > (b);", + "import { type as } from './types';", + "export { type as } from './types';", + "import { type as as as } from './types';", + "export { type as as as } from './types';", + "import { type as as renamed } from './types';", + "export { type as as renamed } from './types';", + "import {type A, type as,} from './types';", + "export {type as as 'renamed'} from './types';", + ].map((code) => ({ code, loader: 'ts' as const, edges: [] })), + ...[ + "async function f(){for await(const x of xs) /import('phantom')/.test(x); import('./real');}", + "async function f(){for /*marker*/ await /*next*/ (const x of xs) /require('phantom')/.test(x); import('./real');}", + "async function f(){for //marker\n await (const x of xs) /import('phantom')/.test(x); import('./real');}", + "async function f(){for await(const x of xs) import('./real');}", + "async function f(){for await(const x of xs) {import('./real');} /import('phantom')/.test(xs);}", + "async function f(){const x = await value / import('./real');}", + "async function f(){const x = await (value) / import('./real');}", + "async function f(){for(const x of xs) /import('phantom')/.test(x); import('./real');}", + "async function f(){for await(const x of import('./real')) /import('phantom')/.test(x);}", + "async function f(){for await(const x of xs) constCall(import('./real'));}", + ].map((code) => ({ code, loader: 'js' as const, edges: ['dynamic:./real'] })), + ...[ + "import {type as renamed} from './required';", + "export {type as renamed} from './required';", + "import {type as as} from './required';", + "export {type as as} from './required';", + "import {type} from './required';", + "import {from} from './required';", + "import {'type' as renamed} from './required';", + "export {type as 'as'} from './required';", + "import {type A, type as renamed} from './required';", + "export {type as, value} from './required';", + ].map((code) => ({ + code, + loader: 'ts' as const, + edges: ['static:./required'], + })), + { + code: "const f = (x = import('./real')) =>
import('phantom'){require('./required')}
;", + loader: 'tsx', + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const x = `import('phantom') ${
import('phantom'){import('./real')}
}`; import(name); require('./' + name);", + loader: 'jsx', + edges: ['dynamic:./real'], + }, +] diff --git a/packages/plugin-utils/src/__tests__/oracle-scanner-graph.test.ts b/packages/plugin-utils/src/__tests__/oracle-scanner-graph.test.ts new file mode 100644 index 000000000..65b1f624e --- /dev/null +++ b/packages/plugin-utils/src/__tests__/oracle-scanner-graph.test.ts @@ -0,0 +1,117 @@ +import { join } from 'node:path' + +import { expect, it, mock } from 'bun:test' + +import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph' +import { ConfigLoadError } from '../load-config' +import { oracleScannerFixtures } from './oracle-scanner-fixtures' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each( + oracleScannerFixtures.flatMap((fixture) => + [false, true].map((prepared) => ({ ...fixture, prepared })), + ), +)( + 'resolves independent real paths without poisoned false edges when $loader prepared=$prepared: $code', + async ({ code, loader, edges, prepared }) => { + // Given: real source-language filenames, live leaves, and poisoned phantom manifests. + const source = code.replaceAll('./types', 'phantom') + const entry = file(`src/main.${loader}`, source) + for (const name of ['real', 'required', 'body']) file(`src/${name}.ts`) + file('node_modules/phantom/package.json', '{') + const programRead = mock(() => ({ + type: 'ImportExpression', + source: { value: 'phantom' }, + })) + const parseSync = mock(() => ({ + errors: [], + get program() { + return programRead() + }, + })) + // When: scan raw or same-language prepared source with diagnostics disabled/injected. + const options = { cwd: root } + const preparedOptions = { + ...options, + prepareSource: (filename: string) => + filename === entry ? source : undefined, + } + __setOxcParserForTest(false) + const disabled = prepared + ? await buildStaticImportGraph('src', undefined, preparedOptions) + : buildStaticImportGraph('src', undefined, options) + __setOxcParserForTest({ parseSync }) + const injected = prepared + ? await buildStaticImportGraph('src', undefined, preparedOptions) + : buildStaticImportGraph('src', undefined, options) + // Then: full graph parity, independent expected paths, and zero AST authority. + expect(injected).toEqual(disabled) + for (const kind of ['static', 'dynamic'] as const) { + expect( + { static: injected.staticImports, dynamic: injected.dynamicImports }[ + kind + ].get(entry), + ).toEqual( + new Set( + edges + .filter((edge) => edge.startsWith(`${kind}:`)) + .map((edge) => + join(root, 'src', `${edge.slice(kind.length + 3)}.ts`), + ), + ), + ) + } + expect(injected.externalImports?.get(entry)).toEqual(new Set()) + expect(parseSync).toHaveBeenCalledTimes(injected.files.length) + expect(programRead).not.toHaveBeenCalled() + }, +) + +it('keeps JavaScript comparison operands under a real compiled Markdown filename', async () => { + // Given: compiled JSX under the original custom extension, never a renamed shim. + const entry = file('src/page.mdown', '# source') + const real = file('src/real.ts') + file('node_modules/phantom/package.json', '{') + const code = + "const x = a < import('./real') > (b); const node =
import('phantom')
;" + const programRead = mock(() => ({})) + const parseSync = mock(() => ({ + errors: [], + get program() { + return programRead() + }, + })) + const options = { + cwd: root, + includeMdx: ['.mdown'], + prepareSource: (filename: string) => + filename === entry ? code : undefined, + } + // When: traverse compiled JS/JSX with both optional diagnostic modes. + __setOxcParserForTest(false) + const disabled = await buildStaticImportGraph('src', undefined, options) + __setOxcParserForTest({ parseSync }) + const injected = await buildStaticImportGraph('src', undefined, options) + // Then: the real path and dynamic operand survive; quoted JSX text stays masked. + expect(injected).toEqual(disabled) + expect(injected.dynamicImports.get(entry)).toEqual(new Set([real])) + expect(injected.staticImports.get(entry)).toEqual(new Set()) + expect(parseSync).toHaveBeenCalledTimes(injected.files.length) + expect(programRead).not.toHaveBeenCalled() +}) + +it('reaches a poisoned manifest when an ambiguous type-named binding is a value', () => { + // Given: the value spelling adjacent to the type-only as countercases. + file('src/main.ts', "import {type as renamed} from 'phantom';") + file('node_modules/phantom/package.json', '{') + __setOxcParserForTest(false) + // When/Then: a genuine value dependency still reaches manifest validation. + expect(() => buildStaticImportGraph('src', undefined, { cwd: root })).toThrow( + ConfigLoadError, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/oracle-scanner.test.ts b/packages/plugin-utils/src/__tests__/oracle-scanner.test.ts new file mode 100644 index 000000000..4eacba67c --- /dev/null +++ b/packages/plugin-utils/src/__tests__/oracle-scanner.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' +import { oracleScannerFixtures } from './oracle-scanner-fixtures' + +it.each([...oracleScannerFixtures])( + 'preserves language-sensitive runtime edges when scanning $loader: $code', + ({ code, loader, edges }) => { + // Given: syntax-only compilation and independently specified dependencies. + new Bun.Transpiler({ loader }).transformSync(code) + // When: scan the original source in its actual language, without executing it. + const actual = scanImports( + code, + loader.endsWith('x'), + loader.startsWith('t'), + ).map(({ kind, specifier }) => `${kind}:${specifier}`) + // Then: lexical/type-only imports disappear and runtime operands survive. + expect(actual).toEqual([...edges]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts b/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts new file mode 100644 index 000000000..4411e9c0e --- /dev/null +++ b/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts @@ -0,0 +1,201 @@ +import { createHash } from 'node:crypto' +import * as fs from 'node:fs' +import { dirname, join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { resolvePackage } from '../owned-module-resolution' +import { createResolutionInputs } from '../resolution-inputs' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('reads the matched root manifest when a nested config has an unrelated manifest', () => { + // Given a native public lookup resolving below an absent nearer package directory. + const importer = file('nested/tsconfig.json', '{}') + const manifest = file('node_modules/preset/package.json', '{}') + file('node_modules/preset/configs/package.json', '{}') + file('node_modules/preset/configs/base.json', '{}') + const inputs = createResolutionInputs() + const reads: string[] = [] + const originalRead = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(target, receiver, args) { + reads.push(String(args[0])) + return Reflect.apply(target, receiver, args) + }, + }), + ) + // When recording public search-directory checks for the completed native lookup. + try { + expect( + resolvePackage('preset/configs/base.json', importer, { + purpose: 'tsconfig-extends', + inputs, + }), + ).toBe(join(root, 'node_modules/preset/configs/base.json')) + // Then the exact owned read excludes the nearer nested manifest and later search directories. + expect(reads).toEqual([manifest]) + expect(inputs.snapshot().fileDependencies).toEqual( + [manifest, join(root, 'node_modules/preset/configs/base.json')].sort(), + ) + expect(inputs.snapshot().missingDependencies).toContain( + join(root, 'nested/node_modules/preset/package.json'), + ) + } finally { + read.mockRestore() + } +}) + +it('records the lexical selecting manifest when a scoped package is linked', () => { + // Given a scoped package whose physical config lies outside node_modules. + const importer = file('nested/tsconfig.json', '{}') + const physical = file( + 'workspace/preset/package.json', + '{"exports":"./base.json"}', + ) + file('workspace/preset/base.json', '{}') + file('node_modules/@scope/placeholder.json', '{}') + fs.symlinkSync( + dirname(physical), + join(root, 'node_modules/@scope/preset'), + 'junction', + ) + const inputs = createResolutionInputs() + // When observing ownership using the already selected canonical path. + expect( + resolvePackage('@scope/preset', importer, { + purpose: 'tsconfig-extends', + inputs, + }), + ).toBe(join(root, 'workspace/preset/base.json')) + // Then evidence retains the matched lexical root rather than its physical location. + expect(inputs.snapshot().fileDependencies).toEqual( + [ + join(root, 'node_modules/@scope/preset/package.json'), + join(root, 'node_modules/@scope/preset/base.json'), + ].sort(), + ) + expect(inputs.snapshot().missingDependencies).toContain( + join(root, 'nested/node_modules/@scope/preset/package.json'), + ) +}) + +it('records a missing exact manifest when a manifestless deep file resolves', () => { + // Given a valid deep-file package without a root manifest. + const importer = file('nested/tsconfig.json', '{}') + file('node_modules/preset/base.json', '{}') + const inputs = createResolutionInputs() + // When observing the completed lookup without requiring a manifest to exist. + expect( + resolvePackage('preset/base.json', importer, { + purpose: 'tsconfig-extends', + inputs, + }), + ).toBe(join(root, 'node_modules/preset/base.json')) + // Then the absent root manifest is repairable without rejecting the valid config. + expect(inputs.snapshot().fileDependencies).toEqual([ + join(root, 'node_modules/preset/base.json'), + ]) + expect(inputs.snapshot().missingDependencies).toContain( + join(root, 'node_modules/preset/package.json'), + ) +}) + +it('skips an existing nearer package when it does not contain the selected file', () => { + // Given a nearer package missing the requested deep file and a matching outer package. + const importer = file('nested/tsconfig.json', '{}') + const nearer = file('nested/node_modules/preset/package.json', '{}') + const manifest = file('node_modules/preset/package.json', '{}') + file('node_modules/preset/..configs/base.json', '{}') + const inputs = createResolutionInputs() + // When recording the native selected file rather than guessing the nearest package. + expect( + resolvePackage('preset/..configs/base.json', importer, { + purpose: 'tsconfig-extends', + inputs, + }), + ).toBe(join(root, 'node_modules/preset/..configs/base.json')) + // Then containment recognizes a dot-prefixed child and excludes the unrelated manifest. + expect(inputs.snapshot().fileDependencies).toEqual( + [ + nearer, + manifest, + join(root, 'node_modules/preset/..configs/base.json'), + ].sort(), + ) + expect(inputs.snapshot().missingDependencies).toContain( + join(root, 'nested/node_modules/preset/..configs/base.json'), + ) +}) + +it('preserves the real read failure when the matched manifest cannot be read', () => { + // Given a resolved config and a failure at the owned manifest read boundary. + const importer = file('tsconfig.json', '{}') + const manifest = file('node_modules/preset/package.json', '{}') + file('node_modules/preset/base.json', '{}') + const failure = Object.assign(new Error('denied'), { + code: 'EACCES', + path: manifest, + }) + const original = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(original, { + apply(target, receiver, args) { + if (args[0] === manifest) throw failure + return Reflect.apply(target, receiver, args) + }, + }), + ) + const inputs = createResolutionInputs() + try { + // When the exact matched manifest read fails. + let caught: unknown + try { + resolvePackage('preset', importer, { + purpose: 'tsconfig-extends', + inputs, + }) + } catch (error) { + caught = error + } + // Then neither the exception nor its attempted input identity is replaced. + expect(caught).toBe(failure) + expect(inputs.snapshot().fileDependencies).toEqual([manifest]) + } finally { + read.mockRestore() + } +}) + +it('changes the observed content fingerprint when only selecting exports changes', () => { + // Given two preexisting configs and an observed selection, independent of native freshness. + const importer = file('tsconfig.json', '{}') + const manifest = file( + 'node_modules/preset/package.json', + '{"exports":"./red.json"}', + ) + file( + 'node_modules/preset/red.json', + '{"compilerOptions":{"paths":{"color":["red.ts"]}}}', + ) + file( + 'node_modules/preset/blue.json', + '{"compilerOptions":{"paths":{"color":["blue.ts"]}}}', + ) + const inputs = createResolutionInputs() + resolvePackage('preset', importer, { purpose: 'tsconfig-extends', inputs }) + const before = createHash('sha256') + for (const path of inputs.snapshot().fileDependencies) + before.update(fs.readFileSync(path)) + // When only the selecting manifest content changes. + fs.writeFileSync(manifest, '{"exports":"./blue.json"}') + const after = createHash('sha256') + for (const path of inputs.snapshot().fileDependencies) + after.update(fs.readFileSync(path)) + // Then unchanged input paths still expose the selecting-content change. + expect(after.digest('hex')).not.toBe(before.digest('hex')) +}) diff --git a/packages/plugin-utils/src/__tests__/parser-corpus.test.ts b/packages/plugin-utils/src/__tests__/parser-corpus.test.ts new file mode 100644 index 000000000..abdc1884a --- /dev/null +++ b/packages/plugin-utils/src/__tests__/parser-corpus.test.ts @@ -0,0 +1,183 @@ +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { extname, join, relative, resolve } from 'node:path' + +import { expect, it, mock } from 'bun:test' + +import { + __setOxcParserForTest, + buildStaticImportGraph, + listSourceFiles, +} from '../import-graph' +import { scanImports } from '../import-scanner' +import { importTypeFixtures } from './import-type-fixtures' +import { jsxBoundaryFixtures } from './jsx-boundary-fixtures' +import { oracleScannerFixtures } from './oracle-scanner-fixtures' + +it('keeps all package, landing and real compiled docs edges parser-independent', async () => { + // Given: actual repository sources and the landing compiler/provider configuration. + const cwd = resolve(import.meta.dir, '../../../..') + const projectRequire = createRequire(join(cwd, 'apps/landing/package.json')) + const compilerRequire = createRequire( + projectRequire.resolve('@mdx-js/loader'), + ) + const { + compile, + }: { + readonly compile: ( + input: { readonly path: string; readonly value: string | undefined }, + options: { readonly providerImportSource: string }, + ) => Promise<{ readonly value: string }> + } = compilerRequire('@mdx-js/mdx') + const roots = readdirSync(join(cwd, 'packages'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(cwd, 'packages', entry.name, 'src')) + const landing = join(cwd, 'apps/landing/src') + roots.push(landing) + const exclude = [ + '__tests__', + '__regression__', + '__fixtures__', + 'dist', + 'pkg', + 'df', + 'generated', + ] + const selection = { includeMdx: ['.mdx'] } + const files = [ + ...new Set( + roots.flatMap((root) => listSourceFiles(root, exclude, selection)), + ), + ].sort() + const code = new Map( + files.map((filename) => [filename, readFileSync(filename, 'utf8')]), + ) + const docs = files.filter((filename) => /\.mdx?$/.test(filename)) + for (const filename of docs) { + const compiled = await compile( + { path: filename, value: code.get(filename) }, + { providerImportSource: '@/mdx-components' }, + ) + code.set(filename, String(compiled)) + } + const options = { + cwd, + exclude, + ...selection, + prepareSource: (filename: string) => + /\.mdx?$/.test(filename) ? code.get(filename) : undefined, + } + const programRead = mock(() => ({ + type: 'ImportDeclaration', + source: { value: 'corpus-phantom' }, + })) + const parseSync = mock((filename: string, source: string) => { + expect(code.get(filename)).toBe(source) + return { + errors: [], + get program() { + return programRead() + }, + } + }) + try { + // When: run the real graph/resolution path both without and with optional diagnostics. + __setOxcParserForTest(false) + const disabled = await buildStaticImportGraph(roots, undefined, options) + __setOxcParserForTest({ parseSync }) + const injected = await buildStaticImportGraph(roots, undefined, options) + // Then: compare every edge, not just aggregate counts; prove the optional path ran. + expect(injected).toEqual(disabled) + expect(injected.files).toEqual(files) + expect(parseSync.mock.calls.map(([filename]) => filename).sort()).toEqual( + files, + ) + expect(programRead).not.toHaveBeenCalled() + const packages = files.filter((filename) => !filename.startsWith(landing)) + const app = files.filter( + (filename) => filename.startsWith(landing) && !docs.includes(filename), + ) + expect(packages.length).toBeGreaterThan(0) + expect(app.length).toBeGreaterThan(0) + expect(docs.length).toBeGreaterThan(0) + for (const filename of docs) { + expect(disabled.externalImports?.get(filename)).toContain( + 'react/jsx-runtime', + ) + expect(disabled.externalImports?.get(filename)).toContain( + '@/mdx-components', + ) + } + const entries = files.map((filename) => ({ + file: relative(cwd, filename).replaceAll('\\', '/'), + extension: extname(filename), + static: [...(disabled.staticImports.get(filename) ?? [])] + .map((target) => relative(cwd, target).replaceAll('\\', '/')) + .sort(), + dynamic: [...(disabled.dynamicImports.get(filename) ?? [])] + .map((target) => relative(cwd, target).replaceAll('\\', '/')) + .sort(), + external: [...(disabled.externalImports?.get(filename) ?? [])].sort(), + })) + const artifact = { + roots: roots.map((root) => relative(cwd, root).replaceAll('\\', '/')), + exclude, + filter: + 'JS/TS source extensions plus .mdx; test/spec files and node_modules omitted', + compiler: + '@mdx-js/mdx via landing @mdx-js/loader; providerImportSource=@/mdx-components; no remark/rehype plugins', + counts: { + packageSources: packages.length, + landingSources: app.length, + compiledDocs: docs.length, + total: files.length, + diagnosticCalls: parseSync.mock.calls.length, + programReads: programRead.mock.calls.length, + staticEdges: entries.reduce( + (sum, entry) => sum + entry.static.length, + 0, + ), + dynamicEdges: entries.reduce( + (sum, entry) => sum + entry.dynamic.length, + 0, + ), + externalEdges: entries.reduce( + (sum, entry) => sum + entry.external.length, + 0, + ), + }, + parity: true, + targeted: jsxBoundaryFixtures.map(({ code, jsx, edges }) => { + const actual = scanImports(code, jsx).map( + ({ kind, specifier }) => `${kind}:${specifier}`, + ) + expect(actual).toEqual([...edges]) + return { code, jsx, expected: edges, actual } + }), + typeTargeted: importTypeFixtures.map(({ code, jsx, edges }) => { + const actual = scanImports(code, jsx).map( + ({ kind, specifier }) => `${kind}:${specifier}`, + ) + expect(actual).toEqual([...edges]) + return { code, jsx, expected: edges, actual } + }), + oracleTargeted: oracleScannerFixtures.map(({ code, loader, edges }) => { + const actual = scanImports( + code, + loader.endsWith('x'), + loader.startsWith('t'), + ).map(({ kind, specifier }) => `${kind}:${specifier}`) + expect(actual).toEqual([...edges]) + return { code, loader, expected: edges, actual } + }), + entries, + } + if (process.env.DEVUP_SCANNER_CORPUS_ARTIFACT) + writeFileSync( + process.env.DEVUP_SCANNER_CORPUS_ARTIFACT, + JSON.stringify(artifact, null, 2), + ) + } finally { + __setOxcParserForTest(undefined) + } +}, 30000) diff --git a/packages/plugin-utils/src/__tests__/parser-independent.test.ts b/packages/plugin-utils/src/__tests__/parser-independent.test.ts new file mode 100644 index 000000000..6a601b968 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/parser-independent.test.ts @@ -0,0 +1,72 @@ +import { expect, it, mock } from 'bun:test' + +import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each([false, true])( + 'discovers the real template interpolation import with parser enabled %s', + (enabled) => { + // Given: valid source and a real leaf; the injected AST describes the prior divergence. + const entry = file( + 'src/main.ts', + "export const result = `${import('./leaf')}`", + ) + const leaf = file('src/leaf.ts') + const parseSync = mock(() => ({ + program: { type: 'ImportExpression', source: { value: './leaf' } }, + errors: [], + })) + __setOxcParserForTest(enabled ? { parseSync } : false) + // When: build the real filesystem graph. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then: interpolation code loads the leaf regardless of parser presence. + expect(graph.dynamicImports.get(entry)).toEqual(new Set([leaf])) + if (enabled) expect(parseSync).toHaveBeenCalledTimes(2) + }, +) + +it.each([false, true])( + 'ignores contradictory AST edges with optional diagnostics %s', + (enabled) => { + // Given: literal code edges and poisoned textual/AST dependencies. + const entry = file( + 'src/main.tsx', + [ + 'export * from "./static"; export {value} from "./reexport"; import "./side-effect";', + 'const value = `text import("phantom") ${import("./dynamic")} ${require("./required")}`;', + 'const nested = `${ `inner ${import("./nested")}` }`;', + 'const regex = /[`"\']import("phantom")/; const division = 1 / import("./division");', + 'const jsx =
import("phantom"){require("./attribute")}
;', + '/* import("phantom") */ import(name); require(name); import("phantom" + name);', + ].join('\n'), + ) + const staticTargets = [ + 'static', + 'reexport', + 'side-effect', + 'required', + 'attribute', + ].map((name) => file(`src/${name}.ts`)) + const dynamicTargets = ['dynamic', 'nested', 'division'].map((name) => + file(`src/${name}.ts`), + ) + file('node_modules/phantom/package.json', '{') + const parseSync = mock(() => ({ + errors: [], + program: { type: 'ImportExpression', source: { value: 'phantom' } }, + })) + __setOxcParserForTest(enabled ? { parseSync } : false) + // When: resolve the fixture through the actual graph path. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then: expected paths come only from the fixture's real literal dependencies. + expect(graph.staticImports.get(entry)).toEqual(new Set(staticTargets)) + expect(graph.dynamicImports.get(entry)).toEqual(new Set(dynamicTargets)) + expect(graph.externalImports?.get(entry)).toEqual(new Set()) + expect(parseSync).toHaveBeenCalledTimes(enabled ? graph.files.length : 0) + }, +) diff --git a/packages/plugin-utils/src/__tests__/prepared-compiler.test.ts b/packages/plugin-utils/src/__tests__/prepared-compiler.test.ts new file mode 100644 index 000000000..3b9e66b74 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-compiler.test.ts @@ -0,0 +1,221 @@ +import { createRequire } from 'node:module' +import { resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { + __setOxcParserForTest, + buildStaticImportGraph, + computeCompiledFiles, +} from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +const projectRequire = createRequire(resolve('apps/landing/package.json')) +const compilerRequire = createRequire(projectRequire.resolve('@mdx-js/loader')) +const { compile } = compilerRequire('@mdx-js/mdx') +const { SourceMapGenerator, SourceMapConsumer } = compilerRequire('source-map') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each([false, true])( + 'scans real compiled MDX rather than fenced examples (async %s)', + async (asyncHook) => { + const markdown = [ + "import './real.js'", + '', + '# Heading', + '', + '```js', + "import './missing'", + "import { Box } from 'fake-package'", + "export {Box} from './missing'", + "require('./missing')", + "import('./missing')", + '```', + ].join('\n') + const entry = file('src/page.mdx', markdown) + file('node_modules/fake-package/package.json', '{') + const real = file('src/real.js') + const compiled = await compile({ value: markdown, path: entry }) + const hook = (filename: string) => + filename === entry + ? { code: String(compiled), map: compiled.map } + : undefined + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: asyncHook ? async (filename) => hook(filename) : hook, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([real])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set()) + }, +) + +it('rejects compiler errors with the actual Markdown filename and original cause', async () => { + const entry = file('src/page.mdx', '<') + let compilerCause: unknown + const promise = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: async (filename) => { + try { + return String(await compile({ value: '<', path: filename })) + } catch (cause) { + compilerCause = cause + throw cause + } + }, + }) + const error = await promise.catch((cause: unknown) => cause) + expect(error).toBeInstanceOf(Error) + if (!(error instanceof Error)) throw new Error('Expected compiler error') + expect(error.message).toContain(entry) + expect(error.cause).toBe(compilerCause) +}) + +it('selects and compiles a project-defined .mdown extension in scanner, graph and numbering', async () => { + const markdown = + "import './leaf'\n\n# Custom Markdown\n\n```js\nimport './missing'\n```" + const entry = file('src/page.mdown', markdown) + const leaf = file('src/leaf.mdown', '# Leaf') + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: ['.mdown'], + prepareSource: async (filename) => { + return String( + await compile( + { value: filename === entry ? markdown : '# Leaf', path: filename }, + { format: 'mdx' }, + ), + ) + }, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) + expect(computeCompiledFiles({ cwd: root, srcDir: 'src', graph })).toEqual([ + 'src/leaf.mdown', + 'src/page.mdown', + ]) +}) + +it('follows a real compiler-injected project-root provider and prepares included dependencies once', async () => { + const entry = file('src/page.mdx', '# Heading') + const provider = file('mdx-components.js', "import 'aliased-library'") + const remark = file('remark-extra.js') + const rehype = file('rehype-extra.js') + file('node_modules/actual/package.json', '{"main":"index.js"}') + const library = file('node_modules/actual/index.js', "import './leaf.config'") + const leaf = file('node_modules/actual/leaf.config.mjs') + const visits: string[] = [] + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + include: ['actual'], + alias: { + provider, + 'aliased-library': 'actual', + 'remark-extra': remark, + 'rehype-extra': rehype, + }, + prepareSource: async (filename) => { + visits.push(filename) + if (filename !== entry) return undefined + const inject = + (specifier: string) => () => (tree: { children: unknown[] }) => { + tree.children.unshift({ + type: 'mdxjsEsm', + value: `import '${specifier}'`, + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ImportDeclaration', + specifiers: [], + source: { type: 'Literal', value: specifier }, + }, + ], + }, + }, + }) + } + const compiled = await compile( + { value: '# Heading', path: filename }, + { + providerImportSource: 'provider', + SourceMapGenerator, + remarkPlugins: [inject('remark-extra')], + rehypePlugins: [inject('rehype-extra')], + }, + ) + return { code: String(compiled), map: compiled.map } + }, + }) + expect(graph.files).toEqual( + [entry, provider, library, leaf, remark, rehype].sort(), + ) + expect(visits.sort()).toEqual(graph.files) +}) + +it('remaps available-parser diagnostic positions through a real MDX compiler map', async () => { + const entry = file('src/page.mdx', '# Heading') + const compiled = await compile( + { value: '# Heading', path: entry }, + { SourceMapGenerator }, + ) + expect(compiled.map).toBeDefined() + const positions: { + line: number + column: number + originalLine: number + originalColumn: number + }[] = [] + await SourceMapConsumer.with( + compiled.map, + null, + (consumer: { + eachMapping: ( + callback: (mapping: { + generatedLine: number + generatedColumn: number + originalLine: number + originalColumn: number + }) => void, + ) => void + }) => { + consumer.eachMapping((mapping) => { + if (mapping.originalLine) + positions.push({ + line: mapping.generatedLine, + column: mapping.generatedColumn + 1, + originalLine: mapping.originalLine, + originalColumn: mapping.originalColumn + 1, + }) + }) + }, + ) + const position = positions[0] + if (!position) throw new Error('Real compiler map has no original mappings') + __setOxcParserForTest({ + parseSync: () => ({ + errors: [ + { + message: 'available parser fixture', + line: position.line, + column: position.column, + }, + ], + }), + }) + await expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => ({ code: String(compiled), map: compiled.map }), + }), + ).rejects.toThrow( + `${entry}:${position.originalLine}:${position.originalColumn}`, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/prepared-diagnostics.test.ts b/packages/plugin-utils/src/__tests__/prepared-diagnostics.test.ts new file mode 100644 index 000000000..26a7fcaf9 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-diagnostics.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from 'bun:test' + +import { preparedDiagnostics } from '../prepared-diagnostics' + +it('formats label-less diagnostic fixtures and unknown diagnostic values', () => { + expect( + preparedDiagnostics('file.mdx', '', ['failure', { message: 'empty' }]), + ).toBe('file.mdx:1:1: failure\nfile.mdx:1:1: empty') +}) + +it('uses the first compiled position when a diagnostic label has no offset', () => { + expect( + preparedDiagnostics('file.mdx', '', [ + { message: 'label', labels: [null, {}] }, + ]), + ).toBe('file.mdx:1:1: label') +}) diff --git a/packages/plugin-utils/src/__tests__/prepared-graph-fixture.ts b/packages/plugin-utils/src/__tests__/prepared-graph-fixture.ts new file mode 100644 index 000000000..33504a0fd --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-graph-fixture.ts @@ -0,0 +1,39 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach } from 'bun:test' + +import { __setOxcParserForTest } from '../import-graph' + +export function createPreparedFixture(setRoot: (root: string) => void) { + let root: string + beforeEach(() => { + const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared-api', + ) + mkdirSync(parent, { recursive: true }) + root = realpathSync.native(mkdtempSync(join(parent, 'prepared-'))) + setRoot(root) + }) + afterEach(() => { + __setOxcParserForTest(undefined) + rmSync(root, { recursive: true, force: true }) + }) + return (path: string, source = 'export {}'): string => { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, source) + return target + } +} diff --git a/packages/plugin-utils/src/__tests__/prepared-graph.test.ts b/packages/plugin-utils/src/__tests__/prepared-graph.test.ts new file mode 100644 index 000000000..4ba045459 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-graph.test.ts @@ -0,0 +1,214 @@ +import { expect, it } from 'bun:test' + +import { + __setOxcParserForTest, + buildStaticImportGraph, + computeCompiledFiles, + createModuleResolver, +} from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('reads raw source only for undefined, not for an empty prepared string', async () => { + const entry = file('src/page.ts', "import './real'") + const real = file('src/real.ts') + const raw = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => undefined, + }) + const empty = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => '', + }) + expect(raw.staticImports.get(entry)).toEqual(new Set([real])) + expect(empty.staticImports.get(entry)).toEqual(new Set()) +}) + +it.each([false, true])( + 'wraps located preparer throws/rejections (async %s)', + async (asyncHook) => { + const entry = file('src/main.ts') + const cause = Object.assign(new Error('Compiler failed'), { + line: 3, + column: 4, + }) + const hook = () => { + throw cause + } + const promise = buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: asyncHook ? async () => hook() : hook, + }) + expect(promise).toBeInstanceOf(Promise) + const error = await promise.catch((failure: unknown) => failure) + if (!(error instanceof Error)) throw new Error('Expected error') + expect(error.message).toBe( + `${entry}:3:4 (in compiled output): Graph source preparation failed: Compiler failed`, + ) + expect(error.cause).toBe(cause) + }, +) + +it('turns asynchronous setup faults into Promise rejections', async () => { + const config = file('tsconfig.json', '{') + const promise = buildStaticImportGraph('src', config, { + cwd: root, + prepareSource: () => '', + }) + expect(promise).toBeInstanceOf(Promise) + await expect(promise).rejects.toThrow(config) +}) + +it.each([ + undefined, + { version: 3, sources: ['original.mdx'], names: [], mappings: 'AAAA;AACA' }, +])('reports every available parser diagnostic with map %j', async (map) => { + const entry = file('src/page.mdx') + __setOxcParserForTest({ + parseSync: () => ({ + errors: [ + { message: 'first', line: 1, column: 1 }, + { message: 'second', line: 2, column: 1 }, + ], + }), + }) + const promise = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => ({ code: 'compiled', map }), + }) + const error = await promise.catch((cause: unknown) => cause) + if (!(error instanceof Error)) throw new Error('Expected diagnostics') + expect(error.message).toContain( + map ? 'original.mdx:1:1' : `${entry}:1:1 (in compiled output)`, + ) + expect(error.message).toContain( + map ? 'original.mdx:2:1' : `${entry}:2:1 (in compiled output)`, + ) + if (map) expect(error.message).not.toContain('(in compiled output)') +}) + +it('does not fall back to raw reads or lexical scanning after a prepared parser throws', async () => { + const entry = file('src/page.mdx', "import './raw'") + const cause = new Error(`${entry}:2:3: parse failed`) + __setOxcParserForTest({ + parseSync: () => { + throw cause + }, + }) + await expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => "import './compiled'", + }), + ).rejects.toThrow(`${entry}:2:3 (in compiled output)`) +}) + +it('uses real prepared source rather than contradictory AST edges', async () => { + const entry = file('src/page.mdx') + const leaf = file('src/leaf.ts') + const phantom = file('src/phantom.ts') + __setOxcParserForTest({ + parseSync: () => ({ + type: 'ImportDeclaration', + source: { value: './phantom' }, + }), + }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: (filename) => + filename === entry ? "import './leaf'" : undefined, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) + expect(graph.staticImporters.get(phantom)).toEqual(new Set()) +}) + +it('reads diagnostic getters without consulting the available-parser program getter', async () => { + const entry = file('src/page.mdx') + const leaf = file('src/leaf.ts') + const result = Object.create({ + get program() { + throw new Error('AST must not decide edges') + }, + get errors() { + return [] + }, + }) + __setOxcParserForTest({ parseSync: () => result }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: (filename) => + filename === entry ? "import './leaf'" : undefined, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) +}) + +it('reports every documented available-parser diagnostic label and remaps each position', async () => { + const entry = file('src/page.mdx') + __setOxcParserForTest({ + parseSync: () => ({ + errors: [ + { + message: 'labels', + labels: [ + { start: 0, end: 1, message: 'first' }, + { start: 2, end: 3, message: null }, + ], + }, + ], + }), + }) + const map = { + version: 3, + sources: ['original.mdx'], + names: [], + mappings: 'AAAA;AACA', + } + const error = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => ({ code: 'a\nb', map }), + }).catch((cause: unknown) => cause) + if (!(error instanceof Error)) throw new Error('Expected diagnostics') + expect(error.message).toContain('original.mdx:1:1') + expect(error.message).toContain('original.mdx:2:1') + expect(error.message).not.toContain(`${entry}:`) + expect(error.message).not.toContain('(in compiled output)') +}) + +it('selects Markdown routes and extensionless imports only with exact opt-in', () => { + const entry = file('src/page.md', "import './leaf'\n\n# Heading") + const leaf = file('src/leaf.md', '# Leaf') + file('src/other.mdx') + expect(() => createModuleResolver({ cwd: root })('./leaf.md', entry)).toThrow( + leaf, + ) + expect(createModuleResolver({ cwd: root })('./leaf', entry)).toBeUndefined() + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: ['.md'], + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) + expect(computeCompiledFiles({ cwd: root, srcDir: 'src', graph })).toEqual([ + 'src/leaf.md', + 'src/page.md', + ]) +}) + +it('rejects unprepared legacy extensionless MDX while the graph remains default-off', () => { + const entry = file('src/main.ts', "import './page'") + const page = file('src/page.mdx', '# Heading') + expect(() => createModuleResolver({ cwd: root })('./page', entry)).toThrow( + page, + ) + expect(buildStaticImportGraph('src', undefined, { cwd: root }).files).toEqual( + [entry], + ) +}) diff --git a/packages/plugin-utils/src/__tests__/prepared-resolver-wasm.test.ts b/packages/plugin-utils/src/__tests__/prepared-resolver-wasm.test.ts new file mode 100644 index 000000000..7deb84897 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-resolver-wasm.test.ts @@ -0,0 +1,206 @@ +import { createRequire } from 'node:module' +import { relative, resolve } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import * as wasm from '../../../../bindings/devup-ui-wasm/pkg' +import { + buildStaticImportGraph, + createModuleResolver, + type PreparedSource, +} from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +const projectRequire = createRequire(resolve('apps/landing/package.json')) +const compilerRequire = createRequire(projectRequire.resolve('@mdx-js/loader')) +const { compile } = compilerRequire('@mdx-js/mdx') +const { SourceMapGenerator, SourceMapConsumer } = compilerRequire('source-map') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) +let debug: boolean +beforeEach(() => { + debug = wasm.isDebug() + wasm.resetBuildState() + wasm.setDebug(false) +}) +afterEach(() => { + wasm.setModuleResolver(undefined) + wasm.resetBuildState() + wasm.registerTheme({}) + wasm.setDebug(debug) +}) + +function extract(filename: string, source: string) { + const output = wasm.codeExtract( + filename, + source, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + try { + return { code: output.code, css: wasm.getCss(undefined, false) } + } finally { + output.free() + } +} + +it.each(['absolute', 'relative'])( + 'uses actual compiled MDX constants like TS constants (%s ids)', + async (mode) => { + const markdown = "export const PRIMARY = 'tomato'\n\n# Heading" + const module = file('src/value.mdx', markdown) + file('src/plain.ts', "export const PRIMARY = 'tomato'") + const entry = file( + 'src/main.tsx', + "import {PRIMARY} from './value.mdx'; import {Box} from '@devup-ui/react'; export const view = ;", + ) + const compiled = await compile( + { value: markdown, path: module }, + { SourceMapGenerator }, + ) + const cache = new Map([ + [module, { code: String(compiled), map: compiled.map }], + ]) + const prepareSource = (filename: string) => cache.get(filename) + const toId = (filename: string) => + (mode === 'absolute' ? filename : relative(root, filename)).replaceAll( + '\\', + '/', + ) + const resolver = createModuleResolver({ cwd: root, prepareSource, toId }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([module])) + expect(resolver('./value.mdx', toId(entry))?.code).toBe(String(compiled)) + wasm.setModuleResolver(resolver) + const prepared = extract( + toId(entry), + "import {PRIMARY} from './value.mdx'; import {Box} from '@devup-ui/react'; export const view = ;", + ) + wasm.resetBuildState() + wasm.setModuleResolver(resolver) + const ordinary = extract( + toId(entry), + "import {PRIMARY} from './plain'; import {Box} from '@devup-ui/react'; export const view = ;", + ) + expect(prepared.css).toBe(ordinary.css) + expect(prepared.css).toContain('color:tomato') + expect(prepared.code).not.toContain('--') + }, +) + +it.each(['raw', 'promise'])( + 'rejects %s imported Markdown through the actual WASM callback', + (mode) => { + const module = file( + 'src/value.mdx', + "export const PRIMARY = 'tomato'\n\n# Heading", + ) + const entry = file('src/main.tsx') + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => + mode === 'promise' ? Promise.resolve('') : undefined, + }) + wasm.setModuleResolver(resolver) + try { + extract( + entry, + "import {PRIMARY} from './value.mdx'; import {Box} from '@devup-ui/react'; export const view = ;", + ) + } catch (cause) { + const error = resolver.remapError(cause) + expect(error.message).toContain(`${entry}:1:1`) + expect(error.message).toContain(module) + expect(error.cause).toBe(cause) + return + } + throw new Error('Expected raw/preparation rejection') + }, +) + +it('leaves the real WASM importing-site error untouched after prepared-module evaluation', async () => { + const markdown = "export const bad = () => 'red'\n\n# Heading" + const module = file('src/value.mdx', markdown) + const entry = file('src/main.tsx') + const compiled = await compile( + { value: markdown, path: module }, + { SourceMapGenerator }, + ) + const resolver = createModuleResolver({ + cwd: root, + prepareSource: (filename) => + filename === module + ? { code: String(compiled), map: compiled.map } + : undefined, + }) + wasm.setModuleResolver(resolver) + try { + extract( + entry, + "import {bad} from './value.mdx'; import {css} from '@devup-ui/react'; export const style = css({color:bad});", + ) + } catch (cause) { + const error = resolver.remapError(cause) + const original = cause instanceof Error ? cause.message : String(cause) + expect(error.message).toBe(original) + expect(error.cause).toBe(cause) + expect(resolver.remapError(error)).toBe(error) + expect(error.message).toContain(`${entry}:1:`) + return + } + throw new Error('Expected imported extraction failure') +}) + +it('remaps synthetic extractor-format diagnostics through a real MDX compiler map', async () => { + const markdown = "export const PRIMARY = 'tomato'\n\n# Heading" + const module = file('src/value.mdx', markdown) + const compiled = await compile( + { value: markdown, path: module }, + { SourceMapGenerator }, + ) + const resolver = createModuleResolver({ + cwd: root, + toId: () => 'src/value.mdx', + prepareSource: () => ({ code: String(compiled), map: compiled.map }), + }) + resolver(module, 'main.ts') + await SourceMapConsumer.with( + compiled.map, + null, + (consumer: { + eachMapping( + callback: (mapping: { + generatedLine: number + generatedColumn: number + originalLine: number + originalColumn: number + source: string + }) => void, + ): void + }) => { + const positions: string[] = [] + consumer.eachMapping((mapping) => { + if (mapping.originalLine) { + const error = resolver.remapError( + `src/value.mdx:${mapping.generatedLine}:${mapping.generatedColumn + 1}: diagnostic`, + ) + expect(error.message).toBe( + `${module}:${mapping.originalLine}:${mapping.originalColumn + 1}: diagnostic`, + ) + positions.push(error.message) + } + }) + expect(positions.length).toBeGreaterThan(0) + }, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/prepared-resolver.test.ts b/packages/plugin-utils/src/__tests__/prepared-resolver.test.ts new file mode 100644 index 000000000..6d246e25e --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-resolver.test.ts @@ -0,0 +1,177 @@ +import { resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each(['.mdx', '.md', '.mdown'])( + 'rejects unprepared %s at the importer', + (extension) => { + const module = file(`src/value${extension}`, '# Raw') + const resolver = createModuleResolver({ cwd: root, includeMdx: ['.mdown'] }) + expect(() => resolver(module, 'src/main.tsx')).toThrow( + `${resolve(root, 'src/main.tsx')}:1:1`, + ) + expect(() => resolver(module, 'src/main.tsx')).toThrow(module) + }, +) + +it.each([ + Promise.resolve(''), + { + code: '', + then() { + throw new Error('must not invoke') + }, + }, + Object.assign(() => {}, { code: '', then() {} }), +])('rejects a thenable without awaiting or invoking it', (value) => { + const module = file('src/value.mdx') + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => value, + }) + expect(() => resolver(module, 'src/main.tsx')).toThrow( + 'prepare Markdown before extraction', + ) +}) + +it('preserves a hook exception and the real aliased module before toId', () => { + const module = file('src/value.mdx') + const cause = new Error('compiler failed') + const resolver = createModuleResolver({ + cwd: root, + alias: { value: module }, + toId: () => { + throw new Error('too early') + }, + prepareSource: (filename) => { + expect(filename).toBe(module) + throw cause + }, + }) + try { + resolver('value', 'src/main.tsx') + } catch (error) { + if (!(error instanceof Error)) throw error + expect(error.cause).toBe(cause) + expect(error.message).toContain(module) + return + } + throw new Error('Expected preparation failure') +}) + +it('accepts empty preparation and reads ordinary undefined sources', () => { + const module = file('src/value.mdx', '# Raw') + const ordinary = file('src/plain.ts', 'export const value = 1') + const resolver = createModuleResolver({ + cwd: root, + prepareSource: (filename) => (filename === module ? '' : undefined), + }) + expect(resolver(module, 'main.ts')).toEqual({ path: module, code: '' }) + expect(resolver(ordinary, 'main.ts')?.code).toBe('export const value = 1') + expect(resolver('./missing', 'main.ts')).toBeUndefined() +}) + +it('remaps every exact id occurrence once, leaving other filenames alone', () => { + const module = file('src/value.mdx') + const other = file('src/other.mdx') + const id = 'src/[value]+(x).mdx' + const map = { version: 3, sources: [id], mappings: 'AAKA', names: [] } + const resolver = createModuleResolver({ + cwd: root, + toId: (filename) => (filename === module ? id : 'other.mdx'), + prepareSource: (filename) => ({ + code: '', + map: filename === module ? map : undefined, + }), + }) + resolver(module, 'main.ts') + resolver(other, 'main.ts') + const original = new Error( + `${id}:1:1: bad\nsee ${id}:1:1 and other.mdx:2:3: bad\nprefix-${id}:1:1: untouched\nother.ts:1:1: untouched`, + ) + const error = resolver.remapError(original) + expect(error.message).toBe( + `${id}:6:1: bad\nsee ${id}:6:1 and ${other}:2:3 (in compiled output): bad\nprefix-${id}:1:1: untouched\nother.ts:1:1: untouched`, + ) + expect(error.cause).toBe(original) + expect(resolver.remapError(error)).toBe(error) + expect(createModuleResolver({ cwd: root }).remapError(original).message).toBe( + original.message, + ) +}) + +it('replaces maps for the same id and deletes a no-longer-prepared generation', () => { + const module = file('src/value.ts') + let generation: { code: string; map?: unknown } | undefined = { + code: '', + map: { version: 3, sources: [module], mappings: 'AAKA' }, + } + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => generation, + }) + resolver(module, 'main.ts') + expect(resolver.remapError(`${module}:1:1: bad`).message).toBe( + `${module}:6:1: bad`, + ) + generation = { + code: '', + map: { version: 3, sources: [module], mappings: 'AAQA' }, + } + resolver(module, 'main.ts') + expect(resolver.remapError(`${module}:1:1: bad`).message).toBe( + `${module}:9:1: bad`, + ) + generation = undefined + resolver(module, 'main.ts') + expect(resolver.remapError(`${module}:1:1: bad`).message).toBe( + `${module}:1:1: bad`, + ) +}) + +it('discards stale Markdown mappings when preparation disappears', () => { + const module = file('src/value.mdx') + let prepared: string | undefined = '' + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => prepared, + }) + resolver(module, 'main.ts') + prepared = undefined + expect(() => resolver(module, 'main.ts')).toThrow(module) + expect(resolver.remapError(`${module}:1:1: bad`).message).toBe( + `${module}:1:1: bad`, + ) +}) + +it('keeps exact Windows ids, uncovered positions and non-Error hook causes', () => { + const module = file('src/value.mdx') + const id = 'C:\\folder\\[value]+.mdx' + const resolver = createModuleResolver({ + cwd: root, + toId: () => id, + prepareSource: () => ({ + code: '', + map: { version: 3, sources: [module], mappings: 'AAAA' }, + }), + }) + resolver(module, 'main.ts') + expect( + resolver.remapError(`${id}:2:1: bad\nlong-${id}:1:1: other`).message, + ).toBe(`${module}:2:1 (in compiled output): bad\nlong-${id}:1:1: other`) + const failed = createModuleResolver({ + cwd: root, + prepareSource: () => { + throw { toString: () => 'compiler not ready' } + }, + }) + expect(() => failed(module, 'main.ts')).toThrow('compiler not ready') +}) diff --git a/packages/plugin-utils/src/__tests__/request-evidence.test.ts b/packages/plugin-utils/src/__tests__/request-evidence.test.ts new file mode 100644 index 000000000..93f2364c5 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/request-evidence.test.ts @@ -0,0 +1,239 @@ +import * as fs from 'node:fs' +import { dirname, join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { buildStaticImportGraph, importGraphFailureOf } from '../index' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('retains four request kinds, written escapes and duplicate token positions', () => { + // Given distinct literal occurrences resolving to the same module. + const source = String.raw`import './\x61'; +export * from './a'; +import('./a'); require('./a'); import './a';` + const entry = file('src/page.ts', source) + const leaf = file('src/a.ts') + // When building the public graph. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then every occurrence survives with genuine quote-token coordinates. + expect(graph.requests).toEqual( + [ + ['static-import', './\\x61', 7, 1, 8], + ['re-export', './a', 31, 2, 15], + ['literal-dynamic-import', './a', 45, 3, 8], + ['literal-require', './a', 61, 3, 24], + ['static-import', './a', 76, 3, 39], + ].map(([kind, request, offset, line, column]) => ({ + importer: entry, + kind, + request, + specifier: './a', + position: { offset, line, column }, + source: 'source', + outcome: { + kind: 'resolved', + path: leaf, + inputs: { + fileDependencies: [leaf], + missingDependencies: [ + join(root, 'src/a'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }, + })), + ) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set([leaf])) + expect(Object.isFrozen(graph.requests)).toBe(true) + expect( + graph.requests.every( + (request) => + Object.isFrozen(request) && + Object.isFrozen(request.position) && + Object.isFrozen(request.outcome), + ), + ).toBe(true) +}) + +it('reports external, unresolved and ignored boundaries without inventing files', () => { + // Given requests that legacy traversal does not follow. + const entry = file( + 'src/main.ts', + "import 'external'; import './missing'; require('ignored')", + ) + // When resolving an explicit native false alias. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { ignored: false }, + }) + // Then the three successful observations are distinct. + expect(graph.requests.map(({ outcome }) => outcome)).toEqual([ + { kind: 'external', request: 'external' }, + { kind: 'unresolved' }, + { kind: 'ignored' }, + ]) + expect(graph.files).toEqual([entry]) + expect(graph.externalImports?.get(entry)).toEqual(new Set(['external'])) +}) + +it.each(['bare', 'absolute', 'nested', 'alias', 'package', 'directory-entry'])( + 'retains the actual matched exclusion entry for %s', + (mode) => { + // Given a poisoned target that must never be read or followed. + const poison = file('blocked/nested/index.ts', "import 'bad'") + file('node_modules/blocked/package.json', '{') + const exclusion = + mode === 'bare' || mode === 'nested' ? 'blocked' : join(root, 'blocked') + const request = + mode === 'absolute' + ? poison.replaceAll('\\', '/') + : mode === 'alias' + ? 'provider' + : mode === 'package' + ? 'blocked' + : mode === 'directory-entry' + ? '../container' + : '../blocked/nested/index' + if (mode === 'directory-entry') + file('container/package.json', '{"main":"../blocked/nested/index.ts"}') + const excludes = + mode === 'package' ? [join(root, 'node_modules/blocked')] : [exclusion] + const entry = file('src/main.ts', `import '${request}'`) + // When resolving with the existing exclusion matcher. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + exclude: excludes, + alias: { provider: poison }, + }) + // Then evidence names the original matched entry, not a fabricated target. + expect(graph.requests.map(({ outcome }) => outcome)).toEqual([ + { kind: 'excluded', entry: excludes[0] }, + ]) + expect(graph.files).toEqual([entry]) + }, +) + +it('orders requests by importer then offset rather than discovery order', () => { + // Given a followed alias target whose name sorts before the source root. + const entry = file('src/z.ts', "import 'provider'; import('external')") + const target = file('outside/a.ts', "require('other')") + // When traversal discovers the target after the entry. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: target }, + }) + // Then stable evidence ordering is independent of traversal append order. + expect( + graph.requests.map(({ importer, position }) => [importer, position.offset]), + ).toEqual([ + [target, 8], + [entry, 7], + [entry, 26], + ]) +}) + +it('retains an exclusion reached only after canonical package resolution', () => { + // Given a package symlink whose real target is excluded. + const manifest = file('blocked/pkg/package.json', '{"main":"index.js"}') + file('blocked/pkg/index.js', "import 'poison'") + file('node_modules/placeholder/package.json', '{}') + fs.symlinkSync( + dirname(manifest), + join(root, 'node_modules/linked'), + 'junction', + ) + const entry = file('src/main.ts', "import 'linked'") + // When native package resolution canonicalizes the target. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + exclude: [dirname(manifest)], + include: ['linked'], + }) + // Then the post-resolution exclusion names the actual matcher entry. + expect(graph.requests.map(({ outcome }) => outcome)).toEqual([ + { kind: 'excluded', entry: dirname(manifest) }, + ]) + expect(graph.files).toEqual([entry]) +}) + +it('keeps the original thrown object untouched and stores only deterministic failing-edge evidence', () => { + // Given an I/O failure at a real resolution boundary. + const entry = file('src/main.ts', "import './leaf'") + const leaf = file('src/leaf.ts') + const cause = new Error('original cause') + const original = Object.freeze(new Error('original failure', { cause })) + const originalStat = fs.statSync + const stat = spyOn(fs, 'statSync').mockImplementation( + new Proxy(originalStat, { + apply(target, thisArg, args) { + if (args[0] === leaf) throw original + return Reflect.apply(target, thisArg, args) + }, + }), + ) + try { + const evidence = [] + for (let run = 0; run < 2; run += 1) { + // When the unchanged resolver throws. + let caught: unknown + try { + buildStaticImportGraph('src', undefined, { cwd: root }) + } catch (error) { + caught = error + } + // Then identity, cause and the failing occurrence are preserved. + expect(caught).toBe(original) + expect(original.cause).toBe(cause) + expect(Object.keys(original)).toEqual([]) + const failure = importGraphFailureOf(caught) + expect(failure).toEqual([ + { + importer: entry, + request: './leaf', + specifier: './leaf', + kind: 'static-import', + position: { offset: 7, line: 1, column: 8 }, + source: 'source', + outcome: { kind: 'error', error: original }, + }, + ]) + evidence.push(failure) + } + expect(evidence[0]).toEqual(evidence[1]) + expect(importGraphFailureOf(new Error('unrelated'))).toBeUndefined() + expect(importGraphFailureOf(null)).toBeUndefined() + expect(importGraphFailureOf('primitive')).toBeUndefined() + expect(importGraphFailureOf(() => undefined)).toBeUndefined() + } finally { + stat.mockRestore() + } +}) + +it('records alias resolution errors without changing their located diagnostic', () => { + // Given a terminal alias miss. + const entry = file('src/main.ts', "export * from 'provider'") + // When the existing resolver rejects the rewritten candidate. + let caught: unknown + try { + buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: './absent' }, + }) + } catch (error) { + caught = error + } + // Then the original error is the same object exposed as evidence. + expect(caught).toBeInstanceOf(Error) + expect(importGraphFailureOf(caught)?.[0]).toMatchObject({ + importer: entry, + kind: 're-export', + request: 'provider', + outcome: { kind: 'error', error: caught }, + }) +}) diff --git a/packages/plugin-utils/src/__tests__/request-source.test.ts b/packages/plugin-utils/src/__tests__/request-source.test.ts new file mode 100644 index 000000000..b4509c050 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/request-source.test.ts @@ -0,0 +1,249 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' +import { buildStaticImportGraph, importGraphFailureOf } from '../index' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('preserves raw Markdown offsets through prose, fences, comments and CRLF', () => { + // Given an ESM block after masked Markdown. + const source = + '# Heading\r\n\r\n```js\r\nimport "fake"\r\n```\r\n\r\n\r\nimport "./leaf"\r\n\r\nProse' + const entry = file('src/page.mdx', source) + const leaf = file('src/leaf.ts') + // When scanning raw opted-in Markdown. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + }) + // Then coordinates refer to the actual source, not compacted ESM blocks. + expect(graph.requests).toEqual([ + { + importer: entry, + request: './leaf', + specifier: './leaf', + kind: 'static-import', + position: { offset: 66, line: 8, column: 8 }, + source: 'source', + outcome: { + kind: 'resolved', + path: leaf, + inputs: { + fileDependencies: [leaf], + missingDependencies: [ + join(root, 'src/leaf'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }, + }, + ]) +}) + +it.each([ + undefined, + { version: 3, sources: ['original.mdx'], names: [], mappings: 'AAAA' }, +])('labels prepared token positions honestly with map %j', async (map) => { + // Given different raw and compiled text under the real Markdown filename. + const entry = file('src/page.mdx', '# Raw') + const leaf = file('src/leaf.ts') + const visits: string[] = [] + // When the unchanged asynchronous driver prepares actual JavaScript. + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: (filename) => { + visits.push(filename) + return filename === entry + ? { code: '\nimport "./leaf"', sourceType: 'compiled-mdx', map } + : undefined + }, + }) + // Then compiled coordinates and caller-owned map are not claimed as raw positions. + expect(graph.requests).toEqual([ + { + importer: entry, + request: './leaf', + specifier: './leaf', + kind: 'static-import', + position: { offset: 8, line: 2, column: 8 }, + source: 'compiled', + map, + outcome: { + kind: 'resolved', + path: leaf, + inputs: { + fileDependencies: [leaf], + missingDependencies: [ + join(root, 'src/leaf'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }, + }, + ]) + expect(visits.sort()).toEqual(graph.files) +}) + +it.each(['.js', '.tsx', '.ts'])( + 'preserves scanner masks and type-only/nonliteral parity in %s', + (extension) => { + // Given text, expressions, type syntax and nonliteral calls. + const source = + extension === '.js' + ? 'const x = a < b; import("external"); require(name)' + : extension === '.tsx' + ? 'const x =
import("fake"){require("external")}
; import type { T } from "types"' + : 'type T = import("types").T; const x = a; import("external"); import(name)' + const entry = file(`src/main${extension}`, source) + // When the graph uses the same lexer context as the legacy scanner. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then only the runtime literal occurrence survives and old object shape is exact. + expect(graph.requests.map(({ specifier }) => specifier)).toEqual([ + 'external', + ]) + expect( + scanImports(source, extension === '.tsx', extension !== '.js'), + ).toEqual([ + { + kind: extension === '.tsx' ? 'static' : 'dynamic', + specifier: 'external', + }, + ]) + expect(graph.requests[0]?.importer).toBe(entry) + }, +) + +it('keeps explicit compiled-MDX JavaScript grammar under a TS filename', async () => { + // Given JavaScript using the value identifier type with JSX. + const entry = file('src/main.ts') + // When preparation explicitly selects JS/JSX. + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => ({ + code: 'const type = 1; const x =
{import("external")}
', + sourceType: 'compiled-mdx', + }), + }) + // Then the actual compiled literal is retained under its real filename. + expect( + graph.requests.map(({ importer, specifier, source }) => ({ + importer, + specifier, + source, + })), + ).toEqual([{ importer: entry, specifier: 'external', source: 'compiled' }]) +}) + +it('does not invent failed-edge evidence for preparation errors before any request', async () => { + // Given a hook failure before scanning. + file('src/page.mdx') + // When the established driver wraps the compiler failure. + const failure = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => { + throw new Error('compiler') + }, + }).catch((error: unknown) => error) + // Then no synthetic scanner edge is attributed to it. + expect(importGraphFailureOf(failure)).toBeUndefined() +}) + +it.each([ + ['\r\n', 19], + ['\r', 18], + ['\n', 18], + ['\u2028', 18], + ['\u2029', 18], +] as const)( + 'locates raw requests after line terminator %j', + (separator, offset) => { + // Given actual source line terminators, not literal backslash escapes. + const source = `const n=0;${separator}import 'external';` + const entry = file('src/main.ts', source) + // When scanning the raw source through the public graph. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then independently known coordinates and legacy selection are preserved. + expect(graph.requests).toEqual([ + { + importer: entry, + request: 'external', + specifier: 'external', + kind: 'static-import', + position: { offset, line: 2, column: 8 }, + source: 'source', + outcome: { kind: 'external', request: 'external' }, + }, + ]) + expect(scanImports(source, false)).toEqual([ + { kind: 'static', specifier: 'external' }, + ]) + }, +) + +it.each(['\u2028', '\u2029'])( + 'locates prepared requests after Unicode line terminator %j', + async (separator) => { + // Given compiled text with an actual Unicode line terminator. + const entry = file('src/main.ts', 'const raw = 1;') + // When preparation supplies the exact audit witness. + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => `const n=0;${separator}import 'external';`, + }) + // Then the position describes compiled text, not the raw file. + expect(graph.requests[0]).toMatchObject({ + importer: entry, + request: 'external', + position: { offset: 18, line: 2, column: 8 }, + source: 'compiled', + outcome: { kind: 'external', request: 'external' }, + }) + }, +) + +it.each([ + ['\u2028', false], + ['\u2029', false], + ['\u2028', true], + ['\u2029', true], +] as const)( + 'locates failing requests after Unicode line terminator %j with preparation %j', + async (separator, prepared) => { + // Given a terminal alias miss in raw or compiled witness text. + const source = `const n=0;${separator}import 'external';` + const entry = file('src/main.ts', prepared ? 'const raw = 1;' : source) + // When the existing resolver rejects the requested alias. + let caught: unknown + try { + await buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { external: './absent' }, + ...(prepared ? { prepareSource: () => source } : {}), + }) + } catch (error) { + caught = error + } + // Then failing-edge evidence retains the independently known position. + expect(caught).toBeInstanceOf(Error) + expect(importGraphFailureOf(caught)).toEqual([ + { + importer: entry, + request: 'external', + specifier: 'external', + kind: 'static-import', + position: { offset: 18, line: 2, column: 8 }, + source: prepared ? 'compiled' : 'source', + ...(prepared ? { map: undefined } : {}), + outcome: { kind: 'error', error: caught }, + }, + ]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts b/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts new file mode 100644 index 000000000..3f4524766 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts @@ -0,0 +1,284 @@ +import * as fs from 'node:fs' +import { dirname, join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../index' +import { + createResolutionInputs, + readResolutionFile, + realpathResolution, +} from '../resolution-inputs' +import { resolutionWatchPath } from '../resolution-watch-path' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('records consulted manifests and missing probes when resolving a linked package', () => { + // Given a linked workspace package with an exports-only entry. + file('src/main.ts', "import 'palette'") + const manifest = file( + 'packages/palette/package.json', + '{"exports":"./red.js"}', + ) + file('packages/palette/red.js', "export const color='red'") + file('node_modules/placeholder/package.json', '{}') + fs.symlinkSync( + dirname(manifest), + join(root, 'node_modules/palette'), + 'junction', + ) + const originalRead = fs.readFileSync + const reads: string[] = [] + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(target, receiver, args) { + reads.push(String(args[0])) + return Reflect.apply(target, receiver, args) + }, + }), + ) + try { + // When scanning with real filesystem IO. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then the outcome names the lexical consulted manifest, not a guessed physical read. + const lexical = join(root, 'node_modules/palette/package.json') + expect(reads.filter((path) => path.endsWith('package.json'))).toEqual([ + lexical, + ]) + expect(graph.requests[0]?.outcome).toMatchObject({ + kind: 'resolved', + inputs: { + fileDependencies: [ + lexical, + join(root, 'node_modules/palette/red.js'), + ].sort(), + missingDependencies: [ + join(root, 'src/node_modules/palette/package.json'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }) + } finally { + read.mockRestore() + } +}) + +it('observes setup inheritance and failed earlier candidates when evaluating an import', () => { + // Given inherited aliases with eager multiple targets. + const config = file( + 'tsconfig.json', + '{"extends":"./base","compilerOptions":{}}', + ) + const base = file( + 'base.json', + '{"compilerOptions":{"paths":{"color":["absent","red.ts","later"]}}}', + ) + const red = file('red.ts', "export const color='red'") + const observations: unknown[] = [] + // When the public callable resolves and reads source. + const resolver = createModuleResolver({ + cwd: root, + onResolutionInputs: (inputs) => observations.push(inputs), + }) + expect(resolver('color', join(root, 'main.ts'))).toMatchObject({ path: red }) + // Then actual setup and eager failed candidates are reported without changing resolution. + expect(observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fileDependencies: [base, config].sort(), + missingDependencies: [join(root, 'base')], + }), + expect.objectContaining({ + fileDependencies: [base, red, config].sort(), + missingDependencies: expect.arrayContaining([ + join(root, 'absent'), + join(root, 'later'), + ]), + }), + ]), + ) + expect(observations.every((inputs) => Object.isFrozen(inputs))).toBe(true) +}) + +it('freezes deterministic snapshots without redoing successful resolution IO', () => { + // Given a real extensionless import and unchanged filesystem. + file('src/main.ts', "import './red'") + file('src/red.ts', "export const color='red'") + // When scanning twice independently. + const first = buildStaticImportGraph('src', undefined, { cwd: root }) + const second = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then the full evidence is stable and its nested arrays cannot change. + expect(first.requests).toEqual(second.requests) + const outcome = first.requests[0]?.outcome + expect(outcome?.kind).toBe('resolved') + if (outcome && 'inputs' in outcome) { + expect(Object.isFrozen(outcome.inputs)).toBe(true) + expect(Object.isFrozen(outcome.inputs.fileDependencies)).toBe(true) + expect(Object.isFrozen(outcome.inputs.missingDependencies)).toBe(true) + } +}) + +it.each(['main', 'fallback', 'json', 'exports', 'direct'])( + 'records actual loaded packaged config chain when using %s resolution', + (mode) => { + // Given a package-config extension found below an absent nearer node_modules directory. + const config = file( + 'src/tsconfig.json', + JSON.stringify({ + extends: mode === 'direct' ? 'preset/base.json' : 'preset', + }), + ) + const manifest = file( + 'node_modules/preset/package.json', + mode === 'main' + ? '{"main":"index.js","tsconfig":"base.json"}' + : mode === 'exports' + ? '{"exports":"./base.json"}' + : mode === 'json' + ? '{"main":"base.json"}' + : '{}', + ) + if (mode === 'main') + file('node_modules/preset/index.js', 'module.exports={}') + const base = file( + `node_modules/preset/${mode === 'fallback' || mode === 'json' ? 'tsconfig.json' : 'base.json'}`, + '{}', + ) + const observations: unknown[] = [] + // When constructing the existing synchronous resolver. + createModuleResolver({ + cwd: root, + tsconfigPath: config, + onResolutionInputs: (inputs) => observations.push(inputs), + }) + // Then only owned reads and public-search missing directories are recorded. + expect(observations).toContainEqual( + expect.objectContaining({ + fileDependencies: [config, manifest, base].sort(), + missingDependencies: expect.arrayContaining([ + join(root, 'src/node_modules/preset/package.json'), + ]), + }), + ) + }, +) + +it('reports a missing inherited configuration before preserving its established failure', () => { + // Given a top config pointing at a nonexistent parent. + file('tsconfig.json', '{"extends":"./missing"}') + const observations: unknown[] = [] + // When setup reaches the original IO failure. + expect(() => + createModuleResolver({ + cwd: root, + onResolutionInputs: (inputs) => observations.push(inputs), + }), + ).toThrow(join(root, 'missing.json')) + // Then the repair paths were observed even though no callable was returned. + expect(observations).toContainEqual({ + fileDependencies: [join(root, 'tsconfig.json')], + missingDependencies: [ + join(root, 'missing'), + join(root, 'missing.json'), + ].sort(), + }) +}) + +it('canonicalizes only transport identity when watching a missing descendant below a symlink', () => { + // Given a symlink and a missing file below its existing target. + const physical = file('physical/existing.ts') + file('links/placeholder.ts') + fs.symlinkSync(dirname(physical), join(root, 'links/package'), 'junction') + const lexical = join(root, 'links/package/new.ts') + // When selecting default and preserved transport identities. + const canonical = resolutionWatchPath(lexical) + // Then resolver evidence remains lexical while transport follows the bundler setting. + expect(canonical).toBe(join(root, 'physical/new.ts')) + expect(resolutionWatchPath(lexical, true)).toBe(lexical) +}) + +it('preserves IO failure identity and records attempted repair inputs', () => { + // Given a permission failure at the narrow existing read seam. + const target = file('blocked.json') + const original = Object.assign(new Error('denied'), { code: 'EACCES' }) + const originalRead = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(fn, receiver, args) { + if (args[0] === target) throw original + return Reflect.apply(fn, receiver, args) + }, + }), + ) + const inputs = createResolutionInputs() + try { + // When reading the input through the observed seam. + let caught: unknown + try { + readResolutionFile(target, inputs) + } catch (error) { + caught = error + } + // Then the original exception and actual attempted filename survive. + expect(caught).toBe(original) + expect(inputs.snapshot()).toEqual({ + fileDependencies: [target], + missingDependencies: [], + }) + } finally { + read.mockRestore() + } +}) + +it.each(['EACCES', 'ENOENT'])( + 'retains lexical watch identity when canonicalization fails with %s', + (code) => { + // Given a transport-only realpath failure, including an unavailable root. + const path = join(root, 'unavailable.ts') + const original = Object.assign(new Error('unavailable'), { code }) + const realpath = spyOn(fs, 'realpathSync').mockImplementation(() => { + throw original + }) + try { + // When asking for watcher identity without changing resolution semantics. + const watched = resolutionWatchPath(path) + // Then no secondary error replaces the original extraction/config failure. + expect(watched).toBe(path) + } finally { + realpath.mockRestore() + } + }, +) + +it('records a failed canonical probe without replacing the original IO error', () => { + // Given a real file whose existing canonicalization seam fails after resolution probes. + const path = file('canonical.ts') + const original = Object.freeze( + Object.assign(new Error('removed during resolution'), { code: 'ENOENT' }), + ) + const realpath = spyOn(fs, 'realpathSync').mockImplementation(() => { + throw original + }) + const inputs = createResolutionInputs() + try { + // When observing that existing realpath invocation. + let caught: unknown + try { + realpathResolution(path, inputs) + } catch (error) { + caught = error + } + // Then its exact path is repairable and its thrown object is identical. + expect(caught).toBe(original) + expect(inputs.snapshot()).toEqual({ + fileDependencies: [], + missingDependencies: [path], + }) + } finally { + realpath.mockRestore() + } +}) diff --git a/packages/plugin-utils/src/__tests__/shared-api.test.ts b/packages/plugin-utils/src/__tests__/shared-api.test.ts new file mode 100644 index 000000000..bf39861c1 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/shared-api.test.ts @@ -0,0 +1,172 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { + buildStaticImportGraph, + createModuleResolver, + listSourceFiles, +} from '../import-graph' +import { collectNumberedFiles } from '../numbering' + +let root: string +beforeEach(() => { + const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared-api', + ) + mkdirSync(parent, { recursive: true }) + root = realpathSync.native(mkdtempSync(join(parent, 'source-'))) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) +function file(path: string, source = 'export {}'): string { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, source) + return target +} + +it('keeps Markdown off by default and selects exact extensions in scanner and numbering', () => { + const js = file('src/main.ts') + const md = file('src/page.md') + file('src/page.mdx') + const custom = file('src/page.mdown') + file('src/generated/skip.md') + expect(listSourceFiles(join(root, 'src'))).toEqual([js]) + expect( + listSourceFiles(join(root, 'src'), ['generated'], { includeMdx: ['.md'] }), + ).toEqual([js, md]) + expect( + collectNumberedFiles({ + roots: [join(root, 'src')], + exclude: ['generated'], + includeMdx: ['.md'], + toId: (path) => path, + }), + ).toEqual([js, md]) + expect( + listSourceFiles(join(root, 'src'), [], { includeMdx: ['.mdown'] }), + ).toEqual([js, custom]) + expect( + collectNumberedFiles({ + roots: [join(root, 'src')], + includeMdx: ['.mdown'], + toId: (path) => path, + }), + ).toEqual([js, custom]) +}) + +it('returns a Promise for a synchronous preparer and follows a project-root provider once', async () => { + const entry = file('src/page.mdx', '# Raw markdown') + const provider = file('mdx-components.js', "import './value.generated'") + const leaf = file('value.generated.mjs') + const visits: string[] = [] + const graphPromise = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + alias: { provider }, + prepareSource: (filename: string) => { + visits.push(filename) + return filename === entry ? "import 'provider'" : undefined + }, + }) + expect(graphPromise).toBeInstanceOf(Promise) + const graph = await graphPromise + expect(graph.files).toEqual([entry, provider, leaf].sort()) + expect(visits.sort()).toEqual(graph.files) +}) + +it('does not discover import examples inside strings, regex or JSX text', () => { + const entry = file( + 'src/main.tsx', + [ + 'const example = "import \'./fake\'";', + 'const poison = "import \'fake-package\'";', + String.raw`const pattern = /"import '\.\/fake'"/;`, + "const template = `before ${`nested import './fake'`} after`;", + 'const element =
import \'./fake\' {import("./real")}
;', + "import './real'", + ].join('\n'), + ) + const real = file('src/real.ts') + file('src/fake.ts') + file('node_modules/fake-package/package.json', '{') + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + expect(graph.staticImports.get(entry)).toEqual(new Set([real])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set([real])) +}) + +it('does not leak template content after regex braces or nested escaped templates', () => { + const entry = file( + 'src/main.ts', + [ + 'const example = tag`prefix ${ /}/.test("x") ? `nested import "./fake"` : "" } tail import "./fake"`;', + "import './real'", + ].join('\n'), + ) + const real = file('src/real.ts') + file('src/fake.ts') + expect( + buildStaticImportGraph('src', undefined, { cwd: root }).staticImports.get( + entry, + ), + ).toEqual(new Set([real])) +}) + +it('masks JSX text after arrow and conditional expression boundaries', () => { + const entry = file( + 'src/main.tsx', + [ + 'const View = () =>
import "./fake" {import("./real")}
;', + 'const branch = flag ?
import "./fake"
: ;', + 'const optional = flag &&
import "./fake"
;', + "import './real'", + ].join('\n'), + ) + const real = file('src/real.ts') + file('src/fake.ts') + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + expect(graph.staticImports.get(entry)).toEqual(new Set([real])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set([real])) +}) + +it('rewrites aliases before tsconfig and never falls through a matched missing target', () => { + const entry = file('src/main.ts') + const target = file('actual/index.js') + file( + 'tsconfig.json', + JSON.stringify({ compilerOptions: { paths: { name: ['./fallback.js'] } } }), + ) + file('fallback.js') + const resolver = createModuleResolver({ + cwd: root, + alias: { name$: target, missing: join(root, 'absent') }, + }) + expect(resolver('name', entry)?.path).toBe(target) + file('node_modules/missing/package.json', '{"main":"index.js"}') + file('node_modules/missing/index.js') + expect(() => resolver('missing', entry)).toThrow( + `${entry}:1:1: Module alias missing cannot resolve candidates ${JSON.stringify([join(root, 'absent')])}`, + ) +}) + +it('does not widen local graph roots merely because an empty alias table exists', () => { + const entry = file('src/main.ts', "import '../outside'") + file('outside.js') + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: {}, + }) + expect(graph.files).toEqual([entry]) +}) diff --git a/packages/plugin-utils/src/__tests__/shared-paths.test.ts b/packages/plugin-utils/src/__tests__/shared-paths.test.ts new file mode 100644 index 000000000..0d19b9b70 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/shared-paths.test.ts @@ -0,0 +1,130 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { afterAll, describe, expect, it } from 'bun:test' + +import { + createNodeModulesExcludeRegex, + GRAPH_SOURCE_FILE_RE, + POST_COMPILED_MDX_RE, + resolveProjectPaths, + resolveSourceDirs, + SOURCE_EXTENSIONS, + SOURCE_FILE_RE, +} from '../shared' + +const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared', +) +mkdirSync(parent, { recursive: true }) +const root = realpathSync(mkdtempSync(join(parent, 'paths-'))) +afterAll(() => rmSync(root, { recursive: true, force: true })) + +describe('project roots', () => { + it('resolves default outputs against the project when cwd differs', () => { + const paths = resolveProjectPaths(root) + expect(paths).toEqual({ + devupFile: join(root, 'devup.json'), + distDir: join(root, 'df'), + cssDir: join(root, 'df/devup-ui'), + }) + }) + it('resolves explicit paths and derives CSS from custom dist when omitted', () => { + expect( + resolveProjectPaths(root, { + devupFile: 'config/theme.json', + distDir: 'generated', + cssDir: 'styles', + }), + ).toEqual({ + devupFile: join(root, 'config/theme.json'), + distDir: join(root, 'generated'), + cssDir: join(root, 'styles'), + }) + expect(resolveProjectPaths(root, { distDir: 'generated' }).cssDir).toBe( + join(root, 'generated/devup-ui'), + ) + }) + it('discovers both src and app when present', () => { + mkdirSync(join(root, 'src')) + mkdirSync(join(root, 'app')) + expect(resolveSourceDirs(root)).toEqual([ + join(root, 'src'), + join(root, 'app'), + ]) + }) + it('retains explicit roots without requiring them to exist yet', () => { + expect( + resolveSourceDirs(root, ['custom', 'custom', resolve(root, 'more')]), + ).toEqual([join(root, 'custom'), join(root, 'more')]) + expect(resolveSourceDirs(root, 'custom')).toEqual([join(root, 'custom')]) + expect(resolveSourceDirs(join(root, 'empty'))).toEqual([]) + }) +}) + +describe('source filters', () => { + it.each(SOURCE_EXTENSIONS)( + 'accepts modern source extension %s', + (extension) => { + expect(SOURCE_FILE_RE.test(`file${extension}`)).toBe(true) + expect(POST_COMPILED_MDX_RE.test(`file.mdx${extension}`)).toBe(true) + expect(GRAPH_SOURCE_FILE_RE.test(`file${extension}`)).toBe(true) + }, + ) + it('discovers MDX without sending raw markdown through the source transform', () => { + expect(GRAPH_SOURCE_FILE_RE.test('page.mdx')).toBe(true) + expect(SOURCE_FILE_RE.test('page.mdx')).toBe(false) + expect(POST_COMPILED_MDX_RE.test('page.mdx')).toBe(false) + expect(GRAPH_SOURCE_FILE_RE.test('page.css')).toBe(false) + }) +}) + +describe('literal package boundaries', () => { + it.each(['foo.bar', 'foo+bar', '@scope/foo.bar'])( + 'includes literal package %s only', + (name) => { + const filter = createNodeModulesExcludeRegex([name]) + expect(filter.test(`node_modules/${name}/index.js`)).toBe(false) + expect( + filter.test( + `C:\\project\\node_modules\\${name.replaceAll('/', '\\')}\\index.js`, + ), + ).toBe(false) + expect(filter.test(`node_modules/${name}extra/index.js`)).toBe(true) + }, + ) + it('does not mistake scoped, substring or regex lookalike packages for includes', () => { + const filter = createNodeModulesExcludeRegex(['pkg', 'foo.bar']) + expect(filter.test('node_modules/@scope/pkg/index.js')).toBe(true) + expect(filter.test('node_modules/other-pkg/index.js')).toBe(true) + expect(filter.test('node_modules/fooXbar/index.js')).toBe(true) + expect(filter.test('not_node_modules/pkg/index.js')).toBe(false) + expect(filter.test('node_modules/@devup-ui-extra/react/index.js')).toBe( + true, + ) + }) + it('uses the innermost node_modules under pnpm or nested dependencies', () => { + const filter = createNodeModulesExcludeRegex(['pkg', '@scope/pkg']) + expect( + filter.test('node_modules/.pnpm/pkg@1/node_modules/pkg/index.js'), + ).toBe(false) + expect( + filter.test( + 'node_modules/.pnpm/@scope+pkg@1/node_modules/@scope/pkg/index.js', + ), + ).toBe(false) + expect(filter.test('node_modules/pkg/node_modules/other/index.js')).toBe( + true, + ) + expect( + filter.test( + 'node_modules/other/node_modules/@devup-editor/react/index.js', + ), + ).toBe(false) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/source-type.test.ts b/packages/plugin-utils/src/__tests__/source-type.test.ts new file mode 100644 index 000000000..f98f349a8 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/source-type.test.ts @@ -0,0 +1,185 @@ +import { createRequire } from 'node:module' +import { relative, resolve } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import * as wasm from '../../../../bindings/devup-ui-wasm/pkg' +import { + __setOxcParserForTest, + buildStaticImportGraph, + createModuleResolver, + type PreparedSource, +} from '../import-graph' +import { PreparedSourceTypeError, readPreparedSource } from '../prepared-source' +import { createPreparedFixture } from './prepared-graph-fixture' + +const projectRequire = createRequire(resolve('apps/landing/package.json')) +const compilerRequire = createRequire(projectRequire.resolve('@mdx-js/loader')) +const { compile } = compilerRequire('@mdx-js/mdx') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) +let debug: boolean +beforeEach(() => { + debug = wasm.isDebug() + wasm.resetBuildState() + wasm.setDebug(false) +}) +afterEach(() => { + wasm.resetBuildState() + wasm.setDebug(debug) + __setOxcParserForTest(undefined) +}) + +it.each([false, true])( + 'extracts compiled custom-extension exports after JSX (%s)', + async (jsx) => { + const markdown = + 'import {Box} from "@devup-ui/react"\n\n' + const module = file('src/value.mdown', markdown) + const entry = file('src/main.tsx', 'import {PRIMARY} from "./value.mdown";') + const compiled = await compile( + { path: module, value: markdown }, + { format: 'mdx', jsx }, + ) + const source: PreparedSource = { + code: `${compiled}\nexport const PRIMARY = 'blue';`, + sourceType: 'compiled-mdx', + map: compiled.map, + } + const prepareSource = (filename: string) => + filename === module ? source : undefined + const toId = (filename: string) => + relative(root, filename).replaceAll('\\', '/') + const resolver = createModuleResolver({ + cwd: root, + includeMdx: ['.mdown'], + prepareSource, + toId, + }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: ['.mdown'], + prepareSource, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([module])) + expect(resolver('./value.mdown', toId(entry))?.sourceType).toBe( + 'compiled-mdx', + ) + wasm.setModuleResolver(resolver) + const output = wasm.codeExtractWithoutSourceMap( + toId(entry), + 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./value.mdown"; export const view = ;', + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + try { + expect(wasm.getCss(undefined, false)).toContain('color:blue') + expect(output.code).not.toContain('--') + expect(output.dependencies).toEqual([toId(module)]) + } finally { + output.free() + } + }, +) + +it('uses JS JSX scanner and diagnostics when explicit mode overrides a TS filename', async () => { + const entry = file('src/page.ts') + const real = file('src/real.js') + file('node_modules/phantom/package.json', '{') + const modes: unknown[] = [] + __setOxcParserForTest({ + parseSync: (_filename, _code, options) => { + modes.push(options?.lang) + return {} + }, + }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: (filename) => + filename === entry + ? { + code: 'const view =
import "phantom"
; import "./real.js";', + sourceType: 'compiled-mdx', + } + : undefined, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([real])) + expect(modes).toContain('jsx') +}) + +it.each(['invalid', 7, null, true, {}])( + 'rejects invalid hook mode before scanner fallback (%p)', + async (value) => { + const entry = file('src/page.ts', '') + const prepared = { code: '' } + Reflect.set(prepared, 'sourceType', value) + await expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => prepared, + }), + ).rejects.toThrow(`${entry}:1:1:`) + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => prepared, + }) + expect(() => resolver(entry, 'main.ts')).toThrow('main.ts:1:1:') + }, +) + +it('preserves getter cause when preparation cannot read optional mode', async () => { + const entry = file('src/page.ts', '') + const cause = new Error('sourceType getter fault') + const prepared = { + code: '', + get sourceType(): 'compiled-mdx' { + throw cause + }, + } + await expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => prepared, + }), + ).rejects.toThrow('sourceType getter fault') + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => prepared, + }) + expect(() => resolver(entry, 'main.ts')).toThrow('main.ts:1:1:') +}) + +it('replaces mode when prepared generations become ordinary source', () => { + const module = file('src/value.ts', 'export const PRIMARY = "red";') + let source: PreparedSource = { + code: 'export const PRIMARY = "blue";', + sourceType: 'compiled-mdx', + } + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => source, + }) + expect(resolver(module, 'main.ts')?.sourceType).toBe('compiled-mdx') + source = { code: '' } + expect(resolver(module, 'main.ts')).toEqual({ path: module, code: '' }) + source = undefined + expect(resolver(module, 'main.ts')).toEqual({ + path: module, + code: 'export const PRIMARY = "red";', + }) +}) + +it('retains empty/string/undefined preparation contracts when mode is omitted', () => { + expect(readPreparedSource(undefined)).toBeUndefined() + expect(readPreparedSource('')).toBe('') + expect(readPreparedSource({ code: '' })).toEqual({ code: '', map: undefined }) + expect(new PreparedSourceTypeError('getter fault').message).toContain( + 'getter fault', + ) +}) diff --git a/packages/plugin-utils/src/__tests__/state-writer.test.ts b/packages/plugin-utils/src/__tests__/state-writer.test.ts new file mode 100644 index 000000000..661189cf2 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/state-writer.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { beginBuild } from '../build-session' +import { createStateWriter, writeFileAtomically } from '../state-writer' + +describe('createStateWriter', () => { + it('leaves the newest snapshot, whichever write would finish last', async () => { + const written: string[] = [] + const release: Array<() => void> = [] + const writer = createStateWriter( + (_path, content) => + new Promise((done) => { + release.push(() => { + written.push(content) + done() + }) + }), + ) + const first = writer.write('state', 'one') + const second = writer.write('state', 'two') + const third = writer.write('state', 'three', 'utf-8') + release[0]() + await first + await new Promise((resolve) => setTimeout(resolve, 0)) + release[1]() + await Promise.all([second, third]) + // the second snapshot was overtaken before it started + expect(written).toEqual(['one', 'three']) + expect(release).toHaveLength(2) + }) + + it('keeps writing after a write fails, and keeps paths apart', async () => { + const calls: string[] = [] + const writer = createStateWriter(async (path, content) => { + calls.push(`${path}=${content}`) + if (content === 'bad') throw new Error('disk full') + }) + const bad = writer.write('a', 'bad') + const good = writer.write('a', 'good') + await expect(bad).rejects.toThrow('disk full') + await good + await writer.write('b', 'other') + expect(calls).toEqual(['a=bad', 'a=good', 'b=other']) + }) +}) + +describe('writeFileAtomically', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'devup-ui-writer-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('replaces the file whole and leaves no temporary file', async () => { + const path = join(dir, 'deep', 'state.json') + await writeFileAtomically(path, '1') + await writeFileAtomically(path, '22') + expect(readFileSync(path, 'utf-8')).toBe('22') + expect(readdirSync(join(dir, 'deep'))).toEqual(['state.json']) + }) + + it('is what the writer uses unless told otherwise', async () => { + const path = join(dir, 'state.json') + await createStateWriter().write(path, 'first') + await createStateWriter().write(path, 'second', 'utf-8') + expect(readFileSync(path, 'utf-8')).toBe('second') + }) + + it('cleans up when the file cannot be replaced', async () => { + const path = join(dir, 'target') + await writeFileAtomically(join(path, 'inner'), 'x') + await expect(writeFileAtomically(path, 'y')).rejects.toBeDefined() + expect(readdirSync(dir)).toEqual(['target']) + }) +}) + +describe('beginBuild', () => { + it('resets the engine only when no other build is running', () => { + let resets = 0 + const engine = { + resetBuildState: () => { + resets += 1 + }, + } + const first = beginBuild(engine) + expect(resets).toBeLessThanOrEqual(1) + const before = resets + const second = beginBuild(engine) + expect(resets).toBe(before) + second() + second() + first() + beginBuild({})() + const third = beginBuild(engine) + expect(resets).toBe(before + 1) + third() + }) +}) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-config-policy.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-config-policy.test.ts new file mode 100644 index 000000000..2b1361c74 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-config-policy.test.ts @@ -0,0 +1,119 @@ +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { resolvePackage } from '../owned-module-resolution' +import { readPackageManifest } from '../packaged-config-inputs' +import { createPreparedFixture } from './prepared-graph-fixture' + +const ts: { + readonly sys: object + nodeNextJsonConfigResolver( + request: string, + importer: string, + host: object, + ): { readonly resolvedModule?: { readonly resolvedFileName: string } } +} = createRequire(import.meta.url)('@typescript/typescript6') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('uses a nested package config before root versions when root has no exports property', () => { + const importer = file('tsconfig.json', '{}') + file( + 'node_modules/preset/package.json', + '{"typesVersions":{"*":{"config":["red.json"]}}}', + ) + file('node_modules/preset/red.json', '{}') + file('node_modules/preset/config/package.json', '{"tsconfig":"blue.json"}') + const expected = file('node_modules/preset/config/blue.json', '{}') + const oracle = ts.nodeNextJsonConfigResolver( + 'preset/config', + importer, + ts.sys, + ).resolvedModule?.resolvedFileName + expect(oracle && resolve(oracle)).toBe(expected) + expect( + resolvePackage('preset/config', importer, { purpose: 'tsconfig-extends' }), + ).toBe(expected) +}) + +it('returns a missing imports result when no package scope exists', () => { + const importer = file('tsconfig.json', '{}') + const oracle = ts.nodeNextJsonConfigResolver('#missing', importer, ts.sys) + .resolvedModule?.resolvedFileName + expect(oracle).toBeUndefined() + expect( + resolvePackage('#missing', importer, { purpose: 'tsconfig-extends' }), + ).toBeUndefined() +}) + +it('preserves non-syntax parser failures rather than translating them to empty manifests', () => { + const manifest = file('package.json', '{}') + const failure = new TypeError('parser failure') + const parse = spyOn(JSON, 'parse').mockImplementation(() => { + throw failure + }) + try { + expect(() => readPackageManifest(manifest, 'tsconfig-extends')).toThrow( + failure, + ) + } finally { + parse.mockRestore() + } +}) + +it('ignores package exports when the compiler takes the dot filesystem-directory branch', () => { + // Given a directory config and a distinct exported JSON in the same directory. + const importer = file('src/tsconfig.json', '{"extends":"."}') + file('src/package.json', '{"exports":"./blue.json"}') + file('src/blue.json', '{}') + // When the packaged entry delegates dot requests to its CJS directory policy. + const oracle = ts.nodeNextJsonConfigResolver('.', importer, ts.sys) + .resolvedModule?.resolvedFileName + expect(oracle && resolve(oracle)).toBe(importer) + // Then exports encapsulation does not govern the filesystem-directory branch. + expect(resolvePackage('.', importer, { purpose: 'tsconfig-extends' })).toBe( + importer, + ) +}) + +it('preserves compiler casing identity when realpath differs only in case', () => { + // Given an exports target whose casing differs from the real directory entry. + const importer = file('tsconfig.json', '{}') + file('node_modules/preset/package.json', '{"exports":"./BLUE.json"}') + file('node_modules/preset/blue.json', '{}') + const expected = + process.platform === 'win32' + ? join(root, 'node_modules/preset/BLUE.json') + : undefined + // When both resolvers use the actual case-sensitive/case-insensitive filesystem. + const oracle = ts.nodeNextJsonConfigResolver('preset', importer, ts.sys) + .resolvedModule?.resolvedFileName + expect(oracle && resolve(oracle)).toBe(expected) + // Then canonicalization changes only genuine symlink origins, not spelling-only identity. + expect( + resolvePackage('preset', importer, { purpose: 'tsconfig-extends' }), + ).toBe(expected) +}) + +it('applies root versions to dot-prefixed child directories without treating them as parent escapes', () => { + // Given an in-package field path whose child name starts with two dots. + const importer = file('tsconfig.json', '{}') + file( + 'node_modules/preset/package.json', + '{"tsconfig":"..configs/red.json","typesVersions":{"*":{"..configs/red.json":["blue.json"]}}}', + ) + file('node_modules/preset/..configs/red.json', '{}') + const expected = file('node_modules/preset/blue.json', '{}') + // When resolving versions before the package field. + const oracle = ts.nodeNextJsonConfigResolver('preset', importer, ts.sys) + .resolvedModule?.resolvedFileName + expect(oracle && resolve(oracle)).toBe(expected) + // Then only an actual parent path component excludes version remapping. + expect( + resolvePackage('preset', importer, { purpose: 'tsconfig-extends' }), + ).toBe(expected) +}) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-manifest-cases.ts b/packages/plugin-utils/src/__tests__/tsconfig-manifest-cases.ts new file mode 100644 index 000000000..0d91e92c5 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-manifest-cases.ts @@ -0,0 +1,204 @@ +export const manifestCases: readonly [ + string, + unknown, + string | undefined, + string?, +][] = [ + [ + 'versioned types matches', + { exports: { 'types@>=6': './blue.json', default: './red.json' } }, + 'blue.json', + ], + [ + 'versioned types misses', + { exports: { 'types@<6': './blue.json', default: './red.json' } }, + 'red.json', + ], + [ + 'invalid range misses', + { exports: { 'types@bad': './blue.json', default: './red.json' } }, + 'red.json', + ], + [ + 'typesVersions root key is tsconfig', + { typesVersions: { '*': { tsconfig: ['blue.json'] } } }, + 'blue.json', + ], + [ + 'typesVersions uses field path', + { + tsconfig: 'red.json', + typesVersions: { '>=6': { 'red.json': ['blue.json'] } }, + }, + 'blue.json', + ], + [ + 'typesVersions deep maps', + { typesVersions: { '^6.0.0': { 'config/*': ['*.json'] } } }, + 'blue.json', + 'preset/config/blue', + ], + [ + 'typesVersions first matching range', + { + typesVersions: { + '*': { tsconfig: ['blue.json'] }, + '>=6': { tsconfig: ['red.json'] }, + }, + }, + 'blue.json', + ], + [ + 'exports overrides versions', + { + exports: './red.json', + typesVersions: { '*': { tsconfig: ['blue.json'] } }, + }, + 'red.json', + ], + ['malformed manifest defaults', '{', 'tsconfig.json'], + [ + 'JSONC manifest selects', + '{/*comment*/"tsconfig":"blue.json",}', + 'blue.json', + ], + ['BOM manifest selects', '\uFEFF{"tsconfig":"blue.json"}', 'blue.json'], + ['unquoted manifest defaults', '{tsconfig:"blue.json"}', 'tsconfig.json'], + [ + 'single quoted manifest defaults', + "{'tsconfig':'blue.json'}", + 'tsconfig.json', + ], + [ + 'hex manifest parse policy', + '{"tsconfig":"blue.json","number":0x1}', + 'blue.json', + ], + [ + 'hex overflow metadata retains tsconfig selection', + `{"tsconfig":"blue.json","other":0x${'F'.repeat(400)}}`, + 'blue.json', + ], + [ + 'separator overflow metadata retains tsconfig selection', + '{"tsconfig":"blue.json","other":1_0e999}', + 'blue.json', + ], + [ + 'hex overflow exports still encapsulates the package', + `{"exports":0x${'F'.repeat(400)}}`, + undefined, + ], + [ + 'negative hex overflow metadata retains tsconfig selection', + `{"tsconfig":"blue.json","other":-0x${'F'.repeat(400)}}`, + 'blue.json', + ], + [ + 'negative separator overflow metadata retains tsconfig selection', + '{"tsconfig":"blue.json","other":-1_0e999}', + 'blue.json', + ], + [ + 'standard JSON decimal overflow retains tsconfig selection', + '{"tsconfig":"blue.json","other":1e999}', + 'blue.json', + ], + [ + 'binary manifest parse policy', + '{"tsconfig":"blue.json","number":0b1}', + 'blue.json', + ], + [ + 'octal manifest parse policy', + '{"tsconfig":"blue.json","number":0o1}', + 'blue.json', + ], + [ + 'numeric separator manifest parse policy', + '{"tsconfig":"blue.json","number":1_000}', + 'blue.json', + ], + [ + 'leading decimal manifest parse policy', + '{"tsconfig":"blue.json","number":.5}', + 'blue.json', + ], + [ + 'trailing decimal manifest parse policy', + '{"tsconfig":"blue.json","number":1.}', + 'blue.json', + ], + [ + 'invalid separator manifest defaults', + '{"tsconfig":"blue.json","number":1__0}', + 'tsconfig.json', + ], + [ + 'invalid hex manifest defaults', + '{"tsconfig":"blue.json","number":0x}', + 'tsconfig.json', + ], + [ + 'leading zero manifest parse policy', + '{"tsconfig":"blue.json","number":01}', + 'tsconfig.json', + ], + [ + 'undefined manifest defaults', + '{"tsconfig":"blue.json","other":undefined}', + 'tsconfig.json', + ], + [ + 'invalid requested subpath', + { exports: { './*': './*.json' } }, + undefined, + 'preset/dir/../blue', + ], + [ + 'invalid first target segment', + { exports: './node_modules/blue.json' }, + undefined, + ], + [ + 'typesVersions invalid value ignored', + { typesVersions: { '*': 1 } }, + 'tsconfig.json', + ], + [ + 'typesVersions matched miss is terminal', + { typesVersions: { '*': { tsconfig: ['missing.json'] } } }, + undefined, + ], + [ + 'typesVersions invalid patterns ignored', + { typesVersions: { '*': { '**': ['blue.json'] } } }, + 'tsconfig.json', + ], + [ + 'typesVersions targets miss then hit', + { typesVersions: { '*': { tsconfig: ['missing.json', 'blue'] } } }, + 'blue.json', + ], + [ + 'version range misses use default', + { typesVersions: { '<6': { tsconfig: ['blue.json'] } } }, + 'tsconfig.json', + ], + [ + 'version mapping long prefix wins', + { + typesVersions: { + '*': { 'config/*': ['red.json'], 'config/long/*': ['blue.json'] }, + }, + }, + 'blue.json', + 'preset/config/long/item', + ], + [ + 'deep version matched miss stops default', + { typesVersions: { '*': { 'blue.json': ['missing.json'] } } }, + undefined, + 'preset/blue.json', + ], +] diff --git a/packages/plugin-utils/src/__tests__/tsconfig-native-freshness.mjs b/packages/plugin-utils/src/__tests__/tsconfig-native-freshness.mjs new file mode 100644 index 000000000..a8d39c224 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-native-freshness.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +const { createModuleResolver } = createRequire(import.meta.url)( + '../../dist/index.cjs', +) +const root = realpathSync(mkdtempSync(join(tmpdir(), 'devup-tsconfig-native-'))) +const file = (path, source) => { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, source) + return target +} +try { + // Given both configs and both module targets exist before the first setup. + file('tsconfig.json', '{"extends":"preset"}') + const manifest = file( + 'node_modules/preset/package.json', + '{"exports":"./red.json"}', + ) + for (const color of ['red', 'blue']) { + file( + `node_modules/preset/${color}.json`, + JSON.stringify({ + compilerOptions: { paths: { color: [`${color}.ts`] } }, + }), + ) + file(`node_modules/preset/${color}.ts`, `export const color='${color}'`) + } + const pid = process.pid + const first = createModuleResolver({ cwd: root }) + assert.equal(first('color', 'main.ts')?.code, "export const color='red'") + const fs = createRequire(import.meta.url)('node:fs') + const originalRead = fs.readFileSync + const reads = [] + fs.readFileSync = (...args) => { + reads.push(String(args[0])) + return originalRead(...args) + } + try { + for (let i = 0; i < 3; i++) first('color', 'main.ts') + assert.deepEqual( + reads, + Array(3).fill(join(root, 'node_modules/preset/red.ts')), + ) + } finally { + fs.readFileSync = originalRead + } + // When only exports changes and setup is reconstructed in this same native process. + writeFileSync(manifest, '{"exports":"./blue.json"}') + const second = createModuleResolver({ cwd: root }) + // Then the new resolver selects blue while the old setup remains frozen. + assert.equal(second('color', 'main.ts')?.code, "export const color='blue'") + assert.equal(first('color', 'main.ts')?.code, "export const color='red'") + assert.equal(process.pid, pid) + console.info( + JSON.stringify({ + runtime: process.release.name, + version: process.version, + pid, + before: 'red', + after: 'blue', + repeatedReads: reads, + }), + ) +} finally { + rmSync(root, { recursive: true, force: true }) +} diff --git a/packages/plugin-utils/src/__tests__/tsconfig-native.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-native.test.ts new file mode 100644 index 000000000..4bc37c779 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-native.test.ts @@ -0,0 +1,24 @@ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +it('selects blue in a genuine same-PID native process after exports-only setup reconstruction', () => { + // Given the checked-in persistent native driver and built public package. + const driver = join(import.meta.dir, 'tsconfig-native-freshness.mjs') + // When the foreground child inherits the caller's process guard. + const result = spawnSync('node', [driver], { + encoding: 'utf8', + timeout: 20000, + }) + // Then the driver proves fresh selection and zero repeated inheritance reads. + expect({ status: result.status, stderr: result.stderr }).toEqual({ + status: 0, + stderr: '', + }) + expect(JSON.parse(result.stdout)).toMatchObject({ + runtime: 'node', + before: 'red', + after: 'blue', + }) +}) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-package-scope.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-package-scope.test.ts new file mode 100644 index 000000000..9435dfca2 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-package-scope.test.ts @@ -0,0 +1,216 @@ +import * as fs from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { resolvePackage } from '../owned-module-resolution' +import { createResolutionInputs } from '../resolution-inputs' +import { readPathAliases } from '../tsconfig' +import { createPreparedFixture } from './prepared-graph-fixture' + +const ts: { + readonly sys: object + nodeNextJsonConfigResolver( + request: string, + importer: string, + host: object, + ): { readonly resolvedModule?: { readonly resolvedFileName: string } } +} = createRequire(import.meta.url)('@typescript/typescript6') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each([ + ['self', 'app', { name: 'app', exports: './blue.json' }, 'blue.json'], + [ + 'self subpath', + 'app/base', + { name: 'app', exports: { './base': './blue.json' } }, + 'blue.json', + ], + ['imports', '#base', { imports: { '#base': './blue.json' } }, 'blue.json'], + [ + 'imports pattern', + '#config/blue', + { imports: { '#config/*': './*.json' } }, + 'blue.json', + ], + [ + 'imports bare target ignores main and tsconfig', + '#base', + { imports: { '#base': 'preset' } }, + undefined, + ], + ['self unavailable', 'app', { name: 'app', exports: {} }, undefined], + ['imports unavailable', '#missing', { imports: {} }, undefined], + ['imports null', '#base', { imports: { '#base': null } }, undefined], + [ + 'imports parent prohibited', + '#base', + { imports: { '#base': '../blue.json' } }, + undefined, + ], + [ + 'imports absolute prohibited', + '#base', + { imports: { '#base': '/blue.json' } }, + undefined, + ], + ['invalid hash', '#', {}, undefined], + ['URI misses', 'https:blue', {}, undefined], + ['dot CJS branch', '.', {}, 'src/tsconfig.json'], + ['parent CJS branch', '..', {}, 'tsconfig.json'], +] as const)( + 'matches compiler nearest scope when %s', + (_, request, manifest, expected) => { + // Given a nearest scope and distinct real entry files. + const importer = file( + 'src/tsconfig.json', + JSON.stringify({ extends: request }), + ) + file('package.json', JSON.stringify(manifest)) + file('tsconfig.json', '{}') + file('blue.json', '{}') + file( + 'node_modules/preset/package.json', + '{"main":"blue.json","tsconfig":"red.json"}', + ) + file('node_modules/preset/blue.json', '{}') + file('node_modules/preset/red.json', '{}') + // When resolving with the installed compiler and the shared owned engine. + const target = expected === undefined ? undefined : join(root, expected) + const oracle = ts.nodeNextJsonConfigResolver(request, importer, ts.sys) + .resolvedModule?.resolvedFileName + expect(oracle && resolve(oracle)).toBe(target) + // Then both select the independent expected target, including misses. + expect( + resolvePackage(request, importer, { purpose: 'tsconfig-extends' }), + ).toBe(target) + }, +) + +it('keeps selected physical origins while recording lexical scope and target probes', () => { + // Given a scoped junction with nested inheritance and relative alias origins. + const config = file('src/tsconfig.json', '{"extends":"@scope/preset"}') + const manifest = file( + 'workspace/preset/package.json', + '{"exports":"./nested/base.json"}', + ) + const base = file( + 'workspace/preset/nested/base.json', + '{"extends":"../shared.json"}', + ) + const shared = file( + 'workspace/preset/shared.json', + '{"compilerOptions":{"paths":{"color":["value.ts"]}}}', + ) + const value = file('workspace/preset/value.ts', 'blue') + file('node_modules/@scope/placeholder.json', '{}') + fs.symlinkSync( + dirname(manifest), + join(root, 'node_modules/@scope/preset'), + 'junction', + ) + const inputs = createResolutionInputs() + // When inheritance selects through the owned resolver. + const aliases = readPathAliases(config, inputs) + // Then physical config owns relative paths but lexical selecting IO remains repairable. + expect(aliases.aliases[0]?.targets).toEqual([value]) + expect(inputs.snapshot().fileDependencies).toEqual( + [ + config, + base, + shared, + join(root, 'node_modules/@scope/preset/package.json'), + join(root, 'node_modules/@scope/preset/nested/base.json'), + ].sort(), + ) +}) + +it('reuses frozen setup evidence without rereading inheritance during repeated imports', () => { + // Given a public resolver and a narrow real read spy. + file('tsconfig.json', '{"extends":"preset"}') + file('node_modules/preset/package.json', '{"exports":"./base.json"}') + file( + 'node_modules/preset/base.json', + '{"compilerOptions":{"paths":{"color":["value.ts"]}}}', + ) + const value = file('node_modules/preset/value.ts', 'blue') + const reads: string[] = [] + const originalRead = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(target, receiver, args) { + reads.push(String(args[0])) + return Reflect.apply(target, receiver, args) + }, + }), + ) + try { + const resolver = createModuleResolver({ cwd: root }) + reads.length = 0 + // When repeated imports use the same setup. + for (let i = 0; i < 3; i++) + expect(resolver('color', 'main.ts')?.path).toBe(value) + // Then only source content, never configs or selecting manifests, is read. + expect(reads).toEqual([value, value, value]) + } finally { + read.mockRestore() + } +}) + +it('locates a cyclic imports map instead of exhausting the native stack', () => { + const importer = file('tsconfig.json', '{"extends":"#base"}') + file('package.json', '{"imports":{"#base":"#base"}}') + expect(() => + ts.nodeNextJsonConfigResolver('#base', importer, ts.sys), + ).toThrow(RangeError) + expect(() => readPathAliases(importer)).toThrow( + `${join(root, 'package.json')}:1:1: Cannot load configuration: Package imports cycle`, + ) +}) + +it.each(['exports', 'manifestless', 'nested'])( + 'searches outward after an unresolvable nearer %s package', + (mode) => { + // Given a nearer existing package and an independently resolving outer candidate. + const importer = file('src/tsconfig.json', '{}') + const request = mode === 'nested' ? 'preset/config' : 'preset' + file( + 'src/node_modules/preset/package.json', + mode === 'exports' ? '{"exports":{}}' : '{}', + ) + file('node_modules/preset/package.json', '{}') + const expected = file( + `node_modules/preset/${mode === 'nested' ? 'config/' : ''}tsconfig.json`, + '{}', + ) + // When trying the complete ancestor search. + const oracle = ts.nodeNextJsonConfigResolver(request, importer, ts.sys) + .resolvedModule?.resolvedFileName + // Then nearest-manifest existence cannot prematurely terminate resolution. + expect(oracle && resolve(oracle)).toBe(expected) + expect( + resolvePackage(request, importer, { purpose: 'tsconfig-extends' }), + ).toBe(expected) + }, +) + +it.each(['.preset', '@scope/preset'])( + 'treats %s as a package rather than a relative path', + (request) => { + // Given a legal package name with its default config. + const importer = file('tsconfig.json', JSON.stringify({ extends: request })) + file(`node_modules/${request}/package.json`, '{}') + const target = file(`node_modules/${request}/tsconfig.json`, '{}') + // When resolving inheritance. + const oracle = ts.nodeNextJsonConfigResolver(request, importer, ts.sys) + .resolvedModule?.resolvedFileName + expect(oracle && resolve(oracle)).toBe(target) + // Then Devup uses the same package selection. + expect(readPathAliases(importer).aliases).toEqual([]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-packaged-resolution.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-packaged-resolution.test.ts new file mode 100644 index 000000000..60f2cfbfb --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-packaged-resolution.test.ts @@ -0,0 +1,199 @@ +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { readPathAliases } from '../tsconfig' +import { createPreparedFixture } from './prepared-graph-fixture' +import { manifestCases } from './tsconfig-manifest-cases' + +const ts: { + readonly version: string + readonly sys: object + nodeNextJsonConfigResolver( + request: string, + importer: string, + host: object, + ): { + readonly resolvedModule?: { readonly resolvedFileName: string } + } +} = createRequire(import.meta.url)('@typescript/typescript6') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +const cases: readonly [string, unknown, string | undefined, string?][] = [ + ['ignores JSON main', { main: 'red.json' }, 'tsconfig.json'], + [ + 'ignores module/types fields', + { module: 'red.json', types: 'red.json' }, + 'tsconfig.json', + ], + ['uses tsconfig field', { tsconfig: 'blue.json' }, 'blue.json'], + ['appends field extension', { tsconfig: 'blue' }, 'blue.json'], + [ + 'falls back after field miss', + { tsconfig: 'missing.json' }, + 'tsconfig.json', + ], + ['ignores empty field', { tsconfig: '' }, 'tsconfig.json'], + ['ignores non-string field', { tsconfig: 1 }, 'tsconfig.json'], + [ + 'exports precedes field', + { exports: './blue.json', tsconfig: 'red.json' }, + 'blue.json', + ], + [ + 'types precedes require by insertion', + { + exports: { + import: './red.json', + types: './blue.json', + require: './red.json', + }, + }, + 'blue.json', + ], + [ + 'require precedes types by insertion', + { exports: { require: './red.json', types: './blue.json' } }, + 'red.json', + ], + [ + 'node is active', + { exports: { module: './red.json', node: './blue.json' } }, + 'blue.json', + ], + [ + 'early default wins', + { exports: { default: './red.json', types: './blue.json' } }, + 'red.json', + ], + [ + 'condition miss advances', + { exports: { require: './missing.json', types: './blue.json' } }, + 'blue.json', + ], + [ + 'condition null blocks', + { exports: { require: null, types: './blue.json' } }, + undefined, + ], + [ + 'array miss advances', + { exports: ['./missing.json', './blue.json'] }, + 'blue.json', + ], + [ + 'invalid array target advances', + { exports: ['../red.json', './blue.json'] }, + 'blue.json', + ], + ['array null blocks', { exports: [null, './blue.json'] }, undefined], + ['empty array misses', { exports: [] }, undefined], + ['false target advances', { exports: [false, './blue.json'] }, 'blue.json'], + ['null exports falls back', { exports: null }, 'tsconfig.json'], + ['false exports falls back', { exports: false }, 'tsconfig.json'], + ['empty exports falls back', { exports: '' }, 'tsconfig.json'], + ['empty map encapsulates', { exports: {} }, undefined], + ['root map encapsulates', { exports: { './red': './red.json' } }, undefined], + ['export JS substitutes JSON', { exports: './red.js' }, 'red.json'], + ['export TS substitutes JSON', { exports: './red.d.ts' }, 'red.json'], + ['export extensionless stays exact', { exports: './blue' }, undefined], + ['export directory stays exact', { exports: './dir' }, undefined], + ['explicit subpath resolves', {}, 'blue.json', 'preset/blue.json'], + ['deep JS substitutes JSON', {}, 'red.json', 'preset/red.js'], + ['deep unknown appends JSON', {}, 'red.xyz.json', 'preset/red.xyz'], + ['deep MJS appends JSON', {}, 'red.mjs.json', 'preset/red.mjs'], + ['directory default is tsconfig', {}, 'dir/tsconfig.json', 'preset/dir'], + ['field directory default', { tsconfig: 'dir' }, 'dir/tsconfig.json'], + [ + 'exact precedes patterns', + { exports: { './*': './red.json', './blue': './blue.json' } }, + 'blue.json', + 'preset/blue', + ], + [ + 'longest pattern wins', + { exports: { './*': './red.json', './config/*': './blue.json' } }, + 'blue.json', + 'preset/config/value', + ], + [ + 'pattern trailer matches', + { exports: { './*.json': './*.json' } }, + 'blue.json', + 'preset/blue.json', + ], + [ + 'slash prefix matches', + { exports: { './config/': './' } }, + 'blue.json', + 'preset/config/blue.json', + ], + [ + 'slash target needs slash', + { exports: { './config/': './blue.json' } }, + undefined, + 'preset/config/blue.json', + ], + [ + 'mixed map root follows dot branch', + { exports: { types: './red.json', '.': './blue.json' } }, + 'blue.json', + ], + [ + 'mixed map deep is unavailable', + { exports: { types: './red.json', './blue': './blue.json' } }, + undefined, + 'preset/blue', + ], + ['invalid target dot segment', { exports: './dir/../blue.json' }, undefined], + [ + 'invalid target node_modules segment', + { exports: './dir/node_modules/blue.json' }, + undefined, + ], + ...manifestCases, +] + +it.each(cases)( + 'matches installed TypeScript when %s', + (_, manifest, expected, request = 'preset') => { + // Given independent, distinct config targets and the installed compiler oracle. + expect(ts.version).toBe('6.0.3') + const config = file('tsconfig.json', JSON.stringify({ extends: request })) + file( + 'node_modules/preset/package.json', + typeof manifest === 'string' ? manifest : JSON.stringify(manifest), + ) + for (const target of [ + 'red.json', + 'blue.json', + 'tsconfig.json', + 'dir/tsconfig.json', + 'red.xyz.json', + 'red.mjs.json', + ]) + file( + `node_modules/preset/${target}`, + JSON.stringify({ compilerOptions: { paths: { selected: [target] } } }), + ) + const target = + expected === undefined + ? undefined + : join(root, 'node_modules/preset', expected) + // When both resolvers select against the same real files without a shared cache. + const oracle = ts.nodeNextJsonConfigResolver(request, config, ts.sys) + .resolvedModule?.resolvedFileName + // Then both must match the fixture's independent expected target (not each other alone). + expect(oracle === undefined ? undefined : resolve(oracle)).toBe(target) + if (target === undefined) + expect(() => readPathAliases(config)).toThrow('Cannot load configuration') + else + expect(readPathAliases(config).aliases[0]?.targets[0]).toBe( + resolve(join(target, '..'), expected ?? ''), + ) + }, +) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-policy-corrections.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-policy-corrections.test.ts new file mode 100644 index 000000000..5cd7cae16 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-policy-corrections.test.ts @@ -0,0 +1,166 @@ +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { resolvePackage } from '../owned-module-resolution' +import { createPreparedFixture } from './prepared-graph-fixture' + +const ts: { + readonly version: string + readonly sys: object + nodeNextJsonConfigResolver( + request: string, + importer: string, + host: object, + ): { + readonly resolvedModule?: { readonly resolvedFileName: string } + } +} = createRequire(import.meta.url)('@typescript/typescript6') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) +const red = '{"compilerOptions":{"paths":{"color":["red.ts"]}}}' +const blue = '{"compilerOptions":{"paths":{"color":["blue.ts"]}}}' + +it.each([false, true])( + 'retains root versions in manifestless child directories (root field=%s)', + (withField) => { + // Given child configs with distinct aliases and root-only version metadata. + const importer = file('tsconfig.json', '{"extends":"preset/dir"}') + file( + 'node_modules/preset/package.json', + JSON.stringify({ + ...(withField ? { tsconfig: 'red.json' } : {}), + typesVersions: { '*': { tsconfig: ['blue.json'] } }, + }), + ) + file('node_modules/preset/red.json', red) + file('node_modules/preset/dir/red.json', red) + file('node_modules/preset/dir/tsconfig.json', red) + const selected = file('node_modules/preset/dir/blue.json', blue) + file('node_modules/preset/dir/red.ts', "export const color='red'") + const target = file( + 'node_modules/preset/dir/blue.ts', + "export const color='blue'", + ) + // When resolving the child directory without a child package.json. + const oracle = ts.nodeNextJsonConfigResolver('preset/dir', importer, ts.sys) + .resolvedModule?.resolvedFileName + // Then root versions apply relative to the child, but its root tsconfig field does not. + expect(oracle && resolve(oracle)).toBe(selected) + expect( + resolvePackage('preset/dir', importer, { purpose: 'tsconfig-extends' }), + ).toBe(selected) + expect(createModuleResolver({ cwd: root })('color', 'main.ts')?.path).toBe( + target, + ) + }, +) + +it.each([ + [ + 'miss', + './missing.json', + 'node_modules/preset/blue.json', + 'node_modules/preset/blue.ts', + ], + ['resolved', './red.json', 'red.json', 'red.ts'], + ['blocked', { require: null, default: './red.json' }, undefined, undefined], +] as const)( + 'distinguishes self exports %s from installed same-name fallback', + (_, exports, selectedPath, targetPath) => { + // Given a self scope and an independently resolving same-name installed package. + const importer = file('tsconfig.json', '{"extends":"preset"}') + file('package.json', JSON.stringify({ name: 'preset', exports })) + file('red.json', red) + file('red.ts', "export const color='red'") + file('node_modules/preset/package.json', '{"exports":"./blue.json"}') + file('node_modules/preset/blue.json', blue) + file('node_modules/preset/blue.ts', "export const color='blue'") + const selected = + selectedPath === undefined ? undefined : join(root, selectedPath) + // When the nearest self lookup returns a resolved target, a miss, or actual null block. + const oracle = ts.nodeNextJsonConfigResolver('preset', importer, ts.sys) + .resolvedModule?.resolvedFileName + // Then only a miss reaches installed fallback; a real block stays unresolved. + expect(oracle && resolve(oracle)).toBe(selected) + expect( + resolvePackage('preset', importer, { purpose: 'tsconfig-extends' }), + ).toBe(selected) + if (targetPath === undefined) + expect(() => createModuleResolver({ cwd: root })).toThrow( + 'Cannot resolve tsconfig extends', + ) + else + expect( + createModuleResolver({ cwd: root })('color', 'main.ts')?.path, + ).toBe(join(root, targetPath)) + }, +) + +it.each([ + ['./dir\\..\\blue.json', undefined], + ['./dir\\blue.json', 'node_modules/preset/dir/blue.json'], + ['.\\blue.json', undefined], +] as const)( + 'normalizes export components without broadening raw eligibility for %s', + (exports, selectedPath) => { + // Given both escaped and ordinary candidate files, with exact raw export syntax. + const importer = file('tsconfig.json', '{}') + file('node_modules/preset/package.json', JSON.stringify({ exports })) + file('node_modules/preset/blue.json', blue) + file('node_modules/preset/dir/blue.json', blue) + const selected = + selectedPath === undefined ? undefined : join(root, selectedPath) + // When TypeScript and the owned engine validate then resolve components. + const oracle = ts.nodeNextJsonConfigResolver('preset', importer, ts.sys) + .resolvedModule?.resolvedFileName + // Then backslash parent components fail while a valid raw ./ prefix can resolve normalized children. + expect(oracle && resolve(oracle)).toBe(selected) + expect( + resolvePackage('preset', importer, { purpose: 'tsconfig-extends' }), + ).toBe(selected) + }, +) + +it.each([ + [ + 'preset/', + 'node_modules/preset/tsconfig.json', + 'node_modules/preset/blue.ts', + ], + ['preset', 'node_modules/preset.json', 'node_modules/red.ts'], + [ + 'preset/dir/', + 'node_modules/preset/dir/tsconfig.json', + 'node_modules/preset/dir/blue.ts', + ], +] as const)( + 'preserves directory identity and bare sibling precedence for %s', + (request, selectedPath, targetPath) => { + // Given existing sibling JSON files and distinct directory configs. + const importer = file('tsconfig.json', JSON.stringify({ extends: request })) + file('node_modules/preset.json', red) + file('node_modules/red.ts', "export const color='red'") + file('node_modules/preset/package.json', '{}') + file('node_modules/preset/tsconfig.json', blue) + file('node_modules/preset/blue.ts', "export const color='blue'") + file('node_modules/preset/dir.json', red) + file('node_modules/preset/dir/tsconfig.json', blue) + file('node_modules/preset/dir/blue.ts', "export const color='blue'") + // When selecting a bare or trailing-separator request. + const oracle = ts.nodeNextJsonConfigResolver(request, importer, ts.sys) + .resolvedModule?.resolvedFileName + // Then the request's independent file/directory priority determines selection. + expect(oracle && resolve(oracle)).toBe(join(root, selectedPath)) + expect( + resolvePackage(request, importer, { purpose: 'tsconfig-extends' }), + ).toBe(join(root, selectedPath)) + expect(createModuleResolver({ cwd: root })('color', 'main.ts')?.path).toBe( + join(root, targetPath), + ) + }, +) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-repair-inputs.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-repair-inputs.test.ts new file mode 100644 index 000000000..b7df5ce75 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-repair-inputs.test.ts @@ -0,0 +1,158 @@ +import * as fs from 'node:fs' +import { join, resolve } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { ConfigLoadError } from '../load-config' +import { createResolutionInputs } from '../resolution-inputs' +import { readPathAliases } from '../tsconfig' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('records every actual failed lexical probe and consulted read without evidence-only lookups', () => { + // Given a nearer miss, an exports array miss, and an outer resolving package. + const config = file('src/tsconfig.json', '{"extends":"preset"}') + const nearer = file('src/node_modules/preset/package.json', '{"exports":{}}') + const manifest = file( + 'node_modules/preset/package.json', + '{"exports":["./absent.json","./blue.json"]}', + ) + const blue = file('node_modules/preset/blue.json', '{}') + const missing = new Set() + const reads: string[] = [] + const originalStat = fs.statSync + const originalRead = fs.readFileSync + const stat = spyOn(fs, 'statSync').mockImplementation( + new Proxy(originalStat, { + apply(target, receiver, args) { + try { + return Reflect.apply(target, receiver, args) + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ) + missing.add(resolve(String(args[0]))) + throw error + } + }, + }), + ) + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(target, receiver, args) { + reads.push(String(args[0])) + return Reflect.apply(target, receiver, args) + }, + }), + ) + const inputs = createResolutionInputs() + try { + // When one setup performs real owned selection. + readPathAliases(config, inputs) + // Then repair evidence is exactly the real lexical IO, with no second resolution pass. + expect(reads).toEqual([config, nearer, manifest, blue]) + expect(inputs.snapshot()).toEqual({ + fileDependencies: [config, nearer, manifest, blue].sort(), + missingDependencies: [...missing].sort(), + }) + expect(missing).toContain(join(root, 'node_modules/preset/absent.json')) + } finally { + stat.mockRestore() + read.mockRestore() + } +}) + +it.each(['EACCES', 'EIO'])( + 'retains actual manifest read failure %s and its lexical repair input', + (code) => { + // Given a real selecting manifest whose read fails at the narrow filesystem seam. + const config = file('tsconfig.json', '{"extends":"preset"}') + const manifest = file( + 'node_modules/preset/package.json', + '{"exports":"./blue.json"}', + ) + file('node_modules/preset/blue.json', '{}') + const failure = Object.assign(new Error('denied'), { code }) + const original = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(original, { + apply(target, receiver, args) { + if (args[0] === manifest) throw failure + return Reflect.apply(target, receiver, args) + }, + }), + ) + const inputs = createResolutionInputs() + try { + let caught: unknown + // When setup attempts the owned read. + try { + readPathAliases(config, inputs) + } catch (error) { + caught = error + } + // Then located config errors preserve the original IO exception, not a resolution miss. + expect(caught).toBeInstanceOf(ConfigLoadError) + if (caught instanceof ConfigLoadError) expect(caught.cause).toBe(failure) + expect(inputs.snapshot().fileDependencies).toEqual( + [config, manifest].sort(), + ) + } finally { + read.mockRestore() + } + }, +) + +it.each(['./exact.txt', './folder', './missing.json'])( + 'retains TypeScript direct extends rules for %s', + (request) => { + // Given an exact non-JSON config and a directory that must not gain index lookup. + const config = file('tsconfig.json', JSON.stringify({ extends: request })) + file('exact.txt', '{"compilerOptions":{"paths":{"value":["blue.ts"]}}}') + file('folder/tsconfig.json', '{}') + const inputs = createResolutionInputs() + // When loading the direct extends branch. + if (request === './exact.txt') { + // Then existing exact filenames are accepted regardless of extension. + expect(readPathAliases(config, inputs).aliases[0]?.targets).toEqual([ + join(root, 'blue.ts'), + ]) + } else { + expect(() => readPathAliases(config, inputs)).toThrow( + 'Cannot load configuration', + ) + expect(inputs.snapshot().missingDependencies).toContain( + join(root, request === './folder' ? 'folder.json' : 'missing.json'), + ) + } + }, +) + +it('publishes setup failure probes through the unchanged public observer', () => { + // Given a blocked export with an existing unexported config. + const config = file('tsconfig.json', '{"extends":"preset"}') + const manifest = file( + 'node_modules/preset/package.json', + '{"exports":{".":null}}', + ) + file('node_modules/preset/tsconfig.json', '{}') + const observations: unknown[] = [] + // When synchronous public setup fails. + expect(() => + createModuleResolver({ + cwd: root, + onResolutionInputs: (inputs) => observations.push(inputs), + }), + ).toThrow(`Cannot resolve tsconfig extends "preset"`) + // Then its finally observer retains the real selecting manifest, even with no returned callable. + expect(observations).toContainEqual( + expect.objectContaining({ fileDependencies: [config, manifest].sort() }), + ) +}) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-version-extensions.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-version-extensions.test.ts new file mode 100644 index 000000000..f25770a8b --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-version-extensions.test.ts @@ -0,0 +1,91 @@ +import { createRequire } from 'node:module' +import { resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { resolvePackage } from '../owned-module-resolution' +import { createPreparedFixture } from './prepared-graph-fixture' + +const ts: { + readonly version: string + readonly sys: object + nodeNextJsonConfigResolver( + request: string, + importer: string, + host: object, + ): { + readonly resolvedModule?: { readonly resolvedFileName: string } + } +} = createRequire(import.meta.url)('@typescript/typescript6') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +for (const request of ['preset', 'preset/entry']) + it.each([ + ['js', true], + ['jsx', true], + ['mjs', true], + ['cjs', true], + ['ts', true], + ['tsx', true], + ['mts', true], + ['cts', true], + ['json', true], + ['d.ts', true], + ['d.mts', true], + ['d.cts', true], + ['mjsx', false], + ['cjsx', false], + ['mtsx', false], + ['ctsx', false], + ] as const)( + `uses only installed TS recognized extension %s for ${request}`, + (extension, recognized) => { + // Given a mapped config whose content cannot overwrite the separate value.ts module. + expect(ts.version).toBe('6.0.3') + const importer = file( + 'tsconfig.json', + JSON.stringify({ extends: request }), + ) + file( + 'node_modules/preset/package.json', + JSON.stringify({ + typesVersions: { + '*': { + tsconfig: [`blue.${extension}`], + entry: [`blue.${extension}`], + }, + }, + }), + ) + const config = file( + `node_modules/preset/blue.${extension}`, + '{"compilerOptions":{"paths":{"color":["value.ts"]}}}', + ) + const target = file( + 'node_modules/preset/value.ts', + "export const color='blue'", + ) + file('node_modules/preset/tsconfig.json', '{}') + const selected = recognized ? config : undefined + // When this selecting seam encounters an explicit substitution extension. + const oracle = ts.nodeNextJsonConfigResolver(request, importer, ts.sys) + .resolvedModule?.resolvedFileName + // Then both match the independently enumerated extension policy, not each other alone. + expect(oracle && resolve(oracle)).toBe(selected) + expect( + resolvePackage(request, importer, { purpose: 'tsconfig-extends' }), + ).toBe(selected) + if (recognized) + expect( + createModuleResolver({ cwd: root })('color', 'main.ts')?.path, + ).toBe(target) + else + expect(() => createModuleResolver({ cwd: root })).toThrow( + 'Cannot resolve tsconfig extends', + ) + }, + ) diff --git a/packages/plugin-utils/src/__tests__/tsconfig.test.ts b/packages/plugin-utils/src/__tests__/tsconfig.test.ts new file mode 100644 index 000000000..9fd01d147 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig.test.ts @@ -0,0 +1,227 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { readPathAliases } from '../tsconfig' + +const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared', +) +let root: string +beforeEach(() => { + mkdirSync(parent, { recursive: true }) + root = realpathSync(mkdtempSync(join(parent, 'tsconfig-'))) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +function fixture(path: string, value: unknown): string { + const file = join(root, path) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, typeof value === 'string' ? value : JSON.stringify(value)) + return file +} + +describe('tsconfig inheritance', () => { + it('resolves inherited baseUrl and paths relative to their defining config', () => { + fixture('config/base.json', { + compilerOptions: { baseUrl: '../', paths: { '@base/*': ['src/*'] } }, + }) + fixture('tsconfig.json', { extends: './config/base.json' }) + const value = fixture('src/value.mts', 'export const value = 1') + const entry = fixture('src/main.ts', "import '@base/value'") + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('@base/value', entry)?.path).toBe(value) + const graph = buildStaticImportGraph( + join(root, 'src'), + join(root, 'tsconfig.json'), + { cwd: root }, + ) + expect(graph.staticImports.get(entry)).toEqual(new Set([value])) + expect(resolver('src/value', entry)?.path).toBe(value) + }) + it('uses paths origin without baseUrl and replaces rather than deep-merging paths', () => { + fixture('config/base.json', { + compilerOptions: { paths: { '@base/*': ['values/*'] } }, + }) + const value = fixture('config/values/value.cts', 'export const value = 1') + fixture('tsconfig.json', { extends: './config/base' }) + expect( + createModuleResolver({ cwd: root })('@base/value', 'main.ts')?.path, + ).toBe(value) + fixture('tsconfig.json', { + extends: './config/base', + compilerOptions: { paths: { '@child': ['child'] } }, + }) + const child = fixture('child.ts', 'child') + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('@base/value', 'main.ts')).toBeUndefined() + expect(resolver('@child', 'main.ts')?.path).toBe(child) + expect(resolver('@child-extra', 'main.ts')).toBeUndefined() + }) + it('applies an overridden baseUrl to inherited paths and merges array parents left to right', () => { + fixture('first.json', { + compilerOptions: { baseUrl: './old', paths: { '@x': ['value'] } }, + }) + fixture('second.json', { compilerOptions: { paths: { '@y': ['value'] } } }) + fixture('tsconfig.json', { + extends: ['./first.json', './second.json'], + compilerOptions: { baseUrl: './new' }, + }) + const value = fixture('new/value.ts', 'new') + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('@y', 'main.ts')?.path).toBe(value) + expect(resolver('@x', 'main.ts')).toBeUndefined() + }) + it('honors exact aliases and longest wildcard prefixes without fallback to shorter aliases', () => { + fixture('tsconfig.json', { + compilerOptions: { + paths: { + '@/*': ['fallback/*'], + '@/specific/*': ['missing/*'], + '@/exact': ['value'], + '@end/*/tail': ['value'], + }, + }, + }) + fixture('fallback/specific/item.ts', 'fallback') + const value = fixture('value.ts', 'value') + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('@/specific/item', 'main.ts')).toBeUndefined() + expect(resolver('@/exact', 'main.ts')?.path).toBe(value) + expect(resolver('@/exact-more', 'main.ts')).toBeUndefined() + expect(resolver('@end/tail', 'main.ts')).toBeUndefined() + expect(resolver('@end/a/tail', 'main.ts')?.path).toBe(value) + }) + it('parses comments and trailing commas without damaging strings', () => { + fixture( + 'tsconfig.json', + '{/* comment */"compilerOptions": {"paths": {"@x": ["src/value",], // trailing\n},}, "text": "quote\\"value,}",}', + ) + const value = fixture('src/value.ts', 'value') + expect(createModuleResolver({ cwd: root })('@x', 'main.ts')?.path).toBe( + value, + ) + }) + it('inherits package configs, explicit package subpaths and package tsconfig fields', () => { + fixture('node_modules/config/package.json', {}) + fixture('node_modules/config/tsconfig.json', { + compilerOptions: { paths: { '@pkg': ['value'] } }, + }) + const value = fixture('node_modules/config/value.ts', 'value') + fixture('tsconfig.json', { extends: 'config' }) + expect(createModuleResolver({ cwd: root })('@pkg', 'main.ts')?.path).toBe( + value, + ) + fixture('tsconfig.json', { extends: 'config/tsconfig.json' }) + expect(createModuleResolver({ cwd: root })('@pkg', 'main.ts')?.path).toBe( + value, + ) + fixture('node_modules/custom/package.json', { + tsconfig: 'configs/base.json', + }) + fixture('node_modules/custom/configs/base.json', { + compilerOptions: { paths: { '@custom': ['value'] } }, + }) + const custom = fixture('node_modules/custom/configs/value.ts', 'custom') + fixture('tsconfig.json', { extends: 'custom' }) + expect( + createModuleResolver({ cwd: root })('@custom', 'main.ts')?.path, + ).toBe(custom) + }) + it('uses package tsconfig instead of JavaScript main when both are present', () => { + fixture('node_modules/config/package.json', { + main: 'index.js', + tsconfig: 'base.json', + }) + fixture('node_modules/config/index.js', 'module.exports = {}') + fixture('node_modules/config/base.json', { + compilerOptions: { paths: { '@pkg': ['value'] } }, + }) + const value = fixture('node_modules/config/value.ts', 'value') + fixture('tsconfig.json', { extends: 'config' }) + expect(createModuleResolver({ cwd: root })('@pkg', 'main.ts')?.path).toBe( + value, + ) + }) + it('defaults a package with JavaScript main to tsconfig.json', () => { + fixture('node_modules/config/package.json', { main: 'index.js' }) + fixture('node_modules/config/index.js', 'module.exports = {}') + fixture('node_modules/config/tsconfig.json', {}) + fixture('tsconfig.json', { extends: 'config' }) + expect(readPathAliases(join(root, 'tsconfig.json')).aliases).toEqual([]) + }) + it('allows diamond inheritance without mistaking repeated parents for a cycle', () => { + fixture('base.json', { compilerOptions: { paths: { '@pkg': ['value'] } } }) + fixture('left.json', { extends: './base' }) + fixture('right.json', { extends: './base' }) + fixture('tsconfig.json', { extends: ['./left', './right'] }) + const value = fixture('value.ts', 'value') + expect(createModuleResolver({ cwd: root })('@pkg', 'main.ts')?.path).toBe( + value, + ) + }) +}) + +describe('tsconfig load errors', () => { + it('reports the missing default config after a package has an unresolved main entry', () => { + fixture('node_modules/incomplete/package.json', { main: 'missing.js' }) + fixture('tsconfig.json', { extends: 'incomplete' }) + expect(() => readPathAliases(join(root, 'tsconfig.json'))).toThrow( + 'tsconfig.json', + ) + }) + + it.each([ + '{', + 'null', + '{"extends":1}', + '{"extends":[1]}', + '{"compilerOptions":1}', + '{"compilerOptions":{"baseUrl":1}}', + '{"compilerOptions":{"paths":[]}}', + '{"compilerOptions":{"paths":{"@x":[1]}}}', + '{/* unterminated', + ])('rejects invalid config %s with its location', (content) => { + const file = fixture('tsconfig.json', content) + expect(() => createModuleResolver({ cwd: root })).toThrow(file) + }) + it.each(['./missing', 'not-installed'])( + 'reports missing inherited config %s', + (parent) => { + fixture('tsconfig.json', { extends: parent }) + expect(() => createModuleResolver({ cwd: root })).toThrow( + 'Cannot load configuration', + ) + }, + ) + it('reports canonical config cycles instead of recursing indefinitely', () => { + fixture('tsconfig.json', { extends: './base' }) + fixture('base.json', { extends: './tsconfig' }) + expect(() => createModuleResolver({ cwd: root })).toThrow( + 'inheritance cycle', + ) + }) + it('reports an inherited package with no tsconfig rather than using an empty config', () => { + fixture('node_modules/private/package.json', { + exports: { './allowed': './base.json' }, + }) + fixture('node_modules/private/base.json', {}) + fixture('tsconfig.json', { extends: 'private' }) + expect(() => createModuleResolver({ cwd: root })).toThrow( + 'Cannot load configuration', + ) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/typescript-version-range.test.ts b/packages/plugin-utils/src/__tests__/typescript-version-range.test.ts new file mode 100644 index 000000000..a5641e26a --- /dev/null +++ b/packages/plugin-utils/src/__tests__/typescript-version-range.test.ts @@ -0,0 +1,86 @@ +import { createRequire } from 'node:module' + +import { expect, it } from 'bun:test' + +import { matchesTypeScriptVersion } from '../typescript-version-range' + +const ts: { + getPackageJsonTypesVersionsPaths( + value: object, + ): { readonly version: string } | undefined +} = createRequire(import.meta.url)('@typescript/typescript6') +it.each([ + ['', true], + ['*', true], + ['||', true], + ['x', true], + ['X', true], + ['6', true], + ['6.0', true], + ['6.0.3', true], + ['=6.0.3', true], + ['7', false], + ['~6', true], + ['~6.0', true], + ['~6.0.3', true], + ['~6.1', false], + ['^6', true], + ['^6.0.3', true], + ['^0', false], + ['^0.1', false], + ['^0.0.3', false], + ['<6', false], + ['<=6', true], + ['>6', false], + ['>=6', true], + ['<=6.0', true], + ['>6.0', false], + ['<6.0.3', false], + ['<=6.0.3', true], + ['>6.0.3', false], + ['>=6.0.3', true], + ['>5.9', true], + ['<7.0', true], + ['<*', false], + ['>*', false], + ['>=*', true], + ['6 - 6', true], + ['6.0 - 6.0', true], + ['6.0.3 - 6.0.3', true], + ['* - 6', true], + ['6 - *', true], + ['* - *', true], + ['7 - 8', false], + ['bad - 6', false], + ['6 - bad', false], + ['>=6.0.3-beta', true], + ['>6.0.3-0', true], + ['6.0.3+build', true], + ['6.0.3-BETA.1', false], + ['6.0.3+abc.1', true], + ['>=5 <7', true], + ['<5 || >=6', true], + ['>=7 || <5', false], + [' 6 ', true], + ['>= 6', false], + ['v6', false], + ['6.0.3.4', false], + ['01', false], +] as const)('tests pinned compiler version against %s', (range, expected) => { + // Given a range and an independently specified answer for stable 6.0.3. + const selection = ts.getPackageJsonTypesVersionsPaths({ [range]: {} }) + // When using the installed parser and the owned range evaluator. + expect(selection !== undefined).toBe(expected) + // Then both match the independent answer, including invalid ranges. + expect(matchesTypeScriptVersion(range)).toBe(expected) +}) + +it.each(['6.0.3-01', '6.0.3-a..b', '6.0.3+..'])( + 'preserves compiler failure for malformed components in %s', + (range) => { + expect(() => ts.getPackageJsonTypesVersionsPaths({ [range]: {} })).toThrow() + expect(() => matchesTypeScriptVersion(range)).toThrow( + 'Invalid TypeScript version range', + ) + }, +) diff --git a/packages/plugin-utils/src/build-session.ts b/packages/plugin-utils/src/build-session.ts new file mode 100644 index 000000000..bd16e68f3 --- /dev/null +++ b/packages/plugin-utils/src/build-session.ts @@ -0,0 +1,23 @@ +export interface ResettableEngine { + resetBuildState(): void +} + +let running = 0 + +/** + * Start a build in this process. The engine is shared by every plugin instance + * in the process, so what an earlier build left in it (prefix, atom hoisting, + * routes, buckets, names, numbers, styles) would otherwise leak into this one: + * it is reset unless another build is still running, whose state it would + * wipe. Returns the function that ends the build. + */ +export function beginBuild(engine: Partial): () => void { + if (running === 0) engine.resetBuildState?.() + running += 1 + let ended = false + return () => { + if (ended) return + ended = true + running -= 1 + } +} diff --git a/packages/plugin-utils/src/compiled-guard.ts b/packages/plugin-utils/src/compiled-guard.ts new file mode 100644 index 000000000..641de0f75 --- /dev/null +++ b/packages/plugin-utils/src/compiled-guard.ts @@ -0,0 +1,142 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, extname, join, resolve } from 'node:path' + +import type { WasmImportAliases } from './types' + +const rootExports = new Set([ + 'Box', + 'Button', + 'Center', + 'Flex', + 'Grid', + 'Image', + 'Input', + 'Text', + 'VStack', + 'css', + 'globalCss', + 'keyframes', + 'styled', +]) +const compatExports = new Set(['Global', 'createGlobalStyle']) +const stylexExports = new Set([ + 'create', + 'props', + 'attrs', + 'keyframes', + 'firstThatWorks', + 'include', + 'defineVars', + 'createTheme', + 'createThemeContract', + 'defineConsts', + 'positionTry', + 'viewTransitionClass', + 'types', +]) +const compatPackages = new Set([ + '@emotion/react', + '@emotion/styled', + 'styled-components', + '@vanilla-extract/css', +]) + +export interface CompiledReference { + readonly request: string + readonly ids: readonly string[] + readonly line: number + readonly column: number +} + +export class UntransformedSourceError extends Error { + readonly name = 'UntransformedSourceError' + constructor( + readonly filename: string, + readonly reference: CompiledReference, + alias = false, + ) { + const extension = extname(filename.split('?')[0]) + super( + `${filename}:${reference.line}:${reference.column}: (in compiled output) untransformed module cannot use import '${reference.request}' (${reference.ids.join('.')}) at build time under extension '${extension}'; add '${extension}' to mdxExtensions and configure its MDX compiler${alias ? `, or set importAliases[${JSON.stringify(reference.request)}]=false for an intended runtime alias` : ''}`, + ) + } +} + +/** Same known compatibility targets as the extractor; unknown exports stay runtime. */ +export function isCompileTimeAlias( + reference: CompiledReference, + aliases: WasmImportAliases, +): boolean { + const { request, ids } = reference + if (!Object.hasOwn(aliases, request) || !ids.length) return false + const name = ids[0] === 'default' ? aliases[request] : ids[0] + if (!name) return false + if ( + request === '@vanilla-extract/css' && + ['style', 'globalStyle'].includes(name) + ) + return true + return ( + ['css', 'keyframes', 'styled', 'Global', 'createGlobalStyle'].includes( + name, + ) || + (!compatPackages.has(request) && rootExports.has(name)) + ) +} + +function targets(value: unknown): string[] { + if (typeof value === 'string') return /\.[cm]?js$/.test(value) ? [value] : [] + if (!value || typeof value !== 'object') return [] + return Object.values(value).flatMap(targets) +} + +/** Identify an actual public export surface, never a basename/package-directory guess. */ +export function createCompileTimeClassifier(libPackage: string) { + const cache = new Map() + return (filename: string, ids: readonly string[]): boolean => { + const file = resolve(filename.split('?')[0]) + let surface = cache.get(file) + if (!cache.has(file)) { + let directory = dirname(file) + while (true) { + const manifest = join(directory, 'package.json') + if (existsSync(manifest)) { + const pkg: unknown = JSON.parse(readFileSync(manifest, 'utf-8')) + if ( + pkg && + typeof pkg === 'object' && + 'name' in pkg && + (pkg.name === libPackage || pkg.name === '@stylexjs/stylex') && + 'exports' in pkg && + pkg.exports && + typeof pkg.exports === 'object' + ) { + for (const [entry, value] of Object.entries(pkg.exports)) { + if ( + ['.', './compat', './stylex'].includes(entry) && + targets(value).some( + (target) => resolve(directory, target) === file, + ) + ) + surface = + pkg.name === '@stylexjs/stylex' ? 'native-stylex' : entry + } + break + } + } + const parent = dirname(directory) + if (parent === directory) break + directory = parent + } + cache.set(file, surface) + } + if (surface === 'native-stylex') + return stylexExports.has(ids[0] === 'default' ? ids[1] : ids[0]) + if (surface === '.') + return ids[0] === 'stylex' + ? stylexExports.has(ids[1]) + : rootExports.has(ids[0]) + if (surface === './compat') return compatExports.has(ids[0]) + return surface === './stylex' && stylexExports.has(ids[0]) + } +} diff --git a/packages/plugin-utils/src/config-files.test.ts b/packages/plugin-utils/src/config-files.test.ts new file mode 100644 index 000000000..8e653e406 --- /dev/null +++ b/packages/plugin-utils/src/config-files.test.ts @@ -0,0 +1,197 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { collectDevupConfigFiles } from './config-files' + +describe('collectDevupConfigFiles', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'devup-config-files-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('returns an absolute path when the relative root is missing', () => { + const root = join(dir, 'devup.json') + + const files = collectDevupConfigFiles(relative(process.cwd(), root)) + + expect(files).toEqual([root]) + }) + + it.each(['{}', '{"extends":[]}'])( + 'returns only the root when it has no parents: %s', + (content) => { + const root = join(dir, 'devup.json') + writeFileSync(root, content) + + const files = collectDevupConfigFiles(root) + + expect(files).toEqual([root]) + }, + ) + + it('retains missing bases when other bases exist', () => { + const root = join(dir, 'devup.json') + const missing = join(dir, 'missing.json') + const base = join(dir, 'base.json') + writeFileSync( + root, + JSON.stringify({ extends: ['./missing.json', './base.json'] }), + ) + writeFileSync(base, '{}') + + const files = collectDevupConfigFiles(root) + + expect(files).toEqual([root, missing, base]) + }) + + it('resolves nested string and array extends relative to each declaring file', () => { + const root = join(dir, 'devup.json') + const parent = join(dir, 'nested', 'parent.json') + const grandparent = join(dir, 'base.json') + mkdirSync(join(dir, 'nested')) + writeFileSync(root, JSON.stringify({ extends: './nested/parent.json' })) + writeFileSync(parent, JSON.stringify({ extends: ['../base.json'] })) + writeFileSync(grandparent, '{}') + + const files = collectDevupConfigFiles(root) + + expect(files).toEqual([root, parent, grandparent]) + }) + + it('visits each base depth-first in the loader merge order', () => { + const root = join(dir, 'devup.json') + const first = join(dir, 'first.json') + const firstBase = join(dir, 'first-base.json') + const second = join(dir, 'second.json') + const secondBase = join(dir, 'second-base.json') + writeFileSync( + root, + JSON.stringify({ extends: ['./first.json', './second.json'] }), + ) + writeFileSync(first, JSON.stringify({ extends: ['./first-base.json'] })) + writeFileSync(second, JSON.stringify({ extends: ['./second-base.json'] })) + writeFileSync(firstBase, '{}') + writeFileSync(secondBase, '{}') + + const files = collectDevupConfigFiles(root) + + expect(files).toEqual([root, first, firstBase, second, secondBase]) + }) + + it('deduplicates normalized paths and shared ancestors at their first visit', () => { + const root = join(dir, 'devup.json') + const first = join(dir, 'first.json') + const shared = join(dir, 'shared.json') + const second = join(dir, 'second.json') + writeFileSync( + root, + JSON.stringify({ + extends: [ + './first.json', + './nested/../first.json', + second, + './shared.json', + ], + }), + ) + writeFileSync(first, JSON.stringify({ extends: ['./shared.json'] })) + writeFileSync(second, JSON.stringify({ extends: ['./shared.json'] })) + writeFileSync(shared, '{}') + + const files = collectDevupConfigFiles(root) + + expect(files).toEqual([root, first, shared, second]) + }) + + it('continues to later bases when an inheritance cycle reaches the root', () => { + const root = join(dir, 'devup.json') + const base = join(dir, 'base.json') + const missing = join(dir, 'missing.json') + writeFileSync( + root, + JSON.stringify({ extends: ['./base.json', './missing.json'] }), + ) + writeFileSync(base, JSON.stringify({ extends: './devup.json' })) + + const files = collectDevupConfigFiles(root) + + expect(files).toEqual([root, base, missing]) + }) + + it('terminates when a config directly extends itself', () => { + const root = join(dir, 'devup.json') + writeFileSync(root, JSON.stringify({ extends: './devup.json' })) + + const files = collectDevupConfigFiles(root) + + expect(files).toEqual([root]) + }) + + it.each(['devup.json', 'base.json'])( + 'locates malformed JSON in %s', + (name) => { + const root = join(dir, 'devup.json') + const invalid = join(dir, name) + writeFileSync(root, JSON.stringify({ extends: './base.json' })) + writeFileSync(invalid, '{ broken') + + const collect = () => collectDevupConfigFiles(root) + + expect(collect).toThrow(SyntaxError) + expect(collect).toThrow( + `${invalid}:1:1: devup config cannot use \`JSON\` at build time: SyntaxError:`, + ) + expect(collect).toThrow('needs valid JSON with an object at the root') + }, + ) + + it.each(['null', '[]', 'true', '"config"'])( + 'rejects a non-object config: %s', + (content) => { + const root = join(dir, 'devup.json') + writeFileSync(root, content) + + const collect = () => collectDevupConfigFiles(root) + + expect(collect).toThrow( + new TypeError( + `${root}:1:1: devup config cannot use \`config\` at build time: the JSON root is not a config object; needs a non-null object, not an array or primitive`, + ), + ) + }, + ) + + it.each([null, 42, {}])('rejects unsupported extends values: %j', (value) => { + const root = join(dir, 'devup.json') + writeFileSync(root, JSON.stringify({ extends: value })) + + const collect = () => collectDevupConfigFiles(root) + + expect(collect).toThrow( + new TypeError( + `${root}:1:1: devup config cannot use \`extends\` at build time: extends is neither a string nor an array; needs a path string or an array of path strings`, + ), + ) + }) + + it('rejects a non-string entry when extends is an array', () => { + const root = join(dir, 'devup.json') + writeFileSync(root, JSON.stringify({ extends: [42] })) + + const collect = () => collectDevupConfigFiles(root) + + expect(collect).toThrow( + new TypeError( + `${root}:1:1: devup config cannot use \`extends entry\` at build time: an extends array entry is not a string; needs every entry to be a path string`, + ), + ) + }) +}) diff --git a/packages/plugin-utils/src/config-files.ts b/packages/plugin-utils/src/config-files.ts new file mode 100644 index 000000000..56f7b3be9 --- /dev/null +++ b/packages/plugin-utils/src/config-files.ts @@ -0,0 +1,54 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +/** Collect root-first config dependencies, including paths not yet on disk. */ +export function collectDevupConfigFiles(configPath: string): string[] { + const files = new Set() + + function visit(file: string): void { + if (files.has(file)) return + files.add(file) + if (!existsSync(file)) return + + const content = readFileSync(file, 'utf-8') + let config: unknown + try { + config = JSON.parse(content) + } catch (cause) { + throw new SyntaxError( + `${file}:1:1: devup config cannot use \`JSON\` at build time: ${String(cause)}; needs valid JSON with an object at the root`, + { cause }, + ) + } + + if ( + typeof config !== 'object' || + config === null || + Array.isArray(config) + ) { + throw new TypeError( + `${file}:1:1: devup config cannot use \`config\` at build time: the JSON root is not a config object; needs a non-null object, not an array or primitive`, + ) + } + if (!('extends' in config)) return + + const entries: unknown = + typeof config.extends === 'string' ? [config.extends] : config.extends + if (!Array.isArray(entries)) { + throw new TypeError( + `${file}:1:1: devup config cannot use \`extends\` at build time: extends is neither a string nor an array; needs a path string or an array of path strings`, + ) + } + for (const entry of entries) { + if (typeof entry !== 'string') { + throw new TypeError( + `${file}:1:1: devup config cannot use \`extends entry\` at build time: an extends array entry is not a string; needs every entry to be a path string`, + ) + } + visit(resolve(dirname(file), entry)) + } + } + + visit(resolve(configPath)) + return [...files] +} diff --git a/packages/plugin-utils/src/dependency-guard.ts b/packages/plugin-utils/src/dependency-guard.ts new file mode 100644 index 000000000..c527fe063 --- /dev/null +++ b/packages/plugin-utils/src/dependency-guard.ts @@ -0,0 +1,127 @@ +import { + type CompiledReference, + createCompileTimeClassifier, + isCompileTimeAlias, + UntransformedSourceError, +} from './compiled-guard' +import { isSelectedSource } from './source-selection' +import type { WasmImportAliases } from './types' + +interface UsedDependency { + readonly request: string + readonly type: string + readonly ids?: unknown + readonly names?: unknown + readonly getIds?: (graph: unknown) => unknown + readonly loc?: unknown +} + +function isDependency(value: unknown): value is UsedDependency { + return ( + !!value && + typeof value === 'object' && + 'request' in value && + typeof value.request === 'string' && + 'type' in value && + typeof value.type === 'string' + ) +} + +function stringIds(value: unknown): readonly string[] { + return Array.isArray(value) && + value.every((id: unknown) => typeof id === 'string') + ? value + : [] +} + +export interface GuardModule { + readonly resource?: string + readonly type: string + readonly dependencies: readonly unknown[] +} + +export interface DependencyGuardOptions { + readonly package: string + readonly mdxExtensions: readonly string[] + readonly importAliases: WasmImportAliases +} + +export function createDependencyGuard(options: DependencyGuardOptions) { + const classify = createCompileTimeClassifier(options.package) + return ( + modules: Iterable, + context: { + readonly graph: unknown + readonly target: (dependency: unknown) => string | undefined + readonly forwarded?: ( + dependency: unknown, + ids: readonly string[], + ) => boolean + }, + ): UntransformedSourceError[] => { + const errors: UntransformedSourceError[] = [] + for (const module of modules) { + if ( + !module.resource || + !module.type.startsWith('javascript') || + isSelectedSource(module.resource.split('?')[0], options.mdxExtensions) + ) + continue + for (const dependency of module.dependencies) { + if ( + !isDependency(dependency) || + ![ + 'harmony import specifier', + 'esm import specifier', + 'cjs full require', + 'cjs require', + ].includes(dependency.type) + ) + continue + const ids = stringIds( + typeof dependency.getIds === 'function' + ? dependency.getIds(context.graph) + : (dependency.ids ?? dependency.names), + ) + if (!ids.length) continue + const loc = dependency.loc + const start = + loc && typeof loc === 'object' && 'start' in loc + ? loc.start + : undefined + const reference: CompiledReference = { + request: dependency.request, + ids, + line: + start && + typeof start === 'object' && + 'line' in start && + typeof start.line === 'number' + ? start.line + : 1, + column: + start && + typeof start === 'object' && + 'column' in start && + typeof start.column === 'number' + ? start.column + 1 + : 1, + } + const target = context.target(dependency) + if ( + isCompileTimeAlias(reference, options.importAliases) || + (target && classify(target, ids)) || + context.forwarded?.(dependency, ids) + ) + errors.push( + new UntransformedSourceError( + module.resource, + reference, + isCompileTimeAlias(reference, options.importAliases), + ), + ) + } + } + return errors + } +} diff --git a/packages/plugin-utils/src/directory-exclusion.ts b/packages/plugin-utils/src/directory-exclusion.ts new file mode 100644 index 000000000..1a515c89c --- /dev/null +++ b/packages/plugin-utils/src/directory-exclusion.ts @@ -0,0 +1,40 @@ +import { posix, win32 } from 'node:path' + +export function createDirectoryExclusion( + exclude: readonly string[] = [], + platform: string = process.platform, +): ((directory: string) => boolean) & { + readonly match: (directory: string) => string | undefined +} { + const windows = platform === 'win32' + const path = windows ? win32 : posix + const normalize = (value: string) => { + const normalized = path.normalize( + windows ? value.replaceAll('/', '\\') : value, + ) + const trimmed = normalized.replace(windows ? /\\+$/ : /\/+$/, '') + return windows ? trimmed.toLowerCase() : trimmed + } + const names = new Map( + exclude + .filter((entry) => !path.isAbsolute(entry)) + .map((entry) => [normalize(entry), entry]), + ) + const absolute = exclude + .filter((entry) => path.isAbsolute(entry)) + .map((entry) => ({ normalized: normalize(entry), entry })) + const match = (directory: string) => { + const candidate = normalize(directory) + for (const part of candidate.split(windows ? '\\' : '/')) { + const entry = names.get(part) + if (entry !== undefined) return entry + } + return absolute.find( + ({ normalized }) => + candidate === normalized || candidate.startsWith(normalized + path.sep), + )?.entry + } + return Object.assign((directory: string) => match(directory) !== undefined, { + match, + }) +} diff --git a/packages/plugin-utils/src/guard-facts.ts b/packages/plugin-utils/src/guard-facts.ts new file mode 100644 index 000000000..2c5b741a4 --- /dev/null +++ b/packages/plugin-utils/src/guard-facts.ts @@ -0,0 +1,157 @@ +import { + bindingScope, + createScopes, + field, + type GuardNode, + guardNode, + type GuardOrigin, + nodeName, + patternNames, +} from './guard-nodes' +import { origin, patternExpressions } from './guard-origins' + +export interface GuardFacts { + readonly references: readonly (GuardOrigin & { readonly pos: number })[] + readonly exports: ReadonlyMap + readonly stars: readonly string[] +} + +export function compiledFacts(ast: unknown): GuardFacts { + const root = guardNode(ast) + const references: (GuardOrigin & { readonly pos: number })[] = [] + const exports = new Map() + const stars: string[] = [] + if (!root) return { references, exports, stars } + const { scopes, moduleScope } = createScopes(root) + for (const statement of root.fields.get('body') ?? []) { + if (statement.kind !== 'ImportDeclaration') continue + const request = nodeName(field(statement, 'source')) + for (const specifier of statement.fields.get('specifiers') ?? []) { + const local = field(specifier, 'local') + if (!local) continue + const ids = + specifier.kind === 'ImportNamespaceSpecifier' + ? [] + : [ + specifier.kind === 'ImportDefaultSpecifier' + ? 'default' + : nodeName(field(specifier, 'imported')), + ] + moduleScope.bindings.set(local.name, { request, ids }) + } + } + function record(value: GuardOrigin | undefined, pos: number) { + if (value?.ids.length) references.push({ ...value, pos }) + } + function visit(node: GuardNode, key = '') { + const scope = scopes.get(node) ?? moduleScope + if (node.kind === 'VariableDeclaration' && node.value === 'const') { + for (const declaration of node.fields.get('declarations') ?? []) { + const id = field(declaration, 'id') + const value = origin(field(declaration, 'init'), scope) + if (id?.kind === 'Identifier' && value) + bindingScope(scope, id.name)?.bindings.set(id.name, value) + } + } + if (node.kind === 'ImportDeclaration') return + if (node.kind === 'ExportAllDeclaration') { + const request = nodeName(field(node, 'source')) + const exported = nodeName(field(node, 'exported')) + if (exported) exports.set(exported, { request, ids: [] }) + else stars.push(request) + return + } + if (node.kind === 'ExportNamedDeclaration') { + const request = nodeName(field(node, 'source')) + for (const specifier of node.fields.get('specifiers') ?? []) { + const name = nodeName(field(specifier, 'exported')) + const local = field(specifier, 'local') + exports.set( + name, + request ? { request, ids: [nodeName(local)] } : origin(local, scope), + ) + } + const declaration = field(node, 'declaration') + if (declaration) { + const ids = + declaration.kind === 'VariableDeclaration' + ? (declaration.fields.get('declarations') ?? []).flatMap((item) => { + const pattern = field(item, 'id') + return pattern ? patternNames(pattern) : [] + }) + : [nodeName(field(declaration, 'id'))] + for (const name of ids) exports.set(name, undefined) + } + } + if (node.kind === 'ExportDefaultDeclaration') + exports.set('default', origin(field(node, 'declaration'), scope)) + if (['VariableDeclarator', 'AssignmentExpression'].includes(node.kind)) { + const pattern = field( + node, + node.kind === 'VariableDeclarator' ? 'id' : 'left', + ) + const value = origin( + field(node, node.kind === 'VariableDeclarator' ? 'init' : 'right'), + scope, + ) + if (pattern?.kind === 'ObjectPattern' && value) { + for (const property of pattern.fields.get('properties') ?? []) { + const propertyKey = field(property, 'key') + if ( + property.kind !== 'Property' || + (property.computed && propertyKey?.kind !== 'Literal') + ) + continue + record( + { + ...value, + ids: [...value.ids, nodeName(propertyKey)], + }, + property.pos, + ) + } + } + } + if (node.kind === 'MemberExpression') { + record(origin(node, scope), node.pos) + const object = field(node, 'object') + if (object && !origin(object, scope)) visit(object, 'object') + const property = field(node, 'property') + if (node.computed && property?.kind !== 'Literal' && property) + visit(property, 'property') + return + } + if (node.kind === 'Identifier') { + if ( + [ + 'id', + 'params', + 'param', + 'imported', + 'exported', + 'local', + 'key', + 'label', + ].includes(key) + ) + return + record(origin(node, scope), node.pos) + return + } + if ( + [ + 'ObjectPattern', + 'ArrayPattern', + 'AssignmentPattern', + 'RestElement', + ].includes(node.kind) + ) { + for (const expression of patternExpressions(node)) visit(expression) + return + } + for (const [name, children] of node.fields) + for (const child of children) visit(child, name) + } + visit(root) + return { references, exports, stars } +} diff --git a/packages/plugin-utils/src/guard-nodes.ts b/packages/plugin-utils/src/guard-nodes.ts new file mode 100644 index 000000000..d8f88dc2c --- /dev/null +++ b/packages/plugin-utils/src/guard-nodes.ts @@ -0,0 +1,153 @@ +export interface GuardNode { + readonly kind: string + readonly name: string + readonly value: string + readonly pos: number + readonly fields: ReadonlyMap + readonly computed: boolean +} + +export function guardNode(value: unknown): GuardNode | undefined { + if ( + !value || + typeof value !== 'object' || + !('type' in value) || + typeof value.type !== 'string' + ) + return + const fields = new Map() + for (const [key, child] of Object.entries(value)) { + const children = (Array.isArray(child) ? child : [child]).flatMap( + (item: unknown) => { + const node = guardNode(item) + return node ? [node] : [] + }, + ) + if (children.length) fields.set(key, children) + } + return { + kind: value.type, + name: 'name' in value && typeof value.name === 'string' ? value.name : '', + value: + 'value' in value && typeof value.value === 'string' + ? value.value + : 'kind' in value && typeof value.kind === 'string' + ? value.kind + : '', + pos: 'start' in value && typeof value.start === 'number' ? value.start : 0, + computed: 'computed' in value && value.computed === true, + fields, + } +} + +export function field(node: GuardNode, key: string): GuardNode | undefined { + return node.fields.get(key)?.[0] +} + +export function nodeName(node: GuardNode | undefined): string { + return node?.name || node?.value || '' +} + +export interface GuardOrigin { + readonly request: string + readonly ids: readonly string[] + readonly kind?: 'require-call' +} + +export interface GuardScope { + readonly parent?: GuardScope + readonly functionScope: boolean + readonly bindings: Map +} + +export function binding( + scope: GuardScope, + name: string, +): GuardOrigin | undefined { + return bindingScope(scope, name)?.bindings.get(name) +} + +export function bindingScope( + scope: GuardScope, + name: string, +): GuardScope | undefined { + return scope.bindings.has(name) + ? scope + : scope.parent && bindingScope(scope.parent, name) +} + +export function patternNames(node: GuardNode): string[] { + if (node.kind === 'Identifier') return [node.name] + if (node.kind === 'Property') { + const value = field(node, 'value') + return value ? patternNames(value) : [] + } + if (node.kind === 'AssignmentPattern') { + const left = field(node, 'left') + return left ? patternNames(left) : [] + } + return [...node.fields.values()].flat().flatMap(patternNames) +} + +export function createScopes(root: GuardNode) { + const scopes = new Map() + const moduleScope: GuardScope = { functionScope: true, bindings: new Map() } + function visit(node: GuardNode, parent: GuardScope) { + const functionScope = + /^(?:Program|FunctionDeclaration|FunctionExpression|ArrowFunctionExpression)$/.test( + node.kind, + ) + const classScope = ['ClassDeclaration', 'ClassExpression'].includes( + node.kind, + ) + const scope = + node === root + ? moduleScope + : functionScope || + classScope || + [ + 'BlockStatement', + 'CatchClause', + 'ForStatement', + 'ForOfStatement', + 'ForInStatement', + 'StaticBlock', + 'SwitchStatement', + ].includes(node.kind) + ? { + parent, + functionScope, + bindings: new Map(), + } + : parent + scopes.set(node, scope) + const id = field(node, 'id') + if (id && ['FunctionDeclaration', 'ClassDeclaration'].includes(node.kind)) + parent.bindings.set(id.name, undefined) + if (functionScope || classScope) { + if (id) scope.bindings.set(id.name, undefined) + for (const parameter of node.fields.get('params') ?? []) + for (const name of patternNames(parameter)) + scope.bindings.set(name, undefined) + } + const param = field(node, 'param') + if (param) + for (const name of patternNames(param)) + scope.bindings.set(name, undefined) + if (node.kind === 'VariableDeclaration') { + let target = scope + if (node.value === 'var') + while (!target.functionScope && target.parent) target = target.parent + for (const declaration of node.fields.get('declarations') ?? []) { + const pattern = field(declaration, 'id') + if (pattern) + for (const name of patternNames(pattern)) + target.bindings.set(name, undefined) + } + } + for (const children of node.fields.values()) + for (const child of children) visit(child, scope) + } + visit(root, moduleScope) + return { scopes, moduleScope } +} diff --git a/packages/plugin-utils/src/guard-origins.ts b/packages/plugin-utils/src/guard-origins.ts new file mode 100644 index 000000000..c4bb269cc --- /dev/null +++ b/packages/plugin-utils/src/guard-origins.ts @@ -0,0 +1,60 @@ +import { + binding, + bindingScope, + field, + type GuardNode, + type GuardOrigin, + type GuardScope, + nodeName, +} from './guard-nodes' + +export function origin( + node: GuardNode | undefined, + scope: GuardScope, +): GuardOrigin | undefined { + if (!node) return + if (node.kind === 'Identifier') return binding(scope, node.name) + if (node.kind === 'CallExpression') { + const callee = field(node, 'callee') + const args = node.fields.get('arguments') ?? [] + const request = args[0] + if ( + callee?.kind === 'Identifier' && + callee.name === 'require' && + !bindingScope(scope, 'require') && + args.length === 1 && + request?.kind === 'Literal' && + request.value + ) + return { request: request.value, ids: [], kind: 'require-call' } + } + if (node.kind === 'MemberExpression') { + const object = origin(field(node, 'object'), scope) + const property = field(node, 'property') + if (object && property && (!node.computed || property.kind === 'Literal')) + return { ...object, ids: [...object.ids, nodeName(property)] } + } +} + +/** Binding patterns are not reads; their defaults and computed keys are. */ +export function patternExpressions(node: GuardNode): readonly GuardNode[] { + if (node.kind === 'AssignmentPattern') { + const left = field(node, 'left') + const right = field(node, 'right') + return [ + ...(left ? patternExpressions(left) : []), + ...(right ? [right] : []), + ] + } + if (node.kind === 'Property') { + const key = field(node, 'key') + const value = field(node, 'value') + return [ + ...(node.computed && key ? [key] : []), + ...(value ? patternExpressions(value) : []), + ] + } + if (['ObjectPattern', 'ArrayPattern', 'RestElement'].includes(node.kind)) + return [...node.fields.values()].flat().flatMap(patternExpressions) + return [] +} diff --git a/packages/plugin-utils/src/import-graph.test.ts b/packages/plugin-utils/src/import-graph.test.ts index cc15f2346..885f501a7 100644 --- a/packages/plugin-utils/src/import-graph.test.ts +++ b/packages/plugin-utils/src/import-graph.test.ts @@ -17,10 +17,62 @@ import { computeCompiledFiles, computeFileReach, computeFileRoutes, + computeReachableFiles, createModuleResolver, planAtomHoist, runImportGraphCli, } from './import-graph' +import { ConfigLoadError } from './load-config' + +describe('computeReachableFiles', () => { + let tempRoot: string + let srcDir: string + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'devup-ui-reachable-files-')) + srcDir = join(tempRoot, 'src') + }) + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }) + }) + + function writeFixture(path: string, code: string): void { + const filePath = join(tempRoot, path) + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, code) + } + + it('follows static and dynamic imports from the entries only', () => { + writeFixture('src/index.tsx', "import './a'\nimport('./lazy')\n") + writeFixture('src/a.tsx', "import './b'\n") + writeFixture('src/b.tsx', 'export const b = 1\n') + writeFixture('src/lazy.tsx', "import './b'\n") + writeFixture('src/unused.stories.tsx', "import './a'\n") + + expect( + computeReachableFiles({ srcDir, entries: [join(srcDir, 'index')] }), + ).toEqual( + ['a.tsx', 'b.tsx', 'index.tsx', 'lazy.tsx'].map((file) => + join(srcDir, file), + ), + ) + }) + + it('reaches nothing from entries outside the source directory', () => { + writeFixture('src/a.tsx', 'export const a = 1\n') + writeFixture('other/entry.tsx', "import '../src/a'\n") + const graph = buildStaticImportGraph(srcDir) + + expect( + computeReachableFiles({ + srcDir, + entries: [join(tempRoot, 'other/entry.tsx'), join(srcDir, 'missing')], + graph, + }), + ).toEqual([]) + }) +}) describe('buildCanonicalMap', () => { let tempRoot: string @@ -552,18 +604,25 @@ describe('buildCanonicalMap', () => { ).toEqual({}) }) - it('ignores a malformed tsconfig (JSON parse error)', () => { + it('reports a located error with cause when tsconfig is malformed', () => { writeFixture('tsconfig.json', '{ this is not json') writeFixture('src/a.tsx', "import './b'\n") writeFixture('src/b.tsx', 'export const b = 1\n') - expect( + let caught: unknown + try { buildCanonicalMap({ cwd, srcDir, tsconfigPath: join(cwd, 'tsconfig.json'), - }), - ).toEqual({ 'src/b.tsx': 'src/a.tsx' }) + }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(ConfigLoadError) + if (!(caught instanceof ConfigLoadError)) throw caught + expect(caught.message).toContain(`${join(cwd, 'tsconfig.json')}:1:1:`) + expect(caught.cause).toBeInstanceOf(SyntaxError) }) it('prefers the longest-prefix alias when multiple tsconfig paths overlap', () => { @@ -1015,11 +1074,7 @@ describe('planAtomHoist', () => { }) }) -// The oxc AST path is the fast parser used when `oxc-parser` is installed in -// the host project. It is absent in this repo, so we inject a fake parser to -// exercise the AST walk (module state is shared across test files, so this is -// reset after each test back to the regex fallback). -describe('oxc AST parsing path', () => { +describe('optional oxc diagnostic path', () => { let tempRoot: string let cwd: string let srcDir: string @@ -1042,115 +1097,91 @@ describe('oxc AST parsing path', () => { writeFileSync(filePath, code) } - it('collects every import/export node kind from the AST (type-only excluded)', () => { - const circular: Record = { type: 'SelfRef' } - circular.self = circular // self-reference -> exercises the `seen` guard + it('ignores every AST edge kind and derives canonical members from real source', () => { const richProgram = { type: 'Program', body: [ - // value import -> static edge (getStringLiteralValue via `.value`) { type: 'ImportDeclaration', importKind: 'value', source: { value: './val' }, }, - // `import type` -> skipped (importKind 'type') { type: 'ImportDeclaration', importKind: 'type', source: { value: './t1' }, }, - // value re-export -> static edge { type: 'ExportNamedDeclaration', exportKind: 'value', source: { value: './exp' }, }, - // `export type` -> skipped (exportKind 'type') { type: 'ExportNamedDeclaration', exportKind: 'type', source: { value: './t2' }, }, - // export-all -> static edge { type: 'ExportAllDeclaration', source: { value: './all' } }, - // dynamic import expression with `.source` { type: 'ImportExpression', source: { value: './dyn1' } }, - // dynamic import expression falling back to `.argument` { type: 'ImportExpression', argument: { value: './dyn2' } }, - // import() call via callee.type === 'Import', specifier via `.raw` { type: 'CallExpression', callee: { type: 'Import' }, arguments: [{ raw: "'./dyn3'" }], }, - // import() call via callee.name === 'import' { type: 'CallExpression', callee: { name: 'import' }, arguments: [{ value: './dyn4' }], }, - // import() with non-array arguments -> first arg undefined -> no push { type: 'CallExpression', callee: { type: 'Import' }, arguments: 'not-an-array', }, - // non-import call (isImportCallee false via name) -> falls through { type: 'CallExpression', callee: { name: 'other' }, arguments: [] }, - // non-record callee -> isImportCallee returns false { type: 'CallExpression', callee: null, arguments: [] }, - // source literal with neither `.value` nor `.raw` -> no push { type: 'ImportDeclaration', importKind: 'value', source: { kind: 'no-literal' }, }, - circular, 'primitive-child', 7, null, - ] as unknown[], + ], } __setOxcParserForTest({ parseSync: (filename: string) => filename.endsWith('a.tsx') ? { program: richProgram } - : { program: { type: 'Program', body: [] as unknown[] } }, + : { program: { type: 'Program', body: [] } }, }) - writeFixture('src/a.tsx', 'parsed by the fake oxc parser, content ignored') - writeFixture( - 'src/val.tsx', - 'parsed by the fake oxc parser, content ignored', - ) - - // Proof the AST path ran: `a` statically imports `./val` -> val collapses - // into a. The regex fallback would parse the literal content -> no imports. + writeFixture('src/a.tsx', "import './real'") + writeFixture('src/val.tsx', 'export {}') + writeFixture('src/real.tsx', 'export {}') expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ - 'src/val.tsx': 'src/a.tsx', + 'src/real.tsx': 'src/a.tsx', }) }) - it('skips AST import/export nodes whose specifiers are all inline-type', () => { + it('elides source type-only imports even when AST import kinds disagree', () => { const program = { type: 'Program', body: [ - // all-inline-type import -> erased by the bundler -> no edge { type: 'ImportDeclaration', importKind: 'value', - specifiers: [{ type: 'ImportSpecifier', importKind: 'type' }], + specifiers: [{ type: 'ImportSpecifier', importKind: 'value' }], source: { value: './phantom' }, }, - // all-inline-type re-export -> erased -> no edge { type: 'ExportNamedDeclaration', exportKind: 'value', - specifiers: [{ type: 'ExportSpecifier', exportKind: 'type' }], + specifiers: [{ type: 'ExportSpecifier', exportKind: 'value' }], source: { value: './phantom2' }, }, - // mixed inline types -> module still imported -> edge kept { type: 'ImportDeclaration', importKind: 'value', @@ -1160,7 +1191,6 @@ describe('oxc AST parsing path', () => { ], source: { value: './mixed' }, }, - // default specifier (no importKind) alongside an inline type -> kept { type: 'ImportDeclaration', importKind: 'value', @@ -1170,38 +1200,47 @@ describe('oxc AST parsing path', () => { ], source: { value: './withdefault' }, }, - // non-record specifier entry -> not type-only -> kept { type: 'ImportDeclaration', importKind: 'value', specifiers: ['bogus'], source: { value: './bogus' }, }, - // empty specifier list (`import {} from`) -> kept (side-effect import) { type: 'ImportDeclaration', importKind: 'value', - specifiers: [] as unknown[], + specifiers: [], source: { value: './empty' }, }, - ] as unknown[], + ], } __setOxcParserForTest({ parseSync: (filename: string) => filename.endsWith('a.tsx') ? { program } - : { program: { type: 'Program', body: [] as unknown[] } }, + : { program: { type: 'Program', body: [] } }, }) - writeFixture('src/a.tsx', 'fake parser input') - writeFixture('src/phantom.tsx', 'fake parser input') - writeFixture('src/phantom2.tsx', 'fake parser input') - writeFixture('src/mixed.tsx', 'fake parser input') - writeFixture('src/withdefault.tsx', 'fake parser input') - writeFixture('src/bogus.tsx', 'fake parser input') - writeFixture('src/empty.tsx', 'fake parser input') - - // phantom/phantom2 gain no importer (edges dropped) -> roots, not members. + writeFixture( + 'src/a.tsx', + [ + "import { type A } from './phantom'", + "export { type B } from './phantom2'", + "import { type A, b } from './mixed'", + "import Default, { type A } from './withdefault'", + "import { value } from './bogus'", + "import {} from './empty'", + ].join('\n'), + ) + for (const name of [ + 'phantom', + 'phantom2', + 'mixed', + 'withdefault', + 'bogus', + 'empty', + ]) + writeFixture(`src/${name}.tsx`, 'export {}') expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ 'src/bogus.tsx': 'src/a.tsx', 'src/empty.tsx': 'src/a.tsx', @@ -1210,20 +1249,19 @@ describe('oxc AST parsing path', () => { }) }) - it('falls back to the regex scan when the oxc parser throws', () => { + it('reports an optional parser fault rather than silently changing edge authority', () => { __setOxcParserForTest({ parseSync: () => { - throw new Error('boom') + throw Object.assign(new Error('boom'), { line: 2, column: 3 }) }, }) writeFixture('src/a.tsx', "import './b'\n") writeFixture('src/b.tsx', 'export const b = 1\n') - // parseSync throws -> parseImportsWithOxc returns undefined -> scanImports. - expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ - 'src/b.tsx': 'src/a.tsx', - }) + expect(() => buildCanonicalMap({ cwd, srcDir })).toThrow( + `${join(srcDir, 'a.tsx')}:2:3`, + ) }) }) @@ -1342,7 +1380,10 @@ describe('createModuleResolver', () => { expect(resolveModule(join(root, 'src/tokens'), 'src/App.tsx')?.path).toBe( posix('src/tokens.ts'), ) - expect(resolveModule('./style.css', 'src/App.tsx')).toBeUndefined() + expect(resolveModule('./style.css', 'src/App.tsx')).toEqual({ + path: posix('src/style.css'), + code: 'body {}', + }) expect(resolveModule('./missing.ts', 'src/App.tsx')).toBeUndefined() expect(resolveModule('not-installed', 'src/App.tsx')).toBeUndefined() }) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index aa55a9bc8..92ec1f77a 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -3,18 +3,46 @@ import { readdirSync, readFileSync, realpathSync, - statSync, writeFileSync, } from 'node:fs' import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' + +import { createDirectoryExclusion } from './directory-exclusion' +import { + freezeImportRequests, + type ImportGraphRequest, + type ImportRequestOutcome, + retainImportGraphFailure, +} from './import-requests' +import { + type ImportRequestReference, + scanImportRequests, +} from './import-scanner' +import { remapMdxError } from './mdx-errors' +import { type AliasResolution, resolveModuleAlias } from './module-alias' +import { resolveFile } from './owned-file-resolution' +import { findPackage, resolvePackage } from './owned-module-resolution' +import { isResolutionFile as isFile } from './packaged-config-inputs' +import { preparedDiagnostics } from './prepared-diagnostics' +import { + createPreparedResolver, + type ModuleResolver, +} from './prepared-resolver' +import { PreparedSourceTypeError, readPreparedSource } from './prepared-source' import { - dirname, - extname, - isAbsolute, - join, - relative, - resolve, -} from 'node:path' + createResolutionInputs, + type ResolutionInputObserver, +} from './resolution-inputs' +import { createNodeModulesExcludeRegex, SOURCE_FILE_RE } from './shared' +import { + isSelectedSource, + type MdxSelection, + sourceExtensions, + type SourceSelectionOptions, +} from './source-selection' +import { type PathAlias, readPathAliases } from './tsconfig' +import type { ModuleAliasOptions } from './types' /** * How map keys (and bucket-root values) are stringified. @@ -33,7 +61,7 @@ function makeToKey(cwd: string, keyBy: GraphKeyMode): (file: string) => string { } export interface BuildCanonicalMapOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string hoistV?: number @@ -42,11 +70,6 @@ export interface BuildCanonicalMapOptions { graph?: StaticImportGraph } -interface ImportReference { - kind: 'static' | 'dynamic' - specifier: string -} - interface OxcParser { parseSync: ( filename: string, @@ -55,12 +78,6 @@ interface OxcParser { ) => unknown } -interface PathAlias { - prefix: string - suffix: string - targets: string[] -} - interface ResolveContext { aliases: PathAlias[] aliasBaseDir: string @@ -68,12 +85,10 @@ interface ResolveContext { srcDir: string } -const jsExtensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs'] -const jsFileRegex = /\.(?:tsx?|jsx?|mjs)$/ -const testFileRegex = /\.(?:test|spec)\.[mc]?[jt]sx?$/ +const testFileRegex = /\.(?:test|spec)\.[mc]?[jt]sx?$/i const routeFileRegex = - /(^|\/)(page|layout|template|default|loading|error|not-found|global-error)\.(tsx|ts|jsx|js)$/ -const leafRouteFileRegex = /(^|\/)page\.(tsx|ts|jsx|js)$/ + /(^|\/)(page|layout|template|default|loading|error|not-found|global-error)\.[^./]+$/ +const leafRouteFileRegex = /(^|\/)page\.[^./]+$/ let cachedOxcParser: false | OxcParser | undefined @@ -85,6 +100,7 @@ let cachedOxcParser: false | OxcParser | undefined * hoisting reads and parses every source file only ONCE per build. */ export interface StaticImportGraph { + readonly requests?: readonly ImportGraphRequest[] /** absolute paths, sorted by POSIX path relative to `srcDir`. */ files: string[] fileSet: Set @@ -109,30 +125,153 @@ export interface StaticImportGraph { externalImports?: Map> } +export interface ScannedStaticImportGraph extends StaticImportGraph { + readonly requests: readonly ImportGraphRequest[] +} + /** * Scan `srcDir` once (`listSourceFiles` -> read -> parse imports -> resolve) * and return the full static import graph. All three graph consumers accept * the result, so the file I/O and parsing cost is paid a single time. */ export function buildStaticImportGraph( - srcDir: string, + srcDir: string | string[], + tsconfigPath: string | undefined, + options: PreparedGraphOptions, +): Promise +export function buildStaticImportGraph( + srcDir: string | string[], tsconfigPath?: string, -): StaticImportGraph { - const resolvedSrcDir = resolve(srcDir) - const files = listSourceFiles(resolvedSrcDir) + options?: SyncGraphOptions, +): ScannedStaticImportGraph +export function buildStaticImportGraph( + srcDir: string | string[], + tsconfigPath?: string, + options: SyncGraphOptions | PreparedGraphOptions = {}, +): ScannedStaticImportGraph | Promise { + if (options.prepareSource) + return drivePreparedGraph(srcDir, tsconfigPath, options) + const traversal = traverseGraph(srcDir, tsconfigPath, options) + let step = traversal.next() + while (!step.done) step = traversal.next(undefined) + return step.value +} + +export type PreparedSource = + | string + | { + readonly code: string + readonly map?: unknown + readonly sourceType?: import('./prepared-source').SourceType + } + | undefined +export type PrepareSource = ( + filename: string, +) => PreparedSource | Promise +export interface SyncGraphOptions extends StaticImportGraphOptions { + readonly prepareSource?: never +} +export interface PreparedGraphOptions extends StaticImportGraphOptions { + readonly prepareSource: PrepareSource +} + +class GraphPreparationError extends Error { + constructor(filename: string, cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause) + const location = + cause instanceof Error && + !(cause instanceof PreparedSourceTypeError) && + 'line' in cause && + typeof cause.line === 'number' + ? `${cause.line}:${'column' in cause && typeof cause.column === 'number' ? cause.column : 1}` + : '1:1' + super( + `${filename}:${location}: Graph source preparation failed: ${message}`, + { cause }, + ) + this.name = 'GraphPreparationError' + } +} + +async function drivePreparedGraph( + srcDir: string | string[], + tsconfigPath: string | undefined, + options: PreparedGraphOptions, +): Promise { + const traversal = traverseGraph(srcDir, tsconfigPath, options) + let step = traversal.next() + while (!step.done) { + const filename = step.value + let prepared: PreparedSource + try { + prepared = readPreparedSource(await options.prepareSource(filename)) + } catch (cause) { + const located = new GraphPreparationError(filename, cause) + if (cause instanceof PreparedSourceTypeError) throw located + const remapped = remapMdxError(located, filename) + throw new Error( + remapped.message.replaceAll( + '(in compiled MDX)', + '(in compiled output)', + ), + { cause }, + ) + } + step = traversal.next(prepared) + } + return step.value +} + +function* traverseGraph( + srcDir: string | string[], + tsconfigPath: string | undefined, + options: StaticImportGraphOptions, +): Generator { + const cwd = resolve(options.cwd ?? process.cwd()) + const roots = (typeof srcDir === 'string' ? [srcDir] : srcDir).map((dir) => + resolve(cwd, dir), + ) + const files = [ + ...new Set( + roots.flatMap((root) => listSourceFiles(root, options.exclude, options)), + ), + ].sort(compareCodePoints) const fileSet = new Set(files) - const aliases = readPathAliases(tsconfigPath) - const context: ResolveContext = { - aliases: aliases.aliases, - aliasBaseDir: aliases.baseDir, - files: fileSet, - srcDir: resolvedSrcDir, + const directoryExclusion = createDirectoryExclusion(options.exclude) + const exclusionObservation: { outcome: ImportRequestOutcome } = { + outcome: { kind: 'unresolved' }, + } + const excludedDirectory = (directory: string) => { + const entry = directoryExclusion.match(directory) + if (entry !== undefined) + exclusionObservation.outcome = { kind: 'excluded', entry } + return entry !== undefined } + let resolutionInputs = createResolutionInputs().snapshot() + const graphInputs = createResolutionInputs() + const resolver = createModulePathResolver( + { + cwd, + tsconfigPath: tsconfigPath ?? join(cwd, 'tsconfig.json'), + conditions: options.conditions, + alias: options.alias, + includeMdx: options.includeMdx, + onResolutionInputs: (inputs) => { + resolutionInputs = inputs + options.onResolutionInputs?.(inputs) + }, + }, + excludedDirectory, + ) + const excluded = createNodeModulesExcludeRegex(options.include ?? []) + const includedRoots = new Set() + const localAliasRoots = new Set() const staticImporters = new Map>() const staticImports = new Map>() const dynamicImports = new Map>() const dynamicTargets = new Set() const externalImports = new Map>() + const requests: ImportGraphRequest[] = [] for (const file of files) { staticImporters.set(file, new Set()) @@ -141,32 +280,149 @@ export function buildStaticImportGraph( externalImports.set(file, new Set()) } - for (const file of files) { - const imports = parseImports(file, readFileSync(file, 'utf-8')) + for (let index = 0; index < files.length; index += 1) { + const file = files[index] + const prepared = yield file + graphInputs.file(file) + options.onResolutionInputs?.(graphInputs.snapshot()) + const code = typeof prepared === 'string' ? prepared : prepared?.code + const imports = + code === undefined + ? parseImports(file, readFileSync(file, 'utf-8')) + : parsePreparedImports( + file, + code, + typeof prepared === 'object' ? prepared.map : undefined, + typeof prepared === 'object' ? prepared.sourceType : undefined, + ) for (const importRef of imports) { - const target = resolveImport(importRef.specifier, file, context) - if (!target) { + const request = { + importer: file, + request: importRef.request, + specifier: importRef.specifier, + kind: importRef.requestKind, + position: importRef.position, + source: code === undefined ? 'source' : 'compiled', + ...(code === undefined + ? {} + : { map: typeof prepared === 'object' ? prepared.map : undefined }), + } satisfies Omit + exclusionObservation.outcome = { kind: 'unresolved' } + try { + const resolution = resolver(importRef.specifier, file) + if (resolution === false) { + requests.push({ ...request, outcome: exclusionObservation.outcome }) + continue + } + if (resolution?.ignored === true) { + requests.push({ ...request, outcome: { kind: 'ignored' } }) + continue + } + const resolved = resolution?.path + if (resolved && excludedDirectory(dirname(resolved))) { + requests.push({ ...request, outcome: exclusionObservation.outcome }) + continue + } + requests.push({ + ...request, + outcome: + resolved !== undefined + ? { kind: 'resolved', path: resolved, inputs: resolutionInputs } + : !importRef.specifier.startsWith('.') && + !importRef.specifier.startsWith('/') && + !isAbsolute(importRef.specifier) + ? { kind: 'external', request: importRef.specifier } + : { kind: 'unresolved' }, + }) + const rewritten = resolution?.request ?? importRef.specifier + const local = + resolved !== undefined && + isInsideDir(cwd, resolved) && + !relative(cwd, resolved).split(/[\\/]/).includes('node_modules') + if (resolved && local && rewritten !== importRef.specifier) + localAliasRoots.add(dirname(resolved)) if ( + resolved && !importRef.specifier.startsWith('.') && - !importRef.specifier.startsWith('/') && !isAbsolute(importRef.specifier) ) { - externalImports.get(file)?.add(importRef.specifier) + const request = rewritten + const parts = request.split('/') + const name = parts.slice(0, request.startsWith('@') ? 2 : 1).join('/') + if (!excluded.test(`node_modules/${name}/`)) { + const dir = findPackage(dirname(file), name, undefined, graphInputs) + if (dir) includedRoots.add(realpathSync(dir)) + } + if (isAbsolute(request) && !local && !excluded.test(resolved)) { + let directory = dirname(resolved) + while ( + dirname(directory) !== directory && + !isFile(join(directory, 'package.json'), graphInputs) + ) + directory = dirname(directory) + if (isFile(join(directory, 'package.json'), graphInputs)) + includedRoots.add(realpathSync(directory)) + } } - continue - } - if (importRef.kind === 'dynamic') { - dynamicTargets.add(target) - dynamicImports.get(file)?.add(target) - continue + const target = + resolved && + isSelectedSource(resolved, options.includeMdx) && + (fileSet.has(resolved) || + roots.some( + (root) => + isInsideDir(root, resolved) && + !relative(root, resolved) + .split(/[\\/]/) + .includes('node_modules'), + ) || + (local && + [...localAliasRoots].some((root) => + isInsideDir(root, resolved), + )) || + (!excluded.test(resolved) && + [...includedRoots].some((root) => isInsideDir(root, resolved)))) + ? resolved + : undefined + if (!target) { + if ( + !importRef.specifier.startsWith('.') && + !importRef.specifier.startsWith('/') && + !isAbsolute(importRef.specifier) + ) { + externalImports.get(file)?.add(importRef.specifier) + } + continue + } + if (!fileSet.has(target)) { + fileSet.add(target) + files.push(target) + staticImporters.set(target, new Set()) + staticImports.set(target, new Set()) + dynamicImports.set(target, new Set()) + externalImports.set(target, new Set()) + } + if (importRef.kind === 'dynamic') { + dynamicTargets.add(target) + dynamicImports.get(file)?.add(target) + continue + } + staticImporters.get(target)?.add(file) + staticImports.get(file)?.add(target) + } catch (error) { + retainImportGraphFailure(error, { + ...request, + outcome: { kind: 'error', error }, + }) + throw error + } finally { + options.onResolutionInputs?.(graphInputs.snapshot()) } - staticImporters.get(target)?.add(file) - staticImports.get(file)?.add(target) } } return { - files, + requests: freezeImportRequests(requests), + files: files.sort(compareCodePoints), fileSet, staticImports, staticImporters, @@ -176,13 +432,37 @@ export function buildStaticImportGraph( } } +export interface StaticImportGraphOptions { + readonly onResolutionInputs?: ResolutionInputObserver + /** Include MDX only when the caller compiles it before extraction. */ + readonly includeMdx?: MdxSelection + readonly alias?: ModuleAliasOptions + readonly cwd?: string + readonly include?: readonly string[] + readonly conditions?: readonly string[] + /** Bare directory names at any depth, or absolute directories and descendants. */ + readonly exclude?: readonly string[] +} + +function graphRoot(srcDir: string | string[], cwd: string): string { + if (typeof srcDir === 'string') return resolve(cwd, srcDir) + let root = resolve(cwd, srcDir[0] ?? '.') + for (const dir of srcDir) { + const absolute = resolve(cwd, dir) + while (!isInsideDir(root, absolute) && dirname(root) !== root) + root = dirname(root) + } + return root +} + export function buildCanonicalMap( opts: BuildCanonicalMapOptions, ): Record { const cwd = resolve(opts.cwd) - const srcDir = resolve(opts.srcDir) + const srcDir = graphRoot(opts.srcDir, cwd) const { files, staticImports, staticImporters, dynamicTargets } = - opts.graph ?? buildStaticImportGraph(srcDir, opts.tsconfigPath) + opts.graph ?? + buildStaticImportGraph(opts.srcDir, opts.tsconfigPath, { cwd }) const globalFiles = getRouteReachableGlobalFiles( files, @@ -227,7 +507,7 @@ export function buildCanonicalMap( } export interface ComputeFileRoutesOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string /** pre-built graph from `buildStaticImportGraph` to skip the file scan. */ @@ -250,14 +530,15 @@ export function computeFileRoutes( opts: ComputeFileRoutesOptions, ): Record { const cwd = resolve(opts.cwd) - const srcDir = resolve(opts.srcDir) + const srcDir = graphRoot(opts.srcDir, cwd) const { files, staticImports } = - opts.graph ?? buildStaticImportGraph(srcDir, opts.tsconfigPath) + opts.graph ?? + buildStaticImportGraph(opts.srcDir, opts.tsconfigPath, { cwd }) const leafRoutes = files .filter((file) => leafRouteFileRegex.test(toPosixRelative(srcDir, file))) .sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) const routeShellFilesByDir = getRouteShellFilesByDir(files, srcDir) @@ -279,7 +560,7 @@ export function computeFileRoutes( } export interface ComputeCompiledFilesOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string /** pre-built graph from `buildStaticImportGraph` to skip the file scan. */ @@ -308,9 +589,10 @@ export function computeCompiledFiles( opts: ComputeCompiledFilesOptions, ): string[] { const cwd = resolve(opts.cwd) - const srcDir = resolve(opts.srcDir) + const srcDir = graphRoot(opts.srcDir, cwd) const { files, staticImports, dynamicImports } = - opts.graph ?? buildStaticImportGraph(srcDir, opts.tsconfigPath) + opts.graph ?? + buildStaticImportGraph(opts.srcDir, opts.tsconfigPath, { cwd }) const allImports = new Map>() for (const file of files) { @@ -336,8 +618,46 @@ export function computeCompiledFiles( return [...compiled].map((file) => toPosixRelative(cwd, file)).sort() } +export interface ComputeReachableFilesOptions { + srcDir: string | string[] + tsconfigPath?: string + /** The bundler's entry modules, as absolute paths with or without extension. */ + entries: string[] + /** pre-built graph from `buildStaticImportGraph` to skip the file scan. */ + graph?: StaticImportGraph +} + +/** + * The source files under `srcDir` a bundler compiles from `entries`: their + * closure over static and dynamic `import()` edges, as absolute paths in the + * graph's order. Extracting them before bundling fills the shared stylesheet + * without the styles of files no entry imports. + */ +export function computeReachableFiles( + opts: ComputeReachableFilesOptions, +): string[] { + const { files, fileSet, staticImports, dynamicImports } = + opts.graph ?? buildStaticImportGraph(opts.srcDir, opts.tsconfigPath) + const queue = opts.entries + .map((entry) => resolveFile(resolve(entry))) + .filter( + (entry): entry is string => + typeof entry === 'string' && fileSet.has(entry), + ) + const reached = new Set() + for (let index = 0; index < queue.length; index += 1) { + const file = queue[index] + if (reached.has(file)) continue + reached.add(file) + for (const imports of [staticImports, dynamicImports]) { + for (const target of imports.get(file) ?? []) queue.push(target) + } + } + return files.filter((file) => reached.has(file)) +} + export interface ComputeFileReachOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string /** @@ -372,9 +692,10 @@ export function computeFileReach( opts: ComputeFileReachOptions, ): Record { const cwd = resolve(opts.cwd) - const srcDir = resolve(opts.srcDir) + const srcDir = graphRoot(opts.srcDir, cwd) const { files, fileSet, staticImports, staticImporters, dynamicTargets } = - opts.graph ?? buildStaticImportGraph(srcDir, opts.tsconfigPath) + opts.graph ?? + buildStaticImportGraph(opts.srcDir, opts.tsconfigPath, { cwd }) let entries: string[] if (opts.entries && opts.entries.length > 0) { @@ -389,7 +710,7 @@ export function computeFileReach( ) } entries = [...new Set(entries)].sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) const toKey = makeToKey(cwd, opts.keyBy ?? 'cwd-relative') @@ -562,6 +883,11 @@ function getStaticClosure( return closure } +/** Order that is the same on every machine, unlike localeCompare. */ +export function compareCodePoints(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + /** * Enumerate every extractable source file under `srcDir`, sorted by POSIX path * (deterministic order). Skips `node_modules`, test/spec files, and non-JS/TS @@ -569,13 +895,19 @@ function getStaticClosure( * pre-warm the extractor over exactly the file set the canonical map was built * from. Returns absolute paths. */ -export function listSourceFiles(srcDir: string): string[] { +export function listSourceFiles( + srcDir: string, + exclude: readonly string[] = [], + options: SourceSelectionOptions = {}, +): string[] { const files: string[] = [] + const excludedDirectory = createDirectoryExclusion(exclude) function visit(dir: string): void { + if (excludedDirectory(resolve(dir))) return if (!existsSync(dir)) return const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => - a.name.localeCompare(b.name), + compareCodePoints(a.name, b.name), ) for (const entry of entries) { const entryPath = join(dir, entry.name) @@ -585,7 +917,7 @@ export function listSourceFiles(srcDir: string): string[] { continue } if (!entry.isFile()) continue - if (!jsFileRegex.test(entry.name)) continue + if (!isSelectedSource(entry.name, options.includeMdx)) continue if (testFileRegex.test(entry.name)) continue files.push(resolve(entryPath)) } @@ -593,31 +925,107 @@ export function listSourceFiles(srcDir: string): string[] { visit(srcDir) return files.sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) } -function parseImports(filename: string, source: string): ImportReference[] { - const astImports = parseImportsWithOxc(filename, source) - if (astImports) return astImports - return scanImports(source) +function parseImports( + filename: string, + source: string, +): ImportRequestReference[] { + if (!SOURCE_FILE_RE.test(filename)) + return scanImportRequests(mdxEsmSource(source), false, false) + return parseSourceImports(filename, source) +} + +function parsePreparedImports( + filename: string, + source: string, + map: unknown, + sourceType?: import('./prepared-source').SourceType, +): ImportRequestReference[] { + return parseSourceImports(filename, source, { map, sourceType }) } -function parseImportsWithOxc( +function parseSourceImports( filename: string, source: string, -): ImportReference[] | undefined { + prepared?: { + readonly map: unknown + readonly sourceType?: import('./prepared-source').SourceType + }, +): ImportRequestReference[] { const parser = getOxcParser() - if (!parser) return undefined - try { - const ast = parser.parseSync(filename, source, { sourceType: 'module' }) - const imports: ImportReference[] = [] - collectAstImports(ast, imports) - return imports - } catch { - return undefined + const ast = parser?.parseSync( + filename, + source, + prepared && + (prepared.sourceType === 'compiled-mdx' || + !/\.[mc]?tsx?$/i.test(filename)) + ? { sourceType: 'module', lang: 'jsx' } + : { sourceType: 'module' }, + ) + if (isRecord(ast) && Array.isArray(ast.errors) && ast.errors.length) { + throw new Error(preparedDiagnostics(filename, source, ast.errors)) + } + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause) + const located = message.startsWith(`${filename}:`) + ? cause + : new GraphPreparationError(filename, cause) + if (!prepared) throw located + const remapped = remapMdxError(located, filename, prepared.map) + throw new Error( + remapped.message.replaceAll('(in compiled MDX)', '(in compiled output)'), + { cause }, + ) + } + return scanImportRequests( + source, + prepared?.sourceType === 'compiled-mdx' || + /\.[jt]sx$/i.test(filename) || + (prepared !== undefined && !/\.[mc]?ts$/i.test(filename)), + prepared?.sourceType !== 'compiled-mdx' && /\.[mc]?tsx?$/i.test(filename), + ) +} + +function mdxEsmSource(source: string): string { + const blocks: string[] = [] + let fence = '' + let esm = false + let comment = false + for (const line of source.split(/(?<=\n)/)) { + const blank = line.replace(/[^\r\n]/g, ' ') + if (line.includes('')) comment = false + esm = false + blocks.push(blank) + continue + } + const marker = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1] + if (marker) { + if (!fence) fence = marker + else if (marker[0] === fence[0] && marker.length >= fence.length) + fence = '' + esm = false + blocks.push(blank) + continue + } + if (fence) { + blocks.push(blank) + continue + } + if (!line.trim()) { + esm = false + blocks.push(blank) + continue + } + if (/^(?:import\s+(?!\()|export\s+)/.test(line)) esm = true + blocks.push(esm ? line : blank) } + return blocks.join('') } function getOxcParser(): OxcParser | undefined { @@ -639,10 +1047,10 @@ function getOxcParser(): OxcParser | undefined { /** * @internal test-only: force the cached oxc parser. oxc-parser is an optional - * peer that is absent in this repo, so the AST path is otherwise unreachable + * peer that is absent in this repo, so the diagnostic path is otherwise unreachable * from tests; module state is shared across test files (no per-file reset), so * `mock.module` cannot toggle it deterministically. Pass `undefined` to clear - * the cache and re-detect (back to the regex fallback). + * the cache and re-detect. The scanner remains the sole edge authority. */ export function __setOxcParserForTest( parser: OxcParser | false | undefined, @@ -650,306 +1058,33 @@ export function __setOxcParserForTest( cachedOxcParser = parser } -function collectAstImports( - node: unknown, - imports: ImportReference[], - seen = new WeakSet(), -): void { - if (!isRecord(node)) return - if (seen.has(node)) return - seen.add(node) - - const type = typeof node.type === 'string' ? node.type : undefined - if ( - type === 'ImportDeclaration' || - type === 'ExportNamedDeclaration' || - type === 'ExportAllDeclaration' - ) { - // `import type`/`export type ... from` carry importKind/exportKind 'type'. - // They are erased at build time (no runtime module), so they must NOT - // become static graph edges — see the regex fallback in `scanImports`. - // The same applies when every specifier is inline-type - // (`import { type A } from` / `export { type A } from`). - if ( - node.importKind !== 'type' && - node.exportKind !== 'type' && - !hasOnlyInlineTypeSpecifiers(node) - ) { - addAstImport(imports, 'static', node.source) - } - } else if (type === 'ImportExpression') { - addAstImport(imports, 'dynamic', node.source ?? node.argument) - } else if (type === 'CallExpression' && isImportCallee(node.callee)) { - const firstArgument = Array.isArray(node.arguments) - ? node.arguments[0] - : undefined - addAstImport(imports, 'dynamic', firstArgument) - } - - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const child of value) { - collectAstImports(child, imports, seen) - } - continue - } - collectAstImports(value, imports, seen) - } -} - -// AST counterpart of `isAllInlineTypeSpecifiers`: an import/re-export whose -// specifiers are ALL inline-type is erased by the bundler (no runtime module), -// so it must not become a static graph edge. Default/namespace specifiers -// carry no `type` kind, so their presence keeps the edge. -function hasOnlyInlineTypeSpecifiers(node: Record): boolean { - const specifiers = node.specifiers - if (!Array.isArray(specifiers) || specifiers.length === 0) return false - return specifiers.every( - (specifier) => - isRecord(specifier) && - (specifier.importKind === 'type' || specifier.exportKind === 'type'), - ) -} - -function addAstImport( - imports: ImportReference[], - kind: ImportReference['kind'], - node: unknown, -): void { - const specifier = getStringLiteralValue(node) - if (specifier) imports.push({ kind, specifier }) -} - -function getStringLiteralValue(node: unknown): string | undefined { - if (!isRecord(node)) return undefined - if (typeof node.value === 'string') return node.value - if (typeof node.raw === 'string') return node.raw.slice(1, -1) - return undefined -} - -function isImportCallee(node: unknown): boolean { - if (!isRecord(node)) return false - return node.type === 'Import' || node.name === 'import' -} - -// A brace clause whose specifiers are ALL inline-type (`{ type A, type B }`) -// is erased by the bundler exactly like a statement-level `import type`: -// TypeScript import elision (the Next.js/SWC default) removes the whole -// statement, so no runtime module is ever produced. Counting such an edge as -// static merges a phantom member into a bucket the bundler never compiles — -// the next-plugin coordinator then waits for a file that can never arrive. -// A mixed clause (`{ type A, b }`) still imports the module for `b` and is -// kept. A default/namespace clause is always a value import and is kept. -function isAllInlineTypeSpecifiers(clause: string | undefined): boolean { - if (!clause) return false - const trimmed = clause.trim() - if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return false - const specifiers = trimmed - .slice(1, -1) - .split(',') - .map((specifier) => specifier.trim()) - .filter((specifier) => specifier.length > 0) - return ( - specifiers.length > 0 && - specifiers.every((specifier) => /^type\s/.test(specifier)) - ) -} - -function scanImports(source: string): ImportReference[] { - const imports: ImportReference[] = [] - const code = stripComments(source) - // The leading `(type\s+)?` is CAPTURED (not skipped) so we can drop type-only - // statements: `import type ... from` / `export type ... from` are erased by - // the bundler and produce NO runtime module — counting them as static graph - // edges merges phantom members into a bucket that the bundler never compiles, - // which is exactly what forced the coordinator's wall-clock fail-open to fire. - // The clause between the keyword and `from` is captured too, so all-inline- - // type specifier lists (`import { type A } from` / `export { type A } from`) - // — which the bundler also erases — are dropped via - // `isAllInlineTypeSpecifiers`. Mixed lists (`import { type A, b } from`) - // keep importing the module for `b`, so they are kept. - const staticImportRegex = - /\bimport\s+(type\s+)?(?:([^'"`]*?)\s+from\s*)?(['"])([^'"]+)\3/gm - const exportFromRegex = - /\bexport\s+(type\s+)?(\*[^'"`]*?|\{[^}]*\})\s+from\s*(['"])([^'"]+)\3/gm - const dynamicImportRegex = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/gm - - for (const match of code.matchAll(staticImportRegex)) { - if (match[1] || isAllInlineTypeSpecifiers(match[2])) continue - imports.push({ kind: 'static', specifier: match[4] }) - } - for (const match of code.matchAll(exportFromRegex)) { - if (match[1] || isAllInlineTypeSpecifiers(match[2])) continue - imports.push({ kind: 'static', specifier: match[4] }) - } - for (const match of code.matchAll(dynamicImportRegex)) { - imports.push({ kind: 'dynamic', specifier: match[2] }) - } - - return imports -} - -function stripComments(source: string): string { - let result = '' - let index = 0 - let quote: false | '"' | "'" | '`' = false - - while (index < source.length) { - const char = source[index] - const next = source[index + 1] - - if (quote) { - // Template-literal CONTENTS are blanked (delimiters and newlines kept): - // embedded code snippets (docs sites, codegen templates) would otherwise - // look like real import statements to the scanners below and create - // phantom graph edges for files the bundler never loads. Contents of - // '/" strings are preserved — import specifiers themselves are read from - // those literals by the scan regexes. - if (quote === '`') { - if (char === '\\') { - result += ' ' - index += 2 - continue - } - result += char === '`' || char === '\n' ? char : ' ' - if (char === '`') quote = false - index += 1 - continue - } - result += char - if (char === '\\') { - result += next ?? '' - index += 2 - continue - } - if (char === quote) quote = false - index += 1 - continue - } - - if (char === '"' || char === "'" || char === '`') { - quote = char - result += char - index += 1 - continue - } - - if (char === '/' && next === '/') { - while (index < source.length && source[index] !== '\n') { - result += ' ' - index += 1 - } - continue - } - - if (char === '/' && next === '*') { - result += ' ' - index += 2 - while ( - index < source.length && - !(source[index] === '*' && source[index + 1] === '/') - ) { - result += source[index] === '\n' ? '\n' : ' ' - index += 1 - } - result += ' ' - index += 2 - continue - } - - result += char - index += 1 - } - - return result -} - -function readPathAliases(tsconfigPath: string | undefined): { - aliases: PathAlias[] - baseDir: string -} { - if (!tsconfigPath || !existsSync(tsconfigPath)) { - return { aliases: [], baseDir: process.cwd() } - } - - const configPath = resolve(tsconfigPath) - const configDir = dirname(configPath) - try { - const config = JSON.parse( - stripTrailingCommas(stripComments(readFileSync(configPath, 'utf-8'))), - ) - if (!isRecord(config) || !isRecord(config.compilerOptions)) { - return { aliases: [], baseDir: configDir } - } - - const baseUrl = - typeof config.compilerOptions.baseUrl === 'string' - ? config.compilerOptions.baseUrl - : '.' - const paths = config.compilerOptions.paths - if (!isRecord(paths)) { - return { aliases: [], baseDir: resolve(configDir, baseUrl) } - } - - const aliases: PathAlias[] = [] - for (const [alias, targetList] of Object.entries(paths)) { - if (!Array.isArray(targetList)) continue - const starIndex = alias.indexOf('*') - aliases.push({ - prefix: starIndex === -1 ? alias : alias.slice(0, starIndex), - suffix: starIndex === -1 ? '' : alias.slice(starIndex + 1), - targets: targetList.filter( - (target): target is string => typeof target === 'string', - ), - }) - } - - aliases.sort((a, b) => b.prefix.length - a.prefix.length) - return { aliases, baseDir: resolve(configDir, baseUrl) } - } catch { - return { aliases: [], baseDir: configDir } - } -} - -function stripTrailingCommas(json: string): string { - return json.replace(/,\s*([}\]])/g, '$1') -} - -function resolveImport( - specifier: string, - importer: string, - context: ResolveContext, -): string | undefined { - const candidateBases: string[] = [] - - if (specifier.startsWith('.')) { - candidateBases.push(resolve(dirname(importer), specifier)) - } else if (specifier.startsWith('/')) { - candidateBases.push(resolve(specifier)) - } else { - candidateBases.push(...resolveAliasCandidates(specifier, context)) - } - - for (const candidateBase of candidateBases) { - const resolvedFile = resolveFile(candidateBase) - if (!resolvedFile) continue - if (!context.files.has(resolvedFile)) continue - if (!isInsideDir(context.srcDir, resolvedFile)) continue - return resolvedFile - } - - return undefined -} - /** A module an import resolved to, as `setModuleResolver` expects it. */ export interface ResolvedModule { + readonly ignored?: never path: string code: string + readonly sourceType?: import('./prepared-source').SourceType +} + +/** Successful native ignore: no filesystem module or manufactured source. */ +export interface IgnoredModule { + readonly ignored: true + readonly path?: never + readonly code?: never + readonly sourceType?: never + readonly request?: never } +export type ModuleResolution = ResolvedModule | IgnoredModule + export interface CreateModuleResolverOptions { + readonly onResolutionInputs?: ResolutionInputObserver + readonly prepareSource?: PrepareSource + readonly alias?: ModuleAliasOptions + readonly includeMdx?: MdxSelection cwd?: string tsconfigPath?: string + conditions?: readonly string[] /** * The name the plugin extracts a file under, given its absolute path. A * resolved module is extracted under this name, so it has to be the one the @@ -964,131 +1099,94 @@ export interface CreateModuleResolverOptions { * `module`, `main`), reading each module. */ export function createModuleResolver({ - cwd = process.cwd(), - tsconfigPath = join(cwd, 'tsconfig.json'), toId = (path) => path, -}: CreateModuleResolverOptions = {}): ( - specifier: string, - importer: string, -) => ResolvedModule | undefined { - const { aliases, baseDir } = readPathAliases(tsconfigPath) - return (specifier, importer) => { - const from = resolve(cwd, importer) - const path = specifier.startsWith('.') - ? resolveSourceFile(resolve(dirname(from), specifier)) - : isAbsolute(specifier) - ? resolveSourceFile(specifier) - : (resolveAliasCandidates(specifier, { - aliases, - aliasBaseDir: baseDir, - }) - .map(resolveSourceFile) - .find(Boolean) ?? resolvePackage(specifier, from)) - return path - ? { path: toId(path), code: readFileSync(path, 'utf-8') } - : undefined - } -} - -const sourceExtensions = [...jsExtensions, '.cjs'] - -/** `resolveFile`, also completing a name like `theme.css` to `theme.css.ts`. */ -function resolveSourceFile(base: string): string | undefined { - const candidates = sourceExtensions.includes(extname(base)) - ? [base] - : [ - ...jsExtensions.map((extension) => `${base}${extension}`), - ...jsExtensions.map((extension) => join(base, `index${extension}`)), - ] - const found = candidates.find(isFile) - return found && resolve(found) + ...options +}: CreateModuleResolverOptions = {}): ModuleResolver { + const resolver = createModulePathResolver({ + ...options, + includeMdx: options.includeMdx ?? true, + }) + const prepared = createPreparedResolver(options) + return Object.assign( + (specifier: string, importer: string) => { + const resolution = resolver(specifier, importer) + if (!resolution) return undefined + if (resolution.ignored === true) return resolution + const { path } = resolution + const source = prepared.read( + path, + resolve(options.cwd ?? process.cwd(), importer), + ) + return prepared.resolved(toId(path), path, source) + }, + { remapError: prepared.remapError }, + ) } -/** Conditions of package `exports`, in the order a bundler prefers them */ -const packageConditions = ['import', 'module', 'default', 'require', 'node'] - -function resolvePackage( +function createModulePathResolver( + { + cwd = process.cwd(), + tsconfigPath = join(cwd, 'tsconfig.json'), + conditions = ['import', 'module', 'require', 'node'], + alias = {}, + includeMdx, + onResolutionInputs, + }: CreateModuleResolverOptions = {}, + excludedDirectory: ( + directory: string, + ) => boolean = createDirectoryExclusion(), +): ( specifier: string, importer: string, -): string | undefined { - const parts = specifier.split('/') - const nameLength = specifier.startsWith('@') ? 2 : 1 - const name = parts.slice(0, nameLength).join('/') - const packageDir = findPackage(dirname(importer), name) - if (!packageDir) return undefined - const found = resolvePackageEntry( - packageDir, - JSON.parse( - readFileSync(join(packageDir, 'package.json'), 'utf-8'), - ) as Record, - ['.', ...parts.slice(nameLength)].join('/'), - ) - return found && realpathSync(found) -} - -function findPackage(dir: string, name: string): string | undefined { - const packageDir = join(dir, 'node_modules', name) - if (isFile(join(packageDir, 'package.json'))) return packageDir - const parent = dirname(dir) - return parent === dir ? undefined : findPackage(parent, name) -} -function resolvePackageEntry( - dir: string, - manifest: Record, - subpath: string, -): string | undefined { - if (manifest.exports !== undefined) { - const target = exportsTarget(manifest.exports, subpath) - return target === undefined - ? undefined - : resolveSourceFile(join(dir, target)) - } - if (subpath !== '.') return resolveSourceFile(join(dir, subpath)) - const main = [manifest.module, manifest.main].find( - (entry): entry is string => typeof entry === 'string', - ) - return resolveSourceFile(join(dir, main ?? 'index')) -} - -function exportsTarget(exports: unknown, subpath: string): string | undefined { - const subpaths = - isRecord(exports) && !Array.isArray(exports) - ? Object.keys(exports).filter((key) => key.startsWith('.')) - : [] - if (subpaths.length === 0) { - return subpath === '.' ? conditionTarget(exports) : undefined - } - const map = exports as Record - if (subpath in map) return conditionTarget(map[subpath]) - for (const key of subpaths) { - const [prefix, suffix = ''] = key.split('*') - if ( - key.includes('*') && - subpath.startsWith(prefix) && - subpath.endsWith(suffix) && - subpath.length >= prefix.length + suffix.length - ) { - const matched = subpath.slice( - prefix.length, - subpath.length - suffix.length, - ) - return conditionTarget(map[key])?.replaceAll('*', matched) +) => AliasResolution | IgnoredModule | false | undefined { + const setupInputs = createResolutionInputs() + const config = (() => { + try { + return readPathAliases(tsconfigPath, setupInputs) + } finally { + onResolutionInputs?.(setupInputs.snapshot()) + } + })() + const { aliases, baseDir, baseUrl } = config + const setup = setupInputs.snapshot() + const extensions = sourceExtensions(includeMdx) + return (specifier, importer) => { + const inputs = createResolutionInputs(setup) + const fileResolver = (path: string) => + resolveFile(path, { extensions, excludedDirectory, inputs }) + const from = resolve(cwd, importer) + try { + return resolveModuleAlias(specifier, { + alias, + importer: from, + resolveRequest: (request, aliased) => + request.startsWith('.') + ? fileResolver(resolve(dirname(from), request)) + : isAbsolute(request) + ? fileResolver(request) + : (resolveAliasCandidates(request, { + aliases, + aliasBaseDir: baseDir, + }) + .map(fileResolver) + .find((candidate) => candidate !== undefined) ?? + (baseUrl + ? fileResolver(resolve(baseUrl, request)) + : undefined) ?? + resolvePackage(request, from, { + conditions, + fileResolver, + excludedDirectory, + aliased, + inputs, + })), + }) + } finally { + onResolutionInputs?.(inputs.snapshot()) } } - return undefined } -function conditionTarget(value: unknown): string | undefined { - if (typeof value === 'string') return value - if (Array.isArray(value)) { - return value.map(conditionTarget).find(Boolean) - } - if (!isRecord(value)) return undefined - return packageConditions - .filter((condition) => condition in value) - .map((condition) => conditionTarget(value[condition])) - .find(Boolean) -} function resolveAliasCandidates( specifier: string, context: Pick, @@ -1097,7 +1195,9 @@ function resolveAliasCandidates( for (const alias of context.aliases) { if ( !specifier.startsWith(alias.prefix) || - !specifier.endsWith(alias.suffix) + !specifier.endsWith(alias.suffix) || + (alias.exact && specifier !== alias.prefix) || + specifier.length < alias.prefix.length + alias.suffix.length ) { continue } @@ -1110,37 +1210,11 @@ function resolveAliasCandidates( resolve(context.aliasBaseDir, target.replace('*', matched)), ) } + break } return candidates } -function resolveFile(candidateBase: string): string | undefined { - const ext = extname(candidateBase) - if (ext) { - if (!jsExtensions.includes(ext)) return undefined - return isFile(candidateBase) ? resolve(candidateBase) : undefined - } - - for (const jsExtension of jsExtensions) { - const candidate = `${candidateBase}${jsExtension}` - if (isFile(candidate)) return resolve(candidate) - } - for (const jsExtension of jsExtensions) { - const candidate = join(candidateBase, `index${jsExtension}`) - if (isFile(candidate)) return resolve(candidate) - } - - return undefined -} - -function isFile(path: string): boolean { - try { - return statSync(path).isFile() - } catch { - return false - } -} - function isInsideDir(dir: string, file: string): boolean { const relPath = relative(dir, file) return relPath === '' || (!relPath.startsWith('..') && !isAbsolute(relPath)) diff --git a/packages/plugin-utils/src/import-mask.ts b/packages/plugin-utils/src/import-mask.ts new file mode 100644 index 000000000..dedb2802e --- /dev/null +++ b/packages/plugin-utils/src/import-mask.ts @@ -0,0 +1,251 @@ +import { maskImportTypes } from './import-type-mask' + +const typeParameterPrefix = + /^<(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\r\n]*(?:\r?\n|$))*(?:const(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\r\n]*(?:\r?\n|$))+)?[A-Za-z_$][\w$]*(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\r\n]*(?:\r?\n|$))*(?:,|=|extends\b(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\r\n]*(?:\r?\n|$))+[^\s=>/])/ + +function startsExpression(previous: string): boolean { + return ( + !/^(?:value|[\w$]+|\)|\]|\+\+|--|\.|\?\.)$/.test(previous) || + /^(?:return|throw|case|delete|void|typeof|new|yield|await|in|instanceof|of|else|do)$/.test( + previous, + ) + ) +} + +/** Lexical masking only: the bundler, not this scanner, validates syntax. */ +export function maskImportText( + source: string, + jsx: boolean, + typescript = true, +): string { + const output = source.split('') + let index = 0 + let previous = '' + let jsxDepth = 0 + let text = false + let tag = false + let tagClosing = false + const parentheses: boolean[] = [] + const expressions: { + readonly braces: number + readonly depth: number + readonly tag: boolean + readonly tagClosing: boolean + readonly text: boolean + }[] = [] + const templates: number[] = [] + const blocks = new Set() + const bodies: { + readonly braces: number + readonly parentheses: number + readonly statement: boolean + }[] = [] + let templateText = false + let braces = 0 + function blank(start: number, end: number): void { + for ( + let offset = start; + offset < Math.min(end, source.length); + offset += 1 + ) { + if (output[offset] !== '\n' && output[offset] !== '\r') + output[offset] = ' ' + } + } + function quoted(quote: string): void { + const start = index++ + while (index < source.length) { + const char = source[index++] + if (char === '\\') index += 1 + else if (char === quote) break + } + blank(start + 1, index - 1) + previous = 'value' + } + function comment(line: boolean): void { + const start = index + index += 2 + while ( + index < source.length && + (line + ? source[index] !== '\n' + : !(source[index] === '*' && source[index + 1] === '/')) + ) + index += 1 + if (!line) index += 2 + blank(start, Math.min(index, source.length)) + } + function regexLiteral(): void { + const start = index++ + let bracket = false + while (index < source.length) { + const part = source[index++] + if (part === '\\') index += 1 + else if (part === '[') bracket = true + else if (part === ']') bracket = false + else if (part === '/' && !bracket) break + } + while (/[a-z]/i.test(source[index] ?? '') && index < source.length) + index += 1 + blank(start, index) + previous = 'value' + } + while (index < source.length) { + const char = source[index] + const next = source[index + 1] + if (templateText) { + blank(index, index + 1) + index += 1 + if (char === '\\') { + blank(index, index + 1) + index += 1 + } else if (char === '`') { + output[index - 1] = '`' + templates.pop() + templateText = false + previous = 'value' + } else if (char === '$' && next === '{') { + blank(index, index + 1) + index += 1 + braces += 1 + templateText = false + previous = '(' + } + continue + } + if ((tag || text) && char === '{') { + expressions.push({ braces, depth: jsxDepth, tag, tagClosing, text }) + braces += 1 + tag = text = false + index += 1 + previous = '{' + continue + } + if (tag) { + if (char === '"' || char === "'") { + quoted(char) + continue + } + if (char === '>') { + jsxDepth += tagClosing ? -1 : source[index - 1] === '/' ? 0 : 1 + tag = false + text = jsxDepth > (expressions.at(-1)?.depth ?? 0) + previous = 'value' + } + blank(index, index + 1) + index += 1 + continue + } + if (text && char !== '<' && char !== '{') { + blank(index, index + 1) + index += 1 + continue + } + if (char === '/' && (next === '/' || next === '*')) { + comment(next === '/') + continue + } + if (char === '`') { + templates.push(braces) + templateText = true + index += 1 + continue + } + if (char === '"' || char === "'") { + quoted(char) + continue + } + if (char === '/' && startsExpression(previous)) { + regexLiteral() + continue + } + const operator = text + ? undefined + : /^(?:>>>=|>>>|>>=|<<=|===|!==|\*\*=|&&=|\|\|=|\?\?=|=>|<<|>>|<=|>=|==|!=|&&|\|\||\?\?|\*\*|\+\+|--|\?\.|[+*/%&|^!-]=)/.exec( + source.slice(index), + )?.[0] + if (operator) { + previous = operator + index += operator.length + continue + } + if ( + jsx && + char === '<' && + (text || + (startsExpression(previous) && + !(typescript && typeParameterPrefix.test(source.slice(index))))) && + /[A-Za-z/>]/.test(next ?? '') + ) { + tag = true + tagClosing = next === '/' + text = false + blank(index, index + 1) + index += 1 + continue + } + if (char === '{') { + const body = bodies.at(-1) + if (body?.braces === braces && body.parentheses === parentheses.length) { + bodies.pop() + if (body.statement) blocks.add(braces) + } else if (/^(?:|;|\)|=>|try|finally|else|do)$/.test(previous)) + blocks.add(braces) + braces += 1 + } + if (char === '}') { + braces -= 1 + if (templates.at(-1) === braces) { + templateText = true + blank(index, index + 1) + } + const expression = expressions.at(-1) + if (expression?.braces === braces) { + expressions.pop() + tag = expression.tag + tagClosing = expression.tagClosing + text = expression.text + } + previous = blocks.delete(braces) ? '' : 'value' + index += 1 + continue + } + if (/[A-Za-z_$]/.test(char)) { + const start = index++ + while (/[\w$]/.test(source[index] ?? '') && index < source.length) + index += 1 + const word = source.slice(start, index) + if ( + word === 'async' && + /^(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/)+function\b/.test( + source.slice(index), + ) + ) + continue + if (word === 'function' || word === 'class') + bodies.push({ + braces, + parentheses: parentheses.length, + statement: + /^(?:|;|[\w$]+|\)|\]|\+\+|--)$/.test(previous) && + !/^(?:return|throw|case|delete|void|typeof|new|yield|await|in|instanceof|of|extends)$/.test( + previous, + ), + }) + previous = word === 'await' && previous === 'for' ? 'for' : word + continue + } + if (char === '(') + parentheses.push(/^(?:if|while|for|with|switch|catch)$/.test(previous)) + if (!/\s/.test(char)) + previous = + char === ')' && parentheses.pop() + ? '' + : char === '!' && typescript && !startsExpression(previous) + ? 'value' + : char + index += 1 + } + const masked = output.join('') + return typescript ? maskImportTypes(masked) : masked +} diff --git a/packages/plugin-utils/src/import-requests.ts b/packages/plugin-utils/src/import-requests.ts new file mode 100644 index 000000000..1baf71aab --- /dev/null +++ b/packages/plugin-utils/src/import-requests.ts @@ -0,0 +1,65 @@ +import type { ImportRequestReference } from './import-scanner' +import type { ResolutionInputs } from './resolution-inputs' + +export type ImportRequestOutcome = + | { + readonly kind: 'resolved' + readonly path: string + readonly inputs: ResolutionInputs + } + | { readonly kind: 'external'; readonly request: string } + | { readonly kind: 'ignored' } + | { readonly kind: 'excluded'; readonly entry: string } + | { readonly kind: 'unresolved' } + | { readonly kind: 'error'; readonly error: unknown } + +export interface ImportGraphRequest { + readonly importer: string + readonly request: string + readonly specifier: string + readonly kind: ImportRequestReference['requestKind'] + readonly position: ImportRequestReference['position'] + readonly source: 'source' | 'compiled' + readonly map?: unknown + readonly outcome: ImportRequestOutcome +} + +const failures = new WeakMap() + +export function importGraphFailureOf( + error: unknown, +): readonly ImportGraphRequest[] | undefined { + return (typeof error === 'object' && error !== null) || + typeof error === 'function' + ? failures.get(error) + : undefined +} + +export function retainImportGraphFailure( + error: unknown, + request: ImportGraphRequest, +): void { + if ( + (typeof error === 'object' && error !== null) || + typeof error === 'function' + ) + failures.set(error, freezeImportRequests([request])) +} + +export function freezeImportRequests( + requests: ImportGraphRequest[], +): readonly ImportGraphRequest[] { + requests.sort((a, b) => + a.importer < b.importer + ? -1 + : a.importer > b.importer + ? 1 + : a.position.offset - b.position.offset, + ) + for (const request of requests) { + Object.freeze(request.position) + Object.freeze(request.outcome) + Object.freeze(request) + } + return Object.freeze(requests) +} diff --git a/packages/plugin-utils/src/import-scanner.ts b/packages/plugin-utils/src/import-scanner.ts new file mode 100644 index 000000000..71d18f200 --- /dev/null +++ b/packages/plugin-utils/src/import-scanner.ts @@ -0,0 +1,189 @@ +import { maskImportText } from './import-mask' + +export interface ImportReference { + readonly kind: 'static' | 'dynamic' + readonly specifier: string +} + +export interface ImportRequestReference extends ImportReference { + readonly requestKind: + 'static-import' | 're-export' | 'literal-dynamic-import' | 'literal-require' + readonly request: string + readonly position: { + readonly offset: number + readonly line: number + readonly column: number + } +} + +function literalValue(raw: string): string { + return raw + .slice(1, -1) + .replace( + /\\(?:u\{([\da-f]+)\}|u([\da-f]{4})|x([\da-f]{2})|\r\n|[\s\S])/gi, + ( + escape: string, + point: string | undefined, + unicode: string | undefined, + hex: string | undefined, + ) => { + const digits = point ?? unicode ?? hex + if (digits) return String.fromCodePoint(Number.parseInt(digits, 16)) + const char = escape.slice(1) + const escaped: Readonly> = { + n: '\n', + r: '\r', + t: '\t', + b: '\b', + f: '\f', + v: '\v', + '0': '\0', + } + return /[\r\n]/.test(char) ? '' : (escaped[char] ?? char) + }, + ) +} + +function allInlineTypes(clause: readonly string[]): boolean { + if (clause[0] !== '{' || clause.at(-1) !== '}') return false + const specifiers = clause + .slice(1, -1) + .join(' ') + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + return ( + specifiers.length > 0 && + specifiers.every((value) => { + const names = value.split(/\s+/) + return ( + names[0] === 'type' && + names.length > 1 && + (names[1] !== 'as' || + names.length === 2 || + (names[2] === 'as' && names.length > 3)) + ) + }) + ) +} + +/** Internal scanner seam; not part of the package export map. */ +export function scanImports( + source: string, + jsx: boolean, + typescript = true, +): ImportReference[] { + return scanImportRequests(source, jsx, typescript).map( + ({ kind, specifier }) => ({ + kind, + specifier, + }), + ) +} + +export function scanImportRequests( + source: string, + jsx: boolean, + typescript = true, +): ImportRequestReference[] { + const tokens = [ + ...maskImportText(source, jsx, typescript).matchAll( + /(['"])[^'"]*\1|[\w$]+|[^\s]/g, + ), + ].map((match) => ({ + value: match[0], + offset: match.index, + literal: match[1] + ? literalValue(source.slice(match.index, match.index + match[0].length)) + : undefined, + })) + const imports: ImportRequestReference[] = [] + function reference( + token: (typeof tokens)[number] & { readonly literal: string }, + requestKind: ImportRequestReference['requestKind'], + ): ImportRequestReference { + const before = source.slice(0, token.offset) + return { + kind: requestKind === 'literal-dynamic-import' ? 'dynamic' : 'static', + specifier: token.literal, + requestKind, + request: source.slice( + token.offset + 1, + token.offset + token.value.length - 1, + ), + position: { + offset: token.offset, + line: before.split(/\r\n|[\r\n\u2028\u2029]/).length, + column: + token.offset - + Math.max( + before.lastIndexOf('\n'), + before.lastIndexOf('\r'), + before.lastIndexOf('\u2028'), + before.lastIndexOf('\u2029'), + ), + }, + } + } + for (let index = 0; index < tokens.length; index += 1) { + const word = tokens[index].value + if ( + !['import', 'export', 'require'].includes(word) || + tokens[index - 1]?.value === '.' + ) + continue + const next = tokens[index + 1] + if (next?.value === '(' && word !== 'export') { + const argument = tokens[index + 2]?.literal + const end = tokens[index + 3]?.value + if (argument && (end === ')' || (word === 'import' && end === ','))) + imports.push( + reference( + { ...tokens[index + 2], literal: argument }, + word === 'require' ? 'literal-require' : 'literal-dynamic-import', + ), + ) + continue + } + if (word === 'require') continue + if (word === 'import' && next?.literal) { + imports.push( + reference({ ...next, literal: next.literal }, 'static-import'), + ) + continue + } + const clause: string[] = [] + let bindings = 0 + for (let cursor = index + 1; cursor < tokens.length; cursor += 1) { + const token = tokens[cursor] + if ( + token.value === 'from' && + bindings === 0 && + tokens[cursor + 1]?.literal !== undefined + ) { + const specifier = tokens[cursor + 1]?.literal + if ( + specifier && + !(clause[0] === 'type' && clause.length > 1 && clause[1] !== ',') && + !allInlineTypes(clause) + ) + imports.push( + reference( + { ...tokens[cursor + 1], literal: specifier }, + word === 'export' ? 're-export' : 'static-import', + ), + ) + break + } + if ( + !/^(?:[\w$]+|[*{},])$/.test(token.value) && + token.literal === undefined + ) + break + if (token.value === '{') bindings += 1 + if (token.value === '}') bindings -= 1 + clause.push(token.value) + } + } + return imports +} diff --git a/packages/plugin-utils/src/import-type-mask.ts b/packages/plugin-utils/src/import-type-mask.ts new file mode 100644 index 000000000..58e5c1f13 --- /dev/null +++ b/packages/plugin-utils/src/import-type-mask.ts @@ -0,0 +1,205 @@ +/** Receives already-masked code; only type-context import keywords are blanked. */ +export function maskImportTypes(source: string): string { + const tokens = [ + ...source.matchAll( + /(['"])[^'"]*\1|=>|\?\.|===|!==|==|!=|&&|\|\||\?\?|[\w$]+|[^\s]/g, + ), + ] + const output = source.split('') + const pairs = new Map() + const stack: number[] = [] + const questions = new Map() + const ternaryColons = new Set() + const parameters = new Set() + const parameterEnds = new Set() + const classes = new Set() + const bindings = new Set() + const variables = new Set() + const value = (index: number): string => tokens[index]?.[0] ?? '' + for (let index = 0; index < tokens.length; index += 1) { + const word = value(index) + if (/^[({[]$/.test(word)) stack.push(index) + else if (/^[)}\]]$/.test(word)) { + const opening = stack.pop() + if (opening !== undefined) pairs.set(opening, index) + } + const scope = stack.at(-1) ?? -1 + if (word === '?' && value(index + 1) !== ':') + questions.set(scope, (questions.get(scope) ?? 0) + 1) + if (word === ':' && (questions.get(scope) ?? 0) > 0) { + ternaryColons.add(index) + questions.set(scope, (questions.get(scope) ?? 0) - 1) + } + } + function typeEnd(start: number, stops: RegExp): number { + const closing: string[] = [] + let functionType = /^(?:\(|<)$/.test(value(start)) + let conditional = false + const delimiters: Readonly> = { + '(': ')', + '[': ']', + '{': '}', + '<': '>', + } + for (let cursor = start; cursor < tokens.length; cursor += 1) { + const word = value(cursor) + if (closing.length === 0) { + if ( + cursor > start && + /[\r\n]/.test( + source.slice(tokens[cursor - 1]?.index, tokens[cursor]?.index), + ) && + /^(?:[\w$]+|\)|\]|\}|>|`|(['"])[^'"]*\1)$/.test(value(cursor - 1)) && + !/^(?:typeof|keyof|extends|infer|import|new|abstract|is|in)$/.test( + value(cursor - 1), + ) && + ((/^[A-Za-z_$][\w$]*$/.test(word) && + !/^(?:extends|is|in)$/.test(word)) || + (word === '(' && !(functionType && value(cursor - 1) === '>'))) + ) + return cursor + if (word === 'extends') conditional = true + if (word === '=>' && functionType && value(cursor - 1) === ')') { + functionType = false + continue + } + if ( + (stops.test(word) && !(word === '?' && conditional)) || + /^(?:const|let|var|export|function|class|interface)$/.test(word) + ) + return cursor + } + if ( + word === '{' && + closing.length === 0 && + cursor > start && + !/^(?:[|&]|=>)$/.test(value(cursor - 1)) + ) + return cursor + const delimiter = delimiters[word] + if (delimiter) closing.push(delimiter) + else if (word === closing.at(-1)) closing.pop() + else if (/^[)}\]>]$/.test(word) && closing.length === 0) return cursor + } + return tokens.length + } + for (const [opening, closing] of pairs) { + if (value(opening) !== '(') continue + let prefix = opening - 1 + if (value(prefix) === '>') { + let depth = 1 + while (prefix > 0 && depth > 0) { + prefix -= 1 + if (value(prefix) === '>') depth += 1 + if (value(prefix) === '<') depth -= 1 + } + prefix -= 1 + } + const declaration = + value(prefix) === 'function' || + value(prefix - 1) === 'function' || + (value(prefix - 1) === '*' && value(prefix - 2) === 'function') + // A colon after grouping may belong to a ternary, not a return type. + // Require the annotation to end at an arrow/body, or a function signature. + const annotationEnd = + value(closing + 1) === ':' && !ternaryColons.has(closing + 1) + ? value(typeEnd(closing + 2, /^(?:=>|;|=|,|\?)$/)) + : '' + if ( + (/^(?:=>|\{)$/.test(value(closing + 1)) || + /^(?:=>|\{)$/.test(annotationEnd) || + (value(closing + 1) === ':' && + declaration && + !ternaryColons.has(closing + 1))) && + !/^(?:if|while|for|with|switch|catch|import|require)$/.test( + value(opening - 1), + ) + ) { + parameters.add(opening) + parameterEnds.add(closing) + } + } + function mask(start: number, end: number): void { + for (const token of tokens.slice(start, end)) { + if (token[0] !== 'import') continue + for (let offset = token.index; offset < token.index + 6; offset += 1) + output[offset] = ' ' + } + } + for (let index = 0; index < tokens.length; index += 1) { + const word = value(index) + const scope = stack.at(-1) ?? -1 + if (word === '<') { + const end = typeEnd(index + 1, /^(?:>|;)$/) + const operand = /^[A-Za-z_$][\w$]*$/.test(value(index - 1)) + const prefix = + /^(?:=|\(|,|return|=>)$/.test(value(index - 1)) && + (value(index + 1) === 'import' || + /^(?:const|extends|,|=)$/.test(value(index + 2))) + if ( + value(end) === '>' && + (prefix || (operand && /^(?:\(|\{|;|,|\))$/.test(value(end + 1)))) + ) { + mask(index + 1, end) + index = end + continue + } + } + if (word === 'type' && /^[A-Za-z_$][\w$]*$/.test(value(index + 1))) { + const equals = typeEnd(index + 2, /^(?:=|;)$/) + if (value(equals) === '=') { + const end = typeEnd(equals + 1, /^;$/) + mask(equals + 1, end) + index = end - 1 + continue + } + } + if ( + /^(?:interface|class)$/.test(word) && + value(index - 1) !== '.' && + /^(?:[A-Za-z_$][\w$]*|\{)$/.test(value(index + 1)) + ) { + let body = index + 1 + while (body < tokens.length && !/^(?:\{|;)$/.test(value(body))) + body = (pairs.get(body) ?? body) + 1 + const end = pairs.get(body) + if (word === 'interface' && end !== undefined) { + mask(body, end) + index = end + continue + } + if (word === 'class') classes.add(body) + } + if ( + /^(?:const|let|var)$/.test(word) && + /^(?:[A-Za-z_$][\w$]*|\{|\[)$/.test(value(index + 1)) + ) { + variables.add(scope) + bindings.add(scope) + } + if ( + (word === ':' && (bindings.has(scope) || parameterEnds.has(index - 1))) || + (/^(?:as|satisfies)$/.test(word) && + /^(?:[\w$]+|[)\]}!]|(['"])[^'"]*\1|`)$/.test(value(index - 1)) && + !/^(?:=|:)$/.test(value(index + 1))) + ) { + const end = typeEnd(index + 1, /^(?:=|,|;|=>|\+|-|&&|\|\||\?\?|\?)$/) + mask(index + 1, end) + index = end - 1 + continue + } + if (word === '=') bindings.delete(scope) + if (word === ',' && (parameters.has(scope) || variables.has(scope))) + bindings.add(scope) + if (word === ';') { + variables.delete(scope) + if (classes.has(scope)) bindings.add(scope) + else bindings.delete(scope) + } + if (/^[({[]$/.test(word)) { + stack.push(index) + if (parameters.has(index) || classes.has(index)) bindings.add(index) + } else if (/^[)}\]]$/.test(word)) stack.pop() + } + return output.join('') +} diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index b66b07abb..d1f8899ed 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -1,3 +1,17 @@ +export { beginBuild, type ResettableEngine } from './build-session' +export { + type CompiledReference, + createCompileTimeClassifier, + isCompileTimeAlias, + UntransformedSourceError, +} from './compiled-guard' +export { collectDevupConfigFiles } from './config-files' +export { + createDependencyGuard, + type DependencyGuardOptions, + type GuardModule, +} from './dependency-guard' +export { compiledFacts, type GuardFacts } from './guard-facts' export { type AtomHoistPlan, buildCanonicalMap, @@ -9,14 +23,56 @@ export { type ComputeFileReachOptions, computeFileRoutes, type ComputeFileRoutesOptions, + computeReachableFiles, + type ComputeReachableFilesOptions, createModuleResolver, type CreateModuleResolverOptions, + type IgnoredModule, listSourceFiles, + type ModuleResolution, planAtomHoist, + type PreparedGraphOptions, + type PreparedSource, + type PrepareSource, type ResolvedModule, + type ScannedStaticImportGraph, type StaticImportGraph, + type StaticImportGraphOptions, + type SyncGraphOptions, } from './import-graph' -export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' +export { + importGraphFailureOf, + type ImportGraphRequest, + type ImportRequestOutcome, +} from './import-requests' +export { type ImportReference, scanImports } from './import-scanner' +export { + ConfigLoadError, + deepMerge, + loadDevupConfig, + loadDevupConfigSync, +} from './load-config' +export { remapMdxError } from './mdx-errors' +export { + isMdxSource, + mdxSourceFilter, + normalizeMdxExtensions, + selectedSourceFilter, +} from './mdx-selection' +export { + collectNumberedFiles, + type CollectNumberedFilesOptions, + extractedNeedles, + type FileNumbering, + seedFileNumbers, +} from './numbering' +export type { ModuleResolver } from './prepared-resolver' +export type { SourceType } from './prepared-source' +export { + type ResolutionInputObserver, + type ResolutionInputs, +} from './resolution-inputs' +export { resolutionWatchPath } from './resolution-watch-path' export { createNodeModulesExcludeRegex, createThemeInterfaceArgs, @@ -24,12 +80,32 @@ export { type DevupThemeInterfaceNames, type DevupUIBasePluginOptions, getFileNumByFilename, + GRAPH_SOURCE_FILE_RE, + MDX_FILE_RE, + POST_COMPILED_MDX_RE, + resolveProjectPaths, + resolveSourceDirs, + SOURCE_EXTENSIONS, + SOURCE_FILE_RE, } from './shared' +export { + isSelectedSource, + type MdxSelection, + type SourceSelectionOptions, +} from './source-selection' +export { + createStateWriter, + type StateWriter, + writeFileAtomically, +} from './state-writer' export type { CustomShorthands, DevupConfig, DevupTheme, ImportAliases, + ModuleAliasDescriptor, + ModuleAliases, + ModuleAliasOptions, ThemeColors, ThemeTypography, Typography, diff --git a/packages/plugin-utils/src/jsonc.ts b/packages/plugin-utils/src/jsonc.ts new file mode 100644 index 000000000..dc5697496 --- /dev/null +++ b/packages/plugin-utils/src/jsonc.ts @@ -0,0 +1,54 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function parseJsonc(source: string, numericLiterals = false): unknown { + let text = '' + let quoted = false + for (let index = 0; index < source.length; index += 1) { + const char = source[index] + const next = source[index + 1] + if (quoted) { + text += char + if (char === '\\') text += source[++index] ?? '' + else if (char === '"') quoted = false + } else if (char === '"') { + quoted = true + text += char + } else if (char === '/' && next === '/') { + while (index < source.length && source[index] !== '\n') index += 1 + text += '\n' + } else if (char === '/' && next === '*') { + const end = source.indexOf('*/', index + 2) + if (end === -1) throw new SyntaxError('Unterminated JSON comment') + text += ' ' + index = end + 1 + } else text += char + } + let json = '' + quoted = false + for (let index = 0; index < text.length; index += 1) { + const char = text[index] + if (quoted) { + json += char + if (char === '\\') json += text[++index] ?? '' + else if (char === '"') quoted = false + } else if (char === '"') { + quoted = true + json += char + } else if (numericLiterals && /[\d.]/.test(char)) { + const numeric = + /^(?:0[xX][\da-fA-F](?:_?[\da-fA-F])*|0[bB][01](?:_?[01])*|0[oO][0-7](?:_?[0-7])*|(?:0|[1-9][\d_]*)(?:\.[\d_]*)?(?:[eE][+-]?[\d_]+)?|\.[\d_]+(?:[eE][+-]?[\d_]+)?)/.exec( + text.slice(index), + )?.[0] + if (!numeric || /^_|_$|__|_[.eE]|[.eE]_/.test(numeric)) + throw new SyntaxError('Invalid numeric literal') + const value = Number(numeric.replaceAll('_', '')) + // TS 6.0.3:42867-42873 keeps infinity; 1e999 is its valid JSON number spelling. + json += value === Infinity ? '1e999' : String(value) + index += numeric.length - 1 + } else if (char !== ',' || !/^\s*[}\]]/.test(text.slice(index + 1))) + json += char + } + return JSON.parse(json.replace(/^\uFEFF/, '')) +} diff --git a/packages/plugin-utils/src/load-config.ts b/packages/plugin-utils/src/load-config.ts index a30c977ab..7564e34f7 100644 --- a/packages/plugin-utils/src/load-config.ts +++ b/packages/plugin-utils/src/load-config.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync, realpathSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' @@ -48,12 +48,48 @@ export function deepMerge(base: T, override: U): T { /** * Parse JSON content safely */ +export class ConfigLoadError extends Error { + constructor( + readonly file: string, + cause: unknown, + ) { + super( + `${file}:1:1: Cannot load configuration: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + this.name = 'ConfigLoadError' + } +} + +function isDevupConfig(value: unknown): value is DevupConfig { + return ( + isPlainObject(value) && + (value.extends === undefined || + (Array.isArray(value.extends) && + value.extends.every((path) => typeof path === 'string'))) + ) +} + function parseConfig(content: string): DevupConfig { - try { - return JSON.parse(content) as DevupConfig - } catch { - return {} + const config: unknown = JSON.parse(content) + if (!isDevupConfig(config)) + throw new TypeError( + 'Expected a config object with an array of extends paths', + ) + return config +} + +function configStack(configPath: string, stack: readonly string[]): string[] { + const canonical = realpathSync(configPath) + if (stack.includes(canonical) || stack.length >= 128) { + throw new ConfigLoadError( + configPath, + new Error( + `Configuration inheritance cycle or depth limit: ${[...stack, canonical].join(' -> ')}`, + ), + ) } + return [...stack, canonical] } /** @@ -83,25 +119,39 @@ function mergeExtendedConfigs( * @param configPath - Path to the devup.json file * @returns Resolved configuration with all extends merged */ -export function loadDevupConfigSync(configPath: string): DevupConfig { - if (!existsSync(configPath)) { - return {} +export function loadDevupConfigSync( + configPath: string, + stack: readonly string[] = [], +): DevupConfig { + const file = resolve(configPath) + let config: DevupConfig + let nextStack: string[] + try { + nextStack = configStack(file, stack) + config = parseConfig(readFileSync(file, 'utf-8')) + } catch (cause) { + if ( + stack.length === 0 && + cause instanceof Error && + 'code' in cause && + cause.code === 'ENOENT' + ) + return {} + if (cause instanceof ConfigLoadError) throw cause + throw new ConfigLoadError(file, cause) } - const content = readFileSync(configPath, 'utf-8') - const config = parseConfig(content) - // If no extends, return the config as-is if (!config.extends || config.extends.length === 0) { return config } - const configDir = dirname(configPath) + const configDir = dirname(file) return mergeExtendedConfigs( config, config.extends.map((extendPath) => - loadDevupConfigSync(resolve(configDir, extendPath)), + loadDevupConfigSync(resolve(configDir, extendPath), nextStack), ), ) } @@ -115,25 +165,39 @@ export function loadDevupConfigSync(configPath: string): DevupConfig { */ export async function loadDevupConfig( configPath: string, + stack: readonly string[] = [], ): Promise { - if (!existsSync(configPath)) { - return {} + const file = resolve(configPath) + let config: DevupConfig + let nextStack: string[] + try { + nextStack = configStack(file, stack) + config = parseConfig(await readFile(file, 'utf-8')) + } catch (cause) { + if ( + stack.length === 0 && + cause instanceof Error && + 'code' in cause && + cause.code === 'ENOENT' + ) + return {} + if (cause instanceof ConfigLoadError) throw cause + throw new ConfigLoadError(file, cause) } - const content = await readFile(configPath, 'utf-8') - const config = parseConfig(content) - // If no extends, return the config as-is if (!config.extends || config.extends.length === 0) { return config } - const configDir = dirname(configPath) + const configDir = dirname(file) // Load extends sequentially to preserve merge order const extendedConfigs: DevupConfig[] = [] for (const extendPath of config.extends) { - extendedConfigs.push(await loadDevupConfig(resolve(configDir, extendPath))) + extendedConfigs.push( + await loadDevupConfig(resolve(configDir, extendPath), nextStack), + ) } return mergeExtendedConfigs(config, extendedConfigs) diff --git a/packages/plugin-utils/src/mdx-errors.ts b/packages/plugin-utils/src/mdx-errors.ts new file mode 100644 index 000000000..54f4d1e60 --- /dev/null +++ b/packages/plugin-utils/src/mdx-errors.ts @@ -0,0 +1,243 @@ +import { posix, win32 } from 'node:path' + +type SourceMap = { + readonly mappings: string + readonly sources: readonly string[] + readonly names: readonly string[] + readonly sourceRoot: string +} +type Segment = { + readonly generated: number + readonly original?: { + readonly source: string + readonly line: number + readonly column: number + } +} +const BASE64 = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +class MdxSourceMapError extends Error { + constructor(filename: string, cause: unknown) { + super( + `${filename}:1:1: Invalid MDX source map: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + this.name = 'MdxSourceMapError' + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function strings(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((entry) => typeof entry === 'string') + ) +} + +function parseMap(input: unknown): SourceMap { + const value: unknown = typeof input === 'string' ? JSON.parse(input) : input + if (!isRecord(value)) throw new TypeError('Expected a source-map object') + if ( + value.version !== 3 || + typeof value.mappings !== 'string' || + !strings(value.sources) || + (value.names !== undefined && !strings(value.names)) || + (value.sourceRoot !== undefined && typeof value.sourceRoot !== 'string') + ) { + throw new TypeError( + 'Expected source-map v3 mappings, sources, names and sourceRoot', + ) + } + return { + mappings: value.mappings, + sources: value.sources, + names: value.names ?? [], + sourceRoot: value.sourceRoot ?? '', + } +} + +function decode(segment: string): number[] { + const values: number[] = [] + let value = 0 + let multiplier = 1 + for (const char of segment) { + const digit = BASE64.indexOf(char) + if (digit < 0) throw new TypeError('Invalid base64 VLQ digit') + value += (digit % 32) * multiplier + if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) + throw new RangeError('VLQ exceeds safe integer bounds') + if (digit >= 32) { + multiplier *= 32 + } else { + values.push((value % 2 === 1 ? -1 : 1) * Math.floor(value / 2)) + value = 0 + multiplier = 1 + } + } + if (multiplier !== 1) throw new TypeError('Unterminated base64 VLQ') + if (![1, 4, 5].includes(values.length)) + throw new TypeError('Expected 1, 4 or 5 VLQ fields') + return values +} + +function sourcePath(root: string, source: string): string { + if ( + !root || + posix.isAbsolute(source) || + win32.isAbsolute(source) || + /^[a-z][\w+.-]*:/i.test(source) + ) + return source + if (/^[a-z][\w+.-]*:\/\//i.test(root)) + return new URL(source, `${root.replace(/\/$/, '')}/`).href + return root.includes('\\') + ? win32.join(root, source) + : posix.join(root, source) +} + +function decodeMap(map: SourceMap): Segment[][] { + let source = 0 + let line = 0 + let column = 0 + let name = 0 + return map.mappings.split(';').map((encodedLine) => { + let generated = 0 + if (!encodedLine) return [] + return encodedLine.split(',').map((encoded) => { + const fields = decode(encoded) + const [delta, sourceDelta, lineDelta, columnDelta, nameDelta] = fields + const previous = generated + generated += delta + if (!Number.isSafeInteger(generated) || generated < previous) + throw new RangeError( + 'Generated columns must be nonnegative and ordered', + ) + if (fields.length === 1) return { generated } + source += sourceDelta + line += lineDelta + column += columnDelta + if ( + ![source, line, column].every( + (value) => Number.isSafeInteger(value) && value >= 0, + ) || + source >= map.sources.length + ) { + throw new RangeError('Original source position is out of bounds') + } + if (nameDelta !== undefined) { + name += nameDelta + if (!Number.isSafeInteger(name) || name < 0 || name >= map.names.length) + throw new RangeError('Name index is out of bounds') + } + return { + generated, + original: { + source: sourcePath(map.sourceRoot, map.sources[source]), + line: line + 1, + column: column + 1, + }, + } + }) + }) +} + +function decodeInput(input: unknown): Segment[][] { + const value: unknown = typeof input === 'string' ? JSON.parse(input) : input + if (!isRecord(value) || !('sections' in value)) + return decodeMap(parseMap(value)) + if (value.version !== 3 || !Array.isArray(value.sections)) + throw new TypeError('Expected source-map v3 sections') + const rows: Segment[][] = [] + let previousLine = -1 + let previousColumn = -1 + for (const section of value.sections) { + if (!isRecord(section) || !isRecord(section.offset)) + throw new TypeError('Expected a section offset and map') + const { line, column } = section.offset + if ( + typeof line !== 'number' || + typeof column !== 'number' || + !Number.isSafeInteger(line) || + !Number.isSafeInteger(column) || + line < 0 || + column < 0 || + line < previousLine || + (line === previousLine && column <= previousColumn) + ) { + throw new RangeError('Section offsets must be nonnegative and ordered') + } + previousLine = line + previousColumn = column + rows.length = line + 1 + rows[line] = (rows[line] ?? []).filter( + (segment) => segment.generated < column, + ) + rows[line].push({ generated: column }) + decodeInput(section.map).forEach((segments, index) => { + const targetLine = line + index + const shifted = segments.map((segment) => ({ + ...segment, + generated: segment.generated + (index === 0 ? column : 0), + })) + if (shifted.some((segment) => !Number.isSafeInteger(segment.generated))) + throw new RangeError('Section generated column is out of bounds') + if (index === 0) rows[targetLine].push(...shifted) + else rows[targetLine] = shifted + }) + } + return rows +} + +/** Remap one-based extractor locations using zero-based source-map columns. */ +export function remapMdxError( + error: unknown, + filename: string, + inputSourceMap?: unknown, +): Error { + let lines: Segment[][] = [] + if (inputSourceMap !== undefined && inputSourceMap !== null) { + try { + lines = decodeInput(inputSourceMap) + } catch (cause) { + throw new MdxSourceMapError(filename, cause) + } + } + const message = error instanceof Error ? error.message : String(error) + const remapped = message + .split('\n') + .map((text) => { + if (!text.startsWith(`${filename}:`)) return text + const location = /^(\d+):(\d+)(?=:|\s|$)/.exec( + text.slice(filename.length + 1), + ) + if (!location) return text + const line = Number(location[1]) + const column = Number(location[2]) + if ( + !Number.isSafeInteger(line) || + !Number.isSafeInteger(column) || + line < 1 || + column < 1 + ) { + throw new MdxSourceMapError( + filename, + new RangeError('Error location is out of bounds'), + ) + } + let selected: Segment | undefined + for (const segment of lines[line - 1] ?? []) { + if (segment.generated > column - 1) break + selected = segment + } + const position = selected?.original + const prefix = position + ? `${position.source}:${position.line}:${position.column}` + : `${filename}:${location[0]} (in compiled MDX)` + return prefix + text.slice(filename.length + 1 + location[0].length) + }) + .join('\n') + return new Error(remapped, { cause: error }) +} diff --git a/packages/plugin-utils/src/mdx-selection.ts b/packages/plugin-utils/src/mdx-selection.ts new file mode 100644 index 000000000..06d9829c1 --- /dev/null +++ b/packages/plugin-utils/src/mdx-selection.ts @@ -0,0 +1,46 @@ +import { extname } from 'node:path' + +import { sourceExtensions } from './source-selection' + +export class MdxExtensionError extends Error { + readonly name = 'MdxExtensionError' + constructor(readonly extension: string) { + super( + `mdxExtensions:1:1: invalid extension ${JSON.stringify(extension)}; use leading-dot literal extensions, for example '.mdown'`, + ) + } +} + +export function normalizeMdxExtensions( + extensions: readonly string[] = ['.mdx'], +): readonly string[] { + return [ + ...new Set( + extensions.map((extension) => { + if (!/^\.[a-z\d][a-z\d_-]*$/i.test(extension)) + throw new MdxExtensionError(extension) + return extension.toLowerCase() + }), + ), + ] +} + +export function isMdxSource( + filename: string, + extensions: readonly string[], +): boolean { + return extensions.includes(extname(filename.split('?')[0]).toLowerCase()) +} + +export function mdxSourceFilter(extensions: readonly string[]): RegExp { + return extensions.length + ? new RegExp( + `\\.${extensions.length === 1 ? extensions[0].slice(1) : `(?:${extensions.map((extension) => extension.slice(1)).join('|')})`}$`, + 'i', + ) + : /$^/ +} + +export function selectedSourceFilter(extensions: readonly string[]): RegExp { + return mdxSourceFilter(sourceExtensions(extensions)) +} diff --git a/packages/plugin-utils/src/module-alias.ts b/packages/plugin-utils/src/module-alias.ts new file mode 100644 index 000000000..a9c8634ff --- /dev/null +++ b/packages/plugin-utils/src/module-alias.ts @@ -0,0 +1,129 @@ +import type { IgnoredModule } from './import-graph' +import type { ModuleAliasDescriptor, ModuleAliasOptions } from './types' + +export class ModuleAliasError extends Error { + constructor(importer: string, specifier: string) { + super(`${importer}:1:1: Module alias cycle cannot resolve ${specifier}`) + this.name = 'ModuleAliasError' + } +} + +export class ModuleAliasCandidatesError extends Error { + readonly name = 'ModuleAliasCandidatesError' + constructor( + readonly importer: string, + readonly key: string, + readonly candidates: readonly string[], + ) { + super( + `${importer}:1:1: Module alias ${key} cannot resolve candidates ${JSON.stringify(candidates)}`, + ) + } +} + +export class ModuleAliasPackageError extends Error { + readonly name = 'ModuleAliasPackageError' + constructor( + readonly importer: string, + readonly request: string, + ) { + super( + `${importer}:1:1: Module alias package ${request} has no resolving export under the active conditions`, + ) + } +} + +export class ModuleAliasConfigurationError extends Error { + readonly name = 'ModuleAliasConfigurationError' + constructor( + readonly importer: string, + readonly key: string, + ) { + super( + `${importer}:1:1: Module alias ${key} cannot use wildcard names in the shared resolver`, + ) + } +} + +export interface AliasResolution { + readonly ignored?: never + readonly path: string + readonly request: string +} + +/** Resolve every rewriting candidate completely before selecting its request. */ +export function resolveModuleAlias( + specifier: string, + context: { + readonly alias: ModuleAliasOptions + readonly importer: string + readonly resolveRequest: ( + request: string, + aliased: boolean, + ) => string | false | undefined + }, +): AliasResolution | IgnoredModule | false | undefined { + const descriptors: readonly (ModuleAliasDescriptor & { + readonly key: string + })[] = + context.alias === false + ? [] + : Array.isArray(context.alias) + ? context.alias.map((entry) => ({ ...entry, key: entry.name })) + : Object.entries(context.alias).map(([key, alias]) => ({ + key, + name: key.endsWith('$') ? key.slice(0, -1) : key, + onlyModule: key.endsWith('$'), + alias, + })) + let failure: + { readonly key: string; readonly candidates: readonly string[] } | undefined + function visit( + request: string, + visited: ReadonlySet, + aliased: boolean, + ): AliasResolution | IgnoredModule | false | undefined { + if (visited.has(request)) + throw new ModuleAliasError(context.importer, specifier) + const next = new Set(visited).add(request) + for (const { key, name, alias: value, onlyModule: exact } of descriptors) { + if (name.includes('*')) + throw new ModuleAliasConfigurationError(context.importer, key) + if (request !== name && (exact || !request.startsWith(`${name}/`))) + continue + const targets = typeof value === 'object' ? value : [value] + const candidates = targets + .filter( + (target) => + target === false || + (request !== target && !request.startsWith(`${target}/`)), + ) + .map((target) => + target === false ? false : target + request.slice(name.length), + ) + if (!candidates.length) continue + failure ??= { + key, + candidates: candidates.filter((candidate) => candidate !== false), + } + let missing = false + for (const candidate of candidates) { + if (candidate === false) return { ignored: true } + const resolved = visit(candidate, next, true) + if (resolved) return resolved + if (resolved === undefined) missing = true + } + return missing ? undefined : false + } + const path = context.resolveRequest(request, aliased) + return typeof path === 'string' ? { path, request } : path + } + const resolved = visit(specifier, new Set(), false) + if (resolved === undefined && failure) + throw new ModuleAliasCandidatesError( + context.importer, + failure.key, + failure.candidates, + ) + return resolved +} diff --git a/packages/plugin-utils/src/numbering.ts b/packages/plugin-utils/src/numbering.ts new file mode 100644 index 000000000..28384cbd0 --- /dev/null +++ b/packages/plugin-utils/src/numbering.ts @@ -0,0 +1,98 @@ +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { listSourceFiles } from './import-graph' +import type { MdxSelection } from './source-selection' + +export interface CollectNumberedFilesOptions { + /** Directories holding the project's own source files */ + roots: string[] + /** Packages whose source the build extracts too (the `include` option) */ + include?: string[] + /** Number MDX only when the caller extracts compiled MDX. Defaults to false. */ + includeMdx?: MdxSelection + readonly exclude?: readonly string[] + cwd?: string + /** + * Only files whose text contains one of these are numbered, so files the + * build never extracts do not take numbers (and lengthen class prefixes) + */ + needles?: string[] + /** The name the plugin extracts a file under, given its absolute path */ + toId?: (path: string) => string +} + +/** Whether the text of the file contains one of the needles */ +function usesAny(file: string, needles: string[]): boolean { + const text = readFileSync(file, 'utf-8') + return needles.some((needle) => text.includes(needle)) +} + +/** Where an included package lives, found the way the bundler finds it. */ +function findPackageDir(cwd: string, name: string): string | undefined { + let dir = resolve(cwd) + for (;;) { + const candidate = join(dir, 'node_modules', name) + if (existsSync(candidate)) return realpathSync(candidate) + const parent = resolve(dir, '..') + if (parent === dir) return undefined + dir = parent + } +} + +/** + * Every file the build can extract, as the names the plugin extracts them + * under: the project's source, then the source of each included package. The + * list is the same on every run for the same files, and the engine numbers it + * in path order, so file numbers (and the class prefixes they make) do not + * depend on which file a bundler worker reaches first. + */ +export function collectNumberedFiles({ + roots, + include = [], + includeMdx = false, + exclude, + cwd = process.cwd(), + needles, + toId = (path) => path.replaceAll('\\', '/'), +}: CollectNumberedFilesOptions): string[] { + const directories = [ + ...roots, + ...include + .map((name) => findPackageDir(cwd, name)) + .filter((dir): dir is string => dir !== undefined), + ] + return [ + ...new Set( + directories.flatMap((dir) => + listSourceFiles(dir, exclude, { includeMdx }) + .filter((file) => !needles || usesAny(file, needles)) + .map(toId), + ), + ), + ].sort() +} + +export interface FileNumbering { + seedFileMap(files: string[]): void +} + +/** + * Number `files` in path order. Files numbered before keep their numbers, so + * running it again as files appear (in development) numbers only the new ones, + * after the existing ones. + */ +export function seedFileNumbers( + engine: FileNumbering, + files: Parameters[0], +): void { + if (files.length > 0) engine.seedFileMap(files) +} + +/** What a file mentions when the build extracts it: the package, StyleX, and the packages Devup UI takes the place of */ +export function extractedNeedles( + libPackage: string, + importAliases: object, +): string[] { + return [libPackage, '@stylexjs/stylex', ...Object.keys(importAliases)] +} diff --git a/packages/plugin-utils/src/owned-file-resolution.ts b/packages/plugin-utils/src/owned-file-resolution.ts new file mode 100644 index 000000000..bc3773143 --- /dev/null +++ b/packages/plugin-utils/src/owned-file-resolution.ts @@ -0,0 +1,90 @@ +import { dirname, join, resolve } from 'node:path' + +import { createDirectoryExclusion } from './directory-exclusion' +import { ConfigLoadError } from './load-config' +import { + isResolutionFile as isFile, + readPackageManifest, +} from './packaged-config-inputs' +import { + realpathResolution, + type ResolutionInputCollector, +} from './resolution-inputs' +import { SOURCE_EXTENSIONS } from './shared' + +export function resolveFile( + candidateBase: string, + options: { + readonly extensions: readonly string[] + readonly excludedDirectory: (directory: string) => boolean + readonly inputs?: ResolutionInputCollector + } = { + extensions: SOURCE_EXTENSIONS, + excludedDirectory: createDirectoryExclusion(), + }, + stack: readonly string[] = [], +): string | false | undefined { + if ( + options.excludedDirectory(dirname(candidateBase)) || + options.excludedDirectory(candidateBase) + ) + return false + if (isFile(candidateBase, options.inputs)) return resolve(candidateBase) + for (const extension of options.extensions) { + const candidate = `${candidateBase}${extension}` + if (isFile(candidate, options.inputs)) return resolve(candidate) + } + const manifestFile = join(candidateBase, 'package.json') + if (isFile(manifestFile, options.inputs)) { + const canonical = realpathResolution(candidateBase, options.inputs) + if (stack.includes(canonical)) + throw new ConfigLoadError( + manifestFile, + new Error( + `Package directory entry cycle: ${[...stack, canonical].join(' -> ')}`, + ), + ) + const manifest = readPackageManifest(manifestFile, 'module', options.inputs) + for (const main of [manifest.module, manifest.main]) { + if (typeof main !== 'string' || !main || main === '.' || main === './') + continue + const found = resolveFile(join(candidateBase, main), options, [ + ...stack, + canonical, + ]) + if (found !== undefined) return found + } + } + for (const extension of options.extensions) { + const candidate = join(candidateBase, `index${extension}`) + if (isFile(candidate, options.inputs)) return resolve(candidate) + } + return undefined +} + +export function resolveJsonFile( + candidate: string, + options: { + readonly inputs?: ResolutionInputCollector + readonly exact?: boolean + readonly configLookup?: boolean + }, +): string | undefined { + const { inputs, exact = false, configLookup = true } = options + const known = /(?:\.d\.ts|\.[^./\\]+)$/.exec(candidate)?.[0] + if ( + known === '.json' || + (configLookup && (known === '.js' || known === '.ts' || known === '.d.ts')) + ) { + const json = + known === '.json' + ? candidate + : candidate.slice(0, -known.length) + '.json' + if (isFile(json, inputs)) return json + } + if (!exact && configLookup) { + const json = `${candidate}.json` + if (isFile(json, inputs)) return json + } + return undefined +} diff --git a/packages/plugin-utils/src/owned-module-resolution.ts b/packages/plugin-utils/src/owned-module-resolution.ts new file mode 100644 index 000000000..66b789468 --- /dev/null +++ b/packages/plugin-utils/src/owned-module-resolution.ts @@ -0,0 +1,148 @@ +import { basename, dirname, join, resolve } from 'node:path' + +import { ModuleAliasPackageError } from './module-alias' +import { exportsTarget } from './package-target' +import { + isResolutionFile as isFile, + readPackageManifest, +} from './packaged-config-inputs' +import { + realpathResolution, + type ResolutionInputCollector, +} from './resolution-inputs' +import { configScopeTarget } from './tsconfig-export-policy' +import { + configPackageCandidate, + directoryConfig, +} from './tsconfig-package-policy' + +interface ModulePolicy { + readonly purpose?: 'module' + readonly conditions: readonly string[] + readonly fileResolver: (path: string) => string | false | undefined + readonly excludedDirectory: (directory: string) => boolean + readonly aliased: boolean + readonly inputs?: ResolutionInputCollector +} +export interface ConfigPolicy { + readonly purpose: 'tsconfig-extends' + readonly inputs?: ResolutionInputCollector + readonly configLookup?: boolean + readonly stack?: readonly string[] +} + +export function resolvePackage( + specifier: string, + importer: string, + options: ModulePolicy | ConfigPolicy, +): string | false | undefined { + const parts = specifier.split('/') + const nameLength = specifier.startsWith('@') ? 2 : 1 + const name = parts.slice(0, nameLength).join('/') + if (options.purpose === 'tsconfig-extends') { + // Installed TS 6.0.3 lib/typescript.js:45327-45343,45963-46575: + // JSON config lookup, CJS require/types/node conditions, no resolution cache. + if (specifier === '.' || specifier === '..') { + const directory = resolve(dirname(importer), specifier) + return directoryConfig( + directory, + { + directory, + manifest: readPackageManifest( + join(directory, 'package.json'), + 'tsconfig-extends', + options.inputs, + ), + }, + options, + ) + } + const scoped = configScopeTarget(specifier, importer, options) + if (scoped !== undefined) return scoped ?? undefined + if (specifier.includes(':')) return undefined + } + let directory = dirname(importer) + while (true) { + const packageDir = join(directory, 'node_modules', name) + switch (options.purpose) { + case 'tsconfig-extends': + if (basename(directory) !== 'node_modules') { + const found = configPackageCandidate( + specifier.endsWith('/') ? `${packageDir}/` : packageDir, + parts.slice(nameLength).join('/'), + options, + ) + if (found) return realpathResolution(found, options.inputs) + } + break + case 'module': + case undefined: { + if (options.excludedDirectory(packageDir)) return false + if (isFile(join(packageDir, 'package.json'), options.inputs)) { + const target = join(packageDir, ...parts.slice(nameLength)) + if ( + options.excludedDirectory(dirname(target)) || + options.excludedDirectory(target) + ) + return false + const manifest = readPackageManifest( + join(packageDir, 'package.json'), + 'module', + options.inputs, + ) + const found = resolvePackageEntry( + packageDir, + manifest, + ['.', ...parts.slice(nameLength)].join('/'), + options, + ) + if ( + found === undefined && + options.aliased && + manifest.exports !== undefined + ) + throw new ModuleAliasPackageError(importer, specifier) + return found ? realpathResolution(found, options.inputs) : found + } + break + } + } + const parent = dirname(directory) + if (parent === directory) return undefined + directory = parent + } +} + +export function findPackage( + dir: string, + name: string, + excludedDirectory?: (directory: string) => boolean, + inputs?: ResolutionInputCollector, +): string | false | undefined { + const packageDir = join(dir, 'node_modules', name) + if (excludedDirectory?.(packageDir)) return false + if (isFile(join(packageDir, 'package.json'), inputs)) return packageDir + const parent = dirname(dir) + return parent === dir + ? undefined + : findPackage(parent, name, excludedDirectory, inputs) +} + +function resolvePackageEntry( + dir: string, + manifest: Record, + subpath: string, + options: ModulePolicy, +): string | false | undefined { + if (manifest.exports !== undefined) { + const target = exportsTarget(manifest.exports, subpath, options.conditions) + return typeof target !== 'string' + ? undefined + : options.fileResolver(join(dir, target)) + } + if (subpath !== '.') return options.fileResolver(join(dir, subpath)) + const main = [manifest.module, manifest.main].find( + (entry): entry is string => typeof entry === 'string', + ) + return options.fileResolver(join(dir, main ?? 'index')) +} diff --git a/packages/plugin-utils/src/package-target.ts b/packages/plugin-utils/src/package-target.ts new file mode 100644 index 000000000..c016f0c2f --- /dev/null +++ b/packages/plugin-utils/src/package-target.ts @@ -0,0 +1,128 @@ +import { isRecord } from './jsonc' +import { matchesTypeScriptVersion } from './typescript-version-range' + +interface TargetPolicy { + readonly purpose: 'module' | 'tsconfig-extends' + readonly conditions: readonly string[] + readonly load: (target: string) => string | undefined +} + +export function conditionTarget( + value: unknown, + policy: TargetPolicy, +): string | null | undefined { + if (typeof value === 'string') return policy.load(value) + if (value === null) return null + if (Array.isArray(value)) { + for (const entry of value) { + const target = conditionTarget(entry, policy) + if (target !== undefined) return target + } + return undefined + } + if (!isRecord(value)) return undefined + for (const [condition, branch] of Object.entries(value)) { + if ( + condition !== 'default' && + !policy.conditions.includes(condition) && + !( + policy.purpose === 'tsconfig-extends' && + condition.startsWith('types@') && + matchesTypeScriptVersion(condition.slice(6)) + ) + ) + continue + const target = conditionTarget(branch, policy) + if (target !== undefined) return target + } + return undefined +} + +export function exportsTarget( + exports: unknown, + subpath: string, + conditions: readonly string[], +): string | null | undefined { + const subpaths = isRecord(exports) + ? Object.keys(exports).filter((key) => key.startsWith('.')) + : [] + const policy: TargetPolicy = { + purpose: 'module', + conditions, + load: (target) => target, + } + if (subpaths.length === 0) + return subpath === '.' ? conditionTarget(exports, policy) : undefined + if (!isRecord(exports)) return undefined + if (subpath in exports) return conditionTarget(exports[subpath], policy) + for (const key of subpaths) { + const [prefix, suffix = ''] = key.split('*') + if ( + key.includes('*') && + subpath.startsWith(prefix) && + subpath.endsWith(suffix) && + subpath.length >= prefix.length + suffix.length + ) { + const matched = subpath.slice( + prefix.length, + subpath.length - suffix.length, + ) + return conditionTarget(exports[key], policy)?.replaceAll('*', matched) + } + } + return undefined +} + +export function matchConfigTarget( + table: unknown, + request: string, +): + | { + readonly target: unknown + readonly subpath: string + readonly pattern: boolean + } + | undefined { + if (!isRecord(table)) return undefined + if ( + !request.endsWith('/') && + !request.includes('*') && + Object.hasOwn(table, request) + ) + return { target: table[request], subpath: '', pattern: false } + const keys = Object.keys(table).filter( + (key) => + key.endsWith('/') || + (key.includes('*') && key.indexOf('*') === key.lastIndexOf('*')), + ) + keys.sort((a, b) => { + const ai = a.indexOf('*') + const bi = b.indexOf('*') + const length = + (bi === -1 ? b.length : bi + 1) - (ai === -1 ? a.length : ai + 1) + return length || (ai === -1 ? 1 : bi === -1 ? -1 : b.length - a.length) + }) + for (const key of keys) { + const star = key.indexOf('*') + if ( + star !== -1 && + request.startsWith(key.slice(0, star)) && + request.endsWith(key.slice(star + 1)) + ) + return { + target: table[key], + subpath: request.substring( + star, + request.length - (key.length - star - 1), + ), + pattern: true, + } + if (request.startsWith(key)) + return { + target: table[key], + subpath: request.slice(key.length), + pattern: false, + } + } + return undefined +} diff --git a/packages/plugin-utils/src/packaged-config-inputs.ts b/packages/plugin-utils/src/packaged-config-inputs.ts new file mode 100644 index 000000000..8d86b8fa9 --- /dev/null +++ b/packages/plugin-utils/src/packaged-config-inputs.ts @@ -0,0 +1,65 @@ +import { statSync } from 'node:fs' + +import { isRecord, parseJsonc } from './jsonc' +import { ConfigLoadError } from './load-config' +import { + readResolutionFile, + recordResolutionFailure, + type ResolutionInputCollector, +} from './resolution-inputs' + +export function isResolutionFile( + path: string, + inputs?: ResolutionInputCollector, +): boolean { + try { + const file = statSync(path).isFile() + if (file) inputs?.file(path) + return file + } catch (cause) { + recordResolutionFailure(path, cause, inputs) + if ( + cause instanceof Error && + 'code' in cause && + (cause.code === 'ENOENT' || cause.code === 'ENOTDIR') + ) + return false + throw cause + } +} + +export function readPackageManifest( + file: string, + purpose: 'module' | 'tsconfig-extends', + inputs?: ResolutionInputCollector, +): Record { + // TypeScript 6.0.3 lib/typescript.js:21164-21184 reads JSON/JSONC, parse misses => {}. + // The @typescript/typescript6 6.0.2 wrapper reexports this installed compiler. + switch (purpose) { + case 'module': + try { + const manifest: unknown = JSON.parse(readResolutionFile(file, inputs)) + if (!isRecord(manifest)) + throw new TypeError('Expected a package manifest object') + return manifest + } catch (cause) { + throw new ConfigLoadError(file, cause) + } + case 'tsconfig-extends': + if (!isResolutionFile(file, inputs)) return {} + try { + const source = readResolutionFile(file, inputs) + let manifest: unknown + try { + manifest = JSON.parse(source) + } catch (cause) { + if (!(cause instanceof SyntaxError)) throw cause + manifest = parseJsonc(source, true) + } + return isRecord(manifest) ? manifest : {} + } catch (cause) { + if (cause instanceof SyntaxError) return {} + throw cause + } + } +} diff --git a/packages/plugin-utils/src/prepared-diagnostics.ts b/packages/plugin-utils/src/prepared-diagnostics.ts new file mode 100644 index 000000000..1a0929b2a --- /dev/null +++ b/packages/plugin-utils/src/prepared-diagnostics.ts @@ -0,0 +1,26 @@ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function preparedDiagnostics( + filename: string, + source: string, + errors: readonly unknown[], +): string { + return errors + .flatMap((error) => { + if (!isRecord(error)) return `${filename}:1:1: ${String(error)}` + const labels = Array.isArray(error.labels) + ? error.labels.filter(isRecord) + : [] + if (labels.length) { + return labels.map((label) => { + const offset = typeof label.start === 'number' ? label.start : 0 + const lines = source.slice(0, offset).split('\n') + return `${filename}:${lines.length}:${(lines.at(-1)?.length ?? 0) + 1}: ${String(error.message)}${typeof label.message === 'string' ? `: ${label.message}` : ''}` + }) + } + return `${filename}:${typeof error.line === 'number' ? error.line : 1}:${typeof error.column === 'number' ? error.column : 1}: ${String(error.message)}` + }) + .join('\n') +} diff --git a/packages/plugin-utils/src/prepared-resolver.ts b/packages/plugin-utils/src/prepared-resolver.ts new file mode 100644 index 000000000..237a04db8 --- /dev/null +++ b/packages/plugin-utils/src/prepared-resolver.ts @@ -0,0 +1,157 @@ +import { extname } from 'node:path' + +import type { + ModuleResolution, + PreparedSource, + PrepareSource, + ResolvedModule, +} from './import-graph' +import { remapMdxError } from './mdx-errors' +import { readPreparedSource } from './prepared-source' +import { + createResolutionInputs, + readResolutionFile, + type ResolutionInputObserver, +} from './resolution-inputs' +import type { MdxSelection } from './source-selection' + +export interface ModuleResolver { + (specifier: string, importer: string): ModuleResolution | undefined + remapError(error: unknown): Error +} + +class ModulePreparationError extends Error { + constructor(importer: string, filename: string, cause: unknown) { + super( + `${importer}:1:1: module source preparation cannot use \`${filename}\` at build time: ${cause instanceof Error ? cause.message : String(cause)}; prepare Markdown before extraction using a synchronous cache lookup, or move the imported value into JS/TS`, + { cause }, + ) + this.name = 'ModulePreparationError' + } +} + +function isThenable(value: unknown): value is PromiseLike { + return ( + value !== null && + (typeof value === 'object' || typeof value === 'function') && + 'then' in value && + typeof value.then === 'function' + ) +} + +/** The mutable store belongs to one resolver, not to the WASM singleton. */ +export function createPreparedResolver(options: { + readonly prepareSource?: PrepareSource + readonly includeMdx?: MdxSelection + readonly onResolutionInputs?: ResolutionInputObserver +}) { + const generations = new Map< + string, + { + readonly filename: string + readonly code: string + readonly map?: unknown + readonly sourceType?: import('./prepared-source').SourceType + } + >() + const idsByFilename = new Map() + const remappedErrors = new WeakSet() + const markdownExtensions = new Set([ + '.md', + '.mdx', + ...(typeof options.includeMdx === 'object' + ? options.includeMdx.map((extension) => extension.toLowerCase()) + : []), + ]) + return { + read(filename: string, importer: string): PreparedSource { + const previousId = idsByFilename.get(filename) + if (previousId !== undefined) generations.delete(previousId) + idsByFilename.delete(filename) + try { + const prepared = options.prepareSource?.(filename) + if (isThenable(prepared)) + throw new TypeError( + 'Source is not ready: the synchronous WASM resolver received a thenable', + ) + if ( + prepared === undefined && + markdownExtensions.has(extname(filename).toLowerCase()) + ) + throw new TypeError('Markdown source has no prepared JavaScript') + const source = readPreparedSource(prepared) + if (source !== undefined) { + const inputs = createResolutionInputs() + inputs.file(filename) + options.onResolutionInputs?.(inputs.snapshot()) + } + return source + } catch (cause) { + throw new ModulePreparationError(importer, filename, cause) + } + }, + resolved( + id: string, + filename: string, + prepared: PreparedSource, + ): ResolvedModule { + if (prepared === undefined) { + generations.delete(id) + const inputs = createResolutionInputs() + try { + return { path: id, code: readResolutionFile(filename, inputs) } + } finally { + options.onResolutionInputs?.(inputs.snapshot()) + } + } + const source = + typeof prepared === 'string' ? { code: prepared } : prepared + generations.set(id, { filename, ...source }) + idsByFilename.set(filename, id) + return { + path: id, + code: source.code, + ...(source.sourceType === undefined + ? {} + : { sourceType: source.sourceType }), + } + }, + remapError(error: unknown): Error { + if (error instanceof Error && remappedErrors.has(error)) return error + const message = error instanceof Error ? error.message : String(error) + const replacements = [...generations] + .flatMap(([id, source]) => { + const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = new RegExp( + `(^|[\\s"'\x60])${escaped}:(\\d+):(\\d+)(?=:|\\s|$)`, + 'g', + ) + return [...message.matchAll(pattern)].map((match) => ({ + start: match.index + match[1].length, + end: match.index + match[0].length, + text: remapMdxError( + `${source.filename}:${match[2]}:${match[3]}`, + source.filename, + source.map, + ).message.replace('(in compiled MDX)', '(in compiled output)'), + })) + }) + .sort((a, b) => a.start - b.start) + const mapped = replacements.reduce( + (state, replacement) => ({ + text: + state.text + + message.slice(state.end, replacement.start) + + replacement.text, + end: replacement.end, + }), + { text: '', end: 0 }, + ) + const result = new Error(mapped.text + message.slice(mapped.end), { + cause: error, + }) + remappedErrors.add(result) + return result + }, + } +} diff --git a/packages/plugin-utils/src/prepared-source.ts b/packages/plugin-utils/src/prepared-source.ts new file mode 100644 index 000000000..d8f840dae --- /dev/null +++ b/packages/plugin-utils/src/prepared-source.ts @@ -0,0 +1,29 @@ +import type { PreparedSource } from './import-graph' + +export type SourceType = 'compiled-mdx' + +export class PreparedSourceTypeError extends TypeError { + constructor(cause: unknown) { + super( + `source type cannot use the prepared source at build time: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + this.name = 'PreparedSourceTypeError' + } +} + +export function readPreparedSource(prepared: PreparedSource): PreparedSource { + if (typeof prepared !== 'object' || prepared === null) return prepared + try { + const sourceType: unknown = prepared.sourceType + if (sourceType !== undefined && sourceType !== 'compiled-mdx') + throw new TypeError('expected `compiled-mdx`') + return { + code: prepared.code, + map: prepared.map, + ...(sourceType === undefined ? {} : { sourceType }), + } + } catch (cause) { + throw new PreparedSourceTypeError(cause) + } +} diff --git a/packages/plugin-utils/src/resolution-inputs.ts b/packages/plugin-utils/src/resolution-inputs.ts new file mode 100644 index 000000000..b7e283a68 --- /dev/null +++ b/packages/plugin-utils/src/resolution-inputs.ts @@ -0,0 +1,72 @@ +import { readFileSync, realpathSync } from 'node:fs' +import { resolve } from 'node:path' + +export interface ResolutionInputs { + readonly fileDependencies: readonly string[] + readonly missingDependencies: readonly string[] +} + +export type ResolutionInputObserver = (inputs: ResolutionInputs) => void + +export function createResolutionInputs(initial?: ResolutionInputs) { + const files = new Set(initial?.fileDependencies) + const missing = new Set(initial?.missingDependencies) + return { + file(path: string) { + files.add(resolve(path)) + }, + missing(path: string) { + missing.add(resolve(path)) + }, + probe(path: string, exists: boolean) { + if (!exists) missing.add(resolve(path)) + }, + snapshot(): ResolutionInputs { + return Object.freeze({ + fileDependencies: Object.freeze([...files].sort()), + missingDependencies: Object.freeze([...missing].sort()), + }) + }, + } +} + +export type ResolutionInputCollector = ReturnType + +export function recordResolutionFailure( + path: string, + error: unknown, + inputs?: ResolutionInputCollector, +): void { + const code: unknown = + error instanceof Error + ? Object.getOwnPropertyDescriptor(error, 'code')?.value + : undefined + if (code === 'ENOENT' || code === 'ENOTDIR') inputs?.missing(path) + else inputs?.file(path) +} + +export function realpathResolution( + path: string, + inputs?: ResolutionInputCollector, +): string { + try { + return realpathSync(path) + } catch (error) { + recordResolutionFailure(path, error, inputs) + throw error + } +} + +export function readResolutionFile( + path: string, + inputs?: ResolutionInputCollector, +): string { + try { + const source = readFileSync(path, 'utf-8') + inputs?.file(path) + return source + } catch (error) { + recordResolutionFailure(path, error, inputs) + throw error + } +} diff --git a/packages/plugin-utils/src/resolution-watch-path.ts b/packages/plugin-utils/src/resolution-watch-path.ts new file mode 100644 index 000000000..221bf9929 --- /dev/null +++ b/packages/plugin-utils/src/resolution-watch-path.ts @@ -0,0 +1,24 @@ +import { realpathSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' + +function canonicalWatchPath(path: string, parent: string): string { + try { + return join(realpathSync(parent), relative(parent, path)) + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') + ) + return path + const next = dirname(parent) + return next === parent ? path : canonicalWatchPath(path, next) + } +} + +export function resolutionWatchPath( + path: string, + preserveSymlinks = false, +): string { + return preserveSymlinks ? path : canonicalWatchPath(path, path) +} diff --git a/packages/plugin-utils/src/shared.ts b/packages/plugin-utils/src/shared.ts index 9d5710be4..bd6f620c5 100644 --- a/packages/plugin-utils/src/shared.ts +++ b/packages/plugin-utils/src/shared.ts @@ -1,5 +1,56 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' + import type { CustomShorthands, ImportAliases } from './types' +export const SOURCE_EXTENSIONS = [ + '.ts', + '.tsx', + '.mts', + '.cts', + '.js', + '.jsx', + '.mjs', + '.cjs', +] as const +export const SOURCE_FILE_RE = /\.(?:[mc]?[jt]s|[jt]sx)$/i +export const MDX_FILE_RE = /\.mdx$/i +export const POST_COMPILED_MDX_RE = /\.mdx\.(?:[mc]?[jt]s|[jt]sx)$/i +export const GRAPH_SOURCE_FILE_RE = /\.(?:[mc]?[jt]s|[jt]sx|mdx)$/i + +export function resolveProjectPaths( + root: string, + options: { + readonly devupFile?: string + readonly distDir?: string + readonly cssDir?: string + } = {}, +): { devupFile: string; distDir: string; cssDir: string } { + const distDir = resolve(root, options.distDir ?? 'df') + return { + devupFile: resolve(root, options.devupFile ?? 'devup.json'), + distDir, + cssDir: options.cssDir + ? resolve(root, options.cssDir) + : resolve(distDir, 'devup-ui'), + } +} + +export function resolveSourceDirs( + root: string, + configured?: string | string[], +): string[] { + const dirs = + configured === undefined + ? ['src', 'app'] + : typeof configured === 'string' + ? [configured] + : configured + return [...new Set(dirs.map((dir) => resolve(root, dir)))].filter( + (dir) => configured !== undefined || existsSync(dir), + ) +} + /** * Extract file number from a devup-ui CSS filename. * @@ -51,12 +102,19 @@ export function getFileNumByFilename(filename: string): number | null { * @returns A RegExp for use in bundler exclude/condition rules */ export function createNodeModulesExcludeRegex( - include: string[], + include: readonly string[], extraExcludes?: string, ): RegExp { - const base = `node_modules(?!.*(${['@devup-ui', '@devup-editor', ...include] + const separator = '[\\/\\\\]' + const names = include.map((name) => + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('/', separator), + ) + const allowed = ['@devup-ui', '@devup-editor'] + .map((scope) => `${scope}${separator}[^\\/\\\\]+`) + .concat(names) .join('|') - .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$))` + // Only the innermost node_modules owns the package (including pnpm stores). + const base = `(?:^|${separator})node_modules${separator}(?!.*${separator}node_modules${separator})(?!(?:${allowed})(?:${separator}|$))` return new RegExp(extraExcludes ? `(${base})|(${extraExcludes})` : base) } diff --git a/packages/plugin-utils/src/source-selection.ts b/packages/plugin-utils/src/source-selection.ts new file mode 100644 index 000000000..1466006a6 --- /dev/null +++ b/packages/plugin-utils/src/source-selection.ts @@ -0,0 +1,27 @@ +import { extname } from 'node:path' + +import { SOURCE_EXTENSIONS, SOURCE_FILE_RE } from './shared' + +export type MdxSelection = boolean | readonly string[] +export interface SourceSelectionOptions { + readonly includeMdx?: MdxSelection +} + +export function sourceExtensions(selection?: MdxSelection): readonly string[] { + return [ + ...SOURCE_EXTENSIONS, + ...(selection === true ? ['.mdx'] : selection || []).map((extension) => + extension.toLowerCase(), + ), + ] +} + +export function isSelectedSource( + filename: string, + selection?: MdxSelection, +): boolean { + return ( + SOURCE_FILE_RE.test(filename) || + sourceExtensions(selection).includes(extname(filename).toLowerCase()) + ) +} diff --git a/packages/plugin-utils/src/state-writer.ts b/packages/plugin-utils/src/state-writer.ts new file mode 100644 index 000000000..45a21ad49 --- /dev/null +++ b/packages/plugin-utils/src/state-writer.ts @@ -0,0 +1,70 @@ +import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +let temporaryFiles = 0 + +/** Write `content` so a reader sees the old file or the new one, never half. */ +export async function writeFileAtomically( + path: string, + content: string, +): Promise { + const temporary = `${path}.${process.pid}.${++temporaryFiles}.tmp` + await mkdir(dirname(path), { recursive: true }) + try { + await writeFile(temporary, content, 'utf-8') + await rename(temporary, path) + } catch (error) { + await rm(temporary, { force: true }) + throw error + } +} + +export type WriteOne = ( + path: string, + content: string, + encoding?: 'utf-8', +) => Promise + +export interface StateWriter { + /** + * Write `content` to `path` after every earlier write to it. A write that a + * newer one for the same path overtakes before it starts is dropped, so the + * file never ends up holding an older snapshot than one already handed in. + */ + write(path: string, content: string, encoding?: 'utf-8'): Promise +} + +/** + * Persist engine snapshots from concurrent transforms. Each call hands in the + * state as it was when the call was made, and the last call made decides what + * the file holds, whichever write the operating system finishes last. + */ +export function createStateWriter( + writeOne: WriteOne = (path, content) => writeFileAtomically(path, content), +): StateWriter { + const revisions = new Map() + const tails = new Map>() + return { + write(path, content, encoding) { + const revision = (revisions.get(path) ?? 0) + 1 + revisions.set(path, revision) + const start = async () => { + if (revisions.get(path) !== revision) return + await (encoding + ? writeOne(path, content, encoding) + : writeOne(path, content)) + } + const previous = tails.get(path) + // With nothing in flight the write starts at once + const run = previous + ? previous.catch(() => undefined).then(start) + : start() + tails.set(path, run) + const done = () => { + if (tails.get(path) === run) tails.delete(path) + } + run.then(done, done) + return run + }, + } +} diff --git a/packages/plugin-utils/src/tsconfig-export-policy.ts b/packages/plugin-utils/src/tsconfig-export-policy.ts new file mode 100644 index 000000000..a5fb04d03 --- /dev/null +++ b/packages/plugin-utils/src/tsconfig-export-policy.ts @@ -0,0 +1,130 @@ +import { dirname, isAbsolute, join, resolve } from 'node:path' + +import { isRecord } from './jsonc' +import { ConfigLoadError } from './load-config' +import { resolveJsonFile } from './owned-file-resolution' +import { type ConfigPolicy, resolvePackage } from './owned-module-resolution' +import { conditionTarget, matchConfigTarget } from './package-target' +import { + isResolutionFile as isFile, + readPackageManifest, +} from './packaged-config-inputs' + +interface PackageScope extends ConfigPolicy { + readonly directory: string + readonly imports?: boolean +} + +export function configExport( + table: unknown, + request: string, + scope: PackageScope, +): string | null | undefined { + let match: ReturnType + if (scope.imports) match = matchConfigTarget(table, request) + else if (request === '.') { + const target = + typeof table === 'string' || + Array.isArray(table) || + (isRecord(table) && + !Object.keys(table).some((key) => key.startsWith('.'))) + ? table + : isRecord(table) + ? table['.'] + : undefined + if (target) match = { target, subpath: '', pattern: false } + } else if ( + isRecord(table) && + Object.keys(table).every((key) => key.startsWith('.')) + ) + match = matchConfigTarget(table, request) + if (!match) return undefined + const { target, subpath, pattern } = match + return conditionTarget(target, { + purpose: 'tsconfig-extends', + conditions: ['require', 'types', 'node'], + load(value) { + if (!pattern && subpath && !value.endsWith('/')) return undefined + if (!value.startsWith('./')) { + if (scope.imports && !value.startsWith('../') && !isAbsolute(value)) { + const request = pattern + ? value.replaceAll('*', subpath) + : value + subpath + const stack = scope.stack ?? [] + if (stack.includes(request)) + throw new ConfigLoadError( + join(scope.directory, 'package.json'), + new Error( + `Package imports cycle: ${[...stack, request].join(' -> ')}`, + ), + ) + const found = resolvePackage( + request, + join(scope.directory, 'tsconfig.json'), + { ...scope, configLookup: false, stack: [...stack, request] }, + ) + return typeof found === 'string' ? found : undefined + } + return undefined + } + const prohibited = (parts: readonly string[]) => + parts.some( + (part) => part === '.' || part === '..' || part === 'node_modules', + ) + const targetPath = value.replaceAll('\\', '/') + const targetSubpath = subpath.replaceAll('\\', '/') + if ( + prohibited(targetPath.split('/').slice(1)) || + prohibited(targetSubpath.split('/')) + ) + return undefined + const selected = resolve( + scope.directory, + pattern + ? targetPath.replaceAll('*', targetSubpath) + : targetPath + targetSubpath, + ) + return resolveJsonFile(selected, { ...scope, exact: true }) + }, + }) +} + +export function configScopeTarget( + request: string, + importer: string, + options: ConfigPolicy, +): string | null | undefined { + if (request === '#') return null + const directory = dirname(importer) + const manifestPath = join(directory, 'package.json') + if (isFile(manifestPath, options.inputs)) { + const manifest = readPackageManifest( + manifestPath, + 'tsconfig-extends', + options.inputs, + ) + if (request.startsWith('#')) + return ( + configExport(manifest.imports, request, { + ...options, + directory, + imports: true, + }) ?? null + ) + const name = manifest.name + if ( + manifest.exports && + typeof name === 'string' && + (request === name || request.startsWith(`${name}/`)) + ) + return configExport( + manifest.exports, + request === name ? '.' : `.${request.slice(name.length)}`, + { ...options, directory }, + ) + return undefined + } + const parent = dirname(directory) + if (parent === directory) return request.startsWith('#') ? null : undefined + return configScopeTarget(request, join(parent, 'tsconfig.json'), options) +} diff --git a/packages/plugin-utils/src/tsconfig-package-policy.ts b/packages/plugin-utils/src/tsconfig-package-policy.ts new file mode 100644 index 000000000..6f116bb7d --- /dev/null +++ b/packages/plugin-utils/src/tsconfig-package-policy.ts @@ -0,0 +1,172 @@ +import { isAbsolute, join, relative, resolve, sep } from 'node:path' + +import { isRecord } from './jsonc' +import { resolveJsonFile } from './owned-file-resolution' +import type { ConfigPolicy } from './owned-module-resolution' +import { + isResolutionFile as isFile, + readPackageManifest, +} from './packaged-config-inputs' +import { configExport } from './tsconfig-export-policy' +import { matchesTypeScriptVersion } from './typescript-version-range' + +interface PackageConfigManifest { + readonly directory: string + readonly manifest: Record +} + +// Installed TS 6.0.3:46566 uses tryGetExtensionFromPath2 (22743,22822-22823). +const recognizedExtension = /\.(?:json|js|jsx|mjs|cjs|ts|tsx|mts|cts)$/ + +function versionPaths( + manifest: Record, +): Record | undefined { + if (!isRecord(manifest.typesVersions)) return undefined + for (const [range, paths] of Object.entries(manifest.typesVersions)) + if (matchesTypeScriptVersion(range)) + return isRecord(paths) ? paths : undefined + return undefined +} + +function mappedPath( + request: string, + paths: Record, + load: (target: string) => string | undefined, +): string | null | undefined { + let key = Object.hasOwn(paths, request) ? request : undefined + let matched = '' + if (key === undefined) { + let length = -1 + for (const pattern of Object.keys(paths)) { + const star = pattern.indexOf('*') + if (star === -1 || star !== pattern.lastIndexOf('*')) continue + const prefix = pattern.slice(0, star) + const suffix = pattern.slice(star + 1) + if ( + star > length && + request.startsWith(prefix) && + request.endsWith(suffix) && + request.length >= prefix.length + suffix.length + ) { + key = pattern + length = star + matched = request.slice(star, request.length - suffix.length) + } + } + } + if (key === undefined) return undefined + const targets = paths[key] + if (Array.isArray(targets)) + for (const target of targets) { + if (typeof target !== 'string') continue + const found = load(matched ? target.replace('*', matched) : target) + if (found) return found + } + return null +} + +export function directoryConfig( + directory: string, + packageConfig: PackageConfigManifest, + options: ConfigPolicy, +): string | undefined { + const { manifest } = packageConfig + const field = + options.configLookup !== false && + relative(packageConfig.directory, directory) === '' + ? manifest.tsconfig + : undefined + const packageFile = + typeof field === 'string' && field ? resolve(directory, field) : undefined + const load = (candidate: string) => + resolveJsonFile(candidate, options) ?? + resolveJsonFile( + join(candidate, options.configLookup === false ? 'index' : 'tsconfig'), + options, + ) + const paths = versionPaths(manifest) + const fieldPath = packageFile ? relative(directory, packageFile) : '' + if ( + paths && + fieldPath !== '..' && + !fieldPath.startsWith(`..${sep}`) && + !isAbsolute(fieldPath) + ) { + const found = mappedPath( + relative( + directory, + packageFile ?? + join( + directory, + options.configLookup === false ? 'index' : 'tsconfig', + ), + ).replaceAll('\\', '/'), + paths, + (target) => { + const candidate = resolve(directory, target) + return recognizedExtension.test(target) && + isFile(candidate, options.inputs) + ? candidate + : load(candidate) + }, + ) + if (found !== undefined) return found ?? undefined + } + return ( + (packageFile ? load(packageFile) : undefined) ?? + resolveJsonFile( + join(directory, options.configLookup === false ? 'index' : 'tsconfig'), + options, + ) + ) +} + +export function configPackageCandidate( + directory: string, + rest: string, + options: ConfigPolicy, +): string | undefined { + const candidate = join(directory, rest) + const nestedFile = join(candidate, 'package.json') + const nestedExists = isFile(nestedFile, options.inputs) + const nested = nestedExists + ? readPackageManifest(nestedFile, 'tsconfig-extends', options.inputs) + : {} + const manifest = rest + ? readPackageManifest( + join(directory, 'package.json'), + 'tsconfig-extends', + options.inputs, + ) + : nested + if (rest && nestedExists && !Object.hasOwn(manifest, 'exports')) + return ( + resolveJsonFile(candidate, options) ?? + directoryConfig( + candidate, + { directory: candidate, manifest: nested }, + options, + ) + ) + if (manifest.exports) + return ( + configExport(manifest.exports, rest ? `./${rest}` : '.', { + ...options, + directory, + }) ?? undefined + ) + const load = (target: string) => + resolveJsonFile(target, options) ?? + directoryConfig(target, { directory, manifest }, options) + const paths = rest ? versionPaths(manifest) : undefined + if (paths) { + const mapped = mappedPath(rest, paths, (target) => { + const path = resolve(directory, target) + return recognizedExtension.test(target) && isFile(path, options.inputs) + ? path + : load(path) + }) + if (mapped !== undefined) return mapped ?? undefined + } + return load(candidate) +} diff --git a/packages/plugin-utils/src/tsconfig.ts b/packages/plugin-utils/src/tsconfig.ts new file mode 100644 index 000000000..d7354aa59 --- /dev/null +++ b/packages/plugin-utils/src/tsconfig.ts @@ -0,0 +1,159 @@ +import { existsSync } from 'node:fs' +import { dirname, isAbsolute, resolve } from 'node:path' + +import { isRecord, parseJsonc } from './jsonc' +import { ConfigLoadError } from './load-config' +import { resolvePackage } from './owned-module-resolution' +import { isResolutionFile } from './packaged-config-inputs' +import { + readResolutionFile, + realpathResolution, + recordResolutionFailure, + type ResolutionInputCollector, +} from './resolution-inputs' + +export interface PathAlias { + readonly prefix: string + readonly suffix: string + readonly exact: boolean + readonly targets: string[] +} + +interface AliasConfig { + readonly baseUrl?: string + readonly paths?: Record + readonly pathsOrigin?: string +} + +function resolveParent( + specifier: string, + file: string, + inputs?: ResolutionInputCollector, +): string { + const request = specifier.replaceAll('\\', '/') + if ( + request.startsWith('./') || + request.startsWith('../') || + isAbsolute(request) + ) { + const candidate = resolve(dirname(file), request) + return isResolutionFile(candidate, inputs) || candidate.endsWith('.json') + ? candidate + : `${candidate}.json` + } + const selected = resolvePackage(request, file, { + purpose: 'tsconfig-extends', + inputs, + }) + if (typeof selected === 'string') return selected + throw new ConfigLoadError( + file, + new Error(`Cannot resolve tsconfig extends ${JSON.stringify(specifier)}`), + ) +} + +function loadAliases( + file: string, + stack: readonly string[], + inputs?: ResolutionInputCollector, +): AliasConfig { + try { + const canonical = realpathResolution(file, inputs) + if (stack.includes(canonical) || stack.length >= 128) + throw new Error( + `Tsconfig inheritance cycle or depth limit: ${[...stack, canonical].join(' -> ')}`, + ) + const config = parseJsonc(readResolutionFile(file, inputs)) + if (!isRecord(config)) throw new TypeError('Expected a tsconfig object') + const parents = + config.extends === undefined + ? [] + : typeof config.extends === 'string' + ? [config.extends] + : config.extends + if ( + !Array.isArray(parents) || + !parents.every((parent) => typeof parent === 'string') + ) + throw new TypeError('Expected extends to be a path or array of paths') + let inherited: AliasConfig = {} + for (const parent of parents) + inherited = { + ...inherited, + ...loadAliases( + resolveParent(parent, file, inputs), + [...stack, canonical], + inputs, + ), + } + if (config.compilerOptions === undefined) return inherited + if (!isRecord(config.compilerOptions)) + throw new TypeError('Expected compilerOptions to be an object') + const options = config.compilerOptions + const current: { + baseUrl?: string + paths?: Record + pathsOrigin?: string + } = {} + if (options.baseUrl !== undefined) { + if (typeof options.baseUrl !== 'string') + throw new TypeError('Expected baseUrl to be a path') + current.baseUrl = resolve(dirname(file), options.baseUrl) + } + if (options.paths !== undefined) { + if (!isRecord(options.paths)) + throw new TypeError('Expected paths to be an object') + current.paths = {} + current.pathsOrigin = dirname(file) + for (const [key, targets] of Object.entries(options.paths)) { + if ( + !Array.isArray(targets) || + !targets.every( + (target): target is string => typeof target === 'string', + ) + ) + throw new TypeError(`Expected string targets for paths.${key}`) + current.paths[key] = targets + } + } + return { ...inherited, ...current } + } catch (cause) { + if (cause instanceof ConfigLoadError) throw cause + recordResolutionFailure(file, cause, inputs) + throw new ConfigLoadError(file, cause) + } +} + +export function readPathAliases( + tsconfigPath?: string, + inputs?: ResolutionInputCollector, +): { + aliases: PathAlias[] + baseDir: string + baseUrl?: string +} { + const exists = tsconfigPath ? existsSync(tsconfigPath) : false + if (tsconfigPath) inputs?.probe(tsconfigPath, exists) + if (!tsconfigPath || !exists) return { aliases: [], baseDir: process.cwd() } + const file = resolve(tsconfigPath) + const config = loadAliases(file, [], inputs) + const baseDir = config.baseUrl ?? config.pathsOrigin ?? dirname(file) + const aliases = Object.entries(config.paths ?? {}).map(([alias, targets]) => { + const star = alias.indexOf('*') + return { + prefix: star === -1 ? alias : alias.slice(0, star), + suffix: star === -1 ? '' : alias.slice(star + 1), + exact: star === -1, + targets: targets.map((target) => resolve(baseDir, target)), + } + }) + aliases.sort( + (a, b) => + Number(b.exact) - Number(a.exact) || b.prefix.length - a.prefix.length, + ) + return { + aliases, + baseDir: config.baseUrl ?? dirname(file), + baseUrl: config.baseUrl, + } +} diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index 1a1365c2d..5dc4cd65f 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -63,6 +63,21 @@ export interface DevupTheme { /** Custom style prop aliases mapped to one or more CSS property names. */ export type CustomShorthands = Record +/** Ordered alias candidates; a reached false explicitly ignores the module. */ +export type ModuleAliases = Readonly< + Record +> + +/** Native ordered entries preserve duplicate names and literal dollar suffixes. */ +export interface ModuleAliasDescriptor { + readonly name: string + readonly alias: string | false | readonly (string | false)[] + readonly onlyModule?: boolean +} + +export type ModuleAliasOptions = + ModuleAliases | readonly ModuleAliasDescriptor[] | false + /** * Devup configuration file structure (devup.json) */ diff --git a/packages/plugin-utils/src/typescript-version-range.ts b/packages/plugin-utils/src/typescript-version-range.ts new file mode 100644 index 000000000..9d8272c58 --- /dev/null +++ b/packages/plugin-utils/src/typescript-version-range.ts @@ -0,0 +1,190 @@ +// Adapted from Microsoft TypeScript 6.0.3 (Apache-2.0), lib/typescript.js:4805-5073. +// Resolution pins the actual installed JS authority, not native TypeScript 7.0.2. +const compiler = [6, 0, 3] as const +type Version = readonly [number, number, number, readonly string[]] +type PartialVersion = { + readonly version: Version + readonly major: string + readonly minor: string + readonly patch: string +} +const wildcard = (part: string) => part === '*' || part === 'x' || part === 'X' +export class TypeScriptVersionRangeError extends Error { + constructor( + readonly range: string, + readonly component: 'prerelease' | 'build', + ) { + super( + `Invalid TypeScript version range ${JSON.stringify(range)}: ${component}`, + ) + this.name = 'TypeScriptVersionRangeError' + } +} + +function partial(text: string): PartialVersion | undefined { + const match = + /^([x*0]|[1-9]\d*)(?:\.([x*0]|[1-9]\d*)(?:\.([x*0]|[1-9]\d*)(?:-([a-z0-9-.]+))?(?:\+([a-z0-9-.]+))?)?)?$/i.exec( + text, + ) + if (!match) return undefined + const [, major, minor = '*', patch = '*', pre = '', build = ''] = match + if ( + pre && + !/^(?:0|[1-9]\d*|[a-z-][a-z0-9-]*)(?:\.(?:0|[1-9]\d*|[a-z-][a-z0-9-]*))*$/i.test( + pre, + ) + ) + throw new TypeScriptVersionRangeError(text, 'prerelease') + if (build && !/^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/i.test(build)) + throw new TypeScriptVersionRangeError(text, 'build') + return { + major, + minor, + patch, + version: [ + wildcard(major) ? 0 : Number(major), + wildcard(major) || wildcard(minor) ? 0 : Number(minor), + wildcard(major) || wildcard(minor) || wildcard(patch) ? 0 : Number(patch), + pre ? pre.split('.') : [], + ], + } +} + +function increment( + value: Version, + field: 0 | 1 | 2, + prerelease = false, +): Version { + return [ + value[0] + Number(field === 0), + field === 0 ? 0 : value[1] + Number(field === 1), + field < 2 ? 0 : value[2] + 1, + prerelease ? ['0'] : [], + ] +} + +function compare(value: Version): number { + return ( + compiler[0] - value[0] || + compiler[1] - value[1] || + compiler[2] - value[2] || + Number(value[3].length > 0) + ) +} +type Operator = '<' | '<=' | '>' | '>=' | '=' +type Comparator = readonly [Operator, Version] +function test([operator, value]: Comparator): boolean { + const order = compare(value) + switch (operator) { + case '<': + return order < 0 + case '<=': + return order <= 0 + case '>': + return order > 0 + case '>=': + return order >= 0 + case '=': + return order === 0 + } +} + +function comparators(text: string): readonly Comparator[] | undefined { + const hyphen = /^\s*([a-z0-9-+.*]+)\s+-\s+([a-z0-9-+.*]+)\s*$/i.exec(text) + const result: Comparator[] = [] + if (hyphen) { + const left = partial(hyphen[1]) + const right = partial(hyphen[2]) + if (!left || !right) return undefined + if (!wildcard(left.major)) result.push(['>=', left.version]) + if (!wildcard(right.major)) + result.push( + wildcard(right.minor) + ? ['<', increment(right.version, 0)] + : wildcard(right.patch) + ? ['<', increment(right.version, 1)] + : ['<=', right.version], + ) + return result + } + for (const simple of text.split(/\s+/)) { + const match = /^([~^<>=]|<=|>=)?\s*([a-z0-9-+.*]+)$/i.exec(simple.trim()) + if (!match) return undefined + const value = partial(match[2]) + if (!value) return undefined + const { major, minor, patch, version } = value + const operator = match[1] + if (wildcard(major)) { + if (operator === '<' || operator === '>') + result.push(['<', [0, 0, 0, ['0']]]) + continue + } + switch (operator) { + case '~': + result.push( + ['>=', version], + ['<', increment(version, wildcard(minor) ? 0 : 1)], + ) + break + case '^': + result.push( + ['>=', version], + [ + '<', + increment( + version, + version[0] > 0 || wildcard(minor) + ? 0 + : version[1] > 0 || wildcard(patch) + ? 1 + : 2, + ), + ], + ) + break + case '<': + case '>=': + result.push([ + operator, + wildcard(minor) || wildcard(patch) + ? [version[0], version[1], version[2], ['0']] + : version, + ]) + break + case '<=': + case '>': + result.push( + wildcard(minor) || wildcard(patch) + ? [ + operator === '<=' ? '<' : '>=', + increment(version, wildcard(minor) ? 0 : 1, true), + ] + : [operator, version], + ) + break + case '=': + case undefined: + if (wildcard(minor) || wildcard(patch)) + result.push( + ['>=', [version[0], version[1], version[2], ['0']]], + ['<', increment(version, wildcard(minor) ? 0 : 1, true)], + ) + else result.push(['=', version]) + break + } + } + return result +} + +export function matchesTypeScriptVersion(range: string): boolean { + const alternatives = range + .trim() + .split(/\|\|/) + .filter(Boolean) + .map((part) => comparators(part.trim())) + return ( + alternatives.every((part) => part !== undefined) && + (alternatives.length === 0 || + alternatives.some((part) => part?.every(test))) + ) +} diff --git a/packages/rsbuild-plugin/README.md b/packages/rsbuild-plugin/README.md index 404fc2997..9c1f7d4a4 100644 --- a/packages/rsbuild-plugin/README.md +++ b/packages/rsbuild-plugin/README.md @@ -177,6 +177,84 @@ const box = const box = ``` +## Publishing And Consuming Libraries + +With the default `extractCss: true`, a library build extracts its styles. Publish +the transformed JavaScript together with its emitted CSS and expose a stylesheet +that consumers import. The published code no longer calls compile-time Devup UI +placeholders. + +Use `extractCss: false` only for an intentionally uncompiled library. This is the +contract demonstrated by `apps/vite-lib`: **every** consumer needs a Devup UI build +plugin configured with the library's exact package name: + +```ts +export default defineConfig({ + plugins: [DevupUI({ include: ['@acme/components'] })], +}) +``` + +The consuming build transforms the library and emits its CSS. Without that +configuration, the uncompiled placeholders throw `Cannot run on the runtime`; +the library must not be treated as standalone browser JavaScript. Both publishing +modes remain free of a styling runtime. + +## Project Roots And Extraction + +Relative `devupFile`, `distDir`, and `cssDir` options resolve against Rsbuild's +`root`, not the shell's working directory. The graph includes existing `src` +and `app` directories, normalized Rspack entries, and `sourceDirs` (a directory +or array of directories). Included packages follow the same effective Rspack +ES-module export conditions as extraction. + +Extraction supports `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, and +`.cjs`. Configure an MDX compiler for `.mdx`; Devup UI runs as a post-loader on +its compiled JavaScript, never on raw Markdown. Rsbuild's transform API does not +expose the incoming source map, so extraction errors label compiled MDX locations +with `(in compiled MDX)`. + +Malformed or cyclic configuration and graph/prewarm failures stop the build with +the root and underlying cause. Empty themes overwrite generated declarations. +Optional file-number seeding failures warn once that IDs may depend on module +arrival order. `atomHoist` composes its shared CSS cache group with existing +groups and creates `splitChunks` when absent; `splitChunks: false` is an error +when atom hoisting is requested. + +## MDX Selection And Compiled-Source Guards + +The graph and build-time resolver receive the same native Rspack `resolve.alias` +map-or-false option. Rspack accepts ordered mixed string/false candidates: +the first resolving file wins, and a reached false ignores the module. Empty +arrays fall through to the raw request; a real rewriting all-miss remains +terminal. Rspack's public alias type does not accept Webpack-style descriptor +arrays. Unsupported wildcard names fail with the importer/key instead of being +silently dropped. + +`mdxExtensions` defaults to `['.mdx']`; `.md` and custom literal extensions such +as `.mdown` are opt-in. Configure the project MDX loader for the same list. +Devup runs on its compiled JavaScript/JSX in a post-loader, retaining the actual +filename. Extraction, graph discovery and numbering share the selected list. + +With extraction enabled, build, dev-server compilations and watch rebuilds use +Rspack's public used-dependency export IDs and actual module targets. A +definitely named compile-time export used by a module outside the supported +JS/TS or selected MDX extensions produces a located module/import error and an +`mdxExtensions` remedy. Static namespace/require member uses remain guarded when +the dependency names the export. Runtime helpers and unused imports are allowed. +For an intended runtime compatibility alias, set `importAliases[package] = false`. + +Whole namespace/require-result objects and dynamic imports are opaque and are +allowed. Rspack additionally exposes some namespace/require destructuring uses +with `ids = []`, and the installed Rspack may omit export IDs for CommonJS +require members, bound results and destructuring altogether. Some renamed +forwarding cannot be traced through its public export data. Those forms are +also opaque and allowed, even though another +bundler with definite export IDs can reject the same source. No provided-export +query is made at `finishModules`. Hidden compile-time exports can still throw +`Cannot run on the runtime` when rendered; add the extension to `mdxExtensions` +and let Devup compile it. The guard does not expand ordinary JS/TS `include` +selection or change `extractCss: false` publishing. + ## Custom Shorthands ```ts diff --git a/packages/rsbuild-plugin/src/__tests__/alias-native.test.ts b/packages/rsbuild-plugin/src/__tests__/alias-native.test.ts new file mode 100644 index 000000000..8ff7c952a --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/alias-native.test.ts @@ -0,0 +1,66 @@ +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { createRsbuild } from '@rsbuild/core' +import { expect, it } from 'bun:test' + +import { aliasFixture } from '../../../plugin-utils/src/__tests__/alias-array-fixture' +import { mdxLanding } from '../../../webpack-plugin/src/__tests__/mdx-build-fixture' +import { DevupUI } from '../plugin' + +it.each(['false', 'later-false', 'first-success', 'empty'])( + 'passes the actual Rspack alias map when %s', + async (kind) => { + const fixture = aliasFixture() + const { root, file } = fixture + const entry = file( + 'src/main.js', + 'import {css} from "@devup-ui/react"; import {value} from "provider"; export const cls=css({color: value === undefined ? "red" : value})', + ) + const blue = file('outside/blue.js', 'export const value="blue"') + file('node_modules/provider/package.json', '{"main":"index.js"}') + file('node_modules/provider/index.js', 'export const value="blue"') + const target: string | false | (string | false)[] = + kind === 'false' + ? false + : kind === 'later-false' + ? [join(root, 'missing'), false] + : kind === 'empty' + ? [] + : [blue, false] + wasm.resetBuildState() + try { + const builder = await createRsbuild({ + cwd: root, + rsbuildConfig: { + mode: 'development', + plugins: [DevupUI({ singleCss: true, include: ['provider'] })], + source: { entry: { main: entry } }, + resolve: { + alias: { + provider: target, + '@devup-ui/react': mdxLanding.resolve('@devup-ui/react'), + }, + }, + output: { + target: 'node', + distPath: { root: join(root, 'out') }, + minify: false, + }, + }, + }) + const result = await builder.build() + try { + expect(wasm.getCss(null, false)).toContain( + kind === 'false' || kind === 'later-false' + ? 'color:red' + : 'color:blue', + ) + } finally { + await result.close() + } + } finally { + fixture.dispose() + } + }, +) diff --git a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts index b57d9ef2a..9f89b1ce0 100644 --- a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts @@ -29,6 +29,7 @@ const source = `import { Box } from '@devup-ui/react' const App = () => ` let codeExtractSpy: ReturnType +const closeCallbacks: (() => void)[] = [] beforeAll(() => { spyOn(fs, 'existsSync').mockReturnValue(true) @@ -52,6 +53,7 @@ beforeAll(() => { }) afterAll(() => { + for (const close of closeCallbacks) close() mock.restore() }) @@ -67,8 +69,12 @@ async function extractedCssDirIn(checkout: string) { cssDir: join(checkout, 'df', 'devup-ui'), }) await plugin.setup({ + context: { rootPath: checkout }, + onCloseBuild: mock((close) => closeCallbacks.push(close)), transform, modifyRsbuildConfig: mock(), + modifyRspackConfig: mock(), + onBeforeBuild: mock(), } as unknown as RsbuildSetupContext) codeExtractSpy.mockClear() diff --git a/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts b/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts new file mode 100644 index 000000000..11f82c2b4 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts @@ -0,0 +1,292 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import * as utils from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { afterEach, expect, it, mock, spyOn } from 'bun:test' + +import { DevupUI } from '../plugin' + +const base = process.env.DEVUP_PLUGIN_TEST_TMP ?? realpathSync(tmpdir()) +const roots: string[] = [] +const closes: (() => void)[] = [] +afterEach(() => { + for (const close of closes.splice(0)) close() + mock.restore() + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }) + wasm.resetBuildState() + wasm.setDebug(true) +}) + +function project() { + mkdirSync(base, { recursive: true }) + const root = realpathSync(mkdtempSync(join(base, 'rsbuild-'))) + roots.push(root) + return root +} +function file(root: string, name: string, contents: string) { + const path = join(root, name) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, contents) + return path +} +const box = (color: string) => + `import { Box } from '@devup-ui/react'; export default ` + +async function setup( + root: string, + options: Parameters[0] = {}, +) { + const transform = mock() + const modify = mock() + const configure = mock() + const before = mock() + await DevupUI(options).setup({ + context: { rootPath: root }, + transform, + modifyRspackConfig: modify, + modifyRsbuildConfig: configure, + onBeforeBuild: before, + onCloseBuild: mock((close) => closes.push(close)), + }) + const config: { plugins?: { apply(compiler: unknown): void }[] } = {} + const apply = (normalized: object) => { + modify.mock.calls[0][0](config, { + environment: { name: 'web', config: { output: { target: 'web' } } }, + }) + config.plugins?.at(-1)?.apply({ + options: normalized, + hooks: { run: { tap: mock() }, thisCompilation: { tap: mock() } }, + }) + } + return { transform, configure, before, apply } +} + +it('writes only beneath the Rsbuild root and clears declarations when the theme is removed', async () => { + const root = project() + file( + root, + 'devup.json', + JSON.stringify({ theme: { colors: { default: { primary: 'red' } } } }), + ) + await setup(root, { extractCss: false, distDir: 'generated' }) + expect(readFileSync(join(root, 'generated/theme.d.ts'), 'utf-8')).toContain( + 'primary', + ) + file(root, 'devup.json', '{}') + await setup(root, { extractCss: false, distDir: 'generated' }) + expect( + readFileSync(join(root, 'generated/theme.d.ts'), 'utf-8'), + ).not.toContain('primary') + expect( + readFileSync(join(root, 'generated/devup-ui/devup-ui.css'), 'utf-8'), + ).not.toContain('--primary') +}) + +it.each(['{', JSON.stringify({ extends: ['./base.json'] })])( + 'fails initial setup for malformed or cyclic real config: %s', + async (contents) => { + const root = project() + file(root, 'devup.json', contents) + file(root, 'base.json', JSON.stringify({ extends: ['./devup.json'] })) + await expect(setup(root)).rejects.toThrow(join(root, 'devup.json')) + }, +) + +it('shares one conditional graph across canonical mapping, reach and reachable prewarm', async () => { + const root = project() + file( + root, + 'src/first.tsx', + `import Page from '../app/second'; import Widget from '../custom/widget'; import value from 'fixture'; export default [Page, Widget, value]`, + ) + file(root, 'app/second.tsx', box('tan')) + file(root, 'custom/widget.tsx', box('blue')) + file(root, 'custom/dead.tsx', 'this is invalid and unreachable') + file( + root, + 'node_modules/fixture/package.json', + JSON.stringify({ + name: 'fixture', + exports: { + browser: './browser.mjs', + node: './node.cjs', + default: './fallback.js', + }, + }), + ) + const browser = file( + root, + 'node_modules/fixture/browser.mjs', + `import { css } from '@devup-ui/react'; export default css({ color: 'green' })`, + ) + file(root, 'node_modules/fixture/node.cjs', `throw new Error('wrong branch')`) + const graph = spyOn(utils, 'buildStaticImportGraph') + const canonical = spyOn(utils, 'buildCanonicalMap') + const reach = spyOn(utils, 'computeFileReach') + const reachable = spyOn(utils, 'computeReachableFiles') + const extract = spyOn(wasm, 'codeExtract') + const api = await setup(root, { + atomHoist: 2, + sourceDirs: 'custom', + include: ['fixture'], + }) + api.apply({ + entry: { + first: { import: ['./src/first.tsx'] }, + second: { import: ['./app/second.tsx'] }, + }, + resolve: { + conditionNames: ['browser'], + byDependency: { esm: { conditionNames: ['import', '...'] } }, + }, + }) + const built = graph.mock.results[0]?.value + expect(graph).toHaveBeenCalledTimes(1) + expect(canonical.mock.calls[0]?.[0].graph).toBe(built) + expect(reach.mock.calls[0]?.[0].graph).toBe(built) + expect(reachable.mock.calls[0]?.[0].graph).toBe(built) + expect(extract.mock.calls.map((call) => call[0])).toContain( + browser.replaceAll('\\', '/'), + ) + expect(extract.mock.calls.map((call) => call[0])).not.toContain( + join(root, 'custom/dead.tsx').replaceAll('\\', '/'), + ) + expect(wasm.getCss(null, false)).toContain('green') +}) + +it.each(['mts', 'cts', 'cjs', 'mjs'])( + 'transforms real modern .%s files', + async (extension) => { + const root = project() + const path = file( + root, + `src/style.${extension}`, + `import { css } from '@devup-ui/react'; export const style = css({ color: 'red' })`, + ) + const api = await setup(root) + api.apply({ + entry: { main: { import: [path] } }, + resolve: { conditionNames: ['browser'] }, + }) + const result = await api.transform.mock.calls[1][1]({ + code: readFileSync(path, 'utf-8'), + resourcePath: path, + addDependency: mock(), + addMissingDependency: mock(), + environment: { name: 'web' }, + }) + expect(result.code).not.toContain('css(') + const sheet = result.code.match(/devup-ui(?:-\d+)?\.css/)?.[0] + expect( + wasm.getCss(utils.getFileNumByFilename(sheet ?? ''), true), + ).toContain('red') + }, +) + +it('extracts compiled MDX in the post-loader without prewarming raw markdown', async () => { + const root = project() + const path = file( + root, + 'app/page.mdx', + `# Markdown\n\nimport { Box } from '@devup-ui/react'\n\n`, + ) + const api = await setup(root) + api.apply({ + entry: { main: { import: [path] } }, + resolve: { conditionNames: ['browser'] }, + }) + expect(wasm.getCss(null, false)).not.toContain('red') + expect(api.transform.mock.calls[2][0]).toMatchObject({ order: 'post' }) + const result = await api.transform.mock.calls[2][1]({ + code: box('red'), + resourcePath: path, + addDependency: mock(), + addMissingDependency: mock(), + environment: { name: 'web' }, + }) + expect(result.code).not.toContain(' { + const root = project() + const api = await setup(root) + spyOn(wasm, 'codeExtract').mockImplementation(() => { + throw new Error(`${join(root, 'page.mdx')}:3:4: broken`) + }) + await expect( + api.transform.mock.calls[2][1]({ + code: '', + resourcePath: join(root, 'page.mdx'), + addDependency: mock(), + }), + ).rejects.toThrow('in compiled MDX') + await expect( + api.transform.mock.calls[1][1]({ + code: '', + resourcePath: join(root, 'page.tsx'), + addDependency: mock(), + }), + ).rejects.toThrow('broken') +}) + +it('fails prewarm with its real source filename and root', async () => { + const root = project() + const path = file( + root, + 'src/style.ts', + `import { css } from '@devup-ui/react'; export const style = css({ color: Math.random() })`, + ) + const api = await setup(root) + expect(() => api.apply({ entry: { main: { import: [path] } } })).toThrow( + `prewarm failed at ${path}`, + ) +}) + +it('warns once for optional seed failure while preserving requested graph guarantees', async () => { + const root = project() + const api = await setup(root) + spyOn(utils, 'collectNumberedFiles').mockImplementation(() => { + throw new Error('seed failed') + }) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + api.apply({ entry: {} }) + api.apply({ entry: {} }) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[1]).toMatchObject({ phase: 'seed', root }) +}) + +it('fails rather than guessing when normalized entries are dynamic', async () => { + const root = project() + const api = await setup(root) + expect(() => api.apply({ entry: () => ({}) })).toThrow( + 'dynamic Rspack entries', + ) +}) + +it('preserves user cache groups when shared splitting is composed', async () => { + const root = project() + const api = await setup(root, { atomHoist: 2 }) + const config: { tools?: { rspack?: (config: object) => void } } = {} + api.configure.mock.calls[0][0](config) + const vendor = { name: 'vendor' } + const rspack = { optimization: { splitChunks: { cacheGroups: { vendor } } } } + config.tools?.rspack?.(rspack) + expect(rspack.optimization.splitChunks.cacheGroups).toMatchObject({ + vendor, + devupUiShared: { enforce: true }, + }) +}) diff --git a/packages/rsbuild-plugin/src/__tests__/mdx-extensions.test.ts b/packages/rsbuild-plugin/src/__tests__/mdx-extensions.test.ts new file mode 100644 index 000000000..fec4f0d70 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/mdx-extensions.test.ts @@ -0,0 +1,123 @@ +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { createRsbuild } from '@rsbuild/core' +import { expect, it } from 'bun:test' + +import { + cjsGuardCases, + guardCases, + mdxBuildFixture, +} from '../../../webpack-plugin/src/__tests__/mdx-build-fixture' +import { DevupUI } from '../plugin' + +async function bundle(source: string, selected = false, extension = '.mdown') { + wasm.resetBuildState() + const fixture = mdxBuildFixture(source, extension) + const cjsIds: unknown[] = [] + try { + const builder = await createRsbuild({ + cwd: fixture.root, + rsbuildConfig: { + mode: 'development', + plugins: [ + DevupUI({ + singleCss: true, + mdxExtensions: selected ? ['.mdx', '.mdown'] : undefined, + }), + ], + source: { entry: { main: fixture.filename } }, + resolve: { alias: fixture.alias }, + output: { + target: 'node', + distPath: { root: join(fixture.root, 'out') }, + minify: false, + }, + tools: { + rspack: { + module: { rules: [fixture.rule] }, + plugins: [ + { + apply(compiler) { + compiler.hooks.thisCompilation.tap( + 'PublicCjsIds', + (compilation) => { + compilation.hooks.finishModules.tap( + 'PublicCjsIds', + (modules) => { + for (const module of modules) { + if (module.resource !== fixture.filename) continue + for (const dependency of module.dependencies) + if (dependency.type.startsWith('cjs')) + cjsIds.push(dependency.ids) + } + }, + ) + }, + ) + }, + }, + ], + externals: { + react: 'commonjs react', + 'react/jsx-runtime': 'commonjs react/jsx-runtime', + }, + }, + }, + }, + }) + const result = await builder.build() + try { + return { css: wasm.getCss(null, false), cjsIds } + } finally { + await result.close() + } + } finally { + fixture.cleanup() + } +} + +it('extracts custom MDX with the same isolated CSS as .mdx', async () => { + const source = 'import {Box} from "@devup-ui/react"\n\n' + const expected = await bundle(source, true, '.mdx') + const custom = await bundle(source, true) + expect(custom.css).toContain('red') + expect(custom.css).toBe(expected.css) +}) + +it.each(guardCases)( + 'uses Rspack definitely-used IDs to guard %s', + async (name, source, errors) => { + const action = bundle(source) + if (errors && name !== 'namespace destructure') + await expect(action).rejects.toThrow() + else expect((await action).css).not.toContain('{') + }, +) + +it.each(cjsGuardCases)( + 'allows require uses with opaque public Rspack IDs when %s', + async (name, source) => { + const result = await bundle(source) + + expect(result.cjsIds).toEqual( + name === 'require shadowed' || name === 'dynamic opaque' + ? [] + : [undefined], + ) + expect(result.css).not.toContain('{') + }, +) + +it('allows opaque destructuring while definite member IDs remain guarded', async () => { + // Rspack gives these destructuring uses the same empty export-ID list. + for (const name of ['css', 'getTheme']) { + const source = `import * as DU from "@devup-ui/react"\n\nexport const {${name}: value} = DU\n\n# Plain` + expect((await bundle(source)).css).not.toContain('{') + } + await expect( + bundle( + 'import * as DU from "@devup-ui/react"\n\nexport const value = DU.css({color:"red"})\n\n# Plain', + ), + ).rejects.toThrow('Rspack build failed') +}) diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 3fa5b9fd4..170333b16 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -20,6 +20,10 @@ import { DevupUI } from '../plugin' type CodeExtractResult = ReturnType type RsbuildPlugin = ReturnType type RsbuildSetupContext = Parameters[0] +const closeCallbacks: (() => void)[] = [] +afterEach(() => { + for (const close of closeCallbacks.splice(0)) close() +}) function createCodeExtractResult( overrides: Partial = {}, @@ -42,6 +46,17 @@ function createSetupContext( return { transform: mock(), modifyRsbuildConfig: mock(), + modifyRspackConfig: mock((callback) => { + const config: { plugins?: { apply(compiler: unknown): void }[] } = {} + callback(config, { environment: { name: 'web' } }) + config.plugins?.[0]?.apply({ + options: {}, + hooks: { run: { tap: mock() }, thisCompilation: { tap: mock() } }, + }) + }), + onBeforeBuild: mock(), + onCloseBuild: mock((close) => closeCallbacks.push(close)), + context: { rootPath: process.cwd() }, renderChunk: mock(), generateBundle: mock(), closeBundle: mock(), @@ -64,8 +79,12 @@ let getThemeInterfaceSpy: ReturnType let registerThemeSpy: ReturnType let setDebugSpy: ReturnType let setPrefixSpy: ReturnType +let realpathSpy: ReturnType beforeAll(() => { + realpathSpy = spyOn(fs, 'realpathSync').mockImplementation((path) => + resolve(String(path)), + ) existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) writeFileSyncSpy = spyOn(fs, 'writeFileSync').mockReturnValue(undefined) mkdirSpy = spyOn(fsPromises, 'mkdir').mockResolvedValue(undefined) @@ -80,6 +99,7 @@ beforeAll(() => { }) afterAll(() => { + realpathSpy.mockRestore() existsSyncSpy.mockRestore() writeFileSyncSpy.mockRestore() mkdirSpy.mockRestore() @@ -151,7 +171,7 @@ describe('DevupUIRsbuildPlugin', () => { readFileSpy.mockResolvedValueOnce(JSON.stringify({})) getThemeInterfaceSpy.mockReturnValue('interface code') existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return true + if (path === resolve('devup.json')) return true return false }) const plugin = DevupUI() @@ -171,7 +191,7 @@ describe('DevupUIRsbuildPlugin', () => { readFileSpy.mockResolvedValueOnce(JSON.stringify({})) getThemeInterfaceSpy.mockReturnValue('') existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return true + if (path === resolve('devup.json')) return true return false }) const plugin = DevupUI() @@ -189,11 +209,9 @@ describe('DevupUIRsbuildPlugin', () => { }) it('should error when write data files', async () => { - const originalConsoleError = console.error - console.error = mock() readFileSpy.mockRejectedValueOnce('error') existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return true + if (path === resolve('devup.json')) return true return false }) const plugin = DevupUI() @@ -201,14 +219,14 @@ describe('DevupUIRsbuildPlugin', () => { expect(plugin.setup).toBeDefined() const transform = mock() const modifyRsbuildConfig = mock() - await plugin.setup( - createSetupContext({ - transform, - modifyRsbuildConfig, - }), - ) - expect(console.error).toHaveBeenCalledWith('error') - console.error = originalConsoleError + await expect( + plugin.setup( + createSetupContext({ + transform, + modifyRsbuildConfig, + }), + ), + ).rejects.toThrow('error') }) it('should not register css transform', async () => { @@ -256,20 +274,22 @@ describe('DevupUIRsbuildPlugin', () => { expect(transform).toHaveBeenCalled() expect(transform).toHaveBeenCalledWith( { - test: /\.(tsx|ts|js|mjs|jsx)$/, + test: pluginUtils.SOURCE_FILE_RE, }, expect.any(Function), ) + const getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('file css') expect( transform.mock.calls[0][1]({ - code: ` - .devup-ui-1 { - color: red; - } - `, + code: '/* placeholder */', + resourcePath: resolve('df', 'devup-ui', 'devup-ui-1.css'), + environment: { name: 'web' }, }), - ).toBe('') + ).toBe('file css') + // A file's stylesheet imports the shared base + expect(getCssSpy).toHaveBeenCalledWith(1, true) + getCssSpy.mockRestore() }) it('should transform code', async () => { const plugin = DevupUI() @@ -286,16 +306,11 @@ describe('DevupUIRsbuildPlugin', () => { expect(transform).toHaveBeenCalled() expect(transform).toHaveBeenCalledWith( { - test: /\.(tsx|ts|js|mjs|jsx)$/, + test: pluginUtils.SOURCE_FILE_RE, }, expect.any(Function), ) - expect( - transform.mock.calls[0][1]({ - code: ``, - }), - ).toBe('') codeExtractSpy.mockReturnValue( createCodeExtractResult({ code: '
', @@ -344,7 +359,7 @@ const App = () => `, expect(transform).toHaveBeenCalled() expect(transform).toHaveBeenCalledWith( { - test: /\.(tsx|ts|js|mjs|jsx)$/, + test: pluginUtils.SOURCE_FILE_RE, }, expect.any(Function), ) @@ -367,13 +382,6 @@ const App = () => `, map: undefined, }) - if (options.updatedBaseStyle) { - expect(writeFileSpy).toHaveBeenCalledWith( - resolve('df', 'devup-ui', 'devup-ui.css'), - expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), - 'utf-8', - ) - } expect(writeFileSpy).toHaveBeenCalledWith( resolve('df', 'devup-ui', 'devup-ui.css'), expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), @@ -408,8 +416,8 @@ const App = () => `, getThemeInterfaceSpy.mockReturnValue('interface code') getDefaultThemeSpy.mockReturnValue(options.getDefaultTheme) existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return options.existsDevupFile - if (path === 'df') return options.existsDistDir + if (path === resolve('devup.json')) return options.existsDevupFile + if (path === resolve('df')) return options.existsDistDir if (path === resolve('df', 'devup-ui')) return options.existsCssDir if (path === join('df', 'sheet.json')) return options.existsSheetFile if (path === join('df', 'classMap.json')) @@ -420,7 +428,7 @@ const App = () => `, const plugin = DevupUI({ singleCss: options.singleCss }) await plugin.setup(createSetupContext()) if (options.existsDevupFile) { - expect(readFileSpy).toHaveBeenCalledWith('devup.json', 'utf-8') + expect(readFileSpy).toHaveBeenCalledWith(resolve('devup.json'), 'utf-8') expect(registerThemeSpy).toHaveBeenCalledWith({}) expect(getThemeInterfaceSpy).toHaveBeenCalledWith( '@devup-ui/react', @@ -431,7 +439,7 @@ const App = () => `, 'DevupTheme', ) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -485,6 +493,48 @@ const App = () => `, expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) + describe('deterministic file numbering', () => { + it.each([ + ['relative', false], + ['posix', true], + ])('numbers the files the scan finds (%s ids)', async (_name, atomMode) => { + const collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + const closeBuild = mock() + try { + collectSpy.mockImplementation((options: any) => { + expect(options.toId('C:\\p\\a.tsx')).toBe( + atomMode ? 'C:/p/a.tsx' : 'C:\\p\\a.tsx', + ) + return ['/p/a.tsx'] + }) + await DevupUI({ + include: ['@acme/ui'], + sourceDirs: ['src'], + atomHoist: atomMode ? 2 : undefined, + }).setup(createSetupContext({ onCloseBuild: closeBuild })) + expect(seedSpy).toHaveBeenCalledWith(['/p/a.tsx']) + expect(collectSpy.mock.calls[0][0]).toMatchObject({ + roots: [resolve('src')], + include: ['@acme/ui'], + }) + closeBuild.mock.calls[0][0]() + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) + await DevupUI().setup(createSetupContext()) + } finally { + collectSpy.mockRestore() + seedSpy.mockRestore() + } + }) + + it('sets the prefix every time, even without one', async () => { + setPrefixSpy.mockClear() + await DevupUI().setup(createSetupContext()) + expect(setPrefixSpy).toHaveBeenCalledWith(null) + }) + }) describe('atomHoist pre-pass', () => { let buildCanonicalMapSpy: ReturnType let computeFileReachSpy: ReturnType @@ -578,14 +628,19 @@ const App = () => `, expect(setAtomHoistSpy).not.toHaveBeenCalled() }) - it('swallows pre-pass errors (atom hoisting stays off)', async () => { + it('fails requested atom planning when the graph fails', async () => { spies() buildCanonicalMapSpy.mockImplementation(() => { throw new Error('boom') }) - await DevupUI({ atomHoist: 2 }).setup( - createSetupContext({ transform: mock(), modifyRsbuildConfig: mock() }), - ) + await expect( + DevupUI({ atomHoist: 2 }).setup( + createSetupContext({ + transform: mock(), + modifyRsbuildConfig: mock(), + }), + ), + ).rejects.toThrow('boom') expect(setAtomHoistSpy).not.toHaveBeenCalled() }) @@ -607,12 +662,14 @@ const App = () => `, const servedChunk = transform.mock.calls[0][1]({ code: '', resourcePath: resolve('df', 'devup-ui', 'devup-ui-3.css'), + environment: { name: 'web' }, }) expect(servedChunk).toBe('CSS_FOR_3') expect(getCssSpy).toHaveBeenCalledWith(3, false) const servedBase = transform.mock.calls[0][1]({ code: '', resourcePath: resolve('df', 'devup-ui', 'devup-ui.css'), + environment: { name: 'web' }, }) expect(servedBase).toBe('CSS_FOR_null') expect(getCssSpy).toHaveBeenCalledWith(null, false) @@ -675,7 +732,12 @@ const App = () => `, // splitChunks missing/false -> no cacheGroup added, no throw const rspackCfg2 = {} as { optimization?: { splitChunks?: unknown } } inject(rspackCfg2) - expect(rspackCfg2.optimization?.splitChunks).toBeUndefined() + expect(rspackCfg2.optimization?.splitChunks).toMatchObject({ + cacheGroups: { devupUiShared: { type: 'css/mini-extract' } }, + }) + expect(() => inject({ optimization: { splitChunks: false } })).toThrow( + 'splitChunks: false', + ) }) it('composes the cacheGroup with existing tools.rspack (function then array)', async () => { @@ -710,4 +772,179 @@ const App = () => `, expect((cfgArr.tools.rspack as unknown[]).length).toBe(2) }) }) + + describe('stylesheets built too early', () => { + let getCssSpy: ReturnType + let readFileSyncSpy: ReturnType + let computeReachableFilesSpy: ReturnType + + afterEach(() => { + getCssSpy.mockRestore() + readFileSyncSpy.mockRestore() + computeReachableFilesSpy.mockRestore() + existsSyncSpy.mockReturnValue(false) + }) + + async function setup(options: Parameters[0] = {}) { + getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('before') + readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue('source') + computeReachableFilesSpy = spyOn( + pluginUtils, + 'computeReachableFiles', + ).mockReturnValue([resolve('src', 'App.tsx')]) + codeExtractSpy.mockReturnValue(createCodeExtractResult()) + const transform = mock() + const onBeforeBuild = mock() + const modifyRspackConfig = mock() + await DevupUI(options).setup( + createSetupContext({ transform, onBeforeBuild, modifyRspackConfig }), + ) + const config: { plugins?: { apply(compiler: unknown): void }[] } = {} + modifyRspackConfig.mock.calls[0][0](config, { + environment: { name: 'web' }, + }) + const taps: Record unknown> = {} + const tap = + (hook: string) => + (name: unknown, fn: (...args: unknown[]) => unknown) => { + taps[name === 'DevupUICompiledSourceGuard' ? `${hook}:guard` : hook] = + fn + } + const compiler = { + options: {}, + watchMode: false, + rspack: { Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 } }, + hooks: { + run: { tap: tap('run') }, + thisCompilation: { tap: tap('start') }, + }, + } + config.plugins![0]!.apply(compiler) + const compilation = { + assets: { 'index.js': {} }, + fileDependencies: new Set(), + missingDependencies: new Set(), + deleteAsset: mock(), + hooks: { + finishModules: { tap: tap('finishModules') }, + processAssets: { tap: tap('processAssets') }, + needAdditionalPass: { tap: tap('needAdditionalPass') }, + }, + } + const serve = (resourcePath: string) => + transform.mock.calls[0][1]({ + resourcePath, + environment: { name: 'web' }, + }) + return { onBeforeBuild, compiler, compilation, taps, serve } + } + + it('extracts the files the entries reach before building', async () => { + const { onBeforeBuild } = await setup({ atomHoist: undefined }) + codeExtractSpy.mockClear() + onBeforeBuild.mock.calls[0][0]({ + environments: { + web: { + entry: { + a: './src/a.tsx', + b: ['./src/b.tsx'], + c: { import: './src/c.tsx' }, + d: { import: ['./src/d.tsx'] }, + }, + }, + }, + }) + expect(computeReachableFilesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + srcDir: [], + tsconfigPath: resolve(process.cwd(), 'tsconfig.json'), + entries: [], + }), + ) + expect(codeExtractSpy).toHaveBeenCalledWith( + resolve('src', 'App.tsx'), + 'source', + '@devup-ui/react', + expect.stringMatching(/^\.\//), + false, + false, + true, + expect.anything(), + ) + + // an extraction error is reported by the transform of that file + codeExtractSpy.mockImplementation(() => { + throw new Error('boom') + }) + expect(() => + onBeforeBuild.mock.calls[0][0]({ environments: {} }), + ).toThrow('prewarm failed') + }) + + it('extracts under posix names in atom mode', async () => { + const { onBeforeBuild } = await setup({ atomHoist: 2 }) + codeExtractSpy.mockClear() + onBeforeBuild.mock.calls[0][0]({ environments: {} }) + expect(codeExtractSpy.mock.calls[0]![0]).toBe( + resolve('src', 'App.tsx').replaceAll('\\', '/'), + ) + }) + + it('compiles once more, writing no file, when a stylesheet changed', async () => { + const { compiler, compilation, taps, serve } = await setup() + writeFileSyncSpy.mockClear() + taps.start!(compilation) + // the shared base is written first, as the CSS loaders read it from disk + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui.css'), + 'before', + 'utf-8', + ) + serve(resolve('df', 'devup-ui', 'devup-ui-1.css')) + getCssSpy.mockReturnValue('after') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).toHaveBeenCalledWith('index.js') + writeFileSyncSpy.mockClear() + expect(taps.needAdditionalPass!()).toBe(true) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui-1.css'), + 'after', + 'utf-8', + ) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui.css'), + 'after', + 'utf-8', + ) + + // one more pass per run at most + compilation.deleteAsset.mockClear() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBe(false) + taps.run!() + expect(taps.needAdditionalPass!()).toBe(true) + + // the dev server rebuilds through the files the transforms write + compiler.watchMode = true + taps.start!(compilation) + taps.finishModules!() + expect(taps.needAdditionalPass!()).toBe(false) + }) + + it('keeps the pass when every stylesheet is current', async () => { + const { compilation, taps, serve } = await setup() + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('before') + writeFileSyncSpy.mockClear() + taps.start!(compilation) + expect(writeFileSyncSpy).not.toHaveBeenCalled() + serve(resolve('df', 'devup-ui', 'devup-ui-1.css')) + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBe(false) + }) + }) }) diff --git a/packages/rsbuild-plugin/src/__tests__/resolution-watch.mjs b/packages/rsbuild-plugin/src/__tests__/resolution-watch.mjs new file mode 100644 index 000000000..bd2e93b33 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/resolution-watch.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { DevupUI } from '../../dist/index.mjs' + +const require = createRequire(import.meta.url) +const { createRsbuild } = await import( + pathToFileURL(require.resolve('@rsbuild/core')).href +) +const landing = createRequire( + join(import.meta.dirname, '../../../../apps/landing/package.json'), +) +const cold = process.argv[2] === 'cold' +const fixture = cold + ? undefined + : await realpath(await mkdtemp(join(tmpdir(), 'rsbuild-resolution-watch-'))) +const root = cold ? process.argv[3] : join(fixture, 'apps/app') +const palette = resolve(root, '../../packages/palette') +const entry = join(root, 'src/main.js') +const manifest = join(palette, 'package.json') +let server +let result +let next +const pending = [] +const nextBuild = () => + new Promise((resolveBuild, rejectBuild) => { + const timer = setTimeout( + () => rejectBuild(new Error('No real exports-only rebuild')), + 12000, + ) + timer.unref() + next = (stats) => { + clearTimeout(timer) + next = undefined + resolveBuild(stats) + } + if (pending.length) next(pending.shift()) + }) +const state = (stats) => { + assert.equal( + stats.hasErrors(), + false, + stats.toString({ all: false, errors: true }), + ) + const compilation = stats.stats + ? stats.stats[0].compilation + : stats.compilation + const module = [...compilation.modules].find( + (candidate) => candidate.resource === entry, + ) + const code = module.originalSource().source().toString() + const selected = code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + assert.ok(selected, code) + const files = Object.keys(compilation.assets).filter((name) => + name.endsWith('.css'), + ) + assert.ok(files.length) + return { + manifestWatched: compilation.fileDependencies.has(manifest), + code, + selected, + files, + } +} +try { + if (!cold) { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules'), { recursive: true }) + await mkdir(palette, { recursive: true }) + await symlink(palette, join(root, 'node_modules/palette'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{}') + await writeFile(manifest, '{"name":"palette","exports":"./red.js"}') + await writeFile(join(palette, 'red.js'), "export const color='red'") + await writeFile(join(palette, 'blue.js'), "export const color='blue'") + await writeFile( + entry, + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + } + const builder = await createRsbuild({ + cwd: root, + rsbuildConfig: { + mode: cold ? 'production' : 'development', + plugins: [DevupUI()], + source: { entry: { main: entry } }, + resolve: { + alias: { '@devup-ui/react': landing.resolve('@devup-ui/react') }, + }, + server: { host: '127.0.0.1', port: 0 }, + output: { + injectStyles: false, + minify: false, + distPath: { root: join(root, cold ? 'cold' : 'out') }, + }, + }, + }) + if (cold) { + result = await builder.build() + const files = await import('node:fs/promises').then(({ readdir }) => + readdir(join(root, 'cold'), { recursive: true }), + ) + const css = ( + await Promise.all( + files + .filter((name) => name.endsWith('.css')) + .map((name) => readFile(join(root, 'cold', name), 'utf-8')), + ) + ).join('\n') + assert.match(css, /background:\s*(?:blue|#00f)(?:;|\s*\})/) + assert.doesNotMatch(css, /background:\s*(?:red|#f00)(?:;|\s*\})/) + console.info(JSON.stringify({ cold: css })) + } else { + builder.onAfterDevCompile(({ stats }) => + next ? next(stats) : pending.push(stats), + ) + const initial = nextBuild() + server = (await builder.startDevServer()).server + const before = state(await initial) + assert.ok(before.manifestWatched, `Manifest not registered: ${manifest}`) + const { port } = server.httpServer.address() + const css = async (snapshot) => + ( + await Promise.all( + snapshot.files.map(async (file) => { + const response = await fetch(`http://127.0.0.1:${port}/${file}`, { + signal: AbortSignal.timeout(10000), + }) + assert.equal(response.status, 200) + return response.text() + }), + ) + ).join('\n') + const beforeCss = await css(before) + assert.match( + beforeCss, + new RegExp( + `\\.${before.selected}\\s*\\{[^}]*background:\\s*red(?:;|\\s*\\})`, + ), + ) + const rebuilt = nextBuild() + const replacement = join(fixture, 'manifest-replacement.json') + await writeFile(replacement, '{"name":"palette","exports":"./blue.js"}') + await rename(replacement, manifest) + let after = state(await rebuilt) + for (let attempt = 0; attempt < 3; attempt += 1) { + if (after.selected !== before.selected) break + after = state(await nextBuild()) + } + const afterCss = await css(after) + assert.notEqual(after.selected, before.selected) + assert.match( + afterCss, + new RegExp( + `\\.${after.selected}\\s*\\{[^}]*background:\\s*(?:blue|#00f)(?:;|\\s*\\})`, + ), + ) + await server.close() + server = undefined + const built = spawnSync('node', [import.meta.filename, 'cold', root], { + encoding: 'utf-8', + }) + assert.equal(built.status, 0, built.stderr) + console.info( + JSON.stringify({ + before: beforeCss, + after: afterCss, + selected: after.selected, + cold: built.stdout, + }), + ) + } +} finally { + await server?.close() + await result?.close() + if (fixture) await rm(fixture, { recursive: true, force: true }) +} diff --git a/packages/rsbuild-plugin/src/__tests__/resolution-watch.test.ts b/packages/rsbuild-plugin/src/__tests__/resolution-watch.test.ts new file mode 100644 index 000000000..82ba4a1cd --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/resolution-watch.test.ts @@ -0,0 +1,19 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +it('serves the new selected CSS rule when only linked workspace exports change', () => { + // Given the real Rsbuild dev-server fixture and two existing palette entries. + const runner = join(import.meta.dir, 'resolution-watch.mjs') + // When only exports is edited and public compilation callbacks complete. + const result = Bun.spawnSync(['node', runner], { + stdout: 'pipe', + stderr: 'pipe', + }) + // Then HTTP-served CSS selects blue and the separate cold build has no red. + expect({ + exitCode: result.exitCode, + error: result.stderr.toString(), + }).toMatchObject({ exitCode: 0 }) + expect(result.stdout.toString()).toContain('"after"') +}, 45000) diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 990a4cd2a..ca2270076 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -1,20 +1,40 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + buildStaticImportGraph, + collectNumberedFiles, computeFileReach, + computeReachableFiles, createCompatTypes, + createDependencyGuard, createModuleResolver, + type CreateModuleResolverOptions, createNodeModulesExcludeRegex, + createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, + isMdxSource, + isSelectedSource, loadDevupConfig, + mdxSourceFilter, mergeImportAliases, + normalizeMdxExtensions, planAtomHoist, + remapMdxError, + type ResolutionInputObserver, + resolutionWatchPath, + resolveProjectPaths, + resolveSourceDirs, + seedFileNumbers, + SOURCE_FILE_RE, + type StaticImportGraph, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -25,12 +45,16 @@ import { importFileRoutes, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, setPrefix, } from '@devup-ui/wasm' -import type { RsbuildPlugin } from '@rsbuild/core' +import type { RsbuildPlugin, Rspack } from '@rsbuild/core' + +const PLUGIN_NAME = 'devup-ui-rsbuild-plugin' export interface DevupUIRsbuildPluginOptions { package: string @@ -43,6 +67,8 @@ export interface DevupUIRsbuildPluginOptions { singleCss: boolean prefix?: string shorthands?: CustomShorthands + sourceDirs?: string | string[] + mdxExtensions?: readonly string[] /** * Atom-level route-aware hoisting threshold (min routes sharing an atom for it * to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). @@ -65,42 +91,35 @@ export interface DevupUIRsbuildPluginOptions { importAliases?: ImportAliases } -let globalCss = '' - async function writeDataFiles( options: Omit< DevupUIRsbuildPluginOptions, 'extractCss' | 'debug' | 'include' >, ) { - try { - const config = await loadDevupConfig(options.devupFile) - const theme = config.theme ?? {} + const config = await loadDevupConfig(options.devupFile) + const theme = config.theme ?? {} - registerTheme(theme) - const interfaceCode = getThemeInterface( - ...createThemeInterfaceArgs(options.package), - ) + registerTheme(theme) + const interfaceCode = getThemeInterface( + ...createThemeInterfaceArgs(options.package), + ) - if (interfaceCode) { - await writeFile( - join(options.distDir, 'theme.d.ts'), - interfaceCode, - 'utf-8', - ) - } - } catch (error) { - console.error(error) - registerTheme({}) - } - await Promise.all([ - !existsSync(options.cssDir) - ? mkdir(options.cssDir, { recursive: true }) - : Promise.resolve(), - !options.singleCss - ? writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) - : Promise.resolve(), - ]) + await writeFile(join(options.distDir, 'theme.d.ts'), interfaceCode, 'utf-8') + if (!existsSync(options.cssDir)) + await mkdir(options.cssDir, { recursive: true }) + if (!options.singleCss) + await writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) +} + +/** + * Write `css` to the stylesheet file at `path` unless it holds it already. The + * CSS loaders read the shared base from disk when a file's stylesheet imports + * it, so it must hold every style extracted so far. + */ +function writeChanged(path: string, css: string) { + if (!existsSync(path) || readFileSync(path, 'utf-8') !== css) + writeFileSync(path, css, 'utf-8') } export const DevupUI = ({ @@ -108,41 +127,69 @@ export const DevupUI = ({ package: libPackage = '@devup-ui/react', extractCss = true, distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), + cssDir: configuredCssDir, devupFile = 'devup.json', debug = false, singleCss = false, prefix, shorthands, + sourceDirs: configuredSourceDirs, atomHoist, importAliases: userImportAliases, + mdxExtensions: configuredMdxExtensions, }: Partial = {}): RsbuildPlugin => { registerShorthands(shorthands ?? {}) const importAliases = mergeImportAliases(userImportAliases) + const mdxExtensions = normalizeMdxExtensions(configuredMdxExtensions) + const excludeModules = createNodeModulesExcludeRegex(include) + let seedWarningEmitted = false + const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), + ) return { - name: 'devup-ui-rsbuild-plugin', + name: PLUGIN_NAME, async setup(api) { - setDebug(debug) - if (prefix) { - setPrefix(prefix) - } - - if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) - await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') - await writeFile( - join(distDir, 'compat.d.ts'), - createCompatTypes(importAliases), - 'utf-8', - ) - - await writeDataFiles({ - package: libPackage, + const root = api.context.rootPath + const { cssDir, - devupFile, + distDir: outputDir, + devupFile: configFile, + } = resolveProjectPaths(root, { distDir, - singleCss, + devupFile, + cssDir: configuredCssDir, }) + // A build starts from its own options, not from what an earlier build + // in this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + api.onCloseBuild?.(endBuild) + setDebug(debug) + setPrefix(prefix ?? null) + + try { + if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true }) + await writeFile(join(outputDir, '.gitignore'), '*', 'utf-8') + await writeFile( + join(outputDir, 'compat.d.ts'), + createCompatTypes(importAliases), + 'utf-8', + ) + + await writeDataFiles({ + package: libPackage, + cssDir, + devupFile: configFile, + distDir: outputDir, + singleCss, + }) + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] setup failed at ${root}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } if (!extractCss) return // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any @@ -153,60 +200,315 @@ export const DevupUI = ({ // POSIX-normalized to match. const atomMode = atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 - setModuleResolver( - createModuleResolver({ - toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), - }), - ) - if (atomMode) { - try { - const root = process.cwd() - const srcDir = resolve(root, 'src') - const tsconfigPath = resolve(root, 'tsconfig.json') - const canonicalMap = buildCanonicalMap({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - }) - importCanonicalMap(canonicalMap) - const fileReach = computeFileReach({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - }) - const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) - if (plan) { - importFileRoutes(plan.reachByBucket) - setAtomHoist(plan.threshold) - } else { - console.info( - '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + const toId = (path: string) => + atomMode ? path.replaceAll('\\', '/') : path + const sourceDirs = [ + ...new Set([ + ...resolveSourceDirs(root, configuredSourceDirs), + ...resolveSourceDirs(root), + ]), + ] + const plans = new Map< + string, + { + graph: StaticImportGraph + roots: string[] + entries: string[] + resolver: ReturnType + resolverOptions: CreateModuleResolverOptions + preserveSymlinks: boolean + inputFiles: Set + missingInputs: Set + } + >() + const prewarm = (plan: NonNullable>) => { + plan.resolver = createModuleResolver(plan.resolverOptions) + setModuleResolver(plan.resolver) + for (const file of computeReachableFiles({ + srcDir: plan.roots, + tsconfigPath: resolve(root, 'tsconfig.json'), + entries: plan.entries, + graph: plan.graph, + })) { + if (!SOURCE_FILE_RE.test(file)) continue + try { + let extractCssDir = relative(dirname(file), cssDir).replaceAll( + '\\', + '/', + ) + if (!extractCssDir.startsWith('./')) + extractCssDir = `./${extractCssDir}` + codeExtract( + toId(file), + readFileSync(file, 'utf-8'), + libPackage, + extractCssDir, + singleCss, + atomMode, + !atomMode, + importAliases, + ) + } catch (cause) { + throw new Error( + `[devup-ui] prewarm failed at ${file} (root ${root}): ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, ) } - } catch { - // Best-effort; on failure atom hoisting stays off (identity). } } + api.onBeforeBuild(() => { + for (const plan of plans.values()) prewarm(plan) + }) + + const servedCss = new Map>() + const checkCompiled = createDependencyGuard({ + package: libPackage, + mdxExtensions, + importAliases, + }) + const stylesheet = (resourcePath: string) => + // A file's stylesheet imports the shared base, except in atom mode, + // where the entry code imports the base itself so that hoisted atoms + // load once (the injected splitChunks cacheGroup, see + // modifyRsbuildConfig, emits the base once) + getCss(getFileNumByFilename(basename(resourcePath)), !atomMode) api.transform( { test: cssDir, }, - ({ resourcePath }) => { - // Non-atom: keep the existing single-string behavior (no regression). - if (!atomMode) return globalCss - // Atom mode: serve the route-specific chunk and have it @import the - // shared base (devup-ui.css) so hoisted atoms load ONCE and are not - // inlined per chunk. The base file itself imports nothing. - // Route chunk and base are SEPARATE modules (the transformed entry - // code imports both via import_main_css); the injected splitChunks - // cacheGroup (see modifyRsbuildConfig) emits the base once. - return getCss(getFileNumByFilename(basename(resourcePath)), false) + ({ resourcePath, environment }) => { + const css = stylesheet(resourcePath) + servedCss.get(environment.name)?.set(resourcePath, css) + return css }, ) + // A stylesheet module is built on its first import, which can come before + // the modules whose styles it holds are extracted. When one was, compile + // once more: every module is extracted by then. The dev server rebuilds it + // through the stylesheet files the transforms write instead. + api.modifyRspackConfig((config, { environment }) => { + const initialize = (normalized: Rspack.Compiler['options']) => { + const baseConditions = normalized.resolve?.conditionNames ?? [ + 'webpack', + normalized.mode === 'development' ? 'development' : 'production', + environment.config?.output?.target === 'node' ? 'node' : 'browser', + ] + const conditions = ( + normalized.resolve?.byDependency?.esm?.conditionNames ?? [ + 'import', + 'module', + '...', + ] + ).flatMap((condition) => + condition === '...' ? baseConditions : [condition], + ) + const entry = normalized.entry + if (typeof entry === 'function') + throw new Error( + `[devup-ui] graph setup failed at ${root}: dynamic Rspack entries are not available for prewarm`, + ) + const rawEntries = + typeof entry === 'string' + ? [entry] + : Array.isArray(entry) + ? entry + : Object.values(entry ?? {}).flatMap((value) => + typeof value === 'string' + ? [value] + : Array.isArray(value) + ? value + : (value.import ?? []), + ) + const entries = rawEntries + .filter((file) => isSelectedSource(file, mdxExtensions)) + .map((file) => resolve(root, file)) + const roots = [ + ...new Set([ + ...sourceDirs, + ...entries.map((file) => dirname(file)), + ]), + ] + const tsconfigPath = resolve(root, 'tsconfig.json') + const inputFiles = new Set() + const missingInputs = new Set() + const preserveSymlinks = normalized.resolve?.symlinks === false + const onResolutionInputs: ResolutionInputObserver = (inputs) => { + for (const path of inputs.fileDependencies) + inputFiles.add(resolutionWatchPath(path, preserveSymlinks)) + for (const path of inputs.missingDependencies) + missingInputs.add(resolutionWatchPath(path, preserveSymlinks)) + } + try { + const resolverOptions = { + cwd: root, + includeMdx: mdxExtensions, + conditions, + alias: normalized.resolve?.alias, + toId, + onResolutionInputs, + } + const resolver = createModuleResolver(resolverOptions) + setModuleResolver(resolver) + const graph = buildStaticImportGraph(roots, tsconfigPath, { + includeMdx: mdxExtensions, + cwd: root, + include, + conditions, + alias: normalized.resolve?.alias, + exclude: [basename(outputDir), basename(cssDir)], + onResolutionInputs, + }) + const plan = { + graph, + roots, + entries, + resolver, + resolverOptions, + preserveSymlinks, + inputFiles, + missingInputs, + } + plans.set(environment.name, plan) + if (atomMode) { + const canonicalMap = buildCanonicalMap({ + srcDir: roots, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + graph, + }) + importCanonicalMap(canonicalMap) + const reach = computeFileReach({ + srcDir: roots, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + graph, + entries: entries.length ? entries : undefined, + }) + const hoist = planAtomHoist(canonicalMap, reach, atomHoist) + if (hoist) { + importFileRoutes(hoist.reachByBucket) + setAtomHoist(hoist.threshold) + } else { + console.info( + '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + ) + } + } + try { + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots, + includeMdx: mdxExtensions, + include, + cwd: root, + needles: extractedNeedles(libPackage, importAliases), + toId, + }), + ) + } catch (cause) { + if (!seedWarningEmitted) { + seedWarningEmitted = true + console.warn( + '[devup-ui] deterministic file seeding failed; class IDs now depend on module arrival order', + { phase: 'seed', root, cause }, + ) + } + } + prewarm(plan) + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] graph setup failed at ${root}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } + } + config.plugins ??= [] + config.plugins.push({ + apply(compiler: Rspack.Compiler) { + initialize(compiler.options) + let passes = 0 + compiler.hooks.run.tap(PLUGIN_NAME, () => { + passes = 0 + }) + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + const plan = plans.get(environment.name) + for (const path of plan?.inputFiles ?? []) + compilation.fileDependencies.add(path) + for (const path of plan?.missingInputs ?? []) + compilation.missingDependencies.add(path) + const served = new Map() + servedCss.set(environment.name, served) + const basePath = join(cssDir, 'devup-ui.css') + const base = stylesheet(basePath) + writeChanged(basePath, base) + let stale: string[] = [] + compilation.hooks.finishModules.tap(PLUGIN_NAME, () => { + if (compiler.watchMode) return + const changed = new Set( + [...served] + .filter(([path, css]) => stylesheet(path) !== css) + .map(([path]) => path), + ) + // A file's stylesheet `@import`s the shared base, which the CSS + // loaders read from disk without passing through this plugin + if (stylesheet(basePath) !== base) changed.add(basePath) + stale = [...changed] + }) + compilation.hooks.finishModules.tap( + 'DevupUICompiledSourceGuard', + (modules) => { + const dependencies = new Map() + for (const module of modules) + for (const dependency of module.dependencies) + dependencies.set(dependency, dependency) + compilation.errors.push( + ...checkCompiled(modules, { + graph: compilation.moduleGraph, + target(dependency) { + const actual = dependencies.get(dependency) + const module = + actual && compilation.moduleGraph.getModule(actual) + return module && + 'resource' in module && + typeof module.resource === 'string' + ? module.resource + : undefined + }, + }), + ) + }, + ) + // The next pass writes the build; this one writes none of its files + compilation.hooks.processAssets.tap( + { + name: PLUGIN_NAME, + stage: + compiler.rspack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + if (stale.length === 0 || passes > 0) return + for (const name of Object.keys(compilation.assets)) + compilation.deleteAsset(name) + }, + ) + compilation.hooks.needAdditionalPass.tap(PLUGIN_NAME, () => { + if (stale.length === 0 || passes > 0) return false + passes += 1 + for (const path of stale) + writeFileSync(path, stylesheet(path), 'utf-8') + return true + }) + }) + }, + }) + }) + api.modifyRsbuildConfig((config) => { const theme = getDefaultTheme() if (theme) { @@ -231,16 +533,20 @@ export const DevupUI = ({ } }) => { rspackConfig.optimization ??= {} - const sc = rspackConfig.optimization.splitChunks - if (sc && typeof sc === 'object') { - sc.cacheGroups ??= {} - sc.cacheGroups['devupUiShared'] = { - type: 'css/mini-extract', - name: 'devup-ui-shared', - test: /[\\/]devup-ui\.css$/, - chunks: 'all', - enforce: true, - } + const splitChunks = rspackConfig.optimization.splitChunks + if (splitChunks === false) + throw new Error( + `[devup-ui] atomHoist requires splitChunks at ${root}; splitChunks: false disables shared CSS`, + ) + const sc = splitChunks ?? {} + rspackConfig.optimization.splitChunks = sc + sc.cacheGroups ??= {} + sc.cacheGroups['devupUiShared'] = { + type: 'css/mini-extract', + name: 'devup-ui-shared', + test: /[\\/]devup-ui\.css$/, + chunks: 'all', + enforce: true, } } config.tools.rspack = Array.isArray(prev) @@ -252,81 +558,115 @@ export const DevupUI = ({ return config }) - api.transform( - { - test: /\.(tsx|ts|js|mjs|jsx)$/, - }, - async ({ code, resourcePath, addDependency }) => { - if (createNodeModulesExcludeRegex(include).test(resourcePath)) - return code - // The stylesheet import is emitted relative to the importing file, as - // in the next/webpack/vite loaders. An absolute cssDir would bake this - // checkout's path into the emitted module, so byte-identical sources - // in two checkouts (git worktrees, a CI matrix, sibling clones) would - // produce different output and any content-addressed or relocated - // build cache would serve the wrong checkout's stylesheet. - // - // Atom mode additionally mirrors vite: the entry CODE imports the - // shared base (import_main_css_in_code=true) so rspack emits - // devup-ui.css once and links it from every entry (hoisted atoms - // shared, not inlined), and the extraction filename is - // POSIX-normalized to match the absolute-keyed canonical map / - // FILE_ROUTES. - let extractCssDir = relative( - dirname(resourcePath), - cssDir, - ).replaceAll('\\', '/') - if (!extractCssDir.startsWith('./')) - extractCssDir = `./${extractCssDir}` - const extractName = atomMode - ? resourcePath.replaceAll('\\', '/') - : resourcePath - const { - code: retCode, - css = '', - map, - cssFile, - updatedBaseStyle, - dependencies = [], - } = codeExtract( - extractName, - code, - libPackage, - extractCssDir, - singleCss, - atomMode, - !atomMode, - importAliases, + const extract: Parameters[1] = async ({ + code, + resourcePath, + addDependency, + addMissingDependency, + environment, + }) => { + if (excludeModules.test(resourcePath)) return code + const plan = plans.get(environment?.name) + if (plan) + setModuleResolver( + createModuleResolver({ + ...plan.resolverOptions, + onResolutionInputs: (inputs) => { + for (const path of inputs.fileDependencies) + addDependency( + resolutionWatchPath(path, plan.preserveSymlinks), + ) + for (const path of inputs.missingDependencies) + addMissingDependency( + resolutionWatchPath(path, plan.preserveSymlinks), + ) + }, + }), ) - for (const dependency of dependencies) addDependency(dependency) - const promises: Promise[] = [] - if (updatedBaseStyle) { - // update base style - promises.push( - writeFile( - join(cssDir, 'devup-ui.css'), - getCss(null, false), - 'utf-8', - ), + // The stylesheet import is emitted relative to the importing file, as + // in the next/webpack/vite loaders. An absolute cssDir would bake this + // checkout's path into the emitted module, so byte-identical sources + // in two checkouts (git worktrees, a CI matrix, sibling clones) would + // produce different output and any content-addressed or relocated + // build cache would serve the wrong checkout's stylesheet. + // + // Atom mode additionally mirrors vite: the entry CODE imports the + // shared base (import_main_css_in_code=true) so rspack emits + // devup-ui.css once and links it from every entry (hoisted atoms + // shared, not inlined), and the extraction filename is + // POSIX-normalized to match the absolute-keyed canonical map / + // FILE_ROUTES. + let extractCssDir = relative(dirname(resourcePath), cssDir).replaceAll( + '\\', + '/', + ) + if (!extractCssDir.startsWith('./')) + extractCssDir = `./${extractCssDir}` + const extractName = atomMode + ? resourcePath.replaceAll('\\', '/') + : resourcePath + const { + code: retCode, + map, + cssFile, + updatedBaseStyle, + dependencies = [], + } = (() => { + try { + return codeExtract( + extractName, + code, + libPackage, + extractCssDir, + singleCss, + atomMode, + !atomMode, + importAliases, + ...(isMdxSource(resourcePath, mdxExtensions) + ? (['compiled-mdx'] as const) + : ([] as const)), ) + } catch (error) { + if (isMdxSource(resourcePath, mdxExtensions)) + throw remapMdxError(error, resourcePath) + throw error } + })() + for (const dependency of dependencies) addDependency(dependency) + const promises: Promise[] = [] + if (updatedBaseStyle) { + // update base style + promises.push( + stateWriter.write( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ), + ) + } - if (cssFile) { - if (globalCss.length < css.length) globalCss = css - promises.push( - writeFile( - join(cssDir, basename(cssFile)), - `/* ${resourcePath} ${Date.now()} */`, - 'utf-8', - ), - ) - } - await Promise.all(promises) - return { - code: retCode, - map, - } - }, + if (cssFile) { + promises.push( + stateWriter.write( + join(cssDir, basename(cssFile)), + `/* ${resourcePath} ${Date.now()} */`, + 'utf-8', + ), + ) + } + await Promise.all(promises) + return { + code: retCode, + map, + } + } + api.transform({ test: SOURCE_FILE_RE }, extract) + // Rsbuild's order: post installs an enforce: post Rspack loader. Its + // transform context does not expose the incoming map, so located errors + // are explicitly labelled as compiled MDX rather than claiming raw lines. + api.transform( + { test: mdxSourceFilter(mdxExtensions), order: 'post' }, + extract, ) }, } diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index a943c028c..d662c59fd 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -177,6 +177,91 @@ const box = const box = ``` +## Publishing Component Libraries + +Choose one of these build-time contracts. Neither mode permits Devup UI's +compile-time component placeholders to run in a consuming application. + +1. Precompiled library: keep `extractCss: true` (the default), publish the + transformed JavaScript and emitted CSS, and expose a stylesheet that consumers + import. Consumers do not need to transform the already compiled library. +2. Uncompiled library: use `DevupUI({ extractCss: false })` intentionally, as in + `apps/vite-lib`. Every consuming application must install a Devup UI build + plugin and include the library's exact package name so its modules are + transformed during the application's build. + +```ts +// The consuming application's vite.config.ts +export default defineConfig({ + plugins: [DevupUI({ include: ['@acme/components'] })], +}) +``` + +An uncompiled library is not standalone browser JavaScript. Using it without +the consumer transformation runs placeholders and throws +`Cannot run on the runtime`. Do not publish it as precompiled output. + +### Aggregated Library CSS + +Library builds and applications using `build.cssCodeSplit: false` prepare the +actual resolved build inputs and their static/dynamic dependencies before any +generated stylesheet is loaded. MDX still runs after the configured compiler. +Unreachable source files contribute no CSS, and complete styles pass through +Vite's normal CSS processing and optimizer in their original import positions. +Ordinary split application builds and development behavior are unchanged. + +If a public bundler hook cannot expose a complete transformed graph, the build +fails with the responsible source/stylesheet and a supported alternative instead +of emitting incomplete CSS. Remaining computed imports must use literal imports +or `import.meta.glob`; plugins must expose additional inputs before the first +generated stylesheet loads. A source transform that awaits generated CSS can +form a circular wait, so unsettled public loads fail after 30 seconds; an +exceptionally slow healthy transform can also reach that limit. + +Some split libraries with plugin-emitted entries produce a standalone generated +stylesheet that cannot retain optimizer output under the shared-sheet contract. +These builds fail with the final asset name. Use `build.cssCodeSplit: false` for +that library, or publish separately imported, precompiled CSS. The real-build +matrix is qualified with Vite 8.3.1 / Rolldown 1.2.10; no private adapter or +bundler version pin is required. + +## MDX Selection And Compiled-Source Guards + +`mdxExtensions` defaults to `['.mdx']`. Add literal extensions such as +`['.mdx', '.md', '.mdown']` only when your project compiler handles them as MDX. +The same list controls post-compiler extraction, graph discovery and numbering. +Compiled output is JavaScript with JSX under its real filename, never renamed +to a JavaScript extension. Configure the MDX compiler separately; Devup runs +after it. Ordinary JavaScript/TypeScript extensions keep their existing handling. + +`DevupUI()` returns the main `devup-ui` plugin, the unchanged +`devup-ui:restore-forwarded-css` plugin, and three independent companions: +`devup-ui:mdx`, `devup-ui:aggregate-css-guard`, and +`devup-ui:compiled-source-guard`. The main plugin remains `enforce: 'pre'`; +the MDX companion is `enforce: 'post'` and only transforms source. + +During builds with extraction enabled, the compiled-source guard inspects final +code through Vite's public parser and resolved module graph. An extension outside +the JavaScript/TypeScript and MDX lists fails with its module, used import and +`mdxExtensions` remedy when a compile-time export is definitely referenced by +name, static namespace/member access or destructuring. Runtime helpers such as +`getTheme`, `setTheme`, `initTheme`, `useTheme` and `ThemeScript`, and unused +imports, are allowed. An active compatibility alias can be disabled with +`importAliases[package] = false` when runtime use is intended. + +Whole namespace/require-result objects and dynamic imports are opaque and are +not rejected; compile-time exports reached only that way may still throw +`Cannot run on the runtime` when rendered. Let Devup transform the extension +instead. Ordinary JS/TS excluded by `include`, and intentional +`extractCss: false` publishing, are not broadened by this guard. + +The complete forwarding graph is finalized by build hooks, not available as an +equivalent completed graph in the dev server. This guard is build-only, including +build-watch compilations; unselected extensions in dev may therefore retain +placeholders and throw when rendered. Add the extension to `mdxExtensions` and +configure its compiler for both dev and build. Vite's native alias format has +no `false` target. + ## Custom Shorthands Custom shorthands are build-plugin options, not theme tokens. Every target diff --git a/packages/vite-plugin/__regression__/aggregate-cycle.bun.ts b/packages/vite-plugin/__regression__/aggregate-cycle.bun.ts new file mode 100644 index 000000000..b172089f5 --- /dev/null +++ b/packages/vite-plugin/__regression__/aggregate-cycle.bun.ts @@ -0,0 +1,45 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' +import { build } from 'vite' + +import { DevupUI } from '../src/plugin' + +it('rejects a real public source-load/generated-CSS circular wait with a located alternative', async () => { + const root = realpathSync + .native(mkdtempSync(join(tmpdir(), 'devup-load-cycle-'))) + .replaceAll('\\', '/') + const entry = `${root}/entry.js` + const sheet = `${root}/df/devup-ui/devup-ui.css` + writeFileSync( + entry, + "import {css} from '@devup-ui/react'; export const style=css({color:'red'});", + ) + try { + const pending = build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + DevupUI(), + { + name: 'source-awaiting-generated-css', + enforce: 'post', + async transform(_source, id) { + if (id === entry) await this.load({ id: sheet }) + }, + }, + ], + build: { write: false, lib: { entry, formats: ['es'] } }, + }) + await expect(pending).rejects.toThrow( + `${entry}:1:1: [devup-ui] aggregate CSS asset`, + ) + await expect(pending).rejects.toThrow('preparation cycle') + await expect(pending).rejects.toThrow('separate imported asset') + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 40000) diff --git a/packages/vite-plugin/src/__tests__/aggregate-build.test.ts b/packages/vite-plugin/src/__tests__/aggregate-build.test.ts new file mode 100644 index 000000000..1ac53cebe --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-build.test.ts @@ -0,0 +1,244 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { basename, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { build } from 'vite' + +import { DevupUI } from '../plugin' + +const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) +const mdx = await import( + pathToFileURL( + createRequire(landing.resolve('@mdx-js/loader')).resolve('@mdx-js/mdx'), + ).href +) + +for (const singleCss of [false, true]) { + for (const delayed of ['first', 'second']) { + for (const configuration of ['library', 'split-library', 'aggregate-app']) { + it(`completes ${configuration} CSS when singleCss=${singleCss} and ${delayed} transforms last`, async () => { + // Given: real inputs, shared sheets, JS/CSS cycles, late compiler edges. + resetBuildState() + const root = realpathSync + .native(mkdtempSync(join(tmpdir(), 'devup-aggregate-'))) + .replaceAll('\\', '/') + const files = { + 'package.json': '{"name":"aggregate-fixture","type":"module"}', + 'entry.js': + "import './red.css'; export {first} from './first.js'; export {second} from './second.js'; export const lazy=()=>import('./dynamic.js'); globalThis.fixtureLazy=lazy; import 'virtual:aggregate'; export {default as Page} from './page.mdx'; import './blue.css';", + 'other.js': + "export {second} from './second.js'; export {first} from './first.js';", + 'first.js': + "import {css,globalCss} from '@devup-ui/react'; import './cycle-a.js'; export {staticStyle} from './style.css.ts'; globalCss({'.x':{color:'green'}}); export const first=css({color:'red'});", + 'second.js': + "import {css,globalCss} from '@devup-ui/react'; globalCss({body:{color:'blue'}}); export const second=css({color:'blue'});", + 'style.css.ts': + "import {css} from '@devup-ui/react'; import {color} from './constants.ts'; export const staticStyle=css({background:color});", + 'constants.ts': "export const color='purple';", + 'dynamic.js': + "import {css} from '@devup-ui/react'; export const dynamic=css({background:'orange'});", + 'injected.js': + "import {css} from '@devup-ui/react'; export const injected=css({background:'yellow'});", + 'plugin-entry.js': + "import {css} from '@devup-ui/react'; export const emitted=css({background:'pink'});", + 'cycle-a.js': "import './cycle-b.js'; export const a='a';", + 'cycle-b.js': "import './cycle-a.js'; export const b='b';", + 'page.mdx': + 'import {Box} from \'@devup-ui/react\'\n\n# Compiled\n\n', + 'unreachable.js': + "import {globalCss} from '@devup-ui/react'; globalCss({body:{color:'magenta'}});", + 'red.css': '@import "./local.css"; @layer b{.x{color:red}}', + 'local.css': + '@import "./cycle.css"; .asset{background:url(./image.svg?no-inline)}', + 'cycle.css': '@import "./local.css"; .local{display:block}', + 'blue.css': '@layer b{.x{color:blue}}', + 'image.svg': + '', + } + for (const [name, source] of Object.entries(files)) { + writeFileSync(join(root, name), source) + } + const transformed = new Set() + const snapshots: string[] = [] + const plugins = DevupUI({ singleCss }) + const originalLoad = plugins[0].load + plugins[0].load = async function (id) { + const result = await originalLoad.call(this, id) + if (/devup-ui(?:-\d+)?\.css$/.test(id)) { + snapshots.push(String(result)) + expect(transformed.has(`${root}/second.js`)).toBe(true) + expect(transformed.has(`${root}/injected.js`)).toBe(true) + expect(transformed.has(`${root}/page.mdx`)).toBe(true) + } + return result + } + try { + // When: the actual optimizer consumes the ordinary generated imports. + const result = await build({ + root, + configFile: false, + logLevel: 'silent', + resolve: { + alias: { + 'react/jsx-runtime': landing.resolve('react/jsx-runtime'), + }, + }, + plugins: [ + { + name: 'aggregate-delay-and-mdx', + enforce: 'pre', + async transform(source, id) { + if (id.endsWith(`/${delayed}.js`)) { + await new Promise((done) => setTimeout(done, 60)) + } + if (id.endsWith('.mdx')) { + return { + code: String( + await mdx.compile({ value: source, path: id }), + ), + map: null, + } + } + }, + }, + plugins, + { + name: 'aggregate-injected-closure', + enforce: 'post', + buildStart() { + if (configuration !== 'split-library') { + this.emitFile({ + type: 'chunk', + id: join(root, 'plugin-entry.js'), + name: 'plugin-entry', + }) + } + }, + resolveId(id) { + if (id === 'virtual:aggregate') return '\0aggregate-companion' + }, + async load(id) { + if (id === '\0aggregate-companion') { + await new Promise((done) => setTimeout(done, 90)) + return 'export const companion=1;' + } + }, + transform(source, id) { + transformed.add(id) + if (id === '\0aggregate-companion') { + return `${source}\nimport ${JSON.stringify(join(root, 'injected.js'))};` + } + }, + }, + ], + build: { + write: false, + minify: false, + cssMinify: 'lightningcss', + cssCodeSplit: configuration === 'split-library', + lib: + configuration === 'aggregate-app' + ? false + : { + entry: { + entry: join(root, 'entry.js'), + other: join(root, 'other.js'), + }, + formats: ['es'], + }, + rolldownOptions: { + input: { + entry: join(root, 'entry.js'), + other: join(root, 'other.js'), + }, + external: ['react/jsx-runtime'], + output: { assetFileNames: 'custom/[name]-[hash][extname]' }, + }, + }, + }) + // Then: complete atoms/base sheet, blue cascade, native CSS processing. + const output = (Array.isArray(result) ? result : [result]).flatMap( + (bundle) => { + if (!('output' in bundle)) + throw new Error('Build returned a watcher') + return bundle.output + }, + ) + const assets = output.filter((item) => item.type === 'asset') + const css = assets + .filter((item) => item.fileName.endsWith('.css')) + .map((item) => String(item.source)) + .join('\n') + expect(snapshots.length).toBeGreaterThan(0) + expect(snapshots.join('\n')).toContain('body{color:blue}') + expect(css).toMatch(/body[^}]*color:#00f/) + for (const color of ['purple', 'orange', '#ff0', '#0ff']) { + expect(css).toContain(color) + } + if (configuration !== 'split-library') expect(css).toContain('pink') + if (configuration !== 'split-library') + expect(css).not.toContain('green') + expect(css).not.toContain('magenta') + expect(css).toMatch(/(?:\.x|body,\.x)\{color:#00f\}/) + const byName = new Map(output.map((item) => [item.fileName, item])) + const entryCss: string[] = [] + const visited = new Set() + function visit(file: string) { + if (visited.has(file)) return + visited.add(file) + const item = byName.get(file) + if (item?.type !== 'chunk') return + for (const dependency of item.imports) visit(dependency) + const metadata = + 'viteMetadata' in item ? item.viteMetadata : undefined + if ( + metadata && + typeof metadata === 'object' && + 'importedCss' in metadata && + metadata.importedCss instanceof Set + ) { + for (const assetName of metadata.importedCss) { + const asset = byName.get(assetName) + if (asset?.type === 'asset') entryCss.push(String(asset.source)) + } + } + } + const entry = output.find( + (item) => + item.type === 'chunk' && item.isEntry && item.name === 'entry', + ) + if (!entry) throw new Error('Entry output missing') + visit(entry.fileName) + if (configuration === 'split-library') { + const winners = [ + ...entryCss.join('\n').matchAll(/([^{}]+)\{color:([^;}]+)/g), + ].filter((match) => match[1].split(',').includes('.x')) + expect(winners.at(-1)?.[2]).toBe('#00f') + } + expect(css).toContain('.local{display:block}') + expect(css).not.toContain('@import') + const image = assets.find((item) => + /custom\/image-[\w-]+\.svg$/.test(item.fileName), + ) + if (!image) throw new Error('Hashed URL asset missing') + expect(css).toContain(basename(image.fileName)) + expect(css).not.toContain('?no-inline') + expect( + assets + .filter((item) => item.fileName.endsWith('.css')) + .every((item) => /^custom\/.+-[\w-]+\.css$/.test(item.fileName)), + ).toBe(true) + expect(transformed.has(`${root}/unreachable.js`)).toBe(false) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, 15000) + } + } +} diff --git a/packages/vite-plugin/src/__tests__/aggregate-css.test.ts b/packages/vite-plugin/src/__tests__/aggregate-css.test.ts new file mode 100644 index 000000000..edbe29bbe --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-css.test.ts @@ -0,0 +1,176 @@ +import { expect, it } from 'bun:test' + +import { createAggregateCssPreparation } from '../aggregate-css' +import { DevupUI } from '../plugin' +import { fixture, module } from './aggregate-fixture' + +it('shares one complete preparation when concurrent sheets use multiple cyclic entries', async () => { + // Given: two roots, a static cycle, dynamic and extensionless virtual edges. + const f = fixture({ first: 'entry', second: 'other' }) + f.modules.set( + 'entry', + module( + "import 'cycle'; export const lazy=()=>import('dynamic')", + ['cycle'], + ['dynamic'], + ), + ) + f.modules.set( + 'cycle', + module("import 'entry'; import 'sheet.css'", ['entry', 'sheet.css']), + ) + f.modules.set('other', module("import 'virtual'", ['virtual'])) + f.modules.set('dynamic', module()) + f.modules.set('virtual', module("import 'injected'", ['injected'])) + f.modules.set('injected', module()) + // When: every first snapshot requests the same environment barrier. + const first = f.preparation.prepare(f.environment, f.context, 'aggregate.css') + const second = f.preparation.prepare(f.environment, f.context, 'numbered.css') + await Promise.all([first, second]) + // Then: exactly the reachable non-CSS closure is ready, without a cycle wait. + expect(first).toBe(second) + expect([...f.loaded].sort()).toEqual([ + 'cycle', + 'dynamic', + 'entry', + 'injected', + 'other', + 'virtual', + ]) +}) + +it('isolates and resets barriers when builds reuse environment objects', async () => { + const f = fixture(['entry']) + const secondEnvironment = {} + f.modules.set('other', module()) + f.preparation.start(secondEnvironment, ['other'], { cssCodeSplit: false }) + const first = f.preparation.prepare(f.environment, f.context, 'first.css') + const second = f.preparation.prepare( + secondEnvironment, + f.context, + 'second.css', + ) + await Promise.all([first, second]) + expect(first).not.toBe(second) + expect([...f.loaded].sort()).toEqual(['entry', 'other']) + f.preparation.start(f.environment, 'other', { cssCodeSplit: false }) + const rebuilt = f.preparation.prepare(f.environment, f.context, 'rebuilt.css') + expect(rebuilt).not.toBe(first) + await rebuilt +}) + +it('leaves ordinary application builds untouched when aggregation is disabled', () => { + const f = fixture() + f.preparation.start(f.environment, 'entry', { cssCodeSplit: true }) + expect( + f.preparation.prepare(f.environment, f.context, 'sheet.css'), + ).toBeUndefined() + expect(f.loaded.size).toBe(0) +}) + +it('uses public external resolution when cached module information lacks isExternal', async () => { + const f = fixture() + f.modules.set( + 'entry', + module("import 'external'; import 'rollup-external'; import 'virtual';", [ + 'external', + 'rollup-external', + 'virtual', + ]), + ) + f.cached.set('external', { ...module(), code: null }) + f.cached.set('rollup-external', { ...module(), isExternal: true }) + f.cached.set('virtual', { ...module(), code: null }) + f.modules.set('virtual', module()) + f.context.resolve = async (id) => + id === 'virtual' ? null : { id, external: id === 'external' } + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + expect([...f.loaded]).toEqual(['entry', 'virtual']) +}) + +it('honors public canonical IDs when resolution redirects a dependency', async () => { + const f = fixture() + f.modules.set('entry', module("import 'alias'", ['alias'])) + f.modules.set('canonical', module()) + f.context.resolve = async (id) => ({ + id: id === 'alias' ? 'canonical' : id, + external: false, + }) + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + expect([...f.loaded]).toEqual(['entry', 'canonical']) +}) + +it('accepts literal and empty-template dynamic imports after transformation', async () => { + const f = fixture() + f.modules.set( + 'entry', + module( + "export const a=()=>import('literal'); export const b=()=>import(`template`)", + [], + ['literal', 'template'], + ), + ) + f.modules.set('literal', module()) + f.modules.set('template', module()) + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + expect([...f.loaded]).toEqual(['entry', 'literal', 'template']) +}) + +it('keeps aggregation out of dev and disabled-extraction build hooks', () => { + const [plugin] = DevupUI({ extractCss: false }) + Reflect.apply(plugin.buildStart, {}, [{ input: 'entry' }]) + expect( + plugin.config.call(undefined, { build: { lib: { entry: 'entry' } } }).build, + ).toBeUndefined() + plugin.closeBundle() +}) + +it('records fallback build inputs without preloading in buildStart', () => { + const [plugin] = DevupUI() + const f = fixture() + Reflect.apply(plugin.buildStart, f.context, [{ input: 'entry' }]) + expect(f.loaded.size).toBe(0) + expect( + plugin.config.call(undefined, { build: { cssCodeSplit: false } }).build, + ).toBeUndefined() + plugin.closeBundle() +}) + +it('fails located when a source transform awaits the generated CSS barrier it blocks', async () => { + const expirations: (() => void)[] = [] + const f = fixture() + const preparation = createAggregateCssPreparation((expire) => { + expirations.push(expire) + return () => {} + }) + preparation.start(f.environment, 'entry', { cssCodeSplit: false }) + const entered = Promise.withResolvers() + f.context.load = async () => { + entered.resolve() + await preparation.prepare(f.environment, f.context, 'aggregate.css') + return module() + } + const pending = preparation.prepare(f.environment, f.context, 'aggregate.css') + await entered.promise + for (const expire of expirations) expire() + await expect(pending).rejects.toThrow( + 'entry:1:1: [devup-ui] aggregate CSS asset aggregate.css', + ) + await expect(pending).rejects.toThrow('preparation cycle') +}) + +it('prepares actual plugin-defined entry modules but never cached orphan modules', async () => { + const f = fixture() + f.cached.set('plugin-entry', { ...module(), isEntry: true }) + f.cached.set('orphan', module()) + f.modules.set('plugin-entry', module()) + f.modules.set('orphan', module()) + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + expect([...f.loaded]).toEqual(['entry', 'plugin-entry']) + expect(() => + f.preparation.observe(f.environment, 'plugin-entry'), + ).not.toThrow() + expect(() => f.preparation.observe(f.environment, 'late')).toThrow( + 'late:1:1: [devup-ui] aggregate CSS asset aggregate.css', + ) +}) diff --git a/packages/vite-plugin/src/__tests__/aggregate-errors.test.ts b/packages/vite-plugin/src/__tests__/aggregate-errors.test.ts new file mode 100644 index 000000000..c73d76473 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-errors.test.ts @@ -0,0 +1,240 @@ +import { expect, it } from 'bun:test' + +import { AggregateCssError } from '../aggregate-css' +import { fixture, module } from './aggregate-fixture' + +const failures: readonly { + readonly name: string + readonly setup: (f: ReturnType) => void + readonly file: string + readonly reason: string +}[] = [ + { + name: 'empty inputs', + setup(f) { + f.preparation.start(f.environment, undefined, { cssCodeSplit: false }) + }, + file: 'aggregate.css', + reason: 'input list is empty', + }, + { + name: 'unresolved entry', + setup(f) { + f.context.resolve = async () => null + }, + file: 'entry', + reason: 'entry could not be resolved', + }, + { + name: 'external entry', + setup(f) { + f.context.resolve = async (id) => ({ id, external: 'absolute' }) + }, + file: 'entry', + reason: 'entry could not be resolved', + }, + { + name: 'unresolved dependency', + setup(f) { + f.modules.set('entry', module("import 'missing'", ['missing'])) + f.context.resolve = async (id) => + id === 'entry' ? { id, external: false } : null + }, + file: 'entry', + reason: 'resolution refused missing', + }, + { + name: 'cyclic canonical resolution', + setup(f) { + f.modules.set('entry', module("import 'alias'", ['alias'])) + f.context.resolve = async (id) => ({ + id: id === 'alias' ? 'canonical' : id === 'canonical' ? 'alias' : id, + external: false, + }) + }, + file: 'entry', + reason: 'ID cycle', + }, + { + name: 'null load', + setup(f) { + f.modules.delete('entry') + }, + file: 'entry', + reason: 'null or incomplete', + }, + { + name: 'incomplete code', + setup(f) { + f.modules.set('entry', { ...module(), code: null }) + }, + file: 'entry', + reason: 'null or incomplete', + }, + { + name: 'incomplete static edges', + setup(f) { + Object.defineProperty(f.modules.get('entry'), 'importedIds', { + value: null, + }) + }, + file: 'entry', + reason: 'null or incomplete', + }, + { + name: 'incomplete dynamic edges', + setup(f) { + Object.defineProperty(f.modules.get('entry'), 'dynamicallyImportedIds', { + value: null, + }) + }, + file: 'entry', + reason: 'null or incomplete', + }, + { + name: 'unresolved import graph', + setup(f) { + f.modules.set('entry', module("export * from 'missing'")) + }, + file: 'entry', + reason: 'unresolved import graph', + }, + { + name: 'computed import', + setup(f) { + f.modules.set( + 'entry', + module("const target='late';\nexport const lazy=()=>import(target)"), + ) + }, + file: 'entry', + reason: 'computed dynamic import', + }, + { + name: 'computed template import', + setup(f) { + f.modules.set( + 'entry', + module('export const lazy=(name)=>import(`./${name}.js`)'), + ) + }, + file: 'entry', + reason: 'computed dynamic import', + }, + { + name: 'missing import source', + setup(f) { + f.context.parse = () => ({ + type: 'Program', + body: [{ type: 'ImportExpression' }], + }) + }, + file: 'entry', + reason: 'incomplete dynamic import', + }, + { + name: 'unknown dynamic source', + setup(f) { + f.context.parse = () => ({ + type: 'Program', + body: [{ type: 'ImportExpression', source: { type: 'Identifier' } }], + }) + }, + file: 'entry', + reason: 'computed dynamic import', + }, + { + name: 'null parser', + setup(f) { + f.context.parse = () => null + }, + file: 'entry', + reason: 'incomplete module information', + }, + { + name: 'incomplete parser', + setup(f) { + f.context.parse = () => ({}) + }, + file: 'entry', + reason: 'incomplete module information', + }, + { + name: 'invalid parser root', + setup(f) { + f.context.parse = () => ({ type: 'Literal' }) + }, + file: 'entry', + reason: 'incomplete module information', + }, + ...[new Error('refusal'), 'non-error refusal'].flatMap((cause) => [ + { + name: `load rejection ${String(cause)}`, + setup(f: ReturnType) { + f.context.load = async () => { + throw cause + } + }, + file: 'entry', + reason: 'public load refused', + }, + { + name: `resolve rejection ${String(cause)}`, + setup(f: ReturnType) { + f.context.resolve = async () => { + throw cause + } + }, + file: 'entry', + reason: 'public resolution refused', + }, + { + name: `parse rejection ${String(cause)}`, + setup(f: ReturnType) { + f.context.parse = () => { + throw cause + } + }, + file: 'entry', + reason: 'public parser refused', + }, + ]), +] + +for (const failure of failures) { + it(`fails closed with a located asset/config alternative when ${failure.name}`, async () => { + // Given: a public seam refusing a complete transformed closure. + const f = fixture() + failure.setup(f) + // When: a generated snapshot attempts preparation. + try { + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + throw new Error('Expected aggregate preparation failure') + } catch (error) { + // Then: no silent partial success; responsible file and alternatives survive. + expect(error).toBeInstanceOf(AggregateCssError) + if (!(error instanceof AggregateCssError)) throw error + expect(error.file).toBe(failure.file) + expect(error.asset).toBe('aggregate.css') + expect(error.message).toContain(failure.reason) + expect(error.message).toContain('build.cssCodeSplit=true') + expect(error.message).toContain('separate imported asset') + if (failure.name === 'computed import') { + expect(error.line).toBe(2) + expect(error.column).toBe(23) + } + } + }) +} + +it('locates an unsafe standalone asset even when a plugin supplied all entry roots', () => { + const f = fixture() + f.preparation.start(f.environment, undefined, { cssCodeSplit: false }) + f.preparation.checkAsset(f.environment, { fileName: 'image.svg' }) + expect(() => + f.preparation.checkAsset(f.environment, { + name: 'devup-ui.css', + fileName: 'custom/devup-ui-hash.css', + }), + ).toThrow('custom/devup-ui-hash.css:1:1: [devup-ui] aggregate CSS asset') +}) diff --git a/packages/vite-plugin/src/__tests__/aggregate-fixture.ts b/packages/vite-plugin/src/__tests__/aggregate-fixture.ts new file mode 100644 index 000000000..69cc7a937 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-fixture.ts @@ -0,0 +1,47 @@ +import { parseAst } from 'vite' + +import { + createAggregateCssPreparation, + type CssClosureContext, +} from '../aggregate-css' + +type Module = NonNullable> + +export function module( + code = '', + importedIds: readonly string[] = [], + dynamicallyImportedIds: readonly string[] = [], +): Module { + return { code, importedIds, dynamicallyImportedIds } +} + +export function fixture( + entries: + string | readonly string[] | Record | undefined = 'entry', +) { + const environment = {} + const preparation = createAggregateCssPreparation() + const modules = new Map([['entry', module()]]) + const cached = new Map() + const loaded = new Set() + const context: CssClosureContext = { + getModuleIds() { + return cached.keys() + }, + getModuleInfo(id) { + return cached.get(id) ?? null + }, + async resolve(id) { + return { id, external: false } + }, + async load({ id }) { + loaded.add(id) + return modules.get(id) ?? null + }, + parse(code) { + return parseAst(code) + }, + } + preparation.start(environment, entries, { lib: { entry: 'entry' } }) + return { environment, preparation, context, modules, cached, loaded } +} diff --git a/packages/vite-plugin/src/__tests__/aggregate-split-asset.test.ts b/packages/vite-plugin/src/__tests__/aggregate-split-asset.test.ts new file mode 100644 index 000000000..73ceadb71 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-split-asset.test.ts @@ -0,0 +1,77 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { build } from 'vite' + +import { DevupUI } from '../plugin' + +for (const singleCss of [false, true]) { + for (const delayed of ['first', 'second']) { + it(`rejects unsafe native standalone CSS when singleCss=${singleCss} and ${delayed} is delayed`, async () => { + resetBuildState() + const root = realpathSync + .native(mkdtempSync(join(tmpdir(), 'devup-split-asset-'))) + .replaceAll('\\', '/') + const files = { + 'package.json': '{"name":"split-fixture","type":"module"}', + 'entry.js': + "import './red.css'; export {first} from './first.js'; export {second} from './second.js'; import './blue.css';", + 'other.js': + "export {first} from './first.js'; export {second} from './second.js';", + 'first.js': + "import {css,globalCss} from '@devup-ui/react'; globalCss({'.x':{color:'green'}}); export const first=css({color:'red'});", + 'second.js': + "import {css,globalCss} from '@devup-ui/react'; globalCss({body:{color:'blue'}}); export const second=css({color:'blue'});", + 'emitted.js': + "import {css} from '@devup-ui/react'; export const third=css({background:'pink'});", + 'red.css': '@layer b{.x{color:red}}', + 'blue.css': '@layer b{.x{color:blue}}', + } + for (const [name, source] of Object.entries(files)) + writeFileSync(join(root, name), source) + try { + const pending = build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + DevupUI({ singleCss }), + { + name: 'actual-plugin-entry', + buildStart() { + this.emitFile({ type: 'chunk', id: `${root}/emitted.js` }) + }, + async transform(_code, id) { + if (id.endsWith(`/${delayed}.js`)) + await new Promise((done) => setTimeout(done, 60)) + }, + }, + ], + build: { + write: false, + cssCodeSplit: true, + cssMinify: 'lightningcss', + lib: { + entry: { entry: `${root}/entry.js`, other: `${root}/other.js` }, + formats: ['es'], + }, + rolldownOptions: { + output: { assetFileNames: 'custom/[name]-[hash][extname]' }, + }, + }, + }) + await expect(pending).rejects.toThrow( + /aggregate CSS asset custom\/devup-ui-[\w-]+\.css/, + ) + await expect(pending).rejects.toThrow( + 'build.cssCodeSplit=false for this library', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + } +} diff --git a/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts b/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts new file mode 100644 index 000000000..195ed0882 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts @@ -0,0 +1,138 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { buildStaticImportGraph } from '@devup-ui/plugin-utils' +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { build } from 'vite' + +import { DevupUI } from '../plugin' + +function unknownFixture() { + resetBuildState() + const root = realpathSync + .native(mkdtempSync(join(tmpdir(), 'devup-unknown-'))) + .replaceAll('\\', '/') + const entry = `${root}/entry.js` + const unknown = `${root}/unknown.js` + writeFileSync( + entry, + "import {css} from '@devup-ui/react'; export const style=css({color:'red'}); export {lazy} from './unknown.js';", + ) + writeFileSync( + unknown, + 'export const lazy=(target)=>import(/* @vite-ignore */ target);', + ) + return { root, entry, unknown } +} + +it('rejects an unknowable dynamic closure with the responsible source and asset alternative', async () => { + const { root, entry, unknown } = unknownFixture() + try { + await expect( + build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [DevupUI()], + build: { write: false, lib: { entry, formats: ['es'] } }, + }), + ).rejects.toThrow(`${unknown}:1:29: [devup-ui] aggregate CSS asset`) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('builds the same module when the documented nonaggregating application alternative is used', async () => { + const { root, entry } = unknownFixture() + try { + const result = await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [DevupUI()], + build: { + write: false, + lib: false, + cssCodeSplit: true, + rolldownOptions: { input: entry }, + }, + }) + const output = (Array.isArray(result) ? result : [result]).flatMap( + (bundle) => { + if (!('output' in bundle)) throw new Error('Build returned a watcher') + return bundle.output + }, + ) + expect(output.some((item) => item.fileName.endsWith('.css'))).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('rejects a real late plugin-emitted module absent from the static source graph', async () => { + resetBuildState() + const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared-api', + ) + mkdirSync(parent, { recursive: true }) + const root = realpathSync + .native(mkdtempSync(join(parent, 'devup-late-'))) + .replaceAll('\\', '/') + mkdirSync(`${root}/src`) + const entry = `${root}/src/entry.js` + const late = `${root}/late.js` + writeFileSync( + entry, + "import {css} from '@devup-ui/react'; export const style=css({color:'red'});", + ) + writeFileSync( + late, + "import {css} from '@devup-ui/react'; export const late=css({color:'blue'});", + ) + const graph = buildStaticImportGraph(`${root}/src`, undefined, { cwd: root }) + expect( + graph.fileSet.has(late.replaceAll('/', '\\')) || graph.fileSet.has(late), + ).toBe(false) + let emitted = false + try { + await expect( + build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + DevupUI({ sourceDirs: ['src'] }), + { + name: 'late-module-after-css-barrier', + transform(_code, id) { + if (!emitted && /devup-ui.*\.css(?:$|\?)/.test(id)) { + emitted = true + this.emitFile({ type: 'chunk', id: late, name: 'late' }) + } + }, + }, + ], + build: { + write: false, + cssCodeSplit: false, + lib: { entry, formats: ['es'] }, + }, + }), + ).rejects.toThrow(`${late}:1:1: [devup-ui] aggregate CSS asset`) + expect(emitted).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/vite-plugin/src/__tests__/core-regression.test.ts b/packages/vite-plugin/src/__tests__/core-regression.test.ts new file mode 100644 index 000000000..8210cffc5 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/core-regression.test.ts @@ -0,0 +1,256 @@ +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { ConfigLoadError } from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { afterEach, beforeEach, expect, it, spyOn } from 'bun:test' +import { createServer, type ViteDevServer } from 'vite' + +import { DevupUI } from '../plugin' + +let root: string +let server: ViteDevServer | undefined +const temp = process.env.DEVUP_PLUGIN_TEST_TMP ?? tmpdir() + +beforeEach(async () => { + wasm.resetBuildState() + root = await realpath(await mkdtemp(join(temp, 'vite-'))) + await writeFile(join(root, 'tsconfig.json'), '{}') +}) + +afterEach(async () => { + await server?.close() + server = undefined + await rm(root, { recursive: true, force: true }) +}) + +it.each(['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs'])( + 'extracts modern .%s modules under the actual Vite root', + async (extension) => { + await mkdir(join(root, 'src')) + await writeFile( + join(root, 'src', `main.${extension}`), + "import {css} from '@devup-ui/react'; export const color = css({bg:'red'});", + ) + server = await createServer({ + root, + tsconfig: join(root, 'tsconfig.json'), + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, hmr: false }, + plugins: [DevupUI({ singleCss: true })], + }) + + const result = await server.transformRequest(`/src/main.${extension}`) + + expect(result?.code).not.toContain('css({') + expect(result?.code).toContain('devup-ui.css') + expect(wasm.getCss(null, false)).toContain('red') + }, +) + +it('clears declarations when a real theme file becomes empty', async () => { + await writeFile( + join(root, 'devup.json'), + JSON.stringify({ theme: { colors: { default: { removed: 'red' } } } }), + ) + const [plugin] = DevupUI() + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, hmr: false, watch: null }, + plugins: [plugin], + }) + await server.watcher.close() + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).toContain( + 'removed', + ) + await writeFile(join(root, 'devup.json'), '{}') + + await plugin.watchChange(join(root, 'devup.json')) + + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).not.toContain( + 'removed', + ) +}) + +it('preserves the prior theme on a malformed watched config', async () => { + await writeFile( + join(root, 'devup.json'), + JSON.stringify({ theme: { colors: { default: { preserved: 'red' } } } }), + ) + const [plugin] = DevupUI() + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, hmr: false, watch: null }, + plugins: [plugin], + }) + await server.watcher.close() + const prior = await readFile(join(root, 'df/theme.d.ts'), 'utf-8') + const priorCss = wasm.getCss(null, false) + expect(server.watcher.getWatched()).toEqual({}) + await writeFile(join(root, 'devup.json'), '{') + const diagnostic = spyOn(console, 'error').mockImplementation(() => {}) + try { + console.error('[unrelated] config diagnostic from another server') + await plugin.watchChange(join(root, 'devup.json')) + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).toBe(prior) + const targeted = diagnostic.mock.calls.find( + ([message]) => + message === + `[devup-ui] theme update failed at ${join(root, 'devup.json')}`, + ) + expect(targeted?.[0]).toBe( + `[devup-ui] theme update failed at ${join(root, 'devup.json')}`, + ) + const error = targeted?.[1] + expect(error).toBeInstanceOf(ConfigLoadError) + if (!(error instanceof ConfigLoadError)) + throw new TypeError('Expected config load diagnostic') + expect(error.file).toBe(join(root, 'devup.json')) + expect(error.cause).toBeInstanceOf(SyntaxError) + expect(wasm.getCss(null, false)).toBe(priorCss) + expect(priorCss).toContain('--preserved') + } finally { + diagnostic.mockRestore() + } +}) + +it('rejects a malformed setup config rather than clearing it silently', async () => { + await writeFile(join(root, 'devup.json'), '{') + + await expect( + createServer({ root, configFile: false, plugins: [DevupUI()] }), + ).rejects.toThrow(join(root, 'devup.json')) +}) + +it.each(['client', 'ssr'])( + 'covers all roots and the included closure for environment %s', + async (consumer) => { + for (const dir of ['src', 'app', 'custom', 'node_modules/foo.bar/nested']) + await mkdir(join(root, dir), { recursive: true }) + await writeFile( + join(root, 'node_modules/foo.bar/package.json'), + JSON.stringify({ + name: 'foo.bar', + exports: { + browser: './browser.mjs', + node: './node.mjs', + import: './import.mjs', + }, + }), + ) + await writeFile( + join(root, 'node_modules/foo.bar/browser.mjs'), + "export {color} from './nested/colors.mts'", + ) + await writeFile( + join(root, 'node_modules/foo.bar/nested/colors.mts'), + "export const color = 'red'", + ) + await writeFile( + join(root, 'node_modules/foo.bar/import.mjs'), + "export const color = 'green'", + ) + await writeFile( + join(root, 'node_modules/foo.bar/node.mjs'), + "export const color = 'blue'", + ) + await writeFile( + join(root, 'src/style.mts'), + "import {color} from 'foo.bar'; import {css} from '@devup-ui/react'; export const style = css({bg:color})", + ) + await writeFile(join(root, 'app/main.ts'), "import '../src/style.mts'") + await writeFile(join(root, 'custom/main.cts'), "import '../src/style.mts'") + server = await createServer({ + root, + configFile: false, + tsconfig: join(root, 'tsconfig.json'), + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, hmr: false }, + resolve: { conditions: ['browser', 'development|production'] }, + ssr: { resolve: { conditions: ['node', 'development|production'] } }, + build: { + ssr: consumer === 'ssr', + rollupOptions: { + input: { app: 'app/main.ts', custom: 'custom/main.cts' }, + }, + }, + plugins: [ + DevupUI({ + sourceDirs: ['src', 'app', 'custom'], + include: ['foo.bar'], + atomHoist: 2, + singleCss: true, + }), + ], + }) + + const result = + consumer === 'ssr' + ? await server.environments.ssr.transformRequest('/src/style.mts') + : await server.transformRequest('/src/style.mts') + + expect(result?.code).not.toContain('css({') + expect(wasm.getCss(null, false)).toContain( + consumer === 'ssr' ? 'blue' : 'red', + ) + expect(wasm.getCss(null, false)).not.toContain( + consumer === 'ssr' ? 'red' : 'blue', + ) + }, +) + +it('extracts MDX only after the installed MDX compiler has compiled raw markdown', async () => { + const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), + ) + const loader = landing.resolve('@mdx-js/loader') + const mdx = await import( + pathToFileURL(createRequire(loader).resolve('@mdx-js/mdx')).href + ) + await writeFile( + join(root, 'page.mdx'), + 'import {Box} from \'@devup-ui/react\'\n\n# Title\n\n', + ) + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + resolve: { + alias: { 'react/jsx-runtime': landing.resolve('react/jsx-runtime') }, + }, + server: { middlewareMode: true, hmr: false }, + plugins: [ + DevupUI({ singleCss: true }), + { + name: 'actual-mdx-compiler', + enforce: 'pre', + async transform(source, id) { + if (!id.endsWith('.mdx')) return + const output = await mdx.compile({ value: source, path: id }) + return { code: String(output), map: null } + }, + }, + ], + }) + + const result = await server.transformRequest('/page.mdx') + + expect(result?.code).not.toContain('bg: "red"') + expect(result?.code).toContain('devup-ui.css') + expect(wasm.getCss(null, false)).toContain('red') +}) diff --git a/packages/vite-plugin/src/__tests__/guard-facts.test.ts b/packages/vite-plugin/src/__tests__/guard-facts.test.ts new file mode 100644 index 000000000..597acae06 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/guard-facts.test.ts @@ -0,0 +1,135 @@ +import { expect, it } from 'bun:test' +import { parseAst } from 'vite' + +import { compiledFacts } from '../../../plugin-utils/src/guard-facts' + +it.each([ + [ + 'member object', + 'import {css} from "library"; export const n=css({color:"red"}).length', + ], + [ + 'parameter default', + 'import {css} from "library"; export function f({x=css({color:"red"})}={}){return x}', + ], + [ + 'array default', + 'import {css} from "library"; const [x=css({color:"red"})]=[]', + ], + [ + 'pattern default', + 'import {css} from "library"; const {x=css({color:"red"})}={}', + ], + [ + 'computed destructure', + 'import * as DU from "library"; const {["css"]:style}=DU', + ], + ['direct require', 'export const n=require("library").css({color:"red"})'], + [ + 'assigned require', + 'const DU=require("library"); export const n=DU.css({color:"red"})', + ], + [ + 'require destructure', + 'const {css:style}=require("library"); export const n=style({color:"red"})', + ], +] as const)('records definite css when %s executes', (_name, source) => { + const ast = parseAst(source) + + const facts = compiledFacts(ast) + + expect( + facts.references.map(({ request, ids }) => ({ request, ids })), + ).toContainEqual({ request: 'library', ids: ['css'] }) +}) + +it.each([ + 'const DU=require("library"); export const value=DU', + 'export const value=require("library")', + 'export const value=import("library")', + 'export function f(require){return require("library").css()}', + 'const require=other; export const value=require("library").css()', + 'export const value=require(name).css()', + 'import * as DU from "library"; export function f({css}={}){return css()}', +] as const)( + 'keeps opaque or shadowed source unclassified when %s', + (source) => { + const ast = parseAst(source) + + const facts = compiledFacts(ast) + + expect(facts.references).toEqual([]) + }, +) + +it('resolves static namespace references while preserving lexical shadowing and opaque uses', () => { + const ast = parseAst(`import * as DU from 'library'; + import {Box as B, getTheme} from 'library'; + export function shadow(DU, {Box:B}) { return [DU.css(), B]; } + function hoisted() { if (false) { var B; } return B; } + const C = class DU { method() { return DU.css(); } static { const B = 1; } }; + const escaped = DU; + const {css: style} = DU; + let picked; ({keyframes: picked} = DU); + const read = [DU.getTheme, DU['css'], B, getTheme]; + let name; const computed = DU[name]; + export default read;`) + const facts = compiledFacts(ast) + expect(facts.references.map((reference) => reference.ids)).toEqual([ + ['css'], + ['keyframes'], + ['getTheme'], + ['css'], + ['Box'], + ['getTheme'], + ]) +}) + +it('retains actual forwarding names and local-export precedence for a bundler to resolve', () => { + const facts = compiledFacts( + parseAst(`import styled from 'alias'; + import {Box as B} from 'library'; + export {styled as Named, B as Widget}; + export {css as cls} from 'library'; + export * from 'star'; + export * as Namespace from 'library'; + export const local = 1, [first, ...rest] = []; + export class LocalClass {} + export default styled;`), + ) + expect([...facts.exports]).toEqual([ + ['Named', { request: 'alias', ids: ['default'] }], + ['Widget', { request: 'library', ids: ['Box'] }], + ['cls', { request: 'library', ids: ['css'] }], + ['Namespace', { request: 'library', ids: [] }], + ['local', undefined], + ['first', undefined], + ['rest', undefined], + ['LocalClass', undefined], + ['default', { request: 'alias', ids: ['default'] }], + ]) + expect(facts.stars).toEqual(['star']) +}) + +it('visits parameter defaults, catches, loop scopes and function expressions without guessing binding names', () => { + const ast = parseAst(`import {css} from 'library'; + const local = function self({color: c = 1}, ...args) { return self; }; + try { throw 1; } catch ({message: css}) { css(); } + for (let css of []) { css(); } + for (let css in {}) { css(); } + for (let css = 0; css < 1; css++) {} + switch (1) { case 1: { let css = 0; } } + export const result = css({color:'red'});`) + expect( + compiledFacts(ast).references.map((reference) => reference.ids), + ).toEqual([['css']]) +}) + +it('ignores data and empty public AST values rather than treating strings as imports', () => { + expect(compiledFacts(undefined).references).toEqual([]) + expect( + compiledFacts( + parseAst(`export default "import {Box} from '@devup-ui/react'"`), + ).references, + ).toEqual([]) +}) diff --git a/packages/vite-plugin/src/__tests__/mdx-extensions.test.ts b/packages/vite-plugin/src/__tests__/mdx-extensions.test.ts new file mode 100644 index 000000000..3a50e49c2 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/mdx-extensions.test.ts @@ -0,0 +1,191 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { getCss, resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { build, type Plugin } from 'vite' + +import { DevupUI } from '../plugin' + +const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) +const compiler: { + compile( + input: { readonly value: string; readonly path: string }, + options: { readonly format: 'mdx' }, + ): Promise<{ readonly value: string }> +} = await import( + pathToFileURL( + createRequire(landing.resolve('@mdx-js/loader')).resolve('@mdx-js/mdx'), + ).href +) +const devup = landing.resolve('@devup-ui/react') + +async function bundle( + source: string, + options: { + extension?: string + selected?: boolean + compilerFirst?: boolean + } = {}, +) { + resetBuildState() + const root = mkdtempSync(join(tmpdir(), 'devup-mdx-vite-')) + const extension = options.extension ?? '.mdown' + const filename = join(root, `page${extension}`) + writeFileSync(filename, source) + writeFileSync( + join(root, 'forward.js'), + 'export {Box as Widget} from "@devup-ui/react"', + ) + writeFileSync(join(root, 'barrel.js'), 'export * from "./forward.js"') + writeFileSync( + join(root, 'ordinary.js'), + 'export function Box(){return "div"}', + ) + const mdx: Plugin = { + name: 'project-mdx', + async transform(code, id) { + if (id === filename.replaceAll('\\', '/')) + return { + code: ( + await compiler.compile({ value: code, path: id }, { format: 'mdx' }) + ).value, + map: null, + } + }, + } + const plugins = DevupUI({ + singleCss: true, + mdxExtensions: options.selected ? ['.mdx', '.mdown'] : undefined, + }) + try { + await build({ + root, + configFile: false, + logLevel: 'silent', + resolve: { alias: { '@devup-ui/react': devup } }, + plugins: options.compilerFirst ? [mdx, plugins] : [plugins, mdx], + build: { + write: false, + minify: false, + rolldownOptions: { + input: filename, + external: ['react', 'react/jsx-runtime'], + }, + }, + }) + return getCss(null, false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +it.each([false, true])( + 'extracts custom MDX with equal CSS when compilerFirst=%s', + async (compilerFirst) => { + const source = 'import {Box} from "@devup-ui/react"\n\n' + const expected = await bundle(source, { + extension: '.mdx', + selected: true, + compilerFirst, + }) + expect(expected).toContain('red') + expect(await bundle(source, { selected: true, compilerFirst })).toBe( + expected, + ) + }, +) + +it.each([ + ['used', 'import {Box} from "@devup-ui/react"\n\n', true], + ['unused', 'import {Box} from "@devup-ui/react"\n\n# Plain', false], + [ + 'runtime', + 'import {getTheme} from "@devup-ui/react"\n\nexport const theme = getTheme\n\n# Plain', + false, + ], + [ + 'member', + 'import * as DU from "@devup-ui/react"\n\nexport const style = DU.css({color:"red"})\n\n# Plain', + true, + ], + [ + 'destructure', + 'import * as DU from "@devup-ui/react"\n\nexport const {css} = DU\n\n# Plain', + true, + ], + [ + 'opaque', + 'import * as DU from "@devup-ui/react"\n\nexport const namespace = DU\n\n# Plain', + false, + ], + [ + 'forwarded Widget', + 'import {Widget} from "./barrel.js"\n\n', + true, + ], + ['ordinary Box', 'import {Box} from "./ordinary.js"\n\n', false], + [ + 'member executable', + 'import {css} from "@devup-ui/react"\n\nexport const n=css({color:"red"}).length', + true, + ], + [ + 'parameter executable', + 'import {css} from "@devup-ui/react"\n\nexport function f({x=css({color:"red"})}={}){return x}', + true, + ], + [ + 'pattern executable', + 'import {css} from "@devup-ui/react"\n\nexport const {x=css({color:"red"})}={}', + true, + ], + [ + 'computed destructure', + 'import * as DU from "@devup-ui/react"\n\nexport const {["css"]:style}=DU', + true, + ], + [ + 'require member', + 'export const style=require("@devup-ui/react").css({color:"red"})', + true, + ], + [ + 'require namespace', + 'export const DU=require("@devup-ui/react")\n\nexport const style=DU.css({color:"red"})', + true, + ], + [ + 'require destructure', + 'export const {css:style}=require("@devup-ui/react")\n\nexport const n=style({color:"red"})', + true, + ], + [ + 'require runtime', + 'export const theme=require("@devup-ui/react").getTheme', + false, + ], + [ + 'require opaque', + 'export const namespace=require("@devup-ui/react")', + false, + ], + [ + 'require shadowed', + 'export function f(require){return require("@devup-ui/react").css()}', + false, + ], + ['dynamic opaque', 'export const namespace=import("@devup-ui/react")', false], +])( + 'guards only definitely used compiled exports when %s', + async (_name, source, errors) => { + const action = bundle(source) + if (errors) await expect(action).rejects.toThrow('mdxExtensions') + else expect(await action).not.toContain('{') + }, +) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 4e14b5fd8..0ba21d75f 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -14,7 +14,14 @@ import { spyOn, } from 'bun:test' -import { DevupUI } from '../plugin' +import { DevupUI as createDevupUI } from '../plugin' + +const pluginInstances = new Set[0]>() +function DevupUI(options?: Parameters[0]) { + const plugin = createDevupUI(options) + pluginInstances.add(plugin[0]) + return plugin +} type CodeExtractResult = ReturnType interface ConfigHookMeta { @@ -48,10 +55,18 @@ interface HotUpdateEnvironment { } interface ViteTestPlugin { + environment?: { + name: string + config: { + consumer: 'client' | 'server' + build?: { write?: boolean } + } + } name: string sharedDuringBuild: true enforce: 'pre' apply: () => boolean + closeBundle: () => void config: ( this: { meta?: ConfigHookMeta } | void, userConfig?: ViteConfig, @@ -59,7 +74,7 @@ interface ViteTestPlugin { configResolved: (config?: { command?: 'serve' | 'build' root?: string - plugins?: object[] + plugins?: readonly object[] }) => Promise watchChange: (id: string) => Promise hotUpdate: ( @@ -80,6 +95,12 @@ interface ViteTestPlugin { load: (id: string) => string | undefined transform: ( this: { + getCombinedSourcemap?: () => { + version: number + sources: string[] + names: string[] + mappings: string + } environment?: { name: string config: { @@ -146,7 +167,10 @@ function createPlugins( } function createPlugin(options?: Parameters[0]): ViteTestPlugin { - return createPlugins(options)[0] + const plugin = createPlugins(options)[0] as unknown as ViteTestPlugin & { + transform: { handler: ViteTestPlugin['transform'] } + } + return { ...plugin, transform: plugin.transform.handler } } const ROLLUP_META: ConfigHookMeta = { @@ -186,6 +210,8 @@ let getThemeInterfaceSpy: ReturnType let registerThemeSpy: ReturnType let setDebugSpy: ReturnType let setPrefixSpy: ReturnType +let loadConfigSpy: ReturnType +let graphSpy: ReturnType beforeEach(() => { existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) @@ -206,9 +232,25 @@ beforeEach(() => { registerThemeSpy = spyOn(wasm, 'registerTheme').mockReturnValue(undefined) setDebugSpy = spyOn(wasm, 'setDebug').mockReturnValue(undefined) setPrefixSpy = spyOn(wasm, 'setPrefix').mockReturnValue(undefined) + loadConfigSpy = spyOn(pluginUtils, 'loadDevupConfig').mockImplementation( + async (file: string) => { + if (!fs.existsSync(file)) return {} + return JSON.parse(await fsPromises.readFile(file, 'utf-8')) + }, + ) + graphSpy = spyOn(pluginUtils, 'buildStaticImportGraph').mockReturnValue({ + files: [], + fileSet: new Set(), + staticImports: new Map(), + staticImporters: new Map(), + dynamicImports: new Map(), + dynamicTargets: new Set(), + }) }) afterEach(() => { + for (const plugin of pluginInstances) plugin.closeBundle() + pluginInstances.clear() existsSyncSpy.mockRestore() mkdirSpy.mockRestore() readFileSpy.mockRestore() @@ -221,16 +263,51 @@ afterEach(() => { registerThemeSpy.mockRestore() setDebugSpy.mockRestore() setPrefixSpy.mockRestore() + loadConfigSpy.mockRestore() + graphSpy.mockRestore() }) describe('devupUIVitePlugin', () => { console.error = mock() + it('remaps compiled MDX extraction failures using the compiler map', async () => { + codeExtractSpy.mockImplementation(() => { + throw new Error('page.mdx:1:1: cannot extract') + }) + const plugin = DevupUI()[2] as unknown as Pick + + await expect( + plugin.transform.call( + { + getCombinedSourcemap: () => ({ + version: 3, + sources: ['authored.mdx'], + names: [], + mappings: 'AAGE', + }), + }, + 'compiled', + 'page.mdx', + ), + ).rejects.toThrow('authored.mdx:4:3') + }) + + it('propagates non-MDX extraction failures without remapping', async () => { + codeExtractSpy.mockImplementation(() => { + throw new Error('source failure') + }) + + await expect(createPlugin().transform('code', 'page.ts')).rejects.toThrow( + 'source failure', + ) + }) it('should apply default options', () => { const plugin = createPlugin({}) expect(plugin).toEqual({ name: 'devup-ui', sharedDuringBuild: true, + closeBundle: expect.any(Function), + buildStart: expect.any(Function), config: expect.any(Function), load: expect.any(Function), watchChange: expect.any(Function), @@ -241,6 +318,7 @@ describe('devupUIVitePlugin', () => { apply: expect.any(Function), generateBundle: expect.any(Function), configResolved: expect.any(Function), + configureServer: expect.any(Function), resolveId: expect.any(Function), }) expect(plugin.apply()).toBe(true) @@ -451,20 +529,19 @@ describe('devupUIVitePlugin', () => { }) describe('deterministic file numbering', () => { - let listSourceFilesSpy: ReturnType - let importFileMapSpy: ReturnType - let exportFileMapSpy: ReturnType + const collectNumberedFiles = pluginUtils.collectNumberedFiles + const seedFileMap = wasm.seedFileMap + let collectSpy: ReturnType + let seedFileMapSpy: ReturnType beforeEach(() => { - listSourceFilesSpy = spyOn(pluginUtils, 'listSourceFiles') - importFileMapSpy = spyOn(wasm, 'importFileMap').mockReturnValue(undefined) - exportFileMapSpy = spyOn(wasm, 'exportFileMap').mockReturnValue('{}') + collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + seedFileMapSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) }) afterEach(() => { - listSourceFilesSpy.mockRestore() - importFileMapSpy.mockRestore() - exportFileMapSpy.mockRestore() + collectSpy.mockRestore() + seedFileMapSpy.mockRestore() }) function onlyDirs(...dirs: string[]) { @@ -472,90 +549,181 @@ describe('devupUIVitePlugin', () => { existsSyncSpy.mockImplementation((path: string) => wanted.has(path)) } - it('numbers files by sorted path, not by transform arrival order', async () => { - onlyDirs('src') - // returned out of order on purpose: arrival order must not leak through - listSourceFilesSpy.mockReturnValue([ - '/p/src/z.tsx', - '/p/src/a.tsx', - '/p/src/m.tsx', - ]) + it('numbers the files the scan finds, in the order it returns them', async () => { + onlyDirs('src', 'app') + collectSpy.mockReturnValue(['/p/app/page.tsx', '/p/src/b.tsx']) - await createPlugin({}).configResolved({ root: '/p' }) + await createPlugin({ include: ['@acme/ui'] }).configResolved({ + root: '/p', + }) - expect(importFileMapSpy).toHaveBeenCalledWith({ - '/p/src/a.tsx': 0, - '/p/src/m.tsx': 1, - '/p/src/z.tsx': 2, + expect(collectSpy).toHaveBeenCalledWith({ + roots: [resolve('/p', 'src'), resolve('/p', 'app')], + includeMdx: ['.mdx'], + include: ['@acme/ui'], + cwd: '/p', + needles: expect.arrayContaining([ + '@devup-ui/react', + '@stylexjs/stylex', + ]), }) + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/app/page.tsx', + '/p/src/b.tsx', + ]) }) - it('normalizes windows separators to match vite module ids', async () => { + // A framework plugin resolves the config once per environment. Seeding + // keeps the numbers files hold, so a second pass numbers only new files. + it('seeds again on a second configResolved', async () => { onlyDirs('src') - listSourceFilesSpy.mockReturnValue(['C:\\p\\src\\a.tsx']) + collectSpy.mockReturnValue(['/p/src/a.tsx']) + const plugin = createPlugin({}) - await createPlugin({}).configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) - expect(importFileMapSpy).toHaveBeenCalledWith({ 'C:/p/src/a.tsx': 0 }) + expect(seedFileMapSpy).toHaveBeenCalledTimes(2) }) - // A framework plugin resolves the config once per environment. Re-seeding - // drops the numbers already given to files outside src/ and app/, and since - // the sheet does not reset with the map, the next such file reuses a live - // number and its atoms overwrite the previous owner's. - it('leaves an already-populated map alone on a second configResolved', async () => { - onlyDirs('src') - listSourceFilesSpy.mockReturnValue(['/p/src/a.tsx']) - exportFileMapSpy.mockReturnValue( - '{"/p/src/a.tsx":0,"/monorepo/packages/ui/X.tsx":1}', - ) - + it('leaves numbering alone when there is nothing to number', async () => { + onlyDirs() + collectSpy.mockReturnValue([]) await createPlugin({}).configResolved({ root: '/p' }) + expect(seedFileMapSpy).not.toHaveBeenCalled() + }) + + it('keeps building when the scan fails', async () => { + onlyDirs('src') + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) - expect(importFileMapSpy).not.toHaveBeenCalled() + const warning = spyOn(console, 'warn').mockImplementation(() => {}) + try { + const plugin = createPlugin({}) + await plugin.configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) + expect(seedFileMapSpy).not.toHaveBeenCalled() + expect(warning).toHaveBeenCalledTimes(1) + expect(warning.mock.calls[0]?.[1]).toMatchObject({ + phase: 'seed', + root: '/p', + cause: expect.any(Error), + }) + } finally { + warning.mockRestore() + } }) - it('scans app/ for App Router projects and dedupes across roots', async () => { - onlyDirs('src', 'app') - listSourceFilesSpy.mockImplementation((dir: string) => - dir === resolve('/p', 'app') - ? ['/p/app/page.tsx', '/p/shared.tsx'] - : ['/p/src/b.tsx', '/p/shared.tsx'], - ) + describe('source collection before seeding', () => { + let listSourceFilesSpy: ReturnType + let readFileSyncSpy: ReturnType - await createPlugin({}).configResolved({ root: '/p' }) + beforeEach(() => { + collectSpy.mockImplementation(collectNumberedFiles) + listSourceFilesSpy = spyOn(pluginUtils, 'listSourceFiles') + readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue( + "import { Box } from '@devup-ui/react'", + ) + }) - expect(importFileMapSpy).toHaveBeenCalledWith({ - '/p/app/page.tsx': 0, - '/p/shared.tsx': 1, - '/p/src/b.tsx': 2, + afterEach(() => { + listSourceFilesSpy.mockRestore() + readFileSyncSpy.mockRestore() }) - }) - it.each([ - ['no conventional source dir exists', () => onlyDirs()], - [ - 'the source dir is empty', - () => { - onlyDirs('src') - listSourceFilesSpy.mockReturnValue([]) - }, - ], - ])('leaves numbering alone when %s', async (_name, setup) => { - setup() - await createPlugin({}).configResolved({ root: '/p' }) - expect(importFileMapSpy).not.toHaveBeenCalled() - }) + it('numbers files by sorted path, not by transform arrival order', async () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue([ + '/p/src/z.tsx', + '/p/src/a.tsx', + '/p/src/m.tsx', + ]) - it('keeps building when the scan fails', async () => { - onlyDirs('src') - listSourceFilesSpy.mockImplementation(() => { - throw new Error('scan boom') + await createPlugin({}).configResolved({ root: '/p' }) + + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/src/a.tsx', + '/p/src/m.tsx', + '/p/src/z.tsx', + ]) }) - await createPlugin({}).configResolved({ root: '/p' }) + it('normalizes windows separators to match vite module ids', async () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue(['C:\\p\\src\\a.tsx']) + + await createPlugin({}).configResolved({ root: '/p' }) + + expect(seedFileMapSpy).toHaveBeenCalledWith(['C:/p/src/a.tsx']) + }) + + it('leaves an already-populated map alone on a second configResolved', async () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue(['/p/src/a.tsx']) + const previousMap = wasm.exportFileMap() + const existingMap = { + '/p/src/a.tsx': 0, + '/monorepo/packages/ui/X.tsx': 1, + } + seedFileMapSpy.mockImplementation(seedFileMap) + const plugin = createPlugin({}) + wasm.importFileMap(existingMap) + try { + await plugin.configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) + + expect(seedFileMapSpy).toHaveBeenCalledTimes(2) + expect(JSON.parse(wasm.exportFileMap())).toEqual(existingMap) + } finally { + wasm.importFileMap(JSON.parse(previousMap)) + } + }) + + it('scans app/ for App Router projects and dedupes across roots', async () => { + onlyDirs('src', 'app') + listSourceFilesSpy.mockImplementation((dir: string) => + dir === resolve('/p', 'app') + ? ['/p/app/page.tsx', '/p/shared.tsx'] + : ['/p/src/b.tsx', '/p/shared.tsx'], + ) + + await createPlugin({}).configResolved({ root: '/p' }) + + expect(listSourceFilesSpy).toHaveBeenCalledWith( + resolve('/p', 'src'), + undefined, + { includeMdx: ['.mdx'] }, + ) + expect(listSourceFilesSpy).toHaveBeenCalledWith( + resolve('/p', 'app'), + undefined, + { includeMdx: ['.mdx'] }, + ) + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/app/page.tsx', + '/p/shared.tsx', + '/p/src/b.tsx', + ]) + }) + + it.each([ + ['no conventional source dir exists', () => onlyDirs()], + [ + 'the source dir is empty', + () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue([]) + }, + ], + ])('leaves numbering alone when %s', async (_name, setup) => { + setup() + + await createPlugin({}).configResolved({ root: '/p' }) - expect(importFileMapSpy).not.toHaveBeenCalled() + expect(seedFileMapSpy).not.toHaveBeenCalled() + }) }) }) @@ -706,6 +874,23 @@ describe('devupUIVitePlugin', () => { ) }) + it('starts a build from its own options and ends it at closeBundle', async () => { + const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue( + undefined, + ) + try { + const first = createPlugin({}) + resetSpy.mockClear() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + first.closeBundle() + first.closeBundle() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + } finally { + resetSpy.mockRestore() + } + }) it.each([ { label: 'per-file css', @@ -894,6 +1079,146 @@ describe('devupUIVitePlugin', () => { expect(serverBundle['base.css'].fileName).toBe('base.css') }) + + it('does not forward server css that the client already emits', async () => { + const [plugin, restore] = createPlugins({}) + const base = asset('base.css', 'devup-ui.css') + const file = asset('file.css', 'devup-ui-3.css') + const entry = chunk('entry.js', [ + 'base.css', + 'file.css', + 'server-only.css', + ]) + const serverBundle: Bundle = { + 'base.css': base, + 'file.css': file, + 'server-only.css': asset('server-only.css', 'server-only.css'), + 'entry.js': entry, + } + const clientBase = asset('base.css', 'devup-ui.css') + const clientFile = asset('file.css', 'devup-ui-3.css') + const clientBundle: Bundle = { + 'base.css': clientBase, + 'file.css': clientFile, + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + + await plugin.generateBundle.call(serverEnv, {}, serverBundle) + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + const forwarded = forwardLikePluginRsc(serverBundle) + + expect(base.source).toEqual('base sheet') + expect(file.source).toEqual('file sheet') + expect(clientBase.source).toEqual('base sheet') + expect(clientFile.source).toEqual('file sheet') + expect(entry.viteMetadata?.importedCss).toEqual( + new Set(['base.css', 'file.css', 'server-only.css']), + ) + expect(forwarded.emitted).toEqual([ + 'base.css.devup-forwarded', + 'file.css.devup-forwarded', + 'server-only.css', + ]) + for (const name of forwarded.emitted) { + clientBundle[name] = asset(name, name) + } + restore.generateBundle.handler({}, clientBundle) + + expect(base.fileName).toBe('base.css') + expect(file.fileName).toBe('file.css') + expect(clientBundle['base.css']).toBe(clientBase) + expect(clientBundle['file.css']).toBe(clientFile) + expect(Object.keys(clientBundle).sort()).toEqual([ + 'base.css', + 'file.css', + 'server-only.css', + ]) + expect(entry.viteMetadata?.importedCss).toEqual( + new Set(['base.css', 'file.css', 'server-only.css']), + ) + }) + + it('ignores no-write analysis bundles when tracking server css', async () => { + const [plugin, restore] = createPlugins({}) + const file = asset('file.css', 'devup-ui-3.css') + const entry = chunk('entry.js', ['file.css']) + const serverBundle: Bundle = { + 'file.css': file, + 'entry.js': entry, + } + await plugin.configResolved(rscConfig({})) + await plugin.generateBundle.call( + { + environment: { + name: 'rsc', + config: { consumer: 'server', build: { write: false } }, + }, + }, + {}, + serverBundle, + ) + const clientBundle: Bundle = { + 'file.css': asset('file.css', 'devup-ui-3.css'), + } + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + restore.generateBundle.handler({}, clientBundle) + + expect(entry.viteMetadata?.importedCss).toEqual(new Set(['file.css'])) + expect(file.fileName).toBe('file.css') + expect(file.source).toBe('file sheet') + expect(Object.keys(clientBundle)).toEqual(['file.css']) + }) + + it('keeps server forwarding for a different output file name', async () => { + const [plugin, restore] = createPlugins({}) + const file = asset('devup-ui-3.server.css', 'devup-ui-3.css') + const entry = chunk('entry.js', ['devup-ui-3.server.css']) + const serverBundle: Bundle = { + 'devup-ui-3.server.css': file, + 'entry.js': entry, + } + const clientBundle: Bundle = { + 'devup-ui-3.client.css': asset( + 'devup-ui-3.client.css', + 'devup-ui-3.css', + ), + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + await plugin.generateBundle.call(serverEnv, {}, serverBundle) + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + const forwarded = forwardLikePluginRsc(serverBundle) + for (const name of forwarded.emitted) clientBundle[name] = file + restore.generateBundle.handler({}, clientBundle) + + expect(entry.viteMetadata?.importedCss).toEqual( + new Set(['devup-ui-3.server.css']), + ) + expect(forwarded.emitted).toEqual(['devup-ui-3.server.css']) + expect(file.fileName).toBe('devup-ui-3.server.css') + expect(file.source).toBe('file sheet') + expect(Object.keys(clientBundle).sort()).toEqual([ + 'devup-ui-3.client.css', + 'devup-ui-3.server.css', + ]) + }) + }) + + it('starts a build from its own options and ends it at closeBundle', async () => { + const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue(undefined) + try { + const first = createPlugin({}) + resetSpy.mockClear() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + first.closeBundle() + first.closeBundle() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + } finally { + resetSpy.mockRestore() + } }) it('resolves a stable id during build', async () => { @@ -951,8 +1276,8 @@ describe('devupUIVitePlugin', () => { getThemeInterfaceSpy.mockReturnValue('interface code') getDefaultThemeSpy.mockReturnValue(options.getDefaultTheme) existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return options.existsDevupFile - if (path === 'df') return options.existsDistDir + if (path === resolve('devup.json')) return options.existsDevupFile + if (path === resolve('df')) return options.existsDistDir if (path === resolve('df', 'devup-ui')) return options.existsCssDir if (path === join('df', 'sheet.json')) return options.existsSheetFile if (path === join('df', 'classMap.json')) @@ -963,7 +1288,7 @@ describe('devupUIVitePlugin', () => { const plugin = createPlugin({ singleCss: options.singleCss }) await plugin.configResolved() if (options.existsDevupFile) { - expect(readFileSpy).toHaveBeenCalledWith('devup.json', 'utf-8') + expect(readFileSpy).toHaveBeenCalledWith(resolve('devup.json'), 'utf-8') expect(registerThemeSpy).toHaveBeenCalledWith({}) expect(getThemeInterfaceSpy).toHaveBeenCalledWith( '@devup-ui/react', @@ -974,7 +1299,7 @@ describe('devupUIVitePlugin', () => { 'DevupTheme', ) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -994,7 +1319,7 @@ describe('devupUIVitePlugin', () => { } }) - it('should reset data files when load error', async () => { + it('propagates config load errors without clearing the registered theme', async () => { writeFileSpy.mockResolvedValueOnce(undefined) getThemeInterfaceSpy.mockReturnValue('interface code') existsSyncSpy.mockReturnValue(true) @@ -1002,10 +1327,10 @@ describe('devupUIVitePlugin', () => { throw new Error('error') }) const plugin = createPlugin({}) - await plugin.configResolved() - expect(registerThemeSpy).toHaveBeenCalledWith({}) + await expect(plugin.configResolved()).rejects.toThrow('error') + expect(registerThemeSpy).not.toHaveBeenCalled() expect(writeFileSpy).toHaveBeenCalledWith( - join('df', '.gitignore'), + resolve('df', '.gitignore'), '*', 'utf-8', ) @@ -1019,7 +1344,7 @@ describe('devupUIVitePlugin', () => { const plugin = createPlugin({}) await plugin.watchChange('devup.json') expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -1048,7 +1373,7 @@ describe('devupUIVitePlugin', () => { }) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -1080,7 +1405,7 @@ describe('devupUIVitePlugin', () => { expect(result).toBeUndefined() expect(writeFileSpy).not.toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), expect.any(String), 'utf-8', ) @@ -1111,7 +1436,7 @@ describe('devupUIVitePlugin', () => { ) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -1201,7 +1526,10 @@ describe('devupUIVitePlugin', () => { }) const plugin = createPlugin({}) await plugin.watchChange('devup.json') - expect(console.error).toHaveBeenCalledWith(expect.any(Error)) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining(resolve('devup.json')), + expect.any(Error), + ) }) it('should load', () => { @@ -1326,7 +1654,7 @@ describe('devupUIVitePlugin', () => { { const plugin = createPlugin({ - cssDir: '', + cssDir: '.', }) expect(plugin.resolveId('devup-ui.css')).toEqual(expect.any(String)) } @@ -1573,7 +1901,7 @@ describe('devupUIVitePlugin atom hoisting', () => { await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) expect(computeFileReachSpy).toHaveBeenCalledWith( - expect.objectContaining({ srcDir: resolve('/p', dir) }), + expect.objectContaining({ srcDir: [resolve('/p', dir)] }), ) }) @@ -1587,7 +1915,7 @@ describe('devupUIVitePlugin atom hoisting', () => { await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) expect(computeFileReachSpy).toHaveBeenCalledWith( - expect.objectContaining({ srcDir: resolve('/p', 'src') }), + expect.objectContaining({ srcDir: [] }), ) }) @@ -1639,11 +1967,13 @@ describe('devupUIVitePlugin atom hoisting', () => { expect(setAtomHoistSpy).toHaveBeenCalledWith(2) }) - it('swallows pre-pass errors (atom hoisting stays off)', async () => { + it('fails requested atom setup with root and cause', async () => { buildCanonicalMapSpy.mockImplementation(() => { throw new Error('boom') }) - await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) + await expect( + runConfigResolved({ atomHoist: 2 }, { root: '/p' }), + ).rejects.toThrow(/atom graph setup failed.*boom/) expect(setAtomHoistSpy).not.toHaveBeenCalled() }) diff --git a/packages/vite-plugin/src/__tests__/resolution-cold.mjs b/packages/vite-plugin/src/__tests__/resolution-cold.mjs new file mode 100644 index 000000000..82be99235 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/resolution-cold.mjs @@ -0,0 +1,35 @@ +import { readdir, readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) +const { build } = await import(pathToFileURL(require.resolve('vite')).href) +const { DevupUI } = await import('../../dist/index.mjs') +const root = process.argv[2] +await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [DevupUI()], + build: { + outDir: 'cold', + lib: { + entry: join(root, 'src/main.js'), + fileName: 'main', + formats: ['es'], + }, + minify: false, + }, +}) +const files = await readdir(join(root, 'cold')) +const css = ( + await Promise.all( + files + .filter((name) => name.endsWith('.css')) + .map((name) => readFile(join(root, 'cold', name), 'utf-8')), + ) +).join('\n') +if (!/background:\s*blue/.test(css) || /background:\s*red/.test(css)) + throw new Error(`Invalid cold CSS: ${css}`) +console.info(css) diff --git a/packages/vite-plugin/src/__tests__/resolution-watch-environments.test.ts b/packages/vite-plugin/src/__tests__/resolution-watch-environments.test.ts new file mode 100644 index 000000000..5eac84ac2 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/resolution-watch-environments.test.ts @@ -0,0 +1,114 @@ +import { once } from 'node:events' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { createServer, type ViteDevServer } from 'vite' + +import { DevupUI } from '../plugin' + +it('retains client missing-input watches when SSR transforms the same importer later', async () => { + // Given different condition branches for one importer in two real environments. + resetBuildState() + const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'vite-input-environments-')), + ) + const root = join(fixture, 'apps/app') + const palette = join(fixture, 'packages/palette') + const candidate = join(palette, 'browser/entry.ts') + let server: ViteDevServer | undefined + let ready: Promise | undefined + let signal: (() => void) | undefined + const added = new Promise((done) => { + signal = done + }) + try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules'), { recursive: true }) + await mkdir(join(palette, 'browser'), { recursive: true }) + await mkdir(join(palette, 'node'), { recursive: true }) + await symlink(palette, join(root, 'node_modules/palette'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{}') + await writeFile( + join(palette, 'package.json'), + '{"name":"palette","exports":{"browser":"./browser/entry","node":"./node/entry.js"}}', + ) + await writeFile( + join(palette, 'browser/entry.js'), + "export const color='red'", + ) + await writeFile( + join(palette, 'node/entry.js'), + "export const color='green'", + ) + await writeFile( + join(root, 'src/main.js'), + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { port: 0 }, + plugins: [ + DevupUI(), + { + name: 'environment-watch-observation', + configureServer(current) { + ready = once(current.watcher, 'ready', { + signal: AbortSignal.timeout(10000), + }) + current.watcher.on('add', (file) => { + if ( + file.replaceAll('\\', '/') === candidate.replaceAll('\\', '/') + ) + signal?.() + }) + }, + }, + ], + }) + await server.listen() + const before = await server.transformRequest('/src/main.js') + await server.environments.ssr.transformRequest('/src/main.js') + await ready + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain('background:red') + // When a client-only missing candidate is created after SSR's distinct observations. + const replacement = join(fixture, 'candidate-replacement.ts') + await writeFile(replacement, "export const color='blue'") + await rename(replacement, candidate) + await Promise.race([ + added, + new Promise((_, reject) => { + const timeout = setTimeout( + () => reject(new Error('No client candidate add event')), + 10000, + ) + timeout.unref() + }), + ]) + const after = await server.transformRequest('/src/main.js') + // Then client cache invalidation survived the later SSR extraction. + expect(after?.code).not.toBe(before?.code) + const selected = after?.code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain(`.${selected}{background:blue}`) + } finally { + await server?.close() + await rm(fixture, { recursive: true, force: true }) + } +}, 30000) diff --git a/packages/vite-plugin/src/__tests__/resolution-watch-scope.test.ts b/packages/vite-plugin/src/__tests__/resolution-watch-scope.test.ts new file mode 100644 index 000000000..39a6a9e6b --- /dev/null +++ b/packages/vite-plugin/src/__tests__/resolution-watch-scope.test.ts @@ -0,0 +1,56 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' +import { createServer, type ViteDevServer } from 'vite' + +import { createMissingInputWatch } from '../resolution-watch' + +it('preserves exact missing scope targets when enrolling public watcher repair inputs', async () => { + // Given a missing scope manifest beside an unrelated existing source tree. + const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'vite-watch-scope-')), + ) + const root = join(fixture, 'app') + const scope = join(fixture, 'scope') + const unrelated = join(scope, 'unrelated') + const manifest = join(scope, 'package.json') + let server: ViteDevServer | undefined + const watch = createMissingInputWatch() + try { + await mkdir(root, { recursive: true }) + await mkdir(unrelated, { recursive: true }) + await writeFile(join(unrelated, 'value.ts'), 'export {}') + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { port: 0 }, + }) + watch.attach(server) + watch.start(join(root, 'main.ts'), server.environments.client) + const requested: unknown[] = [] + const original = server.watcher.add + const add = spyOn(server.watcher, 'add').mockImplementation( + new Proxy(original, { + apply(target, receiver, args) { + requested.push(args[0]) + return Reflect.apply(target, receiver, args) + }, + }), + ) + try { + // When enrolling one ancestor-scope repair input on the real public watcher. + watch.observe(join(root, 'main.ts'), manifest, server.environments.client) + // Then the target stays a file, not an entire unrelated ancestor subtree. + expect(requested).toEqual([manifest]) + } finally { + add.mockRestore() + } + } finally { + watch.close() + await server?.close() + await rm(fixture, { recursive: true, force: true }) + } +}, 30000) diff --git a/packages/vite-plugin/src/__tests__/resolution-watch.test.ts b/packages/vite-plugin/src/__tests__/resolution-watch.test.ts new file mode 100644 index 000000000..63b82be89 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/resolution-watch.test.ts @@ -0,0 +1,201 @@ +import { once } from 'node:events' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { createServer, type ViteDevServer } from 'vite' + +import { DevupUI } from '../plugin' + +it('refreshes the selected CSS class when linked workspace exports alone change', async () => { + resetBuildState() + // Given a monorepo palette outside node_modules and two already existing entries. + const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'vite-resolution-watch-')), + ) + const root = join(fixture, 'apps/app') + const palette = join(fixture, 'packages/palette') + const manifest = join(palette, 'package.json') + let server: ViteDevServer | undefined + let ready: Promise | undefined + const observed = new Set() + let changed: (() => void) | undefined + const update = new Promise((done) => { + changed = done + }) + try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules'), { recursive: true }) + await mkdir(palette, { recursive: true }) + await symlink(palette, join(root, 'node_modules/palette'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{}') + await writeFile(manifest, '{"name":"palette","exports":"./red.js"}') + await writeFile(join(palette, 'red.js'), "export const color='red'") + await writeFile(join(palette, 'blue.js'), "export const color='blue'") + await writeFile( + join(root, 'src/main.js'), + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + const configuration = () => ({ + root, + configFile: false as const, + optimizeDeps: { noDiscovery: true }, + server: { port: 0 }, + plugins: [ + DevupUI(), + { + name: 'resolution-watch-observation', + configureServer(current: ViteDevServer) { + ready = once(current.watcher, 'ready', { + signal: AbortSignal.timeout(10000), + }) + }, + hotUpdate({ file }: { readonly file: string }) { + observed.add(file) + if (file === manifest.replaceAll('\\', '/')) changed?.() + }, + }, + ], + }) + server = await createServer(configuration()) + await server.listen() + const before = await server.transformRequest('/src/main.js') + await ready + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain('background:red') + // When the only mutation switches the package exports target. + const replacement = join(fixture, 'manifest-replacement.json') + await writeFile(replacement, '{"name":"palette","exports":"./blue.js"}') + await rename(replacement, manifest) + await Promise.race([ + update, + new Promise((_, reject) => { + const timeout = setTimeout( + () => reject(new Error('No manifest hotUpdate')), + 10000, + ) + timeout.unref() + }), + ]) + const after = await server.transformRequest('/src/main.js') + // Then the new class selects blue; retained earlier atoms do not define its effective style. + expect(observed).toContain(manifest.replaceAll('\\', '/')) + expect(after?.code).not.toBe(before?.code) + const selected = after?.code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + expect(selected).toBeDefined() + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain(`.${selected}{background:blue}`) + await server.close() + server = undefined + const cold = Bun.spawnSync( + ['node', join(import.meta.dir, 'resolution-cold.mjs'), root], + { stdout: 'pipe', stderr: 'pipe' }, + ) + expect({ + exitCode: cold.exitCode, + error: cold.stderr.toString(), + }).toMatchObject({ exitCode: 0 }) + expect(cold.stdout.toString()).toMatch(/background:\s*blue/) + expect(cold.stdout.toString()).not.toMatch(/background:\s*red/) + } finally { + await server?.close() + await rm(fixture, { recursive: true, force: true }) + } +}, 30000) + +it('refreshes an importer when a missing earlier paths candidate outside the root is created', async () => { + resetBuildState() + // Given an existing fallback and a missing candidate outside the default root watch. + const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'vite-missing-input-')), + ) + const root = join(fixture, 'apps/app') + const outside = join(fixture, 'packages/alternate') + const candidate = join(outside, 'color.ts') + let server: ViteDevServer | undefined + let ready: Promise | undefined + let signal: (() => void) | undefined + const added = new Promise((done) => { + signal = done + }) + try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(outside, { recursive: true }) + await writeFile( + join(root, 'tsconfig.json'), + '{"compilerOptions":{"allowJs":true,"paths":{"palette":["../../packages/alternate/color.ts","src/red.ts"]}}}', + ) + await writeFile(join(root, 'src/red.ts'), "export const color='red'") + await writeFile( + join(root, 'src/main.js'), + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + server = await createServer({ + root, + configFile: false, + resolve: { tsconfigPaths: true }, + optimizeDeps: { noDiscovery: true }, + server: { port: 0 }, + plugins: [ + DevupUI(), + { + name: 'missing-input-observation', + configureServer(current) { + ready = once(current.watcher, 'ready', { + signal: AbortSignal.timeout(10000), + }) + current.watcher.on('add', (file) => { + if ( + file.replaceAll('\\', '/') === candidate.replaceAll('\\', '/') + ) + signal?.() + }) + }, + }, + ], + }) + await server.listen() + const before = await server.transformRequest('/src/main.js') + await ready + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain('background:red') + // When the sole mutation creates the previously missing higher-priority candidate. + const replacement = join(fixture, 'candidate-replacement.ts') + await writeFile(replacement, "export const color='blue'") + await rename(replacement, candidate) + await Promise.race([ + added, + new Promise((_, reject) => { + const timeout = setTimeout( + () => reject(new Error('Missing outside-root add event')), + 10000, + ) + timeout.unref() + }), + ]) + const after = await server.transformRequest('/src/main.js') + // Then the cached importer is invalidated by public watch transport and selects blue. + expect(after?.code).not.toBe(before?.code) + const selected = after?.code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain(`.${selected}{background:blue}`) + } finally { + await server?.close() + await rm(fixture, { recursive: true, force: true }) + } +}, 30000) diff --git a/packages/vite-plugin/src/__tests__/tsconfig-native-watch.mjs b/packages/vite-plugin/src/__tests__/tsconfig-native-watch.mjs new file mode 100644 index 000000000..74ae0de88 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/tsconfig-native-watch.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) +const { createServer } = await import( + pathToFileURL(require.resolve('vite')).href +) +const { DevupUI } = require('../../dist/index.cjs') +const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'devup-config-watch-')), +) +const root = join(fixture, 'apps/app') +const preset = join(fixture, 'packages/preset') +const manifest = join(preset, 'package.json') +let server +try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules/@scope'), { recursive: true }) + await mkdir(preset, { recursive: true }) + await symlink(preset, join(root, 'node_modules/@scope/preset'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{"extends":"@scope/preset"}') + await writeFile(manifest, '{"name":"@scope/preset","exports":"./red.json"}') + for (const color of ['red', 'blue']) { + await writeFile( + join(preset, `${color}.json`), + JSON.stringify({ + compilerOptions: { paths: { palette: [`${color}.ts`] } }, + }), + ) + await writeFile( + join(preset, `${color}.ts`), + `export const color='${color}'`, + ) + } + await writeFile( + join(root, 'src/main.tsx'), + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + let ready + let notify + const update = new Promise((done) => { + notify = done + }) + const signal = AbortSignal.timeout(15000) + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { port: 0 }, + plugins: [ + DevupUI(), + { + name: 'config-watch-observation', + configureServer(current) { + ready = once(current.watcher, 'ready', { signal }) + }, + hotUpdate({ file }) { + if (file === manifest.replaceAll('\\', '/')) notify() + }, + resolveId(id) { + if (id === 'palette') return join(preset, 'red.ts') + }, + }, + ], + }) + await server.listen() + const before = await server.transformRequest('/src/main.tsx') + await ready + const cssPath = join(root, 'df/devup-ui/devup-ui-0.css') + assert.match(await readFile(cssPath, 'utf8'), /background:red/) + const pid = process.pid + // Only the selecting manifest changes; watcher transport is Vite's public API. + const replacement = join(fixture, 'replacement.json') + await writeFile( + replacement, + '{"name":"@scope/preset","exports":"./blue.json"}', + ) + await rename(replacement, manifest) + await Promise.race([ + update, + new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }) + }), + ]) + const after = await server.transformRequest('/src/main.tsx') + assert.notEqual(after?.code, before?.code) + const selected = after?.code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + assert.ok(selected) + const css = await readFile(cssPath, 'utf8') + assert.ok(css.includes(`.${selected}{background:blue}`), css) + assert.equal(process.pid, pid) + console.info( + JSON.stringify({ + runtime: process.version, + pid, + before: before?.code, + after: after?.code, + selected, + css, + }), + ) +} finally { + await server?.close() + await rm(fixture, { recursive: true, force: true }) +} diff --git a/packages/vite-plugin/src/__tests__/tsconfig-native-watch.test.ts b/packages/vite-plugin/src/__tests__/tsconfig-native-watch.test.ts new file mode 100644 index 000000000..5d496627a --- /dev/null +++ b/packages/vite-plugin/src/__tests__/tsconfig-native-watch.test.ts @@ -0,0 +1,20 @@ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +it('reextracts blue CSS through public invalidation in persistent genuine native dev', () => { + // Given the checked-in manifest-only native fixture and built public plugin. + const driver = join(import.meta.dir, 'tsconfig-native-watch.mjs') + // When native Node owns the entire awaited server lifecycle without a restart. + const result = spawnSync('node', [driver], { + encoding: 'utf8', + timeout: 25000, + }) + // Then its actual selected class has a blue declaration, not merely a watcher event. + expect({ status: result.status, stderr: result.stderr }).toEqual({ + status: 0, + stderr: '', + }) + expect(result.stdout).toContain('background:blue') +}, 30000) diff --git a/packages/vite-plugin/src/aggregate-css.ts b/packages/vite-plugin/src/aggregate-css.ts new file mode 100644 index 000000000..311b8da7a --- /dev/null +++ b/packages/vite-plugin/src/aggregate-css.ts @@ -0,0 +1,252 @@ +import type { BuildOptions } from 'vite' + +import { inspectImports } from './aggregate-imports' + +interface ClosureModule { + readonly code: string | null + readonly importedIds: readonly string[] + readonly dynamicallyImportedIds: readonly string[] + readonly isExternal?: boolean + readonly isEntry?: boolean +} + +export interface CssClosureContext { + getModuleIds(): IterableIterator + getModuleInfo(id: string): ClosureModule | null + load(options: { + id: string + resolveDependencies: boolean + }): Promise + resolve( + id: string, + importer?: string, + ): Promise<{ + readonly id: string + readonly external: boolean | string + } | null> + parse(code: string): unknown +} + +interface Preparation { + readonly entries: readonly string[] + promise?: Promise + completed?: { readonly ids: ReadonlySet; readonly asset: string } +} + +export class AggregateCssError extends Error { + readonly file: string + readonly line: number + readonly column: number + + constructor( + location: + | string + | { + readonly file: string + readonly line: number + readonly column: number + }, + readonly asset: string, + reason: string, + ) { + const { file, line, column } = + typeof location === 'string' + ? { file: location, line: 1, column: 1 } + : location + super( + `${file}:${line}:${column}: [devup-ui] aggregate CSS asset ${asset} cannot be completed at build time: ${reason}; use an application build with build.lib=false and build.cssCodeSplit=true, or precompile the responsible module and publish its CSS as a separate imported asset`, + ) + this.file = file + this.line = line + this.column = column + this.name = 'AggregateCssError' + } +} + +// Vite processes these requests as stylesheets, not JS graph vertices. Awaiting +// their loads would wait on this very barrier (including CSS @import cycles). +const CSS_REQUEST = + /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/i + +export function createAggregateCssPreparation( + deadline: (expire: () => void) => () => void = (expire) => { + const timer = setTimeout(expire, 30_000) + return () => clearTimeout(timer) + }, +) { + const environments = new WeakMap() + return { + start( + environment: object, + input: string | readonly string[] | Record | undefined, + build: Pick, + ) { + environments.delete(environment) + if (!build.lib && build.cssCodeSplit !== false) return + const entries = + typeof input === 'string' ? [input] : input ? Object.values(input) : [] + environments.set(environment, { entries }) + }, + prepare(environment: object, context: CssClosureContext, asset: string) { + const state = environments.get(environment) + if (!state) return + state.promise ??= (async () => { + const visited = new Set() + const entries = new Set(state.entries) + for (const id of context.getModuleIds()) { + if (context.getModuleInfo(id)?.isEntry) entries.add(id) + } + async function resolveModule(id: string, importer?: string) { + try { + return await context.resolve(id, importer) + } catch (cause) { + throw new AggregateCssError( + importer ?? id, + asset, + `public resolution refused ${id}: ${cause instanceof Error ? cause.message : String(cause)}`, + ) + } + } + async function walk(id: string, importer: string): Promise { + if (visited.has(id)) return + visited.add(id) + const known = context.getModuleInfo(id) + if (known?.isExternal) return + if (!known || known.code === null) { + const resolved = await resolveModule(id, importer) + if (!resolved && !known) + throw new AggregateCssError( + importer, + asset, + `public resolution refused ${id}`, + ) + if (resolved?.external) return + if (resolved && resolved.id !== id) { + if (visited.has(resolved.id)) + throw new AggregateCssError( + importer, + asset, + `public resolution formed an ID cycle at ${id}`, + ) + return walk(resolved.id, importer) + } + } + if (CSS_REQUEST.test(id)) return + let code = '' + function fail(reason: string, position?: number): never { + const lines = code.slice(0, position).split('\n') + const location = + position === undefined + ? id + : { + file: id, + line: lines.length, + column: (lines.at(-1)?.length ?? 0) + 1, + } + throw new AggregateCssError(location, asset, reason) + } + let info: ClosureModule | null + try { + info = await new Promise( + (resolve, reject) => { + const cancel = deadline(() => + reject( + new AggregateCssError( + id, + asset, + 'public load did not settle within 30 seconds; a source transform awaiting generated CSS creates a preparation cycle', + ), + ), + ) + Promise.resolve() + .then(() => context.load({ id, resolveDependencies: false })) + .then(resolve, reject) + .finally(cancel) + }, + ) + } catch (cause) { + fail( + `public load refused the transformed module: ${cause instanceof Error ? cause.message : String(cause)}`, + ) + } + if ( + !info || + typeof info.code !== 'string' || + !Array.isArray(info.importedIds) || + !Array.isArray(info.dynamicallyImportedIds) + ) { + fail( + 'public load returned null or incomplete transformed module information', + ) + } + const dependencies = [ + ...info.importedIds, + ...info.dynamicallyImportedIds, + ] + code = info.code + let hasImports: boolean + try { + const ast = context.parse(code) + if ( + !ast || + typeof ast !== 'object' || + !('type' in ast) || + ast.type !== 'Program' + ) + fail('public parser returned incomplete module information') + hasImports = inspectImports(ast, fail) + } catch (cause) { + if (cause instanceof AggregateCssError) throw cause + fail( + `public parser refused the transformed module: ${cause instanceof Error ? cause.message : String(cause)}`, + ) + } + if (hasImports && dependencies.length === 0) + fail('public load returned an unresolved import graph') + for (const dependency of dependencies) await walk(dependency, id) + } + if (entries.size === 0) + throw new AggregateCssError( + asset, + asset, + 'the public build input list is empty', + ) + for (const entry of entries) { + const resolved = await resolveModule(entry) + if (!resolved || resolved.external) + throw new AggregateCssError( + entry, + asset, + 'the public build entry could not be resolved to an internal module', + ) + await walk(resolved.id, entry) + } + state.completed = { ids: visited, asset } + })() + return state.promise + }, + observe(environment: object, id: string) { + const completed = environments.get(environment)?.completed + if (completed && !completed.ids.has(id)) { + throw new AggregateCssError( + id, + completed.asset, + 'a plugin introduced a module after the aggregate closure completed; expose its entry/imports before the first generated CSS load', + ) + } + }, + checkAsset( + environment: object, + asset: { readonly name?: string; readonly fileName: string }, + ) { + const state = environments.get(environment) + if (state && /^devup-ui(-\d+)?\.css$/.test(asset.name ?? '')) { + throw new AggregateCssError( + state.entries[0] ?? asset.fileName, + asset.fileName, + 'the native split library emitted a standalone generated stylesheet that the shared-sheet output contract rewrites after optimization; use build.cssCodeSplit=false for this library', + ) + } + }, + } +} diff --git a/packages/vite-plugin/src/aggregate-imports.ts b/packages/vite-plugin/src/aggregate-imports.ts new file mode 100644 index 000000000..562eccfb3 --- /dev/null +++ b/packages/vite-plugin/src/aggregate-imports.ts @@ -0,0 +1,47 @@ +export function inspectImports( + ast: unknown, + fail: (reason: string, position?: number) => never, +): boolean { + if (!ast || typeof ast !== 'object') return false + if (Array.isArray(ast)) + return ast.map((node) => inspectImports(node, fail)).some(Boolean) + let imports = false + if ('type' in ast && ast.type === 'ImportExpression') { + const source = 'source' in ast ? ast.source : undefined + if (!source || typeof source !== 'object' || !('type' in source)) { + fail('public parser returned an incomplete dynamic import') + } + if ( + !( + source.type === 'Literal' && + 'value' in source && + typeof source.value === 'string' + ) && + !( + source.type === 'TemplateLiteral' && + 'expressions' in source && + Array.isArray(source.expressions) && + source.expressions.length === 0 + ) + ) { + fail( + 'computed dynamic import has no knowable resolved closure; replace it with literal imports or import.meta.glob', + 'start' in ast && typeof ast.start === 'number' ? ast.start : undefined, + ) + } + imports = true + } + if ( + 'type' in ast && + (ast.type === 'ImportDeclaration' || + ast.type === 'ExportAllDeclaration' || + ast.type === 'ExportNamedDeclaration') && + 'source' in ast && + ast.source + ) + imports = true + for (const value of Object.values(ast)) { + if (inspectImports(value, fail)) imports = true + } + return imports +} diff --git a/packages/vite-plugin/src/compiled-guard.ts b/packages/vite-plugin/src/compiled-guard.ts new file mode 100644 index 000000000..d2cdfc4c4 --- /dev/null +++ b/packages/vite-plugin/src/compiled-guard.ts @@ -0,0 +1,140 @@ +import { + compiledFacts, + createCompileTimeClassifier, + type GuardFacts, + isCompileTimeAlias, + isSelectedSource, + UntransformedSourceError, + type WasmImportAliases, +} from '@devup-ui/plugin-utils' +import type { Plugin } from 'vite' + +export function createCompiledGuard(options: { + readonly package: string + readonly mdxExtensions: readonly string[] + readonly importAliases: WasmImportAliases + readonly extractCss: boolean +}): Plugin { + type Module = { + readonly facts: GuardFacts + readonly code: string + readonly targets: ReadonlyMap + } + const environments = new Map>() + const classify = createCompileTimeClassifier(options.package) + const targetKey = (ref: { + readonly request: string + readonly kind?: 'require-call' + }) => JSON.stringify([ref.request, ref.kind]) + function environmentModules(environment: object) { + let modules = environments.get(environment) + if (!modules) { + modules = new Map() + environments.set(environment, modules) + } + return modules + } + const plugin: Plugin = { + name: 'devup-ui:compiled-source-guard', + sharedDuringBuild: true, + apply: 'build', + buildStart() { + environmentModules(this.environment ?? plugin).clear() + }, + async moduleParsed(info) { + if (!options.extractCss || !info.code) return + const facts = compiledFacts(this.parse(info.code)) + const requests = new Map< + string, + { readonly request: string; readonly kind?: 'require-call' } + >( + [ + ...facts.references, + ...[...facts.exports.values()].flatMap((ref) => (ref ? [ref] : [])), + ...facts.stars.map((request) => ({ request })), + ].map((ref) => [targetKey(ref), ref]), + ) + const targets = new Map() + for (const [key, ref] of requests) { + const target = await this.resolve(ref.request, info.id, { + kind: ref.kind ?? 'import-statement', + }) + if (target && info.importedIds.includes(target.id)) + targets.set(key, target.id) + } + environmentModules(this.environment ?? plugin).set(info.id, { + facts, + code: info.code, + targets, + }) + }, + buildEnd(error) { + if (error || !options.extractCss) return + const modules = environmentModules(this.environment ?? plugin) + function origins( + id: string, + ids: readonly string[], + seen: ReadonlySet = new Set(), + ): Map { + const key = JSON.stringify([id, ids]) + if (seen.has(key)) return new Map() + if (classify(id, ids)) return new Map([[key, true]]) + const module = modules.get(id) + if (!module || !ids.length) return new Map() + const next = new Set([...seen, key]) + const direct = module.facts.exports.get(ids[0]) + if (module.facts.exports.has(ids[0])) { + const target = direct && module.targets.get(targetKey(direct)) + return target && direct + ? origins(target, [...direct.ids, ...ids.slice(1)], next) + : new Map([[key, false]]) + } + return new Map( + (ids[0] === 'default' ? [] : module.facts.stars).flatMap( + (request) => { + const target = module.targets.get(targetKey({ request })) + return target ? [...origins(target, ids, next)] : [] + }, + ), + ) + } + for (const [id, module] of modules) { + if ( + id.startsWith('\0') || + isSelectedSource(id.split('?')[0], options.mdxExtensions) + ) + continue + for (const ref of module.facts.references) { + const target = module.targets.get(targetKey(ref)) + const targets = target + ? origins(target, ref.ids) + : new Map() + if ( + !isCompileTimeAlias( + { ...ref, line: 1, column: 1 }, + options.importAliases, + ) && + (targets.size !== 1 || ![...targets.values()][0]) + ) + continue + const prefix = module.code.slice(0, ref.pos) + const reference = { + ...ref, + line: prefix.split('\n').length, + column: prefix.length - prefix.lastIndexOf('\n'), + } + this.error({ + id, + pos: ref.pos, + message: new UntransformedSourceError( + id, + reference, + isCompileTimeAlias(reference, options.importAliases), + ).message, + }) + } + } + }, + } + return plugin +} diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 91e585045..df579c8fa 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -3,31 +3,45 @@ import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + buildStaticImportGraph, + collectNumberedFiles, computeFileReach, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, + createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, - listSourceFiles, + isMdxSource, + isSelectedSource, loadDevupConfig, mergeImportAliases, + normalizeMdxExtensions, planAtomHoist, + remapMdxError, + type ResolutionInputObserver, + resolutionWatchPath, + resolveProjectPaths, + resolveSourceDirs, + seedFileNumbers, + SOURCE_FILE_RE, } from '@devup-ui/plugin-utils' import { codeExtract, - exportFileMap, getCss, getDefaultTheme, getThemeInterface, importCanonicalMap, - importFileMap, importFileRoutes, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -37,11 +51,14 @@ import type { EnvironmentModuleNode, ModuleNode, Plugin, - PluginOption, ResolvedConfig, UserConfig, } from 'vite' +import { createAggregateCssPreparation } from './aggregate-css' +import { createCompiledGuard } from './compiled-guard' +import { createMissingInputWatch } from './resolution-watch' + /** * CSS entry files emitted by devup-ui: `devup-ui.css`, `devup-ui-3.css`, ... * @@ -51,59 +68,6 @@ import type { */ const DEVUP_CSS_FILE_RE = /^devup-ui(-\d+)?\.css$/ -const SOURCE_DIR_CANDIDATES = ['src', 'app'] - -/** - * Source roots to scan, or an empty list when none of the conventional layouts - * are present. Scanning the project root instead would sweep in config files - * and other never-transformed modules, so callers skip the scan entirely rather - * than guess. - */ -function resolveSourceDirs(root: string): string[] { - return SOURCE_DIR_CANDIDATES.map((dir) => resolve(root, dir)).filter((dir) => - existsSync(dir), - ) -} - -/** - * Assigns each source file its devup file number up front, ordered by path. - * - * The engine otherwise hands numbers out on first sight, and the bundler - * transforms in parallel, so two identical builds produce different per-file - * class prefixes and therefore different CSS *and* JS asset hashes. Seeding - * from a sorted scan makes the numbering a pure function of the file paths. - * Every source file now holds a slot, where before only the ones that emitted - * styles consumed a number. Prefix length is a step function of the highest - * number handed out (1 char up to 26, 2 up to 1025, 3 beyond), so this is free - * until a project passes 1026 files under the scanned roots, at which point - * prefixes that used to be 2 chars become 3. - * - * Vite reports module ids as absolute POSIX-style paths even on Windows, so the - * scanned paths are normalized to match the keys `codeExtract` will look up. - * - * `importFileMap` REPLACES the engine's map, and a framework plugin resolves the - * config once per environment, so seeding unconditionally would wipe the numbers - * already handed to files outside `sourceDirs`: a monorepo sibling, or anything - * reached through `include`. The style sheet does not reset with the map, so the - * next such file reuses a live number and its atoms overwrite the previous - * owner's. Seeding only into an empty map keeps numbering deterministic on the - * first pass and stable for every later one. - */ -function seedFileMap(sourceDirs: string[]): void { - if (Object.keys(JSON.parse(exportFileMap())).length > 0) return - const sorted = [ - ...new Set( - sourceDirs - .flatMap((dir) => listSourceFiles(dir)) - .map((file) => file.replaceAll('\\', '/')), - ), - ].sort() - if (sorted.length === 0) return - const fileMap: Record = {} - for (const [index, file] of sorted.entries()) fileMap[file] = index - importFileMap(fileMap) -} - /** * Names each devup CSS module after its own file, so every module is emitted * once and shared by all of its importers. @@ -274,6 +238,8 @@ export interface DevupUIPluginOptions { singleCss: boolean prefix?: string shorthands?: CustomShorthands + sourceDirs?: string | string[] + mdxExtensions?: readonly string[] /** * Atom-level route-aware hoisting threshold (min routes sharing an atom for * it to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). @@ -293,26 +259,15 @@ export interface DevupUIPluginOptions { async function writeDataFiles( options: Omit, ) { - try { - const config = await loadDevupConfig(options.devupFile) - const theme = config.theme ?? {} + const config = await loadDevupConfig(options.devupFile) + const theme = config.theme ?? {} - registerTheme(theme) - const interfaceCode = getThemeInterface( - ...createThemeInterfaceArgs(options.package), - ) + registerTheme(theme) + const interfaceCode = getThemeInterface( + ...createThemeInterfaceArgs(options.package), + ) - if (interfaceCode) { - await writeFile( - join(options.distDir, 'theme.d.ts'), - interfaceCode, - 'utf-8', - ) - } - } catch (error) { - console.error(error) - registerTheme({}) - } + await writeFile(join(options.distDir, 'theme.d.ts'), interfaceCode, 'utf-8') // Sequential: writing into cssDir concurrently with its own mkdir loses the // race on a cold start (no `df/`) and fails the build with ENOENT. if (!existsSync(options.cssDir)) { @@ -327,133 +282,346 @@ export function DevupUI({ package: libPackage = '@devup-ui/react', devupFile = 'devup.json', distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), + cssDir: configuredCssDir, extractCss = true, debug = false, include = [], singleCss = false, prefix, shorthands, + sourceDirs: configuredSourceDirs, atomHoist, importAliases: userImportAliases, -}: Partial = {}): PluginOption { + mdxExtensions: configuredMdxExtensions, +}: Partial = {}) { + const mdxExtensions = normalizeMdxExtensions(configuredMdxExtensions) + // A build starts from its own options: whatever an earlier build in this + // process left in the engine (prefix, hoisting, routes, buckets, numbers, + // styles) is gone unless another build is still running. + const endBuild = beginBuild({ resetBuildState }) registerShorthands(shorthands ?? {}) setDebug(debug) - if (prefix) { - setPrefix(prefix) - } + setPrefix(prefix ?? null) const importAliases = mergeImportAliases(userImportAliases) + const excludeModules = createNodeModulesExcludeRegex(include) + const pathOptions = { devupFile, distDir, cssDir: configuredCssDir } + let projectRoot = process.cwd() + let cssDir = configuredCssDir ?? join(distDir, 'devup-ui') + let pathsResolved = false + function resolveFallbackPaths() { + if (!pathsResolved) { + ;({ devupFile, distDir, cssDir } = resolveProjectPaths( + projectRoot, + pathOptions, + )) + pathsResolved = true + } + } const cssMap = new Map() + const aggregateCss = createAggregateCssPreparation() let resolvedConfig: ResolvedConfig | undefined // Set by the client `generateBundle`, run by the late hook of the sibling // plugin once @vitejs/plugin-rsc has forwarded. let restoreForwardedCss: ((outputBundle: ForwardableBundle) => void) | undefined let isServe = false + let isProduction = false + let seedWarningEmitted = false + const setupWatchFiles = new Set() + const missingWatchContexts = new Map void>() + const missingInputWatch = createMissingInputWatch() + let fallbackConditions: readonly string[] = [ + 'import', + 'module', + 'require', + 'node', + ] + const observeSetup: ResolutionInputObserver = (inputs) => { + for (const path of [ + ...inputs.fileDependencies, + ...inputs.missingDependencies, + ]) + setupWatchFiles.add(path) + } + function moduleResolver( + conditions: readonly string[], + onResolutionInputs: ResolutionInputObserver = observeSetup, + ) { + return createModuleResolver({ + cwd: projectRoot, + includeMdx: mdxExtensions, + conditions, + onResolutionInputs, + toId: (path) => path.replaceAll('\\', '/'), + }) + } // The dev server watches cssDir, so every write is an update signal. A // module transformed again writes its sheet again, and the reload that // signal causes transforms it once more: signal only a changed sheet. const writtenCss = new Map() + const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), + ) function writeCssFile(fileName: string, css: string): Promise { if (writtenCss.get(fileName) === css) return Promise.resolve() writtenCss.set(fileName, css) - return writeFile(join(cssDir, fileName), css, 'utf-8') + return stateWriter.write(join(cssDir, fileName), css, 'utf-8') } - const plugin: Plugin = { + const sourceTransform = { + async transform(code, id) { + if (!extractCss) return + resolveFallbackPaths() + const fileName = id.split('?')[0] + if (excludeModules.test(fileName)) return + const environment = this.environment ?? plugin + const preserveSymlinks = + this.environment?.config.resolve.preserveSymlinks ?? + resolvedConfig?.resolve?.preserveSymlinks + missingInputWatch.start(fileName, environment) + aggregateCss.observe(this.environment ?? plugin, id) + const environmentConditions = this.environment?.config.resolve.conditions + setModuleResolver( + moduleResolver( + (environmentConditions + ? ['import', ...environmentConditions] + : fallbackConditions + ).map((condition) => + condition === 'development|production' + ? isProduction + ? 'production' + : 'development' + : condition, + ), + (inputs) => { + for (const path of inputs.fileDependencies) + this.addWatchFile( + resolutionWatchPath(path, preserveSymlinks).replaceAll( + '\\', + '/', + ), + ) + for (const path of inputs.missingDependencies) { + const watched = resolutionWatchPath( + path, + preserveSymlinks, + ).replaceAll('\\', '/') + setupWatchFiles.add(path) + missingInputWatch.observe(fileName, watched, environment) + missingWatchContexts.get(this.environment ?? plugin)?.(watched) + } + }, + ), + ) + let rel = relative(dirname(id), cssDir).replaceAll('\\', '/') + if (!rel.startsWith('./')) rel = `./${rel}` + const { + code: extractedCode, + css = '', + map, + cssFile, + updatedBaseStyle, + dependencies = [], + } = (() => { + try { + return codeExtract( + fileName, + code, + libPackage, + rel, + singleCss, + true, + false, + importAliases, + ...(isMdxSource(fileName, mdxExtensions) + ? (['compiled-mdx'] as const) + : ([] as const)), + ) + } catch (error) { + if (isMdxSource(fileName, mdxExtensions)) + throw remapMdxError(error, fileName, this.getCombinedSourcemap()) + throw error + } + })() + for (const dependency of dependencies) this.addWatchFile(dependency) + const promises: Promise[] = [] + if (updatedBaseStyle) + promises.push(writeCssFile('devup-ui.css', getCss(null, false))) + if (cssFile) { + const fileNum = getFileNumByFilename(cssFile) + const prevCss = cssMap.get(fileNum) + if (prevCss && prevCss.length < css.length) cssMap.set(fileNum, css) + if (css) promises.push(writeCssFile(basename(cssFile), css)) + } + await Promise.all(promises) + return { code: extractedCode, map } + }, + } satisfies Pick + const plugin = { name: 'devup-ui', // The WASM sheet and transform state are intentionally shared. Vite // otherwise recreates this plugin for every environment build, which makes // each environment independently emit the same CSS asset. sharedDuringBuild: true, + configureServer(server) { + missingInputWatch.attach(server) + }, async configResolved(config) { resolvedConfig = config - isServe = config?.command === 'serve' - const projectRoot = config?.root ?? process.cwd() - // Vite ids are POSIX absolute paths - setModuleResolver( - createModuleResolver({ - cwd: projectRoot, - toId: (path) => path.replaceAll('\\', '/'), - }), - ) - const sourceDirs = resolveSourceDirs(projectRoot) try { - seedFileMap(sourceDirs) - } catch { - // Best-effort; on failure numbering falls back to arrival order. - } - if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) - await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') - await writeFile( - join(distDir, 'compat.d.ts'), - createCompatTypes(importAliases), - 'utf-8', - ) - await writeDataFiles({ - package: libPackage, - cssDir, - devupFile, - distDir, - singleCss, - }) - - // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any - // transform so atoms receive global (shared) class names. Composes with - // single-importer collapse: both are keyed by the canonical bucket. Vite - // passes the ABSOLUTE module id to codeExtract, so the graph maps use - // absolute keys (keyBy: 'absolute') to match the engine's bucket keys. - const atomMode = - atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 - if (atomMode) { + isServe = config?.command === 'serve' + isProduction = config?.isProduction ?? false + projectRoot = config?.root ?? process.cwd() + ;({ devupFile, distDir, cssDir } = resolveProjectPaths( + projectRoot, + pathOptions, + )) + pathsResolved = true + const conditions = [ + 'import', + ...(config?.build?.ssr + ? (config.ssr?.resolve?.conditions ?? [ + 'module', + 'node', + 'development|production', + ]) + : (config?.resolve?.conditions ?? [ + 'module', + 'browser', + 'development|production', + ])), + ].map((condition) => + condition === 'development|production' + ? config?.isProduction + ? 'production' + : 'development' + : condition, + ) + // Vite ids are POSIX absolute paths + fallbackConditions = conditions + setModuleResolver(moduleResolver(conditions)) + const sourceDirs = resolveSourceDirs(projectRoot, configuredSourceDirs) + const input = + config?.build?.rolldownOptions?.input ?? + config?.build?.rollupOptions?.input ?? + (config?.build?.lib && config.build.lib.entry) + const rawEntries = + typeof input === 'string' + ? [input] + : Array.isArray(input) + ? input + : input && typeof input === 'object' + ? Object.values(input) + : [] + const entries = rawEntries + .filter((entry): entry is string => typeof entry === 'string') + .filter((entry) => isSelectedSource(entry, mdxExtensions)) + .map((entry) => resolve(projectRoot, entry)) + const roots = [ + ...new Set([ + ...sourceDirs, + ...entries.map((entry) => dirname(entry)), + ]), + ] + if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) + await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') + await writeFile( + join(distDir, 'compat.d.ts'), + createCompatTypes(importAliases), + 'utf-8', + ) + await writeDataFiles({ + package: libPackage, + cssDir, + devupFile, + distDir, + singleCss, + }) + + // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any + // transform so atoms receive global (shared) class names. Composes with + // single-importer collapse: both are keyed by the canonical bucket. Vite + // passes the ABSOLUTE module id to codeExtract, so the graph maps use + // absolute keys (keyBy: 'absolute') to match the engine's bucket keys. + const atomMode = + atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 + if (atomMode) { + try { + const root = projectRoot + // App Router projects keep their sources in `app/`, so a hardcoded + // `src/` made the whole pre-pass a silent no-op for them. + const srcDir = roots + const tsconfigPath = resolve(root, 'tsconfig.json') + const graph = buildStaticImportGraph(roots, tsconfigPath, { + includeMdx: mdxExtensions, + cwd: root, + include, + conditions, + onResolutionInputs: observeSetup, + }) + + const canonicalMap = buildCanonicalMap({ + srcDir, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + graph, + }) + importCanonicalMap(canonicalMap) + + const fileReach = computeFileReach({ + srcDir, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + graph, + entries: entries.length > 0 ? entries : undefined, + }) + const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) + if (plan) { + importFileRoutes(plan.reachByBucket) + setAtomHoist(plan.threshold) + } else { + console.info( + '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + ) + } + } catch (cause) { + throw new Error( + `[devup-ui] atom graph setup failed at ${projectRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } + } try { - const root = projectRoot - // App Router projects keep their sources in `app/`, so a hardcoded - // `src/` made the whole pre-pass a silent no-op for them. - const srcDir = sourceDirs[0] ?? resolve(root, 'src') - const tsconfigPath = resolve(root, 'tsconfig.json') - // C: prefer the bundler's real JS entries; fall back to the heuristic - // (files with no importer) when input is html-only / unavailable. - const input = config.build?.rollupOptions?.input - const rawEntries = - typeof input === 'string' - ? [input] - : Array.isArray(input) - ? input - : input && typeof input === 'object' - ? Object.values(input) - : [] - const entries = rawEntries - .filter((e): e is string => typeof e === 'string') - .filter((e) => /\.(tsx|ts|jsx|js|mjs)$/i.test(e)) - .map((e) => resolve(root, e)) - - const canonicalMap = buildCanonicalMap({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - }) - importCanonicalMap(canonicalMap) - - const fileReach = computeFileReach({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - entries: entries.length > 0 ? entries : undefined, - }) - const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) - if (plan) { - importFileRoutes(plan.reachByBucket) - setAtomHoist(plan.threshold) - } else { - console.info( - '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + // Numbers come from the sorted paths of every file the build can + // extract (source and included packages), not from arrival order. + // Files numbered before keep their numbers, so a later pass in the + // dev server only numbers new files after the existing ones. + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots, + includeMdx: mdxExtensions, + include, + cwd: projectRoot, + needles: extractedNeedles(libPackage, importAliases), + }), + ) + } catch (cause) { + if (!seedWarningEmitted) { + seedWarningEmitted = true + console.warn( + '[devup-ui] deterministic file seeding failed; class IDs now depend on module arrival order', + { phase: 'seed', root: projectRoot, cause }, ) } - } catch { - // Best-effort; on failure atom hoisting stays off (identity). } + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] setup failed at ${projectRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) } }, config(this: { meta?: ConfigHookMeta } | void, userConfig: UserConfig) { @@ -476,7 +644,11 @@ export function DevupUI({ noExternal: [...include, /@devup-ui/, /@devup-editor/], }, } - if (extractCss) { + if ( + extractCss && + !userConfig?.build?.lib && + userConfig?.build?.cssCodeSplit !== false + ) { ret.build = createCssChunkBuildOptions(this?.meta, userConfig) } return ret @@ -484,7 +656,32 @@ export function DevupUI({ apply() { return true }, - async watchChange(id) { + closeBundle(this: void) { + missingInputWatch.close() + missingWatchContexts.clear() + endBuild() + }, + buildStart(options) { + missingWatchContexts.set(this.environment ?? plugin, (path) => + this.addWatchFile(path), + ) + for (const path of setupWatchFiles) + this.addWatchFile( + resolutionWatchPath( + path, + this.environment?.config.resolve.preserveSymlinks ?? + resolvedConfig?.resolve?.preserveSymlinks, + ).replaceAll('\\', '/'), + ) + if (isServe || !extractCss) return + aggregateCss.start( + this.environment ?? plugin, + options.input, + this.environment?.config.build ?? resolvedConfig?.build ?? {}, + ) + }, + async watchChange(this: void, id) { + resolveFallbackPaths() if (resolve(id) === resolve(devupFile) && existsSync(devupFile)) { try { await writeDataFiles({ @@ -495,13 +692,14 @@ export function DevupUI({ singleCss, }) } catch (error) { - console.error(error) + console.error(`[devup-ui] theme update failed at ${devupFile}`, error) } } }, // Runs once per environment. Vite 6+ ignores `handleHotUpdate` on a plugin // that defines this hook, so the devup.json reload lives here as well. async hotUpdate({ file, modules, timestamp }) { + resolveFallbackPaths() const { environment } = this if (environment.config.consumer === 'server') { // A module runner cannot apply CSS, so Vite answers a sheet change @@ -540,6 +738,7 @@ export function DevupUI({ }, // Vite 5 fallback: Vite 6+ does not call this hook when `hotUpdate` exists. async handleHotUpdate({ file, server, modules, timestamp }) { + resolveFallbackPaths() if (resolve(file) !== resolve(devupFile) || !existsSync(devupFile)) { return } @@ -565,6 +764,7 @@ export function DevupUI({ return [] }, resolveId(id, importer) { + resolveFallbackPaths() const fileName = basename(id).split('?')[0] if ( DEVUP_CSS_FILE_RE.test(fileName) && @@ -588,63 +788,26 @@ export function DevupUI({ load(id) { const fileName = basename(id).split('?')[0] if (DEVUP_CSS_FILE_RE.test(fileName)) { - const fileNum = getFileNumByFilename(fileName) - const css = getCss(fileNum, false) - cssMap.set(fileNum, css) - return css + const snapshot = () => { + const fileNum = getFileNumByFilename(fileName) + const css = getCss(fileNum, false) + cssMap.set(fileNum, css) + return css + } + const preparation = aggregateCss.prepare( + this.environment ?? plugin, + this, + id, + ) + return preparation ? preparation.then(snapshot) : snapshot() } }, enforce: 'pre', - async transform(code, id) { - if (!extractCss) return - - const fileName = id.split('?')[0] - if (!/\.(tsx|ts|js|mjs|jsx)$/i.test(fileName)) return - if (createNodeModulesExcludeRegex(include).test(fileName)) { - return - } - - let rel = relative(dirname(id), cssDir).replaceAll('\\', '/') - if (!rel.startsWith('./')) rel = `./${rel}` - - const { - code: extractedCode, - css = '', - map, - cssFile, - updatedBaseStyle, - dependencies = [], - // import main css in code - } = codeExtract( - fileName, - code, - libPackage, - rel, - singleCss, - true, - false, - importAliases, - ) - for (const dependency of dependencies) this.addWatchFile(dependency) - const promises: Promise[] = [] - - if (updatedBaseStyle) { - // update base style - promises.push(writeCssFile('devup-ui.css', getCss(null, false))) - } - - if (cssFile) { - const fileNum = getFileNumByFilename(cssFile) - const prevCss = cssMap.get(fileNum) - if (prevCss && prevCss.length < css.length) cssMap.set(fileNum, css) - // `css` is only set when this transform added styles to the sheet. - if (css) promises.push(writeCssFile(basename(cssFile), css)) - } - await Promise.all(promises) - return { - code: extractedCode, - map, - } + transform: { + async handler(code, id) { + if (!SOURCE_FILE_RE.test(id.split('?')[0])) return + return sourceTransform.transform.call(this, code, id) + }, }, async generateBundle(_options, bundle) { if (!extractCss) return @@ -681,7 +844,7 @@ export function DevupUI({ } } }, - } + } satisfies Plugin const restorePlugin: Plugin = { name: 'devup-ui:restore-forwarded-css', sharedDuringBuild: true, @@ -694,5 +857,42 @@ export function DevupUI({ }, }, } - return [plugin, restorePlugin] + const mdxPlugin = { + name: 'devup-ui:mdx', + enforce: 'post', + sharedDuringBuild: true, + async transform(code, id) { + if (!isMdxSource(id, mdxExtensions)) return + return sourceTransform.transform.call(this, code, id) + }, + } satisfies Plugin + const aggregateGuard = { + name: 'devup-ui:aggregate-css-guard', + sharedDuringBuild: true, + apply: 'build', + generateBundle: { + order: 'post', + handler(_options, bundle) { + for (const asset of Object.values(bundle)) { + if (asset.type === 'asset') { + aggregateCss.checkAsset(this.environment ?? plugin, asset) + } + } + }, + }, + } satisfies Plugin + const compiledGuard = createCompiledGuard({ + package: libPackage, + mdxExtensions, + importAliases, + extractCss, + }) + const plugins: [ + typeof plugin, + typeof restorePlugin, + typeof mdxPlugin, + typeof aggregateGuard, + typeof compiledGuard, + ] = [plugin, restorePlugin, mdxPlugin, aggregateGuard, compiledGuard] + return plugins } diff --git a/packages/vite-plugin/src/resolution-watch.ts b/packages/vite-plugin/src/resolution-watch.ts new file mode 100644 index 000000000..79179057a --- /dev/null +++ b/packages/vite-plugin/src/resolution-watch.ts @@ -0,0 +1,56 @@ +import { basename, dirname } from 'node:path' + +import type { ViteDevServer } from 'vite' + +export function createMissingInputWatch() { + const missingByEnvironment = new Map>>() + let server: ViteDevServer | undefined + const changed = (file: string) => { + const path = file.replaceAll('\\', '/') + let affected = false + for (const environment of Object.values(server?.environments ?? {})) { + for (const [importer, inputs] of missingByEnvironment.get(environment) ?? + []) { + if (!inputs.has(path)) continue + for (const module of environment.moduleGraph.getModulesByFile( + importer, + ) ?? []) { + environment.moduleGraph.invalidateModule(module) + affected = true + } + } + } + if (affected) server?.ws.send({ type: 'full-reload' }) + } + return { + attach(current: ViteDevServer) { + server = current + current.watcher + .on('add', changed) + .on('change', changed) + .on('unlink', changed) + }, + start(importer: string, environment: object) { + const importers = + missingByEnvironment.get(environment) ?? new Map>() + importers.set(importer, new Set()) + missingByEnvironment.set(environment, importers) + }, + observe(importer: string, path: string, environment: object) { + missingByEnvironment.get(environment)?.get(importer)?.add(path) + // Scope manifests can be probed up to the drive root; never enroll that tree. + // Competing source candidates retain their established directory transport. + server?.watcher.add( + basename(path) === 'package.json' ? path : dirname(path), + ) + }, + close() { + server?.watcher + .off('add', changed) + .off('change', changed) + .off('unlink', changed) + server = undefined + missingByEnvironment.clear() + }, + } +} diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md index 343ead564..598d992fd 100644 --- a/packages/webpack-plugin/README.md +++ b/packages/webpack-plugin/README.md @@ -177,6 +177,57 @@ const box = const box = ``` +## Consuming Component Libraries + +A precompiled Devup UI library ships transformed JavaScript and emitted CSS. +Import its published stylesheet; its compile-time components have already been +removed, so the consumer does not need to transform that library again. + +A library intentionally published without extraction (for example with Vite's +`DevupUI({ extractCss: false })`, as in `apps/vite-lib`) requires a Devup UI build +plugin in **every** consuming application and its exact package name in `include`: + +```ts +new DevupUIWebpackPlugin({ include: ['@acme/components'] }) +``` + +The consumer compiles the library and emits its CSS at build time. Uncompiled +output is not standalone browser JavaScript: without this consumer setup, the +component placeholders throw `Cannot run on the runtime`. No styling runtime is +added by either supported publishing mode. + +## MDX Selection And Compiled-Source Guards + +Native `resolve.alias` maps or ordered descriptors are passed unchanged to the +shared graph, build-time resolver and loader; resolver cache identity includes +their order and values. Duplicate descriptors and literal-dollar names retain +native semantics. Empty candidate arrays perform no rewrite and fall through; +an actual rewriting all-miss is terminal. The installed enhanced-resolve factory +rejects mixed string/false arrays before its alias handler, so Webpack reports +its own construction error for those arrays. Wildcard names the shared resolver +cannot represent are located configuration errors, never silently dropped. + +`mdxExtensions` defaults to `['.mdx']`. Configure a project MDX loader for every +literal extension you opt into, for example `['.mdx', '.mdown']`; `.md` is not +selected by default. Devup's post-loader extracts compiler-produced JavaScript +with JSX under the actual filename. Graph discovery and numbering use the same +selection as extraction. + +With extraction enabled, the guard runs after loaders in build, development +compilations and watch rebuilds. Public parser facts, used dependency export IDs +and actual module targets identify definitely used compile-time exports in +modules outside the supported JS/TS and selected MDX extensions. Named bindings, +static namespace/require-result members and destructuring with known export IDs +produce a located module/import error and an `mdxExtensions` remedy. Runtime +helpers and unused imports are allowed; an intended runtime compatibility alias +can be disabled through `importAliases[package] = false`. + +Whole namespace/require-result objects and dynamic imports are opaque and are +allowed. Compile-time exports reached only through these opaque forms may throw +`Cannot run on the runtime` when rendered; let Devup compile the extension +instead. The guard does not expand extraction to ordinary JS/TS excluded by +`include` or change intentional uncompiled-library publishing. + ## Custom Shorthands ```ts diff --git a/packages/webpack-plugin/src/__tests__/alias-native.test.ts b/packages/webpack-plugin/src/__tests__/alias-native.test.ts new file mode 100644 index 000000000..2fa5b4821 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/alias-native.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import type { Compiler, Configuration, Stats } from 'webpack' + +import { aliasFixture } from '../../../plugin-utils/src/__tests__/alias-array-fixture' +import { DevupUIWebpackPlugin } from '../plugin' +import { mdxLanding } from './mdx-build-fixture' + +const bundled: { webpack(config: Configuration): Compiler } = mdxLanding( + 'next/dist/compiled/webpack/webpack', +) + +it.each([false, true])( + 'passes native descriptors through graph, setup and loader when watch=%s', + async (watch) => { + const fixture = aliasFixture() + const { root, file } = fixture + const entry = file( + 'src/main.js', + 'import {css} from "@devup-ui/react"; import {value} from "provider"; export const cls=css({color: value === undefined ? "red" : value})', + ) + const blue = file('outside/blue.js', 'export const value="blue"') + try { + for (const target of [false, blue] as const) { + wasm.resetBuildState() + const compiler = bundled.webpack({ + context: root, + mode: 'development', + entry, + target: 'node', + output: { path: join(root, 'out'), library: { type: 'commonjs2' } }, + resolve: { + alias: [ + { name: 'provider', alias: target }, + { name: 'provider', alias: false }, + { + name: '@devup-ui/react', + alias: mdxLanding.resolve('@devup-ui/react'), + }, + ], + }, + experiments: { css: true }, + module: { rules: [{ test: /\.css$/, type: 'css' }] }, + plugins: [new DevupUIWebpackPlugin({ singleCss: true, watch })], + }) + try { + const stats = await new Promise((done, reject) => + compiler.run((error, result) => { + if (error) reject(error) + else if (result) done(result) + else reject(new Error('Missing build stats')) + }), + ) + expect( + stats.toJson({ all: false, errors: true }).errors ?? [], + ).toEqual([]) + const result: { exports: { cls?: string } } = { exports: {} } + new Function( + 'module', + 'exports', + readFileSync(join(root, 'out/main.js'), 'utf8'), + )(result, result.exports) + expect(wasm.getCss(null, false)).toContain( + `.${result.exports.cls}{color:${target === false ? 'red' : 'blue'}}`, + ) + } finally { + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + } + } + } finally { + fixture.dispose() + } + }, +) diff --git a/packages/webpack-plugin/src/__tests__/core-regression.test.ts b/packages/webpack-plugin/src/__tests__/core-regression.test.ts new file mode 100644 index 000000000..a831d0c13 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/core-regression.test.ts @@ -0,0 +1,261 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import * as pluginUtils from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { afterEach, beforeEach, expect, it, spyOn } from 'bun:test' +import type { Compiler, Configuration } from 'webpack' + +import { DevupUIWebpackPlugin } from '../plugin' +const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) +const bundled: { webpack(config: Configuration): Compiler } = landing( + 'next/dist/compiled/webpack/webpack', +) +function webpack(config: Configuration) { + return bundled.webpack({ + ...config, + optimization: { ...config.optimization, minimize: false }, + }) +} + +let root: string +let compiler: Compiler | undefined + +beforeEach(async () => { + wasm.resetBuildState() + root = await mkdtemp( + join(process.env.DEVUP_PLUGIN_TEST_TMP ?? tmpdir(), 'webpack-'), + ) + await mkdir(join(root, 'src')) +}) + +afterEach(async () => { + if (compiler) + await new Promise((done, reject) => + compiler?.close((error) => (error ? reject(error) : done())), + ) + compiler = undefined + await rm(root, { recursive: true, force: true }) +}) + +it.each(['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs'])( + 'prewarms modern .%s files relative to compiler.context', + async (extension) => { + await writeFile( + join(root, 'src', `main.${extension}`), + "import {css} from '@devup-ui/react'; export const style = css({bg:'red'});", + ) + const plugin = new DevupUIWebpackPlugin({ singleCss: true }) + + compiler = webpack({ + context: root, + mode: 'production', + entry: `./src/main.${extension}`, + plugins: [plugin], + }) + + expect(plugin.options.distDir).toBe(join(root, 'df')) + expect(wasm.getCss(null, false)).toContain('red') + const rules = compiler.options.module.rules + expect(rules).toContainEqual( + expect.objectContaining({ + test: pluginUtils.SOURCE_FILE_RE, + enforce: 'pre', + }), + ) + expect(rules).toContainEqual( + expect.objectContaining({ enforce: 'post', test: /\.mdx$/i }), + ) + }, +) + +it('overwrites empty declarations using the actual compiler context', async () => { + await writeFile( + join(root, 'devup.json'), + JSON.stringify({ theme: { colors: { default: { removed: 'red' } } } }), + ) + const plugin = new DevupUIWebpackPlugin() + compiler = webpack({ context: root, plugins: [plugin] }) + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).toContain( + 'removed', + ) + await writeFile(join(root, 'devup.json'), '{}') + + plugin.writeDataFiles() + + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).not.toContain( + 'removed', + ) +}) + +it.each(['web', 'node'])( + 'reuses one graph covering all roots and the included closure for target %s', + async (target) => { + await mkdir(join(root, 'app')) + await mkdir(join(root, 'custom')) + const library = join(root, 'node_modules', 'foo.bar') + await mkdir(join(library, 'nested'), { recursive: true }) + await writeFile( + join(library, 'package.json'), + JSON.stringify({ + name: 'foo.bar', + exports: { + browser: './browser.mjs', + node: './node.mjs', + default: './fallback.js', + }, + }), + ) + await writeFile( + join(library, 'browser.mjs'), + "export {color} from './nested/colors.mts'", + ) + await writeFile( + join(library, 'nested/colors.mts'), + "export const color = 'red'", + ) + await writeFile(join(library, 'node.mjs'), "export const color = 'blue'") + await writeFile( + join(library, 'fallback.js'), + "export const color = 'green'", + ) + await writeFile( + join(root, 'src/main.mts'), + "import {color} from 'foo.bar'; import {css} from '@devup-ui/react'; export const style = css({bg:color})", + ) + await writeFile(join(root, 'app/page.tsx'), "import '../src/main.mts'") + await writeFile(join(root, 'custom/entry.cts'), "import '../src/main.mts'") + const originalGraph = pluginUtils.buildStaticImportGraph + const captured: { graph?: pluginUtils.StaticImportGraph } = {} + const graph = spyOn( + pluginUtils, + 'buildStaticImportGraph', + ).mockImplementation((...args) => { + captured.graph = originalGraph(...args) + return captured.graph + }) + const canonical = spyOn(pluginUtils, 'buildCanonicalMap') + const reach = spyOn(pluginUtils, 'computeFileReach') + const prewarm = spyOn(pluginUtils, 'computeReachableFiles') + try { + compiler = webpack({ + context: root, + mode: 'production', + target, + entry: { app: './app/page.tsx', custom: './custom/entry.cts' }, + plugins: [ + new DevupUIWebpackPlugin({ + sourceDirs: ['src', 'app', 'custom'], + include: ['foo.bar'], + atomHoist: 2, + singleCss: true, + }), + ], + }) + + expect(graph).toHaveBeenCalledTimes(1) + const shared = captured.graph + expect(shared?.files).toContain( + join(library, target === 'web' ? 'nested/colors.mts' : 'node.mjs'), + ) + expect(shared?.files).not.toContain( + join(library, target === 'web' ? 'node.mjs' : 'nested/colors.mts'), + ) + expect(canonical.mock.calls[0]?.[0].graph).toBe(shared) + expect(reach.mock.calls[0]?.[0].graph).toBe(shared) + expect(prewarm.mock.calls[0]?.[0].graph).toBe(shared) + expect(wasm.getCss(null, false)).toContain( + target === 'web' ? 'red' : 'blue', + ) + } finally { + graph.mockRestore() + canonical.mockRestore() + reach.mockRestore() + prewarm.mockRestore() + } + }, +) + +it('rejects malformed config with its root and original cause', async () => { + await writeFile(join(root, 'devup.json'), '{') + + expect(() => + webpack({ context: root, plugins: [new DevupUIWebpackPlugin()] }), + ).toThrow(join(root, 'devup.json')) +}) + +it('does not parse raw MDX during prewarm', async () => { + await writeFile( + join(root, 'src/page.mdx'), + 'import {Box} from \'@devup-ui/react\'\n\n# Heading\n\n', + ) + const extract = spyOn(wasm, 'codeExtract') + try { + compiler = webpack({ + context: root, + entry: './src/page.mdx', + plugins: [new DevupUIWebpackPlugin()], + }) + + expect(extract).not.toHaveBeenCalled() + } finally { + extract.mockRestore() + } +}) + +it.each([ + ['giant Next pitch', `next-app-loader?page=${'segment/'.repeat(5000)}!`], + ['virtual scheme', 'virtual:next-entry'], + ['virtual ID', '\0next-entry'], + ['empty resource', 'loader?pitch=true!?resourceQueryOnly'], +])('skips virtual or pitch-only entry %s', async (_name, entry) => { + const reachable = spyOn(pluginUtils, 'computeReachableFiles') + try { + compiler = webpack({ + context: root, + mode: 'production', + entry, + plugins: [new DevupUIWebpackPlugin()], + }) + expect(reachable.mock.calls[0]?.[0].entries).toEqual([]) + } finally { + reachable.mockRestore() + } +}) + +it.each([ + './src/main.ts', + './src/main.ts?resource=true', + 'first-loader?value=one!second-loader?value=two!./src/main.ts?resource=true', +])('prewarms the real resource of entry %s', async (entry) => { + await writeFile( + join(root, 'src/main.ts'), + "import {css} from '@devup-ui/react'; export const style = css({bg:'red'})", + ) + await writeFile( + join(root, 'src/unreachable.ts'), + "import {css} from '@devup-ui/react'; export const style = css({bg:'blue'})", + ) + compiler = webpack({ + context: root, + mode: 'production', + entry, + plugins: [new DevupUIWebpackPlugin({ singleCss: true })], + }) + expect(wasm.getCss(null, false)).toContain('red') + expect(wasm.getCss(null, false)).not.toContain('blue') +}) + +it('propagates invalid real entry resource I/O instead of skipping it', () => { + expect(() => + webpack({ + context: root, + entry: 'loader!./src/\0.ts?resource=true', + plugins: [new DevupUIWebpackPlugin()], + }), + ).toThrow() +}) diff --git a/packages/webpack-plugin/src/__tests__/css-loader.test.ts b/packages/webpack-plugin/src/__tests__/css-loader.test.ts index 5e5f80fee..cff741693 100644 --- a/packages/webpack-plugin/src/__tests__/css-loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/css-loader.test.ts @@ -12,6 +12,7 @@ import { } from 'bun:test' import devupUICssLoader from '../css-loader' +import { servedCss } from '../served-css' type CodeExtractResult = ReturnType type CssLoaderThis = ThisParameterType @@ -34,12 +35,15 @@ function createCodeExtractResult( function createCssLoaderContext( resourcePath: string, callback: ReturnType, + compilation?: object, ): CssLoaderThis { return { callback, + cacheable: mock(), addContextDependency: mock(), resourcePath, getOptions: () => ({ watch: resourcePath.includes('devup-ui') }), + _compilation: compilation, } as unknown as CssLoaderThis } @@ -108,4 +112,22 @@ describe('devupUICssLoader', () => { expect(getCssSpy).toBeCalledTimes(1) }) + + it('records the stylesheet it serves in its compilation, uncached', () => { + const callback = mock() + const compilation = {} + getCssSpy.mockReturnValue('base css') + const context = createCssLoaderContext( + '/df/devup-ui/devup-ui.css', + callback, + compilation, + ) + devupUICssLoader.bind(context)(Buffer.from(''), '') + + expect(context.cacheable).toHaveBeenCalledWith(false) + expect(servedCss(compilation)).toEqual( + new Map([['/df/devup-ui/devup-ui.css', 'base css']]), + ) + expect(servedCss(compilation)).toBe(servedCss(compilation)) + }) }) diff --git a/packages/webpack-plugin/src/__tests__/loader.test.ts b/packages/webpack-plugin/src/__tests__/loader.test.ts index 8697cb090..6c8efe49a 100644 --- a/packages/webpack-plugin/src/__tests__/loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/loader.test.ts @@ -1,7 +1,12 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import * as fsPromises from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' import * as nodePath from 'node:path' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { createModuleResolver } from '@devup-ui/plugin-utils' import * as wasm from '@devup-ui/wasm' import { afterEach, @@ -21,7 +26,7 @@ type LoaderThis = ThisParameterType type LoaderCallback = ReturnType interface TestLoaderContext extends Pick< LoaderThis, - 'async' | 'resourcePath' | 'addDependency' + 'async' | 'resourcePath' | 'addDependency' | 'addMissingDependency' > { getOptions: () => Partial _compiler?: { __DEVUP_CACHE: string } @@ -53,6 +58,7 @@ function createLoaderContext( async: mock().mockReturnValue(callback), resourcePath, addDependency: mock(), + addMissingDependency: mock(), ...overrides, } as unknown as LoaderThis } @@ -125,6 +131,224 @@ const waitFor = async (fn: () => void, timeout = 1000) => { } describe('devupUILoader', () => { + it('refreshes same-root configuration and isolates watches when successive loaders evaluate imports', async () => { + // Given unchanged loader options and separately owned dependency subscriptions. + const rootDir = mkdtempSync(join(tmpdir(), 'loader-inputs-')) + writeFileSync(join(rootDir, 'red.ts'), "export const color='red'") + writeFileSync(join(rootDir, 'blue.ts'), "export const color='blue'") + const selected: (string | undefined)[] = [] + const observed = [mock(), mock(), mock()] + const missing = [mock(), mock(), mock()] + const register = spyOn(wasm, 'setModuleResolver').mockImplementation( + (resolver: ReturnType) => { + selected.push(resolver('color', 'main.ts')?.code) + }, + ) + try { + // When a missing config is created and its inherited target changes at the same root. + for (const index of [0, 1, 2]) { + if (index === 1) + writeFileSync( + join(rootDir, 'tsconfig.json'), + '{"extends":"./base.json"}', + ) + if (index > 0) + writeFileSync( + join(rootDir, 'base.json'), + JSON.stringify({ + compilerOptions: { + paths: { color: [index === 1 ? 'red.ts' : 'blue.ts'] }, + }, + }), + ) + await new Promise((done) => { + const context = createLoaderContext( + { rootDir, cssDir: join(rootDir, 'df') }, + () => done(), + join(rootDir, 'main.ts'), + { + addDependency: observed[index], + addMissingDependency: missing[index], + }, + ) + devupUILoader.call(context, Buffer.from('export {}')) + }) + } + // Then each invocation sees current config and watches only through its own context. + expect(selected).toEqual([ + undefined, + "export const color='red'", + "export const color='blue'", + ]) + expect(missing[0]?.mock.calls).toContainEqual([ + join(rootDir, 'tsconfig.json'), + ]) + expect(observed[1]?.mock.calls).toContainEqual([join(rootDir, 'red.ts')]) + expect(observed[2]?.mock.calls).toContainEqual([join(rootDir, 'blue.ts')]) + expect(observed[0]?.mock.calls).not.toContainEqual([ + join(rootDir, 'blue.ts'), + ]) + expect(observed[1]?.mock.calls).not.toContainEqual([ + join(rootDir, 'blue.ts'), + ]) + } finally { + register.mockRestore() + rmSync(rootDir, { recursive: true, force: true }) + } + }) + it('selects a fresh native alias resolver when ordered entries change at the same root', async () => { + const root = mkdtempSync(join(tmpdir(), 'loader-alias-')) + const target = join(root, 'chosen.js') + writeFileSync(target, 'export const value="blue"') + const selected: unknown[] = [] + const register = spyOn(wasm, 'setModuleResolver').mockImplementation( + (resolver: ReturnType) => { + selected.push(resolver('provider', 'src/main.ts')) + }, + ) + try { + for (const alias of [ + [{ name: 'provider', alias: false }], + [ + { name: 'provider', alias: target }, + { name: 'provider', alias: false }, + ], + [{ name: 'provider', alias: false }], + ] as const) { + await new Promise((done) => { + const context = createLoaderContext( + { rootDir: root, alias, cssDir: join(root, 'df') }, + () => done(), + ) + devupUILoader.call(context, Buffer.from('export {}')) + }) + } + expect(selected).toEqual([ + { ignored: true }, + { path: 'chosen.js', code: 'export const value="blue"' }, + { ignored: true }, + ]) + } finally { + register.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }) + it('keys resolver reuse by the explicit project root and active conditions', async () => { + const root = mkdtempSync( + join(process.env.DEVUP_PLUGIN_TEST_TMP ?? tmpdir(), 'loader-'), + ) + for (const project of ['first', 'second']) { + const library = join(root, project, 'node_modules', 'conditional') + mkdirSync(library, { recursive: true }) + writeFileSync( + join(library, 'package.json'), + JSON.stringify({ + exports: { browser: './browser.js', node: './node.js' }, + }), + ) + writeFileSync(join(library, 'browser.js'), project + '-browser') + writeFileSync(join(library, 'node.js'), project + '-node') + } + const selected: (string | undefined)[] = [] + const register = spyOn(wasm, 'setModuleResolver').mockImplementation( + (resolver: ReturnType) => { + selected.push(resolver('conditional', 'src/main.ts')?.code) + }, + ) + try { + for (const [project, condition] of [ + ['first', 'browser'], + ['first', 'node'], + ['second', 'node'], + ]) { + const rootDir = join(root, project) + await new Promise((done) => { + const context = createLoaderContext( + { + rootDir, + conditions: [condition], + cssDir: join(rootDir, 'df/devup-ui'), + }, + () => done(), + join(rootDir, 'src/main.ts'), + ) + devupUILoader.bind(context)(Buffer.from('code')) + }) + } + expect(selected).toEqual(['first-browser', 'first-node', 'second-node']) + } finally { + register.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }) + it('extracts actual compiled MDX while retaining the original resource filename', async () => { + const landing = createRequire( + nodePath.resolve( + import.meta.dir, + '../../../../apps/landing/package.json', + ), + ) + const mdx = await import( + pathToFileURL( + createRequire(landing.resolve('@mdx-js/loader')).resolve('@mdx-js/mdx'), + ).href + ) + const rootDir = process.cwd() + const filename = nodePath.resolve(rootDir, 'src/page.mdx') + const compiled = await mdx.compile({ + value: + 'import {Box} from \'@devup-ui/react\'\n\n# Heading\n\n', + path: filename, + }) + codeExtractSpy.mockRestore() + wasm.resetBuildState() + const callback = mock() + const context = createLoaderContext( + { + package: '@devup-ui/react', + cssDir: nodePath.resolve('df/devup-ui'), + rootDir, + singleCss: true, + }, + callback, + filename, + ) + + await new Promise((done) => { + callback.mockImplementation(() => done()) + devupUILoader.bind(context)(Buffer.from(String(compiled))) + }) + + expect(callback.mock.calls[0][0]).toBeNull() + expect(callback.mock.calls[0][1]).not.toContain('bg: "red"') + expect(callback.mock.calls[0][1]).toContain('devup-ui.css') + }) + + it.each([ + undefined, + { version: 3, sources: ['authored.mdx'], names: [], mappings: 'AAGE' }, + ])( + 'locates MDX errors with the supplied compiler map %j', + async (inputMap) => { + const callback = mock() + const rootDir = nodePath.resolve('/project') + const filename = nodePath.join(rootDir, 'page.mdx') + codeExtractSpy.mockImplementation(() => { + throw new Error('page.mdx:1:1: cannot extract') + }) + const context = createLoaderContext( + { rootDir, cssDir: nodePath.join(rootDir, 'df/devup-ui') }, + callback, + filename, + ) + + devupUILoader.bind(context)(Buffer.from('compiled'), inputMap) + + expect(callback.mock.calls[0][0].message).toContain( + inputMap ? 'authored.mdx:4:3' : 'page.mdx:1:1 (in compiled MDX)', + ) + }, + ) it('resolves imports to cwd-relative ids and depends on the modules read', async () => { const setModuleResolverSpy = spyOn( wasm, diff --git a/packages/webpack-plugin/src/__tests__/mdx-build-fixture.ts b/packages/webpack-plugin/src/__tests__/mdx-build-fixture.ts new file mode 100644 index 000000000..898869ac6 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/mdx-build-fixture.ts @@ -0,0 +1,147 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +export const mdxLanding = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) + +export const guardCases = [ + ['used Box', 'import {Box} from "@devup-ui/react"\n\n', true], + ['unused Box', 'import {Box} from "@devup-ui/react"\n\n# Plain', false], + [ + 'runtime helper', + 'import {getTheme} from "@devup-ui/react"\n\nexport const theme = getTheme\n\n# Plain', + false, + ], + [ + 'namespace css', + 'import * as DU from "@devup-ui/react"\n\nexport const style = DU.css({color:"red"})\n\n# Plain', + true, + ], + [ + 'namespace destructure', + 'import * as DU from "@devup-ui/react"\n\nexport const {css} = DU\n\n# Plain', + true, + ], + [ + 'opaque namespace', + 'import * as DU from "@devup-ui/react"\n\nexport const namespace = DU\n\n# Plain', + false, + ], + [ + 'active alias', + 'import styled from "@emotion/styled"\n\nexport const component = styled("div")\n\n# Plain', + true, + ], + [ + 'runtime alias', + 'import {ThemeProvider} from "styled-components"\n\n', + false, + ], + [ + 'unmapped alias', + 'import {CacheProvider} from "@emotion/react"\n\n', + false, + ], + [ + 'ordinary alias Box', + 'import {Box} from "ordinary-provider"\n\n', + false, + ], + ['actual alias Box', 'import {Box} from "opaque-provider"\n\n', true], + [ + 'shadowed namespace', + 'import * as DU from "@devup-ui/react"\n\nexport function use(DU){return DU.css()}\n\n# Plain', + false, + ], +] as const + +export const cjsGuardCases = [ + [ + 'require member', + 'export const value=require("@devup-ui/react").css({color:"red"})', + true, + ], + [ + 'require namespace', + 'export const DU=require("@devup-ui/react")\n\nexport const value=DU.css({color:"red"})', + true, + ], + [ + 'require destructure', + 'export const {css:value}=require("@devup-ui/react")', + true, + ], + [ + 'require runtime', + 'export const value=require("@devup-ui/react").getTheme', + false, + ], + ['require opaque', 'export const value=require("@devup-ui/react")', false], + [ + 'require shadowed', + 'export function f(require){return require("@devup-ui/react").css()}', + false, + ], + ['dynamic opaque', 'export const value=import("@devup-ui/react")', false], +] as const + +export function mdxBuildFixture(source: string, extension = '.mdown') { + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-mdx-build-')), + ) + const filename = join(root, `page${extension}`) + writeFileSync(filename, source) + writeFileSync( + join(root, 'ordinary.js'), + 'export const Box = "div"; export const CacheProvider = "div"; export const ThemeProvider = "div"; export default () => "div"', + ) + writeFileSync(join(root, 'barrel.js'), 'export * from "./forward.js"') + writeFileSync( + join(root, 'forward.js'), + 'export {Box as Widget} from "@devup-ui/react"', + ) + mkdirSync(join(root, 'node_modules/skipped'), { recursive: true }) + writeFileSync( + join(root, 'node_modules/skipped/package.json'), + '{"name":"skipped","main":"index.js"}', + ) + writeFileSync( + join(root, 'node_modules/skipped/index.js'), + 'import {Box} from "@devup-ui/react"; export const component = Box', + ) + const devup = mdxLanding.resolve('@devup-ui/react') + const alias = { + '@devup-ui/react': devup, + 'opaque-provider': devup, + 'ordinary-provider': join(root, 'ordinary.js'), + '@emotion/styled': join(root, 'ordinary.js'), + '@emotion/react': join(root, 'ordinary.js'), + 'styled-components': join(root, 'ordinary.js'), + } + const rule = { + test: /\.(?:mdown|mdx)$/i, + type: 'javascript/auto', + use: [ + { + loader: mdxLanding.resolve('@mdx-js/loader'), + options: { format: 'mdx', development: false }, + }, + ], + } + return { + root, + filename, + alias, + rule, + cleanup: () => rmSync(root, { recursive: true, force: true }), + } +} diff --git a/packages/webpack-plugin/src/__tests__/mdx-extensions.test.ts b/packages/webpack-plugin/src/__tests__/mdx-extensions.test.ts new file mode 100644 index 000000000..425e6c984 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/mdx-extensions.test.ts @@ -0,0 +1,83 @@ +import * as wasm from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import type { Compiler, Configuration, Stats } from 'webpack' + +import { DevupUIWebpackPlugin } from '../plugin' +import { + cjsGuardCases, + guardCases, + mdxBuildFixture, + mdxLanding, +} from './mdx-build-fixture' + +const bundled: { webpack(config: Configuration): Compiler } = mdxLanding( + 'next/dist/compiled/webpack/webpack', +) + +async function bundle(source: string, selected = false, extension = '.mdown') { + wasm.resetBuildState() + const fixture = mdxBuildFixture(source, extension) + const compiler = bundled.webpack({ + context: fixture.root, + mode: 'development', + entry: fixture.filename, + output: { path: `${fixture.root}/out` }, + resolve: { alias: fixture.alias }, + externals: { + react: 'commonjs react', + 'react/jsx-runtime': 'commonjs react/jsx-runtime', + }, + experiments: { css: true }, + optimization: { minimize: false }, + module: { rules: [fixture.rule, { test: /\.css$/, type: 'css' }] }, + plugins: [ + new DevupUIWebpackPlugin({ + singleCss: true, + mdxExtensions: selected ? ['.mdx', '.mdown'] : undefined, + }), + ], + }) + try { + const stats = await new Promise((done, reject) => + compiler.run((error, result) => { + if (error) reject(error) + else if (result) done(result) + else reject(new Error('Missing build stats')) + }), + ) + return { + errors: stats.toJson({ all: false, errors: true }).errors ?? [], + css: wasm.getCss(null, false), + } + } finally { + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + fixture.cleanup() + } +} + +it('extracts custom MDX with the same isolated CSS as .mdx', async () => { + const source = 'import {Box} from "@devup-ui/react"\n\n' + const standard = await bundle(source, true, '.mdx') + const custom = await bundle(source, true) + expect(custom.errors).toEqual([]) + expect(custom.css).toContain('red') + expect(custom.css).toBe(standard.css) +}) + +it.each([...guardCases, ...cjsGuardCases])( + 'uses final dependencies to guard %s', + async (_name, source, errors) => { + const result = await bundle(source) + expect(result.errors.length > 0).toBe(errors) + if (errors) expect(result.errors[0]?.message).toContain('mdxExtensions') + }, +) + +it('follows an actual forwarded Widget export to Box', async () => { + const result = await bundle( + 'import {Widget} from "./barrel.js"\n\n', + ) + expect(result.errors[0]?.message).toContain('mdxExtensions') +}) diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 2692ead30..e450adb5d 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -17,19 +17,29 @@ import { import type { Compiler } from 'webpack' import { DevupUIWebpackPlugin } from '../plugin' +import { servedCss } from '../served-css' type CodeExtractResult = ReturnType interface MockCompiler { options: { module: { rules: unknown[] } plugins: unknown[] + entry?: unknown + context?: string + } + watchMode?: boolean + webpack: { + DefinePlugin: ReturnType + Compilation: { PROCESS_ASSETS_STAGE_REPORT: number } } - webpack: { DefinePlugin: ReturnType } hooks: { watchRun: { tapPromise: ReturnType } beforeRun: { tapPromise: ReturnType } done: { tapPromise: ReturnType } afterCompile: { tap: ReturnType } + run: { tap: ReturnType } + thisCompilation: { tap: ReturnType } + shutdown: { tap: ReturnType } } } @@ -76,6 +86,8 @@ let mkdirSpy: ReturnType let readFileSpy: ReturnType let statSpy: ReturnType let writeFileSpy: ReturnType +let graphSpy: ReturnType +const closeBuilds = new Set<() => void>() beforeEach(() => { codeExtractSpy = spyOn(wasm, 'codeExtract').mockImplementation( @@ -102,9 +114,19 @@ beforeEach(() => { readFileSpy = spyOn(fsPromises, 'readFile').mockResolvedValue('{}') statSpy = spyOn(fsPromises, 'stat').mockResolvedValue(createStats(0)) writeFileSpy = spyOn(fsPromises, 'writeFile').mockResolvedValue(undefined) + graphSpy = spyOn(pluginUtils, 'buildStaticImportGraph').mockReturnValue({ + files: [], + fileSet: new Set(), + staticImports: new Map(), + staticImporters: new Map(), + dynamicImports: new Map(), + dynamicTargets: new Set(), + }) }) afterEach(() => { + for (const closeBuild of closeBuilds) closeBuild() + closeBuilds.clear() codeExtractSpy.mockRestore() getCssSpy.mockRestore() getDefaultThemeSpy.mockRestore() @@ -124,6 +146,7 @@ afterEach(() => { readFileSpy.mockRestore() statSpy.mockRestore() writeFileSpy.mockRestore() + graphSpy.mockRestore() }) function createCompiler(): MockCompiler { @@ -136,6 +159,7 @@ function createCompiler(): MockCompiler { }, webpack: { DefinePlugin: mock(), + Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 }, }, hooks: { watchRun: { @@ -150,6 +174,17 @@ function createCompiler(): MockCompiler { afterCompile: { tap: mock(), }, + run: { + tap: mock(), + }, + thisCompilation: { + tap: mock(), + }, + shutdown: { + tap: mock((_name: string, closeBuild: () => void) => { + closeBuilds.add(closeBuild) + }), + }, }, } } @@ -157,11 +192,43 @@ function createCompiler(): MockCompiler { describe('devupUIWebpackPlugin', () => { console.error = mock() + describe('deterministic file numbering', () => { + it('numbers the files the scan finds and ends the build at shutdown', () => { + const collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue(undefined) + try { + collectSpy.mockImplementation((options: any) => { + expect(options.toId(resolve('src', 'a.tsx'))).toBe('src/a.tsx') + return ['src/a.tsx', 'src/b.tsx'] + }) + const compiler = createCompiler() + new DevupUIWebpackPlugin({ include: ['@acme/ui'] }).apply( + asCompiler(compiler), + ) + expect(seedSpy).toHaveBeenCalledWith(['src/a.tsx', 'src/b.tsx']) + expect(collectSpy.mock.calls[0][0]).toMatchObject({ + roots: [], + include: ['@acme/ui'], + }) + compiler.hooks.shutdown.tap.mock.calls[0][1]() + compiler.hooks.shutdown.tap.mock.calls[0][1]() + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) + new DevupUIWebpackPlugin({}).apply(asCompiler(createCompiler())) + } finally { + collectSpy.mockRestore() + seedSpy.mockRestore() + resetSpy.mockRestore() + } + }) + }) it('should apply default options', () => { expect(new DevupUIWebpackPlugin({}).options).toEqual({ include: [], package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), + cssDir: join('df', 'devup-ui'), devupFile: 'devup.json', distDir: 'df', watch: false, @@ -217,25 +284,20 @@ describe('devupUIWebpackPlugin', () => { 'CustomShadows', 'DevupTheme', ) - if (_options.getThemeInterface) - expect(writeFileSyncSpy).toHaveBeenCalledWith( - join(options.distDir, 'theme.d.ts'), - _options.getThemeInterface, - { - encoding: 'utf-8', - }, - ) - else expect(writeFileSyncSpy).toHaveBeenCalledTimes(options.watch ? 1 : 0) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(options.distDir, 'theme.d.ts'), + _options.getThemeInterface, + { + encoding: 'utf-8', + }, + ) if (options.watch) expect(writeFileSyncSpy).toHaveBeenCalledWith( join(options.cssDir, 'devup-ui.css'), _options.getCss, ) - else - expect(writeFileSyncSpy).toHaveBeenCalledTimes( - _options.getThemeInterface ? 1 : 0, - ) + else expect(writeFileSyncSpy).toHaveBeenCalledTimes(1) }) }) @@ -270,10 +332,10 @@ describe('devupUIWebpackPlugin', () => { const compiler = createCompiler() await plugin.apply(asCompiler(compiler)) - expect(compiler.options.module.rules.length).toBe(2) + expect(compiler.options.module.rules.length).toBe(3) expect(compiler.options.module.rules[0].exclude).toEqual( - options.include.output, + pluginUtils.createNodeModulesExcludeRegex(options.include.input), ) }) @@ -295,7 +357,8 @@ describe('devupUIWebpackPlugin', () => { watch: true, }) const compiler = createCompiler() - readFileSyncSpy.mockImplementation(() => { + readFileSyncSpy.mockImplementation((file: string) => { + if (file.endsWith('tsconfig.json')) return '{}' throw new Error('error') }) statSpy.mockReturnValue(createStats(1)) @@ -395,16 +458,30 @@ describe('devupUIWebpackPlugin', () => { expect(statSpy).not.toHaveBeenCalled() } } else expect(compiler.hooks.watchRun.tapPromise).not.toHaveBeenCalled() + const resolutionTap = compiler.hooks.afterCompile.tap.mock.calls.find( + ([name]) => name === 'DevupUIResolutionInputs', + ) + expect(resolutionTap).toBeDefined() + const inputFiles = new Set() + const missingInputs = new Set() + resolutionTap?.[1]({ + fileDependencies: inputFiles, + missingDependencies: missingInputs, + }) + expect(missingInputs).toContain(resolve('tsconfig.json')) + const themeTaps = compiler.hooks.afterCompile.tap.mock.calls.filter( + ([name]) => name === 'DevupUIWebpackPlugin', + ) if (options.existsDevupFile) { expect(compiler.hooks.afterCompile.tap).toHaveBeenCalled() const add = mock() - compiler.hooks.afterCompile.tap.mock.calls[0][1]({ + themeTaps[0]?.[1]({ fileDependencies: { add, }, }) expect(add).toHaveBeenCalledWith(resolve(plugin.options.devupFile)) - } else expect(compiler.hooks.afterCompile.tap).not.toHaveBeenCalled() + } else expect(themeTaps).toHaveLength(0) if (options.existsCssDir) { expect(mkdirSpy).not.toHaveBeenCalledWith(plugin.options.cssDir, { recursive: true, @@ -459,7 +536,7 @@ describe('devupUIWebpackPlugin', () => { let importCanonicalMapSpy: ReturnType let importFileRoutesSpy: ReturnType let setAtomHoistSpy: ReturnType - let listSourceFilesSpy: ReturnType + let computeReachableFilesSpy: ReturnType beforeEach(() => { buildCanonicalMapSpy = spyOn( @@ -478,9 +555,9 @@ describe('devupUIWebpackPlugin', () => { ) setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue(undefined) // Default: no source files so pre-warm is a no-op unless a test opts in. - listSourceFilesSpy = spyOn( + computeReachableFilesSpy = spyOn( pluginUtils, - 'listSourceFiles', + 'computeReachableFiles', ).mockReturnValue([]) }) @@ -490,7 +567,7 @@ describe('devupUIWebpackPlugin', () => { importCanonicalMapSpy.mockRestore() importFileRoutesSpy.mockRestore() setAtomHoistSpy.mockRestore() - listSourceFilesSpy.mockRestore() + computeReachableFilesSpy.mockRestore() }) it('runs single-importer collapse even when atomHoist is unset (always-on), without hoisting', () => { @@ -511,15 +588,15 @@ describe('devupUIWebpackPlugin', () => { expect(importFileRoutesSpy).not.toHaveBeenCalled() }) - it('pre-warms the extractor over all source files in build mode when collapse is active', () => { - // Without pre-warm, webpack builds each shared devup-ui-N.css ONCE at - // first import — before later bucket members are extracted — so their - // atoms are dropped. Pre-warming the sheet makes getCss(N) complete from - // the first css-loader call. + it('pre-warms the extractor over the files the entries reach in build mode', () => { + // Without pre-warm, webpack builds each shared stylesheet ONCE at first + // import — before later files are extracted — so their styles are + // dropped. Pre-warming the sheet makes getCss complete from the first + // css-loader call. buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), resolve(process.cwd(), 'src', 'child.tsx'), ]) @@ -532,8 +609,23 @@ describe('devupUIWebpackPlugin', () => { package: '@devup-ui/react', singleCss: true, }) - plugin.apply(asCompiler(createCompiler())) - expect(listSourceFilesSpy).toHaveBeenCalled() + const compiler = createCompiler() + compiler.options.entry = { + main: { import: ['./src/parent', './src/other'] }, + worker: {}, + } + compiler.options.context = '/project' + plugin.apply(asCompiler(compiler)) + expect(computeReachableFilesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + srcDir: [], + tsconfigPath: resolve('/project', 'tsconfig.json'), + entries: [ + resolve('/project', './src/parent'), + resolve('/project', './src/other'), + ], + }), + ) // The loader's resolver is registered before the first extraction, so // imported constants inline as they do in the loader expect(setModuleResolverSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -544,12 +636,12 @@ describe('devupUIWebpackPlugin', () => { importer: string, ) => { path: string } | undefined expect(resolveModule('./plugin.test', import.meta.path)?.path).toBe( - relative(process.cwd(), import.meta.path).replaceAll('\\', '/'), + relative('/project', import.meta.path).replaceAll('\\', '/'), ) setModuleResolverSpy.mockRestore() expect(codeExtractSpy).toHaveBeenCalledTimes(2) expect(codeExtractSpy).toHaveBeenCalledWith( - 'src/parent.tsx', + relative('/project', resolve('src/parent.tsx')).replaceAll('\\', '/'), 'source', '@devup-ui/react', expect.any(String), @@ -559,7 +651,7 @@ describe('devupUIWebpackPlugin', () => { expect.anything(), ) expect(codeExtractSpy).toHaveBeenCalledWith( - 'src/child.tsx', + relative('/project', resolve('src/child.tsx')).replaceAll('\\', '/'), 'source', '@devup-ui/react', expect.any(String), @@ -570,22 +662,21 @@ describe('devupUIWebpackPlugin', () => { ) }) - it('skips pre-warm when the canonical map is empty (no collapse, no race)', () => { + it('pre-warms without collapse, from no entries when they are a function', () => { buildCanonicalMapSpy.mockReturnValue({}) - listSourceFilesSpy.mockReturnValue([ - resolve(process.cwd(), 'src', 'a.tsx'), - ]) - readFileSyncSpy.mockReturnValue('source') - const plugin = new DevupUIWebpackPlugin({}) - plugin.apply(asCompiler(createCompiler())) - expect(codeExtractSpy).not.toHaveBeenCalled() + const compiler = createCompiler() + compiler.options.entry = () => ({}) + new DevupUIWebpackPlugin({}).apply(asCompiler(compiler)) + expect(computeReachableFilesSpy).toHaveBeenCalledWith( + expect.objectContaining({ entries: [] }), + ) }) it('skips pre-warm in watch mode (race only affects one-shot builds)', () => { buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), ]) readFileSyncSpy.mockReturnValue('source') @@ -594,11 +685,11 @@ describe('devupUIWebpackPlugin', () => { expect(codeExtractSpy).not.toHaveBeenCalled() }) - it('swallows pre-warm errors (extraction failure does not break apply)', () => { + it('fails with file and cause when pre-warm extraction fails', () => { buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), ]) readFileSyncSpy.mockReturnValue('source') @@ -606,8 +697,9 @@ describe('devupUIWebpackPlugin', () => { throw new Error('extract boom') }) const plugin = new DevupUIWebpackPlugin({}) - // apply must still complete without throwing - plugin.apply(asCompiler(createCompiler())) + expect(() => plugin.apply(asCompiler(createCompiler()))).toThrow( + /prewarm failed.*parent.tsx.*extract boom/, + ) expect(codeExtractSpy).toHaveBeenCalled() }) @@ -655,13 +747,14 @@ describe('devupUIWebpackPlugin', () => { expect(importFileRoutesSpy).not.toHaveBeenCalled() }) - it('swallows pre-pass errors (atom hoisting stays off)', () => { + it('fails with root and cause when graph setup fails', () => { buildCanonicalMapSpy.mockImplementation(() => { throw new Error('boom') }) const plugin = new DevupUIWebpackPlugin({ atomHoist: 2 }) - // apply must still complete without throwing - plugin.apply(asCompiler(createCompiler())) + expect(() => plugin.apply(asCompiler(createCompiler()))).toThrow( + /graph setup failed.*boom/, + ) expect(setAtomHoistSpy).not.toHaveBeenCalled() }) @@ -682,4 +775,112 @@ describe('devupUIWebpackPlugin', () => { expect(compiler.options.module.rules.length).toBeGreaterThan(0) }) }) + + describe('another pass for stylesheets built too early', () => { + function compile(watch = false) { + const compiler = createCompiler() + const plugin = new DevupUIWebpackPlugin({ watch }) + plugin.apply(asCompiler(compiler)) + const taps: Record unknown> = {} + const tap = + (hook: string) => (_: unknown, fn: (...args: unknown[]) => unknown) => { + taps[hook] = fn + } + const compilation = { + assets: { 'main.js': {}, 'main.css': {} }, + deleteAsset: mock(), + hooks: { + finishModules: { tap: tap('finishModules') }, + processAssets: { tap: tap('processAssets') }, + needAdditionalPass: { tap: tap('needAdditionalPass') }, + }, + } + const run = compiler.hooks.run.tap.mock.calls[0]?.[1] as () => void + const start = () => { + ;( + compiler.hooks.thisCompilation.tap.mock.calls[0]![1] as ( + compilation: object, + ) => void + )(compilation) + } + return { compiler, plugin, compilation, taps, run, start } + } + + it('does not request another CSS pass in watch mode', () => { + const { compiler } = compile(true) + expect(compiler.hooks.run.tap).not.toHaveBeenCalled() + expect( + compiler.hooks.thisCompilation.tap.mock.calls.map(([name]) => name), + ).not.toContain('DevupUIWebpackPlugin') + }) + + it('writes the shared base to disk when it does not hold it yet', () => { + getCssSpy.mockReturnValue('base') + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockImplementation((file: string) => + file.endsWith('tsconfig.json') ? '{}' : 'base', + ) + const { plugin, start } = compile() + writeFileSyncSpy.mockClear() + start() + expect(writeFileSyncSpy).not.toHaveBeenCalled() + + readFileSyncSpy.mockReturnValue('older') + start() + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(plugin.options.cssDir, 'devup-ui.css'), + 'base', + 'utf-8', + ) + }) + + it('compiles once more, writing no file, when a stylesheet changed', () => { + getCssSpy.mockReturnValue('before') + const { compiler, compilation, plugin, taps, run, start } = compile() + start() + servedCss(compilation).set('/df/devup-ui/devup-ui-1.css', 'before') + getCssSpy.mockReturnValue('after') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).toHaveBeenCalledWith('main.js') + expect(compilation.deleteAsset).toHaveBeenCalledWith('main.css') + writeFileSyncSpy.mockClear() + expect(taps.needAdditionalPass!()).toBe(true) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + '/df/devup-ui/devup-ui-1.css', + 'after', + 'utf-8', + ) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(plugin.options.cssDir, 'devup-ui.css'), + 'after', + 'utf-8', + ) + + // one more pass per run at most + compilation.deleteAsset.mockClear() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBeUndefined() + run() + expect(taps.needAdditionalPass!()).toBe(true) + + // a watching compiler rebuilds through the files the loaders write + compiler.watchMode = true + start() + taps.finishModules!() + expect(taps.needAdditionalPass!()).toBeUndefined() + }) + + it('keeps the pass when every stylesheet is current', () => { + getCssSpy.mockReturnValue('same') + const { compilation, taps, start } = compile() + start() + servedCss(compilation).set('/df/devup-ui/devup-ui-1.css', 'same') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBeUndefined() + }) + }) }) diff --git a/packages/webpack-plugin/src/__tests__/resolution-watch.mjs b/packages/webpack-plugin/src/__tests__/resolution-watch.mjs new file mode 100644 index 000000000..8b4e10c1c --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/resolution-watch.mjs @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { DevupUIWebpackPlugin } from '../../dist/index.mjs' + +const landing = createRequire( + join(import.meta.dirname, '../../../../apps/landing/package.json'), +) +const { webpack } = landing('next/dist/compiled/webpack/webpack') +const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'webpack-resolution-watch-')), +) +const root = join(fixture, 'apps/app') +const palette = join(fixture, 'packages/palette') +const entry = join(root, 'src/main.js') +const manifest = join(palette, 'package.json') +const preserveSymlinks = process.argv[2] === 'preserve' +let compiler +let watching +let next +const pending = [] +const nextBuild = () => + new Promise((resolveBuild, rejectBuild) => { + const timer = setTimeout( + () => rejectBuild(new Error('No real exports-only rebuild')), + 12000, + ) + timer.unref() + next = (error, stats) => { + clearTimeout(timer) + next = undefined + error ? rejectBuild(error) : resolveBuild(stats) + } + if (pending.length) next(...pending.shift()) + }) +const state = async (stats) => { + assert.equal( + stats.hasErrors(), + false, + stats.toString({ all: false, errors: true }), + ) + const module = [...stats.compilation.modules].find( + (candidate) => candidate.resource === entry, + ) + const code = module.originalSource().source().toString() + const selected = code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + assert.ok(selected, code) + const css = ( + await Promise.all( + Object.keys(stats.compilation.assets) + .filter((name) => name.endsWith('.css')) + .map((name) => + readFile(join(stats.compilation.outputOptions.path, name), 'utf-8'), + ), + ) + ).join('\n') + return { code, selected, css } +} +const configuration = (watch) => ({ + context: root, + mode: 'development', + entry: './src/main.js', + cache: false, + output: { path: join(root, watch ? 'out' : 'cold') }, + resolve: { + symlinks: !preserveSymlinks, + alias: { '@devup-ui/react': landing.resolve('@devup-ui/react') }, + }, + experiments: { css: true }, + module: { rules: [{ test: /\.css$/, type: 'css' }] }, + optimization: { minimize: false }, + plugins: [new DevupUIWebpackPlugin({ watch, singleCss: false })], +}) +try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules'), { recursive: true }) + await mkdir(palette, { recursive: true }) + await symlink(palette, join(root, 'node_modules/palette'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{}') + await writeFile(manifest, '{"name":"palette","exports":"./red.js"}') + await writeFile(join(palette, 'red.js'), "export const color='red'") + await writeFile(join(palette, 'blue.js'), "export const color='blue'") + await writeFile( + entry, + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + compiler = webpack(configuration(true)) + const initial = nextBuild() + watching = compiler.watch({}, (error, stats) => + next ? next(error, stats) : pending.push([error, stats]), + ) + const beforeStats = await initial + const before = await state(beforeStats) + assert.ok( + before.css.includes(`.${before.selected}{background:red}`), + before.css, + ) + const watchedManifest = preserveSymlinks + ? join(root, 'node_modules/palette/package.json') + : manifest + assert.ok( + beforeStats.compilation.fileDependencies.has(watchedManifest), + `Manifest not registered: ${watchedManifest}`, + ) + const rebuilt = nextBuild() + const replacement = join(fixture, 'manifest-replacement.json') + await writeFile(replacement, '{"name":"palette","exports":"./blue.js"}') + await rename(replacement, manifest) + let after = await state(await rebuilt) + for ( + let attempt = 0; + attempt < 3 && after.selected === before.selected; + attempt += 1 + ) + after = await state(await nextBuild()) + assert.notEqual(after.selected, before.selected) + assert.ok( + after.css.includes(`.${after.selected}{background:blue}`), + after.css, + ) + await new Promise((done, reject) => + watching.close((error) => (error ? reject(error) : done())), + ) + watching = undefined + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + compiler = webpack(configuration(false)) + const coldStats = await new Promise((done, reject) => + compiler.run((error, stats) => (error ? reject(error) : done(stats))), + ) + const cold = await state(coldStats) + assert.ok(cold.css.includes('background:blue'), cold.css) + assert.ok(!cold.css.includes('background:red'), cold.css) + console.info( + JSON.stringify({ preserveSymlinks, watchedManifest, before, after, cold }), + ) +} finally { + if (watching) + await new Promise((done, reject) => + watching.close((error) => (error ? reject(error) : done())), + ) + if (compiler) + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + await rm(fixture, { recursive: true, force: true }) +} diff --git a/packages/webpack-plugin/src/__tests__/resolution-watch.test.ts b/packages/webpack-plugin/src/__tests__/resolution-watch.test.ts new file mode 100644 index 000000000..b0ec77f01 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/resolution-watch.test.ts @@ -0,0 +1,23 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +it.each(['canonical', 'preserve'])( + 'refreshes actual watch CSS when only linked package exports change with %s symlinks', + (mode) => { + // Given the real compiler fixture with two preexisting palette entries. + const runner = join(import.meta.dir, 'resolution-watch.mjs') + // When the runner edits only exports and awaits actual compiler callbacks. + const result = Bun.spawnSync(['node', runner, mode], { + stdout: 'pipe', + stderr: 'pipe', + }) + // Then emitted current-class CSS is blue and a cold build has no red. + expect({ + exitCode: result.exitCode, + error: result.stderr.toString(), + }).toMatchObject({ exitCode: 0 }) + expect(result.stdout.toString()).toContain('"after"') + }, + 45000, +) diff --git a/packages/webpack-plugin/src/compiled-guard.ts b/packages/webpack-plugin/src/compiled-guard.ts new file mode 100644 index 000000000..4e1ca767e --- /dev/null +++ b/packages/webpack-plugin/src/compiled-guard.ts @@ -0,0 +1,189 @@ +import { + compiledFacts, + createCompileTimeClassifier, + createDependencyGuard, + type DependencyGuardOptions, + type GuardFacts, + isCompileTimeAlias, + isSelectedSource, + UntransformedSourceError, +} from '@devup-ui/plugin-utils' +import type { Compiler, Dependency, Module } from 'webpack' + +interface ScriptParser { + readonly state: { readonly module: Module } + readonly hooks: { + readonly program: { + tap( + options: { readonly name: string; readonly stage: number }, + callback: (ast: unknown) => void, + ): void + } + } +} + +function isScriptParser(value: object): value is ScriptParser { + const hooks = 'hooks' in value ? value.hooks : undefined + const program = + hooks && typeof hooks === 'object' && 'program' in hooks + ? hooks.program + : undefined + return ( + !!program && + typeof program === 'object' && + 'tap' in program && + typeof program.tap === 'function' && + 'state' in value + ) +} + +export function registerCompiledGuard( + compiler: Compiler, + options: DependencyGuardOptions, +) { + const check = createDependencyGuard(options) + const classify = createCompileTimeClassifier(options.package) + compiler.hooks.thisCompilation.tap( + 'DevupUICompiledSourceGuard', + (compilation, { normalModuleFactory }) => { + const facts = new Map() + for (const type of [ + 'javascript/auto', + 'javascript/esm', + 'javascript/dynamic', + ]) + normalModuleFactory.hooks.parser + .for(type) + .tap('DevupUICompiledSourceGuard', (parser) => { + if (!isScriptParser(parser)) return + parser.hooks.program.tap( + { name: 'DevupUICompiledSourceGuard', stage: -10000 }, + (ast) => { + facts.set(parser.state.module, compiledFacts(ast)) + }, + ) + }) + compilation.hooks.finishModules.tap( + 'DevupUICompiledSourceGuard', + (modules) => { + const dependencies = new Map() + for (const module of modules) + for (const dependency of module.dependencies) + dependencies.set(dependency, dependency) + const errors = check(modules, { + graph: compilation.moduleGraph, + target(dependency) { + const actual = dependencies.get(dependency) + const module = actual && compilation.moduleGraph.getModule(actual) + return module && + 'resource' in module && + typeof module.resource === 'string' + ? module.resource + : undefined + }, + forwarded(dependency, ids) { + const actual = dependencies.get(dependency) + let module = actual && compilation.moduleGraph.getModule(actual) + let names = ids + const seen = new Set() + while (module && names.length && !seen.has(module)) { + seen.add(module) + if ( + 'resource' in module && + typeof module.resource === 'string' && + classify(module.resource, names) + ) + return true + const target = compilation.moduleGraph + .getExportsInfo(module) + .getExportInfo(names[0]) + .getTarget(compilation.moduleGraph) + module = target?.module + names = target?.export + ? [...target.export, ...names.slice(1)] + : [] + } + return false + }, + }) + function resolved(module: Module, request: string) { + const dependency = module.dependencies.find( + (item) => 'request' in item && item.request === request, + ) + return dependency + ? compilation.moduleGraph.getModule(dependency) + : undefined + } + function origins( + module: Module, + ids: readonly string[], + seen: ReadonlySet = new Set(), + ): Map { + if (seen.has(module)) return new Map() + const key = JSON.stringify([module.identifier(), ids]) + if ( + 'resource' in module && + typeof module.resource === 'string' && + classify(module.resource, ids) + ) + return new Map([[key, true]]) + const symbols = facts.get(module) + if (!symbols || !ids.length) return new Map() + const next = new Set([...seen, module]) + const direct = symbols.exports.get(ids[0]) + if (symbols.exports.has(ids[0])) { + const target = direct && resolved(module, direct.request) + return target && direct + ? origins(target, [...direct.ids, ...ids.slice(1)], next) + : new Map([[key, false]]) + } + if (ids[0] === 'default') return new Map() + return new Map( + symbols.stars.flatMap((request) => { + const target = resolved(module, request) + return target ? [...origins(target, ids, next)] : [] + }), + ) + } + for (const module of modules) { + if ( + !('resource' in module) || + typeof module.resource !== 'string' || + isSelectedSource(module.resource, options.mdxExtensions) || + errors.some((error) => error.filename === module.resource) + ) + continue + for (const ref of facts.get(module)?.references ?? []) { + const target = resolved(module, ref.request) + const targets = target + ? origins(target, ref.ids) + : new Map() + const prefix = ( + module.originalSource()?.source().toString() ?? '' + ).slice(0, ref.pos) + const reference = { + ...ref, + line: prefix.split('\n').length, + column: prefix.length - prefix.lastIndexOf('\n'), + } + if ( + isCompileTimeAlias(reference, options.importAliases) || + (targets.size === 1 && [...targets.values()][0]) + ) { + errors.push( + new UntransformedSourceError( + module.resource, + reference, + isCompileTimeAlias(reference, options.importAliases), + ), + ) + break + } + } + } + compilation.errors.push(...errors) + }, + ) + }, + ) +} diff --git a/packages/webpack-plugin/src/css-loader.ts b/packages/webpack-plugin/src/css-loader.ts index bc6344070..450d9e671 100644 --- a/packages/webpack-plugin/src/css-loader.ts +++ b/packages/webpack-plugin/src/css-loader.ts @@ -2,8 +2,14 @@ import { getFileNumByFilename } from '@devup-ui/plugin-utils' import { getCss } from '@devup-ui/wasm' import type { RawLoaderDefinitionFunction } from 'webpack' +import { servedCss } from './served-css' + const devupUICssLoader: RawLoaderDefinitionFunction = function (_, map, meta) { - const fileNum = getFileNumByFilename(this.resourcePath) - this.callback(null, getCss(fileNum, true), map, meta) + // The stylesheet comes from every extracted module, not from this file + this.cacheable(false) + const css = getCss(getFileNumByFilename(this.resourcePath), true) + if (this._compilation) + servedCss(this._compilation).set(this.resourcePath, css) + this.callback(null, css, map, meta) } export default devupUICssLoader diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index 7b7f5e758..43b1cbff8 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -1,7 +1,15 @@ import { writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' -import { createModuleResolver } from '@devup-ui/plugin-utils' +import { + createModuleResolver, + createStateWriter, + isMdxSource, + type ModuleAliasOptions, + remapMdxError, + type ResolutionInputObserver, + resolutionWatchPath, +} from '@devup-ui/plugin-utils' import { codeExtract, exportClassMap, @@ -21,6 +29,11 @@ export interface DevupUILoaderOptions { watch: boolean singleCss: boolean importAliases?: Record + rootDir?: string + conditions?: readonly string[] + mdxExtensions?: readonly string[] + alias?: ModuleAliasOptions + symlinks?: boolean } function toLoaderError(error: unknown): Error { @@ -34,18 +47,32 @@ function parseSourceMap(sourceMap: string | undefined): string | null { return sourceMap } -let moduleResolver: ReturnType | undefined - +const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), +) /** Resolve imports to the cwd-relative ids this loader extracts files under */ -function setCwdModuleResolver(): void { - moduleResolver ??= createModuleResolver({ - toId: (path) => relative(process.cwd(), path).replaceAll('\\', '/'), +function setCwdModuleResolver(options: { + readonly rootDir: string + readonly conditions: readonly string[] + readonly mdxExtensions: readonly string[] + readonly alias: ModuleAliasOptions | undefined + readonly onResolutionInputs: ResolutionInputObserver +}): void { + const { rootDir, conditions, mdxExtensions, alias, onResolutionInputs } = + options + const moduleResolver = createModuleResolver({ + cwd: rootDir, + includeMdx: mdxExtensions, + conditions, + alias, + onResolutionInputs, + toId: (path) => relative(rootDir, path).replaceAll('\\', '/'), }) setModuleResolver(moduleResolver) } const devupUILoader: RawLoaderDefinitionFunction = - function (source) { + function (source, inputSourceMap) { const { watch, package: libPackage, @@ -55,6 +82,11 @@ const devupUILoader: RawLoaderDefinitionFunction = fileMapFile, singleCss, importAliases = {}, + rootDir = process.cwd(), + conditions = ['import', 'module', 'node'], + mdxExtensions = ['.mdx'], + alias, + symlinks = true, } = this.getOptions() const callback = this.async() const id = this.resourcePath @@ -72,10 +104,21 @@ const devupUILoader: RawLoaderDefinitionFunction = // FILE_ROUTES keys (built with forward slashes by plugin-utils). Without // this, single-importer collapse and atom hoisting silently no-op on // Windows. No-op on POSIX. - const relativePath = relative(process.cwd(), id).replaceAll('\\', '/') + const relativePath = relative(rootDir, id).replaceAll('\\', '/') if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - setCwdModuleResolver() + setCwdModuleResolver({ + rootDir, + conditions, + mdxExtensions, + alias, + onResolutionInputs: (inputs) => { + for (const file of inputs.fileDependencies) + this.addDependency(resolutionWatchPath(file, !symlinks)) + for (const path of inputs.missingDependencies) + this.addMissingDependency(resolutionWatchPath(path, !symlinks)) + }, + }) const { code, css = '', @@ -92,32 +135,39 @@ const devupUILoader: RawLoaderDefinitionFunction = false, true, importAliases, + ...(isMdxSource(id, mdxExtensions) + ? (['compiled-mdx'] as const) + : ([] as const)), ) for (const dependency of dependencies) { - this.addDependency(resolve(dependency)) + this.addDependency(resolve(rootDir, dependency)) } const sourceMap = parseSourceMap(map) const promises: Promise[] = [] if (updatedBaseStyle) { // update base style promises.push( - writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'), + stateWriter.write( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ), ) } if (cssFile) { const content = `${this.resourcePath} ${Date.now()}` // should be reset css promises.push( - writeFile( + stateWriter.write( join(cssDir, basename(cssFile)), watch ? `/* ${content} */` : css, ), ) if (watch) { promises.push( - writeFile(sheetFile, exportSheet()), - writeFile(classMapFile, exportClassMap()), - writeFile(fileMapFile, exportFileMap()), + stateWriter.write(sheetFile, exportSheet()), + stateWriter.write(classMapFile, exportClassMap()), + stateWriter.write(fileMapFile, exportFileMap()), ) } } @@ -126,7 +176,15 @@ const devupUILoader: RawLoaderDefinitionFunction = (error) => callback(toLoaderError(error)), ) } catch (error) { - callback(toLoaderError(error)) + callback( + isMdxSource(id, mdxExtensions) + ? remapMdxError( + error, + relative(rootDir, id).replaceAll('\\', '/'), + inputSourceMap, + ) + : toLoaderError(error), + ) } return } diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 54d0713a9..2a73744e3 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -1,21 +1,36 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' -import { dirname, join, relative, resolve } from 'node:path' +import { dirname, extname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + buildStaticImportGraph, + collectNumberedFiles, computeFileReach, + computeReachableFiles, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, + getFileNumByFilename, type ImportAliases, - listSourceFiles, + isSelectedSource, loadDevupConfigSync, + mdxSourceFilter, mergeImportAliases, + normalizeMdxExtensions, planAtomHoist, + type ResolutionInputObserver, + resolutionWatchPath, + resolveProjectPaths, + resolveSourceDirs, + seedFileNumbers, + SOURCE_FILE_RE, + type StaticImportGraph, type WasmImportAliases, } from '@devup-ui/plugin-utils' import { @@ -30,6 +45,8 @@ import { importSheet, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -37,6 +54,9 @@ import { } from '@devup-ui/wasm' import { type Compiler } from 'webpack' +import { registerCompiledGuard } from './compiled-guard' +import { servedCss } from './served-css' + export interface DevupUIWebpackPluginOptions { package: string cssDir: string @@ -48,6 +68,8 @@ export interface DevupUIWebpackPluginOptions { singleCss: boolean prefix?: string shorthands?: CustomShorthands + sourceDirs?: string | string[] + mdxExtensions?: readonly string[] /** * Atom-level route-aware hoisting threshold. * @@ -79,23 +101,36 @@ export class DevupUIWebpackPlugin { classMapFile: string fileMapFile: string private importAliases: WasmImportAliases + private excludeModules: RegExp + private readonly mdxExtensions: readonly string[] + private seedWarningEmitted = false + private pathOptions: { + readonly devupFile: string + readonly distDir: string + readonly cssDir: string + } constructor({ package: libPackage = '@devup-ui/react', devupFile = 'devup.json', distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), + cssDir = join(distDir, 'devup-ui'), watch = false, debug = false, include = [], singleCss = false, prefix, shorthands, + sourceDirs, atomHoist, importAliases: userImportAliases, + mdxExtensions, }: Partial = {}) { + this.mdxExtensions = normalizeMdxExtensions(mdxExtensions) registerShorthands(shorthands ?? {}) this.importAliases = mergeImportAliases(userImportAliases) + this.excludeModules = createNodeModulesExcludeRegex(include) + this.pathOptions = { devupFile, distDir, cssDir } this.options = { package: libPackage, @@ -108,6 +143,7 @@ export class DevupUIWebpackPlugin { singleCss, prefix, atomHoist, + sourceDirs, } this.sheetFile = join(this.options.distDir, 'sheet.json') @@ -116,24 +152,17 @@ export class DevupUIWebpackPlugin { } writeDataFiles() { - try { - const config = loadDevupConfigSync(this.options.devupFile) - const theme = config.theme ?? {} + const config = loadDevupConfigSync(this.options.devupFile) + const theme = config.theme ?? {} - registerTheme(theme) - const interfaceCode = getThemeInterface( - ...createThemeInterfaceArgs(this.options.package), - ) + registerTheme(theme) + const interfaceCode = getThemeInterface( + ...createThemeInterfaceArgs(this.options.package), + ) - if (interfaceCode) { - writeFileSync(join(this.options.distDir, 'theme.d.ts'), interfaceCode, { - encoding: 'utf-8', - }) - } - } catch (error) { - console.error(error) - registerTheme({}) - } + writeFileSync(join(this.options.distDir, 'theme.d.ts'), interfaceCode, { + encoding: 'utf-8', + }) if (!existsSync(this.options.cssDir)) mkdirSync(this.options.cssDir, { recursive: true }) if (this.options.watch) @@ -143,26 +172,20 @@ export class DevupUIWebpackPlugin { ) } - /** - * Extract every source file under `src` into the shared WASM sheet so that a - * later `getCss(fileNum)` call returns the COMPLETE bucket (all collapsed - * members), not just the first member webpack happened to build. Mirrors the - * loader's `codeExtract` call (same filename keying + options) so re-extraction - * during compilation is idempotent. Best-effort: extraction errors are - * swallowed so a single bad file never breaks the build. - */ - private prewarmExtractor() { - try { - const cwd = process.cwd() - // The same resolver as the loader's, so imported constants and - // stylesheets extract to the classes the loader emits - setModuleResolver( - createModuleResolver({ - toId: (path) => relative(cwd, path).replaceAll('\\', '/'), - }), - ) - const srcDir = resolve(cwd, 'src') - for (const file of listSourceFiles(srcDir)) { + private prewarmExtractor(options: { + graph: StaticImportGraph + entries: string[] + cwd: string + }) { + const { graph, entries, cwd } = options + for (const file of computeReachableFiles({ + srcDir: graph.files, + tsconfigPath: resolve(cwd, 'tsconfig.json'), + entries, + graph, + })) { + if (!SOURCE_FILE_RE.test(file)) continue + try { const relativePath = relative(cwd, file).replaceAll('\\', '/') let relCssDir = relative(dirname(file), this.options.cssDir).replaceAll( '\\', @@ -179,18 +202,117 @@ export class DevupUIWebpackPlugin { true, this.importAliases, ) + } catch (cause) { + throw new Error( + `[devup-ui] prewarm failed at ${file} (root ${cwd}): ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) } - } catch { - // Best-effort warm-up; on failure the css-loader still serves whatever - // atoms were extracted, matching pre-fix behavior. } } apply(compiler: Compiler) { - setDebug(this.options.debug) - if (this.options.prefix) { - setPrefix(this.options.prefix) + if (compiler.hooks.afterEnvironment) { + compiler.hooks.afterEnvironment.tap('DevupUIWebpackPlugin', () => { + this.setupCompiler(compiler) + }) + } else { + this.setupCompiler(compiler) } + } + + private setupCompiler(compiler: Compiler) { + const cwd = compiler.context ?? compiler.options.context ?? process.cwd() + const paths = resolveProjectPaths(cwd, this.pathOptions) + this.options = { ...this.options, ...paths } + this.sheetFile = join(paths.distDir, 'sheet.json') + this.classMapFile = join(paths.distDir, 'classMap.json') + this.fileMapFile = join(paths.distDir, 'fileMap.json') + const sourceDirs = resolveSourceDirs(cwd, this.options.sourceDirs) + const resolveOptions = compiler.options.resolve + const inputFiles = new Set() + const missingInputs = new Set() + const onResolutionInputs: ResolutionInputObserver = (inputs) => { + for (const path of inputs.fileDependencies) + inputFiles.add( + resolutionWatchPath(path, resolveOptions?.symlinks === false), + ) + for (const path of inputs.missingDependencies) + missingInputs.add( + resolutionWatchPath(path, resolveOptions?.symlinks === false), + ) + } + compiler.hooks.afterCompile.tap( + 'DevupUIResolutionInputs', + (compilation) => { + for (const path of inputFiles) compilation.fileDependencies.add(path) + for (const path of missingInputs) + compilation.missingDependencies.add(path) + }, + ) + const baseConditions = resolveOptions?.conditionNames ?? [ + 'webpack', + compiler.options.mode === 'development' ? 'development' : 'production', + 'browser', + ] + const conditions = ( + resolveOptions?.byDependency?.esm?.conditionNames ?? [ + 'import', + 'module', + '...', + ] + ).flatMap((condition) => + condition === '...' ? baseConditions : [condition], + ) + const entry = compiler.options.entry + const entries = + typeof entry === 'function' + ? [] + : Object.values(entry ?? {}).flatMap(({ import: requests = [] }) => + requests.flatMap((request) => { + const resource = request + .slice(request.lastIndexOf('!') + 1) + .split('?')[0] + if ( + !resource || + resource.startsWith('\0') || + (/^[a-z][a-z\d+.-]*:/i.test(resource) && + !/^[a-z]:[\\/]/i.test(resource)) + ) + return [] + return isSelectedSource(resource, this.mdxExtensions) || + !extname(resource) + ? [resolve(cwd, resource)] + : [] + }), + ) + const roots = [ + ...new Set([...sourceDirs, ...entries.map((file) => dirname(file))]), + ] + // A build starts from its own options, not from what an earlier build in + // this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + compiler.hooks.shutdown?.tap('DevupUIWebpackPlugin', endBuild) + try { + setModuleResolver( + createModuleResolver({ + cwd, + includeMdx: this.mdxExtensions, + conditions, + alias: resolveOptions?.alias, + onResolutionInputs, + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] resolver setup failed at ${cwd}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } + setDebug(this.options.debug) + setPrefix(this.options.prefix ?? null) const existsDevup = existsSync(this.options.devupFile) // read devup.json if (!existsSync(this.options.distDir)) @@ -218,7 +340,15 @@ export class DevupUIWebpackPlugin { importFileMap({}) } } - this.writeDataFiles() + try { + this.writeDataFiles() + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] theme setup failed at ${cwd}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any loader // runs codeExtract (apply() body is synchronous, loaders run during @@ -234,16 +364,24 @@ export class DevupUIWebpackPlugin { // merge into that importer's bucket, deduplicating their identical atoms. // The canonical map is built + imported unconditionally; only atom HOISTING // composes on top when `atomHoist` is set. Mirrors next-plugin's pre-pass. - let canonicalMap: Record = {} + let graph: StaticImportGraph try { - const srcDir = resolve(process.cwd(), 'src') - const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') - const cwd = process.cwd() - canonicalMap = buildCanonicalMap({ + const srcDir = roots + const tsconfigPath = resolve(cwd, 'tsconfig.json') + graph = buildStaticImportGraph(roots, tsconfigPath, { + includeMdx: this.mdxExtensions, + cwd, + include: this.options.include, + conditions, + alias: resolveOptions?.alias, + onResolutionInputs, + }) + const canonicalMap = buildCanonicalMap({ srcDir, tsconfigPath, cwd, keyBy: 'cwd-relative', + graph, }) importCanonicalMap(canonicalMap) @@ -253,6 +391,8 @@ export class DevupUIWebpackPlugin { tsconfigPath, cwd, keyBy: 'cwd-relative', + graph, + entries: entries.length ? entries : undefined, }) const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) if (plan) { @@ -264,24 +404,54 @@ export class DevupUIWebpackPlugin { ) } } - } catch { - // Best-effort; on failure canonical() is the identity (no merge) and atom - // hoisting stays off. + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] graph setup failed at ${cwd}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) } - // Pre-warm the extractor so the css-loader serves COMPLETE bucket CSS. + // Number every file the build can extract in path order, so class + // prefixes do not depend on which file a worker reaches first. Numbers + // already handed out (a restored map in watch mode) stay. + try { + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots, + includeMdx: this.mdxExtensions, + include: this.options.include, + cwd, + needles: extractedNeedles(this.options.package, this.importAliases), + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch (cause) { + if (!this.seedWarningEmitted) { + this.seedWarningEmitted = true + console.warn( + '[devup-ui] deterministic file seeding failed; class IDs now depend on module arrival order', + { phase: 'seed', root: cwd, cause }, + ) + } + } + // Pre-warm the extractor so the css-loader serves COMPLETE CSS. // - // Under collapse, several source files share ONE devup-ui-N.css (the - // importer's bucket). The css-loader serves `getCss(N, true)`, but webpack - // builds that shared .css module ONCE — at the FIRST import resolution, - // before the bucket's other members have been extracted — so their atoms - // would be dropped. Turbopack avoids this via its idle coordinator; webpack - // has no such re-serve, so we extract every source file up front (single - // shared WASM instance) to populate the bucket fully BEFORE any css-loader - // runs. Re-extraction by the per-file loader is then idempotent (set-based - // atom dedup). Only needed for one-shot builds when collapse is active. - if (!this.options.watch && Object.keys(canonicalMap).length > 0) { - this.prewarmExtractor() + // Webpack builds a stylesheet module ONCE, at its FIRST import: the shared + // base (global styles of every file) and, under collapse, a bucket's + // devup-ui-N.css shared by several source files. Files extracted after that + // would be missing, so the files the entries reach are extracted up front + // (single shared WASM instance). Re-extraction by the per-file loader is + // then idempotent (set-based atom dedup). Watch mode rebuilds stylesheets + // through the files the loaders write instead. + if (!this.options.watch) { + try { + this.prewarmExtractor({ graph, entries, cwd }) + } catch (cause) { + endBuild() + throw cause + } } if (this.options.watch) { @@ -303,12 +473,72 @@ export class DevupUIWebpackPlugin { compilation.fileDependencies.add(resolve(this.options.devupFile)) }) - compiler.options.plugins.push( - new compiler.webpack.DefinePlugin({ - 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify(getDefaultTheme()), - }), - ) + const definePlugin = new compiler.webpack.DefinePlugin({ + 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify(getDefaultTheme()), + }) + if (compiler.hooks.afterEnvironment) definePlugin.apply(compiler) + else compiler.options.plugins.push(definePlugin) if (!this.options.watch) { + // A stylesheet module is built on its first import, which can come before + // the modules whose styles it holds are extracted. When one was, compile + // once more: every module is extracted by then. Watch mode rebuilds it + // through the stylesheet files the loaders write instead. + let passes = 0 + compiler.hooks.run.tap('DevupUIWebpackPlugin', () => { + passes = 0 + }) + compiler.hooks.thisCompilation.tap( + 'DevupUIWebpackPlugin', + (compilation) => { + const basePath = join(this.options.cssDir, 'devup-ui.css') + const base = getCss(null, true) + // A file's stylesheet `@import`s the shared base, which the CSS + // loaders read from disk without passing through this plugin, so it + // must hold every style extracted so far + if (!existsSync(basePath) || readFileSync(basePath, 'utf-8') !== base) + writeFileSync(basePath, base, 'utf-8') + let stale: string[] = [] + compilation.hooks.finishModules.tap('DevupUIWebpackPlugin', () => { + if (compiler.watchMode) return + const changed = new Set( + [...servedCss(compilation)] + .filter( + ([path, css]) => + getCss(getFileNumByFilename(path), true) !== css, + ) + .map(([path]) => path), + ) + if (getCss(null, true) !== base) changed.add(basePath) + stale = [...changed] + }) + // The next pass writes the build; this one writes none of its files + compilation.hooks.processAssets.tap( + { + name: 'DevupUIWebpackPlugin', + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + if (stale.length === 0 || passes > 0) return + for (const name of Object.keys(compilation.assets)) + compilation.deleteAsset(name) + }, + ) + compilation.hooks.needAdditionalPass.tap( + 'DevupUIWebpackPlugin', + () => { + if (stale.length === 0 || passes > 0) return undefined + passes += 1 + for (const path of stale) + writeFileSync( + path, + getCss(getFileNumByFilename(path), true), + 'utf-8', + ) + return true + }, + ) + }, + ) compiler.hooks.done.tapPromise('DevupUIWebpackPlugin', async (stats) => { if (!stats.hasErrors()) { // write css file @@ -321,31 +551,39 @@ export class DevupUIWebpackPlugin { }) } + const exclude = this.excludeModules + const sourceLoader = { + loader: createRequire(import.meta.url).resolve( + '@devup-ui/webpack-plugin/loader', + ), + options: { + package: this.options.package, + cssDir: this.options.cssDir, + sheetFile: this.sheetFile, + classMapFile: this.classMapFile, + fileMapFile: this.fileMapFile, + watch: this.options.watch, + singleCss: this.options.singleCss, + importAliases: this.importAliases, + rootDir: cwd, + conditions, + alias: resolveOptions?.alias, + mdxExtensions: this.mdxExtensions, + symlinks: resolveOptions?.symlinks, + }, + } compiler.options.module.rules.push( { - test: /\.(tsx|ts|js|mjs|jsx)$/, - exclude: createNodeModulesExcludeRegex( - this.options.include, - '.mdx.[tj]sx?$', - ), + test: SOURCE_FILE_RE, + exclude, enforce: 'pre', - use: [ - { - loader: createRequire(import.meta.url).resolve( - '@devup-ui/webpack-plugin/loader', - ), - options: { - package: this.options.package, - cssDir: this.options.cssDir, - sheetFile: this.sheetFile, - classMapFile: this.classMapFile, - fileMapFile: this.fileMapFile, - watch: this.options.watch, - singleCss: this.options.singleCss, - importAliases: this.importAliases, - }, - }, - ], + use: [sourceLoader], + }, + { + test: mdxSourceFilter(this.mdxExtensions), + exclude, + enforce: 'post', + use: [sourceLoader], }, { test: this.options.cssDir, @@ -362,5 +600,10 @@ export class DevupUIWebpackPlugin { ], }, ) + registerCompiledGuard(compiler, { + package: this.options.package, + mdxExtensions: this.mdxExtensions, + importAliases: this.importAliases, + }) } } diff --git a/packages/webpack-plugin/src/served-css.ts b/packages/webpack-plugin/src/served-css.ts new file mode 100644 index 000000000..c04ca03fd --- /dev/null +++ b/packages/webpack-plugin/src/served-css.ts @@ -0,0 +1,18 @@ +declare global { + var __devupUiServedCss: WeakMap> | undefined +} + +/** + * The stylesheet each devup-ui CSS module of `compilation` was built with, by + * resource path. The plugin and its loaders load as separate modules, so the + * record lives on `globalThis`. + */ +export function servedCss(compilation: object): Map { + globalThis.__devupUiServedCss ??= new WeakMap() + let served = globalThis.__devupUiServedCss.get(compilation) + if (!served) { + served = new Map() + globalThis.__devupUiServedCss.set(compilation, served) + } + return served +}