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
8 changes: 8 additions & 0 deletions packages/format/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 18 additions & 3 deletions packages/format/bin/generate-schema-yamls.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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<Id extends keyof typeof rawSchemas> =
(typeof rawSchemas)[Id];
export type Schema<Id extends keyof RawSchemas> = RawSchemas[Id];
`;

const outputPath = path.resolve(__dirname, "../src/schemas/yamls.ts");
Expand Down
2 changes: 2 additions & 0 deletions packages/format/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
69 changes: 69 additions & 0 deletions packages/format/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
83 changes: 83 additions & 0 deletions packages/format/src/schemas/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("yaml")>();
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<typeof import("yaml")>("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);
});
});
37 changes: 31 additions & 6 deletions packages/format/src/schemas/index.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
18 changes: 18 additions & 0 deletions packages/format/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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"],
},
Expand Down
Loading