1import * as DenoHttpServer from "@effect/platform-deno/DenoHttpServer"
2import { assert, describe, it } from "@effect/vitest"
3import * as ByteSize from "effect/ByteSize"
4import * as Duration from "effect/Duration"
5import * as Effect from "effect/Effect"
6import * as Fiber from "effect/Fiber"
7import * as HttpApiError from "effect/http-api/HttpApiError"
8import * as Cookies from "effect/http/Cookies"
9import * as Etag from "effect/http/Etag"
10import * as FetchHttpClient from "effect/http/FetchHttpClient"
11import * as HttpBody from "effect/http/HttpBody"
12import * as HttpClient from "effect/http/HttpClient"
13import * as HttpClientRequest from "effect/http/HttpClientRequest"
14import * as HttpClientResponse from "effect/http/HttpClientResponse"
15import * as HttpPlatform from "effect/http/HttpPlatform"
16import * as HttpRouter from "effect/http/HttpRouter"
17import * as HttpServer from "effect/http/HttpServer"
18import * as HttpServerRequest from "effect/http/HttpServerRequest"
19import * as HttpServerRespondable from "effect/http/HttpServerRespondable"
20import * as HttpServerResponse from "effect/http/HttpServerResponse"
21import * as Multipart from "effect/http/Multipart"
22import * as UrlParams from "effect/http/UrlParams"
23import * as Latch from "effect/Latch"
24import * as Layer from "effect/Layer"
25import * as ManagedRuntime from "effect/ManagedRuntime"
26import type * as NetAddress from "effect/net/NetAddress"
27import * as Queue from "effect/Queue"
28import * as Schema from "effect/Schema"
29import type * as Scope from "effect/Scope"
30import type * as Socket from "effect/socket/Socket"
31import * as Stream from "effect/Stream"
32import * as Tracer from "effect/Tracer"
33
34const Todo = Schema.Struct({
35 id: Schema.Number,
36 title: Schema.String
37})
38const IdParams = Schema.Struct({
39 id: Schema.FiniteFromString
40})
41const todoResponse = HttpServerResponse.schemaJson(Todo)
42const fixture = `${import.meta.dirname}/fixtures/text.txt`
43
44describe("DenoHttpServer", () => {
45 describe("body omission", () => {
46 for (const status of [204, 205, 304]) {
47 for (const bodyKind of ["text", "stream"]) {
48 it.live(`omits ${bodyKind} bodies for status ${status}`, () =>
49 Effect.gen(function*() {
50 let finalized = false
51 let streamStarted = false
52 yield* HttpServer.serveEffect(Effect.gen(function*() {
53 yield* Effect.addFinalizer(() =>
54 Effect.sync(() => {
55 finalized = true
56 })
57 )
58 const options = { status, contentType: "text/plain", contentLength: 4 }
59 return bodyKind === "text"
60 ? HttpServerResponse.text("body", options)
61 : HttpServerResponse.stream(
62 Stream.fromEffect(Effect.sync(() => {
63 streamStarted = true
64 return new TextEncoder().encode("body")
65 })),
66 options
67 )
68 }))
69 const response = yield* HttpClient.get("/")
70 assert.strictEqual(response.status, status)
71 assert.strictEqual(yield* response.text, "")
72 assert.strictEqual(streamStarted, false)
73 assert.strictEqual(finalized, true)
74 if (status === 304) {
75 assert.strictEqual(response.headers["content-type"], "text/plain")
76 }
77 }).pipe(
78 Effect.timeout("2 seconds"),
79 Effect.provide(DenoHttpServer.layerTest)
80 ), 5000)
81 }
82 }
83
84 for (const [method, status] of [["HEAD", 200], ["GET", 204], ["GET", 205], ["GET", 304]] as const) {
85 it.live(`cancels raw streams for ${method} status ${status}`, () =>
86 Effect.gen(function*() {
87 let cancelled = false
88 const body = new ReadableStream<Uint8Array>({
89 pull(controller) {
90 controller.enqueue(new TextEncoder().encode("body"))
91 controller.close()
92 },
93 cancel() {
94 cancelled = true
95 }
96 }, { highWaterMark: 0 })
97 yield* Effect.addFinalizer(() => Effect.ignoreCause(Effect.promise(() => body.cancel())))
98 yield* HttpServer.serveEffect(Effect.succeed(HttpServerResponse.raw(body, {
99 status,
100 contentType: "text/plain",
101 contentLength: 4
102 })))
103 const response = yield* (method === "HEAD" ? HttpClient.head("/") : HttpClient.get("/"))
104 assert.strictEqual(response.status, status)
105 assert.strictEqual(yield* response.text, "")
106 assert.strictEqual(cancelled, true)
107 if (method === "HEAD") {
108 assert.strictEqual(response.headers["content-length"], "4")
109 }
110 if (method === "HEAD" || status === 304) {
111 assert.strictEqual(response.headers["content-type"], "text/plain")
112 }
113 }).pipe(
114 Effect.timeout("2 seconds"),
115 Effect.provide(DenoHttpServer.layerTest)
116 ), 5000)
117 }
118 })
119
120 it.effect("schema", () =>
121 Effect.gen(function*() {
122 yield* HttpRouter.add(
123 "GET",
124 "/todos/:id",
125 Effect.flatMap(HttpRouter.schemaParams(IdParams), ({ id }) => todoResponse({ id, title: "test" }))
126 ).pipe(HttpRouter.serve, Layer.build)
127 const todo = yield* HttpClient.get("/todos/1").pipe(
128 Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
129 )
130 assert.deepStrictEqual(todo, { id: 1, title: "test" })
131 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
132
133 it.effect("exports a weak ETag generator", () =>
134 Effect.gen(function*() {
135 const generator = yield* Etag.Generator
136 const etag = yield* generator.fromFileWeb(new File(["test"], "test.txt", { lastModified: 0 }))
137 assert.strictEqual(etag._tag, "Weak")
138 }).pipe(Effect.provide(DenoHttpServer.layerHttpServices)))
139
140 it.effect("formData", () =>
141 Effect.gen(function*() {
142 yield* HttpRouter.add(
143 "POST",
144 "/upload",
145 Effect.gen(function*() {
146 const request = yield* HttpServerRequest.HttpServerRequest
147 const formData = yield* request.multipart
148 const part = formData.file
149 assert(typeof part !== "string")
150 const file = part[0]
151 assert(typeof file !== "string")
152 assert(file.path.endsWith("/test.txt"))
153 assert.strictEqual(file.contentType, "text/plain")
154 assert.strictEqual(yield* Effect.promise(() => Deno.readTextFile(file.path)), "test")
155 return yield* HttpServerResponse.json({ ok: "file" in formData })
156 })
157 ).pipe(HttpRouter.serve, Layer.build)
158 const formData = new FormData()
159 formData.append("file", new Blob(["test"], { type: "text/plain" }), "test.txt")
160 const response = yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) })
161 assert.deepStrictEqual(yield* response.json, { ok: true })
162 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
163
164 it.effect("multipartStream", () =>
165 Effect.gen(function*() {
166 yield* HttpRouter.add(
167 "POST",
168 "/upload",
169 Effect.gen(function*() {
170 const request = yield* HttpServerRequest.HttpServerRequest
171 const parts = yield* Stream.runCollect(request.multipartStream)
172 assert.strictEqual(parts.length, 2)
173 const field = parts[0]
174 const file = parts[1]
175 assert(Multipart.isField(field))
176 assert.deepStrictEqual({ key: field.key, value: field.value }, { key: "name", value: "value" })
177 assert(Multipart.isFile(file))
178 assert.deepStrictEqual(
179 { key: file.key, name: file.name, contentType: file.contentType },
180 { key: "file", name: "test.txt", contentType: "text/plain" }
181 )
182 assert.strictEqual(new TextDecoder().decode(yield* file.contentEffect), "test")
183 return HttpServerResponse.empty()
184 })
185 ).pipe(HttpRouter.serve, Layer.build)
186 const formData = new FormData()
187 formData.append("name", "value")
188 formData.append("file", new Blob(["test"], { type: "text/plain" }), "test.txt")
189 const response = yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) })
190 assert.strictEqual(response.status, 204)
191 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
192
193 it.effect("schemaBodyForm", () =>
194 Effect.gen(function*() {
195 yield* HttpRouter.add(
196 "POST",
197 "/upload",
198 Effect.gen(function*() {
199 const files = yield* HttpServerRequest.schemaBodyForm(Schema.Struct({
200 file: Multipart.FilesSchema,
201 test: Schema.String
202 }))
203 assert("file" in files)
204 assert.strictEqual(files.test, "test")
205 return HttpServerResponse.empty()
206 })
207 ).pipe(HttpRouter.serve, Layer.build)
208 const formData = new FormData()
209 formData.append("file", new Blob(["test"], { type: "text/plain" }), "test.txt")
210 formData.append("test", "test")
211 const response = yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) })
212 assert.strictEqual(response.status, 204)
213 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
214
215 it.effect("formData withMaxFileSize", () =>
216 Effect.gen(function*() {
217 yield* HttpRouter.add(
218 "POST",
219 "/upload",
220 Effect.gen(function*() {
221 const request = yield* HttpServerRequest.HttpServerRequest
222 yield* request.multipart
223 return HttpServerResponse.empty()
224 }).pipe(Effect.catchTag("MultipartError", (error) =>
225 error.reason._tag === "FileTooLarge"
226 ? Effect.succeed(HttpServerResponse.empty({ status: 413 }))
227 : Effect.fail(error)))
228 ).pipe(
229 HttpRouter.serve,
230 Layer.build,
231 Effect.provideService(Multipart.MaxFileSize, ByteSize.bytes(100))
232 )
233 const formData = new FormData()
234 formData.append("file", new Blob([new Uint8Array(1000)], { type: "text/plain" }), "test.txt")
235 const response = yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) })
236 assert.strictEqual(response.status, 413)
237 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
238
239 it.effect("formData withMaxFieldSize", () =>
240 Effect.gen(function*() {
241 yield* HttpRouter.add(
242 "POST",
243 "/upload",
244 Effect.gen(function*() {
245 const request = yield* HttpServerRequest.HttpServerRequest
246 yield* request.multipart
247 return HttpServerResponse.empty()
248 }).pipe(Effect.catchTag("MultipartError", (error) =>
249 error.reason._tag === "FieldTooLarge"
250 ? Effect.succeed(HttpServerResponse.empty({ status: 413 }))
251 : Effect.fail(error)))
252 ).pipe(
253 HttpRouter.serve,
254 Layer.build,
255 Effect.provideService(Multipart.MaxFieldSize, ByteSize.bytes(100))
256 )
257 const formData = new FormData()
258 formData.append("file", "x".repeat(1000))
259 const response = yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) })
260 assert.strictEqual(response.status, 413)
261 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
262
263 it.effect("mountApp", () =>
264 Effect.gen(function*() {
265 const child = Effect.map(HttpServerRequest.HttpServerRequest, (_) => HttpServerResponse.text(_.url))
266 yield* HttpRouter.use((router) => router.prefixed("/child").add("*", "*", child)).pipe(
267 HttpRouter.serve,
268 Layer.build
269 )
270 assert.strictEqual(yield* HttpClient.get("/child/1").pipe(Effect.flatMap((_) => _.text)), "/1")
271 assert.strictEqual(yield* HttpClient.get("/child").pipe(Effect.flatMap((_) => _.text)), "/")
272 assert.strictEqual(yield* HttpClient.get("/child?foo=bar").pipe(Effect.flatMap((_) => _.text)), "?foo=bar")
273 assert.strictEqual(yield* HttpClient.get("/child/").pipe(Effect.flatMap((_) => _.text)), "/")
274 assert.strictEqual(
275 yield* HttpClient.get("/child1/", { urlParams: { foo: "bar" } }).pipe(Effect.map((_) => _.status)),
276 404
277 )
278 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
279
280 it.effect("file", () =>
281 Effect.gen(function*() {
282 yield* (yield* HttpServerResponse.file(fixture).pipe(
283 Effect.updateService(HttpPlatform.HttpPlatform, (_) => ({
284 ..._,
285 fileResponse: (path, options) =>
286 Effect.map(_.fileResponse(path, options), (response) => {
287 ;(response as any).headers.etag = "\"etag\""
288 return response
289 })
290 }))
291 )).pipe(Effect.succeed, HttpServer.serveEffect())
292 const response = yield* HttpClient.get("/", {
293 headers: { "accept-encoding": "identity" }
294 })
295 assert.strictEqual(response.status, 200)
296 assert.strictEqual(response.headers["content-type"], "text/plain; charset=UTF-8")
297 assert.strictEqual(response.headers.etag, "\"etag\"")
298 assert.strictEqual((yield* response.text).trim(), "lorem ipsum dolar sit amet")
299 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
300
301 it.effect("fileWeb", () =>
302 Effect.gen(function*() {
303 const now = new Date()
304 const file = new File([new TextEncoder().encode("test")], "test.txt", {
305 type: "text/plain",
306 lastModified: now.getTime()
307 })
308 yield* HttpServerResponse.fileWeb(file).pipe(
309 Effect.updateService(HttpPlatform.HttpPlatform, (_) => ({
310 ..._,
311 fileWebResponse: (file, options) =>
312 Effect.map(_.fileWebResponse(file, options), (response) => ({
313 ...response,
314 headers: { ...response.headers, etag: "W/\"etag\"" }
315 }))
316 })),
317 HttpServer.serveEffect()
318 )
319 const response = yield* HttpClient.get("/")
320 assert.strictEqual(response.status, 200)
321 assert.strictEqual(response.headers["content-type"], "text/plain")
322 assert.strictEqual(response.headers["last-modified"], now.toUTCString())
323 assert.strictEqual(response.headers.etag, "W/\"etag\"")
324 assert.strictEqual(yield* response.text, "test")
325 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
326
327 it.effect("schemaBodyUrlParams", () =>
328 Effect.gen(function*() {
329 yield* HttpRouter.add(
330 "POST",
331 "/todos",
332 Effect.flatMap(
333 HttpServerRequest.schemaBodyUrlParams(Schema.Struct({
334 id: Schema.FiniteFromString,
335 title: Schema.String
336 })),
337 ({ id, title }) => todoResponse({ id, title })
338 )
339 ).pipe(HttpRouter.serve, Layer.build)
340 const todo = yield* HttpClientRequest.post("/todos").pipe(
341 HttpClientRequest.bodyUrlParams({ id: "1", title: "test" }),
342 HttpClient.execute,
343 Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
344 )
345 assert.deepStrictEqual(todo, { id: 1, title: "test" })
346 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
347
348 it.effect("schemaBodyUrlParams error", () =>
349 Effect.gen(function*() {
350 yield* HttpRouter.add(
351 "GET",
352 "/todos",
353 Effect.flatMap(
354 HttpServerRequest.schemaBodyUrlParams(Schema.Struct({
355 id: Schema.FiniteFromString,
356 title: Schema.String
357 })),
358 ({ id, title }) => todoResponse({ id, title })
359 ).pipe(Effect.catchTag("SchemaError", (error) =>
360 Effect.succeed(HttpServerResponse.jsonUnsafe({ error }, { status: 400 }))))
361 ).pipe(HttpRouter.serve, Layer.build)
362 assert.strictEqual((yield* HttpClient.get("/todos")).status, 400)
363 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
364
365 it.effect("schemaBodyFormJson", () =>
366 Effect.gen(function*() {
367 yield* HttpRouter.add(
368 "POST",
369 "/upload",
370 Effect.gen(function*() {
371 const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({ test: Schema.String }))("json")
372 assert.strictEqual(result.test, "content")
373 return HttpServerResponse.empty()
374 })
375 ).pipe(HttpRouter.serve, Layer.build)
376 const formData = new FormData()
377 formData.append("json", JSON.stringify({ test: "content" }))
378 assert.strictEqual((yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) })).status, 204)
379 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
380
381 it.effect("schemaBodyFormJson file", () =>
382 Effect.gen(function*() {
383 yield* HttpRouter.add(
384 "POST",
385 "/upload",
386 Effect.gen(function*() {
387 const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({ test: Schema.String }))("json")
388 assert.strictEqual(result.test, "content")
389 return HttpServerResponse.empty()
390 })
391 ).pipe(HttpRouter.serve, Layer.build)
392 const formData = new FormData()
393 formData.append(
394 "json",
395 new Blob([JSON.stringify({ test: "content" })], { type: "application/json" }),
396 "test.json"
397 )
398 assert.strictEqual((yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) })).status, 204)
399 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
400
401 it.effect("schemaBodyFormJson url encoded", () =>
402 Effect.gen(function*() {
403 yield* HttpRouter.add(
404 "POST",
405 "/upload",
406 Effect.gen(function*() {
407 const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({ test: Schema.String }))("json")
408 assert.strictEqual(result.test, "content")
409 return HttpServerResponse.empty()
410 })
411 ).pipe(HttpRouter.serve, Layer.build)
412 const response = yield* HttpClient.post("/upload", {
413 body: HttpBody.urlParams(UrlParams.fromInput({ json: JSON.stringify({ test: "content" }) }))
414 })
415 assert.strictEqual(response.status, 204)
416 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
417
418 it.effect("tracing", () =>
419 Effect.gen(function*() {
420 yield* HttpRouter.add(
421 "GET",
422 "/",
423 Effect.flatMap(Effect.currentSpan, (_) => HttpServerResponse.json({ spanId: _.spanId, parent: _.parent }))
424 ).pipe(HttpRouter.serve, Layer.build)
425 const requestSpan = yield* Effect.makeSpan("client request")
426 const body = yield* HttpClient.get("/").pipe(
427 Effect.flatMap((response) => response.json),
428 Effect.provideService(
429 Tracer.Tracer,
430 Tracer.make({
431 span(options) {
432 assert.strictEqual(options.name, "http.client GET")
433 assert.strictEqual(options.kind, "client")
434 assert(options.parent._tag === "Some")
435 if (options.parent.value._tag !== "Span") throw new Error("Expected span parent")
436 assert.strictEqual(options.parent.value.name, "request parent")
437 return requestSpan
438 }
439 })
440 ),
441 Effect.withSpan("request parent"),
442 Effect.repeat({ times: 2 })
443 )
444 assert.strictEqual((body as any).parent._tag, "Some")
445 assert.strictEqual((body as any).parent.value.spanId, requestSpan.spanId)
446 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
447
448 it.effect("html", () =>
449 Effect.gen(function*() {
450 yield* HttpRouter.addAll([
451 HttpRouter.route("GET", "/home", HttpServerResponse.html("<html />")),
452 HttpRouter.route(
453 "GET",
454 "/about",
455 HttpServerResponse.html`<html>${Effect.succeed("<body />")}</html>`
456 ),
457 HttpRouter.route(
458 "GET",
459 "/stream",
460 HttpServerResponse.htmlStream`<html>${Stream.make("<body />", 123, "hello")}</html>`
461 )
462 ]).pipe(HttpRouter.serve, Layer.build)
463 assert.strictEqual(yield* HttpClient.get("/home").pipe(Effect.flatMap((_) => _.text)), "<html />")
464 assert.strictEqual(yield* HttpClient.get("/about").pipe(Effect.flatMap((_) => _.text)), "<html><body /></html>")
465 assert.strictEqual(
466 yield* HttpClient.get("/stream").pipe(Effect.flatMap((_) => _.text)),
467 "<html><body />123hello</html>"
468 )
469 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
470
471 it.effect("setCookie", () =>
472 Effect.gen(function*() {
473 yield* HttpRouter.add(
474 "GET",
475 "/home",
476 HttpServerResponse.empty().pipe(
477 HttpServerResponse.setCookieUnsafe("test", "value"),
478 HttpServerResponse.setCookieUnsafe("test2", "value2", {
479 httpOnly: true,
480 secure: true,
481 sameSite: "lax",
482 partitioned: true,
483 path: "/",
484 domain: "example.com",
485 expires: new Date(2022, 1, 1),
486 maxAge: "5 minutes"
487 })
488 )
489 ).pipe(HttpRouter.serve, Layer.build)
490 const response = yield* HttpClient.get("/home")
491 assert.deepStrictEqual(
492 response.cookies.toJSON(),
493 Cookies.fromReadonlyRecord({
494 test: Cookies.makeCookieUnsafe("test", "value"),
495 test2: Cookies.makeCookieUnsafe("test2", "value2", {
496 httpOnly: true,
497 secure: true,
498 sameSite: "lax",
499 partitioned: true,
500 path: "/",
501 domain: "example.com",
502 expires: new Date(2022, 1, 1),
503 maxAge: Duration.minutes(5)
504 })
505 }).toJSON()
506 )
507 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
508
509 it.live("uninterruptible routes", () =>
510 Effect.gen(function*() {
511 yield* HttpRouter.add(
512 "GET",
513 "/home",
514 Effect.gen(function*() {
515 const fiber = Fiber.getCurrent()!
516 setTimeout(() => fiber.interruptUnsafe(fiber.id), 10)
517 yield* Effect.sleep(50)
518 return HttpServerResponse.empty()
519 }),
520 { uninterruptible: true }
521 ).pipe(HttpRouter.serve, Layer.build)
522 assert.strictEqual((yield* HttpClient.get("/home")).status, 204)
523 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
524
525 it.live("disposes after a client aborts a handler awaiting an upstream request", () =>
526 Effect.gen(function*() {
527 const upstreamStarted = Latch.makeUnsafe()
528 const upstream = yield* Effect.acquireRelease(
529 Effect.sync(() => {
530 const controller = new AbortController()
531 const server = Deno.serve(
532 { hostname: "127.0.0.1", port: 0, onListen: () => {}, signal: controller.signal },
533 (request) => {
534 upstreamStarted.openUnsafe()
535 return new Promise<Response>((resolve) => {
536 request.signal.addEventListener("abort", () => resolve(new Response()), { once: true })
537 controller.signal.addEventListener("abort", () => resolve(new Response()), { once: true })
538 })
539 }
540 )
541 return { controller, server }
542 }),
543 ({ controller, server }) =>
544 Effect.sync(() => controller.abort()).pipe(Effect.andThen(Effect.promise(() => server.finished)))
545 )
546 const upstreamPort = (upstream.server.addr as Deno.NetAddr).port
547 const router = HttpRouter.use((router) =>
548 router.add(
549 "GET",
550 "/",
551 Effect.gen(function*() {
552 yield* HttpClient.head(`http://127.0.0.1:${upstreamPort}`)
553 return HttpServerResponse.empty()
554 })
555 )
556 )
557 const serverLayer = DenoHttpServer.layer({
558 hostname: "127.0.0.1",
559 port: 0,
560 onListen: () => {},
561 gracefulShutdownTimeout: "100 millis"
562 })
563 const services = Layer.merge(serverLayer, FetchHttpClient.layer)
564 const runtime = yield* Effect.acquireRelease(
565 Effect.sync(() =>
566 ManagedRuntime.make(Layer.merge(
567 services,
568 HttpRouter.serve(router).pipe(Layer.provide(services))
569 ))
570 ),
571 (runtime) => Effect.promise(() => runtime.dispose())
572 )
573 yield* Effect.promise(() => runtime.context())
574 const downstreamServer = yield* Effect.promise(() => runtime.runPromise(HttpServer.HttpServer))
575 const downstreamPort = (downstreamServer.address as NetAddress.InetAddress).port
576
577 const controller = new AbortController()
578 const downstream = fetch(`http://127.0.0.1:${downstreamPort}`, {
579 signal: controller.signal
580 }).catch(() => undefined)
581 yield* upstreamStarted.await
582 controller.abort()
583 yield* Effect.promise(() => downstream)
584
585 const disposed = yield* Effect.promise(() => runtime.dispose()).pipe(Effect.timeoutOption("2 seconds"))
586 assert.strictEqual(disposed._tag, "Some")
587 }))
588
589 describe("HttpServerRespondable", () => {
590 it.effect("error/schema", () =>
591 Effect.gen(function*() {
592 class CustomError extends Schema.Error<CustomError>("CustomError")({
593 _tag: Schema.tag("CustomError"),
594 name: Schema.String
595 }) {
596 [HttpServerRespondable.symbol]() {
597 return HttpServerResponse.schemaJson(CustomError)(this, { status: 599 })
598 }
599 }
600 yield* HttpRouter.add("GET", "/home", new CustomError({ name: "test" })).pipe(
601 HttpRouter.serve,
602 Layer.build
603 )
604 const response = yield* HttpClient.get("/home")
605 assert.strictEqual(response.status, 599)
606 assert.deepStrictEqual(
607 yield* HttpClientResponse.schemaBodyJson(CustomError)(response),
608 new CustomError({ name: "test" })
609 )
610 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
611
612 it.effect("httpapi error", () =>
613 Effect.gen(function*() {
614 yield* HttpRouter.add("GET", "/home", new HttpApiError.BadRequest({})).pipe(
615 HttpRouter.serve,
616 Layer.build
617 )
618 assert.strictEqual((yield* HttpClient.get("/home")).status, 400)
619 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
620 })
621
622 it.effect("RouterConfig", () =>
623 Effect.gen(function*() {
624 yield* HttpRouter.add("GET", "/:param", Effect.succeed(HttpServerResponse.empty())).pipe(
625 HttpRouter.serve,
626 Layer.build
627 )
628 assert.strictEqual((yield* HttpClient.get("/123456")).status, 404)
629 assert.strictEqual((yield* HttpClient.get("/12345")).status, 204)
630 }).pipe(
631 Effect.provide([
632 DenoHttpServer.layerTest,
633 Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 5 })
634 ])
635 ))
636
637 it.effect("HttpRouter prefixed", () =>
638 Effect.gen(function*() {
639 const handler = HttpRouter.serve(HttpRouter.use(Effect.fnUntraced(function*(router_) {
640 const router = router_.prefixed("/todos")
641 yield* router.add(
642 "GET",
643 "/:id",
644 Effect.flatMap(HttpRouter.schemaParams(IdParams), ({ id }) => todoResponse({ id, title: "test" }))
645 )
646 yield* router.addAll([
647 HttpRouter.route("GET", "/", Effect.succeed(HttpServerResponse.text("root")))
648 ])
649 })))
650 yield* Layer.build(handler)
651 assert.deepStrictEqual(
652 yield* HttpClient.get("/todos/1").pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))),
653 { id: 1, title: "test" }
654 )
655 assert.strictEqual(yield* HttpClient.get("/todos").pipe(Effect.flatMap((_) => _.text)), "root")
656 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
657
658 it.effect("cancels a file body for HEAD requests", () =>
659 Effect.gen(function*() {
660 let cancelled = false
661 const fileResponse = yield* HttpServerResponse.file(fixture).pipe(
662 Effect.updateService(HttpPlatform.HttpPlatform, (platform) => ({
663 ...platform,
664 fileResponse: (path, options) =>
665 Effect.map(platform.fileResponse(path, options), (response) => {
666 assert.strictEqual(response.body._tag, "Raw")
667 const source = (response.body as HttpBody.Raw).body
668 assert(source instanceof ReadableStream)
669 const reader = source.getReader()
670 const body = new ReadableStream<Uint8Array>({
671 pull(controller) {
672 return reader.read().then(({ done, value }) => {
673 if (done) controller.close()
674 else controller.enqueue(value)
675 })
676 },
677 cancel(reason) {
678 cancelled = true
679 return reader.cancel(reason)
680 }
681 })
682 return HttpServerResponse.raw(body, {
683 status: response.status,
684 statusText: response.statusText,
685 headers: response.headers,
686 cookies: response.cookies
687 })
688 })
689 }))
690 )
691 yield* fileResponse.pipe(
692 Effect.succeed,
693 HttpServer.serveEffect()
694 )
695 const response = yield* HttpClient.head("/")
696 assert.strictEqual(response.status, 200)
697 assert(cancelled)
698 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
699
700 it.effect("ignores cancellation errors for HEAD response bodies", () => {
701 const body = new ReadableStream<Uint8Array>()
702 const reader = body.getReader()
703 return Effect.gen(function*() {
704 yield* HttpServerResponse.raw(body).pipe(
705 Effect.succeed,
706 HttpServer.serveEffect()
707 )
708 const response = yield* HttpClient.head("/")
709 assert.strictEqual(response.status, 200)
710 }).pipe(
711 Effect.ensuring(Effect.sync(() => reader.releaseLock())),
712 Effect.provide(DenoHttpServer.layerTest)
713 )
714 })
715
716 it.effect("round trips WebSocket frames and closes cleanly", () =>
717 Effect.gen(function*() {
718 yield* serveWebSocket(echoWebSocket)
719 const server = yield* HttpServer.HttpServer
720 const port = (server.address as NetAddress.InetAddress).port
721 const messages = yield* connectWebSocket(`ws://127.0.0.1:${port}/`, (socket) => socket.send("hello"), 1)
722 assert.deepStrictEqual(messages, ["hello"])
723 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
724
725 it.effect("preserves eager WebSocket frames across an async boundary", () =>
726 Effect.gen(function*() {
727 yield* serveWebSocket((socket) =>
728 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0))).pipe(
729 Effect.andThen(echoWebSocket(socket))
730 )
731 )
732
733 const server = yield* HttpServer.HttpServer
734 const port = (server.address as NetAddress.InetAddress).port
735 const messages = yield* connectWebSocket(`ws://127.0.0.1:${port}/`, (socket) => {
736 socket.send("first")
737 socket.send("second")
738 }, 2)
739
740 assert.deepStrictEqual(messages, ["first", "second"])
741 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
742
743 it.effect("delivers binary WebSocket frames as Uint8Array", () =>
744 Effect.gen(function*() {
745 const received = yield* Queue.unbounded<Uint8Array>()
746 yield* serveWebSocket((socket) =>
747 Effect.gen(function*() {
748 const { pull } = yield* socket.reader
749 while (true) {
750 const chunk = yield* pull
751 for (const message of chunk) {
752 assert(message instanceof Uint8Array)
753 yield* Queue.offer(received, message)
754 }
755 }
756 }).pipe(
757 Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
758 Effect.orDie
759 )
760 )
761
762 const server = yield* HttpServer.HttpServer
763 const port = (server.address as NetAddress.InetAddress).port
764 const socket = yield* openWebSocket(`ws://127.0.0.1:${port}/`)
765 socket.send(new Uint8Array([1, 2, 3]))
766
767 assert.deepStrictEqual(yield* Queue.take(received), new Uint8Array([1, 2, 3]))
768 socket.close()
769 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
770})
771
772const echoWebSocket = (socket: Socket.Socket) =>
773 Effect.gen(function*() {
774 const writer = yield* socket.writer
775 const { pull } = yield* socket.reader
776 while (true) {
777 yield* writer.writeAll(yield* pull)
778 }
779 }).pipe(
780 Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
781 Effect.orDie
782 )
783
784const serveWebSocket = (
785 run: (socket: Socket.Socket) => Effect.Effect<void, Socket.SocketError, Scope.Scope>
786) =>
787 HttpRouter.add(
788 "GET",
789 "/",
790 Effect.gen(function*() {
791 const request = yield* HttpServerRequest.HttpServerRequest
792 const socket = yield* request.upgrade
793 yield* run(socket)
794 return HttpServerResponse.empty()
795 })
796 ).pipe(HttpRouter.serve, Layer.build)
797
798const openWebSocket = (url: string) =>
799 Effect.acquireRelease(
800 Effect.callback<WebSocket, Error>((resume) => {
801 const socket = new WebSocket(url)
802 socket.addEventListener("open", () => resume(Effect.succeed(socket)), { once: true })
803 socket.addEventListener("error", () => resume(Effect.fail(new Error("WebSocket connection failed"))), {
804 once: true
805 })
806 }),
807 (socket) => Effect.sync(() => socket.close())
808 )
809
810const connectWebSocket = (url: string, onOpen: (socket: WebSocket) => void, messageCount: number) =>
811 Effect.acquireUseRelease(
812 openWebSocket(url),
813 (socket) =>
814 Effect.gen(function*() {
815 const messages: Array<string> = []
816 const fiber = yield* Effect.callback<void, Error>((resume) => {
817 socket.addEventListener("message", (event) => {
818 messages.push(event.data)
819 if (messages.length === messageCount) resume(Effect.void)
820 })
821 socket.addEventListener("error", () => resume(Effect.fail(new Error("WebSocket connection failed"))), {
822 once: true
823 })
824 onOpen(socket)
825 }).pipe(Effect.forkChild)
826 yield* Fiber.join(fiber)
827 return messages
828 }),
829 (socket) => Effect.sync(() => socket.close())
830 )