@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.
@@ -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 files = (project.files ?? [])
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.',
@@ -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
- // The disposable directory is the hidden one, and the precious one is not.
74
- // This is the reverse of the old `.bakery/cache` + `.data` pairing, and the
75
- // reversal is the whole point: `.cache` is wiped by the framework itself on
76
- // every version bump and dev<->prod switch, so a `rm -rf .*` or a "clean out
77
- // the dotfiles" sweep does exactly what the framework already does. The
78
- // database is not disposable, so it does not live behind a leading dot where
79
- // such a sweep can reach it.
80
- cacheDir: `${fs.cwd}/.cache`,
81
- // Holds the database and its backups. Visible, and deliberately not under
82
- // `.cache`: clearing a cache must never be able to destroy data.
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
- return left ?? []
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
  }
@@ -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
@@ -19,6 +19,7 @@ export type {
19
19
  MixedPromise,
20
20
  RouteBody,
21
21
  RouteHandler,
22
+ RouteParam,
22
23
  RouteResponse,
23
24
  Wrapped,
24
25
  } from '../types'
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. The entry here goes to `Bun.build`, which does
25
- * Node resolution: `/_nm/pkg/sub` resolves to `pkg/sub/index.js`, and that is
26
- * precisely what the import map's `"<pkg>/": "/_nm/<pkg>/"` prefix entry
27
- * (`utils/http/dom.ts`) produces for an extensionless subpath import.
28
- * `getStatic` returns `null` for that path — it is a directory — so routing
29
- * through it would turn every extensionless subpath import into a 204.
30
- * `nm.test.ts` pins both halves of that divergence.
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 nodeModulesPath = fs.resolve(Bakery.root, nmPath)
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
- if (!nodeModulesPath.startsWith(`${nmRoot}/`)) return undefined
53
- if (fs.isForbidden(nodeModulesPath, nmRoot)) return undefined
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(nodeModulesPath)
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 module = await bundleModule(nodeModulesPath)
67
- return module.success && module.content ? module.content : null
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
- if (!RX_DYNAMIC.test(cleanPath) && !RX_CATCHALL.test(cleanPath)) return null
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(`^/${mappedPaths.join('/')}(?:\\.([a-z]*))?$`),
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
- boundParams[this.params[i]] = match[i + 1]
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 as MapOf<string>
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]' or
77
- // '/docs/[...slug]') addresses the template file, not a route.
78
- if (RX_DYNAMIC.test(path) || RX_CATCHALL.test(path)) return false
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, every catch-all
153
- // declines so the file's own handler (possibly lower-priority — CSS
154
- // falls all the way to StaticHandler) gets asked. One stat, paid only
155
- // when a catch-all is about to answer. `getCatchAllRoute` applies the
156
- // same rule on the discovery path; the two must agree — including the
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
- fs.isFileSync(target)
174
+ servedSourceExists(target)
164
175
  ) {
165
176
  return null
166
177
  }