Skip to content
Closed
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
63 changes: 63 additions & 0 deletions __tests__/fixtures/public-declaration-consumer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { type Static, type TNever, Type } from 'typebox';
import {
createServiceSchema,
type InstantiatedServiceSchemaMap,
Procedure,
type ProcedureDefinition,
type ProcedureDefinitionMap,
type RPCProcedure,
} from '../../router';

const RequestSchema = Type.Object({ value: Type.String() });
const ResponseSchema = Type.Object({ value: Type.String() });

type Equal<Left, Right> = (<T>() => T extends Left ? 1 : 2) extends <
T,
>() => T extends Right ? 1 : 2
? true
: false;

type Assert<T extends true> = T;

type EchoProcedure = RPCProcedure<
object,
object,
object,
typeof RequestSchema,
typeof ResponseSchema,
TNever
>;

export const echo = Procedure.rpc({
requestInit: RequestSchema,
responseData: ResponseSchema,
async handler({ reqInit }) {
return { ok: true, payload: reqInit };
},
});

const definition: ProcedureDefinition<EchoProcedure> = echo;
const constructorResult: typeof echo = definition;
void constructorResult;

export const procedures: ProcedureDefinitionMap<{
echo: EchoProcedure;
}> = { echo };

type EchoRequest = Static<(typeof procedures)['echo']['requestInit']>;
export type ProcedureNamesAreExact = Assert<
Equal<keyof typeof procedures, 'echo'>
>;
export type RequestPayloadIsPreserved = Assert<
Equal<EchoRequest, { value: string }>
>;

const ServiceSchema = createServiceSchema();
export const EchoServiceSchema = ServiceSchema.define(procedures);
const serviceSchemas = { echo: EchoServiceSchema };

export type InstantiatedServices = InstantiatedServiceSchemaMap<
Record<string, unknown>,
object,
typeof serviceSchemas
>;
58 changes: 58 additions & 0 deletions __tests__/public-declaration-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import { expect, test } from 'vitest';

test('public procedure and service-map types support declaration emit', () => {
const rootDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const config = ts.readConfigFile(
path.join(rootDir, 'tsconfig.json'),
(fileName) => ts.sys.readFile(fileName),
);
expect(config.error).toBeUndefined();

const parsed = ts.parseJsonConfigFileContent(
config.config,
ts.sys,
rootDir,
{
declaration: true,
declarationMap: false,
emitDeclarationOnly: true,
noEmit: false,
},
path.join(rootDir, 'tsconfig.json'),
);
const fixture = path.join(
rootDir,
'__tests__/fixtures/public-declaration-consumer.ts',
);
const program = ts.createProgram([fixture], parsed.options);
const declarations = new Map<string, string>();
const emit = program.emit(undefined, (fileName, contents) => {
if (fileName.endsWith('.d.ts')) {
declarations.set(path.basename(fileName), contents);
}
});
const diagnostics = [
...ts.getPreEmitDiagnostics(program),
...emit.diagnostics,
];

expect(
diagnostics.map((diagnostic) =>
ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
),
).toEqual([]);

const consumerDeclaration = declarations.get(
'public-declaration-consumer.d.ts',
);
expect(consumerDeclaration).toContain('ProcedureDefinition');
expect(consumerDeclaration).toContain('ProcedureDefinitionMap');
expect(consumerDeclaration).toContain('InstantiatedServiceSchemaMap');
expect(consumerDeclaration).not.toContain('__BRAND_DO_NOT_USE');
}, 10_000);
6 changes: 6 additions & 0 deletions router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export type {
ProcResponse,
ProcErrors,
ProcType,
AnyServiceSchema,
AnyServiceSchemaMap,
InstantiatedServiceSchemaMap,
} from './services';
export {
createServiceSchema,
Expand All @@ -24,6 +27,9 @@ export type {
ValidProcType,
PayloadType,
ProcedureMap,
ProcedureDefinition,
ProcedureDefinitionMap,
UnwrapProcedureDefinition,
RpcProcedure as RPCProcedure,
UploadProcedure,
SubscriptionProcedure,
Expand Down
50 changes: 33 additions & 17 deletions router/procedures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,7 @@ import {
ReaderErrorSchema,
} from './errors';

/**
* Brands a type to prevent it from being directly constructed.
*/
export type Branded<T> = T & { readonly __BRAND_DO_NOT_USE: unique symbol };

/**
* Unbrands a {@link Branded} type.
*/
export type Unbranded<T> = T extends Branded<infer U> ? U : never;
type Branded<T> = T & { readonly __BRAND_DO_NOT_USE: unique symbol };

/**
* The valid {@link Procedure} types. The `stream` and `upload` types can optionally have a
Expand Down Expand Up @@ -258,6 +250,20 @@ export type AnyProcedure<
ProcedureErrorSchemaType
>;

/**
* A procedure created by one of the {@link Procedure} constructors.
*/
export type ProcedureDefinition<T = AnyProcedure> = Branded<T>;

/**
* Extracts the procedure represented by a {@link ProcedureDefinition}.
*/
export type UnwrapProcedureDefinition<T> = T extends ProcedureDefinition<
infer Procedure
>
? Procedure
: never;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyRpcProcedure = RpcProcedure<any, any, any, any, any, any>;

Expand All @@ -276,6 +282,16 @@ export type ProcedureMap<
ParsedMetadata = object,
> = Record<string, AnyProcedure<Context, State, ParsedMetadata>>;

/**
* Preserves the exact keys and procedure types of a map while requiring every
* value to come from a {@link Procedure} constructor.
*/
export type ProcedureDefinitionMap<
Procedures extends Record<string, unknown> = ProcedureMap,
> = {
[K in keyof Procedures]: ProcedureDefinition<Procedures[K]>;
};

// typescript is funky so with these upcoming procedure constructors, the overloads
// which handle the `init` case _must_ come first, otherwise the `init` property
// is not recognized as optional, for some reason
Expand Down Expand Up @@ -303,7 +319,7 @@ function rpc<
ResponseData,
TNever
>['handler'];
}): Branded<
}): ProcedureDefinition<
RpcProcedure<
Context,
State,
Expand Down Expand Up @@ -335,7 +351,7 @@ function rpc<
ResponseData,
ResponseErr
>['handler'];
}): Branded<
}): ProcedureDefinition<
RpcProcedure<
Context,
State,
Expand Down Expand Up @@ -396,7 +412,7 @@ function upload<
ResponseData,
TNever
>['handler'];
}): Branded<
}): ProcedureDefinition<
UploadProcedure<
Context,
State,
Expand Down Expand Up @@ -432,7 +448,7 @@ function upload<
ResponseData,
ResponseErr
>['handler'];
}): Branded<
}): ProcedureDefinition<
UploadProcedure<
Context,
State,
Expand Down Expand Up @@ -494,7 +510,7 @@ function subscription<
ResponseData,
TNever
>['handler'];
}): Branded<
}): ProcedureDefinition<
SubscriptionProcedure<
Context,
State,
Expand Down Expand Up @@ -526,7 +542,7 @@ function subscription<
ResponseData,
ResponseErr
>['handler'];
}): Branded<
}): ProcedureDefinition<
SubscriptionProcedure<
Context,
State,
Expand Down Expand Up @@ -594,7 +610,7 @@ function stream<
ResponseData,
TNever
>['handler'];
}): Branded<
}): ProcedureDefinition<
StreamProcedure<
Context,
State,
Expand Down Expand Up @@ -630,7 +646,7 @@ function stream<
ResponseData,
ResponseErr
>['handler'];
}): Branded<
}): ProcedureDefinition<
StreamProcedure<
Context,
State,
Expand Down
54 changes: 34 additions & 20 deletions router/services.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Type, type TSchema, type Static } from 'typebox';
import {
Branded,
ProcedureDefinitionMap,
ProcedureMap,
Unbranded,
UnwrapProcedureDefinition,
AnyProcedure,
PayloadType,
} from './procedures';
Expand Down Expand Up @@ -152,10 +152,8 @@ 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<Context, State, ParsedMetadata> = Record<
string,
Branded<AnyProcedure<Context, State, ParsedMetadata>>
>;
type CompatibleProcedureDefinitionMap<Context, State, ParsedMetadata> =
ProcedureDefinitionMap<ProcedureMap<Context, State, ParsedMetadata>>;

export type MaybeDisposable<T extends object = Record<string, unknown>> = T & {
[Symbol.asyncDispose]?: () => PromiseLike<void>;
Expand Down Expand Up @@ -440,13 +438,17 @@ export function createServiceSchema<
*/
static define<
State extends object,
Procedures extends BrandedProcedureMap<Context, State, ParsedMetadata>,
Procedures extends CompatibleProcedureDefinitionMap<
Context,
State,
ParsedMetadata
>,
>(
config: ServiceConfiguration<Context, State>,
procedures: Procedures,
): ServiceSchema<
State,
{ [K in keyof Procedures]: Unbranded<Procedures[K]> }
{ [K in keyof Procedures]: UnwrapProcedureDefinition<Procedures[K]> }
>;
/**
* Creates a new {@link ServiceSchema} with the given procedures.
Expand All @@ -472,22 +474,34 @@ export function createServiceSchema<
*/

static define<
Procedures extends BrandedProcedureMap<Context, object, ParsedMetadata>,
Procedures extends CompatibleProcedureDefinitionMap<
Context,
object,
ParsedMetadata
>,
>(
procedures: Procedures,
): ServiceSchema<
object,
{ [K in keyof Procedures]: Unbranded<Procedures[K]> }
{ [K in keyof Procedures]: UnwrapProcedureDefinition<Procedures[K]> }
>;
// actual implementation
static define(
configOrProcedures:
| ServiceConfiguration<Context, object>
| BrandedProcedureMap<Context, object, ParsedMetadata>,
maybeProcedures?: BrandedProcedureMap<Context, object, ParsedMetadata>,
| CompatibleProcedureDefinitionMap<Context, object, ParsedMetadata>,
maybeProcedures?: CompatibleProcedureDefinitionMap<
Context,
object,
ParsedMetadata
>,
): ServiceSchema<object, ProcedureMap> {
let config: ServiceConfiguration<Context, object>;
let procedures: BrandedProcedureMap<Context, object, ParsedMetadata>;
let procedures: CompatibleProcedureDefinitionMap<
Context,
object,
ParsedMetadata
>;

if (
'initializeState' in configOrProcedures &&
Expand All @@ -501,7 +515,7 @@ export function createServiceSchema<
procedures = maybeProcedures;
} else {
config = { initializeState: () => ({}) };
procedures = configOrProcedures as BrandedProcedureMap<
procedures = configOrProcedures as CompatibleProcedureDefinitionMap<
Context,
object,
ParsedMetadata
Expand Down Expand Up @@ -675,9 +689,9 @@ class ServiceScaffold<
*
* @param procedures - The procedures for this service.
*/
procedures<T extends BrandedProcedureMap<Context, State, ParsedMetadata>>(
procedures: T,
): T {
procedures<
T extends CompatibleProcedureDefinitionMap<Context, State, ParsedMetadata>,
>(procedures: T): T {
return procedures;
}

Expand All @@ -699,9 +713,9 @@ class ServiceScaffold<
* });
* ```
*/
finalize<T extends BrandedProcedureMap<Context, State, ParsedMetadata>>(
procedures: T,
) {
finalize<
T extends CompatibleProcedureDefinitionMap<Context, State, ParsedMetadata>,
>(procedures: T) {
return createServiceSchema<Context, ParsedMetadata>().define(
this.config,
procedures,
Expand Down
Loading