Identity: C2a's StoredSchemaId/StoredVersion owns; main's PersistedSchemaId, PersistedIdentity and both MarkWritten are deleted. Stamp rule: ReadStore judges the stamp before the body. Flush over damage refuses (merge-r1-precedence). Mesh single-file writes route through SingleFileMessagePackBackingStore. TS: store-file.ts commits, C2a's header handling kept. Rust: C2a's readable_store_format in front of the grown decoder. Build not yet verified at this commit. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
560 lines
20 KiB
Markdown
560 lines
20 KiB
Markdown
# CultMesh TS
|
|
|
|
`cultmesh-ts` is the TypeScript projection of CultMesh contracts for local
|
|
tools and browser-adjacent runtimes. The C# `GameCult.Mesh` package owns the
|
|
native CultMesh stream implementation used by Mimir and Fensalir; this package
|
|
mirrors the typed declaration and negotiation shapes without owning the hot
|
|
frame body path.
|
|
|
|
## Local Catalogs
|
|
|
|
The TypeScript projection keeps the same local catalog ergonomics as the other
|
|
runtimes: sorted views, direct lookup, and unsubscribe-able watches.
|
|
|
|
## Shared Primitives
|
|
|
|
CultMesh primitives are shared cross-runtime developer affordances, not just
|
|
DTOs. They bundle the typed surface, metadata, routing hints, diagnostics, and
|
|
fast-path hooks that let application code feel like it is talking to native
|
|
state while CultMesh chooses the right local, shared-memory, IPC, network, or
|
|
WASM route. Runtime code can ask for a whole XY rect, then adapt it to the
|
|
current wire schema at the edge:
|
|
|
|
```ts
|
|
import { CultMesh } from "cultmesh-ts";
|
|
|
|
const viewport = CultMesh.rectFromBounds(-500, -300, 500, 300);
|
|
const request = CultMesh.viewportRequest(viewport, [3, 8]);
|
|
```
|
|
|
|
The request currently projects to the compact `{ minX, minY, maxX, maxY }`
|
|
shape used by Aetheria viewport queries, while the authored code keeps the
|
|
designer-facing concept as a `CultMeshRect`.
|
|
|
|
Typed operations follow the same rule: client code holds a method-shaped handle
|
|
and a runtime context, while CultMesh hides the route and authority metadata
|
|
from call sites that do not need to inspect it.
|
|
|
|
```ts
|
|
const move = CultMesh.operation("game.pilot.move.v1", async (request, context) => {
|
|
return submitToBestRoute(request, context);
|
|
});
|
|
|
|
await move.invoke({ direction: CultMesh.vec2(1, 0) }, CultMesh.operationContext("rts-client", {
|
|
routeHint: CultMesh.routeHint("network", "starfire Verse"),
|
|
}));
|
|
```
|
|
|
|
Typed query surfaces can be read once or watched through the same handle. A
|
|
projection recipe keeps its source metadata, route preference, and reactive
|
|
watcher when it becomes a query surface:
|
|
|
|
```ts
|
|
const objects = CultMesh.projectionRecipe(
|
|
"aetheria.zone.objects.visible",
|
|
[CultMesh.projectionSource("daemon:aetheria.frame.latest.v1")],
|
|
projectObjects,
|
|
{
|
|
routeHint: CultMesh.routeHint("shared-memory", "co-located frame slab"),
|
|
watchProjection: watchObjects,
|
|
},
|
|
).asQuerySurface();
|
|
|
|
const stop = objects.watch(
|
|
{ viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-10, -10, 10, 10)) },
|
|
"browser-starfire",
|
|
(next) => render(next),
|
|
);
|
|
```
|
|
|
|
Typed document handles use the same shape for local cache reads, network
|
|
snapshots, replacement, and client prediction. Authority-specific code plugs in
|
|
the submitter once; gameplay callers keep using the document:
|
|
|
|
```ts
|
|
const input = CultMesh.document(
|
|
"input:pilot-a:thermal",
|
|
pilotInputDocument,
|
|
async (context) => readInput(context),
|
|
{
|
|
routeHint: CultMesh.routeHint("network", "Starbridge Verse"),
|
|
watchDocument: watchInput,
|
|
submitPrediction: async (context, value) => submitPredictedInput(context, value),
|
|
},
|
|
);
|
|
|
|
await input.submitPrediction("pilot-a", predictedInput);
|
|
const stop = input.watch("pilot-a", latest => reconcileInput(latest));
|
|
```
|
|
|
|
Durable nodes hand out typed handles for the documents they register. One type
|
|
owns each schema: a node refuses a definition it did not register, and refuses
|
|
to register a second type for a schema it already holds.
|
|
|
|
```ts
|
|
const station = await CultMesh.startNode(statePath, {
|
|
documents: [stationStockDocument, daemonHealthDocument],
|
|
});
|
|
|
|
const stock = station.document(stationStockDocument, "station:starbridge:stock");
|
|
const current = await stock.latest();
|
|
|
|
await stock.authoritativeWriter().write(updatedStockFromUi);
|
|
|
|
const reactive = stock.authoritativeWriter().reactive();
|
|
await reactive.ready;
|
|
reactive.update(draft => {
|
|
draft.availableMissiles -= 4;
|
|
});
|
|
```
|
|
|
|
`node.get`, `node.getRequired` and the value `node.put` resolves to return the
|
|
object the node's cache holds, not a copy: two reads of one record return the
|
|
same object. That object belongs to the cache and must not be mutated. Mutating
|
|
it changes what later reads see without writing anything to the store. To
|
|
change a document, build a new value (or use a writer) and put it:
|
|
|
|
```ts
|
|
const held = station.getRequired(stationStockDocument, "station:starbridge:stock");
|
|
await station.put(stationStockDocument, "station:starbridge:stock", {
|
|
...held,
|
|
availableMissiles: held.availableMissiles - 4,
|
|
});
|
|
```
|
|
|
|
State pointers are the same kind of managed surface for UI and tools. They can
|
|
advertise source documents, inherit a Verse route when bound, and resolve
|
|
through that Verse without caller-side context plumbing:
|
|
|
|
```ts
|
|
const framePointer = CultMesh.statePointer(
|
|
"daemon:aetheria.frame.latest.v1",
|
|
async (context) => readFrameViaBestRoute(context),
|
|
undefined,
|
|
{
|
|
sources: [CultMesh.projectionSource("daemon:aetheria.frame.latest.v1")],
|
|
routeHint: CultMesh.routeHint("shared-memory", "co-located daemon frame"),
|
|
},
|
|
);
|
|
|
|
const frame = await CultMesh
|
|
.bindStatePointer(verse.withRoute("ipc", "Bifrost tool bridge"), framePointer)
|
|
.resolve();
|
|
|
|
const stateBinding = CultMesh.stateBinding("value", framePointer);
|
|
const stateBindingRecord = CultMesh.stateBindingRecord(stateBinding);
|
|
const restoredStateBinding = CultMesh.stateBindingFromRecord(stateBindingRecord);
|
|
```
|
|
|
|
Eve/CultUI lowerers that still receive authored state refs should use a named
|
|
state-ref resolver instead of local string utilities. Resolvers compose, carry
|
|
source and route metadata, and can be described by tools:
|
|
|
|
```ts
|
|
const daemonRefs = CultMesh.stateRefResolver(
|
|
"aetheria.daemon.refs",
|
|
(stateRef, context) => resolveDaemonStateRef(stateRef, context),
|
|
{
|
|
sources: [CultMesh.projectionSource("daemon:aetheria.frame.latest.v1")],
|
|
routeHint: CultMesh.routeHint("shared-memory", "co-located daemon state"),
|
|
},
|
|
);
|
|
|
|
const catalogRefs = CultMesh.stateRefResolver(
|
|
"aetheria.item_stats.refs",
|
|
(stateRef) => resolveItemStatRef(stateRef),
|
|
);
|
|
|
|
const resolver = daemonRefs.or(catalogRefs);
|
|
const value = resolver.resolve("aetheria:daemon:item-stat:laser.output");
|
|
const diagnostic = CultMesh.describeStateRefResolver(resolver);
|
|
```
|
|
|
|
Operation surfaces should preserve typed CultMesh identity all the way through
|
|
UI/runtime boundaries. Use invocation records and payload records at persistence
|
|
edges instead of local route parsers or string-map conventions:
|
|
|
|
```ts
|
|
const binding = CultMesh.operationBinding("gamecult.aetheria.pilot.set_target.v1", {
|
|
schemaId: "gamecult.aetheria.pilot.set_target.request.v1",
|
|
routeHint: CultMesh.routeHint("network", "co-op Verse"),
|
|
});
|
|
const bindingRecord = CultMesh.operationBindingRecord(binding);
|
|
const restoredBinding = CultMesh.operationBindingFromRecord(bindingRecord);
|
|
|
|
const invocation = CultMesh.operationInvocation(binding, {
|
|
idempotencyKey: "target:starfire:42",
|
|
});
|
|
const invocationRecord = CultMesh.operationInvocationRecord(invocation);
|
|
const payloadRecord = CultMesh.operationPayload({ targetEntityKey: "bandit-7" }).toRecord();
|
|
const restored = CultMesh.operationInvocationFromRecord(invocationRecord);
|
|
```
|
|
|
|
Route fields use the same shared primitive:
|
|
|
|
```ts
|
|
const routeRecord = CultMesh.routeRecord(CultMesh.routeHint("shared-memory", "co-located slab"));
|
|
const route = CultMesh.routeFromRecord(routeRecord);
|
|
```
|
|
|
|
When a client needs one coherent live view composed from several query surfaces,
|
|
use a live feed instead of putting a refresh loop in the UI:
|
|
|
|
```ts
|
|
const viewportFeed = CultMesh.liveFeed(
|
|
"aetheria.rts.viewport.feed",
|
|
snapshotViewport,
|
|
{
|
|
sources: objects.sources,
|
|
routeHint: objects.routeHint,
|
|
watchFeed: CultMesh.pollingQueryWatcher(snapshotViewport, { intervalMs: 50 }),
|
|
},
|
|
);
|
|
|
|
const stopFeed = viewportFeed.watch(request, "browser-starfire", render);
|
|
```
|
|
|
|
```ts
|
|
import { CultMesh } from "cultmesh-ts";
|
|
|
|
const verses = CultMesh.createVerseCatalog<{ verseId: string; label: string }>();
|
|
const stopWatching = verses.watch((verse) => {
|
|
console.log("updated", verse.verseId);
|
|
});
|
|
|
|
verses.upsert("public", { verseId: "public", label: "Public Verse" });
|
|
stopWatching();
|
|
|
|
const peers = CultMesh.createPeerCatalog();
|
|
peers.upsert({
|
|
peerId: "ts-peer",
|
|
verseId: "public",
|
|
endpoints: ["rudp://127.0.0.1:4100"],
|
|
roles: ["read-replica"],
|
|
authorityLeaseId: "lease:ts-peer",
|
|
});
|
|
|
|
const readReplicas = peers.find("public", "read-replica");
|
|
|
|
const leases = CultMesh.createAuthorityLeaseCatalog();
|
|
const stopWatchingLeases = leases.watch((lease) => {
|
|
console.log("lease changed", lease.leaseId);
|
|
});
|
|
|
|
leases.upsert({
|
|
leaseId: "lease:ts-peer",
|
|
verseId: "public",
|
|
peerId: "ts-peer",
|
|
roles: ["read-replica"],
|
|
validFrom: new Date(Date.now() - 1000),
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
const authorizedReplicas = peers.findAuthorized(
|
|
"public",
|
|
"read-replica",
|
|
leases,
|
|
);
|
|
stopWatchingLeases();
|
|
|
|
const schemas = CultMesh.createBuiltInSchemaCatalog();
|
|
const shardCatalogRequest = schemas.get("cultnet.shard_catalog_request.v0");
|
|
|
|
const shards = CultMesh.createShardCatalog();
|
|
shards.upsert({
|
|
shardId: "notes-a",
|
|
ownerRuntimeId: "ts-peer",
|
|
epoch: 1,
|
|
schemaIds: ["cultmesh.note.v0"],
|
|
keyPrefix: "note:",
|
|
});
|
|
|
|
const noteShards = shards.list({
|
|
schemaIds: ["cultmesh.note.v0"],
|
|
recordKeys: ["note:intro"],
|
|
});
|
|
```
|
|
|
|
Schema and shard catalog factories delegate to `cultnet-ts`; the branded
|
|
`CultMesh` entrypoint does not create a second owner for schema discovery or
|
|
topology truth.
|
|
|
|
## Long-Lived Providers
|
|
|
|
TypeScript provider daemons use `CultMeshProviderSession` to retain one logical
|
|
provider lifecycle across physical reconnects. The session owns registration,
|
|
lease renewal, desired typed publication replay, command dispatch, receipt
|
|
publication, and withdrawal. Provider code owns domain state and idempotent
|
|
command transactions; the injected transport owns CultNet wire details.
|
|
|
|
```ts
|
|
import {
|
|
CultMeshProviderRudpTransport,
|
|
CultMeshProviderSession,
|
|
} from "cultmesh-ts";
|
|
|
|
const odinTransport = new CultMeshProviderRudpTransport({
|
|
endpoint: "rudp://127.0.0.1:17871",
|
|
runtimeId: "voidbot-worker-7",
|
|
connectionId: 0x43554c54,
|
|
sessionToken: process.env.ODIN_PROVIDER_SESSION_TOKEN,
|
|
});
|
|
|
|
const session = new CultMeshProviderSession({
|
|
identity: {
|
|
providerId: "voidbot.swarm",
|
|
serviceInstanceId: "voidbot-worker-7",
|
|
endpointId: "odin:voidbot-worker-7",
|
|
verseId: "voidbot.local",
|
|
},
|
|
transport: odinTransport,
|
|
receiptStore: durableReceiptStore,
|
|
publications: [advertisementPublication, surfacePublication],
|
|
commandHandlers: {
|
|
"swarm.set_heat": applyHeatCommandTransaction,
|
|
},
|
|
});
|
|
|
|
await session.start();
|
|
await session.upsertPublication(nextSurfacePublication);
|
|
```
|
|
|
|
Provider, service-instance, endpoint, and body-producer identities are separate
|
|
contracts. The provider session never infers one from another. A durable
|
|
receipt store is required; `CultMeshMemoryProviderReceiptStore` is intended
|
|
only for tests and disposable tools. The store is a durable outbox: a receipt
|
|
is persisted before transport publication, remains pending across reconnects,
|
|
and is marked published only after the current connection accepts it. Receipt
|
|
store failure degrades command intake without inventing a network outage.
|
|
|
|
The RUDP provider transport carries lifecycle payloads inside the existing
|
|
`cultnet.operation_request.v0` and `cultnet.operation_response.v0` envelopes.
|
|
RUDP acknowledgements prove byte delivery only. Registration, renewal,
|
|
publication changes, receipts, and withdrawal complete only after the
|
|
provider-session broker returns a correlated application response. The broker
|
|
owns lease fencing and accepted publication membership; the provider owns its
|
|
desired documents and durable receipt outbox.
|
|
|
|
`CultMeshProviderRudpTransport` is currently a private-development transport.
|
|
Do not advertise it as a public provider boundary until the surrounding
|
|
CultNet session authenticates a claim authorizing the four-part provider
|
|
identity. Source address and successful contact are not authority.
|
|
The adapter carries `sessionToken` in a typed RUDP Connect-evidence map beside
|
|
a fresh `clientSessionId`. Packet retransmits reuse that id; an actual reconnect
|
|
mints another. The receiver must decode and validate the token as authorization
|
|
evidence for the requested provider identity.
|
|
|
|
## RUDP Helpers
|
|
|
|
Node runtimes can build the shared CultNet reliable-UDP transport from the
|
|
branded CultMesh entrypoint while the transport semantics stay owned by
|
|
`cultnet-ts`:
|
|
|
|
```ts
|
|
const server = await CultMesh.createRudpServer("ts-server", 0x1020_3040);
|
|
const endpoint = CultMesh.parseRudpEndpoint(
|
|
`rudp://127.0.0.1:${server.profile.transports[0]?.port}`,
|
|
);
|
|
|
|
const peer = {
|
|
peerId: "ts-server",
|
|
verseId: "local",
|
|
endpoints: [endpoint.uri],
|
|
roles: ["schema"],
|
|
authorityLeaseId: "lease:ts-server",
|
|
};
|
|
|
|
const peers = CultMesh.createPeerCatalog();
|
|
const leases = CultMesh.createAuthorityLeaseCatalog();
|
|
peers.upsert(peer);
|
|
leases.upsert({
|
|
leaseId: "lease:ts-server",
|
|
verseId: "local",
|
|
peerId: "ts-server",
|
|
roles: ["schema"],
|
|
validFrom: new Date(Date.now() - 1_000),
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
|
|
const client = await CultMesh.createRudpPeerForAuthorizedPeer(
|
|
"ts-client",
|
|
0x1020_3040,
|
|
peers,
|
|
leases,
|
|
"local",
|
|
"schema",
|
|
);
|
|
const schemas = CultMesh.createSchemaCatalog();
|
|
await client.syncSchemaCatalog(schemas, { kinds: ["document_payload"] });
|
|
|
|
const health = CultMesh.documentFromPeerSnapshot(
|
|
() => client,
|
|
daemonHealthDocument,
|
|
"daemon:aetheria.health.v1",
|
|
{
|
|
routeHint: CultMesh.routeHint("network", endpoint.uri),
|
|
timeoutMs: 5_000,
|
|
},
|
|
);
|
|
|
|
const latestHealth = await health.latest();
|
|
|
|
const syncedHealth = await station.syncDocumentFromPeerSnapshot(
|
|
() => client,
|
|
daemonHealthDocument,
|
|
"daemon:aetheria.health.v1",
|
|
{
|
|
timeoutMs: 5_000,
|
|
},
|
|
);
|
|
const localHealth = await station
|
|
.document(daemonHealthDocument, "daemon:aetheria.health.v1")
|
|
.latest();
|
|
```
|
|
|
|
`CultMesh.createRudpPeerForPeer(...)` remains available for already trusted
|
|
call sites, and `CultMesh.createRudpClientForPeer(...)` still returns the lower
|
|
level transport when a caller really needs to own handshaking or raw frames.
|
|
Discovery-first schema/catalog paths should prefer
|
|
`createRudpPeerForAuthorizedPeer(...)`, which composes
|
|
`CultMeshPeerCatalog.firstAuthorized(...)` with the authority lease catalog
|
|
before using the peer-card endpoint as a dial target.
|
|
|
|
`CultMesh.documentFromPeerSnapshot(...)` is the document-level path for RUDP
|
|
snapshots. It hides the snapshot request message id, response listener, raw
|
|
payload binary normalization, and MessagePack decode behind the same
|
|
`CultMeshDocumentHandle` shape as local documents. The helper prefers an exact
|
|
schema id match, else a record at the requested key under an id the
|
|
definition declares compatible; a record under any other schema id is another
|
|
type's and is never read in its place, whatever its payload parses as. The
|
|
handle takes a CultCache document definition, and its `latest()` result is read
|
|
and parsed through that definition: a payload is a `DocumentPayload`, which only
|
|
`decodeDocumentPayload(definition, payload)` reads, so no document is read
|
|
without the definition that says how its bytes are laid out.
|
|
|
|
Use `node.syncDocumentFromPeerSnapshot(...)` or
|
|
`CultMesh.syncDocumentFromPeerSnapshot(...)` when a runtime should hydrate its
|
|
local node from the remote snapshot. The helper requests the raw snapshot,
|
|
applies it through the node's document registry, and returns the requested
|
|
typed definition. The record then lives in the node: the caller can keep using
|
|
`node.document(definition, key)` and explicitly select
|
|
`authoritativeWriter()`, `predictionWriter()`, or `observe()`
|
|
without re-threading the RUDP peer.
|
|
|
|
`CultMesh.documentFromStore(...)` and `CultMesh.documentFromSingleFile(...)` take
|
|
the same definition, so
|
|
local schema publications can be read as typed reactive documents without
|
|
manually pulling records, decoding MessagePack, or parsing the payload.
|
|
When the caller wants source selection to be configuration, use
|
|
`CultMesh.documentFromPublication(...)` with a `single-file`, `store`, or
|
|
`peer-snapshot` source; it returns the same typed handle while hiding whether
|
|
sync came from local persistence or a remote CultNet snapshot.
|
|
Use `node.syncDocumentFromPublication(...)` or
|
|
`CultMesh.syncDocumentFromPublication(...)` for the same source-agnostic shape
|
|
when the publication should hydrate a local node before the caller continues
|
|
through `node.document(...)` and its explicit writer or observer.
|
|
|
|
## Streaming Mode
|
|
|
|
CultMesh streaming mode is for audio, video, tensor, and opaque byte frames that
|
|
need to move between diverse runtimes with as few copies as the platform allows.
|
|
It is not document replication with a bigger packet.
|
|
|
|
The split is deliberate:
|
|
|
|
- CultMesh owns stream identity, authority, clock metadata, negotiated body
|
|
transport, frame cursors, and health/backpressure state.
|
|
- The body lane owns frame bytes or native handles: shared memory, D3D11/D3D12
|
|
textures, DMA-BUF, IOSurface, AHardwareBuffer, CultCache page refs, or inline
|
|
bytes as a last resort.
|
|
- Producers report `unavoidableCopyCount`; zero-copy is a target and a measured
|
|
property, not a vibe in a lab coat.
|
|
|
|
The mirrored API shape is `CultMeshStreamCatalog`:
|
|
|
|
```ts
|
|
import { CultMesh } from "cultmesh-ts";
|
|
|
|
const streams = CultMesh.createStreamCatalog();
|
|
const stopWatchingStreams = streams.watch((stream) => {
|
|
console.log("stream declared", stream.streamId);
|
|
});
|
|
const stopWatchingFrames = streams.watchFrames((frame) => {
|
|
console.log("latest frame", frame.streamId, frame.sequence);
|
|
});
|
|
|
|
streams.declare({
|
|
streamId: "mimir:kiyo-pro",
|
|
verseId: "studio",
|
|
ownerPeerId: "starfire",
|
|
kind: "video",
|
|
clock: { clockDomainId: "starfire-qpc" },
|
|
video: {
|
|
width: 1920,
|
|
height: 1080,
|
|
pixelFormat: "YUY2",
|
|
framesPerSecond: 30,
|
|
},
|
|
preferredTransports: [
|
|
"shared-d3d12-texture",
|
|
"shared-memory",
|
|
"cultcache-page",
|
|
],
|
|
});
|
|
|
|
const lane = streams.negotiate("mimir:kiyo-pro", {
|
|
peerId: "fensalir",
|
|
verseId: "studio",
|
|
supportedTransports: ["shared-d3d12-texture", "cultcache-page"],
|
|
acceptedKinds: ["video"],
|
|
canImportGpuHandles: true,
|
|
});
|
|
|
|
streams.publishFrame({
|
|
streamId: lane.streamId,
|
|
sequence: 42n,
|
|
timestampNs: 1_000_000_000n,
|
|
durationNs: 33_333_334n,
|
|
transport: lane.transport,
|
|
nativeHandle: "0xfeed",
|
|
fenceHandle: "0xbeef",
|
|
fenceValue: 7n,
|
|
unavoidableCopyCount: 0,
|
|
});
|
|
|
|
stopWatchingStreams();
|
|
stopWatchingFrames();
|
|
```
|
|
|
|
Current implementation status: this package mirrors typed stream declaration,
|
|
negotiation, watch callbacks, and latest-frame cursor contracts for non-C#
|
|
clients. The native C# path owns shared-memory rings and GPU-handle integration
|
|
points. CultCache page append/readback remains the durable recording or
|
|
nonlocal fallback.
|
|
|
|
## Content Chunks over HTTP(S)
|
|
|
|
`cultmesh-ts/content` answers the C# HTTPS connector's request, `GET` or `HEAD`
|
|
`<mount>?chunkHash=<hex>&recordKey=<key>`. The mount path is the host's; the
|
|
handler reads only the query. Every chunk is hashed against its name before a
|
|
byte is sent, so a corrupt or swapped file answers 500 and never leaves the
|
|
process, and chunks are never served by path.
|
|
|
|
The directory store reads a flat `<root>/<hex>` layout. The publisher writes it
|
|
from `packContent`'s chunks (an artifact of one chunk is the file named by its
|
|
content hash). The store never writes, lists or caches. TLS and concurrency
|
|
bounds belong to the host: memory is at most two buffers of `maxChunkBytes` per
|
|
in-flight request (the store's, and the private copy that is hashed and sent, so a
|
|
host store may reuse its buffer). `onServerFault` reports every 500 (`hash-mismatch`, `too-large`,
|
|
`store-error`) for health.
|
|
|
|
```ts
|
|
import { readFileSync } from "node:fs";
|
|
import { createServer } from "node:https";
|
|
import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener } from "cultmesh-ts/content";
|
|
|
|
const handler = createContentChunkHandler(createDirectoryChunkStore("/srv/content"), {
|
|
onServerFault: fault => console.error("chunk fault", fault.kind, fault.chunkHash),
|
|
});
|
|
createServer({ key: readFileSync("key.pem"), cert: readFileSync("cert.pem") }, toNodeListener(handler))
|
|
.listen(8443, "127.0.0.1");
|
|
```
|