partyserver
Version:
Build real-time applications powered by [Durable Objects](https://developers.cloudflare.com/durable-objects/), inspired by [PartyKit](https://www.partykit.io/).
858 lines (857 loc) • 32.4 kB
JavaScript
import { DurableObject, env } from "cloudflare:workers";
import { nanoid } from "nanoid";
//#region src/connection.ts
if (!("OPEN" in WebSocket)) {
const WebSocketStatus = {
CONNECTING: WebSocket.READY_STATE_CONNECTING,
OPEN: WebSocket.READY_STATE_OPEN,
CLOSING: WebSocket.READY_STATE_CLOSING,
CLOSED: WebSocket.READY_STATE_CLOSED
};
Object.assign(WebSocket, WebSocketStatus);
Object.assign(WebSocket.prototype, WebSocketStatus);
}
function tryGetPartyServerMeta(ws) {
try {
const attachment = WebSocket.prototype.deserializeAttachment.call(ws);
if (!attachment || typeof attachment !== "object") return null;
if (!("__pk" in attachment)) return null;
const pk = attachment.__pk;
if (!pk || typeof pk !== "object") return null;
const { id, tags } = pk;
if (typeof id !== "string") return null;
const { uri } = pk;
return {
id,
tags: Array.isArray(tags) ? tags : [],
uri: typeof uri === "string" ? uri : void 0
};
} catch {
return null;
}
}
function isPartyServerWebSocket(ws) {
return tryGetPartyServerMeta(ws) !== null;
}
/**
* Cache websocket attachments to avoid having to rehydrate them on every property access.
*/
var AttachmentCache = class {
#cache = /* @__PURE__ */ new WeakMap();
get(ws) {
let attachment = this.#cache.get(ws);
if (!attachment) {
attachment = WebSocket.prototype.deserializeAttachment.call(ws);
if (attachment !== void 0) this.#cache.set(ws, attachment);
else throw new Error("Missing websocket attachment. This is most likely an issue in PartyServer, please open an issue at https://github.com/cloudflare/partykit/issues");
}
return attachment;
}
set(ws, attachment) {
this.#cache.set(ws, attachment);
WebSocket.prototype.serializeAttachment.call(ws, attachment);
}
};
const attachments = new AttachmentCache();
const connections = /* @__PURE__ */ new WeakSet();
const isWrapped = (ws) => {
return connections.has(ws);
};
/**
* Wraps a WebSocket with Connection fields that rehydrate the
* socket attachments lazily only when requested.
*/
const createLazyConnection = (ws) => {
if (isWrapped(ws)) return ws;
let initialState;
if ("state" in ws) {
initialState = ws.state;
delete ws.state;
}
const connection = Object.defineProperties(ws, {
id: {
configurable: true,
get() {
return attachments.get(ws).__pk.id;
}
},
uri: {
configurable: true,
get() {
return attachments.get(ws).__pk.uri ?? null;
}
},
tags: {
configurable: true,
get() {
return attachments.get(ws).__pk.tags ?? [];
}
},
socket: {
configurable: true,
get() {
return ws;
}
},
state: {
configurable: true,
get() {
return ws.deserializeAttachment();
}
},
setState: {
configurable: true,
value: function setState(setState) {
let state;
if (setState instanceof Function) state = setState(this.state);
else state = setState;
ws.serializeAttachment(state);
return state;
}
},
deserializeAttachment: {
configurable: true,
value: function deserializeAttachment() {
return attachments.get(ws).__user ?? null;
}
},
serializeAttachment: {
configurable: true,
value: function serializeAttachment(attachment) {
const setting = {
...attachments.get(ws),
__user: attachment ?? null
};
attachments.set(ws, setting);
}
}
});
if (initialState) connection.setState(initialState);
connections.add(connection);
return connection;
};
var HibernatingConnectionIterator = class {
index = 0;
sockets;
constructor(state, tag) {
this.state = state;
this.tag = tag;
}
[Symbol.iterator]() {
return this;
}
next() {
const sockets = this.sockets ?? (this.sockets = this.state.getWebSockets(this.tag));
let socket;
while (socket = sockets[this.index++]) if (socket.readyState === WebSocket.READY_STATE_OPEN) {
if (!isPartyServerWebSocket(socket)) continue;
return {
done: false,
value: createLazyConnection(socket)
};
}
return {
done: true,
value: void 0
};
}
};
/**
* Deduplicate and validate connection tags.
* Returns the final tag array (always includes the connection id as the first tag).
*/
function prepareTags(connectionId, userTags) {
const tags = [connectionId, ...userTags.filter((t) => t !== connectionId)];
if (tags.length > 10) throw new Error("A connection can only have 10 tags, including the default id tag.");
for (const tag of tags) {
if (typeof tag !== "string") throw new Error(`A connection tag must be a string. Received: ${tag}`);
if (tag === "") throw new Error("A connection tag must not be an empty string.");
if (tag.length > 256) throw new Error("A connection tag must not exceed 256 characters");
}
return tags;
}
/**
* When not using hibernation, we track active connections manually.
*/
var InMemoryConnectionManager = class {
#connections = /* @__PURE__ */ new Map();
tags = /* @__PURE__ */ new WeakMap();
getCount() {
return this.#connections.size;
}
getConnection(id) {
return this.#connections.get(id);
}
*getConnections(tag) {
if (!tag) {
yield* this.#connections.values().filter((c) => c.readyState === WebSocket.READY_STATE_OPEN);
return;
}
for (const connection of this.#connections.values()) if ((this.tags.get(connection) ?? []).includes(tag)) yield connection;
}
accept(connection, options) {
connection.accept();
const tags = prepareTags(connection.id, options.tags);
this.#connections.set(connection.id, connection);
this.tags.set(connection, tags);
Object.defineProperty(connection, "tags", {
get: () => tags,
configurable: true
});
const removeConnection = () => {
this.#connections.delete(connection.id);
connection.removeEventListener("close", removeConnection);
connection.removeEventListener("error", removeConnection);
};
connection.addEventListener("close", removeConnection);
connection.addEventListener("error", removeConnection);
return connection;
}
};
/**
* When opting into hibernation, the platform tracks connections for us.
*/
var HibernatingConnectionManager = class {
constructor(controller) {
this.controller = controller;
}
getCount() {
let count = 0;
for (const ws of this.controller.getWebSockets()) if (isPartyServerWebSocket(ws)) count++;
return count;
}
getConnection(id) {
const matching = this.controller.getWebSockets(id).filter((ws) => {
return tryGetPartyServerMeta(ws)?.id === id;
});
if (matching.length === 0) return void 0;
if (matching.length === 1) return createLazyConnection(matching[0]);
throw new Error(`More than one connection found for id ${id}. Did you mean to use getConnections(tag) instead?`);
}
getConnections(tag) {
return new HibernatingConnectionIterator(this.controller, tag);
}
accept(connection, options) {
const tags = prepareTags(connection.id, options.tags);
this.controller.acceptWebSocket(connection, tags);
connection.serializeAttachment({
__pk: {
id: connection.id,
tags,
uri: connection.uri ?? void 0
},
__user: null
});
return createLazyConnection(connection);
}
};
//#endregion
//#region src/index.ts
const NAME_STORAGE_KEY = "__ps_name";
/**
* Reserved WebSocket close codes the runtime synthesizes when there
* was no real Close frame from the peer:
* - 1005 (NoStatusReceived) — peer's frame had no status code.
* - 1006 (AbnormalClosure) — peer dropped the underlying transport
* without sending a Close frame at all.
* - 1015 (TLSHandshake) — TLS failure during connection setup.
*
* These cannot legally appear in an outgoing Close frame, and — more
* importantly for our reciprocation path — there is no peer left to
* receive a reciprocating Close frame. Trying to send one anyway can
* succeed synchronously but fail asynchronously inside the runtime
* with "WebSocket peer disconnected" / "Network connection lost",
* which escapes a synchronous try/catch and surfaces as an unhandled
* promise rejection.
*/
function isReservedCloseCode(code) {
return code === 1005 || code === 1006 || code === 1015;
}
/**
* Reciprocate a peer-initiated Close frame to complete the handshake.
*
* Best-effort: swallows synchronous errors from invalid codes,
* oversize reasons, or sockets that have already been closed by user
* code. Skips the reciprocation entirely when the peer didn't
* actually send a Close frame (reserved codes 1005/1006/1015) — in
* those cases the underlying transport is already gone and writing
* to it would fail asynchronously, which we can't catch here.
*
* Used by both the hibernating and non-hibernating close handlers to
* ensure the close handshake always completes when there is one to
* complete.
*/
function closeQuietly(ws, code, reason) {
if (isReservedCloseCode(code)) return;
try {
ws.close(code, reason);
} catch {}
}
const serverMapCache = /* @__PURE__ */ new WeakMap();
const bindingNameCache = /* @__PURE__ */ new WeakMap();
const DEFAULT_ROUTING_RETRY_OPTIONS = {
maxAttempts: 3,
baseDelayMs: 100,
maxDelayMs: 800
};
function durableObjectGetOptions(options) {
return options?.locationHint ? { locationHint: options.locationHint } : void 0;
}
function validatePositiveInteger(value, name) {
if (!Number.isFinite(value) || value < 1) throw new Error(`${name} must be >= 1`);
if (!Number.isInteger(value)) throw new Error(`${name} must be an integer`);
}
function validatePositiveNumber(value, name) {
if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be > 0`);
}
function resolveRoutingRetryOptions(options) {
if (options === false) return null;
const resolved = {
maxAttempts: options?.maxAttempts ?? DEFAULT_ROUTING_RETRY_OPTIONS.maxAttempts,
baseDelayMs: options?.baseDelayMs ?? DEFAULT_ROUTING_RETRY_OPTIONS.baseDelayMs,
maxDelayMs: options?.maxDelayMs ?? DEFAULT_ROUTING_RETRY_OPTIONS.maxDelayMs,
onRetry: options?.onRetry
};
validatePositiveInteger(resolved.maxAttempts, "routingRetry.maxAttempts");
validatePositiveNumber(resolved.baseDelayMs, "routingRetry.baseDelayMs");
validatePositiveNumber(resolved.maxDelayMs, "routingRetry.maxDelayMs");
if (resolved.baseDelayMs > resolved.maxDelayMs) throw new Error("routingRetry.baseDelayMs must be <= maxDelayMs");
return resolved;
}
function isRetryableDurableObjectError(error) {
if (typeof error !== "object" || error === null) return false;
const typed = error;
return typed.retryable === true && typed.overloaded !== true;
}
function routingRetryDelayMs(attempt, options) {
const upperBoundMs = Math.min(options.maxDelayMs, options.baseDelayMs * 2 ** (attempt - 1));
return Math.floor(Math.random() * upperBoundMs);
}
async function retryDurableObjectOperation(operation, context, retryOptions) {
const resolved = resolveRoutingRetryOptions(retryOptions);
if (!resolved) return await operation();
let attempt = 1;
while (true) try {
return await operation();
} catch (error) {
const nextAttempt = attempt + 1;
if (nextAttempt > resolved.maxAttempts || !isRetryableDurableObjectError(error)) throw error;
const delayMs = routingRetryDelayMs(attempt, resolved);
try {
await resolved.onRetry?.({
error,
attempt,
maxAttempts: resolved.maxAttempts,
delayMs,
name: context.name,
className: context.className
});
} catch (callbackError) {
console.warn("PartyServer routingRetry onRetry callback failed:", callbackError);
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
attempt = nextAttempt;
}
}
/**
* For a given server namespace, create a server with a name.
*
* Makes an RPC that awaits the DO's `onStart()` before returning, so callers
* can invoke user-defined RPC methods on the returned stub and trust that
* `onStart()` has completed. (User-defined RPC methods don't
* otherwise pass through `Server.fetch()`, which is where initialization
* would normally be triggered.)
*
* `this.name` inside the DO is always populated from `ctx.id.name`, so
* the RPC no longer needs to carry the name for bookkeeping; it exists
* purely to synchronize `onStart()` and to deliver `props`.
*/
async function getServerByName(serverNamespace, name, options) {
if (options?.jurisdiction) serverNamespace = serverNamespace.jurisdiction(options.jurisdiction);
const id = serverNamespace.idFromName(name);
const getOptions = durableObjectGetOptions(options);
await retryDurableObjectOperation(() => serverNamespace.get(id, getOptions).setName(name, options?.props), { name }, options?.routingRetry);
return serverNamespace.get(id, getOptions);
}
function camelCaseToKebabCase(str) {
if (str === str.toUpperCase() && str !== str.toLowerCase()) return str.toLowerCase().replace(/_/g, "-");
let kebabified = str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
kebabified = kebabified.startsWith("-") ? kebabified.slice(1) : kebabified;
return kebabified.replace(/_/g, "-").replace(/-$/, "");
}
/**
* Resolve CORS options into a concrete headers object (or null if CORS is disabled).
*/
function resolveCorsHeaders(cors) {
if (cors === true) return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "*",
"Access-Control-Max-Age": "86400"
};
if (cors && typeof cors === "object") {
const h = new Headers(cors);
const record = {};
h.forEach((value, key) => {
record[key] = value;
});
return record;
}
return null;
}
async function routePartykitRequest(req, env$1 = env, options) {
if (!serverMapCache.has(env$1)) {
const namespaceMap = {};
const bindingNames = {};
for (const [k, v] of Object.entries(env$1)) if (v && typeof v === "object" && "idFromName" in v && typeof v.idFromName === "function") {
const kebab = camelCaseToKebabCase(k);
namespaceMap[kebab] = v;
bindingNames[kebab] = k;
}
serverMapCache.set(env$1, namespaceMap);
bindingNameCache.set(env$1, bindingNames);
}
const map = serverMapCache.get(env$1);
const bindingNames = bindingNameCache.get(env$1);
const prefixParts = (options?.prefix || "parties").split("/");
const parts = new URL(req.url).pathname.split("/").filter(Boolean);
if (!prefixParts.every((part, index) => parts[index] === part) || parts.length < prefixParts.length + 2) return null;
const namespace = parts[prefixParts.length];
const name = parts[prefixParts.length + 1];
if (name && namespace) {
if (!map[namespace]) {
if (namespace === "main") {
console.warn("You appear to be migrating a PartyKit project to PartyServer.");
console.warn(`PartyServer doesn't have a "main" party by default. Try adding this to your PartySocket client:\n
party: "${camelCaseToKebabCase(Object.keys(map)[0])}"`);
} else console.error(`The url ${req.url} with namespace "${namespace}" and name "${name}" does not match any server namespace.
Did you forget to add a durable object binding to the class ${namespace[0].toUpperCase() + namespace.slice(1)} in your wrangler.jsonc?`);
return new Response("Invalid request", { status: 400 });
}
const corsHeaders = resolveCorsHeaders(options?.cors);
const isWebSocket = req.headers.get("Upgrade")?.toLowerCase() === "websocket";
function withCorsHeaders(response) {
if (!corsHeaders || isWebSocket) return response;
const newResponse = new Response(response.body, response);
for (const [key, value] of Object.entries(corsHeaders)) newResponse.headers.set(key, value);
return newResponse;
}
if (req.method === "OPTIONS" && corsHeaders) return new Response(null, { headers: corsHeaders });
let doNamespace = map[namespace];
if (options?.jurisdiction) doNamespace = doNamespace.jurisdiction(options.jurisdiction);
const id = doNamespace.idFromName(name);
const getOptions = durableObjectGetOptions(options);
req = new Request(req);
req.headers.set("x-partykit-namespace", namespace);
if (options?.jurisdiction) req.headers.set("x-partykit-jurisdiction", options.jurisdiction);
const className = bindingNames[namespace];
let partyDeprecationWarned = false;
const lobby = {
get party() {
if (!partyDeprecationWarned) {
partyDeprecationWarned = true;
console.warn("lobby.party is deprecated and currently returns the kebab-case namespace (e.g. \"my-agent\"). Use lobby.className instead to get the Durable Object class name (e.g. \"MyAgent\"). In the next major version, lobby.party will return the class name.");
}
return namespace;
},
className,
name
};
if (isWebSocket) {
if (options?.onBeforeConnect) {
const reqOrRes = await options.onBeforeConnect(req, lobby);
if (reqOrRes instanceof Request) req = reqOrRes;
else if (reqOrRes instanceof Response) return reqOrRes;
}
} else if (options?.onBeforeRequest) {
const reqOrRes = await options.onBeforeRequest(req, lobby);
if (reqOrRes instanceof Request) req = reqOrRes;
else if (reqOrRes instanceof Response) return withCorsHeaders(reqOrRes);
}
if (options?.props !== void 0) req.headers.set("x-partykit-props", JSON.stringify(options.props));
const response = await retryDurableObjectOperation(() => doNamespace.get(id, getOptions).fetch(req.clone()), {
name,
className
}, options?.routingRetry);
return isWebSocket ? response : withCorsHeaders(response);
} else return null;
}
var Server = class extends DurableObject {
static options = { hibernate: false };
#status = "zero";
#ParentClass = Object.getPrototypeOf(this).constructor;
#connectionManager = this.#ParentClass.options.hibernate ? new HibernatingConnectionManager(this.ctx) : new InMemoryConnectionManager();
/**
* Execute SQL queries against the Server's database
* @template T Type of the returned rows
* @param strings SQL query template strings
* @param values Values to be inserted into the query
* @returns Array of query results
*/
sql(strings, ...values) {
let query = "";
try {
query = strings.reduce((acc, str, i) => acc + str + (i < values.length ? "?" : ""), "");
return [...this.ctx.storage.sql.exec(query, ...values)];
} catch (e) {
console.error(`failed to execute sql query: ${query}`, e);
throw this.onException(e);
}
}
constructor(ctx, env) {
super(ctx, env);
}
/**
* Handle incoming requests to the server.
*/
async fetch(request) {
try {
const props = request.headers.get("x-partykit-props");
if (props) this.#_props = JSON.parse(props);
if (!this.ctx.id.name && !this.#_name) {
const room = request.headers.get("x-partykit-room");
if (room) this.#_name = room;
}
await this.#ensureInitialized();
if (!this.ctx.id.name && !this.#_name) throw new Error(`Cannot determine the name for ${this.#ParentClass.name}: this.ctx.id.name is undefined, no legacy __ps_name storage record is present, and no x-partykit-room header was supplied. Likely causes:
1. The stub was built via idFromString()/newUniqueId(). PartyServer requires name-based addressing (idFromName/getByName).
2. The workerd/wrangler runtime is too old to expose ctx.id.name — update to a recent wrangler release.
3. You called stub.fetch() directly without going through routePartykitRequest()/getServerByName(). Prefer those, or set the x-partykit-room header.`);
const url = new URL(request.url);
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return await this.onRequest(request);
else {
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair();
let connectionId = url.searchParams.get("_pk");
if (!connectionId) connectionId = nanoid();
let connection = Object.assign(serverWebSocket, {
id: connectionId,
uri: request.url,
server: this.name,
tags: [],
state: null,
setState(setState) {
let state;
if (setState instanceof Function) state = setState(this.state);
else state = setState;
this.state = state;
return this.state;
}
});
const ctx = { request };
const tags = await this.getConnectionTags(connection, ctx);
connection = this.#connectionManager.accept(connection, { tags });
if (!this.#ParentClass.options.hibernate) this.#attachSocketEventHandlers(connection);
await this.onConnect(connection, ctx);
return new Response(null, {
status: 101,
webSocket: clientWebSocket
});
}
} catch (err) {
console.error(`Error in ${this.#ParentClass.name}:${this.ctx.id.name ?? this.#_name ?? "<unnamed>"} fetch:`, err);
if (!(err instanceof Error)) throw err;
if (request.headers.get("Upgrade") === "websocket") {
const pair = new WebSocketPair();
pair[1].accept();
pair[1].send(JSON.stringify({ error: err.stack }));
pair[1].close(1011, "Uncaught exception during session setup");
return new Response(null, {
status: 101,
webSocket: pair[0]
});
} else return new Response(err.stack, { status: 500 });
}
}
async webSocketMessage(ws, message) {
if (!isPartyServerWebSocket(ws)) return;
try {
const connection = createLazyConnection(ws);
await this.#ensureInitialized();
connection.server = this.name;
return this.onMessage(connection, message);
} catch (e) {
console.error(`Error in ${this.#ParentClass.name}:${this.ctx.id.name ?? this.#_name ?? "<unnamed>"} webSocketMessage:`, e);
}
}
async webSocketClose(ws, code, reason, wasClean) {
if (!isPartyServerWebSocket(ws)) return;
try {
const connection = createLazyConnection(ws);
await this.#ensureInitialized();
connection.server = this.name;
await this.onClose(connection, code, reason, wasClean);
} catch (e) {
console.error(`Error in ${this.#ParentClass.name}:${this.ctx.id.name ?? this.#_name ?? "<unnamed>"} webSocketClose:`, e);
} finally {
closeQuietly(ws, code, reason);
}
}
async webSocketError(ws, error) {
if (!isPartyServerWebSocket(ws)) return;
try {
const connection = createLazyConnection(ws);
await this.#ensureInitialized();
connection.server = this.name;
return this.onError(connection, error);
} catch (e) {
console.error(`Error in ${this.#ParentClass.name}:${this.ctx.id.name ?? this.#_name ?? "<unnamed>"} webSocketError:`, e);
}
}
/**
* Read the legacy `__ps_name` storage record as a fallback source of
* `this.name` when `ctx.id.name` is unavailable. Covers:
*
* 1. Alarm handlers firing on alarm records that were scheduled by
* a workerd version that did not yet persist `name` into the
* alarm record (see the Durable Objects ID docs:
* https://developers.cloudflare.com/durable-objects/api/id/#name).
* The runtime contract for current workerd populates `ctx.id.name`
* in alarm handlers — see the "Raw runtime contract" tests — so
* this fallback exists primarily for stale on-disk alarm records
* and for defense-in-depth against future runtime changes.
* 2. Legacy framework-level bootstrap patterns that write
* `__ps_name` directly (or call `setName()`) before triggering
* `__unsafe_ensureInitialized()` — typically DOs addressed via
* `idFromString()` / `newUniqueId()` plus a name override.
*/
async #hydrateNameFromLegacyStorage() {
if (this.#_name) return;
const stored = await this.ctx.storage.get(NAME_STORAGE_KEY);
if (stored) this.#_name = stored;
}
async #persistNameFallbackFromCtxId() {
const ctxName = this.ctx.id.name;
if (ctxName === void 0 || this.#_name) return;
if (await this.ctx.storage.get(NAME_STORAGE_KEY) !== ctxName) await this.ctx.storage.put(NAME_STORAGE_KEY, ctxName);
this.#_name = ctxName;
}
/**
* @internal — Do not use directly. This is an escape hatch for frameworks
* (like Agents) that receive calls via native DO RPC, bypassing the
* standard fetch/alarm/webSocket entry points where initialization
* normally happens. Calling this from application code is unsupported
* and may break without notice.
*/
async __unsafe_ensureInitialized() {
await this.#ensureInitialized();
}
async #ensureInitialized() {
if (this.#status === "started") return;
if (this.ctx.id.name !== void 0) await this.#persistNameFallbackFromCtxId();
else if (!this.#_name) await this.#hydrateNameFromLegacyStorage();
let error;
await this.ctx.blockConcurrencyWhile(async () => {
this.#status = "starting";
try {
await this.onStart(this.#_props);
this.#status = "started";
} catch (e) {
this.#status = "zero";
error = e;
}
});
if (error) throw error;
}
#attachSocketEventHandlers(connection) {
const handleMessageFromClient = (event) => {
this.onMessage(connection, event.data)?.catch((e) => {
console.error("onMessage error:", e);
});
};
const reciprocateClose = (event) => {
closeQuietly(connection, event.code, event.reason);
};
const handleCloseFromClient = (event) => {
connection.removeEventListener("message", handleMessageFromClient);
connection.removeEventListener("close", handleCloseFromClient);
let result;
try {
result = this.onClose(connection, event.code, event.reason, event.wasClean);
} catch (e) {
console.error("onClose error:", e);
reciprocateClose(event);
return;
}
if (result && typeof result.then === "function") result.catch((e) => {
console.error("onClose error:", e);
}).finally(() => reciprocateClose(event));
else reciprocateClose(event);
};
const handleErrorFromClient = (e) => {
connection.removeEventListener("message", handleMessageFromClient);
connection.removeEventListener("error", handleErrorFromClient);
this.onError(connection, e.error)?.catch((e) => {
console.error("onError error:", e);
});
};
connection.addEventListener("close", handleCloseFromClient);
connection.addEventListener("error", handleErrorFromClient);
connection.addEventListener("message", handleMessageFromClient);
}
#_name;
/**
* The name for this server.
*
* Resolves from `this.ctx.id.name` — the native DO id name, populated
* whenever the stub was created via `idFromName()` or `getByName()`.
* This is available inside every entry point (including the constructor,
* alarms, and hibernating websocket handlers).
*
* For alarm handlers firing on stale on-disk alarm records from
* older workerd versions that didn't persist `name` into the alarm
* record, the name is recovered from a storage fallback record.
*
* Throws if neither source is available — typically this means the DO
* was addressed via `idFromString()` or `newUniqueId()`, which is not
* supported by PartyServer.
*/
get name() {
const ctxName = this.ctx.id.name;
if (ctxName !== void 0) return ctxName;
if (this.#_name) return this.#_name;
throw new Error(`Attempting to read .name on ${this.#ParentClass.name}, but this.ctx.id.name is not set and no ${NAME_STORAGE_KEY} fallback record is available. PartyServer requires DOs to be addressed via idFromName()/getByName(), or explicitly bootstrapped with setName() when using idFromString()/newUniqueId(). If this happens in an alarm handler firing on a stale alarm record, initialize the DO from a fetch/RPC entry point first so PartyServer can persist the fallback name.`);
}
/**
* Establish this server's name and trigger `onStart()`.
*
* Use cases:
*
* 1. **Framework-level bootstrap of DOs where `ctx.id.name` is
* undefined** — e.g. DOs addressed via `idFromString()` /
* `newUniqueId()`. `setName()` stashes the name in memory and
* persists it under `__ps_name` so cold-wake invocations
* recover it via `#ensureInitialized()`'s legacy fallback.
* 2. **Delivering initial `props` to `onStart()`** via the
* optional second argument.
*
* For DOs addressed via `idFromName()` / `getByName()`, calling
* `setName()` is redundant — `this.name` is available automatically
* from `ctx.id.name`. The normal initialization path also persists
* a fallback record so old-compat alarm handlers can recover the name.
* Throws if `name` does not match `ctx.id.name`.
*
* **Not appropriate for facets.** Cloudflare Agents and any other
* framework using `ctx.facets.get(...)` should pass an explicit
* `id` in `FacetStartupOptions` so the facet has its own
* `ctx.id.name`:
*
* ```ts
* const stub = ctx.facets.get(facetKey, () => ({
* class: ChildClass,
* id: ctx.exports.SomeBoundDOClass.idFromName(facetName),
* }));
* ```
*
* Without an explicit `id`, the facet inherits the parent DO's
* `ctx.id` (including `ctx.id.name`), and `setName()` will throw
* the ctx.id.name-mismatch error because the facet's intended
* name differs from the parent's. See
* https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/
* for the `FacetStartupOptions.id` semantics.
*
* @deprecated for callers that address DOs via `idFromName()` /
* `getByName()`. Still the supported API for framework-level
* bootstrap of header/`newUniqueId`-addressed DOs and for
* delivering initial `props` to `onStart()`.
*/
async setName(name, props) {
if (!name) throw new Error("A name is required.");
const ctxName = this.ctx.id.name;
if (ctxName !== void 0 && ctxName !== name) throw new Error(`This server's Durable Object id was created for name "${ctxName}", cannot setName to "${name}".`);
if (this.#_name && this.#_name !== name) throw new Error(`This server already has a name: ${this.#_name}, attempting to set to: ${name}`);
if (props !== void 0) this.#_props = props;
if (!this.#_name && ctxName === void 0) {
await this.ctx.storage.put(NAME_STORAGE_KEY, name);
this.#_name = name;
}
await this.#ensureInitialized();
}
/**
* @internal
* @deprecated Retained for backward compatibility with older callers.
* `routePartykitRequest` no longer uses this method; it sends props via
* the `x-partykit-props` header on the underlying `fetch()` request.
*/
async _initAndFetch(name, props, request) {
await this.setName(name, props);
return this.fetch(request);
}
#sendMessageToConnection(connection, message) {
try {
connection.send(message);
} catch (_e) {
connection.close(1011, "Unexpected error");
}
}
/** Send a message to all connected clients, except connection ids listed in `without` */
broadcast(msg, without) {
for (const connection of this.#connectionManager.getConnections()) if (!without || !without.includes(connection.id)) this.#sendMessageToConnection(connection, msg);
}
/** Get a connection by connection id */
getConnection(id) {
return this.#connectionManager.getConnection(id);
}
/**
* Get all connections. Optionally, you can provide a tag to filter returned connections.
* Use `Server#getConnectionTags` to tag the connection on connect.
*/
getConnections(tag) {
return this.#connectionManager.getConnections(tag);
}
/**
* You can tag a connection to filter them in Server#getConnections.
* Each connection supports up to 9 tags, each tag max length is 256 characters.
*/
getConnectionTags(connection, context) {
return [];
}
#_props;
/**
* Called when the server is started for the first time.
*/
onStart(props) {}
/**
* Called when a new connection is made to the server.
*/
onConnect(connection, ctx) {}
/**
* Called when a message is received from a connection.
*/
onMessage(connection, message) {}
/**
* Called when a connection is closed.
*/
onClose(connection, code, reason, wasClean) {}
/**
* Called when an error occurs on a connection.
*/
onError(connection, error) {
console.error(`Error on connection ${connection.id} in ${this.#ParentClass.name}:${this.name}:`, error);
console.info(`Implement onError on ${this.#ParentClass.name} to handle this error.`);
}
/**
* Called when a request is made to the server.
*/
onRequest(request) {
console.warn(`onRequest hasn't been implemented on ${this.#ParentClass.name}:${this.name} responding to ${request.url}`);
return new Response("Not implemented", { status: 404 });
}
/**
* Called when an exception occurs.
* @param error - The error that occurred.
*/
onException(error) {
console.error(`Exception in ${this.#ParentClass.name}:${this.name}:`, error);
console.info(`Implement onException on ${this.#ParentClass.name} to handle this error.`);
}
onAlarm() {
console.log(`Implement onAlarm on ${this.#ParentClass.name} to handle alarms.`);
}
async alarm() {
await this.#ensureInitialized();
await this.onAlarm();
}
};
//#endregion
export { Server, getServerByName, routePartykitRequest };
//# sourceMappingURL=index.js.map