@bakery-framework/core 2.0.0-alpha.8 → 2.0.0-rc.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/README.md +8 -8
- package/package.json +2 -5
- package/src/cache/lru.ts +2 -2
- package/src/cache/shared-db.ts +81 -11
- package/src/cache/string.ts +4 -0
- package/src/cache/tiered.ts +11 -5
- package/src/client/globals.d.ts +7 -7
- package/src/client/livereload.ts +2 -2
- package/src/client/utils.ts +11 -6
- package/src/compiler/compiler.ts +48 -44
- package/src/compiler/dev-service.ts +29 -30
- package/src/compiler/prompt-tracker.ts +2 -2
- package/src/compiler/tsconfig-sync.ts +130 -59
- package/src/core/bakery.ts +6 -3
- package/src/core/cache-version.ts +68 -18
- package/src/core/config.ts +16 -16
- package/src/core/context.ts +8 -8
- package/src/core/define-route.ts +5 -6
- package/src/core/index.ts +19 -7
- package/src/core/init.ts +38 -35
- package/src/core/jsx.ts +19 -4
- package/src/core/paths.ts +3 -1
- package/src/core/plugins.ts +15 -4
- package/src/core/port.ts +6 -6
- package/src/global.d.ts +36 -18
- package/src/handlers/assets/google-font.ts +6 -6
- package/src/handlers/assets/image.ts +36 -3
- package/src/handlers/assets/nm.ts +7 -7
- package/src/handlers/assets/static.ts +55 -12
- package/src/handlers/assets/ts.ts +3 -3
- package/src/handlers/assets/tsx.ts +1 -1
- package/src/handlers/assets/virtual-asset.ts +1 -1
- package/src/handlers/core/$base.ts +24 -8
- package/src/handlers/core/$dynamic.ts +66 -23
- package/src/handlers/core/$error.ts +29 -17
- package/src/handlers/core/$middleware.ts +6 -6
- package/src/handlers/core/$mounts.ts +2 -2
- package/src/handlers/core/$registry.ts +16 -6
- package/src/handlers/core/$routing.ts +18 -19
- package/src/handlers/core/$static.ts +11 -12
- package/src/handlers/routes/api.ts +5 -5
- package/src/handlers/routes/html.ts +2 -2
- package/src/handlers/routes/proxy.ts +1 -1
- package/src/logger/clients.ts +2 -2
- package/src/logger/logger.ts +4 -4
- package/src/logger/serve-log.ts +32 -18
- package/src/plugins/index.ts +1 -1
- package/src/plugins/routes.ts +7 -7
- package/src/plugins/types.ts +13 -11
- package/src/router.ts +12 -12
- package/src/session.ts +41 -23
- package/src/shared.d.ts +5 -5
- package/src/startup.ts +3 -3
- package/src/types.d.ts +10 -10
- package/src/utils/common/case.ts +1 -1
- package/src/utils/common/match.ts +1 -1
- package/src/utils/common/misc.ts +1 -1
- package/src/utils/constants.ts +15 -15
- package/src/utils/fs.ts +97 -39
- package/src/utils/http/authorize.ts +24 -17
- package/src/utils/http/cors.ts +6 -6
- package/src/utils/http/credential.ts +4 -4
- package/src/utils/http/csrf.ts +6 -7
- package/src/utils/http/dom.ts +9 -9
- package/src/utils/http/etag.ts +54 -13
- package/src/utils/http/html.ts +29 -20
- package/src/utils/http/ip.ts +1 -1
- package/src/utils/http/sse.ts +11 -11
- package/src/utils/http/url.ts +4 -4
- package/src/utils/http/validate.ts +3 -4
- package/src/utils/isomorphic/escape.ts +1 -1
- package/src/utils/isomorphic/is.ts +2 -2
- package/src/utils/isomorphic/misc.ts +1 -1
- package/src/utils/isomorphic/stringify.ts +2 -3
- package/src/utils/isomorphic/try.ts +1 -1
- package/src/utils/shared-pool.ts +23 -16
- package/tsconfig.app.json +3 -3
- package/tsconfig.vue.json +3 -3
package/src/plugins/types.ts
CHANGED
|
@@ -6,18 +6,20 @@ 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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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. */
|
|
17
19
|
name: string
|
|
18
20
|
/** Usually one of core's three bases. Written through as-is. */
|
|
19
21
|
extends: string
|
|
20
|
-
/** Globs, relative to the **app root
|
|
22
|
+
/** Globs, relative to the **app root**: the generator rewrites them. */
|
|
21
23
|
include?: string[]
|
|
22
24
|
exclude?: string[]
|
|
23
25
|
/**
|
|
@@ -34,7 +36,7 @@ export interface PluginTsProject {
|
|
|
34
36
|
*
|
|
35
37
|
* Off by default, and that default is the correction: the generator used to
|
|
36
38
|
* write those `paths` into *every* project on the reasoning that an alias is
|
|
37
|
-
* app-wide. It is not. `importMap` is a **browser** import map
|
|
39
|
+
* app-wide. It is not. `importMap` is a **browser** import map: the framework
|
|
38
40
|
* serves it as `<script type="importmap">` and the browser is what resolves
|
|
39
41
|
* its specifiers. An alias in the server project therefore typechecks an
|
|
40
42
|
* import that only the browser can satisfy, which is the same class of bug the
|
|
@@ -47,13 +49,13 @@ export interface PluginTsProject {
|
|
|
47
49
|
* Does code in this project run on the server?
|
|
48
50
|
*
|
|
49
51
|
* Declared, not inferred. The first version read `bun-types` out of the base
|
|
50
|
-
* config and treated that as the marker
|
|
52
|
+
* config and treated that as the marker: true today, and an inference about
|
|
51
53
|
* intent drawn from a detail that exists for another reason. A plugin saying
|
|
52
54
|
* what its project *is* cannot drift out of step with itself.
|
|
53
55
|
*
|
|
54
56
|
* What it controls: the app's schema registration, so the ORM's tables are the
|
|
55
57
|
* app's own rather than the permissive `any` fallback. Off for browser code on
|
|
56
|
-
* purpose
|
|
58
|
+
* purpose: `@bakery-framework/orm` is server-only, so a `.ts` bound for the
|
|
57
59
|
* browser importing `DB` should fail to typecheck rather than be helpfully
|
|
58
60
|
* typed, and the package ships TypeScript source calling `Bun.*`, which a
|
|
59
61
|
* client config cannot compile anyway.
|
|
@@ -63,7 +65,7 @@ export interface PluginTsProject {
|
|
|
63
65
|
|
|
64
66
|
/** What a plugin contributes to the generated tsconfig projects. */
|
|
65
67
|
export interface PluginTsConfig {
|
|
66
|
-
/** A project this plugin owns outright
|
|
68
|
+
/** A project this plugin owns outright: Vue SFCs, for instance. */
|
|
67
69
|
project?: PluginTsProject
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -77,7 +79,7 @@ export interface ServerPlugin {
|
|
|
77
79
|
*
|
|
78
80
|
* A plugin that brings its own file type or its own ambient globals needs a
|
|
79
81
|
* project to typecheck them under. `@bakery-framework/plugin-vue` is the
|
|
80
|
-
* worked example
|
|
82
|
+
* worked example: it owns `.vue` and declares `req`/`body` for SFC scope,
|
|
81
83
|
* globals core deliberately does not provide.
|
|
82
84
|
*/
|
|
83
85
|
tsconfig?: PluginTsConfig
|
package/src/router.ts
CHANGED
|
@@ -27,11 +27,11 @@ import { parsedUrl } from './utils/http/url'
|
|
|
27
27
|
* The origin check lives here rather than in each `canHandle` on purpose:
|
|
28
28
|
* every socket a plugin or an app registers inherits it, instead of every
|
|
29
29
|
* author having to remember. `/_analytics_ws` was the only handler that got it
|
|
30
|
-
* right on its own, and it did so by authenticating
|
|
30
|
+
* right on its own, and it did so by authenticating: `/_livereload` had
|
|
31
31
|
* nothing, and `WebSocketHandler.canHandle` returns `true` by default, so the
|
|
32
32
|
* base class was handing out sockets to anyone who asked.
|
|
33
33
|
*
|
|
34
|
-
* Returning `false` (rather than a 403) keeps the published signature
|
|
34
|
+
* Returning `false` (rather than a 403) keeps the published signature: the
|
|
35
35
|
* caller below turns a refusal into `400 WebSocket Upgrade Failed`, which is
|
|
36
36
|
* also what an unclaimed path gets. The reason reaches the log; the client is
|
|
37
37
|
* told nothing it did not already know.
|
|
@@ -65,10 +65,10 @@ export async function upgradeWebsocket(
|
|
|
65
65
|
* Whether the deny-list applies to whatever this handler is about to answer
|
|
66
66
|
* with. `Bakery.config.blocked` exists to stop files on disk being served, so
|
|
67
67
|
* handlers that read the request path as a route *name* opt out by declaring
|
|
68
|
-
* `servesFiles = false
|
|
68
|
+
* `servesFiles = false`: `ApiHandler`, `ProxyHandler`, `MiddlewareHandler`.
|
|
69
69
|
*
|
|
70
70
|
* Deny by default, and the direction matters. Naming only the obvious file
|
|
71
|
-
* servers
|
|
71
|
+
* servers (Static, Public, NM) would have left `/schema.ts` and
|
|
72
72
|
* `/server.config.ts` reachable through `TSHandler`, which compiles a source
|
|
73
73
|
* file and serves the result. Anything that does not opt out, including a
|
|
74
74
|
* plugin's handler, keeps the check.
|
|
@@ -94,7 +94,7 @@ export async function handleRequest(req: Request) {
|
|
|
94
94
|
// One read of the config getter, not three: `Bakery.serveRoot` walks
|
|
95
95
|
// `hostStore.getStore()?.config ?? getConfig()` on every access, and the
|
|
96
96
|
// blocked-glob check below used to pay the same walk again. And no
|
|
97
|
-
// `fs.resolve` around the target
|
|
97
|
+
// `fs.resolve` around the target: `isForbidden` normalizes both arguments
|
|
98
98
|
// itself, so that was a second full path resolution of the same string on
|
|
99
99
|
// every request.
|
|
100
100
|
const config = Bakery.config
|
|
@@ -102,7 +102,7 @@ export async function handleRequest(req: Request) {
|
|
|
102
102
|
// Before anything else, including the forbidden-path check: a preflight names
|
|
103
103
|
// the route it is asking about in a *header*, not the path, so running it
|
|
104
104
|
// through routing would answer a question nobody asked. The browser sends no
|
|
105
|
-
// credentials with it either, which is why it cannot be
|
|
105
|
+
// credentials with it either, which is why it cannot be authorized.
|
|
106
106
|
//
|
|
107
107
|
// Only when `cors` is configured. Absent, this is not reached at all and the
|
|
108
108
|
// browser's own default applies.
|
|
@@ -136,7 +136,7 @@ export async function handleRequest(req: Request) {
|
|
|
136
136
|
const handler = await Bakery.handlers.fetch.resolve(path, req)
|
|
137
137
|
if (!handler) return new Response('Not Found', { status: 404 })
|
|
138
138
|
|
|
139
|
-
// `matchBlockedCached`
|
|
139
|
+
// `matchBlockedCached` memoizes the verdict on the request store, so the
|
|
140
140
|
// re-check `StaticHandler.handle` keeps for its direct callers costs a map
|
|
141
141
|
// hit instead of a second pair of glob matches.
|
|
142
142
|
if (servesFiles(handler) && matchBlockedCached(config.blocked, path)) {
|
|
@@ -236,14 +236,14 @@ export const serveWebSocket: Bun.WebSocketHandler<any> = {
|
|
|
236
236
|
* Make an error page answer with the error's status.
|
|
237
237
|
*
|
|
238
238
|
* The HTML and TSX error handlers end in `injectIfHtml`, which builds its
|
|
239
|
-
* Response without one
|
|
239
|
+
* Response without one, so an app with `src/error-404.html` served its 404
|
|
240
240
|
* page as `200 OK` and every crawler, cache and monitor believed it. Applied
|
|
241
241
|
* here rather than inside `injectIfHtml` on purpose: the only status signal
|
|
242
242
|
* available down there is `params`, which for a GET is the query string, and
|
|
243
243
|
* `?errorCode=500` is not something a client gets to decide.
|
|
244
244
|
*
|
|
245
245
|
* A `JsonResponseData` (the `/api/` arm) carries its own `.status` that
|
|
246
|
-
* `processResponse` reads, and a BunFile has no status at all
|
|
246
|
+
* `processResponse` reads, and a BunFile has no status at all: both pass
|
|
247
247
|
* through untouched.
|
|
248
248
|
*/
|
|
249
249
|
function applyErrorStatus(
|
|
@@ -277,7 +277,7 @@ export async function handleRequestError(
|
|
|
277
277
|
|
|
278
278
|
// Wrapped for the same reason the registry call below it is: this is app
|
|
279
279
|
// code. An `onError` that throws used to escape `Try.return`'s fallback in
|
|
280
|
-
// the caller
|
|
280
|
+
// the caller (a fallback that itself rejects is not a fallback), so the one
|
|
281
281
|
// hook whose job is to handle failure took the app's error page down with
|
|
282
282
|
// it and the client got Bun's raw 500 instead.
|
|
283
283
|
const [onErrorFailed, configError] = await Try.catch(() =>
|
|
@@ -353,8 +353,8 @@ export async function processResponse(
|
|
|
353
353
|
// (e.g. an auth cookie from a login route) that must not be overwritten.
|
|
354
354
|
sess && resp.headers.append('Set-Cookie', sess)
|
|
355
355
|
|
|
356
|
-
// Every response funnels through here
|
|
357
|
-
// pages
|
|
356
|
+
// Every response funnels through here (pages, API JSON, static files, error
|
|
357
|
+
// pages), so this is the one place that cannot miss one. Applied before ETag
|
|
358
358
|
// so the negotiated `Vary` sees the `Origin` entry and merges with it rather
|
|
359
359
|
// than either overwriting the other.
|
|
360
360
|
const cors = Bakery.config.cors
|
package/src/session.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { DEFAULT_SESSION_PERSIST, DEFAULT_SESSION_TTL } from './utils/constants'
|
|
|
7
7
|
/**
|
|
8
8
|
* Keys under this prefix are framework-internal privilege markers. They share
|
|
9
9
|
* the same bag as application data, so anything that writes a caller-supplied
|
|
10
|
-
* key must refuse this prefix
|
|
10
|
+
* key must refuse this prefix: otherwise a preferences endpoint (or the
|
|
11
11
|
* dashboard's own session editor) becomes a privilege-escalation primitive.
|
|
12
12
|
*/
|
|
13
13
|
export const RESERVED_SESSION_PREFIX = '__bakery.'
|
|
@@ -17,7 +17,7 @@ export function isReservedSessionKey(key: string): boolean {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* Clock seam
|
|
20
|
+
* Clock seam: the cookie half-life bookkeeping is time-based, and its tests
|
|
21
21
|
* inject a clock instead of sleeping (see `__setTestDb` / `__setTestConfig`
|
|
22
22
|
* for the pattern). Only the cookie-reissue math reads this; `createdAt` and
|
|
23
23
|
* TTL expiry keep `Date.now()`.
|
|
@@ -47,7 +47,7 @@ export function newSessionId(): string {
|
|
|
47
47
|
* The cache key for a session id under the current host.
|
|
48
48
|
*
|
|
49
49
|
* There is one `TieredCache('sessions')` for the whole process, and until this
|
|
50
|
-
* existed every id went into it unqualified
|
|
50
|
+
* existed every id went into it unqualified, so under a multi-tenant `hosts`
|
|
51
51
|
* config one tenant's dashboard listed, read and deleted every other tenant's
|
|
52
52
|
* sessions. `hostKey()` is the guard the five other per-tenant caches already
|
|
53
53
|
* use; sessions were the one that never got it.
|
|
@@ -102,6 +102,19 @@ export class Session<
|
|
|
102
102
|
return Session.cache.count
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Attach the session cookie to a response built outside the pipeline.
|
|
107
|
+
*
|
|
108
|
+
* **Takes the `Request`, and that is not incidental.** `getCookie` reads the
|
|
109
|
+
* session through `hasDeferredValue(req, 'session')`, which looks for a
|
|
110
|
+
* symbol the router installs, so the value has to come from the real
|
|
111
|
+
* request. There used to be an instance form, `session.bind(res)`, that
|
|
112
|
+
* called this with `{ session: this }`: a fake carrying no symbol, so the
|
|
113
|
+
* check failed, `getCookie` returned an empty string, and the cookie was
|
|
114
|
+
* never appended. It was a documented method that could not work, and it is
|
|
115
|
+
* gone rather than repaired: the session alone does not know whether the
|
|
116
|
+
* request it belongs to issued a cookie this turn.
|
|
117
|
+
*/
|
|
105
118
|
public static bind(req: Request, response?: Response) {
|
|
106
119
|
if (!response) return response
|
|
107
120
|
|
|
@@ -116,7 +129,7 @@ export class Session<
|
|
|
116
129
|
const session = req.session
|
|
117
130
|
|
|
118
131
|
// Issue only when the session actually changed, or when the read path
|
|
119
|
-
// flagged a half-life refresh (see `markAccessed`)
|
|
132
|
+
// flagged a half-life refresh (see `markAccessed`), not on every read.
|
|
120
133
|
// A per-request Set-Cookie made every session-carrying response unique,
|
|
121
134
|
// defeating If-None-Match, and dirtied merely-read sessions into the
|
|
122
135
|
// flush timer's write batch.
|
|
@@ -138,7 +151,7 @@ export class Session<
|
|
|
138
151
|
? Math.floor(DEFAULT_SESSION_PERSIST / 1000)
|
|
139
152
|
: Math.floor(DEFAULT_SESSION_TTL / 1000)
|
|
140
153
|
|
|
141
|
-
// Only believe x-forwarded-proto behind a trusted proxy
|
|
154
|
+
// Only believe x-forwarded-proto behind a trusted proxy: the same rule
|
|
142
155
|
// getHostname and getClientIp already apply. In production default to
|
|
143
156
|
// Secure, so a terminator that omits the header can't downgrade the cookie.
|
|
144
157
|
const trustProxy = Bakery.config.trustProxy
|
|
@@ -178,7 +191,7 @@ export class Session<
|
|
|
178
191
|
session.rawData = { ...metadata.data }
|
|
179
192
|
session.data = session.initProxy()
|
|
180
193
|
// Rows written before the field existed revive as 0 = "unknown", which
|
|
181
|
-
// `markAccessed` reads as long past half-life
|
|
194
|
+
// `markAccessed` reads as long past half-life, at worst one extra
|
|
182
195
|
// reissue, never a cookie that silently expires under an active user.
|
|
183
196
|
session.cookieIssuedAt = metadata.cookieIssuedAt ?? 0
|
|
184
197
|
return session
|
|
@@ -209,7 +222,7 @@ export class Session<
|
|
|
209
222
|
if (existing.isExpired()) {
|
|
210
223
|
Session.cache.delete(sessionKey(sessionId))
|
|
211
224
|
} else {
|
|
212
|
-
// Accessed, not modified
|
|
225
|
+
// Accessed, not modified. See `markAccessed`. `touch()` here made
|
|
213
226
|
// every session read count as a write.
|
|
214
227
|
existing.markAccessed()
|
|
215
228
|
return existing
|
|
@@ -227,7 +240,7 @@ export class Session<
|
|
|
227
240
|
|
|
228
241
|
/**
|
|
229
242
|
* Epoch ms when `getCookie` last emitted `Set-Cookie` for this id; 0 means
|
|
230
|
-
* never (or unknown
|
|
243
|
+
* never (or unknown: a row written before the field existed). Persisted
|
|
231
244
|
* through `toJSON`, so it only costs a write when a write is already
|
|
232
245
|
* happening; while the instance lives in the memory tier it carries across
|
|
233
246
|
* requests for free.
|
|
@@ -236,7 +249,7 @@ export class Session<
|
|
|
236
249
|
|
|
237
250
|
/**
|
|
238
251
|
* Set by `markAccessed` when the cookie has crossed half its Max-Age;
|
|
239
|
-
* cleared when `getCookie` issues. Deliberately not persisted
|
|
252
|
+
* cleared when `getCookie` issues. Deliberately not persisted: it is
|
|
240
253
|
* per-instance request bookkeeping, and losing it costs nothing (the next
|
|
241
254
|
* read recomputes it from `cookieIssuedAt`).
|
|
242
255
|
*/
|
|
@@ -277,7 +290,7 @@ export class Session<
|
|
|
277
290
|
* Force the session to count as modified: stored on the next flush and its
|
|
278
291
|
* cookie re-issued. This is the published "force a cookie refresh" surface
|
|
279
292
|
* (docs/guides/sessions.md) and keeps its dirty semantics. It is *not* the
|
|
280
|
-
* read path
|
|
293
|
+
* read path: `Session.from` uses `markAccessed`, which slides expiry
|
|
281
294
|
* without paying write costs.
|
|
282
295
|
*/
|
|
283
296
|
public touch(): this {
|
|
@@ -286,18 +299,18 @@ export class Session<
|
|
|
286
299
|
}
|
|
287
300
|
|
|
288
301
|
/**
|
|
289
|
-
* The read-path bump
|
|
302
|
+
* The read-path bump, what `Session.from` does instead of `touch()`.
|
|
290
303
|
* Reading a session must not dirty it: that made every read-only request
|
|
291
304
|
* pay a JSON.stringify + synchronous SQLite write on the next flush and
|
|
292
305
|
* re-issue Set-Cookie, which defeated If-None-Match caching.
|
|
293
306
|
*
|
|
294
307
|
* Liveness needs no work here: the `Session.cache.get` in `from` has
|
|
295
308
|
* already re-stamped the memory tier's accessedAt. What this method owns is
|
|
296
|
-
* sliding cookie expiration
|
|
309
|
+
* sliding cookie expiration: once more than half the cookie's Max-Age has
|
|
297
310
|
* elapsed since it was last issued, flag a reissue. `getCookie` then emits
|
|
298
311
|
* the cookie *and* re-persists the session, which renews the DB row's
|
|
299
312
|
* accessedAt at a cadence of at most maxAge/2 against the pruner's cutoff
|
|
300
|
-
* of maxAge
|
|
313
|
+
* of maxAge, so active sessions never age out server-side either.
|
|
301
314
|
*/
|
|
302
315
|
private markAccessed(): void {
|
|
303
316
|
const maxAgeMs = this.hasPersistedKeys()
|
|
@@ -350,10 +363,10 @@ export class Session<
|
|
|
350
363
|
*
|
|
351
364
|
* This is the missing anti-fixation primitive. `reset()` clears data but
|
|
352
365
|
* keeps the id, so an app that does `req.session.set('userId', …)` on login
|
|
353
|
-
* finishes authentication on the *same* id the visitor arrived with
|
|
366
|
+
* finishes authentication on the *same* id the visitor arrived with,
|
|
354
367
|
* including one an attacker planted. Call it at the privilege boundary:
|
|
355
368
|
*
|
|
356
|
-
* ```ts no-check
|
|
369
|
+
* ```ts no-check: illustrative call site, not a compiled example
|
|
357
370
|
* req.session.regenerate().set('userId', user.id, true)
|
|
358
371
|
* ```
|
|
359
372
|
*
|
|
@@ -362,7 +375,7 @@ export class Session<
|
|
|
362
375
|
* the same object `req.session` already holds, so the rest of the request
|
|
363
376
|
* sees the new id.
|
|
364
377
|
*
|
|
365
|
-
* `createdAt` is deliberately preserved
|
|
378
|
+
* `createdAt` is deliberately preserved: the session continues, only its
|
|
366
379
|
* bearer token is replaced.
|
|
367
380
|
*/
|
|
368
381
|
public regenerate(): this {
|
|
@@ -423,10 +436,6 @@ export class Session<
|
|
|
423
436
|
return this
|
|
424
437
|
}
|
|
425
438
|
|
|
426
|
-
public bind(response?: Response) {
|
|
427
|
-
return Session.bind({ session: this } as any, response)
|
|
428
|
-
}
|
|
429
|
-
|
|
430
439
|
public destroy(): void {
|
|
431
440
|
Session.delete(this.id)
|
|
432
441
|
}
|
|
@@ -454,7 +463,7 @@ export class Session<
|
|
|
454
463
|
|
|
455
464
|
/**
|
|
456
465
|
* Every enumeration below filters to the current host and yields the bare
|
|
457
|
-
* session id, not the cache key
|
|
466
|
+
* session id, not the cache key, so `Session.keys()` still returns ids the
|
|
458
467
|
* dashboard can hand back to `Session.get` / `Session.delete`.
|
|
459
468
|
*/
|
|
460
469
|
static *entries(): IterableIterator<[string, Session<any>]> {
|
|
@@ -487,7 +496,7 @@ export class Session<
|
|
|
487
496
|
// The check is on `hosts` and not on the scope, deliberately: `hostKey`
|
|
488
497
|
// collapses to '' in the unconfigured case too, so the scope alone cannot
|
|
489
498
|
// tell the two situations apart. This method used to compute one anyway and
|
|
490
|
-
// never read it
|
|
499
|
+
// never read it: scoping lives in `Session.entries()`, which the slow path
|
|
491
500
|
// below goes through.
|
|
492
501
|
const hosts = Bakery.config.hosts
|
|
493
502
|
if (!hosts || Object.keys(hosts).length === 0) {
|
|
@@ -495,7 +504,7 @@ export class Session<
|
|
|
495
504
|
}
|
|
496
505
|
|
|
497
506
|
// Scoped path: `TieredCache.search` pages in SQL across the whole table,
|
|
498
|
-
// which is every tenant's sessions, and it has no key-prefix predicate
|
|
507
|
+
// which is every tenant's sessions, and it has no key-prefix predicate, so
|
|
499
508
|
// the filter has to happen before paging, in JS. Bounded by the session
|
|
500
509
|
// table, which the pruner holds down to live sessions.
|
|
501
510
|
const needle = options.search?.trim().toLowerCase() || ''
|
|
@@ -548,6 +557,15 @@ const sessionPruneTimer = setInterval(
|
|
|
548
557
|
1000 * 60 * 15, // prune every 15 minutes
|
|
549
558
|
)
|
|
550
559
|
|
|
560
|
+
// Unref'd, like the two flush timers in `cache/`. A 15-minute prune is not a
|
|
561
|
+
// reason a process cannot exit, and this one is module-level: importing
|
|
562
|
+
// `session.ts` (which `core/index` does) held the event loop open for the
|
|
563
|
+
// life of any process that touched core. The CLI never saw it because every
|
|
564
|
+
// one of its paths ends in `process.exit`; a script, an embedder or a bare
|
|
565
|
+
// `bun -e` that imported the barrel printed its answer and then hung.
|
|
566
|
+
// `onShutdown` still clears it, which is what matters for an orderly stop.
|
|
567
|
+
sessionPruneTimer.unref?.()
|
|
568
|
+
|
|
551
569
|
Bakery.onShutdown(() => {
|
|
552
570
|
clearInterval(sessionPruneTimer)
|
|
553
571
|
})
|
package/src/shared.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { MapOf } from './types'
|
|
|
5
5
|
*
|
|
6
6
|
* These used to be declared twice, verbatim, in `global.d.ts` and
|
|
7
7
|
* `client/globals.d.ts`. Nothing caught it: `skipLibCheck: true` suppresses
|
|
8
|
-
* errors *inside* .d.ts files
|
|
8
|
+
* errors *inside* .d.ts files (including this project's own), so four TS2300
|
|
9
9
|
* duplicate-identifier errors sat there invisibly, and every tsconfig excluded
|
|
10
10
|
* `src/client/**` on top of that.
|
|
11
11
|
*
|
|
@@ -16,7 +16,7 @@ import type { MapOf } from './types'
|
|
|
16
16
|
* declared in two ambient files again.
|
|
17
17
|
*
|
|
18
18
|
* `MapOf` and `Wrapped` have since moved out of the global scope entirely,
|
|
19
|
-
* into `types.d.ts
|
|
19
|
+
* into `types.d.ts`: their names are generic enough to collide with a
|
|
20
20
|
* consuming app's own. What stays global here is framework-specific enough
|
|
21
21
|
* not to, and is needed by browser globals that are bound at runtime rather
|
|
22
22
|
* than imported.
|
|
@@ -27,12 +27,12 @@ import type { MapOf } from './types'
|
|
|
27
27
|
declare global {
|
|
28
28
|
/**
|
|
29
29
|
* Bound by `client/utils.ts` in the browser and `core/init.ts` on the
|
|
30
|
-
* server
|
|
30
|
+
* server: the same isomorphic implementation either side, so code moving
|
|
31
31
|
* between an SFC browser script and a server block keeps the name.
|
|
32
32
|
*/
|
|
33
33
|
var randomId: typeof import('./utils/isomorphic/misc').randomId
|
|
34
34
|
|
|
35
|
-
/** The one JSON envelope
|
|
35
|
+
/** The one JSON envelope. See convention 7. */
|
|
36
36
|
type JsonResponse<T = any> = {
|
|
37
37
|
time: number
|
|
38
38
|
status: number
|
|
@@ -52,7 +52,7 @@ declare global {
|
|
|
52
52
|
(value: any, type: 'undefined'): value is undefined
|
|
53
53
|
// `Function` is the type being narrowed to, and there is no narrower
|
|
54
54
|
// spelling of "callable" a predicate can promise. `noBannedTypes` is off
|
|
55
|
-
// repo-wide, so this needs no suppression
|
|
55
|
+
// repo-wide, so this needs no suppression. See MONOREPO.md for why.
|
|
56
56
|
(value: any, type: 'function'): value is Function
|
|
57
57
|
(value: any, type?: string): boolean
|
|
58
58
|
string(value: any): value is string
|
package/src/startup.ts
CHANGED
|
@@ -39,7 +39,7 @@ let pluginSetup: Promise<void> | null = null
|
|
|
39
39
|
* Calling both meant every non-clustered worker set its plugins up twice.
|
|
40
40
|
* Handler and mount registration is idempotent so that went unnoticed, but the
|
|
41
41
|
* analytics plugin registers a shutdown hook and kicks off `loadAnalyticsData()`
|
|
42
|
-
* from `setup()
|
|
42
|
+
* from `setup()`, so it got two of each. Memoizing the promise (not a boolean)
|
|
43
43
|
* also makes a concurrent second caller await the first run rather than race it.
|
|
44
44
|
*/
|
|
45
45
|
export function setupPlugins(): Promise<void> {
|
|
@@ -96,7 +96,7 @@ export async function runStartupBanner(): Promise<void> {
|
|
|
96
96
|
const host = Bakery.config.host
|
|
97
97
|
|
|
98
98
|
// Ground truth first. The banner's job is to print where the server is
|
|
99
|
-
// listening, and `Bakery.server.port` is what the OS actually gave us
|
|
99
|
+
// listening, and `Bakery.server.port` is what the OS actually gave us,
|
|
100
100
|
// which is the only correct answer under `PORT=0`, and the only *honest*
|
|
101
101
|
// one if the bound port and the requested one ever part company again.
|
|
102
102
|
// `resolvePort` covers the callers that print a banner without a server
|
|
@@ -134,7 +134,7 @@ export async function runStartupBanner(): Promise<void> {
|
|
|
134
134
|
// constant, and any user-supplied value replaces the reference. The
|
|
135
135
|
// default limiter 429s load tests and shared-NAT offices with nothing
|
|
136
136
|
// anywhere saying it exists, so this line is its one announcement. An
|
|
137
|
-
// app-configured value prints nothing
|
|
137
|
+
// app-configured value prints nothing: their choice, their knowledge.
|
|
138
138
|
if (Bakery.config.rateLimit === DEFAULT_RATE_LIMIT) {
|
|
139
139
|
serveLog.RATE_LIMIT_DEFAULT(DEFAULT_RATE_LIMIT)
|
|
140
140
|
}
|
package/src/types.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* These were declared in `declare global`, which is fine inside one repo and a
|
|
5
5
|
* hazard the moment the packages are published: the names are generic enough
|
|
6
6
|
* that a consuming app is likely to define its own `MapOf`, and a global type
|
|
7
|
-
* alias cannot be merged or opted out of
|
|
7
|
+
* alias cannot be merged or opted out of, the app just gets a redeclaration
|
|
8
8
|
* error with nowhere to put the fix.
|
|
9
9
|
*
|
|
10
10
|
* Moving them cost 24 files an `import type` line and cost consumers nothing:
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* that they were always internal plumbing rather than DX.
|
|
13
13
|
*
|
|
14
14
|
* `verbatimModuleSyntax` makes the compiler reject a value-position import of
|
|
15
|
-
* these, so nothing can accidentally introduce a runtime module edge
|
|
15
|
+
* these, so nothing can accidentally introduce a runtime module edge, which
|
|
16
16
|
* matters because `client/utils.ts` is compiled into the browser bundle.
|
|
17
17
|
*/
|
|
18
18
|
export type MapOf<T> = { [key: string]: T }
|
|
@@ -20,7 +20,7 @@ export type MapOf<T> = { [key: string]: T }
|
|
|
20
20
|
/** A value, or a function producing it. */
|
|
21
21
|
export type Wrapped<T, Args extends any[] = []> = T | ((...args: Args) => T)
|
|
22
22
|
|
|
23
|
-
/** Awaited or not
|
|
23
|
+
/** Awaited or not: the shape almost every framework hook returns. */
|
|
24
24
|
export type MixedPromise<T> = Promise<T> | T
|
|
25
25
|
|
|
26
26
|
type ResolveMatchReturn<V, R> = R extends any
|
|
@@ -54,12 +54,12 @@ export type Match<D extends symbol> = {
|
|
|
54
54
|
/**
|
|
55
55
|
* The `body` a route module's default export receives: declared params merge
|
|
56
56
|
* over a permissive base. `body.id` is `string` once you declare it, while
|
|
57
|
-
* `body.anythingElse` stays reachable as `any
|
|
57
|
+
* `body.anythingElse` stays reachable as `any`: the parse rules in
|
|
58
58
|
* `utils/http/body.ts` mean the framework genuinely cannot know every key, so
|
|
59
59
|
* locking the object down would be a lie in the other direction.
|
|
60
60
|
*
|
|
61
61
|
* There is no filename-literal inference (`[id].ts` does not conjure
|
|
62
|
-
* `{ id: string }` on its own
|
|
62
|
+
* `{ id: string }` on its own. That needs codegen); the contract is that the
|
|
63
63
|
* author declares the shape once, at the `defineRoute` / `html` call, instead
|
|
64
64
|
* of annotating the whole signature or settling for `any`.
|
|
65
65
|
*/
|
|
@@ -71,13 +71,13 @@ export type RouteBody<P = {}> = P & MapOf<any>
|
|
|
71
71
|
* directory under the `!` form).
|
|
72
72
|
*
|
|
73
73
|
* Exists so a route over mixed or unknown segments can say
|
|
74
|
-
* `defineRoute<MapOf<RouteParam>>` instead of hand-writing the union
|
|
74
|
+
* `defineRoute<MapOf<RouteParam>>` instead of hand-writing the union, and so
|
|
75
75
|
* the union has one definition to change if it ever grows.
|
|
76
76
|
*/
|
|
77
77
|
export type RouteParam = string | string[]
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* What a route module may actually return
|
|
80
|
+
* What a route module may actually return. Read off `processResponse`
|
|
81
81
|
* (`router.ts`) and `ApiHandler.handle`: a `Response`, a `BunFile` (streamed
|
|
82
82
|
* with an ETag), the JSON envelope, a plain data object or array (JSON-encoded
|
|
83
83
|
* as-is), a string (HTML-sniffed) or number, or nothing (204; a 404 under
|
|
@@ -85,10 +85,10 @@ export type RouteParam = string | string[]
|
|
|
85
85
|
*
|
|
86
86
|
* Deliberately not `object` (which `Handler.Response` uses): `object` absorbs
|
|
87
87
|
* `Response`, `BunFile` and `JsonResponse` into one member and the union stops
|
|
88
|
-
* saying anything
|
|
88
|
+
* saying anything, the same trap `routeTable()`'s dispatch avoids. `MapOf<any>`
|
|
89
89
|
* is the narrowest thing that covers "a plain data object"; a target index
|
|
90
90
|
* signature of `any` does still accept class instances, but that only matters
|
|
91
|
-
* for inference targets
|
|
91
|
+
* for inference targets: this union is a constraint on what an author may
|
|
92
92
|
* return, and nothing consumes a route module's return type downstream, so the
|
|
93
93
|
* members stay legible instead of collapsing.
|
|
94
94
|
*/
|
|
@@ -107,7 +107,7 @@ export type RouteResponse = MixedPromise<
|
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
109
|
* A typed route module default export. `$dynamic.executeModule` calls it as
|
|
110
|
-
* `(req, body, Bakery.server)
|
|
110
|
+
* `(req, body, Bakery.server)`: the third argument is optional here because
|
|
111
111
|
* `Bakery.server` is itself optional before `Bun.serve` runs, and almost no
|
|
112
112
|
* handler wants it.
|
|
113
113
|
*
|
package/src/utils/common/case.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `Case` moved to `utils/isomorphic/case
|
|
2
|
+
* `Case` moved to `utils/isomorphic/case`: the client bundle carried an
|
|
3
3
|
* identical copy. `toHash` stays here: it calls `Bun.hash`, so it is server-only
|
|
4
4
|
* and must not be pulled into the isomorphic layer.
|
|
5
5
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Moved to `utils/isomorphic/match` so the browser bundle and the server share
|
|
3
|
-
* one implementation
|
|
3
|
+
* one implementation: the prototype-chain fix (`Object.hasOwn` rather than
|
|
4
4
|
* `in`) previously had to be applied to both copies separately.
|
|
5
5
|
*/
|
|
6
6
|
export * from '../isomorphic/match'
|
package/src/utils/common/misc.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `is`, `Math2` and `throws` moved to `utils/isomorphic
|
|
2
|
+
* `is`, `Math2` and `throws` moved to `utils/isomorphic/`: the client bundle
|
|
3
3
|
* carried its own copies of all three. They are re-exported here so existing
|
|
4
4
|
* `@server/utils` importers are unaffected.
|
|
5
5
|
*
|
package/src/utils/constants.ts
CHANGED
|
@@ -12,7 +12,7 @@ export const DEFAULT_RATE_LIMIT = { max: 100, refill: 10 }
|
|
|
12
12
|
* Files that must never be served off disk.
|
|
13
13
|
*
|
|
14
14
|
* These are matched against the file a *file-serving* handler is about to
|
|
15
|
-
* return, not against every request path
|
|
15
|
+
* return, not against every request path. See `servesFiles` in `router.ts`.
|
|
16
16
|
* An API route or a proxied path is a name, not a file, and never had any
|
|
17
17
|
* business being tested against this list.
|
|
18
18
|
*
|
|
@@ -22,8 +22,8 @@ export const DEFAULT_RATE_LIMIT = { max: 100, refill: 10 }
|
|
|
22
22
|
* on that attempt.
|
|
23
23
|
*
|
|
24
24
|
* Note what is *not* here: an extension-wide ban on JSON. It caught every
|
|
25
|
-
* JSON document an app might legitimately publish
|
|
26
|
-
* `.well-known` document
|
|
25
|
+
* JSON document an app might legitimately publish: a web app manifest, a
|
|
26
|
+
* `.well-known` document, and since `blocked` in config can only ever append,
|
|
27
27
|
* an app had no way to opt back out. What the list is actually protecting is
|
|
28
28
|
* the handful of JSON files that describe the project, so those are named.
|
|
29
29
|
*/
|
|
@@ -46,9 +46,9 @@ export const DEFAULT_BLOCKED_GLOBS = [
|
|
|
46
46
|
// trailing `**` collapses to a single segment: `{**/node_modules/**}` matches
|
|
47
47
|
// `/node_modules/a` and then stops, so `/node_modules/pkg/index.js` was not
|
|
48
48
|
// blocked by anything. Each pattern still matched on its own, which is why
|
|
49
|
-
// it survived
|
|
49
|
+
// it survived: the defect only exists in the form the config actually uses.
|
|
50
50
|
// `.cache` is the framework's disposable working dir and `bakery/` holds the
|
|
51
|
-
// database and its backups
|
|
51
|
+
// database and its backups: the visible one is the precious one, so that a
|
|
52
52
|
// dotfile sweep cannot reach the data. `bakery/` being un-dotted is why it
|
|
53
53
|
// needs naming here explicitly: `*.db` covers the database file itself, but
|
|
54
54
|
// not the `.ts` schema snapshots under `bakery/backups/`.
|
|
@@ -67,17 +67,17 @@ export const DEFAULT_BLOCKED_GLOBS = [
|
|
|
67
67
|
/**
|
|
68
68
|
* A request path folded to the form the *filesystem* would resolve it to.
|
|
69
69
|
*
|
|
70
|
-
* Two
|
|
70
|
+
* Two normalizations, both of them things the OS does and the glob does not:
|
|
71
71
|
*
|
|
72
72
|
* - **Case.** NTFS and APFS are case-insensitive, so `/PACKAGE.JSON` opens the
|
|
73
|
-
* same bytes as `/package.json
|
|
73
|
+
* same bytes as `/package.json`, but `Bun.Glob` is case-sensitive, so only
|
|
74
74
|
* the second one was blocked. Every file this list protects was reachable by
|
|
75
75
|
* holding down shift, and the static pipeline was verified to serve
|
|
76
76
|
* `/STYLES/GLOBAL.CSS` and `/styles/global.css` as the same 170 bytes.
|
|
77
77
|
* - **Trailing dots and spaces.** Win32 strips both from each path component,
|
|
78
78
|
* so `/package.json.` and `/package.json ` are the same file too.
|
|
79
79
|
*
|
|
80
|
-
* `.`, `..` and any all-dots component are left intact
|
|
80
|
+
* `.`, `..` and any all-dots component are left intact: trimming those would
|
|
81
81
|
* rewrite a relative segment into something else, and containment is
|
|
82
82
|
* `fs.isForbidden`'s job, not this function's.
|
|
83
83
|
*/
|
|
@@ -93,14 +93,14 @@ export function normalizeBlockedPath(path: string): string {
|
|
|
93
93
|
* Each path component truncated at its first `:`.
|
|
94
94
|
*
|
|
95
95
|
* Win32 resolves `x.ts::$DATA` to the bytes of `x.ts` and
|
|
96
|
-
* `dir::$INDEX_ALLOCATION` to `dir`
|
|
96
|
+
* `dir::$INDEX_ALLOCATION` to `dir` (NTFS alternate-data-stream syntax), and
|
|
97
97
|
* `new URL()` keeps the suffix in `pathname`, so a glob anchored on the real
|
|
98
98
|
* name missed while the filesystem opened the file anyway. The directory form
|
|
99
99
|
* matters independently: it defeats the `dir/**` patterns that the file form
|
|
100
100
|
* cannot reach.
|
|
101
101
|
*
|
|
102
102
|
* Only ever used as an *extra* match candidate (see `matchBlocked`), never as
|
|
103
|
-
* a replacement
|
|
103
|
+
* a replacement: colons are legal in POSIX filenames, and a real file named
|
|
104
104
|
* `a:b.db` must stay blocked by `*.db` rather than being tested as `a`.
|
|
105
105
|
*/
|
|
106
106
|
function stripStreamSuffix(path: string): string {
|
|
@@ -117,18 +117,18 @@ function stripStreamSuffix(path: string): string {
|
|
|
117
117
|
/**
|
|
118
118
|
* Whether `path` is refused by the blocked globs.
|
|
119
119
|
*
|
|
120
|
-
* Matched twice: once as written, once
|
|
121
|
-
* different reasons. The
|
|
120
|
+
* Matched twice: once as written, once normalized. Both are needed, and for
|
|
121
|
+
* different reasons. The normalized pass is what closes the case and
|
|
122
122
|
* trailing-dot variants above. The verbatim pass is what keeps an app's own
|
|
123
|
-
* `blocked: ['**\/Secrets.txt']` working
|
|
123
|
+
* `blocked: ['**\/Secrets.txt']` working, `initConfig` appends user patterns
|
|
124
124
|
* to this list untouched, so folding only the path would quietly stop matching
|
|
125
125
|
* the very patterns the app wrote.
|
|
126
126
|
*
|
|
127
127
|
* On a case-*sensitive* filesystem this over-blocks: a file genuinely named
|
|
128
128
|
* `/PACKAGE.JSON`, distinct from `/package.json`, is refused. That is the
|
|
129
|
-
* intended direction to be wrong in, and it is the reason this
|
|
129
|
+
* intended direction to be wrong in, and it is the reason this normalizes the
|
|
130
130
|
* path rather than resolving the file. The router runs this check before it
|
|
131
|
-
* knows which file
|
|
131
|
+
* knows which file (often before there is a file at all), so "ask the
|
|
132
132
|
* filesystem for the real name" is not available at the point the answer is
|
|
133
133
|
* needed, and would cost a `realpath` per request where it was.
|
|
134
134
|
*/
|