@jxsuite/server 0.0.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 +19 -0
- package/src/build.js +67 -0
- package/src/code-api.js +155 -0
- package/src/resolve.js +206 -0
- package/src/server.js +253 -0
- package/src/studio-api.js +689 -0
- package/src/watch.js +134 -0
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jxsuite/server",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
"src/"
|
|
8
|
+
],
|
|
9
|
+
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/server.js"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"chokidar": "^5.0.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/build.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Build.js — Configurable Bun.build pipeline */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build all entries with sensible defaults (browser target, ESM, linked sourcemaps).
|
|
5
|
+
*
|
|
6
|
+
* @param {{
|
|
7
|
+
* entrypoints: string[];
|
|
8
|
+
* outdir: string;
|
|
9
|
+
* match?: Function | RegExp;
|
|
10
|
+
* label?: string;
|
|
11
|
+
* }[]} builds
|
|
12
|
+
*/
|
|
13
|
+
export async function buildAll(builds) {
|
|
14
|
+
for (const entry of builds) {
|
|
15
|
+
const { match: _match, label, ...opts } = entry;
|
|
16
|
+
const result = await Bun.build({
|
|
17
|
+
target: "browser",
|
|
18
|
+
format: "esm",
|
|
19
|
+
sourcemap: "linked",
|
|
20
|
+
...opts,
|
|
21
|
+
});
|
|
22
|
+
if (!result.success) result.logs.forEach((l) => console.error(l));
|
|
23
|
+
else console.log(`Built → ${entry.outdir}/${label ?? "bundle"}.js`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rebuild entries whose match function/regex matches the changed filename.
|
|
29
|
+
*
|
|
30
|
+
* @param {{
|
|
31
|
+
* entrypoints: string[];
|
|
32
|
+
* outdir: string;
|
|
33
|
+
* match?: Function | RegExp;
|
|
34
|
+
* label?: string;
|
|
35
|
+
* }[]} builds
|
|
36
|
+
* @param {string} changedFile
|
|
37
|
+
* @returns {Promise<{ rebuilt: string[]; success: boolean }>}
|
|
38
|
+
*/
|
|
39
|
+
export async function rebuild(builds, changedFile) {
|
|
40
|
+
const rebuilt = [];
|
|
41
|
+
let ok = true;
|
|
42
|
+
for (const entry of builds) {
|
|
43
|
+
if (!entry.match) continue;
|
|
44
|
+
const matches =
|
|
45
|
+
typeof entry.match === "function"
|
|
46
|
+
? entry.match(changedFile)
|
|
47
|
+
: entry.match instanceof RegExp
|
|
48
|
+
? entry.match.test(changedFile)
|
|
49
|
+
: false;
|
|
50
|
+
if (!matches) continue;
|
|
51
|
+
const { match: _match, label, ...opts } = entry;
|
|
52
|
+
const result = await Bun.build({
|
|
53
|
+
target: "browser",
|
|
54
|
+
format: "esm",
|
|
55
|
+
sourcemap: "linked",
|
|
56
|
+
...opts,
|
|
57
|
+
});
|
|
58
|
+
if (result.success) {
|
|
59
|
+
rebuilt.push(label ?? entry.outdir);
|
|
60
|
+
console.log(`Rebuilt → ${entry.outdir}/${label ?? "bundle"}.js (${changedFile} changed)`);
|
|
61
|
+
} else {
|
|
62
|
+
result.logs.forEach((l) => console.error(l));
|
|
63
|
+
ok = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { rebuilt, success: ok };
|
|
67
|
+
}
|
package/src/code-api.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-api.js — OXC-powered code services for the studio function editor
|
|
3
|
+
*
|
|
4
|
+
* Endpoints under /__studio/code/* that provide formatting (oxfmt), minification (Bun.Transpiler),
|
|
5
|
+
* and linting (oxlint CLI) for JS snippets.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
import { unlink } from "node:fs/promises";
|
|
11
|
+
import { format } from "oxfmt";
|
|
12
|
+
|
|
13
|
+
const OXLINT_BIN = resolve(
|
|
14
|
+
import.meta.dir,
|
|
15
|
+
"../../node_modules/.bin",
|
|
16
|
+
process.platform === "win32" ? "oxlint.exe" : "oxlint",
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// ─── Wrapper utilities ───────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} body
|
|
23
|
+
* @param {string[]} [args]
|
|
24
|
+
*/
|
|
25
|
+
function wrapBody(body, args = ["state", "event"]) {
|
|
26
|
+
const params = args.join(", ");
|
|
27
|
+
return `function __jx_fn__(${params}) {\n${body}\n}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {string} formatted */
|
|
31
|
+
function unwrapFormatted(formatted) {
|
|
32
|
+
const lines = formatted.split("\n");
|
|
33
|
+
// Remove first line (function header) and last non-empty line (closing brace)
|
|
34
|
+
let end = lines.length - 1;
|
|
35
|
+
while (end > 0 && lines[end].trim() === "") end--;
|
|
36
|
+
if (lines[end].trim() === "}") end--;
|
|
37
|
+
const bodyLines = lines.slice(1, end + 1);
|
|
38
|
+
// Dedent by one tab (oxfmt uses the project's indentStyle)
|
|
39
|
+
return bodyLines.map((l) => (l.startsWith("\t") ? l.slice(1) : l)).join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {any[]} diagnostics
|
|
44
|
+
* @param {number} headerLen
|
|
45
|
+
*/
|
|
46
|
+
function adjustDiagnostics(diagnostics, headerLen) {
|
|
47
|
+
return diagnostics
|
|
48
|
+
.filter((d) => {
|
|
49
|
+
const line = d.labels?.[0]?.span?.line;
|
|
50
|
+
return line == null || line > 1;
|
|
51
|
+
})
|
|
52
|
+
.map((d) => ({
|
|
53
|
+
...d,
|
|
54
|
+
labels: d.labels.map((/** @type {any} */ label) => ({
|
|
55
|
+
...label,
|
|
56
|
+
span: {
|
|
57
|
+
...label.span,
|
|
58
|
+
offset: label.span.offset - headerLen,
|
|
59
|
+
line: label.span.line - 1,
|
|
60
|
+
},
|
|
61
|
+
})),
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Reusable transpiler ─────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const minifier = new Bun.Transpiler({ minifyWhitespace: true });
|
|
68
|
+
|
|
69
|
+
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {Request} req
|
|
73
|
+
* @param {URL} url
|
|
74
|
+
*/
|
|
75
|
+
export async function handleCodeApi(req, url) {
|
|
76
|
+
const path = url.pathname;
|
|
77
|
+
if (!path.startsWith("/__studio/code/") || req.method !== "POST") return null;
|
|
78
|
+
|
|
79
|
+
let body;
|
|
80
|
+
try {
|
|
81
|
+
body = await req.json();
|
|
82
|
+
} catch {
|
|
83
|
+
return new Response("Invalid JSON", { status: 400 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const action = path.slice("/__studio/code/".length);
|
|
87
|
+
|
|
88
|
+
// ── Format ─────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
if (action === "format") {
|
|
91
|
+
const { code, args } = body;
|
|
92
|
+
if (!code?.trim()) return Response.json({ code: "", errors: [] });
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const wrapped = wrapBody(code, args);
|
|
96
|
+
const result = await format("fn.js", wrapped, { useTabs: true });
|
|
97
|
+
return Response.json({
|
|
98
|
+
code: unwrapFormatted(result.code),
|
|
99
|
+
errors: result.errors,
|
|
100
|
+
});
|
|
101
|
+
} catch (/** @type {any} */ e) {
|
|
102
|
+
return Response.json({ code, errors: [{ message: e.message }] });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Minify ─────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
if (action === "minify") {
|
|
109
|
+
const { code } = body;
|
|
110
|
+
if (!code?.trim()) return Response.json({ code: "" });
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const minified = minifier.transformSync(code).trim();
|
|
114
|
+
return Response.json({ code: minified });
|
|
115
|
+
} catch (/** @type {any} */ e) {
|
|
116
|
+
return Response.json({ code, error: e.message });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Lint ───────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
if (action === "lint") {
|
|
123
|
+
const { code, args } = body;
|
|
124
|
+
if (!code?.trim()) return Response.json({ diagnostics: [] });
|
|
125
|
+
|
|
126
|
+
const wrapped = wrapBody(code, args);
|
|
127
|
+
const headerLen = wrapped.indexOf("\n") + 1;
|
|
128
|
+
const tmpFile = join(
|
|
129
|
+
tmpdir(),
|
|
130
|
+
`__jx_lint_${Date.now()}_${Math.random().toString(36).slice(2)}.js`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await Bun.write(tmpFile, wrapped);
|
|
135
|
+
const proc = Bun.spawn([OXLINT_BIN, "--format=json", "-A", "no-unused-vars", tmpFile], {
|
|
136
|
+
stdout: "pipe",
|
|
137
|
+
stderr: "pipe",
|
|
138
|
+
});
|
|
139
|
+
const output = await new Response(proc.stdout).text();
|
|
140
|
+
await proc.exited;
|
|
141
|
+
|
|
142
|
+
const parsed = JSON.parse(output);
|
|
143
|
+
const adjusted = adjustDiagnostics(parsed.diagnostics || [], headerLen);
|
|
144
|
+
return Response.json({ diagnostics: adjusted });
|
|
145
|
+
} catch (/** @type {any} */ e) {
|
|
146
|
+
return Response.json({ diagnostics: [], error: e.message });
|
|
147
|
+
} finally {
|
|
148
|
+
try {
|
|
149
|
+
await unlink(tmpFile);
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|
package/src/resolve.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/** Resolve.js — Generic $src module proxy + timing: "server" function proxy */
|
|
2
|
+
|
|
3
|
+
import { resolve, relative, dirname } from "node:path";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Handle POST /**jx_resolve** — proxy $prototype + $src entries.
|
|
8
|
+
*
|
|
9
|
+
* @param {Request} req
|
|
10
|
+
* @param {string} root
|
|
11
|
+
*/
|
|
12
|
+
export async function handleResolve(req, root) {
|
|
13
|
+
let body;
|
|
14
|
+
try {
|
|
15
|
+
body = await req.json();
|
|
16
|
+
} catch {
|
|
17
|
+
return new Response("Invalid JSON body", { status: 400 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { $src, $prototype, $export: xport, $base, ...config } = body;
|
|
21
|
+
if (!$src) return new Response("Missing $src", { status: 400 });
|
|
22
|
+
|
|
23
|
+
let moduleAbsPath;
|
|
24
|
+
try {
|
|
25
|
+
if ($base) {
|
|
26
|
+
const docUrlPath = new URL($base).pathname;
|
|
27
|
+
const docDir = docUrlPath.slice(0, docUrlPath.lastIndexOf("/") + 1);
|
|
28
|
+
moduleAbsPath = resolve(resolve(root, "." + docDir), $src);
|
|
29
|
+
} else {
|
|
30
|
+
moduleAbsPath = resolve(root, $src);
|
|
31
|
+
}
|
|
32
|
+
} catch (/** @type {any} */ e) {
|
|
33
|
+
return new Response(`Cannot resolve $src "${$src}": ${e.message}`, { status: 400 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Rebase relative config paths from doc-relative to CWD-relative
|
|
37
|
+
if ($base) {
|
|
38
|
+
const docUrlPath = new URL($base).pathname;
|
|
39
|
+
const docDir = docUrlPath.slice(0, docUrlPath.lastIndexOf("/") + 1);
|
|
40
|
+
const docAbsDir = resolve(root, "." + docDir);
|
|
41
|
+
for (const [k, v] of Object.entries(config)) {
|
|
42
|
+
if (typeof v === "string" && (v.startsWith("./") || v.startsWith("../"))) {
|
|
43
|
+
config[k] = "./" + relative(process.cwd(), resolve(docAbsDir, v));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// .class.json: read schema, follow $implementation to the real JS module
|
|
49
|
+
if (moduleAbsPath.endsWith(".class.json")) {
|
|
50
|
+
try {
|
|
51
|
+
const content = readFileSync(moduleAbsPath, "utf8");
|
|
52
|
+
const classDef = JSON.parse(content);
|
|
53
|
+
|
|
54
|
+
if (classDef.$implementation) {
|
|
55
|
+
// Hybrid mode: redirect to the JS implementation
|
|
56
|
+
const implPath = resolve(dirname(moduleAbsPath), classDef.$implementation);
|
|
57
|
+
const exportName = xport ?? classDef.title ?? $prototype;
|
|
58
|
+
const mod = await import(implPath);
|
|
59
|
+
const ExportedClass = mod[exportName] ?? mod.default?.[exportName];
|
|
60
|
+
if (typeof ExportedClass !== "function") {
|
|
61
|
+
return new Response(`Export "${exportName}" not found in "${classDef.$implementation}"`, {
|
|
62
|
+
status: 500,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const instance = new ExportedClass(config);
|
|
66
|
+
const value =
|
|
67
|
+
typeof instance.resolve === "function"
|
|
68
|
+
? await instance.resolve()
|
|
69
|
+
: "value" in instance
|
|
70
|
+
? instance.value
|
|
71
|
+
: instance;
|
|
72
|
+
return Response.json(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Self-contained: construct class from schema
|
|
76
|
+
const DynClass = classFromSchema(classDef);
|
|
77
|
+
const instance = /** @type {any} */ (new DynClass(config));
|
|
78
|
+
const value =
|
|
79
|
+
typeof instance.resolve === "function"
|
|
80
|
+
? await instance.resolve()
|
|
81
|
+
: "value" in instance
|
|
82
|
+
? instance.value
|
|
83
|
+
: instance;
|
|
84
|
+
return Response.json(value);
|
|
85
|
+
} catch (/** @type {any} */ e) {
|
|
86
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Non-Function $prototype must use .class.json as entrypoint
|
|
91
|
+
return new Response(
|
|
92
|
+
`Non-Function $prototype "${$prototype}" requires a .class.json $src, got "${$src}". ` +
|
|
93
|
+
`Wrap the class in a .class.json schema with $implementation.`,
|
|
94
|
+
{ status: 400 },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Handle POST /**jx_server** — proxy timing: "server" function calls. In dev mode, the runtime
|
|
100
|
+
* sends these instead of hitting the production Hono handler.
|
|
101
|
+
*
|
|
102
|
+
* @param {Request} req
|
|
103
|
+
* @param {string} root
|
|
104
|
+
*/
|
|
105
|
+
export async function handleServerFunction(req, root) {
|
|
106
|
+
let body;
|
|
107
|
+
try {
|
|
108
|
+
body = await req.json();
|
|
109
|
+
} catch {
|
|
110
|
+
return new Response("Invalid JSON body", { status: 400 });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const { $src, $export: xport, $base, arguments: args = {} } = body;
|
|
114
|
+
if (!$src || !xport) return new Response("Missing $src or $export", { status: 400 });
|
|
115
|
+
|
|
116
|
+
let moduleAbsPath;
|
|
117
|
+
try {
|
|
118
|
+
if ($base) {
|
|
119
|
+
const docUrlPath = new URL($base).pathname;
|
|
120
|
+
const docDir = docUrlPath.slice(0, docUrlPath.lastIndexOf("/") + 1);
|
|
121
|
+
moduleAbsPath = resolve(resolve(root, "." + docDir), $src);
|
|
122
|
+
} else {
|
|
123
|
+
moduleAbsPath = resolve(root, $src);
|
|
124
|
+
}
|
|
125
|
+
} catch (/** @type {any} */ e) {
|
|
126
|
+
return new Response(`Cannot resolve $src: ${e.message}`, { status: 400 });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let mod;
|
|
130
|
+
try {
|
|
131
|
+
mod = await import(moduleAbsPath);
|
|
132
|
+
} catch (/** @type {any} */ e) {
|
|
133
|
+
return new Response(`Failed to import "${$src}": ${e.message}`, { status: 500 });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const fn = mod[xport] ?? mod.default?.[xport];
|
|
137
|
+
if (typeof fn !== "function") {
|
|
138
|
+
return new Response(`Export "${xport}" not found in "${$src}"`, { status: 500 });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const result = await fn(args);
|
|
143
|
+
return Response.json(result ?? null);
|
|
144
|
+
} catch (/** @type {any} */ e) {
|
|
145
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Dynamically construct a class from a .class.json schema definition. Server-side variant — no
|
|
151
|
+
* private field limitations.
|
|
152
|
+
*
|
|
153
|
+
* @param {any} classDef
|
|
154
|
+
*/
|
|
155
|
+
function classFromSchema(classDef) {
|
|
156
|
+
const fields = classDef.$defs?.fields ?? {};
|
|
157
|
+
const ctor = classDef.$defs?.constructor;
|
|
158
|
+
const methods = classDef.$defs?.methods ?? {};
|
|
159
|
+
|
|
160
|
+
class DynClass {
|
|
161
|
+
constructor(config = {}) {
|
|
162
|
+
const self = /** @type {any} */ (this);
|
|
163
|
+
const cfg = /** @type {Record<string, any>} */ (config);
|
|
164
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
165
|
+
const id = field.identifier ?? key;
|
|
166
|
+
if (cfg[id] !== undefined) self[id] = cfg[id];
|
|
167
|
+
else if (field.initializer !== undefined) self[id] = field.initializer;
|
|
168
|
+
else if (field.default !== undefined) self[id] = structuredClone(field.default);
|
|
169
|
+
else self[id] = null;
|
|
170
|
+
}
|
|
171
|
+
if (ctor?.body) {
|
|
172
|
+
const bodyStr = Array.isArray(ctor.body) ? ctor.body.join("\n") : ctor.body;
|
|
173
|
+
new Function("config", bodyStr).call(this, config);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const [key, method] of Object.entries(methods)) {
|
|
179
|
+
const name = method.identifier ?? key;
|
|
180
|
+
const params = (method.parameters ?? []).map((/** @type {any} */ p) => {
|
|
181
|
+
if (p.$ref) return p.$ref.split("/").pop();
|
|
182
|
+
return p.identifier ?? p.name ?? "arg";
|
|
183
|
+
});
|
|
184
|
+
const bodyStr = Array.isArray(method.body) ? method.body.join("\n") : (method.body ?? "");
|
|
185
|
+
|
|
186
|
+
if (method.role === "accessor") {
|
|
187
|
+
/** @type {any} */
|
|
188
|
+
const descriptor = {};
|
|
189
|
+
if (method.getter) descriptor.get = new Function(method.getter.body);
|
|
190
|
+
if (method.setter) {
|
|
191
|
+
const sp = (method.setter.parameters ?? []).map(
|
|
192
|
+
(/** @type {any} */ p) => p.$ref?.split("/").pop() ?? "v",
|
|
193
|
+
);
|
|
194
|
+
descriptor.set = new Function(...sp, method.setter.body);
|
|
195
|
+
}
|
|
196
|
+
Object.defineProperty(DynClass.prototype, name, { ...descriptor, configurable: true });
|
|
197
|
+
} else if (method.scope === "static") {
|
|
198
|
+
/** @type {any} */ (DynClass)[name] = new Function(...params, bodyStr);
|
|
199
|
+
} else {
|
|
200
|
+
/** @type {any} */ (DynClass.prototype)[name] = new Function(...params, bodyStr);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
Object.defineProperty(DynClass, "name", { value: classDef.title, configurable: true });
|
|
205
|
+
return DynClass;
|
|
206
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @example
|
|
3
|
+
* import { createDevServer } from "@jxplatform/server";
|
|
4
|
+
*
|
|
5
|
+
* await createDevServer({
|
|
6
|
+
* root: import.meta.dir,
|
|
7
|
+
* builds: [{ entrypoints: ["./src/app.js"], outdir: "./dist", match: /src/, label: "app" }],
|
|
8
|
+
* });
|
|
9
|
+
*
|
|
10
|
+
* jxplatform/server — Jx development server
|
|
11
|
+
*
|
|
12
|
+
* Provides builds, live reload, $src module proxying, timing: "server" function
|
|
13
|
+
* proxying, and studio filesystem integration as a single createDevServer() call.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { resolve, join } from "node:path";
|
|
17
|
+
import { buildAll } from "./build.js";
|
|
18
|
+
import { createWatcher, injectSSE } from "./watch.js";
|
|
19
|
+
import { handleResolve, handleServerFunction } from "./resolve.js";
|
|
20
|
+
import { handleStudioApi } from "./studio-api.js";
|
|
21
|
+
import { handleCodeApi } from "./code-api.js";
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve an npm-style bare specifier from a URL path via node_modules. Handles scoped packages
|
|
26
|
+
* (@scope/pkg/subpath) and respects package.json exports. Strips leading directory segments (e.g.
|
|
27
|
+
* /pages/@scope/pkg/file → @scope/pkg/file).
|
|
28
|
+
*
|
|
29
|
+
* @param {string} root - Absolute project root
|
|
30
|
+
* @param {string} urlPath - URL pathname (e.g. "/pages/@jxplatform/parser/Foo.class.json")
|
|
31
|
+
* @returns {string | null} Absolute file path or null
|
|
32
|
+
*/
|
|
33
|
+
function resolveNpmPath(root, urlPath) {
|
|
34
|
+
let segments = urlPath.split("/").filter(Boolean);
|
|
35
|
+
|
|
36
|
+
// If "node_modules" appears in the path, use everything before it as a subdirectory
|
|
37
|
+
// prefix and everything after as the package specifier.
|
|
38
|
+
// e.g. /examples/demo/node_modules/@scope/pkg → root=root/examples/demo, pkg=@scope/pkg
|
|
39
|
+
const nmIdx = segments.indexOf("node_modules");
|
|
40
|
+
if (nmIdx >= 0) {
|
|
41
|
+
if (nmIdx > 0) root = join(root, ...segments.slice(0, nmIdx));
|
|
42
|
+
segments = segments.slice(nmIdx + 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Find the package start — either @scope/pkg or unscoped pkg
|
|
46
|
+
let start = -1;
|
|
47
|
+
let isScoped = false;
|
|
48
|
+
for (let i = 0; i < segments.length; i++) {
|
|
49
|
+
if (segments[i].startsWith("@")) {
|
|
50
|
+
start = i;
|
|
51
|
+
isScoped = true;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @type {string} */
|
|
57
|
+
let pkgDir = "";
|
|
58
|
+
/** @type {string} */
|
|
59
|
+
let subpath = "";
|
|
60
|
+
|
|
61
|
+
if (isScoped) {
|
|
62
|
+
if (start < 0 || start + 1 >= segments.length) return null;
|
|
63
|
+
const scope = segments[start];
|
|
64
|
+
const pkg = segments[start + 1];
|
|
65
|
+
subpath = segments.slice(start + 2).join("/");
|
|
66
|
+
pkgDir = join(root, "node_modules", scope, pkg);
|
|
67
|
+
} else {
|
|
68
|
+
// Unscoped: try each segment as a package name in node_modules
|
|
69
|
+
for (let i = 0; i < segments.length; i++) {
|
|
70
|
+
const candidate = join(root, "node_modules", segments[i]);
|
|
71
|
+
if (existsSync(join(candidate, "package.json"))) {
|
|
72
|
+
start = i;
|
|
73
|
+
pkgDir = candidate;
|
|
74
|
+
subpath = segments.slice(i + 1).join("/");
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (start < 0) return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const pkgJsonPath = join(pkgDir, "package.json");
|
|
82
|
+
if (!existsSync(pkgJsonPath)) return null;
|
|
83
|
+
|
|
84
|
+
// If there's a subpath, check package.json exports first
|
|
85
|
+
if (subpath) {
|
|
86
|
+
try {
|
|
87
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
88
|
+
const exportKey = `./${subpath}`;
|
|
89
|
+
if (pkgJson.exports && pkgJson.exports[exportKey]) {
|
|
90
|
+
const mapped = join(pkgDir, pkgJson.exports[exportKey]);
|
|
91
|
+
if (existsSync(mapped)) return mapped;
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
94
|
+
// Fall back to direct path
|
|
95
|
+
const direct = join(pkgDir, subpath);
|
|
96
|
+
if (existsSync(direct)) return direct;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Bare package (no subpath): resolve entry point
|
|
100
|
+
try {
|
|
101
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
102
|
+
const exp = pkgJson.exports?.["."];
|
|
103
|
+
const entry =
|
|
104
|
+
(typeof exp === "object" ? (exp.import ?? exp.default) : exp) ??
|
|
105
|
+
pkgJson.module ??
|
|
106
|
+
pkgJson.main;
|
|
107
|
+
if (entry && typeof entry === "string") {
|
|
108
|
+
const resolved = join(pkgDir, entry);
|
|
109
|
+
if (existsSync(resolved)) return resolved;
|
|
110
|
+
}
|
|
111
|
+
} catch {}
|
|
112
|
+
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create and start a Jx development server.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} options
|
|
120
|
+
* @param {string} options.root - Project root (absolute or relative)
|
|
121
|
+
* @param {number} [options.port] - Server port. Default is `3000`
|
|
122
|
+
* @param {{
|
|
123
|
+
* entrypoints: string[];
|
|
124
|
+
* outdir: string;
|
|
125
|
+
* match?: Function | RegExp;
|
|
126
|
+
* label?: string;
|
|
127
|
+
* }[]} [options.builds]
|
|
128
|
+
* - Bun.build entries with optional match regex
|
|
129
|
+
* @param {boolean | object} [options.watch] - Watch config or false to disable. Default is `true`
|
|
130
|
+
* @param {boolean} [options.studio] - Enable /**studio/* endpoints. Default is `true`
|
|
131
|
+
* @param {Function} [options.middleware] - Custom route handler (req, url) => Response|null
|
|
132
|
+
* @returns {Promise<object>} The Bun.serve server object
|
|
133
|
+
*/
|
|
134
|
+
export async function createDevServer(options) {
|
|
135
|
+
const {
|
|
136
|
+
root,
|
|
137
|
+
port = 3000,
|
|
138
|
+
builds = [],
|
|
139
|
+
watch = true,
|
|
140
|
+
studio: enableStudio = true,
|
|
141
|
+
middleware,
|
|
142
|
+
} = options;
|
|
143
|
+
|
|
144
|
+
if (!root) throw new Error("@jxplatform/server: root is required");
|
|
145
|
+
const absRoot = resolve(root);
|
|
146
|
+
|
|
147
|
+
// ─── Build pipeline ─────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
if (builds.length > 0) {
|
|
150
|
+
await buildAll(builds);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── File watcher + SSE ─────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
let handleSSE = null;
|
|
156
|
+
if (watch !== false) {
|
|
157
|
+
const watchOpts = typeof watch === "object" ? watch : {};
|
|
158
|
+
const watcher = createWatcher(absRoot, builds, watchOpts);
|
|
159
|
+
handleSSE = watcher.handleSSE;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Bundle cache for npm packages (bare specifier → bundled JS)
|
|
163
|
+
/** @type {Map<string, string>} */
|
|
164
|
+
const bundleCache = new Map();
|
|
165
|
+
|
|
166
|
+
// ─── HTTP server ────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
const server = Bun.serve({
|
|
169
|
+
port,
|
|
170
|
+
|
|
171
|
+
async fetch(req) {
|
|
172
|
+
const url = new URL(req.url);
|
|
173
|
+
let path = url.pathname;
|
|
174
|
+
if (path.endsWith("/")) path += "index.html";
|
|
175
|
+
else if (path === "") path = "/index.html";
|
|
176
|
+
|
|
177
|
+
// SSE live reload
|
|
178
|
+
if (handleSSE && path === "/__reload") {
|
|
179
|
+
return handleSSE();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// $prototype + $src proxy
|
|
183
|
+
if (path === "/__jx_resolve__" && req.method === "POST") {
|
|
184
|
+
return handleResolve(req, absRoot);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// timing: "server" function proxy
|
|
188
|
+
if (path === "/__jx_server__" && req.method === "POST") {
|
|
189
|
+
return handleServerFunction(req, absRoot);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Studio filesystem API
|
|
193
|
+
if (enableStudio && path.startsWith("/__studio/")) {
|
|
194
|
+
const codeRes = await handleCodeApi(req, url);
|
|
195
|
+
if (codeRes) return codeRes;
|
|
196
|
+
|
|
197
|
+
const res = await handleStudioApi(req, url, absRoot);
|
|
198
|
+
if (res) return res;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Custom middleware
|
|
202
|
+
if (middleware) {
|
|
203
|
+
const res = await middleware(req, url);
|
|
204
|
+
if (res) return res;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Static files
|
|
208
|
+
const file = Bun.file(resolve(absRoot, "." + path));
|
|
209
|
+
if (!(await file.exists())) {
|
|
210
|
+
// Resolve npm-style bare specifiers via node_modules.
|
|
211
|
+
// Bundle on-demand so internal bare specifiers (e.g. lit/...) resolve.
|
|
212
|
+
const resolved = resolveNpmPath(absRoot, path);
|
|
213
|
+
if (resolved) {
|
|
214
|
+
const cacheKey = resolved;
|
|
215
|
+
if (!bundleCache.has(cacheKey)) {
|
|
216
|
+
try {
|
|
217
|
+
const result = await Bun.build({
|
|
218
|
+
entrypoints: [resolved],
|
|
219
|
+
format: "esm",
|
|
220
|
+
minify: false,
|
|
221
|
+
});
|
|
222
|
+
if (result.success && result.outputs.length > 0) {
|
|
223
|
+
bundleCache.set(cacheKey, await result.outputs[0].text());
|
|
224
|
+
}
|
|
225
|
+
} catch (/** @type {any} */ e) {
|
|
226
|
+
console.error("Bundle failed for", resolved, e);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const bundled = bundleCache.get(cacheKey);
|
|
230
|
+
if (bundled) {
|
|
231
|
+
return new Response(bundled, {
|
|
232
|
+
headers: { "Content-Type": "application/javascript; charset=utf-8" },
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return new Response("Not found", { status: 404 });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (handleSSE && path.endsWith(".html")) {
|
|
240
|
+
const html = await file.text();
|
|
241
|
+
return new Response(injectSSE(html), {
|
|
242
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return new Response(file);
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
console.log(`\n@jxplatform/server listening on http://localhost:${server.port}`);
|
|
251
|
+
|
|
252
|
+
return server;
|
|
253
|
+
}
|