wiki-plugin-shoppe 0.0.32 → 0.0.33

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/client/shoppe.js CHANGED
@@ -44,6 +44,10 @@
44
44
  .sw-status.success { background: #d1fae5; color: #065f46; display: block; }
45
45
  .sw-status.error { background: #fee2e2; color: #991b1b; display: block; }
46
46
  .sw-status code { background: rgba(0,0,0,0.08); border-radius: 4px; padding: 1px 5px; font-size: 12px; }
47
+ .sw-progress-bar-track { background: rgba(0,0,0,0.12); border-radius: 4px; height: 8px; margin: 8px 0; overflow: hidden; }
48
+ .sw-progress-bar-fill { height: 100%; background: #1a56db; border-radius: 4px; width: 0%; transition: width 0.25s ease; }
49
+ .sw-progress-meta { display: flex; justify-content: space-between; font-size: 12px; opacity: 0.75; }
50
+ .sw-progress-item { margin-top: 6px; font-size: 13px; font-style: italic; }
47
51
  .sw-remove { display: block; width: 100%; margin-top: 24px; padding: 8px; background: none; border: 1px solid #e5e5ea; border-radius: 8px; font-size: 12px; color: #aaa; cursor: pointer; text-align: center; }
48
52
  .sw-remove:hover { border-color: #cc0000; color: #cc0000; }
49
53
  </style>
@@ -313,25 +317,72 @@
313
317
  // ── Upload ──────────────────────────────────────────────────────────────────
314
318
 
315
319
  async function uploadArchive(file, container) {
320
+ const statusEl = container.querySelector('#sw-upload-status');
321
+
322
+ // Step 1: POST the file, get a jobId back immediately.
316
323
  showStatus(container, '#sw-upload-status', `⏳ Uploading <strong>${file.name}</strong>…`, 'info');
317
324
  const form = new FormData();
318
325
  form.append('archive', file);
326
+ let jobId;
319
327
  try {
320
328
  const resp = await fetch('/plugin/shoppe/upload', { method: 'POST', body: form });
321
329
  const result = await resp.json();
322
- if (!result.success) throw new Error(result.error || 'Upload failed');
330
+ if (!result.success || !result.jobId) throw new Error(result.error || 'Upload failed');
331
+ jobId = result.jobId;
332
+ } catch (err) {
333
+ showStatus(container, '#sw-upload-status', `❌ ${err.message}`, 'error');
334
+ return;
335
+ }
336
+
337
+ // Step 2: Show progress UI and open SSE stream.
338
+ statusEl.className = 'sw-status info';
339
+ statusEl.style.display = 'block';
340
+ statusEl.innerHTML = `
341
+ <div id="sw-progress-title" style="font-weight:600;margin-bottom:6px;">⏳ Processing archive…</div>
342
+ <div class="sw-progress-bar-track"><div id="sw-progress-fill" class="sw-progress-bar-fill"></div></div>
343
+ <div class="sw-progress-meta">
344
+ <span id="sw-progress-count">0 / …</span>
345
+ <span id="sw-progress-pct">0%</span>
346
+ </div>
347
+ <div id="sw-progress-item" class="sw-progress-item"></div>
348
+ `;
349
+
350
+ const fillEl = statusEl.querySelector('#sw-progress-fill');
351
+ const countEl = statusEl.querySelector('#sw-progress-count');
352
+ const pctEl = statusEl.querySelector('#sw-progress-pct');
353
+ const itemEl = statusEl.querySelector('#sw-progress-item');
354
+ const titleEl = statusEl.querySelector('#sw-progress-title');
355
+
356
+ const es = new EventSource(`/plugin/shoppe/upload/progress/${jobId}`);
357
+
358
+ es.addEventListener('start', e => {
359
+ const { total, name } = JSON.parse(e.data);
360
+ titleEl.innerHTML = `⏳ Uploading <strong>${name}</strong> — ${total} item${total !== 1 ? 's' : ''}`;
361
+ countEl.textContent = `0 / ${total}`;
362
+ });
363
+
364
+ es.addEventListener('progress', e => {
365
+ const { current, total, label } = JSON.parse(e.data);
366
+ const pct = total > 0 ? Math.round(current / total * 100) : 0;
367
+ fillEl.style.width = pct + '%';
368
+ countEl.textContent = `${current} / ${total}`;
369
+ pctEl.textContent = pct + '%';
370
+ itemEl.textContent = label;
371
+ });
323
372
 
373
+ es.addEventListener('complete', e => {
374
+ es.close();
375
+ const result = JSON.parse(e.data);
324
376
  const r = result.results;
325
377
  const counts = [
326
- r.books.length && `📚 ${r.books.length} book${r.books.length !== 1 ? 's' : ''}`,
327
- r.music.length && `🎵 ${r.music.length} music item${r.music.length !== 1 ? 's' : ''}`,
328
- r.posts.length && `📝 ${r.posts.length} post${r.posts.length !== 1 ? 's' : ''}`,
329
- r.albums.length && `🖼️ ${r.albums.length} album${r.albums.length !== 1 ? 's' : ''}`,
330
- r.products.length && `📦 ${r.products.length} product${r.products.length !== 1 ? 's' : ''}`,
378
+ r.books.length && `📚 ${r.books.length} book${r.books.length !== 1 ? 's' : ''}`,
379
+ r.music.length && `🎵 ${r.music.length} music item${r.music.length !== 1 ? 's' : ''}`,
380
+ r.posts.length && `📝 ${r.posts.length} post${r.posts.length !== 1 ? 's' : ''}`,
381
+ r.albums.length && `🖼️ ${r.albums.length} album${r.albums.length !== 1 ? 's' : ''}`,
382
+ r.products.length && `📦 ${r.products.length} product${r.products.length !== 1 ? 's' : ''}`,
331
383
  r.appointments && r.appointments.length && `📅 ${r.appointments.length} appointment${r.appointments.length !== 1 ? 's' : ''}`,
332
384
  r.subscriptions && r.subscriptions.length && `🎁 ${r.subscriptions.length} subscription tier${r.subscriptions.length !== 1 ? 's' : ''}`
333
385
  ].filter(Boolean).join(' · ') || 'no items found';
334
-
335
386
  const warnings = (r.warnings && r.warnings.length > 0)
336
387
  ? `<br><br>⚠️ <strong>Warnings (${r.warnings.length}):</strong><br>${r.warnings.map(w => `• ${w}`).join('<br>')}`
337
388
  : '';
@@ -340,9 +391,14 @@
340
391
  <a href="/plugin/shoppe/${result.tenant.uuid}" target="_blank" class="sw-link" style="display:inline-block;margin-top:8px;">View your shoppe →</a>`,
341
392
  'success');
342
393
  loadDirectory(container);
343
- } catch (err) {
344
- showStatus(container, '#sw-upload-status', `❌ ${err.message}`, 'error');
345
- }
394
+ });
395
+
396
+ es.addEventListener('error', e => {
397
+ es.close();
398
+ let msg = 'Upload failed';
399
+ try { msg = JSON.parse(e.data).message; } catch (err) { /* use default */ }
400
+ showStatus(container, '#sw-upload-status', `❌ ${msg}`, 'error');
401
+ });
346
402
  }
347
403
 
348
404
  // ── Save URL (owner) ────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.32",
3
+ "version": "0.0.33",
4
4
  "description": "Multi-tenant digital goods shoppe for federated wiki, powered by Sanora",
5
5
  "keywords": [
6
6
  "wiki",
package/server/server.js CHANGED
@@ -448,8 +448,13 @@ async function registerTenant(name) {
448
448
 
449
449
  function getTenantByIdentifier(identifier) {
450
450
  const tenants = loadTenants();
451
- if (tenants[identifier]) return tenants[identifier];
452
- return Object.values(tenants).find(t => t.emojicode === identifier) || null;
451
+ const entry = tenants[identifier];
452
+ if (entry) {
453
+ // String value = alias left behind after a UUID change (Redis reset); follow it.
454
+ if (typeof entry === 'string') return tenants[entry] || null;
455
+ return entry;
456
+ }
457
+ return Object.values(tenants).find(t => typeof t === 'object' && t.emojicode === identifier) || null;
453
458
  }
454
459
 
455
460
  // ============================================================
@@ -506,15 +511,29 @@ async function sanoraEnsureUser(tenant) {
506
511
  console.log(`[shoppe] Sanora UUID changed ${tenant.uuid} → ${sanoraUser.uuid} (Redis was reset). Updating tenants.json.`);
507
512
  const tenants = loadTenants();
508
513
  const oldUuid = tenant.uuid;
509
- delete tenants[oldUuid];
510
514
  tenant.uuid = sanoraUser.uuid;
511
515
  tenants[sanoraUser.uuid] = tenant;
516
+ // Keep old UUID as a forwarding alias so existing manifest.json / shared URLs still resolve.
517
+ tenants[oldUuid] = sanoraUser.uuid;
512
518
  saveTenants(tenants);
513
519
  }
514
520
 
515
521
  return tenant; // tenant.uuid is now correct
516
522
  }
517
523
 
524
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
525
+
526
+ // fetch() wrapper that retries on 429 with exponential backoff (1s, 2s, 4s).
527
+ async function fetchWithRetry(url, options, maxRetries = 3) {
528
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
529
+ const resp = await fetch(url, options);
530
+ if (resp.status !== 429 || attempt === maxRetries) return resp;
531
+ const delay = 1000 * Math.pow(2, attempt);
532
+ console.warn(`[shoppe] 429 rate limited on ${new URL(url).pathname}, retrying in ${delay}ms…`);
533
+ await sleep(delay);
534
+ }
535
+ }
536
+
518
537
  async function sanoraCreateProduct(tenant, title, category, description, price, shipping, tags) {
519
538
  const { uuid, keys } = tenant;
520
539
  const timestamp = Date.now().toString();
@@ -524,7 +543,7 @@ async function sanoraCreateProduct(tenant, title, category, description, price,
524
543
  sessionless.getKeys = () => keys;
525
544
  const signature = await sessionless.sign(message);
526
545
 
527
- const resp = await fetch(
546
+ const resp = await fetchWithRetry(
528
547
  `${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}`,
529
548
  {
530
549
  method: 'PUT',
@@ -579,7 +598,7 @@ async function sanoraUploadArtifact(tenant, title, fileBuffer, filename, artifac
579
598
  const form = new FormData();
580
599
  form.append('artifact', fileBuffer, { filename, contentType: getMimeType(filename) });
581
600
 
582
- const resp = await fetch(
601
+ const resp = await fetchWithRetry(
583
602
  `${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}/artifact`,
584
603
  {
585
604
  method: 'PUT',
@@ -594,6 +613,10 @@ async function sanoraUploadArtifact(tenant, title, fileBuffer, filename, artifac
594
613
  }
595
614
  );
596
615
 
616
+ if (!resp.ok) {
617
+ const text = await resp.text().catch(() => '');
618
+ throw new Error(`Artifact upload failed (${resp.status}): ${text.slice(0, 200)}`);
619
+ }
597
620
  const result = await resp.json();
598
621
  if (result.error) throw new Error(`Artifact upload failed: ${result.error}`);
599
622
  return result;
@@ -609,7 +632,7 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
609
632
  const form = new FormData();
610
633
  form.append('image', imageBuffer, { filename, contentType: getMimeType(filename) });
611
634
 
612
- const resp = await fetch(
635
+ const resp = await fetchWithRetry(
613
636
  `${getSanoraUrl()}/user/${uuid}/product/${encodeURIComponent(title)}/image`,
614
637
  {
615
638
  method: 'PUT',
@@ -623,6 +646,10 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
623
646
  }
624
647
  );
625
648
 
649
+ if (!resp.ok) {
650
+ const text = await resp.text().catch(() => '');
651
+ throw new Error(`Image upload failed (${resp.status}): ${text.slice(0, 200)}`);
652
+ }
626
653
  const result = await resp.json();
627
654
  if (result.error) throw new Error(`Image upload failed: ${result.error}`);
628
655
  return result;
@@ -731,7 +758,46 @@ async function lucilleUploadVideo(tenant, title, fileBuffer, filename, lucilleUr
731
758
  // ARCHIVE PROCESSING
732
759
  // ============================================================
733
760
 
734
- async function processArchive(zipPath) {
761
+ // ── Upload job store ─────────────────────────────────────────────────────────
762
+ // Each job buffers SSE events so the client can replay them if it connects late.
763
+ const uploadJobs = new Map(); // jobId → { sse: res|null, queue: [], done: false }
764
+
765
+ function countItems(root) {
766
+ let count = 0;
767
+
768
+ const booksDir = path.join(root, 'books');
769
+ if (fs.existsSync(booksDir))
770
+ count += fs.readdirSync(booksDir).filter(f => fs.statSync(path.join(booksDir, f)).isDirectory()).length;
771
+
772
+ const musicDir = path.join(root, 'music');
773
+ if (fs.existsSync(musicDir)) {
774
+ for (const entry of fs.readdirSync(musicDir)) {
775
+ const stat = fs.statSync(path.join(musicDir, entry));
776
+ if (stat.isDirectory()) count++;
777
+ else if (MUSIC_EXTS.has(path.extname(entry).toLowerCase())) count++;
778
+ }
779
+ }
780
+
781
+ const postsDir = path.join(root, 'posts');
782
+ if (fs.existsSync(postsDir)) {
783
+ for (const entry of fs.readdirSync(postsDir)) {
784
+ const entryPath = path.join(postsDir, entry);
785
+ if (!fs.statSync(entryPath).isDirectory()) continue;
786
+ const subDirs = fs.readdirSync(entryPath).filter(f => fs.statSync(path.join(entryPath, f)).isDirectory());
787
+ count += subDirs.length > 0 ? 1 + subDirs.length : 1;
788
+ }
789
+ }
790
+
791
+ for (const dirName of ['albums', 'products', 'subscriptions', 'videos', 'appointments']) {
792
+ const dir = path.join(root, dirName);
793
+ if (fs.existsSync(dir))
794
+ count += fs.readdirSync(dir).filter(f => fs.statSync(path.join(dir, f)).isDirectory()).length;
795
+ }
796
+
797
+ return count;
798
+ }
799
+
800
+ async function processArchive(zipPath, onProgress = () => {}) {
735
801
  const tmpDir = path.join(TMP_DIR, `extract-${Date.now()}`);
736
802
  fs.mkdirSync(tmpDir, { recursive: true });
737
803
  // Use system unzip to stream-extract without loading entire archive into RAM.
@@ -804,6 +870,10 @@ async function processArchive(zipPath) {
804
870
  // If Redis was wiped, this re-creates the user and updates tenant.uuid.
805
871
  tenant = await sanoraEnsureUser(tenant);
806
872
 
873
+ const total = countItems(root);
874
+ let current = 0;
875
+ onProgress({ type: 'start', total, name: manifest.name });
876
+
807
877
  const results = { books: [], music: [], posts: [], albums: [], products: [], videos: [], appointments: [], subscriptions: [], warnings: [] };
808
878
 
809
879
  function readInfo(entryPath) {
@@ -832,6 +902,7 @@ async function processArchive(zipPath) {
832
902
  const description = info.description || '';
833
903
  const price = info.price || 0;
834
904
 
905
+ onProgress({ type: 'progress', current: ++current, total, label: `📚 ${title}` });
835
906
  await sanoraCreateProductResilient(tenant, title, 'book', description, price, 0, buildTags('book', info.keywords));
836
907
 
837
908
  // Cover image — use info.cover to pin a specific file, else first image found
@@ -874,6 +945,7 @@ async function processArchive(zipPath) {
874
945
  try {
875
946
  const description = info.description || `Album: ${albumTitle}`;
876
947
  const price = info.price || 0;
948
+ onProgress({ type: 'progress', current: ++current, total, label: `🎵 ${albumTitle}` });
877
949
  await sanoraCreateProductResilient(tenant, albumTitle, 'music', description, price, 0, buildTags('music,album', info.keywords));
878
950
  const coverFile = info.cover ? (covers.find(f => f === info.cover) || covers[0]) : covers[0];
879
951
  if (coverFile) {
@@ -903,6 +975,7 @@ async function processArchive(zipPath) {
903
975
  const buf = fs.readFileSync(entryPath);
904
976
  const description = trackInfo.description || `Track: ${title}`;
905
977
  const price = trackInfo.price || 0;
978
+ onProgress({ type: 'progress', current: ++current, total, label: `🎵 ${title}` });
906
979
  await sanoraCreateProductResilient(tenant, title, 'music', description, price, 0, buildTags('music,track', trackInfo.keywords));
907
980
  await sanoraUploadArtifact(tenant, title, buf, entry, 'audio');
908
981
  results.music.push({ title, type: 'track' });
@@ -943,6 +1016,7 @@ async function processArchive(zipPath) {
943
1016
  // Register the series itself as a parent product
944
1017
  try {
945
1018
  const description = info.description || `A ${subDirs.length}-part series`;
1019
+ onProgress({ type: 'progress', current: ++current, total, label: `📝 ${seriesTitle} (series)` });
946
1020
  await sanoraCreateProductResilient(tenant, seriesTitle, 'post-series', description, 0, 0, buildTags(`post,series,order:${order}`, info.keywords));
947
1021
 
948
1022
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
@@ -983,6 +1057,7 @@ async function processArchive(zipPath) {
983
1057
  const productTitle = `${seriesTitle}: ${resolvedTitle}`;
984
1058
  const description = partInfo.description || partFm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || resolvedTitle;
985
1059
 
1060
+ onProgress({ type: 'progress', current: ++current, total, label: `📝 ${productTitle}` });
986
1061
  await sanoraCreateProductResilient(tenant, productTitle, 'post', description, 0, 0,
987
1062
  buildTags(`post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`, partInfo.keywords));
988
1063
 
@@ -1025,6 +1100,7 @@ async function processArchive(zipPath) {
1025
1100
  const firstLine = fm.body.split('\n').find(l => l.trim()).replace(/^#+\s*/, '');
1026
1101
  const description = info.description || fm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || firstLine || title;
1027
1102
 
1103
+ onProgress({ type: 'progress', current: ++current, total, label: `📝 ${title}` });
1028
1104
  await sanoraCreateProductResilient(tenant, title, 'post', description, 0, 0, buildTags(`post,blog,order:${order}`, info.keywords));
1029
1105
  await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
1030
1106
 
@@ -1062,6 +1138,7 @@ async function processArchive(zipPath) {
1062
1138
  if (!fs.statSync(entryPath).isDirectory()) continue;
1063
1139
  const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
1064
1140
  try {
1141
+ onProgress({ type: 'progress', current: ++current, total, label: `🖼️ ${entry}` });
1065
1142
  await sanoraCreateProductResilient(tenant, entry, 'album', `Photo album: ${entry}`, 0, 0, 'album,photos');
1066
1143
  if (images.length > 0) {
1067
1144
  const coverBuf = fs.readFileSync(path.join(entryPath, images[0]));
@@ -1099,6 +1176,7 @@ async function processArchive(zipPath) {
1099
1176
  const price = info.price || 0;
1100
1177
  const shipping = info.shipping || 0;
1101
1178
 
1179
+ onProgress({ type: 'progress', current: ++current, total, label: `📦 ${title}` });
1102
1180
  await sanoraCreateProductResilient(tenant, title, 'product', description, price, shipping, buildTags(`product,physical,order:${order}`, info.keywords));
1103
1181
 
1104
1182
  // Hero image: prefer hero.jpg / hero.png, fall back to first image
@@ -1140,6 +1218,7 @@ async function processArchive(zipPath) {
1140
1218
  renewalDays: info.renewalDays || 30
1141
1219
  };
1142
1220
 
1221
+ onProgress({ type: 'progress', current: ++current, total, label: `🎁 ${title}` });
1143
1222
  await sanoraCreateProductResilient(tenant, title, 'subscription', description, price, 0, buildTags('subscription', info.keywords));
1144
1223
 
1145
1224
  // Upload tier metadata (benefits list, renewal period) as a JSON artifact
@@ -1215,6 +1294,7 @@ async function processArchive(zipPath) {
1215
1294
  (lucilleVideoId ? `,lucille-id:${lucilleVideoId},lucille-url:${lucilleBase}` : '');
1216
1295
 
1217
1296
  // Sanora catalog entry (for discovery / storefront)
1297
+ onProgress({ type: 'progress', current: ++current, total, label: `🎬 ${title}` });
1218
1298
  await sanoraCreateProductResilient(tenant, title, 'video', description, price, 0, videoTags);
1219
1299
 
1220
1300
  // Cover / poster image (optional)
@@ -1261,6 +1341,7 @@ async function processArchive(zipPath) {
1261
1341
  advanceDays: info.advanceDays || 30
1262
1342
  };
1263
1343
 
1344
+ onProgress({ type: 'progress', current: ++current, total, label: `📅 ${title}` });
1264
1345
  await sanoraCreateProductResilient(tenant, title, 'appointment', description, price, 0, buildTags('appointment', info.keywords));
1265
1346
 
1266
1347
  // Upload schedule as a JSON artifact so the booking page can retrieve it
@@ -2019,22 +2100,53 @@ async function startServer(params) {
2019
2100
  });
2020
2101
 
2021
2102
  // Upload goods archive (auth via manifest uuid+emojicode)
2022
- app.post('/plugin/shoppe/upload', upload.single('archive'), async (req, res) => {
2023
- try {
2024
- if (!req.file) {
2025
- return res.status(400).json({ success: false, error: 'No archive uploaded' });
2026
- }
2027
- console.log('[shoppe] Processing archive:', req.file.originalname);
2028
- const result = await processArchive(req.file.path);
2029
- res.json({ success: true, ...result });
2030
- } catch (err) {
2031
- console.error('[shoppe] upload error:', err);
2032
- res.status(500).json({ success: false, error: err.message });
2033
- } finally {
2034
- if (req.file && fs.existsSync(req.file.path)) {
2035
- try { fs.unlinkSync(req.file.path); } catch (e) {}
2036
- }
2103
+ app.post('/plugin/shoppe/upload', upload.single('archive'), (req, res) => {
2104
+ if (!req.file) {
2105
+ return res.status(400).json({ success: false, error: 'No archive uploaded' });
2037
2106
  }
2107
+
2108
+ const jobId = crypto.randomBytes(8).toString('hex');
2109
+ const job = { sse: null, queue: [], done: false };
2110
+ uploadJobs.set(jobId, job);
2111
+ setTimeout(() => uploadJobs.delete(jobId), 15 * 60 * 1000); // clean up after 15 min
2112
+
2113
+ res.json({ success: true, jobId });
2114
+
2115
+ function emit(type, data) {
2116
+ job.queue.push({ type, data });
2117
+ if (job.sse) job.sse.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
2118
+ }
2119
+
2120
+ const zipPath = req.file.path;
2121
+ console.log('[shoppe] Processing archive:', req.file.originalname);
2122
+ processArchive(zipPath, emit)
2123
+ .then(result => emit('complete', { success: true, ...result }))
2124
+ .catch(err => { console.error('[shoppe] upload error:', err); emit('error', { message: err.message }); })
2125
+ .finally(() => {
2126
+ job.done = true;
2127
+ if (job.sse) { job.sse.end(); job.sse = null; }
2128
+ if (fs.existsSync(zipPath)) try { fs.unlinkSync(zipPath); } catch (e) {}
2129
+ });
2130
+ });
2131
+
2132
+ app.get('/plugin/shoppe/upload/progress/:jobId', (req, res) => {
2133
+ const job = uploadJobs.get(req.params.jobId);
2134
+ if (!job) return res.status(404).json({ error: 'Unknown job' });
2135
+
2136
+ res.setHeader('Content-Type', 'text/event-stream');
2137
+ res.setHeader('Cache-Control', 'no-cache');
2138
+ res.setHeader('Connection', 'keep-alive');
2139
+ res.flushHeaders();
2140
+
2141
+ // Replay buffered events for late-connecting clients.
2142
+ for (const evt of job.queue) {
2143
+ res.write(`event: ${evt.type}\ndata: ${JSON.stringify(evt.data)}\n\n`);
2144
+ }
2145
+
2146
+ if (job.done) { res.end(); return; }
2147
+
2148
+ job.sse = res;
2149
+ req.on('close', () => { if (job.sse === res) job.sse = null; });
2038
2150
  });
2039
2151
 
2040
2152
  // Get config (owner only)