ima2-gen 2.0.0 → 2.0.2

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +12 -12
  3. package/bin/commands/backfillThumbs.js +24 -0
  4. package/bin/commands/edit.js +7 -6
  5. package/bin/commands/gen.js +13 -6
  6. package/bin/commands/multimode.js +5 -4
  7. package/bin/commands/node.js +4 -4
  8. package/bin/ima2.js +21 -11
  9. package/bin/lib/config-store.js +1 -1
  10. package/docs/API.md +184 -10
  11. package/docs/CLI.md +11 -4
  12. package/docs/FAQ.ko.md +16 -0
  13. package/docs/FAQ.md +30 -0
  14. package/docs/PROMPT_STUDIO.md +3 -1
  15. package/docs/README.ko.md +7 -3
  16. package/docs/migration/runtime-test-inventory.md +17 -1
  17. package/lib/agentImageVideoGen.js +261 -0
  18. package/lib/agentRuntime.js +11 -260
  19. package/lib/agentSettings.js +1 -1
  20. package/lib/agyImageAdapter.js +259 -0
  21. package/lib/capabilities.js +2 -1
  22. package/lib/configKeys.js +1 -1
  23. package/lib/errorClassify.js +8 -7
  24. package/lib/eventBus.js +71 -0
  25. package/lib/geminiApiImageAdapter.js +179 -0
  26. package/lib/generationErrors.js +3 -1
  27. package/lib/grokImageAdapter.js +74 -128
  28. package/lib/grokImageCore.js +153 -0
  29. package/lib/grokMultimodeAdapter.js +7 -4
  30. package/lib/grokRuntime.js +3 -0
  31. package/lib/grokSizeMapper.js +13 -1
  32. package/lib/grokVideoAdapter.js +14 -7
  33. package/lib/grokVideoCanvas.js +13 -0
  34. package/lib/grokVideoPlannerPrompt.js +53 -6
  35. package/lib/historyList.js +19 -2
  36. package/lib/imageModels.js +15 -0
  37. package/lib/imageThumb.js +38 -0
  38. package/lib/inflight.js +54 -17
  39. package/lib/multimodeHelpers.js +10 -0
  40. package/lib/nodeHelpers.js +59 -0
  41. package/lib/oauthProxy/prompts.js +30 -36
  42. package/lib/promptBuilder/systemPrompt.js +2 -5
  43. package/lib/promptSafetyPolicy.js +1 -5
  44. package/lib/providerOptions.js +36 -1
  45. package/lib/responsesFallback.js +53 -44
  46. package/lib/routeHelpers.js +44 -0
  47. package/lib/runtimeContext.js +27 -0
  48. package/lib/ssePublish.js +12 -0
  49. package/lib/storageMigration.js +1 -1
  50. package/lib/storyboardPrefix.js +28 -0
  51. package/lib/thumbBackfill.js +70 -0
  52. package/lib/vertexAuth.js +44 -0
  53. package/lib/videoThumb.js +60 -0
  54. package/package.json +7 -2
  55. package/routes/agy.js +44 -0
  56. package/routes/auth.js +242 -0
  57. package/routes/edit.js +48 -8
  58. package/routes/events.js +78 -0
  59. package/routes/generate.js +135 -135
  60. package/routes/history.js +13 -0
  61. package/routes/index.js +8 -0
  62. package/routes/keys.js +254 -0
  63. package/routes/multimode.js +138 -62
  64. package/routes/nodes.js +107 -129
  65. package/routes/quota.js +58 -7
  66. package/routes/video.js +107 -20
  67. package/server.js +123 -0
  68. package/skills/ima2/SKILL.md +98 -21
  69. package/ui/dist/.vite/manifest.json +12 -12
  70. package/ui/dist/assets/AgentWorkspace-Dth6YijN.js +3 -0
  71. package/ui/dist/assets/{CardNewsWorkspace-BN-ga1lG.js → CardNewsWorkspace-Dav3K5CT.js} +2 -2
  72. package/ui/dist/assets/{NodeCanvas-BbMa4IhI.js → NodeCanvas-C4ifFzB1.js} +2 -2
  73. package/ui/dist/assets/{PromptBuilderPanel-DRwBJRDQ.js → PromptBuilderPanel-CEcyU9PL.js} +1 -1
  74. package/ui/dist/assets/{PromptImportDialog-Dp85kHCq.js → PromptImportDialog-CgQ94Gth.js} +2 -2
  75. package/ui/dist/assets/{PromptImportDiscoverySection-BE8Q8MLD.js → PromptImportDiscoverySection-CuzyzbNI.js} +1 -1
  76. package/ui/dist/assets/{PromptImportFolderSection-PtH5x0sc.js → PromptImportFolderSection-DHLGlO6l.js} +1 -1
  77. package/ui/dist/assets/{PromptLibraryPanel-FnM9tHI9.js → PromptLibraryPanel-BOe18we8.js} +2 -2
  78. package/ui/dist/assets/SettingsWorkspace-Cdgnm4Wa.js +1 -0
  79. package/ui/dist/assets/index-C5PSahkr.js +1 -0
  80. package/ui/dist/assets/index-Dn2AhL6d.css +1 -0
  81. package/ui/dist/assets/index-Tjqx6wUV.js +23 -0
  82. package/ui/dist/index.html +2 -2
  83. package/ui/dist/assets/AgentWorkspace-C21zqdTZ.js +0 -3
  84. package/ui/dist/assets/SettingsWorkspace-MARPGyBL.js +0 -1
  85. package/ui/dist/assets/index-BAFI6htx.js +0 -42
  86. package/ui/dist/assets/index-BSXxr_Bt.js +0 -1
  87. package/ui/dist/assets/index-DS-ADE7U.css +0 -1
package/routes/auth.js ADDED
@@ -0,0 +1,242 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
8
+ const GROK_SCOPE = "openid profile email offline_access grok-cli:access api:access";
9
+ const GROK_TOKEN_URL = "https://auth.x.ai/oauth2/token";
10
+ const CODEX_DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
11
+ // Bundled @openai/codex binary (npm dependency), resolved relative to this
12
+ // module so device-auth login works even when `codex` is not on the user's PATH.
13
+ const CODEX_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "node_modules", ".bin", process.platform === "win32" ? "codex.cmd" : "codex");
14
+ const MAX_CONCURRENT_SESSIONS = 20;
15
+ const sessions = new Map();
16
+ function sid() {
17
+ return randomBytes(16).toString("hex");
18
+ }
19
+ function cleanup(id) {
20
+ const s = sessions.get(id);
21
+ if (s?.pollTimer)
22
+ clearInterval(s.pollTimer);
23
+ if (s?.child && !s.child.killed)
24
+ s.child.kill();
25
+ if (s)
26
+ delete s.deviceCode;
27
+ setTimeout(() => sessions.delete(id), 120_000);
28
+ }
29
+ function stripAnsi(s) {
30
+ return s.replace(/\x1B\[[0-9;]*m/g, "");
31
+ }
32
+ function saveGrokTokens(tokens) {
33
+ const dir = join(homedir(), ".progrok");
34
+ if (!existsSync(dir))
35
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
36
+ let email;
37
+ if (typeof tokens.id_token === "string") {
38
+ try {
39
+ const payload = JSON.parse(Buffer.from(tokens.id_token.split(".")[1], "base64url").toString());
40
+ email = payload.email;
41
+ }
42
+ catch { /* ignore */ }
43
+ }
44
+ const data = {
45
+ accessToken: tokens.access_token,
46
+ refreshToken: tokens.refresh_token,
47
+ expiresAt: typeof tokens.expires_in === "number" ? Date.now() + tokens.expires_in * 1000 : undefined,
48
+ tokenEndpoint: GROK_TOKEN_URL,
49
+ };
50
+ if (email)
51
+ data.email = email;
52
+ // Atomic write: temp file (0600) + rename, so concurrent completions or a crash
53
+ // mid-flush can never truncate/corrupt the only credential file. Rename also
54
+ // guarantees final perms are 0600 even if a looser-perm file pre-existed.
55
+ const target = join(dir, "auth.json");
56
+ const tmp = join(dir, `auth.json.tmp-${randomBytes(6).toString("hex")}`);
57
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
58
+ renameSync(tmp, target);
59
+ }
60
+ async function startGrokDeviceCode() {
61
+ const discovery = await fetch("https://auth.x.ai/.well-known/openid-configuration", { signal: AbortSignal.timeout(10000) });
62
+ const disc = await discovery.json();
63
+ if (!disc.device_authorization_endpoint)
64
+ throw new Error("xAI does not expose device_authorization_endpoint");
65
+ const res = await fetch(disc.device_authorization_endpoint, {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
68
+ body: new URLSearchParams({ client_id: GROK_CLIENT_ID, scope: GROK_SCOPE }),
69
+ signal: AbortSignal.timeout(15000),
70
+ });
71
+ if (!res.ok)
72
+ throw new Error(`Device code request failed: ${res.status}`);
73
+ const dc = await res.json();
74
+ const id = sid();
75
+ const session = {
76
+ provider: "grok",
77
+ userCode: dc.user_code,
78
+ verificationUrl: dc.verification_uri_complete || dc.verification_uri,
79
+ expiresAt: Date.now() + dc.expires_in * 1000,
80
+ status: "pending",
81
+ deviceCode: dc.device_code,
82
+ };
83
+ sessions.set(id, session);
84
+ const interval = Math.max((dc.interval || 5) * 1000, 5000);
85
+ session.pollTimer = setInterval(async () => {
86
+ if (session.status !== "pending") {
87
+ cleanup(id);
88
+ return;
89
+ }
90
+ if (Date.now() > session.expiresAt) {
91
+ session.status = "expired";
92
+ cleanup(id);
93
+ return;
94
+ }
95
+ try {
96
+ const tokenRes = await fetch(disc.token_endpoint, {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
99
+ body: new URLSearchParams({
100
+ grant_type: CODEX_DEVICE_CODE_GRANT,
101
+ client_id: GROK_CLIENT_ID,
102
+ device_code: dc.device_code,
103
+ }),
104
+ signal: AbortSignal.timeout(10000),
105
+ });
106
+ if (tokenRes.ok) {
107
+ const tokens = await tokenRes.json();
108
+ saveGrokTokens(tokens);
109
+ session.status = "complete";
110
+ cleanup(id);
111
+ return;
112
+ }
113
+ const err = await tokenRes.json();
114
+ if (err.error !== "authorization_pending" && err.error !== "slow_down") {
115
+ session.status = "error";
116
+ session.error = err.error || "unknown";
117
+ cleanup(id);
118
+ }
119
+ }
120
+ catch { /* network error, keep polling */ }
121
+ }, interval);
122
+ return { sessionId: id, userCode: dc.user_code, verificationUrl: session.verificationUrl, expiresIn: dc.expires_in };
123
+ }
124
+ function startCodexDeviceCode() {
125
+ return new Promise((resolve, reject) => {
126
+ // Don't hand other providers' secrets to the codex child — it only needs
127
+ // PATH/HOME/codex config to run the ChatGPT device-code login.
128
+ const childEnv = { ...process.env };
129
+ for (const k of ["OPENAI_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", "VERTEX_SERVICE_ACCOUNT_JSON"]) {
130
+ delete childEnv[k];
131
+ }
132
+ const child = spawn(CODEX_BIN, ["login", "--device-auth"], {
133
+ stdio: ["ignore", "pipe", "pipe"],
134
+ env: childEnv,
135
+ });
136
+ let stdout = "";
137
+ let resolved = false;
138
+ const id = sid();
139
+ const session = {
140
+ provider: "codex",
141
+ userCode: "",
142
+ verificationUrl: "",
143
+ expiresAt: Date.now() + 15 * 60 * 1000,
144
+ status: "pending",
145
+ child,
146
+ };
147
+ sessions.set(id, session);
148
+ // Server-side reaper: if the client abandons the flow (closes browser, stops
149
+ // polling), kill the lingering codex child instead of waiting for it to self-exit.
150
+ const reaper = setTimeout(() => {
151
+ if (session.status === "pending") {
152
+ session.status = "expired";
153
+ cleanup(id);
154
+ }
155
+ }, 16 * 60 * 1000);
156
+ reaper.unref?.();
157
+ child.stdout?.on("data", (chunk) => {
158
+ stdout += chunk.toString();
159
+ if (resolved)
160
+ return;
161
+ const clean = stripAnsi(stdout);
162
+ const urlMatch = clean.match(/https:\/\/auth\.openai\.com\/codex\/device/);
163
+ const codeMatch = clean.match(/^\s+([A-Z0-9]{4}-[A-Z0-9]{4,5})\s*$/m);
164
+ if (urlMatch && codeMatch) {
165
+ resolved = true;
166
+ session.userCode = codeMatch[1];
167
+ session.verificationUrl = urlMatch[0];
168
+ resolve({
169
+ sessionId: id,
170
+ userCode: codeMatch[1],
171
+ verificationUrl: urlMatch[0],
172
+ expiresIn: 900,
173
+ });
174
+ }
175
+ });
176
+ child.stderr?.on("data", () => { });
177
+ child.on("close", (code) => {
178
+ if (!resolved) {
179
+ sessions.delete(id);
180
+ reject(new Error(`codex login exited with code ${code} before providing device code`));
181
+ return;
182
+ }
183
+ session.status = code === 0 ? "complete" : "error";
184
+ if (code !== 0)
185
+ session.error = `codex exited with code ${code}`;
186
+ cleanup(id);
187
+ });
188
+ child.on("error", (err) => {
189
+ if (!resolved) {
190
+ sessions.delete(id);
191
+ reject(new Error(`codex not found: ${err.message}`));
192
+ return;
193
+ }
194
+ session.status = "error";
195
+ session.error = err.message;
196
+ cleanup(id);
197
+ });
198
+ setTimeout(() => {
199
+ if (!resolved) {
200
+ sessions.delete(id);
201
+ if (!child.killed)
202
+ child.kill();
203
+ reject(new Error("Timed out waiting for codex device code output"));
204
+ }
205
+ }, 30000);
206
+ });
207
+ }
208
+ export function registerAuthRoutes(app) {
209
+ app.post("/api/auth/switch", async (req, res) => {
210
+ const provider = req.body?.provider;
211
+ if (provider !== "grok" && provider !== "codex") {
212
+ return res.status(400).json({ error: "provider must be grok or codex" });
213
+ }
214
+ if (sessions.size >= MAX_CONCURRENT_SESSIONS) {
215
+ return res.status(429).json({ error: "Too many pending auth sessions" });
216
+ }
217
+ try {
218
+ const result = provider === "grok"
219
+ ? await startGrokDeviceCode()
220
+ : await startCodexDeviceCode();
221
+ res.json(result);
222
+ }
223
+ catch (e) {
224
+ res.status(502).json({ error: e.message });
225
+ }
226
+ });
227
+ app.get("/api/auth/switch/:sessionId", (req, res) => {
228
+ const session = sessions.get(req.params.sessionId);
229
+ if (!session)
230
+ return res.status(404).json({ status: "expired" });
231
+ if (session.status === "complete")
232
+ return res.json({ status: "complete" });
233
+ if (session.status === "error")
234
+ return res.json({ status: "error", error: session.error });
235
+ if (Date.now() > session.expiresAt) {
236
+ session.status = "expired";
237
+ cleanup(req.params.sessionId);
238
+ return res.json({ status: "expired" });
239
+ }
240
+ res.json({ status: "pending" });
241
+ });
242
+ }
package/routes/edit.js CHANGED
@@ -3,11 +3,14 @@ import { safeWriteSidecar } from "../lib/atomicWrite.js";
3
3
  import { join } from "path";
4
4
  import { randomBytes } from "crypto";
5
5
  import { detectImageMimeFromB64 } from "../lib/refs.js";
6
+ import { generateImageThumbnailFromBuffer } from "../lib/imageThumb.js";
6
7
  import { classifyUpstreamError } from "../lib/errorClassify.js";
7
8
  import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
8
9
  import { resolveProviderOptions } from "../lib/providerOptions.js";
9
10
  import { editViaResponses } from "../lib/responsesImageAdapter.js";
10
11
  import { editViaGrok } from "../lib/grokImageAdapter.js";
12
+ import { generateViaAgy } from "../lib/agyImageAdapter.js";
13
+ import { generateViaGeminiApi } from "../lib/geminiApiImageAdapter.js";
11
14
  import { startJob, finishJob, registerJobAbortController, isJobCanceled } from "../lib/inflight.js";
12
15
  import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "../lib/generationCancel.js";
13
16
  import { logEvent, logError } from "../lib/logger.js";
@@ -124,11 +127,11 @@ export function registerEditRoutes(app, ctxRaw) {
124
127
  finishErrorCode = "INVALID_EDIT_INPUT";
125
128
  return res.status(400).json({ error: "Prompt and image are required" });
126
129
  }
127
- if (activeProvider === "grok" && rawMask) {
130
+ if ((activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api") && rawMask) {
128
131
  finishStatus = "error";
129
132
  finishHttpStatus = 400;
130
- finishErrorCode = "GROK_MASK_UNSUPPORTED";
131
- return res.status(400).json({ error: "Grok provider does not support mask editing", code: "GROK_MASK_UNSUPPORTED" });
133
+ const code = activeProvider === "agy" ? "AGY_MASK_UNSUPPORTED" : activeProvider === "gemini-api" ? "GEMINI_API_MASK_UNSUPPORTED" : "GROK_MASK_UNSUPPORTED";
134
+ return res.status(400).json({ error: `${activeProvider === "agy" ? "Agy" : activeProvider === "gemini-api" ? "Gemini API" : "Grok"} provider does not support mask editing`, code });
132
135
  }
133
136
  const maskCheck = validateEditMask(imageB64, rawMask);
134
137
  if (maskCheck.error) {
@@ -166,15 +169,45 @@ export function registerEditRoutes(app, ctxRaw) {
166
169
  let revisedPrompt;
167
170
  let webSearchCalls = 0;
168
171
  let resultMimeFromProvider;
169
- if (activeProvider === "grok") {
172
+ let providerUrl = null;
173
+ if (activeProvider === "gemini-api") {
174
+ const r = await generateViaGeminiApi(`Edit this image: ${prompt}`, requireRuntimeContext(ctx), {
175
+ model: imageModel,
176
+ size: effectiveSize,
177
+ signal: cancelController.signal,
178
+ requestId,
179
+ references: [{ b64: imageB64, declaredMime: null, detectedMime: detectImageMimeFromB64(imageB64) || null }],
180
+ });
181
+ resultB64 = r.b64;
182
+ usage = r.usage;
183
+ revisedPrompt = r.revisedPrompt;
184
+ webSearchCalls = r.webSearchCalls;
185
+ resultMimeFromProvider = r.mime;
186
+ }
187
+ else if (activeProvider === "agy") {
188
+ const r = await generateViaAgy(`Edit this image: ${prompt}`, {
189
+ references: [{ b64: imageB64, declaredMime: null, detectedMime: detectImageMimeFromB64(imageB64) || null }],
190
+ signal: cancelController.signal,
191
+ requestId,
192
+ });
193
+ resultB64 = r.b64;
194
+ usage = r.usage;
195
+ revisedPrompt = r.revisedPrompt;
196
+ webSearchCalls = r.webSearchCalls;
197
+ resultMimeFromProvider = r.mime;
198
+ }
199
+ else if (activeProvider === "grok" || activeProvider === "grok-api") {
200
+ const directApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined;
170
201
  const grokModel = quality === "high" ? "grok-imagine-image-quality" : imageModel;
171
202
  const r = await editViaGrok(prompt, imageB64, ctx, {
172
203
  model: grokModel,
173
204
  size: effectiveSize,
174
205
  signal: cancelController.signal,
175
206
  requestId,
207
+ directApiKey,
176
208
  });
177
209
  resultB64 = r.b64;
210
+ providerUrl = r.providerUrl ?? null;
178
211
  usage = r.usage;
179
212
  revisedPrompt = r.revisedPrompt;
180
213
  webSearchCalls = r.webSearchCalls;
@@ -197,12 +230,16 @@ export function registerEditRoutes(app, ctxRaw) {
197
230
  const elapsed = +((Date.now() - startTime) / 1000).toFixed(1);
198
231
  await mkdir(ctx.config.storage.generatedDir, { recursive: true });
199
232
  throwIfJobCanceled(requestId);
200
- const editMime = activeProvider === "grok"
233
+ const editMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api"
201
234
  ? (resultMimeFromProvider || detectImageMimeFromB64(resultB64) || "image/png")
202
235
  : "image/png";
203
- const editExt = activeProvider === "grok" ? imageFormatFromMime(editMime) : "png";
236
+ const editExt = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? imageFormatFromMime(editMime) : "png";
204
237
  const filename = `${Date.now()}_${randomBytes(ctx.config.ids.generatedHexBytes).toString("hex")}.${editExt}`;
205
- await writeFile(join(ctx.config.storage.generatedDir, filename), Buffer.from(resultB64, "base64"));
238
+ const editBuffer = Buffer.from(resultB64, "base64");
239
+ const editFilePath = join(ctx.config.storage.generatedDir, filename);
240
+ await writeFile(editFilePath, editBuffer);
241
+ generateImageThumbnailFromBuffer(editBuffer, editFilePath).catch(() => { });
242
+ const createdAt = Date.now();
206
243
  const meta = {
207
244
  prompt,
208
245
  userPrompt: prompt,
@@ -218,10 +255,11 @@ export function registerEditRoutes(app, ctxRaw) {
218
255
  provider: activeProvider,
219
256
  kind: "edit",
220
257
  requestId,
221
- createdAt: Date.now(),
258
+ createdAt,
222
259
  usage: usage || null,
223
260
  webSearchCalls,
224
261
  webSearchEnabled,
262
+ ...(providerUrl ? { providerUrl } : {}),
225
263
  };
226
264
  await safeWriteSidecar(join(ctx.config.storage.generatedDir, filename + ".json"), meta);
227
265
  invalidateHistoryIndex();
@@ -247,6 +285,8 @@ export function registerEditRoutes(app, ctxRaw) {
247
285
  promptMode: normalizedPromptMode,
248
286
  webSearchCalls,
249
287
  webSearchEnabled,
288
+ providerUrl,
289
+ createdAt,
250
290
  });
251
291
  }
252
292
  catch (e) {
@@ -0,0 +1,78 @@
1
+ import { subscribe, replaySince, hasReplayGap, replayOldestId, MAX_SSE_LISTENERS } from "../lib/eventBus.js";
2
+ let activeConnections = 0;
3
+ function safeWrite(res, chunk) {
4
+ if (res.writableEnded || res.destroyed)
5
+ return false;
6
+ try {
7
+ res.write(chunk);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ function formatSse(ev) {
15
+ return `id: ${ev.id}\nevent: ${ev.event}\ndata: ${JSON.stringify({ ...ev.data, jobId: ev.jobId })}\n\n`;
16
+ }
17
+ export function registerEventsRoute(app, _ctx) {
18
+ app.get("/api/events", (req, res) => {
19
+ if (activeConnections >= MAX_SSE_LISTENERS) {
20
+ return res.status(503).json({
21
+ error: { code: "SSE_CAPACITY", message: "Too many event stream connections" },
22
+ });
23
+ }
24
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
25
+ res.setHeader("Cache-Control", "no-cache, no-transform");
26
+ res.setHeader("Connection", "keep-alive");
27
+ res.setHeader("X-Accel-Buffering", "no");
28
+ res.flushHeaders?.();
29
+ activeConnections++;
30
+ const headerLastId = parseInt(req.headers["last-event-id"], 10);
31
+ const queryLastId = parseInt(String(req.query.lastEventId ?? ""), 10);
32
+ const lastId = !Number.isNaN(headerLastId) ? headerLastId : queryLastId;
33
+ if (!Number.isNaN(lastId)) {
34
+ if (hasReplayGap(lastId)) {
35
+ const gapPayload = JSON.stringify({
36
+ lastEventId: lastId,
37
+ oldestAvailableId: replayOldestId(),
38
+ });
39
+ if (!safeWrite(res, `event: replay-gap\ndata: ${gapPayload}\n\n`)) {
40
+ activeConnections = Math.max(0, activeConnections - 1);
41
+ return;
42
+ }
43
+ }
44
+ for (const ev of replaySince(lastId)) {
45
+ if (!safeWrite(res, formatSse(ev)))
46
+ break;
47
+ }
48
+ }
49
+ let cleaned = false;
50
+ const unsub = subscribe((ev) => {
51
+ if (!safeWrite(res, formatSse(ev)))
52
+ cleanup();
53
+ });
54
+ const heartbeat = setInterval(() => {
55
+ if (!safeWrite(res, ": ping\n\n"))
56
+ cleanup();
57
+ }, 15_000);
58
+ function cleanup() {
59
+ if (cleaned)
60
+ return;
61
+ cleaned = true;
62
+ unsub();
63
+ clearInterval(heartbeat);
64
+ activeConnections = Math.max(0, activeConnections - 1);
65
+ if (!res.writableEnded && !res.destroyed) {
66
+ try {
67
+ res.end();
68
+ }
69
+ catch {
70
+ /* socket already torn down */
71
+ }
72
+ }
73
+ }
74
+ req.on("close", cleanup);
75
+ res.on("close", cleanup);
76
+ res.on("error", cleanup);
77
+ });
78
+ }