@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/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
- return await Sentry.withIsolationScope(
156
- async () =>
157
- await Sentry.continueTrace(
158
- { sentryTrace, baggage },
159
- async () =>
160
- await Sentry.startSpan(
161
- {
162
- name: 'rpc request',
163
- op: 'rpc.server',
164
- attributes: {
165
- ip,
166
- transport: 'http',
167
- ua: req.headers.get('user-agent') ?? '',
168
- origin: req.headers.get('origin') ?? ''
169
- }
170
- },
171
- async () => {
172
- try {
173
- let beforeSends: Array<() => Promise<any>> = [];
174
- let id = generateCustomId('req_');
175
-
176
- let parseCookies = memo(() =>
177
- Cookie.parse(req.headers.get('cookie') ?? '')
178
- );
179
-
180
- let request: ServiceRequest = {
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
- requestId: id,
188
-
189
- getCookies: () => parseCookies(),
190
- getCookie: (name: string) => parseCookies()[name],
191
- setCookie: (name: string, value: string, opts?: any) => {
192
- let cookie = Cookie.serialize(name, value, opts);
193
- // @ts-ignore
194
- headers.append('Set-Cookie', cookie);
195
- },
196
-
197
- beforeSend: (handler: () => Promise<any>) => {
198
- beforeSends.push(handler);
199
- },
200
-
201
- sharedMiddlewareMemo: new Map<string, Promise<any>>(),
202
-
203
- appendHeaders: (newHeaders: Record<string, string | string[]>) => {
204
- for (let [key, value] of Object.entries(newHeaders)) {
205
- if (Array.isArray(value)) {
206
- for (let v of value) headers.append(key, v);
207
- } else {
208
- headers.append(key, value);
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
- Sentry.getCurrentScope().setContext('rpc.request', {
215
- url: req.url,
216
- query: Object.fromEntries(url.searchParams.entries())
217
- });
218
-
219
- Sentry.getCurrentScope().addAttachment({
220
- filename: 'rpc.request.body.json',
221
- data: body,
222
- contentType: 'application/json'
223
- });
224
-
225
- return provideExecutionContext(
226
- createExecutionContext({
227
- type: 'request',
228
- contextId: id,
229
- ip: ip ?? '0.0.0.0',
230
- userAgent: req.headers.get('user-agent') ?? ''
231
- }),
232
- async () => {
233
- let callsByRpc = new Map<
234
- number,
235
- { id: string; name: string; payload: any }[]
236
- >();
237
-
238
- let resRef = {
239
- body: {
240
- __typename: 'rpc.response',
241
- calls: [] as any[]
242
- },
243
- status: 200
244
- };
245
-
246
- let pathParts = url.pathname.split('/').filter(Boolean);
247
- let lastPart = pathParts[pathParts.length - 1];
248
-
249
- let isSingle = lastPart[0] == '$';
250
-
251
- if (isSingle) {
252
- let id = lastPart.slice(1);
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(call as any);
297
- callsByRpc.set(rpcIndex, calls);
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
- resRef.status = Math.max(resRef.status, res.status);
310
- resRef.body.calls.push(...res.body.calls);
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
- headers.append('x-req-id', id);
315
- headers.append('content-type', 'application/rpc+json');
316
- headers.append('x-powered-by', 'lowerdeck RPC');
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
- await Promise.all(beforeSends.map(s => s()));
413
+ return await otelContext.with(extractedTraceContext, async () => {
414
+ if (!canTrace) {
415
+ return await executeRequest();
416
+ }
319
417
 
320
- return new Response(
321
- serialize.encode(
322
- isSingle ? resRef.body.calls[0].result : resRef.body
323
- ),
324
- {
325
- status: resRef.status,
326
- headers
327
- }
328
- );
329
- }
330
- );
331
- } catch (e) {
332
- console.error(e);
333
-
334
- Sentry.captureException(e);
335
-
336
- return new Response(JSON.stringify(internalServerError().toResponse()), {
337
- status: 500,
338
- headers
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
  };