From 34f148c436ff345f3bccc2779aaef4949f9172d5 Mon Sep 17 00:00:00 2001 From: David Chen Date: Tue, 28 Jul 2026 09:21:28 -0700 Subject: [PATCH] refactor: move framework bindings to independent repositories --- packages/nestjs/CHANGELOG.md | 10 - packages/nestjs/README.md | 10 - packages/nestjs/package.json | 109 - packages/nestjs/src/contracts.test.ts | 2899 ----------------- packages/nestjs/src/e2e.test.ts | 706 ---- packages/nestjs/src/index.test.ts | 28 - packages/nestjs/src/index.ts | 2118 ------------ packages/nestjs/src/microservices.ts | 316 -- packages/nestjs/src/native.ts | 1209 ------- packages/nestjs/src/websockets.ts | 427 --- packages/nestjs/tsconfig.json | 14 - packages/nestjs/vitest.config.ts | 8 - .../reactive-layout-node-canvas/package.json | 2 +- packages/solid/CHANGELOG.md | 10 - packages/solid/README.md | 3 - packages/solid/package.json | 51 - packages/solid/src/index.test.ts | 18 - packages/solid/src/index.ts | 47 - packages/solid/tsconfig.json | 13 - packages/svelte/CHANGELOG.md | 10 - packages/svelte/README.md | 3 - packages/svelte/package.json | 51 - packages/svelte/src/index.test.ts | 15 - packages/svelte/src/index.ts | 50 - packages/svelte/tsconfig.json | 13 - packages/vue/CHANGELOG.md | 10 - packages/vue/README.md | 3 - packages/vue/package.json | 51 - packages/vue/src/index.test.ts | 17 - packages/vue/src/index.ts | 45 - packages/vue/tsconfig.json | 13 - pnpm-lock.yaml | 374 ++- ...kage.mjs => check-node-canvas-package.mjs} | 23 +- 33 files changed, 189 insertions(+), 8487 deletions(-) delete mode 100644 packages/nestjs/CHANGELOG.md delete mode 100644 packages/nestjs/README.md delete mode 100644 packages/nestjs/package.json delete mode 100644 packages/nestjs/src/contracts.test.ts delete mode 100644 packages/nestjs/src/e2e.test.ts delete mode 100644 packages/nestjs/src/index.test.ts delete mode 100644 packages/nestjs/src/index.ts delete mode 100644 packages/nestjs/src/microservices.ts delete mode 100644 packages/nestjs/src/native.ts delete mode 100644 packages/nestjs/src/websockets.ts delete mode 100644 packages/nestjs/tsconfig.json delete mode 100644 packages/nestjs/vitest.config.ts delete mode 100644 packages/solid/CHANGELOG.md delete mode 100644 packages/solid/README.md delete mode 100644 packages/solid/package.json delete mode 100644 packages/solid/src/index.test.ts delete mode 100644 packages/solid/src/index.ts delete mode 100644 packages/solid/tsconfig.json delete mode 100644 packages/svelte/CHANGELOG.md delete mode 100644 packages/svelte/README.md delete mode 100644 packages/svelte/package.json delete mode 100644 packages/svelte/src/index.test.ts delete mode 100644 packages/svelte/src/index.ts delete mode 100644 packages/svelte/tsconfig.json delete mode 100644 packages/vue/CHANGELOG.md delete mode 100644 packages/vue/README.md delete mode 100644 packages/vue/package.json delete mode 100644 packages/vue/src/index.test.ts delete mode 100644 packages/vue/src/index.ts delete mode 100644 packages/vue/tsconfig.json rename scripts/{check-ecosystem-package.mjs => check-node-canvas-package.mjs} (89%) diff --git a/packages/nestjs/CHANGELOG.md b/packages/nestjs/CHANGELOG.md deleted file mode 100644 index 02fe075..0000000 --- a/packages/nestjs/CHANGELOG.md +++ /dev/null @@ -1,10 +0,0 @@ -# @graphrefly/nestjs - -## 0.1.0 - -### Minor Changes - -- c62333b: Publish the independently owned React, Vue, Solid, Svelte, NestJS and - node-canvas integration packages. Each package consumes the strict - zero-dependency `@graphrefly/ts` core through focused package exports and owns - only its framework or runtime peer boundary. diff --git a/packages/nestjs/README.md b/packages/nestjs/README.md deleted file mode 100644 index 8e1efc1..0000000 --- a/packages/nestjs/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# @graphrefly/nestjs - -NestJS boundary bindings for GraphReFly. - -- `@graphrefly/nestjs` provides the structural boundary and decorator contract. -- `@graphrefly/nestjs/native` provides HTTP, guard, filter, cron, and lifecycle bridges. -- `@graphrefly/nestjs/microservices` provides message transport bridges. -- `@graphrefly/nestjs/websockets` provides WebSocket bridges. - -Host request, response, socket, and cancellation handles remain private to the adapter. diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json deleted file mode 100644 index 293bb34..0000000 --- a/packages/nestjs/package.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "name": "@graphrefly/nestjs", - "version": "0.1.0", - "description": "NestJS boundary bindings for GraphReFly.", - "repository": { - "type": "git", - "url": "git+https://github.com/graphrefly/graphrefly-react.git", - "directory": "packages/nestjs" - }, - "type": "module", - "sideEffects": false, - "main": "dist/index.cjs", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - }, - "./native": { - "import": { - "types": "./dist/native.d.ts", - "default": "./dist/native.js" - }, - "require": { - "types": "./dist/native.d.cts", - "default": "./dist/native.cjs" - } - }, - "./microservices": { - "import": { - "types": "./dist/microservices.d.ts", - "default": "./dist/microservices.js" - }, - "require": { - "types": "./dist/microservices.d.cts", - "default": "./dist/microservices.cjs" - } - }, - "./websockets": { - "import": { - "types": "./dist/websockets.d.ts", - "default": "./dist/websockets.js" - }, - "require": { - "types": "./dist/websockets.d.cts", - "default": "./dist/websockets.cjs" - } - } - }, - "files": [ - "dist", - "README.md", - "package.json" - ], - "scripts": { - "build": "tsup src/index.ts src/native.ts src/microservices.ts src/websockets.ts --format esm,cjs --dts --clean && node ../../scripts/check-ecosystem-package.mjs .", - "test": "vitest run" - }, - "peerDependencies": { - "@graphrefly/ts": ">=0.6.2 <1.0.0", - "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0", - "@nestjs/microservices": "^11.0.0", - "@nestjs/websockets": "^11.0.0", - "rxjs": "^7.8.0" - }, - "peerDependenciesMeta": { - "@nestjs/common": { - "optional": true - }, - "@nestjs/core": { - "optional": true - }, - "@nestjs/microservices": { - "optional": true - }, - "@nestjs/websockets": { - "optional": true - }, - "rxjs": { - "optional": true - } - }, - "devDependencies": { - "@graphrefly/ts": "0.6.2", - "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0", - "@nestjs/microservices": "^11.0.0", - "@nestjs/platform-express": "^11.0.0", - "@nestjs/platform-ws": "^11.0.0", - "@nestjs/websockets": "^11.0.0", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.0", - "tsup": "^8.5.1", - "typescript": "^5.7.0", - "vitest": "^4.1.7" - }, - "publishConfig": { - "access": "public" - }, - "license": "MIT" -} diff --git a/packages/nestjs/src/contracts.test.ts b/packages/nestjs/src/contracts.test.ts deleted file mode 100644 index c963796..0000000 --- a/packages/nestjs/src/contracts.test.ts +++ /dev/null @@ -1,2899 +0,0 @@ -import { depLatest, graph } from "@graphrefly/ts"; -import { - externalStore, - jotaiAtom, - nanoAtom, - nodeSnapshot, - readableStore, - recordReadableStore, - signalFromNode, - subscribeNodeValues, - writableStore, - zustandStore, -} from "@graphrefly/ts/adapters"; -import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core"; -import { describe, expect, it, vi } from "vitest"; -import { - createNestGraphBoundaryInterceptor, - createNestGraphBoundaryRunner, - fromNestCron, - fromNestDiagnostics, - fromNestError, - fromNestGuard, - fromNestIntercept, - fromNestLifecycle, - fromNestReq, - GRAPHREFLY_REQUEST_GRAPH, - GRAPHREFLY_ROOT_GRAPH, - GraphCron, - GraphError, - GraphFilter, - GraphGuard, - GraphGuardDecision, - type GraphGuardDecision as GraphGuardDecisionPayload, - GraphHttpReply, - GraphInterval, - GraphLifecycle, - GraphReq, - getGraphToken, - getNestBoundaryBindings, - getNestBoundaryToken, - getNodeToken, - type HttpDataIssue, - issueResponse, - lowerHttpReplyPayload, - lowerProtocolError, - NEST_BOUNDARY_BINDINGS, - CRON_HANDLERS as NEST_CRON_HANDLERS, - EVENT_HANDLERS as NEST_EVENT_HANDLERS, - INTERVAL_HANDLERS as NEST_INTERVAL_HANDLERS, - type NestBoundaryEnvelope, - type NestDiagnosticIngressBoundary, - type NestReplyEnvelope, - nestProvider, - OnGraphEvent, - protocolError, - sanitizeNestDiagnostic, - toNestHttp, -} from "./index.js"; -import { - createGraphMessageBridge, - fromNestMessage, - GRAPHREFLY_NEST_MESSAGE_BRIDGE, - GraphMessage, - type GraphMessageBridge, - GraphMessageReply, - provideGraphMessageProviders, -} from "./microservices.js"; -import { - createGraphCronController, - createGraphExceptionFilter, - createGraphGuardDeniedFilter, - createNestGraphGuardAwaitScope, - GraphGuardDeniedException, - GraphGuardDeniedFilter, - graphCronTarget, - graphLifecycleTarget, - isGraphGuardDeniedException, - provideGraphBoundaryInterceptor, - provideGraphCronScheduler, - provideGraphExceptionFilter, - provideGraphGuard, - provideGraphGuardDeniedFilter, - provideGraphLifecycleHooks, - provideGraphNativeHttpProviders, - provideGraphNativeProviders, -} from "./native.js"; -import { - createGraphWsBridge, - fromNestWs, - GRAPHREFLY_NEST_WS_BRIDGE, - GraphWs, - GraphWsAck, - type GraphWsBridge, - GraphWsReply, - provideGraphWsProviders, -} from "./websockets.js"; - -describe("framework-neutral store adapters (B61)", () => { - it("adapts a node to a readable store with one immediate snapshot", () => { - const g = graph(); - const count = g.state(1); - const store = readableStore(count); - const seen: Array = []; - - const unsubscribe = store.subscribe((value) => seen.push(value)); - count.set(2); - unsubscribe(); - count.set(3); - - expect(store.get()).toBe(3); - expect(seen).toEqual([1, 2]); - expect(nodeSnapshot(count)).toBe(3); - }); - - it("adapts a StateNode to a writable store", () => { - const g = graph(); - const count = g.state(1); - const store = writableStore(count); - const seen: Array = []; - - const unsubscribe = store.subscribe((value) => seen.push(value)); - store.set(2); - store.update((value) => (value ?? 0) + 3); - unsubscribe(); - - expect(store.get()).toBe(5); - expect(count.cache).toBe(5); - expect(seen).toEqual([1, 2, 5]); - }); - - it("supports change-only value subscriptions", () => { - const g = graph(); - const count = g.state(1); - const seen: Array = []; - - const unsubscribe = subscribeNodeValues(count, (value) => seen.push(value), { - changesOnly: true, - }); - count.set(2); - unsubscribe(); - count.set(3); - - expect(seen).toEqual([2]); - }); - - it("keeps activation DATA while suppressing cached and replayed subscribe history", () => { - const g = graph(); - const count = g.state(2); - const doubled = g.derived([count], (value) => value * 2); - const coldSeen: Array = []; - - const unsubscribeCold = readableStore(doubled).subscribe((value) => coldSeen.push(value)); - unsubscribeCold(); - - const replayed = g.node([], null, { replayBuffer: 3 }); - replayed.down([ - ["DATA", 1], - ["DATA", 2], - ["DATA", 3], - ]); - const changes: Array = []; - const unsubscribeChanges = subscribeNodeValues(replayed, (value) => changes.push(value), { - changesOnly: true, - }); - replayed.down([["DATA", 4]]); - unsubscribeChanges(); - - expect(coldSeen).toEqual([undefined, 4]); - expect(changes).toEqual([4]); - }); - - it("routes ERROR and COMPLETE lifecycle messages without exposing protocol internals as values", () => { - const g = graph(); - const source = g.node([], null, { resubscribable: true }); - const values: Array = []; - const errors: unknown[] = []; - const complete = vi.fn(); - - const unsubscribe = subscribeNodeValues(source, (value) => values.push(value), { - onError: (error) => errors.push(error), - onComplete: complete, - }); - - source.down([["DATA", 1]]); - const err = new Error("boom"); - source.down([["ERROR", err]]); - - expect(values).toEqual([1]); - expect(errors).toEqual([err]); - expect(complete).not.toHaveBeenCalled(); - - unsubscribe(); - - const next = g.node([], null); - subscribeNodeValues(next, (value) => values.push(value), { onComplete: complete }); - next.down([["COMPLETE"]]); - expect(complete).toHaveBeenCalledTimes(1); - }); - - it("builds a React-compatible external-store shape without importing React", () => { - const g = graph(); - const count = g.state(1); - const store = externalStore(count); - const changed = vi.fn(); - - const unsubscribe = store.subscribe(changed); - expect(store.getSnapshot()).toBe(1); - expect(store.getServerSnapshot()).toBe(1); - - count.set(2); - unsubscribe(); - count.set(3); - - expect(changed).toHaveBeenCalledTimes(1); - expect(store.getSnapshot()).toBe(3); - }); - - it("builds a keyed record store without framework hooks", () => { - const g = graph(); - const keys = g.state(["a"]); - const a = g.state(1); - const b = g.state(2); - const values: Record = { a, b }; - const store = recordReadableStore(keys, (key) => ({ value: values[key] })); - const seen: Array> = []; - - const unsubscribe = store.subscribe((snapshot) => { - seen.push(snapshot as Record); - }); - values.a.set(3); - keys.set(["a", "b"]); - values.b.set(4); - unsubscribe(); - values.a.set(5); - - expect(seen).toEqual([ - { a: { value: 1 } }, - { a: { value: 3 } }, - { a: { value: 3 }, b: { value: 2 } }, - { a: { value: 3 }, b: { value: 4 } }, - ]); - }); - - it("builds Zustand/Jotai/Nanostores/signals-style facades over caller-owned nodes", () => { - const g = graph(); - const state = g.state({ count: 1 }); - const zustand = zustandStore(state); - const zustandSeen: Array = []; - const unsubZustand = zustand.subscribe((next, prev) => { - zustandSeen.push([next.count, prev.count]); - }); - - zustand.setState((prev) => ({ count: prev.count + 1 })); - zustand.setState({ count: 10 }, true); - unsubZustand(); - - const jotai = jotaiAtom(state); - const nano = nanoAtom(state); - const signal = signalFromNode(state); - const jotaiSeen: Array = []; - const nanoSeen: Array = []; - const signalSeen: Array = []; - - const unsubJotai = jotai.subscribe((value) => jotaiSeen.push(value?.count)); - const unsubNano = nano.listen((value) => nanoSeen.push(value?.count)); - const unsubSignal = signal.subscribe((value) => signalSeen.push(value?.count)); - - jotai.set({ count: 11 }); - nano.update((value) => ({ count: (value?.count ?? 0) + 1 })); - signal.set({ count: 13 }); - unsubJotai(); - unsubNano(); - unsubSignal(); - zustand.destroy(); - - expect(zustandSeen).toEqual([ - [2, 1], - [10, 2], - ]); - expect(jotai.get()?.count).toBe(13); - expect(nano.get()?.count).toBe(13); - expect(signal.get()?.count).toBe(13); - expect(jotaiSeen).toEqual([11, 12, 13]); - expect(nanoSeen).toEqual([11, 12, 13]); - expect(signalSeen).toEqual([11, 12, 13]); - }); - - it("delivers custom Zustand snapshots to subscribers instead of raw node DATA", () => { - const g = graph(); - const state = g.state({ count: 1 }); - const store = zustandStore( - state, - { count: 1, inc: () => {} }, - { - getSnapshot: () => ({ - count: state.cache?.count ?? 0, - inc: () => store.setState((prev) => ({ count: prev.count + 1 })), - }), - write: (_node, value) => state.set({ count: value.count }), - }, - ); - const seen: Array<{ count: number; hasInc: boolean }> = []; - const unsub = store.subscribe((next) => { - seen.push({ count: next.count, hasInc: typeof next.inc === "function" }); - }); - - store.setState((prev) => ({ count: prev.count + 1 })); - unsub(); - store.destroy(); - - expect(seen).toEqual([{ count: 2, hasInc: true }]); - }); - - it("requires writable facades to use StateNode.set or an explicit write bridge", () => { - const g = graph(); - const readonly = g.node([], null); - const readonlyObject = g.node<{ count: number }>([], null); - const unsafeWritableStore = writableStore as unknown as (node: typeof readonly) => unknown; - const unsafeZustandStore = zustandStore as unknown as ( - node: typeof readonlyObject, - initialState: { count: number }, - ) => unknown; - - expect(() => unsafeWritableStore(readonly)).toThrow(/set\(value\) or opts\.write/); - expect(() => unsafeZustandStore(readonlyObject, { count: 0 })).toThrow( - /set\(value\) or opts\.write/, - ); - - const written: number[] = []; - const explicit = writableStore(readonly, { - write: (_node, value) => { - written.push(value); - }, - }); - explicit.set(7); - - expect(written).toEqual([7]); - }); - - it("exposes dependency-free NestJS tokens and method metadata helpers", () => { - class Service { - handle() {} - interval() {} - } - const eventInitializers: Array<(this: unknown) => void> = []; - - OnGraphEvent("orders::created")(Service.prototype.handle, { - name: "handle", - addInitializer(fn: (this: unknown) => void) { - eventInitializers.push(fn); - }, - } as ClassMethodDecoratorContext); - GraphInterval(1000)(Service.prototype, "interval", { - value: Service.prototype.interval, - }); - - const service = new Service(); - eventInitializers.forEach((fn) => { - fn.call(service); - fn.call(service); - }); - - expect(GRAPHREFLY_ROOT_GRAPH).toBe(Symbol.for("graphrefly:root-graph")); - expect(GRAPHREFLY_REQUEST_GRAPH).toBe(Symbol.for("graphrefly:request-graph")); - expect(getGraphToken("orders")).toBe(Symbol.for("graphrefly:graph:orders")); - expect(getNodeToken("orders::created")).toBe(Symbol.for("graphrefly:node:orders::created")); - expect(NEST_EVENT_HANDLERS.get(Service)).toEqual([ - { nodeName: "orders::created", methodKey: "handle" }, - ]); - expect(NEST_CRON_HANDLERS.get(Service)).toBeUndefined(); - expect(NEST_INTERVAL_HANDLERS.get(Service)).toEqual([{ ms: 1000, methodKey: "interval" }]); - }); - - it("records concrete D478 GraphReq and GraphHttpReply binding metadata", () => { - const g = graph(); - const req = fromNestReq(g, { bindingId: "node.http.in" }); - const reply = g.node>([], null, { - name: "reply/node", - }); - class Controller { - post() {} - } - const initializers: Array<(this: unknown) => void> = []; - - GraphReq(req, { bindingId: "http.orders.create.in" })(Controller.prototype.post, { - name: "post", - addInitializer(fn: (this: unknown) => void) { - initializers.push(fn); - }, - } as ClassMethodDecoratorContext); - GraphHttpReply(reply, { bindingId: "http.orders.create.out" })(Controller.prototype.post, { - name: "post", - addInitializer(fn: (this: unknown) => void) { - initializers.push(fn); - }, - } as ClassMethodDecoratorContext); - - const controller = new Controller(); - initializers.forEach((fn) => { - fn.call(controller); - }); - - const token = getNestBoundaryToken("orders.http"); - expect(token).toBe(Symbol.for("graphrefly:nest-boundary:orders.http")); - expect(nestProvider(token, "value")).toEqual({ provide: token, useValue: "value" }); - expect(NEST_BOUNDARY_BINDINGS.get(Controller)).toEqual([ - expect.objectContaining({ - direction: "ingress", - kind: "request", - bindingId: "http.orders.create.in", - methodKey: "post", - boundary: req, - }), - expect.objectContaining({ - direction: "egress", - kind: "http", - bindingId: "http.orders.create.out", - methodKey: "post", - replyNode: reply, - }), - ]); - expect(() => GraphReq(fromNestGuard(g))).toThrow(/expected a request boundary/); - expect(() => GraphHttpReply(reply, {} as { readonly bindingId: string })).toThrow(/bindingId/); - }); - - it("builds keyed Nest ingress envelopes with stable explicit binding ids", () => { - const g = graph(); - const req = fromNestReq< - { requestId: string; body: { readonly orderId: string } }, - { readonly orderId: string } - >(g, { - bindingId: "orders.create", - payload: (host) => host.body, - }); - const seen: NestBoundaryEnvelope<{ readonly orderId: string }>[] = []; - const unsubscribe = req.node.subscribe((msg) => { - if (msg[0] === "DATA") - seen.push(msg[1] as NestBoundaryEnvelope<{ readonly orderId: string }>); - }); - - const envelope = req.emit({ requestId: "req-1", body: { orderId: "o-1" } }); - unsubscribe(); - - expect(req.bindingId).toBe("orders.create"); - expect(envelope).toEqual({ - requestId: "req-1", - bindingId: "orders.create", - version: 1, - payload: { orderId: "o-1" }, - }); - expect(seen).toEqual([envelope]); - expect(g.describe().nodes.some((node) => node.meta?.bindingId === "orders.create")).toBe(true); - }); - - it("allows lifecycle and cron ingress envelopes without fake request ids", () => { - const g = graph(); - const lifecycle = fromNestLifecycle(g, { - bindingId: "lifecycle.app.in", - payload: (host: { readonly event: string }) => ({ event: host.event }), - }); - const cron = fromNestCron(g, { - bindingId: "cron.daily.in", - payload: (host: { readonly tick: string }) => ({ tick: host.tick }), - }); - - expect(lifecycle.emit({ event: "module-destroy" })).toEqual({ - bindingId: "lifecycle.app.in", - version: 1, - payload: { event: "module-destroy" }, - }); - expect(cron.emit({ tick: "midnight" })).toEqual({ - bindingId: "cron.daily.in", - version: 1, - payload: { tick: "midnight" }, - }); - }); - - it("uses deterministic non-random binding ids for Nest ingress fallbacks", () => { - const g = graph(); - - expect(fromNestGuard(g).bindingId).toBe("nestjs.guard"); - expect(fromNestIntercept(g, { name: "orders.intercept" }).bindingId).toBe("orders.intercept"); - expect(fromNestError(g, { bindingId: "orders.error" }).bindingId).toBe("orders.error"); - expect(fromNestLifecycle(g, { bindingId: "app.lifecycle" }).bindingId).toBe("app.lifecycle"); - }); - - it("keeps host-private HTTP handles out of graph DATA and resolves only matching request ids", () => { - const g = graph(); - const egress = g.node>( - [], - null, - { name: "nestjs/http/orders.out" }, - ); - const http = toNestHttp(egress, { bindingId: "orders.http" }); - const resolved: unknown[] = []; - const handle = { - secret: { socket: true }, - resolve(payload: unknown) { - resolved.push(payload); - }, - reject: vi.fn(), - }; - - http.attach({ requestId: "req-1", handle }); - egress.down([ - [ - "DATA", - { - requestId: "req-stale", - bindingId: "orders.http", - version: 1, - payload: { status: 200, body: "stale" }, - }, - ], - ]); - egress.down([ - [ - "DATA", - { - requestId: "req-1", - bindingId: "other.http", - version: 1, - payload: { status: 200, body: "wrong binding" }, - }, - ], - ]); - egress.down([ - [ - "DATA", - { - requestId: "req-1", - bindingId: "orders.http", - version: 1, - payload: { status: 201, body: "created" }, - }, - ], - ]); - - expect(resolved).toEqual([{ status: 201, body: "created" }]); - expect(http.pendingCount()).toBe(0); - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "stale-egress", - "binding-mismatch", - ]); - (http.diagnostics() as unknown[]).length = 0; - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "stale-egress", - "binding-mismatch", - ]); - expect(JSON.stringify(resolved)).not.toContain("socket"); - http.dispose(); - }); - - it("matches Nest HTTP egress by request id by default and scopes by binding id only when requested", () => { - const g = graph(); - const egress = g.node>([], null, { - name: "nestjs/http/default.out", - }); - const http = toNestHttp(egress); - const resolved: unknown[] = []; - - http.attach({ - requestId: "req-1", - handle: { - resolve(payload) { - resolved.push(payload); - }, - reject: vi.fn(), - }, - }); - egress.down([ - [ - "DATA", - { requestId: "req-1", bindingId: "caller.binding", version: 1, payload: { ok: true } }, - ], - ]); - - expect(resolved).toEqual([{ ok: true }]); - expect(http.diagnostics()).toEqual([]); - http.dispose(); - }); - - it("rejects future Nest HTTP attaches after terminal egress", () => { - const g = graph(); - const egress = g.node>([], null, { - name: "nestjs/http/terminal.out", - }); - const http = toNestHttp(egress, { bindingId: "terminal.http" }); - const error = new Error("terminal boom"); - const firstRejected: unknown[] = []; - const laterRejected: unknown[] = []; - - http.attach({ - requestId: "req-1", - handle: { - resolve: vi.fn(), - reject(rejected) { - firstRejected.push(rejected); - }, - }, - }); - egress.down([["ERROR", error]]); - const cleanup = http.attach({ - requestId: "req-2", - handle: { - resolve: vi.fn(), - reject(rejected) { - laterRejected.push(rejected); - }, - }, - }); - - expect(firstRejected).toEqual([error]); - expect(laterRejected).toEqual([error]); - expect(cleanup()).toBe(false); - expect(http.pendingCount()).toBe(0); - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "terminal-egress", - "terminal-egress", - ]); - http.dispose(); - }); - - it("brackets decorator-bound attach, emit, and cleanup in the high-level runner", async () => { - const g = graph(); - const req = fromNestReq< - { readonly requestId: string; readonly body: { readonly ok: true }; readonly fail?: boolean }, - { readonly ok: true } - >(g, { - bindingId: "node.orders.in", - requestId: (host) => host.requestId, - payload: (host) => { - if (host.fail) throw new Error("payload failed"); - return host.body; - }, - }); - const reply = g.node>( - [req.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly ok: true }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "http.orders.out", - version: 1, - payload: envelope.payload, - }, - ], - ]); - }, - { name: "http.orders.out" }, - ); - class Controller { - post() {} - } - GraphReq(req, { bindingId: "http.orders.in" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - GraphHttpReply(reply, { bindingId: "http.orders.out" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - const runner = createNestGraphBoundaryRunner(); - - expect(() => - runner.run(Controller, "post", { requestId: "req-1", body: { ok: true }, fail: true }), - ).toThrow(/payload failed/); - await expect( - runner.run(Controller, "post", { requestId: "req-1", body: { ok: true } }), - ).resolves.toEqual({ ok: true }); - expect(() => runner.run(Controller, "post", { body: { ok: true } })).toThrow( - /GraphHttpReply requires/, - ); - runner.dispose(); - }); - - it("uses binding-level request ids when attaching high-level HTTP replies", async () => { - const g = graph(); - const req = fromNestReq< - { readonly routeRequestId: string; readonly body: { readonly ok: true } }, - { readonly ok: true } - >(g, { - bindingId: "node.binding-request.in", - payload: (host) => host.body, - }); - const reply = g.node>([req.node], (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly ok: true }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "http.binding-request.out", - version: 1, - payload: envelope.payload, - }, - ], - ]); - }); - class Controller { - post() {} - } - GraphReq(req, { - bindingId: "http.binding-request.in", - requestId: (host) => host.routeRequestId, - })(Controller.prototype, "post", { value: Controller.prototype.post }); - GraphHttpReply(reply, { bindingId: "http.binding-request.out" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - const runner = createNestGraphBoundaryRunner(); - - await expect( - runner.run(Controller, "post", { - routeRequestId: "req-binding", - body: { ok: true }, - }), - ).resolves.toEqual({ ok: true }); - runner.dispose(); - }); - - it("limits the high-level interceptor runner to request/interceptor ingress and HTTP egress", () => { - const g = graph(); - const req = fromNestReq<{ readonly requestId: string }, { readonly ok: true }>(g, { - bindingId: "node.phase.request.in", - payload: () => ({ ok: true }), - }); - const guard = fromNestGuard<{ readonly requestId: string }, { readonly guard: true }>(g, { - bindingId: "node.phase.guard.in", - payload: () => ({ guard: true }), - }); - const decision = g.node>([], null); - const requestSeen: unknown[] = []; - const guardSeen: unknown[] = []; - req.node.subscribe((msg) => msg[0] === "DATA" && requestSeen.push(msg[1])); - guard.node.subscribe((msg) => msg[0] === "DATA" && guardSeen.push(msg[1])); - class Controller { - post() {} - } - GraphReq(req, { bindingId: "http.phase.request.in" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - GraphGuard(guard, { bindingId: "guard.phase.in" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - GraphGuardDecision(decision, { bindingId: "guard.phase.out" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - const runner = createNestGraphBoundaryRunner(); - - expect(runner.run(Controller, "post", { requestId: "req-phase" })).toBeUndefined(); - expect(requestSeen).toHaveLength(1); - expect(guardSeen).toHaveLength(0); - runner.dispose(); - }); - - it("fails fast when GraphHttpReply is configured without a matching ingress binding", () => { - const g = graph(); - const reply = g.node>([], null, { - name: "http.reply-only.out", - }); - class Controller { - post() {} - } - GraphHttpReply(reply, { bindingId: "http.reply-only.out" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - const runner = createNestGraphBoundaryRunner(); - - expect(() => runner.run(Controller, "post", { requestId: "req-reply-only" })).toThrow( - /requires at least one ingress/, - ); - runner.dispose(); - }); - - it("does not emit ingress when high-level reply attach fails", () => { - const g = graph(); - const req = fromNestReq<{ readonly requestId: string }, { readonly ok: true }>(g, { - bindingId: "node.duplicate.in", - payload: () => ({ ok: true }), - }); - const reply = g.node>([], null, { - name: "http.duplicate.out", - }); - const seen: unknown[] = []; - const unsubscribe = req.node.subscribe((msg) => { - if (msg[0] === "DATA") seen.push(msg[1]); - }); - class Controller { - post() {} - } - GraphReq(req, { bindingId: "http.duplicate.in" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - GraphHttpReply(reply, { bindingId: "http.duplicate.out" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - const runner = createNestGraphBoundaryRunner(); - const pending = runner.run(Controller, "post", { requestId: "req-dup" }); - if (pending) pending.catch(() => undefined); - - expect(() => runner.run(Controller, "post", { requestId: "req-dup" })).toThrow( - /duplicate pending/, - ); - expect(seen).toHaveLength(1); - unsubscribe(); - runner.dispose(); - }); - - it("resolves inherited Nest boundary metadata for subclass controllers", async () => { - const g = graph(); - const req = fromNestReq<{ readonly requestId: string }, { readonly ok: true }>(g, { - bindingId: "node.inherited.in", - payload: () => ({ ok: true }), - }); - const reply = g.node>( - [req.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly ok: true }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "http.inherited.out", - version: 1, - payload: envelope.payload, - }, - ], - ]); - }, - { name: "http.inherited.out" }, - ); - class BaseController { - post() {} - } - class ChildController extends BaseController {} - GraphReq(req, { bindingId: "http.inherited.in" })(BaseController.prototype, "post", { - value: BaseController.prototype.post, - }); - GraphHttpReply(reply, { bindingId: "http.inherited.out" })(BaseController.prototype, "post", { - value: BaseController.prototype.post, - }); - const runner = createNestGraphBoundaryRunner(); - - await expect( - runner.run(ChildController, "post", { requestId: "req-inherited" }), - ).resolves.toEqual({ ok: true }); - runner.dispose(); - }); - - it("derives a default request id in the high-level interceptor for plain Nest HTTP requests", async () => { - const g = graph(); - const req = fromNestReq<{ readonly requestId: string }, { readonly ok: true }>(g, { - bindingId: "node.default-interceptor.in", - payload: () => ({ ok: true }), - }); - const reply = g.node>( - [req.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly ok: true }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "http.default-interceptor.out", - version: 1, - payload: envelope.payload, - }, - ], - ]); - }, - { name: "http.default-interceptor.out" }, - ); - class Controller { - post() {} - } - GraphReq(req, { bindingId: "http.default-interceptor.in" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - GraphHttpReply(reply, { bindingId: "http.default-interceptor.out" })( - Controller.prototype, - "post", - { value: Controller.prototype.post }, - ); - const interceptor = createNestGraphBoundaryInterceptor(); - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.post, - switchToHttp: () => ({ - getRequest: () => ({ headers: { "x-request-id": "header-req" } }), - }), - }; - - await expect(interceptor.intercept(context)).resolves.toEqual({ ok: true }); - interceptor.dispose(); - }); - - it("ignores lifecycle-only metadata in the interceptor phase bridge", () => { - const g = graph(); - const lifecycle = fromNestLifecycle(g, { - bindingId: "node.lifecycle.only", - payload: () => ({ event: "teardown" }), - }); - const seen: NestBoundaryEnvelope<{ readonly event: string }>[] = []; - const unsubscribe = lifecycle.node.subscribe((msg) => { - if (msg[0] === "DATA") seen.push(msg[1] as NestBoundaryEnvelope<{ readonly event: string }>); - }); - class Controller { - teardown() {} - } - GraphLifecycle(lifecycle, { bindingId: "lifecycle.only" })(Controller.prototype, "teardown", { - value: Controller.prototype.teardown, - }); - const interceptor = createNestGraphBoundaryInterceptor(); - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.teardown, - switchToHttp: () => ({ - getRequest: () => ({ headers: {} }), - }), - }; - - expect(interceptor.intercept(context, { handle: () => "next" })).toBe("next"); - expect(seen).toEqual([]); - unsubscribe(); - interceptor.dispose(); - }); - - it("guards Nest HTTP pending lifecycle and low-level diagnostic retention", () => { - const g = graph(); - const egress = g.node>([], null, { - name: "nestjs/http/guarded.out", - }); - const http = toNestHttp(egress, { - bindingId: "orders.http", - maxDiagnostics: 2, - }); - const handle = { resolve: vi.fn(), reject: vi.fn() }; - - http.attach({ requestId: "req-1", handle }); - expect(() => http.attach({ requestId: "req-1", handle })).toThrow(/duplicate pending/); - - egress.down([ - [ - "DATA", - { requestId: "stale-1", bindingId: "orders.http", version: 1, payload: { ok: false } }, - ], - [ - "DATA", - { requestId: "stale-2", bindingId: "orders.http", version: 1, payload: { ok: false } }, - ], - [ - "DATA", - { requestId: "stale-3", bindingId: "orders.http", version: 1, payload: { ok: false } }, - ], - ]); - - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "stale-egress", - "stale-egress", - ]); - const cleanup = http.attach({ requestId: "req-2", handle }); - expect(cleanup()).toBe(true); - expect(cleanup()).toBe(false); - http.dispose(); - expect(() => http.attach({ requestId: "req-3", handle })).toThrow(/disposed/); - }); - - it("rejects pending Nest HTTP handles on terminal egress and dispose", () => { - const g = graph(); - const egress = g.node>([], null, { - name: "nestjs/http/terminal.out", - }); - const http = toNestHttp(egress, { bindingId: "orders.http" }); - const terminalHandle = { resolve: vi.fn(), reject: vi.fn() }; - const disposeHandle = { resolve: vi.fn(), reject: vi.fn() }; - - http.attach({ requestId: "req-terminal", handle: terminalHandle }); - egress.down([["ERROR", new Error("egress closed")]]); - - expect(terminalHandle.resolve).not.toHaveBeenCalled(); - expect(terminalHandle.reject).toHaveBeenCalledTimes(1); - expect(http.pendingCount()).toBe(0); - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toContain("terminal-egress"); - - const terminalCleanup = http.attach({ requestId: "req-after-terminal", handle: disposeHandle }); - http.dispose(); - - expect(disposeHandle.resolve).not.toHaveBeenCalled(); - expect(disposeHandle.reject).toHaveBeenCalledTimes(1); - expect(terminalCleanup()).toBe(false); - expect(http.pendingCount()).toBe(0); - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).not.toContain( - "dispose-pending", - ); - - const disposeEgress = g.node>([], null, { - name: "nestjs/http/dispose.out", - }); - const disposeHttp = toNestHttp(disposeEgress, { bindingId: "orders.dispose" }); - const liveDisposeHandle = { resolve: vi.fn(), reject: vi.fn() }; - - disposeHttp.attach({ requestId: "req-dispose", handle: liveDisposeHandle }); - disposeHttp.dispose(); - - expect(liveDisposeHandle.resolve).not.toHaveBeenCalled(); - expect(liveDisposeHandle.reject).toHaveBeenCalledTimes(1); - expect(disposeHttp.pendingCount()).toBe(0); - expect(disposeHttp.diagnostics().map((diagnostic) => diagnostic.kind)).toContain( - "dispose-pending", - ); - }); - - it("rejects non-data Nest boundary payload material on ingress and egress", () => { - const g = graph(); - const req = fromNestReq(g, { bindingId: "orders.strict", maxPayloadBytes: 24 }); - const sparse = [] as unknown[]; - sparse[1] = "hole"; - const hidden = { ok: true } as { ok: boolean; runtime?: unknown }; - Object.defineProperty(hidden, "runtime", { - enumerable: false, - value: () => undefined, - }); - const accessorArray: unknown[] = []; - Object.defineProperty(accessorArray, "0", { - enumerable: true, - get() { - throw new Error("getter executed"); - }, - }); - - expect(() => req.emit({ requestId: "req-1" }, { payload: sparse })).toThrow(/sparse/); - expect(() => req.emit({ requestId: "req-1" }, { payload: accessorArray })).toThrow( - /enumerable plain data/, - ); - expect(() => req.emit({ requestId: "req-1" }, { payload: hidden })).toThrow( - /enumerable plain data/, - ); - expect(() => - req.emit({ requestId: "req-1" }, { payload: { text: "this is too large" } }), - ).toThrow(/exceeds/); - expect(() => req.emit({ requestId: "req-1" }, { payload: Number.NaN })).toThrow(/finite/); - expect(() => fromNestReq(g, { bindingId: "bad.version", version: 0 })).toThrow(/must be 1/); - expect(() => fromNestReq(g, { bindingId: "future.version", version: 2 })).toThrow(/must be 1/); - expect(() => req.emit({ requestId: "req-1" }, { version: Number.NaN, payload: null })).toThrow( - /must be 1/, - ); - - const egress = g.node>([], null, { - name: "nestjs/http/strict.out", - }); - const http = toNestHttp(egress, { bindingId: "orders.strict", maxPayloadBytes: 24 }); - const handle = { resolve: vi.fn(), reject: vi.fn() }; - - http.attach({ requestId: "req-1", handle }); - egress.down([ - ["DATA", { bindingId: "orders.strict", version: 1, payload: { ok: true } }], - ["DATA", { requestId: "req-1", bindingId: "orders.strict", version: 1, payload: undefined }], - [ - "DATA", - { - requestId: "req-1", - bindingId: "orders.strict", - version: 1, - payload: { socket: () => undefined }, - }, - ], - [ - "DATA", - { - requestId: "req-1", - bindingId: "orders.strict", - version: 2, - payload: { ok: true }, - }, - ], - ]); - - expect(handle.resolve).not.toHaveBeenCalled(); - expect(handle.reject).toHaveBeenCalledTimes(1); - expect(http.pendingCount()).toBe(0); - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "malformed-egress", - "malformed-egress", - "malformed-egress", - "malformed-egress", - ]); - http.dispose(); - }); - - it("guards Nest ingress payloads against host runtime objects", () => { - const g = graph(); - const req = fromNestReq(g, { bindingId: "orders.raw" }); - - expect(() => - req.emit({ requestId: "req-1" }, { payload: { body: "ok", response: () => undefined } }), - ).toThrow(/data-only/); - expect(() => - req.emit({ requestId: "req-1" }, { payload: { socket: new Map() } }), - ).toThrow(/plain data object/); - }); - - it("lets binding-level payload and requestId override factory defaults", () => { - const g = graph(); - const req = fromNestReq<{ requestId: string; body: { value: string } }, { value: string }>(g, { - bindingId: "node.shared.in", - payload: () => ({ value: "factory" }), - requestId: "factory-req", - }); - const seen: unknown[] = []; - req.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Controller { - a() {} - b() {} - } - GraphReq(req, { - bindingId: "route.a", - payload: (host) => host.body, - requestId: (host) => host.requestId, - order: 2, - })(Controller.prototype, "a", { value: Controller.prototype.a }); - GraphReq(req, { - bindingId: "route.b", - payload: () => ({ value: "binding-b" }), - requestId: "route-b-req", - order: 1, - })(Controller.prototype, "b", { value: Controller.prototype.b }); - - const runner = createNestGraphBoundaryRunner(); - runner.run(Controller, "a", { requestId: "route-a-req", body: { value: "binding-a" } }); - runner.run(Controller, "b", { requestId: "ignored", body: { value: "ignored" } }); - - expect(seen).toEqual([ - { - bindingId: "route.a", - version: 1, - requestId: "route-a-req", - payload: { value: "binding-a" }, - }, - { - bindingId: "route.b", - version: 1, - requestId: "route-b-req", - payload: { value: "binding-b" }, - }, - ]); - expect(getNestBoundaryBindings(Controller, "a")[0]).toMatchObject({ - bindingId: "route.a", - order: 2, - }); - runner.dispose(); - }); - - it("records GraphFilter, GraphError sugar, GraphGuardDecision, and lowerers", () => { - const g = graph(); - const error = fromNestError(g, { bindingId: "node.error.in" }); - const guard = fromNestGuard(g, { bindingId: "node.guard.in" }); - const decision = g.node>([], null); - class Controller { - filtered() {} - guarded() {} - } - - GraphFilter(error, { bindingId: "filter.generic", mode: "observe", order: 1 })( - Controller.prototype, - "filtered", - { value: Controller.prototype.filtered }, - ); - GraphError(error, { bindingId: "filter.error", mode: "handle", order: 2 })( - Controller.prototype, - "filtered", - { value: Controller.prototype.filtered }, - ); - GraphGuard(guard, { bindingId: "guard.in" })(Controller.prototype, "guarded", { - value: Controller.prototype.guarded, - }); - GraphGuardDecision(decision, { bindingId: "guard.out" })(Controller.prototype, "guarded", { - value: Controller.prototype.guarded, - }); - - expect( - getNestBoundaryBindings(Controller, "filtered").map((binding) => binding.bindingId), - ).toEqual(["filter.generic", "filter.error"]); - expect(getNestBoundaryBindings(Controller, "guarded").map((binding) => binding.kind)).toEqual([ - "guard", - "guard-decision", - ]); - - const httpIssue: HttpDataIssue = { - kind: "issue", - code: "orders.closed", - message: "Orders are closed.", - status: 409, - body: { ok: false }, - headers: { "x-graphrefly-issue": "orders.closed" }, - }; - expect(issueResponse(httpIssue)).toEqual({ - status: 409, - body: { ok: false }, - headers: { "x-graphrefly-issue": "orders.closed" }, - }); - expect(lowerHttpReplyPayload({ status: 202, body: { ok: true } }, {})).toEqual({ - status: 202, - body: { ok: true }, - }); - expect(lowerHttpReplyPayload({ kind: "issue", code: "bad", message: "Bad" }, {})).toEqual({ - status: 400, - body: { code: "bad", message: "Bad" }, - }); - expect(protocolError(new Error("secret")).status).toBe(500); - expect( - lowerProtocolError("boom", {}, { protocolError: () => ({ status: 599, body: "masked" }) }), - ).toEqual({ status: 599, body: "masked" }); - }); - - it("exports Nest-native provider bridge objects without leaking them through the generic barrel", () => { - expect(provideGraphBoundaryInterceptor()).toMatchObject({ provide: expect.anything() }); - expect(provideGraphGuard()).toMatchObject({ provide: expect.anything() }); - expect(typeof createGraphExceptionFilter({ target: () => undefined }).catch).toBe("function"); - expect(provideGraphExceptionFilter({ target: () => undefined })).toMatchObject({ - provide: expect.anything(), - }); - const guardDeniedProvider = provideGraphGuardDeniedFilter(); - expect(createGraphGuardDeniedFilter()).toBeInstanceOf(GraphGuardDeniedFilter); - expect(guardDeniedProvider).toBe(GraphGuardDeniedFilter); - expect(guardDeniedProvider).not.toBe(APP_FILTER); - expect(provideGraphExceptionFilter({ target: () => undefined }).provide).not.toBe(APP_FILTER); - expect(provideGraphCronScheduler({ targets: [] })).toMatchObject({ - provide: expect.any(Symbol), - }); - expect(provideGraphLifecycleHooks({ targets: [] })).toMatchObject({ - provide: expect.any(Symbol), - }); - expect("GraphReq" in ({} as typeof import("@graphrefly/ts/adapters"))).toBe(false); - }); - - it("builds explicit native provider bundles without scanning or creating graphs", () => { - const target = () => ({ target: class Target {}, methodKey: "handle" }); - const httpProviders = provideGraphNativeHttpProviders({ - boundaryInterceptor: { host: () => ({ requestId: "req-1" }) }, - guard: {}, - exceptionFilter: { target }, - }); - - expect( - httpProviders.map((provider) => - typeof provider === "function" ? provider : provider.provide, - ), - ).toEqual([APP_INTERCEPTOR, APP_GUARD, GraphGuardDeniedFilter, expect.any(Symbol)]); - expect(provideGraphNativeHttpProviders({ guardDeniedFilter: false })).toHaveLength(2); - - class Controller { - tick() {} - stop() {} - } - const cronTarget = graphCronTarget(Controller, "tick", { - expr: "* * * * *", - timezone: "UTC", - target: class WrongCronTarget {}, - methodKey: "wrong", - } as Parameters[2]); - const lifecycleTarget = graphLifecycleTarget(Controller, "stop", { - event: "module-destroy", - target: class WrongLifecycleTarget {}, - methodKey: "wrong", - }); - const nativeProviders = provideGraphNativeProviders({ - http: false, - cronScheduler: { targets: [cronTarget] }, - lifecycleHooks: { targets: [lifecycleTarget] }, - }); - - expect(cronTarget).toMatchObject({ target: Controller, methodKey: "tick" }); - expect(lifecycleTarget).toMatchObject({ target: Controller, methodKey: "stop" }); - expect(nativeProviders).toHaveLength(2); - expect(nativeProviders.every((provider) => typeof provider !== "function")).toBe(true); - }); - - it("builds D495 focused WebSocket and message provider bundles over explicit bridge options", async () => { - vi.useFakeTimers(); - try { - const g = graph(); - const diagnostics = fromNestDiagnostics(g, { - bindingId: "node.transport.bundle.diagnostics", - }); - const seenDiagnostics: unknown[] = []; - diagnostics.node.subscribe((msg) => { - if (msg[0] === "DATA") seenDiagnostics.push((msg[1] as NestBoundaryEnvelope).payload); - }); - - const wsIngress = fromNestWs(g, { - bindingId: "node.bundle.ws.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const wsReply = g.node>([], null, { - name: "bundle.ws.reply", - }); - class BundleGateway { - handle() {} - } - GraphWs(wsIngress, { - bindingId: "bundle.ws.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(BundleGateway.prototype, "handle", { value: BundleGateway.prototype.handle }); - GraphWsReply(wsReply, { bindingId: "bundle.ws.reply" })(BundleGateway.prototype, "handle", { - value: BundleGateway.prototype.handle, - }); - - const wsProviders = provideGraphWsProviders({ - bridge: { - diagnosticBoundary: diagnostics, - maxDiagnostics: 1, - timeoutMs: 20, - }, - }); - expect(wsProviders.map((provider) => provider.provide)).toEqual([GRAPHREFLY_NEST_WS_BRIDGE]); - expect(provideGraphWsProviders({ bridge: false })).toEqual([]); - const wsProvider = wsProviders[0]; - if (wsProvider === undefined || !("useValue" in wsProvider)) { - throw new Error("Expected GraphWs provider bundle to return an explicit useValue provider"); - } - const wsBridge = wsProvider.useValue as GraphWsBridge<{ - readonly requestId: string; - readonly payload: unknown; - }>; - const wsPending = wsBridge.handleMessage(BundleGateway, "handle", { - requestId: "req-bundle-ws", - payload: { ok: true }, - }); - vi.advanceTimersByTime(20); - await expect(wsPending).rejects.toThrow(/timed out/); - expect(wsBridge.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["timeout"]); - - const messageIngress = fromNestMessage(g, { - bindingId: "node.bundle.message.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const messageReply = g.node>([], null, { - name: "bundle.message.reply", - }); - class BundleMessageController { - handle() {} - } - GraphMessage(messageIngress, { - bindingId: "bundle.message.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(BundleMessageController.prototype, "handle", { - value: BundleMessageController.prototype.handle, - }); - GraphMessageReply(messageReply, { bindingId: "bundle.message.reply" })( - BundleMessageController.prototype, - "handle", - { value: BundleMessageController.prototype.handle }, - ); - - const messageProviders = provideGraphMessageProviders({ - bridge: { - diagnosticBoundary: diagnostics, - maxDiagnostics: 1, - timeoutMs: 20, - }, - }); - expect(messageProviders.map((provider) => provider.provide)).toEqual([ - GRAPHREFLY_NEST_MESSAGE_BRIDGE, - ]); - expect(provideGraphMessageProviders({ bridge: false })).toEqual([]); - const messageProvider = messageProviders[0]; - if (messageProvider === undefined || !("useValue" in messageProvider)) { - throw new Error( - "Expected GraphMessage provider bundle to return an explicit useValue provider", - ); - } - const messageBridge = messageProvider.useValue as GraphMessageBridge<{ - readonly requestId: string; - readonly payload: unknown; - }>; - const messagePending = messageBridge.handleMessage(BundleMessageController, "handle", { - requestId: "req-bundle-message", - payload: { ok: true }, - }); - vi.advanceTimersByTime(20); - await expect(messagePending).rejects.toThrow(/timed out/); - expect(messageBridge.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["timeout"]); - - expect(seenDiagnostics).toEqual([ - expect.objectContaining({ - kind: "timeout", - phase: "ws", - requestId: "req-bundle-ws", - }), - expect.objectContaining({ - kind: "timeout", - phase: "message", - requestId: "req-bundle-message", - }), - ]); - wsBridge.dispose(); - messageBridge.dispose(); - } finally { - vi.useRealTimers(); - } - }); - - it("emits graph-visible Nest diagnostics only through an explicit sanitized boundary", () => { - const g = graph(); - const diagnostics = fromNestDiagnostics(g, { - bindingId: "node.nest.diagnostics", - phase: "http", - }); - const seen: unknown[] = []; - diagnostics.node.subscribe((msg) => { - if (msg[0] === "DATA") seen.push((msg[1] as NestBoundaryEnvelope).payload); - }); - const reply = g.node>([], null, { - name: "nestjs/diagnostic/reply", - }); - const hiddenHandle = { socket: { id: "raw" }, callback: () => undefined }; - const error = Object.assign(new Error("private failure"), hiddenHandle); - const http = toNestHttp(reply, { - bindingId: "http.diagnostics.out", - diagnosticBoundary: diagnostics, - }); - http.attach({ - requestId: "req-diagnostic", - bindingId: "http.diagnostics.out", - handle: { - resolve: vi.fn(), - reject: vi.fn(), - }, - }); - - reply.down([["ERROR", error]]); - - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["terminal-egress"]); - expect(seen).toEqual([ - { - kind: "terminal-egress", - phase: "http", - bindingId: "http.diagnostics.out", - message: "toNestHttp(http.diagnostics.out) rejected pending requests after ERROR", - error: { name: "Error", message: "private failure" }, - }, - ]); - expect(JSON.stringify(seen)).not.toContain("socket"); - expect(JSON.stringify(seen)).not.toContain("callback"); - }); - - it("keeps host cleanup alive when explicit diagnostic ingress rejects DATA", () => { - const g = graph(); - const diagnostics = fromNestDiagnostics(g, { - bindingId: "node.nest.tight-diagnostics", - maxPayloadBytes: 1, - }); - const reply = g.node>([], null); - const reject = vi.fn(); - const http = toNestHttp(reply, { - bindingId: "http.tight-diagnostics.out", - diagnosticBoundary: diagnostics, - }); - http.attach({ - requestId: "req-tight", - bindingId: "http.tight-diagnostics.out", - handle: { - resolve: vi.fn(), - reject, - }, - }); - - expect(() => - reply.down([["ERROR", new Error("too large for diagnostic ingress")]]), - ).not.toThrow(); - expect(reject).toHaveBeenCalledOnce(); - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["terminal-egress"]); - }); - - it("passes sanitized diagnostics into structural diagnostic boundaries", () => { - const g = graph(); - const reply = g.node>([], null); - const emitted: Array<{ readonly host: unknown; readonly payload: unknown }> = []; - const diagnosticBoundary: NestDiagnosticIngressBoundary = { - kind: "diagnostics", - bindingId: "custom.diagnostics", - version: 1, - node: fromNestDiagnostics(g).node, - envelope(host, opts) { - return { - bindingId: opts?.bindingId ?? "custom.diagnostics", - version: opts?.version ?? 1, - payload: opts?.payload ?? host, - }; - }, - emit(host, opts) { - emitted.push({ host, payload: opts?.payload }); - return this.envelope(host, opts); - }, - }; - const http = toNestHttp(reply, { - bindingId: "http.custom-diagnostics.out", - diagnosticBoundary, - }); - - reply.down([["ERROR", Object.assign(new Error("masked"), { socket: { id: "raw" } })]]); - - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["terminal-egress"]); - expect(emitted).toEqual([ - { - host: { - kind: "terminal-egress", - phase: "http", - bindingId: "http.custom-diagnostics.out", - message: "toNestHttp(http.custom-diagnostics.out) rejected pending requests after ERROR", - error: { name: "Error", message: "masked" }, - }, - payload: { - kind: "terminal-egress", - phase: "http", - bindingId: "http.custom-diagnostics.out", - message: "toNestHttp(http.custom-diagnostics.out) rejected pending requests after ERROR", - error: { name: "Error", message: "masked" }, - }, - }, - ]); - expect(JSON.stringify(emitted)).not.toContain("socket"); - }); - - it("keeps Nest diagnostics as host snapshots by default", () => { - const g = graph(); - const diagnostics = fromNestDiagnostics(g, { bindingId: "node.unwired.diagnostics" }); - const seen: unknown[] = []; - diagnostics.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - const reply = g.node>([], null); - const http = toNestHttp(reply, { bindingId: "http.host-snapshot.out" }); - - reply.down([ - [ - "DATA", - { - requestId: "stale", - bindingId: "http.host-snapshot.out", - version: 1, - payload: { ok: true }, - }, - ], - ]); - - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["stale-egress"]); - expect(seen).toEqual([]); - expect(sanitizeNestDiagnostic({ kind: "timeout", message: "late", error: "deadline" })).toEqual( - { - kind: "timeout", - phase: "adapter", - message: "late", - error: { message: "deadline" }, - }, - ); - expect( - sanitizeNestDiagnostic({ - kind: "timeout", - message: "opaque", - error: { - get message() { - throw new Error("hostile getter"); - }, - toString() { - throw new Error("hostile toString"); - }, - }, - }), - ).toEqual({ - kind: "timeout", - phase: "adapter", - message: "opaque", - error: { message: "opaque diagnostic error" }, - }); - function hiddenCallback() { - return "host-private source"; - } - hiddenCallback.toString = () => { - throw new Error("hostile function toString"); - }; - expect( - sanitizeNestDiagnostic({ - kind: "timeout", - message: "function", - error: hiddenCallback, - }), - ).toEqual({ - kind: "timeout", - phase: "adapter", - message: "function", - error: { message: "opaque diagnostic function" }, - }); - }); - - it("targeted guard-denial filter rethrows ordinary exceptions", () => { - const filter = createGraphGuardDeniedFilter(); - const host = { - switchToHttp: () => ({ - getResponse: () => ({ - status: vi.fn(), - json: vi.fn(), - }), - }), - } as Parameters[1]; - - expect(() => filter.catch(new Error("ordinary"), host)).toThrow("ordinary"); - const denial = new GraphGuardDeniedException({ status: 403, body: { denied: true } }); - expect(isGraphGuardDeniedException(denial)).toBe(true); - }); - - it("native guard provider consumes GraphGuard and GraphGuardDecision metadata", async () => { - const g = graph(); - const guard = fromNestGuard< - { readonly requestId: string; readonly allow: boolean }, - { allow: boolean } - >(g, { bindingId: "node.native.guard.in" }); - const decision = g.node>([guard.node], (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ allow: boolean }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "native.guard.out", - version: 1, - payload: envelope.payload.allow - ? { kind: "allow" } - : { - kind: "deny", - status: 409, - body: { accepted: false }, - headers: { "x-graphrefly-guard": "denied" }, - }, - }, - ], - ]); - }); - class Controller { - guarded() {} - } - GraphGuard(guard, { - bindingId: "native.guard.in", - payload: (host) => ({ allow: host.allow }), - requestId: (host) => host.requestId, - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - GraphGuardDecision(decision, { bindingId: "native.guard.out" })( - Controller.prototype, - "guarded", - { - value: Controller.prototype.guarded, - }, - ); - const bridge = provideGraphGuard({ - host: () => ({ requestId: "req-allow", allow: true }), - requestId: (host) => host.requestId, - }).useValue as { canActivate(context: unknown): Promise; onModuleDestroy(): void }; - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.guarded, - switchToHttp: () => ({ getRequest: () => ({}) }), - }; - - await expect(bridge.canActivate(context)).resolves.toBe(true); - const denyBridge = provideGraphGuard({ - host: () => ({ requestId: "req-deny", allow: false }), - requestId: (host) => host.requestId, - }).useValue as typeof bridge; - try { - await denyBridge.canActivate(context); - throw new Error("expected guard denial to throw"); - } catch (error) { - expect(isGraphGuardDeniedException(error)).toBe(true); - expect((error as { getStatus?: () => number }).getStatus?.()).toBe(409); - expect((error as { getResponse?: () => unknown }).getResponse?.()).toEqual({ - accepted: false, - }); - const headers: Record = {}; - const statuses: number[] = []; - const bodies: unknown[] = []; - const guardHost = { - switchToHttp: () => ({ - getResponse: () => ({ - setHeader(name: string, value: string) { - headers[name] = value; - }, - status(value: number) { - statuses.push(value); - }, - json(value: unknown) { - bodies.push(value); - return value; - }, - }), - }), - } as Parameters[1]; - createGraphGuardDeniedFilter().catch(error, guardHost); - expect(headers).toEqual({ "x-graphrefly-guard": "denied" }); - expect(statuses).toEqual([409]); - expect(bodies).toEqual([{ accepted: false }]); - } - bridge.onModuleDestroy(); - denyBridge.onModuleDestroy(); - }); - - it("native guard await mode correlates a later decision with adapter-owned identity", async () => { - const g = graph({ name: "nestjs-guard-await-test" }); - const scope = createNestGraphGuardAwaitScope(); - const guard = fromNestGuard<{ readonly traceId: string }, { readonly traceId: string }>(g, { - bindingId: "node.native.guard.await.in", - }); - const decision = g.state>({ - requestId: "initial-unused", - bindingId: "native.guard.await.out", - version: 1, - payload: { kind: "deny" }, - }); - let invocationId: string | undefined; - guard.node.subscribe((msg) => { - if (msg[0] !== "DATA") return; - const envelope = msg[1] as NestBoundaryEnvelope<{ readonly traceId: string }>; - invocationId = envelope.requestId; - expect(envelope.payload.traceId).toBe("caller-trace-id"); - expect(envelope.requestId).not.toBe(envelope.payload.traceId); - expect(scope.lookupAbortSignal(envelope.requestId ?? "")?.aborted).toBe(false); - queueMicrotask(() => { - decision.set({ - requestId: envelope.requestId ?? "missing", - bindingId: "native.guard.await.out", - version: 1, - payload: { kind: "allow" }, - }); - }); - }); - class Controller { - guarded() {} - } - GraphGuard(guard, { - bindingId: "native.guard.await.in", - payload: (host) => ({ traceId: host.traceId }), - requestId: (host) => host.traceId, - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - GraphGuardDecision(decision, { bindingId: "native.guard.await.out" })( - Controller.prototype, - "guarded", - { value: Controller.prototype.guarded }, - ); - const bridge = provideGraphGuard({ - host: () => ({ traceId: "caller-trace-id" }), - decisionWait: { mode: "await", timeoutMs: 100, maxPending: 1, scope }, - }).useValue as { canActivate(context: unknown): Promise; onModuleDestroy(): void }; - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.guarded, - switchToHttp: () => ({ getRequest: () => ({}) }), - }; - - await expect(bridge.canActivate(context)).resolves.toBe(true); - expect(invocationId).toMatch(/^graphrefly:nest-guard:/); - expect(scope.lookupAbortSignal(invocationId ?? "missing")).toBeUndefined(); - expect(g.topology().nodes).toHaveLength(2); - bridge.onModuleDestroy(); - scope.dispose(); - }); - - it("native guard await mode bounds timeout, overload, host abort, and disposal", async () => { - vi.useFakeTimers(); - try { - const g = graph(); - const scope = createNestGraphGuardAwaitScope(); - const guard = fromNestGuard(g, { bindingId: "node.native.guard.pending.in" }); - const decision = g.node>([], null); - class Controller { - guarded() {} - } - GraphGuard(guard, { bindingId: "native.guard.pending.in" })(Controller.prototype, "guarded", { - value: Controller.prototype.guarded, - }); - GraphGuardDecision(decision, { bindingId: "native.guard.pending.out" })( - Controller.prototype, - "guarded", - { value: Controller.prototype.guarded }, - ); - const abortController = new AbortController(); - const bridge = provideGraphGuard({ - decisionWait: { - mode: "await", - timeoutMs: 20, - maxPending: 1, - scope, - hostAbortSignal: () => abortController.signal, - }, - }).useValue as { canActivate(context: unknown): Promise; onModuleDestroy(): void }; - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.guarded, - switchToHttp: () => ({ getRequest: () => ({}) }), - }; - - const pending = bridge.canActivate(context); - await expect(bridge.canActivate(context)).rejects.toMatchObject({ - getStatus: expect.any(Function), - }); - abortController.abort(); - await expect(pending).resolves.toBe(false); - - const timeoutBridge = provideGraphGuard({ - decisionWait: { mode: "await", timeoutMs: 20, maxPending: 1, scope }, - }).useValue as typeof bridge; - const timedOut = timeoutBridge.canActivate(context); - vi.advanceTimersByTime(20); - await expect(timedOut).resolves.toBe(false); - - const disposedPending = timeoutBridge.canActivate(context); - timeoutBridge.onModuleDestroy(); - await expect(disposedPending).resolves.toBe(false); - await expect(timeoutBridge.canActivate(context)).resolves.toBe(false); - timeoutBridge.onModuleDestroy(); - bridge.onModuleDestroy(); - scope.dispose(); - } finally { - vi.useRealTimers(); - } - }); - - it("native guard provider correlates each guard binding with its own request id", async () => { - const g = graph(); - type GuardHost = { - readonly leftRequestId: string; - readonly rightRequestId: string; - }; - const leftGuard = fromNestGuard(g, { - bindingId: "node.native.guard.left.in", - }); - const rightGuard = fromNestGuard(g, { - bindingId: "node.native.guard.right.in", - }); - const decision = g.node>( - [leftGuard.node, rightGuard.node], - (ctx) => { - const envelopes = [depLatest(ctx, 0), depLatest(ctx, 1)] as Array< - NestBoundaryEnvelope<{ readonly side: "left" | "right" }> | undefined - >; - const messages = envelopes - .filter( - (envelope): envelope is NestBoundaryEnvelope<{ readonly side: "left" | "right" }> => - envelope?.requestId !== undefined, - ) - .map( - (envelope) => - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "native.guard.multi.out", - version: 1, - payload: { kind: "allow", metadata: { side: envelope.payload.side } }, - }, - ] as const, - ); - if (messages.length > 0) ctx.down(messages); - }, - ); - class Controller { - guarded() {} - } - GraphGuard(leftGuard, { - bindingId: "native.guard.left.in", - payload: () => ({ side: "left" }), - requestId: (host) => host.leftRequestId, - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - GraphGuard(rightGuard, { - bindingId: "native.guard.right.in", - payload: () => ({ side: "right" }), - requestId: (host) => host.rightRequestId, - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - GraphGuardDecision(decision, { bindingId: "native.guard.multi.out" })( - Controller.prototype, - "guarded", - { value: Controller.prototype.guarded }, - ); - const bridge = provideGraphGuard({ - host: () => ({ leftRequestId: "req-left", rightRequestId: "req-right" }), - requestId: () => "provider-fallback", - }).useValue as { canActivate(context: unknown): Promise; onModuleDestroy(): void }; - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.guarded, - switchToHttp: () => ({ getRequest: () => ({}) }), - }; - - await expect(bridge.canActivate(context)).resolves.toBe(true); - bridge.onModuleDestroy(); - }); - - it("native guard denial lowers HttpDataIssue headers through the targeted filter", async () => { - const g = graph(); - const guard = fromNestGuard<{ readonly requestId: string }, { readonly apiKey: string }>(g, { - bindingId: "node.native.guard.issue.in", - }); - const issue: HttpDataIssue = { - kind: "issue", - code: "orders.forbidden", - message: "Orders require a valid key.", - status: 451, - body: { accepted: false, code: "orders.forbidden" }, - headers: { "x-graphrefly-issue": "orders.forbidden" }, - }; - const decision = g.node>([guard.node], (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly apiKey: string }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "native.guard.issue.out", - version: 1, - payload: { kind: "deny", issue }, - }, - ], - ]); - }); - class Controller { - guarded() {} - } - GraphGuard(guard, { - bindingId: "native.guard.issue.in", - payload: () => ({ apiKey: "bad" }), - requestId: (host) => host.requestId, - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - GraphGuardDecision(decision, { bindingId: "native.guard.issue.out" })( - Controller.prototype, - "guarded", - { value: Controller.prototype.guarded }, - ); - const bridge = provideGraphGuard({ - host: () => ({ requestId: "req-issue" }), - requestId: (host) => host.requestId, - }).useValue as { canActivate(context: unknown): Promise; onModuleDestroy(): void }; - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.guarded, - switchToHttp: () => ({ getRequest: () => ({}) }), - }; - const statuses: number[] = []; - const bodies: unknown[] = []; - const headers: Record = {}; - const host = { - switchToHttp: () => ({ - getResponse: () => ({ - header(name: string, value: string) { - headers[name] = value; - }, - status(value: number) { - statuses.push(value); - }, - json(value: unknown) { - bodies.push(value); - return value; - }, - }), - }), - } as Parameters[1]; - - await expect(bridge.canActivate(context)).rejects.toBeInstanceOf(GraphGuardDeniedException); - try { - await bridge.canActivate(context); - throw new Error("expected guard denial to throw"); - } catch (error) { - createGraphGuardDeniedFilter().catch(error, host); - } - - expect(statuses).toEqual([451]); - expect(headers).toEqual({ "x-graphrefly-issue": "orders.forbidden" }); - expect(bodies).toEqual([{ accepted: false, code: "orders.forbidden" }]); - bridge.onModuleDestroy(); - }); - - it("native guard decision protocol ERROR uses binding-level protocol-error lowering", async () => { - const g = graph(); - const guard = fromNestGuard<{ readonly requestId: string }, { readonly value: string }>(g, { - bindingId: "node.native.guard.protocol.in", - }); - const decision = g.node>([guard.node], (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly value: string }>; - if (envelope.requestId === undefined) return; - ctx.down([["ERROR", new Error(`secret:${envelope.payload.value}`)]]); - }); - class Controller { - guarded() {} - } - GraphGuard(guard, { - bindingId: "native.guard.protocol.in", - payload: () => ({ value: "hidden" }), - requestId: (host) => host.requestId, - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - GraphGuardDecision(decision, { - bindingId: "native.guard.protocol.out", - protocolError: () => ({ - status: 599, - body: { code: "guard.binding.protocol", message: "binding wins" }, - }), - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - const bridge = provideGraphGuard({ - host: () => ({ requestId: "req-guard-protocol" }), - protocolError: () => ({ - status: 598, - body: { code: "guard.provider.protocol", message: "provider loses" }, - }), - requestId: (host) => host.requestId, - }).useValue as { canActivate(context: unknown): Promise; onModuleDestroy(): void }; - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.guarded, - switchToHttp: () => ({ getRequest: () => ({}) }), - }; - - try { - await bridge.canActivate(context); - throw new Error("expected guard protocol error to throw"); - } catch (error) { - expect(isGraphGuardDeniedException(error)).toBe(false); - expect((error as { getStatus?: () => number }).getStatus?.()).toBe(599); - expect((error as { getResponse?: () => unknown }).getResponse?.()).toEqual({ - code: "guard.binding.protocol", - message: "binding wins", - }); - } - bridge.onModuleDestroy(); - }); - - it("native exception filter handles GraphError with HTTP DATA lowering", async () => { - const g = graph(); - const errorIn = fromNestError< - { readonly requestId: string; readonly exception: Error }, - { message: string } - >(g, { bindingId: "node.native.error.in" }); - const errorOut = g.node>( - [errorIn.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ message: string }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "native.error.out", - version: 1, - payload: { status: 418, body: { message: envelope.payload.message } }, - }, - ], - ]); - }, - ); - class Controller { - handled() {} - } - GraphError(errorIn, { - bindingId: "native.error.in", - payload: (host) => ({ message: host.exception.message }), - requestId: (host) => host.requestId, - })(Controller.prototype, "handled", { value: Controller.prototype.handled }); - GraphHttpReply(errorOut, { bindingId: "native.error.out" })(Controller.prototype, "handled", { - value: Controller.prototype.handled, - }); - const statuses: number[] = []; - const bodies: unknown[] = []; - const filter = provideGraphExceptionFilter({ - target: () => ({ target: Controller, methodKey: "handled" }), - host: (_host, exception) => ({ - requestId: "req-error", - exception: exception instanceof Error ? exception : new Error(String(exception)), - }), - requestId: (host) => host.requestId, - }).useValue as { - catch(exception: unknown, host: unknown): Promise; - onModuleDestroy(): void; - }; - const host = { - switchToHttp: () => ({ - getRequest: () => ({}), - getResponse: () => ({ - status(value: number) { - statuses.push(value); - }, - json(value: unknown) { - bodies.push(value); - return value; - }, - }), - }), - }; - - await filter.catch(new Error("handled"), host); - - expect(statuses).toEqual([418]); - expect(bodies).toEqual([{ message: "handled" }]); - filter.onModuleDestroy(); - }); - - it("native exception filter lowers reply protocol ERROR through the safe 500 fallback", () => { - const g = graph(); - const errorIn = fromNestError< - { readonly requestId: string; readonly exception: Error }, - { message: string } - >(g, { bindingId: "node.native.error.protocol.in" }); - const errorOut = g.node>([errorIn.node], (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ message: string }>; - if (envelope.requestId === undefined) return; - ctx.down([["ERROR", new Error(`secret:${envelope.payload.message}`)]]); - }); - class Controller { - handled() {} - } - GraphError(errorIn, { - bindingId: "native.error.protocol.in", - payload: (host) => ({ message: host.exception.message }), - requestId: (host) => host.requestId, - })(Controller.prototype, "handled", { value: Controller.prototype.handled }); - GraphHttpReply(errorOut, { bindingId: "native.error.protocol.out" })( - Controller.prototype, - "handled", - { value: Controller.prototype.handled }, - ); - const statuses: number[] = []; - const bodies: unknown[] = []; - const filter = createGraphExceptionFilter({ - target: () => ({ target: Controller, methodKey: "handled" }), - host: (_host, exception) => ({ - requestId: "req-error-protocol", - exception: exception instanceof Error ? exception : new Error(String(exception)), - }), - requestId: (host) => host.requestId, - }) as { catch(exception: unknown, host: unknown): unknown; onModuleDestroy(): void }; - const host = { - switchToHttp: () => ({ - getRequest: () => ({}), - getResponse: () => ({ - status(value: number) { - statuses.push(value); - }, - json(value: unknown) { - bodies.push(value); - return value; - }, - }), - }), - }; - - filter.catch(new Error("handled"), host); - - expect(statuses).toEqual([500]); - expect(bodies).toEqual([ - { code: "graphrefly.protocol_error", message: "GraphReFly reply pipeline failed" }, - ]); - filter.onModuleDestroy(); - }); - - it("native exception filter lowers directly when no handling filter has a request id", () => { - const g = graph(); - const errorIn = fromNestError<{ readonly exception: Error }, { message: string }>(g, { - bindingId: "node.native.error.no-request.in", - }); - class Controller { - handled() {} - } - GraphError(errorIn, { - bindingId: "native.error.no-request.in", - payload: (host) => ({ message: host.exception.message }), - })(Controller.prototype, "handled", { value: Controller.prototype.handled }); - const statuses: number[] = []; - const bodies: unknown[] = []; - const filter = createGraphExceptionFilter({ - target: () => ({ target: Controller, methodKey: "handled" }), - host: (_host, exception) => ({ - exception: exception instanceof Error ? exception : new Error(String(exception)), - }), - }) as { catch(exception: unknown, host: unknown): unknown; onModuleDestroy(): void }; - const host = { - switchToHttp: () => ({ - getRequest: () => ({}), - getResponse: () => ({ - status(value: number) { - statuses.push(value); - }, - json(value: unknown) { - bodies.push(value); - return value; - }, - }), - }), - }; - - filter.catch(new Error("handled"), host); - - expect(statuses).toEqual([500]); - expect(bodies).toEqual([ - { code: "graphrefly.protocol_error", message: "GraphReFly reply pipeline failed" }, - ]); - filter.onModuleDestroy(); - }); - - it("native exception filter emits request-correlated observe filters before direct lowering", () => { - const g = graph(); - const errorIn = fromNestError< - { readonly requestId: string; readonly exception: Error }, - { readonly message: string } - >(g, { - bindingId: "node.native.error.observe.in", - }); - const seen: string[] = []; - errorIn.node.subscribe((msg) => { - if (msg[0] === "DATA") seen.push(msg[1].bindingId); - }); - class Controller { - handled() {} - } - GraphFilter(errorIn, { - bindingId: "native.error.observe.in", - mode: "observe", - payload: (host) => ({ message: host.exception.message }), - requestId: (host) => host.requestId, - order: 1, - })(Controller.prototype, "handled", { value: Controller.prototype.handled }); - GraphError(errorIn, { - bindingId: "native.error.handle.in", - payload: (host) => ({ message: host.exception.message }), - requestId: (host) => host.requestId, - order: 2, - })(Controller.prototype, "handled", { value: Controller.prototype.handled }); - const statuses: number[] = []; - const filter = createGraphExceptionFilter({ - target: () => ({ target: Controller, methodKey: "handled" }), - host: (_host, exception) => ({ - requestId: "err-observe", - exception: exception instanceof Error ? exception : new Error(String(exception)), - }), - }) as { catch(exception: unknown, host: unknown): unknown; onModuleDestroy(): void }; - const host = { - switchToHttp: () => ({ - getRequest: () => ({}), - getResponse: () => ({ - status(value: number) { - statuses.push(value); - }, - json(value: unknown) { - return value; - }, - }), - }), - }; - - filter.catch(new Error("handled"), host); - - expect(seen).toEqual(["native.error.observe.in", "native.error.handle.in"]); - expect(statuses).toEqual([500]); - filter.onModuleDestroy(); - }); - - it("native websocket bridge correlates ack/reply by requestId and bindingId without handle DATA", async () => { - const g = graph(); - const ingress = fromNestWs< - { - readonly requestId: string; - readonly body: string; - readonly socket: unknown; - readonly ack: unknown; - }, - { readonly body: string } - >(g, { bindingId: "node.ws.orders.in" }); - const ack = g.node>([], null, { - name: "nestjs/ws/orders.ack", - }); - const reply = g.node>([], null, { - name: "nestjs/ws/orders.reply", - }); - const seen: NestBoundaryEnvelope[] = []; - ingress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Gateway { - handle() {} - } - GraphWs(ingress, { - bindingId: "ws.orders.in", - requestId: (host) => host.requestId, - payload: (host) => ({ body: host.body }), - })(Gateway.prototype, "handle", { value: Gateway.prototype.handle }); - GraphWsAck(ack, { bindingId: "ws.orders.ack" })(Gateway.prototype, "handle", { - value: Gateway.prototype.handle, - }); - GraphWsReply(reply, { bindingId: "ws.orders.reply" })(Gateway.prototype, "handle", { - value: Gateway.prototype.handle, - }); - const ackFn = vi.fn(); - const bridge = createGraphWsBridge({ - ack: (host) => host.ack as (payload: unknown) => void, - }); - - const result = bridge.handleMessage(Gateway, "handle", { - requestId: "req-ws-1", - body: "create", - socket: { send: vi.fn() }, - ack: ackFn, - }); - - expect(seen).toEqual([ - { - bindingId: "ws.orders.in", - version: 1, - requestId: "req-ws-1", - payload: { body: "create" }, - }, - ]); - expect(JSON.stringify(seen[0])).not.toContain("socket"); - expect(JSON.stringify(seen[0])).not.toContain("ack"); - - ack.down([ - [ - "DATA", - { - bindingId: "ws.orders.ack", - version: 1, - requestId: "req-ws-1", - payload: { accepted: true }, - }, - ], - ]); - expect(ackFn).toHaveBeenCalledWith({ accepted: true }, expect.any(Object)); - reply.down([ - [ - "DATA", - { - bindingId: "ws.orders.reply", - version: 1, - requestId: "req-ws-1", - payload: { ok: true }, - }, - ], - ]); - - await expect(result).resolves.toEqual({ ok: true }); - expect(bridge.diagnostics()).toEqual([]); - bridge.dispose(); - }); - - it("native websocket bridge diagnoses wrong/stale/malformed/terminal egress and timeout cleanup", async () => { - vi.useFakeTimers(); - try { - const g = graph(); - const ingress = fromNestWs(g, { - bindingId: "node.ws.strict.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const reply = g.node>([], null, { - name: "nestjs/ws/strict.reply", - }); - class Gateway { - handle() {} - } - GraphWs(ingress, { - bindingId: "ws.strict.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(Gateway.prototype, "handle", { value: Gateway.prototype.handle }); - GraphWsReply(reply, { bindingId: "ws.strict.reply" })(Gateway.prototype, "handle", { - value: Gateway.prototype.handle, - }); - const bridge = createGraphWsBridge({ timeoutMs: 20 }); - const terminal = bridge.handleMessage(Gateway, "handle", { - requestId: "req-ws-terminal", - payload: { ok: true }, - }); - reply.down([ - [ - "DATA", - { - bindingId: "ws.other.reply", - version: 1, - requestId: "req-ws-terminal", - payload: { wrong: true }, - }, - ], - [ - "DATA", - { - bindingId: "ws.strict.reply", - version: 1, - requestId: "req-stale", - payload: { stale: true }, - }, - ], - [ - "DATA", - { - bindingId: "ws.strict.reply", - version: 1, - requestId: "req-ws-terminal", - payload: { socket: () => undefined }, - }, - ], - ["COMPLETE"], - ]); - await expect(terminal).rejects.toThrow(/data-only/); - expect(bridge.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "binding-mismatch", - "stale-egress", - "malformed-egress", - "terminal-egress", - ]); - bridge.dispose(); - - const timeoutReply = g.node>([], null, { - name: "nestjs/ws/timeout.reply", - }); - class TimeoutGateway { - handle() {} - } - GraphWs(ingress, { - bindingId: "ws.strict.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(TimeoutGateway.prototype, "handle", { value: TimeoutGateway.prototype.handle }); - GraphWsReply(timeoutReply, { bindingId: "ws.timeout.reply" })( - TimeoutGateway.prototype, - "handle", - { value: TimeoutGateway.prototype.handle }, - ); - const timeoutBridge = createGraphWsBridge({ timeoutMs: 20 }); - const timeout = timeoutBridge.handleMessage(TimeoutGateway, "handle", { - requestId: "req-ws-timeout", - payload: { ok: true }, - }); - vi.advanceTimersByTime(20); - await expect(timeout).rejects.toThrow(/timed out/); - expect(timeoutBridge.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["timeout"]); - timeoutBridge.dispose(); - } finally { - vi.useRealTimers(); - } - }); - - it("native websocket bridge cleans earlier pending registrations when terminal setup settles", async () => { - const g = graph(); - const ingress = fromNestWs(g, { - bindingId: "node.ws.cleanup.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const ack = g.node>([], null, { - name: "nestjs/ws/cleanup.ack", - }); - const terminalReply = g.node>([], null, { - name: "nestjs/ws/cleanup.terminal", - }); - const seen: NestBoundaryEnvelope[] = []; - ingress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Gateway { - handle() {} - } - GraphWs(ingress, { - bindingId: "ws.cleanup.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(Gateway.prototype, "handle", { value: Gateway.prototype.handle }); - GraphWsAck(ack, { bindingId: "ws.cleanup.ack" })(Gateway.prototype, "handle", { - value: Gateway.prototype.handle, - }); - GraphWsReply(terminalReply, { bindingId: "ws.cleanup.terminal" })(Gateway.prototype, "handle", { - value: Gateway.prototype.handle, - }); - const ackFn = vi.fn(); - const bridge = createGraphWsBridge({ - ack: (host: { readonly ack: (payload: unknown) => void }) => host.ack, - }); - terminalReply.down([["COMPLETE"]]); - - await expect( - bridge.handleMessage(Gateway, "handle", { - requestId: "req-ws-cleanup", - payload: { ok: true }, - ack: ackFn, - socket: {}, - }), - ).rejects.toThrow(); - expect(seen.filter((entry) => entry.requestId === "req-ws-cleanup")).toEqual([]); - - ack.down([ - [ - "DATA", - { - bindingId: "ws.cleanup.ack", - version: 1, - requestId: "req-ws-cleanup", - payload: { accepted: true }, - }, - ], - ]); - expect(ackFn).not.toHaveBeenCalled(); - expect(bridge.diagnostics().map((diagnostic) => diagnostic.kind)).toContain("stale-egress"); - bridge.dispose(); - }); - - it("native websocket bridge rejects unsafe defaults and cleans up on disconnect/dispose", async () => { - const g = graph(); - const rawIngress = fromNestWs(g, { bindingId: "node.ws.raw.in" }); - const safeIngress = fromNestWs(g, { - bindingId: "node.ws.safe.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const reply = g.node>([], null, { - name: "nestjs/ws/lifecycle.reply", - }); - const seen: NestBoundaryEnvelope[] = []; - rawIngress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - safeIngress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class RawGateway { - handle() {} - } - class SafeGateway { - handle() {} - } - GraphWs(rawIngress, { - bindingId: "ws.raw.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - })(RawGateway.prototype, "handle", { value: RawGateway.prototype.handle }); - GraphWs(safeIngress, { - bindingId: "ws.safe.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(SafeGateway.prototype, "handle", { value: SafeGateway.prototype.handle }); - GraphWsReply(reply, { bindingId: "ws.lifecycle.reply" })(SafeGateway.prototype, "handle", { - value: SafeGateway.prototype.handle, - }); - const bridge = createGraphWsBridge(); - expect(() => - bridge.handleMessage(RawGateway, "handle", { - requestId: "req-ws-raw", - socket: { id: "socket-1" }, - }), - ).toThrow(/payload selector/); - expect(seen).toEqual([]); - - const socket = { id: "socket-2" }; - const pending = bridge.handleMessage(SafeGateway, "handle", { - requestId: "req-ws-disconnect", - payload: { ok: true }, - socket, - }); - bridge.handleDisconnect(socket); - await expect(pending).rejects.toThrow(/disconnected/); - expect(bridge.diagnostics().map((diagnostic) => diagnostic.kind)).toContain("dispose-pending"); - - bridge.dispose(); - expect(() => - bridge.handleMessage(SafeGateway, "handle", { - requestId: "req-ws-after-dispose", - payload: { ok: true }, - socket: {}, - }), - ).toThrow(/disposed/); - expect(seen.filter((entry) => entry.requestId === "req-ws-after-dispose")).toEqual([]); - }); - - it("native message bridge correlates replies and dispose cleanup without message-context DATA", async () => { - const g = graph(); - const ingress = fromNestMessage< - { readonly requestId: string; readonly message: string; readonly context: unknown }, - { readonly message: string } - >(g, { bindingId: "node.message.orders.in" }); - const reply = g.node>([], null, { - name: "nestjs/message/orders.reply", - }); - const seen: NestBoundaryEnvelope[] = []; - ingress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Controller { - handle() {} - } - GraphMessage(ingress, { - bindingId: "message.orders.in", - requestId: (host) => host.requestId, - payload: (host) => ({ message: host.message }), - })(Controller.prototype, "handle", { value: Controller.prototype.handle }); - GraphMessageReply(reply, { bindingId: "message.orders.reply" })( - Controller.prototype, - "handle", - { value: Controller.prototype.handle }, - ); - const bridge = createGraphMessageBridge(); - const result = bridge.handleMessage(Controller, "handle", { - requestId: "req-message-1", - message: "reserve", - context: { ack: vi.fn() }, - }); - - expect(seen).toEqual([ - { - bindingId: "message.orders.in", - version: 1, - requestId: "req-message-1", - payload: { message: "reserve" }, - }, - ]); - expect(JSON.stringify(seen[0])).not.toContain("context"); - reply.down([ - [ - "DATA", - { - bindingId: "message.orders.reply", - version: 1, - requestId: "req-message-1", - payload: { result: "ok" }, - }, - ], - ]); - await expect(result).resolves.toEqual({ result: "ok" }); - - const pending = bridge.handleMessage(Controller, "handle", { - requestId: "req-message-dispose", - message: "reserve", - context: {}, - }); - bridge.dispose(); - await expect(pending).rejects.toThrow(/disposed/); - expect(bridge.diagnostics().map((diagnostic) => diagnostic.kind)).toContain("dispose-pending"); - }); - - it("native message bridge rejects unsafe defaults and suppresses ingress after terminal reply setup", async () => { - const g = graph(); - const rawIngress = fromNestMessage(g, { bindingId: "node.message.raw.in" }); - const safeIngress = fromNestMessage(g, { - bindingId: "node.message.safe.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const terminalReply = g.node>([], null, { - name: "nestjs/message/terminal.reply", - }); - const seen: NestBoundaryEnvelope[] = []; - rawIngress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - safeIngress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class RawController { - handle() {} - } - class SafeController { - handle() {} - } - GraphMessage(rawIngress, { - bindingId: "message.raw.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - })(RawController.prototype, "handle", { value: RawController.prototype.handle }); - GraphMessage(safeIngress, { - bindingId: "message.safe.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(SafeController.prototype, "handle", { value: SafeController.prototype.handle }); - GraphMessageReply(terminalReply, { bindingId: "message.terminal.reply" })( - SafeController.prototype, - "handle", - { value: SafeController.prototype.handle }, - ); - const bridge = createGraphMessageBridge(); - expect(() => - bridge.handleMessage(RawController, "handle", { - requestId: "req-message-raw", - context: { pattern: "orders" }, - }), - ).toThrow(/payload selector/); - expect(seen).toEqual([]); - - terminalReply.down([["COMPLETE"]]); - await expect( - bridge.handleMessage(SafeController, "handle", { - requestId: "req-message-terminal", - payload: { ok: true }, - }), - ).rejects.toThrow(); - expect(seen.filter((entry) => entry.requestId === "req-message-terminal")).toEqual([]); - bridge.dispose(); - expect(() => - bridge.handleMessage(SafeController, "handle", { - requestId: "req-message-after-dispose", - payload: { ok: true }, - }), - ).toThrow(/disposed/); - }); - - it("native message bridge cleans earlier pending registrations when terminal setup settles", async () => { - const g = graph(); - const ingress = fromNestMessage(g, { - bindingId: "node.message.cleanup.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const firstReply = g.node>([], null, { - name: "nestjs/message/cleanup.first", - }); - const terminalReply = g.node>([], null, { - name: "nestjs/message/cleanup.terminal", - }); - const seen: NestBoundaryEnvelope[] = []; - ingress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Controller { - handle() {} - } - GraphMessage(ingress, { - bindingId: "message.cleanup.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(Controller.prototype, "handle", { value: Controller.prototype.handle }); - GraphMessageReply(firstReply, { bindingId: "message.cleanup.first" })( - Controller.prototype, - "handle", - { value: Controller.prototype.handle }, - ); - GraphMessageReply(terminalReply, { bindingId: "message.cleanup.terminal" })( - Controller.prototype, - "handle", - { value: Controller.prototype.handle }, - ); - const bridge = createGraphMessageBridge(); - terminalReply.down([["COMPLETE"]]); - - await expect( - bridge.handleMessage(Controller, "handle", { - requestId: "req-message-cleanup", - payload: { ok: true }, - }), - ).rejects.toThrow(); - expect(seen.filter((entry) => entry.requestId === "req-message-cleanup")).toEqual([]); - - firstReply.down([ - [ - "DATA", - { - bindingId: "message.cleanup.first", - version: 1, - requestId: "req-message-cleanup", - payload: { ok: true }, - }, - ], - ]); - expect(bridge.diagnostics().map((diagnostic) => diagnostic.kind)).toContain("stale-egress"); - bridge.dispose(); - }); - - it("native cron provider starts and stops timers while emitting GraphCron ingress", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-05T08:30:00.000Z")); - const g = graph(); - const cron = fromNestCron<{ readonly timestamp_ns: string }, { tick: string }>(g, { - bindingId: "node.native.cron.in", - }); - const seen: unknown[] = []; - cron.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Controller { - tick() {} - } - GraphCron(cron, { - bindingId: "native.cron.in", - payload: (host) => ({ tick: host.timestamp_ns }), - })(Controller.prototype, "tick", { value: Controller.prototype.tick }); - const scheduler = provideGraphCronScheduler({ - targets: [ - { - target: Controller, - methodKey: "tick", - expr: "30 8 * * 1", - tickMs: 1000, - timezone: "UTC", - }, - ], - }).useValue as { onModuleInit(): void; onModuleDestroy(): void }; - - scheduler.onModuleInit(); - - expect(seen).toHaveLength(1); - expect(vi.getTimerCount()).toBe(1); - scheduler.onModuleDestroy(); - expect(vi.getTimerCount()).toBe(0); - vi.useRealTimers(); - }); - - it("native cron provider dedupes by current wall-clock minute without blocking later days", () => { - vi.useFakeTimers(); - try { - vi.setSystemTime(new Date("2026-01-05T08:30:00.000Z")); - const g = graph(); - const cron = fromNestCron<{ readonly timestamp_ms: number }, { readonly tick: number }>(g, { - bindingId: "node.native.cron.daily.in", - }); - const seen: unknown[] = []; - cron.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Controller { - tick() {} - } - GraphCron(cron, { - bindingId: "native.cron.daily.in", - payload: (host) => ({ tick: host.timestamp_ms }), - })(Controller.prototype, "tick", { value: Controller.prototype.tick }); - const scheduler = provideGraphCronScheduler({ - targets: [ - { - target: Controller, - methodKey: "tick", - expr: "30 8 * * *", - tickMs: 1000, - timezone: "UTC", - }, - ], - }).useValue as { onModuleInit(): void; onModuleDestroy(): void }; - - scheduler.onModuleInit(); - vi.advanceTimersByTime(30_000); - vi.setSystemTime(new Date("2026-01-06T08:30:00.000Z")); - vi.advanceTimersByTime(1_000); - scheduler.onModuleDestroy(); - - expect(seen).toHaveLength(2); - } finally { - vi.useRealTimers(); - } - }); - - it("manual cron controller checks current time deterministically without catch-up DATA", () => { - const g = graph(); - const cron = fromNestCron<{ readonly timestamp_ms: number }, { readonly tick: number }>(g, { - bindingId: "node.native.cron.manual.in", - }); - const seen: unknown[] = []; - cron.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Controller { - tick() {} - } - GraphCron(cron, { - bindingId: "native.cron.manual.in", - payload: (host) => ({ tick: host.timestamp_ms }), - })(Controller.prototype, "tick", { value: Controller.prototype.tick }); - const controller = createGraphCronController({ - targets: [ - graphCronTarget(Controller, "tick", { - expr: "30 8 * * 1", - timezone: "UTC", - }), - ], - }); - - controller.check(new Date("2026-01-05T08:29:00.000Z")); - controller.check(new Date("2026-01-05T08:30:00.000Z")); - controller.check(new Date("2026-01-05T08:30:59.000Z")); - controller.check(new Date("2026-01-12T08:30:00.000Z")); - - expect(seen).toEqual([ - { - bindingId: "native.cron.manual.in", - version: 1, - payload: { tick: Date.parse("2026-01-05T08:30:00.000Z") }, - }, - { - bindingId: "native.cron.manual.in", - version: 1, - payload: { tick: Date.parse("2026-01-12T08:30:00.000Z") }, - }, - ]); - expect(() => - createGraphCronController({ - targets: [graphCronTarget(Controller, "tick", { expr: "0 30 8 * * 1" })], - }), - ).toThrow(/expected 5 fields/); - }); - - it("native cron provider rolls back timers when module init fails partway", () => { - vi.useFakeTimers(); - try { - vi.setSystemTime(new Date("2026-01-05T08:30:00.000Z")); - class Controller { - tick() {} - bad() {} - } - const scheduler = provideGraphCronScheduler({ - targets: [ - { target: Controller, methodKey: "tick", expr: "* * * * *", tickMs: 1000 }, - { target: Controller, methodKey: "bad", expr: "* * * * *", tickMs: 0 }, - ], - }).useValue as { onModuleInit(): void; onModuleDestroy(): void }; - - expect(() => scheduler.onModuleInit()).toThrow(/tickMs/); - expect(vi.getTimerCount()).toBe(0); - scheduler.onModuleDestroy(); - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/packages/nestjs/src/e2e.test.ts b/packages/nestjs/src/e2e.test.ts deleted file mode 100644 index 7fdf98b..0000000 --- a/packages/nestjs/src/e2e.test.ts +++ /dev/null @@ -1,706 +0,0 @@ -import "reflect-metadata"; -import type { AddressInfo } from "node:net"; -import { depLatest, graph } from "@graphrefly/ts"; -import { Controller, type INestApplication, Module, Post } from "@nestjs/common"; -import { NestFactory } from "@nestjs/core"; -import { - type ClientProxy, - ClientProxyFactory, - MessagePattern, - Transport, -} from "@nestjs/microservices"; -import { WsAdapter } from "@nestjs/platform-ws"; -import { SubscribeMessage, WebSocketGateway } from "@nestjs/websockets"; -import { firstValueFrom } from "rxjs"; -import { afterEach, describe, expect, it } from "vitest"; -import { - fromNestReq, - GraphHttpReply, - GraphReq, - type NestBoundaryEnvelope, - type NestReplyEnvelope, -} from "./index.js"; -import { - fromNestMessage, - GRAPHREFLY_NEST_MESSAGE_BRIDGE, - GraphMessage, - type GraphMessageBridge, - GraphMessageReply, - provideGraphMessageProviders, -} from "./microservices.js"; -import { provideGraphBoundaryInterceptor } from "./native.js"; -import { - fromNestWs, - GRAPHREFLY_NEST_WS_BRIDGE, - GraphWs, - GraphWsAck, - type GraphWsBridge, - GraphWsReply, - provideGraphWsProviders, -} from "./websockets.js"; - -interface E2eHttpHost { - readonly requestId: string; - readonly body: { readonly orderId?: string; readonly hidden?: unknown }; - readonly headers?: Record; -} - -interface E2eWsHost { - readonly requestId: string; - readonly payload: { readonly orderId: string }; - readonly client: object; - readonly ack: (payload: unknown, envelope: NestReplyEnvelope) => void; -} - -interface E2eMessageHost { - readonly requestId: string; - readonly payload: { readonly orderId: string }; - readonly context: object; -} - -interface E2eNestHarness { - readonly app: INestApplication; - readonly url: string; - readonly tcpPort: number; - readonly wsGateway: { - handle( - bodyOrClient: WsMessageBody | object, - clientOrBody: object | WsMessageBody, - ack?: E2eWsHost["ack"], - ): unknown; - pending(body: WsMessageBody, client: object, ack: E2eWsHost["ack"]): unknown; - handleDisconnect(client: object): void; - }; - readonly messageController: { - handle(body: MessagePatternBody, context: object): unknown; - pending(body: MessagePatternBody, context: object): unknown; - }; - readonly wsBridge: GraphWsBridge; - readonly messageBridge: GraphMessageBridge; - readonly httpSeen: NestBoundaryEnvelope[]; - readonly wsSeen: NestBoundaryEnvelope[]; - readonly messageSeen: NestBoundaryEnvelope[]; - readonly graphJson: () => string; - readonly close: () => Promise; -} - -interface WsMessageBody { - readonly requestId: string; - readonly payload: { readonly orderId: string }; -} - -interface MessagePatternBody { - readonly requestId: string; - readonly payload: { readonly orderId: string }; -} - -const openHarnesses: E2eNestHarness[] = []; -const openClients: ClientProxy[] = []; - -afterEach(async () => { - for (const client of openClients.splice(0).reverse()) client.close(); - for (const harness of openHarnesses.splice(0).reverse()) await harness.close(); -}); - -describe("NestJS v1 e2e wiring (D488/D489)", () => { - it("runs HTTP, WebSocket, and message-pattern bridges without host handle DATA", async () => { - const harness = await createE2eNestHarness(); - openHarnesses.push(harness); - - const response = await fetch(`${harness.url}/orders`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-request-id": "req-http-1", - }, - body: JSON.stringify({ orderId: "ord-http-1", hidden: "selected-out" }), - }); - const body = await response.json(); - - expect(response.status).toBe(201); - expect(body).toEqual({ - ok: true, - kind: "http", - orderId: "ord-http-1", - requestId: "req-http-1", - }); - expect(harness.httpSeen).toEqual([ - { - bindingId: "http.e2e.in", - version: 1, - requestId: "req-http-1", - payload: { orderId: "ord-http-1" }, - }, - ]); - expect(JSON.stringify(harness.httpSeen[0])).not.toContain("headers"); - expect(JSON.stringify(harness.httpSeen[0])).not.toContain("hidden"); - - const ackCalls: unknown[] = []; - const socket = { id: "socket-e2e" }; - const wsReply = await harness.wsGateway.handle( - { requestId: "req-ws-1", payload: { orderId: "ord-ws-1" } }, - socket, - (payload, envelope) => ackCalls.push({ payload, envelope }), - ); - - expect(ackCalls).toEqual([ - { - payload: { accepted: true, orderId: "ord-ws-1" }, - envelope: { - bindingId: "ws.e2e.ack", - version: 1, - requestId: "req-ws-1", - payload: { accepted: true, orderId: "ord-ws-1" }, - }, - }, - ]); - expect(wsReply).toEqual({ ok: true, kind: "ws", orderId: "ord-ws-1" }); - expect(harness.wsSeen).toEqual([ - { - bindingId: "ws.e2e.in", - version: 1, - requestId: "req-ws-1", - payload: { orderId: "ord-ws-1" }, - }, - ]); - expect(JSON.stringify(harness.wsSeen[0])).not.toContain("socket-e2e"); - expect(JSON.stringify(harness.wsSeen[0])).not.toContain("ack"); - expect(harness.wsBridge.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "binding-mismatch", - "stale-egress", - ]); - - const messageReply = await harness.messageController.handle( - { requestId: "req-message-1", payload: { orderId: "ord-message-1" } }, - { id: "message-context" }, - ); - - expect(messageReply).toEqual({ - ok: true, - kind: "message", - orderId: "ord-message-1", - }); - expect(harness.messageSeen).toEqual([ - { - bindingId: "message.e2e.in", - version: 1, - requestId: "req-message-1", - payload: { orderId: "ord-message-1" }, - }, - ]); - expect(JSON.stringify(harness.messageSeen[0])).not.toContain("message-context"); - expect(harness.messageBridge.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "binding-mismatch", - "stale-egress", - ]); - expect(harness.graphJson()).not.toContain("socket-e2e"); - expect(harness.graphJson()).not.toContain("message-context"); - - const disconnectSocket = { id: "socket-disconnect" }; - const disconnected = harness.wsGateway.pending( - { requestId: "req-ws-disconnect", payload: { orderId: "ord-ws-disconnect" } }, - disconnectSocket, - () => undefined, - ); - harness.wsGateway.handleDisconnect(disconnectSocket); - await expect(disconnected).rejects.toThrow(/disconnected/); - expect(harness.wsBridge.diagnostics().map((diagnostic) => diagnostic.kind)).toContain( - "dispose-pending", - ); - - const wsPending = harness.wsGateway.pending( - { requestId: "req-ws-close", payload: { orderId: "ord-ws-close" } }, - { id: "socket-close" }, - () => undefined, - ); - const messagePending = harness.messageController.pending( - { requestId: "req-message-close", payload: { orderId: "ord-message-close" } }, - { id: "message-close-context" }, - ); - const wsPendingRejected = expect(wsPending).rejects.toThrow(/disposed/); - const messagePendingRejected = expect(messagePending).rejects.toThrow(/disposed/); - await harness.close(); - await wsPendingRejected; - await messagePendingRejected; - }); - - it("accepts live WebSocket and TCP transport traffic as test-only coverage over existing APIs", async () => { - const harness = await createE2eNestHarness(); - openHarnesses.push(harness); - - const socket = await openWebSocket(harness.url); - try { - const liveWsReply = nextWebSocketJson(socket); - socket.send( - JSON.stringify({ - event: "orders", - data: { - requestId: "req-ws-live", - payload: { orderId: "ord-ws-live" }, - }, - }), - ); - - await expect(liveWsReply).resolves.toEqual({ - ok: true, - kind: "ws", - orderId: "ord-ws-live", - }); - } finally { - socket.close(); - } - - expect(harness.wsSeen).toEqual([ - { - bindingId: "ws.e2e.in", - version: 1, - requestId: "req-ws-live", - payload: { orderId: "ord-ws-live" }, - }, - ]); - expect(harness.graphJson()).not.toContain("WebSocket"); - expect(harness.graphJson()).not.toContain("readyState"); - - const client = ClientProxyFactory.create({ - transport: Transport.TCP, - options: { host: "127.0.0.1", port: harness.tcpPort }, - }); - openClients.push(client); - await client.connect(); - const liveMessageReply = await firstValueFrom( - client.send("orders.e2e", { - requestId: "req-message-live", - payload: { orderId: "ord-message-live" }, - }), - ); - - expect(liveMessageReply).toEqual({ - ok: true, - kind: "message", - orderId: "ord-message-live", - }); - expect(harness.messageSeen).toEqual([ - { - bindingId: "message.e2e.in", - version: 1, - requestId: "req-message-live", - payload: { orderId: "ord-message-live" }, - }, - ]); - expect(harness.graphJson()).not.toContain("message-context"); - }); -}); - -async function createE2eNestHarness(): Promise { - const g = graph({ name: "nestjs-e2e" }); - const httpSeen: NestBoundaryEnvelope[] = []; - const wsSeen: NestBoundaryEnvelope[] = []; - const messageSeen: NestBoundaryEnvelope[] = []; - - const httpIn = fromNestReq(g, { - bindingId: "node.http.e2e.in", - }); - httpIn.node.subscribe((msg) => msg[0] === "DATA" && httpSeen.push(msg[1])); - const httpReply = g.node>( - [httpIn.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly orderId?: string }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - bindingId: "http.e2e.wrong", - version: 1, - requestId: envelope.requestId, - payload: { status: 599, body: { wrong: true } }, - }, - ], - [ - "DATA", - { - bindingId: "http.e2e.out", - version: 1, - requestId: envelope.requestId, - payload: { - status: 201, - body: { - ok: true, - kind: "http", - orderId: envelope.payload.orderId, - requestId: envelope.requestId, - }, - }, - }, - ], - ]); - }, - { name: "http.e2e.out" }, - ); - - const wsIn = fromNestWs(g, { - bindingId: "node.ws.e2e.in", - }); - wsIn.node.subscribe((msg) => msg[0] === "DATA" && wsSeen.push(msg[1])); - const wsAck = g.node>( - [wsIn.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly orderId: string }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - bindingId: "ws.e2e.other", - version: 1, - requestId: envelope.requestId, - payload: { ignored: true }, - }, - ], - [ - "DATA", - { - bindingId: "ws.e2e.ack", - version: 1, - requestId: "req-ws-stale", - payload: { stale: true }, - }, - ], - [ - "DATA", - { - bindingId: "ws.e2e.ack", - version: 1, - requestId: envelope.requestId, - payload: { accepted: true, orderId: envelope.payload.orderId }, - }, - ], - ]); - }, - { name: "ws.e2e.ack" }, - ); - const wsReply = g.node>( - [wsIn.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly orderId: string }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - bindingId: "ws.e2e.reply", - version: 1, - requestId: envelope.requestId, - payload: { ok: true, kind: "ws", orderId: envelope.payload.orderId }, - }, - ], - ]); - }, - { name: "ws.e2e.reply" }, - ); - - const messageIn = fromNestMessage(g, { - bindingId: "node.message.e2e.in", - }); - messageIn.node.subscribe((msg) => msg[0] === "DATA" && messageSeen.push(msg[1])); - const messageReply = g.node>( - [messageIn.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly orderId: string }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - bindingId: "message.e2e.other", - version: 1, - requestId: envelope.requestId, - payload: { ignored: true }, - }, - ], - [ - "DATA", - { - bindingId: "message.e2e.reply", - version: 1, - requestId: "req-message-stale", - payload: { stale: true }, - }, - ], - [ - "DATA", - { - bindingId: "message.e2e.reply", - version: 1, - requestId: envelope.requestId, - payload: { ok: true, kind: "message", orderId: envelope.payload.orderId }, - }, - ], - ]); - }, - { name: "message.e2e.reply" }, - ); - const wsPendingIn = fromNestWs(g, { - bindingId: "node.ws.e2e.pending.in", - }); - const wsPendingReply = g.node>([], null, { - name: "ws.e2e.pending.reply", - }); - const messagePendingIn = fromNestMessage(g, { - bindingId: "node.message.e2e.pending.in", - }); - const messagePendingReply = g.node>([], null, { - name: "message.e2e.pending.reply", - }); - - class E2eHttpController { - create(): void {} - } - - class E2eWsGateway { - bridge?: GraphWsBridge; - - handle( - bodyOrClient: WsMessageBody | object, - clientOrBody: object | WsMessageBody, - ack: E2eWsHost["ack"] = () => undefined, - ): unknown { - if (this.bridge === undefined) throw new Error("GraphWsBridge was not attached"); - const [client, body] = isWsMessageBody(bodyOrClient) - ? [clientOrBody as object, bodyOrClient] - : [bodyOrClient, clientOrBody as WsMessageBody]; - return this.bridge.handleMessage(E2eWsGateway, "handle", { - requestId: body.requestId, - payload: body.payload, - client, - ack, - }); - } - - pending(body: WsMessageBody, client: object, ack: E2eWsHost["ack"]): unknown { - if (this.bridge === undefined) throw new Error("GraphWsBridge was not attached"); - return this.bridge.handleMessage(E2eWsGateway, "pending", { - requestId: body.requestId, - payload: body.payload, - client, - ack, - }); - } - - handleDisconnect(client: object): void { - if (this.bridge === undefined) throw new Error("GraphWsBridge was not attached"); - this.bridge.handleDisconnect(client); - } - } - - class E2eMessageController { - bridge?: GraphMessageBridge; - - handle(body: MessagePatternBody, context: object): unknown { - if (this.bridge === undefined) throw new Error("GraphMessageBridge was not attached"); - return this.bridge.handleMessage(E2eMessageController, "handle", { - requestId: body.requestId, - payload: body.payload, - context, - }); - } - - pending(body: MessagePatternBody, context: object): unknown { - if (this.bridge === undefined) throw new Error("GraphMessageBridge was not attached"); - return this.bridge.handleMessage(E2eMessageController, "pending", { - requestId: body.requestId, - payload: body.payload, - context, - }); - } - } - - applyClassDecorator(Controller(), E2eHttpController); - applyMethodDecorators( - E2eHttpController.prototype, - "create", - Post("orders"), - GraphReq(httpIn, { - bindingId: "http.e2e.in", - requestId: (host: E2eHttpHost) => host.requestId, - payload: (host: E2eHttpHost) => ({ orderId: host.body.orderId }), - }), - GraphHttpReply(httpReply, { bindingId: "http.e2e.out" }), - ); - - applyClassDecorator(WebSocketGateway(), E2eWsGateway); - applyMethodDecorators( - E2eWsGateway.prototype, - "handle", - SubscribeMessage("orders"), - GraphWs(wsIn, { - bindingId: "ws.e2e.in", - requestId: (host: E2eWsHost) => host.requestId, - payload: (host: E2eWsHost) => host.payload, - }), - GraphWsAck(wsAck, { bindingId: "ws.e2e.ack" }), - GraphWsReply(wsReply, { bindingId: "ws.e2e.reply" }), - ); - applyMethodDecorators( - E2eWsGateway.prototype, - "pending", - SubscribeMessage("orders.pending"), - GraphWs(wsPendingIn, { - bindingId: "ws.e2e.pending.in", - requestId: (host: E2eWsHost) => host.requestId, - payload: (host: E2eWsHost) => host.payload, - }), - GraphWsReply(wsPendingReply, { bindingId: "ws.e2e.pending.reply" }), - ); - - applyClassDecorator(Controller(), E2eMessageController); - applyMethodDecorators( - E2eMessageController.prototype, - "handle", - MessagePattern("orders.e2e"), - GraphMessage(messageIn, { - bindingId: "message.e2e.in", - requestId: (host: E2eMessageHost) => host.requestId, - payload: (host: E2eMessageHost) => host.payload, - }), - GraphMessageReply(messageReply, { bindingId: "message.e2e.reply" }), - ); - applyMethodDecorators( - E2eMessageController.prototype, - "pending", - MessagePattern("orders.e2e.pending"), - GraphMessage(messagePendingIn, { - bindingId: "message.e2e.pending.in", - requestId: (host: E2eMessageHost) => host.requestId, - payload: (host: E2eMessageHost) => host.payload, - }), - GraphMessageReply(messagePendingReply, { bindingId: "message.e2e.pending.reply" }), - ); - - class E2eAppModule {} - applyClassDecorator( - Module({ - controllers: [E2eHttpController, E2eMessageController], - providers: [ - E2eWsGateway, - provideGraphBoundaryInterceptor({ - host: (context) => { - const req = context.switchToHttp().getRequest<{ - body?: E2eHttpHost["body"]; - headers?: E2eHttpHost["headers"]; - }>(); - const requestId = req.headers?.["x-request-id"]; - return { - requestId: Array.isArray(requestId) ? requestId[0] : (requestId ?? "req-http"), - body: req.body ?? {}, - headers: req.headers, - }; - }, - requestId: (host: E2eHttpHost) => host.requestId, - }), - ...provideGraphWsProviders({ - bridge: { - ack: (host) => host.ack, - client: (host) => host.client, - }, - }), - ...provideGraphMessageProviders(), - ], - }), - E2eAppModule, - ); - - const app = await NestFactory.create(E2eAppModule, { logger: false }); - const attachWsAdapter = app.useWebSocketAdapter.bind(app); - attachWsAdapter(new WsAdapter(app)); - const microservice = app.connectMicroservice({ - transport: Transport.TCP, - options: { host: "127.0.0.1", port: 0 }, - }); - await app.startAllMicroservices(); - const tcpPort = tcpPortFor(microservice.unwrap()); - await app.listen(0, "127.0.0.1"); - const wsGateway = app.get(E2eWsGateway); - const messageController = app.get(E2eMessageController, { strict: false }); - const wsBridge = app.get>(GRAPHREFLY_NEST_WS_BRIDGE); - const messageBridge = app.get>(GRAPHREFLY_NEST_MESSAGE_BRIDGE); - wsGateway.bridge = wsBridge; - messageController.bridge = messageBridge; - - let closed = false; - return { - app, - url: await app.getUrl(), - tcpPort, - wsGateway, - messageController, - wsBridge, - messageBridge, - httpSeen, - wsSeen, - messageSeen, - graphJson: () => JSON.stringify(g.describe()), - close: async () => { - if (closed) return; - closed = true; - await app.close(); - }, - }; -} - -function isWsMessageBody(value: unknown): value is WsMessageBody { - return value !== null && typeof value === "object" && "requestId" in value && "payload" in value; -} - -function tcpPortFor(server: unknown): number { - const address = - server !== null && typeof server === "object" && "address" in server - ? (server as { address: () => AddressInfo | string | null }).address() - : undefined; - if (address === null || address === undefined || typeof address === "string") { - throw new Error("Nest TCP live acceptance test could not resolve a random TCP port"); - } - return address.port; -} - -async function openWebSocket(baseUrl: string): Promise { - const url = baseUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:"); - const socket = new WebSocket(url); - await new Promise((resolve, reject) => { - socket.addEventListener("open", () => resolve(), { once: true }); - socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), { - once: true, - }); - }); - return socket; -} - -function nextWebSocketJson(socket: WebSocket): Promise { - return new Promise((resolve, reject) => { - socket.addEventListener( - "message", - (event) => { - try { - resolve(JSON.parse(String(event.data))); - } catch (error) { - reject(error); - } - }, - { once: true }, - ); - }); -} - -function applyClassDecorator(decorator: ClassDecorator, target: abstract new () => unknown): void { - decorator(target); -} - -function applyMethodDecorators( - prototype: object, - methodKey: string, - ...decorators: MethodDecorator[] -): void { - const descriptor = Object.getOwnPropertyDescriptor(prototype, methodKey); - if (descriptor === undefined) throw new Error(`Missing descriptor for ${methodKey}`); - for (const decorator of decorators) decorator(prototype, methodKey, descriptor); -} diff --git a/packages/nestjs/src/index.test.ts b/packages/nestjs/src/index.test.ts deleted file mode 100644 index e57cf95..0000000 --- a/packages/nestjs/src/index.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { graph } from "@graphrefly/ts"; -import { describe, expect, it } from "vitest"; -import { - fromNestReq, - GraphReq, - getNestBoundaryBindings, - NEST_BOUNDARY_ENVELOPE_VERSION, -} from "./index.js"; - -describe("@graphrefly/nestjs", () => { - it("owns structural decorator metadata without importing Nest runtime", () => { - const request = fromNestReq(graph(), { - bindingId: "orders.create.in", - payload: () => ({ ok: true }), - }); - class Controller { - post() {} - } - GraphReq(request)(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - - expect(getNestBoundaryBindings(Controller, "post")).toMatchObject([ - { bindingId: "orders.create.in", kind: "request" }, - ]); - expect(NEST_BOUNDARY_ENVELOPE_VERSION).toBe(1); - }); -}); diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts deleted file mode 100644 index 95cd8e3..0000000 --- a/packages/nestjs/src/index.ts +++ /dev/null @@ -1,2118 +0,0 @@ -/** - * Focused NestJS boundary bindings for GraphReFly (D474/D478). - * - * This subpath stays dependency-light: it exposes graph boundary primitives, - * token/provider shapes, and decorator metadata without importing Nest itself. - * User-land Nest modules/controllers bind these helpers to real decorators and - * host lifecycle objects at the framework edge. - */ - -import type { DataIssue, Graph, Message, Node } from "@graphrefly/ts"; - -function canonicalTupleKey(parts: readonly string[]): string { - return JSON.stringify(parts); -} - -export const NEST_BOUNDARY_ENVELOPE_VERSION = 1; -export const NEST_BOUNDARY_PAYLOAD_MAX_BYTES = 64 * 1024; -export const NEST_HTTP_DIAGNOSTICS_MAX_RETAINED = 100; - -/** D478 minimal graph-visible transport envelope. Payload must be data-only. */ -export interface NestBoundaryEnvelope { - readonly bindingId: string; - readonly version: number; - readonly payload: T; - readonly requestId?: string; -} - -/** Reply-capable egress must carry host-private request correlation (D478). */ -export type NestReplyEnvelope = NestBoundaryEnvelope & { - readonly requestId: string; -}; - -export type NestBoundaryKind = - | "request" - | "guard" - | "interceptor" - | "error" - | "lifecycle" - | "cron" - | "diagnostics" - | "ws" - | "message"; - -export type NestEgressKind = "http" | "guard-decision" | "ws-ack" | "ws-reply" | "message-reply"; -export type NestFilterMode = "handle" | "observe"; -export type NestDiagnosticPhase = - | "adapter" - | "http" - | "guard" - | "filter" - | "cron" - | "lifecycle" - | "ws" - | "message" - | NestBoundaryKind - | NestEgressKind; - -export interface NestHttpResponsePayload { - readonly status: number; - readonly body?: TBody; - readonly headers?: Record; -} - -export interface HttpDataIssue extends DataIssue { - readonly status: number; - readonly body?: unknown; - readonly headers?: Record; -} - -export type NestIssueResponse = ( - issue: DataIssue, - host: THost, -) => NestHttpResponsePayload; - -export type NestProtocolErrorResponse = ( - errorPayload: unknown, - host: THost, -) => NestHttpResponsePayload; - -export type GraphGuardDecision = - | { - readonly kind: "allow"; - readonly reason?: string; - readonly metadata?: Record; - } - | { - readonly kind: "deny"; - readonly reason?: string; - readonly status?: number; - readonly body?: unknown; - readonly headers?: Record; - readonly issue?: DataIssue | HttpDataIssue; - readonly metadata?: Record; - }; - -export interface NestBoundaryDiagnostic { - readonly kind: - | "binding-mismatch" - | "dispose-pending" - | "malformed-egress" - | "stale-egress" - | "terminal-egress" - | "timeout" - | "resolve-threw" - | "reject-threw"; - readonly phase?: NestDiagnosticPhase; - readonly requestId?: string; - readonly bindingId?: string; - readonly expectedBindingId?: string; - readonly message: string; - readonly error?: unknown; -} - -export interface NestDiagnosticErrorPayload { - readonly name?: string; - readonly message: string; -} - -export interface NestDiagnosticPayload { - readonly kind: NestBoundaryDiagnostic["kind"]; - readonly phase: NestDiagnosticPhase; - readonly requestId?: string; - readonly bindingId?: string; - readonly expectedBindingId?: string; - readonly message: string; - readonly error?: NestDiagnosticErrorPayload; -} - -export interface NestDiagnosticInput extends NestBoundaryDiagnostic { - readonly phase?: NestDiagnosticPhase; -} - -export interface NestDiagnosticsOptions - extends Omit, "payload"> { - readonly phase?: NestDiagnosticPhase; -} - -export type NestDiagnosticIngressBoundary = NestIngressBoundary< - NestDiagnosticInput, - NestDiagnosticPayload ->; - -export interface NestIngressBoundary { - readonly kind: NestBoundaryKind; - readonly bindingId: string; - readonly version: number; - readonly node: Node>; - envelope(host: THost, opts?: NestIngressEmitOptions): NestBoundaryEnvelope; - emit(host: THost, opts?: NestIngressEmitOptions): NestBoundaryEnvelope; -} - -export interface NestIngressOptions { - readonly bindingId?: string; - readonly name?: string; - readonly version?: number; - readonly maxPayloadBytes?: number; - readonly requestId?: string | ((host: THost) => string | undefined); - readonly requireRequestId?: boolean; - readonly payload?: (host: THost) => TPayload; -} - -export interface NestIngressEmitOptions { - readonly requestId?: string; - readonly bindingId?: string; - readonly version?: number; - readonly payload?: TPayload; - readonly requireRequestId?: boolean; -} - -export interface NestReplyResponseHandle { - resolve(payload: TPayload, envelope: NestReplyEnvelope): void; - reject(error: unknown, envelope?: NestReplyEnvelope): void; -} - -export interface NestReplyPendingRegistration { - readonly requestId: string; - readonly handle: NestReplyResponseHandle; - readonly bindingId?: string; -} - -export interface NestReplyBoundary { - readonly kind: NestEgressKind; - readonly bindingId: string; - attach(registration: NestReplyPendingRegistration): () => boolean; - pendingCount(): number; - diagnostics(): readonly NestBoundaryDiagnostic[]; - dispose(): void; -} - -export interface NestHttpResponseHandle extends NestReplyResponseHandle {} - -export interface NestHttpPendingRegistration - extends NestReplyPendingRegistration {} - -export interface NestHttpBoundary extends NestReplyBoundary { - readonly kind: "http"; -} - -export interface ToNestHttpOptions { - readonly bindingId?: string; - readonly diagnosticBoundary?: NestDiagnosticIngressBoundary; - readonly diagnosticPhase?: NestDiagnosticPhase; - readonly maxDiagnostics?: number; - readonly maxPayloadBytes?: number; - readonly name?: string; - readonly transform?: (payload: TPayload, envelope: NestReplyEnvelope) => TPayload; - readonly label?: string; -} - -interface NestReplyPendingEntry { - readonly requestId: string; - readonly bindingId?: string; - readonly handle: NestReplyResponseHandle; -} - -interface NestHttpPendingEntry extends NestReplyPendingEntry {} - -export interface NestBoundaryDecoratorOptions { - readonly bindingId?: string; - readonly payload?: (host: THost) => TPayload; - readonly requestId?: string | ((host: THost) => string | undefined); - readonly order?: number; -} - -export interface NestFilterDecoratorOptions - extends NestBoundaryDecoratorOptions { - readonly mode?: NestFilterMode; - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface NestHttpReplyDecoratorOptions { - readonly bindingId: string; - readonly order?: number; - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface NestGuardDecisionDecoratorOptions { - readonly bindingId: string; - readonly order?: number; - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface NestGraphRunOptions { - readonly requestId?: string | ((host: THost) => string | undefined); -} - -export interface NestGraphBoundaryRunnerOptions { - readonly diagnosticBoundary?: NestDiagnosticIngressBoundary; - readonly diagnosticPhase?: NestDiagnosticPhase; -} - -export interface NestGraphBoundaryRunner { - run( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - host: THost, - opts?: NestGraphRunOptions, - ): Promise | undefined; - dispose(): void; -} - -export interface NestExecutionContextLike { - getClass(): DecoratorHostConstructor; - getHandler(): DecoratorBoundMethod; - switchToHttp?(): { getRequest(): T }; -} - -export interface NestCallHandlerLike { - handle(): unknown; -} - -export interface NestGraphBoundaryInterceptorOptions - extends NestGraphRunOptions, - NestGraphBoundaryRunnerOptions { - readonly host?: (context: NestExecutionContextLike) => THost; - readonly runner?: NestGraphBoundaryRunner; -} - -export interface NestGraphBoundaryInterceptor { - intercept( - context: NestExecutionContextLike, - next?: NestCallHandlerLike, - ): Promise | unknown; - dispose(): void; -} - -/** Injection token for a root graph singleton. */ -export const GRAPHREFLY_ROOT_GRAPH = Symbol.for("graphrefly:root-graph"); - -/** Injection token for adapter module options. */ -export const GRAPHREFLY_MODULE_OPTIONS = Symbol.for("graphrefly:module-options"); - -/** Injection token for a request-scoped graph. */ -export const GRAPHREFLY_REQUEST_GRAPH = Symbol.for("graphrefly:request-graph"); - -export type DecoratorHostConstructor = abstract new (...args: unknown[]) => unknown; -export type DecoratorBoundMethod = (...args: unknown[]) => unknown; - -export interface OnGraphEventMeta { - nodeName: string; - methodKey: string | symbol; -} - -export interface GraphIntervalMeta { - ms: number; - methodKey: string | symbol; -} - -export interface GraphCronMeta { - expr: string; - methodKey: string | symbol; -} - -export type NestBoundaryBindingDirection = "ingress" | "egress"; - -export interface NestIngressBindingMeta { - readonly direction: "ingress"; - kind: NestBoundaryKind; - bindingId: string; - methodKey: string | symbol; - readonly boundary: NestIngressBoundary; - readonly payload?: (host: unknown) => unknown; - readonly requestId?: string | ((host: unknown) => string | undefined); - readonly order?: number; - readonly mode?: NestFilterMode; - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface NestHttpReplyBindingMeta { - readonly direction: "egress"; - readonly kind: "http"; - readonly bindingId: string; - readonly methodKey: string | symbol; - readonly replyNode: Node>; - readonly order?: number; - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface NestGuardDecisionBindingMeta { - readonly direction: "egress"; - readonly kind: "guard-decision"; - readonly bindingId: string; - readonly methodKey: string | symbol; - readonly decisionNode: Node>; - readonly order?: number; - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface NestWsAckBindingMeta { - readonly direction: "egress"; - readonly kind: "ws-ack"; - readonly bindingId: string; - readonly methodKey: string | symbol; - readonly ackNode: Node>; - readonly order?: number; -} - -export interface NestWsReplyBindingMeta { - readonly direction: "egress"; - readonly kind: "ws-reply"; - readonly bindingId: string; - readonly methodKey: string | symbol; - readonly replyNode: Node>; - readonly order?: number; -} - -export interface NestMessageReplyBindingMeta { - readonly direction: "egress"; - readonly kind: "message-reply"; - readonly bindingId: string; - readonly methodKey: string | symbol; - readonly replyNode: Node>; - readonly order?: number; -} - -export type NestBoundaryBindingMeta = - | NestIngressBindingMeta - | NestHttpReplyBindingMeta - | NestGuardDecisionBindingMeta - | NestWsAckBindingMeta - | NestWsReplyBindingMeta - | NestMessageReplyBindingMeta; - -export const EVENT_HANDLERS = new WeakMap(); -export const INTERVAL_HANDLERS = new WeakMap(); -export const CRON_HANDLERS = new WeakMap(); -export const NEST_BOUNDARY_BINDINGS = new WeakMap< - DecoratorHostConstructor, - NestBoundaryBindingMeta[] ->(); - -export type GraphMethodDecorator = MethodDecorator & - ((value: DecoratorBoundMethod, context: ClassMethodDecoratorContext) => void); - -/** Minimal Nest provider object shape without importing @nestjs/common. */ -export type NestProviderBinding = - | { readonly provide: string | symbol; readonly useValue: T } - | { readonly provide: string | symbol; readonly useFactory: (...args: unknown[]) => T }; - -/** Get the injection token for a named feature graph. - * @param name - Stable name for the created node or helper. - * @returns A `symbol` value. - * @category adapters - * @example - * ```ts - * import { getGraphToken } from "@graphrefly/nestjs"; - * ``` - */ -export function getGraphToken(name: string): symbol { - assertNonEmptyString(name, "getGraphToken(name)"); - return Symbol.for(`graphrefly:graph:${name}`); -} - -/** Get the injection token for a node at a qualified path. - * @param path - path value used by the helper. - * @returns A `symbol` value. - * @category adapters - * @example - * ```ts - * import { getNodeToken } from "@graphrefly/nestjs"; - * ``` - */ -export function getNodeToken(path: string): symbol { - assertNonEmptyString(path, "getNodeToken(path)"); - return Symbol.for(`graphrefly:node:${path}`); -} - -/** Get the injection token for a named Nest boundary binding. - * @param bindingId - Stable identifier used by the emitted record. - * @returns A `symbol` value. - * @category adapters - * @example - * ```ts - * import { getNestBoundaryToken } from "@graphrefly/nestjs"; - * ``` - */ -export function getNestBoundaryToken(bindingId: string): symbol { - assertNonEmptyString(bindingId, "getNestBoundaryToken(bindingId)"); - return Symbol.for(`graphrefly:nest-boundary:${bindingId}`); -} - -/** Build a dependency-free provider binding shape for user-land Nest modules. - * @param provide - provide value used by the helper. - * @param useValue - use value value used by the helper. - * @returns A `NestProviderBinding` value. - * @category adapters - * @example - * ```ts - * import { nestProvider } from "@graphrefly/nestjs"; - * ``` - */ -export function nestProvider(provide: string | symbol, useValue: T): NestProviderBinding { - return { provide, useValue }; -} - -/** D474 P0 HTTP request ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestReq } from "@graphrefly/nestjs"; - * ``` - */ -export function fromNestReq( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "request", opts); -} - -/** D474 P0 guard/admission ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestGuard } from "@graphrefly/nestjs"; - * ``` - */ -export function fromNestGuard( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "guard", opts); -} - -/** D474 P1 interceptor ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestIntercept } from "@graphrefly/nestjs"; - * ``` - */ -export function fromNestIntercept( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "interceptor", opts); -} - -/** D474 P0 error/filter ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestError } from "@graphrefly/nestjs"; - * ``` - */ -export function fromNestError( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "error", opts); -} - -/** D474 P1 Nest lifecycle hook ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestLifecycle } from "@graphrefly/nestjs"; - * ``` - */ -export function fromNestLifecycle( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "lifecycle", opts); -} - -/** D474 P1 schedule/cron ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestCron } from "@graphrefly/nestjs"; - * ``` - */ -export function fromNestCron( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "cron", opts); -} - -/** D494 explicit graph-visible diagnostics ingress. Emits sanitized data-only payloads. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestDiagnosticIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestDiagnostics } from "@graphrefly/nestjs"; - * ``` - */ -export function fromNestDiagnostics( - graph: Graph, - opts: NestDiagnosticsOptions = {}, -): NestDiagnosticIngressBoundary { - return nestIngress(graph, "diagnostics", { - ...opts, - payload: (diagnostic) => sanitizeNestDiagnostic(diagnostic, opts.phase), - }); -} - -/** D474 later/optional WebSocket ingress. Kept thin for first-slice experiments. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestWs } from "@graphrefly/nestjs"; - * ``` - */ -export function fromNestWs( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "ws", opts); -} - -/** D474 later/optional microservice/message ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestMessage } from "@graphrefly/nestjs"; - * ``` - */ -export function fromNestMessage( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "message", opts); -} - -/** - * D474 HTTP egress resolver. - * - * The returned boundary owns only a host-private pending map. It never stores - * response/socket/ack handles in graph DATA and only resolves handles whose - * requestId (and optional bindingId) match an egress envelope. - * @param egress - egress value used by the helper. - * @param opts - Options that configure the helper. - * @returns A NestHttpBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { toNestHttp } from "@graphrefly/nestjs"; - * ``` - */ -export function toNestHttp( - egress: Node>, - opts: ToNestHttpOptions = {}, -): NestHttpBoundary { - const bindingId = stableBindingId("http", opts); - const scopedBindingId = opts.bindingId; - const maxDiagnostics = diagnosticsRetainedLimit(opts.maxDiagnostics); - const maxPayloadBytes = payloadByteLimit(opts.maxPayloadBytes); - const pending = new Map>(); - const diagnostics: NestBoundaryDiagnostic[] = []; - let active = true; - let terminal: - | { - readonly error: unknown; - readonly message: string; - } - | undefined; - - const report = (diagnostic: NestBoundaryDiagnostic) => { - pushDiagnostic(diagnostics, diagnostic, maxDiagnostics); - try { - const phase = diagnostic.phase ?? opts.diagnosticPhase ?? "http"; - const payload = sanitizeNestDiagnostic({ ...diagnostic, phase }, phase); - opts.diagnosticBoundary?.emit(payload, { payload }); - } catch { - // Graph-visible diagnostics are optional and must not interrupt host cleanup. - } - }; - const keyOf = (requestId: string, requestBindingId?: string) => - requestBindingId === undefined ? requestId : canonicalTupleKey([requestBindingId, requestId]); - const rejectEntry = ( - entry: NestHttpPendingEntry, - error: unknown, - envelope: NestReplyEnvelope | undefined, - message: string, - ) => { - try { - entry.handle.reject(error, envelope); - } catch (rejectError) { - report({ - kind: "reject-threw", - requestId: entry.requestId, - bindingId: entry.bindingId, - message, - error: rejectError, - }); - } - }; - const rejectPending = ( - kind: "dispose-pending" | "terminal-egress", - error: unknown, - message: string, - ): number => { - const entries = [...pending.values()]; - pending.clear(); - if (entries.length === 0) return 0; - report({ kind, bindingId: scopedBindingId, message, error }); - for (const entry of entries) { - rejectEntry(entry, error, undefined, `toNestHttp(${bindingId}) pending reject threw`); - } - return entries.length; - }; - - const unsubscribe = egress.subscribe((msg: Message) => { - if (!active) return; - if (msg[0] === "ERROR" || msg[0] === "COMPLETE" || msg[0] === "TEARDOWN") { - const error = - msg[0] === "ERROR" - ? msg[1] - : new Error(`toNestHttp(${bindingId}) egress received ${msg[0]}`); - const message = `toNestHttp(${bindingId}) rejected pending requests after ${msg[0]}`; - terminal = { error, message }; - const rejectedCount = rejectPending("terminal-egress", error, message); - if (rejectedCount === 0) - report({ kind: "terminal-egress", bindingId: scopedBindingId, message, error }); - return; - } - if (msg[0] !== "DATA") return; - const envelope = msg[1] as NestReplyEnvelope; - const malformed = validateEnvelope(envelope, maxPayloadBytes); - if (malformed !== undefined) { - const correlated = malformedCorrelation(msg[1], scopedBindingId); - if (correlated !== undefined) { - const pendingKey = keyOf(correlated.requestId, scopedBindingId); - const entry = pending.get(pendingKey); - if (entry !== undefined) { - pending.delete(pendingKey); - rejectEntry( - entry, - new Error(malformed), - undefined, - `toNestHttp(${bindingId}) rejected malformed correlated egress`, - ); - } - } - report({ - kind: "malformed-egress", - requestId: correlated?.requestId, - bindingId: correlated?.bindingId, - message: malformed, - }); - return; - } - if (scopedBindingId !== undefined && envelope.bindingId !== scopedBindingId) { - report({ - kind: "binding-mismatch", - requestId: envelope.requestId, - bindingId: envelope.bindingId, - expectedBindingId: scopedBindingId, - message: `toNestHttp(${bindingId}) ignored egress for binding ${envelope.bindingId}`, - }); - return; - } - const pendingKey = keyOf(envelope.requestId, scopedBindingId); - const entry = pending.get(pendingKey); - if (entry === undefined) { - report({ - kind: "stale-egress", - requestId: envelope.requestId, - bindingId: envelope.bindingId, - message: `toNestHttp(${bindingId}) ignored stale requestId ${envelope.requestId}`, - }); - return; - } - pending.delete(pendingKey); - try { - entry.handle.resolve( - opts.transform?.(envelope.payload, envelope) ?? envelope.payload, - envelope, - ); - } catch (error) { - report({ - kind: "resolve-threw", - requestId: envelope.requestId, - bindingId: envelope.bindingId, - message: `toNestHttp(${bindingId}) response handle threw while resolving`, - error, - }); - rejectEntry( - entry, - error, - envelope, - `toNestHttp(${bindingId}) response handle threw while rejecting`, - ); - } - }); - - return { - kind: "http", - bindingId, - attach(registration) { - if (!active) throw new Error(`toNestHttp(${bindingId}) is disposed`); - assertNonEmptyString(registration.requestId, "toNestHttp.attach(requestId)"); - const registrationBindingId = registration.bindingId ?? scopedBindingId; - if (scopedBindingId !== undefined && registrationBindingId !== scopedBindingId) { - throw new Error( - `toNestHttp.attach expected bindingId ${scopedBindingId}, got ${registrationBindingId}`, - ); - } - if (terminal !== undefined) { - const entry = { - requestId: registration.requestId, - bindingId: registrationBindingId, - handle: registration.handle, - }; - report({ - kind: "terminal-egress", - requestId: registration.requestId, - bindingId: registrationBindingId, - message: terminal.message, - error: terminal.error, - }); - rejectEntry( - entry, - terminal.error, - undefined, - `toNestHttp(${bindingId}) terminal response handle reject threw`, - ); - return () => false; - } - const key = keyOf(registration.requestId, scopedBindingId); - if (pending.has(key)) { - throw new Error(`toNestHttp.attach duplicate pending requestId ${registration.requestId}`); - } - const entry = { - requestId: registration.requestId, - bindingId: registrationBindingId, - handle: registration.handle, - }; - pending.set(key, entry); - return () => { - if (pending.get(key) !== entry) return false; - return pending.delete(key); - }; - }, - pendingCount: () => pending.size, - diagnostics: () => diagnostics.slice(), - dispose() { - if (!active) return; - active = false; - rejectPending( - "dispose-pending", - new Error(`toNestHttp(${bindingId}) disposed before response resolution`), - `toNestHttp(${bindingId}) rejected pending requests during dispose`, - ); - unsubscribe(); - }, - }; -} - -/** D478 route-request decorator over an existing ingress boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphReq } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphReq( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("request", boundary, opts); -} - -/** D478 route-guard decorator over an existing ingress boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphGuard } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphGuard( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("guard", boundary, opts); -} - -/** D478 route-interceptor decorator over an existing ingress boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphIntercept } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphIntercept( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("interceptor", boundary, opts); -} - -/** D484 generic filter decorator over an existing ingress boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphFilter } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphFilter( - boundary: NestIngressBoundary, - opts: NestFilterDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("error", boundary, opts); -} - -/** D484 exception-oriented sugar over GraphFilter. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphError } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphError( - boundary: NestIngressBoundary, - opts: NestFilterDecoratorOptions = {}, -): GraphMethodDecorator { - return GraphFilter(boundary, opts); -} - -/** D478 lifecycle decorator over an existing ingress boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphLifecycle } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphLifecycle( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("lifecycle", boundary, opts); -} - -/** D478 cron/schedule decorator over an existing ingress boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphCron } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphCron( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("cron", boundary, opts); -} - -/** D488 WebSocket message ingress decorator over an existing boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphWs } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphWs( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("ws", boundary, opts); -} - -/** D488 microservice/message ingress decorator over an existing boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphMessage } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphMessage( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("message", boundary, opts); -} - -/** D478 HTTP reply decorator over an existing reply node. - * @param replyNode - reply node value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphHttpReply } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphHttpReply( - replyNode: Node>, - opts: NestHttpReplyDecoratorOptions, -): GraphMethodDecorator { - const bindingId = opts?.bindingId; - if (typeof bindingId !== "string" || bindingId.length === 0) { - throw new Error("GraphHttpReply requires a non-empty bindingId"); - } - return registerMeta(NEST_BOUNDARY_BINDINGS, (methodKey) => ({ - direction: "egress" as const, - kind: "http" as const, - bindingId, - methodKey, - replyNode: replyNode as Node>, - order: opts.order, - issueResponse: opts.issueResponse as NestIssueResponse | undefined, - protocolError: opts.protocolError as NestProtocolErrorResponse | undefined, - })); -} - -/** D484 guard decision egress decorator over an existing reply-correlated decision node. - * @param decisionNode - decision node value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphGuardDecision } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphGuardDecision( - decisionNode: Node>, - opts: NestGuardDecisionDecoratorOptions, -): GraphMethodDecorator { - const bindingId = opts?.bindingId; - if (typeof bindingId !== "string" || bindingId.length === 0) { - throw new Error("GraphGuardDecision requires a non-empty bindingId"); - } - return registerMeta(NEST_BOUNDARY_BINDINGS, (methodKey) => ({ - direction: "egress" as const, - kind: "guard-decision" as const, - bindingId, - methodKey, - decisionNode, - order: opts.order, - issueResponse: opts.issueResponse as NestIssueResponse | undefined, - protocolError: opts.protocolError as NestProtocolErrorResponse | undefined, - })); -} - -/** D488 WebSocket acknowledgement egress decorator over an existing reply-correlated node. - * @param ackNode - ack node value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphWsAck } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphWsAck( - ackNode: Node>, - opts: { readonly bindingId: string; readonly order?: number }, -): GraphMethodDecorator { - return graphReplyBinding("ws-ack", ackNode, opts, "GraphWsAck"); -} - -/** D488 WebSocket reply egress decorator over an existing reply-correlated node. - * @param replyNode - reply node value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphWsReply } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphWsReply( - replyNode: Node>, - opts: { readonly bindingId: string; readonly order?: number }, -): GraphMethodDecorator { - return graphReplyBinding("ws-reply", replyNode, opts, "GraphWsReply"); -} - -/** D488 microservice/message reply egress decorator over an existing reply-correlated node. - * @param replyNode - reply node value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphMessageReply } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphMessageReply( - replyNode: Node>, - opts: { readonly bindingId: string; readonly order?: number }, -): GraphMethodDecorator { - return graphReplyBinding("message-reply", replyNode, opts, "GraphMessageReply"); -} - -/** - * Creates a nest graph boundary runner. - * - * @param opts - Options that configure the helper. - * @returns The create nest graph boundary runner result. - * @category adapters - * @example - * ```ts - * import { createNestGraphBoundaryRunner } from "@graphrefly/nestjs"; - * ``` - */ -export function createNestGraphBoundaryRunner( - opts: NestGraphBoundaryRunnerOptions = {}, -): NestGraphBoundaryRunner { - const httpBoundaries = new Map< - Node>, - Map> - >(); - const httpBoundaryFor = ( - node: Node>, - bindingId: string, - ): NestHttpBoundary => { - let byBinding = httpBoundaries.get(node); - if (byBinding === undefined) { - byBinding = new Map(); - httpBoundaries.set(node, byBinding); - } - let boundary = byBinding.get(bindingId); - if (boundary === undefined) { - boundary = toNestHttp(node, { - bindingId, - diagnosticBoundary: opts.diagnosticBoundary, - diagnosticPhase: opts.diagnosticPhase ?? "http", - }); - byBinding.set(bindingId, boundary); - } - return boundary; - }; - - return { - run(target, methodKey, host, opts = {}) { - const ctor = typeof target === "function" ? target : target.constructor; - const bindings = getNestBoundaryBindings(ctor as DecoratorHostConstructor, methodKey); - if (bindings.length === 0) return undefined; - const requestId = requestIdFromRunOptions(host, opts); - const replies = bindings.filter(isHttpReplyBinding); - const ingress = bindings.filter( - (binding): binding is NestIngressBindingMeta => - binding.direction === "ingress" && - (binding.kind === "request" || binding.kind === "interceptor"), - ); - if (replies.length > 0 && ingress.length === 0) { - throw new Error("Nest GraphHttpReply requires at least one ingress boundary"); - } - const ingressEmits = ingress.map((binding) => ({ - binding, - requestId: requestIdFromBinding(host, binding) ?? requestId, - })); - const replyRequestIds = uniqueDefinedStrings(ingressEmits.map((entry) => entry.requestId)); - if (replies.length > 0 && replyRequestIds.length === 0) { - throw new Error("Nest GraphHttpReply requires a stable requestId"); - } - const cleanups: Array<() => boolean> = []; - let resolveReply: ((value: unknown) => void) | undefined; - let rejectReply: ((reason?: unknown) => void) | undefined; - const replyPromise = - replies.length === 0 - ? undefined - : new Promise((resolve, reject) => { - resolveReply = resolve; - rejectReply = reject; - }); - try { - for (const reply of replies) { - const http = httpBoundaryFor(reply.replyNode, reply.bindingId); - for (const replyRequestId of replyRequestIds) { - cleanups.push( - http.attach({ - requestId: replyRequestId, - bindingId: reply.bindingId, - handle: { - resolve: resolveReply ?? (() => undefined), - reject: rejectReply ?? (() => undefined), - }, - }), - ); - } - } - for (const entry of ingressEmits) { - entry.binding.boundary.emit(host, { - bindingId: entry.binding.bindingId, - requestId: entry.requestId, - ...bindingPayloadEmitOption(host, entry.binding), - requireRequestId: requiresRequestId(entry.binding.kind), - }); - } - } catch (error) { - for (const cleanup of cleanups) cleanup(); - throw error; - } - return replyPromise?.finally(() => { - for (const cleanup of cleanups) cleanup(); - }); - }, - dispose() { - for (const byBinding of httpBoundaries.values()) { - for (const boundary of byBinding.values()) boundary.dispose(); - } - httpBoundaries.clear(); - }, - }; -} - -/** - * Creates a nest graph boundary interceptor. - * - * @param opts - Options that configure the helper. - * @returns The create nest graph boundary interceptor result. - * @category adapters - * @example - * ```ts - * import { createNestGraphBoundaryInterceptor } from "@graphrefly/nestjs"; - * ``` - */ -export function createNestGraphBoundaryInterceptor( - opts: NestGraphBoundaryInterceptorOptions = {}, -): NestGraphBoundaryInterceptor { - const runner = opts.runner ?? createNestGraphBoundaryRunner(opts); - let nextRequestSeq = 1; - return { - intercept(context, next) { - const ctor = context.getClass(); - const handler = context.getHandler(); - const methodKey = methodKeyForHandler(ctor, handler); - if (methodKey === undefined) return next?.handle(); - const bindings = getNestBoundaryBindings(ctor, methodKey); - const needsSyntheticRequestId = bindings.some( - (binding) => - binding.direction === "egress" || - (binding.direction === "ingress" && requiresRequestId(binding.kind)), - ); - const host = - opts.host?.(context) ?? - (defaultNestHttpHost(context, () => nextRequestSeq++, needsSyntheticRequestId) as THost) ?? - ({} as THost); - const result = runner.run(ctor, methodKey, host, opts); - return result ?? next?.handle(); - }, - dispose() { - runner.dispose(); - }, - }; -} - -/** - * Creates a get nest boundary bindings. - * - * @param target - Class or instance that owns the decorated method. - * @param methodKey - Method key on the decorated target. - * @returns The get nest boundary bindings result. - * @category adapters - * @example - * ```ts - * import { getNestBoundaryBindings } from "@graphrefly/nestjs"; - * ``` - */ -export function getNestBoundaryBindings( - target: DecoratorHostConstructor | object, - methodKey?: string | symbol, -): readonly NestBoundaryBindingMeta[] { - const ctor = - typeof target === "function" - ? (target as DecoratorHostConstructor) - : ((target as { constructor: DecoratorHostConstructor }) - .constructor as DecoratorHostConstructor); - const bindings = boundaryBindingsFor(ctor); - return sortBoundaryBindings( - methodKey === undefined - ? bindings - : bindings.filter((binding) => binding.methodKey === methodKey), - ); -} - -/** - * Resolves nest method key. - * - * @param ctor - Constructor that may own the decorated handler. - * @param handler - Handler function to match against metadata. - * @returns The stable key or reference string. - * @category adapters - * @example - * ```ts - * import { resolveNestMethodKey } from "@graphrefly/nestjs"; - * ``` - */ -export function resolveNestMethodKey( - ctor: DecoratorHostConstructor, - handler: DecoratorBoundMethod, -): string | symbol | undefined { - return methodKeyForHandler(ctor, handler); -} - -/** - * Creates a binding request ID. - * - * @param host - Host object from the framework boundary. - * @param binding - Nest boundary binding metadata. - * @param fallback - fallback value used by the helper. - * @returns The binding request ID result. - * @category adapters - * @example - * ```ts - * import { bindingRequestId } from "@graphrefly/nestjs"; - * ``` - */ -export function bindingRequestId( - host: THost, - binding: NestBoundaryBindingMeta, - fallback?: string | ((host: THost) => string | undefined), -): string | undefined { - if (binding.direction === "ingress") { - const requestId = requestIdFromBinding(host, binding); - if (requestId !== undefined) return requestId; - } - if (typeof fallback === "string") { - assertNonEmptyString(fallback, "requestId"); - return fallback; - } - if (typeof fallback === "function") { - const requestId = fallback(host); - if (requestId !== undefined) assertNonEmptyString(requestId, "requestId"); - return requestId; - } - return requestIdOf(host, {}, {}); -} - -/** - * Creates a binding emit options. - * - * @param host - Host object from the framework boundary. - * @param binding - Nest boundary binding metadata. - * @param requestId - Host request correlation id. - * @returns The binding emit options result. - * @category adapters - * @example - * ```ts - * import { bindingEmitOptions } from "@graphrefly/nestjs"; - * ``` - */ -export function bindingEmitOptions( - host: THost, - binding: NestIngressBindingMeta, - requestId?: string, -): NestIngressEmitOptions { - return { - bindingId: binding.bindingId, - requestId, - ...bindingPayloadEmitOption(host, binding), - requireRequestId: requiresRequestId(binding.kind), - }; -} - -/** - * Checks whether a value is a data issue. - * - * @param value - Unknown value to check or decode. - * @returns `true` when the value matches the expected shape. - * @category adapters - * @example - * ```ts - * import { isDataIssue } from "@graphrefly/nestjs"; - * ``` - */ -export function isDataIssue(value: unknown): value is DataIssue { - return ( - value !== null && - typeof value === "object" && - (value as { kind?: unknown }).kind === "issue" && - typeof (value as { code?: unknown }).code === "string" && - typeof (value as { message?: unknown }).message === "string" - ); -} - -/** - * Checks whether a value is a HTTP data issue. - * - * @param value - Unknown value to check or decode. - * @returns `true` when the value matches the expected shape. - * @category adapters - * @example - * ```ts - * import { isHttpDataIssue } from "@graphrefly/nestjs"; - * ``` - */ -export function isHttpDataIssue(value: unknown): value is HttpDataIssue { - return isDataIssue(value) && Number.isInteger((value as { status?: unknown }).status); -} - -/** - * Checks whether a value is an issue response. - * - * @param issue - Data issue to lower into a response. - * @param _host - Host object from the framework boundary. - * @returns `true` when the value matches the expected shape. - * @category adapters - * @example - * ```ts - * import { issueResponse } from "@graphrefly/nestjs"; - * ``` - */ -export function issueResponse( - issue: DataIssue, - _host?: THost, -): NestHttpResponsePayload { - if (isHttpDataIssue(issue)) { - return { - status: issue.status, - body: issue.body ?? { code: issue.code, message: issue.message }, - headers: issue.headers, - }; - } - return { - status: 400, - body: { code: issue.code, message: issue.message }, - }; -} - -/** - * Creates a protocol error. - * - * @param _errorPayload - Protocol error payload to lower into a response. - * @param _host - Host object from the framework boundary. - * @returns The protocol error result. - * @category adapters - * @example - * ```ts - * import { protocolError } from "@graphrefly/nestjs"; - * ``` - */ -export function protocolError( - _errorPayload: unknown, - _host?: THost, -): NestHttpResponsePayload { - return { - status: 500, - body: { code: "graphrefly.protocol_error", message: "GraphReFly reply pipeline failed" }, - }; -} - -/** - * Lowers HTTP reply payload. - * - * @param payload - Payload to lower or wrap. - * @param host - Host object from the framework boundary. - * @param opts - Options that configure the helper. - * @returns The lower HTTP reply payload result. - * @category adapters - * @example - * ```ts - * import { lowerHttpReplyPayload } from "@graphrefly/nestjs"; - * ``` - */ -export function lowerHttpReplyPayload( - payload: unknown, - host: THost, - opts: { readonly issueResponse?: NestIssueResponse } = {}, -): NestHttpResponsePayload { - if (isHttpResponsePayload(payload)) return payload; - if (isDataIssue(payload)) return (opts.issueResponse ?? issueResponse)(payload, host); - return { status: 200, body: payload }; -} - -/** - * Lowers protocol error. - * - * @param errorPayload - Protocol error payload to lower into a response. - * @param host - Host object from the framework boundary. - * @param opts - Options that configure the helper. - * @returns The lower protocol error result. - * @category adapters - * @example - * ```ts - * import { lowerProtocolError } from "@graphrefly/nestjs"; - * ``` - */ -export function lowerProtocolError( - errorPayload: unknown, - host: THost, - opts: { readonly protocolError?: NestProtocolErrorResponse } = {}, -): NestHttpResponsePayload { - return (opts.protocolError ?? protocolError)(errorPayload, host); -} - -/** - * Sanitizes nest diagnostic. - * - * @param diagnostic - Diagnostic input to sanitize. - * @param defaultPhase - Phase to use when the diagnostic does not provide one. - * @returns The sanitize nest diagnostic result. - * @category adapters - * @example - * ```ts - * import { sanitizeNestDiagnostic } from "@graphrefly/nestjs"; - * ``` - */ -export function sanitizeNestDiagnostic( - diagnostic: NestDiagnosticInput, - defaultPhase: NestDiagnosticPhase = "adapter", -): NestDiagnosticPayload { - const payload: NestDiagnosticPayload = { - kind: diagnostic.kind, - phase: diagnostic.phase ?? defaultPhase, - message: diagnostic.message, - ...optionalStringField("requestId", diagnostic.requestId), - ...optionalStringField("bindingId", diagnostic.bindingId), - ...optionalStringField("expectedBindingId", diagnostic.expectedBindingId), - ...optionalDiagnosticError(diagnostic.error), - }; - assertGraphVisibleData(payload, "NestDiagnosticPayload", NEST_BOUNDARY_PAYLOAD_MAX_BYTES); - return payload; -} - -function nestIngress( - graph: Graph, - kind: NestBoundaryKind, - opts: NestIngressOptions, -): NestIngressBoundary { - const bindingId = stableBindingId(kind, opts); - const version = parseEnvelopeVersion(opts.version ?? NEST_BOUNDARY_ENVELOPE_VERSION, "version"); - const maxPayloadBytes = payloadByteLimit(opts.maxPayloadBytes); - const node = graph.node>([], null, { - name: opts.name, - meta: { adapter: "nestjs", boundary: "ingress", kind, bindingId, version }, - }); - - return { - kind, - bindingId, - version, - node, - envelope(host, emitOpts = {}) { - const requestId = requestIdOf(host, opts, { - ...emitOpts, - requireRequestId: - emitOpts.requireRequestId ?? opts.requireRequestId ?? requiresRequestId(kind), - }); - const envelopeBindingId = emitOpts.bindingId ?? bindingId; - assertNonEmptyString(envelopeBindingId, "NestBoundaryEnvelope.bindingId"); - const envelopeVersion = parseEnvelopeVersion( - emitOpts.version ?? version, - "NestBoundaryEnvelope.version", - ); - const payload = - "payload" in emitOpts - ? (emitOpts.payload as TPayload) - : opts.payload !== undefined - ? opts.payload(host) - : (host as unknown as TPayload); - assertGraphVisibleData(payload, "NestBoundaryEnvelope.payload", maxPayloadBytes); - const envelope: NestBoundaryEnvelope = { - bindingId: envelopeBindingId, - version: envelopeVersion, - payload, - }; - return requestId === undefined ? envelope : { ...envelope, requestId }; - }, - emit(host, emitOpts = {}) { - const envelope = this.envelope(host, emitOpts); - node.down([["DATA", envelope]]); - return envelope; - }, - }; -} - -function stableBindingId( - kind: NestBoundaryKind | NestEgressKind, - opts: { readonly bindingId?: string; readonly name?: string }, -): string { - const bindingId = opts.bindingId ?? opts.name ?? `nestjs.${kind}`; - assertNonEmptyString(bindingId, "bindingId"); - return bindingId; -} - -function requiresRequestId(kind: NestBoundaryKind): boolean { - return kind === "request" || kind === "guard" || kind === "interceptor" || kind === "error"; -} - -function requestIdFromRunOptions( - host: THost, - opts: NestGraphRunOptions, -): string | undefined { - if (typeof opts.requestId === "string") { - assertNonEmptyString(opts.requestId, "requestId"); - return opts.requestId; - } - if (typeof opts.requestId === "function") { - const requestId = opts.requestId(host); - if (requestId !== undefined) assertNonEmptyString(requestId, "requestId"); - return requestId; - } - return requestIdOf(host, {}, {}); -} - -function requestIdFromBinding( - host: THost, - binding: NestIngressBindingMeta, -): string | undefined { - if (typeof binding.requestId === "string") { - assertNonEmptyString(binding.requestId, "requestId"); - return binding.requestId; - } - if (typeof binding.requestId === "function") { - const requestId = binding.requestId(host); - if (requestId !== undefined) assertNonEmptyString(requestId, "requestId"); - return requestId; - } - return undefined; -} - -function bindingPayloadEmitOption( - host: THost, - binding: NestIngressBindingMeta, -): { readonly payload: unknown } | Record { - return binding.payload === undefined ? {} : { payload: binding.payload(host) }; -} - -function isHttpResponsePayload(value: unknown): value is NestHttpResponsePayload { - if (value === null || typeof value !== "object") return false; - const status = (value as { status?: unknown }).status; - if (!Number.isInteger(status)) return false; - const headers = (value as { headers?: unknown }).headers; - return headers === undefined || isStringRecord(headers); -} - -function isStringRecord(value: unknown): value is Record { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; - for (const entry of Object.values(value)) if (typeof entry !== "string") return false; - return true; -} - -function isHttpReplyBinding(binding: NestBoundaryBindingMeta): binding is NestHttpReplyBindingMeta { - return binding.direction === "egress" && binding.kind === "http"; -} - -function uniqueDefinedStrings(values: readonly (string | undefined)[]): string[] { - const seen = new Set(); - for (const value of values) { - if (value !== undefined) seen.add(value); - } - return [...seen]; -} - -function defaultNestHttpHost( - context: NestExecutionContextLike, - nextSeq: () => number, - needsSyntheticRequestId: boolean, -): unknown | undefined { - const request = context.switchToHttp?.().getRequest>(); - if (request === undefined || request === null || typeof request !== "object") return request; - if (requestIdFromRecord(request) !== undefined) return request; - const headerId = requestIdFromHeaders(request.headers); - if (headerId !== undefined || needsSyntheticRequestId) { - return { ...request, requestId: headerId ?? `nestjs-request:${nextSeq()}` }; - } - return request; -} - -function requestIdFromRecord(record: Record): string | undefined { - for (const key of ["requestId", "id"]) { - const value = record[key]; - if (typeof value === "string" && value.length > 0) return value; - } - return undefined; -} - -function requestIdFromHeaders(headers: unknown): string | undefined { - if (headers === null || typeof headers !== "object") return undefined; - for (const key of ["x-request-id", "x-correlation-id"]) { - const value = (headers as Record)[key]; - const first = Array.isArray(value) ? value[0] : value; - if (typeof first === "string" && first.trim().length > 0) return first.trim(); - } - return undefined; -} - -function graphIngressBinding( - kind: NestBoundaryKind, - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions & NestFilterDecoratorOptions, -): GraphMethodDecorator { - if (boundary.kind !== kind) { - throw new Error(`Graph${kind} expected a ${kind} boundary, got ${boundary.kind}`); - } - const bindingId = opts.bindingId ?? boundary.bindingId; - assertNonEmptyString(bindingId, "bindingId"); - return registerMeta(NEST_BOUNDARY_BINDINGS, (methodKey) => ({ - direction: "ingress" as const, - kind, - bindingId, - methodKey, - boundary: boundary as NestIngressBoundary, - payload: opts.payload as ((host: unknown) => unknown) | undefined, - requestId: opts.requestId as string | ((host: unknown) => string | undefined) | undefined, - order: opts.order, - mode: opts.mode, - issueResponse: opts.issueResponse as NestIssueResponse | undefined, - protocolError: opts.protocolError as NestProtocolErrorResponse | undefined, - })); -} - -function graphReplyBinding( - kind: "ws-ack" | "ws-reply" | "message-reply", - node: Node>, - opts: { readonly bindingId: string; readonly order?: number }, - decoratorName: string, -): GraphMethodDecorator { - const bindingId = opts?.bindingId; - if (typeof bindingId !== "string" || bindingId.length === 0) { - throw new Error(`${decoratorName} requires a non-empty bindingId`); - } - const replyNode = node as Node>; - return registerMeta(NEST_BOUNDARY_BINDINGS, (methodKey) => { - if (kind === "ws-ack") { - return { - direction: "egress" as const, - kind, - bindingId, - methodKey, - ackNode: replyNode, - order: opts.order, - }; - } - return { - direction: "egress" as const, - kind, - bindingId, - methodKey, - replyNode, - order: opts.order, - }; - }); -} - -function methodKeyForHandler( - ctor: DecoratorHostConstructor, - handler: DecoratorBoundMethod, -): string | symbol | undefined { - for (const binding of boundaryBindingsFor(ctor)) { - const candidate = methodOnPrototypeChain(ctor, binding.methodKey); - if (candidate === handler || String(binding.methodKey) === handler.name) - return binding.methodKey; - } - return undefined; -} - -function boundaryBindingsFor(ctor: DecoratorHostConstructor): NestBoundaryBindingMeta[] { - const bindings: NestBoundaryBindingMeta[] = []; - const seen = new Set(); - let current: unknown = ctor; - while (typeof current === "function" && current !== Function.prototype) { - for (const binding of NEST_BOUNDARY_BINDINGS.get(current as DecoratorHostConstructor) ?? []) { - if (seen.has(binding)) continue; - seen.add(binding); - bindings.push(binding); - } - current = Object.getPrototypeOf(current); - } - return bindings; -} - -function sortBoundaryBindings( - bindings: readonly NestBoundaryBindingMeta[], -): readonly NestBoundaryBindingMeta[] { - return bindings - .map((binding, index) => ({ binding, index })) - .sort((a, b) => (a.binding.order ?? 0) - (b.binding.order ?? 0) || a.index - b.index) - .map(({ binding }) => binding); -} - -function methodOnPrototypeChain( - ctor: DecoratorHostConstructor, - methodKey: string | symbol, -): unknown { - let proto: unknown = ctor.prototype; - while (proto !== null && typeof proto === "object") { - if (Object.hasOwn(proto, methodKey)) { - return (proto as Record)[methodKey]; - } - proto = Object.getPrototypeOf(proto); - } - return undefined; -} - -function requestIdOf( - host: THost, - opts: NestIngressOptions, - emitOpts: NestIngressEmitOptions, -): string | undefined { - const fromEmit = emitOpts.requestId; - if (fromEmit !== undefined) { - assertNonEmptyString(fromEmit, "requestId"); - return fromEmit; - } - if (typeof opts.requestId === "string") { - assertNonEmptyString(opts.requestId, "requestId"); - return opts.requestId; - } - if (typeof opts.requestId === "function") { - const requestId = opts.requestId(host); - if (requestId !== undefined) assertNonEmptyString(requestId, "requestId"); - return requestId; - } - const record = host as { requestId?: unknown; id?: unknown }; - if (typeof record?.requestId === "string" && record.requestId.length > 0) return record.requestId; - if (typeof record?.id === "string" && record.id.length > 0) return record.id; - if (emitOpts.requireRequestId ?? opts.requireRequestId ?? false) { - throw new Error("Nest boundary ingress requires a stable requestId"); - } - return undefined; -} - -function validateEnvelope(value: unknown, maxPayloadBytes: number): string | undefined { - try { - assertGraphVisibleData(value, "NestBoundaryEnvelope", maxPayloadBytes); - } catch (error) { - return error instanceof Error ? error.message : "egress envelope must be data-only material"; - } - if (value === null || typeof value !== "object") return "egress DATA is not an envelope object"; - const envelope = value as Partial; - if (typeof envelope.requestId !== "string" || envelope.requestId.length === 0) { - return "egress envelope requestId must be a non-empty string"; - } - if (typeof envelope.bindingId !== "string" || envelope.bindingId.length === 0) { - return "egress envelope bindingId must be a non-empty string"; - } - try { - parseEnvelopeVersion(envelope.version, "egress envelope version"); - } catch { - return `egress envelope version must be ${NEST_BOUNDARY_ENVELOPE_VERSION}`; - } - return undefined; -} - -function malformedCorrelation( - value: unknown, - scopedBindingId: string | undefined, -): { readonly requestId: string; readonly bindingId: string } | undefined { - if (value === null || typeof value !== "object") return undefined; - const requestId = (value as { readonly requestId?: unknown }).requestId; - const bindingId = (value as { readonly bindingId?: unknown }).bindingId; - if (typeof requestId !== "string" || requestId.length === 0) return undefined; - if (typeof bindingId !== "string" || bindingId.length === 0) return undefined; - if (scopedBindingId !== undefined && bindingId !== scopedBindingId) return undefined; - return { requestId, bindingId }; -} - -function parseEnvelopeVersion(value: unknown, label: string): number { - if (value !== NEST_BOUNDARY_ENVELOPE_VERSION) { - throw new Error(`${label} must be ${NEST_BOUNDARY_ENVELOPE_VERSION}`); - } - return value; -} - -function assertNonEmptyString(value: string, label: string): void { - if (value.length === 0) throw new Error(`${label} must be a non-empty string`); -} - -function optionalStringField( - key: K, - value: string | undefined, -): Record | Record { - return value === undefined ? {} : ({ [key]: value } as Record); -} - -function optionalDiagnosticError( - error: unknown, -): { readonly error: NestDiagnosticErrorPayload } | Record { - const summarized = summarizeDiagnosticError(error); - return summarized === undefined ? {} : { error: summarized }; -} - -function summarizeDiagnosticError(error: unknown): NestDiagnosticErrorPayload | undefined { - if (error === undefined) return undefined; - if (error instanceof Error) { - return { - ...optionalDiagnosticName(safeDiagnosticString(() => error.name)), - message: safeDiagnosticString(() => error.message) ?? "diagnostic error", - }; - } - if (typeof error === "string") return { message: error }; - if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") { - return { message: String(error) }; - } - if (typeof error === "symbol") return { message: error.description ?? "symbol" }; - if (typeof error === "function") return { message: "opaque diagnostic function" }; - if (error !== null && typeof error === "object") { - const record = error as { readonly name?: unknown; readonly message?: unknown }; - const message = safeDiagnosticString(() => record.message); - if (message !== undefined) { - return { - ...optionalDiagnosticName(safeDiagnosticString(() => record.name)), - message, - }; - } - return { message: "opaque diagnostic error" }; - } - return { message: String(error) }; -} - -function safeDiagnosticString(read: () => unknown): string | undefined { - try { - const value = read(); - return typeof value === "string" ? value : undefined; - } catch { - return undefined; - } -} - -function optionalDiagnosticName( - name: string | undefined, -): { readonly name: string } | Record { - return name === undefined || name.length === 0 ? {} : { name }; -} - -function payloadByteLimit(value: number | undefined): number { - if (value === undefined) return NEST_BOUNDARY_PAYLOAD_MAX_BYTES; - if (!Number.isSafeInteger(value) || value < 1) { - throw new Error("Nest boundary maxPayloadBytes must be a positive safe integer"); - } - return value; -} - -function diagnosticsRetainedLimit(value: number | undefined): number { - if (value === undefined) return NEST_HTTP_DIAGNOSTICS_MAX_RETAINED; - if (!Number.isSafeInteger(value) || value < 0) { - throw new Error("toNestHttp maxDiagnostics must be a non-negative safe integer"); - } - return value; -} - -function pushDiagnostic( - diagnostics: NestBoundaryDiagnostic[], - diagnostic: NestBoundaryDiagnostic, - maxDiagnostics: number, -): void { - if (maxDiagnostics === 0) return; - diagnostics.push(diagnostic); - if (diagnostics.length > maxDiagnostics) - diagnostics.splice(0, diagnostics.length - maxDiagnostics); -} - -function assertGraphVisibleData( - value: unknown, - path: string, - maxPayloadBytes: number, - seen = new WeakSet(), -): void { - if (value === undefined) { - throw new TypeError(`${path} cannot be undefined; undefined is SENTINEL/no DATA`); - } - if (value === null) return; - const type = typeof value; - if (type === "string" || type === "boolean") return; - if (type === "number") { - if (!Number.isFinite(value)) throw new TypeError(`${path} number must be finite`); - return; - } - if (type === "function" || type === "symbol" || type === "bigint") { - throw new TypeError(`${path} must be data-only; found ${type}`); - } - if (type !== "object") return; - if (seen.has(value as object)) throw new TypeError(`${path} must be acyclic data`); - seen.add(value as object); - if (Array.isArray(value)) { - try { - for (let i = 0; i < value.length; i += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, i); - if (descriptor === undefined) { - throw new TypeError(`${path}[${i}] cannot be a sparse array hole`); - } - if (!descriptor.enumerable || !("value" in descriptor)) { - throw new TypeError(`${path}[${i}] must be enumerable plain data`); - } - assertGraphVisibleData(descriptor.value, `${path}[${i}]`, maxPayloadBytes, seen); - } - for (const key of Reflect.ownKeys(value)) { - if (key === "length") continue; - if (typeof key === "symbol" || !/^(0|[1-9]\d*)$/.test(key)) { - throw new TypeError(`${path} arrays must not carry hidden or extra properties`); - } - } - assertPayloadSize(value, path, maxPayloadBytes); - } finally { - seen.delete(value as object); - } - return; - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) { - throw new TypeError(`${path} must be a plain data object or array`); - } - try { - for (const key of Reflect.ownKeys(value)) { - if (typeof key === "symbol") throw new TypeError(`${path} must not carry symbol keys`); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined) continue; - if (!descriptor.enumerable || !("value" in descriptor)) { - throw new TypeError(`${path}.${key} must be enumerable plain data`); - } - assertGraphVisibleData(descriptor.value, `${path}.${key}`, maxPayloadBytes, seen); - } - assertPayloadSize(value, path, maxPayloadBytes); - } finally { - seen.delete(value as object); - } -} - -function assertPayloadSize(value: unknown, path: string, maxPayloadBytes: number): void { - const serialized = JSON.stringify(value); - if (serialized !== undefined && utf8ByteLength(serialized) > maxPayloadBytes) { - throw new TypeError(`${path} exceeds ${maxPayloadBytes} bytes`); - } -} - -function utf8ByteLength(value: string): number { - let bytes = 0; - for (let i = 0; i < value.length; i += 1) { - const code = value.charCodeAt(i); - if (code < 0x80) { - bytes += 1; - } else if (code < 0x800) { - bytes += 2; - } else if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length) { - const next = value.charCodeAt(i + 1); - if (next >= 0xdc00 && next <= 0xdfff) { - bytes += 4; - i += 1; - } else { - bytes += 3; - } - } else { - bytes += 3; - } - } - return bytes; -} - -function sameMeta(a: T, b: T): boolean { - const left = a as Record; - const right = b as Record; - const keys = Reflect.ownKeys(left); - if (keys.length !== Reflect.ownKeys(right).length) return false; - for (const key of keys) { - if (!Object.is(left[key], right[key])) return false; - } - return true; -} - -function pushUniqueMeta( - registry: WeakMap, - ctor: DecoratorHostConstructor, - item: T, -): void { - const existing = registry.get(ctor) ?? []; - if (existing.some((current) => sameMeta(current, item))) return; - registry.set(ctor, [...existing, item]); -} - -function registerMeta( - registry: WeakMap, - meta: (methodKey: string | symbol) => T, -): GraphMethodDecorator { - return ((targetOrValue: object, contextOrKey: ClassMethodDecoratorContext | string | symbol) => { - if (typeof contextOrKey === "object" && contextOrKey !== null) { - const methodKey = contextOrKey.name; - contextOrKey.addInitializer(function (this: unknown) { - const ctor = (this as { constructor: DecoratorHostConstructor }).constructor; - pushUniqueMeta(registry, ctor, meta(methodKey)); - }); - return; - } - - const ctor = (targetOrValue as { constructor: DecoratorHostConstructor }).constructor; - pushUniqueMeta(registry, ctor, meta(contextOrKey)); - }) as GraphMethodDecorator; -} - -/** Register a method as a DATA-event handler for a graph observe path. - * @param nodeName - node name value used by the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { OnGraphEvent } from "@graphrefly/nestjs"; - * ``` - */ -export function OnGraphEvent(nodeName: string): GraphMethodDecorator { - return registerMeta(EVENT_HANDLERS, (methodKey) => ({ nodeName, methodKey })); -} - -/** Register fixed-interval metadata for a user-land NestJS scheduler bridge. - * @param ms - Duration or timestamp in milliseconds. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphInterval } from "@graphrefly/nestjs"; - * ``` - */ -export function GraphInterval(ms: number): GraphMethodDecorator { - return registerMeta(INTERVAL_HANDLERS, (methodKey) => ({ ms, methodKey })); -} diff --git a/packages/nestjs/src/microservices.ts b/packages/nestjs/src/microservices.ts deleted file mode 100644 index 215a534..0000000 --- a/packages/nestjs/src/microservices.ts +++ /dev/null @@ -1,316 +0,0 @@ -/** - * NestJS microservice/message native phase bridge (D488). - * - * This focused subpath may import `@nestjs/microservices`; the dependency-light - * structural layer and HTTP native bridge stay free of that optional peer. - */ - -import type { CustomTransportStrategy } from "@nestjs/microservices"; -import { - bindingEmitOptions, - bindingRequestId, - type DecoratorHostConstructor, - fromNestMessage, - GraphMessage, - GraphMessageReply, - getNestBoundaryBindings, - type NestBoundaryBindingMeta, - type NestBoundaryDiagnostic, - type NestBoundaryEnvelope, - type NestDiagnosticIngressBoundary, - type NestGraphRunOptions, - type NestIngressBindingMeta, - type NestIngressBoundary, - type NestIngressEmitOptions, - type NestIngressOptions, - type NestMessageReplyBindingMeta, - type NestProviderBinding, - type NestReplyEnvelope, - type NestReplyResponseHandle, - sanitizeNestDiagnostic, - toNestHttp, -} from "./index.js"; - -/** D488 provider token for the focused Nest microservice/message native bridge. */ -export const GRAPHREFLY_NEST_MESSAGE_BRIDGE = Symbol.for("graphrefly:nest:message-bridge"); - -/** Options for the D488 Nest message bridge; transport contexts stay host-private. */ -export interface GraphMessageBridgeOptions extends NestGraphRunOptions { - readonly diagnosticBoundary?: NestDiagnosticIngressBoundary; - readonly timeoutMs?: number; - readonly maxDiagnostics?: number; -} - -/** D495 focused microservice/message provider bundle options. */ -export interface GraphMessageProviderBundleOptions { - readonly bridge?: GraphMessageBridgeOptions | false; -} - -/** Explicit Nest message-pattern phase bridge over `GraphMessage` and `GraphMessageReply` metadata. */ -export interface GraphMessageBridge - extends Pick { - handleMessage( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - host: THost, - opts?: NestGraphRunOptions, - ): Promise | undefined; - onModuleDestroy(): void; - diagnostics(): readonly NestBoundaryDiagnostic[]; - dispose(): void; -} - -type MessageBoundary = ReturnType>; - -/** Build a dependency-light Nest provider for the focused message bridge token. - * @param opts - Options that configure the helper. - * @returns A `NestProviderBinding>` value. - * @category adapters - * @example - * ```ts - * import { provideGraphMessageBridge } from "@graphrefly/nestjs/microservices"; - * ``` - */ -export function provideGraphMessageBridge( - opts: GraphMessageBridgeOptions = {}, -): NestProviderBinding> { - return { provide: GRAPHREFLY_NEST_MESSAGE_BRIDGE, useValue: createGraphMessageBridge(opts) }; -} - -/** Build the D495 focused message provider bundle without adding a router or event bus. - * @param opts - Options that configure the helper. - * @returns A `NestProviderBinding>[]` value. - * @category adapters - * @example - * ```ts - * import { provideGraphMessageProviders } from "@graphrefly/nestjs/microservices"; - * ``` - */ -export function provideGraphMessageProviders( - opts: GraphMessageProviderBundleOptions = {}, -): NestProviderBinding>[] { - return opts.bridge === false ? [] : [provideGraphMessageBridge(opts.bridge ?? {})]; -} - -/** Create a host-private message bridge instance without adding a router or event bus. - * @param opts - Options that configure the helper. - * @returns A `GraphMessageBridge` value. - * @category adapters - * @example - * ```ts - * import { createGraphMessageBridge } from "@graphrefly/nestjs/microservices"; - * ``` - */ -export function createGraphMessageBridge( - opts: GraphMessageBridgeOptions = {}, -): GraphMessageBridge { - return new GraphMessageBridgeImpl(opts); -} - -class GraphMessageBridgeImpl implements GraphMessageBridge { - private readonly boundaries = new WeakMap>(); - private readonly disposable = new Set(); - private readonly localDiagnostics: NestBoundaryDiagnostic[] = []; - private active = true; - - constructor(private readonly opts: GraphMessageBridgeOptions) {} - - close(): void { - this.dispose(); - } - - onModuleDestroy(): void { - this.dispose(); - } - - handleMessage( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - host: THost, - runOpts: NestGraphRunOptions = {}, - ): Promise | undefined { - if (!this.active) throw new Error("GraphMessage native bridge is disposed"); - const bindings = getNestBoundaryBindings(target, methodKey); - const ingress = bindings.filter(isMessageIngress); - if (ingress.length === 0) return undefined; - const replies = bindings.filter(isMessageReply); - const needsRequestId = replies.length > 0; - for (const binding of ingress) assertExplicitNativePayload(binding, "GraphMessage"); - const ingressEmits = ingress.map((binding) => ({ - binding, - requestId: bindingRequestId(host, binding, runOpts.requestId ?? this.opts.requestId), - })); - if (needsRequestId && ingressEmits.some((entry) => entry.requestId === undefined)) { - throw new Error("GraphMessage native bridge requires a stable requestId for reply egress"); - } - const requestIds = uniqueDefinedStrings(ingressEmits.map((entry) => entry.requestId)); - const cleanups: Array<() => boolean> = []; - let timeout: ReturnType | undefined; - let settled = false; - let resolvePromise: ((payload: unknown) => void) | undefined; - let rejectPromise: ((error: unknown) => void) | undefined; - let cleaned = false; - const cleanupAll = () => { - if (cleaned) return; - cleaned = true; - for (const cleanup of cleanups) cleanup(); - this.clearTimeout(timeout); - }; - const handle: NestReplyResponseHandle = { - resolve(payload) { - if (settled) return; - settled = true; - resolvePromise?.(payload); - }, - reject(error) { - if (settled) return; - settled = true; - rejectPromise?.(error); - }, - }; - const promise = - replies.length === 0 - ? undefined - : new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - if (promise !== undefined) { - try { - for (const requestId of requestIds) { - for (const reply of replies) { - cleanups.push( - this.boundaryFor(reply).attach({ - requestId, - bindingId: reply.bindingId, - handle, - }), - ); - } - } - } catch (error) { - cleanupAll(); - handle.reject(error); - return promise; - } - if (settled) { - cleanupAll(); - return promise; - } - if (this.opts.timeoutMs !== undefined) { - timeout = setTimeout(() => { - cleanupAll(); - const error = new Error( - `GraphMessage native bridge timed out waiting for ${requestIds[0]}`, - ); - this.diagnose({ - kind: "timeout", - requestId: requestIds[0], - message: error.message, - error, - }); - handle.reject(error); - }, this.opts.timeoutMs); - } - } - try { - for (const { binding, requestId } of ingressEmits) { - binding.boundary.emit(host, { - ...bindingEmitOptions(host, binding, requestId), - requireRequestId: needsRequestId, - }); - } - } catch (error) { - cleanupAll(); - throw error; - } - return promise?.finally(() => { - cleanupAll(); - }); - } - - diagnostics(): readonly NestBoundaryDiagnostic[] { - return [ - ...this.localDiagnostics, - ...[...this.disposable].flatMap((boundary) => boundary.diagnostics()), - ]; - } - - dispose(): void { - if (!this.active) return; - this.active = false; - for (const boundary of this.disposable) boundary.dispose(); - } - - private boundaryFor(binding: NestMessageReplyBindingMeta): MessageBoundary { - let byBinding = this.boundaries.get(binding.replyNode); - if (byBinding === undefined) { - byBinding = new Map(); - this.boundaries.set(binding.replyNode, byBinding); - } - const existing = byBinding.get(binding.bindingId); - if (existing !== undefined) return existing; - const boundary = toNestHttp(binding.replyNode, { - bindingId: binding.bindingId, - diagnosticBoundary: this.opts.diagnosticBoundary, - diagnosticPhase: "message", - name: "nestjs.message-reply", - maxDiagnostics: this.opts.maxDiagnostics, - }); - byBinding.set(binding.bindingId, boundary); - this.disposable.add(boundary); - return boundary; - } - - private diagnose(diagnostic: NestBoundaryDiagnostic): void { - this.localDiagnostics.push(diagnostic); - try { - const phase = diagnostic.phase ?? "message"; - const payload = sanitizeNestDiagnostic({ ...diagnostic, phase }, phase); - this.opts.diagnosticBoundary?.emit(payload, { payload }); - } catch { - // Graph-visible diagnostics are optional and must not interrupt host cleanup. - } - if ( - this.opts.maxDiagnostics !== undefined && - this.localDiagnostics.length > this.opts.maxDiagnostics - ) { - this.localDiagnostics.splice(0, this.localDiagnostics.length - this.opts.maxDiagnostics); - } - } - - private clearTimeout(timeout: ReturnType | undefined): void { - if (timeout !== undefined) clearTimeout(timeout); - } -} - -function isMessageIngress(binding: NestBoundaryBindingMeta): binding is NestIngressBindingMeta { - return binding.direction === "ingress" && binding.kind === "message"; -} - -function isMessageReply(binding: NestBoundaryBindingMeta): binding is NestMessageReplyBindingMeta { - return binding.direction === "egress" && binding.kind === "message-reply"; -} - -function assertExplicitNativePayload(binding: NestIngressBindingMeta, label: string): void { - if (binding.payload === undefined) { - throw new Error(`${label} native bridge requires an explicit payload selector`); - } -} - -function uniqueDefinedStrings(values: readonly (string | undefined)[]): string[] { - const seen = new Set(); - for (const value of values) if (value !== undefined) seen.add(value); - return [...seen]; -} - -export { - fromNestMessage, - GraphMessage, - GraphMessageReply, - type NestBoundaryEnvelope, - type NestIngressBoundary, - type NestIngressEmitOptions, - type NestIngressOptions, - type NestReplyEnvelope, -}; diff --git a/packages/nestjs/src/native.ts b/packages/nestjs/src/native.ts deleted file mode 100644 index 71597d8..0000000 --- a/packages/nestjs/src/native.ts +++ /dev/null @@ -1,1209 +0,0 @@ -/** - * Nest-native provider bridge for GraphReFly boundary metadata (D484). - * - * This focused subpath is allowed to import Nest/RxJS. The dependency-light - * `@graphrefly/nestjs` structural layer stays Nest-free. - */ - -import { type CronSchedule, type FromCronOptions, matchesCron, parseCron } from "@graphrefly/ts"; -import type { - ArgumentsHost, - CallHandler, - CanActivate, - ExceptionFilter, - ExecutionContext, - OnModuleDestroy, - OnModuleInit, - Provider, -} from "@nestjs/common"; -import { Catch, HttpException, type NestInterceptor } from "@nestjs/common"; -import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core"; -import { from, type Observable } from "rxjs"; -import { - bindingEmitOptions, - bindingRequestId, - createNestGraphBoundaryRunner, - type DecoratorBoundMethod, - type DecoratorHostConstructor, - type GraphGuardDecision, - getNestBoundaryBindings, - isDataIssue, - lowerHttpReplyPayload, - lowerProtocolError, - type NestBoundaryBindingMeta, - type NestDiagnosticIngressBoundary, - type NestGraphRunOptions, - type NestHttpReplyBindingMeta, - type NestHttpResponsePayload, - type NestIngressBindingMeta, - type NestIssueResponse, - type NestProtocolErrorResponse, - protocolError, - resolveNestMethodKey, - toNestHttp, -} from "./index.js"; - -export const GRAPHREFLY_NEST_CRON_SCHEDULER = Symbol.for("graphrefly:nest:cron-scheduler"); -export const GRAPHREFLY_NEST_EXCEPTION_FILTER = Symbol.for("graphrefly:nest:exception-filter"); -export const GRAPHREFLY_NEST_LIFECYCLE_HOOKS = Symbol.for("graphrefly:nest:lifecycle-hooks"); - -export interface GraphNativeHostOptions extends NestGraphRunOptions { - readonly host?: (context: ExecutionContext) => THost; - readonly diagnosticBoundary?: NestDiagnosticIngressBoundary; -} - -export interface GraphNativeHttpOptions extends GraphNativeHostOptions { - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export type GraphGuardDecisionWait = - | { readonly mode?: "same-wave" } - | { - readonly mode: "await"; - readonly timeoutMs: number; - readonly maxPending: number; - readonly scope: NestGraphGuardAwaitScope; - readonly hostAbortSignal?: ( - context: ExecutionContext, - host: THost, - ) => AbortSignal | undefined; - }; - -export interface GraphNativeGuardOptions extends GraphNativeHttpOptions { - readonly decisionWait?: GraphGuardDecisionWait; -} - -/** Host-private cancellation lookup for an explicitly asynchronous guard projector. */ -export interface NestGraphGuardAwaitScope { - lookupAbortSignal(invocationId: string): AbortSignal | undefined; - dispose(): void; -} - -interface NestGraphGuardAwaitScopeState { - active: boolean; - readonly entries: Map; -} - -const graphGuardAwaitScopeStates = new WeakMap< - NestGraphGuardAwaitScope, - NestGraphGuardAwaitScopeState ->(); - -/** Creates the host-private bounded cancellation scope used by await-mode GraphGuard adapters. */ -export function createNestGraphGuardAwaitScope(): NestGraphGuardAwaitScope { - const state: NestGraphGuardAwaitScopeState = { active: true, entries: new Map() }; - const scope: NestGraphGuardAwaitScope = { - lookupAbortSignal(invocationId) { - return state.entries.get(invocationId)?.signal; - }, - dispose() { - if (!state.active) return; - state.active = false; - for (const controller of state.entries.values()) controller.abort(); - state.entries.clear(); - }, - }; - graphGuardAwaitScopeStates.set(scope, state); - return scope; -} - -export interface GraphExceptionFilterTarget { - readonly target: DecoratorHostConstructor | object; - readonly methodKey: string | symbol; -} - -export interface GraphExceptionFilterProviderOptions { - readonly host?: (host: ArgumentsHost, exception: unknown) => THost; - readonly target: ( - host: ArgumentsHost, - exception: unknown, - ) => GraphExceptionFilterTarget | undefined; - readonly diagnosticBoundary?: NestDiagnosticIngressBoundary; - readonly requestId?: string | ((host: THost) => string | undefined); - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface GraphCronProviderTarget { - readonly target: DecoratorHostConstructor | object; - readonly methodKey?: string | symbol; - readonly expr: string; - readonly tickMs?: number; - readonly timezone?: string; - readonly dst?: FromCronOptions["dst"]; - readonly host?: (date: Date) => THost; -} - -export interface GraphCronSchedulerProviderOptions { - readonly targets: readonly GraphCronProviderTarget[]; -} - -export interface GraphCronController { - check(now: Date): void; -} - -export interface GraphCronControllerOptions { - readonly targets: readonly GraphCronProviderTarget[]; -} - -export interface GraphLifecycleProviderTarget { - readonly target: DecoratorHostConstructor | object; - readonly methodKey?: string | symbol; - readonly event?: "module-init" | "module-destroy"; - readonly host?: (event: "module-init" | "module-destroy") => THost; -} - -export interface GraphLifecycleHooksProviderOptions { - readonly targets: readonly GraphLifecycleProviderTarget[]; -} - -export interface GraphNativeHttpProviderBundleOptions { - readonly boundaryInterceptor?: GraphNativeHttpOptions | false; - readonly guard?: GraphNativeGuardOptions | false; - readonly guardDeniedFilter?: boolean; - readonly exceptionFilter?: GraphExceptionFilterProviderOptions; -} - -export interface GraphNativeProviderBundleOptions { - readonly http?: GraphNativeHttpProviderBundleOptions | false; - readonly cronScheduler?: GraphCronSchedulerProviderOptions; - readonly lifecycleHooks?: GraphLifecycleHooksProviderOptions; -} - -/** - * Creates Nest provider bindings for graph boundary interceptor. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphBoundaryInterceptor } from "@graphrefly/nestjs/native"; - * ``` - */ -export function provideGraphBoundaryInterceptor( - opts: GraphNativeHttpOptions = {}, -): Provider { - return { provide: APP_INTERCEPTOR, useValue: new GraphBoundaryInterceptorBridge(opts) }; -} - -/** - * Creates Nest provider bindings for graph guard. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphGuard } from "@graphrefly/nestjs/native"; - * ``` - */ -export function provideGraphGuard( - opts: GraphNativeGuardOptions = {}, -): Provider { - return { provide: APP_GUARD, useValue: new GraphGuardBridge(opts) }; -} - -/** - * Creates Nest provider bindings for graph exception filter. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphExceptionFilter } from "@graphrefly/nestjs/native"; - * ``` - */ -export function provideGraphExceptionFilter( - opts: GraphExceptionFilterProviderOptions, -): Provider { - return { provide: GRAPHREFLY_NEST_EXCEPTION_FILTER, useValue: createGraphExceptionFilter(opts) }; -} - -/** - * Creates a graph exception filter. - * - * @param opts - Options that configure the helper. - * @returns The create graph exception filter result. - * @category adapters - * @example - * ```ts - * import { createGraphExceptionFilter } from "@graphrefly/nestjs/native"; - * ``` - */ -export function createGraphExceptionFilter( - opts: GraphExceptionFilterProviderOptions, -): ExceptionFilter & OnModuleDestroy { - return new GraphExceptionFilterBridge(opts); -} - -/** - * Creates Nest provider bindings for graph guard denied filter. - * - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphGuardDeniedFilter } from "@graphrefly/nestjs/native"; - * ``` - */ -export function provideGraphGuardDeniedFilter(): Provider { - return GraphGuardDeniedFilter; -} - -/** - * Creates a graph guard denied filter. - * - * @returns The create graph guard denied filter result. - * @category adapters - * @example - * ```ts - * import { createGraphGuardDeniedFilter } from "@graphrefly/nestjs/native"; - * ``` - */ -export function createGraphGuardDeniedFilter(): ExceptionFilter { - return new GraphGuardDeniedFilter(); -} - -/** - * Creates Nest provider bindings for graph cron scheduler. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphCronScheduler } from "@graphrefly/nestjs/native"; - * ``` - */ -export function provideGraphCronScheduler(opts: GraphCronSchedulerProviderOptions): Provider { - return { provide: GRAPHREFLY_NEST_CRON_SCHEDULER, useValue: new GraphCronSchedulerBridge(opts) }; -} - -/** - * Creates Nest provider bindings for graph lifecycle hooks. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphLifecycleHooks } from "@graphrefly/nestjs/native"; - * ``` - */ -export function provideGraphLifecycleHooks(opts: GraphLifecycleHooksProviderOptions): Provider { - return { - provide: GRAPHREFLY_NEST_LIFECYCLE_HOOKS, - useValue: new GraphLifecycleHooksBridge(opts), - }; -} - -/** - * Creates Nest provider bindings for graph native HTTP providers. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphNativeHttpProviders } from "@graphrefly/nestjs/native"; - * ``` - */ -export function provideGraphNativeHttpProviders( - opts: GraphNativeHttpProviderBundleOptions = {}, -): Provider[] { - const providers: Provider[] = []; - if (opts.boundaryInterceptor !== false) - providers.push(provideGraphBoundaryInterceptor(opts.boundaryInterceptor ?? {})); - if (opts.guard !== false) providers.push(provideGraphGuard(opts.guard ?? {})); - if (opts.guardDeniedFilter ?? true) providers.push(provideGraphGuardDeniedFilter()); - if (opts.exceptionFilter !== undefined) - providers.push(provideGraphExceptionFilter(opts.exceptionFilter)); - return providers; -} - -/** - * Creates Nest provider bindings for graph native providers. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphNativeProviders } from "@graphrefly/nestjs/native"; - * ``` - */ -export function provideGraphNativeProviders( - opts: GraphNativeProviderBundleOptions = {}, -): Provider[] { - const providers: Provider[] = []; - if (opts.http !== false) providers.push(...provideGraphNativeHttpProviders(opts.http ?? {})); - if (opts.cronScheduler !== undefined) - providers.push(provideGraphCronScheduler(opts.cronScheduler)); - if (opts.lifecycleHooks !== undefined) - providers.push(provideGraphLifecycleHooks(opts.lifecycleHooks)); - return providers; -} - -/** - * Creates a graph cron target. - * - * @param target - Class or instance that owns the decorated method. - * @param methodKey - Method key on the decorated target. - * @param opts - Options that configure the helper. - * @returns The graph cron target result. - * @category adapters - * @example - * ```ts - * import { graphCronTarget } from "@graphrefly/nestjs/native"; - * ``` - */ -export function graphCronTarget( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - opts: Omit, "target" | "methodKey">, -): GraphCronProviderTarget { - return { ...opts, target, methodKey }; -} - -/** - * Creates a graph lifecycle target. - * - * @param target - Class or instance that owns the decorated method. - * @param methodKey - Method key on the decorated target. - * @param opts - Options that configure the helper. - * @returns The graph lifecycle target result. - * @category adapters - * @example - * ```ts - * import { graphLifecycleTarget } from "@graphrefly/nestjs/native"; - * ``` - */ -export function graphLifecycleTarget( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - opts: Omit, "target" | "methodKey"> = {}, -): GraphLifecycleProviderTarget { - return { ...opts, target, methodKey }; -} - -/** - * Creates a graph cron controller. - * - * @param opts - Options that configure the helper. - * @returns The create graph cron controller result. - * @category adapters - * @example - * ```ts - * import { createGraphCronController } from "@graphrefly/nestjs/native"; - * ``` - */ -export function createGraphCronController(opts: GraphCronControllerOptions): GraphCronController { - return new GraphCronControllerImpl(opts); -} - -class GraphBoundaryInterceptorBridge implements NestInterceptor, OnModuleDestroy { - private readonly runner: ReturnType; - - constructor(private readonly opts: GraphNativeHttpOptions) { - this.runner = createNestGraphBoundaryRunner({ - diagnosticBoundary: opts.diagnosticBoundary, - diagnosticPhase: "http", - }); - } - - intercept(context: ExecutionContext, next: CallHandler): Observable { - const host = this.opts.host?.(context) ?? (defaultHttpHost(context) as THost); - const result = this.runner.run( - context.getClass(), - methodKeyForContext(context), - host, - this.opts, - ); - const reply = firstHttpReply(context); - const value = - result === undefined - ? next.handle() - : Promise.resolve(result).then( - (payload) => - writeHttpResponse( - context, - lowerHttpReplyPayload(payload, host, { - issueResponse: reply?.issueResponse ?? this.opts.issueResponse, - }), - ), - (errorPayload) => - writeHttpResponse( - context, - lowerProtocolError(errorPayload, host, { - protocolError: reply?.protocolError ?? this.opts.protocolError, - }), - ), - ); - return isObservableLike(value) ? value : from(Promise.resolve(value)); - } - - onModuleDestroy(): void { - this.runner.dispose(); - } -} - -let nextGraphGuardBridgeId = 0; - -class GraphGuardBridge implements CanActivate, OnModuleDestroy { - private readonly decisions = new WeakMap< - object, - Map>> - >(); - private readonly disposableDecisions = new Set< - ReturnType> - >(); - private readonly bridgeId = ++nextGraphGuardBridgeId; - private nextInvocationId = 0; - private disposed = false; - private readonly activeAwaitSettlements = new Set<() => void>(); - - constructor(private readonly opts: GraphNativeGuardOptions) { - validateGraphGuardDecisionWait(opts.decisionWait); - } - - async canActivate(context: ExecutionContext): Promise { - if (this.disposed) return false; - const methodKey = methodKeyForContext(context); - const bindings = getNestBoundaryBindings(context.getClass(), methodKey); - const guards = bindings.filter(isGuardIngress); - if (guards.length === 0) return true; - const decisions = bindings.filter(isGuardDecision); - if (decisions.length === 0) return false; - const host = this.opts.host?.(context) ?? (defaultHttpHost(context) as THost); - if (this.opts.decisionWait?.mode === "await") { - return this.canActivateAwait(context, host, guards, decisions); - } - const guardEmits = guards.map((guard) => ({ - guard, - requestId: bindingRequestId(host, guard, this.opts.requestId), - })); - if (guardEmits.some((entry) => entry.requestId === undefined)) return false; - const requestIds = new Set(guardEmits.map((entry) => entry.requestId as string)); - const cleanups: Array<() => boolean> = []; - try { - const pending: GuardDecisionState[] = []; - for (const requestId of requestIds) { - for (const decision of decisions) { - const state: GuardDecisionState = { status: "pending", binding: decision }; - pending.push(state); - cleanups.push( - this.decisionBoundary(decision).attach({ - requestId, - bindingId: decision.bindingId, - handle: { - resolve(payload) { - state.status = "resolved"; - state.payload = payload; - }, - reject(error) { - state.status = "rejected"; - state.error = error; - }, - }, - }), - ); - } - } - for (const entry of guardEmits) - entry.guard.boundary.emit(host, bindingEmitOptions(host, entry.guard, entry.requestId)); - const rejected = pending.find((state) => state.status === "rejected"); - if (rejected !== undefined) { - throw new GraphGuardProtocolErrorException( - lowerProtocolError(rejected.error, host, { - protocolError: rejected.binding.protocolError ?? this.opts.protocolError, - }), - ); - } - if (pending.some((state) => state.status !== "resolved")) return false; - const denied = pending.find( - ( - state, - ): state is GuardDecisionState & { - readonly status: "resolved"; - readonly payload: Extract; - } => state.status === "resolved" && state.payload?.kind === "deny", - ); - if (denied !== undefined) { - throw new GraphGuardDeniedException( - guardDecisionResponse(denied.payload, host, { - issueResponse: denied.binding.issueResponse ?? this.opts.issueResponse, - }), - ); - } - return pending.every((state) => state.payload?.kind === "allow"); - } catch (error) { - if (isGraphGuardDeniedException(error) || error instanceof GraphGuardProtocolErrorException) { - throw error; - } - return false; - } finally { - for (const cleanup of cleanups) cleanup(); - } - } - - onModuleDestroy(): void { - if (this.disposed) return; - this.disposed = true; - for (const settle of [...this.activeAwaitSettlements]) settle(); - this.activeAwaitSettlements.clear(); - for (const boundary of this.disposableDecisions) boundary.dispose(); - this.disposableDecisions.clear(); - } - - private async canActivateAwait( - context: ExecutionContext, - host: THost, - guards: readonly NestIngressBindingMeta[], - decisions: readonly NestGuardDecisionBindingMeta[], - ): Promise { - const wait = this.opts.decisionWait; - if (wait?.mode !== "await") return false; - const scopeState = graphGuardAwaitScopeStates.get(wait.scope); - if (scopeState === undefined || !scopeState.active || this.disposed) return false; - if (scopeState.entries.size >= wait.maxPending) { - throw new GraphGuardDeniedException({ - status: 503, - body: { code: "graphrefly.guard.overloaded" }, - }); - } - - const invocationId = `graphrefly:nest-guard:${this.bridgeId}:${++this.nextInvocationId}`; - const controller = new AbortController(); - scopeState.entries.set(invocationId, controller); - const cleanups: Array<() => boolean> = []; - const pending: GuardDecisionState[] = []; - let emittedGuardCount = 0; - let timer: ReturnType | undefined; - let hostSignal: AbortSignal | undefined; - let settled = false; - let settleWait: (() => void) | undefined; - const waitForSettlement = new Promise((resolve) => { - settleWait = resolve; - }); - const settle = () => { - if (settled) return; - settled = true; - settleWait?.(); - }; - const maybeSettle = () => { - if (pending.length > 0 && pending.every((state) => state.status !== "pending")) settle(); - }; - const abort = () => { - controller.abort(); - settle(); - }; - - this.activeAwaitSettlements.add(abort); - controller.signal.addEventListener("abort", settle, { once: true }); - try { - for (const decision of decisions) { - const state: GuardDecisionState = { status: "pending", binding: decision }; - pending.push(state); - cleanups.push( - this.decisionBoundary(decision).attach({ - requestId: invocationId, - bindingId: decision.bindingId, - handle: { - resolve(payload) { - if (state.status !== "pending") return; - state.premature = emittedGuardCount < guards.length; - state.status = "resolved"; - state.payload = payload; - maybeSettle(); - }, - reject(error) { - if (state.status !== "pending") return; - state.status = "rejected"; - state.error = error; - maybeSettle(); - }, - }, - }), - ); - } - hostSignal = wait.hostAbortSignal?.(context, host); - if (hostSignal?.aborted) abort(); - else hostSignal?.addEventListener("abort", abort, { once: true }); - timer = setTimeout(abort, wait.timeoutMs); - if (!controller.signal.aborted) { - for (const guard of guards) { - emittedGuardCount += 1; - guard.boundary.emit(host, bindingEmitOptions(host, guard, invocationId)); - } - } - maybeSettle(); - await waitForSettlement; - if (controller.signal.aborted || this.disposed) return false; - if (pending.some((state) => state.premature)) return false; - const rejected = pending.find((state) => state.status === "rejected"); - if (rejected !== undefined) { - throw new GraphGuardProtocolErrorException( - lowerProtocolError(rejected.error, host, { - protocolError: rejected.binding.protocolError ?? this.opts.protocolError, - }), - ); - } - const denied = pending.find( - ( - state, - ): state is GuardDecisionState & { - readonly status: "resolved"; - readonly payload: Extract; - } => state.status === "resolved" && state.payload?.kind === "deny", - ); - if (denied !== undefined) { - throw new GraphGuardDeniedException( - guardDecisionResponse(denied.payload, host, { - issueResponse: denied.binding.issueResponse ?? this.opts.issueResponse, - }), - ); - } - return pending.every((state) => state.payload?.kind === "allow"); - } catch (error) { - if (isGraphGuardDeniedException(error) || error instanceof GraphGuardProtocolErrorException) { - throw error; - } - return false; - } finally { - if (timer !== undefined) clearTimeout(timer); - hostSignal?.removeEventListener("abort", abort); - controller.signal.removeEventListener("abort", settle); - for (const cleanup of cleanups) cleanup(); - controller.abort(); - scopeState.entries.delete(invocationId); - this.activeAwaitSettlements.delete(abort); - } - } - - private decisionBoundary( - binding: NestGuardDecisionBindingMeta, - ): ReturnType> { - const node = binding.decisionNode as object; - let byBinding = this.decisions.get(node); - if (byBinding === undefined) { - byBinding = new Map(); - this.decisions.set(node, byBinding); - } - const existing = byBinding.get(binding.bindingId); - if (existing !== undefined) return existing; - const boundary = toNestHttp(binding.decisionNode, { - bindingId: binding.bindingId, - diagnosticBoundary: this.opts.diagnosticBoundary, - diagnosticPhase: "guard", - }); - byBinding.set(binding.bindingId, boundary); - this.disposableDecisions.add(boundary); - return boundary; - } -} - -/** - * Represents a graph exception filter bridge. - * - * @category adapters - * @example - * ```ts - * import { GraphExceptionFilterBridge } from "@graphrefly/nestjs/native"; - * ``` - */ -export class GraphExceptionFilterBridge implements ExceptionFilter, OnModuleDestroy { - private readonly replies = new WeakMap< - object, - Map>> - >(); - private readonly disposableReplies = new Set< - ReturnType> - >(); - - constructor(private readonly opts: GraphExceptionFilterProviderOptions) {} - - catch(exception: unknown, host: ArgumentsHost): unknown { - const target = this.opts.target(host, exception); - if (target === undefined) throw exception; - const bindings = getNestBoundaryBindings(target.target, target.methodKey); - const filters = bindings.filter(isErrorIngress); - if (filters.length === 0) throw exception; - const nativeHost = - this.opts.host?.(host, exception) ?? (defaultArgumentsHost(host, exception) as THost); - const filterEmits = filters.map((filter) => ({ - filter, - requestId: bindingRequestId(nativeHost, filter, this.opts.requestId), - })); - const handleFilters = filterEmits.filter((entry) => entry.filter.mode !== "observe"); - const observeOnly = handleFilters.length === 0; - const replies = bindings.filter(isHttpReply); - const cleanups: Array<() => boolean> = []; - const emitFilters = () => { - for (const entry of filterEmits) { - entry.filter.boundary.emit( - nativeHost, - bindingEmitOptions(nativeHost, entry.filter, entry.requestId), - ); - } - }; - if (observeOnly) { - emitFilters(); - throw exception; - } - const requestIds = new Set( - handleFilters - .map((entry) => entry.requestId) - .filter((requestId): requestId is string => requestId !== undefined), - ); - if (replies.length === 0 || requestIds.size === 0) { - for (const entry of filterEmits) { - if (entry.requestId === undefined) continue; - entry.filter.boundary.emit( - nativeHost, - bindingEmitOptions(nativeHost, entry.filter, entry.requestId), - ); - } - const filter = handleFilters[0]?.filter; - const lowered = lowerCaughtException(exception, nativeHost, { - issueResponse: filter?.issueResponse ?? this.opts.issueResponse, - protocolError: filter?.protocolError ?? this.opts.protocolError, - }); - return writeHttpResponse(host, lowered); - } - const pending: FilterReplyState[] = []; - try { - for (const requestId of requestIds) { - for (const reply of replies) { - const state: FilterReplyState = { status: "pending", reply }; - pending.push(state); - cleanups.push( - this.replyBoundary(reply).attach({ - requestId, - bindingId: reply.bindingId, - handle: { - resolve(payload) { - state.status = "resolved"; - state.payload = payload; - }, - reject(error) { - state.status = "rejected"; - state.error = error; - }, - }, - }), - ); - } - } - emitFilters(); - const resolved = pending.find((state) => state.status === "resolved"); - if (resolved !== undefined) { - return writeHttpResponse( - host, - lowerHttpReplyPayload(resolved.payload, nativeHost, { - issueResponse: - resolved.reply.issueResponse ?? - handleFilters[0]?.filter.issueResponse ?? - this.opts.issueResponse, - }), - ); - } - const rejected = pending.find((state) => state.status === "rejected"); - if (rejected !== undefined) { - return writeHttpResponse( - host, - lowerProtocolError(rejected.error, nativeHost, { - protocolError: - rejected.reply.protocolError ?? - handleFilters[0]?.filter.protocolError ?? - this.opts.protocolError, - }), - ); - } - const filter = handleFilters[0]?.filter; - return writeHttpResponse( - host, - lowerCaughtException(exception, nativeHost, { - issueResponse: filter?.issueResponse ?? this.opts.issueResponse, - protocolError: filter?.protocolError ?? this.opts.protocolError, - }), - ); - } finally { - for (const cleanup of cleanups) cleanup(); - } - } - - onModuleDestroy(): void { - for (const boundary of this.disposableReplies) boundary.dispose(); - this.disposableReplies.clear(); - } - - private replyBoundary( - binding: NestHttpReplyBindingMeta, - ): ReturnType> { - const node = binding.replyNode as object; - let byBinding = this.replies.get(node); - if (byBinding === undefined) { - byBinding = new Map(); - this.replies.set(node, byBinding); - } - const existing = byBinding.get(binding.bindingId); - if (existing !== undefined) return existing; - const boundary = toNestHttp(binding.replyNode as NodeReplyPayload, { - bindingId: binding.bindingId, - diagnosticBoundary: this.opts.diagnosticBoundary, - diagnosticPhase: "filter", - }); - byBinding.set(binding.bindingId, boundary); - this.disposableReplies.add(boundary); - return boundary; - } -} - -class GraphCronControllerImpl implements GraphCronController { - private readonly targets: Array<{ - readonly target: GraphCronProviderTarget; - readonly schedule: CronSchedule; - readonly fired: Set; - }>; - - constructor(opts: GraphCronControllerOptions) { - this.targets = opts.targets.map((target) => ({ - target, - schedule: parseCron(target.expr), - fired: new Set(), - })); - } - - check(now: Date): void { - for (const entry of this.targets) { - emitCronTarget(entry.target, entry.schedule, entry.fired, now); - } - } -} - -class GraphCronSchedulerBridge implements OnModuleInit, OnModuleDestroy { - private readonly timers: Array> = []; - - constructor(private readonly opts: GraphCronSchedulerProviderOptions) {} - - onModuleInit(): void { - try { - for (const target of this.opts.targets) this.startTarget(target); - } catch (error) { - this.onModuleDestroy(); - throw error; - } - } - - onModuleDestroy(): void { - for (const timer of this.timers.splice(0)) clearInterval(timer); - } - - private startTarget(target: GraphCronProviderTarget): void { - const tickMs = target.tickMs ?? 60_000; - if (!Number.isFinite(tickMs) || tickMs <= 0) { - throw new RangeError("provideGraphCronScheduler: tickMs must be a positive finite number"); - } - const controller = createGraphCronController({ targets: [target] }); - const check = () => controller.check(new Date()); - check(); - this.timers.push(setInterval(check, tickMs)); - } -} - -class GraphLifecycleHooksBridge implements OnModuleInit, OnModuleDestroy { - constructor(private readonly opts: GraphLifecycleHooksProviderOptions) {} - - onModuleInit(): void { - this.emit("module-init"); - } - - onModuleDestroy(): void { - this.emit("module-destroy"); - } - - private emit(event: "module-init" | "module-destroy"): void { - for (const target of this.opts.targets) { - if (target.event !== undefined && target.event !== event) continue; - const host = target.host?.(event) ?? { event }; - for (const binding of lifecycleBindings(target)) { - binding.boundary.emit(host, bindingEmitOptions(host, binding, undefined)); - } - } - } -} - -type NestHttpReplyPayloadUnknown = unknown; -type NodeReplyPayload = Parameters>[0]; -type NestGuardDecisionBindingMeta = Extract; -interface GuardDecisionState { - status: "pending" | "resolved" | "rejected"; - binding: NestGuardDecisionBindingMeta; - premature?: boolean; - payload?: GraphGuardDecision; - error?: unknown; -} - -interface FilterReplyState { - status: "pending" | "resolved" | "rejected"; - reply: NestHttpReplyBindingMeta; - payload?: NestHttpReplyPayloadUnknown; - error?: unknown; -} - -function validateGraphGuardDecisionWait( - wait: GraphGuardDecisionWait | undefined, -): void { - if (wait?.mode !== "await") return; - if (!Number.isFinite(wait.timeoutMs) || wait.timeoutMs <= 0) { - throw new RangeError("provideGraphGuard: await timeoutMs must be a positive finite number"); - } - if (!Number.isFinite(wait.maxPending) || wait.maxPending < 1) { - throw new RangeError("provideGraphGuard: await maxPending must be a positive finite number"); - } - if (Math.floor(wait.maxPending) !== wait.maxPending) { - throw new RangeError("provideGraphGuard: await maxPending must be an integer"); - } - if (!graphGuardAwaitScopeStates.has(wait.scope)) { - throw new TypeError( - "provideGraphGuard: await scope must be created by createNestGraphGuardAwaitScope", - ); - } -} - -function methodKeyForContext(context: ExecutionContext): string | symbol { - const resolved = resolveNestMethodKey( - context.getClass(), - context.getHandler() as DecoratorBoundMethod, - ); - return resolved ?? context.getHandler().name; -} - -function defaultHttpHost(context: ExecutionContext): unknown { - const request = context.switchToHttp?.().getRequest?.(); - return request ?? {}; -} - -function defaultArgumentsHost(host: ArgumentsHost, exception: unknown): unknown { - const request = host.switchToHttp?.().getRequest?.(); - if (request !== undefined && request !== null) return { ...request, exception }; - return { exception }; -} - -function isObservableLike(value: unknown): value is Observable { - return ( - value !== null && - typeof value === "object" && - typeof (value as { subscribe?: unknown }).subscribe === "function" - ); -} - -function isGuardIngress(binding: NestBoundaryBindingMeta): binding is NestIngressBindingMeta { - return binding.direction === "ingress" && binding.kind === "guard"; -} - -function isErrorIngress(binding: NestBoundaryBindingMeta): binding is NestIngressBindingMeta { - return binding.direction === "ingress" && binding.kind === "error"; -} - -function isHttpReply(binding: NestBoundaryBindingMeta): binding is NestHttpReplyBindingMeta { - return binding.direction === "egress" && binding.kind === "http"; -} - -function isGuardDecision( - binding: NestBoundaryBindingMeta, -): binding is NestGuardDecisionBindingMeta { - return binding.direction === "egress" && binding.kind === "guard-decision"; -} - -function firstHttpReply(context: ExecutionContext): NestHttpReplyBindingMeta | undefined { - return getNestBoundaryBindings(context.getClass(), methodKeyForContext(context)).find( - isHttpReply, - ); -} - -function lifecycleBindings( - target: GraphLifecycleProviderTarget, -): readonly NestIngressBindingMeta[] { - return getNestBoundaryBindings(target.target, target.methodKey).filter( - (binding): binding is NestIngressBindingMeta => - binding.direction === "ingress" && binding.kind === "lifecycle", - ); -} - -function cronBindings(target: GraphCronProviderTarget): readonly NestIngressBindingMeta[] { - return getNestBoundaryBindings(target.target, target.methodKey).filter( - (binding): binding is NestIngressBindingMeta => - binding.direction === "ingress" && binding.kind === "cron", - ); -} - -function emitCronTarget( - target: GraphCronProviderTarget, - schedule: CronSchedule, - fired: Set, - now: Date, -): void { - if (!matchesCron(schedule, now, { timezone: target.timezone })) return; - const key = cronProviderMinuteKey(now, target.timezone); - for (const existing of fired) { - if (!existing.startsWith(`${key.dayKey}:`)) fired.delete(existing); - } - if (fired.has(key.minuteKey)) return; - fired.add(key.minuteKey); - const host = - target.host?.(now) ?? - ({ - iso: now.toISOString(), - timestamp_ms: now.getTime(), - timestamp_ns: (BigInt(now.getTime()) * 1_000_000n).toString(), - timezone: target.timezone, - } satisfies Record); - for (const binding of cronBindings(target)) { - binding.boundary.emit(host, bindingEmitOptions(host, binding, undefined)); - } -} - -function cronProviderMinuteKey( - date: Date, - timezone?: string, -): { readonly dayKey: string; readonly minuteKey: string } { - if (timezone === undefined) { - const dayKey = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; - return { dayKey, minuteKey: `${dayKey}:${date.getHours()}:${date.getMinutes()}` }; - } - const parts = new Intl.DateTimeFormat("en-US-u-ca-gregory-nu-latn", { - timeZone: timezone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - }).formatToParts(date); - const byType = new Map(parts.map((part) => [part.type, part.value])); - const dayKey = `${byType.get("year")}-${byType.get("month")}-${byType.get("day")}`; - return { dayKey, minuteKey: `${dayKey}:${byType.get("hour")}:${byType.get("minute")}` }; -} - -function lowerCaughtException( - exception: unknown, - host: THost, - opts: { - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; - }, -): ReturnType { - if (isDataIssue(exception)) return lowerHttpReplyPayload(exception, host, opts); - return (opts.protocolError ?? protocolError)(exception, host); -} - -function guardDecisionResponse( - decision: Extract, - host: THost, - opts: { readonly issueResponse?: NestIssueResponse }, -): NestHttpResponsePayload { - if (decision.issue !== undefined) return lowerHttpReplyPayload(decision.issue, host, opts); - if (Number.isInteger(decision.status)) { - return { - status: decision.status as number, - body: decision.body ?? { - code: "graphrefly.guard_denied", - message: decision.reason ?? "GraphReFly guard denied request", - }, - headers: decision.headers, - }; - } - return { - status: 403, - body: { - code: "graphrefly.guard_denied", - message: decision.reason ?? "GraphReFly guard denied request", - }, - headers: decision.headers, - }; -} - -/** - * Represents a graph guard denied exception. - * - * @category adapters - * @example - * ```ts - * import { GraphGuardDeniedException } from "@graphrefly/nestjs/native"; - * ``` - */ -export class GraphGuardDeniedException extends HttpException { - readonly payload: NestHttpResponsePayload; - - constructor(payload: NestHttpResponsePayload) { - super(payload.body ?? {}, payload.status); - this.payload = payload; - } -} - -class GraphGuardProtocolErrorException extends HttpException { - constructor(payload: NestHttpResponsePayload) { - super(payload.body ?? {}, payload.status); - } -} - -/** - * Checks whether a value is a graph guard denied exception. - * - * @param value - Unknown value to check or decode. - * @returns `true` when the value matches the expected shape. - * @category adapters - * @example - * ```ts - * import { isGraphGuardDeniedException } from "@graphrefly/nestjs/native"; - * ``` - */ -export function isGraphGuardDeniedException(value: unknown): value is GraphGuardDeniedException { - return value instanceof GraphGuardDeniedException; -} - -/** - * Represents a graph guard denied filter. - * - * @category adapters - * @example - * ```ts - * import { GraphGuardDeniedFilter } from "@graphrefly/nestjs/native"; - * ``` - */ -export class GraphGuardDeniedFilter implements ExceptionFilter { - catch(exception: unknown, host: ArgumentsHost): unknown { - if (!isGraphGuardDeniedException(exception)) throw exception; - return writeHttpResponse(host, exception.payload); - } -} - -Catch(GraphGuardDeniedException)(GraphGuardDeniedFilter); - -function writeHttpResponse( - host: ArgumentsHost, - payload: ReturnType, -): unknown { - const response = host.switchToHttp?.().getResponse?.() as - | { - status?: (status: number) => unknown; - json?: (body: unknown) => unknown; - send?: (body?: unknown) => unknown; - setHeader?: (name: string, value: string) => void; - header?: (name: string, value: string) => void; - } - | undefined; - if (response === undefined) return payload; - for (const [name, value] of Object.entries(payload.headers ?? {})) { - if (typeof response.setHeader === "function") response.setHeader(name, value); - else response.header?.(name, value); - } - response.status?.(payload.status); - if (typeof response.json === "function") return response.json(payload.body ?? {}); - if (typeof response.send === "function") return response.send(payload.body); - return payload; -} diff --git a/packages/nestjs/src/websockets.ts b/packages/nestjs/src/websockets.ts deleted file mode 100644 index 3fa1191..0000000 --- a/packages/nestjs/src/websockets.ts +++ /dev/null @@ -1,427 +0,0 @@ -/** - * NestJS WebSocket native phase bridge (D488). - * - * This focused subpath may import `@nestjs/websockets`; the dependency-light - * structural layer and HTTP native bridge stay free of that optional peer. - */ - -import type { OnGatewayDisconnect } from "@nestjs/websockets"; -import { - bindingEmitOptions, - bindingRequestId, - type DecoratorHostConstructor, - fromNestWs, - GraphWs, - GraphWsAck, - GraphWsReply, - getNestBoundaryBindings, - type NestBoundaryBindingMeta, - type NestBoundaryDiagnostic, - type NestBoundaryEnvelope, - type NestDiagnosticIngressBoundary, - type NestGraphRunOptions, - type NestIngressBindingMeta, - type NestIngressBoundary, - type NestIngressEmitOptions, - type NestIngressOptions, - type NestProviderBinding, - type NestReplyEnvelope, - type NestReplyResponseHandle, - type NestWsAckBindingMeta, - type NestWsReplyBindingMeta, - sanitizeNestDiagnostic, - toNestHttp, -} from "./index.js"; - -/** D488 provider token for the focused Nest WebSocket native bridge. */ -export const GRAPHREFLY_NEST_WS_BRIDGE = Symbol.for("graphrefly:nest:ws-bridge"); - -/** Options for the D488 Nest WebSocket bridge; socket and ack handles stay host-private. */ -export interface GraphWsBridgeOptions extends NestGraphRunOptions { - readonly ack?: (host: THost) => (payload: unknown, envelope: NestReplyEnvelope) => void; - readonly client?: (host: THost) => object | undefined; - readonly diagnosticBoundary?: NestDiagnosticIngressBoundary; - readonly timeoutMs?: number; - readonly maxDiagnostics?: number; -} - -/** D495 focused WebSocket provider bundle options. */ -export interface GraphWsProviderBundleOptions { - readonly bridge?: GraphWsBridgeOptions | false; -} - -/** Explicit Nest WebSocket phase bridge over `GraphWs`, `GraphWsAck`, and `GraphWsReply` metadata. */ -export interface GraphWsBridge extends OnGatewayDisconnect { - handleMessage( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - host: THost, - opts?: NestGraphRunOptions, - ): Promise | undefined; - onModuleDestroy(): void; - diagnostics(): readonly NestBoundaryDiagnostic[]; - dispose(): void; -} - -type WsEgressBinding = NestWsAckBindingMeta | NestWsReplyBindingMeta; -type WsBoundary = ReturnType>; -interface WsPending { - readonly requestId: string; - readonly cleanups: Array<() => boolean>; - readonly reject: (error: unknown) => void; - timeout?: ReturnType; - settled: boolean; -} - -/** Build a dependency-light Nest provider for the focused WebSocket bridge token. - * @param opts - Options that configure the helper. - * @returns A `NestProviderBinding>` value. - * @category adapters - * @example - * ```ts - * import { provideGraphWsBridge } from "@graphrefly/nestjs/websockets"; - * ``` - */ -export function provideGraphWsBridge( - opts: GraphWsBridgeOptions = {}, -): NestProviderBinding> { - return { provide: GRAPHREFLY_NEST_WS_BRIDGE, useValue: createGraphWsBridge(opts) }; -} - -/** Build the D495 focused WebSocket provider bundle without scanning or creating graphs. - * @param opts - Options that configure the helper. - * @returns A `NestProviderBinding>[]` value. - * @category adapters - * @example - * ```ts - * import { provideGraphWsProviders } from "@graphrefly/nestjs/websockets"; - * ``` - */ -export function provideGraphWsProviders( - opts: GraphWsProviderBundleOptions = {}, -): NestProviderBinding>[] { - return opts.bridge === false ? [] : [provideGraphWsBridge(opts.bridge ?? {})]; -} - -/** Create a host-private WebSocket bridge instance without scanning the Nest container. - * @param opts - Options that configure the helper. - * @returns A `GraphWsBridge` value. - * @category adapters - * @example - * ```ts - * import { createGraphWsBridge } from "@graphrefly/nestjs/websockets"; - * ``` - */ -export function createGraphWsBridge( - opts: GraphWsBridgeOptions = {}, -): GraphWsBridge { - return new GraphWsBridgeImpl(opts); -} - -class GraphWsBridgeImpl implements GraphWsBridge { - private readonly boundaries = new WeakMap>(); - private readonly disposable = new Set(); - private readonly pendingByClient = new WeakMap>(); - private readonly localDiagnostics: NestBoundaryDiagnostic[] = []; - private active = true; - - constructor(private readonly opts: GraphWsBridgeOptions) {} - - handleDisconnect(client?: unknown): void { - if (client === null || typeof client !== "object") return; - const pending = this.pendingByClient.get(client); - if (pending === undefined) return; - for (const entry of [...pending]) { - this.rejectPending( - entry, - new Error(`GraphWs native bridge disconnected before ${entry.requestId} resolved`), - "dispose-pending", - ); - } - this.pendingByClient.delete(client); - } - - onModuleDestroy(): void { - this.dispose(); - } - - handleMessage( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - host: THost, - runOpts: NestGraphRunOptions = {}, - ): Promise | undefined { - if (!this.active) throw new Error("GraphWs native bridge is disposed"); - const bindings = wsBindings(target, methodKey); - const ingress = bindings.filter(isWsIngress); - if (ingress.length === 0) return undefined; - const egress = bindings.filter(isWsEgress); - const needsRequestId = egress.length > 0; - for (const binding of ingress) assertExplicitNativePayload(binding, "GraphWs"); - const ingressEmits = ingress.map((binding) => ({ - binding, - requestId: bindingRequestId(host, binding, runOpts.requestId ?? this.opts.requestId), - })); - if (needsRequestId && ingressEmits.some((entry) => entry.requestId === undefined)) { - throw new Error("GraphWs native bridge requires a stable requestId for ack/reply egress"); - } - const requestIds = uniqueDefinedStrings(ingressEmits.map((entry) => entry.requestId)); - const cleanups: Array<() => boolean> = []; - let settled = false; - const replyBindings = egress.filter(isWsReply); - const settleOnAck = replyBindings.length === 0; - let activePending: WsPending | undefined; - let resolvePromise: ((payload: unknown) => void) | undefined; - let rejectPromise: ((error: unknown) => void) | undefined; - let cleaned = false; - const cleanupAll = () => { - if (cleaned) return; - cleaned = true; - for (const cleanup of cleanups) cleanup(); - if (activePending?.timeout !== undefined) clearTimeout(activePending.timeout); - this.unregisterClientPending(host, activePending); - }; - const settleResolve = (payload: unknown) => { - if (settled) return; - settled = true; - if (activePending !== undefined) activePending.settled = true; - resolvePromise?.(payload); - }; - const settleReject = (error: unknown) => { - if (settled) return; - settled = true; - if (activePending !== undefined) activePending.settled = true; - rejectPromise?.(error); - }; - const promise = - egress.length === 0 - ? undefined - : new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - if (promise !== undefined) { - activePending = { - requestId: requestIds[0], - cleanups, - reject: settleReject, - settled: false, - }; - try { - for (const requestId of requestIds) { - for (const binding of egress) { - const boundary = this.boundaryFor(binding); - cleanups.push( - boundary.attach({ - requestId, - bindingId: binding.bindingId, - handle: this.handleFor(binding, host, settleResolve, settleReject, settleOnAck), - }), - ); - } - } - } catch (error) { - cleanupAll(); - settleReject(error); - return promise; - } - if (settled) { - cleanupAll(); - return promise; - } - try { - this.registerClientPending(host, activePending); - } catch (error) { - cleanupAll(); - settleReject(error); - return promise; - } - if (this.opts.timeoutMs !== undefined) { - activePending.timeout = setTimeout(() => { - const error = new Error(`GraphWs native bridge timed out waiting for ${requestIds[0]}`); - this.rejectPending(activePending as WsPending, error, "timeout"); - }, this.opts.timeoutMs); - } - } - try { - for (const { binding, requestId } of ingressEmits) { - binding.boundary.emit(host, { - ...bindingEmitOptions(host, binding, requestId), - requireRequestId: needsRequestId, - }); - } - } catch (error) { - cleanupAll(); - throw error; - } - return promise?.finally(() => { - cleanupAll(); - }); - } - - diagnostics(): readonly NestBoundaryDiagnostic[] { - return [ - ...this.localDiagnostics, - ...[...this.disposable].flatMap((boundary) => boundary.diagnostics()), - ]; - } - - dispose(): void { - if (!this.active) return; - this.active = false; - for (const boundary of this.disposable) boundary.dispose(); - } - - private boundaryFor(binding: WsEgressBinding): WsBoundary { - const node = binding.kind === "ws-ack" ? binding.ackNode : binding.replyNode; - let byBinding = this.boundaries.get(node); - if (byBinding === undefined) { - byBinding = new Map(); - this.boundaries.set(node, byBinding); - } - const existing = byBinding.get(binding.bindingId); - if (existing !== undefined) return existing; - const boundary = toNestHttp(node, { - bindingId: binding.bindingId, - diagnosticBoundary: this.opts.diagnosticBoundary, - diagnosticPhase: "ws", - name: `nestjs.${binding.kind}`, - maxDiagnostics: this.opts.maxDiagnostics, - }); - byBinding.set(binding.bindingId, boundary); - this.disposable.add(boundary); - return boundary; - } - - private handleFor( - binding: WsEgressBinding, - host: THost, - resolve: (payload: unknown) => void, - reject: (error: unknown) => void, - settleOnAck: boolean, - ): NestReplyResponseHandle { - return { - resolve: (payload, envelope) => { - if (binding.kind === "ws-ack") { - this.opts.ack?.(host)?.(payload, envelope); - if (settleOnAck) resolve(payload); - return; - } - resolve(payload); - }, - reject, - }; - } - - private diagnose(diagnostic: NestBoundaryDiagnostic): void { - this.localDiagnostics.push(diagnostic); - try { - const phase = diagnostic.phase ?? "ws"; - const payload = sanitizeNestDiagnostic({ ...diagnostic, phase }, phase); - this.opts.diagnosticBoundary?.emit(payload, { payload }); - } catch { - // Graph-visible diagnostics are optional and must not interrupt host cleanup. - } - if ( - this.opts.maxDiagnostics !== undefined && - this.localDiagnostics.length > this.opts.maxDiagnostics - ) { - this.localDiagnostics.splice(0, this.localDiagnostics.length - this.opts.maxDiagnostics); - } - } - - private registerClientPending(host: THost, pending: WsPending | undefined): void { - if (pending === undefined) return; - const client = this.clientFor(host); - if (client === undefined) return; - let set = this.pendingByClient.get(client); - if (set === undefined) { - set = new Set(); - this.pendingByClient.set(client, set); - } - set.add(pending); - } - - private unregisterClientPending(host: THost, pending: WsPending | undefined): void { - if (pending === undefined) return; - const client = this.clientFor(host); - if (client === undefined) return; - const set = this.pendingByClient.get(client); - if (set === undefined) return; - set.delete(pending); - if (set.size === 0) this.pendingByClient.delete(client); - } - - private rejectPending( - pending: WsPending, - error: unknown, - kind: "dispose-pending" | "timeout", - ): void { - if (pending.settled) return; - pending.settled = true; - for (const cleanup of pending.cleanups) cleanup(); - if (pending.timeout !== undefined) clearTimeout(pending.timeout); - this.diagnose({ - kind, - requestId: pending.requestId, - message: error instanceof Error ? error.message : String(error), - error, - }); - pending.reject(error); - } - - private clientFor(host: THost): object | undefined { - const fromOption = this.opts.client?.(host); - if (fromOption !== null && typeof fromOption === "object") return fromOption; - if (host === null || typeof host !== "object") return undefined; - const record = host as { readonly client?: unknown; readonly socket?: unknown }; - if (record.client !== null && typeof record.client === "object") return record.client; - if (record.socket !== null && typeof record.socket === "object") return record.socket; - return undefined; - } -} - -function wsBindings( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, -): readonly NestBoundaryBindingMeta[] { - return getNestBoundaryBindings(target, methodKey); -} - -function isWsIngress(binding: NestBoundaryBindingMeta): binding is NestIngressBindingMeta { - return binding.direction === "ingress" && binding.kind === "ws"; -} - -function isWsEgress(binding: NestBoundaryBindingMeta): binding is WsEgressBinding { - return ( - binding.direction === "egress" && (binding.kind === "ws-ack" || binding.kind === "ws-reply") - ); -} - -function isWsReply(binding: WsEgressBinding): binding is NestWsReplyBindingMeta { - return binding.kind === "ws-reply"; -} - -function assertExplicitNativePayload(binding: NestIngressBindingMeta, label: string): void { - if (binding.payload === undefined) { - throw new Error(`${label} native bridge requires an explicit payload selector`); - } -} - -function uniqueDefinedStrings(values: readonly (string | undefined)[]): string[] { - const seen = new Set(); - for (const value of values) if (value !== undefined) seen.add(value); - return [...seen]; -} - -export { - fromNestWs, - GraphWs, - GraphWsAck, - GraphWsReply, - type NestBoundaryEnvelope, - type NestIngressBoundary, - type NestIngressEmitOptions, - type NestIngressOptions, - type NestReplyEnvelope, -}; diff --git a/packages/nestjs/tsconfig.json b/packages/nestjs/tsconfig.json deleted file mode 100644 index 24e1b90..0000000 --- a/packages/nestjs/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM"], - "declaration": true, - "experimentalDecorators": true, - "strict": true, - "skipLibCheck": true - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/packages/nestjs/vitest.config.ts b/packages/nestjs/vitest.config.ts deleted file mode 100644 index 1ddf87e..0000000 --- a/packages/nestjs/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - testTimeout: 15_000, - }, -}); diff --git a/packages/reactive-layout-node-canvas/package.json b/packages/reactive-layout-node-canvas/package.json index 885851a..6482bdb 100644 --- a/packages/reactive-layout-node-canvas/package.json +++ b/packages/reactive-layout-node-canvas/package.json @@ -30,7 +30,7 @@ "package.json" ], "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts --clean && node ../../scripts/check-ecosystem-package.mjs .", + "build": "tsup src/index.ts --format esm,cjs --dts --clean && node ../../scripts/check-node-canvas-package.mjs .", "test": "vitest run" }, "peerDependencies": { diff --git a/packages/solid/CHANGELOG.md b/packages/solid/CHANGELOG.md deleted file mode 100644 index 43611cf..0000000 --- a/packages/solid/CHANGELOG.md +++ /dev/null @@ -1,10 +0,0 @@ -# @graphrefly/solid - -## 0.1.0 - -### Minor Changes - -- c62333b: Publish the independently owned React, Vue, Solid, Svelte, NestJS and - node-canvas integration packages. Each package consumes the strict - zero-dependency `@graphrefly/ts` core through focused package exports and owns - only its framework or runtime peer boundary. diff --git a/packages/solid/README.md b/packages/solid/README.md deleted file mode 100644 index 35c63af..0000000 --- a/packages/solid/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @graphrefly/solid - -Solid-owned bindings over the framework-neutral stores exported by `@graphrefly/ts/adapters`. diff --git a/packages/solid/package.json b/packages/solid/package.json deleted file mode 100644 index 41b7bb2..0000000 --- a/packages/solid/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@graphrefly/solid", - "version": "0.1.0", - "description": "Solid bindings for GraphReFly.", - "repository": { - "type": "git", - "url": "git+https://github.com/graphrefly/graphrefly-react.git", - "directory": "packages/solid" - }, - "type": "module", - "sideEffects": false, - "main": "dist/index.cjs", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - } - }, - "files": [ - "dist", - "README.md", - "package.json" - ], - "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts --clean && node ../../scripts/check-ecosystem-package.mjs .", - "test": "vitest run" - }, - "peerDependencies": { - "@graphrefly/ts": ">=0.6.2 <1.0.0", - "solid-js": "^1.9.0" - }, - "devDependencies": { - "@graphrefly/ts": "0.6.2", - "solid-js": "^1.9.0", - "tsup": "^8.5.1", - "typescript": "^5.7.0", - "vitest": "^4.1.7" - }, - "publishConfig": { - "access": "public" - }, - "license": "MIT" -} diff --git a/packages/solid/src/index.test.ts b/packages/solid/src/index.test.ts deleted file mode 100644 index 90f5a06..0000000 --- a/packages/solid/src/index.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { graph } from "@graphrefly/ts"; -import { createRoot } from "solid-js"; -import { describe, expect, it } from "vitest"; -import { createNodeInput } from "./index.js"; - -describe("@graphrefly/solid", () => { - it("owns the Solid lifecycle and DATA write boundary", () => { - createRoot((dispose) => { - const state = graph().state(1); - const [value, setValue] = createNodeInput(state); - expect(value()).toBe(1); - setValue(2); - expect(value()).toBe(2); - expect(() => (setValue as (value: undefined) => void)(undefined)).toThrow(/SENTINEL/); - dispose(); - }); - }); -}); diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts deleted file mode 100644 index 6a9bdad..0000000 --- a/packages/solid/src/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Node } from "@graphrefly/ts"; -import { readableStore, recordReadableStore, type WritableNode } from "@graphrefly/ts/adapters"; -import { type Accessor, createSignal, onCleanup } from "solid-js"; - -function assertDataValue(value: unknown): void { - if (value === undefined) { - throw new TypeError( - "createNodeInput: undefined is SENTINEL/no DATA, not a writable DATA value", - ); - } -} - -function bindReadable(store: { get(): T; subscribe(run: (value: T) => void): () => void }) { - const [value, setValue] = createSignal(store.get()); - const unsubscribe = store.subscribe((next) => { - setValue(() => next); - }); - onCleanup(unsubscribe); - return value; -} - -export function createNodeValue(node: Node): Accessor { - return bindReadable(readableStore(node)); -} - -export function createNodeInput( - node: WritableNode, -): readonly [Accessor, (value: T) => void] { - return [ - createNodeValue(node), - (value: T) => { - assertDataValue(value); - node.set(value); - }, - ] as const; -} - -export function createNodeRecord>( - keysNode: Node, - factory: (key: K) => { [P in keyof R]: Node }, -): Accessor> { - const store = recordReadableStore(keysNode, factory); - return bindReadable({ - get: () => store.get() ?? ({} as Record), - subscribe: (run) => store.subscribe((value) => run(value ?? ({} as Record))), - }); -} diff --git a/packages/solid/tsconfig.json b/packages/solid/tsconfig.json deleted file mode 100644 index 17c2cba..0000000 --- a/packages/solid/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM"], - "declaration": true, - "strict": true, - "skipLibCheck": true - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md deleted file mode 100644 index 281a633..0000000 --- a/packages/svelte/CHANGELOG.md +++ /dev/null @@ -1,10 +0,0 @@ -# @graphrefly/svelte - -## 0.1.0 - -### Minor Changes - -- c62333b: Publish the independently owned React, Vue, Solid, Svelte, NestJS and - node-canvas integration packages. Each package consumes the strict - zero-dependency `@graphrefly/ts` core through focused package exports and owns - only its framework or runtime peer boundary. diff --git a/packages/svelte/README.md b/packages/svelte/README.md deleted file mode 100644 index fb95cf6..0000000 --- a/packages/svelte/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @graphrefly/svelte - -Svelte-owned bindings over the framework-neutral stores exported by `@graphrefly/ts/adapters`. diff --git a/packages/svelte/package.json b/packages/svelte/package.json deleted file mode 100644 index fabbf0d..0000000 --- a/packages/svelte/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@graphrefly/svelte", - "version": "0.1.0", - "description": "Svelte bindings for GraphReFly.", - "repository": { - "type": "git", - "url": "git+https://github.com/graphrefly/graphrefly-react.git", - "directory": "packages/svelte" - }, - "type": "module", - "sideEffects": false, - "main": "dist/index.cjs", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - } - }, - "files": [ - "dist", - "README.md", - "package.json" - ], - "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts --clean && node ../../scripts/check-ecosystem-package.mjs .", - "test": "vitest run" - }, - "peerDependencies": { - "@graphrefly/ts": ">=0.6.2 <1.0.0", - "svelte": "^5.0.0" - }, - "devDependencies": { - "@graphrefly/ts": "0.6.2", - "svelte": "^5.0.0", - "tsup": "^8.5.1", - "typescript": "^5.7.0", - "vitest": "^4.1.7" - }, - "publishConfig": { - "access": "public" - }, - "license": "MIT" -} diff --git a/packages/svelte/src/index.test.ts b/packages/svelte/src/index.test.ts deleted file mode 100644 index 0ceaf51..0000000 --- a/packages/svelte/src/index.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { graph } from "@graphrefly/ts"; -import { get } from "svelte/store"; -import { describe, expect, it } from "vitest"; -import { nodeWritable } from "./index.js"; - -describe("@graphrefly/svelte", () => { - it("owns the Svelte store and DATA write boundary", () => { - const state = graph().state(1); - const store = nodeWritable(state); - expect(get(store)).toBe(1); - store.set(2); - expect(get(store)).toBe(2); - expect(() => (store.set as (value: undefined) => void)(undefined)).toThrow(/SENTINEL/); - }); -}); diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts deleted file mode 100644 index 8cf5574..0000000 --- a/packages/svelte/src/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Node } from "@graphrefly/ts"; -import { - nodeSnapshot, - recordReadableStore, - subscribeNodeValues, - type WritableNode, -} from "@graphrefly/ts/adapters"; -import { type Readable, readable } from "svelte/store"; - -export interface NodeWritable extends Readable { - set(value: T): void; - update(fn: (value: T | undefined) => T): void; -} - -export function nodeReadable(node: Node): Readable { - return readable(nodeSnapshot(node), (set) => - subscribeNodeValues(node, set, { immediate: true }), - ); -} - -function assertDataValue(value: unknown): void { - if (value === undefined) { - throw new TypeError("nodeWritable: undefined is SENTINEL/no DATA, not a writable DATA value"); - } -} - -export function nodeWritable(node: WritableNode): NodeWritable { - const store = nodeReadable(node); - return { - subscribe: store.subscribe, - set(value) { - assertDataValue(value); - node.set(value); - }, - update(fn) { - const next = fn(nodeSnapshot(node)); - assertDataValue(next); - node.set(next); - }, - }; -} - -export function nodeRecord>( - keysNode: Node, - factory: (key: K) => { [P in keyof R]: Node }, -): Readable> { - const store = recordReadableStore(keysNode, factory); - const read = () => store.get() ?? ({} as Record); - return readable(read(), (set) => store.subscribe((value) => set(value ?? ({} as Record)))); -} diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json deleted file mode 100644 index 17c2cba..0000000 --- a/packages/svelte/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM"], - "declaration": true, - "strict": true, - "skipLibCheck": true - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/packages/vue/CHANGELOG.md b/packages/vue/CHANGELOG.md deleted file mode 100644 index a1d01b0..0000000 --- a/packages/vue/CHANGELOG.md +++ /dev/null @@ -1,10 +0,0 @@ -# @graphrefly/vue - -## 0.1.0 - -### Minor Changes - -- c62333b: Publish the independently owned React, Vue, Solid, Svelte, NestJS and - node-canvas integration packages. Each package consumes the strict - zero-dependency `@graphrefly/ts` core through focused package exports and owns - only its framework or runtime peer boundary. diff --git a/packages/vue/README.md b/packages/vue/README.md deleted file mode 100644 index f22cf08..0000000 --- a/packages/vue/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @graphrefly/vue - -Vue-owned bindings over the framework-neutral stores exported by `@graphrefly/ts/adapters`. diff --git a/packages/vue/package.json b/packages/vue/package.json deleted file mode 100644 index 062fbdd..0000000 --- a/packages/vue/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@graphrefly/vue", - "version": "0.1.0", - "description": "Vue bindings for GraphReFly.", - "repository": { - "type": "git", - "url": "git+https://github.com/graphrefly/graphrefly-react.git", - "directory": "packages/vue" - }, - "type": "module", - "sideEffects": false, - "main": "dist/index.cjs", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - } - }, - "files": [ - "dist", - "README.md", - "package.json" - ], - "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts --clean && node ../../scripts/check-ecosystem-package.mjs .", - "test": "vitest run" - }, - "peerDependencies": { - "@graphrefly/ts": ">=0.6.2 <1.0.0", - "vue": "^3.5.0" - }, - "devDependencies": { - "@graphrefly/ts": "0.6.2", - "tsup": "^8.5.1", - "typescript": "^5.7.0", - "vitest": "^4.1.7", - "vue": "^3.5.0" - }, - "publishConfig": { - "access": "public" - }, - "license": "MIT" -} diff --git a/packages/vue/src/index.test.ts b/packages/vue/src/index.test.ts deleted file mode 100644 index 63a343f..0000000 --- a/packages/vue/src/index.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { graph } from "@graphrefly/ts"; -import { describe, expect, it } from "vitest"; -import { effectScope } from "vue"; -import { useNodeInput } from "./index.js"; - -describe("@graphrefly/vue", () => { - it("owns the Vue lifecycle and DATA write boundary", () => { - const state = graph().state(1); - const scope = effectScope(); - const binding = scope.run(() => useNodeInput(state)); - expect(binding?.[0].value).toBe(1); - binding?.[1](2); - expect(binding?.[0].value).toBe(2); - expect(() => (binding?.[1] as (value: undefined) => void)(undefined)).toThrow(/SENTINEL/); - scope.stop(); - }); -}); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts deleted file mode 100644 index 7a15ca7..0000000 --- a/packages/vue/src/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Node } from "@graphrefly/ts"; -import { readableStore, recordReadableStore, type WritableNode } from "@graphrefly/ts/adapters"; -import { onScopeDispose, readonly, type ShallowRef, shallowRef } from "vue"; - -function assertDataValue(value: unknown): void { - if (value === undefined) { - throw new TypeError("useNodeInput: undefined is SENTINEL/no DATA, not a writable DATA value"); - } -} - -function bindReadable(store: { get(): T; subscribe(run: (value: T) => void): () => void }) { - const value = shallowRef(store.get()) as ShallowRef; - const unsubscribe = store.subscribe((next) => { - value.value = next; - }); - onScopeDispose(unsubscribe); - return readonly(value) as Readonly>; -} - -export function useNodeValue(node: Node): Readonly> { - return bindReadable(readableStore(node)); -} - -export function useNodeInput( - node: WritableNode, -): readonly [Readonly>, (value: T) => void] { - return [ - useNodeValue(node), - (value: T) => { - assertDataValue(value); - node.set(value); - }, - ] as const; -} - -export function useNodeRecord>( - keysNode: Node, - factory: (key: K) => { [P in keyof R]: Node }, -): Readonly>> { - const store = recordReadableStore(keysNode, factory); - return bindReadable({ - get: () => store.get() ?? ({} as Record), - subscribe: (run) => store.subscribe((value) => run(value ?? ({} as Record))), - }); -} diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json deleted file mode 100644 index 17c2cba..0000000 --- a/packages/vue/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM"], - "declaration": true, - "strict": true, - "skipLibCheck": true - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94cdfd0..bcf5651 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,45 +54,6 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@25.9.1)(jsdom@29.1.1(canvas@3.2.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)) - packages/nestjs: - devDependencies: - '@graphrefly/ts': - specifier: 0.6.2 - version: 0.6.2(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/microservices@11.1.28)(@nestjs/websockets@11.1.28)(canvas@3.2.3)(react@19.2.6)(rxjs@7.8.2)(solid-js@1.9.14)(svelte@5.56.7)(vue@3.5.40(typescript@5.9.3)) - '@nestjs/common': - specifier: ^11.0.0 - version: 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': - specifier: ^11.0.0 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.28)(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/microservices': - specifier: ^11.0.0 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/platform-express': - specifier: ^11.0.0 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) - '@nestjs/platform-ws': - specifier: ^11.0.0 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.28)(rxjs@7.8.2) - '@nestjs/websockets': - specifier: ^11.0.0 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) - reflect-metadata: - specifier: ^0.2.2 - version: 0.2.2 - rxjs: - specifier: ^7.8.0 - version: 7.8.2 - tsup: - specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.22)(typescript@5.9.3) - typescript: - specifier: ^5.7.0 - version: 5.9.3 - vitest: - specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(jsdom@29.1.1(canvas@3.2.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)) - packages/reactive-layout-node-canvas: dependencies: canvas: @@ -112,60 +73,6 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@25.9.1)(jsdom@29.1.1(canvas@3.2.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)) - packages/solid: - devDependencies: - '@graphrefly/ts': - specifier: 0.6.2 - version: 0.6.2(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/microservices@11.1.28)(@nestjs/websockets@11.1.28)(canvas@3.2.3)(react@19.2.6)(rxjs@7.8.2)(solid-js@1.9.14)(svelte@5.56.7)(vue@3.5.40(typescript@5.9.3)) - solid-js: - specifier: ^1.9.0 - version: 1.9.14 - tsup: - specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.22)(typescript@5.9.3) - typescript: - specifier: ^5.7.0 - version: 5.9.3 - vitest: - specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(jsdom@29.1.1(canvas@3.2.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)) - - packages/svelte: - devDependencies: - '@graphrefly/ts': - specifier: 0.6.2 - version: 0.6.2(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/microservices@11.1.28)(@nestjs/websockets@11.1.28)(canvas@3.2.3)(react@19.2.6)(rxjs@7.8.2)(solid-js@1.9.14)(svelte@5.56.7)(vue@3.5.40(typescript@5.9.3)) - svelte: - specifier: ^5.0.0 - version: 5.56.7 - tsup: - specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.22)(typescript@5.9.3) - typescript: - specifier: ^5.7.0 - version: 5.9.3 - vitest: - specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(jsdom@29.1.1(canvas@3.2.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)) - - packages/vue: - devDependencies: - '@graphrefly/ts': - specifier: 0.6.2 - version: 0.6.2(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/microservices@11.1.28)(@nestjs/websockets@11.1.28)(canvas@3.2.3)(react@19.2.6)(rxjs@7.8.2)(solid-js@1.9.14)(svelte@5.56.7)(vue@3.5.40(typescript@5.9.3)) - tsup: - specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.22)(typescript@5.9.3) - typescript: - specifier: ^5.7.0 - version: 5.9.3 - vitest: - specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(jsdom@29.1.1(canvas@3.2.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)) - vue: - specifier: ^3.5.0 - version: 3.5.40(typescript@5.9.3) - packages: '@asamuzakjp/css-color@5.1.11': @@ -686,13 +593,6 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 - '@nestjs/platform-ws@11.1.28': - resolution: {integrity: sha512-anktDtSYoJEpG8zVvOKHhc2+W9jSX0c3CmnuYUICqgwtc2vC/Md9dBWbyxbB8iU5F5dWICC74MQrBxJMfnZh2g==} - peerDependencies: - '@nestjs/common': ^11.0.0 - '@nestjs/websockets': ^11.0.0 - rxjs: ^7.1.0 - '@nestjs/websockets@11.1.28': resolution: {integrity: sha512-jeyclAURCJTN8S8lctDhfLdiJeDKjZmYWWLav653Fb9hl9c+zx5jPhavI8Xk5++R8u+lX9qzaRxtsjEoxTtjyw==} peerDependencies: @@ -2423,18 +2323,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -2473,13 +2361,15 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@7.29.7': + optional: true '@babel/helper-validator-identifier@7.29.7': {} '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 + optional: true '@babel/runtime@7.29.7': {} @@ -2487,6 +2377,7 @@ snapshots: dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + optional: true '@biomejs/biome@2.4.6': optionalDependencies: @@ -2523,7 +2414,8 @@ snapshots: '@biomejs/cli-win32-x64@2.4.6': optional: true - '@borewit/text-codec@0.2.2': {} + '@borewit/text-codec@0.2.2': + optional: true '@bramus/specificity@2.4.2': dependencies: @@ -2821,6 +2713,7 @@ snapshots: dependencies: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + optional: true '@jridgewell/resolve-uri@3.1.2': {} @@ -2831,7 +2724,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lukeed/csprng@1.1.0': {} + '@lukeed/csprng@1.1.0': + optional: true '@manypkg/find-root@1.1.0': dependencies: @@ -2867,6 +2761,7 @@ snapshots: uid: 2.0.2 transitivePeerDependencies: - supports-color + optional: true '@nestjs/core@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.28)(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: @@ -2882,6 +2777,7 @@ snapshots: '@nestjs/microservices': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) '@nestjs/websockets': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + optional: true '@nestjs/microservices@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: @@ -2893,6 +2789,7 @@ snapshots: tslib: 2.8.1 optionalDependencies: '@nestjs/websockets': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + optional: true '@nestjs/platform-express@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)': dependencies: @@ -2905,17 +2802,7 @@ snapshots: tslib: 2.8.1 transitivePeerDependencies: - supports-color - - '@nestjs/platform-ws@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.28)(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/websockets': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) - rxjs: 7.8.2 - tslib: 2.8.1 - ws: 8.21.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + optional: true '@nestjs/websockets@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: @@ -2926,6 +2813,7 @@ snapshots: reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 + optional: true '@nodelib/fs.scandir@2.1.5': dependencies: @@ -3072,6 +2960,7 @@ snapshots: '@sveltejs/acorn-typescript@1.0.11(acorn@8.17.0)': dependencies: acorn: 8.17.0 + optional: true '@testing-library/dom@10.4.1': dependencies: @@ -3100,8 +2989,10 @@ snapshots: token-types: 6.1.2 transitivePeerDependencies: - supports-color + optional: true - '@tokenizer/token@0.3.0': {} + '@tokenizer/token@0.3.0': + optional: true '@tybys/wasm-util@0.10.2': dependencies: @@ -3133,7 +3024,8 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/trusted-types@2.0.7': {} + '@types/trusted-types@2.0.7': + optional: true '@vitest/expect@4.1.7': dependencies: @@ -3183,11 +3075,13 @@ snapshots: entities: 7.0.1 estree-walker: 2.0.2 source-map-js: 1.2.1 + optional: true '@vue/compiler-dom@3.5.40': dependencies: '@vue/compiler-core': 3.5.40 '@vue/shared': 3.5.40 + optional: true '@vue/compiler-sfc@3.5.40': dependencies: @@ -3200,20 +3094,24 @@ snapshots: magic-string: 0.30.21 postcss: 8.5.22 source-map-js: 1.2.1 + optional: true '@vue/compiler-ssr@3.5.40': dependencies: '@vue/compiler-dom': 3.5.40 '@vue/shared': 3.5.40 + optional: true '@vue/reactivity@3.5.40': dependencies: '@vue/shared': 3.5.40 + optional: true '@vue/runtime-core@3.5.40': dependencies: '@vue/reactivity': 3.5.40 '@vue/shared': 3.5.40 + optional: true '@vue/runtime-dom@3.5.40': dependencies: @@ -3221,19 +3119,23 @@ snapshots: '@vue/runtime-core': 3.5.40 '@vue/shared': 3.5.40 csstype: 3.2.3 + optional: true '@vue/server-renderer@3.5.40': dependencies: '@vue/compiler-ssr': 3.5.40 '@vue/runtime-dom': 3.5.40 '@vue/shared': 3.5.40 + optional: true - '@vue/shared@3.5.40': {} + '@vue/shared@3.5.40': + optional: true accepts@2.0.0: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 + optional: true acorn@8.17.0: {} @@ -3245,7 +3147,8 @@ snapshots: any-promise@1.3.0: {} - append-field@1.0.0: {} + append-field@1.0.0: + optional: true argparse@1.0.10: dependencies: @@ -3257,13 +3160,15 @@ snapshots: dependencies: dequal: 2.0.3 - aria-query@5.3.1: {} + aria-query@5.3.1: + optional: true array-union@2.1.0: {} assertion-error@2.0.1: {} - axobject-query@4.1.0: {} + axobject-query@4.1.0: + optional: true base64-js@1.5.1: {} @@ -3294,12 +3199,14 @@ snapshots: type-is: 2.1.0 transitivePeerDependencies: - supports-color + optional: true braces@3.0.3: dependencies: fill-range: 7.1.1 - buffer-from@1.1.2: {} + buffer-from@1.1.2: + optional: true buffer@5.7.1: dependencies: @@ -3314,8 +3221,10 @@ snapshots: busboy@1.6.0: dependencies: streamsearch: 1.1.0 + optional: true - bytes@3.1.2: {} + bytes@3.1.2: + optional: true cac@6.7.14: {} @@ -3323,11 +3232,13 @@ snapshots: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 + optional: true call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + optional: true canvas@3.2.3: dependencies: @@ -3344,7 +3255,8 @@ snapshots: chownr@1.1.4: {} - clsx@2.1.1: {} + clsx@2.1.1: + optional: true commander@4.1.1: {} @@ -3354,27 +3266,34 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 typedarray: 0.0.6 + optional: true confbox@0.1.8: {} consola@3.4.2: {} - content-disposition@1.1.0: {} + content-disposition@1.1.0: + optional: true - content-type@1.0.5: {} + content-type@1.0.5: + optional: true - content-type@2.0.0: {} + content-type@2.0.0: + optional: true convert-source-map@2.0.0: {} - cookie-signature@1.2.2: {} + cookie-signature@1.2.2: + optional: true - cookie@0.7.2: {} + cookie@0.7.2: + optional: true cors@2.8.6: dependencies: object-assign: 4.1.1 vary: 1.1.2 + optional: true cross-spawn@7.0.6: dependencies: @@ -3408,7 +3327,8 @@ snapshots: deep-extend@0.6.0: {} - depd@2.0.0: {} + depd@2.0.0: + optional: true dequal@2.0.3: {} @@ -3416,7 +3336,8 @@ snapshots: detect-libc@2.1.2: {} - devalue@5.8.2: {} + devalue@5.8.2: + optional: true dir-glob@3.0.1: dependencies: @@ -3429,10 +3350,13 @@ snapshots: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + optional: true - ee-first@1.1.1: {} + ee-first@1.1.1: + optional: true - encodeurl@2.0.0: {} + encodeurl@2.0.0: + optional: true end-of-stream@1.4.5: dependencies: @@ -3443,19 +3367,23 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 - entities@7.0.1: {} + entities@7.0.1: + optional: true entities@8.0.0: {} - es-define-property@1.0.1: {} + es-define-property@1.0.1: + optional: true - es-errors@1.3.0: {} + es-errors@1.3.0: + optional: true es-module-lexer@2.1.0: {} es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 + optional: true esbuild@0.27.7: optionalDependencies: @@ -3486,23 +3414,28 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 - escape-html@1.0.3: {} + escape-html@1.0.3: + optional: true - esm-env@1.2.2: {} + esm-env@1.2.2: + optional: true esprima@4.0.1: {} esrap@2.3.0: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + optional: true - estree-walker@2.0.2: {} + estree-walker@2.0.2: + optional: true estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 - etag@1.8.1: {} + etag@1.8.1: + optional: true expand-template@2.0.3: {} @@ -3540,6 +3473,7 @@ snapshots: vary: 1.1.2 transitivePeerDependencies: - supports-color + optional: true extendable-error@0.1.7: {} @@ -3551,7 +3485,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-safe-stringify@2.1.1: {} + fast-safe-stringify@2.1.1: + optional: true fastq@1.20.1: dependencies: @@ -3569,6 +3504,7 @@ snapshots: uint8array-extras: 1.5.0 transitivePeerDependencies: - supports-color + optional: true fill-range@7.1.1: dependencies: @@ -3584,6 +3520,7 @@ snapshots: statuses: 2.0.2 transitivePeerDependencies: - supports-color + optional: true find-up@4.1.0: dependencies: @@ -3596,9 +3533,11 @@ snapshots: mlly: 1.8.2 rollup: 4.62.2 - forwarded@0.2.0: {} + forwarded@0.2.0: + optional: true - fresh@2.0.0: {} + fresh@2.0.0: + optional: true fs-constants@1.0.0: {} @@ -3617,7 +3556,8 @@ snapshots: fsevents@2.3.3: optional: true - function-bind@1.1.2: {} + function-bind@1.1.2: + optional: true get-intrinsic@1.3.0: dependencies: @@ -3631,11 +3571,13 @@ snapshots: has-symbols: 1.1.0 hasown: 2.0.4 math-intrinsics: 1.1.0 + optional: true get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + optional: true github-from-package@0.0.0: {} @@ -3652,15 +3594,18 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 - gopd@1.2.0: {} + gopd@1.2.0: + optional: true graceful-fs@4.2.11: {} - has-symbols@1.1.0: {} + has-symbols@1.1.0: + optional: true hasown@2.0.4: dependencies: function-bind: 1.1.2 + optional: true html-encoding-sniffer@6.0.0: dependencies: @@ -3675,6 +3620,7 @@ snapshots: setprototypeof: 1.2.0 statuses: 2.0.2 toidentifier: 1.0.1 + optional: true human-id@4.2.0: {} @@ -3690,7 +3636,8 @@ snapshots: ini@1.3.8: {} - ipaddr.js@1.9.1: {} + ipaddr.js@1.9.1: + optional: true is-extglob@2.1.1: {} @@ -3702,11 +3649,13 @@ snapshots: is-potential-custom-element-name@1.0.1: {} - is-promise@4.0.0: {} + is-promise@4.0.0: + optional: true is-reference@3.0.3: dependencies: '@types/estree': 1.0.9 + optional: true is-subdir@1.2.0: dependencies: @@ -3716,7 +3665,8 @@ snapshots: isexe@2.0.0: {} - iterare@1.2.1: {} + iterare@1.2.1: + optional: true joycon@3.1.1: {} @@ -3816,11 +3766,13 @@ snapshots: lines-and-columns@1.2.4: {} - load-esm@1.0.3: {} + load-esm@1.0.3: + optional: true load-tsconfig@0.2.5: {} - locate-character@3.0.0: {} + locate-character@3.0.0: + optional: true locate-path@5.0.0: dependencies: @@ -3836,15 +3788,19 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - math-intrinsics@1.1.0: {} + math-intrinsics@1.1.0: + optional: true mdn-data@2.27.1: {} - media-typer@0.3.0: {} + media-typer@0.3.0: + optional: true - media-typer@1.1.1: {} + media-typer@1.1.1: + optional: true - merge-descriptors@2.0.0: {} + merge-descriptors@2.0.0: + optional: true merge2@1.4.1: {} @@ -3853,17 +3809,21 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 - mime-db@1.52.0: {} + mime-db@1.52.0: + optional: true - mime-db@1.54.0: {} + mime-db@1.54.0: + optional: true mime-types@2.1.35: dependencies: mime-db: 1.52.0 + optional: true mime-types@3.0.2: dependencies: mime-db: 1.54.0 + optional: true mimic-response@3.1.0: {} @@ -3888,6 +3848,7 @@ snapshots: busboy: 1.6.0 concat-stream: 2.0.0 type-is: 1.6.18 + optional: true mz@2.7.0: dependencies: @@ -3899,7 +3860,8 @@ snapshots: napi-build-utils@2.0.0: {} - negotiator@1.0.0: {} + negotiator@1.0.0: + optional: true node-abi@3.94.0: dependencies: @@ -3909,15 +3871,18 @@ snapshots: object-assign@4.1.1: {} - object-hash@3.0.0: {} + object-hash@3.0.0: + optional: true - object-inspect@1.13.4: {} + object-inspect@1.13.4: + optional: true obug@2.1.1: {} on-finished@2.4.1: dependencies: ee-first: 1.1.1 + optional: true once@1.4.0: dependencies: @@ -3949,13 +3914,15 @@ snapshots: dependencies: entities: 8.0.0 - parseurl@1.3.3: {} + parseurl@1.3.3: + optional: true path-exists@4.0.0: {} path-key@3.1.1: {} - path-to-regexp@8.4.2: {} + path-to-regexp@8.4.2: + optional: true path-type@4.0.0: {} @@ -4016,6 +3983,7 @@ snapshots: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + optional: true pump@3.0.4: dependencies: @@ -4028,12 +3996,14 @@ snapshots: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 + optional: true quansync@0.2.11: {} queue-microtask@1.2.3: {} - range-parser@1.3.0: {} + range-parser@1.3.0: + optional: true raw-body@3.0.2: dependencies: @@ -4041,6 +4011,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 unpipe: 1.0.0 + optional: true rc@1.2.8: dependencies: @@ -4073,7 +4044,8 @@ snapshots: readdirp@4.1.2: {} - reflect-metadata@0.2.2: {} + reflect-metadata@0.2.2: + optional: true require-from-string@2.0.2: {} @@ -4142,6 +4114,7 @@ snapshots: path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color + optional: true run-parallel@1.2.0: dependencies: @@ -4150,6 +4123,7 @@ snapshots: rxjs@7.8.2: dependencies: tslib: 2.8.1 + optional: true safe-buffer@5.2.1: {} @@ -4178,12 +4152,15 @@ snapshots: statuses: 2.0.2 transitivePeerDependencies: - supports-color + optional: true seroval-plugins@1.5.6(seroval@1.5.6): dependencies: seroval: 1.5.6 + optional: true - seroval@1.5.6: {} + seroval@1.5.6: + optional: true serve-static@2.2.1: dependencies: @@ -4193,8 +4170,10 @@ snapshots: send: 1.2.1 transitivePeerDependencies: - supports-color + optional: true - setprototypeof@1.2.0: {} + setprototypeof@1.2.0: + optional: true shebang-command@2.0.0: dependencies: @@ -4206,6 +4185,7 @@ snapshots: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 + optional: true side-channel-map@1.0.1: dependencies: @@ -4213,6 +4193,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 object-inspect: 1.13.4 + optional: true side-channel-weakmap@1.0.2: dependencies: @@ -4221,6 +4202,7 @@ snapshots: get-intrinsic: 1.3.0 object-inspect: 1.13.4 side-channel-map: 1.0.1 + optional: true side-channel@1.1.1: dependencies: @@ -4229,6 +4211,7 @@ snapshots: side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + optional: true siginfo@2.0.0: {} @@ -4249,6 +4232,7 @@ snapshots: csstype: 3.2.3 seroval: 1.5.6 seroval-plugins: 1.5.6(seroval@1.5.6) + optional: true source-map-js@1.2.1: {} @@ -4263,11 +4247,13 @@ snapshots: stackback@0.0.2: {} - statuses@2.0.2: {} + statuses@2.0.2: + optional: true std-env@4.1.0: {} - streamsearch@1.1.0: {} + streamsearch@1.1.0: + optional: true string_decoder@1.3.0: dependencies: @@ -4284,6 +4270,7 @@ snapshots: strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 + optional: true sucrase@3.35.1: dependencies: @@ -4315,6 +4302,7 @@ snapshots: zimmerframe: 1.1.4 transitivePeerDependencies: - '@typescript-eslint/types' + optional: true symbol-tree@3.2.4: {} @@ -4366,13 +4354,15 @@ snapshots: dependencies: is-number: 7.0.0 - toidentifier@1.0.1: {} + toidentifier@1.0.1: + optional: true token-types@6.1.2: dependencies: '@borewit/text-codec': 0.2.2 '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + optional: true tough-cookie@6.0.1: dependencies: @@ -4386,7 +4376,8 @@ snapshots: ts-interface-checker@0.1.13: {} - tslib@2.8.1: {} + tslib@2.8.1: + optional: true tsup@8.5.1(postcss@8.5.22)(typescript@5.9.3): dependencies: @@ -4424,14 +4415,17 @@ snapshots: dependencies: media-typer: 0.3.0 mime-types: 2.1.35 + optional: true type-is@2.1.0: dependencies: content-type: 2.0.0 media-typer: 1.1.1 mime-types: 3.0.2 + optional: true - typedarray@0.0.6: {} + typedarray@0.0.6: + optional: true typescript@5.9.3: {} @@ -4440,8 +4434,10 @@ snapshots: uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 + optional: true - uint8array-extras@1.5.0: {} + uint8array-extras@1.5.0: + optional: true undici-types@7.24.6: {} @@ -4449,11 +4445,13 @@ snapshots: universalify@0.1.2: {} - unpipe@1.0.0: {} + unpipe@1.0.0: + optional: true util-deprecate@1.0.2: {} - vary@1.1.2: {} + vary@1.1.2: + optional: true vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7): dependencies: @@ -4504,6 +4502,7 @@ snapshots: '@vue/shared': 3.5.40 optionalDependencies: typescript: 5.9.3 + optional: true w3c-xmlserializer@5.0.0: dependencies: @@ -4532,10 +4531,9 @@ snapshots: wrappy@1.0.2: {} - ws@8.21.0: {} - xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} - zimmerframe@1.1.4: {} + zimmerframe@1.1.4: + optional: true diff --git a/scripts/check-ecosystem-package.mjs b/scripts/check-node-canvas-package.mjs similarity index 89% rename from scripts/check-ecosystem-package.mjs rename to scripts/check-node-canvas-package.mjs index 7882de2..5105c6f 100644 --- a/scripts/check-ecosystem-package.mjs +++ b/scripts/check-node-canvas-package.mjs @@ -16,32 +16,13 @@ const ROOT = resolve(import.meta.dirname, ".."); const packageRoot = resolve(process.argv[2] ?? "."); const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); const expectedEntries = { - "@graphrefly/nestjs": { - ".": ["GraphReq", "createNestGraphBoundaryRunner", "fromNestReq", "getNestBoundaryBindings"], - "./microservices": ["GraphMessage", "createGraphMessageBridge", "provideGraphMessageProviders"], - "./native": [ - "createGraphExceptionFilter", - "createNestGraphGuardAwaitScope", - "provideGraphNativeProviders", - ], - "./websockets": ["GraphWs", "createGraphWsBridge", "provideGraphWsProviders"], - }, "@graphrefly/reactive-layout-node-canvas": { ".": ["nodeCanvasPackageTextMeasurements"], }, - "@graphrefly/solid": { - ".": ["createNodeInput", "createNodeRecord", "createNodeValue"], - }, - "@graphrefly/svelte": { - ".": ["nodeReadable", "nodeRecord", "nodeWritable"], - }, - "@graphrefly/vue": { - ".": ["useNodeInput", "useNodeRecord", "useNodeValue"], - }, }[packageJson.name]; function fail(message) { - throw new Error(`check-ecosystem-package: ${message}`); + throw new Error(`check-node-canvas-package: ${message}`); } function assert(condition, message) { @@ -223,4 +204,4 @@ unsubscribe(); rmSync(externalCwd, { recursive: true, force: true }); } -console.log(`check-ecosystem-package: ${packageJson.name} ESM/CJS/DTS smoke passed`); +console.log(`check-node-canvas-package: ${packageJson.name} ESM/CJS/DTS smoke passed`);