@bakery-framework/core 1.2.2 → 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.
@@ -72,6 +72,43 @@ const routeGlobs = (
72
72
  * is not a file and does not trigger the yield. `findDynamicRoute` applies
73
73
  * the same rule on the cached path; the two must agree.
74
74
  */
75
+ /**
76
+ * Does the requested path name a file some handler serves at that URL?
77
+ *
78
+ * The "a real file always beats a catch-all" rule used to stat the literal
79
+ * path only, which misses every *compiled* URL: `TSHandler` serves
80
+ * `provides.ts` at `/teacher/provides` and `/teacher/provides.js`, so neither
81
+ * spelling named a file on disk and a Vue catch-all above it (priority 58 vs
82
+ * 50) served HTML to a browser that asked for a module. The probe now also
83
+ * tries the registered dynamic extensions against the extensionless base —
84
+ * the same mapping the serving handlers apply, read from the live registry so
85
+ * a plugin's extension (`.vue`) counts without core naming it.
86
+ *
87
+ * The caller clamps `target` inside the root before asking; appending an
88
+ * extension cannot escape it.
89
+ */
90
+ export function servedSourceExists(target: string): boolean {
91
+ if (fs.isFileSync(target)) return true
92
+
93
+ const base = target.endsWith('.js') ? target.slice(0, -3) : target
94
+ for (const handler of Bakery.handlers.fetch.keys()) {
95
+ let exts: unknown
96
+ try {
97
+ exts = (handler as { config?: { ext?: unknown } }).config?.ext
98
+ } catch {
99
+ // A config getter that needs state this process lacks — a handler with
100
+ // no ext table cannot claim a source file either way.
101
+ continue
102
+ }
103
+ if (!Array.isArray(exts)) continue
104
+ for (const ext of exts) {
105
+ if (fs.isFileSync(`${base}.${ext}`)) return true
106
+ }
107
+ }
108
+
109
+ return false
110
+ }
111
+
75
112
  async function getCatchAllRoute(
76
113
  ext: string,
77
114
  dir: fs.AbsolutePath,
@@ -98,7 +135,7 @@ async function getCatchAllRoute(
98
135
  // whose name merely begins with it.
99
136
  if (
100
137
  (target === dir || target.startsWith(`${dir}/`)) &&
101
- fs.isFileSync(target)
138
+ servedSourceExists(target)
102
139
  ) {
103
140
  return null
104
141
  }
@@ -106,7 +143,14 @@ async function getCatchAllRoute(
106
143
 
107
144
  const file = fs.resolve(found.value)
108
145
  if (fs.isForbidden(file, root)) return null
109
- return new RouteData.Info(file, fs.relative(root, file))
146
+ const info = new RouteData.Info(file, fs.relative(root, file))
147
+
148
+ // A bare-directory request (`/docs` with no index) reaches here with no
149
+ // rest segments, and only the `[...name!]` spelling opted into claiming
150
+ // it — the plain form keeps requiring at least one segment.
151
+ if (!restSegments.length && !info.optionalCatchAll) return null
152
+
153
+ return info
110
154
  }
111
155
 
112
156
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: request-to-route dispatcher
@@ -177,12 +221,17 @@ export async function getRoute(
177
221
  if (route) return route
178
222
  }
179
223
 
180
- // `first === 'index'` covers the bare-directory request (`/docs` arrives
181
- // here as an injected 'index' segment): the catch-all pattern requires at
182
- // least one rest segment, so it cannot match that request — returning the
183
- // Info anyway would claim the route with null params.
184
- if (!options.staticOnly && first !== 'index') {
185
- return await getCatchAllRoute(ext, dir, root, [first])
224
+ // `first === 'index'` is the bare-directory request (`/docs` arrives here
225
+ // as an injected 'index' segment, after no index file matched). The plain
226
+ // `[...name]` pattern requires at least one rest segment and cannot claim
227
+ // it; `[...name!]` exists to `getCatchAllRoute` tells them apart.
228
+ if (!options.staticOnly) {
229
+ return await getCatchAllRoute(
230
+ ext,
231
+ dir,
232
+ root,
233
+ first === 'index' ? [] : [first],
234
+ )
186
235
  }
187
236
  }
188
237
 
@@ -98,6 +98,21 @@ const handlerMsgs = {
98
98
  PROXY_REQ: 'I Proxying %y{path}%* -> %b{target}%*',
99
99
  MIDDLEWARE_ERR: 'E Middleware error: %r{error}%*',
100
100
  BUNDLE_ERR: 'E Failed to bundle module (%y{file}%*): %r{error}%*',
101
+ // **Keep each message one unbroken string literal.** Splitting a long one into
102
+ // `'…' + '…'` types as `string`, so `as const` preserves nothing,
103
+ // `messageLogger` extracts no `{placeholder}`, and every call site fails with
104
+ // "Expected 0 arguments, but got 1". Wrapping the value onto its own line, as
105
+ // below, is fine; joining with `+` is not.
106
+ BUNDLE_SIDE_EFFECTS_REPAIRED:
107
+ 'I %y{file}%* tree-shook to an empty export list because its package declares %ysideEffects: false%*; re-bundled through a re-export shim, which keeps the code.',
108
+ BUNDLE_EMPTY_EXPORTS:
109
+ 'E %y{file}%* bundled to an export list with no code behind it — every name it exports is undefined, so it is rejected rather than served. The bundler reported success, and re-bundling through a re-export shim did not recover it. Import the specific module you need instead of the package root.',
110
+ BUNDLE_CJS_INTEROP:
111
+ 'I Generated named exports for the CommonJS package %y{file}%*, so a named import of it works in the browser.',
112
+ BUNDLE_CJS_PROBED:
113
+ 'I Could not read %y{file}%* export names statically, so they were probed by importing it in a short-lived child process.',
114
+ BUNDLE_CJS_DEFAULT_ONLY:
115
+ 'W %y{file}%* assigns %ymodule.exports%* wholesale and its members could not be read, so its browser bundle exports only %ydefault%* — a named import from it fails in the browser with "does not provide an export named …", and nothing fails here. Import the default and read the property off it, or use an ESM build.',
101
116
  } as const
102
117
 
103
118
  export const handlerLog = messageLogger(new Logger('handlers'), handlerMsgs)
@@ -43,6 +43,22 @@ export interface PluginTsProject {
43
43
  * A plugin whose project compiles browser code should set it.
44
44
  */
45
45
  importMapPaths?: boolean
46
+ /**
47
+ * Does code in this project run on the server?
48
+ *
49
+ * Declared, not inferred. The first version read `bun-types` out of the base
50
+ * config and treated that as the marker — true today, and an inference about
51
+ * intent drawn from a detail that exists for another reason. A plugin saying
52
+ * what its project *is* cannot drift out of step with itself.
53
+ *
54
+ * What it controls: the app's schema registration, so the ORM's tables are the
55
+ * app's own rather than the permissive `any` fallback. Off for browser code on
56
+ * purpose — `@bakery-framework/orm` is server-only, so a `.ts` bound for the
57
+ * browser importing `DB` should fail to typecheck rather than be helpfully
58
+ * typed, and the package ships TypeScript source calling `Bun.*`, which a
59
+ * client config cannot compile anyway.
60
+ */
61
+ server?: boolean
46
62
  }
47
63
 
48
64
  /** What a plugin contributes to the generated tsconfig projects. */
package/src/session.ts CHANGED
@@ -12,9 +12,6 @@ import { DEFAULT_SESSION_PERSIST, DEFAULT_SESSION_TTL } from './utils/constants'
12
12
  */
13
13
  export const RESERVED_SESSION_PREFIX = '__bakery.'
14
14
 
15
- /** Marks a session as having passed the DASHPASS check. */
16
- export const DASHPASS_SESSION_KEY = `${RESERVED_SESSION_PREFIX}dashpass`
17
-
18
15
  export function isReservedSessionKey(key: string): boolean {
19
16
  return key.startsWith(RESERVED_SESSION_PREFIX)
20
17
  }
package/src/shared.d.ts CHANGED
@@ -25,6 +25,13 @@ import type { MapOf } from './types'
25
25
  */
26
26
 
27
27
  declare global {
28
+ /**
29
+ * Bound by `client/utils.ts` in the browser and `core/init.ts` on the
30
+ * server — the same isomorphic implementation either side, so code moving
31
+ * between an SFC browser script and a server block keeps the name.
32
+ */
33
+ var randomId: typeof import('./utils/isomorphic/misc').randomId
34
+
28
35
  /** The one JSON envelope — see convention 7. */
29
36
  type JsonResponse<T = any> = {
30
37
  time: number
package/src/types.d.ts CHANGED
@@ -65,6 +65,17 @@ export type Match<D extends symbol> = {
65
65
  */
66
66
  export type RouteBody<P = {}> = P & MapOf<any>
67
67
 
68
+ /**
69
+ * What one dynamic route segment binds: `[id]` a string, `[...rest]` and
70
+ * `[...rest!]` an array of the remaining segments (`[]` for the bare
71
+ * directory under the `!` form).
72
+ *
73
+ * Exists so a route over mixed or unknown segments can say
74
+ * `defineRoute<MapOf<RouteParam>>` instead of hand-writing the union — and so
75
+ * the union has one definition to change if it ever grows.
76
+ */
77
+ export type RouteParam = string | string[]
78
+
68
79
  /**
69
80
  * What a route module may actually return — read off `processResponse`
70
81
  * (`router.ts`) and `ApiHandler.handle`: a `Response`, a `BunFile` (streamed
@@ -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
+ }
@@ -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
- * There used to be two copies of this, and they had drifted into disagreeing
38
- * about *what* they tested: `initHostImportMaps` switched on the entry key,
39
- * `initImportMap` tested the entry value. `{ foo: './.server/client/utils' }`
40
- * was rewritten by one path and silently left alone by the other. One helper,
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
- * The `.server/client/utils` special cases both copies carried are gone rather
44
- * than reconciled. `.server/` is the pre-split layout: those two string
45
- * literals were the last references to it anywhere in `packages`, `apps`,
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
- function resolveDepModule(pkgData: PackageJson, baseMod: string): string {
89
- if (is.string(pkgData.browser)) return pkgData.browser as string
71
+ let installedCache: Promise<string[]> | null = null
90
72
 
91
- if (is.object(pkgData.browser)) {
92
- const cleanBase = baseMod.replace(/^\.\//, '')
93
- const browserField = pkgData.browser as MapOf<string>
94
- const lookupKeys = [baseMod, `./${cleanBase}`, cleanBase]
95
- const matchedOverride = lookupKeys.find(key => browserField[key])
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
- return matchedOverride ? browserField[matchedOverride] : baseMod
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 baseMod
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 imports = await Promise.all(
114
- Object.keys(deps).map(async dep => {
115
- const pkgData = await Try(
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<string>) {
153
- const newParams: MapOf<string> = {}
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
@@ -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<string>,
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<string> | undefined,
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<string>, injects: HtmlInjects) {
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<string> = {},
238
+ params: MapOf<unknown> = {},
239
239
  injects: HtmlInjects = {},
240
240
  ) {
241
241
  const frags = computeInjects(params, injects)
@@ -1,4 +1,5 @@
1
1
  export * from './body'
2
+ export * from './credential'
2
3
  export * from './csrf'
3
4
  export * from './dom'
4
5
  export * from './escape'
@@ -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[] {