@kingsleyweb/bun-ui 0.0.0-stage → 3.0.0-rc.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.
Files changed (240) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/app/docs/App.tsx +72 -0
  4. package/app/docs/api/client.ts +303 -0
  5. package/app/docs/api/contract.ts +24 -0
  6. package/app/docs/api/docs.ts +38 -0
  7. package/app/docs/api/errors.ts +144 -0
  8. package/app/docs/boot.tsx +58 -0
  9. package/app/docs/components/Badge.tsx +65 -0
  10. package/app/docs/components/Button.tsx +37 -0
  11. package/app/docs/components/Card.tsx +41 -0
  12. package/app/docs/components/ConfirmDialog.tsx +173 -0
  13. package/app/docs/components/CopyButton.tsx +68 -0
  14. package/app/docs/components/Dialog.tsx +230 -0
  15. package/app/docs/components/EmptyState.tsx +34 -0
  16. package/app/docs/components/ErrorView.tsx +70 -0
  17. package/app/docs/components/Field.tsx +93 -0
  18. package/app/docs/components/JsonEditor.tsx +151 -0
  19. package/app/docs/components/JsonView.tsx +338 -0
  20. package/app/docs/components/KeyValue.tsx +63 -0
  21. package/app/docs/components/ProblemBanner.tsx +116 -0
  22. package/app/docs/components/ScreenErrorBoundary.tsx +141 -0
  23. package/app/docs/components/Spinner.tsx +33 -0
  24. package/app/docs/components/Table.tsx +33 -0
  25. package/app/docs/components/Tabs.tsx +226 -0
  26. package/app/docs/components/classNames.ts +4 -0
  27. package/app/docs/components/components.css +691 -0
  28. package/app/docs/components/copy.ts +89 -0
  29. package/app/docs/components/fieldContext.ts +29 -0
  30. package/app/docs/components/inputValues.ts +142 -0
  31. package/app/docs/components/inputs.tsx +387 -0
  32. package/app/docs/components/jsonParse.ts +338 -0
  33. package/app/docs/components/listKeys.ts +30 -0
  34. package/app/docs/config.ts +86 -0
  35. package/app/docs/context.ts +33 -0
  36. package/app/docs/embed.ts +74 -0
  37. package/app/docs/env.d.ts +2 -0
  38. package/app/docs/format.ts +9 -0
  39. package/app/docs/hooks/useUrlTab.ts +16 -0
  40. package/app/docs/main.tsx +11 -0
  41. package/app/docs/queryClient.ts +54 -0
  42. package/app/docs/router.tsx +194 -0
  43. package/app/docs/routing.ts +194 -0
  44. package/app/docs/screens/docs/document.ts +27 -0
  45. package/app/docs/screens/docs/http/OperationView.tsx +407 -0
  46. package/app/docs/screens/docs/http/Sidebar.tsx +190 -0
  47. package/app/docs/screens/docs/http/TryItPanel.tsx +603 -0
  48. package/app/docs/screens/docs/http/http.css +405 -0
  49. package/app/docs/screens/docs/http/index.tsx +395 -0
  50. package/app/docs/screens/docs/http/model.ts +604 -0
  51. package/app/docs/screens/docs/http/tryIt.ts +455 -0
  52. package/app/docs/screens/docs/permissions.ts +6 -0
  53. package/app/docs/screens/docs/schema/Prose.tsx +43 -0
  54. package/app/docs/screens/docs/schema/SchemaTree.tsx +319 -0
  55. package/app/docs/screens/docs/schema/index.ts +30 -0
  56. package/app/docs/screens/docs/schema/resolve.ts +466 -0
  57. package/app/docs/screens/docs/schema/schema.css +137 -0
  58. package/app/docs/screens/docs/schema/skeleton.ts +92 -0
  59. package/app/docs/screens/docs/viewer.tsx +154 -0
  60. package/app/docs/screens/docs/ws/InlineText.tsx +25 -0
  61. package/app/docs/screens/docs/ws/PayloadSchema.tsx +39 -0
  62. package/app/docs/screens/docs/ws/format.ts +82 -0
  63. package/app/docs/screens/docs/ws/index.tsx +286 -0
  64. package/app/docs/screens/docs/ws/items.tsx +786 -0
  65. package/app/docs/screens/docs/ws/model.ts +1178 -0
  66. package/app/docs/screens/docs/ws/panels.tsx +440 -0
  67. package/app/docs/screens/docs/ws/ws.css +231 -0
  68. package/app/docs/shortcuts.ts +94 -0
  69. package/app/docs/styles/base.css +847 -0
  70. package/app/docs/styles/tokens.css +143 -0
  71. package/app/docs/theme.ts +56 -0
  72. package/app/kit/unauthorized.ts +222 -0
  73. package/dist/docs/assets/main-0rchxtv5.js +18 -0
  74. package/dist/docs/assets/main-0rchxtv5.js.map +99 -0
  75. package/dist/docs/assets/main-cqgyvs86.css +1 -0
  76. package/dist/docs/manifest.json +26 -0
  77. package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js +6 -0
  78. package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js.map +11 -0
  79. package/dist/jobs/assets/AddFlowDialog-n7gtc84v.css +1 -0
  80. package/dist/jobs/assets/added-cbwsxv3e.js +4 -0
  81. package/dist/jobs/assets/added-cbwsxv3e.js.map +11 -0
  82. package/dist/jobs/assets/bodies-yyw5hmrp.js +6 -0
  83. package/dist/jobs/assets/bodies-yyw5hmrp.js.map +10 -0
  84. package/dist/jobs/assets/channel-tgkvqm8y.js +4 -0
  85. package/dist/jobs/assets/channel-tgkvqm8y.js.map +10 -0
  86. package/dist/jobs/assets/channelChecks-s4yz4yhw.js +4 -0
  87. package/dist/jobs/assets/channelChecks-s4yz4yhw.js.map +10 -0
  88. package/dist/jobs/assets/index-02427smp.css +1 -0
  89. package/dist/jobs/assets/index-09z2cjft.js +4 -0
  90. package/dist/jobs/assets/index-09z2cjft.js.map +16 -0
  91. package/dist/jobs/assets/index-18bn0gvy.css +1 -0
  92. package/dist/jobs/assets/index-47pxmyxn.js +4 -0
  93. package/dist/jobs/assets/index-47pxmyxn.js.map +10 -0
  94. package/dist/jobs/assets/index-4e55z986.js +4 -0
  95. package/dist/jobs/assets/index-4e55z986.js.map +11 -0
  96. package/dist/jobs/assets/index-5c933424.js +4 -0
  97. package/dist/jobs/assets/index-5c933424.js.map +18 -0
  98. package/dist/jobs/assets/index-5kbyqspm.css +1 -0
  99. package/dist/jobs/assets/index-5ng37hk6.css +1 -0
  100. package/dist/jobs/assets/index-5w8w7fvc.css +1 -0
  101. package/dist/jobs/assets/index-71qs5qyx.css +1 -0
  102. package/dist/jobs/assets/index-btpg4r8b.css +1 -0
  103. package/dist/jobs/assets/index-d5r5n0mm.js +4 -0
  104. package/dist/jobs/assets/index-d5r5n0mm.js.map +23 -0
  105. package/dist/jobs/assets/index-e0yy2skq.css +1 -0
  106. package/dist/jobs/assets/index-hs60b25v.js +10 -0
  107. package/dist/jobs/assets/index-hs60b25v.js.map +62 -0
  108. package/dist/jobs/assets/index-k2smrr70.js +4 -0
  109. package/dist/jobs/assets/index-k2smrr70.js.map +9 -0
  110. package/dist/jobs/assets/index-q24jw3w9.js +4 -0
  111. package/dist/jobs/assets/index-q24jw3w9.js.map +32 -0
  112. package/dist/jobs/assets/index-qfb80xen.css +1 -0
  113. package/dist/jobs/assets/index-rtynx6wj.js +4 -0
  114. package/dist/jobs/assets/index-rtynx6wj.js.map +10 -0
  115. package/dist/jobs/assets/live-6k7s6358.js +4 -0
  116. package/dist/jobs/assets/live-6k7s6358.js.map +10 -0
  117. package/dist/jobs/assets/main-0jmeh6mq.js +6 -0
  118. package/dist/jobs/assets/main-0jmeh6mq.js.map +12 -0
  119. package/dist/jobs/assets/main-3yj6bv90.js +5 -0
  120. package/dist/jobs/assets/main-3yj6bv90.js.map +10 -0
  121. package/dist/jobs/assets/main-4g69fn3x.js +5 -0
  122. package/dist/jobs/assets/main-4g69fn3x.js.map +10 -0
  123. package/dist/jobs/assets/main-4gte3eqq.js +5 -0
  124. package/dist/jobs/assets/main-4gte3eqq.js.map +10 -0
  125. package/dist/jobs/assets/main-4j1fcevh.js +5 -0
  126. package/dist/jobs/assets/main-4j1fcevh.js.map +10 -0
  127. package/dist/jobs/assets/main-54qwkz28.css +1 -0
  128. package/dist/jobs/assets/main-5grjvsy7.js +5 -0
  129. package/dist/jobs/assets/main-5grjvsy7.js.map +11 -0
  130. package/dist/jobs/assets/main-5zbx60gq.js +5 -0
  131. package/dist/jobs/assets/main-5zbx60gq.js.map +11 -0
  132. package/dist/jobs/assets/main-9prgjhg4.js +5 -0
  133. package/dist/jobs/assets/main-9prgjhg4.js.map +10 -0
  134. package/dist/jobs/assets/main-a250ffk7.js +17 -0
  135. package/dist/jobs/assets/main-a250ffk7.js.map +115 -0
  136. package/dist/jobs/assets/main-d6ge7w08.js +5 -0
  137. package/dist/jobs/assets/main-d6ge7w08.js.map +10 -0
  138. package/dist/jobs/assets/main-gngzht43.js +5 -0
  139. package/dist/jobs/assets/main-gngzht43.js.map +10 -0
  140. package/dist/jobs/assets/main-hg0hrg8j.js +5 -0
  141. package/dist/jobs/assets/main-hg0hrg8j.js.map +10 -0
  142. package/dist/jobs/assets/main-hm1ng3yg.js +5 -0
  143. package/dist/jobs/assets/main-hm1ng3yg.js.map +12 -0
  144. package/dist/jobs/assets/main-jkvd92z9.js +5 -0
  145. package/dist/jobs/assets/main-jkvd92z9.js.map +10 -0
  146. package/dist/jobs/assets/main-jv5xqd13.js +5 -0
  147. package/dist/jobs/assets/main-jv5xqd13.js.map +21 -0
  148. package/dist/jobs/assets/main-jxre7w93.js +5 -0
  149. package/dist/jobs/assets/main-jxre7w93.js.map +23 -0
  150. package/dist/jobs/assets/main-kwmqkjfx.js +5 -0
  151. package/dist/jobs/assets/main-kwmqkjfx.js.map +11 -0
  152. package/dist/jobs/assets/main-n9cf6bxm.js +5 -0
  153. package/dist/jobs/assets/main-n9cf6bxm.js.map +11 -0
  154. package/dist/jobs/assets/main-pegp0ygb.js +5 -0
  155. package/dist/jobs/assets/main-pegp0ygb.js.map +10 -0
  156. package/dist/jobs/assets/main-qr573853.js +5 -0
  157. package/dist/jobs/assets/main-qr573853.js.map +12 -0
  158. package/dist/jobs/assets/main-rq97rdr4.js +4 -0
  159. package/dist/jobs/assets/main-rq97rdr4.js.map +22 -0
  160. package/dist/jobs/assets/main-szjdmydc.js +5 -0
  161. package/dist/jobs/assets/main-szjdmydc.js.map +13 -0
  162. package/dist/jobs/assets/main-tnbhrm9n.js +7 -0
  163. package/dist/jobs/assets/main-tnbhrm9n.js.map +11 -0
  164. package/dist/jobs/assets/main-ts19j4n0.js +5 -0
  165. package/dist/jobs/assets/main-ts19j4n0.js.map +25 -0
  166. package/dist/jobs/assets/main-w93x1dzs.js +5 -0
  167. package/dist/jobs/assets/main-w93x1dzs.js.map +10 -0
  168. package/dist/jobs/assets/sections-0ksdxs8m.css +1 -0
  169. package/dist/jobs/assets/sections-0y7w4fb4.js +4 -0
  170. package/dist/jobs/assets/sections-0y7w4fb4.js.map +10 -0
  171. package/dist/jobs/assets/sse-mn0t0fh7.js +5 -0
  172. package/dist/jobs/assets/sse-mn0t0fh7.js.map +11 -0
  173. package/dist/jobs/assets/sseChecks-t7tcm7ra.js +4 -0
  174. package/dist/jobs/assets/sseChecks-t7tcm7ra.js.map +10 -0
  175. package/dist/jobs/assets/validation-ysjfy072.js +4 -0
  176. package/dist/jobs/assets/validation-ysjfy072.js.map +9 -0
  177. package/dist/jobs/manifest.json +511 -0
  178. package/dts/docs/assets.d.ts +144 -0
  179. package/dts/docs/assets.d.ts.map +1 -0
  180. package/dts/docs/browser/config.d.ts +61 -0
  181. package/dts/docs/browser/config.d.ts.map +1 -0
  182. package/dts/docs/browser/index.d.ts +12 -0
  183. package/dts/docs/browser/index.d.ts.map +1 -0
  184. package/dts/docs/docsUi.d.ts +42 -0
  185. package/dts/docs/docsUi.d.ts.map +1 -0
  186. package/dts/docs/hosted.d.ts +22 -0
  187. package/dts/docs/hosted.d.ts.map +1 -0
  188. package/dts/docs/index.d.ts +18 -0
  189. package/dts/docs/index.d.ts.map +1 -0
  190. package/dts/docs/shell.d.ts +57 -0
  191. package/dts/docs/shell.d.ts.map +1 -0
  192. package/dts/docs/types.d.ts +137 -0
  193. package/dts/docs/types.d.ts.map +1 -0
  194. package/dts/index.d.ts +21 -0
  195. package/dts/index.d.ts.map +1 -0
  196. package/dts/jobs/assets.d.ts +157 -0
  197. package/dts/jobs/assets.d.ts.map +1 -0
  198. package/dts/jobs/browser/config.d.ts +67 -0
  199. package/dts/jobs/browser/config.d.ts.map +1 -0
  200. package/dts/jobs/browser/index.d.ts +10 -0
  201. package/dts/jobs/browser/index.d.ts.map +1 -0
  202. package/dts/jobs/browser/unauthorized.d.ts +9 -0
  203. package/dts/jobs/browser/unauthorized.d.ts.map +1 -0
  204. package/dts/jobs/index.d.ts +24 -0
  205. package/dts/jobs/index.d.ts.map +1 -0
  206. package/dts/jobs/jobsUi.d.ts +63 -0
  207. package/dts/jobs/jobsUi.d.ts.map +1 -0
  208. package/dts/jobs/shell.d.ts +77 -0
  209. package/dts/jobs/shell.d.ts.map +1 -0
  210. package/dts/jobs/types.d.ts +146 -0
  211. package/dts/jobs/types.d.ts.map +1 -0
  212. package/dts/kit/browser/shared.d.ts +10 -0
  213. package/dts/kit/browser/shared.d.ts.map +1 -0
  214. package/dts/kit/browser/unauthorized.d.ts +74 -0
  215. package/dts/kit/browser/unauthorized.d.ts.map +1 -0
  216. package/dts/kit/unauthorized.d.ts +47 -0
  217. package/dts/kit/unauthorized.d.ts.map +1 -0
  218. package/guides/docs.md +170 -0
  219. package/guides/jobs.md +1120 -0
  220. package/lib/docs/assets.ts +427 -0
  221. package/lib/docs/browser/config.ts +66 -0
  222. package/lib/docs/browser/index.ts +24 -0
  223. package/lib/docs/docsUi.ts +623 -0
  224. package/lib/docs/hosted.ts +31 -0
  225. package/lib/docs/index.ts +37 -0
  226. package/lib/docs/shell.ts +156 -0
  227. package/lib/docs/types.ts +149 -0
  228. package/lib/index.ts +27 -0
  229. package/lib/jobs/assets.ts +448 -0
  230. package/lib/jobs/browser/config.ts +65 -0
  231. package/lib/jobs/browser/index.ts +9 -0
  232. package/lib/jobs/browser/unauthorized.ts +11 -0
  233. package/lib/jobs/index.ts +41 -0
  234. package/lib/jobs/jobsUi.ts +709 -0
  235. package/lib/jobs/shell.ts +205 -0
  236. package/lib/jobs/types.ts +162 -0
  237. package/lib/kit/browser/shared.ts +11 -0
  238. package/lib/kit/browser/unauthorized.ts +114 -0
  239. package/lib/kit/unauthorized.ts +222 -0
  240. package/package.json +198 -4
@@ -0,0 +1,1178 @@
1
+ import type { SpecDocument } from "../../../api/docs";
2
+ import { DEFAULT_EXTENSION_PREFIXES } from "../../../../../lib/docs/browser/config.ts";
3
+ import { resolveRef } from "../schema/resolve";
4
+
5
+ /**
6
+ * The WebSocket reference's pure half: the AsyncAPI 3.0 document read into
7
+ * what the screen shows (header, sidebar items, per-item detail, the
8
+ * specification extensions).
9
+ *
10
+ * Everything is read defensively: the document is JSON from the server, and a
11
+ * field this reader does not find is left out rather than guessed.
12
+ *
13
+ * The extensions are vendor-neutral: each is named on its node as
14
+ * `<prefix>-<name>` and read from the first prefix of
15
+ * {@link WsModelOptions.extensions} that carries it (default
16
+ * `["x-bun-common", "x-bun-jobs"]`), so a document written for either
17
+ * vocabulary — or an operator's own — renders, and an extension under any
18
+ * other prefix is ignored.
19
+ */
20
+
21
+ /** A JSON object, loosely. */
22
+ type Json = Record<string, unknown>;
23
+
24
+ /** Whether a value is a plain JSON object. */
25
+ function isObject(value: unknown): value is Json {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+
29
+ /** A string field, or `undefined`. */
30
+ function str(value: unknown): string | undefined {
31
+ return typeof value === "string" ? value : undefined;
32
+ }
33
+
34
+ /** A number field, or `undefined`. */
35
+ function num(value: unknown): number | undefined {
36
+ return typeof value === "number" && Number.isFinite(value)
37
+ ? value
38
+ : undefined;
39
+ }
40
+
41
+ /** Options of {@link readWsDoc}. */
42
+ export interface WsModelOptions {
43
+ /**
44
+ * The extension prefixes, first hit winning. Defaults to
45
+ * `["x-bun-common", "x-bun-jobs"]`.
46
+ */
47
+ extensions?: readonly string[];
48
+ /**
49
+ * The subprotocol shown when the document states none, e.g. the client
50
+ * contract's. Unset: the panel says the document does not state one.
51
+ */
52
+ defaultSubprotocol?: string;
53
+ }
54
+
55
+ /** One extension read: the full key it was found under, and its value. */
56
+ interface FoundExtension {
57
+ /** The full key, e.g. `x-bun-jobs-limits`. */
58
+ key: string;
59
+ /** Its value. */
60
+ value: unknown;
61
+ }
62
+
63
+ /** Reads the `<prefix>-<name>` extension of a node from the first prefix carrying it. */
64
+ function extensionOf(
65
+ node: Json | undefined,
66
+ name: string,
67
+ prefixes: readonly string[],
68
+ ): FoundExtension | undefined {
69
+ if (node === undefined) {
70
+ return undefined;
71
+ }
72
+ for (const prefix of prefixes) {
73
+ const key = `${prefix}-${name}`;
74
+ if (Object.hasOwn(node, key)) {
75
+ return { key, value: node[key] };
76
+ }
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ /** The last segment of a local `$ref` (`#/channels/queue` → `queue`), unescaped. */
82
+ export function refName(ref: unknown): string | undefined {
83
+ const pointer = isObject(ref) ? str(ref.$ref) : undefined;
84
+ if (pointer === undefined || !pointer.startsWith("#/")) {
85
+ return undefined;
86
+ }
87
+ const last = pointer.split("/").at(-1);
88
+ return last === undefined
89
+ ? undefined
90
+ : last.replaceAll("~1", "/").replaceAll("~0", "~");
91
+ }
92
+
93
+ /** The app path of an item, keeping the search; `base` is the docs section's path within the host app ("" for a standalone viewer). */
94
+ export function itemPath(slug: string, query = "", base = ""): string {
95
+ return `${base}/ws/${encodeURIComponent(slug)}${query ? `?${new URLSearchParams({ q: query }).toString()}` : ""}`;
96
+ }
97
+
98
+ /** The kinds of sidebar item. */
99
+ export type WsItemKind =
100
+ | "channel"
101
+ | "operation"
102
+ | "message"
103
+ | "limits"
104
+ | "close-codes"
105
+ | "upgrade-refusals";
106
+
107
+ /** The URL slug of an item: `channel-queue`, `operation-subscribe`, `message-queue.completed`, `limits`. */
108
+ export function itemSlug(kind: WsItemKind, key?: string): string {
109
+ return key === undefined ? kind : `${kind}-${key}`;
110
+ }
111
+
112
+ /** A channel parameter. */
113
+ export interface WsParameter {
114
+ /** Its name in the address template (`queue`, `jobId`, `runner`). */
115
+ name: string;
116
+ /** What it is. */
117
+ description: string | undefined;
118
+ /**
119
+ * The value's JSON Schema, from the `<prefix>-schema` extension (AsyncAPI
120
+ * 3.0's Parameter Object has no `schema` field); `undefined` when the
121
+ * document gives none.
122
+ */
123
+ schema: Json | undefined;
124
+ }
125
+
126
+ /** One channel: the `connection`, or a logical family. */
127
+ export interface WsChannel {
128
+ /** Its key under `channels` (`connection`, `queue`, ...). */
129
+ key: string;
130
+ /** The item slug. */
131
+ slug: string;
132
+ /** Its title, or the key. */
133
+ title: string;
134
+ /** The address: the socket path for `connection`, a name template otherwise. */
135
+ address: string;
136
+ /** Its description. */
137
+ description: string | undefined;
138
+ /** Address parameters, in template order. */
139
+ parameters: WsParameter[];
140
+ /** Message ids it carries, in document order. */
141
+ messages: string[];
142
+ /** The WebSocket binding's method, when declared. */
143
+ bindingMethod: string | undefined;
144
+ /** Whether this is the transport itself rather than a logical channel. */
145
+ isConnection: boolean;
146
+ }
147
+
148
+ /** One operation. */
149
+ export interface WsOperation {
150
+ /** Its key under `operations`. */
151
+ key: string;
152
+ /** The item slug. */
153
+ slug: string;
154
+ /** Its title, or the key. */
155
+ title: string;
156
+ /** Its summary. */
157
+ summary: string | undefined;
158
+ /** `send` (client → server) or `receive` (server → client). */
159
+ action: "send" | "receive" | undefined;
160
+ /** The channel key it is on. */
161
+ channel: string | undefined;
162
+ /** The message ids it sends or receives. */
163
+ messages: string[];
164
+ /** The reply, for a request/response operation. */
165
+ reply: {
166
+ /** The channel key the reply arrives on. */
167
+ channel: string | undefined;
168
+ /** The message ids the reply may be. */
169
+ messages: string[];
170
+ } | null;
171
+ /** The permission it needs (the `<prefix>-action` extension). */
172
+ permission: string | undefined;
173
+ }
174
+
175
+ /** One message. */
176
+ export interface WsMessage {
177
+ /** Its key under `components.messages` (`subscribe`, `queue.completed`). */
178
+ key: string;
179
+ /** The item slug. */
180
+ slug: string;
181
+ /** Its `name`, or the key. */
182
+ name: string;
183
+ /** Its title. */
184
+ title: string | undefined;
185
+ /** Its summary. */
186
+ summary: string | undefined;
187
+ /** Its content type. */
188
+ contentType: string | undefined;
189
+ /** The payload schema as written (often a `$ref`). */
190
+ payload: unknown;
191
+ /** Its examples, when the document gives any. */
192
+ examples: {
193
+ /** The example's name. */
194
+ name: string | undefined;
195
+ /** Its summary. */
196
+ summary: string | undefined;
197
+ /** Its payload. */
198
+ payload: unknown;
199
+ }[];
200
+ /**
201
+ * For an event message — one whose key is `<kind>.<type>`
202
+ * (`queue.completed`) — the two parts; `null` for a control message (a key
203
+ * with no dot).
204
+ */
205
+ event: {
206
+ /** The part before the first dot (`queue`). */
207
+ kind: string;
208
+ /** The part after it (`completed`). */
209
+ type: string;
210
+ } | null;
211
+ }
212
+
213
+ /** One `<prefix>-close-codes` entry. */
214
+ export interface WsCloseCode {
215
+ /** The close code. */
216
+ code: number;
217
+ /** Its name. */
218
+ name: string;
219
+ /** When it is sent. */
220
+ description: string;
221
+ }
222
+
223
+ /** One `<prefix>-upgrade-refusals` entry. */
224
+ export interface WsUpgradeRefusal {
225
+ /** The HTTP status. */
226
+ status: number;
227
+ /** The problem's `code`. */
228
+ code: string;
229
+ /** When it is sent. */
230
+ description: string;
231
+ /** The response's content type. */
232
+ contentType: string | undefined;
233
+ /** Headers it carries. */
234
+ headers: Record<string, string>;
235
+ }
236
+
237
+ /** `<prefix>-limits`, as far as it was found. */
238
+ export interface WsLimits {
239
+ /** Every numeric limit, by name, in document order. */
240
+ values: [name: string, value: number][];
241
+ /** The replay ring: its size and age, `false` when replay is off, `undefined` when not stated. */
242
+ replay:
243
+ | {
244
+ /** How many events it keeps. */
245
+ size: number | undefined;
246
+ /** How old an event it keeps, in ms. */
247
+ maxAgeMs: number | undefined;
248
+ }
249
+ | false
250
+ | undefined;
251
+ }
252
+
253
+ /** A security scheme, as `components.securitySchemes` declares it. */
254
+ export interface WsSecurityScheme {
255
+ /** Its name. */
256
+ name: string;
257
+ /** Its AsyncAPI type (`http`, `httpApiKey`, `oauth2`, ...). */
258
+ type: string | undefined;
259
+ /** A one-line reading of it (`bearer`, `header X-Key`). */
260
+ detail: string | undefined;
261
+ /** Its description. */
262
+ description: string | undefined;
263
+ /** What a browser WebSocket cannot carry (the `<prefix>-note` extension). */
264
+ note: string | undefined;
265
+ }
266
+
267
+ /** One requirement: every scheme in it is needed together. */
268
+ export type WsRequirement = {
269
+ /** The scheme. */
270
+ scheme: string;
271
+ /** The scopes it needs. */
272
+ scopes: string[];
273
+ }[];
274
+
275
+ /** The document's server. */
276
+ export interface WsServer {
277
+ /** Its key under `servers`: `api`, or the first server's when there is no `api`. */
278
+ key: string;
279
+ /** The host, as served (the request's own when fetched over HTTP). */
280
+ host: string;
281
+ /** Whether the host is still the `{host}` template (the in-process document). */
282
+ hostIsTemplate: boolean;
283
+ /** The template's default host, when it is one. */
284
+ hostDefault: string | undefined;
285
+ /** The socket's path. */
286
+ pathname: string | undefined;
287
+ /** `ws` or `wss`. */
288
+ protocol: string | undefined;
289
+ /** Its description. */
290
+ description: string | undefined;
291
+ /** The socket URL, when host and protocol are known. */
292
+ url: string | undefined;
293
+ }
294
+
295
+ /** The whole document, read. */
296
+ export interface WsDoc {
297
+ /** `info.title`. */
298
+ title: string;
299
+ /** `info.version`. */
300
+ version: string | undefined;
301
+ /** `info.description`. */
302
+ description: string | undefined;
303
+ /** `servers.api` (or the first server). */
304
+ server: WsServer | null;
305
+ /** The subprotocol, and where it was read from. */
306
+ subprotocol: {
307
+ /** The subprotocol, or `undefined` when the document states none and no default was configured. */
308
+ value: string | undefined;
309
+ /** Whether it was read from the document rather than assumed. */
310
+ fromDocument: boolean;
311
+ /**
312
+ * Where it came from: the `<prefix>-subprotocol` extension on the server
313
+ * (`servers.api`, see {@link WsServer.key}), else on the connection
314
+ * channel; the connection channel's description (a document that states
315
+ * it only there); or {@link WsModelOptions.defaultSubprotocol} when the
316
+ * document says nothing. {@link subprotocolSource} words it.
317
+ */
318
+ source:
319
+ | "server-extension"
320
+ | "connection-extension"
321
+ | "connection-prose"
322
+ | "default";
323
+ };
324
+ /** The full key the subprotocol extension was read from (e.g. `x-bun-jobs-subprotocol`), when it was one. */
325
+ subprotocolKey?: string;
326
+ /** The full keys the connection's extensions were found under (e.g. `x-bun-jobs-limits`), for the panels to name. */
327
+ extensionKeys: {
328
+ /** The key the security requirements were read from. */
329
+ security?: string;
330
+ /** The key the limits were read from. */
331
+ limits?: string;
332
+ /** The key the close codes were read from. */
333
+ closeCodes?: string;
334
+ /** The key the upgrade refusals were read from. */
335
+ upgradeRefusals?: string;
336
+ };
337
+ /** Declared schemes that a requirement names. */
338
+ securitySchemes: WsSecurityScheme[];
339
+ /** Every requirement (the `<prefix>-security` extension, else the native list): any one suffices. */
340
+ requirements: WsRequirement[];
341
+ /** Whether some requirement could not be expressed in the native `security` list (it names several schemes). */
342
+ conjunctive: boolean;
343
+ /** Channels: `connection` first, then the families in document order. */
344
+ channels: WsChannel[];
345
+ /** Operations, in document order. */
346
+ operations: WsOperation[];
347
+ /** Messages: control messages, then events. */
348
+ messages: WsMessage[];
349
+ /** `<prefix>-limits`, or `null` when absent. */
350
+ limits: WsLimits | null;
351
+ /** `<prefix>-close-codes`. */
352
+ closeCodes: WsCloseCode[];
353
+ /** `<prefix>-upgrade-refusals`. */
354
+ refusals: WsUpgradeRefusal[];
355
+ }
356
+
357
+ /**
358
+ * Where the subprotocol shown was read from, in words: `from servers.api
359
+ * (x-bun-jobs-subprotocol)`, `from the connection channel
360
+ * (x-bun-common-subprotocol)`, `from the connection channel's description`, or
361
+ * `not stated in the document` (plus `; the configured default` when one was).
362
+ */
363
+ export function subprotocolSource(doc: WsDoc): string {
364
+ switch (doc.subprotocol.source) {
365
+ case "server-extension":
366
+ return `from servers.${doc.server?.key ?? "api"} (${doc.subprotocolKey ?? "subprotocol extension"})`;
367
+ case "connection-extension":
368
+ return `from the connection channel (${doc.subprotocolKey ?? "subprotocol extension"})`;
369
+ case "connection-prose":
370
+ return "from the connection channel's description";
371
+ case "default":
372
+ return doc.subprotocol.value === undefined
373
+ ? "not stated in the document"
374
+ : "not stated; the configured default";
375
+ }
376
+ }
377
+
378
+ /** Reads the subprotocol from the connection channel's prose (`subprotocol \`bun-jobs.v1\``). */
379
+ function readSubprotocol(description: string | undefined): string | undefined {
380
+ return description?.match(/subprotocol `([^`]+)`/)?.[1];
381
+ }
382
+
383
+ /** Reads a scheme's one-line detail. */
384
+ function schemeDetail(scheme: Json): string | undefined {
385
+ const type = str(scheme.type);
386
+ switch (type) {
387
+ case "http":
388
+ return [str(scheme.scheme), str(scheme.bearerFormat)]
389
+ .filter(Boolean)
390
+ .join(", ");
391
+ case "httpApiKey":
392
+ return `${str(scheme.in) ?? "?"} ${str(scheme.name) ?? "?"}`;
393
+ case "oauth2": {
394
+ const scopes = isObject(scheme.flows)
395
+ ? Object.values(scheme.flows).flatMap((flow) =>
396
+ isObject(flow) && isObject(flow.availableScopes)
397
+ ? Object.keys(flow.availableScopes)
398
+ : [],
399
+ )
400
+ : [];
401
+ return scopes.length > 0
402
+ ? `scopes: ${[...new Set(scopes)].join(", ")}`
403
+ : undefined;
404
+ }
405
+ case "openIdConnect":
406
+ return str(scheme.openIdConnectUrl);
407
+ default:
408
+ return undefined;
409
+ }
410
+ }
411
+
412
+ /** Reads a requirement object (`{ bearer: [], key: ["read"] }`). */
413
+ function readRequirement(requirement: unknown): WsRequirement | null {
414
+ if (!isObject(requirement)) {
415
+ return null;
416
+ }
417
+ return Object.entries(requirement).map(([scheme, scopes]) => ({
418
+ scheme,
419
+ scopes: Array.isArray(scopes)
420
+ ? scopes.filter((scope): scope is string => typeof scope === "string")
421
+ : [],
422
+ }));
423
+ }
424
+
425
+ /** Reads a native `server.security` entry: a `$ref`, or an inline scheme with `scopes`. */
426
+ function readNativeRequirement(
427
+ entry: unknown,
428
+ schemes: Json,
429
+ ): WsRequirement | null {
430
+ const name = refName(entry);
431
+ if (name !== undefined) {
432
+ return [{ scheme: name, scopes: [] }];
433
+ }
434
+ if (!isObject(entry)) {
435
+ return null;
436
+ }
437
+ // An inline copy of a declared scheme: find it by its fields.
438
+ const { scopes, ...rest } = entry;
439
+ const match = Object.entries(schemes).find(
440
+ ([, scheme]) =>
441
+ isObject(scheme) &&
442
+ Object.entries(rest).every(
443
+ ([key, value]) => JSON.stringify(scheme[key]) === JSON.stringify(value),
444
+ ),
445
+ );
446
+ return [
447
+ {
448
+ scheme: match?.[0] ?? str(rest.type) ?? "(inline)",
449
+ scopes: Array.isArray(scopes)
450
+ ? scopes.filter((scope): scope is string => typeof scope === "string")
451
+ : [],
452
+ },
453
+ ];
454
+ }
455
+
456
+ /** The ids of a list of message `$ref`s. */
457
+ function messageIds(list: unknown): string[] {
458
+ const entries = Array.isArray(list)
459
+ ? list
460
+ : isObject(list)
461
+ ? Object.values(list)
462
+ : [];
463
+ return entries.flatMap((entry) => {
464
+ const name = refName(entry);
465
+ return name === undefined ? [] : [name];
466
+ });
467
+ }
468
+
469
+ /** Reads the event kind and type from a message key (`queue.completed`); `null` for a control message. */
470
+ function eventOf(key: string): WsMessage["event"] {
471
+ const match = /^([^.]+)\.(.+)$/.exec(key);
472
+ return match ? { kind: match[1]!, type: match[2]! } : null;
473
+ }
474
+
475
+ /** Reads the numeric limits and the replay setting. */
476
+ function readLimits(raw: unknown): WsLimits | null {
477
+ if (!isObject(raw)) {
478
+ return null;
479
+ }
480
+ const values: [string, number][] = [];
481
+ for (const [name, value] of Object.entries(raw)) {
482
+ const n = num(value);
483
+ if (name !== "replay" && n !== undefined) {
484
+ values.push([name, n]);
485
+ }
486
+ }
487
+ const replay = raw.replay;
488
+ return {
489
+ values,
490
+ replay:
491
+ replay === false
492
+ ? false
493
+ : isObject(replay)
494
+ ? { size: num(replay.size), maxAgeMs: num(replay.maxAgeMs) }
495
+ : undefined,
496
+ };
497
+ }
498
+
499
+ /** Reads the document into what the screen shows. */
500
+ export function readWsDoc(
501
+ doc: SpecDocument,
502
+ options: WsModelOptions = {},
503
+ ): WsDoc {
504
+ const prefixes = options.extensions ?? DEFAULT_EXTENSION_PREFIXES;
505
+ const info = isObject(doc.info) ? doc.info : {};
506
+ const components = isObject(doc.components) ? doc.components : {};
507
+ const rawSchemes = isObject(components.securitySchemes)
508
+ ? components.securitySchemes
509
+ : {};
510
+
511
+ // The server.
512
+ const servers = isObject(doc.servers) ? doc.servers : {};
513
+ const serverKey = isObject(servers.api)
514
+ ? "api"
515
+ : Object.keys(servers).find((key) => isObject(servers[key]));
516
+ const rawServer =
517
+ serverKey === undefined ? undefined : (servers[serverKey] as Json);
518
+ let server: WsServer | null = null;
519
+ let requirements: WsRequirement[] = [];
520
+ let nativeCount = 0;
521
+ let securityKey: string | undefined;
522
+ if (rawServer) {
523
+ const variables = isObject(rawServer.variables) ? rawServer.variables : {};
524
+ const hostVar = isObject(variables.host) ? variables.host : undefined;
525
+ const rawHost = str(rawServer.host) ?? "";
526
+ const hostIsTemplate = /\{[^}]+\}/.test(rawHost);
527
+ const hostDefault = str(hostVar?.default);
528
+ const pathname = str(rawServer.pathname);
529
+ const protocol = str(rawServer.protocol);
530
+ const host =
531
+ hostIsTemplate && hostDefault
532
+ ? rawHost.replace(/\{host\}/, hostDefault)
533
+ : rawHost;
534
+ server = {
535
+ key: serverKey!,
536
+ host: rawHost,
537
+ hostIsTemplate,
538
+ hostDefault,
539
+ pathname,
540
+ protocol,
541
+ description: str(rawServer.description),
542
+ url:
543
+ host !== "" && protocol !== undefined
544
+ ? `${protocol}://${host}${pathname ?? ""}`
545
+ : undefined,
546
+ };
547
+ const native = Array.isArray(rawServer.security) ? rawServer.security : [];
548
+ nativeCount = native.length;
549
+ const verbatim = extensionOf(rawServer, "security", prefixes);
550
+ securityKey = verbatim?.key;
551
+ requirements = (
552
+ verbatim !== undefined && Array.isArray(verbatim.value)
553
+ ? verbatim.value.map(readRequirement)
554
+ : native.map((entry) => readNativeRequirement(entry, rawSchemes))
555
+ ).filter(
556
+ (requirement): requirement is WsRequirement => requirement !== null,
557
+ );
558
+ }
559
+ const named = new Set(
560
+ requirements.flatMap((requirement) =>
561
+ requirement.map((part) => part.scheme),
562
+ ),
563
+ );
564
+ const securitySchemes: WsSecurityScheme[] = Object.entries(rawSchemes)
565
+ .filter(([name]) => named.has(name) || named.size === 0)
566
+ .flatMap(([name, scheme]) =>
567
+ isObject(scheme)
568
+ ? [
569
+ {
570
+ name,
571
+ type: str(scheme.type),
572
+ detail: schemeDetail(scheme) || undefined,
573
+ description: str(scheme.description),
574
+ note: str(extensionOf(scheme, "note", prefixes)?.value),
575
+ },
576
+ ]
577
+ : [],
578
+ );
579
+
580
+ // Channels: connection first.
581
+ const rawChannels = isObject(doc.channels) ? doc.channels : {};
582
+ const channels: WsChannel[] = Object.entries(rawChannels)
583
+ .flatMap(([key, channel]): WsChannel[] => {
584
+ if (!isObject(channel)) {
585
+ return [];
586
+ }
587
+ const address = str(channel.address) ?? key;
588
+ const declared = isObject(channel.parameters) ? channel.parameters : {};
589
+ const inTemplate = [...address.matchAll(/\{([^}]+)\}/g)].map(
590
+ (match) => match[1],
591
+ );
592
+ const names = [
593
+ ...inTemplate,
594
+ ...Object.keys(declared).filter((name) => !inTemplate.includes(name)),
595
+ ];
596
+ const bindings = isObject(channel.bindings) ? channel.bindings : {};
597
+ const ws = isObject(bindings.ws) ? bindings.ws : {};
598
+ return [
599
+ {
600
+ key,
601
+ slug: itemSlug("channel", key),
602
+ title: str(channel.title) ?? key,
603
+ address,
604
+ description: str(channel.description),
605
+ parameters: names.map((name) => {
606
+ const parameter = isObject(declared[name])
607
+ ? declared[name]
608
+ : undefined;
609
+ const schema = extensionOf(parameter, "schema", prefixes)?.value;
610
+ return {
611
+ name,
612
+ description: str(parameter?.description),
613
+ schema: isObject(schema) ? schema : undefined,
614
+ };
615
+ }),
616
+ messages: messageIds(channel.messages),
617
+ bindingMethod: str(ws.method),
618
+ // The transport: keyed `connection`, or (under another key) the
619
+ // channel carrying the WebSocket binding's upgrade method, which
620
+ // only the socket itself has.
621
+ isConnection: key === "connection" || str(ws.method) !== undefined,
622
+ },
623
+ ];
624
+ })
625
+ .sort((a, b) => Number(b.isConnection) - Number(a.isConnection));
626
+ const connection = channels.find((channel) => channel.isConnection);
627
+ const rawConnection = connection ? (rawChannels[connection.key] as Json) : {};
628
+
629
+ // Operations.
630
+ const rawOperations = isObject(doc.operations) ? doc.operations : {};
631
+ const operations: WsOperation[] = Object.entries(rawOperations).flatMap(
632
+ ([key, operation]): WsOperation[] => {
633
+ if (!isObject(operation)) {
634
+ return [];
635
+ }
636
+ const action = str(operation.action);
637
+ const reply = isObject(operation.reply) ? operation.reply : null;
638
+ return [
639
+ {
640
+ key,
641
+ slug: itemSlug("operation", key),
642
+ title: str(operation.title) ?? key,
643
+ summary: str(operation.summary),
644
+ action:
645
+ action === "send" || action === "receive" ? action : undefined,
646
+ channel: refName(operation.channel),
647
+ messages: messageIds(operation.messages),
648
+ reply: reply
649
+ ? {
650
+ channel: refName(reply.channel),
651
+ messages: messageIds(reply.messages),
652
+ }
653
+ : null,
654
+ permission: str(extensionOf(operation, "action", prefixes)?.value),
655
+ },
656
+ ];
657
+ },
658
+ );
659
+
660
+ // Messages: control first, then events, each in document order.
661
+ const rawMessages = isObject(components.messages) ? components.messages : {};
662
+ const messages: WsMessage[] = Object.entries(rawMessages)
663
+ .flatMap(([key, message]): WsMessage[] => {
664
+ if (!isObject(message)) {
665
+ return [];
666
+ }
667
+ const examples = Array.isArray(message.examples) ? message.examples : [];
668
+ return [
669
+ {
670
+ key,
671
+ slug: itemSlug("message", key),
672
+ name: str(message.name) ?? key,
673
+ title: str(message.title),
674
+ summary: str(message.summary),
675
+ contentType: str(message.contentType) ?? str(doc.defaultContentType),
676
+ payload: message.payload,
677
+ examples: examples.filter(isObject).map((example) => ({
678
+ name: str(example.name),
679
+ summary: str(example.summary),
680
+ payload: example.payload,
681
+ })),
682
+ event: eventOf(key),
683
+ },
684
+ ];
685
+ })
686
+ .sort((a, b) => Number(a.event !== null) - Number(b.event !== null));
687
+
688
+ // The connection's extensions, on the connection channel (or wherever they
689
+ // are): each is read from the first prefix carrying it, looking at the
690
+ // connection channel first.
691
+ const channelExtension = (name: string): FoundExtension | undefined => {
692
+ for (const prefix of prefixes) {
693
+ const key = `${prefix}-${name}`;
694
+ if (Object.hasOwn(rawConnection, key)) {
695
+ return { key, value: rawConnection[key] };
696
+ }
697
+ const carrier = Object.values(rawChannels).find(
698
+ (channel) => isObject(channel) && Object.hasOwn(channel, key),
699
+ );
700
+ if (isObject(carrier)) {
701
+ return { key, value: carrier[key] };
702
+ }
703
+ }
704
+ return undefined;
705
+ };
706
+ const closeCodesExt = channelExtension("close-codes");
707
+ const closeCodes = (
708
+ Array.isArray(closeCodesExt?.value) ? closeCodesExt.value : []
709
+ ).flatMap((entry): WsCloseCode[] =>
710
+ isObject(entry) && num(entry.code) !== undefined
711
+ ? [
712
+ {
713
+ code: num(entry.code)!,
714
+ name: str(entry.name) ?? "",
715
+ description: str(entry.description) ?? "",
716
+ },
717
+ ]
718
+ : [],
719
+ );
720
+ const refusalsExt = channelExtension("upgrade-refusals");
721
+ const refusals = (
722
+ Array.isArray(refusalsExt?.value) ? refusalsExt.value : []
723
+ ).flatMap((entry): WsUpgradeRefusal[] =>
724
+ isObject(entry) && num(entry.status) !== undefined
725
+ ? [
726
+ {
727
+ status: num(entry.status)!,
728
+ code: str(entry.code) ?? "",
729
+ description: str(entry.description) ?? "",
730
+ contentType: str(entry.contentType),
731
+ headers: isObject(entry.headers)
732
+ ? Object.fromEntries(
733
+ Object.entries(entry.headers).flatMap(([name, value]) =>
734
+ typeof value === "string" ? [[name, value]] : [],
735
+ ),
736
+ )
737
+ : {},
738
+ },
739
+ ]
740
+ : [],
741
+ );
742
+ const limitsExt = channelExtension("limits");
743
+
744
+ // A non-string extension value is as good as absent (as the original
745
+ // reader's `str(...)` at each level did): fall through to the next source.
746
+ const subprotocolOn = (
747
+ node: Json | undefined,
748
+ ): FoundExtension | undefined => {
749
+ const found = extensionOf(node, "subprotocol", prefixes);
750
+ return found !== undefined && str(found.value) !== undefined
751
+ ? found
752
+ : undefined;
753
+ };
754
+ const onServer = subprotocolOn(rawServer);
755
+ const onConnection =
756
+ onServer === undefined ? subprotocolOn(rawConnection) : undefined;
757
+ const prose =
758
+ onServer === undefined && onConnection === undefined
759
+ ? readSubprotocol(connection?.description)
760
+ : undefined;
761
+ const stated = str(onServer?.value) ?? str(onConnection?.value) ?? prose;
762
+ return {
763
+ title: str(info.title) ?? "WebSocket API",
764
+ version: str(info.version),
765
+ description: str(info.description),
766
+ server,
767
+ subprotocol: {
768
+ value: stated ?? options.defaultSubprotocol,
769
+ fromDocument: stated !== undefined,
770
+ source:
771
+ onServer !== undefined
772
+ ? "server-extension"
773
+ : onConnection !== undefined
774
+ ? "connection-extension"
775
+ : prose !== undefined
776
+ ? "connection-prose"
777
+ : "default",
778
+ },
779
+ subprotocolKey: onServer?.key ?? onConnection?.key,
780
+ extensionKeys: {
781
+ security: securityKey,
782
+ limits: limitsExt?.key,
783
+ closeCodes: closeCodesExt?.key,
784
+ upgradeRefusals: refusalsExt?.key,
785
+ },
786
+ securitySchemes,
787
+ requirements,
788
+ conjunctive: requirements.length > nativeCount,
789
+ channels,
790
+ operations,
791
+ messages,
792
+ limits: readLimits(limitsExt?.value),
793
+ closeCodes,
794
+ refusals,
795
+ };
796
+ }
797
+
798
+ /** One entry of the sidebar. */
799
+ export interface WsNavItem {
800
+ /** The item slug. */
801
+ slug: string;
802
+ /** What the entry reads. */
803
+ label: string;
804
+ /** A second line: an address, an action, a summary. */
805
+ hint: string | undefined;
806
+ /** Text the search matches besides the label. */
807
+ keywords: string;
808
+ }
809
+
810
+ /** A sidebar group. */
811
+ export interface WsNavGroup {
812
+ /** The group's heading. */
813
+ title: string;
814
+ /** Its entries. */
815
+ items: WsNavItem[];
816
+ }
817
+
818
+ /** The sidebar: the connection's panels, channels, operations, then messages (control, then events). */
819
+ export function navGroups(doc: WsDoc): WsNavGroup[] {
820
+ const candidates: (WsNavItem | false | null)[] = [
821
+ doc.limits && {
822
+ slug: itemSlug("limits"),
823
+ label: "Limits",
824
+ hint: doc.extensionKeys.limits,
825
+ keywords: doc.limits.values.map(([name]) => name).join(" "),
826
+ },
827
+ doc.closeCodes.length > 0 && {
828
+ slug: itemSlug("close-codes"),
829
+ label: "Close codes",
830
+ hint: doc.extensionKeys.closeCodes,
831
+ keywords: doc.closeCodes
832
+ .map((entry) => `${entry.code} ${entry.name}`)
833
+ .join(" "),
834
+ },
835
+ doc.refusals.length > 0 && {
836
+ slug: itemSlug("upgrade-refusals"),
837
+ label: "Upgrade refusals",
838
+ hint: doc.extensionKeys.upgradeRefusals,
839
+ keywords: doc.refusals
840
+ .map((entry) => `${entry.status} ${entry.code}`)
841
+ .join(" "),
842
+ },
843
+ ];
844
+ const connection = candidates.filter((item): item is WsNavItem =>
845
+ Boolean(item),
846
+ );
847
+ const control = doc.messages.filter((message) => message.event === null);
848
+ const events = doc.messages.filter((message) => message.event !== null);
849
+ const messageItem = (message: WsMessage): WsNavItem => ({
850
+ slug: message.slug,
851
+ label: message.name,
852
+ hint: message.summary,
853
+ keywords: `${message.title ?? ""} ${message.summary ?? ""}`,
854
+ });
855
+ return [
856
+ { title: "Connection", items: connection },
857
+ {
858
+ title: "Channels",
859
+ items: doc.channels.map((channel) => ({
860
+ slug: channel.slug,
861
+ label: channel.title,
862
+ hint: channel.address,
863
+ keywords: `${channel.key} ${channel.address} ${channel.parameters.map((parameter) => parameter.name).join(" ")}`,
864
+ })),
865
+ },
866
+ {
867
+ title: "Operations",
868
+ items: doc.operations.map((operation) => ({
869
+ slug: operation.slug,
870
+ label: operation.title,
871
+ hint: [operation.action, operation.permission]
872
+ .filter(Boolean)
873
+ .join(" · "),
874
+ keywords: `${operation.key} ${operation.summary ?? ""} ${operation.permission ?? ""}`,
875
+ })),
876
+ },
877
+ { title: "Control messages", items: control.map(messageItem) },
878
+ { title: "Event messages", items: events.map(messageItem) },
879
+ ].filter((group) => group.items.length > 0);
880
+ }
881
+
882
+ /** The groups keeping only entries matching `query` (case-insensitive, every word). */
883
+ export function filterGroups(
884
+ groups: readonly WsNavGroup[],
885
+ query: string,
886
+ ): WsNavGroup[] {
887
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
888
+ if (words.length === 0) {
889
+ return [...groups];
890
+ }
891
+ return groups
892
+ .map((group) => ({
893
+ ...group,
894
+ items: group.items.filter((item) => {
895
+ const text =
896
+ `${item.slug} ${item.label} ${item.hint ?? ""} ${item.keywords}`.toLowerCase();
897
+ return words.every((word) => text.includes(word));
898
+ }),
899
+ }))
900
+ .filter((group) => group.items.length > 0);
901
+ }
902
+
903
+ /** What a slug selects. */
904
+ export type WsSelection =
905
+ | {
906
+ /** A channel. */
907
+ kind: "channel";
908
+ /** It. */
909
+ channel: WsChannel;
910
+ }
911
+ | {
912
+ /** An operation. */
913
+ kind: "operation";
914
+ /** It. */
915
+ operation: WsOperation;
916
+ }
917
+ | {
918
+ /** A message. */
919
+ kind: "message";
920
+ /** It. */
921
+ message: WsMessage;
922
+ }
923
+ | {
924
+ /** One of the connection's extension panels. */
925
+ kind: "limits" | "close-codes" | "upgrade-refusals";
926
+ };
927
+
928
+ /** The item a slug names, or `null` when the document has none by it. */
929
+ export function selectItem(doc: WsDoc, slug: string): WsSelection | null {
930
+ if (slug === "limits" && doc.limits) {
931
+ return { kind: "limits" };
932
+ }
933
+ if (slug === "close-codes" && doc.closeCodes.length > 0) {
934
+ return { kind: "close-codes" };
935
+ }
936
+ if (slug === "upgrade-refusals" && doc.refusals.length > 0) {
937
+ return { kind: "upgrade-refusals" };
938
+ }
939
+ const channel = doc.channels.find((candidate) => candidate.slug === slug);
940
+ if (channel) {
941
+ return { kind: "channel", channel };
942
+ }
943
+ const operation = doc.operations.find((candidate) => candidate.slug === slug);
944
+ if (operation) {
945
+ return { kind: "operation", operation };
946
+ }
947
+ const message = doc.messages.find((candidate) => candidate.slug === slug);
948
+ return message ? { kind: "message", message } : null;
949
+ }
950
+
951
+ /** The operations on a channel. */
952
+ export function operationsOn(doc: WsDoc, channel: string): WsOperation[] {
953
+ return doc.operations.filter((operation) => operation.channel === channel);
954
+ }
955
+
956
+ /** The channels carrying a message. */
957
+ export function channelsCarrying(doc: WsDoc, message: string): WsChannel[] {
958
+ return doc.channels.filter((channel) => channel.messages.includes(message));
959
+ }
960
+
961
+ /** The operations sending, receiving or replying with a message. */
962
+ export function operationsUsing(doc: WsDoc, message: string): WsOperation[] {
963
+ return doc.operations.filter(
964
+ (operation) =>
965
+ operation.messages.includes(message) ||
966
+ (operation.reply?.messages.includes(message) ?? false),
967
+ );
968
+ }
969
+
970
+ /** The permissions a channel's operations need, de-duplicated, in order. */
971
+ export function channelPermissions(doc: WsDoc, channel: string): string[] {
972
+ return [
973
+ ...new Set(
974
+ operationsOn(doc, channel).flatMap((operation) =>
975
+ operation.permission === undefined ? [] : [operation.permission],
976
+ ),
977
+ ),
978
+ ];
979
+ }
980
+
981
+ /** The event types a channel carries (`completed`, ...), de-duplicated, in order. */
982
+ export function channelEventTypes(doc: WsDoc, channel: WsChannel): string[] {
983
+ const types = channel.messages.flatMap((key) => {
984
+ const message = doc.messages.find((candidate) => candidate.key === key);
985
+ return message?.event ? [message.event.type] : [];
986
+ });
987
+ return [...new Set(types)];
988
+ }
989
+
990
+ /**
991
+ * How an embedding app applies its own contract to the try-it machinery:
992
+ * the jobs UI passes its name rule and job-id escaping; a standalone viewer
993
+ * passes nothing (the document's own schemas decide, and values fill
994
+ * unescaped).
995
+ */
996
+ export interface WsAddressOptions {
997
+ /**
998
+ * Fallback validation for a parameter the document gives no
999
+ * `<prefix>-schema`: why the value is refused, or `null` when it passes.
1000
+ * Default: anything passes.
1001
+ */
1002
+ nameRule?: (name: string, value: string) => string | null;
1003
+ /**
1004
+ * Friendlier refusal wordings for known schema patterns, by the pattern
1005
+ * string (bun-jobs words its contract's pattern). Default: `Must match
1006
+ * <pattern>.`
1007
+ */
1008
+ patternText?: Readonly<Record<string, string>>;
1009
+ /**
1010
+ * Escapes a value for a filled address (bun-jobs escapes job ids with its
1011
+ * contract's `encodeJobId`). Default: identity.
1012
+ */
1013
+ escapeValue?: (name: string, value: string) => string;
1014
+ }
1015
+
1016
+ /** Compiles a JSON Schema `pattern` (ECMA-262, unicode), or `undefined` when it does not compile. */
1017
+ function compilePattern(pattern: string): RegExp | undefined {
1018
+ try {
1019
+ return new RegExp(pattern, "u");
1020
+ } catch {
1021
+ return undefined;
1022
+ }
1023
+ }
1024
+
1025
+ /**
1026
+ * Why a value is refused by a parameter's `<prefix>-schema` (its `pattern`,
1027
+ * `minLength` and `maxLength`; lengths in code points, as JSON Schema counts
1028
+ * them), or `null` when it passes. A pattern that does not compile is skipped
1029
+ * rather than refusing everything.
1030
+ */
1031
+ function schemaProblem(
1032
+ schema: Json,
1033
+ value: string,
1034
+ options?: WsAddressOptions,
1035
+ ): string | null {
1036
+ const length = [...value].length;
1037
+ const max = num(schema.maxLength);
1038
+ if (max !== undefined && length > max) {
1039
+ return `At most ${max} characters (this is ${length}).`;
1040
+ }
1041
+ const min = num(schema.minLength);
1042
+ if (min !== undefined && length < min) {
1043
+ return `At least ${min} characters.`;
1044
+ }
1045
+ const pattern = str(schema.pattern);
1046
+ const compiled = pattern === undefined ? undefined : compilePattern(pattern);
1047
+ if (compiled && !compiled.test(value)) {
1048
+ return (
1049
+ (pattern === undefined ? undefined : options?.patternText?.[pattern]) ??
1050
+ `Must match ${pattern}.`
1051
+ );
1052
+ }
1053
+ return null;
1054
+ }
1055
+
1056
+ /**
1057
+ * Why a parameter value is refused, or `null` when it is fine (or empty).
1058
+ * The document's `<prefix>-schema` decides when it gives one; otherwise the
1059
+ * options' `nameRule` decides, when the embedding app has one.
1060
+ */
1061
+ export function parameterProblem(
1062
+ name: string,
1063
+ value: string,
1064
+ schema?: Json,
1065
+ options?: WsAddressOptions,
1066
+ ): string | null {
1067
+ if (value === "") {
1068
+ return null;
1069
+ }
1070
+ if (schema !== undefined) {
1071
+ return schemaProblem(schema, value, options);
1072
+ }
1073
+ return options?.nameRule?.(name, value) ?? null;
1074
+ }
1075
+
1076
+ /** The parameters' schemas by name, for {@link fillAddress}. */
1077
+ export function parameterSchemas(
1078
+ parameters: readonly WsParameter[],
1079
+ ): Record<string, Json | undefined> {
1080
+ return Object.fromEntries(
1081
+ parameters.map((parameter) => [parameter.name, parameter.schema]),
1082
+ );
1083
+ }
1084
+
1085
+ /**
1086
+ * An address template filled from parameter values: `queue/{queue}/job/{jobId}`
1087
+ * with each value escaped by the options' `escapeValue`. `null` while a value
1088
+ * is missing or refused by {@link parameterProblem} (under `schemas`, the
1089
+ * document's, when it gives them).
1090
+ */
1091
+ export function fillAddress(
1092
+ address: string,
1093
+ values: Readonly<Record<string, string>>,
1094
+ schemas: Readonly<Record<string, Json | undefined>> = {},
1095
+ options?: WsAddressOptions,
1096
+ ): string | null {
1097
+ let complete = true;
1098
+ const filled = address.replace(/\{([^}]+)\}/g, (_match, name: string) => {
1099
+ const value = values[name] ?? "";
1100
+ if (
1101
+ value === "" ||
1102
+ parameterProblem(name, value, schemas[name], options) !== null
1103
+ ) {
1104
+ complete = false;
1105
+ return "";
1106
+ }
1107
+ return options?.escapeValue?.(name, value) ?? value;
1108
+ });
1109
+ return complete ? filled : null;
1110
+ }
1111
+
1112
+ /**
1113
+ * Why a channel's try-it cannot be opened yet: each empty or refused
1114
+ * parameter, in template order. Empty when it can.
1115
+ */
1116
+ export function tryProblems(
1117
+ channel: WsChannel,
1118
+ values: Readonly<Record<string, string>>,
1119
+ options?: WsAddressOptions,
1120
+ ): string[] {
1121
+ return channel.parameters.flatMap((parameter) => {
1122
+ const value = values[parameter.name] ?? "";
1123
+ if (value === "") {
1124
+ return [`${parameter.name}: fill it in.`];
1125
+ }
1126
+ const problem = parameterProblem(
1127
+ parameter.name,
1128
+ value,
1129
+ parameter.schema,
1130
+ options,
1131
+ );
1132
+ return problem === null ? [] : [`${parameter.name}: ${problem}`];
1133
+ });
1134
+ }
1135
+
1136
+ /**
1137
+ * Whether a channel is something to subscribe to, and so has a try-it at
1138
+ * all: a logical channel name. The connection is the transport the others
1139
+ * travel over, and an address that is a socket path (`/...`) is not a
1140
+ * channel name either; neither gets a try-it, not even a disabled one.
1141
+ */
1142
+ export function channelSubscribable(channel: WsChannel): boolean {
1143
+ return !channel.isConnection && !channel.address.startsWith("/");
1144
+ }
1145
+
1146
+ /**
1147
+ * An event message's highlighted part: the schema of `event.payload` inside
1148
+ * its envelope (resolving `$ref`s on the way), or `undefined`.
1149
+ */
1150
+ export function eventPayloadSchema(
1151
+ payload: unknown,
1152
+ root: SpecDocument,
1153
+ ): unknown {
1154
+ const deref = (value: unknown): unknown => {
1155
+ let current = value;
1156
+ for (let i = 0; i < 16 && isObject(current) && str(current.$ref); i++) {
1157
+ current = resolveRef(root, str(current.$ref)!);
1158
+ }
1159
+ return current;
1160
+ };
1161
+ const envelope = deref(payload);
1162
+ const event =
1163
+ isObject(envelope) && isObject(envelope.properties)
1164
+ ? deref(envelope.properties.event)
1165
+ : undefined;
1166
+ return isObject(event) && isObject(event.properties)
1167
+ ? event.properties.payload
1168
+ : undefined;
1169
+ }
1170
+
1171
+ /**
1172
+ * An event example's `event.payload` (what the event says), or `undefined`
1173
+ * when the frame has none: the part of an event frame the pane shows first.
1174
+ */
1175
+ export function exampleEventPayload(frame: unknown): unknown {
1176
+ const event = isObject(frame) ? frame.event : undefined;
1177
+ return isObject(event) ? event.payload : undefined;
1178
+ }