@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/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
+ }
@@ -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
+ }