diff --git a/ts/EFFECT_NATIVE_REWRITE_AUDIT.md b/ts/EFFECT_NATIVE_REWRITE_AUDIT.md index 6021f8ab..81eb1f4f 100644 --- a/ts/EFFECT_NATIVE_REWRITE_AUDIT.md +++ b/ts/EFFECT_NATIVE_REWRITE_AUDIT.md @@ -12,20 +12,20 @@ Verified source roots: - Effect v4 subtree: `/home/elpresidank/YeeBois/projects/beep-effect2/.repos/effect-v4` - Installed Effect beta used by this workspace: `ts/node_modules/effect` -Current signal counts from `ts/packages` after the 2026-06-02 Client RPC -managed runtime slice: +Current signal counts from `ts/packages` after the 2026-06-02 Client +WebSocket adapter slice: | Signal | Count | | --- | ---: | | `Effect.runPromise` | 203 | | `Map<` | 88 | -| `WebSocket` | 43 | +| `WebSocket` | 72 | | `new Map` | 62 | | `toPromiseRequestor` | 0 | | `makeAsyncProcessor` | 19 | | `receive(` | 18 | | `while (` | 9 | -| `new Error` | 14 | +| `new Error` | 12 | | `new Promise` | 10 | | `JSON.parse` | 7 | | `localStorage` | 8 | @@ -48,6 +48,12 @@ Notes: - The `Effect.runPromise` and `WebSocket` counts dropped in this snapshot because `EffectRpcClient` now owns its RPC/socket layer with `ManagedRuntime` and uses Effect's WebSocket constructor layer. +- The raw `WebSocket` count increased in this snapshot because the adapter + slice added focused tests and typed adapter names; production + `websocket-adapter.ts` is now clean of `try`/`catch`, normal `Error`, and + the previous constructor assertions. +- The `new Error` count dropped because `websocket-adapter.ts` now throws + `S.TaggedErrorClass` adapter errors. - `Record` and `throwLibrarianServiceError` are now clean in `ts/packages`. @@ -377,6 +383,29 @@ Notes: - `cd ts && bun run test` - `git diff --check` +### 2026-06-02: Client WebSocket Adapter Error Slice + +- Status: migrated and root-verified. +- Completed: + - `ts/packages/client/src/socket/websocket-adapter.ts` now models host + fallback failures with `WebSocketAdapterError` via + `S.TaggedErrorClass`. + - Synchronous `getWebSocketConstructor()` and `getRandomValues()` facades + keep their public signatures while using `Result.try` instead of local + `try`/`catch` blocks. + - Runtime predicates now narrow WebSocket constructor modules and crypto + modules without the previous constructor/result type assertions. + - New adapter tests cover global WebSocket selection, optional `ws` + fallback, global crypto, typed crypto failure, and typed adapter errors. +- Verification: + - `bun run --cwd ts/packages/client build` + - `bun run --cwd ts/packages/client test -- src/__tests__/websocket-adapter.test.ts` + - `bun run --cwd ts/packages/client test` + - `cd ts && bun run check` + - `cd ts && bun run build` + - `cd ts && bun run test` + - `git diff --check` + ## Subagent Findings To Preserve - MCP/workbench: @@ -402,12 +431,11 @@ Notes: - Gateway/client: - `EffectRpcClient` now owns its socket/RPC layer with `ManagedRuntime`. Remaining client cleanup should focus on `trustgraph-socket.ts` - higher-level normal `Error` throws/JSON parsing and the public synchronous - `websocket-adapter.ts` compatibility helpers. + higher-level normal `Error` throws/JSON parsing and the client newable + factory assertions. - Knowledge streams still duplicate legacy end-of-stream handling. - - WebSocket adapter shims still contain host-boundary `try`/`catch` and - normal `Error` construction, but their sync exports are public API and - should be migrated in a separate compatibility-preserving slice. + - WebSocket adapter host fallbacks now use `Result.try` and tagged adapter + errors while preserving sync exports. - RAG/providers/storage: - RAG and agent requestor bridges are complete: `toPromiseRequestor` has no remaining `ts/packages` matches. @@ -423,7 +451,6 @@ Notes: - TrustGraph evidence: - `ts/packages/client/src/socket/effect-rpc-client.ts` - `ts/packages/client/src/socket/trustgraph-socket.ts` - - `ts/packages/client/src/socket/websocket-adapter.ts` - Effect primitives: - `effect/unstable/socket` `Socket.makeWebSocket`, `fromWebSocket`, `toChannel`, `layerWebSocket`. @@ -436,8 +463,8 @@ Notes: - Expose Promise-returning methods through a thin adapter. - Finish replacing remaining normal client `Error` constructors with tagged errors before they cross into shared Effect code. - - Preserve public sync exports in `websocket-adapter.ts` while moving host - failure capture toward typed Effect helpers. + - Replace remaining client newable factory assertions with a typed factory + shape that preserves current constructor/function compatibility. - Tests: - `cd ts && bun run --cwd packages/client test` diff --git a/ts/packages/client/src/__tests__/websocket-adapter.test.ts b/ts/packages/client/src/__tests__/websocket-adapter.test.ts new file mode 100644 index 00000000..9a4c0ad8 --- /dev/null +++ b/ts/packages/client/src/__tests__/websocket-adapter.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getRandomValues as fillRandomValues, + getWebSocketConstructor, + WebSocketAdapterError, + WS_OPEN, +} from "../socket/websocket-adapter.js"; + +class FakeWebSocket { + readonly readyState = WS_OPEN; + + constructor(readonly url: string) {} + + send(_data: string): void {} + + close(_code?: number, _reason?: string): void {} + + addEventListener(_type: string, _listener: (event: { readonly type: string }) => void): void {} + + removeEventListener(_type: string, _listener: (event: { readonly type: string }) => void): void {} +} + +describe("websocket adapter", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("prefers a compatible global WebSocket constructor", () => { + vi.stubGlobal("WebSocket", FakeWebSocket); + + const Constructor = getWebSocketConstructor(); + const socket = new Constructor("ws://example.test/rpc"); + + expect(socket).toBeInstanceOf(FakeWebSocket); + expect(socket.readyState).toBe(WS_OPEN); + }); + + it("loads the optional ws constructor when no global WebSocket exists", () => { + vi.stubGlobal("WebSocket", undefined); + + expect(getWebSocketConstructor()).toBeTypeOf("function"); + }); + + it("uses global crypto when available", () => { + const getRandomValues = vi.fn((target: Uint32Array) => { + target[0] = 42; + return target; + }); + vi.stubGlobal("crypto", { getRandomValues }); + + const values = fillRandomValues(new Uint32Array(1)); + + expect(values[0]).toBe(42); + expect(getRandomValues).toHaveBeenCalledOnce(); + }); + + it("raises a typed error when no crypto source is available", () => { + vi.stubGlobal("crypto", undefined); + + expect(() => fillRandomValues(new Uint32Array(2))).toThrow(WebSocketAdapterError); + }); + + it("exposes typed adapter errors", () => { + const error = WebSocketAdapterError.make({ + operation: "test", + message: "typed", + }); + + expect(error._tag).toBe("WebSocketAdapterError"); + expect(error.message).toBe("typed"); + }); +}); diff --git a/ts/packages/client/src/socket/websocket-adapter.ts b/ts/packages/client/src/socket/websocket-adapter.ts index bc451f8f..92d9bf27 100644 --- a/ts/packages/client/src/socket/websocket-adapter.ts +++ b/ts/packages/client/src/socket/websocket-adapter.ts @@ -7,6 +7,7 @@ * Provides its own minimal type definitions for the WebSocket API surface * we actually use, so the package does not require DOM lib types. */ +import { Result, Schema as S } from "effect"; // --------------------------------------------------------------------------- // WebSocket readyState constants (identical in browser WebSocket and 'ws') @@ -64,6 +65,41 @@ export interface IsomorphicWebSocketConstructor { new (url: string): IsomorphicWebSocket; } +export class WebSocketAdapterError extends S.TaggedErrorClass()( + "WebSocketAdapterError", + { + message: S.String, + operation: S.String, + }, +) {} + +const adapterError = (operation: string, message: string): WebSocketAdapterError => + WebSocketAdapterError.make({ operation, message }); + +interface CryptoModule { + readonly randomFillSync: (array: Uint32Array) => Uint32Array; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isWebSocketConstructor(value: unknown): value is IsomorphicWebSocketConstructor { + return typeof value === "function"; +} + +function websocketConstructorFromModule(value: unknown): IsomorphicWebSocketConstructor | undefined { + if (isWebSocketConstructor(value)) return value; + if (!isRecord(value)) return undefined; + if (isWebSocketConstructor(value.WebSocket)) return value.WebSocket; + if (isWebSocketConstructor(value.default)) return value.default; + return undefined; +} + +function isCryptoModule(value: unknown): value is CryptoModule { + return isRecord(value) && typeof value.randomFillSync === "function"; +} + // --------------------------------------------------------------------------- // Runtime helpers // --------------------------------------------------------------------------- @@ -74,24 +110,33 @@ export interface IsomorphicWebSocketConstructor { * - Browser: uses `globalThis.WebSocket` (native) * - Node.js: dynamically `require`s the `ws` npm package * - * @throws Error if no WebSocket implementation is available + * @throws WebSocketAdapterError if no WebSocket implementation is available */ export function getWebSocketConstructor(): IsomorphicWebSocketConstructor { // Browser environment (or Deno, Bun, etc. where WebSocket is global) - if (typeof globalThis !== "undefined" && "WebSocket" in globalThis) { - return (globalThis as unknown as { WebSocket: IsomorphicWebSocketConstructor }).WebSocket; + const globalWebSocket = typeof globalThis !== "undefined" ? globalThis.WebSocket : undefined; + if (isWebSocketConstructor(globalWebSocket)) { + return globalWebSocket; } // Node.js environment — dynamically require 'ws' - try { + const wsModule = Result.getOrThrow(Result.try({ // eslint-disable-next-line @typescript-eslint/no-require-imports - const ws = require("ws"); - return ws as IsomorphicWebSocketConstructor; - } catch { - throw new Error( - 'WebSocket is not available. In Node.js, install the "ws" package: npm install ws', + try: (): unknown => require("ws"), + catch: () => + adapterError( + "websocket-constructor", + 'WebSocket is not available. In Node.js, install the "ws" package: npm install ws', + ), + })); + const wsConstructor = websocketConstructorFromModule(wsModule); + if (wsConstructor === undefined) { + throw adapterError( + "websocket-constructor", + 'The "ws" package did not export a compatible WebSocket constructor', ); } + return wsConstructor; } /** @@ -122,14 +167,20 @@ export function getRandomValues(array: Uint32Array): Uint32Array { return array; } // Node.js fallback for versions < 19 where globalThis.crypto may not exist - try { + const cryptoModule = Result.getOrThrow(Result.try({ // eslint-disable-next-line @typescript-eslint/no-require-imports - const { randomFillSync } = require("node:crypto"); - return randomFillSync(array) as Uint32Array; - } catch { - throw new Error( - "No cryptographic random source available. " + - "Upgrade to Node.js 19+ or ensure the 'crypto' module is available.", + try: (): unknown => require("node:crypto"), + catch: () => + adapterError( + "random-values", + "No cryptographic random source available. Upgrade to Node.js 19+ or ensure the 'crypto' module is available.", + ), + })); + if (!isCryptoModule(cryptoModule)) { + throw adapterError( + "random-values", + 'The "node:crypto" module did not export randomFillSync', ); } + return cryptoModule.randomFillSync(array); }