@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
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { unzipSync } from "fflate";
|
|
4
|
+
import {
|
|
5
|
+
BUNDLE_FORMAT_VERSION,
|
|
6
|
+
BUNDLE_MANIFEST_PATH,
|
|
7
|
+
type WidgetBundleManifest,
|
|
8
|
+
isSafeEntryPath,
|
|
9
|
+
unsafeArchiveEntryPaths,
|
|
10
|
+
} from "./bundle-format";
|
|
11
|
+
import {
|
|
12
|
+
WIDGET_PROTOCOL,
|
|
13
|
+
type WidgetContract,
|
|
14
|
+
isValidPackageId,
|
|
15
|
+
validateContract,
|
|
16
|
+
validatePublishedCsp,
|
|
17
|
+
} from "./contract-types";
|
|
18
|
+
import { extractContract, networkInputSchemaErrors } from "./extract";
|
|
19
|
+
import {
|
|
20
|
+
archiveNameCollisions,
|
|
21
|
+
discoverGroupWidgets,
|
|
22
|
+
discoverGroups,
|
|
23
|
+
entryHash,
|
|
24
|
+
readPackageInfo,
|
|
25
|
+
} from "./pack";
|
|
26
|
+
|
|
27
|
+
export interface ProjectValidation {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
errors: string[];
|
|
30
|
+
warnings: string[];
|
|
31
|
+
widgets: { id: string; group: string }[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Validate contract extraction for every widget in a project (no build required). */
|
|
35
|
+
export function validateProject(projectDir: string): ProjectValidation {
|
|
36
|
+
const errors: string[] = [];
|
|
37
|
+
const warnings: string[] = [];
|
|
38
|
+
const widgets: { id: string; group: string }[] = [];
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
readPackageInfo(projectDir);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
errors.push(e instanceof Error ? e.message : String(e));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const groups = discoverGroups(projectDir);
|
|
47
|
+
if (groups.length === 0) {
|
|
48
|
+
errors.push(
|
|
49
|
+
`No framework groups found under ${join(projectDir, "widgets")} (expected widgets/<group>/package.json)`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const seenIds = new Set<string>();
|
|
54
|
+
for (const group of groups) {
|
|
55
|
+
for (const widgetId of discoverGroupWidgets(group.dir)) {
|
|
56
|
+
const configPath = join(
|
|
57
|
+
group.dir,
|
|
58
|
+
"src",
|
|
59
|
+
"widgets",
|
|
60
|
+
widgetId,
|
|
61
|
+
"widget.config.ts",
|
|
62
|
+
);
|
|
63
|
+
try {
|
|
64
|
+
const extracted = extractContract(configPath);
|
|
65
|
+
warnings.push(...extracted.warnings);
|
|
66
|
+
if (extracted.config.id !== widgetId) {
|
|
67
|
+
errors.push(
|
|
68
|
+
`Widget id '${extracted.config.id}' in ${configPath} does not match its directory name '${widgetId}'`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (seenIds.has(widgetId)) {
|
|
72
|
+
errors.push(
|
|
73
|
+
`Duplicate widget id across framework groups: ${widgetId}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
seenIds.add(widgetId);
|
|
77
|
+
widgets.push({ id: widgetId, group: group.name });
|
|
78
|
+
} catch (e) {
|
|
79
|
+
errors.push(e instanceof Error ? e.message : String(e));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (groups.length > 0 && widgets.length === 0 && errors.length === 0) {
|
|
85
|
+
errors.push(
|
|
86
|
+
"No widgets found (expected widgets/<group>/src/widgets/<id>/widget.config.ts)",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { ok: errors.length === 0, errors, warnings, widgets };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface BundleValidation {
|
|
94
|
+
ok: boolean;
|
|
95
|
+
errors: string[];
|
|
96
|
+
manifest: WidgetBundleManifest | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validate a built `.flwb`: manifest shape, unsafe and colliding archive
|
|
101
|
+
* names, per-entry hashes, contract validity (including the `csp` purpose
|
|
102
|
+
* rules and the v1/v2 rule), exact entry paths. Mirrors
|
|
103
|
+
* `WidgetBundleReader::validate` in packages/wasm/schema/src/widget_bundle.rs,
|
|
104
|
+
* plus the checks only the hub publish and the bundler run: wildcard bases,
|
|
105
|
+
* address-like reasons and network input schema reachability.
|
|
106
|
+
*/
|
|
107
|
+
export function validateBundle(flwbPath: string): BundleValidation {
|
|
108
|
+
const errors: string[] = [];
|
|
109
|
+
if (!existsSync(flwbPath)) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
errors: [`Bundle not found: ${flwbPath}`],
|
|
113
|
+
manifest: null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let entries: Record<string, Uint8Array>;
|
|
118
|
+
try {
|
|
119
|
+
entries = unzipSync(new Uint8Array(readFileSync(flwbPath)));
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
errors: [
|
|
124
|
+
`Failed to read widget bundle ZIP: ${e instanceof Error ? e.message : e}`,
|
|
125
|
+
],
|
|
126
|
+
manifest: null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const manifestBytes = entries[BUNDLE_MANIFEST_PATH];
|
|
131
|
+
if (!manifestBytes) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
errors: [`Widget bundle is missing ${BUNDLE_MANIFEST_PATH}`],
|
|
135
|
+
manifest: null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
let manifest: WidgetBundleManifest;
|
|
139
|
+
try {
|
|
140
|
+
manifest = JSON.parse(new TextDecoder().decode(manifestBytes));
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
errors: [
|
|
145
|
+
`Failed to parse bundle.json: ${e instanceof Error ? e.message : e}`,
|
|
146
|
+
],
|
|
147
|
+
manifest: null,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (
|
|
152
|
+
manifest.formatVersion === 0 ||
|
|
153
|
+
manifest.formatVersion > BUNDLE_FORMAT_VERSION
|
|
154
|
+
) {
|
|
155
|
+
errors.push(
|
|
156
|
+
`Unsupported bundle format version ${manifest.formatVersion} (supported: 1..=${BUNDLE_FORMAT_VERSION})`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (manifest.protocol !== WIDGET_PROTOCOL) {
|
|
160
|
+
errors.push(
|
|
161
|
+
`Unsupported widget protocol '${manifest.protocol}' (expected '${WIDGET_PROTOCOL}')`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (!manifest.packageId) {
|
|
165
|
+
errors.push("Bundle manifest is missing packageId");
|
|
166
|
+
} else if (!isValidPackageId(manifest.packageId)) {
|
|
167
|
+
errors.push(
|
|
168
|
+
`Invalid bundle packageId ${JSON.stringify(manifest.packageId)}: use only letters, digits, '.', '_' and '-'`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (!manifest.widgets || manifest.widgets.length === 0) {
|
|
172
|
+
errors.push("Bundle contains no widgets");
|
|
173
|
+
}
|
|
174
|
+
const names = Object.keys(entries);
|
|
175
|
+
errors.push(...unsafeArchiveEntryPaths(names));
|
|
176
|
+
errors.push(...archiveNameCollisions(names));
|
|
177
|
+
|
|
178
|
+
const sharedPaths = new Set<string>();
|
|
179
|
+
for (const shared of manifest.shared ?? []) {
|
|
180
|
+
sharedPaths.add(shared.path);
|
|
181
|
+
if (!isSafeEntryPath(shared.path) || !shared.path.startsWith("shared/")) {
|
|
182
|
+
errors.push(`Invalid shared chunk path: ${shared.path}`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const data = entries[shared.path];
|
|
186
|
+
if (!data) {
|
|
187
|
+
errors.push(`Widget bundle entry not found: ${shared.path}`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const actual = entryHash(data);
|
|
191
|
+
if (actual !== shared.hash) {
|
|
192
|
+
errors.push(
|
|
193
|
+
`Hash mismatch for shared chunk ${shared.path}: expected ${shared.hash}, got ${actual}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const seenIds = new Set<string>();
|
|
199
|
+
for (const widget of manifest.widgets ?? []) {
|
|
200
|
+
if (seenIds.has(widget.id)) {
|
|
201
|
+
errors.push(`Duplicate widget id in bundle: ${widget.id}`);
|
|
202
|
+
}
|
|
203
|
+
seenIds.add(widget.id);
|
|
204
|
+
const expectedEntry = `widgets/${widget.id}/index.html`;
|
|
205
|
+
if (widget.entry !== expectedEntry) {
|
|
206
|
+
errors.push(
|
|
207
|
+
`Widget '${widget.id}' entry path '${widget.entry}' must be '${expectedEntry}'`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const expectedContract = `widgets/${widget.id}/contract.json`;
|
|
211
|
+
if (widget.contract !== expectedContract) {
|
|
212
|
+
errors.push(
|
|
213
|
+
`Widget '${widget.id}' contract path '${widget.contract}' must be '${expectedContract}'`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const entryData = entries[widget.entry];
|
|
218
|
+
if (!entryData) {
|
|
219
|
+
errors.push(`Widget bundle entry not found: ${widget.entry}`);
|
|
220
|
+
} else {
|
|
221
|
+
const actual = entryHash(entryData);
|
|
222
|
+
if (actual !== widget.entryHash) {
|
|
223
|
+
errors.push(
|
|
224
|
+
`Hash mismatch for widget entry ${widget.entry}: expected ${widget.entryHash}, got ${actual}`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const contractData = entries[widget.contract];
|
|
230
|
+
if (!contractData) {
|
|
231
|
+
errors.push(`Widget bundle entry not found: ${widget.contract}`);
|
|
232
|
+
} else {
|
|
233
|
+
try {
|
|
234
|
+
const contract: WidgetContract = JSON.parse(
|
|
235
|
+
new TextDecoder().decode(contractData),
|
|
236
|
+
);
|
|
237
|
+
if (contract.id !== widget.id) {
|
|
238
|
+
errors.push(
|
|
239
|
+
`Contract id '${contract.id}' does not match widget id '${widget.id}'`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const contractErrors = validateContract(contract);
|
|
243
|
+
errors.push(
|
|
244
|
+
...(contractErrors.length > 0
|
|
245
|
+
? contractErrors
|
|
246
|
+
: [
|
|
247
|
+
...validatePublishedCsp(contract),
|
|
248
|
+
...networkInputSchemaErrors(contract),
|
|
249
|
+
]),
|
|
250
|
+
);
|
|
251
|
+
} catch (e) {
|
|
252
|
+
errors.push(
|
|
253
|
+
`Failed to parse contract for widget '${widget.id}': ${e instanceof Error ? e.message : e}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const asset of widget.assets ?? []) {
|
|
259
|
+
if (!sharedPaths.has(asset)) {
|
|
260
|
+
errors.push(
|
|
261
|
+
`Widget '${widget.id}' references undeclared asset: ${asset}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { ok: errors.length === 0, errors, manifest };
|
|
268
|
+
}
|
package/src/vite.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import type { HtmlTagDescriptor, Plugin } from "vite";
|
|
4
|
+
import { type ExtractResult, extractContract } from "./extract";
|
|
5
|
+
import { contractScriptContent } from "./inline";
|
|
6
|
+
|
|
7
|
+
export interface WidgetEntry {
|
|
8
|
+
id: string;
|
|
9
|
+
htmlPath: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Find `src/widgets/{id}/index.html` entrypoints of a framework group. */
|
|
13
|
+
export function discoverWidgetEntries(rootDir: string): WidgetEntry[] {
|
|
14
|
+
const widgetsDir = join(rootDir, "src", "widgets");
|
|
15
|
+
if (!existsSync(widgetsDir)) return [];
|
|
16
|
+
const entries: WidgetEntry[] = [];
|
|
17
|
+
for (const name of readdirSync(widgetsDir).sort()) {
|
|
18
|
+
const dir = join(widgetsDir, name);
|
|
19
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
20
|
+
const htmlPath = join(dir, "index.html");
|
|
21
|
+
if (existsSync(htmlPath)) entries.push({ id: name, htmlPath });
|
|
22
|
+
}
|
|
23
|
+
return entries;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function widgetIdFromHtmlPath(
|
|
27
|
+
rootDir: string,
|
|
28
|
+
filePath: string,
|
|
29
|
+
): string | null {
|
|
30
|
+
const rel = relative(resolve(rootDir), resolve(filePath))
|
|
31
|
+
.split(sep)
|
|
32
|
+
.join("/");
|
|
33
|
+
const match = /^src\/widgets\/([^/]+)\/index\.html$/.exec(rel);
|
|
34
|
+
return match?.[1] ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const extractCache = new Map<
|
|
38
|
+
string,
|
|
39
|
+
{ mtimeMs: number; result: ExtractResult }
|
|
40
|
+
>();
|
|
41
|
+
|
|
42
|
+
function cachedExtract(configPath: string): ExtractResult {
|
|
43
|
+
const mtimeMs = statSync(configPath).mtimeMs;
|
|
44
|
+
const cached = extractCache.get(configPath);
|
|
45
|
+
if (cached && cached.mtimeMs === mtimeMs) return cached.result;
|
|
46
|
+
const result = extractContract(configPath);
|
|
47
|
+
extractCache.set(configPath, { mtimeMs, result });
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Contract script tag for a widget `index.html`, or `null` when the page is
|
|
53
|
+
* not a widget entry (dev parity with `pack`, which re-injects the contract).
|
|
54
|
+
*/
|
|
55
|
+
export function contractTagForHtml(
|
|
56
|
+
rootDir: string,
|
|
57
|
+
htmlFilePath: string,
|
|
58
|
+
): HtmlTagDescriptor | null {
|
|
59
|
+
const id = widgetIdFromHtmlPath(rootDir, htmlFilePath);
|
|
60
|
+
if (!id) return null;
|
|
61
|
+
const configPath = join(dirname(resolve(htmlFilePath)), "widget.config.ts");
|
|
62
|
+
if (!existsSync(configPath)) return null;
|
|
63
|
+
const { contract } = cachedExtract(configPath);
|
|
64
|
+
return {
|
|
65
|
+
tag: "script",
|
|
66
|
+
children: contractScriptContent(contract),
|
|
67
|
+
injectTo: "head-prepend",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Vite plugin for widget framework groups: discovers `src/widgets/{id}/index.html`
|
|
73
|
+
* as build inputs (one entry per widget id), routes entry/chunk/asset output
|
|
74
|
+
* into `shared/` so `pack` can collect deduplicated chunks, and injects the
|
|
75
|
+
* extracted `__FLW_CONTRACT__` script during dev/build.
|
|
76
|
+
*/
|
|
77
|
+
export function flowLikeWidgets(): Plugin {
|
|
78
|
+
let root = process.cwd();
|
|
79
|
+
return {
|
|
80
|
+
name: "flow-like-widgets",
|
|
81
|
+
config(userConfig) {
|
|
82
|
+
root = resolve(userConfig.root ?? process.cwd());
|
|
83
|
+
const entries = discoverWidgetEntries(root);
|
|
84
|
+
if (entries.length === 0) return {};
|
|
85
|
+
return {
|
|
86
|
+
build: {
|
|
87
|
+
rollupOptions: {
|
|
88
|
+
input: Object.fromEntries(
|
|
89
|
+
entries.map((entry) => [entry.id, entry.htmlPath]),
|
|
90
|
+
),
|
|
91
|
+
output: {
|
|
92
|
+
entryFileNames: "shared/[name]-[hash].js",
|
|
93
|
+
chunkFileNames: "shared/[name]-[hash].js",
|
|
94
|
+
assetFileNames: "shared/[name]-[hash][extname]",
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
transformIndexHtml: {
|
|
101
|
+
order: "pre",
|
|
102
|
+
handler(html, ctx) {
|
|
103
|
+
const tag = contractTagForHtml(root, ctx.filename);
|
|
104
|
+
if (!tag) return;
|
|
105
|
+
return { html, tags: [tag] };
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|