@memberjunction/server 5.46.0 → 5.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import type { Duplex } from 'node:stream';
3
+ import { WebSocket as WsClient, WebSocketServer, type RawData } from 'ws';
4
+ import { BaseSingleton, ShutdownRegistry, type IShutdownable } from '@memberjunction/global';
5
+ import { RealtimeProxyRegistry, REALTIME_PROXY_PATH, type RealtimeProxyTicketEntry } from '@memberjunction/ai';
6
+
7
+ /**
8
+ * MJAPI's realtime websocket **proxy** — the transport half of the self-hosted realtime provider story.
9
+ *
10
+ * A provider driver (e.g. `HuggingFaceRealtime`) mints a one-time ticket into the shared
11
+ * {@link RealtimeProxyRegistry} pointing at an INTERNAL realtime endpoint, and hands the browser a
12
+ * `wss://<mjapi-public>{REALTIME_PROXY_PATH}?ticket=<id>` URL. The browser opens its socket HERE; this
13
+ * proxy consumes the ticket, opens the authenticated upstream leg (injecting any auth server-side), and
14
+ * pumps frames transparently in both directions. The internal endpoint + auth never reach the browser,
15
+ * and the internal box needs no browser-facing ingress — MJAPI stays the single ingress point.
16
+ *
17
+ * Deliberately provider-agnostic: it does no protocol translation (the same-keyed client driver owns the
18
+ * wire vocabulary) and knows nothing about any specific provider — it is a pure authenticated byte tunnel,
19
+ * reusable by any future self-hosted realtime provider that mints a ticket.
20
+ *
21
+ * A {@link BaseSingleton} + {@link IShutdownable}: it tracks live tunnels and closes them on graceful
22
+ * shutdown (drained by MJServer's `ShutdownRegistry` before `httpServer.close()`).
23
+ */
24
+ export class RealtimeProxyServer extends BaseSingleton<RealtimeProxyServer> implements IShutdownable {
25
+ public readonly ShutdownName = 'RealtimeProxyServer';
26
+
27
+ /** `noServer` so THIS server never binds its own upgrade listener — MJServer routes upgrades to it by path. */
28
+ private readonly wss = new WebSocketServer({ noServer: true });
29
+
30
+ /** Live browser↔upstream tunnels, tracked for shutdown teardown. */
31
+ private readonly tunnels = new Set<RealtimeProxyTunnel>();
32
+
33
+ private registered = false;
34
+
35
+ protected constructor() {
36
+ super();
37
+ }
38
+
39
+ /** Process-wide singleton accessor. */
40
+ public static get Instance(): RealtimeProxyServer {
41
+ return super.getInstance<RealtimeProxyServer>();
42
+ }
43
+
44
+ /**
45
+ * Routes an HTTP `upgrade` for {@link REALTIME_PROXY_PATH} to the proxy. Returns `true` when it OWNS
46
+ * (handled/rejected) the request, `false` when the path is not the proxy's — so the caller leaves the
47
+ * socket for the GraphQL websocket server's own upgrade listener. NEVER destroys a socket it doesn't own.
48
+ */
49
+ public TryHandleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): boolean {
50
+ const url = RealtimeProxyServer.parseUrl(request.url);
51
+ if (!url || url.pathname !== REALTIME_PROXY_PATH) {
52
+ return false; // not ours — leave it for the GraphQL WS server
53
+ }
54
+ this.ensureRegistered();
55
+ const ticketId = url.searchParams.get('ticket') ?? '';
56
+ const entry = RealtimeProxyRegistry.Instance.Consume(ticketId);
57
+ if (!entry) {
58
+ RealtimeProxyServer.rejectUpgrade(socket, 401, 'Unauthorized');
59
+ return true;
60
+ }
61
+ this.wss.handleUpgrade(request, socket, head, (browserWs) => this.openTunnel(browserWs, entry));
62
+ return true;
63
+ }
64
+
65
+ /** Opens the upstream leg and wires a bidirectional pump between the browser socket and it. */
66
+ private openTunnel(browserWs: WsClient, entry: RealtimeProxyTicketEntry): void {
67
+ const tunnel = new RealtimeProxyTunnel(browserWs, entry, () => this.tunnels.delete(tunnel));
68
+ this.tunnels.add(tunnel);
69
+ tunnel.Start();
70
+ }
71
+
72
+ /** Registers for graceful-shutdown draining exactly once (lazily, on first real use). */
73
+ private ensureRegistered(): void {
74
+ if (!this.registered) {
75
+ ShutdownRegistry.Instance.Register(this);
76
+ this.registered = true;
77
+ }
78
+ }
79
+
80
+ /** Closes every live tunnel and the proxy server. Idempotent; never throws. */
81
+ public Shutdown(): void {
82
+ for (const tunnel of [...this.tunnels]) {
83
+ tunnel.Close();
84
+ }
85
+ this.tunnels.clear();
86
+ try {
87
+ this.wss.close();
88
+ } catch {
89
+ /* already closing */
90
+ }
91
+ }
92
+
93
+ /** Parses `request.url` (a path+query) into a URL, or `null` when absent/unparseable. */
94
+ private static parseUrl(rawUrl: string | undefined): URL | null {
95
+ if (!rawUrl) {
96
+ return null;
97
+ }
98
+ try {
99
+ return new URL(rawUrl, 'http://internal'); // base is only for parsing path+query
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /** Writes a minimal HTTP error response and destroys the socket (used for a rejected upgrade). */
106
+ private static rejectUpgrade(socket: Duplex, code: number, reason: string): void {
107
+ try {
108
+ socket.write(`HTTP/1.1 ${code} ${reason}\r\nConnection: close\r\n\r\n`);
109
+ } catch {
110
+ /* socket already gone */
111
+ }
112
+ socket.destroy();
113
+ }
114
+ }
115
+
116
+ /**
117
+ * One live browser↔upstream tunnel. Buffers browser→upstream frames until the upstream socket is open,
118
+ * then pumps both directions byte-transparently (text and binary alike). Closing either side closes the other.
119
+ */
120
+ class RealtimeProxyTunnel {
121
+ private upstream: WsClient | null = null;
122
+ /** Frames the browser sent before the upstream opened; flushed in order once it's ready. */
123
+ private readonly pending: Array<{ data: RawData; isBinary: boolean }> = [];
124
+ private closed = false;
125
+
126
+ constructor(
127
+ private readonly browser: WsClient,
128
+ private readonly entry: RealtimeProxyTicketEntry,
129
+ private readonly onClosed: () => void
130
+ ) {}
131
+
132
+ /** Opens the upstream socket and wires both legs. */
133
+ public Start(): void {
134
+ const headers = this.entry.UpstreamAuthHeader ? { Authorization: this.entry.UpstreamAuthHeader } : undefined;
135
+ const upstream = new WsClient(this.entry.UpstreamUrl, { headers });
136
+ this.upstream = upstream;
137
+
138
+ upstream.on('open', () => this.flushPending());
139
+ upstream.on('message', (data: RawData, isBinary: boolean) => this.forward(this.browser, data, isBinary));
140
+ upstream.on('close', () => this.Close());
141
+ upstream.on('error', () => this.Close());
142
+
143
+ this.browser.on('message', (data: RawData, isBinary: boolean) => this.fromBrowser(data, isBinary));
144
+ this.browser.on('close', () => this.Close());
145
+ this.browser.on('error', () => this.Close());
146
+ }
147
+
148
+ /** Browser→upstream: forward immediately when the upstream is open, else queue until it is. */
149
+ private fromBrowser(data: RawData, isBinary: boolean): void {
150
+ if (this.upstream && this.upstream.readyState === WsClient.OPEN) {
151
+ this.forward(this.upstream, data, isBinary);
152
+ } else {
153
+ this.pending.push({ data, isBinary });
154
+ }
155
+ }
156
+
157
+ /** Drains any frames queued before the upstream opened. */
158
+ private flushPending(): void {
159
+ if (!this.upstream) {
160
+ return;
161
+ }
162
+ for (const frame of this.pending) {
163
+ this.forward(this.upstream, frame.data, frame.isBinary);
164
+ }
165
+ this.pending.length = 0;
166
+ }
167
+
168
+ /** Sends one frame on a socket, preserving the text/binary distinction; failures close the tunnel. */
169
+ private forward(target: WsClient, data: RawData, isBinary: boolean): void {
170
+ if (target.readyState !== WsClient.OPEN) {
171
+ return;
172
+ }
173
+ try {
174
+ target.send(data, { binary: isBinary });
175
+ } catch {
176
+ this.Close();
177
+ }
178
+ }
179
+
180
+ /** Closes both legs and detaches the tunnel from the server. Idempotent. */
181
+ public Close(): void {
182
+ if (this.closed) {
183
+ return;
184
+ }
185
+ this.closed = true;
186
+ this.pending.length = 0;
187
+ for (const sock of [this.browser, this.upstream]) {
188
+ try {
189
+ sock?.close();
190
+ } catch {
191
+ /* already closing */
192
+ }
193
+ }
194
+ this.onClosed();
195
+ }
196
+ }
@@ -75,6 +75,9 @@ export class TestQuerySQLResult {
75
75
 
76
76
  @Field(() => String, { nullable: true, description: 'JSON-stringified applied parameters including defaults' })
77
77
  AppliedParameters?: string;
78
+
79
+ @Field(() => String, { nullable: true, description: 'The fully rendered SQL that was executed against the database. On error, reveals transformations (composition, templates, MaxRows wrapping) that may have caused the failure.' })
80
+ RenderedSQL?: string;
78
81
  }
79
82
 
80
83
  /**
@@ -133,6 +136,7 @@ export class TestQuerySQLResolver extends ResolverBase {
133
136
  ExecutionTime: result.ExecutionTime,
134
137
  ErrorMessage: result.ErrorMessage || undefined,
135
138
  AppliedParameters: result.AppliedParameters ? JSON.stringify(result.AppliedParameters) : undefined,
139
+ RenderedSQL: result.RenderedSQL || undefined,
136
140
  };
137
141
  } catch (err) {
138
142
  LogError(err);