@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.
Files changed (91) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +89 -0
  3. package/package.json +69 -0
  4. package/src/cache/index.ts +8 -0
  5. package/src/cache/lru.ts +41 -0
  6. package/src/cache/shared-db.ts +51 -0
  7. package/src/cache/string.ts +150 -0
  8. package/src/cache/tiered.ts +493 -0
  9. package/src/client/globals.d.ts +74 -0
  10. package/src/client/livereload.ts +437 -0
  11. package/src/client/utils.ts +315 -0
  12. package/src/compiler/compiler.ts +263 -0
  13. package/src/compiler/dev-service.ts +660 -0
  14. package/src/compiler/index.ts +2 -0
  15. package/src/compiler/prompt-tracker.ts +36 -0
  16. package/src/compiler/tsconfig-sync.ts +71 -0
  17. package/src/core/bakery.ts +96 -0
  18. package/src/core/cache-version.ts +119 -0
  19. package/src/core/config.ts +296 -0
  20. package/src/core/context.ts +121 -0
  21. package/src/core/index.ts +61 -0
  22. package/src/core/init.ts +90 -0
  23. package/src/core/jsx.ts +152 -0
  24. package/src/core/paths.ts +24 -0
  25. package/src/core/plugins.ts +120 -0
  26. package/src/core/port.ts +73 -0
  27. package/src/global.d.ts +374 -0
  28. package/src/handlers/assets/google-font.ts +225 -0
  29. package/src/handlers/assets/image.ts +136 -0
  30. package/src/handlers/assets/nm.ts +73 -0
  31. package/src/handlers/assets/public.ts +17 -0
  32. package/src/handlers/assets/static.ts +86 -0
  33. package/src/handlers/assets/ts.ts +61 -0
  34. package/src/handlers/assets/tsx.ts +106 -0
  35. package/src/handlers/assets/virtual-asset.ts +104 -0
  36. package/src/handlers/core/$base.ts +256 -0
  37. package/src/handlers/core/$dynamic.ts +285 -0
  38. package/src/handlers/core/$error.ts +301 -0
  39. package/src/handlers/core/$middleware.ts +71 -0
  40. package/src/handlers/core/$mounts.ts +84 -0
  41. package/src/handlers/core/$registry.ts +153 -0
  42. package/src/handlers/core/$routing.ts +205 -0
  43. package/src/handlers/core/$static.ts +100 -0
  44. package/src/handlers/core/$websocket.ts +52 -0
  45. package/src/handlers/index.ts +21 -0
  46. package/src/handlers/routes/api.ts +95 -0
  47. package/src/handlers/routes/html.ts +95 -0
  48. package/src/handlers/routes/livereload.ts +54 -0
  49. package/src/handlers/routes/proxy.ts +74 -0
  50. package/src/logger/clients.ts +12 -0
  51. package/src/logger/index.ts +3 -0
  52. package/src/logger/logger.ts +375 -0
  53. package/src/logger/serve-log.ts +206 -0
  54. package/src/plugins/index.ts +15 -0
  55. package/src/plugins/routes.ts +110 -0
  56. package/src/plugins/types.ts +19 -0
  57. package/src/router.ts +351 -0
  58. package/src/session.ts +556 -0
  59. package/src/shared.d.ts +63 -0
  60. package/src/startup.ts +154 -0
  61. package/src/types.d.ts +111 -0
  62. package/src/utils/common/case.ts +11 -0
  63. package/src/utils/common/index.ts +5 -0
  64. package/src/utils/common/json.ts +35 -0
  65. package/src/utils/common/match.ts +6 -0
  66. package/src/utils/common/misc.ts +53 -0
  67. package/src/utils/common/try.ts +6 -0
  68. package/src/utils/constants.ts +153 -0
  69. package/src/utils/fs.ts +621 -0
  70. package/src/utils/http/body.ts +65 -0
  71. package/src/utils/http/csrf.ts +111 -0
  72. package/src/utils/http/dom.ts +238 -0
  73. package/src/utils/http/escape.ts +8 -0
  74. package/src/utils/http/etag.ts +318 -0
  75. package/src/utils/http/html.ts +525 -0
  76. package/src/utils/http/index.ts +8 -0
  77. package/src/utils/http/ip.ts +32 -0
  78. package/src/utils/http/response.ts +129 -0
  79. package/src/utils/index.ts +4 -0
  80. package/src/utils/isomorphic/case.ts +52 -0
  81. package/src/utils/isomorphic/escape.ts +43 -0
  82. package/src/utils/isomorphic/index.ts +15 -0
  83. package/src/utils/isomorphic/is.ts +36 -0
  84. package/src/utils/isomorphic/match.ts +50 -0
  85. package/src/utils/isomorphic/math.ts +11 -0
  86. package/src/utils/isomorphic/misc.ts +22 -0
  87. package/src/utils/isomorphic/stringify.ts +42 -0
  88. package/src/utils/isomorphic/try.ts +94 -0
  89. package/src/utils/jsonc.ts +10 -0
  90. package/src/utils/shared-pool.ts +193 -0
  91. 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
+ }