From a4e77f5a25c97d692b081ce780ccb8688c3e7366 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 23 Jun 2026 08:34:05 +0300 Subject: [PATCH] Fix TypeScript declaration publishing --- package.json | 17 +-- scripts/build-types.ts | 88 ++++++++++++++ scripts/validate-package.ts | 228 ++++++++++++++++++++++++++++++++++++ tsconfig.types.json | 12 ++ 4 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 scripts/build-types.ts create mode 100644 scripts/validate-package.ts create mode 100644 tsconfig.types.json diff --git a/package.json b/package.json index 54fcb89..d820cb7 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,11 @@ "types": "./dist/index.d.ts", "exports": { ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" + "types": "./dist/index.d.ts", + "import": "./dist/index.js" }, "./storage": { + "types": "./dist/storage.d.ts", "import": "./dist/storage.js" } }, @@ -19,12 +20,10 @@ "logs-mcp": "./dist/mcp/index.js", "logs-serve": "./dist/server/index.js" }, - "files": [ - "dist", - "dashboard/dist" - ], + "files": ["dist", "dashboard/dist"], "scripts": { - "build": "rm -rf dist && bun build src/cli/index.ts src/mcp/index.ts src/server/index.ts src/index.ts src/storage.ts --outdir dist --target bun --splitting --external playwright --external playwright-core --external electron --external chromium-bidi --external lighthouse", + "build": "rm -rf dist && bun build src/cli/index.ts src/mcp/index.ts src/server/index.ts src/index.ts src/storage.ts --outdir dist --target bun --splitting --external playwright --external playwright-core --external electron --external chromium-bidi --external lighthouse && bun scripts/build-types.ts", + "build:types": "bun scripts/build-types.ts", "build:dashboard": "cd dashboard && bun run build", "build:all": "bun run build:dashboard && bun run build", "dev": "bun run src/server/index.ts", @@ -38,7 +37,11 @@ "validate:stress": "bun scripts/high-volume-ingest-stress.ts", "validate:structured-logs": "bun scripts/structured-log-validation-lab.ts", "validate:streams": "bun scripts/stream-load-validation.ts", + "validate:pack": "bun scripts/validate-package.ts", + "validate:package": "bun run validate:pack", "lint": "biome check src/", + "prepack": "bun run build", + "prepublishOnly": "bun run validate:package", "postinstall": "mkdir -p $HOME/.hasna/logs 2>/dev/null || true" }, "repository": { diff --git a/scripts/build-types.ts b/scripts/build-types.ts new file mode 100644 index 0000000..fc14673 --- /dev/null +++ b/scripts/build-types.ts @@ -0,0 +1,88 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const distDir = join(repoRoot, "dist"); + +function run(command: string[], cwd = repoRoot): void { + const result = Bun.spawnSync(command, { + cwd, + stderr: "inherit", + stdout: "inherit", + }); + + if (result.exitCode !== 0) { + throw new Error( + `Command failed (${result.exitCode}): ${command.join(" ")}`, + ); + } +} + +function rewriteRelativeTsImports(directory: string): void { + for (const entry of readdirSync(directory)) { + const path = join(directory, entry); + const stats = statSync(path); + + if (stats.isDirectory()) { + rewriteRelativeTsImports(path); + continue; + } + + if (!path.endsWith(".d.ts")) { + continue; + } + + const source = readFileSync(path, "utf8"); + const rewritten = source.replace( + /(["'])(\.\.?\/[^"']+)\1/g, + (match, quote: string, specifier: string) => { + if (!specifier.endsWith(".ts") || specifier.endsWith(".d.ts")) { + return match; + } + + return `${quote}${specifier.slice(0, -3)}.js${quote}`; + }, + ); + + if (rewritten !== source) { + writeFileSync(path, rewritten); + } + } +} + +run(["bun", "run", "build"], join(repoRoot, "sdk")); + +mkdirSync(distDir, { recursive: true }); +copyFileSync( + join(repoRoot, "sdk", "dist", "index.d.ts"), + join(distDir, "index.d.ts"), +); +copyFileSync( + join(repoRoot, "sdk", "dist", "types.d.ts"), + join(distDir, "types.d.ts"), +); + +run([ + join(repoRoot, "node_modules", ".bin", "tsc"), + "-p", + "tsconfig.types.json", +]); + +if (!existsSync(join(distDir, "index.d.ts"))) { + throw new Error("Missing dist/index.d.ts after declaration build"); +} + +if (!existsSync(join(distDir, "storage.d.ts"))) { + throw new Error("Missing dist/storage.d.ts after declaration build"); +} + +rewriteRelativeTsImports(distDir); diff --git a/scripts/validate-package.ts b/scripts/validate-package.ts new file mode 100644 index 0000000..7d1638a --- /dev/null +++ b/scripts/validate-package.ts @@ -0,0 +1,228 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, posix, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +interface PackageJson { + bin?: Record | string; + exports?: unknown; + main?: string; + types?: string; + typings?: string; +} + +interface PackFile { + path: string; +} + +interface PackResult { + files: PackFile[]; +} + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse( + readFileSync(join(repoRoot, "package.json"), "utf8"), +) as PackageJson; + +function normalizePackagePath(path: string): string { + return path.replace(/\\/g, "/").replace(/^\.\//, ""); +} + +function isLocalPackagePath(value: string): boolean { + return value.startsWith("./"); +} + +function addRequiredPath(requiredPaths: Set, value: unknown): void { + if (typeof value === "string" && isLocalPackagePath(value)) { + requiredPaths.add(normalizePackagePath(value)); + } +} + +function collectExportPaths(requiredPaths: Set, value: unknown): void { + if (typeof value === "string") { + addRequiredPath(requiredPaths, value); + return; + } + + if (!value || typeof value !== "object") { + return; + } + + for (const [condition, target] of Object.entries(value)) { + if ( + condition === "types" || + condition === "import" || + condition === "require" || + condition === "default" + ) { + addRequiredPath(requiredPaths, target); + } + + collectExportPaths(requiredPaths, target); + } +} + +function collectRequiredPackagePaths(): Set { + const requiredPaths = new Set(); + + addRequiredPath(requiredPaths, packageJson.main); + addRequiredPath(requiredPaths, packageJson.types); + addRequiredPath(requiredPaths, packageJson.typings); + + if (typeof packageJson.bin === "string") { + addRequiredPath(requiredPaths, packageJson.bin); + } else if (packageJson.bin) { + for (const target of Object.values(packageJson.bin)) { + addRequiredPath(requiredPaths, target); + } + } + + collectExportPaths(requiredPaths, packageJson.exports); + + return requiredPaths; +} + +function runPackDryRun(): PackResult { + const result = Bun.spawnSync(["npm", "pack", "--dry-run", "--json"], { + cwd: repoRoot, + stderr: "pipe", + stdout: "pipe", + }); + + if (result.exitCode !== 0) { + const stderr = new TextDecoder().decode(result.stderr); + throw new Error(`npm pack --dry-run failed:\n${stderr}`); + } + + const stdout = new TextDecoder().decode(result.stdout); + const jsonStart = stdout.indexOf("["); + const jsonEnd = stdout.lastIndexOf("]"); + + if (jsonStart === -1 || jsonEnd === -1 || jsonEnd < jsonStart) { + throw new Error(`npm pack --dry-run returned no JSON array:\n${stdout}`); + } + + const packResults = JSON.parse( + stdout.slice(jsonStart, jsonEnd + 1), + ) as PackResult[]; + const packResult = packResults[0]; + + if (!packResult) { + throw new Error("npm pack --dry-run returned no package result"); + } + + return packResult; +} + +function extractDeclarationSpecifiers(source: string): string[] { + const specifiers: string[] = []; + const pattern = + /(?:import|export)\s+(?:type\s+)?(?:[^"']*?\s+from\s+)?["']([^"']+)["']|import\(["']([^"']+)["']\)/g; + + for (const match of source.matchAll(pattern)) { + const specifier = match[1] ?? match[2]; + if (specifier) { + specifiers.push(specifier); + } + } + + return specifiers; +} + +function declarationTargetForSpecifier( + fromPath: string, + specifier: string, +): string | null { + if (!specifier.startsWith(".")) { + return null; + } + + if (specifier.endsWith(".ts") && !specifier.endsWith(".d.ts")) { + throw new Error( + `${fromPath} imports ${specifier}; declaration files must reference emitted .js specifiers`, + ); + } + + const basePath = posix.normalize( + posix.join(posix.dirname(fromPath), specifier), + ); + + if (basePath.endsWith(".d.ts")) { + return basePath; + } + + if ( + basePath.endsWith(".js") || + basePath.endsWith(".mjs") || + basePath.endsWith(".cjs") + ) { + return `${basePath.slice(0, basePath.lastIndexOf("."))}.d.ts`; + } + + return `${basePath}.d.ts`; +} + +function validateDeclarationImports(packFiles: Set): string[] { + const errors: string[] = []; + + for (const file of packFiles) { + if (!file.endsWith(".d.ts")) { + continue; + } + + const absolutePath = join(repoRoot, file); + if (!existsSync(absolutePath)) { + errors.push(`${file} is listed by pack but does not exist locally`); + continue; + } + + const source = readFileSync(absolutePath, "utf8"); + for (const specifier of extractDeclarationSpecifiers(source)) { + try { + const target = declarationTargetForSpecifier(file, specifier); + if (target && !packFiles.has(target)) { + errors.push( + `${file} imports ${specifier}, but ${target} is not packed`, + ); + } + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + } + + return errors; +} + +const packResult = runPackDryRun(); +const packFiles = new Set( + packResult.files.map((file) => normalizePackagePath(file.path)), +); +const requiredPaths = collectRequiredPackagePaths(); +const missingRequiredPaths = [...requiredPaths].filter( + (path) => !packFiles.has(path), +); +const declarationErrors = validateDeclarationImports(packFiles); + +if (missingRequiredPaths.length > 0 || declarationErrors.length > 0) { + console.error("Package validation failed."); + + if (missingRequiredPaths.length > 0) { + console.error("Missing package metadata targets:"); + for (const path of missingRequiredPaths) { + console.error(` - ${path}`); + } + } + + if (declarationErrors.length > 0) { + console.error("Invalid declaration imports:"); + for (const error of declarationErrors) { + console.error(` - ${error}`); + } + } + + process.exit(1); +} + +console.log( + `Package validation passed: ${requiredPaths.size} metadata target(s), ${packFiles.size} packed file(s).`, +); diff --git a/tsconfig.types.json b/tsconfig.types.json new file mode 100644 index 0000000..626dd1b --- /dev/null +++ b/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowJs": false, + "declaration": true, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/storage.ts"] +}