@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 +31 -0
- package/__index__.ts +3 -0
- package/package.json +39 -0
- package/src/config.ts +14 -0
- package/src/controller.ts +54 -0
- package/src/service.ts +81 -0
- package/support/cli.web_rpc-client.ts +45 -0
- package/support/client/rpc-node.ts +13 -0
- package/support/client/rpc.ts +272 -0
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
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
|
+
}
|