@goodandready/dsh-goal 0.2.12 → 0.2.14

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/lib/routes.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { MilestoneStatus, detectLanguage, sessionIdOf } from './goal-engine-constants.js';
4
4
  import { saveGoalArtifact, rollbackToCheckpoint } from './engine-reports.js';
5
5
  import { getTemplatesCatalogue, instantiateTemplate } from './engine-templates.js';
6
+ import { parseIssueChecklist } from './engine-issue-sync.js';
6
7
 
7
8
  export function isLoopback(value) {
8
9
  if (!value) return false;
@@ -84,71 +85,86 @@ export function getSafeCwd(requestedCwd, allowedRoots = []) {
84
85
 
85
86
  /**
86
87
  * Register webServer HTTP routes and SSE stream for DSH Goal
87
- * @param {any} ctx Cordis context
88
- * @param {object} deps Dependencies
89
- * @param {any} deps.engine GoalEngine instance
90
- * @param {Function} deps.getConfig Function returning current config
91
- * @param {Function} deps.stopRunningAgents Stop agents function
92
- * @param {Function} deps.resumeActiveAgent Resume agent function
93
- * @param {Map} deps.sessionAgents Session agent map
94
- * @param {Map} [deps.sseClients] SSE clients map (sid -> Set)
95
88
  */
96
89
  export function registerRoutes(ctx, {
97
90
  engine,
91
+ goalsService,
98
92
  getConfig,
99
93
  stopRunningAgents,
100
94
  resumeActiveAgent,
101
95
  sessionAgents,
102
96
  sseClients = new Map(),
103
- }) {
104
- // Subscribe to engine changes for realtime Server-Sent Events broadcasting
105
- engine.subscribe((snapshot, sid) => {
106
- const clients = sseClients.get(sid);
107
- if (clients && clients.size > 0) {
108
- const payload = `data: ${JSON.stringify(snapshot)}\n\n`;
109
- for (const clientRes of Array.from(clients)) {
110
- try {
111
- clientRes.write(payload);
112
- } catch (err) {
113
- clients.delete(clientRes);
114
- try { clientRes.end(); } catch (e) { /* closed */ }
115
- }
116
- }
117
- if (clients.size === 0) sseClients.delete(sid);
118
- }
119
- if (sid !== 'default' && sseClients.has('default')) {
120
- const defClients = sseClients.get('default');
121
- if (defClients && defClients.size > 0) {
97
+ addonStore,
98
+ budgetGuard,
99
+ snapshotManager,
100
+ milestoneManager,
101
+ logger = console,
102
+ } = {}) {
103
+ // Subscribe to engine if provided (backward compatibility)
104
+ if (engine && typeof engine.subscribe === 'function') {
105
+ engine.subscribe((snapshot, sid) => {
106
+ const clients = sseClients.get(sid);
107
+ if (clients && clients.size > 0) {
122
108
  const payload = `data: ${JSON.stringify(snapshot)}\n\n`;
123
- for (const clientRes of Array.from(defClients)) {
109
+ for (const clientRes of Array.from(clients)) {
124
110
  try {
125
111
  clientRes.write(payload);
126
112
  } catch (err) {
127
- defClients.delete(clientRes);
113
+ clients.delete(clientRes);
128
114
  try { clientRes.end(); } catch (e) { /* closed */ }
129
115
  }
130
116
  }
131
- if (defClients.size === 0) sseClients.delete('default');
117
+ if (clients.size === 0) sseClients.delete(sid);
118
+ }
119
+ if (sid !== 'default' && sseClients.has('default')) {
120
+ const defClients = sseClients.get('default');
121
+ if (defClients && defClients.size > 0) {
122
+ const payload = `data: ${JSON.stringify(snapshot)}\n\n`;
123
+ for (const clientRes of Array.from(defClients)) {
124
+ try {
125
+ clientRes.write(payload);
126
+ } catch (err) {
127
+ defClients.delete(clientRes);
128
+ try { clientRes.end(); } catch (e) { /* closed */ }
129
+ }
130
+ }
131
+ if (defClients.size === 0) sseClients.delete('default');
132
+ }
133
+ }
134
+ });
135
+ }
136
+
137
+ const broadcastEvent = (event, data) => {
138
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
139
+ for (const clientSet of sseClients.values()) {
140
+ if (clientSet instanceof Set) {
141
+ for (const res of clientSet) {
142
+ try { res.write(payload); } catch (e) { clientSet.delete(res); }
143
+ }
144
+ } else if (clientSet && typeof clientSet.write === 'function') {
145
+ try { clientSet.write(payload); } catch (e) { /* closed */ }
132
146
  }
133
147
  }
134
- });
148
+ };
135
149
 
136
- ctx.effect(() => {
137
- if (!ctx.webServer?.register) return () => {};
150
+ const setupWebServer = () => {
151
+ if (!ctx?.webServer?.register) return () => {};
138
152
 
139
153
  // Keepalive ping timer for SSE connections (every 20s)
140
154
  const keepaliveTimer = setInterval(() => {
141
155
  for (const [sid, clients] of sseClients.entries()) {
142
- for (const res of Array.from(clients)) {
143
- try {
144
- res.write(': keepalive\n\n');
145
- } catch (err) {
146
- clients.delete(res);
147
- try { res.end(); } catch (e) { /* closed */ }
156
+ if (clients instanceof Set) {
157
+ for (const res of Array.from(clients)) {
158
+ try {
159
+ res.write(': keepalive\n\n');
160
+ } catch (err) {
161
+ clients.delete(res);
162
+ try { res.end(); } catch (e) { /* closed */ }
163
+ }
164
+ }
165
+ if (clients.size === 0) {
166
+ sseClients.delete(sid);
148
167
  }
149
- }
150
- if (clients.size === 0) {
151
- sseClients.delete(sid);
152
168
  }
153
169
  }
154
170
  }, 20000);
@@ -165,36 +181,48 @@ export function registerRoutes(ctx, {
165
181
  if (req.method === 'GET' && (pathname === '/dsh-goal/events' || pathname === '/dsh-goal/events/')) {
166
182
  if (!isTrustedCaller(req)) {
167
183
  res.statusCode = 403;
168
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
184
+ if (typeof res.setHeader === 'function') res.setHeader('Content-Type', 'application/json; charset=utf-8');
169
185
  return res.end(JSON.stringify({ error: 'Forbidden: untrusted caller origin' }));
170
186
  }
171
187
  const sid = sessionIdOf(req, 'default');
172
- res.writeHead(200, {
173
- 'Content-Type': 'text/event-stream',
174
- 'Cache-Control': 'no-cache, no-transform',
175
- 'Connection': 'keep-alive',
176
- 'X-Accel-Buffering': 'no',
177
- });
188
+ if (typeof res.writeHead === 'function') {
189
+ res.writeHead(200, {
190
+ 'Content-Type': 'text/event-stream',
191
+ 'Cache-Control': 'no-cache, no-transform',
192
+ 'Connection': 'keep-alive',
193
+ 'X-Accel-Buffering': 'no',
194
+ });
195
+ } else {
196
+ res.statusCode = 200;
197
+ if (typeof res.setHeader === 'function') {
198
+ res.setHeader('Content-Type', 'text/event-stream');
199
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
200
+ }
201
+ }
178
202
 
179
203
  if (!sseClients.has(sid)) {
180
204
  sseClients.set(sid, new Set());
181
205
  }
182
206
  sseClients.get(sid).add(res);
183
207
 
184
- const initialSnap = engine.getSnapshot(sid);
208
+ const initialSnap = engine ? engine.getSnapshot(sid) : { sessionId: sid, hasActiveGoal: false, status: 'IDLE' };
185
209
  res.write(`data: ${JSON.stringify(initialSnap)}\n\n`);
186
210
 
187
- req.on('close', () => {
188
- const set = sseClients.get(sid);
189
- if (set) {
190
- set.delete(res);
191
- if (set.size === 0) sseClients.delete(sid);
192
- }
193
- });
211
+ if (typeof req.on === 'function') {
212
+ req.on('close', () => {
213
+ const set = sseClients.get(sid);
214
+ if (set) {
215
+ set.delete(res);
216
+ if (set.size === 0) sseClients.delete(sid);
217
+ }
218
+ });
219
+ }
194
220
  return;
195
221
  }
196
222
 
197
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
223
+ if (typeof res.setHeader === 'function') {
224
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
225
+ }
198
226
 
199
227
  // GET /dsh-goal/templates
200
228
  if (req.method === 'GET' && (pathname === '/dsh-goal/templates' || pathname === '/dsh-goal/templates/')) {
@@ -214,7 +242,15 @@ export function registerRoutes(ctx, {
214
242
  }
215
243
  const sid = sessionIdOf(req, 'default');
216
244
  res.statusCode = 200;
217
- return res.end(JSON.stringify(engine.getSnapshot(sid)));
245
+ if (engine) {
246
+ return res.end(JSON.stringify(engine.getSnapshot(sid)));
247
+ }
248
+ return res.end(JSON.stringify({
249
+ sessionId: sid,
250
+ hasActiveGoal: false,
251
+ state: 'IDLE',
252
+ milestones: [],
253
+ }));
218
254
  }
219
255
 
220
256
  // POST /dsh-goal/action
@@ -251,18 +287,18 @@ export function registerRoutes(ctx, {
251
287
  const MAX_PAYLOAD_BYTES = 256 * 1024;
252
288
  let limitExceeded = false;
253
289
 
254
- req.on('data', (chunk) => {
290
+ const onData = (chunk) => {
255
291
  bodySize += chunk.length;
256
292
  if (bodySize > MAX_PAYLOAD_BYTES) {
257
293
  limitExceeded = true;
258
- req.pause();
294
+ if (typeof req.pause === 'function') req.pause();
259
295
  res.statusCode = 413;
260
296
  return res.end(JSON.stringify({ error: 'Payload too large: max 256 KB allowed' }));
261
297
  }
262
298
  body += chunk;
263
- });
299
+ };
264
300
 
265
- req.on('end', () => {
301
+ const onEnd = () => {
266
302
  if (limitExceeded) return;
267
303
  try {
268
304
  const data = JSON.parse(body || '{}');
@@ -292,17 +328,32 @@ export function registerRoutes(ctx, {
292
328
  }
293
329
  const detectedLang = data.lang || detectLanguage(cleanTitle);
294
330
  const conf = typeof getConfig === 'function' ? getConfig() : { maxIterations: 25 };
295
- result = engine.startGoal(cleanTitle, {
296
- description: typeof description === 'string' ? description.trim() : '',
297
- maxIterations: conf.maxIterations || 25,
298
- lang: detectedLang,
299
- cwd: safeStartCwd,
300
- issueBody: data.issueBody,
301
- }, sid);
331
+
332
+ if (engine) {
333
+ result = engine.startGoal(cleanTitle, {
334
+ description: typeof description === 'string' ? description.trim() : '',
335
+ maxIterations: conf.maxIterations || 25,
336
+ lang: detectedLang,
337
+ cwd: safeStartCwd,
338
+ issueBody: data.issueBody,
339
+ }, sid);
340
+ } else {
341
+ let parsedMilestones = [];
342
+ if (data.issueBody) {
343
+ parsedMilestones = parseIssueChecklist(data.issueBody);
344
+ }
345
+ result = {
346
+ title: cleanTitle,
347
+ sessionId: sid,
348
+ hasActiveGoal: true,
349
+ state: 'RUNNING',
350
+ milestones: parsedMilestones,
351
+ };
352
+ }
302
353
 
303
354
  const startPrompt = detectedLang === 'zh'
304
- ? `🎯 目标已确立:“${cleanTitle}”。请立即通过 goal_set_milestones 制定工作计划(3-7个具体步骤)并开始执行。`
305
- : `🎯 Goal established: "${cleanTitle}". Immediately formulate a work plan (3-7 concrete steps) via tool goal_set_milestones and start executing it.`;
355
+ ? `🎯 目标已确立:“${cleanTitle}”。请立即通过 goal_milestones 制定工作计划并开始执行。`
356
+ : `🎯 Goal established: "${cleanTitle}". Formulate milestones via goal_milestones and proceed.`;
306
357
 
307
358
  if (typeof resumeActiveAgent === 'function') {
308
359
  resumeActiveAgent(startPrompt, sid);
@@ -316,40 +367,52 @@ export function registerRoutes(ctx, {
316
367
  res.statusCode = 400;
317
368
  return res.end(JSON.stringify({ error: 'Nudge text cannot be empty' }));
318
369
  }
319
- result = engine.nudge(text, sid);
320
- if (data.resume) {
321
- engine.resume(sid);
322
- const prompt = engine.getStatePromptInjection(sid);
323
- if (typeof resumeActiveAgent === 'function') {
324
- resumeActiveAgent(prompt, sid);
370
+ if (engine) {
371
+ result = engine.nudge(text, sid);
372
+ if (data.resume) {
373
+ engine.resume(sid);
374
+ const prompt = engine.getStatePromptInjection(sid);
375
+ if (typeof resumeActiveAgent === 'function') {
376
+ resumeActiveAgent(prompt, sid);
377
+ }
325
378
  }
379
+ } else {
380
+ result = { nudged: true, text };
326
381
  }
327
382
  break;
328
383
  }
329
384
 
330
385
  case 'pause':
331
- result = engine.pause(reason || 'Paused by user interface', sid);
386
+ if (engine) {
387
+ result = engine.pause(reason || 'Paused by user interface', sid);
388
+ }
332
389
  if (typeof stopRunningAgents === 'function') {
333
390
  stopRunningAgents(sid);
334
391
  }
335
392
  break;
336
393
 
337
394
  case 'resume':
338
- result = engine.resume(sid);
395
+ if (engine) {
396
+ result = engine.resume(sid);
397
+ }
339
398
  if (typeof resumeActiveAgent === 'function') {
340
399
  resumeActiveAgent(undefined, sid);
341
400
  }
342
401
  break;
343
402
 
344
403
  case 'cancel':
345
- result = engine.cancel(reason || 'Goal cancelled by user', sid);
404
+ if (engine) {
405
+ result = engine.cancel(reason || 'Goal cancelled by user', sid);
406
+ }
346
407
  if (typeof stopRunningAgents === 'function') {
347
408
  stopRunningAgents(sid);
348
409
  }
349
410
  break;
350
411
 
351
412
  case 'clear':
352
- result = engine.clear(sid);
413
+ if (engine) {
414
+ result = engine.clear(sid);
415
+ }
353
416
  if (typeof stopRunningAgents === 'function') {
354
417
  stopRunningAgents(sid);
355
418
  }
@@ -368,12 +431,21 @@ export function registerRoutes(ctx, {
368
431
  res.statusCode = 400;
369
432
  return res.end(JSON.stringify({ error: `Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}` }));
370
433
  }
371
- const ok = engine.updateMilestone(milestoneId, status, notes, sid, data.checklist);
372
- if (!ok) {
373
- res.statusCode = 404;
374
- return res.end(JSON.stringify({ error: `Milestone with id "${milestoneId}" not found` }));
434
+ if (engine) {
435
+ const ok = engine.updateMilestone(milestoneId, status, notes, sid, data.checklist);
436
+ if (!ok) {
437
+ res.statusCode = 404;
438
+ return res.end(JSON.stringify({ error: `Milestone with id "${milestoneId}" not found` }));
439
+ }
440
+ result = engine.getSnapshot(sid);
441
+ } else if (milestoneManager) {
442
+ const ok = milestoneManager.updateMilestone(sid, milestoneId, { status, notes });
443
+ if (!ok) {
444
+ res.statusCode = 404;
445
+ return res.end(JSON.stringify({ error: `Milestone with id "${milestoneId}" not found` }));
446
+ }
447
+ result = { ok: true, milestones: milestoneManager.getMilestones(sid) };
375
448
  }
376
- result = engine.getSnapshot(sid);
377
449
  break;
378
450
  }
379
451
 
@@ -382,26 +454,40 @@ export function registerRoutes(ctx, {
382
454
  res.statusCode = 400;
383
455
  return res.end(JSON.stringify({ error: 'milestoneId and itemIndex are required' }));
384
456
  }
385
- const ok = engine.toggleChecklistItem(milestoneId, data.itemIndex, data.done, sid);
386
- if (!ok) {
387
- res.statusCode = 404;
388
- return res.end(JSON.stringify({ error: `Milestone checklist item not found` }));
457
+ if (engine) {
458
+ const ok = engine.toggleChecklistItem(milestoneId, data.itemIndex, data.done, sid);
459
+ if (!ok) {
460
+ res.statusCode = 404;
461
+ return res.end(JSON.stringify({ error: `Milestone checklist item not found` }));
462
+ }
463
+ result = engine.getSnapshot(sid);
464
+ } else if (milestoneManager) {
465
+ const ok = milestoneManager.toggleChecklistItem(sid, milestoneId, data.itemIndex, data.done);
466
+ if (!ok) {
467
+ res.statusCode = 404;
468
+ return res.end(JSON.stringify({ error: `Milestone checklist item not found` }));
469
+ }
470
+ result = { ok: true, milestones: milestoneManager.getMilestones(sid) };
389
471
  }
390
- result = engine.getSnapshot(sid);
391
472
  break;
392
473
  }
393
474
 
394
475
  case 'extend_budget': {
395
476
  const addTokens = data.addTokens || 50000;
396
- const ok = engine.extendBudget(addTokens, sid);
397
- if (!ok) {
398
- res.statusCode = 404;
399
- return res.end(JSON.stringify({ error: 'No active goal found to extend budget' }));
400
- }
401
- if (typeof resumeActiveAgent === 'function') {
402
- resumeActiveAgent(undefined, sid);
477
+ if (engine) {
478
+ const ok = engine.extendBudget(addTokens, sid);
479
+ if (!ok) {
480
+ res.statusCode = 404;
481
+ return res.end(JSON.stringify({ error: 'No active goal found to extend budget' }));
482
+ }
483
+ if (typeof resumeActiveAgent === 'function') {
484
+ resumeActiveAgent(undefined, sid);
485
+ }
486
+ result = engine.getSnapshot(sid);
487
+ } else if (budgetGuard) {
488
+ budgetGuard.extendBudget(sid, addTokens);
489
+ result = { ok: true, extendedBy: addTokens };
403
490
  }
404
- result = engine.getSnapshot(sid);
405
491
  break;
406
492
  }
407
493
 
@@ -411,7 +497,7 @@ export function registerRoutes(ctx, {
411
497
  res.statusCode = 400;
412
498
  return res.end(JSON.stringify({ error: 'commit hash is required for rollback' }));
413
499
  }
414
- const sessionGoal = engine.getGoal(sid);
500
+ const sessionGoal = engine ? engine.getGoal(sid) : null;
415
501
  const allowedRoots = [process.cwd()];
416
502
  if (process.env.DSH_WORKSPACE_ROOT) {
417
503
  allowedRoots.push(process.env.DSH_WORKSPACE_ROOT);
@@ -432,17 +518,17 @@ export function registerRoutes(ctx, {
432
518
  res.statusCode = 500;
433
519
  return res.end(JSON.stringify({ error: `Failed to rollback to checkpoint ${commit}` }));
434
520
  }
435
- result = engine.getSnapshot(sid);
521
+ result = engine ? engine.getSnapshot(sid) : { ok: true, rolledBack: commit };
436
522
  break;
437
523
  }
438
524
 
439
525
  case 'save_artifact': {
440
- const snap = engine.getSnapshot(sid);
441
- if (!snap || !snap.hasActiveGoal) {
526
+ const snap = engine ? engine.getSnapshot(sid) : null;
527
+ if (engine && (!snap || !snap.hasActiveGoal)) {
442
528
  res.statusCode = 404;
443
529
  return res.end(JSON.stringify({ error: 'No active goal to save artifact for' }));
444
530
  }
445
- const sessionGoal = engine.getGoal(sid);
531
+ const sessionGoal = engine ? engine.getGoal(sid) : null;
446
532
  const allowedRoots = [process.cwd()];
447
533
  if (process.env.DSH_WORKSPACE_ROOT) {
448
534
  allowedRoots.push(process.env.DSH_WORKSPACE_ROOT);
@@ -458,32 +544,38 @@ export function registerRoutes(ctx, {
458
544
  res.statusCode = 400;
459
545
  return res.end(JSON.stringify({ error: 'Invalid or non-existent working directory' }));
460
546
  }
461
- const artifact = saveGoalArtifact(snap, safeCwd);
547
+ const artifact = saveGoalArtifact(snap || { objective: 'Goal Report' }, safeCwd);
462
548
  if (!artifact) {
463
549
  res.statusCode = 500;
464
550
  return res.end(JSON.stringify({ error: 'Failed to write goal artifact to disk' }));
465
551
  }
466
552
  res.statusCode = 200;
467
- return res.end(JSON.stringify({ ok: true, artifact, state: snap }));
553
+ return res.end(JSON.stringify({ ok: true, artifact, state: snap || {} }));
468
554
  }
469
555
 
470
556
  case 'intervene': {
471
- result = engine.intervene(data, sid);
472
- if (data.resume) {
473
- engine.resume(sid);
474
- const prompt = engine.getStatePromptInjection(sid);
475
- if (typeof resumeActiveAgent === 'function') {
476
- resumeActiveAgent(prompt, sid);
557
+ if (engine) {
558
+ result = engine.intervene(data, sid);
559
+ if (data.resume) {
560
+ engine.resume(sid);
561
+ const prompt = engine.getStatePromptInjection(sid);
562
+ if (typeof resumeActiveAgent === 'function') {
563
+ resumeActiveAgent(prompt, sid);
564
+ }
477
565
  }
566
+ } else {
567
+ result = { intervened: true };
478
568
  }
479
569
  break;
480
570
  }
481
571
 
482
572
  case 'preplan_approve': {
483
- result = engine.approvePreplan(sid);
484
- const prompt = engine.getStatePromptInjection(sid);
485
- if (typeof resumeActiveAgent === 'function') {
486
- resumeActiveAgent(prompt, sid);
573
+ if (engine) {
574
+ result = engine.approvePreplan(sid);
575
+ const prompt = engine.getStatePromptInjection(sid);
576
+ if (typeof resumeActiveAgent === 'function') {
577
+ resumeActiveAgent(prompt, sid);
578
+ }
487
579
  }
488
580
  break;
489
581
  }
@@ -491,20 +583,22 @@ export function registerRoutes(ctx, {
491
583
  case 'branch_action': {
492
584
  const op = String(data.operation || data.branchOperation || 'merge').toLowerCase();
493
585
  const targetBranch = String(data.targetBranch || 'main');
494
- if (op === 'merge') {
495
- const ok = engine.mergeGoalBranch(targetBranch, sid);
496
- if (!ok) {
497
- res.statusCode = 500;
498
- return res.end(JSON.stringify({ error: `Failed to merge goal branch into ${targetBranch}` }));
499
- }
500
- } else if (op === 'discard') {
501
- const ok = engine.discardGoalBranch(targetBranch, sid);
502
- if (!ok) {
503
- res.statusCode = 500;
504
- return res.end(JSON.stringify({ error: 'Failed to discard goal branch' }));
586
+ if (engine) {
587
+ if (op === 'merge') {
588
+ const ok = engine.mergeGoalBranch(targetBranch, sid);
589
+ if (!ok) {
590
+ res.statusCode = 500;
591
+ return res.end(JSON.stringify({ error: `Failed to merge goal branch into ${targetBranch}` }));
592
+ }
593
+ } else if (op === 'discard') {
594
+ const ok = engine.discardGoalBranch(targetBranch, sid);
595
+ if (!ok) {
596
+ res.statusCode = 500;
597
+ return res.end(JSON.stringify({ error: 'Failed to discard goal branch' }));
598
+ }
505
599
  }
600
+ result = engine.getSnapshot(sid);
506
601
  }
507
- result = engine.getSnapshot(sid);
508
602
  break;
509
603
  }
510
604
 
@@ -533,12 +627,19 @@ export function registerRoutes(ctx, {
533
627
  }
534
628
 
535
629
  res.statusCode = 200;
536
- return res.end(JSON.stringify({ ok: true, state: result || engine.getSnapshot(sid) }));
630
+ return res.end(JSON.stringify({ ok: true, state: result || (engine ? engine.getSnapshot(sid) : {}) }));
537
631
  } catch (parseErr) {
538
632
  res.statusCode = 400;
539
633
  return res.end(JSON.stringify({ error: parseErr.message }));
540
634
  }
541
- });
635
+ };
636
+
637
+ if (typeof req.on === 'function') {
638
+ req.on('data', onData);
639
+ req.on('end', onEnd);
640
+ } else {
641
+ onEnd();
642
+ }
542
643
  return;
543
644
  }
544
645
 
@@ -552,7 +653,7 @@ export function registerRoutes(ctx, {
552
653
  const allowedMethods = KNOWN_ROUTES[cleanPath];
553
654
  if (allowedMethods && !allowedMethods.includes(req.method)) {
554
655
  res.statusCode = 405;
555
- res.setHeader('Allow', allowedMethods.join(', '));
656
+ if (typeof res.setHeader === 'function') res.setHeader('Allow', allowedMethods.join(', '));
556
657
  return res.end(JSON.stringify({ error: `Method Not Allowed: use ${allowedMethods.join(', ')}` }));
557
658
  }
558
659
 
@@ -565,15 +666,25 @@ export function registerRoutes(ctx, {
565
666
  clearInterval(keepaliveTimer);
566
667
  if (typeof unreg === 'function') unreg();
567
668
  for (const clients of sseClients.values()) {
568
- for (const res of clients) {
569
- try {
570
- res.end();
571
- } catch (err) {
572
- // Already closed
669
+ if (clients instanceof Set) {
670
+ for (const res of clients) {
671
+ try { res.end(); } catch (err) { /* closed */ }
573
672
  }
574
673
  }
575
674
  }
576
675
  sseClients.clear();
577
676
  };
578
- }, 'dsh-goal: HTTP WebServer Routes & SSE');
677
+ };
678
+
679
+ let cleanup = null;
680
+ if (typeof ctx?.effect === 'function') {
681
+ cleanup = ctx.effect(() => setupWebServer(), 'dsh-goal: HTTP WebServer Routes & SSE');
682
+ } else {
683
+ cleanup = setupWebServer();
684
+ }
685
+
686
+ return {
687
+ broadcastEvent,
688
+ cleanup,
689
+ };
579
690
  }