@hachej/boring-files 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,6 +26,17 @@ Receipts are scoped by workspace identity, principal, scope and initiator. Ident
26
26
 
27
27
  See [scaffold guide](../../docs/contracts/SCAFFOLD.md) and [resource design](../../docs/architecture/FILES-GIT-EXEC.md).
28
28
 
29
+ ### Large files (blob pointers) and media sniffing
30
+
31
+ A large or binary file (up to 100 MB) can live in the host's blob store as an immutable blob while the workspace holds a small pointer file in its place, `<name>.blob.json` with media type `application/vnd.boring.blob+json`: `{ "boring:blob": 1, "id", "sha256", "size", "type", "name" }`. Where bytes live is the provider's business; the pointer is the provider's representation of a large immutable blob, written and read through the workspace's one provider like any file (no second place, no sync). The host serves the bytes at `GET <blobBase><encodeURIComponent(id)>` (default `/_files/`), same origin, checking access on every request and setting disposition, `nosniff` and a CSP. `@hachej/boring-files/blob` (pure, browser-safe) exports:
32
+
33
+ - `parseBlobPointer(bytes | text)`: strict (input under `BLOB_POINTER_MAX_BYTES` = 4 KB, UTF-8 JSON, every field with its type, 64 lower-case hex digits, a non-negative safe-integer size, a media type, a name without path separators). It returns the pointer, `{ kind: 'unsupported-version', version }` for a higher `boring:blob` (show the file as JSON and say "newer format"), or `undefined`. Hosts validate with the same function.
34
+ - `checkBlobBase(base)`: a same-origin path starting with one `/`; a scheme, a host, `//`, a backslash or whitespace is a `TypeError`. `blobItemOf(pointer, base)` and `resolveBlobPointer(bytes, base)` give `{ name, size, type, url }`, the only thing viewers see.
35
+ - `withBlobPointers(provider, base)`: the revision provider as a listing reads it: each pointer file of a `list` or `search` page carries `blob` (`FileEntry.blob`), read once through the same provider. Reads still return the pointer's bytes.
36
+ - `BLOB_POINTER_MEDIA_TYPE`, `BLOB_POINTER_SUFFIX`, `BLOB_POINTER_VERSION`, `DEFAULT_BLOB_BASE`, `isBlobPointerPath` and the `BlobPointer` / `BlobItem` types.
37
+
38
+ `@hachej/boring-files/file-types` is the one table of what a file is. `sniffMediaType(head)` (pass up to `SNIFF_BYTES` = 8 KB) names PDF, PNG, JPEG, GIF, WebP, HEIC/HEIF (ISO-BMFF `ftyp` brands: `heic`, `heix`, `hevc`, … are HEIC; `mif1`/`msf1` are HEIC with a HEVC compatible brand, HEIF otherwise, unclaimed with an AVIF brand), and zip documents from the head alone: OpenDocument and EPUB by their stored `mimetype` entry, docx/xlsx/pptx by a `[Content_Types].xml` entry and then its main part type when it is stored, else the folder of a later entry (`word/`, `xl/`, `ppt/`). The head is never inflated (`[Content_Types].xml` is almost always deflated, and inflating is asynchronous in a browser), so a zip whose sizes are in a data descriptor is not claimed. Otherwise bytes that are valid UTF-8 with no NUL are `text/plain`, a weak answer that never overrides a name. `delimitedTextType(path, head)` says CSV or TSV from the extension when the bytes are text. `@hachej/boring-agent/file-types` reads this table and head size; it keeps only its own routing kinds.
39
+
29
40
  ## Node hosts
30
41
 
31
42
  `@hachej/boring-files/node-http` bridges `node:http` to the Fetch handlers of these packages: `webRequest(incoming, url, { signal, maxBytes })` reads the body first under a byte cap (never `Readable.toWeb` on a live request, whose cancellation kills the process) and `sendWebResponse(response, outgoing, { signal })` flushes the headers at once, keeps each `set-cookie`, waits for `drain` when the socket is full and cancels the web body when the client disconnects. The example servers use it; `npm run check` refuses hand-written response copies under `examples/`.
package/dist/blob.d.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Large files as blob pointers. A large or binary file (up to 100 MB) lives in the host's blob store as an immutable blob, and the
3
+ * workspace holds a small pointer file in its place (`<name>.blob.json`, media type `application/vnd.boring.blob+json`):
4
+ *
5
+ * { "boring:blob": 1, "id": "<id>", "sha256": "<hex>", "size": <bytes>, "type": "<media type>", "name": "<file name>" }
6
+ *
7
+ * Where bytes live is the provider's business: the pointer is the provider's representation of a large immutable blob, written and
8
+ * read through the workspace's one provider like any other file. The host serves the bytes at `GET <blobBase><encodeURIComponent(id)>`
9
+ * (same origin, access checked on every request). This module reads pointers and names that URL; it never fetches. Pure: no Node, no DOM.
10
+ */
11
+ import type { BlobItem, RevisionProvider, UnsupportedBlobPointer } from './revision-contracts.js';
12
+ export type { BlobItem, UnsupportedBlobPointer } from './revision-contracts.js';
13
+ /** The pointer file's media type. */
14
+ export declare const BLOB_POINTER_MEDIA_TYPE = "application/vnd.boring.blob+json";
15
+ /** The pointer format this module reads (`"boring:blob": 1`). A higher number is a newer format: `parseBlobPointer` says so. */
16
+ export declare const BLOB_POINTER_VERSION = 1;
17
+ /** A pointer file is small: anything this size or larger is not one. */
18
+ export declare const BLOB_POINTER_MAX_BYTES = 4096;
19
+ /** The ending of a pointer file's name in the workspace (`photos/cat.heic.blob.json`). */
20
+ export declare const BLOB_POINTER_SUFFIX = ".blob.json";
21
+ /** Where the host serves blob bytes by default: `GET /_files/<encodeURIComponent(id)>`. */
22
+ export declare const DEFAULT_BLOB_BASE = "/_files/";
23
+ /** A valid pointer, exactly the fields of the format. */
24
+ export interface BlobPointer {
25
+ readonly 'boring:blob': 1;
26
+ readonly id: string;
27
+ /** Lower-case hex SHA-256 of the blob's bytes. */
28
+ readonly sha256: string;
29
+ /** The blob's size in bytes. */
30
+ readonly size: number;
31
+ /** The blob's media type. */
32
+ readonly type: string;
33
+ /** The file name shown for it (no path separators). */
34
+ readonly name: string;
35
+ }
36
+ /**
37
+ * Reads a pointer file's bytes (or text), strictly: input under `BLOB_POINTER_MAX_BYTES`, UTF-8 JSON, every field present with its
38
+ * type, `sha256` 64 lower-case hex digits, `size` a non-negative safe integer, `type` a media type, `name` a file name without path
39
+ * separators. Returns the pointer; `{ kind: 'unsupported-version' }` for a higher `boring:blob`; `undefined` for anything else.
40
+ */
41
+ export declare function parseBlobPointer(input: Uint8Array | string): BlobPointer | UnsupportedBlobPointer | undefined;
42
+ /** Whether a workspace path names a pointer file (`*.blob.json`). */
43
+ export declare const isBlobPointerPath: (path: string) => boolean;
44
+ /**
45
+ * The checked blob base: a same-origin path starting with one `/` (default `/_files/`). A URL with a scheme or host, a
46
+ * protocol-relative `//host`, a backslash, whitespace or a control character is refused with a `TypeError`.
47
+ */
48
+ export declare function checkBlobBase(base?: string): string;
49
+ /** The item a pointer stands for, with the URL of its bytes under a checked `base`. */
50
+ export declare const blobItemOf: (pointer: BlobPointer, base?: string) => BlobItem;
51
+ /** A pointer file's bytes as what to show: `{ kind: 'blob', item }`, an unsupported version, or `undefined` (not a pointer: show the file). */
52
+ export declare function resolveBlobPointer(input: Uint8Array | string, base?: string): {
53
+ readonly kind: 'blob';
54
+ readonly item: BlobItem;
55
+ } | UnsupportedBlobPointer | undefined;
56
+ /**
57
+ * The provider as a listing reads it: each pointer file in a `list` or `search` page carries `blob` (the item it stands for, or its
58
+ * unsupported version), read once through the same provider. Every other call is the provider's own; reads still return the
59
+ * pointer's bytes, so a conditional write of the pointer file works as for any file. `base` is checked here, once.
60
+ */
61
+ export declare function withBlobPointers(provider: RevisionProvider, base?: string): RevisionProvider;
62
+ //# sourceMappingURL=blob.d.ts.map
package/dist/blob.js ADDED
@@ -0,0 +1,99 @@
1
+ /** The pointer file's media type. */
2
+ export const BLOB_POINTER_MEDIA_TYPE = 'application/vnd.boring.blob+json';
3
+ /** The pointer format this module reads (`"boring:blob": 1`). A higher number is a newer format: `parseBlobPointer` says so. */
4
+ export const BLOB_POINTER_VERSION = 1;
5
+ /** A pointer file is small: anything this size or larger is not one. */
6
+ export const BLOB_POINTER_MAX_BYTES = 4096;
7
+ /** The ending of a pointer file's name in the workspace (`photos/cat.heic.blob.json`). */
8
+ export const BLOB_POINTER_SUFFIX = '.blob.json';
9
+ /** Where the host serves blob bytes by default: `GET /_files/<encodeURIComponent(id)>`. */
10
+ export const DEFAULT_BLOB_BASE = '/_files/';
11
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
12
+ // eslint-disable-next-line no-control-regex
13
+ const CONTROL = /[\u0000-\u001f\u007f]/;
14
+ const MEDIA_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/i;
15
+ const SHA256 = /^[0-9a-f]{64}$/;
16
+ const text = (value, max) => typeof value === 'string' && value.length > 0 && value.length <= max && !CONTROL.test(value);
17
+ /**
18
+ * Reads a pointer file's bytes (or text), strictly: input under `BLOB_POINTER_MAX_BYTES`, UTF-8 JSON, every field present with its
19
+ * type, `sha256` 64 lower-case hex digits, `size` a non-negative safe integer, `type` a media type, `name` a file name without path
20
+ * separators. Returns the pointer; `{ kind: 'unsupported-version' }` for a higher `boring:blob`; `undefined` for anything else.
21
+ */
22
+ export function parseBlobPointer(input) {
23
+ if (typeof input === 'string' ? input.length >= BLOB_POINTER_MAX_BYTES || new TextEncoder().encode(input).byteLength >= BLOB_POINTER_MAX_BYTES : input.byteLength >= BLOB_POINTER_MAX_BYTES)
24
+ return undefined;
25
+ let value;
26
+ try {
27
+ value = JSON.parse(typeof input === 'string' ? input : new TextDecoder('utf-8', { fatal: true }).decode(input));
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ if (!isRecord(value))
33
+ return undefined;
34
+ const version = value['boring:blob'];
35
+ if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1)
36
+ return undefined;
37
+ if (version > BLOB_POINTER_VERSION)
38
+ return { kind: 'unsupported-version', version };
39
+ const { id, sha256, size, type, name } = value;
40
+ if (!text(id, 512) || typeof sha256 !== 'string' || !SHA256.test(sha256) || typeof size !== 'number' || !Number.isSafeInteger(size) || size < 0
41
+ || typeof type !== 'string' || !MEDIA_TYPE.test(type) || !text(name, 255) || /[/\\]/.test(name) || name === '.' || name === '..')
42
+ return undefined;
43
+ return { 'boring:blob': 1, id, sha256, size, type, name };
44
+ }
45
+ /** Whether a workspace path names a pointer file (`*.blob.json`). */
46
+ export const isBlobPointerPath = (path) => {
47
+ const name = path.slice(path.lastIndexOf('/') + 1).toLowerCase();
48
+ return name.length > BLOB_POINTER_SUFFIX.length && name.endsWith(BLOB_POINTER_SUFFIX);
49
+ };
50
+ /**
51
+ * The checked blob base: a same-origin path starting with one `/` (default `/_files/`). A URL with a scheme or host, a
52
+ * protocol-relative `//host`, a backslash, whitespace or a control character is refused with a `TypeError`.
53
+ */
54
+ export function checkBlobBase(base = DEFAULT_BLOB_BASE) {
55
+ const origin = 'https://same-origin.invalid';
56
+ let resolved;
57
+ try {
58
+ resolved = new URL(base, origin);
59
+ }
60
+ catch {
61
+ resolved = undefined;
62
+ }
63
+ if (typeof base !== 'string' || !base.startsWith('/') || base.startsWith('//') || /[\\\s]/.test(base) || CONTROL.test(base) || resolved?.origin !== origin) {
64
+ throw new TypeError(`blobBase must be a same-origin path starting with "/", not ${JSON.stringify(base)}`);
65
+ }
66
+ return base;
67
+ }
68
+ /** The item a pointer stands for, with the URL of its bytes under a checked `base`. */
69
+ export const blobItemOf = (pointer, base = DEFAULT_BLOB_BASE) => ({ name: pointer.name, size: pointer.size, type: pointer.type, url: `${checkBlobBase(base)}${encodeURIComponent(pointer.id)}` });
70
+ /** A pointer file's bytes as what to show: `{ kind: 'blob', item }`, an unsupported version, or `undefined` (not a pointer: show the file). */
71
+ export function resolveBlobPointer(input, base = DEFAULT_BLOB_BASE) {
72
+ const parsed = parseBlobPointer(input);
73
+ if (parsed === undefined || 'kind' in parsed)
74
+ return parsed;
75
+ return { kind: 'blob', item: blobItemOf(parsed, base) };
76
+ }
77
+ /**
78
+ * The provider as a listing reads it: each pointer file in a `list` or `search` page carries `blob` (the item it stands for, or its
79
+ * unsupported version), read once through the same provider. Every other call is the provider's own; reads still return the
80
+ * pointer's bytes, so a conditional write of the pointer file works as for any file. `base` is checked here, once.
81
+ */
82
+ export function withBlobPointers(provider, base = DEFAULT_BLOB_BASE) {
83
+ checkBlobBase(base);
84
+ const resolve = async (page, signal) => ({ ...page, entries: await Promise.all(page.entries.map(async (entry) => {
85
+ if (entry.kind !== 'file' || !isBlobPointerPath(entry.path) || entry.size >= BLOB_POINTER_MAX_BYTES)
86
+ return entry;
87
+ try {
88
+ const read = await provider.read({ target: provider.locate(entry.path), revision: { kind: 'latest' } }, signal);
89
+ const resolved = read.kind === 'available' ? resolveBlobPointer(read.snapshot.bytes, base) : undefined;
90
+ return resolved === undefined ? entry : { ...entry, blob: resolved.kind === 'blob' ? resolved.item : resolved };
91
+ }
92
+ catch (error) {
93
+ if (signal?.aborted)
94
+ throw error;
95
+ return entry;
96
+ }
97
+ })) });
98
+ return { ...provider, list: async (request, signal) => resolve(await provider.list(request, signal), signal), search: async (request, signal) => resolve(await provider.search(request, signal), signal) };
99
+ }
@@ -9,11 +9,20 @@ export declare const extensionOf: (path: string) => string;
9
9
  export declare const MEDIA_TYPES: Readonly<Record<string, string>>;
10
10
  /** The IANA media type of a path, `application/octet-stream` when unknown. */
11
11
  export declare const mediaTypeOf: (path: string) => string;
12
- /** The media type a file's leading bytes name (a PDF, PNG, JPEG, GIF or WebP signature), or undefined. */
12
+ /** How many leading bytes `sniffMediaType` looks at: a caller reads this many and passes them in. */
13
+ export declare const SNIFF_BYTES = 8192;
14
+ /**
15
+ * The media type a file's leading bytes name, or undefined. Pass up to `SNIFF_BYTES` of them.
16
+ * - Signatures: PDF, PNG, JPEG, GIF, WebP, HEIC/HEIF and MP4/MOV/M4V (`ftyp` brands), WebM (EBML), and zip-based documents.
17
+ * - Otherwise `text/plain` when the bytes are valid UTF-8 (a character cut at the end is allowed) with no NUL byte: a weak answer, a
18
+ * name says better which text it is (`delimitedTextType` for CSV and TSV).
19
+ */
13
20
  export declare function sniffMediaType(head: Uint8Array): string | undefined;
21
+ /** CSV or TSV: content alone cannot tell them from other text, so the name says which and the bytes must be text. Undefined otherwise. */
22
+ export declare function delimitedTextType(path: string, head: Uint8Array): 'text/csv' | 'text/tab-separated-values' | undefined;
14
23
  /** Which viewer opens a file. Anything not listed is shown as text when it decodes. */
15
- export type ViewerKind = 'markdown' | 'html' | 'canvas' | 'image' | 'pdf' | 'text';
16
- /** The viewer kind of a path: `markdown`, `html`, `canvas`, `image`, `pdf` or `text`. */
24
+ export type ViewerKind = 'markdown' | 'html' | 'canvas' | 'image' | 'pdf' | 'video' | 'text';
25
+ /** The viewer kind of a path: `markdown`, `html`, `canvas`, `image`, `pdf`, `video` or `text`. */
17
26
  export declare const viewerKindOf: (path: string) => ViewerKind;
18
27
  /** Binary viewers never decode a file as text, whatever its bytes look like (an SVG is text). */
19
28
  export declare const isBinaryKind: (path: string) => boolean;
@@ -8,8 +8,10 @@ export const extensionOf = (path) => { const dot = path.lastIndexOf('.'); return
8
8
  /** The IANA media type by extension. Anything not listed is `application/octet-stream` (see `mediaTypeOf`). Null-prototype: `constructor` or `__proto__` is not an entry. */
9
9
  export const MEDIA_TYPES = Object.assign(Object.create(null), {
10
10
  md: 'text/markdown', markdown: 'text/markdown', html: 'text/html', htm: 'text/html', json: 'application/json', svg: 'image/svg+xml',
11
- txt: 'text/plain', csv: 'text/csv', js: 'text/javascript', mjs: 'text/javascript', css: 'text/css', tldraw: 'application/vnd.tldraw+json',
11
+ txt: 'text/plain', csv: 'text/csv', tsv: 'text/tab-separated-values', js: 'text/javascript', mjs: 'text/javascript', css: 'text/css', tldraw: 'application/vnd.tldraw+json',
12
12
  png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
13
+ heic: 'image/heic', heif: 'image/heif',
14
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', m4v: 'video/x-m4v', ogg: 'video/ogg', ogv: 'video/ogg',
13
15
  pdf: 'application/pdf', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
14
16
  xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
15
17
  ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
@@ -18,7 +20,82 @@ export const MEDIA_TYPES = Object.assign(Object.create(null), {
18
20
  });
19
21
  /** The IANA media type of a path, `application/octet-stream` when unknown. */
20
22
  export const mediaTypeOf = (path) => MEDIA_TYPES[extensionOf(path)] ?? 'application/octet-stream';
21
- /** The media type a file's leading bytes name (a PDF, PNG, JPEG, GIF or WebP signature), or undefined. */
23
+ /** How many leading bytes `sniffMediaType` looks at: a caller reads this many and passes them in. */
24
+ export const SNIFF_BYTES = 8192;
25
+ const ascii = (bytes) => String.fromCharCode(...bytes);
26
+ const u16 = (bytes, at) => (bytes[at] ?? 0) | ((bytes[at + 1] ?? 0) << 8);
27
+ const u32 = (bytes, at) => (u16(bytes, at) | (u16(bytes, at + 2) << 16)) >>> 0;
28
+ const HEIC_BRANDS = new Set(['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis']);
29
+ const HEIF_BRANDS = new Set(['mif1', 'msf1', 'heif']);
30
+ const MP4_BRANDS = new Set(['isom', 'iso2', 'iso3', 'iso4', 'iso5', 'iso6', 'mp41', 'mp42', 'avc1', 'dash']);
31
+ /**
32
+ * HEIC/HEIF by their ISO-BMFF `ftyp` box: a HEVC brand (`heic`, `heix`, `hevc`, …) is HEIC; the generic image brands (`mif1`, `msf1`)
33
+ * are HEIC when a compatible brand says HEVC, HEIF otherwise, and nothing when a compatible brand says AVIF (not claimed here).
34
+ */
35
+ function sniffHeif(head) {
36
+ if (head.length < 16 || ascii(head.subarray(4, 8)) !== 'ftyp')
37
+ return undefined;
38
+ const major = ascii(head.subarray(8, 12));
39
+ const end = Math.min(head.length, Math.max(16, (head[0] << 24 | head[1] << 16 | head[2] << 8 | head[3]) >>> 0));
40
+ const compatible = new Set();
41
+ for (let at = 16; at + 4 <= end; at += 4)
42
+ compatible.add(ascii(head.subarray(at, at + 4)));
43
+ if (HEIC_BRANDS.has(major))
44
+ return 'image/heic';
45
+ if (!HEIF_BRANDS.has(major))
46
+ return undefined;
47
+ if (compatible.has('avif') || compatible.has('avis'))
48
+ return undefined;
49
+ return [...compatible].some(brand => HEIC_BRANDS.has(brand)) ? 'image/heic' : 'image/heif';
50
+ }
51
+ const OOXML_MAIN = [
52
+ ['wordprocessingml.document.main', 'word/', MEDIA_TYPES.docx],
53
+ ['spreadsheetml.sheet.main', 'xl/', MEDIA_TYPES.xlsx],
54
+ ['presentationml.presentation.main', 'ppt/', MEDIA_TYPES.pptx],
55
+ ];
56
+ const ZIP_MIMETYPES = new Set([MEDIA_TYPES.odt, MEDIA_TYPES.ods, MEDIA_TYPES.odp, MEDIA_TYPES.epub]);
57
+ /**
58
+ * Zip-based documents from the head alone, without inflating anything: it walks the local file headers that fit in `head`.
59
+ * - OpenDocument and EPUB: a first entry `mimetype`, stored uncompressed, holds the media type itself.
60
+ * - Office Open XML (docx, xlsx, pptx): a `[Content_Types].xml` entry, then the main part's content type when that entry is stored
61
+ * uncompressed, or else the folder of a later entry (`word/`, `xl/`, `ppt/`). Its content is almost always deflated, and inflating
62
+ * is asynchronous in a browser, so the folder rule is the usual one.
63
+ * A zip whose entries have unknown sizes (a data descriptor) cannot be walked past its first entry; it is then not claimed.
64
+ */
65
+ function sniffZip(head) {
66
+ let contentTypes = false;
67
+ for (let offset = 0, index = 0; index < 64 && offset + 30 <= head.length && u32(head, offset) === 0x04034b50; index++) {
68
+ const flags = u16(head, offset + 6), method = u16(head, offset + 8), size = u32(head, offset + 18);
69
+ const nameStart = offset + 30, nameEnd = nameStart + u16(head, offset + 26), dataStart = nameEnd + u16(head, offset + 28);
70
+ if (nameEnd > head.length)
71
+ return undefined;
72
+ const name = ascii(head.subarray(nameStart, nameEnd));
73
+ const stored = method === 0 && (flags & 8) === 0 && dataStart + size <= head.length ? ascii(head.subarray(dataStart, dataStart + size)) : undefined;
74
+ if (index === 0 && name === 'mimetype' && stored !== undefined)
75
+ return ZIP_MIMETYPES.has(stored.trim()) ? stored.trim() : undefined;
76
+ if (name === '[Content_Types].xml') {
77
+ contentTypes = true;
78
+ const named = stored === undefined ? undefined : OOXML_MAIN.find(([main]) => stored.includes(`${main}+xml`));
79
+ if (named)
80
+ return named[2];
81
+ }
82
+ else if (contentTypes) {
83
+ const folder = OOXML_MAIN.find(([, prefix]) => name.startsWith(prefix));
84
+ if (folder)
85
+ return folder[2];
86
+ }
87
+ if (flags & 8)
88
+ return undefined;
89
+ offset = dataStart + size;
90
+ }
91
+ return undefined;
92
+ }
93
+ /**
94
+ * The media type a file's leading bytes name, or undefined. Pass up to `SNIFF_BYTES` of them.
95
+ * - Signatures: PDF, PNG, JPEG, GIF, WebP, HEIC/HEIF and MP4/MOV/M4V (`ftyp` brands), WebM (EBML), and zip-based documents.
96
+ * - Otherwise `text/plain` when the bytes are valid UTF-8 (a character cut at the end is allowed) with no NUL byte: a weak answer, a
97
+ * name says better which text it is (`delimitedTextType` for CSV and TSV).
98
+ */
22
99
  export function sniffMediaType(head) {
23
100
  const at = (signature, offset = 0) => signature.every((byte, index) => head[offset + index] === byte);
24
101
  if (at([0x25, 0x50, 0x44, 0x46, 0x2d]))
@@ -31,17 +108,52 @@ export function sniffMediaType(head) {
31
108
  return 'image/gif';
32
109
  if (at([0x52, 0x49, 0x46, 0x46]) && at([0x57, 0x45, 0x42, 0x50], 8))
33
110
  return 'image/webp';
34
- return undefined;
111
+ const heif = sniffHeif(head);
112
+ if (heif)
113
+ return heif;
114
+ if (head.length >= 16 && ascii(head.subarray(4, 8)) === 'ftyp') {
115
+ const brand = ascii(head.subarray(8, 12));
116
+ if (brand === 'qt ')
117
+ return 'video/quicktime';
118
+ if (brand === 'M4V ' || brand === 'M4VH' || brand === 'M4VP')
119
+ return 'video/x-m4v';
120
+ if (MP4_BRANDS.has(brand))
121
+ return 'video/mp4';
122
+ }
123
+ if (at([0x1a, 0x45, 0xdf, 0xa3]))
124
+ return 'video/webm';
125
+ if (at([0x50, 0x4b, 0x03, 0x04]))
126
+ return sniffZip(head.subarray(0, SNIFF_BYTES));
127
+ const sample = head.subarray(0, SNIFF_BYTES);
128
+ if (sample.length === 0 || sample.includes(0))
129
+ return undefined;
130
+ try {
131
+ new TextDecoder('utf-8', { fatal: true }).decode(sample, { stream: true });
132
+ return 'text/plain';
133
+ }
134
+ catch {
135
+ return undefined;
136
+ }
137
+ }
138
+ /** CSV or TSV: content alone cannot tell them from other text, so the name says which and the bytes must be text. Undefined otherwise. */
139
+ export function delimitedTextType(path, head) {
140
+ const extension = extensionOf(path);
141
+ if (extension !== 'csv' && extension !== 'tsv')
142
+ return undefined;
143
+ if (sniffMediaType(head) !== 'text/plain')
144
+ return undefined;
145
+ return extension === 'csv' ? 'text/csv' : 'text/tab-separated-values';
35
146
  }
36
147
  const VIEWER_KINDS = Object.assign(Object.create(null), {
37
148
  md: 'markdown', markdown: 'markdown', html: 'html', htm: 'html', tldraw: 'canvas',
38
149
  png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', svg: 'image', pdf: 'pdf',
150
+ mp4: 'video', webm: 'video', mov: 'video', m4v: 'video', ogg: 'video', ogv: 'video',
39
151
  txt: 'text', json: 'text', csv: 'text', js: 'text', mjs: 'text', css: 'text',
40
152
  });
41
- /** The viewer kind of a path: `markdown`, `html`, `canvas`, `image`, `pdf` or `text`. */
153
+ /** The viewer kind of a path: `markdown`, `html`, `canvas`, `image`, `pdf`, `video` or `text`. */
42
154
  export const viewerKindOf = (path) => VIEWER_KINDS[extensionOf(path)] ?? 'text';
43
155
  /** Binary viewers never decode a file as text, whatever its bytes look like (an SVG is text). */
44
- export const isBinaryKind = (path) => ['image', 'pdf'].includes(viewerKindOf(path)) && extensionOf(path) !== 'svg';
156
+ export const isBinaryKind = (path) => ['image', 'pdf', 'video'].includes(viewerKindOf(path)) && extensionOf(path) !== 'svg';
45
157
  /** The provider's temporary files are named `.boring-<uuid>.tmp` beside their target. They are removed when the provider opens and are never a resource. */
46
158
  const temporaryPattern = /^\.boring-[0-9a-f-]{36}\.tmp$/;
47
159
  /** The basename of a new temporary file, for a provider's `uuid`. */
@@ -2,10 +2,25 @@ import type { ResourceClient } from './client.js';
2
2
  import type { ResourceAccess, ResourceLocator } from './contracts.js';
3
3
  import type { ResourceIdentity } from './remote-protocol.js';
4
4
  import type { WorkspaceKey } from './workspace-identity.js';
5
+ /** What a viewer or a listing shows for a blob pointer (`./blob`): never the pointer itself. */
6
+ export interface BlobItem {
7
+ readonly name: string;
8
+ readonly size: number;
9
+ readonly type: string;
10
+ /** Same-origin URL of the bytes: `<blobBase><encodeURIComponent(id)>`. */
11
+ readonly url: string;
12
+ }
13
+ /** A blob pointer written in a newer format than `./blob` reads: show it as its JSON file, and say it is a newer format. */
14
+ export interface UnsupportedBlobPointer {
15
+ readonly kind: 'unsupported-version';
16
+ readonly version: number;
17
+ }
5
18
  export interface FileEntry {
6
19
  readonly path: string;
7
20
  readonly kind: 'file' | 'directory';
8
21
  readonly size: number;
22
+ /** A pointer file (`*.blob.json`) as a listing shows it: the item it stands for, or its unsupported version. Set by `withBlobPointers` (`./blob`), never by the protocol. */
23
+ readonly blob?: BlobItem | UnsupportedBlobPointer;
9
24
  }
10
25
  export interface FilePage {
11
26
  readonly entries: readonly FileEntry[];
@@ -81,7 +96,8 @@ export interface RevisionProvider extends ResourceClient {
81
96
  readonly history: (path: string, signal?: AbortSignal) => Promise<readonly SavedRevision[]>;
82
97
  /**
83
98
  * The workspace's change feed (`GET ?op=changes`, server-sent events), reconnecting after the last `seq` seen until `signal`
84
- * aborts; a refused binding (another workspace, revoked access) ends it with a `RevisionError`. Optional: a hand-built provider
99
+ * aborts; a refused binding (another workspace, revoked access) ends it with a `RevisionError`, and so does a route that serves no feed:
100
+ * it answers 501 (the intended signal) or 400 to `?op=changes`, and the views then keep their own refresh. Optional: a hand-built provider
85
101
  * may have none, and a viewer then refreshes on its own schedule.
86
102
  */
87
103
  readonly changes?: (request?: ChangeFeedRequest) => AsyncIterable<WorkspaceChangeEvent>;
package/dist/revision.js CHANGED
@@ -81,8 +81,9 @@ export async function connectRevisionProvider(options) {
81
81
  void response.body?.cancel().catch(() => { });
82
82
  if (response.status === 403 || response.status === 409)
83
83
  throw new RevisionError('denied', 'The selected workspace is unavailable or no longer authorized');
84
- if (response.status === 400)
85
- throw new RevisionError('invalid', 'The change feed request was refused');
84
+ // 501: the route serves no change feed (the intended signal); 400 is the same ending, kept for hosts that already answer it.
85
+ if (response.status === 400 || response.status === 501)
86
+ throw new RevisionError('invalid', response.status === 501 ? 'The route serves no change feed' : 'The change feed request was refused');
86
87
  await pause();
87
88
  continue;
88
89
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-files",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -85,6 +85,10 @@
85
85
  "./file-types": {
86
86
  "types": "./dist/file-types.d.ts",
87
87
  "import": "./dist/file-types.js"
88
+ },
89
+ "./blob": {
90
+ "types": "./dist/blob.d.ts",
91
+ "import": "./dist/blob.js"
88
92
  }
89
93
  },
90
94
  "peerDependencies": {