@jxsuite/server 4.1.0 → 4.2.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": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,6 +18,7 @@
18
18
  "./ai-api": "./src/ai-api.ts",
19
19
  "./data": "./src/data-api.ts",
20
20
  "./import-api": "./src/import-api.ts",
21
+ "./catalog": "./src/extension-catalog.ts",
21
22
  "./packages": "./src/packages.ts",
22
23
  "./project-server": "./src/project-server.ts",
23
24
  "./oauth-loopback": "./src/oauth-loopback.ts",
@@ -39,22 +40,23 @@
39
40
  "upgrade": "bun update --latest && bun install"
40
41
  },
41
42
  "dependencies": {
42
- "@jxsuite/collab": "^0.8.5",
43
- "@jxsuite/compiler": "^3.1.0",
44
- "@jxsuite/create": "^1.3.9",
45
- "@jxsuite/import": "^0.40.1",
46
- "@jxsuite/protocol": "^2.2.0",
47
- "@jxsuite/runtime": "^3.1.0",
48
- "@jxsuite/schema": "^2.1.0",
49
- "@jxsuite/site": "^1.1.0",
50
- "@jxsuite/starters": "^1.7.0",
43
+ "@jxsuite/catalog": "^0.1.0",
44
+ "@jxsuite/collab": "^0.9.0",
45
+ "@jxsuite/compiler": "^4.0.0",
46
+ "@jxsuite/create": "^1.3.10",
47
+ "@jxsuite/import": "^0.40.2",
48
+ "@jxsuite/protocol": "^2.3.0",
49
+ "@jxsuite/runtime": "^4.0.0",
50
+ "@jxsuite/schema": "^2.2.0",
51
+ "@jxsuite/site": "^2.0.0",
52
+ "@jxsuite/starters": "^1.8.0",
51
53
  "chokidar": "^5.0.0",
52
54
  "kysely": "^0.29.5",
53
- "zod": "^4.5.4"
55
+ "zod": "^4.6.4"
54
56
  },
55
57
  "devDependencies": {
56
- "@jxsuite/auth": "^0.5.7",
57
- "@jxsuite/connector": "^0.5.7",
58
- "@jxsuite/parser": "^1.7.0"
58
+ "@jxsuite/auth": "^0.6.0",
59
+ "@jxsuite/connector": "^0.6.0",
60
+ "@jxsuite/parser": "^1.8.0"
59
61
  }
60
62
  }
package/src/ai-api.ts CHANGED
@@ -38,7 +38,14 @@ interface ToolCallDelta {
38
38
  interface OpenAIStreamChunk {
39
39
  choices?: {
40
40
  index?: number;
41
- delta?: { content?: string; tool_calls?: ToolCallDelta[] };
41
+ delta?: {
42
+ content?: string;
43
+ /** Thinking models' chain-of-thought: DeepSeek/Volcengine spell it this way… */
44
+ reasoning_content?: string;
45
+ /** …and OpenRouter this way. Both carry the same thing. */
46
+ reasoning?: string;
47
+ tool_calls?: ToolCallDelta[];
48
+ };
42
49
  finish_reason?: string | null;
43
50
  }[];
44
51
  }
@@ -350,6 +357,15 @@ export async function handleChat(req: Request): Promise<Response> {
350
357
  writeSSE(controller, { type: "delta", content: delta.content });
351
358
  }
352
359
 
360
+ /* Chain-of-thought, under either of the two names providers give it. Forwarded rather
361
+ than dropped: DeepSeek's thinking mode requires every prior turn's reasoning back on
362
+ any request carrying `tools`, so a proxy that swallows these frames makes the NEXT
363
+ round a 400 the client cannot repair. */
364
+ const reasoning = delta.reasoning_content ?? delta.reasoning;
365
+ if (typeof reasoning === "string" && reasoning) {
366
+ writeSSE(controller, { type: "reasoning", content: reasoning });
367
+ }
368
+
353
369
  // Tool calls
354
370
  if (delta.tool_calls) {
355
371
  for (const tc of delta.tool_calls) {
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Catalog — what THIS backend can offer a project (specs/extensions.md §9.2).
3
+ *
4
+ * The available half of the pair whose enabled half is `buildExtensionsPayload`. Two sources, and
5
+ * the split is the point:
6
+ *
7
+ * 1. The shipped first-party catalogue (`@jxsuite/catalog`), which is data generated from the
8
+ * `extensions/` tree, so core keeps no dependency on an extension package (§2).
9
+ * 2. The project's own dependencies, scanned for anything exporting a `jx-extension.json`.
10
+ *
11
+ * Both are then annotated with two facts only a backend holds: whether the PROJECT resolves the
12
+ * package, and whether THIS HOST does. Those are probed rather than declared, because what a
13
+ * desktop build stages and what a Worker bundles are different facts and neither is knowable from a
14
+ * package list — which is also why the catalogue is a platform capability instead of a constant.
15
+ *
16
+ * **`installed` is answered here rather than by the client**, because `listPackages` does not mean
17
+ * one thing across backends: the dev server's own packages route drops a declared dependency it
18
+ * cannot resolve under `node_modules`, while desktop's reads the manifest and keeps it. A surface
19
+ * deciding "do I need to install this first?" from that would be right on one backend and wrong on
20
+ * the other.
21
+ *
22
+ * @docs extending/extensions/first-party
23
+ */
24
+
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import { resolve } from "node:path";
27
+ import { listCatalog } from "@jxsuite/catalog";
28
+ import { buildExtensionRegistry } from "@jxsuite/schema/extension-registry";
29
+ import { createNodeFormatIO, probeExtension } from "@jxsuite/compiler/format-host";
30
+ import type { ExtensionCatalogEntry, ExtensionSectionInfo } from "@jxsuite/protocol";
31
+
32
+ /** The well-known manifest filename, mirroring `@jxsuite/schema`'s `EXTENSION_MANIFEST`. */
33
+ const MANIFEST = "jx-extension.json";
34
+
35
+ interface PackageJson {
36
+ dependencies?: Record<string, string>;
37
+ devDependencies?: Record<string, string>;
38
+ jx?: string;
39
+ }
40
+
41
+ /** Read a project's package.json, or null when it has none or it will not parse. */
42
+ function readPackageJson(projectRoot: string): PackageJson | null {
43
+ const path = resolve(projectRoot, "package.json");
44
+ if (!existsSync(path)) {
45
+ return null;
46
+ }
47
+ try {
48
+ return JSON.parse(readFileSync(path, "utf8")) as PackageJson;
49
+ } catch {
50
+ // A project mid-edit should cost the catalogue its discovered half, not the whole response.
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** Every dependency name a project declares, runtime and dev alike. */
56
+ function declaredDependencies(projectRoot: string): string[] {
57
+ const pkg = readPackageJson(projectRoot);
58
+ return pkg === null
59
+ ? []
60
+ : [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})];
61
+ }
62
+
63
+ /**
64
+ * Load one extension through the REGISTRY rather than reading its manifest directly.
65
+ *
66
+ * Discovery and enablement then agree by construction: this is the same loader `project.json` will
67
+ * run, so a package that builds here is one that will build there, and a package that throws here
68
+ * throws with the message that names the fix. `FormatEntry`'s constructor is pure data and never
69
+ * imports an implementation module, so this stays JSON reads.
70
+ *
71
+ * @param {string} projectRoot
72
+ * @param {string} name - Bare package specifier
73
+ * @returns {Promise<{ sections; formats; title?; description? } | { problem: string }>}
74
+ */
75
+ async function loadEntry(
76
+ projectRoot: string,
77
+ name: string,
78
+ ): Promise<
79
+ | { description?: string; formats: string[]; sections: ExtensionSectionInfo[]; title?: string }
80
+ | { problem: string }
81
+ > {
82
+ try {
83
+ const registry = await buildExtensionRegistry(
84
+ [name],
85
+ createNodeFormatIO(projectRoot),
86
+ resolve(projectRoot, "project.json"),
87
+ );
88
+ const [info] = registry.extensions;
89
+ if (!info) {
90
+ return { problem: `"${name}" did not load as an extension` };
91
+ }
92
+ const sections: ExtensionSectionInfo[] = [];
93
+ const formats = new Set<string>();
94
+ for (const cls of info.classes) {
95
+ if (cls.project) {
96
+ const { key, title } = cls.project;
97
+ sections.push({ key, ...(typeof title === "string" ? { title } : {}) });
98
+ }
99
+ for (const ext of cls.extensions) {
100
+ formats.add(ext);
101
+ }
102
+ }
103
+ return {
104
+ formats: [...formats].toSorted(),
105
+ sections,
106
+ ...(info.manifest.title === undefined ? {} : { title: info.manifest.title }),
107
+ ...(info.manifest.description === undefined
108
+ ? {}
109
+ : { description: info.manifest.description }),
110
+ };
111
+ } catch (error) {
112
+ return { problem: error instanceof Error ? error.message : String(error) };
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Whether a package that failed to resolve nonetheless MEANT to be an extension.
118
+ *
119
+ * `"jx"` is not what a host resolves — the registry goes through the exports map and nothing else,
120
+ * which is why the probe is authoritative. But reading it separates "this package got its exports
121
+ * map wrong" from "this package is not an extension", and that is the difference between a silent
122
+ * omission and a row that says what is broken. It is the first job the field has ever had.
123
+ *
124
+ * @param {string} projectRoot
125
+ * @param {string} name
126
+ * @returns {boolean}
127
+ */
128
+ function declaresJxField(projectRoot: string, name: string): boolean {
129
+ const path = resolve(projectRoot, "node_modules", name, "package.json");
130
+ if (!existsSync(path)) {
131
+ return false;
132
+ }
133
+ try {
134
+ return typeof (JSON.parse(readFileSync(path, "utf8")) as PackageJson).jx === "string";
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * The catalogue for one project: the shipped first-party entries, then anything its own
142
+ * dependencies declare.
143
+ *
144
+ * Every first-party entry is returned whether or not the project has it, because the whole purpose
145
+ * is to advertise what could be turned on. A discovered entry is only ever one the project already
146
+ * depends on.
147
+ *
148
+ * @param {string} projectRoot - Absolute path to the project
149
+ * @returns {Promise<ExtensionCatalogEntry[]>}
150
+ */
151
+ export async function buildExtensionCatalog(projectRoot: string): Promise<ExtensionCatalogEntry[]> {
152
+ const entries: ExtensionCatalogEntry[] = [];
153
+ const seen = new Set<string>();
154
+
155
+ for (const meta of listCatalog()) {
156
+ seen.add(meta.name);
157
+ const found = probeExtension(projectRoot, meta.name);
158
+ entries.push({
159
+ description: meta.description,
160
+ docs: meta.docs,
161
+ installed: found.project,
162
+ name: meta.name,
163
+ sections: meta.sections,
164
+ source: "first-party",
165
+ title: meta.title,
166
+ ...(meta.formats ? { formats: meta.formats } : {}),
167
+ ...(meta.requires ? { requires: meta.requires } : {}),
168
+ // Bundled means "resolves without a project install": the host answers for it, and the
169
+ // Project does not have to.
170
+ ...(found.host && !found.project ? { bundled: true } : {}),
171
+ });
172
+ }
173
+
174
+ for (const name of declaredDependencies(projectRoot)) {
175
+ if (seen.has(name)) {
176
+ continue;
177
+ }
178
+ seen.add(name);
179
+ const found = probeExtension(projectRoot, name);
180
+ if (!found.project) {
181
+ if (declaresJxField(projectRoot, name)) {
182
+ entries.push({
183
+ installed: true,
184
+ name,
185
+ problem:
186
+ `declares "jx" but does not export "./${MANIFEST}", so no host can resolve its ` +
187
+ `manifest — add that entry to its exports map`,
188
+ sections: [],
189
+ source: "project",
190
+ });
191
+ }
192
+ continue;
193
+ }
194
+ const loaded = await loadEntry(projectRoot, name);
195
+ if ("problem" in loaded) {
196
+ entries.push({
197
+ installed: true,
198
+ name,
199
+ problem: loaded.problem,
200
+ sections: [],
201
+ source: "project",
202
+ });
203
+ continue;
204
+ }
205
+ entries.push({
206
+ installed: true,
207
+ name,
208
+ sections: loaded.sections,
209
+ source: "project",
210
+ ...(loaded.title === undefined ? {} : { title: loaded.title }),
211
+ ...(loaded.description === undefined ? {} : { description: loaded.description }),
212
+ ...(loaded.formats.length > 0 ? { formats: loaded.formats } : {}),
213
+ ...(found.host ? { bundled: true } : {}),
214
+ });
215
+ }
216
+
217
+ return entries;
218
+ }
package/src/studio-api.ts CHANGED
@@ -1497,6 +1497,26 @@ export async function handleStudioApi(
1497
1497
  }
1498
1498
  }
1499
1499
 
1500
+ // What this backend can OFFER, enabled or not (specs/extensions.md §9.2) — the shipped
1501
+ // First-party catalogue plus any dependency of THIS project exporting a jx-extension.json, each
1502
+ // Marked with whether the project resolves it and whether this host does.
1503
+ //
1504
+ // Deliberately NOT a field on /__studio/formats. That route builds the project's registry and
1505
+ // Fails when a declared extension does not resolve, which is exactly the state a reader is in
1506
+ // When they need the catalogue to repair it; riding on formats would make the answer unavailable
1507
+ // Precisely when it is the fix.
1508
+ if (path === "/__studio/catalog" && req.method === "GET") {
1509
+ const dir = url.searchParams.get("dir") || activeProjectRoot || root;
1510
+ const projectRoot = isAbsolute(dir) ? dir : resolve(root, dir);
1511
+ try {
1512
+ assertAccessible(projectRoot, root, activeProjectRoot);
1513
+ const { buildExtensionCatalog } = await import("./extension-catalog.ts");
1514
+ return Response.json(await buildExtensionCatalog(projectRoot));
1515
+ } catch (error) {
1516
+ return problem("invalidRequest", errorMessage(error));
1517
+ }
1518
+ }
1519
+
1500
1520
  // Pre-bundled per-project entry schemas (project.schema.json / document.schema.json) for the
1501
1521
  // Studio's editor — regenerated on demand when missing or older than project.json, then bundled
1502
1522
  // Into self-contained compound documents so no relative $ref resolution is needed client-side.
@@ -1846,7 +1866,13 @@ export async function handleStudioApi(
1846
1866
  if (fp.includes("..")) {
1847
1867
  return problem("invalidRequest", "Invalid path");
1848
1868
  }
1849
- const content = await runGit(["show", `${ref}:${fp}`]);
1869
+ /* `./` IS LOAD-BEARING. `git show <rev>:<path>` resolves <path> from the REPOSITORY ROOT,
1870
+ whatever the cwd is; only a `./` prefix makes it relative to the cwd. Every other verb
1871
+ here — status, diff, checkout — is cwd-relative, and so is the `readFile` the studio
1872
+ pairs this with, so without it a project nested inside its repo compared two DIFFERENT
1873
+ FILES: the working copy of `examples/package.json` against the committed root
1874
+ `package.json`, with no error anywhere and a plausible-looking diff. */
1875
+ const content = await runGit(["show", `${ref}:./${fp}`]);
1850
1876
  return Response.json({ content });
1851
1877
  }
1852
1878