@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,205 @@
1
+ import type { UiAssets } from "./assets";
2
+ /**
3
+ * The HTML shell: the one page every client route answers with, and the
4
+ * Content-Security-Policy it is served under.
5
+ *
6
+ * Everything in it is fixed per mount except the nonce, which is fresh per
7
+ * request. The configuration travels as a nonce'd
8
+ * `<script type="application/json">`, escaped for a script context, so a title
9
+ * containing `</script>` cannot end the element early.
10
+ */
11
+ import type { JobsUiConfig, UiTheme } from "./browser/config";
12
+ import { Buffer } from "node:buffer";
13
+ import { JOBS_UI_CONFIG_ELEMENT_ID } from "./browser/config";
14
+
15
+ /** A fresh CSP nonce: 128 random bits, base64. */
16
+ export function createNonce(): string {
17
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString(
18
+ "base64",
19
+ );
20
+ }
21
+
22
+ /** Escapes text for an HTML text node or a quoted attribute value. */
23
+ export function escapeHtml(value: string): string {
24
+ return value
25
+ .replaceAll("&", "&amp;")
26
+ .replaceAll("<", "&lt;")
27
+ .replaceAll(">", "&gt;")
28
+ .replaceAll('"', "&quot;")
29
+ .replaceAll("'", "&#39;");
30
+ }
31
+
32
+ /**
33
+ * Serialises a value as JSON that is safe inside a `<script>` element: `<`,
34
+ * `>` and `&` become `<`/`>`/`&` (so neither `</script>` nor
35
+ * `<!--` can appear), and U+2028/U+2029 are escaped too. The result is still
36
+ * valid JSON that parses back to the same value.
37
+ */
38
+ export function jsonForScript(value: unknown): string {
39
+ return JSON.stringify(value)
40
+ .replaceAll("<", "\\u003c")
41
+ .replaceAll(">", "\\u003e")
42
+ .replaceAll("&", "\\u0026")
43
+ .replaceAll(/\u2028/g, "\\u2028")
44
+ .replaceAll(/\u2029/g, "\\u2029");
45
+ }
46
+
47
+ /** The `color-scheme` meta value for a theme, so the first paint matches it. */
48
+ function colorScheme(theme: UiTheme): string {
49
+ return theme === "system" ? "light dark" : theme;
50
+ }
51
+
52
+ /** Inputs to {@link cspHeader}. */
53
+ export interface CspOptions {
54
+ /** This request's nonce. */
55
+ nonce: string;
56
+ /**
57
+ * `connect-src` sources after `'self'`, usually from {@link connectSources}.
58
+ * A source that is not a single CSP token (whitespace, `;` or `,`) is
59
+ * dropped rather than written into the header. Defaults to none.
60
+ */
61
+ connectSrc?: readonly string[];
62
+ }
63
+
64
+ /** One CSP source expression: no whitespace, `;` or `,` that could end it early. */
65
+ const CSP_TOKEN = /^[^\s;,]+$/;
66
+
67
+ /**
68
+ * The shell's Content-Security-Policy. Scripts only from this origin and
69
+ * carrying the nonce; styles only from files (no inline `<style>`, no `style`
70
+ * attributes in markup — setting `element.style` from script is still fine);
71
+ * connections to `'self'` plus `connectSrc`.
72
+ */
73
+ export function cspHeader(options: CspOptions): string {
74
+ const connect = [
75
+ "'self'",
76
+ ...(options.connectSrc ?? []).filter((source) => CSP_TOKEN.test(source)),
77
+ ];
78
+ return [
79
+ "default-src 'self'",
80
+ `script-src 'self' 'nonce-${options.nonce}'`,
81
+ "style-src 'self'",
82
+ "img-src 'self' data:",
83
+ `connect-src ${connect.join(" ")}`,
84
+ "base-uri 'none'",
85
+ "frame-ancestors 'none'",
86
+ "form-action 'self'",
87
+ ].join("; ");
88
+ }
89
+
90
+ /**
91
+ * A `Host` value usable as a CSP host-source: a DNS name or IPv4 address of
92
+ * letters, digits, `-` and `.` (no empty label, no trailing dot), and an
93
+ * optional port. IPv6 literals are left out — CSP's host-source grammar has
94
+ * no form for them, and `'self'` still covers the page in current browsers.
95
+ */
96
+ const HOST =
97
+ /^([a-z\d](?:[a-z\d-]*[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]*[a-z\d])?)*)(?::(\d{1,5}))?$/i;
98
+
99
+ /** Inputs to {@link connectSources}. */
100
+ export interface ConnectSourcesOptions {
101
+ /**
102
+ * The request's `Host` (`host[:port]`), i.e. the page's origin as the
103
+ * browser loaded it. Attacker-controlled on a misrouted request, so anything
104
+ * outside the `host[:port]` grammar is ignored, never copied into the header.
105
+ */
106
+ host: string | undefined;
107
+ /** Whether the page is `https`: its socket sources are then `wss:`, else `ws:`. */
108
+ secure: boolean;
109
+ /**
110
+ * The API's origin when it is elsewhere (`"https://api.example"`); added in
111
+ * its `http(s)` and `ws(s)` forms. Omitted for a same-origin API.
112
+ */
113
+ apiOrigin?: string;
114
+ /**
115
+ * A dedicated socket port (`config.websocket.port`); adds that port's
116
+ * `ws(s)` origin on the page's hostname. Omitted when the socket shares the
117
+ * page's port.
118
+ */
119
+ websocketPort?: number | null;
120
+ }
121
+
122
+ /**
123
+ * The `connect-src` sources after `'self'`: the `ws(s)` form of the page's own
124
+ * origin (older WebKit does not let `'self'` match `ws:`/`wss:`), a dedicated
125
+ * socket port on the same hostname, and a cross-origin API in both forms.
126
+ * Never a bare `ws:` or `wss:`, which would allow a socket to any host.
127
+ */
128
+ export function connectSources(options: ConnectSourcesOptions): string[] {
129
+ const sources: string[] = [];
130
+ const socket = options.secure ? "wss" : "ws";
131
+ const match = HOST.exec(options.host ?? "");
132
+ const port = match?.[2] === undefined ? undefined : Number(match[2]);
133
+ if (match && (port === undefined || (port >= 1 && port <= 65535))) {
134
+ const hostname = match[1].toLowerCase();
135
+ sources.push(
136
+ `${socket}://${hostname}${port === undefined ? "" : `:${port}`}`,
137
+ );
138
+ const dedicated = options.websocketPort;
139
+ if (
140
+ typeof dedicated === "number" &&
141
+ Number.isInteger(dedicated) &&
142
+ dedicated >= 1 &&
143
+ dedicated <= 65535
144
+ ) {
145
+ sources.push(`${socket}://${hostname}:${dedicated}`);
146
+ }
147
+ }
148
+ if (options.apiOrigin !== undefined) {
149
+ const api = new URL(options.apiOrigin);
150
+ sources.push(
151
+ api.origin,
152
+ `${api.protocol === "https:" ? "wss" : "ws"}://${api.host}`,
153
+ );
154
+ }
155
+ return sources;
156
+ }
157
+
158
+ /** A renderer for one mount: fixed parts computed once, the nonce per call. */
159
+ export type ShellRenderer = (nonce: string) => string;
160
+
161
+ /**
162
+ * Prepares the shell for a mount and a bundle. The returned function only
163
+ * splices a nonce in.
164
+ */
165
+ export function createShellRenderer(
166
+ config: JobsUiConfig,
167
+ assets: UiAssets,
168
+ ): ShellRenderer {
169
+ const href = (name: string): string =>
170
+ escapeHtml(`${config.assetsPath}/${encodeURIComponent(name)}`);
171
+ const integrity = (name: string): string =>
172
+ escapeHtml(assets.files.get(name)?.integrity ?? "");
173
+ const stylesheets = assets.entry.css
174
+ .map(
175
+ (name) =>
176
+ `<link rel="stylesheet" href="${href(name)}" integrity="${integrity(name)}">`,
177
+ )
178
+ .join("\n ");
179
+ const title = escapeHtml(config.title);
180
+ const json = jsonForScript(config);
181
+ const js = assets.entry.js;
182
+ const scheme = colorScheme(config.theme);
183
+
184
+ return (nonce) => {
185
+ const n = escapeHtml(nonce);
186
+ return `<!doctype html>
187
+ <html lang="en">
188
+ <head>
189
+ <meta charset="utf-8">
190
+ <meta name="viewport" content="width=device-width, initial-scale=1">
191
+ <meta name="color-scheme" content="${scheme}">
192
+ <meta property="csp-nonce" nonce="${n}">
193
+ <title>${title}</title>
194
+ <link rel="icon" href="data:,">
195
+ ${stylesheets}
196
+ <script type="application/json" id="${JOBS_UI_CONFIG_ELEMENT_ID}" nonce="${n}">${json}</script>
197
+ <script type="module" src="${href(js)}" integrity="${integrity(js)}" nonce="${n}"></script>
198
+ </head>
199
+ <body>
200
+ <div id="root"></div>
201
+ </body>
202
+ </html>
203
+ `;
204
+ };
205
+ }
@@ -0,0 +1,162 @@
1
+ import type {
2
+ BunRequest,
3
+ BunRouter,
4
+ LoggerLike,
5
+ RouterHandler,
6
+ } from "@kingsleyweb/bun-common";
7
+ import type { JobsApi, JobsApiAuthorizeResult } from "@kingsleyweb/bun-jobs";
8
+ import type { UnauthorizedOptions } from "../kit/browser/unauthorized";
9
+ import type { JobsUiConfig, JobsUiSections, UiTheme } from "./browser/config";
10
+
11
+ /** What {@link JobsUiOptions.authorize} is told about the request being decided. */
12
+ export interface JobsUiAuthorizeContext {
13
+ /** `true` for a bundle file under `<basePath>/assets/`, `false` for the HTML shell. */
14
+ asset: boolean;
15
+ /** The request's full path, e.g. `"/jobs/queues/mail"` (no query string). */
16
+ path: string;
17
+ }
18
+
19
+ /**
20
+ * What `authorize` answers — the same shape as the API's: `true`/`false`, or
21
+ * `{ allow: true }` / `{ allow: false, status?: 401 | 403, reason? }`.
22
+ * A denial is a 401 only when `status` is exactly `401`; any other `status`
23
+ * (a `429`, a `500`, a string) is a 403, as the API does — fail closed. Rate
24
+ * limiting belongs in `middleware`, which can answer 429 itself. Anything
25
+ * that is not one of these shapes denies with 403.
26
+ */
27
+ export type JobsUiAuthorizeResult = JobsApiAuthorizeResult;
28
+
29
+ /**
30
+ * Decides whether a request may load the UI. May be async; a throw (or a
31
+ * rejection) is a 500. A denial is 401 for `status: 401` and 403 for every
32
+ * other status — see {@link JobsUiAuthorizeResult}.
33
+ */
34
+ export type JobsUiAuthorize = (
35
+ req: BunRequest,
36
+ context: JobsUiAuthorizeContext,
37
+ ) => JobsUiAuthorizeResult | Promise<JobsUiAuthorizeResult>;
38
+
39
+ /**
40
+ * Where to send the browser when an authorization check fails, instead of
41
+ * leaving it on a page whose screens report the failure. See
42
+ * {@link JobsUiBaseOptions.unauthorized}; the fields are described on
43
+ * `UnauthorizedOptions`, which `docsUi()` takes too.
44
+ */
45
+ export type JobsUiUnauthorizedOptions = UnauthorizedOptions;
46
+
47
+ /** Options shared by both ways of pointing the UI at its API. */
48
+ export interface JobsUiBaseOptions {
49
+ /**
50
+ * Header every mutation must carry, as the API's `csrf.header`, or `false`
51
+ * for none (`config.csrfHeader` is then `null`). Unset: what `api` reports
52
+ * once the API exposes it (`api.info.csrf.header`, feature-detected), else
53
+ * none (`config.csrfHeader: null`). Until the API reports it, set it by hand
54
+ * to match the API's `csrf.header`.
55
+ */
56
+ csrfHeader?: string | false;
57
+ /**
58
+ * Absolute path the UI is mounted at: no trailing slash, not `"/"`, segments
59
+ * of letters, digits, `_`, `.`, `~` and `-`. Must not equal or sit under
60
+ * the API's `basePath`. Defaults to `"/jobs"`.
61
+ */
62
+ basePath?: string;
63
+ /** Page title and brand text. Defaults to `"Jobs"`. */
64
+ title?: string;
65
+ /**
66
+ * Sections to enable. Each defaults to `true`; switching both off is a
67
+ * `ConfigError`.
68
+ */
69
+ sections?: Partial<JobsUiSections>;
70
+ /**
71
+ * Guards the shell and the bundle — **not** the data, which the API's own
72
+ * `authorize` protects. Same result shape as the API's, and fails closed
73
+ * the same way: a denial is 401 only for `status: 401` and 403 for any
74
+ * other status (`429` included), an unrecognised answer is 403 and a throw
75
+ * is 500. Omitted: everyone may load the page.
76
+ */
77
+ authorize?: JobsUiAuthorize;
78
+ /**
79
+ * Sends the browser to a sign-in (or any other) page when the caller is not
80
+ * authorized, instead of showing the failure on every screen. Off by
81
+ * default (today's behaviour: the shell answers a problem, screens show
82
+ * their messages).
83
+ *
84
+ * With it set, for a status in `statuses` (`[401]` by default):
85
+ *
86
+ * - **The shell:** a browser navigation (`GET`/`HEAD` of a page, not a
87
+ * bundle file) that `authorize` denies answers `303 See Other` to `redirectTo`.
88
+ * A bundle file, and a request that is not a navigation (a script's
89
+ * `fetch`, an `Accept` without `text/html`), keep the problem answer.
90
+ * - **The app:** the first API call (any query or mutation) or live-events
91
+ * message that fails with such a status sends the browser to `redirectTo` with
92
+ * `location.assign`, once per page load.
93
+ *
94
+ * A denial `middleware` answers itself is not redirected: only
95
+ * `authorize`'s. The target is fixed here, never read from the request, so
96
+ * it cannot be an open redirect. See {@link JobsUiUnauthorizedOptions}.
97
+ */
98
+ unauthorized?: JobsUiUnauthorizedOptions;
99
+ /**
100
+ * Middleware run before every UI route, on the `GET`/`HEAD` requests the UI
101
+ * answers (session loading, a rate limiter). Runs before `authorize`.
102
+ * Defaults to none.
103
+ */
104
+ middleware?: readonly RouterHandler[];
105
+ /** Initial colour scheme. Defaults to `"system"` (follows `prefers-color-scheme`). */
106
+ theme?: UiTheme;
107
+ /**
108
+ * Where the bundle comes from. Unset (default): the prebuilt `dist/` when
109
+ * present, else an in-memory `Bun.build` on the first request. `true`:
110
+ * always build in memory. `false`: `dist/` is required — `jobsUi()` throws
111
+ * a `ConfigError` without it.
112
+ */
113
+ dev?: boolean;
114
+ /** Logger, or anything `resolveLogger` accepts. Defaults to a console logger. */
115
+ logger?: LoggerLike;
116
+ }
117
+
118
+ /** `jobsUi()` beside a `createJobsApi` in the same process. */
119
+ export interface JobsUiApiOptions extends JobsUiBaseOptions {
120
+ /**
121
+ * The API this UI manages. Its `basePath`, WebSocket path/port and docs
122
+ * paths are read from it.
123
+ */
124
+ api: JobsApi;
125
+ /** Not with `api`. */
126
+ apiUrl?: never;
127
+ }
128
+
129
+ /** `jobsUi()` for an API in another process or on another origin. */
130
+ export interface JobsUiUrlOptions extends JobsUiBaseOptions {
131
+ /**
132
+ * Base URL of the API, no trailing slash needed: a same-origin absolute
133
+ * path (`"/jobs-api"`) or an `http(s)://` URL. Either way the path follows
134
+ * `basePath`'s segment rules (checked on the string as written, so `..`, a
135
+ * space or `%2F` is refused rather than normalised away) and may not carry
136
+ * a query or a fragment; a URL may not carry credentials, and its path may
137
+ * be empty (the API at the origin root). The WebSocket and docs paths are
138
+ * then discovered by the app from `/meta` at runtime (`config.websocket`
139
+ * and `config.docs` are `null`). A URL's origin is added to the CSP's
140
+ * `connect-src` in `http(s)` and `ws(s)` form. A cross-origin API needs
141
+ * CORS, CSRF and WebSocket origins configured on its side.
142
+ */
143
+ apiUrl: string;
144
+ /** Not with `apiUrl`. */
145
+ api?: never;
146
+ }
147
+
148
+ /** Options for `jobsUi()`: exactly one of `api` and `apiUrl`. */
149
+ export type JobsUiOptions = JobsUiApiOptions | JobsUiUrlOptions;
150
+
151
+ /** A mountable UI. */
152
+ export interface JobsUi {
153
+ /**
154
+ * Serves the shell and the bundle. Mount with `use(ui.basePath, ui.router)`,
155
+ * on a bun-common or a bun-nest adapter.
156
+ */
157
+ readonly router: BunRouter;
158
+ /** The normalised `basePath`, no trailing slash. */
159
+ readonly basePath: string;
160
+ /** The configuration injected into every page, frozen. */
161
+ readonly config: Readonly<JobsUiConfig>;
162
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * What every UI of the package shares and a browser may import: no runtime
3
+ * import, no domain package. The root entry (`@kingsleyweb/bun-ui`) and each
4
+ * UI's `/<ui>/browser` entry re-export from here.
5
+ */
6
+
7
+ /** Colour scheme a UI starts in. `"system"` follows `prefers-color-scheme`. */
8
+ export type UiTheme = "system" | "light" | "dark";
9
+
10
+ /** `Cache-Control` for hashed bundle files: their name changes with their content. */
11
+ export const ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable";
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The `unauthorized` option: where to send the browser when the caller turns
3
+ * out not to be signed in. Shared by `docsUi()` and `jobsUi()`,
4
+ * on both sides of each: the server (the shell's 303) and the app (an API
5
+ * 401). Types, defaults and pure functions only, with no runtime imports: the
6
+ * apps bundle this file for the browser.
7
+ */
8
+
9
+ /** A status that can trigger the redirect: 401 (not signed in) or 403 (not allowed). */
10
+ export type UiUnauthorizedStatus = 401 | 403;
11
+
12
+ /**
13
+ * The `unauthorized` option as a host writes it: where to send the browser
14
+ * when an authorization check fails, instead of leaving it on a page that
15
+ * reports the failure.
16
+ */
17
+ export interface UnauthorizedOptions {
18
+ /**
19
+ * Where to go: a path on this host starting with one `/` (`"/login"`,
20
+ * `"/auth/sign-in?app=jobs"`), or an absolute `http:`/`https:` URL (an
21
+ * identity provider's sign-in page). Required. A path inside the UI's own
22
+ * `basePath` is a `ConfigError`: the UI would redirect to itself.
23
+ * `javascript:`, `data:`, `//host` and anything else are refused.
24
+ */
25
+ redirectTo: string;
26
+ /**
27
+ * Query parameter that carries the page the user was on, so the sign-in
28
+ * page can send them back: path, query and (in the browser) fragment,
29
+ * always a relative URL starting with one `/` (e.g.
30
+ * `/login?returnTo=%2Fdocs%2Fhttp`). `false` adds nothing. Defaults to
31
+ * `"returnTo"`. Letters, digits, `_`, `.`, `~` and `-` only. The sign-in
32
+ * page must still check the value before redirecting to it.
33
+ */
34
+ returnParam?: string | false;
35
+ /**
36
+ * Which failures redirect. Defaults to `[401]`: only "not signed in". A 403
37
+ * means signed in but not allowed, which the page explains itself; add
38
+ * `403` to redirect on that too. Must be a non-empty array of `401` and
39
+ * `403`.
40
+ */
41
+ statuses?: readonly UiUnauthorizedStatus[];
42
+ }
43
+
44
+ /** The resolved `unauthorized` option, as the browser receives it in the page's config. */
45
+ export interface UiUnauthorized {
46
+ /** The target: a path (`"/login"`) or an absolute `http(s)` URL, validated on the server. */
47
+ redirectTo: string;
48
+ /** The query parameter that carries the in-app location back, or `null` for none. */
49
+ returnParam: string | null;
50
+ /** The statuses that redirect, ascending and without repeats (`[401]` by default). */
51
+ statuses: readonly UiUnauthorizedStatus[];
52
+ }
53
+
54
+ /** `unauthorized.returnParam` when unset. */
55
+ export const DEFAULT_RETURN_PARAM = "returnTo";
56
+
57
+ /** `unauthorized.statuses` when unset: only "not signed in". */
58
+ export const DEFAULT_UNAUTHORIZED_STATUSES: readonly UiUnauthorizedStatus[] =
59
+ Object.freeze([401]);
60
+
61
+ /**
62
+ * Whether a value read back from a page's config has the shape of a
63
+ * {@link UiUnauthorized}: enough that a stale or hand-edited shell fails with
64
+ * a message instead of a broken redirect. The server validated the values.
65
+ */
66
+ export function isUiUnauthorized(value: unknown): value is UiUnauthorized {
67
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
68
+ return false;
69
+ }
70
+ const record = value as Record<string, unknown>;
71
+ return (
72
+ typeof record.redirectTo === "string" &&
73
+ (record.returnParam === null || typeof record.returnParam === "string") &&
74
+ Array.isArray(record.statuses) &&
75
+ record.statuses.every((status) => status === 401 || status === 403)
76
+ );
77
+ }
78
+
79
+ /** Any origin, used to parse a path target as a URL and print it back as a path. */
80
+ const PATH_ORIGIN = "http://unauthorized.invalid";
81
+
82
+ /**
83
+ * Makes an in-app location safe to hand back as a relative URL: it starts
84
+ * with exactly one `/` (never `//` or `/\`, which a browser reads as another
85
+ * host). Anything not starting with `/` becomes `/`.
86
+ */
87
+ export function relativeReturnPath(location: string): string {
88
+ if (!location.startsWith("/")) {
89
+ return "/";
90
+ }
91
+ return location.replace(/^[/\\]+/, "/");
92
+ }
93
+
94
+ /**
95
+ * The URL to send the browser to: `redirectTo`, with `returnParam` set to
96
+ * `returnPath` (made relative by {@link relativeReturnPath}) when both are
97
+ * given. A path target stays a path; an absolute target stays absolute. A
98
+ * `returnParam` already in `redirectTo`'s query is replaced, and its
99
+ * fragment is kept.
100
+ */
101
+ export function unauthorizedRedirectUrl(
102
+ redirect: Pick<UiUnauthorized, "redirectTo" | "returnParam">,
103
+ returnPath: string | undefined,
104
+ ): string {
105
+ if (redirect.returnParam === null || returnPath === undefined) {
106
+ return redirect.redirectTo;
107
+ }
108
+ const isPath = redirect.redirectTo.startsWith("/");
109
+ const url = isPath
110
+ ? new URL(redirect.redirectTo, PATH_ORIGIN)
111
+ : new URL(redirect.redirectTo);
112
+ url.searchParams.set(redirect.returnParam, relativeReturnPath(returnPath));
113
+ return isPath ? `${url.pathname}${url.search}${url.hash}` : url.href;
114
+ }