fundus 0.0.2

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 (61) hide show
  1. package/dist/build-CZYFLLvl.js +191 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +1420 -0
  4. package/dist/config.d.ts +2 -0
  5. package/dist/config.js +2 -0
  6. package/dist/core/image-Dqmz8FXS.js +54 -0
  7. package/dist/core/index.d.ts +499 -0
  8. package/dist/core/index.js +962 -0
  9. package/dist/core/reference-graph-CjuHbLDw.d.ts +280 -0
  10. package/dist/core/testing.d.ts +27 -0
  11. package/dist/core/testing.js +80 -0
  12. package/dist/editor/200.html +37 -0
  13. package/dist/editor/_app/immutable/assets/0.DLhIsYA_.css +1 -0
  14. package/dist/editor/_app/immutable/assets/2.DIbbW7U1.css +1 -0
  15. package/dist/editor/_app/immutable/assets/3.C-9vFQ9A.css +1 -0
  16. package/dist/editor/_app/immutable/assets/4.BS8ElpOV.css +1 -0
  17. package/dist/editor/_app/immutable/assets/ConfirmDialog.CHEISE-L.css +1 -0
  18. package/dist/editor/_app/immutable/assets/LibraryContextMenu.CDjCmdV-.css +1 -0
  19. package/dist/editor/_app/immutable/chunks/BfPE5wdt.js +1 -0
  20. package/dist/editor/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  21. package/dist/editor/_app/immutable/chunks/CNRz3sSw.js +3 -0
  22. package/dist/editor/_app/immutable/chunks/CO4RBJfa.js +1 -0
  23. package/dist/editor/_app/immutable/chunks/Cx6qtxJg.js +1 -0
  24. package/dist/editor/_app/immutable/chunks/DjdrdRzT.js +1 -0
  25. package/dist/editor/_app/immutable/chunks/DjvOiDmq.js +1 -0
  26. package/dist/editor/_app/immutable/chunks/n7mvYc42.js +5 -0
  27. package/dist/editor/_app/immutable/chunks/xihTtKlq.js +1 -0
  28. package/dist/editor/_app/immutable/entry/app.BkqbfI8n.js +2 -0
  29. package/dist/editor/_app/immutable/entry/start.C8HHOLly.js +1 -0
  30. package/dist/editor/_app/immutable/nodes/0.xdMI5GEU.js +2 -0
  31. package/dist/editor/_app/immutable/nodes/1.D9p2tfoJ.js +1 -0
  32. package/dist/editor/_app/immutable/nodes/2.BrA0Hyzu.js +3 -0
  33. package/dist/editor/_app/immutable/nodes/3.WO7tZH_d.js +104 -0
  34. package/dist/editor/_app/immutable/nodes/4.DQtx17aW.js +2 -0
  35. package/dist/editor/_app/version.json +1 -0
  36. package/dist/index.d.ts +370 -0
  37. package/dist/index.js +4 -0
  38. package/dist/operations-DYM-5KVl.js +1797 -0
  39. package/dist/runtime/chroma-key-gl.d.ts +23 -0
  40. package/dist/runtime/chroma-key-gl.js +290 -0
  41. package/dist/runtime/components/ChromaKeyVideo.svelte +225 -0
  42. package/dist/runtime/components/Image.svelte +18 -0
  43. package/dist/runtime/components/Slice.svelte +35 -0
  44. package/dist/runtime/components/SliceCanvas.svelte +162 -0
  45. package/dist/runtime/components/SliceDom.svelte +104 -0
  46. package/dist/runtime/components/Video.svelte +60 -0
  47. package/dist/runtime/geometry.d.ts +75 -0
  48. package/dist/runtime/geometry.js +209 -0
  49. package/dist/runtime/index.d.ts +9 -0
  50. package/dist/runtime/index.js +9 -0
  51. package/dist/runtime/kind-handlers.d.ts +46 -0
  52. package/dist/runtime/kind-handlers.js +86 -0
  53. package/dist/runtime/manifest.d.ts +60 -0
  54. package/dist/runtime/manifest.js +117 -0
  55. package/dist/runtime/observe-border-box-size.d.ts +5 -0
  56. package/dist/runtime/observe-border-box-size.js +12 -0
  57. package/dist/runtime/preload-registry.d.ts +26 -0
  58. package/dist/runtime/preload-registry.js +68 -0
  59. package/dist/start-Bhnqt9Dc.js +279 -0
  60. package/dist/start-D2IXOrni.js +2 -0
  61. package/package.json +65 -0
@@ -0,0 +1,86 @@
1
+ import { resolveRetainedSource } from "./preload-registry.js";
2
+ //#region src/kind-handlers.ts
3
+ /** The playable URL hosts feed to their own audio engine. */
4
+ function audioSourceOf(entry) {
5
+ return resolveRetainedSource(entry.src);
6
+ }
7
+ /** Entry kinds whose delivered file is an image the browser can pre-decode. */
8
+ const IMAGE_BACKED_KINDS = new Set(["image", "slice"]);
9
+ /**
10
+ * Entry kinds whose delivered file is retained as an object URL by
11
+ * `preload()` while held. A chroma-key-video's own `src` is a video file, so
12
+ * it retains too; its inlined mask/fallback entries are `image` kind and
13
+ * follow the image handler via the source walk.
14
+ */
15
+ const RETAINED_KINDS = new Set([
16
+ "video",
17
+ "chroma-key-video",
18
+ "audio"
19
+ ]);
20
+ /**
21
+ * Whether a source of `kind` decodes under the given per-kind preload options
22
+ * (options are namespaced per plugin type, like `plugins.<type>` in
23
+ * the config). Image-backed kinds decode by default; each kind opts out
24
+ * independently (`{ image: { decode: false } }` leaves slices decoding).
25
+ */
26
+ function shouldDecode(kind, options) {
27
+ if (!IMAGE_BACKED_KINDS.has(kind)) return false;
28
+ return options[kind]?.decode ?? true;
29
+ }
30
+ /** Fetch a source, throwing a uniform error on a non-OK response. */
31
+ async function fetchCompletely(url) {
32
+ const response = await fetch(url);
33
+ if (!response.ok) throw new Error(`Preloading "${url}" failed: ${response.status} ${response.statusText}.`);
34
+ return response;
35
+ }
36
+ /**
37
+ * The image-backed strategy (image, slice): fetch the full file, then decode
38
+ * by default. Decoding goes via an HTMLImageElement where a DOM exists (the
39
+ * browser serves the bytes from cache); headless environments (Node tests,
40
+ * SSR) skip decoding. Nothing to release — the browser cache owns the bytes.
41
+ */
42
+ function imageBackedHandler(kind) {
43
+ return { async preload(url, options) {
44
+ await (await fetchCompletely(url)).arrayBuffer();
45
+ if (shouldDecode(kind, options) && typeof globalThis.Image === "function") {
46
+ const image = new globalThis.Image();
47
+ image.src = url;
48
+ await image.decode();
49
+ }
50
+ return {};
51
+ } };
52
+ }
53
+ /**
54
+ * The retained-media strategy (video, chroma-key-video, audio): fetch the
55
+ * full file and retain it as an object URL so later playback does not touch
56
+ * the network. Release revokes the URL, freeing the
57
+ * retained memory. Environments without object URLs (SSR) still fetch fully
58
+ * but retain nothing.
59
+ */
60
+ const retainedMediaHandler = {
61
+ async preload(url) {
62
+ const blob = await (await fetchCompletely(url)).blob();
63
+ if (typeof URL.createObjectURL !== "function") return {};
64
+ return { objectUrl: URL.createObjectURL(blob) };
65
+ },
66
+ release(resources) {
67
+ if (resources.objectUrl !== void 0 && typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(resources.objectUrl);
68
+ }
69
+ };
70
+ /** Fallback for kinds without a registered handler: plain full fetch. */
71
+ const unknownKindHandler = { async preload(url) {
72
+ await (await fetchCompletely(url)).arrayBuffer();
73
+ return {};
74
+ } };
75
+ /** The statically registered first-party handlers. */
76
+ const handlerByKind = new Map([
77
+ ["image", imageBackedHandler("image")],
78
+ ["slice", imageBackedHandler("slice")],
79
+ ...[...RETAINED_KINDS].map((kind) => [kind, retainedMediaHandler])
80
+ ]);
81
+ /** The runtime handler owning `kind`'s preload/release strategy (fallback for unknown kinds). */
82
+ function runtimeHandlerFor(kind) {
83
+ return handlerByKind.get(kind) ?? unknownKindHandler;
84
+ }
85
+ //#endregion
86
+ export { RETAINED_KINDS, audioSourceOf, runtimeHandlerFor, shouldDecode };
@@ -0,0 +1,60 @@
1
+ import { ManifestEntry, PreloadOptions } from "#fundus/core";
2
+
3
+ //#region src/manifest.d.ts
4
+ /** One deliverable file of an entry: its URL and the owning entry's kind. */
5
+ interface EntrySource {
6
+ url: string;
7
+ kind: string;
8
+ }
9
+ /**
10
+ * Collect every deliverable URL of an entry, including the entries codegen
11
+ * inlined in place of asset references: any nested object carrying
12
+ * a string `kind` is an entry, and its `src` (when present) is a source.
13
+ * Generic by design: adding a kind does not change preload orchestration.
14
+ */
15
+ declare function collectEntrySources(entry: ManifestEntry): EntrySource[];
16
+ declare class Manifest {
17
+ #private;
18
+ constructor(entries: Record<string, ManifestEntry>);
19
+ /**
20
+ * Fetch every deliverable file of this manifest up front — including the
21
+ * reference closure, which codegen inlined into the entries — and decode
22
+ * image-backed entries. Options are namespaced per asset kind, mirroring
23
+ * `plugins.<type>` in the config: `{ image: { decode: false } }`
24
+ * skips decoding for image entries only; slices keep decoding unless
25
+ * `slice: { decode: false }` is passed too. An option applies to every
26
+ * entry of its kind, inlined reference entries included.
27
+ *
28
+ * Idempotent and concurrent-safe: one in-flight promise per manifest; a
29
+ * second call joins it (or resolves immediately once done), and URLs shared
30
+ * by several entries are fetched once. Sharing goes further than this
31
+ * manifest: every URL is acquired through the module-wide refcounted
32
+ * registry, so a URL another manifest already preloaded (or is preloading)
33
+ * is never fetched again — this manifest just takes a hold on it. A failed
34
+ * preload rolls back this manifest's holds and clears the in-flight state
35
+ * so it can be retried. A joined call's options are ignored: options apply
36
+ * per preload run (the first call after construction or `release()`).
37
+ */
38
+ preload(options?: PreloadOptions): Promise<void>;
39
+ /**
40
+ * Drop this manifest's holds on everything it preloaded. Assets shared
41
+ * with another preloaded manifest stay fully alive for that holder; assets
42
+ * nobody holds anymore are disposed via their kind's release strategy
43
+ * (retained media object URLs are revoked, freeing the memory). Consumers
44
+ * of a fully released source fall back to its network URL.
45
+ *
46
+ * Safe in every state: before or without `preload()` it is a no-op, and
47
+ * calling it while a `preload()` is still in flight drops the holds
48
+ * without cancelling — when the in-flight load completes unheld, the
49
+ * registry disposes it immediately (no leak). Afterwards the manifest's
50
+ * preload state is reset, so a later `preload()` re-acquires from scratch.
51
+ */
52
+ release(): void;
53
+ }
54
+ /**
55
+ * Construct a manifest whose entries are direct readonly properties:
56
+ * `mainCatalog.navigationPanel` is fully typed, and a typo is a compile error.
57
+ */
58
+ declare function createManifest<T extends Record<string, ManifestEntry>>(entries: T): Manifest & Readonly<T>;
59
+ //#endregion
60
+ export { EntrySource, Manifest, collectEntrySources, createManifest };
@@ -0,0 +1,117 @@
1
+ import { acquireSource, releaseSource } from "./preload-registry.js";
2
+ //#region src/manifest.ts
3
+ /**
4
+ * Collect every deliverable URL of an entry, including the entries codegen
5
+ * inlined in place of asset references: any nested object carrying
6
+ * a string `kind` is an entry, and its `src` (when present) is a source.
7
+ * Generic by design: adding a kind does not change preload orchestration.
8
+ */
9
+ function collectEntrySources(entry) {
10
+ const sources = [];
11
+ visit(entry);
12
+ return sources;
13
+ function visit(value) {
14
+ if (Array.isArray(value)) {
15
+ for (const element of value) visit(element);
16
+ return;
17
+ }
18
+ if (typeof value !== "object" || value === null) return;
19
+ const record = value;
20
+ if (typeof record.kind === "string" && typeof record.src === "string") sources.push({
21
+ url: record.src,
22
+ kind: record.kind
23
+ });
24
+ for (const nested of Object.values(record)) visit(nested);
25
+ }
26
+ }
27
+ var Manifest = class {
28
+ #entries;
29
+ #preloading = null;
30
+ /**
31
+ * URLs this manifest currently holds in the module-wide registry (each
32
+ * held at most once — the registry's reference count counts holding
33
+ * manifests, not preload calls). Replaced wholesale by `release()`: an
34
+ * in-flight preload run keeps operating on the set object it captured, so
35
+ * a release during the run (which drains that set) turns the run's own
36
+ * failure rollback into a no-op instead of a double release.
37
+ */
38
+ #heldUrls = /* @__PURE__ */ new Set();
39
+ constructor(entries) {
40
+ for (const key of Object.keys(entries)) if (key in this) throw new Error(`Manifest entry id "${key}" collides with a Manifest member.`);
41
+ Object.assign(this, entries);
42
+ this.#entries = entries;
43
+ }
44
+ /**
45
+ * Fetch every deliverable file of this manifest up front — including the
46
+ * reference closure, which codegen inlined into the entries — and decode
47
+ * image-backed entries. Options are namespaced per asset kind, mirroring
48
+ * `plugins.<type>` in the config: `{ image: { decode: false } }`
49
+ * skips decoding for image entries only; slices keep decoding unless
50
+ * `slice: { decode: false }` is passed too. An option applies to every
51
+ * entry of its kind, inlined reference entries included.
52
+ *
53
+ * Idempotent and concurrent-safe: one in-flight promise per manifest; a
54
+ * second call joins it (or resolves immediately once done), and URLs shared
55
+ * by several entries are fetched once. Sharing goes further than this
56
+ * manifest: every URL is acquired through the module-wide refcounted
57
+ * registry, so a URL another manifest already preloaded (or is preloading)
58
+ * is never fetched again — this manifest just takes a hold on it. A failed
59
+ * preload rolls back this manifest's holds and clears the in-flight state
60
+ * so it can be retried. A joined call's options are ignored: options apply
61
+ * per preload run (the first call after construction or `release()`).
62
+ */
63
+ preload(options = {}) {
64
+ if (!this.#preloading) {
65
+ const tracked = this.#preloadAll(options, this.#heldUrls).catch((cause) => {
66
+ if (this.#preloading === tracked) this.#preloading = null;
67
+ throw cause;
68
+ });
69
+ this.#preloading = tracked;
70
+ }
71
+ return this.#preloading;
72
+ }
73
+ /**
74
+ * Drop this manifest's holds on everything it preloaded. Assets shared
75
+ * with another preloaded manifest stay fully alive for that holder; assets
76
+ * nobody holds anymore are disposed via their kind's release strategy
77
+ * (retained media object URLs are revoked, freeing the memory). Consumers
78
+ * of a fully released source fall back to its network URL.
79
+ *
80
+ * Safe in every state: before or without `preload()` it is a no-op, and
81
+ * calling it while a `preload()` is still in flight drops the holds
82
+ * without cancelling — when the in-flight load completes unheld, the
83
+ * registry disposes it immediately (no leak). Afterwards the manifest's
84
+ * preload state is reset, so a later `preload()` re-acquires from scratch.
85
+ */
86
+ release() {
87
+ const held = this.#heldUrls;
88
+ this.#heldUrls = /* @__PURE__ */ new Set();
89
+ this.#preloading = null;
90
+ for (const url of held) releaseSource(url);
91
+ held.clear();
92
+ }
93
+ async #preloadAll(options, held) {
94
+ const kindByUrl = /* @__PURE__ */ new Map();
95
+ for (const entry of Object.values(this.#entries)) for (const source of collectEntrySources(entry)) if (!kindByUrl.has(source.url)) kindByUrl.set(source.url, source.kind);
96
+ const acquisitions = [];
97
+ for (const [url, kind] of kindByUrl) {
98
+ held.add(url);
99
+ acquisitions.push(acquireSource(url, kind, options));
100
+ }
101
+ const failure = (await Promise.allSettled(acquisitions)).find((outcome) => outcome.status === "rejected");
102
+ if (failure) {
103
+ for (const url of held) releaseSource(url);
104
+ held.clear();
105
+ throw failure.reason;
106
+ }
107
+ }
108
+ };
109
+ /**
110
+ * Construct a manifest whose entries are direct readonly properties:
111
+ * `mainCatalog.navigationPanel` is fully typed, and a typo is a compile error.
112
+ */
113
+ function createManifest(entries) {
114
+ return new Manifest(entries);
115
+ }
116
+ //#endregion
117
+ export { Manifest, collectEntrySources, createManifest };
@@ -0,0 +1,5 @@
1
+ //#region src/observe-border-box-size.d.ts
2
+ /** Observe an element's layout border-box size in whole CSS pixels. */
3
+ declare function observeBorderBoxSize(element: HTMLElement, onSize: (width: number, height: number) => void): () => void;
4
+ //#endregion
5
+ export { observeBorderBoxSize };
@@ -0,0 +1,12 @@
1
+ //#region src/observe-border-box-size.ts
2
+ /** Observe an element's layout border-box size in whole CSS pixels. */
3
+ function observeBorderBoxSize(element, onSize) {
4
+ const observer = new ResizeObserver((entries) => {
5
+ const size = entries[0]?.borderBoxSize?.[0];
6
+ onSize(Math.round(size?.inlineSize ?? element.offsetWidth), Math.round(size?.blockSize ?? element.offsetHeight));
7
+ });
8
+ observer.observe(element);
9
+ return () => observer.disconnect();
10
+ }
11
+ //#endregion
12
+ export { observeBorderBoxSize };
@@ -0,0 +1,26 @@
1
+ import { PreloadOptions } from "#fundus/core";
2
+
3
+ //#region src/preload-registry.d.ts
4
+ /**
5
+ * Acquire one hold on `url` for a manifest: increments the reference count
6
+ * immediately, then loads via the kind handler unless the URL is already
7
+ * loaded (resolves at once) or in flight (joins the pending load). A failed
8
+ * load clears the in-flight state so the URL stays retryable; the caller
9
+ * rolls back its own hold via `releaseSource`.
10
+ */
11
+ declare function acquireSource(url: string, kind: string, options: PreloadOptions): Promise<void>;
12
+ /**
13
+ * Drop one hold on `url`. At zero holds the kind handler's release strategy
14
+ * runs (object URL revoked) and the entry is cleared — unless the load is
15
+ * still in flight, in which case its completion disposes. Releasing an
16
+ * unknown URL is a no-op.
17
+ */
18
+ declare function releaseSource(url: string): void;
19
+ /**
20
+ * The retained object URL of a source while some manifest holds it, or the
21
+ * source itself when not (or no longer) preloaded — after a full release a
22
+ * consumer falls back to the network URL and simply streams.
23
+ */
24
+ declare function resolveRetainedSource(url: string): string;
25
+ //#endregion
26
+ export { acquireSource, releaseSource, resolveRetainedSource };
@@ -0,0 +1,68 @@
1
+ import { runtimeHandlerFor } from "./kind-handlers.js";
2
+ //#region src/preload-registry.ts
3
+ const registryByUrl = /* @__PURE__ */ new Map();
4
+ /**
5
+ * Acquire one hold on `url` for a manifest: increments the reference count
6
+ * immediately, then loads via the kind handler unless the URL is already
7
+ * loaded (resolves at once) or in flight (joins the pending load). A failed
8
+ * load clears the in-flight state so the URL stays retryable; the caller
9
+ * rolls back its own hold via `releaseSource`.
10
+ */
11
+ function acquireSource(url, kind, options) {
12
+ let entry = registryByUrl.get(url);
13
+ if (!entry) {
14
+ entry = {
15
+ kind,
16
+ referenceCount: 0,
17
+ pending: null,
18
+ resources: null
19
+ };
20
+ registryByUrl.set(url, entry);
21
+ }
22
+ entry.referenceCount += 1;
23
+ if (entry.resources !== null) return Promise.resolve();
24
+ let pending = entry.pending;
25
+ if (!pending) {
26
+ const acquired = entry;
27
+ pending = runtimeHandlerFor(kind).preload(url, options).then((resources) => {
28
+ acquired.pending = null;
29
+ if (acquired.referenceCount === 0) {
30
+ runtimeHandlerFor(acquired.kind).release?.(resources);
31
+ registryByUrl.delete(url);
32
+ return;
33
+ }
34
+ acquired.resources = resources;
35
+ }, (cause) => {
36
+ acquired.pending = null;
37
+ if (acquired.referenceCount === 0) registryByUrl.delete(url);
38
+ throw cause;
39
+ });
40
+ acquired.pending = pending;
41
+ }
42
+ return pending;
43
+ }
44
+ /**
45
+ * Drop one hold on `url`. At zero holds the kind handler's release strategy
46
+ * runs (object URL revoked) and the entry is cleared — unless the load is
47
+ * still in flight, in which case its completion disposes. Releasing an
48
+ * unknown URL is a no-op.
49
+ */
50
+ function releaseSource(url) {
51
+ const entry = registryByUrl.get(url);
52
+ if (!entry) return;
53
+ if (entry.referenceCount > 0) entry.referenceCount -= 1;
54
+ if (entry.referenceCount > 0) return;
55
+ if (entry.pending) return;
56
+ if (entry.resources !== null) runtimeHandlerFor(entry.kind).release?.(entry.resources);
57
+ registryByUrl.delete(url);
58
+ }
59
+ /**
60
+ * The retained object URL of a source while some manifest holds it, or the
61
+ * source itself when not (or no longer) preloaded — after a full release a
62
+ * consumer falls back to the network URL and simply streams.
63
+ */
64
+ function resolveRetainedSource(url) {
65
+ return registryByUrl.get(url)?.resources?.objectUrl ?? url;
66
+ }
67
+ //#endregion
68
+ export { acquireSource, releaseSource, resolveRetainedSource };
@@ -0,0 +1,279 @@
1
+ import { r as assertSafeRelativeFilePath, t as FundusOperations, w as loadConfig } from "./operations-DYM-5KVl.js";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { extname, join, resolve, sep } from "node:path";
4
+ import { existsSync } from "node:fs";
5
+ import { createServer } from "node:http";
6
+ //#region src/server.ts
7
+ /**
8
+ * The local Fundus HTTP server: REST under `/api/`, raw and proxy files under
9
+ * `/files/`, and the prebuilt editor SPA at `/` when its build is available.
10
+ */
11
+ const CONTENT_TYPES = {
12
+ ".png": "image/png",
13
+ ".jpg": "image/jpeg",
14
+ ".jpeg": "image/jpeg",
15
+ ".webp": "image/webp",
16
+ ".gif": "image/gif",
17
+ ".avif": "image/avif",
18
+ ".svg": "image/svg+xml",
19
+ ".html": "text/html; charset=utf-8",
20
+ ".js": "text/javascript; charset=utf-8",
21
+ ".css": "text/css; charset=utf-8",
22
+ ".json": "application/json; charset=utf-8",
23
+ ".ico": "image/x-icon",
24
+ ".woff2": "font/woff2",
25
+ ".mp4": "video/mp4",
26
+ ".m4v": "video/mp4",
27
+ ".mov": "video/quicktime",
28
+ ".webm": "video/webm",
29
+ ".mp3": "audio/mpeg",
30
+ ".wav": "audio/wav",
31
+ ".m4a": "audio/mp4",
32
+ ".ogg": "audio/ogg",
33
+ ".flac": "audio/flac"
34
+ };
35
+ function contentTypeFor(fileName) {
36
+ return CONTENT_TYPES[extname(fileName).toLowerCase()] ?? "application/octet-stream";
37
+ }
38
+ function errorResponse(cause, status) {
39
+ const body = { error: { message: cause instanceof Error ? cause.message : String(cause) } };
40
+ return Response.json(body, { status });
41
+ }
42
+ /**
43
+ * Parse a single-range HTTP `Range: bytes=…` header against a known size.
44
+ * Returns the inclusive `[start, end]` byte range, `null` when there is no (or
45
+ * an unhandled multi-)range header — serve the whole file — or `'unsatisfiable'`
46
+ * when the header is syntactically a range but falls outside the file (→ 416).
47
+ * Only the single-range forms browsers actually send while seeking are handled:
48
+ * `bytes=start-end`, `bytes=start-`, and the `bytes=-suffix` tail form.
49
+ */
50
+ function parseByteRange(rangeHeader, size) {
51
+ if (!rangeHeader) return null;
52
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
53
+ if (!match || match[1] === "" && match[2] === "") return null;
54
+ if (size === 0) return "unsatisfiable";
55
+ let start;
56
+ let end;
57
+ if (match[1] === "") {
58
+ const suffix = Number(match[2]);
59
+ start = Math.max(0, size - suffix);
60
+ end = size - 1;
61
+ } else {
62
+ start = Number(match[1]);
63
+ end = match[2] === "" ? size - 1 : Math.min(Number(match[2]), size - 1);
64
+ }
65
+ if (start > end || start >= size) return "unsatisfiable";
66
+ return {
67
+ start,
68
+ end
69
+ };
70
+ }
71
+ async function serveSingleFile(directory, fileName, request) {
72
+ try {
73
+ assertSafeRelativeFilePath(fileName);
74
+ } catch (cause) {
75
+ return errorResponse(cause, 400);
76
+ }
77
+ const filePath = join(directory, fileName);
78
+ if (!existsSync(filePath)) return errorResponse(/* @__PURE__ */ new Error(`File "${fileName}" not found.`), 404);
79
+ const bytes = new Uint8Array(await readFile(filePath));
80
+ const size = bytes.byteLength;
81
+ const headers = {
82
+ "Content-Type": contentTypeFor(fileName),
83
+ "Cache-Control": "no-store",
84
+ "Accept-Ranges": "bytes"
85
+ };
86
+ const range = parseByteRange(request.headers.get("Range"), size);
87
+ if (range === "unsatisfiable") return new Response(null, {
88
+ status: 416,
89
+ headers: {
90
+ ...headers,
91
+ "Content-Range": `bytes */${size}`
92
+ }
93
+ });
94
+ if (range) {
95
+ const slice = bytes.subarray(range.start, range.end + 1);
96
+ return new Response(slice, {
97
+ status: 206,
98
+ headers: {
99
+ ...headers,
100
+ "Content-Range": `bytes ${range.start}-${range.end}/${size}`,
101
+ "Content-Length": String(slice.byteLength)
102
+ }
103
+ });
104
+ }
105
+ return new Response(bytes, { headers: {
106
+ ...headers,
107
+ "Content-Length": String(size)
108
+ } });
109
+ }
110
+ function startServer(options) {
111
+ const operations = new FundusOperations(options.config);
112
+ const editorBuildDirectory = options.editorBuildDirectory ?? null;
113
+ async function serveEditor(pathname) {
114
+ if (editorBuildDirectory) {
115
+ const relative = pathname === "/" ? "index.html" : pathname.slice(1);
116
+ const buildRoot = resolve(editorBuildDirectory);
117
+ const filePath = resolve(buildRoot, relative);
118
+ if ((filePath === buildRoot || filePath.startsWith(`${buildRoot}${sep}`)) && !relative.includes("..")) {
119
+ if ((await stat(filePath).catch(() => null))?.isFile()) {
120
+ const bytes = await readFile(filePath);
121
+ return new Response(new Uint8Array(bytes), { headers: { "Content-Type": contentTypeFor(filePath) } });
122
+ }
123
+ }
124
+ const fallbackPath = join(editorBuildDirectory, "200.html");
125
+ if (existsSync(fallbackPath)) {
126
+ const bytes = await readFile(fallbackPath);
127
+ return new Response(new Uint8Array(bytes), { headers: { "Content-Type": "text/html; charset=utf-8" } });
128
+ }
129
+ }
130
+ return errorResponse(/* @__PURE__ */ new Error("The prebuilt Fundus editor is unavailable."), 500);
131
+ }
132
+ async function handle(request) {
133
+ const pathname = new URL(request.url).pathname;
134
+ const method = request.method.toUpperCase();
135
+ try {
136
+ if (pathname === "/api/state" && method === "GET") return Response.json(await operations.state());
137
+ if (pathname === "/api/assets" && method === "POST") {
138
+ const formData = await request.formData();
139
+ const file = formData.get("file");
140
+ const type = formData.get("type");
141
+ const id = formData.get("id");
142
+ const folder = formData.get("folder");
143
+ if (!(file instanceof File)) return errorResponse(/* @__PURE__ */ new Error("Multipart field \"file\" must be a file."), 400);
144
+ if (typeof type !== "string" || typeof id !== "string") return errorResponse(/* @__PURE__ */ new Error("Multipart fields \"type\" and \"id\" are required."), 400);
145
+ const bytes = new Uint8Array(await file.arrayBuffer());
146
+ if (folder !== null && typeof folder !== "string") return errorResponse(/* @__PURE__ */ new Error("Multipart field \"folder\" must be a string."), 400);
147
+ return Response.json(await operations.ingest({
148
+ fileName: file.name,
149
+ bytes,
150
+ type,
151
+ id,
152
+ folder: folder ?? ""
153
+ }));
154
+ }
155
+ if (pathname === "/api/assets/move" && method === "POST") {
156
+ const body = await request.json();
157
+ if (!Array.isArray(body.assetIds) || body.assetIds.some((id) => typeof id !== "string") || typeof body.folder !== "string") return errorResponse(/* @__PURE__ */ new Error("\"assetIds\" must be an array of strings and \"folder\" must be a string."), 400);
158
+ return Response.json(await operations.moveAssets(body.assetIds, body.folder));
159
+ }
160
+ if (pathname === "/api/assets/delete" && method === "POST") {
161
+ const body = await request.json();
162
+ if (!Array.isArray(body.assetIds) || body.assetIds.some((id) => typeof id !== "string")) return errorResponse(/* @__PURE__ */ new Error("\"assetIds\" must be an array of strings."), 400);
163
+ return Response.json(await operations.deleteAssets(body.assetIds));
164
+ }
165
+ const assetMatch = pathname.match(/^\/api\/assets\/([^/]+)$/);
166
+ if (assetMatch) {
167
+ const id = decodeURIComponent(assetMatch[1]);
168
+ if (method === "PATCH") {
169
+ const body = await request.json();
170
+ return Response.json(await operations.patchAsset(id, body));
171
+ }
172
+ if (method === "DELETE") return Response.json(await operations.deleteAsset(id));
173
+ }
174
+ if (pathname === "/api/manifests" && method === "POST") {
175
+ const body = await request.json();
176
+ if (typeof body.name !== "string") return errorResponse(/* @__PURE__ */ new Error("\"name\" must be a string."), 400);
177
+ return Response.json(await operations.createManifest(body.name));
178
+ }
179
+ if (pathname === "/api/folders" && method === "POST") {
180
+ const body = await request.json();
181
+ if (typeof body.path !== "string") return errorResponse(/* @__PURE__ */ new Error("\"path\" must be a string."), 400);
182
+ return Response.json(await operations.createFolder(body.path));
183
+ }
184
+ const folderMatch = pathname.match(/^\/api\/folders\/([^/]+)$/);
185
+ if (folderMatch) {
186
+ const folder = decodeURIComponent(folderMatch[1]);
187
+ if (method === "PATCH") {
188
+ const body = await request.json();
189
+ if (body.name === void 0 === (body.parent === void 0)) return errorResponse(/* @__PURE__ */ new Error("Provide exactly one of \"name\" or \"parent\"."), 400);
190
+ if (body.name !== void 0) {
191
+ if (typeof body.name !== "string") return errorResponse(/* @__PURE__ */ new Error("\"name\" must be a string."), 400);
192
+ return Response.json(await operations.renameFolder(folder, body.name));
193
+ }
194
+ if (typeof body.parent !== "string") return errorResponse(/* @__PURE__ */ new Error("\"parent\" must be a string."), 400);
195
+ return Response.json(await operations.moveFolder(folder, body.parent));
196
+ }
197
+ if (method === "DELETE") return Response.json(await operations.deleteFolder(folder));
198
+ }
199
+ const manifestMatch = pathname.match(/^\/api\/manifests\/([^/]+)$/);
200
+ if (manifestMatch) {
201
+ const name = decodeURIComponent(manifestMatch[1]);
202
+ if (method === "PATCH") {
203
+ const body = await request.json();
204
+ return Response.json(await operations.patchManifest(name, body));
205
+ }
206
+ if (method === "DELETE") return Response.json(await operations.deleteManifest(name));
207
+ }
208
+ const rawMatch = pathname.match(/^\/files\/raw\/(.+)$/);
209
+ if (rawMatch && method === "GET") return serveSingleFile(options.config.rawDir, decodeURIComponent(rawMatch[1]), request);
210
+ const proxyMatch = pathname.match(/^\/files\/proxy\/(.+)$/);
211
+ if (proxyMatch && method === "GET") return serveSingleFile(options.config.proxyDir, decodeURIComponent(proxyMatch[1]), request);
212
+ if (pathname.startsWith("/api/") || pathname.startsWith("/files/")) return errorResponse(/* @__PURE__ */ new Error(`No route for ${method} ${pathname}.`), 404);
213
+ return serveEditor(pathname);
214
+ } catch (cause) {
215
+ return errorResponse(cause, 400);
216
+ }
217
+ }
218
+ const server = createServer(async (incoming, outgoing) => {
219
+ try {
220
+ await writeWebResponse(outgoing, await handle(await nodeRequestToWebRequest(incoming, options.port)));
221
+ } catch (cause) {
222
+ await writeWebResponse(outgoing, errorResponse(cause, 500));
223
+ }
224
+ });
225
+ server.listen(options.port);
226
+ const address = server.address();
227
+ return {
228
+ port: typeof address === "object" && address ? address.port : options.port,
229
+ stop: () => {
230
+ server.close();
231
+ server.closeAllConnections();
232
+ }
233
+ };
234
+ }
235
+ async function nodeRequestToWebRequest(request, fallbackPort) {
236
+ const host = request.headers.host ?? `localhost:${fallbackPort}`;
237
+ const method = request.method ?? "GET";
238
+ const body = method === "GET" || method === "HEAD" ? void 0 : request;
239
+ return new Request(`http://${host}${request.url ?? "/"}`, {
240
+ method,
241
+ headers: request.headers,
242
+ body,
243
+ duplex: body ? "half" : void 0
244
+ });
245
+ }
246
+ async function writeWebResponse(response, webResponse) {
247
+ response.statusCode = webResponse.status;
248
+ webResponse.headers.forEach((value, name) => response.setHeader(name, value));
249
+ if (!webResponse.body) {
250
+ response.end();
251
+ return;
252
+ }
253
+ response.end(Buffer.from(await webResponse.arrayBuffer()));
254
+ }
255
+ //#endregion
256
+ //#region src/start.ts
257
+ /**
258
+ * `fundus start` serves the prebuilt editor and API for a host project.
259
+ */
260
+ async function runStart(projectRoot) {
261
+ const apiPort = Number(process.env.FUNDUS_API_PORT) || 5176;
262
+ const config = await loadConfig(projectRoot);
263
+ const editorBuildDirectory = join(import.meta.dirname, "editor");
264
+ if (!existsSync(join(editorBuildDirectory, "200.html"))) throw new Error(`The prebuilt Fundus editor is missing from ${editorBuildDirectory}. Reinstall the fundus package or report a packaging error.`);
265
+ const server = startServer({
266
+ config,
267
+ port: apiPort,
268
+ editorBuildDirectory
269
+ });
270
+ console.log(`fundus editor: http://localhost:${server.port} (project: ${projectRoot})`);
271
+ const stop = () => {
272
+ server.stop();
273
+ process.exit(0);
274
+ };
275
+ process.on("SIGINT", stop);
276
+ process.on("SIGTERM", stop);
277
+ }
278
+ //#endregion
279
+ export { runStart as t };
@@ -0,0 +1,2 @@
1
+ import { t as runStart } from "./start-Bhnqt9Dc.js";
2
+ export { runStart };