@jxsuite/server 4.5.0 → 6.0.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.
@@ -28,6 +28,7 @@
28
28
  * string of its own and `Referrer-Policy: no-referrer`, so nothing carries it onward.
29
29
  */
30
30
 
31
+ import { isJsonObject, isNumber, isString } from "@jxsuite/schema/guards";
31
32
  import { secretsMatch } from "./net-guard.ts";
32
33
 
33
34
  /** The loopback redirect path this server hosts. */
@@ -345,29 +346,33 @@ export async function exchangeCode(params: {
345
346
  headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
346
347
  method: "POST",
347
348
  });
348
- const payload = (await response.json().catch(() => null)) as {
349
- access_token?: unknown;
350
- error?: unknown;
351
- error_description?: unknown;
352
- expires_in?: unknown;
353
- refresh_token?: unknown;
354
- scope?: unknown;
355
- token_type?: unknown;
356
- } | null;
349
+ // A body that is not JSON reads as no payload, and is reported by status below.
350
+ const payload: unknown = await response.json().catch(() => null);
351
+ const fields = isJsonObject(payload) ? payload : null;
357
352
 
358
- if (payload && typeof payload.error === "string") {
359
- const description =
360
- typeof payload.error_description === "string" ? payload.error_description : payload.error;
353
+ if (fields && isString(fields.error)) {
354
+ const description = isString(fields.error_description)
355
+ ? fields.error_description
356
+ : fields.error;
361
357
  throw new Error(`The provider refused the sign-in: ${description}`);
362
358
  }
363
- if (!response.ok || !payload || typeof payload.access_token !== "string") {
359
+ if (!response.ok || !fields || !isString(fields.access_token)) {
364
360
  throw new Error(`The token exchange failed (HTTP ${response.status}).`);
365
361
  }
362
+ // RFC 6749 §5.1 makes these optional; each is kept only when the provider sent the right type.
363
+ const optional: Pick<TokenResult, "expiresIn" | "refreshToken" | "scope"> = {};
364
+ if (isNumber(fields.expires_in)) {
365
+ optional.expiresIn = fields.expires_in;
366
+ }
367
+ if (isString(fields.refresh_token)) {
368
+ optional.refreshToken = fields.refresh_token;
369
+ }
370
+ if (isString(fields.scope)) {
371
+ optional.scope = fields.scope;
372
+ }
366
373
  return {
367
- accessToken: payload.access_token,
368
- ...(typeof payload.expires_in === "number" ? { expiresIn: payload.expires_in } : {}),
369
- ...(typeof payload.refresh_token === "string" ? { refreshToken: payload.refresh_token } : {}),
370
- ...(typeof payload.scope === "string" ? { scope: payload.scope } : {}),
371
- tokenType: typeof payload.token_type === "string" ? payload.token_type : "bearer",
374
+ accessToken: fields.access_token,
375
+ ...optional,
376
+ tokenType: isString(fields.token_type) ? fields.token_type : "bearer",
372
377
  };
373
378
  }
package/src/packages.ts CHANGED
@@ -8,42 +8,23 @@
8
8
  import { existsSync } from "node:fs";
9
9
  import { readFile, writeFile } from "node:fs/promises";
10
10
  import { resolve } from "node:path";
11
-
12
- export interface PackageInfo {
13
- name: string;
14
- version: string;
15
- /** True when the dependency lives in `devDependencies` rather than `dependencies`. */
16
- dev?: boolean;
17
- }
18
-
19
- /**
20
- * A dependency's pinned range beside the newest version published for it on the registry.
21
- *
22
- * Every registry-range dependency gets a row, behind or not. Deciding whether a row is an upgrade
23
- * needs a semver comparison against the range's base, and the caller — which has to make that
24
- * comparison anyway to tell an upgrade from a project deliberately pinned ahead — is where it
25
- * belongs. A backend that filtered here could only describe an up-to-date package as an absence.
26
- */
27
- export interface PackageVersionInfo {
28
- name: string;
29
- /** The version range pinned in package.json (e.g. "^0.19.0"). */
30
- current: string;
31
- /** The newest version published for this package on the registry. */
32
- latest: string;
33
- dev?: boolean;
34
- }
35
-
36
- /** Result of a package mutation that runs `bun install` (install / set-versions). */
37
- export interface PackageOpResult {
38
- ok: boolean;
39
- /** Combined stdout/stderr from the bun invocation, surfaced to the user on failure. */
40
- log?: string;
41
- }
42
-
11
+ import type { PackageInfo, PackageOpResult, PackageVersionInfo } from "@jxsuite/protocol";
12
+ import type { JsonValue } from "@jxsuite/schema/types";
13
+
14
+ /* The package rows and operation result are the Studio wire contract (`packages*` routes), declared
15
+ once in @jxsuite/protocol; re-exported so this module's public names stay. Every
16
+ registry-range dependency gets a `PackageVersionInfo` row, behind or not: deciding whether a row
17
+ is an upgrade needs a semver comparison against the range's base, and the caller, which has to
18
+ make that comparison anyway to tell an upgrade from a project deliberately pinned ahead, is where
19
+ it belongs. A backend that filtered here could only describe an up-to-date package as an
20
+ absence. */
21
+ export type { PackageInfo, PackageOpResult, PackageVersionInfo } from "@jxsuite/protocol";
22
+
23
+ /** The manifest members this module edits; every other member is written back unchanged. */
43
24
  interface PackageJson {
44
25
  dependencies?: Record<string, string>;
45
26
  devDependencies?: Record<string, string>;
46
- [key: string]: unknown;
27
+ [key: string]: JsonValue | undefined;
47
28
  }
48
29
 
49
30
  /** Minimal fetch signature (injectable for tests; the global `fetch` satisfies it). */
@@ -72,6 +53,9 @@ async function readPackageJson(root: string): Promise<PackageJson | null> {
72
53
  return null;
73
54
  }
74
55
  try {
56
+ /* SAFETY: package.json is the npm manifest `bun install` reads, an object whose
57
+ `dependencies`/`devDependencies` map package names to version-spec strings; text that is
58
+ not JSON throws into the catch below. */
75
59
  return JSON.parse(await readFile(pkgPath, "utf8")) as PackageJson;
76
60
  } catch {
77
61
  return null;
@@ -104,8 +88,8 @@ export function dependenciesNeedInstall(root: string): boolean {
104
88
 
105
89
  /**
106
90
  * Path to the bun executable. Uses the bun running the current process (`process.execPath`) so a
107
- * packaged desktop app spawns the bun bundled with its Electrobun distribution rather than relying
108
- * on a system-wide `bun` in `$PATH` (which end users may not have). Falls back to a PATH lookup if
91
+ * packaged desktop app spawns the bun bundled with its Electron distribution rather than relying on
92
+ * a system-wide `bun` in `$PATH` (which end users may not have). Falls back to a PATH lookup if
109
93
  * `execPath` is somehow unset.
110
94
  */
111
95
  export function bunExecutable(): string {
@@ -120,7 +104,11 @@ async function runBun(args: string[], cwd: string): Promise<PackageOpResult> {
120
104
  proc.exited,
121
105
  ]);
122
106
  const log = `${stdout}${stderr}`.trim();
123
- return { ok: exitCode === 0, ...(log ? { log } : {}) };
107
+ const result: PackageOpResult = { ok: exitCode === 0 };
108
+ if (log) {
109
+ result.log = log;
110
+ }
111
+ return result;
124
112
  }
125
113
 
126
114
  /** Run `bun install` in the project root. */
@@ -165,6 +153,8 @@ export async function fetchLatestVersion(
165
153
  if (!res.ok) {
166
154
  return null;
167
155
  }
156
+ /* SAFETY: the registry's version endpoint answers that version's manifest, whose `version`
157
+ is a string; a body that is not JSON (or is `null`) throws into the catch, giving null. */
168
158
  const data = (await res.json()) as { version?: string };
169
159
  return data.version ?? null;
170
160
  } catch {
@@ -228,6 +218,7 @@ export async function setPackageVersions(
228
218
  }
229
219
  let pkg: PackageJson;
230
220
  try {
221
+ // SAFETY: same npm manifest contract as readPackageJson; unparseable text is caught below.
231
222
  pkg = JSON.parse(await readFile(pkgPath, "utf8")) as PackageJson;
232
223
  } catch (error) {
233
224
  return { log: `Unparseable package.json: ${String(error)}`, ok: false };
@@ -239,8 +230,7 @@ export async function setPackageVersions(
239
230
  pkg.dependencies[update.name] = update.version;
240
231
  } else {
241
232
  const bucket = update.dev ? "devDependencies" : "dependencies";
242
- pkg[bucket] = { ...(pkg[bucket] as Record<string, string> | undefined) };
243
- (pkg[bucket] as Record<string, string>)[update.name] = update.version;
233
+ pkg[bucket] = { ...pkg[bucket], [update.name]: update.version };
244
234
  }
245
235
  }
246
236
  await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
package/src/problem.ts CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { PROBLEM_MEDIA_TYPE, problemDetails } from "@jxsuite/protocol";
15
15
  import type { ProblemTypeName } from "@jxsuite/protocol";
16
+ import type { JsonObject } from "@jxsuite/schema/types";
16
17
 
17
18
  /**
18
19
  * A failure response for one occurrence of a declared problem type.
@@ -23,14 +24,10 @@ import type { ProblemTypeName } from "@jxsuite/protocol";
23
24
  *
24
25
  * @param {ProblemTypeName} name - A declared type from `PROBLEM_TYPES`
25
26
  * @param {string} [detail] - What happened this time; the line a human reads
26
- * @param {Record<string, unknown>} [extensions] - Extension members this type documents
27
+ * @param {JsonObject} [extensions] - Extension members this type documents
27
28
  * @returns {Response}
28
29
  */
29
- export function problem(
30
- name: ProblemTypeName,
31
- detail?: string,
32
- extensions?: Record<string, unknown>,
33
- ): Response {
30
+ export function problem(name: ProblemTypeName, detail?: string, extensions?: JsonObject): Response {
34
31
  const body = problemDetails(name, detail, extensions);
35
32
  // The explicit Content-Type is the point: `Response.json` would send `application/json`, and
36
33
  // RFC 9457 §3 is what tells a client this body is a problem rather than a result.
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Shared project server factory. Generalizes the chromium variant's ad-hoc in-process Bun.serve
3
- * into one createProjectServer() used by the chromium desktop launcher (and, in Phase 7,
4
- * electrobun).
3
+ * into one createProjectServer() used by the chromium desktop launcher and the Electron hub.
5
4
  *
6
5
  * It serves three surfaces from a single loopback-bound Bun.serve:
7
6
  *
@@ -43,18 +42,44 @@ import {
43
42
  import { problem } from "./problem.ts";
44
43
  import { createLoopbackAuthorizer, OAUTH_CALLBACK_PATH } from "./oauth-loopback.ts";
45
44
  import type { LoopbackAuthorizer } from "./oauth-loopback.ts";
45
+ import type { FsEventBatch } from "./sse.ts";
46
+ import type { JsonPrimitive, JsonValue } from "@jxsuite/schema/types";
47
+
48
+ /**
49
+ * What a handler answers, sent as the reply frame's `result`: anything `JSON.stringify` encodes,
50
+ * with `undefined` sent as `null`. Objects are admitted by kind rather than by JSON shape because
51
+ * handlers answer with typed values, and TypeScript never treats an interface as a `JsonValue`.
52
+ */
53
+ export type RpcResult = JsonPrimitive | object | undefined;
54
+
55
+ /** One RPC method: the request frame's decoded `params` in, the reply's `result` out. */
56
+ export type RpcHandler = (params: JsonValue | undefined) => Promise<RpcResult>;
57
+
58
+ /**
59
+ * What an unsolicited push carries as its `params`: JSON, or the filesystem batch the sidebar
60
+ * consumes (the same batch the dev server sends as its SSE `fs` event).
61
+ */
62
+ export type RpcPushParams = JsonValue | FsEventBatch;
63
+
64
+ /** An RPC request frame as the shell sends it: the id the reply echoes, and what to dispatch. */
65
+ export interface RpcRequestFrame {
66
+ id: number;
67
+ method: string;
68
+ params?: JsonValue;
69
+ }
46
70
 
47
71
  /** A resolved per-window session: its project root plus its RPC handler map. */
48
72
  export interface ProjectServerSession {
49
73
  projectRoot: string | null;
50
- handlers: Record<string, (params: unknown) => Promise<unknown>>;
74
+ handlers: Record<string, RpcHandler>;
51
75
  }
52
76
 
53
77
  export interface CreateProjectServerOptions {
54
78
  /**
55
- * Resolve the session for a given window id (the `win` query param / stashed socket data).
56
- * chromium passes `() => defaultSession`; electrobun (Phase 7) swaps it for a per-window lookup.
57
- * A missing/unknown window returns `null`, which the routes treat as a 404 fail-closed.
79
+ * Resolve the session for a given window id (the `win` query param / stashed socket data). the
80
+ * single-window launcher passes `() => defaultSession`; the Electron hub swaps it for a
81
+ * per-window lookup. A missing/unknown window returns `null`, which the routes treat as a 404
82
+ * fail-closed.
58
83
  */
59
84
  resolveSession: (winId: string | null) => ProjectServerSession | null;
60
85
  /** Absolute dir holding the studio shell + canvas.html + dist/. */
@@ -89,7 +114,7 @@ export interface ProjectServerHandle {
89
114
  * @param winId Deliver only to sockets stashed with this window id; omit for every socket.
90
115
  * @returns How many sockets the message reached — 0 means nothing is listening yet.
91
116
  */
92
- push: (method: string, params?: unknown, winId?: string | null) => number;
117
+ push: (method: string, params?: RpcPushParams, winId?: string | null) => number;
93
118
  /**
94
119
  * The RFC 8252 loopback authorization host. The desktop launcher drives it: `begin()` for the URL
95
120
  * to open in the user's browser, then `await pending.code` and {@link exchangeCode}.
@@ -337,9 +362,14 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
337
362
  sockets.delete(ws);
338
363
  },
339
364
  async message(ws, raw) {
340
- let msg: { id: number; method: string; params?: unknown };
365
+ let msg: RpcRequestFrame;
341
366
  try {
342
- msg = JSON.parse(raw as string) as { id: number; method: string; params?: unknown };
367
+ /* SAFETY: trust boundary. A binary frame decodes as UTF-8, exactly as JSON.parse's own
368
+ string coercion would, and the parse yields a JSON value. The only client on this
369
+ token-gated socket is the shell's RPC transport, which sends `{ id, method, params }`;
370
+ a frame that breaks that finds no handler under its `method` and is answered with an
371
+ error (a `null` frame throws in this callback, as it always has). */
372
+ msg = JSON.parse(raw.toString()) as RpcRequestFrame;
343
373
  } catch {
344
374
  ws.send(JSON.stringify({ error: "Invalid JSON", id: 0 }));
345
375
  return;
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { basename, dirname, extname, relative, resolve } from "node:path";
12
12
  import { stat, writeFile } from "node:fs/promises";
13
+ import { isJsonObject, isString } from "@jxsuite/schema/guards";
13
14
  import { errorMessage } from "@jxsuite/schema/parse";
14
15
  import { isUnder } from "./paths.ts";
15
16
  import { rewriteDocRefs, rewriteTagName } from "./refs.ts";
@@ -99,9 +100,9 @@ export async function applyRename(opts: ApplyRenameOptions): Promise<RenameRepor
99
100
  if (!isDir) {
100
101
  try {
101
102
  const { doc } = await loadDoc(absTo, opts.registry);
102
- const oldTag = (doc as { tagName?: unknown }).tagName;
103
+ const oldTag = isJsonObject(doc) ? doc.tagName : undefined;
103
104
  const newTag = deriveTag(absTo);
104
- if (typeof oldTag === "string" && oldTag.includes("-") && newTag !== oldTag) {
105
+ if (isString(oldTag) && oldTag.includes("-") && newTag !== oldTag) {
105
106
  if (newTag.includes("-")) {
106
107
  tagRename = { from: oldTag, to: newTag };
107
108
  } else {
Binary file
@@ -5,26 +5,25 @@
5
5
  */
6
6
 
7
7
  import { relative } from "node:path";
8
+ import type { FsEvent } from "@jxsuite/protocol";
8
9
 
9
- /** A filesystem change as broadcast to the studio shell (root-relative, forward-slashed path). */
10
- export interface FsEventPayload {
11
- type: "add" | "change" | "unlink" | "addDir" | "unlinkDir";
12
- path: string;
13
- isDir: boolean;
14
- }
10
+ /* A filesystem change as broadcast to the studio shell is the protocol's `FsEvent`, declared once
11
+ there; `FsEventPayload` stays this module's public name for it. */
12
+ export type { FsEvent as FsEventPayload } from "@jxsuite/protocol";
13
+
14
+ const VALID: ReadonlySet<string> = new Set(["add", "change", "unlink", "addDir", "unlinkDir"]);
15
15
 
16
- const VALID = new Set(["add", "change", "unlink", "addDir", "unlinkDir"]);
16
+ /** Whether chokidar's `eventType` is one of the five changes the sidebar is told about. */
17
+ function isFsEventType(eventType: string): eventType is FsEvent["type"] {
18
+ return VALID.has(eventType);
19
+ }
17
20
 
18
21
  /**
19
22
  * Map one chokidar event to a payload, or null for events we ignore (`ready`/`raw`/`error`) or
20
23
  * paths that escape the root.
21
24
  */
22
- export function toFsEvent(
23
- eventType: string,
24
- root: string,
25
- changedPath: string,
26
- ): FsEventPayload | null {
27
- if (!VALID.has(eventType)) {
25
+ export function toFsEvent(eventType: string, root: string, changedPath: string): FsEvent | null {
26
+ if (!isFsEventType(eventType)) {
28
27
  return null;
29
28
  }
30
29
  const path = relative(root, changedPath).replaceAll("\\", "/");
@@ -34,7 +33,7 @@ export function toFsEvent(
34
33
  return {
35
34
  isDir: eventType === "addDir" || eventType === "unlinkDir",
36
35
  path,
37
- type: eventType as FsEventPayload["type"],
36
+ type: eventType,
38
37
  };
39
38
  }
40
39
 
@@ -42,7 +41,7 @@ export function toFsEvent(
42
41
  * Fold one event into a buffer, collapsing redundant churn per path (last-write-wins, except
43
42
  * add+unlink cancels, add+change stays add, and unlink+add becomes a re-add). Returns a new array.
44
43
  */
45
- export function coalesceFsEvents(buffer: FsEventPayload[], next: FsEventPayload): FsEventPayload[] {
44
+ export function coalesceFsEvents(buffer: FsEvent[], next: FsEvent): FsEvent[] {
46
45
  const prev = buffer.find((e) => e.path === next.path);
47
46
  if (!prev) {
48
47
  return [...buffer, next];
@@ -19,6 +19,8 @@
19
19
 
20
20
  import { BUILD_LANES, projectPathsForSiteUrl, siteUrlForPath } from "@jxsuite/schema/asset-paths";
21
21
  import type { AssetLane, AssetMount } from "@jxsuite/schema/asset-paths";
22
+ import { isString } from "@jxsuite/schema/guards";
23
+ import type { JsonValue } from "@jxsuite/schema/types";
22
24
 
23
25
  /**
24
26
  * The lanes a rewritten site URL is re-emitted through, most-publishable first.
@@ -43,6 +45,12 @@ const AUTHORED_LANES: readonly AssetLane[] = [...BUILD_LANES, "root"];
43
45
  */
44
46
  const READ_LANES: readonly AssetLane[] = ["mounts", "root", "public"];
45
47
 
48
+ /** A path-like value split at its first `?` or `#`: the path itself, and what trails it. */
49
+ export interface RefParts {
50
+ core: string;
51
+ suffix: string;
52
+ }
53
+
46
54
  /** Classification of a reference string value. */
47
55
  export type RefClass =
48
56
  | { kind: "none" }
@@ -51,7 +59,7 @@ export type RefClass =
51
59
  | { kind: "path"; core: string; suffix: string; rooted: boolean };
52
60
 
53
61
  /** Split a trailing `?query` or `#fragment` off a path-like value. */
54
- export function splitQueryHash(value: string): { core: string; suffix: string } {
62
+ export function splitQueryHash(value: string): RefParts {
55
63
  for (let i = 0; i < value.length; i += 1) {
56
64
  const ch = value[i];
57
65
  if (ch === "?" || ch === "#") {
@@ -68,8 +76,8 @@ export function splitQueryHash(value: string): { core: string; suffix: string }
68
76
  * actual precision, so bare npm specifiers (which never resolve to the renamed file) are simply
69
77
  * never matched.
70
78
  */
71
- export function classifyRef(value: unknown): RefClass {
72
- if (typeof value !== "string" || value.length === 0) {
79
+ export function classifyRef(value: JsonValue | undefined): RefClass {
80
+ if (!isString(value) || value.length === 0) {
73
81
  return { kind: "none" };
74
82
  }
75
83
  if (
@@ -249,9 +257,12 @@ export interface RemapCtx {
249
257
  * Recompute a single path reference for a rename, preserving its original style. Returns the new
250
258
  * string, or null when nothing changes.
251
259
  *
252
- * `rootRelativeBare` makes a bare value (no "./" or "../") resolve against the project root rather
253
- * than the document directory — used for `$layout`, which the compiler resolves against projectRoot
254
- * (layout-resolver.ts), and for site-relative conventions.
260
+ * `rootRelativeBare` makes the value resolve against the project root rather than the document
261
+ * directory, in every spelling: `./layouts/base.json` in `pages/blog/[slug].json` names
262
+ * `layouts/base.json`, because the compiler resolves `$layout` with `resolve(projectRoot, ref)`
263
+ * (layout-resolver.ts). Treating a `./` value as document-relative there missed every page's own
264
+ * `$layout`, so a renamed layout left them pointing at the old name. Used for `$layout` and for
265
+ * `build.copy` keys, which are project paths too.
255
266
  */
256
267
  export function rewriteRef(
257
268
  cls: { core: string; suffix: string; rooted: boolean },
@@ -262,10 +273,8 @@ export function rewriteRef(
262
273
  if (rooted) {
263
274
  return rewriteSiteUrl(core, suffix, ctx);
264
275
  }
265
- const startsRel = core.startsWith("./") || core.startsWith("../");
266
- const useRoot = rootRelativeBare && !startsRel;
267
- const baseOld = useRoot ? ctx.root : ctx.docOldDir;
268
- const baseNew = useRoot ? ctx.root : ctx.docNewDir;
276
+ const baseOld = rootRelativeBare ? ctx.root : ctx.docOldDir;
277
+ const baseNew = rootRelativeBare ? ctx.root : ctx.docNewDir;
269
278
 
270
279
  const targetOld = joinPosix(baseOld, core);
271
280
  const targetNew = remapTarget(targetOld, ctx.oldAbs, ctx.newAbs);