@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.
- 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/compiler/tsconfig-sync.ts +99 -1
- package/src/core/bakery.ts +11 -12
- package/src/core/cache-version.ts +28 -1
- 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/plugins/types.ts +16 -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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
1
2
|
import { Bakery } from '../core/bakery'
|
|
2
3
|
import { errorMsg, serveLog } from '../logger'
|
|
3
4
|
import type { PluginTsProject } from '../plugins/types'
|
|
@@ -69,6 +70,7 @@ export function coreProjects(): PluginTsProject[] {
|
|
|
69
70
|
return [
|
|
70
71
|
{
|
|
71
72
|
name: 'server',
|
|
73
|
+
server: true,
|
|
72
74
|
extends: '@bakery-framework/core/tsconfig.server.json',
|
|
73
75
|
// Repeated rather than inherited: Bun's runtime does not follow
|
|
74
76
|
// `extends` into a package specifier, only a relative path.
|
|
@@ -138,6 +140,77 @@ function resolveFilesEntry(entry: string): string | null {
|
|
|
138
140
|
}
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The `files` the extended base config declares, resolved for the generated one.
|
|
145
|
+
*
|
|
146
|
+
* **TypeScript's rule is that a child's `files` *replaces* the parent's, and
|
|
147
|
+
* that rule silently disarmed every project a plugin contributes.**
|
|
148
|
+
* `tsconfig.vue.json` lists core's three ambient declarations — `global.d.ts`,
|
|
149
|
+
* `shared.d.ts`, `types.d.ts` — which is where `Bakery`, `AppConfig`, the JSX
|
|
150
|
+
* namespace and `Request.session` come from. `@bakery-framework/plugin-vue`
|
|
151
|
+
* declares one `files` entry of its own for `vue.d.ts`, and that one entry
|
|
152
|
+
* replaced all three: measured on a real app, the generated `vue` project loaded
|
|
153
|
+
* **zero** of them.
|
|
154
|
+
*
|
|
155
|
+
* It hid because `vue.d.ts` happens to declare `req` and `body` itself, so the
|
|
156
|
+
* globals an SFC reaches for most still resolved. Everything else — `Bakery`,
|
|
157
|
+
* `MapOf`, the JSX namespace — was quietly missing.
|
|
158
|
+
*
|
|
159
|
+
* So the base's list is read and merged rather than inherited. Paths inside it
|
|
160
|
+
* are relative to *that* file, which is the property the whole arrangement rests
|
|
161
|
+
* on and the reason they cannot simply be copied across.
|
|
162
|
+
*/
|
|
163
|
+
function readBase(extendsSpecifier: string): string[] {
|
|
164
|
+
try {
|
|
165
|
+
const base = Bun.resolveSync(extendsSpecifier, APP_DIR)
|
|
166
|
+
const parsed = parseJSONC(readFileSync(base, 'utf8'))
|
|
167
|
+
const list: string[] = Array.isArray(parsed?.files) ? parsed.files : []
|
|
168
|
+
const baseDir = fs.dirname(base)
|
|
169
|
+
|
|
170
|
+
return list.map(entry => {
|
|
171
|
+
const abs = fs.resolve(baseDir, entry)
|
|
172
|
+
const rel = fs.relative(PROJECT_DIR, abs).replace(/\\/g, '/')
|
|
173
|
+
return RE_RELATIVE.test(rel) ? rel : `./${rel}`
|
|
174
|
+
})
|
|
175
|
+
} catch {
|
|
176
|
+
// A base that cannot be read is not fatal: the project still compiles, it
|
|
177
|
+
// just loses the ambients — which is the status quo this repairs, not a
|
|
178
|
+
// regression. Assume client-side, which is the conservative half.
|
|
179
|
+
return []
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The app file carrying `declare module '@bakery-framework/orm/schema-registry'`.
|
|
185
|
+
*
|
|
186
|
+
* Declaration merging only happens if the declaring file is in the program, and
|
|
187
|
+
* it reached exactly one project: `server`, because that is the only one whose
|
|
188
|
+
* `include` covers `orm/**`. Everywhere else `SchemaRegistry` stayed empty,
|
|
189
|
+
* `Registered` resolved to `never`, and every table fell back to
|
|
190
|
+
* `MapOf<MapOf<any>>` — the ORM's documented untyped mode, arrived at by
|
|
191
|
+
* accident. It does not error; it just stops checking.
|
|
192
|
+
*
|
|
193
|
+
* **Server-side projects only.** The client project deliberately does not get
|
|
194
|
+
* it: the ORM is server-only, so a browser file importing `DB` should fail to
|
|
195
|
+
* typecheck rather than be helpfully typed. That is not only a preference —
|
|
196
|
+
* `@bakery-framework/orm` ships TypeScript source that calls `Bun.*`, so pulling
|
|
197
|
+
* it into a config without `bun-types` produces errors from inside the package
|
|
198
|
+
* rather than types for the app. Measured when this was applied to every
|
|
199
|
+
* project: 187 new errors in `client`.
|
|
200
|
+
*/
|
|
201
|
+
function schemaRegistrationFile(): string | null {
|
|
202
|
+
const configured = Bakery.config.schema
|
|
203
|
+
const candidates = configured
|
|
204
|
+
? [configured, `${configured}/index.ts`]
|
|
205
|
+
: ['orm/index.ts', 'schema.ts']
|
|
206
|
+
|
|
207
|
+
for (const rel of candidates) {
|
|
208
|
+
const abs = fs.resolve(APP_DIR, rel)
|
|
209
|
+
if (fs.isFileSync(abs)) return abs
|
|
210
|
+
}
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
|
|
141
214
|
/** Every project: core's two, plus whatever the loaded plugins contribute. */
|
|
142
215
|
function allProjects(): PluginTsProject[] {
|
|
143
216
|
const projects = coreProjects()
|
|
@@ -174,9 +247,16 @@ function allProjects(): PluginTsProject[] {
|
|
|
174
247
|
*/
|
|
175
248
|
export async function writeProjects(paths: MapOf<string[]>): Promise<string[]> {
|
|
176
249
|
const written: string[] = []
|
|
250
|
+
const found = schemaRegistrationFile()
|
|
251
|
+
const registrationFile = found
|
|
252
|
+
? (() => {
|
|
253
|
+
const rel = fs.relative(PROJECT_DIR, found).replace(/\\/g, '/')
|
|
254
|
+
return RE_RELATIVE.test(rel) ? rel : `./${rel}`
|
|
255
|
+
})()
|
|
256
|
+
: null
|
|
177
257
|
|
|
178
258
|
for (const project of allProjects()) {
|
|
179
|
-
const
|
|
259
|
+
const own = (project.files ?? [])
|
|
180
260
|
.map(entry => {
|
|
181
261
|
const resolved = resolveFilesEntry(entry)
|
|
182
262
|
if (!resolved) {
|
|
@@ -186,6 +266,24 @@ export async function writeProjects(paths: MapOf<string[]>): Promise<string[]> {
|
|
|
186
266
|
})
|
|
187
267
|
.filter((f): f is string => f !== null)
|
|
188
268
|
|
|
269
|
+
const baseFiles = readBase(project.extends)
|
|
270
|
+
|
|
271
|
+
// The schema registration goes to every server-side project, so an SFC's
|
|
272
|
+
// `<script>` gets the app's real tables rather than the `any` fallback. The
|
|
273
|
+
// server project already reaches it through `include: ['orm/**']`; adding it
|
|
274
|
+
// to `files` there is a harmless duplicate and keeps the rule in one place.
|
|
275
|
+
const registration =
|
|
276
|
+
project.server && registrationFile ? [registrationFile] : []
|
|
277
|
+
|
|
278
|
+
// The base's own `files` are merged back in whenever this project declares
|
|
279
|
+
// any of its own, because a child's `files` *replaces* the parent's — see
|
|
280
|
+
// `readBase`. Left entirely empty, TypeScript inherits correctly and there
|
|
281
|
+
// is nothing to repair.
|
|
282
|
+
const declared = [...own, ...registration]
|
|
283
|
+
const files = declared.length
|
|
284
|
+
? [...new Set([...baseFiles, ...declared])]
|
|
285
|
+
: []
|
|
286
|
+
|
|
189
287
|
const config: Record<string, unknown> = {
|
|
190
288
|
$comment:
|
|
191
289
|
'GENERATED by Bakery on dev boot. Edits are lost; change the plugin or server.config.ts instead.',
|
package/src/core/bakery.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { HandlerMap } from '../handlers/core/$registry'
|
|
|
2
2
|
import { fs } from '../utils/fs'
|
|
3
3
|
import { SharedMemoryPool } from '../utils/shared-pool'
|
|
4
4
|
import { getConfig, resolveHostname } from './config'
|
|
5
|
-
import { getAppVersion, hostStore } from './context'
|
|
5
|
+
import { cacheDir, dataDir, getAppVersion, hostStore } from './context'
|
|
6
6
|
|
|
7
7
|
export type { HostContext } from './context'
|
|
8
8
|
export { hostStore } from './context'
|
|
@@ -70,17 +70,16 @@ export const Bakery: globalThis.Bakery = {
|
|
|
70
70
|
return getAppVersion()
|
|
71
71
|
},
|
|
72
72
|
sharedPool: new SharedMemoryPool(1024 * 1024),
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
dataDir: `${fs.cwd}/bakery`,
|
|
73
|
+
// Defined in `core/context.ts`, which is low enough that a module needing a
|
|
74
|
+
// path does not have to import `Bakery` to get one — reaching them through
|
|
75
|
+
// here is what closed the logger cycle. These stay the reading surface for
|
|
76
|
+
// application and framework code; context is the single definition.
|
|
77
|
+
//
|
|
78
|
+
// Called here rather than forwarded through a getter, so these remain plain
|
|
79
|
+
// writable properties: `nm.test.ts` repoints them at a fixture tree, which a
|
|
80
|
+
// getter turns into `TypeError: Attempted to assign to readonly property`.
|
|
81
|
+
cacheDir: cacheDir(),
|
|
82
|
+
dataDir: dataDir(),
|
|
84
83
|
startNs: Bun.nanoseconds(),
|
|
85
84
|
handlers: {
|
|
86
85
|
fetch: new HandlerMap(),
|
|
@@ -100,12 +100,37 @@ async function run(): Promise<void> {
|
|
|
100
100
|
*/
|
|
101
101
|
export const __wipeCacheDir = wipe
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The one thing in `.cache/` the wipe must not take.
|
|
105
|
+
*
|
|
106
|
+
* **The app's committed `tsconfig.json` *references* `.cache/tsconfig/*.json`,
|
|
107
|
+
* so deleting them breaks the editor for the whole project** — not one setting,
|
|
108
|
+
* everything. TypeScript reports `TS6053: File '…/server.json' not found` for
|
|
109
|
+
* each reference, has no project left to put a file in, and falls back to an
|
|
110
|
+
* inferred one with no ambients: `req.session`, `Bakery`, the JSX namespace and
|
|
111
|
+
* the app's own schema types all stop resolving at once.
|
|
112
|
+
*
|
|
113
|
+
* That happens on every framework upgrade, because the version wipe is keyed on
|
|
114
|
+
* the framework version among others. The developer sees their editor lose every
|
|
115
|
+
* type the moment they bump a patch, and nothing says why — the files come back
|
|
116
|
+
* only on the next `bun run dev`, which is not an obvious remedy for "my types
|
|
117
|
+
* vanished".
|
|
118
|
+
*
|
|
119
|
+
* Keeping them is safe in the direction that matters. A stale project is
|
|
120
|
+
* regenerated on the next dev boot and is, in the meantime, *approximately
|
|
121
|
+
* right* — while a missing one is catastrophically wrong. Nothing is executed
|
|
122
|
+
* from these files either: they configure a typechecker, so the "never read a
|
|
123
|
+
* cache an older framework wrote" rule the wipe exists to enforce does not apply.
|
|
124
|
+
*/
|
|
125
|
+
const WIPE_KEEP = new Set(['tsconfig'])
|
|
126
|
+
|
|
103
127
|
async function wipe(dir: string): Promise<string[]> {
|
|
104
128
|
if (!fs.exists(dir)) return []
|
|
105
129
|
const [readErr, entries] = await Try.catch(() => readdir(dir))
|
|
106
130
|
if (readErr || !entries) return ['<unreadable>']
|
|
107
131
|
|
|
108
132
|
for (const entry of entries) {
|
|
133
|
+
if (WIPE_KEEP.has(entry)) continue
|
|
109
134
|
// Errors are deliberately not swallowed *silently* here — each failure is
|
|
110
135
|
// collected and reported by the caller.
|
|
111
136
|
await Try.catch(() =>
|
|
@@ -115,5 +140,7 @@ async function wipe(dir: string): Promise<string[]> {
|
|
|
115
140
|
|
|
116
141
|
const [rereadErr, left] = await Try.catch(() => readdir(dir))
|
|
117
142
|
if (rereadErr) return ['<unreadable>']
|
|
118
|
-
|
|
143
|
+
// Kept entries are not survivors of a failed delete, and reporting them as
|
|
144
|
+
// such would make the caller withhold the "cache is current" marker forever.
|
|
145
|
+
return (left ?? []).filter(entry => !WIPE_KEEP.has(entry))
|
|
119
146
|
}
|
package/src/core/context.ts
CHANGED
|
@@ -29,6 +29,40 @@ export type HostContext = {
|
|
|
29
29
|
|
|
30
30
|
export const hostStore = new AsyncLocalStorage<HostContext>()
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The two runtime directories, defined here rather than on `Bakery`.
|
|
34
|
+
*
|
|
35
|
+
* `Bakery.cacheDir` / `Bakery.dataDir` remain the way application and framework
|
|
36
|
+
* code reads them — these are the single definition those two forward to, and
|
|
37
|
+
* still the only writer of either path. They live in this module because it is
|
|
38
|
+
* low enough to be imported without pulling in `core/config`, and therefore
|
|
39
|
+
* without pulling in the logger: `compiler/prompt-tracker.ts` needs the cache
|
|
40
|
+
* directory and reaching it through `Bakery` closed a module cycle that made
|
|
41
|
+
* the whole package unimportable. See the note on `prompt-tracker.ts`.
|
|
42
|
+
*
|
|
43
|
+
* **Functions, not constants, and that is not a style choice.** `utils/fs.ts`
|
|
44
|
+
* imports this module for `hostStore`, so the two are themselves a cycle: a
|
|
45
|
+
* top-level `` `${fs.cwd}/.cache` `` here is evaluated with `fs` still
|
|
46
|
+
* uninitialised whenever `core/context` is reached first, and throws
|
|
47
|
+
* `TypeError: undefined is not an object`. Reading `fs.cwd` at call time is
|
|
48
|
+
* what makes the order irrelevant.
|
|
49
|
+
*
|
|
50
|
+
* The disposable directory is the hidden one, and the precious one is not. This
|
|
51
|
+
* is the reverse of the old `.bakery/cache` + `.data` pairing, and the reversal
|
|
52
|
+
* is the whole point: `.cache` is wiped by the framework itself on every version
|
|
53
|
+
* bump and dev<->prod switch, so a `rm -rf .*` or a "clean out the dotfiles"
|
|
54
|
+
* sweep does exactly what the framework already does. The database is not
|
|
55
|
+
* disposable, so it does not live behind a leading dot where such a sweep can
|
|
56
|
+
* reach it, and never under `.cache` — clearing a cache must not destroy data.
|
|
57
|
+
*/
|
|
58
|
+
export function cacheDir(): string {
|
|
59
|
+
return `${fs.cwd}/.cache`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function dataDir(): string {
|
|
63
|
+
return `${fs.cwd}/bakery`
|
|
64
|
+
}
|
|
65
|
+
|
|
32
66
|
/**
|
|
33
67
|
* `matchBlocked`, deduplicated within the current request.
|
|
34
68
|
*
|
|
@@ -51,14 +85,6 @@ export function matchBlockedCached(
|
|
|
51
85
|
return verdict
|
|
52
86
|
}
|
|
53
87
|
|
|
54
|
-
/**
|
|
55
|
-
* The **application's** version, from `<cwd>/package.json`.
|
|
56
|
-
*
|
|
57
|
-
* Named `getBakeryVersion` until 2026-08-09, which is exactly the wrong name:
|
|
58
|
-
* it reads the package.json of whatever is being served, not the framework's.
|
|
59
|
-
* That misnomer hid a real bug for as long as it existed — see
|
|
60
|
-
* {@link getFrameworkVersion}.
|
|
61
|
-
*/
|
|
62
88
|
/**
|
|
63
89
|
* What each version reader returns when it cannot read its manifest.
|
|
64
90
|
*
|
|
@@ -78,6 +104,14 @@ const UNKNOWN_APP = '0.0.0-unknown-app'
|
|
|
78
104
|
const UNKNOWN_FW = '0.0.0-unknown-framework'
|
|
79
105
|
|
|
80
106
|
let _appVersion: string | null = null
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The **application's** version, from `<cwd>/package.json`.
|
|
110
|
+
*
|
|
111
|
+
* Not the framework's — this reads the manifest of whatever is being served.
|
|
112
|
+
* The distinction is load-bearing for cache invalidation; see
|
|
113
|
+
* {@link getFrameworkVersion}.
|
|
114
|
+
*/
|
|
81
115
|
export function getAppVersion() {
|
|
82
116
|
if (_appVersion) return _appVersion
|
|
83
117
|
try {
|
package/src/core/index.ts
CHANGED
package/src/core/init.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomId } from '../utils/isomorphic/misc'
|
|
1
2
|
import { createElement, Fragment, html } from './jsx'
|
|
2
3
|
|
|
3
4
|
const hasDevWorkerArg = process.argv.includes('--dev-worker')
|
|
@@ -84,6 +85,11 @@ Object.assign(globalThis, {
|
|
|
84
85
|
createElement,
|
|
85
86
|
Fragment,
|
|
86
87
|
html,
|
|
88
|
+
// The same value the browser runtime binds (`client/utils.ts`), so code
|
|
89
|
+
// that moves between an SFC's browser script and its server block — where
|
|
90
|
+
// it runs as a bare global either way — does not lose the name. Declared
|
|
91
|
+
// once, in `shared.d.ts`.
|
|
92
|
+
randomId,
|
|
87
93
|
})
|
|
88
94
|
|
|
89
95
|
process.on('SIGHUP', () => {})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { bundleModule } from '../../compiler'
|
|
2
2
|
import { Bakery } from '../../core/bakery'
|
|
3
|
-
import { toHash } from '../../utils/common'
|
|
3
|
+
import { Try, toHash } from '../../utils/common'
|
|
4
4
|
import { fs } from '../../utils/fs'
|
|
5
5
|
import { Handler } from '../core/$base'
|
|
6
6
|
|
|
@@ -13,6 +13,52 @@ export class NMHandler extends Handler {
|
|
|
13
13
|
return path.startsWith('/_nm/')
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The file a `/_nm/` path names.
|
|
18
|
+
*
|
|
19
|
+
* **The literal path wins whenever it exists, and the order is the whole
|
|
20
|
+
* point.** `Bun.build` resolves a directory or package entry itself, with
|
|
21
|
+
* *browser* conditions — which is what picks `vue`'s `esm-bundler` build over
|
|
22
|
+
* the CJS one behind its `node` condition. `Bun.resolveSync` has no such
|
|
23
|
+
* knob: it answers with Bun's own server conditions. Resolving here first and
|
|
24
|
+
* handing `Bun.build` the concrete file therefore silently downgraded every
|
|
25
|
+
* package with a `browser`/`node` split — `/_nm/vue` came back as
|
|
26
|
+
* `index.mjs` re-exporting `vue.cjs.js`, and the browser then rejected
|
|
27
|
+
* `import { Fragment } from 'vue'`.
|
|
28
|
+
*
|
|
29
|
+
* So the literal path goes first whenever it is on disk, and `resolveSync` is
|
|
30
|
+
* the second candidate rather than the first. It is still needed, for two
|
|
31
|
+
* distinct cases: a public subpath that does not match the physical layout —
|
|
32
|
+
* `@vue-material/core` maps `"./utils"` to `./dist/utils/index.js`, so the
|
|
33
|
+
* documented `@vue-material/core/utils` has no `utils` directory to find and
|
|
34
|
+
* used to 500 — and a package whose root is declared *only* through
|
|
35
|
+
* `exports`, where `Bun.build` on the directory answers `ModuleNotFound`
|
|
36
|
+
* because it looks for `main`/`module` and finds neither.
|
|
37
|
+
*
|
|
38
|
+
* Hence a list rather than one answer: the caller bundles the first candidate
|
|
39
|
+
* that builds. Both orders are wrong on their own.
|
|
40
|
+
*/
|
|
41
|
+
private static resolveEntry(nmPath: string, nmRoot: string): string[] {
|
|
42
|
+
const literal = fs.resolve(Bakery.root, nmPath)
|
|
43
|
+
const candidates = fs.exists(literal) ? [literal] : []
|
|
44
|
+
|
|
45
|
+
const specifier = nmPath.replace(/^node_modules\//, '')
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const resolved = fs.resolve(Bun.resolveSync(specifier, Bakery.root))
|
|
49
|
+
// A resolver answer still has to be inside `node_modules`: an `exports`
|
|
50
|
+
// map and a `browser` field can both point outside the package, and this
|
|
51
|
+
// path is reachable from a URL.
|
|
52
|
+
if (resolved.startsWith(`${nmRoot}/`) && resolved !== literal) {
|
|
53
|
+
candidates.push(resolved)
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Not exposed by the map, or no map to consult.
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return candidates.length ? candidates : [literal]
|
|
60
|
+
}
|
|
61
|
+
|
|
16
62
|
/**
|
|
17
63
|
* Serve a browser-ready bundle of a file inside `node_modules`.
|
|
18
64
|
*
|
|
@@ -21,13 +67,15 @@ export class NMHandler extends Handler {
|
|
|
21
67
|
* comment names now go through it. This one cannot, and the reason is not
|
|
22
68
|
* the root (it takes a `roots` argument) — it is that `getStatic` answers
|
|
23
69
|
* "is there a plain file literally at this path", and `/_nm/` deliberately
|
|
24
|
-
* asks a different question
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
70
|
+
* asks a different question: what would an importer of this specifier get?
|
|
71
|
+
* `resolveEntry` answers it, applying the package's `exports` map, and
|
|
72
|
+
* `Bun.build` then does directory-index resolution on whatever survives — so
|
|
73
|
+
* `/_nm/pkg/sub` reaches `pkg/sub/index.js`, which is precisely what the
|
|
74
|
+
* import map's `"<pkg>/": "/_nm/<pkg>/"` prefix entry (`utils/http/dom.ts`)
|
|
75
|
+
* produces for an extensionless subpath import. `getStatic` returns `null`
|
|
76
|
+
* for that path — it is a directory — so routing through it would turn every
|
|
77
|
+
* extensionless subpath import into a 204. `nm.test.ts` pins both halves of
|
|
78
|
+
* that divergence.
|
|
31
79
|
*
|
|
32
80
|
* What *was* missing is the second half of the pair. Containment was spelled
|
|
33
81
|
* out here and was correct, but `.forbidden` was never checked, so `/_nm/*`
|
|
@@ -43,16 +91,21 @@ export class NMHandler extends Handler {
|
|
|
43
91
|
static async handle(path: string) {
|
|
44
92
|
const nmPath = path.replace(/^\/_nm\//, 'node_modules/')
|
|
45
93
|
const nmRoot = fs.resolve(Bakery.root, 'node_modules')
|
|
46
|
-
const
|
|
94
|
+
const candidates = this.resolveEntry(nmPath, nmRoot)
|
|
47
95
|
|
|
48
96
|
// Strictly below the root, so `node_modules` itself is not an entry point.
|
|
49
97
|
// `getStatic` admits `file === root` and then rejects it as a directory;
|
|
50
98
|
// here the same answer has to come from the containment test, because
|
|
51
|
-
// nothing downstream stats the path.
|
|
52
|
-
|
|
53
|
-
|
|
99
|
+
// nothing downstream stats the path. Every candidate is checked: the
|
|
100
|
+
// resolver's answer is no more trusted than the URL's.
|
|
101
|
+
const allowed = candidates.filter(
|
|
102
|
+
candidate =>
|
|
103
|
+
candidate.startsWith(`${nmRoot}/`) &&
|
|
104
|
+
!fs.isForbidden(candidate, nmRoot),
|
|
105
|
+
)
|
|
106
|
+
if (!allowed.length) return undefined
|
|
54
107
|
|
|
55
|
-
const nmFile = Bun.file(
|
|
108
|
+
const nmFile = Bun.file(allowed[0])
|
|
56
109
|
const sourceMtime = fs.exists(nmFile) ? nmFile.lastModified : null
|
|
57
110
|
|
|
58
111
|
const cacheId = toHash(nmPath)
|
|
@@ -63,8 +116,14 @@ export class NMHandler extends Handler {
|
|
|
63
116
|
cacheName,
|
|
64
117
|
sourceMtime,
|
|
65
118
|
async () => {
|
|
66
|
-
const
|
|
67
|
-
|
|
119
|
+
for (const candidate of allowed) {
|
|
120
|
+
// Wrapped: an entry point Bun cannot resolve is a *throw*, not a
|
|
121
|
+
// `success: false`, and that is the expected outcome of the first
|
|
122
|
+
// candidate for a package whose root exists only in `exports`.
|
|
123
|
+
const [err, module] = await Try.catch(() => bundleModule(candidate))
|
|
124
|
+
if (!err && module?.success && module.content) return module.content
|
|
125
|
+
}
|
|
126
|
+
return null
|
|
68
127
|
},
|
|
69
128
|
)
|
|
70
129
|
|
|
@@ -6,21 +6,43 @@ import { processBody } from '../../utils/http'
|
|
|
6
6
|
const RX_PARAM = /[[\]{}()*+?.\\^$|]/g
|
|
7
7
|
export const RX_DYNAMIC = /\[([\w$]+)\]/
|
|
8
8
|
export const RX_CATCHALL = /\[\.\.\.([\w$]+)\]/
|
|
9
|
+
export const RX_OPT_CATCHALL = /\[\.\.\.([\w$]+)!\]/
|
|
9
10
|
|
|
10
11
|
export function getDynamicRoute(path: string): Handler.Dynamic.Route | null {
|
|
11
12
|
const cleanPath = path.replace(/\\/g, '/').replace(/^\/+/, '')
|
|
12
13
|
if (!cleanPath) return null
|
|
13
|
-
|
|
14
|
+
// Three disjoint spellings: `!` before the `]` keeps `[...x!]` from
|
|
15
|
+
// matching either of the other two, so each test sees only its own form.
|
|
16
|
+
if (
|
|
17
|
+
!RX_DYNAMIC.test(cleanPath) &&
|
|
18
|
+
!RX_CATCHALL.test(cleanPath) &&
|
|
19
|
+
!RX_OPT_CATCHALL.test(cleanPath)
|
|
20
|
+
) {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
14
23
|
|
|
15
24
|
const params: string[] = []
|
|
16
25
|
const segments = cleanPath.split('/')
|
|
17
26
|
const last = segments.length - 1
|
|
18
27
|
let catchAll = false
|
|
28
|
+
let optionalCatchAll = false
|
|
19
29
|
|
|
20
30
|
const mappedPaths: string[] = []
|
|
21
31
|
for (let i = 0; i < segments.length; i++) {
|
|
22
32
|
const segment = segments[i]
|
|
23
33
|
|
|
34
|
+
const optionalMatch = segment.match(RX_OPT_CATCHALL)
|
|
35
|
+
if (optionalMatch) {
|
|
36
|
+
if (i !== last) return null
|
|
37
|
+
params.push(optionalMatch[1])
|
|
38
|
+
// The whole segment — separator included — is optional, so the pattern
|
|
39
|
+
// is assembled below rather than pushed here: `docs/[...slug!]` has to
|
|
40
|
+
// match `/docs` itself, which `/docs/(.*)` cannot.
|
|
41
|
+
catchAll = true
|
|
42
|
+
optionalCatchAll = true
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
const catchAllMatch = segment.match(RX_CATCHALL)
|
|
25
47
|
if (catchAllMatch) {
|
|
26
48
|
// Only terminal: a segment after `[...x]` has no unambiguous meaning
|
|
@@ -30,7 +52,9 @@ export function getDynamicRoute(path: string): Handler.Dynamic.Route | null {
|
|
|
30
52
|
params.push(catchAllMatch[1])
|
|
31
53
|
// `.+` rather than `.*`: the catch-all requires at least one segment,
|
|
32
54
|
// so `docs/[...slug]` does not shadow a `docs/index` sibling for
|
|
33
|
-
// `/docs` itself.
|
|
55
|
+
// `/docs` itself. `[...slug!]` is the spelling that opts into the
|
|
56
|
+
// bare directory — and an index sibling still wins there, because
|
|
57
|
+
// static discovery runs before dynamic in `resolveRouteFile`.
|
|
34
58
|
mappedPaths.push('(.+)')
|
|
35
59
|
catchAll = true
|
|
36
60
|
continue
|
|
@@ -46,10 +70,16 @@ export function getDynamicRoute(path: string): Handler.Dynamic.Route | null {
|
|
|
46
70
|
mappedPaths.push(segment.replace(RX_PARAM, '\\$&'))
|
|
47
71
|
}
|
|
48
72
|
|
|
73
|
+
const joined = mappedPaths.join('/')
|
|
74
|
+
const body = optionalCatchAll
|
|
75
|
+
? `${joined ? `/${joined}` : ''}(?:/(.*))?`
|
|
76
|
+
: `/${joined}`
|
|
77
|
+
|
|
49
78
|
return {
|
|
50
|
-
pattern: new RegExp(
|
|
79
|
+
pattern: new RegExp(`^${body}(?:\\.([a-z]*))?$`),
|
|
51
80
|
params,
|
|
52
81
|
catchAll,
|
|
82
|
+
optionalCatchAll,
|
|
53
83
|
}
|
|
54
84
|
}
|
|
55
85
|
|
|
@@ -62,8 +92,9 @@ export namespace RouteData {
|
|
|
62
92
|
readonly valid: boolean
|
|
63
93
|
readonly isDynamic: boolean
|
|
64
94
|
readonly catchAll: boolean
|
|
95
|
+
readonly optionalCatchAll: boolean
|
|
65
96
|
readonly regex: RegExp | null
|
|
66
|
-
getParams(path: string): MapOf<string> | null
|
|
97
|
+
getParams(path: string): MapOf<string | string[]> | null
|
|
67
98
|
}
|
|
68
99
|
|
|
69
100
|
export type Meta = {
|
|
@@ -92,6 +123,7 @@ export class RouteData {
|
|
|
92
123
|
readonly path: fs.RelativePath
|
|
93
124
|
readonly regex: RegExp | null
|
|
94
125
|
readonly catchAll: boolean
|
|
126
|
+
readonly optionalCatchAll: boolean
|
|
95
127
|
|
|
96
128
|
constructor(filePath: fs.AbsolutePath, path: fs.RelativePath) {
|
|
97
129
|
this.filePath = fs.resolve(filePath) as fs.AbsolutePath
|
|
@@ -101,6 +133,7 @@ export class RouteData {
|
|
|
101
133
|
this.regex = route?.pattern || null
|
|
102
134
|
this.params = route?.params || []
|
|
103
135
|
this.catchAll = route?.catchAll || false
|
|
136
|
+
this.optionalCatchAll = route?.optionalCatchAll || false
|
|
104
137
|
}
|
|
105
138
|
|
|
106
139
|
get file() {
|
|
@@ -115,17 +148,28 @@ export class RouteData {
|
|
|
115
148
|
return this.regex !== null
|
|
116
149
|
}
|
|
117
150
|
|
|
118
|
-
getParams(path: string): MapOf<string> | null {
|
|
151
|
+
getParams(path: string): MapOf<string | string[]> | null {
|
|
119
152
|
if (!this.regex) return null
|
|
120
153
|
const cleanPath = path.startsWith('/') ? path : `/${path}`
|
|
121
154
|
const match = cleanPath.match(this.regex)
|
|
122
155
|
if (!match) return null
|
|
123
156
|
|
|
124
|
-
const boundParams: MapOf<string> = {}
|
|
157
|
+
const boundParams: MapOf<string | string[]> = {}
|
|
125
158
|
for (let i = 0; i < this.params.length; i++) {
|
|
126
|
-
|
|
159
|
+
const value = match[i + 1]
|
|
160
|
+
// The catch-all is always terminal, so it is always the last param —
|
|
161
|
+
// and it binds as the *segments*, not the joined string: every
|
|
162
|
+
// consumer was calling `.split('/')` on it anyway, and the joined
|
|
163
|
+
// form silently conflated `/docs/a%2Fb` with `/docs/a/b`. A bare
|
|
164
|
+
// directory under `[...name!]` binds `[]`, which is also what makes
|
|
165
|
+
// "no rest" distinguishable from a single empty segment.
|
|
166
|
+
if (this.catchAll && i === this.params.length - 1) {
|
|
167
|
+
boundParams[this.params[i]] = value ? value.split('/') : []
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
boundParams[this.params[i]] = value
|
|
127
171
|
}
|
|
128
|
-
return boundParams
|
|
172
|
+
return boundParams
|
|
129
173
|
}
|
|
130
174
|
}
|
|
131
175
|
}
|
|
@@ -147,6 +191,8 @@ export namespace Handler {
|
|
|
147
191
|
params: string[]
|
|
148
192
|
/** True when the final segment is a `[...name]` multi-segment matcher. */
|
|
149
193
|
catchAll?: boolean
|
|
194
|
+
/** True for the `[[...name]]` form, which also matches its bare directory. */
|
|
195
|
+
optionalCatchAll?: boolean
|
|
150
196
|
}
|
|
151
197
|
}
|
|
152
198
|
|
|
@@ -10,9 +10,10 @@ import {
|
|
|
10
10
|
type Route,
|
|
11
11
|
RX_CATCHALL,
|
|
12
12
|
RX_DYNAMIC,
|
|
13
|
+
RX_OPT_CATCHALL,
|
|
13
14
|
} from './$base'
|
|
14
15
|
import { resolveMount } from './$mounts'
|
|
15
|
-
import { getRoute } from './$routing'
|
|
16
|
+
import { getRoute, servedSourceExists } from './$routing'
|
|
16
17
|
|
|
17
18
|
const dynamicCaches = new Map<any, HandlerCache<RegExp, Route.Info>>()
|
|
18
19
|
|
|
@@ -73,9 +74,17 @@ export class DynamicHandler extends Handler {
|
|
|
73
74
|
|
|
74
75
|
static canHandle(path: string, req?: Request): MixedPromise<boolean>
|
|
75
76
|
static async canHandle(path: string) {
|
|
76
|
-
// A request path spelled like a route template ('/blog/[id]'
|
|
77
|
-
// '/docs/[...slug]') addresses the template file,
|
|
78
|
-
|
|
77
|
+
// A request path spelled like a route template ('/blog/[id]',
|
|
78
|
+
// '/docs/[...slug]' or '/docs/[...slug!]') addresses the template file,
|
|
79
|
+
// not a route. `RX_OPT_CATCHALL` is tested too because the `!` keeps the
|
|
80
|
+
// optional spelling from matching `RX_CATCHALL`.
|
|
81
|
+
if (
|
|
82
|
+
RX_DYNAMIC.test(path) ||
|
|
83
|
+
RX_CATCHALL.test(path) ||
|
|
84
|
+
RX_OPT_CATCHALL.test(path)
|
|
85
|
+
) {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
79
88
|
if (this.cache.has(hostKey(path))) return true
|
|
80
89
|
// The dynamic half of the line above. A dynamic route is never written to
|
|
81
90
|
// `this.cache`, so without this every request to one ran `resolveRoute`
|
|
@@ -149,18 +158,20 @@ export class DynamicHandler extends Handler {
|
|
|
149
158
|
}
|
|
150
159
|
if (deferred) {
|
|
151
160
|
// A real file always beats a catch-all, whatever handler would serve
|
|
152
|
-
// it: when the requested path names an existing file,
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
161
|
+
// it: when the requested path names an existing file — literally, or
|
|
162
|
+
// through a compiled extension like `provides.ts` at `/provides.js`
|
|
163
|
+
// (see `servedSourceExists`) — every catch-all declines so the file's
|
|
164
|
+
// own handler (possibly lower-priority — CSS falls all the way to
|
|
165
|
+
// StaticHandler) gets asked. A handful of stats, paid only when a
|
|
166
|
+
// catch-all is about to answer. `getCatchAllRoute` applies the same
|
|
167
|
+
// rule on the discovery path; the two must agree — including the
|
|
157
168
|
// containment clamp: `root + path` is unresolved, so a `..` in the
|
|
158
169
|
// path would have `statSync` resolve it outside the root and turn this
|
|
159
170
|
// into an existence probe. See the comment there.
|
|
160
171
|
const target = fs.resolve(root, `.${path}`)
|
|
161
172
|
if (
|
|
162
173
|
(target === root || target.startsWith(`${root}/`)) &&
|
|
163
|
-
|
|
174
|
+
servedSourceExists(target)
|
|
164
175
|
) {
|
|
165
176
|
return null
|
|
166
177
|
}
|