diff --git a/framework/framework/api.ts b/framework/framework/api.ts index 6d0f89d1d..049adf0c5 100644 --- a/framework/framework/api.ts +++ b/framework/framework/api.ts @@ -3,27 +3,32 @@ import Schema from 'schemastery'; import { param } from './decorators'; import { BadRequestError, MethodNotAllowedError, NotFoundError } from './error'; import { } from './interface'; -import { Handler } from './server'; +import { ConnectionHandler, Handler } from './server'; import { Types } from './validator'; const BINARY = Symbol.for('hydro.api.response.binary'); const REDIRECT = Symbol.for('hydro.api.response.redirect'); -export type ApiCall = { - readonly type: 'Query' | 'Mutation'; +type MaybePromise = T | Promise; +export type ApiType = 'Query' | 'Mutation' | 'Subscription'; +export type ApiCall = { + readonly type: Type; readonly input: Schema; - readonly func: (context: any, args: Arg) => Promise | Res; - readonly hooks: ApiCall[]; + readonly func: (Type extends 'Subscription' + ? (context: any, args: Arg, emit: (payload: Res) => void) => (() => MaybePromise) + : (context: any, args: Arg) => MaybePromise>); + readonly hooks: ApiCall<'Query', Arg, void>[]; }; -export const _get = (type: 'Query' | 'Mutation') => ( +export const _get = (type: Type) => ( schema: Schema, - func: (context: Handler, args: Arg) => Promise | Res, - hooks: ApiCall[] = [], -): ApiCall => ({ input: schema, func, hooks, type } as const); // eslint-disable-line + func: ApiCall['func'], + hooks: ApiCall<'Query', Arg, void, never>[] = [], +): ApiCall => ({ input: schema, func, hooks, type } as const); // eslint-disable-line export const Query = _get('Query'); export const Mutation = _get('Mutation'); +export const Subscription = _get('Subscription'); export class BinaryResponse { [BINARY]: true; @@ -50,9 +55,10 @@ export const APIS = { } as const; export interface Apis { builtin: { - 'query.batch': ApiCall<{ op: string, args: any }[], { [key: string]: any }>; - 'mutation.batch': ApiCall<{ op: string, args: any }[], { [key: string]: any }>; + 'query.batch': ApiCall<'Query', { op: string, args: any }[], { [key: string]: any }>; + 'mutation.batch': ApiCall<'Mutation', { op: string, args: any }[], { [key: string]: any }>; } + test: typeof TestApis; } export type FlattenedApis = Apis[keyof Apis]; @@ -90,6 +96,21 @@ export const projection = >(input: T, schema: S export interface ApiExecutionContext { } +function handleArguments(args: any) { + try { + if (typeof args.args === 'string') { + args.args = JSON.parse(args.args); + } + if (typeof args.projection === 'string') { + args.projection = '{['.includes(args.projection[0]) + ? JSON.parse(args.projection) + : args.projection.split(',').map((i) => i.trim()).filter((i) => i); + } + } catch (e) { + throw new BadRequestError('Invalid arguments'); + } +} + export class ApiService extends Service { constructor(ctx: Context) { super(ctx, 'api'); @@ -108,7 +129,10 @@ export class ApiService extends Service { }); } - async execute(context: ApiExecutionContext, callOrName: ApiCall | string, rawArgs: any, emitHook?: any, project?: any) { + async execute( + context: ApiExecutionContext, callOrName: ApiCall | string, + rawArgs: any, emitHook?: any, project?: any, sendPayload?: (payload: any) => void, + ) { const call = typeof callOrName === 'string' ? APIS[callOrName] : callOrName; if (!call) throw new NotFoundError(callOrName); const { input, func, hooks } = call; @@ -125,7 +149,19 @@ export class ApiService extends Service { await emitHook?.('api/before', args); await emitHook?.(`api/before/${callOrName}`, args); } - const result = await func(context, args as any); + let result = await func(context, args as any, sendPayload); + if (result && 'next' in result) { + const it = result as AsyncGenerator; + while (true) { + const value = await it.next(); // eslint-disable-line no-await-in-loop + if (value.done) { + result = value; + break; + } else { + sendPayload?.(value.value); + } + } + } return (project && typeof result === 'object' && result !== null) ? projection(result, project) : result; } } @@ -143,25 +179,17 @@ export class ApiHandler extends Handler { throw new MethodNotAllowedError(this.request.method); } if (!APIS[op]) throw new BadRequestError('Invalid operation'); + if (APIS[op].type === 'Subscription') { + throw new BadRequestError('Subscription operation cannot be called in HTTP handler'); + } if (APIS[op].type === 'Mutation' && this.request.method.toLowerCase() === 'get') { throw new BadRequestError('Mutation operation cannot be called with GET method'); } + handleArguments(this.args); // @ts-ignore await this.ctx.parallel('handler/api/before', this); // @ts-ignore await this.ctx.parallel(`handler/api/before/${op}`, this); - try { - if (typeof this.args.args === 'string') { - this.args.args = JSON.parse(this.args.args); - } - if (typeof this.args.projection === 'string') { - this.args.projection = '{['.includes(this.args.projection[0]) - ? JSON.parse(this.args.projection) - : this.args.projection.split(',').map((i) => i.trim()).filter((i) => i); - } - } catch (e) { - throw new BadRequestError('Invalid arguments'); - } const result = await this.ctx.api.execute( this, op, { domainId: this.args.domainId, ...this.args, ...(this.args.args || {}) }, (m, args) => (this.ctx.parallel as any)(m, args), this.args.projection, @@ -176,9 +204,104 @@ export class ApiHandler extends Handler { } } +export class ApiConnectionHandler extends ConnectionHandler { + dispose: () => Promise | void; + isRpc: boolean; + + @param('op', Types.String) + async prepare({ }, op: string) { + if (op === 'rpc') { + this.isRpc = true; + return; + } + if (!APIS[op]) throw new BadRequestError('Invalid operation'); + if (APIS[op].type !== 'Subscription') { + throw new BadRequestError('Only subscription operations are supported'); + } + handleArguments(this.args); + // @ts-ignore + await this.ctx.parallel('handler/api/before', this); + // @ts-ignore + await this.ctx.parallel(`handler/api/before/${op}`, this); + this.dispose = await this.ctx.api.execute( + this, op, { domainId: this.args.domainId, ...this.args, ...(this.args.args || {}) }, + (m, args) => (this.ctx.parallel as any)(m, args), this.args.projection, (p) => this.send(p), + ); + } + + async message(message) { + if (!this.isRpc) throw new BadRequestError('Only RPC operations are supported'); + if (typeof message === 'string') { + try { + message = JSON.parse(message); + } catch (e) { + throw new BadRequestError('Invalid message'); + } + } + if (!APIS[message.op]) throw new BadRequestError('Invalid operation'); + if (APIS[message.op].type !== 'Subscription') { + throw new BadRequestError('Only subscription operations are supported'); + } + handleArguments(message); + const result = await this.ctx.api.execute( + this, message.op, message.args, (m, args) => (this.ctx.parallel as any)(m, args), message.projection, + ); + this.send(result); + } + + async cleanup() { + await this.dispose?.(); + } +} + export function applyApiHandler(ctx: Context, name: string, path: string) { ctx.plugin(ApiService); - ctx.inject(['server', 'api'], ({ Route }) => { + ctx.inject(['server', 'api'], ({ Route, Connection }) => { Route(name, path, ApiHandler); + Connection(`${name}_conn`, `${path}/conn`, ApiConnectionHandler); + }); +} + +const TestApis = { + 'test.query': Query(Schema.object({ + name: Schema.string(), + }), (c, { name }) => ({ + ok: true, + name, + })), + 'test.mutation': Mutation(Schema.object({ + name: Schema.string().required(), + }), (c, { name }) => ({ + ok: true, + name, + })), + 'test.mutation_progress': Mutation(Schema.object({ + count: Schema.number().step(1).min(1).default(10), + }), async function* (c, { count }) { + for (let i = 1; i <= count; i++) { + yield { progress: i }; + } + return { + ok: true, + count, + }; + }), + 'test.subscription': Subscription(Schema.object({ + initial: Schema.number().step(1).min(0).default(0), + }), (c, { initial }, send) => { + let count = initial; + const interval = setInterval(() => { + count++; + send({ count }); + }, 1000); + return () => { + clearInterval(interval); + }; + }), +} as const; + +export function applyTestApis(ctx: Context) { + ctx.inject(['api'], ({ api }) => { + api.provide(TestApis); }); } diff --git a/framework/framework/router.ts b/framework/framework/router.ts index 55dc0a38e..0b60ce6bb 100644 --- a/framework/framework/router.ts +++ b/framework/framework/router.ts @@ -1,6 +1,6 @@ import { IncomingMessage } from 'http'; import KoaRouter from 'koa-router'; -import { pathToRegexp } from 'path-to-regexp'; +import { Keys, pathToRegexp } from 'path-to-regexp'; import type WebSocket from 'ws'; import type { KoaContext } from './server'; @@ -13,13 +13,22 @@ function remove(list: T[], item: T) { export class WebSocketLayer { clients = new Set(); regexp: RegExp; + keys: Keys; constructor(path: Parameters[0], public callback?: WebSocketCallback) { - this.regexp = pathToRegexp(path).regexp; + const r = pathToRegexp(path); + this.regexp = r.regexp; + this.keys = r.keys; } accept(socket: WebSocket, request: IncomingMessage, ctx: KoaContext) { - if (!this.regexp.test(new URL(request.url, `http://${request.headers.host}`).pathname)) return false; + const match = this.regexp.exec(new URL(request.url, `http://${request.headers.host}`).pathname); + if (!match) return false; + ctx.params ||= {}; + for (let i = 0; i < this.keys.length; i++) { + ctx.params[this.keys[i].name] = match[i + 1]; + ctx.HydroContext.args[this.keys[i].name] = match[i + 1]; + } this.clients.add(socket); socket.on('close', () => { this.clients.delete(socket);