@specbook/vite 0.0.0-stage → 0.1.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 +110 -0
- package/README.md +6 -2
- package/dist/index.js +1348 -0
- package/package.json +58 -3
- package/src/docs.ts +102 -0
- package/src/environments.ts +114 -0
- package/src/examples.ts +77 -0
- package/src/files.ts +59 -0
- package/src/index.ts +722 -0
- package/src/proxy.ts +92 -0
- package/src/sequences.ts +135 -0
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import { handleProxy as proxy } from '@specbook/core'
|
|
3
|
+
|
|
4
|
+
export interface ProxyConfig {
|
|
5
|
+
services: Record<string, { baseUrl: Record<string, string | undefined> }>
|
|
6
|
+
secretNames: Record<string, { env?: string }>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ProxyDeps {
|
|
10
|
+
getConfig(): Promise<ProxyConfig>
|
|
11
|
+
/** Secrets from files for `env`: the environment's own file over the shared one. */
|
|
12
|
+
readSecrets(env: string): Promise<Record<string, string>>
|
|
13
|
+
env: Record<string, string | undefined>
|
|
14
|
+
/** Secrets typed into the UI this session, per environment (`*` = any); held in memory only. */
|
|
15
|
+
memory?: Record<string, Record<string, string>>
|
|
16
|
+
fetch?: typeof fetch
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MOUNT = '/__specbook/proxy'
|
|
20
|
+
|
|
21
|
+
function readBody(req: IncomingMessage): Promise<Uint8Array> {
|
|
22
|
+
return new Promise((res, rej) => {
|
|
23
|
+
const parts: Buffer[] = []
|
|
24
|
+
req.on('data', (c: Buffer) => parts.push(c))
|
|
25
|
+
req.on('end', () => res(new Uint8Array(Buffer.concat(parts))))
|
|
26
|
+
req.on('error', rej)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The dev server's adapter around the shared proxy in `@specbook/core`: it turns a Node request into a
|
|
32
|
+
* `Request`, supplies the project's upstreams and the secret lookup, and writes the `Response` back.
|
|
33
|
+
* What may be forwarded (private hosts, cross-site requests, limits) is decided in core, the same code a
|
|
34
|
+
* hosted Worker and the extension run.
|
|
35
|
+
*
|
|
36
|
+
* Lookup order for `{{secrets.NAME}}`: a value saved in this session for the environment, one for any
|
|
37
|
+
* environment, a process environment variable, the environment's own secrets file, the shared file.
|
|
38
|
+
* (A value the page itself already put in the request is not a placeholder any more, so it wins.)
|
|
39
|
+
*/
|
|
40
|
+
export async function handleProxy(
|
|
41
|
+
req: IncomingMessage,
|
|
42
|
+
res: ServerResponse,
|
|
43
|
+
deps: ProxyDeps,
|
|
44
|
+
): Promise<boolean> {
|
|
45
|
+
const url = req.url ?? '/'
|
|
46
|
+
if (!url.startsWith(`${MOUNT}/`)) return false
|
|
47
|
+
|
|
48
|
+
const config = await deps.getConfig()
|
|
49
|
+
const secretsByEnv = new Map<string, Record<string, string>>()
|
|
50
|
+
const method = req.method ?? 'GET'
|
|
51
|
+
const headers = new Headers()
|
|
52
|
+
for (const [k, v] of Object.entries(req.headers))
|
|
53
|
+
if (v !== undefined) headers.set(k, Array.isArray(v) ? v.join(', ') : v)
|
|
54
|
+
const body = method === 'GET' || method === 'HEAD' ? undefined : await readBody(req)
|
|
55
|
+
const request = new Request(`http://${req.headers.host ?? 'localhost'}${url}`, {
|
|
56
|
+
method,
|
|
57
|
+
headers,
|
|
58
|
+
body: body?.length ? (body as BodyInit) : undefined,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const response = await proxy(request, {
|
|
62
|
+
mount: MOUNT,
|
|
63
|
+
policy: {
|
|
64
|
+
upstream: (env, service) => config.services[service]?.baseUrl[env],
|
|
65
|
+
// a developer's own machine: their local services are the point
|
|
66
|
+
allowPrivateHosts: true,
|
|
67
|
+
},
|
|
68
|
+
lookup: async (name, ctx) => {
|
|
69
|
+
const env = ctx.env ?? ''
|
|
70
|
+
const key = config.secretNames[name]?.env ?? name
|
|
71
|
+
const fromMemory = deps.memory?.[env]?.[name] ?? deps.memory?.['*']?.[name]
|
|
72
|
+
if (fromMemory !== undefined) return fromMemory
|
|
73
|
+
if (deps.env[key] !== undefined) return deps.env[key]
|
|
74
|
+
let local = secretsByEnv.get(env)
|
|
75
|
+
if (!local) {
|
|
76
|
+
local = await deps.readSecrets(env)
|
|
77
|
+
secretsByEnv.set(env, local)
|
|
78
|
+
}
|
|
79
|
+
return local[key]
|
|
80
|
+
},
|
|
81
|
+
fetch: deps.fetch,
|
|
82
|
+
})
|
|
83
|
+
if (!response) return false
|
|
84
|
+
|
|
85
|
+
res.statusCode = response.status
|
|
86
|
+
response.headers.forEach((v, k) => {
|
|
87
|
+
res.setHeader(k, v)
|
|
88
|
+
})
|
|
89
|
+
if (response.body) for await (const chunk of response.body) res.write(chunk)
|
|
90
|
+
res.end()
|
|
91
|
+
return true
|
|
92
|
+
}
|
package/src/sequences.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface SequenceMeta {
|
|
5
|
+
id: string
|
|
6
|
+
title: string
|
|
7
|
+
description?: string
|
|
8
|
+
tags?: string[]
|
|
9
|
+
group?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const KEYS = ['id', 'title', 'description', 'group', 'tags'] as const
|
|
13
|
+
|
|
14
|
+
/** Reads a quoted string literal at `i`, returning its value and the index after it. */
|
|
15
|
+
function readString(src: string, i: number): [string, number] | undefined {
|
|
16
|
+
const q = src[i]
|
|
17
|
+
if (q !== "'" && q !== '"' && q !== '`') return undefined
|
|
18
|
+
let out = ''
|
|
19
|
+
for (let j = i + 1; j < src.length; j++) {
|
|
20
|
+
const c = src[j] as string
|
|
21
|
+
if (c === '\\') {
|
|
22
|
+
const n = src[j + 1] as string
|
|
23
|
+
out += n === 'n' ? '\n' : n === 't' ? '\t' : n
|
|
24
|
+
j++
|
|
25
|
+
} else if (c === q) return [out, j + 1]
|
|
26
|
+
else if (q === '`' && c === '$' && src[j + 1] === '{') return undefined
|
|
27
|
+
else out += c
|
|
28
|
+
}
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const skipSpace = (src: string, i: number) => {
|
|
33
|
+
while (i < src.length) {
|
|
34
|
+
if (/\s/.test(src[i] as string)) i++
|
|
35
|
+
else if (src.startsWith('//', i))
|
|
36
|
+
i = src.indexOf('\n', i) < 0 ? src.length : src.indexOf('\n', i)
|
|
37
|
+
else if (src.startsWith('/*', i))
|
|
38
|
+
i = src.indexOf('*/', i) < 0 ? src.length : src.indexOf('*/', i) + 2
|
|
39
|
+
else break
|
|
40
|
+
}
|
|
41
|
+
return i
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Index after the value starting at `i` (strings, brackets, and anything up to the next `,`/`}`). */
|
|
45
|
+
function skipValue(src: string, i: number): number {
|
|
46
|
+
let depth = 0
|
|
47
|
+
for (let j = i; j < src.length; j++) {
|
|
48
|
+
const c = src[j] as string
|
|
49
|
+
const s = readString(src, j)
|
|
50
|
+
if (s) j = s[1] - 1
|
|
51
|
+
else if (c === '`' || c === "'" || c === '"') return src.length
|
|
52
|
+
else if ('([{'.includes(c)) depth++
|
|
53
|
+
else if (')]}'.includes(c)) {
|
|
54
|
+
if (depth === 0) return j
|
|
55
|
+
depth--
|
|
56
|
+
} else if (c === ',' && depth === 0) return j
|
|
57
|
+
}
|
|
58
|
+
return src.length
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pulls `id`, `title`, `description`, `group` and `tags` out of the `sequence({ ... })` call in a `.seq.ts`
|
|
63
|
+
* without importing it. Returns undefined when any of them is not a plain literal (computed titles,
|
|
64
|
+
* concatenation, ...), so the caller can import that file instead.
|
|
65
|
+
*/
|
|
66
|
+
export function parseSequenceMeta(src: string): SequenceMeta | undefined {
|
|
67
|
+
const call = /\bsequence\s*(?:<[^>]*>)?\s*\(\s*\{/.exec(src)
|
|
68
|
+
if (!call) return undefined
|
|
69
|
+
const found: Record<string, unknown> = {}
|
|
70
|
+
let i = call.index + call[0].length
|
|
71
|
+
for (;;) {
|
|
72
|
+
i = skipSpace(src, i)
|
|
73
|
+
if (i >= src.length || src[i] === '}') break
|
|
74
|
+
const key = /^[A-Za-z_$][\w$]*|^(['"])(.*?)\1/.exec(src.slice(i))
|
|
75
|
+
if (!key) return undefined
|
|
76
|
+
const name = key[2] ?? key[0]
|
|
77
|
+
i = skipSpace(src, i + key[0].length)
|
|
78
|
+
if (src[i] !== ':') {
|
|
79
|
+
// shorthand `id,` or a method; either way not a literal
|
|
80
|
+
if ((KEYS as readonly string[]).includes(name)) return undefined
|
|
81
|
+
i = skipValue(src, i)
|
|
82
|
+
} else {
|
|
83
|
+
i = skipSpace(src, i + 1)
|
|
84
|
+
if ((KEYS as readonly string[]).includes(name)) {
|
|
85
|
+
const str = readString(src, i)
|
|
86
|
+
if (str) {
|
|
87
|
+
found[name] = str[0]
|
|
88
|
+
i = str[1]
|
|
89
|
+
} else if (src[i] === '[' && name === 'tags') {
|
|
90
|
+
const tags: string[] = []
|
|
91
|
+
i++
|
|
92
|
+
for (;;) {
|
|
93
|
+
i = skipSpace(src, i)
|
|
94
|
+
if (src[i] === ']') {
|
|
95
|
+
i++
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
const t = readString(src, i)
|
|
99
|
+
if (!t) return undefined
|
|
100
|
+
tags.push(t[0])
|
|
101
|
+
i = skipSpace(src, t[1])
|
|
102
|
+
if (src[i] === ',') i++
|
|
103
|
+
}
|
|
104
|
+
found[name] = tags
|
|
105
|
+
} else return undefined
|
|
106
|
+
i = skipSpace(src, i)
|
|
107
|
+
if (src[i] !== ',' && src[i] !== '}') return undefined // e.g. 'a' + 'b'
|
|
108
|
+
} else i = skipValue(src, i)
|
|
109
|
+
}
|
|
110
|
+
i = skipSpace(src, i)
|
|
111
|
+
if (src[i] === ',') i++
|
|
112
|
+
}
|
|
113
|
+
if (typeof found.id !== 'string' || typeof found.title !== 'string') return undefined
|
|
114
|
+
return found as unknown as SequenceMeta
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface SequenceScan {
|
|
118
|
+
/** keyed by id, with the project-relative file it came from */
|
|
119
|
+
meta: Record<string, SequenceMeta & { file: string }>
|
|
120
|
+
/** files whose header is not plain literals; the UI imports these */
|
|
121
|
+
unresolved: string[]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function scanSequences(root: string, files: string[]): Promise<SequenceScan> {
|
|
125
|
+
const meta: SequenceScan['meta'] = {}
|
|
126
|
+
const unresolved: string[] = []
|
|
127
|
+
await Promise.all(
|
|
128
|
+
files.map(async (file) => {
|
|
129
|
+
const parsed = parseSequenceMeta(await readFile(join(root, file), 'utf8').catch(() => ''))
|
|
130
|
+
if (parsed) meta[parsed.id] = { ...parsed, file }
|
|
131
|
+
else unresolved.push(file)
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
return { meta, unresolved: unresolved.sort() }
|
|
135
|
+
}
|