feat(chord): hostAddress instead of placement, required on remote types; only hosts with an address serve remote types

The contract field placement is hostAddress, required on every remote() (type and declaration error); the
fallback for remote types without one is gone. invalid/placement is invalid/host_address. host/placement.ts
(Placement) is host/destination.ts (Destination); here is decided by address alone, and a host without an
address refuses to serve a remote type. A hold always carries its address on the wire (the binary codec's
ADDRESS flag is gone, the text codec requires it). Tests and examples declare their host addresses.
This commit is contained in:
Mario Zechner 2026-10-29 07:37:37 +00:00
parent d0687bce85
commit 5ec2418820
65 changed files with 801 additions and 306 deletions

View file

@ -12,7 +12,7 @@ const Note = obj({ text: str() });
export const NotesV1 = remote("notes", {
version: 1,
params: { session: str() },
placement: ({ session }) => Session({ id: session }), // where an object lives; not in the fingerprint
hostAddress: ({ session }) => Session({ id: session }), // where an object lives; not in the fingerprint
variants: {
editor: { state: { notes: arr(Note) }, methods: { add: method(Note, str()), clear: method() } },
reader: { state: { notes: arr(Note) } },
@ -22,17 +22,17 @@ export const Notes = evolve(NotesV1, (e) => e.args("add", obj({ text: str(), tag
type NotesProvider = Implementation<typeof Notes>; // { variant: "editor"; members: { notes, add(args, context), clear(context) } } | { variant: "reader"; ... }
```
- `remote(name, { version, params, placement?, variants })`: per-principal variants, each with `state` (schemas) and `methods`
- `remote(name, { version, params, hostAddress, variants })`: per-principal variants, each with `state` (schemas) and `methods`
(`method(args?, result?)`; an omitted side is `null`, and a method whose arguments are `null` omits them:
`clear(context)`). `local(name, { version, params, members })`: one flat map,
where a schema is state and `method(...)` a method.
- `placement(params) => Address` (remote only, optional): the address of the host that serves the object for these
- `hostAddress(params) => Address` (remote only, required; a declaration error without it): the address of the host that serves the object for these
params (`host/`'s routing table). The caller computes it and sends it with its hold, so a relay routes by the address
alone. It is not part of the normal form or the fingerprint and `evolve` carries it.
- `hostKind(name, params?)`: a kind of addressable host (`Server = hostKind("server")`, `Session = hostKind("session",
{ id: str() })`); `params` follow the params policy below. Calling it makes an `Address { kind, params, key }`, its
key built like an identity key (`session|S1`); two addresses are equal when their keys are. The params are checked
where the address is routed or served (`invalid/placement`), not when it is made. No host-name strings in app code.
where the address is routed or served (`invalid/host_address`), not when it is made. No host-name strings in app code.
- Schema helpers: `str`, `num`, `int`, `bool`, `nul`, `lit`, `oneOf`, `union`, `obj`, `arr`, `map`, `opt`, `bytes`,
`unknown`, and `Static`. They return ordinary TypeBox schemas.
- `evolve(previous, (e) => e...)`: the next version from structured edits (`state`, `addState`, `removeState`, `args`,
@ -65,7 +65,7 @@ Rules:
- `checkAccepts(contract, accepts)`: validates an accepts table without calling a mapping; returns the accepted
versions (version and fingerprint).
- `contractData(contract)`: the Chord-owned data behind a handle (its `placement`, its versions with their
- `contractData(contract)`: the Chord-owned data behind a handle (its `hostAddress`, its versions with their
`VariantData`, `MethodData` and schema snapshots, its `ParamsData`); a local contract's members are the variant
`LOCAL`. Type-level helpers: `ShapeOf`, `CurrentVariants`, `ContractShape`, `Params` and `Method`.
- `currentVariants(contract)`: the current version's variants (a local contract has the one variant `LOCAL`);

View file

@ -97,8 +97,8 @@ export type ContractData = {
readonly kind: Kind;
readonly name: string;
readonly params: ParamsData;
/** A remote contract's placement: the address of the host serving an object for its params. */
readonly placement: Placement | undefined;
/** A remote contract's host address: the address of the host serving an object for its params; none for local. */
readonly hostAddress: HostAddress | undefined;
/** Every version of the history, oldest first; the last is current. */
readonly versions: readonly VersionData[];
/** The kind of every member name ever declared in the history. */
@ -107,8 +107,8 @@ export type ContractData = {
export type MemberKind = "state" | "method" | "keyed";
/** A placement as Chord calls it: with checked params. Plugin code: what it returns is checked where it is routed. */
export type Placement = (params: ParamsOf<Contract>) => Address;
/** A `hostAddress` as Chord calls it: with checked params. Plugin code: what it returns is checked where routed. */
export type HostAddress = (params: ParamsOf<Contract>) => Address;
export type VersionData = {
readonly version: number;

View file

@ -4,11 +4,11 @@
import {
type Contract,
createContract,
type HostAddress,
type Kind,
LOCAL,
type MemberKind,
type MethodData,
type Placement,
type VariantData,
} from "./contract.ts";
import { materialize } from "./fingerprint.ts";
@ -79,7 +79,7 @@ export function remote<
readonly version: V;
readonly params: P & NoInfer<RequiredFields<P>>;
/** The address of the host serving an object for these params; never part of the fingerprint. */
readonly placement?: (params: { [F in keyof P]: Static<P[F]> }) => Address;
readonly hostAddress: (params: { [F in keyof P]: Static<P[F]> }) => Address;
readonly variants: D & NoInfer<SameMembers<D>>;
},
): Contract<{
@ -90,16 +90,14 @@ export function remote<
}> {
// Declaration policy: JavaScript callers reach every check below.
checkName(name, "A contract name");
checkKeys(declaration, ["version", "params", "placement", "variants"], name);
checkKeys(declaration, ["version", "params", "hostAddress", "variants"], name);
const variants = entriesOf(declaration.variants, `${name}.variants`);
if (variants.length === 0) throw new Error(`${name}.variants: at least one variant is required`);
const { placement } = declaration;
if (placement !== undefined && typeof placement !== "function") {
throw new Error(`${name}.placement: expected a function of the params`);
}
const { hostAddress } = declaration;
if (typeof hostAddress !== "function") throw new Error(`${name}.hostAddress: expected a function of the params`);
const read = variants.map(([variant, members]) => [variant, readVariant(members, `${name}.${variant}`)] as const);
// The placement gets the checked params of this contract.
return declare("remote", name, declaration, new Map(read), placement as Placement | undefined);
// The host address gets the checked params of this contract.
return declare("remote", name, declaration, new Map(read), hostAddress as HostAddress);
}
/**
@ -165,7 +163,7 @@ function declare<C extends Contract>(
name: string,
declaration: { readonly version: number; readonly params: unknown },
variants: ReadonlyMap<string, VariantData>,
placement?: Placement,
hostAddress?: HostAddress,
): C {
const { version } = declaration;
if (!Number.isSafeInteger(version) || version < 1) throw new Error(`${name}.version: expected a positive integer`);
@ -173,7 +171,7 @@ function declare<C extends Contract>(
checkMembers(variants, kinds, (variant, member, memberKind) => memberPath(name, kind, variant, member, memberKind));
const params = readParams(entriesOf(declaration.params, `${name}.params`), `${name}.params`);
const versions = [materialize(kind, params, version, variants, [])];
return createContract({ kind, name, params, placement, versions, kinds });
return createContract({ kind, name, params, hostAddress, versions, kinds });
}
/**

View file

@ -1,5 +1,5 @@
// Host kinds and addresses (§3): a kind of addressable host with params like a contract's, and the address of one
// host of a kind, which a remote contract's placement computes from its params.
// host of a kind, which a remote contract's `hostAddress` computes from its params.
import { checkName } from "./names.ts";
import { joinKey, type Params, type ParamsData, type RequiredFields, readParams } from "./params.ts";

View file

@ -33,7 +33,7 @@ try {
- `checkParams(contract, raw, context)`: one compiled closed-object check of a get's params and their key;
`identityKey(contract, key, principalKey)` (no principal key for `local`); `checkAddress(kind, params, raw,
context)`: the same check of an address's params against its host kind's (`invalid/placement`), and its key.
context)`: the same check of an address's params against its host kind's (`invalid/host_address`), and its key.
- `bindProvider(contract, accepts)` once per registration, then `checkArgs(provider, method, raw, boundVersion,
context)` per call. It returns `{ kind: "method", args, result? }` (current-shape arguments, and the result mapping
back to the caller's version) or `{ kind: "call", args, call }` (old arguments for the provider's replacement).
@ -51,7 +51,7 @@ Rules:
them. Results and state are never checked. Nothing in L1 is frozen.
- A method without arguments (argument schema `null`) omits the argument in references and implementations; `null`
is its internal and wire form, which `checkArgs` checks. Unknown fields fail.
- A failed check is `invalid/args`, `invalid/params` or `invalid/placement` with `details.path`, a JSON Pointer from TypeBox's first error.
- A failed check is `invalid/args`, `invalid/params` or `invalid/host_address` with `details.path`, a JSON Pointer from TypeBox's first error.
TypeBox does not escape `~` and `/`, so keys containing them make the path ambiguous.
- `checkArgs` refuses an unknown method with `invalid/member` and an unaccepted version or a refused method with
`contract_mismatch`. It never invokes a `call` replacement.

View file

@ -8,7 +8,7 @@ export const PROTOCOL_VERSION = 1;
/** Every code with the reasons it may carry. `invalid` requires a reason; every other code may omit it. */
export const CHORD_ERROR_REASONS = {
invalid: ["args", "params", "member", "placement", "reentrant", "read_after_bulk", "cycle"],
invalid: ["args", "params", "member", "host_address", "reentrant", "read_after_bulk", "cycle"],
contract_mismatch: ["updating"],
forbidden: ["revoked"],
gone: [],

View file

@ -32,11 +32,11 @@ export function identityKey<C extends Contract>(
}
/**
* An address from a kind's name and params as a hold names it or a placement returned it: the params checked against
* the kind's with one compiled closed-object check (`invalid/placement` on failure), the key computed.
* An address from a kind's name and params as a hold names it or a `hostAddress` returned it: the params checked
* against the kind's with one compiled closed-object check (`invalid/host_address` on failure), the key computed.
*/
export function checkAddress(kind: string, params: ParamsData, raw: unknown, context: Context): Address {
check(validatorFor(params, context), raw, "placement", context);
check(validatorFor(params, context), raw, "host_address", context);
// The check above establishes a flat record of the kind's scalar fields.
const values = raw as { readonly [field: string]: string | number | boolean };
return { kind, params: values, key: addressKey(kind, params, values) };

View file

@ -34,7 +34,7 @@ const validators = new WeakMap<SchemaOwner, Validator>();
export function check(
validator: Validator,
value: unknown,
reason: "args" | "params" | "placement",
reason: "args" | "params" | "host_address",
context: Context,
): void {
context.telemetry.metrics?.counter("chord.data.validations").add(1);
@ -48,7 +48,7 @@ export function check(
function failedCheck(
schema: TSchema,
value: unknown,
reason: "args" | "params" | "placement",
reason: "args" | "params" | "host_address",
context: Context,
): ChordError {
context.telemetry.metrics?.counter("chord.data.walks").add(1);

View file

@ -79,7 +79,7 @@ Addressable hosts and routes (slice 4a): a server routes holds of worker types t
export const Server = hostKind("server");
export const Session = hostKind("session", { id: str() });
export const Notes = remote("notes", { version: 1, params: { session: str() },
placement: ({ session }) => Session({ id: session }), variants: { … } });
hostAddress: ({ session }) => Session({ id: session }), variants: { … } });
const server = createHost<P>({ address: Server(), principal: { kind: "server" }, onError });
server.route(Session, { // before load, one route per kind
@ -125,7 +125,8 @@ server.accept(webSocketLink(ws), { principal, limits: { part: 16 * MiB, decoded:
- `createHost<P, R>({ address?, principal?, principalKey?, telemetry?, onError, closeTimeout?, readyTimeout?,
heartbeat?, revokedBy?, compression? })`; `R` is the app's revocation type (JSON). `address` (`Session({ id: "S1" })`, see `contracts/`'s `hostKind`) makes the host addressable: it serves only
objects placed there; without one it is a client. A host with a `principal` acts as it for its own requests; a host
without one (a client) has none and serves only local types (`serve` of a remote type throws at `load`).
without one (a client) has none. A host serves remote types only with both a principal and an address (`serve` of a
remote type throws at `load` otherwise).
`principalKey` maps a principal
to its part of an identity (L1's canonical JSON by default; the same function on every host). `closeTimeout` (30 s)
bounds each native's close and the drain of an ending object's calls; `readyTimeout` (60 s) bounds every activation
@ -166,7 +167,7 @@ server.accept(webSocketLink(ws), { principal, limits: { part: 16 * MiB, decoded:
(values, ops, results, arguments; never params, never arguments sent up its parent link) and inflates deflated parts
it owns in place. Child links refuse compressed parts (a protocol error: clients never send them, and a small part
could inflate to 64 MiB). Without one (a browser) it sends uncompressed and inflates asynchronously
(`DecompressionStream`), only parts it owns once their id and placement are known and their wire size passed,
(`DecompressionStream`), only parts it owns once their id and destination are known and their wire size passed,
within the smaller of its link's decoded limit and a hold's stamped one, holding later messages of the link in
order; acks go out after a message was applied, so a slow inflate is backpressure like a slow consumer.
- **Cycles across hosts (Q5):** every object instance gets an instance id (uuidv7) at its first attempt; a new object
@ -235,17 +236,17 @@ server.accept(webSocketLink(ws), { principal, limits: { part: 16 * MiB, decoded:
the object activates or retries, while there is no parent link or it is down; `failed` while the object is failed
and no attempt runs; `live`. Throws at once for invalid params (`invalid/params`) and a local type not served here
(`unavailable`); before `load` resolved
these are decided once it did, and the reference fails `broken` with that error. A placement or `authorize` that
throws or refuses, or a placement whose params fail its kind's check (`invalid/placement`), fails the reference
these are decided once it did, and the reference fails `broken` with that error. A `hostAddress` or `authorize` that
throws or refuses, or a host address whose params fail its kind's check (`invalid/host_address`), fails the reference
`broken`. A local type never crosses a link. Where a reference made here (`host.get`/`hold`, `self.get`, `ctx.get`)
or a hold arriving on a link goes, first match (`Placement`; the address is the contract's `placement` of the params,
or a hold arriving on a link goes, first match (`Destination`; the address is the contract's `hostAddress` of the params,
computed by the caller; a hold carries it to be routed, and a host serving the type computes it again):
| Case | Goes |
| --- | --- |
| the type is served here and its address is this host's, or the host or the type has none | here |
| the type is served here and its address is this host's | here |
| a reference made here to a served type whose address is another host's | on, as if not served here (rows below): worker S1 reaches `Notes { session: "S2" }` through the parent |
| an arriving hold for a served type whose params place it on another host | broken `invalid/placement` (misrouted; never forwarded) |
| an arriving hold for a served type whose params place it on another host | broken `invalid/host_address` (misrouted; never forwarded) |
| the address is of a kind this host has a route for | over that route's link (the params checked against the kind, `authorize` first; `forbidden` when it refuses) |
| otherwise (a remote type) | over the parent link, open yet or not; a hold that arrived *on* the parent link is never sent back: `unavailable` |
| an arriving hold and no parent link | `unavailable` |
@ -458,7 +459,7 @@ cycles, throwing factories), `06-natives.ts` (opening in order, `not_ready`, clo
`08-remote.ts` (two hosts, variants per principal over a shared store, replicas), `09-calls.ts` (calls over a link,
bytes, errors, `ctx.principal`, cancellation, release), `10-drops.ts` (a hold before any link, `ref.state`, parked
calls, a drop and the re-hold), `11-linger-and-retry.ts` (`linger`, failed references, `ref.retry()`),
`12-routes.ts` (addresses, placement, a route to an in-process worker, a guest's `ctx.get` through it),
`12-routes.ts` (addresses, host addresses, a route to an in-process worker, a guest's `ctx.get` through it),
`13-relay.ts` (tab, server and a forked worker over WebSocket and IPC, the worker's start data through its core
facet, §18), `14-flow-control.ts` (a slow tab: folding at the provider, and at the worker per origin through a
relay while a fast tab keeps receiving), `15-revocation.ts` (a grant revoked: its tabs' links closed, its objects on
@ -488,8 +489,8 @@ delta)` (the keep-alive counts), `cycle(chain, id, closing)` (the `invalid/cycle
Inside the directory: `ChordHost` (types, objects by identity, facets, the batch, the backoff map, the endings new
instances wait for, its links and the heartbeat timer, its routes and the parent path; `reference` checks a
reference's route, `bind` binds it to an object here or a remote object over a path, `acquire` gets one for a
caller), `Placement` (where a reference or an arriving hold goes: here, a route's path or the parent's; whether a hold
served here is placed here), `Route` (one per routed kind: per address its `Path`, at most one running
caller), `Destination` (where a reference or an arriving hold goes: here, a route's path or the parent's; whether a
hold served here is placed here), `Route` (one per routed kind: per address its `Path`, at most one running
`resolve`, the backoff per address; an address is kept while it has holders, a link or a running `resolve`),
`ChordObject` (one per identity: its state across attempts, holders, retries, cycle checks with its instance id and
the chain it was created for, lingering, ending and revocation, the count of its running and finishing job runs,

View file

@ -1,6 +1,6 @@
// Placement (§3, section 1's table): where a reference made on a host, or a hold arriving on one of its links, goes:
// here, over the route of its placement's address, or over the parent link; and the check that an object served here
// is placed here.
// Destination (§3, section 1's table): where a reference made on a host, or a hold arriving on one of its links, goes:
// here, over the route of its host address, or over the parent link; and the check that an object served here lives
// here.
import type { Context } from "../../context/index.ts";
import type { Address, Contract, ParamsOf } from "../contracts/index.ts";
@ -12,13 +12,13 @@ import type { HoldRequest, HostLink, Path } from "../remote/internal.ts";
import type { Served } from "./object.ts";
import type { Route } from "./route.ts";
/** Where something goes: here, or over a path (with the address it was placed at, if its type has placement). */
/** Where something is placed: here, or over a path (with the host address it was computed or named with). */
export type Placed =
| { readonly kind: "here" }
| { readonly kind: "path"; readonly path: Path; readonly address: WireAddress | undefined };
| { readonly kind: "path"; readonly path: Path; readonly address: WireAddress };
/** A host's placement: its address, its served types, its routes and its parent path, as the host keeps them. */
export class Placement {
/** A host's destinations: its address, its served types, its routes and its parent path, as the host keeps them. */
export class Destination {
readonly #address: Address | undefined;
readonly #types: ReadonlyMap<string, Served>;
readonly #routes: ReadonlyMap<string, Route>;
@ -37,15 +37,16 @@ export class Placement {
}
/**
* Where a reference to `Type` with `params` made as `principal` goes: here when its type is served here and placed
* here (or this host has no address, or the type no placement); otherwise over the route of its placement's kind,
* or over the parent link (placement decides where an object lives: a worker reaches another worker's object of a
* type it serves too). Throws `invalid/placement` for a placement whose params do not fit its kind, `forbidden` when
* the route refuses, and what a placement or `authorize` throws.
* Where a reference to `Type` with `params` made as `principal` goes: here for a local type (served here: the host
* refused it otherwise) or a remote type served here whose host address is this host's; otherwise over the route of
* its host address's kind, or over the parent link (the host address decides where an object lives: a worker
* reaches another worker's object of a type it serves too). Throws `invalid/host_address` for a host address whose
* params do not fit its kind, `forbidden` when the route refuses, and what a `hostAddress` or `authorize` throws.
*/
reference(Type: Contract, params: ParamsOf<Contract>, principal: Principal | undefined, context: Context): Placed {
const address = placementOf(Type, params);
if (this.#types.has(Type.name) && this.#placedHere(address)) return HERE;
if (Type.kind === "local") return HERE;
const address = hostAddressOf(Type, params);
if (this.#types.has(Type.name) && this.#isHere(address)) return HERE;
return { kind: "path", path: this.#pathTo(address, principal, context) ?? this.#parent, address };
}
@ -66,40 +67,40 @@ export class Placement {
}
/**
* Throws `invalid/placement` for a hold of `Type` with `params` (checked) served here but placed on another host
* (misrouted; never forwarded). The placement is computed here from the params: the address a hold names only
* routes it.
* Throws `invalid/host_address` for a hold of `Type` with `params` (checked) served here but whose host address is
* another host (misrouted; never forwarded). The host address is computed here from the params: the address a hold
* names only routes it.
*/
assertHere(Type: Contract, params: ParamsOf<Contract>): void {
const address = placementOf(Type, params);
if (this.#placedHere(address)) return;
throw invalid("placement", `${Type.name}: placed on ${address!.kind}, this host is ${this.#address!.key}`);
const address = hostAddressOf(Type, params);
if (this.#isHere(address)) return;
// A host serving a remote type has an address (`serve` refuses one without).
throw invalid("host_address", `${Type.name}: placed on ${address.kind}, this host is ${this.#address!.key}`);
}
/**
* The path to `address` when this host routes its kind (its params checked, `authorize` run for `principal`);
* `undefined` without an address or a route for its kind.
* `undefined` without a route for its kind.
*/
#pathTo(address: WireAddress | undefined, principal: Principal | undefined, context: Context): Path | undefined {
if (address === undefined) return undefined;
#pathTo(address: WireAddress, principal: Principal | undefined, context: Context): Path | undefined {
return this.#routes.get(address.kind)?.path(address.params, principal, context);
}
/** Whether an object placed at `address` lives here: this host has no address, the type no placement, or equal. */
#placedHere(address: WireAddress | undefined): boolean {
/** Whether an object whose host address is `address` lives here: this host has that address. */
#isHere(address: WireAddress): boolean {
const own = this.#address;
return own === undefined || address === undefined || sameAddress(own, address);
return own !== undefined && sameAddress(own, address);
}
}
const HERE: Placed = { kind: "here" };
/** The address `Type`'s placement computes for `params`; `undefined` for a type without placement. */
function placementOf(Type: Contract, params: ParamsOf<Contract>): WireAddress | undefined {
const placement = contractData(Type).placement;
if (placement === undefined) return undefined;
/** The address a remote `Type`'s `hostAddress` computes for `params`. */
function hostAddressOf(Type: Contract, params: ParamsOf<Contract>): WireAddress {
// Every remote contract has one (declaration policy, `remote`).
const hostAddress = contractData(Type).hostAddress!;
// Plugin code: what it returns is checked where it is routed or served.
const { kind, params: values } = placement(params) as { readonly kind: string; readonly params: unknown };
const { kind, params: values } = hostAddress(params) as { readonly kind: string; readonly params: unknown };
return { kind, params: values };
}

View file

@ -48,11 +48,11 @@ import { Activity, type ActivityCounts } from "./activity.ts";
import { notLive } from "./attempt.ts";
import { Backoff } from "./backoff.ts";
import { type Caller, type Ctx, withLinkedAbortSignal } from "./ctx.ts";
import { Destination, type Placed } from "./destination.ts";
import { type Facet, type FacetContribution, type ServedEntry, setUp } from "./facet.ts";
import { contributionOf } from "./members.ts";
import type { CloseFailed } from "./native.ts";
import { ChordObject, type Holder, type LocalContract, type RemoteContract, type Served } from "./object.ts";
import { type Placed, Placement } from "./placement.ts";
import { type Presence, References } from "./presence.ts";
import { Binding, type CheckedParams, type Held, type Reference, type Target } from "./reference.ts";
import { RevocationKeys, type RevokedBy } from "./revocation.ts";
@ -191,8 +191,8 @@ export interface Host<P extends Principal = Principal, R extends JsonValue = Jso
load(facets: readonly { readonly name: string; readonly facet: Facet<P> }[], context: Context): Promise<void>;
/**
* A reference once its object is live; waits for `load` first. It acts as the host's principal and goes where its
* type's placement puts it: here, over a route, or over the parent link (`Placement`). A broken failure rejects at
* once; through transient ones it waits until its deadline, then rejects with the last failure's error.
* type's host address puts it: here, over a route, or over the parent link (`Destination`). A broken failure
* rejects at once; through transient ones it waits until its deadline, then rejects with the last failure's error.
*/
get<C extends Contract>(Type: C, params: ParamsOf<C>, context: Context, options?: GetOptions): Promise<Reference<C>>;
/**
@ -316,7 +316,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
*/
readonly unplaced = new Path(() => {});
readonly #routes = new Map<string, Route>();
readonly #placement: Placement;
readonly #destination: Destination;
/** The downstream origins this host relays holds for, by their number on upstream links (never reused). */
readonly relays = new Map<number, ProviderSide>();
#nextOrigin = 1;
@ -344,7 +344,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
this.#revocationKeys = new RevocationKeys(options.revokedBy as RevokedBy<Principal, JsonValue> | undefined);
this.timeouts = { close: options.closeTimeout ?? 30_000, ready: options.readyTimeout ?? 60_000 };
this.#heartbeat = { ping: options.heartbeat?.ping ?? 15_000, close: options.heartbeat?.close ?? 30_000 };
this.#placement = new Placement(this.address, this.#types, this.#routes, this.#parent);
this.#destination = new Destination(this.address, this.#types, this.#routes, this.#parent);
// Chord's own: `openStore` made it.
this.store = options.store as ChordStore | undefined;
this.store?.attach({ failed: (error) => this.#storageFailed(error), batch: this.batch });
@ -576,13 +576,13 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
}
/**
* Binds `binding` to the object for (Type, params) as its principal where its placement puts it (`Placement`): the
* object here, created with its first attempt if there is none, or a remote object held over its path. Throws as
* `Placement.reference` does.
* Binds `binding` to the object for (Type, params) as its principal where its host address puts it (`Destination`):
* the object here, created with its first attempt if there is none, or a remote object held over its path. Throws
* as `Destination.reference` does.
*/
bind(binding: Binding, Type: Contract, params: CheckedParams, context: Context): Target {
const { principal } = binding.caller;
const placed = this.#placement.reference(Type, params.params, principal, context);
const placed = this.#destination.reference(Type, params.params, principal, context);
let target: Target;
if (placed.kind === "path") {
const { caller, dependent } = binding;
@ -598,16 +598,16 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
return target;
}
/** Where a hold arriving on `link` goes (`Placement.hold`); `released` once the host closed. */
/** Where a hold arriving on `link` goes (`Destination.hold`); `released` once the host closed. */
place(request: HoldRequest, link: HostLink, caller: Caller, context: Context): Placed {
this.#assertOpen();
return this.#placement.hold(request, link, caller.principal, context);
return this.#destination.hold(request, link, caller.principal, context);
}
/**
* The object a hold placed here binds to, acting as `principal`: a remote type served here (otherwise
* `unavailable`) with the hold's version and fingerprint (`contract_mismatch`), valid params, and placed here, its
* placement computed from the params (`invalid/placement`); not an activation the hold's chain waits for
* host address computed from the params (`invalid/host_address`); not an activation the hold's chain waits for
* (`invalid/cycle`, Q5). A new object keeps the hold's chain.
*/
serving(request: HoldRequest, params: unknown, principal: Principal, context: Context): ChordObject {
@ -618,7 +618,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
}
this.#assertMatch(served, version, fingerprint);
const checked = checkParams(served.contract, params, context);
this.#placement.assertHere(served.contract, checked.params);
this.#destination.assertHere(served.contract, checked.params);
const object = this.#object(served, checked, principal, context, chain);
const activation = object.activationId;
if (activation !== undefined && chain.includes(activation)) throw this.cycle(chain, activation, [object.label]);
@ -628,11 +628,11 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
/**
* The path a remote reference of `Type` with `params` acting as `principal` goes over now: `ref.retry()` places a
* reference failed for a part past the limits anew (a route may have forgotten its old path). Throws as
* `Placement.reference` does, and `unavailable` for a type served here now.
* `Destination.reference` does, and `unavailable` for a type served here now.
*/
pathOf(Type: Contract, params: ParamsOf<Contract>, principal: Principal | undefined): Path {
// A retry has no context of its own: its admission checks count on none.
const placed = this.#placement.reference(Type, params, principal, BACKGROUND_CONTEXT);
const placed = this.#destination.reference(Type, params, principal, BACKGROUND_CONTEXT);
if (placed.kind === "path") return placed.path;
throw new ChordError({ code: "unavailable", message: `${Type.name} is served here now` });
}
@ -713,8 +713,8 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
/**
* A reference to `Type` made by `caller` for `host.hold` and getters: params checked at once (`invalid/params`,
* also before `load`), where it goes too (`#assertRoute`), then bound (a placement or route that fails fails the
* reference broken); before `load` resolved, where it goes once it did (a host closed meanwhile releases it).
* also before `load`), where it goes too (`#assertRoute`), then bound (a `hostAddress` or route that fails fails
* the reference broken); before `load` resolved, where it goes once it did (a host closed meanwhile releases it).
*/
#hold(Type: Contract, params: unknown, caller: Caller, context: Context, options: GetOptions): Binding {
this.#assertOpen();
@ -737,8 +737,8 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
}
/**
* Binds a reference (`bind`); where it cannot go (`#assertRoute`, a placement or `authorize` that throws or refuses)
* fails it broken, until `ref.retry()` binds it again.
* Binds a reference (`bind`); where it cannot go (`#assertRoute`, a `hostAddress` or `authorize` that throws or
* refuses) fails it broken, until `ref.retry()` binds it again.
*/
#bindOrFail(binding: Binding, Type: Contract, checked: CheckedParams, context: Context): void {
try {
@ -932,6 +932,10 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
if (contract.kind === "remote" && this.principal === undefined) {
throw new Error(`${contract.name}: a host without a principal serves only local types`);
}
// A remote object lives on the host its `hostAddress` names: only a host with an address can be that host.
if (contract.kind === "remote" && this.address === undefined) {
throw new Error(`${contract.name}: a host without an address serves only local types`);
}
const common = { plugin, provider: bindProvider(contract, {}), linger: options.linger ?? 0 };
// The contract's kind decides the factory's signature and the contract's shape.
const served: Served =

View file

@ -63,7 +63,7 @@ export class Route {
/**
* The path to the address with `params`, for a hold acting as `principal`: the params checked against the kind
* (`invalid/placement`), then `authorize` (`forbidden` when it refuses; a throw is the hold's failure).
* (`invalid/host_address`), then `authorize` (`forbidden` when it refuses; a throw is the hold's failure).
*/
path(params: unknown, principal: Principal | undefined, context: Context): Path {
const address = checkAddress(this.kind, this.#params, params, context);

View file

@ -51,7 +51,7 @@ tab.connect(webSocketLink(new WebSocket(url)), { principal: { kind: "server" } }
fields: unsigned varints (ids, indexes, counts,
lengths), length-prefixed UTF-8 strings, the incarnation as 16 and the fingerprint as 8 raw bytes, parts as
length-prefixed bytes (decoded as views of the received message, never parsed), other JSON fields (a hold's
address and principal, errors) as length-prefixed JSON; a hold's optional fields behind one flags byte; a payload
address params and principal, errors) as length-prefixed JSON; a hold's optional fields behind one flags byte; a payload
marked as carrying bytes adds its segments (count, lengths, raw bytes); `failed`'s `retryIn` as one more than it
(0: absent). A part's length prefix is its length doubled, plus one when the part is deflated. A hold's `chain`
as a count and 16 raw bytes per instance id, its `limit` as a varint. No outer length.
@ -74,8 +74,8 @@ tab.connect(webSocketLink(new WebSocket(url)), { principal: { kind: "server" } }
- `Message` (`message.ts`): the protocol's one tagged union, from the TypeBox schema `MESSAGE`: `hello { protocol,
incarnation, calls }` (`calls`: how many concurrent calls this side accepts per origin, its call window),
`hold { hold, type, version, fingerprint, params, address?, principal?, incarnation? }` (`address`: `{ kind, params }`
the caller's placement computed, it routes the hold; the provider places it by its params; absent for a type without placement; `principal` and `incarnation`: whom it acts for, read only
`hold { hold, type, version, fingerprint, params, address, principal?, incarnation? }` (`address`: `{ kind, params }`
the caller's `hostAddress` computed, it routes the hold; the provider places it by its params; `principal` and `incarnation`: whom it acts for, read only
on a parent link; `chain?`: the instance ids of the activations waiting for the hold, at most 32, read only on a
trusted link; `limit?`: the decoded limit per part of the link a relay admitted it on, read only on a parent link), `release`, `call { call, hold, method, args?, segments? }`, `abort`, `bound { holds: [{ hold,
variant, members: [{ member, value }], failed }] }` (one message may bind several holds: first bindings, re-holds),

View file

@ -50,15 +50,13 @@ const PRESENT = 1;
const SEGMENTS = 2;
/** A hold's optional fields, one bit each. */
const ADDRESS = 1;
const PRINCIPAL = 1;
const PRINCIPAL = 2;
const INCARNATION = 2;
const INCARNATION = 4;
const CHAIN = 4;
const CHAIN = 8;
const LIMIT = 16;
const LIMIT = 8;
/** A part's length prefix is its length doubled, plus one when the part is deflated. */
const DEFLATED = 1;
@ -106,17 +104,14 @@ function encodeFields(message: Message, writer: Writer): void {
writer.raw(hex(message.fingerprint, 8));
writer.part(message.params);
const { address, principal, incarnation, chain, limit } = message;
writer.string(address.kind);
writer.json(address.params);
writer.byte(
(address === undefined ? 0 : ADDRESS) |
(principal === undefined ? 0 : PRINCIPAL) |
(principal === undefined ? 0 : PRINCIPAL) |
(incarnation === undefined ? 0 : INCARNATION) |
(chain === undefined ? 0 : CHAIN) |
(limit === undefined ? 0 : LIMIT),
);
if (address !== undefined) {
writer.string(address.kind);
writer.json(address.params);
}
if (principal !== undefined) writer.json(principal);
if (incarnation !== undefined) writer.raw(hex(incarnation.replaceAll("-", ""), 16));
if (chain !== undefined) {
@ -207,9 +202,9 @@ function decodeFields(kind: (typeof KINDS)[number], reader: Reader): Message {
const version = reader.varint();
const fingerprint = hexText(reader.raw(8));
const params = reader.part();
const address = { kind: reader.string(), params: reader.json() };
const flags = reader.byte();
if (flags > (ADDRESS | PRINCIPAL | INCARNATION | CHAIN | LIMIT)) throw new Error("binary: unknown hold flags");
const address = flags & ADDRESS ? { address: { kind: reader.string(), params: reader.json() } } : {};
if (flags > (PRINCIPAL | INCARNATION | CHAIN | LIMIT)) throw new Error("binary: unknown hold flags");
const principal = flags & PRINCIPAL ? { principal: reader.json() as { kind: string } } : {};
const incarnation = flags & INCARNATION ? { incarnation: uuid(reader.raw(16)) } : {};
const chain = flags & CHAIN ? { chain: reader.list(() => uuid(reader.raw(16))) } : {};
@ -221,7 +216,7 @@ function decodeFields(kind: (typeof KINDS)[number], reader: Reader): Message {
version,
fingerprint,
params,
...address,
address,
...principal,
...incarnation,
...chain,

View file

@ -62,8 +62,8 @@ const MESSAGE = union([
version: int({ min: 1 }),
fingerprint: str({ pattern: "^[0-9a-f]{16}$" }),
params: part,
// The address the caller computed (absent for a type without placement).
address: opt(address),
// The address the caller computed with the type's `hostAddress`: it routes the hold.
address,
// Whom the hold acts for and the origin's incarnation; read only on a parent link.
principal: opt(principal),
incarnation: opt(incarnation),

View file

@ -75,16 +75,10 @@ function ownTuple(message: Message): string {
const { address, principal, incarnation, chain, limit } = message;
const head = json(["H", message.hold, message.type, message.version, message.fingerprint]).slice(0, -1);
// Optional trailing fields: an absent one in the middle is null, absent ones at the end are left out.
const optional = [
address === undefined ? null : [address.kind, address.params],
principal ?? null,
incarnation ?? null,
chain ?? null,
limit ?? null,
];
const optional = [principal ?? null, incarnation ?? null, chain ?? null, limit ?? null];
while (optional.length > 0 && optional.at(-1) === null) optional.pop();
const rest = optional.map((field) => `,${json(field)}`).join("");
return `${head},${partText(message.params)}${rest}]`;
return `${head},${partText(message.params)},${json([address.kind, address.params])}${rest}]`;
}
case "release":
return json(["R", message.hold]);
@ -188,14 +182,10 @@ function fromFields(fields: unknown[], rest: Uint8Array): Message {
arity(3);
return { kind: "hello", protocol: field(0), incarnation: field(1), calls: field(2) };
case "H": {
arity(5, 6, 7, 8, 9, 10);
const [address, principal, incarnation, chain, limit] = [5, 6, 7, 8, 9].map((index) => f[index] ?? null);
let wireAddress = {};
if (address !== null) {
const pair = list(address);
if (pair.length !== 2) throw new Error("text: an address is a pair");
wireAddress = { address: { kind: pair[0], params: pair[1] } };
}
arity(6, 7, 8, 9, 10);
const address = list(f[5]);
if (address.length !== 2) throw new Error("text: an address is a pair");
const [principal, incarnation, chain, limit] = [6, 7, 8, 9].map((index) => f[index] ?? null);
return {
kind: "hold",
hold: field(0),
@ -203,7 +193,7 @@ function fromFields(fields: unknown[], rest: Uint8Array): Message {
version: field(2),
fingerprint: field(3),
params: textPart(f[4]),
...wireAddress,
address: { kind: address[0] as string, params: address[1] },
...(principal === null ? {} : { principal: principal as { kind: string } }),
...(incarnation === null ? {} : { incarnation: incarnation as string }),
...(chain === null ? {} : { chain: chain as string[] }),

View file

@ -83,7 +83,7 @@ within each link's limits, compression at the origin, activation chains and revo
`state` with A's and B's entries: the derive sees both at once, never A new with B old.
- `Hold` (one per `hold` served here, a `Holder` of its object): takes its object once `load` resolved; the hold's
`params` part is parsed then (malformed: a protocol error). A refused type, a mismatch, invalid params, a wrong
placement or a snapshot that throws is a broken `failed` that ends it (the provider forgets the id). While pending
host address or a snapshot that throws is a broken `failed` that ends it (the provider forgets the id). While pending
it follows its object's `state`: each failure, transient or broken, is sent as `failed` with `retryIn`, the rest of
the object's backoff window (absent when broken), the current one at once when the object is failed already; the
next attempt is announced with `loading`; the hold stays, so a `retry` (from any holder) renews even a broken object
@ -146,7 +146,7 @@ within each link's limits, compression at the origin, activation chains and revo
until `retry`; the message's other entries apply. Its call arguments go up a parent link
uncompressed (the parent may have accepted it as a child). It keeps its
binding's caller: two references with different principals are two remote objects. Its holds carry the params as a
part and the address its placement computed. A `bound` (a first binding, a re-hold; also on a live hold)
part and the address its `hostAddress` computed. A `bound` (a first binding, a re-hold; also on a live hold)
replaces the replicas in the message's batch: members it had get the snapshot as one frame `[["r", value]]` and a
fresh decoder, members outside a new variant vanish (reads throw `invalid/member`, watches get one `failed` call and
stay open, derives over them fail; a later binding with the member brings it back as `[["r", value]]`, as on the

View file

@ -72,7 +72,7 @@ type HoldState =
/** The members it follows, in the order they were subscribed. */
readonly followed: Followed[];
}
/** Refused where it arrived (placement, `authorize`, a mismatch, invalid params, a cycle): `retry` starts it again. */
/** Refused where it arrived (host address, `authorize`, a mismatch, invalid params, a cycle): `retry` restarts it. */
| { readonly kind: "refused"; readonly request: HoldRequest }
| { readonly kind: "released" };
@ -86,8 +86,8 @@ const BINDING: HoldState = { kind: "binding" };
/**
* One hold served here: a reference to its object acting as its caller (the hold's principal). While pending, each
* failure of its object is sent as `failed` (with the rest of its backoff window) and the next attempt as `loading`; a
* hold that cannot be taken (a refused type, a mismatch, invalid params or placement) is a broken `failed` that holds
* nothing until a `retry` starts it again. Once live it binds when its link's window has a slot, then sends a part per batch that changed its members
* hold that cannot be taken (a refused type, a mismatch, invalid params or a wrong host address) is a broken `failed`
* that holds nothing until a `retry` starts it again. Once live it binds when its link's window has a slot, then sends a part per batch that changed its members
* (or keeps their ops pending while the window is full), and `gone` when its object ends.
*/
export class Hold implements Holder {
@ -139,7 +139,8 @@ export class Hold implements Holder {
* Admits the hold once `load` resolved: its params within the link's part limit (`too_large/part`), then relayed
* (it is replaced by a relayed hold), or served here, its params opened and parsed (a malformed part is a protocol
* error), its object taken and followed (`moved`). A hold that cannot go anywhere, a type that does not match,
* invalid params, a wrong placement or a cycle (Q5) is a broken `failed`; the hold is kept, refused, for a `retry`.
* invalid params, a wrong host address or a cycle (Q5) is a broken `failed`; the hold is kept, refused, for a
* `retry`.
*/
async start(request: HoldRequest): Promise<void> {
const { link } = this.#provider;

View file

@ -86,7 +86,7 @@ export class RemoteObject implements Target {
host: AnyHost,
contract: Contract,
{ params, key }: CheckedParams,
address: WireAddress | undefined,
address: WireAddress,
caller: Caller,
path: Path,
dependent: ChordObject | undefined,
@ -100,7 +100,7 @@ export class RemoteObject implements Target {
version,
fingerprint,
params: jsonPart(params),
...(address === undefined ? {} : { address }),
address,
};
this.caller = caller;
this.#params = params;
@ -216,7 +216,7 @@ export class RemoteObject implements Target {
/**
* `ref.retry()`: a failed object's provider is asked for an attempt (a hold it refused is started there again). One
* that failed for a part past the link's limits is placed anew and held with a new id, now or on its path's next
* link (a placement that throws fails it broken again).
* link (a `hostAddress` that throws fails it broken again).
*/
retry(): void {
if (this.#state.kind !== "failed") return;
@ -227,7 +227,7 @@ export class RemoteObject implements Target {
this.#hold?.side.retry(this.#hold.id);
}
/** Places a reference whose part was past the limits anew, on its path now (a placement that throws fails it). */
/** Places a reference whose part was past the limits anew, on its path now (a throwing `hostAddress` fails it). */
#place(): void {
let path: Path;
try {

View file

@ -4,13 +4,15 @@
// Run by hand from packages/chord (not in CI):
// node --conditions=source --experimental-strip-types --expose-gc test/next/bench/compression.ts
import { BACKGROUND_CONTEXT } from "../../../src/context/index.ts";
import { arr, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { arr, hostKind, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { defineState } from "../../../src/next/documents/index.ts";
import { createHost, defineFacet } from "../../../src/next/host/index.ts";
import { type Compression, type Link, memoryLinkPair } from "../../../src/next/link/index.ts";
import { DeflatedPart, inflateAsync } from "../../../src/next/link/internal.ts";
import { nodeCompression } from "../../../src/next-node/compression.ts";
const Server = hostKind("server");
const context = BACKGROUND_CONTEXT;
type P = { kind: "owner" } | { kind: "server" };
@ -23,6 +25,7 @@ const Store = local("bench.store", { version: 1, params: {}, members: { doc: obj
const View = remote("bench.view", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { owner: { state: { doc: obj({ entries: arr(Entry) }) }, methods: { export: method(undefined, str()) } } },
});
@ -115,7 +118,12 @@ for (const [name, json] of [
/** A server compressing with `server` and a client with `client` (none: it inflates asynchronously). */
async function linked(server: Compression | undefined, client: Compression | undefined) {
const serverHost = createHost<P>({ principal: { kind: "server" }, compression: server, onError: console.error });
const serverHost = createHost<P>({
address: Server(),
principal: { kind: "server" },
compression: server,
onError: console.error,
});
await serverHost.load([{ name: "@bench/app", facet }], context);
const clientHost = createHost<P>({ compression: client, onError: console.error });
await clientHost.load([], context);

View file

@ -8,13 +8,15 @@
import { GCProfiler } from "node:v8";
import { InMemoryTelemetryContext } from "@earendil-works/pi-telemetry";
import { BACKGROUND_CONTEXT, type Context, withTelemetry } from "../../../src/context/index.ts";
import { arr, bytes, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { arr, bytes, hostKind, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { defineState } from "../../../src/next/documents/index.ts";
import { createHost, defineFacet, type Host, type RefState } from "../../../src/next/host/index.ts";
import { type Codec, type Link, memoryLinkPair } from "../../../src/next/link/index.ts";
import { binaryCodec, type Message, textCodec } from "../../../src/next/link/internal.ts";
import type { NativeWatchable } from "../../../src/watch.ts";
const Server = hostKind("server");
type P = { kind: "owner" } | { kind: "server" };
const Entry = obj({ id: int(), text: str(), author: str(), at: int() });
@ -37,6 +39,7 @@ const Store = local("bench.store", {
const View = remote("bench.view", {
version: 1,
params: { name: str() },
hostAddress: () => Server(),
variants: { owner: { state: { doc: Shape } } },
});
@ -44,6 +47,7 @@ const View = remote("bench.view", {
const Files = remote("bench.files", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
owner: { methods: { ping: method(obj({ n: int() }), int()), put: method(obj({ data: bytes() }), int()) } },
},
@ -138,6 +142,7 @@ const document = (): Entry[] => Array.from({ length: 500 }, (_, id) => entry(id,
async function setup(name: string, entries: Entry[], codec: Codec, metered = true) {
const context = metered ? counting : BACKGROUND_CONTEXT;
const server = createHost<P>({
address: Server(),
principal: { kind: "server" },
telemetry: metered ? telemetry : undefined,
onError: console.error,

View file

@ -39,7 +39,7 @@ const Store = local("bench.store", {
const View = remote("bench.view", {
version: 1,
params: { session: str() },
placement: ({ session }) => Session({ id: session }),
hostAddress: ({ session }) => Session({ id: session }),
variants: { owner: { state: { doc: Shape }, methods: { stream: method(str()), ping: method(int(), int()) } } },
});

View file

@ -3,6 +3,7 @@ import {
type AcceptsTable,
arr,
evolve,
hostKind,
int,
local,
method,
@ -13,12 +14,15 @@ import {
} from "../../../src/next/contracts/index.ts";
import { checkAccepts } from "../../../src/next/contracts/internal.ts";
const Server = hostKind("server");
const Note = obj({ text: str() });
const NoteV2 = obj({ text: str(), tag: str() });
const NotesV1 = remote("notes", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: {
editor: { state: { notes: arr(Note), count: int() }, methods: { add: method(Note, str()), clear: method() } },
reader: { state: { notes: arr(Note) }, methods: { add: method(Note, str()) } },

View file

@ -19,11 +19,12 @@ import {
import { checkAccepts, currentVariants, LOCAL, takesNoArgs } from "../../../src/next/contracts/internal.ts";
import { bindProvider, checkParams } from "../../../src/next/data/internal.ts";
import type { Watchable } from "../../../src/next/state/index.ts";
import { expectCollected } from "../support.ts";
import { expectCollected, Server } from "../support.ts";
const Notes = remote("notes", {
version: 1,
params: { session: str(), page: int() },
hostAddress: () => Server(),
variants: {
editor: { state: { notes: arr(str()) }, methods: { add: method(obj({ text: str() }), str()), clear: method() } },
reader: { state: { notes: arr(str()) } },

View file

@ -1,10 +1,25 @@
import Type from "typebox";
import { describe, expect, it } from "vitest";
import { arr, evolve, int, keyed, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import {
arr,
evolve,
hostKind,
int,
keyed,
local,
method,
obj,
remote,
str,
} from "../../../src/next/contracts/index.ts";
import { currentVariants, LOCAL } from "../../../src/next/contracts/internal.ts";
const Note = obj({ text: str() });
const Server = hostKind("server");
const hostAddress = () => Server();
/** Declares through an untyped door, as a JavaScript caller would. */
const untyped = {
remote: remote as (name: unknown, declaration: unknown) => unknown,
@ -16,6 +31,7 @@ describe("declaration checks", () => {
const Notes = remote("notes", {
version: 1,
params: { session: str() },
hostAddress,
variants: {
editor: { state: { notes: arr(Note) }, methods: { add: method(Note, str()), clear: method() } },
reader: { state: { notes: arr(Note) } },
@ -31,77 +47,109 @@ describe("declaration checks", () => {
expect(Info).toMatchObject({ kind: "local", name: "app.session-info", version: 3 });
});
// Each case declares a valid `hostAddress` unless it checks that one, so the check it names is the one reached.
it.each([
["empty name", "", { version: 1, params: {}, variants: { a: {} } }, 'A contract name "": expected lowercase'],
[
"empty name",
"",
{ version: 1, params: {}, hostAddress, variants: { a: {} } },
'A contract name "": expected lowercase',
],
[
"name not a string",
1,
{ version: 1, params: {}, variants: { a: {} } },
{ version: 1, params: {}, hostAddress, variants: { a: {} } },
"A contract name 1: expected lowercase",
],
["an uppercase name", "Notes", { version: 1, params: {}, variants: { a: {} } }, "expected lowercase"],
["a name with a colon", "a:b", { version: 1, params: {}, variants: { a: {} } }, "expected lowercase"],
["version zero", "n", { version: 0, params: {}, variants: { a: {} } }, "n.version: expected a positive integer"],
[
"an uppercase name",
"Notes",
{ version: 1, params: {}, hostAddress, variants: { a: {} } },
"expected lowercase",
],
[
"a name with a colon",
"a:b",
{ version: 1, params: {}, hostAddress, variants: { a: {} } },
"expected lowercase",
],
[
"version zero",
"n",
{ version: 0, params: {}, hostAddress, variants: { a: {} } },
"n.version: expected a positive integer",
],
[
"fractional version",
"n",
{ version: 1.5, params: {}, variants: { a: {} } },
{ version: 1.5, params: {}, hostAddress, variants: { a: {} } },
"n.version: expected a positive integer",
],
[
"unsafe version",
"n",
{ version: 2 ** 53, params: {}, variants: { a: {} } },
{ version: 2 ** 53, params: {}, hostAddress, variants: { a: {} } },
"n.version: expected a positive integer",
],
[
"unknown declaration key",
"n",
{ version: 1, params: {}, variants: { a: {} }, place: "x" },
{ version: 1, params: {}, hostAddress, variants: { a: {} }, place: "x" },
'n: unknown key "place"',
],
[
"a placement that is no function",
"a host address that is no function",
"n",
{ version: 1, params: {}, variants: { a: {} }, placement: "x" },
"n.placement: expected a function of the params",
{ version: 1, params: {}, variants: { a: {} }, hostAddress: "x" },
"n.hostAddress: expected a function of the params",
],
["no variants", "n", { version: 1, params: {}, variants: {} }, "n.variants: at least one variant is required"],
["missing params", "n", { version: 1, variants: { a: {} } }, "n.params: expected an object"],
[
"no host address",
"n",
{ version: 1, params: {}, variants: { a: {} } },
"n.hostAddress: expected a function of the params",
],
[
"no variants",
"n",
{ version: 1, params: {}, hostAddress, variants: {} },
"n.variants: at least one variant is required",
],
["missing params", "n", { version: 1, hostAddress, variants: { a: {} } }, "n.params: expected an object"],
[
"unknown variant key",
"n",
{ version: 1, params: {}, variants: { a: { members: {} } } },
{ version: 1, params: {}, hostAddress, variants: { a: { members: {} } } },
'n.a: unknown key "members"',
],
[
"a schema under methods",
"n",
{ version: 1, params: {}, variants: { a: { methods: { m: str() } } } },
{ version: 1, params: {}, hostAddress, variants: { a: { methods: { m: str() } } } },
"n.a.methods.m: expected method(...)",
],
[
"a method under state",
"n",
{ version: 1, params: {}, variants: { a: { state: { s: method() } } } },
{ version: 1, params: {}, hostAddress, variants: { a: { state: { s: method() } } } },
"n.a.state.s: a method belongs in methods",
],
[
"bad state schema",
"n",
{ version: 1, params: {}, variants: { a: { state: { s: { not: {} } } } } },
{ version: 1, params: {}, hostAddress, variants: { a: { state: { s: { not: {} } } } } },
'n.a.state.s: unsupported keyword "not"',
],
[
"open arguments",
"n",
{ version: 1, params: {}, variants: { a: { methods: { m: method(Type.Object({})) } } } },
{ version: 1, params: {}, hostAddress, variants: { a: { methods: { m: method(Type.Object({})) } } } },
"n.a.methods.m.args: argument objects must be closed",
],
[
"bad result",
"n",
{ version: 1, params: {}, variants: { a: { methods: { m: method(undefined, { not: {} }) } } } },
{ version: 1, params: {}, hostAddress, variants: { a: { methods: { m: method(undefined, { not: {} }) } } } },
'n.a.methods.m.result: unsupported keyword "not"',
],
])("remote refuses %s", (_case, name, declaration, message) => {
@ -110,7 +158,7 @@ describe("declaration checks", () => {
it.each([
["unknown key", { version: 1, params: {}, members: {}, variants: {} }, 'i: unknown key "variants"'],
["a placement", { version: 1, params: {}, members: {}, placement: () => null }, 'i: unknown key "placement"'],
["a host address", { version: 1, params: {}, members: {}, hostAddress }, 'i: unknown key "hostAddress"'],
[
"bad member schema",
{ version: 1, params: {}, members: { s: { not: {} } } },
@ -125,6 +173,13 @@ describe("declaration checks", () => {
expect(() => untyped.local("i", declaration)).toThrow(message);
});
it("refuse a remote declaration without a host address at the type level", () => {
expect(() =>
// @ts-expect-error: every remote type declares its host address
remote("n", { version: 1, params: {}, variants: { a: {} } }),
).toThrow("n.hostAddress: expected a function of the params");
});
it("does not check state for closed objects", () => {
expect(() => local("i", { version: 1, params: {}, members: { s: Type.Object({ a: str() }) } })).not.toThrow();
});
@ -136,6 +191,7 @@ describe("D11: members across variants", () => {
remote("n", {
version: 1,
params: {},
hostAddress,
// @ts-expect-error: `x` is a method in variant b
variants: { a: { state: { x: str() } }, b: { methods: { x: method() } } },
}),
@ -147,6 +203,7 @@ describe("D11: members across variants", () => {
remote("n", {
version: 1,
params: {},
hostAddress,
// @ts-expect-error: `x` is both state and a method
variants: { a: { state: { x: str() }, methods: { x: method() } } },
}),
@ -158,6 +215,7 @@ describe("D11: members across variants", () => {
remote("n", {
version: 1,
params: {},
hostAddress,
// @ts-expect-error: `m` takes a string in a and an integer in b
variants: { a: { methods: { m: method(str()) } }, b: { methods: { m: method(int()) } } },
}),
@ -166,6 +224,7 @@ describe("D11: members across variants", () => {
remote("n", {
version: 1,
params: {},
hostAddress,
variants: {
a: { methods: { m: method(str(), str()) } },
b: { methods: { m: method(str({ max: 3 }), str()) } },
@ -179,6 +238,7 @@ describe("D11: members across variants", () => {
remote("n", {
version: 1,
params: {},
hostAddress,
variants: {
editor: { state: { notes: arr(obj({ text: str(), author: str() })) }, methods: { m: method(str()) } },
reader: { state: { notes: arr(obj({ text: str() })) }, methods: { m: method(str()) } },
@ -190,9 +250,9 @@ describe("D11: members across variants", () => {
describe("reserved member names", () => {
it.each(["then", "release", "variant", "state", "retry"])("refuses %s in remote, local and evolve", (name) => {
expect(() => remote("n", { version: 1, params: {}, variants: { v: { state: { [name]: str() } } } })).toThrow(
`n.v.state.${name}: "${name}" is reserved: a reference has it`,
);
expect(() =>
remote("n", { version: 1, params: {}, hostAddress, variants: { v: { state: { [name]: str() } } } }),
).toThrow(`n.v.state.${name}: "${name}" is reserved: a reference has it`);
expect(() => local("n", { version: 1, params: {}, members: { [name]: method() } })).toThrow(
`n.members.${name}: "${name}" is reserved: a reference has it`,
);
@ -220,6 +280,7 @@ describe("keyed members (slice 6a §12)", () => {
remote("app.notes", {
version: 1,
params: {},
hostAddress,
// @ts-expect-error keyed members are only in local contracts
variants: { editor: { state: { notes: keyed(Note) } } },
}),

View file

@ -7,6 +7,7 @@ import {
arr,
bool,
evolve,
hostKind,
type Implementation,
int,
local,
@ -18,11 +19,14 @@ import {
str,
} from "../../../src/next/contracts/index.ts";
const Server = hostKind("server");
const Note = obj({ text: str() });
const NotesV1 = remote("notes", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: {
editor: { state: { notes: arr(Note), count: int() }, methods: { add: method(Note, str()), clear: method() } },
reader: { state: { notes: arr(Note) }, methods: { add: method(Note, str()) } },
@ -164,7 +168,12 @@ describe("kinds and fixed parts", () => {
});
it("a new base is a full declaration with an explicit version and no older versions", () => {
const base = remote("notes", { version: 5, params: { session: str(), tenant: str() }, variants: { editor: {} } });
const base = remote("notes", {
version: 5,
params: { session: str(), tenant: str() },
hostAddress: () => Server(),
variants: { editor: {} },
});
expect(contractData(base).versions.map(({ version }) => version)).toEqual([5]);
expect(evolve(base, (e) => e).version).toBe(6);
});

View file

@ -1,12 +1,15 @@
import Type, { type TSchema } from "typebox";
import { describe, expect, it } from "vitest";
import { evolve, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { evolve, hostKind, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { NORMAL_FORM_VERSION } from "../../../src/next/contracts/internal.ts";
import type { Params } from "../../../src/next/contracts/params.ts";
const Server = hostKind("server");
/** Fingerprint of a one-variant remote contract with state `s` and params `params`. */
function fingerprintOf(state: TSchema, params: Params = {}): string {
return remote("n", { version: 1, params, variants: { a: { state: { s: state } } } }).fingerprint;
return remote("n", { version: 1, params, hostAddress: () => Server(), variants: { a: { state: { s: state } } } })
.fingerprint;
}
describe("fingerprints", () => {
@ -15,6 +18,7 @@ describe("fingerprints", () => {
const Notes = remote("notes", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: {
editor: { state: { notes: obj({ text: str() }) }, methods: { add: method(obj({ text: str() }), str()) } },
reader: { state: { notes: obj({ text: str() }) } },
@ -58,7 +62,8 @@ describe("fingerprints", () => {
});
it("give the same v1 from a separate declaration, and evolve keeps v1's", () => {
const declare = () => remote("n", { version: 1, params: {}, variants: { a: { state: { s: str() } } } });
const declare = () =>
remote("n", { version: 1, params: {}, hostAddress: () => Server(), variants: { a: { state: { s: str() } } } });
const v1 = declare();
const v2 = evolve(v1, (e) => e.state("a", "s", int()));
expect(declare().fingerprint).toBe(v1.fingerprint);
@ -67,14 +72,19 @@ describe("fingerprints", () => {
});
it("differ between local and remote", () => {
const asRemote = remote("n", { version: 1, params: {}, variants: { members: { state: { s: str() } } } });
const asRemote = remote("n", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { members: { state: { s: str() } } },
});
const asLocal = local("n", { version: 1, params: {}, members: { s: str() } });
expect(asLocal.fingerprint).not.toBe(asRemote.fingerprint);
});
it("do not depend on the name or version", () => {
const a = remote("a", { version: 1, params: {}, variants: { v: {} } });
const b = remote("b", { version: 7, params: {}, variants: { v: {} } });
const a = remote("a", { version: 1, params: {}, hostAddress: () => Server(), variants: { v: {} } });
const b = remote("b", { version: 7, params: {}, hostAddress: () => Server(), variants: { v: {} } });
expect(a.fingerprint).toBe(b.fingerprint);
});
});

View file

@ -30,7 +30,7 @@ describe("host kinds", () => {
expect(hostKind("@pi/session.v2_~-").kind).toBe("@pi/session.v2_~-");
});
it("check an address's params where it is routed, with the kind's compiled check: invalid/placement", () => {
it("check an address's params where it is routed, with the kind's compiled check: invalid/host_address", () => {
const params = kindParams(Session);
expect(checkAddress("session", params, { id: "S1" }, context).key).toBe("session|S1");
for (const bad of [{ id: 1 }, {}, { id: "S1", extra: 1 }, null]) {
@ -40,7 +40,7 @@ describe("host kinds", () => {
} catch (error) {
thrown = error;
}
expect(isChordError(thrown, "invalid") && thrown.reason).toBe("placement");
expect(isChordError(thrown, "invalid") && thrown.reason).toBe("host_address");
}
});
@ -54,18 +54,22 @@ describe("host kinds", () => {
});
});
describe("placement", () => {
describe("hostAddress", () => {
const variants = { v: { state: { n: int() } } };
it("is not part of the fingerprint and is carried by evolve", () => {
const placement = ({ session }: { session: string }) => Session({ id: session });
const placed = remote("app.placed", { version: 1, params: { session: str() }, placement, variants });
const unplaced = remote("app.placed", { version: 1, params: { session: str() }, variants });
expect(placed.fingerprint).toBe(unplaced.fingerprint);
expect(contractData(placed).placement).toBe(placement);
expect(contractData(unplaced).placement).toBeUndefined();
const next = evolve(placed, (v) => v.addState("v", "m", int()));
expect(contractData(next).placement).toBe(placement);
const hostAddress = ({ session }: { session: string }) => Session({ id: session });
const onSession = remote("app.placed", { version: 1, params: { session: str() }, hostAddress, variants });
const onServer = remote("app.placed", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants,
});
expect(onSession.fingerprint).toBe(onServer.fingerprint);
expect(contractData(onSession).hostAddress).toBe(hostAddress);
const next = evolve(onSession, (v) => v.addState("v", "m", int()));
expect(contractData(next).hostAddress).toBe(hostAddress);
});
it("is typed by the contract's params", () => {
@ -73,7 +77,7 @@ describe("placement", () => {
version: 1,
params: { session: str() },
// @ts-expect-error: the params have no `id`
placement: ({ id }) => Session({ id }),
hostAddress: ({ id }) => Session({ id }),
variants,
});
});

View file

@ -1,6 +1,20 @@
import Type from "typebox";
import { describe, expect, it } from "vitest";
import { arr, bool, bytes, int, num, obj, oneOf, opt, remote, str } from "../../../src/next/contracts/index.ts";
import {
arr,
bool,
bytes,
hostKind,
int,
num,
obj,
oneOf,
opt,
remote,
str,
} from "../../../src/next/contracts/index.ts";
const Server = hostKind("server");
const variants = { a: { state: { s: str() } } };
@ -15,16 +29,17 @@ describe("params policy", () => {
c: bool(),
d: oneOf("chat", "agent"),
},
hostAddress: () => Server(),
variants,
}),
).not.toThrow();
});
it("refuses optional fields, in the types and at declaration", () => {
// @ts-expect-error: params fields are required
expect(() => remote("n", { version: 1, params: { a: opt(str()) }, variants })).toThrow(
"n.params.a: a param is a required str(), int(), bool() or oneOf(...) of strings",
);
expect(() =>
// @ts-expect-error: params fields are required
remote("n", { version: 1, params: { a: opt(str()) }, hostAddress: () => Server(), variants }),
).toThrow("n.params.a: a param is a required str(), int(), bool() or oneOf(...) of strings");
});
it.each([
@ -36,6 +51,8 @@ describe("params policy", () => {
["defaults", Type.String({ default: "x" })],
])("refuses %s, in the types and at declaration", (_case, schema) => {
// @ts-expect-error: not a params field schema
expect(() => remote("n", { version: 1, params: { p: schema }, variants })).toThrow("n.params.p: a param is");
expect(() => remote("n", { version: 1, params: { p: schema }, hostAddress: () => Server(), variants })).toThrow(
"n.params.p: a param is",
);
});
});

View file

@ -15,7 +15,7 @@ import {
} from "../../../src/next/contracts/index.ts";
import { isChordError } from "../../../src/next/data/index.ts";
import { bindProvider, type CheckedArgs, checkArgs, type Value } from "../../../src/next/data/internal.ts";
import { counted, expectCollected } from "../support.ts";
import { counted, expectCollected, Server } from "../support.ts";
const Note = obj({ text: str() });
const NoteV2 = obj({ text: str(), tag: str() });
@ -23,6 +23,7 @@ const NoteV2 = obj({ text: str(), tag: str() });
const NotesV1 = remote("notes", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: {
editor: {
state: { notes: arr(Note) },

View file

@ -78,7 +78,7 @@ describe("protocol table", () => {
const pinned = {
version: 1,
reasons: {
invalid: ["args", "params", "member", "placement", "reentrant", "read_after_bulk", "cycle"],
invalid: ["args", "params", "member", "host_address", "reentrant", "read_after_bulk", "cycle"],
contract_mismatch: ["updating"],
forbidden: ["revoked"],
gone: [],

View file

@ -20,7 +20,7 @@ describe("failure causes", () => {
});
it("the kind comes from the stage, never from the error code", () => {
const chord = invalid("placement", "wrong host");
const chord = invalid("host_address", "wrong host");
expect(openFailure(chord, "open")).toEqual({ kind: "transient", error: chord });
expect(openFailure(broken(chord), "open")).toEqual({ kind: "broken", error: chord });
});

View file

@ -5,11 +5,12 @@ import { bool, int, local, oneOf, remote, str } from "../../../src/next/contract
import { isChordError } from "../../../src/next/data/index.ts";
import { checkParams, identityKey } from "../../../src/next/data/internal.ts";
import { validatorFor } from "../../../src/next/data/validator.ts";
import { counted, expectCollected } from "../support.ts";
import { counted, expectCollected, Server } from "../support.ts";
const Session = remote("session", {
version: 1,
params: { id: str({ min: 1 }), mode: oneOf("chat", "agent"), page: int({ min: 0 }), open: bool() },
hostAddress: () => Server(),
variants: { owner: {} },
});

View file

@ -3,7 +3,7 @@
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/next/examples/08-remote.ts
import { BACKGROUND_CONTEXT } from "../../../src/context/index.ts";
import { arr, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { arr, hostKind, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { defineState } from "../../../src/next/documents/index.ts";
import { createHost, defineFacet } from "../../../src/next/host/index.ts";
import { memoryLinkPair } from "../../../src/next/link/index.ts";
@ -17,10 +17,14 @@ type P = { kind: "owner" } | { kind: "guest"; session: string } | { kind: "serve
const Note = obj({ text: str(), author: str() });
const PublicNote = obj({ text: str() });
// The server's host kind: every remote contract names the address of the host serving its objects.
const Server = hostKind("server");
// A remote contract: one object per (type, params, principal key), in the variant its factory chooses.
const Notes = remote("example.notes", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: {
editor: { state: { notes: arr(Note), count: int() }, methods: { add: method(obj({ text: str() })) } },
reader: { state: { notes: arr(PublicNote), count: int() } },
@ -68,8 +72,12 @@ const facet = defineFacet<P>((host) => {
});
});
// 1. The server: it acts as `server` for its own requests.
const server = createHost<P>({ principal: { kind: "server" }, onError: (error) => console.error("server:", error) });
// 1. The server, at its address: it acts as `server` for its own requests.
const server = createHost<P>({
address: Server(),
principal: { kind: "server" },
onError: (error) => console.error("server:", error),
});
await server.load([{ name: "@example/notes", facet }], context);
// 2. A client (a tab): no principal of its own. The entry point authenticated it as a guest of S1 and stamps that on

View file

@ -3,7 +3,7 @@
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/next/examples/09-calls.ts
import { BACKGROUND_CONTEXT, withCancel } from "../../../src/context/index.ts";
import { bytes, int, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { bytes, hostKind, int, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { isChordError, PluginError } from "../../../src/next/data/index.ts";
import { createHost, defineFacet } from "../../../src/next/host/index.ts";
import { memoryLinkPair } from "../../../src/next/link/index.ts";
@ -12,9 +12,13 @@ const context = BACKGROUND_CONTEXT;
type P = { kind: "owner" } | { kind: "server" };
// The server's host kind: a remote contract's `hostAddress` names the host serving its objects.
const Server = hostKind("server");
const Files = remote("example.files", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
owner: {
methods: {
@ -58,7 +62,11 @@ const facet = defineFacet<P>((host) => {
}));
});
const server = createHost<P>({ principal: { kind: "server" }, onError: (error) => console.error("server:", error) });
const server = createHost<P>({
address: Server(),
principal: { kind: "server" },
onError: (error) => console.error("server:", error),
});
await server.load([{ name: "@example/files", facet }], context);
const [up, down] = memoryLinkPair();
server.accept(up, { principal: { kind: "owner" } }, context);

View file

@ -3,7 +3,7 @@
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/next/examples/10-drops.ts
import { BACKGROUND_CONTEXT, type Context } from "../../../src/context/index.ts";
import { arr, int, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { arr, hostKind, int, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { defineState } from "../../../src/next/documents/index.ts";
import { createHost, defineFacet, type Host } from "../../../src/next/host/index.ts";
import { type Link, memoryLinkPair } from "../../../src/next/link/index.ts";
@ -12,9 +12,13 @@ const context = BACKGROUND_CONTEXT;
type P = { kind: "owner" } | { kind: "server" };
// The server's host kind: a remote contract's `hostAddress` names the host serving its objects.
const Server = hostKind("server");
const Notes = remote("example.notes", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: {
editor: {
state: { notes: arr(str()) },
@ -47,7 +51,11 @@ const facet = defineFacet<P>((host) => {
);
});
const server = createHost<P>({ principal: { kind: "server" }, onError: (error) => console.error("server:", error) });
const server = createHost<P>({
address: Server(),
principal: { kind: "server" },
onError: (error) => console.error("server:", error),
});
await server.load([{ name: "@example/notes", facet }], context);
const client = createHost<P>({ onError: (error) => console.error("client:", error) });
await client.load([], context);

View file

@ -1,4 +1,4 @@
// Routes: host kinds and addresses, a contract's placement, a server routing a session's objects to an in-process
// Routes: host kinds and addresses, a contract's host address, a server routing a session's objects to an in-process
// worker (one `resolve` per address, `authorize` per principal), a guest's `ctx.get` on the server reaching the worker
// as the guest, and a worker that drops and is resolved again.
// Run from packages/chord:
@ -26,7 +26,7 @@ const Session = hostKind("session", { id: str() });
const Notes = remote("example.notes", {
version: 1,
params: { session: str() },
placement: ({ session }) => Session({ id: session }),
hostAddress: ({ session }) => Session({ id: session }),
variants: {
editor: {
state: { notes: arr(str()) },
@ -36,10 +36,11 @@ const Notes = remote("example.notes", {
},
});
// The lobby lives on the server (no placement): its handler gets notes for whoever calls it.
// The lobby lives on the server: its handler gets notes for whoever calls it.
const Lobby = remote("example.lobby", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { visitor: { methods: { peek: method(obj({ session: str() }), str()) } } },
});

View file

@ -30,7 +30,7 @@ const SessionInfo = local("example.session-info", { version: 1, params: {}, memb
const Notes = remote("example.notes", {
version: 1,
params: { session: str() },
placement: ({ session }) => Session({ id: session }),
hostAddress: ({ session }) => Session({ id: session }),
variants: {
owner: { state: { info: Init, notes: arr(str()) }, methods: { add: method(obj({ text: str() }), int()) } },
},

View file

@ -28,7 +28,7 @@ const Reply = local("example.reply", {
const View = remote("example.view", {
version: 1,
params: { session: str() },
placement: ({ session }) => Session({ id: session }),
hostAddress: ({ session }) => Session({ id: session }),
variants: { owner: { state: { reply: obj({ text: str() }) } } },
});

View file

@ -33,7 +33,7 @@ const Session = hostKind("session", { id: str() });
const Notes = remote("example.notes", {
version: 1,
params: { session: str() },
placement: ({ session }) => Session({ id: session }),
hostAddress: ({ session }) => Session({ id: session }),
variants: {
all: { state: { notes: arr(str()) }, methods: { add: method(obj({ text: str() })), follow: method() } },
},

View file

@ -6,7 +6,7 @@
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/next/examples/16-presence-and-activity.ts
import { BACKGROUND_CONTEXT } from "../../../src/context/index.ts";
import { int, method, remote, str } from "../../../src/next/contracts/index.ts";
import { hostKind, int, method, remote, str } from "../../../src/next/contracts/index.ts";
import { createHost, defineFacet } from "../../../src/next/host/index.ts";
import { memoryLinkPair } from "../../../src/next/link/index.ts";
@ -14,15 +14,19 @@ const context = BACKGROUND_CONTEXT;
type P = { kind: "user"; name: string } | { kind: "server" };
// The server's host kind: a remote contract's `hostAddress` names the host serving its objects.
const Server = hostKind("server");
/** A shared document view: who else is looking (`viewers`), and a slow method. */
const Doc = remote("example.doc", {
version: 1,
params: { id: str() },
hostAddress: () => Server(),
variants: { v: { state: { viewers: int() }, methods: { think: method(undefined, int()) } } },
});
// 1. The server serves `Doc`; its factory derives the viewer count from `host.references(Doc)` (on the facet's host).
const server = createHost<P>({ principal: { kind: "server" }, onError: console.error });
const server = createHost<P>({ address: Server(), principal: { kind: "server" }, onError: console.error });
const facet = defineFacet<P>((host) => {
host.serve(
Doc,

View file

@ -9,7 +9,7 @@
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/next/examples/17-limits-and-compression.ts
import { BACKGROUND_CONTEXT } from "../../../src/context/index.ts";
import { arr, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { arr, hostKind, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts";
import { isChordError } from "../../../src/next/data/index.ts";
import { defineState } from "../../../src/next/documents/index.ts";
import { createHost, defineFacet, type Host } from "../../../src/next/host/index.ts";
@ -29,10 +29,14 @@ const Store = local("example.store", {
members: { entries: arr(Entry), append: method(Entry) },
});
// The server's host kind: a remote contract's `hostAddress` names the host serving its objects.
const Server = hostKind("server");
/** What a tab holds: the conversation's entries, and appending. */
const Conversation = remote("example.conversation", {
version: 1,
params: { id: str() },
hostAddress: () => Server(),
variants: { v: { state: { entries: arr(Entry) }, methods: { append: method(Entry) } } },
});
@ -78,7 +82,12 @@ function counted(link: Link, bytes: { received: number }): Link {
* `limits`; the tab's own link to the server has `tabLimits`.
*/
async function setUp(compression: Compression | undefined, limits?: PartLimits, tabLimits?: PartLimits) {
const server = createHost<P>({ principal: { kind: "server" }, compression, onError: console.error });
const server = createHost<P>({
address: Server(),
principal: { kind: "server" },
compression,
onError: console.error,
});
await server.load([{ name: "@example/conversation", facet }], context);
const tab = createHost<P>({ onError: console.error });
await tab.load([], context);
@ -168,6 +177,7 @@ for (const compression of [undefined, nodeCompression]) {
// child, would refuse a compressed part. Its 100 KB argument arrives as JSON, within the server's default limits.
{
const server = createHost<P>({
address: Server(),
principal: { kind: "server" },
compression: nodeCompression,
onError: console.error,

View file

@ -6,7 +6,7 @@
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/next/examples/19-jobs.ts
import { BACKGROUND_CONTEXT, type Context } from "../../../src/context/index.ts";
import { arr, int, local, method, obj, remote, type Static, str } from "../../../src/next/contracts/index.ts";
import { arr, hostKind, int, local, method, obj, remote, type Static, str } from "../../../src/next/contracts/index.ts";
import { defineState } from "../../../src/next/documents/index.ts";
import { createHost, defineFacet, sleep } from "../../../src/next/host/index.ts";
import { defineJob } from "../../../src/next/jobs/index.ts";
@ -56,10 +56,14 @@ const ChatStore = local("example.chat-store", {
},
});
// The server's host kind: a remote contract's `hostAddress` names the host serving its objects.
const Server = hostKind("server");
/** Per-principal views: the owner runs shells and sees their output, a guest sees which shells run. */
const Chat = remote("example.chat", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
owner: {
state: { entries: arr(str()), shells: arr(Shell) },
@ -170,7 +174,11 @@ const facet = defineFacet<P>((host) => {
});
// 2. A server and two tabs, the owner's and a guest's.
const server = createHost<P>({ principal: { kind: "server" }, onError: (error) => console.error(error) });
const server = createHost<P>({
address: Server(),
principal: { kind: "server" },
onError: (error) => console.error(error),
});
await server.load([{ name: "@example/chat", facet }], context);
async function tab(principal: P) {
const host = createHost<P>({ onError: (error) => console.error(error) });

View file

@ -6,9 +6,14 @@ import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
import { bool, int, local, method, remote } from "../../../src/next/contracts/index.ts";
import { createHost, defineFacet } from "../../../src/next/host/index.ts";
import { constant } from "../../../src/next/state/index.ts";
import { fakeTimers, flushed, framesOf, linked, loaded, Notes, serveNotes, transported } from "../support.ts";
import { fakeTimers, flushed, framesOf, linked, loaded, Notes, Server, serveNotes, transported } from "../support.ts";
const Kept = remote("app.kept", { version: 1, params: {}, variants: { v: { state: { n: int() } } } });
const Kept = remote("app.kept", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
describe("host.activity", () => {
it("counts holds arriving over links and served here, not this host's own references", async () => {

View file

@ -21,7 +21,17 @@ import { TableChanges } from "../../../src/next/store/table-changes.ts";
import { evolveTable } from "../../../src/next/store/table-evolve.ts";
import { TableHandle } from "../../../src/next/store/table-handle.ts";
import { prepareTables } from "../../../src/next/store/table-migration.ts";
import { failures, fakeTimers, flushed, loaded, memoryStore, metered, rejection, tableOwner } from "../support.ts";
import {
failures,
fakeTimers,
flushed,
loaded,
memoryStore,
metered,
rejection,
Server,
tableOwner,
} from "../support.ts";
const List = defineState("list", { schema: obj({ items: arr(str()) }), version: 1, initial: () => ({ items: [] }) });
const Page = local("app.page", { version: 1, params: { id: int() }, members: { label: str(), slow: method() } });
@ -565,6 +575,7 @@ describe("remote variants", () => {
const Two = remote("app.two", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { full: { state: { n: int() }, methods: { bump: method() } }, view: { state: { n: int() } } },
});
const owner = { kind: "owner" } as const;
@ -572,7 +583,11 @@ describe("remote variants", () => {
/** A host acting as `owner` whose factory for `Two` returns `implementation`. */
async function two(implementation: unknown) {
const errors: HostError[] = [];
const host = createHost<typeof owner>({ principal: owner, onError: (error) => errors.push(error) });
const host = createHost<typeof owner>({
address: Server(),
principal: owner,
onError: (error) => errors.push(error),
});
const facet = defineFacet<typeof owner>((h) => h.serve(Two, () => implementation as never));
await host.load([{ name: "@test/app", facet }], context);
return { host, errors };

View file

@ -5,7 +5,16 @@ import { withLinkedAbortSignal } from "../../../src/next/host/ctx.ts";
import type { ChordHost } from "../../../src/next/host/host.ts";
import type { Ctx, Host, Reference } from "../../../src/next/host/index.ts";
import { constant } from "../../../src/next/state/index.ts";
import { flushed, incarnationOf, linked, loaded, rawParent, rejection, type TestPrincipal } from "../support.ts";
import {
flushed,
incarnationOf,
linked,
loaded,
rawParent,
rejection,
Server,
type TestPrincipal,
} from "../support.ts";
const Item = local("app.item", { version: 1, params: { id: int() }, members: { id: int() } });
const Desk = local("app.desk", {
@ -111,7 +120,12 @@ describe("ctx", () => {
});
it("a ctx.get still waiting when its handler settles gives up: its reference is released at once", async () => {
const Far = remote("app.far", { version: 1, params: {}, variants: { v: { state: { n: int() } } } });
const Far = remote("app.far", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
const Starter = local("app.starter", { version: 1, params: {}, members: { start: method() } });
let pending: Promise<unknown> | undefined;
const { host } = await loaded((h) =>
@ -146,6 +160,7 @@ describe("local handlers act as the host (§4, audit)", () => {
const Gate = remote("app.gate", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { methods: { who: method(undefined, str()), through: method(undefined, str()) } } },
});
const who = (ctx: Ctx<TestPrincipal>) => `${ctx.principal?.kind ?? "none"} ${ctx.incarnation}`;

View file

@ -31,6 +31,8 @@ import {
partValue,
RawPeer,
rejection,
SERVER_ADDRESS,
Server,
Session,
serveNotes,
type TestPrincipal,
@ -482,13 +484,24 @@ describe("getters", () => {
forEachCodec(() => {
describe("principals and where a reference goes", () => {
const Who = remote("app.who", { version: 1, params: { id: int() }, variants: { v: { state: { who: str() } } } });
const Outer = remote("app.outer", { version: 1, params: {}, variants: { v: { state: { who: str() } } } });
const Who = remote("app.who", {
version: 1,
params: { id: int() },
hostAddress: () => Server(),
variants: { v: { state: { who: str() } } },
});
const Outer = remote("app.outer", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { who: str() } } },
});
const Inner = local("app.inner", { version: 1, params: { id: int() }, members: { who: str() } });
const Desk = local("app.desk", { version: 1, params: {}, members: { who: method(undefined, str()) } });
const Gate = remote("app.gate", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { methods: { who: method(undefined, str()) } } },
});
type P = TestPrincipal;
@ -518,6 +531,12 @@ forEachCodec(() => {
await expect(loading).rejects.toThrow("app.who: a host without a principal serves only local types");
});
it("a host without an address serves no remote type, whatever its principal", async () => {
const host = createHost<P>({ principal: { kind: "server" }, onError: () => {} });
const loading = host.load([{ name: "@test/app", facet: defineFacet<P>((h) => serveAll(h, [])) }], context);
await expect(loading).rejects.toThrow("app.who: a host without an address serves only local types");
});
it("ctx.get acts as ctx.principal: a guest's call gets the guest's view; a local handler's acts as the host", async () => {
const { server, client } = await linked((h) => serveAll(h, []), { child: { kind: "guest", session: "S1" } });
expect(await (await client.get(Gate, {}, context)).who(context)).toBe("guest");
@ -533,6 +552,7 @@ forEachCodec(() => {
it("identity includes the principal key: equal keys share one object", async () => {
const factories: string[] = [];
const host = createHost<P>({
address: Server(),
principal: { kind: "guest", session: "S1" },
principalKey: (principal) => principal.kind,
onError: () => {},
@ -549,6 +569,7 @@ forEachCodec(() => {
version: Who.version,
fingerprint: Who.fingerprint,
params: jsonPart({ id: 1 }),
address: SERVER_ADDRESS,
});
await peer.next("bound");
expect(factories).toEqual(["1 guest"]);
@ -562,6 +583,7 @@ forEachCodec(() => {
version: Who.version,
fingerprint: Who.fingerprint,
params: jsonPart({ id: 1 }),
address: SERVER_ADDRESS,
});
await owner.next("bound");
expect(factories).toEqual(["1 guest", "1 owner"]);
@ -569,7 +591,12 @@ forEachCodec(() => {
it("remote objects with empty params are not activated by load; they live while referenced", async () => {
const factories: string[] = [];
const Empty = remote("app.empty", { version: 1, params: {}, variants: { v: { state: { n: int() } } } });
const Empty = remote("app.empty", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
const { server } = await linked((h) =>
h.serve(Empty, (_, principal) => {
factories.push(principal.kind);
@ -661,7 +688,7 @@ describe("acquiring: hold and get", () => {
const offline = { code: "unavailable", message: "app.svc(1): offline" };
it("invalid params and a local type not served here: at once", async () => {
const host = createHost<TestPrincipal>({ ...noErrors, principal: { kind: "server" } });
const host = createHost<TestPrincipal>({ address: Server(), ...noErrors, principal: { kind: "server" } });
await host.load([{ name: "@test/app", facet: defineFacet<TestPrincipal>((h) => void serveNotes(h)) }], context);
const params = { code: "invalid", reason: "params" };
expect(() => host.hold(Notes, { session: 1 } as never, context)).toThrow("params");
@ -814,8 +841,8 @@ forEachCodec(() => {
return { host, child: new RawPeer(b), parent, errors };
}
/** Sends a hold of `PlacedNotes` (or `type`) for `session`, naming `address` when given. */
function holdOf(peer: RawPeer, session: string, address?: string, type: string = PlacedNotes.name, hold = 0) {
/** Sends a hold of `PlacedNotes` (or `type`) for `session`, naming the session `address`. */
function holdOf(peer: RawPeer, session: string, address: string, type: string = PlacedNotes.name, hold = 0) {
peer.send({
kind: "hold",
hold,
@ -823,13 +850,13 @@ forEachCodec(() => {
version: PlacedNotes.version,
fingerprint: PlacedNotes.fingerprint,
params: jsonPart({ session }),
...(address === undefined ? {} : { address: { kind: "session", params: { id: address } } }),
address: { kind: "session", params: { id: address } },
});
}
const misplaced = {
code: "invalid",
reason: "placement",
reason: "host_address",
message: "app.placed-notes: placed on session, this host is session|S1",
};
@ -839,7 +866,7 @@ forEachCodec(() => {
expect((await child.next("bound")).holds[0]).toMatchObject({ hold: 0 });
});
it("refuses a hold served here whose params place it elsewhere, whatever address it names: invalid/placement (race 5)", async () => {
it("refuses a hold served here whose params place it elsewhere, whatever address it names: invalid/host_address (race 5)", async () => {
const { child } = await worker();
holdOf(child, "S2", "S1");
expect(await child.next("failed")).toMatchObject({
@ -847,7 +874,7 @@ forEachCodec(() => {
cause: { kind: "broken", error: { kind: "chord", error: misplaced } },
});
// A refused hold is kept (a retry starts it again): the next hold takes the next id.
holdOf(child, "S2", undefined, PlacedNotes.name, 1);
holdOf(child, "S2", "S2", PlacedNotes.name, 1);
expect(await child.next("failed")).toMatchObject({
hold: 1,
cause: { kind: "broken", error: { kind: "chord", error: misplaced } },
@ -860,7 +887,7 @@ forEachCodec(() => {
expect((await child.next("bound")).holds[0]).toMatchObject({ hold: 0 });
});
it("sends a reference made here whose placement is another host over the parent link, though it serves the type", async () => {
it("sends a reference made here whose host address is another host over the parent link, though it serves the type", async () => {
const { host, parent } = await worker({ parent: true });
const elsewhere = host.hold(PlacedNotes, { session: "S2" }, context);
const hold = await parent!.next("hold");
@ -871,7 +898,7 @@ forEachCodec(() => {
it("sends a hold it cannot serve or route over the parent link, unparsed", async () => {
const { child, parent } = await worker({ parent: true });
holdOf(child, "S9", undefined, "app.elsewhere");
holdOf(child, "S9", "S9", "app.elsewhere");
const relayed = await parent!.next("hold");
expect(relayed).toMatchObject({ type: "app.elsewhere" });
expect(partValue(relayed.params)).toEqual({ session: "S9" });
@ -879,7 +906,7 @@ forEachCodec(() => {
it("never sends a hold from the parent back to it: unavailable (race 8)", async () => {
const { parent } = await worker({ parent: true });
holdOf(parent!, "S9", undefined, "app.elsewhere");
holdOf(parent!, "S9", "S9", "app.elsewhere");
expect(await parent!.next("failed")).toMatchObject({
hold: 0,
cause: {
@ -892,25 +919,25 @@ forEachCodec(() => {
it("refuses a hold it cannot serve or route without a parent link: unavailable", async () => {
const { child } = await worker();
holdOf(child, "S9", undefined, "app.elsewhere");
holdOf(child, "S9", "S9", "app.elsewhere");
expect(await child.next("failed")).toMatchObject({
cause: { kind: "broken", error: { kind: "chord", error: { code: "unavailable" } } },
});
});
it("fails a reference broken with what its placement throws", async () => {
it("fails a reference broken with what its hostAddress throws", async () => {
const Throwing = remote("app.throwing", {
version: 1,
params: {},
placement: () => {
throw new Error("placement threw");
hostAddress: () => {
throw new Error("hostAddress threw");
},
variants: { v: { state: { n: int() } } },
});
const client = createHost<TestPrincipal>(noErrors);
await client.load([], context);
expect(await rejection(client.get(Throwing, {}, context))).toMatchObject({
message: "app.throwing: placement threw",
message: "app.throwing: hostAddress threw",
});
expect(client.hold(Throwing, {}, context).state.value).toMatchObject({ kind: "failed" });
});

View file

@ -22,6 +22,7 @@ import {
loaded,
memoryStore,
metered,
Server,
transported,
valuesOf,
} from "../support.ts";
@ -324,7 +325,12 @@ describe("re-binding when a new attempt goes live", () => {
describe("subscribers", () => {
it("a hold over a link on a remote object re-exposing a local member gets frames again after the local retry", async () => {
const Count = local("app.count", { version: 1, params: {}, members: { n: obj({ n: int() }), add: method() } });
const View = remote("app.view", { version: 1, params: {}, variants: { v: { state: { n: obj({ n: int() }) } } } });
const View = remote("app.view", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: obj({ n: int() }) } } },
});
const Counter = defineState("counter", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) });
const selves: Self[] = [];
const { server, client } = await linked((h) => {
@ -406,10 +412,11 @@ describe("a member the new attempt's variant lacks", () => {
const Two = remote("app.two", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { full: { state: { n: int() } }, view: { state: { m: int() } } },
});
const owner = { kind: "owner" } as const;
const host = createHost<typeof owner>({ principal: owner, onError: () => {} });
const host = createHost<typeof owner>({ address: Server(), principal: owner, onError: () => {} });
const selves: Self[] = [];
const facet = defineFacet<typeof owner>((h) =>
h.serve(Two, (_, _principal, self) => {

View file

@ -26,6 +26,8 @@ import {
rejection,
relayed,
restartable,
SERVER_ADDRESS,
Server,
Session,
StoreRestartable,
storeRestartable,
@ -705,14 +707,19 @@ describe("linger", () => {
/** Every object attempted in this file, weakly: the objects of the cycles among them. */
const attempted = instancesOf(ChordObject, "attempt");
/** On the server (no placement): its activation holds `Far` on the session's worker. */
const Near = remote("app.near", { version: 1, params: { session: str() }, variants: { v: { state: { n: int() } } } });
/** On the server: its activation holds `Far` on the session's worker. */
const Near = remote("app.near", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
/** On the session's worker: its activation holds `Near` back on the server, or, with `local`, `Middle`. */
const Far = remote("app.far", {
version: 1,
params: { session: str() },
placement: ({ session }) => Session({ id: session }),
hostAddress: ({ session }) => Session({ id: session }),
variants: { v: { state: { n: int() } } },
});
@ -720,6 +727,7 @@ const Far = remote("app.far", {
const Middle = remote("app.middle", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
@ -730,7 +738,7 @@ const Hub = local("app.hub", { version: 1, params: { session: str() }, members:
const Loop = remote("app.loop", {
version: 1,
params: { here: str(), there: str() },
placement: ({ here }) => Session({ id: here }),
hostAddress: ({ here }) => Session({ id: here }),
variants: { v: { state: { n: int() } } },
});
@ -910,7 +918,15 @@ function farHold(hold: number): Extract<Message, { kind: "hold" }> {
/** A hold of `Near` for S1, as a worker sends it to the server. */
function nearHold(hold: number): Extract<Message, { kind: "hold" }> {
const { name: type, version, fingerprint } = Near;
return { kind: "hold", hold, type, version, fingerprint, params: jsonPart({ session: "S1" }) };
return {
kind: "hold",
hold,
type,
version,
fingerprint,
params: jsonPart({ session: "S1" }),
address: SERVER_ADDRESS,
};
}
describe("a failed activation's cleanup (audit)", () => {

View file

@ -22,6 +22,8 @@ import {
PlacedNotes,
RawPeer,
relayed,
SERVER_ADDRESS,
Server,
serveNotes,
type TestPrincipal,
testCodec,
@ -84,9 +86,10 @@ describe("host.references", () => {
const Shown = remote("app.shown", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: { v: { state: { here: int() } } },
});
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const server = createHost<TestPrincipal>({ address: Server(), principal: { kind: "server" }, onError: () => {} });
await server.load(
[
{
@ -130,9 +133,10 @@ describe("host.references", () => {
const Viewed = remote("app.viewed", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: { v: { state: { count: int(), viewers: int() } } },
});
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const server = createHost<TestPrincipal>({ address: Server(), principal: { kind: "server" }, onError: () => {} });
const facet = defineFacet<TestPrincipal>((host) => {
serveNotes(host);
host.serve(Viewed, ({ session }, _principal, self) => {
@ -151,7 +155,15 @@ describe("host.references", () => {
server.accept(end, { principal: { kind: "owner" }, codec: testCodec() }, context);
const peer = new RawPeer(b);
const { name: type, version, fingerprint } = Viewed;
peer.send({ kind: "hold", hold: 0, type, version, fingerprint, params: jsonPart({ session: "S1" }) });
peer.send({
kind: "hold",
hold: 0,
type,
version,
fingerprint,
params: jsonPart({ session: "S1" }),
address: SERVER_ADDRESS,
});
return peer;
};
const drop = { armed: false };
@ -176,9 +188,10 @@ describe("host.references", () => {
const Viewed = remote("app.viewed", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { viewers: int() } } },
});
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const server = createHost<TestPrincipal>({ address: Server(), principal: { kind: "server" }, onError: () => {} });
const facet = defineFacet<TestPrincipal>((host) => {
host.serve(
Viewed,
@ -195,13 +208,13 @@ describe("host.references", () => {
server.accept(closingOnSend(a, drop), { principal: { kind: "owner" }, codec: testCodec() }, context);
const peer = new RawPeer(b);
const { name: type, version, fingerprint } = Viewed;
peer.send({ kind: "hold", hold: 0, type, version, fingerprint, params: "{}" });
peer.send({ kind: "hold", hold: 0, type, version, fingerprint, params: "{}", address: SERVER_ADDRESS });
await peer.next("bound");
peer.send({ kind: "ack" });
await transported();
// The second hold's presence entry changes the first's state: sending it closes the link.
drop.armed = true;
peer.send({ kind: "hold", hold: 1, type, version, fingerprint, params: "{}" });
peer.send({ kind: "hold", hold: 1, type, version, fingerprint, params: "{}", address: SERVER_ADDRESS });
await transported();
expect(peer.closed).toBe(true);
expect(server.references(Viewed).value).toEqual([]);
@ -210,8 +223,13 @@ describe("host.references", () => {
});
it("completes a release's bookkeeping before its departure publishes: a link closing then leaves no lingering", async () => {
const Viewed = remote("app.viewed", { version: 1, params: {}, variants: { v: { state: { viewers: int() } } } });
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const Viewed = remote("app.viewed", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { viewers: int() } } },
});
const server = createHost<TestPrincipal>({ address: Server(), principal: { kind: "server" }, onError: () => {} });
const facet = defineFacet<TestPrincipal>((host) => {
host.serve(
Viewed,
@ -229,7 +247,7 @@ describe("host.references", () => {
const peer = new RawPeer(b);
const { name: type, version, fingerprint } = Viewed;
for (const hold of [0, 1]) {
peer.send({ kind: "hold", hold, type, version, fingerprint, params: "{}" });
peer.send({ kind: "hold", hold, type, version, fingerprint, params: "{}", address: SERVER_ADDRESS });
await peer.next("bound");
peer.send({ kind: "ack" });
}
@ -248,6 +266,7 @@ describe("host.references", () => {
it("removes a bulk departure with one list and one splice op per entry, in one frame (a revoke of many holds)", async () => {
const server = createHost<TestPrincipal, { session: string }>({
address: Server(),
principal: { kind: "server" },
revokedBy: (principal) => (principal.kind === "guest" ? [{ session: principal.session }] : []),
onError: () => {},
@ -258,7 +277,15 @@ describe("host.references", () => {
const peer = new RawPeer(b);
const { name: type, version, fingerprint } = Notes;
for (let hold = 0; hold < 4; hold++) {
peer.send({ kind: "hold", hold, type, version, fingerprint, params: jsonPart({ session: "S1" }) });
peer.send({
kind: "hold",
hold,
type,
version,
fingerprint,
params: jsonPart({ session: "S1" }),
address: SERVER_ADDRESS,
});
}
await transported();
const references = server.references(Notes);
@ -285,7 +312,12 @@ describe("host.references", () => {
});
it("has no entries for lingering objects", async () => {
const Kept = remote("app.kept", { version: 1, params: {}, variants: { v: { state: { n: int() } } } });
const Kept = remote("app.kept", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
const { server, client } = await linked((h) =>
h.serve(Kept, () => ({ variant: "v", members: { n: constant(1) } }), { linger: 60_000 }),
);

View file

@ -29,6 +29,7 @@ import {
memoryStore,
Notes,
rejection,
Server,
settledHeap,
} from "../support.ts";
@ -338,12 +339,13 @@ describe("remote references", () => {
const Two = remote("app.two", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { full: { state: { n: int() }, methods: { bump: method() } }, view: { state: { m: int() } } },
});
const Holder = local("app.holder", { version: 1, params: {}, members: { n: int() } });
const seen: unknown[] = [];
const owner = { kind: "owner" } as const;
const host = createHost<typeof owner>({ principal: owner, onError: () => {} });
const host = createHost<typeof owner>({ address: Server(), principal: owner, onError: () => {} });
const facet = defineFacet<typeof owner>((h) => {
h.serve(Two, () => ({ variant: "view", members: { m: constant(1) } }));
h.serve(Holder, (_, self) => {
@ -369,6 +371,7 @@ describe("remote reference types", () => {
const Two = remote("app.two", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { full: { state: { n: int() }, methods: { bump: method() } }, view: { state: { m: int() } } },
});
type Ref = Reference<typeof Two>;
@ -384,6 +387,7 @@ describe("remote reference types", () => {
const Two = remote("app.two", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { full: { state: { n: int() }, methods: { bump: method() } }, view: { state: { m: int() } } },
});
expectTypeOf<Held<typeof Two>["variant"]>().toEqualTypeOf<"full" | "view" | undefined>();

View file

@ -19,6 +19,8 @@ import {
rawParent,
rawWorkers,
relayed,
SERVER_ADDRESS,
Server,
Session,
serveNotes,
type TestPrincipal,
@ -50,9 +52,10 @@ const Barrier = local("app.barrier", { version: 1, params: {}, members: { n: int
/** A guest's variant: its `wait` method's index. */
const { wait } = wireNames(Notes, "reader").methods as { wait: number };
/** A worker of session S1 serving `Notes` (as `serveNotes`), its parent a raw peer whose holds name their principal. */
/** A worker of session S1 serving `Notes` (as `serveNotes`, at the address `Notes` names), its parent a raw peer whose holds name their principal. */
async function worker(setup: (host: FacetHost<TestPrincipal>) => void = (h) => void serveNotes(h)) {
const host = createHost<TestPrincipal, Revocation>({
address: Server(),
principal: { kind: "worker", session: "S1" },
revokedBy,
onError: () => {},
@ -71,6 +74,7 @@ function holdAs(peer: RawPeer, hold: number, principal: TestPrincipal, Type: typ
version: Type.version,
fingerprint: Type.fingerprint,
params: jsonPart({ session: "S1" }),
address: SERVER_ADDRESS,
principal,
});
}
@ -79,6 +83,7 @@ describe("revocation keys", () => {
it("are computed once per principal object, compared as canonical JSON", async () => {
let calls = 0;
const host = createHost<TestPrincipal, { a: number; b: number }>({
address: Server(),
principal: { kind: "server" },
revokedBy: () => {
calls++;
@ -103,6 +108,7 @@ describe("revocation keys", () => {
it("keep no principal alive (a WeakMap keyed by the principal the host cached them for)", async () => {
const cached: WeakRef<TestPrincipal>[] = [];
const host = createHost<TestPrincipal, Revocation>({
address: Server(),
principal: { kind: "worker", session: "S1" },
revokedBy: (principal) => {
// The principal decoded on this host, the cache's key: not the sender's object.
@ -172,7 +178,12 @@ forEachCodec(() => {
});
it("abandons an activation in progress (race 1): its open's context is aborted, its holders get gone", async () => {
const Slow = remote("app.slow", { version: 1, params: {}, variants: { v: { state: { n: int() } } } });
const Slow = remote("app.slow", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
const aborted: unknown[] = [];
const { host, parent } = await worker((h) =>
h.serve(Slow, (_, _principal, self) => {
@ -196,6 +207,7 @@ forEachCodec(() => {
version: Slow.version,
fingerprint: Slow.fingerprint,
params: "{}",
address: SERVER_ADDRESS,
principal: S1,
});
await transported();
@ -217,6 +229,7 @@ forEachCodec(() => {
// A worker connects before it loads (W6); a load can wait for an activation (the start barrier).
const opened = Promise.withResolvers<void>();
const host = createHost<TestPrincipal, Revocation>({
address: Server(),
principal: { kind: "worker", session: "S1" },
revokedBy,
onError: () => {},
@ -313,6 +326,7 @@ forEachCodec(() => {
describe("links and revoke messages", () => {
it("closes the child links whose principal it revokes, never the parent link", async () => {
const host = createHost<TestPrincipal, Revocation>({
address: Server(),
principal: { kind: "server" },
revokedBy,
onError: () => {},
@ -336,6 +350,7 @@ forEachCodec(() => {
it("ignores a revoke from a child link (race 6)", async () => {
const host = createHost<TestPrincipal, Revocation>({
address: Server(),
principal: { kind: "server" },
revokedBy,
onError: () => {},

View file

@ -12,6 +12,7 @@ import {
PlacedNotes,
rejection,
relayed,
Server,
Session,
serveNotes,
type TestPrincipal,
@ -101,18 +102,18 @@ forEachCodec(() => {
});
});
it("fails a hold broken invalid/placement for an address whose params do not fit the kind", async () => {
it("fails a hold broken invalid/host_address for an address whose params do not fit the kind", async () => {
const Misplaced = remote("app.misplaced", {
version: 1,
params: { n: int() },
// An address of the right kind with params of the wrong type, as a buggy placement could compute.
placement: ({ n }) => ({ kind: "session", params: { id: n }, key: "" }) as never,
// An address of the right kind with params of the wrong type, as a buggy `hostAddress` could compute.
hostAddress: ({ n }) => ({ kind: "session", params: { id: n }, key: "" }) as never,
variants: { v: { state: { n: int() } } },
});
const { tab, resolves } = await relayed();
expect(await rejection(tab.get(Misplaced, { n: 1 }, context))).toMatchObject({
code: "invalid",
reason: "placement",
reason: "host_address",
});
expect(resolves).toEqual([]);
});
@ -322,11 +323,16 @@ forEachCodec(() => {
const Who = remote("app.who", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { methods: { app: method(undefined, str()) } } },
});
const resolved: (string | undefined)[] = [];
let worker: Host<TestPrincipal> | undefined;
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const server = createHost<TestPrincipal>({
address: Server(),
principal: { kind: "server" },
onError: () => {},
});
server.route(
Session,
{

View file

@ -36,6 +36,7 @@ import {
loaded,
Notes,
rawParent,
Server,
serveNotes,
type TestPrincipal,
transported,
@ -789,9 +790,10 @@ describe("on", () => {
const Halves = remote("app.halves", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { half: int() }, methods: { set: method(int()) } } },
});
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const server = createHost<TestPrincipal>({ address: Server(), principal: { kind: "server" }, onError: () => {} });
const serve = defineFacet<TestPrincipal>((h) =>
h.serve(Halves, (_, _principal, self) => {
const doc = self.state(Doc);
@ -844,7 +846,7 @@ describe("on", () => {
});
it("follows ref.state: a reference over a link going loading and live again", async () => {
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const server = createHost<TestPrincipal>({ address: Server(), principal: { kind: "server" }, onError: () => {} });
await server.load([{ name: "@test/app", facet: defineFacet<TestPrincipal>((h) => void serveNotes(h)) }], context);
const Viewer = local("app.viewer", { version: 1, params: { id: int() }, members: {} });
const states: string[] = [];
@ -1069,7 +1071,12 @@ describe("routines and the end", () => {
});
it("act as a remote object's principal", async () => {
const Watcher = remote("app.watcher", { version: 1, params: {}, variants: { v: { state: {} } } });
const Watcher = remote("app.watcher", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: {} } },
});
const seen: unknown[] = [];
const { client } = await linked(
(h) =>

View file

@ -34,6 +34,7 @@ import {
loaded,
metered,
rejection,
Server,
type TestPrincipal,
transported,
} from "../support.ts";
@ -1057,11 +1058,12 @@ describe("keep-alive", () => {
const View = remote("app.view", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { running: arr(unknown()) } } },
});
let job: JobHandle<{ text: string }, string, undefined> | undefined;
let ran = false;
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const server = createHost<TestPrincipal>({ address: Server(), principal: { kind: "server" }, onError: () => {} });
const facet = defineFacet<TestPrincipal>((h) =>
h.serve(View, (_, _principal, self) => {
job = self.job(Echo, {
@ -1157,13 +1159,19 @@ describe("keep-alive", () => {
describe("object end", () => {
type Revocation = { session: string };
const Jobs = remote("app.jobs", { version: 1, params: {}, variants: { v: { state: {} } } });
const Jobs = remote("app.jobs", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: {} } },
});
it("revocation (R11): runs' signals fire with the end's error, the drain waits for them, no finish", async () => {
let job: JobHandle<{ text: string }, string, undefined> | undefined;
const finished: string[] = [];
let reason: unknown;
const server = createHost<TestPrincipal, Revocation>({
address: Server(),
principal: { kind: "server" },
revokedBy: (p) => (p.kind === "guest" ? [{ session: p.session }] : []),
onError: () => {},
@ -1209,6 +1217,7 @@ describe("object end", () => {
const gate = Promise.withResolvers<void>();
const events: string[] = [];
const server = createHost<TestPrincipal, Revocation>({
address: Server(),
principal: { kind: "server" },
revokedBy: (p) => (p.kind === "guest" ? [{ session: p.session }] : []),
onError: () => {},

View file

@ -20,11 +20,18 @@ import {
RawPeer,
rawLinked,
rawProvider,
SERVER_ADDRESS,
Server,
testCodec,
transported,
} from "../support.ts";
const Thing = remote("app.thing", { version: 1, params: {}, variants: { only: { state: { n: int() } } } });
const Thing = remote("app.thing", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { only: { state: { n: int() } } },
});
const hold = {
kind: "hold",
@ -33,6 +40,7 @@ const hold = {
version: 1,
fingerprint: "0000000000000000",
params: "{}",
address: SERVER_ADDRESS,
} as const;
const problems = (errors: readonly HostError[]) =>

View file

@ -21,12 +21,23 @@ function partText(part: Part): Part {
}
const incarnation = "0190f5a2-7c3e-7d4a-9b1c-2d3e4f5a6b7c";
/** The address a hold names: `Server()` on the wire. */
const server = { kind: "server", params: {} };
const error = { kind: "chord", error: { code: "released", message: "gone" } } as const;
/** One message of every kind, with the optional fields both present and absent; parts are JSON text. */
const EVERY_KIND: Message[] = [
{ kind: "hello", protocol: 1, incarnation, calls: 256 },
{ kind: "hold", hold: 300, type: "app.notes", version: 2, fingerprint: "0123456789abcdef", params: '{"id":1}' },
{
kind: "hold",
hold: 300,
type: "app.notes",
version: 2,
fingerprint: "0123456789abcdef",
params: '{"id":1}',
address: server,
},
{
kind: "hold",
hold: 1,
@ -45,6 +56,7 @@ const EVERY_KIND: Message[] = [
version: 1,
fingerprint: "0123456789abcdef",
params: "{}",
address: server,
incarnation,
},
{ kind: "release", hold: 0 },
@ -106,7 +118,16 @@ const EVERY_KIND: Message[] = [
{ kind: "ack" },
{ kind: "ack", origin: 1 },
{ kind: "closed", origin: 300 },
{ kind: "hold", origin: 2, hold: 0, type: "t", version: 1, fingerprint: "0123456789abcdef", params: "{}" },
{
kind: "hold",
origin: 2,
hold: 0,
type: "t",
version: 1,
fingerprint: "0123456789abcdef",
params: "{}",
address: server,
},
{ kind: "release", origin: 2, hold: 3 },
{ kind: "call", origin: 2, call: 1, hold: 3, method: 0, args: "[1]" },
{ kind: "abort", origin: 2, call: 1 },
@ -130,10 +151,20 @@ const EVERY_KIND: Message[] = [
version: 1,
fingerprint: "0123456789abcdef",
params: "{}",
address: server,
chain: [incarnation, incarnation],
limit: 65_536,
},
{ kind: "hold", hold: 4, type: "t", version: 1, fingerprint: "0123456789abcdef", params: "{}", limit: 0 },
{
kind: "hold",
hold: 4,
type: "t",
version: 1,
fingerprint: "0123456789abcdef",
params: "{}",
address: server,
limit: 0,
},
{ kind: "revoke", revocation: { grant: "g1" } },
{ kind: "ping" },
{ kind: "pong" },
@ -181,7 +212,15 @@ forEachCodec(() => {
const codec = testCodec();
const deflated = () => new DeflatedPart(new Uint8Array([0, 1, 2, 255]));
const messages: Message[] = [
{ kind: "hold", hold: 0, type: "t", version: 1, fingerprint: "0123456789abcdef", params: deflated() },
{
kind: "hold",
hold: 0,
type: "t",
version: 1,
fingerprint: "0123456789abcdef",
params: deflated(),
address: server,
},
{ kind: "call", call: 0, hold: 0, method: 0, args: deflated() },
{ kind: "result", call: 0, value: deflated(), segments: [new Uint8Array([7])] },
{
@ -253,6 +292,7 @@ forEachCodec(() => {
version: 1,
fingerprint: "0123456789abcdef",
params: jsonPart({ "\uFEFFkey": "\uFEFFvalue" }),
address: server,
};
const decoded = codec.decode(codec.encode(message)) as Extract<Message, { kind: "hold" }>;
expect(plain(decoded)).toEqual(message);
@ -283,9 +323,10 @@ describe("the binary codec", () => {
version: 1,
fingerprint: "0123456789abcdef",
params: "{}",
address: server,
});
// kind, id, type, version, fingerprint, params, the optional fields' flags
expect(hold).toHaveLength(1 + 1 + 2 + 1 + 8 + 3 + 1);
// kind, id, type, version, fingerprint, params, the address's kind and params, the optional fields' flags
expect(hold).toHaveLength(1 + 1 + 2 + 1 + 8 + 3 + 7 + 3 + 1);
expect([...binaryCodec.encode({ kind: "ack" })]).toEqual([13]);
// An origin: the kind byte's high bit, then the origin as a varint.
expect([...binaryCodec.encode({ kind: "ack", origin: 300 })]).toEqual([13 | 0x80, 0xac, 0x02]);
@ -345,6 +386,7 @@ describe("the binary codec", () => {
version: 1,
fingerprint: "0123456789abcdef",
params: "{}",
address: server,
});
const forged = hold.slice();
forged[forged.length - 1] = 32;
@ -446,10 +488,12 @@ describe("the text codec", () => {
version: 1,
fingerprint: "0123456789abcdef",
params: "{}",
address: server,
} as const;
expect(text(hold)).toBe('["H",0,"t",1,"0123456789abcdef",[0,{}]]');
expect(text({ ...hold, incarnation })).toBe(`["H",0,"t",1,"0123456789abcdef",[0,{}],null,null,"${incarnation}"]`);
expect(text({ ...hold, limit: 5 })).toBe('["H",0,"t",1,"0123456789abcdef",[0,{}],null,null,null,null,5]');
const head = '["H",0,"t",1,"0123456789abcdef",[0,{}],["server",{}]';
expect(text(hold)).toBe(`${head}]`);
expect(text({ ...hold, incarnation })).toBe(`${head},null,"${incarnation}"]`);
expect(text({ ...hold, limit: 5 })).toBe(`${head},null,null,null,5]`);
});
it("puts byte segments after the tuple and a newline, their lengths the tuple's last field", () => {
@ -474,6 +518,15 @@ describe("the text codec", () => {
expect(() => textCodec.decode(new TextEncoder().encode('["@",1,["@",2,["a"]]]'))).toThrow("wraps one message");
expect(() => textCodec.decode(new TextEncoder().encode('["@",1]'))).toThrow("wraps one message");
});
it("refuses a hold without its address: every hold names one", () => {
const decode = (tuple: string) => textCodec.decode(new TextEncoder().encode(tuple));
expect(() => decode('["H",0,"t",1,"0123456789abcdef",[0,{}]]')).toThrow('a "H" message with 5 fields');
expect(() => decode('["H",0,"t",1,"0123456789abcdef",[0,{}],null]')).toThrow("expected a list");
expect(() => decode('["H",0,"t",1,"0123456789abcdef",[0,{}],["server"]]')).toThrow("an address is a pair");
const hold = { kind: "hold", hold: 0, type: "t", version: 1, fingerprint: "0123456789abcdef", params: "{}" };
expect(() => checkMessage(hold, context)).toThrow("message schema");
});
});
describe("describeMessage", () => {
@ -534,6 +587,7 @@ describe("the binary codec's short strings", () => {
version: 1,
fingerprint: "0123456789abcdef",
params: jsonPart(type),
address: server,
};
expect(plain(binaryCodec.decode(binaryCodec.encode(message)))).toEqual(message);
});

View file

@ -4,7 +4,7 @@ import { WebSocket, WebSocketServer } from "ws";
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
import { createHost, defineFacet } from "../../../src/next/host/index.ts";
import { type WebSocketLike, webSocketLink } from "../../../src/next/link/index.ts";
import { Notes, serveNotes, type TestPrincipal, transported } from "../support.ts";
import { Notes, Server, serveNotes, type TestPrincipal, transported } from "../support.ts";
/** A socket playing a WebSocket by hand. */
class FakeSocket implements WebSocketLike {
@ -93,7 +93,7 @@ describe("WebSocket links over a fake socket", () => {
/** A `ws` server on a free loopback port whose connections a server host accepts, and a URL to it. */
async function loopback(options: { readonly maxPayload?: number } = {}) {
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const server = createHost<TestPrincipal>({ address: Server(), principal: { kind: "server" }, onError: () => {} });
await server.load([{ name: "@test/app", facet: defineFacet<TestPrincipal>((h) => void serveNotes(h)) }], context);
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1", maxPayload: options.maxPayload });
await new Promise<void>((resolve) => wss.once("listening", resolve));

View file

@ -23,6 +23,8 @@ import {
rawParent,
rawProvider,
rejection,
SERVER_ADDRESS,
Server,
serveNotes,
testCodec,
transported,
@ -287,6 +289,7 @@ forEachCodec(() => {
version: Notes.version,
fingerprint: Notes.fingerprint,
params: jsonPart({ session: "S1" }),
address: SERVER_ADDRESS,
});
const [bound] = (await peer.next("bound")).holds;
expect(bound!.variant).toBe(EDITOR.variant);
@ -415,6 +418,7 @@ forEachCodec(() => {
const Echo = remote("app.echo", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { methods: { echo: method(map(int()), map(int())) } } },
});
const { client } = await linked((h) =>

View file

@ -20,6 +20,8 @@ import {
linked,
RawPeer,
rawLinked,
SERVER_ADDRESS,
Server,
type TestPrincipal,
testCodec,
transported,
@ -50,6 +52,7 @@ const N = defineState("n", { schema: obj({ n: int() }), version: 1, initial: ()
const Halves = remote("app.halves", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: obj({ n: int() }), half: int() }, methods: { set: method(int()), wait: method() } } },
});
@ -91,18 +94,24 @@ async function halves() {
function hold(peer: RawPeer, id: number, Type: { name: string; version: number; fingerprint: string }) {
const { name: type, version, fingerprint } = Type;
peer.send({ kind: "hold", hold: id, type, version, fingerprint, params: "{}" });
peer.send({ kind: "hold", hold: id, type, version, fingerprint, params: "{}", address: SERVER_ADDRESS });
}
/** The provider object behind a hold, reached through the host's own lookup (to end it by hand). */
function providerObject(server: unknown) {
const host = server as ChordHost<TestPrincipal>;
const { name: type, version, fingerprint } = Halves;
return host.serving({ type, version, fingerprint, params: "{}" }, {}, { kind: "owner" }, context);
const request = { type, version, fingerprint, params: "{}", address: SERVER_ADDRESS };
return host.serving(request, {}, { kind: "owner" }, context);
}
/** A remote type whose activation never completes: its open waits for its abort. */
const Pending = remote("app.pending", { version: 1, params: {}, variants: { v: { state: { n: int() } } } });
const Pending = remote("app.pending", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
function servePending(h: Parameters<Parameters<typeof rawLinked>[0]>[0]) {
h.serve(Pending, (_, _principal, self) => {
@ -221,7 +230,12 @@ forEachCodec(() => {
fakeTimers();
it("a new hold of an object failed transiently is sent the failure at once", async () => {
const Flaky = remote("app.flaky", { version: 1, params: {}, variants: { v: { state: { n: int() } } } });
const Flaky = remote("app.flaky", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
const { peer } = await rawLinked((h) =>
h.serve(Flaky, (_, _principal, self) => {
self.open(
@ -309,6 +323,7 @@ forEachCodec(() => {
const Slow = local("app.slow", { version: 1, params: {}, members: { n: int() } });
const { server, refs } = await (async () => {
const server = createHost<TestPrincipal>({
address: Server(),
principal: { kind: "server" },
readyTimeout: 2_000,
onError: () => {},
@ -343,7 +358,12 @@ forEachCodec(() => {
});
/** A remote type whose open throws `offline` (broken when `failing.broken`) while `failing.left` is above 0. */
const Flaky = remote("app.flaky", { version: 1, params: {}, variants: { v: { state: { n: int() } } } });
const Flaky = remote("app.flaky", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
function serveFlaky(h: Parameters<Parameters<typeof rawLinked>[0]>[0], failing: { left: number; broken: boolean }) {
h.serve(Flaky, (_, _principal, self) => {
@ -441,11 +461,13 @@ forEachCodec(() => {
const Tally = remote("app.tally", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: obj({ n: int() }) }, methods: { bump: method() } } },
});
const Fresh = remote("app.fresh", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: obj({ n: int() }) }, methods: { bump: method() } } },
});
@ -521,6 +543,7 @@ forEachCodec(() => {
const Retiring = remote("app.retiring", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
v: {
state: { doc: obj({ n: int() }), big: bool(), t: int() },
@ -536,7 +559,7 @@ const retiredAll = [RETIRING.state.doc!, RETIRING.state.big!, RETIRING.state.t!]
/** A server serving `Retiring` to a raw peer that acks only when told; `call` calls a method of hold 0. */
async function retiring() {
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const server = createHost<TestPrincipal>({ address: Server(), principal: { kind: "server" }, onError: () => {} });
await server.load(
[
{
@ -583,7 +606,7 @@ async function retiring() {
expect((await peer.next("result")).call).toBe(id);
};
const { name: type, version, fingerprint } = Retiring;
peer.send({ kind: "hold", hold: 0, type, version, fingerprint, params: "{}" });
peer.send({ kind: "hold", hold: 0, type, version, fingerprint, params: "{}", address: SERVER_ADDRESS });
await peer.next("bound");
peer.ack();
return { peer, call };

View file

@ -22,6 +22,7 @@ import {
rawWorkers,
rejection,
relayed,
Server,
Session,
serveNotes,
type TestPrincipal,
@ -33,6 +34,7 @@ import {
const ServerWho = remote("app.server-who", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { who: str() }, methods: { echo: method(str(), str()) } } },
});
@ -40,7 +42,7 @@ const ServerWho = remote("app.server-who", {
const Asker = remote("app.asker", {
version: 1,
params: { session: str() },
placement: ({ session }) => Session({ id: session }),
hostAddress: ({ session }) => Session({ id: session }),
variants: { v: { state: { who: str() }, methods: { ask: method(str(), str()) } } },
});
@ -48,7 +50,7 @@ const Asker = remote("app.asker", {
const Neighbour = remote("app.neighbour", {
version: 1,
params: { session: str(), other: str() },
placement: ({ session }) => Session({ id: session }),
hostAddress: ({ session }) => Session({ id: session }),
variants: { v: { state: { count: int() } } },
});
@ -141,6 +143,7 @@ forEachCodec(() => {
const Gateway = remote("app.gateway", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: int() }, methods: { open: method(str(), int()) } } },
});
// With params: not activated by `load`, which would wait for the raw worker.
@ -342,6 +345,7 @@ async function compressing(options: {
}) {
const errors: HostError[] = [];
const server = createHost<TestPrincipal>({
address: Server(),
principal: { kind: "server" },
compression: options.server,
onError: (error) => errors.push(error),

View file

@ -21,6 +21,8 @@ import {
RAW_PEER,
RawPeer,
rawLinked,
SERVER_ADDRESS,
Server,
serveNotes,
type TestPrincipal,
testCodec,
@ -39,7 +41,16 @@ function hold(
) {
const { name: type, version, fingerprint } = { ...Type, ...override };
const from = origin === undefined ? {} : { origin };
peer.send({ kind: "hold", ...from, hold: id, type, version, fingerprint, params: jsonPart(params) });
peer.send({
kind: "hold",
...from,
hold: id,
type,
version,
fingerprint,
params: jsonPart(params),
address: SERVER_ADDRESS,
});
}
/** Sends a call of the method at index `method` of the bound variant, with `args` (none when left out). */
@ -50,7 +61,12 @@ function call(peer: RawPeer, id: number, holdId: number, method: number, args?:
}
/** A remote type kept per id. */
const Kept = remote("app.kept", { version: 1, params: { id: int() }, variants: { v: { state: { n: int() } } } });
const Kept = remote("app.kept", {
version: 1,
params: { id: int() },
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
/** The wire's indexes of `Notes`'s reader variant. */
const READER = wireNames(Notes, "reader");
@ -120,7 +136,11 @@ forEachCodec(() => {
});
it("arriving before load resolved wait for it", async () => {
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const server = createHost<TestPrincipal>({
address: Server(),
principal: { kind: "server" },
onError: () => {},
});
const [a, b] = memoryLinkPair();
server.accept(a, { principal: { kind: "owner" }, codec: testCodec() }, context);
const peer = new RawPeer(b);
@ -177,7 +197,12 @@ forEachCodec(() => {
});
it("before bound are refused not_ready (race 3)", async () => {
const Slow = remote("app.slow", { version: 1, params: {}, variants: { v: { state: { n: int() } } } });
const Slow = remote("app.slow", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
const { peer } = await rawLinked((h) =>
h.serve(Slow, (_, _principal, self) => {
self.open(
@ -333,7 +358,11 @@ forEachCodec(() => {
it("a hold released while it waits for load takes nothing once load resolves", async () => {
const events: string[] = [];
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const server = createHost<TestPrincipal>({
address: Server(),
principal: { kind: "server" },
onError: () => {},
});
const [a, b] = memoryLinkPair();
server.accept(a, { principal: { kind: "owner" }, codec: testCodec() }, context);
const peer = new RawPeer(b);
@ -354,6 +383,7 @@ forEachCodec(() => {
const Slow = remote("app.slow", {
version: 1,
params: { id: int() },
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
const aborted: string[] = [];
@ -381,6 +411,7 @@ forEachCodec(() => {
const Closing = remote("app.closing", {
version: 1,
params: { id: int() },
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
const events: string[] = [];
@ -429,6 +460,7 @@ forEachCodec(() => {
const Counter = remote("app.counter", {
version: 1,
params: { id: int() },
hostAddress: () => Server(),
variants: { v: { state: { n: int(), seen: int() }, methods: { bump: method() } } },
});
const N = defineState("n", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) });
@ -488,6 +520,7 @@ forEachCodec(() => {
const Grid = remote("app.grid", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
v: {
state: { grid: obj({ cells: map(int()) }), one: int() },
@ -520,7 +553,7 @@ function serveGrid(h: FacetHost<TestPrincipal>): void {
/** A server serving `Notes` and `Grid` to a raw peer that acks only when told, the link's context counting. */
async function slowConsumer() {
const counting = await metered();
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const server = createHost<TestPrincipal>({ address: Server(), principal: { kind: "server" }, onError: () => {} });
await server.load(
[
{
@ -729,7 +762,12 @@ forEachCodec(() => {
});
it("carry constants and failed members, and an empty variant's binding, in a first bound (race 24)", async () => {
const Empty = remote("app.empty", { version: 1, params: {}, variants: { v: {} } });
const Empty = remote("app.empty", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: {} },
});
const N = defineState("n", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) });
const { peer } = await rawLinked((h) => {
h.serve(Empty, () => ({ variant: "v", members: {} }));
@ -836,6 +874,7 @@ forEachCodec(() => {
const Halves = remote("app.halves", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { one: int(), half: int() }, methods: { set: method(int()) } } },
});
@ -968,7 +1007,15 @@ forEachCodec(() => {
/** A hold of `Notes` for S1 as a message. */
function notesHold(id: number): Extract<Message, { kind: "hold" }> {
const { name: type, version, fingerprint } = Notes;
return { kind: "hold", hold: id, type, version, fingerprint, params: jsonPart({ session: "S1" }) };
return {
kind: "hold",
hold: id,
type,
version,
fingerprint,
params: jsonPart({ session: "S1" }),
address: SERVER_ADDRESS,
};
}
const UTF8 = new TextEncoder();

View file

@ -21,6 +21,7 @@ import {
rawWorkers,
rejection,
relayed,
Server,
serveNotes,
type TestPrincipal,
testCodec,
@ -379,7 +380,11 @@ forEachCodec(() => {
describe("a parent link that is down (audit)", () => {
it("holds a hold arriving meanwhile on the parent path: it waits loading and binds on the next parent link", async () => {
const server = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const server = createHost<TestPrincipal>({
address: Server(),
principal: { kind: "server" },
onError: () => {},
});
await server.load(
[{ name: "@test/app", facet: defineFacet<TestPrincipal>((h) => void serveNotes(h)) }],
context,

View file

@ -35,6 +35,7 @@ import {
rawParent,
rawProvider,
rejection,
Server,
serveNotes,
type TestPrincipal,
testCodec,
@ -48,7 +49,12 @@ const Counter = defineState("counter", { schema: obj({ n: int() }), version: 1,
forEachCodec(() => {
describe("a hold over the parent link", () => {
it("reject a broken failure at once", async () => {
const Broken = remote("app.broken", { version: 1, params: {}, variants: { v: { state: { n: int() } } } });
const Broken = remote("app.broken", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
const { client } = await linked((h) =>
h.serve(Broken, () => {
throw new Error("factory threw");
@ -197,6 +203,7 @@ forEachCodec(() => {
const Flaky = remote("app.flaky", {
version: 1,
params: { id: int() },
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
const opens: string[] = [];
@ -250,6 +257,7 @@ forEachCodec(() => {
const Halves = remote("app.halves", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { half: int() }, methods: { set: method(int()) } } },
});
const { server, client } = await linked((h) =>
@ -363,7 +371,12 @@ forEachCodec(() => {
fakeTimers();
it("fails at once with the real cause when its load holds it (no wait for a timeout)", async () => {
const Flaky = remote("app.flaky", { version: 1, params: {}, variants: { v: { state: { n: int() } } } });
const Flaky = remote("app.flaky", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { n: int() } } },
});
const View = local("app.view", { version: 1, params: {}, members: { n: int() } });
const { server, client: first } = await linked((h) =>
h.serve(Flaky, (_, _principal, self) => {
@ -406,6 +419,7 @@ forEachCodec(() => {
const Shifting = remote("app.shifting", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
a: { state: { x: int(), y: int() }, methods: { m: method() } },
b: { state: { y: int(), z: int() } },
@ -592,10 +606,15 @@ forEachCodec(() => {
const NotesV2 = remote("app.notes", {
version: 2,
params: { session: str() },
hostAddress: () => Server(),
variants: { editor: { state: { count: int() } } },
});
const { server, client, links } = await linked((h) => void serveNotes(h));
const upgraded = createHost<TestPrincipal>({ principal: { kind: "server" }, onError: () => {} });
const upgraded = createHost<TestPrincipal>({
address: Server(),
principal: { kind: "server" },
onError: () => {},
});
const facet = defineFacet<TestPrincipal>((h) =>
h.serve(NotesV2, () => ({ variant: "editor", members: { count: constant(0) } })),
);
@ -644,6 +663,7 @@ forEachCodec(() => {
const Flaky = remote("app.flaky", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { only: { state: {}, methods: { ping: method() } } },
});
let factories = 0;

View file

@ -401,6 +401,7 @@ export async function linked(
const errors = { server: [] as HostError[], client: [] as HostError[] };
const { telemetry, readyTimeout } = options;
const server = createHost<TestPrincipal>({
address: Server(),
principal: { kind: "server" },
telemetry,
readyTimeout,
@ -547,6 +548,7 @@ export async function rawLinked(
): Promise<{ readonly server: Host<TestPrincipal>; readonly peer: RawPeer; readonly errors: HostError[] }> {
const errors: HostError[] = [];
const server = createHost<TestPrincipal>({
address: Server(),
principal: { kind: "server" },
readyTimeout: options.readyTimeout,
compression: options.compression,
@ -580,8 +582,19 @@ const NOTES_VARIANTS = {
reader: { state: { notes: arr(obj({ text: str() })), count: int() }, methods: { wait: method() } },
};
/** The host kind of the test servers, where every test type but `PlacedNotes` lives. */
export const Server = hostKind("server");
/** `Server()` as a hold names it on the wire: the address of every test type served on the server. */
export const SERVER_ADDRESS = { kind: "server", params: {} };
/** The remote type of the link tests: notes per session, an editor variant for owners, a reader one for guests. */
export const Notes = remote("app.notes", { version: 1, params: { session: str() }, variants: NOTES_VARIANTS });
export const Notes = remote("app.notes", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: NOTES_VARIANTS,
});
/** The kind of the relay tests' workers: one per session. */
export const Session = hostKind("session", { id: str() });
@ -590,7 +603,7 @@ export const Session = hostKind("session", { id: str() });
export const PlacedNotes = remote("app.placed-notes", {
version: 1,
params: { session: str() },
placement: ({ session }) => Session({ id: session }),
hostAddress: ({ session }) => Session({ id: session }),
variants: NOTES_VARIANTS,
});
@ -630,7 +643,7 @@ export function serveNotes(
},
};
});
// The two types differ in their name and placement only.
// The two types differ in their name and host address only.
host.serve(Type as typeof Notes, ({ session }, principal, self) => {
probe.factories.push(`${session} ${principal.kind}`);
const store = self.get(NoteStore, { session });
@ -807,6 +820,7 @@ export async function relayed(
const resolves: string[] = [];
const { revokedBy, compression = {} } = options;
const server = createHost<TestPrincipal>({
address: Server(),
principal: { kind: "server" },
revokedBy: options.serverRevokedBy ?? revokedBy,
compression: compression.server,