From de5bc27670172094c1f4a0843d59a5d5cdc8fcdb Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 9 Jul 2026 20:00:05 +0100 Subject: [PATCH] fix: allow user-defined interfaces as Server Props Interfaces do not get implicit index signatures in TypeScript, so the Record bound on Props rejected them with 'Index signature for type string is missing' (surfaced downstream as cloudflare/agents#1886). Bound Props by 'object' on Server, getServerByName, and routePartykitRequest. The T constraints on the latter two become Server: the #_props private field carries Props covariantly, so a subclass declaring interface Props failed the old Server constraint, and the DO stub's setName param resolved to the constraint's Record type. Defaults stay Record so untyped usage still reads props values as unknown. --- .changeset/olive-frogs-invent.md | 7 +++ packages/partyserver/src/index.ts | 16 ++++-- .../partyserver/src/tests/props.test-d.ts | 53 +++++++++++++++++++ 3 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 .changeset/olive-frogs-invent.md create mode 100644 packages/partyserver/src/tests/props.test-d.ts diff --git a/.changeset/olive-frogs-invent.md b/.changeset/olive-frogs-invent.md new file mode 100644 index 00000000..7504b375 --- /dev/null +++ b/.changeset/olive-frogs-invent.md @@ -0,0 +1,7 @@ +--- +"partyserver": patch +--- + +Allow user-defined interfaces as `Props`. + +Interfaces do not get implicit index signatures in TypeScript, so the previous `Record` bound rejected them with "Index signature for type 'string' is missing". `Props` is now bounded by `object` on `Server`, `getServerByName`, and `routePartykitRequest`, and the `T` constraints on the latter two are `Server` so subclasses declaring interface `Props` satisfy them too. Generic defaults stay `Record`, so untyped usage still reads props values as `unknown`. diff --git a/packages/partyserver/src/index.ts b/packages/partyserver/src/index.ts index bec3dd0c..4c2b60e9 100644 --- a/packages/partyserver/src/index.ts +++ b/packages/partyserver/src/index.ts @@ -291,8 +291,8 @@ function decodeProps(header: string): unknown { */ export async function getServerByName< Env extends Cloudflare.Env = Cloudflare.Env, - T extends Server = Server, - Props extends Record = Record + T extends Server = Server, + Props extends object = Record >( serverNamespace: DurableObjectNamespace, name: string, @@ -428,8 +428,8 @@ function resolveCorsHeaders( export async function routePartykitRequest< Env extends Cloudflare.Env = Cloudflare.Env, - T extends Server = Server, - Props extends Record = Record + T extends Server = Server, + Props extends object = Record >( req: Request, env: Env = defaultEnv as Env, @@ -586,9 +586,15 @@ Did you forget to add a durable object binding to the class ${namespace[0].toUpp } } +/** + * @template Env Environment type containing bindings + * @template Props Initial-props type delivered to `onStart()`. Bounded by + * `object` rather than `Record` so user-defined interfaces + * qualify — interfaces have no implicit index signature. + */ export class Server< Env extends Cloudflare.Env = Cloudflare.Env, - Props extends Record = Record + Props extends object = Record > extends DurableObject { static options: { hibernate?: boolean } = { hibernate: false diff --git a/packages/partyserver/src/tests/props.test-d.ts b/packages/partyserver/src/tests/props.test-d.ts new file mode 100644 index 00000000..b1d9223a --- /dev/null +++ b/packages/partyserver/src/tests/props.test-d.ts @@ -0,0 +1,53 @@ +/** + * Type tests for Server props typing. Compile-time only — this file is + * typechecked but never executed (vitest only picks up `*.test.ts`). + * + * Interfaces do not get implicit index signatures in TypeScript, so a + * `Record` bound rejects user-defined interfaces with + * "Index signature for type 'string' is missing". Props bounds must accept + * plain interfaces while still rejecting non-object props. + */ +import { Server, getServerByName, routePartykitRequest } from "../index"; + +// A well-defined interface with NO index signature. +interface AuthProps { + userId: string; + permissions: string[]; +} + +declare const authProps: AuthProps; +declare const env: Cloudflare.Env; +declare const request: Request; + +// ============================================ +// POSITIVE TESTS - interface props must be accepted +// ============================================ + +// Server must be instantiable with interface Props. +declare class AuthServer extends Server {} +declare const serverNamespace: DurableObjectNamespace; + +// getServerByName must accept interface-typed props. +getServerByName(serverNamespace, "instance", { props: authProps }); + +// onStart receives the interface type. +declare const authServer: AuthServer; +authServer.onStart(authProps); + +// routePartykitRequest must accept interface-typed props. +routePartykitRequest(request, env, { props: authProps }); + +// ============================================ +// NEGATIVE TESTS - non-object props stay rejected +// ============================================ + +// @ts-expect-error — a primitive is not a props bag +declare class BadServer extends Server {} + +getServerByName(serverNamespace, "instance", { + // @ts-expect-error — a primitive is not a props bag + props: "not-an-object" +}); + +// Silence unused-declaration noise; this file only exists to typecheck. +export type {};