caracms 0.0.0-stage → 0.2.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.
Files changed (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The media routes (M5b Phase 2), one handler behind `/_caracms/media/[...path]`
3
+ * (../../routes/media.ts). The routes, what they take and give: ../../types.ts →
4
+ * `MEDIA_ENDPOINTS`. A security boundary: it takes files and ids from the browser and
5
+ * writes to the bucket and Mux.
6
+ *
7
+ * - **Live mode**: the guard's prefix rule has already required a session (401) and, for
8
+ * a POST, the same origin (403); this checks both again (defence in depth, as the live
9
+ * handlers do). **Local mode** works with no sign-in, the origin still checked for a POST.
10
+ * - **No lock** (Decision 7): an upload, or reading the library, changes no document.
11
+ * - **Every reply is `Cache-Control: private, no-store`.**
12
+ * - **Unconfigured: 503 with a sentence naming what's missing**, images (the bucket and
13
+ * imgproxy) and clips (Mux) apart: one missing never disables the other.
14
+ * - **The Mux token never reaches the browser, nor do Mux's error bodies** (server log
15
+ * only). Ids are checked against `^[A-Za-z0-9]{1,128}$` before they reach a URL.
16
+ * - **The upload has its own body cap** (40 MB, upload.ts), not the write route's 2 MB or
17
+ * live mode's 16 KB; the video upload's JSON is held to 16 KB.
18
+ * - **M6c G4: `POST replace`** (Replace… on a file, Decision 9) is the one media route that
19
+ * changes documents: the mode's store does it under the same rules as a move (replace.ts;
20
+ * live mode takes each document's lock), with the tab's header as a write has it. The new
21
+ * file must already be in the bucket.
22
+ */
23
+ import type { ResolvedSite } from '../../../config.ts';
24
+ import type { Person } from '../../../live/messages.ts';
25
+ import { IMAGE_WIDTHS, thumbnailUrl, type MediaSettings } from '../../../media.ts';
26
+ import type {
27
+ ApiError,
28
+ LibraryClip,
29
+ MediaClipsResponse,
30
+ MediaFileUploadResponse,
31
+ MediaImagesResponse,
32
+ MediaRefusal,
33
+ MediaUnset,
34
+ MediaUploadResponse,
35
+ ReplaceFileResponse,
36
+ VideoUploadResponse,
37
+ VideoUploadStatus,
38
+ } from '../../types.ts';
39
+ import { MEDIA_MAX_BYTES, RELEASE_HEADER } from '../../types.ts';
40
+ import type { DraftsStore } from '../drafts-store.ts';
41
+ import { parseReplaceBody } from '../replace.ts';
42
+ import { checkOrigin, seqOf, tabOf, WriteRefused } from '../write.ts';
43
+ import { BucketError, s3Bucket, type Bucket } from './bucket.ts';
44
+ import { readImagesConfig, readMuxConfig } from './config.ts';
45
+ import { isFileKey } from '../../../files.ts';
46
+ import { entryOf, fileEntryOf, libraryFor, mediaUses, UPLOADS, type Library, type MediaUses } from './library.ts';
47
+ import { memoryMedia } from './memory.ts';
48
+ import { clipTitle, muxClient, MuxRouteError, MUX_ID, type Clip, type MuxClient } from './mux.ts';
49
+ import { readCapped, storeUpload, tooLargeMessage, UploadRefused, uploadName } from './upload.ts';
50
+
51
+ export const MEDIA_PREFIX = '/_caracms/media/';
52
+ const HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'private, no-store' };
53
+ const json = (status: number, body: unknown, extra: Record<string, string> = {}) => new Response(JSON.stringify(body), { status, headers: { ...HEADERS, ...extra } });
54
+ const JSON_CAP = 16_384;
55
+ /** A key the thumbnail route signs: an upload's, nothing else. */
56
+ const UPLOAD_KEY = /^uploads\/[0-9a-f]{12}\/[^/\u0000-\u001f\u007f]{1,255}$/;
57
+
58
+ type Env = Record<string, string | undefined>;
59
+
60
+ export interface MediaContext {
61
+ site: Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>;
62
+ /** The mode's store (server/mode.ts): where-used reads its listing. */
63
+ store: DraftsStore;
64
+ mode: 'local' | 'live';
65
+ /** The signed-in person (live mode); null in local mode. */
66
+ person: Person | null;
67
+ env?: Env;
68
+ production?: boolean;
69
+ log?: (line: string) => void;
70
+ /** Mux's and the bucket's `fetch` (tests). Memory mode never uses it. */
71
+ fetch?: typeof fetch;
72
+ }
73
+
74
+ /** The image side, ready: the library and the signing settings. */
75
+ function images(ctx: MediaContext): { library: Library; signing: MediaSettings } | Response {
76
+ const config = readImagesConfig(ctx.env ?? process.env, ctx.production);
77
+ if (config.kind === 'unset') return json(503, { error: config.message, code: 'media-unset', missing: config.missing } satisfies MediaUnset);
78
+ if (config.kind === 'memory') return { library: libraryFor('memory', () => memoryMedia().bucket), signing: config.signing };
79
+ const b = config.bucket;
80
+ return { library: libraryFor(`s3:${b.endpoint}:${b.bucket}:${b.accessKeyId}`, (): Bucket => s3Bucket(b, ctx.fetch)), signing: config.signing };
81
+ }
82
+
83
+ function mux(ctx: MediaContext): MuxClient | Response {
84
+ const config = readMuxConfig(ctx.env ?? process.env, ctx.production);
85
+ if (config.kind === 'unset') return json(503, { error: config.message, code: 'mux-unset', missing: config.missing } satisfies MediaUnset);
86
+ const log = ctx.log ?? ((line: string) => console.error(line));
87
+ if (config.kind === 'memory') return muxClient({ tokenId: 'memory', tokenSecret: 'memory', fetch: memoryMedia().mux.fetch, log });
88
+ return muxClient({ tokenId: config.tokenId, tokenSecret: config.tokenSecret, fetch: ctx.fetch, log });
89
+ }
90
+
91
+ const withUses = (clip: Clip, uses: MediaUses): LibraryClip => ({ ...clip, usedIn: clip.playbackId ? (uses.clips.get(clip.playbackId) ?? []) : [] });
92
+
93
+ async function readJson(request: Request, shape = '{ "name": "<file name>" }'): Promise<Record<string, unknown>> {
94
+ const type = (request.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase();
95
+ if (type !== 'application/json') throw new WriteRefused(415, `Send JSON: ${shape}.`);
96
+ const body = await readCapped(request, JSON_CAP);
97
+ if (!body.ok) throw new WriteRefused(413, 'That body is too large.');
98
+ try {
99
+ const value = JSON.parse(new TextDecoder().decode(body.bytes)) as unknown;
100
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>;
101
+ } catch {
102
+ /* below */
103
+ }
104
+ throw new WriteRefused(400, `Send JSON: ${shape}.`);
105
+ }
106
+
107
+ /** Answer one request under `/_caracms/media/`. */
108
+ export async function handleMedia(request: Request, ctx: MediaContext): Promise<Response> {
109
+ const log = ctx.log ?? ((line: string) => console.error(line));
110
+ const url = new URL(request.url);
111
+ const rest = url.pathname.startsWith(MEDIA_PREFIX) ? url.pathname.slice(MEDIA_PREFIX.length) : null;
112
+ const parts = rest === null ? [] : rest.split('/').filter(Boolean);
113
+ const [route, id, extra] = parts;
114
+ const method = request.method.toUpperCase();
115
+ const only = (allowed: string): Response | null =>
116
+ method === allowed ? null : json(405, { error: `This route takes ${allowed}.` } satisfies ApiError, { Allow: allowed });
117
+
118
+ try {
119
+ // Defence in depth: the guard already did both in live mode.
120
+ if (ctx.mode === 'live' && !ctx.person) return json(401, { error: 'signed-out', code: 'signed-out' } satisfies ApiError);
121
+ if (method !== 'GET') checkOrigin(request);
122
+ const by = ctx.mode === 'live' ? (ctx.person?.email ?? null) : null;
123
+
124
+ if (route === 'upload' && id === undefined) {
125
+ const refusedMethod = only('POST');
126
+ if (refusedMethod) return refusedMethod;
127
+ const side = images(ctx);
128
+ if (side instanceof Response) {
129
+ await request.body?.cancel().catch(() => {});
130
+ return side;
131
+ }
132
+ const body = await readCapped(request, MEDIA_MAX_BYTES);
133
+ if (!body.ok) return json(413, { error: tooLargeMessage(body.size), code: 'too-large' } satisfies MediaRefusal);
134
+ const stored = await storeUpload(side.library.bucket, { bytes: body.bytes, name: uploadName(request), by }, { head: (key) => side.library.metaOf(key) });
135
+ if (stored.created) side.library.added(stored.key, stored.meta);
136
+ const uses = await mediaUses(ctx.store, ctx.site);
137
+ const status = stored.created ? 201 : 200;
138
+ if (isFileKey(stored.key)) return json(status, { file: fileEntryOf(stored.key, stored.meta, uses), created: stored.created } satisfies MediaFileUploadResponse);
139
+ const image = entryOf(side.signing, stored.key, stored.meta, uses);
140
+ return json(status, { image, created: stored.created } satisfies MediaUploadResponse);
141
+ }
142
+
143
+ if (route === 'images' && id === undefined) {
144
+ const refusedMethod = only('GET');
145
+ if (refusedMethod) return refusedMethod;
146
+ const side = images(ctx);
147
+ if (side instanceof Response) return side;
148
+ const uses = await mediaUses(ctx.store, ctx.site);
149
+ const [listed, files] = await Promise.all([side.library.images(side.signing, uses), side.library.files(uses)]);
150
+ return json(200, { images: listed, files } satisfies MediaImagesResponse);
151
+ }
152
+
153
+ if (route === 'replace' && id === undefined) {
154
+ const refusedMethod = only('POST');
155
+ if (refusedMethod) return refusedMethod;
156
+ const side = images(ctx);
157
+ if (side instanceof Response) {
158
+ await request.body?.cancel().catch(() => {});
159
+ return side;
160
+ }
161
+ const body = parseReplaceBody(await readJson(request, '{ "from": "<the file\'s key>", "to": "<the new file\'s key>" }'));
162
+ if (!(await side.library.metaOf(body.to))) return json(404, { error: "The new file isn't in the library, so nothing was changed. Upload it again.", code: 'not-found' } satisfies ApiError);
163
+ const context = { person: ctx.person, tab: tabOf(request), release: request.headers.get(RELEASE_HEADER) === '1', seq: seqOf(request) };
164
+ return json(200, (await ctx.store.replaceFile(body, context)) satisfies ReplaceFileResponse);
165
+ }
166
+
167
+ if (route === 'thumbnail' && id === undefined) {
168
+ const refusedMethod = only('GET');
169
+ if (refusedMethod) return refusedMethod;
170
+ const side = images(ctx);
171
+ if (side instanceof Response) return side;
172
+ const key = url.searchParams.get('key') ?? '';
173
+ const width = Number(url.searchParams.get('width') ?? '320');
174
+ if (!UPLOAD_KEY.test(key) || !key.startsWith(UPLOADS) || isFileKey(key)) return json(400, { error: 'key must be an image upload\'s key (uploads/<hash>/<name>); a file has no thumbnail.' } satisfies ApiError);
175
+ if (!IMAGE_WIDTHS.includes(width)) return json(400, { error: `width must be one of ${IMAGE_WIDTHS.join(', ')}.` } satisfies ApiError);
176
+ return new Response(null, { status: 302, headers: { Location: thumbnailUrl(side.signing, key, width), 'Cache-Control': 'private, no-store' } });
177
+ }
178
+
179
+ // ── Clips ──
180
+ const known = route === 'video-upload' || route === 'clips' || route === 'playback';
181
+ if (!known || extra !== undefined || (route === 'playback' && id === undefined)) return json(404, { error: 'Not found.' } satisfies ApiError);
182
+ if (id !== undefined && !MUX_ID.test(id)) return json(404, { error: 'Not found.' } satisfies ApiError);
183
+
184
+ if (route === 'video-upload' && id === undefined) {
185
+ const refusedMethod = only('POST');
186
+ if (refusedMethod) return refusedMethod;
187
+ const client = mux(ctx);
188
+ if (client instanceof Response) return client;
189
+ // cors_origin is the request's own origin, as the browser sent it (checkOrigin has
190
+ // matched its host to this request's). No Origin, no upload: Mux takes the PUT only from it.
191
+ const origin = request.headers.get('origin');
192
+ if (!origin || origin === 'null') return json(403, { error: 'Video uploads are only accepted from the editor on this site.' } satisfies ApiError);
193
+ const body = await readJson(request);
194
+ const title = clipTitle(body.name);
195
+ if (!title) return json(400, { error: 'Send the file name: { "name": "<file name>" }.' } satisfies ApiError);
196
+ const upload = await client.createUpload(new URL(origin).origin, title);
197
+ return json(201, upload satisfies VideoUploadResponse);
198
+ }
199
+
200
+ const refusedMethod = only('GET');
201
+ if (refusedMethod) return refusedMethod;
202
+ const client = mux(ctx);
203
+ if (client instanceof Response) return client;
204
+ if (route === 'video-upload') return json(200, (await client.upload(id!)) satisfies VideoUploadStatus);
205
+ const uses = await mediaUses(ctx.store, ctx.site);
206
+ if (route === 'clips' && id === undefined) return json(200, { clips: (await client.clips()).map((c) => withUses(c, uses)) } satisfies MediaClipsResponse);
207
+ if (route === 'clips') return json(200, withUses(await client.clip(id!), uses) satisfies LibraryClip);
208
+ return json(200, withUses(await client.clipOfPlayback(id!), uses) satisfies LibraryClip);
209
+ } catch (error) {
210
+ if (error instanceof UploadRefused) return json(error.status, { error: error.message, code: error.code } satisfies MediaRefusal);
211
+ if (error instanceof MuxRouteError) return json(error.status, { error: error.message } satisfies ApiError);
212
+ if (error instanceof WriteRefused) return json(error.status, { error: error.message, ...error.body });
213
+ if (error instanceof BucketError) {
214
+ log(`[caracms] media bucket: ${error.message}`);
215
+ const said = error.status !== null && error.status >= 400 && error.status < 500;
216
+ return json(502, { error: said ? 'The media bucket refused that, so nothing was stored.' : "The media bucket didn't answer, so nothing was stored. Try again in a moment." } satisfies ApiError);
217
+ }
218
+ log(`[caracms] ${url.pathname} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
219
+ return json(500, { error: 'Something went wrong with the media library.' } satisfies ApiError);
220
+ }
221
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * The library (M5b Decision 7): the bucket's `uploads/` as `LibraryImage`s, newest first,
3
+ * each with where it's used and an alt text already used for it.
4
+ *
5
+ * - **Where it's used** is worked out from the store on every listing (`mediaUses`): each
6
+ * document in the store's listing (the `drafts` copy with autosaves in live mode, the
7
+ * disk in local mode), read whole, any value with an image `key` or a clip `video` in
8
+ * it, in any block, a kept unknown one and the brand's logo included (the content still
9
+ * names it, so it's used). **An alt text already used** is the first non-empty one in
10
+ * the listing's document order, decorative ones skipped (nothing records which was
11
+ * written last).
12
+ * **A focal point already set** is chosen the same way, on its own: the first one in the
13
+ * listing's document order, whether or not that use has the alt text, decorative ones
14
+ * included (a focal point is a spot on the photo, not on a crop or a meaning).
15
+ * - **Cost:** the bucket's listing is kept per process for 60 seconds, and this process's
16
+ * own uploads are added at once (an upload from another place the site runs shows within
17
+ * a minute). **An object's metadata is kept by key for the life of the process**: a key
18
+ * never changes what it points at. No paging in v1.
19
+ * - **M6c G4: files** (`caracms/files`' types) are listed beside the images, each with the
20
+ * documents that **link** to it, found by schema (relink.ts → `fileLinksIn`: link fields,
21
+ * buttons and rich text, in blocks, page fields, fields documents, shared blocks and
22
+ * Site Settings), so a plain-text mention of its address doesn't count (Decision 8).
23
+ * - **Thumbnails are signed URLs in the listing** (`thumbnail`, `thumbnail2x`: `rs:fit` at
24
+ * 320 and 640, rungs of the page's ladder, so they share its cache). The signing key
25
+ * never leaves the server; the bucket key is in every image path anyway.
26
+ */
27
+ import { join } from 'node:path';
28
+ import type { ResolvedSite } from '../../../config.ts';
29
+ import { fileTypeOf, isFileKey } from '../../../files.ts';
30
+ import { isClipValue, isImageValue, parseFocal } from '../../../media-value.ts';
31
+ import { thumbnailUrl, type MediaSettings } from '../../../media.ts';
32
+ import { fileLinksIn } from '../../../relink.ts';
33
+ import type { FileUse, LibraryFile, LibraryImage, MediaUse } from '../../types.ts';
34
+ import type { DraftsStore } from '../drafts-store.ts';
35
+ import type { Bucket, BucketObject, ObjectMeta } from './bucket.ts';
36
+ import { onMediaReset } from './memory.ts';
37
+
38
+ /** The prefix every upload goes under (Decision 6). */
39
+ export const UPLOADS = 'uploads/';
40
+ export const LISTING_TTL_MS = 60_000;
41
+ const IMAGE_KEY = /^uploads\/[^/]+\/[^/]+\.(?:jpe?g|png|webp|avif|heic|heif|gif|svg)$/i;
42
+ const HEAD_CONCURRENCY = 8;
43
+
44
+ export interface MediaUses {
45
+ /** Image key → the documents that use it, the first non-empty alt text and the first focal point. */
46
+ images: Map<string, { usedIn: MediaUse[]; alt: string | null; focal: [number, number] | null }>;
47
+ /** Playback ID → the documents that use it. */
48
+ clips: Map<string, MediaUse[]>;
49
+ /** M6c G4: a file's key → the documents that link to it (by schema), with how many links each. */
50
+ files: Map<string, FileUse[]>;
51
+ }
52
+
53
+ /** Every image and clip the store's documents name, and every file they link to, in the listing's order. */
54
+ export async function mediaUses(store: DraftsStore, site: Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>): Promise<MediaUses> {
55
+ const entries = await store.list();
56
+ const source = await store.source();
57
+ const texts = await Promise.all(entries.map((e) => source.readText(join(site.root, e.file)).catch(() => null)));
58
+ const images: MediaUses['images'] = new Map();
59
+ const clips: MediaUses['clips'] = new Map();
60
+ const files: MediaUses['files'] = new Map();
61
+ entries.forEach((entry, i) => {
62
+ const text = texts[i];
63
+ if (!text) return;
64
+ let raw: unknown;
65
+ try {
66
+ raw = JSON.parse(text);
67
+ } catch {
68
+ return;
69
+ }
70
+ const use: MediaUse = { collection: entry.collection, path: entry.path, title: entry.label };
71
+ const collection = site.collections[entry.collection];
72
+ if (collection) {
73
+ for (const link of fileLinksIn(text, site, collection)) {
74
+ let list = files.get(link.key);
75
+ if (!list) files.set(link.key, (list = []));
76
+ const mine = list.find((u) => u.collection === use.collection && u.path === use.path);
77
+ if (mine) mine.links++;
78
+ else list.push({ ...use, links: 1 });
79
+ }
80
+ }
81
+ const add = (list: MediaUse[]) => {
82
+ if (!list.some((u) => u.collection === use.collection && u.path === use.path)) list.push(use);
83
+ };
84
+ walk(raw, (value) => {
85
+ if (isImageValue(value)) {
86
+ const key = value.key.trim();
87
+ let found = images.get(key);
88
+ if (!found) images.set(key, (found = { usedIn: [], alt: null, focal: null }));
89
+ add(found.usedIn);
90
+ const alt = typeof value.alt === 'string' ? value.alt.trim() : '';
91
+ if (found.alt === null && alt && value.decorative !== true) found.alt = alt;
92
+ found.focal ??= parseFocal(value.focal) ?? null;
93
+ return true;
94
+ }
95
+ if (isClipValue(value)) {
96
+ const playbackId = value.video.trim();
97
+ let found = clips.get(playbackId);
98
+ if (!found) clips.set(playbackId, (found = []));
99
+ add(found);
100
+ return true;
101
+ }
102
+ return false;
103
+ });
104
+ });
105
+ return { images, clips, files };
106
+ }
107
+
108
+ /** Every object and array in `value`, depth first in document order; `visit` returning true stops the descent there. */
109
+ function walk(value: unknown, visit: (value: Record<string, unknown>) => boolean, depth = 0): void {
110
+ if (depth > 64 || !value || typeof value !== 'object') return;
111
+ if (Array.isArray(value)) {
112
+ for (const item of value) walk(item, visit, depth + 1);
113
+ return;
114
+ }
115
+ const object = value as Record<string, unknown>;
116
+ if (visit(object)) return;
117
+ for (const child of Object.values(object)) walk(child, visit, depth + 1);
118
+ }
119
+
120
+ /** The bucket's listing and metadata, cached as Decision 7 says. One per bucket, per process. */
121
+ export class Library {
122
+ private listing: { at: number; objects: Map<string, BucketObject> } | null = null;
123
+ private loading: Promise<Map<string, BucketObject>> | null = null;
124
+ private readonly meta = new Map<string, ObjectMeta>();
125
+
126
+ readonly bucket: Bucket;
127
+ private readonly now: () => number;
128
+
129
+ constructor(bucket: Bucket, now: () => number = () => Date.now()) {
130
+ this.bucket = bucket;
131
+ this.now = now;
132
+ }
133
+
134
+ /** The objects under `uploads/`: the bucket's listing, at most 60 s old, with this process's uploads in it. */
135
+ async objects(): Promise<BucketObject[]> {
136
+ if (!this.listing || this.now() - this.listing.at >= LISTING_TTL_MS) {
137
+ this.loading ??= this.bucket
138
+ .list(UPLOADS)
139
+ .then((list) => {
140
+ const objects = new Map(list.filter((o) => IMAGE_KEY.test(o.key) || isFileKey(o.key)).map((o) => [o.key, o]));
141
+ // An upload this process made while the listing was on its way stays in it.
142
+ for (const [key, o] of this.listing?.objects ?? []) if (!objects.has(key) && this.meta.has(key)) objects.set(key, o);
143
+ this.listing = { at: this.now(), objects };
144
+ return objects;
145
+ })
146
+ .finally(() => {
147
+ this.loading = null;
148
+ });
149
+ await this.loading;
150
+ }
151
+ return [...this.listing!.objects.values()];
152
+ }
153
+
154
+ /** An object's metadata: kept for the process once read (a key never changes). Null: no such object. */
155
+ async metaOf(key: string): Promise<ObjectMeta | null> {
156
+ const kept = this.meta.get(key);
157
+ if (kept) return kept;
158
+ const meta = await this.bucket.head(key);
159
+ if (meta) this.meta.set(key, meta);
160
+ return meta;
161
+ }
162
+
163
+ /** This process's own upload: in the listing and the metadata at once. */
164
+ added(key: string, meta: ObjectMeta): void {
165
+ this.meta.set(key, meta);
166
+ this.listing?.objects.set(key, { key, lastModified: meta.lastModified, size: meta.size });
167
+ }
168
+
169
+ /** The library's images, newest first, each with where it's used. */
170
+ async images(signing: MediaSettings, uses: MediaUses): Promise<LibraryImage[]> {
171
+ return (await this.read((key) => !isFileKey(key))).map(([key, meta]) => entryOf(signing, key, meta, uses));
172
+ }
173
+
174
+ /** M6c G4: the library's files, newest first, each with what links to it. */
175
+ async files(uses: MediaUses): Promise<LibraryFile[]> {
176
+ return (await this.read(isFileKey)).map(([key, meta]) => fileEntryOf(key, meta, uses));
177
+ }
178
+
179
+ /** The listing's objects that `which` keeps, with their metadata, newest first (one gone since the listing is left out). */
180
+ private async read(which: (key: string) => boolean): Promise<[string, ObjectMeta][]> {
181
+ const objects = (await this.objects()).filter((o) => which(o.key));
182
+ const metas = await mapLimit(objects, HEAD_CONCURRENCY, (o) => this.metaOf(o.key));
183
+ const out: [string, ObjectMeta][] = [];
184
+ objects.forEach((o, i) => {
185
+ const meta = metas[i];
186
+ if (meta === null) return; // gone between listing and reading: not shown
187
+ out.push([o.key, { ...meta!, lastModified: o.lastModified ?? meta!.lastModified, size: o.size ?? meta!.size }]);
188
+ });
189
+ return out.sort(([ak, a], [bk, b]) => (b.lastModified ?? '').localeCompare(a.lastModified ?? '') || ak.localeCompare(bk));
190
+ }
191
+ }
192
+
193
+ /** M6c G4: one file's library entry. */
194
+ export function fileEntryOf(key: string, meta: ObjectMeta, uses: MediaUses): LibraryFile {
195
+ return {
196
+ kind: 'file',
197
+ key,
198
+ name: meta.name || key.slice(key.lastIndexOf('/') + 1),
199
+ type: fileTypeOf(key)?.label ?? '',
200
+ bytes: meta.size,
201
+ uploadedAt: meta.lastModified,
202
+ by: meta.by || null,
203
+ usedIn: uses.files.get(key) ?? [],
204
+ };
205
+ }
206
+
207
+ /** One image's library entry. */
208
+ export function entryOf(signing: MediaSettings, key: string, meta: ObjectMeta, uses: MediaUses): LibraryImage {
209
+ const used = uses.images.get(key);
210
+ return {
211
+ kind: 'image',
212
+ key,
213
+ name: meta.name || key.slice(key.lastIndexOf('/') + 1),
214
+ w: meta.w,
215
+ h: meta.h,
216
+ bytes: meta.size,
217
+ uploadedAt: meta.lastModified,
218
+ by: meta.by || null,
219
+ thumbnail: thumbnailUrl(signing, key, 320),
220
+ thumbnail2x: thumbnailUrl(signing, key, 640),
221
+ usedIn: used?.usedIn ?? [],
222
+ alt: used?.alt ?? null,
223
+ focal: used?.focal ?? null,
224
+ };
225
+ }
226
+
227
+ async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
228
+ const out = new Array<R>(items.length);
229
+ let next = 0;
230
+ const worker = async () => {
231
+ while (next < items.length) {
232
+ const i = next++;
233
+ out[i] = await fn(items[i]!);
234
+ }
235
+ };
236
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
237
+ return out;
238
+ }
239
+
240
+ const SHARED = Symbol.for('caracms.mediaLibraries');
241
+ type Shared = { [SHARED]?: Map<string, Library> };
242
+
243
+ /** The process's library for a bucket (by `id`: `memory`, or the endpoint, bucket and access key). */
244
+ export function libraryFor(id: string, make: () => Bucket): Library {
245
+ const g = globalThis as Shared;
246
+ if (!g[SHARED]) {
247
+ g[SHARED] = new Map();
248
+ const libraries = g[SHARED];
249
+ onMediaReset(() => libraries.clear());
250
+ }
251
+ let library = g[SHARED].get(id);
252
+ if (!library) g[SHARED].set(id, (library = new Library(make())));
253
+ return library;
254
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `CARACMS_MEDIA=memory` (M5b Decision 5): an in-memory bucket and a fake Mux, one of
3
+ * each per process, so a test (or a browser test, whose dev server runs in the test's
4
+ * process) can seed them and look at what the routes did. **Refused on a production
5
+ * server** (config.ts), and **it always wins**: with it set, nothing touches the real
6
+ * bucket or Mux, whatever else is set.
7
+ *
8
+ * const media = memoryMedia();
9
+ * media.bucket.seed('uploads/3f9a1c07b2e4/harbour-wall.jpg', { w: 4000, h: 2667, name: 'Harbour wall.jpg', uploadedAt: '2026-10-16T09:00:00Z' });
10
+ * media.mux.seedClip({ playbackId: 'abc', title: 'Kiln', mp4: 'ready' }); // placeable
11
+ * media.mux.seedClip({ playbackId: 'def', mp4: 'preparing' }); // processing
12
+ * media.mux.seedClip({ status: 'errored', error: { type: 'invalid_input', messages: ['no video track'] } });
13
+ * media.reset(); // empty again, caches dropped
14
+ *
15
+ * The fake Mux is a `fetch` that answers Mux's API (`/video/v1/uploads`, `/assets`,
16
+ * `/playback-ids`), paging the list with `next_cursor`, so the real client (mux.ts) runs
17
+ * against it. A direct upload's `url` is `https://mux.test/upload/<id>`: a browser test
18
+ * routes that PUT itself, then calls `receive(id)` (the file arrived: an asset, preparing)
19
+ * and `ready(assetId)` (the asset and its MP4 are ready).
20
+ */
21
+ import { MemoryBucket } from './bucket.ts';
22
+ import { MUX_API } from './mux.ts';
23
+
24
+ type Json = any;
25
+
26
+ export interface SeedClip {
27
+ id?: string;
28
+ playbackId?: string | null;
29
+ /** Mux's asset status. Default `ready`. */
30
+ status?: 'preparing' | 'ready' | 'errored';
31
+ /** The `highest` MP4's status; `none` for an asset with no static rendition. Default `ready`. */
32
+ mp4?: 'ready' | 'preparing' | 'errored' | 'skipped' | 'none';
33
+ title?: string | null;
34
+ duration?: number;
35
+ w?: number;
36
+ h?: number;
37
+ /** ISO. Default now. */
38
+ createdAt?: string;
39
+ error?: { type?: string; messages?: string[] };
40
+ }
41
+
42
+ let counter = 0;
43
+ const id = (prefix: string) => `${prefix}${(++counter).toString(36).padStart(6, '0')}${Math.random().toString(36).slice(2, 10)}`.replace(/[^A-Za-z0-9]/g, '');
44
+
45
+ export class MemoryMux {
46
+ readonly assets = new Map<string, Json>();
47
+ readonly uploads = new Map<string, Json>();
48
+ /** Every request the fake answered: `{ method, path, body }` (the upload settings and `cors_origin` are checked from here). */
49
+ readonly requests: { method: string; path: string; body: Json }[] = [];
50
+ /** The next request answers this instead (a full plan, a refused token). */
51
+ failNext: { status: number; body: string } | null = null;
52
+ /** The list's page size (Mux's is up to 100), small in tests of the cursor. */
53
+ pageSize = 100;
54
+ now: () => number = () => Date.now();
55
+
56
+ seedClip(seed: SeedClip = {}): string {
57
+ const assetId = seed.id ?? id('asset');
58
+ const playbackId = seed.playbackId === undefined ? id('pb') : seed.playbackId;
59
+ const mp4 = seed.mp4 ?? 'ready';
60
+ this.assets.set(assetId, {
61
+ id: assetId,
62
+ status: seed.status ?? 'ready',
63
+ created_at: String(Math.floor((seed.createdAt ? Date.parse(seed.createdAt) : this.now()) / 1000)),
64
+ duration: seed.duration ?? 12.5,
65
+ aspect_ratio: '16:9',
66
+ playback_ids: playbackId ? [{ id: playbackId, policy: 'public' }] : [],
67
+ static_renditions: mp4 === 'none' ? undefined : { files: [{ resolution: 'highest', name: 'highest.mp4', status: mp4 }] },
68
+ tracks: [{ type: 'video', max_width: seed.w ?? 1920, max_height: seed.h ?? 1080 }],
69
+ meta: seed.title === null ? {} : { title: seed.title ?? 'A clip' },
70
+ ...(seed.error ? { errors: seed.error } : {}),
71
+ });
72
+ return assetId;
73
+ }
74
+
75
+ /** An upload's file has arrived: Mux makes its asset, preparing, with its MP4 to come. Returns the asset id. */
76
+ receive(uploadId: string, seed: Omit<SeedClip, 'id' | 'status' | 'mp4'> = {}): string {
77
+ const upload = this.uploads.get(uploadId);
78
+ if (!upload) throw new Error(`no upload ${uploadId}`);
79
+ const assetId = this.seedClip({ ...seed, title: seed.title ?? upload.new_asset_settings?.meta?.title ?? null, status: 'preparing', mp4: 'preparing' });
80
+ upload.status = 'asset_created';
81
+ upload.asset_id = assetId;
82
+ return assetId;
83
+ }
84
+
85
+ /** The asset and its MP4 are ready (or set any part: `{ status, mp4 }`). */
86
+ ready(assetId: string, to: { status?: SeedClip['status']; mp4?: Exclude<SeedClip['mp4'], 'none'> } = {}): void {
87
+ const asset = this.assets.get(assetId);
88
+ if (!asset) throw new Error(`no asset ${assetId}`);
89
+ asset.status = to.status ?? 'ready';
90
+ asset.static_renditions = { files: [{ resolution: 'highest', name: 'highest.mp4', status: to.mp4 ?? 'ready' }] };
91
+ }
92
+
93
+ clear(): void {
94
+ this.assets.clear();
95
+ this.uploads.clear();
96
+ this.requests.length = 0;
97
+ this.failNext = null;
98
+ this.pageSize = 100;
99
+ }
100
+
101
+ /** Mux's API, in memory: what the client (mux.ts) is given as its `fetch`. */
102
+ readonly fetch: typeof fetch = async (input, init) => {
103
+ const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
104
+ const method = (init?.method ?? 'GET').toUpperCase();
105
+ const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as Json) : null;
106
+ const path = url.pathname;
107
+ this.requests.push({ method, path: path + url.search, body });
108
+ const reply = (status: number, value: unknown) => new Response(typeof value === 'string' ? value : JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } });
109
+ if (url.origin !== MUX_API) return reply(404, { error: { type: 'not_found', messages: ['not Mux'] } });
110
+ if (this.failNext) {
111
+ const { status, body: text } = this.failNext;
112
+ this.failNext = null;
113
+ return reply(status, text);
114
+ }
115
+ const notFound = () => reply(404, { error: { type: 'not_found', messages: ['The requested resource does not exist.'] } });
116
+ let m: RegExpExecArray | null;
117
+ if (path === '/video/v1/uploads' && method === 'POST') {
118
+ const uploadId = id('up');
119
+ const upload = { id: uploadId, url: `https://mux.test/upload/${uploadId}`, status: 'waiting', cors_origin: body?.cors_origin, new_asset_settings: body?.new_asset_settings, asset_id: undefined as string | undefined };
120
+ this.uploads.set(uploadId, upload);
121
+ return reply(201, { data: upload });
122
+ }
123
+ if ((m = /^\/video\/v1\/uploads\/([^/]+)$/.exec(path)) && method === 'GET') {
124
+ const upload = this.uploads.get(m[1]!);
125
+ return upload ? reply(200, { data: upload }) : notFound();
126
+ }
127
+ if (path === '/video/v1/assets' && method === 'GET') {
128
+ const all = [...this.assets.values()];
129
+ const start = Number(url.searchParams.get('cursor') ?? '0') || 0;
130
+ const size = Math.min(this.pageSize, Number(url.searchParams.get('limit') ?? '100') || 100);
131
+ const page = all.slice(start, start + size);
132
+ const next = start + size < all.length ? String(start + size) : null;
133
+ return reply(200, { data: page, next_cursor: next });
134
+ }
135
+ if ((m = /^\/video\/v1\/assets\/([^/]+)$/.exec(path)) && method === 'GET') {
136
+ const asset = this.assets.get(m[1]!);
137
+ return asset ? reply(200, { data: asset }) : notFound();
138
+ }
139
+ if ((m = /^\/video\/v1\/playback-ids\/([^/]+)$/.exec(path)) && method === 'GET') {
140
+ for (const asset of this.assets.values()) {
141
+ if ((asset.playback_ids as Json[]).some((p) => p.id === m![1])) return reply(200, { data: { id: m[1], policy: 'public', object: { type: 'asset', id: asset.id } } });
142
+ }
143
+ // Mux's answer for an ID from another environment.
144
+ return reply(400, { error: { type: 'invalid_parameters', messages: ['Playback ID belongs to a mismatching environment'] } });
145
+ }
146
+ return notFound();
147
+ };
148
+ }
149
+
150
+ export interface MemoryMedia {
151
+ bucket: MemoryBucket;
152
+ mux: MemoryMux;
153
+ /** Empty both, and drop the library's caches (library.ts). */
154
+ reset(): void;
155
+ }
156
+
157
+ const SHARED = Symbol.for('caracms.memoryMedia');
158
+ const RESETS = Symbol.for('caracms.mediaCaches');
159
+ type Shared = { [SHARED]?: MemoryMedia; [RESETS]?: Set<() => void> };
160
+
161
+ /** Register a cache to drop on `reset()` (library.ts's listing and metadata caches). */
162
+ export function onMediaReset(fn: () => void): void {
163
+ const g = globalThis as Shared;
164
+ (g[RESETS] ??= new Set()).add(fn);
165
+ }
166
+
167
+ /** The process's in-memory bucket and fake Mux. */
168
+ export function memoryMedia(): MemoryMedia {
169
+ const g = globalThis as Shared;
170
+ if (!g[SHARED]) {
171
+ const bucket = new MemoryBucket();
172
+ const mux = new MemoryMux();
173
+ g[SHARED] = {
174
+ bucket,
175
+ mux,
176
+ reset() {
177
+ bucket.clear();
178
+ mux.clear();
179
+ for (const fn of g[RESETS] ?? []) fn();
180
+ },
181
+ };
182
+ }
183
+ return g[SHARED];
184
+ }