import { type Static, type TSchema, Type } from "typebox"; import { Value } from "typebox/value"; import { ChordError, invalid } from "./errors.ts"; import { canonicalJson, fits, hash64 } from "./schema.ts"; import type { Ctx, Exposed, Readable, RefBase, StateSource } from "./types.ts"; const MEMBER = Symbol("chord.member"); /** A method's arguments and result; `undefined`: it takes no arguments, or returns nothing (void). */ export interface MethodSpec< A extends TSchema | undefined = TSchema | undefined, R extends TSchema | undefined = TSchema | undefined, > { readonly [MEMBER]: "method"; readonly args: A; readonly result: R; } export interface OpaqueSpec { readonly [MEMBER]: "opaque"; readonly id: string; /** Type carrier only; never set. */ readonly type?: T; } /** * A method. `method()`: no arguments (called as `ref.name(ctx)`, implemented as `(ctx) => …`) and no result. * `method(args)`: no result, the handler returns nothing (void). `method(args, result)`, or `method(undefined, result)` * for a method without arguments that returns something. */ export function method(): MethodSpec; export function method(args: A): MethodSpec; export function method(args: A, result: R): MethodSpec; export function method(args?: TSchema, result?: TSchema): MethodSpec { return { [MEMBER]: "method", args, result }; } /** A native in-process object identified by `name@version` (local contracts only). */ export const opaque = (id: string): OpaqueSpec => { if (!/^[^@\s]+@\d+$/.test(id)) throw new TypeError(`opaque id must be name@version: ${id}`); return { [MEMBER]: "opaque", id }; }; export interface VariantDef { readonly state?: Readonly>; readonly methods?: Readonly>; } /** Where objects of a remote type live: one named host. */ export interface HostNamed { readonly name: string; } /** Where objects of a remote type live: the host `prefix` + the input's string field `key` (`session:` + `session`). */ export interface HostFamily { readonly prefix: string; readonly key: K; } /** A remote type's placement: data only, so a description carries it and every host can check it. */ export type Placement = HostNamed | HostFamily; /** Objects of the type live on the host named `name` (`hostNamed("server")`). */ export function hostNamed(name: string): HostNamed { if (typeof name !== "string" || name === "") throw new TypeError("hostNamed needs a host name"); return { name }; } /** * Objects of the type live on `prefix` + the value of the input's string field `key`: `hostFamily("session:", * "session")` puts `{ session: "S1", … }` on `session:S1`. The field is required and must be a non-empty string. */ export function hostFamily(prefix: string, key: K): HostFamily { if (typeof prefix !== "string" || prefix === "") throw new TypeError("hostFamily needs a host-name prefix"); if (typeof key !== "string" || key === "") throw new TypeError("hostFamily needs an input field name"); return { prefix, key }; } /** Required string fields of `I`: what a `hostFamily` may name. */ type StringKeys = { [K in keyof I]-?: I[K] extends string ? K : never }[keyof I] & string; /** The placements a remote type with input `I` may have. */ export type PlacementFor = HostNamed | HostFamily>; /** The host an object of `placement` with this (normalized) input lives on. Throws `invalid/placement`. */ export function resolveHost(typeName: string, placement: Placement, input: unknown): string { if ("name" in placement) return placement.name; const value = input !== null && typeof input === "object" ? (input as Record)[placement.key] : undefined; if (typeof value !== "string" || value === "") throw invalid("placement", `${typeName}: input field ${placement.key} names no host`); return placement.prefix + value; } /** Whether the host named `hostName` may serve a type of `placement`. */ export const placedOn = (placement: Placement, hostName: string): boolean => "name" in placement ? placement.name === hostName : hostName.startsWith(placement.prefix) && hostName.length > placement.prefix.length; export const placementText = (placement: Placement): string => "name" in placement ? placement.name : `${placement.prefix}<${placement.key}>`; export interface RemoteDef { readonly version: number; readonly input: TSchema; /** Where its objects live: `hostNamed(…)` or `hostFamily(…)`. Part of the contract (and its fingerprint). */ readonly host: Placement; readonly variants: Readonly>; } /** A local member: a method, an opaque native object, or a state member described by its schema. */ export type LocalMember = MethodSpec | OpaqueSpec | TSchema; export interface LocalDef { readonly version: number; readonly input: TSchema; readonly members?: Readonly>; } // A description travels as a reference's contract token (wire.ts), so it is declared once, as the schema that validates it. // `TSchema` is an empty interface: any object is one, which is all a peer's schema is checked for here. const SchemaObject = Type.Unsafe(Type.Object({})); const MemberDescriptionSchema = Type.Union([ // Absent `args`: the method takes none. Absent `result`: it returns nothing. Type.Object({ kind: Type.Literal("method"), args: Type.Optional(SchemaObject), result: Type.Optional(SchemaObject), }), Type.Object({ kind: Type.Literal("state"), schema: SchemaObject }), Type.Object({ kind: Type.Literal("opaque"), id: Type.String() }), ]); export type MemberDescription = Static; /** Local contracts have one unnamed variant. */ export const LOCAL_VARIANT = ""; const PlacementSchema = Type.Union([ Type.Object({ name: Type.String({ minLength: 1 }) }, { additionalProperties: false }), Type.Object( { prefix: Type.String({ minLength: 1 }), key: Type.String({ minLength: 1 }) }, { additionalProperties: false }, ), ]); export const DescriptionSchema = Type.Object({ name: Type.String({ minLength: 1 }), scope: Type.Union([Type.Literal("remote"), Type.Literal("local")]), version: Type.Integer(), input: SchemaObject, /** Remote types only (required there): where their objects live. */ host: Type.Optional(PlacementSchema), variants: Type.Record(Type.String(), Type.Record(Type.String(), MemberDescriptionSchema)), }); export type Description = Static; interface ContractBase { readonly name: string; readonly version: number; readonly input: TSchema; readonly description: Description; readonly fingerprint: string; } export interface RemoteContract extends ContractBase { readonly scope: "remote"; readonly def: D; } export interface LocalContract extends ContractBase { readonly scope: "local"; readonly def: D; } export type AnyContract = RemoteContract | LocalContract; /** Names a reference uses itself; members may not use them. */ export const RESERVED_MEMBERS: ReadonlySet = new Set([ "variant", "status", "ready", "shape", "error", "onShape", "onStatus", "release", "then", "contract", ]); function describeMember(name: string, member: unknown, scope: "remote" | "local"): MemberDescription { if (RESERVED_MEMBERS.has(name)) throw new TypeError(`member name is reserved: ${name}`); const brand = (member as { [MEMBER]?: string })[MEMBER]; if (brand === "method") { const spec = member as MethodSpec; return { kind: "method", ...(spec.args === undefined ? {} : { args: spec.args }), ...(spec.result === undefined ? {} : { result: spec.result }), }; } if (brand === "opaque") { if (scope === "remote") throw new TypeError(`remote contracts cannot expose opaque members: ${name}`); return { kind: "opaque", id: (member as OpaqueSpec).id }; } return { kind: "state", schema: member as TSchema }; } function finish(contract: Omit): C { return { ...contract, fingerprint: hash64(canonicalJson(contract.description)) } as C; } function checkHeader(name: string, version: number): void { if (!name) throw new TypeError("contract name is empty"); if (!Number.isInteger(version) || version < 1) throw new TypeError(`contract version must be a positive integer`); } export function remote( name: string, def: D & { readonly host: PlacementFor> }, ): RemoteContract { checkHeader(name, def.version); const placement = def.host as Placement | undefined; if (placement === undefined || !Value.Check(PlacementSchema, placement)) throw new TypeError(`remote contract ${name} needs \`host\`: hostNamed(…) or hostFamily(…)`); // A JSON copy: the description travels, and a placement object may carry nothing else. const host: Placement = "name" in placement ? { name: placement.name } : { prefix: placement.prefix, key: placement.key }; const variants: Record> = {}; if (Object.keys(def.variants).length === 0) throw new TypeError(`remote contract ${name} has no variants`); for (const [kind, variant] of Object.entries(def.variants)) { const members: Record = {}; for (const [key, schema] of Object.entries(variant.state ?? {})) members[key] = describeMember(key, schema, "remote"); for (const [key, spec] of Object.entries(variant.methods ?? {})) { if (key in members) throw new TypeError(`member ${key} is both state and method in ${name}.${kind}`); members[key] = describeMember(key, spec, "remote"); if (members[key].kind !== "method") throw new TypeError(`methods.${key} is not a method()`); } variants[kind] = members; } const description: Description = { name, scope: "remote", version: def.version, input: def.input, host, variants }; return finish>({ scope: "remote", name, version: def.version, input: def.input, def, description, }); } export function local(name: string, def: D): LocalContract { checkHeader(name, def.version); const members: Record = {}; for (const [key, member] of Object.entries(def.members ?? {})) members[key] = describeMember(key, member, "local"); const description: Description = { name, scope: "local", version: def.version, input: def.input, variants: { [LOCAL_VARIANT]: members }, }; return finish>({ scope: "local", name, version: def.version, input: def.input, def, description }); } /** * A consumer token received over a link (only its description travels). Has no `def`, so it is only good for binding * and validation, never for TypeScript types. */ export function contractFromDescription(description: Description): AnyContract { const { name, scope, version, input, host } = description; if ( typeof name !== "string" || (scope !== "remote" && scope !== "local") || !Number.isInteger(version) || (scope === "remote") !== (host !== undefined) ) { throw new ChordError("invalid", "malformed contract token", { reason: "token" }); } const base = { name, version, input, description, fingerprint: hash64(canonicalJson(description)) }; return scope === "remote" ? { ...base, scope, def: { version, input, host: host!, variants: {} } } : { ...base, scope, def: { version, input } }; } /** Returns why `provider` does not cover `consumer`, or undefined when it does. */ export function coverage(provider: Description, consumer: Description): string | undefined { if (provider.name !== consumer.name || provider.scope !== consumer.scope) return "different type"; if (!samePlacement(provider, consumer)) return "host"; if (!fits(consumer.input, provider.input)) return "input"; for (const [kind, consumerMembers] of Object.entries(consumer.variants)) { const providerMembers = provider.variants[kind]; if (providerMembers === undefined) return `variant ${kind} missing`; for (const [name, wanted] of Object.entries(consumerMembers)) { const offered = providerMembers[name]; if (offered === undefined || offered.kind !== wanted.kind) return `${kind}.${name} missing or of another kind`; if (offered.kind === "method" && wanted.kind === "method") { // Both take arguments or neither does; a consumer that expects no result accepts any. if ((wanted.args === undefined) !== (offered.args === undefined)) return `${kind}.${name} arguments`; if (wanted.args !== undefined && offered.args !== undefined && !fits(wanted.args, offered.args)) return `${kind}.${name} arguments`; if (wanted.result !== undefined && (offered.result === undefined || !fits(offered.result, wanted.result))) return `${kind}.${name} result`; } else if (offered.kind === "state" && wanted.kind === "state") { if (!fits(offered.schema, wanted.schema)) return `${kind}.${name} state`; } else if (offered.kind === "opaque" && wanted.kind === "opaque" && offered.id !== wanted.id) { return `${kind}.${name} opaque ${offered.id} != ${wanted.id}`; } } } return undefined; } const samePlacement = (a: Description, b: Description): boolean => canonicalJson(a.host ?? null) === canonicalJson(b.host ?? null); const placementOf = (description: Description): string => description.host === undefined ? "(local)" : placementText(description.host); /** Maps the provider's current implementation to the view a consumer of an older contract version expects. */ export type Adapter = (current: Record) => Record; export type Accepts = Readonly>; /** How a consumer token binds to a provider: directly or through an `accepts` adapter. Throws contract_mismatch. */ export function bind(provider: AnyContract, accepts: Accepts | undefined, consumer: AnyContract): Adapter | undefined { if (provider.fingerprint === consumer.fingerprint) return undefined; // A moved type is another type for its consumers: no adapter reaches across hosts. if (!samePlacement(provider.description, consumer.description)) { throw new ChordError( "contract_mismatch", `${provider.name} lives on ${placementOf(provider.description)}, the consumer expects ${placementOf(consumer.description)}`, ); } if (provider.version === consumer.version) { const reason = coverage(provider.description, consumer.description); if (reason === undefined) return undefined; throw new ChordError( "contract_mismatch", `${provider.name}@${provider.version} does not cover the consumer's description (${reason}); forgotten version bump?`, ); } const adapter = accepts?.[consumer.version]; if (adapter === undefined) { throw new ChordError( "contract_mismatch", `${provider.name}: provider version ${provider.version}, consumer version ${consumer.version}, no accepts adapter`, ); } return adapter; } // ─── Static types ──────────────────────────────────────────────────────────── type Empty = Record; type StateOf = V extends { readonly state: infer S extends Readonly> } ? S : Empty; type StaticOf = S extends TSchema ? Static : never; type MethodsOf = V extends { readonly methods: infer M extends Readonly> } ? M : Empty; /** What a handler returns: the result's value, or nothing for a void method. */ type Returned = [R] extends [TSchema] ? Static | Promise> : void | Promise; /** What a call resolves to. */ type Resolved = [R] extends [TSchema] ? Static : undefined; /** A method's handler; `P` is the app's principal type (`ctx.principal`). */ export type MethodImplementation = M extends MethodSpec ? [A] extends [TSchema] ? (args: Static, ctx: Ctx

) => Returned : (ctx: Ctx

) => Returned : never; export type Call = M extends MethodSpec ? [A] extends [TSchema] ? (args: Static, ctx?: Ctx) => Promise> : (ctx?: Ctx) => Promise> : never; /** Whether `contract`'s method `name` takes no arguments (it is called as `ref.name(ctx)`). */ export function takesNoArgs(contract: AnyContract, name: string): boolean { for (const members of Object.values(contract.description.variants)) { const member = members[name]; if (member?.kind === "method") return member.args === undefined; } return false; } type VariantImplementation = { readonly variant: K } & { readonly [S in keyof StateOf]: StateSource[S]>>; } & { readonly [M in keyof MethodsOf]: MethodImplementation[M], P>; }; /** * A reference in one variant: its state members are plain readables (a reference you hold has its state) and its * methods calls. `variant` discriminates the union `Ref` is for a multi-variant type. */ type VariantRef = RefBase & { readonly variant: K } & { readonly [S in keyof StateOf]: Readable[S]>>; } & { readonly [M in keyof MethodsOf]: Call[M]>; }; type LocalImplementationMember = M extends MethodSpec ? MethodImplementation : M extends OpaqueSpec ? T | Exposed : M extends TSchema ? StateSource> : never; type LocalRefMember = M extends MethodSpec ? Call : M extends OpaqueSpec ? T | undefined : M extends TSchema ? Readable> : never; type LocalMembers = D extends { readonly members: infer M extends Readonly> } ? M : Empty; export type InputOf = C extends { readonly def: { readonly input: infer I extends TSchema } } ? Static : never; /** What a factory returns for contract C; its methods' `ctx.principal` is `P`. */ export type ImplementationOf = C extends RemoteContract ? { [K in keyof D["variants"]]: VariantImplementation }[keyof D["variants"]] : C extends LocalContract ? { readonly [K in keyof LocalMembers]: LocalImplementationMember[K], P> } & Record< string, unknown > : never; /** * What `get` returns for contract C. A remote type's reference is a union discriminated by `variant`: another variant's * members are reachable only after narrowing (`if (ref.variant === "editor") ref.add(…)`). A local type's reference has * its members. */ export type Ref = C extends RemoteContract ? { [K in keyof D["variants"]]: VariantRef }[keyof D["variants"]] : C extends LocalContract ? RefBase & { readonly [K in keyof LocalMembers]: LocalRefMember[K]> } : never;