@jxsuite/server 0.1.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -1
- package/src/server.js +43 -6
- package/src/studio-api.js +154 -68
- package/src/watch.js +3 -2
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.5.1",
|
|
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 "@
|
|
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
|
-
*
|
|
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/@
|
|
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("@
|
|
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@
|
|
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
|
|
25
|
+
function assertAccessible(filePath, root, activeProjectRoot) {
|
|
19
26
|
const rel = relative(root, filePath);
|
|
20
|
-
if (rel.startsWith("..")
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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)
|
|
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)
|
|
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
|
|
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
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
507
|
-
|
|
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({
|
|
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
|
|
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
|
|