mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 21:57:29 +00:00
fix(packages): add server-only bundles (#1349)
This commit is contained in:
@@ -163,6 +163,7 @@ async function measure(entryPoints, external = []) {
|
||||
write: false,
|
||||
outdir: '/tmp/bundle-size-out',
|
||||
external,
|
||||
conditions: ['browser'],
|
||||
logLevel: 'silent',
|
||||
});
|
||||
|
||||
@@ -199,6 +200,7 @@ async function measureVirtual(code, resolveDir, external = []) {
|
||||
write: false,
|
||||
outdir: '/tmp/bundle-size-out',
|
||||
external,
|
||||
conditions: ['browser'],
|
||||
logLevel: 'silent',
|
||||
});
|
||||
|
||||
@@ -213,12 +215,17 @@ async function measureVirtual(code, resolveDir, external = []) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the `default` condition from an export value.
|
||||
* Handles both `{ default: "./dist/..." }` objects and plain string values.
|
||||
* Resolve the browser production path from an export value.
|
||||
*
|
||||
* Prefers `browser.default` (browser prod bundle) over top-level `default`
|
||||
* (server bundle) so we measure the code consumers actually ship.
|
||||
*/
|
||||
function resolveDefault(exportValue) {
|
||||
if (typeof exportValue === 'string') return exportValue;
|
||||
if (typeof exportValue === 'object' && exportValue !== null) {
|
||||
if (typeof exportValue.browser === 'object' && exportValue.browser !== null) {
|
||||
return exportValue.browser.default ?? exportValue.default ?? null;
|
||||
}
|
||||
return exportValue.default ?? null;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Validates that all server entry points are safe to import in a non-browser
|
||||
* environment.
|
||||
*
|
||||
* Auto-discovers packages from `packages/`, reads their `exports` field to find
|
||||
* entries with a `browser`/`default` condition split, resolves the `default`
|
||||
* (server) path, and dynamically imports each one. Any module that throws
|
||||
* during evaluation (e.g. due to missing browser globals) is reported.
|
||||
*
|
||||
* Requires packages to be built first (`pnpm build:packages`).
|
||||
*
|
||||
* Usage: node .github/scripts/ssr-check.js
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
} from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const ROOT = resolve(__dirname, '../..');
|
||||
const PACKAGES_DIR = join(ROOT, 'packages');
|
||||
|
||||
const SKIP_PACKAGES = new Set([
|
||||
'react-native',
|
||||
'skins',
|
||||
'icons',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Resolve the server path from an export value.
|
||||
*
|
||||
* Only returns a path for entries with a `browser`/`default` split (these are
|
||||
* the ones that have separate server builds). The top-level `default` is the
|
||||
* server entry point.
|
||||
*/
|
||||
function resolveServerPath(exportValue) {
|
||||
if (typeof exportValue !== 'object' || exportValue === null) return null;
|
||||
if (!('browser' in exportValue)) return null;
|
||||
return typeof exportValue.default === 'string' ? exportValue.default : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a wildcard export key to actual server files on disk.
|
||||
*/
|
||||
function resolveWildcard(pkgDir, exportKey, exportValue) {
|
||||
const serverPath = resolveServerPath(exportValue);
|
||||
if (!serverPath) return [];
|
||||
|
||||
const fullPattern = resolve(pkgDir, serverPath);
|
||||
const starIdx = fullPattern.indexOf('*');
|
||||
if (starIdx === -1) return [];
|
||||
|
||||
const lastSlash = fullPattern.lastIndexOf('/', starIdx);
|
||||
const scanDir = fullPattern.slice(0, lastSlash);
|
||||
const prefix = fullPattern.slice(lastSlash + 1, starIdx);
|
||||
const suffix = fullPattern.slice(starIdx + 1);
|
||||
|
||||
if (!existsSync(scanDir)) return [];
|
||||
|
||||
const isDirectoryPattern = suffix.startsWith('/');
|
||||
|
||||
return readdirSync(scanDir, { withFileTypes: true })
|
||||
.filter((d) => {
|
||||
if (!d.name.startsWith(prefix)) return false;
|
||||
return isDirectoryPattern ? d.isDirectory() : d.isFile();
|
||||
})
|
||||
.filter((d) => {
|
||||
if (isDirectoryPattern) return true;
|
||||
return d.name.endsWith(suffix);
|
||||
})
|
||||
.map((d) => {
|
||||
const stem = isDirectoryPattern
|
||||
? d.name.slice(prefix.length)
|
||||
: d.name.slice(prefix.length, d.name.length - suffix.length);
|
||||
const fullPath = fullPattern.replace('*', stem);
|
||||
return { stem, fullPath };
|
||||
})
|
||||
.filter(({ fullPath }) => {
|
||||
if (!existsSync(fullPath)) return false;
|
||||
return readFileSync(fullPath, 'utf8').trim().length > 0;
|
||||
})
|
||||
.sort((a, b) => a.stem.localeCompare(b.stem))
|
||||
.map(({ stem, fullPath }) => ({
|
||||
label: `${exportKey.replace('*', stem)}`,
|
||||
path: fullPath,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Discover all server entry points across packages. */
|
||||
function discoverServerEntries() {
|
||||
const entries = [];
|
||||
|
||||
for (const dirName of readdirSync(PACKAGES_DIR).sort()) {
|
||||
if (SKIP_PACKAGES.has(dirName)) continue;
|
||||
|
||||
const pkgJsonPath = join(PACKAGES_DIR, dirName, 'package.json');
|
||||
if (!existsSync(pkgJsonPath)) continue;
|
||||
|
||||
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
||||
if (!pkgJson.exports) continue;
|
||||
|
||||
const pkgName = pkgJson.name;
|
||||
const pkgDir = join(PACKAGES_DIR, dirName);
|
||||
|
||||
for (const [key, value] of Object.entries(pkgJson.exports)) {
|
||||
if (key.endsWith('.css')) continue;
|
||||
|
||||
if (key.includes('*')) {
|
||||
for (const r of resolveWildcard(pkgDir, key, value)) {
|
||||
entries.push({
|
||||
name: `${pkgName}/${r.label.slice(2)}`,
|
||||
path: r.path,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const serverPath = resolveServerPath(value);
|
||||
if (!serverPath) continue;
|
||||
|
||||
const absolutePath = resolve(pkgDir, serverPath);
|
||||
if (!existsSync(absolutePath)) continue;
|
||||
if (readFileSync(absolutePath, 'utf8').trim().length === 0) continue;
|
||||
|
||||
const label = key === '.' ? pkgName : `${pkgName}/${key.slice(2)}`;
|
||||
entries.push({ name: label, path: absolutePath });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
const entries = discoverServerEntries();
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.error('No server entry points found. Are packages built?');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Checking ${entries.length} server entry points...\n`);
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const url = pathToFileURL(entry.path).href;
|
||||
try {
|
||||
await import(url);
|
||||
console.log(` ✓ ${entry.name}`);
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${entry.name}`);
|
||||
failures.push({ name: entry.name, error });
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`${failures.length} entry point(s) failed:\n`);
|
||||
for (const { name, error } of failures) {
|
||||
console.error(` ${name}:`);
|
||||
console.error(` ${error.message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`All ${entries.length} server entry points imported successfully.`);
|
||||
Reference in New Issue
Block a user