@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 +16 -14
- package/src/ai-api.ts +17 -1
- package/src/extension-catalog.ts +218 -0
- package/src/studio-api.ts +27 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "4.
|
|
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/
|
|
43
|
-
"@jxsuite/
|
|
44
|
-
"@jxsuite/
|
|
45
|
-
"@jxsuite/
|
|
46
|
-
"@jxsuite/
|
|
47
|
-
"@jxsuite/
|
|
48
|
-
"@jxsuite/
|
|
49
|
-
"@jxsuite/
|
|
50
|
-
"@jxsuite/
|
|
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.
|
|
55
|
+
"zod": "^4.6.4"
|
|
54
56
|
},
|
|
55
57
|
"devDependencies": {
|
|
56
|
-
"@jxsuite/auth": "^0.
|
|
57
|
-
"@jxsuite/connector": "^0.
|
|
58
|
-
"@jxsuite/parser": "^1.
|
|
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?: {
|
|
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
|
-
|
|
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
|
|