@jini-ai/chat 0.6.15-rc.2 → 0.6.16
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.md +6 -1
- package/dist/core/accepted-input.js +20 -11
- package/dist/core/ag-ui/entry.js +3 -1
- package/dist/core/agent-plugin-chat-state.js +3 -1
- package/dist/core/antigravity/tool-presentation.d.ts +13 -0
- package/dist/core/antigravity/tool-presentation.js +20 -0
- package/dist/core/assistant-client/ag-ui-transport.js +14 -2
- package/dist/core/assistant-client/attachment-liveness.js +1 -1
- package/dist/core/assistant-client/durable-run-subscription.js +2 -1
- package/dist/core/assistant-client/transport.js +13 -7
- package/dist/core/assistant-content.js +2 -1
- package/dist/core/composer-history.js +2 -1
- package/dist/core/durable-projection.js +2 -1
- package/dist/core/durable-runs/continuation.js +2 -1
- package/dist/core/events.d.ts +5 -1
- package/dist/core/messages.d.ts +5 -1
- package/dist/core/partial-json.js +18 -9
- package/dist/core/persistence/title.d.ts +1 -1
- package/dist/core/persistence/title.js +25 -7
- package/dist/core/question-form/normalize.js +1 -1
- package/dist/core/question-form/scan.js +25 -15
- package/dist/core/question-form/secret-answers.js +2 -1
- package/dist/core/run-activity.js +8 -4
- package/dist/core/run-events/entry.js +6 -2
- package/dist/core/tool-events.d.ts +18 -8
- package/dist/core/tool-events.js +72 -16
- package/dist/core/tool-presentation.d.ts +14 -0
- package/dist/core/tool-presentation.js +8 -0
- package/dist/core/transcript.js +13 -6
- package/dist/core/turn-usage.js +7 -2
- package/dist/core/user-text-redaction.d.ts +4 -6
- package/dist/core/user-text-redaction.js +13 -6
- package/dist/core/util/manifest.js +13 -11
- package/dist/core/util/markdown-context.d.ts +14 -0
- package/dist/core/util/markdown-context.js +42 -4
- package/dist/core/util/markup-attributes.js +1 -1
- package/dist/core/util/parser.js +7 -7
- package/dist/core/util/pointer.js +22 -6
- package/dist/core/util/recover.js +5 -8
- package/dist/core/util/strip.js +12 -15
- package/dist/core/util/validate.js +30 -18
- package/dist/embed-widget/browser/frontend-session-bridge.d.ts +99 -0
- package/dist/embed-widget/browser/frontend-session-bridge.js +384 -0
- package/dist/embed-widget/browser/lazy-loader.js +16 -12
- package/dist/embed-widget/browser/session-store.d.ts +3 -9
- package/dist/embed-widget/browser/session-store.js +6 -38
- package/dist/embed-widget/react/dialog.hooks.js +2 -2
- package/dist/embed-widget/react/embed.d.ts +3 -10
- package/dist/embed-widget/react/embed.js +8 -52
- package/dist/embed-widget/react/header.hooks.js +1 -1
- package/dist/embed-widget/transports/fetch-sse.js +2 -1
- package/dist/embed-widget/vanilla/index.d.ts +0 -5
- package/dist/embed-widget/vanilla/index.js +23 -26
- package/dist/embed-widget/vanilla/state.d.ts +3 -11
- package/dist/embed-widget/vanilla/state.js +10 -39
- package/dist/embed-widget/vanilla/style.css +1 -1
- package/dist/react/agent-bridge/frontend-session-bridge.d.ts +2 -56
- package/dist/react/agent-bridge/frontend-session-bridge.js +2 -248
- package/dist/react/components/AttachmentPreviewModal.hooks.js +2 -1
- package/dist/react/components/ChatSettingsDialog.js +18 -9
- package/dist/react/components/Composer.js +5 -4
- package/dist/react/components/ConversationList.js +5 -4
- package/dist/react/components/Icon.d.ts +1 -1
- package/dist/react/components/Icon.js +1 -0
- package/dist/react/components/Markdown.d.ts +1 -0
- package/dist/react/components/Markdown.js +30 -27
- package/dist/react/components/McpUiSurfaceCard.js +2 -1
- package/dist/react/components/MessageList.d.ts +1 -1
- package/dist/react/components/MessageList.hooks.js +3 -2
- package/dist/react/components/MessageRow.d.ts +3 -0
- package/dist/react/components/MessageRow.js +32 -14
- package/dist/react/components/MessageRow.usage.js +3 -1
- package/dist/react/components/QuestionForm.js +4 -5
- package/dist/react/components/SentAttachment.js +4 -1
- package/dist/react/components/TodoCard.js +9 -1
- package/dist/react/components/ToolCard.d.ts +3 -1
- package/dist/react/components/ToolCard.js +84 -64
- package/dist/react/components/ToolCard.rules.d.ts +20 -0
- package/dist/react/components/ToolCard.rules.js +92 -0
- package/dist/react/components/composer-discovery.js +8 -8
- package/dist/react/features/chat-pane/components/AgentPluginUsage.js +1 -2
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.d.ts +1 -1
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +31 -9
- package/dist/react/features/chat-pane/components/ChatPane.js +7 -5
- package/dist/react/features/chat-pane/components/RuntimeDropUp.js +1 -1
- package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +1 -1
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +2 -2
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +3 -1
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +13 -5
- package/dist/react/features/chat-pane/hooks/useChatPaneAgentControl.hooks.js +2 -2
- package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +1 -1
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +2 -2
- package/dist/react/features/chat-pane/hooks/useChatPaneWorkingDirectory.hooks.js +2 -2
- package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +2 -2
- package/dist/react/features/chat-pane/model-options.js +4 -2
- package/dist/react/features/chat-pane/styles.d.ts +1 -1
- package/dist/react/features/chat-pane/styles.js +95 -5
- package/dist/react/features/chat-pane/types.d.ts +11 -0
- package/dist/react/hooks/assistant-chats.js +22 -8
- package/dist/react/hooks/useArtifactStream.js +2 -1
- package/dist/react/hooks/useComposer.js +2 -2
- package/dist/react/hooks/useComposerHistoryKeys.hooks.js +2 -2
- package/dist/react/hooks/useConversation.js +9 -4
- package/dist/react/hooks/useExtEventGroups.js +4 -2
- package/dist/react/hooks/useLatestOperation.js +2 -1
- package/dist/react/hooks/useQuestionForms.js +32 -22
- package/dist/react/hooks/useRunStream.js +4 -2
- package/dist/react/hooks/useUserTextGuard.d.ts +1 -0
- package/dist/react/hooks/useUserTextGuard.js +3 -1
- package/dist/react/message-blocks.js +4 -2
- package/dist/react/styles/reference.css +9 -0
- package/dist/react/tool-call-display.d.ts +11 -0
- package/dist/react/tool-call-display.js +59 -2
- package/dist/server/durable-runs/recover.js +13 -5
- package/dist/server/run-finalizer/entry.js +1 -0
- package/dist/store/runtime/chat-history-store.js +9 -3
- package/dist/store/runtime/durable-run-store.js +34 -24
- package/dist/store/runtime/run-ledger.js +7 -4
- package/dist/store/sql/boundary.js +3 -1
- package/dist/store/sql/cursor.js +8 -3
- package/dist/store/sql/store.js +36 -28
- package/package.json +93 -88
- package/dist/core/browser/page-actions.d.ts +0 -121
- package/dist/core/browser/page-actions.js +0 -115
- package/dist/embed-widget/browser/page-actions.d.ts +0 -2
- package/dist/embed-widget/browser/page-actions.js +0 -3
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* a standalone document reply, or as a single fenced ```html block.
|
|
7
7
|
*/
|
|
8
8
|
import { validateHtmlArtifact } from './validate.js';
|
|
9
|
+
import { fencedCodeRanges } from './markdown-context.js';
|
|
9
10
|
const HTML_OPEN_RE = /<html\b/gi;
|
|
10
11
|
const HTML_CLOSE_RE = /<\/html\s*>/gi;
|
|
11
12
|
const ADJACENT_DOCTYPE_RE = /<!doctype\s+html\b[^>]*>\s*$/i;
|
|
12
|
-
const HTML_FENCE_RE = /```(?:html|HTML)
|
|
13
|
+
const HTML_FENCE_RE = /```(?:html|HTML)/g;
|
|
13
14
|
function findLastArtifactOpen(sourceText, identifier) {
|
|
14
15
|
if (!identifier)
|
|
15
16
|
return sourceText.lastIndexOf('<artifact');
|
|
@@ -99,15 +100,11 @@ export function recoverStandaloneHtmlDocument({ sourceText }) {
|
|
|
99
100
|
*/
|
|
100
101
|
export function recoverHtmlDocumentFromMarkdownFence({ sourceText }) {
|
|
101
102
|
const text = String(sourceText || '');
|
|
102
|
-
HTML_FENCE_RE.lastIndex = 0;
|
|
103
103
|
let recovered = null;
|
|
104
104
|
let count = 0;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (!/<\/html\s*>$/i.test(candidate))
|
|
109
|
-
continue;
|
|
110
|
-
if (!validateHtmlArtifact({ content: candidate }).ok)
|
|
105
|
+
for (const range of fencedCodeRanges({ content: text, opener: HTML_FENCE_RE })) {
|
|
106
|
+
const candidate = range.body.replace(/^/, '').trim();
|
|
107
|
+
if (!/<\/html\s*>$/i.test(candidate) || !validateHtmlArtifact({ content: candidate }).ok)
|
|
111
108
|
continue;
|
|
112
109
|
recovered = candidate;
|
|
113
110
|
count += 1;
|
package/dist/core/util/strip.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
+
import { trimChars } from '@jini-ai/core/primitives';
|
|
1
2
|
/**
|
|
2
3
|
* @module strip
|
|
3
4
|
*
|
|
4
5
|
* Post-stream `<artifact>` removal/summarization and the streaming-display
|
|
5
6
|
* counterpart that surfaces an in-flight (not-yet-closed) artifact block.
|
|
6
7
|
*/
|
|
7
|
-
import { computeSkipRanges, isRealArtifactOpenAt, rangeContains } from './markdown-context.js';
|
|
8
|
+
import { fencedCodeRanges, computeSkipRanges, isRealArtifactOpenAt, rangeContains } from './markdown-context.js';
|
|
8
9
|
import { parseQuotedAttrs } from './markup-attributes.js';
|
|
9
10
|
import { recoverHtmlArtifactFromPrecedingDocument, recoverHtmlDocumentFromMarkdownFence, recoverStandaloneHtmlDocument, } from './recover.js';
|
|
10
11
|
const OPEN = '<artifact';
|
|
11
12
|
const CLOSE = '</artifact>';
|
|
12
|
-
const HTML_FENCE_RE = /```(?:html|HTML)
|
|
13
|
+
const HTML_FENCE_RE = /```(?:html|HTML)/g;
|
|
13
14
|
function findUnskipped(content, needle, fromIndex, ranges) {
|
|
14
15
|
let from = fromIndex;
|
|
15
16
|
// `needle` (always `CLOSE`, non-empty) makes `content.indexOf` itself
|
|
@@ -91,17 +92,14 @@ export function stripArtifact({ content }) {
|
|
|
91
92
|
return (content.slice(0, block.open) + content.slice(block.close + CLOSE.length)).trim();
|
|
92
93
|
}
|
|
93
94
|
function findSingleRecoverableHtmlFence(content) {
|
|
94
|
-
HTML_FENCE_RE.lastIndex = 0;
|
|
95
95
|
let recovered = null;
|
|
96
96
|
let count = 0;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
recovered = { start: match.index, end: match.index + match[0].length, html };
|
|
97
|
+
for (const range of fencedCodeRanges({ content, opener: HTML_FENCE_RE })) {
|
|
98
|
+
const html = range.body.replace(/^/, '').trim();
|
|
99
|
+
if (recoverHtmlDocumentFromMarkdownFence({ sourceText: content.slice(range.start, range.end) }) === html) {
|
|
100
|
+
recovered = { start: range.start, end: range.end, html };
|
|
102
101
|
count += 1;
|
|
103
102
|
}
|
|
104
|
-
match = HTML_FENCE_RE.exec(content);
|
|
105
103
|
}
|
|
106
104
|
return count === 1 ? recovered : null;
|
|
107
105
|
}
|
|
@@ -177,10 +175,9 @@ function artifactExtensionForAttrs(attrs) {
|
|
|
177
175
|
}
|
|
178
176
|
// The slug a persist path would derive the file name from.
|
|
179
177
|
function artifactBaseNameForAttrs(attrs) {
|
|
180
|
-
return ((attrs['identifier'] || attrs['title'] || 'artifact')
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
.replace(/^-+|-+$/g, '')
|
|
178
|
+
return (trimChars({ text: (attrs['identifier'] || attrs['title'] || 'artifact')
|
|
179
|
+
.toLowerCase()
|
|
180
|
+
.replace(/[^a-z0-9_-]+/g, '-'), chars: '-' })
|
|
184
181
|
.slice(0, 60) || 'artifact');
|
|
185
182
|
}
|
|
186
183
|
/**
|
|
@@ -296,7 +293,7 @@ export function splitStreamingArtifact({ content }) {
|
|
|
296
293
|
// known yet, but an artifact is starting, so show the box (empty body)
|
|
297
294
|
// and hide the partial `<artifact …` tail from Markdown.
|
|
298
295
|
return {
|
|
299
|
-
head: content.slice(0, open).
|
|
296
|
+
head: content.slice(0, open).trimEnd(),
|
|
300
297
|
live: { artifactType: '', title: '', identifier: '', content: '' },
|
|
301
298
|
};
|
|
302
299
|
}
|
|
@@ -311,7 +308,7 @@ export function splitStreamingArtifact({ content }) {
|
|
|
311
308
|
if (artifactType && !/html|text\//i.test(artifactType))
|
|
312
309
|
return { head: content, live: null };
|
|
313
310
|
return {
|
|
314
|
-
head: content.slice(0, open).
|
|
311
|
+
head: content.slice(0, open).trimEnd(),
|
|
315
312
|
live: {
|
|
316
313
|
artifactType,
|
|
317
314
|
title: attrs['title'] ?? '',
|
|
@@ -43,8 +43,9 @@ const URL_ATTRIBUTE_RE = /\b(href|src|srcset|poster|action|formaction|data|xlink
|
|
|
43
43
|
const STYLE_ATTRIBUTE_RE = /\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'`=<>]+))/gi;
|
|
44
44
|
const HTML_TAG_RE = /<[a-z][^>]*>/gi;
|
|
45
45
|
const STYLE_BLOCK_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
|
46
|
-
const CSS_URL_RE = /\burl\(
|
|
47
|
-
const
|
|
46
|
+
const CSS_URL_RE = /\burl\((?:\s*"([^"]*)"\s*|\s*'([^']*)'\s*|([^)]*))\)/gi;
|
|
47
|
+
const CSS_IMPORT_START_RE = /@import\s+/gi;
|
|
48
|
+
const CSS_QUOTED_IMPORT_RE = /(?:"([^"]*)"|'([^']*)')/y;
|
|
48
49
|
/**
|
|
49
50
|
* Sniff-test whether `content` looks like a complete HTML document rather
|
|
50
51
|
* than prose that happens to mention HTML.
|
|
@@ -127,22 +128,34 @@ function hasReservedWorkspacePathInStyleBlocks(content) {
|
|
|
127
128
|
return false;
|
|
128
129
|
}
|
|
129
130
|
function cssTextReferencesReservedWorkspacePath(cssText) {
|
|
130
|
-
for (const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// on a successful match — `.find` always finds it; the assertion just
|
|
137
|
-
// satisfies `noUncheckedIndexedAccess`.
|
|
138
|
-
const candidate = match.slice(1).find((value) => value !== undefined);
|
|
139
|
-
if (candidateReferencesReservedWorkspacePath(candidate, false)) {
|
|
140
|
-
return true;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
131
|
+
for (const match of cssReferenceMatches({ cssText })) {
|
|
132
|
+
// Quote/unquoted alternatives are mutually exclusive, so exactly one capture is defined
|
|
133
|
+
// after success; TypeScript cannot express that guarantee for RegExpExecArray.
|
|
134
|
+
const candidate = match.slice(1).find((value) => value !== undefined);
|
|
135
|
+
if (candidateReferencesReservedWorkspacePath(candidate, false))
|
|
136
|
+
return true;
|
|
143
137
|
}
|
|
144
138
|
return false;
|
|
145
139
|
}
|
|
140
|
+
/** Keep each import's whole span consumed, including URL imports already inspected by the first
|
|
141
|
+
* pass. Rescanning @import text quoted inside such a URL would change the path-refusal policy. */
|
|
142
|
+
function* cssReferenceMatches({ cssText }) {
|
|
143
|
+
yield* cssText.matchAll(CSS_URL_RE);
|
|
144
|
+
const imports = new RegExp(CSS_IMPORT_START_RE);
|
|
145
|
+
const url = new RegExp(CSS_URL_RE.source, 'iy');
|
|
146
|
+
const quoted = new RegExp(CSS_QUOTED_IMPORT_RE);
|
|
147
|
+
let header = imports.exec(cssText);
|
|
148
|
+
while (header !== null) {
|
|
149
|
+
url.lastIndex = imports.lastIndex;
|
|
150
|
+
quoted.lastIndex = imports.lastIndex;
|
|
151
|
+
const value = url.exec(cssText) ?? quoted.exec(cssText);
|
|
152
|
+
if (value) {
|
|
153
|
+
imports.lastIndex = value.index + value[0].length;
|
|
154
|
+
yield value;
|
|
155
|
+
}
|
|
156
|
+
header = imports.exec(cssText);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
146
159
|
function candidateReferencesReservedWorkspacePath(candidate, splitCandidates) {
|
|
147
160
|
const paths = splitCandidates ? srcsetCandidateUrls(candidate) : [firstUrlToken(candidate)];
|
|
148
161
|
return paths.some((path) => {
|
|
@@ -171,10 +184,9 @@ function srcsetCandidateUrls(srcset) {
|
|
|
171
184
|
continue;
|
|
172
185
|
}
|
|
173
186
|
candidate ??= { isDataUrl: /^data:/i.test(srcset.slice(index)), sawWhitespaceAfterUrl: false };
|
|
174
|
-
if (isSpace)
|
|
187
|
+
if (isSpace)
|
|
175
188
|
candidate.sawWhitespaceAfterUrl = true;
|
|
176
|
-
|
|
177
|
-
else if (endsSrcsetCandidate(candidate, char)) {
|
|
189
|
+
if (!isSpace && endsSrcsetCandidate(candidate, char)) {
|
|
178
190
|
candidates.push(srcset.slice(start, index));
|
|
179
191
|
start = index + 1;
|
|
180
192
|
candidate = null;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type PageDriver } from '@jini-ai/agentic';
|
|
2
|
+
import type { ApprovalAnswer } from '@jini-ai/core';
|
|
3
|
+
import type { ChatPaneAgentToolAction, ChatPaneAgentBridgeAccess } from '../../react/features/chat-pane/types.js';
|
|
4
|
+
export interface FrontendSessionBridgeOptions {
|
|
5
|
+
/** Host validates its server-bound envelope before the shared page executor sees plain args. */
|
|
6
|
+
readonly preparePageInput?: (args: {
|
|
7
|
+
action: ChatPaneAgentToolAction;
|
|
8
|
+
}) => Record<string, unknown>;
|
|
9
|
+
readonly capabilityIds?: readonly string[];
|
|
10
|
+
/** Bounds the HTTP envelope before it reaches a host JSON-body limit. Oversized reads refuse. */
|
|
11
|
+
readonly maxResponseBytes?: number;
|
|
12
|
+
/** Flush host state; a defined return replaces the machine response before acknowledgement. */
|
|
13
|
+
readonly onBeforeResponse?: (args: {
|
|
14
|
+
action: ChatPaneAgentToolAction;
|
|
15
|
+
output: unknown;
|
|
16
|
+
}) => unknown | Promise<unknown>;
|
|
17
|
+
readonly onAfterResponse?: (args: {
|
|
18
|
+
action: ChatPaneAgentToolAction;
|
|
19
|
+
output: unknown;
|
|
20
|
+
}) => void | Promise<void>;
|
|
21
|
+
/** Host policy and the existing app dialog. It never receives an assistant decision. */
|
|
22
|
+
readonly pageApproval?: {
|
|
23
|
+
isDirect(required: {
|
|
24
|
+
capabilityId: string;
|
|
25
|
+
input: Record<string, unknown>;
|
|
26
|
+
}): boolean;
|
|
27
|
+
request(required: {
|
|
28
|
+
capabilityId: string;
|
|
29
|
+
input: Record<string, unknown>;
|
|
30
|
+
signal: AbortSignal;
|
|
31
|
+
}): Promise<ApprovalAnswer>;
|
|
32
|
+
};
|
|
33
|
+
/** Supplying one claims the six `page.*` verbs. Omit for a chat-only surface. */
|
|
34
|
+
readonly pageDriver?: PageDriver;
|
|
35
|
+
/**
|
|
36
|
+
* Product capabilities, keyed by id prefix (`'cms.'`). Each claims every capability under it.
|
|
37
|
+
* This is how a consumer exposes verbs the engine has never heard of.
|
|
38
|
+
*/
|
|
39
|
+
readonly executors?: Readonly<Record<string, (capabilityId: string, input: Record<string, unknown>) => Promise<unknown>>>;
|
|
40
|
+
/** Called for every invocation before it runs — the hook a product uses to show an activity trail. */
|
|
41
|
+
readonly onInvocation?: (action: ChatPaneAgentToolAction) => void;
|
|
42
|
+
/** Reported for failures that are nobody's tool call: stream errors, malformed frames. */
|
|
43
|
+
readonly onError?: (error: unknown) => void;
|
|
44
|
+
}
|
|
45
|
+
/** The host supplies its origin and stream transport; native fetch defaults may be overridden. */
|
|
46
|
+
export interface FrontendSessionBridgeArgs {
|
|
47
|
+
/** Pass an empty string for the same-origin proxy used by a browser host. */
|
|
48
|
+
readonly baseUrl: string;
|
|
49
|
+
readonly endpointPrefix?: string;
|
|
50
|
+
readonly headers?: () => Record<string, string>;
|
|
51
|
+
readonly openStream: (args: {
|
|
52
|
+
url: string;
|
|
53
|
+
headers?: Record<string, string>;
|
|
54
|
+
}) => Pick<EventSource, 'addEventListener' | 'close'>;
|
|
55
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
56
|
+
readonly request?: (args: {
|
|
57
|
+
url: string;
|
|
58
|
+
init: RequestInit;
|
|
59
|
+
timeoutMs: number;
|
|
60
|
+
}) => Promise<Response>;
|
|
61
|
+
}
|
|
62
|
+
export interface FrontendSessionBridge {
|
|
63
|
+
/** Hand this to `ChatPane`'s `agentControl.bridgeAccess`. */
|
|
64
|
+
readonly bridgeAccess: ChatPaneAgentBridgeAccess;
|
|
65
|
+
/**
|
|
66
|
+
* Resolves the *first* time the daemon attaches this surface — an "is it live yet" signal.
|
|
67
|
+
*
|
|
68
|
+
* Its `bindToken` is only correct until the next reconnect, so do not stash it. Call
|
|
69
|
+
* {@link FrontendSessionBridge.bindToken} when building a run request instead.
|
|
70
|
+
*/
|
|
71
|
+
readonly ready: Promise<{
|
|
72
|
+
sessionId: string;
|
|
73
|
+
bindToken: string;
|
|
74
|
+
}>;
|
|
75
|
+
/**
|
|
76
|
+
* The token for the attachment that is live *now*, or `undefined` before the first attach and
|
|
77
|
+
* after {@link FrontendSessionBridge.close}. Read it at run-start; see the module doc.
|
|
78
|
+
*/
|
|
79
|
+
bindToken(): string | undefined;
|
|
80
|
+
close(): void;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Opens the surface's connection and starts routing invocations.
|
|
84
|
+
*
|
|
85
|
+
* @param options - What this page can serve and where the daemon is.
|
|
86
|
+
* @returns The pane's bridge, the attach result, and a teardown.
|
|
87
|
+
*/
|
|
88
|
+
export declare function createFrontendSessionBridge({ baseUrl: base, openStream, request, fetch, endpointPrefix, headers }: FrontendSessionBridgeArgs, options?: FrontendSessionBridgeOptions): FrontendSessionBridge;
|
|
89
|
+
/** Authenticated streaming replacement for EventSource when credentials belong in headers.
|
|
90
|
+
* Uses the shared SSE decoder; reconnect creates a new attachment and clears the old token.
|
|
91
|
+
* O(stream bytes), bounded frame buffer; close aborts fetch/readers and the reconnect timer. */
|
|
92
|
+
export declare function openFetchFrontendStream({ url, headers }: {
|
|
93
|
+
url: string;
|
|
94
|
+
headers: () => Record<string, string>;
|
|
95
|
+
}, { fetch: send, reconnectMs }?: {
|
|
96
|
+
fetch?: typeof globalThis.fetch;
|
|
97
|
+
reconnectMs?: number;
|
|
98
|
+
}): Pick<EventSource, 'addEventListener' | 'close'>;
|
|
99
|
+
//# sourceMappingURL=frontend-session-bridge.d.ts.map
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { decodeSseFrames } from '@jini-ai/core/primitives';
|
|
2
|
+
/**
|
|
3
|
+
* @module agent-bridge/frontend-session-bridge
|
|
4
|
+
*
|
|
5
|
+
* The browser half of agent-driven frontend control: one SSE connection to the daemon, and the
|
|
6
|
+
* routing that turns each relayed invocation into a call on something in this page.
|
|
7
|
+
*
|
|
8
|
+
* Everything on the server side already existed — `FrontendSessionRegistry` addresses a run to a
|
|
9
|
+
* surface, the frontend-session routes carry invocations out and answers back, and
|
|
10
|
+
* `createFrontendControl` assembles them. `ChatPaneAgentBridgeAccess` declared the contract this
|
|
11
|
+
* satisfies and had **zero implementations**, which is why an agent could reach a tool and the
|
|
12
|
+
* tool could reach nothing.
|
|
13
|
+
*
|
|
14
|
+
* Design notes worth knowing before changing this:
|
|
15
|
+
*
|
|
16
|
+
* - **The connection is owned here, not by `subscribe`.** `ChatPaneAgentBridgeAccess.subscribe` is
|
|
17
|
+
* called by the chat pane's own effect, so tying the stream's lifetime to it would mean page
|
|
18
|
+
* control dies when the pane unmounts — and the page is drivable whether or not a chat pane
|
|
19
|
+
* exists. `subscribe` registers a listener; `close()` tears the connection down.
|
|
20
|
+
* - **Capabilities are claimed from what is actually wired.** The claim sent at connect time is
|
|
21
|
+
* derived from the presence of a `pageDriver` and the keys of `executors`, never from a static
|
|
22
|
+
* list. A surface that claims what it cannot serve turns a fast, named refusal into a timeout.
|
|
23
|
+
* - **An executed invocation is never executed twice.** If the stream drops between the daemon
|
|
24
|
+
* delivering an invocation and this page POSTing the answer, the daemon correctly fails that
|
|
25
|
+
* call — but the click already happened. Without the `executed` set below, the retry clicks
|
|
26
|
+
* again. Buttons are not idempotent.
|
|
27
|
+
* - **The bind token never leaves this module except to the host.** It arrives on the `attached`
|
|
28
|
+
* event and must ride to run-start; it deliberately does not appear in any URL (see
|
|
29
|
+
* `@jini-ai/daemon`'s `FrontendSessionHandle`).
|
|
30
|
+
* - **Read the bind token at send time, not once.** `EventSource` reconnects on its own — after a
|
|
31
|
+
* daemon restart, a sleeping laptop, or an ordinary network blip — and each reattach mints a new
|
|
32
|
+
* session and a new token. A host that captured `ready`'s token keeps sending a dead one, every
|
|
33
|
+
* later run fails to bind, and the only trace is a daemon-side log line: the agent is simply told
|
|
34
|
+
* "no frontend is bound to this run". `ready` therefore answers "has it attached at least once";
|
|
35
|
+
* {@link FrontendSessionBridge.bindToken} answers "what is valid now", and only the latter
|
|
36
|
+
* belongs in a run request.
|
|
37
|
+
*/
|
|
38
|
+
import { CHAT_CAPABILITIES } from '../../core/index.js';
|
|
39
|
+
import { PAGE_CAPABILITIES, executePageCapability } from '@jini-ai/agentic';
|
|
40
|
+
/** How many settled invocation ids to remember for replay suppression. */
|
|
41
|
+
const EXECUTED_MEMORY = 256;
|
|
42
|
+
/** Ids this surface can actually serve, given what the host wired up. */
|
|
43
|
+
function claimedCapabilities(options) {
|
|
44
|
+
if (options.capabilityIds)
|
|
45
|
+
return [...options.capabilityIds];
|
|
46
|
+
const claims = CHAT_CAPABILITIES.map((capability) => capability.id);
|
|
47
|
+
if (options.pageDriver !== undefined) {
|
|
48
|
+
claims.push(...PAGE_CAPABILITIES.map((capability) => capability.id));
|
|
49
|
+
}
|
|
50
|
+
// Dialog-support handshake only; page.approval is not an executable tool id.
|
|
51
|
+
if (options.pageApproval)
|
|
52
|
+
claims.push('page.approval');
|
|
53
|
+
claims.push(...Object.keys(options.executors ?? {}));
|
|
54
|
+
return claims;
|
|
55
|
+
}
|
|
56
|
+
function errorMessage(error) {
|
|
57
|
+
return error instanceof Error ? error.message : String(error);
|
|
58
|
+
}
|
|
59
|
+
/** Parses a frame's data into an object, or says why it is not one. */
|
|
60
|
+
function parseFrameObject(data) {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(data);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
return { error };
|
|
67
|
+
}
|
|
68
|
+
// `JSON.parse` succeeds on any valid JSON value, not just objects — a bare `null`, number,
|
|
69
|
+
// string or boolean parses cleanly and would throw *outside* this try/catch on the very next
|
|
70
|
+
// `frame['type']` read (`null` is the one of those that actually throws; the others just read
|
|
71
|
+
// back `undefined` harmlessly). Guarding here routes every one of those through the same
|
|
72
|
+
// `onError` path as an unparseable frame, rather than only some of them crashing the listener.
|
|
73
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
74
|
+
return { error: new Error(`malformed frame: expected a JSON object, got ${JSON.stringify(parsed)}`) };
|
|
75
|
+
}
|
|
76
|
+
return { frame: parsed };
|
|
77
|
+
}
|
|
78
|
+
/** Reads an `invocation` frame into the pane's action shape, refusing one with no usable id. */
|
|
79
|
+
function readInvocation(frame) {
|
|
80
|
+
const rawInvocationId = frame['invocationId'];
|
|
81
|
+
if (typeof rawInvocationId !== 'string' || rawInvocationId.length === 0) {
|
|
82
|
+
return { kind: 'malformed', error: new Error('invocation frame missing invocationId') };
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
kind: 'invocation',
|
|
86
|
+
action: {
|
|
87
|
+
invocationId: rawInvocationId,
|
|
88
|
+
capabilityId: String(frame['capabilityId']),
|
|
89
|
+
input: (frame['input'] ?? {}),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Classifies one frame's raw data; the listener only applies the result. */
|
|
94
|
+
function readFrame(data) {
|
|
95
|
+
const parsed = parseFrameObject(data);
|
|
96
|
+
if ('error' in parsed)
|
|
97
|
+
return { kind: 'malformed', error: parsed.error };
|
|
98
|
+
const { frame } = parsed;
|
|
99
|
+
switch (frame['type']) {
|
|
100
|
+
case 'cancelled':
|
|
101
|
+
return { kind: 'cancelled', invocationId: String(frame['invocationId']) };
|
|
102
|
+
case 'attached':
|
|
103
|
+
return { kind: 'attached', sessionId: String(frame['sessionId']), bindToken: String(frame['bindToken']) };
|
|
104
|
+
case 'error':
|
|
105
|
+
return { kind: 'refused', message: String(frame['message']) };
|
|
106
|
+
case 'invocation':
|
|
107
|
+
return readInvocation(frame);
|
|
108
|
+
default:
|
|
109
|
+
// Unknown frame types are ignored rather than treated as errors, so the daemon's event
|
|
110
|
+
// vocabulary can grow without every deployed surface needing to ship first.
|
|
111
|
+
return { kind: 'ignored' };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Opens the surface's connection and starts routing invocations.
|
|
116
|
+
*
|
|
117
|
+
* @param options - What this page can serve and where the daemon is.
|
|
118
|
+
* @returns The pane's bridge, the attach result, and a teardown.
|
|
119
|
+
*/
|
|
120
|
+
export function createFrontendSessionBridge({ baseUrl: base, openStream, request, fetch, endpointPrefix = '/api/frontend-sessions', headers }, options = {}) {
|
|
121
|
+
const capabilities = claimedCapabilities(options);
|
|
122
|
+
// The only caller (`respond`) never passes its own signal, so the deadline is the whole signal.
|
|
123
|
+
const send = request ?? (({ url, init, timeoutMs }) => (fetch ?? globalThis.fetch)(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }));
|
|
124
|
+
let sessionId;
|
|
125
|
+
/** Replaced on every reattach; a promise resolved once could never carry this. */
|
|
126
|
+
let currentBindToken;
|
|
127
|
+
// Definite assignment rather than no-op placeholders: a Promise executor runs synchronously, so
|
|
128
|
+
// both are set before this line finishes, and placeholders would only add two functions that
|
|
129
|
+
// can never be called.
|
|
130
|
+
let resolveReady;
|
|
131
|
+
let rejectReady;
|
|
132
|
+
const ready = new Promise((resolve, reject) => {
|
|
133
|
+
resolveReady = resolve;
|
|
134
|
+
rejectReady = reject;
|
|
135
|
+
});
|
|
136
|
+
/** Chat-pane listeners. The pane registers exactly one; a second host could register another. */
|
|
137
|
+
const chatListeners = new Set();
|
|
138
|
+
/** Invocation ids already run here, so a stream reconnect cannot double-click a button. */
|
|
139
|
+
const executed = new Set();
|
|
140
|
+
const pending = new Map();
|
|
141
|
+
// A page driver owns one live screen. Serialize its observations, effect and acknowledgement;
|
|
142
|
+
// otherwise a second invocation could overwrite the first one's host-bound navigation plan.
|
|
143
|
+
let pageQueue = Promise.resolve();
|
|
144
|
+
const abortPending = () => {
|
|
145
|
+
for (const controller of pending.values()) {
|
|
146
|
+
controller.abort();
|
|
147
|
+
}
|
|
148
|
+
pending.clear();
|
|
149
|
+
};
|
|
150
|
+
const query = capabilities.map((id) => `capability=${encodeURIComponent(id)}`).join('&');
|
|
151
|
+
const source = openStream({ url: `${base}${endpointPrefix}/stream?${query}`, ...(headers ? { headers: headers() } : {}) });
|
|
152
|
+
async function respond(invocationId, body) {
|
|
153
|
+
if (sessionId === undefined)
|
|
154
|
+
throw new Error('the surface is not attached yet');
|
|
155
|
+
const wire = boundedFrontendResponse({ invocationId, body, maxBytes: options.maxResponseBytes });
|
|
156
|
+
const response = await send({
|
|
157
|
+
url: `${base}${endpointPrefix}/${encodeURIComponent(sessionId)}/responses`,
|
|
158
|
+
init: { method: 'POST', headers: { 'content-type': 'application/json', ...headers?.(), ...(headers ? { 'x-frontend-bind-token': currentBindToken ?? '' } : {}) }, body: wire.payload },
|
|
159
|
+
timeoutMs: 15_000,
|
|
160
|
+
});
|
|
161
|
+
await assertFrontendResponse({ response, invocationId, body, scoped: Boolean(headers), refused: wire.refused });
|
|
162
|
+
}
|
|
163
|
+
const respondSuccess = (invocationId, output) => respond(invocationId, { ok: true, output });
|
|
164
|
+
const respondError = (invocationId, message) => respond(invocationId, { ok: false, message });
|
|
165
|
+
/** Freeze once, ask on the app's existing human-only channel, then use the same page executor.
|
|
166
|
+
* The server pauses its execution deadline only after acknowledging the bounded human wait. */
|
|
167
|
+
async function servePage(action, signal) {
|
|
168
|
+
const input = options.preparePageInput ? options.preparePageInput({ action }) : structuredClone(action.input);
|
|
169
|
+
const args = { capabilityId: action.capabilityId, input };
|
|
170
|
+
if (options.pageApproval && !options.pageApproval.isDirect(args)) {
|
|
171
|
+
await respond(action.invocationId, { phase: 'approval-pending' });
|
|
172
|
+
const answer = await options.pageApproval.request({ ...args, input: structuredClone(input), signal });
|
|
173
|
+
// Report the decision before effects, including decline/expiry; the server audits it.
|
|
174
|
+
await respond(action.invocationId, { phase: 'execution-started', decision: answer.confirmed ? 'approved' : answer.reason });
|
|
175
|
+
if (!answer.confirmed)
|
|
176
|
+
return { executed: false, reason: answer.reason };
|
|
177
|
+
}
|
|
178
|
+
if (signal.aborted)
|
|
179
|
+
return { executed: false, reason: 'abandoned' };
|
|
180
|
+
return executePageCapability({ driver: options.pageDriver, ...args }, { signal });
|
|
181
|
+
}
|
|
182
|
+
/** Runs one non-chat invocation here and answers it. Chat ones go to the pane instead. */
|
|
183
|
+
async function serveLocally(action, controller) {
|
|
184
|
+
let output;
|
|
185
|
+
try {
|
|
186
|
+
controller.signal.throwIfAborted();
|
|
187
|
+
if (options.pageDriver !== undefined && action.capabilityId.startsWith('page.')) {
|
|
188
|
+
output = await servePage(action, controller.signal);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const entry = Object.entries(options.executors ?? {})
|
|
192
|
+
.find(([prefix]) => action.capabilityId.startsWith(prefix));
|
|
193
|
+
if (entry === undefined)
|
|
194
|
+
throw new Error(`nothing on this page serves "${action.capabilityId}"`);
|
|
195
|
+
output = await entry[1](action.capabilityId, action.input);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
pending.delete(action.invocationId);
|
|
200
|
+
await respondError(action.invocationId, errorMessage(error)).catch(options.onError ?? (() => undefined));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await acknowledge({ action, output, signal: controller.signal });
|
|
204
|
+
pending.delete(action.invocationId);
|
|
205
|
+
}
|
|
206
|
+
async function acknowledge({ action, output, signal }) {
|
|
207
|
+
try {
|
|
208
|
+
signal.throwIfAborted();
|
|
209
|
+
const projected = await options.onBeforeResponse?.({ action, output });
|
|
210
|
+
const acceptedOutput = projected ?? output;
|
|
211
|
+
await respondSuccess(action.invocationId, acceptedOutput);
|
|
212
|
+
signal.throwIfAborted();
|
|
213
|
+
await options.onAfterResponse?.({ action, output: acceptedOutput });
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
options.onError?.(error);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function remember(invocationId) {
|
|
220
|
+
executed.add(invocationId);
|
|
221
|
+
if (executed.size > EXECUTED_MEMORY) {
|
|
222
|
+
const oldest = executed.values().next().value;
|
|
223
|
+
if (oldest !== undefined)
|
|
224
|
+
executed.delete(oldest);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Runs one well-formed invocation: replay check, announcement, then pane or local serving. */
|
|
228
|
+
function handleInvocation(action) {
|
|
229
|
+
// A redelivery after a dropped answer must not re-run the side effect.
|
|
230
|
+
if (executed.has(action.invocationId)) {
|
|
231
|
+
void respondError(action.invocationId, 'already executed on this surface').catch(() => undefined);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (options.capabilityIds && !options.capabilityIds.includes(action.capabilityId)) {
|
|
235
|
+
void respondError(action.invocationId, 'Capability unavailable').catch(() => undefined);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
remember(action.invocationId);
|
|
239
|
+
options.onInvocation?.(action);
|
|
240
|
+
if (action.capabilityId.startsWith('chat.')) {
|
|
241
|
+
for (const listener of chatListeners)
|
|
242
|
+
listener(action);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const controller = new AbortController();
|
|
246
|
+
pending.set(action.invocationId, controller);
|
|
247
|
+
if (action.capabilityId.startsWith('page.')) {
|
|
248
|
+
pageQueue = pageQueue.then(() => serveLocally(action, controller)).catch(error => options.onError?.(error));
|
|
249
|
+
}
|
|
250
|
+
else
|
|
251
|
+
void serveLocally(action, controller);
|
|
252
|
+
}
|
|
253
|
+
source.addEventListener('message', (event) => {
|
|
254
|
+
const frame = readFrame(event.data);
|
|
255
|
+
switch (frame.kind) {
|
|
256
|
+
case 'cancelled':
|
|
257
|
+
pending.get(frame.invocationId)?.abort();
|
|
258
|
+
return;
|
|
259
|
+
case 'attached':
|
|
260
|
+
abortPending();
|
|
261
|
+
sessionId = frame.sessionId;
|
|
262
|
+
currentBindToken = frame.bindToken;
|
|
263
|
+
// A no-op after the first attach — which is exactly why `currentBindToken` exists.
|
|
264
|
+
resolveReady({ sessionId, bindToken: currentBindToken });
|
|
265
|
+
return;
|
|
266
|
+
case 'refused':
|
|
267
|
+
rejectReady(new Error(frame.message));
|
|
268
|
+
return;
|
|
269
|
+
case 'malformed':
|
|
270
|
+
options.onError?.(frame.error);
|
|
271
|
+
return;
|
|
272
|
+
case 'invocation':
|
|
273
|
+
handleInvocation(frame.action);
|
|
274
|
+
return;
|
|
275
|
+
case 'ignored':
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
source.addEventListener('error', (event) => {
|
|
280
|
+
abortPending();
|
|
281
|
+
// The daemon deletes a session's bind token the instant that connection drops, so by the time
|
|
282
|
+
// this fires the token in hand is already gone from the registry. Holding it until the next
|
|
283
|
+
// `attached` frame leaves a window where `bindToken()` hands back something dead, and a run
|
|
284
|
+
// started in that window fails to bind — reported to the agent only as "no frontend is bound
|
|
285
|
+
// to this run", with the real reason (a reconnect it never saw) nowhere in view.
|
|
286
|
+
// Reporting no token is the honest answer for a surface that is, right now, not attached.
|
|
287
|
+
currentBindToken = undefined;
|
|
288
|
+
sessionId = undefined;
|
|
289
|
+
options.onError?.(event);
|
|
290
|
+
});
|
|
291
|
+
const bridgeAccess = {
|
|
292
|
+
subscribe(onAction) {
|
|
293
|
+
chatListeners.add(onAction);
|
|
294
|
+
// Removes this listener only. The connection outlives any one pane — see the module doc.
|
|
295
|
+
return () => chatListeners.delete(onAction);
|
|
296
|
+
},
|
|
297
|
+
respondSuccess,
|
|
298
|
+
respondError,
|
|
299
|
+
};
|
|
300
|
+
return {
|
|
301
|
+
bridgeAccess,
|
|
302
|
+
ready,
|
|
303
|
+
bindToken: () => currentBindToken,
|
|
304
|
+
close() {
|
|
305
|
+
abortPending();
|
|
306
|
+
source.close();
|
|
307
|
+
chatListeners.clear();
|
|
308
|
+
// A closed bridge serves nothing, so binding a run to it would strand that run rather than
|
|
309
|
+
// fail it — this is what stops a torn-down surface (a StrictMode remount, a route change)
|
|
310
|
+
// from handing its dead token to the next run.
|
|
311
|
+
currentBindToken = undefined;
|
|
312
|
+
sessionId = undefined;
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/** Authenticated streaming replacement for EventSource when credentials belong in headers.
|
|
317
|
+
* Uses the shared SSE decoder; reconnect creates a new attachment and clears the old token.
|
|
318
|
+
* O(stream bytes), bounded frame buffer; close aborts fetch/readers and the reconnect timer. */
|
|
319
|
+
export function openFetchFrontendStream({ url, headers }, { fetch: send = globalThis.fetch, reconnectMs = 1000 } = {}) {
|
|
320
|
+
const events = new EventTarget();
|
|
321
|
+
const abort = new AbortController();
|
|
322
|
+
let retry;
|
|
323
|
+
let delay = reconnectMs;
|
|
324
|
+
async function connect() {
|
|
325
|
+
let retryable = true;
|
|
326
|
+
try {
|
|
327
|
+
const response = await send(url, { credentials: 'same-origin', mode: 'same-origin', redirect: 'error', headers: headers(), signal: abort.signal });
|
|
328
|
+
retryable = ![401, 403, 404, 429].includes(response.status);
|
|
329
|
+
if (!response.ok || !response.body)
|
|
330
|
+
throw new Error('Frontend stream unavailable');
|
|
331
|
+
for await (const frame of decodeSseFrames({ source: streamChunks(response.body) }, { maxBufferChars: 128_000 })) {
|
|
332
|
+
delay = reconnectMs; // A delivered event proves the connection recovered.
|
|
333
|
+
if (!abort.signal.aborted)
|
|
334
|
+
events.dispatchEvent(new MessageEvent('message', { data: frame.data }));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch { /* The stream error has one value-free notification below. */ }
|
|
338
|
+
if (abort.signal.aborted)
|
|
339
|
+
return;
|
|
340
|
+
events.dispatchEvent(new Event('error'));
|
|
341
|
+
if (retryable) {
|
|
342
|
+
retry = setTimeout(() => { void connect(); }, delay);
|
|
343
|
+
delay = Math.min(delay * 2, 30_000);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
void connect();
|
|
347
|
+
return { addEventListener: events.addEventListener.bind(events),
|
|
348
|
+
close() { abort.abort(); clearTimeout(retry); } };
|
|
349
|
+
}
|
|
350
|
+
async function* streamChunks(body) {
|
|
351
|
+
const reader = body.getReader();
|
|
352
|
+
try {
|
|
353
|
+
for (;;) {
|
|
354
|
+
const next = await reader.read();
|
|
355
|
+
if (next.done) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
yield next.value;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
await reader.cancel().catch(() => undefined);
|
|
363
|
+
reader.releaseLock();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/** Transport bounds differ from executor output truncation: keep the envelope valid JSON. */
|
|
367
|
+
function boundedFrontendResponse({ invocationId, body, maxBytes }) {
|
|
368
|
+
const payload = JSON.stringify({ invocationId, ...body });
|
|
369
|
+
if (maxBytes === undefined || new TextEncoder().encode(payload).length <= maxBytes)
|
|
370
|
+
return { payload, refused: false };
|
|
371
|
+
return { payload: JSON.stringify({ invocationId, ok: false, message: 'Frontend result too large; narrow the query.' }), refused: true };
|
|
372
|
+
}
|
|
373
|
+
async function assertFrontendResponse({ response, invocationId, body, scoped, refused }) {
|
|
374
|
+
if (!response.ok)
|
|
375
|
+
throw new Error(`answering "${invocationId}" failed: ${response.status}`);
|
|
376
|
+
if (refused)
|
|
377
|
+
throw new Error('Frontend result too large; narrow the query.');
|
|
378
|
+
if ('phase' in body || scoped) {
|
|
379
|
+
const result = await response.json();
|
|
380
|
+
if (!result.settled)
|
|
381
|
+
throw new Error('Page invocation expired or was cancelled; no action was taken');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
//# sourceMappingURL=frontend-session-bridge.js.map
|