@bakery-framework/core 1.2.3 → 2.0.0-alpha.11

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.
@@ -2,6 +2,7 @@ import { Bakery } from '../../core/bakery'
2
2
  import { handlerLog } from '../../logger/serve-log'
3
3
  import { FileSystem } from '../../utils/fs'
4
4
  import { checkCsrf, response } from '../../utils/http'
5
+ import { parsedUrl } from '../../utils/http/url'
5
6
  import type { Handler } from '../core/$base'
6
7
  import { bustInDev, DynamicHandler } from '../core/$dynamic'
7
8
  import { ErrorHandler } from '../core/$error'
@@ -29,8 +30,7 @@ export class ApiHandler extends DynamicHandler {
29
30
  static async handle(path: string, req: Request) {
30
31
  // State-changing methods must be same-origin. SameSite=Lax alone does not
31
32
  // cover this: a cross-site form POST is a CORS-simple request.
32
- const url: URL = (req as any).__parsedUrl || new URL(req.url)
33
- const csrf = checkCsrf(req, url)
33
+ const csrf = checkCsrf(req, parsedUrl(req))
34
34
  if (csrf) return response.json.error(403, csrf) as unknown as Response
35
35
 
36
36
  const info = await this.resolveRoute(path)
@@ -1,6 +1,7 @@
1
1
  import { Bakery } from '../../core/bakery'
2
2
  import { handlerLog } from '../../logger'
3
3
  import { response } from '../../utils/http'
4
+ import { parsedUrl } from '../../utils/http/url'
4
5
  import { Handler } from '../core/$base'
5
6
 
6
7
  export class ProxyHandler extends Handler {
@@ -30,7 +31,7 @@ export class ProxyHandler extends Handler {
30
31
  baseTarget +
31
32
  (trailingPath.startsWith('/') ? '' : '/') +
32
33
  trailingPath +
33
- ((req as any).__parsedUrl || new URL(req.url)).search
34
+ parsedUrl(req).search
34
35
  break
35
36
  }
36
37
 
@@ -27,6 +27,12 @@ const serveMsgs = {
27
27
  'I Synced %ytsconfig.json%* paths with %yserver.config.ts%*!',
28
28
  TSCONFIG_PROJECTS_WRITTEN:
29
29
  'I Wrote %y{count}%* tsconfig project(s) to %y.cache/tsconfig/%*',
30
+ // One-time repair. Previous releases wired the generated projects into the
31
+ // app's tsconfig.json as `references`, which broke `tsc -p <app>`
32
+ // (TS6305/6306/6310). Named so the rewrite of a tracked file comes with a
33
+ // line saying why it happened.
34
+ TSCONFIG_REFERENCES_REMOVED:
35
+ 'I Removed generated %yreferences%* from %ytsconfig.json%* — the %y.cache/tsconfig/%* projects are standalone, and referencing them broke %ytsc -p%*',
30
36
  // A plugin asking for a project name that is taken. Named rather than
31
37
  // silent: the symptom otherwise is one plugin's types quietly not applying,
32
38
  // discovered much later and blamed on the wrong thing.
@@ -98,6 +104,21 @@ const handlerMsgs = {
98
104
  PROXY_REQ: 'I Proxying %y{path}%* -> %b{target}%*',
99
105
  MIDDLEWARE_ERR: 'E Middleware error: %r{error}%*',
100
106
  BUNDLE_ERR: 'E Failed to bundle module (%y{file}%*): %r{error}%*',
107
+ // **Keep each message one unbroken string literal.** Splitting a long one into
108
+ // `'…' + '…'` types as `string`, so `as const` preserves nothing,
109
+ // `messageLogger` extracts no `{placeholder}`, and every call site fails with
110
+ // "Expected 0 arguments, but got 1". Wrapping the value onto its own line, as
111
+ // below, is fine; joining with `+` is not.
112
+ BUNDLE_SIDE_EFFECTS_REPAIRED:
113
+ '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.',
114
+ BUNDLE_EMPTY_EXPORTS:
115
+ '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.',
116
+ BUNDLE_CJS_INTEROP:
117
+ 'I Generated named exports for the CommonJS package %y{file}%*, so a named import of it works in the browser.',
118
+ BUNDLE_CJS_PROBED:
119
+ 'I Could not read %y{file}%* export names statically, so they were probed by importing it in a short-lived child process.',
120
+ BUNDLE_CJS_DEFAULT_ONLY:
121
+ '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
122
  } as const
102
123
 
103
124
  export const handlerLog = messageLogger(new Logger('handlers'), handlerMsgs)
@@ -6,11 +6,13 @@ export type ValidResponses = Handler.Response
6
6
  /**
7
7
  * A TypeScript project a plugin contributes to the app.
8
8
  *
9
- * Written to `.cache/tsconfig/<name>.json` on every dev boot and referenced
10
- * from the app's root `tsconfig.json`, so the editor typechecks each file under
11
- * the project that owns it. Regenerated rather than committed: it is derived
12
- * from the plugin list and the app's config, and `.cache/` is the disposable
13
- * half of the two runtime directories.
9
+ * Written to `.cache/tsconfig/<name>.json` on every dev boot, as a standalone
10
+ * project invoked directly (`vue-tsc -p .cache/tsconfig/vue.json`). It is
11
+ * deliberately *not* referenced from the app's root `tsconfig.json` a
12
+ * `references` entry to an unbuilt `noEmit` project makes `tsc -p <app>` fail
13
+ * (TS6305/6306/6310; see `compiler/tsconfig-sync.ts`). Regenerated rather
14
+ * than committed: it is derived from the plugin list and the app's config, and
15
+ * `.cache/` is the disposable half of the two runtime directories.
14
16
  */
15
17
  export interface PluginTsProject {
16
18
  /** File name under `.cache/tsconfig/`, and the project's identity. */
package/src/router.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  withStatus,
20
20
  } from './utils/http'
21
21
  import { applyCors, preflightResponse } from './utils/http/cors'
22
+ import { parsedUrl } from './utils/http/url'
22
23
 
23
24
  /**
24
25
  * Resolve a WebSocket upgrade, refusing cross-origin handshakes first.
@@ -39,7 +40,7 @@ export async function upgradeWebsocket(
39
40
  req: Request,
40
41
  path: string,
41
42
  ): Promise<boolean | undefined> {
42
- const url: URL = (req as any).__parsedUrl || new URL(req.url)
43
+ const url = parsedUrl(req)
43
44
  const denied = checkWebSocketOrigin(req, url)
44
45
  if (denied) {
45
46
  serveLog.WEBSOCKET_ERR({
@@ -85,10 +86,9 @@ export function handleRequest(
85
86
  req: Request,
86
87
  ): Handler.Response | MixedPromise<symbol>
87
88
  export async function handleRequest(req: Request) {
88
- // worker.ts parses and attaches this before calling in; the fallback keeps
89
- // direct callers (tests, embedders) working.
90
- const url: URL = (req as any).__parsedUrl || new URL(req.url)
91
- ;(req as any).__parsedUrl = url
89
+ // Shared with worker.ts, which has already asked for the same parse; a
90
+ // direct caller (test, embedder) gets it parsed here instead.
91
+ const url = parsedUrl(req)
92
92
  const path = url.pathname
93
93
 
94
94
  // One read of the config getter, not three: `Bakery.serveRoot` walks
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
package/src/utils/fs.ts CHANGED
@@ -395,6 +395,35 @@ export namespace FileSystem {
395
395
  let curr = safeResolve(pathToCheck)
396
396
  const normalizedRoot = safeResolve(root)
397
397
 
398
+ // A path outside `root` is forbidden, and this is the clause that says so.
399
+ //
400
+ // The walk below is bounded by `curr.startsWith(normalizedRoot)`, so an
401
+ // out-of-root path skipped the loop body entirely and fell through to
402
+ // `return false` — "not forbidden". That is a guard failing *open* on the
403
+ // one input it most needs to refuse, and it made every caller that relied
404
+ // on this for containment (all of them: `constants.ts` said so in as many
405
+ // words) incapable of catching an escape.
406
+ //
407
+ // Not theoretical. `$routing.ts` builds its route globs with `\*` to mean a
408
+ // literal asterisk; on Windows a backslash is a path *separator*, so Bun
409
+ // read the pattern as drive-absolute, ignored `cwd`, and matched files at
410
+ // `C:\`. `getRoute` resolved one, asked this function, was told "allowed",
411
+ // and returned an `Info` pointing six levels above the serve root. The glob
412
+ // is fixed too, but the glob was only how it was reached — a resolved file
413
+ // outside the root has to be refused here whatever produced it.
414
+ //
415
+ // The separator suffix matters: a bare `startsWith` would also accept a
416
+ // sibling directory whose name merely begins with the root's. A root that
417
+ // is already a filesystem root ends in `/` and must not gain a second one —
418
+ // `C://` matches nothing, which would make every path under `C:/` read as
419
+ // an escape. `fs.test.ts` drives both through its root corpus.
420
+ const rootPrefix = normalizedRoot.endsWith('/')
421
+ ? normalizedRoot
422
+ : `${normalizedRoot}/`
423
+ if (curr !== normalizedRoot && !curr.startsWith(rootPrefix)) {
424
+ return true
425
+ }
426
+
398
427
  const store = hostStore.getStore()
399
428
  const seen = store ? (store.forbiddenProbes ??= new Map()) : null
400
429
 
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The request-predicate half of plugin authorization.
3
+ *
4
+ * One implementation, deliberately in core, for the same reason
5
+ * `credential.ts` is here: the dashboard, db-explorer and analytics plugins
6
+ * each need this guard, and per-plugin copies of security code drift. These
7
+ * three had already drifted — one coerced its predicate's return with
8
+ * `Boolean`, one gated on `!DEV` where the others read `PROD`, one swallowed a
9
+ * `getClientIp` throw into an empty string and carried on. Each divergence is
10
+ * resolved below toward the fail-closed reading, and each is marked as a
11
+ * decision rather than left to look like a style choice.
12
+ *
13
+ * It owns *only* the predicate — no logins, no sessions, no backoff. The shared
14
+ * key path is `credential.ts`; a plugin that offers both doors composes them.
15
+ *
16
+ * `getClientIp` is imported from `./ip` directly, never through this
17
+ * directory's barrel or `@bakery-framework/core`: a core module that reaches for a
18
+ * barrel closes an import cycle, which is how 67 tests once failed with
19
+ * `ReferenceError: Cannot access 'Logger' before initialization`.
20
+ */
21
+
22
+ import { getClientIp } from './ip'
23
+
24
+ /**
25
+ * A host application's access predicate. Returning `true` admits; returning
26
+ * anything else, or throwing, denies (convention 2).
27
+ *
28
+ * The framework authenticates nobody. The application, which already knows who
29
+ * its users are, supplies this.
30
+ */
31
+ export type AuthorizeFn = (req: Request) => boolean | Promise<boolean>
32
+
33
+ /**
34
+ * Addresses only, never hostnames. Module-private: neither plugin copy
35
+ * exported it, and the membership test is the whole of the useful surface.
36
+ *
37
+ * `'localhost'` used to be a member, back when the request's *hostname* was
38
+ * compared against this set as well. A peer address is never the string
39
+ * `localhost`, and accepting it meant `X-Forwarded-For: localhost` counted as
40
+ * loopback under `trustProxy` — and, worse, that `new URL(req.url).hostname`
41
+ * did too. Bun builds that from the client's own `Host` header and
42
+ * `DEFAULT_HOST` is 0.0.0.0, so any peer on the LAN could send
43
+ * `Host: localhost` and be handed a database browser.
44
+ */
45
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
46
+
47
+ /** True when the request came from this machine. */
48
+ export function isLoopback(req: Request): boolean {
49
+ // The peer address is the only evidence here the client does not choose.
50
+ //
51
+ // DECISION (divergence 3): a throw returns `false` immediately rather than
52
+ // falling through with `ip = ''`. `getClientIp` reads config and the live
53
+ // server, either of which may be absent (tests, early boot). The two spell
54
+ // the same answer today, because `LOOPBACK.has('')` is false — but only by
55
+ // coincidence, and the fall-through invites a later edit that adds a second
56
+ // source of evidence below this line and silently consults it on the
57
+ // indeterminate path. Returning here says the answer is settled: no address
58
+ // means no evidence, and per convention 2 an indeterminate answer is a
59
+ // denial, not a reason to ask something the requester controls.
60
+ try {
61
+ return LOOPBACK.has(getClientIp(req))
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The default when an application configures no predicate: loopback in
69
+ * development, nobody in production. Forgetting to configure a plugin
70
+ * therefore cannot expose it to the internet.
71
+ */
72
+ export function defaultAuthorize(req: Request): boolean {
73
+ // DECISION (divergence 2): gate on `PROD`, not `!DEV`, and read it at call
74
+ // time — the mode flags are accessors on `process.env` (`core/init.ts`), so
75
+ // they are process state and tests flip them.
76
+ //
77
+ // The two spellings look interchangeable because `init.ts` derives them as
78
+ // complements (`PROD = !isDev && !--dev-worker`). They are not, because they
79
+ // are *independently settable* accessors and the test fixtures set them one
80
+ // at a time: `asDev` flips `DEV` alone and leaves the ambient `PROD` — which
81
+ // under `bun test` is `true` — in place. In that state `!DEV` admits a
82
+ // loopback caller and `PROD` denies, so `PROD` is both the fail-closed
83
+ // reading and the one that literally names the condition it means, rather
84
+ // than inferring production from the absence of development.
85
+ //
86
+ // Hence a test for the *positive* development marker rather than a truthiness
87
+ // test on `PROD`, which is the one place a bare `PROD` gate would be weaker
88
+ // than `!DEV`. The flags do not exist until `core/init.ts` has run, and
89
+ // `if (undefined)` falls straight through to the loopback check — so an
90
+ // uninitialised process would open the door an initialised production one
91
+ // keeps shut. Unset is not evidence of development; it is no evidence at all.
92
+ //
93
+ // This read `PROD !== false` while the flags were real booleans. Bun 1.4
94
+ // rejects accessor descriptors on `process.env`, so they are `'1'`/`''`
95
+ // strings now (see `core/init.ts`) — and `!== false` is true for *every*
96
+ // string, including the `''` a development server sets, so the gate would
97
+ // have denied on loopback in development while still looking correct.
98
+ // `DEV === '1'` is the same statement in the encoding that survives: only a
99
+ // booted development server sets it, and production (`''`) and never-booted
100
+ // (`undefined`) both fail it.
101
+ //
102
+ // `isLoopback` would very likely deny on its own there, having no config or
103
+ // server to read an address from. That is a second line of defence, not this
104
+ // one's excuse: a guard should not depend on another guard's failure mode.
105
+ if (import.meta.env.DEV !== '1') return false
106
+ return isLoopback(req)
107
+ }
108
+
109
+ /** The configured predicate, or the fail-closed default. */
110
+ export function resolveAuthorize(fn?: AuthorizeFn): AuthorizeFn {
111
+ return fn ?? defaultAuthorize
112
+ }
113
+
114
+ /**
115
+ * Run a predicate without letting a broken one grant access. Guard semantics
116
+ * per convention 2: the *authorizer* may throw or answer nonsense, and the
117
+ * answer to any indeterminate state is denial.
118
+ */
119
+ export async function isAuthorized(
120
+ authorize: AuthorizeFn,
121
+ req: Request,
122
+ ): Promise<boolean> {
123
+ try {
124
+ // DECISION (divergence 1): `=== true`, never `Boolean(...)`. The predicate
125
+ // comes from application code and `AuthorizeFn`'s return type is only
126
+ // advice — an untyped, transpiled or `as any` predicate can hand back
127
+ // anything. `Boolean` admits every truthy non-boolean, so a check that
128
+ // answers with a status string denies on `""` and *grants* on `"no"`, and
129
+ // one that answers with a count grants on any non-zero. Admission is the
130
+ // expensive direction to get wrong; require the exact affirmative.
131
+ return (await authorize(req)) === true
132
+ } catch {
133
+ // A predicate that throws is indeterminate, and indeterminate is denied.
134
+ return false
135
+ }
136
+ }
@@ -1,4 +1,5 @@
1
1
  import type { MapOf } from '../../types'
2
+ import { parsedUrl } from './url'
2
3
 
3
4
  export async function processBody(req: Request): Promise<MapOf<any>> {
4
5
  const getParsedBody = async (): Promise<MapOf<any>> => {
@@ -18,8 +19,7 @@ export async function processBody(req: Request): Promise<MapOf<any>> {
18
19
  }
19
20
 
20
21
  function getBodyFromURI(req: Request): MapOf<any> {
21
- const url = (req as any).__parsedUrl || new URL(req.url)
22
- const searchParams = url.searchParams
22
+ const searchParams = parsedUrl(req).searchParams
23
23
  return Object.fromEntries(searchParams.entries())
24
24
  }
25
25
 
@@ -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
@@ -231,6 +231,33 @@ export namespace ETag {
231
231
  if (conditionalRes) return conditionalRes
232
232
  }
233
233
 
234
+ // Range handling itself lives in Bun.serve, not here: any Response whose
235
+ // body is a *path-backed* BunFile is sliced by the runtime — 206 with
236
+ // Content-Range on a satisfiable single range, 416 past EOF — including
237
+ // the negotiated compressed variants above (the range then addresses the
238
+ // encoded bytes, which is what RFC 9110 says a range means). What the
239
+ // runtime does not do is advertise: a plain 200 or a HEAD said nothing,
240
+ // so players that probe HEAD for `Accept-Ranges` before attempting seeks
241
+ // never tried. Advertised here because this is the one funnel every
242
+ // file-serving handler's BunFile passes through, and only here — an
243
+ // in-memory Blob or a `sendText` string body ignores Range entirely
244
+ // (served whole, 200), which is why `.name` gates the claim.
245
+ //
246
+ // Skipped when Bun's own range path is about to answer (a GET carrying
247
+ // `Range`): that path appends its own `Accept-Ranges: bytes` to the
248
+ // 206/416, and setting it here too emitted `bytes, bytes`. The trade,
249
+ // measured on Bun 1.4.0: a GET whose Range is malformed or multipart is
250
+ // served whole with no advertisement from either side — acceptable,
251
+ // since a client that already sent `Range` is not the one this probe
252
+ // header exists for. HEAD ignores `Range` and gets the header even when
253
+ // one is present.
254
+ if (
255
+ resolvedFile.name &&
256
+ !(req && req.method === 'GET' && req.headers.has('range'))
257
+ ) {
258
+ headers['Accept-Ranges'] = 'bytes'
259
+ }
260
+
234
261
  return new Response(resolvedFile, { headers })
235
262
  }
236
263