@bakery-framework/core 1.0.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/LICENSE +19 -0
- package/README.md +89 -0
- package/package.json +69 -0
- package/src/cache/index.ts +8 -0
- package/src/cache/lru.ts +41 -0
- package/src/cache/shared-db.ts +51 -0
- package/src/cache/string.ts +150 -0
- package/src/cache/tiered.ts +493 -0
- package/src/client/globals.d.ts +74 -0
- package/src/client/livereload.ts +437 -0
- package/src/client/utils.ts +315 -0
- package/src/compiler/compiler.ts +263 -0
- package/src/compiler/dev-service.ts +660 -0
- package/src/compiler/index.ts +2 -0
- package/src/compiler/prompt-tracker.ts +36 -0
- package/src/compiler/tsconfig-sync.ts +71 -0
- package/src/core/bakery.ts +96 -0
- package/src/core/cache-version.ts +119 -0
- package/src/core/config.ts +296 -0
- package/src/core/context.ts +121 -0
- package/src/core/index.ts +61 -0
- package/src/core/init.ts +90 -0
- package/src/core/jsx.ts +152 -0
- package/src/core/paths.ts +24 -0
- package/src/core/plugins.ts +120 -0
- package/src/core/port.ts +73 -0
- package/src/global.d.ts +374 -0
- package/src/handlers/assets/google-font.ts +225 -0
- package/src/handlers/assets/image.ts +136 -0
- package/src/handlers/assets/nm.ts +73 -0
- package/src/handlers/assets/public.ts +17 -0
- package/src/handlers/assets/static.ts +86 -0
- package/src/handlers/assets/ts.ts +61 -0
- package/src/handlers/assets/tsx.ts +106 -0
- package/src/handlers/assets/virtual-asset.ts +104 -0
- package/src/handlers/core/$base.ts +256 -0
- package/src/handlers/core/$dynamic.ts +285 -0
- package/src/handlers/core/$error.ts +301 -0
- package/src/handlers/core/$middleware.ts +71 -0
- package/src/handlers/core/$mounts.ts +84 -0
- package/src/handlers/core/$registry.ts +153 -0
- package/src/handlers/core/$routing.ts +205 -0
- package/src/handlers/core/$static.ts +100 -0
- package/src/handlers/core/$websocket.ts +52 -0
- package/src/handlers/index.ts +21 -0
- package/src/handlers/routes/api.ts +95 -0
- package/src/handlers/routes/html.ts +95 -0
- package/src/handlers/routes/livereload.ts +54 -0
- package/src/handlers/routes/proxy.ts +74 -0
- package/src/logger/clients.ts +12 -0
- package/src/logger/index.ts +3 -0
- package/src/logger/logger.ts +375 -0
- package/src/logger/serve-log.ts +206 -0
- package/src/plugins/index.ts +15 -0
- package/src/plugins/routes.ts +110 -0
- package/src/plugins/types.ts +19 -0
- package/src/router.ts +351 -0
- package/src/session.ts +556 -0
- package/src/shared.d.ts +63 -0
- package/src/startup.ts +154 -0
- package/src/types.d.ts +111 -0
- package/src/utils/common/case.ts +11 -0
- package/src/utils/common/index.ts +5 -0
- package/src/utils/common/json.ts +35 -0
- package/src/utils/common/match.ts +6 -0
- package/src/utils/common/misc.ts +53 -0
- package/src/utils/common/try.ts +6 -0
- package/src/utils/constants.ts +153 -0
- package/src/utils/fs.ts +621 -0
- package/src/utils/http/body.ts +65 -0
- package/src/utils/http/csrf.ts +111 -0
- package/src/utils/http/dom.ts +238 -0
- package/src/utils/http/escape.ts +8 -0
- package/src/utils/http/etag.ts +318 -0
- package/src/utils/http/html.ts +525 -0
- package/src/utils/http/index.ts +8 -0
- package/src/utils/http/ip.ts +32 -0
- package/src/utils/http/response.ts +129 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/isomorphic/case.ts +52 -0
- package/src/utils/isomorphic/escape.ts +43 -0
- package/src/utils/isomorphic/index.ts +15 -0
- package/src/utils/isomorphic/is.ts +36 -0
- package/src/utils/isomorphic/match.ts +50 -0
- package/src/utils/isomorphic/math.ts +11 -0
- package/src/utils/isomorphic/misc.ts +22 -0
- package/src/utils/isomorphic/stringify.ts +42 -0
- package/src/utils/isomorphic/try.ts +94 -0
- package/src/utils/jsonc.ts +10 -0
- package/src/utils/shared-pool.ts +193 -0
- package/tsconfig.app.json +34 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative endpoint tables for plugins.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard already used a `Record<path, handler>` map; analytics used a
|
|
5
|
+
* hand-rolled `if (path === … && method === …)` chain. This generalises the
|
|
6
|
+
* dashboard's shape — the one that was already working — so a third plugin does
|
|
7
|
+
* not arrive with a fourth style of dispatch.
|
|
8
|
+
*
|
|
9
|
+
* Scope is deliberately narrow: exact-match paths only, no params or wildcards.
|
|
10
|
+
* Anything needing those belongs in the core router (`DynamicHandler`), not in
|
|
11
|
+
* a parallel implementation here.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { checkCsrf, checkSameOrigin } from '../utils/http/csrf'
|
|
15
|
+
import { response } from '../utils/http/response'
|
|
16
|
+
import type { ValidResponses } from './types'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Handlers may return anything the router knows how to serialize — the
|
|
20
|
+
* dashboard's return `JsonResponseData` and `BunFile` as well as `Response`.
|
|
21
|
+
* `ValidResponses` (= `Handler.Response`) is the framework's name for that set.
|
|
22
|
+
*/
|
|
23
|
+
export type PluginRoute = (req: Request, url: URL) => ValidResponses
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Keys are either a bare path (`'/api/_x/stats'`, matching any method) or a
|
|
27
|
+
* method-qualified path (`'POST /api/_x/reset'`). A method-qualified entry wins
|
|
28
|
+
* over a bare one for the same path.
|
|
29
|
+
*
|
|
30
|
+
* The two forms are guarded differently — see `guardFor` below. A bare key is
|
|
31
|
+
* same-origin-only; a method-qualified one gets the ordinary CSRF check.
|
|
32
|
+
*/
|
|
33
|
+
export type PluginRouteTable = Record<string, PluginRoute>
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What a dispatch built from `T` actually resolves to: the union of that
|
|
37
|
+
* table's own handler return types, awaited, with the `undefined`/`void` cases
|
|
38
|
+
* replaced by `null` — exactly what `dispatch` does at runtime with `?? null`.
|
|
39
|
+
*
|
|
40
|
+
* Keyed on the table type rather than flattened to `ValidResponses` because
|
|
41
|
+
* `ValidResponses` contains `object`, which absorbs `Response`, `Bun.BunFile`
|
|
42
|
+
* and `JsonResponseData` back into one useless member. Declaring a table with
|
|
43
|
+
* `satisfies PluginRouteTable` (rather than annotating it) keeps the literal
|
|
44
|
+
* type, and with it the precision.
|
|
45
|
+
*/
|
|
46
|
+
export type PluginRouteResult<T extends PluginRouteTable> =
|
|
47
|
+
| Exclude<Awaited<ReturnType<T[keyof T]>>, undefined | void>
|
|
48
|
+
// The 403 `dispatch` answers with when the request fails the guard below.
|
|
49
|
+
// Spelled out rather than smuggled through: a dispatch can return something
|
|
50
|
+
// no endpoint in the table declares, and the caller should have to see that.
|
|
51
|
+
| Response
|
|
52
|
+
| null
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How hard `dispatch` guards a match, by how much the table declared.
|
|
56
|
+
*
|
|
57
|
+
* A **method-qualified** key (`'POST /api/_x/reset'`) is an author who thought
|
|
58
|
+
* about methods, so it gets the ordinary `checkCsrf`: safe methods through,
|
|
59
|
+
* unsafe ones same-origin. `'GET /api/_x/public'` is therefore also the way to
|
|
60
|
+
* declare an endpoint that *is* meant to answer cross-origin.
|
|
61
|
+
*
|
|
62
|
+
* A **bare** key matches every method, which means the author did not say —
|
|
63
|
+
* and that is exactly how the dashboard's mutating endpoints ended up
|
|
64
|
+
* answering a `GET`. `checkCsrf` is no help there: it waves GET through by
|
|
65
|
+
* definition, so a cross-site `<img src="/api/_dashboard/execute-action">`
|
|
66
|
+
* arrives with the session attached and runs. A bare key therefore gets the
|
|
67
|
+
* stricter `checkSameOrigin`, on every method: the request must not come from
|
|
68
|
+
* another site whatever verb it used.
|
|
69
|
+
*
|
|
70
|
+
* The result is that a plugin cannot forget CSRF by omission. Forgetting to
|
|
71
|
+
* name the method now costs cross-origin reachability rather than the guard.
|
|
72
|
+
*/
|
|
73
|
+
function guardFor(qualified: boolean) {
|
|
74
|
+
return qualified ? checkCsrf : checkSameOrigin
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function routeTable<T extends PluginRouteTable>(routes: T) {
|
|
78
|
+
/**
|
|
79
|
+
* Declared as an overload — the same shape `handleRequest` uses in
|
|
80
|
+
* `router.ts`. The signature carries the precise per-table union; the
|
|
81
|
+
* implementation only ever sees the `PluginRoute` *constraint*, so on its own
|
|
82
|
+
* it could infer no better than the wide `ValidResponses`.
|
|
83
|
+
*/
|
|
84
|
+
function dispatch(req: Request, url?: URL): Promise<PluginRouteResult<T>>
|
|
85
|
+
async function dispatch(req: Request, url: URL = new URL(req.url)) {
|
|
86
|
+
const path = url.pathname
|
|
87
|
+
const qualified = `${req.method} ${path}`
|
|
88
|
+
|
|
89
|
+
// hasOwn, not plain indexing: a bare `routes[path]` lookup would reach
|
|
90
|
+
// inherited Object.prototype members for a suitably named path.
|
|
91
|
+
const isQualified = Object.hasOwn(routes, qualified)
|
|
92
|
+
const handler = isQualified
|
|
93
|
+
? routes[qualified]
|
|
94
|
+
: Object.hasOwn(routes, path)
|
|
95
|
+
? routes[path]
|
|
96
|
+
: undefined
|
|
97
|
+
|
|
98
|
+
if (!handler) return null
|
|
99
|
+
|
|
100
|
+
// Runs after the match and before the handler, so an unmatched path still
|
|
101
|
+
// resolves to `null` and falls through to the rest of the router rather
|
|
102
|
+
// than being claimed by a 403.
|
|
103
|
+
const denied = guardFor(isQualified)(req, url)
|
|
104
|
+
if (denied) return response.error('Forbidden', 403)
|
|
105
|
+
|
|
106
|
+
return (await handler(req, url)) ?? null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return dispatch
|
|
110
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Handler } from '../handlers/core/$base'
|
|
2
|
+
import type { MixedPromise } from '../types'
|
|
3
|
+
|
|
4
|
+
export type ValidResponses = Handler.Response
|
|
5
|
+
|
|
6
|
+
export interface ServerPlugin {
|
|
7
|
+
name: string
|
|
8
|
+
setup?(config: ProcessedAppConfig): MixedPromise<void>
|
|
9
|
+
onStart?(server: Bun.Server<any>): MixedPromise<void>
|
|
10
|
+
onRequest?(req: Request): ValidResponses
|
|
11
|
+
onRoute?(req: Request): MixedPromise<void>
|
|
12
|
+
onError?(error: Handler.Error.Data, req?: Request): ValidResponses
|
|
13
|
+
onShutdown?(): MixedPromise<void>
|
|
14
|
+
onCompile?(content: string, path: string): MixedPromise<string>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function definePlugin<T extends ServerPlugin>(plugin: T): T {
|
|
18
|
+
return plugin
|
|
19
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { Bakery, hostStore } from './core/bakery'
|
|
2
|
+
import { matchBlockedCached } from './core/context'
|
|
3
|
+
import { PluginHooks } from './core/plugins'
|
|
4
|
+
import { DefaultErrorHandler } from './handlers/assets/static'
|
|
5
|
+
import type { Handler } from './handlers/core/$base'
|
|
6
|
+
import { ErrorHandler } from './handlers/core/$error'
|
|
7
|
+
import { WebSocketHandler } from './handlers/core/$websocket'
|
|
8
|
+
import { errorMsg, getElapsed, log, serveLog } from './logger'
|
|
9
|
+
import { Session } from './session'
|
|
10
|
+
import type { MixedPromise } from './types'
|
|
11
|
+
import { JsonResponseData } from './utils'
|
|
12
|
+
import { is, Try } from './utils/common'
|
|
13
|
+
import { fs } from './utils/fs'
|
|
14
|
+
import {
|
|
15
|
+
checkWebSocketOrigin,
|
|
16
|
+
ETag,
|
|
17
|
+
injectIfHtml,
|
|
18
|
+
response,
|
|
19
|
+
withStatus,
|
|
20
|
+
} from './utils/http'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a WebSocket upgrade, refusing cross-origin handshakes first.
|
|
24
|
+
*
|
|
25
|
+
* The origin check lives here rather than in each `canHandle` on purpose:
|
|
26
|
+
* every socket a plugin or an app registers inherits it, instead of every
|
|
27
|
+
* author having to remember. `/_analytics_ws` was the only handler that got it
|
|
28
|
+
* right on its own, and it did so by authenticating — `/_livereload` had
|
|
29
|
+
* nothing, and `WebSocketHandler.canHandle` returns `true` by default, so the
|
|
30
|
+
* base class was handing out sockets to anyone who asked.
|
|
31
|
+
*
|
|
32
|
+
* Returning `false` (rather than a 403) keeps the published signature — the
|
|
33
|
+
* caller below turns a refusal into `400 WebSocket Upgrade Failed`, which is
|
|
34
|
+
* also what an unclaimed path gets. The reason reaches the log; the client is
|
|
35
|
+
* told nothing it did not already know.
|
|
36
|
+
*/
|
|
37
|
+
export async function upgradeWebsocket(
|
|
38
|
+
req: Request,
|
|
39
|
+
path: string,
|
|
40
|
+
): Promise<boolean | undefined> {
|
|
41
|
+
const url: URL = (req as any).__parsedUrl || new URL(req.url)
|
|
42
|
+
const denied = checkWebSocketOrigin(req, url)
|
|
43
|
+
if (denied) {
|
|
44
|
+
serveLog.WEBSOCKET_ERR({
|
|
45
|
+
ip: Bakery.server?.requestIP(req)?.address || 'Unknown',
|
|
46
|
+
error: denied,
|
|
47
|
+
})
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const upgrade = await Bakery.handlers.websocket.handle(path, req)
|
|
53
|
+
return Boolean(upgrade)
|
|
54
|
+
} catch (err: any) {
|
|
55
|
+
serveLog.UNHANDLED_ERR({
|
|
56
|
+
error: `Error checking WebSocketHandler: ${errorMsg(err)}`,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether the deny-list applies to whatever this handler is about to answer
|
|
64
|
+
* with. `Bakery.config.blocked` exists to stop files on disk being served, so
|
|
65
|
+
* handlers that read the request path as a route *name* opt out by declaring
|
|
66
|
+
* `servesFiles = false` — `ApiHandler`, `ProxyHandler`, `MiddlewareHandler`.
|
|
67
|
+
*
|
|
68
|
+
* Deny by default, and the direction matters. Naming only the obvious file
|
|
69
|
+
* servers — Static, Public, NM — would have left `/schema.ts` and
|
|
70
|
+
* `/server.config.ts` reachable through `TSHandler`, which compiles a source
|
|
71
|
+
* file and serves the result. Anything that does not opt out, including a
|
|
72
|
+
* plugin's handler, keeps the check.
|
|
73
|
+
*
|
|
74
|
+
* The flag lives on `Handler` (see its doc comment) rather than in a list
|
|
75
|
+
* here so that `DynamicHandler.resolveRoute` can gate the *resolved-file*
|
|
76
|
+
* check on the same answer. Two lists would have drifted, and the resolved
|
|
77
|
+
* check is what closes the extension-substitution hole this one cannot see.
|
|
78
|
+
*/
|
|
79
|
+
function servesFiles(handler: any): boolean {
|
|
80
|
+
return handler?.servesFiles !== false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function handleRequest(
|
|
84
|
+
req: Request,
|
|
85
|
+
): Handler.Response | MixedPromise<symbol>
|
|
86
|
+
export async function handleRequest(req: Request) {
|
|
87
|
+
// worker.ts parses and attaches this before calling in; the fallback keeps
|
|
88
|
+
// direct callers (tests, embedders) working.
|
|
89
|
+
const url: URL = (req as any).__parsedUrl || new URL(req.url)
|
|
90
|
+
;(req as any).__parsedUrl = url
|
|
91
|
+
const path = url.pathname
|
|
92
|
+
|
|
93
|
+
// One read of the config getter, not three: `Bakery.serveRoot` walks
|
|
94
|
+
// `hostStore.getStore()?.config ?? getConfig()` on every access, and the
|
|
95
|
+
// blocked-glob check below used to pay the same walk again. And no
|
|
96
|
+
// `fs.resolve` around the target — `isForbidden` normalises both arguments
|
|
97
|
+
// itself, so that was a second full path resolution of the same string on
|
|
98
|
+
// every request.
|
|
99
|
+
const config = Bakery.config
|
|
100
|
+
const serveRoot = config.root
|
|
101
|
+
if (fs.isForbidden(serveRoot + path, serveRoot)) {
|
|
102
|
+
return new Response('Forbidden', { status: 403 })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (req.headers.get('Upgrade') === 'websocket') {
|
|
106
|
+
const wsHandled = await upgradeWebsocket(req, path)
|
|
107
|
+
return wsHandled
|
|
108
|
+
? WebSocketHandler.WS_UPGRADE
|
|
109
|
+
: response.error('WebSocket Upgrade Failed', 400)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await PluginHooks.onRoute(req)
|
|
113
|
+
|
|
114
|
+
const pluginResponse = await PluginHooks.onRequest(req)
|
|
115
|
+
if (pluginResponse) return pluginResponse
|
|
116
|
+
|
|
117
|
+
// Resolved rather than dispatched, because the blocked globs are a question
|
|
118
|
+
// about *which* handler is about to answer. Testing them against the raw
|
|
119
|
+
// path up front made `/api/manifest.json` a 403 before routing had a say,
|
|
120
|
+
// and no config could opt out of it. `resolve` is what `HandlerMap.handle`
|
|
121
|
+
// calls anyway, so this costs nothing extra.
|
|
122
|
+
const handler = await Bakery.handlers.fetch.resolve(path, req)
|
|
123
|
+
if (!handler) return new Response('Not Found', { status: 404 })
|
|
124
|
+
|
|
125
|
+
// `matchBlockedCached` memoises the verdict on the request store, so the
|
|
126
|
+
// re-check `StaticHandler.handle` keeps for its direct callers costs a map
|
|
127
|
+
// hit instead of a second pair of glob matches.
|
|
128
|
+
if (servesFiles(handler) && matchBlockedCached(config.blocked, path)) {
|
|
129
|
+
return new Response('Forbidden', { status: 403 })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return handler.handle(path, req)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const isWSHandler = (handler: any) =>
|
|
136
|
+
handler &&
|
|
137
|
+
(handler.prototype instanceof WebSocketHandler ||
|
|
138
|
+
handler === WebSocketHandler)
|
|
139
|
+
|
|
140
|
+
function prepareWSData(ws: any) {
|
|
141
|
+
ws.data ||= {}
|
|
142
|
+
const mainData = ws.data
|
|
143
|
+
if (isWSHandler(mainData.this)) {
|
|
144
|
+
mainData.data ||= {}
|
|
145
|
+
return { handler: mainData.this, data: mainData.data }
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The resolved config is handed to `fn` rather than re-read inside it:
|
|
152
|
+
* `Bakery.config` inside the callback would land on exactly the store entry
|
|
153
|
+
* installed here, so reading it again paid a second
|
|
154
|
+
* `AsyncLocalStorage.getStore` per WebSocket event for the same object.
|
|
155
|
+
*/
|
|
156
|
+
function runInHostContext<T>(
|
|
157
|
+
ws: any,
|
|
158
|
+
fn: (config: Readonly<ProcessedAppConfig>) => Promise<T> | T,
|
|
159
|
+
): Promise<T> | T {
|
|
160
|
+
const mainData = ws?.data || {}
|
|
161
|
+
const config = mainData.config || Bakery.config
|
|
162
|
+
const hostname = mainData.hostname || ''
|
|
163
|
+
return hostStore.run({ config, hostname }, () => fn(config))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const serveWebSocket: Bun.WebSocketHandler<any> = {
|
|
167
|
+
async message(ws: any, message) {
|
|
168
|
+
return runInHostContext(ws, async config => {
|
|
169
|
+
try {
|
|
170
|
+
const h = prepareWSData(ws)
|
|
171
|
+
if (h) return await h.handler.message(ws, message, h.data)
|
|
172
|
+
config.websocket.message(ws, message)
|
|
173
|
+
} catch (err: any) {
|
|
174
|
+
serveLog.UNHANDLED_ERR({
|
|
175
|
+
error: `WebSocket message error: ${errorMsg(err)}`,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
},
|
|
180
|
+
async open(ws: any) {
|
|
181
|
+
return runInHostContext(ws, async config => {
|
|
182
|
+
try {
|
|
183
|
+
const h = prepareWSData(ws)
|
|
184
|
+
if (h) return await h.handler.open(ws, h.data)
|
|
185
|
+
await config.websocket.open?.(ws)
|
|
186
|
+
} catch (err: any) {
|
|
187
|
+
serveLog.UNHANDLED_ERR({
|
|
188
|
+
error: `WebSocket open error: ${errorMsg(err)}`,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
},
|
|
193
|
+
async close(ws: any, code: number, reason: string) {
|
|
194
|
+
return runInHostContext(ws, async config => {
|
|
195
|
+
try {
|
|
196
|
+
const h = prepareWSData(ws)
|
|
197
|
+
if (h) return await h.handler.close(ws, code, reason, h.data)
|
|
198
|
+
await config.websocket.close?.(ws, code, reason)
|
|
199
|
+
} catch (err: any) {
|
|
200
|
+
serveLog.UNHANDLED_ERR({
|
|
201
|
+
error: `WebSocket close error: ${errorMsg(err)}`,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
},
|
|
206
|
+
async drain(ws: any) {
|
|
207
|
+
return runInHostContext(ws, async config => {
|
|
208
|
+
try {
|
|
209
|
+
const h = prepareWSData(ws)
|
|
210
|
+
if (h) return await h.handler.drain(ws, h.data)
|
|
211
|
+
await config.websocket.drain?.(ws)
|
|
212
|
+
} catch (err: any) {
|
|
213
|
+
serveLog.UNHANDLED_ERR({
|
|
214
|
+
error: `WebSocket drain error: ${errorMsg(err)}`,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Make an error page answer with the error's status.
|
|
223
|
+
*
|
|
224
|
+
* The HTML and TSX error handlers end in `injectIfHtml`, which builds its
|
|
225
|
+
* Response without one — so an app with `src/error-404.html` served its 404
|
|
226
|
+
* page as `200 OK` and every crawler, cache and monitor believed it. Applied
|
|
227
|
+
* here rather than inside `injectIfHtml` on purpose: the only status signal
|
|
228
|
+
* available down there is `params`, which for a GET is the query string, and
|
|
229
|
+
* `?errorCode=500` is not something a client gets to decide.
|
|
230
|
+
*
|
|
231
|
+
* A `JsonResponseData` (the `/api/` arm) carries its own `.status` that
|
|
232
|
+
* `processResponse` reads, and a BunFile has no status at all — both pass
|
|
233
|
+
* through untouched.
|
|
234
|
+
*/
|
|
235
|
+
function applyErrorStatus(
|
|
236
|
+
res: Awaited<Handler.Response>,
|
|
237
|
+
code: number,
|
|
238
|
+
): Awaited<Handler.Response> {
|
|
239
|
+
if (!(res instanceof Response)) return res
|
|
240
|
+
if (!Number.isInteger(code) || code < 400 || code > 599) return res
|
|
241
|
+
return withStatus(res, code)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function handleRequestError(
|
|
245
|
+
path: string,
|
|
246
|
+
req?: Request,
|
|
247
|
+
error?: any,
|
|
248
|
+
): Handler.Response
|
|
249
|
+
export async function handleRequestError(
|
|
250
|
+
path: string,
|
|
251
|
+
req?: Request,
|
|
252
|
+
error?: any,
|
|
253
|
+
) {
|
|
254
|
+
req ||= new Request('http://localhost/__internal__')
|
|
255
|
+
error = ErrorHandler.extractErrorData(error)
|
|
256
|
+
|
|
257
|
+
const pluginRes = await PluginHooks.onError(error, req)
|
|
258
|
+
if (pluginRes) return pluginRes
|
|
259
|
+
|
|
260
|
+
const bakeryError = Object.assign({}, error, {
|
|
261
|
+
errorBody: `${error.errorBody} at ${path}`,
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// Wrapped for the same reason the registry call below it is: this is app
|
|
265
|
+
// code. An `onError` that throws used to escape `Try.return`'s fallback in
|
|
266
|
+
// the caller — a fallback that itself rejects is not a fallback — so the one
|
|
267
|
+
// hook whose job is to handle failure took the app's error page down with
|
|
268
|
+
// it and the client got Bun's raw 500 instead.
|
|
269
|
+
const [onErrorFailed, configError] = await Try.catch(() =>
|
|
270
|
+
Bakery.config.onError(bakeryError),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if (onErrorFailed) {
|
|
274
|
+
serveLog.UNHANDLED_ERR({
|
|
275
|
+
error: `Error in config.onError: ${errorMsg(onErrorFailed)}`,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (configError instanceof Response) {
|
|
280
|
+
const injectedRes = await injectIfHtml(configError)
|
|
281
|
+
return injectedRes || configError
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const errRes = await Bakery.handlers.error.handle(path, req, error)
|
|
286
|
+
if (errRes) return applyErrorStatus(errRes, error.errorCode)
|
|
287
|
+
} catch (err: any) {
|
|
288
|
+
serveLog.UNHANDLED_ERR({
|
|
289
|
+
error: `Error in ErrorHandler: ${errorMsg(err)}`,
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return DefaultErrorHandler.handle(path, req, error)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function processResponse(
|
|
297
|
+
data: Handler.Response | MixedPromise<symbol>,
|
|
298
|
+
req: Request,
|
|
299
|
+
): Promise<Response | undefined> {
|
|
300
|
+
data = await data
|
|
301
|
+
let status = 200
|
|
302
|
+
let type = 'text/plain; charset=utf-8'
|
|
303
|
+
if (data === WebSocketHandler.WS_UPGRADE) return
|
|
304
|
+
if (data === null || data === undefined)
|
|
305
|
+
return new Response(null, { status: 204 })
|
|
306
|
+
|
|
307
|
+
const resp = await (async function getResponse() {
|
|
308
|
+
if (data instanceof Response) {
|
|
309
|
+
return (await injectIfHtml(data)) || data
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (data instanceof Blob) {
|
|
313
|
+
return ETag.sendFile(data as Bun.BunFile, req)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (typeof data === 'string') {
|
|
317
|
+
const injected = await injectIfHtml(data)
|
|
318
|
+
return injected || response.text(data)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (data instanceof JsonResponseData) {
|
|
322
|
+
data.time = getElapsed(req.startNs)
|
|
323
|
+
status = data.status
|
|
324
|
+
data = data.toJson()
|
|
325
|
+
type = 'application/json'
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (is.object(data)) {
|
|
329
|
+
data = JSON.stringify(data)
|
|
330
|
+
type = 'application/json'
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return ETag.sendText(String(data), req, type, status)
|
|
334
|
+
})()
|
|
335
|
+
|
|
336
|
+
const sess = Session.getCookie(req)
|
|
337
|
+
|
|
338
|
+
// append, not set: a handler may already have issued its own Set-Cookie
|
|
339
|
+
// (e.g. an auth cookie from a login route) that must not be overwritten.
|
|
340
|
+
sess && resp.headers.append('Set-Cookie', sess)
|
|
341
|
+
const final = ETag.sendResponse(req, resp)
|
|
342
|
+
if (!(final instanceof Response)) {
|
|
343
|
+
log({
|
|
344
|
+
by: 'final-response',
|
|
345
|
+
level: 'error',
|
|
346
|
+
msg: 'ETag failed to generate a valid response',
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return final
|
|
351
|
+
}
|