@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,297 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
import type { EventEmitter } from "node:events";
|
|
3
|
+
import { type Server, type ServerResponse, createServer } from "node:http";
|
|
4
|
+
import { createServer as createNetServer } from "node:net";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
type FrameworkGroup,
|
|
8
|
+
discoverGroupWidgets,
|
|
9
|
+
discoverGroups,
|
|
10
|
+
} from "../pack";
|
|
11
|
+
import {
|
|
12
|
+
ContractCache,
|
|
13
|
+
type ContractGroupRef,
|
|
14
|
+
handleContractRequest,
|
|
15
|
+
} from "./contract-endpoint";
|
|
16
|
+
import { type HarnessWidget, harnessHtml } from "./harness-html";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_HARNESS_PORT = 4700;
|
|
19
|
+
|
|
20
|
+
export interface DevServerOptions {
|
|
21
|
+
/** Harness port (child dev servers get the next free ports after it) */
|
|
22
|
+
port?: number;
|
|
23
|
+
quiet?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DevGroup {
|
|
27
|
+
group: FrameworkGroup;
|
|
28
|
+
widgetIds: string[];
|
|
29
|
+
/** Port assigned by the harness and forwarded to vite (`--port --strictPort`) */
|
|
30
|
+
assignedPort: number;
|
|
31
|
+
/** Port parsed from the dev server's `Local:` line — authoritative once seen */
|
|
32
|
+
reportedPort: number | null;
|
|
33
|
+
child: ChildProcess | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DevServerHandle {
|
|
37
|
+
port: number;
|
|
38
|
+
url: string;
|
|
39
|
+
groups: DevGroup[];
|
|
40
|
+
server: Server;
|
|
41
|
+
close(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function widgetEntryUrl(port: number, widgetId: string): string {
|
|
45
|
+
return `http://localhost:${port}/src/widgets/${widgetId}/index.html`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isPortFree(port: number): Promise<boolean> {
|
|
49
|
+
return new Promise((done) => {
|
|
50
|
+
const probe = createNetServer();
|
|
51
|
+
probe.once("error", () => done(false));
|
|
52
|
+
probe.listen({ port, host: "127.0.0.1" }, () => {
|
|
53
|
+
probe.close(() => done(true));
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function nextFreePort(
|
|
59
|
+
start: number,
|
|
60
|
+
taken: Set<number>,
|
|
61
|
+
): Promise<number> {
|
|
62
|
+
let port = start;
|
|
63
|
+
while (taken.has(port) || !(await isPortFree(port))) {
|
|
64
|
+
port += 1;
|
|
65
|
+
if (port > 65535) throw new Error("No free port found for a dev server");
|
|
66
|
+
}
|
|
67
|
+
return port;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
|
|
71
|
+
const LOCAL_URL_RE = /\bLocal:\s+https?:\/\/[^\s:/]+:(\d+)/;
|
|
72
|
+
|
|
73
|
+
function lineSplitter(
|
|
74
|
+
onLine: (line: string) => void,
|
|
75
|
+
): (chunk: unknown) => void {
|
|
76
|
+
let buffer = "";
|
|
77
|
+
return (chunk) => {
|
|
78
|
+
buffer += String(chunk);
|
|
79
|
+
const lines = buffer.split(/\r?\n/);
|
|
80
|
+
buffer = lines.pop() ?? "";
|
|
81
|
+
for (const line of lines) onLine(line);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Start one framework group's own dev script.
|
|
87
|
+
*
|
|
88
|
+
* Port mechanism: the harness assigns each group a free port up front and
|
|
89
|
+
* forwards it (`bun run dev -- --port <n> --strictPort`), so widget iframe
|
|
90
|
+
* URLs are deterministic from the start. As a safety net the child's stdout
|
|
91
|
+
* is also scanned for vite's `Local: http://localhost:<port>` line; if the
|
|
92
|
+
* dev script ignored the forwarded flags (or vite still moved), the reported
|
|
93
|
+
* port overrides the assigned one for all URLs the harness serves.
|
|
94
|
+
*/
|
|
95
|
+
function spawnGroup(
|
|
96
|
+
entry: DevGroup,
|
|
97
|
+
log: (line: string) => void,
|
|
98
|
+
): ChildProcess {
|
|
99
|
+
const child = spawn(
|
|
100
|
+
"bun",
|
|
101
|
+
["run", "dev", "--", "--port", String(entry.assignedPort), "--strictPort"],
|
|
102
|
+
{
|
|
103
|
+
cwd: entry.group.dir,
|
|
104
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
105
|
+
detached: process.platform !== "win32",
|
|
106
|
+
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
const onLine = (line: string) => {
|
|
110
|
+
const clean = line.replace(ANSI_RE, "");
|
|
111
|
+
const match = LOCAL_URL_RE.exec(clean);
|
|
112
|
+
const port = match?.[1] !== undefined ? Number(match[1]) : Number.NaN;
|
|
113
|
+
if (Number.isInteger(port) && port > 0) entry.reportedPort = port;
|
|
114
|
+
if (clean.trim().length > 0) log(clean);
|
|
115
|
+
};
|
|
116
|
+
child.stdout?.on("data", lineSplitter(onLine));
|
|
117
|
+
child.stderr?.on("data", lineSplitter(onLine));
|
|
118
|
+
child.on("error", (e) => log(`failed to start dev server: ${e.message}`));
|
|
119
|
+
child.on("exit", (code, signal) => {
|
|
120
|
+
log(`dev server exited (${signal ?? `code ${code}`})`);
|
|
121
|
+
});
|
|
122
|
+
return child;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function killChild(child: ChildProcess | null): void {
|
|
126
|
+
if (!child || child.exitCode !== null || child.pid === undefined) return;
|
|
127
|
+
if (process.platform === "win32") {
|
|
128
|
+
child.kill();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
process.kill(-child.pid, "SIGTERM");
|
|
133
|
+
} catch {
|
|
134
|
+
child.kill("SIGTERM");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function widgetList(devGroups: DevGroup[]): HarnessWidget[] {
|
|
139
|
+
const widgets: HarnessWidget[] = [];
|
|
140
|
+
for (const entry of devGroups) {
|
|
141
|
+
const port = entry.reportedPort ?? entry.assignedPort;
|
|
142
|
+
for (const id of entry.widgetIds) {
|
|
143
|
+
widgets.push({
|
|
144
|
+
group: entry.group.name,
|
|
145
|
+
id,
|
|
146
|
+
entryUrl: widgetEntryUrl(port, id),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return widgets;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
154
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
155
|
+
res.end(JSON.stringify(body));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `flow-like-widgets dev`: spawn every framework group's dev script and serve
|
|
160
|
+
* the mock-host harness page (design §8.4 Layer 1) plus its JSON endpoints:
|
|
161
|
+
*
|
|
162
|
+
* - `GET /` harness page
|
|
163
|
+
* - `GET /api/widgets` live widget list + iframe entry URLs
|
|
164
|
+
* - `GET /api/contract/<group>/<id>` re-extracted contract (mtime-cached)
|
|
165
|
+
*/
|
|
166
|
+
export async function startDevServer(
|
|
167
|
+
projectDir: string,
|
|
168
|
+
opts: DevServerOptions = {},
|
|
169
|
+
): Promise<DevServerHandle> {
|
|
170
|
+
const project = resolve(projectDir);
|
|
171
|
+
const groups = discoverGroups(project);
|
|
172
|
+
if (groups.length === 0) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`No framework groups found under ${join(project, "widgets")} (expected widgets/<group>/package.json)`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const harnessPort = opts.port ?? DEFAULT_HARNESS_PORT;
|
|
179
|
+
const taken = new Set<number>([harnessPort]);
|
|
180
|
+
const devGroups: DevGroup[] = [];
|
|
181
|
+
let searchFrom = harnessPort + 1;
|
|
182
|
+
for (const group of groups) {
|
|
183
|
+
const widgetIds = discoverGroupWidgets(group.dir);
|
|
184
|
+
if (widgetIds.length === 0) continue;
|
|
185
|
+
const assignedPort = await nextFreePort(searchFrom, taken);
|
|
186
|
+
taken.add(assignedPort);
|
|
187
|
+
searchFrom = assignedPort + 1;
|
|
188
|
+
devGroups.push({
|
|
189
|
+
group,
|
|
190
|
+
widgetIds,
|
|
191
|
+
assignedPort,
|
|
192
|
+
reportedPort: null,
|
|
193
|
+
child: null,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (devGroups.length === 0) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`No widgets found (expected widgets/<group>/src/widgets/<id>/widget.config.ts under ${project})`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const log = opts.quiet ? () => {} : (line: string) => console.log(line);
|
|
203
|
+
for (const entry of devGroups) {
|
|
204
|
+
log(
|
|
205
|
+
`[harness] starting '${entry.group.name}' dev server on port ${entry.assignedPort} (${entry.widgetIds.join(", ")})`,
|
|
206
|
+
);
|
|
207
|
+
entry.child = spawnGroup(entry, (line) =>
|
|
208
|
+
log(`[${entry.group.name}] ${line}`),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const cache = new ContractCache();
|
|
213
|
+
const groupRefs: ContractGroupRef[] = devGroups.map((entry) => ({
|
|
214
|
+
name: entry.group.name,
|
|
215
|
+
dir: entry.group.dir,
|
|
216
|
+
}));
|
|
217
|
+
const refreshWidgetIds = () => {
|
|
218
|
+
for (const entry of devGroups) {
|
|
219
|
+
const ids = discoverGroupWidgets(entry.group.dir);
|
|
220
|
+
if (ids.length > 0) entry.widgetIds = ids;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const server = createServer((req, res) => {
|
|
225
|
+
const url = new URL(req.url ?? "/", `http://localhost:${harnessPort}`);
|
|
226
|
+
if (req.method !== "GET") {
|
|
227
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (url.pathname === "/") {
|
|
231
|
+
refreshWidgetIds();
|
|
232
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
233
|
+
res.end(harnessHtml(widgetList(devGroups)));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (url.pathname === "/api/widgets") {
|
|
237
|
+
refreshWidgetIds();
|
|
238
|
+
sendJson(res, 200, { widgets: widgetList(devGroups) });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const match = /^\/api\/contract\/([^/]+)\/([^/]+)$/.exec(url.pathname);
|
|
242
|
+
if (match?.[1] !== undefined && match[2] !== undefined) {
|
|
243
|
+
const { status, body } = handleContractRequest(
|
|
244
|
+
cache,
|
|
245
|
+
groupRefs,
|
|
246
|
+
decodeURIComponent(match[1]),
|
|
247
|
+
decodeURIComponent(match[2]),
|
|
248
|
+
);
|
|
249
|
+
sendJson(res, status, body);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
sendJson(res, 404, { error: "not found" });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
await new Promise<void>((done, fail) => {
|
|
257
|
+
const onError = (e: Error) =>
|
|
258
|
+
fail(
|
|
259
|
+
new Error(
|
|
260
|
+
`Failed to bind the harness port ${harnessPort}: ${e.message} (pass --port to choose another)`,
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
server.once("error", onError);
|
|
264
|
+
server.listen(harnessPort, "127.0.0.1", () => {
|
|
265
|
+
server.off("error", onError);
|
|
266
|
+
done();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
} catch (e) {
|
|
270
|
+
for (const entry of devGroups) killChild(entry.child);
|
|
271
|
+
throw e;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let closed = false;
|
|
275
|
+
const processEmitter = process as EventEmitter;
|
|
276
|
+
const close = async () => {
|
|
277
|
+
if (closed) return;
|
|
278
|
+
closed = true;
|
|
279
|
+
processEmitter.off("SIGINT", onSignal);
|
|
280
|
+
processEmitter.off("SIGTERM", onSignal);
|
|
281
|
+
for (const entry of devGroups) killChild(entry.child);
|
|
282
|
+
await new Promise<void>((done) => server.close(() => done()));
|
|
283
|
+
};
|
|
284
|
+
const onSignal = () => {
|
|
285
|
+
void close().then(() => process.exit(0));
|
|
286
|
+
};
|
|
287
|
+
process.once("SIGINT", onSignal);
|
|
288
|
+
process.once("SIGTERM", onSignal);
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
port: harnessPort,
|
|
292
|
+
url: `http://localhost:${harnessPort}/`,
|
|
293
|
+
groups: devGroups,
|
|
294
|
+
server,
|
|
295
|
+
close,
|
|
296
|
+
};
|
|
297
|
+
}
|