dsh-panel-control 0.3.14 → 0.3.15
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/index.js +804 -706
- package/package.json +1 -1
- package/scripts/host-smoke.mjs +49 -2
package/index.js
CHANGED
|
@@ -1,706 +1,804 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host half of dsh-panel-control.
|
|
3
|
-
*
|
|
4
|
-
* The client half manages the panel-navigation area of the sidebar. Everything
|
|
5
|
-
* it needs is local except one thing: "auto classify" asks a model to file the
|
|
6
|
-
* entries into groups, and only the Host can reach a model.
|
|
7
|
-
*
|
|
8
|
-
* So this half does exactly one job: it registers one POST route under the
|
|
9
|
-
* /api carrier every DSH Host already serves, and answers it with a single
|
|
10
|
-
* non-streaming completion from the profile's default model. The Client calls
|
|
11
|
-
* it through `ctx.connection.rpc.call`, so authentication, the request
|
|
12
|
-
* envelope and the response envelope stay the Host's own, not ours.
|
|
13
|
-
*
|
|
14
|
-
* Nothing is streamed back: the caller needs a small JSON object, and the reply
|
|
15
|
-
* is one short generation (a few hundred output tokens at most).
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { promises as fs } from 'node:fs';
|
|
19
|
-
import os from 'node:os';
|
|
20
|
-
import path from 'node:path';
|
|
21
|
-
import { fileURLToPath } from 'node:url';
|
|
22
|
-
|
|
23
|
-
const PACKAGE = 'dsh-panel-control';
|
|
24
|
-
const ENDPOINT = 'dsh-panel-control/classify';
|
|
25
|
-
const ROUTE_PATH = `/api/${ENDPOINT}`;
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Where the Client's settings live.
|
|
29
|
-
*
|
|
30
|
-
* `localStorage` is not good enough for this: the renderer's origin is the
|
|
31
|
-
* Host's loopback address, and its port changes between app launches, so every
|
|
32
|
-
* restart lands on a fresh, empty storage area and the sidebar silently falls
|
|
33
|
-
* back to registration order. A file under the profile's data directory
|
|
34
|
-
* survives restarts, upgrades and origin changes alike.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
*
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
`
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
} catch {
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
async function
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
*
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
logHost(
|
|
542
|
-
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
if (
|
|
675
|
-
|
|
676
|
-
}
|
|
677
|
-
if (
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
if (
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Host half of dsh-panel-control.
|
|
3
|
+
*
|
|
4
|
+
* The client half manages the panel-navigation area of the sidebar. Everything
|
|
5
|
+
* it needs is local except one thing: "auto classify" asks a model to file the
|
|
6
|
+
* entries into groups, and only the Host can reach a model.
|
|
7
|
+
*
|
|
8
|
+
* So this half does exactly one job: it registers one POST route under the
|
|
9
|
+
* /api carrier every DSH Host already serves, and answers it with a single
|
|
10
|
+
* non-streaming completion from the profile's default model. The Client calls
|
|
11
|
+
* it through `ctx.connection.rpc.call`, so authentication, the request
|
|
12
|
+
* envelope and the response envelope stay the Host's own, not ours.
|
|
13
|
+
*
|
|
14
|
+
* Nothing is streamed back: the caller needs a small JSON object, and the reply
|
|
15
|
+
* is one short generation (a few hundred output tokens at most).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { promises as fs } from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const PACKAGE = 'dsh-panel-control';
|
|
24
|
+
const ENDPOINT = 'dsh-panel-control/classify';
|
|
25
|
+
const ROUTE_PATH = `/api/${ENDPOINT}`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where the Client's settings live.
|
|
29
|
+
*
|
|
30
|
+
* `localStorage` is not good enough for this: the renderer's origin is the
|
|
31
|
+
* Host's loopback address, and its port changes between app launches, so every
|
|
32
|
+
* restart lands on a fresh, empty storage area and the sidebar silently falls
|
|
33
|
+
* back to registration order. A file under the profile's data directory
|
|
34
|
+
* survives restarts, upgrades and origin changes alike.
|
|
35
|
+
*/
|
|
36
|
+
// Tests (and anyone sandboxing this plugin) point DPC_STATE_DIR somewhere else;
|
|
37
|
+
// the smoke harness writes here, and it must not touch the real preferences or
|
|
38
|
+
// the real log that support questions get asked about.
|
|
39
|
+
const STATE_DIR = process.env.DPC_STATE_DIR
|
|
40
|
+
? path.resolve(process.env.DPC_STATE_DIR)
|
|
41
|
+
: path.join(os.homedir(), '.dsh', 'panel-control');
|
|
42
|
+
const PREFS_FILE = path.join(STATE_DIR, 'prefs.json');
|
|
43
|
+
const HOST_LOG = path.join(STATE_DIR, 'host.log');
|
|
44
|
+
const CLIENT_LOG = path.join(STATE_DIR, 'client.log');
|
|
45
|
+
const MAX_PREFS_BYTES = 256 * 1024;
|
|
46
|
+
const LOG_KEEP_BYTES = 256 * 1024;
|
|
47
|
+
|
|
48
|
+
/** The Client half this Host half ships with, used by the recovery nudge below. */
|
|
49
|
+
const CLIENT_FILE = fileURLToPath(new URL('./client.js', import.meta.url));
|
|
50
|
+
|
|
51
|
+
/** Set `DPC_NUDGE=off` to keep the Host strictly read-only about its own files. */
|
|
52
|
+
/**
|
|
53
|
+
* The nudge is opt-in, and that default is deliberate.
|
|
54
|
+
*
|
|
55
|
+
* Rewriting `client.js` makes the loader hot-swap this module into the live
|
|
56
|
+
* page. That re-evaluation runs the whole slot reconcile again, and a plugin
|
|
57
|
+
* whose registration is not idempotent then throws on its second
|
|
58
|
+
* `register({ id: 'workspace-control' })` and loses its sidebar entry: healing
|
|
59
|
+
* this plugin broke a neighbour's. A page reload fixes the frozen list without
|
|
60
|
+
* touching anyone else, so the safe path is the banner, and the nudge stays
|
|
61
|
+
* behind DPC_NUDGE=on for people who need it and accept the risk.
|
|
62
|
+
*/
|
|
63
|
+
const NUDGE_ENABLED = ['on', '1', 'true', 'yes'].includes(
|
|
64
|
+
String(process.env.DPC_NUDGE || '').trim().toLowerCase(),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
/** Set once the nudge below actually rewrote the Client file in this process. */
|
|
68
|
+
let recoveredAt = 0;
|
|
69
|
+
|
|
70
|
+
/** `String.fromCharCode(10)`: a literal newline in source is easy to mangle. */
|
|
71
|
+
const NL = String.fromCharCode(10);
|
|
72
|
+
|
|
73
|
+
/** Identifies one Host process, so a log spanning several launches shows which
|
|
74
|
+
* apply/mount lines belong together. */
|
|
75
|
+
const BOOT = Math.random().toString(36).slice(2, 8);
|
|
76
|
+
|
|
77
|
+
/** Route limits: this endpoint spends model tokens, so only local callers and
|
|
78
|
+
* only a small body are accepted. */
|
|
79
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
80
|
+
const MAX_ENTRIES = 64;
|
|
81
|
+
const MAX_GROUPS = 4;
|
|
82
|
+
const MAX_OUTPUT_TOKENS = 700;
|
|
83
|
+
const TIMEOUT_MS = 45_000;
|
|
84
|
+
|
|
85
|
+
/* ------------------------------------------------------------------ helpers */
|
|
86
|
+
|
|
87
|
+
function isRecord(value) {
|
|
88
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isLoopbackAuthority(authority) {
|
|
92
|
+
if (typeof authority !== 'string' || authority === '') return false;
|
|
93
|
+
try {
|
|
94
|
+
const url = new URL(`http://${authority}`);
|
|
95
|
+
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return false;
|
|
96
|
+
const hostname = url.hostname.replace(/\.$/, '');
|
|
97
|
+
return hostname === 'localhost' || hostname === '::1' || hostname === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(hostname);
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Only the page the Host itself served may call this route.
|
|
105
|
+
*
|
|
106
|
+
* "Host and Origin are both loopback" is not enough: every dev server, every
|
|
107
|
+
* other app's webview and every local playground on this machine is also
|
|
108
|
+
* loopback, so that check let them rewrite the user's layout and spend model
|
|
109
|
+
* tokens. The discriminator is the port: the page is same-origin only when its
|
|
110
|
+
* origin carries the very authority this request arrived on.
|
|
111
|
+
*/
|
|
112
|
+
function isOwnRequest(request) {
|
|
113
|
+
const host = request.headers.get('host');
|
|
114
|
+
if (!isLoopbackAuthority(host)) return false;
|
|
115
|
+
// A browser labels where the request came from; anything but same-origin (or
|
|
116
|
+
// a non-browser caller) is a different page and has no business here.
|
|
117
|
+
const site = request.headers.get('sec-fetch-site');
|
|
118
|
+
if (site !== null && site !== 'same-origin' && site !== 'none') return false;
|
|
119
|
+
const origin = request.headers.get('origin');
|
|
120
|
+
if (origin === null) return true;
|
|
121
|
+
try {
|
|
122
|
+
const url = new URL(origin);
|
|
123
|
+
if (url.host === host) return true;
|
|
124
|
+
// The desktop shell serves the page from a custom protocol: that page is
|
|
125
|
+
// still the Host's own, even though its origin is not an http authority.
|
|
126
|
+
return url.protocol === 'dsh-app:' || url.protocol === 'file:';
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function reply(rpcId, result) {
|
|
133
|
+
// Connection's client requires `error.details`, even when empty.
|
|
134
|
+
const value =
|
|
135
|
+
result && result.ok === false
|
|
136
|
+
? { ...result, error: { ...result.error, details: (result.error && result.error.details) || {} } }
|
|
137
|
+
: result;
|
|
138
|
+
return Response.json({ type: 'server-response', rpcId, result: value });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function refusal(code, message) {
|
|
142
|
+
return { ok: false, error: { code, message } };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function describeError(error) {
|
|
146
|
+
if (error && typeof error.message === 'string' && error.message) return error.message;
|
|
147
|
+
try {
|
|
148
|
+
return JSON.stringify(error);
|
|
149
|
+
} catch {
|
|
150
|
+
return String(error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* --------------------------------------------------------------- local state */
|
|
155
|
+
|
|
156
|
+
async function ensureStateDir() {
|
|
157
|
+
await fs.mkdir(STATE_DIR, { recursive: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function appendLine(file, line) {
|
|
161
|
+
try {
|
|
162
|
+
await ensureStateDir();
|
|
163
|
+
await fs.appendFile(file, `${new Date().toISOString()} [${BOOT}] ${line}\n`, 'utf8');
|
|
164
|
+
const stat = await fs.stat(file);
|
|
165
|
+
if (stat.size > LOG_KEEP_BYTES) {
|
|
166
|
+
const text = await fs.readFile(file, 'utf8');
|
|
167
|
+
await fs.writeFile(file, text.slice(-Math.floor(LOG_KEEP_BYTES / 2)), 'utf8');
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
/* Logging is a convenience: it must never break a request. */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const logHost = (line) => {
|
|
175
|
+
void appendLine(HOST_LOG, line);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const logClient = (line) => {
|
|
179
|
+
void appendLine(CLIENT_LOG, line);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
async function readPrefsFile() {
|
|
183
|
+
try {
|
|
184
|
+
const text = await fs.readFile(PREFS_FILE, 'utf8');
|
|
185
|
+
const parsed = JSON.parse(text);
|
|
186
|
+
return isRecord(parsed) ? parsed : null;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error && error.code === 'ENOENT') return null;
|
|
189
|
+
await logHost(`prefs read failed: ${describeError(error)}`);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function writePrefsFileNow(prefs) {
|
|
195
|
+
if (!isRecord(prefs)) throw new Error('preferences must be an object');
|
|
196
|
+
const text = JSON.stringify(prefs);
|
|
197
|
+
if (text.length > MAX_PREFS_BYTES) throw new Error('preferences are too large');
|
|
198
|
+
await ensureStateDir();
|
|
199
|
+
const temporary = `${PREFS_FILE}.tmp`;
|
|
200
|
+
await fs.writeFile(temporary, text, 'utf8');
|
|
201
|
+
await fs.rename(temporary, PREFS_FILE);
|
|
202
|
+
return text.length;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* One writer at a time.
|
|
207
|
+
*
|
|
208
|
+
* Two windows (or a retry racing the debounce) otherwise both write the same
|
|
209
|
+
* `.tmp` path: one rename wins, the other fails with EPERM/EEXIST and that save
|
|
210
|
+
* is lost even though the caller was told nothing. A promise chain costs nothing
|
|
211
|
+
* here -- saves are rare and small -- and keeps the last write the one on disk.
|
|
212
|
+
*/
|
|
213
|
+
let writeChain = Promise.resolve();
|
|
214
|
+
|
|
215
|
+
function writePrefsFile(prefs) {
|
|
216
|
+
const next = writeChain.then(
|
|
217
|
+
() => writePrefsFileNow(prefs),
|
|
218
|
+
() => writePrefsFileNow(prefs),
|
|
219
|
+
);
|
|
220
|
+
// Keep the chain alive after a rejection: the caller still gets the error.
|
|
221
|
+
writeChain = next.catch(() => {});
|
|
222
|
+
return next;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ------------------------------------------------------------------- prompt */
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* One prompt, no chat history: the model answers a single question about a
|
|
229
|
+
* short list. Everything it needs is inside the message, and the reply is
|
|
230
|
+
* parsed defensively because a model can always answer with prose.
|
|
231
|
+
*/
|
|
232
|
+
function buildPrompt(draft) {
|
|
233
|
+
const language = draft.locale === 'zh' ? '中文' : 'English';
|
|
234
|
+
const groupNames = (draft.groups || []).map((group) => group.name).filter(Boolean);
|
|
235
|
+
const entries = draft.entries.map((entry) => ({ id: entry.id, label: entry.label }));
|
|
236
|
+
return [
|
|
237
|
+
`You are filing the entry list of a developer tool's sidebar into a few named sections.`,
|
|
238
|
+
`Answer in ${language}.`,
|
|
239
|
+
``,
|
|
240
|
+
`Existing section names (reuse them when they fit; you may add new ones):`,
|
|
241
|
+
groupNames.length ? JSON.stringify(groupNames) : '[] (none yet)',
|
|
242
|
+
``,
|
|
243
|
+
`Entries to file, as JSON:`,
|
|
244
|
+
JSON.stringify(entries),
|
|
245
|
+
``,
|
|
246
|
+
`Rules:`,
|
|
247
|
+
`- Produce 2 to ${MAX_GROUPS} sections; every entry appears in exactly one section.`,
|
|
248
|
+
`- Section names are short: 2-4 Chinese characters, or 1-3 English words.`,
|
|
249
|
+
`- Group by what the entries are for, not by their order.`,
|
|
250
|
+
`- Answer with a single JSON object and nothing else: {"groups":[{"name":"...","ids":["..."]}]}`,
|
|
251
|
+
`- No prose, no markdown fence, no comments.`,
|
|
252
|
+
].join('\n');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Pull the first JSON object out of a model reply (tolerates a code fence). */
|
|
256
|
+
function extractJson(text) {
|
|
257
|
+
const raw = typeof text === 'string' ? text : '';
|
|
258
|
+
const start = raw.indexOf('{');
|
|
259
|
+
const end = raw.lastIndexOf('}');
|
|
260
|
+
if (start < 0 || end <= start) return null;
|
|
261
|
+
try {
|
|
262
|
+
return JSON.parse(raw.slice(start, end + 1));
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Keep only the answer this caller can use: known ids, at most MAX_GROUPS
|
|
269
|
+
* sections, short names, no duplicates. */
|
|
270
|
+
function sanitizeAnswer(answer, draft) {
|
|
271
|
+
if (!isRecord(answer) || !Array.isArray(answer.groups)) return null;
|
|
272
|
+
const known = new Set(draft.entries.map((entry) => entry.id));
|
|
273
|
+
const used = new Set();
|
|
274
|
+
const groups = [];
|
|
275
|
+
for (const group of answer.groups) {
|
|
276
|
+
if (!isRecord(group) || !Array.isArray(group.ids)) continue;
|
|
277
|
+
const name = typeof group.name === 'string' ? group.name.trim().slice(0, 24) : '';
|
|
278
|
+
const ids = [];
|
|
279
|
+
for (const id of group.ids) {
|
|
280
|
+
if (typeof id !== 'string' || !known.has(id) || used.has(id)) continue;
|
|
281
|
+
used.add(id);
|
|
282
|
+
ids.push(id);
|
|
283
|
+
}
|
|
284
|
+
if (ids.length === 0) continue;
|
|
285
|
+
groups.push({ name: name || `#${groups.length + 1}`, ids });
|
|
286
|
+
if (groups.length >= MAX_GROUPS) break;
|
|
287
|
+
}
|
|
288
|
+
if (groups.length === 0) return null;
|
|
289
|
+
return { groups };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* --------------------------------------------------------------------- call */
|
|
293
|
+
|
|
294
|
+
function serviceOf(scope, key) {
|
|
295
|
+
try {
|
|
296
|
+
if (scope && typeof scope.get === 'function') {
|
|
297
|
+
const value = scope.get(key);
|
|
298
|
+
if (value !== undefined && value !== null) return value;
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
/* a service that is not available yet reads as missing, not as a crash */
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
return scope ? scope[key] : undefined;
|
|
305
|
+
} catch {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function currentModel(scope) {
|
|
311
|
+
const defaults = serviceOf(scope, 'agentDefaultModel');
|
|
312
|
+
if (defaults && typeof defaults.currentSelection === 'function') {
|
|
313
|
+
try {
|
|
314
|
+
const selection = defaults.currentSelection();
|
|
315
|
+
if (selection && typeof selection.provider === 'string' && typeof selection.model === 'string') {
|
|
316
|
+
return selection;
|
|
317
|
+
}
|
|
318
|
+
} catch {
|
|
319
|
+
/* fall through: the LLM service may still know a default */
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** One non-streaming completion: the joined text of a single stream. */
|
|
326
|
+
async function complete(scope, prompt, signal) {
|
|
327
|
+
const llm = serviceOf(scope, 'llm');
|
|
328
|
+
if (!llm || typeof llm !== 'object') throw new Error('the Host LLM service is not available');
|
|
329
|
+
const selection = currentModel(scope);
|
|
330
|
+
if (!selection) throw new Error('no default model is configured for this profile');
|
|
331
|
+
|
|
332
|
+
const messages = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
|
|
333
|
+
const request = {
|
|
334
|
+
provider: selection.provider,
|
|
335
|
+
model: selection.model,
|
|
336
|
+
messages,
|
|
337
|
+
maxTokens: MAX_OUTPUT_TOKENS,
|
|
338
|
+
};
|
|
339
|
+
// Filing labels needs no deliberation, and a thinking budget would eat the
|
|
340
|
+
// whole output allowance before a single character of the answer is emitted.
|
|
341
|
+
request.reasoningEffort = 'off';
|
|
342
|
+
|
|
343
|
+
let text = '';
|
|
344
|
+
let finish = null;
|
|
345
|
+
const seen = new Set();
|
|
346
|
+
const consume = async (stream) => {
|
|
347
|
+
for await (const chunk of stream) {
|
|
348
|
+
if (!chunk || typeof chunk !== 'object') continue;
|
|
349
|
+
seen.add(String(chunk.type));
|
|
350
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text;
|
|
351
|
+
else if (chunk.type === 'finish') finish = chunk;
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// `prepareCall` validates the route and freezes the resolved config; the
|
|
356
|
+
// prepared stream only accepts exactly that config, so hand it back as-is.
|
|
357
|
+
let prepared = null;
|
|
358
|
+
if (typeof llm.prepareCall === 'function') prepared = await llm.prepareCall(request, signal);
|
|
359
|
+
if (prepared && typeof prepared.stream === 'function') await consume(prepared.stream(prepared.config));
|
|
360
|
+
else await consume(llm.stream(request));
|
|
361
|
+
|
|
362
|
+
if (finish && isRecord(finish.reason)) {
|
|
363
|
+
const kind = finish.reason.kind;
|
|
364
|
+
if (kind && kind !== 'stop' && kind !== 'max-tokens') throw new Error(`the model stopped early (${kind})`);
|
|
365
|
+
}
|
|
366
|
+
return { text, model: `${selection.provider}/${selection.model}`, seen: Array.from(seen).join('+') };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* ------------------------------------------------------------------ handler */
|
|
370
|
+
|
|
371
|
+
async function classify(scope, payload, signal) {
|
|
372
|
+
if (!isRecord(payload)) return refusal('bad-request', 'The request payload is not an object.');
|
|
373
|
+
const entries = [];
|
|
374
|
+
for (const entry of (Array.isArray(payload.entries) ? payload.entries : []).slice(0, MAX_ENTRIES)) {
|
|
375
|
+
if (!isRecord(entry) || typeof entry.id !== 'string' || entry.id === '') continue;
|
|
376
|
+
entries.push({
|
|
377
|
+
id: entry.id,
|
|
378
|
+
label: typeof entry.label === 'string' && entry.label !== '' ? entry.label.slice(0, 80) : entry.id,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
if (entries.length === 0) return refusal('bad-request', 'There is nothing to file: the entry list is empty.');
|
|
382
|
+
|
|
383
|
+
const groups = [];
|
|
384
|
+
for (const group of (Array.isArray(payload.groups) ? payload.groups : []).slice(0, MAX_GROUPS)) {
|
|
385
|
+
if (!isRecord(group) || typeof group.name !== 'string' || group.name.trim() === '') continue;
|
|
386
|
+
groups.push({ name: group.name.trim().slice(0, 24) });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const draft = { entries, groups, locale: payload.locale === 'zh' ? 'zh' : 'en' };
|
|
390
|
+
|
|
391
|
+
const deadline = new AbortController();
|
|
392
|
+
const timer = setTimeout(() => deadline.abort(), TIMEOUT_MS);
|
|
393
|
+
const mergedSignal = () => {
|
|
394
|
+
if (!signal) return deadline.signal;
|
|
395
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
|
|
396
|
+
return AbortSignal.any([signal, deadline.signal]);
|
|
397
|
+
}
|
|
398
|
+
return deadline.signal;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
const answer = await complete(scope, buildPrompt(draft), mergedSignal());
|
|
403
|
+
const parsed = sanitizeAnswer(extractJson(answer.text), draft);
|
|
404
|
+
if (!parsed) {
|
|
405
|
+
logHost(`classify: unusable answer (${answer.seen}, ${answer.text.length} chars)`);
|
|
406
|
+
return refusal('unparsable-answer', 'The model did not answer with usable JSON.');
|
|
407
|
+
}
|
|
408
|
+
const filed = new Set(parsed.groups.flatMap((group) => group.ids));
|
|
409
|
+
logHost(`classify: ${parsed.groups.length} groups over ${filed.size}/${entries.length} entries (${answer.model})`);
|
|
410
|
+
return {
|
|
411
|
+
ok: true,
|
|
412
|
+
value: {
|
|
413
|
+
groups: parsed.groups,
|
|
414
|
+
unfiled: entries.filter((entry) => !filed.has(entry.id)).map((entry) => entry.id),
|
|
415
|
+
model: answer.model,
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
} catch (error) {
|
|
419
|
+
const aborted = deadline.signal.aborted || (signal && signal.aborted);
|
|
420
|
+
return refusal(aborted ? 'timeout' : 'model-failed', describeError(error));
|
|
421
|
+
} finally {
|
|
422
|
+
clearTimeout(timer);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* -------------------------------------------------------------------- route */
|
|
427
|
+
|
|
428
|
+
async function handle(scope, request) {
|
|
429
|
+
if (!isOwnRequest(request)) return new Response('forbidden', { status: 403 });
|
|
430
|
+
if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
|
|
431
|
+
const contentType = request.headers.get('content-type');
|
|
432
|
+
if (!contentType || contentType.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
|
|
433
|
+
return new Response('content type must be application/json', { status: 415 });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Read the text first: `request.json()` would parse the whole body before the
|
|
437
|
+
// size check could refuse it, which is exactly what the limit exists to stop.
|
|
438
|
+
let raw;
|
|
439
|
+
try {
|
|
440
|
+
raw = await request.text();
|
|
441
|
+
} catch {
|
|
442
|
+
return new Response('body is unreadable', { status: 400 });
|
|
443
|
+
}
|
|
444
|
+
if (raw.length > MAX_BODY_BYTES) return new Response('body too large', { status: 413 });
|
|
445
|
+
let message;
|
|
446
|
+
try {
|
|
447
|
+
message = JSON.parse(raw);
|
|
448
|
+
} catch {
|
|
449
|
+
return new Response('body is not JSON', { status: 400 });
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const rpcId = isRecord(message) && typeof message.rpcId === 'string' ? message.rpcId : 'invalid-request';
|
|
453
|
+
if (
|
|
454
|
+
!isRecord(message) ||
|
|
455
|
+
message.type !== 'client-request' ||
|
|
456
|
+
typeof message.rpcId !== 'string' ||
|
|
457
|
+
message.method !== ENDPOINT ||
|
|
458
|
+
!isRecord(message.payload)
|
|
459
|
+
) {
|
|
460
|
+
return reply(rpcId, refusal('gateway/bad-request', 'This is not a panel-control request.'));
|
|
461
|
+
}
|
|
462
|
+
const call = message.payload;
|
|
463
|
+
const method = typeof call.method === 'string' ? call.method : '';
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
if (method === 'classify') return reply(rpcId, await classify(scope, call.payload, request.signal));
|
|
467
|
+
if (method === 'load') return reply(rpcId, await loadPreferences());
|
|
468
|
+
if (method === 'save') return reply(rpcId, await savePreferences(call.payload));
|
|
469
|
+
if (method === 'inventory') return reply(rpcId, inventorySnapshot(scope));
|
|
470
|
+
if (method === 'report') {
|
|
471
|
+
const detail = isRecord(call.payload) ? call.payload : {};
|
|
472
|
+
const text = JSON.stringify(detail).slice(0, 4_000);
|
|
473
|
+
logClient(`${typeof detail.kind === 'string' ? detail.kind : 'report'} ${text}`);
|
|
474
|
+
return reply(rpcId, { ok: true, value: { logged: true } });
|
|
475
|
+
}
|
|
476
|
+
return reply(rpcId, refusal('unknown-method', `Unknown method: ${String(call.method)}`));
|
|
477
|
+
} catch (error) {
|
|
478
|
+
logHost(`request ${method || '?'} failed: ${describeError(error)}`);
|
|
479
|
+
return reply(rpcId, refusal('handler-failed', describeError(error)));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function loadPreferences() {
|
|
484
|
+
const prefs = await readPrefsFile();
|
|
485
|
+
return { ok: true, value: { prefs, file: PREFS_FILE } };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function savePreferences(payload) {
|
|
489
|
+
if (!isRecord(payload) || !isRecord(payload.prefs)) {
|
|
490
|
+
return refusal('bad-request', 'A preferences object is required.');
|
|
491
|
+
}
|
|
492
|
+
const bytes = await writePrefsFile(payload.prefs);
|
|
493
|
+
return { ok: true, value: { saved: true, bytes } };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* What the Host is serving to pages right now, taken from the same module graph
|
|
498
|
+
* that builds `window.__DSH_BOOT__`. Comparing revisions is the only reliable
|
|
499
|
+
* way to tell "this page booted with an older plugin list": the page cannot
|
|
500
|
+
* read the Host's list, and the profile manifest says nothing about which
|
|
501
|
+
* plugins actually made it into the served graph.
|
|
502
|
+
*/
|
|
503
|
+
let clientModules = null;
|
|
504
|
+
|
|
505
|
+
function inventorySnapshot(scope) {
|
|
506
|
+
if (!clientModules) clientModules = serviceOf(scope, 'clientModules');
|
|
507
|
+
const graph = clientModules && typeof clientModules.graph === 'function' ? clientModules.graph() : null;
|
|
508
|
+
if (!graph || typeof graph.rev !== 'string') return { ok: false, error: { message: 'The plugin graph is not readable.' } };
|
|
509
|
+
const entries = Array.isArray(graph.entries) ? graph.entries : [];
|
|
510
|
+
return {
|
|
511
|
+
ok: true,
|
|
512
|
+
value: {
|
|
513
|
+
rev: graph.rev,
|
|
514
|
+
release: typeof graph.release === 'string' ? graph.release : null,
|
|
515
|
+
ids: entries.map((entry) => String(entry && entry.id)).filter(Boolean),
|
|
516
|
+
// True when this process had to hand itself to the page: the page's list is
|
|
517
|
+
// still stale, but the entry it lacks is this very plugin, so there is
|
|
518
|
+
// nothing left for the user to do.
|
|
519
|
+
recovered: recoveredAt > 0,
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Write one line per apply that answers the only question a cold start leaves
|
|
526
|
+
* open: did this package even reach the Host's plugin graph?
|
|
527
|
+
*
|
|
528
|
+
* The page can only load what the graph advertises, so "missing from the list"
|
|
529
|
+
* and "listed but never fetched" have completely different fixes. One line,
|
|
530
|
+
* once per apply, settles it from `host.log` alone.
|
|
531
|
+
*/
|
|
532
|
+
function logInventoryOnApply(scope) {
|
|
533
|
+
try {
|
|
534
|
+
const snapshot = inventorySnapshot(scope);
|
|
535
|
+
if (!snapshot.ok) {
|
|
536
|
+
logHost(`graph: unreadable (${String(snapshot.error && snapshot.error.message)})`);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const value = snapshot.value;
|
|
540
|
+
const listed = value.ids.includes(PACKAGE);
|
|
541
|
+
logHost(
|
|
542
|
+
`graph: ${listed ? 'lists' : 'does not list'} ${PACKAGE} · rev ${value.rev} · ` +
|
|
543
|
+
`${value.ids.length} client entr${value.ids.length === 1 ? 'y' : 'ies'}`,
|
|
544
|
+
);
|
|
545
|
+
if (listed) void probeOwnBundle(scope);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
logHost(`graph: probe failed (${describeError(error)})`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Serve the current revision when a page asks for one this process has retired.
|
|
553
|
+
*
|
|
554
|
+
* The registry addresses every bundle by a revision derived from the file's
|
|
555
|
+
* mtime, ctime and size, and it answers only the revisions it read at
|
|
556
|
+
* composition time ("versioned code is immutable"). A page keeps the list it
|
|
557
|
+
* booted with, so as soon as this package's file is rewritten - by an editor, by
|
|
558
|
+
* git normalising line endings, or by the retry nudge that used to live here -
|
|
559
|
+
* the page's revision stops existing and its request becomes a 404 that nothing
|
|
560
|
+
* reports: the plugin is simply absent from the sidebar.
|
|
561
|
+
*
|
|
562
|
+
* This wraps the registry's own resource lookup for this one package. A 404 for
|
|
563
|
+
* a stale revision is retried at the revision the Host holds now, and the page
|
|
564
|
+
* gets the bytes it needs; everything else passes straight through, and the
|
|
565
|
+
* patch is undone on teardown.
|
|
566
|
+
*/
|
|
567
|
+
const SERVE_CURRENT = String(process.env.DPC_SERVE_CURRENT || '').toLowerCase() !== 'off';
|
|
568
|
+
let servePatched = null;
|
|
569
|
+
let serveLoggedAt = 0;
|
|
570
|
+
|
|
571
|
+
function serveCurrentRevision(scope) {
|
|
572
|
+
if (!SERVE_CURRENT || servePatched) return;
|
|
573
|
+
const modules = clientModules || serviceOf(scope, 'clientModules');
|
|
574
|
+
if (!modules || typeof modules.bundleResource !== 'function') return;
|
|
575
|
+
const original = modules.bundleResource.bind(modules);
|
|
576
|
+
servePatched = modules;
|
|
577
|
+
modules.bundleResource = async function bundleResourceWithFallback(method, url) {
|
|
578
|
+
const answer = await original(method, url);
|
|
579
|
+
if (answer && answer.status !== 404) return answer;
|
|
580
|
+
try {
|
|
581
|
+
const requested = String(url || '');
|
|
582
|
+
if (!requested.includes(PACKAGE)) return answer;
|
|
583
|
+
const graph = typeof modules.graph === 'function' ? modules.graph() : null;
|
|
584
|
+
const rows = graph && Array.isArray(graph.entries) ? graph.entries : [];
|
|
585
|
+
const entry = rows.find((row) => row && row.id === PACKAGE);
|
|
586
|
+
if (!entry || typeof entry.url !== 'string') return answer;
|
|
587
|
+
const retryUrl = entry.url.startsWith('/') ? entry.url : `/${entry.url}`;
|
|
588
|
+
if (retryUrl === requested) return answer;
|
|
589
|
+
const retried = await original(method, retryUrl);
|
|
590
|
+
if (!retried || retried.status !== 200) return answer;
|
|
591
|
+
if (Date.now() - serveLoggedAt > 30_000) {
|
|
592
|
+
serveLoggedAt = Date.now();
|
|
593
|
+
logHost(
|
|
594
|
+
`served ${PACKAGE} at the current revision: the page asked for a retired one ` +
|
|
595
|
+
'(its plugin list predates the last write to client.js)',
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
return retried;
|
|
599
|
+
} catch (error) {
|
|
600
|
+
logHost(`stale revision fallback failed: ${describeError(error)}`);
|
|
601
|
+
return answer;
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function releaseServeCurrent() {
|
|
607
|
+
const modules = servePatched;
|
|
608
|
+
servePatched = null;
|
|
609
|
+
if (!modules) return;
|
|
610
|
+
try {
|
|
611
|
+
delete modules.bundleResource;
|
|
612
|
+
} catch {
|
|
613
|
+
/* a frozen service keeps the wrapper; it only ever adds a retry */
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Ask the Host to serve this package's own bundle, the way the page would.
|
|
619
|
+
*
|
|
620
|
+
* A row can be advertised and still be unservable: the registry answers only
|
|
621
|
+
* the revisions it read at composition time, and everything else is a 404 that
|
|
622
|
+
* the page drops without a word. Runs the same request the browser would make.
|
|
623
|
+
*/
|
|
624
|
+
async function probeOwnBundle(scope) {
|
|
625
|
+
try {
|
|
626
|
+
const modules = clientModules || serviceOf(scope, 'clientModules');
|
|
627
|
+
const graph = modules && typeof modules.graph === 'function' ? modules.graph() : null;
|
|
628
|
+
const entries = graph && Array.isArray(graph.entries) ? graph.entries : [];
|
|
629
|
+
const entry = entries.find((row) => row && row.id === PACKAGE);
|
|
630
|
+
if (!entry || typeof entry.url !== 'string' || typeof modules.fetchBundle !== 'function') return;
|
|
631
|
+
const response = await modules.fetchBundle({ method: 'GET', url: entry.url });
|
|
632
|
+
logHost(
|
|
633
|
+
`graph: own bundle at rev ${String(entry.rev)} answers ${response.status}` +
|
|
634
|
+
(response.status === 200 ? '' : ' — the page cannot load a bundle the Host will not serve'),
|
|
635
|
+
);
|
|
636
|
+
} catch (error) {
|
|
637
|
+
logHost(`graph: own bundle probe failed (${describeError(error)})`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Say it out loud when the page never came back.
|
|
643
|
+
*
|
|
644
|
+
* The Host is up (this half ran) and yet the Client half reports nothing: the
|
|
645
|
+
* window was built from a plugin list that no longer matches the code on disk,
|
|
646
|
+
* so its script request went nowhere and the page silently omits the entry. The
|
|
647
|
+
* user sees a plugin that "does not start", so write the diagnosis where it can
|
|
648
|
+
* be found -- once per launch.
|
|
649
|
+
*
|
|
650
|
+
* This runs in a timer, outside every request and outside Cordis' own error
|
|
651
|
+
* handling: anything thrown here kills the whole Host process. Every branch is
|
|
652
|
+
* therefore contained, and the caller adds one more net.
|
|
653
|
+
*/
|
|
654
|
+
let watchdogSpoken = false;
|
|
655
|
+
|
|
656
|
+
async function watchFirstClientBoot(startedAt) {
|
|
657
|
+
try {
|
|
658
|
+
// Several applies can share one process (hot swap); only the first one speaks.
|
|
659
|
+
if (watchdogSpoken) return;
|
|
660
|
+
watchdogSpoken = true;
|
|
661
|
+
let text = '';
|
|
662
|
+
try {
|
|
663
|
+
text = await fs.readFile(CLIENT_LOG, 'utf8');
|
|
664
|
+
} catch (error) {
|
|
665
|
+
if (!error || error.code !== 'ENOENT') throw error;
|
|
666
|
+
}
|
|
667
|
+
const since = startedAt - 4_000;
|
|
668
|
+
let newest = 0;
|
|
669
|
+
let reported = false;
|
|
670
|
+
for (const line of text.split(String.fromCharCode(10))) {
|
|
671
|
+
if (!line.includes('"kind":"boot"')) continue;
|
|
672
|
+
const stamp = Date.parse(line.slice(0, line.indexOf(' ')));
|
|
673
|
+
if (!Number.isFinite(stamp)) continue;
|
|
674
|
+
if (stamp > newest) newest = stamp;
|
|
675
|
+
if (stamp >= since) reported = true;
|
|
676
|
+
}
|
|
677
|
+
if (reported) return;
|
|
678
|
+
// A page that reported shortly before this apply is an already-running page:
|
|
679
|
+
// it did load this plugin, and a Host hot swap (which happens on every edit
|
|
680
|
+
// while developing) must not be reported as "the window never got the code".
|
|
681
|
+
if (newest >= startedAt - 120_000) return;
|
|
682
|
+
logHost(
|
|
683
|
+
'client half did not report within 30s: the window is running a plugin list from before this code ' +
|
|
684
|
+
'(a rebuilt interface can take this long to finish loading; press the refresh button if the entry is missing)',
|
|
685
|
+
);
|
|
686
|
+
// The page will not ask for a script that is not in its frozen list, but it
|
|
687
|
+
// does watch this package's files: one byte-level change to the Client entry
|
|
688
|
+
// makes the loader hand the module to the live page. Flip the trailing
|
|
689
|
+
// newline, so the file stays byte-stable instead of growing on every launch.
|
|
690
|
+
if (!NUDGE_ENABLED) {
|
|
691
|
+
logHost('recovery nudge stayed off (opt in with DPC_NUDGE=on); a page reload is the safe fix');
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
// The page may have reported while this timer was waiting: never rewrite a
|
|
696
|
+
// file the running page just loaded, because the resulting hot swap is
|
|
697
|
+
// what re-runs everybody's registration.
|
|
698
|
+
const late = await fs.readFile(CLIENT_LOG, 'utf8').catch(() => '');
|
|
699
|
+
const quiet = late
|
|
700
|
+
.split(String.fromCharCode(10))
|
|
701
|
+
.some((line) => {
|
|
702
|
+
if (!line.includes('"kind":"boot"')) return false;
|
|
703
|
+
const stamp = Date.parse(line.slice(0, line.indexOf(' ')));
|
|
704
|
+
return Number.isFinite(stamp) && stamp >= since;
|
|
705
|
+
});
|
|
706
|
+
if (quiet) {
|
|
707
|
+
logHost('recovery nudge skipped: the page reported while the watchdog was waiting');
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const source = await fs.readFile(CLIENT_FILE, 'utf8');
|
|
711
|
+
const next = source.endsWith(NL + NL) ? source.slice(0, -1) : source + NL;
|
|
712
|
+
await fs.writeFile(CLIENT_FILE, next, 'utf8');
|
|
713
|
+
recoveredAt = Date.now();
|
|
714
|
+
logHost(
|
|
715
|
+
`recovery nudge: rewrote ${path.basename(CLIENT_FILE)} (${next.length - source.length} byte) so the ` +
|
|
716
|
+
'loader hands this plugin to the running page; the sidebar entry should appear within a second',
|
|
717
|
+
);
|
|
718
|
+
} catch (error) {
|
|
719
|
+
logHost(`recovery nudge failed: ${describeError(error)}`);
|
|
720
|
+
}
|
|
721
|
+
} catch (error) {
|
|
722
|
+
logHost(`client boot watchdog failed: ${describeError(error)}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Register the route; returns null while `connection` is not available yet so
|
|
727
|
+
* that the caller can retry on a later tick. */
|
|
728
|
+
function mount(scope) {
|
|
729
|
+
const connection = serviceOf(scope, 'connection');
|
|
730
|
+
if (!connection || !connection.fetch || typeof connection.fetch.register !== 'function') return null;
|
|
731
|
+
return connection.fetch.register({
|
|
732
|
+
path: ROUTE_PATH,
|
|
733
|
+
methods: ['POST'],
|
|
734
|
+
requestBody: 'buffered',
|
|
735
|
+
fetch: (request) => handle(scope, request),
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export function apply(ctx) {
|
|
740
|
+
let disposed = false;
|
|
741
|
+
let dispose = null;
|
|
742
|
+
let retry = null;
|
|
743
|
+
let attempts = 0;
|
|
744
|
+
|
|
745
|
+
logHost('apply: host half starting');
|
|
746
|
+
|
|
747
|
+
const attach = (scope) => {
|
|
748
|
+
if (disposed || dispose) return;
|
|
749
|
+
// A hot swap replaces the services themselves: a reference cached from the
|
|
750
|
+
// previous apply would answer with the old graph.
|
|
751
|
+
clientModules = null;
|
|
752
|
+
attempts += 1;
|
|
753
|
+
try {
|
|
754
|
+
dispose = mount(scope);
|
|
755
|
+
} catch (error) {
|
|
756
|
+
console.error(`[${PACKAGE}] failed to register ${ROUTE_PATH}`, error);
|
|
757
|
+
logHost(`mount failed: ${describeError(error)}`);
|
|
758
|
+
}
|
|
759
|
+
if (dispose) {
|
|
760
|
+
logHost(`route mounted at ${ROUTE_PATH} (attempt ${attempts})`);
|
|
761
|
+
logInventoryOnApply(scope);
|
|
762
|
+
serveCurrentRevision(scope);
|
|
763
|
+
const startedAt = Date.now();
|
|
764
|
+
// A timer callback that throws is fatal for the Host: contain it here and
|
|
765
|
+
// again inside the async body.
|
|
766
|
+
const watchdog = setTimeout(() => {
|
|
767
|
+
void watchFirstClientBoot(startedAt).catch((error) =>
|
|
768
|
+
logHost(`client boot watchdog failed: ${describeError(error)}`),
|
|
769
|
+
);
|
|
770
|
+
}, 30_000);
|
|
771
|
+
if (typeof watchdog.unref === 'function') watchdog.unref();
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (!disposed) retry = setTimeout(() => attach(scope), 1000);
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
try {
|
|
778
|
+
if (typeof ctx.inject === 'function') ctx.inject(['connection'], attach);
|
|
779
|
+
else attach(ctx);
|
|
780
|
+
} catch (error) {
|
|
781
|
+
console.error(`[${PACKAGE}] host half could not start`, error);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Cordis calls this on unload: drop the route and any pending retry.
|
|
785
|
+
if (typeof ctx.effect === 'function') {
|
|
786
|
+
ctx.effect(
|
|
787
|
+
() => () => {
|
|
788
|
+
disposed = true;
|
|
789
|
+
if (retry) clearTimeout(retry);
|
|
790
|
+
retry = null;
|
|
791
|
+
if (typeof dispose === 'function') {
|
|
792
|
+
try {
|
|
793
|
+
dispose();
|
|
794
|
+
} catch {
|
|
795
|
+
/* the Host already tore the route down */
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
dispose = null;
|
|
799
|
+
releaseServeCurrent();
|
|
800
|
+
},
|
|
801
|
+
`${PACKAGE}: classification route`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
}
|