@jxsuite/server 1.3.0 → 2.1.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.
@@ -9,16 +9,14 @@
9
9
  */
10
10
 
11
11
  import { basename, dirname, extname, relative, resolve } from "node:path";
12
- import { readFile, stat, writeFile } from "node:fs/promises";
12
+ import { stat, writeFile } from "node:fs/promises";
13
13
  import { errorMessage } from "@jxsuite/schema/parse";
14
14
  import { isUnder } from "./paths.ts";
15
15
  import { rewriteDocRefs, rewriteTagName } from "./refs.ts";
16
+ import { documentGlob, fwd, loadDoc, skipScanPath } from "./scan.ts";
16
17
  import type { RefChange } from "./refs.ts";
17
18
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
18
19
 
19
- /** Normalise a path to forward slashes. */
20
- const fwd = (p: string) => p.replaceAll("\\", "/");
21
-
22
20
  export interface FileChange {
23
21
  path: string;
24
22
  count: number;
@@ -51,20 +49,6 @@ export interface ApplyRenameOptions {
51
49
  registry: FormatRegistry;
52
50
  }
53
51
 
54
- /** A parsed document plus the serializer that round-trips it back to disk. */
55
- interface LoadedDoc {
56
- doc: unknown;
57
- serialize: ((doc: unknown) => Promise<string>) | null;
58
- }
59
-
60
- const isJsonPath = (p: string) => p.endsWith(".json");
61
-
62
- /** Skip vendored / build / agent directories (mirrors the component-discovery filter). */
63
- function skip(match: string): boolean {
64
- const f = fwd(match);
65
- return match.includes("node_modules") || f.includes("dist/") || f.includes(".claude/");
66
- }
67
-
68
52
  /** Derive a custom-element tag from a component filename (strips `.class.json` or the extension). */
69
53
  export function deriveTag(absPath: string): string {
70
54
  const base = basename(absPath);
@@ -75,44 +59,6 @@ export function deriveTag(absPath: string): string {
75
59
  return ext ? base.slice(0, -ext.length) : base;
76
60
  }
77
61
 
78
- /** Read + parse a file, returning the doc and a matching serializer (null when none is available). */
79
- async function loadDoc(fp: string, registry: FormatRegistry): Promise<LoadedDoc> {
80
- const raw = await readFile(fp, "utf8");
81
- if (isJsonPath(fp)) {
82
- const trailingNewline = raw.endsWith("\n");
83
- return {
84
- doc: JSON.parse(raw) as unknown,
85
- serialize: (doc) =>
86
- Promise.resolve(JSON.stringify(doc, null, 2) + (trailingNewline ? "\n" : "")),
87
- };
88
- }
89
- const ext = extname(fp);
90
- const parseEntry = registry.byExtension(ext, "parse");
91
- if (!parseEntry) {
92
- throw new Error(`No parser for "${ext}"`);
93
- }
94
- const doc = await parseEntry.call("parse", raw);
95
- const serializeEntry = registry.byExtension(ext, "serialize");
96
- return {
97
- doc,
98
- serialize: serializeEntry
99
- ? async (d) => {
100
- const out = await serializeEntry.call("serialize", d);
101
- return typeof out === "string" ? out : String(out);
102
- }
103
- : null,
104
- };
105
- }
106
-
107
- /** Build the recursive glob covering JSON plus every registered document/content extension. */
108
- function documentGlob(registry: FormatRegistry) {
109
- const exts = new Set(["json"]);
110
- for (const ext of registry.documentExtensions()) {
111
- exts.add(ext.replace(/^\./, ""));
112
- }
113
- return new Bun.Glob(`**/*.{${[...exts].join(",")}}`);
114
- }
115
-
116
62
  /**
117
63
  * Rewrite references (and, for components, the tag) across the project after a rename. The caller
118
64
  * must have already moved the file on disk.
@@ -162,7 +108,7 @@ export async function applyRename(opts: ApplyRenameOptions): Promise<RenameRepor
162
108
  let tagRefsUpdated = 0;
163
109
 
164
110
  for await (const match of documentGlob(opts.registry).scan({ cwd: opts.root, dot: false })) {
165
- if (skip(match)) {
111
+ if (skipScanPath(match)) {
166
112
  continue;
167
113
  }
168
114
  const fp = fwd(resolve(opts.root, match));
Binary file
@@ -5,6 +5,13 @@
5
5
 
6
6
  export { applyRename, deriveTag } from "./apply.ts";
7
7
  export type { ApplyRenameOptions, FileChange, RenameReport } from "./apply.ts";
8
+ export { findReferences, invalidateReferenceCache } from "./find-refs.ts";
9
+ export type {
10
+ FindReferencesOptions,
11
+ ReferenceFile,
12
+ ReferenceHit,
13
+ ReferencesResult,
14
+ } from "./find-refs.ts";
8
15
  export { coalesceFsEvents, toFsEvent } from "./fs-events.ts";
9
16
  export type { FsEventPayload } from "./fs-events.ts";
10
17
  export { createFsWatcher } from "./watcher.ts";
@@ -1,14 +1,21 @@
1
1
  /**
2
- * Refs.ts — pure document reference engine for rename-refactoring.
2
+ * Refs.ts — pure document reference engine, shared by the rename refactor and the usage query.
3
3
  *
4
- * Two passes, both operating on a parsed (JSON) document tree with no filesystem access: -
5
- * `rewriteDocRefs` — recompute every file-path reference across a rename (Pillar B). -
6
- * `rewriteTagName` — rename a custom-element tag wherever it is used (Pillar C).
4
+ * One structure-aware walk, two directions. {@link walkDocRefs} dispatches by JSON key and hands
5
+ * every reference-bearing string to a visitor; whatever the visitor returns is written back, so a
6
+ * visitor that always returns `null` is a pure reader. Three passes are built on it:
7
7
  *
8
- * The walk is structure-aware (it dispatches by JSON key), not a blind string search, and mutates
9
- * the passed document in place — callers parse a fresh object per file, so this is safe. Precision
10
- * comes from `rewriteRef`'s resolve-and-compare gate in paths.ts: a reference is only rewritten
11
- * when it actually resolves to the renamed file.
8
+ * - `rewriteDocRefs` — recompute every file-path reference across a rename (Pillar B).
9
+ * - `rewriteTagName` — rename a custom-element tag wherever it is used (Pillar C).
10
+ * - `countTagUses` — the same tag walk, counting instead of writing (the usage query).
11
+ *
12
+ * The walk dispatches by JSON key rather than searching strings, and mutates the passed document in
13
+ * place — callers parse a fresh object per file, so this is safe. Precision comes from
14
+ * `rewriteRef`'s resolve-and-compare gate in paths.ts: a reference is only rewritten (or counted as
15
+ * a usage) when it actually resolves to the file in question.
16
+ *
17
+ * The visitor seam is what stops the read side from being a second, drifting copy of the key list.
18
+ * A new reference-bearing key added below is a key the usage count sees on the same commit.
12
19
  */
13
20
 
14
21
  import { classifyRef, rewriteRef } from "./paths.ts";
@@ -26,79 +33,74 @@ export interface RewriteResult {
26
33
  changes: RefChange[];
27
34
  }
28
35
 
36
+ /**
37
+ * One visit of a reference-bearing string.
38
+ *
39
+ * @param value — the raw authored reference, exactly as written.
40
+ * @param refType — which key carried it (`$ref`, `$layout`, `attr`, `imports`, `$elements`, `url`).
41
+ * @param rootRelativeBare — whether a bare value resolves against the project root (`$layout`).
42
+ * @returns A replacement string to write back, or `null` to leave the document untouched.
43
+ */
44
+ export type RefVisitor = (
45
+ value: string,
46
+ refType: string,
47
+ rootRelativeBare: boolean,
48
+ ) => string | null;
49
+
29
50
  /** Matches `url(...)` targets inside a CSS string value, capturing the optional quote. */
30
51
  const URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
31
52
 
32
- /** Recompute a scalar string reference under `key`, recording any change. */
33
- function rewriteScalar(
53
+ /** Offer a scalar string reference under `key` to the visitor, writing back any replacement. */
54
+ function visitScalar(
34
55
  container: Record<string, unknown>,
35
56
  key: string,
36
57
  refType: string,
37
- ctx: RemapCtx,
38
- changes: RefChange[],
58
+ visit: RefVisitor,
39
59
  rootRelativeBare: boolean,
40
60
  ): void {
41
61
  const value = container[key];
42
- const cls = classifyRef(value);
43
- if (cls.kind !== "path") {
62
+ if (typeof value !== "string") {
44
63
  return;
45
64
  }
46
- const out = rewriteRef(cls, ctx, rootRelativeBare);
65
+ const out = visit(value, refType, rootRelativeBare);
47
66
  if (out !== null) {
48
- changes.push({ from: value as string, refType, to: out });
49
67
  container[key] = out;
50
68
  }
51
69
  }
52
70
 
53
- /** Recompute a string array member (e.g. an `$elements` entry), recording any change. */
54
- function rewriteIndexed(
55
- arr: unknown[],
56
- index: number,
57
- refType: string,
58
- ctx: RemapCtx,
59
- changes: RefChange[],
60
- ): void {
71
+ /** Offer a string array member (e.g. an `$elements` entry) to the visitor. */
72
+ function visitIndexed(arr: unknown[], index: number, refType: string, visit: RefVisitor): void {
61
73
  const value = arr[index];
62
- const cls = classifyRef(value);
63
- if (cls.kind !== "path") {
74
+ if (typeof value !== "string") {
64
75
  return;
65
76
  }
66
- const out = rewriteRef(cls, ctx, false);
77
+ const out = visit(value, refType, false);
67
78
  if (out !== null) {
68
- changes.push({ from: value as string, refType, to: out });
69
79
  arr[index] = out;
70
80
  }
71
81
  }
72
82
 
73
- /** Rewrite every `url(...)` target inside a CSS string. */
74
- function rewriteUrls(value: string, ctx: RemapCtx, changes: RefChange[]): string {
83
+ /** Offer every `url(...)` target inside a CSS string to the visitor. */
84
+ function visitUrls(value: string, visit: RefVisitor): string {
75
85
  return value.replaceAll(URL_RE, (match, quote: string, inner: string) => {
76
- const cls = classifyRef(inner.trim());
77
- if (cls.kind !== "path") {
78
- return match;
79
- }
80
- const out = rewriteRef(cls, ctx, false);
81
- if (out === null) {
82
- return match;
83
- }
84
- changes.push({ from: inner.trim(), refType: "url", to: out });
85
- return `url(${quote}${out}${quote})`;
86
+ const out = visit(inner.trim(), "url", false);
87
+ return out === null ? match : `url(${quote}${out}${quote})`;
86
88
  });
87
89
  }
88
90
 
89
- /** Walk a `style` value tree, rewriting `url(...)` in every string it contains. */
90
- function walkStyle(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
91
+ /** Walk a `style` value tree, visiting `url(...)` in every string it contains. */
92
+ function walkStyle(node: unknown, visit: RefVisitor): void {
91
93
  if (Array.isArray(node)) {
92
94
  const arr = node as unknown[];
93
95
  for (let i = 0; i < arr.length; i += 1) {
94
96
  const item = arr[i];
95
97
  if (typeof item === "string") {
96
- const out = rewriteUrls(item, ctx, changes);
98
+ const out = visitUrls(item, visit);
97
99
  if (out !== item) {
98
100
  arr[i] = out;
99
101
  }
100
102
  } else {
101
- walkStyle(item, ctx, changes);
103
+ walkStyle(item, visit);
102
104
  }
103
105
  }
104
106
  return;
@@ -110,21 +112,26 @@ function walkStyle(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
110
112
  for (const key of Object.keys(obj)) {
111
113
  const value = obj[key];
112
114
  if (typeof value === "string") {
113
- const out = rewriteUrls(value, ctx, changes);
115
+ const out = visitUrls(value, visit);
114
116
  if (out !== value) {
115
117
  obj[key] = out;
116
118
  }
117
119
  } else {
118
- walkStyle(value, ctx, changes);
120
+ walkStyle(value, visit);
119
121
  }
120
122
  }
121
123
  }
122
124
 
123
- /** Recursive document walk dispatching each reference-bearing key. */
124
- function walk(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
125
+ /**
126
+ * Recursive document walk dispatching each reference-bearing key to `visit`.
127
+ *
128
+ * This is the single definition of "what counts as a reference" in a Jx document. Mutates `doc`
129
+ * only where the visitor asks it to.
130
+ */
131
+ export function walkDocRefs(node: unknown, visit: RefVisitor): void {
125
132
  if (Array.isArray(node)) {
126
133
  for (const item of node as unknown[]) {
127
- walk(item, ctx, changes);
134
+ walkDocRefs(item, visit);
128
135
  }
129
136
  return;
130
137
  }
@@ -137,28 +144,28 @@ function walk(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
137
144
  switch (key) {
138
145
  case "$ref": {
139
146
  // File-path $ref only; state-scope refs (#/…, $map/…) are filtered by classifyRef.
140
- rewriteScalar(obj, key, "$ref", ctx, changes, false);
147
+ visitScalar(obj, key, "$ref", visit, false);
141
148
  break;
142
149
  }
143
150
  case "$layout": {
144
- rewriteScalar(obj, key, "$layout", ctx, changes, true);
151
+ visitScalar(obj, key, "$layout", visit, true);
145
152
  break;
146
153
  }
147
154
  case "$src":
148
155
  case "$implementation": {
149
- rewriteScalar(obj, key, key, ctx, changes, false);
156
+ visitScalar(obj, key, key, visit, false);
150
157
  break;
151
158
  }
152
159
  case "src":
153
160
  case "href": {
154
- rewriteScalar(obj, key, "attr", ctx, changes, false);
161
+ visitScalar(obj, key, "attr", visit, false);
155
162
  break;
156
163
  }
157
164
  case "imports": {
158
165
  if (value && typeof value === "object" && !Array.isArray(value)) {
159
166
  const imp = value as Record<string, unknown>;
160
167
  for (const name of Object.keys(imp)) {
161
- rewriteScalar(imp, name, "imports", ctx, changes, false);
168
+ visitScalar(imp, name, "imports", visit, false);
162
169
  }
163
170
  }
164
171
  break;
@@ -168,22 +175,22 @@ function walk(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
168
175
  const els = value as unknown[];
169
176
  for (let i = 0; i < els.length; i += 1) {
170
177
  if (typeof els[i] === "string") {
171
- rewriteIndexed(els, i, "$elements", ctx, changes);
178
+ visitIndexed(els, i, "$elements", visit);
172
179
  } else {
173
- walk(els[i], ctx, changes);
180
+ walkDocRefs(els[i], visit);
174
181
  }
175
182
  }
176
183
  } else {
177
- walk(value, ctx, changes);
184
+ walkDocRefs(value, visit);
178
185
  }
179
186
  break;
180
187
  }
181
188
  case "style": {
182
- walkStyle(value, ctx, changes);
189
+ walkStyle(value, visit);
183
190
  break;
184
191
  }
185
192
  default: {
186
- walk(value, ctx, changes);
193
+ walkDocRefs(value, visit);
187
194
  }
188
195
  }
189
196
  }
@@ -192,12 +199,31 @@ function walk(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
192
199
  /** Rewrite every file-path reference in `doc` for a rename described by `ctx`. Mutates `doc`. */
193
200
  export function rewriteDocRefs(doc: unknown, ctx: RemapCtx): RewriteResult {
194
201
  const changes: RefChange[] = [];
195
- walk(doc, ctx, changes);
202
+ walkDocRefs(doc, (value, refType, rootRelativeBare) => {
203
+ const cls = classifyRef(value);
204
+ if (cls.kind !== "path") {
205
+ return null;
206
+ }
207
+ const out = rewriteRef(cls, ctx, rootRelativeBare);
208
+ if (out === null) {
209
+ return null;
210
+ }
211
+ changes.push({ from: value, refType, to: out });
212
+ return out;
213
+ });
196
214
  return { changes, doc };
197
215
  }
198
216
 
199
- /** Recursive walk setting `tagName: newTag` wherever it currently equals `oldTag`. */
200
- function walkTags(node: unknown, oldTag: string, newTag: string, counter: { n: number }): void {
217
+ /**
218
+ * Recursive tag walk. With `newTag` set, every `tagName === oldTag` is rewritten; with `newTag`
219
+ * null, they are only counted — the read side of the same traversal.
220
+ */
221
+ function walkTags(
222
+ node: unknown,
223
+ oldTag: string,
224
+ newTag: string | null,
225
+ counter: { n: number },
226
+ ): void {
201
227
  if (Array.isArray(node)) {
202
228
  for (const item of node as unknown[]) {
203
229
  walkTags(item, oldTag, newTag, counter);
@@ -209,7 +235,9 @@ function walkTags(node: unknown, oldTag: string, newTag: string, counter: { n: n
209
235
  }
210
236
  const obj = node as Record<string, unknown>;
211
237
  if (obj.tagName === oldTag) {
212
- obj.tagName = newTag;
238
+ if (newTag !== null) {
239
+ obj.tagName = newTag;
240
+ }
213
241
  counter.n += 1;
214
242
  }
215
243
  for (const key of Object.keys(obj)) {
@@ -234,3 +262,15 @@ export function rewriteTagName(
234
262
  }
235
263
  return { count: counter.n, doc };
236
264
  }
265
+
266
+ /**
267
+ * How many nodes in `doc` carry `tagName === tag`. Read-only; `doc` is never touched.
268
+ *
269
+ * A component's OWN definition file answers 1 for its root node, which is why the usage query skips
270
+ * the definition file rather than subtracting one — a component may legitimately nest itself.
271
+ */
272
+ export function countTagUses(doc: unknown, tag: string): number {
273
+ const counter = { n: 0 };
274
+ walkTags(doc, tag, null, counter);
275
+ return counter.n;
276
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Scan.ts — the project document sweep the refactor engines share.
3
+ *
4
+ * `applyRename` (write pass) and `findReferences` (read pass) walk exactly the same file set: every
5
+ * JSON document plus every extension the project's format registry claims, minus the vendored /
6
+ * build / agent directories. Keeping one definition of "the project's documents" is the point — a
7
+ * rename that rewrites a file the usage count never looked at is a lie in both directions.
8
+ */
9
+
10
+ import { extname } from "node:path";
11
+ import { readFile } from "node:fs/promises";
12
+ import type { FormatRegistry } from "@jxsuite/schema/format-registry";
13
+
14
+ /** Normalise a path to forward slashes (Windows `path` returns backslashes). */
15
+ export const fwd = (p: string) => p.replaceAll("\\", "/");
16
+
17
+ /** A parsed document plus the serializer that round-trips it back to disk (null when none). */
18
+ export interface LoadedDoc {
19
+ doc: unknown;
20
+ serialize: ((doc: unknown) => Promise<string>) | null;
21
+ }
22
+
23
+ const isJsonPath = (p: string) => p.endsWith(".json");
24
+
25
+ /** Skip vendored / build / agent directories (mirrors the component-discovery filter). */
26
+ export function skipScanPath(match: string): boolean {
27
+ const f = fwd(match);
28
+ return match.includes("node_modules") || f.includes("dist/") || f.includes(".claude/");
29
+ }
30
+
31
+ /** Build the recursive glob covering JSON plus every registered document/content extension. */
32
+ export function documentGlob(registry: FormatRegistry): Bun.Glob {
33
+ const exts = new Set(["json"]);
34
+ for (const ext of registry.documentExtensions()) {
35
+ exts.add(ext.replace(/^\./, ""));
36
+ }
37
+ return new Bun.Glob(`**/*.{${[...exts].join(",")}}`);
38
+ }
39
+
40
+ /**
41
+ * Read + parse a file, returning the doc and a matching serializer (null when the format can parse
42
+ * but not serialize). Throws when no parser claims the extension.
43
+ */
44
+ export async function loadDoc(fp: string, registry: FormatRegistry): Promise<LoadedDoc> {
45
+ const raw = await readFile(fp, "utf8");
46
+ if (isJsonPath(fp)) {
47
+ const trailingNewline = raw.endsWith("\n");
48
+ return {
49
+ doc: JSON.parse(raw) as unknown,
50
+ serialize: (doc) =>
51
+ Promise.resolve(JSON.stringify(doc, null, 2) + (trailingNewline ? "\n" : "")),
52
+ };
53
+ }
54
+ const ext = extname(fp);
55
+ const parseEntry = registry.byExtension(ext, "parse");
56
+ if (!parseEntry) {
57
+ throw new Error(`No parser for "${ext}"`);
58
+ }
59
+ const doc = await parseEntry.call("parse", raw);
60
+ const serializeEntry = registry.byExtension(ext, "serialize");
61
+ return {
62
+ doc,
63
+ serialize: serializeEntry
64
+ ? async (d) => {
65
+ const out = await serializeEntry.call("serialize", d);
66
+ return typeof out === "string" ? out : String(out);
67
+ }
68
+ : null,
69
+ };
70
+ }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { watch as chokidarWatch } from "chokidar";
10
10
  import { coalesceFsEvents, toFsEvent } from "./fs-events.ts";
11
+ import { invalidateReferenceCache } from "./find-refs.ts";
11
12
  import type { FsEventPayload } from "./fs-events.ts";
12
13
 
13
14
  const IGNORE_SEGMENTS = ["node_modules", ".git", "dist", ".jx", ".direnv", ".devenv"];
@@ -45,6 +46,9 @@ export function createFsWatcher(
45
46
  if (!event) {
46
47
  return;
47
48
  }
49
+ // Any move under the root can change who references what, so the usage cache drops the whole
50
+ // Project rather than trying to derive which queries this one path could have affected.
51
+ invalidateReferenceCache(root);
48
52
  buffer = coalesceFsEvents(buffer, event);
49
53
  if (timer) {
50
54
  clearTimeout(timer);
package/src/resolve.ts CHANGED
@@ -11,6 +11,7 @@ import { loadAssetMounts } from "@jxsuite/compiler/asset-mounts";
11
11
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
12
12
  import type { DynamicClass } from "@jxsuite/runtime/types";
13
13
  import type { JxClassDef, ProjectConfig } from "@jxsuite/schema/types";
14
+ import { problem } from "./problem.ts";
14
15
 
15
16
  interface ModuleNamespace {
16
17
  default?: Record<string, unknown>;
@@ -126,12 +127,12 @@ export async function handleResolve(
126
127
  try {
127
128
  body = (await req.json()) as ResolveBody;
128
129
  } catch {
129
- return new Response("Invalid JSON body", { status: 400 });
130
+ return problem("invalidRequest", "Invalid JSON body");
130
131
  }
131
132
 
132
133
  const { $src, $prototype, $export: xport, $base, ...config } = body;
133
134
  if (!$src) {
134
- return new Response("Missing $src", { status: 400 });
135
+ return problem("invalidRequest", "Missing $src");
135
136
  }
136
137
 
137
138
  let moduleAbsPath;
@@ -200,7 +201,7 @@ export async function handleResolve(
200
201
  // A trusted (bare-specifier) class file names its own sibling implementation; only a
201
202
  // Project-local relative $src is held to the root-escape guard.
202
203
  if (!trustedResolution && !isImportable(implPath, root, activeProjectRoot)) {
203
- return new Response(`$implementation escapes the project root`, { status: 403 });
204
+ return problem("pathOutsideProject", "$implementation escapes the project root");
204
205
  }
205
206
  const exportName = xport ?? classDef.title ?? $prototype;
206
207
  const mod = (await import(implPath)) as ModuleNamespace;
@@ -236,7 +237,7 @@ export async function handleResolve(
236
237
  : instance;
237
238
  return Response.json(value);
238
239
  } catch (error) {
239
- return Response.json({ error: errorMessage(error) }, { status: 500 });
240
+ return problem("internalError", errorMessage(error));
240
241
  }
241
242
  }
242
243
 
@@ -260,12 +261,12 @@ export async function handleServerFunction(req: Request, root: string) {
260
261
  try {
261
262
  body = (await req.json()) as ServerFunctionBody;
262
263
  } catch {
263
- return new Response("Invalid JSON body", { status: 400 });
264
+ return problem("invalidRequest", "Invalid JSON body");
264
265
  }
265
266
 
266
267
  const { $src, $export: xport, $base, arguments: args = {} } = body;
267
268
  if (!$src || !xport) {
268
- return new Response("Missing $src or $export", { status: 400 });
269
+ return problem("invalidRequest", "Missing $src or $export");
269
270
  }
270
271
 
271
272
  let moduleAbsPath;
@@ -278,9 +279,7 @@ export async function handleServerFunction(req: Request, root: string) {
278
279
  moduleAbsPath = resolve(root, $src);
279
280
  }
280
281
  } catch (error) {
281
- return new Response(`Cannot resolve $src: ${errorMessage(error)}`, {
282
- status: 400,
283
- });
282
+ return problem("invalidRequest", `Cannot resolve $src: ${errorMessage(error)}`);
284
283
  }
285
284
 
286
285
  if (!isImportable(moduleAbsPath, root, null)) {
@@ -312,7 +311,7 @@ export async function handleServerFunction(req: Request, root: string) {
312
311
  );
313
312
  return Response.json(result ?? null);
314
313
  } catch (error) {
315
- return Response.json({ error: errorMessage(error) }, { status: 500 });
314
+ return problem("internalError", errorMessage(error));
316
315
  }
317
316
  }
318
317