Files
tech-log-frontend/src/adapters/browser-rpc/transport.ts
T
DongHyeonkaandClaude Opus 5 4bff9ca151 chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed
on it through 43 template commits, so it was missing all three rounds of
adapter remediation — including files it never had, such as the shared
`abortable-operation` primitive and the `exact-snapshot` decoder that
later fixes are written against. Taking only the newest round was not
possible for that reason: the delta is coherent only as a whole.

The product had not touched `src/adapters` at all since materialization,
so the 140-file delta applied with a three-way merge and no conflicts.
`package.json` was the single overlap and merged cleanly: the product owns
`name`, the template contributed `check:adapter-inventory`,
`check:remediation-ledger` and the image-resolve-signal type fixture.
All 24 product-owned files — README, index.html, CI workflow, i18n
catalog, home page, generated schemas, evidence scripts, component and
visual snapshots — are byte-identical to `main`.

`template.lock.json` now pins the synced revision and tree.

Verified in this repository, not inherited from the template: six type
projects, lint, nine gates (adapter inventory, remediation ledger,
registries, diagnostics, realtime boundaries, architecture, browser
file/storage boundaries, optional recipes, documentation), the production
build, and 2,054 of 2,073 tests. The 19 failures are all in
`tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing
sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template
records; four suites that failed once under parallel load pass in
isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:04:58 +09:00

182 lines
5.7 KiB
TypeScript

import type {
BrowserRpcKind,
BrowserRpcOperationV3,
BrowserRpcProtocol,
BrowserRpcProviderProfile,
BrowserRpcRuntimeBindingIdentity,
BrowserRpcTransportFailureCode,
} from "../../contracts/browser-rpc.ts";
export type BrowserRpcTransportFailure = Readonly<{
code: BrowserRpcTransportFailureCode;
retryAfterMs?: number;
}>;
export type BrowserRpcTransportCall = Readonly<{
operation: BrowserRpcOperationV3;
profile: BrowserRpcProviderProfile;
request: unknown;
encodedRequestBytes: number;
attempt: number;
timeoutMs: number;
signal: AbortSignal;
idempotencyKey?: string;
}>;
export type BrowserRpcUnaryTransportResult =
| Readonly<{
ok: true;
message: unknown;
encodedBytes: number;
}>
| Readonly<{
ok: false;
failure: BrowserRpcTransportFailure;
}>;
export type BrowserRpcStreamFrame =
| Readonly<{
kind: "MESSAGE";
message: unknown;
encodedBytes: number;
}>
| Readonly<{
kind: "TERMINAL";
ok: true;
}>
| Readonly<{
kind: "TERMINAL";
ok: false;
failure: BrowserRpcTransportFailure;
}>;
/**
* RPC-RR-01. A server stream is a physical resource, not just a sequence.
*
* A bare `AsyncIterable` gives the runtime no way to cancel the underlying
* stream or to learn when it actually closed: `iterator.return()` is a request
* a non-cooperative implementation may ignore. The runtime could then time out,
* report the call finished, and admit a second stream for the same operation
* while the first was still running against the server.
*
* The lease separates the three concerns the runtime needs:
*
* - `frames` is the sequence,
* - `cancel(reason)` is a synchronous request to stop the physical stream,
* - `waitClosed()` settles only once that stream is really closed,
* - `streamId` names the physical stream so two leases are never confused.
*/
export type BrowserRpcServerStreamLease = Readonly<{
streamId: string;
frames: AsyncIterable<BrowserRpcStreamFrame>;
cancel(reason: string): void;
waitClosed(): Promise<void>;
}>;
export type BrowserRpcTransport = BrowserRpcRuntimeBindingIdentity &
Readonly<{
invokeUnary?(
call: BrowserRpcTransportCall,
): Promise<BrowserRpcUnaryTransportResult>;
openServerStream?(
call: BrowserRpcTransportCall,
): BrowserRpcServerStreamLease;
}>;
const STREAM_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
/**
* RPC-RR-01. Decodes a lease from own data descriptors before the runtime
* registers it, so an accessor cannot hand the registry one object and the
* cancellation path another.
*/
export function decodeServerStreamLease(
value: unknown,
): BrowserRpcServerStreamLease | null {
if (value === null || typeof value !== "object") return null;
let streamId: unknown;
let frames: unknown;
let cancel: unknown;
let waitClosed: unknown;
try {
if (Object.getOwnPropertySymbols(value).length > 0) return null;
const names = Object.getOwnPropertyNames(value).sort();
const expected = ["cancel", "frames", "streamId", "waitClosed"];
if (
names.length !== expected.length ||
names.some((name, index) => name !== expected[index])
) {
return null;
}
for (const name of names) {
const descriptor = Object.getOwnPropertyDescriptor(value, name);
if (!descriptor || !("value" in descriptor)) return null;
}
streamId = Object.getOwnPropertyDescriptor(value, "streamId")?.value;
frames = Object.getOwnPropertyDescriptor(value, "frames")?.value;
cancel = Object.getOwnPropertyDescriptor(value, "cancel")?.value;
waitClosed = Object.getOwnPropertyDescriptor(value, "waitClosed")?.value;
} catch {
return null;
}
// RPC-02. The async-iterator lookup is a read of foreign state like any
// other, so it happens inside the decoder's own boundary. Performing it after
// the `try` let a throwing `Symbol.asyncIterator` getter escape this
// function as a native `TypeError`, breaking the decoder's totality.
let openFrames: unknown;
try {
if (
typeof streamId !== "string" ||
!STREAM_ID.test(streamId) ||
frames === null ||
typeof frames !== "object" ||
typeof cancel !== "function" ||
typeof waitClosed !== "function"
) {
return null;
}
openFrames = (frames as AsyncIterable<unknown>)[Symbol.asyncIterator];
if (typeof openFrames !== "function") return null;
} catch {
return null;
}
const iterate = (openFrames as () => AsyncIterator<BrowserRpcStreamFrame>)
.bind(frames);
return Object.freeze({
streamId,
frames: Object.freeze({
[Symbol.asyncIterator]: iterate,
}) as AsyncIterable<BrowserRpcStreamFrame>,
cancel: (cancel as (reason: string) => void).bind(value),
waitClosed: (waitClosed as () => Promise<void>).bind(value),
});
}
export function defineBrowserRpcTransport(
transport: BrowserRpcTransport,
): BrowserRpcTransport {
if (
!transport.runtimeProfileId ||
!transport.providerId ||
!isProtocol(transport.protocol) ||
!isRpcKind(transport.rpcKind) ||
(transport.rpcKind === "UNARY" &&
(typeof transport.invokeUnary !== "function" ||
transport.openServerStream !== undefined)) ||
(transport.rpcKind === "SERVER_STREAM" &&
(typeof transport.openServerStream !== "function" ||
transport.invokeUnary !== undefined))
) {
throw new TypeError("Browser RPC transport is invalid.");
}
return Object.freeze({ ...transport });
}
function isProtocol(value: string): value is BrowserRpcProtocol {
return value === "CONNECT_HTTP" || value === "GRPC_WEB";
}
function isRpcKind(value: string): value is BrowserRpcKind {
return value === "UNARY" || value === "SERVER_STREAM";
}