@zhin.js/client 2.1.9 → 2.1.11
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 -0
- package/client/console-events.ts +37 -0
- package/client/index.ts +44 -2
- package/client/mediaSrc.test.ts +15 -0
- package/client/mediaSrc.ts +59 -12
- package/client/message-content/MarkdownContent.tsx +128 -0
- package/client/message-content/markdown.test.ts +48 -0
- package/client/message-content/markdown.ts +247 -0
- package/client/persistence/idb-store.test.ts +11 -0
- package/client/persistence/idb-store.ts +20 -4
- package/client/websocket/index.ts +6 -1
- package/client/websocket/manager.test.ts +145 -12
- package/client/websocket/manager.ts +189 -17
- package/client/websocket/types.ts +5 -0
- package/client/workroom/WorkroomRunsPage.tsx +248 -0
- package/client/{orchestration/registerOrchestrationConsole.test.ts → workroom/registerWorkroomConsole.test.ts} +8 -8
- package/client/workroom/registerWorkroomConsole.ts +17 -0
- package/dist/console-events.d.ts +7 -0
- package/dist/console-events.d.ts.map +1 -0
- package/dist/console-events.js +26 -0
- package/dist/console-events.js.map +1 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/mediaSrc.d.ts.map +1 -1
- package/dist/mediaSrc.js +66 -12
- package/dist/mediaSrc.js.map +1 -1
- package/dist/message-content/MarkdownContent.d.ts +11 -0
- package/dist/message-content/MarkdownContent.d.ts.map +1 -0
- package/dist/message-content/MarkdownContent.js +68 -0
- package/dist/message-content/MarkdownContent.js.map +1 -0
- package/dist/message-content/markdown.d.ts +47 -0
- package/dist/message-content/markdown.d.ts.map +1 -0
- package/dist/message-content/markdown.js +219 -0
- package/dist/message-content/markdown.js.map +1 -0
- package/dist/persistence/idb-store.d.ts +3 -0
- package/dist/persistence/idb-store.d.ts.map +1 -1
- package/dist/persistence/idb-store.js +13 -3
- package/dist/persistence/idb-store.js.map +1 -1
- package/dist/websocket/index.d.ts +1 -1
- package/dist/websocket/index.d.ts.map +1 -1
- package/dist/websocket/index.js +1 -1
- package/dist/websocket/index.js.map +1 -1
- package/dist/websocket/manager.d.ts +19 -0
- package/dist/websocket/manager.d.ts.map +1 -1
- package/dist/websocket/manager.js +176 -18
- package/dist/websocket/manager.js.map +1 -1
- package/dist/websocket/types.d.ts +4 -0
- package/dist/websocket/types.d.ts.map +1 -1
- package/dist/websocket/types.js.map +1 -1
- package/dist/workroom/WorkroomRunsPage.d.ts +2 -0
- package/dist/workroom/WorkroomRunsPage.d.ts.map +1 -0
- package/dist/workroom/WorkroomRunsPage.js +64 -0
- package/dist/workroom/WorkroomRunsPage.js.map +1 -0
- package/dist/workroom/registerWorkroomConsole.d.ts +4 -0
- package/dist/workroom/registerWorkroomConsole.d.ts.map +1 -0
- package/dist/workroom/registerWorkroomConsole.js +16 -0
- package/dist/workroom/registerWorkroomConsole.js.map +1 -0
- package/package.json +3 -3
- package/client/orchestration/OrchestrationRunsPage.tsx +0 -217
- package/client/orchestration/registerOrchestrationConsole.ts +0 -17
- package/dist/orchestration/OrchestrationRunsPage.d.ts +0 -2
- package/dist/orchestration/OrchestrationRunsPage.d.ts.map +0 -1
- package/dist/orchestration/OrchestrationRunsPage.js +0 -65
- package/dist/orchestration/OrchestrationRunsPage.js.map +0 -1
- package/dist/orchestration/registerOrchestrationConsole.d.ts +0 -4
- package/dist/orchestration/registerOrchestrationConsole.d.ts.map +0 -1
- package/dist/orchestration/registerOrchestrationConsole.js +0 -16
- package/dist/orchestration/registerOrchestrationConsole.js.map +0 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
export type MarkdownInline =
|
|
2
|
+
| { readonly type: 'text'; readonly value: string }
|
|
3
|
+
| { readonly type: 'strong' | 'emphasis' | 'strike' | 'code'; readonly value: string }
|
|
4
|
+
| { readonly type: 'link'; readonly value: string; readonly href: string };
|
|
5
|
+
|
|
6
|
+
export type MarkdownBlock =
|
|
7
|
+
| { readonly type: 'heading'; readonly level: number; readonly content: MarkdownInline[] }
|
|
8
|
+
| { readonly type: 'paragraph' | 'quote'; readonly content: MarkdownInline[] }
|
|
9
|
+
| { readonly type: 'list'; readonly ordered: boolean; readonly items: ReadonlyArray<{ readonly content: MarkdownInline[]; readonly checked?: boolean }> }
|
|
10
|
+
| { readonly type: 'code'; readonly language: string; readonly value: string; readonly closed: boolean }
|
|
11
|
+
| { readonly type: 'table'; readonly header: ReadonlyArray<MarkdownInline[]>; readonly rows: ReadonlyArray<ReadonlyArray<MarkdownInline[]>> }
|
|
12
|
+
| { readonly type: 'rule' };
|
|
13
|
+
|
|
14
|
+
export type CodeTokenKind = 'plain' | 'comment' | 'string' | 'number' | 'keyword' | 'literal';
|
|
15
|
+
|
|
16
|
+
export interface CodeToken {
|
|
17
|
+
readonly kind: CodeTokenKind;
|
|
18
|
+
readonly value: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Excluding another opening bracket from link labels prevents the regexp
|
|
22
|
+
// engine from rescanning an attacker-controlled run of `[` characters.
|
|
23
|
+
const INLINE_PATTERN = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*|__[^_\n]+__)|(~~[^~\n]+~~)|(\*[^*\n]+\*|_[^_\n]+_)|(\[[^\[\]\n]+\]\([^\s()]+(?:\s+"[^"\n]*")?\))|(https?:\/\/[^\s<>()]+)/g;
|
|
24
|
+
const TABLE_DIVIDER = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
|
|
25
|
+
|
|
26
|
+
export function isSafeMarkdownHref(href: string): boolean {
|
|
27
|
+
const value = href.trim();
|
|
28
|
+
if (!value) return false;
|
|
29
|
+
if (/^(https?:|mailto:)/i.test(value)) return true;
|
|
30
|
+
return /^(\/|#|\.\/|\.\.\/)/.test(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseMarkdownInline(value: string): MarkdownInline[] {
|
|
34
|
+
const tokens: MarkdownInline[] = [];
|
|
35
|
+
let cursor = 0;
|
|
36
|
+
let match: RegExpExecArray | null;
|
|
37
|
+
INLINE_PATTERN.lastIndex = 0;
|
|
38
|
+
|
|
39
|
+
while ((match = INLINE_PATTERN.exec(value)) !== null) {
|
|
40
|
+
if (match.index > cursor) tokens.push({ type: 'text', value: value.slice(cursor, match.index) });
|
|
41
|
+
const raw = match[0];
|
|
42
|
+
if (match[1]) {
|
|
43
|
+
tokens.push({ type: 'code', value: raw.slice(1, -1) });
|
|
44
|
+
} else if (match[2]) {
|
|
45
|
+
tokens.push({ type: 'strong', value: raw.slice(2, -2) });
|
|
46
|
+
} else if (match[3]) {
|
|
47
|
+
tokens.push({ type: 'strike', value: raw.slice(2, -2) });
|
|
48
|
+
} else if (match[4]) {
|
|
49
|
+
tokens.push({ type: 'emphasis', value: raw.slice(1, -1) });
|
|
50
|
+
} else if (match[5]) {
|
|
51
|
+
const parsed = raw.match(/^\[([^\[\]]+)\]\(([^\s()]+)(?:\s+"[^"\n]*")?\)$/);
|
|
52
|
+
if (parsed && isSafeMarkdownHref(parsed[2])) {
|
|
53
|
+
tokens.push({ type: 'link', value: parsed[1], href: parsed[2] });
|
|
54
|
+
} else {
|
|
55
|
+
tokens.push({ type: 'text', value: raw });
|
|
56
|
+
}
|
|
57
|
+
} else if (match[6]) {
|
|
58
|
+
tokens.push({ type: 'link', value: raw, href: raw });
|
|
59
|
+
}
|
|
60
|
+
cursor = INLINE_PATTERN.lastIndex;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (cursor < value.length) tokens.push({ type: 'text', value: value.slice(cursor) });
|
|
64
|
+
const merged: MarkdownInline[] = [];
|
|
65
|
+
for (const token of tokens) {
|
|
66
|
+
const previous = merged.at(-1);
|
|
67
|
+
if (token.type === 'text' && previous?.type === 'text') {
|
|
68
|
+
merged[merged.length - 1] = { type: 'text', value: previous.value + token.value };
|
|
69
|
+
} else {
|
|
70
|
+
merged.push(token);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return merged.length > 0 ? merged : [{ type: 'text', value }];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function splitTableRow(line: string): string[] {
|
|
77
|
+
return line
|
|
78
|
+
.trim()
|
|
79
|
+
.replace(/^\|/, '')
|
|
80
|
+
.replace(/\|$/, '')
|
|
81
|
+
.split(/(?<!\\)\|/)
|
|
82
|
+
.map((cell) => cell.trim().replace(/\\\|/g, '|'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function startsBlock(lines: readonly string[], index: number): boolean {
|
|
86
|
+
const line = lines[index] ?? '';
|
|
87
|
+
return /^(?:```|~~~|#{1,6}\s+|>\s?|\s*(?:[-+*]|\d+[.)])\s+|\s*(?:-{3,}|\*{3,}|_{3,})\s*$)/.test(line)
|
|
88
|
+
|| (line.includes('|') && TABLE_DIVIDER.test(lines[index + 1] ?? ''));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseMarkdown(value: string): MarkdownBlock[] {
|
|
92
|
+
const lines = value.replace(/\r\n?/g, '\n').split('\n');
|
|
93
|
+
const blocks: MarkdownBlock[] = [];
|
|
94
|
+
let index = 0;
|
|
95
|
+
|
|
96
|
+
while (index < lines.length) {
|
|
97
|
+
const line = lines[index];
|
|
98
|
+
if (!line.trim()) {
|
|
99
|
+
index += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const fence = line.match(/^\s*(```|~~~)\s*([^\s`]*)?.*$/);
|
|
104
|
+
if (fence) {
|
|
105
|
+
const marker = fence[1];
|
|
106
|
+
const language = (fence[2] ?? '').toLowerCase();
|
|
107
|
+
const code: string[] = [];
|
|
108
|
+
let closed = false;
|
|
109
|
+
index += 1;
|
|
110
|
+
while (index < lines.length) {
|
|
111
|
+
if (lines[index].trim() === marker) {
|
|
112
|
+
closed = true;
|
|
113
|
+
index += 1;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
code.push(lines[index]);
|
|
117
|
+
index += 1;
|
|
118
|
+
}
|
|
119
|
+
blocks.push({ type: 'code', language, value: code.join('\n'), closed });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
|
124
|
+
if (heading) {
|
|
125
|
+
blocks.push({ type: 'heading', level: heading[1].length, content: parseMarkdownInline(heading[2]) });
|
|
126
|
+
index += 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
131
|
+
blocks.push({ type: 'rule' });
|
|
132
|
+
index += 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (/^>\s?/.test(line)) {
|
|
137
|
+
const quoted: string[] = [];
|
|
138
|
+
while (index < lines.length && /^>\s?/.test(lines[index])) {
|
|
139
|
+
quoted.push(lines[index].replace(/^>\s?/, ''));
|
|
140
|
+
index += 1;
|
|
141
|
+
}
|
|
142
|
+
blocks.push({ type: 'quote', content: parseMarkdownInline(quoted.join('\n')) });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const listMatch = line.match(/^\s*([-+*]|\d+[.)])\s+(.+)$/);
|
|
147
|
+
if (listMatch) {
|
|
148
|
+
const ordered = /^\d/.test(listMatch[1]);
|
|
149
|
+
const items: Array<{ content: MarkdownInline[]; checked?: boolean }> = [];
|
|
150
|
+
while (index < lines.length) {
|
|
151
|
+
const item = lines[index].match(/^\s*([-+*]|\d+[.)])\s+(.+)$/);
|
|
152
|
+
if (!item || /^\d/.test(item[1]) !== ordered) break;
|
|
153
|
+
const task = item[2].match(/^\[([ xX])\]\s+(.+)$/);
|
|
154
|
+
items.push({
|
|
155
|
+
content: parseMarkdownInline(task ? task[2] : item[2]),
|
|
156
|
+
...(task ? { checked: task[1].toLowerCase() === 'x' } : {}),
|
|
157
|
+
});
|
|
158
|
+
index += 1;
|
|
159
|
+
}
|
|
160
|
+
blocks.push({ type: 'list', ordered, items });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (line.includes('|') && TABLE_DIVIDER.test(lines[index + 1] ?? '')) {
|
|
165
|
+
const header = splitTableRow(line).map(parseMarkdownInline);
|
|
166
|
+
const rows: MarkdownInline[][][] = [];
|
|
167
|
+
index += 2;
|
|
168
|
+
while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
|
|
169
|
+
rows.push(splitTableRow(lines[index]).map(parseMarkdownInline));
|
|
170
|
+
index += 1;
|
|
171
|
+
}
|
|
172
|
+
blocks.push({ type: 'table', header, rows });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const paragraph = [line];
|
|
177
|
+
index += 1;
|
|
178
|
+
while (index < lines.length && lines[index].trim() && !startsBlock(lines, index)) {
|
|
179
|
+
paragraph.push(lines[index]);
|
|
180
|
+
index += 1;
|
|
181
|
+
}
|
|
182
|
+
blocks.push({ type: 'paragraph', content: parseMarkdownInline(paragraph.join('\n')) });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return blocks;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const KEYWORDS: Record<string, ReadonlySet<string>> = {
|
|
189
|
+
javascript: new Set(['async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally', 'for', 'from', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'of', 'return', 'static', 'switch', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'yield']),
|
|
190
|
+
typescript: new Set(['abstract', 'any', 'as', 'asserts', 'async', 'await', 'boolean', 'break', 'case', 'catch', 'class', 'const', 'continue', 'declare', 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'finally', 'for', 'from', 'function', 'if', 'implements', 'import', 'in', 'infer', 'instanceof', 'interface', 'keyof', 'let', 'namespace', 'never', 'new', 'number', 'object', 'of', 'readonly', 'return', 'satisfies', 'static', 'string', 'switch', 'symbol', 'throw', 'try', 'type', 'typeof', 'undefined', 'unknown', 'var', 'void', 'while', 'yield']),
|
|
191
|
+
python: new Set(['and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield']),
|
|
192
|
+
shell: new Set(['case', 'do', 'done', 'elif', 'else', 'esac', 'export', 'fi', 'for', 'function', 'if', 'in', 'local', 'then', 'while']),
|
|
193
|
+
css: new Set(['@import', '@media', '@supports', 'and', 'important', 'not', 'only']),
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const LANGUAGE_ALIASES: Record<string, string> = {
|
|
197
|
+
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
|
198
|
+
ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript',
|
|
199
|
+
py: 'python', bash: 'shell', sh: 'shell', zsh: 'shell', jsonc: 'json',
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export function highlightCodeLine(line: string, language = ''): CodeToken[] {
|
|
203
|
+
const normalized = LANGUAGE_ALIASES[language] ?? language;
|
|
204
|
+
const keywords = KEYWORDS[normalized] ?? KEYWORDS.javascript;
|
|
205
|
+
const tokens: CodeToken[] = [];
|
|
206
|
+
let index = 0;
|
|
207
|
+
|
|
208
|
+
const push = (kind: CodeTokenKind, value: string) => {
|
|
209
|
+
if (value) tokens.push({ kind, value });
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
while (index < line.length) {
|
|
213
|
+
const rest = line.slice(index);
|
|
214
|
+
const comment = normalized === 'python' || normalized === 'shell'
|
|
215
|
+
? rest.match(/^#.*/)
|
|
216
|
+
: rest.match(/^\/\/.*|^\/\*.*?\*\//);
|
|
217
|
+
if (comment) {
|
|
218
|
+
push('comment', comment[0]);
|
|
219
|
+
index += comment[0].length;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const string = rest.match(/^(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)/);
|
|
223
|
+
if (string) {
|
|
224
|
+
push('string', string[0]);
|
|
225
|
+
index += string[0].length;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const number = rest.match(/^(?:0x[\da-f]+|\d+(?:\.\d+)?(?:e[+-]?\d+)?)/i);
|
|
229
|
+
if (number) {
|
|
230
|
+
push('number', number[0]);
|
|
231
|
+
index += number[0].length;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const word = rest.match(/^[A-Za-z_$@][\w$-]*/);
|
|
235
|
+
if (word) {
|
|
236
|
+
const literal = /^(?:true|false|null|undefined|None|True|False)$/.test(word[0]);
|
|
237
|
+
push(literal ? 'literal' : keywords.has(word[0]) ? 'keyword' : 'plain', word[0]);
|
|
238
|
+
index += word[0].length;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const plain = rest.match(/^[^A-Za-z_$@'"`\d/#]+/) ?? rest.match(/^./);
|
|
242
|
+
push('plain', plain?.[0] ?? '');
|
|
243
|
+
index += plain?.[0].length ?? 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return tokens;
|
|
247
|
+
}
|
|
@@ -108,6 +108,17 @@ describe("applyConsoleEvent inbox record ids", () => {
|
|
|
108
108
|
expect(new Set(rows.map((r) => r.id)).size).toBe(2);
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
it('deduplicates the same resumable event across history and live delivery', async () => {
|
|
112
|
+
const event = { ...PUSH, runtimeId: 'runtime-a', eventId: 7, timestamp: 1700000000000 };
|
|
113
|
+
await applyConsoleEvent(event);
|
|
114
|
+
await applyConsoleEvent(event);
|
|
115
|
+
|
|
116
|
+
const rows = await idbListInbox('icqq', 'bot-1', 'message');
|
|
117
|
+
expect(rows).toHaveLength(1);
|
|
118
|
+
expect(rows[0]?.id).toBe('runtime-a:7');
|
|
119
|
+
expect(rows[0]?.updatedAt).toBe(1700000000000);
|
|
120
|
+
});
|
|
121
|
+
|
|
111
122
|
it("reads legacy records that only carry endpointKey (camelCase)", async () => {
|
|
112
123
|
// 旧版(DB v1 时代)记录只有 endpointKey 字段,升级后仍需能被列出
|
|
113
124
|
const { idbPutInbox } = await import("./idb-store.js");
|
|
@@ -66,13 +66,29 @@ export async function idbListInbox(
|
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
export async function applyConsoleEvent(event: {
|
|
69
|
+
export async function applyConsoleEvent(event: {
|
|
70
|
+
type: string;
|
|
71
|
+
data?: unknown;
|
|
72
|
+
runtimeId?: string;
|
|
73
|
+
eventId?: number;
|
|
74
|
+
timestamp?: number;
|
|
75
|
+
}): Promise<void> {
|
|
70
76
|
const parsed = parseConsoleInboxEvent(event);
|
|
71
77
|
if (!parsed) return;
|
|
72
|
-
const updatedAt =
|
|
78
|
+
const updatedAt = typeof event.timestamp === 'number' && Number.isFinite(event.timestamp)
|
|
79
|
+
? event.timestamp
|
|
80
|
+
: Date.now();
|
|
81
|
+
const resumableId = typeof event.runtimeId === 'string'
|
|
82
|
+
&& event.runtimeId
|
|
83
|
+
&& Number.isSafeInteger(event.eventId)
|
|
84
|
+
&& (event.eventId ?? 0) > 0
|
|
85
|
+
? `${event.runtimeId}:${event.eventId}`
|
|
86
|
+
: null;
|
|
73
87
|
await idbPutInbox({
|
|
74
|
-
//
|
|
75
|
-
|
|
88
|
+
// Resumable Host events are idempotent across history/live redelivery.
|
|
89
|
+
// Legacy/unsequenced sources retain collision-safe append ids.
|
|
90
|
+
id: resumableId
|
|
91
|
+
?? `${parsed.adapter}:${parsed.endpointKey}:${parsed.type}:${updatedAt}:${inboxEventSeq++}:${Math.random().toString(36).slice(2, 8)}`,
|
|
76
92
|
adapter: parsed.adapter,
|
|
77
93
|
endpoint_id: parsed.endpointKey,
|
|
78
94
|
kind: parsed.kind,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export * from "./types";
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
WebSocketManager,
|
|
4
|
+
type ConsoleEventListener,
|
|
5
|
+
type ConsoleEventRecoveryGap,
|
|
6
|
+
type ConsoleEventRecoveryGapListener,
|
|
7
|
+
} from "./manager";
|
|
3
8
|
export { getWebSocketManager, destroyWebSocketManager, resetWebSocketManager } from "./instance";
|
|
4
9
|
export { useWebSocket, useConfig, useConfigYaml, useFiles, useEnvFiles, useDatabase } from "./hooks";
|
|
5
10
|
import { getWebSocketManager } from "./instance";
|
|
@@ -54,6 +54,32 @@ function sseResponse(status = 200, body?: ReadableStream<Uint8Array> | null) {
|
|
|
54
54
|
} as Response;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
function eventHistoryResponse(items: unknown[] = [], runtimeId = 'runtime-test', latestEventId?: number) {
|
|
58
|
+
const inferredLatest = (items.at(-1) as { eventId?: number } | undefined)?.eventId ?? 0;
|
|
59
|
+
return {
|
|
60
|
+
ok: true,
|
|
61
|
+
status: 200,
|
|
62
|
+
json: async () => ({
|
|
63
|
+
success: true,
|
|
64
|
+
data: {
|
|
65
|
+
runtimeId,
|
|
66
|
+
items,
|
|
67
|
+
oldestAvailableEventId: items.length ? 1 : null,
|
|
68
|
+
latestEventId: latestEventId ?? inferredLatest,
|
|
69
|
+
nextAfter: inferredLatest,
|
|
70
|
+
hasMore: false,
|
|
71
|
+
gap: false,
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
} as Response;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function mockConsoleTransport(sse: () => Response | Promise<Response>) {
|
|
78
|
+
return vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
|
|
79
|
+
return String(input).includes('/api/events/history') ? eventHistoryResponse() : await sse();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
57
83
|
describe("WebSocketManager REST/SSE transport", () => {
|
|
58
84
|
beforeEach(() => {
|
|
59
85
|
vi.useFakeTimers();
|
|
@@ -68,7 +94,7 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
68
94
|
|
|
69
95
|
it("connects SSE without a token (local Host with empty TokenRegistry)", async () => {
|
|
70
96
|
installBrowserGlobals();
|
|
71
|
-
const fetchMock =
|
|
97
|
+
const fetchMock = mockConsoleTransport(() => sseResponse(200));
|
|
72
98
|
vi.stubGlobal("fetch", fetchMock);
|
|
73
99
|
|
|
74
100
|
const manager = new WebSocketManager({ reconnectInterval: 100, maxReconnectAttempts: 3 });
|
|
@@ -81,7 +107,8 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
81
107
|
await Promise.resolve();
|
|
82
108
|
|
|
83
109
|
expect(fetchMock).toHaveBeenCalled();
|
|
84
|
-
const
|
|
110
|
+
const sseCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('/history'))!;
|
|
111
|
+
const init = sseCall[1] as RequestInit;
|
|
85
112
|
const headers = init.headers as Record<string, string>;
|
|
86
113
|
expect(headers.Accept).toBe("text/event-stream");
|
|
87
114
|
expect(headers.Authorization).toBeUndefined();
|
|
@@ -92,7 +119,7 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
92
119
|
|
|
93
120
|
it("sends Authorization when a token is stored", async () => {
|
|
94
121
|
installBrowserGlobals(mockStorage({ zhin_api_token: "secret" }));
|
|
95
|
-
const fetchMock =
|
|
122
|
+
const fetchMock = mockConsoleTransport(() => sseResponse(200));
|
|
96
123
|
vi.stubGlobal("fetch", fetchMock);
|
|
97
124
|
|
|
98
125
|
const manager = new WebSocketManager();
|
|
@@ -101,17 +128,18 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
101
128
|
await Promise.resolve();
|
|
102
129
|
await Promise.resolve();
|
|
103
130
|
|
|
104
|
-
const
|
|
131
|
+
const sseCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('/history'))!;
|
|
132
|
+
const init = sseCall[1] as RequestInit;
|
|
105
133
|
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer secret");
|
|
106
134
|
manager.disconnect();
|
|
107
135
|
});
|
|
108
136
|
|
|
109
137
|
it("reconnects after a transient SSE failure (no ERROR deadlock)", async () => {
|
|
110
138
|
installBrowserGlobals();
|
|
111
|
-
let
|
|
112
|
-
const fetchMock =
|
|
113
|
-
|
|
114
|
-
if (
|
|
139
|
+
let sseCalls = 0;
|
|
140
|
+
const fetchMock = mockConsoleTransport(() => {
|
|
141
|
+
sseCalls += 1;
|
|
142
|
+
if (sseCalls === 1) return sseResponse(503, null);
|
|
115
143
|
return sseResponse(200);
|
|
116
144
|
});
|
|
117
145
|
vi.stubGlobal("fetch", fetchMock);
|
|
@@ -127,14 +155,14 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
127
155
|
await Promise.resolve();
|
|
128
156
|
await Promise.resolve();
|
|
129
157
|
expect(manager.getState()).toBe(ConnectionState.RECONNECTING);
|
|
130
|
-
expect(
|
|
158
|
+
expect(sseCalls).toBe(1);
|
|
131
159
|
|
|
132
160
|
// scheduleReconnect delay = 100 * attempt(1) = 100ms
|
|
133
161
|
await vi.advanceTimersByTimeAsync(100);
|
|
134
162
|
await Promise.resolve();
|
|
135
163
|
await Promise.resolve();
|
|
136
164
|
|
|
137
|
-
expect(
|
|
165
|
+
expect(sseCalls).toBeGreaterThanOrEqual(2);
|
|
138
166
|
expect(manager.getState()).toBe(ConnectionState.CONNECTED);
|
|
139
167
|
manager.disconnect();
|
|
140
168
|
});
|
|
@@ -194,7 +222,7 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
194
222
|
|
|
195
223
|
it("stops reconnecting after maxReconnectAttempts", async () => {
|
|
196
224
|
installBrowserGlobals();
|
|
197
|
-
const fetchMock =
|
|
225
|
+
const fetchMock = mockConsoleTransport(() => sseResponse(500, null));
|
|
198
226
|
vi.stubGlobal("fetch", fetchMock);
|
|
199
227
|
|
|
200
228
|
const manager = new WebSocketManager({
|
|
@@ -210,7 +238,112 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
210
238
|
}
|
|
211
239
|
|
|
212
240
|
expect(manager.getState()).toBe(ConnectionState.ERROR);
|
|
213
|
-
|
|
241
|
+
const sseCalls = fetchMock.mock.calls.filter(([input]) => !String(input).includes('/history'));
|
|
242
|
+
expect(sseCalls.length).toBeLessThanOrEqual(3);
|
|
214
243
|
manager.disconnect();
|
|
215
244
|
});
|
|
245
|
+
|
|
246
|
+
it('recovers typed history before live SSE and preserves event metadata', async () => {
|
|
247
|
+
installBrowserGlobals();
|
|
248
|
+
const encoder = new TextEncoder();
|
|
249
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
250
|
+
start(controller) {
|
|
251
|
+
controller.enqueue(encoder.encode([
|
|
252
|
+
'id: 2',
|
|
253
|
+
'event: message.receive',
|
|
254
|
+
'runtime: runtime-a',
|
|
255
|
+
'timestamp: 200',
|
|
256
|
+
'data: {"adapter":"sandbox","endpointKey":"bot","content":"live"}',
|
|
257
|
+
'',
|
|
258
|
+
'',
|
|
259
|
+
].join('\n')));
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
const historyEvent = {
|
|
263
|
+
runtimeId: 'runtime-a', eventId: 1, type: 'message.receive', timestamp: 100,
|
|
264
|
+
data: { adapter: 'sandbox', endpointKey: 'bot', content: 'history' },
|
|
265
|
+
};
|
|
266
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => (
|
|
267
|
+
String(input).includes('/history')
|
|
268
|
+
? eventHistoryResponse([historyEvent], 'runtime-a')
|
|
269
|
+
: sseResponse(200, stream)
|
|
270
|
+
));
|
|
271
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
272
|
+
const received: Array<{ eventId: number; delivery?: string }> = [];
|
|
273
|
+
const manager = new WebSocketManager();
|
|
274
|
+
manager.onConsoleEvent('message.receive', (event) => received.push({ eventId: event.eventId, delivery: event.delivery }));
|
|
275
|
+
manager.connect();
|
|
276
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
277
|
+
await Promise.resolve();
|
|
278
|
+
await Promise.resolve();
|
|
279
|
+
|
|
280
|
+
expect(received).toEqual([
|
|
281
|
+
{ eventId: 1, delivery: 'history' },
|
|
282
|
+
{ eventId: 2, delivery: 'live' },
|
|
283
|
+
]);
|
|
284
|
+
expect(mockApplyConsoleEvent).toHaveBeenCalledTimes(2);
|
|
285
|
+
manager.disconnect();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('keeps an in-memory cursor when localStorage is unavailable', async () => {
|
|
289
|
+
const storage = mockStorage();
|
|
290
|
+
storage.setItem = () => { throw new Error('storage disabled'); };
|
|
291
|
+
installBrowserGlobals(storage);
|
|
292
|
+
const historyEvent = {
|
|
293
|
+
runtimeId: 'runtime-a', eventId: 1, type: 'message.receive', timestamp: 100,
|
|
294
|
+
data: { adapter: 'sandbox', endpointKey: 'bot', content: 'history' },
|
|
295
|
+
};
|
|
296
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
297
|
+
const after = new URL(String(input)).searchParams.get('after');
|
|
298
|
+
return after === '1'
|
|
299
|
+
? eventHistoryResponse([], 'runtime-a', 1)
|
|
300
|
+
: eventHistoryResponse([historyEvent], 'runtime-a', 1);
|
|
301
|
+
});
|
|
302
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
303
|
+
const received: number[] = [];
|
|
304
|
+
const manager = new WebSocketManager();
|
|
305
|
+
manager.onConsoleEvent('message.receive', (event) => received.push(event.eventId));
|
|
306
|
+
const recover = manager as unknown as { recoverEventHistory(signal: AbortSignal): Promise<void> };
|
|
307
|
+
await recover.recoverEventHistory(new AbortController().signal);
|
|
308
|
+
await recover.recoverEventHistory(new AbortController().signal);
|
|
309
|
+
|
|
310
|
+
expect(received).toEqual([1]);
|
|
311
|
+
expect(fetchMock.mock.calls[1]?.[0]).toContain('after=1');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('exposes a typed recovery gap instead of silently treating it as complete', async () => {
|
|
315
|
+
installBrowserGlobals();
|
|
316
|
+
const response = eventHistoryResponse([], 'runtime-new', 9);
|
|
317
|
+
const originalJson = response.json;
|
|
318
|
+
response.json = async () => {
|
|
319
|
+
const body = await originalJson() as { data: Record<string, unknown> };
|
|
320
|
+
return { ...body, data: { ...body.data, gap: true, oldestAvailableEventId: 8 } };
|
|
321
|
+
};
|
|
322
|
+
vi.stubGlobal('fetch', vi.fn(async () => response));
|
|
323
|
+
const manager = new WebSocketManager();
|
|
324
|
+
const gaps: unknown[] = [];
|
|
325
|
+
manager.onConsoleEventRecoveryGap((gap) => gaps.push(gap));
|
|
326
|
+
const recover = manager as unknown as { recoverEventHistory(signal: AbortSignal): Promise<void> };
|
|
327
|
+
await recover.recoverEventHistory(new AbortController().signal);
|
|
328
|
+
|
|
329
|
+
expect(gaps).toEqual([expect.objectContaining({
|
|
330
|
+
page: expect.objectContaining({ runtimeId: 'runtime-new', gap: true }),
|
|
331
|
+
})]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('does not persist a cursor past an inbox event that failed durable storage', async () => {
|
|
335
|
+
const stored: Record<string, string> = {};
|
|
336
|
+
installBrowserGlobals(mockStorage(stored));
|
|
337
|
+
mockApplyConsoleEvent.mockRejectedValueOnce(new Error('IndexedDB unavailable'));
|
|
338
|
+
const historyEvent = {
|
|
339
|
+
runtimeId: 'runtime-a', eventId: 1, type: 'message.receive', timestamp: 100,
|
|
340
|
+
data: { adapter: 'sandbox', endpointKey: 'bot', content: 'history' },
|
|
341
|
+
};
|
|
342
|
+
vi.stubGlobal('fetch', vi.fn(async () => eventHistoryResponse([historyEvent], 'runtime-a', 1)));
|
|
343
|
+
const manager = new WebSocketManager();
|
|
344
|
+
const recover = manager as unknown as { recoverEventHistory(signal: AbortSignal): Promise<void> };
|
|
345
|
+
await recover.recoverEventHistory(new AbortController().signal);
|
|
346
|
+
|
|
347
|
+
expect(Object.keys(stored).filter((key) => key.includes('event-cursor'))).toEqual([]);
|
|
348
|
+
});
|
|
216
349
|
});
|