@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 +388 -0
- package/package.json +40 -0
- package/src/add.ts +109 -0
- package/src/bundle-format.ts +104 -0
- package/src/cli.ts +156 -0
- package/src/contract-types.ts +759 -0
- package/src/csp-reason.ts +307 -0
- package/src/csp-source.ts +361 -0
- package/src/csp.ts +54 -0
- package/src/dev/contract-endpoint.ts +103 -0
- package/src/dev/form-model.ts +63 -0
- package/src/dev/harness-html.ts +864 -0
- package/src/dev/server.ts +297 -0
- package/src/extract.ts +1620 -0
- package/src/generated/widget-source-data.ts +982 -0
- package/src/html.ts +60 -0
- package/src/index.ts +17 -0
- package/src/inline.ts +247 -0
- package/src/pack.ts +502 -0
- package/src/psl.ts +145 -0
- package/src/validate-cmd.ts +268 -0
- package/src/vite.ts +109 -0
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
|
+
}
|