From 2f7a53b50d80a16e7c257489f5eeb2635ff8a8c4 Mon Sep 17 00:00:00 2001 From: Kawanet Date: Mon, 24 Aug 2026 01:16:47 +0900 Subject: [PATCH 1/3] Bench modules named on the command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparing this package against itself — a published build, a branch, the working tree — meant swapping dist/ in place between runs, which mutates the tree and puts each build in its own process, out of reach of the counterbalanced ordering. Naming the files instead keeps every build in one rotation, each behind a class of its own so the compared builds do not meet at shared call sites. Co-authored-by: Claude --- builder/bench.cli.ts | 23 +++++++--- builder/node-url.shim.ts | 7 +++ builder/rollup-bench.config.ts | 1 + builder/rollup-browser-test.config.ts | 1 + test/utils/adapters.ts | 65 +++++++++++++++++++++++---- 5 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 builder/node-url.shim.ts diff --git a/builder/bench.cli.ts b/builder/bench.cli.ts index 54d2b04..d47316c 100644 --- a/builder/bench.cli.ts +++ b/builder/bench.cli.ts @@ -18,6 +18,10 @@ const param = (key: string, def: string): string => { const DURATION = +param("DURATION", "500") const SETS = +param("SETS", "10") const TARGET = param("TARGET", "") +// Module paths given on the command line. Naming files is a more +// specific request than TARGET, so they replace it rather than filter +// it, turning the run into a comparison of this package's own builds. +const PATHS = ("object" === typeof location) ? [] : process.argv.slice(2) const SHUFFLE_SEED = 0x53484132 // ASCII "SHA2" // Garbage in, immediate stop: the caller chose the values. @@ -125,10 +129,15 @@ async function main(): Promise { ["crypto.subtle.digest()", new A.SubtleCrypto()], ] - // TARGET picks modules by comma-separated substrings; - // the default (empty) measures everything. - const wants = TARGET.split(",").map(t => t.trim()).filter(Boolean) - const picked = ADAPTERS.filter(([name]) => !wants.length || wants.some(t => name.includes(t))) + // Named paths replace the comparison outright rather than filtering + // it, so every cell then measures a build of this package and only + // the code differs between them. + const named = PATHS.map((path): [string, A.Adapter] => [path, A.dynamicModule(path)]) + + // TARGET picks modules by comma-separated substrings; the default + // (empty) measures everything, and named paths skip it entirely. + const wants = named.length ? [] : TARGET.split(",").map(t => t.trim()).filter(Boolean) + const picked = named.length ? named : ADAPTERS.filter(([name]) => !wants.length || wants.some(t => name.includes(t))) if (wants.length && picked.length === 0) { throw new Error(`TARGET matched nothing: ${TARGET}`) @@ -138,6 +147,9 @@ async function main(): Promise { // the sync implementation when it has one, otherwise the async one // in the same rotation, and a cell-less adapter is simply skipped. const cells: Cell[] = [] + // Loading a module is a cost of the import, not of a digest, so an + // adapter that needs one gets it out of the way before any closure. + for (const [, adapter] of picked) await adapter.setup() for (const [name, adapter] of picked) { const s = adapter.makeStringBench(stringPairs) if (s) cells.push({name, input: "string", impl: "sync", fn: s, opsPerRepeat: stringPairs.length, repeat: 0, times: []}) @@ -148,7 +160,8 @@ async function main(): Promise { } const env = ("object" === typeof process && process.version) ? `node ${process.version}` : navigator.userAgent - out(`# ${env} DURATION=${DURATION} SETS=${SETS} TARGET=${TARGET || "(all)"}`) + const scope = named.length ? `FILES=${named.length}` : `TARGET=${TARGET || "(all)"}` + out(`# ${env} DURATION=${DURATION} SETS=${SETS} ${scope}`) const random = mulberry32(SHUFFLE_SEED) diff --git a/builder/node-url.shim.ts b/builder/node-url.shim.ts new file mode 100644 index 0000000..9986751 --- /dev/null +++ b/builder/node-url.shim.ts @@ -0,0 +1,7 @@ +// Browser stand-in for `node:url`, aliased in by the rollup configs. +// Only the CLI-only dynamic adapter reaches for it, so nothing here is +// ever meant to run; it exists to keep the browser bundle self-contained. + +export const pathToFileURL = (_path: string): never => { + throw new Error("pathToFileURL() not supported") +} diff --git a/builder/rollup-bench.config.ts b/builder/rollup-bench.config.ts index ccf6901..6127f5e 100644 --- a/builder/rollup-bench.config.ts +++ b/builder/rollup-bench.config.ts @@ -102,6 +102,7 @@ const browserConfig: RollupOptions = { entries: [ {find: "node:assert", replacement: here("./node-assert.shim.ts")}, {find: "node:crypto", replacement: here("./node-crypto.shim.ts")}, + {find: "node:url", replacement: here("./node-url.shim.ts")}, {find: "sha256-uint8array", replacement: here("../browser/import.js")}, {find: /^(\.\.\/)+lib\/sha256-uint8array\.ts$/, replacement: here("../browser/import.js")}, ], diff --git a/builder/rollup-browser-test.config.ts b/builder/rollup-browser-test.config.ts index 0a378aa..0713a8d 100644 --- a/builder/rollup-browser-test.config.ts +++ b/builder/rollup-browser-test.config.ts @@ -66,6 +66,7 @@ const rollupConfig: RollupOptions = { {find: "node:test", replacement: here("./node-test.shim.ts")}, {find: "node:assert", replacement: here("./node-assert.shim.ts")}, {find: "node:crypto", replacement: here("./node-crypto.shim.ts")}, + {find: "node:url", replacement: here("./node-url.shim.ts")}, {find: "sha256-uint8array", replacement: here("../browser/import.js")}, // The suites spell the entry as a relative path; same shim either way. {find: /^(\.\.\/)+lib\/sha256-uint8array\.ts$/, replacement: here("../browser/import.js")}, diff --git a/test/utils/adapters.ts b/test/utils/adapters.ts index 411e03b..24d57e6 100644 --- a/test/utils/adapters.ts +++ b/test/utils/adapters.ts @@ -13,10 +13,24 @@ import jsSha from "jssha/dist/sha256" import forgeSha from "node-forge/lib/sha256.js" import {strict as assert} from "node:assert" import * as nodeCrypto from "node:crypto" +import {pathToFileURL} from "node:url" import shaJs from "sha.js/sha256.js" import {createHash as ownCreateHash} from "../../lib/sha256-uint8array.ts" import {arrayToHex} from "./utils.ts" +// update() is published one input shape at a time, so a caller holding +// a union has to narrow it back down before every call — a test the +// implementation then repeats internally. Naming the union it already +// takes at run time lets the adapters hand theirs straight over: method +// parameters are bivariant, so the published overloads satisfy this on +// their own and the package keeps publishing exactly what it always has. +interface IHash { + update(data: string | Uint8Array | ArrayBufferView): IHash; + digest(encoding: string): string; +} + +type ICreateHash = (algorithm?: string) => IHash + export interface BenchPair { data: T; expect: string; @@ -35,6 +49,12 @@ export abstract class Adapter { declare noAsync?: boolean; declare noBench?: boolean; + // An adapter that has to load something before its first hash() + // overrides this. The runner awaits it once, before any measuring, + // so a module load never lands inside a timed window. + async setup(): Promise { + } + hash(_data: string | Uint8Array | ArrayBufferView): string { throw new Error("hash() not supported") } @@ -81,16 +101,10 @@ const hasSubtle = ("undefined" !== typeof crypto) && crypto.subtle && ("function */ export class SHA256Uint8Array extends Adapter { - private createHash = ownCreateHash; + private createHash: ICreateHash = ownCreateHash; hash(data: string | Uint8Array | ArrayBufferView): string { - const hash = this.createHash() - if ("string" === typeof data) { - hash.update(data) // same call either way: update() is overloaded, not union-typed - } else { - hash.update(data) - } - return hash.digest("hex") + return this.createHash().update(data).digest("hex") } } @@ -249,6 +263,41 @@ export class JsSha256 extends Adapter { } } +/** + * A module named on the command line rather than a package this file + * knows about. It exists to compare builds of this package with each + * other — a published dist/, a branch build, the working tree — where + * every cell runs the same implementation and only the code differs, + * so the numbers answer "did this change help?" directly. + * + * Each path gets a class of its own, for the same reason the benchmark + * closures above are built per adapter: one shared method would send + * every module's createHash, and the differently shaped hashes it + * returns, through the same call sites, so the compared builds would + * blend into each other's numbers instead of standing apart. + * + * Note: it expects the createHash() entry point this package documents, + * so it is not a general adapter for arbitrary modules. + */ + +export const dynamicModule = (path: string): Adapter => new class extends Adapter { + private loaded: ICreateHash | null = null; + + override async setup(): Promise { + const module = await import(pathToFileURL(path).href) as {createHash?: ICreateHash} + if ("function" !== typeof module.createHash) { + throw new Error(`${path}: no createHash export`) + } + this.loaded = module.createHash + } + + hash(data: string | Uint8Array | ArrayBufferView): string { + const createHash = this.loaded + if (!createHash) throw new Error(`${path}: setup() not awaited`) + return createHash().update(data).digest("hex") + } +}() + /** * https://developer.mozilla.org/docs/Web/API/SubtleCrypto */ From cf2ee7699e9fac413fefe73fd12765834a528fb5 Mon Sep 17 00:00:00 2001 From: Kawanet Date: Mon, 31 Aug 2026 23:08:36 +0900 Subject: [PATCH 2/3] Answer -h, and refuse the arguments that are not paths Every argument is a module to import, so `-h` was resolved as a filename and reported as a missing module. Print the invocation and the three environment variables instead, and refuse anything else that leads with a dash rather than trying to load it. Co-authored-by: Claude --- builder/bench.cli.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/builder/bench.cli.ts b/builder/bench.cli.ts index d47316c..a772dae 100644 --- a/builder/bench.cli.ts +++ b/builder/bench.cli.ts @@ -24,6 +24,23 @@ const TARGET = param("TARGET", "") const PATHS = ("object" === typeof location) ? [] : process.argv.slice(2) const SHUFFLE_SEED = 0x53484132 // ASCII "SHA2" +// Every other argument is a module to import, so a leading dash can only +// be a mistyped option — worth refusing rather than trying to load. +const USAGE = [ + "usage: bench.cli.ts [module.mjs ...]", + " DURATION=500 milliseconds each cell is calibrated towards", + " SETS=10 measured sets per cell", + " TARGET= comma-separated name substrings, unused when modules are named", +].join("\n") + +if (PATHS.includes("-h") || PATHS.includes("--help")) { + console.log(USAGE) + process.exit(0) +} + +const dashed = PATHS.find(path => path.startsWith("-")) +if (dashed) throw new Error(`unknown option: ${dashed}, try -h`) + // Garbage in, immediate stop: the caller chose the values. if (!(Number.isFinite(DURATION) && DURATION > 0 && Number.isInteger(SETS) && SETS > 0)) { throw new Error(`invalid DURATION=${param("DURATION", "")} SETS=${param("SETS", "")}`) From 2963fc407959bddc90581946bf4d334116c92362 Mon Sep 17 00:00:00 2001 From: Yusuke Kawasaki Date: Mon, 31 Aug 2026 23:35:04 +0900 Subject: [PATCH 3/3] CommonJS module interop Co-authored-by: Claude --- test/utils/adapters.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/utils/adapters.ts b/test/utils/adapters.ts index 24d57e6..552679a 100644 --- a/test/utils/adapters.ts +++ b/test/utils/adapters.ts @@ -31,6 +31,8 @@ interface IHash { type ICreateHash = (algorithm?: string) => IHash +const hasCreateHash = (v: any): v is {createHash: ICreateHash} => ("function" === typeof v?.createHash) + export interface BenchPair { data: T; expect: string; @@ -284,8 +286,9 @@ export const dynamicModule = (path: string): Adapter => new class extends Adapte private loaded: ICreateHash | null = null; override async setup(): Promise { - const module = await import(pathToFileURL(path).href) as {createHash?: ICreateHash} - if ("function" !== typeof module.createHash) { + let module = await import(pathToFileURL(path).href) + if (!hasCreateHash(module)) module = module?.default + if (!hasCreateHash(module)) { throw new Error(`${path}: no createHash export`) } this.loaded = module.createHash