dsh-markdown-typora 0.3.5 → 0.3.11
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/README.md +15 -14
- package/core.json +5 -5
- package/host/document-store.js +181 -0
- package/host/live-documents.js +524 -0
- package/host/native/file-worker.exe +0 -0
- package/host/native/file-worker.exe.build.json +10 -0
- package/host/native-worker-lease.js +127 -0
- package/host/native-worker-path.ps1 +63 -0
- package/host/package.json +1 -0
- package/host/safe-file.js +137 -0
- package/host/text-codec.js +67 -0
- package/host.json +50 -0
- package/lib/client.js +929 -216
- package/lib/index.js +1506 -1120
- package/package.json +5 -3
- package/renderer-cm6/cm6/app.js +193 -151
- package/renderer-cm6/dsh-bridge.js +216 -5
package/lib/index.js
CHANGED
|
@@ -1,1120 +1,1506 @@
|
|
|
1
|
-
// dsh-markdown-typora 宿主半边(DSH 0.2.1 契约版):
|
|
2
|
-
// renderer-cm6 静态路由 + 版本守卫编辑面 API(open/save/stat/edit)+ SSE watch + markdown 模型工具。
|
|
3
|
-
//
|
|
4
|
-
// 插件三件套形状(宿主 cordis loader 读的就是这个):
|
|
5
|
-
// export const name / export const inject / export function apply(ctx)
|
|
6
|
-
//
|
|
7
|
-
// 架构(见 README.md):引擎 0 改动,插件只做 DSH 皮/桥/文件 IO。
|
|
8
|
-
// · 静态资源来自插件根 renderer-cm6/(core-sync 从 EXE 构建产物逐字节同步);
|
|
9
|
-
// ·
|
|
10
|
-
// · save
|
|
11
|
-
// · 写成功后向所有 SSE 连接广播 version(客户端按 path 过滤 + 脏缓冲不覆盖)。
|
|
12
|
-
//
|
|
13
|
-
// 保留的既有不变量:
|
|
14
|
-
// 1. 信任栅栏:ctx.connection.requestRejection 可用就以方法形式调用;否则只放行 loopback。
|
|
15
|
-
// 2. body 上限 64MB:含内联图的 Markdown 十几 MB 在真实用户里常见(8MB 曾把 8.13MB 文档拒之门外)。
|
|
16
|
-
// 3. fs.watch 是 async(返回 close 函数);不支持时降级为 2s 探测。旧实现把它当同步调用 ⇒
|
|
17
|
-
// unhandled rejection + watcher 泄漏。这里 await 捕获并幂等 close。
|
|
18
|
-
// 4. 每个 async 分支都有 try/catch,错误以 {ok:false,error:{code,message}} 返回,code 原样透传。
|
|
19
|
-
|
|
20
|
-
import { readFile, stat as fsStat,
|
|
21
|
-
import { createReadStream } from 'node:fs';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
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
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
await
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
if (
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
if (
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
req.on('
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
};
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
'
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1
|
+
// dsh-markdown-typora 宿主半边(DSH 0.2.1 契约版):
|
|
2
|
+
// renderer-cm6 静态路由 + 版本守卫编辑面 API(open/save/stat/edit)+ SSE watch + markdown 模型工具。
|
|
3
|
+
//
|
|
4
|
+
// 插件三件套形状(宿主 cordis loader 读的就是这个):
|
|
5
|
+
// export const name / export const inject / export function apply(ctx)
|
|
6
|
+
//
|
|
7
|
+
// 架构(见 README.md):引擎 0 改动,插件只做 DSH 皮/桥/文件 IO。
|
|
8
|
+
// · 静态资源来自插件根 renderer-cm6/(core-sync 从 EXE 构建产物逐字节同步);
|
|
9
|
+
// · 同一文件的写入排队校验 ctx.fs 版本,本地文件使用桌面壳的原子写入与编码处理;
|
|
10
|
+
// · 每次 save 都要求 baseVersion,版本冲突返回 FS_STALE_VERSION;
|
|
11
|
+
// · 写成功后向所有 SSE 连接广播 version(客户端按 path 过滤 + 脏缓冲不覆盖)。
|
|
12
|
+
//
|
|
13
|
+
// 保留的既有不变量:
|
|
14
|
+
// 1. 信任栅栏:ctx.connection.requestRejection 可用就以方法形式调用;否则只放行 loopback。
|
|
15
|
+
// 2. body 上限 64MB:含内联图的 Markdown 十几 MB 在真实用户里常见(8MB 曾把 8.13MB 文档拒之门外)。
|
|
16
|
+
// 3. fs.watch 是 async(返回 close 函数);不支持时降级为 2s 探测。旧实现把它当同步调用 ⇒
|
|
17
|
+
// unhandled rejection + watcher 泄漏。这里 await 捕获并幂等 close。
|
|
18
|
+
// 4. 每个 async 分支都有 try/catch,错误以 {ok:false,error:{code,message}} 返回,code 原样透传。
|
|
19
|
+
|
|
20
|
+
import { readFile, stat as fsStat, realpath } from 'node:fs/promises';
|
|
21
|
+
import { createReadStream } from 'node:fs';
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
// 下面三个静态导入**只保留 HMR 依赖边**(host/*.js 变更 ⇒ dsh-hmr 把插件条目一并重启),
|
|
25
|
+
// 实例不直接消费:DSH HMR 的替换计划只驱逐「变更模块 + 其 importer」(planReplacement
|
|
26
|
+
// 只向上闭包,不向下含依赖),协同更新分批到达时 host/ 的 CJS 三件会混代 ——
|
|
27
|
+
// 新 document-store 顶层解构旧 live-documents 的导出 ⇒ “liveCommitDirectory is not a
|
|
28
|
+
// function”(real-manager-enable-r1 实证)。消费实例一律经 loadHostModules() 以
|
|
29
|
+
// 「整组驱逐 + 同代重载」取得:document-store 与 live-documents 永远来自同一代磁盘内容,
|
|
30
|
+
// 不降级、不换目录、共享房间/原子写语义不变。
|
|
31
|
+
import rawDiskStore from '../host/document-store.js';
|
|
32
|
+
import rawLiveStore from '../host/live-documents.js';
|
|
33
|
+
import rawTextCodec from '../host/text-codec.js';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import { dirname, join, normalize, sep, extname } from 'node:path';
|
|
36
|
+
|
|
37
|
+
const requirePlugin = createRequire(import.meta.url);
|
|
38
|
+
const nodeFs = requirePlugin('node:fs');
|
|
39
|
+
const nodeModule = requirePlugin('node:module');
|
|
40
|
+
/** loader 代际(ABI):图记录若由不带本代「局部 require 所有权」的旧 loader 建立
|
|
41
|
+
* (同 digest 的旧 CCB slot 仍在活进程里),一律**不复用**:显式重建一代,
|
|
42
|
+
* 旧 owner 交由其自己的 close/退休路径排空;重建后的同代码热载才稳定复用。 */
|
|
43
|
+
const HOST_GRAPH_ABI = 2;
|
|
44
|
+
/** 本代 Module → 原始 require(WeakMap:模块可回收,不积累强引用)。 */
|
|
45
|
+
const OWNED_ORIGINALS = new WeakMap();
|
|
46
|
+
/* ── host 运行时图(digest 整组复用/重载;进程级注册表,realpath 归一)────────────
|
|
47
|
+
* 真实 HMR 实测(real-hmr-worker-count-r2):每代无条件驱逐 ⇒ 每代一个 file-worker。
|
|
48
|
+
* 方案(v3,按完整 host 清单而非手维护依赖图):
|
|
49
|
+
* ① digest = host/ 全部源成员(含 native-worker-lease.js、native-worker-path.ps1、
|
|
50
|
+
* native/ 子树、package.json)的 rel+字节哈希,目录键走 fs.realpath(junction 拼写归一);
|
|
51
|
+
* ② 相同 digest ⇒ 整组复用(不 require、不碰缓存)——worker、SQLite databases、
|
|
52
|
+
* beforeExit 全部延续;未变 native 写逻辑不可能被旧 writer 顶替;
|
|
53
|
+
* ③ digest 变 ⇒ 先**预载+验证**新图(拓扑序 require 四入口、导出面形状校验),
|
|
54
|
+
* 全部成功才原子发布;失败则还原被逐出的缓存键并抛出 —— 旧图不被提前退休、
|
|
55
|
+
* 不 shutdown、原样可用;缺文件在读清单时即硬错误(无 sentinel、无静默复用);
|
|
56
|
+
* ④ 发布后旧图进 retiredList:其活跃 host 归零时调用**旧代自身**的
|
|
57
|
+
* shutdownFileWorker(worker 为该模块实例独占;在途写由其内部 operations 排空),
|
|
58
|
+
* 然后清空 modules 引用并移除记录 —— exports/连接不再被注册表持有,模块可被
|
|
59
|
+
* GC(含其模块级 SQLite databases);新代冷启不受旧代 closing 影响;
|
|
60
|
+
* ⑤ 同代多 host/多 live 窗口共享一张图:单 host.close 只减计数,最后一个才释放。
|
|
61
|
+
* 不降级、不换目录、共享房间/原子写语义不变。 */
|
|
62
|
+
const HOST_GRAPH_KEY = Symbol.for('dsh-markdown-typora:host-graph');
|
|
63
|
+
const HOST_ENTRY_ORDER = ['text-codec.js', 'safe-file.js', 'live-documents.js', 'document-store.js']; // 依赖拓扑序
|
|
64
|
+
function hostGraphSlot(hostDir) {
|
|
65
|
+
const root = (globalThis[HOST_GRAPH_KEY] ??= new Map());
|
|
66
|
+
let slot = root.get(hostDir);
|
|
67
|
+
if (!slot) { slot = { current: null, retiredList: [] }; root.set(hostDir, slot); }
|
|
68
|
+
return slot;
|
|
69
|
+
}
|
|
70
|
+
const HOST_GRAPH_REQUIRED = new Set(['document-store.js', 'live-documents.js', 'text-codec.js', 'safe-file.js',
|
|
71
|
+
'native-worker-lease.js', 'native-worker-path.ps1', 'native/file-worker.exe', 'native/file-worker.exe.build.json']);
|
|
72
|
+
function readHostGraph(hostDir) {
|
|
73
|
+
// 真实 sync-host 约定:manifest 在**插件根**(host/ 上一级)的 host.json。
|
|
74
|
+
// 逐条校验:相对路径、无绝对/..、realpath 后仍在 canonical host 根内(防 symlink 逃逸)、
|
|
75
|
+
// bytes/sha256 与声明一致;必需成员(4 个 JS + native 脚本/二进制 + build manifest)必须声明。
|
|
76
|
+
// 任一不满足 ⇒ 硬失败(publish 之前),旧图保活。
|
|
77
|
+
const manifestPath = join(dirname(hostDir), 'host.json');
|
|
78
|
+
let manifest;
|
|
79
|
+
try { manifest = JSON.parse(nodeFs.readFileSync(manifestPath, 'utf8')); }
|
|
80
|
+
catch (error) { throw Object.assign(new Error('host graph manifest 不可读: ' + manifestPath), { cause: error }); }
|
|
81
|
+
if (!manifest || !Array.isArray(manifest.files) || manifest.files.length === 0)
|
|
82
|
+
throw new Error('host graph manifest 形状无效(files 为空): ' + manifestPath);
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
const members = [];
|
|
85
|
+
for (const entry of manifest.files) {
|
|
86
|
+
if (!entry || typeof entry.path !== 'string' || !entry.path) throw new Error('host graph manifest 条目无效');
|
|
87
|
+
const rel = entry.path;
|
|
88
|
+
if (pathIsAbsoluteShim(rel) || rel.split('/').some((segment) => segment === '..'))
|
|
89
|
+
throw new Error('host graph manifest 条目必须为 host 内相对路径: ' + rel);
|
|
90
|
+
if (seen.has(rel)) throw new Error('host graph manifest 重复条目: ' + rel);
|
|
91
|
+
seen.add(rel);
|
|
92
|
+
const real = nodeFs.realpathSync(join(hostDir, rel)); // 缺失/坏链 ⇒ throw(硬失败)
|
|
93
|
+
if (real !== hostDir && !real.startsWith(hostDir + sep)) // canonical 核对:不许 symlink 指到 host 外
|
|
94
|
+
throw new Error('host graph 成员逃逸 canonical 根: ' + rel + ' → ' + real);
|
|
95
|
+
const bytes = nodeFs.readFileSync(real);
|
|
96
|
+
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
97
|
+
if (bytes.length !== entry.bytes || digest !== entry.sha256)
|
|
98
|
+
throw new Error(`host graph 成员与 manifest 不匹配: ${rel}(期望 ${entry.bytes}B/${String(entry.sha256).slice(0, 12)},实际 ${bytes.length}B/${digest.slice(0, 12)})`);
|
|
99
|
+
members.push({ rel, real, bytes });
|
|
100
|
+
}
|
|
101
|
+
for (const required of HOST_GRAPH_REQUIRED)
|
|
102
|
+
if (!seen.has(required)) throw new Error('host graph manifest 缺少必需成员: ' + required);
|
|
103
|
+
members.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
104
|
+
const graph = createHash('sha256');
|
|
105
|
+
for (const member of members) {
|
|
106
|
+
graph.update(member.rel); graph.update('\0');
|
|
107
|
+
graph.update(createHash('sha256').update(member.bytes).digest());
|
|
108
|
+
}
|
|
109
|
+
return { digest: graph.digest('hex'), members };
|
|
110
|
+
}
|
|
111
|
+
function pathIsAbsoluteShim(p) { return p.startsWith('/') || /^[A-Za-z]:/.test(p) || p.startsWith('\\\\'); }
|
|
112
|
+
function evictHostCacheKeys(members) {
|
|
113
|
+
// 只逐 .js 成员;缓存键 = realpath。还原函数**先清空本次全部成员键**(含装载新产生、
|
|
114
|
+
// 原先不存在的条目),再放回快照 —— 失败后旧 host 的延迟 require 只见旧实例,不混代。
|
|
115
|
+
const backup = [];
|
|
116
|
+
const keys = [];
|
|
117
|
+
for (const member of members) {
|
|
118
|
+
if (!member.rel.endsWith('.js')) continue;
|
|
119
|
+
keys.push(member.real);
|
|
120
|
+
const existing = requirePlugin.cache[member.real];
|
|
121
|
+
if (existing !== undefined) backup.push([member.real, existing]);
|
|
122
|
+
try { delete requirePlugin.cache[member.real]; } catch { /* 原本不在 */ }
|
|
123
|
+
}
|
|
124
|
+
return () => {
|
|
125
|
+
for (const key of keys) { try { delete requirePlugin.cache[key]; } catch { /* 已不在 */ } }
|
|
126
|
+
for (const [key, mod] of backup) requirePlugin.cache[key] = mod;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function finalizeHostRetirement(slot, record) {
|
|
130
|
+
// 顺序约定(transport):先旧代 retireLiveStorage()(仍有会话/在途 ⇒ MP_LIVE_ACTIVE,
|
|
131
|
+
// 此时**不算已清理**——保持 pending,绝不误判 done),再旧代 safeFile.shutdownFileWorker()
|
|
132
|
+
//(成功后自行移除 beforeExit 监听)。全部完成才清引用、移除记录。
|
|
133
|
+
if (record.retired === 'done') return; // 幂等:跨代/跨路径重复触发只收一次
|
|
134
|
+
const live = record.modules.get('live-documents.js');
|
|
135
|
+
try {
|
|
136
|
+
const retired = live?.retireLiveStorage?.();
|
|
137
|
+
if (retired && retired.ok !== true) return scheduleRetirementRetry(slot, record);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error?.code === 'MP_LIVE_ACTIVE') return scheduleRetirementRetry(slot, record);
|
|
140
|
+
/* 其它错误:存储未收口也要继续收 worker/引用 —— 进程退出兜底仍在 */
|
|
141
|
+
}
|
|
142
|
+
const safeFile = record.modules.get('safe-file.js');
|
|
143
|
+
try { void Promise.resolve(safeFile?.shutdownFileWorker?.()).catch(() => { /* 旧代关停失败不阻塞;退出 hook 兜底 */ }); }
|
|
144
|
+
catch { /* 旧代关停失败不阻塞 */ }
|
|
145
|
+
record.retired = 'done';
|
|
146
|
+
// 解除本代模块的局部 require 所有权:恢复原 mod.require(不碰全局行为),之后其惰性
|
|
147
|
+
// require 回到普通解析;本代记录随后清引用。
|
|
148
|
+
unbindOwnedRequires(record);
|
|
149
|
+
record.modules.clear(); // 释放引用:旧模块(含模块级 worker/SQLite 状态)可被 GC
|
|
150
|
+
const at = slot.retiredList.indexOf(record);
|
|
151
|
+
if (at !== -1) slot.retiredList.splice(at, 1); // 记录不复留:不持有 exports/连接
|
|
152
|
+
}
|
|
153
|
+
function scheduleRetirementRetry(slot, record) {
|
|
154
|
+
// MP_LIVE_ACTIVE:仍有在途操作/在场会话 —— 稍后重试一次;仍活跃则留 pending(退出兜底)。
|
|
155
|
+
if (record.retireRetry) return;
|
|
156
|
+
record.retireRetry = setTimeout(() => {
|
|
157
|
+
record.retireRetry = null;
|
|
158
|
+
if (record.retired === 'pending' && record.hosts.size === 0) finalizeHostRetirement(slot, record);
|
|
159
|
+
}, 300);
|
|
160
|
+
record.retireRetry.unref?.();
|
|
161
|
+
}
|
|
162
|
+
function loadHostGraph() {
|
|
163
|
+
// import.meta.url 可能带 HMR 查询串(?g=…)——取文件本体路径。
|
|
164
|
+
const here = dirname(fileURLToPath(import.meta.url.split('?')[0]));
|
|
165
|
+
const hostDir = nodeFs.realpathSync(normalize(join(here, '..', 'host'))); // 缺失 ⇒ 硬错误
|
|
166
|
+
const slot = hostGraphSlot(hostDir);
|
|
167
|
+
const graph = readHostGraph(hostDir); // 任一成员缺/不可读 ⇒ 硬错误(无 sentinel)
|
|
168
|
+
// 复用条件必须同时满足 digest 与 **loader 代际**:活进程里可能已有同 digest 的旧 CCB slot
|
|
169
|
+
//(不带本代「局部 require 所有权」)—— 它不能无条件复用,须显式按本代重建一次。
|
|
170
|
+
if (slot.current && slot.current.digest === graph.digest && slot.current.abi === HOST_GRAPH_ABI) {
|
|
171
|
+
const current = slot.current;
|
|
172
|
+
return { live: current.modules.get('live-documents.js'), disk: current.modules.get('document-store.js'),
|
|
173
|
+
codec: current.modules.get('text-codec.js'), graph: current };
|
|
174
|
+
}
|
|
175
|
+
// 预载新图:逐出本图 .js 成员缓存键(含 native-worker-lease 等),拓扑序 require,形状验证。
|
|
176
|
+
const restore = evictHostCacheKeys(graph.members);
|
|
177
|
+
const loaded = new Map();
|
|
178
|
+
const cjsModules = new Map();
|
|
179
|
+
const cjsByFile = new Map();
|
|
180
|
+
try {
|
|
181
|
+
for (const name of HOST_ENTRY_ORDER) {
|
|
182
|
+
loaded.set(name, requirePlugin('../host/' + name));
|
|
183
|
+
// 捕获本代模块的 CJS Module 对象(供局部 resolver 用;**不写全局 require.cache**)。
|
|
184
|
+
try {
|
|
185
|
+
const resolved = requirePlugin.resolve('../host/' + name);
|
|
186
|
+
const real = nodeFs.realpathSync(resolved);
|
|
187
|
+
const mod = requirePlugin.cache[real] || requirePlugin.cache[resolved] || null;
|
|
188
|
+
cjsModules.set(name, mod);
|
|
189
|
+
if (mod) {
|
|
190
|
+
cjsByFile.set(mod.filename, mod);
|
|
191
|
+
if (real !== mod.filename) cjsByFile.set(real, mod);
|
|
192
|
+
}
|
|
193
|
+
} catch { cjsModules.set(name, null); }
|
|
194
|
+
}
|
|
195
|
+
const live = loaded.get('live-documents.js'), disk = loaded.get('document-store.js');
|
|
196
|
+
if (typeof live?.LiveDocuments !== 'function' || typeof live?.withFileCommit !== 'function'
|
|
197
|
+
|| typeof disk?.DocumentStore !== 'function' || typeof disk?.createSession !== 'function'
|
|
198
|
+
|| typeof loaded.get('text-codec.js')?.decodeDocument !== 'function'
|
|
199
|
+
|| typeof loaded.get('safe-file.js')?.prepareFile !== 'function')
|
|
200
|
+
throw new Error('host graph 新代导出面不完整(digest=' + graph.digest.slice(0, 12) + ')');
|
|
201
|
+
} catch (error) {
|
|
202
|
+
restore(); // 回滚缓存:旧图成员(含懒 require 路径)原位、不被破坏
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
// ① 发布前校验(绑定/导出一致性)——失败 ⇒ 回滚并抛出;**旧图不退休、不 shutdown**。
|
|
206
|
+
try {
|
|
207
|
+
validateOwnedGraph(loaded, cjsModules);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
restore();
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
// ② 在图记录建立后、对外发布前完成逐 Module 实例的 require 绑定;绑定失败同样回滚。
|
|
213
|
+
const nextGraph = { dir: hostDir, abi: HOST_GRAPH_ABI, digest: graph.digest, modules: loaded, cjs: cjsModules, cjsByFile, hosts: new Set(), retired: 'active', generation: (slot.current?.generation ?? 0) + 1 };
|
|
214
|
+
try {
|
|
215
|
+
bindOwnedRequires(nextGraph);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
unbindOwnedRequires(nextGraph);
|
|
218
|
+
restore();
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
// ③ 原子发布:旧图记账退休(host 归零才真正释放),新图上桌。
|
|
222
|
+
const previous = slot.current;
|
|
223
|
+
if (previous) {
|
|
224
|
+
previous.retired = previous.hosts.size === 0 ? 'done' : 'pending';
|
|
225
|
+
slot.retiredList.push(previous);
|
|
226
|
+
if (previous.retired === 'done') finalizeHostRetirement(slot, previous);
|
|
227
|
+
}
|
|
228
|
+
slot.current = nextGraph;
|
|
229
|
+
return { live: loaded.get('live-documents.js'), disk: loaded.get('document-store.js'),
|
|
230
|
+
codec: loaded.get('text-codec.js'), graph: slot.current };
|
|
231
|
+
}
|
|
232
|
+
/** 发布前绑定/导出一致性校验:真实 Module 对象缺失或 exports 形状不符 ⇒ 直接失败(不静默放过)。 */
|
|
233
|
+
function validateOwnedGraph(loaded, cjsModules) {
|
|
234
|
+
for (const name of HOST_ENTRY_ORDER) {
|
|
235
|
+
const mod = cjsModules.get(name);
|
|
236
|
+
if (!mod || !mod.exports || mod.exports !== loaded.get(name))
|
|
237
|
+
throw new Error('host graph 校验失败:成员 Module 缺失或 exports 不匹配(' + name + ')');
|
|
238
|
+
}
|
|
239
|
+
const live = loaded.get('live-documents.js'), disk = loaded.get('document-store.js');
|
|
240
|
+
if (typeof live?.defaultDirectory !== 'function')
|
|
241
|
+
throw new Error('host graph 校验失败:live-documents.defaultDirectory 非函数(混代绑定)');
|
|
242
|
+
if (typeof live?.LiveDocuments !== 'function' || typeof live?.withFileCommit !== 'function')
|
|
243
|
+
throw new Error('host graph 校验失败:live-documents 导出面不完整');
|
|
244
|
+
if (typeof disk?.DocumentStore !== 'function' || typeof disk?.createSession !== 'function')
|
|
245
|
+
throw new Error('host graph 校验失败:document-store 导出面不完整');
|
|
246
|
+
// 只读构造探针:document-store 构造函数仅赋值(无 IO),defaultDirectory 混代时必抛
|
|
247
|
+
// liveCommitDirectory is not a function —— 这正是发布前要拦下的形态。
|
|
248
|
+
const probe = new disk.DocumentStore();
|
|
249
|
+
if (!probe || typeof probe.snapshot !== 'function' || typeof probe.commitDirectory !== 'string' || !probe.commitDirectory)
|
|
250
|
+
throw new Error('host graph 校验失败:DocumentStore 构造绑定异常');
|
|
251
|
+
probe.jobs?.clear?.();
|
|
252
|
+
}
|
|
253
|
+
/** 逐 Module 实例安装本代 require(WeakMap 保存原 require;非 prototype、非 _load)。 */
|
|
254
|
+
function bindOwnedRequires(graph) {
|
|
255
|
+
for (const [name, mod] of graph.cjs) {
|
|
256
|
+
if (!mod || typeof mod.require !== 'function' || !mod.exports)
|
|
257
|
+
throw new Error('host graph 绑定失败:成员 Module 不可用(' + name + ')');
|
|
258
|
+
if (OWNED_ORIGINALS.has(mod)) continue; // 已绑定(同代重复发布不应发生)
|
|
259
|
+
const original = mod.require;
|
|
260
|
+
const owned = graph.cjsByFile;
|
|
261
|
+
const wrapped = function ownedRequire(request) {
|
|
262
|
+
if (typeof request === 'string' && request[0] === '.' && owned) {
|
|
263
|
+
let filename = null;
|
|
264
|
+
try { filename = nodeModule._resolveFilename(request, mod, false); } catch { /* 交给原 require 报错 */ }
|
|
265
|
+
if (filename) {
|
|
266
|
+
const hit = owned.get(filename);
|
|
267
|
+
if (hit) return hit.exports;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return original.call(mod, request);
|
|
271
|
+
};
|
|
272
|
+
OWNED_ORIGINALS.set(mod, original);
|
|
273
|
+
mod.require = wrapped;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** 恢复本代模块的原 require(退役路径);重复调用安全。 */
|
|
277
|
+
function unbindOwnedRequires(record) {
|
|
278
|
+
if (!record || !record.cjs) return;
|
|
279
|
+
for (const [, mod] of record.cjs) {
|
|
280
|
+
const original = OWNED_ORIGINALS.get(mod);
|
|
281
|
+
if (original) { try { mod.require = original; } catch { /* 已冻结则忽略 */ } OWNED_ORIGINALS.delete(mod); }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// 无兜底:装载失败就让它失败(绝不回落到可能混代的 raw* 实例掩盖问题)。
|
|
285
|
+
const hostModules = loadHostGraph();
|
|
286
|
+
const diskStore = hostModules.disk;
|
|
287
|
+
const liveStore = hostModules.live;
|
|
288
|
+
const textCodec = hostModules.codec;
|
|
289
|
+
const activeHostGraph = hostModules.graph;
|
|
290
|
+
function registerHost(token) { activeHostGraph.hosts.add(token); }
|
|
291
|
+
function scheduleHostRetirement(ownerGraph) {
|
|
292
|
+
// 已接纳的异步 save 排空后才允许关闭旧代资源(worker/连接);期间另一 active host
|
|
293
|
+
// 不受任何影响(owners 已归零才会走到这里,且只作用于被顶代的图)。
|
|
294
|
+
const here = dirname(fileURLToPath(import.meta.url.split('?')[0]));
|
|
295
|
+
const hostDir = nodeFs.realpathSync(normalize(join(here, '..', 'host')));
|
|
296
|
+
void Promise.allSettled([...saveJobs.values()]).then(() => {
|
|
297
|
+
if (ownerGraph.hosts.size === 0 && ownerGraph.retired === 'pending') finalizeHostRetirement(hostGraphSlot(hostDir), ownerGraph);
|
|
298
|
+
}).catch(() => { /* 排空失败不阻塞 */ });
|
|
299
|
+
}
|
|
300
|
+
function unregisterHost(token) {
|
|
301
|
+
const ownerGraph = activeHostGraph;
|
|
302
|
+
if (ownerGraph.hosts.delete(token) && ownerGraph.hosts.size === 0 && ownerGraph.retired === 'pending') scheduleHostRetirement(ownerGraph);
|
|
303
|
+
}
|
|
304
|
+
void rawDiskStore; void rawLiveStore; void rawTextCodec;
|
|
305
|
+
|
|
306
|
+
export const name = 'dsh-markdown-typora';
|
|
307
|
+
|
|
308
|
+
/** 必需服务:webServer(路由挂载点)+ fs(版本守卫读写)。 */
|
|
309
|
+
export const inject = ['webServer', 'fs'];
|
|
310
|
+
|
|
311
|
+
const ROUTE_PREFIX = '/dsh-markdown-typora';
|
|
312
|
+
const STATIC_PREFIX = ROUTE_PREFIX + '/renderer-cm6/';
|
|
313
|
+
const API_PREFIX = ROUTE_PREFIX + '/api/';
|
|
314
|
+
// Only the editor's explicit local-file actions use its native file capability.
|
|
315
|
+
// The model tool must use the host filesystem's read projection and write policy.
|
|
316
|
+
const MODEL_TOOL = Symbol('markdown model tool');
|
|
317
|
+
const IMG_PREFIX = ROUTE_PREFIX + '/img/';
|
|
318
|
+
const CORE_JSON_PATH = ROUTE_PREFIX + '/core.json';
|
|
319
|
+
const KNOWN_ENDPOINTS = new Set(['open', 'save', 'stat', 'edit', 'live']);
|
|
320
|
+
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
321
|
+
|
|
322
|
+
/** 请求体上限:64MB。含内联图文档常见 10MB+;上限之外明确 413,不静默截断。 */
|
|
323
|
+
export const BODY_MAX_BYTES = 64 * 1024 * 1024;
|
|
324
|
+
/** 本地图片响应上限:与请求体同口径。超过只给 413,不把整块内容读进内存。 */
|
|
325
|
+
export const IMAGE_MAX_BYTES = 64 * 1024 * 1024;
|
|
326
|
+
const WATCH_DEBOUNCE_MS = 120;
|
|
327
|
+
const WATCH_PROBE_MS = 2000;
|
|
328
|
+
const SSE_HEARTBEAT_MS = 15000;
|
|
329
|
+
|
|
330
|
+
const HERE = dirname(fileURLToPath(import.meta.url.split('?')[0]));
|
|
331
|
+
export const PLUGIN_ROOT = normalize(join(HERE, '..'));
|
|
332
|
+
const localDocuments = new diskStore.DocumentStore();
|
|
333
|
+
// Shared by all createHost instances in this process. Failures do not poison
|
|
334
|
+
// subsequent saves; a queued request must validate its version on admission.
|
|
335
|
+
const saveJobs = new Map();
|
|
336
|
+
const pathKey = p => process.platform === 'win32' ? normalize(p).toLowerCase() : normalize(p);
|
|
337
|
+
async function canonicalTarget(target) {
|
|
338
|
+
const p=nativeTargetPath(target);
|
|
339
|
+
try {return await realpath(p);} catch(error) {if(error.code!=='ENOENT')throw error;return p;}
|
|
340
|
+
}
|
|
341
|
+
function queueSave(key, action) {
|
|
342
|
+
const job=(saveJobs.get(key)||Promise.resolve()).catch(()=>{}).then(action);
|
|
343
|
+
saveJobs.set(key,job);
|
|
344
|
+
const done=()=>{if(saveJobs.get(key)===job)saveJobs.delete(key);};job.then(done,done);
|
|
345
|
+
return job;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** 静态资源 MIME 表(含 CM6 引擎实际用到的 woff/woff2/ttf/wasm/map,以及 mermaid 的 svg/png)。 */
|
|
349
|
+
export const MIME_BY_EXT = {
|
|
350
|
+
'.html': 'text/html; charset=utf-8',
|
|
351
|
+
'.htm': 'text/html; charset=utf-8',
|
|
352
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
353
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
354
|
+
'.cjs': 'text/javascript; charset=utf-8',
|
|
355
|
+
'.css': 'text/css; charset=utf-8',
|
|
356
|
+
'.json': 'application/json; charset=utf-8',
|
|
357
|
+
'.map': 'application/json; charset=utf-8',
|
|
358
|
+
'.svg': 'image/svg+xml',
|
|
359
|
+
'.png': 'image/png',
|
|
360
|
+
'.jpg': 'image/jpeg',
|
|
361
|
+
'.jpeg': 'image/jpeg',
|
|
362
|
+
'.gif': 'image/gif',
|
|
363
|
+
'.webp': 'image/webp',
|
|
364
|
+
'.ico': 'image/x-icon',
|
|
365
|
+
'.woff': 'font/woff',
|
|
366
|
+
'.woff2': 'font/woff2',
|
|
367
|
+
'.ttf': 'font/ttf',
|
|
368
|
+
'.otf': 'font/otf',
|
|
369
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
370
|
+
'.wasm': 'application/wasm',
|
|
371
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
export function mimeFor(filePath) {
|
|
375
|
+
return MIME_BY_EXT[extname(String(filePath)).toLowerCase()] ?? 'application/octet-stream';
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** ETag 由 size-mtime 组成(要求口径);mtime 取整毫秒,避免频繁重建时抖动。 */
|
|
379
|
+
export function makeEtag(size, mtimeMs) {
|
|
380
|
+
const s = Number.isFinite(Number(size)) ? Math.trunc(Number(size)) : 0;
|
|
381
|
+
const m = Number.isFinite(Number(mtimeMs)) ? Math.trunc(Number(mtimeMs)) : 0;
|
|
382
|
+
return `"${s}-${m}"`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** If-None-Match 匹配(支持 *、逗号列表、弱前缀 W/)。命中返回 304。 */
|
|
386
|
+
export function isNotModified(ifNoneMatch, etag) {
|
|
387
|
+
if (typeof ifNoneMatch !== 'string' || ifNoneMatch.length === 0) return false;
|
|
388
|
+
if (ifNoneMatch.trim() === '*') return true;
|
|
389
|
+
return ifNoneMatch
|
|
390
|
+
.split(',')
|
|
391
|
+
.map((part) => part.trim().replace(/^W\//, ''))
|
|
392
|
+
.some((candidate) => candidate === etag);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* 缓存策略:
|
|
397
|
+
* · 入口 HTML、app.js/app.css、dsh-*.js:no-cache(每次带 ETag 校验,拿 304);
|
|
398
|
+
* · 字体(woff/woff2/ttf/otf/eot):public, max-age=604800 —— 内容哈希文件名,长缓存安全;
|
|
399
|
+
* · 其余(worker/mermaid/图标):no-cache + ETag。
|
|
400
|
+
*/
|
|
401
|
+
export function cacheControlFor(filePath) {
|
|
402
|
+
const base = String(filePath).split(/[\\/]/).pop() ?? '';
|
|
403
|
+
const ext = extname(base).toLowerCase();
|
|
404
|
+
if (ext === '.woff' || ext === '.woff2' || ext === '.ttf' || ext === '.otf' || ext === '.eot') {
|
|
405
|
+
return 'public, max-age=604800';
|
|
406
|
+
}
|
|
407
|
+
if (base === 'index.html' || base === 'app.js' || base === 'app.css' || /^dsh-[^/]*\.js$/i.test(base)) {
|
|
408
|
+
return 'no-cache';
|
|
409
|
+
}
|
|
410
|
+
return 'no-cache';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* 把一个 URL 相对路径解析成 renderer-cm6 根内的绝对路径;越界/编码异常/盘符/空字节一律 null。
|
|
415
|
+
* 这是路径穿越防护的唯一入口(纯函数,可被测试直接覆盖)。
|
|
416
|
+
*/
|
|
417
|
+
export function resolveStaticTarget(rootDir, rawRelative) {
|
|
418
|
+
if (typeof rawRelative !== 'string') return null;
|
|
419
|
+
let decoded;
|
|
420
|
+
try {
|
|
421
|
+
decoded = decodeURIComponent(rawRelative);
|
|
422
|
+
} catch {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
if (decoded.length === 0) decoded = 'index.html';
|
|
426
|
+
if (decoded.includes('\0')) return null;
|
|
427
|
+
const slashed = decoded.replace(/\\/g, '/');
|
|
428
|
+
if (slashed.startsWith('/')) return null;
|
|
429
|
+
if (/^[A-Za-z]:/.test(slashed)) return null;
|
|
430
|
+
const segments = slashed.split('/');
|
|
431
|
+
if (segments.some((segment) => segment === '..')) return null;
|
|
432
|
+
const root = normalize(rootDir);
|
|
433
|
+
const target = normalize(join(root, slashed));
|
|
434
|
+
if (target !== root && !target.startsWith(root + sep)) return null;
|
|
435
|
+
return target;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/* ══ 本地图片路由(文档相对路径图的取字节通道)══════════════════════════════════════
|
|
439
|
+
* 为什么需要:CM6 引擎按"当前文档所在目录"解析相对图片路径(`image-path.js` 的
|
|
440
|
+
* resolveImageSource);桌面壳把它拼成 file:// 并靠 file 页面读盘。DSH 的编辑面是
|
|
441
|
+
* **http 源**:CSP 的 img-src 不含 file:,Chromium 也禁止 http 页面加载 file:// 子资源
|
|
442
|
+
* ⇒ 相对路径图必然"图片加载失败"。所以由插件提供唯一合法的本地图片通道:
|
|
443
|
+
* GET /dsh-markdown-typora/img/<绝对本地路径(逐段百分号编码,保留 /)>
|
|
444
|
+
* 桥(renderer-cm6/dsh-bridge.js)在 open 时把 <base href> 指到
|
|
445
|
+
* /dsh-markdown-typora/img/<文档目录>/
|
|
446
|
+
* ⇒ 引擎发出的相对 URL 原样落到本路由。浏览器按 RFC 3986 自己归一化 `../`,
|
|
447
|
+
* 因此这里拿到的永远是规整的绝对路径。
|
|
448
|
+
*
|
|
449
|
+
* 安全边界(三条都必须成立,缺一即拒):
|
|
450
|
+
* ① 形态:只接受盘符绝对路径(D:/x/y.png)或 UNC(//server/share/y.png),
|
|
451
|
+
* 拒空段 / 单点 / 双点 / NUL / 坏编码 —— 归一化不靠浏览器,自己逐段验;
|
|
452
|
+
* ② 扩展名:图片白名单之外一律不服务(本路由不是通用文件服务);
|
|
453
|
+
* ③ 只在既有信任栅栏之后(loopback + 宿主鉴权),且只读、只 GET/HEAD。
|
|
454
|
+
* 说明:该读面不强于既有 /api/open(它本来就能读任意路径文本)。 */
|
|
455
|
+
|
|
456
|
+
/** 图片 MIME(本路由专用;比静态表窄,且不接受 fonts/wasm 之类)。 */
|
|
457
|
+
export const IMAGE_MIME_BY_EXT = {
|
|
458
|
+
'.png': 'image/png',
|
|
459
|
+
'.jpg': 'image/jpeg',
|
|
460
|
+
'.jpeg': 'image/jpeg',
|
|
461
|
+
'.jfif': 'image/jpeg',
|
|
462
|
+
'.gif': 'image/gif',
|
|
463
|
+
'.webp': 'image/webp',
|
|
464
|
+
'.bmp': 'image/bmp',
|
|
465
|
+
'.avif': 'image/avif',
|
|
466
|
+
'.svg': 'image/svg+xml',
|
|
467
|
+
'.ico': 'image/x-icon',
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
export function imageMimeFor(filePath) {
|
|
471
|
+
return IMAGE_MIME_BY_EXT[extname(String(filePath)).toLowerCase()] ?? null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* 把 URL 里 `/img/` 之后的剩余路径还原成**绝对本地路径**;不合规一律 null(纯函数)。
|
|
476
|
+
* 规则见本节注释①。解码逐段进行:`%2e%2e` 解出来是 `..`,同样被拒。
|
|
477
|
+
*/
|
|
478
|
+
export function decodeImageRequestPath(rawPath) {
|
|
479
|
+
if (typeof rawPath !== 'string' || rawPath.length === 0) return null;
|
|
480
|
+
const slashed = rawPath.replace(/\\/g, '/');
|
|
481
|
+
let unc = false;
|
|
482
|
+
let rest = slashed;
|
|
483
|
+
if (rest.startsWith('//')) { unc = true; rest = rest.slice(2); }
|
|
484
|
+
else if (rest.startsWith('/')) rest = rest.slice(1);
|
|
485
|
+
if (rest.length === 0) return null;
|
|
486
|
+
const segments = rest.split('/');
|
|
487
|
+
const decoded = [];
|
|
488
|
+
// Windows 设备名段(CON/NUL/COM1…)会被当成设备打开:读 CON 可能把请求挂住,
|
|
489
|
+
// 所以连"图片扩展名"都不看,段一级直接拒。
|
|
490
|
+
const DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
491
|
+
for (const segment of segments) {
|
|
492
|
+
let value;
|
|
493
|
+
try {
|
|
494
|
+
value = decodeURIComponent(segment);
|
|
495
|
+
} catch {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
if (value.length === 0) return null;
|
|
499
|
+
if (value === '.' || value === '..') return null;
|
|
500
|
+
if (value.includes('\0')) return null;
|
|
501
|
+
if (/[\\/]/.test(value)) return null; // %2f/%5c 解出来的分隔符不许再切一层
|
|
502
|
+
if (DEVICE_NAME.test(value)) return null;
|
|
503
|
+
if (decoded.length > 0 && value.includes(':')) return null; // 备用数据流(a.png:b)不收
|
|
504
|
+
decoded.push(value);
|
|
505
|
+
}
|
|
506
|
+
if (decoded.length < 1) return null;
|
|
507
|
+
let localPath;
|
|
508
|
+
if (unc) {
|
|
509
|
+
if (decoded.length < 2) return null; // //server 后面至少还要有共享名
|
|
510
|
+
localPath = '//' + decoded.join('/');
|
|
511
|
+
} else {
|
|
512
|
+
if (!/^[A-Za-z]:$/.test(decoded[0])) return null; // 必须是盘符绝对路径
|
|
513
|
+
if (decoded.length < 2) return null;
|
|
514
|
+
localPath = decoded[0].toUpperCase() + '/' + decoded.slice(1).join('/');
|
|
515
|
+
}
|
|
516
|
+
if (!imageMimeFor(localPath)) return null;
|
|
517
|
+
return normalize(localPath);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** 服务一次本地图片请求:ETag/304、HEAD、大小上限、流式输出。返回是否已响应。 */
|
|
521
|
+
export async function serveImageFile(req, res, rawPath) {
|
|
522
|
+
const method = req?.method === 'HEAD' ? 'HEAD' : 'GET';
|
|
523
|
+
const target = decodeImageRequestPath(rawPath);
|
|
524
|
+
if (!target) {
|
|
525
|
+
sendText(res, 400, 'bad image path');
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
let info;
|
|
529
|
+
try {
|
|
530
|
+
info = await fsStat(target);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
sendText(res, error?.code === 'ENOENT' ? 404 : 500, error?.code === 'ENOENT' ? 'not found' : 'stat error');
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
if (!info.isFile()) {
|
|
536
|
+
sendText(res, 404, 'not found');
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
if (info.size > IMAGE_MAX_BYTES) {
|
|
540
|
+
sendText(res, 413, 'image too large');
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
const etag = makeEtag(info.size, info.mtimeMs);
|
|
544
|
+
const headers = {
|
|
545
|
+
'content-type': imageMimeFor(target),
|
|
546
|
+
'cache-control': 'no-cache',
|
|
547
|
+
etag,
|
|
548
|
+
'x-content-type-options': 'nosniff',
|
|
549
|
+
// SVG 在 <img> 里不执行脚本,但**直接导航**(地址栏/window.open)时就是同源文档、脚本可跑。
|
|
550
|
+
// 这条 CSP 对 <img> 渲染零影响,对文档导航把脚本/表单/子资源全部中和。
|
|
551
|
+
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox",
|
|
552
|
+
};
|
|
553
|
+
if (isNotModified(req.headers['if-none-match'], etag)) {
|
|
554
|
+
res.writeHead(304, headers);
|
|
555
|
+
res.end();
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
// 不写 content-length:stat 与流之间有 TOCTOU(文件被改写会截断/挂住)。
|
|
559
|
+
// 交给 chunked 传输,浏览器按流读完即可。
|
|
560
|
+
res.writeHead(200, headers);
|
|
561
|
+
if (method === 'HEAD') {
|
|
562
|
+
res.end();
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
await new Promise((resolve) => {
|
|
566
|
+
let settled = false;
|
|
567
|
+
const done = () => { if (settled) return; settled = true; resolve(); };
|
|
568
|
+
const stream = createReadStream(target);
|
|
569
|
+
stream.on('error', () => { try { res.destroy(); } catch { /* 已断 */ } done(); });
|
|
570
|
+
stream.on('end', done);
|
|
571
|
+
res.on('close', () => { try { stream.destroy(); } catch { /* 已断 */ } done(); });
|
|
572
|
+
stream.pipe(res);
|
|
573
|
+
});
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** FsError 等错误的稳定码(分支认码不认文案;不许把 code 吞成 message)。 */
|
|
578
|
+
export function errorCode(error) {
|
|
579
|
+
const code = error?.code ?? error?.cause?.code;
|
|
580
|
+
if (typeof code === 'string' && code.length > 0) return code;
|
|
581
|
+
if (error?.name === 'AbortError') return 'FS_ABORTED';
|
|
582
|
+
return 'fs-error';
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* DSH 文件策略拒绝这次写入(workspace-write 只允许会话工作区内的路径)。
|
|
587
|
+
* 只在这个明确码/文案上走原生 fs 回退;OS 的 EACCES/EPERM 仍然照常失败。
|
|
588
|
+
*/
|
|
589
|
+
export function isSandboxWriteDenied(error) {
|
|
590
|
+
if (errorCode(error) === 'FS_SANDBOX_DENIED') return true;
|
|
591
|
+
const message = String(error?.message ?? error ?? '');
|
|
592
|
+
return /file access denied under (workspace-write|read-only) mode/i.test(message);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function codedError(code, message) {
|
|
596
|
+
const error = new Error(String(message));
|
|
597
|
+
error.code = code;
|
|
598
|
+
return error;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** ctx.fs 的 target 同时带 displayPath 和 realpath targetKey;原生写用后者。 */
|
|
602
|
+
function nativeTargetPath(target) {
|
|
603
|
+
const key = target?.targetKey;
|
|
604
|
+
if (typeof key === 'string' && key.length > 0) return key;
|
|
605
|
+
const displayPath = target?.displayPath;
|
|
606
|
+
return typeof displayPath === 'string' ? displayPath : '';
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** 与 dsh-fs-local 的 versionOf 同形:dev:ino:size:mtimeNs:ctimeNs(bigint)。 */
|
|
610
|
+
function versionOfBigInt(info) {
|
|
611
|
+
return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Local Markdown saves share the desktop's stable snapshot, encoding and
|
|
616
|
+
* staged native replacement. Never truncate the destination before commit.
|
|
617
|
+
* The UI's existing explicit local-file write capability remains scoped here.
|
|
618
|
+
*/
|
|
619
|
+
async function directWriteText(fs, target, text, baseVersion, diskHash) {
|
|
620
|
+
const file=nativeTargetPath(target),session=diskStore.createSession();
|
|
621
|
+
if(!file)throw codedError('FS_IO_ERROR','direct write: target has no native path');
|
|
622
|
+
const version=async()=>{const info=await fs.stat(target);if(!info)throw codedError('FS_NOT_FOUND','file no longer exists');return String(info.version);};
|
|
623
|
+
try {
|
|
624
|
+
if(await version()!==baseVersion)throw codedError('FS_STALE_VERSION','file changed since read');
|
|
625
|
+
await localDocuments.load(session,file);
|
|
626
|
+
if(diskHash && session.baseline.sha256!==diskHash)throw codedError('FS_STALE_VERSION','file changed since the synchronized revision');
|
|
627
|
+
if(await version()!==baseVersion)throw codedError('FS_STALE_VERSION','file changed while preparing save');
|
|
628
|
+
const expected=textCodec.encodeDocument(text,session),expectedHash=createHash('sha256').update(expected).digest('hex');
|
|
629
|
+
localDocuments.markDirty(session,{documentId:session.documentId,revision:1,dirty:true});
|
|
630
|
+
await localDocuments.save(session,{filePath:session.filePath,text,documentId:session.documentId,revision:1});
|
|
631
|
+
const before=await version(),saved=await localDocuments.snapshot(session.filePath),after=await version();
|
|
632
|
+
if(before!==after||saved.baseline.sha256!==expectedHash)throw codedError('FS_STALE_VERSION','file changed after save; draft retained');
|
|
633
|
+
return {version:after,displayPath:target.displayPath,size:saved.bytes.length};
|
|
634
|
+
} catch(error) {
|
|
635
|
+
if(['MP_FILE_CONFLICT','MP_FILE_MISSING'].includes(error.code))error.code='FS_STALE_VERSION';
|
|
636
|
+
throw error;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
function textBlock(text) {
|
|
642
|
+
return [{ type: 'text', text: String(text ?? '') }];
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function badRequest(message) {
|
|
646
|
+
return { ok: false, error: { code: 'bad-request', message } };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function hostnameOf(hostHeader) {
|
|
650
|
+
if (typeof hostHeader !== 'string' || hostHeader.length === 0) return '';
|
|
651
|
+
if (hostHeader.startsWith('[')) {
|
|
652
|
+
const end = hostHeader.indexOf(']');
|
|
653
|
+
return end > 0 ? hostHeader.slice(1, end).toLowerCase() : '';
|
|
654
|
+
}
|
|
655
|
+
return hostHeader.split(':')[0].toLowerCase();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** loopback 兜底栅栏:Host 必须本机;跨站请求拒绝;有 Origin 时必须与 Host 同源。 */
|
|
659
|
+
export function isTrustedLoopbackRequest(req) {
|
|
660
|
+
const host = req?.headers?.host;
|
|
661
|
+
if (!host) return false;
|
|
662
|
+
const hostname = hostnameOf(host);
|
|
663
|
+
if (!LOOPBACK_HOSTNAMES.has(hostname)) return false;
|
|
664
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false;
|
|
665
|
+
const origin = req.headers.origin;
|
|
666
|
+
if (origin === undefined) return true;
|
|
667
|
+
try {
|
|
668
|
+
return new URL(origin).host.toLowerCase() === String(host).toLowerCase();
|
|
669
|
+
} catch {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* 读请求体,超限返回 {ok:false,code:'too-large'};网络错误返回 {ok:false,code:'request-error'}。
|
|
676
|
+
* 超限后暂停消费剩余数据(不把 64MB 之外的攻击流量继续读进内存)。
|
|
677
|
+
*/
|
|
678
|
+
export function readBody(req, limit = BODY_MAX_BYTES) {
|
|
679
|
+
return new Promise((resolve) => {
|
|
680
|
+
const chunks = [];
|
|
681
|
+
let total = 0;
|
|
682
|
+
let settled = false;
|
|
683
|
+
const finish = (value) => {
|
|
684
|
+
if (settled) return;
|
|
685
|
+
settled = true;
|
|
686
|
+
resolve(value);
|
|
687
|
+
};
|
|
688
|
+
req.on('data', (chunk) => {
|
|
689
|
+
if (settled) return;
|
|
690
|
+
total += chunk.length;
|
|
691
|
+
if (total > limit) {
|
|
692
|
+
chunks.length = 0;
|
|
693
|
+
req.pause();
|
|
694
|
+
finish({ ok: false, code: 'too-large' });
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
chunks.push(chunk);
|
|
698
|
+
});
|
|
699
|
+
req.on('end', () => finish({ ok: true, buffer: Buffer.concat(chunks) }));
|
|
700
|
+
req.on('error', () => finish({ ok: false, code: 'request-error' }));
|
|
701
|
+
req.on('aborted', () => finish({ ok: false, code: 'request-error' }));
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function sendJson(res, status, value, extraHeaders) {
|
|
706
|
+
const body = Buffer.from(JSON.stringify(value), 'utf8');
|
|
707
|
+
if (res.headersSent) {
|
|
708
|
+
try { res.end(); } catch { /* 已断 */ }
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
res.writeHead(status, {
|
|
712
|
+
'content-type': 'application/json; charset=utf-8',
|
|
713
|
+
'cache-control': 'no-store',
|
|
714
|
+
'content-length': body.length,
|
|
715
|
+
...extraHeaders,
|
|
716
|
+
});
|
|
717
|
+
res.end(body);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function sendText(res, status, text, extraHeaders) {
|
|
721
|
+
if (res.headersSent) {
|
|
722
|
+
try { res.end(); } catch { /* 已断 */ }
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', ...extraHeaders });
|
|
726
|
+
res.end(text);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* 宿主核心:把 ctx 依赖收成一个可独立测试的对象。
|
|
731
|
+
* @param {{fs:object, log?:object, rootDir?:string, bodyLimit?:number, watchDebounceMs?:number,
|
|
732
|
+
* watchProbeMs?:number, heartbeatMs?:number, connectionProvider?:() => any}} [options]
|
|
733
|
+
*/
|
|
734
|
+
export function createHost(options = {}) {
|
|
735
|
+
const fs = options.fs;
|
|
736
|
+
if (!fs || typeof fs.resolve !== 'function' || typeof fs.writeText !== 'function') {
|
|
737
|
+
throw new Error('dsh-markdown-typora: a filesystem service (ctx.fs) is required');
|
|
738
|
+
}
|
|
739
|
+
const log = options.log ?? console;
|
|
740
|
+
const rootDir = normalize(options.rootDir ?? PLUGIN_ROOT);
|
|
741
|
+
const staticRoot = normalize(join(rootDir, 'renderer-cm6'));
|
|
742
|
+
const coreJsonFile = normalize(join(rootDir, 'core.json'));
|
|
743
|
+
const bodyLimit = options.bodyLimit ?? BODY_MAX_BYTES;
|
|
744
|
+
const watchDebounceMs = options.watchDebounceMs ?? WATCH_DEBOUNCE_MS;
|
|
745
|
+
const watchProbeMs = options.watchProbeMs ?? WATCH_PROBE_MS;
|
|
746
|
+
const heartbeatMs = options.heartbeatMs ?? SSE_HEARTBEAT_MS;
|
|
747
|
+
const connectionProvider = typeof options.connectionProvider === 'function'
|
|
748
|
+
? options.connectionProvider
|
|
749
|
+
: () => undefined;
|
|
750
|
+
|
|
751
|
+
/** 每个被观察文件一条 entry;refs 计数到 0 才释放(SSE 多 tab 共享)。 */
|
|
752
|
+
const watchers = new Map();
|
|
753
|
+
const live = options.liveDocuments ?? new liveStore.LiveDocuments();
|
|
754
|
+
const leases = new Map();
|
|
755
|
+
let closed = false;
|
|
756
|
+
|
|
757
|
+
function leaseOf(client) {
|
|
758
|
+
const lease = typeof client === 'string' && leases.get(client);
|
|
759
|
+
if (!lease) throw codedError('MP_LIVE_LEASE', '编辑同步会话已过期,当前草稿已保留');
|
|
760
|
+
return lease;
|
|
761
|
+
}
|
|
762
|
+
function releaseLive(client) {
|
|
763
|
+
if (!leases.delete(client)) return;
|
|
764
|
+
live.leave(client);
|
|
765
|
+
}
|
|
766
|
+
function liveEndpoint(payload) {
|
|
767
|
+
leaseOf(payload.client);
|
|
768
|
+
if (payload.action === 'leave') {
|
|
769
|
+
releaseLive(payload.client);
|
|
770
|
+
return {ok:true,value:null};
|
|
771
|
+
}
|
|
772
|
+
if (payload.action !== 'exchange') return badRequest('unknown live action');
|
|
773
|
+
return Promise.resolve(live.exchange(payload.client,payload.request)).then(value=>({ok:true,value}));
|
|
774
|
+
}
|
|
775
|
+
/** Persisted-file notifications are scoped to the watched target. Unsaved edits use independent document leases. */
|
|
776
|
+
const liveStreams = new Set();
|
|
777
|
+
|
|
778
|
+
function watchKeyOf(target) {
|
|
779
|
+
return String(target?.targetKey ?? target?.displayPath ?? '');
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function broadcastChange(target, version, delta) {
|
|
783
|
+
const data = {
|
|
784
|
+
path: target?.displayPath,
|
|
785
|
+
targetKey: target?.targetKey,
|
|
786
|
+
version: String(version ?? ''),
|
|
787
|
+
...(delta ? { delta: { oldString: String(delta.oldString ?? '').slice(0, 400), newString: String(delta.newString ?? '').slice(0, 400) } } : {}),
|
|
788
|
+
};
|
|
789
|
+
const frame = `event: change\ndata: ${JSON.stringify(data)}\n\n`;
|
|
790
|
+
for (const stream of liveStreams) {
|
|
791
|
+
if(stream.targetKey !== watchKeyOf(target))continue;
|
|
792
|
+
try {
|
|
793
|
+
stream.res.write(frame);
|
|
794
|
+
} catch { /* 连接已断,close 事件会清理 */ }
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function tickWatch(entry, baseline = false) {
|
|
799
|
+
if (entry.disposed) return;
|
|
800
|
+
let version = '';
|
|
801
|
+
let missing = false;
|
|
802
|
+
try {
|
|
803
|
+
const info = await fs.stat(entry.target);
|
|
804
|
+
if (info === undefined) missing = true;
|
|
805
|
+
else version = String(info.version ?? '');
|
|
806
|
+
} catch {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (entry.disposed) return;
|
|
810
|
+
const changed = entry.lastVersion !== undefined && (missing || version !== entry.lastVersion);
|
|
811
|
+
if (changed && !baseline) {
|
|
812
|
+
if (entry.debounce) clearTimeout(entry.debounce);
|
|
813
|
+
entry.debounce = setTimeout(() => {
|
|
814
|
+
entry.debounce = null;
|
|
815
|
+
if (!entry.disposed) broadcastChange(entry.target, version);
|
|
816
|
+
}, watchDebounceMs);
|
|
817
|
+
entry.debounce.unref?.();
|
|
818
|
+
}
|
|
819
|
+
if (!missing) entry.lastVersion = version;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function startWatch(entry) {
|
|
823
|
+
if (entry.started) return;
|
|
824
|
+
entry.started = true;
|
|
825
|
+
// FS.watch 是 async:resolve 为 close 函数;不支持时 reject(FS_IO_ERROR)。旧实现把它当
|
|
826
|
+
// 同步调用且不 catch —— 这里必须 await 并保留 close,否则 watcher 泄漏 / unhandled rejection。
|
|
827
|
+
try {
|
|
828
|
+
const close = await fs.watch(entry.target, () => { void tickWatch(entry); }, entry.controller.signal);
|
|
829
|
+
if (entry.disposed) {
|
|
830
|
+
try { if (typeof close === 'function') await close(); } catch { /* 已停 */ }
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
entry.close = typeof close === 'function' ? close : null;
|
|
834
|
+
} catch (error) {
|
|
835
|
+
if (entry.disposed) return;
|
|
836
|
+
log.warn?.('%s: fs.watch unavailable (%s) — SSE 退化为 %dms 探测', name, String(error?.message ?? error), watchProbeMs);
|
|
837
|
+
entry.timer = setInterval(() => { void tickWatch(entry); }, watchProbeMs);
|
|
838
|
+
entry.timer.unref?.();
|
|
839
|
+
}
|
|
840
|
+
await tickWatch(entry, true);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function ensureWatch(target) {
|
|
844
|
+
const key = watchKeyOf(target);
|
|
845
|
+
let entry = watchers.get(key);
|
|
846
|
+
if (entry) {
|
|
847
|
+
entry.refs += 1;
|
|
848
|
+
return entry;
|
|
849
|
+
}
|
|
850
|
+
entry = {
|
|
851
|
+
refs: 1,
|
|
852
|
+
target,
|
|
853
|
+
lastVersion: undefined,
|
|
854
|
+
controller: new AbortController(),
|
|
855
|
+
close: null,
|
|
856
|
+
timer: null,
|
|
857
|
+
debounce: null,
|
|
858
|
+
disposed: false,
|
|
859
|
+
started: false,
|
|
860
|
+
};
|
|
861
|
+
watchers.set(key, entry);
|
|
862
|
+
void startWatch(entry);
|
|
863
|
+
return entry;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function releaseWatch(target) {
|
|
867
|
+
const key = watchKeyOf(target);
|
|
868
|
+
const entry = watchers.get(key);
|
|
869
|
+
if (!entry) return;
|
|
870
|
+
entry.refs -= 1;
|
|
871
|
+
if (entry.refs > 0) return;
|
|
872
|
+
watchers.delete(key);
|
|
873
|
+
entry.disposed = true;
|
|
874
|
+
try { entry.controller.abort(); } catch { /* 已停 */ }
|
|
875
|
+
if (entry.timer) clearInterval(entry.timer);
|
|
876
|
+
if (entry.debounce) clearTimeout(entry.debounce);
|
|
877
|
+
const close = entry.close;
|
|
878
|
+
entry.close = null;
|
|
879
|
+
if (typeof close === 'function') {
|
|
880
|
+
Promise.resolve().then(() => close()).catch(() => { /* close 失败不影响进程 */ });
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async function serveStatic(req, res, rawRelative) {
|
|
885
|
+
const target = resolveStaticTarget(staticRoot, rawRelative);
|
|
886
|
+
if (!target) {
|
|
887
|
+
sendText(res, 403, 'forbidden');
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
let info;
|
|
891
|
+
try {
|
|
892
|
+
info = await fsStat(target);
|
|
893
|
+
} catch (error) {
|
|
894
|
+
sendText(res, error?.code === 'ENOENT' ? 404 : 500, error?.code === 'ENOENT' ? 'not found' : 'stat error');
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
let filePath = target;
|
|
898
|
+
if (info.isDirectory()) {
|
|
899
|
+
filePath = join(target, 'index.html');
|
|
900
|
+
try {
|
|
901
|
+
info = await fsStat(filePath);
|
|
902
|
+
} catch {
|
|
903
|
+
sendText(res, 404, 'not found');
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (!info.isFile()) {
|
|
908
|
+
sendText(res, 404, 'not found');
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const etag = makeEtag(info.size, info.mtimeMs);
|
|
912
|
+
const headers = {
|
|
913
|
+
'content-type': mimeFor(filePath),
|
|
914
|
+
'cache-control': cacheControlFor(filePath),
|
|
915
|
+
etag,
|
|
916
|
+
'x-content-type-options': 'nosniff',
|
|
917
|
+
};
|
|
918
|
+
if (isNotModified(req.headers['if-none-match'], etag)) {
|
|
919
|
+
res.writeHead(304, headers);
|
|
920
|
+
res.end();
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
let data;
|
|
924
|
+
try {
|
|
925
|
+
data = await readFile(filePath);
|
|
926
|
+
} catch {
|
|
927
|
+
sendText(res, 500, 'read error');
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
res.writeHead(200, { ...headers, 'content-length': data.length });
|
|
931
|
+
res.end(req.method === 'HEAD' ? undefined : data);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async function serveCoreJson(req, res) {
|
|
935
|
+
let data;
|
|
936
|
+
try {
|
|
937
|
+
data = await readFile(coreJsonFile);
|
|
938
|
+
} catch (error) {
|
|
939
|
+
// 不生成 core.json 的旧包也不能让客户端崩:明确 404 + 可诊断文案。
|
|
940
|
+
sendJson(res, error?.code === 'ENOENT' ? 404 : 500, {
|
|
941
|
+
ok: false,
|
|
942
|
+
error: {
|
|
943
|
+
code: error?.code === 'ENOENT' ? 'not-found' : 'fs-error',
|
|
944
|
+
message: error?.code === 'ENOENT'
|
|
945
|
+
? 'core.json has not been generated yet (run scripts/sync-core.mjs)'
|
|
946
|
+
: String(error?.message ?? error),
|
|
947
|
+
},
|
|
948
|
+
});
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const headers = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' };
|
|
952
|
+
try {
|
|
953
|
+
const info = await fsStat(coreJsonFile);
|
|
954
|
+
const etag = makeEtag(info.size, info.mtimeMs);
|
|
955
|
+
headers.etag = etag;
|
|
956
|
+
if (isNotModified(req.headers['if-none-match'], etag)) {
|
|
957
|
+
res.writeHead(304, headers);
|
|
958
|
+
res.end();
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
} catch { /* 无 stat 也要能返回内容 */ }
|
|
962
|
+
res.writeHead(200, { ...headers, 'content-length': data.length });
|
|
963
|
+
res.end(req.method === 'HEAD' ? undefined : data);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** GET /dsh-markdown-typora/api/watch?path=…:SSE 外部变更流;心跳 15s;close 幂等清理。 */
|
|
967
|
+
async function handleWatch(req, res, url) {
|
|
968
|
+
const path = url.searchParams.get('path') ?? '';
|
|
969
|
+
if (!path) {
|
|
970
|
+
sendJson(res, 400, badRequest('path is required'));
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
let target;
|
|
974
|
+
try {
|
|
975
|
+
target = await fs.resolve(path);
|
|
976
|
+
} catch (error) {
|
|
977
|
+
sendJson(res, 200, { ok: false, error: { code: errorCode(error), message: String(error?.message ?? error) } });
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (res.headersSent) return;
|
|
981
|
+
res.writeHead(200, {
|
|
982
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
983
|
+
'cache-control': 'no-store',
|
|
984
|
+
connection: 'keep-alive',
|
|
985
|
+
'x-content-type-options': 'nosniff',
|
|
986
|
+
});
|
|
987
|
+
res.write('retry: 2000\n\n');
|
|
988
|
+
const stream = { res, targetKey: watchKeyOf(target) };
|
|
989
|
+
liveStreams.add(stream);
|
|
990
|
+
const heartbeat = setInterval(() => {
|
|
991
|
+
try { res.write(': ping\n\n'); } catch { /* close 清理 */ }
|
|
992
|
+
}, heartbeatMs);
|
|
993
|
+
heartbeat.unref?.();
|
|
994
|
+
const entry = ensureWatch(target);
|
|
995
|
+
let closed = false;
|
|
996
|
+
const cleanup = () => {
|
|
997
|
+
if (closed) return;
|
|
998
|
+
closed = true;
|
|
999
|
+
clearInterval(heartbeat);
|
|
1000
|
+
liveStreams.delete(stream);
|
|
1001
|
+
releaseWatch(target);
|
|
1002
|
+
};
|
|
1003
|
+
req.on('close', cleanup);
|
|
1004
|
+
req.on('aborted', cleanup);
|
|
1005
|
+
res.on('close', cleanup);
|
|
1006
|
+
res.on('error', cleanup);
|
|
1007
|
+
try {
|
|
1008
|
+
res.write(`event: ready\ndata: ${JSON.stringify({ path: target.displayPath, version: String(entry.lastVersion ?? '') })}\n\n`);
|
|
1009
|
+
} catch { /* 已断 */ }
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function resolveOptions(payload, invocation) {
|
|
1013
|
+
const cwd = invocation?.cwd ?? payload?.cwd;
|
|
1014
|
+
return { ...(typeof cwd === 'string' && cwd ? {cwd} : {}), ...(invocation?.signal ? {signal:invocation.signal} : {}) };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
async function apiOpen(payload, authority, resolvedTarget, invocation) {
|
|
1018
|
+
const path = typeof payload?.path === 'string' ? payload.path : '';
|
|
1019
|
+
if (!path) return badRequest('path is required');
|
|
1020
|
+
const target = resolvedTarget ?? await fs.resolve(path, resolveOptions(payload, invocation));
|
|
1021
|
+
for(let attempt=0;attempt<3;attempt++) {
|
|
1022
|
+
const info=await fs.stat(target, invocation?.signal);
|
|
1023
|
+
if(!info)throw codedError('FS_NOT_FOUND',`not found: ${target.displayPath}`);
|
|
1024
|
+
if(info.type!=='file')throw codedError('FS_NOT_REGULAR_FILE',`not a regular file: ${target.displayPath}`);
|
|
1025
|
+
let text, baseline;
|
|
1026
|
+
// The local provider's version identifies native bytes. Use the same
|
|
1027
|
+
// strict BOM/EOL decoder as its writer; virtual providers keep their API.
|
|
1028
|
+
const native=authority===MODEL_TOOL ? null : await fsStat(nativeTargetPath(target),{bigint:true}).catch(e=>{if(e.code!=='ENOENT')throw e;return null;});
|
|
1029
|
+
if(native&&versionOfBigInt(native)===String(info.version)) {
|
|
1030
|
+
try {const snapshot=await localDocuments.snapshot(nativeTargetPath(target)); baseline=snapshot.baseline.sha256; text=textCodec.decodeDocument(snapshot.bytes,target.displayPath).text;}
|
|
1031
|
+
catch(error){if(error.code==='MP_FILE_CONFLICT')continue;throw error;}
|
|
1032
|
+
} else text=await fs.readText(target, invocation?.signal);
|
|
1033
|
+
const after=await fs.stat(target, invocation?.signal);
|
|
1034
|
+
if(!after||String(after.version)!==String(info.version))continue;
|
|
1035
|
+
const value={text,version:after.version,displayPath:target.displayPath,size:after.size,type:after.type};
|
|
1036
|
+
if (payload.live === true && baseline && authority !== MODEL_TOOL) {
|
|
1037
|
+
const canonical=await canonicalTarget(target);
|
|
1038
|
+
const seed=text.replace(/\r\n?/g,'\n');
|
|
1039
|
+
const joined=await live.join(canonical,seed,baseline);
|
|
1040
|
+
if (closed) { live.leave(joined.client);throw codedError('MP_LIVE_CLOSED','editor host stopped'); }
|
|
1041
|
+
leases.set(joined.client,{canonical:pathKey(canonical)});
|
|
1042
|
+
value.live={client:joined.client,epoch:joined.epoch,version:joined.version,diskHash:joined.diskHash,dirty:joined.text!==seed,savedVersion:joined.savedVersion,
|
|
1043
|
+
...(Array.isArray(joined.recovered)?{recovered:joined.recovered}:{})};
|
|
1044
|
+
value.text=joined.text;
|
|
1045
|
+
}
|
|
1046
|
+
return {ok:true,value};
|
|
1047
|
+
}
|
|
1048
|
+
throw codedError('FS_STALE_VERSION','file changed during read; retry after the external write finishes');
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function apiSave(payload) {
|
|
1052
|
+
const path = typeof payload?.path === 'string' ? payload.path : '';
|
|
1053
|
+
const text = typeof payload?.text === 'string' ? payload.text : null;
|
|
1054
|
+
const baseVersion = typeof payload?.baseVersion === 'string' ? payload.baseVersion : '';
|
|
1055
|
+
if (!path) return badRequest('path is required');
|
|
1056
|
+
if (text === null) return badRequest('text is required');
|
|
1057
|
+
if (!baseVersion) return badRequest('baseVersion is required — refusing to overwrite without a version guard');
|
|
1058
|
+
const target = await fs.resolve(path, resolveOptions(payload)),key=pathKey(await canonicalTarget(target));
|
|
1059
|
+
return queueSave(key,async()=>{
|
|
1060
|
+
const current=await fs.resolve(path,resolveOptions(payload));
|
|
1061
|
+
if(pathKey(await canonicalTarget(current))!==key)throw codedError('FS_STALE_VERSION','save target changed while queued');
|
|
1062
|
+
const before=await fs.stat(current);
|
|
1063
|
+
if(!before)throw codedError('FS_STALE_VERSION','file no longer exists');
|
|
1064
|
+
let result;
|
|
1065
|
+
const native=await fsStat(nativeTargetPath(current),{bigint:true}).catch(e=>{if(e.code!=='ENOENT')throw e;return null;});
|
|
1066
|
+
const local=!!native && versionOfBigInt(native)===String(before.version);
|
|
1067
|
+
if (payload.live) {
|
|
1068
|
+
const ticket=payload.live;
|
|
1069
|
+
if(!local||leaseOf(ticket.client).canonical!==key)throw codedError('MP_LIVE_LEASE','synchronized save target changed');
|
|
1070
|
+
let writerVersion;
|
|
1071
|
+
result=await live.save(ticket.client,{epoch:ticket.epoch,version:ticket.version,text},async({diskHash})=>{
|
|
1072
|
+
const version=await fs.stat(current);
|
|
1073
|
+
const identity=await fsStat(nativeTargetPath(current),{bigint:true});
|
|
1074
|
+
if (!version || versionOfBigInt(identity)!==String(version.version) || pathKey(await canonicalTarget(await fs.resolve(path,resolveOptions(payload))))!==key)
|
|
1075
|
+
throw codedError('FS_STALE_VERSION','file identity changed while waiting to save');
|
|
1076
|
+
const receipt=await directWriteText(fs,current,text,String(version.version),diskHash);
|
|
1077
|
+
writerVersion=receipt.version;
|
|
1078
|
+
return receipt;
|
|
1079
|
+
});
|
|
1080
|
+
// 内核最终契约:写者回执保持权威(provider.version 原样),live 元数据以
|
|
1081
|
+
// liveVersion/savedVersion/diskHash/epoch 另列。此防御兼容过渡期旧形状
|
|
1082
|
+
// (旧内核把 head.version 并进回执盖掉写者版本)—— 两种形状下客户端拿到的
|
|
1083
|
+
// version 都是盘版本、房间版本都在 liveVersion。
|
|
1084
|
+
if(writerVersion!==undefined&&result&&typeof result==='object'&&String(result.version)!==String(writerVersion))
|
|
1085
|
+
result={...result,liveVersion:result.version,version:writerVersion};
|
|
1086
|
+
}else if(String(before.version)!==baseVersion)throw codedError('FS_STALE_VERSION','file changed since read');
|
|
1087
|
+
else if(local)result=await live.external(nativeTargetPath(current),()=>directWriteText(fs,current,text,baseVersion));
|
|
1088
|
+
else try {result=await fs.writeText(current,text,{kind:'replaceIfVersion',version:baseVersion});}
|
|
1089
|
+
catch(error){if(!isSandboxWriteDenied(error))throw error;result=await directWriteText(fs,current,text,baseVersion);}
|
|
1090
|
+
const version=result?.version;broadcastChange(current,version);
|
|
1091
|
+
const value={version,displayPath:current.displayPath,size:result.size??Buffer.byteLength(text,'utf8')};
|
|
1092
|
+
if(payload.live&&result&&typeof result==='object'){
|
|
1093
|
+
// 已授权 live 保存专属:按客户端 applyLiveReceipt 约定透传最小 live 元数据
|
|
1094
|
+
// (savedVersion/liveVersion/epoch/diskHash;provider version 保持权威)。
|
|
1095
|
+
// 普通旧保存与模型写不走此分支 —— live capabilities 不外泄。
|
|
1096
|
+
if(Number.isSafeInteger(result.savedVersion))value.savedVersion=result.savedVersion;
|
|
1097
|
+
if(Number.isSafeInteger(result.liveVersion))value.liveVersion=result.liveVersion;
|
|
1098
|
+
if(typeof result.epoch==='string'&&result.epoch)value.epoch=result.epoch;
|
|
1099
|
+
if(typeof result.diskHash==='string'&&result.diskHash)value.diskHash=result.diskHash;
|
|
1100
|
+
}
|
|
1101
|
+
return {ok:true,value};
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
async function apiStat(payload) {
|
|
1106
|
+
const path = typeof payload?.path === 'string' ? payload.path : '';
|
|
1107
|
+
if (!path) return badRequest('path is required');
|
|
1108
|
+
const target = await fs.resolve(path, resolveOptions(payload));
|
|
1109
|
+
const info = await fs.stat(target);
|
|
1110
|
+
if (!info) return { ok: true, value: { missing: true } };
|
|
1111
|
+
return { ok: true, value: { version: info.version, size: info.size, type: info.type, displayPath: target.displayPath } };
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
async function apiEdit(payload, invocation) {
|
|
1115
|
+
const path = typeof payload?.path === 'string' ? payload.path : '';
|
|
1116
|
+
const oldString = typeof payload?.oldString === 'string' ? payload.oldString : null;
|
|
1117
|
+
const newString = typeof payload?.newString === 'string' ? payload.newString : null;
|
|
1118
|
+
if (!path) return badRequest('path is required');
|
|
1119
|
+
if (oldString === null || newString === null) return badRequest('oldString and newString are required');
|
|
1120
|
+
if(!oldString)return badRequest('oldString must not be empty');
|
|
1121
|
+
const resolution=resolveOptions(payload, invocation);
|
|
1122
|
+
const target=await fs.resolve(path,resolution),key=pathKey(await canonicalTarget(target));
|
|
1123
|
+
return queueSave(key,async()=>{
|
|
1124
|
+
invocation?.signal?.throwIfAborted();
|
|
1125
|
+
const current=await fs.resolve(path,resolution);
|
|
1126
|
+
if(pathKey(await canonicalTarget(current))!==key)throw codedError('FS_STALE_VERSION','edit target changed while queued');
|
|
1127
|
+
const opened=await apiOpen(payload,MODEL_TOOL,current,invocation),value=opened.value;
|
|
1128
|
+
if(!opened.ok)return opened;
|
|
1129
|
+
if(payload.baseVersion&&payload.baseVersion!==value.version)throw codedError('FS_STALE_VERSION','file changed since read');
|
|
1130
|
+
const first=value.text.indexOf(oldString);
|
|
1131
|
+
if(first<0)throw codedError('FS_EDIT_NOT_FOUND','old_string was not found');
|
|
1132
|
+
if(payload.replaceAll!==true && value.text.includes(oldString,first+oldString.length))throw codedError('FS_AMBIGUOUS_EDIT','ambiguous edit');
|
|
1133
|
+
const write=()=>fs.editText(current,{oldString,newString,replaceAll:payload.replaceAll===true},{version:value.version},invocation?.signal,invocation?.policy);
|
|
1134
|
+
const native=await fsStat(nativeTargetPath(current),{bigint:true}).catch(e=>{if(e.code!=='ENOENT')throw e;return null;});
|
|
1135
|
+
const result=native && versionOfBigInt(native)===String(value.version)
|
|
1136
|
+
? await live.external(nativeTargetPath(current),write) : await write();
|
|
1137
|
+
broadcastChange(current,result.version,{oldString,newString});
|
|
1138
|
+
return {ok:true,value:{version:result.version,displayPath:current.displayPath,changed:newString!==oldString}};
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async function dispatch(endpoint, payload, authority, execution) {
|
|
1143
|
+
try {
|
|
1144
|
+
let invocation;
|
|
1145
|
+
if (authority === MODEL_TOOL) {
|
|
1146
|
+
const session = execution?.agent?.session;
|
|
1147
|
+
let policy;
|
|
1148
|
+
if (endpoint === 'edit' && fs.sandboxMode !== undefined) {
|
|
1149
|
+
policy = await options.sandboxPolicy?.()?.resolve(session ? {session} : {});
|
|
1150
|
+
if (!policy?.mode) throw codedError('FS_SANDBOX_DENIED', 'Cannot resolve filesystem policy for this tool call');
|
|
1151
|
+
}
|
|
1152
|
+
invocation = {signal: execution?.signal, cwd: policy?.workspaceRoot ?? session?.header?.cwd, policy};
|
|
1153
|
+
invocation.signal?.throwIfAborted();
|
|
1154
|
+
}
|
|
1155
|
+
switch (endpoint) {
|
|
1156
|
+
case 'open': return await apiOpen(payload, authority, undefined, invocation);
|
|
1157
|
+
case 'save': return await apiSave(payload);
|
|
1158
|
+
case 'stat': return await apiStat(payload);
|
|
1159
|
+
case 'live': return authority === MODEL_TOOL ? badRequest('editor lease required') : await liveEndpoint(payload);
|
|
1160
|
+
case 'edit': return await apiEdit(payload, invocation);
|
|
1161
|
+
default: return badRequest(`unknown endpoint: ${String(endpoint)}`);
|
|
1162
|
+
}
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
const code = errorCode(error);
|
|
1165
|
+
try { log.warn?.('%s: %s failed (%s): %s', name, String(endpoint), code, String(error?.message ?? error)); } catch { /* 日志失败不影响响应 */ }
|
|
1166
|
+
return { ok: false, error: { code, message: String(error?.message ?? error) } };
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** 信任栅栏:requestRejection 以方法调用;不可用时只放行 loopback。 */
|
|
1171
|
+
function fenceStatus(req) {
|
|
1172
|
+
let rejection;
|
|
1173
|
+
try {
|
|
1174
|
+
let connection;
|
|
1175
|
+
try { connection = connectionProvider(); } catch { connection = undefined; }
|
|
1176
|
+
if (typeof connection?.requestRejection === 'function') {
|
|
1177
|
+
try { rejection = connection.requestRejection.call(connection, req); } catch { rejection = 403; }
|
|
1178
|
+
} else if (!isTrustedLoopbackRequest(req)) {
|
|
1179
|
+
rejection = 403;
|
|
1180
|
+
}
|
|
1181
|
+
} catch {
|
|
1182
|
+
rejection = isTrustedLoopbackRequest(req) ? undefined : 403;
|
|
1183
|
+
}
|
|
1184
|
+
if (rejection === undefined) return undefined;
|
|
1185
|
+
return typeof rejection === 'number' ? rejection : 403;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async function handleApiPost(req, res, url) {
|
|
1189
|
+
const endpoint = url.pathname.slice(API_PREFIX.length).replace(/\/+$/, '');
|
|
1190
|
+
if (!KNOWN_ENDPOINTS.has(endpoint)) {
|
|
1191
|
+
try { req.resume(); } catch { /* 已断 */ }
|
|
1192
|
+
sendJson(res, 404, { ok: false, error: { code: 'not-found', message: `unsupported endpoint: ${String(endpoint)}` } });
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const mediaType = req.headers['content-type']?.split(';', 1)[0]?.trim().toLowerCase();
|
|
1196
|
+
if (mediaType !== 'application/json') {
|
|
1197
|
+
try { req.resume(); } catch { /* 已断 */ }
|
|
1198
|
+
sendJson(res, 415, badRequest('content type must be application/json'));
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
const body = await readBody(req, bodyLimit);
|
|
1202
|
+
if (!body.ok) {
|
|
1203
|
+
const status = body.code === 'too-large' ? 413 : 400;
|
|
1204
|
+
sendJson(res, status, { ok: false, error: { code: body.code, message: body.code === 'too-large' ? `body exceeds ${bodyLimit} bytes` : 'body read failed' } }, { connection: 'close' });
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
let payload;
|
|
1208
|
+
try {
|
|
1209
|
+
payload = JSON.parse(body.buffer.toString('utf8'));
|
|
1210
|
+
} catch {
|
|
1211
|
+
sendJson(res, 400, badRequest('body is not JSON'));
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
1215
|
+
sendJson(res, 400, badRequest('body must be a JSON object'));
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
const result = await dispatch(endpoint, payload);
|
|
1219
|
+
sendJson(res, 200, result);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const route = {
|
|
1223
|
+
kind: 'prefix',
|
|
1224
|
+
path: ROUTE_PREFIX,
|
|
1225
|
+
handler: async (req, res) => {
|
|
1226
|
+
try {
|
|
1227
|
+
const rejection = fenceStatus(req);
|
|
1228
|
+
if (rejection !== undefined) {
|
|
1229
|
+
sendText(res, rejection, rejection === 401 ? 'unauthorized' : 'forbidden');
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
let url;
|
|
1233
|
+
try {
|
|
1234
|
+
url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
|
|
1235
|
+
} catch {
|
|
1236
|
+
sendText(res, 400, 'bad request');
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
const pathname = url.pathname;
|
|
1240
|
+
const isRead = req.method === 'GET' || req.method === 'HEAD';
|
|
1241
|
+
|
|
1242
|
+
if (isRead && pathname === CORE_JSON_PATH) {
|
|
1243
|
+
await serveCoreJson(req, res);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
// 本地图片通道(文档相对路径图):见 serveImageFile 上方注释。
|
|
1247
|
+
if (isRead && pathname.startsWith(IMG_PREFIX)) {
|
|
1248
|
+
await serveImageFile(req, res, pathname.slice(IMG_PREFIX.length));
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
if (isRead && pathname === STATIC_PREFIX.slice(0, -1)) {
|
|
1252
|
+
await serveStatic(req, res, 'index.html');
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
if (isRead && pathname.startsWith(STATIC_PREFIX)) {
|
|
1256
|
+
await serveStatic(req, res, pathname.slice(STATIC_PREFIX.length));
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
if (req.method === 'GET' && pathname === API_PREFIX + 'watch') {
|
|
1260
|
+
await handleWatch(req, res, url);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
if (req.method === 'POST' && pathname.startsWith(API_PREFIX)) {
|
|
1264
|
+
await handleApiPost(req, res, url);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
sendJson(res, 404, { ok: false, error: { code: 'not-found', message: `not found: ${pathname}` } });
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
const code = errorCode(error);
|
|
1270
|
+
try { log.warn?.('%s: request failed (%s): %s', name, code, String(error?.message ?? error)); } catch { /* 日志失败不影响响应 */ }
|
|
1271
|
+
if (!res.headersSent) sendJson(res, 200, { ok: false, error: { code, message: String(error?.message ?? error) } });
|
|
1272
|
+
else { try { res.end(); } catch { /* 已断 */ } }
|
|
1273
|
+
}
|
|
1274
|
+
},
|
|
1275
|
+
};
|
|
1276
|
+
|
|
1277
|
+
function close() {
|
|
1278
|
+
closed=true;
|
|
1279
|
+
// 先放本 host 自己的资源(leases → SSE → watchers),再离场代际计数:
|
|
1280
|
+
// 已接纳的异步 save 排空后才可能触发旧代资源关闭(scheduleHostRetirement)。
|
|
1281
|
+
for(const client of leases.keys())releaseLive(client);
|
|
1282
|
+
for (const stream of liveStreams) {
|
|
1283
|
+
try { stream.res.end(); } catch { /* 已断 */ }
|
|
1284
|
+
}
|
|
1285
|
+
liveStreams.clear();
|
|
1286
|
+
for (const entry of watchers.values()) {
|
|
1287
|
+
entry.disposed = true;
|
|
1288
|
+
try { entry.controller.abort(); } catch { /* 已停 */ }
|
|
1289
|
+
if (entry.timer) clearInterval(entry.timer);
|
|
1290
|
+
if (entry.debounce) clearTimeout(entry.debounce);
|
|
1291
|
+
const closeFn = entry.close;
|
|
1292
|
+
entry.close = null;
|
|
1293
|
+
if (typeof closeFn === 'function') {
|
|
1294
|
+
Promise.resolve().then(() => closeFn()).catch(() => { /* close 失败不影响进程 */ });
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
watchers.clear();
|
|
1298
|
+
unregisterHost(hostToken); // 代际生命周期:最后一步离场;同代其它 host 不受影响
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const hostToken = {};
|
|
1302
|
+
registerHost(hostToken);
|
|
1303
|
+
return { route, dispatch, close, broadcastChange, _internals: { watchers, liveStreams } };
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function safeGet(ctx, serviceName) {
|
|
1307
|
+
try {
|
|
1308
|
+
return ctx?.get?.(serviceName);
|
|
1309
|
+
} catch {
|
|
1310
|
+
return undefined;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function safeLogger(ctx) {
|
|
1315
|
+
try {
|
|
1316
|
+
if (typeof ctx?.logger === 'function') return ctx.logger(name);
|
|
1317
|
+
} catch { /* 用 console */ }
|
|
1318
|
+
return console;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
export function apply(ctx) {
|
|
1322
|
+
const log = safeLogger(ctx);
|
|
1323
|
+
let fsService = safeGet(ctx, 'fs');
|
|
1324
|
+
if (!fsService) {
|
|
1325
|
+
try { fsService = ctx.fs; } catch { fsService = undefined; }
|
|
1326
|
+
}
|
|
1327
|
+
if (!fsService) {
|
|
1328
|
+
log.warn?.('%s: ctx.fs unavailable — plugin disabled', name);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
let host;
|
|
1333
|
+
try {
|
|
1334
|
+
host = createHost({
|
|
1335
|
+
fs: fsService,
|
|
1336
|
+
log,
|
|
1337
|
+
rootDir: PLUGIN_ROOT,
|
|
1338
|
+
sandboxPolicy: () => safeGet(ctx, 'sandboxPolicy'),
|
|
1339
|
+
connectionProvider: () => safeGet(ctx, 'connection'),
|
|
1340
|
+
});
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
log.warn?.('%s: host init failed: %s', name, String(error?.message ?? error));
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
try {
|
|
1347
|
+
if (typeof ctx.effect === 'function') {
|
|
1348
|
+
ctx.effect(() => {
|
|
1349
|
+
const dispose = ctx.webServer.register(host.route);
|
|
1350
|
+
log.info?.('%s: routes mounted on %s', name, ROUTE_PREFIX);
|
|
1351
|
+
return () => {
|
|
1352
|
+
try { if (typeof dispose === 'function') dispose(); } catch { /* 已清理 */ }
|
|
1353
|
+
try { host.close(); } catch { /* 已清理 */ }
|
|
1354
|
+
};
|
|
1355
|
+
}, 'dsh-markdown-typora: routes');
|
|
1356
|
+
} else {
|
|
1357
|
+
ctx.webServer.register(host.route);
|
|
1358
|
+
}
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
log.warn?.('%s: route registration failed: %s', name, String(error?.message ?? error));
|
|
1361
|
+
try { host.close(); } catch { /* 已清理 */ }
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// markdown 模型工具:Agent 的编辑入口(与编辑面 tab 同一守卫路径)。
|
|
1366
|
+
registerMarkdownTool(ctx, { dispatch: host.dispatch, log });
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* markdown 工具定义(纯数据 + execute,不触达宿主):便于测试/外部校验 defineTool 形态。
|
|
1371
|
+
* DSH 0.2.1 的 defineTool 需要 output.schema + output.render;0.2.0 的旧形态(顶层 render)会抛。
|
|
1372
|
+
*/
|
|
1373
|
+
export function createMarkdownToolDefinition(dispatch) {
|
|
1374
|
+
return {
|
|
1375
|
+
name: 'markdown',
|
|
1376
|
+
description: [
|
|
1377
|
+
'读写 Markdown(及任意文本)文件,与 MarkPilot 编辑面板走同一版本守卫通道。',
|
|
1378
|
+
'read 返回全文与版本;edit 按字面 oldString→newString 原子替换(同 edit 工具语义)。',
|
|
1379
|
+
'冲突纪律:save/edit 报 FS_STALE_VERSION 说明文件在读取后被改过(可能用户正在编辑面板里打字)——',
|
|
1380
|
+
'此时必须重新 read 拿新版本再重试,绝不盲目覆盖;用户编辑永远优先。',
|
|
1381
|
+
].join(' '),
|
|
1382
|
+
parameters: {
|
|
1383
|
+
action: { type: 'string', required: true, description: 'read = 读文件(text+version);edit = 字面替换后守卫写回。', enum: ['read', 'edit'] },
|
|
1384
|
+
path: { type: 'string', required: true, description: '文件的绝对路径。' },
|
|
1385
|
+
cwd: { type: 'string', description: '可选:相对路径的解析基准目录。' },
|
|
1386
|
+
oldString: { type: 'string', description: 'action=edit 必填:要替换的原文字面量(须在文件中唯一,除非 replaceAll)。' },
|
|
1387
|
+
newString: { type: 'string', description: 'action=edit 必填:替换后的文字面量。' },
|
|
1388
|
+
replaceAll: { type: 'boolean', description: 'action=edit 可选:替换所有出现(默认 false)。' },
|
|
1389
|
+
baseVersion: { type: 'string', description: '可选:上次 read 拿到的版本;提供后文件已变则拒绝(FS_STALE_VERSION),不盲写。' },
|
|
1390
|
+
},
|
|
1391
|
+
output: {
|
|
1392
|
+
schema: {
|
|
1393
|
+
type: 'object',
|
|
1394
|
+
additionalProperties: false,
|
|
1395
|
+
properties: {
|
|
1396
|
+
ok: { type: 'boolean', required: true },
|
|
1397
|
+
action: { type: 'string', required: true },
|
|
1398
|
+
text: { type: 'string' },
|
|
1399
|
+
version: { type: 'string' },
|
|
1400
|
+
displayPath: { type: 'string' },
|
|
1401
|
+
changed: { type: 'boolean' },
|
|
1402
|
+
error: {
|
|
1403
|
+
type: 'object',
|
|
1404
|
+
additionalProperties: false,
|
|
1405
|
+
properties: {
|
|
1406
|
+
code: { type: 'string', required: true },
|
|
1407
|
+
message: { type: 'string', required: true },
|
|
1408
|
+
},
|
|
1409
|
+
},
|
|
1410
|
+
},
|
|
1411
|
+
},
|
|
1412
|
+
render: (args, value) => {
|
|
1413
|
+
if (value?.ok !== true) {
|
|
1414
|
+
return textBlock(`markdown ${String(args?.action ?? '')} 失败:${value?.error?.code ?? 'error'} — ${value?.error?.message ?? ''}`);
|
|
1415
|
+
}
|
|
1416
|
+
if (value.action === 'read') {
|
|
1417
|
+
const text = String(value.text ?? '');
|
|
1418
|
+
const head = text.length > 8000 ? `${text.slice(0, 8000)}\n…(共 ${text.length} 字符,已截断)` : text;
|
|
1419
|
+
return textBlock(`${value.displayPath ?? ''}(版本 ${String(value.version ?? '').slice(0, 12)}…):\n${head}`);
|
|
1420
|
+
}
|
|
1421
|
+
return textBlock(`已应用编辑:${value.displayPath ?? ''}${value.changed === false ? '(oldString 与 newString 相同,内容未变)' : ''},新版本 ${String(value.version ?? '').slice(0, 12)}…`);
|
|
1422
|
+
},
|
|
1423
|
+
},
|
|
1424
|
+
async execute(args, execution) {
|
|
1425
|
+
const action = args?.action === 'edit' ? 'edit' : 'read';
|
|
1426
|
+
try {
|
|
1427
|
+
if (action === 'read') {
|
|
1428
|
+
const result = await dispatch('open', { path: args?.path, cwd: args?.cwd }, MODEL_TOOL, execution);
|
|
1429
|
+
if (result.ok !== true) return { ok: false, action, error: result.error };
|
|
1430
|
+
return {
|
|
1431
|
+
ok: true,
|
|
1432
|
+
action,
|
|
1433
|
+
text: result.value.text,
|
|
1434
|
+
version: result.value.version,
|
|
1435
|
+
displayPath: result.value.displayPath,
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
const result = await dispatch('edit', {
|
|
1439
|
+
path: args?.path,
|
|
1440
|
+
cwd: args?.cwd,
|
|
1441
|
+
oldString: args?.oldString,
|
|
1442
|
+
newString: args?.newString,
|
|
1443
|
+
replaceAll: args?.replaceAll === true,
|
|
1444
|
+
baseVersion: args?.baseVersion,
|
|
1445
|
+
}, MODEL_TOOL, execution);
|
|
1446
|
+
if (result.ok !== true) return { ok: false, action, error: result.error };
|
|
1447
|
+
return {
|
|
1448
|
+
ok: true,
|
|
1449
|
+
action,
|
|
1450
|
+
version: result.value.version,
|
|
1451
|
+
displayPath: result.value.displayPath,
|
|
1452
|
+
changed: result.value.changed,
|
|
1453
|
+
};
|
|
1454
|
+
} catch (error) {
|
|
1455
|
+
return { ok: false, action, error: { code: errorCode(error), message: String(error?.message ?? error) } };
|
|
1456
|
+
}
|
|
1457
|
+
},
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* markpilot 模型工具(read/edit):与编辑面 tab 同一守卫。加载失败只告警,不影响插件其它能力。
|
|
1463
|
+
*/
|
|
1464
|
+
export function registerMarkdownTool(ctx, { dispatch, log } = {}) {
|
|
1465
|
+
const onWarn = (error) => {
|
|
1466
|
+
try { log?.warn?.('dsh-markdown-typora: model tool disabled: %s', String(error?.message ?? error)); } catch { /* 忽略 */ }
|
|
1467
|
+
};
|
|
1468
|
+
try {
|
|
1469
|
+
if (typeof ctx?.inject !== 'function') return;
|
|
1470
|
+
ctx.inject(['tools'], (sctx) => {
|
|
1471
|
+
try {
|
|
1472
|
+
const tools = sctx?.get?.('tools') ?? sctx?.tools;
|
|
1473
|
+
if (!tools || typeof tools.register !== 'function') {
|
|
1474
|
+
onWarn(new Error('Host tools.register is unavailable'));
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
sctx.effect(() => {
|
|
1478
|
+
let stopped = false;
|
|
1479
|
+
let dispose;
|
|
1480
|
+
Promise.resolve()
|
|
1481
|
+
.then(() => import('@deepseek-ai/dsh-tools'))
|
|
1482
|
+
.then((mod) => {
|
|
1483
|
+
if (stopped) return;
|
|
1484
|
+
const defineTool = mod?.defineTool;
|
|
1485
|
+
if (typeof defineTool !== 'function') throw new Error('Host defineTool is unavailable');
|
|
1486
|
+
const tool = defineTool(createMarkdownToolDefinition(dispatch));
|
|
1487
|
+
dispose = tools.register(tool);
|
|
1488
|
+
})
|
|
1489
|
+
.catch((error) => {
|
|
1490
|
+
if (!stopped) onWarn(error);
|
|
1491
|
+
});
|
|
1492
|
+
return () => {
|
|
1493
|
+
stopped = true;
|
|
1494
|
+
try { if (typeof dispose === 'function') dispose(); } catch (error) { onWarn(error); }
|
|
1495
|
+
};
|
|
1496
|
+
}, 'dsh-markdown-typora: markdown tool');
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
onWarn(error);
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
} catch (error) {
|
|
1502
|
+
onWarn(error);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
export default { name, inject, apply };
|