Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions examples/audiobook-curator/src/audible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
CuratorError,
audibleHosts,
contributorNames,
errorMessage,
normalizedIdentity,
syncDirectory,
syncFile,
Expand Down Expand Up @@ -199,7 +200,7 @@ export const requestWithAttempts = async (
failure = error;
}
}
throw new CuratorError(failure instanceof Error ? failure.message : `Request failed: ${url}`);
throw new CuratorError(errorMessage(failure, `Request failed: ${url}`));
};

export const searchAudible = async (
Expand Down Expand Up @@ -232,7 +233,7 @@ export const searchAudible = async (
region,
})));
} catch (error) {
errors.push({ error: error instanceof Error ? error.message : 'Audible search failed.', region });
errors.push({ error: errorMessage(error, 'Audible search failed.'), region });
}
}
candidates.sort((left, right) => right.evidence.score - left.evidence.score);
Expand Down Expand Up @@ -316,7 +317,7 @@ export const cacheAudibleEdition = async (
chapterPath = join(cache, 'chapters.json');
await writeReceipt(chapterPath, chapters);
} catch (error) {
chapterError = error instanceof Error ? error.message : 'Audible chapter request failed.';
chapterError = errorMessage(error, 'Audible chapter request failed.');
}
const images = productRecord.product_images;
const imageUrl = images !== null && typeof images === 'object' && !Array.isArray(images)
Expand Down
6 changes: 3 additions & 3 deletions examples/audiobook-curator/src/evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type AudibleRegion,
type CuratorHttpClient,
} from './audible.ts';
import { CuratorError, asRecord, audibleHosts, contributorNames, readJson, utcNow, writeReceipt } from './foundation.ts';
import { CuratorError, asRecord, audibleHosts, contributorNames, errorMessage, readJson, utcNow, writeReceipt } from './foundation.ts';
import { probeMediaRecord, type LibraryDependencies } from './library.ts';
import { runMediaProcess, type MediaProcess } from './media-process.ts';

Expand Down Expand Up @@ -152,7 +152,7 @@ const pythonMatcher = (python: string, process: MediaProcess): AcousticMatcher =
asRecord(parsed);
return Object.freeze(parsed);
} catch (error) {
throw new CuratorError(`Audiolocate is optional; install it for ${python}, or inject an acoustic matcher. ${error instanceof Error ? error.message : ''}`.trim());
throw new CuratorError(`Audiolocate is optional; install it for ${python}, or inject an acoustic matcher. ${errorMessage(error, '')}`.trim());
}
};

Expand Down Expand Up @@ -282,7 +282,7 @@ export const identifyAudibleSample = async (
if (input.all !== true) break;
}
} catch (error) {
const reason = error instanceof Error ? error.message : 'Acoustic comparison failed.';
const reason = errorMessage(error, 'Acoustic comparison failed.');
attempts.push({ ...base, reason, status: reason.includes('no sample URL') ? 'skipped' : 'error' });
}
}
Expand Down
3 changes: 3 additions & 0 deletions examples/audiobook-curator/src/foundation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export class CuratorError extends Error {}

export const utcNow = (): string => new Date().toISOString();

export const errorMessage = (error: unknown, fallback: string): string =>
error instanceof Error ? error.message : fallback;

/** Narrows an unknown value to a plain record, or returns an empty one. */
export const asRecord = (value: unknown): Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
Expand Down
4 changes: 2 additions & 2 deletions examples/audiobook-curator/src/integrity-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { lstat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';

import { chapterMappingIssues, type ChapterRow } from './conversion.ts';
import { CuratorError, asRecord, readJson, sha256File, utcNow, writeReceipt } from './foundation.ts';
import { CuratorError, asRecord, errorMessage, readJson, sha256File, utcNow, writeReceipt } from './foundation.ts';
import { probeMediaDetails, probeMediaRecord, type LibraryDependencies, type MediaDetails, type MediaRecord } from './library.ts';
import { runMediaProcess, type MediaProcess } from './media-process.ts';

Expand Down Expand Up @@ -117,7 +117,7 @@ export const auditAudiobookIntegrity = async (
fullDecode = 'verified';
} catch (error) {
fullDecode = 'failed';
issues.push(`full decode failed: ${error instanceof Error ? error.message : 'unknown failure'}`);
issues.push(`full decode failed: ${errorMessage(error, 'unknown failure')}`);
}
}
const after = await lstat(file);
Expand Down
18 changes: 8 additions & 10 deletions examples/audiobook-curator/src/library.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { lstat, opendir } from 'node:fs/promises';
import { basename, dirname, extname, join, relative, resolve } from 'node:path';

import { audioExtensions, mapWithConcurrency, naturalCompare, normalizedIdentity, utcNow } from './foundation.ts';
import { audioExtensions, errorMessage, mapWithConcurrency, naturalCompare, normalizedIdentity, utcNow } from './foundation.ts';
import { runMediaProcess, type MediaProcess } from './media-process.ts';

const maximumEntries = 65_536;
Expand Down Expand Up @@ -204,23 +204,21 @@ const discover = async (source: string): Promise<{ readonly files: string[]; rea
return { files, root: selected };
};

const errorMessage = (error: unknown): string => error instanceof Error ? error.message : 'Audiobook inspection failed.';

export const createInventory = async (
input: { readonly source: string; readonly strict?: boolean },
dependencies: LibraryDependencies = {},
): Promise<InventoryReceipt> => {
const discovered = await discover(input.source);
const files: MediaRecord[] = [];
const errors: Array<{ error: string; path: string }> = [];
for (const path of discovered.files) {
const outcomes = await mapWithConcurrency(discovered.files, 2, async (path) => {
dependencies.signal?.throwIfAborted();
try {
files.push(await probeMediaRecord(path, discovered.root, dependencies));
return { ok: true as const, record: await probeMediaRecord(path, discovered.root, dependencies) };
} catch (error) {
errors.push(Object.freeze({ error: errorMessage(error), path }));
return { error: errorMessage(error, 'Audiobook inspection failed.'), ok: false as const, path };
}
}
});
const files = outcomes.flatMap((outcome) => outcome.ok ? [outcome.record] : []);
const errors = outcomes.flatMap((outcome) => outcome.ok ? [] : [Object.freeze({ error: outcome.error, path: outcome.path })]);
return Object.freeze({
errors: Object.freeze(errors),
exitCode: input.strict === true && errors.length > 0 ? 1 : 0,
Expand Down Expand Up @@ -275,7 +273,7 @@ const auditFile = async (path: string, root: string, dependencies: LibraryDepend
const metadata = await lstat(path);
return Object.freeze({
bytes: metadata.size,
error: errorMessage(error),
error: errorMessage(error, 'Audiobook inspection failed.'),
extension: extname(path).toLowerCase(),
missing: Object.freeze({ album: false, artwork: false, author: false, chapters: false, title: false }),
path: resolve(path),
Expand Down
5 changes: 2 additions & 3 deletions examples/audiobook-curator/src/media-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
asRecord,
contributorNames,
escapeFfmetadata,
errorMessage,
readJson,
sha256File,
syncDirectory,
Expand Down Expand Up @@ -106,8 +107,6 @@ const chaptersFromDetails = (details: MediaDetails): Omit<ChapterRow, 'number'>[

const duration = (details: MediaDetails): number => Number(asRecord(details.format).duration ?? 0);

const errorText = (error: unknown): string => error instanceof Error ? error.message : 'Media mutation failed.';

export const cleanCatalogText = (value: unknown): string => String(value ?? '')
.replaceAll(/<br\s*\/?>/giu, '\n')
.replaceAll(/<[^>]+>/gu, '')
Expand Down Expand Up @@ -400,6 +399,6 @@ export const applyAudiobookChapters = async (
return receipt;
} catch (error) {
if (error instanceof CuratorError) throw error;
throw new CuratorError(errorText(error));
throw new CuratorError(errorMessage(error, 'Media mutation failed.'));
} finally { await rm(work, { force: true, recursive: true }); }
};
24 changes: 24 additions & 0 deletions examples/rsc-agent-runtime/src/dev/canonical-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createHash } from 'node:crypto';

/**
* Key-sorted, undefined-skipping canonical JSON used for runtime metadata
* digests. Throws on non-finite numbers and non-JSON values so digests can
* never silently diverge between writers.
*/
export const canonicalJson = (value: unknown): string => {
if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.');
return JSON.stringify(value);
}
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.');
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().flatMap((key) => {
const item = record[key];
return item === undefined ? [] : [`${JSON.stringify(key)}:${canonicalJson(item)}`];
}).join(',')}}`;
};

export const digestValue = (value: unknown): string =>
createHash('sha256').update(canonicalJson(value)).digest('hex');
20 changes: 1 addition & 19 deletions examples/rsc-agent-runtime/src/dev/generation-materializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { open, lstat, mkdir, readdir, readFile, writeFile } from 'node:fs/promis
import { spawn } from 'node:child_process';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';

import { canonicalJson, digestValue } from './canonical-json.js';
import { emitRuntimeArtifacts } from '../build/emit-artifacts.js';
import type {
RscEnvironmentCheckpointValidator,
Expand Down Expand Up @@ -90,25 +91,6 @@ export interface MaterializeRuntimeGenerationOptions {

const digestBytes = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex');

const canonicalJson = (value: unknown): string => {
if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.');
return JSON.stringify(value);
}
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.');

const input = value as Record<string, unknown>;
return `{${Object.keys(input).sort().flatMap((key) => {
const item = input[key];
return item === undefined ? [] : [`${JSON.stringify(key)}:${canonicalJson(item)}`];
}).join(',')}}`;
};

const digestValue = (value: unknown): string =>
createHash('sha256').update(canonicalJson(value)).digest('hex');

const freezeJson = (value: unknown, seen = new WeakSet<object>()): JsonValue => {
if (value === null || typeof value === 'boolean' || typeof value === 'string') return value;
if (typeof value === 'number') {
Expand Down
18 changes: 1 addition & 17 deletions examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type RscEnvironmentCheckpointStore,
type RscRuntimeEnvironmentName,
} from './environment-checkpoint-store.js';
import { canonicalJson, digestValue } from './canonical-json.js';
import {
captureRuntimeGenerationSnapshot,
materializeRuntimeGeneration,
Expand Down Expand Up @@ -508,23 +509,6 @@ const validateAppBinding = (value: unknown): void => {
const clonePrepared = (prepared: DevRuntimePreparedProject): DevRuntimePreparedProject =>
deepFreeze(structuredClone(prepared));

const canonicalJson = (value: unknown): string => {
if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.');
return JSON.stringify(value);
}
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.');
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().flatMap((key) => {
const item = record[key];
return item === undefined ? [] : [`${JSON.stringify(key)}:${canonicalJson(item)}`];
}).join(',')}}`;
};

const digestValue = (value: unknown): string => createHash('sha256').update(canonicalJson(value)).digest('hex');

const transportDigest = (prepared: DevRuntimePreparedProject): string => digestValue({
provider: prepared.provider,
servers: prepared.servers.map((server) => ({
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createTargetDiagnostics } from './diagnostics.ts';
import { hasErrors, type Diagnostic } from '../core/diagnostics.ts';
import { readMcpTransport, unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts';
import { isValidPackageName } from '../core/project-context.ts';
import { isRecord } from '../core/strict-json.ts';
import {
pathTokens,
type AgentBundleConfig,
Expand Down Expand Up @@ -590,8 +591,7 @@ const lspServerFields: ReadonlySet<string> = new Set([
]);

/** Normalized config extension values are already strict JSON, so a plain shape test is enough. */
const isDataRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const isDataRecord: (value: unknown) => value is Readonly<Record<string, unknown>> = isRecord;

const isPlainDataRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
isDataRecord(value) && [null, Object.prototype].includes(Object.getPrototypeOf(value));
Expand Down
15 changes: 2 additions & 13 deletions packages/agent-bundle/src/build/emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import {
import { basename, dirname, join, resolve } from 'node:path';

import { sha256Hex, stableJson } from '../core/digest.ts';
import { isErrno } from '../core/errors.ts';
import { assertInside } from '../core/paths.ts';
import { assertInside, exists, toPosixPath } from '../core/paths.ts';
import type { TargetArtifactEntry } from '../adapters/types.ts';
import {
artifactHookIndexName,
Expand Down Expand Up @@ -56,21 +55,11 @@ export { artifactHookIndexName } from './hook-index.ts';
export type { ArtifactHook, ArtifactHookIndex } from './hook-index.ts';
export const artifactManifestName = 'agent-bundle.manifest.json';

const normalizeRelativePath = (path: string): string => path.replaceAll('\\', '/');
const normalizeRelativePath = toPosixPath;

const executableFileMode = (file: ArtifactFile): number | undefined =>
(file.mode & 0o111) === 0 ? undefined : file.mode;

const exists = async (path: string): Promise<boolean> => {
try {
await lstat(path);
return true;
} catch (error) {
if (isErrno(error, 'ENOENT')) return false;
throw error;
}
};

export const resolveArtifactDestination = (root: string, relativePath: string): string =>
assertInside(root, resolve(root, relativePath));

Expand Down
6 changes: 2 additions & 4 deletions packages/agent-bundle/src/build/inspect-bundler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { TargetHookEntry } from '../adapters/types.ts';
import { isPlainRecord } from '../core/strict-json.ts';
import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts';
import { scanEntryExports } from './entry-exports.ts';
import {
Expand Down Expand Up @@ -62,10 +63,7 @@ export const generatedDtsTsconfigToken = '<generated-dts-tsconfig>';

const artifactOutputToken = (target: string): string => `<output>/${target}`;

const isPlainObject = (value: object): boolean => {
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};
const isPlainObject: (value: object) => boolean = isPlainRecord;

/**
* Renders a composed bundler config as JSON-safe data without dropping the
Expand Down
9 changes: 3 additions & 6 deletions packages/agent-bundle/src/build/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
satisfiesGeneratedRuntimeFloor,
} from '../core/runtime.ts';
import { isValidPackageName, isValidPackageVersion } from '../core/project-context.ts';
import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts';
import { isPlainRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts';

export type ArtifactManifestFileKind = 'bundle' | 'copy' | 'generated' | 'prebuilt';
export type ArtifactManifestValidationStatus = 'passed';
Expand Down Expand Up @@ -104,11 +104,8 @@ const fail = (message: string): never => {
throw new TypeError(`Artifact manifest ${message}`);
};

const isPlainObject = (value: unknown): value is JsonRecord => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};
// Inputs are parsed JSON, so the canonical guard's narrowing is retyped to JsonRecord.
const isPlainObject = isPlainRecord as (value: unknown) => value is JsonRecord;

const requireRecord = (value: unknown, location: string): JsonRecord =>
isPlainObject(value) ? value : fail(`${location} must be a plain object.`);
Expand Down
9 changes: 3 additions & 6 deletions packages/agent-bundle/src/build/package-build.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { existsSync } from 'node:fs';
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { basename, dirname, join, relative, resolve } from 'node:path';
import { basename, dirname, join, resolve } from 'node:path';

import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts';
import { DiagnosticError } from '../core/diagnostics.ts';
import { assertInside } from '../core/paths.ts';
import { assertInside, toPosixRelative } from '../core/paths.ts';
import { declarationBuildDiagnostics, replayDeclarationEmit } from './declaration-diagnostics.ts';
import { listArtifactFiles, publishArtifact, resolveArtifactDestination } from './emit.ts';
import { scanEntryExports } from './entry-exports.ts';
Expand Down Expand Up @@ -53,9 +53,6 @@ export interface PackageBuildResult {
readonly outputRoot: string;
}

const toPosixRelative = (root: string, path: string): string =>
relative(resolve(root), path).replaceAll('\\', '/');

const relativeSourceInputs = (projectRoot: string, inputs: readonly string[]): readonly string[] =>
Object.freeze([...new Set(inputs.map((input) => toPosixRelative(projectRoot, assertInside(projectRoot, input))))]
.sort((left, right) => left.localeCompare(right)));
Expand Down Expand Up @@ -203,7 +200,7 @@ export const planPackageEntries = async (
}
const outputRelativePath = `bin/${name}.js`;
const emittedBinDirectory = dirname(resolve(options.packageOutputRoot, outputRelativePath));
const relativeArtifact = relative(emittedBinDirectory, options.artifactRoot).replaceAll('\\', '/');
const relativeArtifact = toPosixRelative(emittedBinDirectory, options.artifactRoot);
const source = packageBuild.bins[0]?.source ?? packageBuild.lib!.source;
entries.push({
aliases: { [installEntryRuntimeSpecifier]: installEntryRuntimePath() },
Expand Down
Loading
Loading