bosia 0.8.13 → 0.8.15
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/cli/start.ts +3 -1
- package/src/core/client/App.svelte +18 -3
- package/src/core/dev.ts +10 -19
- package/src/core/port.ts +16 -0
- package/src/core/prerender.ts +68 -66
- package/src/core/server.ts +37 -18
package/package.json
CHANGED
package/src/cli/start.ts
CHANGED
|
@@ -26,5 +26,7 @@ export async function runStart() {
|
|
|
26
26
|
// mid-drain. The terminal delivers SIGINT to the whole process group,
|
|
27
27
|
// so the child already gets the signal — no forwarding needed.
|
|
28
28
|
for (const sig of ["SIGINT", "SIGTERM"] as const) process.on(sig, () => {});
|
|
29
|
-
|
|
29
|
+
// Propagate the child's code — a refused boot must not report success to CI,
|
|
30
|
+
// Docker restart policies or orchestrators.
|
|
31
|
+
process.exit(await proc.exited);
|
|
30
32
|
}
|
|
@@ -77,6 +77,8 @@
|
|
|
77
77
|
let showLoading = $state(false);
|
|
78
78
|
let currentLayoutPaths: string[] = [];
|
|
79
79
|
let lastSettledPath = "";
|
|
80
|
+
let lastNavKey = ""; // pathname + search of the last processed nav
|
|
81
|
+
let lastTick = 0; // invalidationTick value last processed
|
|
80
82
|
// Skip bar on the very first effect run (initial hydration — data already present)
|
|
81
83
|
let firstNav = true;
|
|
82
84
|
let navDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -107,10 +109,12 @@
|
|
|
107
109
|
|
|
108
110
|
// Subscribe to `invalidationTick` so `invalidate()` can wake the effect
|
|
109
111
|
// without a URL change.
|
|
110
|
-
|
|
112
|
+
const currentTick = appState.invalidationTick; // still tracks reactively
|
|
111
113
|
|
|
112
114
|
const path = router.currentRoute;
|
|
113
|
-
const
|
|
115
|
+
const url = new URL(path, window.location.origin);
|
|
116
|
+
const pathname = url.pathname;
|
|
117
|
+
const navKey = pathname + url.search;
|
|
114
118
|
const match = findMatch(clientRoutes, pathname);
|
|
115
119
|
if (!match) return;
|
|
116
120
|
|
|
@@ -121,11 +125,22 @@
|
|
|
121
125
|
if (isFirst) {
|
|
122
126
|
currentLayoutPaths = (match.route as any).layoutPaths ?? [];
|
|
123
127
|
lastSettledPath = pathname;
|
|
128
|
+
lastNavKey = navKey;
|
|
129
|
+
lastTick = currentTick;
|
|
124
130
|
// Restore-after-reload: router.init() staged this entry's snapshot.
|
|
125
131
|
settleSnapshot();
|
|
126
132
|
return; // Initial hydration — data already in SSR props, no fetch needed
|
|
127
133
|
}
|
|
128
134
|
|
|
135
|
+
// Hash-only change (same pathname+search, no invalidation): scroll, don't
|
|
136
|
+
// refetch. A static host can't answer the parent-snapshot POST a refetch may
|
|
137
|
+
// issue, which would otherwise render the error page.
|
|
138
|
+
if (navKey === lastNavKey && currentTick === lastTick) {
|
|
139
|
+
settleScroll();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
lastTick = currentTick;
|
|
143
|
+
|
|
129
144
|
appState.form = null;
|
|
130
145
|
if (navDoneTimer) {
|
|
131
146
|
clearTimeout(navDoneTimer);
|
|
@@ -160,7 +175,6 @@
|
|
|
160
175
|
// For each layout depth + the page, compare the cached entry (if any)
|
|
161
176
|
// against the live URL/params and the dirty set. If everything is
|
|
162
177
|
// cacheable, skip the fetch entirely.
|
|
163
|
-
const url = new URL(path, window.location.origin);
|
|
164
178
|
const ctx = liveContext(pathname, match.params, url);
|
|
165
179
|
const layoutIds = (match.route as any).layoutIds as (string | null)[];
|
|
166
180
|
const pageId = (match.route as any).pageId as string | null;
|
|
@@ -243,6 +257,7 @@
|
|
|
243
257
|
LoadingComponent = null;
|
|
244
258
|
currentLayoutPaths = destLayoutPaths;
|
|
245
259
|
lastSettledPath = pathname;
|
|
260
|
+
lastNavKey = navKey;
|
|
246
261
|
navDoneTimer = setTimeout(() => {
|
|
247
262
|
navDone = false;
|
|
248
263
|
}, 400);
|
package/src/core/dev.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readdirSync, statSync, watch, type Dirent } from "fs";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { loadEnv, resetDeclaredKeys } from "./env.ts";
|
|
5
5
|
import { BOSIA_NODE_PATH } from "./paths.ts";
|
|
6
|
+
import { pidsOnPort } from "./port.ts";
|
|
6
7
|
import { affectsRouteManifest, shouldIgnoreForRebuild } from "./devWatch.ts";
|
|
7
8
|
|
|
8
9
|
// Dev always writes to .bosia/dev so a parallel `bun run build` (writing to ./dist)
|
|
@@ -159,26 +160,16 @@ const APP_PORT = DEV_PORT + 1; // internal, hidden from user
|
|
|
159
160
|
// disk → "ENOENT reading +page-<hash>.js" 500s. Reap whatever is listening on
|
|
160
161
|
// APP_PORT before we spawn, so a stale child can never shadow the fresh build.
|
|
161
162
|
async function reapStaleAppServer(port: number) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
.map(Number)
|
|
171
|
-
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
|
|
172
|
-
for (const pid of pids) {
|
|
173
|
-
try {
|
|
174
|
-
process.kill(pid, "SIGKILL");
|
|
175
|
-
console.log(`🧹 Reaped stale app server holding port ${port} (pid ${pid})`);
|
|
176
|
-
} catch {}
|
|
177
|
-
}
|
|
178
|
-
if (pids.length) await Bun.sleep(200); // let the port free before we bind
|
|
179
|
-
} catch {
|
|
180
|
-
// lsof unavailable or errored — fail open; a real conflict surfaces on bind.
|
|
163
|
+
// pidsOnPort fails open when lsof is unavailable — a real conflict then
|
|
164
|
+
// surfaces on bind instead.
|
|
165
|
+
const pids = (await pidsOnPort(port)).filter((pid) => pid !== process.pid);
|
|
166
|
+
for (const pid of pids) {
|
|
167
|
+
try {
|
|
168
|
+
process.kill(pid, "SIGKILL");
|
|
169
|
+
console.log(`🧹 Reaped stale app server holding port ${port} (pid ${pid})`);
|
|
170
|
+
} catch {}
|
|
181
171
|
}
|
|
172
|
+
if (pids.length) await Bun.sleep(200); // let the port free before we bind
|
|
182
173
|
}
|
|
183
174
|
|
|
184
175
|
async function startAppServer() {
|
package/src/core/port.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** PIDs listening on `port`. Fails open to [] — lsof may be absent. */
|
|
2
|
+
export async function pidsOnPort(port: number): Promise<number[]> {
|
|
3
|
+
try {
|
|
4
|
+
const proc = Bun.spawn(["lsof", "-ti", `tcp:${port}`, "-sTCP:LISTEN"], {
|
|
5
|
+
stdout: "pipe",
|
|
6
|
+
stderr: "ignore",
|
|
7
|
+
});
|
|
8
|
+
const out = await new Response(proc.stdout).text();
|
|
9
|
+
await proc.exited;
|
|
10
|
+
return [...new Set(out.split("\n").map((s) => s.trim()))]
|
|
11
|
+
.map(Number)
|
|
12
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
13
|
+
} catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/core/prerender.ts
CHANGED
|
@@ -27,6 +27,7 @@ export function getEphemeralPort(): Promise<number> {
|
|
|
27
27
|
const CORE_DIR = import.meta.dir;
|
|
28
28
|
|
|
29
29
|
const PRERENDER_TIMEOUT = Number(process.env.PRERENDER_TIMEOUT) || 5_000; // 5s default
|
|
30
|
+
const PRERENDER_CONCURRENCY = Number(process.env.PRERENDER_CONCURRENCY) || 6;
|
|
30
31
|
|
|
31
32
|
// ─── Prerendering ─────────────────────────────────────────
|
|
32
33
|
|
|
@@ -97,79 +98,62 @@ export function prerenderApiOutPath(routePath: string): string {
|
|
|
97
98
|
return `${OUT_DIR}/prerendered${routePath.replace(/\/$/, "")}.json`;
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/** Dynamic route → import module, call entries(), expand to concrete targets. */
|
|
102
|
+
async function expandDynamicRoute(
|
|
103
|
+
pattern: string,
|
|
104
|
+
filePath: string,
|
|
105
|
+
kind: "page" | "api",
|
|
106
|
+
ts: TrailingSlash,
|
|
107
|
+
): Promise<PrerenderTarget[]> {
|
|
108
|
+
try {
|
|
109
|
+
const mod = await import(join(process.cwd(), filePath));
|
|
110
|
+
if (typeof mod.entries !== "function") {
|
|
111
|
+
console.warn(` ⚠️ ${pattern} has prerender=true but no entries() export — skipped`);
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
const entryList: Record<string, string>[] = await mod.entries();
|
|
115
|
+
return entryList.map((entry) => ({
|
|
116
|
+
path: substituteParams(pattern, entry),
|
|
117
|
+
kind,
|
|
118
|
+
trailingSlash: ts,
|
|
119
|
+
}));
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error(` ❌ Failed to resolve entries() for ${pattern}:`, err);
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
100
126
|
async function detectPrerenderRoutes(manifest: RouteManifest): Promise<PrerenderTarget[]> {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
if (!route.pageServer) continue;
|
|
127
|
+
const pageTasks = manifest.pages.map(async (route): Promise<PrerenderTarget[]> => {
|
|
128
|
+
if (!route.pageServer) return [];
|
|
104
129
|
const filePath = join("src", "routes", route.pageServer);
|
|
105
130
|
const content = await Bun.file(filePath).text();
|
|
106
|
-
if (!/export\s+const\s+prerender\s*=\s*true/.test(content))
|
|
131
|
+
if (!/export\s+const\s+prerender\s*=\s*true/.test(content)) return [];
|
|
107
132
|
if (/export\s+const\s+ssr\s*=\s*false/.test(content)) {
|
|
108
133
|
console.warn(
|
|
109
134
|
` ⚠️ ${route.pattern} has prerender=true && ssr=false — contradictory, skipped`,
|
|
110
135
|
);
|
|
111
|
-
|
|
136
|
+
return [];
|
|
112
137
|
}
|
|
113
|
-
|
|
114
138
|
const ts = route.trailingSlash;
|
|
139
|
+
if (route.pattern.includes("[")) return expandDynamicRoute(route.pattern, filePath, "page", ts);
|
|
140
|
+
return [{ path: route.pattern, kind: "page", trailingSlash: ts }];
|
|
141
|
+
});
|
|
115
142
|
|
|
116
|
-
|
|
117
|
-
// Dynamic route — import module and call entries() to get param values
|
|
118
|
-
try {
|
|
119
|
-
const mod = await import(join(process.cwd(), filePath));
|
|
120
|
-
if (typeof mod.entries !== "function") {
|
|
121
|
-
console.warn(
|
|
122
|
-
` ⚠️ ${route.pattern} has prerender=true but no entries() export — skipped`,
|
|
123
|
-
);
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
const entryList: Record<string, string>[] = await mod.entries();
|
|
127
|
-
for (const entry of entryList) {
|
|
128
|
-
targets.push({
|
|
129
|
-
path: substituteParams(route.pattern, entry),
|
|
130
|
-
kind: "page",
|
|
131
|
-
trailingSlash: ts,
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
} catch (err) {
|
|
135
|
-
console.error(` ❌ Failed to resolve entries() for ${route.pattern}:`, err);
|
|
136
|
-
}
|
|
137
|
-
} else {
|
|
138
|
-
targets.push({ path: route.pattern, kind: "page", trailingSlash: ts });
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
for (const route of manifest.apis) {
|
|
143
|
+
const apiTasks = manifest.apis.map(async (route): Promise<PrerenderTarget[]> => {
|
|
143
144
|
const filePath = join("src", "routes", route.server);
|
|
144
145
|
const content = await Bun.file(filePath).text();
|
|
145
|
-
if (!/export\s+const\s+prerender\s*=\s*true/.test(content))
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (typeof mod.entries !== "function") {
|
|
151
|
-
console.warn(
|
|
152
|
-
` ⚠️ ${route.pattern} has prerender=true but no entries() export — skipped`,
|
|
153
|
-
);
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
const entryList: Record<string, string>[] = await mod.entries();
|
|
157
|
-
for (const entry of entryList) {
|
|
158
|
-
targets.push({
|
|
159
|
-
path: substituteParams(route.pattern, entry),
|
|
160
|
-
kind: "api",
|
|
161
|
-
trailingSlash: "never",
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
} catch (err) {
|
|
165
|
-
console.error(` ❌ Failed to resolve entries() for ${route.pattern}:`, err);
|
|
166
|
-
}
|
|
167
|
-
} else {
|
|
168
|
-
targets.push({ path: route.pattern, kind: "api", trailingSlash: "never" });
|
|
169
|
-
}
|
|
170
|
-
}
|
|
146
|
+
if (!/export\s+const\s+prerender\s*=\s*true/.test(content)) return [];
|
|
147
|
+
if (route.pattern.includes("["))
|
|
148
|
+
return expandDynamicRoute(route.pattern, filePath, "api", "never");
|
|
149
|
+
return [{ path: route.pattern, kind: "api", trailingSlash: "never" }];
|
|
150
|
+
});
|
|
171
151
|
|
|
172
|
-
|
|
152
|
+
// Unbounded Promise.all over all routes; each task holds only small text reads
|
|
153
|
+
// and route metadata, so RAM stays trivial. Chunk it if route counts ever hit
|
|
154
|
+
// tens of thousands (open file descriptors would be the first limit).
|
|
155
|
+
const results = await Promise.all([...pageTasks, ...apiTasks]);
|
|
156
|
+
return results.flat();
|
|
173
157
|
}
|
|
174
158
|
|
|
175
159
|
export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<void> {
|
|
@@ -198,11 +182,12 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
198
182
|
for (const sig of signals) process.once(sig, onSignal);
|
|
199
183
|
|
|
200
184
|
try {
|
|
201
|
-
// Poll /_health until ready (max 10s)
|
|
185
|
+
// Poll /_health until ready (max 10s). Check first, sleep only on failure —
|
|
186
|
+
// avoids a guaranteed floor when the server is already up.
|
|
202
187
|
const base = `http://localhost:${port}`;
|
|
203
188
|
let ready = false;
|
|
204
|
-
|
|
205
|
-
|
|
189
|
+
const deadline = Date.now() + 10_000;
|
|
190
|
+
while (Date.now() < deadline) {
|
|
206
191
|
try {
|
|
207
192
|
const res = await fetch(`${base}/_health`);
|
|
208
193
|
if (res.ok) {
|
|
@@ -212,6 +197,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
212
197
|
} catch {
|
|
213
198
|
/* not ready yet */
|
|
214
199
|
}
|
|
200
|
+
await Bun.sleep(50);
|
|
215
201
|
}
|
|
216
202
|
|
|
217
203
|
if (!ready) {
|
|
@@ -221,7 +207,11 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
221
207
|
|
|
222
208
|
mkdirSync(`${OUT_DIR}/prerendered`, { recursive: true });
|
|
223
209
|
|
|
224
|
-
|
|
210
|
+
const prerenderOne = async ({
|
|
211
|
+
path: routePath,
|
|
212
|
+
kind,
|
|
213
|
+
trailingSlash: ts,
|
|
214
|
+
}: PrerenderTarget): Promise<void> => {
|
|
225
215
|
try {
|
|
226
216
|
if (kind === "api") {
|
|
227
217
|
// APIs: fetch the bare route URL, write body to `<path>.json`.
|
|
@@ -233,7 +223,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
233
223
|
mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
234
224
|
writeFileSync(outPath, body);
|
|
235
225
|
console.log(` ✅ ${routePath} → ${outPath}`);
|
|
236
|
-
|
|
226
|
+
return;
|
|
237
227
|
}
|
|
238
228
|
|
|
239
229
|
// Hit the canonical URL so the server doesn't 308 us mid-prerender
|
|
@@ -278,7 +268,19 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
278
268
|
console.error(` ❌ Failed to prerender ${routePath}:`, err);
|
|
279
269
|
}
|
|
280
270
|
}
|
|
281
|
-
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Bounded worker pool: N workers pull from a shared index until targets drain.
|
|
274
|
+
let next = 0;
|
|
275
|
+
const worker = async () => {
|
|
276
|
+
while (next < targets.length) {
|
|
277
|
+
const target = targets[next++];
|
|
278
|
+
if (target) await prerenderOne(target);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
await Promise.all(
|
|
282
|
+
Array.from({ length: Math.min(PRERENDER_CONCURRENCY, targets.length) }, worker),
|
|
283
|
+
);
|
|
282
284
|
|
|
283
285
|
console.log("✅ Prerendering complete");
|
|
284
286
|
} finally {
|
package/src/core/server.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } f
|
|
|
24
24
|
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
25
25
|
import { dev500WithPlugins } from "./dev-500.ts";
|
|
26
26
|
import { OUT_DIR } from "./paths.ts";
|
|
27
|
+
import { pidsOnPort } from "./port.ts";
|
|
27
28
|
import { buildPrerenderManifest, buildStaticManifest, lookupStatic } from "./staticManifest.ts";
|
|
28
29
|
import { dedup } from "./dedup.ts";
|
|
29
30
|
import {
|
|
@@ -1092,7 +1093,14 @@ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : isDev ? 9001 :
|
|
|
1092
1093
|
// Elysia's chained generics drift when plugins add routes — track the app as a
|
|
1093
1094
|
// loose `Elysia` so plugin-extended types stay assignable.
|
|
1094
1095
|
let app: Elysia = new Elysia({
|
|
1095
|
-
serve: {
|
|
1096
|
+
serve: {
|
|
1097
|
+
maxRequestBodySize: BODY_SIZE_LIMIT,
|
|
1098
|
+
idleTimeout: IDLE_TIMEOUT,
|
|
1099
|
+
// Elysia's Bun adapter defaults reusePort:true — SO_REUSEPORT lets a second
|
|
1100
|
+
// server silently join the port and the kernel splits traffic between two
|
|
1101
|
+
// different builds. Opt in only for deliberate N-worker clustering.
|
|
1102
|
+
reusePort: process.env.BOSIA_REUSE_PORT === "1",
|
|
1103
|
+
},
|
|
1096
1104
|
}) as unknown as Elysia;
|
|
1097
1105
|
|
|
1098
1106
|
// Plugins.backend.before — runs before framework middleware/routes.
|
|
@@ -1160,23 +1168,34 @@ for (const plugin of plugins) {
|
|
|
1160
1168
|
}
|
|
1161
1169
|
}
|
|
1162
1170
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
}
|
|
1171
|
+
try {
|
|
1172
|
+
app.listen(PORT, () => {
|
|
1173
|
+
// In dev mode the proxy owns the user-facing port — don't print the internal port
|
|
1174
|
+
if (!isDev) console.log(`⬡ Bosia server running at http://localhost:${PORT}`);
|
|
1175
|
+
// Last line of startup on purpose — the cache identity contract is the one
|
|
1176
|
+
// config mistake that leaks one user's page to another, so it stays visible.
|
|
1177
|
+
if (CACHE_ENABLED) {
|
|
1178
|
+
console.log(
|
|
1179
|
+
`\n🔑 Response cache tells users apart ONLY by these cookies/headers: [${CACHE_KEYS.join(", ")}]\n` +
|
|
1180
|
+
` Using a different session cookie or auth header? Add its name to CACHE_KEYS,\n` +
|
|
1181
|
+
` or one user's personalised page can be served to another. Routes personalised\n` +
|
|
1182
|
+
` by anything else should set \`export const cache = false\`.\n` +
|
|
1183
|
+
` Note: the runtime auto-warns only on uncovered *cookie* reads — it CANNOT\n` +
|
|
1184
|
+
` detect header-based personalisation, so custom auth headers (X-Api-Key,\n` +
|
|
1185
|
+
` X-Auth-Token, …) must be added to CACHE_KEYS by hand.\n`,
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
// Bun.serve runs inline inside .listen(), so a failed bind lands here.
|
|
1191
|
+
if ((err as { code?: string })?.code !== "EADDRINUSE") throw err;
|
|
1192
|
+
const [pid] = await pidsOnPort(PORT);
|
|
1193
|
+
console.error(
|
|
1194
|
+
`\n❌ Port ${PORT} is already serving${pid ? ` (pid ${pid})` : ""}.\n` +
|
|
1195
|
+
` Stop it or set PORT to a free port.\n`,
|
|
1196
|
+
);
|
|
1197
|
+
process.exit(1);
|
|
1198
|
+
}
|
|
1180
1199
|
|
|
1181
1200
|
async function shutdown() {
|
|
1182
1201
|
if (shuttingDown) {
|