@markdstage/markdstage 0.1.1

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.
Files changed (67) hide show
  1. package/README.md +90 -0
  2. package/bin/markdstage.mjs +12 -0
  3. package/package.json +45 -0
  4. package/shared/README.md +1014 -0
  5. package/shared/THIRD-PARTY-NOTICES.md +19 -0
  6. package/shared/deck-state.mjs +105 -0
  7. package/shared/docs/custom-theme-authoring.md +208 -0
  8. package/shared/markdown-deck.mjs +220 -0
  9. package/shared/markdstage-guide.mjs +276 -0
  10. package/shared/presenter-window.mjs +17 -0
  11. package/shared/renderer/architecture-document.mjs +596 -0
  12. package/shared/renderer/architecture-edit.mjs +298 -0
  13. package/shared/renderer/architecture-editor.mjs +449 -0
  14. package/shared/renderer/architecture.mjs +4033 -0
  15. package/shared/renderer/import-path.mjs +11 -0
  16. package/shared/renderer/index.html +106 -0
  17. package/shared/renderer/renderer.js +2082 -0
  18. package/shared/renderer/slides.css +614 -0
  19. package/shared/renderer/speaker-notes.mjs +106 -0
  20. package/shared/renderer/theme.mjs +205 -0
  21. package/shared/runtime/browser.mjs +539 -0
  22. package/shared/runtime/custom-theme.mjs +135 -0
  23. package/shared/runtime/deck-session.mjs +188 -0
  24. package/shared/runtime/errors.mjs +17 -0
  25. package/shared/runtime/output-paths.mjs +159 -0
  26. package/shared/runtime/output.mjs +385 -0
  27. package/shared/runtime/presentation-server.mjs +505 -0
  28. package/shared/runtime/static-files.mjs +70 -0
  29. package/shared/schema/README.md +228 -0
  30. package/shared/schema/architecture-v1.schema.json +664 -0
  31. package/shared/schema/examples/web-app.architecture.json +119 -0
  32. package/shared/schema/theme-metadata-v1.schema.json +75 -0
  33. package/shared/schema/theme-v1.json +84 -0
  34. package/shared/scripts/architecture-assets.mjs +226 -0
  35. package/shared/scripts/asset-paths.mjs +92 -0
  36. package/shared/scripts/atomic-markdown-replace.mjs +46 -0
  37. package/shared/scripts/markdown-blocks.mjs +182 -0
  38. package/shared/scripts/markdown-files.mjs +63 -0
  39. package/shared/scripts/markdown-save-coordinator.mjs +18 -0
  40. package/shared/scripts/markdown-watcher.mjs +80 -0
  41. package/shared/scripts/theme-paths.mjs +108 -0
  42. package/shared/scripts/vendor-assets.mjs +132 -0
  43. package/shared/scripts/workspace-root.mjs +32 -0
  44. package/shared/vendor/highlight.LICENSE +29 -0
  45. package/shared/vendor/highlight.min.js +1244 -0
  46. package/shared/vendor/marked.min.js +6 -0
  47. package/shared/vendor/mermaid.min.js.part-0001 +268 -0
  48. package/shared/vendor/mermaid.min.js.part-0002 +304 -0
  49. package/shared/vendor/mermaid.min.js.part-0003 +324 -0
  50. package/shared/vendor/mermaid.min.js.part-0004 +374 -0
  51. package/shared/vendor/mermaid.min.js.part-0005 +564 -0
  52. package/shared/vendor/mermaid.min.js.part-0006 +1308 -0
  53. package/shared/vendor/mermaid.min.js.part-0007 +269 -0
  54. package/shared/vendor/purify.min.js +3 -0
  55. package/shared/vendor/vendor-assets.lock.json +60 -0
  56. package/src/cli.mjs +347 -0
  57. package/src/commands/capture.mjs +23 -0
  58. package/src/commands/export.mjs +18 -0
  59. package/src/commands/guide.mjs +23 -0
  60. package/src/commands/inspect.mjs +35 -0
  61. package/src/commands/present.mjs +91 -0
  62. package/src/commands/skill.mjs +114 -0
  63. package/src/commands/validate.mjs +79 -0
  64. package/src/deck.mjs +63 -0
  65. package/src/exit.mjs +58 -0
  66. package/src/runtime.mjs +77 -0
  67. package/src/skills.mjs +155 -0
@@ -0,0 +1,505 @@
1
+ // Loopback presentation server used by the MarkdStage CLI.
2
+ //
3
+ // Security model:
4
+ // * The listener binds to 127.0.0.1 only.
5
+ // * Every route lives below an unguessable per-process URL token, so other
6
+ // local processes cannot discover the deck by scanning ports.
7
+ // * Mutating routes require a same-origin `Origin` header, and every request
8
+ // must carry a loopback `Host` header (DNS-rebinding protection).
9
+ // * Mutable state responses are `no-store`; files stay inside the workspace.
10
+ //
11
+ // The routes mirror the Canvas Extension endpoints the renderer calls, so the
12
+ // browser experience (navigation, presenter view, next-slide preview, notes,
13
+ // overview, custom themes, Mermaid, Architecture DSL, local assets) is identical.
14
+
15
+ import { createServer } from "node:http";
16
+ import { realpath, stat } from "node:fs/promises";
17
+ import { dirname, join, relative, resolve, sep } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { randomBytes } from "node:crypto";
20
+ import { resolveAssetFile } from "../scripts/asset-paths.mjs";
21
+ import { isMarkdownPath, listMarkdownFiles } from "../scripts/markdown-files.mjs";
22
+ import { isPathInside } from "./output-paths.mjs";
23
+ import { safeJoin, sendChunkedVendorAsset, sendFile } from "./static-files.mjs";
24
+
25
+ const RUNTIME_DIR = dirname(fileURLToPath(import.meta.url));
26
+ const EXT_DIR = resolve(RUNTIME_DIR, "..");
27
+ const VENDOR_DIR = join(EXT_DIR, "vendor");
28
+ const VENDOR_MANIFEST = join(VENDOR_DIR, "vendor-assets.lock.json");
29
+ const MAX_BODY_BYTES = 4096;
30
+
31
+ export function createUrlToken() {
32
+ return randomBytes(24).toString("base64url");
33
+ }
34
+
35
+ function json(res, status, payload, { cache = "no-store" } = {}) {
36
+ res.statusCode = status;
37
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
38
+ res.setHeader("Cache-Control", cache);
39
+ res.end(JSON.stringify(payload));
40
+ }
41
+
42
+ function readJsonBody(req, limit = MAX_BODY_BYTES) {
43
+ return new Promise((resolvePromise, rejectPromise) => {
44
+ let size = 0;
45
+ const chunks = [];
46
+ req.on("data", (chunk) => {
47
+ size += chunk.length;
48
+ if (size > limit) {
49
+ rejectPromise(new Error("payload_too_large"));
50
+ req.destroy();
51
+ return;
52
+ }
53
+ chunks.push(chunk);
54
+ });
55
+ req.on("end", () => {
56
+ try {
57
+ const text = Buffer.concat(chunks).toString("utf8");
58
+ resolvePromise(text ? JSON.parse(text) : {});
59
+ } catch (_) {
60
+ rejectPromise(new Error("invalid_json"));
61
+ }
62
+ });
63
+ req.on("error", rejectPromise);
64
+ });
65
+ }
66
+
67
+ function hostAllowed(req, port) {
68
+ const host = req.headers.host;
69
+ if (!host) return false;
70
+ return host === `127.0.0.1:${port}` || host === `localhost:${port}` || host === `[::1]:${port}`;
71
+ }
72
+
73
+ function broadcast(session) {
74
+ const message = `data: ${session.version}\n\n`;
75
+ for (const client of [...session.clients]) {
76
+ try {
77
+ client.write(message);
78
+ } catch (_) {
79
+ session.clients.delete(client);
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Start the presentation server for a deck session.
86
+ *
87
+ * Returns `{ url, token, port, close, broadcast }`. `session.url` is set to the
88
+ * token-scoped base URL so the shared output runtime can render from it.
89
+ */
90
+ export async function startPresentationServer(session, { onLog, token = createUrlToken() } = {}) {
91
+ const base = `/${token}`;
92
+ let port = 0;
93
+
94
+ const server = createServer(async (req, res) => {
95
+ res.setHeader("X-Content-Type-Options", "nosniff");
96
+ res.setHeader("Referrer-Policy", "no-referrer");
97
+ if (!hostAllowed(req, port)) {
98
+ res.statusCode = 403;
99
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
100
+ res.end("Forbidden");
101
+ return;
102
+ }
103
+
104
+ let requestUrl;
105
+ let pathname = "/";
106
+ try {
107
+ requestUrl = new URL(req.url, `http://127.0.0.1:${port}`);
108
+ pathname = decodeURIComponent(requestUrl.pathname);
109
+ } catch (_) {
110
+ res.statusCode = 400;
111
+ res.end("Bad request");
112
+ return;
113
+ }
114
+
115
+ if (pathname !== base && !pathname.startsWith(`${base}/`)) {
116
+ res.statusCode = 404;
117
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
118
+ res.end("Not found");
119
+ return;
120
+ }
121
+ const route = pathname.slice(base.length) || "/";
122
+
123
+ const sameOrigin = () => {
124
+ const origin = req.headers.origin;
125
+ return !origin || origin === `http://127.0.0.1:${port}`;
126
+ };
127
+
128
+ if (route === "/" || route === "/index.html") {
129
+ await sendFile(res, join(EXT_DIR, "renderer", "index.html"), { cache: false });
130
+ return;
131
+ }
132
+
133
+ if (route === "/state") {
134
+ const offset = Math.max(
135
+ -1,
136
+ Math.min(1, Number.parseInt(requestUrl.searchParams.get("offset") || "0", 10) || 0),
137
+ );
138
+ const total = session.slides.length;
139
+ const targetIndex = Math.max(0, Math.min(total - 1, session.index + offset));
140
+ json(res, 200, {
141
+ version: session.version,
142
+ deckVersion: session.deckVersion,
143
+ markdown: offset && total ? session.slides[targetIndex] : session.markdown,
144
+ index: offset ? targetIndex : session.index,
145
+ total,
146
+ theme: session.theme,
147
+ themeLocked: session.themeLocked,
148
+ customThemeFile: session.customThemeFile,
149
+ customThemeCss: session.customThemeCss,
150
+ customThemeMeta: session.customThemeMeta,
151
+ mode: session.mode,
152
+ sourceBacked: false,
153
+ sourceMode: "snapshot",
154
+ sourceWatchStatus: session.watchStatus || "inactive",
155
+ sourceWatchError: session.watchError || "",
156
+ presenterRunning: false,
157
+ architectureEdit: false,
158
+ architectureDetailedEdit: false,
159
+ });
160
+ return;
161
+ }
162
+
163
+ if (route === "/deck") {
164
+ json(res, 200, { deckVersion: session.deckVersion, slides: session.slides });
165
+ return;
166
+ }
167
+
168
+ if (route === "/events") {
169
+ res.statusCode = 200;
170
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
171
+ res.setHeader("Cache-Control", "no-store");
172
+ res.setHeader("Connection", "keep-alive");
173
+ res.write("retry: 2000\n\n");
174
+ res.write(`data: ${session.version}\n\n`);
175
+ session.clients.add(res);
176
+ const heartbeat = setInterval(() => {
177
+ try {
178
+ res.write(": ping\n\n");
179
+ } catch (_) {
180
+ /* dropped client cleaned up on close */
181
+ }
182
+ }, 15000);
183
+ req.on("close", () => {
184
+ clearInterval(heartbeat);
185
+ session.clients.delete(res);
186
+ });
187
+ return;
188
+ }
189
+
190
+ if (route === "/navigate") {
191
+ if (req.method !== "POST") {
192
+ res.setHeader("Allow", "POST");
193
+ json(res, 405, { ok: false, error: "method_not_allowed" });
194
+ return;
195
+ }
196
+ if (!sameOrigin()) {
197
+ json(res, 403, { ok: false, error: "origin_not_allowed" });
198
+ return;
199
+ }
200
+ let body;
201
+ try {
202
+ body = await readJsonBody(req);
203
+ } catch (error) {
204
+ res.setHeader("Connection", "close");
205
+ json(res, error?.message === "payload_too_large" ? 413 : 400, {
206
+ ok: false,
207
+ error: error?.message || "bad_request",
208
+ });
209
+ return;
210
+ }
211
+ const hasIndex = typeof body.index === "number" && Number.isFinite(body.index);
212
+ const hasDelta = typeof body.delta === "number" && Number.isFinite(body.delta);
213
+ if (hasIndex === hasDelta) {
214
+ json(res, 400, {
215
+ ok: false,
216
+ error: "exactly one of index or delta is required",
217
+ });
218
+ return;
219
+ }
220
+ if (!session.slides.length) {
221
+ json(res, 409, { ok: false, error: "no_deck" });
222
+ return;
223
+ }
224
+ const changed = session.navigate(hasIndex ? body.index : session.index + body.delta);
225
+ if (changed) broadcast(session);
226
+ json(res, 200, {
227
+ ok: true,
228
+ changed,
229
+ version: session.version,
230
+ index: session.index,
231
+ total: session.slides.length,
232
+ mode: session.mode,
233
+ });
234
+ return;
235
+ }
236
+
237
+ // Print and capture renders read their frozen deck snapshot here.
238
+ if (route === "/export-data") {
239
+ const jobToken = requestUrl.searchParams.get("token") || "";
240
+ const job = session.exportJobs.get(jobToken);
241
+ if (!job) {
242
+ res.statusCode = 404;
243
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
244
+ res.end("Export snapshot not found");
245
+ return;
246
+ }
247
+ json(res, 200, {
248
+ slides: job.slides,
249
+ theme: job.theme,
250
+ themeLocked: job.themeLocked,
251
+ customThemeCss: job.customThemeCss,
252
+ customThemeMeta: job.customThemeMeta,
253
+ });
254
+ return;
255
+ }
256
+
257
+ if (route === "/export-status") {
258
+ if (req.method !== "POST") {
259
+ res.setHeader("Allow", "POST");
260
+ res.statusCode = 405;
261
+ res.end("Method not allowed");
262
+ return;
263
+ }
264
+ const jobToken = requestUrl.searchParams.get("token") || "";
265
+ const job = session.exportJobs.get(jobToken);
266
+ if (!job) {
267
+ res.statusCode = 404;
268
+ res.end("Export snapshot not found");
269
+ return;
270
+ }
271
+ let body;
272
+ try {
273
+ body = await readJsonBody(req, 256 * 1024);
274
+ } catch (error) {
275
+ res.statusCode = error?.message === "payload_too_large" ? 413 : 400;
276
+ res.end("Invalid export status");
277
+ return;
278
+ }
279
+ if (body.status !== "ready" && body.status !== "error") {
280
+ res.statusCode = 400;
281
+ res.end("Invalid export status");
282
+ return;
283
+ }
284
+ job.status = body.status;
285
+ job.error = typeof body.error === "string" ? body.error.slice(0, 2_000) : "";
286
+ job.layout =
287
+ body.layout && typeof body.layout === "object" && Array.isArray(body.layout.slides)
288
+ ? body.layout
289
+ : null;
290
+ res.statusCode = 204;
291
+ res.end();
292
+ return;
293
+ }
294
+
295
+ if (route === "/markdown-files") {
296
+ if (req.method !== "GET") {
297
+ res.setHeader("Allow", "GET");
298
+ json(res, 405, { ok: false, error: "method_not_allowed" });
299
+ return;
300
+ }
301
+ const { files, truncated } = await listMarkdownFiles(session.workspaceRoot);
302
+ json(res, 200, {
303
+ ok: true,
304
+ files,
305
+ truncated,
306
+ current: session.sourceName || "",
307
+ });
308
+ return;
309
+ }
310
+
311
+ // Loading another workspace Markdown file from the browser 📂 button.
312
+ if (route === "/import") {
313
+ if (req.method !== "POST") {
314
+ res.setHeader("Allow", "POST");
315
+ json(res, 405, { ok: false, error: "method_not_allowed" });
316
+ return;
317
+ }
318
+ if (!sameOrigin()) {
319
+ json(res, 403, { ok: false, error: "origin_not_allowed" });
320
+ return;
321
+ }
322
+ let body;
323
+ try {
324
+ body = await readJsonBody(req);
325
+ } catch (error) {
326
+ res.setHeader("Connection", "close");
327
+ json(res, 400, { ok: false, error: error?.message || "bad_request" });
328
+ return;
329
+ }
330
+ const requested = typeof body.path === "string" ? body.path.trim() : "";
331
+ if (!requested) {
332
+ json(res, 400, { ok: false, error: "path (string) is required" });
333
+ return;
334
+ }
335
+ const root = resolve(session.workspaceRoot);
336
+ const candidate = safeJoin(root, requested);
337
+ if (!candidate || !isPathInside(root, candidate) || !isMarkdownPath(candidate)) {
338
+ json(res, 400, { ok: false, error: "path_outside_workspace" });
339
+ return;
340
+ }
341
+ let canonical;
342
+ try {
343
+ canonical = await realpath(candidate);
344
+ const info = await stat(canonical);
345
+ if (!info.isFile() || !isPathInside(await realpath(root), canonical)) {
346
+ throw new Error("not_a_file");
347
+ }
348
+ } catch (_) {
349
+ json(res, 404, { ok: false, error: "file_not_found" });
350
+ return;
351
+ }
352
+ try {
353
+ session.file = canonical;
354
+ session.sourceName = relative(root, canonical).split(sep).join("/");
355
+ await session.load();
356
+ broadcast(session);
357
+ } catch (error) {
358
+ json(res, 400, { ok: false, error: error?.code || "import_failed" });
359
+ return;
360
+ }
361
+ json(res, 200, {
362
+ ok: true,
363
+ version: session.version,
364
+ index: session.index,
365
+ total: session.slides.length,
366
+ theme: session.theme,
367
+ sourceName: session.sourceName,
368
+ sourceMode: "snapshot",
369
+ sourceWatchStatus: session.watchStatus || "inactive",
370
+ });
371
+ return;
372
+ }
373
+
374
+ // Canvas-only routes. The CLI keeps them explicit so the browser reports an
375
+ // actionable message instead of a bare 404.
376
+ if (
377
+ route === "/present" ||
378
+ route === "/export" ||
379
+ route === "/edit" ||
380
+ route === "/edit-mode" ||
381
+ route === "/source-mode" ||
382
+ route === "/architecture-editor/open"
383
+ ) {
384
+ json(res, 501, {
385
+ ok: false,
386
+ error: "not_supported",
387
+ message:
388
+ "This action is available in the MarkdStage canvas. Use the markdstage CLI commands instead (for example: markdstage export).",
389
+ });
390
+ return;
391
+ }
392
+
393
+ if (route === "/vendor/mermaid.min.js") {
394
+ await sendChunkedVendorAsset(res, VENDOR_DIR, VENDOR_MANIFEST, "mermaid.min.js", (message) =>
395
+ onLog?.(message, "error"),
396
+ );
397
+ return;
398
+ }
399
+
400
+ if (route.startsWith("/renderer/") || route.startsWith("/vendor/")) {
401
+ const abs = safeJoin(EXT_DIR, route);
402
+ if (!abs) {
403
+ res.statusCode = 403;
404
+ res.end("Forbidden");
405
+ return;
406
+ }
407
+ await sendFile(res, abs, { cache: route.startsWith("/vendor/") });
408
+ return;
409
+ }
410
+
411
+ if (route.startsWith("/theme-assets/")) {
412
+ const assetPath = route.slice("/theme-assets/".length);
413
+ if (!session.customThemeDir || !session.customThemeAssets.has(assetPath)) {
414
+ res.statusCode = 404;
415
+ res.end("Theme asset not found");
416
+ return;
417
+ }
418
+ const themeRoot = resolve(session.workspaceRoot, session.customThemeDir);
419
+ const candidate = safeJoin(themeRoot, assetPath);
420
+ if (!candidate) {
421
+ res.statusCode = 403;
422
+ res.end("Forbidden");
423
+ return;
424
+ }
425
+ try {
426
+ const [realThemeRoot, realAsset, realWorkspaceRoot] = await Promise.all([
427
+ realpath(themeRoot),
428
+ realpath(candidate),
429
+ realpath(session.workspaceRoot),
430
+ ]);
431
+ if (
432
+ !isPathInside(realWorkspaceRoot, realThemeRoot) ||
433
+ !isPathInside(realThemeRoot, realAsset)
434
+ ) {
435
+ res.statusCode = 403;
436
+ res.end("Forbidden");
437
+ return;
438
+ }
439
+ await sendFile(res, realAsset, { cache: true });
440
+ } catch (_) {
441
+ res.statusCode = 404;
442
+ res.end("Theme asset not found");
443
+ }
444
+ return;
445
+ }
446
+
447
+ if (route.startsWith("/assets/")) {
448
+ try {
449
+ const abs = await resolveAssetFile(
450
+ session.workspaceRoot,
451
+ session.sourceName,
452
+ route.slice("/assets/".length),
453
+ );
454
+ if (!abs) {
455
+ res.statusCode = 404;
456
+ res.end("Asset not found");
457
+ return;
458
+ }
459
+ await sendFile(res, abs, { cache: true });
460
+ } catch (error) {
461
+ const forbidden = [
462
+ "invalid_asset_path",
463
+ "asset_source_outside_workspace",
464
+ "asset_root_outside_workspace",
465
+ "asset_outside_workspace",
466
+ ].includes(error?.code);
467
+ res.statusCode = forbidden ? 403 : 404;
468
+ res.end(forbidden ? "Forbidden" : "Asset not found");
469
+ }
470
+ return;
471
+ }
472
+
473
+ res.statusCode = 404;
474
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
475
+ res.end("Not found");
476
+ });
477
+
478
+ await new Promise((ready) => server.listen(0, "127.0.0.1", ready));
479
+ const address = server.address();
480
+ port = typeof address === "object" && address ? address.port : 0;
481
+ const url = `http://127.0.0.1:${port}${base}/`;
482
+ session.url = url;
483
+
484
+ return {
485
+ server,
486
+ url,
487
+ token,
488
+ port,
489
+ broadcast: () => broadcast(session),
490
+ close: () =>
491
+ new Promise((done) => {
492
+ for (const client of [...session.clients]) {
493
+ try {
494
+ client.end();
495
+ } catch (_) {
496
+ /* the client may already be gone */
497
+ }
498
+ }
499
+ session.clients.clear();
500
+ server.close(() => done());
501
+ server.closeAllConnections?.();
502
+ }),
503
+ };
504
+ }
505
+
@@ -0,0 +1,70 @@
1
+ // Static file helpers shared by the Canvas Extension presentation server and the
2
+ // MarkdStage CLI presentation server.
3
+
4
+ import { readFile } from "node:fs/promises";
5
+ import { extname, join, normalize, resolve, sep } from "node:path";
6
+ import { reconstructAsset } from "../scripts/vendor-assets.mjs";
7
+
8
+ export const MIME = {
9
+ ".html": "text/html; charset=utf-8",
10
+ ".css": "text/css; charset=utf-8",
11
+ ".js": "text/javascript; charset=utf-8",
12
+ ".mjs": "text/javascript; charset=utf-8",
13
+ ".json": "application/json; charset=utf-8",
14
+ ".svg": "image/svg+xml",
15
+ ".png": "image/png",
16
+ ".jpg": "image/jpeg",
17
+ ".jpeg": "image/jpeg",
18
+ ".gif": "image/gif",
19
+ ".webp": "image/webp",
20
+ ".avif": "image/avif",
21
+ ".ico": "image/x-icon",
22
+ ".bmp": "image/bmp",
23
+ };
24
+
25
+ export function mimeFor(path) {
26
+ return MIME[extname(path).toLowerCase()] || "application/octet-stream";
27
+ }
28
+
29
+ // Reject relative paths that escape rootDir to prevent path traversal.
30
+ export function safeJoin(rootDir, rel) {
31
+ const cleaned = rel.replace(/^[/\\]+/, "");
32
+ const abs = normalize(join(rootDir, cleaned));
33
+ const root = resolve(rootDir);
34
+ if (abs !== root && !abs.startsWith(root + sep)) return null;
35
+ return abs;
36
+ }
37
+
38
+ export async function sendFile(res, absPath, { cache } = {}) {
39
+ try {
40
+ const buf = await readFile(absPath);
41
+ res.statusCode = 200;
42
+ res.setHeader("Content-Type", mimeFor(absPath));
43
+ res.setHeader(
44
+ "Cache-Control",
45
+ cache ? "public, max-age=31536000, immutable" : "no-store",
46
+ );
47
+ res.end(buf);
48
+ } catch (_) {
49
+ res.statusCode = 404;
50
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
51
+ res.end("Not found");
52
+ }
53
+ }
54
+
55
+ export async function sendChunkedVendorAsset(res, vendorDir, vendorManifest, assetName, onError) {
56
+ try {
57
+ const buffer = await reconstructAsset(vendorDir, assetName, vendorManifest);
58
+ res.statusCode = 200;
59
+ res.setHeader("Content-Type", mimeFor(assetName));
60
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
61
+ res.end(buffer);
62
+ } catch (error) {
63
+ onError?.(
64
+ `MarkdStage: vendor asset integrity failure for ${assetName}: ${error.message}`,
65
+ );
66
+ res.statusCode = 500;
67
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
68
+ res.end("Vendor asset integrity failure");
69
+ }
70
+ }