@jxsuite/server 8.4.0 → 8.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "8.4.0",
3
+ "version": "8.5.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,17 @@
55
55
  "upgrade": "bun update --latest && bun install"
56
56
  },
57
57
  "dependencies": {
58
- "@jxsuite/ai": "^0.43.1",
59
- "@jxsuite/catalog": "^0.1.9",
60
- "@jxsuite/collab": "^0.11.7",
61
- "@jxsuite/compiler": "^8.3.0",
62
- "@jxsuite/create": "^1.5.5",
63
- "@jxsuite/import": "^0.42.5",
64
- "@jxsuite/protocol": "^6.2.3",
65
- "@jxsuite/runtime": "^6.2.0",
66
- "@jxsuite/schema": "^5.3.0",
67
- "@jxsuite/site": "^2.4.0",
68
- "@jxsuite/starters": "^3.3.1",
58
+ "@jxsuite/ai": "^0.43.2",
59
+ "@jxsuite/catalog": "^0.1.10",
60
+ "@jxsuite/collab": "^0.11.8",
61
+ "@jxsuite/compiler": "^8.4.0",
62
+ "@jxsuite/create": "^1.6.0",
63
+ "@jxsuite/import": "^0.43.0",
64
+ "@jxsuite/protocol": "^6.2.4",
65
+ "@jxsuite/runtime": "^6.2.1",
66
+ "@jxsuite/schema": "^5.4.0",
67
+ "@jxsuite/site": "^2.5.0",
68
+ "@jxsuite/starters": "^3.3.2",
69
69
  "@npmcli/arborist": "^10.0.4",
70
70
  "chokidar": "^6.0.0",
71
71
  "kysely": "^0.29.8",
@@ -75,9 +75,9 @@
75
75
  "zod": "^4.6.7"
76
76
  },
77
77
  "devDependencies": {
78
- "@jxsuite/auth": "^0.8.5",
79
- "@jxsuite/connector": "^0.8.5",
80
- "@jxsuite/parser": "^4.3.1",
78
+ "@jxsuite/auth": "^0.8.6",
79
+ "@jxsuite/connector": "^0.8.6",
80
+ "@jxsuite/parser": "^4.3.2",
81
81
  "@jxsuite/test-support": "^0.0.0",
82
82
  "@types/npm-package-arg": "^6.1.4",
83
83
  "@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. This runs under
6
- * Bun for the same reason `jx dev` does, so `@jxsuite/compiler`'s CLI resolves it from the project
7
- * and spawns it.
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
- if (ext === ".jxn" && !options.dropComments) {
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
- const rows = await convertDocuments(args.root, args.paths, args);
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, statSync } from "node:fs";
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 { projectFilePath, readProjectFile } from "@jxsuite/schema/project-file/node";
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.json's mtime moves. */
130
- const registryCache = new Map<string, { mtime: number; registry: Promise<ExtensionRegistry> }>();
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.json. */
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.json under "${projectRoot}"`);
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.json under "${projectRoot}" does not parse: ${read.error}`,
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.json's mtime. */
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 projectJsonPath = projectFilePath(projectRoot);
164
- const mtime = statSync(projectJsonPath).mtimeMs;
166
+ const stamp = projectFileStamp(projectRoot);
165
167
  const cached = registryCache.get(projectRoot);
166
- if (cached && cached.mtime === mtime) {
168
+ if (stamp && cached && isSameStamp(cached, stamp)) {
167
169
  return cached.registry;
168
170
  }
169
171
  const registry = buildProjectExtensionRegistry(projectRoot, config);
170
- registryCache.set(projectRoot, { mtime, registry });
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 { JsonObject } from "@jxsuite/schema/types";
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: JsonObject; result: ImportDoneSummary }
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
- /* SAFETY: importSite's emit phase has just written project.json as a Jx project config,
302
- which is a JSON object; text that is not JSON throws into the error line below. */
303
- const config = JSON.parse(
304
- await readFile(projectFilePath(result.outDir), "utf8"),
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 { existsSync, readFileSync, statSync } from "node:fs";
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, ProjectConfig } from "@jxsuite/schema/types";
27
- import { projectFilePath } from "@jxsuite/schema/project-file/node";
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<string, { mtime: number; runtime: Promise<MountRuntime | null> }>();
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 projectJsonPath = projectFilePath(projectRoot);
85
- if (!existsSync(projectJsonPath)) {
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.mtime === mtimeMs) {
93
+ if (cached && isSameStamp(cached, stamp)) {
91
94
  return cached.runtime;
92
95
  }
93
- const runtime = buildRuntime(projectRoot, projectJsonPath).catch((error) => {
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, { mtime: mtimeMs, runtime });
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
- projectJsonPath: string,
110
+ projectFile: string,
108
111
  ): Promise<MountRuntime | null> {
109
- /* SAFETY: trust boundary. project.json is the project's own config, held to its per-project
110
- schema by the editor and `jx validate`; the sections are only relayed to the mounts that own
111
- them, and any throw lands in getRuntime's catch, which disables the mounts with a warning. */
112
- const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
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) {
@@ -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 { isJxnPath, parseJxn, serializeJxn } from "@jxsuite/schema/jxn";
16
- import type { JsonValue, JxDocument } from "@jxsuite/schema/types";
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
@@ -4,7 +4,7 @@ import { siteBasePath } from "@jxsuite/schema/asset-paths";
4
4
  import { dirname, isAbsolute, relative, resolve } from "node:path";
5
5
  import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
6
6
  import { isFunction, isNonNullObject, isString } from "@jxsuite/schema/guards";
7
- import { readFileSync, statSync } from "node:fs";
7
+ import { readFileSync } from "node:fs";
8
8
  import { createRequire } from "node:module";
9
9
  import { projectDevEnv } from "./dev-vars.ts";
10
10
  import { containedPath } from "./net-guard.ts";
@@ -25,7 +25,8 @@ import type {
25
25
  } from "@jxsuite/schema/types";
26
26
  import type { ProjectDevEnv } from "./dev-vars.ts";
27
27
  import { problem } from "./problem.ts";
28
- import { projectFilePath } from "@jxsuite/schema/project-file/node";
28
+ import { parseProjectSource } from "@jxsuite/schema/project-file";
29
+ import { isSameStamp, projectFileStamp } from "@jxsuite/schema/project-file/node";
29
30
 
30
31
  /**
31
32
  * Any value a module exports, as the proxies see it before they check it. Nothing about an export
@@ -194,7 +195,10 @@ function baseDirectory($base: string, root: string, projectRoot: string): string
194
195
  * seconds for data it never reads. Both together put a component fetch past the canvas's register
195
196
  * timeout on every cold open.
196
197
  */
197
- const projectEntryCache = new Map<string, { mtime: number; entry: Promise<ProjectEntry | null> }>();
198
+ const projectEntryCache = new Map<
199
+ string,
200
+ { mtime: number; path: string; entry: Promise<ProjectEntry | null> }
201
+ >();
198
202
 
199
203
  /**
200
204
  * Lazy-load project context (project.json + extension-loaded sections) for class instantiation.
@@ -256,31 +260,30 @@ async function completeSections(context: ProjectContext): Promise<void> {
256
260
 
257
261
  /** Load (or reuse) the cached project config, extension registry and asset mounts. */
258
262
  async function loadProjectEntry(projectRoot: string): Promise<ProjectEntry | null> {
259
- const projectJsonPath = projectFilePath(projectRoot);
260
- const mtime = statSync(projectJsonPath, { throwIfNoEntry: false })?.mtimeMs;
261
- if (mtime === undefined) {
263
+ const stamp = projectFileStamp(projectRoot);
264
+ if (!stamp) {
262
265
  return null;
263
266
  }
264
267
  const cached = projectEntryCache.get(projectRoot);
265
- if (cached && cached.mtime === mtime) {
268
+ if (cached && isSameStamp(cached, stamp)) {
266
269
  return cached.entry;
267
270
  }
268
- const entry = readProjectEntry(projectRoot, projectJsonPath, mtime);
269
- projectEntryCache.set(projectRoot, { entry, mtime });
271
+ const entry = readProjectEntry(projectRoot, stamp.path, stamp.mtime);
272
+ projectEntryCache.set(projectRoot, { ...stamp, entry });
270
273
  return entry;
271
274
  }
272
275
 
273
- /** Read one `project.json` generation. Never rejects: any failure answers "no project context". */
276
+ /** Read one project file generation. Never rejects: any failure answers "no project context". */
274
277
  async function readProjectEntry(
275
278
  projectRoot: string,
276
- projectJsonPath: string,
279
+ projectFile: string,
277
280
  mtime: number,
278
281
  ): Promise<ProjectEntry | null> {
279
282
  try {
280
- /* SAFETY: trust boundary. project.json is the project's own config file, which the editor
281
- and `jx validate` hold to its per-project schema. Any failure reading it below is caught
282
- here and answers "no project context". */
283
- const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
283
+ /* Trust boundary. The project file is the project's own config, which the editor and
284
+ `jx validate` hold to its per-project schema. Any failure reading it below is caught here
285
+ and answers "no project context". */
286
+ const config = parseProjectSource(readFileSync(projectFile, "utf8"), projectFile);
284
287
  const registry = await buildProjectExtensionRegistry(projectRoot, config);
285
288
  const { mounts } = await loadAssetMounts(registry, config, projectRoot);
286
289
  return { config, mounts, mtime, registry };
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 },
@@ -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 { projectFilePath } from "@jxsuite/schema/project-file/node";
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
- const configPath = projectFilePath(projectRoot);
56
- if (!existsSync(configPath)) {
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.mtime === mtimeMs) {
61
+ if (cached && isSameStamp(cached, stamp)) {
62
62
  return cached.outDir;
63
63
  }
64
64
  let outDir: string | null = null;
65
65
  try {
66
- // SAFETY: JSON.parse without a reviver only ever yields a JSON value; its `any` says no more.
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
- // A `null` document has no settings to read at all, so it declines like an unparseable one.
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.json is the editor's problem to report, not this server's to guess
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, { mtime: mtimeMs, outDir });
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 { PROJECT_FILE_NAME, classifyProjectFile } from "@jxsuite/schema/project-file";
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.json change) ──
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 projectJsonPath = projectFilePath(projectRoot);
125
- let mtime = 0;
133
+ const stamp = projectFileStamp(projectRoot) ?? { mtime: 0, path: "" };
126
134
  let projectConfig: ProjectConfig | undefined;
127
135
  try {
128
- mtime = statSync(projectJsonPath).mtimeMs;
129
- /* SAFETY: buildProjectExtensionRegistry reads only `projectConfig?.extensions` and validates
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.mtime === mtime && !anyNowInstalled(projectRoot, cached.missing)) {
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, { missing, mtime, registry });
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
- try {
519
- const text = await readFile(projectFilePath(absDir), "utf8");
520
- // SAFETY: JSON.parse without a reviver only ever produces JSON values.
521
- const raw = JSON.parse(text) as JsonValue;
522
- if (classifyProjectFile(text) === "project" && isJsonObject(raw)) {
523
- isSiteProject = true;
524
- /* SAFETY: `raw` is a JSON object and every ProjectConfig member is optional; the config
525
- is only relayed to the client here, never interpreted. */
526
- projectConfig = raw as ProjectConfig;
527
- }
528
- } catch {}
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
- let absFile = expandTilde(filePath);
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(dir);
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
- /* SAFETY: the config is relayed to the client verbatim and not interpreted here, so its
567
- member types are the client's to check; malformed JSON throws into the catch below. */
568
- const config = JSON.parse(readFileSync(found.projectFile, "utf8")) as ProjectConfig;
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, absFile)),
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(`**/${PROJECT_FILE_NAME}`, root)) {
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
- // SAFETY: JSON.parse without a reviver only ever produces JSON values.
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
- /* SAFETY: `raw` is a JSON object and every ProjectConfig member is optional; the site
602
- list only relays the config to the client. */
603
- sites.push({ config: raw as ProjectConfig, path: projectDir });
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
- /* SAFETY: generateProject just wrote this project.json from its own template, so it is a
694
- project config; it is relayed to the client, not interpreted here. */
695
- const config = JSON.parse(await readFile(projectFilePath(destPath), "utf8")) as ProjectConfig;
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;