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