| 1 | import type * as cf from "@cloudflare/workers-types"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schema from "effect/Schema"; |
| 5 | import * as HttpBody from "effect/http/HttpBody"; |
| 6 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 7 | import { DurableObjectState } from "./DurableObjectState.ts"; |
| 8 | |
| 9 | export type RawWebSocket = cf.WebSocket; |
| 10 | |
| 11 | /** |
| 12 | * An application attachment could not be encoded, decoded, read, or written. |
| 13 | * Catch this tag to choose whether to fail, ignore the attachment, or close the |
| 14 | * connection. Codec failures and native exceptions are preserved in `cause`. |
| 15 | */ |
| 16 | export class WebSocketAttachmentError extends Data.TaggedError( |
| 17 | "WebSocketAttachmentError", |
| 18 | )<{ |
| 19 | /** The failed attachment operation, or `missing` for a nullish attachment. */ |
| 20 | readonly reason: "encode" | "decode" | "missing" | "read" | "write"; |
| 21 | /** A description of the failed operation. */ |
| 22 | readonly message: string; |
| 23 | /** The original schema error or native exception, when present. */ |
| 24 | readonly cause?: unknown; |
| 25 | }> {} |
| 26 | |
| 27 | export interface WebSocket { |
| 28 | readonly ws: RawWebSocket; |
| 29 | send(data: string | Uint8Array): Effect.Effect<void>; |
| 30 | close(code: number, reason: string): Effect.Effect<void>; |
| 31 | /** |
| 32 | * Encode an application value and persist the encoded representation alongside |
| 33 | * this socket. The native structured-clone and serialized-size limits apply; |
| 34 | * schema validation does not guarantee that the encoded value is cloneable. |
| 35 | * Requires only the codec's encoding services. Does not close the socket. |
| 36 | * |
| 37 | * @example |
| 38 | * ```ts |
| 39 | * yield* socket.setAttachment(Schema.NumberFromString, 42); |
| 40 | * // The unchecked deserializeAttachment() now returns "42". |
| 41 | * ``` |
| 42 | */ |
| 43 | setAttachment<S extends Schema.Constraint>( |
| 44 | schema: S, |
| 45 | value: NoInfer<S["Type"]>, |
| 46 | ): Effect.Effect<void, WebSocketAttachmentError, S["EncodingServices"]>; |
| 47 | /** |
| 48 | * Read and decode an application attachment, including after hibernation. |
| 49 | * A nullish native value fails with reason `missing`; other values are decoded |
| 50 | * from the codec's encoded side. Requires only its decoding services. |
| 51 | * No envelope or schema version is added, so unchecked attachments interoperate |
| 52 | * and applications own migrations and invalid-attachment policy. |
| 53 | * |
| 54 | * @example |
| 55 | * ```ts |
| 56 | * const count = yield* socket.getAttachment(Schema.NumberFromString); |
| 57 | * // count is a number, not the stored string. |
| 58 | * ``` |
| 59 | */ |
| 60 | getAttachment<S extends Schema.Constraint>( |
| 61 | schema: S, |
| 62 | ): Effect.Effect<S["Type"], WebSocketAttachmentError, S["DecodingServices"]>; |
| 63 | serializeAttachment<T>(value: T): void; |
| 64 | deserializeAttachment<T>(): T | null; |
| 65 | } |
| 66 | |
| 67 | export const fromWebSocket = (ws: RawWebSocket): WebSocket => ({ |
| 68 | ws, |
| 69 | send: (data) => Effect.sync(() => ws.send(data as any)), |
| 70 | close: (code, reason) => Effect.sync(() => ws.close(code, reason)), |
| 71 | setAttachment: (schema, value) => |
| 72 | Schema.encodeEffect(schema)(value).pipe( |
| 73 | Effect.mapError( |
| 74 | (cause) => |
| 75 | new WebSocketAttachmentError({ |
| 76 | reason: "encode", |
| 77 | message: "Could not encode WebSocket attachment", |
| 78 | cause, |
| 79 | }), |
| 80 | ), |
| 81 | Effect.flatMap((encoded) => |
| 82 | Effect.try({ |
| 83 | try: () => ws.serializeAttachment(encoded), |
| 84 | catch: (cause) => |
| 85 | new WebSocketAttachmentError({ |
| 86 | reason: "write", |
| 87 | message: "Could not write WebSocket attachment", |
| 88 | cause, |
| 89 | }), |
| 90 | }), |
| 91 | ), |
| 92 | ), |
| 93 | getAttachment: (schema) => |
| 94 | Effect.try({ |
| 95 | try: () => ws.deserializeAttachment(), |
| 96 | catch: (cause) => |
| 97 | new WebSocketAttachmentError({ |
| 98 | reason: "read", |
| 99 | message: "Could not read WebSocket attachment", |
| 100 | cause, |
| 101 | }), |
| 102 | }).pipe( |
| 103 | Effect.flatMap((encoded) => |
| 104 | encoded === null || encoded === undefined |
| 105 | ? Effect.fail( |
| 106 | new WebSocketAttachmentError({ |
| 107 | reason: "missing", |
| 108 | message: "WebSocket attachment is missing", |
| 109 | }), |
| 110 | ) |
| 111 | : Schema.decodeUnknownEffect(schema)(encoded).pipe( |
| 112 | Effect.mapError( |
| 113 | (cause) => |
| 114 | new WebSocketAttachmentError({ |
| 115 | reason: "decode", |
| 116 | message: "Could not decode WebSocket attachment", |
| 117 | cause, |
| 118 | }), |
| 119 | ), |
| 120 | ), |
| 121 | ), |
| 122 | ), |
| 123 | serializeAttachment: (value) => ws.serializeAttachment(value), |
| 124 | deserializeAttachment: () => ws.deserializeAttachment() as any, |
| 125 | }); |
| 126 | |
| 127 | // declare global { |
| 128 | // const WebSocketPair: new () => [cf.WebSocket, cf.WebSocket]; |
| 129 | // } |
| 130 | |
| 131 | export const upgrade = Effect.fn(function* () { |
| 132 | const _Response = Response as any as typeof cf.Response; |
| 133 | const ctx = yield* DurableObjectState; |
| 134 | // @ts-expect-error |
| 135 | const [client, server] = new WebSocketPair(); |
| 136 | const serverSocket = fromWebSocket(server); |
| 137 | yield* ctx.acceptWebSocket(serverSocket); |
| 138 | const rawResponse = new _Response(null, { |
| 139 | status: 101, |
| 140 | webSocket: client, |
| 141 | }); |
| 142 | const effectResponse = HttpServerResponse.setBody( |
| 143 | HttpServerResponse.empty({ status: 101 }), |
| 144 | HttpBody.raw(rawResponse), |
| 145 | ); |
| 146 | return [effectResponse, serverSocket] as const; |
| 147 | }); |