@firtoz/socka
Version:
Standard Schema–first WebSocket RPC for TypeScript — Bun, Hono, Node ws, Cloudflare Workers, Durable Objects
317 lines (315 loc) • 9.77 kB
JavaScript
import { parseStandardSchema, parseWirePayload, decodeSockaWire, SockaWireError, encodeServerError, SockaError, encodeServerResponse, encodeSockaWire, encodeServerEvent, reportSockaError } from './chunk-IFIGKR3W.js';
import { exhaustiveGuard } from '@firtoz/maybe-error';
function isLooseUpgradeConfig(config) {
return "strictUpgradeRequest" in config && config.strictUpgradeRequest === false;
}
function broadcastSockaEventToPeers(sessions, self, event, body, excludeSelf = false) {
for (const [ws, session] of sessions) {
if (excludeSelf && ws === self.websocket) continue;
session.emitWireEvent(event, body);
}
}
function broadcastSockaEventToAll(sessions, event, body) {
for (const session of sessions.values()) {
session.emitWireEvent(event, body);
}
}
async function broadcastContractPushToAll(sessions, contract, name, body) {
const schema = contract.pushes[name];
if (!schema) {
throw new Error(`socka: unknown push ${String(name)}`);
}
const validated = await parseStandardSchema(
schema,
body
);
broadcastSockaEventToAll(sessions, name, validated);
}
var SockaWebSocketSession = class {
constructor(websocket, sessions, config, init) {
this.websocket = websocket;
this.sessions = sessions;
this.config = config;
this.wireFormat = config.wireFormat ?? "json";
if (isLooseUpgradeConfig(config)) {
const createData = config.createData;
const create = createData ?? ((_i) => ({}));
this._data = create(init ?? {});
} else {
if (!init?.request) {
throw new Error(
"socka: strict upgrade (default) requires a Request on the upgrade init (e.g. Bun upgrade with `data: { \u2026, request: req }`, or Hono default sockaInit), or use SockaWebSocketSessionConfigLoose with strictUpgradeRequest: false"
);
}
const strictInit = { request: init.request };
if (config.createData) {
this._data = config.createData(strictInit);
} else {
this._data = {};
}
}
}
get data() {
return this._data;
}
/**
* Session data for every connection in the same {@link sessions} map (same room),
* optionally excluding this socket.
*/
listPeers(options) {
const out = [];
for (const [ws, s] of this.sessions) {
if (options?.excludeSelf && ws === this.websocket) continue;
out.push(s.data);
}
return out;
}
/**
* Like {@link listPeers} but maps each peer {@link SockaWebSocketSession}
* (e.g. when you need more than {@link #data}).
*/
listPeersWith(map, options) {
const out = [];
for (const [ws, s] of this.sessions) {
if (options?.excludeSelf && ws === this.websocket) continue;
out.push(map(s));
}
return out;
}
/** Count of sessions in this room (same {@link sessions} map), optionally excluding self. */
peerCount(options) {
let n = 0;
for (const [ws] of this.sessions) {
if (options?.excludeSelf && ws === this.websocket) continue;
n += 1;
}
return n;
}
/** Whether any peer sessions exist (optionally excluding self). */
hasPeers(options) {
return this.peerCount(options) > 0;
}
/**
* Invokes the user {@link typeof SockaWebSocketSessionConfig.handleClose} callback.
* Server adapters should call this when the WebSocket closes, **before** deleting
* this session from the shared `sessions` map.
*/
async invokeHandleClose() {
await this.config.handleClose(this);
}
async handleRawMessage(rawMessage) {
if (this.wireFormat !== "json") {
await this.reportValidationError(
new Error("socka: unexpected JSON frame in msgpack mode"),
rawMessage
);
return;
}
const deserialize = this.config.deserializeJson ?? JSON.parse;
let parsed;
try {
parsed = deserialize(rawMessage);
} catch {
await this.reportValidationError(
new Error("socka: invalid JSON"),
rawMessage
);
return;
}
await this.dispatchAfterParsed(parsed, rawMessage);
}
async handleBinaryMessage(buffer) {
if (this.wireFormat !== "msgpack") {
await this.reportValidationError(
new Error("socka: unexpected binary frame in JSON mode"),
buffer
);
return;
}
let parsed;
try {
parsed = parseWirePayload(buffer, "msgpack");
} catch (err) {
await this.reportValidationError(
err instanceof Error ? err : new Error("socka: msgpack decode failed"),
buffer
);
return;
}
await this.dispatchAfterParsed(parsed, buffer);
}
async dispatchAfterParsed(parsed, originalWire) {
let decoded;
try {
decoded = decodeSockaWire(parsed);
} catch (err) {
if (err instanceof SockaWireError) {
await this.reportValidationError(err, originalWire);
return;
}
throw err;
}
switch (decoded.kind) {
case "clientRequest":
await this.dispatchClientRequest(decoded.frame, originalWire);
return;
case "serverResponse":
case "serverError":
case "serverEvent":
await this.reportValidationError(
new Error("socka: unexpected server-originated frame from client"),
parsed
);
return;
default:
exhaustiveGuard(decoded);
}
}
async dispatchClientRequest(frame, _originalWire) {
const rpcName = frame.rpc;
const procedure = this.config.contract.calls[rpcName];
if (!procedure) {
const errorFrame = encodeServerError(
frame.id,
`Unknown call: ${rpcName}`,
{ rpc: rpcName }
);
this.sendWireFrame(errorFrame);
return;
}
let validatedInput;
if (procedure.input) {
try {
validatedInput = await parseStandardSchema(procedure.input, frame.body);
} catch (err) {
const msg = err instanceof Error ? err.message : "Input validation failed";
const errorFrame = encodeServerError(frame.id, msg, {
rpc: rpcName
});
this.sendWireFrame(errorFrame);
return;
}
}
let result;
try {
if (procedure.input) {
const handler = this.config.handlers[rpcName];
result = await handler(validatedInput, this);
} else {
const handler = this.config.handlers[rpcName];
result = await handler(this);
}
} catch (err) {
this.config.onHandlerError?.(err, rpcName, validatedInput, this);
const sockaErr = err instanceof SockaError ? err : new SockaError(
err instanceof Error ? err.message : "Handler failed"
);
const errorFrame = encodeServerError(frame.id, sockaErr.message, {
rpc: rpcName,
code: sockaErr.code,
data: sockaErr.data
});
this.sendWireFrame(errorFrame);
return;
}
if (procedure.output === void 0) {
return;
}
let validatedOutput;
try {
validatedOutput = await parseStandardSchema(procedure.output, result);
} catch (err) {
const msg = err instanceof Error ? err.message : "Output validation failed";
const errorFrame = encodeServerError(frame.id, msg, { rpc: rpcName });
this.sendWireFrame(errorFrame);
return;
}
const responseFrame = encodeServerResponse(
frame.id,
rpcName,
validatedOutput
);
this.sendWireFrame(responseFrame);
}
encodeOutgoing(frame) {
return encodeSockaWire(
frame,
this.wireFormat,
this.config.serializeJson ?? JSON.stringify
);
}
sendWireFrame(frame) {
if (this.websocket.readyState !== WebSocket.OPEN) {
return;
}
const encoded = this.encodeOutgoing(frame);
if (typeof encoded === "string") {
this.websocket.send(encoded);
return;
}
const copy = new Uint8Array(encoded.byteLength);
copy.set(encoded);
this.websocket.send(copy.buffer);
}
/**
* Send a server event frame (wire). Prefer {@link emitPush} so
* payloads are validated against the contract.
*/
emitWireEvent(event, body) {
const frame = encodeServerEvent(event, body);
this.sendWireFrame(frame);
}
async emitPush(name, body) {
const schema = this.config.contract.pushes[name];
if (!schema) {
throw new Error(`socka: unknown push ${String(name)}`);
}
const validated = await parseStandardSchema(
schema,
body
);
this.emitWireEvent(name, validated);
}
async broadcastPush(name, body, excludeSelf = false) {
const schema = this.config.contract.pushes[name];
if (!schema) {
throw new Error(`socka: unknown push ${String(name)}`);
}
const validated = await parseStandardSchema(
schema,
body
);
broadcastSockaEventToPeers(
this.sessions,
this,
name,
validated,
excludeSelf
);
}
async reportValidationError(error, originalMessage) {
if (this.config.onValidationError) {
await this.config.onValidationError(error, originalMessage);
} else {
console.error("socka: validation error:", error, originalMessage);
}
}
};
function runSockaSessionOnAttached(config, session) {
const cb = config.onAttached;
if (!cb) return;
try {
const result = cb(session);
void Promise.resolve(result).catch((error) => {
reportSockaError(config.reportError, {
kind: "serverOnAttached",
error
});
});
} catch (error) {
reportSockaError(config.reportError, { kind: "serverOnAttached", error });
}
}
export { SockaWebSocketSession, broadcastContractPushToAll, broadcastSockaEventToAll, broadcastSockaEventToPeers, runSockaSessionOnAttached };
//# sourceMappingURL=chunk-TTXY7O5P.js.map
//# sourceMappingURL=chunk-TTXY7O5P.js.map