API index and search · Build metadata
Source snapshot
packages/daemon-client/src/index.ts
1export type WireValue = number | string | boolean | null;23export type StreamMode = "flat" | "normalized";45export type MaterializationPolicy =6 | { kind: "pinned"; name?: string }7 | { kind: "whileSubscribed"; idleTtlMs?: number };89export interface MaterializeInput {10 ast: unknown;11 mode?: StreamMode;12 policy?: MaterializationPolicy;13 subject?: string;14 leaseTtlMs?: number;15 maxSubscribers?: number;16 metadata?: Record<string, unknown>;17 /** The browser's opaque follower-affinity ticket (FOLLOWER-AFFINITY-DESIGN.md §3), forwarded by18 * the api-server. {@link HttpRindleDaemonClient} lifts it into the `Rindle-Affinity` request19 * HEADER (never the body) so the fleet edge routes this `/materialize` to the follower the20 * browser's ws is pinned to (§4) — both legs co-locate on one follower. Placement, not21 * authorization: the follower verifies it, the api-server forwards it opaquely. Absent ⇒ no22 * affinity (single daemon / affinity off), byte-identical to today. */23 affinity?: string;24}2526export interface MaterializeOutput {27 materializationId: string;28 queryKey?: string;29 leaseToken: string;30 reused?: boolean;31 /** Fresh follower-placement ticket returned by an affinity-enabled daemon. A machine client32 * opening a separate ws for this follower-local lease (for example a room upstream) offers it33 * as a subprotocol so the fleet edge routes that socket back to the minting follower. */34 affinity?: string;35}3637export interface DematerializeInput {38 materializationId?: string;39 queryKey?: string;40 name?: string;41}4243export interface DematerializeOutput {44 removed: boolean;45}4647export interface SqlStatement {48 sql: string;49 params?: WireValue[];50}5152/** A foreign (non-client) writer's durable identity: a stable, low-cardinality `id` plus that53 * producer's own gapless sequence — the same `(producer, sequence)` model Kafka uses.54 *55 * The daemon stores ONE row per producer and overwrites it in place, so the dedup state is56 * bounded by how many producers you have, not by how much you write. The contract that buys:57 * number a producer's writes `1, 2, 3, …` and send them **in order**. `seq <= last` is absorbed58 * (`applied: false`, nothing runs), `seq == last + 1` applies, and anything beyond is a `409`59 * gap. Express concurrency with several producer ids, not by fanning out under one — and keep60 * ids stable and few, because a fresh id per process re-introduces unbounded growth. */61export interface WriteProducer {62 id: string;63 seq: number;64}6566export interface SqlTxn {67 /** A foreign write's dedup watermark. Mutually exclusive with `clientID`/`mid` — supplying68 * both is a `400`, because a silent precedence rule is what let the two write authorities69 * disagree about which receipt they stored. */70 producer?: WriteProducer;71 clientID?: string;72 mid?: number;73 statements: SqlStatement[];74}7576export interface SqlTxnOutput {77 applied?: boolean;78 cv?: number;79 txId?: number | string;80 /** Replicator-master commit identity (the public mutation SQL facade calls this `cursor`). */81 cursor?: string;82 /** Flat convenience mirror of this client's entry in `lmidAdvances`. */83 lmid?: number;84 lmidAdvances?: Array<{ clientID: string; lmid: number }>;85}8687/** A raw read: one `SELECT` and its positional parameters. The read counterpart to {@link SqlTxn}88 * — no idempotency/mutation fields (reads are idempotent).89 *90 * `consistency` / `routingKey` are **client-tier routing directives**: `consistency` is consumed91 * by {@link SplitDaemonClient}, `routingKey` by the read router. They ride along the wire (the92 * router is reached through an `HttpRindleDaemonClient`, so `routingKey` must) but the daemon93 * itself ignores them and reads only `sql` + `params`. `consistency` defaults to `"eventual"`94 * (route to a replica, scaling reads off the write-master); set `"strong"` to route to the master95 * for read-your-writes after a write to the same data. `routingKey` co-locates a replica read on a96 * chosen follower (e.g. by tenant); absent, the router places it by the SQL text. */97export interface SqlRead {98 sql: string;99 params?: WireValue[];100 consistency?: "eventual" | "strong";101 routingKey?: string;102}103104/** The `/execute-sql-read` response: result columns in order and each row as a bare cell array105 * (NOT keyed objects — the daemon keeps per-cell object construction off the write-master; zip106 * `cols` with each row client-side if you want objects). Lossless for duplicate/aliased columns. */107export interface SqlReadOutput {108 cols: string[];109 rows: WireValue[][];110}111112export type RowChangeTxn = {113 source: string;114 offset: string;115 changes: Array<116 | { table: string; op: "add"; row: WireValue[] }117 | { table: string; op: "remove"; old: WireValue[] }118 | { table: string; op: "edit"; old: WireValue[]; row: WireValue[] }119 >;120 /** The room write-behind extensions (RINDLE-REALTIME §5.3, each opt-in by121 * presence): the placement fence (`doc` + `epoch` — a stale epoch gets a122 * `409 {error:"fenced"}`), the §8.3 batch identity (`batchHash` — same id +123 * different body is a loud 500), and the CAS preconditions (`cas: true` — a miss124 * gets a `409 {error:"conflict", conflicts}` with authoritative images, nothing125 * applied). Plain change sources send none and are byte-compatible unchanged. */126 doc?: string;127 epoch?: number;128 batchHash?: string;129 cas?: boolean;130};131132export interface RowChangeTxnOutput {133 /** `false` = the `(source, offset)` keyset absorbed a replay. */134 applied?: boolean;135 cv?: number;136}137138/** `/claim-room-epoch` (RINDLE-REALTIME §2.5): bump + return the placement epoch. */139export interface ClaimRoomEpochInput {140 doc: string;141}142export interface ClaimRoomEpochOutput {143 epoch: number;144}145146/** `/room-lmids` (§3.3): the domain-scoped ledger's lmid per client under `doc` — the147 * room's boot probe. `doc` scopes the read to `_rindle_room_client_mutations` (§7.1), so a148 * client's room stream never aliases its slow-path daemon stream. */149export interface RoomLmidsInput {150 doc: string;151 clients: string[];152}153export interface RoomLmidsOutput {154 lmids: Record<string, number>;155}156157/** `/mutate-session/begin` (DAEMON-INTERACTIVE-TXN-DESIGN.md §4.1): open an interactive write158 * transaction on the master, held open across round trips under the daemon-owned deadline.159 * Client-attributed sessions (`clientID` + `mid`) get mid dedup/gap resolution UP FRONT — an160 * absorbed replay opens no session and the caller must skip the mutator. `statements` is the161 * accumulated write prefix to replay into the fresh transaction (sound: nothing before the162 * first read observed DB state); `query` rides the begin so a one-read mutator pays exactly163 * one extra round trip over the batch path. */164export interface MutationSessionBegin {165 clientID?: string;166 mid?: number;167 /** A foreign session's dedup watermark; see {@link WriteProducer}. Mutually exclusive with168 * `clientID`/`mid`. */169 producer?: WriteProducer;170 statements?: SqlStatement[];171 query?: SqlStatement;172}173174export interface MutationSessionBeginOutput {175 /** Present iff a session opened. */176 sessionId?: string;177 /** The `query` read's result, when one rode the begin (`{cols, rows}`, bare cell arrays). */178 read?: SqlReadOutput;179 /** True when mid/producer-watermark dedup absorbed the envelope at begin: NO session opened, the180 * mutator must not run — the remaining fields are the authoritative replay output, the same181 * shape {@link RindleDaemonClient.executeSqlTxn} answers for a deduped mid. */182 absorbed?: boolean;183 applied?: boolean;184 lmid?: number;185 lmidAdvances?: Array<{ clientID: string; lmid: number }>;186}187188export interface MutationSessionExec {189 sessionId: string;190 statements: SqlStatement[];191}192193/** A read through the open transaction (read-your-writes) — flat `{sessionId, sql, params}`,194 * answered in the `/execute-sql-read` shape. */195export interface MutationSessionQuery {196 sessionId: string;197 sql: string;198 params?: WireValue[];199}200201export interface MutationSessionRef {202 sessionId: string;203}204205export interface MutationRejection {206 clientID: string;207 mid: number;208 reason?: string;209}210211export interface MutationRejectionOutput {212 cv?: number;213 lmid?: number;214}215216/** One pure migration file: either ordered DDL statements or ordered DML statements. The opaque217 * `id` is the journaled idempotency key. `checksum` is the canonical SHA-256 content identity218 * sent by current deploy tooling; it is required for data migrations and optional only for219 * compatibility with legacy private DDL callers. `overrideHash` is an operator-reviewed checksum220 * for the content this migration originally applied with. DDL may be additive or destructive,221 * while `RENAME` and raw `blob` remain unsupported. */222export interface MigrateInput {223 id: string;224 checksum?: string;225 overrideHash?: string;226 statements: string[];227}228229/** The `/migrate` response: `applied` is false when `id` was already journaled (an idempotent230 * replay ran nothing); `hashOverridden` reports that the supplied override matched the journaled231 * content identity; `schemaVersion` is the version the daemon will report AFTER it serves the new232 * shape (the latest applied migration id, `DRIZZLE-MIGRATIONS-DESIGN.md` §5.3). `restarting` is233 * true when the daemon is self-restarting to re-introspect (a supervised daemon with234 * `RINDLE_RESTART_ON_MIGRATE`) — the caller should wait for it to come back before its next call. */235export interface MigrateOutput {236 applied: boolean;237 hashOverridden?: boolean;238 schemaVersion?: string;239 restarting?: boolean;240}241242/** One entry in a {@link MigrateBatchOutput}: the migration `id`, whether this apply ran it243 * (false = the `id` was already journaled, an idempotent no-op), and whether its override matched244 * the journaled content identity. */245export interface MigrateResultItem {246 id: string;247 applied: boolean;248 hashOverridden?: boolean;249 schemaVersion?: string;250}251252/** The response to the ARRAY form of `/migrate` ({@link HttpRindleDaemonClient.migrateBatch}): a253 * whole ordered set applied in one round-trip so a supervised daemon self-restarts ONCE for the254 * set instead of once per migration. `results` are the migrations processed before any failure, in255 * order; `applied` counts the newly-applied ones; `schemaVersion` is the latest applied id;256 * `restarting` is true when the daemon is self-restarting to re-introspect (wait for it before the257 * next call). On a failed migration `error`/`failedId` name it — the batch stops there, earlier258 * migrations are committed (forward-only), and the daemon does NOT restart: fix forward and259 * re-send. Unlike the single-migration form, this always arrives as HTTP 200; inspect `error`. */260export interface MigrateBatchOutput {261 results: MigrateResultItem[];262 applied: number;263 schemaVersion?: string;264 restarting?: boolean;265 error?: string;266 errorCode?: string;267 failedId?: string;268}269270/** The SSR one-shot read (`SSR-DESIGN.md` §3). Materialize-or-reuse the query, read its current271 * view ONCE, return it assembled; registers no subscriber. `visibilityKey`/`ttlMs` are optional. */272export interface QueryOnceInput {273 ast: unknown;274 visibilityKey?: string;275 ttlMs?: number;276 /** The browser's opaque follower-affinity ticket — see {@link MaterializeInput.affinity}. Lifted277 * into the `Rindle-Affinity` header so an SSR / one-shot read co-locates on the same pinned278 * follower the browser's ws is (or will be) on. */279 affinity?: string;280}281282/** The `POST /query` response: assembled `rows` (nested by name, ready to hydrate without an283 * engine), the `cvMin` baseline they reflect, the per-query `schema` hello, and `queryKey`. */284export interface QueryOnceOutput {285 queryKey?: string;286 cvMin?: number;287 bootId?: string;288 schema?: unknown;289 rows: Array<{ cols: Record<string, WireValue>; [rel: string]: unknown }>;290}291292/** One column in a {@link SchemaTable}: its name and the daemon's `ColType` wire name. */293export interface SchemaColumn {294 name: string;295 /** The daemon's `ColType` wire name. Because the daemon only ever *introspects* the SQLite file296 * and affinity is lossy, this is `"string"` or `"number"` in practice — a column's app-level297 * `"boolean"`/`"json"` intent is not recoverable from the file (`DRIZZLE-MIGRATIONS-DESIGN.md`298 * §6.2). The full vocabulary is `"string" | "number" | "boolean" | "json"`. */299 type: string;300 /** `true` when the column is nullable (introspected `pragma_table_info.notnull == 0`); the301 * generated column becomes `.nullable()`, typing it `T | null`. PK columns are always `false`302 * (row identity). Absent on older daemons ⇒ treat as non-nullable. See design 206. */303 nullable?: boolean;304}305306/** One base table in the daemon's introspected schema: its name, ordered columns, and PK columns. */307export interface SchemaTable {308 name: string;309 columns: SchemaColumn[];310 primaryKey: string[];311}312313/** The daemon's introspected base-table schema (`GET /schema`) — the input to client-schema314 * codegen (`DRIZZLE-MIGRATIONS-DESIGN.md` §6.2). Tables are sorted by name and the daemon's own315 * bookkeeping tables (`_rindle_*`) are excluded. */316export interface SchemaOutput {317 tables: SchemaTable[];318}319320export interface RindleDaemonClient {321 materialize(input: MaterializeInput): Promise<MaterializeOutput>;322 dematerialize(input: DematerializeInput): Promise<DematerializeOutput>;323 executeSqlTxn(input: SqlTxn): Promise<SqlTxnOutput>;324 /** Raw SQL read (a single `SELECT` + params) against the latest committed snapshot, returning325 * `{ cols, rows }` (bare row arrays). The read counterpart to {@link executeSqlTxn}. Under326 * {@link SplitDaemonClient} this defaults to a replica (`consistency:"eventual"`); pass327 * `consistency:"strong"` for read-your-writes on the master. */328 executeSqlRead(input: SqlRead): Promise<SqlReadOutput>;329 applyRowChangeTxn(input: RowChangeTxn): Promise<RowChangeTxnOutput>;330 rejectMutation(input: MutationRejection): Promise<MutationRejectionOutput>;331 /** SSR one-shot read (`SSR-DESIGN.md` §3): the current assembled view, no subscription. */332 query(input: QueryOnceInput): Promise<QueryOnceOutput>;333 /** Apply one schema migration through the daemon's controlled DDL channel + journal it334 * (`DRIZZLE-MIGRATIONS-DESIGN.md` §5.1). Idempotent by `id`. The new shape is served only after335 * the daemon is bounced (migrate-at-bounce, §5.2) — this applies DDL to the file but does not336 * re-introspect the running engine. Drive it from a migration runner, not the request path. */337 migrate(input: MigrateInput): Promise<MigrateOutput>;338 /** Open an interactive mutation session (DAEMON-INTERACTIVE-TXN-DESIGN.md §4) — the write339 * transaction a read-bearing mutator lazily upgrades onto. Optional as a group with the four340 * ops below: only the daemon's control plane serves sessions (master-only — a follower's341 * write-fence rejects begin); a client that lacks them forces the legacy committed-state342 * read path. Session ops against a finished/expired session reject with a343 * {@link DaemonHttpError} whose `status` is 410 — treat it as infra (retry the envelope;344 * begin-time dedup absorbs a committed outcome). */345 beginMutationSession?(input: MutationSessionBegin): Promise<MutationSessionBeginOutput>;346 execInMutationSession?(input: MutationSessionExec): Promise<unknown>;347 queryInMutationSession?(input: MutationSessionQuery): Promise<SqlReadOutput>;348 commitMutationSession?(input: MutationSessionRef): Promise<SqlTxnOutput>;349 rollbackMutationSession?(input: MutationSessionRef): Promise<unknown>;350 /** Claim the next placement epoch for a room's doc (RINDLE-REALTIME §2.5). Optional:351 * only daemons serving as a room write authority implement it; the API server's352 * room host refuses loudly when its configured client lacks it. */353 claimRoomEpoch?(input: ClaimRoomEpochInput): Promise<ClaimRoomEpochOutput>;354 /** The room's boot probe (§3.3). Optional, same contract as {@link claimRoomEpoch}. */355 roomLmids?(input: RoomLmidsInput): Promise<RoomLmidsOutput>;356}357358export interface HttpRindleDaemonClientPaths {359 materialize: string;360 dematerialize: string;361 executeSqlTxn: string;362 executeSqlRead: string;363 applyRowChangeTxn: string;364 rejectMutation: string;365 query: string;366 migrate: string;367 schema: string;368 claimRoomEpoch: string;369 roomLmids: string;370 mutateSessionBegin: string;371 mutateSessionExec: string;372 mutateSessionQuery: string;373 mutateSessionCommit: string;374 mutateSessionRollback: string;375}376377export type HeaderValue = string | undefined;378export type HeadersInput = Record<string, HeaderValue>;379export type HeadersFactory = () => HeadersInput | PromiseLike<HeadersInput>;380381export interface FetchResponseLike {382 ok: boolean;383 status: number;384 statusText: string;385 text(): Promise<string>;386 /** Optional response headers — the real `fetch` Response exposes these. Used to read the387 * daemon's `Rindle-Boot-Id` (see {@link HttpRindleDaemonClientOptions.onBootId}). */388 headers?: { get(name: string): string | null };389}390391export type FetchLike = (392 input: string,393 init: { method: string; headers: Record<string, string>; body: string },394) => Promise<FetchResponseLike>;395396export interface HttpRindleDaemonClientOptions {397 baseUrl: string;398 fetch?: FetchLike;399 headers?: HeadersInput | HeadersFactory;400 paths?: Partial<HttpRindleDaemonClientPaths>;401 /** Fired with the daemon's boot id on the first control-plane response and again whenever it402 * CHANGES — i.e. the daemon restarted (it keeps no durable lease/materialization state). Use403 * it to re-assert pins / re-materialize. Treat every call as "(re)assert now"; it rides404 * responses you already make (e.g. an ingester's writes), so no polling is needed. Must not405 * throw or reject — handle your own errors (e.g. `onBootId: () => server.assertPins().catch(log)`). */406 onBootId?: (bootId: string) => void;407}408409/** The api-server → fleet control-plane header carrying the browser's opaque affinity ticket on410 * `/materialize` and `/query` (FOLLOWER-AFFINITY-DESIGN.md §5). Inlined (not imported from411 * `@rindle/affinity`'s `HEADER_NAME`) to keep this client free of that crate's `node:crypto`412 * dependency — an api-server may run on a Worker. Keep the two spellings in lock-step. */413const AFFINITY_HEADER = "Rindle-Affinity";414415const defaultPaths: HttpRindleDaemonClientPaths = {416 materialize: "/materialize",417 dematerialize: "/dematerialize",418 executeSqlTxn: "/execute-sql-txn",419 executeSqlRead: "/execute-sql-read",420 applyRowChangeTxn: "/apply-row-change-txn",421 rejectMutation: "/reject-mutation",422 query: "/query",423 migrate: "/migrate",424 schema: "/schema",425 claimRoomEpoch: "/claim-room-epoch",426 roomLmids: "/room-lmids",427 mutateSessionBegin: "/mutate-session/begin",428 mutateSessionExec: "/mutate-session/exec",429 mutateSessionQuery: "/mutate-session/query",430 mutateSessionCommit: "/mutate-session/commit",431 mutateSessionRollback: "/mutate-session/rollback",432};433434export class DaemonHttpError extends Error {435 readonly status: number;436 readonly statusText: string;437 readonly body: string;438439 constructor(status: number, statusText: string, body: string) {440 super(`rindle daemon request failed: ${status} ${statusText}${body ? `: ${body}` : ""}`);441 this.name = "DaemonHttpError";442 this.status = status;443 this.statusText = statusText;444 this.body = body;445 }446}447448export class HttpRindleDaemonClient implements RindleDaemonClient {449 private readonly baseUrl: string;450 private readonly fetchImpl: FetchLike;451 private readonly headers?: HeadersInput | HeadersFactory;452 private readonly paths: HttpRindleDaemonClientPaths;453 private readonly onBootId?: (bootId: string) => void;454 private lastBootId?: string;455456 constructor(opts: HttpRindleDaemonClientOptions) {457 this.baseUrl = opts.baseUrl;458 this.fetchImpl = opts.fetch ?? defaultFetch;459 this.headers = opts.headers;460 this.paths = { ...defaultPaths, ...opts.paths };461 this.onBootId = opts.onBootId;462 }463464 materialize(input: MaterializeInput): Promise<MaterializeOutput> {465 // The affinity ticket rides the `Rindle-Affinity` HEADER, not the body (the fleet edge reads it466 // pre-dispatch this POST to the pinned follower). Strip it from the body so the467 // daemon's `MaterializeInput` deserialization never sees an unknown field.468 const { affinity, ...body } = input;469 return this.post(this.paths.materialize, body, affinity);470 }471472 dematerialize(input: DematerializeInput): Promise<DematerializeOutput> {473 return this.post(this.paths.dematerialize, input);474 }475476 executeSqlTxn(input: SqlTxn): Promise<SqlTxnOutput> {477 return this.post(this.paths.executeSqlTxn, input);478 }479480 executeSqlRead(input: SqlRead): Promise<SqlReadOutput> {481 // Sends the full input (like `query` carries `visibilityKey`): `routingKey` must survive this482 // hop so the read router — reached THROUGH this client — can place by it. The daemon itself483 // reads only `sql` + `params` and ignores the routing directives.484 return this.post(this.paths.executeSqlRead, input);485 }486487 applyRowChangeTxn(input: RowChangeTxn): Promise<RowChangeTxnOutput> {488 return this.post(this.paths.applyRowChangeTxn, input);489 }490491 claimRoomEpoch(input: ClaimRoomEpochInput): Promise<ClaimRoomEpochOutput> {492 return this.post(this.paths.claimRoomEpoch, input);493 }494495 roomLmids(input: RoomLmidsInput): Promise<RoomLmidsOutput> {496 return this.post(this.paths.roomLmids, input);497 }498499 rejectMutation(input: MutationRejection): Promise<MutationRejectionOutput> {500 return this.post(this.paths.rejectMutation, input);501 }502503 beginMutationSession(input: MutationSessionBegin): Promise<MutationSessionBeginOutput> {504 return this.post(this.paths.mutateSessionBegin, input);505 }506507 execInMutationSession(input: MutationSessionExec): Promise<unknown> {508 return this.post(this.paths.mutateSessionExec, input);509 }510511 queryInMutationSession(input: MutationSessionQuery): Promise<SqlReadOutput> {512 return this.post(this.paths.mutateSessionQuery, input);513 }514515 commitMutationSession(input: MutationSessionRef): Promise<SqlTxnOutput> {516 return this.post(this.paths.mutateSessionCommit, input);517 }518519 rollbackMutationSession(input: MutationSessionRef): Promise<unknown> {520 return this.post(this.paths.mutateSessionRollback, input);521 }522523 query(input: QueryOnceInput): Promise<QueryOnceOutput> {524 const { affinity, ...body } = input;525 return this.post(this.paths.query, body, affinity);526 }527528 migrate(input: MigrateInput): Promise<MigrateOutput> {529 return this.post(this.paths.migrate, input);530 }531532 /** Apply an ordered SET of migrations in one round-trip — the array form of {@link migrate}. The533 * daemon applies them in order (stopping at the first failure) and, when supervised, self-restarts534 * ONCE for the whole set. Drive it from a migration runner (`rindle migrate apply` does). See535 * {@link MigrateBatchOutput} — inspect its `error` field rather than relying on the HTTP status. */536 migrateBatch(inputs: MigrateInput[]): Promise<MigrateBatchOutput> {537 return this.post(this.paths.migrate, inputs);538 }539540 /** The daemon's introspected base-table schema for client-schema codegen541 * (`DRIZZLE-MIGRATIONS-DESIGN.md` §6.2). Read-only `GET /schema`, bearer-auth'd when the daemon542 * has a token configured. Deliberately NOT on {@link RindleDaemonClient}: schema codegen is a543 * dev/CI concern, not part of the runtime control-plane contract a router/splitter satisfies. */544 schema(): Promise<SchemaOutput> {545 return this.get(this.paths.schema);546 }547548 private async post<T>(path: string, input: unknown, affinity?: string): Promise<T> {549 const headers = await this.resolveHeaders();550 // The api-server → fleet control-plane affinity ticket rides as a header on top of the usual551 // bearer/content headers (FOLLOWER-AFFINITY-DESIGN.md §5); the fleet edge reads it to route552 // to the pinned follower. Only `/materialize` + `/query` carry one.553 if (affinity !== undefined) headers[AFFINITY_HEADER] = affinity;554 const res = await this.fetchImpl(urlJoin(this.baseUrl, path), {555 method: "POST",556 headers: { "content-type": "application/json", ...headers },557 body: JSON.stringify(input),558 });559 this.observeBootId(res);560 const body = await res.text();561 if (!res.ok) throw new DaemonHttpError(res.status, res.statusText, body);562 return (body ? JSON.parse(body) : undefined) as T;563 }564565 /** A bearer-auth'd GET (the read-only `/schema`, `/version`-style routes). Carries no request566 * body — `defaultFetch` strips the placeholder before the real fetch (WHATWG fetch rejects a567 * GET body). */568 private async get<T>(path: string): Promise<T> {569 const headers = await this.resolveHeaders();570 const res = await this.fetchImpl(urlJoin(this.baseUrl, path), {571 method: "GET",572 headers,573 body: "",574 });575 this.observeBootId(res);576 const body = await res.text();577 if (!res.ok) throw new DaemonHttpError(res.status, res.statusText, body);578 return (body ? JSON.parse(body) : undefined) as T;579 }580581 /** Notice the daemon's boot id on any response; fire `onBootId` on the first one and on every582 * change (a restart). Updates `lastBootId` BEFORE the hook so a re-assert it triggers (which583 * POSTs again, same boot id) does not re-fire — no recursion. */584 private observeBootId(res: FetchResponseLike): void {585 const bootId = res.headers?.get("rindle-boot-id") ?? undefined;586 if (!bootId || bootId === this.lastBootId) return;587 this.lastBootId = bootId;588 try {589 this.onBootId?.(bootId);590 } catch {591 // A misbehaving hook must never break the originating request.592 }593 }594595 private async resolveHeaders(): Promise<Record<string, string>> {596 const raw = typeof this.headers === "function" ? await this.headers() : (this.headers ?? {});597 const out: Record<string, string> = {};598 for (const [key, value] of Object.entries(raw)) {599 if (value !== undefined) out[key] = value;600 }601 return out;602 }603}604605function urlJoin(baseUrl: string, path: string): string {606 const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;607 const rel = path.startsWith("/") ? path.slice(1) : path;608 return new URL(rel, base).toString();609}610611type RealFetch = (612 input: string,613 init: { method: string; headers: Record<string, string>; body?: string },614) => Promise<FetchResponseLike>;615616const defaultFetch: FetchLike = async (input, init) => {617 const fetchImpl = (globalThis as { fetch?: RealFetch }).fetch;618 if (!fetchImpl) throw new Error("global fetch is unavailable; pass opts.fetch to HttpRindleDaemonClient");619 // A GET/HEAD must not carry a request body (WHATWG fetch throws); the client passes "" as a620 // placeholder for those, so drop it before reaching the real fetch.621 if (init.method === "GET" || init.method === "HEAD") {622 return fetchImpl(input, { method: init.method, headers: init.headers });623 }624 return fetchImpl(input, init);625};626