@jxsuite/server 8.0.1 → 8.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 +9 -9
- package/src/ai-api.ts +123 -13
- package/src/refactor/apply.ts +14 -0
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/watcher.ts +13 -5
- package/src/server.ts +66 -5
- package/src/watch.ts +20 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "8.0
|
|
3
|
+
"version": "8.2.0",
|
|
4
4
|
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -54,20 +54,20 @@
|
|
|
54
54
|
"upgrade": "bun update --latest && bun install"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@jxsuite/ai": "^0.
|
|
58
|
-
"@jxsuite/catalog": "^0.1.
|
|
57
|
+
"@jxsuite/ai": "^0.42.0",
|
|
58
|
+
"@jxsuite/catalog": "^0.1.6",
|
|
59
59
|
"@jxsuite/collab": "^0.11.4",
|
|
60
|
-
"@jxsuite/compiler": "^8.0.
|
|
61
|
-
"@jxsuite/create": "^1.5.
|
|
62
|
-
"@jxsuite/import": "^0.42.
|
|
63
|
-
"@jxsuite/protocol": "^6.
|
|
60
|
+
"@jxsuite/compiler": "^8.0.2",
|
|
61
|
+
"@jxsuite/create": "^1.5.2",
|
|
62
|
+
"@jxsuite/import": "^0.42.2",
|
|
63
|
+
"@jxsuite/protocol": "^6.2.0",
|
|
64
64
|
"@jxsuite/runtime": "^6.0.1",
|
|
65
65
|
"@jxsuite/schema": "^5.0.0",
|
|
66
66
|
"@jxsuite/site": "^2.2.2",
|
|
67
|
-
"@jxsuite/starters": "^3.1.
|
|
67
|
+
"@jxsuite/starters": "^3.1.4",
|
|
68
68
|
"@npmcli/arborist": "^10.0.4",
|
|
69
69
|
"chokidar": "^6.0.0",
|
|
70
|
-
"kysely": "^0.29.
|
|
70
|
+
"kysely": "^0.29.8",
|
|
71
71
|
"npm-package-arg": "^14.0.0",
|
|
72
72
|
"oxfmt": "^0.75.0",
|
|
73
73
|
"ws": "^8.22.1",
|
package/src/ai-api.ts
CHANGED
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
* 2. Fallback: OPENAI_BASE_URL env var
|
|
23
23
|
* 3. Default: https://api.openai.com/v1
|
|
24
24
|
*
|
|
25
|
+
* Provider family (specs/ai.md §2.6): a request carrying `X-Api-Provider: anthropic` is served by
|
|
26
|
+
* the Anthropic Messages API instead, with the same two flows read from ANTHROPIC_API_KEY,
|
|
27
|
+
* ANTHROPIC_BASE_URL and `https://api.anthropic.com`. An env key is still attached only to its own
|
|
28
|
+
* family's env/default base URL, so neither server key reaches the other family or a caller's
|
|
29
|
+
* endpoint. Any other value is refused: a key must never be sent to a provider it was not meant
|
|
30
|
+
* for. The models route lists both families in `providers`.
|
|
31
|
+
*
|
|
25
32
|
* A base URL resolving to a cloud metadata / link-local host is refused (SSRF defense).
|
|
26
33
|
*
|
|
27
34
|
* Prompt-cache affinity: a request carrying a valid X-Jx-Ai-Session header (the client's
|
|
@@ -38,15 +45,19 @@
|
|
|
38
45
|
import {
|
|
39
46
|
AI_SESSION_HEADER,
|
|
40
47
|
affinityKey,
|
|
48
|
+
ANTHROPIC_BASE_URL,
|
|
41
49
|
createChatHandler,
|
|
42
50
|
DEFAULT_UPSTREAM_BASE_URL,
|
|
43
51
|
extractUpstreamErrorMessage,
|
|
44
52
|
isAiSessionId,
|
|
53
|
+
listAnthropicModels,
|
|
45
54
|
modelsResponse,
|
|
46
55
|
problemResponse,
|
|
47
56
|
} from "@jxsuite/ai/gateway";
|
|
48
57
|
import type { GatewayRefusal, Upstream } from "@jxsuite/ai/gateway";
|
|
58
|
+
import { AI_PROVIDER_HEADER } from "@jxsuite/ai/streaming-client";
|
|
49
59
|
import { problemDetails } from "@jxsuite/protocol";
|
|
60
|
+
import type { AiModelInfo, AiProviderFamily } from "@jxsuite/protocol";
|
|
50
61
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
51
62
|
import { problemTypeForStatus } from "./problem.ts";
|
|
52
63
|
|
|
@@ -55,6 +66,19 @@ import { problemTypeForStatus } from "./problem.ts";
|
|
|
55
66
|
/** The model a chat request that names none is forwarded with. */
|
|
56
67
|
const DEFAULT_MODEL = "gpt-4o";
|
|
57
68
|
|
|
69
|
+
/** The model an Anthropic chat request that names none is forwarded with, and the probe prefers. */
|
|
70
|
+
const DEFAULT_ANTHROPIC_MODEL = "claude-opus-5-5";
|
|
71
|
+
|
|
72
|
+
/** Every family this server's chat route can reach, as the models route declares them. */
|
|
73
|
+
const PROVIDERS: AiProviderFamily[] = ["openai-compat", "anthropic"];
|
|
74
|
+
|
|
75
|
+
/** The Claude catalogue the models route answers when it cannot list the caller's own. */
|
|
76
|
+
const ANTHROPIC_DEFAULTS: AiModelInfo[] = [
|
|
77
|
+
{ contextWindow: 1_000_000, id: "claude-opus-5-5", name: "Claude Opus 5.5" },
|
|
78
|
+
{ contextWindow: 1_000_000, id: "claude-sonnet-5-5", name: "Claude Sonnet 5.5" },
|
|
79
|
+
{ contextWindow: 1_000_000, id: "claude-haiku-5-5", name: "Claude Haiku 5.5" },
|
|
80
|
+
];
|
|
81
|
+
|
|
58
82
|
/**
|
|
59
83
|
* This server's namespace for affinity keys. Every dev/desktop server shares it: their keys never
|
|
60
84
|
* meet a hosted backend's, because that one hashes under its own scope.
|
|
@@ -95,16 +119,64 @@ function isBlockedHost(rawUrl: string): boolean {
|
|
|
95
119
|
}
|
|
96
120
|
|
|
97
121
|
interface AiConfig {
|
|
122
|
+
family: AiProviderFamily;
|
|
98
123
|
apiKey: string | null;
|
|
99
124
|
baseUrl: string;
|
|
100
125
|
missingKey: boolean;
|
|
101
126
|
reject?: { status: number; message: string };
|
|
102
127
|
}
|
|
103
128
|
|
|
129
|
+
/** Where each family's server-side key and base URL come from, and its default endpoint. */
|
|
130
|
+
interface FamilyEnvironment {
|
|
131
|
+
keyVariable: "OPENAI_API_KEY" | "ANTHROPIC_API_KEY";
|
|
132
|
+
baseUrlVariable: "OPENAI_BASE_URL" | "ANTHROPIC_BASE_URL";
|
|
133
|
+
defaultBaseUrl: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const ENVIRONMENTS: Readonly<Record<AiProviderFamily, FamilyEnvironment>> = {
|
|
137
|
+
anthropic: {
|
|
138
|
+
baseUrlVariable: "ANTHROPIC_BASE_URL",
|
|
139
|
+
defaultBaseUrl: ANTHROPIC_BASE_URL,
|
|
140
|
+
keyVariable: "ANTHROPIC_API_KEY",
|
|
141
|
+
},
|
|
142
|
+
"openai-compat": {
|
|
143
|
+
baseUrlVariable: "OPENAI_BASE_URL",
|
|
144
|
+
defaultBaseUrl: DEFAULT_UPSTREAM_BASE_URL,
|
|
145
|
+
keyVariable: "OPENAI_API_KEY",
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The family a request names in `X-Api-Provider`: absent or empty is the OpenAI-compatible default,
|
|
151
|
+
* and a value naming no family this server reaches is null, which the caller refuses.
|
|
152
|
+
*/
|
|
153
|
+
function familyOf(req: Request): AiProviderFamily | null {
|
|
154
|
+
const named = (req.headers.get(AI_PROVIDER_HEADER) || "").trim().toLowerCase();
|
|
155
|
+
if (named === "" || named === "openai-compat") {
|
|
156
|
+
return "openai-compat";
|
|
157
|
+
}
|
|
158
|
+
return named === "anthropic" ? "anthropic" : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
104
161
|
function getConfig(req: Request): AiConfig {
|
|
105
162
|
const authHeader = req.headers.get("Authorization") || "";
|
|
106
163
|
const apiKeyHeader = req.headers.get("X-Api-Key") || "";
|
|
107
164
|
const baseUrlHeader = req.headers.get("X-Api-Base-URL") || "";
|
|
165
|
+
const named = familyOf(req);
|
|
166
|
+
if (named === null) {
|
|
167
|
+
return {
|
|
168
|
+
apiKey: null,
|
|
169
|
+
baseUrl: "",
|
|
170
|
+
family: "openai-compat",
|
|
171
|
+
missingKey: true,
|
|
172
|
+
reject: {
|
|
173
|
+
status: 400,
|
|
174
|
+
message: `Unknown ${AI_PROVIDER_HEADER}: this server reaches openai-compat and anthropic.`,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const family = named;
|
|
179
|
+
const environment = ENVIRONMENTS[family];
|
|
108
180
|
|
|
109
181
|
// Track the PROVENANCE of the key: a header-supplied key may ride a caller's custom base URL; the
|
|
110
182
|
// Server's env key never leaves for a caller-chosen endpoint (key-exfiltration defense).
|
|
@@ -120,12 +192,14 @@ function getConfig(req: Request): AiConfig {
|
|
|
120
192
|
}
|
|
121
193
|
|
|
122
194
|
const baseFromHeader = Boolean(baseUrlHeader);
|
|
123
|
-
const baseUrl =
|
|
195
|
+
const baseUrl =
|
|
196
|
+
baseUrlHeader || process.env[environment.baseUrlVariable] || environment.defaultBaseUrl;
|
|
124
197
|
|
|
125
198
|
if (isBlockedHost(baseUrl)) {
|
|
126
199
|
return {
|
|
127
200
|
apiKey: null,
|
|
128
201
|
baseUrl,
|
|
202
|
+
family,
|
|
129
203
|
missingKey: true,
|
|
130
204
|
reject: { status: 403, message: "Base URL host is not permitted." },
|
|
131
205
|
};
|
|
@@ -137,6 +211,7 @@ function getConfig(req: Request): AiConfig {
|
|
|
137
211
|
return {
|
|
138
212
|
apiKey: null,
|
|
139
213
|
baseUrl,
|
|
214
|
+
family,
|
|
140
215
|
missingKey: true,
|
|
141
216
|
reject: {
|
|
142
217
|
status: 401,
|
|
@@ -145,12 +220,13 @@ function getConfig(req: Request): AiConfig {
|
|
|
145
220
|
};
|
|
146
221
|
}
|
|
147
222
|
|
|
148
|
-
// Env key is only ever attached to
|
|
149
|
-
|
|
150
|
-
|
|
223
|
+
// Env key is only ever attached to its own family's env/default base URL.
|
|
224
|
+
const envKey = process.env[environment.keyVariable];
|
|
225
|
+
if (!apiKey && envKey) {
|
|
226
|
+
apiKey = envKey;
|
|
151
227
|
}
|
|
152
228
|
|
|
153
|
-
return { apiKey, baseUrl, missingKey: !apiKey };
|
|
229
|
+
return { apiKey, baseUrl, family, missingKey: !apiKey };
|
|
154
230
|
}
|
|
155
231
|
|
|
156
232
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
@@ -173,21 +249,21 @@ function refuse(status: number, message: string): GatewayRefusal {
|
|
|
173
249
|
* session header adds the hashed affinity key; an invalid one is ignored rather than refused.
|
|
174
250
|
*/
|
|
175
251
|
async function resolveUpstream(req: Request): Promise<Upstream | GatewayRefusal> {
|
|
176
|
-
const { apiKey, baseUrl, reject } = getConfig(req);
|
|
252
|
+
const { apiKey, baseUrl, family, reject } = getConfig(req);
|
|
177
253
|
if (reject) {
|
|
178
254
|
return refuse(reject.status, reject.message);
|
|
179
255
|
}
|
|
180
256
|
if (!apiKey) {
|
|
181
257
|
return refuse(
|
|
182
258
|
401,
|
|
183
|
-
|
|
259
|
+
`No API key configured. Set ${ENVIRONMENTS[family].keyVariable} env var or send X-Api-Key header.`,
|
|
184
260
|
);
|
|
185
261
|
}
|
|
186
262
|
const upstream: Upstream = {
|
|
187
263
|
apiKey,
|
|
188
264
|
baseUrl,
|
|
189
|
-
defaultModel: DEFAULT_MODEL,
|
|
190
|
-
family
|
|
265
|
+
defaultModel: family === "anthropic" ? DEFAULT_ANTHROPIC_MODEL : DEFAULT_MODEL,
|
|
266
|
+
family,
|
|
191
267
|
managed: false,
|
|
192
268
|
};
|
|
193
269
|
const sessionId = req.headers.get(AI_SESSION_HEADER);
|
|
@@ -203,7 +279,7 @@ const chatHandler = createChatHandler<{ readonly signal: AbortSignal }>({
|
|
|
203
279
|
resolveUpstream: (request) => resolveUpstream(request),
|
|
204
280
|
});
|
|
205
281
|
|
|
206
|
-
/** Handle POST /__studio/ai/chat — proxy chat
|
|
282
|
+
/** Handle POST /__studio/ai/chat — proxy the chat to the request's provider family via SSE. */
|
|
207
283
|
export async function handleChat(req: Request): Promise<Response> {
|
|
208
284
|
return chatHandler(req, { signal: req.signal });
|
|
209
285
|
}
|
|
@@ -219,10 +295,13 @@ export async function handleChat(req: Request): Promise<Response> {
|
|
|
219
295
|
* key is available.
|
|
220
296
|
*/
|
|
221
297
|
export async function handleModels(req: Request): Promise<Response> {
|
|
222
|
-
const { apiKey, baseUrl, missingKey, reject } = getConfig(req);
|
|
298
|
+
const { apiKey, baseUrl, family, missingKey, reject } = getConfig(req);
|
|
223
299
|
if (reject) {
|
|
224
300
|
return problemResponse(refuse(reject.status, reject.message));
|
|
225
301
|
}
|
|
302
|
+
if (family === "anthropic") {
|
|
303
|
+
return anthropicModels(apiKey, baseUrl);
|
|
304
|
+
}
|
|
226
305
|
|
|
227
306
|
// No key available → return hardcoded defaults so the UI can at least render.
|
|
228
307
|
if (missingKey) {
|
|
@@ -232,7 +311,12 @@ export async function handleModels(req: Request): Promise<Response> {
|
|
|
232
311
|
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextWindow: 1_000_000 },
|
|
233
312
|
{ id: "gpt-4o-mini", name: "GPT-4o Mini", contextWindow: 128_000 },
|
|
234
313
|
];
|
|
235
|
-
return modelsResponse({
|
|
314
|
+
return modelsResponse({
|
|
315
|
+
models: defaults,
|
|
316
|
+
configured: false,
|
|
317
|
+
managed: false,
|
|
318
|
+
providers: PROVIDERS,
|
|
319
|
+
});
|
|
236
320
|
}
|
|
237
321
|
|
|
238
322
|
// Key is available — proxy to the upstream /models endpoint.
|
|
@@ -256,6 +340,7 @@ export async function handleModels(req: Request): Promise<Response> {
|
|
|
256
340
|
models: defaults,
|
|
257
341
|
configured: true,
|
|
258
342
|
managed: false,
|
|
343
|
+
providers: PROVIDERS,
|
|
259
344
|
upstreamError: upstreamResp.status,
|
|
260
345
|
upstreamMessage,
|
|
261
346
|
});
|
|
@@ -280,7 +365,7 @@ export async function handleModels(req: Request): Promise<Response> {
|
|
|
280
365
|
ownedBy: owned_by,
|
|
281
366
|
}));
|
|
282
367
|
|
|
283
|
-
return modelsResponse({ models, configured: true, managed: false });
|
|
368
|
+
return modelsResponse({ models, configured: true, managed: false, providers: PROVIDERS });
|
|
284
369
|
} catch (error) {
|
|
285
370
|
// Network error → return defaults.
|
|
286
371
|
const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
|
|
@@ -288,12 +373,37 @@ export async function handleModels(req: Request): Promise<Response> {
|
|
|
288
373
|
models: defaults,
|
|
289
374
|
configured: true,
|
|
290
375
|
managed: false,
|
|
376
|
+
providers: PROVIDERS,
|
|
291
377
|
upstreamError: "network",
|
|
292
378
|
upstreamMessage: errorMessage(error),
|
|
293
379
|
});
|
|
294
380
|
}
|
|
295
381
|
}
|
|
296
382
|
|
|
383
|
+
/**
|
|
384
|
+
* The models route for the Anthropic family: the caller's own listing from `/v1/models`, or the
|
|
385
|
+
* Claude defaults when there is no key or the listing failed, with the reason beside them exactly
|
|
386
|
+
* as the OpenAI-compatible branch reports one. `defaultModel` is always set, because a client that
|
|
387
|
+
* has chosen nothing must not fall back to an OpenAI model id here.
|
|
388
|
+
*/
|
|
389
|
+
async function anthropicModels(apiKey: string | null, baseUrl: string): Promise<Response> {
|
|
390
|
+
const declared = { defaultModel: DEFAULT_ANTHROPIC_MODEL, managed: false, providers: PROVIDERS };
|
|
391
|
+
if (!apiKey) {
|
|
392
|
+
return modelsResponse({ ...declared, configured: false, models: ANTHROPIC_DEFAULTS });
|
|
393
|
+
}
|
|
394
|
+
const listing = await listAnthropicModels({ apiKey, baseUrl });
|
|
395
|
+
if (listing.ok) {
|
|
396
|
+
return modelsResponse({ ...declared, configured: true, models: listing.models });
|
|
397
|
+
}
|
|
398
|
+
return modelsResponse({
|
|
399
|
+
...declared,
|
|
400
|
+
configured: true,
|
|
401
|
+
models: ANTHROPIC_DEFAULTS,
|
|
402
|
+
upstreamError: listing.upstreamError,
|
|
403
|
+
upstreamMessage: listing.message,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
297
407
|
// ─── Route handler ───────────────────────────────────────────────────────────
|
|
298
408
|
|
|
299
409
|
/**
|
package/src/refactor/apply.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { errorMessage } from "@jxsuite/schema/parse";
|
|
|
15
15
|
import { isUnder } from "./paths.ts";
|
|
16
16
|
import { rewriteDocRefs, rewriteTagName } from "./refs.ts";
|
|
17
17
|
import { documentGlob, fwd, loadDoc, skipScanPath } from "./scan.ts";
|
|
18
|
+
import { invalidateReferenceCache } from "./find-refs.ts";
|
|
18
19
|
import { refactorMounts } from "./mounts.ts";
|
|
19
20
|
import type { RefChange } from "./refs.ts";
|
|
20
21
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
@@ -74,6 +75,19 @@ export function deriveTag(absPath: string): string {
|
|
|
74
75
|
* must have already moved the file on disk.
|
|
75
76
|
*/
|
|
76
77
|
export async function applyRename(opts: ApplyRenameOptions): Promise<RenameReport> {
|
|
78
|
+
/* The usage cache is otherwise dropped by the file watcher, which reports this rename some time
|
|
79
|
+
after the route has answered it, and not at all under a path it ignores. The caller's very next
|
|
80
|
+
question is "who uses the new name?", and a confident zero read from the old sweep is the one
|
|
81
|
+
answer a delete must never be prompted by. On every exit: a pass that threw may have written. */
|
|
82
|
+
try {
|
|
83
|
+
return await rewriteReferences(opts);
|
|
84
|
+
} finally {
|
|
85
|
+
invalidateReferenceCache(opts.root);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The refactor pass itself: see {@link applyRename}. */
|
|
90
|
+
async function rewriteReferences(opts: ApplyRenameOptions): Promise<RenameReport> {
|
|
77
91
|
const root = fwd(opts.root);
|
|
78
92
|
const absFrom = fwd(opts.absFrom);
|
|
79
93
|
const absTo = fwd(opts.absTo);
|
|
Binary file
|
package/src/refactor/watcher.ts
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* Watcher.ts — a small chokidar wrapper that emits coalesced, batched FS-event payloads to a sink.
|
|
3
3
|
*
|
|
4
4
|
* Used by the desktop project session (which has no SSE stream) to push filesystem changes to its
|
|
5
|
-
* webview over RPC
|
|
6
|
-
* build/reload)
|
|
5
|
+
* webview over RPC, and by the dev server for an active project its own watcher in watch.ts
|
|
6
|
+
* (intertwined with build/reload) cannot see. Both share the pure `toFsEvent`/`coalesceFsEvents`
|
|
7
|
+
* helpers.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { watch } from "chokidar";
|
|
11
|
+
import { relative } from "node:path";
|
|
10
12
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
11
13
|
import { coalesceFsEvents, toFsEvent } from "./fs-events.ts";
|
|
12
14
|
import { invalidateReferenceCache } from "./find-refs.ts";
|
|
@@ -15,8 +17,14 @@ import type { FsEventPayload } from "./fs-events.ts";
|
|
|
15
17
|
|
|
16
18
|
const IGNORE_SEGMENTS = ["node_modules", ".git", "dist", ".jx", ".direnv", ".devenv"];
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Whether a path below `root` is one of the folders no project wants events for.
|
|
22
|
+
*
|
|
23
|
+
* Judged from the root down, because what lies above it says nothing about the project: read whole,
|
|
24
|
+
* a project at `~/dist/site` or `~/sites/.jx/site` would have every entry in it ignored.
|
|
25
|
+
*/
|
|
26
|
+
function isIgnored(root: string, path: string): boolean {
|
|
27
|
+
const f = `/${relative(root, path).replaceAll("\\", "/")}`;
|
|
20
28
|
return IGNORE_SEGMENTS.some((s) => f.includes(`/${s}/`) || f.endsWith(`/${s}`));
|
|
21
29
|
}
|
|
22
30
|
|
|
@@ -45,7 +53,7 @@ export function createFsWatcher(
|
|
|
45
53
|
followSymlinks: true,
|
|
46
54
|
ignoreInitial: true,
|
|
47
55
|
ignorePermissionErrors: true,
|
|
48
|
-
ignored: createWatchIgnore(root, isIgnored),
|
|
56
|
+
ignored: createWatchIgnore(root, (path) => isIgnored(root, path)),
|
|
49
57
|
});
|
|
50
58
|
|
|
51
59
|
watcher.on("all", (eventType, changedPath) => {
|
package/src/server.ts
CHANGED
|
@@ -17,6 +17,8 @@ import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
|
17
17
|
import { buildAll } from "./build.ts";
|
|
18
18
|
import { createCollabRegistry } from "./collab.ts";
|
|
19
19
|
import { createWatcher, injectSSE } from "./watch.ts";
|
|
20
|
+
import { invalidateReferenceCache } from "./refactor/find-refs.ts";
|
|
21
|
+
import { createFsWatcher } from "./refactor/watcher.ts";
|
|
20
22
|
import { handleJxMounts } from "./jx-mounts.ts";
|
|
21
23
|
import {
|
|
22
24
|
handleResolve,
|
|
@@ -122,15 +124,39 @@ export async function createDevServer(options: {
|
|
|
122
124
|
await buildAll(builds);
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
// Active studio project root (set via /__studio/activate, used for static file fallback)
|
|
128
|
+
let activeProjectRoot: string | null = null;
|
|
129
|
+
let activeProjectWatcher: ReturnType<typeof createFsWatcher> | null = null;
|
|
130
|
+
|
|
131
|
+
/** The active project as the Studio that activated it spells it, which its `fs` filter compares. */
|
|
132
|
+
let activeProjectSpelling = "";
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A root-relative path inside the active project, as that project's Studio looks for it.
|
|
136
|
+
*
|
|
137
|
+
* Studio keeps and activates a project by whatever it was opened with, usually an absolute path,
|
|
138
|
+
* and drops an `fs` event that does not start with it. The root watcher's own paths are relative
|
|
139
|
+
* to the server root, so read as they stand they reach no Studio holding an absolute root.
|
|
140
|
+
*/
|
|
141
|
+
const spelledForStudio = (path: string): string => {
|
|
142
|
+
if (activeProjectRoot === null) {
|
|
143
|
+
return path;
|
|
144
|
+
}
|
|
145
|
+
const inside = relative(activeProjectRoot, resolve(absRoot, path)).replaceAll("\\", "/");
|
|
146
|
+
const outside = inside === "" || inside === ".." || inside.startsWith("../");
|
|
147
|
+
return outside || isAbsolute(inside) ? path : `${activeProjectSpelling}/${inside}`;
|
|
148
|
+
};
|
|
149
|
+
|
|
125
150
|
// ─── File watcher + SSE ─────────────────────────────────────────────────────
|
|
126
151
|
|
|
127
152
|
let handleSSE = null;
|
|
153
|
+
let rootWatcher: ReturnType<typeof createWatcher> | null = null;
|
|
128
154
|
let fsWatcher: ReturnType<typeof createWatcher>["watcher"] | null = null;
|
|
129
155
|
if (watch !== false) {
|
|
130
156
|
const watchOpts = watch === true ? {} : watch;
|
|
131
|
-
|
|
132
|
-
({ handleSSE } =
|
|
133
|
-
fsWatcher =
|
|
157
|
+
rootWatcher = createWatcher(absRoot, builds, { eventPath: spelledForStudio, ...watchOpts });
|
|
158
|
+
({ handleSSE } = rootWatcher);
|
|
159
|
+
fsWatcher = rootWatcher.watcher;
|
|
134
160
|
}
|
|
135
161
|
|
|
136
162
|
// Bundle cache for npm packages (bare specifier → bundled JS)
|
|
@@ -155,8 +181,38 @@ export async function createDevServer(options: {
|
|
|
155
181
|
});
|
|
156
182
|
};
|
|
157
183
|
|
|
158
|
-
|
|
159
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Watch the project being activated, when the server's own watcher cannot see it.
|
|
186
|
+
*
|
|
187
|
+
* `allowedRoots` and a project of the account's own can both lie outside the server root, and a
|
|
188
|
+
* project in a folder the root watcher ignores (`.cache`, `dist`) is below it and just as unseen.
|
|
189
|
+
* The usage cache is invalidated by the filesystem and the Files panel is kept current by it, so
|
|
190
|
+
* a project nothing watches would answer from its first sweep and show its first listing for the
|
|
191
|
+
* rest of the session. `createFsWatcher` drops the project's usage answers itself, and its events
|
|
192
|
+
* reach Studio over the same stream as the root watcher's, under the prefix that Studio filters
|
|
193
|
+
* on.
|
|
194
|
+
*
|
|
195
|
+
* Dropped here as well, since the project may have changed while it was not the active one.
|
|
196
|
+
*/
|
|
197
|
+
async function watchActiveProject(next: string | null): Promise<void> {
|
|
198
|
+
await activeProjectWatcher?.close();
|
|
199
|
+
activeProjectWatcher = null;
|
|
200
|
+
if (next === null || rootWatcher === null) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
invalidateReferenceCache(next);
|
|
204
|
+
const { broadcastEvent, sees } = rootWatcher;
|
|
205
|
+
if (containedPath(next, absRoot) === null || !sees(next)) {
|
|
206
|
+
activeProjectWatcher = createFsWatcher(next, (events) => {
|
|
207
|
+
broadcastEvent("fs", {
|
|
208
|
+
events: events.map((event) => ({
|
|
209
|
+
...event,
|
|
210
|
+
path: `${activeProjectSpelling}/${event.path}`,
|
|
211
|
+
})),
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
160
216
|
|
|
161
217
|
// ─── Realtime co-editing (/__studio/collab) ─────────────────────────────────
|
|
162
218
|
|
|
@@ -280,6 +336,7 @@ export async function createDevServer(options: {
|
|
|
280
336
|
const raw = body.root || null;
|
|
281
337
|
if (!raw) {
|
|
282
338
|
activeProjectRoot = null;
|
|
339
|
+
await watchActiveProject(null);
|
|
283
340
|
return Response.json({ ok: true, root: null });
|
|
284
341
|
}
|
|
285
342
|
const requested = resolve(absRoot, raw);
|
|
@@ -293,6 +350,10 @@ export async function createDevServer(options: {
|
|
|
293
350
|
// And RFC 9457 §3.2 exists precisely so a problem can carry what a type documents.
|
|
294
351
|
return problem("forbidden", "root not permitted", { ok: false });
|
|
295
352
|
}
|
|
353
|
+
activeProjectSpelling = raw.replaceAll("\\", "/").replace(/\/+$/, "");
|
|
354
|
+
if (requested !== activeProjectRoot) {
|
|
355
|
+
await watchActiveProject(requested);
|
|
356
|
+
}
|
|
296
357
|
activeProjectRoot = requested;
|
|
297
358
|
return Response.json({ ok: true, root: activeProjectRoot });
|
|
298
359
|
}
|
package/src/watch.ts
CHANGED
|
@@ -81,10 +81,12 @@ export function injectSSE(html: string) {
|
|
|
81
81
|
* debounce?: number;
|
|
82
82
|
* reloadOnAnyChange?: boolean;
|
|
83
83
|
* preReload?: (filename: string) => Promise<void> | void;
|
|
84
|
+
* eventPath?: (path: string) => string;
|
|
84
85
|
* }} [opts]
|
|
85
86
|
* @returns {{
|
|
86
87
|
* broadcast: () => void;
|
|
87
88
|
* handleSSE: (request?: Request) => Response;
|
|
89
|
+
* sees: (path: string) => boolean;
|
|
88
90
|
* watcher: import("chokidar").FSWatcher;
|
|
89
91
|
* }}
|
|
90
92
|
*/
|
|
@@ -97,6 +99,8 @@ export function createWatcher(
|
|
|
97
99
|
reloadOnAnyChange?: boolean;
|
|
98
100
|
/** Runs before the reload broadcast — e.g. `jx dev` rebuilds the site here. */
|
|
99
101
|
preReload?: (filename: string) => Promise<void> | void;
|
|
102
|
+
/** How an `fs` event names a root-relative path on the wire. Unchanged by default. */
|
|
103
|
+
eventPath?: (path: string) => string;
|
|
100
104
|
} = {},
|
|
101
105
|
) {
|
|
102
106
|
const ignore = opts.ignore ?? DEFAULT_IGNORE;
|
|
@@ -129,9 +133,11 @@ export function createWatcher(
|
|
|
129
133
|
return;
|
|
130
134
|
}
|
|
131
135
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
/* The usage query's cache is invalidated by the filesystem, never by a timer: a delete
|
|
137
|
+
confirmation that says "used on 7 pages" must be answering about the tree as it is now. All
|
|
138
|
+
of it, not this root's: the answers are filed under the ACTIVE PROJECT, which is usually a
|
|
139
|
+
folder below the server root this watcher is on, and a sweep costs less than a wrong count. */
|
|
140
|
+
invalidateReferenceCache();
|
|
135
141
|
|
|
136
142
|
// Structured FS events for the studio sidebar (coalesced + batched), emitted as a named "fs"
|
|
137
143
|
// SSE event so the preview iframe ignores them while the studio shell subscribes to them.
|
|
@@ -141,7 +147,12 @@ export function createWatcher(
|
|
|
141
147
|
clearTimeout(fsTimer ?? undefined);
|
|
142
148
|
fsTimer = setTimeout(() => {
|
|
143
149
|
if (fsBuffer.length > 0) {
|
|
144
|
-
|
|
150
|
+
const { eventPath } = opts;
|
|
151
|
+
broadcastEvent("fs", {
|
|
152
|
+
events: eventPath
|
|
153
|
+
? fsBuffer.map((event) => ({ ...event, path: eventPath(event.path) }))
|
|
154
|
+
: fsBuffer,
|
|
155
|
+
});
|
|
145
156
|
fsBuffer = [];
|
|
146
157
|
}
|
|
147
158
|
}, fsDebounceMs);
|
|
@@ -183,5 +194,9 @@ export function createWatcher(
|
|
|
183
194
|
console.error("[watch] chokidar error:", message ?? err);
|
|
184
195
|
});
|
|
185
196
|
|
|
186
|
-
|
|
197
|
+
/* Whether an absolute path below `root` is one this watcher reports, by the rule it hands chokidar
|
|
198
|
+
above. Lets the server tell which active project it has to watch separately. */
|
|
199
|
+
const sees = (path: string) => !shouldIgnore(path, ignore);
|
|
200
|
+
|
|
201
|
+
return { broadcast, broadcastEvent, handleSSE, sees, watcher };
|
|
187
202
|
}
|