@bakery-framework/core 1.2.3 → 2.0.0-alpha.1
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/package.json +1 -1
- package/src/cache/shared-db.ts +7 -9
- package/src/client/globals.d.ts +1 -1
- package/src/client/utils.ts +41 -9
- package/src/compiler/compiler.ts +458 -39
- package/src/compiler/prompt-tracker.ts +19 -2
- package/src/core/bakery.ts +11 -12
- package/src/core/context.ts +42 -8
- package/src/core/index.ts +1 -0
- package/src/core/init.ts +6 -0
- package/src/handlers/assets/nm.ts +74 -15
- package/src/handlers/core/$base.ts +54 -8
- package/src/handlers/core/$dynamic.ts +21 -10
- package/src/handlers/core/$routing.ts +57 -8
- package/src/logger/serve-log.ts +15 -0
- package/src/session.ts +0 -3
- package/src/shared.d.ts +7 -0
- package/src/types.d.ts +11 -0
- package/src/utils/http/credential.ts +70 -0
- package/src/utils/http/dom.ts +73 -64
- package/src/utils/http/html.ts +4 -4
- package/src/utils/http/index.ts +1 -0
- package/src/utils/isomorphic/misc.ts +16 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-credential check for plugin surfaces (`plugin({ credential:
|
|
3
|
+
* import.meta.env.SOME_KEY })`).
|
|
4
|
+
*
|
|
5
|
+
* One implementation, deliberately in core: the db-explorer and analytics
|
|
6
|
+
* plugins each need it and per-plugin copies of security code drift. It owns
|
|
7
|
+
* *only* the comparison — no logins, no sessions, no backoff.
|
|
8
|
+
*
|
|
9
|
+
* A credential is presented three ways, in this order of preference:
|
|
10
|
+
*
|
|
11
|
+
* - `x-<name>` header (scriptable, never logged by default)
|
|
12
|
+
* - `Authorization: Bearer <credential>`
|
|
13
|
+
* - `?<name>=<credential>` query, for a human opening a URL in a browser
|
|
14
|
+
* who cannot set a header — the caller is expected to strip it from the
|
|
15
|
+
* URL client-side; it *does* reach server logs, which is documented, and
|
|
16
|
+
* the header forms exist for anything automated.
|
|
17
|
+
*
|
|
18
|
+
* An empty or missing configured credential **disables** this path rather
|
|
19
|
+
* than matching everything: `credential: import.meta.env.KEY` with the
|
|
20
|
+
* variable unset must mean "off", not "open".
|
|
21
|
+
*
|
|
22
|
+
* The compare is constant-time. A plain `===` on a secret is a timing oracle,
|
|
23
|
+
* and `timingSafeEqual` costs one line — but it throws on length mismatch, so
|
|
24
|
+
* the length is checked first (which leaks only the length, never the bytes).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
28
|
+
|
|
29
|
+
export function credentialMatches(
|
|
30
|
+
configured: string | undefined,
|
|
31
|
+
presented: string | null | undefined,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (!configured || !presented) return false
|
|
34
|
+
|
|
35
|
+
const a = Buffer.from(configured)
|
|
36
|
+
const b = Buffer.from(presented)
|
|
37
|
+
if (a.length !== b.length) return false
|
|
38
|
+
return timingSafeEqual(a, b)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pull a presented credential off a request: the `x-<name>` header, a Bearer
|
|
43
|
+
* token, or the `<name>` query parameter, in that order. Returns `null` when
|
|
44
|
+
* none is present. `name` is the header/query key — `db-key`, `analytics-key`.
|
|
45
|
+
*/
|
|
46
|
+
export function readCredential(req: Request, name: string): string | null {
|
|
47
|
+
const header = req.headers.get(`x-${name}`)
|
|
48
|
+
if (header) return header
|
|
49
|
+
|
|
50
|
+
const bearer = req.headers
|
|
51
|
+
.get('authorization')
|
|
52
|
+
?.replace(/^Bearer\s+/i, '')
|
|
53
|
+
.trim()
|
|
54
|
+
if (bearer) return bearer
|
|
55
|
+
|
|
56
|
+
return new URL(req.url).searchParams.get(name)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The whole check in one call: does the request present `configured` under
|
|
61
|
+
* `name`? `false` for an unset credential or an absent presentation.
|
|
62
|
+
*/
|
|
63
|
+
export function requestHasCredential(
|
|
64
|
+
req: Request,
|
|
65
|
+
configured: string | undefined,
|
|
66
|
+
name: string,
|
|
67
|
+
): boolean {
|
|
68
|
+
if (!configured) return false
|
|
69
|
+
return credentialMatches(configured, readCredential(req, name))
|
|
70
|
+
}
|
package/src/utils/http/dom.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises'
|
|
1
2
|
import { LRUCache } from '../../cache/lru'
|
|
2
3
|
import { Bakery, hostStore } from '../../core/bakery'
|
|
3
4
|
import type { MapOf } from '../../types'
|
|
@@ -16,16 +17,6 @@ export function clearHeadBodyCache() {
|
|
|
16
17
|
|
|
17
18
|
export { headBodyCache }
|
|
18
19
|
|
|
19
|
-
type PackageJson = {
|
|
20
|
-
name: string
|
|
21
|
-
version: string
|
|
22
|
-
main?: string
|
|
23
|
-
module?: string
|
|
24
|
-
browser?: string | MapOf<string>
|
|
25
|
-
dependencies?: MapOf<string>
|
|
26
|
-
devDependencies?: MapOf<string>
|
|
27
|
-
}
|
|
28
|
-
|
|
29
20
|
let depMap = ''
|
|
30
21
|
|
|
31
22
|
const hostDepMaps = new Map<string, string>()
|
|
@@ -34,22 +25,14 @@ const hostDepMaps = new Map<string, string>()
|
|
|
34
25
|
* Normalise one import-map entry, for both the process-level map and the
|
|
35
26
|
* per-host maps.
|
|
36
27
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* two callers, so they cannot disagree again.
|
|
28
|
+
* One helper, two callers, deliberately: they used to be separate copies that
|
|
29
|
+
* had drifted into testing different things — one switched on the entry key,
|
|
30
|
+
* the other on the entry value — so the same input normalised two ways
|
|
31
|
+
* depending on which path saw it.
|
|
42
32
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `docs` or `tests`, and the directory they name no longer exists, so a value
|
|
47
|
-
* pointing at it is a broken path either way. It normalises as an ordinary
|
|
48
|
-
* relative specifier now.
|
|
49
|
-
*
|
|
50
|
-
* `@client/utils` stays, and stays keyed on the *key*: it is a live alias — it
|
|
51
|
-
* is the default `importMap` entry in `core/config.ts` — and the browser
|
|
52
|
-
* runtime is served from a fixed URL, so the target is not the app's to choose.
|
|
33
|
+
* `@client/utils` is keyed on the *key*: it is a live alias, the default
|
|
34
|
+
* `importMap` entry in `core/config.ts`, and the browser runtime is served from
|
|
35
|
+
* a fixed URL, so the target is not the app's to choose.
|
|
53
36
|
*/
|
|
54
37
|
function normalizeImportEntry(key: string, value: unknown): [string, string] {
|
|
55
38
|
const cleanKey = key.replace(/\*$/, '')
|
|
@@ -85,53 +68,79 @@ export function initHostImportMaps() {
|
|
|
85
68
|
}
|
|
86
69
|
}
|
|
87
70
|
|
|
88
|
-
|
|
89
|
-
if (is.string(pkgData.browser)) return pkgData.browser as string
|
|
71
|
+
let installedCache: Promise<string[]> | null = null
|
|
90
72
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Every installed package, top level and scoped, as `name` and `name/`.
|
|
75
|
+
*
|
|
76
|
+
* Read from `node_modules` rather than from `dependencies`, and the difference
|
|
77
|
+
* is the whole point: a package can be installed and imported without being
|
|
78
|
+
* declared — a transitive one, or a dependency someone forgot to add — and the
|
|
79
|
+
* browser's failure for a specifier the map misses names its own rule rather
|
|
80
|
+
* than the missing entry: *"Failed to resolve module specifier 'pkg'. Relative
|
|
81
|
+
* references must start with either "/", "./", or "../"."*
|
|
82
|
+
*
|
|
83
|
+
* Cheap — one `readdir` per scope, no `package.json` reads — and memoised per
|
|
84
|
+
* process besides: the import map reads it at boot, `bundleModule` on every
|
|
85
|
+
* bundle (as its `external` list), and a `readdir` sweep per bundle is pure
|
|
86
|
+
* waste. A dev restart is a new process, so an install still shows up.
|
|
87
|
+
*/
|
|
88
|
+
export function installedPackages(): Promise<string[]> {
|
|
89
|
+
installedCache ??= readInstalledPackages()
|
|
90
|
+
return installedCache
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function readInstalledPackages(): Promise<string[]> {
|
|
94
|
+
const root = fs.resolve(fs.cwd, 'node_modules')
|
|
95
|
+
const [err, entries] = await Try.catch(() => readdir(root))
|
|
96
|
+
if (err || !entries) return []
|
|
97
|
+
|
|
98
|
+
const names: string[] = []
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
// `.bin`, `.cache` and friends are not packages.
|
|
101
|
+
if (entry.startsWith('.')) continue
|
|
96
102
|
|
|
97
|
-
|
|
103
|
+
if (!entry.startsWith('@')) {
|
|
104
|
+
names.push(entry)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const [scopeErr, scoped] = await Try.catch(() =>
|
|
109
|
+
readdir(`${root}/${entry}`),
|
|
110
|
+
)
|
|
111
|
+
if (scopeErr || !scoped) continue
|
|
112
|
+
for (const name of scoped) {
|
|
113
|
+
if (!name.startsWith('.')) names.push(`${entry}/${name}`)
|
|
114
|
+
}
|
|
98
115
|
}
|
|
99
116
|
|
|
100
|
-
return
|
|
117
|
+
return names
|
|
101
118
|
}
|
|
102
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Build the browser import map.
|
|
122
|
+
*
|
|
123
|
+
* **Resolution happens at the other end of the URL, and rewriting happens
|
|
124
|
+
* nowhere.** An entry maps a package to `/_nm/<name>`; `NMHandler` hands that to
|
|
125
|
+
* `Bun.build`, which applies real browser resolution — `exports` maps,
|
|
126
|
+
* conditions, the `browser` field. Naming an entry file here instead would mean
|
|
127
|
+
* reimplementing all of that, badly.
|
|
128
|
+
*
|
|
129
|
+
* Covering every installed package is what removes the need for a compile-time
|
|
130
|
+
* rewrite of bare specifiers, and the map reaches code the compiler never sees:
|
|
131
|
+
* an inline `<script type="module">` in an `.html` page arrives at the browser
|
|
132
|
+
* with its imports intact.
|
|
133
|
+
*
|
|
134
|
+
* App-declared `importMap` entries are applied last and win. One of them,
|
|
135
|
+
* `@client/utils`, does not point into `node_modules` at all.
|
|
136
|
+
*/
|
|
103
137
|
export async function initImportMap() {
|
|
104
|
-
const pkgContent = await Try(() =>
|
|
105
|
-
Bun.file(fs.resolve(fs.cwd, 'package.json')).json(),
|
|
106
|
-
)
|
|
107
|
-
const pkg: any = pkgContent || {}
|
|
108
138
|
const map = Bakery.config.importMap || {}
|
|
109
|
-
const deps = pkg.dependencies || {}
|
|
110
|
-
|
|
111
139
|
const resolvedMap: MapOf<string> = {}
|
|
112
140
|
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
Bun.file(`./node_modules/${dep}/package.json`).json(),
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
return { dep, pkgData: pkgData as PackageJson | null }
|
|
120
|
-
}),
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
for (const { dep, pkgData } of imports) {
|
|
124
|
-
if (!pkgData) continue
|
|
125
|
-
|
|
126
|
-
const actualName = pkgData.name || dep
|
|
127
|
-
resolvedMap[`${actualName}/`] = `/_nm/${actualName}/`
|
|
128
|
-
|
|
129
|
-
const baseMod = pkgData.module || pkgData.main || 'index.js'
|
|
130
|
-
const mod = resolveDepModule(pkgData, baseMod)
|
|
131
|
-
|
|
132
|
-
const finalMod = typeof mod === 'string' ? mod : 'index.js'
|
|
133
|
-
resolvedMap[actualName] =
|
|
134
|
-
`/_nm/${actualName}/${finalMod.replace(/^\.\//, '')}`
|
|
141
|
+
for (const name of await installedPackages()) {
|
|
142
|
+
resolvedMap[`${name}/`] = `/_nm/${name}/`
|
|
143
|
+
resolvedMap[name] = `/_nm/${name}`
|
|
135
144
|
}
|
|
136
145
|
|
|
137
146
|
for (const [k, v] of Object.entries(map)) {
|
|
@@ -149,8 +158,8 @@ export namespace DOMTools {
|
|
|
149
158
|
return `<script type="importmap">${map}</script>`
|
|
150
159
|
}
|
|
151
160
|
|
|
152
|
-
export function params(params: MapOf<
|
|
153
|
-
const newParams: MapOf<
|
|
161
|
+
export function params(params: MapOf<unknown>) {
|
|
162
|
+
const newParams: MapOf<unknown> = {}
|
|
154
163
|
|
|
155
164
|
for (const [k, v] of Object.entries(params)) {
|
|
156
165
|
if (k.startsWith('$$')) continue
|
package/src/utils/http/html.ts
CHANGED
|
@@ -80,7 +80,7 @@ export const STREAM_THRESHOLD_BYTES = 64 * 1024
|
|
|
80
80
|
|
|
81
81
|
export async function injectIfHtml(
|
|
82
82
|
data: string | Response | Blob,
|
|
83
|
-
params?: MapOf<
|
|
83
|
+
params?: MapOf<unknown>,
|
|
84
84
|
injects?: HtmlInjects,
|
|
85
85
|
): Promise<Response | null> {
|
|
86
86
|
if (data instanceof Response && isInjected(data)) return data
|
|
@@ -127,7 +127,7 @@ export async function injectIfHtml(
|
|
|
127
127
|
* streamed path's fallback both come through here. */
|
|
128
128
|
function bufferedResponse(
|
|
129
129
|
content: string,
|
|
130
|
-
params: MapOf<
|
|
130
|
+
params: MapOf<unknown> | undefined,
|
|
131
131
|
injects: HtmlInjects | undefined,
|
|
132
132
|
responseInit: ResponseInit & { headers?: any },
|
|
133
133
|
): Response {
|
|
@@ -182,7 +182,7 @@ function getConfigInjects() {
|
|
|
182
182
|
* into a pull callback, where the AsyncLocalStorage host context that
|
|
183
183
|
* `getConfigInjects` and `DOMTools.importMap` read may no longer be live.
|
|
184
184
|
*/
|
|
185
|
-
function computeInjects(params: MapOf<
|
|
185
|
+
function computeInjects(params: MapOf<unknown>, injects: HtmlInjects) {
|
|
186
186
|
const configInjects = getConfigInjects()
|
|
187
187
|
const paramsStr = DOMTools.params(params)
|
|
188
188
|
|
|
@@ -235,7 +235,7 @@ function rewriteFontsUrls(html: string): string {
|
|
|
235
235
|
|
|
236
236
|
export function assembleHtml(
|
|
237
237
|
content: string,
|
|
238
|
-
params: MapOf<
|
|
238
|
+
params: MapOf<unknown> = {},
|
|
239
239
|
injects: HtmlInjects = {},
|
|
240
240
|
) {
|
|
241
241
|
const frags = computeInjects(params, injects)
|
package/src/utils/http/index.ts
CHANGED
|
@@ -15,6 +15,22 @@ export function any<T = any>(value: any): T {
|
|
|
15
15
|
return value
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* A hex id of `length` characters, from `crypto.getRandomValues`.
|
|
20
|
+
*
|
|
21
|
+
* Isomorphic, and moving it here is what made it so: it lived in
|
|
22
|
+
* `client/utils.ts` as a browser global only, so the same call in a server
|
|
23
|
+
* block was a ReferenceError — reported from an app. `crypto` is a global in
|
|
24
|
+
* both runtimes; nothing here is browser-specific.
|
|
25
|
+
*/
|
|
26
|
+
export function randomId(length = 8) {
|
|
27
|
+
const arr = new Uint8Array(Math.ceil(length / 2))
|
|
28
|
+
crypto.getRandomValues(arr)
|
|
29
|
+
return Array.from(arr, dec => dec.toString(16).padStart(2, '0'))
|
|
30
|
+
.join('')
|
|
31
|
+
.slice(0, length)
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
export function repeat(n: number): number[]
|
|
19
35
|
export function repeat<T>(n: number, fn: (i: number) => T): T[]
|
|
20
36
|
export function repeat<T>(n: number, fn?: (i: number) => T): unknown[] {
|