@unoverse-platform/studio 0.1.90 → 0.1.92

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.
Files changed (132) hide show
  1. package/local/plugin.ts +21 -65
  2. package/package.json +13 -3
  3. package/vendor/base/src/agent-mcp/discovery.ts +203 -0
  4. package/vendor/base/src/agent-mcp/handoff.ts +56 -0
  5. package/vendor/base/src/agent-mcp/index.ts +44 -0
  6. package/vendor/base/src/agent-mcp/invoke.ts +153 -0
  7. package/vendor/base/src/agent-mcp/lean.ts +114 -0
  8. package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
  9. package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
  10. package/vendor/base/src/agent-mcp/rowComponents.ts +234 -0
  11. package/vendor/base/src/agent-mcp/tools.ts +134 -0
  12. package/vendor/base/src/agent-mcp/types.ts +138 -0
  13. package/vendor/base/src/auth/auth.ts +307 -0
  14. package/vendor/base/src/auth/authConfig.ts +73 -0
  15. package/vendor/base/src/boot.ts +245 -0
  16. package/vendor/base/src/definitions/definitions.ts +18 -0
  17. package/vendor/base/src/definitions/dsPackage.ts +45 -0
  18. package/vendor/base/src/definitions/fsCache.ts +155 -0
  19. package/vendor/base/src/definitions/index.ts +19 -0
  20. package/vendor/base/src/definitions/inputs.ts +30 -0
  21. package/vendor/base/src/definitions/load/compile/component.ts +80 -0
  22. package/vendor/base/src/definitions/load/compile/index.ts +12 -0
  23. package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
  24. package/vendor/base/src/definitions/load/compile/template.ts +153 -0
  25. package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
  26. package/vendor/base/src/definitions/load/expand/index.ts +17 -0
  27. package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
  28. package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
  29. package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
  30. package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
  31. package/vendor/base/src/definitions/load/load.ts +487 -0
  32. package/vendor/base/src/definitions/model.ts +238 -0
  33. package/vendor/base/src/definitions/registry/catalog.ts +659 -0
  34. package/vendor/base/src/definitions/registry/homes.ts +252 -0
  35. package/vendor/base/src/definitions/theme.ts +383 -0
  36. package/vendor/base/src/executor.ts +284 -0
  37. package/vendor/base/src/http/uwsFetchAdapter.ts +397 -0
  38. package/vendor/base/src/items/baseVersion.ts +96 -0
  39. package/vendor/base/src/items/collect.ts +246 -0
  40. package/vendor/base/src/items/fingerprint.ts +34 -0
  41. package/vendor/base/src/items/publish.ts +212 -0
  42. package/vendor/base/src/lint/context.mjs +56 -0
  43. package/vendor/base/src/lint/design/defs.mjs +46 -0
  44. package/vendor/base/src/lint/design/file.mjs +737 -0
  45. package/vendor/base/src/lint/design/index.d.mts +30 -0
  46. package/vendor/base/src/lint/design/index.mjs +996 -0
  47. package/vendor/base/src/lint/design/tokens.mjs +128 -0
  48. package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
  49. package/vendor/base/src/lint/design/walk.mjs +444 -0
  50. package/vendor/base/src/lint/index.mjs +28 -0
  51. package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
  52. package/vendor/base/src/lint/nodes/index.d.mts +28 -0
  53. package/vendor/base/src/lint/nodes/index.mjs +82 -0
  54. package/vendor/base/src/lint/nodes/load.mjs +126 -0
  55. package/vendor/base/src/lint/nodes/node.mjs +764 -0
  56. package/vendor/base/src/lint/nodes/package.mjs +112 -0
  57. package/vendor/base/src/lint/nodes/schema.mjs +75 -0
  58. package/vendor/base/src/logger.ts +33 -0
  59. package/vendor/base/src/manifests/compose.ts +539 -0
  60. package/vendor/base/src/manifests/executor/callback.ts +107 -0
  61. package/vendor/base/src/manifests/executor/context.ts +110 -0
  62. package/vendor/base/src/manifests/executor/index.ts +47 -0
  63. package/vendor/base/src/manifests/executor/lookup.ts +21 -0
  64. package/vendor/base/src/manifests/executor/memory.ts +62 -0
  65. package/vendor/base/src/manifests/executor/promise.ts +71 -0
  66. package/vendor/base/src/manifests/executor/refire.ts +34 -0
  67. package/vendor/base/src/manifests/executor/send.ts +65 -0
  68. package/vendor/base/src/manifests/executor/session.ts +46 -0
  69. package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
  70. package/vendor/base/src/manifests/index.ts +280 -0
  71. package/vendor/base/src/manifests/integrity.ts +93 -0
  72. package/vendor/base/src/manifests/rows.ts +33 -0
  73. package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
  74. package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
  75. package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
  76. package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
  77. package/vendor/base/src/manifests/runtime/channels/service.ts +138 -0
  78. package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
  79. package/vendor/base/src/manifests/runtime/context.ts +156 -0
  80. package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
  81. package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
  82. package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
  83. package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
  84. package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
  85. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
  86. package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
  87. package/vendor/base/src/manifests/runtime/events.ts +223 -0
  88. package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
  89. package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
  90. package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
  91. package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
  92. package/vendor/base/src/manifests/runtime/index.ts +57 -0
  93. package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
  94. package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
  95. package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
  96. package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
  97. package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
  98. package/vendor/base/src/manifests/runtime/state.ts +171 -0
  99. package/vendor/base/src/manifests/runtime/templating.ts +110 -0
  100. package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
  101. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +550 -0
  102. package/vendor/base/src/manifests/runtime/usage.ts +87 -0
  103. package/vendor/base/src/manifests/source.ts +196 -0
  104. package/vendor/base/src/manifests/watch.ts +67 -0
  105. package/vendor/base/src/markdown/document.ts +141 -0
  106. package/vendor/base/src/paths.ts +180 -0
  107. package/vendor/base/src/platform/audioManager.ts +100 -0
  108. package/vendor/base/src/platform/clientTransport.ts +65 -0
  109. package/vendor/base/src/platform/executionContext.ts +157 -0
  110. package/vendor/base/src/platform/index.ts +10 -0
  111. package/vendor/base/src/platform/pluginAPI.ts +146 -0
  112. package/vendor/base/src/platform/redis.ts +54 -0
  113. package/vendor/base/src/platform/serviceCalls.ts +222 -0
  114. package/vendor/base/src/pluginBase.ts +248 -0
  115. package/vendor/base/src/plugins/README.md +57 -0
  116. package/vendor/base/src/plugins/discovery.ts +87 -0
  117. package/vendor/base/src/plugins/index.ts +16 -0
  118. package/vendor/base/src/plugins/loader.ts +179 -0
  119. package/vendor/base/src/plugins/redis.ts +36 -0
  120. package/vendor/base/src/plugins/state.ts +53 -0
  121. package/vendor/base/src/registry.ts +95 -0
  122. package/vendor/base/src/security/permissions.ts +93 -0
  123. package/vendor/base/src/security/rules.ts +73 -0
  124. package/vendor/base/src/template/SafeExpression.ts +556 -0
  125. package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
  126. package/vendor/base/src/template/index.ts +22 -0
  127. package/vendor/base/src/types.ts +50 -0
  128. package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
  129. package/vendor/base/src/workflow/PlatformMemoryCore.ts +449 -0
  130. package/vendor/base/src/workflow/routing.ts +122 -0
  131. package/vendor/sdk/lib/core/store.ts +26 -6
  132. package/local/mcp.ts +0 -265
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Node Execution - Handles executing nodes (PromiseNode, CallbackNode, ServiceCall)
3
+ */
4
+
5
+ import type { NodeDefinition } from "./types.js";
6
+ import { getNode } from "./registry.js";
7
+ import { buildExecutionContext } from "./platform/index.js";
8
+
9
+ /**
10
+ * Create a logger for a node type - outputs structured JSON for Loki/Promtail
11
+ */
12
+ function createLogger(nodeType: string) {
13
+ const log = (level: string, ...args: any[]) => {
14
+ const msg = args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ");
15
+ console.log(JSON.stringify({ level, nodeType, msg, service: "node-service" }));
16
+ };
17
+ return {
18
+ info: (...args: any[]) => log("info", ...args),
19
+ error: (...args: any[]) => log("error", ...args),
20
+ debug: (...args: any[]) => log("debug", ...args),
21
+ warn: (...args: any[]) => log("warn", ...args),
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Execute a PromiseNode (single execution, returns result)
27
+ */
28
+ async function executePromiseNode(nodeDef: NodeDefinition, inputs: any, config: any, context: any) {
29
+ const ExecutorClass = nodeDef.executor.executor || nodeDef.executor;
30
+ const executor = new ExecutorClass(nodeDef.type);
31
+
32
+ // Inject logger into executor (base classes expect this.logger)
33
+ executor.logger = createLogger(nodeDef.type);
34
+
35
+ // Ensure base class methods are available (some executors call this.validateAndGetContext)
36
+ if (!executor.validateAndGetContext) {
37
+ executor.validateAndGetContext = (ctx: any) => ({
38
+ workflowId: ctx?.workflowId || ctx?.workflow?.id || "",
39
+ executionId: ctx?.executionId || ctx?.workflow?.runId || "",
40
+ nodeId: ctx?.nodeId || "",
41
+ });
42
+ }
43
+
44
+ if (!executor.execute || typeof executor.execute !== "function") {
45
+ throw new Error(`Node ${nodeDef.type} executor does not have an execute method`);
46
+ }
47
+
48
+ const executionContext = buildExecutionContext(nodeDef.type, inputs, config, context);
49
+ return await executor.execute(inputs, config, executionContext);
50
+ }
51
+
52
+ /**
53
+ * Execute a CallbackNode (generator, emits multiple outputs)
54
+ * Returns an async generator that yields outputs
55
+ */
56
+ export async function* executeCallbackNode(nodeDef: NodeDefinition, inputs: any, config: any, context: any) {
57
+ const ExecutorClass = nodeDef.executor.executor || nodeDef.executor;
58
+ const executor = new ExecutorClass(nodeDef.type);
59
+
60
+ // Inject logger into executor (base classes expect this.logger)
61
+ executor.logger = createLogger(nodeDef.type);
62
+
63
+ // Ensure base class methods are available (some executors call this.validateAndGetContext)
64
+ if (!executor.validateAndGetContext) {
65
+ executor.validateAndGetContext = (ctx: any) => ({
66
+ workflowId: ctx?.workflowId || ctx?.workflow?.id || "",
67
+ executionId: ctx?.executionId || ctx?.workflow?.runId || "",
68
+ nodeId: ctx?.nodeId || "",
69
+ });
70
+ }
71
+
72
+ if (!executor.initializeState || !executor.handleEvent) {
73
+ // A PromiseNode on the stream path: design-system nodes ride the callback-actor
74
+ // lifecycle (template uiComponent) but their universal executor is a PromiseNode
75
+ // that outputs at the end (UNOVERSE_NODE_RUNTIME.md). One output, then done.
76
+ if (typeof (executor as any).execute === "function") {
77
+ yield await executePromiseNode(nodeDef, inputs, config, context);
78
+ return;
79
+ }
80
+ throw new Error(`Node ${nodeDef.type} is a CallbackNode but missing initializeState or handleEvent`);
81
+ }
82
+
83
+ const executionContext = buildExecutionContext(nodeDef.type, inputs, config, context);
84
+
85
+ // Set executionContext on executor for nodes that need it (e.g., OpenAIStream)
86
+ executor.executionContext = executionContext;
87
+
88
+ // Initialize state
89
+ let state = executor.initializeState(inputs);
90
+
91
+ // Queue-based streaming: emit() pushes to queue, generator yields from queue
92
+ const outputQueue: any[] = [];
93
+ const state_: { resolver: ((v: { value: any; done: boolean }) => void) | null; done: boolean } = {
94
+ resolver: null,
95
+ done: false,
96
+ };
97
+
98
+ // emit() either resolves a waiting consumer or queues the output
99
+ let emitCount = 0;
100
+ const emit = (output: any) => {
101
+ emitCount++;
102
+ if (state_.resolver) {
103
+ const resolve = state_.resolver;
104
+ state_.resolver = null;
105
+ resolve({ value: output, done: false });
106
+ } else {
107
+ outputQueue.push(output);
108
+ }
109
+ };
110
+
111
+ // Run execution in background - don't await it
112
+ const executionPromise = (async () => {
113
+ try {
114
+ // Pass executionContext as 4th parameter for nodes that need it (e.g., AWSNovaSpeech)
115
+ state = await executor.handleEvent({ type: "EXECUTE", inputs, config }, state, emit, executionContext);
116
+
117
+ // Only auto-continue if node is NOT yielding for user input (focusInputRequired).
118
+ // Guard against a node that never completes and makes no progress: a signal-driven
119
+ // node (e.g. Loop) run standalone takes "no action" on CONTINUE and never sets
120
+ // isComplete, which would spin forever and flood logs. Break after repeated
121
+ // no-progress iterations (no new emit AND unchanged state), plus a hard backstop.
122
+ const stateSig = (s: any) => {
123
+ try {
124
+ return JSON.stringify(s ?? null);
125
+ } catch {
126
+ return String(emitCount); // uncomparable state → fall back to emit progress
127
+ }
128
+ };
129
+ // The signature is consulted ONLY when emits have stalled: stringifying the full
130
+ // node state per iteration is O(state size) per chunk — O(n²) over a long stream
131
+ // for a state-accumulating node (message history, streamed text). Emit progress
132
+ // alone proves progress on the common path, so skip the stringify entirely there.
133
+ let prevSig: string | null = null;
134
+ let prevEmit = emitCount;
135
+ let stalls = 0;
136
+ let total = 0;
137
+ while (!state.isComplete && !state.focusInputRequired) {
138
+ if (++total > 100000) break; // hard backstop
139
+ state = await executor.handleEvent(
140
+ { type: "CONTINUE", inputs: { continue: true }, config },
141
+ state,
142
+ emit,
143
+ executionContext,
144
+ );
145
+ if (emitCount !== prevEmit) {
146
+ stalls = 0;
147
+ prevSig = null; // stale — recompute only if emits stall again
148
+ } else {
149
+ const sig = stateSig(state);
150
+ if (prevSig !== null && sig !== prevSig) {
151
+ stalls = 0;
152
+ } else if (prevSig !== null && ++stalls >= 3) {
153
+ // No new emit and unchanged state across a few CONTINUEs → the node is
154
+ // signal-driven (e.g. Loop) and won't advance standalone. Stop quietly.
155
+ break;
156
+ }
157
+ prevSig = sig;
158
+ }
159
+ prevEmit = emitCount;
160
+ }
161
+ } finally {
162
+ state_.done = true;
163
+ // Signal completion to any waiting consumer
164
+ if (state_.resolver) {
165
+ const resolve = state_.resolver;
166
+ state_.resolver = null;
167
+ resolve({ value: undefined, done: true });
168
+ }
169
+ }
170
+ })();
171
+
172
+ // Yield outputs as they arrive
173
+ try {
174
+ while (true) {
175
+ // First drain the queue
176
+ if (outputQueue.length > 0) {
177
+ yield outputQueue.shift();
178
+ continue;
179
+ }
180
+
181
+ // If execution is done, drain any remaining queue items before finishing
182
+ if (state_.done) {
183
+ // CRITICAL: Yield any remaining queued outputs before exiting
184
+ // This ensures the final emit() is always sent to the client
185
+ while (outputQueue.length > 0) {
186
+ yield outputQueue.shift();
187
+ }
188
+ break;
189
+ }
190
+
191
+ // Wait for next output or completion
192
+ const result = await new Promise<{ value: any; done: boolean }>((resolve) => {
193
+ state_.resolver = resolve;
194
+ });
195
+
196
+ if (result.done) {
197
+ break;
198
+ }
199
+
200
+ yield result.value;
201
+ }
202
+ } finally {
203
+ // Ensure execution completes even if generator is abandoned
204
+ await executionPromise;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Execute a service call on a node (calls handleServiceCall method)
210
+ * Used for service nodes like OpenAIEmbeddingService, BedrockEmbeddingService
211
+ */
212
+ export async function executeServiceCall(
213
+ nodeType: string,
214
+ method: string,
215
+ params: any,
216
+ config: any,
217
+ context: any,
218
+ ): Promise<any> {
219
+ const nodeDef = getNode(nodeType);
220
+
221
+ if (!nodeDef) {
222
+ throw new Error(`Node type not found: ${nodeType}`);
223
+ }
224
+
225
+ if (!nodeDef.executor) {
226
+ throw new Error(`No executor found for node type: ${nodeType}`);
227
+ }
228
+
229
+ const ExecutorClass = nodeDef.executor.executor || nodeDef.executor;
230
+ /**
231
+ * WITH THE NODE TYPE, like the two other construction sites in this file.
232
+ *
233
+ * A code node's executor hardcodes its own type in its constructor, so `new ExecutorClass()`
234
+ * worked for every one of them and the missing argument sat here unnoticed. The two SHARED
235
+ * manifest classes cannot: one class serves every manifest node, so the type IS the
236
+ * argument, and without it `this.nodeType` is undefined and the lookup fails with
237
+ * `No manifest loaded for node type "undefined"`.
238
+ *
239
+ * It broke the SERVICE channel only, which is why it survived the whole migration: every
240
+ * manifest node so far is reached by the graph (executePromiseNode / executeCallbackNode
241
+ * above, both of which pass it). SpatialSearch is the first manifest node that is ONLY ever
242
+ * called as a service, so it is the first to hit this line.
243
+ */
244
+ const executor = new ExecutorClass(nodeDef.type);
245
+
246
+ // Inject logger into executor (base classes expect this.logger)
247
+ executor.logger = createLogger(nodeType);
248
+
249
+ if (!executor.handleServiceCall || typeof executor.handleServiceCall !== "function") {
250
+ throw new Error(`Node ${nodeType} executor does not have a handleServiceCall method`);
251
+ }
252
+
253
+ const executionContext = buildExecutionContext(nodeType, {}, config, context);
254
+ return await executor.handleServiceCall(method, params, config, executionContext);
255
+ }
256
+
257
+ /**
258
+ * Execute a node (dispatches to correct executor based on type)
259
+ */
260
+ export async function executeNode(nodeType: string, inputs: any, config: any, context: any) {
261
+ const nodeDef = getNode(nodeType);
262
+
263
+ if (!nodeDef) {
264
+ throw new Error(`Node type not found: ${nodeType}`);
265
+ }
266
+
267
+ if (!nodeDef.executor) {
268
+ throw new Error(`No executor found for node type: ${nodeType}`);
269
+ }
270
+
271
+ // PromiseNode - single execution
272
+ if (nodeDef.executionMode === "single") {
273
+ return await executePromiseNode(nodeDef, inputs, config, context);
274
+ }
275
+
276
+ // CallbackNode - collect all outputs and return final result
277
+ const outputs: any[] = [];
278
+ for await (const output of executeCallbackNode(nodeDef, inputs, config, context)) {
279
+ outputs.push(output);
280
+ }
281
+
282
+ // Return the last output (final result)
283
+ return outputs[outputs.length - 1] || {};
284
+ }
@@ -0,0 +1,397 @@
1
+ /**
2
+ * uWS ↔ Web-Fetch adapter — the ONLY bespoke transport code.
3
+ *
4
+ * The MCP SDK's transport is framework-agnostic: `WebStandardStreamableHTTPServerTransport`
5
+ * exposes `handleRequest(req: Request) => Promise<Response>` — the exact entry the SDK
6
+ * documents for Hono / Cloudflare Workers / Deno / Bun. So we do NOT hand-roll the MCP
7
+ * protocol: this adapter just converts a uWS request into a Web `Request`, and writes the
8
+ * Web `Response` (single-shot JSON, or a long-lived SSE stream) back onto the uWS
9
+ * `HttpResponse`. All MCP protocol logic stays inside the SDK transport.
10
+ *
11
+ * uWS rules honored here (these are the easy things to get wrong):
12
+ * - `HttpRequest` is valid only synchronously → method/url/headers are read up-front.
13
+ * - `onAborted` MUST be registered before the first `await`, or uWS throws on disconnect.
14
+ * - every write happens inside `res.cork()`.
15
+ * - body chunks are copied synchronously (uWS reuses the chunk's backing memory).
16
+ */
17
+ import { gzip as gzipCallback, createGzip, constants as zlibConstants } from "node:zlib";
18
+ import { promisify } from "node:util";
19
+ import { Readable } from "node:stream";
20
+ import { pipeline } from "node:stream/promises";
21
+ import type { HttpRequest, HttpResponse } from "uWebSockets.js";
22
+
23
+ const gzipAsync = promisify(gzipCallback);
24
+
25
+ const STATUS_TEXT: Record<number, string> = {
26
+ 200: "OK",
27
+ 202: "Accepted",
28
+ 204: "No Content",
29
+ 304: "Not Modified",
30
+ 400: "Bad Request",
31
+ 404: "Not Found",
32
+ 405: "Method Not Allowed",
33
+ 406: "Not Acceptable",
34
+ 500: "Internal Server Error",
35
+ };
36
+ const statusLine = (code: number): string => `${code} ${STATUS_TEXT[code] ?? "OK"}`;
37
+
38
+ /** Text-ish payloads compress ~4:1; anything else (images, audio) is already packed. */
39
+ const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|vnd\.unoverse))/;
40
+ /** Below this, the gzip header + CPU outweigh the shrink. */
41
+ const COMPRESS_MIN_BYTES = 1024;
42
+ /**
43
+ * BUFFER THEN SPILL. A single-shot body is held up to this many bytes so a small
44
+ * response (every MCP reply, most JSON) still goes out in ONE write with an exact
45
+ * length and the cached-gzip path. A body that runs past it is STREAMED from here:
46
+ * head first, then chunks as they arrive, gzip applied on the fly. Before this every
47
+ * response was buffered whole, so time-to-first-byte equalled time-to-last-byte, and a
48
+ * large proxied list cost its full size twice in memory (buffer plus gzip output).
49
+ */
50
+ const STREAM_AFTER_BYTES = 64 * 1024;
51
+
52
+ type PumpState = { aborted: boolean; reader?: ReadableStreamDefaultReader<Uint8Array>; drained?: () => void };
53
+
54
+ /** Status, the upstream headers uWS must not see twice, encoding, CORS. Inside a cork. */
55
+ function writeHead(res: HttpResponse, response: Response, gzip: boolean): void {
56
+ res.writeStatus(statusLine(response.status));
57
+ response.headers.forEach((value, key) => {
58
+ const k = key.toLowerCase();
59
+ // uWS generates these itself on every response. Forwarding an upstream copy
60
+ // (e.g. the engine's own `date`) yields a DUPLICATE header — nginx just warns
61
+ // and serves 200, but HTTP/3 (QUIC) rejects the malformed header block with
62
+ // QUIC_PACKET_READ_ERROR. Skip them so uWS's single copy is authoritative.
63
+ // access-control-*: writeCors() below is authoritative — forwarding an upstream
64
+ // copy too produced the doubled `access-control-allow-origin: *,*` seen live.
65
+ if (k === "content-length" || k === "date" || k === "server" || k.startsWith("access-control-")) return;
66
+ res.writeHeader(key, value);
67
+ });
68
+ if (gzip) {
69
+ res.writeHeader("Content-Encoding", "gzip");
70
+ // Encoding varies by the request's Accept-Encoding, so shared caches must key on it.
71
+ res.writeHeader("Vary", "Accept-Encoding");
72
+ }
73
+ writeCors(res);
74
+ }
75
+
76
+ /**
77
+ * One chunk onto the socket, honouring BACKPRESSURE. `res.write` returns false when
78
+ * uWS's socket buffer is full; the bytes are queued, but reading on regardless meant a
79
+ * stalled client (a backgrounded tab, a slow tunnel) grew that queue without bound
80
+ * while the engine kept streaming into it. Park until uWS reports the socket writable
81
+ * again, or the client aborts (onAborted wakes the parked pump).
82
+ */
83
+ async function writeChunk(res: HttpResponse, state: PumpState, chunk: Uint8Array | string): Promise<void> {
84
+ let ok = true;
85
+ res.cork(() => {
86
+ ok = res.write(chunk);
87
+ });
88
+ if (ok || state.aborted) return;
89
+ await new Promise<void>((resolve) => {
90
+ state.drained = () => {
91
+ state.drained = undefined;
92
+ resolve();
93
+ };
94
+ res.onWritable(() => {
95
+ state.drained?.();
96
+ return true;
97
+ });
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Compressed bytes for responses that carry an ETag, keyed on it. An ETag says the body
103
+ * is a fixed thing (the app shell, the embed bundle, keyed on the build's mtime), and
104
+ * those are the biggest payloads this server sends: 427KB of shell and 186KB of embed
105
+ * were gzipped afresh on EVERY 200, ~10-30ms of CPU each. Bounded and insertion-evicted;
106
+ * a new build has a new ETag, so a stale entry is never served, only aged out.
107
+ */
108
+ const GZIP_CACHE_MAX = 32;
109
+ const gzipCache = new Map<string, Buffer>();
110
+
111
+ /** gzip `payload` OFF the event loop (zlib's threadpool), reusing the cached bytes
112
+ * when the response names itself with an ETag. The old `gzipSync` ran on the same
113
+ * loop that carries live audio frames and every SSE pump. */
114
+ async function compress(payload: Buffer, etag: string | null, contentType: string): Promise<Buffer> {
115
+ const key = etag ? `${etag}|${contentType}|${payload.length}` : null;
116
+ if (key) {
117
+ const hit = gzipCache.get(key);
118
+ if (hit) return hit;
119
+ }
120
+ const gz = await gzipAsync(payload);
121
+ if (key) {
122
+ if (gzipCache.size >= GZIP_CACHE_MAX) gzipCache.delete(gzipCache.keys().next().value as string);
123
+ gzipCache.set(key, gz);
124
+ }
125
+ return gz;
126
+ }
127
+
128
+ function writeCors(res: HttpResponse): void {
129
+ // The workbench is same-origin (via the Vite proxy) so needs none of this; kept so
130
+ // external MCP clients / the Inspector / the canvas can connect cross-origin.
131
+ res.writeHeader("Access-Control-Allow-Origin", "*");
132
+ res.writeHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
133
+ // `ngrok-skip-browser-warning` (2026-08-28): the webSDK sends it on ngrok-hosted dev
134
+ // tunnels to bypass the free tier's interstitial; a custom header makes the browser
135
+ // PREFLIGHT, and a preflight that doesn't allow it fails the whole request ("blocked
136
+ // by CORS: header field not allowed") — observed live inside ChatGPT's widget sandbox.
137
+ // Harmless to allow always: it's only ever SENT to ngrok hosts.
138
+ res.writeHeader(
139
+ "Access-Control-Allow-Headers",
140
+ "Content-Type, Authorization, mcp-session-id, mcp-protocol-version, ngrok-skip-browser-warning",
141
+ );
142
+ res.writeHeader("Access-Control-Expose-Headers", "mcp-session-id, mcp-protocol-version");
143
+ // A PREFLIGHT IS A ROUND TRIP THAT CARRIES NO DATA, AND WITHOUT THIS THE BROWSER REPEATS
144
+ // IT CONSTANTLY. Chrome's default preflight cache is 5 seconds, so an app making a series
145
+ // of MCP calls pays OPTIONS → 204 → the real request, doubling the round trips on nearly
146
+ // every one. On a universe served from one region that is the dominant cost of loading an
147
+ // app: measured London → UAE, a round trip is ~150ms and the server's own processing is
148
+ // ~0ms, so the preflights were most of the wait, not the work.
149
+ //
150
+ // 24h is the practical ceiling (Chromium caps at 2h, Firefox at 24h; both clamp rather
151
+ // than ignore). The response being cached says only which methods and headers are allowed,
152
+ // which is static in this file — no authorization decision is cached, because auth is
153
+ // checked on the real request every time.
154
+ res.writeHeader("Access-Control-Max-Age", "86400");
155
+ }
156
+
157
+ /** Buffer the full request body; resolves on the last chunk (copies as it goes). */
158
+ function readBody(res: HttpResponse): Promise<Buffer> {
159
+ return new Promise((resolve) => {
160
+ // Collect copies and concat ONCE at the end. The old per-chunk
161
+ // `Buffer.concat([buf, chunk])` reallocated+copied the whole accumulator on every
162
+ // chunk — O(n²) in body size, which hurts large /execute payloads. Each chunk is
163
+ // still copied synchronously (uWS reuses the chunk's backing memory after return).
164
+ const chunks: Buffer[] = [];
165
+ res.onData((chunk, isLast) => {
166
+ // COPY each chunk synchronously NOW — uWS detaches/reuses the chunk's backing
167
+ // ArrayBuffer after this callback returns. `Buffer.from(arrayBuffer)` is only a
168
+ // VIEW (no copy), so wrap in a Uint8Array, which Buffer.from then copies; deferring
169
+ // a bare view to the final concat would read detached memory.
170
+ chunks.push(Buffer.from(new Uint8Array(chunk)));
171
+ if (isLast) resolve(chunks.length === 1 ? chunks[0] : Buffer.concat(chunks));
172
+ });
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Drive a uWS request through a Web-fetch handler: OPTIONS preflight, body buffering,
178
+ * and pumping the Response (single-shot JSON or long-lived SSE) back to uWS.
179
+ */
180
+ export async function handleFetch(
181
+ res: HttpResponse,
182
+ req: HttpRequest,
183
+ handler: (request: Request) => Promise<Response>,
184
+ ): Promise<void> {
185
+ const state: PumpState = { aborted: false };
186
+ // THE HANDLER LEARNS OF THE DISCONNECT. `Request.signal` is how a Fetch handler is told
187
+ // its caller went away; without it a streaming handler (a node that runs for minutes)
188
+ // kept working for a client that had hung up, and an engine that had reaped a run could
189
+ // not stop the node it had started. Observed live 2026-09-05.
190
+ const abort = new AbortController();
191
+ res.onAborted(() => {
192
+ state.aborted = true;
193
+ abort.abort();
194
+ // A pump parked on backpressure must wake to see the abort, or it (and its
195
+ // keepalive timer) would hang for the life of the process.
196
+ state.drained?.();
197
+ void state.reader?.cancel().catch(() => {});
198
+ });
199
+
200
+ // HttpRequest is only valid synchronously — capture everything now.
201
+ const method = req.getMethod().toUpperCase();
202
+ const query = req.getQuery();
203
+ const url = `http://localhost${req.getUrl()}${query ? `?${query}` : ""}`;
204
+ const headers: Record<string, string> = {};
205
+ req.forEach((k, v) => {
206
+ headers[k] = v;
207
+ });
208
+
209
+ if (method === "OPTIONS") {
210
+ res.cork(() => {
211
+ res.writeStatus(statusLine(204));
212
+ writeCors(res);
213
+ res.endWithoutBody();
214
+ });
215
+ return;
216
+ }
217
+
218
+ const body = method !== "GET" && method !== "HEAD" ? await readBody(res) : undefined;
219
+ if (state.aborted) return;
220
+
221
+ let response: Response;
222
+ try {
223
+ const init: RequestInit = { method, headers, signal: abort.signal };
224
+ if (body && body.length) init.body = new Uint8Array(body); // BodyInit-compatible copy of the Buffer
225
+ response = await handler(new Request(url, init));
226
+ } catch (err) {
227
+ // Log the detail server-side; return a GENERIC message to the client (no internal
228
+ // detail leakage — MCP security best-practices).
229
+ console.error("[unoverse] request handler error:", err);
230
+ if (!state.aborted) {
231
+ res.cork(() => {
232
+ res.writeStatus(statusLine(500));
233
+ res.writeHeader("Content-Type", "application/json");
234
+ writeCors(res);
235
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal error" }, id: null }));
236
+ });
237
+ }
238
+ return;
239
+ }
240
+ if (state.aborted) return;
241
+
242
+ const isSse = (response.headers.get("content-type") ?? "").startsWith("text/event-stream");
243
+
244
+ // Single-shot responses (everything except SSE): buffered up to STREAM_AFTER_BYTES so
245
+ // text payloads can be gzipped in one write (neither uWS nor the DO load balancer
246
+ // compresses — the widget boot alone was ~660KB of JS/JSON that gzips to ~150KB), and
247
+ // STREAMED past that so a large body's first bytes leave before its last arrive.
248
+ if (!isSse) {
249
+ const contentType = response.headers.get("content-type") ?? "";
250
+ const acceptsGzip = /(^|,)\s*gzip\s*(;|,|$)/.test(headers["accept-encoding"] ?? "");
251
+ const compressible = acceptsGzip && COMPRESSIBLE.test(contentType) && !response.headers.get("content-encoding");
252
+
253
+ // An ETag names a FIXED body (the app shell, the embed bundle): those are buffered
254
+ // whole so the cached-gzip path serves them, however large. Only ETag-less bodies
255
+ // (proxied lists, anything dynamic) spill into streaming.
256
+ const spillAfter = response.headers.get("etag") ? Infinity : STREAM_AFTER_BYTES;
257
+ const reader = response.body?.getReader();
258
+ state.reader = reader;
259
+ const prefix: Uint8Array[] = [];
260
+ let size = 0;
261
+ let complete = !reader;
262
+ if (reader) {
263
+ try {
264
+ for (;;) {
265
+ const { done, value } = await reader.read();
266
+ if (state.aborted) {
267
+ void reader.cancel().catch(() => {});
268
+ return;
269
+ }
270
+ if (done) {
271
+ complete = true;
272
+ break;
273
+ }
274
+ if (value?.length) {
275
+ prefix.push(value);
276
+ size += value.length;
277
+ }
278
+ if (size >= spillAfter) break;
279
+ }
280
+ } catch {
281
+ complete = true; // body unreadable — end empty below, as before
282
+ prefix.length = 0;
283
+ size = 0;
284
+ }
285
+ }
286
+
287
+ if (complete) {
288
+ const payload = size ? Buffer.concat(prefix) : null;
289
+ const shouldCompress = compressible && payload !== null && payload.length >= COMPRESS_MIN_BYTES;
290
+ const body = shouldCompress && payload ? await compress(payload, response.headers.get("etag"), contentType) : payload;
291
+ if (state.aborted) return;
292
+ res.cork(() => {
293
+ writeHead(res, response, shouldCompress);
294
+ // `res.end()` for the empty case, NEVER `endWithoutBody()`. On HTTP/1.1 with no
295
+ // Content-Length, endWithoutBody() leaves the response unterminated: the client
296
+ // waits on a reply that never finishes until the socket's idle timeout kills the
297
+ // connection ~12s later. Every bodyless response paid it — the MCP handshake's
298
+ // 202 ack among them, so every app open stalled ~10s before its first paint
299
+ // (observed live on bppunoverse 2026-08-21; reproduced in isolation: a JSON
300
+ // response 28ms, a null-body 202 11.9s). end() closes with content-length: 0.
301
+ if (body && body.length) res.end(body);
302
+ else res.end();
303
+ });
304
+ return;
305
+ }
306
+
307
+ // SPILL: the body is larger than the buffer. Head now, then the prefix and the rest
308
+ // as they arrive, chunked. Compression rides a streaming gzip that flushes per input
309
+ // chunk (Z_SYNC_FLUSH), so nothing waits inside zlib for a fuller block.
310
+ res.cork(() => writeHead(res, response, compressible));
311
+ const rest = async function* (): AsyncGenerator<Uint8Array> {
312
+ yield* prefix;
313
+ for (;;) {
314
+ const { done, value } = await reader!.read();
315
+ if (done || state.aborted) return;
316
+ if (value?.length) yield value;
317
+ }
318
+ };
319
+ let source: AsyncIterable<Uint8Array> = rest();
320
+ let gz: ReturnType<typeof createGzip> | null = null;
321
+ if (compressible) {
322
+ gz = createGzip({ flush: zlibConstants.Z_SYNC_FLUSH });
323
+ void pipeline(Readable.from(rest()), gz).catch(() => {});
324
+ source = gz as AsyncIterable<Uint8Array>;
325
+ }
326
+ try {
327
+ for await (const chunk of source) {
328
+ if (state.aborted) break;
329
+ await writeChunk(res, state, chunk);
330
+ }
331
+ if (!state.aborted) res.cork(() => res.end());
332
+ } catch {
333
+ if (!state.aborted) res.cork(() => res.end());
334
+ } finally {
335
+ if (state.aborted) {
336
+ void reader!.cancel().catch(() => {});
337
+ gz?.destroy();
338
+ }
339
+ }
340
+ return;
341
+ }
342
+
343
+ // SSE: write head, then pump chunks until the transport closes the stream.
344
+ const reader = response.body?.getReader();
345
+ state.reader = reader;
346
+ res.cork(() => writeHead(res, response, false));
347
+
348
+ if (!reader) {
349
+ if (!state.aborted) res.cork(() => res.end());
350
+ return;
351
+ }
352
+
353
+ // SSE pump lifecycle. A GET stream that dies leaves its session registered while
354
+ // every push silently drops (the SDK sends nothing to a missing standalone stream),
355
+ // so these logs are the ground truth for whether a client's inbound stream exists.
356
+ const path = new URL(url).pathname;
357
+ // Session id ties a pump to its MCP session (request header on established sessions,
358
+ // response header on initialize).
359
+ const sid = headers["mcp-session-id"] ?? response.headers.get("mcp-session-id") ?? "-";
360
+ if (isSse) console.log(`[unoverse:sse] pump OPEN ${method} ${path} sid=${sid}`);
361
+
362
+ // KEEPALIVE — an idle SSE stream writes NOTHING, and a silent connection is fair game
363
+ // for any middlebox (the Vite dev proxy kills one after ~2 min; observed as "client
364
+ // aborted" mid-run, after which every push into the still-registered session drops
365
+ // silently). An SSE comment line every 25s keeps every hop alive; parsers ignore
366
+ // comments by spec, so no client sees them.
367
+ // 10s, not 25s: the client's zombie watchdog declares death after ~1.5 missed
368
+ // beats, so the beat interval directly sets how long a silently-dead stream can
369
+ // blind the canvas. Comment lines are ~18 bytes — frequency is free.
370
+ const keepalive = isSse
371
+ ? setInterval(() => {
372
+ // Not while parked on backpressure: a beat would only deepen the queue.
373
+ if (!state.aborted && !state.drained) res.cork(() => res.write(": keepalive\n\n"));
374
+ }, 10_000)
375
+ : null;
376
+
377
+ try {
378
+ for (;;) {
379
+ const { done, value } = await reader.read();
380
+ if (state.aborted) {
381
+ void reader.cancel().catch(() => {});
382
+ if (isSse) console.log(`[unoverse:sse] pump CLOSED (client aborted) ${method} ${path} sid=${sid}`);
383
+ return;
384
+ }
385
+ if (done) break;
386
+ if (value) await writeChunk(res, state, value);
387
+ }
388
+ if (!state.aborted) res.cork(() => res.end());
389
+ if (isSse) console.log(`[unoverse:sse] pump CLOSED (stream ended) ${method} ${path} sid=${sid}`);
390
+ } catch {
391
+ void reader.cancel().catch(() => {});
392
+ if (!state.aborted) res.cork(() => res.end());
393
+ if (isSse) console.log(`[unoverse:sse] pump CLOSED (pump error) ${method} ${path} sid=${sid}`);
394
+ } finally {
395
+ if (keepalive) clearInterval(keepalive);
396
+ }
397
+ }