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/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
+ )
94
134
  }
95
135
 
96
- if (available.length === 0) {
97
- return routes[0]
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
- 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
- }
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 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,205 @@ 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" }, { status: 405 }),
152
215
  )
153
216
  }
154
217
 
155
- const route = negotiateRoute(group, accept)
156
- if (!route) {
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
- new Response("Not Acceptable", { status: 406 }),
235
+ Response.json({ status: 406, message: "not acceptable" }, { status: 406 }),
159
236
  )
160
237
  }
161
- const descriptor = Route.descriptor(route)
162
238
 
163
- const context = {
164
- ...descriptor,
165
- request,
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 = route.handler(
169
- context as any,
170
- () => Effect.succeed(undefined),
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 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
- )
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 descriptor = Route.descriptor(route)
201
- const path = descriptor.path
202
- const routes = pathGroups.get(path) ?? []
203
- routes.push(route)
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, toWebHandler(routes)]
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
+ }
@@ -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
- method: "GET",
23
- format: "text",
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
- .toExtend<
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
- void,
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
- void,
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
- void
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
+ })