@flow-like/widget-bundler 0.1.1

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 ADDED
@@ -0,0 +1,388 @@
1
+ # @flow-like/widget-bundler
2
+
3
+ Builds `.flwb` (Flow-Like Widget Bundle) artifacts for package projects: extracts typed contracts from plain TypeScript widget configs, inlines framework build output into self-contained documents, and packs everything into a deterministic ZIP with `bundle.json`.
4
+
5
+ The Rust source of truth for the emitted formats is `packages/wasm/schema/src/widget.rs` (`contract.json`) and `packages/wasm/schema/src/widget_bundle.rs` (`bundle.json` / archive layout).
6
+
7
+ ## CLI
8
+
9
+ ```bash
10
+ # Pack all framework groups of a project into widgets.flwb
11
+ bunx @flow-like/widget-bundler pack --project . --out widgets.flwb \
12
+ [--serving-prefix flow-widget://pkg@hash/] [--created-at 2026-07-21T12:00:00Z]
13
+
14
+ # Mock-host dev harness (design §8.4 Layer 1): starts every framework group's
15
+ # own dev script and serves a browser page that is a real flw/1 host
16
+ bunx @flow-like/widget-bundler dev [--project .] [--port 4700]
17
+
18
+ # Validate widget contracts of a project, or a built bundle
19
+ bunx @flow-like/widget-bundler validate .
20
+ bunx @flow-like/widget-bundler validate widgets.flwb
21
+
22
+ # Scaffold a new widget inside a framework group
23
+ bunx @flow-like/widget-bundler add kpi-card --group widgets/react
24
+ ```
25
+
26
+ `dev` spawns `bun run dev -- --port <n> --strictPort` per framework group (ports assigned from the harness port upward; the child's `Local:` stdout line overrides the assignment if the dev script ignores the flags) and serves the harness on `http://localhost:4700/`: every widget in a sandboxed iframe speaking `flw/1`, with a contract-generated props panel, `dev.fixtures` presets, event log with contract validation, query invoker, theme toggle + token editor, viewport/preview controls, and a raw protocol trace. Contracts are re-extracted per request, cached by `widget.config.ts` mtime (`GET /api/contract/<group>/<id>`).
27
+
28
+ `pack` requires each framework group (`widgets/<group>/`) to be built first (`bun run build` producing `dist/`). `createdAt` is omitted from `bundle.json` unless `--created-at` or `SOURCE_DATE_EPOCH` is set, keeping builds byte-for-byte deterministic. Every file under a group's `dist/shared/` is packed (chunks can import each other); each widget's `assets` lists the chunks its document references directly.
29
+
30
+ Packed documents carry a `<meta>` CSP built from the widget's `capabilities`
31
+ only. It allows bundle assets from their own web origin and Flow-Like's desktop
32
+ widget protocol, local `data:` and `blob:` bytes, and `--serving-prefix` adds
33
+ another asset source. It never lists network hosts. Declare those per widget in `csp` (see
34
+ [Network access](#network-access)).
35
+
36
+ The `--connect` flag was removed. `pack --connect` now fails with
37
+ `The --connect flag was removed: declare network sources per widget in widget.config.ts "csp"`,
38
+ and the programmatic `connectHosts` pack option fails the same way. Move each
39
+ host into the `csp` of the widgets that need it.
40
+
41
+ ## Vite plugin
42
+
43
+ ```ts
44
+ // widgets/react/vite.config.ts
45
+ import { defineConfig } from "vite";
46
+ import { flowLikeWidgets } from "@flow-like/widget-bundler/vite";
47
+
48
+ export default defineConfig({ plugins: [flowLikeWidgets()] });
49
+ ```
50
+
51
+ Discovers `src/widgets/*/index.html` as one build input per widget id, routes entry/chunk/asset output into `dist/.../shared/`, and injects the extracted `__FLW_CONTRACT__` script during dev and build (pack re-injects it authoritatively).
52
+
53
+ ## Widget authoring
54
+
55
+ ```ts
56
+ // widgets/react/src/widgets/sales-chart/widget.config.ts
57
+ import { defineWidget } from "@flow-like/widget-sdk";
58
+
59
+ interface Inputs {
60
+ /** Chart headline @default "Sales" */
61
+ title: string;
62
+ }
63
+ interface Events {
64
+ refreshRequested: void;
65
+ }
66
+ interface Queries {
67
+ getValue: { args: void; returns: string };
68
+ }
69
+
70
+ export default defineWidget<Inputs, Events, Queries>({
71
+ id: "sales-chart",
72
+ name: "Sales Chart",
73
+ description: "Interactive chart",
74
+ sizing: { defaultHeight: 320, resizable: true },
75
+ });
76
+ ```
77
+
78
+ Contracts are derived statically (TypeScript compiler API + `ts-json-schema-generator`): JSDoc `@default` / `@minimum` / `@maximum` become pin defaults and bounds, string unions become enum choices, and `void` payloads become `null` schemas. Geometry annotations select the shared geometry validator; other schemas have their `$ref`s inlined and cannot be recursive. Non-optional inputs without a `@default` produce a warning because standalone dev and generated pins have no initial value.
79
+
80
+ Geometry types from `@flow-like/widget-sdk` produce native Geometry pins while
81
+ keeping the same TypeScript contract for widget props, events, and queries:
82
+
83
+ ```ts
84
+ import {
85
+ defineWidget,
86
+ type GeoPoint,
87
+ type GeoPolygon,
88
+ type GeoPosition,
89
+ } from "@flow-like/widget-sdk";
90
+
91
+ /** @geometry Point */
92
+ type SearchCenter = {
93
+ type: "Point";
94
+ coordinates: GeoPosition;
95
+ };
96
+
97
+ interface Inputs {
98
+ center?: SearchCenter;
99
+ /** @default [] */
100
+ stops: GeoPoint[];
101
+ /** @uniqueItems true @default [] */
102
+ visited: GeoPoint[];
103
+ areas?: Record<string, GeoPolygon>;
104
+ }
105
+ ```
106
+
107
+ The generated contract represents a point input as
108
+ `{"type":"json","schema":{"type":"object","x-flow-like-type":"geometry","x-geometry":"Point"}}`.
109
+ `Any` omits `x-geometry`. The host converts that metadata into the Geometry
110
+ pins used by Instantiate Widget and Update Widget Inputs.
111
+
112
+ The SDK also exports `GeoLineString`, `GeoMultiPoint`,
113
+ `GeoMultiLineString`, `GeoMultiPolygon`, `GeoGeometryCollection`, and
114
+ `GeoGeometry`. Custom scalar geometry types can use `@geometry` with one of
115
+ those concrete GeoJSON kinds or `Any`. Put the annotation on the element type
116
+ for arrays, sets, and maps. The annotation replaces the structural schema with
117
+ the geometry profile and retains descriptions and defaults. Extraction rejects
118
+ incompatible type annotations and invalid geometry defaults.
119
+ Geometry is two-dimensional WGS 84 GeoJSON with
120
+ positions in `[longitude, latitude]` order. Feature wrappers are unsupported.
121
+ An explicit nullable union stays JSON-shaped and does not produce a native
122
+ Geometry pin.
123
+
124
+ ## Network access
125
+
126
+ A widget runs with no network access by default. It can always read bytes it
127
+ already holds: `fetch("data:…")`, object URLs from `URL.createObjectURL`,
128
+ `<audio>`/`<video>` with `data:` or `blob:` sources, and `data:`/`blob:` images
129
+ and `data:` fonts. These local schemes are never network access, need no
130
+ approval and cannot be declared. Scripts, frames and workers always stay limited
131
+ to the bundle.
132
+
133
+ To reach other sites, declare `csp` as a list of purpose groups. Each group
134
+ says why the widget needs the network (`reason`) and which sources it may use,
135
+ either as fixed addresses or as widget inputs that carry addresses at runtime.
136
+ Every viewer approves the sources for themselves before the widget can reach
137
+ them.
138
+
139
+ ```ts
140
+ export default defineWidget<Inputs, Events, Queries>({
141
+ id: "live-map",
142
+ name: "Live map",
143
+ capabilities: { workers: true },
144
+ csp: [
145
+ {
146
+ reason: "Loads map tiles and live vehicle positions",
147
+ connectSrc: ["https://api.maptiler.com", "wss://live.example.com"],
148
+ imgSrc: ["https://a.tile.openstreetmap.org", "https://b.tile.openstreetmap.org"],
149
+ },
150
+ {
151
+ reason: "Loads web fonts for map labels",
152
+ styleSrc: ["https://fonts.googleapis.com"],
153
+ fontSrc: ["https://fonts.gstatic.com"],
154
+ },
155
+ ],
156
+ });
157
+ ```
158
+
159
+ | Key | Directive | Allowed schemes | Used for |
160
+ | --- | --- | --- | --- |
161
+ | `connectSrc` | `connect-src` | `https`, `wss` | `fetch`, `XMLHttpRequest`, WebSocket, EventSource |
162
+ | `imgSrc` | `img-src` | `https` | images |
163
+ | `fontSrc` | `font-src` | `https` | web fonts |
164
+ | `mediaSrc` | `media-src` | `https` | audio and video |
165
+ | `styleSrc` | `style-src` | `https` | stylesheets |
166
+
167
+ A group may also hold `inputs` (see [Addresses known only at runtime](#addresses-known-only-at-runtime)).
168
+ No other key is allowed.
169
+
170
+ ### Reasons
171
+
172
+ The viewer sees the reason next to the sources, labelled as text from the
173
+ publisher and placed below what Flow-Like says about each source. Write one
174
+ short sentence about what the widget does with the network. Extraction
175
+ normalizes it (NFC, runs of whitespace become one space, trimmed) and the build
176
+ fails when it:
177
+
178
+ - is not 8 to 120 characters long, or has fewer than 3 letters;
179
+ - uses anything other than letters, marks, numbers, spaces and `, . : ; ( ) - ' / &`
180
+ (no quotes, emoji, `!`, `?`, `%` or `_`);
181
+ - mixes ASCII letters with letters of another script in one word;
182
+ - contains a web address, an email address or a domain name (`cesium.com`,
183
+ `www.`, `https://`); "Node.js" and "e.g." are fine;
184
+ - mentions Flow-Like, or claims that sources are verified, trusted, approved,
185
+ official, certified, secure or safe;
186
+ - in a group with `inputs`, claims who supplies the addresses (app, admin,
187
+ organization, company, workspace, project, owner, …);
188
+ - repeats the reason of another group.
189
+
190
+ Reasons, groups and levels are for display only. They never change what the
191
+ widget may reach, and editing a reason alone does not ask viewers again.
192
+
193
+ ### Sources
194
+
195
+ Each source is `scheme://host` or `scheme://*.host`. Extraction lowercases
196
+ sources, converts internationalized hosts (and wildcard bases) to punycode,
197
+ sorts and deduplicates each list, and checks the grammar shared with the Rust
198
+ schema (`packages/wasm/schema/src/widget_policy.rs`). A source is rejected,
199
+ and the build fails with
200
+ `Invalid widget csp source "<src>" in csp[<n>].<directive> for widget <id>: <reason>`,
201
+ when it has any of the following:
202
+
203
+ - a `*` anywhere but a leading `*.` label (`*`, `https://*`, `https://a.*.example.com`);
204
+ - a port (including `:443`), a path, a query, a fragment or userinfo;
205
+ - a keyword, nonce or hash (`'self'`, `'unsafe-inline'`, `'nonce-…'`);
206
+ - only a scheme (`https:`, `data:`, `blob:`), or `http://` or `ws://`;
207
+ - an IP literal;
208
+ - a host equal to or ending in `localhost`, `local`, `internal`, `lan`,
209
+ `home.arpa`, `test`, `example`, `invalid`, `onion`, or an IP-mapping name
210
+ (`nip.io`, `sslip.io`, `traefik.me`, `localtest.me`, `lvh.me`,
211
+ `localhost.direct`);
212
+ - a wildcard over a public suffix (`https://*.co.uk`, `https://*.kawasaki.jp`),
213
+ checked against the Public Suffix List compiled into the bundler.
214
+
215
+ | Limit | Value |
216
+ | --- | --- |
217
+ | Purpose groups | 1–8 |
218
+ | Sources across all groups (a wildcard counts once per directive) | 16 |
219
+ | Source bytes, Σ(length + 1) | 1536 |
220
+ | Each group | at least one source or one input |
221
+ | A source | belongs to one group (it may appear under several directives of that group) |
222
+
223
+ Hosts can also reject a source that points at their own domain. In that case
224
+ the widget runs without its network permissions and the viewer sees a notice.
225
+
226
+ ### Wildcards
227
+
228
+ `https://*.tiles.example.com` matches every host that ends in
229
+ `.tiles.example.com`, at any depth, and never `tiles.example.com` itself;
230
+ declare that host separately when the widget needs it. Prefer exact hosts: they
231
+ work on every engine, while hosts whose engine mishandles wildcards can
232
+ withhold wildcard sources.
233
+
234
+ ### Warning levels
235
+
236
+ Flow-Like classifies every source from the Public Suffix List and a curated
237
+ catalog of services. Publishers cannot influence the level; the viewer sees it
238
+ on each source and the group shows its highest level.
239
+
240
+ | Level | Label | Meaning | Examples |
241
+ | --- | --- | --- | --- |
242
+ | `known` | Identified service | one named operator; no account holder can receive requests or publish content there | `https://gibs.earthdata.nasa.gov`, `https://tile.googleapis.com` |
243
+ | `external` | External site | one party receives, and Flow-Like cannot verify who | `https://tiles.customer-maps.com`, `https://mybucket.s3.eu-central-1.amazonaws.com`, `https://api.cesium.com` |
244
+ | `shared` | Hosts user content | many parties publish content there; they cannot receive requests | `https://assets.ion.cesium.com`, `https://cdn.jsdelivr.net`, `https://*.github.io` |
245
+ | `broad` | Anyone can receive | open hosting: anyone who signs up can receive what the widget sends | `https://*.s3.eu-central-1.amazonaws.com`, `https://s3.eu-central-1.amazonaws.com`, `https://storage.googleapis.com`, `https://*.pages.dev`, request-capture and tunnel services |
246
+
247
+ Every level up to `shared` is presented calmly with a one-line explanation.
248
+ Only `broad` gets destructive styling, a confirmation for "Always allow" and
249
+ "Don't allow" as the default button, so avoid it when a narrower source works:
250
+ a bucket's own host instead of the regional endpoint or a bucket wildcard, or
251
+ an input that carries the bucket's URL.
252
+
253
+ ### Addresses known only at runtime
254
+
255
+ Some addresses exist only while the app runs: a customer's CDN, a bucket
256
+ chosen in a flow, signed URLs that expire. Declare the inputs that carry them:
257
+
258
+ ```ts
259
+ csp: [
260
+ {
261
+ reason: "Loads map tiles from tile servers given to it at runtime",
262
+ inputs: [
263
+ { path: "tileUrl", directives: ["imgSrc", "connectSrc"],
264
+ template: { subdomainsInput: "tileSubdomains" } },
265
+ ],
266
+ },
267
+ ],
268
+ ```
269
+
270
+ - `path` is `root *( "." key / "[]" / ".*" )`, at most 6 segments. The root is
271
+ a widget input: a `string` input must be the whole path, a `json` input may
272
+ descend with `.key`, `[]` (array items) and `.*` (map values). The path must
273
+ reach `type: string` in the input's generated JSON Schema, and `.*` needs an
274
+ object type with `additionalProperties` or `patternProperties`
275
+ (`Record<string, …>`). `publicMediaGrants` is reserved by the host.
276
+ - The host reads the input values it forwards to the widget and keeps only
277
+ `scheme://host` of each URL (`https` or `wss`). Paths, queries and signatures
278
+ never matter, so rotating a signed URL on the same host needs no new
279
+ approval. Ports, IP addresses, userinfo and local names are rejected;
280
+ relative, `data:` and `blob:` values are skipped.
281
+ - Each viewer approves new hosts for themselves; nothing is approved for other
282
+ viewers or by the publisher.
283
+ - `template` expands `{s}` in the host from a fixed list (`subdomains: ["a", "b", "c"]`)
284
+ or from another `string`/`json` input (`subdomainsInput`). Other placeholders
285
+ and runtime wildcards are not supported.
286
+ - At most 8 inputs across all groups, and each path appears once.
287
+ - `@default` values are merged inside the widget, so the host never sees them.
288
+ The build warns when a default URL's host is not declared as a static source.
289
+ - The build warns when an input feeds `mediaSrc` without `connectSrc`: hls.js
290
+ and MSE players fetch playlists and segments through `connectSrc`, only
291
+ native HLS uses `mediaSrc`.
292
+
293
+ ### Recipes
294
+
295
+ **Cesium ion.** Tile hosts need `connectSrc` and `imgSrc` because CesiumJS
296
+ loads imagery through XHR and falls back to `<img>`. Its image feature probe
297
+ fetches a `data:` URL, which every widget may do. Drop groups for ion assets you
298
+ don't use.
299
+
300
+ ```ts
301
+ capabilities: { workers: true, wasm: true },
302
+ csp: [
303
+ { reason: "Loads globe terrain, imagery and 3D tiles from Cesium ion",
304
+ connectSrc: ["https://api.cesium.com", "https://assets.ion.cesium.com"],
305
+ imgSrc: ["https://assets.ion.cesium.com"] },
306
+ { reason: "Loads Bing Maps aerial imagery that Cesium ion points to",
307
+ connectSrc: ["https://dev.virtualearth.net", "https://ecn.t0.tiles.virtualearth.net",
308
+ "https://ecn.t1.tiles.virtualearth.net", "https://ecn.t2.tiles.virtualearth.net",
309
+ "https://ecn.t3.tiles.virtualearth.net"],
310
+ imgSrc: ["https://ecn.t0.tiles.virtualearth.net", "https://ecn.t1.tiles.virtualearth.net",
311
+ "https://ecn.t2.tiles.virtualearth.net", "https://ecn.t3.tiles.virtualearth.net"] },
312
+ { reason: "Loads Google photorealistic 3D tiles through Cesium ion",
313
+ connectSrc: ["https://tile.googleapis.com"] },
314
+ ],
315
+ ```
316
+
317
+ That is 13 of the 16 sources, all exact hosts. `api.cesium.com` is `external`
318
+ (account holders can write assets with their own token),
319
+ `assets.ion.cesium.com` is `shared` (user uploads), the Bing and Google hosts
320
+ are `known`.
321
+
322
+ **NASA GIBS.** Level `known`. Add `gibs-a`, `gibs-b` and `gibs-c` only if the
323
+ map client shards requests.
324
+
325
+ ```ts
326
+ csp: [{ reason: "Loads satellite imagery tiles from NASA GIBS",
327
+ imgSrc: ["https://gibs.earthdata.nasa.gov"],
328
+ connectSrc: ["https://gibs.earthdata.nasa.gov"] }],
329
+ ```
330
+
331
+ **S3, GCS and Azure signed URLs.** A flow signs the URLs and updates the input:
332
+
333
+ ```ts
334
+ csp: [{ reason: "Shows map layers from storage files given to it at runtime",
335
+ inputs: [{ path: "layers[].url", directives: ["imgSrc", "connectSrc"] }] }],
336
+ ```
337
+
338
+ | URL the flow produces | Approved source | Level |
339
+ | --- | --- | --- |
340
+ | `https://acme-tiles.s3.eu-central-1.amazonaws.com/t/1/2/3.png?X-Amz-…` | `https://acme-tiles.s3.eu-central-1.amazonaws.com` | `external` |
341
+ | `https://s3.eu-central-1.amazonaws.com/acme-tiles/…` (path-style) | `https://s3.eu-central-1.amazonaws.com` | `broad` (every bucket in the region) |
342
+ | `https://acme-tiles.storage.googleapis.com/…` | `https://acme-tiles.storage.googleapis.com` | `external` |
343
+ | `https://storage.googleapis.com/acme-tiles/…?X-Goog-…` | `https://storage.googleapis.com` | `broad` |
344
+ | `https://acmetiles.blob.core.windows.net/tiles/…?sv=…&sig=…` | `https://acmetiles.blob.core.windows.net` | `external` |
345
+
346
+ - Sign virtual-hosted style (the bucket in the host) and use regional
347
+ endpoints. The legacy global S3 host redirects, and the redirect target was
348
+ never approved.
349
+ - Bucket CORS must allow `GET` from origin `*`: the sandboxed widget sends
350
+ `Origin: null`, and allowing `null` is unsafe. The signature stays the
351
+ capability.
352
+ - Re-sign before expiry (for example 60 minutes signed, refreshed at 50).
353
+ - Don't redirect from an API to a presigned URL on another host.
354
+
355
+ ### Contract and enforcement
356
+
357
+ A widget that declares `csp` gets `contractVersion: 2` in `contract.json`, with
358
+ each group canonical (sources sorted bytewise, inputs sorted by path,
359
+ directives in the order of the table above). Widgets without it stay at
360
+ version 1. Hosts and hubs released before `csp` support reject version 2
361
+ bundles instead of silently dropping the declaration. Older bundler releases
362
+ do drop `csp`, and the widget then has no network access.
363
+
364
+ The pack-time `<meta>` CSP is not a security control. It allows
365
+ `connect-src data: blob:` and `media-src data: blob:` plus bundle assets, and
366
+ never contains `csp` sources, so a server that sends no header still keeps the
367
+ widget away from the declared sites. The host sends the enforced policy with
368
+ the document, including the approved sources and the granted capabilities, and
369
+ replaces the packed meta. Store previews never get `csp` sources. The dev
370
+ harness does not enforce CSP either, so requests that work under `dev` can
371
+ still be blocked in Flow-Like until a source is declared and approved.
372
+
373
+ `validate` checks the purpose rules, reasons, source grammar, wildcard bases,
374
+ input paths and the version rule, for a project and for a built `.flwb`.
375
+
376
+ ## Mise integration (design §8.2)
377
+
378
+ ```toml
379
+ [tasks."bundle:widgets"]
380
+ depends = ["build:widgets"]
381
+ run = "bunx @flow-like/widget-bundler pack --project . --out widgets.flwb"
382
+ ```
383
+
384
+ ## Programmatic API
385
+
386
+ ```ts
387
+ import { pack, validateProject, validateBundle, extractContract } from "@flow-like/widget-bundler";
388
+ ```
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@flow-like/widget-bundler",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Flow-Like widget bundler: extracts typed contracts from TS widgets and packs framework builds into .flwb bundles.",
6
+ "license": "MIT",
7
+ "files": ["src"],
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "bin": {
12
+ "flow-like-widgets": "./src/cli.ts"
13
+ },
14
+ "exports": {
15
+ ".": "./src/index.ts",
16
+ "./vite": "./src/vite.ts"
17
+ },
18
+ "scripts": {
19
+ "typecheck": "tsc -p tsconfig.json --noEmit",
20
+ "test": "bun test"
21
+ },
22
+ "dependencies": {
23
+ "@flow-like/widget-sdk": "0.1.1",
24
+ "fflate": "0.8.3",
25
+ "smol-toml": "1.8.0",
26
+ "ts-json-schema-generator": "^2.4.0",
27
+ "typescript": "^5.7.3"
28
+ },
29
+ "devDependencies": {
30
+ "vite": "^7.3.6"
31
+ },
32
+ "peerDependencies": {
33
+ "vite": ">=5"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "vite": {
37
+ "optional": true
38
+ }
39
+ }
40
+ }
package/src/add.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { isValidWidgetId } from "./contract-types";
4
+
5
+ export interface AddResult {
6
+ widgetDir: string;
7
+ files: string[];
8
+ }
9
+
10
+ function displayName(widgetId: string): string {
11
+ return widgetId
12
+ .split("-")
13
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
14
+ .join(" ");
15
+ }
16
+
17
+ function widgetConfigTemplate(widgetId: string, name: string): string {
18
+ return `import { defineWidget } from "@flow-like/widget-sdk";
19
+
20
+ interface Inputs {
21
+ /** Widget headline @default "${name}" */
22
+ title: string;
23
+ }
24
+
25
+ interface Events {
26
+ titleClicked: { title: string };
27
+ }
28
+
29
+ interface Queries {
30
+ getTitle: { args: void; returns: string };
31
+ }
32
+
33
+ export default defineWidget<Inputs, Events, Queries>({
34
+ id: "${widgetId}",
35
+ name: "${name}",
36
+ description: "Describe what this widget does.",
37
+ sizing: { defaultHeight: 320, resizable: true },
38
+ });
39
+ `;
40
+ }
41
+
42
+ function indexHtmlTemplate(name: string): string {
43
+ return `<!doctype html>
44
+ <html lang="en">
45
+ <head>
46
+ <meta charset="utf-8" />
47
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
48
+ <title>${name}</title>
49
+ </head>
50
+ <body>
51
+ <div id="root"></div>
52
+ <script type="module" src="./index.ts"></script>
53
+ </body>
54
+ </html>
55
+ `;
56
+ }
57
+
58
+ function indexTsTemplate(): string {
59
+ return `import { mountFlowWidget } from "@flow-like/widget-sdk";
60
+ import widget from "./widget.config";
61
+
62
+ const { $props, emit, onQuery } = mountFlowWidget(widget);
63
+
64
+ const root = document.getElementById("root");
65
+ if (root) {
66
+ $props.subscribe((props) => {
67
+ root.textContent = props.title;
68
+ });
69
+ root.addEventListener("click", () => {
70
+ emit("titleClicked", { title: $props.get().title });
71
+ });
72
+ }
73
+
74
+ onQuery("getTitle", () => $props.get().title);
75
+ `;
76
+ }
77
+
78
+ /** Scaffold `src/widgets/<id>/` inside a framework group. */
79
+ export function addWidget(groupDir: string, widgetId: string): AddResult {
80
+ if (!isValidWidgetId(widgetId)) {
81
+ throw new Error(
82
+ `Invalid widget id '${widgetId}': must be non-empty lowercase kebab-case ([a-z0-9-])`,
83
+ );
84
+ }
85
+ const group = resolve(groupDir);
86
+ if (!existsSync(group)) {
87
+ throw new Error(`Framework group directory not found: ${group}`);
88
+ }
89
+ const widgetDir = join(group, "src", "widgets", widgetId);
90
+ if (existsSync(widgetDir)) {
91
+ throw new Error(`Widget directory already exists: ${widgetDir}`);
92
+ }
93
+ mkdirSync(widgetDir, { recursive: true });
94
+
95
+ const name = displayName(widgetId);
96
+ const files = [
97
+ ["widget.config.ts", widgetConfigTemplate(widgetId, name)],
98
+ ["index.html", indexHtmlTemplate(name)],
99
+ ["index.ts", indexTsTemplate()],
100
+ ] as const;
101
+
102
+ const written: string[] = [];
103
+ for (const [file, content] of files) {
104
+ const path = join(widgetDir, file);
105
+ writeFileSync(path, content);
106
+ written.push(path);
107
+ }
108
+ return { widgetDir, files: written };
109
+ }
@@ -0,0 +1,104 @@
1
+ // Mirrors packages/wasm/schema/src/widget_bundle.rs (serde camelCase) exactly.
2
+
3
+ export const BUNDLE_FORMAT_VERSION = 1;
4
+ export const BUNDLE_MANIFEST_PATH = "bundle.json";
5
+ export const WIDGET_BUNDLE_EXTENSION = "flwb";
6
+ export const WIDGET_BUNDLE_MEDIA_TYPE =
7
+ "application/vnd.flow-like.widget-bundle";
8
+
9
+ export interface BundleSharedEntry {
10
+ path: string;
11
+ /** `sha256:<hex>` of the entry bytes */
12
+ hash: string;
13
+ }
14
+
15
+ export interface BundleSizeHint {
16
+ raw: number;
17
+ gzip?: number;
18
+ }
19
+
20
+ export interface BundleWidgetEntry {
21
+ id: string;
22
+ name: string;
23
+ description: string;
24
+ /** Archive path of the widget document, e.g. `widgets/{id}/index.html` */
25
+ entry: string;
26
+ /** Archive path of the widget contract, e.g. `widgets/{id}/contract.json` */
27
+ contract: string;
28
+ /** `sha256:<hex>` of the entry document bytes */
29
+ entryHash: string;
30
+ /** Shared chunk paths this widget references */
31
+ assets: string[];
32
+ framework?: string;
33
+ sizeHint?: BundleSizeHint;
34
+ }
35
+
36
+ export interface WidgetBundleManifest {
37
+ formatVersion: number;
38
+ packageId: string;
39
+ packageVersion: string;
40
+ /** Host<->widget postMessage protocol version, e.g. `flw/1` */
41
+ protocol: string;
42
+ createdAt?: string;
43
+ shared: BundleSharedEntry[];
44
+ widgets: BundleWidgetEntry[];
45
+ }
46
+
47
+ /**
48
+ * Relative `/`-separated path that stays inside the unpack directory on every
49
+ * OS: `:` would allow drive prefixes (`C:/`, `C:x`) and NTFS streams.
50
+ */
51
+ export function isSafeEntryPath(path: string): boolean {
52
+ return (
53
+ path.length > 0 &&
54
+ !path.startsWith("/") &&
55
+ !path.includes("\\") &&
56
+ !path.includes(":") &&
57
+ !path.includes("\0") &&
58
+ path.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..")
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Mirrors the archive name check in `WidgetBundleReader::validate`: directory
64
+ * entries are checked without their trailing `/`.
65
+ */
66
+ export function unsafeArchiveEntryPaths(names: readonly string[]): string[] {
67
+ return names
68
+ .filter((name) => !isSafeEntryPath(name.replace(/\/$/, "")))
69
+ .map((name) => `Unsafe widget bundle entry path: ${name}`);
70
+ }
71
+
72
+ /** Rebuild with serde field order and skip-serializing semantics. */
73
+ export function canonicalizeManifest(
74
+ manifest: WidgetBundleManifest,
75
+ ): WidgetBundleManifest {
76
+ return {
77
+ formatVersion: manifest.formatVersion,
78
+ packageId: manifest.packageId,
79
+ packageVersion: manifest.packageVersion,
80
+ protocol: manifest.protocol,
81
+ ...(manifest.createdAt !== undefined && { createdAt: manifest.createdAt }),
82
+ shared: manifest.shared.map((s) => ({ path: s.path, hash: s.hash })),
83
+ widgets: manifest.widgets.map((w) => ({
84
+ id: w.id,
85
+ name: w.name,
86
+ description: w.description,
87
+ entry: w.entry,
88
+ contract: w.contract,
89
+ entryHash: w.entryHash,
90
+ assets: [...w.assets],
91
+ ...(w.framework !== undefined && { framework: w.framework }),
92
+ ...(w.sizeHint !== undefined && {
93
+ sizeHint: {
94
+ raw: w.sizeHint.raw,
95
+ ...(w.sizeHint.gzip !== undefined && { gzip: w.sizeHint.gzip }),
96
+ },
97
+ }),
98
+ })),
99
+ };
100
+ }
101
+
102
+ export function manifestToJson(manifest: WidgetBundleManifest): string {
103
+ return JSON.stringify(canonicalizeManifest(manifest), null, 2);
104
+ }