@api.global/typedsocket

Typed request/response communication over WebSockets with one peer-scoped transport for JSON RPC and ordered virtual-stream-v1 byte streams. TypedSocket 10 integrates TypedRequest 9, enforces an exact package-major handshake on wire major 9, shared by TypedSocket 9 and 10, and binds every server operation to the physical peer and routing surface selected during upgrade.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

pnpm add @api.global/typedsocket @api.global/typedrequest @api.global/typedrequest-interfaces

Server applications also need SmartServe:

pnpm add @push.rocks/smartserve

Supported runtimes are Node.js (client and server) and browsers (client). Deno is not supported and not tested.

TypedSocket 10 requires @api.global/typedrequest 9.1.0 or later 9.x, @api.global/typedrequest-interfaces 7.1 or newer within major 7, and @push.rocks/smartserve 10.0.0 or newer within major 10. These packages resolve one TypedRequest 9 router graph and must not be mixed with earlier router or stream APIs.

Version 10 transport model

Each physical WebSocket peer has one always-on TypedSocket transport:

  • text frames carry bidirectional TypedRequest envelopes;
  • binary frames carry the same peer's virtual-stream-v1 streams;
  • SmartServe fixes the peer's routingSurface and transportOwner during upgrade;
  • the client and server must complete the exact TypedSocket package-major handshake before application requests or streams are admitted; it carries wire major 9, which TypedSocket 9 and 10 share, so 9 and 10 peers interoperate;
  • the exact handshake also requires typedrequest-cancellation-v1; mixed peers fail before application traffic;
  • client connection restoration runs after the handshake and before desired tags and the connected state are published.

There are no optional native-byte or native-message capability modes in version 10. The v6 nativeBytes, native-byte-v1, native-message-v1, binary-message channel, and capability-mode APIs are not part of the public surface. There is also no TypedSocket.fromSmartServe() attachment shortcut: server composition must happen before SmartServe is constructed.

Define shared contracts

TypedSocket uses ordinary TypedRequest interfaces. VirtualStreams use the transport-neutral TypedRequest interface types:

import type {
  ITypedRequest,
  TVirtualStream,
  implementsTR,
} from '@api.global/typedrequest-interfaces';

export interface IGreetRequest extends implementsTR<ITypedRequest, IGreetRequest> {
  method: 'greet';
  request: { name: string };
  response: { message: string };
}

export interface IUploadRequest extends implementsTR<ITypedRequest, IUploadRequest> {
  method: 'upload';
  request: {
    stream: TVirtualStream<'send'>;
  };
  response: {
    storedBytes: number;
  };
}

export interface IDownloadRequest extends implementsTR<ITypedRequest, IDownloadRequest> {
  method: 'download';
  request: { objectId: string };
  response: {
    // Direction is local to the requester. The server handler sees 'send'.
    stream: TVirtualStream<'receive'>;
  };
}

export interface IRestoreSubscriptionsRequest
  extends implementsTR<ITypedRequest, IRestoreSubscriptionsRequest> {
  method: 'restoreSubscriptions';
  request: { topics: string[] };
  response: { restored: true };
}

TypedHandler reverses stream directions at the handler boundary. An upload declared as requester-local send reaches the server handler as local receive; a download declared as requester-local receive is created by the handler as local send.

Server setup with SmartServe

Construction order is part of the transport contract:

  1. Create and populate the application TypedRouter.
  2. Call TypedSocket.createServer().
  3. Obtain the generated transport routing surface with getServerRoutingSurface().
  4. Construct SmartServe with that routing surface and the exact webSocketTransportOwner object.
  5. Call attachSmartServe().
  6. Start SmartServe.
import { TypedSocket } from '@api.global/typedsocket';
import { TypedHandler, TypedRouter } from '@api.global/typedrequest';
import { SmartServe } from '@push.rocks/smartserve';

const applicationRouter = new TypedRouter();

applicationRouter.addTypedHandler(
  new TypedHandler<IGreetRequest>('greet', async ({ name }) => ({
    message: `Hello, ${name}!`,
  })),
);

const typedSocket = TypedSocket.createServer(applicationRouter, {
  onServerConnectionReady: (connection) => {
    typedSocket.setServerTag(connection, 'application-client');
    return undefined;
  },
});

const smartServe = new SmartServe({
  port: 3000,
  websocket: {
    typedRouter: typedSocket.getServerRoutingSurface(applicationRouter),
    transportOwner: typedSocket.webSocketTransportOwner,
  },
});

typedSocket.attachSmartServe(smartServe);
await smartServe.start();

Do not pass applicationRouter directly to websocket.typedRouter. createServer() creates a distinct routing surface that composes the private TypedSocket protocol before the application router. SmartServe must bind that returned surface and the exact transport-owner identity to the peer.

onServerConnectionReady(connection) may synchronously assign protected tags or other connection-local state after the exact handshake response has been settled. It must return undefined; returning any other value, including a Promise or custom thenable, or throwing closes the connection before readiness is published.

Multiple isolated routing surfaces

One TypedSocket can compose multiple application routers without making them reachable from one another. Resolve the corresponding generated surface during upgrade:

const publicRouter = new TypedRouter();
const adminRouter = new TypedRouter();
const typedSocket = TypedSocket.createServer([publicRouter, adminRouter]);

const smartServe = new SmartServe({
  port: 3000,
  authorityValidation: 'strict',
  websocket: {
    resolveTypedRouter: (context) => {
      if (context.url.hostname === 'api.example.com') {
        return typedSocket.getServerRoutingSurface(publicRouter);
      }
      if (context.url.hostname === 'admin.example.com') {
        return typedSocket.getServerRoutingSurface(adminRouter);
      }
      return undefined;
    },
    transportOwner: typedSocket.webSocketTransportOwner,
  },
});

typedSocket.attachSmartServe(smartServe);
await smartServe.start();

SmartServe rejects an upgrade when resolveTypedRouter() returns undefined. typedRouter and resolveTypedRouter are mutually exclusive, as are transportOwner and resolveTransportOwner.

Client setup

The client router handles server-initiated requests. createClient() resolves only after the package-major handshake, optional connection restoration, and desired-tag reconciliation succeed.

import { TypedHandler, TypedRouter } from '@api.global/typedrequest';
import { TypedSocket } from '@api.global/typedsocket';

const clientRouter = new TypedRouter();

clientRouter.addTypedHandler(
  new TypedHandler<IGreetRequest>('greet', async ({ name }, tools) => {
    tools?.abortSignal.throwIfAborted();
    return { message: `Hello from the client, ${name}!` };
  }),
);

const client = await TypedSocket.createClient(
  clientRouter,
  'https://api.example.com',
  {
    autoReconnect: true,
    maxRetries: 20,
    initialBackoffMs: 1_000,
    maxBackoffMs: 30_000,
  },
);

const response = await client
  .createTypedRequest<IGreetRequest>('greet')
  .fire({ name: 'Ada' });

maxRetries (default and maximum 100) is the exact number of reconnect attempts, each opening a new socket, after a connection is lost or the first attempt fails; 0 disables reconnection. A successful connection resets the count. reconnectScheduled numbers the attempts from 1, and reconnectExhausted reports attempts equal to maxRetries. A client whose attempts run out has given up for good: it logs one error, releases itself, settles on disconnected, and then publishes reconnectExhausted. With autoReconnect: false or maxRetries: 0 a lost connection ends the client the same way, without a diagnostic: nothing was attempted, so the disconnected status is the whole report. Create a new client to connect again.

Use TypedSocket.useWindowLocationOriginUrl() for same-origin browser connections. Remote connections must use https: or wss:. Plain http: and ws: are restricted to loopback hosts, and to the exact origin of the page a client runs in (globalThis.location of a browser page) when that page was itself served over plain http:, such as a development server reached on a LAN address: the page's own requests already travel in the clear. A page served over https: never connects in the clear. URLs containing credentials or fragments are rejected, and lifecycle logs redact paths and query strings.

A client writes its lifecycle messages (connecting, connected, reconnect scheduling, restore deferral, and transient failures it reconnects from) only to the onDebugLog option, so by default nothing reaches stdout and machine-readable output stays clean. Pass onDebugLog: console.debug to print them, or forward them to your own logger. A throwing sink never disturbs the connection. Failures the client cannot recover from are reported with console.warn and console.error regardless: a terminal failure (an incompatible version handshake, a refused restoration, a denied tag reconciliation) is one warning naming the step, the redacted endpoint and the cause, such as TypedSocket version handshake with ws://example.com failed, ending the client: …, and exhausted reconnects are one error; structured events for the same lifecycle are available from statusSubject and diagnosticsSubject. Preparation failures reach the startup caller or the sanitized preparationFailed diagnostic without logging the callback's error.

Authenticate before each WebSocket upgrade

prepareConnection runs before every physical connection, including reconnects, before any WebSocket upgrade or TypedSocket frame. Its frozen context contains the validated WebSocket url, monotonically increasing attempt (starting at one), deadline, connection-owned abortSignal, and supportsHeaders. The callback may return nothing or { headers }; TypedSocket validates and copies headers into an immutable snapshot for that attempt.

Node clients can prepare fresh upgrade credentials through the existing ws transport. Redirects are disabled, so credentials never follow a redirect:

const client = await TypedSocket.createClient(clientRouter, serverUrl, {
  prepareConnection: async ({ abortSignal }) => ({
    headers: {
      Authorization: `Bearer ${await obtainAccessToken(abortSignal)}`,
    },
  }),
  connectionPreparationTimeoutMs: 5_000,
});

obtainAccessToken is application-owned. Headers are bounded to 32 entries and 8,192 aggregate bytes; each value is at most 4,096 printable ASCII characters. Duplicate names differing only in case, accessors, symbolic keys, line breaks, empty Authorization/Cookie values, transport options, and handshake or hop-by-hop headers (including Host, Origin and Sec-WebSocket-*) are refused. No callback can replace the endpoint, TLS policy, protocols, or redirect policy.

Browser clients cannot set upgrade headers. Returning nonempty headers there rejects with TypedSocketConnectionPreparationError, code unsupported-headers; nothing is silently omitted. A browser callback may instead perform its own authenticated HTTP request that installs a short-lived cookie ticket for the exact WebSocket origin and audience, then return nothing:

const client = await TypedSocket.createClient(clientRouter, serverUrl, {
  prepareConnection: async ({ abortSignal }) => {
    const response = await fetch(ticketEndpoint, {
      method: 'POST',
      credentials: 'include',
      signal: abortSignal,
      headers: { Authorization: `Bearer ${await obtainAccessToken(abortSignal)}` },
    });
    if (!response.ok) throw new Error('WebSocket ticket preparation was refused.');
  },
});

Ticket issuance, cookie attributes, origin/audience checks, one-use consumption, and verified identity are the server/application's responsibility. TypedSocket does not authenticate through JSON RPC, create tickets, or put credentials in URLs. The package-major handshake establishes transport compatibility only.

Preparation has a 10-second default and maximum deadline, configurable downward with a positive integer connectionPreparationTimeoutMs. A callback rejection, invalid result, unsupported headers, or exhausted deadline ends the client; startup rejects with TypedSocketConnectionPreparationError (codes callback-rejected, invalid-options, unsupported-headers, or timeout). The callback's rejection is retained as the standard cause, while package messages and the terminal preparationFailed diagnostic contain only a static code and redacted endpoint. After such a failure, retry explicitly by creating a new client once application authentication can succeed.

Callbacks must pass abortSignal to owned HTTP work and release their own resources. The signal aborts on cancellation, timeout, physical disconnect, or stop. Cancellation and timeout cannot be held open by a callback that ignores its signal; a late result is observed and never opens a socket. Node transport shutdown waits for the owned socket to close with a bounded one-second close deadline. No application request or physical effect is retried by preparation.

Startup interrupted by stop() or the client abortSignal rejects with the exported TypedSocketClientStartupAbortedError, whose name is AbortError and code is TYPEDSOCKET_CLIENT_STARTUP_ABORTED. Consumers should use this type or code rather than comparing a private error message. Only the caller's own stop() or abortSignal rejects a startup this way; a startup whose retry the client ended terminally rejects with that failure.

Connecting over a Unix socket

A local controller can reach a TypedSocket server through a Unix domain socket instead of TCP. The server side needs no TypedSocket change: give SmartServe 10 a unixSocket listener, which owns the socket file and its permissions (mode 0600 by default).

const smartServe = new SmartServe({
  unixSocket: { path: '/run/myservice/control.sock' },
  websocket: {
    typedRouter: server.getServerRoutingSurface(appRouter),
    transportOwner: server.webSocketTransportOwner,
  },
});
server.attachSmartServe(smartServe);
await smartServe.start();

The client names the socket with unixSocketPath. The server URL still supplies the Host header and the request path. It must use http: or ws:, because TLS is not available over the socket, whose file permissions govern access, and like any plain http:/ws: URL its host follows the rule above: a loopback host, or the origin of an http: page the client runs in.

const client = await TypedSocket.createClient(clientRouter, 'http://localhost', {
  unixSocketPath: '/run/myservice/control.sock',
});

The socket carries the same TypedSocket protocol as a TCP connection, and a reconnect opens a fresh connection to the same path. Node.js uses the ws package, loaded when unixSocketPath or prepareConnection is set, and rejects a path containing :. Browsers and Bun reject unixSocketPath.

Restoring application connection state

restoreConnection restores application state on a connection already authenticated during upgrade. It runs after the version handshake and before tags or readiness. Its request factory is deadline-bound and becomes invalid when the callback finishes:

declare const serverUrl: string;
declare const subscribedTopics: string[];

const client = await TypedSocket.createClient(clientRouter, serverUrl, {
  restoreConnection: async ({ createTypedRequest, abortSignal }) => {
    if (abortSignal.aborted) return;
    await createTypedRequest<IRestoreSubscriptionsRequest>('restoreSubscriptions').fire(
      { topics: subscribedTopics },
    );
  },
});

A TypedSocketHandshakeError is terminal for that client startup. It is raised only when the peer proves itself incompatible: a package-major or cancellation-protocol mismatch, a handshake response carrying an error or a retry hint (a compatible server sends neither), a malformed handshake envelope, a binary frame before handshake completion, or the server refusing the client's handshake (a TypedSocket server closes with 1008 and the reason TypedSocket text frame arrived before readiness or TypedSocket package major mismatch). The client closes the connection with 1008, or records the server's refusal, and publishes a connectionClosed diagnostic with closeCode: 1008, phase: 'handshake' and terminal: true instead of falling back to a reduced transport, rejects its pending requests with the TypedSocketHandshakeError, and never reconnects. The client then ends exactly like a refused restoration: it releases itself and settles on disconnected, on a reconnect as on the first connection, where createClient() also rejects with the error. That one diagnostic is the whole report of the failure; no reconnectExhausted follows it.

A handshake that did not complete is transient, because it proves nothing about the peer: the ten-second TYPEDSOCKET_HANDSHAKE_TIMEOUT_MS deadline expiring, the handshake request timing out or failing, or the connection closing before the server answered with any other code or reason, including the server's own 1008 close when its handshake deadline expires. The client closes with 1013 (Try Again Later; sent as 4013, because browsers accept only application close codes), publishes a connectionClosed diagnostic with closeCode: 1013, the reason, phase: 'handshake' and terminal: false, and reconnects with the usual backoff, maxRetries and reconnectExhausted. On the first connection createClient() with autoReconnect stays pending through those retries and rejects when they run out, or with the terminal error (TypedSocketHandshakeError, TypedSocketConnectionRestoreDeniedError or TypedSocketConnectionPreparationError) as soon as a retry ends the client; with autoReconnect: false it rejects at once with a plain Error. Only failures before the handshake completes are retried this way: once it has completed, the same ten-second deadline bounds connection restoration, and running out of it there is the exhausted restoration deadline described below, which ends the client.

A refused restoration is terminal too, because retrying can only repeat the peer's verdict. Three rules decide a restoration that did not complete, in this order.

Whose refusal it is. A refusal is the hook's own only when the hook refused on its own account, which this package decides by provenance rather than by timing. A rejection it injected — a stopped client, a closed connection, a restore request it interrupted — is that interruption reported back to it, even when the hook catches it and rethrows something of its own, and an attempt whose restore request was still waiting for its answer when the interruption ran keeps the interruption. So a hook that ends its own client — aborting the client abortSignal or calling stop() — after its request was answered and then rejects still reports its own refusal, while a client stopped or aborted from outside reports that shutdown exactly as an ordinary shutdown does. A hook still working when the attempt ends reaches no refusal, and one it reaches afterwards never rewrites the finished attempt.

What ends the client, and what reconnects. Exactly three refusals end it, each a TypedSocketConnectionRestoreDeniedError: the hook's own refusal, which carries the error the hook threw as its standard cause, so the refusal's reason and data reach the consumer; the exhausted restoration deadline; and exhausted retained-callback capacity. The two bounds are this package's own and carry no cause. Everything else that fails a restoration is an ordinary connection failure the client reconnects from, including a transport failure, an interrupted restoration, and a connection that changed under the hook — the peer closing while the hook is finishing.

  • On the first connection, createClient() rejects with the refusal and the client is already stopped, also when the refusal meets a retry after a transient first failure.
  • On a reconnect, the client publishes one restoreDenied diagnostic carrying the refusal, the attempt, maxRetries, and the redacted endpoint, then settles on disconnected. It never offers itself again.

What the consumer still owns. A refused client releases itself the way a failed createClient() does: no socket, no reconnect or deferral timer, and the listener it registered on the consumer's abortSignal removed, so stop() afterwards is the documented idempotent no-op. Subscriptions to statusSubject and diagnosticsSubject stay the consumer's to unsubscribe.

Throw TypedSocketRestoreDeferral instead when the refusal is temporary.

Deferring a refused registration

A server may refuse a peer for a stated period instead of permanently, for example while a protocol rollout is in progress. Throw TypedSocketRestoreDeferral from restoreConnection to close that attempt cleanly, wait, and offer the peer again:

import { TypedResponseError } from '@api.global/typedrequest';
import { TypedSocketRestoreDeferral } from '@api.global/typedsocket';

const client = await TypedSocket.createClient(clientRouter, serverUrl, {
  restoreConnection: async ({ createTypedRequest }) => {
    try {
      await createTypedRequest<IOfferRegistration>('offerRegistration').fire(
        { nodeName: 'pallet-a' },
      );
    } catch (errorArg) {
      if (
        errorArg instanceof TypedResponseError
        && errorArg.errorData?.reason === 'protocol-incompatible'
      ) {
        throw new TypedSocketRestoreDeferral(5 * 60 * 1000);
      }
      throw errorArg;
    }
  },
});
  • retryAfterMs must be a positive safe integer of at most TYPEDSOCKET_MAX_RESTORE_DEFERRAL_MS (3,600,000). Non-finite, negative, zero, fractional, and larger values are refused by RangeError at construction, and that RangeError reaches the client as an ordinary restore denial.
  • A deferral does not consume a reconnect retry: retryCount and the exponential backoff both stay where they were, so a peer refused for days keeps its retries for real connection failures. A refusal consumes its scheduled retry and then ends the client.
  • A deferral is honoured only while reconnection is possible, so autoReconnect: false, a stopped or aborted client, and exhausted retries surface the deferral to the caller instead.
  • The deferred attempt runs on its own timer, detached from the attempt that deferred it, so a long-refused client never nests reconnect chains. That timer is referenced, exactly like a pending reconnect delay: a pending deferral keeps the host process alive until it fires or the client stops, because a client-only process that exited during the window would never offer itself again. stop() and the client abortSignal cancel a pending deferral, so shutdown never waits for one.
  • When the first connection attempt is deferred, createClient() resolves with a client in the reconnecting state instead of rejecting: a refused peer stays up and keeps its workloads. Use statusSubject or diagnosticsSubject to observe when it becomes connected.
  • Every deferral publishes a restoreDeferred diagnostic carrying retryAfterMs, the unchanged attempt, maxRetries, and the redacted endpoint.

Explicit server targets

Client requests target their server implicitly because the client owns one current physical connection. Server-initiated requests always require an explicit ISmartServeConnectionWrapper:

const target = await typedSocket.findTargetConnectionByTag('account', {
  accountId: 'account-123',
});

if (target) {
  const response = await typedSocket
    .createTypedRequest<IGreetRequest>('greet', target, {
      timeoutMs: 15_000,
    })
    .fire({ name: 'server push' });
}

Inside a server handler, bind follow-up work to the request's exact trusted peer:

applicationRouter.addTypedHandler(
  new TypedHandler<IGreetRequest>('greet', async ({ name }, tools) => {
    const target = typedSocket.getServerConnectionForRequest(tools);
    typedSocket.setServerTag(target, 'authenticated', { subject: 'user-123' });
    return { message: `Hello, ${name}!` };
  }),
);

findTargetConnection(), findAllTargetConnections(), and their tag variants return only live peers attached to this TypedSocket's generated routing surfaces. There is no implicit single-peer server fallback.

VirtualStreams

TypedSocket 10 supplies TypedRequest 9's IVirtualStreamTransport for each handshake-ready physical peer. TypedRequest serializes only the JSON-compatible descriptor in the parent envelope; ordered Uint8Array chunks travel as bounded binary frames on that exact peer.

All stream facades expose protocol, direction, streamId, optional contentType and integrity, opened, completion, closed, and abort(). Senders add send(), writable, and close(). Receivers add receive(), readable, accept(), and reject(). Concurrent sender calls may fill the existing bounded admission window; authorization and frame transmission remain same-stream ordered, and each promise still resolves only after the receiver dequeues that exact logical chunk. close() waits for every admitted send before emitting FIN.

receive() returns one complete logical chunk at a time and undefined at graceful EOF. Dequeued bytes reach the application immediately while the corresponding bounded ACK write completes independently. ACK write failures still reject stream completion, and accept() waits for pending ACK writes before sending acceptance. The receiver must call accept() after draining EOF. completion resolves with the shared acceptance receipt; abnormal termination rejects it. Direct receive() and readable consumption are mutually exclusive.

Bounded application acceptance

Receivers can finish application processing, such as archive repacking and a durable metadata commit, before calling accept(). Both endpoints independently limit this phase to 30 seconds by default. A send creator can select acceptanceTimeoutMs on createRegistration() or the server createVirtualStream() facade. A receiver uses the locally configured receiverAcceptanceTimeoutMs on TypedSocket.createClient(), TypedSocket.createServer(), or VirtualStreamManager construction. That receiver policy applies to every stream received by that socket or manager, including receive creators. Receive creators cannot set the sender-only registration option.

const server = TypedSocket.createServer(applicationRouter, {
  receiverAcceptanceTimeoutMs: 600_000,
});
const registration = client.virtualStreams.createRegistration({
  creatorDirection: 'send',
  acceptanceTimeoutMs: 600_000,
});

Both options are snapshotted and validated as integer milliseconds from 1 through 3,600,000 (one hour). Neither option is serialized: the sender cannot extend the receiver's policy. The sender's fixed deadline starts after FIN transmission settles. The receiver's fixed deadline starts after validated FIN and drainage of all retained chunks and acknowledgements. Other traffic, clock changes, and authorization revalidation never renew these deadlines. The default runtime uses a monotonic clock; custom runtimes without monotonicNow() use a single fixed duration timer.

Opening, chunk delivery, unread payloads, FIN transmission, and ACCEPT transmission retain their existing transport deadlines and resource ceilings. The parent TypedRequest timeout is also unchanged. Applications whose processing outlives the parent request should return an admission response promptly, own the continuing processing operation, and call accept() only after the application commit completes. Observe completion rejection throughout processing, including after readable EOF: abort and join owned source/storage work before releasing its resources. closed alone does not mean acceptance or application commit. A timeout sends RESET (or closes a failed transport), rejects completion on both endpoints, and joins transport cleanup.

The descriptor, binary frames, and handshake remain virtual-stream-v1. Existing Socket 8 peers remain compatible and retain their own 30-second processing limit; a longer acceptance wait requires each receiving peer to configure its own policy in a version that supports it.

Client-created streams with manager registrations

Application-level client streams use the advanced manager registration API, then bind the registration to TypedRequest's public facade:

import { VirtualStream } from '@api.global/typedrequest';

const transport = client.virtualStreams.getClientTransport();
if (!transport) {
  throw new Error('TypedSocket client transport is not connected');
}

const registration = client.virtualStreams.createRegistration({
  creatorDirection: 'send',
  contentType: 'application/octet-stream',
});

const stream = VirtualStream.fromRegistration({
  transport,
  registration,
});

const request = client.createTypedRequest<IUploadRequest>('upload');
const responsePromise = request.fire({ stream });

await stream.opened;
await stream.send(new Uint8Array([1, 2, 3]));
await stream.close();

const response = await responsePromise;

Client registrations do not take a peer target: the manager binds them to the current handshake-ready client generation. Registration is synchronous and silent. Its descriptor capability expires if it is not consumed, and TypedRequest owns disposal after the facade is created. Do not hand-build descriptors or reuse them across connections.

The matching server handler receives a requester-local send stream as local receive:

applicationRouter.addTypedHandler(
  new TypedHandler<IUploadRequest>('upload', async ({ stream }) => {
    let storedBytes = 0;
    while (true) {
      const chunk = await stream.receive();
      if (chunk === undefined) break;
      storedBytes += chunk.byteLength;
    }
    await stream.accept();
    return { storedBytes };
  }),
);

Server-created streams and the authorization facade

Server application code should create streams through TypedSocket.createVirtualStream(). This facade requires an exact attached target and a configured virtualStreamAuthorizationAdapter; it synchronously binds application authorization before publishing a descriptor.

interface IStreamAuthorization {
  subject: string;
  objectId: string;
  revision: string;
}

declare function isStreamAuthorityCurrent(
  authority: IStreamAuthorization,
  operation: 'open' | 'chunk' | 'accept' | 'reject',
): Promise<boolean>;

const typedSocket = TypedSocket.createServer(applicationRouter, {
  virtualStreamAuthorizationAdapter: {
    bind: (authorization, context) => {
      const authority = authorization as IStreamAuthorization;
      if (!authority.subject || !authority.objectId || !authority.revision) {
        throw new Error('Invalid stream authorization');
      }
      const target = context.target;

      return {
        revalidate: async ({ operation, connection, abortSignal }) => {
          if (
            abortSignal.aborted
            || connection.side !== 'server'
            || connection.peer !== target
          ) return false;
          return await isStreamAuthorityCurrent(authority, operation);
        },
      };
    },
  },
});

bind() must return synchronously and must provide revalidate(context). Revalidation runs with the exact connection binding, operation (open, chunk, accept, or reject), deadline, and abort signal. Return literal true only while the application authority remains current. Primitive boolean results settle their authorization slot synchronously. Promise results remain charged against the unchanged limits of four active revalidations per connection and 128 per server until the callback actually settles, including after caller-visible timeout or abort. A fifth per-connection or 129th server-wide asynchronous revalidation fails closed instead of raising those limits.

declare function loadBoundedObjectChunks(
  objectId: string,
): AsyncIterable<Uint8Array>;

applicationRouter.addTypedHandler(
  new TypedHandler<IDownloadRequest>('download', async ({ objectId }, tools) => {
    const target = typedSocket.getServerConnectionForRequest(tools);
    const stream = typedSocket.createVirtualStream({
      target,
      creatorDirection: 'send',
      contentType: 'application/octet-stream',
      authorization: {
        subject: 'user-123',
        objectId,
        revision: 'revision-7',
      } satisfies IStreamAuthorization,
    });

    const production = (async () => {
      await stream.opened;
      for await (const chunk of loadBoundedObjectChunks(objectId)) {
        await stream.send(chunk);
      }
      await stream.close();
    })();
    void production.catch((error) => stream.abort(error).catch(() => undefined));

    return { stream };
  }),
);

Finite streams may include { algorithm: 'sha256', byteLength, digest } integrity metadata. Open-ended streams omit integrity. Capabilities are opaque, single-use, peer-scoped, generation-scoped, and short-lived.

Connection tags

Client tag mutation is default-deny. Declare exact rules on the server:

const typedSocket = TypedSocket.createServer(applicationRouter, {
  clientTagPolicy: {
    authorizationTimeoutMs: 2_000,
    rules: [{
      name: 'workspace',
      owner: 'client',
      validateAndAuthorize: ({ payload, operation, abortSignal }) => {
        if (abortSignal.aborted) return false;
        if (operation === 'remove') return true;
        return typeof payload === 'object'
          && payload !== null
          && typeof Reflect.get(payload, 'workspaceId') === 'string';
      },
    }],
  },
});
await client.setTag('workspace', { workspaceId: 'workspace-123' });
await client.removeTag('workspace');

Use setServerTag() and removeServerTag() for authentication, roles, registration state, and other server-owned metadata. A server-owned name remains protected from client overwrite after removal. Desired client tags are reconciled after reconnect only after restoreConnection succeeds. A denial during that reconciliation follows the handshake's rule: a transient one retries and a verdict is terminal. The codes in TYPEDSOCKET_TRANSIENT_TAG_DENIAL_CODES (TAG_AUTHORIZATION_TIMEOUT, TAG_MUTATION_CAPACITY_EXCEEDED, TAG_CONNECTION_CLOSED; isTransientTagDenialCode() tests a code) report the server's momentary state: the attempt fails like a lost connection, the tag stays desired and the next attempt reapplies it, within the usual backoff, maxRetries and reconnectExhausted. No tagMutationDenied diagnostic is published for it, because that event carries no lifecycle verdict and the tag was not lost. Every other code is a verdict that reconnecting could only repeat: the client publishes one tagMutationDenied diagnostic (side: 'client', the operation, the tag name and the denial code), drops the denied tag from its desired state, releases itself, and ends on disconnected. A live setTag() or removeTag() is unaffected: it rejects with every denial code alike, publishes tagMutationDenied, and keeps its connection.

Do not use a universal allClients broadcast tag. Assign a dedicated application tag and target only clients that implement the corresponding server-initiated method.

Events

Events are one-way pushes from a server to its clients. They have no answer, take no request handler slot and are never retried; use them to tell connected clients that something changed, and let the clients read the new state with ordinary requests.

Describe each event in the shared contracts:

import type { ITypedSocketEvent } from '@api.global/typedsocket';

export interface IEvent_BookChanged extends ITypedSocketEvent<{ bookId: string }> {
  name: 'bookChanged';
}

On the client, handle the event and read state again whenever the client may have missed some:

const removeHandler = client.onEvent<IEvent_BookChanged>('bookChanged', ({ bookId }) => {
  void reloadBook(bookId);
});

client.eventResyncSubject.subscribe(({ reason, names }) => {
  // reason 'registered': these names are now delivered on this connection.
  // reason 'gap': events were dropped. Either way, read the state again.
  void reloadEverything(names);
});

removeHandler(); // the last handler removed for a name unregisters it

On the server, target connections by a server-owned tag, optionally narrowed by a synchronous filter:

// After authentication, e.g. in a sign-in handler:
typedSocket.setServerTag(typedSocket.getServerConnectionForRequest(tools), 'user', { userId });

const result = typedSocket.publishToTag<IEvent_BookChanged>('user', 'bookChanged', { bookId }, {
  filter: (connection) => mayReadBook(connection, bookId),
});
// result: { delivered, dropped, skipped }

typedSocket.sendEvent<IEvent_BookChanged>(connection, 'bookChanged', { bookId }); // one connection
  • Registration. A client's first onEvent() handler for a name registers that name with the server; the function it returns removes the handler, and removing the last handler for a name unregisters it. Registration runs on every connection before it reports connected, and again after each reconnect. A connection receives only the names it registered; the others count as skipped. A client with no event handlers never registers anything.
  • Delivery and order. publishToTag() and sendEvent() are synchronous. The payload is encoded once per call, and the filter runs for every tagged connection before anything is sent, so a throwing filter sends nothing. Each connection receives events in publish order. The client runs their handlers as each frame arrives, in arrival order; events never wait for queued requests or handler slots and do not count against the text-queue limits. It does not await a handler's promise. Handler errors are logged with console.error and never reach the connection.
  • No replay. Events sent while a client is disconnected are lost. eventResyncSubject emits { reason: 'registered', names } when names become registered on a connection (after connecting, after a reconnect, or for a new handler), and { reason: 'gap', names } with every handled name when events were dropped. Treat either as "read the state again".
  • Backpressure. Each connection's outbound buffer may hold up to TYPEDSOCKET_MAX_EVENT_BUFFERED_BYTES_PER_PEER (1 MiB) before an event is dropped. The publish result counts a dropped event as dropped. Once the buffer has room again (checked every TYPEDSOCKET_EVENT_GAP_POLL_MS, 100 ms) the connection receives a gap notice, and the client emits the gap resync before it handles the next event. A connection that stays full for TYPEDSOCKET_EVENT_STALL_TIMEOUT_MS (30 s) is closed with 1013 TypedSocket event consumer stalled and a non-terminal server connectionClosed diagnostic; the client reconnects and registers again. A transport that does not report bufferedAmount has no budget.
  • Limits. Event names are bounded text identifiers of up to TYPEDSOCKET_MAX_EVENT_NAME_BYTES (256 bytes), and a connection registers at most TYPEDSOCKET_MAX_EVENT_NAMES_PER_PEER (256) names. An encoded event may be up to TYPEDSOCKET_MAX_EVENT_FRAME_BYTES (256 KiB). Publishing throws a TypeError for a payload that is not JSON and a RangeError for an event that is too large, before anything is sent. An event whose sequence does not increase, or that carries unexpected keys, closes the client connection like any other malformed envelope (1008, not terminal).
  • Authority. Events carry no identity and clients cannot address each other; the server alone decides who receives what. Use server-owned tags and the filter for that decision, the same way the server authorizes the read the client makes afterwards.
  • Compatibility. Events are additive within wire major 9, which TypedSocket 9 and 10 share. A 9.0 client never registers and receives no events. A 9.1 client connected to a 9.0 server gets an error answer to its registration: it stays connected without events, logs one console.warn line and publishes one eventInterestDenied diagnostic (side: 'client', code: 'EVENT_INTEREST_FAILED').

Lifecycle, limits, and diagnostics

  • statusSubject publishes new, connecting, connected, disconnected, and reconnecting transitions.
  • diagnosticsSubject publishes bounded structured events for invariant closes, peer rejection, connection preparation failure, reconnect scheduling, restore deferral, restore denial, reconnect exhaustion, tag denial, and event interest denial. Subscribers own unsubscription; the subject does not complete.
  • A connectionClosed diagnostic reports a close this package decided, with its scope, closeCode and static reason, the phase the connection had reached (handshake before the version handshake completed, ready after it), and terminal. Act on terminal, never on the close code: a terminal close itself ends the client because the peer proved incompatible, and the client never reconnects after it; every other close is a connection loss, not a verdict on the peer: the client retries it only while autoReconnect is on and maxRetries is not used up, and it can still end later, which reconnectExhausted, restoreDenied a reconnect's tagMutationDenied, or preparationFailed reports separately. The codes overlap: 1008 is both a refused handshake (terminal: true) and an exceeded text queue or a malformed envelope after the handshake (terminal: false), and 1013 is an unfinished handshake, exhausted callback capacity after it, and, on the server, a stalled event consumer (all terminal: false). On the server, terminal marks a close that ends a TypedSocket client, the refusal of its handshake. A close the peer or the consumer starts, such as a normal close or stop(), publishes no connectionClosed diagnostic.
  • A refused connection restoration is terminal: the client stops reconnecting, publishes one restoreDenied diagnostic, releases itself, and ends on disconnected.
  • An incompatible version handshake is terminal the same way: the client publishes one terminal connectionClosed diagnostic (closeCode: 1008, phase: 'handshake'), releases itself, and ends on disconnected, including when a reconnect meets a server that now answers incompatibly, for example after the server was upgraded to a release with another wire major.
  • A tag the server denies while a reconnect reconciles the desired client tags is terminal the same way, unless the code is transient (TYPEDSOCKET_TRANSIENT_TAG_DENIAL_CODES): one tagMutationDenied diagnostic, then the client releases itself and ends on disconnected. A transient denial is retried like a lost connection and keeps the tag.
  • A client that can never connect again ends the same way. With autoReconnect: false or maxRetries: 0, a lost connection releases the client and leaves it on disconnected; no diagnostic follows, because nothing was attempted. A client that used up maxRetries logs one TypedSocket giving up on … after N attempts. error, releases itself (no socket, no reconnect timer, no connection preparation, no listener on the consumer's abortSignal), settles on disconnected, and then publishes one reconnectExhausted diagnostic. It never reconnects again, and the consumer's later stop() is a no-op; create a new client to connect again.
  • Each terminal failure after upgrade is reported by exactly one diagnostic (connectionClosed with terminal: true, restoreDenied or tagMutationDenied) and one console.warn line; reconnectExhausted never follows it, and the consumer's later stop() is a no-op.
  • stop() disables client reconnect, cancels a pending restore deferral, rejects pending work, closes streams, and releases router registrations. Server stop() detaches TypedSocket state and composition but does not stop SmartServe.
  • Request timeoutMs and abortSignal are supported on both sides. Server requests are cancelled on target disconnect or server stop.
  • Timeout, caller abort, requester disconnect, target disconnect, client/server stop, and connection replacement abort the exact remote handler through TypedTools.abortSignal.
  • Cancellation identity binds the physical peer, generated routing surface, connection generation, method, correlation ID, and fresh requestInstanceId. The control method is __typedsocket_cancelRequest with protocol typedrequest-cancellation-v1 and is never broadcast or forwarded to another peer.
  • Handshake and cancellation-control envelopes carry their own fresh top-level requestInstanceId. A cancellation payload separately names the exact application request instance being cancelled. Malformed, oversized, or authority-mismatched identities fail closed.
  • A cancellation that wins routing before handler registration creates an early-cancel tombstone and delivers an already-aborted signal when that exact request registers. Completion replaces it with a terminal tombstone, so a late cancellation is ignored; reuse of the correlation ID is safe only with a fresh request instance ID.
  • Active handlers are capped at 64 per connection and 1,024 per TypedSocket. Disconnect, stop, and the five-minute handler lifetime abort and detach work, but global active accounting is released only when TypedRequest calls the registration's complete() callback after the handler promise settles. Internal cancellation stats report that full unsettled count as active, its detached subset as detachedActive, and only live attached states as connections.
  • Early/terminal cancellation tombstones are capped at 1,024 per connection and 16,384 per TypedSocket and expire after ten seconds. Per-connection overage closes that connection. On global pressure, largest-consumer selection includes the triggering connection and deterministically closes the oldest attached connection with the largest tombstone share. The triggering cancellation is admitted only when another consumer is reclaimed and the triggering connection remains open.
  • Client limits may lower package ceilings but cannot raise them. Untrusted network deployments should lower text-frame and queue ceilings to match the application protocol.
  • The stream transport bounds connections, active streams, logical chunk size, queued chunks and bytes, raw frames, outbound frames, revalidations, arrival accounting, tombstones, capability lifetime, outstanding protocol progress, and cleanup time. An open stream with no queued or retained work may remain idle indefinitely.
  • Incoming binary frames are validated immediately, then dispatched in independent per-stream FIFO lanes. A slow application authorization callback on one stream cannot block data or control on another. RESET immediately cancels its own lane, including a held authority callback; it discards pending work instead of waiting behind it. The 64-frame / 2 MiB inbound budget includes active work; CHUNK traffic leaves eight admission slots for control and each stream may retain at most 40 incomplete frames before its own stream is reset. Malformed framing and the aggregate hard ceiling still close the connection.
  • Server binary output pipelines up to eight native sends, with exact frame identity accounting until every native settlement arrives, including after detach. Logical chunks fill a sliding eight-fragment window; logical sequence, authorization, receiver dequeue ACKs, and FIN ordering stay intact. Both server and client reserve eight outbound entries for control and yield to data after at most four controls. This removes application-level cross-stream waits; a single WebSocket still has ordered transport delivery, including when carried over HTTP/3.
  • Client binary output submits one frame immediately when idle, reserving a later continuation so repeated or reentrant sends cannot drain synchronously without a bound. Continuation turns send at most eight frames and 256 KiB. Control frames remain preferred, but queued data progresses after at most four controls. Requested follow-up turns use a cancellable immediate task when available, a shared MessageChannel task queue in browsers, and a zero-delay timer only when neither exists. A prospective 2 MiB WebSocket.bufferedAmount high watermark pauses dequeue until the socket reaches the 1 MiB low watermark; the existing 30-second progress deadline still bounds blocked work.
  • The default runtime uses a monotonic clock to share client frame settlements across one connection deadline timer and move one lazy endpoint progress deadline instead of replacing its timer. Custom manager runtimes may provide monotonicNow() for the same consolidation; otherwise duration timers preserve the exact timeout boundary.
  • Invalid framing, overflow, integrity failure, authority revocation, handshake failure, and timeout fail closed. Physical-peer identity and raw-frame settlement identity are never inferred from caller-controlled payloads.

Selected stream defaults are 32 KiB physical frames, 4 MiB logical chunks, 32 active streams per connection, a 10-second handshake and capability deadline, a 30-second deadline while protocol progress or retained chunks are outstanding, and a 5-second revalidation deadline. Root exports provide the principal package ceilings and timeout constants.

VirtualStream benchmark

Run the tracked Node loopback benchmark from the repository with:

pnpm run benchmark:virtualstream

The default workload transfers 64 MiB in each direction as 64 KiB chunks over one connection and one outstanding send per stream. It reports throughput, sender latency, event-loop delay, memory deltas, and final transport accounting. Use --send-window=<1..8>, --streams=8 or --streams=32, --chunk-kib=<1..4096>, --total-mib=<value>, --integrity, and --delay-receive --receive-delay-ms=<value> to exercise pipelining, concurrency, chunk, integrity, and receiver-delay cases. The benchmark always enforces the unchanged stream and connection chunk/byte ceilings. --assert enforces the 100 MiB/s and 2 ms p95 targets only for the default 64 MiB, 64 KiB, one-stream, one-send-window acceptance workload.

Public API summary

TypedSocket

API Side Purpose
TypedSocket.createClient(router, url, options?) client Connects (over TCP, or a Unix socket with unixSocketPath), handshakes, restores connection state, reconciles tags, and registers event names.
TypedSocket.createServer(routerOrRouters, options?) server Composes private protocol and application routers before SmartServe construction.
getServerRoutingSurface(applicationRouter?) server Returns the exact generated router SmartServe must bind during upgrade.
attachSmartServe(smartServe) server Attaches lifecycle, authority guards, and peer-scoped stream resolvers.
createTypedRequest(method, target?, options?) both Creates a TypedRequest; server calls require an explicit target.
createVirtualStream(options) server Creates an exact authorized stream facade for one attached peer.
getServerConnectionForRequest(tools) server Resolves the exact trusted physical peer for an incoming request.
setTag() / removeTag() client Mutates an explicitly allowed client-owned tag.
setServerTag() / removeServerTag() server Maintains protected server-owned peer metadata.
onEvent(name, handler) client Handles one-way server events with this name; returns the function that removes the handler.
eventResyncSubject client Publishes when names become registered or events were dropped, so the application reads state again.
publishToTag(tag, name, payload, { filter }?) server Sends a one-way event to the tagged connections that registered the name.
sendEvent(connection, name, payload) server Sends a one-way event to one connection.
findTargetConnection*() / findAllTargetConnections*() server Finds live attached targets by predicate or tag.
getStatus() both Returns the current connection status.
stop() both Releases all TypedSocket-owned lifecycle state.

virtualStreams

VirtualStreamManager is the peer-scoped transport manager. Client applications may use getClientTransport() and createRegistration() for explicit creator registrations. getStats() exposes bounded transport accounting. Server registration is not exposed on the manager; server applications must use the authorization-enforcing TypedSocket.createVirtualStream() facade.

Migration to version 10

  • Upgrade SmartServe to 10.0.0 or newer within major 10; the peer dependency range is >=10 <11.
  • Deno is no longer supported. unixSocketPath requires Node.js.
  • The wire is unchanged: the handshake still carries wire major 9, so TypedSocket 9 and 10 peers interoperate and both sides can be upgraded independently.

Earlier migration to version 9

  • Resolve every application and transport router from TypedRequest 9.1.0 or later 9.x. The generated server routing surface now exposes TypedRequest 9's routeAndAddResponse() contract: it resolves the answered request or null for a delivered response, a refused envelope or remote cancellation. A transport drops a null result without sending a JSON null answer.
  • Upgrade both peers to TypedSocket 9. The exact package-major handshake rejects TypedSocket 8 before application requests or streams run.
  • Upgrade SmartServe to 9.0.0 or newer within major 9 so its transports accept the same TypedRequest 9 router graph. Authentication preparation is opt-in; use prepareConnection to obtain each physical connection's credentials before its WebSocket upgrade.

Earlier migration to version 8

  • Replace TypedRequest 7 and SmartServe 5 with TypedRequest 8.0.3 or newer and SmartServe 6.2.4 or newer within majors 6, 7 and 8 so the transport resolves one TypedRouter major.
  • Treat wire major 8 as intentionally incompatible with TypedSocket 7. The exact handshake requires package major 8, typedrequest-cancellation-v1, and a fresh request instance ID; there is no compatibility fallback.
  • TypedSocket 7 application APIs remain otherwise unchanged.
  • When migrating directly from version 6, remove nativeByteCapabilityMode, nativeMessageCapabilityMode, nativeBytes, message-channel APIs, and native-specific authorization adapters.
  • Replace native stream DTOs with TVirtualStream<'send' | 'receive'> from @api.global/typedrequest-interfaces.
  • Replace fromSmartServe() with the required createServer() → SmartServe construction → attachSmartServe() order.
  • Pass getServerRoutingSurface(applicationRouter) to SmartServe, not the application router itself.
  • Always pass an explicit server target to createTypedRequest().
  • Configure virtualStreamAuthorizationAdapter and use createVirtualStream() for server-created streams.
  • Treat a package-major handshake failure as terminal; there is no JSON-only or capability-disabled fallback.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license.md file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme
8.1 MiB
Languages
TypeScript 100%