@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.
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/app/docs/App.tsx +72 -0
- package/app/docs/api/client.ts +303 -0
- package/app/docs/api/contract.ts +24 -0
- package/app/docs/api/docs.ts +38 -0
- package/app/docs/api/errors.ts +144 -0
- package/app/docs/boot.tsx +58 -0
- package/app/docs/components/Badge.tsx +65 -0
- package/app/docs/components/Button.tsx +37 -0
- package/app/docs/components/Card.tsx +41 -0
- package/app/docs/components/ConfirmDialog.tsx +173 -0
- package/app/docs/components/CopyButton.tsx +68 -0
- package/app/docs/components/Dialog.tsx +230 -0
- package/app/docs/components/EmptyState.tsx +34 -0
- package/app/docs/components/ErrorView.tsx +70 -0
- package/app/docs/components/Field.tsx +93 -0
- package/app/docs/components/JsonEditor.tsx +151 -0
- package/app/docs/components/JsonView.tsx +338 -0
- package/app/docs/components/KeyValue.tsx +63 -0
- package/app/docs/components/ProblemBanner.tsx +116 -0
- package/app/docs/components/ScreenErrorBoundary.tsx +141 -0
- package/app/docs/components/Spinner.tsx +33 -0
- package/app/docs/components/Table.tsx +33 -0
- package/app/docs/components/Tabs.tsx +226 -0
- package/app/docs/components/classNames.ts +4 -0
- package/app/docs/components/components.css +691 -0
- package/app/docs/components/copy.ts +89 -0
- package/app/docs/components/fieldContext.ts +29 -0
- package/app/docs/components/inputValues.ts +142 -0
- package/app/docs/components/inputs.tsx +387 -0
- package/app/docs/components/jsonParse.ts +338 -0
- package/app/docs/components/listKeys.ts +30 -0
- package/app/docs/config.ts +86 -0
- package/app/docs/context.ts +33 -0
- package/app/docs/embed.ts +74 -0
- package/app/docs/env.d.ts +2 -0
- package/app/docs/format.ts +9 -0
- package/app/docs/hooks/useUrlTab.ts +16 -0
- package/app/docs/main.tsx +11 -0
- package/app/docs/queryClient.ts +54 -0
- package/app/docs/router.tsx +194 -0
- package/app/docs/routing.ts +194 -0
- package/app/docs/screens/docs/document.ts +27 -0
- package/app/docs/screens/docs/http/OperationView.tsx +407 -0
- package/app/docs/screens/docs/http/Sidebar.tsx +190 -0
- package/app/docs/screens/docs/http/TryItPanel.tsx +603 -0
- package/app/docs/screens/docs/http/http.css +405 -0
- package/app/docs/screens/docs/http/index.tsx +395 -0
- package/app/docs/screens/docs/http/model.ts +604 -0
- package/app/docs/screens/docs/http/tryIt.ts +455 -0
- package/app/docs/screens/docs/permissions.ts +6 -0
- package/app/docs/screens/docs/schema/Prose.tsx +43 -0
- package/app/docs/screens/docs/schema/SchemaTree.tsx +319 -0
- package/app/docs/screens/docs/schema/index.ts +30 -0
- package/app/docs/screens/docs/schema/resolve.ts +466 -0
- package/app/docs/screens/docs/schema/schema.css +137 -0
- package/app/docs/screens/docs/schema/skeleton.ts +92 -0
- package/app/docs/screens/docs/viewer.tsx +154 -0
- package/app/docs/screens/docs/ws/InlineText.tsx +25 -0
- package/app/docs/screens/docs/ws/PayloadSchema.tsx +39 -0
- package/app/docs/screens/docs/ws/format.ts +82 -0
- package/app/docs/screens/docs/ws/index.tsx +286 -0
- package/app/docs/screens/docs/ws/items.tsx +786 -0
- package/app/docs/screens/docs/ws/model.ts +1178 -0
- package/app/docs/screens/docs/ws/panels.tsx +440 -0
- package/app/docs/screens/docs/ws/ws.css +231 -0
- package/app/docs/shortcuts.ts +94 -0
- package/app/docs/styles/base.css +847 -0
- package/app/docs/styles/tokens.css +143 -0
- package/app/docs/theme.ts +56 -0
- package/app/kit/unauthorized.ts +222 -0
- package/dist/docs/assets/main-0rchxtv5.js +18 -0
- package/dist/docs/assets/main-0rchxtv5.js.map +99 -0
- package/dist/docs/assets/main-cqgyvs86.css +1 -0
- package/dist/docs/manifest.json +26 -0
- package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js +6 -0
- package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js.map +11 -0
- package/dist/jobs/assets/AddFlowDialog-n7gtc84v.css +1 -0
- package/dist/jobs/assets/added-cbwsxv3e.js +4 -0
- package/dist/jobs/assets/added-cbwsxv3e.js.map +11 -0
- package/dist/jobs/assets/bodies-yyw5hmrp.js +6 -0
- package/dist/jobs/assets/bodies-yyw5hmrp.js.map +10 -0
- package/dist/jobs/assets/channel-tgkvqm8y.js +4 -0
- package/dist/jobs/assets/channel-tgkvqm8y.js.map +10 -0
- package/dist/jobs/assets/channelChecks-s4yz4yhw.js +4 -0
- package/dist/jobs/assets/channelChecks-s4yz4yhw.js.map +10 -0
- package/dist/jobs/assets/index-02427smp.css +1 -0
- package/dist/jobs/assets/index-09z2cjft.js +4 -0
- package/dist/jobs/assets/index-09z2cjft.js.map +16 -0
- package/dist/jobs/assets/index-18bn0gvy.css +1 -0
- package/dist/jobs/assets/index-47pxmyxn.js +4 -0
- package/dist/jobs/assets/index-47pxmyxn.js.map +10 -0
- package/dist/jobs/assets/index-4e55z986.js +4 -0
- package/dist/jobs/assets/index-4e55z986.js.map +11 -0
- package/dist/jobs/assets/index-5c933424.js +4 -0
- package/dist/jobs/assets/index-5c933424.js.map +18 -0
- package/dist/jobs/assets/index-5kbyqspm.css +1 -0
- package/dist/jobs/assets/index-5ng37hk6.css +1 -0
- package/dist/jobs/assets/index-5w8w7fvc.css +1 -0
- package/dist/jobs/assets/index-71qs5qyx.css +1 -0
- package/dist/jobs/assets/index-btpg4r8b.css +1 -0
- package/dist/jobs/assets/index-d5r5n0mm.js +4 -0
- package/dist/jobs/assets/index-d5r5n0mm.js.map +23 -0
- package/dist/jobs/assets/index-e0yy2skq.css +1 -0
- package/dist/jobs/assets/index-hs60b25v.js +10 -0
- package/dist/jobs/assets/index-hs60b25v.js.map +62 -0
- package/dist/jobs/assets/index-k2smrr70.js +4 -0
- package/dist/jobs/assets/index-k2smrr70.js.map +9 -0
- package/dist/jobs/assets/index-q24jw3w9.js +4 -0
- package/dist/jobs/assets/index-q24jw3w9.js.map +32 -0
- package/dist/jobs/assets/index-qfb80xen.css +1 -0
- package/dist/jobs/assets/index-rtynx6wj.js +4 -0
- package/dist/jobs/assets/index-rtynx6wj.js.map +10 -0
- package/dist/jobs/assets/live-6k7s6358.js +4 -0
- package/dist/jobs/assets/live-6k7s6358.js.map +10 -0
- package/dist/jobs/assets/main-0jmeh6mq.js +6 -0
- package/dist/jobs/assets/main-0jmeh6mq.js.map +12 -0
- package/dist/jobs/assets/main-3yj6bv90.js +5 -0
- package/dist/jobs/assets/main-3yj6bv90.js.map +10 -0
- package/dist/jobs/assets/main-4g69fn3x.js +5 -0
- package/dist/jobs/assets/main-4g69fn3x.js.map +10 -0
- package/dist/jobs/assets/main-4gte3eqq.js +5 -0
- package/dist/jobs/assets/main-4gte3eqq.js.map +10 -0
- package/dist/jobs/assets/main-4j1fcevh.js +5 -0
- package/dist/jobs/assets/main-4j1fcevh.js.map +10 -0
- package/dist/jobs/assets/main-54qwkz28.css +1 -0
- package/dist/jobs/assets/main-5grjvsy7.js +5 -0
- package/dist/jobs/assets/main-5grjvsy7.js.map +11 -0
- package/dist/jobs/assets/main-5zbx60gq.js +5 -0
- package/dist/jobs/assets/main-5zbx60gq.js.map +11 -0
- package/dist/jobs/assets/main-9prgjhg4.js +5 -0
- package/dist/jobs/assets/main-9prgjhg4.js.map +10 -0
- package/dist/jobs/assets/main-a250ffk7.js +17 -0
- package/dist/jobs/assets/main-a250ffk7.js.map +115 -0
- package/dist/jobs/assets/main-d6ge7w08.js +5 -0
- package/dist/jobs/assets/main-d6ge7w08.js.map +10 -0
- package/dist/jobs/assets/main-gngzht43.js +5 -0
- package/dist/jobs/assets/main-gngzht43.js.map +10 -0
- package/dist/jobs/assets/main-hg0hrg8j.js +5 -0
- package/dist/jobs/assets/main-hg0hrg8j.js.map +10 -0
- package/dist/jobs/assets/main-hm1ng3yg.js +5 -0
- package/dist/jobs/assets/main-hm1ng3yg.js.map +12 -0
- package/dist/jobs/assets/main-jkvd92z9.js +5 -0
- package/dist/jobs/assets/main-jkvd92z9.js.map +10 -0
- package/dist/jobs/assets/main-jv5xqd13.js +5 -0
- package/dist/jobs/assets/main-jv5xqd13.js.map +21 -0
- package/dist/jobs/assets/main-jxre7w93.js +5 -0
- package/dist/jobs/assets/main-jxre7w93.js.map +23 -0
- package/dist/jobs/assets/main-kwmqkjfx.js +5 -0
- package/dist/jobs/assets/main-kwmqkjfx.js.map +11 -0
- package/dist/jobs/assets/main-n9cf6bxm.js +5 -0
- package/dist/jobs/assets/main-n9cf6bxm.js.map +11 -0
- package/dist/jobs/assets/main-pegp0ygb.js +5 -0
- package/dist/jobs/assets/main-pegp0ygb.js.map +10 -0
- package/dist/jobs/assets/main-qr573853.js +5 -0
- package/dist/jobs/assets/main-qr573853.js.map +12 -0
- package/dist/jobs/assets/main-rq97rdr4.js +4 -0
- package/dist/jobs/assets/main-rq97rdr4.js.map +22 -0
- package/dist/jobs/assets/main-szjdmydc.js +5 -0
- package/dist/jobs/assets/main-szjdmydc.js.map +13 -0
- package/dist/jobs/assets/main-tnbhrm9n.js +7 -0
- package/dist/jobs/assets/main-tnbhrm9n.js.map +11 -0
- package/dist/jobs/assets/main-ts19j4n0.js +5 -0
- package/dist/jobs/assets/main-ts19j4n0.js.map +25 -0
- package/dist/jobs/assets/main-w93x1dzs.js +5 -0
- package/dist/jobs/assets/main-w93x1dzs.js.map +10 -0
- package/dist/jobs/assets/sections-0ksdxs8m.css +1 -0
- package/dist/jobs/assets/sections-0y7w4fb4.js +4 -0
- package/dist/jobs/assets/sections-0y7w4fb4.js.map +10 -0
- package/dist/jobs/assets/sse-mn0t0fh7.js +5 -0
- package/dist/jobs/assets/sse-mn0t0fh7.js.map +11 -0
- package/dist/jobs/assets/sseChecks-t7tcm7ra.js +4 -0
- package/dist/jobs/assets/sseChecks-t7tcm7ra.js.map +10 -0
- package/dist/jobs/assets/validation-ysjfy072.js +4 -0
- package/dist/jobs/assets/validation-ysjfy072.js.map +9 -0
- package/dist/jobs/manifest.json +511 -0
- package/dts/docs/assets.d.ts +144 -0
- package/dts/docs/assets.d.ts.map +1 -0
- package/dts/docs/browser/config.d.ts +61 -0
- package/dts/docs/browser/config.d.ts.map +1 -0
- package/dts/docs/browser/index.d.ts +12 -0
- package/dts/docs/browser/index.d.ts.map +1 -0
- package/dts/docs/docsUi.d.ts +42 -0
- package/dts/docs/docsUi.d.ts.map +1 -0
- package/dts/docs/hosted.d.ts +22 -0
- package/dts/docs/hosted.d.ts.map +1 -0
- package/dts/docs/index.d.ts +18 -0
- package/dts/docs/index.d.ts.map +1 -0
- package/dts/docs/shell.d.ts +57 -0
- package/dts/docs/shell.d.ts.map +1 -0
- package/dts/docs/types.d.ts +137 -0
- package/dts/docs/types.d.ts.map +1 -0
- package/dts/index.d.ts +21 -0
- package/dts/index.d.ts.map +1 -0
- package/dts/jobs/assets.d.ts +157 -0
- package/dts/jobs/assets.d.ts.map +1 -0
- package/dts/jobs/browser/config.d.ts +67 -0
- package/dts/jobs/browser/config.d.ts.map +1 -0
- package/dts/jobs/browser/index.d.ts +10 -0
- package/dts/jobs/browser/index.d.ts.map +1 -0
- package/dts/jobs/browser/unauthorized.d.ts +9 -0
- package/dts/jobs/browser/unauthorized.d.ts.map +1 -0
- package/dts/jobs/index.d.ts +24 -0
- package/dts/jobs/index.d.ts.map +1 -0
- package/dts/jobs/jobsUi.d.ts +63 -0
- package/dts/jobs/jobsUi.d.ts.map +1 -0
- package/dts/jobs/shell.d.ts +77 -0
- package/dts/jobs/shell.d.ts.map +1 -0
- package/dts/jobs/types.d.ts +146 -0
- package/dts/jobs/types.d.ts.map +1 -0
- package/dts/kit/browser/shared.d.ts +10 -0
- package/dts/kit/browser/shared.d.ts.map +1 -0
- package/dts/kit/browser/unauthorized.d.ts +74 -0
- package/dts/kit/browser/unauthorized.d.ts.map +1 -0
- package/dts/kit/unauthorized.d.ts +47 -0
- package/dts/kit/unauthorized.d.ts.map +1 -0
- package/guides/docs.md +170 -0
- package/guides/jobs.md +1120 -0
- package/lib/docs/assets.ts +427 -0
- package/lib/docs/browser/config.ts +66 -0
- package/lib/docs/browser/index.ts +24 -0
- package/lib/docs/docsUi.ts +623 -0
- package/lib/docs/hosted.ts +31 -0
- package/lib/docs/index.ts +37 -0
- package/lib/docs/shell.ts +156 -0
- package/lib/docs/types.ts +149 -0
- package/lib/index.ts +27 -0
- package/lib/jobs/assets.ts +448 -0
- package/lib/jobs/browser/config.ts +65 -0
- package/lib/jobs/browser/index.ts +9 -0
- package/lib/jobs/browser/unauthorized.ts +11 -0
- package/lib/jobs/index.ts +41 -0
- package/lib/jobs/jobsUi.ts +709 -0
- package/lib/jobs/shell.ts +205 -0
- package/lib/jobs/types.ts +162 -0
- package/lib/kit/browser/shared.ts +11 -0
- package/lib/kit/browser/unauthorized.ts +114 -0
- package/lib/kit/unauthorized.ts +222 -0
- package/package.json +198 -4
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jobsUi()`: a {@link BunRouter} serving the management app — the HTML shell
|
|
3
|
+
* for every client route under `basePath`, and the hashed bundle under
|
|
4
|
+
* `<basePath>/assets/`.
|
|
5
|
+
*
|
|
6
|
+
* Like `createJobsApi`, its routes are registered **relative to the mount**
|
|
7
|
+
* (`/`, `/*`, `/assets/:file`), so it is mounted with
|
|
8
|
+
* `app.use(ui.basePath, ui.router)` and `ui.router.fetch("/")` works on its
|
|
9
|
+
* own. The mount path must equal `basePath`: the injected configuration and
|
|
10
|
+
* every asset URL are built from it.
|
|
11
|
+
*/
|
|
12
|
+
import type {
|
|
13
|
+
BunRequest,
|
|
14
|
+
BunResponse,
|
|
15
|
+
Logger,
|
|
16
|
+
RouterHandler,
|
|
17
|
+
} from "@kingsleyweb/bun-common";
|
|
18
|
+
import type { JobsApi } from "@kingsleyweb/bun-jobs";
|
|
19
|
+
import type { UiAsset, UiAssets } from "./assets";
|
|
20
|
+
import type { UiUnauthorized } from "./browser/unauthorized";
|
|
21
|
+
import type { JobsUiConfig, JobsUiSections, UiTheme } from "./browser/config";
|
|
22
|
+
import type { ShellRenderer } from "./shell";
|
|
23
|
+
import type {
|
|
24
|
+
JobsUi,
|
|
25
|
+
JobsUiAuthorize,
|
|
26
|
+
JobsUiAuthorizeContext,
|
|
27
|
+
JobsUiOptions,
|
|
28
|
+
} from "./types";
|
|
29
|
+
import { BunRouter, resolveLogger } from "@kingsleyweb/bun-common";
|
|
30
|
+
import { ConfigError } from "@kingsleyweb/bun-jobs";
|
|
31
|
+
import { DEFAULT_DIST_DIR, DEFAULT_ENTRY, resolveAssetSource } from "./assets";
|
|
32
|
+
import { ASSET_CACHE_CONTROL } from "../kit/browser/shared";
|
|
33
|
+
import {
|
|
34
|
+
redirectsDenial,
|
|
35
|
+
resolveUnauthorized,
|
|
36
|
+
sendUnauthorizedRedirect,
|
|
37
|
+
} from "../kit/unauthorized";
|
|
38
|
+
import {
|
|
39
|
+
connectSources,
|
|
40
|
+
createNonce,
|
|
41
|
+
createShellRenderer,
|
|
42
|
+
cspHeader,
|
|
43
|
+
} from "./shell";
|
|
44
|
+
|
|
45
|
+
/** The UI's default mount path. */
|
|
46
|
+
export const DEFAULT_BASE_PATH = "/jobs";
|
|
47
|
+
|
|
48
|
+
/** The default page title. */
|
|
49
|
+
export const DEFAULT_TITLE = "Jobs";
|
|
50
|
+
|
|
51
|
+
/** The path under `basePath` the bundle is served from. */
|
|
52
|
+
export const ASSETS_SUBPATH = "/assets";
|
|
53
|
+
|
|
54
|
+
export { ASSET_CACHE_CONTROL };
|
|
55
|
+
|
|
56
|
+
/** A path segment `basePath` may contain — the API's own rule. */
|
|
57
|
+
const PATH_SEGMENT = /^[\w.~-]+$/;
|
|
58
|
+
|
|
59
|
+
/** A header name (an RFC 9110 token). */
|
|
60
|
+
const HEADER_NAME = /^[!#$%&'*+.^`|~\w-]+$/;
|
|
61
|
+
|
|
62
|
+
// The option's defaults, kept here for `./lib/jobsUi` importers; the
|
|
63
|
+
// option itself is the kit's (`lib/kit/unauthorized`), shared with docsUi().
|
|
64
|
+
export {
|
|
65
|
+
DEFAULT_RETURN_PARAM,
|
|
66
|
+
DEFAULT_UNAUTHORIZED_STATUSES,
|
|
67
|
+
} from "../kit/browser/unauthorized";
|
|
68
|
+
|
|
69
|
+
/** Where the bundle comes from; tests point these at a fixture. */
|
|
70
|
+
export interface JobsUiInternals {
|
|
71
|
+
/** The dist directory read when present. Defaults to the package's `dist/`. */
|
|
72
|
+
distDir?: string;
|
|
73
|
+
/** The entry built in memory without a dist. Defaults to `app/main.tsx`. */
|
|
74
|
+
entry?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validates and normalises an absolute mount path, as the API validates its
|
|
79
|
+
* `basePath`: absolute, trailing slashes dropped, not `"/"`, and only
|
|
80
|
+
* segments of letters, digits, `_`, `.`, `~` and `-`.
|
|
81
|
+
*/
|
|
82
|
+
export function normalizeBasePath(value: unknown, what: string): string {
|
|
83
|
+
if (typeof value !== "string" || !value.startsWith("/")) {
|
|
84
|
+
throw new ConfigError(
|
|
85
|
+
`${what} must be an absolute path starting with "/"`,
|
|
86
|
+
{
|
|
87
|
+
value,
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const trimmed = value.replace(/\/+$/, "");
|
|
92
|
+
if (trimmed === "") {
|
|
93
|
+
throw new ConfigError(
|
|
94
|
+
`${what} may not be "/": the UI's catch-all would answer for every GET route of the host`,
|
|
95
|
+
{ value },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
for (const segment of trimmed.slice(1).split("/")) {
|
|
99
|
+
if (!PATH_SEGMENT.test(segment) || segment === "." || segment === "..") {
|
|
100
|
+
throw new ConfigError(
|
|
101
|
+
`${what} may only contain "/"-separated segments of letters, digits, "_", ".", "~" and "-"`,
|
|
102
|
+
{ value },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return trimmed;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Whether `path` is `base` or lies under it, segment-wise. */
|
|
110
|
+
function isWithin(path: string, base: string): boolean {
|
|
111
|
+
return path === base || path.startsWith(`${base}/`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Where the API is, as the browser will reach it. */
|
|
115
|
+
interface ResolvedApiTarget {
|
|
116
|
+
/** `JobsUiConfig.apiBase`. */
|
|
117
|
+
apiBase: string;
|
|
118
|
+
/** The API's same-origin path, when known: requests under it are left to the API. */
|
|
119
|
+
apiPath: string | undefined;
|
|
120
|
+
/** The API's origin when it is a URL (`"https://api.example"`), else `undefined`. */
|
|
121
|
+
apiOrigin: string | undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The raw parts of an `http(s)://` URL as written, before `new URL()`
|
|
126
|
+
* normalises them: the authority and the path (up to any `?` or `#`).
|
|
127
|
+
* `undefined` when the string does not start with an `http(s)://` authority.
|
|
128
|
+
*/
|
|
129
|
+
const RAW_HTTP_URL = /^https?:\/\/([^/?#]*)([^?#]*)/i;
|
|
130
|
+
|
|
131
|
+
/** Refuses a query or fragment in `apiUrl` with a message naming which. */
|
|
132
|
+
function refuseQueryOrFragment(apiUrl: string): void {
|
|
133
|
+
const query = apiUrl.indexOf("?");
|
|
134
|
+
const hash = apiUrl.indexOf("#");
|
|
135
|
+
if (query !== -1 && (hash === -1 || query < hash)) {
|
|
136
|
+
throw new ConfigError("apiUrl may not carry a query", { apiUrl });
|
|
137
|
+
}
|
|
138
|
+
if (hash !== -1) {
|
|
139
|
+
throw new ConfigError("apiUrl may not carry a fragment", { apiUrl });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolves `apiUrl`: a same-origin absolute path or an `http(s)` URL.
|
|
145
|
+
*
|
|
146
|
+
* The URL form's path obeys the path form's segment rules. They are checked
|
|
147
|
+
* on the **raw** string, because `new URL()` has already resolved `..`,
|
|
148
|
+
* percent-encoded a space and turned `\` into `/` — `http://h/a/../b` would
|
|
149
|
+
* otherwise quietly become `http://h/b`. The raw path is validated with
|
|
150
|
+
* {@link normalizeBasePath}, then compared with the parsed `pathname`: any
|
|
151
|
+
* difference means the parser rewrote something the rules did not see, and
|
|
152
|
+
* is refused too. An empty or `"/"` path is the API at the origin root.
|
|
153
|
+
*/
|
|
154
|
+
function resolveApiUrl(apiUrl: unknown): ResolvedApiTarget {
|
|
155
|
+
if (typeof apiUrl !== "string" || apiUrl === "") {
|
|
156
|
+
throw new ConfigError("apiUrl must be an absolute path or an http(s) URL", {
|
|
157
|
+
apiUrl,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (apiUrl.startsWith("/") && !apiUrl.startsWith("//")) {
|
|
161
|
+
refuseQueryOrFragment(apiUrl);
|
|
162
|
+
const path = normalizeBasePath(apiUrl, "apiUrl");
|
|
163
|
+
return { apiBase: path, apiPath: path, apiOrigin: undefined };
|
|
164
|
+
}
|
|
165
|
+
let url: URL;
|
|
166
|
+
try {
|
|
167
|
+
url = new URL(apiUrl);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new ConfigError("apiUrl must be an absolute path or an http(s) URL", {
|
|
170
|
+
apiUrl,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
174
|
+
throw new ConfigError("apiUrl must use http: or https:", { apiUrl });
|
|
175
|
+
}
|
|
176
|
+
const raw = RAW_HTTP_URL.exec(apiUrl);
|
|
177
|
+
if (raw === null) {
|
|
178
|
+
throw new ConfigError("apiUrl must be an absolute path or an http(s) URL", {
|
|
179
|
+
apiUrl,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
refuseQueryOrFragment(apiUrl);
|
|
183
|
+
const [, authority = "", rawPath = ""] = raw;
|
|
184
|
+
if (authority.includes("@") || url.username !== "" || url.password !== "") {
|
|
185
|
+
throw new ConfigError("apiUrl may not carry credentials", { apiUrl });
|
|
186
|
+
}
|
|
187
|
+
const path = /^\/*$/.test(rawPath)
|
|
188
|
+
? ""
|
|
189
|
+
: normalizeBasePath(rawPath, "apiUrl");
|
|
190
|
+
if (url.pathname.replace(/\/+$/, "") !== path) {
|
|
191
|
+
throw new ConfigError(
|
|
192
|
+
`apiUrl may only contain "/"-separated segments of letters, digits, "_", ".", "~" and "-"`,
|
|
193
|
+
{ apiUrl },
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
apiBase: `${url.origin}${path}`,
|
|
198
|
+
apiPath: undefined,
|
|
199
|
+
apiOrigin: url.origin,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The read-only summary a newer API exposes as `api.info` (bun-jobs'
|
|
205
|
+
* `JobsApiInfo`). Typed structurally and feature-detected, so the UI works
|
|
206
|
+
* with an API that predates it and prefers it once it is there.
|
|
207
|
+
*/
|
|
208
|
+
export interface JobsApiInfoLike {
|
|
209
|
+
/** The API's CSRF settings; `header` is lower-cased, `null` for none. */
|
|
210
|
+
csrf?: { header?: string | null };
|
|
211
|
+
/** Full paths of the spec documents actually routed, or `null` when docs are off. */
|
|
212
|
+
docs?: { openapi: string; asyncapi?: string } | null;
|
|
213
|
+
/** The socket's full path and, for a dedicated socket, its bound port; `null` without one. */
|
|
214
|
+
websocket?: { path: string; port?: number } | null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** `api.info`, when this API version has it. */
|
|
218
|
+
function infoOf(api: JobsApi): JobsApiInfoLike | undefined {
|
|
219
|
+
const info = (api as { info?: unknown }).info;
|
|
220
|
+
return typeof info === "object" && info !== null ? info : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The CSRF header the API reports (`api.info.csrf.header`), when it reports one. */
|
|
224
|
+
function csrfHeaderFromApi(api: JobsApi): string | undefined {
|
|
225
|
+
const header = infoOf(api)?.csrf?.header;
|
|
226
|
+
return typeof header === "string" && header !== "" ? header : undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The docs paths: `api.info.docs` when present, else from the API's routes. */
|
|
230
|
+
function docsFromApi(api: JobsApi): JobsUiConfig["docs"] {
|
|
231
|
+
const info = infoOf(api);
|
|
232
|
+
if (info !== undefined && info.docs !== undefined) {
|
|
233
|
+
return info.docs === null
|
|
234
|
+
? null
|
|
235
|
+
: { openapi: info.docs.openapi, asyncapi: info.docs.asyncapi ?? null };
|
|
236
|
+
}
|
|
237
|
+
const pathOf = (operationId: string): string | undefined =>
|
|
238
|
+
api.routes.find((route) => route.operationId === operationId)?.path;
|
|
239
|
+
const openapi = pathOf("getOpenApiDocument");
|
|
240
|
+
if (openapi === undefined) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
return { openapi, asyncapi: pathOf("getAsyncApiDocument") ?? null };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The socket: `api.info.websocket` when present, else `api.websocket`. */
|
|
247
|
+
function websocketFromApi(api: JobsApi): JobsUiConfig["websocket"] {
|
|
248
|
+
const info = infoOf(api);
|
|
249
|
+
if (info !== undefined && info.websocket !== undefined) {
|
|
250
|
+
return info.websocket === null
|
|
251
|
+
? null
|
|
252
|
+
: { path: info.websocket.path, port: info.websocket.port ?? null };
|
|
253
|
+
}
|
|
254
|
+
const socket = api.websocket;
|
|
255
|
+
return socket ? { path: socket.path, port: socket.port ?? null } : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Validates `sections`, defaulting each to `true`. */
|
|
259
|
+
function resolveSections(value: JobsUiOptions["sections"]): JobsUiSections {
|
|
260
|
+
if (value !== undefined && (typeof value !== "object" || value === null)) {
|
|
261
|
+
throw new ConfigError("sections must be an object", { value });
|
|
262
|
+
}
|
|
263
|
+
const sections: JobsUiSections = { manage: true, docs: true };
|
|
264
|
+
for (const key of ["manage", "docs"] as const) {
|
|
265
|
+
const flag = value?.[key];
|
|
266
|
+
if (flag !== undefined && typeof flag !== "boolean") {
|
|
267
|
+
throw new ConfigError(`sections.${key} must be a boolean`, {
|
|
268
|
+
value: flag,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
sections[key] = flag ?? true;
|
|
272
|
+
}
|
|
273
|
+
if (!sections.manage && !sections.docs) {
|
|
274
|
+
throw new ConfigError(
|
|
275
|
+
"sections: at least one of manage and docs must be enabled",
|
|
276
|
+
{ sections },
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return sections;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Validates `theme`. */
|
|
283
|
+
function resolveTheme(value: unknown): UiTheme {
|
|
284
|
+
if (value === undefined) {
|
|
285
|
+
return "system";
|
|
286
|
+
}
|
|
287
|
+
if (value === "system" || value === "light" || value === "dark") {
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
throw new ConfigError(`theme must be "system", "light" or "dark"`, {
|
|
291
|
+
value,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Validates `csrfHeader`, falling back to what the API reports. */
|
|
296
|
+
function resolveCsrfHeader(
|
|
297
|
+
value: unknown,
|
|
298
|
+
api: JobsApi | undefined,
|
|
299
|
+
): string | null {
|
|
300
|
+
if (value === undefined) {
|
|
301
|
+
return (api && csrfHeaderFromApi(api)) ?? null;
|
|
302
|
+
}
|
|
303
|
+
if (value === false) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
if (typeof value !== "string" || !HEADER_NAME.test(value)) {
|
|
307
|
+
throw new ConfigError("csrfHeader must be a header name or false", {
|
|
308
|
+
value,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** A frozen copy of the config, nested objects included. */
|
|
315
|
+
function freezeConfig(config: JobsUiConfig): Readonly<JobsUiConfig> {
|
|
316
|
+
for (const value of Object.values(config)) {
|
|
317
|
+
if (typeof value === "object" && value !== null) {
|
|
318
|
+
Object.freeze(value);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return Object.freeze(config);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Sends an RFC 9457 problem, shaped as the API's (contract §2.1). */
|
|
325
|
+
function sendProblem(
|
|
326
|
+
req: BunRequest,
|
|
327
|
+
res: BunResponse,
|
|
328
|
+
status: number,
|
|
329
|
+
code: string,
|
|
330
|
+
title: string,
|
|
331
|
+
detail: string,
|
|
332
|
+
): void {
|
|
333
|
+
res.status(status);
|
|
334
|
+
res.setHeader("Content-Type", "application/problem+json");
|
|
335
|
+
res.setHeader("Cache-Control", "no-store");
|
|
336
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
337
|
+
if (req.method === "HEAD") {
|
|
338
|
+
void res.end();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
res.send(
|
|
342
|
+
JSON.stringify({
|
|
343
|
+
type: `urn:bun-jobs:error:${code}`,
|
|
344
|
+
title,
|
|
345
|
+
status,
|
|
346
|
+
code,
|
|
347
|
+
detail,
|
|
348
|
+
instance: req.path,
|
|
349
|
+
}),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Normalises an `authorize` answer, as the API's own `decide` does: a denial
|
|
355
|
+
* is 401 only for `status: 401`, 403 for any other status, and anything
|
|
356
|
+
* unrecognised denies with 403.
|
|
357
|
+
*/
|
|
358
|
+
function decide(result: unknown):
|
|
359
|
+
| { allow: true }
|
|
360
|
+
| {
|
|
361
|
+
allow: false;
|
|
362
|
+
status: 401 | 403;
|
|
363
|
+
reason?: string;
|
|
364
|
+
} {
|
|
365
|
+
if (result === true) {
|
|
366
|
+
return { allow: true };
|
|
367
|
+
}
|
|
368
|
+
if (typeof result === "object" && result !== null && "allow" in result) {
|
|
369
|
+
const answer = result as {
|
|
370
|
+
allow: unknown;
|
|
371
|
+
status?: unknown;
|
|
372
|
+
reason?: unknown;
|
|
373
|
+
};
|
|
374
|
+
if (answer.allow === true) {
|
|
375
|
+
return { allow: true };
|
|
376
|
+
}
|
|
377
|
+
if (answer.allow === false) {
|
|
378
|
+
return {
|
|
379
|
+
allow: false,
|
|
380
|
+
status: answer.status === 401 ? 401 : 403,
|
|
381
|
+
...(typeof answer.reason === "string" && answer.reason !== ""
|
|
382
|
+
? { reason: answer.reason }
|
|
383
|
+
: {}),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return { allow: false, status: 403 };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The `authorize` step of every UI route. With `redirect`, a denial whose
|
|
392
|
+
* status it lists answers a page navigation with a 303 to the target; a
|
|
393
|
+
* bundle file and anything that is not a navigation keep the problem.
|
|
394
|
+
*/
|
|
395
|
+
function authorizeStep(
|
|
396
|
+
authorize: JobsUiAuthorize,
|
|
397
|
+
asset: boolean,
|
|
398
|
+
logger: Logger,
|
|
399
|
+
redirect: UiUnauthorized | null,
|
|
400
|
+
): RouterHandler {
|
|
401
|
+
return async (req, res, next) => {
|
|
402
|
+
const context: JobsUiAuthorizeContext = { asset, path: req.path };
|
|
403
|
+
let decision: ReturnType<typeof decide>;
|
|
404
|
+
try {
|
|
405
|
+
decision = decide(await authorize(req, context));
|
|
406
|
+
} catch (error) {
|
|
407
|
+
logger.error("jobs ui authorize threw; refusing the request", {
|
|
408
|
+
error,
|
|
409
|
+
path: req.path,
|
|
410
|
+
});
|
|
411
|
+
sendProblem(
|
|
412
|
+
req,
|
|
413
|
+
res,
|
|
414
|
+
500,
|
|
415
|
+
"INTERNAL",
|
|
416
|
+
"Internal server error",
|
|
417
|
+
"Internal server error",
|
|
418
|
+
);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (decision.allow) {
|
|
422
|
+
return next();
|
|
423
|
+
}
|
|
424
|
+
if (redirectsDenial(redirect, !asset, decision.status, req)) {
|
|
425
|
+
sendUnauthorizedRedirect(req, res, redirect);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (decision.status === 401) {
|
|
429
|
+
sendProblem(
|
|
430
|
+
req,
|
|
431
|
+
res,
|
|
432
|
+
401,
|
|
433
|
+
"UNAUTHORIZED",
|
|
434
|
+
"Authentication required",
|
|
435
|
+
decision.reason ?? "Authentication required",
|
|
436
|
+
);
|
|
437
|
+
} else {
|
|
438
|
+
sendProblem(
|
|
439
|
+
req,
|
|
440
|
+
res,
|
|
441
|
+
403,
|
|
442
|
+
"FORBIDDEN",
|
|
443
|
+
"Forbidden",
|
|
444
|
+
decision.reason ?? "Not allowed to open this page",
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Creates the UI with an explicit bundle source. {@link jobsUi} is this with
|
|
452
|
+
* the package's own `dist/` and `app/main.tsx`; tests point it at a fixture.
|
|
453
|
+
*/
|
|
454
|
+
export function createJobsUi(
|
|
455
|
+
options: JobsUiOptions,
|
|
456
|
+
internals: JobsUiInternals = {},
|
|
457
|
+
): JobsUi {
|
|
458
|
+
if (typeof options !== "object" || options === null) {
|
|
459
|
+
throw new ConfigError("jobsUi() needs an options object", {});
|
|
460
|
+
}
|
|
461
|
+
const hasApi = options.api !== undefined;
|
|
462
|
+
const hasUrl = options.apiUrl !== undefined;
|
|
463
|
+
if (hasApi === hasUrl) {
|
|
464
|
+
throw new ConfigError(
|
|
465
|
+
hasApi
|
|
466
|
+
? "jobsUi() takes api or apiUrl, not both"
|
|
467
|
+
: "jobsUi() needs api (a createJobsApi result) or apiUrl",
|
|
468
|
+
{},
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
const api = options.api;
|
|
472
|
+
if (
|
|
473
|
+
api !== undefined &&
|
|
474
|
+
(typeof api !== "object" ||
|
|
475
|
+
api === null ||
|
|
476
|
+
typeof api.basePath !== "string" ||
|
|
477
|
+
!Array.isArray(api.routes))
|
|
478
|
+
) {
|
|
479
|
+
throw new ConfigError("api must be the result of createJobsApi()", {});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const basePath = normalizeBasePath(
|
|
483
|
+
options.basePath ?? DEFAULT_BASE_PATH,
|
|
484
|
+
"basePath",
|
|
485
|
+
);
|
|
486
|
+
const target: ResolvedApiTarget = api
|
|
487
|
+
? { apiBase: api.basePath, apiPath: api.basePath, apiOrigin: undefined }
|
|
488
|
+
: resolveApiUrl(options.apiUrl);
|
|
489
|
+
if (target.apiPath !== undefined && isWithin(basePath, target.apiPath)) {
|
|
490
|
+
throw new ConfigError(
|
|
491
|
+
`basePath ${basePath} may not equal or sit under the API's basePath ${target.apiPath}: the API answers every request under its basePath with a JSON 404`,
|
|
492
|
+
{ basePath, apiBasePath: target.apiPath },
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
const title = options.title ?? DEFAULT_TITLE;
|
|
496
|
+
if (typeof title !== "string" || title.trim() === "") {
|
|
497
|
+
throw new ConfigError("title must be a non-empty string", { title });
|
|
498
|
+
}
|
|
499
|
+
const middleware = options.middleware ?? [];
|
|
500
|
+
if (
|
|
501
|
+
!Array.isArray(middleware) ||
|
|
502
|
+
!middleware.every((handler) => typeof handler === "function")
|
|
503
|
+
) {
|
|
504
|
+
throw new ConfigError("middleware must be an array of functions", {});
|
|
505
|
+
}
|
|
506
|
+
if (
|
|
507
|
+
options.authorize !== undefined &&
|
|
508
|
+
typeof options.authorize !== "function"
|
|
509
|
+
) {
|
|
510
|
+
throw new ConfigError("authorize must be a function", {});
|
|
511
|
+
}
|
|
512
|
+
if (options.dev !== undefined && typeof options.dev !== "boolean") {
|
|
513
|
+
throw new ConfigError("dev must be a boolean", { dev: options.dev });
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const config = freezeConfig({
|
|
517
|
+
version: 1,
|
|
518
|
+
title,
|
|
519
|
+
basePath,
|
|
520
|
+
assetsPath: `${basePath}${ASSETS_SUBPATH}`,
|
|
521
|
+
apiBase: target.apiBase,
|
|
522
|
+
csrfHeader: resolveCsrfHeader(options.csrfHeader, api),
|
|
523
|
+
websocket: api ? websocketFromApi(api) : null,
|
|
524
|
+
docs: api ? docsFromApi(api) : null,
|
|
525
|
+
sections: resolveSections(options.sections),
|
|
526
|
+
theme: resolveTheme(options.theme),
|
|
527
|
+
unauthorized: resolveUnauthorized(
|
|
528
|
+
options.unauthorized,
|
|
529
|
+
basePath,
|
|
530
|
+
(message, context) => new ConfigError(message, context),
|
|
531
|
+
),
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const logger = resolveLogger(options.logger).child({ component: "jobs-ui" });
|
|
535
|
+
const loadAssets = resolveAssetSource({
|
|
536
|
+
dev: options.dev,
|
|
537
|
+
distDir: internals.distDir ?? DEFAULT_DIST_DIR,
|
|
538
|
+
entry: internals.entry ?? DEFAULT_ENTRY,
|
|
539
|
+
logger,
|
|
540
|
+
fail: (message, context) => new ConfigError(message, context),
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// The shell is rendered from the bundle, so it waits for the first load.
|
|
544
|
+
let renderer: { assets: UiAssets; render: ShellRenderer } | undefined;
|
|
545
|
+
const ready = async (): Promise<{
|
|
546
|
+
assets: UiAssets;
|
|
547
|
+
render: ShellRenderer;
|
|
548
|
+
}> => {
|
|
549
|
+
if (renderer === undefined) {
|
|
550
|
+
const assets = await loadAssets();
|
|
551
|
+
renderer ??= { assets, render: createShellRenderer(config, assets) };
|
|
552
|
+
}
|
|
553
|
+
return renderer;
|
|
554
|
+
};
|
|
555
|
+
/** Loads the bundle, or answers 500 and returns `undefined`. */
|
|
556
|
+
const readyOrFail = async (
|
|
557
|
+
req: BunRequest,
|
|
558
|
+
res: BunResponse,
|
|
559
|
+
): Promise<{ assets: UiAssets; render: ShellRenderer } | undefined> => {
|
|
560
|
+
try {
|
|
561
|
+
return await ready();
|
|
562
|
+
} catch (error) {
|
|
563
|
+
logger.error("jobs ui could not load its bundle", { error });
|
|
564
|
+
sendProblem(
|
|
565
|
+
req,
|
|
566
|
+
res,
|
|
567
|
+
500,
|
|
568
|
+
"INTERNAL",
|
|
569
|
+
"Internal server error",
|
|
570
|
+
"Internal server error",
|
|
571
|
+
);
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const apiOrigin = target.apiOrigin;
|
|
577
|
+
const websocketPort = config.websocket?.port ?? null;
|
|
578
|
+
const serveShell: RouterHandler = async (req, res) => {
|
|
579
|
+
const loaded = await readyOrFail(req, res);
|
|
580
|
+
if (!loaded) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const nonce = createNonce();
|
|
584
|
+
const html = loaded.render(nonce);
|
|
585
|
+
res.status(200);
|
|
586
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
587
|
+
res.setHeader("Cache-Control", "no-store");
|
|
588
|
+
// The Host header first: it is the origin the browser loaded the page
|
|
589
|
+
// from. `connectSources` drops it unless it is a plain `host[:port]`.
|
|
590
|
+
const connectSrc = connectSources({
|
|
591
|
+
host: req.get("host") ?? req.host,
|
|
592
|
+
secure: req.secure,
|
|
593
|
+
apiOrigin,
|
|
594
|
+
websocketPort,
|
|
595
|
+
});
|
|
596
|
+
res.setHeader("Content-Security-Policy", cspHeader({ nonce, connectSrc }));
|
|
597
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
598
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
599
|
+
if (req.method === "HEAD") {
|
|
600
|
+
void res.end();
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
res.send(html);
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const serveAsset: RouterHandler = async (req, res) => {
|
|
607
|
+
const loaded = await readyOrFail(req, res);
|
|
608
|
+
if (!loaded) {
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const name = (req.params as Record<string, string | undefined>).file;
|
|
612
|
+
const file: UiAsset | undefined =
|
|
613
|
+
name === undefined ? undefined : loaded.assets.files.get(name);
|
|
614
|
+
if (file === undefined) {
|
|
615
|
+
sendProblem(
|
|
616
|
+
req,
|
|
617
|
+
res,
|
|
618
|
+
404,
|
|
619
|
+
"NOT_FOUND",
|
|
620
|
+
"Not found",
|
|
621
|
+
`No asset ${req.path}`,
|
|
622
|
+
);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
res.setHeader("Content-Type", file.type);
|
|
626
|
+
res.setHeader("Cache-Control", ASSET_CACHE_CONTROL);
|
|
627
|
+
res.setHeader("ETag", file.etag);
|
|
628
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
629
|
+
const match = req.get("if-none-match");
|
|
630
|
+
if (
|
|
631
|
+
match !== null &&
|
|
632
|
+
match
|
|
633
|
+
.split(",")
|
|
634
|
+
.some((tag) => tag.trim().replace(/^W\//, "") === file.etag)
|
|
635
|
+
) {
|
|
636
|
+
res.status(304);
|
|
637
|
+
void res.end();
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
res.status(200);
|
|
641
|
+
if (req.method === "HEAD") {
|
|
642
|
+
res.setHeader("Content-Length", String(file.size));
|
|
643
|
+
void res.end();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
res.send(file.body);
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const notFound: RouterHandler = (req, res) => {
|
|
650
|
+
sendProblem(
|
|
651
|
+
req,
|
|
652
|
+
res,
|
|
653
|
+
404,
|
|
654
|
+
"NOT_FOUND",
|
|
655
|
+
"Not found",
|
|
656
|
+
`No asset ${req.path}`,
|
|
657
|
+
);
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// A request under the API's own path is the API's, even when the API is
|
|
661
|
+
// mounted under this UI's basePath and after it.
|
|
662
|
+
const apiPath = target.apiPath;
|
|
663
|
+
const leaveApiAlone: RouterHandler = (req, _res, next) =>
|
|
664
|
+
apiPath !== undefined && isWithin(req.path, apiPath)
|
|
665
|
+
? next("route")
|
|
666
|
+
: next();
|
|
667
|
+
|
|
668
|
+
const guards = (asset: boolean): RouterHandler[] => [
|
|
669
|
+
leaveApiAlone,
|
|
670
|
+
...middleware,
|
|
671
|
+
...(options.authorize
|
|
672
|
+
? [authorizeStep(options.authorize, asset, logger, config.unauthorized)]
|
|
673
|
+
: []),
|
|
674
|
+
];
|
|
675
|
+
|
|
676
|
+
const router = new BunRouter();
|
|
677
|
+
for (const verb of ["get", "head"] as const) {
|
|
678
|
+
const register = router[verb].bind(router) as unknown as (
|
|
679
|
+
path: string,
|
|
680
|
+
...callbacks: RouterHandler[]
|
|
681
|
+
) => unknown;
|
|
682
|
+
// `:file` is one segment; anything deeper under the assets path is a 404,
|
|
683
|
+
// never the shell. (A named `*file` would lose its name when the router is
|
|
684
|
+
// mounted — bun-common flattens it to a positional wildcard.)
|
|
685
|
+
register(`${ASSETS_SUBPATH}/:file`, ...guards(true), serveAsset);
|
|
686
|
+
register(ASSETS_SUBPATH, ...guards(true), notFound);
|
|
687
|
+
register(`${ASSETS_SUBPATH}/*`, ...guards(true), notFound);
|
|
688
|
+
register("/", ...guards(false), serveShell);
|
|
689
|
+
register("/*", ...guards(false), serveShell);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return Object.freeze({ router, basePath, config });
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Creates the management UI: validates the options (throwing `ConfigError`
|
|
697
|
+
* for anything unusable), resolves the configuration the app is given, and
|
|
698
|
+
* builds the router.
|
|
699
|
+
*
|
|
700
|
+
* ```ts
|
|
701
|
+
* const api = createJobsApi({ jobs, basePath: "/admin/jobs-api", authorize });
|
|
702
|
+
* const ui = jobsUi({ api, basePath: "/admin/jobs" });
|
|
703
|
+
* app.use(api.basePath, api.router);
|
|
704
|
+
* app.use(ui.basePath, ui.router);
|
|
705
|
+
* ```
|
|
706
|
+
*/
|
|
707
|
+
export function jobsUi(options: JobsUiOptions): JobsUi {
|
|
708
|
+
return createJobsUi(options);
|
|
709
|
+
}
|