@travetto/web-rpc 6.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ <!-- This file was generated by @travetto/doc and should not be modified directly -->
2
+ <!-- Please modify https://github.com/travetto/travetto/tree/main/module/web-rpc/DOC.tsx and execute "npx trv doc" to rebuild -->
3
+ # Web RPC Support
4
+
5
+ ## RPC support for a Web Application
6
+
7
+ **Install: @travetto/web-rpc**
8
+ ```bash
9
+ npm install @travetto/web-rpc
10
+
11
+ # or
12
+
13
+ yarn add @travetto/web-rpc
14
+ ```
15
+
16
+ This module allows for a highly focused scenario, of supporting RPC operations within a [Web API](https://github.com/travetto/travetto/tree/main/module/web#readme "Declarative api for Web Applications with support for the dependency injection.") application. The module takes care of producing the appropriate handler for the RPC commands along with the ability to generate the appropriate client to be used to interact with the RPC functionality. The generated client uses Proxy-based objects, along with [Typescript](https://typescriptlang.org) magic to create a dynamic client that does not rely on generating a lot of code.
17
+
18
+ ## CLI - web:rpc-client
19
+ The library will create the RPC client in one of three flavors: fetch, fetch + node, angular.
20
+
21
+ **Terminal: Command Service**
22
+ ```bash
23
+ $ trv web:rpc-client --help
24
+
25
+ Usage: web:rpc-client [options] <type:config|node|web> [output:string]
26
+
27
+ Options:
28
+ -e, --env <string> Application environment
29
+ -m, --module <module> Module to run for
30
+ -h, --help display help for command
31
+ ```
package/__index__.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './src/config.ts';
2
+ export * from './src/service.ts';
3
+ export * from './src/controller.ts';
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@travetto/web-rpc",
3
+ "version": "6.0.0-rc.2",
4
+ "description": "RPC support for a Web Application",
5
+ "keywords": [
6
+ "web",
7
+ "rpc",
8
+ "client",
9
+ "travetto",
10
+ "typescript"
11
+ ],
12
+ "homepage": "https://travetto.io",
13
+ "license": "MIT",
14
+ "author": {
15
+ "email": "travetto.framework@gmail.com",
16
+ "name": "Travetto Framework"
17
+ },
18
+ "files": [
19
+ "__index__.ts",
20
+ "src",
21
+ "support"
22
+ ],
23
+ "main": "__index__.ts",
24
+ "repository": {
25
+ "url": "git+https://github.com/travetto/travetto.git",
26
+ "directory": "module/web-rpc"
27
+ },
28
+ "dependencies": {
29
+ "@travetto/config": "^6.0.0-rc.2",
30
+ "@travetto/schema": "^6.0.0-rc.2",
31
+ "@travetto/web": "^6.0.0-rc.2"
32
+ },
33
+ "travetto": {
34
+ "displayName": "Web RPC Support"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }
package/src/config.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { Config } from '@travetto/config';
2
+
3
+ export type WebRpcClient = {
4
+ type: 'node' | 'web';
5
+ output: string;
6
+ };
7
+
8
+ /**
9
+ * Web body parse configuration
10
+ */
11
+ @Config('web.rpc')
12
+ export class WebRpcConfig {
13
+ clients: WebRpcClient[] = [];
14
+ }
@@ -0,0 +1,54 @@
1
+ import { Inject } from '@travetto/di';
2
+ import { Any, AppError, Util } from '@travetto/runtime';
3
+ import {
4
+ HeaderParam, Controller, Undocumented, ExcludeInterceptors, ControllerRegistry,
5
+ WebAsyncContext, Body, EndpointUtil, BodyParseInterceptor, Post, WebCommonUtil,
6
+ RespondInterceptor
7
+ } from '@travetto/web';
8
+
9
+ @Controller('/rpc')
10
+ @ExcludeInterceptors(val => !(val instanceof BodyParseInterceptor || val instanceof RespondInterceptor || val.category === 'global'))
11
+ @Undocumented()
12
+ export class WebRpController {
13
+
14
+ @Inject()
15
+ ctx: WebAsyncContext;
16
+
17
+ /**
18
+ * RPC main entrypoint
19
+ */
20
+ @Post('/:target')
21
+ async onRequest(target: string, @HeaderParam('X-TRV-RPC-INPUTS') paramInput?: string, @Body() body?: Any): Promise<unknown> {
22
+ const endpoint = ControllerRegistry.getEndpointById(target);
23
+
24
+ if (!endpoint) {
25
+ throw new AppError('Unknown endpoint', { category: 'notfound' });
26
+ }
27
+
28
+ const bodyParamIdx = endpoint.params.findIndex((x) => x.location === 'body');
29
+
30
+ const { request } = this.ctx;
31
+
32
+ let params: unknown[];
33
+
34
+ // Allow request to read inputs from header
35
+ if (paramInput) {
36
+ params = Util.decodeSafeJSON(paramInput)!;
37
+ } else if (Array.isArray(body)) { // Params passed via body
38
+ params = body;
39
+ if (bodyParamIdx >= 0) { // Re-assign body
40
+ request.body = params[bodyParamIdx];
41
+ }
42
+ } else if (body) {
43
+ throw new AppError('Invalid parameters, must be an array', { category: 'data' });
44
+ } else {
45
+ params = [];
46
+ }
47
+
48
+ const final = endpoint.params.map((x, i) => (x.location === 'body' && paramInput) ? EndpointUtil.MissingParamSymbol : params[i]);
49
+ WebCommonUtil.setRequestParams(request, final);
50
+
51
+ // Dispatch
52
+ return await endpoint.filter!({ request: this.ctx.request });
53
+ }
54
+ }
package/src/service.ts ADDED
@@ -0,0 +1,81 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+
4
+ import { Inject, Injectable } from '@travetto/di';
5
+ import { ControllerRegistry } from '@travetto/web';
6
+ import { Runtime, RuntimeIndex } from '@travetto/runtime';
7
+ import { ManifestModuleUtil } from '@travetto/manifest';
8
+
9
+ import { clientFactory } from '../support/client/rpc.ts';
10
+ import { WebRpcClient, WebRpcConfig } from './config.ts';
11
+
12
+ @Injectable({ autoCreate: !Runtime.production })
13
+ export class WebRpcClientGeneratorService {
14
+
15
+ @Inject()
16
+ config: WebRpcConfig;
17
+
18
+ async postConstruct(): Promise<void> {
19
+ this.render();
20
+
21
+ if (!this.config.clients.length || !Runtime.dynamic) {
22
+ return;
23
+ }
24
+ ControllerRegistry.on(() => this.render());
25
+ }
26
+
27
+ async #getClasses(relativeTo: string): Promise<{ name: string, import: string }[]> {
28
+ return ControllerRegistry.getClasses()
29
+ .filter(x => {
30
+ const entry = RuntimeIndex.getEntry(Runtime.getSourceFile(x));
31
+ return entry && entry.role === 'std';
32
+ })
33
+ .map(x => {
34
+ const imp = ManifestModuleUtil.withOutputExtension(Runtime.getImport(x));
35
+ const base = Runtime.workspaceRelative(RuntimeIndex.manifest.build.typesFolder);
36
+ return {
37
+ name: x.name,
38
+ import: path.relative(relativeTo, `${base}/node_modules/${imp}`)
39
+ };
40
+ });
41
+ }
42
+
43
+ async renderProvider(config: WebRpcClient): Promise<void> {
44
+ await fs.mkdir(config.output, { recursive: true });
45
+
46
+ const classes = await this.#getClasses(config.output);
47
+
48
+ const clientSourceFile = RuntimeIndex.getFromImport('@travetto/web-rpc/support/client/rpc.ts')!.sourceFile;
49
+ const clientOutputFile = path.resolve(config.output, path.basename(clientSourceFile));
50
+ const clientSourceContents = await fs.readFile(clientSourceFile, 'utf8');
51
+
52
+ const flavorSourceFile = RuntimeIndex.getFromImport(`@travetto/web-rpc/support/client/rpc-${config.type}.ts`)!.sourceFile;
53
+ const flavorOutputFile = path.resolve(config.output, path.basename(flavorSourceFile));
54
+ const flavorSourceContents = (await fs.readFile(flavorSourceFile, 'utf8').catch(() => ''))
55
+ .replaceAll(/^\s*\/\/\s*@ts-ignore[^\n]*\n/gsm, '')
56
+ .replaceAll(/^\/\/\s*#UNCOMMENT (.*)/gm, (_, v) => v);
57
+
58
+ const factoryOutputFile = path.resolve(config.output, 'factory.ts');
59
+ const factorySourceContents = [
60
+ `import { ${clientFactory.name} } from './rpc.ts';`,
61
+ ...classes.map((n) => `import type { ${n.name} } from '${n.import}';`),
62
+ '',
63
+ `export const factory = ${clientFactory.name}<{`,
64
+ ...classes.map(x => ` ${x.name}: ${x.name};`),
65
+ '}>();',
66
+ ].join('\n');
67
+
68
+ await fs.writeFile(clientOutputFile, clientSourceContents, 'utf8');
69
+ await fs.writeFile(factoryOutputFile, factorySourceContents, 'utf8');
70
+
71
+ if (flavorSourceContents) {
72
+ await fs.writeFile(flavorOutputFile, flavorSourceContents, 'utf8');
73
+ }
74
+ }
75
+
76
+ async render(): Promise<void> {
77
+ for (const config of this.config.clients) {
78
+ this.renderProvider(config);
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,45 @@
1
+ import path from 'node:path';
2
+
3
+ import { Env } from '@travetto/runtime';
4
+ import { CliCommand, CliCommandShape, CliValidationResultError } from '@travetto/cli';
5
+ import { DependencyRegistry } from '@travetto/di';
6
+ import { RootRegistry } from '@travetto/registry';
7
+ import { Ignore } from '@travetto/schema';
8
+
9
+ import type { WebRpcClient } from '../src/config.ts';
10
+ import { WebRpcClientGeneratorService } from '../src/service.ts';
11
+
12
+ /**
13
+ * Generate the web-rpc client
14
+ */
15
+ @CliCommand({ with: { env: true, module: true } })
16
+ export class CliWebRpcCommand implements CliCommandShape {
17
+
18
+ @Ignore()
19
+ module: string;
20
+
21
+ preMain(): void {
22
+ Env.DEBUG.set(false);
23
+ Env.TRV_DYNAMIC.set(false);
24
+ }
25
+
26
+ get #service(): Promise<WebRpcClientGeneratorService> {
27
+ return RootRegistry.init().then(() => DependencyRegistry.getInstance(WebRpcClientGeneratorService));
28
+ }
29
+
30
+ async main(type: WebRpcClient['type'] | 'config', output?: string): Promise<void> {
31
+ if (type === 'config') {
32
+ const svc = await this.#service;
33
+ await svc.render();
34
+ } else {
35
+ if (!output) {
36
+ throw new CliValidationResultError(this, [
37
+ { message: 'output is required when type is not `config`', source: 'arg' }
38
+ ]);
39
+ }
40
+ const svc = await this.#service;
41
+ output = path.resolve(output);
42
+ return svc.renderProvider({ type, output });
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,13 @@
1
+ import { consumeError } from './rpc.ts';
2
+
3
+ export async function toNodeError(payload: unknown): Promise<Error> {
4
+ try {
5
+ let res = undefined;
6
+ const { AppError } = await import('@travetto/runtime');
7
+ res = AppError.fromJSON(payload);
8
+ if (res) {
9
+ return res;
10
+ }
11
+ } catch { }
12
+ return consumeError(payload);
13
+ }
@@ -0,0 +1,272 @@
1
+ type MethodKeys<C extends {}> = {
2
+ [METHOD in keyof C]: C[METHOD] extends Function ? METHOD : never
3
+ }[keyof C];
4
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
+ type PromiseFn = (...args: any) => Promise<unknown>;
6
+ type PromiseRes<V extends PromiseFn> = Awaited<ReturnType<V>>;
7
+
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ const isBlobMap = (x: any): x is Record<string, Blob> => x && typeof x === 'object' && x[Object.keys(x)[0]] instanceof Blob;
10
+
11
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ const isBlobLike = (x: any): x is Record<string, Blob> | Blob => x instanceof Blob || isBlobMap(x);
13
+
14
+ export type PreRequestHandler = (item: RequestInit) => Promise<RequestInit | undefined | void>;
15
+ export type PostResponseHandler = (item: Response) => Promise<Response | undefined | void>;
16
+
17
+ export type RpcRequest = {
18
+ core?: Partial<RequestInit> & {
19
+ timeout?: number;
20
+ retriesOnConnectFailure?: number;
21
+ path?: string;
22
+ };
23
+ url: URL | string;
24
+ consumeJSON?: <T>(text?: unknown) => (T | Promise<T>);
25
+ consumeError?: (item: unknown) => (Error | Promise<Error>);
26
+ preRequestHandlers?: PreRequestHandler[];
27
+ postResponseHandlers?: PostResponseHandler[];
28
+ };
29
+
30
+ export type RpcClient<T extends Record<string, {}>, E extends Record<string, Function> = {}> = {
31
+ [C in keyof T]: Pick<T[C], MethodKeys<T[C]>> & Record<MethodKeys<T[C]>, E>
32
+ };
33
+
34
+ export type RpcClientFactory<T extends Record<string, {}>> =
35
+ <R extends Record<string, Function>>(
36
+ baseOpts: RpcRequest,
37
+ decorate?: (opts: RpcRequest) => R
38
+ ) => RpcClient<T, R>;
39
+
40
+ function isResponse(v: unknown): v is Response {
41
+ return !!v && typeof v === 'object' && 'status' in v && !!v.status && 'headers' in v && !!v.headers;
42
+ }
43
+
44
+ function isPlainObject(obj: unknown): obj is Record<string, unknown> {
45
+ return typeof obj === 'object' // separate from primitives
46
+ && obj !== undefined
47
+ && obj !== null // is obvious
48
+ && obj.constructor === Object // separate instances (Array, DOM, ...)
49
+ && Object.prototype.toString.call(obj) === '[object Object]'; // separate build-in like Math
50
+ }
51
+
52
+ function registerTimeout<T extends (number | string | { unref(): unknown })>(
53
+ controller: AbortController,
54
+ timeout: number,
55
+ start: (fn: (...args: unknown[]) => unknown, delay: number) => T,
56
+ stop: (val: T) => void
57
+ ): void {
58
+ const timer = start(() => controller.abort(), timeout);
59
+ if (!(typeof timer === 'number' || typeof timer === 'string')) {
60
+ timer.unref();
61
+ }
62
+ controller.signal.onabort = (): void => { timer && stop(timer); };
63
+ }
64
+
65
+ function buildRequest<T extends RequestInit>(base: T, controller: string, endpoint: string): T {
66
+ return {
67
+ ...base,
68
+ method: 'POST',
69
+ path: `${controller}:${endpoint}`,
70
+ headers: {
71
+ ...base.headers,
72
+ 'Content-Type': 'application/json',
73
+ }
74
+ };
75
+ }
76
+
77
+ export function getBody(inputs: unknown[]): { body: FormData | string, headers: Record<string, string> } {
78
+ // If we do not have a blob, simple output
79
+ if (!inputs.some(isBlobLike)) {
80
+ return {
81
+ body: JSON.stringify(inputs),
82
+ headers: {
83
+ 'Content-Type': 'application/json'
84
+ }
85
+ };
86
+ }
87
+
88
+ const plainInputs = inputs.map(x => isBlobLike(x) ? null : x);
89
+ const form = new FormData();
90
+
91
+ for (const inp of inputs.filter(isBlobLike)) {
92
+ if (inp instanceof Blob) {
93
+ form.append('file', inp, (inp instanceof File) ? inp.name : undefined);
94
+ } else {
95
+ for (const [name, blob] of Object.entries(inp)) {
96
+ form.append(name, blob, (blob instanceof File) ? blob.name : undefined);
97
+ }
98
+ }
99
+ }
100
+
101
+ return {
102
+ body: form,
103
+ headers: {
104
+ 'X-TRV-RPC-INPUTS': btoa(encodeURIComponent(JSON.stringify(plainInputs)))
105
+ }
106
+ };
107
+ }
108
+
109
+ export function consumeJSON<T>(text: string | unknown): T {
110
+ if (typeof text !== 'string') {
111
+ return consumeJSON(JSON.stringify(text));
112
+ } else if (text === null || text === undefined || text === '') {
113
+ return undefined!;
114
+ }
115
+ try {
116
+ return JSON.parse(text, (key, value): unknown => {
117
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[.]\d{3}Z/.test(value)) {
118
+ return new Date(value);
119
+ } else {
120
+ return value;
121
+ }
122
+ });
123
+ } catch (err) {
124
+ throw new Error(`Unable to parse response: ${text}, Unknown error: ${err}`);
125
+ }
126
+ }
127
+
128
+ export async function consumeError(err: unknown): Promise<Error> {
129
+ if (err instanceof Error) {
130
+ return err;
131
+ } else if (isResponse(err)) {
132
+ const out = new Error(err.statusText);
133
+ Object.assign(out, { status: err.status });
134
+ return consumeError(out);
135
+ } else if (isPlainObject(err)) {
136
+ const out = new Error();
137
+ Object.assign(out, err);
138
+ return consumeError(out);
139
+ } else {
140
+ return new Error('Unknown error');
141
+ }
142
+ }
143
+
144
+ export async function invokeFetch<T>(request: RpcRequest, ...params: unknown[]): Promise<T> {
145
+ let core = request.core!;
146
+
147
+ try {
148
+ const { body, headers } = getBody(params);
149
+ core.body = body;
150
+ core.headers = {
151
+ ...core.headers ?? {},
152
+ ...headers
153
+ };
154
+
155
+ for (const fn of request.preRequestHandlers ?? []) {
156
+ const computed = await fn(core);
157
+ if (computed) {
158
+ core = computed;
159
+ }
160
+ }
161
+
162
+ const signals = [];
163
+ if (core.signal) {
164
+ signals.push(core.signal);
165
+ }
166
+
167
+ if (core.timeout) {
168
+ const controller = new AbortController();
169
+ // Node/Browser handling of timeout registration
170
+ registerTimeout(controller, core.timeout, setTimeout, clearTimeout);
171
+ signals.push(controller.signal);
172
+ }
173
+
174
+ if (signals.length) {
175
+ core.signal = AbortSignal.any(signals);
176
+ }
177
+
178
+ let resolved: Response | undefined;
179
+ for (let i = 0; i <= (core.retriesOnConnectFailure ?? 0); i += 1) {
180
+ try {
181
+ const url = typeof request.url === 'string' ? new URL(request.url) : request.url;
182
+ if (request.core?.path) {
183
+ url.pathname = `${url.pathname}/${request.core.path}`.replaceAll('//', '/');
184
+ }
185
+ resolved = await fetch(url, core);
186
+ break;
187
+ } catch (err) {
188
+ if (i < (core.retriesOnConnectFailure ?? 0)) {
189
+ await new Promise(r => setTimeout(r, 1000)); // Wait 1s
190
+ continue;
191
+ } else {
192
+ throw err;
193
+ }
194
+ }
195
+ }
196
+
197
+ if (!resolved) {
198
+ throw new Error('Unable to connect');
199
+ }
200
+
201
+ for (const fn of request.postResponseHandlers ?? []) {
202
+ const computed = await fn(resolved);
203
+ if (computed) {
204
+ resolved = computed;
205
+ }
206
+ }
207
+
208
+ const contentType = resolved.headers.get('Content-Type')?.split(';')[0];
209
+
210
+ if (resolved.ok) {
211
+ const text = await resolved.text();
212
+ if (contentType === 'application/json') {
213
+ return await request.consumeJSON!<T>(text);
214
+ } else if (contentType === 'text/plain') {
215
+ return await request.consumeJSON!<T>(text);
216
+ } else {
217
+ throw new Error(`Unknown content type: ${contentType}`);
218
+ }
219
+ } else {
220
+ let responseObject;
221
+ if (contentType === 'application/json') {
222
+ const text = await resolved.text();
223
+ responseObject = await request.consumeJSON!(text);
224
+ } else {
225
+ responseObject = resolved;
226
+ }
227
+ throw responseObject;
228
+ }
229
+ } catch (err) {
230
+ throw await request.consumeError!(err);
231
+ }
232
+ }
233
+
234
+ export function clientFactory<T extends Record<string, {}>>(): RpcClientFactory<T> {
235
+ // @ts-ignore
236
+ return function (opts, decorate) {
237
+ const client: RpcRequest = {
238
+ consumeJSON,
239
+ consumeError,
240
+ ...opts,
241
+ core: { timeout: 0, credentials: 'include', mode: 'cors', ...opts.core },
242
+ };
243
+ const cache: Record<string, unknown> = {};
244
+ // @ts-ignore
245
+ return new Proxy({}, {
246
+ get: (_, controller: string) =>
247
+ cache[controller] ??= new Proxy({}, {
248
+ get: (__, endpoint: string): unknown => {
249
+ const final: RpcRequest = {
250
+ ...client,
251
+ core: buildRequest(client.core!, controller, endpoint)
252
+ };
253
+ return cache[`${controller}/${endpoint}`] ??= Object.defineProperties(
254
+ invokeFetch.bind(null, final),
255
+ Object.fromEntries(
256
+ Object.entries(decorate?.(final) ?? {}).map(([k, v]) => [k, { value: v }])
257
+ )
258
+ );
259
+ }
260
+ })
261
+ });
262
+ };
263
+ }
264
+
265
+ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
266
+ export function withConfigFactoryDecorator(opts: RpcRequest) {
267
+ return {
268
+ withConfig<V extends PromiseFn>(this: V, extra: Partial<RpcRequest['core']>, ...params: Parameters<V>): Promise<PromiseRes<V>> {
269
+ return invokeFetch({ ...opts, core: { ...opts.core, ...extra } }, ...params);
270
+ }
271
+ };
272
+ }