@specbook/ui 0.0.0-stage → 0.2.0
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 +6 -2
- package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
- package/dist-viewer/apple-touch-icon.png +0 -0
- package/dist-viewer/assets/Host-CSrM9wY1.js +37 -0
- package/dist-viewer/assets/faker-B3c03STC.js +11 -0
- package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
- package/dist-viewer/assets/index-ez9c20v3.js +1 -0
- package/dist-viewer/assets/main-38QGGpNT.js +14 -0
- package/dist-viewer/assets/mount-CGL2tOpN.js +2 -0
- package/dist-viewer/assets/panel-C-vBZrep.css +1 -0
- package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
- package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
- package/dist-viewer/assets/worker-ClGb0tE1.js +181 -0
- package/dist-viewer/favicon.svg +1 -0
- package/dist-viewer/index.html +19 -0
- package/dist-viewer/specbook.js +1 -0
- package/index.html +12 -0
- package/package.json +61 -3
- package/src/app.tsx +368 -0
- package/src/assets/mark.svg +1 -0
- package/src/badge.ts +14 -0
- package/src/branding.ts +92 -0
- package/src/components/Catalog.tsx +324 -0
- package/src/components/EnvBadge.tsx +48 -0
- package/src/components/EnvironmentMenu.tsx +262 -0
- package/src/components/Fields.tsx +220 -0
- package/src/components/GitHubStar.tsx +22 -0
- package/src/components/ListResizer.tsx +70 -0
- package/src/components/ModeHint.tsx +17 -0
- package/src/components/Palette.tsx +85 -0
- package/src/components/PoweredBy.tsx +15 -0
- package/src/components/ShareMenu.tsx +94 -0
- package/src/components/ThemeToggle.tsx +52 -0
- package/src/embed/panel.tsx +115 -0
- package/src/embed/styles.ts +27 -0
- package/src/embed.ts +65 -0
- package/src/engine.ts +574 -0
- package/src/extension.ts +101 -0
- package/src/fielddoc.ts +83 -0
- package/src/main.tsx +24 -0
- package/src/markdown.ts +133 -0
- package/src/nav.ts +118 -0
- package/src/oauth-callback.ts +87 -0
- package/src/oauth.ts +194 -0
- package/src/panel/Body.tsx +182 -0
- package/src/panel/BodyEditor.tsx +92 -0
- package/src/panel/CodeSample.tsx +238 -0
- package/src/panel/Combo.tsx +124 -0
- package/src/panel/ControlsGrid.tsx +189 -0
- package/src/panel/FieldDoc.tsx +42 -0
- package/src/panel/FieldTable.tsx +113 -0
- package/src/panel/Host.tsx +100 -0
- package/src/panel/OAuthSignIn.tsx +163 -0
- package/src/panel/OperationDocs.tsx +181 -0
- package/src/panel/Panel.tsx +197 -0
- package/src/panel/ParamGrid.tsx +396 -0
- package/src/panel/RequestPanel.tsx +627 -0
- package/src/panel/RequestView.tsx +108 -0
- package/src/panel/ResponseView.tsx +182 -0
- package/src/panel/SecretsBar.tsx +189 -0
- package/src/panel/SequenceDocs.tsx +132 -0
- package/src/panel/SequencePanel.tsx +247 -0
- package/src/panel/StepExchange.tsx +118 -0
- package/src/panel/completions.ts +114 -0
- package/src/panel/draft.ts +106 -0
- package/src/panel/fieldnotes-split.ts +55 -0
- package/src/panel/fieldnotes.ts +40 -0
- package/src/panel/graphql.ts +31 -0
- package/src/panel/icons.tsx +85 -0
- package/src/panel/jsonCursor.ts +115 -0
- package/src/panel/stepnotes-split.ts +38 -0
- package/src/panel/stepnotes.ts +58 -0
- package/src/routing.ts +120 -0
- package/src/runtime.ts +32 -0
- package/src/schema.ts +359 -0
- package/src/secrets.ts +61 -0
- package/src/status.ts +27 -0
- package/src/store.ts +12 -0
- package/src/styles/app.css +821 -0
- package/src/styles/panel.css +1939 -0
- package/src/styles/tokens.css +122 -0
- package/src/theme.ts +37 -0
- package/src/viewer/Opening.tsx +43 -0
- package/src/viewer/bundles.ts +74 -0
- package/src/viewer/client.ts +54 -0
- package/src/viewer/embed.ts +83 -0
- package/src/viewer/faker-en.ts +3 -0
- package/src/viewer/main.tsx +289 -0
- package/src/viewer/mount.ts +52 -0
- package/src/viewer/protocol.ts +54 -0
- package/src/viewer/recent.ts +22 -0
- package/src/viewer/registry-data.ts +8 -0
- package/src/viewer/registry.ts +163 -0
- package/src/viewer/share.ts +134 -0
- package/src/viewer/site.ts +3 -0
- package/src/viewer/uploads.ts +53 -0
- package/src/viewer/viewer.css +184 -0
- package/src/viewer/worker-factory.ts +14 -0
- package/src/viewer/worker.ts +176 -0
- package/src/virtual.d.ts +104 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface Graphql {
|
|
2
|
+
query: string
|
|
3
|
+
variables: string
|
|
4
|
+
rest: Record<string, unknown>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** A GraphQL body is `{ query, variables?, operationName? }`; split it so each part gets its own editor. */
|
|
8
|
+
export function splitGraphql(body: string): Graphql | undefined {
|
|
9
|
+
try {
|
|
10
|
+
const { query, variables, ...rest } = JSON.parse(body)
|
|
11
|
+
if (typeof query !== 'string') return undefined
|
|
12
|
+
return {
|
|
13
|
+
query,
|
|
14
|
+
variables: variables === undefined ? '' : JSON.stringify(variables, null, 2),
|
|
15
|
+
rest,
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function joinGraphql(g: Graphql): string {
|
|
23
|
+
let variables: unknown
|
|
24
|
+
try {
|
|
25
|
+
variables = g.variables.trim() ? JSON.parse(g.variables) : undefined
|
|
26
|
+
} catch {
|
|
27
|
+
// keep what was typed so the body stays editable; the panel reports invalid JSON
|
|
28
|
+
variables = g.variables
|
|
29
|
+
}
|
|
30
|
+
return JSON.stringify({ query: g.query, variables, ...g.rest }, null, 2)
|
|
31
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** A paper plane pointing right, the usual "send" mark. SVG because Unicode has no paper-plane emoji. */
|
|
2
|
+
export function PaperPlane({ size = 16 }: { size?: number }) {
|
|
3
|
+
return (
|
|
4
|
+
<svg
|
|
5
|
+
viewBox="0 0 24 24"
|
|
6
|
+
width={size}
|
|
7
|
+
height={size}
|
|
8
|
+
fill="none"
|
|
9
|
+
stroke="currentColor"
|
|
10
|
+
stroke-width="1.8"
|
|
11
|
+
stroke-linecap="round"
|
|
12
|
+
stroke-linejoin="round"
|
|
13
|
+
aria-hidden="true"
|
|
14
|
+
>
|
|
15
|
+
<path d="M3 3l19 9-19 9 3.5-9z" />
|
|
16
|
+
<path d="M6.5 12H14" />
|
|
17
|
+
</svg>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A padlock: the operation needs credentials. */
|
|
22
|
+
export function Lock({ size = 12 }: { size?: number }) {
|
|
23
|
+
return (
|
|
24
|
+
<svg
|
|
25
|
+
viewBox="0 0 24 24"
|
|
26
|
+
width={size}
|
|
27
|
+
height={size}
|
|
28
|
+
fill="none"
|
|
29
|
+
stroke="currentColor"
|
|
30
|
+
stroke-width="2"
|
|
31
|
+
stroke-linecap="round"
|
|
32
|
+
stroke-linejoin="round"
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
>
|
|
35
|
+
<rect x="4" y="11" width="16" height="10" rx="2" />
|
|
36
|
+
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
|
37
|
+
</svg>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The share mark of Apple's platforms and most browsers: an arrow rising out of a tray. */
|
|
42
|
+
export function ShareIcon({ size = 16 }: { size?: number }) {
|
|
43
|
+
return (
|
|
44
|
+
<svg
|
|
45
|
+
viewBox="0 0 24 24"
|
|
46
|
+
width={size}
|
|
47
|
+
height={size}
|
|
48
|
+
fill="none"
|
|
49
|
+
stroke="currentColor"
|
|
50
|
+
stroke-width="1.8"
|
|
51
|
+
stroke-linecap="round"
|
|
52
|
+
stroke-linejoin="round"
|
|
53
|
+
aria-hidden="true"
|
|
54
|
+
>
|
|
55
|
+
<path d="M12 15V3" />
|
|
56
|
+
<path d="M7.5 7.5 12 3l4.5 4.5" />
|
|
57
|
+
<path d="M5 11H4.5A1.5 1.5 0 0 0 3 12.5v7A1.5 1.5 0 0 0 4.5 21h15a1.5 1.5 0 0 0 1.5-1.5v-7a1.5 1.5 0 0 0-1.5-1.5H19" />
|
|
58
|
+
</svg>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function GitHubMark({ size = 16 }: { size?: number }) {
|
|
63
|
+
return (
|
|
64
|
+
<svg viewBox="0 0 16 16" width={size} height={size} fill="currentColor" aria-hidden="true">
|
|
65
|
+
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.6 7.6 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
|
|
66
|
+
</svg>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function StarIcon({ size = 14 }: { size?: number }) {
|
|
71
|
+
return (
|
|
72
|
+
<svg
|
|
73
|
+
viewBox="0 0 24 24"
|
|
74
|
+
width={size}
|
|
75
|
+
height={size}
|
|
76
|
+
fill="none"
|
|
77
|
+
stroke="currentColor"
|
|
78
|
+
stroke-width="1.8"
|
|
79
|
+
stroke-linejoin="round"
|
|
80
|
+
aria-hidden="true"
|
|
81
|
+
>
|
|
82
|
+
<path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" />
|
|
83
|
+
</svg>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { KeyPath } from '../schema'
|
|
2
|
+
|
|
3
|
+
export interface JsonCursor {
|
|
4
|
+
/** `key`: completing a property name of the object at `path`. `value`: completing the value at `path`. */
|
|
5
|
+
mode: 'key' | 'value'
|
|
6
|
+
path: KeyPath
|
|
7
|
+
/** the range a completion replaces: a whole string (quotes included) or the word being typed */
|
|
8
|
+
from: number
|
|
9
|
+
to: number
|
|
10
|
+
inString: boolean
|
|
11
|
+
/** what was typed so far inside the string or word */
|
|
12
|
+
prefix: string
|
|
13
|
+
/** key mode: properties the object already has */
|
|
14
|
+
existing: Set<string>
|
|
15
|
+
/** key mode in a string: a `:` already follows, so only the key is replaced */
|
|
16
|
+
hasColon: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Frame =
|
|
20
|
+
| { t: 'o'; key?: string; expectKey: boolean; keys: Set<string> }
|
|
21
|
+
| { t: 'a'; index: number }
|
|
22
|
+
|
|
23
|
+
const WORD = /[\w$.+-]/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where the cursor is in a (possibly unfinished) JSON document: which object or value, whether a key
|
|
27
|
+
* or a value is being written, and what is already there. Tolerates broken JSON, since the user is
|
|
28
|
+
* mid-edit. Returns undefined at the document root or where nothing can be completed.
|
|
29
|
+
*/
|
|
30
|
+
export function jsonCursor(text: string, pos: number): JsonCursor | undefined {
|
|
31
|
+
const stack: Frame[] = []
|
|
32
|
+
const top = () => stack[stack.length - 1]
|
|
33
|
+
let i = 0
|
|
34
|
+
while (i < pos) {
|
|
35
|
+
const c = text[i]
|
|
36
|
+
if (c === '"') {
|
|
37
|
+
let j = i + 1
|
|
38
|
+
let value = ''
|
|
39
|
+
while (j < text.length && text[j] !== '"' && text[j] !== '\n') {
|
|
40
|
+
if (text[j] === '\\') {
|
|
41
|
+
value += text[j + 1] ?? ''
|
|
42
|
+
j += 2
|
|
43
|
+
} else value += text[j++]
|
|
44
|
+
}
|
|
45
|
+
const closed = text[j] === '"'
|
|
46
|
+
const t = top()
|
|
47
|
+
if (pos <= j || (!closed && pos <= text.length && j >= pos)) {
|
|
48
|
+
// the cursor is inside this string
|
|
49
|
+
const isKey = t?.t === 'o' && t.expectKey
|
|
50
|
+
const to = closed ? j + 1 : pos
|
|
51
|
+
let after = to
|
|
52
|
+
while (/\s/.test(text[after] ?? '')) after++
|
|
53
|
+
return finish({
|
|
54
|
+
mode: isKey ? 'key' : 'value',
|
|
55
|
+
from: i,
|
|
56
|
+
to,
|
|
57
|
+
inString: true,
|
|
58
|
+
prefix: text.slice(i + 1, pos),
|
|
59
|
+
hasColon: text[after] === ':',
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
if (t?.t === 'o' && t.expectKey) {
|
|
63
|
+
t.key = value
|
|
64
|
+
t.keys.add(value)
|
|
65
|
+
t.expectKey = false
|
|
66
|
+
}
|
|
67
|
+
i = j + 1
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (c === '{') stack.push({ t: 'o', expectKey: true, keys: new Set() })
|
|
71
|
+
else if (c === '[') stack.push({ t: 'a', index: 0 })
|
|
72
|
+
else if (c === '}' || c === ']') stack.pop()
|
|
73
|
+
else if (c === ',') {
|
|
74
|
+
const t = top()
|
|
75
|
+
if (t?.t === 'o') {
|
|
76
|
+
t.expectKey = true
|
|
77
|
+
t.key = undefined
|
|
78
|
+
} else if (t?.t === 'a') t.index++
|
|
79
|
+
}
|
|
80
|
+
i++
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// not inside a string: the word being typed, if any
|
|
84
|
+
let from = pos
|
|
85
|
+
while (from > 0 && WORD.test(text[from - 1] ?? '')) from--
|
|
86
|
+
let to = pos
|
|
87
|
+
while (to < text.length && WORD.test(text[to] ?? '')) to++
|
|
88
|
+
const t = top()
|
|
89
|
+
return finish({
|
|
90
|
+
mode: t?.t === 'o' && t.expectKey ? 'key' : 'value',
|
|
91
|
+
from,
|
|
92
|
+
to,
|
|
93
|
+
inString: false,
|
|
94
|
+
prefix: text.slice(from, pos),
|
|
95
|
+
hasColon: false,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
function finish(
|
|
99
|
+
c: Pick<JsonCursor, 'mode' | 'from' | 'to' | 'inString' | 'prefix' | 'hasColon'>,
|
|
100
|
+
) {
|
|
101
|
+
if (stack.length === 0) return undefined
|
|
102
|
+
const path: KeyPath = []
|
|
103
|
+
for (let k = 0; k < stack.length; k++) {
|
|
104
|
+
const f = stack[k] as Frame
|
|
105
|
+
const last = k === stack.length - 1
|
|
106
|
+
if (f.t === 'o') {
|
|
107
|
+
if (last && c.mode === 'key') break
|
|
108
|
+
if (f.key === undefined) return undefined
|
|
109
|
+
path.push(f.key)
|
|
110
|
+
} else path.push(f.index)
|
|
111
|
+
}
|
|
112
|
+
const f = top()
|
|
113
|
+
return { ...c, path, existing: f?.t === 'o' ? f.keys : new Set<string>() }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface SequenceNotes {
|
|
2
|
+
/** the page without the sections that belong to steps */
|
|
3
|
+
intro: string
|
|
4
|
+
/** step name -> HTML of the section headed by that name */
|
|
5
|
+
notes: Map<string, string>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** The text of a heading, without tags or entities. */
|
|
9
|
+
const plain = (h: string) =>
|
|
10
|
+
h
|
|
11
|
+
.replace(/<[^>]+>/g, '')
|
|
12
|
+
.replace(/&/g, '&')
|
|
13
|
+
.replace(/</g, '<')
|
|
14
|
+
.replace(/>/g, '>')
|
|
15
|
+
.replace(/"/g, '"')
|
|
16
|
+
.replace(/'/g, "'")
|
|
17
|
+
|
|
18
|
+
const norm = (s: string) => s.replace(/\s+/g, ' ').trim().toLowerCase()
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A `## Heading` that names a step becomes that step's note (everything up to the next `##`).
|
|
22
|
+
* Anything else stays in the intro, so ordinary headings keep working.
|
|
23
|
+
*/
|
|
24
|
+
export function splitStepNotes(html: string, stepNames: string[]): SequenceNotes {
|
|
25
|
+
const notes = new Map<string, string>()
|
|
26
|
+
if (!html || stepNames.length === 0) return { intro: html, notes }
|
|
27
|
+
const wanted = new Map(stepNames.map((n) => [norm(n), n]))
|
|
28
|
+
const intro: string[] = []
|
|
29
|
+
// the page is rendered markdown, so every `##` is an <h2> at the top level
|
|
30
|
+
for (const chunk of html.split(/(?=<h2[ >])/)) {
|
|
31
|
+
const m = /^<h2[^>]*>([\s\S]*?)<\/h2>/.exec(chunk)
|
|
32
|
+
const name = m ? wanted.get(norm(plain(m[1] as string))) : undefined
|
|
33
|
+
if (m && name !== undefined) {
|
|
34
|
+
if (!notes.has(name)) notes.set(name, chunk.slice(m[0].length))
|
|
35
|
+
} else intro.push(chunk)
|
|
36
|
+
}
|
|
37
|
+
return { intro: intro.join(''), notes }
|
|
38
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'preact/hooks'
|
|
2
|
+
import type { SequenceInfo } from '../engine'
|
|
3
|
+
import { markdownToHtml } from '../markdown'
|
|
4
|
+
import { registry } from '../runtime'
|
|
5
|
+
|
|
6
|
+
export { type SequenceNotes, splitStepNotes } from './stepnotes-split'
|
|
7
|
+
|
|
8
|
+
import { type SequenceNotes, splitStepNotes } from './stepnotes-split'
|
|
9
|
+
|
|
10
|
+
/** The sequence's markdown page (or its description) split into an intro and per-step notes. */
|
|
11
|
+
export function useSequenceNotes(info: SequenceInfo): SequenceNotes & { loaded: boolean } {
|
|
12
|
+
const { seq } = info
|
|
13
|
+
const [html, setHtml] = useState<string>()
|
|
14
|
+
const [loaded, setLoaded] = useState(false)
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let live = true
|
|
17
|
+
setHtml(undefined)
|
|
18
|
+
setLoaded(false)
|
|
19
|
+
const page = registry.docs.find(
|
|
20
|
+
(d) => d.sequence === seq.meta.id || d.sequenceFile === info.file,
|
|
21
|
+
)
|
|
22
|
+
if (!page) {
|
|
23
|
+
setLoaded(true)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
page
|
|
27
|
+
.load()
|
|
28
|
+
.then((h: string) => live && setHtml(h))
|
|
29
|
+
.finally(() => live && setLoaded(true))
|
|
30
|
+
return () => {
|
|
31
|
+
live = false
|
|
32
|
+
}
|
|
33
|
+
}, [seq.meta.id, info.file])
|
|
34
|
+
|
|
35
|
+
const names = useMemo(() => seq.nodes.map((n) => n.name), [seq])
|
|
36
|
+
return useMemo(() => {
|
|
37
|
+
const source = html ?? markdownToHtml(seq.meta.description)
|
|
38
|
+
return { ...splitStepNotes(source, names), loaded }
|
|
39
|
+
}, [html, names, seq, loaded])
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The markdown beside a saved request (`<name>.req.md`), rendered. */
|
|
43
|
+
export function useRequestNote(file: string | undefined): string | undefined {
|
|
44
|
+
const [html, setHtml] = useState<string>()
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
let live = true
|
|
47
|
+
setHtml(undefined)
|
|
48
|
+
if (file)
|
|
49
|
+
registry.docs
|
|
50
|
+
.find((d) => d.requestFile === file)
|
|
51
|
+
?.load()
|
|
52
|
+
.then((h: string) => live && setHtml(h))
|
|
53
|
+
return () => {
|
|
54
|
+
live = false
|
|
55
|
+
}
|
|
56
|
+
}, [file])
|
|
57
|
+
return html
|
|
58
|
+
}
|
package/src/routing.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MissingSecretsError,
|
|
3
|
+
type PreparedRequest,
|
|
4
|
+
secretNames,
|
|
5
|
+
substituteText,
|
|
6
|
+
type Transport,
|
|
7
|
+
} from '@specbook/core'
|
|
8
|
+
import { originOf, SECRET_PLACEHOLDER, type SecretStore } from './secrets'
|
|
9
|
+
|
|
10
|
+
/** Who makes the outbound call for a service. */
|
|
11
|
+
export type Route = 'direct' | 'proxy' | 'extension'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Per service and per request:
|
|
15
|
+
* - a configured proxy (the dev server, or a hosted portal's same-origin route) takes every call;
|
|
16
|
+
* - otherwise a same-origin API is called straight from the page (no extension, cookies just work);
|
|
17
|
+
* - otherwise the extension, when it is installed and enabled, bypasses CORS;
|
|
18
|
+
* - otherwise a direct call, which may be blocked by CORS and says so.
|
|
19
|
+
*/
|
|
20
|
+
export function routeFor(o: {
|
|
21
|
+
proxy: boolean
|
|
22
|
+
extension: boolean
|
|
23
|
+
baseUrl: string
|
|
24
|
+
pageOrigin?: string
|
|
25
|
+
}): Route {
|
|
26
|
+
if (o.proxy) return 'proxy'
|
|
27
|
+
if (o.pageOrigin && originOf(o.baseUrl) === o.pageOrigin) return 'direct'
|
|
28
|
+
if (o.extension) return 'extension'
|
|
29
|
+
return 'direct'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A config `baseUrl` may be relative (`/api/v1`): it is resolved against the page. */
|
|
33
|
+
export function absoluteBase(baseUrl: string, pageHref?: string): string {
|
|
34
|
+
try {
|
|
35
|
+
return new URL(baseUrl, pageHref).toString()
|
|
36
|
+
} catch {
|
|
37
|
+
return baseUrl
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const names = (req: PreparedRequest): string[] => {
|
|
42
|
+
const out = new Set<string>()
|
|
43
|
+
const scan = (v: unknown) => {
|
|
44
|
+
if (typeof v === 'string') for (const n of secretNames(new TextEncoder().encode(v))) out.add(n)
|
|
45
|
+
else if (Array.isArray(v)) v.forEach(scan)
|
|
46
|
+
else if (v && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype)
|
|
47
|
+
Object.values(v).forEach(scan)
|
|
48
|
+
}
|
|
49
|
+
scan(req.headers)
|
|
50
|
+
scan(req.query)
|
|
51
|
+
scan(req.body)
|
|
52
|
+
return [...out]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Puts the values the person typed into a request, bound to the API's origin. A name with no value is
|
|
57
|
+
* left as a placeholder for whoever executes the call (the dev server falls back to its environment
|
|
58
|
+
* files, the extension to its own store); a direct call has no one to fall back on, so it stops there.
|
|
59
|
+
*/
|
|
60
|
+
export function withPageSecrets(
|
|
61
|
+
inner: Transport,
|
|
62
|
+
o: {
|
|
63
|
+
store: SecretStore
|
|
64
|
+
originFor(service: string): string | undefined
|
|
65
|
+
unresolved: 'keep' | 'fail'
|
|
66
|
+
},
|
|
67
|
+
): Transport {
|
|
68
|
+
return (req, signal) => {
|
|
69
|
+
const asked = names(req)
|
|
70
|
+
if (asked.length === 0) return inner(req, signal)
|
|
71
|
+
const origin = o.originFor(req.service)
|
|
72
|
+
const values = new Map<string, string>()
|
|
73
|
+
const missing: string[] = []
|
|
74
|
+
for (const n of asked) {
|
|
75
|
+
const v = origin ? o.store.get(origin, n) : undefined
|
|
76
|
+
if (v === undefined) missing.push(n)
|
|
77
|
+
else values.set(n, v)
|
|
78
|
+
}
|
|
79
|
+
if (missing.length && o.unresolved === 'fail') throw new MissingSecretsError(missing)
|
|
80
|
+
const walk = (v: unknown): unknown =>
|
|
81
|
+
typeof v === 'string'
|
|
82
|
+
? substituteText(v, values)
|
|
83
|
+
: Array.isArray(v)
|
|
84
|
+
? v.map(walk)
|
|
85
|
+
: v && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype
|
|
86
|
+
? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]))
|
|
87
|
+
: v
|
|
88
|
+
return inner(
|
|
89
|
+
{
|
|
90
|
+
...req,
|
|
91
|
+
headers: walk(req.headers) as typeof req.headers,
|
|
92
|
+
query: walk(req.query) as typeof req.query,
|
|
93
|
+
body: walk(req.body),
|
|
94
|
+
},
|
|
95
|
+
signal,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function routingTransport(o: {
|
|
101
|
+
direct: Transport
|
|
102
|
+
proxy?: Transport
|
|
103
|
+
extension?: Transport
|
|
104
|
+
baseOf(service: string): string
|
|
105
|
+
pageOrigin?: string
|
|
106
|
+
}): Transport {
|
|
107
|
+
return (req, signal) => {
|
|
108
|
+
const route = routeFor({
|
|
109
|
+
proxy: !!o.proxy,
|
|
110
|
+
extension: !!o.extension,
|
|
111
|
+
baseUrl: o.baseOf(req.service),
|
|
112
|
+
pageOrigin: o.pageOrigin,
|
|
113
|
+
})
|
|
114
|
+
const send = route === 'proxy' ? o.proxy : route === 'extension' ? o.extension : o.direct
|
|
115
|
+
return (send ?? o.direct)(req, signal)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Re-exported for the credentials prompt. */
|
|
120
|
+
export { SECRET_PLACEHOLDER }
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as reg from 'virtual:specbook/registry'
|
|
2
|
+
import { createEngine, pickRunnable } from './engine'
|
|
3
|
+
import { detectExtension, type ExtensionBridge } from './extension'
|
|
4
|
+
import { version } from './store'
|
|
5
|
+
|
|
6
|
+
export const registry = reg
|
|
7
|
+
|
|
8
|
+
// looking for the extension costs a short wait when it is absent, so a run does not repeat it
|
|
9
|
+
let found: { at: number; bridge: ExtensionBridge | undefined } | undefined
|
|
10
|
+
const extension = async () => {
|
|
11
|
+
if (found && Date.now() - found.at < 30_000) return found.bridge
|
|
12
|
+
const bridge = await detectExtension()
|
|
13
|
+
found = { at: Date.now(), bridge }
|
|
14
|
+
return bridge
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const engine = createEngine(reg as never, { extension })
|
|
18
|
+
|
|
19
|
+
export const isStatic = reg.mode === 'static'
|
|
20
|
+
|
|
21
|
+
/** Dev only: writes a project file through the plugin, then busts import caches. */
|
|
22
|
+
export async function saveFile(path: string, content: string): Promise<void> {
|
|
23
|
+
const r = await fetch('/__specbook/save', {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'content-type': 'application/json' },
|
|
26
|
+
body: JSON.stringify({ path, content }),
|
|
27
|
+
})
|
|
28
|
+
if (!r.ok) throw new Error(((await r.json()) as { error?: string }).error ?? 'save failed')
|
|
29
|
+
version.value = Date.now()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { pickRunnable }
|