@unoverse-platform/studio 0.1.89 → 0.1.91
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 +20 -12
- package/package.json +13 -3
- package/screens/TemplatesLibraryView.tsx +31 -6
- package/screens/TemplatesView.tsx +1 -1
- 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 +140 -0
- package/vendor/base/src/agent-mcp/lean.ts +111 -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 +172 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +130 -0
- package/vendor/base/src/auth/auth.ts +278 -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 +128 -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 +643 -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 +260 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +199 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +198 -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.mjs +64 -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 +107 -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 +125 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +150 -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 +542 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +187 -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 +154 -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 +438 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
|
@@ -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,260 @@
|
|
|
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 { gzipSync } from "node:zlib";
|
|
18
|
+
import type { HttpRequest, HttpResponse } from "uWebSockets.js";
|
|
19
|
+
|
|
20
|
+
const STATUS_TEXT: Record<number, string> = {
|
|
21
|
+
200: "OK",
|
|
22
|
+
202: "Accepted",
|
|
23
|
+
204: "No Content",
|
|
24
|
+
304: "Not Modified",
|
|
25
|
+
400: "Bad Request",
|
|
26
|
+
404: "Not Found",
|
|
27
|
+
405: "Method Not Allowed",
|
|
28
|
+
406: "Not Acceptable",
|
|
29
|
+
500: "Internal Server Error",
|
|
30
|
+
};
|
|
31
|
+
const statusLine = (code: number): string => `${code} ${STATUS_TEXT[code] ?? "OK"}`;
|
|
32
|
+
|
|
33
|
+
/** Text-ish payloads compress ~4:1; anything else (images, audio) is already packed. */
|
|
34
|
+
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|vnd\.unoverse))/;
|
|
35
|
+
/** Below this, the gzip header + CPU outweigh the shrink. */
|
|
36
|
+
const COMPRESS_MIN_BYTES = 1024;
|
|
37
|
+
|
|
38
|
+
function writeCors(res: HttpResponse): void {
|
|
39
|
+
// The workbench is same-origin (via the Vite proxy) so needs none of this; kept so
|
|
40
|
+
// external MCP clients / the Inspector / the canvas can connect cross-origin.
|
|
41
|
+
res.writeHeader("Access-Control-Allow-Origin", "*");
|
|
42
|
+
res.writeHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
43
|
+
// `ngrok-skip-browser-warning` (2026-08-28): the webSDK sends it on ngrok-hosted dev
|
|
44
|
+
// tunnels to bypass the free tier's interstitial; a custom header makes the browser
|
|
45
|
+
// PREFLIGHT, and a preflight that doesn't allow it fails the whole request ("blocked
|
|
46
|
+
// by CORS: header field not allowed") — observed live inside ChatGPT's widget sandbox.
|
|
47
|
+
// Harmless to allow always: it's only ever SENT to ngrok hosts.
|
|
48
|
+
res.writeHeader(
|
|
49
|
+
"Access-Control-Allow-Headers",
|
|
50
|
+
"Content-Type, Authorization, mcp-session-id, mcp-protocol-version, ngrok-skip-browser-warning",
|
|
51
|
+
);
|
|
52
|
+
res.writeHeader("Access-Control-Expose-Headers", "mcp-session-id, mcp-protocol-version");
|
|
53
|
+
// A PREFLIGHT IS A ROUND TRIP THAT CARRIES NO DATA, AND WITHOUT THIS THE BROWSER REPEATS
|
|
54
|
+
// IT CONSTANTLY. Chrome's default preflight cache is 5 seconds, so an app making a series
|
|
55
|
+
// of MCP calls pays OPTIONS → 204 → the real request, doubling the round trips on nearly
|
|
56
|
+
// every one. On a universe served from one region that is the dominant cost of loading an
|
|
57
|
+
// app: measured London → UAE, a round trip is ~150ms and the server's own processing is
|
|
58
|
+
// ~0ms, so the preflights were most of the wait, not the work.
|
|
59
|
+
//
|
|
60
|
+
// 24h is the practical ceiling (Chromium caps at 2h, Firefox at 24h; both clamp rather
|
|
61
|
+
// than ignore). The response being cached says only which methods and headers are allowed,
|
|
62
|
+
// which is static in this file — no authorization decision is cached, because auth is
|
|
63
|
+
// checked on the real request every time.
|
|
64
|
+
res.writeHeader("Access-Control-Max-Age", "86400");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Buffer the full request body; resolves on the last chunk (copies as it goes). */
|
|
68
|
+
function readBody(res: HttpResponse): Promise<Buffer> {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
// Collect copies and concat ONCE at the end. The old per-chunk
|
|
71
|
+
// `Buffer.concat([buf, chunk])` reallocated+copied the whole accumulator on every
|
|
72
|
+
// chunk — O(n²) in body size, which hurts large /execute payloads. Each chunk is
|
|
73
|
+
// still copied synchronously (uWS reuses the chunk's backing memory after return).
|
|
74
|
+
const chunks: Buffer[] = [];
|
|
75
|
+
res.onData((chunk, isLast) => {
|
|
76
|
+
// COPY each chunk synchronously NOW — uWS detaches/reuses the chunk's backing
|
|
77
|
+
// ArrayBuffer after this callback returns. `Buffer.from(arrayBuffer)` is only a
|
|
78
|
+
// VIEW (no copy), so wrap in a Uint8Array, which Buffer.from then copies; deferring
|
|
79
|
+
// a bare view to the final concat would read detached memory.
|
|
80
|
+
chunks.push(Buffer.from(new Uint8Array(chunk)));
|
|
81
|
+
if (isLast) resolve(chunks.length === 1 ? chunks[0] : Buffer.concat(chunks));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Drive a uWS request through a Web-fetch handler: OPTIONS preflight, body buffering,
|
|
88
|
+
* and pumping the Response (single-shot JSON or long-lived SSE) back to uWS.
|
|
89
|
+
*/
|
|
90
|
+
export async function handleFetch(
|
|
91
|
+
res: HttpResponse,
|
|
92
|
+
req: HttpRequest,
|
|
93
|
+
handler: (request: Request) => Promise<Response>,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const state: { aborted: boolean; reader?: ReadableStreamDefaultReader<Uint8Array> } = { aborted: false };
|
|
96
|
+
res.onAborted(() => {
|
|
97
|
+
state.aborted = true;
|
|
98
|
+
void state.reader?.cancel().catch(() => {});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// HttpRequest is only valid synchronously — capture everything now.
|
|
102
|
+
const method = req.getMethod().toUpperCase();
|
|
103
|
+
const query = req.getQuery();
|
|
104
|
+
const url = `http://localhost${req.getUrl()}${query ? `?${query}` : ""}`;
|
|
105
|
+
const headers: Record<string, string> = {};
|
|
106
|
+
req.forEach((k, v) => {
|
|
107
|
+
headers[k] = v;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (method === "OPTIONS") {
|
|
111
|
+
res.cork(() => {
|
|
112
|
+
res.writeStatus(statusLine(204));
|
|
113
|
+
writeCors(res);
|
|
114
|
+
res.endWithoutBody();
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const body = method !== "GET" && method !== "HEAD" ? await readBody(res) : undefined;
|
|
120
|
+
if (state.aborted) return;
|
|
121
|
+
|
|
122
|
+
let response: Response;
|
|
123
|
+
try {
|
|
124
|
+
const init: RequestInit = { method, headers };
|
|
125
|
+
if (body && body.length) init.body = new Uint8Array(body); // BodyInit-compatible copy of the Buffer
|
|
126
|
+
response = await handler(new Request(url, init));
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// Log the detail server-side; return a GENERIC message to the client (no internal
|
|
129
|
+
// detail leakage — MCP security best-practices).
|
|
130
|
+
console.error("[unoverse] request handler error:", err);
|
|
131
|
+
if (!state.aborted) {
|
|
132
|
+
res.cork(() => {
|
|
133
|
+
res.writeStatus(statusLine(500));
|
|
134
|
+
res.writeHeader("Content-Type", "application/json");
|
|
135
|
+
writeCors(res);
|
|
136
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal error" }, id: null }));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (state.aborted) return;
|
|
142
|
+
|
|
143
|
+
const isSse = (response.headers.get("content-type") ?? "").startsWith("text/event-stream");
|
|
144
|
+
|
|
145
|
+
// Single-shot responses (everything except SSE) are buffered so large text payloads
|
|
146
|
+
// can be gzipped. Neither uWS nor the DO load balancer compresses, so without this
|
|
147
|
+
// every byte travels identity-encoded — the widget boot alone was ~660KB of JS/JSON
|
|
148
|
+
// that gzips to ~150KB. SSE must stream, so it is never buffered or compressed.
|
|
149
|
+
if (!isSse) {
|
|
150
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
151
|
+
const acceptsGzip = /(^|,)\s*gzip\s*(;|,|$)/.test(headers["accept-encoding"] ?? "");
|
|
152
|
+
let payload: Buffer | null = null;
|
|
153
|
+
try {
|
|
154
|
+
payload = Buffer.from(await response.arrayBuffer());
|
|
155
|
+
} catch {
|
|
156
|
+
payload = null; // body unreadable — fall through and end empty below
|
|
157
|
+
}
|
|
158
|
+
if (state.aborted) return;
|
|
159
|
+
const shouldCompress =
|
|
160
|
+
acceptsGzip &&
|
|
161
|
+
payload !== null &&
|
|
162
|
+
payload.length >= COMPRESS_MIN_BYTES &&
|
|
163
|
+
COMPRESSIBLE.test(contentType) &&
|
|
164
|
+
!response.headers.get("content-encoding");
|
|
165
|
+
const body = shouldCompress && payload ? gzipSync(payload) : payload;
|
|
166
|
+
res.cork(() => {
|
|
167
|
+
res.writeStatus(statusLine(response.status));
|
|
168
|
+
response.headers.forEach((value, key) => {
|
|
169
|
+
const k = key.toLowerCase();
|
|
170
|
+
// uWS generates these itself on every response. Forwarding an upstream copy
|
|
171
|
+
// (e.g. the engine's own `date`) yields a DUPLICATE header — nginx just warns
|
|
172
|
+
// and serves 200, but HTTP/3 (QUIC) rejects the malformed header block with
|
|
173
|
+
// QUIC_PACKET_READ_ERROR. Skip them so uWS's single copy is authoritative.
|
|
174
|
+
// access-control-*: writeCors() below is authoritative — forwarding an upstream
|
|
175
|
+
// copy too produced the doubled `access-control-allow-origin: *,*` seen live.
|
|
176
|
+
if (k === "content-length" || k === "date" || k === "server" || k.startsWith("access-control-")) return;
|
|
177
|
+
res.writeHeader(key, value);
|
|
178
|
+
});
|
|
179
|
+
if (shouldCompress) {
|
|
180
|
+
res.writeHeader("Content-Encoding", "gzip");
|
|
181
|
+
// Encoding varies by the request's Accept-Encoding, so shared caches must key on it.
|
|
182
|
+
res.writeHeader("Vary", "Accept-Encoding");
|
|
183
|
+
}
|
|
184
|
+
writeCors(res);
|
|
185
|
+
// `res.end()` for the empty case, NEVER `endWithoutBody()`. On HTTP/1.1 with no
|
|
186
|
+
// Content-Length, endWithoutBody() leaves the response unterminated: the client
|
|
187
|
+
// waits on a reply that never finishes until the socket's idle timeout kills the
|
|
188
|
+
// connection ~12s later. Every bodyless response paid it — the MCP handshake's
|
|
189
|
+
// 202 ack among them, so every app open stalled ~10s before its first paint
|
|
190
|
+
// (observed live on bppunoverse 2026-08-21; reproduced in isolation: a JSON
|
|
191
|
+
// response 28ms, a null-body 202 11.9s). end() closes with content-length: 0.
|
|
192
|
+
if (body && body.length) res.end(body);
|
|
193
|
+
else res.end();
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// SSE: write head, then pump chunks until the transport closes the stream.
|
|
199
|
+
const reader = response.body?.getReader();
|
|
200
|
+
state.reader = reader;
|
|
201
|
+
res.cork(() => {
|
|
202
|
+
res.writeStatus(statusLine(response.status));
|
|
203
|
+
response.headers.forEach((value, key) => {
|
|
204
|
+
const k = key.toLowerCase();
|
|
205
|
+
// See the single-shot branch for why these are skipped.
|
|
206
|
+
if (k === "content-length" || k === "date" || k === "server" || k.startsWith("access-control-")) return;
|
|
207
|
+
res.writeHeader(key, value);
|
|
208
|
+
});
|
|
209
|
+
writeCors(res);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (!reader) {
|
|
213
|
+
if (!state.aborted) res.cork(() => res.end());
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// SSE pump lifecycle. A GET stream that dies leaves its session registered while
|
|
218
|
+
// every push silently drops (the SDK sends nothing to a missing standalone stream),
|
|
219
|
+
// so these logs are the ground truth for whether a client's inbound stream exists.
|
|
220
|
+
const path = new URL(url).pathname;
|
|
221
|
+
// Session id ties a pump to its MCP session (request header on established sessions,
|
|
222
|
+
// response header on initialize).
|
|
223
|
+
const sid = headers["mcp-session-id"] ?? response.headers.get("mcp-session-id") ?? "-";
|
|
224
|
+
if (isSse) console.log(`[unoverse:sse] pump OPEN ${method} ${path} sid=${sid}`);
|
|
225
|
+
|
|
226
|
+
// KEEPALIVE — an idle SSE stream writes NOTHING, and a silent connection is fair game
|
|
227
|
+
// for any middlebox (the Vite dev proxy kills one after ~2 min; observed as "client
|
|
228
|
+
// aborted" mid-run, after which every push into the still-registered session drops
|
|
229
|
+
// silently). An SSE comment line every 25s keeps every hop alive; parsers ignore
|
|
230
|
+
// comments by spec, so no client sees them.
|
|
231
|
+
// 10s, not 25s: the client's zombie watchdog declares death after ~1.5 missed
|
|
232
|
+
// beats, so the beat interval directly sets how long a silently-dead stream can
|
|
233
|
+
// blind the canvas. Comment lines are ~18 bytes — frequency is free.
|
|
234
|
+
const keepalive = isSse
|
|
235
|
+
? setInterval(() => {
|
|
236
|
+
if (!state.aborted) res.cork(() => res.write(": keepalive\n\n"));
|
|
237
|
+
}, 10_000)
|
|
238
|
+
: null;
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
for (;;) {
|
|
242
|
+
const { done, value } = await reader.read();
|
|
243
|
+
if (state.aborted) {
|
|
244
|
+
void reader.cancel().catch(() => {});
|
|
245
|
+
if (isSse) console.log(`[unoverse:sse] pump CLOSED (client aborted) ${method} ${path} sid=${sid}`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (done) break;
|
|
249
|
+
if (value) res.cork(() => res.write(value));
|
|
250
|
+
}
|
|
251
|
+
if (!state.aborted) res.cork(() => res.end());
|
|
252
|
+
if (isSse) console.log(`[unoverse:sse] pump CLOSED (stream ended) ${method} ${path} sid=${sid}`);
|
|
253
|
+
} catch {
|
|
254
|
+
void reader.cancel().catch(() => {});
|
|
255
|
+
if (!state.aborted) res.cork(() => res.end());
|
|
256
|
+
if (isSse) console.log(`[unoverse:sse] pump CLOSED (pump error) ${method} ${path} sid=${sid}`);
|
|
257
|
+
} finally {
|
|
258
|
+
if (keepalive) clearInterval(keepalive);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The design system version an asset was authored against.
|
|
3
|
+
*
|
|
4
|
+
* TWO PARTS, because neither alone is enough:
|
|
5
|
+
*
|
|
6
|
+
* 1.4.2+a3f1
|
|
7
|
+
* └───┘ └──┘
|
|
8
|
+
* │ └── HASH over every member's content. Derived, so it cannot be forgotten or
|
|
9
|
+
* │ bumped wrongly, and it moves the instant anything moves. This is what
|
|
10
|
+
* │ actually answers "did it change".
|
|
11
|
+
* └──────── PACKAGE VERSION, when there is one. Declared, so it can be stale, but it
|
|
12
|
+
* is ORDERED, which a hash never is. This is what answers "which way".
|
|
13
|
+
*
|
|
14
|
+
* A hash alone tells you two things differ and not which is newer. A version alone can be
|
|
15
|
+
* wrong, and is absent entirely in the monorepo where the design system is uncommitted
|
|
16
|
+
* source on disk rather than an installed package. So: hash always, version when known.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT IS FOR. Every published asset is built ON the design system: a component composes
|
|
19
|
+
* marketplace atoms, an app composes components, an org's tokens override a foundation
|
|
20
|
+
* scale. When the foundation moves, an item authored against the older one may reference
|
|
21
|
+
* something that no longer exists. The symptom is a hole where a component should be, seen
|
|
22
|
+
* by an end user and traceable to nothing. This is the record that makes it traceable.
|
|
23
|
+
*
|
|
24
|
+
* It cannot be backfilled: nothing else anywhere records what a definition was written
|
|
25
|
+
* against. See migration 018 and DECLARATIVE_NODES.md §9.12.
|
|
26
|
+
*/
|
|
27
|
+
import { createHash } from "crypto";
|
|
28
|
+
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
29
|
+
import { join } from "path";
|
|
30
|
+
import { fingerprintOf } from "./fingerprint.js";
|
|
31
|
+
|
|
32
|
+
/** Short, readable, and only ever compared for equality. */
|
|
33
|
+
function shortHash(s: string): string {
|
|
34
|
+
return createHash("sha256").update(s).digest("hex").slice(0, 8);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every file under a directory, sorted, so the hash is order-independent. */
|
|
38
|
+
function filesUnder(dir: string, prefix = "", out: Record<string, string> = {}): Record<string, string> {
|
|
39
|
+
if (!existsSync(dir)) return out;
|
|
40
|
+
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
41
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
42
|
+
if (e.isDirectory()) filesUnder(join(dir, e.name), rel, out);
|
|
43
|
+
else if (/\.(ya?ml|json)$/.test(e.name)) out[rel] = readFileSync(join(dir, e.name), "utf8");
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The declared version, if the design system arrived as a package.
|
|
50
|
+
*
|
|
51
|
+
* Absent in the monorepo, where it is source on disk. That absence is honest rather than a
|
|
52
|
+
* gap: there is no version, because nobody cut one.
|
|
53
|
+
*/
|
|
54
|
+
function packageVersion(designSystemDir: string): string | null {
|
|
55
|
+
// The bundle lives at <package>/definitions, so its package.json is one level up.
|
|
56
|
+
for (const candidate of [join(designSystemDir, "..", "package.json"), join(designSystemDir, "package.json")]) {
|
|
57
|
+
if (!existsSync(candidate)) continue;
|
|
58
|
+
try {
|
|
59
|
+
const v = JSON.parse(readFileSync(candidate, "utf8"))?.version;
|
|
60
|
+
if (typeof v === "string") return v;
|
|
61
|
+
} catch {
|
|
62
|
+
/* unreadable package.json: fall through to the hash alone */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compute the version of the design system at `designSystemDir` (the resolved marketplace
|
|
70
|
+
* directory: on-disk design/marketplace, or the installed bundle).
|
|
71
|
+
*
|
|
72
|
+
* Hashes components, atoms and styles together, because they only mean anything as a set:
|
|
73
|
+
* a universe holding half of them renders against a contract with holes.
|
|
74
|
+
*/
|
|
75
|
+
export function designSystemVersion(designSystemDir: string): string {
|
|
76
|
+
const parts: string[] = [];
|
|
77
|
+
|
|
78
|
+
for (const kind of ["components", "atoms"] as const) {
|
|
79
|
+
const home = join(designSystemDir, kind);
|
|
80
|
+
if (!existsSync(home)) continue;
|
|
81
|
+
for (const e of readdirSync(home, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
82
|
+
if (e.name.startsWith(".") || e.name === "README.md") continue;
|
|
83
|
+
const files = e.isDirectory()
|
|
84
|
+
? filesUnder(join(home, e.name))
|
|
85
|
+
: { [e.name]: readFileSync(join(home, e.name), "utf8") };
|
|
86
|
+
parts.push(`${kind}/${e.name}:${fingerprintOf(files)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const styles = filesUnder(join(designSystemDir, "styles"));
|
|
91
|
+
if (Object.keys(styles).length) parts.push(`styles:${fingerprintOf(styles)}`);
|
|
92
|
+
|
|
93
|
+
const hash = shortHash(parts.join("|"));
|
|
94
|
+
const declared = packageVersion(designSystemDir);
|
|
95
|
+
return declared ? `${declared}+${hash}` : hash;
|
|
96
|
+
}
|