@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.
- package/local/plugin.ts +21 -65
- package/package.json +13 -3
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +153 -0
- package/vendor/base/src/agent-mcp/lean.ts +114 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +234 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +138 -0
- package/vendor/base/src/auth/auth.ts +307 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +155 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +659 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +397 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +246 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +212 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.d.mts +28 -0
- package/vendor/base/src/lint/nodes/index.mjs +82 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +110 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +138 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +156 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +550 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +196 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +157 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +449 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
- 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
|
+
}
|