@firtoz/socka
Version:
Standard Schema–first WebSocket RPC for TypeScript — Bun, Hono, Node ws, Cloudflare Workers, Durable Objects
603 lines (600 loc) • 18.3 kB
JavaScript
import { RESERVED_SOCKA_PROCEDURE_NAMES } from './chunk-YMT4HAH7.js';
import { parseWirePayload, decodeSockaWire, SockaWireError, encodeClientRequest, encodeSockaWire, SockaError, reportSockaError, parseStandardSchema } from './chunk-IFIGKR3W.js';
// src/client/SockaWebSocketClient.ts
var SockaWebSocketClient = class {
constructor(options) {
this.manualClose = false;
this.reconnectAttempt = 0;
this.statusListeners = /* @__PURE__ */ new Set();
this.opts = options;
this.contract = options.contract;
this.wireFormat = options.wireFormat ?? "json";
this.serializeJson = options.serializeJson ?? JSON.stringify;
this.deserializeJson = options.deserializeJson ?? JSON.parse;
this.onResponseCb = options.onResponse;
this.onServerErrorCb = options.onServerError;
this.onEventCb = options.onEvent;
this.onValidationError = options.onValidationError;
if (options.autoConnect !== false) {
this._status = "connecting";
this.attachSocket(this.createSocket());
} else {
this._status = "idle";
}
}
setStatus(next) {
if (this._status === next) return;
this._status = next;
for (const fn of this.statusListeners) {
fn(next);
}
}
/** Current connection lifecycle state. */
get status() {
return this._status;
}
/**
* Subscribe to {@link status} changes. The listener is called immediately with the
* current status, then on every transition. Returns an unsubscribe function.
*/
onStatusChange(listener) {
this.statusListeners.add(listener);
listener(this._status);
return () => {
this.statusListeners.delete(listener);
};
}
createSocket() {
if (this.opts.webSocket) {
return this.opts.webSocket;
}
if (this.opts.url) {
return new WebSocket(this.opts.url);
}
throw new Error("Either 'url' or 'webSocket' must be provided");
}
attachSocket(ws) {
this.setStatus("connecting");
this.ws = ws;
if (this.wireFormat === "msgpack") {
ws.binaryType = "arraybuffer";
}
ws.addEventListener("open", (event) => {
const prev = this.reconnectAttempt;
this.reconnectAttempt = 0;
if (prev > 0) {
this.opts.onReconnected?.({ attempt: prev });
}
this.setStatus("open");
this.opts.onOpen?.(event);
});
ws.addEventListener("message", (event) => {
this.handleMessageEvent(event);
});
ws.addEventListener("close", (event) => {
this.opts.onClose?.(event);
if (this.manualClose) {
this.setStatus("closed");
return;
}
if (!this.getReconnectEnabled()) {
this.setStatus("closed");
return;
}
const cfg = this.resolveReconnectConfig();
const maxAttempts = cfg.maxAttempts;
if (maxAttempts !== void 0 && this.reconnectAttempt >= maxAttempts) {
this.setStatus("closed");
return;
}
this.maybeScheduleReconnect();
});
ws.addEventListener("error", (event) => {
this.opts.onError?.(event);
});
}
getReconnectEnabled() {
if (this.opts.reconnect === false) return false;
if (this.opts.webSocket !== void 0 && this.opts.reconnect === void 0) {
return false;
}
return this.opts.url !== void 0;
}
resolveReconnectConfig() {
const r = this.opts.reconnect;
if (r === false) return {};
return r ?? {};
}
computeReconnectDelayMs(attempt, cfg) {
const initial = cfg.initialDelayMs ?? 1e3;
const max = cfg.maxDelayMs ?? 3e4;
const jitterRatio = cfg.jitter ?? 0.2;
const base = Math.min(max, initial * 2 ** Math.max(0, attempt - 1));
const spread = base * jitterRatio;
return Math.max(0, base + (Math.random() * 2 - 1) * spread);
}
maybeScheduleReconnect() {
if (this.manualClose) return;
if (!this.getReconnectEnabled()) return;
const cfg = this.resolveReconnectConfig();
const maxAttempts = cfg.maxAttempts;
if (maxAttempts !== void 0 && this.reconnectAttempt >= maxAttempts) {
return;
}
if (cfg.pauseWhenHidden !== false && typeof document !== "undefined" && document.hidden) {
this.setStatus("reconnecting");
const onVis = () => {
if (!document.hidden) {
document.removeEventListener("visibilitychange", onVis);
this.maybeScheduleReconnect();
}
};
document.addEventListener("visibilitychange", onVis);
return;
}
this.reconnectAttempt += 1;
this.setStatus("reconnecting");
const delayMs = this.computeReconnectDelayMs(this.reconnectAttempt, cfg);
this.opts.onReconnecting?.({ attempt: this.reconnectAttempt, delayMs });
if (this.reconnectTimer !== void 0) {
clearTimeout(this.reconnectTimer);
}
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = void 0;
if (this.manualClose) return;
this.openReplacementSocket();
}, delayMs);
}
openReplacementSocket() {
if (this.manualClose) return;
if (!this.opts.url) return;
this.ws = void 0;
this.attachSocket(this.createSocket());
}
handleMessageEvent(event) {
try {
const fmt = this.wireFormat;
let payload;
if (fmt === "json") {
if (typeof event.data !== "string") {
this.onValidationError?.(
new Error("socka: expected JSON text frame"),
event.data
);
return;
}
payload = event.data;
} else {
if (!(event.data instanceof ArrayBuffer)) {
this.onValidationError?.(
new Error("socka: expected ArrayBuffer msgpack frame"),
event.data
);
return;
}
payload = event.data;
}
let parsed;
try {
parsed = parseWirePayload(payload, fmt, this.deserializeJson);
} catch (err) {
this.onValidationError?.(
err instanceof Error ? err : new Error(String(err)),
event.data
);
return;
}
let decoded;
try {
decoded = decodeSockaWire(parsed);
} catch (err) {
if (err instanceof SockaWireError) {
this.onValidationError?.(err, parsed);
return;
}
throw err;
}
switch (decoded.kind) {
case "serverResponse":
this.onResponseCb?.(decoded.frame);
break;
case "serverError":
this.onServerErrorCb?.(decoded.frame);
break;
case "serverEvent":
this.onEventCb?.(decoded.frame);
break;
case "clientRequest":
this.onValidationError?.(
new Error("socka: unexpected clientRequest frame from server"),
parsed
);
break;
default: {
const _exhaustive = decoded;
throw new Error(
`socka: unexpected wire decode branch ${JSON.stringify(_exhaustive)}`
);
}
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.onValidationError?.(err, event.data);
}
}
/**
* Creates the WebSocket (when {@link SockaWebSocketClientOptions.autoConnect}
* was `false`) and waits until the connection is open.
*/
async connect() {
if (!this.ws) {
this.attachSocket(this.createSocket());
}
await this.waitForOpen();
}
sendRequest(id, rpc, body) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not open");
}
const frame = encodeClientRequest(id, rpc, body);
const encoded = encodeSockaWire(frame, this.wireFormat, this.serializeJson);
if (typeof encoded === "string") {
this.ws.send(encoded);
return;
}
const copy = new Uint8Array(encoded.byteLength);
copy.set(encoded);
this.ws.send(copy.buffer);
}
close(code, reason) {
this.manualClose = true;
if (this.reconnectTimer !== void 0) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = void 0;
}
this.setStatus("closed");
this.ws?.close(code, reason);
}
get readyState() {
return this.ws?.readyState ?? WebSocket.CONNECTING;
}
get socket() {
if (!this.ws) {
throw new Error(
"socka: WebSocket not created yet; call connect() first or use autoConnect: true"
);
}
return this.ws;
}
async waitForOpen() {
const ws = this.ws;
if (!ws) {
throw new Error(
"socka: WebSocket not created yet; call connect() first or use autoConnect: true"
);
}
if (ws.readyState === WebSocket.OPEN) {
return;
}
return new Promise((resolve, reject) => {
const abortController = new AbortController();
const { signal } = abortController;
const cleanup = () => {
abortController.abort();
};
ws.addEventListener(
"open",
() => {
cleanup();
resolve();
},
{ signal }
);
ws.addEventListener(
"error",
() => {
cleanup();
reject(new Error("WebSocket connection failed"));
},
{ signal }
);
});
}
};
// src/client/SockaSession.ts
var RESERVED_CALL_NAMES = new Set(RESERVED_SOCKA_PROCEDURE_NAMES);
function waitForPushAbortError() {
if (typeof DOMException !== "undefined") {
return new DOMException("waitForPush aborted", "AbortError");
}
return new Error("socka: waitForPush aborted");
}
var SockaSessionBase = class {
constructor(options) {
this.pending = /* @__PURE__ */ new Map();
this.idSeq = 0;
this.pushListeners = /* @__PURE__ */ new Map();
const { pushHandlers, reportError, ...clientOpts } = options;
this.reportError = reportError;
this.client = new SockaWebSocketClient({
...clientOpts,
onResponse: (frame) => this.handleResponse(frame),
onServerError: (frame) => this.handleServerError(frame),
onEvent: (frame) => this.handleEvent(frame)
});
this.subscribe = this.createSubscribeApi();
const sendBag = this.buildSendMethods();
for (const name of Object.keys(sendBag)) {
if (RESERVED_CALL_NAMES.has(name)) {
throw new Error(
`socka: call name "${name}" is reserved on SockaSession.send; rename it in defineSocka`
);
}
}
this.send = sendBag;
if (pushHandlers) {
for (const key of Object.keys(pushHandlers)) {
const fn = pushHandlers[key];
if (fn) {
this.subscribe.on(key, fn);
}
}
}
}
createSubscribeApi() {
return {
on: (name, handler) => {
this.addPushListener(name, handler);
},
off: (name, handler) => {
this.removePushListener(name, handler);
},
once: (name, handler) => {
const wrapped = (payload) => {
this.removePushListener(name, wrapped);
try {
const result = handler(
payload
);
void Promise.resolve(result).catch((error) => {
reportSockaError(this.reportError, {
kind: "clientEventListener",
eventName: String(name),
error
});
});
} catch (error) {
reportSockaError(this.reportError, {
kind: "clientEventListener",
eventName: String(name),
error
});
}
};
this.addPushListener(name, wrapped);
},
waitForPush: (name, options) => this.waitForPushImpl(name, options)
};
}
addPushListener(name, handler) {
let set = this.pushListeners.get(name);
if (!set) {
set = /* @__PURE__ */ new Set();
this.pushListeners.set(name, set);
}
set.add(handler);
}
removePushListener(name, handler) {
this.pushListeners.get(name)?.delete(handler);
}
waitForPushImpl(name, options) {
return new Promise((resolve, reject) => {
const signal = options?.signal;
if (signal?.aborted) {
reject(waitForPushAbortError());
return;
}
const onAbort = () => {
cleanup();
reject(waitForPushAbortError());
};
signal?.addEventListener("abort", onAbort);
let timeoutId;
if (options?.timeoutMs != null) {
timeoutId = setTimeout(() => {
cleanup();
reject(new Error("socka: waitForPush timed out"));
}, options.timeoutMs);
}
const listener = (payload) => {
if (options?.predicate && !options.predicate(payload)) {
return;
}
cleanup();
resolve(payload);
};
const cleanup = () => {
if (timeoutId !== void 0) clearTimeout(timeoutId);
signal?.removeEventListener("abort", onAbort);
this.removePushListener(name, listener);
};
this.addPushListener(name, listener);
});
}
buildSendMethods() {
const methods = {};
for (const name of Object.keys(this.client.contract.calls)) {
const proc = this.client.contract.calls[name];
if (proc.input) {
methods[name] = (input) => this.call(name, input);
} else {
methods[name] = () => this.call(name, void 0);
}
}
return methods;
}
call(callName, input) {
return (async () => {
await this.client.connect();
if (this.client.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket not connected");
}
const id = this.nextId(callName);
const body = input !== void 0 && input !== null ? input : {};
const proc = this.client.contract.calls[callName];
if (proc !== void 0 && proc.output === void 0) {
try {
this.client.sendRequest(id, callName, body);
} catch (err) {
throw err instanceof Error ? err : new Error(String(err));
}
return;
}
return new Promise((resolve, reject) => {
this.pending.set(id, { rpc: callName, resolve, reject });
try {
this.client.sendRequest(id, callName, body);
} catch (err) {
this.pending.delete(id);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
})();
}
handleResponse(frame) {
const proc = this.client.contract.calls[frame.rpc];
if (!proc) {
const entry2 = this.pending.get(frame.id);
if (entry2) {
this.pending.delete(frame.id);
entry2.reject(new SockaError(`Unknown call: ${frame.rpc}`));
}
return;
}
if (proc.output === void 0) {
const entry2 = this.pending.get(frame.id);
if (entry2) {
this.pending.delete(frame.id);
reportSockaError(this.reportError, {
kind: "clientUnexpectedServerResponse",
rpc: frame.rpc,
requestId: frame.id
});
entry2.reject(
new SockaError(
"socka: unexpected serverResponse for fire-and-forget call",
{ requestId: frame.id, rpc: frame.rpc }
)
);
} else {
reportSockaError(this.reportError, {
kind: "clientUnexpectedServerResponse",
rpc: frame.rpc,
requestId: frame.id
});
}
return;
}
const entry = this.pending.get(frame.id);
if (!entry) return;
this.pending.delete(frame.id);
void parseStandardSchema(proc.output, frame.body).then(
(validated) => entry.resolve(validated),
(err) => entry.reject(err instanceof Error ? err : new Error(String(err)))
);
}
handleServerError(frame) {
const entry = this.pending.get(frame.id);
if (entry) {
this.pending.delete(frame.id);
entry.reject(SockaError.fromWire(frame));
return;
}
const err = SockaError.fromWire(frame);
const rpcName = frame.rpc;
if (rpcName === void 0) {
reportSockaError(this.reportError, {
kind: "clientFireAndForgetRpcError",
error: err
});
return;
}
const proc = this.client.contract.calls[rpcName];
if (proc !== void 0 && proc.output === void 0) {
reportSockaError(this.reportError, {
kind: "clientFireAndForgetRpcError",
error: err
});
return;
}
reportSockaError(this.reportError, {
kind: "clientOrphanServerError",
error: err
});
}
handleEvent(frame) {
const schema = this.client.contract.pushes[frame.event];
if (schema) {
void parseStandardSchema(schema, frame.body).then(
(validated) => this.dispatchValidatedPush(frame.event, validated),
(error) => reportSockaError(this.reportError, {
kind: "clientEventValidation",
eventName: frame.event,
error
})
);
} else {
this.dispatchValidatedPush(frame.event, frame.body);
}
}
dispatchValidatedPush(pushName, payload) {
const set = this.pushListeners.get(pushName);
if (!set || set.size === 0) return;
for (const fn of [...set]) {
try {
const result = fn(payload);
void Promise.resolve(result).catch((error) => {
reportSockaError(this.reportError, {
kind: "clientEventListener",
eventName: pushName,
error
});
});
} catch (error) {
reportSockaError(this.reportError, {
kind: "clientEventListener",
eventName: pushName,
error
});
}
}
}
nextId(prefix) {
return `${prefix}-${++this.idSeq}`;
}
rejectAllPending(reason) {
for (const [, entry] of this.pending) {
entry.reject(reason);
}
this.pending.clear();
}
close(code, reason) {
this.client.close(code, reason);
}
/** Opens the WebSocket when using {@link SockaWebSocketClientOptions.autoConnect} `false`. */
connect() {
return this.client.connect();
}
/** Same as {@link SockaWebSocketClient.status}. */
get status() {
return this.client.status;
}
/** Same as {@link SockaWebSocketClient.onStatusChange}. */
onStatusChange(listener) {
return this.client.onStatusChange(listener);
}
};
var SockaSession = SockaSessionBase;
export { SockaSession, SockaWebSocketClient };
//# sourceMappingURL=chunk-THFUHQJ3.js.map
//# sourceMappingURL=chunk-THFUHQJ3.js.map