effect-start 0.15.0 → 0.17.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.
Files changed (46) hide show
  1. package/package.json +2 -1
  2. package/src/ContentNegotiation.test.ts +103 -0
  3. package/src/ContentNegotiation.ts +10 -3
  4. package/src/Development.test.ts +119 -0
  5. package/src/Development.ts +137 -0
  6. package/src/Entity.test.ts +592 -0
  7. package/src/Entity.ts +359 -0
  8. package/src/FileRouter.ts +2 -2
  9. package/src/Http.test.ts +315 -20
  10. package/src/Http.ts +153 -11
  11. package/src/PathPattern.ts +3 -1
  12. package/src/Route.ts +26 -10
  13. package/src/RouteBody.test.ts +98 -66
  14. package/src/RouteBody.ts +125 -35
  15. package/src/RouteHook.ts +15 -14
  16. package/src/RouteHttp.test.ts +2549 -83
  17. package/src/RouteHttp.ts +337 -113
  18. package/src/RouteHttpTracer.ts +92 -0
  19. package/src/RouteMount.test.ts +23 -10
  20. package/src/RouteMount.ts +161 -4
  21. package/src/RouteSchema.test.ts +346 -0
  22. package/src/RouteSchema.ts +386 -7
  23. package/src/RouteSse.test.ts +249 -0
  24. package/src/RouteSse.ts +195 -0
  25. package/src/RouteTree.test.ts +233 -85
  26. package/src/RouteTree.ts +98 -44
  27. package/src/StreamExtra.ts +21 -1
  28. package/src/Values.test.ts +263 -0
  29. package/src/Values.ts +68 -6
  30. package/src/bun/BunBundle.ts +0 -73
  31. package/src/bun/BunHttpServer.ts +23 -7
  32. package/src/bun/BunRoute.test.ts +162 -0
  33. package/src/bun/BunRoute.ts +144 -105
  34. package/src/hyper/HyperHtml.test.ts +119 -0
  35. package/src/hyper/HyperHtml.ts +10 -2
  36. package/src/hyper/HyperNode.ts +2 -0
  37. package/src/hyper/HyperRoute.test.tsx +197 -0
  38. package/src/hyper/HyperRoute.ts +61 -0
  39. package/src/hyper/index.ts +4 -0
  40. package/src/hyper/jsx.d.ts +15 -0
  41. package/src/index.ts +2 -0
  42. package/src/node/FileSystem.ts +8 -0
  43. package/src/testing/TestLogger.test.ts +0 -3
  44. package/src/testing/TestLogger.ts +15 -9
  45. package/src/FileSystemExtra.test.ts +0 -242
  46. package/src/FileSystemExtra.ts +0 -66
package/src/RouteHttp.ts CHANGED
@@ -1,108 +1,173 @@
1
- import * as Array from "effect/Array"
2
1
  import * as Cause from "effect/Cause"
3
2
  import * as Effect from "effect/Effect"
3
+ import * as FiberId from "effect/FiberId"
4
+ import * as FiberRef from "effect/FiberRef"
5
+ import * as HashSet from "effect/HashSet"
6
+ import * as Option from "effect/Option"
7
+ import * as ParseResult from "effect/ParseResult"
4
8
  import * as Runtime from "effect/Runtime"
9
+ import * as Stream from "effect/Stream"
5
10
  import * as ContentNegotiation from "./ContentNegotiation.ts"
11
+ import * as Entity from "./Entity.ts"
6
12
  import * as Http from "./Http.ts"
7
13
  import * as Route from "./Route.ts"
8
14
  import * as RouteBody from "./RouteBody.ts"
15
+ import * as RouteHttpTracer from "./RouteHttpTracer.ts"
9
16
  import * as RouteMount from "./RouteMount.ts"
10
17
  import * as RouteTree from "./RouteTree.ts"
18
+ import * as StreamExtra from "./StreamExtra.ts"
19
+
20
+ export {
21
+ currentSpanNameGenerator,
22
+ currentTracerDisabledWhen,
23
+ parentSpanFromHeaders,
24
+ withSpanNameGenerator,
25
+ withTracerDisabledWhen,
26
+ } from "./RouteHttpTracer.ts"
11
27
 
12
28
  type UnboundedRouteWithMethod = Route.Route.With<{
13
29
  method: RouteMount.RouteMount.Method
14
30
  format?: RouteBody.Format
15
31
  }>
16
32
 
33
+ // Used to match Accept headers against available route formats.
34
+ // text/* matches any text type (text/plain, text/event-stream, text/markdown, etc.)
17
35
  const formatToMediaType = {
18
- text: "text/plain",
36
+ text: "text/*",
19
37
  html: "text/html",
20
38
  json: "application/json",
21
39
  bytes: "application/octet-stream",
22
40
  } as const
23
41
 
24
- const formatToContentType = {
25
- text: "text/plain; charset=utf-8",
26
- html: "text/html; charset=utf-8",
27
- json: "application/json",
28
- bytes: "application/octet-stream",
42
+ // Used after content negotiation to determine which format was selected.
43
+ const mediaTypeToFormat = {
44
+ "text/*": "text",
45
+ "text/html": "html",
46
+ "application/json": "json",
47
+ "application/octet-stream": "bytes",
29
48
  } as const
30
49
 
31
- function toResponse(result: unknown, format?: string): Response {
32
- if (result instanceof Response) {
33
- return result
34
- }
50
+ /**
51
+ * A synthetic fiber used to tag interruptions caused by client disconnects.
52
+ * Number stands for HTTP status 499 "Client Closed Request".
53
+ * This is what @effect/platform does to signal request cancelation.
54
+ */
55
+ export const clientAbortFiberId = FiberId.runtime(-499, 0)
35
56
 
36
- const contentType = format && format in formatToContentType
37
- ? formatToContentType[format]
38
- : typeof result === "string"
39
- ? "text/html; charset=utf-8"
40
- : "application/json"
57
+ const isClientAbort = (cause: Cause.Cause<unknown>): boolean =>
58
+ Cause.isInterruptedOnly(cause)
59
+ && HashSet.some(
60
+ Cause.interruptors(cause),
61
+ (id) => id === clientAbortFiberId,
62
+ )
41
63
 
42
- const body = contentType === "application/json"
43
- ? JSON.stringify(result)
44
- : result
64
+ const getStatusFromCause = (cause: Cause.Cause<unknown>): number => {
65
+ const failure = Cause.failureOption(cause)
45
66
 
46
- return new Response(body as BodyInit, {
47
- headers: { "Content-Type": contentType },
48
- })
67
+ if (failure._tag === "Some") {
68
+ const error = failure.value as { _tag?: string }
69
+ if (error._tag === "ParseError" || error._tag === "RequestBodyError") {
70
+ return 400
71
+ }
72
+ }
73
+
74
+ return 500
49
75
  }
50
76
 
51
- function getMediaType(format: string | undefined): string | undefined {
52
- return format && format in formatToMediaType
53
- ? formatToMediaType[format]
54
- : undefined
77
+ function streamResponse(
78
+ stream: Stream.Stream<unknown, unknown, unknown>,
79
+ headers: Record<string, string | null | undefined>,
80
+ status: number,
81
+ runtime: Runtime.Runtime<any>,
82
+ ): Response {
83
+ const encoder = new TextEncoder()
84
+ const byteStream = (stream as Stream.Stream<unknown, unknown, never>).pipe(
85
+ Stream.map((chunk): Uint8Array =>
86
+ typeof chunk === "string"
87
+ ? encoder.encode(chunk)
88
+ : chunk as Uint8Array
89
+ ),
90
+ Stream.catchAll((error) =>
91
+ Stream.fail(
92
+ error instanceof Error ? error : new Error(String(error)),
93
+ )
94
+ ),
95
+ )
96
+ return new Response(
97
+ Stream.toReadableStreamRuntime(byteStream, runtime),
98
+ { status, headers: headers as Record<string, string> },
99
+ )
55
100
  }
56
101
 
57
- const defaultFormatPriority = ["json", "text", "html", "bytes"] as const
102
+ function toResponse(
103
+ entity: Entity.Entity<any>,
104
+ format: string | undefined,
105
+ runtime: Runtime.Runtime<any>,
106
+ ): Effect.Effect<Response, ParseResult.ParseError> {
107
+ const contentType = Entity.type(entity)
108
+ const status = entity.status ?? 200
109
+ const headers = { ...entity.headers, "content-type": contentType }
58
110
 
59
- function negotiateRoute(
60
- routes: UnboundedRouteWithMethod[],
61
- accept: string | null,
62
- ): UnboundedRouteWithMethod | undefined {
63
- if (routes.length === 1) {
64
- if (!accept) return routes[0]
65
- const format = Route.descriptor(routes[0]).format
66
- const mediaType = getMediaType(format)
67
- if (!mediaType) return routes[0]
68
- const matched = ContentNegotiation.media(accept, [mediaType])
69
- return matched.length > 0 ? routes[0] : undefined
111
+ if (StreamExtra.isStream(entity.body)) {
112
+ return Effect.succeed(streamResponse(entity.body, headers, status, runtime))
70
113
  }
71
114
 
72
- const formatMap = new Map<string, UnboundedRouteWithMethod>()
73
- const available: string[] = []
74
- const mediaTypeMap = new Map<string, UnboundedRouteWithMethod>()
115
+ if (format === "json") {
116
+ return Effect.map(
117
+ entity.json as Effect.Effect<object, ParseResult.ParseError>,
118
+ (data) => new Response(JSON.stringify(data), { status, headers }),
119
+ )
120
+ }
75
121
 
76
- for (const route of routes) {
77
- const format = Route.descriptor(route).format
78
- if (format && !formatMap.has(format)) {
79
- formatMap.set(format, route)
80
- }
81
- const mediaType = getMediaType(format)
82
- if (format && mediaType && !mediaTypeMap.has(mediaType)) {
83
- available.push(mediaType)
84
- mediaTypeMap.set(mediaType, route)
85
- }
122
+ if (format === "text" || format === "html") {
123
+ return Effect.map(
124
+ entity.text as Effect.Effect<string, ParseResult.ParseError>,
125
+ (text) => new Response(text, { status, headers }),
126
+ )
86
127
  }
87
128
 
88
- if (!accept) {
89
- for (const format of defaultFormatPriority) {
90
- const route = formatMap.get(format)
91
- if (route) return route
92
- }
93
- return routes[0]
129
+ if (format === "bytes") {
130
+ return Effect.map(
131
+ entity.bytes as Effect.Effect<Uint8Array, ParseResult.ParseError>,
132
+ (bytes) => new Response(bytes as BodyInit, { status, headers }),
133
+ )
134
+ }
135
+
136
+ return Effect.succeed(
137
+ streamResponse(entity.stream, headers, status, runtime),
138
+ )
139
+ }
140
+
141
+ type Handler = (
142
+ context: any,
143
+ next: (context?: Record<string, unknown>) => Entity.Entity<any, any>,
144
+ ) => Effect.Effect<Entity.Entity<any>, any, any>
145
+
146
+ function determineSelectedFormat(
147
+ accept: string | null,
148
+ routes: UnboundedRouteWithMethod[],
149
+ ): RouteBody.Format | undefined {
150
+ const formats = routes
151
+ .filter((r) => Route.descriptor(r).method !== "*")
152
+ .map((r) => Route.descriptor(r).format)
153
+ .filter((f): f is Exclude<RouteBody.Format, "*"> => Boolean(f) && f !== "*")
154
+
155
+ const uniqueFormats = [...new Set(formats)]
156
+ const mediaTypes = uniqueFormats
157
+ .map((f) => formatToMediaType[f])
158
+ .filter(Boolean)
159
+
160
+ if (mediaTypes.length === 0) {
161
+ return undefined
94
162
  }
95
163
 
96
- if (available.length === 0) {
97
- return routes[0]
164
+ if (!accept) {
165
+ return uniqueFormats[0]
98
166
  }
99
167
 
100
- const preferred = ContentNegotiation.media(accept, available)
101
- if (preferred.length > 0) {
102
- const best = mediaTypeMap.get(preferred[0])
103
- if (best) {
104
- return best
105
- }
168
+ const negotiated = ContentNegotiation.media(accept, mediaTypes)
169
+ if (negotiated.length > 0) {
170
+ return mediaTypeToFormat[negotiated[0]]
106
171
  }
107
172
 
108
173
  return undefined
@@ -111,15 +176,13 @@ function negotiateRoute(
111
176
  export const toWebHandlerRuntime = <R>(
112
177
  runtime: Runtime.Runtime<R>,
113
178
  ) => {
114
- const run = Runtime.runPromise(runtime)
179
+ const runFork = Runtime.runFork(runtime)
115
180
 
116
181
  return (
117
- routes: Iterable<
118
- UnboundedRouteWithMethod
119
- >,
182
+ routes: Iterable<UnboundedRouteWithMethod>,
120
183
  ): Http.WebHandler => {
121
- const grouped = Array.groupBy(
122
- Array.fromIterable(routes),
184
+ const grouped = Object.groupBy(
185
+ routes,
123
186
  (route) => Route.descriptor(route).method?.toUpperCase() ?? "*",
124
187
  )
125
188
  const wildcards = grouped["*"] ?? []
@@ -137,49 +200,221 @@ export const toWebHandlerRuntime = <R>(
137
200
 
138
201
  for (const method in grouped) {
139
202
  if (method !== "*") {
140
- methodGroups[method] = [...wildcards, ...grouped[method]]
203
+ methodGroups[method] = grouped[method]
141
204
  }
142
205
  }
143
206
 
144
207
  return (request) => {
145
208
  const method = request.method.toUpperCase()
146
209
  const accept = request.headers.get("accept")
147
- const group = methodGroups[method]
210
+ const methodRoutes = methodGroups[method] ?? []
148
211
 
149
- if (!group || group.length === 0) {
212
+ if (methodRoutes.length === 0 && wildcards.length === 0) {
150
213
  return Promise.resolve(
151
- new Response("Method Not Allowed", { status: 405 }),
214
+ Response.json({ status: 405, message: "method not allowed" }, {
215
+ status: 405,
216
+ }),
152
217
  )
153
218
  }
154
219
 
155
- const route = negotiateRoute(group, accept)
156
- if (!route) {
220
+ const allRoutes = [...wildcards, ...methodRoutes]
221
+ const selectedFormat = determineSelectedFormat(accept, allRoutes)
222
+
223
+ const hasSpecificFormatRoutes = allRoutes.some((r) => {
224
+ const format = Route.descriptor(r).format
225
+ return format && format !== "*"
226
+ })
227
+ const hasWildcardFormatRoutes = allRoutes.some((r) =>
228
+ Route.descriptor(r).format === "*"
229
+ )
230
+
231
+ if (
232
+ selectedFormat === undefined
233
+ && hasSpecificFormatRoutes
234
+ && !hasWildcardFormatRoutes
235
+ ) {
157
236
  return Promise.resolve(
158
- new Response("Not Acceptable", { status: 406 }),
237
+ Response.json({ status: 406, message: "not acceptable" }, {
238
+ status: 406,
239
+ }),
159
240
  )
160
241
  }
161
- const descriptor = Route.descriptor(route)
162
242
 
163
- const context = {
164
- ...descriptor,
165
- request,
243
+ const createChain = (
244
+ initialContext: any,
245
+ ): Effect.Effect<Entity.Entity<any>, any, any> => {
246
+ let index = 0
247
+ let currentContext = initialContext
248
+ let routePathSet = false
249
+
250
+ const runNext = (
251
+ passedContext?: any,
252
+ ): Effect.Effect<Entity.Entity<any>, any, any> => {
253
+ if (passedContext !== undefined) {
254
+ currentContext = passedContext
255
+ }
256
+
257
+ if (index >= allRoutes.length) {
258
+ return Effect.succeed(
259
+ Entity.make(
260
+ { status: 404, message: "route not found" },
261
+ { status: 404 },
262
+ ),
263
+ )
264
+ }
265
+
266
+ const route = allRoutes[index++]
267
+ const descriptor = Route.descriptor(route)
268
+ const format = descriptor.format
269
+ const handler = route.handler as unknown as Handler
270
+
271
+ if (format && format !== "*" && format !== selectedFormat) {
272
+ return runNext()
273
+ }
274
+
275
+ currentContext = { ...currentContext, ...descriptor }
276
+
277
+ const nextArg = (ctx?: any) =>
278
+ Entity.effect(Effect.suspend(() => runNext(ctx)))
279
+
280
+ const routePath = descriptor["path"]
281
+ if (!routePathSet && routePath !== undefined) {
282
+ routePathSet = true
283
+ return Effect.flatMap(
284
+ Effect.currentSpan.pipe(Effect.option),
285
+ (spanOption) => {
286
+ if (Option.isSome(spanOption)) {
287
+ spanOption.value.attribute("http.route", routePath)
288
+ }
289
+ return handler(currentContext, nextArg)
290
+ },
291
+ )
292
+ }
293
+
294
+ return handler(currentContext, nextArg)
295
+ }
296
+
297
+ return runNext()
166
298
  }
167
299
 
168
- const effect = route.handler(
169
- context as any,
170
- () => Effect.succeed(undefined),
300
+ const effect = Effect.withFiberRuntime<Response, unknown, R>(
301
+ (fiber) => {
302
+ const tracerDisabled =
303
+ !fiber.getFiberRef(FiberRef.currentTracerEnabled)
304
+ || fiber.getFiberRef(RouteHttpTracer.currentTracerDisabledWhen)(
305
+ request,
306
+ )
307
+
308
+ const url = new URL(request.url)
309
+
310
+ const innerEffect = Effect.gen(function*() {
311
+ const result = yield* createChain({ request, selectedFormat })
312
+
313
+ const entity = Entity.isEntity(result)
314
+ ? result
315
+ : Entity.make(result, { status: 200 })
316
+
317
+ if (entity.status === 404 && entity.body === undefined) {
318
+ return Response.json({ status: 406, message: "not acceptable" }, {
319
+ status: 406,
320
+ })
321
+ }
322
+
323
+ return yield* toResponse(entity, selectedFormat, runtime)
324
+ })
325
+
326
+ if (tracerDisabled) {
327
+ return innerEffect
328
+ }
329
+
330
+ const spanNameGenerator = fiber.getFiberRef(
331
+ RouteHttpTracer.currentSpanNameGenerator,
332
+ )
333
+
334
+ return Effect.useSpan(
335
+ spanNameGenerator(request),
336
+ {
337
+ parent: Option.getOrUndefined(
338
+ RouteHttpTracer.parentSpanFromHeaders(request.headers),
339
+ ),
340
+ kind: "server",
341
+ captureStackTrace: false,
342
+ },
343
+ (span) => {
344
+ span.attribute("http.request.method", request.method)
345
+ span.attribute("url.full", url.toString())
346
+ span.attribute("url.path", url.pathname)
347
+ const query = url.search.slice(1)
348
+ if (query !== "") {
349
+ span.attribute("url.query", query)
350
+ }
351
+ span.attribute("url.scheme", url.protocol.slice(0, -1))
352
+
353
+ const userAgent = request.headers.get("user-agent")
354
+ if (userAgent !== null) {
355
+ span.attribute("user_agent.original", userAgent)
356
+ }
357
+
358
+ return Effect.flatMap(
359
+ Effect.exit(Effect.withParentSpan(innerEffect, span)),
360
+ (exit) => {
361
+ if (exit._tag === "Success") {
362
+ span.attribute(
363
+ "http.response.status_code",
364
+ exit.value.status,
365
+ )
366
+ }
367
+ return exit
368
+ },
369
+ )
370
+ },
371
+ )
372
+ },
171
373
  )
172
374
 
173
- return run(
174
- effect.pipe(
175
- Effect.map((result) => toResponse(result, descriptor.format)),
176
- Effect.catchAllCause((cause) =>
177
- Effect.succeed(
178
- new Response(Cause.pretty(cause), { status: 500 }),
179
- )
375
+ return new Promise((resolve) => {
376
+ const fiber = runFork(
377
+ effect.pipe(
378
+ Effect.scoped,
379
+ Effect.catchAllCause((cause) =>
380
+ Effect.gen(function*() {
381
+ yield* Effect.logError(cause)
382
+ const status = getStatusFromCause(cause)
383
+ return Response.json({ status, message: Cause.pretty(cause) }, {
384
+ status,
385
+ })
386
+ })
387
+ ),
180
388
  ),
181
- ),
182
- )
389
+ )
390
+
391
+ request.signal?.addEventListener(
392
+ "abort",
393
+ () => {
394
+ fiber.unsafeInterruptAsFork(clientAbortFiberId)
395
+ },
396
+ { once: true },
397
+ )
398
+
399
+ fiber.addObserver((exit) => {
400
+ if (exit._tag === "Success") {
401
+ resolve(exit.value)
402
+ } else if (isClientAbort(exit.cause)) {
403
+ resolve(
404
+ Response.json({ status: 499, message: "client closed request" }, {
405
+ status: 499,
406
+ }),
407
+ )
408
+ } else {
409
+ const status = getStatusFromCause(exit.cause)
410
+ resolve(
411
+ Response.json({ status, message: Cause.pretty(exit.cause) }, {
412
+ status,
413
+ }),
414
+ )
415
+ }
416
+ })
417
+ })
183
418
  }
184
419
  }
185
420
  }
@@ -190,30 +425,19 @@ export const toWebHandler: (
190
425
 
191
426
  export function* walkHandles(
192
427
  tree: RouteTree.RouteTree,
428
+ runtime: Runtime.Runtime<never> = Runtime.defaultRuntime,
193
429
  ): Generator<[path: string, handler: Http.WebHandler]> {
194
- const pathGroups = new Map<
195
- string,
196
- Array<Route.Route.With<{ path: string; method: string }>>
197
- >()
430
+ const pathGroups = new Map<string, RouteMount.MountedRoute[]>()
198
431
 
199
432
  for (const route of RouteTree.walk(tree)) {
200
- const descriptor = Route.descriptor(route)
201
- const path = descriptor.path
202
- const routes = pathGroups.get(path) ?? []
203
- routes.push(route)
204
- pathGroups.set(path, routes)
433
+ const path = Route.descriptor(route).path
434
+ const group = pathGroups.get(path) ?? []
435
+ group.push(route)
436
+ pathGroups.set(path, group)
205
437
  }
206
438
 
439
+ const toHandler = toWebHandlerRuntime(runtime)
207
440
  for (const [path, routes] of pathGroups) {
208
- yield [path, toWebHandler(routes)]
441
+ yield [path, toHandler(routes as Iterable<UnboundedRouteWithMethod>)]
209
442
  }
210
443
  }
211
-
212
- export function fetch(
213
- handle: Http.WebHandler,
214
- init: RequestInit & ({ url: string } | { path: string }),
215
- ): Promise<Response> {
216
- const url = "path" in init ? `http://localhost${init.path}` : init.url
217
- const request = new Request(url, init)
218
- return Promise.resolve(handle(request))
219
- }
@@ -0,0 +1,92 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as FiberRef from "effect/FiberRef"
3
+ import * as GlobalValue from "effect/GlobalValue"
4
+ import * as Option from "effect/Option"
5
+ import type * as Predicate from "effect/Predicate"
6
+ import * as Tracer from "effect/Tracer"
7
+
8
+ export const currentTracerDisabledWhen = GlobalValue.globalValue(
9
+ Symbol.for("effect-start/RouteHttp/tracerDisabledWhen"),
10
+ () => FiberRef.unsafeMake<Predicate.Predicate<Request>>(() => false),
11
+ )
12
+
13
+ export const withTracerDisabledWhen = <A, E, R>(
14
+ effect: Effect.Effect<A, E, R>,
15
+ predicate: Predicate.Predicate<Request>,
16
+ ): Effect.Effect<A, E, R> =>
17
+ Effect.locally(effect, currentTracerDisabledWhen, predicate)
18
+
19
+ export const currentSpanNameGenerator = GlobalValue.globalValue(
20
+ Symbol.for("effect-start/RouteHttp/spanNameGenerator"),
21
+ () =>
22
+ FiberRef.unsafeMake<(request: Request) => string>(
23
+ (request) => `http.server ${request.method}`,
24
+ ),
25
+ )
26
+
27
+ export const withSpanNameGenerator = <A, E, R>(
28
+ effect: Effect.Effect<A, E, R>,
29
+ f: (request: Request) => string,
30
+ ): Effect.Effect<A, E, R> => Effect.locally(effect, currentSpanNameGenerator, f)
31
+
32
+ const w3cTraceparent = (
33
+ headers: Headers,
34
+ ): Option.Option<Tracer.ExternalSpan> => {
35
+ const header = headers.get("traceparent")
36
+ if (header === null) return Option.none()
37
+
38
+ const parts = header.split("-")
39
+ if (parts.length < 4) return Option.none()
40
+
41
+ const [_version, traceId, spanId, flags] = parts
42
+ if (!traceId || !spanId) return Option.none()
43
+
44
+ return Option.some(Tracer.externalSpan({
45
+ spanId,
46
+ traceId,
47
+ sampled: flags === "01",
48
+ }))
49
+ }
50
+
51
+ const b3Single = (headers: Headers): Option.Option<Tracer.ExternalSpan> => {
52
+ const header = headers.get("b3")
53
+ if (header === null) return Option.none()
54
+
55
+ const parts = header.split("-")
56
+ if (parts.length < 2) return Option.none()
57
+
58
+ const [traceId, spanId, sampledStr] = parts
59
+ if (!traceId || !spanId) return Option.none()
60
+
61
+ return Option.some(Tracer.externalSpan({
62
+ spanId,
63
+ traceId,
64
+ sampled: sampledStr === "1",
65
+ }))
66
+ }
67
+
68
+ const xb3 = (headers: Headers): Option.Option<Tracer.ExternalSpan> => {
69
+ const traceId = headers.get("x-b3-traceid")
70
+ const spanId = headers.get("x-b3-spanid")
71
+ if (traceId === null || spanId === null) return Option.none()
72
+
73
+ const sampled = headers.get("x-b3-sampled")
74
+
75
+ return Option.some(Tracer.externalSpan({
76
+ spanId,
77
+ traceId,
78
+ sampled: sampled === "1",
79
+ }))
80
+ }
81
+
82
+ export const parentSpanFromHeaders = (
83
+ headers: Headers,
84
+ ): Option.Option<Tracer.ExternalSpan> => {
85
+ let span = w3cTraceparent(headers)
86
+ if (span._tag === "Some") return span
87
+
88
+ span = b3Single(headers)
89
+ if (span._tag === "Some") return span
90
+
91
+ return xb3(headers)
92
+ }