@colixsystems/widget-sdk 0.198.0 → 0.199.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.
package/README.md CHANGED
@@ -9,6 +9,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
9
9
  | `ctx.datastore` | `@colixsystems/datastore-client` | `tables.{list,get}`, `schema(tableId)`, `myPermissions(tableId, { recordId? })`, `records(tableId).{ list(query), get(id), create(values), update(id,values) [PATCH], delete(id), aggregate(spec), permissions(recordId).{ list, grant, update, revoke } }` |
10
10
  | `ctx.directory` | `@colixsystems/directory-client` | `me()`, `users.{list,get,invite,deactivate,reactivate}`, `groups.{list,create,remove,addMember,removeMember,listMine}`, `invites.{list,revoke,resend}` |
11
11
  | `ctx.assets` | `@colixsystems/assets-client` | the Asset Manager: `get(id)`, `list(query)`, `upload(formData)` over `/files` — what `useAsset()` (single asset by id) and `useAssetsByTag()` (every asset carrying a tag) resolve |
12
+ | `ctx.widget.assets` | — (built by the host) | the files the widget package bundles: a map of file name → absolute URL, `{}` when it bundles nothing — what `useBundledAsset()` resolves |
12
13
  | `ctx.payments` | `@colixsystems/payments-client` | `requestPayment(body)`, `getPayment(id)` |
13
14
 
14
15
  **Wire / casing: snake_case end to end.** The clients send and return snake_case **verbatim** (`created_at`, `group_ids`, `can_read`, `amount_cents`, `data_type`, `is_active`, …). There is **no case transform anywhere** — not on the client and not in the backend; the only casing boundary is Prisma `@map` (snake_case field → camelCase column). Author-defined record column values pass through verbatim. Every `list(...)` returns the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you.
@@ -59,8 +60,9 @@ The data layer lives in **four separate domain-client packages**, each instantia
59
60
  | **DATASTORE** | `useTyping(tableId, recordId)` | `{ typingUserIds, setTyping, status }` — status `"connecting" \| "live" \| "unavailable"` | `records(table).typing` — `datastore.read:<table>`. Who else is typing in one record, over the same socket as `useDatastoreSubscription`. Call `setTyping(true)` on every keystroke (throttled to one signal per 3 s; `false` is sent after 5 s idle) and `setTyping(false)` on send, clear or blur. `typingUserIds` lists the OTHER users' ids; each drops out 6 s after their last signal. Needs a signed-in user who can read the record. Never throws: an anonymous visitor, a refused subscription or a host whose client lacks `typing` reads `{ typingUserIds: [], status: "unavailable" }`. Re-subscribes when either id changes. |
60
61
  | **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
61
62
  | **DATASTORE** | `useCanWrite(tableId, options)` (optional: options) | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "may this caller write", reading the same table-ACL answer the write endpoint enforces — so a table granting Create to Everyone answers `true` for a logged-out visitor, and this hook alone is the right gate for a widget meant to work without signing in. Pass `{ recordId }` for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with `useUser()` to also tell "not signed in" apart from "signed in but forbidden" — both resolve `canWrite: false` here. Falsy `tableId`, or a host that hasn't injected `myPermissions` (an older host), collapses to `{ canWrite: false, loading: false, error: null, refetch: async () => undefined }` rather than throwing. |
62
- | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
63
+ | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, asset, loading, error, refetch }` | `ctx.assets.get` — no scope |
63
64
  | **FILES** | `useAssetsByTag(tag, { type })` (optional: the whole argument, and `type` within it) | `{ assets, loading, error, refetch }` | `ctx.assets.list` (unwraps `{ data, meta }` to `assets`) — no scope. `type` defaults to `"image"`; pass `"all"` / `"audio"` / `"video"` / `"document"` to widen. Falsy `tag` collapses to `assets: []` without a round-trip. |
65
+ | **FILES** (`ctx.widget`) | `useBundledAsset(fileName)` | `string \| null` | `ctx.widget.assets` — no scope. The URL of an image or sound the widget package ships in its `assets/` directory, or `null` when it has no file with that name. See [Bundled images and sounds](#bundled-images-and-sounds). |
64
66
  | **DIRECTORY** (`ctx.directory`) | `useDirectory(query)` (optional: query) | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
65
67
  | **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset, update }` | `directory.users.*` — `users.read:*` plus the `users.read` (or `users.write`) capability, else `FORBIDDEN` (edits, incl. `sendPasswordReset()` and `update()`, also `users.write:*`; `remove()` also `users.delete:*`) |
66
68
  | **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
@@ -81,8 +83,29 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
81
83
 
82
84
  ## Status
83
85
 
84
- `v0.198.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
86
+ `v0.199.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
85
87
 
88
+ ### What's new in 0.199.0 (contract 1.159.0)
89
+
90
+ **A widget package can bundle its own images and sounds — `assets/` + `useBundledAsset(fileName)`.** Files in a flat `assets/` directory ship with the package, and the new hook returns each one's URL on web and native alike:
91
+
92
+ ```jsx
93
+ import { Image, useAudioPlayer, useBundledAsset } from "@colixsystems/widget-sdk";
94
+
95
+ function Welcome() {
96
+ const logo = useBundledAsset("logo.png");
97
+ const chime = useAudioPlayer(useBundledAsset("chime.mp3"));
98
+ return logo ? <Image source={{ uri: logo }} style={{ width: 96, height: 96 }} /> : null;
99
+ }
100
+ ```
101
+
102
+ - **Allowed:** `png`, `jpg`, `jpeg`, `gif`, `webp`, `mp3`, `m4a` and `wav`; at most 50 files; the whole package at most 12 MB. The rules are published as `CONTRACT.bundledAssets` and `CONTRACT.packageMaxBytes`.
103
+ - **`ctx.widget.assets`** is the map the hook reads: bundled file name → absolute URL, `{}` when the package bundles nothing. A host that predates it publishes no map, and the hook returns `null`.
104
+ - **New linter rule `bundled-asset-missing` (error).** With the new `assetFiles` option, `lintSource` flags a `useBundledAsset("name")` literal naming a file the package does not ship. `appstudio-widget lint` passes the option itself and also validates every file in `assets/`.
105
+ - **New exports** `validateBundledAssets`, `sniffBundledAssetType` and `isValidBundledAssetName`, from the package root and from `@colixsystems/widget-sdk/bundled-assets` (ESM and CommonJS).
106
+ - **`appstudio-widget dev`** serves a widget directory's files at `/assets/<name>`, lists them at `/assets.json`, and reloads when one changes.
107
+
108
+ See [Bundled images and sounds](#bundled-images-and-sounds). Additive: a widget with no `assets/` directory is unaffected.
86
109
  ### What's new in 0.198.0 (contract 1.158.0)
87
110
 
88
111
  **`manifest.translations` may declare up to 200 keys (was 100).** A messenger or a form builder outgrows 100 strings once it ships every label its users read. Widening: every manifest that validated before still does.
@@ -2596,6 +2619,70 @@ native-only package while claiming web (or vice-versa) fails the
2596
2619
  platform, or ship the split pair. So you can't accidentally publish a widget
2597
2620
  that renders on one platform and blanks on the other.
2598
2621
 
2622
+ ## Bundled images and sounds
2623
+
2624
+ A widget package can ship its own images and sounds, so a logo, an illustration or a notification chime travels with the widget instead of being uploaded to every workspace by hand. Put the files in an `assets/` directory at the package root and list it in `package.json`'s `files`:
2625
+
2626
+ ```
2627
+ my-widget/
2628
+ ├── package.json "files": ["widget.jsx", "appstudio.manifest.json", "assets"]
2629
+ ├── manifest.js
2630
+ ├── widget.jsx
2631
+ └── assets/
2632
+ ├── logo.png
2633
+ └── chime.mp3
2634
+ ```
2635
+
2636
+ The rules are published as `CONTRACT.bundledAssets` and `CONTRACT.packageMaxBytes`:
2637
+
2638
+ | Rule | Value |
2639
+ | ---- | ----- |
2640
+ | Images | `png`, `jpg`, `jpeg`, `gif`, `webp` |
2641
+ | Sounds | `mp3`, `m4a`, `wav` |
2642
+ | Layout | one flat `assets/` directory — no subdirectories |
2643
+ | File name | starts with a letter or digit; letters, digits, `.`, `-` and `_` only; at most 64 characters; unique ignoring case |
2644
+ | Files | at most 50 |
2645
+ | Package size | at most 12 MB for the whole package, bundled files included |
2646
+
2647
+ A file must really be the type its extension says: the content is checked, so a renamed `.svg` or `.bmp` is refused.
2648
+
2649
+ Read a file with `useBundledAsset(fileName)`. It returns an absolute URL on both the web Player and the native app, or `null` when the package has no file with that name:
2650
+
2651
+ ```jsx
2652
+ import {
2653
+ Image,
2654
+ Pressable,
2655
+ Text,
2656
+ useAudioPlayer,
2657
+ useBundledAsset,
2658
+ } from "@colixsystems/widget-sdk";
2659
+
2660
+ export default function Welcome() {
2661
+ const logo = useBundledAsset("logo.png");
2662
+ const chime = useAudioPlayer(useBundledAsset("chime.mp3"));
2663
+ return (
2664
+ <Pressable onPress={() => chime.play()}>
2665
+ {logo ? <Image source={{ uri: logo }} style={{ width: 96, height: 96 }} /> : null}
2666
+ <Text>Tap to play</Text>
2667
+ </Pressable>
2668
+ );
2669
+ }
2670
+ ```
2671
+
2672
+ Only an uploaded package can bundle files. A widget written in the Studio has none, so `useBundledAsset` returns `null` there; use `useAsset` / `useAssetsByTag` for files a workspace uploads itself. When a workspace installs the widget, its bundled files also appear in that workspace's assets.
2673
+
2674
+ `appstudio-widget lint` checks an `assets/` directory beside each file it lints. A file that may not be bundled is an error named by its code (`ASSET_NAME_INVALID`, `ASSET_TYPE_NOT_ALLOWED`, `ASSET_EMPTY`, `ASSET_CONTENT_MISMATCH`, `ASSET_NAME_DUPLICATE`, `ASSET_TOO_MANY`), and the linter rule **`bundled-asset-missing`** (error) flags a `useBundledAsset("name")` literal that names a file the directory does not contain. A name built at runtime is not checked. The same checks are available as functions:
2675
+
2676
+ ```js
2677
+ import { lintSource } from "@colixsystems/widget-sdk/linter";
2678
+ import { validateBundledAssets } from "@colixsystems/widget-sdk/bundled-assets";
2679
+
2680
+ const { ok, errors, assets } = validateBundledAssets([{ name: "logo.png", bytes }]);
2681
+ const report = lintSource(source, { assetFiles: assets.map((a) => a.name) });
2682
+ ```
2683
+
2684
+ `validateBundledAssets(files)` returns `{ ok, errors: [{ code, name, message }], assets: [{ name, kind, mimeType, sizeBytes }] }`. `sniffBundledAssetType(bytes)` identifies one file from its leading bytes and `isValidBundledAssetName(name)` checks one name. Omit `assetFiles` and `lintSource` skips `bundled-asset-missing`.
2685
+
2599
2686
  ## Linter
2600
2687
 
2601
2688
  ```sh
@@ -2645,7 +2732,9 @@ and serves it over `http://127.0.0.1:4400`:
2645
2732
  | ----- | ------- |
2646
2733
  | `GET /widget.mjs` | the transpiled entry — an ES module the Studio loads exactly like a published `widget.mjs` |
2647
2734
  | `GET /manifest.json` | the manifest (from `--manifest`, else a sibling `manifest.json`); only consulted for a bare-component bundle |
2648
- | `GET /__dev/events` | Server-Sent-Events stream that emits `reload` on every source change |
2735
+ | `GET /assets/<name>` | a bundled image or sound from the widget directory's `assets/` (directory mode) |
2736
+ | `GET /assets.json` | `{ assets }` — the names of the valid bundled files, sorted |
2737
+ | `GET /__dev/events` | Server-Sent-Events stream that emits `reload` on every source change, and on every change under `assets/` |
2649
2738
  | `GET /__dev/health` | `{ ok, manifestId }` |
2650
2739
 
2651
2740
  Then, in the Studio Builder (dev mode only), open the **Dev widgets** panel in
@@ -0,0 +1,60 @@
1
+ // Reads a package's assets/ directory from disk for the Node-side tools (the
2
+ // CLI, the dev server, the first-party packer). The rules themselves live in
3
+ // bundled-assets.js, which stays filesystem-free.
4
+
5
+ import { lstatSync, readdirSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import {
8
+ BUNDLED_ASSET_DIRECTORY,
9
+ validateBundledAssets,
10
+ } from "./bundled-assets.js";
11
+
12
+ const NO_BYTES = new Uint8Array(0);
13
+
14
+ // npm pack never ships these, so they are not part of the package.
15
+ function _isOsMetadata(name) {
16
+ return name === ".DS_Store" || name.startsWith("._");
17
+ }
18
+
19
+ function _isRealDirectory(path) {
20
+ if (!path) return false;
21
+ try {
22
+ return lstatSync(path).isDirectory();
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ function _readEntry(assetsDir, entry) {
29
+ const absPath = join(assetsDir, entry.name);
30
+ // A trailing slash makes the validator refuse the subdirectory by name.
31
+ if (entry.isDirectory()) return { name: `${entry.name}/`, absPath, bytes: NO_BYTES };
32
+ return { name: entry.name, absPath, bytes: readFileSync(absPath) };
33
+ }
34
+
35
+ /** Path of the assets/ directory that sits beside a widget's source. */
36
+ export function bundledAssetDirectoryOf(ownerDir) {
37
+ return join(ownerDir, BUNDLED_ASSET_DIRECTORY);
38
+ }
39
+
40
+ /**
41
+ * Read and validate one assets/ directory (non-recursive). Links are never
42
+ * followed: a linked directory reads as absent and a linked file is skipped.
43
+ *
44
+ * Returns the `validateBundledAssets` result plus `files`
45
+ * (`[{ name, absPath, bytes }]`) and `fileNames` (the regular files present,
46
+ * valid or not). A missing directory, or no path, is an empty, valid result.
47
+ */
48
+ export function loadBundledAssets(assetsDir) {
49
+ const files = _isRealDirectory(assetsDir)
50
+ ? readdirSync(assetsDir, { withFileTypes: true })
51
+ .filter((entry) => entry.isFile() || entry.isDirectory())
52
+ .filter((entry) => !_isOsMetadata(entry.name))
53
+ .map((entry) => _readEntry(assetsDir, entry))
54
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
55
+ : [];
56
+ const fileNames = files
57
+ .filter((file) => !file.name.endsWith("/"))
58
+ .map((file) => file.name);
59
+ return { files, fileNames, ...validateBundledAssets(files) };
60
+ }
@@ -0,0 +1,239 @@
1
+ // The files a widget package ships in its assets/ directory: which names and
2
+ // types are allowed. Pure and filesystem-free, so the CLI, the dev server, the
3
+ // first-party packer and the backend all apply one rule set.
4
+ // CommonJS twin of bundled-assets.js for the backend. Keep both in lockstep.
5
+
6
+ const { CONTRACT } = require("./contract.cjs");
7
+
8
+ const RULES = CONTRACT.bundledAssets;
9
+ const BUNDLED_ASSET_DIRECTORY = RULES.directory;
10
+ const ALLOWED_EXTENSIONS = Object.values(RULES.types).flatMap(
11
+ (type) => type.extensions,
12
+ );
13
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
14
+
15
+ const PNG = { kind: "image", mimeType: "image/png", extensions: ["png"] };
16
+ const JPEG = { kind: "image", mimeType: "image/jpeg", extensions: ["jpg", "jpeg"] };
17
+ const GIF = { kind: "image", mimeType: "image/gif", extensions: ["gif"] };
18
+ const WEBP = { kind: "image", mimeType: "image/webp", extensions: ["webp"] };
19
+ const WAV = { kind: "audio", mimeType: "audio/wav", extensions: ["wav"] };
20
+ const MP3 = { kind: "audio", mimeType: "audio/mpeg", extensions: ["mp3"] };
21
+ const M4A = { kind: "audio", mimeType: "audio/mp4", extensions: ["m4a"] };
22
+
23
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
24
+ const JPEG_SIGNATURE = [0xff, 0xd8, 0xff];
25
+ const M4A_BRANDS = ["M4A ", "M4B ", "mp42", "isom", "mp41"];
26
+
27
+ function _hasBytes(bytes, signature, offset) {
28
+ if (bytes.length < offset + signature.length) return false;
29
+ return signature.every((byte, i) => bytes[offset + i] === byte);
30
+ }
31
+
32
+ function _hasText(bytes, text, offset) {
33
+ return _hasBytes(bytes, Array.from(text, (ch) => ch.charCodeAt(0)), offset);
34
+ }
35
+
36
+ // kbps by bitrate index; index 0 (free format) and 15 are never accepted.
37
+ const MPEG_BITRATES = {
38
+ v1Layer1: [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448],
39
+ v1Layer2: [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384],
40
+ v1Layer3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
41
+ v2Layer1: [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256],
42
+ v2Layer2or3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
43
+ };
44
+ // Hz by the header's version bits (3 = MPEG-1, 2 = MPEG-2, 0 = MPEG-2.5).
45
+ const MPEG_SAMPLE_RATES = {
46
+ 3: [44100, 48000, 32000],
47
+ 2: [22050, 24000, 16000],
48
+ 0: [11025, 12000, 8000],
49
+ };
50
+ const MPEG_LAYER_1 = 3;
51
+ const MPEG_LAYER_3 = 1;
52
+
53
+ function _mpegBitrateTable(isVersion1, layer) {
54
+ if (layer === MPEG_LAYER_1) return isVersion1 ? MPEG_BITRATES.v1Layer1 : MPEG_BITRATES.v2Layer1;
55
+ if (!isVersion1) return MPEG_BITRATES.v2Layer2or3;
56
+ return layer === MPEG_LAYER_3 ? MPEG_BITRATES.v1Layer3 : MPEG_BITRATES.v1Layer2;
57
+ }
58
+
59
+ /** Byte length of the MPEG audio frame whose header starts at `offset`, or 0. */
60
+ function _mpegFrameLength(bytes, offset) {
61
+ if (bytes.length < offset + 4) return 0;
62
+ if (bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) return 0;
63
+ const version = (bytes[offset + 1] >> 3) & 3;
64
+ const layer = (bytes[offset + 1] >> 1) & 3;
65
+ const bitrateIndex = bytes[offset + 2] >> 4;
66
+ const sampleRateIndex = (bytes[offset + 2] >> 2) & 3;
67
+ const padding = (bytes[offset + 2] >> 1) & 1;
68
+ if (version === 1 || layer === 0 || sampleRateIndex === 3) return 0;
69
+ if (bitrateIndex === 0 || bitrateIndex === 15) return 0;
70
+ const isVersion1 = version === 3;
71
+ const bitrate = _mpegBitrateTable(isVersion1, layer)[bitrateIndex] * 1000;
72
+ const sampleRate = MPEG_SAMPLE_RATES[version][sampleRateIndex];
73
+ if (layer === MPEG_LAYER_1) return (Math.floor((12 * bitrate) / sampleRate) + padding) * 4;
74
+ const samplesPerByte = layer === MPEG_LAYER_3 && !isVersion1 ? 72 : 144;
75
+ return Math.floor((samplesPerByte * bitrate) / sampleRate) + padding;
76
+ }
77
+
78
+ // A two-byte sync alone matches a UTF-16 BOM, so a second frame must follow.
79
+ function _isMpegAudioAt(bytes, offset) {
80
+ const length = _mpegFrameLength(bytes, offset);
81
+ if (length === 0) return false;
82
+ const next = offset + length;
83
+ return next === bytes.length || _mpegFrameLength(bytes, next) > 0;
84
+ }
85
+
86
+ const ID3_HEADER_BYTES = 10;
87
+
88
+ function _isMp3(bytes) {
89
+ if (!_hasText(bytes, "ID3", 0)) return _isMpegAudioAt(bytes, 0);
90
+ if (bytes.length < ID3_HEADER_BYTES || bytes[3] === 0xff || bytes[4] === 0xff) return false;
91
+ const size = [bytes[6], bytes[7], bytes[8], bytes[9]];
92
+ if (size.some((byte) => byte >= 0x80)) return false;
93
+ const tagBytes = (size[0] << 21) | (size[1] << 14) | (size[2] << 7) | size[3];
94
+ const footerBytes = bytes[5] & 0x10 ? ID3_HEADER_BYTES : 0;
95
+ return _isMpegAudioAt(bytes, ID3_HEADER_BYTES + tagBytes + footerBytes);
96
+ }
97
+
98
+ function _describe(type) {
99
+ return { kind: type.kind, mimeType: type.mimeType, extensions: [...type.extensions] };
100
+ }
101
+
102
+ function _sniff(bytes) {
103
+ if (_hasBytes(bytes, PNG_SIGNATURE, 0)) return PNG;
104
+ if (_hasBytes(bytes, JPEG_SIGNATURE, 0)) return JPEG;
105
+ if (_hasText(bytes, "GIF87a", 0) || _hasText(bytes, "GIF89a", 0)) return GIF;
106
+ if (_hasText(bytes, "RIFF", 0)) {
107
+ if (_hasText(bytes, "WEBP", 8)) return WEBP;
108
+ if (_hasText(bytes, "WAVE", 8)) return WAV;
109
+ return null;
110
+ }
111
+ if (_hasText(bytes, "ftyp", 4)) {
112
+ return M4A_BRANDS.some((brand) => _hasText(bytes, brand, 8)) ? M4A : null;
113
+ }
114
+ if (_isMp3(bytes)) return MP3;
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * Identify a bundled file from its leading bytes alone.
120
+ * Returns `{ kind: "image" | "audio", mimeType, extensions }`, or `null` when
121
+ * the bytes are not one of the bundled types.
122
+ */
123
+ function sniffBundledAssetType(bytes) {
124
+ if (!bytes || typeof bytes.length !== "number") return null;
125
+ const type = _sniff(bytes);
126
+ return type ? _describe(type) : null;
127
+ }
128
+
129
+ function _extensionOf(name) {
130
+ const dot = name.lastIndexOf(".");
131
+ return dot === -1 ? "" : name.slice(dot + 1).toLowerCase();
132
+ }
133
+
134
+ function _isWellFormedName(name) {
135
+ return (
136
+ typeof name === "string" &&
137
+ name.length <= RULES.maxNameLength &&
138
+ NAME_RE.test(name) &&
139
+ !name.includes("..")
140
+ );
141
+ }
142
+
143
+ /** True when `name` may be used as a file name inside a package's assets/. */
144
+ function isValidBundledAssetName(name) {
145
+ return _isWellFormedName(name) && ALLOWED_EXTENSIONS.includes(_extensionOf(name));
146
+ }
147
+
148
+ function _extensionList() {
149
+ const last = ALLOWED_EXTENSIONS[ALLOWED_EXTENSIONS.length - 1];
150
+ return `${ALLOWED_EXTENSIONS.slice(0, -1).join(", ")} and ${last}`;
151
+ }
152
+
153
+ function _error(code, name, rule) {
154
+ const where = `${BUNDLED_ASSET_DIRECTORY}/${name === null ? "" : name}`;
155
+ return { code, name, message: `${where}: ${rule}` };
156
+ }
157
+
158
+ function _fileError(file, seenNames) {
159
+ const { name, bytes } = file;
160
+ if (!_isWellFormedName(name)) {
161
+ return _error(
162
+ "ASSET_NAME_INVALID",
163
+ typeof name === "string" ? name : String(name),
164
+ "a bundled file name starts with a letter or digit, uses only letters, " +
165
+ `digits, dots, hyphens and underscores, and is at most ${RULES.maxNameLength} ` +
166
+ "characters long. Subdirectories are not allowed.",
167
+ );
168
+ }
169
+ const extension = _extensionOf(name);
170
+ if (!ALLOWED_EXTENSIONS.includes(extension)) {
171
+ return _error(
172
+ "ASSET_TYPE_NOT_ALLOWED",
173
+ name,
174
+ `only ${_extensionList()} files can be bundled.`,
175
+ );
176
+ }
177
+ if (seenNames.has(name.toLowerCase())) {
178
+ return _error(
179
+ "ASSET_NAME_DUPLICATE",
180
+ name,
181
+ "another bundled file has the same name when upper and lower case are ignored.",
182
+ );
183
+ }
184
+ seenNames.add(name.toLowerCase());
185
+ if (!bytes || !bytes.length) {
186
+ return _error("ASSET_EMPTY", name, "the file is empty.");
187
+ }
188
+ const sniffed = _sniff(bytes);
189
+ if (!sniffed || !sniffed.extensions.includes(extension)) {
190
+ return _error(
191
+ "ASSET_CONTENT_MISMATCH",
192
+ name,
193
+ `the file's content is not a valid .${extension} file.`,
194
+ );
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /**
200
+ * Validate the files of a package's assets/ directory.
201
+ * `files` is `[{ name, bytes }]`. Returns `{ ok, errors, assets }` where each
202
+ * error is `{ code, name, message }` and each accepted asset is
203
+ * `{ name, kind, mimeType, sizeBytes }`, sorted by name.
204
+ * Error codes: ASSET_NAME_INVALID, ASSET_TYPE_NOT_ALLOWED, ASSET_EMPTY,
205
+ * ASSET_CONTENT_MISMATCH, ASSET_NAME_DUPLICATE, ASSET_TOO_MANY.
206
+ */
207
+ function validateBundledAssets(files) {
208
+ const list = Array.isArray(files) ? files : [];
209
+ const errors = [];
210
+ const assets = [];
211
+ const seenNames = new Set();
212
+ if (list.length > RULES.maxFiles) {
213
+ errors.push(
214
+ _error(
215
+ "ASSET_TOO_MANY",
216
+ null,
217
+ `a package can bundle at most ${RULES.maxFiles} files; this one has ${list.length}.`,
218
+ ),
219
+ );
220
+ }
221
+ for (const file of list) {
222
+ const error = _fileError(file || {}, seenNames);
223
+ if (error) {
224
+ errors.push(error);
225
+ continue;
226
+ }
227
+ const { kind, mimeType } = _sniff(file.bytes);
228
+ assets.push({ name: file.name, kind, mimeType, sizeBytes: file.bytes.length });
229
+ }
230
+ assets.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
231
+ return { ok: errors.length === 0, errors, assets };
232
+ }
233
+
234
+ module.exports = {
235
+ BUNDLED_ASSET_DIRECTORY,
236
+ isValidBundledAssetName,
237
+ sniffBundledAssetType,
238
+ validateBundledAssets,
239
+ };
@@ -0,0 +1,239 @@
1
+ // The files a widget package ships in its assets/ directory: which names and
2
+ // types are allowed. Pure and filesystem-free, so the CLI, the dev server, the
3
+ // first-party packer and the backend all apply one rule set.
4
+ // Keep bundled-assets.cjs in lockstep.
5
+
6
+ import { CONTRACT } from "./contract.js";
7
+
8
+ const RULES = CONTRACT.bundledAssets;
9
+ const BUNDLED_ASSET_DIRECTORY = RULES.directory;
10
+ const ALLOWED_EXTENSIONS = Object.values(RULES.types).flatMap(
11
+ (type) => type.extensions,
12
+ );
13
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
14
+
15
+ const PNG = { kind: "image", mimeType: "image/png", extensions: ["png"] };
16
+ const JPEG = { kind: "image", mimeType: "image/jpeg", extensions: ["jpg", "jpeg"] };
17
+ const GIF = { kind: "image", mimeType: "image/gif", extensions: ["gif"] };
18
+ const WEBP = { kind: "image", mimeType: "image/webp", extensions: ["webp"] };
19
+ const WAV = { kind: "audio", mimeType: "audio/wav", extensions: ["wav"] };
20
+ const MP3 = { kind: "audio", mimeType: "audio/mpeg", extensions: ["mp3"] };
21
+ const M4A = { kind: "audio", mimeType: "audio/mp4", extensions: ["m4a"] };
22
+
23
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
24
+ const JPEG_SIGNATURE = [0xff, 0xd8, 0xff];
25
+ const M4A_BRANDS = ["M4A ", "M4B ", "mp42", "isom", "mp41"];
26
+
27
+ function _hasBytes(bytes, signature, offset) {
28
+ if (bytes.length < offset + signature.length) return false;
29
+ return signature.every((byte, i) => bytes[offset + i] === byte);
30
+ }
31
+
32
+ function _hasText(bytes, text, offset) {
33
+ return _hasBytes(bytes, Array.from(text, (ch) => ch.charCodeAt(0)), offset);
34
+ }
35
+
36
+ // kbps by bitrate index; index 0 (free format) and 15 are never accepted.
37
+ const MPEG_BITRATES = {
38
+ v1Layer1: [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448],
39
+ v1Layer2: [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384],
40
+ v1Layer3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
41
+ v2Layer1: [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256],
42
+ v2Layer2or3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
43
+ };
44
+ // Hz by the header's version bits (3 = MPEG-1, 2 = MPEG-2, 0 = MPEG-2.5).
45
+ const MPEG_SAMPLE_RATES = {
46
+ 3: [44100, 48000, 32000],
47
+ 2: [22050, 24000, 16000],
48
+ 0: [11025, 12000, 8000],
49
+ };
50
+ const MPEG_LAYER_1 = 3;
51
+ const MPEG_LAYER_3 = 1;
52
+
53
+ function _mpegBitrateTable(isVersion1, layer) {
54
+ if (layer === MPEG_LAYER_1) return isVersion1 ? MPEG_BITRATES.v1Layer1 : MPEG_BITRATES.v2Layer1;
55
+ if (!isVersion1) return MPEG_BITRATES.v2Layer2or3;
56
+ return layer === MPEG_LAYER_3 ? MPEG_BITRATES.v1Layer3 : MPEG_BITRATES.v1Layer2;
57
+ }
58
+
59
+ /** Byte length of the MPEG audio frame whose header starts at `offset`, or 0. */
60
+ function _mpegFrameLength(bytes, offset) {
61
+ if (bytes.length < offset + 4) return 0;
62
+ if (bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) return 0;
63
+ const version = (bytes[offset + 1] >> 3) & 3;
64
+ const layer = (bytes[offset + 1] >> 1) & 3;
65
+ const bitrateIndex = bytes[offset + 2] >> 4;
66
+ const sampleRateIndex = (bytes[offset + 2] >> 2) & 3;
67
+ const padding = (bytes[offset + 2] >> 1) & 1;
68
+ if (version === 1 || layer === 0 || sampleRateIndex === 3) return 0;
69
+ if (bitrateIndex === 0 || bitrateIndex === 15) return 0;
70
+ const isVersion1 = version === 3;
71
+ const bitrate = _mpegBitrateTable(isVersion1, layer)[bitrateIndex] * 1000;
72
+ const sampleRate = MPEG_SAMPLE_RATES[version][sampleRateIndex];
73
+ if (layer === MPEG_LAYER_1) return (Math.floor((12 * bitrate) / sampleRate) + padding) * 4;
74
+ const samplesPerByte = layer === MPEG_LAYER_3 && !isVersion1 ? 72 : 144;
75
+ return Math.floor((samplesPerByte * bitrate) / sampleRate) + padding;
76
+ }
77
+
78
+ // A two-byte sync alone matches a UTF-16 BOM, so a second frame must follow.
79
+ function _isMpegAudioAt(bytes, offset) {
80
+ const length = _mpegFrameLength(bytes, offset);
81
+ if (length === 0) return false;
82
+ const next = offset + length;
83
+ return next === bytes.length || _mpegFrameLength(bytes, next) > 0;
84
+ }
85
+
86
+ const ID3_HEADER_BYTES = 10;
87
+
88
+ function _isMp3(bytes) {
89
+ if (!_hasText(bytes, "ID3", 0)) return _isMpegAudioAt(bytes, 0);
90
+ if (bytes.length < ID3_HEADER_BYTES || bytes[3] === 0xff || bytes[4] === 0xff) return false;
91
+ const size = [bytes[6], bytes[7], bytes[8], bytes[9]];
92
+ if (size.some((byte) => byte >= 0x80)) return false;
93
+ const tagBytes = (size[0] << 21) | (size[1] << 14) | (size[2] << 7) | size[3];
94
+ const footerBytes = bytes[5] & 0x10 ? ID3_HEADER_BYTES : 0;
95
+ return _isMpegAudioAt(bytes, ID3_HEADER_BYTES + tagBytes + footerBytes);
96
+ }
97
+
98
+ function _describe(type) {
99
+ return { kind: type.kind, mimeType: type.mimeType, extensions: [...type.extensions] };
100
+ }
101
+
102
+ function _sniff(bytes) {
103
+ if (_hasBytes(bytes, PNG_SIGNATURE, 0)) return PNG;
104
+ if (_hasBytes(bytes, JPEG_SIGNATURE, 0)) return JPEG;
105
+ if (_hasText(bytes, "GIF87a", 0) || _hasText(bytes, "GIF89a", 0)) return GIF;
106
+ if (_hasText(bytes, "RIFF", 0)) {
107
+ if (_hasText(bytes, "WEBP", 8)) return WEBP;
108
+ if (_hasText(bytes, "WAVE", 8)) return WAV;
109
+ return null;
110
+ }
111
+ if (_hasText(bytes, "ftyp", 4)) {
112
+ return M4A_BRANDS.some((brand) => _hasText(bytes, brand, 8)) ? M4A : null;
113
+ }
114
+ if (_isMp3(bytes)) return MP3;
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * Identify a bundled file from its leading bytes alone.
120
+ * Returns `{ kind: "image" | "audio", mimeType, extensions }`, or `null` when
121
+ * the bytes are not one of the bundled types.
122
+ */
123
+ function sniffBundledAssetType(bytes) {
124
+ if (!bytes || typeof bytes.length !== "number") return null;
125
+ const type = _sniff(bytes);
126
+ return type ? _describe(type) : null;
127
+ }
128
+
129
+ function _extensionOf(name) {
130
+ const dot = name.lastIndexOf(".");
131
+ return dot === -1 ? "" : name.slice(dot + 1).toLowerCase();
132
+ }
133
+
134
+ function _isWellFormedName(name) {
135
+ return (
136
+ typeof name === "string" &&
137
+ name.length <= RULES.maxNameLength &&
138
+ NAME_RE.test(name) &&
139
+ !name.includes("..")
140
+ );
141
+ }
142
+
143
+ /** True when `name` may be used as a file name inside a package's assets/. */
144
+ function isValidBundledAssetName(name) {
145
+ return _isWellFormedName(name) && ALLOWED_EXTENSIONS.includes(_extensionOf(name));
146
+ }
147
+
148
+ function _extensionList() {
149
+ const last = ALLOWED_EXTENSIONS[ALLOWED_EXTENSIONS.length - 1];
150
+ return `${ALLOWED_EXTENSIONS.slice(0, -1).join(", ")} and ${last}`;
151
+ }
152
+
153
+ function _error(code, name, rule) {
154
+ const where = `${BUNDLED_ASSET_DIRECTORY}/${name === null ? "" : name}`;
155
+ return { code, name, message: `${where}: ${rule}` };
156
+ }
157
+
158
+ function _fileError(file, seenNames) {
159
+ const { name, bytes } = file;
160
+ if (!_isWellFormedName(name)) {
161
+ return _error(
162
+ "ASSET_NAME_INVALID",
163
+ typeof name === "string" ? name : String(name),
164
+ "a bundled file name starts with a letter or digit, uses only letters, " +
165
+ `digits, dots, hyphens and underscores, and is at most ${RULES.maxNameLength} ` +
166
+ "characters long. Subdirectories are not allowed.",
167
+ );
168
+ }
169
+ const extension = _extensionOf(name);
170
+ if (!ALLOWED_EXTENSIONS.includes(extension)) {
171
+ return _error(
172
+ "ASSET_TYPE_NOT_ALLOWED",
173
+ name,
174
+ `only ${_extensionList()} files can be bundled.`,
175
+ );
176
+ }
177
+ if (seenNames.has(name.toLowerCase())) {
178
+ return _error(
179
+ "ASSET_NAME_DUPLICATE",
180
+ name,
181
+ "another bundled file has the same name when upper and lower case are ignored.",
182
+ );
183
+ }
184
+ seenNames.add(name.toLowerCase());
185
+ if (!bytes || !bytes.length) {
186
+ return _error("ASSET_EMPTY", name, "the file is empty.");
187
+ }
188
+ const sniffed = _sniff(bytes);
189
+ if (!sniffed || !sniffed.extensions.includes(extension)) {
190
+ return _error(
191
+ "ASSET_CONTENT_MISMATCH",
192
+ name,
193
+ `the file's content is not a valid .${extension} file.`,
194
+ );
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /**
200
+ * Validate the files of a package's assets/ directory.
201
+ * `files` is `[{ name, bytes }]`. Returns `{ ok, errors, assets }` where each
202
+ * error is `{ code, name, message }` and each accepted asset is
203
+ * `{ name, kind, mimeType, sizeBytes }`, sorted by name.
204
+ * Error codes: ASSET_NAME_INVALID, ASSET_TYPE_NOT_ALLOWED, ASSET_EMPTY,
205
+ * ASSET_CONTENT_MISMATCH, ASSET_NAME_DUPLICATE, ASSET_TOO_MANY.
206
+ */
207
+ function validateBundledAssets(files) {
208
+ const list = Array.isArray(files) ? files : [];
209
+ const errors = [];
210
+ const assets = [];
211
+ const seenNames = new Set();
212
+ if (list.length > RULES.maxFiles) {
213
+ errors.push(
214
+ _error(
215
+ "ASSET_TOO_MANY",
216
+ null,
217
+ `a package can bundle at most ${RULES.maxFiles} files; this one has ${list.length}.`,
218
+ ),
219
+ );
220
+ }
221
+ for (const file of list) {
222
+ const error = _fileError(file || {}, seenNames);
223
+ if (error) {
224
+ errors.push(error);
225
+ continue;
226
+ }
227
+ const { kind, mimeType } = _sniff(file.bytes);
228
+ assets.push({ name: file.name, kind, mimeType, sizeBytes: file.bytes.length });
229
+ }
230
+ assets.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
231
+ return { ok: errors.length === 0, errors, assets };
232
+ }
233
+
234
+ export {
235
+ BUNDLED_ASSET_DIRECTORY,
236
+ isValidBundledAssetName,
237
+ sniffBundledAssetType,
238
+ validateBundledAssets,
239
+ };