diff --git a/.changeset/eff-153-deno-http-server.md b/.changeset/eff-153-deno-http-server.md new file mode 100644 index 00000000000..a2e26116428 --- /dev/null +++ b/.changeset/eff-153-deno-http-server.md @@ -0,0 +1,5 @@ +--- +"@effect/platform-deno": patch +--- + +Add a native Deno HTTP server with multipart requests, file responses, and WebSocket upgrades. diff --git a/.changeset/eff-153-websocket-initial-frames.md b/.changeset/eff-153-websocket-initial-frames.md new file mode 100644 index 00000000000..1f1142a068a --- /dev/null +++ b/.changeset/eff-153-websocket-initial-frames.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Support replaying initial WebSocket messages and normalize `ArrayBuffer` frames to `Uint8Array`. diff --git a/packages/effect/src/unstable/socket/Socket.ts b/packages/effect/src/unstable/socket/Socket.ts index 3647638c868..1ccdb094ffe 100644 --- a/packages/effect/src/unstable/socket/Socket.ts +++ b/packages/effect/src/unstable/socket/Socket.ts @@ -608,10 +608,19 @@ export const fromWebSocket = ( options?: { readonly closeCodeIsError?: ((code: number) => boolean) | undefined readonly openTimeout?: Duration.Input | undefined + /** + * Replays buffered events on the first run after the socket opens and before + * the run's `onOpen` effect. + * + * @category options + * @since 4.0.0 + */ + readonly onInitialRun?: ((ws: globalThis.WebSocket) => ReadonlyArray) | undefined } | undefined ): Effect.Effect> => Effect.withFiber((fiber) => { let currentWS: globalThis.WebSocket | undefined + let initial = true const latch = Latch.makeUnsafe(false) const acquireContext = fiber.context as Context.Context const closeCodeIsError = options?.closeCodeIsError ?? defaultCloseCodeIsError @@ -638,7 +647,7 @@ export const fromWebSocket = ( ) return run(effect) } - const result = handler(event.data) + const result = handler(event.data instanceof ArrayBuffer ? new Uint8Array(event.data) : event.data) if (Effect.isEffect(result)) { run(result) } @@ -708,6 +717,10 @@ export const fromWebSocket = ( open = true currentWS = ws latch.openUnsafe() + if (initial && options?.onInitialRun) { + initial = false + for (const event of options.onInitialRun(ws)) onMessage(event) + } if (opts?.onOpen) yield* opts.onOpen return yield* Effect.catchFilter( FiberSet.join(fiberSet), diff --git a/packages/platform-deno/src/DenoHttpServer.ts b/packages/platform-deno/src/DenoHttpServer.ts new file mode 100644 index 00000000000..251a7c57531 --- /dev/null +++ b/packages/platform-deno/src/DenoHttpServer.ts @@ -0,0 +1,535 @@ +/** + * Native Deno implementation of the Effect `HttpServer`. + * + * @since 4.0.0 + */ +import * as Config from "effect/Config" +import type { ConfigError } from "effect/Config" +import * as Context from "effect/Context" +import * as Duration from "effect/Duration" +import * as Effect from "effect/Effect" +import * as Fiber from "effect/Fiber" +import type * as FileSystem from "effect/FileSystem" +import { flow } from "effect/Function" +import * as Inspectable from "effect/Inspectable" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import type * as Path from "effect/Path" +import type * as Record from "effect/Record" +import type * as Schema from "effect/Schema" +import * as Scope from "effect/Scope" +import * as Stream from "effect/Stream" +import * as Cookies from "effect/unstable/http/Cookies" +import * as Etag from "effect/unstable/http/Etag" +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient" +import * as Headers from "effect/unstable/http/Headers" +import type * as HttpBody from "effect/unstable/http/HttpBody" +import type { HttpClient } from "effect/unstable/http/HttpClient" +import * as HttpEffect from "effect/unstable/http/HttpEffect" +import * as IncomingMessage from "effect/unstable/http/HttpIncomingMessage" +import type { HttpMethod } from "effect/unstable/http/HttpMethod" +import type { HttpPlatform } from "effect/unstable/http/HttpPlatform" +import * as Server from "effect/unstable/http/HttpServer" +import * as Error from "effect/unstable/http/HttpServerError" +import * as ServerRequest from "effect/unstable/http/HttpServerRequest" +import type * as ServerResponse from "effect/unstable/http/HttpServerResponse" +import type * as Multipart from "effect/unstable/http/Multipart" +import * as UrlParams from "effect/unstable/http/UrlParams" +import * as Socket from "effect/unstable/socket/Socket" +import * as Platform from "./DenoHttpPlatform.ts" +import * as DenoMultipart from "./DenoMultipart.ts" +import * as DenoServices from "./DenoServices.ts" + +/** + * Native Deno TCP, TLS, or Unix serve options managed by the scoped server. + * + * **Details** + * + * `signal` and `onError` are omitted because the scope and Effect HTTP handler + * own server and failure lifecycles. Server constructors additionally accept + * WebSocket settings that apply to every upgrade; `protocol` therefore cannot + * vary per request. + * + * @category options + * @since 4.0.0 + */ +export type ServeOptions = + | (Omit & Partial) + | Omit + +/** + * Creates a scoped native Deno HTTP server. + * + * @category constructors + * @since 4.0.0 + */ +export const make = Effect.fnUntraced(function*( + options: ServeOptions & { + readonly disablePreemptiveShutdown?: boolean | undefined + readonly gracefulShutdownTimeout?: Duration.Input | undefined + readonly websocket?: Deno.UpgradeWebSocketOptions | undefined + } +) { + const scope = yield* Effect.scope + type Handler = (request: Request, info: Deno.ServeHandlerInfo) => Promise + const notFound: Handler = (_request, _info) => Promise.resolve(new Response("not found", { status: 404 })) + const handlerStack: Array = [notFound] + let currentHandler = notFound + const { + disablePreemptiveShutdown, + gracefulShutdownTimeout, + websocket, + ...serveOptions + } = options + const server = Deno.serve( + serveOptions as Deno.ServeTcpOptions, + (request, info) => currentHandler(request, info) + ) as Deno.HttpServer + + const shutdown = yield* Effect.promise(() => server.shutdown()).pipe(Effect.cached) + const preemptiveShutdown = disablePreemptiveShutdown ? Effect.void : Effect.timeoutOrElse(shutdown, { + duration: gracefulShutdownTimeout ?? Duration.seconds(20), + orElse: () => Effect.void + }) + + yield* Scope.addFinalizer(scope, shutdown) + + const serverAddress = server.addr + const address: Server.Address = serverAddress.transport === "unix" + ? { _tag: "UnixAddress", path: serverAddress.path } + : { + _tag: "TcpAddress", + port: (serverAddress as Deno.NetAddr).port, + hostname: (serverAddress as Deno.NetAddr).hostname + } + + return Server.make({ + address, + serve: Effect.fnUntraced(function*(httpApp, middleware) { + const parent = yield* Effect.fiber + const services = parent.context + const serveScope = Context.getUnsafe(services, Scope.Scope) + const scope = Scope.forkUnsafe(serveScope, "parallel") + + const httpEffect = HttpEffect.toHandled(httpApp, (request, response) => { + const denoRequest = request as DenoServerRequest + if (denoRequest.upgraded) return cancelResponseBody(response.body) + return Effect.flatMap( + makeResponse(request, response, services, scope), + (response) => Effect.sync(() => denoRequest.resolve(response)) + ) + }, middleware) + + function handler( + request: Request, + info: Deno.ServeHandlerInfo + ): Promise { + return new Promise((resolve) => { + const map = new Map(services.mapUnsafe) + map.set( + ServerRequest.HttpServerRequest.key, + new DenoServerRequest(request, info.remoteAddr, resolve, removeHost(request.url), websocket) + ) + const fiber = Fiber.runIn(Effect.runForkWith(Context.makeUnsafe(map))(httpEffect), scope) + request.signal.addEventListener("abort", () => { + fiber.interruptUnsafe(parent.id, Error.ClientAbort.annotation) + }, { once: true }) + }) + } + + yield* Scope.addFinalizerExit(serveScope, () => { + const index = handlerStack.lastIndexOf(handler) + if (index !== -1) handlerStack.splice(index, 1) + currentHandler = handlerStack[handlerStack.length - 1] ?? notFound + return preemptiveShutdown + }) + handlerStack.push(handler) + currentHandler = handler + }) + }) +}) + +const makeResponse = Effect.fnUntraced(function*( + request: ServerRequest.HttpServerRequest, + response: ServerResponse.HttpServerResponse, + context: Context.Context, + scope: Scope.Scope +) { + const fields: { + headers: globalThis.Headers + status?: number + statusText?: string + } = { + headers: new globalThis.Headers(response.headers), + status: response.status + } + + if (!Cookies.isEmpty(response.cookies)) { + for (const header of Cookies.toSetCookieHeaders(response.cookies)) { + fields.headers.append("set-cookie", header) + } + } + if (response.statusText !== undefined) fields.statusText = response.statusText + + if (request.method === "HEAD") { + yield* cancelResponseBody(response.body) + return new Response(undefined, fields) + } + response = HttpEffect.scopeTransferToStream(response) + const body = response.body + switch (body._tag) { + case "Empty": + return new Response(undefined, fields) + case "Uint8Array": + case "Raw": { + if (body.body instanceof Response) { + for (const [key, value] of fields.headers.entries()) body.body.headers.set(key, value) + return body.body + } + return new Response(body.body as any, fields) + } + case "FormData": + return new Response(body.formData as any, fields) + case "Stream": + return new Response( + Stream.toReadableStreamWith( + Stream.unwrap(Effect.withFiber((fiber) => { + Fiber.runIn(fiber, scope) + return Effect.succeed(body.stream) + })), + context + ), + fields + ) + } +}) + +/** + * Provides only the native Deno HTTP server. + * + * @category layers + * @since 4.0.0 + */ +export const layerServer: ( + options: ServeOptions & { + readonly disablePreemptiveShutdown?: boolean | undefined + readonly gracefulShutdownTimeout?: Duration.Input | undefined + readonly websocket?: Deno.UpgradeWebSocketOptions | undefined + } +) => Layer.Layer = flow( + make, + Layer.effect(Server.HttpServer) +) + +/** + * Provides Deno HTTP platform services and the standard Deno service set. + * + * @category layers + * @since 4.0.0 + */ +export const layerHttpServices: Layer.Layer = Layer.mergeAll( + Platform.layer, + Etag.layerWeak, + DenoServices.layer +) + +/** + * Provides a native Deno HTTP server together with Deno HTTP services. + * + * @category layers + * @since 4.0.0 + */ +export const layer = ( + options: ServeOptions & { + readonly disablePreemptiveShutdown?: boolean | undefined + readonly gracefulShutdownTimeout?: Duration.Input | undefined + readonly websocket?: Deno.UpgradeWebSocketOptions | undefined + } +): Layer.Layer => + Layer.mergeAll(layerServer(options), layerHttpServices) + +/** + * Starts a Deno HTTP server on an ephemeral loopback port for tests. + * + * @category layers + * @since 4.0.0 + */ +export const layerTest: Layer.Layer< + Server.HttpServer | HttpPlatform | FileSystem.FileSystem | Etag.Generator | Path.Path | HttpClient +> = Server.layerTestClient.pipe( + Layer.provide(FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })) + )), + Layer.provideMerge(layer({ hostname: "127.0.0.1", port: 0, onListen: () => {} })) +) + +/** + * Creates the Deno HTTP server and support-services layer from configurable options. + * + * @category layers + * @since 4.0.0 + */ +export const layerConfig = ( + options: Config.Wrap< + ServeOptions & { + readonly disablePreemptiveShutdown?: boolean | undefined + readonly gracefulShutdownTimeout?: Duration.Input | undefined + readonly websocket?: Deno.UpgradeWebSocketOptions | undefined + } + > +): Layer.Layer< + Server.HttpServer | HttpPlatform | FileSystem.FileSystem | Etag.Generator | Path.Path, + ConfigError +> => + Layer.mergeAll( + Layer.effect(Server.HttpServer)(Effect.flatMap(Config.unwrap(options), make)), + layerHttpServices + ) + +class DenoServerRequest extends Inspectable.Class implements ServerRequest.HttpServerRequest { + readonly [ServerRequest.TypeId]: typeof ServerRequest.TypeId + readonly [IncomingMessage.TypeId]: typeof IncomingMessage.TypeId + readonly source: Request + readonly remoteAddr: Deno.NetAddr | Deno.UnixAddr + readonly url: string + readonly websocketOptions: Deno.UpgradeWebSocketOptions | undefined + public resolve: (response: Response) => void + public upgraded = false + public headersOverride?: Headers.Headers | undefined + private remoteAddressOverride?: Option.Option | undefined + + constructor( + source: Request, + remoteAddr: Deno.NetAddr | Deno.UnixAddr, + resolve: (response: Response) => void, + url: string, + websocketOptions: Deno.UpgradeWebSocketOptions | undefined, + headersOverride?: Headers.Headers, + remoteAddressOverride?: Option.Option + ) { + super() + this[ServerRequest.TypeId] = ServerRequest.TypeId + this[IncomingMessage.TypeId] = IncomingMessage.TypeId + this.source = source + this.remoteAddr = remoteAddr + this.resolve = resolve + this.url = url + this.websocketOptions = websocketOptions + this.headersOverride = headersOverride + this.remoteAddressOverride = remoteAddressOverride + } + toJSON(): unknown { + return IncomingMessage.inspect(this, { + _id: "HttpServerRequest", + method: this.method, + url: this.originalUrl + }) + } + modify(options: { + readonly url?: string | undefined + readonly headers?: Headers.Headers | undefined + readonly remoteAddress?: Option.Option | undefined + }) { + return new DenoServerRequest( + this.source, + this.remoteAddr, + this.resolve, + options.url ?? this.url, + this.websocketOptions, + options.headers ?? this.headersOverride, + "remoteAddress" in options ? options.remoteAddress : this.remoteAddressOverride + ) + } + get method(): HttpMethod { + return this.source.method.toUpperCase() as HttpMethod + } + get originalUrl() { + return this.source.url + } + get remoteAddress(): Option.Option { + return this.remoteAddressOverride ?? (this.remoteAddr.transport === "tcp" + ? Option.some(this.remoteAddr.hostname) + : Option.none()) + } + get headers(): Headers.Headers { + this.headersOverride ??= Headers.fromInput(this.source.headers) + return this.headersOverride + } + + private cachedCookies: Record.ReadonlyRecord | undefined + get cookies() { + return this.cachedCookies ??= Cookies.parseHeader(this.headers.cookie ?? "") + } + + get stream(): Stream.Stream { + return this.source.body + ? Stream.fromReadableStream({ + evaluate: () => this.source.body ?? emptyReadableStream, + onError: (cause) => + new Error.HttpServerError({ + reason: new Error.RequestParseError({ request: this, cause }) + }) + }) + : Stream.fail( + new Error.HttpServerError({ + reason: new Error.RequestParseError({ + request: this, + description: "can not create stream from empty body" + }) + }) + ) + } + + private textEffect: Effect.Effect | undefined + get text(): Effect.Effect { + return this.textEffect ??= Effect.runSync(Effect.cached( + Effect.tryPromise({ + try: () => this.source.text(), + catch: (cause) => + new Error.HttpServerError({ + reason: new Error.RequestParseError({ request: this, cause }) + }) + }) + )) + } + get json(): Effect.Effect { + return Effect.flatMap(this.text, (_) => + Effect.try({ + try: () => JSON.parse(_) as Schema.Json, + catch: (cause) => + new Error.HttpServerError({ + reason: new Error.RequestParseError({ request: this, cause }) + }) + })) + } + get urlParamsBody(): Effect.Effect { + return Effect.flatMap(this.text, (_) => + Effect.try({ + try: () => UrlParams.fromInput(new URLSearchParams(_)), + catch: (cause) => + new Error.HttpServerError({ + reason: new Error.RequestParseError({ request: this, cause }) + }) + })) + } + + private multipartEffect: + | Effect.Effect + | undefined + get multipart(): Effect.Effect< + Multipart.Persisted, + Multipart.MultipartError, + Scope.Scope | FileSystem.FileSystem | Path.Path + > { + return this.multipartEffect ??= Effect.runSync(Effect.cached(DenoMultipart.persisted(this.source))) + } + get multipartStream(): Stream.Stream { + return DenoMultipart.stream(this.source) + } + + private arrayBufferEffect: Effect.Effect | undefined + get arrayBuffer(): Effect.Effect { + if (this.arrayBufferEffect) return this.arrayBufferEffect + this.arrayBufferEffect = Effect.runSync(Effect.cached( + Effect.tryPromise({ + try: () => this.source.arrayBuffer(), + catch: (cause) => + new Error.HttpServerError({ + reason: new Error.RequestParseError({ request: this, cause }) + }) + }) + )) + this.textEffect = Effect.map(this.arrayBufferEffect, (_) => new TextDecoder().decode(_)) + return this.arrayBufferEffect + } + + get upgrade(): Effect.Effect { + return Effect.flatMap( + Effect.try({ + try: () => Deno.upgradeWebSocket(this.source, this.websocketOptions), + catch: (cause) => + new Error.HttpServerError({ + reason: new Error.RequestParseError({ + request: this, + cause, + description: "Not an upgradeable ServerRequest" + }) + }) + }), + (upgrade) => { + const buffered: Array = [] + const buffer = (event: MessageEvent) => buffered.push(event) + upgrade.socket.addEventListener("message", buffer) + this.upgraded = true + this.resolve(upgrade.response) + + return Effect.callback((resume) => { + const cleanup = () => { + upgrade.socket.removeEventListener("open", onOpen) + upgrade.socket.removeEventListener("error", onFailure) + upgrade.socket.removeEventListener("close", onFailure) + } + const onFailure = (cause: Event) => { + cleanup() + upgrade.socket.removeEventListener("message", buffer) + buffered.length = 0 + resume(Effect.fail( + new Error.HttpServerError({ + reason: new Error.RequestParseError({ + request: this, + cause, + description: "WebSocket upgrade failed before open" + }) + }) + )) + } + const onOpen = () => { + cleanup() + resume(Socket.fromWebSocket( + Effect.acquireRelease( + Effect.succeed(upgrade.socket), + (socket) => Effect.sync(() => socket.close(1000)) + ), + { + onInitialRun: (socket) => { + socket.removeEventListener("message", buffer) + return buffered.splice(0) + } + } + )) + } + upgrade.socket.addEventListener("open", onOpen, { once: true }) + upgrade.socket.addEventListener("error", onFailure, { once: true }) + upgrade.socket.addEventListener("close", onFailure, { once: true }) + return Effect.sync(() => { + cleanup() + upgrade.socket.removeEventListener("message", buffer) + buffered.length = 0 + upgrade.socket.close() + }) + }) + } + ) + } +} + +const cancelResponseBody = (body: HttpBody.HttpBody): Effect.Effect => { + const stream = (body as any).body + if ((body._tag === "Raw" || body._tag === "Uint8Array") && stream instanceof ReadableStream) { + return Effect.ignoreCause(Effect.promise(() => stream.cancel())) + } + return Effect.void +} + +const emptyReadableStream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array()) + controller.close() + } +}) + +const removeHost = (url: string) => { + if (url[0] === "/") return url + const index = url.indexOf("/", url.indexOf("//") + 2) + return index === -1 ? "/" : url.slice(index) +} diff --git a/packages/platform-deno/src/DenoHttpServerRequest.ts b/packages/platform-deno/src/DenoHttpServerRequest.ts new file mode 100644 index 00000000000..d9fc0591805 --- /dev/null +++ b/packages/platform-deno/src/DenoHttpServerRequest.ts @@ -0,0 +1,18 @@ +/** + * Accessor for the web-standard request behind a Deno HTTP server request. + * + * Unlike Bun's accessor, this function has no route-pattern generic because + * `Deno.serve` always receives a standard `Request`. Connection information is + * intentionally kept internal to `DenoHttpServer`. + * + * @since 4.0.0 + */ +import type { HttpServerRequest } from "effect/unstable/http/HttpServerRequest" + +/** + * Returns the underlying web-standard `Request` from an Effect `HttpServerRequest`. + * + * @category accessors + * @since 4.0.0 + */ +export const toDenoServerRequest = (self: HttpServerRequest): Request => (self as any).source diff --git a/packages/platform-deno/src/index.ts b/packages/platform-deno/src/index.ts index 9e29206681d..438bcb4e46c 100644 --- a/packages/platform-deno/src/index.ts +++ b/packages/platform-deno/src/index.ts @@ -29,6 +29,16 @@ export * as DenoHttpClient from "./DenoHttpClient.ts" */ export * as DenoHttpPlatform from "./DenoHttpPlatform.ts" +/** + * @since 4.0.0 + */ +export * as DenoHttpServer from "./DenoHttpServer.ts" + +/** + * @since 4.0.0 + */ +export * as DenoHttpServerRequest from "./DenoHttpServerRequest.ts" + /** * @since 4.0.0 */ diff --git a/packages/platform-deno/test/DenoHttpServer.test.ts b/packages/platform-deno/test/DenoHttpServer.test.ts new file mode 100644 index 00000000000..3bc4592144b --- /dev/null +++ b/packages/platform-deno/test/DenoHttpServer.test.ts @@ -0,0 +1,735 @@ +import * as DenoHttpServer from "@effect/platform-deno/DenoHttpServer" +import { assert, describe, it } from "@effect/vitest" +import * as Duration from "effect/Duration" +import * as Effect from "effect/Effect" +import * as Fiber from "effect/Fiber" +import * as Latch from "effect/Latch" +import * as Layer from "effect/Layer" +import * as ManagedRuntime from "effect/ManagedRuntime" +import * as Queue from "effect/Queue" +import * as Schema from "effect/Schema" +import type * as Scope from "effect/Scope" +import * as Stream from "effect/Stream" +import * as Tracer from "effect/Tracer" +import * as Cookies from "effect/unstable/http/Cookies" +import * as Etag from "effect/unstable/http/Etag" +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient" +import * as HttpBody from "effect/unstable/http/HttpBody" +import * as HttpClient from "effect/unstable/http/HttpClient" +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" +import * as HttpPlatform from "effect/unstable/http/HttpPlatform" +import * as HttpRouter from "effect/unstable/http/HttpRouter" +import * as HttpServer from "effect/unstable/http/HttpServer" +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest" +import * as HttpServerRespondable from "effect/unstable/http/HttpServerRespondable" +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" +import * as Multipart from "effect/unstable/http/Multipart" +import * as UrlParams from "effect/unstable/http/UrlParams" +import * as HttpApiError from "effect/unstable/httpapi/HttpApiError" +import type * as Socket from "effect/unstable/socket/Socket" + +const Todo = Schema.Struct({ + id: Schema.Number, + title: Schema.String +}) +const IdParams = Schema.Struct({ + id: Schema.FiniteFromString +}) +const todoResponse = HttpServerResponse.schemaJson(Todo) +const fixture = `${import.meta.dirname}/fixtures/text.txt` + +describe("DenoHttpServer", () => { + it.effect("schema", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "GET", + "/todos/:id", + Effect.flatMap(HttpRouter.schemaParams(IdParams), ({ id }) => todoResponse({ id, title: "test" })) + ).pipe(HttpRouter.serve, Layer.build) + const todo = yield* HttpClient.get("/todos/1").pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)) + ) + assert.deepStrictEqual(todo, { id: 1, title: "test" }) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("exports a weak ETag generator", () => + Effect.gen(function*() { + const generator = yield* Etag.Generator + const etag = yield* generator.fromFileWeb(new File(["test"], "test.txt", { lastModified: 0 })) + assert.strictEqual(etag._tag, "Weak") + }).pipe(Effect.provide(DenoHttpServer.layerHttpServices))) + + it.effect("formData", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "POST", + "/upload", + Effect.gen(function*() { + const request = yield* HttpServerRequest.HttpServerRequest + const formData = yield* request.multipart + const part = formData.file + assert(typeof part !== "string") + const file = part[0] + assert(typeof file !== "string") + assert(file.path.endsWith("/test.txt")) + assert.strictEqual(file.contentType, "text/plain") + assert.strictEqual(yield* Effect.promise(() => Deno.readTextFile(file.path)), "test") + return yield* HttpServerResponse.json({ ok: "file" in formData }) + }) + ).pipe(HttpRouter.serve, Layer.build) + const formData = new FormData() + formData.append("file", new Blob(["test"], { type: "text/plain" }), "test.txt") + const response = yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) }) + assert.deepStrictEqual(yield* response.json, { ok: true }) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("multipartStream", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "POST", + "/upload", + Effect.gen(function*() { + const request = yield* HttpServerRequest.HttpServerRequest + const parts = yield* Stream.runCollect(request.multipartStream) + assert.strictEqual(parts.length, 2) + const field = parts[0] + const file = parts[1] + assert(Multipart.isField(field)) + assert.deepStrictEqual({ key: field.key, value: field.value }, { key: "name", value: "value" }) + assert(Multipart.isFile(file)) + assert.deepStrictEqual( + { key: file.key, name: file.name, contentType: file.contentType }, + { key: "file", name: "test.txt", contentType: "text/plain" } + ) + assert.strictEqual(new TextDecoder().decode(yield* file.contentEffect), "test") + return HttpServerResponse.empty() + }) + ).pipe(HttpRouter.serve, Layer.build) + const formData = new FormData() + formData.append("name", "value") + formData.append("file", new Blob(["test"], { type: "text/plain" }), "test.txt") + const response = yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) }) + assert.strictEqual(response.status, 204) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("schemaBodyForm", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "POST", + "/upload", + Effect.gen(function*() { + const files = yield* HttpServerRequest.schemaBodyForm(Schema.Struct({ + file: Multipart.FilesSchema, + test: Schema.String + })) + assert("file" in files) + assert.strictEqual(files.test, "test") + return HttpServerResponse.empty() + }) + ).pipe(HttpRouter.serve, Layer.build) + const formData = new FormData() + formData.append("file", new Blob(["test"], { type: "text/plain" }), "test.txt") + formData.append("test", "test") + const response = yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) }) + assert.strictEqual(response.status, 204) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("formData withMaxFileSize", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "POST", + "/upload", + Effect.gen(function*() { + const request = yield* HttpServerRequest.HttpServerRequest + yield* request.multipart + return HttpServerResponse.empty() + }).pipe(Effect.catchTag("MultipartError", (error) => + error.reason._tag === "FileTooLarge" + ? Effect.succeed(HttpServerResponse.empty({ status: 413 })) + : Effect.fail(error))) + ).pipe( + HttpRouter.serve, + Layer.build, + Effect.provideService(Multipart.MaxFileSize, 100) + ) + const formData = new FormData() + formData.append("file", new Blob([new Uint8Array(1000)], { type: "text/plain" }), "test.txt") + const response = yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) }) + assert.strictEqual(response.status, 413) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("formData withMaxFieldSize", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "POST", + "/upload", + Effect.gen(function*() { + const request = yield* HttpServerRequest.HttpServerRequest + yield* request.multipart + return HttpServerResponse.empty() + }).pipe(Effect.catchTag("MultipartError", (error) => + error.reason._tag === "FieldTooLarge" + ? Effect.succeed(HttpServerResponse.empty({ status: 413 })) + : Effect.fail(error))) + ).pipe( + HttpRouter.serve, + Layer.build, + Effect.provideService(Multipart.MaxFieldSize, 100) + ) + const formData = new FormData() + formData.append("file", "x".repeat(1000)) + const response = yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) }) + assert.strictEqual(response.status, 413) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("mountApp", () => + Effect.gen(function*() { + const child = Effect.map(HttpServerRequest.HttpServerRequest, (_) => HttpServerResponse.text(_.url)) + yield* HttpRouter.use((router) => router.prefixed("/child").add("*", "*", child)).pipe( + HttpRouter.serve, + Layer.build + ) + assert.strictEqual(yield* HttpClient.get("/child/1").pipe(Effect.flatMap((_) => _.text)), "/1") + assert.strictEqual(yield* HttpClient.get("/child").pipe(Effect.flatMap((_) => _.text)), "/") + assert.strictEqual(yield* HttpClient.get("/child?foo=bar").pipe(Effect.flatMap((_) => _.text)), "?foo=bar") + assert.strictEqual(yield* HttpClient.get("/child/").pipe(Effect.flatMap((_) => _.text)), "/") + assert.strictEqual( + yield* HttpClient.get("/child1/", { urlParams: { foo: "bar" } }).pipe(Effect.map((_) => _.status)), + 404 + ) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("file", () => + Effect.gen(function*() { + yield* (yield* HttpServerResponse.file(fixture).pipe( + Effect.updateService(HttpPlatform.HttpPlatform, (_) => ({ + ..._, + fileResponse: (path, options) => + Effect.map(_.fileResponse(path, options), (response) => { + ;(response as any).headers.etag = "\"etag\"" + return response + }) + })) + )).pipe(Effect.succeed, HttpServer.serveEffect()) + const response = yield* HttpClient.get("/", { + headers: { "accept-encoding": "identity" } + }) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.headers["content-type"], "text/plain; charset=UTF-8") + assert.strictEqual(response.headers.etag, "\"etag\"") + assert.strictEqual((yield* response.text).trim(), "lorem ipsum dolar sit amet") + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("fileWeb", () => + Effect.gen(function*() { + const now = new Date() + const file = new File([new TextEncoder().encode("test")], "test.txt", { + type: "text/plain", + lastModified: now.getTime() + }) + yield* HttpServerResponse.fileWeb(file).pipe( + Effect.updateService(HttpPlatform.HttpPlatform, (_) => ({ + ..._, + fileWebResponse: (file, options) => + Effect.map(_.fileWebResponse(file, options), (response) => ({ + ...response, + headers: { ...response.headers, etag: "W/\"etag\"" } + })) + })), + HttpServer.serveEffect() + ) + const response = yield* HttpClient.get("/") + assert.strictEqual(response.status, 200) + assert.strictEqual(response.headers["content-type"], "text/plain") + assert.strictEqual(response.headers["last-modified"], now.toUTCString()) + assert.strictEqual(response.headers.etag, "W/\"etag\"") + assert.strictEqual(yield* response.text, "test") + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("schemaBodyUrlParams", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "POST", + "/todos", + Effect.flatMap( + HttpServerRequest.schemaBodyUrlParams(Schema.Struct({ + id: Schema.FiniteFromString, + title: Schema.String + })), + ({ id, title }) => todoResponse({ id, title }) + ) + ).pipe(HttpRouter.serve, Layer.build) + const todo = yield* HttpClientRequest.post("/todos").pipe( + HttpClientRequest.bodyUrlParams({ id: "1", title: "test" }), + HttpClient.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)) + ) + assert.deepStrictEqual(todo, { id: 1, title: "test" }) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("schemaBodyUrlParams error", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "GET", + "/todos", + Effect.flatMap( + HttpServerRequest.schemaBodyUrlParams(Schema.Struct({ + id: Schema.FiniteFromString, + title: Schema.String + })), + ({ id, title }) => todoResponse({ id, title }) + ).pipe(Effect.catchTag("SchemaError", (error) => + Effect.succeed(HttpServerResponse.jsonUnsafe({ error }, { status: 400 })))) + ).pipe(HttpRouter.serve, Layer.build) + assert.strictEqual((yield* HttpClient.get("/todos")).status, 400) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("schemaBodyFormJson", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "POST", + "/upload", + Effect.gen(function*() { + const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({ test: Schema.String }))("json") + assert.strictEqual(result.test, "content") + return HttpServerResponse.empty() + }) + ).pipe(HttpRouter.serve, Layer.build) + const formData = new FormData() + formData.append("json", JSON.stringify({ test: "content" })) + assert.strictEqual((yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) })).status, 204) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("schemaBodyFormJson file", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "POST", + "/upload", + Effect.gen(function*() { + const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({ test: Schema.String }))("json") + assert.strictEqual(result.test, "content") + return HttpServerResponse.empty() + }) + ).pipe(HttpRouter.serve, Layer.build) + const formData = new FormData() + formData.append( + "json", + new Blob([JSON.stringify({ test: "content" })], { type: "application/json" }), + "test.json" + ) + assert.strictEqual((yield* HttpClient.post("/upload", { body: HttpBody.formData(formData) })).status, 204) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("schemaBodyFormJson url encoded", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "POST", + "/upload", + Effect.gen(function*() { + const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({ test: Schema.String }))("json") + assert.strictEqual(result.test, "content") + return HttpServerResponse.empty() + }) + ).pipe(HttpRouter.serve, Layer.build) + const response = yield* HttpClient.post("/upload", { + body: HttpBody.urlParams(UrlParams.fromInput({ json: JSON.stringify({ test: "content" }) })) + }) + assert.strictEqual(response.status, 204) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("tracing", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "GET", + "/", + Effect.flatMap(Effect.currentSpan, (_) => HttpServerResponse.json({ spanId: _.spanId, parent: _.parent })) + ).pipe(HttpRouter.serve, Layer.build) + const requestSpan = yield* Effect.makeSpan("client request") + const body = yield* HttpClient.get("/").pipe( + Effect.flatMap((response) => response.json), + Effect.provideService( + Tracer.Tracer, + Tracer.make({ + span(options) { + assert.strictEqual(options.name, "http.client GET") + assert.strictEqual(options.kind, "client") + assert(options.parent._tag === "Some") + if (options.parent.value._tag !== "Span") throw new Error("Expected span parent") + assert.strictEqual(options.parent.value.name, "request parent") + return requestSpan + } + }) + ), + Effect.withSpan("request parent"), + Effect.repeat({ times: 2 }) + ) + assert.strictEqual((body as any).parent._tag, "Some") + assert.strictEqual((body as any).parent.value.spanId, requestSpan.spanId) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("html", () => + Effect.gen(function*() { + yield* HttpRouter.addAll([ + HttpRouter.route("GET", "/home", HttpServerResponse.html("")), + HttpRouter.route( + "GET", + "/about", + HttpServerResponse.html`${Effect.succeed("")}` + ), + HttpRouter.route( + "GET", + "/stream", + HttpServerResponse.htmlStream`${Stream.make("", 123, "hello")}` + ) + ]).pipe(HttpRouter.serve, Layer.build) + assert.strictEqual(yield* HttpClient.get("/home").pipe(Effect.flatMap((_) => _.text)), "") + assert.strictEqual(yield* HttpClient.get("/about").pipe(Effect.flatMap((_) => _.text)), "") + assert.strictEqual( + yield* HttpClient.get("/stream").pipe(Effect.flatMap((_) => _.text)), + "123hello" + ) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("setCookie", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "GET", + "/home", + HttpServerResponse.empty().pipe( + HttpServerResponse.setCookieUnsafe("test", "value"), + HttpServerResponse.setCookieUnsafe("test2", "value2", { + httpOnly: true, + secure: true, + sameSite: "lax", + partitioned: true, + path: "/", + domain: "example.com", + expires: new Date(2022, 1, 1), + maxAge: "5 minutes" + }) + ) + ).pipe(HttpRouter.serve, Layer.build) + const response = yield* HttpClient.get("/home") + assert.deepStrictEqual( + response.cookies.toJSON(), + Cookies.fromReadonlyRecord({ + test: Cookies.makeCookieUnsafe("test", "value"), + test2: Cookies.makeCookieUnsafe("test2", "value2", { + httpOnly: true, + secure: true, + sameSite: "lax", + partitioned: true, + path: "/", + domain: "example.com", + expires: new Date(2022, 1, 1), + maxAge: Duration.minutes(5) + }) + }).toJSON() + ) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.live("uninterruptible routes", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "GET", + "/home", + Effect.gen(function*() { + const fiber = Fiber.getCurrent()! + setTimeout(() => fiber.interruptUnsafe(fiber.id), 10) + yield* Effect.sleep(50) + return HttpServerResponse.empty() + }), + { uninterruptible: true } + ).pipe(HttpRouter.serve, Layer.build) + assert.strictEqual((yield* HttpClient.get("/home")).status, 204) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.live("disposes after a client aborts a handler awaiting an upstream request", () => + Effect.gen(function*() { + const upstreamStarted = Latch.makeUnsafe() + const upstream = yield* Effect.acquireRelease( + Effect.sync(() => { + const controller = new AbortController() + const server = Deno.serve( + { hostname: "127.0.0.1", port: 0, onListen: () => {}, signal: controller.signal }, + (request) => { + upstreamStarted.openUnsafe() + return new Promise((resolve) => { + request.signal.addEventListener("abort", () => resolve(new Response()), { once: true }) + controller.signal.addEventListener("abort", () => resolve(new Response()), { once: true }) + }) + } + ) + return { controller, server } + }), + ({ controller, server }) => + Effect.sync(() => controller.abort()).pipe(Effect.andThen(Effect.promise(() => server.finished))) + ) + const upstreamPort = (upstream.server.addr as Deno.NetAddr).port + const router = HttpRouter.use((router) => + router.add( + "GET", + "/", + Effect.gen(function*() { + yield* HttpClient.head(`http://127.0.0.1:${upstreamPort}`) + return HttpServerResponse.empty() + }) + ) + ) + const serverLayer = DenoHttpServer.layer({ + hostname: "127.0.0.1", + port: 0, + onListen: () => {}, + gracefulShutdownTimeout: "100 millis" + }) + const services = Layer.merge(serverLayer, FetchHttpClient.layer) + const runtime = yield* Effect.acquireRelease( + Effect.sync(() => + ManagedRuntime.make(Layer.merge( + services, + HttpRouter.serve(router).pipe(Layer.provide(services)) + )) + ), + (runtime) => Effect.promise(() => runtime.dispose()) + ) + yield* Effect.promise(() => runtime.context()) + const downstreamServer = yield* Effect.promise(() => runtime.runPromise(HttpServer.HttpServer)) + const downstreamPort = (downstreamServer.address as HttpServer.TcpAddress).port + + const controller = new AbortController() + const downstream = fetch(`http://127.0.0.1:${downstreamPort}`, { + signal: controller.signal + }).catch(() => undefined) + yield* upstreamStarted.await + controller.abort() + yield* Effect.promise(() => downstream) + + const disposed = yield* Effect.promise(() => runtime.dispose()).pipe(Effect.timeoutOption("2 seconds")) + assert.strictEqual(disposed._tag, "Some") + }).pipe(Effect.scoped)) + + describe("HttpServerRespondable", () => { + it.effect("error/schema", () => + Effect.gen(function*() { + class CustomError extends Schema.ErrorClass("CustomError")({ + _tag: Schema.tag("CustomError"), + name: Schema.String + }) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(CustomError)(this, { status: 599 }) + } + } + yield* HttpRouter.add("GET", "/home", new CustomError({ name: "test" })).pipe( + HttpRouter.serve, + Layer.build + ) + const response = yield* HttpClient.get("/home") + assert.strictEqual(response.status, 599) + assert.deepStrictEqual( + yield* HttpClientResponse.schemaBodyJson(CustomError)(response), + new CustomError({ name: "test" }) + ) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("httpapi error", () => + Effect.gen(function*() { + yield* HttpRouter.add("GET", "/home", new HttpApiError.BadRequest({})).pipe( + HttpRouter.serve, + Layer.build + ) + assert.strictEqual((yield* HttpClient.get("/home")).status, 400) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + }) + + it.effect("RouterConfig", () => + Effect.gen(function*() { + yield* HttpRouter.add("GET", "/:param", Effect.succeed(HttpServerResponse.empty())).pipe( + HttpRouter.serve, + Layer.build + ) + assert.strictEqual((yield* HttpClient.get("/123456")).status, 404) + assert.strictEqual((yield* HttpClient.get("/12345")).status, 204) + }).pipe( + Effect.provide([ + DenoHttpServer.layerTest, + Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 5 }) + ]) + )) + + it.effect("HttpRouter prefixed", () => + Effect.gen(function*() { + const handler = HttpRouter.serve(HttpRouter.use(Effect.fnUntraced(function*(router_) { + const router = router_.prefixed("/todos") + yield* router.add( + "GET", + "/:id", + Effect.flatMap(HttpRouter.schemaParams(IdParams), ({ id }) => todoResponse({ id, title: "test" })) + ) + yield* router.addAll([ + HttpRouter.route("GET", "/", Effect.succeed(HttpServerResponse.text("root"))) + ]) + }))) + yield* Layer.build(handler) + assert.deepStrictEqual( + yield* HttpClient.get("/todos/1").pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))), + { id: 1, title: "test" } + ) + assert.strictEqual(yield* HttpClient.get("/todos").pipe(Effect.flatMap((_) => _.text)), "root") + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("cancels a file body for HEAD requests", () => + Effect.gen(function*() { + let cancelled = false + const fileResponse = yield* HttpServerResponse.file(fixture).pipe( + Effect.updateService(HttpPlatform.HttpPlatform, (platform) => ({ + ...platform, + fileResponse: (path, options) => + Effect.map(platform.fileResponse(path, options), (response) => { + assert.strictEqual(response.body._tag, "Raw") + const source = (response.body as HttpBody.Raw).body + assert(source instanceof ReadableStream) + const reader = source.getReader() + const body = new ReadableStream({ + pull(controller) { + return reader.read().then(({ done, value }) => { + if (done) controller.close() + else controller.enqueue(value) + }) + }, + cancel(reason) { + cancelled = true + return reader.cancel(reason) + } + }) + return HttpServerResponse.raw(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + cookies: response.cookies + }) + }) + })) + ) + yield* fileResponse.pipe( + Effect.succeed, + HttpServer.serveEffect() + ) + const response = yield* HttpClient.head("/") + assert.strictEqual(response.status, 200) + assert(cancelled) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("ignores cancellation errors for HEAD response bodies", () => { + const body = new ReadableStream() + const reader = body.getReader() + return Effect.gen(function*() { + yield* HttpServerResponse.raw(body).pipe( + Effect.succeed, + HttpServer.serveEffect() + ) + const response = yield* HttpClient.head("/") + assert.strictEqual(response.status, 200) + }).pipe( + Effect.ensuring(Effect.sync(() => reader.releaseLock())), + Effect.provide(DenoHttpServer.layerTest) + ) + }) + + it.effect("round trips WebSocket frames and closes cleanly", () => + Effect.gen(function*() { + yield* serveWebSocket(Effect.fnUntraced(function*(socket) { + const write = yield* socket.writer + yield* socket.runRaw((message) => write(message)) + })) + const server = yield* HttpServer.HttpServer + const port = (server.address as HttpServer.TcpAddress).port + const messages = yield* connectWebSocket(`ws://127.0.0.1:${port}/`, (socket) => socket.send("hello"), 1) + assert.deepStrictEqual(messages, ["hello"]) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("preserves eager WebSocket frames across an async boundary", () => + Effect.gen(function*() { + yield* serveWebSocket(Effect.fnUntraced(function*(socket) { + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))) + const write = yield* socket.writer + yield* socket.runRaw((message) => write(message)) + })) + + const server = yield* HttpServer.HttpServer + const port = (server.address as HttpServer.TcpAddress).port + const messages = yield* connectWebSocket(`ws://127.0.0.1:${port}/`, (socket) => { + socket.send("first") + socket.send("second") + }, 2) + + assert.deepStrictEqual(messages, ["first", "second"]) + }).pipe(Effect.provide(DenoHttpServer.layerTest))) + + it.effect("delivers binary WebSocket frames as Uint8Array", () => + Effect.gen(function*() { + const received = yield* Queue.unbounded() + yield* serveWebSocket(Effect.fnUntraced(function*(socket) { + yield* socket.runRaw((message) => { + assert(message instanceof Uint8Array) + return Queue.offer(received, message) + }) + })) + + const server = yield* HttpServer.HttpServer + const port = (server.address as HttpServer.TcpAddress).port + const socket = yield* openWebSocket(`ws://127.0.0.1:${port}/`) + socket.send(new Uint8Array([1, 2, 3])) + + assert.deepStrictEqual(yield* Queue.take(received), new Uint8Array([1, 2, 3])) + socket.close() + }).pipe(Effect.scoped, Effect.provide(DenoHttpServer.layerTest))) +}) + +const serveWebSocket = ( + run: (socket: Socket.Socket) => Effect.Effect +) => + HttpRouter.add( + "GET", + "/", + Effect.gen(function*() { + const request = yield* HttpServerRequest.HttpServerRequest + const socket = yield* request.upgrade + yield* run(socket) + return HttpServerResponse.empty() + }) + ).pipe(HttpRouter.serve, Layer.build) + +const openWebSocket = (url: string) => + Effect.acquireRelease( + Effect.callback((resume) => { + const socket = new WebSocket(url) + socket.addEventListener("open", () => resume(Effect.succeed(socket)), { once: true }) + socket.addEventListener("error", () => resume(Effect.fail(new Error("WebSocket connection failed"))), { + once: true + }) + }), + (socket) => Effect.sync(() => socket.close()) + ) + +const connectWebSocket = (url: string, onOpen: (socket: WebSocket) => void, messageCount: number) => + Effect.acquireUseRelease( + openWebSocket(url), + (socket) => + Effect.gen(function*() { + const messages: Array = [] + const fiber = yield* Effect.callback((resume) => { + socket.addEventListener("message", (event) => { + messages.push(event.data) + if (messages.length === messageCount) resume(Effect.void) + }) + socket.addEventListener("error", () => resume(Effect.fail(new Error("WebSocket connection failed"))), { + once: true + }) + onOpen(socket) + }).pipe(Effect.forkChild) + yield* Fiber.join(fiber) + return messages + }), + (socket) => Effect.sync(() => socket.close()) + )