@nylorun/agents 0.0.0-bootstrap → 0.3.0-beta

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.
@@ -0,0 +1,491 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { expandPluginPlaceholders } from "./launch.js";
4
+ export const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
5
+ export const MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
6
+ const PLUGIN_FIELDS = new Set([
7
+ "$schema",
8
+ "name",
9
+ "version",
10
+ "description",
11
+ "author",
12
+ "homepage",
13
+ "repository",
14
+ "license",
15
+ "keywords",
16
+ "extensions",
17
+ ]);
18
+ const AUTHOR_FIELDS = new Set(["name", "email", "url"]);
19
+ const RESERVED_ENV = new Set(["PLUGIN_ROOT", "PLUGIN_DATA"]);
20
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
21
+ export class PluginError extends Error {
22
+ code;
23
+ diagnostics;
24
+ constructor(code, message, diagnostics = []) {
25
+ super(message);
26
+ this.name = "PluginError";
27
+ this.code = code;
28
+ this.diagnostics = diagnostics;
29
+ }
30
+ }
31
+ export function loadPlugin(directory) {
32
+ const root = resolveRoot(directory);
33
+ const manifestPath = join(root, "plugin.json");
34
+ const manifestReal = realInside(root, manifestPath);
35
+ if (!manifestReal || !existsSync(manifestReal) || !statSync(manifestReal).isFile())
36
+ throw new PluginError("plugin.manifest-missing", `plugin.json must be a file inside ${root}`, [{ severity: "warning", code: "plugin.manifest-missing", message: "plugin.json is missing or escapes the package root", path: manifestPath }]);
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(readFileSync(manifestReal, "utf8"));
40
+ }
41
+ catch {
42
+ throw new PluginError("plugin.manifest-invalid", "plugin.json is not valid JSON");
43
+ }
44
+ if (!isRecord(parsed))
45
+ throw new PluginError("plugin.manifest-invalid", "plugin.json must be a JSON object");
46
+ const diagnostics = [];
47
+ for (const key of Object.keys(parsed)) {
48
+ if (!PLUGIN_FIELDS.has(key))
49
+ diagnostics.push({
50
+ severity: "warning",
51
+ code: "plugin.unknown-field",
52
+ message: `Ignored unknown plugin.json field '${key}'`,
53
+ path: manifestPath,
54
+ });
55
+ }
56
+ if (parsed.$schema !== PLUGIN_SCHEMA)
57
+ throw new PluginError("plugin.schema-unsupported", "plugin.json $schema must be the Agent Plugins 1.0.0 plugin schema", diagnostics);
58
+ if (!isPluginName(parsed.name))
59
+ throw new PluginError("plugin.name-invalid", "plugin.json name must be 1-64 characters of lowercase letters, digits, hyphens, and periods", diagnostics);
60
+ validateMetadata(parsed, diagnostics);
61
+ const extensions = parsed.extensions;
62
+ if (extensions !== undefined && !isRecord(extensions))
63
+ diagnostics.push({
64
+ severity: "warning",
65
+ code: "plugin.extensions-ignored",
66
+ message: "Ignored non-object extensions field",
67
+ path: manifestPath,
68
+ });
69
+ else if (isRecord(extensions)) {
70
+ for (const namespace of Object.keys(extensions))
71
+ diagnostics.push({
72
+ severity: "info",
73
+ code: "plugin.extension-ignored",
74
+ message: `Ignored unimplemented extension namespace '${namespace}'`,
75
+ path: manifestPath,
76
+ });
77
+ }
78
+ const skills = loadSkills(root, diagnostics);
79
+ const mcpServers = loadMcp(root, parsed.name, diagnostics);
80
+ return {
81
+ root,
82
+ name: parsed.name,
83
+ ...(typeof parsed.description === "string" ? { description: parsed.description } : {}),
84
+ skills,
85
+ mcpServers,
86
+ diagnostics,
87
+ };
88
+ }
89
+ function resolveRoot(directory) {
90
+ const resolved = resolve(directory);
91
+ if (!existsSync(resolved))
92
+ throw new PluginError("plugin.missing", `Plugin directory does not exist: ${directory}`);
93
+ const real = realpathSync(resolved);
94
+ if (!statSync(real).isDirectory())
95
+ throw new PluginError("plugin.missing", `Plugin path is not a directory: ${directory}`);
96
+ return real;
97
+ }
98
+ function validateMetadata(manifest, diagnostics) {
99
+ for (const field of ["version", "description", "homepage", "repository", "license"]) {
100
+ if (manifest[field] !== undefined && typeof manifest[field] !== "string")
101
+ throw new PluginError("plugin.manifest-invalid", `plugin.json ${field} must be a string`, diagnostics);
102
+ }
103
+ if (manifest.keywords !== undefined) {
104
+ if (!Array.isArray(manifest.keywords) || manifest.keywords.some((item) => typeof item !== "string"))
105
+ throw new PluginError("plugin.manifest-invalid", "plugin.json keywords must be an array of strings", diagnostics);
106
+ }
107
+ if (manifest.author !== undefined) {
108
+ if (!isRecord(manifest.author))
109
+ throw new PluginError("plugin.manifest-invalid", "plugin.json author must be an object", diagnostics);
110
+ for (const key of Object.keys(manifest.author)) {
111
+ if (!AUTHOR_FIELDS.has(key) || typeof manifest.author[key] !== "string")
112
+ throw new PluginError("plugin.manifest-invalid", "plugin.json author contains an invalid field", diagnostics);
113
+ }
114
+ }
115
+ }
116
+ function loadSkills(root, diagnostics) {
117
+ const location = join(root, "skills");
118
+ if (!existsSync(location))
119
+ return {};
120
+ const real = realInside(root, location);
121
+ if (!real || !statSync(real).isDirectory()) {
122
+ diagnostics.push({
123
+ severity: "warning",
124
+ code: "plugin.skills-invalid",
125
+ message: "skills/ is not a directory inside the package",
126
+ path: location,
127
+ });
128
+ return {};
129
+ }
130
+ const skills = {};
131
+ for (const entry of readdirSync(real, { withFileTypes: true })) {
132
+ if (!entry.isDirectory() || entry.name.startsWith("."))
133
+ continue;
134
+ const directory = join(real, entry.name);
135
+ const directoryReal = realInside(root, directory);
136
+ if (!directoryReal) {
137
+ diagnostics.push({
138
+ severity: "warning",
139
+ code: "plugin.skill-skipped",
140
+ message: `Skipped skill '${entry.name}' because its directory escapes the package`,
141
+ path: directory,
142
+ });
143
+ continue;
144
+ }
145
+ const skillFile = join(directoryReal, "SKILL.md");
146
+ if (!existsSync(skillFile))
147
+ continue;
148
+ const skillReal = realInside(root, skillFile);
149
+ if (!skillReal || !statSync(skillReal).isFile()) {
150
+ diagnostics.push({
151
+ severity: "warning",
152
+ code: "plugin.skill-skipped",
153
+ message: `Skipped skill '${entry.name}' because SKILL.md escapes the package`,
154
+ path: skillFile,
155
+ });
156
+ continue;
157
+ }
158
+ const parsed = parseSkill(readFileSync(skillReal, "utf8"), entry.name);
159
+ if (!parsed) {
160
+ diagnostics.push({
161
+ severity: "warning",
162
+ code: "plugin.skill-skipped",
163
+ message: `Skipped skill '${entry.name}' because SKILL.md is not a valid Agent Skill`,
164
+ path: skillFile,
165
+ });
166
+ continue;
167
+ }
168
+ if (skills[parsed.name]) {
169
+ diagnostics.push({
170
+ severity: "warning",
171
+ code: "plugin.skill-skipped",
172
+ message: `Skipped duplicate skill '${parsed.name}'`,
173
+ path: skillFile,
174
+ });
175
+ continue;
176
+ }
177
+ const resources = readResources(root, directoryReal, diagnostics);
178
+ skills[parsed.name] = {
179
+ name: parsed.name,
180
+ description: parsed.description,
181
+ instructions: parsed.instructions,
182
+ ...(Object.keys(resources).length === 0 ? {} : { resources }),
183
+ };
184
+ }
185
+ return skills;
186
+ }
187
+ function parseSkill(text, directoryName) {
188
+ const match = /^-{3}\r?\n([\s\S]*?)\r?\n-{3}(?:\r?\n|$)/.exec(text);
189
+ if (!match)
190
+ return undefined;
191
+ const fields = {};
192
+ for (const raw of (match[1] ?? "").split(/\r?\n/)) {
193
+ if (!raw.trim() || raw.trimStart().startsWith("#"))
194
+ continue;
195
+ const line = /^([\w-]+)\s*:\s*(.*)$/.exec(raw);
196
+ if (!line || line[1] === "metadata")
197
+ continue;
198
+ fields[line[1]] = unquote(line[2] ?? "");
199
+ }
200
+ const name = fields.name?.trim() ?? "";
201
+ const description = fields.description?.trim() ?? "";
202
+ if (!name || !description || description.length > 1024)
203
+ return undefined;
204
+ if (name.length > 64 || !SKILL_NAME.test(name) || name !== directoryName)
205
+ return undefined;
206
+ return { name, description, instructions: text.slice(match[0].length).replace(/^\r?\n/, "") };
207
+ }
208
+ function readResources(root, directory, diagnostics) {
209
+ const resources = {};
210
+ const walk = (current, depth) => {
211
+ if (depth > 6)
212
+ return;
213
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
214
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
215
+ continue;
216
+ const full = join(current, entry.name);
217
+ if (entry.isDirectory()) {
218
+ const real = realInside(root, full);
219
+ if (!real)
220
+ continue;
221
+ walk(real, depth + 1);
222
+ continue;
223
+ }
224
+ if (!entry.isFile() && !entry.isSymbolicLink())
225
+ continue;
226
+ if (depth === 0 && entry.name === "SKILL.md")
227
+ continue;
228
+ const real = realInside(root, full);
229
+ if (!real || !statSync(real).isFile()) {
230
+ diagnostics.push({
231
+ severity: "warning",
232
+ code: "plugin.resource-denied",
233
+ message: `Denied resource outside the package: ${entry.name}`,
234
+ path: full,
235
+ });
236
+ continue;
237
+ }
238
+ const bytes = readFileSync(real);
239
+ if (bytes.includes(0))
240
+ continue;
241
+ const rel = relative(directory, real).split(sep).join("/");
242
+ if (!rel || rel.startsWith(".."))
243
+ continue;
244
+ resources[rel] = bytes.toString("utf8");
245
+ }
246
+ };
247
+ walk(directory, 0);
248
+ return resources;
249
+ }
250
+ function loadMcp(root, pluginName, diagnostics) {
251
+ const location = join(root, "mcp.json");
252
+ if (!existsSync(location))
253
+ return {};
254
+ const real = realInside(root, location);
255
+ if (!real || !statSync(real).isFile()) {
256
+ diagnostics.push({
257
+ severity: "warning",
258
+ code: "plugin.mcp-invalid",
259
+ message: "mcp.json is not a file inside the package",
260
+ path: location,
261
+ });
262
+ return {};
263
+ }
264
+ let parsed;
265
+ try {
266
+ parsed = JSON.parse(readFileSync(real, "utf8"));
267
+ }
268
+ catch {
269
+ diagnostics.push({
270
+ severity: "warning",
271
+ code: "plugin.mcp-invalid",
272
+ message: "mcp.json is not valid JSON",
273
+ path: location,
274
+ });
275
+ return {};
276
+ }
277
+ if (!isRecord(parsed) || Object.keys(parsed).some((key) => key !== "$schema" && key !== "mcpServers")) {
278
+ diagnostics.push({
279
+ severity: "warning",
280
+ code: "plugin.mcp-invalid",
281
+ message: "mcp.json must contain only $schema and mcpServers",
282
+ path: location,
283
+ });
284
+ return {};
285
+ }
286
+ if (parsed.$schema !== MCP_SCHEMA) {
287
+ diagnostics.push({
288
+ severity: "warning",
289
+ code: "plugin.mcp-invalid",
290
+ message: "mcp.json $schema does not match the plugin schema version",
291
+ path: location,
292
+ });
293
+ return {};
294
+ }
295
+ if (!isRecord(parsed.mcpServers)) {
296
+ diagnostics.push({
297
+ severity: "warning",
298
+ code: "plugin.mcp-invalid",
299
+ message: "mcp.json mcpServers must be an object",
300
+ path: location,
301
+ });
302
+ return {};
303
+ }
304
+ const pluginData = join(dirname(root), ".nylorun", "plugin-data", pluginName);
305
+ const servers = {};
306
+ for (const [name, value] of Object.entries(parsed.mcpServers)) {
307
+ const server = validateServer(name, value, root, pluginData);
308
+ if (!server) {
309
+ diagnostics.push({
310
+ severity: "warning",
311
+ code: "plugin.mcp-server-skipped",
312
+ message: `Skipped invalid MCP server '${name}'`,
313
+ path: location,
314
+ });
315
+ continue;
316
+ }
317
+ if (server.type === "stdio")
318
+ mkdirSync(pluginData, { recursive: true });
319
+ servers[name] = server;
320
+ }
321
+ return servers;
322
+ }
323
+ function validateServer(name, value, root, pluginData) {
324
+ if (!isRecord(value) || typeof value.type !== "string")
325
+ return undefined;
326
+ if (value.type === "stdio")
327
+ return validateStdio(name, value, root, pluginData);
328
+ if (value.type === "streamable-http" || value.type === "sse")
329
+ return validateRemote(name, value);
330
+ return undefined;
331
+ }
332
+ function validateStdio(name, value, root, pluginData) {
333
+ const allowed = new Set(["type", "command", "args", "env", "cwd"]);
334
+ if (Object.keys(value).some((key) => !allowed.has(key)))
335
+ return undefined;
336
+ if (typeof value.command !== "string" || !isCommandToken(value.command))
337
+ return undefined;
338
+ if (value.command.startsWith("./") && !commandStaysInside(root, value.command))
339
+ return undefined;
340
+ let args;
341
+ if (value.args !== undefined) {
342
+ if (!Array.isArray(value.args) || value.args.some((item) => typeof item !== "string"))
343
+ return undefined;
344
+ args = value.args;
345
+ }
346
+ let env;
347
+ if (value.env !== undefined) {
348
+ if (!isRecord(value.env))
349
+ return undefined;
350
+ env = {};
351
+ for (const [key, item] of Object.entries(value.env)) {
352
+ if (typeof item !== "string" || RESERVED_ENV.has(key))
353
+ return undefined;
354
+ env[key] = item;
355
+ }
356
+ }
357
+ let cwd;
358
+ if (value.cwd !== undefined) {
359
+ if (typeof value.cwd !== "string" || !isCwdForm(value.cwd))
360
+ return undefined;
361
+ const expanded = expandPluginPlaceholders(value.cwd, root, pluginData);
362
+ const absolute = expanded.startsWith("/") || /^[A-Za-z]:[\\/]/.test(expanded)
363
+ ? expanded
364
+ : resolve(root, expanded);
365
+ const boundary = value.cwd === "${PLUGIN_DATA}" || value.cwd.startsWith("${PLUGIN_DATA}/")
366
+ ? pluginData
367
+ : root;
368
+ if (!isInside(boundary, absolute))
369
+ return undefined;
370
+ cwd = value.cwd;
371
+ }
372
+ return {
373
+ name,
374
+ type: "stdio",
375
+ command: value.command,
376
+ ...(args === undefined ? {} : { args }),
377
+ ...(env === undefined ? {} : { env }),
378
+ ...(cwd === undefined ? {} : { cwd }),
379
+ };
380
+ }
381
+ function validateRemote(name, value) {
382
+ const allowed = new Set(["type", "url", "headers"]);
383
+ if (Object.keys(value).some((key) => !allowed.has(key)))
384
+ return undefined;
385
+ if (typeof value.url !== "string" || !isRemoteUrl(value.url))
386
+ return undefined;
387
+ let headers;
388
+ if (value.headers !== undefined) {
389
+ if (!isRecord(value.headers))
390
+ return undefined;
391
+ const seen = new Set();
392
+ headers = {};
393
+ for (const [key, item] of Object.entries(value.headers)) {
394
+ if (typeof item !== "string" || !isHeaderName(key) || /[\r\n\0]/.test(item))
395
+ return undefined;
396
+ const folded = key.toLowerCase();
397
+ if (seen.has(folded))
398
+ return undefined;
399
+ seen.add(folded);
400
+ headers[key] = item;
401
+ }
402
+ }
403
+ return {
404
+ name,
405
+ type: value.type,
406
+ url: value.url,
407
+ ...(headers === undefined ? {} : { headers }),
408
+ };
409
+ }
410
+ function commandStaysInside(root, command) {
411
+ const lexical = resolve(root, command);
412
+ if (!isInside(root, lexical))
413
+ return false;
414
+ return !existsSync(lexical) || realInside(root, lexical) !== undefined;
415
+ }
416
+ function isCommandToken(command) {
417
+ if (!command || /\s/.test(command))
418
+ return false;
419
+ if (command.startsWith("./"))
420
+ return !command.split("/").includes("..");
421
+ return !command.includes("/") && !command.includes("\\") && !command.includes("${");
422
+ }
423
+ function isCwdForm(cwd) {
424
+ return (cwd.startsWith("./") ||
425
+ cwd === "${PLUGIN_ROOT}" ||
426
+ cwd.startsWith("${PLUGIN_ROOT}/") ||
427
+ cwd === "${PLUGIN_DATA}" ||
428
+ cwd.startsWith("${PLUGIN_DATA}/"));
429
+ }
430
+ function isRemoteUrl(value) {
431
+ let url;
432
+ try {
433
+ url = new URL(value);
434
+ }
435
+ catch {
436
+ return false;
437
+ }
438
+ if (url.username || url.password || url.hash)
439
+ return false;
440
+ if (url.protocol === "https:")
441
+ return true;
442
+ if (url.protocol !== "http:")
443
+ return false;
444
+ return isLoopback(url.hostname);
445
+ }
446
+ function isLoopback(hostname) {
447
+ const host = hostname.replace(/^\[|\]$/g, "");
448
+ if (host === "localhost" || host === "::1")
449
+ return true;
450
+ const match = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
451
+ if (!match)
452
+ return false;
453
+ return match.slice(1).every((part) => Number(part) <= 255);
454
+ }
455
+ function isHeaderName(name) {
456
+ return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name);
457
+ }
458
+ function isPluginName(value) {
459
+ if (typeof value !== "string" || value.length < 1 || value.length > 64)
460
+ return false;
461
+ if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(value) && value.length !== 1)
462
+ return false;
463
+ if (value.length === 1)
464
+ return /^[a-z0-9]$/.test(value);
465
+ return !value.includes("--") && !value.includes("..");
466
+ }
467
+ function realInside(root, candidate) {
468
+ if (!existsSync(candidate))
469
+ return undefined;
470
+ try {
471
+ const real = realpathSync(candidate);
472
+ return isInside(root, real) ? real : undefined;
473
+ }
474
+ catch {
475
+ return undefined;
476
+ }
477
+ }
478
+ function isInside(root, target) {
479
+ const rel = relative(root, target);
480
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
481
+ }
482
+ function isRecord(value) {
483
+ return !!value && typeof value === "object" && !Array.isArray(value);
484
+ }
485
+ function unquote(value) {
486
+ const trimmed = value.trim();
487
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
488
+ (trimmed.startsWith("'") && trimmed.endsWith("'")))
489
+ return trimmed.slice(1, -1);
490
+ return trimmed;
491
+ }
@@ -0,0 +1,8 @@
1
+ import type { CapabilityDeclaration } from "@nylorun/core/define";
2
+ import { type LoadedPlugin, type PluginDiagnostic } from "./load.js";
3
+ export interface PluginCapability extends CapabilityDeclaration {
4
+ readonly diagnostics: readonly PluginDiagnostic[];
5
+ }
6
+ /** Read an Agent Plugin package and return one capability for `Agent.use`. */
7
+ export declare function plugin(directory: string): PluginCapability;
8
+ export declare function capabilityFromPlugin(loaded: LoadedPlugin): PluginCapability;
@@ -0,0 +1,45 @@
1
+ import { loadPlugin, } from "./load.js";
2
+ const SKILL_USAGE = "The following skills provide specialized instructions. When a task matches a skill description, call load_skill with that name before proceeding.";
3
+ /** Read an Agent Plugin package and return one capability for `Agent.use`. */
4
+ export function plugin(directory) {
5
+ const loaded = loadPlugin(directory);
6
+ return capabilityFromPlugin(loaded);
7
+ }
8
+ export function capabilityFromPlugin(loaded) {
9
+ const names = Object.values(loaded.skills);
10
+ const instructions = names.length === 0
11
+ ? undefined
12
+ : [SKILL_USAGE, formatCatalog(names.map((skill) => ({
13
+ name: skill.name,
14
+ description: skill.description,
15
+ })))];
16
+ const skills = Object.fromEntries(names.map((skill) => [
17
+ skill.name,
18
+ { name: skill.name, description: skill.description },
19
+ ]));
20
+ return {
21
+ id: loaded.name,
22
+ type: "agent-plugin",
23
+ pluginRoot: loaded.root,
24
+ ...(loaded.description === undefined ? {} : { description: loaded.description }),
25
+ ...(instructions === undefined ? {} : { instructions }),
26
+ ...(names.length === 0 ? {} : { skills, skillRecords: loaded.skills }),
27
+ ...(Object.keys(loaded.mcpServers).length === 0
28
+ ? {}
29
+ : { mcpServers: loaded.mcpServers }),
30
+ diagnostics: loaded.diagnostics,
31
+ };
32
+ }
33
+ function formatCatalog(skills) {
34
+ const entries = skills
35
+ .map((skill) => ` <skill>\n <name>${escapeXml(skill.name)}</name>\n <description>${escapeXml(skill.description)}</description>\n </skill>`)
36
+ .join("\n");
37
+ return `<available_skills>\n${entries}\n</available_skills>`;
38
+ }
39
+ function escapeXml(value) {
40
+ return value
41
+ .replaceAll("&", "&amp;")
42
+ .replaceAll("<", "&lt;")
43
+ .replaceAll(">", "&gt;")
44
+ .replaceAll('"', "&quot;");
45
+ }
package/dist/sse.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { type Transport } from "./http.js";
2
+ export interface SSEEvent {
3
+ event: string;
4
+ data: string;
5
+ id?: string;
6
+ }
7
+ /** Fetch-based SSE supports Authorization headers and works in browsers and Node. */
8
+ export declare function readSSE(response: Response, signal: AbortSignal): AsyncGenerator<SSEEvent>;
9
+ export declare function observeSSE(transport: Transport, path: string, options?: {
10
+ signal?: AbortSignal;
11
+ cursor?: string;
12
+ }): AsyncGenerator<SSEEvent>;
package/dist/sse.js ADDED
@@ -0,0 +1,97 @@
1
+ import { delay, RuntimeError } from "./http.js";
2
+ /** Fetch-based SSE supports Authorization headers and works in browsers and Node. */
3
+ export async function* readSSE(response, signal) {
4
+ if (!response.headers.get("content-type")?.includes("text/event-stream"))
5
+ throw new Error("Expected an SSE response");
6
+ if (!response.body)
7
+ throw new Error("SSE response has no body");
8
+ const reader = response.body.getReader();
9
+ const decoder = new TextDecoder();
10
+ let buffer = "";
11
+ let event = "message";
12
+ let data = [];
13
+ let lastId;
14
+ const abort = () => {
15
+ void reader.cancel().catch(() => { });
16
+ };
17
+ signal.addEventListener("abort", abort, { once: true });
18
+ try {
19
+ while (!signal.aborted) {
20
+ const result = await reader.read();
21
+ if (result.done)
22
+ break;
23
+ buffer += decoder.decode(result.value, { stream: true });
24
+ if (buffer.length > 2_000_000)
25
+ throw new Error("SSE frame exceeds size limit");
26
+ let index;
27
+ while ((index = buffer.indexOf("\n")) >= 0) {
28
+ const line = buffer.slice(0, index).replace(/\r$/, "");
29
+ buffer = buffer.slice(index + 1);
30
+ if (line === "") {
31
+ if (data.length)
32
+ yield {
33
+ event,
34
+ data: data.join("\n"),
35
+ ...(lastId === undefined ? {} : { id: lastId }),
36
+ };
37
+ event = "message";
38
+ data = [];
39
+ continue;
40
+ }
41
+ if (line.startsWith(":"))
42
+ continue;
43
+ const colon = line.indexOf(":");
44
+ const field = colon < 0 ? line : line.slice(0, colon);
45
+ const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "");
46
+ if (field === "event")
47
+ event = value;
48
+ if (field === "data")
49
+ data.push(value);
50
+ if (field === "id" && !value.includes("\0"))
51
+ lastId = value;
52
+ if (data.reduce((n, v) => n + v.length, 0) > 2_000_000)
53
+ throw new Error("SSE event exceeds size limit");
54
+ }
55
+ }
56
+ }
57
+ finally {
58
+ signal.removeEventListener("abort", abort);
59
+ await reader.cancel().catch(() => { });
60
+ reader.releaseLock();
61
+ }
62
+ }
63
+ export async function* observeSSE(transport, path, options = {}) {
64
+ const controller = new AbortController();
65
+ const signal = options.signal ?? controller.signal;
66
+ let cursor = options.cursor;
67
+ let retry = 250;
68
+ while (!signal.aborted) {
69
+ try {
70
+ const response = await transport.request(path, {
71
+ signal,
72
+ headers: {
73
+ Accept: "text/event-stream",
74
+ ...(cursor ? { "Last-Event-ID": cursor } : {}),
75
+ },
76
+ });
77
+ for await (const event of readSSE(response, signal)) {
78
+ retry = 250;
79
+ if (event.id)
80
+ cursor = event.id;
81
+ yield event;
82
+ }
83
+ }
84
+ catch (error) {
85
+ if (signal.aborted)
86
+ return;
87
+ if (error instanceof RuntimeError &&
88
+ error.status >= 400 &&
89
+ error.status < 500 &&
90
+ ![408, 429].includes(error.status))
91
+ throw error;
92
+ }
93
+ if (!signal.aborted)
94
+ await delay(retry, signal).catch(() => { });
95
+ retry = Math.min(30000, retry * 2);
96
+ }
97
+ }