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
26 changes: 20 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,29 +67,43 @@ jobs:
steps:
- uses: actions/checkout@v7

- uses: pnpm/action-setup@v6
with:
package_json_file: conformance/package.json

- uses: actions/setup-node@v7
with:
node-version: '22'
node-version: '26'
cache: pnpm
cache-dependency-path: conformance/pnpm-lock.yaml

- name: Install conformance package
working-directory: conformance
run: npm ci
run: pnpm install --frozen-lockfile

- name: Lint
working-directory: conformance
run: pnpm turbo run _lint

- name: Typecheck and build codec.ts, verifying its dual ESM/CJS + types with attw
working-directory: conformance
run: pnpm turbo run _typecheck

- name: Confirm generate.mjs's output matches the committed vector files
- name: Confirm generate.ts's output matches the committed vector files
working-directory: conformance
run: |
cp handshake.v1.json /tmp/handshake-committed.json
cp tokens.v1.json /tmp/tokens-committed.json
cp frames.v1.json /tmp/frames-committed.json
npm run generate
pnpm turbo run _generate
if ! diff -u /tmp/handshake-committed.json handshake.v1.json || ! diff -u /tmp/tokens-committed.json tokens.v1.json || ! diff -u /tmp/frames-committed.json frames.v1.json; then
echo "::error::conformance/*.v1.json is out of date. Run 'npm run generate' in conformance/ and commit the result -- never edit the vector files directly."
echo "::error::conformance/*.v1.json is out of date. Run 'pnpm run generate' in conformance/ and commit the result -- never edit the vector files directly."
exit 1
fi

- name: Verify every vector round-trips through cbor2
working-directory: conformance
run: npm run verify
run: pnpm turbo run _test

required-checks:
name: Required Checks
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
node_modules/
dist/
.turbo/
.eslintcache
28 changes: 22 additions & 6 deletions conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,33 @@ Golden test vectors: every implementation's CI must decode each vector's `wire_h
## Regenerating

```
npm install
npm run generate # writes {handshake,tokens,frames}.v1.json from generate.mjs's vector definitions
npm run verify # decodes every committed vector and confirms it round-trips
pnpm install
pnpm run generate # rebuilds codec.ts first, then writes {handshake,tokens,frames}.v1.json from generate.ts's vector definitions
pnpm test # rebuilds codec.ts first, then decodes every committed vector and confirms it round-trips
```

`generate.mjs` is the actual source of truth, not the JSON files: every vector's `message` is authored as plain JS data matching a CDDL rule's fields, and `wire_hex` is derived mechanically by canonically CBOR-encoding it via [`cbor2`](https://www.npmjs.com/package/cbor2)'s CDE (CBOR Common Deterministic Encoding) mode -- the RFC 8949 4.2 core deterministic rules DAG-CBOR itself builds on -- never hand-typed. CI regenerates and diffs against the committed files the same way `spec/`'s own `cddl-validate` job does for `protocol.cddl`, so the two can never silently drift apart.
`generate.ts` is the actual source of truth, not the JSON files: every vector's `message` is authored as plain TypeScript data matching a CDDL rule's fields, and `wire_hex` is derived mechanically by canonically CBOR-encoding it via [`cbor2`](https://www.npmjs.com/package/cbor2)'s CDE (CBOR Common Deterministic Encoding) mode -- the RFC 8949 4.2 core deterministic rules DAG-CBOR itself builds on -- never hand-typed. CI regenerates and diffs against the committed files the same way `spec/`'s own `cddl-validate` job does for `protocol.cddl`, so the two can never silently drift apart.

`codec.mjs` defines the one JSON convention every vector's `message` needs: since JSON has no byte-string type, a CDDL `bstr` field is written as `{ "hex": "<lowercase hex>" }` rather than a raw string or number array. `toWire`/`fromWire` convert between that marker shape and the real bytes CBOR needs on the way in and out.
## Tasks, caching, and linting

`build`/`generate`/`test`/`typecheck`/`lint` are each a thin public script that calls `turbo run _<name>` -- e.g. `"build": "turbo run _build"`, `"_build": "tsdown"`. `turbo.json` keys its task graph on those same underscore names (never the public ones: a task literally named `build` would make `pnpm run build`'s own `turbo run build` call resolve straight back to itself, the recursive-call case Turborepo's docs warn against). `generate`/`test`/`typecheck`/`lint` all depend on `build`, so any of them rebuilds `codec.ts` first when something it depends on changed, and replays the cached result when nothing did -- running several in one invocation (`pnpm turbo run _generate _test _typecheck _lint`, what `just conformance` does) still only builds once, deduplicated across every task that needs it.

Linting uses [`@exadev/eslint-config`](https://www.npmjs.com/package/@exadev/eslint-config), the org's shared config, plus Prettier via `eslint-plugin-prettier`. Typed lint rules need `dist/`'s declarations to resolve the self-referenced package import, which is exactly why `_lint` depends on `_build` too.

## codec.ts is a real, polymorphic package, not just a shared file

`codec.ts` defines the one JSON convention every vector's `message` needs: since JSON has no byte-string type, a CDDL `bstr` field is written as `{ "hex": "<lowercase hex>" }` rather than a raw string or number array. `toWire`/`fromWire` convert between that marker shape and the real bytes CBOR needs on the way in and out; `isVectorFile` validates a parsed vector file at the JSON boundary rather than trusting an `as` cast.

`generate.ts` and `verify.test.ts` both import it as `@exadev/wire-mesh-conformance` (self-referencing the package by its own name), not via a relative path -- `pnpm run build` (`tsdown`) compiles `codec.ts` into dual ESM/CJS output plus `.d.mts`/`.d.cts` declarations under `dist/`, and `package.json`'s `exports` map is what makes the self-reference resolve to that built output rather than the source file. This means the same artifact every consumer would actually get is what runs here, not a stand-in. [`@arethetypeswrong/cli`](https://github.com/arethetypeswrong/arethetypeswrong.github.io) (wired into the `build` script via `tsdown`'s own `attw` option) checks that dual-package surface resolves correctly under Node's `node16` module resolution -- catching the class of "works in this repo, broken for a real consumer" bug that a bare `tsc` build can't see, before it ever has to matter.

Node (26+) runs every `.ts` file here directly via its own native TypeScript support -- no `tsx`/`ts-node` needed. `tsconfig.json` sets `moduleResolution: "nodenext"` to match that reality, and is scoped to `codec.ts` alone (`"include": ["codec.ts"]`) since that's the one file `tsdown` actually builds -- anything else in this scope (a script's own top-level await, a config file's own type shape) would otherwise leak into what gets type-checked as part of the *build*. Everything else (`generate.ts`, `verify.test.ts`, `tsdown.config.ts`, `eslint.config.ts`) is covered by `tsconfig.node.json` instead, which extends `tsconfig.json`. `pnpm run typecheck` runs both.

Signature and public-key bytes throughout are clearly-synthetic filler (`aa`/`bb`/`ee`/`ff`-repeated hex), not real cryptographic material -- these vectors freeze the wire-exact envelope shape (map key ordering, field presence, the recursive delegation-chain nesting), not a working signature, the same scope Cascade's own frozen vectors commit to for fields with no real crypto behind them.

## Gotcha: `cbor2` doesn't recognise a Node `Buffer` as a byte string

Feeding a plain Node `Buffer` (rather than a plain `Uint8Array`) into `cbor2`'s `encode()` silently produces the wrong output: `Buffer` overrides `toJSON()`, and `cbor2`'s type dispatch falls through to a generic-object encoder that serialises it as a garbled `{ type: "Buffer", data: [...] }` CBOR map instead of a byte string, with no error raised. Confirmed directly while writing this generator -- caught only because the verifier's round-trip check failed with an unreadable diff. `toWire()` in `codec.mjs` guards against this explicitly, converting every marker to a genuine `Uint8Array` via `Uint8Array.from(Buffer.from(hex, "hex"))` rather than passing a `Buffer` straight to `encode()`.
Feeding a plain Node `Buffer` (rather than a plain `Uint8Array`) into `cbor2`'s `encode()` silently produces the wrong output: `Buffer` overrides `toJSON()`, and `cbor2`'s type dispatch falls through to a generic-object encoder that serialises it as a garbled `{ type: "Buffer", data: [...] }` CBOR map instead of a byte string, with no error raised. Confirmed directly while writing this generator -- caught only because the verifier's round-trip check failed with an unreadable diff. `toWire()` in `codec.ts` guards against this explicitly, converting every marker to a genuine `Uint8Array` via `Uint8Array.from(Buffer.from(hex, "hex"))` rather than passing a `Buffer` straight to `encode()`.

## Gotcha: `typescript` is pinned below 6.1, not left on latest

`typescript-eslint` (which `@exadev/eslint-config` depends on) does not yet support TypeScript 7 -- confirmed directly, `eslint` fails outright with "typescript-eslint does not support TS 7.0" against the latest `typescript` release. `typescript` is pinned to `6.0.3`, the newest release still inside `typescript-eslint`'s own `>=4.8.4 <6.1.0` peer range, rather than left on latest -- this is the documented-incompatibility exception the org's own dependency convention already carves out for exactly this situation. Bump it back to latest once [typescript-eslint#10940](https://github.com/typescript-eslint/typescript-eslint/issues/10940) ships support for TS 7.
53 changes: 0 additions & 53 deletions conformance/codec.mjs

This file was deleted.

127 changes: 127 additions & 0 deletions conformance/codec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Shared JSON<->wire helpers for the conformance vector generator and its vitest suite.
//
// JSON has no byte-string type, so every CDDL `bstr` field is represented in a vector's `message` as `{ "hex": "<lowercase hex>" }` rather than a raw string or array of numbers -- this keeps `message` valid, diffable JSON while still letting the codec reconstruct exactly the bytes CBOR needs. `toWire` walks a `message` value replacing every such marker with a real byte buffer before encoding; `fromWire` walks a decoded value the other way, turning every real byte string back into the same marker shape so it can be compared against the original `message` with a plain deep-equal.

export interface HexBytes {
hex: string;
}

export type JsonWire =
| null
| boolean
| number
| string
| HexBytes
| JsonWire[]
| { [key: string]: JsonWire };

export interface Vector {
name: string;
message: JsonWire;
wire_hex: string;
}

export interface VectorFile {
protocol_version: number;
description: string;
vectors: Vector[];
}

export function hex(value: string): HexBytes {
return { hex: value.toLowerCase() };
}

function isHexBytes(value: unknown): value is HexBytes {
if (typeof value !== "object" || value === null || Array.isArray(value))
return false;
if (!("hex" in value)) return false;
if (Object.keys(value).length !== 1) return false;
return typeof value.hex === "string";
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
!(value instanceof Uint8Array) &&
!(value instanceof Map)
);
}

export function toWire(value: JsonWire): unknown {
if (isHexBytes(value)) {
// A plain Uint8Array, not a Node Buffer: cbor2's encoder dispatches on the exact constructor and doesn't recognise Buffer as a byte string, falling back to Buffer's own toJSON() and encoding it as a garbled {type, data} map instead -- confirmed directly, not a hypothetical.
return Uint8Array.from(Buffer.from(value.hex, "hex"));
}
if (Array.isArray(value)) {
return value.map(toWire);
}
if (value !== null && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = toWire(v);
return out;
}
return value;
}

export function fromWire(value: unknown): JsonWire {
if (value instanceof Uint8Array) {
return hex(Buffer.from(value).toString("hex"));
}
if (Array.isArray(value)) {
return value.map(fromWire);
}
if (value instanceof Map) {
const out: Record<string, JsonWire> = {};
for (const [k, v] of value.entries()) out[String(k)] = fromWire(v);
return out;
}
if (isPlainObject(value)) {
const out: Record<string, JsonWire> = {};
for (const [k, v] of Object.entries(value)) out[k] = fromWire(v);
return out;
}
if (
value === null ||
typeof value === "boolean" ||
typeof value === "number" ||
typeof value === "string"
) {
return value;
}
throw new Error(
`fromWire: unsupported decoded value of type ${typeof value}`,
);
}

function isJsonWire(value: unknown): value is JsonWire {
if (
value === null ||
typeof value === "boolean" ||
typeof value === "number" ||
typeof value === "string"
) {
return true;
}
if (Array.isArray(value)) return value.every(isJsonWire);
if (typeof value === "object") return Object.values(value).every(isJsonWire);
return false;
}

function isVector(value: unknown): value is Vector {
if (typeof value !== "object" || value === null) return false;
if (!("name" in value) || !("message" in value) || !("wire_hex" in value))
return false;
return (
typeof value.name === "string" &&
isJsonWire(value.message) &&
typeof value.wire_hex === "string"
);
}

export function isVectorFile(value: unknown): value is VectorFile {
if (typeof value !== "object" || value === null) return false;
if (!("vectors" in value)) return false;
return Array.isArray(value.vectors) && value.vectors.every(isVector);
}
28 changes: 28 additions & 0 deletions conformance/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { exadevConfig } from "@exadev/eslint-config";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import globals from "globals";

export default exadevConfig(
{},
{
ignores: ["dist", "coverage", "node_modules", ".turbo"],
},
{
languageOptions: {
parserOptions: {
project: ["./tsconfig.json", "./tsconfig.node.json"],
tsconfigRootDir: import.meta.dirname,
},
globals: { ...globals.node },
},
},
{
rules: {
"@typescript-eslint/consistent-type-imports": [
"error",
{ fixStyle: "inline-type-imports" },
],
},
},
eslintPluginPrettierRecommended,
);
Loading