bosia 1.2.4 → 1.2.6
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 +5 -6
- package/package.json +2 -1
- package/src/core/backend.ts +56 -2
- package/src/core/cache.ts +378 -30
- package/src/core/client/App.svelte +17 -9
- package/src/core/client/prefetch.ts +57 -24
- package/src/core/cors.ts +17 -1
- package/src/core/dedup.ts +51 -7
- package/src/core/env.ts +3 -0
- package/src/core/hooks.ts +1 -1
- package/src/core/html.ts +13 -1
- package/src/core/plugins/access-log.ts +128 -0
- package/src/core/prerender.ts +475 -79
- package/src/core/renderer.ts +69 -28
- package/src/core/server.ts +95 -53
- package/src/core/server.workers.ts +1 -1
- package/src/core/types/plugin.ts +2 -1
- package/templates/default/.env.example +20 -5
- package/templates/default/tsconfig.json +1 -1
- package/templates/demo/.env.example +20 -5
- package/templates/demo/src/hooks.server.ts +3 -12
- package/templates/demo/tsconfig.json +1 -1
- package/templates/shop/src/app.d.ts +0 -1
- package/templates/shop/src/hooks.server.ts +3 -11
- package/templates/shop/src/routes/+layout.server.ts +0 -1
- package/templates/shop/tsconfig.json +1 -1
- package/templates/store/src/app.d.ts +0 -1
- package/templates/store/src/hooks.server.ts +3 -11
- package/templates/store/src/routes/+layout.server.ts +0 -1
- package/templates/store/tsconfig.json +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { router, scrollToHash } from "./router.svelte.ts";
|
|
4
4
|
import { findMatch } from "../matcher.ts";
|
|
5
5
|
import { clientRoutes, defaultTitle } from "bosia:routes";
|
|
6
|
-
import { consumePrefetch,
|
|
6
|
+
import { consumePrefetch, clearPrefetches, dataUrl, readDataResponse } from "./prefetch.ts";
|
|
7
7
|
import { appState, clearDirty } from "./appState.svelte.ts";
|
|
8
8
|
import { captureSnapshot, liveContext, shouldRerun, type CacheEntry } from "./loaderCache.ts";
|
|
9
9
|
import { pickErrorPage } from "../errorMatch.ts";
|
|
@@ -350,18 +350,26 @@
|
|
|
350
350
|
|
|
351
351
|
// Load components + data in parallel, then update state atomically
|
|
352
352
|
// to avoid a flash of stale/empty data before the fetch completes.
|
|
353
|
-
|
|
354
|
-
|
|
353
|
+
// `fresh` covers `invalidated` plus dirty keys/URLs: a prefetch sent before
|
|
354
|
+
// an invalidate() skipped `_fresh`, so it may hold the server's stale copy.
|
|
355
|
+
const cached = match.route.hasServerData && !fresh ? consumePrefetch(path, maskBits) : null;
|
|
356
|
+
clearPrefetches(); // clear remaining entries on navigation — matches SvelteKit behavior
|
|
355
357
|
// A prerendered route's data is a fixed file, so there is nothing to omit.
|
|
356
358
|
const prerendered = match.route.prerender;
|
|
359
|
+
const fetchData = () =>
|
|
360
|
+
fetch(dataUrl(path, prerendered ? undefined : maskBits, fresh && !prerendered))
|
|
361
|
+
.then(readDataResponse)
|
|
362
|
+
// Only a failed request reaches here now — offline, DNS, aborted.
|
|
363
|
+
// A response that arrived is read for what it says, not discarded.
|
|
364
|
+
.catch(() => null);
|
|
357
365
|
const dataFetch = cached
|
|
358
|
-
?
|
|
366
|
+
? // A prefetch still in flight: wait for it; fetch again if it failed or
|
|
367
|
+
// errored (a hover-time 500 shouldn't stand in for the click's answer).
|
|
368
|
+
cached instanceof Promise
|
|
369
|
+
? cached.then((data) => (data && !data.error ? data : fetchData()))
|
|
370
|
+
: Promise.resolve(cached)
|
|
359
371
|
: match.route.hasServerData
|
|
360
|
-
?
|
|
361
|
-
.then(readDataResponse)
|
|
362
|
-
// Only a failed request reaches here now — offline, DNS, aborted.
|
|
363
|
-
// A response that arrived is read for what it says, not discarded.
|
|
364
|
-
.catch(() => null)
|
|
372
|
+
? fetchData()
|
|
365
373
|
: Promise.resolve(null);
|
|
366
374
|
|
|
367
375
|
// Scrolls first: waiting goto()/use:enhance calls resume after the scroll.
|
|
@@ -109,21 +109,46 @@ function errorMessage(res: Response): string {
|
|
|
109
109
|
return res.statusText || "Internal Server Error";
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
// `mask`: the `_invalidated` bits the prefetch sent (null = asked for every layer).
|
|
113
|
+
const prefetchCache = new Map<string, { data: any; ts: number; mask: string | null }>();
|
|
113
114
|
const MAX_PREFETCH_ENTRIES = 50;
|
|
114
115
|
|
|
115
|
-
// In-flight
|
|
116
|
-
|
|
116
|
+
// In-flight prefetches. A click that lands while its prefetch is still loading
|
|
117
|
+
// (a tap: touchstart prefetches ~100ms before the click) waits for that fetch
|
|
118
|
+
// instead of sending the same `/__bosia/data` request a second time. It gets
|
|
119
|
+
// whatever the response said (data, a redirect, an error), read like the
|
|
120
|
+
// router's own fetch; `null` only when the request itself failed.
|
|
121
|
+
// `clearPrefetches()` empties it on every navigation, so a prefetch that started
|
|
122
|
+
// before it neither fills the cache nor serves a later click.
|
|
123
|
+
const pending = new Map<string, { run: Promise<any | null>; mask: string | null }>();
|
|
117
124
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Prefetched data for a path, or the in-flight prefetch's promise (resolves to
|
|
127
|
+
* the data, or `null` when the prefetch can't be used — fetch it yourself).
|
|
128
|
+
* The entry is consumed: a second call gets `null`. `mask` is the navigation's
|
|
129
|
+
* own `_invalidated` bits: a prefetch built against the loader cache as it was
|
|
130
|
+
* at hover time may have left out a layer this navigation needs.
|
|
131
|
+
*/
|
|
132
|
+
export function consumePrefetch(path: string, mask: string): any | Promise<any | null> | null {
|
|
133
|
+
const inflight = pending.get(path);
|
|
134
|
+
if (inflight) {
|
|
135
|
+
pending.delete(path);
|
|
136
|
+
return inflight.mask === null || inflight.mask === mask ? inflight.run : null;
|
|
137
|
+
}
|
|
120
138
|
const entry = prefetchCache.get(path);
|
|
121
139
|
if (entry === undefined) return null;
|
|
122
140
|
prefetchCache.delete(path);
|
|
123
141
|
if (Date.now() - entry.ts > 30_000) return null;
|
|
142
|
+
if (entry.mask !== null && entry.mask !== mask) return null;
|
|
124
143
|
return entry.data;
|
|
125
144
|
}
|
|
126
145
|
|
|
146
|
+
/** Drops every prefetch, finished or in flight — call on navigation. */
|
|
147
|
+
export function clearPrefetches(): void {
|
|
148
|
+
prefetchCache.clear();
|
|
149
|
+
pending.clear();
|
|
150
|
+
}
|
|
151
|
+
|
|
127
152
|
/** Prefetches data for a path and stores in cache. No-op if already cached/in-flight. */
|
|
128
153
|
export async function prefetchPath(path: string): Promise<void> {
|
|
129
154
|
// Nothing to fetch for the page we're on. A click also focuses its link, so
|
|
@@ -152,27 +177,35 @@ export async function prefetchPath(path: string): Promise<void> {
|
|
|
152
177
|
// A route without server data never fetches on click either.
|
|
153
178
|
if (!warmMatch?.route.hasServerData) return;
|
|
154
179
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
180
|
+
// Only a plain data response is kept for a later click; `ok` alone would
|
|
181
|
+
// cache a guard's login page (200 after the redirect was followed).
|
|
182
|
+
let storable = false;
|
|
183
|
+
// Send the same mask as a real client nav would so the server leaves out
|
|
184
|
+
// layers the client already holds. Falls back to sending everything when
|
|
185
|
+
// the route can't be matched (e.g. external/unknown URL).
|
|
186
|
+
const mask = buildMaskBits(path);
|
|
187
|
+
const run = (async (): Promise<any | null> => {
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch(dataUrl(path, mask ?? undefined));
|
|
190
|
+
storable = res.ok && !res.redirected && isJsonResponse(res);
|
|
191
|
+
return await readDataResponse(res);
|
|
192
|
+
} catch {
|
|
193
|
+
// Silently ignore — prefetch is best-effort
|
|
194
|
+
return null;
|
|
170
195
|
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
196
|
+
})();
|
|
197
|
+
pending.set(path, { run, mask });
|
|
198
|
+
const data = await run;
|
|
199
|
+
// Consumed by a click, or dropped by a navigation, while in flight.
|
|
200
|
+
if (pending.get(path)?.run !== run) return;
|
|
201
|
+
pending.delete(path);
|
|
202
|
+
// A guard's redirect is the session's answer now, not in 30s.
|
|
203
|
+
if (data === null || !storable || data.error || data.redirect) return;
|
|
204
|
+
if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
|
|
205
|
+
const oldest = prefetchCache.keys().next().value;
|
|
206
|
+
if (oldest !== undefined) prefetchCache.delete(oldest);
|
|
175
207
|
}
|
|
208
|
+
prefetchCache.set(path, { data, ts: Date.now(), mask });
|
|
176
209
|
}
|
|
177
210
|
|
|
178
211
|
function getLinkHref(anchor: HTMLAnchorElement): string | null {
|
package/src/core/cors.ts
CHANGED
|
@@ -29,7 +29,23 @@ function parseHeaderList(value: string): string[] {
|
|
|
29
29
|
* response with `Access-Control-Allow-Origin: X` to a different origin Y.
|
|
30
30
|
*/
|
|
31
31
|
export function applyCorsVary(headers: Headers): void {
|
|
32
|
-
headers
|
|
32
|
+
mergeVary(headers, "Origin");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Add `value` to the Vary header, keeping what is there: `set("Vary", "Origin")`
|
|
37
|
+
* dropped `Accept-Encoding`, so a CDN could hand a brotli body to a client that
|
|
38
|
+
* never asked for one.
|
|
39
|
+
*/
|
|
40
|
+
export function mergeVary(headers: Headers, value: string): void {
|
|
41
|
+
const current = headers.get("Vary");
|
|
42
|
+
if (!current) {
|
|
43
|
+
headers.set("Vary", value);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const parts = current.split(",").map((p) => p.trim().toLowerCase());
|
|
47
|
+
if (parts.includes("*") || parts.includes(value.toLowerCase())) return;
|
|
48
|
+
headers.set("Vary", `${current}, ${value}`);
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
/**
|
package/src/core/dedup.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// different users never share a loader result. Custom session cookie names
|
|
9
9
|
// must be listed in CACHE_KEYS. See docs/guides/request-deduplication.md.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
type Inflight = { promise: Promise<unknown>; start: number };
|
|
12
|
+
const inflight = new Map<string, Inflight>();
|
|
12
13
|
|
|
13
14
|
/** Build a stable dedup key from a URL: normalized path + sorted query. */
|
|
14
15
|
export function dedupKey(url: URL): string {
|
|
@@ -19,11 +20,54 @@ export function dedupKey(url: URL): string {
|
|
|
19
20
|
return search ? `${path}?${search}` : path;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Coalesce concurrent calls under `key`. `fn` runs at most once per inflight
|
|
25
|
+
* window. With `maxAgeMs` > 0 a call is joined only until it is that old:
|
|
26
|
+
* one that never settles (a hung loader with LOAD_TIMEOUT=0) would otherwise
|
|
27
|
+
* take every later request for the key down with it. A call that started
|
|
28
|
+
* before `replaceStartedBefore` is replaced rather than joined (the caller
|
|
29
|
+
* knows it is stuck); later calls join the new one.
|
|
30
|
+
*/
|
|
31
|
+
export function dedup<T>(
|
|
32
|
+
key: string,
|
|
33
|
+
fn: () => Promise<T>,
|
|
34
|
+
maxAgeMs = 0,
|
|
35
|
+
replaceStartedBefore = -Infinity,
|
|
36
|
+
): Promise<T> {
|
|
24
37
|
const existing = inflight.get(key);
|
|
25
|
-
if (existing
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
if (existing && existing.start >= replaceStartedBefore) {
|
|
39
|
+
if (maxAgeMs <= 0) return existing.promise as Promise<T>;
|
|
40
|
+
const left = maxAgeMs - (performance.now() - existing.start);
|
|
41
|
+
if (left > 0) {
|
|
42
|
+
// Joined until the call reaches maxAgeMs; then the first joiner
|
|
43
|
+
// replaces it and the rest join the new call. One timer per joiner:
|
|
44
|
+
// on Workers a timer dies with the request that set it.
|
|
45
|
+
const joined = existing.promise as Promise<T>;
|
|
46
|
+
return new Promise<T>((resolve, reject) => {
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
const stuck = inflight.get(key) === existing;
|
|
49
|
+
dedup(key, fn, maxAgeMs, stuck ? Infinity : -Infinity).then(resolve, reject);
|
|
50
|
+
}, left);
|
|
51
|
+
(timer as { unref?: () => void }).unref?.();
|
|
52
|
+
joined.then(
|
|
53
|
+
(v) => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
resolve(v);
|
|
56
|
+
},
|
|
57
|
+
(e) => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
reject(e);
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const entry: Inflight = { promise: null as unknown as Promise<T>, start: performance.now() };
|
|
66
|
+
const promise = fn().finally(() => {
|
|
67
|
+
// Never drops a newer call that replaced a stale one.
|
|
68
|
+
if (inflight.get(key) === entry) inflight.delete(key);
|
|
69
|
+
});
|
|
70
|
+
entry.promise = promise;
|
|
71
|
+
inflight.set(key, entry);
|
|
72
|
+
return promise;
|
|
29
73
|
}
|
package/src/core/env.ts
CHANGED
package/src/core/hooks.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Usage in src/hooks.server.ts:
|
|
4
4
|
//
|
|
5
5
|
// import { sequence } from "bosia";
|
|
6
|
-
// export const handle = sequence(authHandle,
|
|
6
|
+
// export const handle = sequence(authHandle, guardHandle);
|
|
7
7
|
|
|
8
8
|
// ─── Cookie Types ─────────────────────────────────────────
|
|
9
9
|
|
package/src/core/html.ts
CHANGED
|
@@ -674,6 +674,18 @@ export function compress(
|
|
|
674
674
|
return compressBytes(textEncoder.encode(body), contentType, req, status, extraHeaders);
|
|
675
675
|
}
|
|
676
676
|
|
|
677
|
+
/**
|
|
678
|
+
* The Vary value for a body that may be compressed: a loader's or handler's
|
|
679
|
+
* own `vary` (e.g. `Cookie`) used to replace `Accept-Encoding`, so a CDN could
|
|
680
|
+
* hand brotli to a client that never asked for it.
|
|
681
|
+
*/
|
|
682
|
+
export function varyWithEncoding(vary: string | undefined): string {
|
|
683
|
+
if (!vary) return "Accept-Encoding";
|
|
684
|
+
const parts = vary.split(",").map((p) => p.trim().toLowerCase());
|
|
685
|
+
if (parts.includes("*") || parts.includes("accept-encoding")) return vary;
|
|
686
|
+
return `${vary}, Accept-Encoding`;
|
|
687
|
+
}
|
|
688
|
+
|
|
677
689
|
/** `encoded`: the result of `encodeForRequest` when the caller already has it
|
|
678
690
|
* (a cache write reuses the same bytes); omitted, it is built here. */
|
|
679
691
|
export function compressBytes(
|
|
@@ -688,8 +700,8 @@ export function compressBytes(
|
|
|
688
700
|
// override them instead of getting comma-joined by Headers.
|
|
689
701
|
const headers: Record<string, string> = {
|
|
690
702
|
"content-type": contentType,
|
|
691
|
-
vary: "Accept-Encoding",
|
|
692
703
|
...extraHeaders,
|
|
704
|
+
vary: varyWithEncoding(extraHeaders?.vary),
|
|
693
705
|
};
|
|
694
706
|
if (encoded) {
|
|
695
707
|
return new Response(encoded.encoded, {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { BosiaPlugin } from "../types/plugin.ts";
|
|
2
|
+
|
|
3
|
+
export interface AccessLogEntry {
|
|
4
|
+
method: string;
|
|
5
|
+
/** Path without the query string. */
|
|
6
|
+
path: string;
|
|
7
|
+
/** The status the client got. */
|
|
8
|
+
status: number;
|
|
9
|
+
/** Time to build the response, in milliseconds (not to deliver its body). */
|
|
10
|
+
ms: number;
|
|
11
|
+
request: Request;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AccessLogOptions {
|
|
15
|
+
/** Return false to skip a line, e.g. `(e) => e.status >= 400 || e.ms > 200`. */
|
|
16
|
+
filter?: (entry: AccessLogEntry) => boolean;
|
|
17
|
+
/** One line per request, without the trailing newline. */
|
|
18
|
+
format?: (entry: AccessLogEntry) => string;
|
|
19
|
+
/** Where lines go. Defaults to console.log, one call per batch. May be async;
|
|
20
|
+
* a failed write loses that batch, never the request or the process. */
|
|
21
|
+
write?: (chunk: string) => void | Promise<unknown>;
|
|
22
|
+
/**
|
|
23
|
+
* Lines are collected and written together at most this often (ms). One
|
|
24
|
+
* write per request costs a syscall each: on a pipe (`docker logs`, systemd)
|
|
25
|
+
* that was ~11–14µs of CPU per request, batched ~3µs. 0 writes every line at
|
|
26
|
+
* once. Defaults to 100 on Bun, 0 on Workers (no timers after a response).
|
|
27
|
+
*/
|
|
28
|
+
flushMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Flush early once this much is waiting, so a burst never piles up here. */
|
|
32
|
+
const MAX_BUFFER_CHARS = 64 * 1024;
|
|
33
|
+
|
|
34
|
+
function defaultFormat(e: AccessLogEntry): string {
|
|
35
|
+
return `[${e.method}] ${e.path} ${e.status} (${e.ms.toFixed(1)}ms)`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One console.log per batch. On Bun it blocks until the pipe takes every byte;
|
|
40
|
+
* `process.stdout.write` queued on a slow pipe and lost the queue at exit, and
|
|
41
|
+
* `fs.writeSync(1)` wrote part of a batch (non-blocking stdout) and dropped the
|
|
42
|
+
* rest — ~870 of 3000 lines in a test, both ways. Like any console.log, a log
|
|
43
|
+
* reader that stops reading slows the server.
|
|
44
|
+
*/
|
|
45
|
+
function defaultWrite(chunk: string): void {
|
|
46
|
+
console.log(chunk.endsWith("\n") ? chunk.slice(0, -1) : chunk);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Path of a request URL without allocating a URL object. */
|
|
50
|
+
function pathOf(url: string): string {
|
|
51
|
+
const start = url.indexOf("/", url.indexOf("//") + 2);
|
|
52
|
+
if (start === -1) return "/";
|
|
53
|
+
const q = url.indexOf("?", start);
|
|
54
|
+
return q === -1 ? url.slice(start) : url.slice(start, q);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Logs one line per request: method, path, status, duration. Opt-in — the
|
|
59
|
+
* app templates no longer log every request from `hooks.server.ts`; a reverse
|
|
60
|
+
* proxy (nginx, Caddy) can keep the access log instead.
|
|
61
|
+
*
|
|
62
|
+
* Reads the final response (`onResponse`), so the status is what the client
|
|
63
|
+
* got whatever other plugins did, and every request is logged exactly once.
|
|
64
|
+
*/
|
|
65
|
+
export function accessLog(options: AccessLogOptions = {}): BosiaPlugin {
|
|
66
|
+
const format = options.format ?? defaultFormat;
|
|
67
|
+
const write = options.write ?? defaultWrite;
|
|
68
|
+
// Workers drops timers once the response is sent: a batch would never flush.
|
|
69
|
+
const flushMs = typeof Bun === "undefined" ? 0 : (options.flushMs ?? 100);
|
|
70
|
+
const filter = options.filter;
|
|
71
|
+
|
|
72
|
+
let buffer = "";
|
|
73
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
74
|
+
const flush = () => {
|
|
75
|
+
if (timer) {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
timer = null;
|
|
78
|
+
}
|
|
79
|
+
if (buffer === "") return;
|
|
80
|
+
const out = buffer;
|
|
81
|
+
buffer = "";
|
|
82
|
+
try {
|
|
83
|
+
const done = write(out);
|
|
84
|
+
if (done instanceof Promise) done.catch(() => {});
|
|
85
|
+
} catch {
|
|
86
|
+
// A broken sink loses this batch, not the request or the process.
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
let exitHooked = false;
|
|
90
|
+
|
|
91
|
+
const log = (entry: AccessLogEntry) => {
|
|
92
|
+
if (filter && !filter(entry)) return;
|
|
93
|
+
buffer += format(entry) + "\n";
|
|
94
|
+
if (flushMs <= 0 || buffer.length >= MAX_BUFFER_CHARS) flush();
|
|
95
|
+
else if (!timer) {
|
|
96
|
+
// Lines still waiting when the server drains and exits. Added on the
|
|
97
|
+
// first buffered line, so `bosia build` (which loads the config too)
|
|
98
|
+
// never registers one.
|
|
99
|
+
if (!exitHooked && typeof process !== "undefined" && typeof process.once === "function") {
|
|
100
|
+
exitHooked = true;
|
|
101
|
+
process.once("exit", flush);
|
|
102
|
+
}
|
|
103
|
+
timer = setTimeout(flush, flushMs);
|
|
104
|
+
(timer as { unref?: () => void }).unref?.();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
name: "access-log",
|
|
110
|
+
backend: {
|
|
111
|
+
before(app) {
|
|
112
|
+
// onResponse swallows a throw here (a broken filter/format/sink),
|
|
113
|
+
// so logging can never change or fail a response.
|
|
114
|
+
return app.onResponse(({ request, response, start }) => {
|
|
115
|
+
log({
|
|
116
|
+
method: request.method,
|
|
117
|
+
path: pathOf(request.url),
|
|
118
|
+
status: response.status,
|
|
119
|
+
ms: performance.now() - start,
|
|
120
|
+
request,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export default accessLog;
|