@markdstage/markdstage 3.3.0 → 3.8.0

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.
@@ -13,16 +13,24 @@
13
13
  // overview, custom themes, Mermaid, Architecture DSL, local assets) is identical.
14
14
 
15
15
  import { createServer } from "node:http";
16
- import { realpath, stat } from "node:fs/promises";
17
- import { dirname, join, relative, resolve, sep } from "node:path";
16
+ import { readFile, realpath, stat } from "node:fs/promises";
17
+ import { dirname, join, resolve } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { randomBytes } from "node:crypto";
20
20
  import { resolveAssetFile } from "../scripts/asset-paths.mjs";
21
+ import { loadSlideBackgrounds, resolveSlideBackgroundFile } from "./slide-backgrounds.mjs";
21
22
  import { importedArchitectureBlockIndex } from "../scripts/markdown-blocks.mjs";
22
23
  import { isMarkdownPath, listMarkdownFiles } from "../scripts/markdown-files.mjs";
24
+ import { createMarkdownWatcher } from "../scripts/markdown-watcher.mjs";
23
25
  import { startArchitectureEditorServer } from "./architecture-editor-server.mjs";
24
26
  import { saveArchitectureSource } from "./architecture-source.mjs";
25
- import { isPathInside } from "./output-paths.mjs";
27
+ import { exportPdf, exportPptx } from "./output.mjs";
28
+ import {
29
+ isPathInside,
30
+ outputPathForSource,
31
+ pdfNameForSource,
32
+ pptxNameForSource,
33
+ } from "./output-paths.mjs";
26
34
  import { safeJoin, sendChunkedVendorAsset, sendFile } from "./static-files.mjs";
27
35
 
28
36
  const RUNTIME_DIR = dirname(fileURLToPath(import.meta.url));
@@ -93,13 +101,139 @@ function broadcast(session) {
93
101
  */
94
102
  export async function startPresentationServer(
95
103
  session,
96
- { editable = false, onLog, presenter, token = createUrlToken() } = {},
104
+ {
105
+ application = false,
106
+ editable = false,
107
+ exporters,
108
+ initialSourceMode = "snapshot",
109
+ onLog,
110
+ presenter,
111
+ token = createUrlToken(),
112
+ watcherFactory = createMarkdownWatcher,
113
+ } = {},
97
114
  ) {
98
115
  const base = `/${token}`;
99
116
  const architectureEditors = new Map();
100
- const editingAvailable = editable === true;
117
+ const applicationMode = application === true;
118
+ const editingAvailable = applicationMode || editable === true;
119
+ const exportPdfImpl = exporters?.pdf ?? exportPdf;
120
+ const exportPptxImpl = exporters?.pptx ?? exportPptx;
121
+ let sourceMode = initialSourceMode === "live" ? "live" : "snapshot";
122
+ let sourceWatchStatus = "inactive";
123
+ let sourceWatchError = "";
124
+ let sourceWatcher = null;
125
+ let sourceWatcherGeneration = 0;
126
+ let sourceOperation = Promise.resolve();
101
127
  let port = 0;
102
128
 
129
+ const runSourceOperation = (operation) => {
130
+ const result = sourceOperation.then(operation, operation);
131
+ sourceOperation = result.catch(() => {});
132
+ return result;
133
+ };
134
+
135
+ const setWatchState = (status, error = "") => {
136
+ const changed = sourceWatchStatus !== status || sourceWatchError !== error;
137
+ sourceWatchStatus = status;
138
+ sourceWatchError = error;
139
+ session.watchStatus = status;
140
+ session.watchError = error;
141
+ if (changed && port) broadcast(session);
142
+ };
143
+
144
+ const closeSourceWatcher = () => {
145
+ sourceWatcherGeneration += 1;
146
+ sourceWatcher?.close();
147
+ sourceWatcher = null;
148
+ };
149
+
150
+ const reloadSourceNow = async (generation) => {
151
+ if (
152
+ generation !== sourceWatcherGeneration ||
153
+ sourceMode !== "live" ||
154
+ !session.file
155
+ ) {
156
+ return;
157
+ }
158
+ const sourceFile = session.file;
159
+ try {
160
+ if ((await readFile(sourceFile, "utf8")) === session.sourceMarkdown) {
161
+ setWatchState("watching");
162
+ return;
163
+ }
164
+ if (
165
+ generation !== sourceWatcherGeneration ||
166
+ sourceMode !== "live" ||
167
+ session.file !== sourceFile
168
+ ) {
169
+ return;
170
+ }
171
+ await session.load({ preserveIndex: true });
172
+ setWatchState("watching");
173
+ broadcast(session);
174
+ onLog?.(`reloaded ${session.sourceName} (${session.slides.length} slides)`);
175
+ } catch (error) {
176
+ if (
177
+ generation !== sourceWatcherGeneration ||
178
+ sourceMode !== "live" ||
179
+ session.file !== sourceFile
180
+ ) {
181
+ return;
182
+ }
183
+ setWatchState("error", error?.code || "source_reload_failed");
184
+ onLog?.(
185
+ `reload failed, keeping the last valid deck: ${error?.message || error}`,
186
+ "error",
187
+ );
188
+ }
189
+ };
190
+
191
+ const reloadSource = (generation) =>
192
+ runSourceOperation(() => reloadSourceNow(generation));
193
+
194
+ const bindSourceWatcher = () => {
195
+ closeSourceWatcher();
196
+ if (sourceMode !== "live" || !session.file) {
197
+ setWatchState("inactive");
198
+ return { ok: true };
199
+ }
200
+ try {
201
+ const generation = sourceWatcherGeneration;
202
+ sourceWatcher = watcherFactory({
203
+ path: session.file,
204
+ onChange: () => reloadSource(generation),
205
+ onError: (error) => {
206
+ setWatchState("error", "watch_failed");
207
+ onLog?.(`watch error: ${error?.message || error}`, "error");
208
+ },
209
+ });
210
+ setWatchState("watching");
211
+ return { ok: true, generation };
212
+ } catch (error) {
213
+ setWatchState("error", "watch_failed");
214
+ return {
215
+ ok: false,
216
+ error: "watch_failed",
217
+ message: error?.message || "The Markdown watcher could not be started.",
218
+ };
219
+ }
220
+ };
221
+
222
+ const setSourceMode = async (mode, { reload = true } = {}) => {
223
+ sourceMode = mode === "live" ? "live" : "snapshot";
224
+ const result = bindSourceWatcher();
225
+ if (sourceMode === "live" && result.ok && reload) {
226
+ await reloadSourceNow(result.generation);
227
+ }
228
+ return sourceMode === "live"
229
+ ? {
230
+ ok: true,
231
+ status: sourceWatchStatus,
232
+ error: sourceWatchError,
233
+ }
234
+ : { ok: true };
235
+ };
236
+
103
237
  const server = createServer(async (req, res) => {
104
238
  res.setHeader("X-Content-Type-Options", "nosniff");
105
239
  res.setHeader("Referrer-Policy", "no-referrer");
@@ -158,21 +292,23 @@ export async function startPresentationServer(
158
292
  customThemeCss: session.customThemeCss,
159
293
  customThemeMeta: session.customThemeMeta,
160
294
  mode: session.mode,
161
- sourceBacked: editingAvailable,
162
- sourceModeAvailable: false,
163
- sourceMode: editingAvailable ? "live" : "snapshot",
164
- sourceWatchStatus: session.watchStatus || "inactive",
165
- sourceWatchError: session.watchError || "",
295
+ sourceBacked: Boolean(session.file),
296
+ sourceModeAvailable: applicationMode && Boolean(session.file),
297
+ sourceMode,
298
+ sourceWatchStatus,
299
+ sourceWatchError,
166
300
  presenterRunning: Boolean(presenter?.isRunning?.()),
167
301
  presenterWindowAvailable: Boolean(presenter),
168
302
  presenterViewAvailable: Boolean(presenter),
169
- pdfExportAvailable: false,
170
- pptxExportAvailable: false,
171
- markdownImportAvailable: false,
172
- architectureEditAvailable: editingAvailable,
173
- architectureEdit: editingAvailable && Boolean(session.architectureEdit),
174
- architectureDetailedEdit: editingAvailable,
175
- architectureDetailedEditTarget: editingAvailable ? "window" : "",
303
+ pdfExportAvailable: applicationMode && Boolean(session.file),
304
+ pptxExportAvailable: applicationMode && Boolean(session.file),
305
+ markdownImportAvailable: applicationMode,
306
+ architectureEditAvailable: editingAvailable && Boolean(session.file),
307
+ architectureEdit:
308
+ editingAvailable && Boolean(session.file) && Boolean(session.architectureEdit),
309
+ architectureDetailedEdit: editingAvailable && Boolean(session.file),
310
+ architectureDetailedEditTarget:
311
+ editingAvailable && session.file ? "window" : "",
176
312
  });
177
313
  return;
178
314
  }
@@ -267,6 +403,7 @@ export async function startPresentationServer(
267
403
  themeLocked: job.themeLocked,
268
404
  customThemeCss: job.customThemeCss,
269
405
  customThemeMeta: job.customThemeMeta,
406
+ mermaidImageFallback: job.mermaidImageFallback === true,
270
407
  });
271
408
  return;
272
409
  }
@@ -310,6 +447,10 @@ export async function startPresentationServer(
310
447
  }
311
448
 
312
449
  if (route === "/markdown-files") {
450
+ if (!applicationMode) {
451
+ json(res, 501, { ok: false, error: "not_supported" });
452
+ return;
453
+ }
313
454
  if (req.method !== "GET") {
314
455
  res.setHeader("Allow", "GET");
315
456
  json(res, 405, { ok: false, error: "method_not_allowed" });
@@ -327,6 +468,10 @@ export async function startPresentationServer(
327
468
 
328
469
  // Loading another workspace Markdown file from the browser 📂 button.
329
470
  if (route === "/import") {
471
+ if (!applicationMode) {
472
+ json(res, 501, { ok: false, error: "not_supported" });
473
+ return;
474
+ }
330
475
  if (req.method !== "POST") {
331
476
  res.setHeader("Allow", "POST");
332
477
  json(res, 405, { ok: false, error: "method_not_allowed" });
@@ -345,6 +490,12 @@ export async function startPresentationServer(
345
490
  return;
346
491
  }
347
492
  const requested = typeof body.path === "string" ? body.path.trim() : "";
493
+ const requestedSourceMode =
494
+ body.sourceMode === undefined ? "snapshot" : body.sourceMode;
495
+ if (requestedSourceMode !== "snapshot" && requestedSourceMode !== "live") {
496
+ json(res, 400, { ok: false, error: "invalid_source_mode" });
497
+ return;
498
+ }
348
499
  if (!requested) {
349
500
  json(res, 400, { ok: false, error: "path (string) is required" });
350
501
  return;
@@ -366,29 +517,72 @@ export async function startPresentationServer(
366
517
  json(res, 404, { ok: false, error: "file_not_found" });
367
518
  return;
368
519
  }
520
+ let sourceResult;
369
521
  try {
370
- session.file = canonical;
371
- session.sourceName = relative(root, canonical).split(sep).join("/");
372
- await session.load();
373
- broadcast(session);
522
+ sourceResult = await runSourceOperation(async () => {
523
+ await session.openFile(canonical);
524
+ return setSourceMode(requestedSourceMode);
525
+ });
374
526
  } catch (error) {
375
527
  json(res, 400, { ok: false, error: error?.code || "import_failed" });
376
528
  return;
377
529
  }
378
- json(res, 200, {
379
- ok: true,
530
+ broadcast(session);
531
+ json(res, sourceResult.ok ? 200 : 409, {
532
+ ...sourceResult,
380
533
  version: session.version,
381
534
  index: session.index,
382
535
  total: session.slides.length,
383
536
  theme: session.theme,
384
537
  sourceName: session.sourceName,
385
- sourceMode: "snapshot",
386
- sourceWatchStatus: session.watchStatus || "inactive",
538
+ sourceMode,
539
+ sourceWatchStatus,
540
+ });
541
+ return;
542
+ }
543
+
544
+ if (applicationMode && route === "/source-mode") {
545
+ if (req.method !== "POST") {
546
+ res.setHeader("Allow", "POST");
547
+ json(res, 405, { ok: false, error: "method_not_allowed" });
548
+ return;
549
+ }
550
+ if (!sameOrigin()) {
551
+ json(res, 403, { ok: false, error: "origin_not_allowed" });
552
+ return;
553
+ }
554
+ let body;
555
+ try {
556
+ body = await readJsonBody(req);
557
+ } catch (error) {
558
+ res.setHeader("Connection", "close");
559
+ json(res, error?.message === "payload_too_large" ? 413 : 400, {
560
+ ok: false,
561
+ error: error?.message || "bad_request",
562
+ });
563
+ return;
564
+ }
565
+ if (!session.file) {
566
+ json(res, 409, { ok: false, error: "no_deck" });
567
+ return;
568
+ }
569
+ if (body.mode !== "snapshot" && body.mode !== "live") {
570
+ json(res, 400, { ok: false, error: "invalid_source_mode" });
571
+ return;
572
+ }
573
+ const previous = sourceMode;
574
+ const result = await runSourceOperation(() => setSourceMode(body.mode));
575
+ json(res, result.ok ? 200 : 409, {
576
+ ...result,
577
+ changed: result.ok ? previous !== sourceMode : false,
578
+ sourceMode,
579
+ sourceWatchStatus,
580
+ sourceWatchError,
387
581
  });
388
582
  return;
389
583
  }
390
584
 
391
- if (editingAvailable && route === "/edit-mode") {
585
+ if (editingAvailable && session.file && route === "/edit-mode") {
392
586
  if (req.method !== "POST") {
393
587
  res.setHeader("Allow", "POST");
394
588
  json(res, 405, { ok: false, error: "method_not_allowed" });
@@ -423,7 +617,7 @@ export async function startPresentationServer(
423
617
  return;
424
618
  }
425
619
 
426
- if (editingAvailable && route === "/edit") {
620
+ if (editingAvailable && session.file && route === "/edit") {
427
621
  if (req.method !== "POST") {
428
622
  res.setHeader("Allow", "POST");
429
623
  json(res, 405, { ok: false, error: "method_not_allowed" });
@@ -444,78 +638,102 @@ export async function startPresentationServer(
444
638
  });
445
639
  return;
446
640
  }
447
- if (!session.architectureEdit) {
448
- json(res, 409, { ok: false, error: "edit_mode_disabled" });
449
- return;
450
- }
451
641
  if (typeof body.source !== "string" || !body.source.trim()) {
452
642
  json(res, 400, { ok: false, error: "source (string) is required" });
453
643
  return;
454
644
  }
455
- const index = Number.isInteger(body.index) ? body.index : session.index;
456
- const block = Number.isInteger(body.block) ? body.block : 0;
457
- const deckVersion = Number.isInteger(body.deckVersion)
458
- ? body.deckVersion
459
- : session.deckVersion;
460
- if (index < 0 || index >= session.slides.length) {
461
- json(res, 400, { ok: false, error: "index_out_of_range" });
462
- return;
463
- }
464
- if (deckVersion !== session.deckVersion) {
465
- json(res, 409, { ok: false, error: "deck_changed" });
466
- return;
467
- }
468
- const globalBlock = importedArchitectureBlockIndex(session.slides, index, block);
469
- if (globalBlock === null) {
470
- json(res, 404, { ok: false, error: "block_not_found" });
471
- return;
472
- }
473
- const result = await saveArchitectureSource({
474
- workspaceRoot: session.workspaceRoot,
475
- sourcePath: session.sourceName,
476
- sourceFile: session.file,
477
- blockIndex: globalBlock,
478
- source: body.source,
479
- expectedMarkdown: session.sourceMarkdown,
480
- });
481
- if (!result.ok) {
482
- const status =
483
- result.error === "source_changed"
484
- ? 409
485
- : result.error === "block_not_found"
486
- ? 404
487
- : result.error === "source_file_too_large"
488
- ? 413
489
- : result.error === "source_write_failed"
490
- ? 500
491
- : 422;
492
- json(res, status, result);
493
- return;
494
- }
495
- try {
496
- await session.load({ preserveIndex: true });
497
- } catch (error) {
498
- json(res, 500, {
499
- ok: false,
500
- error: "source_reload_failed",
501
- message: error?.message || "The saved deck could not be reloaded.",
645
+ const response = await runSourceOperation(async () => {
646
+ if (!session.architectureEdit) {
647
+ return {
648
+ status: 409,
649
+ body: { ok: false, error: "edit_mode_disabled" },
650
+ };
651
+ }
652
+ const index = Number.isInteger(body.index) ? body.index : session.index;
653
+ const block = Number.isInteger(body.block) ? body.block : 0;
654
+ const deckVersion = Number.isInteger(body.deckVersion)
655
+ ? body.deckVersion
656
+ : session.deckVersion;
657
+ if (index < 0 || index >= session.slides.length) {
658
+ return {
659
+ status: 400,
660
+ body: { ok: false, error: "index_out_of_range" },
661
+ };
662
+ }
663
+ if (deckVersion !== session.deckVersion) {
664
+ return {
665
+ status: 409,
666
+ body: { ok: false, error: "deck_changed" },
667
+ };
668
+ }
669
+ const globalBlock = importedArchitectureBlockIndex(
670
+ session.slides,
671
+ index,
672
+ block,
673
+ );
674
+ if (globalBlock === null) {
675
+ return {
676
+ status: 404,
677
+ body: { ok: false, error: "block_not_found" },
678
+ };
679
+ }
680
+ try {
681
+ await loadSlideBackgrounds(session.workspaceRoot, session.sourceName, session.slides);
682
+ } catch (error) {
683
+ return { status: 400, body: { ok: false, error: error.code, message: error.message } };
684
+ }
685
+ const result = await saveArchitectureSource({
686
+ workspaceRoot: session.workspaceRoot,
687
+ sourcePath: session.sourceName,
688
+ sourceFile: session.file,
689
+ blockIndex: globalBlock,
690
+ source: body.source,
691
+ expectedMarkdown: session.sourceMarkdown,
502
692
  });
503
- return;
504
- }
505
- broadcast(session);
506
- json(res, 200, {
507
- ok: true,
508
- version: session.version,
509
- deckVersion: session.deckVersion,
510
- index,
511
- block,
512
- markdown: session.markdown,
513
- fileSaved: true,
693
+ if (!result.ok) {
694
+ const status =
695
+ result.error === "source_changed"
696
+ ? 409
697
+ : result.error === "block_not_found"
698
+ ? 404
699
+ : result.error === "source_file_too_large"
700
+ ? 413
701
+ : result.error === "source_write_failed"
702
+ ? 500
703
+ : 422;
704
+ return { status, body: result };
705
+ }
706
+ try {
707
+ await session.load({ preserveIndex: true });
708
+ } catch (error) {
709
+ return {
710
+ status: 500,
711
+ body: {
712
+ ok: false,
713
+ error: "source_reload_failed",
714
+ message: error?.message || "The saved deck could not be reloaded.",
715
+ },
716
+ };
717
+ }
718
+ return {
719
+ status: 200,
720
+ body: {
721
+ ok: true,
722
+ version: session.version,
723
+ deckVersion: session.deckVersion,
724
+ index,
725
+ block,
726
+ markdown: session.markdown,
727
+ fileSaved: true,
728
+ },
729
+ };
514
730
  });
731
+ if (response.status === 200) broadcast(session);
732
+ json(res, response.status, response.body);
515
733
  return;
516
734
  }
517
735
 
518
- if (editingAvailable && route === "/architecture-editor/open") {
736
+ if (editingAvailable && session.file && route === "/architecture-editor/open") {
519
737
  if (req.method !== "POST") {
520
738
  res.setHeader("Allow", "POST");
521
739
  json(res, 405, { ok: false, error: "method_not_allowed" });
@@ -559,11 +777,13 @@ export async function startPresentationServer(
559
777
  theme: session.theme,
560
778
  logger: onLog,
561
779
  onMarkdownSaved: async ({ sourcePath }) => {
562
- if (resolve(session.workspaceRoot, sourcePath) !== resolve(session.file)) {
563
- return;
564
- }
565
- await session.load({ preserveIndex: true });
566
- broadcast(session);
780
+ await runSourceOperation(async () => {
781
+ if (resolve(session.workspaceRoot, sourcePath) !== resolve(session.file)) {
782
+ return;
783
+ }
784
+ await session.load({ preserveIndex: true });
785
+ broadcast(session);
786
+ });
567
787
  },
568
788
  });
569
789
  architectureEditors.set(key, entry);
@@ -608,6 +828,10 @@ export async function startPresentationServer(
608
828
  json(res, 403, { ok: false, error: "origin_not_allowed" });
609
829
  return;
610
830
  }
831
+ if (!session.slides.length) {
832
+ json(res, 409, { ok: false, error: "no_deck" });
833
+ return;
834
+ }
611
835
  try {
612
836
  const result = req.method === "POST" ? await presenter.open() : await presenter.close();
613
837
  json(res, 200, { ok: true, ...result });
@@ -621,11 +845,77 @@ export async function startPresentationServer(
621
845
  return;
622
846
  }
623
847
 
848
+ if (applicationMode && (route === "/export" || route === "/export-pptx")) {
849
+ if (req.method !== "POST") {
850
+ res.setHeader("Allow", "POST");
851
+ json(res, 405, { ok: false, error: "method_not_allowed" });
852
+ return;
853
+ }
854
+ if (!sameOrigin()) {
855
+ json(res, 403, { ok: false, error: "origin_not_allowed" });
856
+ return;
857
+ }
858
+ if (!session.slides.length) {
859
+ json(res, 409, { ok: false, error: "no_deck" });
860
+ return;
861
+ }
862
+ const pptx = route === "/export-pptx";
863
+ let body = {};
864
+ if (pptx) {
865
+ try {
866
+ body = await readJsonBody(req);
867
+ if (!body || typeof body !== "object" || Array.isArray(body) ||
868
+ (body.mermaidImageFallback !== undefined && typeof body.mermaidImageFallback !== "boolean")) {
869
+ throw new Error("invalid_export_options");
870
+ }
871
+ } catch (error) {
872
+ json(res, error?.message === "payload_too_large" ? 413 : 400, {
873
+ ok: false,
874
+ error: error?.message || "bad_request",
875
+ });
876
+ return;
877
+ }
878
+ }
879
+ try {
880
+ const result = pptx
881
+ ? await exportPptxImpl(
882
+ session,
883
+ outputPathForSource(session.sourceName, pptxNameForSource(session.sourceName)),
884
+ session.theme,
885
+ undefined,
886
+ { mermaidImageFallback: body.mermaidImageFallback === true },
887
+ )
888
+ : await exportPdfImpl(
889
+ session,
890
+ outputPathForSource(session.sourceName, pdfNameForSource(session.sourceName)),
891
+ session.theme,
892
+ );
893
+ json(res, 200, result);
894
+ } catch (error) {
895
+ json(
896
+ res,
897
+ error?.code === "no_deck" || error?.code === "export_in_progress"
898
+ ? 409
899
+ : 500,
900
+ {
901
+ ok: false,
902
+ error:
903
+ error?.code || (pptx ? "pptx_export_failed" : "pdf_export_failed"),
904
+ message:
905
+ error?.message ||
906
+ (pptx ? "PowerPoint export failed." : "PDF export failed."),
907
+ },
908
+ );
909
+ }
910
+ return;
911
+ }
912
+
624
913
  // Routes that are unavailable in this CLI server mode stay explicit so the
625
914
  // browser reports an actionable message instead of a bare 404.
626
915
  if (
627
916
  route === "/present" ||
628
917
  route === "/export" ||
918
+ route === "/export-pptx" ||
629
919
  route === "/edit" ||
630
920
  route === "/edit-mode" ||
631
921
  route === "/source-mode" ||
@@ -694,6 +984,22 @@ export async function startPresentationServer(
694
984
  return;
695
985
  }
696
986
 
987
+ if (route.startsWith("/background-assets/")) {
988
+ try {
989
+ const file = await resolveSlideBackgroundFile(
990
+ session.workspaceRoot,
991
+ session.sourceName,
992
+ `/assets/${route.slice("/background-assets/".length)}`,
993
+ );
994
+ await sendFile(res, file, { cache: false });
995
+ } catch (error) {
996
+ res.statusCode = error?.code === "slide_background_too_large" ? 413
997
+ : error?.code === "invalid_slide_background" ? 403 : 404;
998
+ res.end(error.message);
999
+ }
1000
+ return;
1001
+ }
1002
+
697
1003
  if (route.startsWith("/assets/")) {
698
1004
  try {
699
1005
  const abs = await resolveAssetFile(
@@ -730,14 +1036,23 @@ export async function startPresentationServer(
730
1036
  port = typeof address === "object" && address ? address.port : 0;
731
1037
  const url = `http://127.0.0.1:${port}${base}/`;
732
1038
  session.url = url;
1039
+ const initialWatcher = bindSourceWatcher();
1040
+ if (sourceMode === "live" && initialWatcher.ok) {
1041
+ await runSourceOperation(() => reloadSourceNow(initialWatcher.generation));
1042
+ }
733
1043
 
734
1044
  return {
735
1045
  server,
736
1046
  url,
737
1047
  token,
738
1048
  port,
1049
+ get sourceMode() {
1050
+ return sourceMode;
1051
+ },
739
1052
  broadcast: () => broadcast(session),
740
1053
  close: async () => {
1054
+ closeSourceWatcher();
1055
+ await sourceOperation.catch(() => {});
741
1056
  await Promise.all(
742
1057
  [...architectureEditors.values()].map(async (entry) =>
743
1058
  (entry.editor ?? (await entry.promise.catch(() => null)))?.close().catch(() => {}),