@jxsuite/server 0.37.1 → 1.1.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 +20 -10
- package/src/collab.ts +29 -33
- package/src/data-api.ts +874 -0
- package/src/dev-vars.ts +61 -0
- package/src/dev.ts +141 -0
- package/src/jx-mounts.ts +166 -0
- package/src/project-server.ts +10 -0
- package/src/resolve.ts +20 -12
- package/src/server.ts +10 -0
- package/src/studio-api.ts +53 -13
- package/src/watch.ts +17 -1
package/src/dev-vars.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-vars — `<project>/.dev.vars` parsing (wrangler convention, specs/extensions.md §13).
|
|
3
|
+
*
|
|
4
|
+
* Secret VALUES never enter project.json; locally they live in the git-ignored .dev.vars file and
|
|
5
|
+
* the dev server merges them over process.env when constructing mount environments. The format is
|
|
6
|
+
* dotenv-shaped: KEY=VALUE lines, `#` comments, optional single/double quotes. The compiler CLI
|
|
7
|
+
* keeps its own tiny copy (db-push.ts) — the compiler cannot depend on this package.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse .dev.vars text into a name → value record.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} text
|
|
17
|
+
* @returns {Record<string, string>}
|
|
18
|
+
*/
|
|
19
|
+
export function parseDevVars(text: string): Record<string, string> {
|
|
20
|
+
const out: Record<string, string> = {};
|
|
21
|
+
for (const rawLine of text.split("\n")) {
|
|
22
|
+
const line = rawLine.trim();
|
|
23
|
+
if (line === "" || line.startsWith("#")) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const eq = line.indexOf("=");
|
|
27
|
+
if (eq === -1) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const key = line.slice(0, eq).trim();
|
|
31
|
+
let value = line.slice(eq + 1).trim();
|
|
32
|
+
if (
|
|
33
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
34
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
35
|
+
) {
|
|
36
|
+
value = value.slice(1, -1);
|
|
37
|
+
}
|
|
38
|
+
if (key !== "") {
|
|
39
|
+
out[key] = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load `<projectRoot>/.dev.vars`, or an empty record when absent.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} projectRoot
|
|
49
|
+
* @returns {Record<string, string>}
|
|
50
|
+
*/
|
|
51
|
+
export function loadDevVars(projectRoot: string): Record<string, string> {
|
|
52
|
+
const path = resolve(projectRoot, ".dev.vars");
|
|
53
|
+
if (!existsSync(path)) {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
return parseDevVars(readFileSync(path, "utf8"));
|
|
58
|
+
} catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/dev.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-server CLI entry — what `jx dev` spawns under Bun in a standalone project.
|
|
3
|
+
*
|
|
4
|
+
* Usage: bun @jxsuite/server/dev [--root <dir>] [--port <n>]
|
|
5
|
+
*
|
|
6
|
+
* For a site project (a project.json at the root), the entry builds the site up front, serves the
|
|
7
|
+
* built pages from dist/ ahead of the static-source fallback, and rebuilds before each live-reload
|
|
8
|
+
* broadcast so the browser always reloads into fresh output. Argument parsing and the middleware
|
|
9
|
+
* factory are exported for tests; the server boot runs only when executed directly
|
|
10
|
+
* (import.meta.main).
|
|
11
|
+
*
|
|
12
|
+
* @module @jxsuite/server/dev
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, statSync } from "node:fs";
|
|
16
|
+
import { join, normalize, resolve, sep } from "node:path";
|
|
17
|
+
import { createDevServer } from "./server.ts";
|
|
18
|
+
import { injectSSE } from "./watch.ts";
|
|
19
|
+
|
|
20
|
+
export interface DevArgs {
|
|
21
|
+
port: number;
|
|
22
|
+
root: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse `--root`/`--port` (plus an optional positional root) into dev-server options.
|
|
27
|
+
*
|
|
28
|
+
* @param {string[]} argv - Raw arguments (process.argv.slice(2))
|
|
29
|
+
* @returns {DevArgs} Resolved root and port (default 3000)
|
|
30
|
+
*/
|
|
31
|
+
export function parseDevArgs(argv: string[]): DevArgs {
|
|
32
|
+
let root = ".";
|
|
33
|
+
let port = 3000;
|
|
34
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
35
|
+
const arg = argv[index]!;
|
|
36
|
+
if (arg === "--root") {
|
|
37
|
+
index += 1;
|
|
38
|
+
root = argv[index] ?? root;
|
|
39
|
+
} else if (arg === "--port") {
|
|
40
|
+
index += 1;
|
|
41
|
+
const parsed = Math.trunc(Number(argv[index] ?? ""));
|
|
42
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
43
|
+
port = parsed;
|
|
44
|
+
}
|
|
45
|
+
} else if (!arg.startsWith("--")) {
|
|
46
|
+
root = arg;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { port, root: resolve(root) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Middleware serving built pages from `<root>/dist` for page-like GET requests. Directory URLs
|
|
54
|
+
* (with or without a trailing slash) map to their index.html; anything not present in dist falls
|
|
55
|
+
* through to the dev server's static-source handling.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} root - Absolute project root
|
|
58
|
+
* @returns {(req: Request, url: URL) => Response | null}
|
|
59
|
+
*/
|
|
60
|
+
export function createDistMiddleware(
|
|
61
|
+
root: string,
|
|
62
|
+
): (req: Request, url: URL) => Promise<Response | null> {
|
|
63
|
+
const distDir = join(root, "dist");
|
|
64
|
+
return async (req, url) => {
|
|
65
|
+
if (req.method !== "GET") {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const safe = normalize(decodeURIComponent(url.pathname)).replaceAll("\\", "/");
|
|
69
|
+
const candidate = resolve(distDir, `.${safe.startsWith("/") ? safe : `/${safe}`}`);
|
|
70
|
+
if (candidate !== distDir && !candidate.startsWith(distDir + sep)) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
let filePath: string | null = null;
|
|
74
|
+
if (existsSync(candidate)) {
|
|
75
|
+
filePath = statSync(candidate).isDirectory() ? join(candidate, "index.html") : candidate;
|
|
76
|
+
} else {
|
|
77
|
+
filePath = join(candidate, "index.html");
|
|
78
|
+
}
|
|
79
|
+
if (!filePath || !existsSync(filePath)) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
// Built pages get the live-reload client, like the server's own .html responses
|
|
83
|
+
if (filePath.endsWith(".html")) {
|
|
84
|
+
const html = await Bun.file(filePath).text();
|
|
85
|
+
return new Response(injectSSE(html), {
|
|
86
|
+
headers: { "Cache-Control": "no-cache", "Content-Type": "text/html; charset=utf-8" },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return new Response(Bun.file(filePath), { headers: { "Cache-Control": "no-cache" } });
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface DevServerHandle {
|
|
94
|
+
port: number;
|
|
95
|
+
stop: () => void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Boot the dev server for a root. Site projects (a project.json at the root) are built up front and
|
|
100
|
+
* rebuilt before every live-reload broadcast, with built pages served from dist/.
|
|
101
|
+
*
|
|
102
|
+
* @param {DevArgs} args - Resolved root and port
|
|
103
|
+
* @returns {Promise<DevServerHandle>} The listening server
|
|
104
|
+
*/
|
|
105
|
+
export async function startDev({ port, root }: DevArgs): Promise<DevServerHandle> {
|
|
106
|
+
const isSite = existsSync(join(root, "project.json"));
|
|
107
|
+
|
|
108
|
+
let building = Promise.resolve();
|
|
109
|
+
const rebuild = () => {
|
|
110
|
+
building = building.then(async () => {
|
|
111
|
+
const { buildSite } = await import("@jxsuite/compiler/site");
|
|
112
|
+
const result = await buildSite(root, { verbose: false });
|
|
113
|
+
if (result.errors.length > 0) {
|
|
114
|
+
for (const err of result.errors) {
|
|
115
|
+
console.error(` build error: ${err}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return building;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (isSite) {
|
|
123
|
+
console.log(`Building ${root}...`);
|
|
124
|
+
await rebuild();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (await createDevServer({
|
|
128
|
+
port,
|
|
129
|
+
root,
|
|
130
|
+
...(isSite && {
|
|
131
|
+
middleware: createDistMiddleware(root),
|
|
132
|
+
watch: { preReload: () => rebuild(), reloadOnAnyChange: true },
|
|
133
|
+
}),
|
|
134
|
+
})) as DevServerHandle;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (import.meta.main) {
|
|
138
|
+
const { port, root } = parseDevArgs(process.argv.slice(2));
|
|
139
|
+
const server = await startDev({ port, root });
|
|
140
|
+
console.log(`Jx dev server running at http://localhost:${server.port} (root: ${root})`);
|
|
141
|
+
}
|
package/src/jx-mounts.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jx-mounts — registry-driven `/_jx/*` dispatch for the dev server (specs/extensions.md §11).
|
|
3
|
+
*
|
|
4
|
+
* Extension classes with a `server` block mount the same fetch-style handlers here that the
|
|
5
|
+
* generated site worker mounts in production: one shared context object per project, handlers built
|
|
6
|
+
* once via the static `mount(options, ctx)` capability and dispatched by basePath prefix.
|
|
7
|
+
*
|
|
8
|
+
* Dev conveniences over the production worker:
|
|
9
|
+
*
|
|
10
|
+
* - `env` is process.env merged under the project's `.dev.vars` plus `JX_PROJECT_ROOT`;
|
|
11
|
+
* - Connector classes with `local: "<provider>"` are stood in by the registry's class for that
|
|
12
|
+
* provider (e.g. D1 → sqlite at `<project>/.jx/data/<connection>.sqlite`);
|
|
13
|
+
* - Mount options set `autoSync: true`, so table schemas sync additively on first touch.
|
|
14
|
+
*
|
|
15
|
+
* The runtime is cached per project root and invalidated when project.json changes on disk.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
19
|
+
import { resolve } from "node:path";
|
|
20
|
+
import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
|
|
21
|
+
import { loadDevVars } from "./dev-vars.ts";
|
|
22
|
+
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
23
|
+
import type { FormatEntry } from "@jxsuite/schema/format-registry";
|
|
24
|
+
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
25
|
+
|
|
26
|
+
type MountHandler = (request: Request, env: Record<string, unknown>) => Promise<Response>;
|
|
27
|
+
|
|
28
|
+
interface MountRuntime {
|
|
29
|
+
handlers: { basePath: string; handler: MountHandler }[];
|
|
30
|
+
env: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Per-project runtime cache, invalidated when project.json's mtime moves. */
|
|
34
|
+
const runtimeCache = new Map<string, { mtime: number; runtime: Promise<MountRuntime | null> }>();
|
|
35
|
+
|
|
36
|
+
/** Reset the runtime cache (test hook). */
|
|
37
|
+
export function resetJxMounts(): void {
|
|
38
|
+
runtimeCache.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Dispatch a `/_jx/*` request to the project's extension mounts.
|
|
43
|
+
*
|
|
44
|
+
* @param {Request} req
|
|
45
|
+
* @param {URL} url
|
|
46
|
+
* @param {string} projectRoot - Absolute project root (the active studio project, or the server
|
|
47
|
+
* root)
|
|
48
|
+
* @returns {Promise<Response | null>} A response, or null when no mount claims the path
|
|
49
|
+
*/
|
|
50
|
+
export async function handleJxMounts(
|
|
51
|
+
req: Request,
|
|
52
|
+
url: URL,
|
|
53
|
+
projectRoot: string,
|
|
54
|
+
): Promise<Response | null> {
|
|
55
|
+
if (!url.pathname.startsWith("/_jx/")) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const runtime = await getRuntime(projectRoot);
|
|
59
|
+
if (!runtime) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
for (const { basePath, handler } of runtime.handlers) {
|
|
63
|
+
if (url.pathname === basePath || url.pathname.startsWith(`${basePath}/`)) {
|
|
64
|
+
return handler(req, runtime.env);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Load (or reuse) the mount runtime for a project root. */
|
|
71
|
+
function getRuntime(projectRoot: string): Promise<MountRuntime | null> {
|
|
72
|
+
const projectJsonPath = resolve(projectRoot, "project.json");
|
|
73
|
+
if (!existsSync(projectJsonPath)) {
|
|
74
|
+
return Promise.resolve(null);
|
|
75
|
+
}
|
|
76
|
+
const { mtimeMs } = statSync(projectJsonPath);
|
|
77
|
+
const cached = runtimeCache.get(projectRoot);
|
|
78
|
+
if (cached && cached.mtime === mtimeMs) {
|
|
79
|
+
return cached.runtime;
|
|
80
|
+
}
|
|
81
|
+
const runtime = buildRuntime(projectRoot, projectJsonPath).catch((error) => {
|
|
82
|
+
console.warn(
|
|
83
|
+
`jx-mounts: failed to build extension mounts for ${projectRoot}:`,
|
|
84
|
+
error instanceof Error ? error.message : String(error),
|
|
85
|
+
);
|
|
86
|
+
return null;
|
|
87
|
+
});
|
|
88
|
+
runtimeCache.set(projectRoot, { mtime: mtimeMs, runtime });
|
|
89
|
+
return runtime;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Build the runtime: registry, sections, connector stand-ins, and the ordered mount handlers. */
|
|
93
|
+
async function buildRuntime(
|
|
94
|
+
projectRoot: string,
|
|
95
|
+
projectJsonPath: string,
|
|
96
|
+
): Promise<MountRuntime | null> {
|
|
97
|
+
const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
|
|
98
|
+
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
99
|
+
const mounts = registry.serverMounts();
|
|
100
|
+
if (mounts.length === 0) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sections: Record<string, unknown> = {};
|
|
105
|
+
for (const contribution of registry.projectContributions()) {
|
|
106
|
+
const { key } = contribution.project!;
|
|
107
|
+
if (key in config) {
|
|
108
|
+
sections[key] = config[key];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const connectors = await resolveConnectorStandins(registry);
|
|
113
|
+
const env: Record<string, unknown> = {
|
|
114
|
+
...process.env,
|
|
115
|
+
...loadDevVars(projectRoot),
|
|
116
|
+
JX_PROJECT_ROOT: projectRoot,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// One shared mutable context, passed to every mount in order (specs/extensions.md §11).
|
|
120
|
+
const ctx: Record<string, unknown> = {};
|
|
121
|
+
const handlers: MountRuntime["handlers"] = [];
|
|
122
|
+
for (const entry of mounts) {
|
|
123
|
+
const { basePath } = entry.server!;
|
|
124
|
+
const handler = (await entry.call(
|
|
125
|
+
"mount",
|
|
126
|
+
{ autoSync: true, basePath, connectors, sections },
|
|
127
|
+
ctx,
|
|
128
|
+
)) as MountHandler;
|
|
129
|
+
handlers.push({ basePath, handler });
|
|
130
|
+
}
|
|
131
|
+
return { env, handlers };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve connector implementation classes keyed by provider id, applying the dev-server `local`
|
|
136
|
+
* stand-in rule: a connector declaring `local: "<provider>"` is served by the registry's class for
|
|
137
|
+
* that provider instead of its own (specs/extensions.md §12). Shared with the data-api push seam so
|
|
138
|
+
* section-owner deploySchema contributions (e.g. auth) see the same stand-ins as the mounts.
|
|
139
|
+
*
|
|
140
|
+
* @param {ExtensionRegistry} registry
|
|
141
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
142
|
+
*/
|
|
143
|
+
export async function resolveConnectorStandins(
|
|
144
|
+
registry: ExtensionRegistry,
|
|
145
|
+
): Promise<Record<string, unknown>> {
|
|
146
|
+
const entries = registry.connectors();
|
|
147
|
+
const out: Record<string, unknown> = {};
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
const provider = String(entry.connector!.provider);
|
|
150
|
+
const { local } = entry.connector!;
|
|
151
|
+
const target =
|
|
152
|
+
typeof local === "string"
|
|
153
|
+
? (entries.find((e) => e.connector!.provider === local) ?? entry)
|
|
154
|
+
: entry;
|
|
155
|
+
out[provider] = await implementationClass(target);
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Import a connector entry's implementation module and pick the export named by its title. */
|
|
161
|
+
async function implementationClass(entry: FormatEntry): Promise<unknown> {
|
|
162
|
+
const mod = await entry.implementation();
|
|
163
|
+
const title = (entry.classDef.title as string | undefined) ?? entry.name;
|
|
164
|
+
const fromDefault = (mod.default as Record<string, unknown> | undefined)?.[title];
|
|
165
|
+
return mod[title] ?? fromDefault ?? mod.default;
|
|
166
|
+
}
|
package/src/project-server.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
29
29
|
import { realpathSync } from "node:fs";
|
|
30
|
+
import { handleJxMounts } from "./jx-mounts.ts";
|
|
30
31
|
import { handleResolve, handleServerFunction } from "./resolve.ts";
|
|
31
32
|
import { handleAiApi } from "./ai-api.ts";
|
|
32
33
|
import { handleImportApi } from "./import-api.ts";
|
|
@@ -327,6 +328,15 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
327
328
|
return new Response("No project", { status: 404 });
|
|
328
329
|
}
|
|
329
330
|
|
|
331
|
+
// 4b. Extension server mounts (/_jx/data etc.) — registry-driven, same wire contract as
|
|
332
|
+
// The generated site worker and the dev server (specs/extensions.md §11).
|
|
333
|
+
if (normPath.startsWith("/_jx/")) {
|
|
334
|
+
const mountRes = await handleJxMounts(req, url, root);
|
|
335
|
+
if (mountRes) {
|
|
336
|
+
return mountRes;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
330
340
|
// 5. Privileged HTTP routes (RCE-capable: dynamic import()). Gate with token + loopback
|
|
331
341
|
// Origin/Host BEFORE dispatch.
|
|
332
342
|
if (
|
package/src/resolve.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { dirname, relative, resolve } from "node:path";
|
|
4
4
|
import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
|
|
5
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
|
-
import {
|
|
7
|
+
import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
|
|
8
|
+
import { loadProjectSections } from "@jxsuite/compiler/project-sections";
|
|
8
9
|
import type { DynamicClass } from "@jxsuite/runtime/types";
|
|
9
10
|
import type { JxClassDef, ProjectConfig } from "@jxsuite/schema/types";
|
|
10
11
|
|
|
@@ -35,15 +36,16 @@ interface ServerFunctionBody {
|
|
|
35
36
|
[key: string]: unknown;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
/** Per-project context cache, invalidated when project.json changes on disk. */
|
|
40
|
+
const projectContextCache = new Map<string, { mtime: number; context: Record<string, unknown> }>();
|
|
41
|
+
|
|
38
42
|
/**
|
|
39
|
-
* Lazy-load project context (project.json +
|
|
43
|
+
* Lazy-load project context (project.json + extension-loaded sections) for class instantiation.
|
|
44
|
+
* Sections land under their section keys (e.g. `content` for the parser), matching the compiler's
|
|
45
|
+
* `_project` shape.
|
|
40
46
|
*
|
|
41
47
|
* @param {string} projectRoot
|
|
42
|
-
* @returns {Promise<{
|
|
43
|
-
* config: Record<string, unknown>;
|
|
44
|
-
* contentTypes: Map<string, unknown[]>;
|
|
45
|
-
* root: string;
|
|
46
|
-
* } | null>}
|
|
48
|
+
* @returns {Promise<Record<string, unknown> | null>} `{ config, root, ...sections }` or null
|
|
47
49
|
*/
|
|
48
50
|
async function loadProjectContext(projectRoot: string) {
|
|
49
51
|
const projectJsonPath = resolve(projectRoot, "project.json");
|
|
@@ -51,11 +53,17 @@ async function loadProjectContext(projectRoot: string) {
|
|
|
51
53
|
return null;
|
|
52
54
|
}
|
|
53
55
|
try {
|
|
56
|
+
const { mtimeMs } = statSync(projectJsonPath);
|
|
57
|
+
const cached = projectContextCache.get(projectRoot);
|
|
58
|
+
if (cached && cached.mtime === mtimeMs) {
|
|
59
|
+
return cached.context;
|
|
60
|
+
}
|
|
54
61
|
const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
63
|
+
const sections = await loadProjectSections(projectRoot, config, registry);
|
|
64
|
+
const context = { config, root: projectRoot, ...sections };
|
|
65
|
+
projectContextCache.set(projectRoot, { context, mtime: mtimeMs });
|
|
66
|
+
return context;
|
|
59
67
|
} catch {
|
|
60
68
|
return null;
|
|
61
69
|
}
|
package/src/server.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { join, relative, resolve } from "node:path";
|
|
|
17
17
|
import { buildAll } from "./build.ts";
|
|
18
18
|
import { createCollabRegistry } from "./collab.ts";
|
|
19
19
|
import { createWatcher, injectSSE } from "./watch.ts";
|
|
20
|
+
import { handleJxMounts } from "./jx-mounts.ts";
|
|
20
21
|
import { handleResolve, handleServerFunction } from "./resolve.ts";
|
|
21
22
|
import { assertAccessible, handleStudioApi } from "./studio-api.ts";
|
|
22
23
|
import { handleCodeApi } from "./code-api.ts";
|
|
@@ -268,6 +269,15 @@ export async function createDevServer(options: {
|
|
|
268
269
|
return handleServerFunction(req, absRoot);
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
// Extension server mounts (/_jx/data etc.) — registry-driven, same wire contract as the
|
|
273
|
+
// Generated site worker (specs/extensions.md §11).
|
|
274
|
+
if (path.startsWith("/_jx/")) {
|
|
275
|
+
const mountRes = await handleJxMounts(req, url, activeProjectRoot ?? absRoot);
|
|
276
|
+
if (mountRes) {
|
|
277
|
+
return mountRes;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
271
281
|
// Studio filesystem API
|
|
272
282
|
if (enableStudio && path.startsWith("/__studio/")) {
|
|
273
283
|
// Realtime co-editing: WebSocket upgrade or capability probe
|
package/src/studio-api.ts
CHANGED
|
@@ -11,7 +11,12 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from
|
|
|
11
11
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
12
12
|
import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
13
13
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
buildExtensionsPayload,
|
|
16
|
+
buildProjectExtensionRegistry,
|
|
17
|
+
} from "@jxsuite/compiler/format-host";
|
|
18
|
+
import { readBundledProjectSchemas } from "@jxsuite/compiler/schema-command";
|
|
19
|
+
import { handleDataApi } from "./data-api.ts";
|
|
15
20
|
import { applyRename } from "./refactor/apply.ts";
|
|
16
21
|
import {
|
|
17
22
|
bunExecutable,
|
|
@@ -20,6 +25,7 @@ import {
|
|
|
20
25
|
outdatedPackages,
|
|
21
26
|
setPackageVersions,
|
|
22
27
|
} from "./packages.ts";
|
|
28
|
+
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
23
29
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
24
30
|
import type { ClassJsonDef } from "./types.ts";
|
|
25
31
|
import type { DesignOptions } from "@jxsuite/create/generate";
|
|
@@ -113,18 +119,18 @@ function collectSlotDefs(node: unknown, out: SlotDef[] = []): SlotDef[] {
|
|
|
113
119
|
return out;
|
|
114
120
|
}
|
|
115
121
|
|
|
116
|
-
// ───
|
|
122
|
+
// ─── Extension registry (per project root, invalidated on project.json change) ──
|
|
117
123
|
|
|
118
|
-
const
|
|
124
|
+
const extensionRegistryCache = new Map<string, { mtime: number; registry: ExtensionRegistry }>();
|
|
119
125
|
|
|
120
126
|
/**
|
|
121
|
-
* Build (or reuse)
|
|
122
|
-
* empty registry is returned when there is no project.json — only .json files are handled then.
|
|
127
|
+
* Build (or reuse) a project's extension registry (built from its project.json `extensions` array).
|
|
128
|
+
* An empty registry is returned when there is no project.json — only .json files are handled then.
|
|
123
129
|
*
|
|
124
130
|
* @param {string} projectRoot
|
|
125
|
-
* @returns {Promise<
|
|
131
|
+
* @returns {Promise<ExtensionRegistry>}
|
|
126
132
|
*/
|
|
127
|
-
async function
|
|
133
|
+
async function getExtensionRegistry(projectRoot: string): Promise<ExtensionRegistry> {
|
|
128
134
|
const projectJsonPath = resolve(projectRoot, "project.json");
|
|
129
135
|
let mtime = 0;
|
|
130
136
|
let projectConfig: ProjectConfig | undefined;
|
|
@@ -134,15 +140,21 @@ async function getFormatRegistry(projectRoot: string): Promise<FormatRegistry> {
|
|
|
134
140
|
} catch {
|
|
135
141
|
projectConfig = undefined;
|
|
136
142
|
}
|
|
137
|
-
const cached =
|
|
143
|
+
const cached = extensionRegistryCache.get(projectRoot);
|
|
138
144
|
if (cached && cached.mtime === mtime) {
|
|
139
145
|
return cached.registry;
|
|
140
146
|
}
|
|
141
|
-
const registry = await
|
|
142
|
-
|
|
147
|
+
const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
|
|
148
|
+
extensionRegistryCache.set(projectRoot, { mtime, registry });
|
|
143
149
|
return registry;
|
|
144
150
|
}
|
|
145
151
|
|
|
152
|
+
/** Format-dispatch view of {@link getExtensionRegistry} (parse/serialize/discover/load lookups). */
|
|
153
|
+
async function getFormatRegistry(projectRoot: string): Promise<FormatRegistry> {
|
|
154
|
+
const registry = await getExtensionRegistry(projectRoot);
|
|
155
|
+
return registry.formats;
|
|
156
|
+
}
|
|
157
|
+
|
|
146
158
|
/**
|
|
147
159
|
* Check that a path is under either the server root OR the active project root. This allows file
|
|
148
160
|
* operations on external projects that have been explicitly activated via /__studio/activate.
|
|
@@ -260,6 +272,12 @@ export async function handleStudioApi(
|
|
|
260
272
|
) {
|
|
261
273
|
const path = url.pathname;
|
|
262
274
|
|
|
275
|
+
// Data-domain owner console + secrets (names only) — delegated to data-api.ts.
|
|
276
|
+
const dataRes = await handleDataApi(req, url, root, activeProjectRoot);
|
|
277
|
+
if (dataRes) {
|
|
278
|
+
return dataRes;
|
|
279
|
+
}
|
|
280
|
+
|
|
263
281
|
// Project metadata
|
|
264
282
|
if (path === "/__studio/project" && req.method === "GET") {
|
|
265
283
|
try {
|
|
@@ -1154,15 +1172,18 @@ export async function handleStudioApi(
|
|
|
1154
1172
|
}
|
|
1155
1173
|
|
|
1156
1174
|
// Format registry listing — lets the studio introspect available formats without
|
|
1157
|
-
// Fetching each .class.json itself.
|
|
1175
|
+
// Fetching each .class.json itself. The sibling `extensions` array carries each enabled
|
|
1176
|
+
// Extension's manifest identity and project-section contributions (additive; the `formats`
|
|
1177
|
+
// Shape is unchanged for compat).
|
|
1158
1178
|
if (path === "/__studio/formats" && req.method === "GET") {
|
|
1159
1179
|
const dir = url.searchParams.get("dir") || activeProjectRoot || root;
|
|
1160
1180
|
const projectRoot = isAbsolute(dir) ? dir : resolve(root, dir);
|
|
1161
1181
|
try {
|
|
1162
1182
|
assertAccessible(projectRoot, root, activeProjectRoot);
|
|
1163
|
-
const registry = await
|
|
1183
|
+
const registry = await getExtensionRegistry(projectRoot);
|
|
1164
1184
|
return Response.json({
|
|
1165
|
-
|
|
1185
|
+
extensions: buildExtensionsPayload(registry),
|
|
1186
|
+
formats: registry.formats.entries.map((e) => ({
|
|
1166
1187
|
capabilities: e.capabilities,
|
|
1167
1188
|
documentKinds: e.documentKinds,
|
|
1168
1189
|
exportTarget: e.exportTarget,
|
|
@@ -1178,6 +1199,25 @@ export async function handleStudioApi(
|
|
|
1178
1199
|
}
|
|
1179
1200
|
}
|
|
1180
1201
|
|
|
1202
|
+
// Pre-bundled per-project entry schemas (project.schema.json / document.schema.json) for the
|
|
1203
|
+
// Studio's editor — regenerated on demand when missing or older than project.json, then bundled
|
|
1204
|
+
// Into self-contained compound documents so no relative $ref resolution is needed client-side.
|
|
1205
|
+
if (path === "/__studio/project-schemas" && req.method === "GET") {
|
|
1206
|
+
const dir = url.searchParams.get("dir") || activeProjectRoot || root;
|
|
1207
|
+
const projectRoot = isAbsolute(dir) ? dir : resolve(root, dir);
|
|
1208
|
+
try {
|
|
1209
|
+
assertAccessible(projectRoot, root, activeProjectRoot);
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
return Response.json({ error: errorMessage(error) }, { status: 400 });
|
|
1212
|
+
}
|
|
1213
|
+
try {
|
|
1214
|
+
const { document, project } = await readBundledProjectSchemas(projectRoot);
|
|
1215
|
+
return Response.json({ document, project });
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
return Response.json({ error: errorMessage(error) }, { status: 500 });
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1181
1221
|
if (path === "/__studio/plugin-schema" && req.method === "GET") {
|
|
1182
1222
|
const src = url.searchParams.get("src");
|
|
1183
1223
|
const prototype = url.searchParams.get("prototype");
|
package/src/watch.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { FsEventPayload } from "./refactor/fs-events.ts";
|
|
|
10
10
|
const DEFAULT_IGNORE = [
|
|
11
11
|
"**/node_modules/**",
|
|
12
12
|
"**/dist/**",
|
|
13
|
+
"**/.cache/**",
|
|
13
14
|
"**/.git/**",
|
|
14
15
|
"**/.jx/**",
|
|
15
16
|
"**/.devenv/**",
|
|
@@ -62,7 +63,12 @@ export function injectSSE(html: string) {
|
|
|
62
63
|
*
|
|
63
64
|
* @param {string} root - Absolute path to watch
|
|
64
65
|
* @param {BuildEntry[]} builds - Build entries (for selective rebuild)
|
|
65
|
-
* @param {{
|
|
66
|
+
* @param {{
|
|
67
|
+
* ignore?: string[];
|
|
68
|
+
* debounce?: number;
|
|
69
|
+
* reloadOnAnyChange?: boolean;
|
|
70
|
+
* preReload?: (filename: string) => Promise<void> | void;
|
|
71
|
+
* }} [opts]
|
|
66
72
|
* @returns {{
|
|
67
73
|
* broadcast: () => void;
|
|
68
74
|
* handleSSE: () => Response;
|
|
@@ -76,11 +82,14 @@ export function createWatcher(
|
|
|
76
82
|
ignore?: string[];
|
|
77
83
|
debounce?: number;
|
|
78
84
|
reloadOnAnyChange?: boolean;
|
|
85
|
+
/** Runs before the reload broadcast — e.g. `jx dev` rebuilds the site here. */
|
|
86
|
+
preReload?: (filename: string) => Promise<void> | void;
|
|
79
87
|
} = {},
|
|
80
88
|
) {
|
|
81
89
|
const ignore = opts.ignore ?? DEFAULT_IGNORE;
|
|
82
90
|
const debounceMs = opts.debounce ?? 50;
|
|
83
91
|
const reloadOnAnyChange = opts.reloadOnAnyChange ?? false;
|
|
92
|
+
const { preReload } = opts;
|
|
84
93
|
|
|
85
94
|
const clients = new Set<(msg: string) => void>();
|
|
86
95
|
const encoder = new TextEncoder();
|
|
@@ -172,6 +181,13 @@ export function createWatcher(
|
|
|
172
181
|
|
|
173
182
|
clearTimeout(timer ?? undefined);
|
|
174
183
|
timer = setTimeout(async () => {
|
|
184
|
+
if (preReload) {
|
|
185
|
+
try {
|
|
186
|
+
await preReload(filename);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error(`[watch] preReload failed: ${(error as Error).message}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
175
191
|
if (builds.length > 0) {
|
|
176
192
|
const result = await rebuild(builds, filename);
|
|
177
193
|
if (!result.success) {
|