@jxsuite/protocol 1.0.0 → 1.1.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/protocol",
3
- "version": "1.0.0",
3
+ "version": "1.1.2",
4
4
  "description": "The Studio Backend Protocol — wire types and the canonical route table every Jx Studio backend implements",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,8 +14,10 @@
14
14
  "type": "module",
15
15
  "exports": {
16
16
  ".": "./src/index.ts",
17
- "./types": "./src/types.ts",
18
- "./routes": "./src/routes.ts"
17
+ "./problem": "./src/problem.ts",
18
+ "./problems": "./src/problems.ts",
19
+ "./routes": "./src/routes.ts",
20
+ "./types": "./src/types.ts"
19
21
  },
20
22
  "publishConfig": {
21
23
  "provenance": true
@@ -25,6 +27,6 @@
25
27
  "test:coverage": "bun test --isolate --coverage"
26
28
  },
27
29
  "dependencies": {
28
- "@jxsuite/schema": "^1.5.0"
30
+ "@jxsuite/schema": "^1.8.0"
29
31
  }
30
32
  }
package/src/index.ts CHANGED
@@ -6,5 +6,7 @@
6
6
  * @license MIT
7
7
  */
8
8
 
9
+ export * from "./problem";
10
+ export * from "./problems";
9
11
  export * from "./routes";
10
12
  export type * from "./types";
package/src/problem.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * RFC 9457 Problem Details — one failure shape for every Jx backend.
3
+ *
4
+ * Before this, each backend invented its own: `{error: "..."}` here, bare text there, a 200 with an
5
+ * `upstreamError` field somewhere else. The cost landed on the client, which grew a separate reader
6
+ * per shape, and on the user, who could get a failure with no detail at all because the reader that
7
+ * ran was not the one for the shape that arrived.
8
+ *
9
+ * A problem is **data about one occurrence of one problem type**, and the type is a URI. Two
10
+ * decisions follow from that and are worth stating, because both look arbitrary until they are
11
+ * explained:
12
+ *
13
+ * - **`type` is absolute.** RFC 9457 permits a relative reference, resolved against the request
14
+ * URL — which on a dev server is `http://127.0.0.1:3000/problems/…` and serves nothing. An
15
+ * absolute `https://jxsuite.com/problems/…` means the same thing everywhere, which is the whole
16
+ * point of identifying a type by URI.
17
+ * - **`instance` is never emitted.** It identifies the specific occurrence, and Jx has no
18
+ * per-occurrence resource to point at. A field whose only possible value is a fabricated URI is
19
+ * noise that looks like information.
20
+ *
21
+ * @license MIT
22
+ */
23
+
24
+ /** The media type a problem response carries (RFC 9457 §3). */
25
+ export const PROBLEM_MEDIA_TYPE = "application/problem+json";
26
+
27
+ /** The namespace every Jx problem type lives under. Absolute, for the reason in the module note. */
28
+ export const PROBLEM_TYPE_BASE = "https://jxsuite.com/problems/";
29
+
30
+ /**
31
+ * A problem document (RFC 9457 §3.1).
32
+ *
33
+ * `title` describes the **type** and must not vary between occurrences; `detail` describes **this**
34
+ * occurrence and is the one a human reads. Conflating them is the standard's most common misuse and
35
+ * the reason a client cannot group failures.
36
+ */
37
+ export interface ProblemDetails {
38
+ /** Absolute URI identifying the problem type. */
39
+ type: string;
40
+ /** Short, human-readable summary of the TYPE. Stable across occurrences. */
41
+ title: string;
42
+ /** The HTTP status the origin generated, repeated so a stored problem stays self-describing. */
43
+ status: number;
44
+ /** Human-readable explanation of THIS occurrence. */
45
+ detail?: string;
46
+ /**
47
+ * Compatibility alias for `detail`, emitted for one release.
48
+ *
49
+ * Every existing Jx client reads `body.error`. Emitting both lets the server change shape without
50
+ * a synchronized client release across ~160 call sites: the server lands alone, the clients
51
+ * follow, and a third one-line change deletes this field. It is **not** part of RFC 9457 and no
52
+ * new reader should be written against it.
53
+ *
54
+ * @deprecated Read `detail`. This field is removed once every client has migrated.
55
+ */
56
+ error?: string;
57
+ /** Extension members (RFC 9457 §3.2) — anything the type documents. */
58
+ [extension: string]: unknown;
59
+ }
60
+
61
+ /** The slug identifying a Jx problem type, i.e. the last segment of its `type` URI. */
62
+ export function problemSlug(type: unknown): string | null {
63
+ if (typeof type !== "string" || !type.startsWith(PROBLEM_TYPE_BASE)) {
64
+ return null;
65
+ }
66
+ const slug = type.slice(PROBLEM_TYPE_BASE.length);
67
+ return slug === "" ? null : slug;
68
+ }
69
+
70
+ /** True when the value has the shape of a problem document. */
71
+ export function isProblemDetails(value: unknown): value is ProblemDetails {
72
+ if (!value || typeof value !== "object") {
73
+ return false;
74
+ }
75
+ const candidate = value as Partial<ProblemDetails>;
76
+ return typeof candidate.type === "string" && typeof candidate.status === "number";
77
+ }
78
+
79
+ /**
80
+ * The one line a failure body carries, or null when it carries none.
81
+ *
82
+ * The order is most-specific-first: `detail` describes THIS occurrence, the legacy `error` is the
83
+ * same string under the pre-RFC-9457 name, and `title` describes the TYPE — useful, but the weaker
84
+ * answer whenever a `detail` exists.
85
+ *
86
+ * Null rather than a generic string, because a caller usually knows a better fallback than any
87
+ * generic one: "Failed to list connections" beats "Request failed (500)". Use {@link problemMessage}
88
+ * when there is no such context.
89
+ *
90
+ * @param {unknown} body - A parsed response body, whatever shape it turned out to be
91
+ * @returns {string | null}
92
+ */
93
+ export function problemDetail(body: unknown): string | null {
94
+ if (typeof body === "string") {
95
+ return body.trim() === "" ? null : body;
96
+ }
97
+ if (!body || typeof body !== "object") {
98
+ return null;
99
+ }
100
+ const candidate = body as { detail?: unknown; error?: unknown; title?: unknown };
101
+ for (const value of [candidate.detail, candidate.error, candidate.title]) {
102
+ if (typeof value === "string" && value.trim() !== "") {
103
+ return value;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * The one line a human should be shown for a failure, from any shape a Jx backend has ever sent.
111
+ *
112
+ * This is the reader the client keeps — one, not five. It reads a problem's `detail`, then the
113
+ * legacy `{error}` body, then the type's `title`, and finally falls back to the status. The order
114
+ * is most-specific-first, because a `title` describes the type rather than the occurrence and is
115
+ * the less useful of the two whenever a `detail` exists.
116
+ *
117
+ * @param {unknown} body - A parsed response body, whatever shape it turned out to be
118
+ * @param {number} [status] - The response status, used only when the body says nothing
119
+ * @returns {string}
120
+ */
121
+ export function problemMessage(body: unknown, status?: number): string {
122
+ return (
123
+ problemDetail(body) ?? (status === undefined ? "Request failed" : `Request failed (${status})`)
124
+ );
125
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The Jx problem-type registry — the failure counterpart of `STUDIO_ROUTES`.
3
+ *
4
+ * A problem type is a URI a client can key on, and a URI nobody wrote down is a string each backend
5
+ * spells differently. So the types live here, in one table, in the same idiom as the route table:
6
+ * declared once, exported as data, rendered into the docs by the same generator, and checked by the
7
+ * same drift machinery.
8
+ *
9
+ * **What belongs here.** A type is a *class* of failure a client might handle differently — not a
10
+ * message. "The project root was refused" is a type; "root /x/y was refused" is a `detail`. If two
11
+ * entries would only ever differ in their wording, they are one type.
12
+ *
13
+ * @license MIT
14
+ */
15
+
16
+ import { PROBLEM_TYPE_BASE } from "./problem.ts";
17
+ import type { ProblemDetails } from "./problem.ts";
18
+
19
+ /** One declared problem type: what it means, and the status it is generated with. */
20
+ export interface ProblemType {
21
+ /** Absolute `type` URI. */
22
+ type: string;
23
+ /** Short, human-readable summary of the type. Stable across occurrences. */
24
+ title: string;
25
+ /** The HTTP status this type is generated with. */
26
+ status: number;
27
+ /** Extension members this type documents, if any. */
28
+ extensions?: readonly string[];
29
+ }
30
+
31
+ const problem = (
32
+ slug: string,
33
+ status: number,
34
+ title: string,
35
+ extensions?: readonly string[],
36
+ ): ProblemType => ({
37
+ status,
38
+ title,
39
+ type: `${PROBLEM_TYPE_BASE}${slug}`,
40
+ ...(extensions === undefined ? {} : { extensions }),
41
+ });
42
+
43
+ /**
44
+ * Every problem type a Jx backend may answer with, keyed by a stable name.
45
+ *
46
+ * Grouped by what went wrong rather than by which route emitted it: the same bad request is the
47
+ * same problem whether it arrived at `/__studio/files` or `/__studio/data/rows`, and a client that
48
+ * handles `invalid-request` handles both.
49
+ */
50
+ export const PROBLEM_TYPES = {
51
+ // ─── The request ──────────────────────────────────────────────────────────
52
+ invalidRequest: problem("invalid-request", 400, "The request was malformed or incomplete"),
53
+ notFound: problem("not-found", 404, "The requested resource does not exist"),
54
+ methodNotAllowed: problem("method-not-allowed", 405, "That method is not allowed on this route"),
55
+ conflict: problem("conflict", 409, "The request conflicts with the current state", ["conflicts"]),
56
+ payloadTooLarge: problem("payload-too-large", 413, "The request body is too large"),
57
+
58
+ // ─── Access ───────────────────────────────────────────────────────────────
59
+ /*
60
+ * 401 and 403 are separate types because they ask the client for different things: 401 means
61
+ * "authenticate and try again", 403 means "no". Collapsing them would make a missing API key
62
+ * indistinguishable from a refused project root.
63
+ */
64
+ unauthorized: problem("unauthorized", 401, "Credentials are missing or not accepted"),
65
+ forbidden: problem("forbidden", 403, "The request was refused"),
66
+ /*
67
+ * Distinct from `forbidden` because the client does something different with it: it renders an
68
+ * install link. That is the test for whether a type earns its own entry.
69
+ */
70
+ needsInstallationAccess: problem(
71
+ "needs-installation-access",
72
+ 403,
73
+ "The GitHub App is not installed for that account",
74
+ ["installUrl"],
75
+ ),
76
+ pathOutsideProject: problem(
77
+ "path-outside-project",
78
+ 403,
79
+ "The path resolves outside the active project",
80
+ ),
81
+
82
+ // ─── Backend state ────────────────────────────────────────────────────────
83
+ noActiveProject: problem("no-active-project", 409, "No project is active on this backend"),
84
+ capabilityUnavailable: problem(
85
+ "capability-unavailable",
86
+ 501,
87
+ "This backend does not provide that capability",
88
+ ),
89
+ upstreamFailure: problem("upstream-failure", 502, "An upstream service failed", ["upstream"]),
90
+ internalError: problem("internal-error", 500, "The backend failed to complete the request"),
91
+ } as const satisfies Record<string, ProblemType>;
92
+
93
+ /** A stable problem-type name in {@link PROBLEM_TYPES}. */
94
+ export type ProblemTypeName = keyof typeof PROBLEM_TYPES;
95
+
96
+ /**
97
+ * Build a problem document for one occurrence of a declared type.
98
+ *
99
+ * `detail` is duplicated into the deprecated `error` member so existing clients keep working while
100
+ * they migrate; see the note on {@link ProblemDetails.error}.
101
+ *
102
+ * @param {ProblemTypeName} name - A declared type
103
+ * @param {string} [detail] - What happened THIS time
104
+ * @param {Record<string, unknown>} [extensions] - Extension members this type documents
105
+ * @returns {ProblemDetails}
106
+ */
107
+ export function problemDetails(
108
+ name: ProblemTypeName,
109
+ detail?: string,
110
+ extensions: Record<string, unknown> = {},
111
+ ): ProblemDetails {
112
+ const declared = PROBLEM_TYPES[name];
113
+ return {
114
+ ...extensions,
115
+ status: declared.status,
116
+ title: declared.title,
117
+ type: declared.type,
118
+ ...(detail === undefined || detail === "" ? {} : { detail, error: detail }),
119
+ };
120
+ }
121
+
122
+ /** The declared type a `type` URI names, or null when it is not one of ours. */
123
+ export function problemTypeByUri(type: unknown): ProblemType | null {
124
+ if (typeof type !== "string") {
125
+ return null;
126
+ }
127
+ return Object.values(PROBLEM_TYPES).find((declared) => declared.type === type) ?? null;
128
+ }
package/src/routes.ts CHANGED
@@ -114,6 +114,15 @@ export const STUDIO_ROUTES = {
114
114
  fileDelete: route("DELETE", "/__studio/file", "Delete a file"),
115
115
  fileUpload: route("POST", "/__studio/file/upload", "Upload binary content to a path"),
116
116
  fileRename: route("POST", "/__studio/file/rename", "Rename/move (+ refactor report)"),
117
+ references: route(
118
+ "GET",
119
+ "/__studio/references",
120
+ "Where a file or a component tag is used (?path=&tag=, at least one) → ReferencesResult " +
121
+ "{files, filesReferencing, refsTotal} — the read side of the rename refactor's own walker, " +
122
+ "cached until the backend's watcher sees the tree move (backs findReferences)",
123
+ 'Usage counts are hidden: no "Used on N pages" in the inspector, no Selection: Find Usages, ' +
124
+ "and delete/rename confirmations state no reference count.",
125
+ ),
117
126
  locate: route("POST", "/__studio/locate", "Find a file by name → {path | null}"),
118
127
  collab: route(
119
128
  "GET",
@@ -121,8 +130,11 @@ export const STUDIO_ROUTES = {
121
130
  "Realtime co-editing: a WebSocket upgrade speaking the @jxsuite/collab wire envelope (one " +
122
131
  "socket per project, documents multiplexed by path; y-protocols sync + project-level " +
123
132
  "awareness in lib0 binary frames — see @jxsuite/collab/envelope for the frame layout and " +
124
- "the docEpoch/doc-reset lifecycle). A plain GET (no Upgrade) answers {collab: true, " +
125
- "version} as the capability probe.",
133
+ "the docEpoch/doc-reset lifecycle). A plain GET (no Upgrade) answers {collab, protocols, " +
134
+ "version} as the capability probe, and `protocols` is the subprotocol negotiation: the " +
135
+ "client offers one of them as Sec-WebSocket-Protocol and the server echoes it, or the " +
136
+ "client offers none when the server advertises none, because RFC 6455 §4.1 fails a " +
137
+ "connection whose offer went unechoed.",
126
138
  "Realtime co-editing is unavailable; Studio edits solo with file-level saves",
127
139
  ),
128
140
 
@@ -183,6 +195,27 @@ export const STUDIO_ROUTES = {
183
195
  "Code editors show no lint markers.",
184
196
  ),
185
197
 
198
+ // ─── Site build ───────────────────────────────────────────────────────────
199
+ /**
200
+ * Build the site to its output directory, so what a reader opens is what the author sees.
201
+ *
202
+ * `View: Open in Browser` runs this first. Without it the reader gets whatever the last build
203
+ * left on disk — which for most projects is nothing, and for the rest is yesterday — while the
204
+ * canvas beside them shows today. A preview that silently shows stale output is worse than one
205
+ * that says it cannot open.
206
+ *
207
+ * The reply also names `url`, the ORIGIN the result is browsable at. It is not this server's:
208
+ * these paths mean the project's SOURCES, and a built page addresses its own OUTPUT by the same
209
+ * paths (`/components/x.js` is the formula module here and the custom element there), so a
210
+ * backend serves the built site somewhere of its own and says where.
211
+ */
212
+ buildSite: route(
213
+ "POST",
214
+ "/__studio/build",
215
+ "Build the site to its output directory → {routes, files, errors, url}",
216
+ "Open in Browser reports that this target cannot build a preview.",
217
+ ),
218
+
186
219
  // ─── Packages ─────────────────────────────────────────────────────────────
187
220
  packages: route("GET", "/__studio/packages", "List dependencies (PackageInfo[])"),
188
221
  packagesAdd: route("POST", "/__studio/packages/add", "Add a dependency"),
package/src/types.ts CHANGED
@@ -18,7 +18,15 @@ export type JsonValue = SchemaJsonValue | undefined;
18
18
 
19
19
  // ─── Errors ──────────────────────────────────────────────────────────────────
20
20
 
21
- /** Message-level failure body every protocol route may return. */
21
+ /**
22
+ * The pre-RFC-9457 failure body.
23
+ *
24
+ * Superseded by {@link ProblemDetails} in `./problem.ts`, and kept because it is still the shape a
25
+ * client may receive from a backend that has not migrated. A conforming backend answers
26
+ * `application/problem+json`; a reader written with `problemDetail` handles both.
27
+ *
28
+ * @deprecated Emit a problem document. Read this only through `problemDetail`.
29
+ */
22
30
  export interface ErrorBody {
23
31
  error: string;
24
32
  /** Machine-readable discriminator (e.g. "remote_moved", "cf_not_connected"). */
@@ -60,6 +68,48 @@ export interface RenameResult {
60
68
  error?: string;
61
69
  }
62
70
 
71
+ /** One kind of reference in one file, with how many times it occurs there. */
72
+ export interface ReferenceHit {
73
+ /**
74
+ * Which key carried it: `$ref`, `$layout`, `$src`, `attr`, `imports`, `$elements`, `url`,
75
+ * `tagName`.
76
+ */
77
+ refType: string;
78
+ /** The reference exactly as authored (`"./Card.json"`), or `"<my-card>"` for a tag instance. */
79
+ ref: string;
80
+ count: number;
81
+ }
82
+
83
+ /** Every reference to the queried target from one document. */
84
+ export interface ReferenceFile {
85
+ /** Project-relative, forward-slashed. */
86
+ path: string;
87
+ /** Total references in this file (the sum of `refs[].count`). */
88
+ count: number;
89
+ refs: ReferenceHit[];
90
+ }
91
+
92
+ /**
93
+ * Where a file or a component tag is used — the answer behind "Used on N pages", Find Usages, and
94
+ * the reference count inside every delete and rename confirmation. Never includes the target file
95
+ * itself: a component's own definition declares the tag it is named by, and counting that would
96
+ * report an unused component as used once.
97
+ */
98
+ export interface ReferencesResult {
99
+ /** The project-relative path queried, or null when only a tag was. */
100
+ path: string | null;
101
+ /** The tag queried — passed in, or derived from a component document at `path`. */
102
+ tagName: string | null;
103
+ /** Referencing files, sorted by path. */
104
+ files: ReferenceFile[];
105
+ /** `files.length`, carried on the wire so a caller can render a count without the list. */
106
+ filesReferencing: number;
107
+ /** Total references across every file. */
108
+ refsTotal: number;
109
+ /** Documents that could not be parsed. A non-empty list makes every count above a FLOOR. */
110
+ errors: { path: string; error: string }[];
111
+ }
112
+
63
113
  // ─── Git ─────────────────────────────────────────────────────────────────────
64
114
 
65
115
  export interface GitFileStatus {