diff --git a/__tests__/cancellation.test.ts b/__tests__/cancellation.test.ts index 789b08dd..856dc8bd 100644 --- a/__tests__/cancellation.test.ts +++ b/__tests__/cancellation.test.ts @@ -4,9 +4,9 @@ import { Err, Ok, Procedure, - ServiceSchema, ValidProcType, createClient, + createServiceSchema, createServer, } from '../router'; import { testMatrix } from '../testUtil/fixtures/matrix'; @@ -29,7 +29,8 @@ function makeMockHandler( ) { return vi.fn< Procedure< - Record, + object, + object, T, TObject, TObject | null, @@ -39,6 +40,8 @@ function makeMockHandler( >(impl); } +const ServiceSchema = createServiceSchema(); + describe.each(testMatrix())( 'clean handler cancellation ($transport.name transport, $codec.name codec)', diff --git a/__tests__/cleanup.test.ts b/__tests__/cleanup.test.ts index ef9b60bc..190244b2 100644 --- a/__tests__/cleanup.test.ts +++ b/__tests__/cleanup.test.ts @@ -15,8 +15,8 @@ import { Ok, Procedure, ProcedureHandlerContext, - ServiceSchema, createClient, + createServiceSchema, createServer, } from '../router'; import { @@ -503,13 +503,14 @@ describe('request finishing triggers signal onabort', async () => { ] as const)('handler aborts $procedureType', async ({ procedureType }) => { const clientTransport = getClientTransport('client'); const serverTransport = getServerTransport(); - const handler = vi.fn<(ctx: ProcedureHandlerContext) => void>(); + const handler = + vi.fn<(ctx: ProcedureHandlerContext) => void>(); const serverId = serverTransport.clientId; const serviceName = 'service'; const procedureName = procedureType; const services = { - [serviceName]: ServiceSchema.define({ + [serviceName]: createServiceSchema().define({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any [procedureType]: (Procedure[procedureType] as any)({ requestInit: Type.Object({}), @@ -519,7 +520,11 @@ describe('request finishing triggers signal onabort', async () => { } : {}), responseData: Type.Object({}), - async handler({ ctx }: { ctx: ProcedureHandlerContext }) { + async handler({ + ctx, + }: { + ctx: ProcedureHandlerContext; + }) { handler(ctx); return new Promise(() => { diff --git a/__tests__/context.test.ts b/__tests__/context.test.ts index c33e69ad..3d51dd6a 100644 --- a/__tests__/context.test.ts +++ b/__tests__/context.test.ts @@ -5,14 +5,9 @@ import { } from '../testUtil/fixtures/cleanup'; import { testMatrix } from '../testUtil/fixtures/matrix'; import { TestSetupHelpers } from '../testUtil/fixtures/transports'; -import { - Ok, - Procedure, - ServiceSchema, - createClient, - createServer, -} from '../router'; +import { Ok, Procedure, createClient, createServer } from '../router'; import { Type } from '@sinclair/typebox'; +import { createServiceSchema } from '../router/services'; describe('should handle incompatabilities', async () => { const { addPostTestCleanup, postTestCleanup } = createPostTestCleanups(); @@ -40,19 +35,25 @@ describe('should handle incompatabilities', async () => { // setup const clientTransport = getClientTransport('client'); const serverTransport = getServerTransport(); + + const extendedContext = { + testctx: Math.random().toString(), + }; + + const ServiceSchema = createServiceSchema(); + const services = { testservice: ServiceSchema.define({ testrpc: Procedure.rpc({ requestInit: Type.Object({}), responseData: Type.String(), handler: async ({ ctx }) => { - return Ok((ctx as unknown as typeof extendedContext).testctx); + return Ok(ctx.testctx); }, }), }), }; - const extendedContext = { testctx: Math.random().toString() }; createServer(serverTransport, services, { extendedContext, }); @@ -74,9 +75,13 @@ describe('should handle incompatabilities', async () => { const clientTransport = getClientTransport('client'); const serverTransport = getServerTransport(); + const extendedContext = { testctx: Math.random().toString() }; + + const ServiceSchema = createServiceSchema(); + const TestServiceScaffold = ServiceSchema.scaffold({ initializeState: (ctx) => ({ - fromctx: (ctx as unknown as typeof extendedContext).testctx, + fromctx: ctx.testctx, }), }); const services = { @@ -93,7 +98,43 @@ describe('should handle incompatabilities', async () => { }), }; + createServer(serverTransport, services, { + extendedContext, + }); + const client = createClient( + clientTransport, + serverTransport.clientId, + ); + addPostTestCleanup(async () => { + await cleanupTransports([clientTransport, serverTransport]); + }); + + const res = await client.testservice.testrpc.rpc({}); + + expect(res).toEqual({ ok: true, payload: extendedContext.testctx }); + }); + + test('should be able to access context in procedures', async () => { + // setup + const clientTransport = getClientTransport('client'); + const serverTransport = getServerTransport(); + const extendedContext = { testctx: Math.random().toString() }; + + const ServiceSchema = createServiceSchema(); + + const services = { + testservice: ServiceSchema.define({ + testrpc: Procedure.rpc({ + requestInit: Type.Object({}), + responseData: Type.String(), + handler: async ({ ctx }) => { + return Ok(ctx.testctx); + }, + }), + }), + }; + createServer(serverTransport, services, { extendedContext, }); diff --git a/__tests__/e2e.test.ts b/__tests__/e2e.test.ts index 33e875a6..bd4e60d7 100644 --- a/__tests__/e2e.test.ts +++ b/__tests__/e2e.test.ts @@ -31,7 +31,7 @@ import { testMatrix } from '../testUtil/fixtures/matrix'; import { Type } from '@sinclair/typebox'; import { Procedure, - ServiceSchema, + createServiceSchema, Ok, UNCAUGHT_ERROR_CODE, CANCEL_CODE, @@ -949,7 +949,7 @@ describe.each(testMatrix())( }); const services = { - test: ServiceSchema.define({ + test: createServiceSchema().define({ getData: Procedure.rpc({ requestInit: Type.Object({}), responseData: Type.Object({ diff --git a/__tests__/invalid-request.test.ts b/__tests__/invalid-request.test.ts index fd324e16..cc007759 100644 --- a/__tests__/invalid-request.test.ts +++ b/__tests__/invalid-request.test.ts @@ -5,8 +5,8 @@ import { Ok, OkResult, Procedure, - ServiceSchema, createClient, + createServiceSchema, createServer, } from '../router'; import { testMatrix } from '../testUtil/fixtures/matrix'; @@ -22,6 +22,8 @@ import { TestSetupHelpers } from '../testUtil/fixtures/transports'; import { nanoid } from 'nanoid'; import { getClientSendFn } from '../testUtil'; +const ServiceSchema = createServiceSchema(); + describe('cancels invalid request', () => { const { transport, codec } = testMatrix()[0]; const opts = { codec: codec.codec }; diff --git a/__tests__/middleware.test.ts b/__tests__/middleware.test.ts index e0ede7f5..37334dab 100644 --- a/__tests__/middleware.test.ts +++ b/__tests__/middleware.test.ts @@ -12,7 +12,7 @@ import { createServer, Ok, Procedure, - ServiceSchema, + createServiceSchema, Middleware, } from '../router'; import { createMockTransportNetwork } from '../testUtil/fixtures/mockTransport'; @@ -244,7 +244,7 @@ describe('middleware test', () => { readByMiddlewareSignal: boolean; }>(); - const AsyncStorageSchemas = ServiceSchema.define({ + const AsyncStorageSchemas = createServiceSchema().define({ gimmeStore: Procedure.rpc({ requestInit: Type.Object({}), responseData: Type.Object({}), diff --git a/__tests__/typescript-stress.test.ts b/__tests__/typescript-stress.test.ts index 204bcb21..9752d5aa 100644 --- a/__tests__/typescript-stress.test.ts +++ b/__tests__/typescript-stress.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, test } from 'vitest'; +import { assert, describe, expect, test } from 'vitest'; import { Procedure } from '../router/procedures'; -import { ServiceSchema } from '../router/services'; +import { createServiceSchema } from '../router/services'; import { Type } from '@sinclair/typebox'; import { createServer } from '../router/server'; import { createClient } from '../router/client'; @@ -12,7 +12,10 @@ import { ResultUnwrapOk, unwrapOrThrow, } from '../router/result'; -import { TestServiceSchema } from '../testUtil/fixtures/services'; +import { + testContext, + TestServiceWithContextSchema, +} from '../testUtil/fixtures/services'; import { readNextResult } from '../testUtil'; import { createClientHandshakeOptions, @@ -25,6 +28,7 @@ import { createMockTransportNetwork } from '../testUtil/fixtures/mockTransport'; const requestData = Type.Union([ Type.Object({ a: Type.Number() }), Type.Object({ c: Type.String() }), + Type.Object({ d: Type.Number() }), ]); const responseData = Type.Object({ b: Type.Union([Type.Number(), Type.String()]), @@ -41,7 +45,10 @@ const responseError = Type.Union([ ]); const fnBody = Procedure.rpc< - Record, + typeof testContext, + { + db: string; + }, typeof requestData, typeof responseData, typeof responseError @@ -49,9 +56,11 @@ const fnBody = Procedure.rpc< requestInit: requestData, responseData, responseError, - async handler({ reqInit }) { + async handler({ reqInit, ctx }) { if ('c' in reqInit) { return Ok({ b: reqInit.c }); + } else if ('d' in reqInit) { + return Ok({ b: ctx.add(reqInit.d, reqInit.d) }); } else { return Ok({ b: reqInit.a }); } @@ -61,7 +70,9 @@ const fnBody = Procedure.rpc< // typescript is limited to max 50 constraints // see: https://github.com/microsoft/TypeScript/issues/33541 // we should be able to support more than that due to how we make services -const StupidlyLargeServiceSchema = ServiceSchema.define({ +const StupidlyLargeServiceSchema = createServiceSchema< + typeof testContext +>().define({ f1: fnBody, f2: fnBody, f3: fnBody, @@ -182,13 +193,16 @@ describe("ensure typescript doesn't give up trying to infer the types for large x1: StupidlyLargeServiceSchema, y1: StupidlyLargeServiceSchema, z1: StupidlyLargeServiceSchema, - test: TestServiceSchema, + test: TestServiceWithContextSchema, }; const mockTransportNetwork = createMockTransportNetwork(); const server = createServer( mockTransportNetwork.getServerTransport(), services, + { + extendedContext: testContext, + }, ); const client = createClient( @@ -204,10 +218,42 @@ describe("ensure typescript doesn't give up trying to infer the types for large expect(server).toBeTruthy(); expect(client).toBeTruthy(); }); + + test('service with context should be able to access context in procedures', async () => { + const services = { + a: StupidlyLargeServiceSchema, + b: StupidlyLargeServiceSchema, + }; + const mockTransportNetwork = createMockTransportNetwork(); + const server = createServer( + mockTransportNetwork.getServerTransport(), + services, + { + extendedContext: testContext, + }, + ); + + const client = createClient( + mockTransportNetwork.getClientTransport('client'), + 'SERVER', + { eagerlyConnect: false }, + ); + + const res = await client.a.f2.rpc({ d: 1 }); + assert(res.ok); + expect(res.payload.b).toBe(2); + + const res2 = await client.b.f11.rpc({ d: 10 }); + assert(res2.ok); + expect(res2.payload.b).toBe(20); + + expect(server).toBeTruthy(); + expect(client).toBeTruthy(); + }); }); const services = { - test: ServiceSchema.define({ + test: createServiceSchema().define({ rpc: Procedure.rpc({ requestInit: Type.Object({ n: Type.Number() }), responseData: Type.Object({ n: Type.Number() }), diff --git a/router/client.ts b/router/client.ts index 6cded350..804518c8 100644 --- a/router/client.ts +++ b/router/client.ts @@ -140,9 +140,20 @@ type ServiceClient = { * @template Srv - The type of the server. */ export type Client< - Services extends AnyServiceSchemaMap, - IS extends - InstantiatedServiceSchemaMap = InstantiatedServiceSchemaMap, + // Context is a server-side implementation detail that doesn't affect the client interface + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Services extends AnyServiceSchemaMap, + IS extends InstantiatedServiceSchemaMap< + // Context is a server-side implementation detail that doesn't affect the client interface + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + Services + > = InstantiatedServiceSchemaMap< + // Context is a server-side implementation detail that doesn't affect the client interface + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + Services + >, > = { [SvcName in keyof IS]: ServiceClient; }; @@ -204,7 +215,10 @@ const defaultClientOptions: ClientOptions = { * @param {Partial} providedClientOptions - The options for the client. * @returns The client for the server. */ -export function createClient( +// We are using any here because the ServiceContext is a server-side implementation +// detail that doesn't affect the client interface +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function createClient>( transport: ClientTransport, serverId: TransportClientId, providedClientOptions: Partial< diff --git a/router/context.ts b/router/context.ts index b91552e0..e97324af 100644 --- a/router/context.ts +++ b/router/context.ts @@ -5,27 +5,6 @@ import { ErrResult } from './result'; import { CancelErrorSchema } from './errors'; import { Static } from '@sinclair/typebox'; -/** - * ServiceContext exist for the purpose of declaration merging - * to extend the context with additional properties. - * - * For example: - * - * ```ts - * declare module '@replit/river' { - * interface ServiceContext { - * db: Database; - * } - * } - * - * createServer(someTransport, myServices, { extendedContext: { db: myDb } }); - * ``` - * - * Once you do this, your {@link ProcedureHandlerContext} will have `db` property on it. - */ -/* eslint-disable-next-line @typescript-eslint/no-empty-interface */ -export interface ServiceContext {} - /** * The parsed metadata schema for a service. This is the * return value of the {@link ServerHandshakeOptions.validate} @@ -47,9 +26,9 @@ export interface ParsedMetadata extends Record {} /** * This is passed to every procedure handler and contains various context-level - * information and utilities. This may be extended, see {@link ServiceContext} + * information and utilities. */ -export type ProcedureHandlerContext = ServiceContext & { +export type ProcedureHandlerContext = Context & { /** * State for this service as defined by the service definition. */ diff --git a/router/index.ts b/router/index.ts index f7661bc3..ece437cb 100644 --- a/router/index.ts +++ b/router/index.ts @@ -9,7 +9,7 @@ export type { ProcType, } from './services'; export { - ServiceSchema, + createServiceSchema, serializeSchema, SerializedServerSchema, SerializedServiceSchema, @@ -49,11 +49,7 @@ export type { MiddlewareParam, MiddlewareContext, } from './server'; -export type { - ParsedMetadata, - ServiceContext, - ProcedureHandlerContext, -} from './context'; +export type { ParsedMetadata, ProcedureHandlerContext } from './context'; export { Ok, Err } from './result'; export type { Result, diff --git a/router/procedures.ts b/router/procedures.ts index b4656dd1..2cc43bdd 100644 --- a/router/procedures.ts +++ b/router/procedures.ts @@ -50,6 +50,7 @@ export type Cancellable = T | Static; * @template ResponseErr - The TypeBox schema of the error object. */ export interface RpcProcedure< + Context, State, RequestInit extends PayloadType, ResponseData extends PayloadType, @@ -61,7 +62,7 @@ export interface RpcProcedure< responseError: ResponseErr; description?: string; handler(param: { - ctx: ProcedureHandlerContext; + ctx: ProcedureHandlerContext; reqInit: Static; }): Promise, Cancellable>>>; } @@ -77,6 +78,7 @@ export interface RpcProcedure< * @template ResponseErr - The TypeBox schema of the error object. */ export interface UploadProcedure< + Context, State, RequestInit extends PayloadType, RequestData extends PayloadType, @@ -90,7 +92,7 @@ export interface UploadProcedure< responseError: ResponseErr; description?: string; handler(param: { - ctx: ProcedureHandlerContext; + ctx: ProcedureHandlerContext; reqInit: Static; reqReadable: Readable< Static, @@ -108,6 +110,7 @@ export interface UploadProcedure< * @template ResponseErr - The TypeBox schema of the error object. */ export interface SubscriptionProcedure< + Context, State, RequestInit extends PayloadType, ResponseData extends PayloadType, @@ -119,7 +122,7 @@ export interface SubscriptionProcedure< responseError: ResponseErr; description?: string; handler(param: { - ctx: ProcedureHandlerContext; + ctx: ProcedureHandlerContext; reqInit: Static; resWritable: Writable< Result, Cancellable>> @@ -138,6 +141,7 @@ export interface SubscriptionProcedure< * @template ResponseErr - The TypeBox schema of the error object. */ export interface StreamProcedure< + Context, State, RequestInit extends PayloadType, RequestData extends PayloadType, @@ -151,7 +155,7 @@ export interface StreamProcedure< responseError: ResponseErr; description?: string; handler(param: { - ctx: ProcedureHandlerContext; + ctx: ProcedureHandlerContext; reqInit: Static; reqReadable: Readable< Static, @@ -179,6 +183,7 @@ export interface StreamProcedure< * @template ResponseData - The TypeBox schema of the response object. */ export type Procedure< + Context, State, Ty extends ValidProcType, RequestInit extends PayloadType, @@ -188,6 +193,7 @@ export type Procedure< > = { type: Ty } & (RequestData extends PayloadType ? Ty extends 'upload' ? UploadProcedure< + Context, State, RequestInit, RequestData, @@ -196,6 +202,7 @@ export type Procedure< > : Ty extends 'stream' ? StreamProcedure< + Context, State, RequestInit, RequestData, @@ -204,9 +211,15 @@ export type Procedure< > : never : Ty extends 'rpc' - ? RpcProcedure + ? RpcProcedure : Ty extends 'subscription' - ? SubscriptionProcedure + ? SubscriptionProcedure< + Context, + State, + RequestInit, + ResponseData, + ResponseErr + > : never); /** @@ -215,7 +228,8 @@ export type Procedure< * @template State - The context state object. You can provide this to constrain * the type of procedures. */ -export type AnyProcedure = Procedure< +export type AnyProcedure = Procedure< + Context, State, ValidProcType, PayloadType, @@ -230,7 +244,10 @@ export type AnyProcedure = Procedure< * @template State - The context state object. You can provide this to constrain * the type of procedures. */ -export type ProcedureMap = Record>; +export type ProcedureMap = Record< + string, + AnyProcedure +>; // typescript is funky so with these upcoming procedure constructors, the overloads // which handle the `init` case _must_ come first, otherwise the `init` property @@ -241,6 +258,7 @@ export type ProcedureMap = Record>; */ // signature: default errors function rpc< + Context, State, RequestInit extends PayloadType, ResponseData extends PayloadType, @@ -249,11 +267,18 @@ function rpc< responseData: ResponseData; responseError?: never; description?: string; - handler: RpcProcedure['handler']; -}): Branded>; + handler: RpcProcedure< + Context, + State, + RequestInit, + ResponseData, + TNever + >['handler']; +}): Branded>; // signature: explicit errors function rpc< + Context, State, RequestInit extends PayloadType, ResponseData extends PayloadType, @@ -264,12 +289,15 @@ function rpc< responseError: ResponseErr; description?: string; handler: RpcProcedure< + Context, State, RequestInit, ResponseData, ResponseErr >['handler']; -}): Branded>; +}): Branded< + RpcProcedure +>; // implementation function rpc({ @@ -284,6 +312,7 @@ function rpc({ responseError?: ProcedureErrorSchemaType; description?: string; handler: RpcProcedure< + object, object, PayloadType, PayloadType, @@ -305,6 +334,7 @@ function rpc({ */ // signature: init with default errors function upload< + Context, State, RequestInit extends PayloadType, RequestData extends PayloadType, @@ -316,6 +346,7 @@ function upload< responseError?: never; description?: string; handler: UploadProcedure< + Context, State, RequestInit, RequestData, @@ -323,11 +354,19 @@ function upload< TNever >['handler']; }): Branded< - UploadProcedure + UploadProcedure< + Context, + State, + RequestInit, + RequestData, + ResponseData, + TNever + > >; // signature: init with explicit errors function upload< + Context, State, RequestInit extends PayloadType, RequestData extends PayloadType, @@ -340,6 +379,7 @@ function upload< responseError: ResponseErr; description?: string; handler: UploadProcedure< + Context, State, RequestInit, RequestData, @@ -347,7 +387,14 @@ function upload< ResponseErr >['handler']; }): Branded< - UploadProcedure + UploadProcedure< + Context, + State, + RequestInit, + RequestData, + ResponseData, + ResponseErr + > >; // implementation @@ -365,6 +412,7 @@ function upload({ responseError?: ProcedureErrorSchemaType; description?: string; handler: UploadProcedure< + object, object, PayloadType, PayloadType, @@ -388,6 +436,7 @@ function upload({ */ // signature: default errors function subscription< + Context, State, RequestInit extends PayloadType, ResponseData extends PayloadType, @@ -397,15 +446,19 @@ function subscription< responseError?: never; description?: string; handler: SubscriptionProcedure< + Context, State, RequestInit, ResponseData, TNever >['handler']; -}): Branded>; +}): Branded< + SubscriptionProcedure +>; // signature: explicit errors function subscription< + Context, State, RequestInit extends PayloadType, ResponseData extends PayloadType, @@ -416,13 +469,14 @@ function subscription< responseError: ResponseErr; description?: string; handler: SubscriptionProcedure< + Context, State, RequestInit, ResponseData, ResponseErr >['handler']; }): Branded< - SubscriptionProcedure + SubscriptionProcedure >; // implementation @@ -438,6 +492,7 @@ function subscription({ responseError?: ProcedureErrorSchemaType; description?: string; handler: SubscriptionProcedure< + object, object, PayloadType, PayloadType, @@ -459,6 +514,7 @@ function subscription({ */ // signature: with default errors function stream< + Context, State, RequestInit extends PayloadType, RequestData extends PayloadType, @@ -470,6 +526,7 @@ function stream< responseError?: never; description?: string; handler: StreamProcedure< + Context, State, RequestInit, RequestData, @@ -477,11 +534,19 @@ function stream< TNever >['handler']; }): Branded< - StreamProcedure + StreamProcedure< + Context, + State, + RequestInit, + RequestData, + ResponseData, + TNever + > >; // signature: explicit errors function stream< + Context, State, RequestInit extends PayloadType, RequestData extends PayloadType, @@ -494,6 +559,7 @@ function stream< responseError: ResponseErr; description?: string; handler: StreamProcedure< + Context, State, RequestInit, RequestData, @@ -501,7 +567,14 @@ function stream< ResponseErr >['handler']; }): Branded< - StreamProcedure + StreamProcedure< + Context, + State, + RequestInit, + RequestData, + ResponseData, + ResponseErr + > >; // implementation @@ -519,6 +592,7 @@ function stream({ responseError?: ProcedureErrorSchemaType; description?: string; handler: StreamProcedure< + object, object, PayloadType, PayloadType, diff --git a/router/server.ts b/router/server.ts index b579ed02..786b34fc 100644 --- a/router/server.ts +++ b/router/server.ts @@ -28,11 +28,7 @@ import { ProtocolVersion, TransportClientId, } from '../transport/message'; -import { - ServiceContext, - ProcedureHandlerContext, - ParsedMetadata, -} from './context'; +import { ProcedureHandlerContext, ParsedMetadata } from './context'; import { Logger } from '../logging/log'; import { Value } from '@sinclair/typebox/value'; import { Err, Result, Ok, ErrResult } from './result'; @@ -57,11 +53,14 @@ type StreamId = string; * Represents a server with a set of services. Use {@link createServer} to create it. * @template Services - The type of services provided by the server. */ -export interface Server { +export interface Server< + Context extends object, + Services extends AnyServiceSchemaMap, +> { /** * Services defined for this server. */ - services: InstantiatedServiceSchemaMap; + services: InstantiatedServiceSchemaMap; /** * A set of stream ids that are currently open. */ @@ -70,7 +69,7 @@ export interface Server { close: () => Promise; } -interface StreamInitProps { +interface StreamInitProps { // msg derived streamId: StreamId; procedureName: string; @@ -82,7 +81,7 @@ interface StreamInitProps { procClosesWithInit: boolean; // server level - serviceContext: ServiceContext & { state: object }; + serviceContext: Context & { state: object }; procedure: AnyProcedure; sessionMetadata: ParsedMetadata; @@ -104,11 +103,13 @@ interface ProcStream { handleSessionDisconnect: () => void; } -class RiverServer - implements Server +class RiverServer< + Context extends object, + Services extends AnyServiceSchemaMap, +> implements Server { private transport: ServerTransport; - private contextMap: Map; + private contextMap: Map; private log?: Logger; private middlewares: Array; @@ -123,7 +124,7 @@ class RiverServer private maxCancelledStreamTombstonesPerSession: number; public streams: Map; - public services: InstantiatedServiceSchemaMap; + public services: InstantiatedServiceSchemaMap; private unregisterTransportListeners: () => void; @@ -131,18 +132,23 @@ class RiverServer transport: ServerTransport, services: Services, handshakeOptions?: ServerHandshakeOptions, - extendedContext?: ServiceContext, + extendedContext?: Context, maxCancelledStreamTombstonesPerSession = 200, middlewares: Array = [], ) { const instances: Record = {}; this.middlewares = middlewares; - this.services = instances as InstantiatedServiceSchemaMap; + this.services = instances as InstantiatedServiceSchemaMap< + Context, + Services + >; this.contextMap = new Map(); + extendedContext = extendedContext ?? ({} as Context); + for (const [name, service] of Object.entries(services)) { - const instance = service.instantiate(extendedContext ?? {}); + const instance = service.instantiate(extendedContext); instances[name] = instance; this.contextMap.set(instance, { @@ -246,7 +252,7 @@ class RiverServer this.transport.addEventListener('transportStatus', handleTransportStatus); } - private createNewProcStream(span: Span, props: StreamInitProps) { + private createNewProcStream(span: Span, props: StreamInitProps) { const { streamId, initialSession, @@ -561,7 +567,7 @@ class RiverServer closeReadable(); } - const handlerContextWithSpan: ProcedureHandlerContext = { + const handlerContextWithSpan: ProcedureHandlerContext = { ...serviceContext, from: from, sessionId, @@ -700,7 +706,7 @@ class RiverServer private validateNewProcStream( initMessage: OpaqueTransportMessage, - ): StreamInitProps | null { + ): StreamInitProps | null { // lifetime safety: this is a sync function so this session cant transition // to another state before we finish const session = this.transport.sessions.get(initMessage.from); @@ -1012,7 +1018,7 @@ function getStreamCloseBackwardsCompat(protocolVersion: ProtocolVersion) { } export interface MiddlewareContext - extends Readonly, 'cancel'>> { + extends Readonly, 'cancel'>> { readonly streamId: StreamId; readonly procedureName: string; readonly serviceName: string; @@ -1039,12 +1045,15 @@ export type Middleware = (param: MiddlewareParam) => void; * @param extendedContext - An optional object containing additional context to be passed to all services. * @returns A promise that resolves to a server instance with the registered services. */ -export function createServer( +export function createServer< + Context extends object, + Services extends AnyServiceSchemaMap, +>( transport: ServerTransport, services: Services, providedServerOptions?: Partial<{ handshakeOptions?: ServerHandshakeOptions; - extendedContext?: ServiceContext; + extendedContext?: Context; /** * Maximum number of cancelled streams to keep track of to avoid * cascading stream errors. @@ -1055,7 +1064,7 @@ export function createServer( */ middlewares?: Array; }>, -): Server { +): Server { return new RiverServer( transport, services, diff --git a/router/services.ts b/router/services.ts index e575ef0b..6c898ca9 100644 --- a/router/services.ts +++ b/router/services.ts @@ -6,7 +6,6 @@ import { AnyProcedure, PayloadType, } from './procedures'; -import { ServiceContext } from './context'; import { flattenErrorType, ProcedureErrorSchemaType, @@ -19,8 +18,9 @@ import { * You shouldn't construct these directly, use {@link ServiceSchema} instead. */ export interface Service< + Context, State extends object, - Procs extends ProcedureMap, + Procs extends ProcedureMap, > { readonly state: State; readonly procedures: Procs; @@ -30,17 +30,22 @@ export interface Service< /** * Represents any {@link Service} object. */ -export type AnyService = Service; +export type AnyService = Service; /** * Represents any {@link ServiceSchema} object. */ -export type AnyServiceSchema = ServiceSchema; +export type AnyServiceSchema = InstanceType< + ReturnType> +>; /** * A dictionary of {@link ServiceSchema}s, where the key is the service name. */ -export type AnyServiceSchemaMap = Record; +export type AnyServiceSchemaMap = Record< + string, + AnyServiceSchema +>; // This has the secret sauce to keep go to definition working, the structure is // somewhat delicate, so be careful when modifying it. Would be nice to add a @@ -49,9 +54,23 @@ export type AnyServiceSchemaMap = Record; * Takes a {@link AnyServiceSchemaMap} and returns a dictionary of instantiated * services. */ -export type InstantiatedServiceSchemaMap = { - [K in keyof T]: T[K] extends ServiceSchema - ? Service +export type InstantiatedServiceSchemaMap< + Context extends object, + T extends AnyServiceSchemaMap, +> = { + [K in keyof T]: T[K] extends AnyServiceSchema + ? T[K] extends { + initializeState: (ctx: Context) => infer S; + procedures: infer P; + } + ? Service< + Context, + S extends object ? S : object, + P extends ProcedureMap + ? P + : ProcedureMap + > + : never : never; }; @@ -123,7 +142,10 @@ export type ProcType< * A list of procedures where every procedure is "branded", as-in the procedure * was created via the {@link Procedure} constructors. */ -type BrandedProcedureMap = Record>>; +type BrandedProcedureMap = Record< + string, + Branded> +>; type MaybeDisposable = State & { [Symbol.asyncDispose]?: () => Promise; @@ -133,11 +155,14 @@ type MaybeDisposable = State & { /** * The configuration for a service. */ -export interface ServiceConfiguration { +export interface ServiceConfiguration< + Context extends object, + State extends object, +> { /** * A factory function for creating a fresh state. */ - initializeState: (extendedContext: ServiceContext) => MaybeDisposable; + initializeState: (extendedContext: Context) => MaybeDisposable; } // TODO remove once clients migrate to v2 @@ -242,253 +267,298 @@ export function serializeSchema( } /** - * The schema for a {@link Service}. This is used to define a service, specifically - * its initial state and procedures. + * Creates a ServiceSchema class that can be used to define services with their initial state and procedures. + * This is a factory function that returns a ServiceSchema class constructor bound to the specified Context type. + * + * @template Context - The context type that will be available to all procedures in services created with this schema. + * @returns A ServiceSchema class constructor with static methods for defining services. + * + * @example + * ```ts + * // Create a ServiceSchema class for your context type + * const ServiceSchema = createServiceSchema<{ userId: string }>(); + * + * // Define a simple stateless service + * const mathService = ServiceSchema.define({ + * add: Procedure.rpc({ + * requestInit: Type.Object({ a: Type.Number(), b: Type.Number() }), + * responseData: Type.Object({ result: Type.Number() }), + * async handler(ctx, init) { + * return Ok({ result: init.a + init.b }); + * } + * }), + * getUserId: Procedure.rpc({ + * requestInit: Type.Object({}), + * responseData: Type.Object({ id: Type.String() }), + * async handler(ctx) { + * return Ok({ id: ctx.userId }); + * } + * }), + * }); + * ``` * - * There are two ways to define a service: - * 1. the {@link ServiceSchema.define} static method, which takes a configuration and - * a list of procedures directly. Use this to ergonomically define a service schema - * in one go. Good for smaller services, especially if they're stateless. - * 2. the {@link ServiceSchema.scaffold} static method, which creates a scaffold that - * can be used to define procedures separately from the configuration. Use this to - * better organize your service's definition, especially if it's a large service. - * You can also use it in a builder pattern to define the service in a more - * fluent way. + * There are two main ways to define services with the returned ServiceSchema class: * - * See the static methods for more information and examples. + * 1. **ServiceSchema.define()** - Takes a configuration and procedures directly. + * Use this for smaller services or when you want to define everything in one place. * - * When defining procedures, use the {@link Procedure} constructors to create them. + * 2. **ServiceSchema.scaffold()** - Creates a scaffold that can be used to define + * procedures separately from the configuration. Use this for larger services or + * when you want to organize procedures across multiple files. + * + * When defining procedures, always use the {@link Procedure} constructors to create them. */ -export class ServiceSchema< - State extends object, - Procedures extends ProcedureMap, -> { - /** - * Factory function for creating a fresh state. - */ - protected readonly initializeState: ( - extendedContext: ServiceContext, - ) => MaybeDisposable; - - /** - * The procedures for this service. - */ - readonly procedures: Procedures; - - /** - * @param config - The configuration for this service. - * @param procedures - The procedures for this service. - */ - protected constructor( - config: ServiceConfiguration, - procedures: Procedures, - ) { - this.initializeState = config.initializeState; - this.procedures = procedures; - } - - /** - * Creates a {@link ServiceScaffold}, which can be used to define procedures - * that can then be merged into a {@link ServiceSchema}, via the scaffold's - * `finalize` method. - * - * There are two patterns that work well with this method. The first is using - * it to separate the definition of procedures from the definition of the - * service's configuration: - * ```ts - * const MyServiceScaffold = ServiceSchema.scaffold({ - * initializeState: () => ({ count: 0 }), - * }); - * - * const incrementProcedures = MyServiceScaffold.procedures({ - * increment: Procedure.rpc({ - * requestInit: Type.Object({ amount: Type.Number() }), - * responseData: Type.Object({ current: Type.Number() }), - * async handler(ctx, init) { - * ctx.state.count += init.amount; - * return Ok({ current: ctx.state.count }); - * } - * }), - * }) - * - * const MyService = MyServiceScaffold.finalize({ - * ...incrementProcedures, - * // you can also directly define procedures here - * }); - * ``` - * This might be really handy if you have a very large service and you're - * wanting to split it over multiple files. You can define the scaffold - * in one file, and then import that scaffold in other files where you - * define procedures - and then finally import the scaffolds and your - * procedure objects in a final file where you finalize the scaffold into - * a service schema. - * - * The other way is to use it like in a builder pattern: - * ```ts - * const MyService = ServiceSchema - * .scaffold({ initializeState: () => ({ count: 0 }) }) - * .finalize({ - * increment: Procedure.rpc({ - * requestInit: Type.Object({ amount: Type.Number() }), - * responseData: Type.Object({ current: Type.Number() }), - * async handler(ctx, init) { - * ctx.state.count += init.amount; - * return Ok({ current: ctx.state.count }); - * } - * }), - * }) - * ``` - * Depending on your preferences, this may be a more appealing way to define - * a schema versus using the {@link ServiceSchema.define} method. - */ - static scaffold(config: ServiceConfiguration) { - return new ServiceScaffold(config); - } - - /** - * Creates a new {@link ServiceSchema} with the given configuration and procedures. - * - * All procedures must be created with the {@link Procedure} constructors. - * - * NOTE: There is an overload that lets you just provide the procedures alone if your - * service has no state. - * - * @param config - The configuration for this service. - * @param procedures - The procedures for this service. - * - * @example - * ``` - * const service = ServiceSchema.define( - * { initializeState: () => ({ count: 0 }) }, - * { - * increment: Procedure.rpc({ - * requestInit: Type.Object({ amount: Type.Number() }), - * responseData: Type.Object({ current: Type.Number() }), - * async handler(ctx, init) { - * ctx.state.count += init.amount; - * return Ok({ current: ctx.state.count }); - * } - * }), - * }, - * ); - * ``` - */ - static define< +export function createServiceSchema() { + return class ServiceSchema< State extends object, - Procedures extends BrandedProcedureMap, - >( - config: ServiceConfiguration, - procedures: Procedures, - ): ServiceSchema< - State, - { [K in keyof Procedures]: Unbranded } - >; - /** - * Creates a new {@link ServiceSchema} with the given procedures. - * - * All procedures must be created with the {@link Procedure} constructors. - * - * NOTE: There is an overload that lets you provide configuration as well, - * if your service has extra configuration like a state. - * - * @param procedures - The procedures for this service. - * - * @example - * ``` - * const service = ServiceSchema.define({ - * add: Procedure.rpc({ - * requestInit: Type.Object({ a: Type.Number(), b: Type.Number() }), - * responseData: Type.Object({ result: Type.Number() }), - * async handler(ctx, init) { - * return Ok({ result: init.a + init.b }); - * } - * }), - * }); - */ - static define>>( - procedures: Procedures, - ): ServiceSchema< - Record, - { [K in keyof Procedures]: Unbranded } - >; - // actual implementation - static define( - configOrProcedures: - | ServiceConfiguration - | BrandedProcedureMap, - maybeProcedures?: BrandedProcedureMap, - ): ServiceSchema { - let config: ServiceConfiguration; - let procedures: BrandedProcedureMap; - - if ( - 'initializeState' in configOrProcedures && - typeof configOrProcedures.initializeState === 'function' + Procedures extends ProcedureMap, + > { + /** + * Factory function for creating a fresh state. + */ + readonly initializeState: ( + extendedContext: Context, + ) => MaybeDisposable; + + /** + * The procedures for this service. + */ + readonly procedures: Procedures; + + /** + * @param config - The configuration for this service. + * @param procedures - The procedures for this service. + */ + constructor( + config: ServiceConfiguration, + procedures: Procedures, ) { - if (!maybeProcedures) { - throw new Error('Expected procedures to be defined'); - } + this.initializeState = config.initializeState; + this.procedures = procedures; + } - config = configOrProcedures as ServiceConfiguration; - procedures = maybeProcedures; - } else { - config = { initializeState: () => ({}) }; - procedures = configOrProcedures as BrandedProcedureMap; + /** + * Creates a {@link ServiceScaffold}, which can be used to define procedures + * that can then be merged into a {@link ServiceSchema}, via the scaffold's + * `finalize` method. + * + * There are two patterns that work well with this method. The first is using + * it to separate the definition of procedures from the definition of the + * service's configuration: + * ```ts + * const MyServiceScaffold = ServiceSchema.scaffold({ + * initializeState: () => ({ count: 0 }), + * }); + * + * const incrementProcedures = MyServiceScaffold.procedures({ + * increment: Procedure.rpc({ + * requestInit: Type.Object({ amount: Type.Number() }), + * responseData: Type.Object({ current: Type.Number() }), + * async handler(ctx, init) { + * ctx.state.count += init.amount; + * return Ok({ current: ctx.state.count }); + * } + * }), + * }) + * + * const MyService = MyServiceScaffold.finalize({ + * ...incrementProcedures, + * // you can also directly define procedures here + * }); + * ``` + * This might be really handy if you have a very large service and you're + * wanting to split it over multiple files. You can define the scaffold + * in one file, and then import that scaffold in other files where you + * define procedures - and then finally import the scaffolds and your + * procedure objects in a final file where you finalize the scaffold into + * a service schema. + * + * The other way is to use it like in a builder pattern: + * ```ts + * const MyService = ServiceSchema + * .scaffold({ initializeState: () => ({ count: 0 }) }) + * .finalize({ + * increment: Procedure.rpc({ + * requestInit: Type.Object({ amount: Type.Number() }), + * responseData: Type.Object({ current: Type.Number() }), + * async handler(ctx, init) { + * ctx.state.count += init.amount; + * return Ok({ current: ctx.state.count }); + * } + * }), + * }) + * ``` + * Depending on your preferences, this may be a more appealing way to define + * a schema versus using the {@link ServiceSchema.define} method. + */ + static scaffold( + config: ServiceConfiguration, + ) { + return new ServiceScaffold(config); } - return new ServiceSchema(config, procedures); - } + /** + * Creates a new {@link ServiceSchema} with the given configuration and procedures. + * + * All procedures must be created with the {@link Procedure} constructors. + * + * NOTE: There is an overload that lets you just provide the procedures alone if your + * service has no state. + * + * @param config - The configuration for this service. + * @param procedures - The procedures for this service. + * + * @example + * ``` + * const service = ServiceSchema.define( + * { initializeState: () => ({ count: 0 }) }, + * { + * increment: Procedure.rpc({ + * requestInit: Type.Object({ amount: Type.Number() }), + * responseData: Type.Object({ current: Type.Number() }), + * async handler(ctx, init) { + * ctx.state.count += init.amount; + * return Ok({ current: ctx.state.count }); + * } + * }), + * }, + * ); + * ``` + */ + static define< + State extends object, + Procedures extends BrandedProcedureMap, + >( + config: ServiceConfiguration, + procedures: Procedures, + ): ServiceSchema< + State, + { [K in keyof Procedures]: Unbranded } + >; + /** + * Creates a new {@link ServiceSchema} with the given procedures. + * + * All procedures must be created with the {@link Procedure} constructors. + * + * NOTE: There is an overload that lets you provide configuration as well, + * if your service has extra configuration like a state. + * + * @param procedures - The procedures for this service. + * + * @example + * ``` + * const service = ServiceSchema.define({ + * add: Procedure.rpc({ + * requestInit: Type.Object({ a: Type.Number(), b: Type.Number() }), + * responseData: Type.Object({ result: Type.Number() }), + * async handler(ctx, init) { + * return Ok({ result: init.a + init.b }); + * } + * }), + * }); + */ + + static define>( + procedures: Procedures, + ): ServiceSchema< + object, + { [K in keyof Procedures]: Unbranded } + >; + // actual implementation + static define( + configOrProcedures: + | ServiceConfiguration + | BrandedProcedureMap, + maybeProcedures?: BrandedProcedureMap, + ): ServiceSchema { + let config: ServiceConfiguration; + let procedures: BrandedProcedureMap; + + if ( + 'initializeState' in configOrProcedures && + typeof configOrProcedures.initializeState === 'function' + ) { + if (!maybeProcedures) { + throw new Error('Expected procedures to be defined'); + } + + config = configOrProcedures as ServiceConfiguration; + procedures = maybeProcedures; + } else { + config = { initializeState: () => ({}) }; + procedures = configOrProcedures as BrandedProcedureMap; + } - /** - * Serializes this schema's procedures into a plain object that is JSON compatible. - */ - serialize(): SerializedServiceSchema { - return { - procedures: Object.fromEntries( - Object.entries(this.procedures).map(([procName, procDef]) => [ - procName, - { - init: Strict(procDef.requestInit), - output: Strict(procDef.responseData), - errors: getSerializedProcErrors(procDef), - // Only add `description` field if the type declares it. - ...('description' in procDef - ? { description: procDef.description } - : {}), - type: procDef.type, - // Only add the `input` field if the type declares it. - ...('requestData' in procDef - ? { - input: Strict(procDef.requestData), - } - : {}), - }, - ]), - ), - }; - } + return new ServiceSchema(config, procedures); + } - // TODO remove once clients migrate to v2 - /** - * Same as {@link ServiceSchema.serialize}, but with a format that is compatible with - * protocol v1. This is useful to be able to continue to generate schemas for older - * clients as they are still supported. - */ - serializeV1Compat(): SerializedServiceSchemaProtocolv1 { - return { - procedures: Object.fromEntries( - Object.entries(this.procedures).map( - ([procName, procDef]): [ - string, - SerializedProcedureSchemaProtocolv1, - ] => { - if (procDef.type === 'rpc' || procDef.type === 'subscription') { + /** + * Serializes this schema's procedures into a plain object that is JSON compatible. + */ + serialize(): SerializedServiceSchema { + return { + procedures: Object.fromEntries( + Object.entries(this.procedures).map(([procName, procDef]) => [ + procName, + { + init: Strict(procDef.requestInit), + output: Strict(procDef.responseData), + errors: getSerializedProcErrors(procDef), + // Only add `description` field if the type declares it. + ...('description' in procDef + ? { description: procDef.description } + : {}), + type: procDef.type, + // Only add the `input` field if the type declares it. + ...('requestData' in procDef + ? { + input: Strict(procDef.requestData), + } + : {}), + }, + ]), + ), + }; + } + + // TODO remove once clients migrate to v2 + /** + * Same as {@link ServiceSchema.serialize}, but with a format that is compatible with + * protocol v1. This is useful to be able to continue to generate schemas for older + * clients as they are still supported. + */ + serializeV1Compat(): SerializedServiceSchemaProtocolv1 { + return { + procedures: Object.fromEntries( + Object.entries(this.procedures).map( + ([procName, procDef]): [ + string, + SerializedProcedureSchemaProtocolv1, + ] => { + if (procDef.type === 'rpc' || procDef.type === 'subscription') { + return [ + procName, + { + // BACKWARDS COMPAT: map init to input for protocolv1 + // this is the only change needed to make it compatible. + input: Strict(procDef.requestInit), + output: Strict(procDef.responseData), + errors: getSerializedProcErrors(procDef), + // Only add `description` field if the type declares it. + ...('description' in procDef + ? { description: procDef.description } + : {}), + type: procDef.type, + }, + ]; + } + + // No backwards compatibility needed for upload and stream types, as having an `init` + // all the time is compatible with protocol v1. return [ procName, { - // BACKWARDS COMPAT: map init to input for protocolv1 - // this is the only change needed to make it compatible. - input: Strict(procDef.requestInit), + init: Strict(procDef.requestInit), output: Strict(procDef.responseData), errors: getSerializedProcErrors(procDef), // Only add `description` field if the type declares it. @@ -496,54 +566,38 @@ export class ServiceSchema< ? { description: procDef.description } : {}), type: procDef.type, + input: Strict(procDef.requestData), }, ]; - } - - // No backwards compatibility needed for upload and stream types, as having an `init` - // all the time is compatible with protocol v1. - return [ - procName, - { - init: Strict(procDef.requestInit), - output: Strict(procDef.responseData), - errors: getSerializedProcErrors(procDef), - // Only add `description` field if the type declares it. - ...('description' in procDef - ? { description: procDef.description } - : {}), - type: procDef.type, - input: Strict(procDef.requestData), - }, - ]; - }, + }, + ), ), - ), - }; - } + }; + } - /** - * Instantiates this schema into a {@link Service} object. - * - * You probably don't need this, usually the River server will handle this - * for you. - */ - instantiate(extendedContext: ServiceContext): Service { - const state = this.initializeState(extendedContext); - const dispose = async () => { - await state[Symbol.asyncDispose]?.(); - state[Symbol.dispose]?.(); - }; - - return Object.freeze({ - state, - procedures: this.procedures, - [Symbol.asyncDispose]: dispose, - }); - } + /** + * Instantiates this schema into a {@link Service} object. + * + * You probably don't need this, usually the River server will handle this + * for you. + */ + instantiate(extendedContext: Context): Service { + const state = this.initializeState(extendedContext); + const dispose = async () => { + await state[Symbol.asyncDispose]?.(); + state[Symbol.dispose]?.(); + }; + + return Object.freeze({ + state, + procedures: this.procedures, + [Symbol.asyncDispose]: dispose, + }); + } + }; } -function getSerializedProcErrors( +export function getSerializedProcErrors( procDef: AnyProcedure, ): ProcedureErrorSchemaType { if ( @@ -566,16 +620,16 @@ function getSerializedProcErrors( * @see {@link ServiceSchema.scaffold} */ // note that this isn't exported -class ServiceScaffold { +class ServiceScaffold { /** * The configuration for this service. */ - protected readonly config: ServiceConfiguration; + protected readonly config: ServiceConfiguration; /** * @param config - The configuration for this service. */ - constructor(config: ServiceConfiguration) { + constructor(config: ServiceConfiguration) { this.config = config; } @@ -599,7 +653,7 @@ class ServiceScaffold { * * @param procedures - The procedures for this service. */ - procedures>(procedures: T): T { + procedures>(procedures: T): T { return procedures; } @@ -621,9 +675,7 @@ class ServiceScaffold { * }); * ``` */ - finalize>( - procedures: T, - ): ServiceSchema }> { - return ServiceSchema.define(this.config, procedures); + finalize>(procedures: T) { + return createServiceSchema().define(this.config, procedures); } } diff --git a/testUtil/fixtures/cleanup.ts b/testUtil/fixtures/cleanup.ts index 68a7a03a..af08e79a 100644 --- a/testUtil/fixtures/cleanup.ts +++ b/testUtil/fixtures/cleanup.ts @@ -84,7 +84,9 @@ export async function ensureTransportBuffersAreEventuallyEmpty( ); } -export async function ensureServerIsClean(s: Server) { +export async function ensureServerIsClean( + s: Server, +) { return waitFor(() => expect( s.streams, @@ -111,7 +113,7 @@ export async function testFinishesCleanly({ }: Partial<{ clientTransports: Array>; serverTransport: ServerTransport; - server: Server; + server: Server; }>) { // pre-close invariants // invariant check servers first as heartbeats are authoritative on their side diff --git a/testUtil/fixtures/services.ts b/testUtil/fixtures/services.ts index 971ac964..09a788b4 100644 --- a/testUtil/fixtures/services.ts +++ b/testUtil/fixtures/services.ts @@ -1,9 +1,11 @@ import { Type } from '@sinclair/typebox'; -import { ServiceSchema } from '../../router/services'; +import { createServiceSchema } from '../../router/services'; import { Err, Ok, unwrapOrThrow } from '../../router/result'; import { Observable } from '../observable/observable'; import { Procedure } from '../../router'; +const ServiceSchema = createServiceSchema(); + export const EchoRequest = Type.Object({ msg: Type.String(), ignore: Type.Boolean(), @@ -127,8 +129,43 @@ export const TestServiceSchema = TestServiceScaffold.finalize({ ...testServiceProcedures, }); +export const testContext = { + logger: { + info: (message: string) => { + console.log(message); + }, + }, + add: (a: number, b: number) => a + b, +}; + +const TestServiceWithContextScaffold = createServiceSchema< + typeof testContext +>().scaffold({ + initializeState: () => ({ count: 0 }), +}); + +const testServiceWithContextProcedures = + TestServiceWithContextScaffold.procedures({ + add: Procedure.rpc({ + requestInit: Type.Object({ n: Type.Number() }), + responseData: Type.Object({ result: Type.Number() }), + async handler({ ctx, reqInit: { n } }) { + ctx.state.count += n; + + return Ok({ result: ctx.state.count }); + }, + }), + }); + +export const TestServiceWithContextSchema = + TestServiceWithContextScaffold.finalize({ + ...testServiceWithContextProcedures, + }); + export const OrderingServiceSchema = ServiceSchema.define( - { initializeState: () => ({ msgs: [] as Array }) }, + { + initializeState: () => ({ msgs: [] as Array }), + }, { add: Procedure.rpc({ requestInit: Type.Object({ n: Type.Number() }), @@ -139,7 +176,6 @@ export const OrderingServiceSchema = ServiceSchema.define( return Ok({ n }); }, }), - getAll: Procedure.rpc({ requestInit: Type.Object({}), responseData: Type.Object({ msgs: Type.Array(Type.Number()) }),