Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/connection-tags-property.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"partyserver": minor
---

Add `connection.tags` property to read back tags assigned via `getConnectionTags()`. Works in both hibernating and in-memory modes. Tags are validated and always include the connection id as the first tag.
5 changes: 5 additions & 0 deletions .changeset/fix-lobby-party-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"partyserver": patch
---

Add `lobby.className` to `onBeforeConnect`/`onBeforeRequest` callbacks, providing the Durable Object class name (e.g. `"MyAgent"`). The existing `lobby.party` field is now deprecated (it returns the kebab-case URL namespace) and will be changed to return the class name in the next major version.
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/hono-party/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20251218.0",
"hono": "^4.11.1",
"partyserver": "^0.1.4"
"partyserver": "^0.1.5"
}
}
93 changes: 60 additions & 33 deletions packages/partyserver/src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type ConnectionAttachments = {
// TODO: remove this once we have
// durable object level setState
server: string;
tags: string[];
};
__user?: unknown;
};
Expand All @@ -57,11 +58,20 @@ function tryGetPartyServerMeta(
if (!pk || typeof pk !== "object") {
return null;
}
const { id, server } = pk as { id?: unknown; server?: unknown };
const { id, server, tags } = pk as {
id?: unknown;
server?: unknown;
tags?: unknown;
};
if (typeof id !== "string" || typeof server !== "string") {
return null;
}
return pk as ConnectionAttachments["__pk"];
// Default tags to [] for connections created before tags were stored
return {
id,
server,
tags: Array.isArray(tags) ? tags : []
} as ConnectionAttachments["__pk"];
} catch {
return null;
}
Expand Down Expand Up @@ -138,6 +148,12 @@ export const createLazyConnection = (
return attachments.get(ws).__pk.server;
}
},
tags: {
get() {
// Default to [] for connections accepted before tags were stored
return attachments.get(ws).__pk.tags ?? [];
}
},
socket: {
get() {
return ws;
Expand Down Expand Up @@ -233,6 +249,36 @@ class HibernatingConnectionIterator<T> implements IterableIterator<
}
}

/**
* Deduplicate and validate connection tags.
* Returns the final tag array (always includes the connection id as the first tag).
*/
function prepareTags(connectionId: string, userTags: string[]): string[] {
const tags = [connectionId, ...userTags.filter((t) => t !== connectionId)];

// validate tags against documented restrictions
// https://developers.cloudflare.com/durable-objects/api/hibernatable-websockets-api/#state-methods-for-websockets
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;
}

export interface ConnectionManager {
getCount(): number;
getConnection<TState>(id: string): Connection<TState> | undefined;
Expand Down Expand Up @@ -280,12 +326,16 @@ export class InMemoryConnectionManager<TState> implements ConnectionManager {
accept(connection: Connection, options: { tags: string[]; server: string }) {
connection.accept();

const tags = prepareTags(connection.id, options.tags);

this.#connections.set(connection.id, connection);
this.tags.set(connection, [
// make sure we have id tag
connection.id,
...options.tags.filter((t) => t !== connection.id)
]);
this.tags.set(connection, tags);

// Expose tags on the connection object itself
Object.defineProperty(connection, "tags", {
get: () => tags,
configurable: true
});

const removeConnection = () => {
this.#connections.delete(connection.id);
Expand Down Expand Up @@ -336,37 +386,14 @@ export class HibernatingConnectionManager<TState> implements ConnectionManager {
}

accept(connection: Connection, options: { tags: string[]; server: string }) {
// dedupe tags in case user already provided id tag
const tags = [
connection.id,
...options.tags.filter((t) => t !== connection.id)
];

// validate tags against documented restrictions
// shttps://developers.cloudflare.com/durable-objects/api/hibernatable-websockets-api/#state-methods-for-websockets
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");
}
}
const tags = prepareTags(connection.id, options.tags);

this.controller.acceptWebSocket(connection, tags);
connection.serializeAttachment({
__pk: {
id: connection.id,
server: options.server
server: options.server,
tags
},
__user: null
});
Expand Down
88 changes: 56 additions & 32 deletions packages/partyserver/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ const serverMapCache = new WeakMap<
Record<string, DurableObjectNamespace>
>();

// Maps kebab-case namespace -> original env binding name (e.g. "my-agent" -> "MyAgent")
const bindingNameCache = new WeakMap<object, Record<string, string>>();

/**
* For a given server namespace, create a server with a name.
*/
Expand Down Expand Up @@ -87,8 +90,20 @@ function camelCaseToKebabCase(str: string): string {
// Convert any remaining underscores to hyphens and remove trailing -'s
return kebabified.replace(/_/g, "-").replace(/-$/, "");
}
export interface Lobby<Env = Cloudflare.Env> {
/**
* The kebab-case namespace from the URL path (e.g. `"my-agent"`).
* @deprecated Use `className` instead, which returns the Durable Object class name.
* In the next major version, `party` will return the class name instead of the kebab-case namespace.
*/
party: string;
/** The Durable Object class name / env binding name (e.g. `"MyAgent"`). */
className: Extract<keyof Env, string>;
/** The room / instance name extracted from the URL. */
name: string;
}

export interface PartyServerOptions<
// biome-ignore lint/correctness/noUnusedVariables: it's ok, we'll remove this in the next major
Env = Cloudflare.Env,
Props = Record<string, unknown>
> {
Expand Down Expand Up @@ -122,17 +137,11 @@ export interface PartyServerOptions<
cors?: boolean | HeadersInit;
onBeforeConnect?: (
req: Request,
lobby: {
party: string;
name: string;
}
lobby: Lobby<Env>
) => Response | Request | void | Promise<Response | Request | void>;
onBeforeRequest?: (
req: Request,
lobby: {
party: string;
name: string;
}
lobby: Lobby<Env>
) =>
| Response
| Request
Expand Down Expand Up @@ -175,26 +184,28 @@ export async function routePartykitRequest<
options?: PartyServerOptions<Env, Props>
): Promise<Response | null> {
if (!serverMapCache.has(env)) {
serverMapCache.set(
env,
Object.entries(env).reduce((acc, [k, v]) => {
if (
v &&
typeof v === "object" &&
"idFromName" in v &&
typeof v.idFromName === "function"
) {
Object.assign(acc, { [camelCaseToKebabCase(k)]: v });
return acc;
}
return acc;
}, {})
);
const namespaceMap: Record<string, DurableObjectNamespace> = {};
const bindingNames: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) {
if (
v &&
typeof v === "object" &&
"idFromName" in v &&
typeof v.idFromName === "function"
) {
const kebab = camelCaseToKebabCase(k);
namespaceMap[kebab] = v as DurableObjectNamespace;
bindingNames[kebab] = k;
}
}
serverMapCache.set(env, namespaceMap);
bindingNameCache.set(env, bindingNames);
}
const map = serverMapCache.get(env) as unknown as Record<
string,
DurableObjectNamespace<T>
>;
const bindingNames = bindingNameCache.get(env) as Record<string, string>;

const prefix = options?.prefix || "parties";
const prefixParts = prefix.split("/");
Expand Down Expand Up @@ -271,12 +282,27 @@ Did you forget to add a durable object binding to the class ${namespace[0].toUpp
req.headers.set("x-partykit-props", JSON.stringify(options?.props));
}

const className = bindingNames[namespace] as Extract<keyof Env, string>;
let partyDeprecationWarned = false;
const lobby: Lobby<Env> = {
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, {
party: namespace,
name
});
const reqOrRes = await options.onBeforeConnect(req, lobby);
if (reqOrRes instanceof Request) {
req = reqOrRes;
} else if (reqOrRes instanceof Response) {
Expand All @@ -285,10 +311,7 @@ Did you forget to add a durable object binding to the class ${namespace[0].toUpp
}
} else {
if (options?.onBeforeRequest) {
const reqOrRes = await options.onBeforeRequest(req, {
party: namespace,
name
});
const reqOrRes = await options.onBeforeRequest(req, lobby);
if (reqOrRes instanceof Request) {
req = reqOrRes;
} else if (reqOrRes instanceof Response) {
Expand Down Expand Up @@ -408,6 +431,7 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam
let connection: Connection = Object.assign(serverWebSocket, {
id: connectionId,
server: this.name,
tags: [] as string[],
state: null as unknown as ConnectionState<unknown>,
setState<T = unknown>(setState: T | ConnectionSetStateFn<T>) {
let state: T;
Expand Down
Loading
Loading