haechi 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +264 -0
- package/README.md +50 -4
- package/docs/README.md +4 -6
- package/docs/current/api-stability.ko.md +4 -1
- package/docs/current/api-stability.md +4 -1
- package/docs/current/configuration.ko.md +233 -0
- package/docs/current/configuration.md +233 -0
- package/docs/current/release-0.5-implementation-scope.ko.md +69 -0
- package/docs/current/release-0.5-implementation-scope.md +69 -0
- package/docs/current/release-0.6-implementation-scope.ko.md +151 -0
- package/docs/current/release-0.6-implementation-scope.md +151 -0
- package/docs/current/release-process.ko.md +2 -2
- package/docs/current/release-process.md +2 -2
- package/docs/current/risk-register-release-gate.ko.md +3 -3
- package/docs/current/risk-register-release-gate.md +4 -3
- package/docs/current/threat-model.ko.md +8 -4
- package/docs/current/threat-model.md +8 -4
- package/haechi.config.example.json +13 -1
- package/package.json +4 -2
- package/packages/auth/index.mjs +170 -0
- package/packages/cli/bin/haechi.mjs +253 -27
- package/packages/cli/runtime.mjs +113 -7
- package/packages/core/index.mjs +126 -6
- package/packages/policy/index.mjs +82 -0
- package/packages/protocol-adapters/index.mjs +33 -14
- package/packages/proxy/index.mjs +237 -4
- package/packages/stream-filter/index.mjs +194 -0
package/packages/proxy/index.mjs
CHANGED
|
@@ -1,31 +1,71 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { inspectResponseStream } from "../stream-filter/index.mjs";
|
|
3
4
|
|
|
4
5
|
export const DEFAULT_PROXY_PORT = 1016;
|
|
5
6
|
|
|
6
7
|
export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "127.0.0.1", allowRemoteBind = false }) {
|
|
7
8
|
assertSafeProxyBind({ host, allowRemoteBind });
|
|
8
9
|
const { haechi, config, protocolAdapter } = runtime;
|
|
10
|
+
const rateLimiter = createRateLimiter();
|
|
9
11
|
|
|
10
12
|
const server = createServer(async (request, response) => {
|
|
11
13
|
try {
|
|
12
14
|
if (request.method === "GET" && request.url === "/__haechi/health") {
|
|
15
|
+
// Intentionally unauthenticated; exposes only the mode.
|
|
13
16
|
writeJson(response, 200, { ok: true, mode: config.mode });
|
|
14
17
|
return;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
assertRelativeProxyTarget(request.url);
|
|
18
21
|
const routeContext = protocolAdapter.classifyRequest(request);
|
|
22
|
+
|
|
23
|
+
// Authenticate, resolve the policy profile, and rate-limit BEFORE reading
|
|
24
|
+
// the body, so a denied/throttled request cannot stream a large body.
|
|
25
|
+
const gate = await authorizeRequest({ runtime, request, routeContext, rateLimiter });
|
|
26
|
+
if (gate.denied) {
|
|
27
|
+
writeJson(response, gate.denied.status, {
|
|
28
|
+
error: gate.denied.error,
|
|
29
|
+
message: gate.denied.message
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const { identity, profile, policyEngine, modelAllowlist } = gate;
|
|
34
|
+
const authContext = { identity, profile, policyEngine };
|
|
35
|
+
|
|
19
36
|
const body = await readBody(request, {
|
|
20
37
|
maxBytes: config.limits.maxRequestBytes
|
|
21
38
|
});
|
|
22
39
|
const json = parseJsonBody(body);
|
|
23
40
|
|
|
41
|
+
// Model allowlist runs after body read (the model field is in the body).
|
|
42
|
+
if (modelAllowlist && typeof json?.model === "string" && !modelAllowlist.includes(json.model)) {
|
|
43
|
+
await recordProxyDecision({
|
|
44
|
+
runtime, routeContext, identity, profile,
|
|
45
|
+
decision: "model_not_allowed",
|
|
46
|
+
reason: `model:${json.model}`,
|
|
47
|
+
enforced: true,
|
|
48
|
+
blocked: true
|
|
49
|
+
});
|
|
50
|
+
writeJson(response, 403, {
|
|
51
|
+
error: "haechi_model_not_allowed",
|
|
52
|
+
message: `Model not allowed: ${json.model}`
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
24
57
|
if (isStreamingRequest(json, routeContext)) {
|
|
58
|
+
if (config.streaming.requestMode === "inspect") {
|
|
59
|
+
await handleInspectedStream({ runtime, request, response, routeContext, json, authContext });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
25
63
|
if (config.streaming.requestMode === "pass-through") {
|
|
26
64
|
await recordProxyDecision({
|
|
27
65
|
runtime,
|
|
28
66
|
routeContext,
|
|
67
|
+
identity,
|
|
68
|
+
profile,
|
|
29
69
|
decision: "streaming_request_pass_through",
|
|
30
70
|
reason: "streaming_request_pass_through",
|
|
31
71
|
enforced: false,
|
|
@@ -45,7 +85,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
45
85
|
|
|
46
86
|
writeJson(response, 501, {
|
|
47
87
|
error: "haechi_streaming_unsupported",
|
|
48
|
-
message: "Streaming requests are blocked unless streaming.requestMode is
|
|
88
|
+
message: "Streaming requests are blocked unless streaming.requestMode is set to pass-through or inspect"
|
|
49
89
|
});
|
|
50
90
|
return;
|
|
51
91
|
}
|
|
@@ -53,6 +93,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
53
93
|
const result = routeContext.protectRequest
|
|
54
94
|
? await haechi.protectJson(json, {
|
|
55
95
|
...routeContext,
|
|
96
|
+
...authContext,
|
|
56
97
|
operation: `request:${routeContext.operation}`,
|
|
57
98
|
direction: "request",
|
|
58
99
|
mode: config.policy.mode ?? config.mode
|
|
@@ -79,6 +120,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
79
120
|
upstreamResponse,
|
|
80
121
|
routeContext,
|
|
81
122
|
runtime,
|
|
123
|
+
authContext,
|
|
82
124
|
issuedTokens: result.issuedTokens ?? []
|
|
83
125
|
});
|
|
84
126
|
|
|
@@ -114,7 +156,196 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
114
156
|
};
|
|
115
157
|
}
|
|
116
158
|
|
|
117
|
-
|
|
159
|
+
// Authenticate → resolve policy profile → rate-limit. Returns the request's
|
|
160
|
+
// identity/profile/policyEngine/modelAllowlist, or a denial. Auth is required
|
|
161
|
+
// exactly when an authProvider is configured (auth.provider !== "none").
|
|
162
|
+
async function authorizeRequest({ runtime, request, routeContext, rateLimiter }) {
|
|
163
|
+
const { authProvider, policyProfiles } = runtime;
|
|
164
|
+
let identity = null;
|
|
165
|
+
|
|
166
|
+
if (authProvider) {
|
|
167
|
+
try {
|
|
168
|
+
identity = await authProvider.authenticate(request);
|
|
169
|
+
} catch {
|
|
170
|
+
await recordAuthDenied({ runtime, routeContext, reason: "provider_error" });
|
|
171
|
+
return { denied: { status: 401, error: "haechi_auth_denied", message: "Authentication failed" } };
|
|
172
|
+
}
|
|
173
|
+
if (!identity) {
|
|
174
|
+
const reason = hasBearerHeader(request) ? "invalid_token" : "no_token";
|
|
175
|
+
await recordAuthDenied({ runtime, routeContext, reason });
|
|
176
|
+
return { denied: { status: 401, error: "haechi_auth_denied", message: "Authentication required" } };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const resolved = policyProfiles.resolve(identity);
|
|
181
|
+
|
|
182
|
+
if (resolved.rate && resolved.rate.requestsPerMinute) {
|
|
183
|
+
const key = identity?.id ?? "anonymous";
|
|
184
|
+
if (!rateLimiter.allow(key, resolved.rate.requestsPerMinute)) {
|
|
185
|
+
await recordProxyDecision({
|
|
186
|
+
runtime, routeContext, identity, profile: resolved.profile,
|
|
187
|
+
decision: "rate_limited",
|
|
188
|
+
reason: `rate:${resolved.rate.requestsPerMinute}`,
|
|
189
|
+
enforced: true,
|
|
190
|
+
blocked: true
|
|
191
|
+
});
|
|
192
|
+
return { denied: { status: 429, error: "haechi_rate_limited", message: "Rate limit exceeded" } };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
identity,
|
|
198
|
+
profile: resolved.profile,
|
|
199
|
+
policyEngine: resolved.policyEngine,
|
|
200
|
+
modelAllowlist: resolved.modelAllowlist
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hasBearerHeader(request) {
|
|
205
|
+
const header = request?.headers?.authorization ?? request?.headers?.Authorization;
|
|
206
|
+
return typeof header === "string" && /^Bearer\s+/i.test(header.trim());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function createRateLimiter() {
|
|
210
|
+
// In-memory fixed-window counter. Per-process: resets on restart, not shared
|
|
211
|
+
// across replicas — acceptable for a single-process self-hosted preview.
|
|
212
|
+
const windows = new Map();
|
|
213
|
+
const windowMs = 60000;
|
|
214
|
+
return {
|
|
215
|
+
allow(key, limit) {
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
const slot = windows.get(key);
|
|
218
|
+
if (!slot || now - slot.windowStart >= windowMs) {
|
|
219
|
+
windows.set(key, { windowStart: now, count: 1 });
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
if (slot.count >= limit) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
slot.count += 1;
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function recordAuthDenied({ runtime, routeContext, reason }) {
|
|
232
|
+
await recordProxyDecision({
|
|
233
|
+
runtime, routeContext, identity: null, profile: null,
|
|
234
|
+
decision: "auth_denied",
|
|
235
|
+
reason,
|
|
236
|
+
enforced: true,
|
|
237
|
+
blocked: true
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function handleInspectedStream({ runtime, request, response, routeContext, json, authContext = {} }) {
|
|
242
|
+
const { haechi, config } = runtime;
|
|
243
|
+
|
|
244
|
+
// Inspection needs to know the wire format and delta channel for this route.
|
|
245
|
+
if (!routeContext.streaming) {
|
|
246
|
+
writeJson(response, 501, {
|
|
247
|
+
error: "haechi_streaming_uninspectable_route",
|
|
248
|
+
message: `streaming.requestMode is "inspect" but route ${routeContext.routeId} has no known streaming format`
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The request body is ordinary JSON even when the response streams, so it is
|
|
254
|
+
// protected like any other request.
|
|
255
|
+
const requestResult = routeContext.protectRequest
|
|
256
|
+
? await haechi.protectJson(json, {
|
|
257
|
+
...routeContext,
|
|
258
|
+
...authContext,
|
|
259
|
+
operation: `request:${routeContext.operation}`,
|
|
260
|
+
direction: "request",
|
|
261
|
+
mode: config.policy.mode ?? config.mode
|
|
262
|
+
})
|
|
263
|
+
: { payload: json, blocked: false };
|
|
264
|
+
|
|
265
|
+
if (requestResult.blocked) {
|
|
266
|
+
writeJson(response, 403, {
|
|
267
|
+
error: "haechi_policy_block",
|
|
268
|
+
summary: requestResult.summary,
|
|
269
|
+
auditId: requestResult.auditEvent.id
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const upstreamResponse = await forward({
|
|
275
|
+
upstream: config.target.upstream,
|
|
276
|
+
request,
|
|
277
|
+
body: JSON.stringify(requestResult.payload),
|
|
278
|
+
timeoutMs: config.limits.upstreamTimeoutMs
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const streamMode = config.streaming.responseMode ?? config.responseProtection.mode ?? config.policy.mode ?? config.mode;
|
|
282
|
+
const protector = haechi.createStreamProtector({
|
|
283
|
+
...routeContext,
|
|
284
|
+
...authContext,
|
|
285
|
+
operation: `response-stream:${routeContext.operation}`,
|
|
286
|
+
direction: "response",
|
|
287
|
+
mode: streamMode,
|
|
288
|
+
maxMatchBytes: config.streaming.maxMatchBytes
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
response.writeHead(upstreamResponse.status, streamingResponseHeaders(upstreamResponse));
|
|
292
|
+
|
|
293
|
+
const { blocked, summary } = await inspectResponseStream({
|
|
294
|
+
source: upstreamResponse.body ?? emptyAsyncIterable(),
|
|
295
|
+
sink: nodeResponseSink(response),
|
|
296
|
+
streaming: routeContext.streaming,
|
|
297
|
+
protector
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await recordStreamDecision({
|
|
301
|
+
runtime, routeContext, blocked, summary, mode: streamMode,
|
|
302
|
+
identity: authContext.identity ?? null, profile: authContext.profile ?? null
|
|
303
|
+
});
|
|
304
|
+
response.end();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function streamingResponseHeaders(upstreamResponse) {
|
|
308
|
+
const headers = Object.fromEntries(upstreamResponse.headers.entries());
|
|
309
|
+
delete headers["content-length"];
|
|
310
|
+
delete headers["content-encoding"];
|
|
311
|
+
return headers;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function nodeResponseSink(response) {
|
|
315
|
+
return {
|
|
316
|
+
write(text) {
|
|
317
|
+
response.write(text);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function* emptyAsyncIterable() {
|
|
323
|
+
// No upstream body to inspect.
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function recordStreamDecision({ runtime, routeContext, blocked, summary, mode, identity = null, profile = null }) {
|
|
327
|
+
if (typeof runtime.auditSink?.record !== "function") {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
await runtime.auditSink.record({
|
|
331
|
+
id: randomUUID(),
|
|
332
|
+
timestamp: new Date().toISOString(),
|
|
333
|
+
protocol: routeContext?.protocol ?? "proxy",
|
|
334
|
+
operation: `response-stream:${routeContext?.operation ?? "unknown"}`,
|
|
335
|
+
mode,
|
|
336
|
+
identity,
|
|
337
|
+
profile,
|
|
338
|
+
enforced: !["dry-run", "report-only"].includes(mode),
|
|
339
|
+
blocked,
|
|
340
|
+
decision: blocked ? "stream_blocked" : "stream_inspected",
|
|
341
|
+
reason: blocked ? "stream_policy_block" : "stream_inspected",
|
|
342
|
+
routeId: routeContext?.routeId ?? "unknown",
|
|
343
|
+
pathHash: routeContext?.path ? shortHash(routeContext.path) : null,
|
|
344
|
+
summary
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, authContext = {}, issuedTokens = [] }) {
|
|
118
349
|
const headers = Object.fromEntries(upstreamResponse.headers.entries());
|
|
119
350
|
|
|
120
351
|
if (!runtime.config.responseProtection.enabled || !routeContext.protectResponse) {
|
|
@@ -204,6 +435,7 @@ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, i
|
|
|
204
435
|
|
|
205
436
|
const result = await runtime.haechi.protectJson(json, {
|
|
206
437
|
...routeContext,
|
|
438
|
+
...authContext,
|
|
207
439
|
operation: `response:${routeContext.operation}`,
|
|
208
440
|
direction: "response",
|
|
209
441
|
mode: runtime.config.responseProtection.mode ?? runtime.config.policy.mode ?? runtime.config.mode
|
|
@@ -488,7 +720,7 @@ async function cancelReader(reader) {
|
|
|
488
720
|
}
|
|
489
721
|
}
|
|
490
722
|
|
|
491
|
-
async function recordProxyDecision({ runtime, routeContext, decision, reason, enforced, blocked }) {
|
|
723
|
+
async function recordProxyDecision({ runtime, routeContext, decision, reason, enforced, blocked, identity = null, profile = null }) {
|
|
492
724
|
if (typeof runtime.auditSink?.record !== "function") {
|
|
493
725
|
return;
|
|
494
726
|
}
|
|
@@ -499,7 +731,8 @@ async function recordProxyDecision({ runtime, routeContext, decision, reason, en
|
|
|
499
731
|
protocol: routeContext?.protocol ?? "proxy",
|
|
500
732
|
operation: routeContext ? `proxy:${routeContext.protocol}:${routeContext.routeId ?? "unknown"}` : "proxy",
|
|
501
733
|
mode: runtime.config.policy.mode ?? runtime.config.mode,
|
|
502
|
-
identity
|
|
734
|
+
identity,
|
|
735
|
+
profile,
|
|
503
736
|
enforced,
|
|
504
737
|
blocked,
|
|
505
738
|
decision,
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// SSE / NDJSON streaming response inspection.
|
|
2
|
+
//
|
|
3
|
+
// Frames are parsed incrementally, the primary delta-text channel is run
|
|
4
|
+
// through a bounded sliding buffer (cross-frame matches caught up to
|
|
5
|
+
// streaming.maxMatchBytes), and all other string leaves in a frame get
|
|
6
|
+
// within-frame protection. The whole stream is audited once at the end.
|
|
7
|
+
|
|
8
|
+
const SSE_DONE = "[DONE]";
|
|
9
|
+
|
|
10
|
+
export async function inspectResponseStream({ source, sink, streaming, protector, format }) {
|
|
11
|
+
const wireFormat = format ?? streaming?.format ?? "ndjson";
|
|
12
|
+
const deltaPath = streaming?.deltaPath ?? null;
|
|
13
|
+
const decoder = new TextDecoder("utf-8");
|
|
14
|
+
const frames = createFrameSplitter(wireFormat);
|
|
15
|
+
|
|
16
|
+
let blocked = false;
|
|
17
|
+
|
|
18
|
+
async function handleFrame(raw) {
|
|
19
|
+
const frame = { raw, body: raw.trim() };
|
|
20
|
+
const parsed = parseFrame(frame, wireFormat);
|
|
21
|
+
if (!parsed.ok) {
|
|
22
|
+
// Non-JSON frame (e.g. `data: [DONE]`, comments, keep-alives): pass
|
|
23
|
+
// through verbatim — there is nothing to inspect.
|
|
24
|
+
sink.write(frame.raw);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const json = parsed.json;
|
|
29
|
+
let deltaText = null;
|
|
30
|
+
if (deltaPath) {
|
|
31
|
+
const found = getByPath(json, deltaPath);
|
|
32
|
+
if (typeof found === "string") {
|
|
33
|
+
deltaText = found;
|
|
34
|
+
setByPath(json, deltaPath, "");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Within-frame protection for everything except the delta channel.
|
|
39
|
+
const extras = await protector.protectFrameExtras(json);
|
|
40
|
+
if (extras.blocked) {
|
|
41
|
+
blocked = true;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const frameObject = extras.value;
|
|
45
|
+
|
|
46
|
+
if (deltaText !== null) {
|
|
47
|
+
const pushed = await protector.push(deltaText);
|
|
48
|
+
if (pushed.blocked) {
|
|
49
|
+
blocked = true;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setByPath(frameObject, deltaPath, pushed.text);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
sink.write(serializeFrame(frameObject, wireFormat, frame));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for await (const chunk of source) {
|
|
59
|
+
for (const frame of frames.push(decoder.decode(chunk, { stream: true }))) {
|
|
60
|
+
await handleFrame(frame);
|
|
61
|
+
if (blocked) {
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (blocked) {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!blocked) {
|
|
71
|
+
for (const frame of frames.end(decoder.decode())) {
|
|
72
|
+
await handleFrame(frame);
|
|
73
|
+
if (blocked) {
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!blocked) {
|
|
80
|
+
// Flush the held tail of the delta buffer as a synthesized final frame.
|
|
81
|
+
const flushed = await protector.flush();
|
|
82
|
+
if (flushed.blocked) {
|
|
83
|
+
blocked = true;
|
|
84
|
+
} else if (flushed.text && deltaPath) {
|
|
85
|
+
sink.write(serializeFrame(buildPathObject(deltaPath, flushed.text), wireFormat, null));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The caller closes the sink AFTER recording the stream decision, so the
|
|
90
|
+
// audit write is durable before the client connection ends.
|
|
91
|
+
return { blocked, summary: protector.summary() };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createFrameSplitter(format) {
|
|
95
|
+
const delimiter = format === "sse" ? "\n\n" : "\n";
|
|
96
|
+
let buffer = "";
|
|
97
|
+
return {
|
|
98
|
+
// Append text and return the raw text of every complete frame now
|
|
99
|
+
// available; the trailing partial is retained for the next push.
|
|
100
|
+
push(text) {
|
|
101
|
+
buffer += text;
|
|
102
|
+
const out = [];
|
|
103
|
+
let index;
|
|
104
|
+
while ((index = buffer.indexOf(delimiter)) !== -1) {
|
|
105
|
+
const raw = buffer.slice(0, index + delimiter.length);
|
|
106
|
+
buffer = buffer.slice(index + delimiter.length);
|
|
107
|
+
if (raw.trim()) {
|
|
108
|
+
out.push(raw);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
},
|
|
113
|
+
// Flush any trailing partial frame at end of stream.
|
|
114
|
+
end(text) {
|
|
115
|
+
buffer += text;
|
|
116
|
+
const remainder = buffer;
|
|
117
|
+
buffer = "";
|
|
118
|
+
return remainder.trim() ? [remainder] : [];
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseFrame(frame, format) {
|
|
124
|
+
if (!frame) {
|
|
125
|
+
return { ok: false };
|
|
126
|
+
}
|
|
127
|
+
let payload = frame.body;
|
|
128
|
+
if (format === "sse") {
|
|
129
|
+
const dataLines = payload
|
|
130
|
+
.split("\n")
|
|
131
|
+
.filter((line) => line.startsWith("data:"))
|
|
132
|
+
.map((line) => line.slice(5).trim());
|
|
133
|
+
if (dataLines.length === 0) {
|
|
134
|
+
return { ok: false };
|
|
135
|
+
}
|
|
136
|
+
payload = dataLines.join("");
|
|
137
|
+
if (payload === SSE_DONE) {
|
|
138
|
+
return { ok: false };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
return { ok: true, json: JSON.parse(payload) };
|
|
143
|
+
} catch {
|
|
144
|
+
return { ok: false };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function serializeFrame(json, format, original) {
|
|
149
|
+
const body = JSON.stringify(json);
|
|
150
|
+
if (format === "sse") {
|
|
151
|
+
return `data: ${body}\n\n`;
|
|
152
|
+
}
|
|
153
|
+
// NDJSON: preserve the original trailing newline style when available.
|
|
154
|
+
return original && original.raw.endsWith("\n") ? `${body}\n` : `${body}\n`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function getByPath(value, path) {
|
|
158
|
+
let current = value;
|
|
159
|
+
for (const part of path) {
|
|
160
|
+
if (current == null) {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
current = current[part];
|
|
164
|
+
}
|
|
165
|
+
return current;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function setByPath(value, path, next) {
|
|
169
|
+
let current = value;
|
|
170
|
+
for (let index = 0; index < path.length - 1; index += 1) {
|
|
171
|
+
const part = path[index];
|
|
172
|
+
if (current[part] == null || typeof current[part] !== "object") {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
current = current[part];
|
|
176
|
+
}
|
|
177
|
+
if (current == null || typeof current !== "object") {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
current[path[path.length - 1]] = next;
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function buildPathObject(path, leaf) {
|
|
185
|
+
const root = typeof path[0] === "number" ? [] : {};
|
|
186
|
+
let current = root;
|
|
187
|
+
for (let index = 0; index < path.length - 1; index += 1) {
|
|
188
|
+
const nextIsIndex = typeof path[index + 1] === "number";
|
|
189
|
+
current[path[index]] = nextIsIndex ? [] : {};
|
|
190
|
+
current = current[path[index]];
|
|
191
|
+
}
|
|
192
|
+
current[path[path.length - 1]] = leaf;
|
|
193
|
+
return root;
|
|
194
|
+
}
|