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..f0f9066a6b --- /dev/null +++ b/packages/format/src/index.test.ts @@ -0,0 +1,69 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +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 }) => + typeof imports[path]?.types === "string" + ? { path: resolve(packageRoot, imports[path].types) } + : undefined, + ); + }, +}; + +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..747fe81281 --- /dev/null +++ b/packages/format/src/schemas/index.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +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), + }; +}); + +// 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, YAML } = await load(); + 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 load(); + + expect(Object.keys(schemas)).toEqual(Object.keys(schemaYamls)); + expect(schemaIds).toEqual(Object.keys(schemaYamls)); + + 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 load(); + 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..6650bfe702 100644 --- a/packages/format/src/schemas/index.ts +++ b/packages/format/src/schemas/index.ts @@ -1,10 +1,35 @@ -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. +// 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) => { + 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(parsed ?? describeSchema({ schema: { id } }).schema), + set: settle, + }); +} 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"], },