mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
chord/next/app: startApp and startWorker with the fixed app layout, worker kinds, AppInfo and WorkerInfo; sessions (hashed tokens, rolling cookies, bearer tokens), the one-time owner link, the owner token file, device pairing; Files per host with ordered chunked uploads; export and import per address; plugin roots, installs and applyChanges; optional users, invite and ephemeral login facets. HTTP: deny by default (public routes and sockets), remoteAddress, readSignedCookie. Examples 53-55.
42 KiB
42 KiB
Changelog
[Unreleased]
Added
- Chord v2's plugins component gains the plugin model (slice 12):
createPlugins({ api: { name, versions } })checks every import of the app's versioned API (@acme/app/v3, per-kind modules such as@acme/app/v3/web): an unlisted version, another kind's module, two versions in one facet and a direct import of Chord are build errors naming the file and line. Roots may be{ path, required }: a required root's plugin failing atloadrejects it with aPluginErrorafter the load settled. Files named<name>.<kind>.<ext>(contract.web.tsx) are of that kind, refused in facets of another kind with the chain of imports named. Single-file plugins (hello.server.tsat a root's top, grouped by name, host packages only).installPlugin({ git, ref } | { npm, version }, root, ctx)installs plugins from git (a shallow clone of a branch, tag or commit; a repository of several plugins too) or npm; a package lists the plugin folders it provides in itspackage.json("chord": { "plugins": ["./notes", "plugins/*", "!plugins/draft"] }), used by discovery andinstallPlugin(without it: one plugin when the package's top has facet files, else its subfolders with apackage.json).testPlugin(dir, { kind, modules, stubs, principal }, ctx)builds a plugin as production does and loads it on an in-memory host beside stub facets, collecting its contributions to registries nobody serves and listing the contracts it waits for. Agents' guide:docs/next/plugin-authoring.md. - Chord v2 gains the app kit (slice 13,
src/next-node/app, to be exported as@earendil-works/chord/next/app):startApp<P>({ name, dataDir?, root?, listen?, api, kinds, principal, workers?, files?, install?, onError }, ctx)lays out the data directory (server/store.sqlite,workers/<kind>/<address>/,plugins/,logs/,owner-token; one server per data directory), creates the server's store and host, HTTP, one workers component per worker kind and the plugins component over the app's required root and the user's root, and loads the server's plugins with the kit's objects;startWorker({ api, kinds, busy?, onError })is a worker's entry (its directory and store, the link to the server first, its plugins). The start-data objectsAppInfoandWorkerInfoserve process values to plugins. Sessions (Sessions:issue,verify,revoke,list; tokens kept as SHA-256 hashes, compared in constant time; persisted ones end after 400 days unused and their browser cookie is renewed daily while used, ephemeral ones live in memory, at mostlimits.ephemeralSessions) authenticate a signedHttpOnly,SameSite=Strictcookie orAuthorization: Bearer; a one-time owner link per start (app.ownerLink(), the token in the URL's fragment, single use, 10 minutes), an owner token file (mode 0600, written atomically) and device pairing (POST /_chord/pair, owner approval, a poll returning the token once; bounded in all and per client address) sign the owner in. The data directory, its stores and secret files are made private (0700, 0600) at every start; the home directory, a file system's root and another user's directory are refused.Filesper host: small and chunked writes at a stored offset, ranged reads, a watchable index, caps, at mostlimits.uploadsuploads under way.app.exportAddress(address, path, ctx)andapp.importAddress(archive, address, ctx)move a worker's store and files as a tar, holding the worker's process lock meanwhile (refusing an existing address, another app's archive, every path leaving the target, and archives pastlimits.importBytesorlimits.importEntries).app.applyChanges(ctx)andapp.plugins.install(source, ctx). Optional plugins as exported facets:defineUsers(profile),inviteFacet(),ephemeralLoginFacet(). - Chord v2's
HttpRequestgainsremoteAddress, the peer's address as the listener knows it (for limits per client). - Chord v2's HTTP component gains
readSignedCookie(request, name, secret):readCookie's value with its signed expiry, for renewing a cookie before it expires.
Changed
- Chord v2's HTTP component denies by default (slice 13): a request or plugin socket without a principal gets 401 before plugin code runs unless its route or socket is marked
public: true({ handler, public: true }; acrossSiteroute is public), and the/chordlinks endpoint refuses requests without a principal. - Chord v2 plugins import each other by relative path within a root (copied into the importer's script) and across repositories by package name through the package's
exports, pinned independencies; slice 10's imports by plugin name (@app/notes/contract) are removed, and a relative path leaving the root is a build error.
[1.5.0] - 2026-11-02
Breaking Changes
- Schemas are JSON Schema checked by
@earendil-works/pi-schemainstead of TypeBox, which is no longer a dependency of Chord. Contracts,self.state(...)schemas and method signatures take pi-schema builders or any JSON Schema object (TypeBox-built schemas still validate, being JSON Schema, but type asunknown); handler and reference types come from pi-schema'sStatic. Validation, input defaults and the stripping of unknown fields work on any JSON Schema by its shape (before, defaults and stripping applied only to TypeBox-built schemas).formatis an annotation, no longer checked:str({ format: "email" })accepts"nope"(TypeBox checked the formats it knew); check a format in the handler where it matters. Aninvaliderror's message is now<path> <message>with pi-schema's paths and messages (a missing field at its own path,missing;expected string, got numberinstead ofmust be string), and itsdetailsare pi-schema'sValidationErrors ({ path, keyword, message }) instead of TypeBox's errors. Link frames, descriptions, fingerprints and identities are unchanged for schemas with the same JSON. Migration, keeping a contract's fingerprint (it is computed from the schema's JSON):Type.String/Number/Integer/Boolean/Null/Unknown/Array/Union/Optional(...)becomestr/num/int/bool/nul/unknown/arr/union/opt(...)with the same options;Type.Object(p)becomesobj(p, { open: true })(pi-schema'sobjis closed by default) andType.Object(p, { additionalProperties: false })obj(p);Type.Enum(values)becomesoneOf(values);Type.Literal(v)becomes{ type: typeof v, const: v }(pi-schema'slit(v)has notype);Type.Record(Type.String(), v)becomes{ type: "object", patternProperties: { "^.*$": v } }(pi-schema'smap(v)usesadditionalProperties);Type.Intersect(list)becomes{ allOf: list };Type.Function(params, result)andType.Void()become{ type: "function", parameters, returnType }and{ type: "void" };Type.Unsafe<T>(schema)becomesschema as Schema<T>;Type.Immutable(schema)becomes the schema (cast itsSchema<readonly T[]>type);opt(...)marks only anobjproperty;StaticandTSchemacome from pi-schema (Static,Schema). pi-schema's ownlit,mapand closedobjgive other JSON and so other fingerprints, which matters only while peers of both versions talk. traceIn(options, fn, context)takes the context last (wastraceIn(context, options, fn)), like the rest of the API.- Watch listeners take one change object before the context:
WatchHandle.start((change, context) => …)withchange: WatchChange<T>={ value, ops, retired }, andNativeWatchHandle.start(({ value }, context) => …)withNativeWatchChange<T>. A change is plain data, those fields only; the frame's context is the second argument.Watch.advance(change, context)takes the same object, and a frame delivered alone, with nothing folded into it, reaches the listener as the object the source passed. Migration:start(async (value, ops, ctx) => …)becomesstart(async ({ value, ops }, ctx) => …),start(async (value, ctx) => …)on a native watchstart(async ({ value }, ctx) => …), andadvance(value, ops, ctx)becomesadvance({ value, ops, retired: false }, ctx). SqliteExecutor.runCounted(sql, ...params)runs one statement likerunand resolves with the rows it inserted, updated or deleted, as SQLite'schanges()counts them (index writes and rows changed by triggers do not count). The Node driver takes it fromnode:sqlite; the Durable Object driver readsSELECT changes()after the statement (the cursor'srowsWrittenalso counts index writes), sorunstays as cheap as before. Migration: customSqliteDatabaseandSqliteExecutorimplementations addrunCounted.- Retirement is a flag, not an end:
WatchEndlosesretired, andWatch.retire(context)is removed. A source reports a retirement withadvance({ value, ops, retired: true }, context), whose value is what the address shows after it, and the watch goes on: the next call hasretired: true, folded with every other pending frame, so a document created at the address afterwards arrives in the same or a later call as[["r", value]].endAfterDelivery()tells a watch its source sends nothing more: it takes no more frames, delivers what is pending, and endsendedafter its last call returned, at once when nothing is pending and no call runs; it stays attached until then, so the source'send()(such as at its close),stop(), cancellation, or a throwing listener still end it first. Migration: a listener that waited forclosedto giveretiredor tested for anullvalue testschange.retired; a source that calledretire(ctx)callsadvance({ value, ops, retired: true }, ctx), thenendAfterDelivery()when its owner ends with the retirement. - Watch listeners get a tagged call, and a watch stays open through a failure its source recovers from:
WatchCall<T>is{ kind: "value", value, ops, retired }or{ kind: "failed", error }, andNativeWatchCall<T>is{ kind: "value", value }or{ kind: "failed", error }(replacingWatchChange<T>andNativeWatchChange<T>). After afailedcallwatch.valuekeeps the last value and the next value arrives as[["r", value]]; while the listener is busy, a failure drops the pending values (valuethenfailedfolds into the failure) and a value replaces a pending failure (failedthenvaluefolds into the value, asr).Watch.advance(call, context)takes aWatchCall, a value call delivered alone still reaches the listener as the object the source passed (no allocation), andWatch.fail(error, context)andNativeWatch.fail(error, context)send a failure. Migration:start(async ({ value, ops }, ctx) => …)becomesstart(async (call, ctx) => { if (call.kind === "failed") return; const { value, ops } = call; … }), a nativestart(async ({ value }, ctx) => …)likewise;advance({ value, ops, retired }, ctx)becomesadvance({ kind: "value", value, ops, retired }, ctx). WatchEndis tagged bykindinstead ofreason, and a watch whose acquisition context is aborted endsabortedinstead ofcancelled:{ kind: "stopped" | "aborted" | "ended" },{ kind: "listener_error", error }, or{ kind: "failed", error }(a final failure only).Watch.cancel()isabort(),observeCancellation(signal)isobserveAbort(signal), andend(failure?)takes the end:end({ kind: "ended" })orend({ kind: "failed", error }). Migration:end.reason === "cancelled"becomesend.kind === "aborted", every otherend.reasonbecomesend.kind;watch.end()becomeswatch.end({ kind: "ended" }),watch.end({ error })becomeswatch.end({ kind: "failed", error }).
Added
- Chord v2's store (
@earendil-works/chord/next) gainsChordStore.commitSeries(commits, receive, ctx): several commits in one backend transaction, each at its own seq with its own outbox row, the store metadata written once. Each commit's check runs on a step that sees the commits before it, before its writes; a check that throws refuses that commit and stores the ones before it, while a failed statement or write rolls the whole series back, and a failedCOMMITstops the store. It replacescommitInBatch, which is removed. Durable'sStoreStoragestores each Session commit call through it. - Chord v2 has a package export again,
@earendil-works/chord/next(experimental): every layer's API for apps (contracts, data, documents, hosts, jobs, links, state, the store), and, marked internal, the store's surface for adapters (StoreOwnerTables,OwnerTable, the documents engine's reads and writes, the commit step,applyBatch). v2's store gains one id allocator for every id in it, claims per table and per document namespace, Durable's Storage semantics for a refused commit (it rejects that commit only; an unknown outcome stops the store), document historiesnone,latestandrewindablewith reads at a point, archives carrying document revisions, and batch apply; v2 objects gainself.follow(another runtime's synchronous observers as sources) and persisted jobs (persist: { resume }, a storedabort(id, ctx), deferred state changes). - Chord v2 (
@earendil-works/chord/next) gains registries, writables, wake signals and root scopes (slice 8):defineRegistry<C, V>(name, { version })tokens shared by plugins, served by the defining plugin's facet withhost.serve(R, { compose, accepts? }); contributions from facets (defineFacet((host, self) => self.contribute(R, value))) and host-lifetime objects (StoreSelf.contribute), plain values or Chord watchables, in a table sorted by plugin name and composed lazily (once at the end ofload's setups, then when read or followed, in the batch that changed the table); readers throughself.registry(R)(a declared dependency: an object activates once the registry has a value, and failstransientwith the new reasonunavailable/undeclaredwhen nobody serves it) andhost.registry(R, ctx), aRegistryValue<V>withobserve(observer, ctx); older contributions and readers adapted byaccepts; the helperskeyedEntriesandorderedEntries; theonErrorkindsregistry_conflict,registry_failed,contribution_failedandcontribution_refused.self.writable(initial)is an in-process value an attempt sets (identity compared); native sources (writables, registry values,ref.state) are derive sources.self.retryOn(...sources)retries a failed object when a registry value changes.host.root(fn, context, { principal? })runs code outside objects with a handler'sctx, sealed whenfnsettles,contextaborts or the host closes. - Chord v2 gains plugins (slice 7c):
src/next-node/plugins(Node, to be exported as@earendil-works/chord/next/plugins) finds plugin directories, builds each facet with esbuild (now an optional peer dependency of@earendil-works/chord, imported only there), evaluates it withvm.Script, maps plugin stack traces (mapStack), installs plugin dependencies, watches plugin and base code, and rolls reloads out across hosts.host.loadandhost.reloadwork per plugin: a plugin whose setup, factory or declarations fail is left out atload({ failed }) or kept on its old code atreload(failedin the report,reload(facets, ctx, { failed })for build and evaluation failures), with a closure rule keeping a consumer old when its provider was kept old; an abort after a reload's commit rejects withdetails.committed. - Chord v2 gains HTTP and workers (slice 10):
src/next-node/http(to be exported as@earendil-works/chord/next/http) builds a Web-standardfetchhandler from the routes, wraps, sockets, hosts, relays and CSP sources plugins contribute to the registryHttp, with authenticators from the registryAuthenticators, responses as descriptions (json,html,byteswith ranges,redirect,error;fileResponse), and a security baseline on by default (provenance by the socket andHost, foreign requests anonymous,Originchecks, default headers with a per-response CSP nonce, signed cookies, safe downloads); Chord's own Node listener as a host-lifetime object,nodeRequestfor apps mounting it on their own Node server, the links endpoint and plugin sockets with a heartbeat, closed at once when their contribution leaves.wsis an optional peer dependency, imported only there.src/next-node/workers(createWorkers,workerMain) runs a forked worker per address of a host kind, started on demand, stopped when idle, never restarted by itself (a failure stays untilretryor a rollout), its plugins built by the server and shipped. The plugins component defines one definition per facet kind (kinds: { name: { platform, hostPackages } }, modules given atplugins.load(host, { kind, core, modules })), splits loading intocreatePluginRuntime()(no esbuild), offersscripts(kind), rolls out with one result per host (RolloutStepreturns{ hosts }), and resolves cross-plugin imports by plugin name (@app/notes/contract). Kernel:host.routereturns aRouteHandle(retry,suspend) and a route has no backoff timer (a failedresolvefails its holderswaiting);host.root(fn, ctx, { principal: undefined })runs anonymous; a link readsoriginonly on parent and route links (a protocol error on a child link) and drops a per-origin record once it is idle. - Chord v2, experimental and not exported yet: the next object runtime in
src/next(contracts, hosts and objects, documents, derives, links and relays, routines and jobs, the store), with its tests and runnable examples intest/nextand its design indocs/next(design.md, the decision logdecisions.md, the slice planslices.md). Its API changes with every slice until it gets an entry point. NativeWatchable<T>: an in-process value that changes and can be watched (value,watch(context)returning aNativeWatchHandle<T>), the in-process counterpart ofWatchable<T>.WatchHandle<T>andWatchEnd, the one watch API, moved from Durable: a serialized observation withvalue,start(listener),stop(), andclosed; the listener gets aWatchChange<T>({ value, ops, retired }) and the context.WatchEndreasons arestopped,cancelled,ended,listener_error, andfailed.Watch<T>, the one watch handle implementation, moved from Durable: a source creates one per watch and feeds it withadvance(change, context)(retired: truefor a retirement),end()orend({ error }),endAfterDelivery(), andobserveCancellation(signal). Frames that arrive while the listener runs (or beforestart) are delivered in one call with the newest value, their ops folded byPendingOpsand the newest frame's context; past 10,000 pending ops after folding, the call gets one root replacement[["r", newest]]instead (replaces the collapse after 100 undelivered frames); termination drops the listener and every reference into the source.Watchable<T>: a value that changes and can be watched (value,watch(context)returning aWatchHandle<T>), the common interface of documents, derives and other observable values.NativeWatchHandle<T>and its implementationNativeWatch<T>: the same lifecycle for in-process values, with listener({ value }, context)(NativeWatchChange<T>) and no ops: one call per value in order, and past 100 pending values one call with the value of the optionalreplace()or the newest value.PendingOpsandPendingTakein@earendil-works/chord/delta: the pending ops of one slow consumer, kept raw and folded while they wait (appends to one path join, a primitive set replaces earlier leaf ops on its path,rdrops everything before it);sizecounts the pending ops after folding;take()empties the queue and hands over the folded ops, orreplacepast 10,000 pending ops. Received batches and op tuples are never mutated.ownedSlice(text, start, end?)in@earendil-works/chord/delta: a slice of a string that does not keep the whole string alive (#9265)appendText(draft, key, text)in@earendil-works/chord/delta: append to a string in a draft at O(text) cost, recorded as an append op without comparing the old and new strings (a plaindraft[key] += textmakesprepare()read the whole string, O(length) per change);StringKeys<T>names the keys it accepts (#9265)
Changed
- Chord v2 (
@earendil-works/chord/next) checks its schemas with@earendil-works/pi-schemainstead of TypeBox: its helpers (str,num,int,bool,nul,lit,oneOf,union,obj,arr,map,opt,bytes,unknown) build on pi-schema's builders and keep the JSON contracts were fingerprinted with, so every fingerprint, normal form and stored record stays the same (a test records them and reads a store written before the switch).StaticandSchema(wasTSchema) come from pi-schema;opt(...)marks an object field or a table column and is no schema elsewhere (a top-levelopthad no effect before); a schema with a non-enumerable key (a TypeBox schema's metadata) is refused at declaration;map(bytes())is accepted now (it was refused whilebytes()carried a TypeBox refinement).formatis an annotation, no longer checked ({ type: "string", format: "email" }accepts"nope"; TypeBox checked the formats it knew). A failed check's message comes from pi-schema (args/title: expected string, got integer), itsdetails.pathescapes~and/(JSON Pointer); reasons and codes are unchanged. Declaring and compiling are cheaper (a 20-method contract 1.07 ms to 0.86 ms, a validator's first use 172 µs to 48 µs), a check costs the same (0.25 µs), the message envelope check up to 100 ns more per message. - The delta tracker keeps its one open change in a plain field and needs a
WeakRefonly for a second change open at the same time (one per change before). AWeakReftarget survives the scavenges of its job, so a run of changes in one job promoted every change's overlay to old space: 200,000 small changes in one job now promote 2 MB instead of 47.5 MB and take 3.1 µs each instead of 3.8 µs (3.0 instead of 3.5 µs when they span jobs). A draft dropped withoutprepare()orabort()now stays referenced by its tracker until the tracker's nextbeginChange()oradopt(). applyImmutable(),applyImmutableBatches()and tracker revisions copy each container on a changed path with spread (orObject.assign()onto a null-prototype object) instead of oneObject.defineProperty()per key, about 25 times faster per container (#9265)
Fixed
- A host a route resolves is admitted even when the relayed reference that asked for it is released, its client disconnects, or its principal is refused or revoked while the route resolves. Before, the parent never read the new host's link: a spawned worker's own references to its parent (its
lifetime: "host"objects' upstream references) waited until they failedunavailable/ready_timeout, reported as a possible reference cycle across hosts. - The Node SQLite driver keeps the 256 most recently used prepared statements per connection instead of every statement it ever prepared, so callers generating SQL of unbounded shapes (a query builder) no longer grow memory without bound.
- Fixed append ops from the delta tracker and
diffRevisions()keeping the whole string they were cut from alive (V8 represents a slice as a view of its source), so a delta chain, a watch backlog or a replica built from appends to a growing text held one copy of the text per commit (#9265)
[1.4.0] - 2026-10-21
Breaking Changes
- Custom
Contextimplementations must providetelemetry: TelemetrySpan; use the no-op spanNOOP_TELEMETRY_CONTEXTwhen disabled.withTelemetry()accepts spans, not adapters.
Added
@earendil-works/chord/telemetry-sqlite: portable SQLite telemetry recording with bounded settled-span queues, ten-second metric aggregates, periodic flushes, and retention cleanup.Context.telemetryis an explicit, non-optional telemetry parent (no-op by default).withTelemetry(telemetry, context)derives it without changing values or cancellation;traceIn(context, options, fn)starts a child span and passes its derived context to the callback.@earendil-works/chord/sqlite: portable SQLite interfaces, atomic migration runner, and schema-prefix validation, with Node (/sqlite/node) and Cloudflare Durable Object (/sqlite/cloudflare) drivers. Moved from Durable, which keeps its storage implementations and re-exports the moved public names.@earendil-works/chord/output:Progresstakes an optionalProgressClock(now,setTimer) to pace commits on, by defaultDate.nowandsetTimeout.
[1.3.0] - 2026-10-15
Breaking Changes
- Terminology follows the concepts: a reference (what
getreturns) isRef<C>(wasHandle<C>), its statusRefStatus(wasHandleStatus); what a factory returns isImplementationOf<C>(wasBehaviorOf<C>); a resource's options are aResourceDeclaration(wasResourceSpec); epochs are code versions:Ctx.codeVersion,RootOptions.codeVersion,Host.codeVersion/previousCodeVersion, and thecodeVersionfields of frames and reload reports (all wereepoch). Docs and messages say object (was record) and reference (was handle and hold); theholdandreleaseframe types keep their names - A remote object's variant is named
variant(waskind): factories return{ variant: "editor", … }, references exposeref.variantandshape.variant, and theshapeframe carriesvariant.variantis reserved as a member name;kindno longer is - Links have a protocol version (
PROTOCOL_VERSION, now 2), announced inhello. Peers of different versions do not talk: the side that notices closes the link with reasonprotocol-version <n>, and both sides report it throughLinkOptions.onProtocolMismatch. Peers without a version (older Chord) are refused serve(…, { lifetime })replaceseager:"referenced"(the default) creates an object at the firstgetof its address and disposes it after the last reference is released (pluslinger);"host"creates the type's one object when its facet loads and keeps it until the host closes or a reload removes its facet.lifetime: "host"is refused atservefor remote types, for inputs that do not accept{}and together withlinger; everygetof such a type reaches its one object, created for the host principal, and another input isinvalid/input.eager: { input }and the extra objects host-principalgets with other inputs created are gone. Only"host"objects may register (reasoninvalid/not_host_lifetime, wasnot_eager).LoadOptions.awaitHostObjectsreplacesawaitEager- Declarations are calls by name:
self.state(name, schema, initial, { version?, migrate? })returns the cell,self.resource(name, { version, deps?, exclusive?, open, close? })the resource (revis nowversion),self.on(name, source, [select,] handler)declares a listener;self.derive(sources, fn)andself.task(fn)take no name.cell(),derivation(),resource(),listener(),self.resources,self.listen,self.tasksand the declaration maps are gone, and so are the typesCellDecl,ResourceDecl,DerivationDecl,ListenerDecl. A repeated name of one kind in a run isinvalid/duplicate - State versions belong to cells:
ServeOptions.stateis gone; a cell'sversion(default 1) andmigrate(old, from)replace it. A raised version migrates only that cell; a lowered one refuses the reload - A derivation is new at every reload (computed at the commit) instead of kept with its function replaced. A listener whose state source a reload replaced subscribes again and gets the current value again: listeners on state must reconcile. Listeners on state subscribe when they activate, so they get the value current then
method(args?, result?): without arguments a method is calledref.name(ctx?)and implemented(ctx) => …; without a result it is void (callers getundefined). Descriptions record both by leavingargs/resultout (fingerprints of such methods change),callframes omitargsandresultframesvaluefor them. Coverage requires both sides to agree on arguments; a void consumer accepts a result- Remote contracts declare where their objects live, and callers no longer name a host:
remote(name, { …, host: hostNamed("server") })orhost: hostFamily("session:", "session")(the hostsession:+ the input's required string fieldsession, checked by TypeScript).hostis required on remote contracts;GetOptions.atis removed. Agetnormalizes the input, resolves the host, and acquires locally or over the link to it.serverefuses a type whose placement does not include the host (also at a reload), a host refuses a hold whose input its own contract places elsewhere (invalid/placement), and a relay refuses a hold whose target disagrees with its contract token. The placement is part of the description and fingerprint; coverage requires equal placements, andacceptsadapters do not bridge placements. Remote references are addressed by type, normalized input and principal, without a host name.PROTOCOL_VERSIONis 3 (remote tokens carryhost; a hold's input is normalized). New exports:hostNamed,hostFamily,Placement,PlacementFor,HostNamed,HostFamily FacetApihas asignal(below): code that builds aFacetApiby hand, for example to wrapserve, must pass it on- Principals are typed and supplied by the app:
createHost<P>,Host<P>,defineFacet<P>,FacetApi<P>,Serve<P>,Factory<C, P>,Self<P>,Ctx<P>,GetOptions<P>,RootOptions<P>,Route<P>,AcceptOptions<P>andPrincipalMatch<P>take the app's principal type (defaultunknown), so factories, methods, listeners and tasks narrow on it without casts. Chord invents no principals any more:createHost({ self })(required) is the principal of the host's own code, replacing the built-inhostPrincipal({ host: true }, removed);connect(link, { principal })(required) is what the parent's own requests act as, replacing{ host: <parent name> };Route.principalis required, replacing the default{ host: <host name> }.HostOptions.upstreamis{ link, principal, ...LinkOptions }(was a link, withupstreamOptions). The host's own principal is recognized byprincipalKey, not object identity. ThePrincipaltype is gone;ConnectOptionsandPrincipalPatternare new serve(type, factory, { lifetime: "host" })compiles only for a local type whose input has no required fields, and withoutlinger;servestill refuses the rest at runtime.ServeOptionsis a union ofReferencedServeOptionsandHostServeOptions, andservetakesServeOptionsFor<C>- An awaited reference has its state, local or remote.
ctx.getandhost.getare async: they resolve once the reference is live and every state member of its variant has its first snapshot, and reject with the activation error,aborted(the ctx aborted or its call settled;host.get'ssignal) orunavailable/ready_timeoutafterHostOptions.readyTimeout(default 60000 ms), releasing only that reference.host.holdis the synchronous form (possiblyloading) for UI runtimes;ref.readystays.self.getstays synchronous: an object goes live only once its references are ready and hydrated, and the state it exposes or derives from (also forwarded from a remote reference held another way) has its values; activation and reload prepare wait at mostreadyTimeout(a reference cycle across hosts fails with a hint instead of hanging) - State members are typed
T(wasT | undefined) andStateSource<T>isReadable<T>. Reading state before the first hydration throwsnot_ready, outside the current variantnot_in_variant, after the reference ended its end error. A remote reference is a union discriminated byvariant: another variant's members are reachable only after narrowing.MembersOfis gone. A listener added before a member is readable gets its snapshot then (noundefinedsnapshot first) - A remote reference stays subscribed to its variant's state while held, with or without listeners (it no longer forgets its value when the last listener leaves).
GetOptions.state: falsemakes a method-only reference: no state unless another reference to the object wants it, ready once live Shape.codeVersionand theshapeframe'scodeVersionare gonePROTOCOL_VERSIONis 4: aholdimplies the state of its variant (state: falseopts out untilsub;unsubstops it); the provider answers with units, ashapelisting the members the consumer lacks (state) followed by their snapshots;stateframes are addressed by hold and member (hold,m) withas: "snap" | "reset" | "ops" | "error"andvalue(absent:undefined);sub/unsubtake a hold;resyncasks for a member again;againmarks a re-hold after a link loss;origin(replacingclient) names the client connection a hold comes from
Added
@earendil-works/chord/output:OutputBuffer, which keeps a head or tail window of a stream at a cost proportional to each chunk,Progress, which paces commits of a changing value by their size, plusboundOutput,sanitizeOutput,characterEnd,utf8ByteLength, and theOutputLimits,BoundedOutput,OutputSlice, andOutputSkiptypes. Moved from@earendil-works/pi-durable, which now uses them from here.- Remote references to one object share one binding per host and key (type, normalized input identity, principal key, contract fingerprint): one hold, one stream per state member, calls over the same hold; every reference keeps its own status, observers and
release.HostOptions.linger({ types: { [typeName]: ms }, cap }, cap default 4) keeps a binding held and hydrated after its last reference is released, so a reference taken again meanwhile costs nothing on the link - Holds from clients carry an opaque
origin: the id the accepting host gave the client connection, forwarded by relays and honored only from the parent link, so a host can tell client connections apart; a disconnect releases that client's holds at once - Flow control by units: the first unit of a new reference passes the window, so a
getnever waits behind other references' state; units for reloads and re-holds wait, built lazily from the current values; waiting ops are capped per link (LinkOptions.pendingCap, default four windows), beyond which the oldest streams become resets (a relay asks downstream withresync) StateOptions,EventOf,ValueOfandLifetimetypesself.state(name, schema, { init }, options?): a cell whose first value comes from its source.init(ctx)(may be async) runs once the object's resources are open and its references live, before the object goes live, so no method, listener, task or subscriber sees another value; a throw or an invalid value fails the activation. A reload keeps the cell without runninginit; a cell a reload adds runs it while the reload prepares (a failure refuses the reload). Until then the cell'svaluethrowsnot_ready.StateInittype- Links are flow controlled end to end: peers acknowledge what they processed (
ackframes, announced withacksinhello), and while a window of frames is unacknowledged (windowinLinkOptions, default 256 KiB), state frames wait at the sender, coalesced per subscription, instead of queuing in front of call results. A stalled peer costs a bounded slot per subscription; a slot that outgrows it becomes a reset. Calls, results and control frames are never held back, so under load a result can arrive before state changes made before it maxCallsinAcceptOptionslimits the calls a peer may have in progress on a link; further ones are answered at once withunavailable(reasontoo_many_calls) and the link stays openFacetApi.signalaborts once that run of the facet is no longer installed: a committed reload replaced or removed it, the reload that loaded it aborted, or the host closed. While a reload prepares, the installed and the candidate run are both liveHostOptions.taskStopDeadline(default 5000 ms) bounds how long a reload waits for aborted tasks before it starts their replacements- Links deflate call arguments, results and state snapshots whose JSON exceeds
PACK_THRESHOLD(64K characters): over IPC as a base64 field, over WebSockets as a binary message. Relaying hosts forward the deflated bytes unread; the host that uses the payload inflates it.webSocketLink(socket, { compress, acceptPacked })(WebSocketLinkOptions) controls sending and accepting packed frames (servers should refuse them from untrusted clients), andinflateMessageturns a packed WebSocket message back into a text frame
Changed
Link.sendreturns the encoded frame's size as sent (deflated payloads count as sent) when the transport knows it (memory and WebSocket links); flow control counts these sizes, so links that return nothing (IPC, custom links) are not windowed- A reload starts an object's new tasks only after its aborted ones finished (at most
taskStopDeadline, then anyway, reported); before, the old and the new run of a task worked side by side - When a reload forces the retirement of the previous code version, it first aborts the roots still running on it (their
ctx.signal, reasonsuperseded) and gives them up to 1 s to stop before it closes what only that code used; a call with such a ctx issuperseded. Before, resources were closed under running roots, which were never told
Fixed
nodeLoaderregisters every facet's directory before importing any, so a facet that imports a file of a facet listed after it gets the same module copy as that facet; before, the load order could produce duplicates.- A link that is already closed when a host accepts or routes to it is no longer kept as a connection: before, a route that returned a dead link (a child process that exited before its link was wrapped) made every later call to that host name fail with
unavailableuntil restart, and leaked the connection's ping timer. - Fixed
processLock()failing withlisten EINVALon macOS and the BSDs when the temporary directory is too deep for a socket path (sun_path holds 104 bytes); the lock socket then lives in a private per-user directory,/tmp/chord2-<uid> - A child host whose hello arrived while a cluster reload ran was marked in step without taking part, so it stayed on the old code version and every call to it failed
supersededuntil it reconnected. It is now synced or reconciled when the reload ends; a child already behind is fenced at once, so its roots' calls up are admitted meanwhile. A reconciliation that is running when a reload starts likewise syncs its child only after that reload, against the committed code version - A child host retired its previous code version as soon as a cluster reload's
preparearrived, before its parent did, so the parent's old roots gotsupersededon it. The coordinator now retires before it sendsprepare, a child waits for its parent'sretire(at mostretireDeadline), and a child coming in step (or about to be reconciled) is told which code versions the parent retired before - Disposed objects stayed reachable from the host until an object with the same address was created again (every distinct guest, session or input leaked one object, its references and the code they bind)
- References kept every code version's services, and with them the factory code of every reload, reachable for as long as they lived (held references, objects of lifetime
"host"); old code generations could never be collected - Call, task and root contexts were linked to long-lived signals with
AbortSignal.any; Node keeps such dependants reachable while they have abort listeners, so a listener onctx.signalkept its closure (and its code generation) alive for the object's lifetime. Contexts now detach when they close - IPC links and the host manager's control channel wrote to a peer process that had exited without a callback, so the
EPIPENode reports for a write in the gap between the exit anddisconnect(anacksent just after a worker shut down) became an unhandlederrorevent that crashed the process. The link now ends on a failed write (reasonipc send failed: EPIPE), and control messages to a gone peer are dropped - A relayed call ran under the origin's cancel signal only, so a forced retirement of the relaying host could not cancel it before closing the old code's resources; it now follows its root's signal
- A resource kept across a reload closed with the
closeof the factory run that first opened it, and kept that run's code reachable; it now adopts the newest declaration at commit forkDriverpassed its ownCHORD2_LOCKto forked children, so a child host that forked its own children made those without a lock of their own fail withlocked- A call through a remote reference accepted a ctx from another host, or an object not made by Chord, and carried its root over the link; it now rejects it with
invalid/foreign_ctxlike a local reference (#10959)
[1.2.0] - 2026-10-12
Breaking Changes
- Replaced the facet and service runtime with a typed host runtime: contracts (
local,remote,method,opaque) are runtime values described with TypeBox schemas, objects are served by hosts (createHost,defineFacet,defineRegistry) and reached through handles, and hosts exchange JSON frames over memory, Node IPC, or WebSocket links (memoryLinkPair,ipcLink,webSocketLink,acceptWebSockets), with hot reload of plugins. Seedocs/tutorial.mdanddocs/guide.md - Removed
defineService,createFacetHost,createRemoteServiceBinding,createStaticFacetLoader,combineFacetLoaders, the remote-service endpoint, provider, error, and wire helpers, and their types. The olddefineFacet,Facet, andFacetLoaderare replaced by the new runtime's names of the same spelling - Removed the
./bundlerexport and the facet bundle loaders of./node;./nodenow exports the child host manager (createHostManager,forkDriver,childControl,processLock) and the facet module loadernodeLoader - Removed
@earendil-works/pi-client,@earendil-works/pi-protocol, and@earendil-works/pi-server, which served only the old runtime; they are no longer released
Changed
replicatedState,copyJson,isJsonValue,Context,JsonValue,JsonRepresentation,Draft, and the replicated state types keep their names on the root export;./contextand./deltaare unchanged, and./contextalso exports theContextandContextKeytypes- Depends on
typeboxinstead ofesbuild
Fixed
- Fixed appending to an array costing a walk over the whole array: the delta tracker now plans inserts and removals that keep the remaining elements in order from the changed ranges alone, and
applyImmutablebuilds an appended array in one copy instead of two. 32,000 one-item appends, one commit each, take 1.4 s instead of 11.3 s