@bakery-framework/core 2.2.2 → 2.4.0
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/core/config.ts +5 -0
- package/src/core/host-mounts.ts +174 -0
- package/src/global.d.ts +11 -0
- package/src/router.ts +122 -5
- package/src/session-signing.ts +90 -0
- package/src/session.ts +14 -2
- package/src/utils/fs.ts +120 -21
package/package.json
CHANGED
package/src/core/config.ts
CHANGED
|
@@ -12,6 +12,10 @@ import { fs } from '../utils/fs'
|
|
|
12
12
|
import { checkCacheVersion } from './cache-version'
|
|
13
13
|
import { hostStore } from './context'
|
|
14
14
|
|
|
15
|
+
// Re-exported for the CLI's boot checks, which reach config through this
|
|
16
|
+
// subpath; the module itself imports nothing, so it can sit under the router.
|
|
17
|
+
export { checkHostMounts } from './host-mounts'
|
|
18
|
+
|
|
15
19
|
export const NOOP = () => {}
|
|
16
20
|
|
|
17
21
|
const defaultConfig: Required<AppConfig> = {
|
|
@@ -63,6 +67,7 @@ const defaultConfig: Required<AppConfig> = {
|
|
|
63
67
|
sessions: {},
|
|
64
68
|
root: 'src',
|
|
65
69
|
hosts: {},
|
|
70
|
+
hostMounts: [],
|
|
66
71
|
websocket: {
|
|
67
72
|
message: NOOP,
|
|
68
73
|
open: NOOP,
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host mounts: a host that serves one path subtree of the app at its root.
|
|
3
|
+
*
|
|
4
|
+
* `hostMounts: [{ host: 'kit.*', path: '/kit' }]` makes `kit.example.com/forms`
|
|
5
|
+
* the page `/kit/forms` is on every other host. The subtree stays part of the
|
|
6
|
+
* app: its pages load the app's modules, stylesheets and API from the root,
|
|
7
|
+
* the way they do on any host, so only page requests move into it. Which
|
|
8
|
+
* requests those are is `routeOf` in `router.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Patterns rather than `hosts` keys, because a mount's hostname is often not
|
|
11
|
+
* known at boot: an app whose domain is a setting can still name "the `kit`
|
|
12
|
+
* label of whatever domain this is". A mount is also not a tenant. It changes
|
|
13
|
+
* which page a path names and nothing else, so sessions, caches and the
|
|
14
|
+
* `hosts` entries do not see it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type HostMount = {
|
|
18
|
+
/**
|
|
19
|
+
* The hostnames the mount serves: a name (`kit.example.com`), a pattern in
|
|
20
|
+
* which `*` stands for one or more whole labels (`kit.*`,
|
|
21
|
+
* `*.docs.example.com`), or a function of the hostname, which it receives
|
|
22
|
+
* lowercased and without the port.
|
|
23
|
+
*/
|
|
24
|
+
host: string | ((hostname: string) => boolean)
|
|
25
|
+
/** The subtree served at the host's root, such as `/kit`. */
|
|
26
|
+
path: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type CompiledHostMount = {
|
|
30
|
+
/** The subtree: a leading slash and no trailing one. */
|
|
31
|
+
readonly path: string
|
|
32
|
+
/** Whether a lowercased hostname without its port is served by the mount. */
|
|
33
|
+
matches(hostname: string): boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One label of a hostname pattern, `*` aside. */
|
|
37
|
+
const LABEL = /^[a-z0-9_-]+$/
|
|
38
|
+
|
|
39
|
+
/** `*` as a pattern label: one or more whole labels. */
|
|
40
|
+
const ANY_LABELS = '[^.]+(?:\\.[^.]+)*'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A hostname pattern as a regular expression. Throws naming `where` when
|
|
44
|
+
* `host` is not a name, or puts `*` inside a label: `k*t.example.com` would
|
|
45
|
+
* have to choose between matching part of a label and matching labels, and
|
|
46
|
+
* either reading surprises someone.
|
|
47
|
+
*/
|
|
48
|
+
export function hostPattern(host: string, where = 'host'): RegExp {
|
|
49
|
+
const labels = host.toLowerCase().split('.')
|
|
50
|
+
if (labels.some(label => label === '')) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`${where} '${host}' is not a hostname: a name has no empty label`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
const parts = labels.map(label => {
|
|
56
|
+
if (label === '*') return ANY_LABELS
|
|
57
|
+
if (!LABEL.test(label)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`${where} '${host}' has the label '${label}': a label is letters, digits, '-' and '_', and '*' stands for whole labels only`,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
return label
|
|
63
|
+
})
|
|
64
|
+
return new RegExp(`^${parts.join('\\.')}$`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What every host serves from the app's root, a mounted one included: the
|
|
69
|
+
* framework's and the plugins' routes, the API, and the site-wide metadata
|
|
70
|
+
* RFC 8615 reserves. The router keeps requests under these out of a mount,
|
|
71
|
+
* and no mount may name a path under one.
|
|
72
|
+
*/
|
|
73
|
+
export const ROOT_ONLY = ['/_', '/api/', '/.well-known/'] as const
|
|
74
|
+
|
|
75
|
+
function mountPath(value: unknown, where: string): string {
|
|
76
|
+
if (typeof value !== 'string' || !value.startsWith('/')) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`${where} must be a path starting with '/': got ${JSON.stringify(value)}`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
const path = value.replace(/\/+$/, '')
|
|
82
|
+
if (path === '') {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`${where} '${value}' is the whole app, which every host already serves`,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
const segments = path.slice(1).split('/')
|
|
88
|
+
if (segments.some(s => s === '' || s === '.' || s === '..')) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`${where} '${value}' has an empty, '.' or '..' segment: a mount names one directory`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
if (/[?#*\\]/.test(path)) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${where} '${value}' is a path, so it holds no '?', '#', '*' or '\\'`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
const reserved = ROOT_ONLY.find(prefix => `${path}/`.startsWith(prefix))
|
|
99
|
+
if (reserved) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`${where} '${value}' is under ${reserved}, which every host serves as it is`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
return path
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function compile(mount: HostMount, where: string): CompiledHostMount {
|
|
108
|
+
if (!mount || typeof mount !== 'object') {
|
|
109
|
+
throw new Error(`${where} must be { host, path }`)
|
|
110
|
+
}
|
|
111
|
+
const path = mountPath(mount.path, `${where}.path`)
|
|
112
|
+
const { host } = mount
|
|
113
|
+
if (typeof host === 'function') return { path, matches: host }
|
|
114
|
+
if (typeof host !== 'string') {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`${where}.host must be a hostname, a pattern such as 'kit.*', or a function of the hostname`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
const pattern = hostPattern(host, `${where}.host`)
|
|
120
|
+
return { path, matches: hostname => pattern.test(hostname) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Compiled once per array: the processed config is frozen for the life of
|
|
125
|
+
* the process, and a test that swaps it in brings a new array, so identity is
|
|
126
|
+
* the whole validity check.
|
|
127
|
+
*/
|
|
128
|
+
const compiled = new WeakMap<
|
|
129
|
+
readonly HostMount[],
|
|
130
|
+
readonly CompiledHostMount[]
|
|
131
|
+
>()
|
|
132
|
+
|
|
133
|
+
/** `hostMounts` compiled, throwing at the first entry that cannot mean anything. */
|
|
134
|
+
export function compileHostMounts(
|
|
135
|
+
mounts: readonly HostMount[],
|
|
136
|
+
): readonly CompiledHostMount[] {
|
|
137
|
+
const cached = compiled.get(mounts)
|
|
138
|
+
if (cached) return cached
|
|
139
|
+
if (!Array.isArray(mounts)) {
|
|
140
|
+
throw new Error('hostMounts must be an array of { host, path }')
|
|
141
|
+
}
|
|
142
|
+
const result = mounts.map((mount, i) => compile(mount, `hostMounts[${i}]`))
|
|
143
|
+
compiled.set(mounts, result)
|
|
144
|
+
return result
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Compile the app's `hostMounts` before anything binds, so an entry that
|
|
149
|
+
* cannot mean anything stops the boot and names itself, rather than throwing
|
|
150
|
+
* on the first request that reaches it.
|
|
151
|
+
*/
|
|
152
|
+
export function checkHostMounts(config: {
|
|
153
|
+
hostMounts: readonly HostMount[]
|
|
154
|
+
}): void {
|
|
155
|
+
compileHostMounts(config.hostMounts)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The first mount serving `hostname`, or null. */
|
|
159
|
+
export function findHostMount(
|
|
160
|
+
mounts: readonly CompiledHostMount[],
|
|
161
|
+
hostname: string,
|
|
162
|
+
): CompiledHostMount | null {
|
|
163
|
+
if (!mounts.length) return null
|
|
164
|
+
const host = hostname.toLowerCase()
|
|
165
|
+
for (const mount of mounts) {
|
|
166
|
+
if (mount.matches(host)) return mount
|
|
167
|
+
}
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** `path` on a mounted host, as the app names it: `/` is the mount itself. */
|
|
172
|
+
export function mountedPath(path: string, mount: string): string {
|
|
173
|
+
return path === '/' ? mount : `${mount}${path}`
|
|
174
|
+
}
|
package/src/global.d.ts
CHANGED
|
@@ -168,6 +168,17 @@ declare global {
|
|
|
168
168
|
sessions?: import('./session').SessionOptions
|
|
169
169
|
|
|
170
170
|
hosts?: Record<string, HostEntry>
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Hosts that serve one path subtree of the app at their root:
|
|
174
|
+
* `{ host: 'kit.*', path: '/kit' }` makes `kit.example.com/forms` the
|
|
175
|
+
* page `/kit/forms`. Only page requests move into the subtree (a
|
|
176
|
+
* navigation, or a method other than GET and HEAD); the modules,
|
|
177
|
+
* stylesheets, files and API those pages load come from the app's root,
|
|
178
|
+
* as on any host. The first entry whose `host` matches wins. See
|
|
179
|
+
* `core/host-mounts.ts` and `routeOf` in `router.ts`.
|
|
180
|
+
*/
|
|
181
|
+
hostMounts?: import('./core/host-mounts').HostMount[]
|
|
171
182
|
}
|
|
172
183
|
|
|
173
184
|
type ServerPlugin = _plugins.ServerPlugin
|
package/src/router.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import { Bakery, hostStore } from './core/bakery'
|
|
1
|
+
import { Bakery, getHostname, hostStore } from './core/bakery'
|
|
2
2
|
import { matchBlockedCached } from './core/context'
|
|
3
|
+
import {
|
|
4
|
+
compileHostMounts,
|
|
5
|
+
findHostMount,
|
|
6
|
+
mountedPath,
|
|
7
|
+
ROOT_ONLY,
|
|
8
|
+
} from './core/host-mounts'
|
|
3
9
|
import { PluginHooks } from './core/plugins'
|
|
4
10
|
import { DefaultErrorHandler } from './handlers/assets/static'
|
|
5
11
|
import type { Handler } from './handlers/core/$base'
|
|
6
12
|
import { ErrorHandler } from './handlers/core/$error'
|
|
13
|
+
import { resolveMount } from './handlers/core/$mounts'
|
|
7
14
|
import { WebSocketHandler } from './handlers/core/$websocket'
|
|
8
15
|
import { errorMsg, getElapsed, log, serveLog } from './logger'
|
|
9
16
|
import { Session } from './session'
|
|
@@ -82,14 +89,104 @@ function servesFiles(handler: any): boolean {
|
|
|
82
89
|
return handler?.servesFiles !== false
|
|
83
90
|
}
|
|
84
91
|
|
|
92
|
+
/**
|
|
93
|
+
* How routing reads one request: the path it resolves, the host mount it
|
|
94
|
+
* moved into, and whether this URL's answer depends on the kind of request,
|
|
95
|
+
* which is what `processResponse` sends a `Vary` for.
|
|
96
|
+
*/
|
|
97
|
+
type Routing = { path: string; mount: string | null; varies: boolean }
|
|
98
|
+
|
|
99
|
+
export type RequestRoute = Readonly<Pick<Routing, 'path' | 'mount'>>
|
|
100
|
+
|
|
101
|
+
/** Weak, as `parsedUrl`'s memo is: an entry lives as long as its request. */
|
|
102
|
+
const routings = new WeakMap<Request, Routing>()
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether `path` could be a page of a mounted subtree: nothing in `ROOT_ONLY`,
|
|
106
|
+
* no file named, and no plugin's route mount or proxy entry claiming it.
|
|
107
|
+
*
|
|
108
|
+
* "No file named" is a last segment with no extension. A page links its
|
|
109
|
+
* stylesheets, images and downloads by their root paths, and a navigation to
|
|
110
|
+
* one of them (a PDF opened in a tab) has to reach the file.
|
|
111
|
+
*/
|
|
112
|
+
function pagePathOnMount(
|
|
113
|
+
path: string,
|
|
114
|
+
config: Readonly<ProcessedAppConfig>,
|
|
115
|
+
): boolean {
|
|
116
|
+
if (ROOT_ONLY.some(prefix => path.startsWith(prefix))) return false
|
|
117
|
+
if (path.slice(path.lastIndexOf('/') + 1).includes('.')) return false
|
|
118
|
+
if (resolveMount(path)) return false
|
|
119
|
+
for (const prefix in config.proxy) {
|
|
120
|
+
if (path.startsWith(prefix)) return false
|
|
121
|
+
}
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether `req` asks for a page rather than for something a page loads.
|
|
127
|
+
*
|
|
128
|
+
* A navigation (a tab or a frame loading the URL) is one, and so is any
|
|
129
|
+
* method but GET and HEAD: a form post, or a server action, which a Vue page
|
|
130
|
+
* posts to its own URL. A module import, a stylesheet, an image and a `fetch`
|
|
131
|
+
* are not, and that is the line that has to hold: an import of a TypeScript
|
|
132
|
+
* module is written `/kit/useDemo`, with no extension to tell it from a page,
|
|
133
|
+
* and moved into the subtree it would reach a catch-all page instead.
|
|
134
|
+
*
|
|
135
|
+
* A browser says which it is in `Sec-Fetch-Mode`. A client that sends no
|
|
136
|
+
* Fetch Metadata is navigating when it asks for HTML.
|
|
137
|
+
*/
|
|
138
|
+
function isPageRequest(req: Request): boolean {
|
|
139
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return true
|
|
140
|
+
if (req.headers.has('upgrade')) return false
|
|
141
|
+
const mode = req.headers.get('sec-fetch-mode')
|
|
142
|
+
if (mode) return mode === 'navigate'
|
|
143
|
+
return (req.headers.get('accept') ?? '').includes('text/html')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function routing(req: Request): Routing {
|
|
147
|
+
const cached = routings.get(req)
|
|
148
|
+
if (cached) return cached
|
|
149
|
+
|
|
150
|
+
// Shared with worker.ts, which has already asked for the same parse; a
|
|
151
|
+
// direct caller (test, embedder) gets it parsed here instead.
|
|
152
|
+
const path = parsedUrl(req).pathname
|
|
153
|
+
let result: Routing = { path, mount: null, varies: false }
|
|
154
|
+
|
|
155
|
+
// A partial config (a test's, an embedder's) may carry no `hostMounts`.
|
|
156
|
+
const config = Bakery.config
|
|
157
|
+
const mounts = config.hostMounts?.length
|
|
158
|
+
? compileHostMounts(config.hostMounts)
|
|
159
|
+
: null
|
|
160
|
+
const mount = mounts && findHostMount(mounts, getHostname(req, config))
|
|
161
|
+
if (mount && pagePathOnMount(path, config)) {
|
|
162
|
+
result = isPageRequest(req)
|
|
163
|
+
? { path: mountedPath(path, mount.path), mount: mount.path, varies: true }
|
|
164
|
+
: { path, mount: null, varies: true }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
routings.set(req, result)
|
|
168
|
+
return result
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The path routing resolves for `req`, and the host mount it moved into.
|
|
173
|
+
*
|
|
174
|
+
* On a host that no entry of `hostMounts` serves, that is the request's own
|
|
175
|
+
* path and no mount. On a mounted host, a page request (`isPageRequest`) for
|
|
176
|
+
* a path that can be a page (`pagePathOnMount`) resolves under the mount:
|
|
177
|
+
* `/forms` is `/kit/forms`, and `/` is `/kit`. Every other request keeps its
|
|
178
|
+
* path, so the modules, stylesheets and API a subtree's pages load still come
|
|
179
|
+
* from the app's root. Memoized per request.
|
|
180
|
+
*/
|
|
181
|
+
export function routeOf(req: Request): RequestRoute {
|
|
182
|
+
return routing(req)
|
|
183
|
+
}
|
|
184
|
+
|
|
85
185
|
export function handleRequest(
|
|
86
186
|
req: Request,
|
|
87
187
|
): Handler.Response | MixedPromise<symbol>
|
|
88
188
|
export async function handleRequest(req: Request) {
|
|
89
|
-
|
|
90
|
-
// direct caller (test, embedder) gets it parsed here instead.
|
|
91
|
-
const url = parsedUrl(req)
|
|
92
|
-
const path = url.pathname
|
|
189
|
+
const path = routing(req).path
|
|
93
190
|
|
|
94
191
|
// One read of the config getter, not three: `Bakery.serveRoot` walks
|
|
95
192
|
// `hostStore.getStore()?.config ?? getConfig()` on every access, and the
|
|
@@ -312,6 +409,21 @@ export async function handleRequestError(
|
|
|
312
409
|
return DefaultErrorHandler.handle(path, req, error)
|
|
313
410
|
}
|
|
314
411
|
|
|
412
|
+
/** Add `tokens` to `res`'s `Vary`, keeping what it names and naming each once. */
|
|
413
|
+
function addVary(res: Response, tokens: readonly string[]): void {
|
|
414
|
+
const existing = res.headers.get('Vary')
|
|
415
|
+
const named = new Set(
|
|
416
|
+
(existing ?? '')
|
|
417
|
+
.split(',')
|
|
418
|
+
.map(token => token.trim().toLowerCase())
|
|
419
|
+
.filter(Boolean),
|
|
420
|
+
)
|
|
421
|
+
if (named.has('*')) return
|
|
422
|
+
const missing = tokens.filter(token => !named.has(token.toLowerCase()))
|
|
423
|
+
if (!missing.length) return
|
|
424
|
+
res.headers.set('Vary', [existing, ...missing].filter(Boolean).join(', '))
|
|
425
|
+
}
|
|
426
|
+
|
|
315
427
|
export async function processResponse(
|
|
316
428
|
data: Handler.Response | MixedPromise<symbol>,
|
|
317
429
|
req: Request,
|
|
@@ -373,6 +485,11 @@ export async function processResponse(
|
|
|
373
485
|
const cors = Bakery.config.cors
|
|
374
486
|
if (cors) applyCors(cors, req, resp)
|
|
375
487
|
|
|
488
|
+
// On a mounted host one URL can answer a navigation from the subtree and a
|
|
489
|
+
// module import from the root (see `routeOf`), so a cache keyed on the URL
|
|
490
|
+
// alone could hand either one to the other.
|
|
491
|
+
if (routing(req).varies) addVary(resp, ['Sec-Fetch-Mode', 'Accept'])
|
|
492
|
+
|
|
376
493
|
const final = ETag.sendResponse(req, resp)
|
|
377
494
|
if (!(final instanceof Response)) {
|
|
378
495
|
log({
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { Bakery } from './core/bakery'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Session cookies are signed: `sId=<id>.<mac>`, an HMAC-SHA256 of the id.
|
|
7
|
+
*
|
|
8
|
+
* The id alone is the bearer token, and it is unguessable, so the signature
|
|
9
|
+
* adds nothing to whether a stolen cookie works. What it buys is a refusal
|
|
10
|
+
* that costs nothing: with `sessions.store`, every request carrying a
|
|
11
|
+
* well-formed id used to cost a lookup by primary key before the rate limiter
|
|
12
|
+
* could refuse it (`Session.attach` runs first, since `keyBy` may read the
|
|
13
|
+
* session), so a flood of made-up ids from one address queued on the pool
|
|
14
|
+
* behind real traffic. A made-up id now fails here, in microseconds, and no
|
|
15
|
+
* store is asked. The built-in store gains the same, for its SQLite tier.
|
|
16
|
+
*
|
|
17
|
+
* The stored id is unchanged: only the cookie carries the MAC, so no store
|
|
18
|
+
* row or column moves.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Bytes of the MAC a cookie carries: 128 bits, ample for a MAC. */
|
|
22
|
+
const MAC_BYTES = 16
|
|
23
|
+
|
|
24
|
+
/** The key made for this process from `bakery/session.key`, once read. */
|
|
25
|
+
let keptKey: Buffer | null = null
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The key that signs: `sessions.secret` when set, else the one kept in the
|
|
29
|
+
* data directory, made on first use. Every worker on the machine reads the
|
|
30
|
+
* same file, and the first to make it wins (`wx`), so they agree. Servers
|
|
31
|
+
* sharing one store need the same `sessions.secret`.
|
|
32
|
+
*/
|
|
33
|
+
function signingKey(secret: string | undefined): Buffer | string {
|
|
34
|
+
if (secret) return secret
|
|
35
|
+
keptKey ??= keyFromFile(`${Bakery.dataDir}/session.key`)
|
|
36
|
+
return keptKey
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function keyFromFile(path: string): Buffer {
|
|
40
|
+
const read = () => Buffer.from(readFileSync(path, 'utf8').trim(), 'base64url')
|
|
41
|
+
try {
|
|
42
|
+
return read()
|
|
43
|
+
} catch (error: any) {
|
|
44
|
+
if (error?.code !== 'ENOENT') throw error
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Absent: make it. A worker that loses the race to create it reads the
|
|
48
|
+
// winner's instead, so every worker signs with the same key.
|
|
49
|
+
mkdirSync(Bakery.dataDir, { recursive: true })
|
|
50
|
+
const key = randomBytes(32)
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(path, key.toString('base64url'), { flag: 'wx', mode: 0o600 })
|
|
53
|
+
return key
|
|
54
|
+
} catch (error: any) {
|
|
55
|
+
if (error?.code === 'EEXIST') return read()
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Session cookies are signed with a key kept in ${path}, which could not be written (${error?.message ?? error}). Set sessions.secret in server.config.ts instead.`,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mac(key: Buffer | string, id: string): Buffer {
|
|
63
|
+
return createHmac('sha256', key).update(id).digest().subarray(0, MAC_BYTES)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The cookie value for a session id: the id, a dot, and its MAC. */
|
|
67
|
+
export function signSessionId(id: string, secret?: string): string {
|
|
68
|
+
return `${id}.${mac(signingKey(secret), id).toString('base64url')}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The session id a cookie value names, or `''` when its MAC does not match
|
|
73
|
+
* (or it has none, as a cookie issued before signing has none): such a
|
|
74
|
+
* cookie names no session, and nothing is looked up for it. Compared in
|
|
75
|
+
* constant time, so the comparison says nothing about how close a guess came.
|
|
76
|
+
*/
|
|
77
|
+
export function verifiedSessionId(value: string, secret?: string): string {
|
|
78
|
+
const dot = value.lastIndexOf('.')
|
|
79
|
+
if (dot <= 0) return ''
|
|
80
|
+
const id = value.slice(0, dot)
|
|
81
|
+
const given = Buffer.from(value.slice(dot + 1), 'base64url')
|
|
82
|
+
const expected = mac(signingKey(secret), id)
|
|
83
|
+
if (given.length !== expected.length) return ''
|
|
84
|
+
return timingSafeEqual(given, expected) ? id : ''
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Test seam: forget the kept key, so the next use reads the file again. */
|
|
88
|
+
export function __forgetSessionKey(): void {
|
|
89
|
+
keptKey = null
|
|
90
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Bakery, hostKey } from './core/bakery'
|
|
|
3
3
|
import { peekConfig, resolveHostname } from './core/config'
|
|
4
4
|
import { hostStore } from './core/context'
|
|
5
5
|
import { errorMsg, serveLog } from './logger/serve-log'
|
|
6
|
+
import { signSessionId, verifiedSessionId } from './session-signing'
|
|
6
7
|
import type { MapOf } from './types'
|
|
7
8
|
import { deferredValue, hasDeferredValue } from './utils'
|
|
8
9
|
import { DEFAULT_SESSION_PERSIST, DEFAULT_SESSION_TTL } from './utils/constants'
|
|
@@ -175,6 +176,13 @@ export interface SessionOptions {
|
|
|
175
176
|
* `Session.endForAccount` can end every session of one account.
|
|
176
177
|
*/
|
|
177
178
|
account?: string
|
|
179
|
+
/**
|
|
180
|
+
* Signs the session cookie, so a made-up id is refused before any store is
|
|
181
|
+
* asked (`session-signing.ts`). Omitted, a key is made once and kept in
|
|
182
|
+
* `bakery/session.key`. Servers that share one store need the same secret.
|
|
183
|
+
* Changing it ends every session: their cookies no longer verify.
|
|
184
|
+
*/
|
|
185
|
+
secret?: string
|
|
178
186
|
}
|
|
179
187
|
|
|
180
188
|
/**
|
|
@@ -359,7 +367,8 @@ export class Session<
|
|
|
359
367
|
req.url?.startsWith('https:') || forwardedHttps || import.meta.env.PROD,
|
|
360
368
|
)
|
|
361
369
|
const secureFlag = isHttps ? '; Secure' : ''
|
|
362
|
-
|
|
370
|
+
const value = signSessionId(session.id, sessionOptions()?.secret)
|
|
371
|
+
return `sId=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secureFlag}`
|
|
363
372
|
}
|
|
364
373
|
|
|
365
374
|
public static delete(reqOrId: string | Request): boolean {
|
|
@@ -413,7 +422,10 @@ export class Session<
|
|
|
413
422
|
|
|
414
423
|
if (!request.headers.has('cookie')) return ''
|
|
415
424
|
const cookieHeader = request.headers.get('cookie') || ''
|
|
416
|
-
|
|
425
|
+
const value = cookieHeader.match(/(?:^|;\s*)sId=([^;]+)/)?.[1]
|
|
426
|
+
// Signed: an id whose MAC does not match names no session, so neither
|
|
427
|
+
// store is asked about it. See `session-signing.ts`.
|
|
428
|
+
return value ? verifiedSessionId(value, sessionOptions()?.secret) : ''
|
|
417
429
|
}
|
|
418
430
|
|
|
419
431
|
public static from<T extends MapOf<any> = MapOf<any>>(
|
package/src/utils/fs.ts
CHANGED
|
@@ -3,7 +3,11 @@ import {
|
|
|
3
3
|
existsSync as nodeExistsSync,
|
|
4
4
|
statSync as nodeStatSync,
|
|
5
5
|
} from 'node:fs'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
mkdir as fsMkdir,
|
|
8
|
+
readdir as fsReaddir,
|
|
9
|
+
rm as fsRm,
|
|
10
|
+
} from 'node:fs/promises'
|
|
7
11
|
import {
|
|
8
12
|
dirname as nodeDirname,
|
|
9
13
|
relative as nodeRelative,
|
|
@@ -288,9 +292,11 @@ export namespace FileSystem {
|
|
|
288
292
|
* one question about one file cost two stats and an allocation.
|
|
289
293
|
*
|
|
290
294
|
* It shows up wherever a caller asks both questions, which is the common
|
|
291
|
-
* shape - `
|
|
292
|
-
*
|
|
293
|
-
*
|
|
295
|
+
* shape - `nm.ts`'s `exists(nmFile) ? nmFile.lastModified`, both mtime
|
|
296
|
+
* comparisons in `image.ts`, and the cache check `validCache` was before
|
|
297
|
+
* the source's version went into the cache file's name (see
|
|
298
|
+
* `versionedName`). Measured on this machine, four interleaved rounds
|
|
299
|
+
* against a CPU-bound control flat at 27-31 ms:
|
|
294
300
|
*
|
|
295
301
|
* validCache, file present 33.0 us -> 17.3 us 1.9x
|
|
296
302
|
* validCache, file absent 34.9 us -> 17.4 us 2.0x
|
|
@@ -563,19 +569,106 @@ export namespace FileSystem {
|
|
|
563
569
|
}
|
|
564
570
|
|
|
565
571
|
/**
|
|
566
|
-
*
|
|
567
|
-
*
|
|
572
|
+
* The name a build of `cacheName` from a source of `sourceMtime` is kept
|
|
573
|
+
* under: the mtime goes into the name ahead of the extension, which still
|
|
574
|
+
* decides the MIME type and the compression (`abc.js` built from a source of
|
|
575
|
+
* 1791456519209 is `abc@1791456519209.js`). With no mtime to check, the
|
|
576
|
+
* plain name.
|
|
577
|
+
*
|
|
578
|
+
* So a cached file answers for one version of its source, and existence is
|
|
579
|
+
* the whole test. The test before compared the source's mtime with the
|
|
580
|
+
* cache file's mtime, which is its *write* time: a build that read the
|
|
581
|
+
* source, then saw it edited before it wrote, left a file newer than the
|
|
582
|
+
* edit, which then counted as current for it and served the previous
|
|
583
|
+
* version until the next edit. Measured with a build held open across an
|
|
584
|
+
* edit: the request for the edit and every request after it got the build
|
|
585
|
+
* of the source before it. That is how an SFC in a dev server kept its old
|
|
586
|
+
* styles beside a new template and script after three quick edits.
|
|
587
|
+
*/
|
|
588
|
+
function versionedName(cacheName: string, sourceMtime: number | null): string {
|
|
589
|
+
if (!sourceMtime) return cacheName
|
|
590
|
+
const ext = parse(cacheName).ext
|
|
591
|
+
return `${cacheName.slice(0, cacheName.length - ext.length)}@${sourceMtime}${ext}`
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Delete the builds of `cacheName` from sources older than `sourceMtime`, raw
|
|
596
|
+
* and compressed, once a newer one is written: every edit would otherwise
|
|
597
|
+
* leave its predecessor's three files behind for as long as the cache lives.
|
|
598
|
+
*
|
|
599
|
+
* Older only, so a build that finishes late never deletes a newer one beside
|
|
600
|
+
* it. Best effort, and off the request's path: a file another request is
|
|
601
|
+
* still sending cannot be deleted on Windows, and the next build's sweep
|
|
602
|
+
* finds it again. Listing the directory is the cost, 1.5 ms for 3000 entries
|
|
603
|
+
* on this machine, paid once per build.
|
|
604
|
+
*/
|
|
605
|
+
async function dropOlderBuilds(
|
|
606
|
+
cacheDir: string,
|
|
607
|
+
cacheName: string,
|
|
608
|
+
sourceMtime: number,
|
|
609
|
+
): Promise<void> {
|
|
610
|
+
const ext = parse(cacheName).ext
|
|
611
|
+
const stem = cacheName.slice(0, cacheName.length - ext.length)
|
|
612
|
+
const [, names] = await Try.catch(() => fsReaddir(cacheDir))
|
|
613
|
+
if (!names) return
|
|
614
|
+
|
|
615
|
+
const prefix = `${stem}@`
|
|
616
|
+
for (const name of names) {
|
|
617
|
+
if (!name.startsWith(prefix)) continue
|
|
618
|
+
const rest = name.slice(prefix.length)
|
|
619
|
+
const version = rest.match(/^(\d+)/)?.[1]
|
|
620
|
+
if (!version || Number(version) >= sourceMtime) continue
|
|
621
|
+
// The rest of the name after the version must be this entry's own
|
|
622
|
+
// extension, compressed or not: `abc@1.root.js` is another entry.
|
|
623
|
+
const suffix = rest.slice(version.length)
|
|
624
|
+
if (!buildSuffixes(ext).includes(suffix)) continue
|
|
625
|
+
// Not awaited one by one, and a failure is only a file left for the
|
|
626
|
+
// next sweep: see above.
|
|
627
|
+
void fsRm(resolve(cacheDir, name), { force: true }).catch(() => undefined)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** `.js`, `.js.zst`, `.js.gz`: the names one build writes, for `ext`. */
|
|
632
|
+
function buildSuffixes(ext: string): string[] {
|
|
633
|
+
return [ext, ...COMPRESSION_MAP.map(c => `${ext}${c.ext}`)]
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* How long a superseded build is kept before its sweep. A build of an
|
|
638
|
+
* older source can finish after a newer one has written, and its caller
|
|
639
|
+
* opens the file it was handed a moment later: a sweep run straight away
|
|
640
|
+
* deleted it first, and that request answered ENOENT (2 runs in 8 of the
|
|
641
|
+
* test that holds a build open across an edit). Ten seconds is far longer
|
|
642
|
+
* than a request takes to open its file.
|
|
568
643
|
*/
|
|
569
|
-
|
|
570
|
-
|
|
644
|
+
let sweepDelayMs = 10_000
|
|
645
|
+
|
|
646
|
+
/** Test seam: how long a sweep waits. See `sweepDelayMs`. */
|
|
647
|
+
export function __setSweepDelay(ms: number): void {
|
|
648
|
+
sweepDelayMs = ms
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Sweep the versions before `sourceMtime` once the delay has passed. */
|
|
652
|
+
function scheduleSweep(
|
|
653
|
+
cacheDir: string,
|
|
654
|
+
cacheName: string,
|
|
655
|
+
sourceMtime: number,
|
|
656
|
+
): void {
|
|
657
|
+
const timer = setTimeout(
|
|
658
|
+
() => void dropOlderBuilds(cacheDir, cacheName, sourceMtime),
|
|
659
|
+
sweepDelayMs,
|
|
660
|
+
)
|
|
661
|
+
// A pending sweep is housekeeping: it must not hold a process open.
|
|
662
|
+
timer.unref?.()
|
|
571
663
|
}
|
|
572
664
|
|
|
573
665
|
type FileContent = string | Uint8Array<ArrayBuffer> | ArrayBuffer
|
|
574
666
|
|
|
575
667
|
/**
|
|
576
|
-
* Builds currently running, keyed on the resolved raw path
|
|
668
|
+
* Builds currently running, keyed on the resolved raw path, which names the
|
|
669
|
+
* source's version (`versionedName`).
|
|
577
670
|
*
|
|
578
|
-
*
|
|
671
|
+
* The cache test is a pure filesystem test with no record of work in progress,
|
|
579
672
|
* so N concurrent first-hits on a cold cache each saw "not cached", each ran
|
|
580
673
|
* `compiler()`, and each wrote the same three paths. Measured: 8 concurrent
|
|
581
674
|
* callers produced 8 compiler invocations. Every `.ts`/`.tsx`/`.vue` asset
|
|
@@ -589,12 +682,12 @@ export namespace FileSystem {
|
|
|
589
682
|
* bounded by construction (convention 6): an entry exists only while its own
|
|
590
683
|
* build is running, never past it.
|
|
591
684
|
*
|
|
592
|
-
* The key
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
685
|
+
* The key carries the version because the path does. A caller for a newer
|
|
686
|
+
* source used to join a build of an older one, and this note said the next
|
|
687
|
+
* request would re-validate and rebuild: it did not, since that build's file
|
|
688
|
+
* was written after the edit and so counted as current (`versionedName`).
|
|
689
|
+
* Builds of two versions write two sets of paths now, so they never race
|
|
690
|
+
* onto the same three.
|
|
598
691
|
*/
|
|
599
692
|
const inFlightBuilds = new Map<string, Promise<Bun.BunFile | null>>()
|
|
600
693
|
|
|
@@ -605,7 +698,7 @@ export namespace FileSystem {
|
|
|
605
698
|
compiler: () => MixedPromise<FileContent | null>,
|
|
606
699
|
compress = true,
|
|
607
700
|
): Promise<Bun.BunFile | null> {
|
|
608
|
-
const rawPath = resolve(cacheDir, cacheName)
|
|
701
|
+
const rawPath = resolve(cacheDir, versionedName(cacheName, sourceMtime))
|
|
609
702
|
|
|
610
703
|
const pending = inFlightBuilds.get(rawPath)
|
|
611
704
|
if (pending) return pending
|
|
@@ -637,11 +730,17 @@ export namespace FileSystem {
|
|
|
637
730
|
const compressible = compress && isCompressible(ext)
|
|
638
731
|
|
|
639
732
|
const rawFile = Bun.file(rawPath)
|
|
733
|
+
// Once written, superseded versions go, after a delay: see
|
|
734
|
+
// `dropOlderBuilds` and `sweepDelayMs`.
|
|
735
|
+
const built = <T>(file: T): T => {
|
|
736
|
+
if (sourceMtime) scheduleSweep(cacheDir, cacheName, sourceMtime)
|
|
737
|
+
return file
|
|
738
|
+
}
|
|
640
739
|
|
|
641
740
|
if (compressible) {
|
|
642
741
|
for (const { ext: compExt } of COMPRESSION_MAP) {
|
|
643
742
|
const compFile = Bun.file(`${rawPath}${compExt}`)
|
|
644
|
-
if (
|
|
743
|
+
if (exists(compFile)) return compFile
|
|
645
744
|
}
|
|
646
745
|
|
|
647
746
|
const content = await compiler()
|
|
@@ -670,19 +769,19 @@ export namespace FileSystem {
|
|
|
670
769
|
}),
|
|
671
770
|
])
|
|
672
771
|
|
|
673
|
-
return Bun.file(rawPath + COMPRESSION_MAP[0].ext)
|
|
772
|
+
return built(Bun.file(rawPath + COMPRESSION_MAP[0].ext))
|
|
674
773
|
}
|
|
675
774
|
|
|
676
775
|
// The mirror of the compressed branch's check above. Without it a
|
|
677
776
|
// non-compressible entry (a .woff2 from the gstatic proxy, say) was
|
|
678
777
|
// rebuilt (for fonts, re-fetched upstream) on every single request.
|
|
679
|
-
if (
|
|
778
|
+
if (exists(rawFile)) return rawFile
|
|
680
779
|
|
|
681
780
|
const content = await compiler()
|
|
682
781
|
if (content == null) return null
|
|
683
782
|
await mkdir(cacheDir)
|
|
684
783
|
await rawFile.write(content)
|
|
685
|
-
return rawFile
|
|
784
|
+
return built(rawFile)
|
|
686
785
|
}
|
|
687
786
|
|
|
688
787
|
/**
|