mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-06-30 17:09:38 +02:00
Model websocket adapter failures with tagged errors
This commit is contained in:
parent
74ba05703a
commit
da23ac0657
3 changed files with 178 additions and 28 deletions
|
|
@ -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<string, any>` 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`
|
||||
|
||||
|
|
|
|||
72
ts/packages/client/src/__tests__/websocket-adapter.test.ts
Normal file
72
ts/packages/client/src/__tests__/websocket-adapter.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>()(
|
||||
"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<string, unknown> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue