lecodes-sdk 2.0.7 → 2.0.9

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 (68) hide show
  1. package/dist/global.d.ts +6 -0
  2. package/dist/types/g2/Scene2D.d.ts +4 -0
  3. package/dist/types/gl/Light.d.ts +36 -1
  4. package/dist/types/gl/Scene.d.ts +4 -0
  5. package/dist/types/inject.d.ts +2 -1
  6. package/dist/types/runtime/rpc.d.ts +2 -0
  7. package/dist/types/server/context.d.ts +4 -0
  8. package/dist/types/server/db/defineDb.d.ts +1 -1
  9. package/dist/types/server/db/fields.d.ts +25 -1
  10. package/dist/types/server/db/marci/query.d.ts +39 -2
  11. package/dist/types/server/db/types.d.ts +13 -7
  12. package/dist/types/server/files/db.d.ts +39 -0
  13. package/dist/types/server/files/models.d.ts +131 -0
  14. package/dist/types/server/inject.d.ts +1 -0
  15. package/dist/types/ui/NativeView.d.ts +3 -0
  16. package/dist/types/ui/UI.d.ts +1 -0
  17. package/dist/types/ui/UILayer.d.ts +25 -0
  18. package/dist/types/ui/UIModal.d.ts +20 -16
  19. package/dist/types/ui/UIPopover.d.ts +5 -3
  20. package/dist/types/ui/UIVideo.d.ts +3 -0
  21. package/dist/types/ui/UIWidget.d.ts +25 -24
  22. package/dist/types/ui/presentable.d.ts +10 -0
  23. package/dist/types/ui/transitions.d.ts +2 -1
  24. package/dist/types/ui/tree.d.ts +1 -0
  25. package/dist/types/version.d.ts +1 -1
  26. package/dist/types.json +1 -1
  27. package/package.json +3 -2
  28. package/prompts/3d-scene-files.md +3 -3
  29. package/prompts/3d-scene.md +1 -1
  30. package/prompts/dist/3d-app.md +4 -4
  31. package/src/bridges/gl.d.ts +5 -0
  32. package/src/bridges/tree.d.ts +24 -12
  33. package/src/chisel.ts +1 -1
  34. package/src/compile/libraryImports.ts +9 -0
  35. package/src/compile/serverTypes.ts +6 -0
  36. package/src/g2/Scene2D.ts +10 -0
  37. package/src/gl/Light.ts +78 -2
  38. package/src/gl/Material.ts +3 -0
  39. package/src/gl/Scene.ts +11 -1
  40. package/src/inject.ts +2 -0
  41. package/src/runtime/rpc.ts +15 -3
  42. package/src/server/context.ts +4 -0
  43. package/src/server/db/defineDb.ts +62 -9
  44. package/src/server/db/fields.ts +21 -1
  45. package/src/server/db/httpTransport.ts +9 -1
  46. package/src/server/db/marci/query.ts +71 -4
  47. package/src/server/db/types.ts +16 -5
  48. package/src/server/files/db.ts +182 -0
  49. package/src/server/files/host.ts +496 -0
  50. package/src/server/files/models.ts +116 -0
  51. package/src/server/host.ts +17 -4
  52. package/src/server/inject.ts +1 -0
  53. package/src/server/runtime.ts +41 -0
  54. package/src/server/validate.ts +5 -0
  55. package/src/ui/NativeView.ts +11 -0
  56. package/src/ui/UI.ts +1 -0
  57. package/src/ui/UIBottomSheet.ts +8 -7
  58. package/src/ui/UILayer.ts +88 -0
  59. package/src/ui/UIModal.ts +59 -43
  60. package/src/ui/UINode.ts +9 -1
  61. package/src/ui/UIPopover.ts +23 -9
  62. package/src/ui/UIVideo.ts +11 -0
  63. package/src/ui/UIWidget.ts +70 -44
  64. package/src/ui/presentable.ts +11 -1
  65. package/src/ui/transitions.ts +36 -7
  66. package/src/ui/tree.ts +4 -0
  67. package/src/version.ts +1 -1
  68. package/tests/helpers/fakeTree.ts +7 -3
@@ -0,0 +1,496 @@
1
+ /**
2
+ * The HOST's half of stored files (./models.ts) — the same code on the runner and under `lecodes dev`.
3
+ * Never bundled into project code.
4
+ *
5
+ * The bytes of a file are one file on disk, `<dir>/<id>`, named by its `File` row. Three steps:
6
+ *
7
+ * receiveUploads the process that owns the socket reads a multipart endpoint call: the files go to
8
+ * `<dir>/tmp/<id>`, what is known of them travels to the worker in the request
9
+ * context (`uploads`), the arguments keep `{ "$file": <index> }` in their places
10
+ * createFilesHost the worker, around every endpoint call: `adopt` gives each upload its `File` row
11
+ * (ownerless) and moves the bytes into place — the endpoint gets a `File` it can
12
+ * write to a `t.file()` field; `finish` deletes the uploads the endpoint attached
13
+ * to nothing, then removes the bytes of every row the database let go of
14
+ * fileResponse `GET <base>/files/<id>/<name>`
15
+ *
16
+ * An upload written to a `t.image()` field is rewritten before its row is attached (`seam.image`):
17
+ * turned as its Exif says, its metadata dropped, no larger than the field's `max`, WebP. The codec is
18
+ * sharp, which the host process brings (`FilesHostConfig.sharp`) — this package does not depend on it.
19
+ * sharp's own builds read no HEIC (an iPhone's photo as it is stored): a host that also brings
20
+ * `heic-decode` (libheif as wasm, `FilesHostConfig.heic`) has those decoded by it, and sharp is handed
21
+ * the pixels.
22
+ *
23
+ * One rule decides when bytes are removed: the `File` row was deleted. However it went — a field was
24
+ * given another file, its row was deleted, a cascade took it — marcidb's journal of the model says so
25
+ * (`$journal`, deletes), and `finish` reads it. Rows are the truth; a start reconciles the folder with
26
+ * them once, for the case where the database itself was replaced under the files (a reset, a schema
27
+ * that could not be migrated).
28
+ */
29
+
30
+ import { createReadStream } from "node:fs"
31
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"
32
+ import { join } from "node:path"
33
+ import { Readable } from "node:stream"
34
+ import { currentRequest, type RequestContext } from "../context"
35
+ import type { MarciOp, MarciTransport } from "../db/marci/query"
36
+ import type { Db } from "../db/types"
37
+ import { FILE_MODEL, setFilesSeam, type FilesSeam, type ImageFacts, type ImageRule } from "./models"
38
+
39
+ /** What the receiving process knows of one uploaded file; its bytes are in `<dir>/tmp/<id>`. */
40
+ export type Upload = { id: string, name: string, type: string, size: number, width?: number, height?: number }
41
+
42
+ export type UploadLimits = {
43
+ /** One file, bytes (default 10 MB). */
44
+ maxFile?: number
45
+ /** Files in one call (default 10). */
46
+ maxFiles?: number
47
+ }
48
+
49
+ /** A refused upload: `status` is what the endpoint call answers with. */
50
+ export class UploadError extends Error {
51
+ readonly status: number
52
+ constructor(status: number, message: string) {
53
+ super(message)
54
+ // read by the invoker the way a project's own ApiError is
55
+ this.name = "ApiError"
56
+ this.status = status
57
+ }
58
+ }
59
+
60
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
61
+ const JOURNAL = "lecodes-files"
62
+ const MB = 1024 * 1024
63
+ const TMP_MAX_AGE_MS = 60 * 60 * 1000
64
+ /** The most pixels a picture may have to be decoded at all: a small file can claim a huge frame. */
65
+ const MAX_IMAGE_PIXELS = 50_000_000
66
+ const WEBP_QUALITY = 80
67
+
68
+ // ───────────────────────────── what a file is ─────────────────────────────
69
+
70
+ const ascii = (b: Uint8Array, at: number, text: string) => {
71
+ for (let i = 0; i < text.length; i++) if (b[at + i] !== text.charCodeAt(i)) return false
72
+ return true
73
+ }
74
+
75
+ /** The media type the BYTES say (their first ones are enough); null when they say nothing known. */
76
+ export const sniffType = (b: Uint8Array): string | null => {
77
+ if (b.length < 12) return null
78
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"
79
+ if (b[0] === 0x89 && ascii(b, 1, "PNG")) return "image/png"
80
+ if (ascii(b, 0, "GIF8")) return "image/gif"
81
+ if (ascii(b, 0, "RIFF") && ascii(b, 8, "WEBP")) return "image/webp"
82
+ if (ascii(b, 0, "RIFF") && ascii(b, 8, "WAVE")) return "audio/wav"
83
+ if (ascii(b, 4, "ftyp")) {
84
+ const brand = String.fromCharCode(b[8], b[9], b[10], b[11])
85
+ if (brand === "avif" || brand === "avis") return "image/avif"
86
+ if (brand.startsWith("hei") || brand === "mif1") return "image/heic"
87
+ if (brand === "M4A ") return "audio/mp4"
88
+ if (brand === "qt ") return "video/quicktime"
89
+ return "video/mp4"
90
+ }
91
+ if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return "video/webm"
92
+ if (ascii(b, 0, "OggS")) return "audio/ogg"
93
+ if (ascii(b, 0, "ID3") || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)) return "audio/mpeg"
94
+ if (ascii(b, 0, "%PDF")) return "application/pdf"
95
+ return null
96
+ }
97
+
98
+ /** Types a browser may be handed to SHOW; anything else is a download (no page runs from this origin). */
99
+ const INLINE = /^(image\/(jpeg|png|gif|webp|avif)|video\/|audio\/|application\/pdf$)/
100
+
101
+ const EXT: Record<string, string> = {
102
+ "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", "image/avif": "avif", "image/heic": "heic",
103
+ "video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm",
104
+ "audio/mpeg": "mp3", "audio/mp4": "m4a", "audio/wav": "wav", "audio/ogg": "ogg", "application/pdf": "pdf",
105
+ }
106
+
107
+ const u16 = (b: Uint8Array, at: number) => (b[at] << 8) | b[at + 1]
108
+ const u32 = (b: Uint8Array, at: number) => ((b[at] << 24) | (b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]) >>> 0
109
+ const le16 = (b: Uint8Array, at: number) => b[at] | (b[at + 1] << 8)
110
+ const le24 = (b: Uint8Array, at: number) => b[at] | (b[at + 1] << 8) | (b[at + 2] << 16)
111
+
112
+ /**
113
+ * The orientation a JPEG's Exif block names (1…8; 1 = as stored), 1 when it names none. A camera
114
+ * stores the sensor's frame and says how to turn it: 5…8 are the quarter turns, where the picture
115
+ * as it is SHOWN is as wide as the stored frame is tall. `at` is the APP1 segment's payload.
116
+ */
117
+ const exifOrientation = (b: Uint8Array, at: number, end: number): number => {
118
+ if (end - at < 14 || !ascii(b, at, "Exif")) return 1
119
+ const tiff = at + 6
120
+ const little = b[tiff] === 0x49
121
+ const r16 = (i: number) => little ? le16(b, i) : u16(b, i)
122
+ const r32 = (i: number) => little ? (b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24)) >>> 0 : u32(b, i)
123
+ const ifd = tiff + r32(tiff + 4)
124
+ if (ifd + 2 > end) return 1
125
+ const count = r16(ifd)
126
+ for (let i = 0; i < count; i++) {
127
+ const entry = ifd + 2 + i * 12
128
+ if (entry + 12 > end) break
129
+ if (r16(entry) === 0x0112) { const value = r16(entry + 8); return value >= 1 && value <= 8 ? value : 1 }
130
+ }
131
+ return 1
132
+ }
133
+
134
+ /** An image's pixel size AS IT IS SHOWN, read from its header (PNG, GIF, JPEG, WebP); null for anything else. */
135
+ export const imageSize = (b: Uint8Array, type: string | null): { width: number, height: number } | null => {
136
+ if (type === "image/png" && b.length >= 24) return { width: u32(b, 16), height: u32(b, 20) }
137
+ if (type === "image/gif" && b.length >= 10) return { width: le16(b, 6), height: le16(b, 8) }
138
+ if (type === "image/webp" && b.length >= 30) {
139
+ if (ascii(b, 12, "VP8X")) return { width: le24(b, 24) + 1, height: le24(b, 27) + 1 }
140
+ if (ascii(b, 12, "VP8L")) { const v = b[21] | (b[22] << 8) | (b[23] << 16) | (b[24] << 24); return { width: (v & 0x3fff) + 1, height: ((v >> 14) & 0x3fff) + 1 } }
141
+ if (ascii(b, 12, "VP8 ")) return { width: le16(b, 26) & 0x3fff, height: le16(b, 28) & 0x3fff }
142
+ }
143
+ if (type === "image/jpeg") {
144
+ // the frame header is the first SOFn marker; the segments before it are skipped by their lengths
145
+ let at = 2
146
+ let orientation = 1
147
+ while (at + 9 < b.length) {
148
+ if (b[at] !== 0xff) return null
149
+ const marker = b[at + 1]
150
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
151
+ const width = u16(b, at + 7), height = u16(b, at + 5)
152
+ // a quarter turn: every renderer shows the picture turned, so this is its size
153
+ return orientation >= 5 ? { width: height, height: width } : { width, height }
154
+ }
155
+ if (marker === 0xe1 && orientation === 1) orientation = exifOrientation(b, at + 4, Math.min(b.length, at + 2 + u16(b, at + 2)))
156
+ at += 2 + u16(b, at + 2)
157
+ }
158
+ }
159
+ return null
160
+ }
161
+
162
+ /** A name fit for a url's last segment; the extension follows what the bytes are. */
163
+ const cleanName = (raw: string, type: string | null): string => {
164
+ let name = raw.split(/[\\/]/).pop()!.replace(/[\u0000-\u001f\u007f"<>|:*?]/g, "").trim().slice(-120) || "file"
165
+ const ext = type ? EXT[type] : undefined
166
+ if (ext && !new RegExp(`\\.(${ext === "jpg" ? "jpe?g" : ext})$`, "i").test(name)) name = `${name.replace(/\.[A-Za-z0-9]{1,5}$/, "")}.${ext}`
167
+ return name
168
+ }
169
+
170
+ // ───────────────────────────── pictures ─────────────────────────────
171
+
172
+ /** The part of sharp this file uses (`import sharp from "sharp"` is one). */
173
+ export type Sharp = (input: Uint8Array, options?: { limitInputPixels?: number, raw?: { width: number, height: number, channels: 4 } }) => {
174
+ rotate(): ReturnType<Sharp>
175
+ resize(options: { width: number, height: number, fit: "inside", withoutEnlargement: boolean }): ReturnType<Sharp>
176
+ webp(options?: { quality?: number }): ReturnType<Sharp>
177
+ toBuffer(options: { resolveWithObject: true }): Promise<{ data: Uint8Array, info: { width: number, height: number, size: number } }>
178
+ }
179
+
180
+ /** The part of heic-decode this file uses (`import heic from "heic-decode"` is one). */
181
+ export type Heic = {
182
+ all(input: { buffer: Uint8Array }): Promise<{ width: number, height: number, decode(): Promise<{ width: number, height: number, data: Uint8ClampedArray }> }[] & { dispose(): void }>
183
+ }
184
+
185
+ /**
186
+ * `bytes` as a `t.image()` field keeps a picture: as it is SHOWN (the Exif turn applied to the
187
+ * pixels), nothing of its metadata, its longer side at most `rule.max`, WebP. Null for a GIF, which
188
+ * stays as it came (its frames). Throws `UploadError` for what is no picture, or one that cannot be read.
189
+ */
190
+ export const fitImage = async (sharp: Sharp, bytes: Uint8Array, rule: ImageRule, heic?: Heic | null): Promise<{ data: Uint8Array, width: number, height: number } | null> => {
191
+ const type = sniffType(bytes)
192
+ if (!type?.startsWith("image/")) throw new UploadError(400, "a picture is expected")
193
+ if (type === "image/gif") return null
194
+ try {
195
+ let source = sharp(bytes, { limitInputPixels: MAX_IMAGE_PIXELS })
196
+ if (type === "image/heic" && heic) {
197
+ // the file's first picture, as libheif shows it (it applies the turn the file names)
198
+ const pictures = await heic.all({ buffer: bytes })
199
+ try {
200
+ const [first] = pictures
201
+ if (first.width * first.height > MAX_IMAGE_PIXELS) throw new Error("too large")
202
+ const { width, height, data } = await first.decode()
203
+ source = sharp(new Uint8Array(data.buffer, data.byteOffset, data.byteLength), { raw: { width, height, channels: 4 } })
204
+ } finally {
205
+ pictures.dispose()
206
+ }
207
+ }
208
+ const { data, info } = await source
209
+ .rotate()
210
+ .resize({ width: rule.max, height: rule.max, fit: "inside", withoutEnlargement: true })
211
+ .webp({ quality: WEBP_QUALITY })
212
+ .toBuffer({ resolveWithObject: true })
213
+ return { data, width: info.width, height: info.height }
214
+ } catch {
215
+ throw new UploadError(400, type === "image/heic" ? "a HEIC picture cannot be read — send it as JPEG" : "the picture cannot be read")
216
+ }
217
+ }
218
+
219
+ // ───────────────────────────── receiving ─────────────────────────────
220
+
221
+ export const isMultipart = (req: Request) => /^multipart\/form-data/i.test(req.headers.get("content-type") ?? "")
222
+
223
+ /**
224
+ * Read a multipart endpoint call: the field `args` is the JSON body of a plain call (`{"args":[…]}`,
225
+ * a file's place taken by `{"$file": <index>}`), the fields `file0…` are the files. Throws
226
+ * `UploadError`; nothing is left on disk then.
227
+ */
228
+ export const receiveUploads = async (req: Request, dir: string, limits: UploadLimits = {}): Promise<{ args: unknown[], uploads: Upload[] }> => {
229
+ const maxFile = limits.maxFile ?? 10 * MB
230
+ const maxFiles = limits.maxFiles ?? 10
231
+ const tooBig = () => new UploadError(413, `a file is at most ${Math.round(maxFile / MB)} MB`)
232
+ if (Number(req.headers.get("content-length") ?? 0) > maxFile * maxFiles + MB) throw tooBig()
233
+
234
+ let form: Awaited<ReturnType<Request["formData"]>>
235
+ try { form = await req.formData() } catch { throw new UploadError(400, "the body is not multipart form data") }
236
+ let args: unknown
237
+ try { args = JSON.parse(String(form.get("args")))?.args } catch { args = null }
238
+ if (!Array.isArray(args)) throw new UploadError(400, "the field \"args\" must be {\"args\":[…]}")
239
+
240
+ const blobs: Blob[] = []
241
+ for (let i = 0; ; i++) {
242
+ const part = form.get(`file${i}`)
243
+ if (part === null) break
244
+ if (typeof part === "string") throw new UploadError(400, `file${i} is not a file`)
245
+ if (i >= maxFiles) throw new UploadError(413, `a call carries at most ${maxFiles} files`)
246
+ if (part.size > maxFile) throw tooBig()
247
+ blobs.push(part)
248
+ }
249
+
250
+ const tmp = join(dir, "tmp")
251
+ await mkdir(tmp, { recursive: true })
252
+ const uploads: Upload[] = []
253
+ try {
254
+ for (const blob of blobs) {
255
+ const bytes = new Uint8Array(await blob.arrayBuffer())
256
+ const sniffed = sniffType(bytes)
257
+ const size = imageSize(bytes, sniffed)
258
+ const upload: Upload = {
259
+ id: crypto.randomUUID(),
260
+ name: cleanName((blob as { name?: string }).name ?? "", sniffed),
261
+ // what the bytes are; for the rest, what the app said
262
+ type: sniffed ?? (blob.type && !/^(text\/html|image\/svg)/i.test(blob.type) ? blob.type : "application/octet-stream"),
263
+ size: bytes.length,
264
+ ...(size ?? {}),
265
+ }
266
+ await writeFile(join(tmp, upload.id), bytes)
267
+ uploads.push(upload)
268
+ }
269
+ } catch (e) {
270
+ await dropUploads(dir, uploads)
271
+ throw e
272
+ }
273
+ return { args, uploads }
274
+ }
275
+
276
+ /** Remove what `receiveUploads` left, for a call that never reached the worker. */
277
+ export const dropUploads = async (dir: string, uploads: Upload[]) => {
278
+ for (const u of uploads) await rm(join(dir, "tmp", u.id), { force: true })
279
+ }
280
+
281
+ // ───────────────────────────── serving ─────────────────────────────
282
+
283
+ /** `GET <base>/files/<id>/<name>` — `rest` is what follows `/files/`. */
284
+ export const fileResponse = async (dir: string, rest: string, req: Request, headers: Record<string, string> = {}): Promise<Response> => {
285
+ const id = rest.split("/")[0]
286
+ const path = join(dir, id)
287
+ const info = UUID.test(id) ? await stat(path).catch(() => null) : null
288
+ if (!info?.isFile()) return new Response("Not found", { status: 404, headers })
289
+
290
+ // the type is what the BYTES say — never the name in the url, which anyone can write
291
+ const head = new Uint8Array(64)
292
+ await new Promise<void>((resolve, reject) => {
293
+ let at = 0
294
+ createReadStream(path, { start: 0, end: 63 })
295
+ .on("data", (chunk) => { head.set(chunk as Uint8Array, at); at += chunk.length })
296
+ .on("end", resolve).on("error", reject)
297
+ })
298
+ const type = sniffType(head)
299
+ const inline = type !== null && INLINE.test(type)
300
+
301
+ const out: Record<string, string> = {
302
+ ...headers,
303
+ "content-type": inline ? type : "application/octet-stream",
304
+ "x-content-type-options": "nosniff",
305
+ // an id is one upload: its bytes never change
306
+ "cache-control": "public, max-age=31536000, immutable",
307
+ "accept-ranges": "bytes",
308
+ ...(inline ? {} : { "content-disposition": "attachment" }),
309
+ }
310
+ let start = 0, end = info.size - 1, status = 200
311
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get("range") ?? "")
312
+ if (range && (range[1] || range[2])) {
313
+ if (range[1]) { start = Number(range[1]); if (range[2]) end = Math.min(end, Number(range[2])) }
314
+ else start = Math.max(0, info.size - Number(range[2]))
315
+ if (start > end) return new Response(null, { status: 416, headers: { ...headers, "content-range": `bytes */${info.size}` } })
316
+ status = 206
317
+ out["content-range"] = `bytes ${start}-${end}/${info.size}`
318
+ }
319
+ out["content-length"] = String(end - start + 1)
320
+ if (req.method === "HEAD" || info.size === 0) return new Response(null, { status, headers: out })
321
+ return new Response(Readable.toWeb(createReadStream(path, { start, end })) as unknown as ReadableStream, { status, headers: out })
322
+ }
323
+
324
+ // ───────────────────────────── the worker ─────────────────────────────
325
+
326
+ export type FilesHostConfig = {
327
+ /** The project's db (the one with `t.file()` fields, when it has any). */
328
+ db: Db<any> | null
329
+ /** Where this database's files are. */
330
+ dir: string
331
+ /** The bytes all files of the database may take together (absent = no limit). */
332
+ quota?: number
333
+ /** The codec of `t.image()` fields, asked for at the first picture (`() => import("sharp")`'s default). */
334
+ sharp?: () => Promise<Sharp | null>
335
+ /** The reader of HEIC pictures, asked for at the first of them (`() => import("heic-decode")`'s default). */
336
+ heic?: () => Promise<Heic | null>
337
+ log?: (level: "info" | "warn" | "error", text: string) => void
338
+ }
339
+
340
+ export type FilesHost = {
341
+ /** The project has `t.file()` fields. */
342
+ readonly enabled: boolean
343
+ /** Give the bundle's db layer its seam (once, after the bundle is loaded). */
344
+ install(): void
345
+ /** The arguments with every `{ "$file": i }` turned into the `File` the endpoint receives. */
346
+ adopt(args: unknown[], rc: RequestContext): Promise<unknown[]>
347
+ /** After the call, whatever it ended with. */
348
+ finish(rc: RequestContext): Promise<void>
349
+ }
350
+
351
+ const isPlaceholder = (v: unknown): v is { $file: number } =>
352
+ typeof v === "object" && v !== null && typeof (v as { $file?: unknown }).$file === "number" && Object.keys(v).length === 1
353
+
354
+ const hasPlaceholder = (v: unknown): boolean =>
355
+ isPlaceholder(v) || (Array.isArray(v) ? v.some(hasPlaceholder) : typeof v === "object" && v !== null && Object.values(v).some(hasPlaceholder))
356
+
357
+ export const createFilesHost = (cfg: FilesHostConfig): FilesHost => {
358
+ const fields = cfg.db?.$files ?? []
359
+ const enabled = fields.length > 0
360
+ const log = cfg.log ?? (() => {})
361
+ const transport = (): MarciTransport => {
362
+ const t = (globalThis as unknown as { __lecodesDbTransport?: MarciTransport }).__lecodesDbTransport
363
+ if (!t) throw new Error("files: no db transport")
364
+ return t
365
+ }
366
+ const exec = (op: Omit<MarciOp, "model">) => transport().exec({ model: FILE_MODEL, ...op })
367
+ /** No field of any row holds it. */
368
+ const ownerless = Object.fromEntries(fields.map(f => [f.owner, null]))
369
+
370
+ /** The `File` objects of the calls in flight → their rows. */
371
+ const uploaded = new WeakMap<object, string>()
372
+ const inFlight = new WeakMap<RequestContext, string[]>()
373
+ /** What the uploads of the calls in flight are called (an image field renames: `cat.jpg` → `cat.webp`). */
374
+ const names = new Map<string, string>()
375
+ let sharp: Promise<Sharp | null> | null = null
376
+ let heic: Promise<Heic | null> | null = null
377
+ const image = async (id: string, rule: ImageRule): Promise<ImageFacts> => {
378
+ const codec = await (sharp ??= cfg.sharp?.() ?? Promise.resolve(null))
379
+ if (!codec) throw new Error("files: a t.image() field needs sharp, and this host has none")
380
+ const path = join(cfg.dir, id)
381
+ const bytes = new Uint8Array(await readFile(path))
382
+ const fit = await fitImage(codec, bytes, rule, sniffType(bytes) === "image/heic" ? await (heic ??= cfg.heic?.().catch(() => null) ?? Promise.resolve(null)) : null)
383
+ if (!fit) {
384
+ const size = imageSize(bytes, "image/gif")
385
+ if (!size) throw new UploadError(400, "the picture cannot be read")
386
+ return { name: cleanName(names.get(id) ?? "", "image/gif"), type: "image/gif", size: bytes.length, ...size }
387
+ }
388
+ // beside, then over: whoever reads the file sees one picture or the other, whole
389
+ await writeFile(join(cfg.dir, "tmp", id), fit.data)
390
+ await rename(join(cfg.dir, "tmp", id), path)
391
+ return { name: cleanName(names.get(id) ?? "", "image/webp"), type: "image/webp", size: fit.data.length, width: fit.width, height: fit.height }
392
+ }
393
+ const seam: FilesSeam = {
394
+ uploaded: (value) => typeof value === "object" && value !== null ? uploaded.get(value) : undefined,
395
+ url: (id, name) => `${currentRequest()?.filesBase ?? ""}/files/${id}/${encodeURIComponent(name)}`,
396
+ image,
397
+ dirty: false,
398
+ }
399
+
400
+ // The bytes of the rows the database let go of. One reader at a time: a read confirms the entries
401
+ // of the read before it.
402
+ let settling: Promise<void> = Promise.resolve()
403
+ const settle = () => (settling = settling.catch(() => {}).then(async () => {
404
+ let after: number | undefined
405
+ for (;;) {
406
+ const entries: { seq: number, row: { id: string } }[] = await exec({ action: "$journalRead", journal: { name: JOURNAL, after, limit: 100 } })
407
+ if (!entries.length) return
408
+ for (const entry of entries) {
409
+ await rm(join(cfg.dir, entry.row.id), { force: true })
410
+ after = entry.seq
411
+ }
412
+ }
413
+ }))
414
+
415
+ // Once, before the first call that has to do with files — not at load: a deploy loads the bundle
416
+ // before the database has its schema.
417
+ let ready: Promise<void> | null = null
418
+ const start = () => (ready ??= (async () => {
419
+ await mkdir(join(cfg.dir, "tmp"), { recursive: true })
420
+ await exec({ action: "$journalOpen", journal: { name: JOURNAL, on: ["delete"] } })
421
+ // uploads a crashed process attached to nothing (the ones of a call still running are younger)
422
+ await exec({ action: "deleteMany", query: { $where: { ...ownerless, createdAt: { $lt: Date.now() - TMP_MAX_AGE_MS } } } })
423
+ await settle()
424
+ // …and the folder against the rows: the database may be another one than the files were stored under
425
+ const rows: { id: string }[] = await exec({ action: "findMany", query: { id: true } })
426
+ const known = new Set(rows.map(r => r.id))
427
+ for (const name of await readdir(cfg.dir)) {
428
+ if (UUID.test(name) && !known.has(name)) await rm(join(cfg.dir, name), { force: true })
429
+ }
430
+ for (const name of await readdir(join(cfg.dir, "tmp"))) {
431
+ const path = join(cfg.dir, "tmp", name)
432
+ const info = await stat(path).catch(() => null)
433
+ if (info && Date.now() - info.mtimeMs > TMP_MAX_AGE_MS) await rm(path, { force: true })
434
+ }
435
+ })().catch((e) => { ready = null; throw e }))
436
+
437
+ return {
438
+ enabled,
439
+ install: () => setFilesSeam(enabled ? seam : null),
440
+
441
+ adopt: async (args, rc) => {
442
+ const uploads = (rc.uploads ?? []) as Upload[]
443
+ if (!uploads.length && !hasPlaceholder(args)) return args
444
+ try {
445
+ if (!enabled) throw new UploadError(400, "this project stores no files — a model needs a t.file() field")
446
+ await start()
447
+ if (cfg.quota !== undefined && uploads.length) {
448
+ const used = (await exec({ action: "aggregate", query: { $sum: "size" } }))?.sum ?? 0
449
+ if (used + uploads.reduce((n, u) => n + u.size, 0) > cfg.quota) throw new UploadError(413, "the project's file storage is full")
450
+ }
451
+ // the rows first, then the bytes into place: a file on disk always has its row
452
+ if (uploads.length) {
453
+ await transport().batch(uploads.map(u => ({
454
+ model: FILE_MODEL, action: "insert",
455
+ data: { id: u.id, name: u.name, type: u.type, size: u.size, ...(u.width ? { width: u.width, height: u.height } : {}) },
456
+ })))
457
+ inFlight.set(rc, uploads.map(u => u.id))
458
+ for (const u of uploads) names.set(u.id, u.name)
459
+ for (const u of uploads) await rename(join(cfg.dir, "tmp", u.id), join(cfg.dir, u.id))
460
+ }
461
+ const files = uploads.map((u) => {
462
+ const file = Object.freeze({ name: u.name, size: u.size, type: u.type })
463
+ uploaded.set(file, u.id)
464
+ return file
465
+ })
466
+ const place = (v: unknown): unknown => {
467
+ if (isPlaceholder(v)) return files[v.$file] ?? (() => { throw new UploadError(400, `the call names file ${v.$file}, and carries ${files.length}`) })()
468
+ if (Array.isArray(v)) return v.map(place)
469
+ if (typeof v === "object" && v !== null) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, place(x)]))
470
+ return v
471
+ }
472
+ return place(args) as unknown[]
473
+ } catch (e) {
474
+ if (!inFlight.has(rc)) await dropUploads(cfg.dir, uploads)
475
+ throw e
476
+ }
477
+ },
478
+
479
+ finish: async (rc) => {
480
+ if (!enabled) return
481
+ const ids = inFlight.get(rc)
482
+ if (!ids && !seam.dirty) return
483
+ try {
484
+ inFlight.delete(rc)
485
+ for (const id of ids ?? []) names.delete(id)
486
+ // an upload the endpoint wrote to no field
487
+ if (ids) await exec({ action: "deleteMany", query: { $where: { ...ownerless, id: { $in: ids } } } })
488
+ seam.dirty = false
489
+ await start()
490
+ await settle()
491
+ } catch (e) {
492
+ log("error", `files: ${(e as Error)?.message ?? e}`)
493
+ }
494
+ },
495
+ }
496
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Stored files: `t.file()` on a model.
3
+ *
4
+ * A project never declares where a file lives. A model that has a `t.file()` field makes the db carry
5
+ * one more model, the PLATFORM's `File` — a row per stored file: what it is (name, type, size, an
6
+ * image's dimensions) and whose it is. The owner is a reference FROM the file TO its row, with
7
+ * `onDelete("cascade")`, one optional reference per `t.file()` field of the schema (`postImage`,
8
+ * `userAvatar`): deleting a post — by name, or because its author was deleted — deletes its files in
9
+ * the same transaction, by the database's own rule. The field on the owner is the reverse side of
10
+ * that reference.
11
+ *
12
+ * The bytes are the host's (./host.ts): a file on disk named by the row's id. They go only because
13
+ * the row went — the host reads the deletions from marcidb's journal of this model.
14
+ *
15
+ * What a project sees is a VALUE: it writes a `File` (an upload, an endpoint's parameter) and reads a
16
+ * `StoredFile`. The url is computed when the row is read, never stored.
17
+ */
18
+
19
+ import { t, type Field } from "../db/fields"
20
+
21
+ export const FILE_MODEL = "File"
22
+
23
+ /** A stored file as a project reads it. */
24
+ export type StoredFile = {
25
+ /** Where the bytes are served from. Public, not guessable; it never changes for this file. */
26
+ url: string
27
+ name: string
28
+ /** The media type: `image/jpeg`, `video/mp4`, `application/pdf`. */
29
+ type: string
30
+ /** Bytes. */
31
+ size: number
32
+ /** An image's dimensions in pixels (absent for anything else). */
33
+ width?: number
34
+ height?: number
35
+ }
36
+
37
+ /** A stored picture (`t.image()`): its size is always known. */
38
+ export type StoredImage = StoredFile & { width: number, height: number }
39
+
40
+ /**
41
+ * A file as an endpoint receives it — the app's `File` (a picked file, a photo), uploaded with the
42
+ * call. Written to a `t.file()` field, it is stored; an endpoint that writes it nowhere drops it.
43
+ */
44
+ export type UploadedFile = { readonly name: string, readonly size: number, readonly type: string }
45
+
46
+ /** One `t.file()` field of the schema; `image` = it is a `t.image()`, with its rule. */
47
+ export type FileField = { model: string, key: string, array: boolean, owner: string, image?: ImageRule }
48
+
49
+ export type ImageRule = { max: number }
50
+ /** What a file became when it was made fit for an image field: the facts its row carries from then on. */
51
+ export type ImageFacts = { name: string, type: string, size: number, width: number, height: number }
52
+
53
+ const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
54
+ const uncap = (s: string) => s.charAt(0).toLowerCase() + s.slice(1)
55
+ /** The reference on `File` that says "I am this field of that row": `Post.image` → `postImage`. */
56
+ export const fileOwnerKey = (model: string, key: string) => `${uncap(model)}${cap(key)}`
57
+
58
+ /** What a data browser may show of a file (the rest is the platform's bookkeeping). */
59
+ export const filePublicFields = () => ({
60
+ name: t.string(),
61
+ type: t.string(),
62
+ size: t.int(),
63
+ width: t.int().optional(),
64
+ height: t.int().optional(),
65
+ createdAt: t.date().default("now"),
66
+ })
67
+
68
+ /** The whole `File` of a schema with these `t.file()` fields. */
69
+ export const fileFields = (fields: FileField[]): Record<string, Field<any, any>> => {
70
+ const out: Record<string, Field<any, any>> = {
71
+ /** Also the name of the bytes on disk and the id in the url. */
72
+ id: t.uuid(),
73
+ ...filePublicFields(),
74
+ /** The place in a `t.file().array()` field. */
75
+ position: t.int().default(0),
76
+ }
77
+ for (const f of fields) {
78
+ if (f.owner in out) throw new Error(`defineDb: ${f.model}.${f.key} — the name "${f.owner}" is taken on the platform's File model; name the field differently`)
79
+ out[f.owner] = t.one(f.model).optional().onDelete("cascade")
80
+ }
81
+ return out
82
+ }
83
+
84
+ // ───────────────────────────── the host's seam ─────────────────────────────
85
+
86
+ /**
87
+ * What the host (./host.ts) gives the db layer of a bundle — a global, because the bundle carries its
88
+ * own copy of this module. Absent (a unit test without a host): nothing is an upload, urls are relative.
89
+ */
90
+ export type FilesSeam = {
91
+ /** The id of the `File` row behind an upload of the current request; undefined for anything else —
92
+ * a value the app sent as JSON is never an upload, whatever it looks like. */
93
+ uploaded(value: unknown): string | undefined
94
+ url(id: string, name: string): string
95
+ /** Make the upload `id` fit for a `t.image()` field (its bytes are rewritten in place) and say what
96
+ * it is now. Throws an `ApiError` for what is no picture. */
97
+ image(id: string, rule: ImageRule): Promise<ImageFacts>
98
+ /** A write that may have removed rows of `File` went through the db (a delete, a rewritten file field): their bytes are due. */
99
+ dirty: boolean
100
+ }
101
+ const seam = globalThis as unknown as { __lecodesFiles?: FilesSeam }
102
+ export const filesSeam = (): FilesSeam | undefined => seam.__lecodesFiles
103
+ export const setFilesSeam = (value: FilesSeam | null) => {
104
+ if (value) seam.__lecodesFiles = value
105
+ else delete seam.__lecodesFiles
106
+ }
107
+
108
+ const URL_ID = /\/files\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\/|$)/
109
+ /** The row a `StoredFile` the app handed back stands for (by its url); undefined for anything else. */
110
+ export const storedFileId = (value: unknown): string | undefined => {
111
+ const url = (value as { url?: unknown } | null)?.url
112
+ return typeof url === "string" ? URL_ID.exec(url)?.[1] : undefined
113
+ }
114
+
115
+ export const fileUrl = (id: string, name: string): string =>
116
+ seam.__lecodesFiles?.url(id, name) ?? `/files/${id}/${encodeURIComponent(name)}`
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  export {
9
- loadServerModules, createInvoker, createSubscribeGuard, setChannelPublisher, setRequestProvider,
9
+ loadServerModules, createInvoker, createFileWriter, createSubscribeGuard, setChannelPublisher, setRequestProvider,
10
10
  } from "./runtime"
11
11
  export type { LoadedServer, InvokeResult, InvokerOptions, ServerRegistration } from "./runtime"
12
12
  export type { RequestContext } from "./context"
@@ -25,6 +25,10 @@ export type { AuthHost, AuthHostConfig, AuthMail } from "./auth/host"
25
25
  export type { AuthState, AuthOps } from "./auth/types"
26
26
  export type { AppConfig } from "./auth/appConfig"
27
27
  export type { IdentityProvider } from "./auth/models"
28
+ export { createFilesHost, receiveUploads, dropUploads, fileResponse, isMultipart, UploadError } from "./files/host"
29
+ export type { FilesHost, FilesHostConfig, Heic, Sharp, Upload, UploadLimits } from "./files/host"
30
+ export type { StoredFile, StoredImage } from "./files/models"
31
+ import { FILE_MODEL, filePublicFields } from "./files/models"
28
32
  import { AUTH_IDENTITY_MODEL, AUTH_SESSION_MODEL, identityPublicFields, sessionPublicFields } from "./auth/models"
29
33
 
30
34
  import type { LoadedServer } from "./runtime"
@@ -42,8 +46,10 @@ export type ServerDescription = {
42
46
  marci: string
43
47
  /** `auth: true` = one of the platform's two models (`Session`, `Identity` — they come with
44
48
  * `withAuth`), described WITHOUT its secrets (token and password hashes, a pending code): what a
45
- * data browser may show, read-only. */
46
- models: Record<string, { fields: Record<string, FieldDescription>, auth?: true }>
49
+ * data browser may show, read-only. `files: true` = the platform's `File` (it comes with a
50
+ * `t.file()` field): what each stored file is, read-only too — a file is written through its field.
51
+ * A `t.file()` field itself is described as `kind: "file"`. */
52
+ models: Record<string, { fields: Record<string, FieldDescription>, auth?: true, files?: true }>
47
53
  endpoints: { id: string, params: unknown[] | null }[]
48
54
  channels: string[]
49
55
  /** The project's model of a user (`null` = its db has no sign-in). */
@@ -72,9 +78,16 @@ export const describeServer = (server: LoadedServer): ServerDescription => {
72
78
  [AUTH_SESSION_MODEL]: [...Object.keys(sessionPublicFields()), "user"],
73
79
  [AUTH_IDENTITY_MODEL]: [...Object.keys(identityPublicFields()), "user"],
74
80
  } : {}
81
+ const files = db.$files
82
+ if (files.length) shown[FILE_MODEL] = ["id", ...Object.keys(filePublicFields())]
75
83
  for (const [name, m] of Object.entries(db.$models as Record<string, Model>)) {
76
84
  const fields = Object.entries(m.fields as Record<string, Field<any, any>>).filter(([k]) => !shown[name] || shown[name].includes(k))
77
- models[name] = { fields: Object.fromEntries(fields.map(([k, f]) => [k, describeField(f)])), ...(shown[name] ? { auth: true as const } : {}) }
85
+ const described = Object.fromEntries(fields.map(([k, f]) => [k, describeField(f)]))
86
+ // a file field is, in the database, a relation to File: what a project declared is said here
87
+ for (const f of files) {
88
+ if (f.model === name) described[f.key] = { kind: "file", optional: false, unique: false, index: false, hasDefault: false, array: f.array, isId: false }
89
+ }
90
+ models[name] = { fields: described, ...(name === FILE_MODEL ? { files: true as const } : shown[name] ? { auth: true as const } : {}) }
78
91
  }
79
92
  }
80
93
  const params = (server.manifest.params ?? {}) as Record<string, unknown[]>
@@ -11,3 +11,4 @@ export { t, model, defineDb, ref } from "./db"
11
11
  export { ApiError } from "./errors"
12
12
  export { channel } from "./channel"
13
13
  export { request } from "./context"
14
+ export type { StoredFile, StoredImage } from "./files/models"