@lowerdeck/rpc-server 1.0.6 → 1.0.8
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/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.module.js +1 -1
- package/dist/index.module.js.map +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/index.umd.js.map +1 -1
- package/dist/rpcMux.d.ts +1 -0
- package/dist/rpcMux.d.ts.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/rpcMux.ts +290 -173
- package/src/server.ts +107 -38
package/src/rpcMux.ts
CHANGED
|
@@ -4,6 +4,14 @@ import {
|
|
|
4
4
|
notFoundError,
|
|
5
5
|
validationError
|
|
6
6
|
} from '@lowerdeck/error';
|
|
7
|
+
import {
|
|
8
|
+
isTelemetryEnabled,
|
|
9
|
+
otelContext,
|
|
10
|
+
propagation,
|
|
11
|
+
SpanKind,
|
|
12
|
+
SpanStatusCode,
|
|
13
|
+
trace
|
|
14
|
+
} from '@lowerdeck/telemetry';
|
|
7
15
|
import { createExecutionContext, provideExecutionContext } from '@lowerdeck/execution-context';
|
|
8
16
|
import { generateCustomId } from '@lowerdeck/id';
|
|
9
17
|
import { memo } from '@lowerdeck/memo';
|
|
@@ -14,7 +22,10 @@ import * as Cookie from 'cookie';
|
|
|
14
22
|
import { ServiceRequest } from './controller';
|
|
15
23
|
import { parseForwardedFor } from './extractIp';
|
|
16
24
|
|
|
25
|
+
let verbose = process.env.NODE_ENV !== 'production';
|
|
26
|
+
|
|
17
27
|
let Sentry = getSentry();
|
|
28
|
+
let tracer = trace.getTracer('lowerdeck.rpc-server');
|
|
18
29
|
|
|
19
30
|
let validation = v.object({
|
|
20
31
|
calls: v.array(
|
|
@@ -26,9 +37,38 @@ let validation = v.object({
|
|
|
26
37
|
)
|
|
27
38
|
});
|
|
28
39
|
|
|
40
|
+
let summarizeRpcTarget = (url: URL, body: any) => {
|
|
41
|
+
let pathParts = url.pathname.split('/').filter(Boolean);
|
|
42
|
+
let lastPart = pathParts[pathParts.length - 1] ?? '';
|
|
43
|
+
|
|
44
|
+
if (lastPart.startsWith('$') && lastPart.length > 1) {
|
|
45
|
+
return lastPart.slice(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let callNames: string[] = [];
|
|
49
|
+
if (body && typeof body == 'object' && Array.isArray(body.calls)) {
|
|
50
|
+
callNames = body.calls
|
|
51
|
+
.map((call: { name?: unknown }) =>
|
|
52
|
+
typeof call?.name == 'string' ? call.name.trim() : ''
|
|
53
|
+
)
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (callNames.length == 1) return callNames[0];
|
|
58
|
+
|
|
59
|
+
if (callNames.length > 1) {
|
|
60
|
+
let preview = callNames.slice(0, 3).join(', ');
|
|
61
|
+
if (callNames.length > 3) return `${preview}, +${callNames.length - 3} more`;
|
|
62
|
+
return preview;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return url.pathname;
|
|
66
|
+
};
|
|
67
|
+
|
|
29
68
|
export let rpcMux = (
|
|
30
69
|
opts: {
|
|
31
70
|
path: string;
|
|
71
|
+
allowRootSpan?: boolean;
|
|
32
72
|
cors?: {
|
|
33
73
|
headers?: string[];
|
|
34
74
|
} & ({ domains: string[] } | { check: (origin: string) => boolean });
|
|
@@ -85,7 +125,7 @@ export let rpcMux = (
|
|
|
85
125
|
? {
|
|
86
126
|
'access-control-allow-origin': origin,
|
|
87
127
|
'access-control-allow-methods': 'POST, OPTIONS',
|
|
88
|
-
'access-control-allow-headers': `Content-Type, Authorization, Baggage, Sentry-Trace${
|
|
128
|
+
'access-control-allow-headers': `Content-Type, Authorization, Baggage, Sentry-Trace, traceparent, tracestate${
|
|
89
129
|
additionalCorsHeaders ?? ''
|
|
90
130
|
}`,
|
|
91
131
|
'access-control-max-age': '604800',
|
|
@@ -152,137 +192,121 @@ export let rpcMux = (
|
|
|
152
192
|
}
|
|
153
193
|
}
|
|
154
194
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
url: req.url,
|
|
182
|
-
headers: req.headers,
|
|
183
|
-
query: url.searchParams,
|
|
184
|
-
body,
|
|
185
|
-
rawBody: body,
|
|
195
|
+
let extractedTraceContext = propagation.extract(otelContext.active(), req.headers, {
|
|
196
|
+
get: (carrier, key) => carrier.get(key) ?? undefined,
|
|
197
|
+
keys: carrier => Array.from(carrier.keys())
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
let incomingParent = trace.getSpanContext(extractedTraceContext);
|
|
201
|
+
let canTrace = isTelemetryEnabled() && (!!incomingParent || !!opts.allowRootSpan);
|
|
202
|
+
let requestSpanTarget = summarizeRpcTarget(url, body);
|
|
203
|
+
let requestSpanName = `rpc request: ${requestSpanTarget}`;
|
|
204
|
+
let requestSpanOp = 'rpc.server.request';
|
|
205
|
+
|
|
206
|
+
let executeRequest = async () =>
|
|
207
|
+
await Sentry.withIsolationScope(
|
|
208
|
+
async () =>
|
|
209
|
+
await Sentry.continueTrace(
|
|
210
|
+
{ sentryTrace, baggage },
|
|
211
|
+
async () =>
|
|
212
|
+
await Sentry.startSpan(
|
|
213
|
+
{
|
|
214
|
+
name: requestSpanName,
|
|
215
|
+
op: requestSpanOp,
|
|
216
|
+
attributes: {
|
|
217
|
+
'sentry.op': requestSpanOp,
|
|
218
|
+
'rpc.request.target': requestSpanTarget,
|
|
219
|
+
'rpc.description': requestSpanName,
|
|
220
|
+
'sentry.description': requestSpanName,
|
|
186
221
|
ip,
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
222
|
+
transport: 'http',
|
|
223
|
+
ua: req.headers.get('user-agent') ?? '',
|
|
224
|
+
origin: req.headers.get('origin') ?? ''
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
async () => {
|
|
228
|
+
try {
|
|
229
|
+
let beforeSends: Array<() => Promise<any>> = [];
|
|
230
|
+
let id = generateCustomId('req_');
|
|
231
|
+
|
|
232
|
+
let parseCookies = memo(() =>
|
|
233
|
+
Cookie.parse(req.headers.get('cookie') ?? '')
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
let request: ServiceRequest = {
|
|
237
|
+
url: req.url,
|
|
238
|
+
headers: req.headers,
|
|
239
|
+
query: url.searchParams,
|
|
240
|
+
body,
|
|
241
|
+
rawBody: body,
|
|
242
|
+
ip,
|
|
243
|
+
requestId: id,
|
|
244
|
+
|
|
245
|
+
getCookies: () => parseCookies(),
|
|
246
|
+
getCookie: (name: string) => parseCookies()[name],
|
|
247
|
+
setCookie: (name: string, value: string, opts?: any) => {
|
|
248
|
+
let cookie = Cookie.serialize(name, value, opts);
|
|
249
|
+
// @ts-ignore
|
|
250
|
+
headers.append('Set-Cookie', cookie);
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
beforeSend: (handler: () => Promise<any>) => {
|
|
254
|
+
beforeSends.push(handler);
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
sharedMiddlewareMemo: new Map<string, Promise<any>>(),
|
|
258
|
+
|
|
259
|
+
appendHeaders: (newHeaders: Record<string, string | string[]>) => {
|
|
260
|
+
for (let [key, value] of Object.entries(newHeaders)) {
|
|
261
|
+
if (Array.isArray(value)) {
|
|
262
|
+
for (let v of value) headers.append(key, v);
|
|
263
|
+
} else {
|
|
264
|
+
headers.append(key, value);
|
|
265
|
+
}
|
|
209
266
|
}
|
|
210
267
|
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
let rpcIndex = handlerNameToRpcMap.get(id);
|
|
254
|
-
if (rpcIndex == undefined) {
|
|
255
|
-
return new Response(
|
|
256
|
-
JSON.stringify(
|
|
257
|
-
notFoundError({ entity: 'handler' }).toResponse()
|
|
258
|
-
),
|
|
259
|
-
{ status: 404, headers }
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
let calls = callsByRpc.get(rpcIndex) ?? [];
|
|
264
|
-
calls.push({
|
|
265
|
-
id: generateCustomId('call_'),
|
|
266
|
-
name: id,
|
|
267
|
-
payload: body
|
|
268
|
-
});
|
|
269
|
-
callsByRpc.set(rpcIndex, calls);
|
|
270
|
-
} else {
|
|
271
|
-
let valRes = validation.validate(body);
|
|
272
|
-
if (!valRes.success) {
|
|
273
|
-
return new Response(
|
|
274
|
-
JSON.stringify(
|
|
275
|
-
validationError({
|
|
276
|
-
errors: valRes.errors,
|
|
277
|
-
entity: 'request_data'
|
|
278
|
-
}).toResponse()
|
|
279
|
-
),
|
|
280
|
-
{ status: 406, headers }
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
for (let call of valRes.value.calls) {
|
|
285
|
-
let rpcIndex = handlerNameToRpcMap.get(call.name);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
Sentry.getCurrentScope().setContext('rpc.request', {
|
|
271
|
+
url: req.url,
|
|
272
|
+
query: Object.fromEntries(url.searchParams.entries())
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
Sentry.getCurrentScope().addAttachment({
|
|
276
|
+
filename: 'rpc.request.body.json',
|
|
277
|
+
data: body,
|
|
278
|
+
contentType: 'application/json'
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
return provideExecutionContext(
|
|
282
|
+
createExecutionContext({
|
|
283
|
+
type: 'request',
|
|
284
|
+
contextId: id,
|
|
285
|
+
ip: ip ?? '0.0.0.0',
|
|
286
|
+
userAgent: req.headers.get('user-agent') ?? ''
|
|
287
|
+
}),
|
|
288
|
+
async () => {
|
|
289
|
+
let callsByRpc = new Map<
|
|
290
|
+
number,
|
|
291
|
+
{ id: string; name: string; payload: any }[]
|
|
292
|
+
>();
|
|
293
|
+
|
|
294
|
+
let resRef = {
|
|
295
|
+
body: {
|
|
296
|
+
__typename: 'rpc.response',
|
|
297
|
+
calls: [] as any[]
|
|
298
|
+
},
|
|
299
|
+
status: 200
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
let pathParts = url.pathname.split('/').filter(Boolean);
|
|
303
|
+
let lastPart = pathParts[pathParts.length - 1];
|
|
304
|
+
|
|
305
|
+
let isSingle = lastPart[0] == '$';
|
|
306
|
+
|
|
307
|
+
if (isSingle) {
|
|
308
|
+
let id = lastPart.slice(1);
|
|
309
|
+
let rpcIndex = handlerNameToRpcMap.get(id);
|
|
286
310
|
if (rpcIndex == undefined) {
|
|
287
311
|
return new Response(
|
|
288
312
|
JSON.stringify(
|
|
@@ -293,55 +317,148 @@ export let rpcMux = (
|
|
|
293
317
|
}
|
|
294
318
|
|
|
295
319
|
let calls = callsByRpc.get(rpcIndex) ?? [];
|
|
296
|
-
calls.push(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
await Promise.all(
|
|
302
|
-
Array.from(callsByRpc.entries()).map(async ([rpcIndex, calls]) => {
|
|
303
|
-
let rpc = rpcs[rpcIndex];
|
|
304
|
-
let res = await rpc.runMany(request, {
|
|
305
|
-
requestId: id,
|
|
306
|
-
calls
|
|
320
|
+
calls.push({
|
|
321
|
+
id: generateCustomId('call_'),
|
|
322
|
+
name: id,
|
|
323
|
+
payload: body
|
|
307
324
|
});
|
|
325
|
+
callsByRpc.set(rpcIndex, calls);
|
|
326
|
+
} else {
|
|
327
|
+
let valRes = validation.validate(body);
|
|
328
|
+
if (!valRes.success) {
|
|
329
|
+
return new Response(
|
|
330
|
+
JSON.stringify(
|
|
331
|
+
validationError({
|
|
332
|
+
errors: valRes.errors,
|
|
333
|
+
entity: 'request_data'
|
|
334
|
+
}).toResponse()
|
|
335
|
+
),
|
|
336
|
+
{ status: 406, headers }
|
|
337
|
+
);
|
|
338
|
+
}
|
|
308
339
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
340
|
+
for (let call of valRes.value.calls) {
|
|
341
|
+
let rpcIndex = handlerNameToRpcMap.get(call.name);
|
|
342
|
+
if (rpcIndex == undefined) {
|
|
343
|
+
return new Response(
|
|
344
|
+
JSON.stringify(
|
|
345
|
+
notFoundError({ entity: 'handler' }).toResponse()
|
|
346
|
+
),
|
|
347
|
+
{ status: 404, headers }
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let calls = callsByRpc.get(rpcIndex) ?? [];
|
|
352
|
+
calls.push(call as any);
|
|
353
|
+
callsByRpc.set(rpcIndex, calls);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
313
356
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
357
|
+
await Promise.all(
|
|
358
|
+
Array.from(callsByRpc.entries()).map(async ([rpcIndex, calls]) => {
|
|
359
|
+
let rpc = rpcs[rpcIndex];
|
|
360
|
+
let res = await rpc.runMany(request, {
|
|
361
|
+
requestId: id,
|
|
362
|
+
calls
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
resRef.status = Math.max(resRef.status, res.status);
|
|
366
|
+
resRef.body.calls.push(...res.body.calls);
|
|
367
|
+
})
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
headers.append('x-req-id', id);
|
|
371
|
+
headers.append('content-type', 'application/rpc+json');
|
|
372
|
+
headers.append('x-powered-by', 'lowerdeck RPC');
|
|
373
|
+
|
|
374
|
+
await Promise.all(beforeSends.map(s => s()));
|
|
375
|
+
|
|
376
|
+
return new Response(
|
|
377
|
+
serialize.encode(
|
|
378
|
+
isSingle ? resRef.body.calls[0].result : resRef.body
|
|
379
|
+
),
|
|
380
|
+
{
|
|
381
|
+
status: resRef.status,
|
|
382
|
+
headers
|
|
383
|
+
}
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
);
|
|
387
|
+
} catch (e) {
|
|
388
|
+
if (verbose) console.error(e);
|
|
389
|
+
|
|
390
|
+
Sentry.captureException(e);
|
|
391
|
+
|
|
392
|
+
return new Response(
|
|
393
|
+
JSON.stringify(
|
|
394
|
+
internalServerError({
|
|
395
|
+
inner: verbose
|
|
396
|
+
? e instanceof Error
|
|
397
|
+
? { message: e.message, stack: e.stack }
|
|
398
|
+
: { error: e }
|
|
399
|
+
: undefined
|
|
400
|
+
}).toResponse()
|
|
401
|
+
),
|
|
402
|
+
{
|
|
403
|
+
status: 500,
|
|
404
|
+
headers
|
|
405
|
+
}
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
);
|
|
317
412
|
|
|
318
|
-
|
|
413
|
+
return await otelContext.with(extractedTraceContext, async () => {
|
|
414
|
+
if (!canTrace) {
|
|
415
|
+
return await executeRequest();
|
|
416
|
+
}
|
|
319
417
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
|
|
418
|
+
return await tracer.startActiveSpan(
|
|
419
|
+
requestSpanName,
|
|
420
|
+
{
|
|
421
|
+
kind: SpanKind.SERVER,
|
|
422
|
+
attributes: {
|
|
423
|
+
'sentry.op': requestSpanOp,
|
|
424
|
+
'rpc.system': 'lowerdeck',
|
|
425
|
+
'rpc.request.target': requestSpanTarget,
|
|
426
|
+
'rpc.description': requestSpanName,
|
|
427
|
+
'sentry.description': requestSpanName,
|
|
428
|
+
'http.request.method': req.method,
|
|
429
|
+
'url.path': url.pathname,
|
|
430
|
+
ip: ip ?? '',
|
|
431
|
+
transport: 'http',
|
|
432
|
+
ua: req.headers.get('user-agent') ?? '',
|
|
433
|
+
origin: req.headers.get('origin') ?? ''
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
async span => {
|
|
437
|
+
try {
|
|
438
|
+
let response = await executeRequest();
|
|
439
|
+
|
|
440
|
+
span.setAttribute('http.response.status_code', response.status);
|
|
441
|
+
if (response.status >= 500) {
|
|
442
|
+
span.setStatus({
|
|
443
|
+
code: SpanStatusCode.ERROR,
|
|
444
|
+
message: `RPC request failed with status ${response.status}`
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return response;
|
|
449
|
+
} catch (error) {
|
|
450
|
+
span.recordException(error as Error);
|
|
451
|
+
span.setStatus({
|
|
452
|
+
code: SpanStatusCode.ERROR,
|
|
453
|
+
message: error instanceof Error ? error.message : String(error)
|
|
454
|
+
});
|
|
455
|
+
throw error;
|
|
456
|
+
} finally {
|
|
457
|
+
span.end();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
);
|
|
461
|
+
});
|
|
345
462
|
}
|
|
346
463
|
};
|
|
347
464
|
};
|