fms-odata-js 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,457 @@
1
+ /**
2
+ * FileMaker container field I/O.
3
+ *
4
+ * FMS exposes three container operations over OData v4 — see
5
+ * `docs/filemaker-odata-container-guide.md` for the full reference. The
6
+ * library maps to them as follows:
7
+ *
8
+ * GET /<EntitySet>(<key>)/<field>/$value → ContainerRef#get / getStream
9
+ * PATCH /<EntitySet>(<key>)/<field> → ContainerRef#upload({ encoding: 'binary' }) — DEFAULT
10
+ * PATCH /<EntitySet>(<key>) (JSON body) → ContainerRef#upload({ encoding: 'base64' })
11
+ * POST /<EntitySet> (JSON body) → Query#create({ … }) with container annotation keys
12
+ *
13
+ * Supported MIME types per the Claris guide:
14
+ * image/png, image/jpeg, image/gif, image/tiff, application/pdf
15
+ *
16
+ * Clearing a container is not in the Claris guide. The library PATCHes the
17
+ * record with `{ <field>: null }`.
18
+ */
19
+
20
+ import type { EntityRef } from './entity.js'
21
+ import { executeRequest } from './http.js'
22
+ import type { RequestOptions } from './types.js'
23
+ import { encodePathSegment } from './url.js'
24
+
25
+ /** Result of a successful container download. */
26
+ export interface ContainerDownload {
27
+ /** Binary contents as a `Blob` (works in browsers, Web Viewer, and Node 18+). */
28
+ blob: Blob
29
+ /** MIME type reported by FMS (e.g. `image/png`); empty string if absent. */
30
+ contentType: string
31
+ /** Filename parsed from `Content-Disposition`, when present. */
32
+ filename?: string
33
+ /** Byte length. `0` indicates an empty container. */
34
+ size: number
35
+ }
36
+
37
+ /** Input accepted by `ContainerRef#upload`. */
38
+ export interface ContainerUploadInput {
39
+ /** Binary payload. `fetch` accepts all three forms natively. */
40
+ data: Blob | ArrayBuffer | Uint8Array
41
+ /**
42
+ * Content-Type to send with the upload. Optional: when omitted, the library
43
+ * sniffs the MIME from the payload's magic bytes (PNG, JPEG, GIF, TIFF, PDF).
44
+ * Throws if it cannot detect a supported type.
45
+ */
46
+ contentType?: string
47
+ /**
48
+ * Optional filename to store in the container. Surfaced on download via
49
+ * `Content-Disposition: attachment; filename="…"`. When omitted, FMS stores
50
+ * (and surfaces back) the auto-generated name `Untitled.png`.
51
+ */
52
+ filename?: string
53
+ /**
54
+ * Wire format:
55
+ *
56
+ * - `'binary'` (default): PATCH `…/<field>` with raw bytes. Restricted to
57
+ * PNG, JPEG, GIF, TIFF, and PDF.
58
+ * - `'base64'`: PATCH `…/<EntitySet>(<key>)` with a JSON body containing
59
+ * the file as a base64 string plus `@com.filemaker.odata.…` annotations.
60
+ * Use this when you need to update multiple container fields (or mix
61
+ * container and regular field updates) in a single request.
62
+ */
63
+ encoding?: 'binary' | 'base64'
64
+ }
65
+
66
+ /**
67
+ * MIME types FMS accepts for container fields. Other types (audio, video,
68
+ * zip, docx, …) may upload but won't be classified as a media type and won't
69
+ * preview correctly in FileMaker.
70
+ */
71
+ export const FM_CONTAINER_SUPPORTED_MIME_TYPES = [
72
+ 'image/png',
73
+ 'image/jpeg',
74
+ 'image/gif',
75
+ 'image/tiff',
76
+ 'application/pdf',
77
+ ] as const
78
+
79
+ export type FMContainerMimeType = (typeof FM_CONTAINER_SUPPORTED_MIME_TYPES)[number]
80
+
81
+ /** @internal — strict mime check (case-insensitive, ignores parameters). */
82
+ function normalizeMime(value: string): string {
83
+ return (value.split(';')[0] ?? '').trim().toLowerCase()
84
+ }
85
+
86
+ function isSupportedContainerMime(value: string): boolean {
87
+ const normalized = normalizeMime(value)
88
+ return (FM_CONTAINER_SUPPORTED_MIME_TYPES as readonly string[]).includes(normalized)
89
+ }
90
+
91
+ /**
92
+ * Sniff the MIME type from the magic bytes of an uploaded payload. Returns
93
+ * `undefined` when none of the supported signatures match.
94
+ *
95
+ * Signatures:
96
+ * PNG — 89 50 4E 47 0D 0A 1A 0A
97
+ * JPEG — FF D8 FF
98
+ * GIF — 47 49 46 38 (`GIF8`)
99
+ * TIFF — 49 49 2A 00 (little-endian) | 4D 4D 00 2A (big-endian)
100
+ * PDF — 25 50 44 46 (`%PDF`)
101
+ *
102
+ * @internal
103
+ */
104
+ export function sniffContainerMime(bytes: Uint8Array): FMContainerMimeType | undefined {
105
+ if (bytes.length >= 8 &&
106
+ bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
107
+ bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) {
108
+ return 'image/png'
109
+ }
110
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
111
+ return 'image/jpeg'
112
+ }
113
+ if (bytes.length >= 4 &&
114
+ bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
115
+ return 'image/gif'
116
+ }
117
+ if (bytes.length >= 4 &&
118
+ ((bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) ||
119
+ (bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a))) {
120
+ return 'image/tiff'
121
+ }
122
+ if (bytes.length >= 4 &&
123
+ bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {
124
+ return 'application/pdf'
125
+ }
126
+ return undefined
127
+ }
128
+
129
+ /**
130
+ * Handle to a single FileMaker container field on a specific record. Created
131
+ * via `EntityRef#container(fieldName)`.
132
+ */
133
+ export class ContainerRef {
134
+ /** @internal */ readonly _entity: EntityRef<unknown>
135
+ readonly fieldName: string
136
+
137
+ constructor(entity: EntityRef<unknown>, fieldName: string) {
138
+ if (!fieldName) {
139
+ throw new TypeError('ContainerRef: fieldName is required')
140
+ }
141
+ this._entity = entity
142
+ this.fieldName = fieldName
143
+ }
144
+
145
+ /**
146
+ * Absolute URL of the container field itself
147
+ * (`…/<EntitySet>(<key>)/<fieldName>`). This is the URL used by binary
148
+ * `upload()`. Append `/$value` to download.
149
+ */
150
+ url(): string {
151
+ return `${this._entity.toURL()}/${encodePathSegment(this.fieldName)}`
152
+ }
153
+
154
+ /** @internal — `…/<field>/$value` for downloads. */
155
+ private _valueUrl(): string {
156
+ return `${this.url()}/$value`
157
+ }
158
+
159
+ /**
160
+ * Download the container's contents and buffer them into a `Blob`. For
161
+ * very large payloads prefer `getStream()` to avoid buffering in memory.
162
+ */
163
+ async get(opts: RequestOptions = {}): Promise<ContainerDownload> {
164
+ const res = await executeRequest(this._entity._client._ctx, this._valueUrl(), {
165
+ method: 'GET',
166
+ // FMS quirk: `Accept: application/octet-stream` makes `$value` return the
167
+ // stored filename string as `text/plain` instead of the binary. Use the
168
+ // wildcard so FMS returns the actual bytes with a sniffed Content-Type.
169
+ // See `docs/filemaker-quirks.md`.
170
+ accept: 'none',
171
+ ...(opts.signal ? { signal: opts.signal } : {}),
172
+ })
173
+
174
+ const contentType = res.headers.get('content-type') ?? ''
175
+ const disposition = res.headers.get('content-disposition')
176
+ const filename = disposition ? parseContentDispositionFilename(disposition) : undefined
177
+
178
+ const blob = await res.blob()
179
+ const out: ContainerDownload = {
180
+ blob,
181
+ contentType,
182
+ size: blob.size,
183
+ }
184
+ if (filename !== undefined) out.filename = filename
185
+ return out
186
+ }
187
+
188
+ /**
189
+ * Stream the container's contents without buffering. Useful for large files
190
+ * (`pipeTo()` into a writable, forward to another `Response`, etc.).
191
+ *
192
+ * Throws if the underlying `Response` has no body.
193
+ */
194
+ async getStream(opts: RequestOptions = {}): Promise<ReadableStream<Uint8Array>> {
195
+ const res = await executeRequest(this._entity._client._ctx, this._valueUrl(), {
196
+ method: 'GET',
197
+ // See note in `get()` — Accept: */* avoids the FMS `octet-stream` quirk.
198
+ accept: 'none',
199
+ ...(opts.signal ? { signal: opts.signal } : {}),
200
+ })
201
+ if (!res.body) {
202
+ throw new TypeError('ContainerRef.getStream: response has no body')
203
+ }
204
+ return res.body
205
+ }
206
+
207
+ /**
208
+ * Upload binary contents to the container. Replaces any existing value.
209
+ *
210
+ * Default `encoding: 'binary'` PATCHes `…/<field>` with raw bytes and a
211
+ * `Content-Type` header. Restricted to PNG / JPEG / GIF / TIFF / PDF.
212
+ *
213
+ * `encoding: 'base64'` PATCHes `…/<EntitySet>(<key>)` with a JSON body
214
+ * containing base64 data plus `@com.filemaker.odata.…` annotations. Use
215
+ * this when updating multiple container fields (or mixing container and
216
+ * regular fields) in a single round-trip.
217
+ *
218
+ * `contentType` is optional — when omitted, the library sniffs the MIME
219
+ * from the payload's magic bytes. Throws if no supported signature matches.
220
+ */
221
+ async upload(input: ContainerUploadInput, opts: RequestOptions = {}): Promise<void> {
222
+ const encoding = input.encoding ?? 'binary'
223
+
224
+ // Resolve contentType: caller-supplied takes precedence; otherwise sniff.
225
+ const bytes = await toUint8Array(input.data)
226
+ let contentType = input.contentType
227
+ if (!contentType) {
228
+ const sniffed = sniffContainerMime(bytes)
229
+ if (!sniffed) {
230
+ throw new TypeError(
231
+ 'ContainerRef.upload: contentType is required and could not be sniffed from the payload. ' +
232
+ `Pass a contentType explicitly (one of ${FM_CONTAINER_SUPPORTED_MIME_TYPES.join(', ')}).`,
233
+ )
234
+ }
235
+ contentType = sniffed
236
+ }
237
+
238
+ if (encoding === 'binary') {
239
+ if (!isSupportedContainerMime(contentType)) {
240
+ throw new TypeError(
241
+ `ContainerRef.upload (binary): contentType "${contentType}" is not a FileMaker-supported container type. ` +
242
+ `Use one of ${FM_CONTAINER_SUPPORTED_MIME_TYPES.join(', ')}, or switch to { encoding: 'base64' }.`,
243
+ )
244
+ }
245
+ const headers: Record<string, string> = {
246
+ 'Content-Type': contentType,
247
+ }
248
+ if (input.filename) {
249
+ headers['Content-Disposition'] = formatContentDisposition(input.filename)
250
+ }
251
+ // Normalize to a fresh ArrayBuffer so every fetch implementation (undici,
252
+ // browser, FM Web Viewer) sends raw bytes rather than relying on
253
+ // Uint8Array serialization quirks. Copying also rules out SharedArrayBuffer.
254
+ const body = new ArrayBuffer(bytes.byteLength)
255
+ new Uint8Array(body).set(bytes)
256
+ await executeRequest(this._entity._client._ctx, this.url(), {
257
+ method: 'PATCH',
258
+ headers,
259
+ body,
260
+ accept: 'none',
261
+ ...(opts.signal ? { signal: opts.signal } : {}),
262
+ })
263
+ return
264
+ }
265
+
266
+ // base64 mode: PATCH the record with FileMaker annotations.
267
+ const body = buildContainerJsonBody({
268
+ [this.fieldName]: {
269
+ data: toBase64(bytes),
270
+ contentType,
271
+ ...(input.filename ? { filename: input.filename } : {}),
272
+ },
273
+ })
274
+ await executeRequest(this._entity._client._ctx, this._entity.toURL(), {
275
+ method: 'PATCH',
276
+ headers: {
277
+ 'Content-Type': 'application/json',
278
+ Prefer: 'return=minimal',
279
+ },
280
+ body: JSON.stringify(body),
281
+ accept: 'none',
282
+ ...(opts.signal ? { signal: opts.signal } : {}),
283
+ })
284
+ }
285
+
286
+ /**
287
+ * Clear the container value. FMS has no documented per-field DELETE for
288
+ * record-level data, so the supported path is to PATCH the record with
289
+ * `{ <fieldName>: null }`.
290
+ */
291
+ async delete(opts: RequestOptions = {}): Promise<void> {
292
+ const body: Record<string, unknown> = { [this.fieldName]: null }
293
+ await executeRequest(this._entity._client._ctx, this._entity.toURL(), {
294
+ method: 'PATCH',
295
+ headers: {
296
+ 'Content-Type': 'application/json',
297
+ Prefer: 'return=minimal',
298
+ },
299
+ body: JSON.stringify(body),
300
+ accept: 'none',
301
+ ...(opts.signal ? { signal: opts.signal } : {}),
302
+ })
303
+ }
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // JSON-body annotation helpers (shared with multi-container PATCH and POST)
308
+ // ---------------------------------------------------------------------------
309
+
310
+ /**
311
+ * One container value to embed in a JSON body, in raw byte form. Used by
312
+ * `buildContainerJsonBody` and `EntityRef#patchContainers`.
313
+ */
314
+ export interface ContainerJsonValue {
315
+ /** Base64-encoded bytes (without `data:` prefix or whitespace). */
316
+ data: string
317
+ /** REQUIRED MIME type. Stored as `@com.filemaker.odata.ContentType`. */
318
+ contentType: string
319
+ /** Optional filename. Stored as `@com.filemaker.odata.Filename`. */
320
+ filename?: string
321
+ }
322
+
323
+ /**
324
+ * Build a JSON body for a POST/PATCH that updates one or more container
325
+ * fields (and optionally regular fields). Inserts the FileMaker annotation
326
+ * keys for every container value.
327
+ *
328
+ * @example
329
+ * buildContainerJsonBody(
330
+ * {
331
+ * photo: { data: '<base64>', contentType: 'image/png', filename: 'p.png' },
332
+ * contract: { data: '<base64>', contentType: 'application/pdf' },
333
+ * },
334
+ * { website: 'https://example.com' },
335
+ * )
336
+ */
337
+ export function buildContainerJsonBody(
338
+ containers: Record<string, ContainerJsonValue>,
339
+ regularFields: Record<string, unknown> = {},
340
+ ): Record<string, unknown> {
341
+ const body: Record<string, unknown> = { ...regularFields }
342
+ for (const [field, value] of Object.entries(containers)) {
343
+ if (!value.contentType) {
344
+ throw new TypeError(`buildContainerJsonBody: "${field}".contentType is required`)
345
+ }
346
+ body[field] = value.data
347
+ body[`${field}@com.filemaker.odata.ContentType`] = value.contentType
348
+ if (value.filename) {
349
+ body[`${field}@com.filemaker.odata.Filename`] = value.filename
350
+ }
351
+ }
352
+ return body
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Content-Disposition helpers
357
+ // ---------------------------------------------------------------------------
358
+
359
+ /**
360
+ * Parse the `filename` (or RFC 5987 `filename*`) from a `Content-Disposition`
361
+ * header. Returns `undefined` when no filename parameter is present.
362
+ *
363
+ * Per RFC 6266 §4.3, `filename*` (with explicit charset) takes precedence
364
+ * over `filename` when both are supplied.
365
+ *
366
+ * @internal
367
+ */
368
+ export function parseContentDispositionFilename(value: string): string | undefined {
369
+ // RFC 5987: filename*=charset'lang'percent-encoded-value
370
+ const ext = value.match(/filename\*\s*=\s*([^']+)'[^']*'([^;]+)/i)
371
+ if (ext) {
372
+ const charset = ext[1]!.trim().toLowerCase()
373
+ const encoded = ext[2]!.trim()
374
+ try {
375
+ const decoded = decodeURIComponent(encoded)
376
+ // We only honour utf-8; for any other charset return the percent-decoded
377
+ // bytes verbatim (best effort — non-utf8 in headers is exceptionally rare).
378
+ return charset === 'utf-8' || charset === 'utf8' ? decoded : decoded
379
+ } catch {
380
+ // fall through to the plain `filename`
381
+ }
382
+ }
383
+
384
+ // Plain `filename="..."` or `filename=...` (unquoted, up to `;` or end).
385
+ const plain = value.match(/filename\s*=\s*("([^"\\]*(?:\\.[^"\\]*)*)"|([^;]+))/i)
386
+ if (plain) {
387
+ const quoted = plain[2]
388
+ if (quoted !== undefined) return quoted.replace(/\\(.)/g, '$1').trim()
389
+ const unquoted = plain[3]
390
+ if (unquoted !== undefined) return unquoted.trim()
391
+ }
392
+ return undefined
393
+ }
394
+
395
+ /**
396
+ * Build a `Content-Disposition` value for an upload. The disposition-type is
397
+ * `inline` to match the example in the Claris OData docs.
398
+ *
399
+ * The Claris doc example uses the **unquoted** form
400
+ * (`filename=ALFKI.png`); FMS has been observed to mis-parse the RFC 6266
401
+ * quoted form on some deployments. So when the filename is composed of
402
+ * RFC 7230 token characters this function emits the unquoted form, falls
403
+ * back to a quoted form when the name contains spaces or other separator
404
+ * characters, and additionally emits an RFC 5987 `filename*=UTF-8''…`
405
+ * parameter for non-ASCII names.
406
+ *
407
+ * @internal
408
+ */
409
+ export function formatContentDisposition(filename: string): string {
410
+ // RFC 7230 token = 1*tchar; tchar excludes separators and CTLs.
411
+ const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
412
+ // eslint-disable-next-line no-control-regex
413
+ const needsRfc5987 = /[^\x00-\x7F]/.test(filename)
414
+
415
+ let base: string
416
+ if (TOKEN_RE.test(filename)) {
417
+ base = `inline; filename=${filename}`
418
+ } else {
419
+ const safeAscii = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '\\"')
420
+ base = `inline; filename="${safeAscii}"`
421
+ }
422
+ if (!needsRfc5987) return base
423
+ return `${base}; filename*=UTF-8''${encodeURIComponent(filename)}`
424
+ }
425
+
426
+ /** Coerce a `Blob | ArrayBuffer | Uint8Array` to a `Uint8Array`. */
427
+ async function toUint8Array(
428
+ data: Blob | ArrayBuffer | Uint8Array,
429
+ ): Promise<Uint8Array> {
430
+ if (data instanceof Uint8Array) return data
431
+ if (data instanceof ArrayBuffer) return new Uint8Array(data)
432
+ // Treat anything else with an `arrayBuffer()` method (e.g. Blob) generically.
433
+ const ab = await (data as Blob).arrayBuffer()
434
+ return new Uint8Array(ab)
435
+ }
436
+
437
+ /**
438
+ * Encode bytes to a base64 string. Uses Node's `Buffer` when available, else
439
+ * a chunked `btoa` fallback (avoids stack overflow on large arrays).
440
+ *
441
+ * @internal
442
+ */
443
+ export function toBase64(bytes: Uint8Array): string {
444
+ if (typeof Buffer !== 'undefined') {
445
+ return Buffer.from(bytes).toString('base64')
446
+ }
447
+ let bin = ''
448
+ const chunk = 0x8000
449
+ for (let i = 0; i < bytes.length; i += chunk) {
450
+ bin += String.fromCharCode.apply(
451
+ null,
452
+ Array.from(bytes.subarray(i, i + chunk)),
453
+ )
454
+ }
455
+ // eslint-disable-next-line no-restricted-globals
456
+ return btoa(bin)
457
+ }