@colixsystems/widget-sdk 0.38.0 → 0.40.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/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 the single-file entry JSX→JS with the SAME Sucrase pass the
12
- // publish path runs (`transforms:["jsx"]`, automatic runtime, production),
13
- // so the served `/widget.mjs` is byte-for-byte the shape the loader expects;
14
- // * serve it (plus a best-effort `/manifest.json`) over HTTP with permissive
15
- // CORS for the Vite dev origin;
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 { readFileSync, existsSync, watch } from "node:fs";
27
- import { resolve, dirname, join, sep } from "node:path";
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 do NOT — a sibling fetched over HTTP can't be handed the loader's
33
- // blob shims for its own bare imports so v1 refuses them with guidance
34
- // rather than serving a module the browser silently fails to resolve.
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 to
78
- * reject split-impl bundles in v1 with a clear message instead of serving a
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 the widget entry
139
- * @param {string|null} [opts.manifestPath] explicit `--manifest` path
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({ entryPath, manifestPath = null, transform, onLint }) {
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
- const entryDir = dirname(entryPath);
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 = resolveManifest(entryPath, manifestPath);
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
- function readEntry() {
158
- return readFileSync(entryPath, "utf8");
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 serveWidget(res) {
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 = readEntry();
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 ${entryPath}: ${err.message}`);
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
- "v1 of `appstudio-widget dev` serves a single-file entry only " +
175
- "bundle split-impl widgets with a real build step (esbuild/rollup) " +
176
- "and load the built widget.mjs, or inline the helpers into the entry.";
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
- let code;
182
- try {
183
- code = transpile(transform, source);
184
- } catch (err) {
185
- // Surface the transpile error as the module body so the browser console
186
- // (and the Builder dev panel) shows the real syntax error, not an opaque
187
- // "failed to fetch dynamically imported module".
188
- const safe = JSON.stringify(String(err.message || err));
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 = resolveManifest(entryPath, manifestPath);
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") return serveWidget(res);
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 = readEntry();
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 + reload broadcast. Returns the running
290
- * dev-server handle. The caller (the CLI) owns logging the banner.
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 the widget entry (relative or absolute)
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({ entry, port = 4400, manifest = null, log = () => {} }) {
300
- const entryPath = resolve(entry);
301
- if (!existsSync(entryPath)) {
302
- throw new Error(`Entry not found: ${entryPath}`);
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, so a browser/CLI
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 (non-recursive reliable cross-platform).
324
- // Debounce bursts (editors emit multiple events per save) before linting +
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(dev.entryDir, (_event, filename) => {
328
- if (filename && !/\.(jsx?|json)$/.test(String(filename))) return;
329
- if (timer) clearTimeout(timer);
330
- timer = setTimeout(() => {
331
- timer = null;
332
- dev.lintEntry();
333
- dev.broadcastReload();
334
- log(`reload ${dev.sseClientCount()} client(s)`);
335
- }, 60);
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 { server: dev.server, port: actualPort, url, manifestId: dev.manifestId, close };
909
+ return {
910
+ server: dev.server,
911
+ port: actualPort,
912
+ url,
913
+ manifestId: dev.manifestId,
914
+ directoryMode: dev.directoryMode,
915
+ close,
916
+ };
350
917
  }