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.
- package/dist/build-CZYFLLvl.js +191 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1420 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/core/image-Dqmz8FXS.js +54 -0
- package/dist/core/index.d.ts +499 -0
- package/dist/core/index.js +962 -0
- package/dist/core/reference-graph-CjuHbLDw.d.ts +280 -0
- package/dist/core/testing.d.ts +27 -0
- package/dist/core/testing.js +80 -0
- package/dist/editor/200.html +37 -0
- package/dist/editor/_app/immutable/assets/0.DLhIsYA_.css +1 -0
- package/dist/editor/_app/immutable/assets/2.DIbbW7U1.css +1 -0
- package/dist/editor/_app/immutable/assets/3.C-9vFQ9A.css +1 -0
- package/dist/editor/_app/immutable/assets/4.BS8ElpOV.css +1 -0
- package/dist/editor/_app/immutable/assets/ConfirmDialog.CHEISE-L.css +1 -0
- package/dist/editor/_app/immutable/assets/LibraryContextMenu.CDjCmdV-.css +1 -0
- package/dist/editor/_app/immutable/chunks/BfPE5wdt.js +1 -0
- package/dist/editor/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/dist/editor/_app/immutable/chunks/CNRz3sSw.js +3 -0
- package/dist/editor/_app/immutable/chunks/CO4RBJfa.js +1 -0
- package/dist/editor/_app/immutable/chunks/Cx6qtxJg.js +1 -0
- package/dist/editor/_app/immutable/chunks/DjdrdRzT.js +1 -0
- package/dist/editor/_app/immutable/chunks/DjvOiDmq.js +1 -0
- package/dist/editor/_app/immutable/chunks/n7mvYc42.js +5 -0
- package/dist/editor/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/dist/editor/_app/immutable/entry/app.BkqbfI8n.js +2 -0
- package/dist/editor/_app/immutable/entry/start.C8HHOLly.js +1 -0
- package/dist/editor/_app/immutable/nodes/0.xdMI5GEU.js +2 -0
- package/dist/editor/_app/immutable/nodes/1.D9p2tfoJ.js +1 -0
- package/dist/editor/_app/immutable/nodes/2.BrA0Hyzu.js +3 -0
- package/dist/editor/_app/immutable/nodes/3.WO7tZH_d.js +104 -0
- package/dist/editor/_app/immutable/nodes/4.DQtx17aW.js +2 -0
- package/dist/editor/_app/version.json +1 -0
- package/dist/index.d.ts +370 -0
- package/dist/index.js +4 -0
- package/dist/operations-DYM-5KVl.js +1797 -0
- package/dist/runtime/chroma-key-gl.d.ts +23 -0
- package/dist/runtime/chroma-key-gl.js +290 -0
- package/dist/runtime/components/ChromaKeyVideo.svelte +225 -0
- package/dist/runtime/components/Image.svelte +18 -0
- package/dist/runtime/components/Slice.svelte +35 -0
- package/dist/runtime/components/SliceCanvas.svelte +162 -0
- package/dist/runtime/components/SliceDom.svelte +104 -0
- package/dist/runtime/components/Video.svelte +60 -0
- package/dist/runtime/geometry.d.ts +75 -0
- package/dist/runtime/geometry.js +209 -0
- package/dist/runtime/index.d.ts +9 -0
- package/dist/runtime/index.js +9 -0
- package/dist/runtime/kind-handlers.d.ts +46 -0
- package/dist/runtime/kind-handlers.js +86 -0
- package/dist/runtime/manifest.d.ts +60 -0
- package/dist/runtime/manifest.js +117 -0
- package/dist/runtime/observe-border-box-size.d.ts +5 -0
- package/dist/runtime/observe-border-box-size.js +12 -0
- package/dist/runtime/preload-registry.d.ts +26 -0
- package/dist/runtime/preload-registry.js +68 -0
- package/dist/start-Bhnqt9Dc.js +279 -0
- package/dist/start-D2IXOrni.js +2 -0
- 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 };
|