@samva/vite 0.8.0 → 0.9.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.
@@ -0,0 +1,780 @@
1
+ import { E as projectPath, O as templateDirectories, T as isWithin, c as restoreBase, d as emailCompatibility, h as renderEmailFixtures, i as loadProject, k as isObject, m as renderChannelFixture, o as compileBase, v as createBrandResolver, w as isProjectFile } from "./project-Cp8qb3s0.js";
2
+ import { existsSync } from "node:fs";
3
+ import { open, readFile, realpath, rename, rm, stat } from "node:fs/promises";
4
+ import { isIP } from "node:net";
5
+ import { extname, isAbsolute, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { loadEnv } from "vite";
8
+ import { createHash, randomUUID } from "node:crypto";
9
+ import { renderIrPreview } from "@samva/markup/preview";
10
+ /** The refusal both the store and the dev-server route return for an oversized entry. */
11
+ const EDITOR_SOURCE_TOO_LARGE_MESSAGE = `Authored source exceeds the 1 MiB limit.`;
12
+ const hashSource = (source) => createHash("sha256").update(source, "utf8").digest("hex").slice(0, 16);
13
+ const deriveName = (path) => path.slice(path.lastIndexOf("/") + 1).replace(/\.tsx$/, "");
14
+ /** The document id for one channel of an entry. Email is the entry's own path. */
15
+ const documentId = (entryPath, channel) => channel === "email" ? entryPath : `${entryPath}#${channel}`;
16
+ const catalogFinding = (file, item) => ({
17
+ file,
18
+ severity: item.severity,
19
+ code: item.code,
20
+ message: item.message
21
+ });
22
+ /** A template's input contract is a JSON Schema, so its variables are that schema's top-level properties. */
23
+ const variablesOf = (schema) => {
24
+ const properties = schema["properties"];
25
+ if (!isObject(properties)) return [];
26
+ const required = schema["required"];
27
+ const names = Array.isArray(required) ? required : [];
28
+ return Object.entries(properties).map(([name, property]) => {
29
+ const examples = isObject(property) ? property["examples"] : void 0;
30
+ const example = Array.isArray(examples) ? examples[0] : void 0;
31
+ return {
32
+ name,
33
+ required: names.includes(name),
34
+ ...typeof example === "string" ? { sample: example } : {}
35
+ };
36
+ });
37
+ };
38
+ const chooseFixture = (fixtures, wanted) => wanted !== void 0 && fixtures.includes(wanted) ? wanted : fixtures[0] ?? null;
39
+ /** One preview render of the viewed fixture, or null when the renderer refuses it. */
40
+ const previewRender = (entry, fixture, revision, assetBase) => {
41
+ try {
42
+ const preview = renderIrPreview(entry.ir, entry.fixtures[fixture]);
43
+ return {
44
+ revision,
45
+ subject: preview.subject,
46
+ ...preview.preheader === void 0 ? {} : { preheader: preview.preheader },
47
+ html: restoreBase(preview.html, assetBase),
48
+ text: preview.text,
49
+ selections: preview.selections
50
+ };
51
+ } catch {
52
+ return null;
53
+ }
54
+ };
55
+ /** Everything one channel of one entry shows, computed once per compile. */
56
+ const buildChannel = (entry, channel, assetBase) => {
57
+ const fixtures = Object.keys(entry.fixtures);
58
+ if (channel === "email") {
59
+ const delivered = renderEmailFixtures(entry);
60
+ const diagnostics = [...entry.diagnostics, ...delivered.flatMap((result) => result.diagnostics)];
61
+ const incompatibilities = emailCompatibility(entry, delivered);
62
+ return {
63
+ identity: JSON.stringify({
64
+ diagnostics,
65
+ incompatibilities,
66
+ fixtures: delivered.map((result) => ({
67
+ fixture: result.fixture,
68
+ subject: result.rendered?.subject ?? null,
69
+ preheader: result.rendered?.preheader ?? null,
70
+ html: result.rendered?.html ?? null,
71
+ text: result.rendered?.text ?? null
72
+ }))
73
+ }),
74
+ content: (revision, wanted) => {
75
+ const fixture = chooseFixture(fixtures, wanted);
76
+ return {
77
+ channel: "email",
78
+ preview: { status: "current" },
79
+ fixtures,
80
+ fixture,
81
+ render: fixture === null ? null : previewRender(entry, fixture, revision, assetBase),
82
+ diagnostics,
83
+ incompatibilities
84
+ };
85
+ }
86
+ };
87
+ }
88
+ const rendered = fixtures.map((fixture) => ({
89
+ fixture,
90
+ result: renderChannelFixture(entry, channel, fixture)
91
+ }));
92
+ const diagnostics = [...entry.diagnostics, ...rendered.flatMap(({ result }) => result.ok ? [] : result.diagnostics)];
93
+ return {
94
+ identity: JSON.stringify({
95
+ diagnostics,
96
+ rendered
97
+ }),
98
+ content: (revision, wanted) => {
99
+ const fixture = chooseFixture(fixtures, wanted);
100
+ const result = rendered.find((item) => item.fixture === fixture)?.result;
101
+ return {
102
+ channel,
103
+ preview: { status: "current" },
104
+ fixtures,
105
+ fixture,
106
+ render: result?.ok !== true ? null : result.channel === "sms" ? {
107
+ revision,
108
+ text: result.text
109
+ } : {
110
+ revision,
111
+ ...result.message
112
+ },
113
+ diagnostics
114
+ };
115
+ }
116
+ };
117
+ };
118
+ /** Two builds of one document differ when what the editor shows differs. */
119
+ const moved = (before, entry) => {
120
+ if (before === void 0) return true;
121
+ if (before.rev !== entry.rev || before.authoredSource !== entry.authoredSource) return true;
122
+ return before.content.fixture !== entry.content.fixture;
123
+ };
124
+ var EditorFileStore = class {
125
+ projectOptions;
126
+ options;
127
+ root;
128
+ loaded = false;
129
+ mutationQueue = Promise.resolve();
130
+ serialize(operation) {
131
+ const running = this.mutationQueue.then(operation, operation);
132
+ this.mutationQueue = running.catch(() => void 0);
133
+ return running;
134
+ }
135
+ initialLoad;
136
+ project;
137
+ entries = /* @__PURE__ */ new Map();
138
+ catalogDiagnostics = [];
139
+ fixtures = /* @__PURE__ */ new Map();
140
+ listeners = /* @__PURE__ */ new Set();
141
+ catalogListeners = /* @__PURE__ */ new Set();
142
+ constructor(options = {}) {
143
+ this.options = options;
144
+ this.projectOptions = {
145
+ ...options,
146
+ assetBase: compileBase(options.assetBase)
147
+ };
148
+ this.root = resolve(options.root ?? process.cwd());
149
+ }
150
+ /** The bytes of an imported asset from the latest compile, by its content-addressed file name. */
151
+ asset(fileName) {
152
+ const project = this.project;
153
+ const entry = project?.catalog.assets.find((asset) => asset.fileName === fileName);
154
+ const bytes = entry === void 0 ? void 0 : project?.files[entry.path];
155
+ return entry === void 0 || bytes === void 0 || typeof bytes === "string" ? void 0 : {
156
+ contentType: entry.contentType,
157
+ bytes
158
+ };
159
+ }
160
+ /** The directories the store scans, for a host that watches them. */
161
+ directories() {
162
+ return templateDirectories(this.root, this.projectOptions.dir);
163
+ }
164
+ async resolvePath(entryPath) {
165
+ if (!entryPath.endsWith(".tsx") || entryPath.includes("\0")) return void 0;
166
+ const full = resolve(this.root, entryPath);
167
+ const dirs = await this.directories();
168
+ if (!dirs.some((dir) => isWithin(full, dir))) return void 0;
169
+ const real = await realpath(full).catch(() => void 0);
170
+ if (real === void 0) return void 0;
171
+ return (await Promise.all(dirs.map((dir) => realpath(dir).catch(() => void 0)))).some((dir) => dir !== void 0 && isWithin(real, dir)) ? real : void 0;
172
+ }
173
+ /** The entry path of an absolute file path, when a template entry could live there. */
174
+ entryPathFor(path) {
175
+ const full = isAbsolute(path) ? path : join(this.root, path);
176
+ const relative = projectPath(this.root, full);
177
+ if (!relative.endsWith(".tsx") || relative.startsWith("..")) return void 0;
178
+ return this.project?.catalog.templates.some((entry) => entry.entryPath === relative) === true || this.project?.catalog.failures.some((entry) => entry.entryPath === relative) === true ? relative : void 0;
179
+ }
180
+ build(entry, channel, authoredSource, id, fixture) {
181
+ const built = buildChannel(entry, channel, this.options.assetBase);
182
+ const rev = hashSource(authoredSource + built.identity);
183
+ const content = built.content(rev, fixture);
184
+ return {
185
+ template: {
186
+ id,
187
+ name: deriveName(entry.entryPath),
188
+ channel,
189
+ sourceKind: "tsx"
190
+ },
191
+ file: entry.entryPath,
192
+ content,
193
+ authoredSource,
194
+ rev,
195
+ variables: variablesOf(entry.ir.schema),
196
+ compiled: entry
197
+ };
198
+ }
199
+ catalogSnapshot() {
200
+ return {
201
+ templates: [...this.entries.values()].map((entry) => entry.template).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
202
+ diagnostics: [...this.catalogDiagnostics]
203
+ };
204
+ }
205
+ ensureLoaded() {
206
+ if (this.loaded) return Promise.resolve(this.catalogSnapshot());
207
+ if (this.initialLoad !== void 0) return this.initialLoad;
208
+ const loading = this.rebuild(false);
209
+ this.initialLoad = loading;
210
+ loading.then(() => {
211
+ if (this.initialLoad === loading) this.initialLoad = void 0;
212
+ }, () => {
213
+ if (this.initialLoad === loading) this.initialLoad = void 0;
214
+ });
215
+ return loading;
216
+ }
217
+ async rebuild(emitDocuments) {
218
+ const previous = new Map(this.entries);
219
+ const project = await loadProject(this.projectOptions, this.options.brands);
220
+ this.project = project;
221
+ this.options.onProject?.(project);
222
+ this.entries.clear();
223
+ this.catalogDiagnostics.length = 0;
224
+ this.catalogDiagnostics.push(...project.diagnostics.map((item) => catalogFinding("", item)));
225
+ for (const compiled of project.catalog.templates) {
226
+ const source = project.files[compiled.entryPath];
227
+ const authoredSource = typeof source === "string" ? source : "";
228
+ for (const channel of compiled.channels) {
229
+ const id = documentId(compiled.entryPath, channel);
230
+ this.entries.set(id, this.build(compiled, channel, authoredSource, id, this.fixtures.get(id)));
231
+ }
232
+ }
233
+ for (const failed of project.catalog.failures) {
234
+ const source = project.files[failed.entryPath];
235
+ const authoredSource = typeof source === "string" ? source : "";
236
+ this.catalogDiagnostics.push(...failed.diagnostics.map((item) => catalogFinding(failed.entryPath, item)));
237
+ for (const [id, before] of previous) {
238
+ if (before.file !== failed.entryPath) continue;
239
+ this.entries.set(id, {
240
+ ...before,
241
+ content: {
242
+ ...before.content,
243
+ fixtures: [],
244
+ fixture: null,
245
+ render: null,
246
+ diagnostics: failed.diagnostics
247
+ },
248
+ authoredSource,
249
+ rev: hashSource(authoredSource + JSON.stringify(failed.diagnostics)),
250
+ variables: [],
251
+ compiled: void 0
252
+ });
253
+ }
254
+ }
255
+ this.loaded = true;
256
+ if (emitDocuments) {
257
+ for (const [id, entry] of this.entries) if (moved(previous.get(id), entry)) this.publishEntry(id, entry);
258
+ }
259
+ for (const listener of this.catalogListeners) listener();
260
+ return this.catalogSnapshot();
261
+ }
262
+ async catalog() {
263
+ return this.ensureLoaded();
264
+ }
265
+ /**
266
+ * Recompile the project after a template, partial, theme or asset changed.
267
+ * `changed` names the file whose change asked for this refresh.
268
+ *
269
+ * This store writes template files itself, so an editor save comes back as a
270
+ * change event indistinguishable from an author's own edit in another program.
271
+ * Recompiling for it would republish the document with no client attached,
272
+ * which is exactly how a foreign edit reaches the editor. A changed file that
273
+ * still holds the bytes the current compile read is already built, so there is
274
+ * nothing to publish and nothing to rebuild. Every other path is a real change.
275
+ */
276
+ async refreshAll(options = {}) {
277
+ const loading = this.initialLoad;
278
+ if (loading !== void 0) await loading;
279
+ else if (!this.loaded) return this.ensureLoaded();
280
+ return this.serialize(async () => await this.alreadyBuilt(options.changed) ? this.catalogSnapshot() : this.rebuild(options.documents ?? true));
281
+ }
282
+ /** True when `path` still holds the exact bytes the latest compile read from it. */
283
+ async alreadyBuilt(path) {
284
+ if (path === void 0 || this.project === void 0) return false;
285
+ const full = isAbsolute(path) ? path : join(this.root, path);
286
+ const found = this.project.files[projectPath(this.root, full)];
287
+ if (found === void 0) return false;
288
+ const known = typeof found === "string" ? Buffer.from(found, "utf8") : found;
289
+ const disk = await readFile(full).catch(() => void 0);
290
+ if (disk === void 0) return false;
291
+ return Buffer.from(known).equals(disk);
292
+ }
293
+ async list() {
294
+ return (await this.catalog()).templates;
295
+ }
296
+ async open(id) {
297
+ if (!this.loaded) await this.ensureLoaded();
298
+ const entry = this.entries.get(id);
299
+ if (entry === void 0) return void 0;
300
+ return {
301
+ id: null,
302
+ name: entry.template.name,
303
+ rev: entry.rev,
304
+ variables: entry.variables,
305
+ metadata: {},
306
+ ...entry.content,
307
+ origin: {
308
+ kind: "tsx",
309
+ file: entry.file,
310
+ authoredSource: entry.authoredSource
311
+ },
312
+ access: { kind: "editable" }
313
+ };
314
+ }
315
+ /**
316
+ * Serialize editor saves and atomically replace the complete entry after a fresh disk check.
317
+ * Arbitrary external writers do not participate in this queue: the final check and rename
318
+ * are not a filesystem compare-and-swap, so authors must resolve overlapping saves externally.
319
+ */
320
+ async saveSource(id, baseRev, authoredSource, client) {
321
+ if (Buffer.byteLength(authoredSource, "utf8") > 1048576) return {
322
+ ok: false,
323
+ kind: "refused",
324
+ error: EDITOR_SOURCE_TOO_LARGE_MESSAGE
325
+ };
326
+ await this.ensureLoaded();
327
+ return this.serialize(async () => {
328
+ const entry = this.entries.get(id);
329
+ const path = entry === void 0 ? void 0 : await this.resolvePath(entry.file);
330
+ if (entry === void 0 || path === void 0) return {
331
+ ok: false,
332
+ kind: "missing",
333
+ error: "Template not found."
334
+ };
335
+ const disk = await readFile(path, "utf8");
336
+ if (entry.rev !== baseRev || disk !== entry.authoredSource) {
337
+ await this.rebuild(true);
338
+ return {
339
+ ok: false,
340
+ kind: "conflict",
341
+ error: "Source changed on disk. Reload before saving.",
342
+ actualRev: this.entries.get(id)?.rev ?? entry.rev
343
+ };
344
+ }
345
+ const temporary = `${path}.${randomUUID()}.tmp`;
346
+ const mode = (await stat(path)).mode;
347
+ const file = await open(temporary, "wx", mode);
348
+ try {
349
+ await file.writeFile(authoredSource, "utf8");
350
+ await file.chmod(mode);
351
+ await file.sync();
352
+ await file.close();
353
+ if (await readFile(path, "utf8") !== disk) {
354
+ await this.rebuild(true);
355
+ return {
356
+ ok: false,
357
+ kind: "conflict",
358
+ error: "Source changed on disk. Reload before saving.",
359
+ actualRev: this.entries.get(id)?.rev ?? entry.rev
360
+ };
361
+ }
362
+ await rename(temporary, path);
363
+ } finally {
364
+ await file.close().finally(() => rm(temporary, { force: true }));
365
+ }
366
+ const previous = new Map(this.entries);
367
+ await this.rebuild(false);
368
+ for (const [changedId, changed] of this.entries) if (moved(previous.get(changedId), changed)) this.publishEntry(changedId, changed, changedId === id ? client : void 0);
369
+ const saved = this.entries.get(id);
370
+ return saved === void 0 ? {
371
+ ok: false,
372
+ kind: "missing",
373
+ error: "The saved entry is no longer discoverable."
374
+ } : {
375
+ ok: true,
376
+ rev: saved.rev
377
+ };
378
+ });
379
+ }
380
+ /**
381
+ * Show another declared fixture of a document. It re-renders the same
382
+ * compile, so the revision does not move; the resulting document reaches every
383
+ * watcher on the change stream, attributed to the client that asked.
384
+ *
385
+ * It runs in the mutation queue for the same reason a save does: the viewed
386
+ * fixture is part of the entry, and a rebuild that straddled the switch would
387
+ * take its own snapshot from before it and its render from after it, then
388
+ * publish the difference with no client.
389
+ */
390
+ async viewFixture(id, fixture, client) {
391
+ if (!this.loaded) await this.ensureLoaded();
392
+ return this.serialize(async () => {
393
+ const entry = this.entries.get(id);
394
+ if (entry === void 0) return {
395
+ ok: false,
396
+ kind: "missing",
397
+ error: "template not found"
398
+ };
399
+ if (!entry.content.fixtures.includes(fixture) || entry.compiled === void 0) return {
400
+ ok: false,
401
+ kind: "refused",
402
+ error: `Template declares no fixture named ${JSON.stringify(fixture)}.`
403
+ };
404
+ const next = this.build(entry.compiled, entry.template.channel, entry.authoredSource, id, fixture);
405
+ this.fixtures.set(id, fixture);
406
+ this.entries.set(id, next);
407
+ this.publishEntry(id, next, client);
408
+ return { ok: true };
409
+ });
410
+ }
411
+ publishEntry(id, entry, client) {
412
+ const change = {
413
+ id,
414
+ rev: entry.rev,
415
+ authoredSource: entry.authoredSource,
416
+ ...client === void 0 ? {} : { sourceClient: client },
417
+ ...entry.content
418
+ };
419
+ for (const listener of this.listeners) listener(change);
420
+ }
421
+ subscribe(listener) {
422
+ this.listeners.add(listener);
423
+ return () => this.listeners.delete(listener);
424
+ }
425
+ subscribeCatalog(listener) {
426
+ this.catalogListeners.add(listener);
427
+ return () => this.catalogListeners.delete(listener);
428
+ }
429
+ };
430
+ //#endregion
431
+ //#region src/editor.ts
432
+ const DEFAULT_ROUTE = "/";
433
+ const packagedEditorDir = fileURLToPath(new URL("./editor/", import.meta.url));
434
+ const EDITOR_DIR = existsSync(packagedEditorDir) ? packagedEditorDir : fileURLToPath(new URL("../dist/editor/", import.meta.url));
435
+ const MIME = {
436
+ ".html": "text/html; charset=utf-8",
437
+ ".js": "text/javascript; charset=utf-8",
438
+ ".css": "text/css; charset=utf-8",
439
+ ".svg": "image/svg+xml",
440
+ ".json": "application/json",
441
+ ".map": "application/json",
442
+ ".ico": "image/x-icon",
443
+ ".png": "image/png",
444
+ ".woff2": "font/woff2"
445
+ };
446
+ /** Normalize + validate the mount route: absolute, root allowed, no trailing slash. */
447
+ const normalizeRoute = (raw) => {
448
+ const route = raw === "/" ? "/" : raw.replace(/\/+$/, "");
449
+ if (!route.startsWith("/") || route === "" || route.includes("//") || route.includes("?") || route.includes("#")) throw new Error(`samvaEditor: \`route\` must be an absolute URL path (received ${JSON.stringify(raw)})`);
450
+ return route;
451
+ };
452
+ const sendJson = (res, status, body) => {
453
+ res.statusCode = status;
454
+ res.setHeader("content-type", "application/json");
455
+ res.end(JSON.stringify(body));
456
+ };
457
+ /** Serve a file under the built editor dir; returns false (untouched res) if absent/outside. */
458
+ const serveStatic = async (res, filePath) => {
459
+ const resolved = resolve(filePath);
460
+ const root = EDITOR_DIR.replace(/\/$/, "");
461
+ if (resolved !== root && !resolved.startsWith(`${root}/`)) return false;
462
+ const body = await readFile(resolved).catch(() => void 0);
463
+ if (body === void 0) return false;
464
+ res.statusCode = 200;
465
+ res.setHeader("content-type", MIME[extname(resolved)] ?? "application/octet-stream");
466
+ res.end(body);
467
+ return true;
468
+ };
469
+ const VITE_INTERNAL_PREFIXES = [
470
+ "/@vite/",
471
+ "/@id/",
472
+ "/@fs/",
473
+ "/@react-refresh",
474
+ "/__vite",
475
+ "/node_modules/.vite/"
476
+ ];
477
+ const isViteInternalPath = (pathname) => VITE_INTERNAL_PREFIXES.some((prefix) => pathname === prefix.replace(/\/$/, "") || pathname.startsWith(prefix));
478
+ const hasFileExtension = (pathname) => extname(pathname) !== "";
479
+ const underRoute = (route, pathname) => route === "/" || pathname === route || pathname.startsWith(`${route}/`);
480
+ const childRoute = (route, child) => route === "/" ? `/${child}` : `${route}/${child}`;
481
+ const isLoopbackHostname = (hostname) => {
482
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
483
+ if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::1") return true;
484
+ if (isIP(normalized) !== 4) return false;
485
+ return normalized.split(".")[0] === "127";
486
+ };
487
+ const isLoopbackAddress = (address) => {
488
+ if (address === void 0) return false;
489
+ const normalized = address.startsWith("::ffff:") ? address.slice(7) : address;
490
+ return isLoopbackHostname(normalized);
491
+ };
492
+ const isTrustedSourceSave = (req) => {
493
+ if (!isLoopbackAddress(req.socket.remoteAddress)) return false;
494
+ const origin = req.headers.origin;
495
+ if (origin === void 0) return false;
496
+ try {
497
+ const parsed = new URL(origin);
498
+ return (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.username === "" && parsed.password === "" && isLoopbackHostname(parsed.hostname);
499
+ } catch {
500
+ return false;
501
+ }
502
+ };
503
+ const MAX_BODY_BYTES = 6295552;
504
+ var EditorBodyTooLarge = class extends Error {};
505
+ const readBody = (req) => new Promise((done, fail) => {
506
+ const chunks = [];
507
+ let bytes = 0;
508
+ req.on("data", (chunk) => {
509
+ bytes += chunk.length;
510
+ if (bytes > MAX_BODY_BYTES) {
511
+ chunks.length = 0;
512
+ fail(new EditorBodyTooLarge("Request body exceeds the editor limit."));
513
+ return;
514
+ }
515
+ chunks.push(chunk);
516
+ });
517
+ req.on("end", () => done(Buffer.concat(chunks).toString("utf8")));
518
+ req.on("error", fail);
519
+ req.on("aborted", () => fail(/* @__PURE__ */ new Error("Request body aborted.")));
520
+ });
521
+ const createSseClient = (res, docId, clientId) => {
522
+ let pending;
523
+ let draining = false;
524
+ const flush = () => {
525
+ if (pending === void 0) {
526
+ draining = false;
527
+ return;
528
+ }
529
+ const frame = pending;
530
+ pending = void 0;
531
+ if (res.write(frame)) flush();
532
+ else draining = true;
533
+ };
534
+ res.on("drain", flush);
535
+ return {
536
+ res,
537
+ docId,
538
+ clientId,
539
+ push(payload) {
540
+ const frame = `event: samva:change\ndata: ${JSON.stringify(payload)}\n\n`;
541
+ if (draining) {
542
+ pending = frame;
543
+ return;
544
+ }
545
+ if (!res.write(frame)) draining = true;
546
+ }
547
+ };
548
+ };
549
+ const samvaEditor = (options = {}) => {
550
+ const route = normalizeRoute(options.route ?? DEFAULT_ROUTE);
551
+ const apiPrefix = childRoute(route, "api");
552
+ let root = process.cwd();
553
+ let store = new EditorFileStore({ root });
554
+ let authenticated = false;
555
+ let env = {};
556
+ const clients = /* @__PURE__ */ new Set();
557
+ const catalogClients = /* @__PURE__ */ new Set();
558
+ let refreshTail = Promise.resolve();
559
+ const queueStoreRefresh = (server, run) => {
560
+ refreshTail = refreshTail.then(run, run).catch((cause) => {
561
+ server.config.logger.error(`samvaEditor refresh failed: ${cause instanceof Error ? cause.message : String(cause)}`);
562
+ });
563
+ return refreshTail;
564
+ };
565
+ return {
566
+ name: "samva-editor",
567
+ config(user) {
568
+ return user.server?.host === void 0 ? { server: {
569
+ host: "127.0.0.1",
570
+ allowedHosts: ["localhost", ".localhost"]
571
+ } } : {};
572
+ },
573
+ configResolved(resolved) {
574
+ root = resolve(resolved.root, options.projectRoot ?? ".");
575
+ env = loadEnv(resolved.mode, root, "");
576
+ authenticated = Boolean(env.SAMVA_API_KEY);
577
+ },
578
+ async configureServer(server) {
579
+ const brands = createBrandResolver({
580
+ root,
581
+ env
582
+ });
583
+ let brandReport = "";
584
+ const reportBrand = (resolution) => {
585
+ const text = resolution.ok ? resolution.warnings.map((warning) => warning.message).join("\n") : resolution.message;
586
+ if (text === brandReport) return;
587
+ brandReport = text;
588
+ if (text === "") return;
589
+ if (resolution.ok) server.config.logger.warn(`samvaEditor: ${text}`);
590
+ else server.config.logger.error(`samvaEditor: ${text}`);
591
+ };
592
+ store = new EditorFileStore({
593
+ root,
594
+ dir: options.templatesDir,
595
+ theme: options.theme,
596
+ assetBase: childRoute(apiPrefix, "assets"),
597
+ brands,
598
+ onProject: (project) => reportBrand(project.brand)
599
+ });
600
+ server.watcher.add(root);
601
+ let burst = /* @__PURE__ */ new Set();
602
+ let burstQueued = false;
603
+ const relevant = (path) => {
604
+ const relative = projectPath(root, path);
605
+ if (relative.startsWith("..") || relative === ".samva") return false;
606
+ if (relative.startsWith(`.samva/`)) return false;
607
+ if (relative.split("/").includes("node_modules")) return false;
608
+ return isProjectFile(relative);
609
+ };
610
+ const onFileEvent = (path) => {
611
+ if (!relevant(path)) return;
612
+ burst.add(path);
613
+ if (burstQueued) return;
614
+ burstQueued = true;
615
+ queueStoreRefresh(server, async () => {
616
+ const paths = burst;
617
+ burst = /* @__PURE__ */ new Set();
618
+ burstQueued = false;
619
+ const only = paths.size === 1 ? [...paths][0] : void 0;
620
+ return store.refreshAll({
621
+ documents: true,
622
+ changed: only
623
+ });
624
+ });
625
+ };
626
+ server.watcher.on("change", onFileEvent);
627
+ server.watcher.on("add", onFileEvent);
628
+ server.watcher.on("unlink", onFileEvent);
629
+ const unsubscribe = store.subscribe((change) => {
630
+ const { sourceClient, ...document } = change;
631
+ server.ws.send({
632
+ type: "custom",
633
+ event: "samva:document",
634
+ data: document
635
+ });
636
+ for (const client of clients) {
637
+ if (client.docId !== change.id) continue;
638
+ const origin = sourceClient !== void 0 && sourceClient === client.clientId ? "self" : "external";
639
+ client.push({
640
+ ...document,
641
+ origin
642
+ });
643
+ }
644
+ });
645
+ const unsubscribeCatalog = store.subscribeCatalog(() => {
646
+ for (const res of catalogClients) res.write("event: samva:catalog\ndata: refresh\n\n");
647
+ server.ws.send({
648
+ type: "custom",
649
+ event: "samva:catalog"
650
+ });
651
+ });
652
+ let cleaned = false;
653
+ const cleanup = () => {
654
+ if (cleaned) return;
655
+ cleaned = true;
656
+ server.watcher.off("change", onFileEvent);
657
+ server.watcher.off("add", onFileEvent);
658
+ server.watcher.off("unlink", onFileEvent);
659
+ server.watcher.off("close", cleanup);
660
+ server.httpServer?.off("close", cleanup);
661
+ unsubscribe();
662
+ unsubscribeCatalog();
663
+ for (const client of clients) client.res.end();
664
+ clients.clear();
665
+ for (const res of catalogClients) res.end();
666
+ catalogClients.clear();
667
+ };
668
+ server.httpServer?.once("close", cleanup);
669
+ server.watcher.once("close", cleanup);
670
+ const handler = (req, res, next) => {
671
+ const url = req.url ?? "";
672
+ const pathname = url.split("?")[0] || "/";
673
+ const query = new URLSearchParams(url.split("?")[1] ?? "");
674
+ const apiRequest = pathname === apiPrefix || pathname.startsWith(`${apiPrefix}/`);
675
+ if (!apiRequest && !underRoute(route, pathname)) return next();
676
+ if (!apiRequest && route === "/" && isViteInternalPath(pathname)) return next();
677
+ (async () => {
678
+ if (pathname === `${apiPrefix}/capabilities`) return sendJson(res, 200, { authenticated });
679
+ if (pathname === `${apiPrefix}/templates`) {
680
+ const catalog = await store.catalog();
681
+ return sendJson(res, 200, {
682
+ templates: catalog.templates,
683
+ diagnostics: catalog.diagnostics
684
+ });
685
+ }
686
+ if (pathname === `${apiPrefix}/document`) {
687
+ const id = query.get("id");
688
+ if (!id) return sendJson(res, 400, { error: "missing id" });
689
+ if (req.method === "POST") {
690
+ if (!isTrustedSourceSave(req)) return sendJson(res, 403, { error: "Source saves require the editor origin." });
691
+ let body;
692
+ try {
693
+ body = JSON.parse(await readBody(req));
694
+ } catch (error) {
695
+ if (error instanceof EditorBodyTooLarge) return sendJson(res, 413, { error: error.message });
696
+ return sendJson(res, 400, { error: "malformed JSON body" });
697
+ }
698
+ if (!isObject(body) || body["kind"] !== "authoredSource" || typeof body["baseRev"] !== "string" || typeof body["authoredSource"] !== "string") return sendJson(res, 400, { error: "Expected a revision-bound authored source update." });
699
+ if (Buffer.byteLength(body["authoredSource"], "utf8") > 1048576) return sendJson(res, 413, { error: EDITOR_SOURCE_TOO_LARGE_MESSAGE });
700
+ const outcome = await store.saveSource(id, body["baseRev"], body["authoredSource"], query.get("client") ?? void 0);
701
+ if (outcome.ok) return sendJson(res, 200, { rev: outcome.rev });
702
+ return sendJson(res, outcome.kind === "conflict" ? 409 : outcome.kind === "missing" ? 404 : 422, outcome);
703
+ }
704
+ if (req.method !== "GET") return sendJson(res, 405, { error: "Use GET or POST." });
705
+ const snapshot = await store.open(id);
706
+ if (snapshot === void 0) return sendJson(res, 404, { error: "template not found" });
707
+ return sendJson(res, 200, snapshot);
708
+ }
709
+ if (pathname === `${apiPrefix}/fixture`) {
710
+ const id = query.get("id");
711
+ if (!id) return sendJson(res, 400, { error: "missing id" });
712
+ if (req.method !== "POST") return sendJson(res, 405, { error: "viewing a fixture is a POST" });
713
+ const raw = await readBody(req);
714
+ let body;
715
+ try {
716
+ body = JSON.parse(raw);
717
+ } catch {
718
+ return sendJson(res, 400, { error: "malformed JSON body" });
719
+ }
720
+ if (!isObject(body) || typeof body["fixture"] !== "string") return sendJson(res, 400, { error: "malformed JSON body" });
721
+ const outcome = await store.viewFixture(id, body["fixture"], query.get("client") ?? void 0);
722
+ if (outcome.ok) return sendJson(res, 200, {});
723
+ return sendJson(res, outcome.kind === "missing" ? 404 : 422, { error: outcome.error });
724
+ }
725
+ if (pathname.startsWith(`${apiPrefix}/assets/`)) {
726
+ const asset = store.asset(pathname.slice(`${apiPrefix}/assets/`.length));
727
+ if (asset === void 0) return sendJson(res, 404, { error: "asset not found" });
728
+ res.statusCode = 200;
729
+ res.setHeader("content-type", asset.contentType);
730
+ res.end(Buffer.from(asset.bytes));
731
+ return;
732
+ }
733
+ if (pathname === `${apiPrefix}/events`) {
734
+ const id = query.get("id");
735
+ if (!id) return sendJson(res, 400, { error: "missing id" });
736
+ res.statusCode = 200;
737
+ res.setHeader("content-type", "text/event-stream");
738
+ res.setHeader("cache-control", "no-cache");
739
+ res.setHeader("connection", "keep-alive");
740
+ res.write(": connected\n\n");
741
+ const client = createSseClient(res, id, query.get("client") ?? void 0);
742
+ clients.add(client);
743
+ req.on("close", () => clients.delete(client));
744
+ return;
745
+ }
746
+ if (pathname === `${apiPrefix}/catalog-events`) {
747
+ res.statusCode = 200;
748
+ res.setHeader("content-type", "text/event-stream");
749
+ res.setHeader("cache-control", "no-cache");
750
+ res.setHeader("connection", "keep-alive");
751
+ res.write(": connected\n\n");
752
+ catalogClients.add(res);
753
+ req.on("close", () => catalogClients.delete(res));
754
+ return;
755
+ }
756
+ if (apiRequest) return sendJson(res, 404, { error: "not found" });
757
+ if (route !== "/" && pathname === route) {
758
+ res.statusCode = 302;
759
+ res.setHeader("location", `${route}/`);
760
+ res.end();
761
+ return;
762
+ }
763
+ const rel = route === "/" ? pathname.replace(/^\//, "") : pathname.slice(route.length).replace(/^\//, "");
764
+ if (await serveStatic(res, join(EDITOR_DIR, rel === "" ? "index.html" : rel))) return;
765
+ const assetPrefix = childRoute(route, "assets");
766
+ if (hasFileExtension(pathname) || pathname === assetPrefix || pathname.startsWith(`${assetPrefix}/`)) return next();
767
+ if (await serveStatic(res, join(EDITOR_DIR, "index.html"))) return;
768
+ return next();
769
+ })().catch((error) => {
770
+ sendJson(res, error instanceof EditorBodyTooLarge ? 413 : 500, { error: error instanceof Error ? error.message : String(error) });
771
+ });
772
+ };
773
+ server.middlewares.use(handler);
774
+ }
775
+ };
776
+ };
777
+ //#endregion
778
+ export { samvaEditor as t };
779
+
780
+ //# sourceMappingURL=editor-Cow7zZ_9.js.map