One codec, `packages/cultmesh-ts/src/realtime-wire.ts`, rule for rule what `CultMeshRealtimeWireProtocol` does: the 37-byte little-endian fixed header with magic 0x31545343, the delivery byte as the C# enum ordinal, the i64 epoch and sequence, three u16 identity lengths, the i32 payload length, the stamped header size and wire version, then UTF-8 identities and the payload. It touches no socket; QUIC stream framing stays transport-owned. The blank reading in `Validate` is `IsNullOrWhiteSpace`, whose whitespace set is not `String.prototype.trim`'s, so it borrows `isNullOrWhiteSpaceCSharp` from `cultnet-ts` rather than growing a second reading of the same rule. Byte parity is proven in both directions and in two processes, which is what the authority vectors cost us to learn. `contracts/cultmesh/realtime-frame-vectors.json` is written by the new C# test under CULTMESH_WRITE_VECTORS=1 and decoded and re-encoded here; `realtime-frame-vectors.ts-written.json` is written by `scripts/write-cultmesh-realtime-frame-vectors.mjs` with this codec, over different identities and epochs, and decoded by the same C# test with the reference implementation alone. Both files share one field shape, so each side reads both with one reader. The two cases that would put megabytes into a committed contract file, identities at exactly 65,535 UTF-8 bytes and a 64 MiB payload, are recorded as a build rule plus the digest of the whole encoding; both sides build them the same way. Three things the C# reference gets from its type system and TypeScript does not, each refused rather than left to surface as a stray runtime error: a `bigint` outside the i64 range, a delivery string the union cannot spell, and a leading U+FEFF, which `TextDecoder` deletes by default and `Encoding.UTF8.GetString` keeps. The last one is a real divergence the test caught; the decoder now sets `ignoreBOM`. U+FEFF is not whitespace to .NET, so such an identity passes the transport contract and reaches the wire. There was no C# test for this codec before; this is also its first. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
177 lines
7.9 KiB
JavaScript
177 lines
7.9 KiB
JavaScript
// The TypeScript -> C# direction of the CultMesh realtime frame parity check.
|
|
//
|
|
// `contracts/cultmesh/realtime-frame-vectors.json` is written by the C#
|
|
// reference and decoded by TypeScript. This script writes the mirror file,
|
|
// `contracts/cultmesh/realtime-frame-vectors.ts-written.json`: the TypeScript
|
|
// codec (`packages/cultmesh-ts/src/realtime-wire.ts`) encodes the frames and
|
|
// builds the malformed bytes, and the C# test
|
|
// `CultMeshRealtimeWireProtocolTests.RealtimeFrameVectorsWrittenByTypeScriptDecodeHere`
|
|
// judges the committed file with the reference implementation only.
|
|
//
|
|
// The two files share one field shape, so each side reads both with one reader:
|
|
// an identity is a string or `{ repeat, byteLength }`, a payload is base64 or
|
|
// `{ ramp }`, and `encoding` is null when the frame is too large to sit in a
|
|
// committed contract file, where the digest carries the parity instead.
|
|
//
|
|
// npm run build --workspace packages/cultmesh-ts
|
|
// node scripts/write-cultmesh-realtime-frame-vectors.mjs
|
|
//
|
|
// Unlike the authority vectors, nothing here is randomised: rerunning on an
|
|
// unchanged codec rewrites the same bytes.
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, writeFileSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const modulePath = join(repoRoot, "packages", "cultmesh-ts", "dist", "realtime-wire.js");
|
|
const outputPath = join(repoRoot, "contracts", "cultmesh", "realtime-frame-vectors.ts-written.json");
|
|
if (!existsSync(modulePath)) {
|
|
throw new Error(`${modulePath} is missing; run 'npm run build --workspace packages/cultmesh-ts' first.`);
|
|
}
|
|
const { encodeRealtimeFrame, decodeRealtimeFrame, CULTMESH_REALTIME_MAX_PAYLOAD_BYTES } =
|
|
createRequire(import.meta.url)(modulePath);
|
|
|
|
// Deliberately not the C# file's frames: different identities, different
|
|
// epochs, a different ramp length. Agreeing on a copy of the other side's
|
|
// inputs would prove less.
|
|
const WIDE_CHANNEL_UNIT = "\u00fc\u2603";
|
|
const WIDE_SCHEMA_UNIT = "\u2603\u00fc";
|
|
const WIDE_BODY_UNIT = "\u00e7\u2603";
|
|
const WIDE_IDENTITY_BYTES = 65535;
|
|
const INLINE_LIMIT = 4096;
|
|
|
|
const sha256 = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
const base64 = bytes => Buffer.from(bytes).toString("base64");
|
|
|
|
function repeat(unit, byteLength) {
|
|
const unitBytes = Buffer.byteLength(unit, "utf8");
|
|
if (byteLength % unitBytes !== 0) {
|
|
throw new Error(`'${unit}' is ${unitBytes} UTF-8 bytes and does not divide ${byteLength}.`);
|
|
}
|
|
return unit.repeat(byteLength / unitBytes);
|
|
}
|
|
|
|
function ramp(length) {
|
|
const bytes = new Uint8Array(length);
|
|
for (let index = 0; index < length; index += 1) bytes[index] = index % 251;
|
|
return bytes;
|
|
}
|
|
|
|
const frames = [
|
|
{
|
|
label: "ts reliable-ordered, small payload",
|
|
frame: frame("ts.zone-a", "cultmesh.realtime.v1", "ts-body-1", 21n, 22n, "reliable-ordered", Uint8Array.from([0, 1, 254, 255])),
|
|
},
|
|
{
|
|
label: "ts latest-only, small payload",
|
|
frame: frame("ts.zone-a", "cultmesh.realtime.v1", "ts-body-1", 23n, 24n, "latest-only", Uint8Array.from([0x7f, 0x80])),
|
|
},
|
|
{
|
|
label: "ts unreliable, small payload",
|
|
frame: frame("ts.zone-a", "cultmesh.realtime.v1", "ts-body-1", 25n, 26n, "unreliable", Uint8Array.from([0xa5])),
|
|
},
|
|
{
|
|
label: "ts empty payload",
|
|
frame: frame("ts.zone-a", "cultmesh.realtime.v1", "ts-body-1", 0n, 0n, "latest-only", new Uint8Array(0)),
|
|
},
|
|
{
|
|
label: "ts multi-byte identities",
|
|
frame: frame(
|
|
"ts.z\u00f8ne-\u2202", "cultmesh.realtime.\u00b51", "ts-b\u00f6dy-\ud83e\uddca", 27n, 28n, "unreliable",
|
|
new TextEncoder().encode("ts-payload-\u2202"),
|
|
),
|
|
},
|
|
{
|
|
label: "ts epoch and sequence at long.MaxValue",
|
|
frame: frame("ts.zone-a", "cultmesh.realtime.v1", "ts-body-1", 2n ** 63n - 1n, 2n ** 63n - 1n, "reliable-ordered", Uint8Array.from([7])),
|
|
},
|
|
{
|
|
label: "ts identities at exactly 65535 UTF-8 bytes",
|
|
frame: frame(
|
|
repeat(WIDE_CHANNEL_UNIT, WIDE_IDENTITY_BYTES), repeat(WIDE_SCHEMA_UNIT, WIDE_IDENTITY_BYTES),
|
|
repeat(WIDE_BODY_UNIT, WIDE_IDENTITY_BYTES), 29n, 30n, "latest-only", Uint8Array.from([0x33, 0x44]),
|
|
),
|
|
identityRepeats: [WIDE_CHANNEL_UNIT, WIDE_SCHEMA_UNIT, WIDE_BODY_UNIT],
|
|
},
|
|
{
|
|
label: "ts 64 MiB payload",
|
|
frame: frame("ts.zone-a", "cultmesh.realtime.v1", "ts-body-1", 31n, 32n, "unreliable", ramp(CULTMESH_REALTIME_MAX_PAYLOAD_BYTES)),
|
|
rampPayload: true,
|
|
},
|
|
];
|
|
|
|
function frame(channelId, schemaId, bodyId, producerEpoch, sequence, delivery, payload) {
|
|
return { channelId, schemaId, bodyId, producerEpoch, sequence, delivery, payload };
|
|
}
|
|
|
|
const good = encodeRealtimeFrame(frames[0].frame);
|
|
const spoil = mutate => { const copy = Uint8Array.from(good); mutate(copy); return copy; };
|
|
const malformed = [
|
|
{ label: "ts truncated header", bytes: good.subarray(0, 36) },
|
|
{ label: "ts wrong magic", bytes: spoil(bytes => { bytes[2] ^= 0xff; }) },
|
|
{ label: "ts wire version 2", bytes: spoil(bytes => { bytes[35] = 2; }) },
|
|
{ label: "ts header size 38", bytes: spoil(bytes => { bytes[31] = 38; }) },
|
|
{ label: "ts delivery 3", bytes: spoil(bytes => { bytes[4] = 3; }) },
|
|
{ label: "ts delivery 128", bytes: spoil(bytes => { bytes[4] = 128; }) },
|
|
{ label: "ts payload length -1", bytes: spoil(bytes => { bytes[30] = 0x80; }) },
|
|
{ label: "ts length sum off by one", bytes: Uint8Array.from([...good, 0]) },
|
|
];
|
|
|
|
const recorded = frames.map(entry => {
|
|
const encoded = encodeRealtimeFrame(entry.frame);
|
|
// A file the TypeScript codec cannot read back is not evidence of anything.
|
|
const roundTrip = decodeRealtimeFrame(encoded);
|
|
for (const key of ["channelId", "schemaId", "bodyId", "producerEpoch", "sequence", "delivery"]) {
|
|
if (roundTrip[key] !== entry.frame[key]) throw new Error(`${entry.label}: ${key} did not round-trip`);
|
|
}
|
|
if (Buffer.compare(Buffer.from(roundTrip.payload), Buffer.from(entry.frame.payload)) !== 0) {
|
|
throw new Error(`${entry.label}: payload did not round-trip`);
|
|
}
|
|
const identity = (value, unit) =>
|
|
(unit === undefined ? value : { repeat: unit, byteLength: WIDE_IDENTITY_BYTES });
|
|
const [channelUnit, schemaUnit, bodyUnit] = entry.identityRepeats ?? [];
|
|
return {
|
|
label: entry.label,
|
|
fields: {
|
|
channelId: identity(entry.frame.channelId, channelUnit),
|
|
schemaId: identity(entry.frame.schemaId, schemaUnit),
|
|
bodyId: identity(entry.frame.bodyId, bodyUnit),
|
|
// JSON has one number type and these do not fit it; the C# reader takes
|
|
// them as `long`, so they are written as JSON integers, not strings.
|
|
producerEpoch: entry.frame.producerEpoch,
|
|
sequence: entry.frame.sequence,
|
|
delivery: entry.frame.delivery,
|
|
payload: entry.rampPayload ? { ramp: entry.frame.payload.byteLength } : base64(entry.frame.payload),
|
|
},
|
|
encoding: encoded.byteLength > INLINE_LIMIT ? null : base64(encoded),
|
|
encodedLength: encoded.byteLength,
|
|
sha256OfEncoding: sha256(encoded),
|
|
};
|
|
});
|
|
|
|
const refusals = malformed.map(entry => {
|
|
let message = "<no refusal>";
|
|
try {
|
|
decodeRealtimeFrame(entry.bytes);
|
|
} catch (error) {
|
|
message = error.message;
|
|
}
|
|
if (message === "<no refusal>") throw new Error(`${entry.label} was not refused`);
|
|
return { label: entry.label, encoding: base64(entry.bytes), message };
|
|
});
|
|
|
|
// `JSON.stringify` refuses a bigint, and the two i64 fields must land in the
|
|
// file as JSON integers for the C# reader. They are written through a placeholder
|
|
// and spliced back, rather than quoted as strings.
|
|
const bigints = new Map();
|
|
const text = JSON.stringify({ frames: recorded, malformed: refusals }, (_key, value) => {
|
|
if (typeof value !== "bigint") return value;
|
|
const token = `@@bigint:${bigints.size}@@`;
|
|
bigints.set(token, value.toString());
|
|
return token;
|
|
}, 2);
|
|
writeFileSync(outputPath, `${[...bigints].reduce((carry, [token, digits]) => carry.replace(`"${token}"`, digits), text)}\n`);
|
|
console.log(`wrote ${outputPath}`);
|