@jxsuite/server 0.0.1 → 0.5.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/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "0.0.1",
3
+ "version": "0.5.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/jxsuite/jx.git",
9
+ "directory": "packages/server"
10
+ },
6
11
  "files": [
7
12
  "src/"
8
13
  ],
@@ -10,6 +15,9 @@
10
15
  "exports": {
11
16
  ".": "./src/server.js"
12
17
  },
18
+ "publishConfig": {
19
+ "provenance": true
20
+ },
13
21
  "scripts": {
14
22
  "upgrade": "bunx npm-check-updates -u && bun install"
15
23
  },
package/src/server.js CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * @example
3
- * import { createDevServer } from "@jxplatform/server";
3
+ * import { createDevServer } from "@jxsuite/server";
4
4
  *
5
5
  * await createDevServer({
6
6
  * root: import.meta.dir,
7
7
  * builds: [{ entrypoints: ["./src/app.js"], outdir: "./dist", match: /src/, label: "app" }],
8
8
  * });
9
9
  *
10
- * jxplatform/server — Jx development server
10
+ * jxsuite/server — Jx development server
11
11
  *
12
12
  * Provides builds, live reload, $src module proxying, timing: "server" function
13
13
  * proxying, and studio filesystem integration as a single createDevServer() call.
@@ -27,7 +27,7 @@ import { existsSync, readFileSync } from "node:fs";
27
27
  * /pages/@scope/pkg/file → @scope/pkg/file).
28
28
  *
29
29
  * @param {string} root - Absolute project root
30
- * @param {string} urlPath - URL pathname (e.g. "/pages/@jxplatform/parser/Foo.class.json")
30
+ * @param {string} urlPath - URL pathname (e.g. "/pages/@jxsuite/parser/Foo.class.json")
31
31
  * @returns {string | null} Absolute file path or null
32
32
  */
33
33
  function resolveNpmPath(root, urlPath) {
@@ -141,7 +141,7 @@ export async function createDevServer(options) {
141
141
  middleware,
142
142
  } = options;
143
143
 
144
- if (!root) throw new Error("@jxplatform/server: root is required");
144
+ if (!root) throw new Error("@jxsuite/server: root is required");
145
145
  const absRoot = resolve(root);
146
146
 
147
147
  // ─── Build pipeline ─────────────────────────────────────────────────────────
@@ -163,6 +163,10 @@ export async function createDevServer(options) {
163
163
  /** @type {Map<string, string>} */
164
164
  const bundleCache = new Map();
165
165
 
166
+ // Active studio project root (set via /__studio/activate, used for static file fallback)
167
+ /** @type {string | null} */
168
+ let activeProjectRoot = null;
169
+
166
170
  // ─── HTTP server ────────────────────────────────────────────────────────────
167
171
 
168
172
  const server = Bun.serve({
@@ -191,10 +195,19 @@ export async function createDevServer(options) {
191
195
 
192
196
  // Studio filesystem API
193
197
  if (enableStudio && path.startsWith("/__studio/")) {
198
+ // Activate project — tells the server which project root to use for static file fallback
199
+ if (path === "/__studio/activate" && req.method === "POST") {
200
+ const body = await req.json();
201
+ const raw = body.root || null;
202
+ // Always store as absolute path
203
+ activeProjectRoot = raw ? resolve(absRoot, raw) : null;
204
+ return Response.json({ ok: true, root: activeProjectRoot });
205
+ }
206
+
194
207
  const codeRes = await handleCodeApi(req, url);
195
208
  if (codeRes) return codeRes;
196
209
 
197
- const res = await handleStudioApi(req, url, absRoot);
210
+ const res = await handleStudioApi(req, url, absRoot, activeProjectRoot);
198
211
  if (res) return res;
199
212
  }
200
213
 
@@ -205,8 +218,32 @@ export async function createDevServer(options) {
205
218
  }
206
219
 
207
220
  // Static files
221
+
222
+ // If the URL path is an absolute filesystem path under the active project, serve directly.
223
+ // Browsers produce "//abs/path" when an absolute path is used as a URL path — normalise.
224
+ const fsPath = path.startsWith("//") ? path.slice(1) : path;
225
+ if (activeProjectRoot && fsPath.startsWith(activeProjectRoot)) {
226
+ const file = Bun.file(fsPath);
227
+ if (await file.exists()) {
228
+ return new Response(file);
229
+ }
230
+ }
231
+
208
232
  const file = Bun.file(resolve(absRoot, "." + path));
209
233
  if (!(await file.exists())) {
234
+ // Try resolving relative to active studio project root
235
+ if (activeProjectRoot) {
236
+ const projectFile = Bun.file(resolve(activeProjectRoot, "." + path));
237
+ if (await projectFile.exists()) {
238
+ return new Response(projectFile);
239
+ }
240
+ // Mirror production: public/ contents are served at root
241
+ const publicFile = Bun.file(resolve(activeProjectRoot, "public", "." + path));
242
+ if (await publicFile.exists()) {
243
+ return new Response(publicFile);
244
+ }
245
+ }
246
+
210
247
  // Resolve npm-style bare specifiers via node_modules.
211
248
  // Bundle on-demand so internal bare specifiers (e.g. lit/...) resolve.
212
249
  const resolved = resolveNpmPath(absRoot, path);
@@ -247,7 +284,7 @@ export async function createDevServer(options) {
247
284
  },
248
285
  });
249
286
 
250
- console.log(`\n@jxplatform/server listening on http://localhost:${server.port}`);
287
+ console.log(`\n@jxsuite/server listening on http://localhost:${server.port}`);
251
288
 
252
289
  return server;
253
290
  }
package/src/studio-api.js CHANGED
@@ -7,17 +7,29 @@
7
7
  * All paths are relative to the project root. Directory traversal above root is rejected.
8
8
  */
9
9
 
10
- import { resolve, relative, basename, dirname } from "node:path";
10
+ import { resolve, relative, basename, dirname, isAbsolute } from "node:path";
11
11
  import { readdir, stat, readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
12
12
  import { readFileSync, existsSync } from "node:fs";
13
13
 
14
+ /** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
15
+ const fwd = (/** @type {string} */ p) => p.replaceAll("\\", "/");
16
+
14
17
  /**
18
+ * Check that a path is under either the server root OR the active project root. This allows file
19
+ * operations on external projects that have been explicitly activated via /__studio/activate.
20
+ *
15
21
  * @param {string} filePath
16
22
  * @param {string} root
23
+ * @param {string | null} activeProjectRoot
17
24
  */
18
- function assertUnderRoot(filePath, root) {
25
+ function assertAccessible(filePath, root, activeProjectRoot) {
19
26
  const rel = relative(root, filePath);
20
- if (rel.startsWith("..") || rel.startsWith("/")) throw new Error("Path outside project root");
27
+ if (!rel.startsWith("..") && !rel.startsWith("/")) return;
28
+ if (activeProjectRoot) {
29
+ const relActive = relative(activeProjectRoot, filePath);
30
+ if (!relActive.startsWith("..") && !relActive.startsWith("/")) return;
31
+ }
32
+ throw new Error("Path outside project root");
21
33
  }
22
34
 
23
35
  /**
@@ -26,8 +38,9 @@ function assertUnderRoot(filePath, root) {
26
38
  * @param {Request} req
27
39
  * @param {URL} url
28
40
  * @param {string} root
41
+ * @param {string | null} [activeProjectRoot]
29
42
  */
30
- export async function handleStudioApi(req, url, root) {
43
+ export async function handleStudioApi(req, url, root, activeProjectRoot = null) {
31
44
  const path = url.pathname;
32
45
 
33
46
  // Project metadata
@@ -46,15 +59,15 @@ export async function handleStudioApi(req, url, root) {
46
59
 
47
60
  // Project info — probe a directory for site-project characteristics
48
61
  if (path === "/__studio/project-info" && req.method === "GET") {
49
- const dir = url.searchParams.get("dir") ?? ".";
50
- const absDir = resolve(root, dir);
62
+ const dir = url.searchParams.get("dir") || activeProjectRoot || root;
63
+ const absDir = isAbsolute(dir) ? dir : resolve(root, dir);
51
64
  try {
52
- assertUnderRoot(absDir, root);
65
+ assertAccessible(absDir, root, activeProjectRoot);
53
66
  } catch (/** @type {any} */ e) {
54
67
  return Response.json({ error: e.message }, { status: 400 });
55
68
  }
56
69
  try {
57
- const projectRoot = relative(root, absDir) || ".";
70
+ const projectRoot = fwd(absDir);
58
71
  const conventionalDirs = [
59
72
  "pages",
60
73
  "layouts",
@@ -97,19 +110,18 @@ export async function handleStudioApi(req, url, root) {
97
110
  let dir = dirname(
98
111
  filePath.startsWith("~") ? filePath.replace("~", process.env.HOME || "") : filePath,
99
112
  );
100
- const stopAt = "/";
101
- while (dir && dir !== stopAt) {
113
+ while (dir) {
102
114
  const candidate = resolve(dir, "project.json");
103
115
  if (existsSync(candidate)) {
104
116
  const config = JSON.parse(readFileSync(candidate, "utf8"));
105
- const relPath = relative(root, dir);
117
+ const relPath = fwd(dir);
106
118
  const absFile = filePath.startsWith("~")
107
119
  ? filePath.replace("~", process.env.HOME || "")
108
120
  : filePath;
109
- const fileRelPath = relative(dir, absFile);
121
+ const fileRelPath = fwd(relative(dir, absFile));
110
122
  return Response.json({
111
123
  sitePath: dir,
112
- relPath: relPath || ".",
124
+ relPath: relPath,
113
125
  fileRelPath,
114
126
  projectConfig: config,
115
127
  });
@@ -124,19 +136,43 @@ export async function handleStudioApi(req, url, root) {
124
136
  }
125
137
  }
126
138
 
139
+ // Find a project directory by name — searches $HOME for the first matching directory with a
140
+ // project.json. Dev-mode workaround for when showDirectoryPicker() can't provide absolute paths.
141
+ if (path === "/__studio/find-project" && req.method === "GET") {
142
+ const name = url.searchParams.get("name");
143
+ if (!name) return Response.json({ error: "Missing name" }, { status: 400 });
144
+ try {
145
+ const home = process.env.HOME || process.env.USERPROFILE || "";
146
+ if (!home) return Response.json({ path: null });
147
+ const glob = new Bun.Glob(`**/${name}/project.json`);
148
+ for await (const match of glob.scan({ cwd: home, dot: false })) {
149
+ if (match.includes("node_modules") || match.includes(".Trash")) continue;
150
+ const abs = resolve(home, dirname(match));
151
+ return Response.json({ path: abs });
152
+ }
153
+ return Response.json({ path: null });
154
+ } catch (/** @type {any} */ e) {
155
+ return Response.json({ error: e.message }, { status: 500 });
156
+ }
157
+ }
158
+
127
159
  // Discover site projects — find all project.json files under root
128
160
  if (path === "/__studio/sites" && req.method === "GET") {
129
161
  try {
130
162
  const glob = new Bun.Glob("**/project.json");
131
163
  const sites = [];
132
164
  for await (const match of glob.scan({ cwd: root, dot: false })) {
133
- if (match.includes("node_modules") || match.includes("dist/") || match.includes(".claude/"))
165
+ if (
166
+ match.includes("node_modules") ||
167
+ fwd(match).includes("dist/") ||
168
+ fwd(match).includes(".claude/")
169
+ )
134
170
  continue;
135
171
  const fp = resolve(root, match);
136
172
  try {
137
173
  const raw = JSON.parse(await readFile(fp, "utf8"));
138
174
  if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
139
- const projectDir = dirname(match) === "." ? "." : dirname(match).replaceAll("\\", "/");
175
+ const projectDir = fwd(dirname(fp));
140
176
  sites.push({ path: projectDir, config: raw });
141
177
  }
142
178
  } catch {}
@@ -149,15 +185,24 @@ export async function handleStudioApi(req, url, root) {
149
185
 
150
186
  // List files
151
187
  if (path === "/__studio/files" && req.method === "GET") {
152
- const dir = url.searchParams.get("dir") ?? ".";
188
+ const dir = url.searchParams.get("dir") || activeProjectRoot || root;
153
189
  const pattern = url.searchParams.get("glob");
154
- const absDir = resolve(root, dir);
190
+ const absDir = isAbsolute(dir) ? dir : resolve(root, dir);
155
191
  try {
156
- assertUnderRoot(absDir, root);
192
+ assertAccessible(absDir, root, activeProjectRoot);
157
193
  } catch (/** @type {any} */ e) {
158
194
  return Response.json({ error: e.message }, { status: 400 });
159
195
  }
160
196
 
197
+ /** Report a path relative to the active project root (or server root as fallback). */
198
+ const reportRelative = (/** @type {string} */ fp) => {
199
+ if (activeProjectRoot) {
200
+ const rel = relative(activeProjectRoot, fp);
201
+ if (!rel.startsWith("..")) return fwd(rel);
202
+ }
203
+ return fwd(relative(root, fp));
204
+ };
205
+
161
206
  try {
162
207
  if (pattern) {
163
208
  const glob = new Bun.Glob(pattern);
@@ -169,7 +214,7 @@ export async function handleStudioApi(req, url, root) {
169
214
  if (!s.isDirectory()) {
170
215
  files.push({
171
216
  name: basename(match),
172
- path: relative(root, fp),
217
+ path: reportRelative(fp),
173
218
  size: s.size,
174
219
  modified: s.mtime.toISOString(),
175
220
  });
@@ -187,7 +232,7 @@ export async function handleStudioApi(req, url, root) {
187
232
  const s = await stat(fp);
188
233
  files.push({
189
234
  name: entry.name,
190
- path: relative(root, fp),
235
+ path: reportRelative(fp),
191
236
  type: entry.isDirectory() ? "directory" : "file",
192
237
  size: s.size,
193
238
  modified: s.mtime.toISOString(),
@@ -201,36 +246,60 @@ export async function handleStudioApi(req, url, root) {
201
246
 
202
247
  // Component discovery — scan project for custom element definitions
203
248
  if (path === "/__studio/components" && req.method === "GET") {
204
- const dir = url.searchParams.get("dir");
205
- const scanRoot = dir ? resolve(root, dir) : root;
206
- if (dir) {
207
- try {
208
- assertUnderRoot(scanRoot, root);
209
- } catch (/** @type {any} */ e) {
210
- return Response.json({ error: e.message }, { status: 400 });
211
- }
249
+ const dir = url.searchParams.get("dir") || activeProjectRoot || root;
250
+ const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
251
+ try {
252
+ assertAccessible(scanRoot, root, activeProjectRoot);
253
+ } catch (/** @type {any} */ e) {
254
+ return Response.json({ error: e.message }, { status: 400 });
212
255
  }
213
256
  try {
214
- const glob = new Bun.Glob("**/*.json");
257
+ const glob = new Bun.Glob("**/*.{json,md}");
215
258
  const components = [];
216
259
  for await (const match of glob.scan({ cwd: scanRoot, dot: false })) {
217
- if (match.includes("node_modules") || match.includes("dist/") || match.includes(".claude/"))
260
+ if (
261
+ match.includes("node_modules") ||
262
+ fwd(match).includes("dist/") ||
263
+ fwd(match).includes(".claude/")
264
+ )
218
265
  continue;
219
266
  const fp = resolve(scanRoot, match);
220
267
  try {
221
- const content = JSON.parse(await readFile(fp, "utf8"));
268
+ /** @type {any} */
269
+ let content;
270
+ if (match.endsWith(".md")) {
271
+ // Parse YAML frontmatter to check for Jx component
272
+ const source = await readFile(fp, "utf8");
273
+ const fmMatch = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
274
+ if (!fmMatch) continue;
275
+ // Quick check: must have tagName with hyphen
276
+ if (!/^tagName:\s*.+-.+/m.test(fmMatch[1])) continue;
277
+ const { transpileJxMarkdown } = await import("@jxsuite/parser/transpile");
278
+ content = transpileJxMarkdown(source);
279
+ } else {
280
+ content = JSON.parse(await readFile(fp, "utf8"));
281
+ }
222
282
  if (content.tagName && content.tagName.includes("-")) {
223
283
  components.push({
224
284
  tagName: content.tagName,
225
285
  $id: content.$id || null,
226
- path: match,
286
+ path: fwd(match),
227
287
  source: "jx",
228
288
  props: Object.entries(content.state || {})
229
- .filter(
230
- ([, d]) =>
231
- d && typeof d === "object" && !d.$prototype && !d.$handler && !d.$compute,
232
- )
233
- .map(([name, d]) => ({ name, type: d.type, default: d.default })),
289
+ .filter(([, d]) => {
290
+ if (d == null) return false;
291
+ // Shorthand: "key": "value" or "key": 0 etc.
292
+ if (typeof d !== "object") return true;
293
+ // Full form: skip computed/handler/prototype entries
294
+ return !d.$prototype && !d.$handler && !d.$compute;
295
+ })
296
+ .map(([name, d]) => {
297
+ if (typeof d !== "object") {
298
+ // Shorthand: infer type from value
299
+ return { name, type: typeof d, default: d };
300
+ }
301
+ return { name, type: d.type, default: d.default };
302
+ }),
234
303
  hasElements: Array.isArray(content.$elements) && content.$elements.length > 0,
235
304
  });
236
305
  }
@@ -312,8 +381,8 @@ export async function handleStudioApi(req, url, root) {
312
381
 
313
382
  // List CEM-bearing npm packages
314
383
  if (path === "/__studio/packages" && req.method === "GET") {
315
- const dir = url.searchParams.get("dir");
316
- const scanRoot = dir ? resolve(root, dir) : root;
384
+ const dir = url.searchParams.get("dir") || activeProjectRoot || root;
385
+ const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
317
386
  try {
318
387
  const pkgPath = resolve(scanRoot, "package.json");
319
388
  if (!existsSync(pkgPath)) return Response.json([]);
@@ -350,8 +419,8 @@ export async function handleStudioApi(req, url, root) {
350
419
  if (path === "/__studio/cem" && req.method === "GET") {
351
420
  const pkg = url.searchParams.get("pkg");
352
421
  if (!pkg) return new Response("Missing pkg", { status: 400 });
353
- const dir = url.searchParams.get("dir");
354
- const scanRoot = dir ? resolve(root, dir) : root;
422
+ const dir = url.searchParams.get("dir") || activeProjectRoot || root;
423
+ const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
355
424
  try {
356
425
  const depPkgPath = resolve(scanRoot, "node_modules", ...pkg.split("/"), "package.json");
357
426
  const fallbackPath = resolve(root, "node_modules", ...pkg.split("/"), "package.json");
@@ -379,8 +448,8 @@ export async function handleStudioApi(req, url, root) {
379
448
  const name = body.name;
380
449
  if (!name || typeof name !== "string")
381
450
  return Response.json({ error: "Missing name" }, { status: 400 });
382
- const dir = body.dir;
383
- const cwd = dir ? resolve(root, dir) : root;
451
+ const dir = body.dir || activeProjectRoot;
452
+ const cwd = dir ? (isAbsolute(dir) ? dir : resolve(root, dir)) : root;
384
453
  const args = ["add", name];
385
454
  if (body.dev) args.splice(1, 0, "-d");
386
455
  const proc = Bun.spawn(["bun", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
@@ -405,8 +474,8 @@ export async function handleStudioApi(req, url, root) {
405
474
  const name = body.name;
406
475
  if (!name || typeof name !== "string")
407
476
  return Response.json({ error: "Missing name" }, { status: 400 });
408
- const dir = body.dir;
409
- const cwd = dir ? resolve(root, dir) : root;
477
+ const dir = body.dir || activeProjectRoot;
478
+ const cwd = dir ? (isAbsolute(dir) ? dir : resolve(root, dir)) : root;
410
479
  const proc = Bun.spawn(["bun", "remove", name], { cwd, stdout: "pipe", stderr: "pipe" });
411
480
  const exitCode = await proc.exited;
412
481
  if (exitCode !== 0) {
@@ -422,27 +491,20 @@ export async function handleStudioApi(req, url, root) {
422
491
  }
423
492
  }
424
493
 
425
- // Read file (supports absolute system paths for ?open= workflow)
494
+ // Read file
426
495
  if (path === "/__studio/file" && req.method === "GET") {
427
496
  const fp = url.searchParams.get("path");
428
497
  if (!fp) return new Response("Missing path", { status: 400 });
429
- const isAbsolute = fp.startsWith("/") || fp.startsWith("~");
430
- const abs = isAbsolute
431
- ? fp.startsWith("~")
432
- ? fp.replace("~", process.env.HOME || "")
433
- : fp
434
- : resolve(root, fp);
435
- if (!isAbsolute) {
436
- try {
437
- assertUnderRoot(abs, root);
438
- } catch (/** @type {any} */ e) {
439
- return new Response(e.message, { status: 400 });
440
- }
498
+ const abs = fp.startsWith("~") ? fp.replace("~", process.env.HOME || "") : fp;
499
+ try {
500
+ assertAccessible(abs, root, activeProjectRoot);
501
+ } catch (/** @type {any} */ e) {
502
+ return new Response(e.message, { status: 400 });
441
503
  }
442
504
  try {
443
505
  return Response.json({
444
506
  content: await readFile(abs, "utf8"),
445
- path: isAbsolute ? fp : relative(root, abs),
507
+ path: fp,
446
508
  });
447
509
  } catch (/** @type {any} */ e) {
448
510
  return e.code === "ENOENT"
@@ -457,14 +519,34 @@ export async function handleStudioApi(req, url, root) {
457
519
  if (!fp) return new Response("Missing path", { status: 400 });
458
520
  const abs = resolve(root, fp);
459
521
  try {
460
- assertUnderRoot(abs, root);
522
+ assertAccessible(abs, root, activeProjectRoot);
461
523
  } catch (/** @type {any} */ e) {
462
524
  return new Response(e.message, { status: 400 });
463
525
  }
464
526
  try {
465
527
  await mkdir(dirname(abs), { recursive: true });
466
528
  await writeFile(abs, await req.text(), "utf8");
467
- return Response.json({ ok: true, path: relative(root, abs) });
529
+ return Response.json({ ok: true, path: fwd(relative(root, abs)) });
530
+ } catch (/** @type {any} */ e) {
531
+ return Response.json({ error: e.message }, { status: 500 });
532
+ }
533
+ }
534
+
535
+ // Upload binary file
536
+ if (path === "/__studio/file/upload" && req.method === "POST") {
537
+ const fp = url.searchParams.get("path");
538
+ if (!fp) return new Response("Missing path", { status: 400 });
539
+ const abs = resolve(root, fp);
540
+ try {
541
+ assertAccessible(abs, root, activeProjectRoot);
542
+ } catch (/** @type {any} */ e) {
543
+ return new Response(e.message, { status: 400 });
544
+ }
545
+ try {
546
+ await mkdir(dirname(abs), { recursive: true });
547
+ const buffer = await req.arrayBuffer();
548
+ await Bun.write(abs, new Uint8Array(buffer));
549
+ return Response.json({ ok: true, path: fwd(relative(root, abs)) });
468
550
  } catch (/** @type {any} */ e) {
469
551
  return Response.json({ error: e.message }, { status: 500 });
470
552
  }
@@ -476,13 +558,13 @@ export async function handleStudioApi(req, url, root) {
476
558
  if (!fp) return new Response("Missing path", { status: 400 });
477
559
  const abs = resolve(root, fp);
478
560
  try {
479
- assertUnderRoot(abs, root);
561
+ assertAccessible(abs, root, activeProjectRoot);
480
562
  } catch (/** @type {any} */ e) {
481
563
  return new Response(e.message, { status: 400 });
482
564
  }
483
565
  try {
484
566
  await unlink(abs);
485
- return Response.json({ ok: true, path: relative(root, abs) });
567
+ return Response.json({ ok: true, path: fwd(relative(root, abs)) });
486
568
  } catch (/** @type {any} */ e) {
487
569
  return e.code === "ENOENT"
488
570
  ? new Response("Not found", { status: 404 })
@@ -503,15 +585,19 @@ export async function handleStudioApi(req, url, root) {
503
585
  const absFrom = resolve(root, from);
504
586
  const absTo = resolve(root, to);
505
587
  try {
506
- assertUnderRoot(absFrom, root);
507
- assertUnderRoot(absTo, root);
588
+ assertAccessible(absFrom, root, activeProjectRoot);
589
+ assertAccessible(absTo, root, activeProjectRoot);
508
590
  } catch (/** @type {any} */ e) {
509
591
  return new Response(e.message, { status: 400 });
510
592
  }
511
593
  try {
512
594
  await mkdir(dirname(absTo), { recursive: true });
513
595
  await rename(absFrom, absTo);
514
- return Response.json({ ok: true, from: relative(root, absFrom), to: relative(root, absTo) });
596
+ return Response.json({
597
+ ok: true,
598
+ from: fwd(relative(root, absFrom)),
599
+ to: fwd(relative(root, absTo)),
600
+ });
515
601
  } catch (/** @type {any} */ e) {
516
602
  return Response.json({ error: e.message }, { status: 500 });
517
603
  }
@@ -533,8 +619,8 @@ export async function handleStudioApi(req, url, root) {
533
619
  const matches = [];
534
620
  for await (const match of glob.scan({ cwd: root, dot: false })) {
535
621
  // Skip node_modules / dist / hidden dirs
536
- if (match.includes("node_modules") || match.includes("dist/")) continue;
537
- matches.push(match.split("\\").join("/"));
622
+ if (match.includes("node_modules") || fwd(match).includes("dist/")) continue;
623
+ matches.push(fwd(match));
538
624
  }
539
625
  if (matches.length === 0) return Response.json({ path: null });
540
626
  return Response.json({
package/src/watch.js CHANGED
@@ -53,12 +53,13 @@ export function injectSSE(html) {
53
53
  *
54
54
  * @param {string} root - Absolute path to watch
55
55
  * @param {any[]} builds - Build entries (for selective rebuild)
56
- * @param {{ ignore?: string[]; debounce?: number }} [opts]
56
+ * @param {{ ignore?: string[]; debounce?: number; reloadOnAnyChange?: boolean }} [opts]
57
57
  * @returns {{ broadcast: () => void; handleSSE: () => Response }}
58
58
  */
59
59
  export function createWatcher(root, builds, opts = {}) {
60
60
  const ignore = opts.ignore ?? DEFAULT_IGNORE;
61
61
  const debounceMs = opts.debounce ?? 50;
62
+ const reloadOnAnyChange = opts.reloadOnAnyChange ?? false;
62
63
 
63
64
  /** @type {Set<(msg: string) => void>} */
64
65
  const clients = new Set();
@@ -126,7 +127,7 @@ export function createWatcher(root, builds, opts = {}) {
126
127
  }
127
128
  }
128
129
  console.log(`Changed → ${filename}`);
129
- broadcast();
130
+ if (reloadOnAnyChange) broadcast();
130
131
  }, debounceMs);
131
132
  });
132
133