From d6bf7b0753c3c8101a62280146e0842d72f9f3df Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 8 Oct 2026 18:42:28 -0400 Subject: [PATCH 1/3] format: parse schemas lazily The package root re-exports `schemas`, which parsed every schema YAML at module load. Consumers that only use the types and guards (such as @ethdebug/pointers) paid for the yaml library, all schema sources, and the parse. Define each entry of `schemas` as a getter that parses on first read and then becomes a plain data property. Mark the package side-effect free so bundlers drop the schema modules when nothing imports them. The generated Schema type no longer comes from a runtime `as const` copy of every parsed schema; the generator writes it as a type instead. --- packages/format/CHANGELOG.md | 8 +++ packages/format/bin/generate-schema-yamls.js | 21 +++++- packages/format/package.json | 2 + packages/format/src/index.test.ts | 68 ++++++++++++++++++++ packages/format/src/schemas/index.test.ts | 52 +++++++++++++++ packages/format/src/schemas/index.ts | 30 +++++++-- 6 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 packages/format/src/index.test.ts create mode 100644 packages/format/src/schemas/index.test.ts diff --git a/packages/format/CHANGELOG.md b/packages/format/CHANGELOG.md index 2989e4d9bf..58918d31d2 100644 --- a/packages/format/CHANGELOG.md +++ b/packages/format/CHANGELOG.md @@ -6,6 +6,13 @@ tracked in the root [`CHANGELOG.md`](../../CHANGELOG.md). ## Unreleased +### Changed + +- `schemas` parses each schema from YAML on first access instead of at + module load, and the package declares `"sideEffects": false`. A bundle + that imports only the types and guards no longer includes the `yaml` + library or the schema sources. The API is unchanged ([#373]). + ## 0.1.0-draft.2 — 2026-10-07 ### Changed @@ -148,6 +155,7 @@ First publication. [#305]: https://github.com/ethdebug/format/pull/305 [#323]: https://github.com/ethdebug/format/pull/323 [#325]: https://github.com/ethdebug/format/pull/325 +[#373]: https://github.com/ethdebug/format/pull/373 [`0ef2f37`]: https://github.com/ethdebug/format/commit/0ef2f37 [`10ab103`]: https://github.com/ethdebug/format/commit/10ab103 [`21e532e`]: https://github.com/ethdebug/format/commit/21e532e diff --git a/packages/format/bin/generate-schema-yamls.js b/packages/format/bin/generate-schema-yamls.js index 2320d832fb..308186b245 100644 --- a/packages/format/bin/generate-schema-yamls.js +++ b/packages/format/bin/generate-schema-yamls.js @@ -36,6 +36,22 @@ const rawSchemas = Object.entries(schemaYamls) .map(([id, yaml]) => ({ [id]: YAML.parse(yaml) })) .reduce((a, b) => ({ ...a, ...b }), {}); +// Write a parsed schema as the type that `as const` would give it, so the +// generated module carries no parsed copy of the schemas at runtime +const toConstType = (value) => { + if (Array.isArray(value)) { + return `readonly [${value.map(toConstType).join(", ")}]`; + } + if (value !== null && typeof value === "object") { + const members = Object.entries(value).map( + ([key, member]) => + `readonly ${JSON.stringify(key)}: ${toConstType(member)}`, + ); + return `{ ${members.join("; ")} }`; + } + return JSON.stringify(value); +}; + const output = `// THIS FILE GETS AUTO-GENERATED AS PART OF THIS PACKAGE'S BUILD PROCESS // Please do not modify it directly or allow it to get checked into source control. @@ -49,10 +65,9 @@ export const schemaYamls: SchemaYamlsById = ${JSON.stringify( 2, )}; -const rawSchemas = ${JSON.stringify(rawSchemas, undefined, 2)} as const; +type RawSchemas = ${toConstType(rawSchemas)}; -export type Schema = - (typeof rawSchemas)[Id]; +export type Schema = RawSchemas[Id]; `; const outputPath = path.resolve(__dirname, "../src/schemas/yamls.ts"); diff --git a/packages/format/package.json b/packages/format/package.json index 9ed4de11a9..51785644b8 100644 --- a/packages/format/package.json +++ b/packages/format/package.json @@ -4,6 +4,7 @@ "description": "ethdebug/format schemas distributed as NPM package", "type": "module", "main": "dist/src/index.js", + "sideEffects": false, "repository": { "type": "git", "url": "git+https://github.com/ethdebug/format.git", @@ -78,6 +79,7 @@ "chalk": "^4.1.0", "cli-highlight": "^2.1.11", "concurrently": "^8.2.2", + "esbuild": "^0.27.0", "indent-string": "^4.0.0", "nodemon": "^3.1.11", "ts-node": "^10.9.2", diff --git a/packages/format/src/index.test.ts b/packages/format/src/index.test.ts new file mode 100644 index 0000000000..88f4855802 --- /dev/null +++ b/packages/format/src/index.test.ts @@ -0,0 +1,68 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; + +import { build, type Plugin } from "esbuild"; +import { describe, expect, it } from "vitest"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); + +// resolve the package's `#...` imports to source, so the check does not +// depend on a fresh `dist/` +const { imports } = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +); +const sourceImports: Plugin = { + name: "source-imports", + setup(build) { + build.onResolve({ filter: /^#/ }, ({ path }) => ({ + path: fileURLToPath( + new URL(imports[path].types, `file://${packageRoot}`), + ), + })); + }, +}; + +const bundle = async (contents: string) => { + const { metafile, outputFiles } = await build({ + stdin: { contents, resolveDir: packageRoot, loader: "ts" }, + bundle: true, + minify: true, + format: "esm", + write: false, + metafile: true, + logLevel: "silent", + plugins: [sourceImports], + }); + + // only the inputs that contribute code to the output + const [{ inputs }] = Object.values(metafile.outputs); + return { + inputs: Object.keys(inputs).filter((input) => inputs[input].bytesInOutput), + gzipped: gzipSync(outputFiles[0].contents).length, + }; +}; + +const isSchemaInput = (input: string) => + input.includes("node_modules/yaml/") || input.endsWith("yamls.ts"); + +describe("bundling the package root", () => { + it("leaves out the schemas when they are not imported", async () => { + const { inputs, gzipped } = await bundle( + `import { isPointer } from "./src/index.js"; + globalThis.result = isPointer({});`, + ); + + expect(inputs.filter(isSchemaInput)).toEqual([]); + expect(gzipped).toBeLessThan(10_000); + }); + + it("includes the schemas when they are imported", async () => { + const { inputs } = await bundle( + `import { schemas } from "./src/index.js"; + globalThis.result = schemas;`, + ); + + expect(inputs.filter(isSchemaInput)).not.toEqual([]); + }); +}); diff --git a/packages/format/src/schemas/index.test.ts b/packages/format/src/schemas/index.test.ts new file mode 100644 index 0000000000..3ee2133468 --- /dev/null +++ b/packages/format/src/schemas/index.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; +import * as YAML from "yaml"; + +import { schemaYamls } from "./yamls.js"; + +vi.mock("yaml", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + parse: vi.fn(original.parse), + parseDocument: vi.fn(original.parseDocument), + }; +}); + +describe("schemas", () => { + it("parses each schema only when it is first read", async () => { + const { schemas, schemaIds } = await import("../index.js"); + const parse = vi.mocked(YAML.parse); + + expect(schemaIds.length).toBeGreaterThan(0); + expect(parse).not.toHaveBeenCalled(); + expect(YAML.parseDocument).not.toHaveBeenCalled(); + + const id = "schema:ethdebug/format/pointer"; + expect(schemas[id]).toHaveProperty("$id", id); + const calls = parse.mock.calls.length; + expect(calls).toBeGreaterThan(0); + + expect(schemas[id]).toBe(schemas[id]); + expect(parse.mock.calls.length).toBe(calls); + }); + + it("holds every schema, parsed from its YAML", async () => { + const { schemas, schemaIds } = await import("../index.js"); + + expect(Object.keys(schemas)).toEqual(Object.keys(schemaYamls)); + expect(schemaIds).toEqual(Object.keys(schemaYamls)); + + for (const [id, yaml] of Object.entries(schemaYamls)) { + expect(schemas[id]).toEqual(YAML.parse(yaml, { merge: true })); + } + }); + + it("accepts assignment like a plain object", async () => { + const { schemas } = await import("../index.js"); + const id = "schema:ethdebug/format/data/value"; + const replacement = { $id: id, title: "replaced" }; + + schemas[id] = replacement; + expect(schemas[id]).toBe(replacement); + }); +}); diff --git a/packages/format/src/schemas/index.ts b/packages/format/src/schemas/index.ts index fc62290fc8..9170c752c2 100644 --- a/packages/format/src/schemas/index.ts +++ b/packages/format/src/schemas/index.ts @@ -1,10 +1,28 @@ -import { describeSchema } from "#describe"; +import { type JSONSchema, describeSchema } from "#describe"; import { schemaYamls } from "./yamls.js"; export type { Schema } from "./yamls.js"; export const schemaIds: string[] = Object.keys(schemaYamls); -export const schemas = schemaIds - .map((id) => ({ - [id]: describeSchema({ schema: { id } }).schema, - })) - .reduce((a, b) => ({ ...a, ...b }), {}); + +// Each schema is parsed from YAML on first access, not at module load. +// The first read or write turns the getter into a plain data property. +export const schemas: { [id: string]: JSONSchema } = {}; + +for (const id of schemaIds) { + const settle = (value: JSONSchema) => { + Object.defineProperty(schemas, id, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + return value; + }; + + Object.defineProperty(schemas, id, { + enumerable: true, + configurable: true, + get: () => settle(describeSchema({ schema: { id } }).schema), + set: settle, + }); +} From a81f5d12b1348bf94b4455b6f514e07ee95a586d Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 8 Oct 2026 19:14:39 -0400 Subject: [PATCH 2/3] format: keep lazy schemas readable after freeze or seal Freezing or sealing `schemas` made each accessor non-configurable, so the first read threw when it tried to redefine the property. Keep the parsed value in the accessor's closure and redefine the property only while it is still configurable. Load a fresh module for each test so the tests do not share state. --- packages/format/src/schemas/index.test.ts | 45 +++++++++++++++++++---- packages/format/src/schemas/index.ts | 23 ++++++++---- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/packages/format/src/schemas/index.test.ts b/packages/format/src/schemas/index.test.ts index 3ee2133468..747fe81281 100644 --- a/packages/format/src/schemas/index.test.ts +++ b/packages/format/src/schemas/index.test.ts @@ -1,5 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; -import * as YAML from "yaml"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { schemaYamls } from "./yamls.js"; @@ -12,9 +11,25 @@ vi.mock("yaml", async (importOriginal) => { }; }); +// a fresh copy of the package root, with fresh yaml spies, for each test +beforeEach(() => { + vi.resetModules(); +}); +const load = async () => ({ + ...(await import("../index.js")), + YAML: await import("yaml"), +}); + +const parsedYamls = async () => { + const YAML = await vi.importActual("yaml"); + return Object.entries(schemaYamls).map( + ([id, yaml]) => [id, YAML.parse(yaml, { merge: true })] as const, + ); +}; + describe("schemas", () => { it("parses each schema only when it is first read", async () => { - const { schemas, schemaIds } = await import("../index.js"); + const { schemas, schemaIds, YAML } = await load(); const parse = vi.mocked(YAML.parse); expect(schemaIds.length).toBeGreaterThan(0); @@ -31,18 +46,34 @@ describe("schemas", () => { }); it("holds every schema, parsed from its YAML", async () => { - const { schemas, schemaIds } = await import("../index.js"); + const { schemas, schemaIds } = await load(); expect(Object.keys(schemas)).toEqual(Object.keys(schemaYamls)); expect(schemaIds).toEqual(Object.keys(schemaYamls)); - for (const [id, yaml] of Object.entries(schemaYamls)) { - expect(schemas[id]).toEqual(YAML.parse(yaml, { merge: true })); + for (const [id, expected] of await parsedYamls()) { + expect(schemas[id]).toEqual(expected); } }); + for (const lock of [Object.freeze, Object.seal]) { + it(`reads every schema after ${lock.name}`, async () => { + const { schemas } = await load(); + const id = "schema:ethdebug/format/pointer"; + const before = schemas[id]; + + lock(schemas); + + for (const [id, expected] of await parsedYamls()) { + expect(schemas[id]).toEqual(expected); + expect(schemas[id]).toBe(schemas[id]); + } + expect(schemas[id]).toBe(before); + }); + } + it("accepts assignment like a plain object", async () => { - const { schemas } = await import("../index.js"); + const { schemas } = await load(); const id = "schema:ethdebug/format/data/value"; const replacement = { $id: id, title: "replaced" }; diff --git a/packages/format/src/schemas/index.ts b/packages/format/src/schemas/index.ts index 9170c752c2..6650bfe702 100644 --- a/packages/format/src/schemas/index.ts +++ b/packages/format/src/schemas/index.ts @@ -5,24 +5,31 @@ export type { Schema } from "./yamls.js"; export const schemaIds: string[] = Object.keys(schemaYamls); // Each schema is parsed from YAML on first access, not at module load. -// The first read or write turns the getter into a plain data property. +// Until then, its property is an accessor; the first read or write turns +// it into a plain data property, unless `schemas` has been frozen or +// sealed, in which case the accessor keeps returning the parsed value. export const schemas: { [id: string]: JSONSchema } = {}; for (const id of schemaIds) { + let parsed: JSONSchema | undefined; + const settle = (value: JSONSchema) => { - Object.defineProperty(schemas, id, { - value, - enumerable: true, - writable: true, - configurable: true, - }); + parsed = value; + if (Object.getOwnPropertyDescriptor(schemas, id)?.configurable) { + Object.defineProperty(schemas, id, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } return value; }; Object.defineProperty(schemas, id, { enumerable: true, configurable: true, - get: () => settle(describeSchema({ schema: { id } }).schema), + get: () => settle(parsed ?? describeSchema({ schema: { id } }).schema), set: settle, }); } From bd8197ecfcc1e5d3a9d7faa17f45fb71124b5685 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 8 Oct 2026 19:14:45 -0400 Subject: [PATCH 3/3] format: run package tests against source The `#...` imports resolved to `dist/` under vitest, so a stale build could hide a regression. Alias them to their `types` entries, which point at source. Also build the bundle test's plugin paths with a URL relative to the package and skip imports that have no `types` entry. --- packages/format/src/index.test.ts | 11 ++++++----- packages/format/vitest.config.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/format/src/index.test.ts b/packages/format/src/index.test.ts index 88f4855802..f0f9066a6b 100644 --- a/packages/format/src/index.test.ts +++ b/packages/format/src/index.test.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { gzipSync } from "node:zlib"; @@ -15,11 +16,11 @@ const { imports } = JSON.parse( const sourceImports: Plugin = { name: "source-imports", setup(build) { - build.onResolve({ filter: /^#/ }, ({ path }) => ({ - path: fileURLToPath( - new URL(imports[path].types, `file://${packageRoot}`), - ), - })); + build.onResolve({ filter: /^#/ }, ({ path }) => + typeof imports[path]?.types === "string" + ? { path: resolve(packageRoot, imports[path].types) } + : undefined, + ); }, }; diff --git a/packages/format/vitest.config.ts b/packages/format/vitest.config.ts index 58ad168d00..c16f2d4ad7 100644 --- a/packages/format/vitest.config.ts +++ b/packages/format/vitest.config.ts @@ -1,6 +1,24 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + import { defineProject } from "vitest/config"; +// Resolve the package's `#...` imports to source (their `types` entry), +// so tests run against the current code, not a possibly stale `dist/` +const { imports } = JSON.parse( + readFileSync(new URL("./package.json", import.meta.url), "utf8"), +); +const alias = Object.entries(imports) + .filter(([, target]) => typeof target === "object") + .map(([name, target]) => ({ + find: new RegExp(`^${name}$`), + replacement: fileURLToPath( + new URL((target as { types: string }).types, import.meta.url), + ), + })); + export default defineProject({ + resolve: { alias }, test: { setupFiles: ["./vitest.setup.ts"], },