@jxsuite/server 0.28.5 → 0.30.1

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.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Refs.ts — pure document reference engine for rename-refactoring.
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).
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.
12
+ */
13
+
14
+ import { classifyRef, rewriteRef } from "./paths.ts";
15
+ import type { RemapCtx } from "./paths.ts";
16
+
17
+ /** A single reference rewrite, for the rename report. */
18
+ export interface RefChange {
19
+ refType: string;
20
+ from: string;
21
+ to: string;
22
+ }
23
+
24
+ export interface RewriteResult {
25
+ doc: unknown;
26
+ changes: RefChange[];
27
+ }
28
+
29
+ /** Matches `url(...)` targets inside a CSS string value, capturing the optional quote. */
30
+ const URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
31
+
32
+ /** Recompute a scalar string reference under `key`, recording any change. */
33
+ function rewriteScalar(
34
+ container: Record<string, unknown>,
35
+ key: string,
36
+ refType: string,
37
+ ctx: RemapCtx,
38
+ changes: RefChange[],
39
+ rootRelativeBare: boolean,
40
+ ): void {
41
+ const value = container[key];
42
+ const cls = classifyRef(value);
43
+ if (cls.kind !== "path") {
44
+ return;
45
+ }
46
+ const out = rewriteRef(cls, ctx, rootRelativeBare);
47
+ if (out !== null) {
48
+ changes.push({ from: value as string, refType, to: out });
49
+ container[key] = out;
50
+ }
51
+ }
52
+
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 {
61
+ const value = arr[index];
62
+ const cls = classifyRef(value);
63
+ if (cls.kind !== "path") {
64
+ return;
65
+ }
66
+ const out = rewriteRef(cls, ctx, false);
67
+ if (out !== null) {
68
+ changes.push({ from: value as string, refType, to: out });
69
+ arr[index] = out;
70
+ }
71
+ }
72
+
73
+ /** Rewrite every `url(...)` target inside a CSS string. */
74
+ function rewriteUrls(value: string, ctx: RemapCtx, changes: RefChange[]): string {
75
+ 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
+ });
87
+ }
88
+
89
+ /** Walk a `style` value tree, rewriting `url(...)` in every string it contains. */
90
+ function walkStyle(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
91
+ if (Array.isArray(node)) {
92
+ const arr = node as unknown[];
93
+ for (let i = 0; i < arr.length; i += 1) {
94
+ const item = arr[i];
95
+ if (typeof item === "string") {
96
+ const out = rewriteUrls(item, ctx, changes);
97
+ if (out !== item) {
98
+ arr[i] = out;
99
+ }
100
+ } else {
101
+ walkStyle(item, ctx, changes);
102
+ }
103
+ }
104
+ return;
105
+ }
106
+ if (!node || typeof node !== "object") {
107
+ return;
108
+ }
109
+ const obj = node as Record<string, unknown>;
110
+ for (const key of Object.keys(obj)) {
111
+ const value = obj[key];
112
+ if (typeof value === "string") {
113
+ const out = rewriteUrls(value, ctx, changes);
114
+ if (out !== value) {
115
+ obj[key] = out;
116
+ }
117
+ } else {
118
+ walkStyle(value, ctx, changes);
119
+ }
120
+ }
121
+ }
122
+
123
+ /** Recursive document walk dispatching each reference-bearing key. */
124
+ function walk(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
125
+ if (Array.isArray(node)) {
126
+ for (const item of node as unknown[]) {
127
+ walk(item, ctx, changes);
128
+ }
129
+ return;
130
+ }
131
+ if (!node || typeof node !== "object") {
132
+ return;
133
+ }
134
+ const obj = node as Record<string, unknown>;
135
+ for (const key of Object.keys(obj)) {
136
+ const value = obj[key];
137
+ switch (key) {
138
+ case "$ref": {
139
+ // File-path $ref only; state-scope refs (#/…, $map/…) are filtered by classifyRef.
140
+ rewriteScalar(obj, key, "$ref", ctx, changes, false);
141
+ break;
142
+ }
143
+ case "$layout": {
144
+ rewriteScalar(obj, key, "$layout", ctx, changes, true);
145
+ break;
146
+ }
147
+ case "$src":
148
+ case "$implementation": {
149
+ rewriteScalar(obj, key, key, ctx, changes, false);
150
+ break;
151
+ }
152
+ case "src":
153
+ case "href": {
154
+ rewriteScalar(obj, key, "attr", ctx, changes, false);
155
+ break;
156
+ }
157
+ case "imports": {
158
+ if (value && typeof value === "object" && !Array.isArray(value)) {
159
+ const imp = value as Record<string, unknown>;
160
+ for (const name of Object.keys(imp)) {
161
+ rewriteScalar(imp, name, "imports", ctx, changes, false);
162
+ }
163
+ }
164
+ break;
165
+ }
166
+ case "$elements": {
167
+ if (Array.isArray(value)) {
168
+ const els = value as unknown[];
169
+ for (let i = 0; i < els.length; i += 1) {
170
+ if (typeof els[i] === "string") {
171
+ rewriteIndexed(els, i, "$elements", ctx, changes);
172
+ } else {
173
+ walk(els[i], ctx, changes);
174
+ }
175
+ }
176
+ } else {
177
+ walk(value, ctx, changes);
178
+ }
179
+ break;
180
+ }
181
+ case "style": {
182
+ walkStyle(value, ctx, changes);
183
+ break;
184
+ }
185
+ default: {
186
+ walk(value, ctx, changes);
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ /** Rewrite every file-path reference in `doc` for a rename described by `ctx`. Mutates `doc`. */
193
+ export function rewriteDocRefs(doc: unknown, ctx: RemapCtx): RewriteResult {
194
+ const changes: RefChange[] = [];
195
+ walk(doc, ctx, changes);
196
+ return { changes, doc };
197
+ }
198
+
199
+ /** Recursive walk setting `tagName: newTag` wherever it currently equals `oldTag`. */
200
+ function walkTags(node: unknown, oldTag: string, newTag: string, counter: { n: number }): void {
201
+ if (Array.isArray(node)) {
202
+ for (const item of node as unknown[]) {
203
+ walkTags(item, oldTag, newTag, counter);
204
+ }
205
+ return;
206
+ }
207
+ if (!node || typeof node !== "object") {
208
+ return;
209
+ }
210
+ const obj = node as Record<string, unknown>;
211
+ if (obj.tagName === oldTag) {
212
+ obj.tagName = newTag;
213
+ counter.n += 1;
214
+ }
215
+ for (const key of Object.keys(obj)) {
216
+ walkTags(obj[key], oldTag, newTag, counter);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Rename a custom-element tag throughout `doc` — instance nodes (`<old-tag>`), mapped-list
222
+ * `map.tagName`, and the component definition file's own root `tagName`. File references (`$ref`,
223
+ * `$elements`, `cases`) are intentionally left to `rewriteDocRefs`, so the two passes compose.
224
+ * Mutates `doc`.
225
+ */
226
+ export function rewriteTagName(
227
+ doc: unknown,
228
+ oldTag: string,
229
+ newTag: string,
230
+ ): { doc: unknown; count: number } {
231
+ const counter = { n: 0 };
232
+ if (oldTag !== newTag) {
233
+ walkTags(doc, oldTag, newTag, counter);
234
+ }
235
+ return { count: counter.n, doc };
236
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Watcher.ts — a small chokidar wrapper that emits coalesced, batched FS-event payloads to a sink.
3
+ *
4
+ * Used by the desktop project session (which has no SSE stream) to push filesystem changes to its
5
+ * webview over RPC. The dev server keeps its own watcher in watch.ts (intertwined with
6
+ * build/reload); both share the pure `toFsEvent`/`coalesceFsEvents` helpers.
7
+ */
8
+
9
+ import { watch as chokidarWatch } from "chokidar";
10
+ import { coalesceFsEvents, toFsEvent } from "./fs-events.ts";
11
+ import type { FsEventPayload } from "./fs-events.ts";
12
+
13
+ const IGNORE_SEGMENTS = ["node_modules", ".git", "dist", ".jx", ".direnv", ".devenv"];
14
+
15
+ function isIgnored(path: string): boolean {
16
+ const f = path.replaceAll("\\", "/");
17
+ return IGNORE_SEGMENTS.some((s) => f.includes(`/${s}/`) || f.endsWith(`/${s}`));
18
+ }
19
+
20
+ export interface FsWatcherHandle {
21
+ close: () => Promise<void>;
22
+ }
23
+
24
+ /**
25
+ * Watch `root`, coalescing rapid changes and delivering batches to `sink`. Returns a handle whose
26
+ * `close()` stops the watcher and clears any pending batch.
27
+ */
28
+ export function createFsWatcher(
29
+ root: string,
30
+ sink: (events: FsEventPayload[]) => void,
31
+ opts: { debounce?: number } = {},
32
+ ): FsWatcherHandle {
33
+ const debounceMs = opts.debounce ?? 40;
34
+ let buffer: FsEventPayload[] = [];
35
+ let timer: ReturnType<typeof setTimeout> | null = null;
36
+
37
+ const watcher = chokidarWatch(root, {
38
+ ignoreInitial: true,
39
+ ignorePermissionErrors: true,
40
+ ignored: (path) => isIgnored(path),
41
+ });
42
+
43
+ watcher.on("all", (eventType, changedPath) => {
44
+ const event = toFsEvent(eventType, root, changedPath);
45
+ if (!event) {
46
+ return;
47
+ }
48
+ buffer = coalesceFsEvents(buffer, event);
49
+ if (timer) {
50
+ clearTimeout(timer);
51
+ }
52
+ timer = setTimeout(() => {
53
+ if (buffer.length > 0) {
54
+ sink(buffer);
55
+ buffer = [];
56
+ }
57
+ }, debounceMs);
58
+ });
59
+
60
+ return {
61
+ close: async () => {
62
+ if (timer) {
63
+ clearTimeout(timer);
64
+ timer = null;
65
+ }
66
+ buffer = [];
67
+ await watcher.close();
68
+ },
69
+ };
70
+ }
package/src/resolve.ts CHANGED
@@ -6,7 +6,34 @@ import { existsSync, readFileSync } from "node:fs";
6
6
  import { createRequire } from "node:module";
7
7
  import { loadContentTypes } from "@jxsuite/compiler/content-loader";
8
8
  import type { DynamicClass } from "@jxsuite/runtime/types";
9
- import type { JxClassDef } from "@jxsuite/schema/types";
9
+ import type { JxClassDef, ProjectConfig } from "@jxsuite/schema/types";
10
+
11
+ interface ModuleNamespace {
12
+ default?: Record<string, unknown>;
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ interface ClassInstance {
17
+ resolve?: () => unknown;
18
+ value?: unknown;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ interface ResolveBody {
23
+ $src?: string;
24
+ $prototype?: string;
25
+ $export?: string;
26
+ $base?: string;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ interface ServerFunctionBody {
31
+ $src?: string;
32
+ $export?: string;
33
+ $base?: string;
34
+ arguments?: Record<string, unknown>;
35
+ [key: string]: unknown;
36
+ }
10
37
 
11
38
  /**
12
39
  * Lazy-load project context (project.json + content types) for class instantiation.
@@ -24,7 +51,7 @@ async function loadProjectContext(projectRoot: string) {
24
51
  return null;
25
52
  }
26
53
  try {
27
- const config = JSON.parse(readFileSync(projectJsonPath, "utf8"));
54
+ const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
28
55
  const contentTypes = config.contentTypes
29
56
  ? await loadContentTypes(projectRoot, config)
30
57
  : new Map();
@@ -46,9 +73,9 @@ export async function handleResolve(
46
73
  root: string,
47
74
  activeProjectRoot: string | null = null,
48
75
  ) {
49
- let body;
76
+ let body: ResolveBody;
50
77
  try {
51
- body = await req.json();
78
+ body = (await req.json()) as ResolveBody;
52
79
  } catch {
53
80
  return new Response("Invalid JSON body", { status: 400 });
54
81
  }
@@ -114,14 +141,16 @@ export async function handleResolve(
114
141
  // Hybrid mode: redirect to the JS implementation
115
142
  const implPath = resolve(dirname(moduleAbsPath), classDef.$implementation);
116
143
  const exportName = xport ?? classDef.title ?? $prototype;
117
- const mod = await import(implPath);
118
- const ExportedClass = mod[exportName] ?? mod.default?.[exportName];
144
+ const mod = (await import(implPath)) as ModuleNamespace;
145
+ const ExportedClass =
146
+ exportName === undefined ? undefined : (mod[exportName] ?? mod.default?.[exportName]);
119
147
  if (typeof ExportedClass !== "function") {
120
148
  return new Response(`Export "${exportName}" not found in "${classDef.$implementation}"`, {
121
149
  status: 500,
122
150
  });
123
151
  }
124
- const instance = new ExportedClass(config);
152
+ const ClassCtor = ExportedClass as new (config: unknown) => ClassInstance;
153
+ const instance = new ClassCtor(config);
125
154
  const value =
126
155
  typeof instance.resolve === "function"
127
156
  ? await instance.resolve()
@@ -165,9 +194,9 @@ export async function handleResolve(
165
194
  * @param {string} root
166
195
  */
167
196
  export async function handleServerFunction(req: Request, root: string) {
168
- let body;
197
+ let body: ServerFunctionBody;
169
198
  try {
170
- body = await req.json();
199
+ body = (await req.json()) as ServerFunctionBody;
171
200
  } catch {
172
201
  return new Response("Invalid JSON body", { status: 400 });
173
202
  }
@@ -192,9 +221,9 @@ export async function handleServerFunction(req: Request, root: string) {
192
221
  });
193
222
  }
194
223
 
195
- let mod;
224
+ let mod: ModuleNamespace;
196
225
  try {
197
- mod = await import(moduleAbsPath);
226
+ mod = (await import(moduleAbsPath)) as ModuleNamespace;
198
227
  } catch (error) {
199
228
  return new Response(`Failed to import "${$src}": ${errorMessage(error)}`, {
200
229
  status: 500,
@@ -209,7 +238,7 @@ export async function handleServerFunction(req: Request, root: string) {
209
238
  }
210
239
 
211
240
  try {
212
- const result = await fn(args);
241
+ const result = await (fn as (args: Record<string, unknown>) => unknown)(args);
213
242
  return Response.json(result ?? null);
214
243
  } catch (error) {
215
244
  return Response.json({ error: errorMessage(error) }, { status: 500 });
package/src/server.ts CHANGED
@@ -30,6 +30,14 @@ import { existsSync, readFileSync } from "node:fs";
30
30
  * @param {string} urlPath - URL pathname (e.g. "/pages/@jxsuite/parser/Foo.class.json")
31
31
  * @returns {string | null} Absolute file path or null
32
32
  */
33
+ interface PackageJson {
34
+ exports?: Record<string, string | { import?: string; default?: string }>;
35
+ customElements?: string;
36
+ module?: string;
37
+ main?: string;
38
+ [key: string]: unknown;
39
+ }
40
+
33
41
  function resolveNpmPath(rootDir: string, urlPath: string) {
34
42
  let root = rootDir;
35
43
  let segments = urlPath.split("/").filter(Boolean);
@@ -49,7 +57,7 @@ function resolveNpmPath(rootDir: string, urlPath: string) {
49
57
  let start = -1;
50
58
  let isScoped = false;
51
59
  for (let i = 0; i < segments.length; i++) {
52
- if (segments[i].startsWith("@")) {
60
+ if (segments[i]!.startsWith("@")) {
53
61
  start = i;
54
62
  isScoped = true;
55
63
  break;
@@ -63,14 +71,14 @@ function resolveNpmPath(rootDir: string, urlPath: string) {
63
71
  if (start < 0 || start + 1 >= segments.length) {
64
72
  return null;
65
73
  }
66
- const scope = segments[start];
67
- const pkg = segments[start + 1];
74
+ const scope = segments[start]!;
75
+ const pkg = segments[start + 1]!;
68
76
  subpath = segments.slice(start + 2).join("/");
69
77
  pkgDir = join(root, "node_modules", scope, pkg);
70
78
  } else {
71
79
  // Unscoped: try each segment as a package name in node_modules
72
80
  for (let i = 0; i < segments.length; i++) {
73
- const candidate = join(root, "node_modules", segments[i]);
81
+ const candidate = join(root, "node_modules", segments[i]!);
74
82
  if (existsSync(join(candidate, "package.json"))) {
75
83
  start = i;
76
84
  pkgDir = candidate;
@@ -91,10 +99,11 @@ function resolveNpmPath(rootDir: string, urlPath: string) {
91
99
  // If there's a subpath, check package.json exports first
92
100
  if (subpath) {
93
101
  try {
94
- const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
102
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as PackageJson;
95
103
  const exportKey = `./${subpath}`;
96
- if (pkgJson.exports && pkgJson.exports[exportKey]) {
97
- const mapped = join(pkgDir, pkgJson.exports[exportKey]);
104
+ const exportVal = pkgJson.exports?.[exportKey];
105
+ if (typeof exportVal === "string") {
106
+ const mapped = join(pkgDir, exportVal);
98
107
  if (existsSync(mapped)) {
99
108
  return mapped;
100
109
  }
@@ -107,7 +116,7 @@ function resolveNpmPath(rootDir: string, urlPath: string) {
107
116
  }
108
117
  // CEM-relative: subpath may be relative to the custom elements manifest directory
109
118
  try {
110
- const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
119
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as PackageJson;
111
120
  if (pkgJson.customElements) {
112
121
  const cemDir = pkgJson.customElements.replace(/\/[^/]+$/, "");
113
122
  const cemRelative = join(pkgDir, cemDir, subpath);
@@ -120,7 +129,7 @@ function resolveNpmPath(rootDir: string, urlPath: string) {
120
129
 
121
130
  // Bare package (no subpath): resolve entry point
122
131
  try {
123
- const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
132
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as PackageJson;
124
133
  const exp = pkgJson.exports?.["."];
125
134
  const entry =
126
135
  (typeof exp === "object" ? (exp.import ?? exp.default) : exp) ??
@@ -236,7 +245,7 @@ export async function createDevServer(options: {
236
245
  if (enableStudio && path.startsWith("/__studio/")) {
237
246
  // Activate project — tells the server which project root to use for static file fallback
238
247
  if (path === "/__studio/activate" && req.method === "POST") {
239
- const body = await req.json();
248
+ const body = (await req.json()) as { root?: string };
240
249
  const raw = body.root || null;
241
250
  // Always store as absolute path
242
251
  activeProjectRoot = raw ? resolve(absRoot, raw) : null;
@@ -265,9 +274,12 @@ export async function createDevServer(options: {
265
274
  // Static files
266
275
 
267
276
  // If the URL path is an absolute filesystem path under the active project, serve directly.
268
- // Browsers produce "//abs/path" when an absolute path is used as a URL path — normalise.
269
- const fsPath = path.startsWith("//") ? path.slice(1) : path;
270
- if (activeProjectRoot && fsPath.startsWith(activeProjectRoot)) {
277
+ // A POSIX absolute path arrives as "//abs/path"; a Windows one as "/C:/dir/file" (leading
278
+ // Slash + forward slashes), so drop the slash before the drive letter. Compare with
279
+ // Separators normalised, since activeProjectRoot is OS-native (backslashes on Windows).
280
+ const fsPath = path.startsWith("//") ? path.slice(1) : path.replace(/^\/([A-Za-z]:)/, "$1");
281
+ const normSep = (p: string) => p.replaceAll("\\", "/");
282
+ if (activeProjectRoot && normSep(fsPath).startsWith(normSep(activeProjectRoot))) {
271
283
  const file = Bun.file(fsPath);
272
284
  if (await file.exists()) {
273
285
  return new Response(file);
@@ -302,7 +314,7 @@ export async function createDevServer(options: {
302
314
  minify: false,
303
315
  });
304
316
  if (result.success && result.outputs.length > 0) {
305
- bundleCache.set(cacheKey, await result.outputs[0].text());
317
+ bundleCache.set(cacheKey, await result.outputs[0]!.text());
306
318
  }
307
319
  } catch (error) {
308
320
  console.error("Bundle failed for", resolved, error);