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/configurable-connection-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"partyserver": patch
---

Add `configurable: true` to the `state`, `setState`, `serializeAttachment`, and `deserializeAttachment` property descriptors on connection objects. This allows downstream consumers (like the Cloudflare Agents SDK) to redefine these properties with `Object.defineProperty` for namespacing or wrapping internal state storage. Default behavior is unchanged.
5 changes: 5 additions & 0 deletions .changeset/validate-room-or-basepath.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"partysocket": patch
---

Throw a clear error when constructing a `PartySocket` without `room` or `basePath` (and without `startClosed: true`), instead of silently connecting to a malformed URL containing `"undefined"` as the room name.
4 changes: 4 additions & 0 deletions packages/partyserver/src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,13 @@ export const createLazyConnection = (
}
},
state: {
configurable: true,
get() {
return ws.deserializeAttachment() as ConnectionState<unknown>;
}
},
setState: {
configurable: true,
value: function setState<T>(setState: T | ConnectionSetStateFn<T>) {
let state: T;
if (setState instanceof Function) {
Expand All @@ -163,13 +165,15 @@ export const createLazyConnection = (
},

deserializeAttachment: {
configurable: true,
value: function deserializeAttachment<T = unknown>() {
const attachment = attachments.get(ws);
return (attachment.__user ?? null) as T;
}
},

serializeAttachment: {
configurable: true,
value: function serializeAttachment<T = unknown>(attachment: T) {
const setting = {
...attachments.get(ws),
Expand Down
104 changes: 104 additions & 0 deletions packages/partyserver/src/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,110 @@ describe("Server", () => {
return promise;
});

it("allows state and setState to be redefined on a connection", async () => {
const ctx = createExecutionContext();
const request = new Request(
"http://example.com/parties/configurable-state/room1",
{
headers: {
Upgrade: "websocket"
}
}
);
const response = await worker.fetch(request, env, ctx);
const ws = response.webSocket!;

const { promise, resolve, reject } = Promise.withResolvers<void>();
ws.accept();
ws.addEventListener("message", (message) => {
try {
// The server redefines state/setState and uses them to send back
// { answer: 42 }, proving that Object.defineProperty worked.
expect(JSON.parse(message.data as string)).toEqual({ answer: 42 });
resolve();
} catch (e) {
reject(e);
} finally {
ws.close();
}
});

return promise;
});

it("allows state and setState to be redefined on a non-hibernating connection", async () => {
const ctx = createExecutionContext();
const request = new Request(
"http://example.com/parties/configurable-state-in-memory/room1",
{
headers: {
Upgrade: "websocket"
}
}
);
const response = await worker.fetch(request, env, ctx);
const ws = response.webSocket!;

const { promise, resolve, reject } = Promise.withResolvers<void>();
ws.accept();
ws.addEventListener("message", (message) => {
try {
// The non-hibernating server redefines state/setState and sends back
// { answer: 99 }, proving Object.defineProperty works on this path too.
expect(JSON.parse(message.data as string)).toEqual({ answer: 99 });
resolve();
} catch (e) {
reject(e);
} finally {
ws.close();
}
});

return promise;
});

it("persists state through setState and reads it back via state getter", async () => {
const ctx = createExecutionContext();
const request = new Request(
"http://example.com/parties/state-round-trip/room1",
{
headers: { Upgrade: "websocket" }
}
);
const response = await worker.fetch(request, env, ctx);
const ws = response.webSocket!;
ws.accept();

// Collect all messages to verify the full round-trip
const messages: unknown[] = [];
const { promise, resolve, reject } = Promise.withResolvers<void>();

ws.addEventListener("message", (event) => {
try {
messages.push(JSON.parse(event.data as string));

if (messages.length === 1) {
// First response: "get" should return the initial state set in onConnect
expect(messages[0]).toEqual({ count: 1 });
// Now ask the server to increment using the updater function form
ws.send("increment");
} else if (messages.length === 2) {
// Second response: state should reflect the increment
expect(messages[1]).toEqual({ count: 2 });
resolve();
}
} catch (e) {
reject(e);
}
});

// Ask the server to read back the state that was set in onConnect
ws.send("get");

await promise;
ws.close();
});

// it("can be connected with a query parameter");
// it("can be connected with a header");

Expand Down
96 changes: 96 additions & 0 deletions packages/partyserver/src/tests/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export type Env = {
Stateful: DurableObjectNamespace<Stateful>;
OnStartServer: DurableObjectNamespace<OnStartServer>;
Mixed: DurableObjectNamespace<Mixed>;
ConfigurableState: DurableObjectNamespace<ConfigurableState>;
ConfigurableStateInMemory: DurableObjectNamespace<ConfigurableStateInMemory>;
StateRoundTrip: DurableObjectNamespace<StateRoundTrip>;
};

export class Stateful extends Server {
Expand Down Expand Up @@ -93,6 +96,99 @@ export class Mixed extends Server {
}
}

/**
* Tests that state and setState on a connection can be redefined via
* Object.defineProperty (configurable: true). This simulates what the
* Cloudflare Agents SDK does to namespace internal state keys.
*/
export class ConfigurableState extends Server {
static options = {
hibernate: true
};

onConnect(connection: Connection): void {
// Redefine state and setState with a custom namespace,
// similar to what the Agents SDK does.
let _customState: unknown = { custom: true };

Object.defineProperty(connection, "state", {
configurable: true,
get() {
return _customState;
}
});

Object.defineProperty(connection, "setState", {
configurable: true,
value(newState: unknown) {
_customState = newState;
return _customState;
}
});

// Use the redefined setState / state to verify they work
connection.setState({ answer: 42 });
connection.send(JSON.stringify(connection.state));
}
}

/**
* Tests that setState persists state and the state getter reads it back
* correctly through the serialization layer (hibernating path).
*/
export class StateRoundTrip extends Server {
static options = {
hibernate: true
};

onConnect(connection: Connection): void {
connection.setState({ count: 1 });
}

onMessage(connection: Connection, message: string | ArrayBuffer): void {
if (message === "get") {
connection.send(JSON.stringify(connection.state));
} else if (message === "increment") {
connection.setState((prev: { count: number } | null) => ({
count: (prev?.count ?? 0) + 1
}));
connection.send(JSON.stringify(connection.state));
}
}
}

/**
* Same as ConfigurableState but without hibernation (non-hibernating path).
* Verifies that the Object.assign path also allows redefinition.
*/
export class ConfigurableStateInMemory extends Server {
// no hibernate — uses the in-memory Object.assign path
onConnect(connection: Connection): void {
let _customState: unknown = { custom: true };

Object.defineProperty(connection, "state", {
configurable: true,
get() {
return _customState;
},
set(v: unknown) {
_customState = v;
}
});

Object.defineProperty(connection, "setState", {
configurable: true,
value(newState: unknown) {
_customState = newState;
return _customState;
}
});

connection.setState({ answer: 99 });
connection.send(JSON.stringify(connection.state));
}
}

export default {
async fetch(request: Request, env: Env, _ctx: ExecutionContext) {
return (
Expand Down
20 changes: 20 additions & 0 deletions packages/partyserver/src/tests/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,33 @@
{
"name": "Mixed",
"class_name": "Mixed"
},
{
"name": "ConfigurableState",
"class_name": "ConfigurableState"
},
{
"name": "ConfigurableStateInMemory",
"class_name": "ConfigurableStateInMemory"
},
{
"name": "StateRoundTrip",
"class_name": "StateRoundTrip"
}
]
},
"migrations": [
{
"tag": "v1", // Should be unique for each entry
"new_classes": ["Stateful", "OnStartServer", "Mixed"]
},
{
"tag": "v2",
"new_classes": [
"ConfigurableState",
"ConfigurableStateInMemory",
"StateRoundTrip"
]
}
]
}
34 changes: 31 additions & 3 deletions packages/partyserver/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,46 @@ export type Connection<TState = unknown> = WebSocket & {

/**
* Arbitrary state associated with this connection.
* Read-only, use Connection.setState to update the state.
* Read-only — use {@link Connection.setState} to update.
*
* This property is configurable, meaning it can be redefined via
* `Object.defineProperty` by downstream consumers (e.g. the Cloudflare
* Agents SDK) to namespace or wrap internal state storage.
*/
state: ConnectionState<TState>;

/**
* Update the state associated with this connection.
*
* Accepts either a new state value or an updater function that receives
* the previous state and returns the next state.
*
* This property is configurable, meaning it can be redefined via
* `Object.defineProperty` by downstream consumers. If you redefine
* `state` and `setState`, you are responsible for calling
* `serializeAttachment` / `deserializeAttachment` yourself if you need
* the state to survive hibernation.
*/
setState(
state: TState | ConnectionSetStateFn<TState> | null
): ConnectionState<TState>;

/** @deprecated use Connection.setState instead */
/**
* @deprecated use {@link Connection.setState} instead.
*
* Low-level method to persist data in the connection's attachment storage.
* This property is configurable and can be redefined by downstream
* consumers that need to wrap or namespace the underlying storage.
*/
serializeAttachment<T = unknown>(attachment: T): void;

/** @deprecated use Connection.state instead */
/**
* @deprecated use {@link Connection.state} instead.
*
* Low-level method to read data from the connection's attachment storage.
* This property is configurable and can be redefined by downstream
* consumers that need to wrap or namespace the underlying storage.
*/
deserializeAttachment<T = unknown>(): T | null;

/**
Expand Down
7 changes: 7 additions & 0 deletions packages/partysocket/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ export default class PartySocket extends ReconnectingWebSocket {

this.setWSProperties(wsOptions);

if (!partySocketOptions.startClosed && !this.room && !this.basePath) {
this.close();
throw new Error(
"Either room or basePath must be provided to connect. Use startClosed: true to create a socket and set them via updateProperties before calling reconnect()."
);
}

if (!partySocketOptions.disableNameValidation) {
if (partySocketOptions.party?.includes("/")) {
console.warn(
Expand Down
Loading
Loading