@lowerdeck/rpc-server 1.0.0

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/src/rpcMux.ts ADDED
@@ -0,0 +1,303 @@
1
+ import {
2
+ internalServerError,
3
+ notAcceptableError,
4
+ notFoundError,
5
+ validationError
6
+ } from '@lowerdeck/error';
7
+ import { createExecutionContext, provideExecutionContext } from '@lowerdeck/execution-context';
8
+ import { generateCustomId } from '@lowerdeck/id';
9
+ import { memo } from '@lowerdeck/memo';
10
+ import { getSentry } from '@lowerdeck/sentry';
11
+ import { serialize } from '@lowerdeck/serialize';
12
+ import { v } from '@lowerdeck/validation';
13
+ import * as Cookie from 'cookie';
14
+ import { ServiceRequest } from './controller';
15
+ import { parseForwardedFor } from './extractIp';
16
+
17
+ let Sentry = getSentry();
18
+
19
+ let validation = v.object({
20
+ calls: v.array(
21
+ v.object({
22
+ id: v.string(),
23
+ name: v.string(),
24
+ payload: v.any()
25
+ })
26
+ )
27
+ });
28
+
29
+ export let rpcMux = (
30
+ opts: {
31
+ path: string;
32
+ cors?: {
33
+ headers?: string[];
34
+ } & ({ domains: string[] } | { check: (origin: string) => boolean });
35
+ },
36
+ rpcs: {
37
+ handlerNames: string[];
38
+ runMany: (
39
+ req: ServiceRequest,
40
+ body: {
41
+ requestId: string;
42
+ calls: {
43
+ id: string;
44
+ name: string;
45
+ payload: any;
46
+ }[];
47
+ }
48
+ ) => Promise<{
49
+ status: number;
50
+ body: {
51
+ calls: any[];
52
+ };
53
+ }>;
54
+ }[]
55
+ ) => {
56
+ let handlerNameToRpcMap = new Map<string, number>(
57
+ rpcs.flatMap((rpc, i) => rpc.handlerNames.map(name => [name, i]))
58
+ );
59
+
60
+ return {
61
+ path: opts.path,
62
+
63
+ fetch: async (req: any): Promise<any> => {
64
+ let origin = req.headers.get('origin') ?? '';
65
+ let corsOk = false;
66
+
67
+ if (opts.cors && 'domains' in opts.cors) {
68
+ try {
69
+ let url = new URL(origin);
70
+ let rootDomain = url.hostname.split('.').slice(-2).join('.');
71
+ corsOk = opts.cors.domains.includes(rootDomain);
72
+ } catch (e) {
73
+ // Ignore -> no cors
74
+ }
75
+ } else if (opts.cors && 'check' in opts.cors) {
76
+ corsOk = opts.cors.check(origin);
77
+ }
78
+
79
+ let url = new URL(req.url);
80
+
81
+ let additionalCorsHeaders = opts.cors?.headers?.join(', ');
82
+ if (additionalCorsHeaders) additionalCorsHeaders = `, ${additionalCorsHeaders}`.trim();
83
+
84
+ let corsHeaders: Record<string, string> = corsOk
85
+ ? {
86
+ 'access-control-allow-origin': origin,
87
+ 'access-control-allow-methods': 'POST, OPTIONS',
88
+ 'access-control-allow-headers': `Content-Type, Authorization, Baggage, Sentry-Trace${
89
+ additionalCorsHeaders ?? ''
90
+ }`,
91
+ 'access-control-max-age': '604800',
92
+ 'access-control-allow-credentials': 'true'
93
+ }
94
+ : {};
95
+
96
+ if (req.method == 'OPTIONS') {
97
+ if (corsOk) {
98
+ return new Response(null, {
99
+ status: 204,
100
+ headers: corsHeaders
101
+ });
102
+ }
103
+
104
+ return new Response(null, { status: 403 });
105
+ }
106
+
107
+ let body: any = null;
108
+
109
+ try {
110
+ body = serialize.decode(await req.text());
111
+ } catch (e) {
112
+ return new Response(
113
+ JSON.stringify(notAcceptableError({ message: 'Invalid JSON' }).toResponse()),
114
+ { status: 406 }
115
+ );
116
+ }
117
+
118
+ let sentryTraceHeaders = req.headers.get('sentry-trace');
119
+ let sentryTrace =
120
+ (Array.isArray(sentryTraceHeaders)
121
+ ? sentryTraceHeaders.join(',')
122
+ : sentryTraceHeaders) ?? undefined;
123
+ let baggage = req.headers.get('baggage');
124
+
125
+ let ip = parseForwardedFor(
126
+ req.headers.get('lowerdeck-connecting-ip') ??
127
+ req.headers.get('cf-connecting-ip') ??
128
+ req.headers.get('x-forwarded-for') ??
129
+ req.headers.get('x-real-ip')
130
+ );
131
+
132
+ let headers = new Headers();
133
+
134
+ if (corsOk) {
135
+ for (let [key, value] of Object.entries(corsHeaders)) {
136
+ headers.append(key, value);
137
+ }
138
+ }
139
+
140
+ return await Sentry.withIsolationScope(
141
+ async () =>
142
+ await Sentry.continueTrace(
143
+ { sentryTrace, baggage },
144
+ async () =>
145
+ await Sentry.startSpan(
146
+ {
147
+ name: 'rpc request',
148
+ op: 'rpc.server',
149
+ attributes: {
150
+ ip,
151
+ transport: 'http',
152
+ ua: req.headers.get('user-agent') ?? '',
153
+ origin: req.headers.get('origin') ?? ''
154
+ }
155
+ },
156
+ async () => {
157
+ try {
158
+ let beforeSends: Array<() => Promise<any>> = [];
159
+ let id = generateCustomId('req_');
160
+
161
+ let parseCookies = memo(() =>
162
+ Cookie.parse(req.headers.get('cookie') ?? '')
163
+ );
164
+
165
+ let request: ServiceRequest = {
166
+ url: req.url,
167
+ headers: req.headers,
168
+ query: url.searchParams,
169
+ body,
170
+ rawBody: body,
171
+ ip,
172
+ requestId: id,
173
+
174
+ getCookies: () => parseCookies(),
175
+ getCookie: (name: string) => parseCookies()[name],
176
+ setCookie: (name: string, value: string, opts?: any) => {
177
+ let cookie = Cookie.serialize(name, value, opts);
178
+ // @ts-ignore
179
+ headers.append('Set-Cookie', cookie);
180
+ },
181
+
182
+ beforeSend: (handler: () => Promise<any>) => {
183
+ beforeSends.push(handler);
184
+ },
185
+
186
+ sharedMiddlewareMemo: new Map<string, Promise<any>>(),
187
+
188
+ appendHeaders: (newHeaders: Record<string, string | string[]>) => {
189
+ for (let [key, value] of Object.entries(newHeaders)) {
190
+ if (Array.isArray(value)) {
191
+ for (let v of value) headers.append(key, v);
192
+ } else {
193
+ headers.append(key, value);
194
+ }
195
+ }
196
+ }
197
+ };
198
+
199
+ Sentry.getCurrentScope().setContext('rpc.request', {
200
+ url: req.url,
201
+ query: Object.fromEntries(url.searchParams.entries())
202
+ });
203
+
204
+ Sentry.getCurrentScope().addAttachment({
205
+ filename: 'rpc.request.body.json',
206
+ data: body,
207
+ contentType: 'application/json'
208
+ });
209
+
210
+ let valRes = validation.validate(body);
211
+ if (!valRes.success) {
212
+ return new Response(
213
+ JSON.stringify(
214
+ validationError({
215
+ errors: valRes.errors,
216
+ entity: 'request_data'
217
+ }).toResponse()
218
+ ),
219
+ { status: 406, headers }
220
+ );
221
+ }
222
+
223
+ return provideExecutionContext(
224
+ createExecutionContext({
225
+ type: 'request',
226
+ contextId: id,
227
+ ip: ip ?? '0.0.0.0',
228
+ userAgent: req.headers.get('user-agent') ?? ''
229
+ }),
230
+ async () => {
231
+ let callsByRpc = new Map<
232
+ number,
233
+ { id: string; name: string; payload: any }[]
234
+ >();
235
+
236
+ for (let call of body.calls) {
237
+ let rpcIndex = handlerNameToRpcMap.get(call.name);
238
+ if (rpcIndex == undefined) {
239
+ return new Response(
240
+ JSON.stringify(
241
+ notFoundError({ entity: 'handler' }).toResponse()
242
+ ),
243
+ { status: 404, headers }
244
+ );
245
+ }
246
+
247
+ let calls = callsByRpc.get(rpcIndex) ?? [];
248
+ calls.push(call);
249
+ callsByRpc.set(rpcIndex, calls);
250
+ }
251
+
252
+ // let res = await runMany(request);
253
+
254
+ let resRef = {
255
+ body: {
256
+ __typename: 'rpc.response',
257
+ calls: [] as any[]
258
+ },
259
+ status: 200
260
+ };
261
+
262
+ await Promise.all(
263
+ Array.from(callsByRpc.entries()).map(async ([rpcIndex, calls]) => {
264
+ let rpc = rpcs[rpcIndex];
265
+ let res = await rpc.runMany(request, {
266
+ requestId: id,
267
+ calls
268
+ });
269
+
270
+ resRef.status = Math.max(resRef.status, res.status);
271
+ resRef.body.calls.push(...res.body.calls);
272
+ })
273
+ );
274
+
275
+ headers.append('x-req-id', id);
276
+ headers.append('content-type', 'application/rpc+json');
277
+ headers.append('x-powered-by', 'lowerdeck RPC');
278
+
279
+ await Promise.all(beforeSends.map(s => s()));
280
+
281
+ return new Response(serialize.encode(resRef.body), {
282
+ status: resRef.status,
283
+ headers
284
+ });
285
+ }
286
+ );
287
+ } catch (e) {
288
+ console.error(e);
289
+
290
+ Sentry.captureException(e);
291
+
292
+ return new Response(JSON.stringify(internalServerError().toResponse()), {
293
+ status: 500,
294
+ headers
295
+ });
296
+ }
297
+ }
298
+ )
299
+ )
300
+ );
301
+ }
302
+ };
303
+ };
package/src/server.ts ADDED
@@ -0,0 +1,204 @@
1
+ import { internalServerError, isServiceError, notFoundError } from '@lowerdeck/error';
2
+ import { getSentry } from '@lowerdeck/sentry';
3
+ import { Controller, Handler, ServiceRequest } from './controller';
4
+
5
+ let Sentry = getSentry();
6
+
7
+ export let createServer =
8
+ (opts: {
9
+ onError?: (opts: {
10
+ request: ServiceRequest;
11
+ error: any;
12
+ reqId: string;
13
+ callId: string;
14
+ callName: string;
15
+ }) => void;
16
+ onRequest?: (opts: {
17
+ reqId: string;
18
+ callId: string;
19
+ callName: string;
20
+ request: ServiceRequest;
21
+ response: { status: number; body: any };
22
+ }) => void;
23
+ }) =>
24
+ (controller: Controller<any>) => {
25
+ let findHandler = (name: string): Handler<any, any, any> | null => {
26
+ let parts = name.split(':');
27
+ let current = controller;
28
+
29
+ while (current && parts.length > 0) {
30
+ current = current[parts.shift()!];
31
+ if (!current) return null;
32
+ }
33
+
34
+ if (current && current instanceof Handler) return current;
35
+
36
+ return null;
37
+ };
38
+
39
+ let getSupportedHandlerNames = (controller: Controller<any>): string[] =>
40
+ Object.entries(controller).flatMap(([key, value]) => {
41
+ if (value instanceof Handler) return [key];
42
+ if (typeof value == 'object')
43
+ return getSupportedHandlerNames(value).map(name => `${key}:${name}`);
44
+ return [];
45
+ });
46
+
47
+ let handlerNames = getSupportedHandlerNames(controller);
48
+
49
+ let run = async (
50
+ req: ServiceRequest,
51
+ call: { id: string; name: string; payload: any },
52
+ reqId: string
53
+ ): Promise<{
54
+ response: any;
55
+ status: number;
56
+ request: ServiceRequest;
57
+ }> => {
58
+ let request = { ...req, body: call.payload };
59
+
60
+ try {
61
+ let handler = findHandler(call.name);
62
+
63
+ if (!handler) {
64
+ return {
65
+ request,
66
+ status: 404,
67
+ response: notFoundError({ entity: 'handler' }).toResponse()
68
+ };
69
+ }
70
+
71
+ let response = await handler.run(request, {});
72
+
73
+ return {
74
+ status: 200,
75
+ request: req,
76
+ response: response.response
77
+ };
78
+ } catch (e) {
79
+ console.error(e);
80
+
81
+ if (isServiceError(e)) {
82
+ if (e.data.status >= 500) {
83
+ Sentry.captureException(e, {
84
+ tags: { reqId }
85
+ });
86
+ }
87
+
88
+ return {
89
+ request,
90
+ status: e.data.status,
91
+ response: e.toResponse()
92
+ };
93
+ }
94
+
95
+ Sentry.captureException(e, {
96
+ tags: { reqId }
97
+ });
98
+
99
+ opts.onError?.({
100
+ callName: call.name,
101
+ callId: call.id,
102
+ request: req,
103
+ error: e,
104
+ reqId
105
+ });
106
+
107
+ return {
108
+ request,
109
+ status: 500,
110
+ response: internalServerError().toResponse()
111
+ };
112
+ }
113
+ };
114
+
115
+ let runMany = async (
116
+ req: ServiceRequest,
117
+ body: {
118
+ calls: {
119
+ id: string;
120
+ name: string;
121
+ payload: any;
122
+ }[];
123
+ requestId: string;
124
+ }
125
+ ): Promise<{
126
+ status: number;
127
+ body: any;
128
+ }> => {
129
+ let callRes = await Promise.all(
130
+ body.calls.map(async (call, i) => {
131
+ let res = await run(req, call as any, body.requestId);
132
+
133
+ try {
134
+ opts.onRequest?.({
135
+ reqId: body.requestId,
136
+ callId: call.id,
137
+ callName: call.name,
138
+ request: res.request,
139
+ response: { status: res.status, body: res.response }
140
+ });
141
+ } catch (e) {
142
+ Sentry.captureException(e);
143
+ console.error(e);
144
+ }
145
+
146
+ return {
147
+ __typename: 'rpc.response.call',
148
+ id: call.id,
149
+ name: call.name,
150
+ status: res.status,
151
+ result: res.response
152
+ };
153
+ })
154
+ );
155
+
156
+ return {
157
+ status: Math.max(...callRes.map(c => c.status)),
158
+ body: {
159
+ __typename: 'rpc.response',
160
+ calls: callRes
161
+ }
162
+ };
163
+ };
164
+
165
+ return {
166
+ handlerNames,
167
+ runMany
168
+
169
+ // fetch,
170
+
171
+ // http: async (
172
+ // req: IncomingMessage & {
173
+ // body: any;
174
+ // },
175
+ // res: ServerResponse & { send: (body: any) => void }
176
+ // ) => {
177
+ // let headers = new Headers(
178
+ // Object.fromEntries(
179
+ // Object.entries(req.headers).map(([key, value]) => [
180
+ // key,
181
+ // value === undefined ? '' : String(value)
182
+ // ])
183
+ // )
184
+ // );
185
+ // let url = new URL(req.url ?? '', `http://${req.headers.host}`);
186
+
187
+ // let request = new Request(url.toString(), {
188
+ // method: req.method,
189
+ // headers,
190
+ // body: JSON.stringify(req.body)
191
+ // });
192
+
193
+ // let response = await fetch(request);
194
+
195
+ // res.statusCode = response.status;
196
+
197
+ // for (let [key, value] of response.headers.entries()) {
198
+ // res.setHeader(key, value);
199
+ // }
200
+
201
+ // res.send(response.body);
202
+ // }
203
+ };
204
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "@lowerdeck/tsconfig/base.json",
4
+ "exclude": [
5
+ "dist"
6
+ ],
7
+ "include": [
8
+ "src"
9
+ ],
10
+ "compilerOptions": {
11
+ "outDir": "dist"
12
+ }
13
+ }
package/tsup.config.js ADDED
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['esm', 'cjs'],
6
+ splitting: false,
7
+ sourcemap: true,
8
+ clean: true,
9
+ bundle: true,
10
+ dts: true
11
+ });