@jxsuite/server 8.4.0 → 8.6.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 +16 -15
- package/src/convert.ts +60 -14
- package/src/data-api.ts +16 -12
- package/src/import-api.ts +7 -7
- package/src/jx-mounts.ts +18 -15
- package/src/refactor/scan.ts +22 -3
- package/src/resolve.ts +22 -16
- package/src/server.ts +11 -1
- package/src/site-output.ts +11 -16
- package/src/studio-api.ts +61 -47
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.6.0",
|
|
4
4
|
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -55,17 +55,18 @@
|
|
|
55
55
|
"upgrade": "bun update --latest && bun install"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@jxsuite/ai": "^0.43.
|
|
59
|
-
"@jxsuite/catalog": "^0.1.
|
|
60
|
-
"@jxsuite/collab": "^0.11.
|
|
61
|
-
"@jxsuite/compiler": "^8.
|
|
62
|
-
"@jxsuite/create": "^1.
|
|
63
|
-
"@jxsuite/
|
|
64
|
-
"@jxsuite/
|
|
65
|
-
"@jxsuite/
|
|
66
|
-
"@jxsuite/
|
|
67
|
-
"@jxsuite/
|
|
68
|
-
"@jxsuite/
|
|
58
|
+
"@jxsuite/ai": "^0.43.3",
|
|
59
|
+
"@jxsuite/catalog": "^0.1.11",
|
|
60
|
+
"@jxsuite/collab": "^0.11.9",
|
|
61
|
+
"@jxsuite/compiler": "^8.5.0",
|
|
62
|
+
"@jxsuite/create": "^1.7.0",
|
|
63
|
+
"@jxsuite/erase": "^0.1.0",
|
|
64
|
+
"@jxsuite/import": "^0.44.0",
|
|
65
|
+
"@jxsuite/protocol": "^6.2.5",
|
|
66
|
+
"@jxsuite/runtime": "^6.2.2",
|
|
67
|
+
"@jxsuite/schema": "^5.5.0",
|
|
68
|
+
"@jxsuite/site": "^2.5.1",
|
|
69
|
+
"@jxsuite/starters": "^3.4.0",
|
|
69
70
|
"@npmcli/arborist": "^10.0.4",
|
|
70
71
|
"chokidar": "^6.0.0",
|
|
71
72
|
"kysely": "^0.29.8",
|
|
@@ -75,9 +76,9 @@
|
|
|
75
76
|
"zod": "^4.6.7"
|
|
76
77
|
},
|
|
77
78
|
"devDependencies": {
|
|
78
|
-
"@jxsuite/auth": "^0.8.
|
|
79
|
-
"@jxsuite/connector": "^0.8.
|
|
80
|
-
"@jxsuite/parser": "^4.3.
|
|
79
|
+
"@jxsuite/auth": "^0.8.7",
|
|
80
|
+
"@jxsuite/connector": "^0.8.7",
|
|
81
|
+
"@jxsuite/parser": "^4.3.3",
|
|
81
82
|
"@jxsuite/test-support": "^0.0.0",
|
|
82
83
|
"@types/npm-package-arg": "^6.1.4",
|
|
83
84
|
"@types/npmcli__arborist": "^6.3.3",
|
package/src/convert.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `jx convert`: move a document between `.json` and `.jxn`, with every reference repaired
|
|
2
|
+
* `jx convert`: move a document between `.json` and `.jxn`, with every reference repaired, and the
|
|
3
|
+
* project file between `project.json` and `project.jxn` (site-architecture.md §2.3).
|
|
3
4
|
*
|
|
4
5
|
* It is a rename, because that is what makes it safe: writing a new file beside the old one would
|
|
5
|
-
* leave every `$ref` to it dangling, and the refactor pass is what repairs them.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* leave every `$ref` to it dangling, and the refactor pass is what repairs them. A project file is
|
|
7
|
+
* named by nothing, so it needs no repair, and writing it beside the old one would leave a folder
|
|
8
|
+
* holding both, which every host refuses. This runs under Bun for the same reason `jx dev` does, so
|
|
9
|
+
* `@jxsuite/compiler`'s CLI resolves it from the project and spawns it.
|
|
8
10
|
*
|
|
9
11
|
* @docs framework/build/cli
|
|
10
12
|
*/
|
|
@@ -16,6 +18,11 @@ import { loadProjectConfig } from "@jxsuite/compiler/site-loader";
|
|
|
16
18
|
import { countJxnComments, parseJxn, serializeJxn } from "@jxsuite/schema/jxn";
|
|
17
19
|
import { isJsonObject, isString } from "@jxsuite/schema/guards";
|
|
18
20
|
import { errorMessage, parseJxDocument } from "@jxsuite/schema/parse";
|
|
21
|
+
import {
|
|
22
|
+
isProjectFileName,
|
|
23
|
+
parseProjectSource,
|
|
24
|
+
serializeProjectFile,
|
|
25
|
+
} from "@jxsuite/schema/project-file";
|
|
19
26
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
20
27
|
import { applyRename } from "./refactor/index.ts";
|
|
21
28
|
|
|
@@ -77,6 +84,34 @@ function expand(path: string): string[] {
|
|
|
77
84
|
});
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
/** Whether a path is the project's own file, which is the one root file that converts. */
|
|
88
|
+
function isProjectFileAt(root: string, path: string): boolean {
|
|
89
|
+
return isProjectFileName(relative(root, path));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Why the project file cannot be converted, or null when it can. The other name already being there
|
|
94
|
+
* needs no check here: the folder then holds both, and loading the project has refused it.
|
|
95
|
+
*/
|
|
96
|
+
async function projectFileRefusal(path: string, options: ConvertOptions): Promise<string | null> {
|
|
97
|
+
const ext = extname(path).toLowerCase();
|
|
98
|
+
if (ext === `.${options.to}`) {
|
|
99
|
+
return `already .${options.to}`;
|
|
100
|
+
}
|
|
101
|
+
return ext === ".jxn" ? commentRefusal(path, options) : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Why a `.jxn` file's comments keep it from becoming `.json`, or null when nothing would be lost. */
|
|
105
|
+
async function commentRefusal(path: string, options: ConvertOptions): Promise<string | null> {
|
|
106
|
+
if (options.dropComments) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const comments = countJxnComments(await readFile(path, "utf8"));
|
|
110
|
+
return comments > 0
|
|
111
|
+
? `it has ${comments} comment(s), and .json has nowhere to keep them. --drop-comments converts it anyway`
|
|
112
|
+
: null;
|
|
113
|
+
}
|
|
114
|
+
|
|
80
115
|
/** Why a file cannot be converted, or null when it can. */
|
|
81
116
|
async function refusal(
|
|
82
117
|
root: string,
|
|
@@ -87,6 +122,9 @@ async function refusal(
|
|
|
87
122
|
if (!existsSync(path)) {
|
|
88
123
|
return "there is no such file";
|
|
89
124
|
}
|
|
125
|
+
if (isProjectFileAt(root, path)) {
|
|
126
|
+
return projectFileRefusal(path, options);
|
|
127
|
+
}
|
|
90
128
|
const name = relative(root, path).replaceAll("\\", "/");
|
|
91
129
|
if (!DOCUMENT_ROOTS.some((prefix) => name.startsWith(prefix))) {
|
|
92
130
|
return `only ${DOCUMENT_ROOTS.join(", ")} hold documents`;
|
|
@@ -108,13 +146,7 @@ async function refusal(
|
|
|
108
146
|
if (existsSync(targetOf(path, options.to))) {
|
|
109
147
|
return `${basename(targetOf(path, options.to))} already exists`;
|
|
110
148
|
}
|
|
111
|
-
|
|
112
|
-
const comments = countJxnComments(await readFile(path, "utf8"));
|
|
113
|
-
if (comments > 0) {
|
|
114
|
-
return `it has ${comments} comment(s), and .json has nowhere to keep them. --drop-comments converts it anyway`;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return null;
|
|
149
|
+
return ext === ".jxn" ? commentRefusal(path, options) : null;
|
|
118
150
|
}
|
|
119
151
|
|
|
120
152
|
/** The path a file has once converted. */
|
|
@@ -123,8 +155,12 @@ function targetOf(path: string, to: ConvertTarget): string {
|
|
|
123
155
|
}
|
|
124
156
|
|
|
125
157
|
/** The file's text in the other format. */
|
|
126
|
-
async function converted(path: string, to: ConvertTarget): Promise<string> {
|
|
158
|
+
async function converted(root: string, path: string, to: ConvertTarget): Promise<string> {
|
|
127
159
|
const text = await readFile(path, "utf8");
|
|
160
|
+
if (isProjectFileAt(root, path)) {
|
|
161
|
+
// Canonical form: the comments a `.jxn` had are what `--drop-comments` agreed to lose.
|
|
162
|
+
return serializeProjectFile(parseProjectSource(text, path), null, targetOf(path, to));
|
|
163
|
+
}
|
|
128
164
|
return to === "jxn"
|
|
129
165
|
? serializeJxn(parseJxDocument(text, path))
|
|
130
166
|
: `${JSON.stringify(parseJxn(text, path), null, 2)}\n`;
|
|
@@ -156,7 +192,7 @@ export async function convertDocuments(
|
|
|
156
192
|
continue;
|
|
157
193
|
}
|
|
158
194
|
try {
|
|
159
|
-
const text = await converted(path, options.to);
|
|
195
|
+
const text = await converted(root, path, options.to);
|
|
160
196
|
if (options.dryRun) {
|
|
161
197
|
continue;
|
|
162
198
|
}
|
|
@@ -164,6 +200,9 @@ export async function convertDocuments(
|
|
|
164
200
|
the new, and nothing beside it to be mistaken for the document. */
|
|
165
201
|
await writeFile(path, text);
|
|
166
202
|
await rename(path, target);
|
|
203
|
+
if (isProjectFileAt(root, path)) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
167
206
|
const report = await applyRename({ absFrom: path, absTo: target, registry: formats, root });
|
|
168
207
|
row.references = report.references.filesChanged;
|
|
169
208
|
if (report.errors.length > 0) {
|
|
@@ -236,7 +275,14 @@ export async function runConvert(
|
|
|
236
275
|
print("Usage: jx convert <paths…> --to jxn|json [--dry-run] [--drop-comments]");
|
|
237
276
|
return 2;
|
|
238
277
|
}
|
|
239
|
-
|
|
278
|
+
let rows: ConvertRow[];
|
|
279
|
+
try {
|
|
280
|
+
rows = await convertDocuments(args.root, args.paths, args);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
// The project itself does not load, so there is nothing to convert in it.
|
|
283
|
+
print(errorMessage(error));
|
|
284
|
+
return 1;
|
|
285
|
+
}
|
|
240
286
|
for (const row of rows) {
|
|
241
287
|
if (row.problem !== null) {
|
|
242
288
|
print(`${row.from}: ${row.problem}`);
|
package/src/data-api.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* (kind "auth") after the connector plan inside {@link pushDataSchema}.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { existsSync, readFileSync
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
20
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
21
21
|
import { Kysely, sql } from "kysely";
|
|
22
22
|
import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
|
|
@@ -53,7 +53,7 @@ import type {
|
|
|
53
53
|
SecretsSetResponse,
|
|
54
54
|
} from "@jxsuite/protocol";
|
|
55
55
|
import { problem, problemTypeForStatus } from "./problem.ts";
|
|
56
|
-
import {
|
|
56
|
+
import { isSameStamp, projectFileStamp, readProjectFile } from "@jxsuite/schema/project-file/node";
|
|
57
57
|
|
|
58
58
|
// ─── Local section shapes (structural — the connector owns the full types) ───
|
|
59
59
|
|
|
@@ -126,24 +126,27 @@ class ApiError extends Error {
|
|
|
126
126
|
|
|
127
127
|
// ─── Project + registry resolution ───────────────────────────────────────────
|
|
128
128
|
|
|
129
|
-
/** Per-project registry cache, invalidated when project
|
|
130
|
-
const registryCache = new Map<
|
|
129
|
+
/** Per-project registry cache, invalidated when the project file moves or changes. */
|
|
130
|
+
const registryCache = new Map<
|
|
131
|
+
string,
|
|
132
|
+
{ mtime: number; path: string; registry: Promise<ExtensionRegistry> }
|
|
133
|
+
>();
|
|
131
134
|
|
|
132
135
|
/** Reset the registry cache (test hook). */
|
|
133
136
|
export function resetDataApi(): void {
|
|
134
137
|
registryCache.clear();
|
|
135
138
|
}
|
|
136
139
|
|
|
137
|
-
/** Read the project's config + connector-owned sections; 404 when there is no project.
|
|
140
|
+
/** Read the project's config + connector-owned sections; 404 when there is no project file. */
|
|
138
141
|
function getProject(projectRoot: string): ProjectSections {
|
|
139
142
|
const read = readProjectFile(projectRoot);
|
|
140
143
|
if (!read) {
|
|
141
|
-
throw new ApiError(404, `No project
|
|
144
|
+
throw new ApiError(404, `No project file under "${projectRoot}"`);
|
|
142
145
|
}
|
|
143
146
|
if ("error" in read) {
|
|
144
147
|
throw new ApiError(
|
|
145
148
|
404,
|
|
146
|
-
`The project
|
|
149
|
+
`The project file under "${projectRoot}" does not parse: ${read.error}`,
|
|
147
150
|
);
|
|
148
151
|
}
|
|
149
152
|
const { config } = read;
|
|
@@ -158,16 +161,17 @@ function getProject(projectRoot: string): ProjectSections {
|
|
|
158
161
|
return { config, connections, tables };
|
|
159
162
|
}
|
|
160
163
|
|
|
161
|
-
/** Build (or reuse) the project's extension registry, keyed on project
|
|
164
|
+
/** Build (or reuse) the project's extension registry, keyed on the project file's stamp. */
|
|
162
165
|
function getRegistry(projectRoot: string, config: ProjectConfig): Promise<ExtensionRegistry> {
|
|
163
|
-
const
|
|
164
|
-
const mtime = statSync(projectJsonPath).mtimeMs;
|
|
166
|
+
const stamp = projectFileStamp(projectRoot);
|
|
165
167
|
const cached = registryCache.get(projectRoot);
|
|
166
|
-
if (cached && cached
|
|
168
|
+
if (stamp && cached && isSameStamp(cached, stamp)) {
|
|
167
169
|
return cached.registry;
|
|
168
170
|
}
|
|
169
171
|
const registry = buildProjectExtensionRegistry(projectRoot, config);
|
|
170
|
-
|
|
172
|
+
if (stamp) {
|
|
173
|
+
registryCache.set(projectRoot, { ...stamp, registry });
|
|
174
|
+
}
|
|
171
175
|
return registry;
|
|
172
176
|
}
|
|
173
177
|
|
package/src/import-api.ts
CHANGED
|
@@ -36,7 +36,8 @@ import type { ImportProgressEvent } from "@jxsuite/import/pipeline";
|
|
|
36
36
|
import type { ImportSiteOptions, ImportSiteResult } from "@jxsuite/import/run";
|
|
37
37
|
import { IMPORT_MAX_PAGES } from "@jxsuite/protocol";
|
|
38
38
|
import type { ImportBreakpointPolicy } from "@jxsuite/protocol";
|
|
39
|
-
import type {
|
|
39
|
+
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
40
|
+
import { parseProjectSource } from "@jxsuite/schema/project-file";
|
|
40
41
|
import { projectFilePath } from "@jxsuite/schema/project-file/node";
|
|
41
42
|
|
|
42
43
|
export interface ImportApiOptions {
|
|
@@ -86,7 +87,7 @@ type ImportStreamLine =
|
|
|
86
87
|
| { type: "heartbeat" }
|
|
87
88
|
| { type: "ready"; root: string }
|
|
88
89
|
| ({ type: "progress" } & Omit<ImportProgressEvent, "root">)
|
|
89
|
-
| { type: "done"; root: string; config:
|
|
90
|
+
| { type: "done"; root: string; config: ProjectConfig; result: ImportDoneSummary }
|
|
90
91
|
| { type: "error"; error: string };
|
|
91
92
|
|
|
92
93
|
/** The AI pass's credentials, resolved from the request headers or the environment. */
|
|
@@ -298,11 +299,10 @@ async function handleImportSite(req: Request, opts: ImportApiOptions): Promise<R
|
|
|
298
299
|
writeLine({ type: "progress", ...progress });
|
|
299
300
|
});
|
|
300
301
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
) as JsonObject;
|
|
302
|
+
// The import's emit phase has just written it; text that does not read throws into the
|
|
303
|
+
// Error line below.
|
|
304
|
+
const configPath = projectFilePath(result.outDir);
|
|
305
|
+
const config = parseProjectSource(await readFile(configPath, "utf8"), configPath);
|
|
306
306
|
/*
|
|
307
307
|
* The result travels with the `done` line. It used to be discarded here: the pipeline had
|
|
308
308
|
* computed the page list, the file count, the warnings and (with verify) a per-page
|
package/src/jx-mounts.ts
CHANGED
|
@@ -16,15 +16,16 @@
|
|
|
16
16
|
* The runtime is cached per project root and invalidated when project.json changes on disk.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
20
|
import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
|
|
21
21
|
import { isString } from "@jxsuite/schema/guards";
|
|
22
22
|
import { projectDevEnv } from "./dev-vars.ts";
|
|
23
23
|
import type { ProjectDevEnv } from "./dev-vars.ts";
|
|
24
24
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
25
25
|
import type { ImplementationExport, MountHandler } from "@jxsuite/schema/format-registry";
|
|
26
|
-
import type { JxDocumentValue
|
|
27
|
-
import {
|
|
26
|
+
import type { JxDocumentValue } from "@jxsuite/schema/types";
|
|
27
|
+
import { parseProjectSource } from "@jxsuite/schema/project-file";
|
|
28
|
+
import { isSameStamp, projectFileStamp } from "@jxsuite/schema/project-file/node";
|
|
28
29
|
|
|
29
30
|
interface MountRuntime {
|
|
30
31
|
handlers: { basePath: string; handler: MountHandler }[];
|
|
@@ -32,7 +33,10 @@ interface MountRuntime {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/** Per-project runtime cache, invalidated when project.json's mtime moves. */
|
|
35
|
-
const runtimeCache = new Map<
|
|
36
|
+
const runtimeCache = new Map<
|
|
37
|
+
string,
|
|
38
|
+
{ mtime: number; path: string; runtime: Promise<MountRuntime | null> }
|
|
39
|
+
>();
|
|
36
40
|
|
|
37
41
|
/** Reset the runtime cache (test hook). */
|
|
38
42
|
export function resetJxMounts(): void {
|
|
@@ -81,35 +85,34 @@ export async function handleJxMounts(
|
|
|
81
85
|
|
|
82
86
|
/** Load (or reuse) the mount runtime for a project root. */
|
|
83
87
|
function getRuntime(projectRoot: string): Promise<MountRuntime | null> {
|
|
84
|
-
const
|
|
85
|
-
if (!
|
|
88
|
+
const stamp = projectFileStamp(projectRoot);
|
|
89
|
+
if (!stamp) {
|
|
86
90
|
return Promise.resolve(null);
|
|
87
91
|
}
|
|
88
|
-
const { mtimeMs } = statSync(projectJsonPath);
|
|
89
92
|
const cached = runtimeCache.get(projectRoot);
|
|
90
|
-
if (cached && cached
|
|
93
|
+
if (cached && isSameStamp(cached, stamp)) {
|
|
91
94
|
return cached.runtime;
|
|
92
95
|
}
|
|
93
|
-
const runtime = buildRuntime(projectRoot,
|
|
96
|
+
const runtime = buildRuntime(projectRoot, stamp.path).catch((error) => {
|
|
94
97
|
console.warn(
|
|
95
98
|
`jx-mounts: failed to build extension mounts for ${projectRoot}:`,
|
|
96
99
|
error instanceof Error ? error.message : String(error),
|
|
97
100
|
);
|
|
98
101
|
return null;
|
|
99
102
|
});
|
|
100
|
-
runtimeCache.set(projectRoot, {
|
|
103
|
+
runtimeCache.set(projectRoot, { ...stamp, runtime });
|
|
101
104
|
return runtime;
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
/** Build the runtime: registry, sections, connector stand-ins, and the ordered mount handlers. */
|
|
105
108
|
async function buildRuntime(
|
|
106
109
|
projectRoot: string,
|
|
107
|
-
|
|
110
|
+
projectFile: string,
|
|
108
111
|
): Promise<MountRuntime | null> {
|
|
109
|
-
/*
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const config =
|
|
112
|
+
/* Trust boundary. The project file is the project's own config, held to its per-project schema
|
|
113
|
+
by the editor and `jx validate`; the sections are only relayed to the mounts that own them,
|
|
114
|
+
and any throw lands in getRuntime's catch, which disables the mounts with a warning. */
|
|
115
|
+
const config = parseProjectSource(readFileSync(projectFile, "utf8"), projectFile);
|
|
113
116
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
114
117
|
const mounts = registry.serverMounts();
|
|
115
118
|
if (mounts.length === 0) {
|
package/src/refactor/scan.ts
CHANGED
|
@@ -7,13 +7,20 @@
|
|
|
7
7
|
* rename that rewrites a file the usage count never looked at is a lie in both directions.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { extname } from "node:path";
|
|
10
|
+
import { basename, extname } from "node:path";
|
|
11
11
|
import { readFile } from "node:fs/promises";
|
|
12
12
|
import type { FormatRegistry, ValueEdit } from "@jxsuite/schema/format-registry";
|
|
13
13
|
import { isJsonObject } from "@jxsuite/schema/guards";
|
|
14
14
|
import { rewriteJson } from "@jxsuite/schema/json-layout";
|
|
15
|
-
import {
|
|
16
|
-
|
|
15
|
+
import {
|
|
16
|
+
classifyJxn,
|
|
17
|
+
isJxnPath,
|
|
18
|
+
parseJxn,
|
|
19
|
+
parseProject,
|
|
20
|
+
serializeJxn,
|
|
21
|
+
serializeProject,
|
|
22
|
+
} from "@jxsuite/schema/jxn";
|
|
23
|
+
import type { JsonValue, JxDocument, ProjectConfig } from "@jxsuite/schema/types";
|
|
17
24
|
|
|
18
25
|
/** Normalise a path to forward slashes (Windows `path` returns backslashes). */
|
|
19
26
|
export const fwd = (p: string) => p.replaceAll("\\", "/");
|
|
@@ -88,6 +95,18 @@ export async function loadDoc(fp: string, registry: FormatRegistry): Promise<Loa
|
|
|
88
95
|
serialize: (doc) => Promise.resolve(rewriteJson(doc, raw)),
|
|
89
96
|
};
|
|
90
97
|
}
|
|
98
|
+
if (basename(fp) === "project.jxn" && classifyJxn(raw) === "project") {
|
|
99
|
+
/* The project file, in its own grammar (`site-architecture.md` §2.3): a rename repairs the
|
|
100
|
+
references it holds, `defaults.layout` among them, and the merge keeps its comments. A page
|
|
101
|
+
named `project.jxn` declares no project, and is read as the document it is below. */
|
|
102
|
+
const config: unknown = parseProject(raw, fp);
|
|
103
|
+
return {
|
|
104
|
+
doc: isJsonObject(config) ? config : null,
|
|
105
|
+
rewrite: null,
|
|
106
|
+
// SAFETY: the configuration this just read, with reference values replaced by reference values.
|
|
107
|
+
serialize: (doc) => Promise.resolve(serializeProject(doc as ProjectConfig, raw)),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
91
110
|
if (isJxnPath(fp)) {
|
|
92
111
|
/* The one format whose writer can be told what it is replacing, so repairing a reference in a
|
|
93
112
|
`.jxn` file changes that reference and leaves its comments where they were. */
|
package/src/resolve.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/** Resolve.js — Generic $src module proxy + timing: "server" function proxy */
|
|
2
2
|
|
|
3
|
+
import { eraseClass } from "@jxsuite/erase";
|
|
3
4
|
import { siteBasePath } from "@jxsuite/schema/asset-paths";
|
|
4
5
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
6
|
import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
|
|
6
7
|
import { isFunction, isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
7
|
-
import { readFileSync
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
8
9
|
import { createRequire } from "node:module";
|
|
9
10
|
import { projectDevEnv } from "./dev-vars.ts";
|
|
10
11
|
import { containedPath } from "./net-guard.ts";
|
|
@@ -25,7 +26,8 @@ import type {
|
|
|
25
26
|
} from "@jxsuite/schema/types";
|
|
26
27
|
import type { ProjectDevEnv } from "./dev-vars.ts";
|
|
27
28
|
import { problem } from "./problem.ts";
|
|
28
|
-
import {
|
|
29
|
+
import { parseProjectSource } from "@jxsuite/schema/project-file";
|
|
30
|
+
import { isSameStamp, projectFileStamp } from "@jxsuite/schema/project-file/node";
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Any value a module exports, as the proxies see it before they check it. Nothing about an export
|
|
@@ -194,7 +196,10 @@ function baseDirectory($base: string, root: string, projectRoot: string): string
|
|
|
194
196
|
* seconds for data it never reads. Both together put a component fetch past the canvas's register
|
|
195
197
|
* timeout on every cold open.
|
|
196
198
|
*/
|
|
197
|
-
const projectEntryCache = new Map<
|
|
199
|
+
const projectEntryCache = new Map<
|
|
200
|
+
string,
|
|
201
|
+
{ mtime: number; path: string; entry: Promise<ProjectEntry | null> }
|
|
202
|
+
>();
|
|
198
203
|
|
|
199
204
|
/**
|
|
200
205
|
* Lazy-load project context (project.json + extension-loaded sections) for class instantiation.
|
|
@@ -256,31 +261,30 @@ async function completeSections(context: ProjectContext): Promise<void> {
|
|
|
256
261
|
|
|
257
262
|
/** Load (or reuse) the cached project config, extension registry and asset mounts. */
|
|
258
263
|
async function loadProjectEntry(projectRoot: string): Promise<ProjectEntry | null> {
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
if (mtime === undefined) {
|
|
264
|
+
const stamp = projectFileStamp(projectRoot);
|
|
265
|
+
if (!stamp) {
|
|
262
266
|
return null;
|
|
263
267
|
}
|
|
264
268
|
const cached = projectEntryCache.get(projectRoot);
|
|
265
|
-
if (cached && cached
|
|
269
|
+
if (cached && isSameStamp(cached, stamp)) {
|
|
266
270
|
return cached.entry;
|
|
267
271
|
}
|
|
268
|
-
const entry = readProjectEntry(projectRoot,
|
|
269
|
-
projectEntryCache.set(projectRoot, {
|
|
272
|
+
const entry = readProjectEntry(projectRoot, stamp.path, stamp.mtime);
|
|
273
|
+
projectEntryCache.set(projectRoot, { ...stamp, entry });
|
|
270
274
|
return entry;
|
|
271
275
|
}
|
|
272
276
|
|
|
273
|
-
/** Read one
|
|
277
|
+
/** Read one project file generation. Never rejects: any failure answers "no project context". */
|
|
274
278
|
async function readProjectEntry(
|
|
275
279
|
projectRoot: string,
|
|
276
|
-
|
|
280
|
+
projectFile: string,
|
|
277
281
|
mtime: number,
|
|
278
282
|
): Promise<ProjectEntry | null> {
|
|
279
283
|
try {
|
|
280
|
-
/*
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const config =
|
|
284
|
+
/* Trust boundary. The project file is the project's own config, which the editor and
|
|
285
|
+
`jx validate` hold to its per-project schema. Any failure reading it below is caught here
|
|
286
|
+
and answers "no project context". */
|
|
287
|
+
const config = parseProjectSource(readFileSync(projectFile, "utf8"), projectFile);
|
|
284
288
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
285
289
|
const { mounts } = await loadAssetMounts(registry, config, projectRoot);
|
|
286
290
|
return { config, mounts, mtime, registry };
|
|
@@ -591,7 +595,9 @@ function isCtorDef(
|
|
|
591
595
|
*
|
|
592
596
|
* @param {JxClassDef} classDef
|
|
593
597
|
*/
|
|
594
|
-
function classFromSchema(
|
|
598
|
+
function classFromSchema(authored: JxClassDef) {
|
|
599
|
+
// Member bodies as JavaScript before anything compiles them (spec.md §23.1).
|
|
600
|
+
const classDef = eraseClass(authored, authored.title ?? "a class");
|
|
595
601
|
const fields = classDef.$defs?.fields ?? {};
|
|
596
602
|
const rawCtor = classDef.$defs?.constructor;
|
|
597
603
|
const ctor = isCtorDef(rawCtor) ? rawCtor : undefined;
|
package/src/server.ts
CHANGED
|
@@ -42,6 +42,8 @@ import { handleCodeApi } from "./code-api.ts";
|
|
|
42
42
|
import { handleAiApi } from "./ai-api.ts";
|
|
43
43
|
import { handleImportApi } from "./import-api.ts";
|
|
44
44
|
import { mediaTypeForPath } from "@jxsuite/schema/media-type";
|
|
45
|
+
import { isJxnPath } from "@jxsuite/schema/jxn";
|
|
46
|
+
import { jxnAsJson } from "@jxsuite/site/serve";
|
|
45
47
|
import { problem } from "./problem.ts";
|
|
46
48
|
|
|
47
49
|
import { resolveNpmPath } from "./npm-path.ts";
|
|
@@ -175,7 +177,15 @@ export async function createDevServer(options: {
|
|
|
175
177
|
* implementations to stop using. `mediaTypeForPath` answers null everywhere else, so the
|
|
176
178
|
* inference stays in charge of the ordinary cases.
|
|
177
179
|
*/
|
|
178
|
-
const fileResponse = (file: ReturnType<typeof Bun.file>) => {
|
|
180
|
+
const fileResponse = async (file: ReturnType<typeof Bun.file>) => {
|
|
181
|
+
if (file.name !== undefined && isJxnPath(file.name)) {
|
|
182
|
+
/* The canvas runtime fetches a `$ref` and reads it as JSON, as a site origin answers it
|
|
183
|
+
(`@jxsuite/site` `serve.ts`); Studio reads a file's text through `/__studio/file`. */
|
|
184
|
+
return jxnAsJson(await file.bytes(), file.name, (type) => ({
|
|
185
|
+
...NO_CACHE,
|
|
186
|
+
"Content-Type": type,
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
179
189
|
const corrected = file.name === undefined ? null : mediaTypeForPath(file.name);
|
|
180
190
|
return new Response(file, {
|
|
181
191
|
headers: corrected === null ? NO_CACHE : { ...NO_CACHE, "Content-Type": corrected },
|
package/src/site-output.ts
CHANGED
|
@@ -30,13 +30,13 @@
|
|
|
30
30
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
31
31
|
import { join, resolve } from "node:path";
|
|
32
32
|
import { isJsonObject, isString } from "@jxsuite/schema/guards";
|
|
33
|
-
import type { JsonValue } from "@jxsuite/schema/types";
|
|
34
33
|
|
|
35
34
|
import { serveContained } from "./net-guard.ts";
|
|
36
|
-
import {
|
|
35
|
+
import { parseProjectSource } from "@jxsuite/schema/project-file";
|
|
36
|
+
import { isSameStamp, projectFileStamp } from "@jxsuite/schema/project-file/node";
|
|
37
37
|
|
|
38
38
|
/** What `project.json` says the build writes to, cached per root against its mtime. */
|
|
39
|
-
const outDirCache = new Map<string, { mtime: number; outDir: string | null }>();
|
|
39
|
+
const outDirCache = new Map<string, { mtime: number; outDir: string | null; path: string }>();
|
|
40
40
|
|
|
41
41
|
/** Reset the output-directory cache (test hook). */
|
|
42
42
|
export function resetSiteOutput(): void {
|
|
@@ -52,32 +52,27 @@ export function resetSiteOutput(): void {
|
|
|
52
52
|
* file read.
|
|
53
53
|
*/
|
|
54
54
|
export function siteOutDir(projectRoot: string): string | null {
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// Both project files is no project until the editor's report is acted on, so this declines.
|
|
56
|
+
const stamp = projectFileStamp(projectRoot);
|
|
57
|
+
if (!stamp) {
|
|
57
58
|
return null;
|
|
58
59
|
}
|
|
59
|
-
const { mtimeMs } = statSync(configPath);
|
|
60
60
|
const cached = outDirCache.get(projectRoot);
|
|
61
|
-
if (cached && cached
|
|
61
|
+
if (cached && isSameStamp(cached, stamp)) {
|
|
62
62
|
return cached.outDir;
|
|
63
63
|
}
|
|
64
64
|
let outDir: string | null = null;
|
|
65
65
|
try {
|
|
66
|
-
|
|
67
|
-
const config = JSON.parse(readFileSync(configPath, "utf8")) as JsonValue;
|
|
66
|
+
const config: unknown = parseProjectSource(readFileSync(stamp.path, "utf8"), stamp.path);
|
|
68
67
|
const build = isJsonObject(config) ? config.build : undefined;
|
|
69
68
|
const declared = isJsonObject(build) ? build.outDir : undefined;
|
|
70
|
-
|
|
71
|
-
outDir =
|
|
72
|
-
config === null
|
|
73
|
-
? null
|
|
74
|
-
: resolve(projectRoot, isString(declared) && declared ? declared : "dist");
|
|
69
|
+
outDir = resolve(projectRoot, isString(declared) && declared ? declared : "dist");
|
|
75
70
|
} catch {
|
|
76
|
-
// An unparseable project
|
|
71
|
+
// An unparseable project file is the editor's problem to report, not this server's to guess
|
|
77
72
|
// At. No output directory means this fallback simply declines, and the request 404s as before.
|
|
78
73
|
outDir = null;
|
|
79
74
|
}
|
|
80
|
-
outDirCache.set(projectRoot, {
|
|
75
|
+
outDirCache.set(projectRoot, { ...stamp, outDir });
|
|
81
76
|
return outDir;
|
|
82
77
|
}
|
|
83
78
|
|
package/src/studio-api.ts
CHANGED
|
@@ -48,11 +48,20 @@ import type { JsonObject, JsonPrimitive, JsonValue, ProjectConfig } from "@jxsui
|
|
|
48
48
|
import { isFunction, isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
49
49
|
import { problem } from "./problem.ts";
|
|
50
50
|
import { scanFiles } from "./fs-glob.ts";
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
PROJECT_FILE_NAMES,
|
|
53
|
+
classifyProjectFile,
|
|
54
|
+
parseProjectFile,
|
|
55
|
+
parseProjectSource,
|
|
56
|
+
projectFileConflict,
|
|
57
|
+
} from "@jxsuite/schema/project-file";
|
|
52
58
|
import {
|
|
53
59
|
findEnclosingProjectOnDisk,
|
|
54
60
|
hasProjectFile,
|
|
61
|
+
isSameStamp,
|
|
55
62
|
projectFilePath,
|
|
63
|
+
projectFileStamp,
|
|
64
|
+
readProjectFile,
|
|
56
65
|
} from "@jxsuite/schema/project-file/node";
|
|
57
66
|
|
|
58
67
|
/** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
|
|
@@ -102,11 +111,11 @@ interface Cem {
|
|
|
102
111
|
modules?: CemModule[];
|
|
103
112
|
}
|
|
104
113
|
|
|
105
|
-
// ─── Extension registry (per project root, invalidated on project
|
|
114
|
+
// ─── Extension registry (per project root, invalidated on a project file change) ──
|
|
106
115
|
|
|
107
116
|
const extensionRegistryCache = new Map<
|
|
108
117
|
string,
|
|
109
|
-
{ mtime: number; missing: string[]; registry: ExtensionRegistry }
|
|
118
|
+
{ mtime: number; missing: string[]; path: string; registry: ExtensionRegistry }
|
|
110
119
|
>();
|
|
111
120
|
|
|
112
121
|
/**
|
|
@@ -121,26 +130,23 @@ const extensionRegistryCache = new Map<
|
|
|
121
130
|
* @returns {Promise<ExtensionRegistry>}
|
|
122
131
|
*/
|
|
123
132
|
async function getExtensionRegistry(projectRoot: string): Promise<ExtensionRegistry> {
|
|
124
|
-
const
|
|
125
|
-
let mtime = 0;
|
|
133
|
+
const stamp = projectFileStamp(projectRoot) ?? { mtime: 0, path: "" };
|
|
126
134
|
let projectConfig: ProjectConfig | undefined;
|
|
127
135
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
that list itself; every other ProjectConfig member is optional and unread on this path. */
|
|
131
|
-
projectConfig = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
|
|
136
|
+
// The registry reads only `projectConfig?.extensions`, and checks that list itself.
|
|
137
|
+
projectConfig = parseProjectSource(readFileSync(stamp.path, "utf8"), stamp.path);
|
|
132
138
|
} catch {
|
|
133
139
|
projectConfig = undefined;
|
|
134
140
|
}
|
|
135
141
|
const cached = extensionRegistryCache.get(projectRoot);
|
|
136
|
-
if (cached && cached
|
|
142
|
+
if (cached && isSameStamp(cached, stamp) && !anyNowInstalled(projectRoot, cached.missing)) {
|
|
137
143
|
return cached.registry;
|
|
138
144
|
}
|
|
139
145
|
const { missing, registry } = await buildInstalledExtensionRegistry(projectRoot, projectConfig);
|
|
140
146
|
if (missing.length > 0 && missing.join("\n") !== cached?.missing.join("\n")) {
|
|
141
147
|
console.warn(`[studio] ${missingExtensionsNotice(missing)}`);
|
|
142
148
|
}
|
|
143
|
-
extensionRegistryCache.set(projectRoot, {
|
|
149
|
+
extensionRegistryCache.set(projectRoot, { ...stamp, missing, registry });
|
|
144
150
|
return registry;
|
|
145
151
|
}
|
|
146
152
|
|
|
@@ -515,22 +521,26 @@ export async function handleStudioApi(
|
|
|
515
521
|
|
|
516
522
|
let isSiteProject = false;
|
|
517
523
|
let projectConfig: ProjectConfig | null = null;
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
524
|
+
let projectProblem: string | null = null;
|
|
525
|
+
const read = readProjectFile(absDir);
|
|
526
|
+
const verdict = read ? classifyProjectFile(read.text, read.path) : null;
|
|
527
|
+
if (read && "config" in read && verdict === "project") {
|
|
528
|
+
isSiteProject = true;
|
|
529
|
+
// Relayed to the client here, never interpreted.
|
|
530
|
+
projectConfig = read.config;
|
|
531
|
+
} else if (read && "error" in read && verdict !== "other" && read.text !== "") {
|
|
532
|
+
/* A file that does not read is believed, as the walk believes it (desktop.md §4.6), and
|
|
533
|
+
opened with no configuration so the client can show it as text. A folder holding both
|
|
534
|
+
files has no text to show, and stays what it was. */
|
|
535
|
+
isSiteProject = true;
|
|
536
|
+
projectProblem = read.error;
|
|
537
|
+
}
|
|
529
538
|
|
|
530
539
|
return Response.json({
|
|
531
540
|
directories,
|
|
532
541
|
isSiteProject,
|
|
533
542
|
projectConfig,
|
|
543
|
+
...(projectProblem !== null && { projectProblem }),
|
|
534
544
|
projectRoot,
|
|
535
545
|
});
|
|
536
546
|
} catch (error) {
|
|
@@ -548,27 +558,27 @@ export async function handleStudioApi(
|
|
|
548
558
|
// Walk up looking for project.json. Accept a directory (e.g. the project root itself, as
|
|
549
559
|
// Deep links like ?project=/abs/my-site pass) by starting the walk AT the directory and
|
|
550
560
|
// Treating its project.json as the target file, so callers land on the home page.
|
|
551
|
-
|
|
552
|
-
let dir: string;
|
|
561
|
+
const absFile = expandTilde(filePath);
|
|
553
562
|
let isDir = false;
|
|
554
563
|
try {
|
|
555
564
|
isDir = statSync(absFile).isDirectory();
|
|
556
565
|
} catch {}
|
|
557
|
-
if (isDir) {
|
|
558
|
-
dir = absFile;
|
|
559
|
-
absFile = projectFilePath(absFile);
|
|
560
|
-
} else {
|
|
561
|
-
dir = dirname(absFile);
|
|
562
|
-
}
|
|
563
566
|
// A page named project.json is not a project, and neither is Nx's file of that name.
|
|
564
|
-
const found = findEnclosingProjectOnDisk(
|
|
567
|
+
const found = findEnclosingProjectOnDisk(isDir ? absFile : dirname(absFile));
|
|
568
|
+
if (found?.verdict === "conflict") {
|
|
569
|
+
return problem("conflict", projectFileConflict(found.root));
|
|
570
|
+
}
|
|
565
571
|
if (found) {
|
|
566
|
-
/*
|
|
567
|
-
|
|
568
|
-
|
|
572
|
+
/* Relayed to the client and not interpreted here. A file that does not read is still the
|
|
573
|
+
project's (site-architecture.md §2.3): the client opens it with no configuration, says
|
|
574
|
+
where the file fails and shows it as text, rather than finding no project at all. */
|
|
575
|
+
const read = parseProjectFile(found.projectFile, readFileSync(found.projectFile, "utf8"));
|
|
576
|
+
// A folder is asked for as its project file, which the client reads as "go home".
|
|
577
|
+
const asked = isDir ? join(absFile, basename(found.projectFile)) : absFile;
|
|
569
578
|
return Response.json({
|
|
570
|
-
fileRelPath: fwd(relative(found.root,
|
|
571
|
-
projectConfig: config,
|
|
579
|
+
fileRelPath: fwd(relative(found.root, asked)),
|
|
580
|
+
projectConfig: "config" in read ? read.config : null,
|
|
581
|
+
...("error" in read && { projectProblem: read.error }),
|
|
572
582
|
relPath: fwd(found.root),
|
|
573
583
|
sitePath: found.root,
|
|
574
584
|
});
|
|
@@ -582,8 +592,8 @@ export async function handleStudioApi(
|
|
|
582
592
|
// Discover site projects — find all project.json files under root
|
|
583
593
|
if (path === "/__studio/sites" && req.method === "GET") {
|
|
584
594
|
try {
|
|
585
|
-
const sites = [];
|
|
586
|
-
for await (const match of scanFiles(
|
|
595
|
+
const sites: { config: ProjectConfig; path: string; problem?: string }[] = [];
|
|
596
|
+
for await (const match of scanFiles(`**/{${PROJECT_FILE_NAMES.join(",")}}`, root)) {
|
|
587
597
|
if (
|
|
588
598
|
match.includes("node_modules") ||
|
|
589
599
|
fwd(match).includes("dist/") ||
|
|
@@ -594,13 +604,17 @@ export async function handleStudioApi(
|
|
|
594
604
|
const fp = resolve(root, match);
|
|
595
605
|
try {
|
|
596
606
|
const text = await readFile(fp, "utf8");
|
|
597
|
-
|
|
598
|
-
const raw = JSON.parse(text) as JsonValue;
|
|
599
|
-
if (classifyProjectFile(text) === "project" && isJsonObject(raw)) {
|
|
607
|
+
if (classifyProjectFile(text, fp) === "project") {
|
|
600
608
|
const projectDir = fwd(dirname(fp));
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
sites.
|
|
609
|
+
// The site list only relays the config to the client.
|
|
610
|
+
const config = parseProjectSource(text, fp);
|
|
611
|
+
const twin = sites.find((site) => site.path === projectDir);
|
|
612
|
+
if (twin) {
|
|
613
|
+
// Neither file is the project's, so the list says so rather than choosing.
|
|
614
|
+
twin.problem = projectFileConflict(projectDir);
|
|
615
|
+
} else {
|
|
616
|
+
sites.push({ config, path: projectDir });
|
|
617
|
+
}
|
|
604
618
|
}
|
|
605
619
|
} catch {}
|
|
606
620
|
}
|
|
@@ -690,9 +704,9 @@ export async function handleStudioApi(
|
|
|
690
704
|
}
|
|
691
705
|
await generateProject(destPath, projectOptions);
|
|
692
706
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
const config =
|
|
707
|
+
// It was just written by generateProject, and is relayed to the client, not interpreted.
|
|
708
|
+
const configPath = projectFilePath(destPath);
|
|
709
|
+
const config = parseProjectSource(await readFile(configPath, "utf8"), configPath);
|
|
696
710
|
// Root-relative while the project happens to sit under the server root (the historical
|
|
697
711
|
// Shape every caller already handles); absolute otherwise.
|
|
698
712
|
const inRoot = containedPath(destPath, root) !== null;
|