cultnet-ts/content: the TypeScript CDN manifest, codec, hashing, record keys and packer
One browser-safe module owns the manifest and chunk-ref shapes, their MessagePack form (positional ten-array, metadata as ordered entries, duplicate keys and unsafe integers refused, payload depth through cultcache-ts's own checkPayloadDepth), normalizeHash/parseSha256Hex, both record-key grammars (StableToken per UTF-16 unit), WebCrypto sha256Hex and packContent. cultcache-ts/document re-exports checkPayloadDepth. scripts/write-cultmesh-content-vectors.mjs writes the TypeScript manifest vectors for the C# reference to judge. Builds. Co-Authored-By below. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c21bfd03f5
commit
0ca81f52da
6 changed files with 667 additions and 1 deletions
99
contracts/cultmesh/content-vectors.ts-written.json
Normal file
99
contracts/cultmesh/content-vectors.ts-written.json
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
{
|
||||
"vectors": [
|
||||
{
|
||||
"label": "ts multi-chunk body",
|
||||
"kind": "manifest",
|
||||
"input": {
|
||||
"artifactId": "Pixel Heroes/Hüt ☃",
|
||||
"kind": "asset",
|
||||
"version": "",
|
||||
"mimeType": "application/x-cultnet-ts-vector",
|
||||
"createdAtUtc": "2026-10-19T00:00:00.0000000+00:00",
|
||||
"chunkSizeBytes": 131072,
|
||||
"tags": [
|
||||
"a",
|
||||
"b"
|
||||
],
|
||||
"metadata": [
|
||||
[
|
||||
"10",
|
||||
"x"
|
||||
],
|
||||
[
|
||||
"alpha",
|
||||
"y"
|
||||
],
|
||||
[
|
||||
"2",
|
||||
"z"
|
||||
]
|
||||
],
|
||||
"body": {
|
||||
"length": 777777,
|
||||
"multiplier": 7,
|
||||
"modulus": 256
|
||||
}
|
||||
},
|
||||
"recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:unversioned:cf8a453a36981321977355448d24df49bcefee22be6e3730b9140e87c29013f9",
|
||||
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBjZjhhNDUzYTM2OTgxMzIxOTc3MzU1NDQ4ZDI0ZGY0OWJjZWZlZTIyYmU2ZTM3MzBiOTE0MGU4N2MyOTAxM2Y5zgAL3jG/YXBwbGljYXRpb24veC1jdWx0bmV0LXRzLXZlY3RvctkhMjAyNi0xMC0xOVQwMDowMDowMC4wMDAwMDAwKzAwOjAwlpTZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDgAzgACAADZT21lc2g6Y2RuOmNodW5rOjM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDiU2UAzNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4zgACAADOAAIAANlPbWVzaDpjZG46Y2h1bms6Mzc3YzdiMGNjMjI5ZjQ3MDU2NzBkNjNkNTQ0YzI3NWI2NjNkZjZlOGM4MzFiMDk4NDZhNWM0OGNjYTA4Yjg0OJTZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAQAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4lNlAMzc3YzdiMGNjMjI5ZjQ3MDU2NzBkNjNkNTQ0YzI3NWI2NjNkZjZlOGM4MzFiMDk4NDZhNWM0OGNjYTA4Yjg0OM4ABgAAzgACAADZT21lc2g6Y2RuOmNodW5rOjM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDiU2UAzNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4zgAIAADOAAIAANlPbWVzaDpjZG46Y2h1bms6Mzc3YzdiMGNjMjI5ZjQ3MDU2NzBkNjNkNTQ0YzI3NWI2NjNkZjZlOGM4MzFiMDk4NDZhNWM0OGNjYTA4Yjg0OJTZQGEwYmI2OWY0NDg2MWViM2RiNTYwMjZhMWFiYmY5MWZmYTYyMTMyNTA4ZDVhMDZlMGQwZGRjNWNjMjZiZTMzZmPOAAoAAM4AAd4x2U9tZXNoOmNkbjpjaHVuazphMGJiNjlmNDQ4NjFlYjNkYjU2MDI2YTFhYmJmOTFmZmE2MjEzMjUwOGQ1YTA2ZTBkMGRkYzVjYzI2YmUzM2ZjkqFhoWKDojEwoXilYWxwaGGheaEyoXo="
|
||||
},
|
||||
{
|
||||
"label": "ts empty body",
|
||||
"kind": "manifest",
|
||||
"input": {
|
||||
"artifactId": "Pixel Heroes/Hüt ☃",
|
||||
"kind": "asset",
|
||||
"version": "",
|
||||
"mimeType": "application/x-cultnet-ts-vector",
|
||||
"createdAtUtc": "2026-10-19T00:00:00.0000000+00:00",
|
||||
"chunkSizeBytes": 131072,
|
||||
"tags": [
|
||||
"a",
|
||||
"b"
|
||||
],
|
||||
"metadata": [
|
||||
[
|
||||
"10",
|
||||
"x"
|
||||
],
|
||||
[
|
||||
"alpha",
|
||||
"y"
|
||||
],
|
||||
[
|
||||
"2",
|
||||
"z"
|
||||
]
|
||||
],
|
||||
"body": {
|
||||
"length": 0,
|
||||
"multiplier": 7,
|
||||
"modulus": 256
|
||||
}
|
||||
},
|
||||
"recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AL9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAA2U9tZXNoOmNkbjpjaHVuazplM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1kqFhoWKDojEwoXilYWxwaGGheaEyoXo="
|
||||
},
|
||||
{
|
||||
"label": "ts single chunk, versioned",
|
||||
"kind": "manifest",
|
||||
"input": {
|
||||
"artifactId": "Pixel Heroes/Hüt ☃",
|
||||
"kind": "asset",
|
||||
"version": "V1 Beta",
|
||||
"mimeType": "application/x-cultnet-ts-vector",
|
||||
"createdAtUtc": "2026-10-19T00:00:00.0000000+00:00",
|
||||
"chunkSizeBytes": 4194304,
|
||||
"tags": [],
|
||||
"metadata": [],
|
||||
"body": {
|
||||
"length": 1000,
|
||||
"multiplier": 7,
|
||||
"modulus": 256
|
||||
}
|
||||
},
|
||||
"recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:v1-beta:89f4ff56a25dd1db06a4ce6033603775d705fb96f30f8693733fef602a1ca532",
|
||||
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSnVjEgQmV0YdlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMs0D6L9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRlNlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMgDNA+jZT21lc2g6Y2RuOmNodW5rOjg5ZjRmZjU2YTI1ZGQxZGIwNmE0Y2U2MDMzNjAzNzc1ZDcwNWZiOTZmMzBmODY5MzczM2ZlZjYwMmExY2E1MzKQgA=="
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -105,7 +105,7 @@ export function documentPayloadBytes(payload: DocumentPayload): Uint8Array {
|
|||
return payload as unknown as Uint8Array;
|
||||
}
|
||||
|
||||
export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth";
|
||||
export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH, checkPayloadDepth } from "./payload-depth";
|
||||
|
||||
// The payload nesting bound covers the MessagePack the library itself writes: the plain default formatter and a codec. A
|
||||
// user formatter's bytes are opaque and in any format; its format and bounds are its own.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@
|
|||
"types": "./dist/cultmesh-authority.d.ts",
|
||||
"require": "./dist/cultmesh-authority.js",
|
||||
"default": "./dist/cultmesh-authority.js"
|
||||
},
|
||||
"./content": {
|
||||
"types": "./dist/content.d.ts",
|
||||
"require": "./dist/content.js",
|
||||
"default": "./dist/content.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
|
|
|||
212
packages/cultnet-ts/src/content.ts
Normal file
212
packages/cultnet-ts/src/content.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// The one TypeScript owner of the CultMesh CDN shapes: the manifest and chunk reference, their MessagePack form, hash
|
||||
// normalization, both record-key grammars, SHA-256 hashing and packing. The C# reference is
|
||||
// `src/GameCult.Mesh/CultMeshCdn.cs`; `contracts/cultmesh/content-vectors.*.json` and `cdn-record-key-vectors.json` pin
|
||||
// the bytes and the keys.
|
||||
//
|
||||
// Browser-safe by construction: `crypto.subtle`, `TextEncoder`, `Uint8Array` and the codecs this package already ships.
|
||||
// No `node:` import. A manifest names its chunks by hash and carries no address: where bytes are fetched from is the
|
||||
// caller's to resolve through CultMesh.
|
||||
|
||||
import { Decoder, encode } from "@msgpack/msgpack";
|
||||
import { checkPayloadDepth } from "@gamecult/cultcache-ts/document";
|
||||
import { isNullOrWhiteSpaceCSharp, trimCSharp } from "./cultmesh-authority";
|
||||
|
||||
export const CULTMESH_CDN_MANIFEST_SCHEMA = "gamecult.mesh.cdn_artifact_manifest.v1";
|
||||
export const DEFAULT_CHUNK_SIZE_BYTES = 4 * 1024 * 1024;
|
||||
export const EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
|
||||
export interface CultMeshCdnChunkRef {
|
||||
readonly chunkHash: string;
|
||||
readonly offset: number;
|
||||
readonly sizeBytes: number;
|
||||
readonly recordKey: string;
|
||||
}
|
||||
|
||||
// Metadata is ordered entries, never an object: a JS object reorders integer-like keys, and the entry order is wire bytes.
|
||||
export interface CultMeshCdnArtifactManifest {
|
||||
readonly artifactId: string;
|
||||
readonly kind: string;
|
||||
readonly version: string;
|
||||
readonly contentHash: string;
|
||||
readonly sizeBytes: number;
|
||||
readonly mimeType: string;
|
||||
readonly createdAtUtc: string;
|
||||
readonly chunks: readonly CultMeshCdnChunkRef[];
|
||||
readonly tags: readonly string[];
|
||||
readonly metadata: readonly (readonly [string, string])[];
|
||||
}
|
||||
|
||||
export interface CultMeshCdnPackOptions {
|
||||
readonly kind?: string;
|
||||
readonly version?: string;
|
||||
readonly mimeType?: string;
|
||||
readonly createdAtUtc?: string;
|
||||
readonly chunkSizeBytes?: number;
|
||||
readonly tags?: readonly string[];
|
||||
readonly metadata?: readonly (readonly [string, string])[];
|
||||
}
|
||||
|
||||
export interface CultMeshCdnPackedChunk {
|
||||
readonly chunkHash: string;
|
||||
readonly payload: Uint8Array;
|
||||
}
|
||||
|
||||
// ---- hashes and keys -------------------------------------------------------------------------------------------------
|
||||
|
||||
// `ToLowerInvariant`: each character on its own, so no context rule applies (a final sigma stays `σ`), and a character
|
||||
// whose lower case is not a single character (`İ`) stays as it is.
|
||||
const lowerInvariant = (text: string): string =>
|
||||
Array.from(text, character => { const lower = character.toLowerCase(); return Array.from(lower).length === 1 ? lower : character; }).join("");
|
||||
|
||||
/** `CultMeshCdn.NormalizeHash`: a case-insensitive `sha256:` prefix is stripped before the trim, and nothing checks for hex. */
|
||||
export function normalizeHash(hash: string, parameterName = "hash"): string {
|
||||
if (isNullOrWhiteSpaceCSharp(hash)) throw new Error(`Hash must be non-empty. (Parameter '${parameterName}')`);
|
||||
return lowerInvariant(trimCSharp(hash.slice(0, 7).toLowerCase() === "sha256:" ? hash.slice(7) : hash));
|
||||
}
|
||||
|
||||
/** The hash a file name or a fetch may rest on: `normalizeHash`, then exactly 64 lowercase hexadecimal digits. */
|
||||
export function parseSha256Hex(hash: string, parameterName = "hash"): string {
|
||||
const hex = normalizeHash(hash, parameterName);
|
||||
if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error(`Hash must be 64 hexadecimal digits. (Parameter '${parameterName}')`);
|
||||
return hex;
|
||||
}
|
||||
|
||||
export async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||
const subtle = globalThis.crypto?.subtle;
|
||||
if (!subtle) throw new Error("WebCrypto SHA-256 is unavailable.");
|
||||
return Array.from(new Uint8Array(await subtle.digest("SHA-256", bytes as BufferSource)), byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export const chunkRecordKey = (chunkHash: string): string => `mesh:cdn:chunk:${normalizeHash(chunkHash, "chunkHash")}`;
|
||||
|
||||
// `CultMeshCdn.StableToken`, unit by unit: a letter or decimal digit, `-`, `_` and `.` survive, anything else (a lone
|
||||
// surrogate included) becomes `-`, and a token that is empty after the trim falls back to the hash of the original.
|
||||
async function stableToken(value: string, parameterName: string): Promise<string> {
|
||||
if (isNullOrWhiteSpaceCSharp(value)) throw new Error(`Value must be non-empty. (Parameter '${parameterName}')`);
|
||||
const token = lowerInvariant(trimCSharp(value)).split("").map(unit => /^[\p{L}\p{Nd}\-_.]$/u.test(unit) ? unit : "-").join("").replace(/^-+|-+$/g, "");
|
||||
return token || sha256Hex(new TextEncoder().encode(value));
|
||||
}
|
||||
|
||||
export async function artifactRecordKey(artifactId: string, version: string, contentHash: string): Promise<string> {
|
||||
const artifact = await stableToken(artifactId, "artifactId");
|
||||
const versioned = isNullOrWhiteSpaceCSharp(version) ? "unversioned" : await stableToken(version, "version");
|
||||
return `mesh:cdn:artifact:${artifact}:${versioned}:${normalizeHash(contentHash, "contentHash")}`;
|
||||
}
|
||||
|
||||
// ---- validation ------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** `ValidateManifestShape`, in its order and with its messages, then the metadata keys a dictionary could not hold twice. */
|
||||
export function validateManifest(manifest: CultMeshCdnArtifactManifest): void {
|
||||
if (manifest.sizeBytes < 0) throw new Error("CDN artifact manifest has a negative size.");
|
||||
let expectedOffset = 0;
|
||||
for (const reference of [...manifest.chunks].sort((a, b) => a.offset - b.offset)) {
|
||||
if (reference.offset !== expectedOffset) throw new Error("CDN artifact chunks are not contiguous.");
|
||||
if (reference.sizeBytes < 0) throw new Error("CDN artifact chunk has a negative size.");
|
||||
expectedOffset += reference.sizeBytes;
|
||||
}
|
||||
if (expectedOffset !== manifest.sizeBytes) throw new Error("CDN artifact chunk sizes do not sum to the manifest size.");
|
||||
if (new Set(manifest.metadata.map(([key]) => key)).size !== manifest.metadata.length) {
|
||||
throw new Error("CDN artifact manifest metadata has a duplicate key.");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- MessagePack -----------------------------------------------------------------------------------------------------
|
||||
|
||||
const concat = (parts: readonly Uint8Array[]): Uint8Array => {
|
||||
const bytes = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
||||
parts.reduce((offset, part) => (bytes.set(part, offset), offset + part.length), 0);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const mapHeader = (count: number): Uint8Array =>
|
||||
count < 16 ? Uint8Array.of(0x80 | count)
|
||||
: count < 0x10000 ? Uint8Array.of(0xde, count >> 8, count & 0xff)
|
||||
: Uint8Array.of(0xdf, count >>> 24, (count >> 16) & 0xff, (count >> 8) & 0xff, count & 0xff);
|
||||
|
||||
/** The positional ten-array of the C# `[MessagePackObject]`: chunk references are four-arrays, metadata a map in entry order. */
|
||||
export function encodeManifest(manifest: CultMeshCdnArtifactManifest): Uint8Array {
|
||||
const { artifactId, kind, version, contentHash, sizeBytes, mimeType, createdAtUtc, chunks, tags, metadata } = manifest;
|
||||
return concat([
|
||||
Uint8Array.of(0x9a),
|
||||
...[artifactId, kind, version, contentHash, sizeBytes, mimeType, createdAtUtc].map(field => encode(field)),
|
||||
encode(chunks.map(c => [c.chunkHash, c.offset, c.sizeBytes, c.recordKey])),
|
||||
encode(tags),
|
||||
mapHeader(metadata.length),
|
||||
...metadata.flatMap(([key, value]) => [encode(key), encode(value)]),
|
||||
]);
|
||||
}
|
||||
|
||||
const wrongType = (field: string): never => { throw new Error(`CDN artifact manifest field '${field}' has the wrong type.`); };
|
||||
const text = (value: unknown, field: string): string => typeof value === "string" ? value : wrongType(field);
|
||||
const list = (value: unknown, field: string): unknown[] => Array.isArray(value) ? value : wrongType(field);
|
||||
const record = (value: unknown, field: string): Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Uint8Array) ? value as Record<string, unknown> : wrongType(field);
|
||||
const SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
// Any integer width, as the reference reads; a value a JS number cannot hold exactly is refused, never rounded.
|
||||
const integer = (value: unknown, field: string): number => {
|
||||
const number = typeof value === "bigint" ? (value >= -SAFE && value <= SAFE ? Number(value) : NaN) : value;
|
||||
return typeof number === "number" && Number.isSafeInteger(number) ? number : wrongType(field);
|
||||
};
|
||||
|
||||
/** The reference's reader: depth first, exact types, then `validateManifest`. */
|
||||
export function decodeManifest(bytes: Uint8Array): CultMeshCdnArtifactManifest {
|
||||
checkPayloadDepth(bytes, "CDN artifact manifest");
|
||||
// The decoder builds a JS object, which reorders integer-like keys; the converter sees the keys in wire order.
|
||||
const keys: string[] = [];
|
||||
const decoder = new Decoder({ useBigInt64: true, mapKeyConverter: key => { keys.push(text(key, "metadata")); return key as string; } });
|
||||
const fields = list(decoder.decode(bytes), "manifest");
|
||||
if (fields.length !== 10) wrongType("manifest");
|
||||
const map = record(fields[9], "metadata");
|
||||
const manifest: CultMeshCdnArtifactManifest = {
|
||||
artifactId: text(fields[0], "artifactId"),
|
||||
kind: text(fields[1], "kind"),
|
||||
version: text(fields[2], "version"),
|
||||
contentHash: text(fields[3], "contentHash"),
|
||||
sizeBytes: integer(fields[4], "sizeBytes"),
|
||||
mimeType: text(fields[5], "mimeType"),
|
||||
createdAtUtc: text(fields[6], "createdAtUtc"),
|
||||
chunks: list(fields[7], "chunks").map(item => {
|
||||
const chunk = list(item, "chunks");
|
||||
if (chunk.length !== 4) wrongType("chunks");
|
||||
const sizeBytes = integer(chunk[2], "chunks.sizeBytes");
|
||||
return { chunkHash: text(chunk[0], "chunks.chunkHash"), offset: integer(chunk[1], "chunks.offset"),
|
||||
sizeBytes: sizeBytes >= -(2 ** 31) && sizeBytes < 2 ** 31 ? sizeBytes : wrongType("chunks.sizeBytes"), recordKey: text(chunk[3], "chunks.recordKey") };
|
||||
}),
|
||||
tags: list(fields[8], "tags").map(tag => text(tag, "tags")),
|
||||
metadata: keys.map(key => [key, text(map[key], "metadata")] as const),
|
||||
};
|
||||
validateManifest(manifest);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
// ---- packing ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** `CultMeshCdn.PackArtifact`: content-addressed chunks and the manifest that names them. An empty body is one empty chunk. */
|
||||
export async function packContent(
|
||||
artifactId: string,
|
||||
bytes: Uint8Array,
|
||||
options: CultMeshCdnPackOptions = {},
|
||||
): Promise<{ manifest: CultMeshCdnArtifactManifest; chunks: CultMeshCdnPackedChunk[] }> {
|
||||
if (isNullOrWhiteSpaceCSharp(artifactId)) throw new Error("Artifact id must be non-empty. (Parameter 'artifactId')");
|
||||
const size = options.chunkSizeBytes ?? DEFAULT_CHUNK_SIZE_BYTES;
|
||||
if (!Number.isInteger(size) || size < 1 || size > 2 ** 31 - 1) throw new Error("ChunkSizeBytes must be between 1 and 2147483647.");
|
||||
const offsets = bytes.length === 0 ? [0] : Array.from({ length: Math.ceil(bytes.length / size) }, (_, index) => index * size);
|
||||
const chunks = await Promise.all(offsets.map(async offset => {
|
||||
const payload = bytes.slice(offset, offset + size);
|
||||
return { chunkHash: await sha256Hex(payload), payload };
|
||||
}));
|
||||
const manifest: CultMeshCdnArtifactManifest = {
|
||||
artifactId,
|
||||
kind: isNullOrWhiteSpaceCSharp(options.kind) ? "asset" : options.kind!,
|
||||
version: options.version ?? "",
|
||||
contentHash: await sha256Hex(bytes),
|
||||
sizeBytes: bytes.length,
|
||||
mimeType: isNullOrWhiteSpaceCSharp(options.mimeType) ? "application/octet-stream" : options.mimeType!,
|
||||
createdAtUtc: options.createdAtUtc ?? `${new Date().toISOString().slice(0, 23)}0000+00:00`,
|
||||
chunks: chunks.map(({ chunkHash, payload }, index) => ({ chunkHash, offset: offsets[index]!, sizeBytes: payload.length, recordKey: chunkRecordKey(chunkHash) })),
|
||||
tags: options.tags ?? [],
|
||||
metadata: options.metadata ?? [],
|
||||
};
|
||||
validateManifest(manifest);
|
||||
return { manifest, chunks };
|
||||
}
|
||||
290
packages/cultnet-ts/test/content.test.ts
Normal file
290
packages/cultnet-ts/test/content.test.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
// The shared CultMesh CDN shapes, pinned rule by rule.
|
||||
//
|
||||
// Wire bytes and record keys are judged against files the C# reference and Rust write
|
||||
// (`contracts/cultmesh/content-vectors.cs-written.json`, `.rs-written.json`, `cdn-record-key-vectors.json`); a fixture
|
||||
// authored on this side alone would only agree with itself. The TypeScript-written file is judged here from its own
|
||||
// declared inputs and by the C# reference in `CultMeshContentVectorTests`.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { build } from "esbuild";
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
import { CultCachePayloadDepthError } from "@gamecult/cultcache-ts/document";
|
||||
|
||||
import {
|
||||
EMPTY_SHA256,
|
||||
artifactRecordKey,
|
||||
chunkRecordKey,
|
||||
decodeManifest,
|
||||
encodeManifest,
|
||||
normalizeHash,
|
||||
packContent,
|
||||
parseSha256Hex,
|
||||
sha256Hex,
|
||||
validateManifest,
|
||||
type CultMeshCdnArtifactManifest,
|
||||
} from "../src/content";
|
||||
|
||||
const CONTRACTS = join(__dirname, "..", "..", "..", "..", "contracts", "cultmesh");
|
||||
const vectors = (name: string): { vectors: { label: string; kind: string; messagePackBase64: string }[] } =>
|
||||
JSON.parse(readFileSync(join(CONTRACTS, name), "utf8"));
|
||||
const manifestVectors = (name: string) => vectors(name).vectors.filter(v => v.kind === "manifest")
|
||||
.map(v => ({ label: v.label, bytes: new Uint8Array(Buffer.from(v.messagePackBase64, "base64")) }));
|
||||
const csManifest = (label: string) => manifestVectors("content-vectors.cs-written.json").find(v => v.label === label)!.bytes;
|
||||
|
||||
// The C# fixture: byte i is i % 251, 262,144-byte chunks, and the options its test passes.
|
||||
const FIXTURE_BODY = Uint8Array.from({ length: 1_315_551 }, (_, i) => i % 251);
|
||||
const FIXTURE_OPTIONS = {
|
||||
chunkSizeBytes: 262_144,
|
||||
version: "1",
|
||||
mimeType: "application/x-cultnet-vector",
|
||||
createdAtUtc: "2026-09-22T00:00:00.0000000+00:00",
|
||||
};
|
||||
const FIXTURE_HASH = "f4ae2a7c95f4d966058f75c13185aa0ab6a91fc5afad886296ef2aee755a2af1";
|
||||
const HASH = "a".repeat(64);
|
||||
|
||||
const manifestOf = (over: Partial<CultMeshCdnArtifactManifest> = {}): CultMeshCdnArtifactManifest => ({
|
||||
artifactId: "id", kind: "asset", version: "1", contentHash: HASH, sizeBytes: 4, mimeType: "text/plain", createdAtUtc: "t",
|
||||
chunks: [{ chunkHash: HASH, offset: 0, sizeBytes: 4, recordKey: `mesh:cdn:chunk:${HASH}` }], tags: ["t"], metadata: [["k", "v"]],
|
||||
...over,
|
||||
});
|
||||
// Wire bytes of a ten-array with any field replaced, metadata as a plain object (no integer-like keys).
|
||||
const wire = (over: Record<number, unknown> = {}) => {
|
||||
const m = manifestOf();
|
||||
const fields: unknown[] = [m.artifactId, m.kind, m.version, m.contentHash, m.sizeBytes, m.mimeType, m.createdAtUtc,
|
||||
m.chunks.map(c => [c.chunkHash, c.offset, c.sizeBytes, c.recordKey]), m.tags, { k: "v" }];
|
||||
for (const [index, value] of Object.entries(over)) fields[Number(index)] = value;
|
||||
return encode(fields, { useBigInt64: true, maxDepth: 1000 });
|
||||
};
|
||||
const refusal = (bytes: Uint8Array) => assert.throws(() => decodeManifest(bytes), /./);
|
||||
|
||||
test("each C# manifest vector decodes and re-encodes byte-identically", () => {
|
||||
const vs = manifestVectors("content-vectors.cs-written.json");
|
||||
assert.deepEqual(vs.map(v => v.label), ["manifest", "manifest_out_of_order", "manifest_metadata_order", "manifest_empty_body"]);
|
||||
for (const { label, bytes } of vs) assert.deepEqual(encodeManifest(decodeManifest(bytes)), bytes, label);
|
||||
});
|
||||
|
||||
test("the Rust-written manifests decode and re-encode byte-identically", () => {
|
||||
const vs = manifestVectors("content-vectors.rs-written.json");
|
||||
assert.ok(vs.length >= 4);
|
||||
for (const { label, bytes } of vs) assert.deepEqual(encodeManifest(decodeManifest(bytes)), bytes, label);
|
||||
});
|
||||
|
||||
test("packContent of the C# fixture body and options equals the cs-written manifest, and of an empty body manifest_empty_body", async () => {
|
||||
const { manifest, chunks } = await packContent("eureka/content-vector", FIXTURE_BODY, FIXTURE_OPTIONS);
|
||||
assert.deepEqual(encodeManifest(manifest), csManifest("manifest"));
|
||||
assert.equal(manifest.contentHash, FIXTURE_HASH);
|
||||
assert.equal(chunks.length, 6);
|
||||
const empty = await packContent("eureka/content-vector", new Uint8Array(0), FIXTURE_OPTIONS);
|
||||
assert.deepEqual(encodeManifest(empty.manifest), csManifest("manifest_empty_body"));
|
||||
assert.equal(empty.chunks.length, 1, "an empty artifact packs one chunk");
|
||||
assert.equal(empty.chunks[0]!.payload.length, 0);
|
||||
assert.equal(empty.chunks[0]!.chunkHash, EMPTY_SHA256);
|
||||
});
|
||||
|
||||
test("the packed chunks are copies that rejoin to the body, each named by the hash of its bytes", async () => {
|
||||
const body = Uint8Array.from({ length: 10 }, (_, i) => i);
|
||||
const { manifest, chunks } = await packContent("x", body, { chunkSizeBytes: 4 });
|
||||
body.fill(9);
|
||||
assert.deepEqual(chunks.map(c => c.payload.length), [4, 4, 2]);
|
||||
assert.deepEqual(Array.from(chunks.flatMap(c => Array.from(c.payload))), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
for (const [i, c] of chunks.entries()) {
|
||||
assert.equal(c.chunkHash, await sha256Hex(c.payload));
|
||||
assert.deepEqual(manifest.chunks[i], { chunkHash: c.chunkHash, offset: i * 4, sizeBytes: c.payload.length, recordKey: chunkRecordKey(c.chunkHash) });
|
||||
}
|
||||
const twins = await packContent("x", new Uint8Array(8), { chunkSizeBytes: 4 });
|
||||
assert.equal(twins.manifest.chunks[0]!.chunkHash, twins.manifest.chunks[1]!.chunkHash, "equal chunks share a hash");
|
||||
});
|
||||
|
||||
test("pack defaults: kind asset, mime octet-stream, empty version, tags and metadata", async () => {
|
||||
const bare = (await packContent("x", new Uint8Array(3))).manifest;
|
||||
assert.deepEqual([bare.kind, bare.mimeType, bare.version, bare.tags, bare.metadata], ["asset", "application/octet-stream", "", [], []]);
|
||||
const blank = (await packContent("x", new Uint8Array(3), { kind: " ", mimeType: "" })).manifest;
|
||||
assert.deepEqual([blank.kind, blank.mimeType], ["asset", "application/octet-stream"]);
|
||||
const given = (await packContent("x", new Uint8Array(3), { kind: "build", mimeType: "text/plain", version: "2", tags: ["a"], metadata: [["k", "v"]] })).manifest;
|
||||
assert.deepEqual([given.kind, given.mimeType, given.version, given.tags, given.metadata], ["build", "text/plain", "2", ["a"], [["k", "v"]]]);
|
||||
assert.equal(given.artifactId, "x");
|
||||
});
|
||||
|
||||
test("packContent refuses a blank artifact id, a chunk size outside 1..2147483647 and duplicate metadata keys", async () => {
|
||||
for (const id of ["", " ", "\u0085\u00a0"]) await assert.rejects(packContent(id, new Uint8Array(1)), /Artifact id must be non-empty/);
|
||||
for (const chunkSizeBytes of [0, -1, 2 ** 31, 1.5, NaN]) await assert.rejects(packContent("x", new Uint8Array(1), { chunkSizeBytes }), /ChunkSizeBytes/);
|
||||
await packContent("x", new Uint8Array(1), { chunkSizeBytes: 2 ** 31 - 1 });
|
||||
await assert.rejects(packContent("x", new Uint8Array(1), { metadata: [["a", "1"], ["a", "2"]] }), /duplicate key/);
|
||||
});
|
||||
|
||||
test("default createdAtUtc matches the C# round-trip 'O' format", async () => {
|
||||
const { manifest } = await packContent("x", new Uint8Array(1));
|
||||
assert.match(manifest.createdAtUtc, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{7}\+00:00$/);
|
||||
assert.ok(Math.abs(Date.parse(manifest.createdAtUtc.replace(/(\.\d{3})\d{4}\+00:00$/, "$1Z")) - Date.now()) < 60_000);
|
||||
});
|
||||
|
||||
test("manifest_out_of_order decodes and validates, and its chunks keep wire order", () => {
|
||||
const decoded = decodeManifest(csManifest("manifest_out_of_order"));
|
||||
assert.deepEqual(decoded.chunks.map(c => c.offset / 262_144), [3, 0, 5, 1, 4, 2]);
|
||||
});
|
||||
|
||||
test("metadata is ordered entries, immune to JS integer-key ordering", () => {
|
||||
assert.deepEqual(decodeManifest(csManifest("manifest_metadata_order")).metadata, [["zeta", "1"], ["alpha", "2"], ["mid", "3"]]);
|
||||
const metadata = [["10", "a"], ["alpha", "b"], ["2", "c"]] as const;
|
||||
const bytes = encodeManifest(manifestOf({ metadata }));
|
||||
assert.deepEqual(decodeManifest(bytes).metadata, metadata);
|
||||
assert.deepEqual(encodeManifest(decodeManifest(bytes)), bytes);
|
||||
});
|
||||
|
||||
test("a map larger than a fixmap round-trips in entry order", () => {
|
||||
for (const count of [16, 70_000]) {
|
||||
const metadata = Array.from({ length: count }, (_, i) => [`k${count - i}`, "v"] as const);
|
||||
const bytes = encodeManifest(manifestOf({ metadata }));
|
||||
assert.deepEqual(decodeManifest(bytes).metadata, metadata);
|
||||
}
|
||||
});
|
||||
|
||||
test("a manifest with a duplicated metadata key is refused, in decode and in validate", () => {
|
||||
const metadata = [["a", "1"], ["b", "x"], ["a", "2"]] as const;
|
||||
assert.throws(() => decodeManifest(encodeManifest(manifestOf({ metadata }))), /duplicate key/);
|
||||
assert.throws(() => validateManifest(manifestOf({ metadata })), /duplicate key/);
|
||||
validateManifest(manifestOf({ metadata: [["a", "1"], ["b", "1"]] }));
|
||||
});
|
||||
|
||||
test("a metadata key that is not a string, and a __proto__ key, are refused", () => {
|
||||
const withMap = (...entry: Uint8Array[]) => Uint8Array.from([...wire().slice(0, -5), 0x81, ...entry.flatMap(part => Array.from(part))]);
|
||||
decodeManifest(withMap(encode("k"), encode("v")));
|
||||
refusal(withMap(encode(5), encode("v")));
|
||||
refusal(withMap(encode(new Uint8Array(2)), encode("v")));
|
||||
refusal(withMap(encode("__proto__"), encode("v")));
|
||||
});
|
||||
|
||||
test("an integer field accepts any width and refuses what a JS number cannot hold exactly", () => {
|
||||
const empty = { 4: 0, 7: [] as unknown[] };
|
||||
// Past the integer rule, a size nothing sums to fails the sum check; a refused integer fails the type check first.
|
||||
assert.throws(() => decodeManifest(wire({ ...empty, 4: 2 ** 53 - 1 })), /do not sum/);
|
||||
assert.throws(() => decodeManifest(wire({ ...empty, 4: 2n ** 53n - 1n })), /do not sum/);
|
||||
assert.throws(() => decodeManifest(wire({ ...empty, 4: 2n ** 53n + 1n })), /wrong type/);
|
||||
assert.throws(() => decodeManifest(wire({ ...empty, 4: -(2n ** 53n) })), /wrong type/);
|
||||
assert.throws(() => decodeManifest(wire({ ...empty, 4: 0.5 })), /wrong type/);
|
||||
// A 64-bit encoding of a small value is the same value.
|
||||
const marked = wire({ 4: 0xdeadbeef, 7: [[HASH, 0, 4, "k"]] });
|
||||
const at = marked.findIndex((_, i) => marked[i] === 0xce && marked[i + 1] === 0xde && marked[i + 4] === 0xef);
|
||||
const wide = Uint8Array.from([...marked.slice(0, at), 0xcf, 0, 0, 0, 0, 0, 0, 0, 4, ...marked.slice(at + 5)]);
|
||||
assert.equal(decodeManifest(wide).sizeBytes, 4);
|
||||
});
|
||||
|
||||
test("a chunk size outside int32 is refused as the reference's int cannot hold it", () => {
|
||||
const chunk = (size: number) => decodeManifest(wire({ 4: size, 7: [[HASH, 0, size, "k"]] }));
|
||||
assert.equal(chunk(2 ** 31 - 1).chunks[0]!.sizeBytes, 2 ** 31 - 1);
|
||||
assert.throws(() => chunk(2 ** 31), /wrong type/);
|
||||
assert.throws(() => decodeManifest(wire({ 4: -(2 ** 31) - 1, 7: [[HASH, 0, -(2 ** 31) - 1, "k"]] })), /wrong type/);
|
||||
});
|
||||
|
||||
test("a payload nested 257 deep is refused before decoding and 256 is not refused for depth", () => {
|
||||
const nest = (depth: number) => Uint8Array.from([...Array<number>(depth).fill(0x91), 0xc0]);
|
||||
assert.throws(() => decodeManifest(nest(257)), CultCachePayloadDepthError);
|
||||
assert.throws(() => decodeManifest(nest(256)), (error: unknown) => error instanceof Error && !(error instanceof CultCachePayloadDepthError));
|
||||
// The nesting is inside a field of an otherwise valid manifest: the outer array is depth 1.
|
||||
const inside = (depth: number) => wire({ 8: Array.from({ length: depth }).reduce<unknown>(value => [value], null) });
|
||||
assert.throws(() => decodeManifest(inside(256)), CultCachePayloadDepthError);
|
||||
assert.throws(() => decodeManifest(inside(255)), (error: unknown) => error instanceof Error && !(error instanceof CultCachePayloadDepthError));
|
||||
});
|
||||
|
||||
test("a manifest of the wrong shape is refused naming the field and never the value", () => {
|
||||
const canary = "CANARY-7f3a91";
|
||||
for (const bytes of [
|
||||
wire({ 0: 5 }), wire({ 1: 5 }), wire({ 2: 5 }), wire({ 3: 5 }), wire({ 5: 5 }), wire({ 6: 5 }),
|
||||
wire({ 4: canary }), wire({ 7: canary }), wire({ 7: [canary] }), wire({ 7: [[HASH, 0, 4]] }), wire({ 7: [[HASH, canary, 4, "k"]] }),
|
||||
wire({ 7: [[5, 0, 4, "k"]] }), wire({ 7: [[HASH, 0, 4, 5]] }), wire({ 8: canary }), wire({ 8: [5] }), wire({ 9: canary }), wire({ 9: { k: 5 } }),
|
||||
wire({ 9: null }), wire({ 9: [] }), wire({ 0: null }), encode([canary]), encode(canary), encode(canary).slice(0, 4),
|
||||
Uint8Array.from([...wire(), 0xc0]), encode([...Array<unknown>(11).fill(canary)]), wire({ 7: [[HASH, 0, 4, "k", canary]] }),
|
||||
]) {
|
||||
assert.throws(() => decodeManifest(bytes), (error: unknown) => error instanceof Error && !error.message.includes(canary) && error.message.length > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("validateManifest refuses negative size, a gap, an overlap and a size-sum mismatch with the C# messages", () => {
|
||||
const chunk = (offset: number, sizeBytes: number) => ({ chunkHash: HASH, offset, sizeBytes, recordKey: "k" });
|
||||
const refuses = (over: Partial<CultMeshCdnArtifactManifest>, message: string) =>
|
||||
assert.throws(() => validateManifest(manifestOf(over)), new Error(message));
|
||||
refuses({ sizeBytes: -1, chunks: [] }, "CDN artifact manifest has a negative size.");
|
||||
refuses({ sizeBytes: 4, chunks: [chunk(0, 2), chunk(3, 1)] }, "CDN artifact chunks are not contiguous.");
|
||||
refuses({ sizeBytes: 4, chunks: [chunk(0, 3), chunk(2, 2)] }, "CDN artifact chunks are not contiguous.");
|
||||
refuses({ sizeBytes: 4, chunks: [chunk(1, 4)] }, "CDN artifact chunks are not contiguous.");
|
||||
refuses({ sizeBytes: 0, chunks: [chunk(0, -1)] }, "CDN artifact chunk has a negative size.");
|
||||
refuses({ sizeBytes: 5, chunks: [chunk(0, 4)] }, "CDN artifact chunk sizes do not sum to the manifest size.");
|
||||
refuses({ sizeBytes: 3, chunks: [chunk(0, 4)] }, "CDN artifact chunk sizes do not sum to the manifest size.");
|
||||
refuses({ sizeBytes: -1, chunks: [chunk(1, 1)] }, "CDN artifact manifest has a negative size.");
|
||||
validateManifest(manifestOf({ sizeBytes: 4, chunks: [chunk(2, 2), chunk(0, 2)] }));
|
||||
validateManifest(manifestOf({ sizeBytes: 0, chunks: [] }));
|
||||
});
|
||||
|
||||
test("normalizeHash: 'SHA256:ABC ' -> 'abc', ' sha256:abc' -> 'sha256:abc', blank throws", () => {
|
||||
assert.equal(normalizeHash("SHA256:ABC "), "abc");
|
||||
assert.equal(normalizeHash("sHa256:AbC"), "abc");
|
||||
assert.equal(normalizeHash(" sha256:abc"), "sha256:abc");
|
||||
assert.equal(normalizeHash("sha256:sha256:ab"), "sha256:ab");
|
||||
assert.equal(normalizeHash("sha256: "), "");
|
||||
assert.equal(normalizeHash("\u00a0\u0130X\u00a0"), "\u0130x");
|
||||
for (const blank of ["", " ", "\u0085\u2003"]) assert.throws(() => normalizeHash(blank, "chunkHash"), /Hash must be non-empty\. \(Parameter 'chunkHash'\)/);
|
||||
});
|
||||
|
||||
test("parseSha256Hex accepts 64 hex digits in any case and prefix, and refuses anything else without echoing it", () => {
|
||||
assert.equal(parseSha256Hex(`SHA256: ${"AB".repeat(32)} `), "ab".repeat(32));
|
||||
for (const bad of ["a".repeat(63), "a".repeat(65), `${"a".repeat(63)}g`, "", "sha256:", `sha256:CANARY-${"a".repeat(50)}`]) {
|
||||
assert.throws(() => parseSha256Hex(bad), (error: unknown) => error instanceof Error && !error.message.includes("CANARY"));
|
||||
}
|
||||
});
|
||||
|
||||
test("every entry in cdn-record-key-vectors.json derives to its recordKey", async () => {
|
||||
const file = JSON.parse(readFileSync(join(CONTRACTS, "cdn-record-key-vectors.json"), "utf8")) as {
|
||||
artifacts: { artifactIdUnits: number[]; versionUnits: number[]; contentHash: string; recordKey: string }[];
|
||||
chunks: { chunkHash: string; recordKey: string }[];
|
||||
};
|
||||
assert.ok(file.artifacts.length >= 14 && file.chunks.length >= 7, "the file carries the C# traps");
|
||||
const text = (units: number[]) => String.fromCharCode(...units);
|
||||
for (const a of file.artifacts) {
|
||||
assert.equal(await artifactRecordKey(text(a.artifactIdUnits), text(a.versionUnits), a.contentHash), a.recordKey, JSON.stringify(a.artifactIdUnits));
|
||||
}
|
||||
for (const c of file.chunks) assert.equal(chunkRecordKey(c.chunkHash), c.recordKey, c.chunkHash);
|
||||
assert.ok(file.artifacts.some(a => a.artifactIdUnits.includes(0xd800)), "a lone surrogate survives the file");
|
||||
});
|
||||
|
||||
test("record keys refuse a blank id, version-less artifacts are 'unversioned', and a blank hash throws", async () => {
|
||||
await assert.rejects(artifactRecordKey(" ", "1", HASH), /Value must be non-empty\. \(Parameter 'artifactId'\)/);
|
||||
await assert.rejects(artifactRecordKey("a", "1", " "), /Hash must be non-empty\. \(Parameter 'contentHash'\)/);
|
||||
assert.throws(() => chunkRecordKey(""), /\(Parameter 'chunkHash'\)/);
|
||||
assert.equal(await artifactRecordKey("a", " ", HASH), `mesh:cdn:artifact:a:unversioned:${HASH}`);
|
||||
assert.equal(chunkRecordKey(`SHA256:${HASH.toUpperCase()}`), `mesh:cdn:chunk:${HASH}`);
|
||||
});
|
||||
|
||||
test("sha256Hex of '' and of the fixture body equal the published digests", async () => {
|
||||
assert.equal(await sha256Hex(new Uint8Array(0)), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
assert.equal(await sha256Hex(FIXTURE_BODY), FIXTURE_HASH);
|
||||
assert.equal(await sha256Hex(new TextEncoder().encode("abc")), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
assert.equal(EMPTY_SHA256, await sha256Hex(new Uint8Array(0)));
|
||||
});
|
||||
|
||||
test("esbuild bundles dist/content.js with platform 'browser' without error", async () => {
|
||||
const result = await build({
|
||||
entryPoints: [join(__dirname, "..", "..", "dist", "content.js")], bundle: true, platform: "browser", format: "esm", write: false, logLevel: "silent",
|
||||
});
|
||||
assert.ok(result.outputFiles[0]!.text.includes("gamecult.mesh.cdn_artifact_manifest.v1"));
|
||||
});
|
||||
|
||||
test("the committed ts-written file re-packs from its declared inputs to the same bytes and record keys", async () => {
|
||||
type Input = { artifactId: string; kind: string; version: string; mimeType: string; createdAtUtc: string; chunkSizeBytes: number; tags: string[];
|
||||
metadata: [string, string][]; body: { length: number; multiplier: number; modulus: number } };
|
||||
const file = JSON.parse(readFileSync(join(CONTRACTS, "content-vectors.ts-written.json"), "utf8")) as
|
||||
{ vectors: { label: string; kind: string; input: Input; recordKey: string; messagePackBase64: string }[] };
|
||||
assert.ok(file.vectors.length >= 3);
|
||||
assert.ok(file.vectors.some(v => v.input.body.length === 0) && file.vectors.some(v => v.input.body.length > v.input.chunkSizeBytes));
|
||||
for (const { label, kind, input, recordKey, messagePackBase64 } of file.vectors) {
|
||||
assert.equal(kind, "manifest");
|
||||
const { body, ...options } = input;
|
||||
const { manifest } = await packContent(input.artifactId, Uint8Array.from({ length: body.length }, (_, i) => (i * body.multiplier) % body.modulus), options);
|
||||
const bytes = new Uint8Array(Buffer.from(messagePackBase64, "base64"));
|
||||
assert.deepEqual(encodeManifest(manifest), bytes, label);
|
||||
assert.deepEqual(encodeManifest(decodeManifest(bytes)), bytes, label);
|
||||
assert.equal(await artifactRecordKey(manifest.artifactId, manifest.version, manifest.contentHash), recordKey, label);
|
||||
}
|
||||
});
|
||||
60
scripts/write-cultmesh-content-vectors.mjs
Normal file
60
scripts/write-cultmesh-content-vectors.mjs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// The TypeScript -> C# direction of the CultMesh content parity check.
|
||||
//
|
||||
// `contracts/cultmesh/content-vectors.cs-written.json` and `.rs-written.json` are written by the reference and by Rust
|
||||
// and decoded by TypeScript. This script writes the mirror file, `contracts/cultmesh/content-vectors.ts-written.json`:
|
||||
// `packages/cultnet-ts/src/content.ts` packs each declared input and encodes the manifest, and the C# test
|
||||
// `CultMeshContentVectorTests.TypeScriptManifestVectorsMatchTheReference` judges the committed file with the reference
|
||||
// only (it decodes the bytes, packs the same input itself and compares, and derives the record key).
|
||||
//
|
||||
// Deliberately not the C# file's inputs: a multi-byte artifact id, no version, integer-like metadata keys that a JS object
|
||||
// would reorder, a different body and chunk size. Agreeing on a copy of the other side's inputs would prove less.
|
||||
//
|
||||
// npm run build --workspace packages/cultnet-ts
|
||||
// node scripts/write-cultmesh-content-vectors.mjs
|
||||
//
|
||||
// Nothing here is randomised: rerunning on an unchanged codec rewrites the same bytes.
|
||||
|
||||
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", "cultnet-ts", "dist", "content.js");
|
||||
const outputPath = join(repoRoot, "contracts", "cultmesh", "content-vectors.ts-written.json");
|
||||
if (!existsSync(modulePath)) {
|
||||
throw new Error(`${modulePath} is missing; run 'npm run build --workspace packages/cultnet-ts' first.`);
|
||||
}
|
||||
const { packContent, encodeManifest, artifactRecordKey } = createRequire(import.meta.url)(modulePath);
|
||||
|
||||
const common = {
|
||||
artifactId: "Pixel Heroes/H\u00fct \u2603",
|
||||
kind: "asset",
|
||||
version: "",
|
||||
mimeType: "application/x-cultnet-ts-vector",
|
||||
createdAtUtc: "2026-10-19T00:00:00.0000000+00:00",
|
||||
chunkSizeBytes: 131072,
|
||||
tags: ["a", "b"],
|
||||
metadata: [["10", "x"], ["alpha", "y"], ["2", "z"]],
|
||||
};
|
||||
const inputs = [
|
||||
["ts multi-chunk body", { ...common, body: { length: 777777, multiplier: 7, modulus: 256 } }],
|
||||
["ts empty body", { ...common, body: { length: 0, multiplier: 7, modulus: 256 } }],
|
||||
["ts single chunk, versioned", { ...common, version: "V1 Beta", chunkSizeBytes: 4194304, tags: [], metadata: [], body: { length: 1000, multiplier: 7, modulus: 256 } }],
|
||||
];
|
||||
|
||||
const vectors = [];
|
||||
for (const [label, input] of inputs) {
|
||||
const { body, ...options } = input;
|
||||
const bytes = Uint8Array.from({ length: body.length }, (_, index) => (index * body.multiplier) % body.modulus);
|
||||
const { manifest } = await packContent(input.artifactId, bytes, options);
|
||||
vectors.push({
|
||||
label,
|
||||
kind: "manifest",
|
||||
input,
|
||||
recordKey: await artifactRecordKey(manifest.artifactId, manifest.version, manifest.contentHash),
|
||||
messagePackBase64: Buffer.from(encodeManifest(manifest)).toString("base64"),
|
||||
});
|
||||
}
|
||||
writeFileSync(outputPath, `${JSON.stringify({ vectors }, null, 2)}\n`);
|
||||
console.log(`wrote ${vectors.length} vectors to ${outputPath}`);
|
||||
Loading…
Reference in a new issue