effect-start 0.15.0 → 0.16.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/package.json +1 -1
- package/src/ContentNegotiation.test.ts +103 -0
- package/src/ContentNegotiation.ts +10 -3
- package/src/Entity.test.ts +592 -0
- package/src/Entity.ts +362 -0
- package/src/Http.test.ts +315 -20
- package/src/Http.ts +153 -11
- package/src/PathPattern.ts +3 -1
- package/src/Route.ts +22 -10
- package/src/RouteBody.test.ts +81 -29
- package/src/RouteBody.ts +122 -35
- package/src/RouteHook.ts +15 -14
- package/src/RouteHttp.test.ts +2546 -83
- package/src/RouteHttp.ts +321 -113
- package/src/RouteHttpTracer.ts +92 -0
- package/src/RouteMount.test.ts +23 -10
- package/src/RouteMount.ts +161 -4
- package/src/RouteSchema.test.ts +346 -0
- package/src/RouteSchema.ts +386 -7
- package/src/RouteTree.test.ts +233 -85
- package/src/RouteTree.ts +98 -44
- package/src/StreamExtra.ts +21 -1
- package/src/Values.test.ts +263 -0
- package/src/Values.ts +60 -0
- package/src/bun/BunHttpServer.ts +23 -7
- package/src/bun/BunRoute.test.ts +162 -0
- package/src/bun/BunRoute.ts +146 -105
- package/src/index.ts +1 -0
- package/src/testing/TestLogger.test.ts +0 -3
- package/src/testing/TestLogger.ts +15 -9
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
: result
|
|
64
|
+
const getStatusFromCause = (cause: Cause.Cause<unknown>): number => {
|
|
65
|
+
const failure = Cause.failureOption(cause)
|
|
45
66
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
)
|
|
94
134
|
}
|
|
95
135
|
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
98
162
|
}
|
|
99
163
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
164
|
+
if (!accept) {
|
|
165
|
+
return uniqueFormats[0]
|
|
166
|
+
}
|
|
167
|
+
|
|
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
|
|
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 =
|
|
122
|
-
|
|
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,205 @@ export const toWebHandlerRuntime = <R>(
|
|
|
137
200
|
|
|
138
201
|
for (const method in grouped) {
|
|
139
202
|
if (method !== "*") {
|
|
140
|
-
methodGroups[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
|
|
210
|
+
const methodRoutes = methodGroups[method] ?? []
|
|
148
211
|
|
|
149
|
-
if (
|
|
212
|
+
if (methodRoutes.length === 0 && wildcards.length === 0) {
|
|
150
213
|
return Promise.resolve(
|
|
151
|
-
|
|
214
|
+
Response.json({ status: 405, message: "method not allowed" }, { status: 405 }),
|
|
152
215
|
)
|
|
153
216
|
}
|
|
154
217
|
|
|
155
|
-
const
|
|
156
|
-
|
|
218
|
+
const allRoutes = [...wildcards, ...methodRoutes]
|
|
219
|
+
const selectedFormat = determineSelectedFormat(accept, allRoutes)
|
|
220
|
+
|
|
221
|
+
const hasSpecificFormatRoutes = allRoutes.some((r) => {
|
|
222
|
+
const format = Route.descriptor(r).format
|
|
223
|
+
return format && format !== "*"
|
|
224
|
+
})
|
|
225
|
+
const hasWildcardFormatRoutes = allRoutes.some((r) =>
|
|
226
|
+
Route.descriptor(r).format === "*"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if (
|
|
230
|
+
selectedFormat === undefined
|
|
231
|
+
&& hasSpecificFormatRoutes
|
|
232
|
+
&& !hasWildcardFormatRoutes
|
|
233
|
+
) {
|
|
157
234
|
return Promise.resolve(
|
|
158
|
-
|
|
235
|
+
Response.json({ status: 406, message: "not acceptable" }, { status: 406 }),
|
|
159
236
|
)
|
|
160
237
|
}
|
|
161
|
-
const descriptor = Route.descriptor(route)
|
|
162
238
|
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
239
|
+
const createChain = (
|
|
240
|
+
initialContext: any,
|
|
241
|
+
): Effect.Effect<Entity.Entity<any>, any, any> => {
|
|
242
|
+
let index = 0
|
|
243
|
+
let currentContext = initialContext
|
|
244
|
+
let routePathSet = false
|
|
245
|
+
|
|
246
|
+
const runNext = (
|
|
247
|
+
passedContext?: any,
|
|
248
|
+
): Effect.Effect<Entity.Entity<any>, any, any> => {
|
|
249
|
+
if (passedContext !== undefined) {
|
|
250
|
+
currentContext = passedContext
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (index >= allRoutes.length) {
|
|
254
|
+
return Effect.succeed(
|
|
255
|
+
Entity.make(
|
|
256
|
+
{ status: 404, message: "route not found" },
|
|
257
|
+
{ status: 404 },
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const route = allRoutes[index++]
|
|
263
|
+
const descriptor = Route.descriptor(route)
|
|
264
|
+
const format = descriptor.format
|
|
265
|
+
const handler = route.handler as unknown as Handler
|
|
266
|
+
|
|
267
|
+
if (format && format !== "*" && format !== selectedFormat) {
|
|
268
|
+
return runNext()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
currentContext = { ...currentContext, ...descriptor }
|
|
272
|
+
|
|
273
|
+
const nextArg = (ctx?: any) =>
|
|
274
|
+
Entity.effect(Effect.suspend(() => runNext(ctx)))
|
|
275
|
+
|
|
276
|
+
const routePath = descriptor["path"]
|
|
277
|
+
if (!routePathSet && routePath !== undefined) {
|
|
278
|
+
routePathSet = true
|
|
279
|
+
return Effect.flatMap(
|
|
280
|
+
Effect.currentSpan.pipe(Effect.option),
|
|
281
|
+
(spanOption) => {
|
|
282
|
+
if (Option.isSome(spanOption)) {
|
|
283
|
+
spanOption.value.attribute("http.route", routePath)
|
|
284
|
+
}
|
|
285
|
+
return handler(currentContext, nextArg)
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return handler(currentContext, nextArg)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return runNext()
|
|
166
294
|
}
|
|
167
295
|
|
|
168
|
-
const effect =
|
|
169
|
-
|
|
170
|
-
|
|
296
|
+
const effect = Effect.withFiberRuntime<Response, unknown, R>(
|
|
297
|
+
(fiber) => {
|
|
298
|
+
const tracerDisabled =
|
|
299
|
+
!fiber.getFiberRef(FiberRef.currentTracerEnabled)
|
|
300
|
+
|| fiber.getFiberRef(RouteHttpTracer.currentTracerDisabledWhen)(
|
|
301
|
+
request,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
const url = new URL(request.url)
|
|
305
|
+
|
|
306
|
+
const innerEffect = Effect.gen(function*() {
|
|
307
|
+
const result = yield* createChain({ request, selectedFormat })
|
|
308
|
+
|
|
309
|
+
const entity = Entity.isEntity(result)
|
|
310
|
+
? result
|
|
311
|
+
: Entity.make(result, { status: 200 })
|
|
312
|
+
|
|
313
|
+
if (entity.status === 404 && entity.body === undefined) {
|
|
314
|
+
return Response.json({ status: 406, message: "not acceptable" }, { status: 406 })
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return yield* toResponse(entity, selectedFormat, runtime)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
if (tracerDisabled) {
|
|
321
|
+
return innerEffect
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const spanNameGenerator = fiber.getFiberRef(
|
|
325
|
+
RouteHttpTracer.currentSpanNameGenerator,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return Effect.useSpan(
|
|
329
|
+
spanNameGenerator(request),
|
|
330
|
+
{
|
|
331
|
+
parent: Option.getOrUndefined(
|
|
332
|
+
RouteHttpTracer.parentSpanFromHeaders(request.headers),
|
|
333
|
+
),
|
|
334
|
+
kind: "server",
|
|
335
|
+
captureStackTrace: false,
|
|
336
|
+
},
|
|
337
|
+
(span) => {
|
|
338
|
+
span.attribute("http.request.method", request.method)
|
|
339
|
+
span.attribute("url.full", url.toString())
|
|
340
|
+
span.attribute("url.path", url.pathname)
|
|
341
|
+
const query = url.search.slice(1)
|
|
342
|
+
if (query !== "") {
|
|
343
|
+
span.attribute("url.query", query)
|
|
344
|
+
}
|
|
345
|
+
span.attribute("url.scheme", url.protocol.slice(0, -1))
|
|
346
|
+
|
|
347
|
+
const userAgent = request.headers.get("user-agent")
|
|
348
|
+
if (userAgent !== null) {
|
|
349
|
+
span.attribute("user_agent.original", userAgent)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return Effect.flatMap(
|
|
353
|
+
Effect.exit(Effect.withParentSpan(innerEffect, span)),
|
|
354
|
+
(exit) => {
|
|
355
|
+
if (exit._tag === "Success") {
|
|
356
|
+
span.attribute(
|
|
357
|
+
"http.response.status_code",
|
|
358
|
+
exit.value.status,
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
return exit
|
|
362
|
+
},
|
|
363
|
+
)
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
},
|
|
171
367
|
)
|
|
172
368
|
|
|
173
|
-
return
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
Effect.
|
|
178
|
-
|
|
179
|
-
|
|
369
|
+
return new Promise((resolve) => {
|
|
370
|
+
const fiber = runFork(
|
|
371
|
+
effect.pipe(
|
|
372
|
+
Effect.scoped,
|
|
373
|
+
Effect.catchAllCause((cause) =>
|
|
374
|
+
Effect.gen(function*() {
|
|
375
|
+
yield* Effect.logError(cause)
|
|
376
|
+
const status = getStatusFromCause(cause)
|
|
377
|
+
return Response.json({ status, message: Cause.pretty(cause) }, { status })
|
|
378
|
+
})
|
|
379
|
+
),
|
|
180
380
|
),
|
|
181
|
-
)
|
|
182
|
-
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
request.signal?.addEventListener(
|
|
384
|
+
"abort",
|
|
385
|
+
() => {
|
|
386
|
+
fiber.unsafeInterruptAsFork(clientAbortFiberId)
|
|
387
|
+
},
|
|
388
|
+
{ once: true },
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
fiber.addObserver((exit) => {
|
|
392
|
+
if (exit._tag === "Success") {
|
|
393
|
+
resolve(exit.value)
|
|
394
|
+
} else if (isClientAbort(exit.cause)) {
|
|
395
|
+
resolve(Response.json({ status: 499, message: "client closed request" }, { status: 499 }))
|
|
396
|
+
} else {
|
|
397
|
+
const status = getStatusFromCause(exit.cause)
|
|
398
|
+
resolve(Response.json({ status, message: Cause.pretty(exit.cause) }, { status }))
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
})
|
|
183
402
|
}
|
|
184
403
|
}
|
|
185
404
|
}
|
|
@@ -190,30 +409,19 @@ export const toWebHandler: (
|
|
|
190
409
|
|
|
191
410
|
export function* walkHandles(
|
|
192
411
|
tree: RouteTree.RouteTree,
|
|
412
|
+
runtime: Runtime.Runtime<never> = Runtime.defaultRuntime,
|
|
193
413
|
): Generator<[path: string, handler: Http.WebHandler]> {
|
|
194
|
-
const pathGroups = new Map<
|
|
195
|
-
string,
|
|
196
|
-
Array<Route.Route.With<{ path: string; method: string }>>
|
|
197
|
-
>()
|
|
414
|
+
const pathGroups = new Map<string, RouteMount.MountedRoute[]>()
|
|
198
415
|
|
|
199
416
|
for (const route of RouteTree.walk(tree)) {
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
pathGroups.set(path, routes)
|
|
417
|
+
const path = Route.descriptor(route).path
|
|
418
|
+
const group = pathGroups.get(path) ?? []
|
|
419
|
+
group.push(route)
|
|
420
|
+
pathGroups.set(path, group)
|
|
205
421
|
}
|
|
206
422
|
|
|
423
|
+
const toHandler = toWebHandlerRuntime(runtime)
|
|
207
424
|
for (const [path, routes] of pathGroups) {
|
|
208
|
-
yield [path,
|
|
425
|
+
yield [path, toHandler(routes as Iterable<UnboundedRouteWithMethod>)]
|
|
209
426
|
}
|
|
210
427
|
}
|
|
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
|
+
}
|
package/src/RouteMount.test.ts
CHANGED
|
@@ -15,13 +15,14 @@ test.it("uses GET method", async () => {
|
|
|
15
15
|
.toMatchObjectType<{
|
|
16
16
|
method: "GET"
|
|
17
17
|
format: "text"
|
|
18
|
+
request: Request
|
|
18
19
|
}>()
|
|
19
20
|
test
|
|
20
|
-
.expect(context)
|
|
21
|
-
.toEqual(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
.expect(context.method)
|
|
22
|
+
.toEqual("GET")
|
|
23
|
+
test
|
|
24
|
+
.expect(context.format)
|
|
25
|
+
.toEqual("text")
|
|
25
26
|
|
|
26
27
|
return "Hello, World!"
|
|
27
28
|
})
|
|
@@ -30,7 +31,7 @@ test.it("uses GET method", async () => {
|
|
|
30
31
|
|
|
31
32
|
test
|
|
32
33
|
.expectTypeOf(route)
|
|
33
|
-
.
|
|
34
|
+
.toMatchTypeOf<
|
|
34
35
|
Route.RouteSet.RouteSet<
|
|
35
36
|
{},
|
|
36
37
|
{},
|
|
@@ -388,7 +389,7 @@ test.it("schemaHeaders flattens method into route descriptor", () => {
|
|
|
388
389
|
readonly hello: string
|
|
389
390
|
}
|
|
390
391
|
},
|
|
391
|
-
|
|
392
|
+
unknown,
|
|
392
393
|
ParseResult.ParseError,
|
|
393
394
|
HttpServerRequest.HttpServerRequest
|
|
394
395
|
>
|
|
@@ -406,7 +407,7 @@ test.it("schemaHeaders flattens method into route descriptor", () => {
|
|
|
406
407
|
readonly "x-custom-header": string
|
|
407
408
|
}
|
|
408
409
|
},
|
|
409
|
-
|
|
410
|
+
unknown,
|
|
410
411
|
ParseResult.ParseError,
|
|
411
412
|
HttpServerRequest.HttpServerRequest
|
|
412
413
|
>
|
|
@@ -439,11 +440,11 @@ test.it("schemaHeaders flattens method into route descriptor", () => {
|
|
|
439
440
|
{ method: "POST" },
|
|
440
441
|
{
|
|
441
442
|
headers: {
|
|
442
|
-
hello: string
|
|
443
|
+
readonly hello: string
|
|
443
444
|
}
|
|
444
445
|
postOnly: string
|
|
445
446
|
},
|
|
446
|
-
|
|
447
|
+
unknown
|
|
447
448
|
>
|
|
448
449
|
>()
|
|
449
450
|
|
|
@@ -466,3 +467,15 @@ test.it("schemaHeaders flattens method into route descriptor", () => {
|
|
|
466
467
|
postOnly: string
|
|
467
468
|
}>()
|
|
468
469
|
})
|
|
470
|
+
|
|
471
|
+
test.it("provides request in default bindings", () => {
|
|
472
|
+
Route.get(
|
|
473
|
+
Route.text((ctx) => {
|
|
474
|
+
test
|
|
475
|
+
.expectTypeOf(ctx.request)
|
|
476
|
+
.toEqualTypeOf<Request>()
|
|
477
|
+
|
|
478
|
+
return Effect.succeed("ok")
|
|
479
|
+
}),
|
|
480
|
+
)
|
|
481
|
+
})
|