mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(build): ship NodeNext-resolvable declarations from every package (#5532)
* fix(build): ship NodeNext-resolvable declarations from every package tsc copies relative specifiers verbatim, so packages whose sources use bundler-style extensionless imports (core, inspector) published .d.ts files that a module/moduleResolution nodenext consumer cannot resolve. Core already rewrote its emitted JavaScript for this, but not its declarations. Move that rewrite to scripts/rewrite-relative-import-extensions.mjs, make it cover .d.ts too (adding .js or /index.js, and failing the build when a declaration import has no declaration), and run it after every tsc emit: core, engine, sdk, producer, aws-lambda, gcp-cloud-run and inspector. verify:packed-manifests now typechecks the clean packed consumer with skipLibCheck false and type-only imports of every typed subpath, so an unresolvable import inside a published declaration fails the gate and is attributed to its package. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * chore(ci): allow the moved core rewrite-esm-extensions script deletion Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ci: rebuild the edit-accuracy bench when the import-extension rewriter changes The package builds now run scripts/rewrite-relative-import-extensions.mjs, so main's edit_accuracy paths filter has to include it (check-edit-accuracy-filter test). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ec97816a92
commit
f55b366175
@@ -262,6 +262,7 @@ jobs:
|
||||
- "packages/{studio,studio-server,core,parsers,player,sdk,lint,cli,engine,producer}/{package.json,tsconfig.json,tsup.config.ts}"
|
||||
- "packages/*/package-subpaths.json"
|
||||
- "scripts/package-subpaths.mjs"
|
||||
- "scripts/rewrite-relative-import-extensions.mjs"
|
||||
- "scripts/ci/base-file.sh"
|
||||
- "scripts/ci/install-workspace-dependencies.sh"
|
||||
- ".github/actions/prepare-ffmpeg-bin/**"
|
||||
|
||||
@@ -129,6 +129,8 @@ RUN timeout 300 bun install --frozen-lockfile \
|
||||
#
|
||||
# Keep the ordering dependency-correct: anything the core build reads must be
|
||||
# copied above it, and packages nothing above depends on stay below.
|
||||
# Core's build runs the shared post-emit import rewrite from the repo root.
|
||||
COPY scripts/rewrite-relative-import-extensions.mjs scripts/rewrite-relative-import-extensions.mjs
|
||||
COPY packages/parsers/ packages/parsers/
|
||||
COPY packages/lint/ packages/lint/
|
||||
COPY packages/studio-server/ packages/studio-server/
|
||||
|
||||
@@ -140,7 +140,9 @@ RUN npm install --global bun@1.4.2 && \
|
||||
bun install --frozen-lockfile
|
||||
|
||||
# Bring in the source. Core's build generates files its source imports
|
||||
# (e.g. runtime-inline); the other packages run from source under bun.
|
||||
# (e.g. runtime-inline); the other packages run from source under bun. The
|
||||
# build also runs the shared post-emit import rewrite from the repo root.
|
||||
COPY scripts/rewrite-relative-import-extensions.mjs ./scripts/rewrite-relative-import-extensions.mjs
|
||||
COPY packages/core ./packages/core
|
||||
COPY packages/engine ./packages/engine
|
||||
COPY packages/lint ./packages/lint
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@
|
||||
"player:perf": "bun run --filter @hyperframes/player perf",
|
||||
"format:check": "oxfmt --check .",
|
||||
"knip": "knip",
|
||||
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/prune-fallow-audit-caches.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
|
||||
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/prune-fallow-audit-caches.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
|
||||
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
|
||||
"test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'",
|
||||
"generate:previews": "tsx scripts/generate-template-previews.ts",
|
||||
|
||||
@@ -56,6 +56,9 @@ await Promise.all([
|
||||
// through node_modules to the sibling package's already-built `dist/`
|
||||
// types instead of pulling its full source tree into emit (which would
|
||||
// violate rootDir).
|
||||
execSync("tsc -p tsconfig.build.json --emitDeclarationOnly", { stdio: "inherit" });
|
||||
execSync(
|
||||
"tsc -p tsconfig.build.json --emitDeclarationOnly && node ../../scripts/rewrite-relative-import-extensions.mjs dist",
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
console.log("[Build] Complete: dist/{index,handler,sdk/index,cdk/index}.js + .d.ts");
|
||||
|
||||
@@ -896,7 +896,7 @@
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run build:fonts && bun run build:svg-selector-aliases && bun run build:hyperframes-runtime && bun run build:position-edits-render && bun run build:audio-fx-runtime && tsc && tsx scripts/rewrite-esm-extensions.ts",
|
||||
"build": "bun run build:fonts && bun run build:svg-selector-aliases && bun run build:hyperframes-runtime && bun run build:position-edits-render && bun run build:audio-fx-runtime && tsc && node ../../scripts/rewrite-relative-import-extensions.mjs dist",
|
||||
"build:fonts": "node scripts/build-fonts.mjs",
|
||||
"test": "bun run check:position-edits-render && bun run check:svg-selector-aliases && bun run build:audio-fx-runtime && vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, extname, join, normalize, resolve } from "node:path";
|
||||
|
||||
const distDir = resolve(import.meta.dirname, "../dist");
|
||||
const runtimeExtensions = new Set([".js", ".mjs", ".cjs", ".json", ".wasm", ".node"]);
|
||||
|
||||
function listJavaScriptFiles(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const fullPath = join(dir, entry);
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...listJavaScriptFiles(fullPath));
|
||||
} else if (entry.endsWith(".js")) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function hasRuntimeExtension(specifier: string): boolean {
|
||||
return runtimeExtensions.has(extname(specifier));
|
||||
}
|
||||
|
||||
function isRelativeSpecifier(specifier: string): boolean {
|
||||
return specifier.startsWith("./") || specifier.startsWith("../");
|
||||
}
|
||||
|
||||
function resolveExistingJsSpecifier(fromFile: string, specifier: string): string | undefined {
|
||||
const absolute = resolve(dirname(fromFile), specifier);
|
||||
const jsFile = `${absolute}.js`;
|
||||
const indexFile = join(absolute, "index.js");
|
||||
|
||||
if (existsSync(jsFile)) return `${specifier}.js`;
|
||||
if (existsSync(indexFile)) return `${specifier}/index.js`;
|
||||
}
|
||||
|
||||
function resolveRuntimeSpecifier(fromFile: string, specifier: string): string {
|
||||
if (!isRelativeSpecifier(specifier)) return specifier;
|
||||
if (hasRuntimeExtension(specifier)) return specifier;
|
||||
|
||||
return resolveExistingJsSpecifier(fromFile, specifier) ?? specifier;
|
||||
}
|
||||
|
||||
function rewriteSpecifiers(filePath: string, source: string): string {
|
||||
const patterns = [
|
||||
/(from\s+["'])(\.\.?\/[^"']+)(["'])/g,
|
||||
/(import\s+["'])(\.\.?\/[^"']+)(["'])/g,
|
||||
/(import\(\s*["'])(\.\.?\/[^"']+)(["']\s*\))/g,
|
||||
];
|
||||
|
||||
return patterns.reduce(
|
||||
(nextSource, pattern) =>
|
||||
nextSource.replace(pattern, (_match, prefix: string, specifier: string, suffix: string) => {
|
||||
return `${prefix}${resolveRuntimeSpecifier(filePath, specifier)}${suffix}`;
|
||||
}),
|
||||
source,
|
||||
);
|
||||
}
|
||||
|
||||
let changed = 0;
|
||||
for (const filePath of listJavaScriptFiles(distDir)) {
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
const rewritten = rewriteSpecifiers(filePath, source);
|
||||
if (rewritten !== source) {
|
||||
writeFileSync(filePath, rewritten);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "core_esm_extensions_rewritten",
|
||||
distDir: normalize(distDir),
|
||||
changedFiles: changed,
|
||||
}),
|
||||
);
|
||||
@@ -80,7 +80,7 @@
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc && node ../../scripts/rewrite-relative-import-extensions.mjs dist",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -153,7 +153,9 @@ RUN bun packages/aws-lambda/scripts/probe-webgpu-adapter.ts \
|
||||
# image continues from the already-proven binary and installed workspaces.
|
||||
FROM beginframe-contract AS runtime
|
||||
|
||||
# Copy source for the packages the render path needs.
|
||||
# Copy source for the packages the render path needs, plus the shared post-emit
|
||||
# import rewrite their builds run.
|
||||
COPY scripts/rewrite-relative-import-extensions.mjs scripts/rewrite-relative-import-extensions.mjs
|
||||
COPY packages/core/ packages/core/
|
||||
COPY packages/engine/ packages/engine/
|
||||
COPY packages/producer/ packages/producer/
|
||||
|
||||
@@ -54,6 +54,9 @@ await Promise.all([
|
||||
// through node_modules to the sibling package's already-built `dist/`
|
||||
// types instead of pulling its full source tree into emit (which would
|
||||
// violate rootDir).
|
||||
execSync("tsc -p tsconfig.build.json --emitDeclarationOnly", { stdio: "inherit" });
|
||||
execSync(
|
||||
"tsc -p tsconfig.build.json --emitDeclarationOnly && node ../../scripts/rewrite-relative-import-extensions.mjs dist",
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
console.log("[Build] Complete: dist/{index,server,sdk/index,terraform}.js + .d.ts");
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
// Builds the inspector for Node and for the page. Bun runs the TypeScript sources directly (package.json "bun").
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { copyFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { build } from "esbuild";
|
||||
import { rewriteRelativeImportExtensions } from "../../scripts/rewrite-relative-import-extensions.mjs";
|
||||
|
||||
const at = (path) => new URL(path, import.meta.url).pathname;
|
||||
const { dependencies } = JSON.parse(readFileSync(at("./package.json"), "utf8"));
|
||||
@@ -56,28 +49,4 @@ const tsc = createRequire(import.meta.url).resolve("typescript/bin/tsc");
|
||||
execFileSync(process.execPath, [tsc, "-p", at("./tsconfig.types.json")], { stdio: "inherit" });
|
||||
// tsc emits declarations for TypeScript only; the element map's JavaScript ships its own.
|
||||
copyFileSync(at("./src/elementMap.d.ts"), at("./dist/types/inspector/src/elementMap.d.ts"));
|
||||
addDeclarationExtensions(at("./dist/types"));
|
||||
|
||||
// NodeNext consumers reject extensionless relative imports, and tsc keeps the source's bundler-style ones.
|
||||
function addDeclarationExtensions(dir) {
|
||||
for (const entry of readdirSync(dir, { recursive: true })) {
|
||||
const file = join(dir, entry);
|
||||
if (!file.endsWith(".d.ts")) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
const rewritten = source.replace(
|
||||
/((?:from|import\()\s*["'])(\.{1,2}\/[^"']*)(["'])/g,
|
||||
(whole, open, specifier, close) => {
|
||||
const target = resolveDeclaration(dirname(file), specifier);
|
||||
return target ? open + target + close : whole;
|
||||
},
|
||||
);
|
||||
if (rewritten !== source) writeFileSync(file, rewritten);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDeclaration(from, specifier) {
|
||||
if (/\.[cm]?js$/.test(specifier)) return undefined;
|
||||
if (existsSync(join(from, `${specifier}.d.ts`))) return `${specifier}.js`;
|
||||
if (existsSync(join(from, specifier, "index.d.ts"))) return `${specifier}/index.js`;
|
||||
throw new Error(`No declaration for ${specifier} imported from ${from}`);
|
||||
}
|
||||
rewriteRelativeImportExtensions(at("./dist/types"));
|
||||
|
||||
@@ -10,6 +10,7 @@ import { mkdirSync, rmSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { BUNDLES, WORKERS } from "./buildEntries.mjs";
|
||||
import { rewriteRelativeImportExtensions } from "../../scripts/rewrite-relative-import-extensions.mjs";
|
||||
|
||||
rmSync("dist", { recursive: true, force: true });
|
||||
mkdirSync("dist", { recursive: true });
|
||||
@@ -54,5 +55,6 @@ import { execSync } from "child_process";
|
||||
execSync("tsc --emitDeclarationOnly --declaration --declarationMap false", {
|
||||
stdio: "inherit",
|
||||
});
|
||||
rewriteRelativeImportExtensions("dist");
|
||||
|
||||
console.log("[Build] Complete: dist/index.js, dist/public-server.js, *.d.ts");
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc && node ../../scripts/rewrite-relative-import-extensions.mjs dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -76,6 +76,10 @@ export const ALLOWED_DELETIONS = new Map([
|
||||
"docs/public/catalog/vendor/three.module.min.json",
|
||||
"bundled into three-modules.json so catalog scripts run under the docs host policy",
|
||||
],
|
||||
[
|
||||
"packages/core/scripts/rewrite-esm-extensions.ts",
|
||||
"moved to scripts/rewrite-relative-import-extensions.mjs as plain ESM so packages without tsx can use it; now also rewrites .d.ts",
|
||||
],
|
||||
[
|
||||
"packages/studio/src/hooks/gsapTargetCache.ts",
|
||||
"its only caller was the deleted var-offset move writer; nothing imports isElementGsapTargeted",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
// Every package whose JavaScript or declarations come out of tsc runs this on its output.
|
||||
// tsc copies relative specifiers verbatim, so the extensionless `./types` that bundler
|
||||
// resolution accepts in source would ship as-is, and strict Node ESM (a NodeNext consumer, or
|
||||
// `node` itself for JavaScript) cannot resolve it. This adds `.js` or `/index.js` after emit.
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const RELATIVE_SPECIFIER_PATTERNS = [
|
||||
/(\bfrom\s*["'])(\.\.?\/[^"']+)(["'])/g,
|
||||
/(\bimport\s+["'])(\.\.?\/[^"']+)(["'])/g,
|
||||
/(\bimport\(\s*["'])(\.\.?\/[^"']+)(["']\s*\))/g,
|
||||
];
|
||||
const EXPLICIT_EXTENSION = /\.(?:[cm]?js|json|wasm|node)$/;
|
||||
|
||||
/**
|
||||
* Return the specifier Node ESM resolves to the same file, or undefined when nothing matches.
|
||||
* A declaration names the JavaScript it describes, so it resolves against declarations but gains `.js`.
|
||||
*/
|
||||
function resolveRelativeSpecifier(fromFile, specifier, emittedExtension) {
|
||||
if (EXPLICIT_EXTENSION.test(specifier)) return specifier;
|
||||
const target = resolve(dirname(fromFile), specifier);
|
||||
if (existsSync(`${target}${emittedExtension}`)) return `${specifier}.js`;
|
||||
if (existsSync(join(target, `index${emittedExtension}`))) return `${specifier}/index.js`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite one emitted file. Declarations must resolve, since an unresolved one is a broken type
|
||||
* contract. JavaScript leaves an unresolved specifier alone: it may sit inside a string, and the
|
||||
* packed-manifest verifier still rejects a real extensionless import.
|
||||
*/
|
||||
function rewriteRelativeImports(file, source) {
|
||||
const declaration = file.endsWith(".d.ts");
|
||||
return RELATIVE_SPECIFIER_PATTERNS.reduce(
|
||||
(text, pattern) =>
|
||||
text.replace(pattern, (whole, open, specifier, close) => {
|
||||
const resolved = resolveRelativeSpecifier(file, specifier, declaration ? ".d.ts" : ".js");
|
||||
if (resolved) return open + resolved + close;
|
||||
if (declaration) throw new Error(`${file} imports ${specifier}, which has no declaration`);
|
||||
return whole;
|
||||
}),
|
||||
source,
|
||||
);
|
||||
}
|
||||
|
||||
const EMITTED_FILE = /\.(?:js|d\.ts)$/;
|
||||
|
||||
function rewriteEmittedFile(file) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const rewritten = rewriteRelativeImports(file, source);
|
||||
if (rewritten === source) return false;
|
||||
writeFileSync(file, rewritten);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function rewriteRelativeImportExtensions(dir) {
|
||||
return readdirSync(dir, { recursive: true })
|
||||
.map((entry) => join(dir, entry))
|
||||
.filter((file) => EMITTED_FILE.test(file))
|
||||
.filter(rewriteEmittedFile).length;
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const dir = resolve(process.argv[2] ?? "dist");
|
||||
const changedFiles = rewriteRelativeImportExtensions(dir);
|
||||
console.log(JSON.stringify({ event: "relative_import_extensions_rewritten", dir, changedFiles }));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { rewriteRelativeImportExtensions } from "./rewrite-relative-import-extensions.mjs";
|
||||
|
||||
function withEmittedFiles(files, callback) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hyperframes-rewrite-test-"));
|
||||
try {
|
||||
for (const [file, source] of Object.entries(files)) {
|
||||
mkdirSync(dirname(join(dir, file)), { recursive: true });
|
||||
writeFileSync(join(dir, file), source);
|
||||
}
|
||||
callback(dir, (file) => readFileSync(join(dir, file), "utf8"));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("relative import extension rewrite", () => {
|
||||
it("points declaration imports at the JavaScript they describe", () => {
|
||||
withEmittedFiles(
|
||||
{
|
||||
"index.d.ts": [
|
||||
'import type { A } from "./types";',
|
||||
'export * from "./adapters";',
|
||||
'import "./side";',
|
||||
'export declare const c: import("./types").A;',
|
||||
'export type { D } from "./explicit.js";',
|
||||
"",
|
||||
].join("\n"),
|
||||
"types.d.ts": "export type A = string;\n",
|
||||
"adapters/index.d.ts": "export {};\n",
|
||||
"b.d.ts": "export declare const b: number;\n",
|
||||
"nested/c.d.ts": 'export { b } from "../b";\n',
|
||||
"side.d.ts": "export {};\n",
|
||||
},
|
||||
(dir, read) => {
|
||||
assert.equal(rewriteRelativeImportExtensions(dir), 2);
|
||||
assert.equal(
|
||||
read("index.d.ts"),
|
||||
[
|
||||
'import type { A } from "./types.js";',
|
||||
'export * from "./adapters/index.js";',
|
||||
'import "./side.js";',
|
||||
'export declare const c: import("./types.js").A;',
|
||||
'export type { D } from "./explicit.js";',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
assert.equal(read("nested/c.d.ts"), 'export { b } from "../b.js";\n');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("fails the build when a declaration import has no declaration behind it", () => {
|
||||
withEmittedFiles({ "index.d.ts": 'export * from "./missing";\n' }, (dir) => {
|
||||
assert.throws(
|
||||
() => rewriteRelativeImportExtensions(dir),
|
||||
/index\.d\.ts imports \.\/missing, which has no declaration/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves JavaScript against emitted JavaScript and leaves unmatched text alone", () => {
|
||||
withEmittedFiles(
|
||||
{
|
||||
"index.js": 'export * from "./util";\nconst snippet = \'import x from "./not-emitted"\';\n',
|
||||
"util.js": "export {};\n",
|
||||
},
|
||||
(dir, read) => {
|
||||
rewriteRelativeImportExtensions(dir);
|
||||
assert.equal(
|
||||
read("index.js"),
|
||||
'export * from "./util.js";\nconst snippet = \'import x from "./not-emitted"\';\n',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves already-correct output untouched", () => {
|
||||
withEmittedFiles(
|
||||
{ "index.d.ts": 'export * from "./a.js";\n', "a.d.ts": "export {};\n" },
|
||||
(dir) => assert.equal(rewriteRelativeImportExtensions(dir), 0),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -335,6 +335,62 @@ export function renderBrowserConsumer(specifiers) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Name every typed export in a type-only import: a bare `import "x"` lets tsc skip unresolved targets. */
|
||||
export function renderTypeConsumer(specifiers) {
|
||||
return `${specifiers
|
||||
.map(
|
||||
(specifier, index) =>
|
||||
`import type * as packedTypes${index} from ${JSON.stringify(specifier)};`,
|
||||
)
|
||||
.join("\n")}\nexport {};\n`;
|
||||
}
|
||||
|
||||
/** Strict Node ESM, with declarations checked: skipLibCheck hides unresolvable imports inside .d.ts files. */
|
||||
export const CONSUMER_TSCONFIG = {
|
||||
compilerOptions: {
|
||||
target: "ES2022",
|
||||
module: "NodeNext",
|
||||
moduleResolution: "NodeNext",
|
||||
lib: ["ESNext", "DOM", "DOM.Iterable"],
|
||||
types: ["node"],
|
||||
strict: true,
|
||||
skipLibCheck: false,
|
||||
noEmit: true,
|
||||
},
|
||||
include: ["consumer.ts"],
|
||||
};
|
||||
|
||||
/** Count tsc errors by the installed package they come from, so a failure names its owner. */
|
||||
export function countTypeErrorsByPackage(output) {
|
||||
const counts = {};
|
||||
for (const line of output.split("\n")) {
|
||||
const match = /^(\S+?)\(\d+,\d+\): error TS\d+/.exec(line);
|
||||
if (!match) continue;
|
||||
const owner = /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)\//.exec(match[1])?.[1] ?? match[1];
|
||||
counts[owner] = (counts[owner] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function typecheckPackedConsumer(fixtureDir) {
|
||||
try {
|
||||
execFileSync(process.execPath, [join(fixtureDir, "node_modules", "typescript", "bin", "tsc")], {
|
||||
cwd: fixtureDir,
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
const output = `${error.stdout ?? ""}${error.stderr ?? ""}`;
|
||||
const counts = Object.entries(countTypeErrorsByPackage(output))
|
||||
.map(([owner, count]) => `${owner}: ${count}`)
|
||||
.join(", ");
|
||||
throw new Error(
|
||||
`Packed declarations fail a NodeNext consumer (module/moduleResolution nodenext, skipLibCheck false). ` +
|
||||
`Errors by package: ${counts || "unknown"}\n${output.split("\n").slice(0, 20).join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function writeConsumerFixture(packDir, packedWorkspaces) {
|
||||
const fixtureDir = join(packDir, "consumer");
|
||||
mkdirSync(fixtureDir);
|
||||
@@ -356,6 +412,9 @@ function writeConsumerFixture(packDir, packedWorkspaces) {
|
||||
readFileSync(join(ROOT, "packages/studio/package.json"), "utf8"),
|
||||
);
|
||||
dependencies.vite = studioPackage.devDependencies.vite;
|
||||
// Studio's declarations name React types; a React consumer brings these itself.
|
||||
dependencies["@types/react"] = studioPackage.devDependencies["@types/react"];
|
||||
dependencies["@types/react-dom"] = studioPackage.devDependencies["@types/react-dom"];
|
||||
writeFileSync(
|
||||
join(fixtureDir, "package.json"),
|
||||
JSON.stringify(
|
||||
@@ -377,30 +436,11 @@ function writeConsumerFixture(packDir, packedWorkspaces) {
|
||||
);
|
||||
|
||||
const contracts = listPackedExportContracts(packedWorkspaces);
|
||||
const typeImports = contracts
|
||||
const typedSpecifiers = contracts
|
||||
.filter(({ typechecked }) => typechecked)
|
||||
.map(({ specifier }) => `import ${JSON.stringify(specifier)};`)
|
||||
.join("\n");
|
||||
writeFileSync(join(fixtureDir, "consumer.ts"), `${typeImports}\n`);
|
||||
writeFileSync(
|
||||
join(fixtureDir, "tsconfig.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
compilerOptions: {
|
||||
target: "ES2022",
|
||||
module: "NodeNext",
|
||||
moduleResolution: "NodeNext",
|
||||
lib: ["ES2022", "DOM", "DOM.Iterable"],
|
||||
strict: true,
|
||||
skipLibCheck: true,
|
||||
noEmit: true,
|
||||
},
|
||||
include: ["consumer.ts"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
.map(({ specifier }) => specifier);
|
||||
writeFileSync(join(fixtureDir, "consumer.ts"), renderTypeConsumer(typedSpecifiers));
|
||||
writeFileSync(join(fixtureDir, "tsconfig.json"), JSON.stringify(CONSUMER_TSCONFIG, null, 2));
|
||||
|
||||
const specifiers = contracts.map(({ specifier }) => specifier);
|
||||
const nodeSpecifiers = contracts
|
||||
@@ -441,11 +481,7 @@ function verifyPackedConsumer(packDir, packedWorkspaces) {
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync(process.execPath, [join(fixtureDir, "node_modules", "typescript", "bin", "tsc")], {
|
||||
cwd: fixtureDir,
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
typecheckPackedConsumer(fixtureDir);
|
||||
const smokeOutput = execFileSync("node", ["consumer-smoke.mjs"], {
|
||||
cwd: fixtureDir,
|
||||
encoding: "utf8",
|
||||
@@ -469,7 +505,7 @@ function verifyPackedConsumer(packDir, packedWorkspaces) {
|
||||
}
|
||||
console.log(smokeOutput.trim());
|
||||
console.log(
|
||||
"Verified clean packed consumer install, Node execution, TypeScript/Vite resolution, and CLI startup.",
|
||||
"Verified clean packed consumer install, Node execution, NodeNext declaration check, Vite resolution, and CLI startup.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,19 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
CONSUMER_TSCONFIG,
|
||||
countTypeErrorsByPackage,
|
||||
listPackedExportContracts,
|
||||
listPackageSizeIssues,
|
||||
listPackedJavaScriptImportIssues,
|
||||
packageExportSpecifier,
|
||||
renderBrowserConsumer,
|
||||
renderTypeConsumer,
|
||||
verifyCliLicense,
|
||||
} from "./verify-packed-manifests.mjs";
|
||||
|
||||
const TSC = join(import.meta.dirname, "..", "node_modules", "typescript", "bin", "tsc");
|
||||
|
||||
describe("packed manifest verifier", () => {
|
||||
it("passes a package inside its size budget", () => {
|
||||
const budget = { packed: 1000, unpacked: 4000 };
|
||||
@@ -224,4 +229,68 @@ describe("packed manifest verifier", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("names each typed export so tsc must resolve and check its declarations", () => {
|
||||
assert.equal(
|
||||
renderTypeConsumer(["@hyperframes/core", "@hyperframes/core/lint"]),
|
||||
'import type * as packedTypes0 from "@hyperframes/core";\n' +
|
||||
'import type * as packedTypes1 from "@hyperframes/core/lint";\n' +
|
||||
"export {};\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("attributes consumer type errors to the installed package that owns them", () => {
|
||||
const output = [
|
||||
"node_modules/@hyperframes/core/dist/adapters/gsap.d.ts(1,35): error TS2835: Relative import paths need explicit file extensions.",
|
||||
"node_modules/@hyperframes/core/dist/index.d.ts(2,15): error TS2307: Cannot find module './core.types'.",
|
||||
"node_modules/hyperframes/dist/cli.d.ts(1,1): error TS2307: Cannot find module './x'.",
|
||||
"consumer.ts(1,30): error TS2307: Cannot find module '@hyperframes/missing'.",
|
||||
" Did you mean './types.js'?",
|
||||
].join("\n");
|
||||
assert.deepEqual(countTypeErrorsByPackage(output), {
|
||||
"@hyperframes/core": 2,
|
||||
hyperframes: 1,
|
||||
"consumer.ts": 1,
|
||||
});
|
||||
});
|
||||
|
||||
function typecheckConsumerOf(declaration) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hyperframes-nodenext-test-"));
|
||||
try {
|
||||
const pkgDir = join(dir, "node_modules", "@hyperframes", "example");
|
||||
mkdirSync(join(pkgDir, "dist"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(pkgDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@hyperframes/example",
|
||||
type: "module",
|
||||
exports: { ".": { types: "./dist/index.d.ts", import: "./dist/index.js" } },
|
||||
}),
|
||||
);
|
||||
writeFileSync(join(pkgDir, "dist", "index.d.ts"), declaration);
|
||||
writeFileSync(join(pkgDir, "dist", "types.d.ts"), "export type Example = string;\n");
|
||||
writeFileSync(join(dir, "consumer.ts"), renderTypeConsumer(["@hyperframes/example"]));
|
||||
// The fixture has no @types/node to load; everything else matches the packed gate.
|
||||
const compilerOptions = { ...CONSUMER_TSCONFIG.compilerOptions, types: [] };
|
||||
writeFileSync(
|
||||
join(dir, "tsconfig.json"),
|
||||
JSON.stringify({ ...CONSUMER_TSCONFIG, compilerOptions }),
|
||||
);
|
||||
execFileSync(process.execPath, [TSC], { cwd: dir, encoding: "utf8", stdio: "pipe" });
|
||||
return "";
|
||||
} catch (error) {
|
||||
return String(error.stdout);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it("fails a NodeNext consumer on an extensionless import inside a published declaration", () => {
|
||||
assert.equal(CONSUMER_TSCONFIG.compilerOptions.skipLibCheck, false);
|
||||
assert.match(
|
||||
typecheckConsumerOf('export type { Example } from "./types";\n'),
|
||||
/node_modules\/@hyperframes\/example\/dist\/index\.d\.ts\(1,\d+\): error TS283[45]/,
|
||||
);
|
||||
assert.equal(typecheckConsumerOf('export type { Example } from "./types.js";\n'), "");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user