@kingsleyweb/bun-ui 0.0.0-stage → 3.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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-cqgyvs86.css +1 -0
  74. package/dist/docs/assets/main-pgxtmtja.js +18 -0
  75. package/dist/docs/assets/main-pgxtmtja.js.map +99 -0
  76. package/dist/docs/manifest.json +26 -0
  77. package/dist/jobs/assets/AddFlowDialog-0ec5ff4r.js +6 -0
  78. package/dist/jobs/assets/AddFlowDialog-0ec5ff4r.js.map +11 -0
  79. package/dist/jobs/assets/AddFlowDialog-n7gtc84v.css +1 -0
  80. package/dist/jobs/assets/added-ma7xb2dx.js +4 -0
  81. package/dist/jobs/assets/added-ma7xb2dx.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-a5chwqet.js +4 -0
  85. package/dist/jobs/assets/channel-a5chwqet.js.map +10 -0
  86. package/dist/jobs/assets/channelChecks-1epbhv9a.js +4 -0
  87. package/dist/jobs/assets/channelChecks-1epbhv9a.js.map +10 -0
  88. package/dist/jobs/assets/index-02427smp.css +1 -0
  89. package/dist/jobs/assets/index-18bn0gvy.css +1 -0
  90. package/dist/jobs/assets/index-5kbyqspm.css +1 -0
  91. package/dist/jobs/assets/index-5ng37hk6.css +1 -0
  92. package/dist/jobs/assets/index-5w8w7fvc.css +1 -0
  93. package/dist/jobs/assets/index-71qs5qyx.css +1 -0
  94. package/dist/jobs/assets/index-7kbae3pm.js +4 -0
  95. package/dist/jobs/assets/index-7kbae3pm.js.map +11 -0
  96. package/dist/jobs/assets/index-btpg4r8b.css +1 -0
  97. package/dist/jobs/assets/index-c7qk888t.js +4 -0
  98. package/dist/jobs/assets/index-c7qk888t.js.map +10 -0
  99. package/dist/jobs/assets/index-de1fgbgf.js +4 -0
  100. package/dist/jobs/assets/index-de1fgbgf.js.map +9 -0
  101. package/dist/jobs/assets/index-e0yy2skq.css +1 -0
  102. package/dist/jobs/assets/index-j3h4v03g.js +4 -0
  103. package/dist/jobs/assets/index-j3h4v03g.js.map +23 -0
  104. package/dist/jobs/assets/index-m7zk18cj.js +4 -0
  105. package/dist/jobs/assets/index-m7zk18cj.js.map +16 -0
  106. package/dist/jobs/assets/index-mykmd5cv.js +4 -0
  107. package/dist/jobs/assets/index-mykmd5cv.js.map +10 -0
  108. package/dist/jobs/assets/index-pjtmzrcd.js +4 -0
  109. package/dist/jobs/assets/index-pjtmzrcd.js.map +32 -0
  110. package/dist/jobs/assets/index-qfb80xen.css +1 -0
  111. package/dist/jobs/assets/index-xfq292gs.js +4 -0
  112. package/dist/jobs/assets/index-xfq292gs.js.map +18 -0
  113. package/dist/jobs/assets/index-y5zw06cs.js +10 -0
  114. package/dist/jobs/assets/index-y5zw06cs.js.map +62 -0
  115. package/dist/jobs/assets/live-erz9pvnm.js +4 -0
  116. package/dist/jobs/assets/live-erz9pvnm.js.map +10 -0
  117. package/dist/jobs/assets/main-2n5wnj51.js +5 -0
  118. package/dist/jobs/assets/main-2n5wnj51.js.map +12 -0
  119. package/dist/jobs/assets/main-3xxhy5jh.js +5 -0
  120. package/dist/jobs/assets/main-3xxhy5jh.js.map +11 -0
  121. package/dist/jobs/assets/main-47391fgm.js +5 -0
  122. package/dist/jobs/assets/main-47391fgm.js.map +23 -0
  123. package/dist/jobs/assets/main-54qwkz28.css +1 -0
  124. package/dist/jobs/assets/main-6xdkyzv1.js +5 -0
  125. package/dist/jobs/assets/main-6xdkyzv1.js.map +10 -0
  126. package/dist/jobs/assets/main-7jzem39c.js +4 -0
  127. package/dist/jobs/assets/main-7jzem39c.js.map +22 -0
  128. package/dist/jobs/assets/main-9qd3nqs5.js +5 -0
  129. package/dist/jobs/assets/main-9qd3nqs5.js.map +12 -0
  130. package/dist/jobs/assets/main-b35rfpdf.js +5 -0
  131. package/dist/jobs/assets/main-b35rfpdf.js.map +11 -0
  132. package/dist/jobs/assets/main-e8zsqez2.js +5 -0
  133. package/dist/jobs/assets/main-e8zsqez2.js.map +10 -0
  134. package/dist/jobs/assets/main-eekxtm5w.js +5 -0
  135. package/dist/jobs/assets/main-eekxtm5w.js.map +25 -0
  136. package/dist/jobs/assets/main-fcq7hfk0.js +5 -0
  137. package/dist/jobs/assets/main-fcq7hfk0.js.map +10 -0
  138. package/dist/jobs/assets/main-fn8r2zyb.js +5 -0
  139. package/dist/jobs/assets/main-fn8r2zyb.js.map +10 -0
  140. package/dist/jobs/assets/main-grcj8v1z.js +5 -0
  141. package/dist/jobs/assets/main-grcj8v1z.js.map +21 -0
  142. package/dist/jobs/assets/main-gzppw8ng.js +5 -0
  143. package/dist/jobs/assets/main-gzppw8ng.js.map +11 -0
  144. package/dist/jobs/assets/main-jex4pqaa.js +7 -0
  145. package/dist/jobs/assets/main-jex4pqaa.js.map +11 -0
  146. package/dist/jobs/assets/main-kjywj2zd.js +5 -0
  147. package/dist/jobs/assets/main-kjywj2zd.js.map +10 -0
  148. package/dist/jobs/assets/main-pqke97qp.js +5 -0
  149. package/dist/jobs/assets/main-pqke97qp.js.map +13 -0
  150. package/dist/jobs/assets/main-qahvywvw.js +5 -0
  151. package/dist/jobs/assets/main-qahvywvw.js.map +10 -0
  152. package/dist/jobs/assets/main-smabxbn4.js +6 -0
  153. package/dist/jobs/assets/main-smabxbn4.js.map +12 -0
  154. package/dist/jobs/assets/main-v2f8a4g3.js +5 -0
  155. package/dist/jobs/assets/main-v2f8a4g3.js.map +10 -0
  156. package/dist/jobs/assets/main-vargz49c.js +5 -0
  157. package/dist/jobs/assets/main-vargz49c.js.map +11 -0
  158. package/dist/jobs/assets/main-xf44bv26.js +17 -0
  159. package/dist/jobs/assets/main-xf44bv26.js.map +115 -0
  160. package/dist/jobs/assets/main-yg6scp84.js +5 -0
  161. package/dist/jobs/assets/main-yg6scp84.js.map +10 -0
  162. package/dist/jobs/assets/main-yjykmsvp.js +5 -0
  163. package/dist/jobs/assets/main-yjykmsvp.js.map +10 -0
  164. package/dist/jobs/assets/main-zk3n47nq.js +5 -0
  165. package/dist/jobs/assets/main-zk3n47nq.js.map +10 -0
  166. package/dist/jobs/assets/main-zx20z2e9.js +5 -0
  167. package/dist/jobs/assets/main-zx20z2e9.js.map +10 -0
  168. package/dist/jobs/assets/sections-0ksdxs8m.css +1 -0
  169. package/dist/jobs/assets/sections-b570mm22.js +4 -0
  170. package/dist/jobs/assets/sections-b570mm22.js.map +10 -0
  171. package/dist/jobs/assets/sse-42e8mhar.js +5 -0
  172. package/dist/jobs/assets/sse-42e8mhar.js.map +11 -0
  173. package/dist/jobs/assets/sseChecks-0jskzcs1.js +4 -0
  174. package/dist/jobs/assets/sseChecks-0jskzcs1.js.map +10 -0
  175. package/dist/jobs/assets/validation-199pberh.js +4 -0
  176. package/dist/jobs/assets/validation-199pberh.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 +194 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Okafor Obinna Kingsley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,81 @@
1
- # Temporary Holding Version
1
+ # @kingsleyweb/bun-ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Every bun-node browser UI in one package, each behind its own subpath. A UI is
4
+ a React app prebuilt into `dist/<ui>/` and served by a bun-common router you
5
+ mount on any bun-common `BunHttpAdapter`, `BunRouter` or bun-nest's
6
+ `BunHttpAdapter`: hash-named immutable assets, computed Subresource Integrity
7
+ and a strict Content-Security-Policy. React and TanStack Query are bundled, so
8
+ the host installs neither and has no build step.
9
+
10
+ | Subpath | What | Needs |
11
+ |---|---|---|
12
+ | `@kingsleyweb/bun-ui/jobs` | `jobsUi()`: the management UI for the `@kingsleyweb/bun-jobs` management API. [Guide](guides/jobs.md) | `@kingsleyweb/bun-jobs` |
13
+ | `@kingsleyweb/bun-ui/jobs/browser` | the jobs UI's browser-safe config contract (`JobsUiConfig`, `JOBS_UI_CONFIG_ELEMENT_ID`, …) | nothing |
14
+ | `@kingsleyweb/bun-ui/docs` | `docsUi()`: a self-hosted viewer for one OpenAPI 3.1 or AsyncAPI 3.0 document. [Guide](guides/docs.md) | nothing |
15
+ | `@kingsleyweb/bun-ui/docs/browser` | the docs viewer's browser-safe config contract and the `unauthorized` helpers | nothing |
16
+ | `@kingsleyweb/bun-ui/docs/hosted` | what bun-common's `docsRouter` `"self-hosted"` viewer loads to serve the viewer itself | nothing |
17
+ | `@kingsleyweb/bun-ui/docs/embed`, `/docs/embed/{routing,router,context,permissions,client,errors,unauthorized}` | the viewer's screens as source, for a React app that bundles them into its own UI | the host's `react` and `@tanstack/react-query` |
18
+ | `@kingsleyweb/bun-ui` | what every UI shares, browser-safe: `UiTheme`, the `unauthorized` option's types and defaults, `ASSET_CACHE_CONTROL` | nothing |
19
+
20
+ Each UI's domain package is an **optional** peer that only its own subpath
21
+ needs: install `@kingsleyweb/bun-jobs` to use `/jobs`. An app that mounts one
22
+ UI never loads another's code or bundle.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ bun add @kingsleyweb/bun-ui # for /docs
28
+ bun add @kingsleyweb/bun-ui @kingsleyweb/bun-jobs # for /jobs
29
+ ```
30
+
31
+ Requires Bun >= 1.4.2. Bun runs the shipped TypeScript source in `lib/`; your
32
+ type checker reads the built declarations in `dts/`, which describe `lib/`
33
+ only, so no React or TanStack type reaches your project.
34
+
35
+ ## Quick start: the jobs UI
36
+
37
+ ```ts
38
+ import { BunHttpAdapter } from "@kingsleyweb/bun-common";
39
+ import { createJobsApi } from "@kingsleyweb/bun-jobs";
40
+ import { jobsUi } from "@kingsleyweb/bun-ui/jobs";
41
+
42
+ const api = createJobsApi({ jobs, basePath: "/admin/jobs-api", authorize });
43
+ const ui = jobsUi({ api, basePath: "/admin/jobs" });
44
+
45
+ const app = new BunHttpAdapter();
46
+ app.use(api.basePath, api.router);
47
+ app.use(ui.basePath, ui.router);
48
+ await app.listen(3000);
49
+ ```
50
+
51
+ [guides/jobs.md](guides/jobs.md) has every option, the screens, the
52
+ permissions each element needs and the security notes.
53
+
54
+ ## Quick start: the docs viewer
55
+
56
+ ```ts
57
+ import { BunRouter } from "@kingsleyweb/bun-common";
58
+ import { docsUi } from "@kingsleyweb/bun-ui/docs";
59
+
60
+ const app = new BunRouter();
61
+ const ui = docsUi({ document: "/api/openapi.json", basePath: "/docs" });
62
+ app.use(ui.basePath, ui.router);
63
+ ```
64
+
65
+ bun-common's `docsRouter({ viewer: { openapi: "self-hosted" } })` serves the
66
+ same viewer from your own origin once `@kingsleyweb/bun-ui` is installed.
67
+ [guides/docs.md](guides/docs.md) has every option and the embedding guide.
68
+
69
+ ## Layout
70
+
71
+ The package is layered: `lib/<ui>/` is a UI's server (and `lib/<ui>/browser/`
72
+ its browser-safe half), `app/<ui>/` its React app, `__tests__/<ui>/` its tests
73
+ and `dist/<ui>/` its prebuilt bundle. `lib/kit/` holds what the UIs share; it
74
+ never imports a domain package.
75
+
76
+ ## Building
77
+
78
+ `bun scripts/build.ts` writes each UI's bundle into `dist/<ui>/` and
79
+ `bun run build:types` the declarations into `dts/`; `prepack` runs both, so a
80
+ pack publishes both. Both directories are gitignored. In the repo, a UI with
81
+ no `dist/<ui>/` builds in memory on its first request.
@@ -0,0 +1,72 @@
1
+ import { QueryClientProvider } from "@tanstack/react-query";
2
+ import type { QueryClient } from "@tanstack/react-query";
3
+ import type { FetchLike } from "./api/client";
4
+ import type { DocsUiConfig } from "../../lib/docs/browser/config.ts";
5
+ import { useEffect, useMemo } from "react";
6
+ import { DocsFetchContext, DocsUiConfigContext } from "./context";
7
+ import { RouterProvider } from "./router";
8
+ import {
9
+ createUnauthorizedRedirect,
10
+ watchAuthFailures,
11
+ } from "../kit/unauthorized";
12
+ import { DocsViewer } from "./screens/docs/viewer";
13
+ import { findSearchBox, shortcutFor } from "./shortcuts";
14
+
15
+ /** Props of {@link App}. */
16
+ export interface AppProps {
17
+ /** The injected configuration. */
18
+ config: DocsUiConfig;
19
+ /** The query client (tests pass one without retries). */
20
+ queryClient: QueryClient;
21
+ /** The `fetch` every request goes through (tests inject one). Defaults to `globalThis.fetch`. */
22
+ fetch?: FetchLike;
23
+ }
24
+
25
+ /** The `/` shortcut: focus the screen's search box. */
26
+ function useSearchShortcut() {
27
+ useEffect(() => {
28
+ const onKeyDown = (event: KeyboardEvent) => {
29
+ if (shortcutFor(event) === "focus-search") {
30
+ const box = findSearchBox(document);
31
+ if (box) {
32
+ event.preventDefault();
33
+ box.focus();
34
+ }
35
+ }
36
+ };
37
+ window.addEventListener("keydown", onKeyDown);
38
+ return () => window.removeEventListener("keydown", onKeyDown);
39
+ }, []);
40
+ }
41
+
42
+ /**
43
+ * The whole viewer: providers, routing, the document. With
44
+ * `config.unauthorized`, a failed document read whose status it lists sends
45
+ * the browser to the sign-in target, once (`unauthorized.ts`). Only the
46
+ * viewer's own queries reach the cache: the try-it panel's calls do not, so
47
+ * the documented API's 401 to a tried request never redirects.
48
+ */
49
+ export function App({ config, queryClient, fetch }: AppProps) {
50
+ useSearchShortcut();
51
+ const unauthorized = useMemo(
52
+ () => createUnauthorizedRedirect(config.unauthorized, config.basePath),
53
+ [config.unauthorized, config.basePath],
54
+ );
55
+ useEffect(
56
+ () => watchAuthFailures(queryClient, unauthorized),
57
+ [queryClient, unauthorized],
58
+ );
59
+ return (
60
+ <DocsUiConfigContext value={config}>
61
+ <DocsFetchContext value={fetch ?? null}>
62
+ <QueryClientProvider client={queryClient}>
63
+ <RouterProvider basePath={config.basePath}>
64
+ <div className="docs-viewer">
65
+ <DocsViewer />
66
+ </div>
67
+ </RouterProvider>
68
+ </QueryClientProvider>
69
+ </DocsFetchContext>
70
+ </DocsUiConfigContext>
71
+ );
72
+ }
@@ -0,0 +1,303 @@
1
+ import { JSON_METHODS, MUTATING_METHODS } from "./contract";
2
+ import { ApiError, isProblem } from "./errors";
3
+
4
+ /**
5
+ * The viewer's HTTP client: it fetches the document, and sends the try-it
6
+ * panel's requests. Same-origin credentials, `Accept: application/json`
7
+ * always, `Content-Type: application/json` on POST/PUT/PATCH (bodiless ones
8
+ * included), and the configured CSRF header on every mutation.
9
+ */
10
+
11
+ /** An HTTP method the client sends. */
12
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
13
+
14
+ /** A scalar query value. */
15
+ export type QueryScalar = string | number | boolean;
16
+
17
+ /**
18
+ * A query-string value: arrays become repeated keys (`state=a&state=b`), an
19
+ * empty array becomes `key=`, and `undefined`/`null` are left out.
20
+ */
21
+ export type QueryValue =
22
+ QueryScalar | readonly QueryScalar[] | null | undefined;
23
+
24
+ /** The query of one request. */
25
+ export type QueryParams = Readonly<Record<string, QueryValue>>;
26
+
27
+ /** Options of one {@link DocsClient.request}. */
28
+ export interface RequestOptions {
29
+ /** Query parameters; see {@link QueryValue}. */
30
+ query?: QueryParams;
31
+ /** A JSON body. `undefined` sends none (the `Content-Type` still goes on POST/PUT/PATCH). */
32
+ body?: unknown;
33
+ /** Aborts the request (TanStack Query passes one). An abort rejects with the fetch's own `AbortError`, never an {@link ApiError}. */
34
+ signal?: AbortSignal;
35
+ }
36
+
37
+ /** One response as {@link DocsClient.requestRaw} resolves it. */
38
+ export interface RawResponse {
39
+ /** Whether the status is 2xx. */
40
+ ok: boolean;
41
+ /** The HTTP status received. */
42
+ status: number;
43
+ /** The status text received (often `""` over HTTP/2). */
44
+ statusText: string;
45
+ /** The response headers the caller can see, lower-cased names in alphabetical order. A browser hides `Set-Cookie`, and cross-origin ones not in `Access-Control-Expose-Headers`. */
46
+ headers: Record<string, string>;
47
+ /** The body: parsed JSON when the `Content-Type` is JSON and it parses, the text otherwise, `undefined` when empty. */
48
+ body: unknown;
49
+ /** For a non-2xx status, the {@link ApiError} `request` would have thrown (a problem's fields verbatim); `undefined` on success. */
50
+ error: ApiError | undefined;
51
+ /** Milliseconds from sending to the body having been read. */
52
+ durationMs: number;
53
+ }
54
+
55
+ /** The `fetch` the client calls; injectable for tests. */
56
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
57
+
58
+ /** A client for the one server the viewer talks to. */
59
+ export interface DocsClient {
60
+ /** The base URL requests are prefixed with. */
61
+ readonly base: string;
62
+ /** Sends one request; resolves the parsed JSON (or `undefined` for a 204), rejects with an {@link ApiError}. */
63
+ request: <T>(
64
+ method: HttpMethod,
65
+ path: string,
66
+ options?: RequestOptions,
67
+ ) => Promise<T>;
68
+ /**
69
+ * Sends one request like {@link DocsClient.request}, with the same headers,
70
+ * credentials and problem+json parsing, and resolves the whole response
71
+ * instead of only its body. An error status **resolves** (with `ok: false`
72
+ * and the {@link ApiError} `request` would have thrown in `error`); only a
73
+ * network failure rejects (an {@link ApiError} of kind `network`), and an
74
+ * abort rejects with the fetch's own `AbortError`.
75
+ */
76
+ requestRaw: (
77
+ method: HttpMethod,
78
+ path: string,
79
+ options?: RequestOptions,
80
+ ) => Promise<RawResponse>;
81
+ }
82
+
83
+ /** The value sent in the CSRF header. An API accepts any non-empty value. */
84
+ export const CSRF_HEADER_VALUE = "1";
85
+
86
+ /** Serialises a query: repeated keys for arrays, `undefined`/`null` skipped. Returns `""` or `"?…"`. */
87
+ export function serializeQuery(query: QueryParams | undefined): string {
88
+ if (!query) {
89
+ return "";
90
+ }
91
+ const params = new URLSearchParams();
92
+ for (const [key, value] of Object.entries(query)) {
93
+ if (value === undefined || value === null) {
94
+ continue;
95
+ }
96
+ if (Array.isArray(value)) {
97
+ const items = value as readonly QueryScalar[];
98
+ if (items.length === 0) {
99
+ params.append(key, "");
100
+ }
101
+ for (const item of items) {
102
+ params.append(key, String(item));
103
+ }
104
+ continue;
105
+ }
106
+ params.append(key, String(value));
107
+ }
108
+ const text = params.toString();
109
+ return text ? `?${text}` : "";
110
+ }
111
+
112
+ /** Percent-encodes one path segment (`/` becomes `%2F`). */
113
+ export function segment(value: string): string {
114
+ return encodeURIComponent(value);
115
+ }
116
+
117
+ /** Whether a `Content-Type` names JSON (`application/json` or any `+json`). */
118
+ function isJsonType(contentType: string): boolean {
119
+ const media = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
120
+ return media === "application/json" || media.endsWith("+json");
121
+ }
122
+
123
+ /** Whether an error is the fetch's abort, which must propagate untouched. */
124
+ function isAbort(error: unknown, signal: AbortSignal | undefined): boolean {
125
+ return (
126
+ signal?.aborted === true ||
127
+ (error instanceof Error && error.name === "AbortError") ||
128
+ (typeof DOMException !== "undefined" &&
129
+ error instanceof DOMException &&
130
+ error.name === "AbortError")
131
+ );
132
+ }
133
+
134
+ /** Parses JSON text, or returns `undefined` when it is not JSON. */
135
+ function tryParse(text: string): { value: unknown } | undefined {
136
+ try {
137
+ return { value: JSON.parse(text) };
138
+ } catch {
139
+ return undefined;
140
+ }
141
+ }
142
+
143
+ /** Turns an error response into an {@link ApiError}. */
144
+ function errorFromResponse(response: Response, text: string, path: string) {
145
+ const parsed = isJsonType(response.headers.get("content-type") ?? "")
146
+ ? tryParse(text)
147
+ : undefined;
148
+ if (parsed && isProblem(parsed.value)) {
149
+ return ApiError.fromProblem({ ...parsed.value, status: response.status });
150
+ }
151
+ const snippet = text.trim().slice(0, 300);
152
+ return new ApiError({
153
+ kind: "http",
154
+ status: response.status,
155
+ code: `HTTP_${response.status}`,
156
+ title: response.statusText || `Request failed (${response.status})`,
157
+ detail: snippet || undefined,
158
+ instance: path,
159
+ });
160
+ }
161
+
162
+ /** Options of {@link createDocsClient}. */
163
+ export interface DocsClientOptions {
164
+ /** The base URL requests are prefixed with (the document's resolved server, or the document's own URL for the document fetch). */
165
+ base: string;
166
+ /** The CSRF header mutations carry, or `null`. */
167
+ csrfHeader: string | null;
168
+ /** The `fetch` to use. Defaults to `globalThis.fetch`, looked up per call so a test can replace it. */
169
+ fetch?: FetchLike;
170
+ }
171
+
172
+ /**
173
+ * Creates the client for one base URL. A relative `path` joins the base; a
174
+ * `path` that is itself an absolute URL is sent as it is (a document URL the
175
+ * operator configured).
176
+ */
177
+ export function createDocsClient(options: DocsClientOptions): DocsClient {
178
+ const base = options.base.replace(/\/+$/, "");
179
+
180
+ /** Builds the URL and `RequestInit` of one request: every header rule lives here. */
181
+ function prepare(
182
+ method: HttpMethod,
183
+ path: string,
184
+ { query, body, signal }: RequestOptions,
185
+ ): { url: string; init: RequestInit } {
186
+ const target = /^https?:\/\//.test(path) ? path : `${base}${path}`;
187
+ const url = `${target}${serializeQuery(query)}`;
188
+ const headers: Record<string, string> = { Accept: "application/json" };
189
+ if (JSON_METHODS.has(method)) {
190
+ headers["Content-Type"] = "application/json";
191
+ }
192
+ if (options.csrfHeader && MUTATING_METHODS.has(method)) {
193
+ headers[options.csrfHeader] = CSRF_HEADER_VALUE;
194
+ }
195
+ const init: RequestInit = {
196
+ method,
197
+ headers,
198
+ credentials: "same-origin",
199
+ };
200
+ if (body !== undefined) {
201
+ init.body = JSON.stringify(body);
202
+ }
203
+ if (signal) {
204
+ init.signal = signal;
205
+ }
206
+ return { url, init };
207
+ }
208
+
209
+ /** Calls `fetch`; a failure other than an abort becomes a `network` {@link ApiError}. */
210
+ async function send(
211
+ url: string,
212
+ init: RequestInit,
213
+ path: string,
214
+ ): Promise<Response> {
215
+ const fetcher = options.fetch ?? globalThis.fetch;
216
+ try {
217
+ return await fetcher(url, init);
218
+ } catch (error) {
219
+ if (isAbort(error, init.signal ?? undefined)) {
220
+ throw error;
221
+ }
222
+ throw new ApiError({
223
+ kind: "network",
224
+ status: 0,
225
+ code: "NETWORK_ERROR",
226
+ title: "The server could not be reached",
227
+ detail: error instanceof Error ? error.message : String(error),
228
+ instance: path,
229
+ cause: error,
230
+ });
231
+ }
232
+ }
233
+
234
+ async function request<T>(
235
+ method: HttpMethod,
236
+ path: string,
237
+ requestOptions: RequestOptions = {},
238
+ ): Promise<T> {
239
+ const { url, init } = prepare(method, path, requestOptions);
240
+ const response = await send(url, init, path);
241
+
242
+ if (response.status === 204 || response.status === 205) {
243
+ return undefined as T;
244
+ }
245
+ const text = await response.text();
246
+ if (!response.ok) {
247
+ throw errorFromResponse(response, text, path);
248
+ }
249
+ if (text === "") {
250
+ return undefined as T;
251
+ }
252
+ const parsed = isJsonType(response.headers.get("content-type") ?? "")
253
+ ? tryParse(text)
254
+ : undefined;
255
+ if (!parsed) {
256
+ throw new ApiError({
257
+ kind: "parse",
258
+ status: response.status,
259
+ code: "INVALID_RESPONSE",
260
+ title: "The server answered something other than JSON",
261
+ detail: text.trim().slice(0, 300) || undefined,
262
+ instance: path,
263
+ });
264
+ }
265
+ return parsed.value as T;
266
+ }
267
+
268
+ async function requestRaw(
269
+ method: HttpMethod,
270
+ path: string,
271
+ requestOptions: RequestOptions = {},
272
+ ): Promise<RawResponse> {
273
+ const { url, init } = prepare(method, path, requestOptions);
274
+ const started = performance.now();
275
+ const response = await send(url, init, path);
276
+ const text =
277
+ response.status === 204 || response.status === 205
278
+ ? ""
279
+ : await response.text();
280
+ const durationMs = performance.now() - started;
281
+ const parsed =
282
+ text !== "" && isJsonType(response.headers.get("content-type") ?? "")
283
+ ? tryParse(text)
284
+ : undefined;
285
+ const headers: Record<string, string> = {};
286
+ for (const [name, value] of [...response.headers.entries()].sort(
287
+ ([a, b]) => (a < b ? -1 : a > b ? 1 : 0),
288
+ )) {
289
+ headers[name.toLowerCase()] = value;
290
+ }
291
+ return {
292
+ ok: response.ok,
293
+ status: response.status,
294
+ statusText: response.statusText,
295
+ headers,
296
+ body: parsed ? parsed.value : text === "" ? undefined : text,
297
+ error: response.ok ? undefined : errorFromResponse(response, text, path),
298
+ durationMs,
299
+ };
300
+ }
301
+
302
+ return { base, request, requestRaw };
303
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Constants of the viewer's own HTTP layer: which methods mutate (a try-it
3
+ * confirms them), and which must carry `Content-Type: application/json` even
4
+ * when bodiless. Generic HTTP, shared with no one: the documented API's own
5
+ * constants are its own.
6
+ */
7
+
8
+ /** HTTP methods treated as mutations: every one but `GET`/`HEAD`/`OPTIONS`. */
9
+ export const MUTATING_METHODS: ReadonlySet<string> = new Set([
10
+ "POST",
11
+ "PUT",
12
+ "PATCH",
13
+ "DELETE",
14
+ ]);
15
+
16
+ /** Methods that carry `Content-Type: application/json` even when bodiless. */
17
+ export const JSON_METHODS: ReadonlySet<string> = new Set([
18
+ "POST",
19
+ "PUT",
20
+ "PATCH",
21
+ ]);
22
+
23
+ /** The last instant a `Date` holds. */
24
+ export const MAX_DATE_MS = 8_640_000_000_000_000;
@@ -0,0 +1,38 @@
1
+ import type { DocsClient } from "./client";
2
+
3
+ /**
4
+ * A spec document as JSON. The viewers narrow the parts they render with
5
+ * their own models.
6
+ */
7
+ export type SpecDocument = Record<string, unknown>;
8
+
9
+ /** Query keys of the document read, under `["docs"]`. */
10
+ export const docsKeys = {
11
+ /** Every docs read. */
12
+ all: ["docs"] as const,
13
+ /** The one document at a URL. */
14
+ document: (url: string) => ["docs", "document", url] as const,
15
+ } satisfies Record<string, unknown>;
16
+
17
+ /** The document's kind, read from its own marker field. */
18
+ export type DocumentKind = "openapi" | "asyncapi";
19
+
20
+ /** What the document is: OpenAPI 3.1 when it carries `openapi`, AsyncAPI 3.0 for `asyncapi`, `null` when neither. */
21
+ export function documentKind(doc: SpecDocument): DocumentKind | null {
22
+ if (typeof doc.asyncapi === "string") {
23
+ return "asyncapi";
24
+ }
25
+ if (typeof doc.openapi === "string") {
26
+ return "openapi";
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /** `GET <documentUrl>`: the one document the viewer shows. */
32
+ export function getDocument(
33
+ client: DocsClient,
34
+ url: string,
35
+ signal?: AbortSignal,
36
+ ): Promise<SpecDocument> {
37
+ return client.request<SpecDocument>("GET", url, { signal });
38
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Every failed request the viewer makes, whatever went wrong. An RFC 9457
3
+ * problem response keeps its fields verbatim; anything else is mapped onto
4
+ * the same shape so a screen has one thing to render.
5
+ */
6
+
7
+ /** A validation issue inside a problem. */
8
+ export interface ProblemIssueDto {
9
+ /** Which part of the request failed. */
10
+ target: string;
11
+ /** Dotted path within the target, or `""` for the target itself. */
12
+ path: string;
13
+ /** Human-readable description. */
14
+ message: string;
15
+ }
16
+
17
+ /** An `application/problem+json` body, as far as the viewer reads it. */
18
+ export interface ProblemDto {
19
+ /** The HTTP status. */
20
+ status: number;
21
+ /** The machine code. */
22
+ code: string;
23
+ /** The short title. */
24
+ title: string;
25
+ /** The human detail, when there is one. */
26
+ detail?: string;
27
+ /** The problem's `type` URI, when there is one. */
28
+ type?: string;
29
+ /** Validation issues. */
30
+ issues?: ProblemIssueDto[];
31
+ /** Safe, whitelisted context. */
32
+ context?: Record<string, unknown>;
33
+ /** The request path that failed. */
34
+ instance?: string;
35
+ }
36
+
37
+ /** Where an {@link ApiError} came from. */
38
+ export type ApiErrorKind =
39
+ /** The API answered with an `application/problem+json` body. */
40
+ | "problem"
41
+ /** The API (or something in front of it) answered an error that was not problem+json. */
42
+ | "http"
43
+ /** No response arrived: offline, DNS, CORS, connection reset. */
44
+ | "network"
45
+ /** A 2xx whose body was not the JSON it should have been. */
46
+ | "parse";
47
+
48
+ /** Fields an {@link ApiError} is built from. */
49
+ export interface ApiErrorInit {
50
+ /** Where the error came from. */
51
+ kind: ApiErrorKind;
52
+ /** HTTP status, or `0` for a network error. */
53
+ status: number;
54
+ /** Machine code: the problem's `code`, or `NETWORK_ERROR` / `HTTP_<status>` / `INVALID_RESPONSE`. */
55
+ code: string;
56
+ /** Short title. */
57
+ title: string;
58
+ /** Human detail, when there is one. */
59
+ detail?: string;
60
+ /** The problem's `type` URI, when it was a problem. */
61
+ type?: string;
62
+ /** Validation issues. */
63
+ issues?: ProblemIssueDto[];
64
+ /** The problem's whitelisted context. */
65
+ context?: Record<string, unknown>;
66
+ /** The request path that failed. */
67
+ instance?: string;
68
+ /** The underlying error (network/parse failures). */
69
+ cause?: unknown;
70
+ }
71
+
72
+ /** One failed request. */
73
+ export class ApiError extends Error {
74
+ /** Where the error came from. */
75
+ readonly kind: ApiErrorKind;
76
+ /** HTTP status, or `0` for a network error. */
77
+ readonly status: number;
78
+ /** Machine code, e.g. `HTTP_404`. */
79
+ readonly code: string;
80
+ /** Short, stable title. */
81
+ readonly title: string;
82
+ /** Human detail for this occurrence. */
83
+ readonly detail: string | undefined;
84
+ /** The problem's `type` URI, when it was a problem. */
85
+ readonly type: string | undefined;
86
+ /** Validation issues. */
87
+ readonly issues: ProblemIssueDto[];
88
+ /** Whitelisted context. */
89
+ readonly context: Record<string, unknown>;
90
+ /** The request path that failed. */
91
+ readonly instance: string | undefined;
92
+
93
+ constructor(init: ApiErrorInit) {
94
+ super(init.detail ?? init.title, { cause: init.cause });
95
+ this.name = "ApiError";
96
+ this.kind = init.kind;
97
+ this.status = init.status;
98
+ this.code = init.code;
99
+ this.title = init.title;
100
+ this.detail = init.detail;
101
+ this.type = init.type;
102
+ this.issues = init.issues ?? [];
103
+ this.context = init.context ?? {};
104
+ this.instance = init.instance;
105
+ }
106
+
107
+ /** Builds one from a problem body. */
108
+ static fromProblem(problem: ProblemDto): ApiError {
109
+ return new ApiError({
110
+ kind: "problem",
111
+ status: problem.status,
112
+ code: problem.code,
113
+ title: problem.title,
114
+ detail: problem.detail,
115
+ type: problem.type,
116
+ issues: problem.issues,
117
+ context: problem.context,
118
+ instance: problem.instance,
119
+ });
120
+ }
121
+
122
+ /** Whether the caller is not signed in or not allowed (401/403). */
123
+ get isAuth(): boolean {
124
+ return this.status === 401 || this.status === 403;
125
+ }
126
+ }
127
+
128
+ /** Whether a value is an {@link ApiError}. */
129
+ export function isApiError(value: unknown): value is ApiError {
130
+ return value instanceof ApiError;
131
+ }
132
+
133
+ /** Whether a parsed body has the fields a problem must carry. */
134
+ export function isProblem(value: unknown): value is ProblemDto {
135
+ if (typeof value !== "object" || value === null) {
136
+ return false;
137
+ }
138
+ const record = value as Record<string, unknown>;
139
+ return (
140
+ typeof record.status === "number" &&
141
+ typeof record.code === "string" &&
142
+ typeof record.title === "string"
143
+ );
144
+ }