@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/src/pack.ts
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
11
|
+
import { type ZipOptions, type Zippable, gzipSync, zipSync } from "fflate";
|
|
12
|
+
import { parse as parseToml } from "smol-toml";
|
|
13
|
+
import {
|
|
14
|
+
BUNDLE_FORMAT_VERSION,
|
|
15
|
+
BUNDLE_MANIFEST_PATH,
|
|
16
|
+
type BundleSharedEntry,
|
|
17
|
+
type BundleWidgetEntry,
|
|
18
|
+
type WidgetBundleManifest,
|
|
19
|
+
manifestToJson,
|
|
20
|
+
unsafeArchiveEntryPaths,
|
|
21
|
+
} from "./bundle-format";
|
|
22
|
+
import {
|
|
23
|
+
WIDGET_PROTOCOL,
|
|
24
|
+
contractToJson,
|
|
25
|
+
isValidPackageId,
|
|
26
|
+
} from "./contract-types";
|
|
27
|
+
import { buildCsp, injectCspMeta } from "./csp";
|
|
28
|
+
import { extractContract } from "./extract";
|
|
29
|
+
import { injectContractScript, inlineHtml } from "./inline";
|
|
30
|
+
|
|
31
|
+
export const CONNECT_HOSTS_REMOVED =
|
|
32
|
+
'The connectHosts pack option was removed: declare network sources per widget in widget.config.ts "csp"';
|
|
33
|
+
|
|
34
|
+
export interface PackOptions {
|
|
35
|
+
out?: string;
|
|
36
|
+
/** Extra bundle asset source for the pack-time meta CSP */
|
|
37
|
+
servingPrefix?: string | null;
|
|
38
|
+
createdAt?: string;
|
|
39
|
+
quiet?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PackResult {
|
|
43
|
+
outPath: string;
|
|
44
|
+
bytes: Uint8Array;
|
|
45
|
+
/** Whole-file sha256 hex (the manifest `widget_bundle_hash`) */
|
|
46
|
+
hash: string;
|
|
47
|
+
manifest: WidgetBundleManifest;
|
|
48
|
+
warnings: string[];
|
|
49
|
+
report: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PackageInfo {
|
|
53
|
+
id: string;
|
|
54
|
+
version: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Fixed ZIP entry mtime (1980-01-01) for deterministic archives */
|
|
58
|
+
export const ZIP_ENTRY_MTIME_MS = 315532800000;
|
|
59
|
+
|
|
60
|
+
export function sha256Hex(data: Uint8Array): string {
|
|
61
|
+
return createHash("sha256").update(data).digest("hex");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function entryHash(data: Uint8Array): string {
|
|
65
|
+
return `sha256:${sha256Hex(data)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read the package `id`/`version` from `flow-like.toml`; accepts the manifest
|
|
70
|
+
* form (top-level keys) and the template form (`[package]` table).
|
|
71
|
+
*/
|
|
72
|
+
export function readPackageInfo(projectDir: string): PackageInfo {
|
|
73
|
+
const tomlPath = join(projectDir, "flow-like.toml");
|
|
74
|
+
if (!existsSync(tomlPath)) {
|
|
75
|
+
throw new Error(`No flow-like.toml found in ${resolve(projectDir)}`);
|
|
76
|
+
}
|
|
77
|
+
const doc = parseToml(readFileSync(tomlPath, "utf8")) as Record<
|
|
78
|
+
string,
|
|
79
|
+
unknown
|
|
80
|
+
>;
|
|
81
|
+
const pkg =
|
|
82
|
+
typeof doc.package === "object" && doc.package !== null
|
|
83
|
+
? (doc.package as Record<string, unknown>)
|
|
84
|
+
: {};
|
|
85
|
+
const id = typeof doc.id === "string" ? doc.id : pkg.id;
|
|
86
|
+
const version = typeof doc.version === "string" ? doc.version : pkg.version;
|
|
87
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`${tomlPath} is missing the package 'id' (top-level or [package].id)`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (!isValidPackageId(id)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Invalid package id ${JSON.stringify(id)} in ${tomlPath}: use only letters, digits, '.', '_' and '-'`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (typeof version !== "string" || version.length === 0) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`${tomlPath} is missing the package 'version' (top-level or [package].version)`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return { id, version };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Archive path as case-insensitive filesystems that strip trailing dots and spaces resolve it */
|
|
106
|
+
function foldedArchivePath(path: string): string | null {
|
|
107
|
+
const segments: string[] = [];
|
|
108
|
+
for (const segment of path.split("/")) {
|
|
109
|
+
const folded = segment
|
|
110
|
+
.toUpperCase()
|
|
111
|
+
.toLowerCase()
|
|
112
|
+
.replace(/[. ]+$/, "");
|
|
113
|
+
if (folded.length === 0) return null;
|
|
114
|
+
segments.push(folded);
|
|
115
|
+
}
|
|
116
|
+
return segments.join("/");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Archive names that would overwrite each other when unpacked. Mirrors
|
|
121
|
+
* `archive_name_collisions` in packages/wasm/schema/src/widget_bundle.rs.
|
|
122
|
+
*/
|
|
123
|
+
export function archiveNameCollisions(names: readonly string[]): string[] {
|
|
124
|
+
const errors: string[] = [];
|
|
125
|
+
const entries: [string, string][] = [];
|
|
126
|
+
for (const name of names) {
|
|
127
|
+
if (name.endsWith("/")) continue;
|
|
128
|
+
const folded = foldedArchivePath(name);
|
|
129
|
+
if (folded === null) {
|
|
130
|
+
errors.push(
|
|
131
|
+
`Widget bundle entry '${name}' has a path segment made only of dots or spaces`,
|
|
132
|
+
);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
entries.push([name, folded]);
|
|
136
|
+
}
|
|
137
|
+
const files = new Map<string, string>();
|
|
138
|
+
const directories = new Map<string, string>();
|
|
139
|
+
for (const [name, folded] of entries) {
|
|
140
|
+
const existing = files.get(folded);
|
|
141
|
+
if (existing !== undefined) {
|
|
142
|
+
errors.push(
|
|
143
|
+
`Widget bundle entries '${existing}' and '${name}' collide on case-insensitive filesystems`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
files.set(folded, name);
|
|
147
|
+
for (
|
|
148
|
+
let cut = folded.lastIndexOf("/");
|
|
149
|
+
cut !== -1;
|
|
150
|
+
cut = folded.lastIndexOf("/", cut - 1)
|
|
151
|
+
) {
|
|
152
|
+
const parent = folded.slice(0, cut);
|
|
153
|
+
if (!directories.has(parent)) directories.set(parent, name);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const [name, folded] of entries) {
|
|
157
|
+
const other = directories.get(folded);
|
|
158
|
+
if (other !== undefined) {
|
|
159
|
+
errors.push(
|
|
160
|
+
`Widget bundle entry '${name}' collides with directory of '${other}' on case-insensitive filesystems`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return errors;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface FrameworkGroup {
|
|
168
|
+
name: string;
|
|
169
|
+
dir: string;
|
|
170
|
+
framework: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const FRAMEWORK_DEPENDENCIES: [string, string][] = [
|
|
174
|
+
["svelte", "svelte"],
|
|
175
|
+
["vue", "vue"],
|
|
176
|
+
["solid-js", "solid"],
|
|
177
|
+
["preact", "preact"],
|
|
178
|
+
["lit", "lit"],
|
|
179
|
+
["react", "react"],
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
export function discoverGroups(projectDir: string): FrameworkGroup[] {
|
|
183
|
+
const widgetsDir = join(projectDir, "widgets");
|
|
184
|
+
if (!existsSync(widgetsDir)) return [];
|
|
185
|
+
const groups: FrameworkGroup[] = [];
|
|
186
|
+
for (const name of readdirSync(widgetsDir).sort()) {
|
|
187
|
+
const dir = join(widgetsDir, name);
|
|
188
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
189
|
+
const packageJsonPath = join(dir, "package.json");
|
|
190
|
+
if (!existsSync(packageJsonPath)) continue;
|
|
191
|
+
groups.push({
|
|
192
|
+
name,
|
|
193
|
+
dir,
|
|
194
|
+
framework: detectFramework(packageJsonPath, name),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return groups;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function detectFramework(packageJsonPath: string, fallback: string): string {
|
|
201
|
+
try {
|
|
202
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
203
|
+
dependencies?: Record<string, string>;
|
|
204
|
+
devDependencies?: Record<string, string>;
|
|
205
|
+
};
|
|
206
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
207
|
+
for (const [dep, framework] of FRAMEWORK_DEPENDENCIES) {
|
|
208
|
+
if (deps[dep]) return framework;
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// informational only; fall through to the directory name
|
|
212
|
+
}
|
|
213
|
+
return fallback;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function discoverGroupWidgets(groupDir: string): string[] {
|
|
217
|
+
const widgetsDir = join(groupDir, "src", "widgets");
|
|
218
|
+
if (!existsSync(widgetsDir)) return [];
|
|
219
|
+
return readdirSync(widgetsDir)
|
|
220
|
+
.sort()
|
|
221
|
+
.filter(
|
|
222
|
+
(name) =>
|
|
223
|
+
statSync(join(widgetsDir, name)).isDirectory() &&
|
|
224
|
+
existsSync(join(widgetsDir, name, "widget.config.ts")),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function findBuiltDocument(
|
|
229
|
+
groupDir: string,
|
|
230
|
+
widgetId: string,
|
|
231
|
+
groupWidgetCount: number,
|
|
232
|
+
): string {
|
|
233
|
+
const candidates = [
|
|
234
|
+
join(groupDir, "dist", "widgets", widgetId, "index.html"),
|
|
235
|
+
join(groupDir, "dist", "src", "widgets", widgetId, "index.html"),
|
|
236
|
+
];
|
|
237
|
+
if (groupWidgetCount === 1) {
|
|
238
|
+
candidates.push(join(groupDir, "dist", "index.html"));
|
|
239
|
+
}
|
|
240
|
+
for (const candidate of candidates) {
|
|
241
|
+
if (existsSync(candidate)) return candidate;
|
|
242
|
+
}
|
|
243
|
+
throw new Error(
|
|
244
|
+
`No built document found for widget '${widgetId}'. Tried:\n${candidates
|
|
245
|
+
.map((c) => ` - ${c}`)
|
|
246
|
+
.join(
|
|
247
|
+
"\n",
|
|
248
|
+
)}\nRun \`bun run build\` in ${groupDir} first (the group's build script must emit dist/).`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function collectSharedFiles(distDir: string): Map<string, Uint8Array> {
|
|
253
|
+
const sharedDir = join(distDir, "shared");
|
|
254
|
+
const files = new Map<string, Uint8Array>();
|
|
255
|
+
if (!existsSync(sharedDir)) return files;
|
|
256
|
+
const walk = (dir: string) => {
|
|
257
|
+
for (const name of readdirSync(dir).sort()) {
|
|
258
|
+
const full = join(dir, name);
|
|
259
|
+
if (statSync(full).isDirectory()) {
|
|
260
|
+
walk(full);
|
|
261
|
+
} else {
|
|
262
|
+
const rel = relative(sharedDir, full).split(sep).join("/");
|
|
263
|
+
files.set(`shared/${rel}`, new Uint8Array(readFileSync(full)));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
walk(sharedDir);
|
|
268
|
+
return files;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function formatSize(bytes: number): string {
|
|
272
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
273
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
274
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function resolveCreatedAt(explicit: string | undefined): string | undefined {
|
|
278
|
+
if (explicit !== undefined) return explicit;
|
|
279
|
+
const epoch = process.env.SOURCE_DATE_EPOCH;
|
|
280
|
+
if (!epoch) return undefined;
|
|
281
|
+
const seconds = Number(epoch);
|
|
282
|
+
if (!Number.isFinite(seconds)) return undefined;
|
|
283
|
+
return new Date(seconds * 1000).toISOString();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
interface PackedWidget {
|
|
287
|
+
id: string;
|
|
288
|
+
name: string;
|
|
289
|
+
description: string;
|
|
290
|
+
framework: string;
|
|
291
|
+
html: Uint8Array;
|
|
292
|
+
contractJson: Uint8Array;
|
|
293
|
+
assets: string[];
|
|
294
|
+
gzipSize: number;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Build the `.flwb` widget bundle for a scaffolded package project. */
|
|
298
|
+
export async function pack(
|
|
299
|
+
projectDir: string,
|
|
300
|
+
opts: PackOptions = {},
|
|
301
|
+
): Promise<PackResult> {
|
|
302
|
+
if ("connectHosts" in opts) {
|
|
303
|
+
throw new Error(CONNECT_HOSTS_REMOVED);
|
|
304
|
+
}
|
|
305
|
+
const project = resolve(projectDir);
|
|
306
|
+
const info = readPackageInfo(project);
|
|
307
|
+
const groups = discoverGroups(project);
|
|
308
|
+
if (groups.length === 0) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
`No framework groups found under ${join(project, "widgets")} (expected widgets/<group>/package.json)`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const warnings: string[] = [];
|
|
315
|
+
const widgets: PackedWidget[] = [];
|
|
316
|
+
const shared = new Map<string, Uint8Array>();
|
|
317
|
+
const seenIds = new Set<string>();
|
|
318
|
+
|
|
319
|
+
for (const group of groups) {
|
|
320
|
+
const widgetIds = discoverGroupWidgets(group.dir);
|
|
321
|
+
if (widgetIds.length === 0) continue;
|
|
322
|
+
const distDir = resolve(group.dir, "dist");
|
|
323
|
+
const groupShared = collectSharedFiles(distDir);
|
|
324
|
+
|
|
325
|
+
for (const widgetId of widgetIds) {
|
|
326
|
+
const configPath = join(
|
|
327
|
+
group.dir,
|
|
328
|
+
"src",
|
|
329
|
+
"widgets",
|
|
330
|
+
widgetId,
|
|
331
|
+
"widget.config.ts",
|
|
332
|
+
);
|
|
333
|
+
const extracted = extractContract(configPath);
|
|
334
|
+
const csp = buildCsp(
|
|
335
|
+
opts.servingPrefix ?? null,
|
|
336
|
+
extracted.contract.capabilities,
|
|
337
|
+
);
|
|
338
|
+
warnings.push(...extracted.warnings);
|
|
339
|
+
if (extracted.config.id !== widgetId) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`Widget id '${extracted.config.id}' in ${configPath} does not match its directory name '${widgetId}'`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
if (seenIds.has(widgetId)) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`Duplicate widget id across framework groups: ${widgetId}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
seenIds.add(widgetId);
|
|
350
|
+
|
|
351
|
+
const htmlPath = findBuiltDocument(group.dir, widgetId, widgetIds.length);
|
|
352
|
+
const htmlDir = dirname(htmlPath);
|
|
353
|
+
const resolveAsset = (relPath: string): Uint8Array | null => {
|
|
354
|
+
const full = resolve(htmlDir, relPath);
|
|
355
|
+
if (full !== distDir && !full.startsWith(distDir + sep)) return null;
|
|
356
|
+
if (!existsSync(full) || !statSync(full).isFile()) return null;
|
|
357
|
+
return new Uint8Array(readFileSync(full));
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
let inlineResult: ReturnType<typeof inlineHtml>;
|
|
361
|
+
try {
|
|
362
|
+
inlineResult = inlineHtml(readFileSync(htmlPath, "utf8"), resolveAsset);
|
|
363
|
+
} catch (e) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Failed to inline ${htmlPath}: ${e instanceof Error ? e.message : e}`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (const asset of inlineResult.external) {
|
|
370
|
+
const chunk = groupShared.get(asset);
|
|
371
|
+
if (!chunk) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`Widget '${widgetId}' references '${asset}' but ${join(distDir, asset)} does not exist`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const existing = shared.get(asset);
|
|
377
|
+
if (existing && entryHash(existing) !== entryHash(chunk)) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
`Shared chunk collision: '${asset}' has different content across framework groups`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
for (const [path, data] of groupShared) {
|
|
384
|
+
const existing = shared.get(path);
|
|
385
|
+
if (existing && entryHash(existing) !== entryHash(data)) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`Shared chunk collision: '${path}' has different content across framework groups`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
shared.set(path, data);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
let html = injectContractScript(inlineResult.html, extracted.contract);
|
|
394
|
+
html = injectCspMeta(html, csp);
|
|
395
|
+
const htmlBytes = new TextEncoder().encode(html);
|
|
396
|
+
|
|
397
|
+
widgets.push({
|
|
398
|
+
id: widgetId,
|
|
399
|
+
name: extracted.config.name,
|
|
400
|
+
description: extracted.config.description,
|
|
401
|
+
framework: group.framework,
|
|
402
|
+
html: htmlBytes,
|
|
403
|
+
contractJson: new TextEncoder().encode(
|
|
404
|
+
contractToJson(extracted.contract),
|
|
405
|
+
),
|
|
406
|
+
assets: inlineResult.external,
|
|
407
|
+
gzipSize: gzipSync(htmlBytes, { level: 6, mtime: 0 }).length,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (widgets.length === 0) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
`No widgets found (expected widgets/<group>/src/widgets/<id>/widget.config.ts under ${project})`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
widgets.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
418
|
+
|
|
419
|
+
const sharedEntries: BundleSharedEntry[] = [...shared.keys()]
|
|
420
|
+
.sort()
|
|
421
|
+
.map((path) => ({ path, hash: entryHash(shared.get(path) as Uint8Array) }));
|
|
422
|
+
|
|
423
|
+
const widgetEntries: BundleWidgetEntry[] = widgets.map((widget) => ({
|
|
424
|
+
id: widget.id,
|
|
425
|
+
name: widget.name,
|
|
426
|
+
description: widget.description,
|
|
427
|
+
entry: `widgets/${widget.id}/index.html`,
|
|
428
|
+
contract: `widgets/${widget.id}/contract.json`,
|
|
429
|
+
entryHash: entryHash(widget.html),
|
|
430
|
+
assets: widget.assets,
|
|
431
|
+
framework: widget.framework,
|
|
432
|
+
sizeHint: { raw: widget.html.length, gzip: widget.gzipSize },
|
|
433
|
+
}));
|
|
434
|
+
|
|
435
|
+
const createdAt = resolveCreatedAt(opts.createdAt);
|
|
436
|
+
const manifest: WidgetBundleManifest = {
|
|
437
|
+
formatVersion: BUNDLE_FORMAT_VERSION,
|
|
438
|
+
packageId: info.id,
|
|
439
|
+
packageVersion: info.version,
|
|
440
|
+
protocol: WIDGET_PROTOCOL,
|
|
441
|
+
...(createdAt !== undefined && { createdAt }),
|
|
442
|
+
shared: sharedEntries,
|
|
443
|
+
widgets: widgetEntries,
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const entries = new Map<string, Uint8Array>();
|
|
447
|
+
entries.set(
|
|
448
|
+
BUNDLE_MANIFEST_PATH,
|
|
449
|
+
new TextEncoder().encode(manifestToJson(manifest)),
|
|
450
|
+
);
|
|
451
|
+
for (const [path, data] of shared) entries.set(path, data);
|
|
452
|
+
for (const widget of widgets) {
|
|
453
|
+
entries.set(`widgets/${widget.id}/index.html`, widget.html);
|
|
454
|
+
entries.set(`widgets/${widget.id}/contract.json`, widget.contractJson);
|
|
455
|
+
}
|
|
456
|
+
const names = [...entries.keys()];
|
|
457
|
+
const archiveErrors = [
|
|
458
|
+
...unsafeArchiveEntryPaths(names),
|
|
459
|
+
...archiveNameCollisions(names),
|
|
460
|
+
];
|
|
461
|
+
if (archiveErrors.length > 0) {
|
|
462
|
+
throw new Error(archiveErrors.join("; "));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const zipOptions: ZipOptions = {
|
|
466
|
+
level: 6,
|
|
467
|
+
mtime: new Date(ZIP_ENTRY_MTIME_MS),
|
|
468
|
+
};
|
|
469
|
+
const zippable: Zippable = {};
|
|
470
|
+
for (const path of [...entries.keys()].sort()) {
|
|
471
|
+
zippable[path] = [entries.get(path) as Uint8Array, zipOptions];
|
|
472
|
+
}
|
|
473
|
+
const bytes = zipSync(zippable);
|
|
474
|
+
const hash = sha256Hex(bytes);
|
|
475
|
+
|
|
476
|
+
const outPath = resolve(opts.out ?? join(project, "widgets.flwb"));
|
|
477
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
478
|
+
writeFileSync(outPath, bytes);
|
|
479
|
+
|
|
480
|
+
const lines: string[] = [];
|
|
481
|
+
for (const widget of widgets) {
|
|
482
|
+
lines.push(
|
|
483
|
+
`widget ${widget.id.padEnd(24)} raw ${formatSize(widget.html.length).padStart(9)} gzip ${formatSize(widget.gzipSize).padStart(9)}`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
for (const [path, data] of [...shared.entries()].sort(([a], [b]) =>
|
|
487
|
+
a < b ? -1 : 1,
|
|
488
|
+
)) {
|
|
489
|
+
lines.push(
|
|
490
|
+
`shared ${path.padEnd(24)} raw ${formatSize(data.length).padStart(9)}`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
lines.push(`bundle ${outPath}`);
|
|
494
|
+
lines.push(
|
|
495
|
+
`total ${formatSize(bytes.length)} (${widgets.length} widget${widgets.length === 1 ? "" : "s"}, ${shared.size} shared chunk${shared.size === 1 ? "" : "s"})`,
|
|
496
|
+
);
|
|
497
|
+
lines.push(`sha256 ${hash}`);
|
|
498
|
+
const report = lines.join("\n");
|
|
499
|
+
if (!opts.quiet) console.log(report);
|
|
500
|
+
|
|
501
|
+
return { outPath, bytes, hash, manifest, warnings, report };
|
|
502
|
+
}
|
package/src/psl.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Mirror of the ICANN parts of packages/wasm/schema/src/widget_sources
|
|
2
|
+
// (index.rs, rules.rs), built from the generated PSL rules. Driven by
|
|
3
|
+
// packages/wasm/schema/tests/fixtures/widget_source_classification.json.
|
|
4
|
+
|
|
5
|
+
import { PUBLIC_SUFFIX_RULES } from "./generated/widget-source-data";
|
|
6
|
+
|
|
7
|
+
export const WILDCARD_PUBLIC_SUFFIX_MESSAGE =
|
|
8
|
+
"wildcard base is a public suffix or spans public suffixes";
|
|
9
|
+
|
|
10
|
+
interface IcannIndex {
|
|
11
|
+
normal: Set<string>;
|
|
12
|
+
wildcard: Set<string>;
|
|
13
|
+
exception: Set<string>;
|
|
14
|
+
tlds: Set<string>;
|
|
15
|
+
below: Set<string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let index: IcannIndex | null = null;
|
|
19
|
+
|
|
20
|
+
function labelStarts(host: string): number[] {
|
|
21
|
+
const starts = [0];
|
|
22
|
+
for (let position = 0; position < host.length; position++) {
|
|
23
|
+
if (host[position] === ".") starts.push(position + 1);
|
|
24
|
+
}
|
|
25
|
+
return starts;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function lastLabel(domain: string): string {
|
|
29
|
+
return domain.slice(domain.lastIndexOf(".") + 1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function strictAncestors(domain: string): string[] {
|
|
33
|
+
return labelStarts(domain)
|
|
34
|
+
.slice(1)
|
|
35
|
+
.map((start) => domain.slice(start));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Label count of the ICANN public suffix of `host`, implicit `*` included. */
|
|
39
|
+
function icannSuffixLabels(rules: IcannIndex, host: string): number {
|
|
40
|
+
const starts = labelStarts(host);
|
|
41
|
+
const count = starts.length;
|
|
42
|
+
const exception = starts.findIndex((start) =>
|
|
43
|
+
rules.exception.has(host.slice(start)),
|
|
44
|
+
);
|
|
45
|
+
if (exception >= 0) return count - exception - 1;
|
|
46
|
+
const matched = starts.findIndex((start, position) => {
|
|
47
|
+
const next = starts[position + 1];
|
|
48
|
+
return (
|
|
49
|
+
rules.normal.has(host.slice(start)) ||
|
|
50
|
+
(next !== undefined && rules.wildcard.has(host.slice(next)))
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
return matched >= 0 ? count - matched : 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isSuffixIn(rules: IcannIndex, host: string): boolean {
|
|
57
|
+
return icannSuffixLabels(rules, host) >= labelStarts(host).length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildIndex(): IcannIndex {
|
|
61
|
+
const rules: IcannIndex = {
|
|
62
|
+
normal: new Set(),
|
|
63
|
+
wildcard: new Set(),
|
|
64
|
+
exception: new Set(),
|
|
65
|
+
tlds: new Set(),
|
|
66
|
+
below: new Set(),
|
|
67
|
+
};
|
|
68
|
+
const wildcardDomains: string[] = [];
|
|
69
|
+
const normalDomains: string[] = [];
|
|
70
|
+
for (const line of PUBLIC_SUFFIX_RULES.split("\n")) {
|
|
71
|
+
if (!line.startsWith("i ")) continue;
|
|
72
|
+
const rule = line.slice(2);
|
|
73
|
+
if (rule.startsWith("*.")) {
|
|
74
|
+
const domain = rule.slice(2);
|
|
75
|
+
rules.wildcard.add(domain);
|
|
76
|
+
wildcardDomains.push(domain);
|
|
77
|
+
rules.tlds.add(lastLabel(domain));
|
|
78
|
+
} else if (rule.startsWith("!")) {
|
|
79
|
+
const domain = rule.slice(1);
|
|
80
|
+
rules.exception.add(domain);
|
|
81
|
+
rules.tlds.add(lastLabel(domain));
|
|
82
|
+
} else {
|
|
83
|
+
rules.normal.add(rule);
|
|
84
|
+
normalDomains.push(rule);
|
|
85
|
+
rules.tlds.add(lastLabel(rule));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const candidates = [
|
|
89
|
+
...wildcardDomains.flatMap((domain) => [
|
|
90
|
+
domain,
|
|
91
|
+
...strictAncestors(domain),
|
|
92
|
+
]),
|
|
93
|
+
...normalDomains.flatMap(strictAncestors),
|
|
94
|
+
];
|
|
95
|
+
for (const candidate of candidates) {
|
|
96
|
+
if (!isSuffixIn(rules, candidate)) rules.below.add(candidate);
|
|
97
|
+
}
|
|
98
|
+
return rules;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function icann(): IcannIndex {
|
|
102
|
+
index ??= buildIndex();
|
|
103
|
+
return index;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether `host` is itself an ICANN public suffix (`co.uk`, `foo.ck`). */
|
|
107
|
+
export function isIcannSuffix(host: string): boolean {
|
|
108
|
+
return isSuffixIn(icann(), host);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether `host` is a strict ancestor of an ICANN rule without being one (`kawasaki.jp`). */
|
|
112
|
+
export function isIcannBelow(host: string): boolean {
|
|
113
|
+
return icann().below.has(host);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Whether `label` is the last label of an ICANN rule. */
|
|
117
|
+
export function isIcannTld(label: string): boolean {
|
|
118
|
+
return icann().tlds.has(label);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Base of a leading-label wildcard source (`https://*.b.com` → `b.com`). */
|
|
122
|
+
export function wildcardSourceBase(source: string): string | null {
|
|
123
|
+
const separator = source.indexOf("://");
|
|
124
|
+
if (separator < 0) return null;
|
|
125
|
+
const host = source.slice(separator + 3).split("/")[0] ?? "";
|
|
126
|
+
return host.startsWith("*.") ? host.slice(2) : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Mirrors `validate_wildcard_bases`: a wildcard whose base is an ICANN public
|
|
131
|
+
* suffix or spans one is rejected (`wildcard-public-suffix`). Other sources
|
|
132
|
+
* are ignored; the structural grammar reports their errors.
|
|
133
|
+
*/
|
|
134
|
+
export function validateWildcardBases(sources: Iterable<string>): string[] {
|
|
135
|
+
const errors: string[] = [];
|
|
136
|
+
for (const source of sources) {
|
|
137
|
+
const base = wildcardSourceBase(source);
|
|
138
|
+
if (base !== null && (isIcannSuffix(base) || isIcannBelow(base))) {
|
|
139
|
+
errors.push(
|
|
140
|
+
`Invalid csp source "${source}": ${WILDCARD_PUBLIC_SUFFIX_MESSAGE}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return errors;
|
|
145
|
+
}
|