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.
- package/package.json +2 -1
- package/src/ContentNegotiation.test.ts +103 -0
- package/src/ContentNegotiation.ts +10 -3
- package/src/Development.test.ts +119 -0
- package/src/Development.ts +137 -0
- package/src/Entity.test.ts +592 -0
- package/src/Entity.ts +359 -0
- package/src/FileRouter.ts +2 -2
- package/src/Http.test.ts +315 -20
- package/src/Http.ts +153 -11
- package/src/PathPattern.ts +3 -1
- package/src/Route.ts +26 -10
- package/src/RouteBody.test.ts +98 -66
- package/src/RouteBody.ts +125 -35
- package/src/RouteHook.ts +15 -14
- package/src/RouteHttp.test.ts +2549 -83
- package/src/RouteHttp.ts +337 -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/RouteSse.test.ts +249 -0
- package/src/RouteSse.ts +195 -0
- 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 +68 -6
- package/src/bun/BunBundle.ts +0 -73
- package/src/bun/BunHttpServer.ts +23 -7
- package/src/bun/BunRoute.test.ts +162 -0
- package/src/bun/BunRoute.ts +144 -105
- package/src/hyper/HyperHtml.test.ts +119 -0
- package/src/hyper/HyperHtml.ts +10 -2
- package/src/hyper/HyperNode.ts +2 -0
- package/src/hyper/HyperRoute.test.tsx +197 -0
- package/src/hyper/HyperRoute.ts +61 -0
- package/src/hyper/index.ts +4 -0
- package/src/hyper/jsx.d.ts +15 -0
- package/src/index.ts +2 -0
- package/src/node/FileSystem.ts +8 -0
- package/src/testing/TestLogger.test.ts +0 -3
- package/src/testing/TestLogger.ts +15 -9
- package/src/FileSystemExtra.test.ts +0 -242
- 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
|
|
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
|
-
|
|
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 (
|
|
97
|
-
return
|
|
164
|
+
if (!accept) {
|
|
165
|
+
return uniqueFormats[0]
|
|
98
166
|
}
|
|
99
167
|
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
102
|
-
|
|
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
|
|
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,221 @@ 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" }, {
|
|
215
|
+
status: 405,
|
|
216
|
+
}),
|
|
152
217
|
)
|
|
153
218
|
}
|
|
154
219
|
|
|
155
|
-
const
|
|
156
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
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 =
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
Effect.
|
|
178
|
-
|
|
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
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
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,
|
|
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
|
+
}
|