Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
# @suaveplan/drive-transport-https - HTTPS relay transport for the drive mesh. Implements TransportLike<MessageEnvelope> over a relay endpoint with bearer auth, long-poll receive, exponential backoff, and AbortSignal cancellation.
## Summary
HTTPS-relay-backed `TransportLike<MessageEnvelope>` for the Suaveplan drive mesh. Implements long-poll receive, stateless POST send, exponential backoff with equal-jitter, and Zod-validated wire format. Browser-friendly: the relay is the only HTTP server in the loop, so peers never need to host an inbound endpoint.
```ts
import { HttpsTransport } from "@suaveplan/drive-transport-https";
const transport = new HttpsTransport({
relayUrl: "https://relay.example.com",
ourPeerId: "ab12...",
authToken: process.env.RELAY_TOKEN,
});
transport.start();
const off = transport.onMessage((envelope) => console.log(envelope.id));
await transport.connect("peer-b", "ignored");
await transport.send("peer-b", envelope);
off();
await transport.stop();
```
See `src/transport/https-transport.md` for full semantics, error-handling guidance, and the relay contract.
<!-- begin:auto-docs -->
## Documentation tree
- [schemas](./src/schemas/transport.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transport.schema](./src/schemas/transport.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [defaults](./src/transport/defaults.md) — Centralises default values for `HttpsTransport` so every part of the package agrees on the same numbers. Living in its…
- [https-transport](./src/transport/https-transport.md) — Implements `HttpsTransport`, the HTTPS-relay-backed `TransportLike<MessageEnvelope>` for `@suaveplan/drive-mesh` and an…
- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-transport-https`. Every failure mode the HTTPS relay transp…
<!-- end:auto-docs --> - [transport.schema](./src/schemas/transport.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults](./src/transport/defaults.md) — Centralises default values for `HttpsTransport` so every part of the package agrees on the same numbers. Living in its…
- [https-transport](./src/transport/https-transport.md) — Implements `HttpsTransport`, the HTTPS-relay-backed `TransportLike<MessageEnvelope>` for `@suaveplan/drive-mesh` and an…
- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-transport-https`. Every failure mode the HTTPS relay transp…
- [transport.schema](./src/schemas/transport.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults](./src/transport/defaults.md) — Centralises default values for `HttpsTransport` so every part of the package agrees on the same numbers. Living in its…
- [https-transport](./src/transport/https-transport.md) — Implements `HttpsTransport`, the HTTPS-relay-backed `TransportLike<MessageEnvelope>` for `@suaveplan/drive-mesh` and an…
- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-transport-https`. Every failure mode the HTTPS relay transp…
- [transport.schema](./src/schemas/transport.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults](./src/transport/defaults.md) — Centralises default values for `HttpsTransport` so every part of the package agrees on the same numbers. Living in its…
- [https-transport](./src/transport/https-transport.md) — Implements `HttpsTransport`, the HTTPS-relay-backed `TransportLike<MessageEnvelope>` for `@suaveplan/drive-mesh` and an…
- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-transport-https`. Every failure mode the HTTPS relay transp…
- [transport.schema](./src/schemas/transport.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults](./src/transport/defaults.md) — Centralises default values for `HttpsTransport` so every part of the package agrees on the same numbers. Living in its…
- [https-transport](./src/transport/https-transport.md) — Implements `HttpsTransport`, the HTTPS-relay-backed `TransportLike<MessageEnvelope>` for `@suaveplan/drive-mesh` and an…
- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-transport-https`. Every failure mode the HTTPS relay transp…

View File

@@ -0,0 +1,81 @@
{
"name": "@suaveplan/drive-transport-https",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "HTTPS relay transport for the drive mesh. Implements TransportLike<MessageEnvelope> over a relay endpoint with bearer auth, long-poll receive, exponential backoff, and AbortSignal cancellation.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/error-codes.d.ts",
"default": "./dist/error-codes.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,61 @@
# error-codes
## Purpose
Defines the structured error registry for `@suaveplan/drive-transport-https`. Every failure mode the HTTPS relay transport can produce is registered through `defineCodes("HTTPS_TRANSPORT", [...])` so consumers can branch on stable string codes (`"HTTPS_TRANSPORT.SEND_FAILED"`, etc.) without parsing English-language error messages. The companion `HttpsTransportError` class extends the project-wide `SuaveplanError` base so transport errors compose with cause-chain traversal, redaction, and OTel span-event adapters defined in `@suaveplan/error`.
## Features
- Frozen `HTTPS_TRANSPORT_CODES` constant covering nine failure classes spanning lifecycle, send, receive, authentication, and abort paths.
- `HttpsTransportErrorCode` literal union derived from the registry so call sites stay type-safe.
- `HttpsTransportError` class with the `SuaveplanError` constructor signature widened to public, accepting `(code, message, options?)` plus the standard `cause` field.
- Side-effect-safe registration via `defineCodes` so subpath imports automatically see codes registered.
## Usage: Basic Example
```ts
import {
HTTPS_TRANSPORT_CODES,
HttpsTransportError,
} from "@suaveplan/drive-transport-https";
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.SEND_FAILED,
"relay returned 503"
);
```
## Usage: Advanced Example
```ts
import {
HTTPS_TRANSPORT_CODES,
HttpsTransportError,
} from "@suaveplan/drive-transport-https";
try {
await transport.send(peerId, envelope);
} catch (err) {
if (err instanceof HttpsTransportError) {
switch (err.code) {
case HTTPS_TRANSPORT_CODES.AUTH_FAILED:
return refreshTokenAndRetry();
case HTTPS_TRANSPORT_CODES.RELAY_UNAVAILABLE:
return queueLocalAndBackoff(err);
default:
throw err;
}
}
throw err;
}
```
## API Reference
- `HTTPS_TRANSPORT_CODES` — `Readonly<Record<string, string>>` of `"HTTPS_TRANSPORT.<NAME>"` strings.
- `HttpsTransportErrorCode` — string-literal union of every value in `HTTPS_TRANSPORT_CODES`.
- `HttpsTransportError` — `class HttpsTransportError extends SuaveplanError<HttpsTransportErrorCode>` with constructor `(code, message, options?)`. Inherits the structured-redaction surface from `SuaveplanError`.
## Implementation Notes
The nine codes split cleanly into lifecycle (`NOT_CONNECTED` / `CLOSED` / `ABORTED` / `PEER_UNKNOWN`), wire (`SEND_FAILED` / `RECV_FAILED` / `MALFORMED_RESPONSE`), and policy (`AUTH_FAILED` / `RELAY_UNAVAILABLE`) groups so monitoring dashboards can attach SLOs per group without redacting cardinality. `RELAY_UNAVAILABLE` is reserved for transient 5xx classes; `SEND_FAILED` and `RECV_FAILED` are retained for non-retryable 4xx classes (other than `401` / `403` which become `AUTH_FAILED`). `MALFORMED_RESPONSE` fires when the relay returns 200 with a body that fails Zod boundary validation — typically signals a relay-version mismatch.

View File

@@ -0,0 +1,28 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const HTTPS_TRANSPORT_CODES = defineCodes("HTTPS_TRANSPORT", [
"NOT_CONNECTED",
"CLOSED",
"SEND_FAILED",
"RECV_FAILED",
"AUTH_FAILED",
"RELAY_UNAVAILABLE",
"MALFORMED_RESPONSE",
"ABORTED",
"PEER_UNKNOWN",
] as const);
export type HttpsTransportErrorCode =
(typeof HTTPS_TRANSPORT_CODES)[keyof typeof HTTPS_TRANSPORT_CODES];
export class HttpsTransportError extends SuaveplanError<HttpsTransportErrorCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
public constructor(
code: HttpsTransportErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}

View File

@@ -0,0 +1,31 @@
// Side-effect import ensures HTTPS_TRANSPORT_CODES is registered for
// subpath consumers that import only from a feature path.
import "./error-codes.js";
export {
HTTPS_TRANSPORT_CODES,
HttpsTransportError,
type HttpsTransportErrorCode,
} from "./error-codes.js";
export {
HttpsTransportOptionsSchema,
RecvResponseSchema,
SendBodySchema,
SendResponseSchema,
} from "./schemas/transport.schema.js";
export {
DEFAULT_BACKOFF_BASE_MS,
DEFAULT_BACKOFF_MAX_MS,
DEFAULT_LONG_POLL_TIMEOUT_MS,
DEFAULT_POLL_IDLE_DELAY_MS,
HttpsTransport,
RECV_BATCH_CAP,
} from "./transport/index.js";
export type {
FetchFn,
HttpsTransportOptions,
RecvResponse,
SendBody,
SendResponse,
Unsubscribe,
} from "./types/transport.types.js";

View File

@@ -0,0 +1,6 @@
export {
HttpsTransportOptionsSchema,
RecvResponseSchema,
SendBodySchema,
SendResponseSchema,
} from "./transport.schema.js";

View File

@@ -0,0 +1,55 @@
# transport.schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Boundary-only Zod schemas for `@suaveplan/drive-transport-https`. These schemas validate every external input the transport accepts: constructor options, relay `POST /send` request and response bodies, and relay `GET /recv` response bodies. Internal cross-module calls trust their inputs because every entry point at the package boundary either parses-or-throws via these schemas, so once a value is accepted it conforms to its declared type.
## Features
- `HttpsTransportOptionsSchema` — validates the constructor option bag (`relayUrl` must be a URL, `ourPeerId` non-empty, `backoffBaseMs ≤ backoffMaxMs`).
- `SendBodySchema` — validates `{ to, envelope }` request bodies before they hit the relay so a typo cannot leak through to wire format.
- `SendResponseSchema` — `{ ok, code? }` shape that the transport interprets as accepted vs policy-rejected.
- `RecvResponseSchema` — `{ envelopes, nextCursor }`; ensures every envelope returned by the relay matches the structural shape of `MessageEnvelope` before being dispatched to subscribers.
- `MessageEnvelope` shape mirrored verbatim from `@suaveplan/types`.
## Usage: Basic Example
```ts
import {
HttpsTransportOptionsSchema,
} from "@suaveplan/drive-transport-https/schemas";
const validated = HttpsTransportOptionsSchema.parse(rawOptions);
```
## Usage: Advanced Example
```ts
import {
RecvResponseSchema,
SendBodySchema,
} from "@suaveplan/drive-transport-https/schemas";
// On a custom relay-tester harness:
const body = SendBodySchema.parse(JSON.parse(rawRequestBody));
const recvJson = RecvResponseSchema.parse(await response.json());
for (const envelope of recvJson.envelopes) {
// envelope is a fully-typed MessageEnvelope here
}
```
## API Reference
- `HttpsTransportOptionsSchema` — accepts `HttpsTransportOptions`.
- `SendBodySchema` — accepts `SendBody`.
- `SendResponseSchema` — accepts `SendResponse`.
- `RecvResponseSchema` — accepts `RecvResponse`.
Each schema exposes Zod's standard surface: `.parse(value)` (throws on invalid) and `.safeParse(value)` (returns a discriminated `{ success, data | error }`).
## Implementation Notes
The schemas live in their own subpath export so consumers writing custom relay servers can import them without pulling in the runtime transport class. `relayUrl` requires a fully-qualified URL (file paths and relative URLs are rejected). The `backoffBaseMs ≤ backoffMaxMs` invariant is enforced via Zod's `refine` so the constructor cannot accept a config that would produce ever-shrinking backoffs. Nothing in this module loads at module evaluation time except Zod's schema graph, which is pure.

View File

@@ -0,0 +1,59 @@
/**
* Zod boundary schemas for `@suaveplan/drive-transport-https`.
*
* Every public entry point validates its inputs against these schemas
* so the rest of the package can trust its inputs without re-checking
* — schema: boundary-only.
*/
import * as z from "zod";
const PeerIdSchema = z
.string()
.min(1, "peer id must be non-empty")
.max(256, "peer id too long");
const HeadersSchema = z.record(z.string(), z.string());
const MessageEnvelopeSchema = z.object({
id: z.string().min(1),
type: z.string().min(1),
senderId: PeerIdSchema,
targetId: z.union([PeerIdSchema, z.literal("*")]),
payload: z.unknown(),
timestamp: z.number().int().nonnegative(),
headers: HeadersSchema,
});
export const SendBodySchema = z.object({
to: PeerIdSchema,
envelope: MessageEnvelopeSchema,
});
export const SendResponseSchema = z.object({
ok: z.boolean(),
code: z.string().optional(),
});
export const RecvResponseSchema = z.object({
envelopes: z.array(MessageEnvelopeSchema).readonly(),
nextCursor: z.string(),
});
export const HttpsTransportOptionsSchema = z
.object({
relayUrl: z.string().url("relayUrl must be an absolute URL"),
ourPeerId: PeerIdSchema,
authToken: z.string().min(1).optional(),
longPollTimeoutMs: z.number().int().positive().optional(),
pollIdleDelayMs: z.number().int().nonnegative().optional(),
backoffBaseMs: z.number().int().positive().optional(),
backoffMaxMs: z.number().int().positive().optional(),
})
.refine(
(v) =>
v.backoffBaseMs === undefined ||
v.backoffMaxMs === undefined ||
v.backoffBaseMs <= v.backoffMaxMs,
{ message: "backoffBaseMs must be <= backoffMaxMs" }
);

View File

@@ -0,0 +1,52 @@
# defaults
## Purpose
Centralises default values for `HttpsTransport` so every part of the package agrees on the same numbers. Living in its own module makes the constants discoverable and lets the typecheck-time schema cap derived values (e.g. backoffs) without circular imports.
## Features
- `DEFAULT_LONG_POLL_TIMEOUT_MS = 30_000` — relays hold the long-poll for up to thirty seconds when no messages are pending.
- `DEFAULT_POLL_IDLE_DELAY_MS = 0` — re-poll immediately after an empty long-poll. Tests pass a small non-zero value to synchronise loops with `FakeClock`.
- `DEFAULT_BACKOFF_BASE_MS = 250` — first retry waits ~250 ms; doubles each failure.
- `DEFAULT_BACKOFF_MAX_MS = 30_000` — maximum backoff between retries.
- `RECV_BATCH_CAP = 256` — receive-loop yields between dispatched batches above this size.
## Usage: Basic Example
```ts
import { DEFAULT_LONG_POLL_TIMEOUT_MS } from "@suaveplan/drive-transport-https";
console.log(`relays hold up to ${DEFAULT_LONG_POLL_TIMEOUT_MS} ms`);
```
## Usage: Advanced Example
```ts
import {
DEFAULT_BACKOFF_BASE_MS,
DEFAULT_BACKOFF_MAX_MS,
RECV_BATCH_CAP,
} from "@suaveplan/drive-transport-https";
const transport = new HttpsTransport({
relayUrl: "https://relay.example.com",
ourPeerId: "ab12...",
// Halve the defaults for a low-latency intranet:
backoffBaseMs: DEFAULT_BACKOFF_BASE_MS / 2,
backoffMaxMs: DEFAULT_BACKOFF_MAX_MS / 2,
});
console.log(`receive loop yields above ${RECV_BATCH_CAP} envelopes`);
```
## API Reference
- `DEFAULT_LONG_POLL_TIMEOUT_MS: number` — 30 000.
- `DEFAULT_POLL_IDLE_DELAY_MS: number` — 0.
- `DEFAULT_BACKOFF_BASE_MS: number` — 250.
- `DEFAULT_BACKOFF_MAX_MS: number` — 30 000.
- `RECV_BATCH_CAP: number` — 256.
## Implementation Notes
The defaults are deliberately conservative: thirty-second long-polls match the typical browser idle-tab timeout, so a tab that has been backgrounded for the full window naturally gets a single fresh poll on the next visibility transition rather than starving connection slots. The 250 ms backoff base keeps fast-flapping relays from hammering the receiver while still re-establishing the loop quickly under jitter. `RECV_BATCH_CAP` exists so a slow subscriber chain (logging, telemetry, etc.) cannot starve the next poll cycle on a noisy relay.

View File

@@ -0,0 +1,13 @@
/** Default option values for `HttpsTransport`. Documented and frozen. */
export const DEFAULT_LONG_POLL_TIMEOUT_MS = 30_000;
export const DEFAULT_POLL_IDLE_DELAY_MS = 0;
export const DEFAULT_BACKOFF_BASE_MS = 250;
export const DEFAULT_BACKOFF_MAX_MS = 30_000;
/**
* Cap on the number of envelopes the receive loop dispatches per
* tick. The relay will return up to this many per long-poll batch.
* Above this, the loop yields between batches so a single slow
* subscriber cannot block the next poll cycle.
*/
export const RECV_BATCH_CAP = 256;

View File

@@ -0,0 +1,96 @@
# https-transport
## Purpose
Implements `HttpsTransport`, the HTTPS-relay-backed `TransportLike<MessageEnvelope>` for `@suaveplan/drive-mesh` and any other consumer that wants a browser-friendly transport. Sending is a stateless `POST /send` to the relay; receiving is a long-poll `GET /recv` loop that runs in the background and dispatches every returned envelope to every subscribed handler. The transport is fully `AbortSignal`-aware, exponential-backoff-aware on transient failures, and Zod-validated at every wire boundary so a misconfigured relay produces a typed `HttpsTransportError` instead of a silently-mangled envelope.
## Features
- Implements `TransportLike<MessageEnvelope>` from `@suaveplan/types/transport` exactly — `connect` / `disconnect` / `send` / `onMessage` / `getConnectedPeers` plus extra `start` / `stop` / `cursor` / `state` lifecycle helpers.
- Long-poll receive loop with configurable `longPollTimeoutMs`, `pollIdleDelayMs`, and exponential backoff (`backoffBaseMs` / `backoffMaxMs` with equal-jitter).
- Zod-validated wire format: `SendBodySchema`, `SendResponseSchema`, `RecvResponseSchema` enforce the relay contract; mismatched bodies raise `HTTPS_TRANSPORT.MALFORMED_RESPONSE`.
- Distinct error codes per failure class: `NOT_CONNECTED` / `CLOSED` / `SEND_FAILED` / `RECV_FAILED` / `AUTH_FAILED` / `RELAY_UNAVAILABLE` / `MALFORMED_RESPONSE` / `ABORTED` / `PEER_UNKNOWN`.
- Auth-failure short-circuit: a `401`/`403` from `/recv` stops the loop instead of looping forever — back-pressure belongs to the consumer.
- Subscribers are observers: a handler that throws is swallowed so one bad subscriber cannot poison the loop.
- Receive-loop yield: dispatches above `RECV_BATCH_CAP` envelopes yield via `await Promise.resolve()` between batches so a noisy relay cannot starve the next poll cycle.
- Injectable `fetchFn`, `Clock`, `Rng` — every random / time / network choice is testable without `vi.useFakeTimers()`.
## Usage: Basic Example
```ts
import { HttpsTransport } from "@suaveplan/drive-transport-https";
const transport = new HttpsTransport({
relayUrl: "https://relay.example.com",
ourPeerId: "ab12...",
authToken: process.env.RELAY_TOKEN,
});
transport.start();
const off = transport.onMessage((envelope) => {
console.log("received", envelope.id);
});
await transport.connect("peer-b", "ignored");
await transport.send("peer-b", {
id: "msg-1",
type: "ping",
senderId: "ab12...",
targetId: "peer-b",
payload: new Uint8Array([1, 2, 3]),
timestamp: Date.now(),
headers: {},
});
off();
await transport.stop();
```
## Usage: Advanced Example
```ts
import {
HTTPS_TRANSPORT_CODES,
HttpsTransport,
HttpsTransportError,
} from "@suaveplan/drive-transport-https";
const transport = new HttpsTransport({
relayUrl: "https://relay.example.com",
ourPeerId: "ab12...",
authToken: refreshToken(),
longPollTimeoutMs: 10_000,
backoffBaseMs: 100,
backoffMaxMs: 2_000,
fetchFn: instrumentedFetch,
});
transport.start();
try {
await transport.send(remotePeerId, envelope);
} catch (err) {
if (
err instanceof HttpsTransportError &&
err.code === HTTPS_TRANSPORT_CODES.AUTH_FAILED
) {
await refreshAndRestart(transport);
} else {
throw err;
}
}
```
## API Reference
- `new HttpsTransport(options: HttpsTransportOptions)` — constructor; validates `options` against `HttpsTransportOptionsSchema`.
- `transport.start(): void` — begin the long-poll loop. Throws `HTTPS_TRANSPORT.CLOSED` if previously stopped. Idempotent while running.
- `transport.stop(): Promise<void>` — abort the loop; idempotent. After stop, the transport cannot be restarted.
- `transport.connect(peerId, address): Promise<void>` — register a peer. The HTTPS relay model is stateless so `address` is recorded but unused.
- `transport.disconnect(peerId): Promise<void>` — remove a peer.
- `transport.send(peerId, envelope): Promise<void>` — `POST /send`. Throws on auth, relay-unavailable, send-failed, malformed-response.
- `transport.onMessage(handler): Unsubscribe` — subscribe.
- `transport.getConnectedPeers(): readonly PeerId[]`.
- `transport.cursor: string` — the relay's last `nextCursor` (diagnostic only).
- `transport.state: "idle" | "running" | "stopped"`.
## Implementation Notes
The receive loop is a single async function that runs until the abort signal fires. On a transient failure (`RELAY_UNAVAILABLE` / `RECV_FAILED` / `MALFORMED_RESPONSE`) it backs off via `exp(failures) / 2 + jitter` clamped to `backoffMaxMs` and retries; on `AUTH_FAILED` it terminates the loop and propagates the error. Send is independent of the loop — even if the loop is in an extended backoff window, `send()` can still issue a `POST /send` because it shares only the `AbortController`'s signal with the loop, not the loop's retry state machine.
`stop()` aborts the controller and awaits the loop's promise (catching any propagated error so `stop()` itself remains a clean no-throw). Subsequent `start()` calls throw `HTTPS_TRANSPORT.CLOSED` to make double-lifecycle errors loud rather than silent. Auth failures inside `send()` propagate immediately (no backoff) because back-pressure for credential refresh belongs in the consumer.

View File

@@ -0,0 +1,430 @@
/**
* `HttpsTransport` — `TransportLike<MessageEnvelope>` over an HTTPS
* relay.
*
* Lifecycle:
* 1. `new HttpsTransport(options)` validates options.
* 2. `transport.start()` begins the long-poll receive loop.
* 3. `transport.connect(peerId, _address)` registers a peer for
* sending. The HTTPS relay model does not need a per-peer
* socket — sending is stateless — so `address` is recorded for
* symmetry with other transports but not used.
* 4. `transport.send(peerId, envelope)` `POST`s to the relay's
* `/send` endpoint with `{ to, envelope }`.
* 5. Subscribers registered via `onMessage(handler)` receive every
* envelope the relay returns from `GET /recv`.
* 6. `transport.disconnect(peerId)` removes the peer.
* 7. `transport.stop()` cancels the receive loop.
*/
import type {
Clock,
MessageEnvelope,
PeerId,
TransportLike,
} from "@suaveplan/types";
import { HTTPS_TRANSPORT_CODES, HttpsTransportError } from "../error-codes.js";
import {
HttpsTransportOptionsSchema,
RecvResponseSchema,
SendBodySchema,
SendResponseSchema,
} from "../schemas/transport.schema.js";
import type {
FetchFn,
HttpsTransportOptions,
Unsubscribe,
} from "../types/transport.types.js";
import {
DEFAULT_BACKOFF_BASE_MS,
DEFAULT_BACKOFF_MAX_MS,
DEFAULT_LONG_POLL_TIMEOUT_MS,
DEFAULT_POLL_IDLE_DELAY_MS,
RECV_BATCH_CAP,
} from "./defaults.js";
type State = "idle" | "running" | "stopped";
export class HttpsTransport implements TransportLike<MessageEnvelope> {
readonly #relayUrl: string;
readonly #ourPeerId: PeerId;
readonly #authToken: string | undefined;
readonly #fetchFn: FetchFn;
readonly #longPollTimeoutMs: number;
readonly #pollIdleDelayMs: number;
readonly #backoffBaseMs: number;
readonly #backoffMaxMs: number;
readonly #clock: Clock;
readonly #random: () => number;
readonly #peers = new Map<PeerId, string>();
readonly #handlers = new Set<(envelope: MessageEnvelope) => void>();
#state: State = "idle";
#cursor = "";
readonly #abortController = new AbortController();
#loopPromise: Promise<void> | undefined;
#lastAuthError: HttpsTransportError | undefined;
constructor(options: HttpsTransportOptions) {
HttpsTransportOptionsSchema.parse({
relayUrl: options.relayUrl,
ourPeerId: options.ourPeerId,
authToken: options.authToken,
longPollTimeoutMs: options.longPollTimeoutMs,
pollIdleDelayMs: options.pollIdleDelayMs,
backoffBaseMs: options.backoffBaseMs,
backoffMaxMs: options.backoffMaxMs,
});
this.#relayUrl = options.relayUrl.replace(/\/+$/, "");
this.#ourPeerId = options.ourPeerId;
this.#authToken = options.authToken;
this.#fetchFn = options.fetchFn ?? defaultFetch;
this.#longPollTimeoutMs =
options.longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
this.#pollIdleDelayMs =
options.pollIdleDelayMs ?? DEFAULT_POLL_IDLE_DELAY_MS;
this.#backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
this.#backoffMaxMs = options.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS;
this.#clock = options.clock ?? Date.now;
this.#random = options.random ?? Math.random;
}
/** Begin the long-poll receive loop. Idempotent. */
public start(): void {
if (this.#state === "stopped") {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.CLOSED,
"transport has been stopped and cannot be restarted"
);
}
if (this.#state === "running") return;
this.#state = "running";
this.#loopPromise = this.#runReceiveLoop(this.#abortController.signal);
}
/**
* Stop the receive loop and reject in-flight `send` operations.
* Idempotent. After stop, the transport cannot be restarted.
*/
public async stop(): Promise<void> {
if (this.#state === "stopped") return;
const wasRunning = this.#state === "running";
this.#state = "stopped";
if (wasRunning) {
this.#abortController.abort();
}
if (this.#loopPromise) {
// The loop is designed to never reject — every iteration's
// throws are caught either via the AUTH_FAILED fast-exit
// branch or via the backoff sleep path. Awaiting directly
// is therefore safe.
await this.#loopPromise;
}
this.#peers.clear();
this.#handlers.clear();
}
public async connect(peerId: PeerId, address: string): Promise<void> {
this.#assertActive();
this.#peers.set(peerId, address);
}
public async disconnect(peerId: PeerId): Promise<void> {
this.#peers.delete(peerId);
}
public async send(
peerId: PeerId,
envelope: MessageEnvelope
): Promise<void> {
this.#assertActive();
if (!this.#peers.has(peerId)) {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.PEER_UNKNOWN,
`peer ${peerId} is not connected`
);
}
const body = SendBodySchema.parse({ to: peerId, envelope });
const init: RequestInit = {
method: "POST",
headers: this.#requestHeaders(),
body: JSON.stringify(body),
signal: this.#abortController.signal,
};
let response: Response;
try {
response = await this.#fetchFn(`${this.#relayUrl}/send`, init);
} catch (cause) {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.SEND_FAILED,
`relay /send fetch threw: ${describeError(cause)}`,
{ cause: toError(cause) }
);
}
if (response.status === 401 || response.status === 403) {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.AUTH_FAILED,
`relay /send rejected auth (status ${response.status})`
);
}
if (response.status >= 500) {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.RELAY_UNAVAILABLE,
`relay /send returned ${response.status}`
);
}
if (!response.ok) {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.SEND_FAILED,
`relay /send returned ${response.status}`
);
}
let parsed: unknown;
try {
parsed = await response.json();
} catch (cause) {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.MALFORMED_RESPONSE,
`relay /send body was not JSON: ${describeError(cause)}`,
{ cause: toError(cause) }
);
}
const result = SendResponseSchema.safeParse(parsed);
if (!result.success) {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.MALFORMED_RESPONSE,
`relay /send returned an unexpected body: ${result.error.message}`
);
}
if (!result.data.ok) {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.SEND_FAILED,
`relay /send rejected: ${result.data.code ?? "unknown"}`
);
}
}
public onMessage(
handler: (envelope: MessageEnvelope) => void
): Unsubscribe {
this.#handlers.add(handler);
return () => {
this.#handlers.delete(handler);
};
}
public getConnectedPeers(): readonly PeerId[] {
return Array.from(this.#peers.keys());
}
/** Exposed for diagnostics — current cursor returned by /recv. */
public get cursor(): string {
return this.#cursor;
}
/** Exposed for diagnostics — current lifecycle state. */
public get state(): State {
return this.#state;
}
/**
* The auth error that terminated the receive loop, if any. The
* loop self-terminates on `401`/`403` rather than throwing because
* the loop promise is never awaited by the consumer; this getter
* lets the consumer inspect the failure and decide whether to
* refresh credentials and create a fresh transport.
*/
public get lastAuthError(): HttpsTransportError | undefined {
return this.#lastAuthError;
}
#requestHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"content-type": "application/json",
accept: "application/json",
};
if (this.#authToken !== undefined) {
headers["authorization"] = `Bearer ${this.#authToken}`;
}
return headers;
}
async #runReceiveLoop(signal: AbortSignal): Promise<void> {
let consecutiveFailures = 0;
while (this.#state === "running" && !signal.aborted) {
try {
const recvUrl = new URL(`${this.#relayUrl}/recv`);
recvUrl.searchParams.set("for", this.#ourPeerId);
if (this.#cursor !== "") {
recvUrl.searchParams.set("since", this.#cursor);
}
recvUrl.searchParams.set(
"timeoutMs",
String(this.#longPollTimeoutMs)
);
const response = await this.#fetchFn(recvUrl, {
method: "GET",
headers: this.#requestHeaders(),
signal,
});
if (signal.aborted) return;
if (response.status === 401 || response.status === 403) {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.AUTH_FAILED,
`relay /recv rejected auth (status ${response.status})`
);
}
if (response.status >= 500) {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.RELAY_UNAVAILABLE,
`relay /recv returned ${response.status}`
);
}
if (!response.ok) {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.RECV_FAILED,
`relay /recv returned ${response.status}`
);
}
let json: unknown;
try {
json = await response.json();
} catch (cause) {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.MALFORMED_RESPONSE,
`relay /recv body was not JSON: ${describeError(cause)}`,
{ cause: toError(cause) }
);
}
const parsed = RecvResponseSchema.safeParse(json);
if (!parsed.success) {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.MALFORMED_RESPONSE,
`relay /recv returned an unexpected body: ${parsed.error.message}`
);
}
consecutiveFailures = 0;
const { envelopes, nextCursor } = parsed.data;
this.#cursor = nextCursor;
let dispatchedSinceYield = 0;
for (const envelope of envelopes) {
for (const h of this.#handlers) {
try {
h(envelope as MessageEnvelope);
} catch {
// Handlers are observers — their crashes
// must not poison the loop. Swallowed
// intentionally.
}
}
dispatchedSinceYield++;
if (dispatchedSinceYield >= RECV_BATCH_CAP) {
dispatchedSinceYield = 0;
await Promise.resolve();
}
}
if (envelopes.length === 0 && this.#pollIdleDelayMs > 0) {
await sleep(this.#pollIdleDelayMs, signal);
}
} catch (err) {
if (
err instanceof HttpsTransportError &&
err.code === HTTPS_TRANSPORT_CODES.AUTH_FAILED
) {
// Auth failures terminate the loop — back-pressure
// belongs to the consumer. We surface the error via
// `lastAuthError` rather than throwing because the
// loop promise is never awaited by the consumer
// (only by `stop()`'s catch-and-discard) and an
// unhandled rejection here would surface as a
// process-level warning under vitest.
this.#lastAuthError = err;
this.#state = "stopped";
return;
}
consecutiveFailures++;
const wait = this.#computeBackoff(consecutiveFailures);
await sleep(wait, signal);
}
}
}
#computeBackoff(consecutiveFailures: number): number {
const exp = Math.min(
this.#backoffMaxMs,
this.#backoffBaseMs * 2 ** (consecutiveFailures - 1)
);
// Equal jitter: half deterministic, half random in [0, exp/2).
const jitter = (this.#random() * exp) / 2;
return Math.floor(exp / 2 + jitter);
}
#assertActive(): void {
if (this.#state === "idle") {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.NOT_CONNECTED,
"transport has not been started — call start() first"
);
}
if (this.#state === "stopped") {
throw new HttpsTransportError(
HTTPS_TRANSPORT_CODES.CLOSED,
"transport has been stopped"
);
}
}
}
function defaultFetch(
input: string | URL | Request,
init?: RequestInit
): Promise<Response> {
if (typeof globalThis.fetch !== "function") {
return Promise.reject(
new HttpsTransportError(
HTTPS_TRANSPORT_CODES.NOT_CONNECTED,
"no fetch implementation available — pass fetchFn"
)
);
}
return globalThis.fetch(input, init);
}
async function sleep(ms: number, signal: AbortSignal): Promise<void> {
// No `signal.aborted` early-return: the recv loop only reaches the
// catch's `await sleep(...)` while the loop's `while (state === "running"
// && !signal.aborted)` condition still holds, and there is no yield
// point between catch entry and this call — so a pre-aborted signal is
// unreachable here. If sleep is ever called from a new caller that
// doesn't enforce that invariant, prefer fixing the caller over re-
// adding the guard.
if (ms <= 0) return;
return new Promise<void>((resolve) => {
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
resolve();
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
function describeError(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
function toError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err));
}

View File

@@ -0,0 +1,8 @@
export {
DEFAULT_BACKOFF_BASE_MS,
DEFAULT_BACKOFF_MAX_MS,
DEFAULT_LONG_POLL_TIMEOUT_MS,
DEFAULT_POLL_IDLE_DELAY_MS,
RECV_BATCH_CAP,
} from "./defaults.js";
export { HttpsTransport } from "./https-transport.js";

View File

@@ -0,0 +1,8 @@
export type {
FetchFn,
HttpsTransportOptions,
RecvResponse,
SendBody,
SendResponse,
Unsubscribe,
} from "./transport.types.js";

View File

@@ -0,0 +1,68 @@
# transport.types
## Purpose
Shared public types for `@suaveplan/drive-transport-https`. Defines the constructor option bag (`HttpsTransportOptions`), the relay request / response shapes (`SendBody` / `SendResponse` / `RecvResponse`), the `FetchFn` injection point that makes the whole package testable without a network, and the `Unsubscribe` handle type returned by `onMessage`. Living in `types/` keeps the runtime modules focused on behaviour and leaves a single, documented surface for downstream consumers (drive-mesh, browser apps) to import.
## Features
- `HttpsTransportOptions` — every knob the transport exposes: `relayUrl`, `ourPeerId`, `authToken?`, `fetchFn?`, `longPollTimeoutMs?`, `pollIdleDelayMs?`, `backoffBaseMs?`, `backoffMaxMs?`, `clock?`, `rng?`.
- `FetchFn` — a `fetch`-shaped injection point; production defaults to `globalThis.fetch` and tests pass a deterministic stub.
- `SendBody` / `SendResponse` — the relay's `POST /send` contract with `{ ok, code? }` policy responses.
- `RecvResponse` — the relay's `GET /recv` response with `envelopes` plus a `nextCursor` for at-least-once delivery semantics.
- `Unsubscribe` — alias for `() => void` returned by the subscription API.
## Usage: Basic Example
```ts
import type {
HttpsTransportOptions,
} from "@suaveplan/drive-transport-https/types";
const options: HttpsTransportOptions = {
relayUrl: "https://relay.example.com",
ourPeerId: "ab12...",
authToken: process.env.RELAY_TOKEN,
};
```
## Usage: Advanced Example
```ts
import type {
FetchFn,
HttpsTransportOptions,
RecvResponse,
} from "@suaveplan/drive-transport-https/types";
const fakeFetch: FetchFn = async (input) => {
if (String(input).includes("/recv")) {
const body: RecvResponse = { envelopes: [], nextCursor: "0" };
return new Response(JSON.stringify(body), { status: 200 });
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
};
const options: HttpsTransportOptions = {
relayUrl: "https://relay.test",
ourPeerId: "deadbeef",
fetchFn: fakeFetch,
longPollTimeoutMs: 1_000,
pollIdleDelayMs: 25,
backoffBaseMs: 50,
backoffMaxMs: 500,
};
```
## API Reference
- `interface HttpsTransportOptions` — see "Features" for fields. All optional fields default through `defaults.ts`.
- `type FetchFn = (input: string | URL, init?: RequestInit) => Promise<Response>`.
- `interface RecvResponse { envelopes: ReadonlyArray<MessageEnvelope>; nextCursor: string }`.
- `interface SendBody { to: PeerId; envelope: MessageEnvelope }`.
- `interface SendResponse { ok: boolean; code?: string }`.
- `type Unsubscribe = () => void`.
## Implementation Notes
`HttpsTransportOptions` is `readonly` end-to-end so the transport can capture the option bag once at construction without copying. Defaults are applied internally; consumers are not required to set anything beyond `relayUrl` and `ourPeerId`. The clock and RNG injection points exist purely so deterministic tests do not need `vi.useFakeTimers()` — `FakeClock` from `@suaveplan/testing` is the canonical pattern.

View File

@@ -0,0 +1,103 @@
/**
* Public types for `@suaveplan/drive-transport-https`.
*
* The transport adheres to `TransportLike<MessageEnvelope>` from
* `@suaveplan/types/transport` with one extra runtime convenience:
* lifecycle is explicit (`start()` / `stop()`), so the receive-loop
* does not start spinning until the consumer is ready.
*/
import type {
Clock,
FetchLike,
MessageEnvelope,
PeerId,
} from "@suaveplan/types";
/**
* A `fetch`-shaped function. Injectable so tests can drive responses.
*
* Backwards-compatibility alias for `FetchLike` from `@suaveplan/types` —
* the canonical workspace contract. Existing consumers that import
* `FetchFn` from this package keep working unchanged.
*/
export type FetchFn = FetchLike;
export interface HttpsTransportOptions {
/** Relay URL — the base URL of the relay server. */
readonly relayUrl: string;
/** Our own peer id — used as the `for` query in `recv` calls. */
readonly ourPeerId: PeerId;
/** Optional bearer token sent in `Authorization`. */
readonly authToken?: string;
/**
* `fetch` implementation. Defaults to `globalThis.fetch` when
* available; tests inject a fake.
*/
readonly fetchFn?: FetchFn;
/**
* Long-poll request timeout in milliseconds. The relay holds the
* connection up to this long when there are no pending messages.
* Default: 30_000 (30 s).
*/
readonly longPollTimeoutMs?: number;
/**
* Idle delay between successful empty long-polls. Default: 0
* (immediate re-poll). Tests pass a small non-zero value to
* synchronise spin loops with FakeClock advances.
*/
readonly pollIdleDelayMs?: number;
/**
* Backoff base in ms for transient relay failures. Default:
* 250 ms — first retry waits ~250 ms, doubles each failure up to
* `backoffMaxMs`.
*/
readonly backoffBaseMs?: number;
/** Max backoff in ms. Default: 30_000 (30 s). */
readonly backoffMaxMs?: number;
/** Optional clock — defaults to `Date.now`. */
readonly clock?: Clock;
/**
* Optional jitter source — defaults to `Math.random`. Returns a
* float in `[0, 1)`. Used only for backoff jitter, never for
* cryptographic decisions.
*/
readonly random?: () => number;
}
/**
* The wire shape relays return from `GET /recv?for=...&since=...`.
* The transport forwards `envelopes` to subscribed handlers and
* persists `nextCursor` for the following call.
*/
export interface RecvResponse {
readonly envelopes: ReadonlyArray<MessageEnvelope>;
readonly nextCursor: string;
}
/**
* The shape `POST /send` accepts. The relay returns `{ ok: true }`
* (status 200) on accepted submissions, `{ ok: false, code }` on
* policy rejections.
*/
export interface SendBody {
readonly to: PeerId;
readonly envelope: MessageEnvelope;
}
export interface SendResponse {
readonly ok: boolean;
readonly code?: string;
}
/** Subscription handle returned by `onMessage`. */
export type Unsubscribe = () => void;

View File

@@ -0,0 +1,92 @@
/**
* Shared test helpers for the HttpsTransport test suite. Centralises
* fake fetch construction so each test file stays focused on the
* behaviour under test.
*/
import type { MessageEnvelope, PeerId } from "@suaveplan/types";
import type { FetchFn } from "../src/types/transport.types.js";
export interface FakeFetchCall {
readonly url: string;
readonly method: string;
readonly headers: Readonly<Record<string, string>>;
readonly body: string | undefined;
}
export interface FakeFetchOptions {
readonly responder: (call: FakeFetchCall) => Response | Promise<Response>;
readonly history?: FakeFetchCall[];
}
export function makeFakeFetch(options: FakeFetchOptions): FetchFn {
const history = options.history ?? [];
return async (
input: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
const method = init?.method ?? "GET";
const headers = init?.headers
? Object.fromEntries(
Object.entries(init.headers as Record<string, string>).map(
([k, v]) => [k.toLowerCase(), v]
)
)
: {};
const body = typeof init?.body === "string" ? init.body : undefined;
const call: FakeFetchCall = { url, method, headers, body };
history.push(call);
if (init?.signal?.aborted) {
throw new DOMException("aborted", "AbortError");
}
return options.responder(call);
};
}
export function jsonResponse(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json" },
});
}
export function makeEnvelope(
overrides: Partial<MessageEnvelope> = {}
): MessageEnvelope {
return {
id: overrides.id ?? "msg-1",
type: overrides.type ?? "ping",
senderId: overrides.senderId ?? "peer-a",
targetId: overrides.targetId ?? "peer-b",
payload: overrides.payload ?? new Uint8Array([1, 2, 3]),
timestamp: overrides.timestamp ?? 1_000,
headers: overrides.headers ?? {},
} as MessageEnvelope;
}
export const TEST_PEER: PeerId = "peer-b";
/** Yield the microtask queue several times to let async loops progress. */
export async function flushMicrotasks(times = 4): Promise<void> {
for (let i = 0; i < times; i++) {
await Promise.resolve();
}
}
/**
* Wait until `predicate()` returns true or the deadline expires.
* Polls with a small real-time delay so backoff timers (which use
* real `setTimeout`) get a chance to fire between checks.
*/
export async function waitUntil(
predicate: () => boolean,
timeoutMs = 2_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await new Promise<void>((r) => setTimeout(r, 5));
}
throw new Error("waitUntil: condition never satisfied");
}

View File

@@ -0,0 +1,72 @@
/**
* Tests for the `defaultFetch` fallback path: when no `fetchFn` is
* passed and `globalThis.fetch` is missing the call rejects with
* `NOT_CONNECTED`. These tests sit in their own file so the
* globalThis.fetch monkey-patching cannot leak into the
* loop-oriented suites.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { HttpsTransport, HttpsTransportError } from "../src/index.js";
import { makeEnvelope } from "./_helpers.js";
const RELAY = "https://relay.test";
describe("HttpsTransport — defaultFetch fallback", () => {
it("send rejects with HttpsTransportError when globalThis.fetch is missing", {
timeout: 5000,
}, async () => {
const orig = globalThis.fetch;
// @ts-expect-error — deliberately remove
globalThis.fetch = undefined;
try {
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
backoffBaseMs: 1,
backoffMaxMs: 5,
longPollTimeoutMs: 10,
pollIdleDelayMs: 0,
});
t.start();
await t.connect("peer-b", "x");
await expect(
t.send("peer-b", makeEnvelope())
).rejects.toBeInstanceOf(HttpsTransportError);
await t.stop();
} finally {
globalThis.fetch = orig;
}
});
it("uses globalThis.fetch when no fetchFn is provided", {
timeout: 5000,
}, async () => {
const orig = globalThis.fetch;
let called = 0;
const stub = async (_url: string | URL): Promise<Response> => {
called++;
await Promise.resolve();
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
globalThis.fetch = stub as typeof fetch;
try {
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
longPollTimeoutMs: 10,
pollIdleDelayMs: 0,
});
t.start();
await t.connect("peer-b", "x");
await t.send("peer-b", makeEnvelope());
await t.stop();
expect(called).toBeGreaterThan(0);
} finally {
globalThis.fetch = orig;
}
});
});

View File

@@ -0,0 +1,51 @@
/**
* HTTPS_TRANSPORT_CODES spec conformance — registry frozen, every
* code is namespaced under `HTTPS_TRANSPORT.`, every code is usable
* to construct an `HttpsTransportError`.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { HTTPS_TRANSPORT_CODES, HttpsTransportError } from "../src/index.js";
describe("HTTPS_TRANSPORT_CODES is frozen and namespaced", () => {
it("HTTPS_TRANSPORT_CODES is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(HTTPS_TRANSPORT_CODES)).toBe(true);
});
it("every value namespaced under HTTPS_TRANSPORT.", {
timeout: 5000,
}, () => {
for (const v of Object.values(HTTPS_TRANSPORT_CODES)) {
expect(v).toMatch(/^HTTPS_TRANSPORT\./);
}
});
it("every code constructs an HttpsTransportError that preserves it", {
timeout: 5000,
}, () => {
for (const code of Object.values(HTTPS_TRANSPORT_CODES)) {
const e = new HttpsTransportError(code, "ok");
expect(e.code).toBe(code);
expect(e).toBeInstanceOf(HttpsTransportError);
}
});
it("registry contains the nine documented codes", {
timeout: 5000,
}, () => {
const expected = [
"NOT_CONNECTED",
"CLOSED",
"SEND_FAILED",
"RECV_FAILED",
"AUTH_FAILED",
"RELAY_UNAVAILABLE",
"MALFORMED_RESPONSE",
"ABORTED",
"PEER_UNKNOWN",
] as const;
for (const k of expected) {
expect(HTTPS_TRANSPORT_CODES).toHaveProperty(k);
}
});
});

View File

@@ -0,0 +1,211 @@
/**
* Lifecycle and option-validation tests for `HttpsTransport`.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
HTTPS_TRANSPORT_CODES,
HttpsTransport,
type HttpsTransportError,
} from "../src/index.js";
import { jsonResponse, makeFakeFetch } from "./_helpers.js";
const RELAY = "https://relay.test";
describe("HttpsTransport — lifecycle", () => {
it("constructor rejects an invalid relayUrl", { timeout: 5000 }, () => {
expect(
() =>
new HttpsTransport({
relayUrl: "not-a-url",
ourPeerId: "peer-a",
})
).toThrow();
});
it("constructor rejects empty ourPeerId", { timeout: 5000 }, () => {
expect(
() =>
new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "",
})
).toThrow();
});
it("constructor rejects backoffBaseMs > backoffMaxMs", {
timeout: 5000,
}, () => {
expect(
() =>
new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
backoffBaseMs: 1000,
backoffMaxMs: 100,
})
).toThrow();
});
it("start is idempotent while running", { timeout: 5000 }, async () => {
const fetchFn = makeFakeFetch({
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 50,
pollIdleDelayMs: 5,
});
t.start();
t.start();
expect(t.state).toBe("running");
await t.stop();
});
it("start after stop throws CLOSED", { timeout: 5000 }, async () => {
const fetchFn = makeFakeFetch({
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
});
t.start();
await t.stop();
try {
t.start();
throw new Error("expected CLOSED");
} catch (err) {
expect((err as HttpsTransportError).code).toBe(
HTTPS_TRANSPORT_CODES.CLOSED
);
}
});
it("stop is idempotent", { timeout: 5000 }, async () => {
const fetchFn = makeFakeFetch({
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
});
t.start();
await t.stop();
await t.stop();
expect(t.state).toBe("stopped");
});
it("stop before start does not throw", { timeout: 5000 }, async () => {
const fetchFn = makeFakeFetch({
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
});
await t.stop();
expect(t.state).toBe("stopped");
});
it("connect/disconnect track peers and getConnectedPeers reports them", {
timeout: 5000,
}, async () => {
const fetchFn = makeFakeFetch({
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 50,
pollIdleDelayMs: 5,
});
t.start();
await t.connect("peer-b", "ignored");
await t.connect("peer-c", "ignored");
expect(t.getConnectedPeers()).toEqual(["peer-b", "peer-c"]);
await t.disconnect("peer-b");
expect(t.getConnectedPeers()).toEqual(["peer-c"]);
// disconnect on an unknown peer is a no-op
await t.disconnect("peer-x");
expect(t.getConnectedPeers()).toEqual(["peer-c"]);
await t.stop();
});
it("connect before start throws NOT_CONNECTED", {
timeout: 5000,
}, async () => {
const fetchFn = makeFakeFetch({
responder: () => jsonResponse({ ok: true }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
});
await expect(t.connect("peer-b", "x")).rejects.toMatchObject({
code: HTTPS_TRANSPORT_CODES.NOT_CONNECTED,
});
});
it("cursor starts empty and updates after a successful /recv", {
timeout: 5000,
}, async () => {
let recvCalls = 0;
const fetchFn = makeFakeFetch({
responder: (call) => {
if (call.url.includes("/recv")) {
recvCalls++;
return jsonResponse({
envelopes: [],
nextCursor: "c-1",
});
}
return jsonResponse({ ok: true });
},
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 25,
pollIdleDelayMs: 5,
});
expect(t.cursor).toBe("");
expect(t.state).toBe("idle");
t.start();
// Wait for at least one successful recv cycle to complete.
const start = Date.now();
while (t.cursor === "" && Date.now() - start < 2000) {
await new Promise<void>((r) => setTimeout(r, 5));
}
expect(t.cursor).toBe("c-1");
expect(recvCalls).toBeGreaterThan(0);
await t.stop();
});
it("clears handlers and peers on stop", { timeout: 5000 }, async () => {
const fetchFn = makeFakeFetch({
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 25,
});
t.start();
await t.connect("peer-b", "x");
const off = t.onMessage(() => undefined);
await t.stop();
expect(t.getConnectedPeers()).toEqual([]);
// Calling unsubscribe after stop is a no-op (set already cleared).
off();
});
});

View File

@@ -0,0 +1,442 @@
/**
* `HttpsTransport` long-poll receive loop — covers happy dispatch,
* cursor advance, malformed-body retry, transient-failure backoff,
* auth-failure termination, batch-yield, and abort.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { MessageEnvelope } from "@suaveplan/types";
import {
HTTPS_TRANSPORT_CODES,
HttpsTransport,
RECV_BATCH_CAP,
} from "../src/index.js";
import {
type FakeFetchCall,
jsonResponse,
makeEnvelope,
makeFakeFetch,
waitUntil,
} from "./_helpers.js";
const RELAY = "https://relay.test";
describe("HttpsTransport.recv — long-poll loop", () => {
it("dispatches envelopes returned by /recv to handlers and advances cursor", {
timeout: 5000,
}, async () => {
let recvCalls = 0;
const envelopes = [
makeEnvelope({ id: "a" }),
makeEnvelope({ id: "b" }),
];
const fetchFn = makeFakeFetch({
responder: (call) => {
if (call.url.includes("/recv")) {
recvCalls++;
if (recvCalls === 1) {
return jsonResponse({
envelopes,
nextCursor: "cursor-1",
});
}
return jsonResponse({
envelopes: [],
nextCursor: "cursor-1",
});
}
return jsonResponse({ ok: true });
},
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 50,
pollIdleDelayMs: 10,
});
const seen: MessageEnvelope[] = [];
t.onMessage((env) => seen.push(env));
t.start();
await waitUntil(() => seen.length === 2);
expect(seen[0]?.id).toBe("a");
expect(seen[1]?.id).toBe("b");
expect(t.cursor).toBe("cursor-1");
await t.stop();
});
it("includes since=cursor on subsequent /recv calls", {
timeout: 5000,
}, async () => {
const history: FakeFetchCall[] = [];
let recvCalls = 0;
const fetchFn = makeFakeFetch({
responder: (call) => {
if (call.url.includes("/recv")) {
recvCalls++;
if (recvCalls === 1) {
return jsonResponse({
envelopes: [makeEnvelope()],
nextCursor: "cursor-1",
});
}
return jsonResponse({
envelopes: [],
nextCursor: "cursor-1",
});
}
return jsonResponse({ ok: true });
},
history,
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 25,
pollIdleDelayMs: 5,
});
t.onMessage(() => undefined);
t.start();
await waitUntil(() => recvCalls >= 2);
const secondRecv = history.filter((c) => c.url.includes("/recv"))[1];
expect(secondRecv?.url).toContain("since=cursor-1");
await t.stop();
});
it("AUTH_FAILED on /recv stops the loop", { timeout: 5000 }, async () => {
let recvCalls = 0;
const fetchFn = makeFakeFetch({
responder: (call) => {
if (call.url.includes("/recv")) {
recvCalls++;
return new Response("nope", { status: 403 });
}
return jsonResponse({ ok: true });
},
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 25,
});
t.start();
await waitUntil(() => t.state === "stopped");
expect(recvCalls).toBe(1);
await t.stop();
});
it("backs off on 503 and retries", { timeout: 5000 }, async () => {
let recvCalls = 0;
const fetchFn = makeFakeFetch({
responder: (call) => {
if (call.url.includes("/recv")) {
recvCalls++;
if (recvCalls < 3) {
return new Response("down", { status: 503 });
}
return jsonResponse({ envelopes: [], nextCursor: "ok" });
}
return jsonResponse({ ok: true });
},
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 25,
backoffBaseMs: 1,
backoffMaxMs: 5,
pollIdleDelayMs: 5,
});
t.start();
await waitUntil(() => t.cursor === "ok");
expect(recvCalls).toBeGreaterThanOrEqual(3);
await t.stop();
});
it("backs off on 4xx other than auth (RECV_FAILED) and retries", {
timeout: 5000,
}, async () => {
let recvCalls = 0;
const fetchFn = makeFakeFetch({
responder: (call) => {
if (call.url.includes("/recv")) {
recvCalls++;
if (recvCalls < 2) {
return new Response("bad", { status: 418 });
}
return jsonResponse({ envelopes: [], nextCursor: "ok" });
}
return jsonResponse({ ok: true });
},
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 25,
backoffBaseMs: 1,
backoffMaxMs: 5,
pollIdleDelayMs: 5,
});
t.start();
await waitUntil(() => t.cursor === "ok");
expect(recvCalls).toBeGreaterThanOrEqual(2);
await t.stop();
});
it("backs off on malformed JSON body (MALFORMED_RESPONSE) and retries", {
timeout: 5000,
}, async () => {
let recvCalls = 0;
const fetchFn = makeFakeFetch({
responder: (call) => {
if (call.url.includes("/recv")) {
recvCalls++;
if (recvCalls < 2) {
return new Response("not-json{{", {
status: 200,
headers: { "content-type": "application/json" },
});
}
return jsonResponse({ envelopes: [], nextCursor: "ok" });
}
return jsonResponse({ ok: true });
},
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 25,
backoffBaseMs: 1,
backoffMaxMs: 5,
pollIdleDelayMs: 5,
});
t.start();
await waitUntil(() => t.cursor === "ok");
expect(recvCalls).toBeGreaterThanOrEqual(2);
await t.stop();
});
it("backs off on unexpected JSON shape (MALFORMED_RESPONSE) and retries", {
timeout: 5000,
}, async () => {
let recvCalls = 0;
const fetchFn = makeFakeFetch({
responder: (call) => {
if (call.url.includes("/recv")) {
recvCalls++;
if (recvCalls < 2) {
return jsonResponse({ wrong: "shape" });
}
return jsonResponse({ envelopes: [], nextCursor: "ok" });
}
return jsonResponse({ ok: true });
},
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 25,
backoffBaseMs: 1,
backoffMaxMs: 5,
pollIdleDelayMs: 5,
});
t.start();
await waitUntil(() => t.cursor === "ok");
expect(recvCalls).toBeGreaterThanOrEqual(2);
await t.stop();
});
it("yields after RECV_BATCH_CAP envelopes per long-poll batch", {
timeout: 10000,
}, async () => {
let dispatched = 0;
const big = Array.from({ length: RECV_BATCH_CAP + 5 }, (_, i) =>
makeEnvelope({ id: `e-${i}` })
);
let recvCalls = 0;
const fetchFn = makeFakeFetch({
responder: (call) => {
if (call.url.includes("/recv")) {
recvCalls++;
if (recvCalls === 1) {
return jsonResponse({
envelopes: big,
nextCursor: "post-batch",
});
}
return jsonResponse({
envelopes: [],
nextCursor: "post-batch",
});
}
return jsonResponse({ ok: true });
},
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 50,
pollIdleDelayMs: 5,
});
t.onMessage(() => {
dispatched++;
});
t.start();
await waitUntil(() => dispatched === big.length);
await t.stop();
});
it("a handler that throws does not poison the loop", {
timeout: 5000,
}, async () => {
let recvCalls = 0;
const fetchFn = makeFakeFetch({
responder: (call) => {
if (call.url.includes("/recv")) {
recvCalls++;
if (recvCalls <= 2) {
return jsonResponse({
envelopes: [makeEnvelope({ id: `e-${recvCalls}` })],
nextCursor: `c-${recvCalls}`,
});
}
return jsonResponse({
envelopes: [],
nextCursor: `c-${recvCalls}`,
});
}
return jsonResponse({ ok: true });
},
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 25,
pollIdleDelayMs: 5,
});
const seen: string[] = [];
t.onMessage(() => {
throw new Error("subscriber-A");
});
t.onMessage((e) => {
seen.push(e.id);
});
t.start();
await waitUntil(() => seen.length >= 2);
await t.stop();
});
it("unsubscribed handlers stop receiving envelopes", {
timeout: 5000,
}, async () => {
// Single-shot fetch: return one envelope then empty forever so
// the loop quiesces rather than spinning.
let recvCalls = 0;
const fetchFn = makeFakeFetch({
responder: (call) => {
if (call.url.includes("/recv")) {
recvCalls++;
if (recvCalls === 1) {
return jsonResponse({
envelopes: [makeEnvelope({ id: "first" })],
nextCursor: "c-1",
});
}
return jsonResponse({ envelopes: [], nextCursor: "c-1" });
}
return jsonResponse({ ok: true });
},
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 25,
pollIdleDelayMs: 5,
});
const seen: string[] = [];
const off = t.onMessage((e) => seen.push(e.id));
t.start();
await waitUntil(() => seen.length >= 1);
expect(seen).toEqual(["first"]);
// After unsubscribing, the same handler should never be called
// again — but we still need to verify the loop kept running.
off();
const recvBeforeStop = recvCalls;
await waitUntil(() => recvCalls > recvBeforeStop);
await t.stop();
expect(seen).toEqual(["first"]);
});
it("AUTH_FAILED on /recv propagates via lastAuthError", {
timeout: 5000,
}, async () => {
const fetchFn = makeFakeFetch({
responder: (call) => {
if (call.url.includes("/recv")) {
return new Response("nope", { status: 401 });
}
return jsonResponse({ ok: true });
},
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 25,
});
expect(t.lastAuthError).toBeUndefined();
t.start();
await waitUntil(() => t.lastAuthError !== undefined);
expect(t.state).toBe("stopped");
const last = t.lastAuthError;
expect(last).toBeDefined();
expect(last?.code).toBe(HTTPS_TRANSPORT_CODES.AUTH_FAILED);
await t.stop();
});
it("handles zero-ms backoff sleep deterministically", {
timeout: 5000,
}, async () => {
let recvCalls = 0;
const fetchFn = makeFakeFetch({
responder: (call) => {
if (call.url.includes("/recv")) {
recvCalls++;
if (recvCalls < 2) {
return new Response("bad", { status: 500 });
}
return jsonResponse({
envelopes: [],
nextCursor: "ok",
});
}
return jsonResponse({ ok: true });
},
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-zero",
fetchFn,
longPollTimeoutMs: 25,
backoffBaseMs: 1,
backoffMaxMs: 5,
pollIdleDelayMs: 5,
// Zero jitter — `computeBackoff(1)` = floor(1/2 + 0) = 0,
// forcing the `sleep(0, signal)` early-return path.
random: () => 0,
});
t.start();
await waitUntil(() => t.cursor === "ok");
expect(recvCalls).toBeGreaterThanOrEqual(2);
await t.stop();
});
});

View File

@@ -0,0 +1,143 @@
/**
* Boundary schema tests — confirm every schema rejects malformed
* input and accepts the canonical shape.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
HttpsTransportOptionsSchema,
RecvResponseSchema,
SendBodySchema,
SendResponseSchema,
} from "../src/index.js";
import { makeEnvelope } from "./_helpers.js";
describe("HttpsTransportOptionsSchema", () => {
it("accepts a minimal valid option bag", { timeout: 5000 }, () => {
const r = HttpsTransportOptionsSchema.safeParse({
relayUrl: "https://r.test",
ourPeerId: "peer-a",
});
expect(r.success).toBe(true);
});
it("rejects relative URL", { timeout: 5000 }, () => {
const r = HttpsTransportOptionsSchema.safeParse({
relayUrl: "/relative",
ourPeerId: "peer-a",
});
expect(r.success).toBe(false);
});
it("rejects empty ourPeerId", { timeout: 5000 }, () => {
const r = HttpsTransportOptionsSchema.safeParse({
relayUrl: "https://r.test",
ourPeerId: "",
});
expect(r.success).toBe(false);
});
it("rejects backoffBaseMs > backoffMaxMs", { timeout: 5000 }, () => {
const r = HttpsTransportOptionsSchema.safeParse({
relayUrl: "https://r.test",
ourPeerId: "peer-a",
backoffBaseMs: 1000,
backoffMaxMs: 100,
});
expect(r.success).toBe(false);
});
it("rejects non-positive longPollTimeoutMs", { timeout: 5000 }, () => {
const r = HttpsTransportOptionsSchema.safeParse({
relayUrl: "https://r.test",
ourPeerId: "peer-a",
longPollTimeoutMs: 0,
});
expect(r.success).toBe(false);
});
it("accepts only one backoff bound", { timeout: 5000 }, () => {
const a = HttpsTransportOptionsSchema.safeParse({
relayUrl: "https://r.test",
ourPeerId: "peer-a",
backoffBaseMs: 100,
});
expect(a.success).toBe(true);
const b = HttpsTransportOptionsSchema.safeParse({
relayUrl: "https://r.test",
ourPeerId: "peer-a",
backoffMaxMs: 100,
});
expect(b.success).toBe(true);
});
});
describe("SendBodySchema", () => {
it("accepts a canonical body", { timeout: 5000 }, () => {
const r = SendBodySchema.safeParse({
to: "peer-b",
envelope: makeEnvelope(),
});
expect(r.success).toBe(true);
});
it("rejects non-string envelope id", { timeout: 5000 }, () => {
const r = SendBodySchema.safeParse({
to: "peer-b",
envelope: { ...makeEnvelope(), id: 42 },
});
expect(r.success).toBe(false);
});
it("accepts broadcast targetId", { timeout: 5000 }, () => {
const r = SendBodySchema.safeParse({
to: "peer-b",
envelope: { ...makeEnvelope(), targetId: "*" },
});
expect(r.success).toBe(true);
});
});
describe("SendResponseSchema", () => {
it("accepts { ok: true }", { timeout: 5000 }, () => {
expect(SendResponseSchema.safeParse({ ok: true }).success).toBe(true);
});
it("accepts { ok: false, code: 'POLICY' }", { timeout: 5000 }, () => {
expect(
SendResponseSchema.safeParse({ ok: false, code: "POLICY" }).success
).toBe(true);
});
it("rejects missing ok", { timeout: 5000 }, () => {
expect(SendResponseSchema.safeParse({ code: "X" }).success).toBe(false);
});
});
describe("RecvResponseSchema", () => {
it("accepts { envelopes: [...], nextCursor: 'x' }", {
timeout: 5000,
}, () => {
expect(
RecvResponseSchema.safeParse({
envelopes: [makeEnvelope()],
nextCursor: "x",
}).success
).toBe(true);
});
it("rejects missing nextCursor", { timeout: 5000 }, () => {
expect(RecvResponseSchema.safeParse({ envelopes: [] }).success).toBe(
false
);
});
it("rejects an envelope missing required fields", { timeout: 5000 }, () => {
expect(
RecvResponseSchema.safeParse({
envelopes: [{ id: "x" }],
nextCursor: "n",
}).success
).toBe(false);
});
});

View File

@@ -0,0 +1,383 @@
/**
* `HttpsTransport.send` — covers happy path, 4xx, 5xx, malformed
* response bodies, ok:false rejections, fetch-throws, and lifecycle
* preconditions.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
HTTPS_TRANSPORT_CODES,
HttpsTransport,
HttpsTransportError,
} from "../src/index.js";
import {
type FakeFetchCall,
jsonResponse,
makeEnvelope,
makeFakeFetch,
TEST_PEER,
} from "./_helpers.js";
const RELAY = "https://relay.test";
describe("HttpsTransport.send — wire flow", () => {
it("posts to /send with bearer auth and the envelope body", {
timeout: 5000,
}, async () => {
const history: FakeFetchCall[] = [];
const fetchFn = makeFakeFetch({
responder: (call) =>
call.url.endsWith("/send")
? jsonResponse({ ok: true })
: jsonResponse({ envelopes: [], nextCursor: "0" }),
history,
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
authToken: "secret",
fetchFn,
longPollTimeoutMs: 100,
});
t.start();
await t.connect(TEST_PEER, "ignored");
await t.send(TEST_PEER, makeEnvelope());
const sendCall = history.find((c) => c.url === `${RELAY}/send`);
expect(sendCall).toBeDefined();
expect(sendCall?.method).toBe("POST");
expect(sendCall?.headers.authorization).toBe("Bearer secret");
const parsed = JSON.parse(sendCall?.body ?? "{}");
expect(parsed.to).toBe(TEST_PEER);
expect(parsed.envelope.id).toBe("msg-1");
await t.stop();
});
it("strips trailing slashes from relayUrl", { timeout: 5000 }, async () => {
const history: FakeFetchCall[] = [];
const fetchFn = makeFakeFetch({
responder: () => jsonResponse({ ok: true }),
history,
});
const t = new HttpsTransport({
relayUrl: `${RELAY}//`,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 100,
});
t.start();
await t.connect(TEST_PEER, "ignored");
await t.send(TEST_PEER, makeEnvelope());
const sendCall = history.find((c) => c.url.endsWith("/send"));
expect(sendCall?.url).toBe(`${RELAY}/send`);
await t.stop();
});
it("omits the Authorization header when no token is set", {
timeout: 5000,
}, async () => {
const history: FakeFetchCall[] = [];
const fetchFn = makeFakeFetch({
responder: () => jsonResponse({ ok: true }),
history,
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 100,
});
t.start();
await t.connect(TEST_PEER, "ignored");
await t.send(TEST_PEER, makeEnvelope());
const sendCall = history.find((c) => c.url.endsWith("/send"));
expect(sendCall?.headers.authorization).toBeUndefined();
await t.stop();
});
it("maps 401 to AUTH_FAILED", { timeout: 5000 }, async () => {
const fetchFn = makeFakeFetch({
responder: (call) =>
call.url.endsWith("/send")
? new Response("nope", { status: 401 })
: jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 100,
});
t.start();
await t.connect(TEST_PEER, "x");
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
code: HTTPS_TRANSPORT_CODES.AUTH_FAILED,
});
await t.stop();
});
it("maps 503 to RELAY_UNAVAILABLE", { timeout: 5000 }, async () => {
const fetchFn = makeFakeFetch({
responder: (call) =>
call.url.endsWith("/send")
? new Response("down", { status: 503 })
: jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 100,
});
t.start();
await t.connect(TEST_PEER, "x");
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
code: HTTPS_TRANSPORT_CODES.RELAY_UNAVAILABLE,
});
await t.stop();
});
it("maps 400 (other 4xx) to SEND_FAILED", { timeout: 5000 }, async () => {
const fetchFn = makeFakeFetch({
responder: (call) =>
call.url.endsWith("/send")
? new Response("bad", { status: 400 })
: jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 100,
});
t.start();
await t.connect(TEST_PEER, "x");
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
code: HTTPS_TRANSPORT_CODES.SEND_FAILED,
});
await t.stop();
});
it("treats fetch-throws as SEND_FAILED with cause", {
timeout: 5000,
}, async () => {
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn: async (url) => {
if (String(url).endsWith("/send")) {
throw new Error("ECONNRESET");
}
return jsonResponse({ envelopes: [], nextCursor: "0" });
},
longPollTimeoutMs: 100,
});
t.start();
await t.connect(TEST_PEER, "x");
try {
await t.send(TEST_PEER, makeEnvelope());
throw new Error("expected SEND_FAILED");
} catch (err) {
expect(err).toBeInstanceOf(HttpsTransportError);
const e = err as HttpsTransportError;
expect(e.code).toBe(HTTPS_TRANSPORT_CODES.SEND_FAILED);
expect(e.message).toContain("ECONNRESET");
expect(e.cause).toBeInstanceOf(Error);
}
await t.stop();
});
it("treats fetch-throws of non-Error as SEND_FAILED with stringified cause", {
timeout: 5000,
}, async () => {
const fetchSubstitute = async (
url: string | URL
): Promise<Response> => {
if (String(url).endsWith("/send")) {
// Throw a non-Error value to exercise the toError(cause)
// branch that wraps strings.
const literal: unknown = "boom-string";
throw literal;
}
return jsonResponse({ envelopes: [], nextCursor: "0" });
};
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn: fetchSubstitute,
longPollTimeoutMs: 100,
});
t.start();
await t.connect(TEST_PEER, "x");
try {
await t.send(TEST_PEER, makeEnvelope());
throw new Error("expected SEND_FAILED");
} catch (err) {
const e = err as HttpsTransportError;
expect(e.code).toBe(HTTPS_TRANSPORT_CODES.SEND_FAILED);
expect(e.message).toContain("boom-string");
}
await t.stop();
});
it("MALFORMED_RESPONSE when /send returns non-JSON 200", {
timeout: 5000,
}, async () => {
const fetchFn = makeFakeFetch({
responder: (call) =>
call.url.endsWith("/send")
? new Response("not-json{{", {
status: 200,
headers: { "content-type": "application/json" },
})
: jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 100,
});
t.start();
await t.connect(TEST_PEER, "x");
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
code: HTTPS_TRANSPORT_CODES.MALFORMED_RESPONSE,
});
await t.stop();
});
it("MALFORMED_RESPONSE when /send body is unexpected JSON shape", {
timeout: 5000,
}, async () => {
const fetchFn = makeFakeFetch({
responder: (call) =>
call.url.endsWith("/send")
? jsonResponse({ unexpected: true })
: jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 100,
});
t.start();
await t.connect(TEST_PEER, "x");
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
code: HTTPS_TRANSPORT_CODES.MALFORMED_RESPONSE,
});
await t.stop();
});
it("SEND_FAILED when relay returns { ok: false, code: 'POLICY' }", {
timeout: 5000,
}, async () => {
const fetchFn = makeFakeFetch({
responder: (call) =>
call.url.endsWith("/send")
? jsonResponse({ ok: false, code: "POLICY" })
: jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 100,
});
t.start();
await t.connect(TEST_PEER, "x");
try {
await t.send(TEST_PEER, makeEnvelope());
throw new Error("expected");
} catch (err) {
const e = err as HttpsTransportError;
expect(e.code).toBe(HTTPS_TRANSPORT_CODES.SEND_FAILED);
expect(e.message).toContain("POLICY");
}
await t.stop();
});
it("SEND_FAILED when relay returns { ok: false } without a code", {
timeout: 5000,
}, async () => {
const fetchFn = makeFakeFetch({
responder: (call) =>
call.url.endsWith("/send")
? jsonResponse({ ok: false })
: jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 100,
});
t.start();
await t.connect(TEST_PEER, "x");
try {
await t.send(TEST_PEER, makeEnvelope());
throw new Error("expected");
} catch (err) {
const e = err as HttpsTransportError;
expect(e.code).toBe(HTTPS_TRANSPORT_CODES.SEND_FAILED);
expect(e.message).toContain("unknown");
}
await t.stop();
});
it("PEER_UNKNOWN when sending to a peer that has not connected", {
timeout: 5000,
}, async () => {
const fetchFn = makeFakeFetch({
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
longPollTimeoutMs: 100,
});
t.start();
await expect(
t.send("not-connected", makeEnvelope())
).rejects.toMatchObject({
code: HTTPS_TRANSPORT_CODES.PEER_UNKNOWN,
});
await t.stop();
});
it("NOT_CONNECTED when send() is called before start()", {
timeout: 5000,
}, async () => {
const fetchFn = makeFakeFetch({
responder: () => jsonResponse({ ok: true }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
});
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
code: HTTPS_TRANSPORT_CODES.NOT_CONNECTED,
});
});
it("CLOSED when send() is called after stop()", {
timeout: 5000,
}, async () => {
const fetchFn = makeFakeFetch({
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
});
const t = new HttpsTransport({
relayUrl: RELAY,
ourPeerId: "peer-a",
fetchFn,
});
t.start();
await t.stop();
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
code: HTTPS_TRANSPORT_CODES.CLOSED,
});
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/drive-transport-https",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig();