@colixsystems/widget-sdk 0.37.0 → 0.39.0
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 +27 -10
- package/dist/cli.js +11 -3
- package/dist/contract.cjs +107 -13
- package/dist/contract.js +107 -13
- package/dist/dev-shims.js +255 -0
- package/dist/devserver.js +640 -73
- package/dist/hooks.js +60 -31
- package/dist/index.d.ts +10 -10
- package/dist/index.js +1 -1
- package/dist/index.native.js +1 -1
- package/dist/webbundle.js +125 -0
- package/package.json +7 -2
package/dist/devserver.js
CHANGED
|
@@ -7,12 +7,40 @@
|
|
|
7
7
|
// text → rewrite bare imports to host shims → blob `import()` → register), so a
|
|
8
8
|
// widget that renders under `dev` will publish unchanged.
|
|
9
9
|
//
|
|
10
|
+
// Two modes (sc-1027 — directory mode is the v2 default):
|
|
11
|
+
//
|
|
12
|
+
// directory mode (multi-file, runs against `first-party-widgets/<name>/`)
|
|
13
|
+
// • reads `widget.json` for the canonical manifest + component source(s);
|
|
14
|
+
// • picks the WEB variant (`componentSources["widget.web.jsx"]` if split-
|
|
15
|
+
// impl, else `componentSource`/`componentSources["widget.jsx"]`);
|
|
16
|
+
// • serves the entry at `/widget.mjs` (unchanged for back-compat with the
|
|
17
|
+
// loader) with its relative imports rewritten to absolute `/file/<rel>`
|
|
18
|
+
// URLs so the browser fetches sibling files from the dev server directly;
|
|
19
|
+
// • serves every sibling `.jsx` at `/file/<rel>` — Sucrase-transpiled, with
|
|
20
|
+
// bare imports rewritten to `/shim/<spec>` URLs (vetted statically-shimmed
|
|
21
|
+
// set only — see `dev-shims.shimmableSpecifiersForSiblings`) and relative
|
|
22
|
+
// imports rewritten to absolute `/file/<resolved-rel>` URLs;
|
|
23
|
+
// • serves `/shim/<slug>` ESM shims that re-export from
|
|
24
|
+
// `globalThis.__appstudioWidgetHost__` — the same global the Studio's
|
|
25
|
+
// blob shims populate, so the dev path stays in lockstep with the
|
|
26
|
+
// published path;
|
|
27
|
+
// • watches the whole widget directory recursively for `.jsx`/`.json` and
|
|
28
|
+
// broadcasts `reload` to every connected client.
|
|
29
|
+
//
|
|
30
|
+
// single-file mode (legacy v1 — passed a bare `.jsx` entry)
|
|
31
|
+
// • original REQ-WSDK-DEVKIT behaviour. Kept so existing tasks don't
|
|
32
|
+
// break, but `dev-widgets/` is gone and the documented home for new
|
|
33
|
+
// dev widgets is `first-party-widgets/<slug>/`. Relative imports in a
|
|
34
|
+
// single-file entry still return 422 (they have no sibling to point
|
|
35
|
+
// at). Slated for removal in a follow-up once everything has moved.
|
|
36
|
+
//
|
|
10
37
|
// What this module owns:
|
|
11
|
-
// * transpile
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// * serve
|
|
15
|
-
// CORS for the
|
|
38
|
+
// * transpile each served file JSX→JS with the SAME Sucrase pass the publish
|
|
39
|
+
// path runs (`transforms:["jsx"]`, automatic runtime, production), so the
|
|
40
|
+
// dev bundle is byte-for-byte the shape the loader expects;
|
|
41
|
+
// * serve `/widget.mjs` + best-effort `/manifest.json` + (directory mode)
|
|
42
|
+
// `/file/<rel>` + `/shim/<slug>` over HTTP with permissive CORS for the
|
|
43
|
+
// Studio dev origin;
|
|
16
44
|
// * a Server-Sent-Events channel (`/__dev/events`) that emits `reload` on
|
|
17
45
|
// every source change so the Builder re-fetches + re-registers + re-renders;
|
|
18
46
|
// * re-lint (reusing `lintSource`) on every change, printing findings without
|
|
@@ -23,18 +51,49 @@
|
|
|
23
51
|
// weight; only the dev command pulls the transform in.
|
|
24
52
|
|
|
25
53
|
import { createServer } from "node:http";
|
|
26
|
-
import {
|
|
27
|
-
|
|
54
|
+
import {
|
|
55
|
+
readFileSync,
|
|
56
|
+
existsSync,
|
|
57
|
+
statSync,
|
|
58
|
+
watch,
|
|
59
|
+
realpathSync,
|
|
60
|
+
} from "node:fs";
|
|
61
|
+
import { resolve, dirname, join, sep, relative, posix } from "node:path";
|
|
28
62
|
import { lintSource } from "./linter.js";
|
|
63
|
+
import {
|
|
64
|
+
getShimSource,
|
|
65
|
+
shimmableSpecifiersForSiblings,
|
|
66
|
+
shimSlugFor,
|
|
67
|
+
shimSpecifierFromSlug,
|
|
68
|
+
} from "./dev-shims.js";
|
|
69
|
+
import { bundleWebEntry } from "./webbundle.js";
|
|
29
70
|
|
|
30
71
|
// Bare-import / relative-import detection. The loader rewrites bare specifiers
|
|
31
72
|
// in the ENTRY to client-side blob shims, so they resolve fine. Relative
|
|
32
|
-
// imports
|
|
33
|
-
//
|
|
34
|
-
//
|
|
73
|
+
// imports in a SINGLE-FILE entry have nowhere to resolve — single-file mode
|
|
74
|
+
// refuses them with guidance. In directory mode, relative imports in the entry
|
|
75
|
+
// AND in siblings are rewritten to absolute dev-server URLs.
|
|
35
76
|
const RELATIVE_IMPORT_RE =
|
|
36
77
|
/(?:^|\n)\s*(?:import|export)[^;\n]*?from\s*['"](\.\.?\/[^'"]+)['"]/g;
|
|
37
78
|
|
|
79
|
+
// Static-import / static-export-from anchored at a statement boundary
|
|
80
|
+
// (start-of-line, `;`, or newline before `import`/`export`) so the rewriter
|
|
81
|
+
// doesn't touch occurrences inside string literals or comment bodies. The
|
|
82
|
+
// dynamic-import form (`import("...")`) is matched separately by
|
|
83
|
+
// `DYNAMIC_IMPORT_RE` below — it's safe to anchor in the same way because
|
|
84
|
+
// it's also valid only as an expression.
|
|
85
|
+
//
|
|
86
|
+
// Capture groups for both regexes:
|
|
87
|
+
// 1 = leading keyword + whitespace (and the leading boundary char, which we
|
|
88
|
+
// preserve verbatim during replace so the surrounding statement isn't
|
|
89
|
+
// glued onto the previous line)
|
|
90
|
+
// 2 = opening quote
|
|
91
|
+
// 3 = specifier
|
|
92
|
+
const STATIC_IMPORT_RE =
|
|
93
|
+
/((?:^|[\n;])\s*(?:import|export)\s+(?:[^'";\n]*?\s+from\s*)?)(['"])([^'"]+)\2/gm;
|
|
94
|
+
const DYNAMIC_IMPORT_RE =
|
|
95
|
+
/((?:^|[\s;\(,!?:=])import\s*\(\s*)(['"])([^'"]+)\2/g;
|
|
96
|
+
|
|
38
97
|
/**
|
|
39
98
|
* Lazily resolve `sucrase`'s `transform`. Kept out of the module's static
|
|
40
99
|
* imports so the published SDK (runtime + types) never forces the dependency;
|
|
@@ -74,9 +133,9 @@ export function transpile(transform, source) {
|
|
|
74
133
|
}
|
|
75
134
|
|
|
76
135
|
/**
|
|
77
|
-
* Find relative (`./` / `../`) import specifiers in a source string. Used
|
|
78
|
-
* reject split-impl bundles
|
|
79
|
-
* module the browser can't resolve.
|
|
136
|
+
* Find relative (`./` / `../`) import specifiers in a source string. Used in
|
|
137
|
+
* single-file mode to reject split-impl bundles with a clear message instead
|
|
138
|
+
* of serving a module the browser can't resolve.
|
|
80
139
|
*
|
|
81
140
|
* @param {string} source
|
|
82
141
|
* @returns {string[]} unique relative specifiers
|
|
@@ -89,6 +148,95 @@ export function findRelativeImports(source) {
|
|
|
89
148
|
return Array.from(out);
|
|
90
149
|
}
|
|
91
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Resolve a relative specifier (`./foo` / `../bar/baz`) into an absolute
|
|
153
|
+
* `/file/<rel>` path under the widget directory. The result is what the dev
|
|
154
|
+
* server serves and what the browser fetches. Returns null when the resolved
|
|
155
|
+
* path escapes the widget directory — the caller refuses to bundle it.
|
|
156
|
+
*
|
|
157
|
+
* Specifiers without a `.jsx`/`.js`/`.json` extension get a `.jsx` default so
|
|
158
|
+
* `import Helper from "./helper"` works without forcing the author to type
|
|
159
|
+
* the extension (matches Vite / Metro behaviour).
|
|
160
|
+
*
|
|
161
|
+
* @param {string} fromRel the rel-path of the importing file (POSIX-style,
|
|
162
|
+
* relative to the widget dir, e.g. "MapWidget.web.jsx" or "lib/util.jsx").
|
|
163
|
+
* @param {string} specifier the relative specifier as written in source.
|
|
164
|
+
* @returns {string | null} POSIX-style rel-path under the widget dir, or null
|
|
165
|
+
* if the resolution escapes it.
|
|
166
|
+
*/
|
|
167
|
+
export function resolveRelativeImport(fromRel, specifier) {
|
|
168
|
+
if (!specifier.startsWith("./") && !specifier.startsWith("../")) return null;
|
|
169
|
+
const fromDir = posix.dirname(fromRel.replace(/\\/g, "/"));
|
|
170
|
+
const joined = posix.normalize(posix.join(fromDir, specifier));
|
|
171
|
+
if (joined.startsWith("..") || joined.startsWith("/")) return null;
|
|
172
|
+
// Default the extension when omitted. A specifier with a non-JS extension
|
|
173
|
+
// (asset imports like `./icon.svg`) is rejected — the dev server only
|
|
174
|
+
// serves JS modules; static asset bundling needs a real build step.
|
|
175
|
+
if (/\.(jsx?|json)$/.test(joined)) return joined;
|
|
176
|
+
if (/\.[A-Za-z0-9]+$/.test(joined)) return null;
|
|
177
|
+
return `${joined}.jsx`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Rewrite imports inside a served file so the browser can resolve every
|
|
182
|
+
* specifier without help from the host loader:
|
|
183
|
+
* • relative `./foo` / `../bar` → absolute `${baseUrl}/file/<resolved-rel>`
|
|
184
|
+
* • bare `<shimmable>` → absolute `${baseUrl}/shim/<slug>`
|
|
185
|
+
* • other bare specifiers (non-shimmable for siblings) are LEFT ALONE so
|
|
186
|
+
* the browser surfaces a clear "Failed to resolve module specifier" error
|
|
187
|
+
* and the dev server logs it as a lint finding alongside.
|
|
188
|
+
*
|
|
189
|
+
* Pure string-level — no AST.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} source transpiled JS source (post-Sucrase).
|
|
192
|
+
* @param {object} ctx
|
|
193
|
+
* @param {string} ctx.fromRel POSIX-style rel-path of the file being served
|
|
194
|
+
* (so relative imports resolve from its directory).
|
|
195
|
+
* @param {string} ctx.baseUrl origin of the dev server (e.g. http://127.0.0.1:4400).
|
|
196
|
+
* @param {string[]} ctx.shimmable bare specifiers the dev server has shims for.
|
|
197
|
+
* @returns {{ code: string, unresolved: string[] }} rewritten source plus the
|
|
198
|
+
* list of bare specifiers that were not shimmable (informational; the dev
|
|
199
|
+
* server logs these so the author sees them in the terminal).
|
|
200
|
+
*/
|
|
201
|
+
export function rewriteImportsForDirectoryMode(source, ctx) {
|
|
202
|
+
const { fromRel, baseUrl, shimmable } = ctx;
|
|
203
|
+
const unresolved = new Set();
|
|
204
|
+
const handler = (match, prefix, quote, spec) => {
|
|
205
|
+
// Relative — resolve under the widget dir.
|
|
206
|
+
if (spec.startsWith("./") || spec.startsWith("../")) {
|
|
207
|
+
const rel = resolveRelativeImport(fromRel, spec);
|
|
208
|
+
if (!rel) {
|
|
209
|
+
unresolved.add(spec);
|
|
210
|
+
return match;
|
|
211
|
+
}
|
|
212
|
+
return `${prefix}${quote}${baseUrl}/file/${rel}${quote}`;
|
|
213
|
+
}
|
|
214
|
+
// Absolute or already-resolved — leave alone.
|
|
215
|
+
if (
|
|
216
|
+
spec.startsWith("/") ||
|
|
217
|
+
spec.startsWith("http:") ||
|
|
218
|
+
spec.startsWith("https:") ||
|
|
219
|
+
spec.startsWith("blob:") ||
|
|
220
|
+
spec.startsWith("data:")
|
|
221
|
+
) {
|
|
222
|
+
return match;
|
|
223
|
+
}
|
|
224
|
+
// Bare — rewrite if shimmable, else surface as unresolved.
|
|
225
|
+
if (shimmable.includes(spec)) {
|
|
226
|
+
return `${prefix}${quote}${baseUrl}/shim/${shimSlugFor(spec)}${quote}`;
|
|
227
|
+
}
|
|
228
|
+
unresolved.add(spec);
|
|
229
|
+
return match;
|
|
230
|
+
};
|
|
231
|
+
// Two passes: anchored static-import + anchored dynamic-import. The split
|
|
232
|
+
// (vs. one mega-regex) keeps each pattern simple AND lets us anchor each at
|
|
233
|
+
// its real statement boundary — so substrings inside string literals like
|
|
234
|
+
// `const s = "import x from 'react'"` never match.
|
|
235
|
+
let out = source.replace(STATIC_IMPORT_RE, handler);
|
|
236
|
+
out = out.replace(DYNAMIC_IMPORT_RE, handler);
|
|
237
|
+
return { code: out, unresolved: Array.from(unresolved) };
|
|
238
|
+
}
|
|
239
|
+
|
|
92
240
|
/**
|
|
93
241
|
* Best-effort manifest resolution for bare-component bundles (the loader's
|
|
94
242
|
* shape B, where the manifest does NOT ride in the bundle). Marketplace
|
|
@@ -121,6 +269,112 @@ export function resolveManifest(entryPath, manifestPath) {
|
|
|
121
269
|
return null;
|
|
122
270
|
}
|
|
123
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Load + validate a `first-party-widgets/<name>/widget.json` and resolve the
|
|
274
|
+
* canonical paths into absolute filesystem locations. Throws with a clear
|
|
275
|
+
* message on any missing/malformed field — failing here beats failing later
|
|
276
|
+
* when the browser dynamic-imports an empty `widget.mjs`.
|
|
277
|
+
*
|
|
278
|
+
* Returns:
|
|
279
|
+
* {
|
|
280
|
+
* widgetDir: <abs path of the widget directory>,
|
|
281
|
+
* manifestPath: <abs path of the manifest .js>,
|
|
282
|
+
* // The chosen WEB-platform entry file. For a split-impl widget that's
|
|
283
|
+
* // `componentSources["widget.web.jsx"]`; for a single-source widget
|
|
284
|
+
* // it's `componentSource` (or `componentSources["widget.jsx"]`).
|
|
285
|
+
* entryPath: <abs path>,
|
|
286
|
+
* entryRel: <POSIX-style rel-path under the widget dir>,
|
|
287
|
+
* }
|
|
288
|
+
*
|
|
289
|
+
* @param {string} widgetDir absolute path to the widget directory
|
|
290
|
+
* @returns {{widgetDir: string, manifestPath: string, entryPath: string, entryRel: string}}
|
|
291
|
+
*/
|
|
292
|
+
export function loadWidgetJson(widgetDir) {
|
|
293
|
+
const configPath = join(widgetDir, "widget.json");
|
|
294
|
+
if (!existsSync(configPath)) {
|
|
295
|
+
throw new Error(`No widget.json at ${configPath}`);
|
|
296
|
+
}
|
|
297
|
+
let config;
|
|
298
|
+
try {
|
|
299
|
+
config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
300
|
+
} catch (err) {
|
|
301
|
+
throw new Error(`Could not parse ${configPath}: ${err.message}`);
|
|
302
|
+
}
|
|
303
|
+
if (!config.manifestSource || typeof config.manifestSource !== "string") {
|
|
304
|
+
throw new Error(`${configPath}: manifestSource is required`);
|
|
305
|
+
}
|
|
306
|
+
// `widget.json` paths are repo-root-relative for the canonical layout
|
|
307
|
+
// (first-party-widgets/<slug>/...), but the dev server doesn't need to
|
|
308
|
+
// assume the parent layout — it only needs to land under `widgetDir`. We
|
|
309
|
+
// try widget-dir-relative first (so a future widget.json with `./foo.js`
|
|
310
|
+
// works), then strip a `first-party-widgets/<slug>/` prefix as a back-
|
|
311
|
+
// compat path for today's widget.json files. `_isUnder` catches anything
|
|
312
|
+
// that resolves outside `widgetDir`.
|
|
313
|
+
const widgetSlug = widgetDir.split(sep).pop();
|
|
314
|
+
function _resolveUnderWidget(label, p) {
|
|
315
|
+
const candidates = [
|
|
316
|
+
resolve(widgetDir, p),
|
|
317
|
+
p.startsWith(`first-party-widgets/${widgetSlug}/`)
|
|
318
|
+
? resolve(widgetDir, p.slice(`first-party-widgets/${widgetSlug}/`.length))
|
|
319
|
+
: null,
|
|
320
|
+
resolve(widgetDir, "..", "..", p),
|
|
321
|
+
].filter(Boolean);
|
|
322
|
+
for (const abs of candidates) {
|
|
323
|
+
if (_isUnder(widgetDir, abs) && existsSync(abs)) return abs;
|
|
324
|
+
}
|
|
325
|
+
throw new Error(
|
|
326
|
+
`${configPath}: ${label} must point at a file under ${widgetDir} (got "${p}")`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const manifestAbs = _resolveUnderWidget("manifestSource", config.manifestSource);
|
|
330
|
+
|
|
331
|
+
let entryAbs;
|
|
332
|
+
if (config.componentSources && typeof config.componentSources === "object") {
|
|
333
|
+
const map = config.componentSources;
|
|
334
|
+
// Prefer the web variant; fall back to the cross-platform `widget.jsx` if a
|
|
335
|
+
// dev'd widget is native-only (rare for the dev loop — the Studio Player
|
|
336
|
+
// is web).
|
|
337
|
+
const pick =
|
|
338
|
+
(typeof map["widget.web.jsx"] === "string" && map["widget.web.jsx"]) ||
|
|
339
|
+
(typeof map["widget.jsx"] === "string" && map["widget.jsx"]) ||
|
|
340
|
+
null;
|
|
341
|
+
if (!pick) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`${configPath}: componentSources has no web-runnable entry ` +
|
|
344
|
+
`(expected "widget.web.jsx" or "widget.jsx").`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
entryAbs = _resolveUnderWidget("componentSources entry", pick);
|
|
348
|
+
} else if (typeof config.componentSource === "string") {
|
|
349
|
+
entryAbs = _resolveUnderWidget("componentSource", config.componentSource);
|
|
350
|
+
} else {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`${configPath}: must set componentSource OR componentSources.`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const entryRel = relative(widgetDir, entryAbs).split(sep).join("/");
|
|
356
|
+
return { widgetDir, manifestPath: manifestAbs, entryPath: entryAbs, entryRel };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function _isUnder(parentAbs, childAbs) {
|
|
360
|
+
const prefix = parentAbs.endsWith(sep) ? parentAbs : parentAbs + sep;
|
|
361
|
+
return childAbs === parentAbs || childAbs.startsWith(prefix);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Defense-in-depth — resolve symlinks before the containment check. The dev
|
|
365
|
+
// server runs on 127.0.0.1 and the developer is the only attacker, but a
|
|
366
|
+
// poisoned symlink under `widgetDir` would otherwise let `/file/<rel>` serve
|
|
367
|
+
// arbitrary repo content. Falls back to the lexical check when realpath
|
|
368
|
+
// can't follow (e.g. broken symlink) so a typo doesn't 500 the server.
|
|
369
|
+
function _isUnderReal(parentRealAbs, childAbs) {
|
|
370
|
+
try {
|
|
371
|
+
const childReal = realpathSync(childAbs);
|
|
372
|
+
return _isUnder(parentRealAbs, childReal);
|
|
373
|
+
} catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
124
378
|
function setCors(res) {
|
|
125
379
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
126
380
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
@@ -134,71 +388,256 @@ function setCors(res) {
|
|
|
134
388
|
* `broadcastReload()` the watcher calls, and a `manifestId` getter for the
|
|
135
389
|
* health route + CLI banner.
|
|
136
390
|
*
|
|
391
|
+
* Either `entryPath` (single-file mode) OR `widgetDir` (directory mode) is
|
|
392
|
+
* required. Directory mode reads `widget.json` from `widgetDir` and chooses
|
|
393
|
+
* the web entry; single-file mode behaves as v1.
|
|
394
|
+
*
|
|
137
395
|
* @param {object} opts
|
|
138
|
-
* @param {string} opts.entryPath absolute path to
|
|
139
|
-
* @param {string
|
|
396
|
+
* @param {string} [opts.entryPath] absolute path to a single-file widget entry
|
|
397
|
+
* @param {string} [opts.widgetDir] absolute path to a directory with widget.json
|
|
398
|
+
* @param {string|null} [opts.manifestPath] explicit `--manifest` path (single-file mode)
|
|
399
|
+
* @param {string[]} [opts.sdkExports] named-export list for the SDK shim
|
|
400
|
+
* (directory mode passes this; single-file mode doesn't need it because the
|
|
401
|
+
* single entry's bare imports are blob-shimmed by the loader).
|
|
140
402
|
* @param {(code: string, opts: object) => { code: string }} opts.transform
|
|
141
403
|
* @param {(msg: string) => void} [opts.onLint] receives lint summary lines
|
|
142
404
|
*/
|
|
143
|
-
export function createDevServer({
|
|
405
|
+
export function createDevServer({
|
|
406
|
+
entryPath,
|
|
407
|
+
widgetDir,
|
|
408
|
+
manifestPath = null,
|
|
409
|
+
sdkExports = [],
|
|
410
|
+
transform,
|
|
411
|
+
onLint,
|
|
412
|
+
}) {
|
|
144
413
|
if (!transform) throw new Error("createDevServer: transform is required");
|
|
145
|
-
|
|
414
|
+
if (!entryPath && !widgetDir) {
|
|
415
|
+
throw new Error("createDevServer: entryPath or widgetDir is required");
|
|
416
|
+
}
|
|
417
|
+
if (entryPath && widgetDir) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
"createDevServer: pass either entryPath OR widgetDir, not both",
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const directoryMode = !!widgetDir;
|
|
424
|
+
let resolvedEntryPath = entryPath;
|
|
425
|
+
let resolvedManifestPath = manifestPath;
|
|
426
|
+
let entryRel = null;
|
|
427
|
+
let watchRoot;
|
|
428
|
+
|
|
429
|
+
if (directoryMode) {
|
|
430
|
+
const cfg = loadWidgetJson(widgetDir);
|
|
431
|
+
resolvedEntryPath = cfg.entryPath;
|
|
432
|
+
resolvedManifestPath = cfg.manifestPath;
|
|
433
|
+
entryRel = cfg.entryRel;
|
|
434
|
+
watchRoot = cfg.widgetDir;
|
|
435
|
+
} else {
|
|
436
|
+
watchRoot = dirname(resolvedEntryPath);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// sc-1064: extra node_modules roots for the web-entry bundler so it resolves
|
|
440
|
+
// a widget's vetted web deps (`react-leaflet`, `leaflet`, …). A first-party
|
|
441
|
+
// widget lives at `<repo>/first-party-widgets/<slug>/`; the vetted web
|
|
442
|
+
// packages install under `<repo>/frontend/node_modules` (adding-vetted-
|
|
443
|
+
// packages skill §3a). Walk up to that root; esbuild also searches the
|
|
444
|
+
// entry's own ancestor node_modules by default, so a widget that vendored
|
|
445
|
+
// its deps locally still resolves.
|
|
446
|
+
const bundleNodePaths = [];
|
|
447
|
+
const _frontendNm = resolve(watchRoot, "..", "..", "frontend", "node_modules");
|
|
448
|
+
if (existsSync(_frontendNm)) bundleNodePaths.push(_frontendNm);
|
|
449
|
+
|
|
146
450
|
const sseClients = new Set();
|
|
147
451
|
let manifest = null;
|
|
148
452
|
try {
|
|
149
|
-
manifest =
|
|
453
|
+
manifest = directoryMode
|
|
454
|
+
? _importManifestJs(resolvedManifestPath)
|
|
455
|
+
: resolveManifest(resolvedEntryPath, resolvedManifestPath);
|
|
150
456
|
} catch (err) {
|
|
151
|
-
// A malformed manifest file shouldn't crash the server — surface it and
|
|
152
|
-
// serve shape-A bundles (manifest in the bundle) regardless.
|
|
153
457
|
if (onLint) onLint(`manifest: ${err.message}`);
|
|
154
458
|
}
|
|
155
459
|
const manifestId = manifest?.id || null;
|
|
460
|
+
const shimmable = shimmableSpecifiersForSiblings();
|
|
461
|
+
// Resolve the watch root once for the symlink-aware containment check.
|
|
462
|
+
// `realpathSync` is sync + cheap; failure (broken symlink) falls back to
|
|
463
|
+
// the lexical watchRoot.
|
|
464
|
+
let watchRootReal = watchRoot;
|
|
465
|
+
try {
|
|
466
|
+
watchRootReal = realpathSync(watchRoot);
|
|
467
|
+
} catch {
|
|
468
|
+
/* keep lexical */
|
|
469
|
+
}
|
|
470
|
+
// Pin the base URL from the listening server's bound interface (set after
|
|
471
|
+
// `listen`). Until then, fall back to a placeholder that the rewriter only
|
|
472
|
+
// uses for resolving relative URLs — `startDevServer` writes the real value
|
|
473
|
+
// once the socket is bound. NOT derived from `req.headers["host"]`: a
|
|
474
|
+
// request with a forged `Host: evil.com` header would otherwise have every
|
|
475
|
+
// /file/ + /shim/ URL rewritten to point at the attacker.
|
|
476
|
+
let boundBaseUrl = "http://127.0.0.1";
|
|
477
|
+
const setBoundBaseUrl = (u) => {
|
|
478
|
+
boundBaseUrl = u;
|
|
479
|
+
};
|
|
156
480
|
|
|
157
|
-
|
|
158
|
-
|
|
481
|
+
// Resolve a `/file/<rel>` request to an absolute filesystem path under the
|
|
482
|
+
// widget directory, or null when the path escapes / doesn't exist.
|
|
483
|
+
// `_isUnderReal` resolves symlinks before the containment check so a
|
|
484
|
+
// poisoned symlink under the widget dir can't serve arbitrary repo content.
|
|
485
|
+
function resolveFileRel(rel) {
|
|
486
|
+
if (!directoryMode) return null;
|
|
487
|
+
if (!rel || rel.includes("\0")) return null;
|
|
488
|
+
// Posix-style on the wire; normalize and refuse `..` traversal.
|
|
489
|
+
const normalised = posix.normalize(rel.replace(/^\/+/, ""));
|
|
490
|
+
if (normalised.startsWith("..") || normalised.startsWith("/")) return null;
|
|
491
|
+
const abs = join(watchRoot, normalised.split("/").join(sep));
|
|
492
|
+
if (!_isUnder(watchRoot, abs)) return null;
|
|
493
|
+
if (!_isUnderReal(watchRootReal, abs)) return null;
|
|
494
|
+
if (!existsSync(abs)) return null;
|
|
495
|
+
try {
|
|
496
|
+
if (!statSync(abs).isFile()) return null;
|
|
497
|
+
} catch {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
return { abs, rel: normalised };
|
|
159
501
|
}
|
|
160
502
|
|
|
161
|
-
function
|
|
503
|
+
function transpileWithGuards(absPath) {
|
|
504
|
+
const source = readFileSync(absPath, "utf8");
|
|
505
|
+
try {
|
|
506
|
+
return { ok: true, code: transpile(transform, source) };
|
|
507
|
+
} catch (err) {
|
|
508
|
+
return { ok: false, error: err };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function serveEntry(req, res) {
|
|
513
|
+
if (directoryMode) {
|
|
514
|
+
// sc-1064: bundle the WEB entry with the SAME routine the publish packer
|
|
515
|
+
// runs, so `dev` and the published `.tgz` are byte-shape-identical. The
|
|
516
|
+
// bundler inlines siblings + vetted web deps (`react-leaflet`, `leaflet`,
|
|
517
|
+
// CSS, PNG assets) and leaves ONLY the host-resolved set
|
|
518
|
+
// (`hostExternalSpecifiers()`) as bare imports — the Studio loader's
|
|
519
|
+
// blob-shim path rewrites those exactly as it does for a published bundle
|
|
520
|
+
// (CLAUDE.md §3, one path). Siblings no longer need the `/file/` rewrite:
|
|
521
|
+
// esbuild has already inlined them.
|
|
522
|
+
let code;
|
|
523
|
+
try {
|
|
524
|
+
code = await bundleWebEntry({
|
|
525
|
+
entryPath: resolvedEntryPath,
|
|
526
|
+
nodePaths: bundleNodePaths,
|
|
527
|
+
});
|
|
528
|
+
} catch (err) {
|
|
529
|
+
const safe = JSON.stringify(String(err.message || err));
|
|
530
|
+
res.writeHead(200, { "Content-Type": "text/javascript" });
|
|
531
|
+
res.end(`throw new Error(${safe});`);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
res.writeHead(200, { "Content-Type": "text/javascript" });
|
|
535
|
+
res.end(code);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Single-file mode — transpile-only (kept for v1 back-compat).
|
|
162
540
|
let source;
|
|
163
541
|
try {
|
|
164
|
-
source =
|
|
542
|
+
source = readFileSync(resolvedEntryPath, "utf8");
|
|
165
543
|
} catch (err) {
|
|
166
544
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
167
|
-
res.end(`Could not read entry ${
|
|
545
|
+
res.end(`Could not read entry ${resolvedEntryPath}: ${err.message}`);
|
|
168
546
|
return;
|
|
169
547
|
}
|
|
548
|
+
let code;
|
|
549
|
+
try {
|
|
550
|
+
code = transpile(transform, source);
|
|
551
|
+
} catch (err) {
|
|
552
|
+
const safe = JSON.stringify(String(err.message || err));
|
|
553
|
+
res.writeHead(200, { "Content-Type": "text/javascript" });
|
|
554
|
+
res.end(`throw new Error(${safe});`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
// Single-file mode has no sibling to resolve relative imports against.
|
|
170
558
|
const relatives = findRelativeImports(source);
|
|
171
559
|
if (relatives.length > 0) {
|
|
172
560
|
const msg =
|
|
173
561
|
`Entry imports relative modules (${relatives.join(", ")}). ` +
|
|
174
|
-
"
|
|
175
|
-
"
|
|
176
|
-
"
|
|
562
|
+
"Single-file mode of `appstudio-widget dev` serves one file only. " +
|
|
563
|
+
"Move the widget under `first-party-widgets/<slug>/` with a " +
|
|
564
|
+
"widget.json pointer, then point the dev task at that directory — " +
|
|
565
|
+
"the dev server picks up siblings automatically (REQ-WSDK-DEVKIT v2).";
|
|
177
566
|
res.writeHead(422, { "Content-Type": "text/plain" });
|
|
178
567
|
res.end(msg);
|
|
179
568
|
return;
|
|
180
569
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
570
|
+
|
|
571
|
+
res.writeHead(200, { "Content-Type": "text/javascript" });
|
|
572
|
+
res.end(code);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function serveFile(req, res, rel) {
|
|
576
|
+
if (!directoryMode) {
|
|
577
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
578
|
+
res.end("/file/ is directory-mode only");
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const resolved = resolveFileRel(rel);
|
|
582
|
+
if (!resolved) {
|
|
583
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
584
|
+
res.end(`File not found under widget directory: ${rel}`);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const tx = transpileWithGuards(resolved.abs);
|
|
588
|
+
if (!tx.ok) {
|
|
589
|
+
const safe = JSON.stringify(String(tx.error.message || tx.error));
|
|
189
590
|
res.writeHead(200, { "Content-Type": "text/javascript" });
|
|
190
591
|
res.end(`throw new Error(${safe});`);
|
|
191
592
|
return;
|
|
192
593
|
}
|
|
594
|
+
const baseUrl = boundBaseUrl;
|
|
595
|
+
const rewritten = rewriteImportsForDirectoryMode(tx.code, {
|
|
596
|
+
fromRel: resolved.rel,
|
|
597
|
+
baseUrl,
|
|
598
|
+
shimmable,
|
|
599
|
+
});
|
|
600
|
+
if (rewritten.unresolved.length > 0 && onLint) {
|
|
601
|
+
onLint(
|
|
602
|
+
`file ${resolved.rel}: unshimmable bare imports — ${rewritten.unresolved.join(", ")}`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
193
605
|
res.writeHead(200, { "Content-Type": "text/javascript" });
|
|
194
|
-
res.end(code);
|
|
606
|
+
res.end(rewritten.code);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function serveShim(res, slug) {
|
|
610
|
+
if (!directoryMode) {
|
|
611
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
612
|
+
res.end("/shim/ is directory-mode only");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const specifier = shimSpecifierFromSlug(slug);
|
|
616
|
+
if (!shimmable.includes(specifier)) {
|
|
617
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
618
|
+
res.end(
|
|
619
|
+
`No shim for "${specifier}". Sibling files may only import: ` +
|
|
620
|
+
`${shimmable.join(", ")}. Move the import to the widget entry where ` +
|
|
621
|
+
`the Studio loader's blob-shim path handles the full vetted set.`,
|
|
622
|
+
);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const src = getShimSource(specifier, { sdkExports });
|
|
626
|
+
if (!src) {
|
|
627
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
628
|
+
res.end(`Could not build shim for ${specifier}`);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
res.writeHead(200, { "Content-Type": "text/javascript" });
|
|
632
|
+
res.end(src);
|
|
195
633
|
}
|
|
196
634
|
|
|
197
635
|
function serveManifest(res) {
|
|
198
|
-
// Re-read so an edited sibling manifest.json is picked up live.
|
|
199
636
|
let m = null;
|
|
200
637
|
try {
|
|
201
|
-
m =
|
|
638
|
+
m = directoryMode
|
|
639
|
+
? _importManifestJs(resolvedManifestPath)
|
|
640
|
+
: resolveManifest(resolvedEntryPath, resolvedManifestPath);
|
|
202
641
|
} catch {
|
|
203
642
|
m = null;
|
|
204
643
|
}
|
|
@@ -223,6 +662,20 @@ export function createDevServer({ entryPath, manifestPath = null, transform, onL
|
|
|
223
662
|
req.on("close", () => sseClients.delete(res));
|
|
224
663
|
}
|
|
225
664
|
|
|
665
|
+
// Once the server starts listening, the bound port is known — derive the
|
|
666
|
+
// canonical base URL from it. This keeps every /file/ + /shim/ URL pinned
|
|
667
|
+
// to the dev server's actual origin no matter what `Host` header a request
|
|
668
|
+
// carries. The caller may also call setBoundBaseUrl explicitly (e.g. when
|
|
669
|
+
// running behind a tunnel that needs a specific public URL).
|
|
670
|
+
function _autoBindOnListen(server) {
|
|
671
|
+
server.on("listening", () => {
|
|
672
|
+
const addr = server.address();
|
|
673
|
+
if (addr && typeof addr === "object") {
|
|
674
|
+
setBoundBaseUrl(`http://127.0.0.1:${addr.port}`);
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
226
679
|
const server = createServer((req, res) => {
|
|
227
680
|
setCors(res);
|
|
228
681
|
if (req.method === "OPTIONS") {
|
|
@@ -231,17 +684,35 @@ export function createDevServer({ entryPath, manifestPath = null, transform, onL
|
|
|
231
684
|
return;
|
|
232
685
|
}
|
|
233
686
|
const url = (req.url || "/").split("?")[0];
|
|
234
|
-
if (url === "/widget.mjs")
|
|
687
|
+
if (url === "/widget.mjs") {
|
|
688
|
+
// serveEntry is async (it bundles the web entry). Catch a rejected
|
|
689
|
+
// promise so a bundler failure never crashes the dev process — it
|
|
690
|
+
// surfaces as a 500 the browser logs instead.
|
|
691
|
+
serveEntry(req, res).catch((err) => {
|
|
692
|
+
if (!res.headersSent) {
|
|
693
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
694
|
+
res.end(`Bundle failed: ${err?.message || err}`);
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
235
699
|
if (url === "/manifest.json") return serveManifest(res);
|
|
236
700
|
if (url === "/__dev/events") return serveEvents(req, res);
|
|
237
701
|
if (url === "/__dev/health") {
|
|
238
702
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
239
|
-
res.end(JSON.stringify({ ok: true, manifestId }));
|
|
703
|
+
res.end(JSON.stringify({ ok: true, manifestId, mode: directoryMode ? "directory" : "single-file" }));
|
|
240
704
|
return;
|
|
241
705
|
}
|
|
706
|
+
if (directoryMode && url.startsWith("/file/")) {
|
|
707
|
+
return serveFile(req, res, url.slice("/file/".length));
|
|
708
|
+
}
|
|
709
|
+
if (directoryMode && url.startsWith("/shim/")) {
|
|
710
|
+
return serveShim(res, url.slice("/shim/".length));
|
|
711
|
+
}
|
|
242
712
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
243
713
|
res.end("Not found");
|
|
244
714
|
});
|
|
715
|
+
_autoBindOnListen(server);
|
|
245
716
|
|
|
246
717
|
function broadcastReload() {
|
|
247
718
|
for (const res of sseClients) {
|
|
@@ -256,7 +727,7 @@ export function createDevServer({ entryPath, manifestPath = null, transform, onL
|
|
|
256
727
|
function lintEntry() {
|
|
257
728
|
let source;
|
|
258
729
|
try {
|
|
259
|
-
source =
|
|
730
|
+
source = readFileSync(resolvedEntryPath, "utf8");
|
|
260
731
|
} catch {
|
|
261
732
|
return;
|
|
262
733
|
}
|
|
@@ -274,7 +745,10 @@ export function createDevServer({ entryPath, manifestPath = null, transform, onL
|
|
|
274
745
|
server,
|
|
275
746
|
broadcastReload,
|
|
276
747
|
lintEntry,
|
|
277
|
-
entryDir,
|
|
748
|
+
entryDir: watchRoot,
|
|
749
|
+
watchRoot,
|
|
750
|
+
directoryMode,
|
|
751
|
+
setBoundBaseUrl,
|
|
278
752
|
get manifestId() {
|
|
279
753
|
return manifest?.id || null;
|
|
280
754
|
},
|
|
@@ -284,27 +758,110 @@ export function createDevServer({ entryPath, manifestPath = null, transform, onL
|
|
|
284
758
|
};
|
|
285
759
|
}
|
|
286
760
|
|
|
761
|
+
// Import a manifest .js file (pure ESM data module) so the dev server can
|
|
762
|
+
// serve a parsed manifest at `/manifest.json` from a first-party widget that
|
|
763
|
+
// stores the manifest as JS (the canonical shape). Falls back to JSON when
|
|
764
|
+
// the file is JSON. The published-bundle path imports the manifest the same
|
|
765
|
+
// way, keeping both in lockstep.
|
|
766
|
+
function _importManifestJs(manifestAbsPath) {
|
|
767
|
+
if (manifestAbsPath.endsWith(".json")) {
|
|
768
|
+
return JSON.parse(readFileSync(manifestAbsPath, "utf8"));
|
|
769
|
+
}
|
|
770
|
+
// For .js / .mjs files we read and `Function`-eval the export — same shape
|
|
771
|
+
// pack-first-party-widget.mjs uses (a tiny ESM data module). Async import is
|
|
772
|
+
// available but synchronous here keeps the createDevServer call signature
|
|
773
|
+
// simple; the file is tiny and re-read on every manifest request.
|
|
774
|
+
const src = readFileSync(manifestAbsPath, "utf8");
|
|
775
|
+
// Match `export const manifest = {...};` or `export default {...};`.
|
|
776
|
+
const m = src.match(/export\s+const\s+manifest\s*=\s*(\{[\s\S]*?\});?\s*$/m);
|
|
777
|
+
if (m) return _safeEvalObjectLiteral(m[1], manifestAbsPath);
|
|
778
|
+
const m2 = src.match(/export\s+default\s+(\{[\s\S]*?\});?\s*$/m);
|
|
779
|
+
if (m2) return _safeEvalObjectLiteral(m2[1], manifestAbsPath);
|
|
780
|
+
throw new Error(
|
|
781
|
+
`Could not find an exported manifest in ${manifestAbsPath} — ` +
|
|
782
|
+
"expected `export const manifest = { ... }` or `export default { ... }`.",
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function _safeEvalObjectLiteral(src, path) {
|
|
787
|
+
// Wrap in parens so the JS parser treats `{...}` as an expression, not a block.
|
|
788
|
+
// Manifests are pure data (no requires, no side effects) so a Function-eval
|
|
789
|
+
// is safe — same shape the canonical pack script's dynamic import produces.
|
|
790
|
+
try {
|
|
791
|
+
// eslint-disable-next-line no-new-func
|
|
792
|
+
return Function(`"use strict";return (${src});`)();
|
|
793
|
+
} catch (err) {
|
|
794
|
+
throw new Error(
|
|
795
|
+
`Could not parse manifest object in ${path}: ${err.message}`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
287
800
|
/**
|
|
288
801
|
* Resolve sucrase, create the dev server, start listening, watch the entry's
|
|
289
|
-
* directory, and wire change → re-lint +
|
|
290
|
-
*
|
|
802
|
+
* directory (recursively in directory mode), and wire change → re-lint +
|
|
803
|
+
* reload broadcast. Returns the running dev-server handle.
|
|
291
804
|
*
|
|
292
805
|
* @param {object} opts
|
|
293
|
-
* @param {string} opts.entry path to
|
|
806
|
+
* @param {string} opts.entry path to a widget entry file OR a widget
|
|
807
|
+
* directory containing widget.json. Directory mode is autodetected.
|
|
294
808
|
* @param {number} [opts.port]
|
|
295
|
-
* @param {string|null} [opts.manifest] explicit manifest path
|
|
809
|
+
* @param {string|null} [opts.manifest] explicit manifest path (single-file)
|
|
810
|
+
* @param {string[]} [opts.sdkExports] caller-supplied SDK named-export list
|
|
811
|
+
* (only used in directory mode for the /shim/@colixsystems/widget-sdk shim).
|
|
812
|
+
* Defaults to a small core set; richer callers (the SDK CLI) can pass the
|
|
813
|
+
* full list from the loaded SDK.
|
|
296
814
|
* @param {(line: string) => void} [opts.log]
|
|
297
|
-
* @returns {Promise<{ server: import("node:http").Server, port: number, url: string, manifestId: string|null, close: () => Promise<void> }>}
|
|
815
|
+
* @returns {Promise<{ server: import("node:http").Server, port: number, url: string, manifestId: string|null, directoryMode: boolean, close: () => Promise<void> }>}
|
|
298
816
|
*/
|
|
299
|
-
export async function startDevServer({
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
817
|
+
export async function startDevServer({
|
|
818
|
+
entry,
|
|
819
|
+
port = 4400,
|
|
820
|
+
manifest = null,
|
|
821
|
+
sdkExports,
|
|
822
|
+
log = () => {},
|
|
823
|
+
}) {
|
|
824
|
+
const entryAbs = resolve(entry);
|
|
825
|
+
if (!existsSync(entryAbs)) {
|
|
826
|
+
throw new Error(`Entry not found: ${entryAbs}`);
|
|
827
|
+
}
|
|
828
|
+
let directoryMode = false;
|
|
829
|
+
try {
|
|
830
|
+
if (statSync(entryAbs).isDirectory()) directoryMode = true;
|
|
831
|
+
} catch {
|
|
832
|
+
/* fall through */
|
|
303
833
|
}
|
|
834
|
+
if (directoryMode && !existsSync(join(entryAbs, "widget.json"))) {
|
|
835
|
+
throw new Error(
|
|
836
|
+
`${entryAbs} is a directory but has no widget.json. Pass a single-file ` +
|
|
837
|
+
".jsx entry OR a first-party widget directory (widget.json + sources).",
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
304
841
|
const transform = await loadTransform();
|
|
842
|
+
// Default SDK exports list — derived from the actual SDK namespace via
|
|
843
|
+
// Object.keys, matching exactly what the Studio's `buildShims()` does. This
|
|
844
|
+
// keeps the dev server's /shim/@colixsystems/widget-sdk surface in lockstep
|
|
845
|
+
// with the published path: if the SDK adds a new export, both paths pick it
|
|
846
|
+
// up on the next dev-server restart without any hand-list maintenance.
|
|
847
|
+
// Caller-supplied `sdkExports` still wins, so tests can pin a fixed list.
|
|
848
|
+
let defaultSdkExports = sdkExports;
|
|
849
|
+
if (!defaultSdkExports) {
|
|
850
|
+
try {
|
|
851
|
+
const sdk = await import("@colixsystems/widget-sdk");
|
|
852
|
+
defaultSdkExports = Object.keys(sdk).filter((k) => k !== "default");
|
|
853
|
+
} catch {
|
|
854
|
+
// SDK not resolvable from this Node process (e.g. dev server running
|
|
855
|
+
// in a detached workspace). Fall back to a small core set — broken
|
|
856
|
+
// dev experience for unusual exports, but better than crashing the CLI.
|
|
857
|
+
defaultSdkExports = ["defineWidget", "View", "Text", "Pressable"];
|
|
858
|
+
}
|
|
859
|
+
}
|
|
305
860
|
const dev = createDevServer({
|
|
306
|
-
entryPath,
|
|
861
|
+
entryPath: directoryMode ? undefined : entryAbs,
|
|
862
|
+
widgetDir: directoryMode ? entryAbs : undefined,
|
|
307
863
|
manifestPath: manifest,
|
|
864
|
+
sdkExports: defaultSdkExports,
|
|
308
865
|
transform,
|
|
309
866
|
onLint: log,
|
|
310
867
|
});
|
|
@@ -315,30 +872,33 @@ export async function startDevServer({ entry, port = 4400, manifest = null, log
|
|
|
315
872
|
});
|
|
316
873
|
const actualPort = dev.server.address().port;
|
|
317
874
|
// Advertise 127.0.0.1, not "localhost": the server binds the IPv4 loopback,
|
|
318
|
-
// but "localhost" resolves to IPv6 (::1) first on Windows
|
|
319
|
-
// hitting http://localhost:<port> would fail to connect. 127.0.0.1 is
|
|
320
|
-
// unambiguous, loopback-only (not LAN-exposed), and works cross-platform.
|
|
875
|
+
// but "localhost" resolves to IPv6 (::1) first on Windows.
|
|
321
876
|
const url = `http://127.0.0.1:${actualPort}`;
|
|
877
|
+
// Pin the rewriter's base URL from the bound interface — NOT the request's
|
|
878
|
+
// Host header — so every /file/ + /shim/ URL is anchored at the server's
|
|
879
|
+
// real origin regardless of what an attacker on the same host puts in Host.
|
|
880
|
+
dev.setBoundBaseUrl(url);
|
|
322
881
|
|
|
323
|
-
// Watch the entry's directory (
|
|
324
|
-
//
|
|
325
|
-
// broadcasting a single reload.
|
|
882
|
+
// Watch the entry's directory (recursive in directory mode so siblings
|
|
883
|
+
// anywhere under widgetDir trigger a reload). Debounce bursts (editors emit
|
|
884
|
+
// multiple events per save) before re-linting + broadcasting a single reload.
|
|
326
885
|
let timer = null;
|
|
327
|
-
const watcher = watch(
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
886
|
+
const watcher = watch(
|
|
887
|
+
dev.watchRoot,
|
|
888
|
+
{ recursive: directoryMode },
|
|
889
|
+
(_event, filename) => {
|
|
890
|
+
if (filename && !/\.(jsx?|json)$/.test(String(filename))) return;
|
|
891
|
+
if (timer) clearTimeout(timer);
|
|
892
|
+
timer = setTimeout(() => {
|
|
893
|
+
timer = null;
|
|
894
|
+
dev.lintEntry();
|
|
895
|
+
dev.broadcastReload();
|
|
896
|
+
log(`reload → ${dev.sseClientCount()} client(s)`);
|
|
897
|
+
}, 60);
|
|
898
|
+
},
|
|
899
|
+
);
|
|
337
900
|
|
|
338
|
-
// Guard against the watched path not existing under some sandboxes.
|
|
339
901
|
watcher.on("error", (err) => log(`watch error: ${err.message}`));
|
|
340
|
-
|
|
341
|
-
// Initial lint pass so the author sees findings immediately.
|
|
342
902
|
dev.lintEntry();
|
|
343
903
|
|
|
344
904
|
async function close() {
|
|
@@ -346,5 +906,12 @@ export async function startDevServer({ entry, port = 4400, manifest = null, log
|
|
|
346
906
|
await new Promise((res) => dev.server.close(res));
|
|
347
907
|
}
|
|
348
908
|
|
|
349
|
-
return {
|
|
909
|
+
return {
|
|
910
|
+
server: dev.server,
|
|
911
|
+
port: actualPort,
|
|
912
|
+
url,
|
|
913
|
+
manifestId: dev.manifestId,
|
|
914
|
+
directoryMode: dev.directoryMode,
|
|
915
|
+
close,
|
|
916
|
+
};
|
|
350
917
|
}
|