bosia 0.2.3 → 0.3.1
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 +39 -39
- package/package.json +56 -54
- package/src/ambient.d.ts +31 -0
- package/src/cli/add.ts +120 -114
- package/src/cli/build.ts +10 -10
- package/src/cli/create.ts +142 -137
- package/src/cli/dev.ts +7 -9
- package/src/cli/feat.ts +266 -258
- package/src/cli/index.ts +51 -42
- package/src/cli/registry.ts +136 -115
- package/src/cli/start.ts +17 -17
- package/src/cli/test.ts +25 -0
- package/src/core/build.ts +72 -56
- package/src/core/client/App.svelte +177 -156
- package/src/core/client/appState.svelte.ts +33 -31
- package/src/core/client/enhance.ts +83 -78
- package/src/core/client/hydrate.ts +95 -81
- package/src/core/client/prefetch.ts +101 -94
- package/src/core/client/router.svelte.ts +64 -51
- package/src/core/cookies.ts +70 -66
- package/src/core/cors.ts +44 -35
- package/src/core/csrf.ts +38 -38
- package/src/core/dedup.ts +17 -17
- package/src/core/dev.ts +196 -168
- package/src/core/env.ts +160 -148
- package/src/core/envCodegen.ts +73 -73
- package/src/core/errors.ts +48 -49
- package/src/core/hooks.ts +50 -50
- package/src/core/html.ts +184 -145
- package/src/core/matcher.ts +130 -121
- package/src/core/paths.ts +8 -10
- package/src/core/plugin.ts +113 -107
- package/src/core/prerender.ts +191 -122
- package/src/core/renderer.ts +359 -286
- package/src/core/routeFile.ts +140 -127
- package/src/core/routeTypes.ts +144 -83
- package/src/core/scanner.ts +125 -95
- package/src/core/server.ts +538 -424
- package/src/core/types.ts +25 -20
- package/src/lib/index.ts +8 -8
- package/src/lib/utils.ts +44 -30
- package/templates/default/.prettierignore +5 -0
- package/templates/default/.prettierrc.json +9 -0
- package/templates/default/README.md +5 -5
- package/templates/default/package.json +22 -18
- package/templates/default/src/app.css +80 -80
- package/templates/default/src/app.d.ts +3 -3
- package/templates/default/src/routes/+error.svelte +7 -10
- package/templates/default/src/routes/+layout.svelte +2 -2
- package/templates/default/src/routes/+page.svelte +30 -32
- package/templates/default/src/routes/about/+page.svelte +3 -3
- package/templates/default/tsconfig.json +20 -20
- package/templates/demo/.prettierignore +5 -0
- package/templates/demo/.prettierrc.json +9 -0
- package/templates/demo/README.md +9 -9
- package/templates/demo/package.json +22 -17
- package/templates/demo/src/app.css +80 -80
- package/templates/demo/src/app.d.ts +3 -3
- package/templates/demo/src/hooks.server.ts +9 -9
- package/templates/demo/src/routes/(public)/+layout.svelte +45 -23
- package/templates/demo/src/routes/(public)/+page.svelte +96 -67
- package/templates/demo/src/routes/(public)/about/+page.svelte +13 -25
- package/templates/demo/src/routes/(public)/all/[...catchall]/+page.svelte +24 -28
- package/templates/demo/src/routes/(public)/blog/+page.svelte +55 -46
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +36 -38
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.svelte +60 -42
- package/templates/demo/src/routes/+error.svelte +10 -7
- package/templates/demo/src/routes/+layout.server.ts +4 -4
- package/templates/demo/src/routes/+layout.svelte +2 -2
- package/templates/demo/src/routes/actions-test/+page.server.ts +16 -16
- package/templates/demo/src/routes/actions-test/+page.svelte +49 -49
- package/templates/demo/src/routes/api/hello/+server.ts +25 -25
- package/templates/demo/tsconfig.json +20 -20
- package/templates/todo/.prettierignore +5 -0
- package/templates/todo/.prettierrc.json +9 -0
- package/templates/todo/README.md +9 -9
- package/templates/todo/package.json +22 -17
- package/templates/todo/src/app.css +80 -80
- package/templates/todo/src/app.d.ts +7 -7
- package/templates/todo/src/hooks.server.ts +9 -9
- package/templates/todo/src/routes/+error.svelte +10 -7
- package/templates/todo/src/routes/+layout.server.ts +4 -4
- package/templates/todo/src/routes/+layout.svelte +2 -2
- package/templates/todo/src/routes/+page.svelte +44 -44
- package/templates/todo/template.json +1 -1
- package/templates/todo/tsconfig.json +20 -20
package/src/core/server.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Elysia } from "elysia";
|
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
4
|
import { join, resolve as resolvePath } from "path";
|
|
5
5
|
|
|
6
|
-
import { findMatch, compileRoutes } from "./matcher.ts";
|
|
6
|
+
import { findMatch, compileRoutes, canonicalPathname } from "./matcher.ts";
|
|
7
7
|
import { apiRoutes, serverRoutes } from "bosia:routes";
|
|
8
8
|
|
|
9
9
|
// Pre-compile route patterns into RegExp at startup (shared by renderer.ts via module reference)
|
|
@@ -17,7 +17,13 @@ import type { CsrfConfig } from "./csrf.ts";
|
|
|
17
17
|
import { getCorsHeaders, handlePreflight } from "./cors.ts";
|
|
18
18
|
import type { CorsConfig } from "./cors.ts";
|
|
19
19
|
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
loadRouteData,
|
|
22
|
+
loadMetadata,
|
|
23
|
+
renderSSRStream,
|
|
24
|
+
renderErrorPage,
|
|
25
|
+
renderPageWithFormData,
|
|
26
|
+
} from "./renderer.ts";
|
|
21
27
|
import { getServerTime } from "../lib/utils.ts";
|
|
22
28
|
import { dedup, dedupKey } from "./dedup.ts";
|
|
23
29
|
|
|
@@ -29,21 +35,26 @@ let userHandle: Handle | null = null;
|
|
|
29
35
|
|
|
30
36
|
const hooksPath = join(process.cwd(), "src", "hooks.server.ts");
|
|
31
37
|
if (existsSync(hooksPath)) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
try {
|
|
39
|
+
const mod = await import(hooksPath);
|
|
40
|
+
if (typeof mod.handle === "function") {
|
|
41
|
+
userHandle = mod.handle as Handle;
|
|
42
|
+
console.log("🪝 Loaded hooks.server.ts");
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.warn("⚠️ Failed to load hooks.server.ts:", err);
|
|
46
|
+
}
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
// ─── Env Helpers ─────────────────────────────────────────
|
|
44
50
|
|
|
45
51
|
function splitCsvEnv(key: string): string[] | undefined {
|
|
46
|
-
|
|
52
|
+
return (
|
|
53
|
+
process.env[key]
|
|
54
|
+
?.split(",")
|
|
55
|
+
.map((s) => s.trim())
|
|
56
|
+
.filter(Boolean) || undefined
|
|
57
|
+
);
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
// ─── CSRF Config ─────────────────────────────────────────
|
|
@@ -51,14 +62,14 @@ function splitCsvEnv(key: string): string[] | undefined {
|
|
|
51
62
|
const _csrfAllowedOrigins = splitCsvEnv("CSRF_ALLOWED_ORIGINS");
|
|
52
63
|
|
|
53
64
|
const CSRF_CONFIG: CsrfConfig = {
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
checkOrigin: true,
|
|
66
|
+
allowedOrigins: _csrfAllowedOrigins,
|
|
56
67
|
};
|
|
57
68
|
|
|
58
69
|
if (_csrfAllowedOrigins?.length) {
|
|
59
|
-
|
|
70
|
+
console.log(`🛡️ CSRF allowed origins: ${_csrfAllowedOrigins.join(", ")}`);
|
|
60
71
|
} else {
|
|
61
|
-
|
|
72
|
+
console.log("🛡️ CSRF: same-origin only");
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
// ─── CORS Config ──────────────────────────────────────────
|
|
@@ -66,374 +77,475 @@ if (_csrfAllowedOrigins?.length) {
|
|
|
66
77
|
const _corsAllowedOrigins = splitCsvEnv("CORS_ALLOWED_ORIGINS");
|
|
67
78
|
|
|
68
79
|
const CORS_CONFIG: CorsConfig | null = _corsAllowedOrigins?.length
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
? {
|
|
81
|
+
allowedOrigins: _corsAllowedOrigins,
|
|
82
|
+
allowedMethods: splitCsvEnv("CORS_ALLOWED_METHODS"),
|
|
83
|
+
allowedHeaders: splitCsvEnv("CORS_ALLOWED_HEADERS"),
|
|
84
|
+
exposedHeaders: splitCsvEnv("CORS_EXPOSED_HEADERS"),
|
|
85
|
+
credentials: process.env.CORS_CREDENTIALS === "true" || undefined,
|
|
86
|
+
maxAge: parseCorsMaxAge(process.env.CORS_MAX_AGE),
|
|
87
|
+
}
|
|
88
|
+
: null;
|
|
78
89
|
|
|
79
90
|
if (_corsAllowedOrigins?.length) {
|
|
80
|
-
|
|
91
|
+
console.log(`🌐 CORS allowed origins: ${_corsAllowedOrigins.join(", ")}`);
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
// ─── Core Request Resolver ────────────────────────────────
|
|
84
95
|
// This is the inner handler that hooks wrap around.
|
|
85
96
|
|
|
86
97
|
function isValidRoutePath(path: string, origin: string): boolean {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
try {
|
|
99
|
+
return new URL(path, origin).origin === origin;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
92
103
|
}
|
|
93
104
|
|
|
94
105
|
/** Resolve a file path and verify it stays within the allowed base directory. Returns null if traversal detected. */
|
|
95
106
|
function safePath(base: string, untrusted: string): string | null {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
const root = resolvePath(base);
|
|
108
|
+
const full = resolvePath(join(base, untrusted));
|
|
109
|
+
return full.startsWith(root + "/") || full === root ? full : null;
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
/** Extract action name from URL searchParams — `?/login` → "login", no slash key → "default". */
|
|
102
113
|
function parseActionName(url: URL): string {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
for (const key of url.searchParams.keys()) {
|
|
115
|
+
if (key.startsWith("/")) return key.slice(1) || "default";
|
|
116
|
+
}
|
|
117
|
+
return "default";
|
|
107
118
|
}
|
|
108
119
|
|
|
109
120
|
async function resolve(event: RequestEvent): Promise<Response> {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
121
|
+
const { request, url, locals, cookies } = event;
|
|
122
|
+
const path = url.pathname;
|
|
123
|
+
const method = request.method.toUpperCase();
|
|
124
|
+
|
|
125
|
+
// Health check endpoint — for load balancers and orchestrators
|
|
126
|
+
if (path === "/_health") {
|
|
127
|
+
if (shuttingDown) {
|
|
128
|
+
return Response.json({ status: "shutting_down" }, { status: 503 });
|
|
129
|
+
}
|
|
130
|
+
const { timestamp, timezone } = getServerTime();
|
|
131
|
+
return Response.json({ status: "ok", timestamp, timezone });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Data endpoint — returns server loader data as JSON for client-side navigation
|
|
135
|
+
if (path.startsWith("/__bosia/data/")) {
|
|
136
|
+
const routePathStr =
|
|
137
|
+
path
|
|
138
|
+
.slice("/__bosia/data".length)
|
|
139
|
+
.replace(/\.json$/, "")
|
|
140
|
+
.replace(/^\/index$/, "/") || "/";
|
|
141
|
+
|
|
142
|
+
if (!isValidRoutePath(routePathStr, url.origin)) {
|
|
143
|
+
return Response.json({ error: "Invalid path", status: 400 }, { status: 400 });
|
|
144
|
+
}
|
|
145
|
+
const routeUrl = new URL(routePathStr, url.origin);
|
|
146
|
+
for (const [key, val] of url.searchParams.entries()) {
|
|
147
|
+
routeUrl.searchParams.append(key, val);
|
|
148
|
+
}
|
|
149
|
+
// Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
|
|
150
|
+
event.url = routeUrl;
|
|
151
|
+
const dedupKeyStr = dedupKey(routeUrl, request);
|
|
152
|
+
try {
|
|
153
|
+
const result = await dedup(dedupKeyStr, async () => {
|
|
154
|
+
const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
|
|
155
|
+
const data = await loadRouteData(routeUrl, locals, request, cookies);
|
|
156
|
+
|
|
157
|
+
let metadata = null;
|
|
158
|
+
if (pageMatch) {
|
|
159
|
+
try {
|
|
160
|
+
const meta = await loadMetadata(
|
|
161
|
+
pageMatch.route,
|
|
162
|
+
pageMatch.params,
|
|
163
|
+
routeUrl,
|
|
164
|
+
locals,
|
|
165
|
+
cookies,
|
|
166
|
+
request,
|
|
167
|
+
);
|
|
168
|
+
if (meta) metadata = { title: meta.title, description: meta.description };
|
|
169
|
+
} catch {
|
|
170
|
+
/* non-fatal */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const cookiesWereAccessed = (cookies as CookieJar).accessed || result.cookiesAccessed;
|
|
178
|
+
const cc = cookiesWereAccessed
|
|
179
|
+
? "private, no-cache"
|
|
180
|
+
: "public, max-age=0, must-revalidate";
|
|
181
|
+
|
|
182
|
+
if (!result.data) {
|
|
183
|
+
return compress(
|
|
184
|
+
JSON.stringify({ pageData: {}, layoutData: [] }),
|
|
185
|
+
"application/json",
|
|
186
|
+
request,
|
|
187
|
+
200,
|
|
188
|
+
{ "Cache-Control": cc },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return compress(
|
|
192
|
+
JSON.stringify({ ...result.data, metadata: result.metadata }),
|
|
193
|
+
"application/json",
|
|
194
|
+
request,
|
|
195
|
+
200,
|
|
196
|
+
{ "Cache-Control": cc },
|
|
197
|
+
);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (err instanceof Redirect) {
|
|
200
|
+
return compress(
|
|
201
|
+
JSON.stringify({ redirect: err.location, status: err.status }),
|
|
202
|
+
"application/json",
|
|
203
|
+
request,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (err instanceof HttpError) {
|
|
207
|
+
return compress(
|
|
208
|
+
JSON.stringify({ error: err.message, status: err.status }),
|
|
209
|
+
"application/json",
|
|
210
|
+
request,
|
|
211
|
+
err.status,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (isDev) console.error("Data endpoint error:", err);
|
|
215
|
+
else console.error("Data endpoint error:", (err as Error).message ?? err);
|
|
216
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Static files
|
|
221
|
+
if (isStaticPath(path)) {
|
|
222
|
+
// dist/client: serve with cache headers based on whether filename is hashed
|
|
223
|
+
if (path.startsWith("/dist/client/")) {
|
|
224
|
+
const resolved = safePath(
|
|
225
|
+
"./dist/client",
|
|
226
|
+
path.split("?")[0].slice("/dist/client".length),
|
|
227
|
+
);
|
|
228
|
+
if (resolved) {
|
|
229
|
+
const file = Bun.file(resolved);
|
|
230
|
+
if (await file.exists()) {
|
|
231
|
+
const filename = path.split("/").pop() ?? "";
|
|
232
|
+
const isHashed = /\-[a-z0-9]{8,}\.[a-z]+$/.test(filename);
|
|
233
|
+
const cacheControl =
|
|
234
|
+
!isDev && isHashed ? "public, max-age=31536000, immutable" : "no-cache";
|
|
235
|
+
return new Response(file, { headers: { "Cache-Control": cacheControl } });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return new Response("Not Found", { status: 404 });
|
|
239
|
+
}
|
|
240
|
+
const pubPath = safePath("./public", path);
|
|
241
|
+
if (pubPath) {
|
|
242
|
+
const pub = Bun.file(pubPath);
|
|
243
|
+
if (await pub.exists()) return new Response(pub);
|
|
244
|
+
}
|
|
245
|
+
const distPath = safePath("./dist", path);
|
|
246
|
+
if (distPath) {
|
|
247
|
+
const dist = Bun.file(distPath);
|
|
248
|
+
if (await dist.exists()) return new Response(dist);
|
|
249
|
+
}
|
|
250
|
+
const staticPath = safePath("./dist/static", path);
|
|
251
|
+
if (staticPath) {
|
|
252
|
+
const staticFile = Bun.file(staticPath);
|
|
253
|
+
if (await staticFile.exists()) return new Response(staticFile);
|
|
254
|
+
}
|
|
255
|
+
return new Response("Not Found", { status: 404 });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Prerendered pages — serve static HTML built at build time
|
|
259
|
+
// Try both `<path>/index.html` (always/ignore mode) and `<path>.html` (never mode)
|
|
260
|
+
const prerenderCandidates =
|
|
261
|
+
path === "/" ? ["index.html"] : [`${path}/index.html`, `${path.replace(/\/$/, "")}.html`];
|
|
262
|
+
for (const candidate of prerenderCandidates) {
|
|
263
|
+
const prerenderPath = safePath("./dist/prerendered", candidate);
|
|
264
|
+
if (!prerenderPath) continue;
|
|
265
|
+
const prerenderFile = Bun.file(prerenderPath);
|
|
266
|
+
if (await prerenderFile.exists()) {
|
|
267
|
+
return new Response(prerenderFile, {
|
|
268
|
+
headers: {
|
|
269
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
270
|
+
"Cache-Control": "public, max-age=3600",
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// API routes (+server.ts)
|
|
277
|
+
const apiMatch = findMatch(apiRoutes, path);
|
|
278
|
+
if (apiMatch) {
|
|
279
|
+
try {
|
|
280
|
+
const mod = await apiMatch.route.module();
|
|
281
|
+
const handler = mod[method];
|
|
282
|
+
|
|
283
|
+
if (!handler) {
|
|
284
|
+
const allowed = Object.keys(mod)
|
|
285
|
+
.filter((k) => /^[A-Z]+$/.test(k))
|
|
286
|
+
.join(", ");
|
|
287
|
+
return Response.json(
|
|
288
|
+
{ error: `Method ${method} not allowed` },
|
|
289
|
+
{ status: 405, headers: { Allow: allowed } },
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
event.params = apiMatch.params;
|
|
294
|
+
return await handler({ request, params: apiMatch.params, url, locals, cookies });
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (isDev) console.error("API route error:", err);
|
|
297
|
+
else console.error("API route error:", (err as Error).message ?? err);
|
|
298
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Trailing-slash canonicalization — 308 preserves method (form POSTs included)
|
|
303
|
+
const canonicalMatch = findMatch(serverRoutes, path);
|
|
304
|
+
if (canonicalMatch) {
|
|
305
|
+
const canonical = canonicalPathname(
|
|
306
|
+
path,
|
|
307
|
+
(canonicalMatch.route as any).trailingSlash ?? "never",
|
|
308
|
+
);
|
|
309
|
+
if (canonical !== null) {
|
|
310
|
+
return new Response(null, {
|
|
311
|
+
status: 308,
|
|
312
|
+
headers: { Location: canonical + url.search + url.hash },
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Form actions — POST to page routes with `actions` export
|
|
318
|
+
if (method === "POST") {
|
|
319
|
+
const pageMatch = findMatch(serverRoutes, path);
|
|
320
|
+
if (pageMatch?.route.pageServer) {
|
|
321
|
+
// `use:enhance` sets this header — return JSON instead of re-rendering HTML
|
|
322
|
+
const isEnhanced = request.headers.get("x-bosia-action") === "1";
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const mod = await pageMatch.route.pageServer();
|
|
326
|
+
if (mod.actions && typeof mod.actions === "object") {
|
|
327
|
+
const actionName = parseActionName(url);
|
|
328
|
+
const action = mod.actions[actionName];
|
|
329
|
+
if (!action) {
|
|
330
|
+
if (isEnhanced) {
|
|
331
|
+
return Response.json(
|
|
332
|
+
{
|
|
333
|
+
type: "error",
|
|
334
|
+
status: 404,
|
|
335
|
+
message: `Action "${actionName}" not found`,
|
|
336
|
+
},
|
|
337
|
+
{ status: 404 },
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
return renderErrorPage(
|
|
341
|
+
404,
|
|
342
|
+
`Action "${actionName}" not found`,
|
|
343
|
+
url,
|
|
344
|
+
request,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
event.params = pageMatch.params;
|
|
349
|
+
let result: any;
|
|
350
|
+
try {
|
|
351
|
+
result = await action(event);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
if (err instanceof Redirect) {
|
|
354
|
+
if (isEnhanced) {
|
|
355
|
+
return Response.json({
|
|
356
|
+
type: "redirect",
|
|
357
|
+
status: 303,
|
|
358
|
+
location: err.location,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return new Response(null, {
|
|
362
|
+
status: 303,
|
|
363
|
+
headers: { Location: err.location },
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
if (err instanceof HttpError) {
|
|
367
|
+
if (isEnhanced) {
|
|
368
|
+
return Response.json(
|
|
369
|
+
{ type: "error", status: err.status, message: err.message },
|
|
370
|
+
{ status: err.status },
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return renderErrorPage(err.status, err.message, url, request);
|
|
374
|
+
}
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Redirect returned (not thrown)
|
|
379
|
+
if (result instanceof Redirect) {
|
|
380
|
+
if (isEnhanced) {
|
|
381
|
+
return Response.json({
|
|
382
|
+
type: "redirect",
|
|
383
|
+
status: 303,
|
|
384
|
+
location: result.location,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
return new Response(null, {
|
|
388
|
+
status: 303,
|
|
389
|
+
headers: { Location: result.location },
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ActionFailure — re-render with failure status
|
|
394
|
+
if (result instanceof ActionFailure) {
|
|
395
|
+
if (isEnhanced) {
|
|
396
|
+
return Response.json(
|
|
397
|
+
{ type: "failure", status: result.status, data: result.data },
|
|
398
|
+
{ status: result.status },
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
return renderPageWithFormData(
|
|
402
|
+
url,
|
|
403
|
+
locals,
|
|
404
|
+
request,
|
|
405
|
+
cookies,
|
|
406
|
+
result.data,
|
|
407
|
+
result.status,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Success — re-render page with action return data
|
|
412
|
+
if (isEnhanced) {
|
|
413
|
+
return Response.json({
|
|
414
|
+
type: "success",
|
|
415
|
+
status: 200,
|
|
416
|
+
data: result ?? null,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return renderPageWithFormData(
|
|
420
|
+
url,
|
|
421
|
+
locals,
|
|
422
|
+
request,
|
|
423
|
+
cookies,
|
|
424
|
+
result ?? null,
|
|
425
|
+
200,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
} catch (err) {
|
|
429
|
+
if (err instanceof Redirect) {
|
|
430
|
+
if (isEnhanced) {
|
|
431
|
+
return Response.json({
|
|
432
|
+
type: "redirect",
|
|
433
|
+
status: 303,
|
|
434
|
+
location: err.location,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
return new Response(null, {
|
|
438
|
+
status: 303,
|
|
439
|
+
headers: { Location: err.location },
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
if (err instanceof HttpError) {
|
|
443
|
+
if (isEnhanced) {
|
|
444
|
+
return Response.json(
|
|
445
|
+
{ type: "error", status: err.status, message: err.message },
|
|
446
|
+
{ status: err.status },
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
return renderErrorPage(err.status, err.message, url, request);
|
|
450
|
+
}
|
|
451
|
+
if (isDev) console.error("Form action error:", err);
|
|
452
|
+
else console.error("Form action error:", (err as Error).message ?? err);
|
|
453
|
+
if (isEnhanced) {
|
|
454
|
+
return Response.json(
|
|
455
|
+
{ type: "error", status: 500, message: "Internal Server Error" },
|
|
456
|
+
{ status: 500 },
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// SSR pages (+page.svelte) — streaming by default
|
|
465
|
+
const streamResponse = await renderSSRStream(url, locals, request, cookies);
|
|
466
|
+
if (!streamResponse) return renderErrorPage(404, "Not Found", url, request);
|
|
467
|
+
return streamResponse;
|
|
362
468
|
}
|
|
363
469
|
|
|
364
470
|
// ─── Request Entry ────────────────────────────────────────
|
|
365
471
|
|
|
366
472
|
const SECURITY_HEADERS: Record<string, string> = {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
473
|
+
"X-Content-Type-Options": "nosniff",
|
|
474
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
475
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
370
476
|
};
|
|
371
477
|
|
|
372
478
|
async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
479
|
+
// Reject new non-health requests during shutdown
|
|
480
|
+
if (shuttingDown && url.pathname !== "/_health") {
|
|
481
|
+
return new Response("Service Unavailable", {
|
|
482
|
+
status: 503,
|
|
483
|
+
headers: { "Retry-After": "5" },
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
inFlight++;
|
|
488
|
+
try {
|
|
489
|
+
// Handle CORS preflight before CSRF check (OPTIONS is CSRF-exempt)
|
|
490
|
+
if (CORS_CONFIG && request.method === "OPTIONS") {
|
|
491
|
+
const preflight = handlePreflight(request, CORS_CONFIG);
|
|
492
|
+
if (preflight) return preflight;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const csrfError = checkCsrf(request, url, CSRF_CONFIG);
|
|
496
|
+
if (csrfError) {
|
|
497
|
+
console.warn(`[CSRF] Blocked request: ${csrfError}`);
|
|
498
|
+
return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isDev);
|
|
502
|
+
const event: RequestEvent = { request, url, locals: {}, params: {}, cookies: cookieJar };
|
|
503
|
+
const response = userHandle ? await userHandle({ event, resolve }) : await resolve(event);
|
|
504
|
+
|
|
505
|
+
const headers = new Headers(response.headers);
|
|
506
|
+
for (const [k, v] of Object.entries(SECURITY_HEADERS)) headers.set(k, v);
|
|
507
|
+
// Apply CORS headers for allowed origins
|
|
508
|
+
if (CORS_CONFIG) {
|
|
509
|
+
const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
|
|
510
|
+
if (corsHeaders) {
|
|
511
|
+
for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// Apply any Set-Cookie headers accumulated during the request
|
|
515
|
+
for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
|
|
516
|
+
return new Response(response.body, {
|
|
517
|
+
status: response.status,
|
|
518
|
+
statusText: response.statusText,
|
|
519
|
+
headers,
|
|
520
|
+
});
|
|
521
|
+
} catch (err) {
|
|
522
|
+
if (isDev) console.error("Unhandled request error:", err);
|
|
523
|
+
else console.error("Unhandled request error:", (err as Error).message ?? err);
|
|
524
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
525
|
+
} finally {
|
|
526
|
+
inFlight--;
|
|
527
|
+
if (shuttingDown && inFlight === 0 && drainResolve) {
|
|
528
|
+
drainResolve();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
423
531
|
}
|
|
424
532
|
|
|
425
533
|
// ─── CORS Max Age ─────────────────────────────────────────
|
|
426
534
|
|
|
427
535
|
function parseCorsMaxAge(value?: string): number | undefined {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
536
|
+
if (!value) return undefined;
|
|
537
|
+
if (!/^\d+$/.test(value)) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
`Invalid CORS_MAX_AGE: "${value}" — must be a non-negative integer (seconds)`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
const n = parseInt(value, 10);
|
|
543
|
+
if (!Number.isFinite(n) || n > Number.MAX_SAFE_INTEGER) {
|
|
544
|
+
throw new Error(
|
|
545
|
+
`Invalid CORS_MAX_AGE: "${value}" — must be a non-negative integer (seconds)`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
return n;
|
|
437
549
|
}
|
|
438
550
|
|
|
439
551
|
// ─── Body Size Limit ──────────────────────────────────────
|
|
@@ -442,24 +554,24 @@ function parseCorsMaxAge(value?: string): number | undefined {
|
|
|
442
554
|
// Default: 512K (matches SvelteKit).
|
|
443
555
|
|
|
444
556
|
function parseBodySizeLimit(value?: string): number {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
557
|
+
if (!value) return 512 * 1024;
|
|
558
|
+
if (value === "Infinity") return 0; // Bun: 0 = no limit
|
|
559
|
+
const match = value.match(/^(\d+(?:\.\d+)?)\s*([KMG]?)$/i);
|
|
560
|
+
if (!match) throw new Error(`Invalid BODY_SIZE_LIMIT: "${value}"`);
|
|
561
|
+
const num = parseFloat(match[1]);
|
|
562
|
+
const unit = match[2].toUpperCase();
|
|
563
|
+
if (unit === "K") return Math.floor(num * 1024);
|
|
564
|
+
if (unit === "M") return Math.floor(num * 1024 * 1024);
|
|
565
|
+
if (unit === "G") return Math.floor(num * 1024 * 1024 * 1024);
|
|
566
|
+
return Math.floor(num);
|
|
455
567
|
}
|
|
456
568
|
|
|
457
569
|
const BODY_SIZE_LIMIT = parseBodySizeLimit(process.env.BODY_SIZE_LIMIT);
|
|
458
570
|
|
|
459
571
|
if (BODY_SIZE_LIMIT === 0) {
|
|
460
|
-
|
|
572
|
+
console.log("📦 Body size limit: none");
|
|
461
573
|
} else {
|
|
462
|
-
|
|
574
|
+
console.log(`📦 Body size limit: ${BODY_SIZE_LIMIT} bytes`);
|
|
463
575
|
}
|
|
464
576
|
|
|
465
577
|
// ─── Graceful Shutdown State ──────────────────────────────
|
|
@@ -470,73 +582,75 @@ let drainResolve: (() => void) | null = null;
|
|
|
470
582
|
|
|
471
583
|
// ─── Elysia App ───────────────────────────────────────────
|
|
472
584
|
|
|
473
|
-
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) :
|
|
585
|
+
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : isDev ? 9001 : 9000;
|
|
474
586
|
|
|
475
587
|
const app = new Elysia({ serve: { maxRequestBodySize: BODY_SIZE_LIMIT } })
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
588
|
+
.onError(({ error }) => {
|
|
589
|
+
if (isDev) console.error("Uncaught server error:", error);
|
|
590
|
+
else console.error("Uncaught server error:", (error as Error)?.message ?? error);
|
|
591
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
592
|
+
})
|
|
593
|
+
// Static files are served by resolve() with path traversal protection and security headers
|
|
594
|
+
// API routes must intercept all HTTP methods before the GET catch-all
|
|
595
|
+
.onBeforeHandle(async ({ request }) => {
|
|
596
|
+
const url = new URL(request.url);
|
|
597
|
+
if (!findMatch(apiRoutes, url.pathname)) return; // not an API route
|
|
598
|
+
return handleRequest(request, url);
|
|
599
|
+
})
|
|
600
|
+
// SSR pages
|
|
601
|
+
.get("*", ({ request }) => {
|
|
602
|
+
const url = new URL(request.url);
|
|
603
|
+
return handleRequest(request, url);
|
|
604
|
+
})
|
|
605
|
+
// Non-GET catch-alls so onBeforeHandle fires for API routes on other methods
|
|
606
|
+
.post("*", ({ request }) => {
|
|
607
|
+
const url = new URL(request.url);
|
|
608
|
+
return handleRequest(request, url);
|
|
609
|
+
})
|
|
610
|
+
.put("*", ({ request }) => {
|
|
611
|
+
const url = new URL(request.url);
|
|
612
|
+
return handleRequest(request, url);
|
|
613
|
+
})
|
|
614
|
+
.patch("*", ({ request }) => {
|
|
615
|
+
const url = new URL(request.url);
|
|
616
|
+
return handleRequest(request, url);
|
|
617
|
+
})
|
|
618
|
+
.delete("*", ({ request }) => {
|
|
619
|
+
const url = new URL(request.url);
|
|
620
|
+
return handleRequest(request, url);
|
|
621
|
+
})
|
|
622
|
+
.options("*", ({ request }) => {
|
|
623
|
+
const url = new URL(request.url);
|
|
624
|
+
return handleRequest(request, url);
|
|
625
|
+
});
|
|
514
626
|
|
|
515
627
|
app.listen(PORT, () => {
|
|
516
|
-
|
|
517
|
-
|
|
628
|
+
// In dev mode the proxy owns the user-facing port — don't print the internal port
|
|
629
|
+
if (!isDev) console.log(`⬡ Bosia server running at http://localhost:${PORT}`);
|
|
518
630
|
});
|
|
519
631
|
|
|
520
632
|
async function shutdown() {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
633
|
+
if (shuttingDown) return;
|
|
634
|
+
shuttingDown = true;
|
|
635
|
+
console.log("⏳ Shutting down — draining in-flight requests...");
|
|
636
|
+
|
|
637
|
+
if (inFlight > 0) {
|
|
638
|
+
await Promise.race([
|
|
639
|
+
new Promise<void>((r) => {
|
|
640
|
+
drainResolve = r;
|
|
641
|
+
}),
|
|
642
|
+
Bun.sleep(10_000),
|
|
643
|
+
]);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (inFlight > 0) {
|
|
647
|
+
console.warn(`⚠️ Force shutdown with ${inFlight} request(s) still in flight`);
|
|
648
|
+
} else {
|
|
649
|
+
console.log("✅ All requests drained");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
app.stop().then(() => process.exit(0));
|
|
653
|
+
setTimeout(() => process.exit(1), 5_000);
|
|
540
654
|
}
|
|
541
655
|
|
|
542
656
|
process.on("SIGTERM", shutdown);
|