Initial Commit
This commit is contained in:
44
packages/networking/drive/drive-transport-https/README.md
Normal file
44
packages/networking/drive/drive-transport-https/README.md
Normal 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…
|
||||
81
packages/networking/drive/drive-transport-https/package.json
Normal file
81
packages/networking/drive/drive-transport-https/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
31
packages/networking/drive/drive-transport-https/src/index.ts
Normal file
31
packages/networking/drive/drive-transport-https/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
HttpsTransportOptionsSchema,
|
||||
RecvResponseSchema,
|
||||
SendBodySchema,
|
||||
SendResponseSchema,
|
||||
} from "./transport.schema.js";
|
||||
@@ -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.
|
||||
@@ -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" }
|
||||
);
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
FetchFn,
|
||||
HttpsTransportOptions,
|
||||
RecvResponse,
|
||||
SendBody,
|
||||
SendResponse,
|
||||
Unsubscribe,
|
||||
} from "./transport.types.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig();
|
||||
Reference in New Issue
Block a user