@luckystack/server 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +85 -83
- package/LICENSE +21 -21
- package/README.md +198 -198
- package/dist/chunk-X3OSC5W3.js +44 -0
- package/dist/chunk-X3OSC5W3.js.map +1 -0
- package/dist/index.d.ts +43 -40
- package/dist/index.js +840 -395
- package/dist/index.js.map +1 -1
- package/dist/parseArgv.js +3 -34
- package/dist/parseArgv.js.map +1 -1
- package/docs/argv-parsing.md +256 -256
- package/docs/create-server.md +295 -295
- package/docs/http-routes.md +309 -309
- package/docs/request-pipeline.md +198 -198
- package/docs/runtime-maps.md +206 -206
- package/docs/security-defaults.md +236 -236
- package/package.json +21 -11
package/dist/index.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyServerArgv,
|
|
3
|
+
getParsedBundles,
|
|
4
|
+
getParsedPort,
|
|
5
|
+
parseServerArgv
|
|
6
|
+
} from "./chunk-X3OSC5W3.js";
|
|
7
|
+
|
|
1
8
|
// src/createServer.ts
|
|
2
9
|
import http from "http";
|
|
3
|
-
import { registerBindAddress, writeBootUuid, getLogger as
|
|
10
|
+
import { registerBindAddress, writeBootUuid, getLogger as getLogger12, getProjectConfig as getProjectConfig13, tryCatch as tryCatch9 } from "@luckystack/core";
|
|
4
11
|
|
|
5
12
|
// src/httpHandler.ts
|
|
6
13
|
import { randomUUID } from "crypto";
|
|
@@ -8,40 +15,139 @@ import {
|
|
|
8
15
|
allowedOrigin,
|
|
9
16
|
dispatchHook as dispatchHook5,
|
|
10
17
|
extractTokenFromRequest as extractTokenFromRequest3,
|
|
11
|
-
getLogger as
|
|
18
|
+
getLogger as getLogger7,
|
|
12
19
|
getParams,
|
|
13
|
-
getProjectConfig as
|
|
14
|
-
hasCookie
|
|
20
|
+
getProjectConfig as getProjectConfig10,
|
|
21
|
+
hasCookie,
|
|
22
|
+
readSession as readSession3,
|
|
23
|
+
tryCatchSync
|
|
15
24
|
} from "@luckystack/core";
|
|
16
|
-
import { getSession as getSession3 } from "@luckystack/login";
|
|
17
25
|
|
|
18
26
|
// src/logSanitize.ts
|
|
19
27
|
import { getProjectConfig, isRedactedLogKey } from "@luckystack/core";
|
|
20
28
|
var REDACTED_PLACEHOLDER = "[REDACTED]";
|
|
29
|
+
var TRUNCATED_PLACEHOLDER = "[TRUNCATED]";
|
|
30
|
+
var MAX_SANITIZE_DEPTH = 40;
|
|
21
31
|
var isRedactedKey = (key) => {
|
|
22
32
|
if (isRedactedLogKey(key)) return true;
|
|
23
33
|
return key.toLowerCase() === getProjectConfig().http.sessionCookieName.toLowerCase();
|
|
24
34
|
};
|
|
25
|
-
var
|
|
35
|
+
var sanitizeWithGuards = (value, depth, seen) => {
|
|
26
36
|
if (value === null || typeof value !== "object") return value;
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
if (seen.has(value)) return TRUNCATED_PLACEHOLDER;
|
|
38
|
+
if (depth >= MAX_SANITIZE_DEPTH) return TRUNCATED_PLACEHOLDER;
|
|
39
|
+
seen.add(value);
|
|
40
|
+
try {
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
return value.map((entry) => sanitizeWithGuards(entry, depth + 1, seen));
|
|
43
|
+
}
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [key, val] of Object.entries(value)) {
|
|
46
|
+
out[key] = isRedactedKey(key) ? REDACTED_PLACEHOLDER : sanitizeWithGuards(val, depth + 1, seen);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
} finally {
|
|
50
|
+
seen.delete(value);
|
|
31
51
|
}
|
|
32
|
-
return out;
|
|
33
52
|
};
|
|
53
|
+
var sanitizeForLog = (value) => sanitizeWithGuards(value, 0, /* @__PURE__ */ new WeakSet());
|
|
34
54
|
|
|
35
55
|
// src/securityHeadersRegistry.ts
|
|
36
|
-
|
|
56
|
+
import { createRegistry } from "@luckystack/core";
|
|
57
|
+
var builderRegistry = createRegistry(null);
|
|
37
58
|
var registerSecurityHeaders = (builder) => {
|
|
38
|
-
|
|
59
|
+
builderRegistry.register(builder);
|
|
60
|
+
};
|
|
61
|
+
var getSecurityHeadersBuilder = () => builderRegistry.get();
|
|
62
|
+
|
|
63
|
+
// src/httpRoutes/csrfMiddleware.ts
|
|
64
|
+
import { dispatchHook, getCookieValue, getCsrfConfig, getProjectConfig as getProjectConfig2, readSession } from "@luckystack/core";
|
|
65
|
+
|
|
66
|
+
// src/capabilities.ts
|
|
67
|
+
import { createRequire } from "module";
|
|
68
|
+
var localRequire = createRequire(import.meta.url);
|
|
69
|
+
var esmResolve = import.meta.resolve;
|
|
70
|
+
var _warnedResolverMissing = false;
|
|
71
|
+
var warnResolverMissingOnce = () => {
|
|
72
|
+
if (_warnedResolverMissing) return;
|
|
73
|
+
_warnedResolverMissing = true;
|
|
74
|
+
console.warn(
|
|
75
|
+
"[luckystack:capabilities] import.meta.resolve is unavailable (Node < 20.6). Optional package detection falls back to require.resolve, which cannot resolve import-only exports maps and may misreport @luckystack/* packages as absent. Upgrade to Node >= 20.6 to ensure correct capability detection."
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
var has = (pkg) => {
|
|
79
|
+
if (typeof esmResolve === "function") {
|
|
80
|
+
try {
|
|
81
|
+
esmResolve(pkg);
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
warnResolverMissingOnce();
|
|
88
|
+
try {
|
|
89
|
+
localRequire.resolve(pkg);
|
|
90
|
+
return true;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error instanceof Error && error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
var capabilities = {
|
|
99
|
+
login: has("@luckystack/login"),
|
|
100
|
+
presence: has("@luckystack/presence"),
|
|
101
|
+
sync: has("@luckystack/sync")
|
|
102
|
+
};
|
|
103
|
+
var OPTIONAL_PACKAGES = [
|
|
104
|
+
"login",
|
|
105
|
+
"email",
|
|
106
|
+
"error-tracking",
|
|
107
|
+
"presence",
|
|
108
|
+
"docs-ui"
|
|
109
|
+
];
|
|
110
|
+
var canResolve = (specifier) => has(specifier);
|
|
111
|
+
var loginMod;
|
|
112
|
+
var getLogin = async () => {
|
|
113
|
+
if (loginMod !== void 0) return loginMod;
|
|
114
|
+
loginMod = capabilities.login ? await import("@luckystack/login") : null;
|
|
115
|
+
return loginMod;
|
|
116
|
+
};
|
|
117
|
+
var presenceMod;
|
|
118
|
+
var getPresence = async () => {
|
|
119
|
+
if (presenceMod !== void 0) return presenceMod;
|
|
120
|
+
presenceMod = capabilities.presence ? await import("@luckystack/presence") : null;
|
|
121
|
+
return presenceMod;
|
|
122
|
+
};
|
|
123
|
+
var syncMod;
|
|
124
|
+
var getSync = async () => {
|
|
125
|
+
if (syncMod !== void 0) return syncMod;
|
|
126
|
+
syncMod = capabilities.sync ? await import("@luckystack/sync") : null;
|
|
127
|
+
return syncMod;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// src/originExemptRegistry.ts
|
|
131
|
+
var exemptPaths = [];
|
|
132
|
+
var registerOriginExemptPath = (matcher) => {
|
|
133
|
+
exemptPaths.push(matcher);
|
|
134
|
+
};
|
|
135
|
+
var getOriginExemptPaths = () => exemptPaths;
|
|
136
|
+
var clearOriginExemptPaths = () => {
|
|
137
|
+
exemptPaths.length = 0;
|
|
138
|
+
};
|
|
139
|
+
var isOriginExemptPath = (routePath) => exemptPaths.some((matcher) => routePath.startsWith(matcher.pathPrefix));
|
|
140
|
+
|
|
141
|
+
// src/httpRoutes/timingSafeEqual.ts
|
|
142
|
+
import { timingSafeEqual as cryptoTimingSafeEqual } from "crypto";
|
|
143
|
+
var timingSafeStringEqual = (a, b) => {
|
|
144
|
+
const aBuf = Buffer.from(a, "utf8");
|
|
145
|
+
const bBuf = Buffer.from(b, "utf8");
|
|
146
|
+
if (aBuf.length !== bBuf.length) return false;
|
|
147
|
+
return cryptoTimingSafeEqual(aBuf, bBuf);
|
|
39
148
|
};
|
|
40
|
-
var getSecurityHeadersBuilder = () => registeredBuilder;
|
|
41
149
|
|
|
42
150
|
// src/httpRoutes/csrfMiddleware.ts
|
|
43
|
-
import { dispatchHook, getCsrfConfig, getProjectConfig as getProjectConfig2 } from "@luckystack/core";
|
|
44
|
-
import { getSession } from "@luckystack/login";
|
|
45
151
|
var enforceCsrfOnStateChangingRequest = async ({
|
|
46
152
|
req,
|
|
47
153
|
res,
|
|
@@ -51,20 +157,41 @@ var enforceCsrfOnStateChangingRequest = async ({
|
|
|
51
157
|
}) => {
|
|
52
158
|
const config = getProjectConfig2();
|
|
53
159
|
const isCookieMode = !config.session.basedToken;
|
|
54
|
-
const isStateChanging = req.method !== "GET" && req.method !== "OPTIONS";
|
|
160
|
+
const isStateChanging = req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS";
|
|
55
161
|
const isCallbackPath = routePath.startsWith("/auth/callback");
|
|
56
162
|
const isAuthBootstrap = routePath === "/auth/api/credentials";
|
|
57
|
-
const
|
|
58
|
-
|
|
163
|
+
const isExemptFromCsrf = isAuthBootstrap || isCallbackPath || isOriginExemptPath(routePath);
|
|
164
|
+
const isCsrfCandidate = routePath.startsWith("/api/") || routePath.startsWith("/sync/") || routePath.startsWith("/auth/api/") || !routePath.startsWith("/auth/") && !routePath.startsWith("/assets/");
|
|
165
|
+
if (!(isCookieMode && isStateChanging && isCsrfCandidate && !isExemptFromCsrf)) {
|
|
59
166
|
return false;
|
|
60
167
|
}
|
|
61
|
-
const csrfSession = await getSession(token);
|
|
62
|
-
if (!csrfSession?.id) return false;
|
|
63
168
|
const csrfConfig = getCsrfConfig();
|
|
64
169
|
const headerKey = csrfConfig.headerName.toLowerCase();
|
|
65
170
|
const headerValue = req.headers[headerKey];
|
|
66
171
|
const provided = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
67
|
-
if (
|
|
172
|
+
if (!capabilities.login) {
|
|
173
|
+
const cookieValue = getCookieValue(req.headers.cookie, csrfConfig.cookieName);
|
|
174
|
+
if (cookieValue && provided && timingSafeStringEqual(provided, cookieValue)) return false;
|
|
175
|
+
void dispatchHook("csrfMismatch", {
|
|
176
|
+
route: routePath,
|
|
177
|
+
method: req.method,
|
|
178
|
+
requestId,
|
|
179
|
+
userId: void 0,
|
|
180
|
+
providedToken: Boolean(provided)
|
|
181
|
+
});
|
|
182
|
+
res.statusCode = 403;
|
|
183
|
+
res.setHeader("Content-Type", "application/json");
|
|
184
|
+
res.end(JSON.stringify({
|
|
185
|
+
status: "error",
|
|
186
|
+
errorCode: "auth.csrfMismatch",
|
|
187
|
+
message: "CSRF token missing or invalid. Fetch /auth/csrf first."
|
|
188
|
+
}));
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
if (!token) return false;
|
|
192
|
+
const csrfSession = await readSession(token);
|
|
193
|
+
if (!csrfSession?.id) return false;
|
|
194
|
+
if (provided && csrfSession.csrfToken && timingSafeStringEqual(provided, csrfSession.csrfToken)) return false;
|
|
68
195
|
void dispatchHook("csrfMismatch", {
|
|
69
196
|
route: routePath,
|
|
70
197
|
method: req.method,
|
|
@@ -83,16 +210,35 @@ var enforceCsrfOnStateChangingRequest = async ({
|
|
|
83
210
|
};
|
|
84
211
|
|
|
85
212
|
// src/httpRoutes/csrfRoute.ts
|
|
86
|
-
import {
|
|
213
|
+
import { randomBytes } from "crypto";
|
|
214
|
+
import { getCsrfConfig as getCsrfConfig2, readSession as readSession2 } from "@luckystack/core";
|
|
215
|
+
var serializeCsrfCookie = (name, value, opts) => {
|
|
216
|
+
const parts = [`${name}=${value}`];
|
|
217
|
+
if (opts.httpOnly) parts.push("HttpOnly");
|
|
218
|
+
if (opts.sameSite) parts.push(`SameSite=${opts.sameSite.charAt(0).toUpperCase()}${opts.sameSite.slice(1)}`);
|
|
219
|
+
if (opts.secure) parts.push("Secure");
|
|
220
|
+
parts.push(`Path=${opts.path ?? "/"}`);
|
|
221
|
+
if (typeof opts.maxAgeMs === "number") parts.push(`Max-Age=${String(Math.floor(opts.maxAgeMs / 1e3))}`);
|
|
222
|
+
return parts.join("; ");
|
|
223
|
+
};
|
|
87
224
|
var handleCsrfRoute = async ({ res, routePath, token }) => {
|
|
88
225
|
if (routePath !== "/auth/csrf") return false;
|
|
226
|
+
const csrfConfig = getCsrfConfig2();
|
|
227
|
+
if (!capabilities.login) {
|
|
228
|
+
const doubleSubmit = randomBytes(csrfConfig.tokenLength).toString("hex");
|
|
229
|
+
res.statusCode = 200;
|
|
230
|
+
res.setHeader("Set-Cookie", serializeCsrfCookie(csrfConfig.cookieName, doubleSubmit, csrfConfig.cookieOptions));
|
|
231
|
+
res.setHeader("Content-Type", "application/json");
|
|
232
|
+
res.end(JSON.stringify({ status: "success", csrfToken: doubleSubmit }));
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
89
235
|
if (!token) {
|
|
90
236
|
res.statusCode = 401;
|
|
91
237
|
res.setHeader("Content-Type", "application/json");
|
|
92
238
|
res.end(JSON.stringify({ status: "error", errorCode: "auth.unauthenticated" }));
|
|
93
239
|
return true;
|
|
94
240
|
}
|
|
95
|
-
const csrfSession = await
|
|
241
|
+
const csrfSession = await readSession2(token);
|
|
96
242
|
if (!csrfSession?.id) {
|
|
97
243
|
res.statusCode = 401;
|
|
98
244
|
res.setHeader("Content-Type", "application/json");
|
|
@@ -123,6 +269,7 @@ var handleFaviconRoute = async ({ res, routePath, options }) => {
|
|
|
123
269
|
// src/httpRoutes/healthRoutes.ts
|
|
124
270
|
import {
|
|
125
271
|
computeSynchronizedEnvHashes,
|
|
272
|
+
describeHealthHashConfig,
|
|
126
273
|
getProjectConfig as getProjectConfig3,
|
|
127
274
|
prisma,
|
|
128
275
|
readBootUuid,
|
|
@@ -169,14 +316,18 @@ var handleReadyzRoute = async ({ res, routePath }) => {
|
|
|
169
316
|
var handleHealthRoute = async ({ res, routePath }) => {
|
|
170
317
|
if (routePath !== getProjectConfig3().http.healthEndpoint) return false;
|
|
171
318
|
const bootUuid = await readBootUuid();
|
|
172
|
-
const synchronizedHashes = computeSynchronizedEnvHashes();
|
|
319
|
+
const synchronizedHashes = computeSynchronizedEnvHashes(bootUuid);
|
|
173
320
|
res.statusCode = bootUuid ? 200 : 503;
|
|
174
321
|
res.setHeader("Content-Type", "application/json");
|
|
175
322
|
res.end(JSON.stringify({
|
|
176
323
|
status: bootUuid ? "ok" : "degraded",
|
|
177
324
|
bootUuid,
|
|
178
325
|
envKey: resolveEnvKey(),
|
|
179
|
-
synchronizedHashes
|
|
326
|
+
synchronizedHashes,
|
|
327
|
+
//? Tell the router HOW these hashes were produced (mode + whether the salt is
|
|
328
|
+
//? the `@bootUuid` sentinel) so it can hash its local values with the SAME
|
|
329
|
+
//? config instead of its own default. Never exposes a static salt (a secret).
|
|
330
|
+
healthHash: describeHealthHashConfig()
|
|
180
331
|
}));
|
|
181
332
|
return true;
|
|
182
333
|
};
|
|
@@ -200,7 +351,9 @@ var handleTestResetRoute = async ({ req, res, routePath }) => {
|
|
|
200
351
|
return true;
|
|
201
352
|
}
|
|
202
353
|
const requiredToken = process.env.TEST_RESET_TOKEN;
|
|
203
|
-
|
|
354
|
+
const providedToken = req.headers["x-test-reset-token"];
|
|
355
|
+
const tokenValue = Array.isArray(providedToken) ? providedToken[0] : providedToken;
|
|
356
|
+
if (!requiredToken || !tokenValue || !timingSafeStringEqual(tokenValue, requiredToken)) {
|
|
204
357
|
res.statusCode = 403;
|
|
205
358
|
res.setHeader("Content-Type", "application/json");
|
|
206
359
|
res.end(JSON.stringify({ status: "error", errorCode: "auth.forbidden" }));
|
|
@@ -232,8 +385,7 @@ var handleTestResetRoute = async ({ req, res, routePath }) => {
|
|
|
232
385
|
await scanAndDelete(sessionPattern, "sessions");
|
|
233
386
|
await scanAndDelete(activeUsersPattern, "activeUsers");
|
|
234
387
|
const rawUrl = req.url ?? "/";
|
|
235
|
-
const
|
|
236
|
-
const includeFlag = URL.canParse(rawUrl, base) ? new URL(rawUrl, base).searchParams.get("include") ?? "" : "";
|
|
388
|
+
const includeFlag = URL.canParse(rawUrl, "http://localhost") ? new URL(rawUrl, "http://localhost").searchParams.get("include") ?? "" : "";
|
|
237
389
|
if (includeFlag.split(",").map((s) => s.trim()).includes("hooks")) {
|
|
238
390
|
clearAllHooks();
|
|
239
391
|
cleared.push("hooks");
|
|
@@ -257,15 +409,14 @@ import {
|
|
|
257
409
|
checkRateLimit,
|
|
258
410
|
dispatchHook as dispatchHook2,
|
|
259
411
|
getLogger,
|
|
260
|
-
getProjectConfig as getProjectConfig5
|
|
412
|
+
getProjectConfig as getProjectConfig5,
|
|
413
|
+
resolveClientIp
|
|
261
414
|
} from "@luckystack/core";
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
loginWithCredentials
|
|
268
|
-
} from "@luckystack/login";
|
|
415
|
+
|
|
416
|
+
// src/httpRoutes/sessionCookie.ts
|
|
417
|
+
var resolveCookieSecure = (sessionCookieSecure, secureEnv) => sessionCookieSecure ?? secureEnv === "true";
|
|
418
|
+
|
|
419
|
+
// src/httpRoutes/authApiRoute.ts
|
|
269
420
|
var parseSessionBasedTokenHeader = (headerValue) => {
|
|
270
421
|
if (headerValue === void 0) return null;
|
|
271
422
|
const value = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
@@ -282,18 +433,26 @@ var handleAuthApiRoute = async ({
|
|
|
282
433
|
sessionCookieOptions
|
|
283
434
|
}) => {
|
|
284
435
|
if (!routePath.startsWith("/auth/api")) return false;
|
|
436
|
+
const login = await getLogin();
|
|
437
|
+
if (!login) {
|
|
438
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
439
|
+
res.end(JSON.stringify({ status: false, reason: "auth.disabled" }));
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
285
442
|
const config = getProjectConfig5();
|
|
286
443
|
const sessionCookieName = config.http.sessionCookieName;
|
|
287
444
|
const shouldLogDev = config.logging.devLogs;
|
|
288
445
|
const providerName = routePath.split("/")[3];
|
|
289
|
-
const provider = getOAuthProviders().find((p) => p.name === providerName);
|
|
446
|
+
const provider = login.getOAuthProviders().find((p) => p.name === providerName);
|
|
290
447
|
if (!provider?.name) {
|
|
291
448
|
res.setHeader("Content-Type", "application/json");
|
|
292
449
|
res.end(JSON.stringify({ status: false, reason: "login.providerNotFound" }));
|
|
293
450
|
return true;
|
|
294
451
|
}
|
|
295
|
-
if (isFullOAuthProvider(provider)) {
|
|
296
|
-
const
|
|
452
|
+
if (login.isFullOAuthProvider(provider)) {
|
|
453
|
+
const reqUrl = new URL(req.url ?? "/", "http://placeholder");
|
|
454
|
+
const returnUrl = reqUrl.searchParams.get("return_url") ?? void 0;
|
|
455
|
+
const oauthState = await login.createOAuthState(provider.name, { usePkce: provider.usePkce, returnUrl });
|
|
297
456
|
if (!oauthState) {
|
|
298
457
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
299
458
|
res.end(JSON.stringify({ status: false, reason: "login.oauthStateInitFailed" }));
|
|
@@ -302,28 +461,42 @@ var handleAuthApiRoute = async ({
|
|
|
302
461
|
const clientId = encodeURIComponent(provider.clientID);
|
|
303
462
|
const callbackUrl = encodeURIComponent(provider.callbackURL);
|
|
304
463
|
const scope = encodeURIComponent(provider.scope.join(" "));
|
|
305
|
-
const state = encodeURIComponent(oauthState);
|
|
464
|
+
const state = encodeURIComponent(oauthState.state);
|
|
465
|
+
const stateTtl = config.auth.oauthStateTtlSeconds;
|
|
466
|
+
const secureFlag = resolveCookieSecure(config.http.sessionCookieSecure, process.env.SECURE) ? " Secure;" : "";
|
|
467
|
+
res.setHeader(
|
|
468
|
+
"Set-Cookie",
|
|
469
|
+
`${login.OAUTH_STATE_COOKIE_NAME}=${oauthState.stateCookie}; Path=/; HttpOnly;${secureFlag} SameSite=Lax; Max-Age=${stateTtl}`
|
|
470
|
+
);
|
|
471
|
+
const pkceParams = oauthState.codeChallenge ? `&code_challenge=${encodeURIComponent(oauthState.codeChallenge)}&code_challenge_method=S256` : "";
|
|
306
472
|
res.writeHead(302, {
|
|
307
|
-
Location: `${provider.authorizationURL}?client_id=${clientId}&redirect_uri=${callbackUrl}&scope=${scope}&response_type=code&prompt=select_account&state=${state}`
|
|
473
|
+
Location: `${provider.authorizationURL}?client_id=${clientId}&redirect_uri=${callbackUrl}&scope=${scope}&response_type=code&prompt=select_account&state=${state}${pkceParams}`
|
|
308
474
|
});
|
|
309
475
|
res.end();
|
|
310
476
|
return true;
|
|
311
477
|
}
|
|
312
478
|
const rateLimiting = config.rateLimiting;
|
|
313
|
-
|
|
314
|
-
|
|
479
|
+
const requesterIp = resolveClientIp({
|
|
480
|
+
rawAddress: req.socket.remoteAddress,
|
|
481
|
+
headers: req.headers,
|
|
482
|
+
trustProxy: config.http.trustProxy,
|
|
483
|
+
trustedProxyHopCount: config.http.trustedProxyHopCount
|
|
484
|
+
});
|
|
485
|
+
const ipLimitCount = rateLimiting.defaultApiLimit !== false && rateLimiting.defaultApiLimit > 0 ? rateLimiting.defaultApiLimit : rateLimiting.auth.enabled && rateLimiting.auth.maxAttempts > 0 ? rateLimiting.auth.maxAttempts : null;
|
|
486
|
+
if (ipLimitCount !== null) {
|
|
487
|
+
const ipWindowMs = rateLimiting.defaultApiLimit === false ? rateLimiting.auth.windowMs : rateLimiting.windowMs;
|
|
315
488
|
const { allowed, resetIn } = await checkRateLimit({
|
|
316
489
|
key: `ip:${requesterIp}:auth:credentials`,
|
|
317
|
-
limit:
|
|
318
|
-
windowMs:
|
|
490
|
+
limit: ipLimitCount,
|
|
491
|
+
windowMs: ipWindowMs
|
|
319
492
|
});
|
|
320
493
|
if (!allowed) {
|
|
321
494
|
void dispatchHook2("rateLimitExceeded", {
|
|
322
495
|
scope: "auth",
|
|
323
496
|
key: `ip:${requesterIp}:auth:credentials`,
|
|
324
|
-
limit:
|
|
325
|
-
windowMs:
|
|
326
|
-
count:
|
|
497
|
+
limit: ipLimitCount,
|
|
498
|
+
windowMs: ipWindowMs,
|
|
499
|
+
count: ipLimitCount + 1,
|
|
327
500
|
route: routePath,
|
|
328
501
|
ip: requesterIp
|
|
329
502
|
});
|
|
@@ -336,7 +509,7 @@ var handleAuthApiRoute = async ({
|
|
|
336
509
|
return true;
|
|
337
510
|
}
|
|
338
511
|
}
|
|
339
|
-
const result = await loginWithCredentials(params);
|
|
512
|
+
const result = await login.loginWithCredentials(params, { supersedeToken: token ?? void 0, requesterIp });
|
|
340
513
|
if (!result?.status) {
|
|
341
514
|
const reasonKey = typeof result?.reason === "string" && result.reason.length > 0 ? result.reason : "api.internalServerError";
|
|
342
515
|
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
@@ -344,7 +517,7 @@ var handleAuthApiRoute = async ({
|
|
|
344
517
|
return true;
|
|
345
518
|
}
|
|
346
519
|
if (result.newToken) {
|
|
347
|
-
if (token) await deleteSession(token);
|
|
520
|
+
if (token) await login.deleteSession(token, { skipSocketLogout: true });
|
|
348
521
|
const requestedSessionMode = parseSessionBasedTokenHeader(req.headers["x-session-based-token"]);
|
|
349
522
|
const useSessionBasedToken = requestedSessionMode ?? config.session.basedToken;
|
|
350
523
|
if (shouldLogDev) getLogger().debug("http: setting cookie with new token");
|
|
@@ -363,18 +536,50 @@ var handleAuthApiRoute = async ({
|
|
|
363
536
|
return true;
|
|
364
537
|
};
|
|
365
538
|
|
|
539
|
+
// src/httpRoutes/authLogoutRoute.ts
|
|
540
|
+
import { getLogger as getLogger2, getProjectConfig as getProjectConfig6, tryCatch as tryCatch3 } from "@luckystack/core";
|
|
541
|
+
var handleAuthLogoutRoute = async ({ res, routePath, method, token }) => {
|
|
542
|
+
if (routePath !== "/auth/logout") return false;
|
|
543
|
+
if (method !== "POST") {
|
|
544
|
+
res.statusCode = 405;
|
|
545
|
+
res.setHeader("Allow", "POST");
|
|
546
|
+
res.setHeader("Content-Type", "application/json");
|
|
547
|
+
res.end(JSON.stringify({ status: "error", errorCode: "api.methodNotAllowed" }));
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
if (token) {
|
|
551
|
+
const login = await getLogin();
|
|
552
|
+
if (login) {
|
|
553
|
+
const [deleteError] = await tryCatch3(() => login.deleteSession(token));
|
|
554
|
+
if (deleteError) {
|
|
555
|
+
getLogger2().warn("http logout: deleteSession failed \u2014 clearing cookie anyway", { err: deleteError });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const http2 = getProjectConfig6().http;
|
|
560
|
+
const secure = process.env.SECURE === "true";
|
|
561
|
+
res.setHeader(
|
|
562
|
+
"Set-Cookie",
|
|
563
|
+
`${http2.sessionCookieName}=; HttpOnly; SameSite=${http2.sessionCookieSameSite}; Path=${http2.sessionCookiePath}; Max-Age=0; ${secure ? "Secure;" : ""}`
|
|
564
|
+
);
|
|
565
|
+
res.statusCode = 200;
|
|
566
|
+
res.setHeader("Content-Type", "application/json");
|
|
567
|
+
res.end(JSON.stringify({ status: "success", result: true }));
|
|
568
|
+
return true;
|
|
569
|
+
};
|
|
570
|
+
|
|
366
571
|
// src/httpRoutes/authProvidersRoute.ts
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
572
|
+
var handleAuthProvidersRoute = async ({ req, res, routePath }) => {
|
|
573
|
+
if (routePath !== "/auth/providers" || req.method !== "GET") return false;
|
|
574
|
+
const login = await getLogin();
|
|
575
|
+
const providers = login ? login.getOAuthProviders().map((provider) => provider.name) : [];
|
|
370
576
|
res.setHeader("Content-Type", "application/json");
|
|
371
|
-
res.end(JSON.stringify({ providers
|
|
372
|
-
return
|
|
577
|
+
res.end(JSON.stringify({ providers }));
|
|
578
|
+
return true;
|
|
373
579
|
};
|
|
374
580
|
|
|
375
581
|
// src/httpRoutes/authCallbackRoute.ts
|
|
376
|
-
import { getLogger as
|
|
377
|
-
import { deleteSession as deleteSession2, loginCallback } from "@luckystack/login";
|
|
582
|
+
import { getLogger as getLogger3, getProjectConfig as getProjectConfig7 } from "@luckystack/core";
|
|
378
583
|
var handleAuthCallbackRoute = async ({
|
|
379
584
|
req,
|
|
380
585
|
res,
|
|
@@ -383,24 +588,34 @@ var handleAuthCallbackRoute = async ({
|
|
|
383
588
|
sessionCookieOptions
|
|
384
589
|
}) => {
|
|
385
590
|
if (!routePath.startsWith("/auth/callback")) return false;
|
|
386
|
-
const
|
|
591
|
+
const login = await getLogin();
|
|
592
|
+
if (!login) {
|
|
593
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
594
|
+
res.end("Auth is not enabled");
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
const config = getProjectConfig7();
|
|
387
598
|
const sessionCookieName = config.http.sessionCookieName;
|
|
388
599
|
const shouldLogDev = config.logging.devLogs;
|
|
389
|
-
const
|
|
390
|
-
const
|
|
391
|
-
|
|
600
|
+
const publicOrigin = (config.app.publicUrl || "").replace(/\/+$/, "");
|
|
601
|
+
const loginRedirect = config.loginRedirectUrl || "/";
|
|
602
|
+
const baseLocation = /^https?:\/\//i.test(loginRedirect) ? loginRedirect : `${publicOrigin}${loginRedirect.startsWith("/") ? loginRedirect : `/${loginRedirect}`}`;
|
|
603
|
+
const callbackResult = await login.loginCallback(routePath, req, res, {
|
|
604
|
+
defaultRedirectUrl: baseLocation,
|
|
605
|
+
supersedeToken: token ?? void 0
|
|
392
606
|
});
|
|
393
607
|
if (!callbackResult) {
|
|
394
608
|
res.writeHead(401, { "Content-Type": "text/plain" });
|
|
395
609
|
res.end("Login failed");
|
|
396
610
|
return true;
|
|
397
611
|
}
|
|
398
|
-
if (token) await
|
|
399
|
-
if (shouldLogDev)
|
|
612
|
+
if (token) await login.deleteSession(token, { skipSocketLogout: true });
|
|
613
|
+
if (shouldLogDev) getLogger3().debug("http: setting cookie or redirect with new token");
|
|
400
614
|
const { token: newToken, redirectUrl } = callbackResult;
|
|
401
615
|
if (config.session.basedToken) {
|
|
402
|
-
const
|
|
403
|
-
|
|
616
|
+
const hashIndex = redirectUrl.indexOf("#");
|
|
617
|
+
const baseUrl = hashIndex === -1 ? redirectUrl : redirectUrl.slice(0, hashIndex);
|
|
618
|
+
res.writeHead(302, { Location: `${baseUrl}#token=${newToken}` });
|
|
404
619
|
} else {
|
|
405
620
|
res.setHeader("Set-Cookie", `${sessionCookieName}=${newToken}; ${sessionCookieOptions}`);
|
|
406
621
|
res.writeHead(302, { Location: redirectUrl });
|
|
@@ -414,13 +629,13 @@ import {
|
|
|
414
629
|
captureException,
|
|
415
630
|
dispatchHook as dispatchHook3,
|
|
416
631
|
extractTokenFromRequest,
|
|
417
|
-
getLogger as
|
|
418
|
-
tryCatch as
|
|
632
|
+
getLogger as getLogger4,
|
|
633
|
+
tryCatch as tryCatch4
|
|
419
634
|
} from "@luckystack/core";
|
|
420
635
|
import { handleHttpApiRequest } from "@luckystack/api";
|
|
421
636
|
|
|
422
637
|
// src/sse.ts
|
|
423
|
-
import { getProjectConfig as
|
|
638
|
+
import { getProjectConfig as getProjectConfig8 } from "@luckystack/core";
|
|
424
639
|
var isExpectingEventStream = (acceptHeader) => {
|
|
425
640
|
if (!acceptHeader) return false;
|
|
426
641
|
const value = Array.isArray(acceptHeader) ? acceptHeader.join(",") : acceptHeader;
|
|
@@ -428,7 +643,7 @@ var isExpectingEventStream = (acceptHeader) => {
|
|
|
428
643
|
};
|
|
429
644
|
var queryRequestsStream = (queryString) => {
|
|
430
645
|
if (!queryString) return false;
|
|
431
|
-
const { queryParam, enabledValue } =
|
|
646
|
+
const { queryParam, enabledValue } = getProjectConfig8().http.stream;
|
|
432
647
|
const params = new URLSearchParams(queryString);
|
|
433
648
|
const value = params.get(queryParam);
|
|
434
649
|
return value === enabledValue || value === "1";
|
|
@@ -444,7 +659,7 @@ var initSseResponse = (res) => {
|
|
|
444
659
|
Connection: "keep-alive",
|
|
445
660
|
"X-Accel-Buffering": "no"
|
|
446
661
|
});
|
|
447
|
-
const connectedComment =
|
|
662
|
+
const connectedComment = getProjectConfig8().http.stream.connectedComment;
|
|
448
663
|
if (connectedComment) {
|
|
449
664
|
res.write(`${connectedComment}
|
|
450
665
|
|
|
@@ -457,6 +672,8 @@ var sendSseEvent = ({
|
|
|
457
672
|
data
|
|
458
673
|
}) => {
|
|
459
674
|
if (res.writableEnded) return;
|
|
675
|
+
const contentType = res.getHeader("Content-Type");
|
|
676
|
+
if (typeof contentType !== "string" || !contentType.includes("text/event-stream")) return;
|
|
460
677
|
res.write(`event: ${event}
|
|
461
678
|
`);
|
|
462
679
|
res.write(`data: ${JSON.stringify(data)}
|
|
@@ -464,6 +681,15 @@ var sendSseEvent = ({
|
|
|
464
681
|
`);
|
|
465
682
|
};
|
|
466
683
|
|
|
684
|
+
// src/httpRoutes/resolveRequesterIp.ts
|
|
685
|
+
import { getProjectConfig as getProjectConfig9, resolveClientIp as resolveClientIp2 } from "@luckystack/core";
|
|
686
|
+
var resolveRequesterIp = (req) => {
|
|
687
|
+
const trustProxy = getProjectConfig9().http.trustProxy;
|
|
688
|
+
const trustedProxyHopCount = getProjectConfig9().http.trustedProxyHopCount;
|
|
689
|
+
const rawRemoteAddress = req.socket.remoteAddress;
|
|
690
|
+
return rawRemoteAddress || trustProxy && (req.headers["x-forwarded-for"] || req.headers["x-real-ip"]) ? resolveClientIp2({ rawAddress: rawRemoteAddress, headers: req.headers, trustProxy, trustedProxyHopCount }) : void 0;
|
|
691
|
+
};
|
|
692
|
+
|
|
467
693
|
// src/httpRoutes/apiRoute.ts
|
|
468
694
|
var handleApiRoute = async ({
|
|
469
695
|
req,
|
|
@@ -479,11 +705,15 @@ var handleApiRoute = async ({
|
|
|
479
705
|
let streamClosed = false;
|
|
480
706
|
if (useHttpStream) {
|
|
481
707
|
initSseResponse(res);
|
|
482
|
-
|
|
708
|
+
const markClosed = () => {
|
|
483
709
|
streamClosed = true;
|
|
484
|
-
}
|
|
710
|
+
};
|
|
711
|
+
req.on("close", markClosed);
|
|
712
|
+
req.on("error", markClosed);
|
|
713
|
+
req.on("aborted", markClosed);
|
|
714
|
+
res.on("error", markClosed);
|
|
485
715
|
}
|
|
486
|
-
const [error, handled] = await
|
|
716
|
+
const [error, handled] = await tryCatch4(async () => {
|
|
487
717
|
const httpToken = extractTokenFromRequest(req);
|
|
488
718
|
const apiName = routePath.slice(5);
|
|
489
719
|
if (!apiName) {
|
|
@@ -505,11 +735,12 @@ var handleApiRoute = async ({
|
|
|
505
735
|
}
|
|
506
736
|
const apiData = typeof params === "object" ? { ...params } : {};
|
|
507
737
|
delete apiData.stream;
|
|
738
|
+
const requesterIp = resolveRequesterIp(req);
|
|
508
739
|
const result = await handleHttpApiRequest({
|
|
509
740
|
name: apiName,
|
|
510
741
|
data: apiData,
|
|
511
742
|
token: httpToken,
|
|
512
|
-
requesterIp
|
|
743
|
+
requesterIp,
|
|
513
744
|
xLanguageHeader: req.headers["x-language"],
|
|
514
745
|
acceptLanguageHeader: req.headers["accept-language"],
|
|
515
746
|
method,
|
|
@@ -531,7 +762,7 @@ var handleApiRoute = async ({
|
|
|
531
762
|
if (!error) {
|
|
532
763
|
return handled ?? true;
|
|
533
764
|
}
|
|
534
|
-
|
|
765
|
+
getLogger4().error("http-api: top-level handler threw", error, { routePath, method, requestId });
|
|
535
766
|
captureException(error, { routePath, method, requestId, source: "httpHandler.api" });
|
|
536
767
|
void dispatchHook3("apiError", {
|
|
537
768
|
route: routePath,
|
|
@@ -561,10 +792,9 @@ import {
|
|
|
561
792
|
captureException as captureException2,
|
|
562
793
|
dispatchHook as dispatchHook4,
|
|
563
794
|
extractTokenFromRequest as extractTokenFromRequest2,
|
|
564
|
-
getLogger as
|
|
565
|
-
tryCatch as
|
|
795
|
+
getLogger as getLogger5,
|
|
796
|
+
tryCatch as tryCatch5
|
|
566
797
|
} from "@luckystack/core";
|
|
567
|
-
import { handleHttpSyncRequest } from "@luckystack/sync";
|
|
568
798
|
var normalizeHttpSyncParams = (params) => {
|
|
569
799
|
const source = params ?? {};
|
|
570
800
|
const data = source.data && typeof source.data === "object" ? source.data : {};
|
|
@@ -585,15 +815,26 @@ var handleSyncRoute = async ({
|
|
|
585
815
|
requestId
|
|
586
816
|
}) => {
|
|
587
817
|
if (!routePath.startsWith("/sync/")) return false;
|
|
818
|
+
const sync = capabilities.sync ? await getSync() : null;
|
|
819
|
+
if (!sync) {
|
|
820
|
+
res.setHeader("Content-Type", "application/json");
|
|
821
|
+
res.writeHead(404);
|
|
822
|
+
res.end(JSON.stringify({ status: "error", errorCode: "sync.disabled", message: "sync.disabled" }));
|
|
823
|
+
return true;
|
|
824
|
+
}
|
|
588
825
|
const useHttpStream = shouldUseHttpStream({ acceptHeader: req.headers.accept, queryString });
|
|
589
826
|
let streamClosed = false;
|
|
590
827
|
if (useHttpStream) {
|
|
591
828
|
initSseResponse(res);
|
|
592
|
-
|
|
829
|
+
const markClosed = () => {
|
|
593
830
|
streamClosed = true;
|
|
594
|
-
}
|
|
831
|
+
};
|
|
832
|
+
req.on("close", markClosed);
|
|
833
|
+
req.on("error", markClosed);
|
|
834
|
+
req.on("aborted", markClosed);
|
|
835
|
+
res.on("error", markClosed);
|
|
595
836
|
}
|
|
596
|
-
const [error, handled] = await
|
|
837
|
+
const [error, handled] = await tryCatch5(async () => {
|
|
597
838
|
if (method !== "POST") {
|
|
598
839
|
const response = {
|
|
599
840
|
status: "error",
|
|
@@ -629,14 +870,15 @@ var handleSyncRoute = async ({
|
|
|
629
870
|
return true;
|
|
630
871
|
}
|
|
631
872
|
const syncParams = normalizeHttpSyncParams(params);
|
|
632
|
-
const
|
|
873
|
+
const requesterIp = resolveRequesterIp(req);
|
|
874
|
+
const result = await sync.handleHttpSyncRequest({
|
|
633
875
|
name: `sync/${syncName}`,
|
|
634
876
|
cb: syncParams.cb,
|
|
635
877
|
data: syncParams.data,
|
|
636
878
|
receiver: syncParams.receiver,
|
|
637
879
|
ignoreSelf: syncParams.ignoreSelf,
|
|
638
880
|
token: httpToken,
|
|
639
|
-
requesterIp
|
|
881
|
+
requesterIp,
|
|
640
882
|
xLanguageHeader: req.headers["x-language"],
|
|
641
883
|
acceptLanguageHeader: req.headers["accept-language"],
|
|
642
884
|
stream: useHttpStream ? (payload) => {
|
|
@@ -657,7 +899,7 @@ var handleSyncRoute = async ({
|
|
|
657
899
|
if (!error) {
|
|
658
900
|
return handled ?? true;
|
|
659
901
|
}
|
|
660
|
-
|
|
902
|
+
getLogger5().error("http-sync: top-level handler threw", error, { routePath, method, requestId });
|
|
661
903
|
captureException2(error, { routePath, method, requestId, source: "httpHandler.sync" });
|
|
662
904
|
void dispatchHook4("syncError", {
|
|
663
905
|
route: routePath,
|
|
@@ -682,7 +924,7 @@ var handleSyncRoute = async ({
|
|
|
682
924
|
};
|
|
683
925
|
|
|
684
926
|
// src/httpRoutes/customRoutes.ts
|
|
685
|
-
import { captureException as captureException3, getLogger as
|
|
927
|
+
import { captureException as captureException3, getLogger as getLogger6, tryCatch as tryCatch6 } from "@luckystack/core";
|
|
686
928
|
|
|
687
929
|
// src/customRoutesRegistry.ts
|
|
688
930
|
var handlers = [];
|
|
@@ -703,9 +945,9 @@ var clearCustomRoutes = () => {
|
|
|
703
945
|
|
|
704
946
|
// src/httpRoutes/customRoutes.ts
|
|
705
947
|
var runHandler = async (handler, req, res, ctx, source) => {
|
|
706
|
-
const [error, handled] = await
|
|
948
|
+
const [error, handled] = await tryCatch6(() => handler(req, res, ctx));
|
|
707
949
|
if (error) {
|
|
708
|
-
|
|
950
|
+
getLogger6().error(`${source} threw`, error, { routePath: ctx.routePath, method: ctx.method });
|
|
709
951
|
captureException3(error, { routePath: ctx.routePath, method: ctx.method, source });
|
|
710
952
|
if (!res.writableEnded) {
|
|
711
953
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
@@ -754,6 +996,7 @@ var handleCustomRoutes = async ({
|
|
|
754
996
|
// src/httpRoutes/staticRoutes.ts
|
|
755
997
|
import path from "path";
|
|
756
998
|
var KNOWN_STATIC_FILE_REGEX = /^\/(assets\/[a-zA-Z0-9_/-]+|[a-zA-Z0-9_-]+)\.(png|jpg|jpeg|gif|svg|html|css|js)$/;
|
|
999
|
+
var SERVE_DENYLIST_REGEX = /(^\/server\.js$)|(\.map$)/;
|
|
757
1000
|
var serveWithRewrittenUrl = async (serveFile, req, res, rewrittenUrl) => {
|
|
758
1001
|
const originalUrl = req.url;
|
|
759
1002
|
req.url = rewrittenUrl;
|
|
@@ -769,14 +1012,18 @@ var handleStaticAndSpaFallback = async ({
|
|
|
769
1012
|
routePath,
|
|
770
1013
|
options
|
|
771
1014
|
}) => {
|
|
772
|
-
if (
|
|
1015
|
+
if (SERVE_DENYLIST_REGEX.test(routePath)) {
|
|
1016
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1017
|
+
res.end("Not Found");
|
|
1018
|
+
return true;
|
|
1019
|
+
}
|
|
1020
|
+
if (routePath.startsWith("/assets/")) {
|
|
773
1021
|
if (!options.serveFile) {
|
|
774
1022
|
res.writeHead(404);
|
|
775
1023
|
res.end("Not Found");
|
|
776
1024
|
return true;
|
|
777
1025
|
}
|
|
778
|
-
|
|
779
|
-
await serveWithRewrittenUrl(options.serveFile, req, res, assetPath);
|
|
1026
|
+
await serveWithRewrittenUrl(options.serveFile, req, res, routePath);
|
|
780
1027
|
return true;
|
|
781
1028
|
}
|
|
782
1029
|
if (KNOWN_STATIC_FILE_REGEX.test(routePath)) {
|
|
@@ -802,21 +1049,10 @@ var handleStaticAndSpaFallback = async ({
|
|
|
802
1049
|
return true;
|
|
803
1050
|
};
|
|
804
1051
|
|
|
805
|
-
// src/originExemptRegistry.ts
|
|
806
|
-
var exemptPaths = [];
|
|
807
|
-
var registerOriginExemptPath = (matcher) => {
|
|
808
|
-
exemptPaths.push(matcher);
|
|
809
|
-
};
|
|
810
|
-
var getOriginExemptPaths = () => exemptPaths;
|
|
811
|
-
var clearOriginExemptPaths = () => {
|
|
812
|
-
exemptPaths.length = 0;
|
|
813
|
-
};
|
|
814
|
-
var isOriginExemptPath = (routePath) => exemptPaths.some((matcher) => routePath.startsWith(matcher.pathPrefix));
|
|
815
|
-
|
|
816
1052
|
// src/httpHandler.ts
|
|
817
1053
|
var buildSessionCookieOptions = (sessionExpiryDays, secure, http2) => `HttpOnly; SameSite=${http2.sessionCookieSameSite}; Path=${http2.sessionCookiePath}; Max-Age=${60 * 60 * 24 * sessionExpiryDays}; ${secure ? "Secure;" : ""}`;
|
|
818
1054
|
var setSecurityHeaders = (req, res, origin) => {
|
|
819
|
-
const { cors, securityHeaders } =
|
|
1055
|
+
const { cors, securityHeaders } = getProjectConfig10().http;
|
|
820
1056
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
821
1057
|
res.setHeader("Access-Control-Allow-Methods", cors.allowedMethods);
|
|
822
1058
|
res.setHeader("Access-Control-Allow-Headers", cors.allowedHeaders);
|
|
@@ -830,15 +1066,16 @@ var setSecurityHeaders = (req, res, origin) => {
|
|
|
830
1066
|
res.setHeader("X-Content-Type-Options", securityHeaders.contentTypeOptions);
|
|
831
1067
|
const builder = getSecurityHeadersBuilder();
|
|
832
1068
|
if (builder) {
|
|
833
|
-
|
|
1069
|
+
const [error] = tryCatchSync(() => {
|
|
834
1070
|
const custom = builder(req);
|
|
835
1071
|
if (custom) {
|
|
836
1072
|
for (const [name, value] of Object.entries(custom)) {
|
|
837
1073
|
res.setHeader(name, value);
|
|
838
1074
|
}
|
|
839
1075
|
}
|
|
840
|
-
}
|
|
841
|
-
|
|
1076
|
+
});
|
|
1077
|
+
if (error) {
|
|
1078
|
+
getLogger7().warn("securityHeadersBuilder threw \u2014 falling back to defaults", { err: error });
|
|
842
1079
|
}
|
|
843
1080
|
}
|
|
844
1081
|
};
|
|
@@ -850,6 +1087,7 @@ var PRE_PARAMS_ROUTES = [
|
|
|
850
1087
|
handleHealthRoute,
|
|
851
1088
|
handleTestResetRoute,
|
|
852
1089
|
handleAuthProvidersRoute,
|
|
1090
|
+
handleAuthLogoutRoute,
|
|
853
1091
|
handlePreParamsCustomRoutes
|
|
854
1092
|
];
|
|
855
1093
|
var POST_PARAMS_ROUTES = [
|
|
@@ -898,7 +1136,7 @@ var refreshSessionCookieIfPresent = async ({
|
|
|
898
1136
|
}) => {
|
|
899
1137
|
const hasTokenCookie = hasCookie(req.headers.cookie, sessionCookieName);
|
|
900
1138
|
if (!hasTokenCookie || !token) return;
|
|
901
|
-
const currentSession = await
|
|
1139
|
+
const currentSession = await readSession3(token);
|
|
902
1140
|
if (currentSession?.id) {
|
|
903
1141
|
res.setHeader("Set-Cookie", `${sessionCookieName}=${token}; ${sessionCookieOptions}`);
|
|
904
1142
|
}
|
|
@@ -916,25 +1154,28 @@ var parseRequestParams = async ({
|
|
|
916
1154
|
if (res.writableEnded) return null;
|
|
917
1155
|
if (params && typeof params === "object" && Object.keys(params).length > 0) {
|
|
918
1156
|
if (shouldLogDev) {
|
|
919
|
-
|
|
1157
|
+
getLogger7().debug(`[${requestId}] ${method} ${routePath}`, {
|
|
920
1158
|
params: sanitizeForLog(params)
|
|
921
1159
|
});
|
|
922
1160
|
}
|
|
923
1161
|
} else {
|
|
924
1162
|
if (shouldLogDev) {
|
|
925
|
-
|
|
1163
|
+
getLogger7().debug(`[${requestId}] ${method} ${routePath}`);
|
|
926
1164
|
}
|
|
927
1165
|
params = {};
|
|
928
1166
|
}
|
|
929
1167
|
return params;
|
|
930
1168
|
};
|
|
931
1169
|
var handleHttpRequest = async (req, res, options) => {
|
|
932
|
-
const config =
|
|
1170
|
+
const config = getProjectConfig10();
|
|
933
1171
|
const shouldLogDev = config.logging.devLogs;
|
|
934
1172
|
const sessionCookieName = config.http.sessionCookieName;
|
|
935
1173
|
const sessionCookieOptions = buildSessionCookieOptions(
|
|
936
1174
|
config.session.expiryDays,
|
|
937
|
-
|
|
1175
|
+
//? Honor the explicit `http.sessionCookieSecure` override (CORE-39), else the
|
|
1176
|
+
//? `SECURE` env flag — shared with the OAuth state cookie via
|
|
1177
|
+
//? `resolveCookieSecure` so the two can never drift (WAVE4).
|
|
1178
|
+
resolveCookieSecure(config.http.sessionCookieSecure, process.env.SECURE),
|
|
938
1179
|
config.http
|
|
939
1180
|
);
|
|
940
1181
|
const url = req.url ?? "/";
|
|
@@ -1015,6 +1256,8 @@ import {
|
|
|
1015
1256
|
allowedOrigin as allowedOrigin2,
|
|
1016
1257
|
applySocketMiddlewares,
|
|
1017
1258
|
attachSocketRedisAdapter,
|
|
1259
|
+
formatRoomName,
|
|
1260
|
+
redis as redis3,
|
|
1018
1261
|
setIoInstance,
|
|
1019
1262
|
socketEventNames,
|
|
1020
1263
|
buildJoinRoomResponseEventName,
|
|
@@ -1022,27 +1265,21 @@ import {
|
|
|
1022
1265
|
buildGetJoinedRoomsResponseEventName,
|
|
1023
1266
|
extractLanguageFromHeader,
|
|
1024
1267
|
extractTokenFromSocket,
|
|
1025
|
-
getLogger as
|
|
1026
|
-
getProjectConfig as
|
|
1268
|
+
getLogger as getLogger8,
|
|
1269
|
+
getProjectConfig as getProjectConfig11,
|
|
1027
1270
|
dispatchHook as dispatchHook6,
|
|
1028
1271
|
normalizeErrorResponse,
|
|
1029
|
-
|
|
1272
|
+
readSession as readSession4,
|
|
1273
|
+
writeSession,
|
|
1274
|
+
tryCatch as tryCatch7
|
|
1030
1275
|
} from "@luckystack/core";
|
|
1031
1276
|
import { handleApiRequest } from "@luckystack/api";
|
|
1032
|
-
import { handleSyncRequest } from "@luckystack/sync";
|
|
1033
|
-
import { getSession as getSession4, saveSession } from "@luckystack/login";
|
|
1034
|
-
import {
|
|
1035
|
-
initActivityBroadcaster,
|
|
1036
|
-
socketConnected,
|
|
1037
|
-
socketDisconnecting,
|
|
1038
|
-
socketLeaveRoom
|
|
1039
|
-
} from "@luckystack/presence";
|
|
1040
1277
|
var sessionLocks = /* @__PURE__ */ new Map();
|
|
1041
1278
|
var withSessionLock = async (token, fn) => {
|
|
1042
1279
|
const prev = sessionLocks.get(token) ?? Promise.resolve();
|
|
1043
1280
|
const next = prev.then(fn, fn);
|
|
1044
1281
|
sessionLocks.set(token, next);
|
|
1045
|
-
await
|
|
1282
|
+
await tryCatch7(() => next);
|
|
1046
1283
|
if (sessionLocks.get(token) === next) sessionLocks.delete(token);
|
|
1047
1284
|
};
|
|
1048
1285
|
var getVisibleSocketRooms = (socket, token) => {
|
|
@@ -1058,15 +1295,290 @@ var sanitizeSessionRoomKeys = (session) => {
|
|
|
1058
1295
|
const { code: _legacyCode, codes: _legacyCodes, ...sanitizedSession } = session;
|
|
1059
1296
|
return sanitizedSession;
|
|
1060
1297
|
};
|
|
1298
|
+
var validateRoomRequest = (socket, responseIndex, token, group, preferredLocale, buildResponseEventName) => {
|
|
1299
|
+
if (typeof responseIndex !== "number") return false;
|
|
1300
|
+
if (!token) {
|
|
1301
|
+
socket.emit(buildResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1302
|
+
response: { status: "error", errorCode: "auth.required" },
|
|
1303
|
+
preferredLocale
|
|
1304
|
+
}));
|
|
1305
|
+
return false;
|
|
1306
|
+
}
|
|
1307
|
+
if (!group) {
|
|
1308
|
+
socket.emit(buildResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1309
|
+
response: { status: "error", errorCode: "room.invalid" },
|
|
1310
|
+
preferredLocale
|
|
1311
|
+
}));
|
|
1312
|
+
return false;
|
|
1313
|
+
}
|
|
1314
|
+
return true;
|
|
1315
|
+
};
|
|
1316
|
+
var executeRoomMutation = async (opts) => {
|
|
1317
|
+
const {
|
|
1318
|
+
socket,
|
|
1319
|
+
token,
|
|
1320
|
+
group,
|
|
1321
|
+
responseIndex,
|
|
1322
|
+
preferredLocale,
|
|
1323
|
+
shouldLogDev,
|
|
1324
|
+
buildResponseEventName,
|
|
1325
|
+
preHook,
|
|
1326
|
+
postHook,
|
|
1327
|
+
blockedErrorCode,
|
|
1328
|
+
logVerb,
|
|
1329
|
+
mutate
|
|
1330
|
+
} = opts;
|
|
1331
|
+
const session = await readSession4(token);
|
|
1332
|
+
if (!session) {
|
|
1333
|
+
socket.emit(buildResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1334
|
+
response: { status: "error", errorCode: "session.notFound" },
|
|
1335
|
+
preferredLocale
|
|
1336
|
+
}));
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
const preResult = await dispatchHook6(preHook, { token, room: group });
|
|
1340
|
+
if (preResult.stopped) {
|
|
1341
|
+
socket.emit(buildResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1342
|
+
response: {
|
|
1343
|
+
status: "error",
|
|
1344
|
+
errorCode: preResult.signal.errorCode || blockedErrorCode
|
|
1345
|
+
},
|
|
1346
|
+
preferredLocale,
|
|
1347
|
+
userLanguage: session.language
|
|
1348
|
+
}));
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
const existingRoomCodes = getSessionRoomCodes(session);
|
|
1352
|
+
const roomPurpose = preHook === "preRoomJoin" ? "join" : "leave";
|
|
1353
|
+
const physicalRoom = formatRoomName(group, { purpose: roomPurpose, userId: session.id });
|
|
1354
|
+
const nextRoomCodes = await mutate(socket, physicalRoom, group, existingRoomCodes);
|
|
1355
|
+
const sanitizedSession = sanitizeSessionRoomKeys(session);
|
|
1356
|
+
await writeSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes });
|
|
1357
|
+
const visibleRooms = getVisibleSocketRooms(socket, token);
|
|
1358
|
+
socket.emit(buildResponseEventName(responseIndex), { rooms: visibleRooms });
|
|
1359
|
+
if (shouldLogDev) {
|
|
1360
|
+
getLogger8().debug(`Socket ${socket.id} ${logVerb} group ${group}`);
|
|
1361
|
+
}
|
|
1362
|
+
void dispatchHook6(postHook, { token, room: group, allRooms: visibleRooms });
|
|
1363
|
+
};
|
|
1364
|
+
var registerApiAndSyncEvents = (ctx) => {
|
|
1365
|
+
const { socket, token } = ctx;
|
|
1366
|
+
socket.on(socketEventNames.apiRequest, (msg) => {
|
|
1367
|
+
void handleApiRequest({ msg, socket, token });
|
|
1368
|
+
});
|
|
1369
|
+
if (capabilities.sync) {
|
|
1370
|
+
socket.on(socketEventNames.sync, (msg) => {
|
|
1371
|
+
void (async () => {
|
|
1372
|
+
const sync = await getSync();
|
|
1373
|
+
if (sync) await sync.handleSyncRequest({ msg, socket, token });
|
|
1374
|
+
})();
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
var registerCancellationEvents = (ctx) => {
|
|
1379
|
+
const { socket } = ctx;
|
|
1380
|
+
socket.on(socketEventNames.syncCancel, (data) => {
|
|
1381
|
+
const cb = typeof data.cb === "string" ? data.cb : null;
|
|
1382
|
+
if (!cb) return;
|
|
1383
|
+
abortSyncByCb(socket.id, cb);
|
|
1384
|
+
});
|
|
1385
|
+
socket.on(socketEventNames.apiCancel, (data) => {
|
|
1386
|
+
const responseIndex = data.responseIndex;
|
|
1387
|
+
if (typeof responseIndex !== "number" && typeof responseIndex !== "string") return;
|
|
1388
|
+
abortApiByResponseIndex(socket.id, responseIndex);
|
|
1389
|
+
});
|
|
1390
|
+
};
|
|
1391
|
+
var registerRoomEvents = (ctx) => {
|
|
1392
|
+
const { socket, token, preferredLocale, shouldLogDev } = ctx;
|
|
1393
|
+
socket.on(socketEventNames.joinRoom, (data) => {
|
|
1394
|
+
const group = typeof data.group === "string" ? data.group.trim() : "";
|
|
1395
|
+
const responseIndex = data.responseIndex;
|
|
1396
|
+
if (!validateRoomRequest(socket, responseIndex, token, group, preferredLocale, buildJoinRoomResponseEventName)) return;
|
|
1397
|
+
if (!token) return;
|
|
1398
|
+
void withSessionLock(token, async () => {
|
|
1399
|
+
await executeRoomMutation({
|
|
1400
|
+
socket,
|
|
1401
|
+
token,
|
|
1402
|
+
group,
|
|
1403
|
+
responseIndex,
|
|
1404
|
+
preferredLocale,
|
|
1405
|
+
shouldLogDev,
|
|
1406
|
+
buildResponseEventName: buildJoinRoomResponseEventName,
|
|
1407
|
+
preHook: "preRoomJoin",
|
|
1408
|
+
postHook: "postRoomJoin",
|
|
1409
|
+
blockedErrorCode: "room.joinBlocked",
|
|
1410
|
+
logVerb: "joined",
|
|
1411
|
+
mutate: async (sock, physicalRoom, rawGroup, existingCodes) => {
|
|
1412
|
+
const nextCodes = [.../* @__PURE__ */ new Set([...existingCodes, rawGroup])];
|
|
1413
|
+
await sock.join(physicalRoom);
|
|
1414
|
+
return nextCodes;
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
});
|
|
1418
|
+
});
|
|
1419
|
+
socket.on(socketEventNames.leaveRoom, (data) => {
|
|
1420
|
+
const group = typeof data.group === "string" ? data.group.trim() : "";
|
|
1421
|
+
const responseIndex = data.responseIndex;
|
|
1422
|
+
if (!validateRoomRequest(socket, responseIndex, token, group, preferredLocale, buildLeaveRoomResponseEventName)) return;
|
|
1423
|
+
if (!token) return;
|
|
1424
|
+
void withSessionLock(token, async () => {
|
|
1425
|
+
await executeRoomMutation({
|
|
1426
|
+
socket,
|
|
1427
|
+
token,
|
|
1428
|
+
group,
|
|
1429
|
+
responseIndex,
|
|
1430
|
+
preferredLocale,
|
|
1431
|
+
shouldLogDev,
|
|
1432
|
+
buildResponseEventName: buildLeaveRoomResponseEventName,
|
|
1433
|
+
preHook: "preRoomLeave",
|
|
1434
|
+
postHook: "postRoomLeave",
|
|
1435
|
+
blockedErrorCode: "room.leaveBlocked",
|
|
1436
|
+
logVerb: "left",
|
|
1437
|
+
mutate: async (sock, physicalRoom, rawGroup, existingCodes) => {
|
|
1438
|
+
const nextCodes = existingCodes.filter((c) => c !== rawGroup);
|
|
1439
|
+
await sock.leave(physicalRoom);
|
|
1440
|
+
return nextCodes;
|
|
1441
|
+
}
|
|
1442
|
+
});
|
|
1443
|
+
});
|
|
1444
|
+
});
|
|
1445
|
+
socket.on(socketEventNames.getJoinedRooms, (data) => {
|
|
1446
|
+
const responseIndex = data.responseIndex;
|
|
1447
|
+
if (typeof responseIndex !== "number") return;
|
|
1448
|
+
if (!token) {
|
|
1449
|
+
socket.emit(buildGetJoinedRoomsResponseEventName(responseIndex), {
|
|
1450
|
+
...normalizeErrorResponse({
|
|
1451
|
+
response: { status: "error", errorCode: "auth.required" },
|
|
1452
|
+
preferredLocale
|
|
1453
|
+
}),
|
|
1454
|
+
rooms: []
|
|
1455
|
+
});
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
socket.emit(buildGetJoinedRoomsResponseEventName(responseIndex), {
|
|
1459
|
+
rooms: getVisibleSocketRooms(socket, token)
|
|
1460
|
+
});
|
|
1461
|
+
});
|
|
1462
|
+
};
|
|
1463
|
+
var registerDisconnectEvent = (ctx) => {
|
|
1464
|
+
const { socket, token, activityBroadcasterEnabled, shouldLogDev } = ctx;
|
|
1465
|
+
socket.on(socketEventNames.disconnect, (reason) => {
|
|
1466
|
+
abortAllForSocket(socket.id);
|
|
1467
|
+
void dispatchHook6("onSocketDisconnect", { socketId: socket.id, token, reason });
|
|
1468
|
+
if (activityBroadcasterEnabled) {
|
|
1469
|
+
void getPresence().then((presence) => {
|
|
1470
|
+
presence?.clearActivity(socket.id);
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
if (activityBroadcasterEnabled && token) {
|
|
1474
|
+
void (async () => {
|
|
1475
|
+
const presence = await getPresence();
|
|
1476
|
+
if (presence) presence.socketDisconnecting({ token, socket, reason });
|
|
1477
|
+
})();
|
|
1478
|
+
} else {
|
|
1479
|
+
if (!token) return;
|
|
1480
|
+
if (shouldLogDev) {
|
|
1481
|
+
getLogger8().debug(`user disconnected`, { reason });
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
};
|
|
1486
|
+
var registerUpdateLocationEvent = (ctx) => {
|
|
1487
|
+
const { socket, token, activityBroadcasterEnabled, locationProviderEnabled, shouldLogDev } = ctx;
|
|
1488
|
+
socket.on(
|
|
1489
|
+
socketEventNames.updateLocation,
|
|
1490
|
+
(newLocation) => {
|
|
1491
|
+
if (!token) return;
|
|
1492
|
+
if (!locationProviderEnabled) return;
|
|
1493
|
+
if (shouldLogDev) {
|
|
1494
|
+
getLogger8().debug("updating location", { pathName: newLocation.pathName });
|
|
1495
|
+
}
|
|
1496
|
+
void withSessionLock(token, async () => {
|
|
1497
|
+
let returnedUser = null;
|
|
1498
|
+
if (activityBroadcasterEnabled) {
|
|
1499
|
+
const presence = await getPresence();
|
|
1500
|
+
if (presence) {
|
|
1501
|
+
returnedUser = await presence.socketLeaveRoom({ token, socket, newPath: newLocation.pathName });
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
const user = returnedUser ?? await readSession4(token);
|
|
1505
|
+
if (!user) return;
|
|
1506
|
+
const extendedUser = user;
|
|
1507
|
+
const oldLocation = extendedUser.location;
|
|
1508
|
+
const sanitizedUser = sanitizeSessionRoomKeys({ ...extendedUser, location: newLocation });
|
|
1509
|
+
await writeSession(token, sanitizedUser);
|
|
1510
|
+
void dispatchHook6("onLocationUpdate", { token, oldLocation, newLocation });
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
);
|
|
1514
|
+
};
|
|
1515
|
+
var registerActivityEvents = (ctx) => {
|
|
1516
|
+
const { socket, token, activityBroadcasterEnabled, io } = ctx;
|
|
1517
|
+
if (activityBroadcasterEnabled && token) {
|
|
1518
|
+
void (async () => {
|
|
1519
|
+
const presence = await getPresence();
|
|
1520
|
+
if (presence) presence.initActivityBroadcaster({ socket, token });
|
|
1521
|
+
})();
|
|
1522
|
+
}
|
|
1523
|
+
if (activityBroadcasterEnabled) {
|
|
1524
|
+
void (async () => {
|
|
1525
|
+
const presence = await getPresence();
|
|
1526
|
+
if (!presence) return;
|
|
1527
|
+
presence.startActivitySampler({ io });
|
|
1528
|
+
presence.recordActivity(socket.id);
|
|
1529
|
+
})();
|
|
1530
|
+
socket.on(socketEventNames.activity, () => {
|
|
1531
|
+
void getPresence().then((presence) => {
|
|
1532
|
+
presence?.recordActivity(socket.id);
|
|
1533
|
+
});
|
|
1534
|
+
});
|
|
1535
|
+
socket.on(socketEventNames.intentionalReconnect, () => {
|
|
1536
|
+
void getPresence().then((presence) => {
|
|
1537
|
+
presence?.recordActivity(socket.id);
|
|
1538
|
+
});
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
};
|
|
1542
|
+
var rejoinPersistedRooms = (ctx) => {
|
|
1543
|
+
const { socket, token, shouldLogDev } = ctx;
|
|
1544
|
+
if (!token) return;
|
|
1545
|
+
void (async () => {
|
|
1546
|
+
const [rejoinError, codes] = await tryCatch7(async () => {
|
|
1547
|
+
await socket.join(token);
|
|
1548
|
+
const session = await readSession4(token);
|
|
1549
|
+
const roomCodes = session ? getSessionRoomCodes(session) : [];
|
|
1550
|
+
const userId = session?.id ?? null;
|
|
1551
|
+
for (const roomCode of roomCodes) {
|
|
1552
|
+
await socket.join(formatRoomName(roomCode, { purpose: "join", userId }));
|
|
1553
|
+
}
|
|
1554
|
+
return roomCodes;
|
|
1555
|
+
});
|
|
1556
|
+
if (rejoinError) {
|
|
1557
|
+
getLogger8().warn(`socket room rejoin failed for ${socket.id}`, { error: rejoinError.message });
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (shouldLogDev) {
|
|
1561
|
+
getLogger8().debug(`socket ${socket.id} (re)joined rooms: ${(codes ?? []).join(", ") || "(none)"}`);
|
|
1562
|
+
}
|
|
1563
|
+
})();
|
|
1564
|
+
};
|
|
1061
1565
|
var loadSocket = (httpServer, options = {}) => {
|
|
1062
|
-
const config =
|
|
1566
|
+
const config = getProjectConfig11();
|
|
1063
1567
|
const shouldLogDev = config.logging.devLogs;
|
|
1064
1568
|
const shouldLogSocketStartup = config.logging.socketStartup;
|
|
1065
1569
|
const io = new SocketIOServer(httpServer, {
|
|
1066
1570
|
cors: {
|
|
1067
1571
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
1068
1572
|
origin: (origin, callback) => {
|
|
1069
|
-
if (!origin
|
|
1573
|
+
if (!origin) {
|
|
1574
|
+
if (config.http.cors.allowOriginless) {
|
|
1575
|
+
callback(null, true);
|
|
1576
|
+
} else {
|
|
1577
|
+
callback(new Error("Origin-less WebSocket upgrades are not allowed"));
|
|
1578
|
+
}
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
if (allowedOrigin2(origin)) {
|
|
1070
1582
|
callback(null, true);
|
|
1071
1583
|
} else {
|
|
1072
1584
|
callback(new Error("Not allowed by CORS"));
|
|
@@ -1080,225 +1592,53 @@ var loadSocket = (httpServer, options = {}) => {
|
|
|
1080
1592
|
});
|
|
1081
1593
|
setIoInstance(io);
|
|
1082
1594
|
applySocketMiddlewares(io);
|
|
1083
|
-
|
|
1595
|
+
const pubClient = redis3.duplicate();
|
|
1596
|
+
const subClient = redis3.duplicate();
|
|
1597
|
+
attachSocketRedisAdapter(io, { pubClient, subClient });
|
|
1084
1598
|
if (shouldLogSocketStartup) {
|
|
1085
|
-
|
|
1599
|
+
getLogger8().info("SocketIO server initialized (redis adapter attached)");
|
|
1086
1600
|
}
|
|
1087
1601
|
io.on(socketEventNames.connect, (socket) => {
|
|
1088
1602
|
const token = extractTokenFromSocket(socket);
|
|
1089
1603
|
const activityBroadcasterEnabled = config.socketActivityBroadcaster ?? false;
|
|
1090
1604
|
const locationProviderEnabled = config.locationProviderEnabled ?? false;
|
|
1091
1605
|
const preferredLocale = extractLanguageFromHeader(socket.handshake.headers["x-language"]) || extractLanguageFromHeader(socket.handshake.headers["accept-language"]) || void 0;
|
|
1092
|
-
if (token) {
|
|
1093
|
-
void
|
|
1606
|
+
if (token && capabilities.presence) {
|
|
1607
|
+
void (async () => {
|
|
1608
|
+
const presence = await getPresence();
|
|
1609
|
+
if (presence) await presence.socketConnected({ token, io });
|
|
1610
|
+
})();
|
|
1094
1611
|
}
|
|
1095
1612
|
void dispatchHook6("onSocketConnect", {
|
|
1096
1613
|
socketId: socket.id,
|
|
1097
1614
|
token,
|
|
1098
1615
|
ip: socket.handshake.address
|
|
1099
1616
|
});
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
socket.on(socketEventNames.joinRoom, (data) => {
|
|
1117
|
-
const group = typeof data.group === "string" ? data.group.trim() : "";
|
|
1118
|
-
const responseIndex = data.responseIndex;
|
|
1119
|
-
if (typeof responseIndex !== "number") return;
|
|
1120
|
-
if (!token) {
|
|
1121
|
-
socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1122
|
-
response: { status: "error", errorCode: "auth.required" },
|
|
1123
|
-
preferredLocale
|
|
1124
|
-
}));
|
|
1125
|
-
return;
|
|
1126
|
-
}
|
|
1127
|
-
if (!group) {
|
|
1128
|
-
socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1129
|
-
response: { status: "error", errorCode: "room.invalid" },
|
|
1130
|
-
preferredLocale
|
|
1131
|
-
}));
|
|
1132
|
-
return;
|
|
1133
|
-
}
|
|
1134
|
-
void withSessionLock(token, async () => {
|
|
1135
|
-
const session = await getSession4(token);
|
|
1136
|
-
if (!session) {
|
|
1137
|
-
socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1138
|
-
response: { status: "error", errorCode: "session.notFound" },
|
|
1139
|
-
preferredLocale
|
|
1140
|
-
}));
|
|
1141
|
-
return;
|
|
1142
|
-
}
|
|
1143
|
-
const preResult = await dispatchHook6("preRoomJoin", { token, room: group });
|
|
1144
|
-
if (preResult.stopped) {
|
|
1145
|
-
socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1146
|
-
response: {
|
|
1147
|
-
status: "error",
|
|
1148
|
-
errorCode: preResult.signal.errorCode || "room.joinBlocked"
|
|
1149
|
-
},
|
|
1150
|
-
preferredLocale,
|
|
1151
|
-
userLanguage: session.language
|
|
1152
|
-
}));
|
|
1153
|
-
return;
|
|
1154
|
-
}
|
|
1155
|
-
const existingRoomCodes = getSessionRoomCodes(session);
|
|
1156
|
-
const nextRoomCodes = [.../* @__PURE__ */ new Set([...existingRoomCodes, group])];
|
|
1157
|
-
await socket.join(group);
|
|
1158
|
-
const sanitizedSession = sanitizeSessionRoomKeys(session);
|
|
1159
|
-
await saveSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes });
|
|
1160
|
-
const visibleRooms = getVisibleSocketRooms(socket, token);
|
|
1161
|
-
socket.emit(buildJoinRoomResponseEventName(responseIndex), { rooms: visibleRooms });
|
|
1162
|
-
if (shouldLogDev) {
|
|
1163
|
-
getLogger7().debug(`Socket ${socket.id} joined group ${group}`);
|
|
1164
|
-
}
|
|
1165
|
-
void dispatchHook6("postRoomJoin", { token, room: group, allRooms: visibleRooms });
|
|
1166
|
-
});
|
|
1167
|
-
});
|
|
1168
|
-
socket.on(socketEventNames.leaveRoom, (data) => {
|
|
1169
|
-
const group = typeof data.group === "string" ? data.group.trim() : "";
|
|
1170
|
-
const responseIndex = data.responseIndex;
|
|
1171
|
-
if (typeof responseIndex !== "number") return;
|
|
1172
|
-
if (!token) {
|
|
1173
|
-
socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1174
|
-
response: { status: "error", errorCode: "auth.required" },
|
|
1175
|
-
preferredLocale
|
|
1176
|
-
}));
|
|
1177
|
-
return;
|
|
1178
|
-
}
|
|
1179
|
-
if (!group) {
|
|
1180
|
-
socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1181
|
-
response: { status: "error", errorCode: "room.invalid" },
|
|
1182
|
-
preferredLocale
|
|
1183
|
-
}));
|
|
1184
|
-
return;
|
|
1185
|
-
}
|
|
1186
|
-
void withSessionLock(token, async () => {
|
|
1187
|
-
const session = await getSession4(token);
|
|
1188
|
-
if (!session) {
|
|
1189
|
-
socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1190
|
-
response: { status: "error", errorCode: "session.notFound" },
|
|
1191
|
-
preferredLocale
|
|
1192
|
-
}));
|
|
1193
|
-
return;
|
|
1194
|
-
}
|
|
1195
|
-
const preResult = await dispatchHook6("preRoomLeave", { token, room: group });
|
|
1196
|
-
if (preResult.stopped) {
|
|
1197
|
-
socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1198
|
-
response: {
|
|
1199
|
-
status: "error",
|
|
1200
|
-
errorCode: preResult.signal.errorCode || "room.leaveBlocked"
|
|
1201
|
-
},
|
|
1202
|
-
preferredLocale,
|
|
1203
|
-
userLanguage: session.language
|
|
1204
|
-
}));
|
|
1205
|
-
return;
|
|
1206
|
-
}
|
|
1207
|
-
const existingRoomCodes = getSessionRoomCodes(session);
|
|
1208
|
-
const nextRoomCodes = existingRoomCodes.filter((roomCode) => roomCode !== group);
|
|
1209
|
-
await socket.leave(group);
|
|
1210
|
-
const sanitizedSession = sanitizeSessionRoomKeys(session);
|
|
1211
|
-
await saveSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes });
|
|
1212
|
-
const visibleRooms = getVisibleSocketRooms(socket, token);
|
|
1213
|
-
socket.emit(buildLeaveRoomResponseEventName(responseIndex), { rooms: visibleRooms });
|
|
1214
|
-
if (shouldLogDev) {
|
|
1215
|
-
getLogger7().debug(`Socket ${socket.id} left group ${group}`);
|
|
1216
|
-
}
|
|
1217
|
-
void dispatchHook6("postRoomLeave", { token, room: group, allRooms: visibleRooms });
|
|
1218
|
-
});
|
|
1219
|
-
});
|
|
1220
|
-
socket.on(socketEventNames.getJoinedRooms, (data) => {
|
|
1221
|
-
const responseIndex = data.responseIndex;
|
|
1222
|
-
if (typeof responseIndex !== "number") return;
|
|
1223
|
-
if (!token) {
|
|
1224
|
-
socket.emit(buildGetJoinedRoomsResponseEventName(responseIndex), {
|
|
1225
|
-
...normalizeErrorResponse({
|
|
1226
|
-
response: { status: "error", errorCode: "auth.required" },
|
|
1227
|
-
preferredLocale
|
|
1228
|
-
}),
|
|
1229
|
-
rooms: []
|
|
1230
|
-
});
|
|
1231
|
-
return;
|
|
1232
|
-
}
|
|
1233
|
-
socket.emit(buildGetJoinedRoomsResponseEventName(responseIndex), {
|
|
1234
|
-
rooms: getVisibleSocketRooms(socket, token)
|
|
1235
|
-
});
|
|
1236
|
-
});
|
|
1237
|
-
socket.on(socketEventNames.disconnect, (reason) => {
|
|
1238
|
-
abortAllForSocket(socket.id);
|
|
1239
|
-
void dispatchHook6("onSocketDisconnect", { socketId: socket.id, token, reason });
|
|
1240
|
-
if (activityBroadcasterEnabled && token) {
|
|
1241
|
-
void socketDisconnecting({ token, socket, reason });
|
|
1242
|
-
} else {
|
|
1243
|
-
if (!token) return;
|
|
1244
|
-
if (shouldLogDev) {
|
|
1245
|
-
getLogger7().debug(`user disconnected`, { reason });
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
});
|
|
1249
|
-
socket.on(
|
|
1250
|
-
socketEventNames.updateLocation,
|
|
1251
|
-
(newLocation) => {
|
|
1252
|
-
if (!token) return;
|
|
1253
|
-
if (!locationProviderEnabled) return;
|
|
1254
|
-
if (shouldLogDev) {
|
|
1255
|
-
getLogger7().debug("updating location", { pathName: newLocation.pathName });
|
|
1256
|
-
}
|
|
1257
|
-
void withSessionLock(token, async () => {
|
|
1258
|
-
let returnedUser = null;
|
|
1259
|
-
if (activityBroadcasterEnabled) {
|
|
1260
|
-
returnedUser = await socketLeaveRoom({ token, socket, newPath: newLocation.pathName });
|
|
1261
|
-
}
|
|
1262
|
-
const user = returnedUser ?? await getSession4(token);
|
|
1263
|
-
if (!user) return;
|
|
1264
|
-
const extendedUser = user;
|
|
1265
|
-
const oldLocation = extendedUser.location;
|
|
1266
|
-
extendedUser.location = newLocation;
|
|
1267
|
-
await saveSession(token, user);
|
|
1268
|
-
void dispatchHook6("onLocationUpdate", { token, oldLocation, newLocation });
|
|
1269
|
-
});
|
|
1270
|
-
}
|
|
1271
|
-
);
|
|
1272
|
-
if (activityBroadcasterEnabled && token) {
|
|
1273
|
-
initActivityBroadcaster({ socket, token });
|
|
1274
|
-
}
|
|
1275
|
-
if (token) {
|
|
1276
|
-
void (async () => {
|
|
1277
|
-
const [rejoinError, codes] = await tryCatch6(async () => {
|
|
1278
|
-
await socket.join(token);
|
|
1279
|
-
const session = await getSession4(token);
|
|
1280
|
-
const roomCodes = session ? getSessionRoomCodes(session) : [];
|
|
1281
|
-
for (const roomCode of roomCodes) {
|
|
1282
|
-
await socket.join(roomCode);
|
|
1283
|
-
}
|
|
1284
|
-
return roomCodes;
|
|
1285
|
-
});
|
|
1286
|
-
if (rejoinError) {
|
|
1287
|
-
getLogger7().warn(`socket room rejoin failed for ${socket.id}`, { error: rejoinError.message });
|
|
1288
|
-
return;
|
|
1289
|
-
}
|
|
1290
|
-
if (shouldLogDev) {
|
|
1291
|
-
getLogger7().debug(`socket ${socket.id} (re)joined rooms: ${(codes ?? []).join(", ") || "(none)"}`);
|
|
1292
|
-
}
|
|
1293
|
-
})();
|
|
1294
|
-
}
|
|
1617
|
+
const ctx = {
|
|
1618
|
+
socket,
|
|
1619
|
+
token,
|
|
1620
|
+
preferredLocale,
|
|
1621
|
+
activityBroadcasterEnabled,
|
|
1622
|
+
locationProviderEnabled,
|
|
1623
|
+
shouldLogDev,
|
|
1624
|
+
io
|
|
1625
|
+
};
|
|
1626
|
+
registerApiAndSyncEvents(ctx);
|
|
1627
|
+
registerCancellationEvents(ctx);
|
|
1628
|
+
registerRoomEvents(ctx);
|
|
1629
|
+
registerDisconnectEvent(ctx);
|
|
1630
|
+
registerUpdateLocationEvent(ctx);
|
|
1631
|
+
registerActivityEvents(ctx);
|
|
1632
|
+
rejoinPersistedRooms(ctx);
|
|
1295
1633
|
});
|
|
1296
|
-
return io;
|
|
1634
|
+
return { io, adapterClients: { pubClient, subClient } };
|
|
1297
1635
|
};
|
|
1298
1636
|
|
|
1299
1637
|
// src/verifyBootstrap.ts
|
|
1300
1638
|
import {
|
|
1301
|
-
|
|
1639
|
+
collectSynchronizedEnvKeys,
|
|
1640
|
+
getLogger as getLogger9,
|
|
1641
|
+
getProjectConfig as getProjectConfig12,
|
|
1302
1642
|
isDeployConfigRegistered,
|
|
1303
1643
|
isLocalizedNormalizerRegistered,
|
|
1304
1644
|
isProjectConfigRegistered,
|
|
@@ -1325,8 +1665,12 @@ var verifyBootstrap = async (requirements = {}) => {
|
|
|
1325
1665
|
}
|
|
1326
1666
|
}
|
|
1327
1667
|
if (requirements.requireOAuthProviders) {
|
|
1328
|
-
const
|
|
1329
|
-
if (
|
|
1668
|
+
const login = await getLogin();
|
|
1669
|
+
if (!login) {
|
|
1670
|
+
missing.push(
|
|
1671
|
+
"OAuth providers required (`requireOAuthProviders`) but `@luckystack/login` is not installed. Install it, or drop the requirement if this app has no auth."
|
|
1672
|
+
);
|
|
1673
|
+
} else if (login.getOAuthProviders().length <= 1) {
|
|
1330
1674
|
missing.push(
|
|
1331
1675
|
"OAuth providers \u2014 call `registerOAuthProviders([...])` from `luckystack/login/oauthProviders.ts` (or skip this check if your app uses credentials only)."
|
|
1332
1676
|
);
|
|
@@ -1338,7 +1682,7 @@ var verifyBootstrap = async (requirements = {}) => {
|
|
|
1338
1682
|
"RuntimeMapsProvider \u2014 call `registerRuntimeMapsProvider({...})` from `server/prod/runtimeMaps.ts`. Without it, every api/sync request returns notFound."
|
|
1339
1683
|
);
|
|
1340
1684
|
} else {
|
|
1341
|
-
|
|
1685
|
+
getLogger9().warn(
|
|
1342
1686
|
"[LuckyStack] No RuntimeMapsProvider registered \u2014 api/sync requests will resolve to empty maps. Devkit hot-reload usually registers one automatically."
|
|
1343
1687
|
);
|
|
1344
1688
|
}
|
|
@@ -1349,11 +1693,20 @@ var verifyBootstrap = async (requirements = {}) => {
|
|
|
1349
1693
|
"LocalizedNormalizer \u2014 call `registerLocalizedNormalizer({...})` from your bootstrap. Without it, error response messages will be the raw errorCode (no i18n)."
|
|
1350
1694
|
);
|
|
1351
1695
|
} else {
|
|
1352
|
-
|
|
1696
|
+
getLogger9().warn(
|
|
1353
1697
|
"[LuckyStack] No LocalizedNormalizer registered \u2014 error messages will pass through as the raw errorCode."
|
|
1354
1698
|
);
|
|
1355
1699
|
}
|
|
1356
1700
|
}
|
|
1701
|
+
if (isProjectConfigRegistered()) {
|
|
1702
|
+
const synchronizedKeyCount = collectSynchronizedEnvKeys().length;
|
|
1703
|
+
const healthHashMode = getProjectConfig12().http.healthHash.mode;
|
|
1704
|
+
if (synchronizedKeyCount > 0 && healthHashMode === "plain") {
|
|
1705
|
+
getLogger9().warn(
|
|
1706
|
+
`[LuckyStack] SECURITY: /_health exposes UNSALTED sha256 fingerprints of ${String(synchronizedKeyCount)} synchronized env secret(s) by default. Set \`http.healthHash.mode\` to 'hmac' (or 'salted' with salt '@bootUuid') to stop publishing brute-forceable secret fingerprints to unauthenticated callers.`
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1357
1710
|
if (missing.length === 0) return;
|
|
1358
1711
|
const detail = missing.map((line, idx) => ` ${idx + 1}. ${line}`).join("\n");
|
|
1359
1712
|
throw new Error(
|
|
@@ -1368,58 +1721,25 @@ var verifyBootstrap = async (requirements = {}) => {
|
|
|
1368
1721
|
|
|
1369
1722
|
// src/runtimeMapsLoader.ts
|
|
1370
1723
|
import {
|
|
1724
|
+
getLogger as getLogger10,
|
|
1371
1725
|
registerRuntimeMapsProvider
|
|
1372
1726
|
} from "@luckystack/core";
|
|
1373
|
-
|
|
1374
|
-
// src/argv.ts
|
|
1375
|
-
var parsedBundles = [];
|
|
1376
|
-
var parsedPort = null;
|
|
1377
|
-
var hasRun = false;
|
|
1378
|
-
var PORT_PATTERN = /^\d+$/;
|
|
1379
|
-
var parseServerArgv = (argv) => {
|
|
1380
|
-
if (argv.length > 2) {
|
|
1381
|
-
throw new Error(
|
|
1382
|
-
`[luckystack:argv] unexpected positional argument(s): "${argv.slice(2).join(" ")}". Usage: npm run server -- <bundle[,bundle...]> [port]`
|
|
1383
|
-
);
|
|
1384
|
-
}
|
|
1385
|
-
const bundles = argv[0] && argv[0].length > 0 ? [...new Set(argv[0].split(",").map((s) => s.trim()).filter(Boolean))] : [];
|
|
1386
|
-
let port = null;
|
|
1387
|
-
const portArg = argv[1];
|
|
1388
|
-
if (portArg !== void 0) {
|
|
1389
|
-
if (!PORT_PATTERN.test(portArg)) {
|
|
1390
|
-
throw new Error(
|
|
1391
|
-
`[luckystack:argv] port argument must be numeric, got: "${portArg}". Usage: npm run server -- <bundle[,bundle...]> [port]`
|
|
1392
|
-
);
|
|
1393
|
-
}
|
|
1394
|
-
port = Number.parseInt(portArg, 10);
|
|
1395
|
-
}
|
|
1396
|
-
return { bundles, port };
|
|
1397
|
-
};
|
|
1398
|
-
var applyServerArgv = () => {
|
|
1399
|
-
if (hasRun) return;
|
|
1400
|
-
hasRun = true;
|
|
1401
|
-
const result = parseServerArgv(process.argv.slice(2));
|
|
1402
|
-
parsedBundles = result.bundles;
|
|
1403
|
-
parsedPort = result.port;
|
|
1404
|
-
if (parsedPort !== null) {
|
|
1405
|
-
process.env.SERVER_PORT = String(parsedPort);
|
|
1406
|
-
}
|
|
1407
|
-
};
|
|
1408
|
-
var getParsedBundles = () => parsedBundles;
|
|
1409
|
-
var getParsedPort = () => parsedPort;
|
|
1410
|
-
|
|
1411
|
-
// src/runtimeMapsLoader.ts
|
|
1412
1727
|
var emptyRuntimeMaps = {
|
|
1413
1728
|
apisObject: {},
|
|
1414
1729
|
syncObject: {},
|
|
1415
1730
|
functionsObject: {}
|
|
1416
1731
|
};
|
|
1417
1732
|
var isRuntimeMapRecord = (value) => Boolean(value) && typeof value === "object";
|
|
1418
|
-
var normalizeGeneratedModule = (moduleValue) => {
|
|
1733
|
+
var normalizeGeneratedModule = (moduleValue, preset) => {
|
|
1419
1734
|
const moduleRecord = moduleValue && typeof moduleValue === "object" ? moduleValue : {};
|
|
1420
1735
|
const apiCandidate = moduleRecord.apis;
|
|
1421
1736
|
const syncCandidate = moduleRecord.syncs;
|
|
1422
1737
|
const functionCandidate = moduleRecord.functions;
|
|
1738
|
+
if (!isRuntimeMapRecord(apiCandidate) && !isRuntimeMapRecord(syncCandidate) && !isRuntimeMapRecord(functionCandidate)) {
|
|
1739
|
+
getLogger10().warn(
|
|
1740
|
+
`[luckystack:runtimeMaps] preset "${preset}" loaded but has no recognised shape (expected { apis, syncs, functions }). Verify that generateServerRequests has been run and the correct file is imported. All routes for this preset will return notFound.`
|
|
1741
|
+
);
|
|
1742
|
+
}
|
|
1423
1743
|
return {
|
|
1424
1744
|
apisObject: isRuntimeMapRecord(apiCandidate) ? apiCandidate : {},
|
|
1425
1745
|
syncObject: isRuntimeMapRecord(syncCandidate) ? syncCandidate : {},
|
|
@@ -1481,19 +1801,19 @@ var createProdRuntimeMapsProvider = (options) => {
|
|
|
1481
1801
|
let loadedAny = false;
|
|
1482
1802
|
for (const { preset, mod } of loadedModules) {
|
|
1483
1803
|
if (!mod) {
|
|
1484
|
-
|
|
1804
|
+
getLogger10().warn(
|
|
1485
1805
|
`[luckystack:runtimeMaps] preset "${preset}" failed to load \u2014 skipping. Calls owned by that preset will return notFound until the generated module resolves.`
|
|
1486
1806
|
);
|
|
1487
1807
|
continue;
|
|
1488
1808
|
}
|
|
1489
1809
|
loadedAny = true;
|
|
1490
|
-
const normalized = normalizeGeneratedModule(mod);
|
|
1810
|
+
const normalized = normalizeGeneratedModule(mod, preset);
|
|
1491
1811
|
mergeInto(merged.apisObject, normalized.apisObject, "api", preset, apiOrigin);
|
|
1492
1812
|
mergeInto(merged.syncObject, normalized.syncObject, "sync", preset, syncOrigin);
|
|
1493
1813
|
mergeInto(merged.functionsObject, normalized.functionsObject, "function", preset, functionOrigin);
|
|
1494
1814
|
}
|
|
1495
1815
|
if (!loadedAny) {
|
|
1496
|
-
|
|
1816
|
+
getLogger10().warn(
|
|
1497
1817
|
`[luckystack:runtimeMaps] no presets resolved (tried: ${presets.join(", ")}). Every api/sync request will return notFound until at least one generated module loads.`
|
|
1498
1818
|
);
|
|
1499
1819
|
return emptyRuntimeMaps;
|
|
@@ -1532,7 +1852,111 @@ var registerProdRuntimeMapsProvider = (options) => {
|
|
|
1532
1852
|
return provider;
|
|
1533
1853
|
};
|
|
1534
1854
|
|
|
1855
|
+
// src/stopServer.ts
|
|
1856
|
+
import {
|
|
1857
|
+
dispatchHook as dispatchHook7,
|
|
1858
|
+
flushErrorTrackers,
|
|
1859
|
+
getLogger as getLogger11,
|
|
1860
|
+
tryCatch as tryCatch8
|
|
1861
|
+
} from "@luckystack/core";
|
|
1862
|
+
var DEFAULT_SHUTDOWN_TIMEOUT_MS = 1e4;
|
|
1863
|
+
var withTimeout = async (label, timeoutMs, fn) => {
|
|
1864
|
+
let timer;
|
|
1865
|
+
const timeout = new Promise((resolve) => {
|
|
1866
|
+
timer = setTimeout(() => {
|
|
1867
|
+
getLogger11().warn(`[shutdown] step "${label}" exceeded ${String(timeoutMs)}ms \u2014 moving on`);
|
|
1868
|
+
resolve(false);
|
|
1869
|
+
}, timeoutMs);
|
|
1870
|
+
timer.unref();
|
|
1871
|
+
});
|
|
1872
|
+
const run = (async () => {
|
|
1873
|
+
const [error] = await tryCatch8(fn);
|
|
1874
|
+
if (error) getLogger11().warn(`[shutdown] step "${label}" failed: ${error.message}`);
|
|
1875
|
+
return true;
|
|
1876
|
+
})();
|
|
1877
|
+
const completed = await Promise.race([run, timeout]);
|
|
1878
|
+
if (timer) clearTimeout(timer);
|
|
1879
|
+
return completed;
|
|
1880
|
+
};
|
|
1881
|
+
var closeHttpServer = (httpServer) => new Promise((resolve, reject) => {
|
|
1882
|
+
httpServer.close((error) => {
|
|
1883
|
+
if (error) reject(error);
|
|
1884
|
+
else resolve();
|
|
1885
|
+
});
|
|
1886
|
+
});
|
|
1887
|
+
var closeIoServer = (ioServer) => new Promise((resolve, reject) => {
|
|
1888
|
+
void ioServer.close((error) => {
|
|
1889
|
+
if (error) reject(error);
|
|
1890
|
+
else resolve();
|
|
1891
|
+
});
|
|
1892
|
+
});
|
|
1893
|
+
var quitRedisClient = async (client) => {
|
|
1894
|
+
await client.quit();
|
|
1895
|
+
};
|
|
1896
|
+
var runGracefulShutdown = async (deps, options = {}) => {
|
|
1897
|
+
const { httpServer, ioServer, adapterClients } = deps;
|
|
1898
|
+
const reason = options.reason ?? "manual";
|
|
1899
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
|
|
1900
|
+
getLogger11().info(`[shutdown] graceful shutdown started (reason=${reason})`);
|
|
1901
|
+
const httpClosed = withTimeout("http-close", timeoutMs, () => closeHttpServer(httpServer));
|
|
1902
|
+
await withTimeout("preServerStop-hook", timeoutMs, async () => {
|
|
1903
|
+
await dispatchHook7("preServerStop", { reason, timeoutMs });
|
|
1904
|
+
});
|
|
1905
|
+
await withTimeout("flush-error-trackers", timeoutMs, () => flushErrorTrackers());
|
|
1906
|
+
await withTimeout("io-close", timeoutMs, () => closeIoServer(ioServer));
|
|
1907
|
+
await httpClosed;
|
|
1908
|
+
await withTimeout("redis-pub-quit", timeoutMs, () => quitRedisClient(adapterClients.pubClient));
|
|
1909
|
+
await withTimeout("redis-sub-quit", timeoutMs, () => quitRedisClient(adapterClients.subClient));
|
|
1910
|
+
getLogger11().info("[shutdown] graceful shutdown complete");
|
|
1911
|
+
};
|
|
1912
|
+
|
|
1535
1913
|
// src/createServer.ts
|
|
1914
|
+
var initDevTools = async () => {
|
|
1915
|
+
const { initConsolelog } = await import("@luckystack/core");
|
|
1916
|
+
initConsolelog();
|
|
1917
|
+
const devkitModuleId = "@luckystack/devkit";
|
|
1918
|
+
const devkit = await import(devkitModuleId);
|
|
1919
|
+
await devkit.initializeAll();
|
|
1920
|
+
devkit.setupWatchers();
|
|
1921
|
+
process.once("SIGINT", () => process.exit(0));
|
|
1922
|
+
process.once("SIGTERM", () => process.exit(0));
|
|
1923
|
+
};
|
|
1924
|
+
var listenLuckyStackServer = (httpServer, ip, port, callback) => new Promise((resolve, reject) => {
|
|
1925
|
+
const startPort = typeof port === "string" ? Number.parseInt(port, 10) : port;
|
|
1926
|
+
const autoIncrement = ["1", "true"].includes(
|
|
1927
|
+
(process.env.SERVER_PORT_AUTO_INCREMENT ?? "").toLowerCase()
|
|
1928
|
+
);
|
|
1929
|
+
const tryListen = (attemptPort) => {
|
|
1930
|
+
const onError = (err) => {
|
|
1931
|
+
if (err.code !== "EADDRINUSE") {
|
|
1932
|
+
reject(err);
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
if (autoIncrement) {
|
|
1936
|
+
getLogger12().warn(
|
|
1937
|
+
`Port ${String(attemptPort)} is in use \u2014 trying ${String(attemptPort + 1)} (SERVER_PORT_AUTO_INCREMENT=1)`
|
|
1938
|
+
);
|
|
1939
|
+
tryListen(attemptPort + 1);
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
getLogger12().error(
|
|
1943
|
+
`Port ${String(attemptPort)} is already in use \u2014 the server did NOT start. Another \`npm run server\` is probably still running (stop it), or set SERVER_PORT to a free port, or set SERVER_PORT_AUTO_INCREMENT=1 to auto-pick the next free port.`
|
|
1944
|
+
);
|
|
1945
|
+
reject(err);
|
|
1946
|
+
};
|
|
1947
|
+
httpServer.once("error", onError);
|
|
1948
|
+
httpServer.listen(attemptPort, ip, () => {
|
|
1949
|
+
httpServer.off("error", onError);
|
|
1950
|
+
const config = getProjectConfig13();
|
|
1951
|
+
if (config.logging.socketStartup || config.logging.devLogs) {
|
|
1952
|
+
getLogger12().info(`Server is running on http://${ip}:${String(attemptPort)}/`);
|
|
1953
|
+
}
|
|
1954
|
+
callback?.();
|
|
1955
|
+
resolve(httpServer);
|
|
1956
|
+
});
|
|
1957
|
+
};
|
|
1958
|
+
tryListen(startPort);
|
|
1959
|
+
});
|
|
1536
1960
|
var createLuckyStackServer = async (options = {}) => {
|
|
1537
1961
|
if (options.loadGeneratedMaps) {
|
|
1538
1962
|
registerProdRuntimeMapsProvider({
|
|
@@ -1553,16 +1977,9 @@ var createLuckyStackServer = async (options = {}) => {
|
|
|
1553
1977
|
port: typeof port === "string" ? Number.parseInt(port, 10) : port
|
|
1554
1978
|
});
|
|
1555
1979
|
if (enableDevTools) {
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
const devkit = await import(devkitModuleId);
|
|
1560
|
-
await devkit.initializeAll();
|
|
1561
|
-
devkit.setupWatchers();
|
|
1562
|
-
process.once("SIGINT", () => process.exit(0));
|
|
1563
|
-
process.once("SIGTERM", () => process.exit(0));
|
|
1564
|
-
}
|
|
1565
|
-
const [bootUuidError] = await tryCatch7(() => writeBootUuid());
|
|
1980
|
+
await initDevTools();
|
|
1981
|
+
}
|
|
1982
|
+
const [bootUuidError] = await tryCatch9(() => writeBootUuid());
|
|
1566
1983
|
if (bootUuidError) {
|
|
1567
1984
|
throw new Error(
|
|
1568
1985
|
"Failed to write the boot UUID to Redis. Check REDIS_HOST / REDIS_PORT / REDIS_USER / REDIS_PASSWORD and that Redis is reachable.",
|
|
@@ -1572,21 +1989,34 @@ var createLuckyStackServer = async (options = {}) => {
|
|
|
1572
1989
|
const httpServer = http.createServer((req, res) => {
|
|
1573
1990
|
void handleHttpRequest(req, res, options);
|
|
1574
1991
|
});
|
|
1575
|
-
|
|
1992
|
+
httpServer.on("error", (err) => {
|
|
1993
|
+
if (err.code === "EADDRINUSE") return;
|
|
1994
|
+
getLogger12().error("[http-server] runtime error", err);
|
|
1995
|
+
});
|
|
1996
|
+
const { io: ioServer, adapterClients } = loadSocket(httpServer, {
|
|
1576
1997
|
maxHttpBufferSize: options.maxHttpBufferSize
|
|
1577
1998
|
});
|
|
1578
|
-
const listen = (callback) =>
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1999
|
+
const listen = (callback) => listenLuckyStackServer(httpServer, ip, port, callback);
|
|
2000
|
+
let shutdownPromise = null;
|
|
2001
|
+
const stop = (stopOptions = {}) => {
|
|
2002
|
+
shutdownPromise ??= runGracefulShutdown({ httpServer, ioServer, adapterClients }, stopOptions);
|
|
2003
|
+
return shutdownPromise;
|
|
2004
|
+
};
|
|
2005
|
+
if (!enableDevTools) {
|
|
2006
|
+
const handleSignal = (reason) => {
|
|
2007
|
+
void (async () => {
|
|
2008
|
+
await stop({ reason });
|
|
2009
|
+
process.exit(0);
|
|
2010
|
+
})();
|
|
2011
|
+
};
|
|
2012
|
+
process.once("SIGTERM", () => {
|
|
2013
|
+
handleSignal("SIGTERM");
|
|
1587
2014
|
});
|
|
1588
|
-
|
|
1589
|
-
|
|
2015
|
+
process.once("SIGINT", () => {
|
|
2016
|
+
handleSignal("SIGINT");
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
return { httpServer, ioServer, listen, stop, close: stop };
|
|
1590
2020
|
};
|
|
1591
2021
|
|
|
1592
2022
|
// src/bootstrap.ts
|
|
@@ -1637,11 +2067,26 @@ var loadOverlayFolder = async (overlayRoot) => {
|
|
|
1637
2067
|
}
|
|
1638
2068
|
}
|
|
1639
2069
|
};
|
|
2070
|
+
var importOptionalPackageRegisters = async () => {
|
|
2071
|
+
for (const pkg of OPTIONAL_PACKAGES) {
|
|
2072
|
+
const specifier = `@luckystack/${pkg}/register`;
|
|
2073
|
+
if (!canResolve(specifier)) continue;
|
|
2074
|
+
await importIfExistsSpecifier(specifier);
|
|
2075
|
+
}
|
|
2076
|
+
};
|
|
2077
|
+
var importIfExistsSpecifier = async (specifier) => {
|
|
2078
|
+
try {
|
|
2079
|
+
await import(specifier);
|
|
2080
|
+
} catch {
|
|
2081
|
+
}
|
|
2082
|
+
};
|
|
1640
2083
|
var bootstrapLuckyStack = async (options = {}) => {
|
|
1641
2084
|
const overlayRoot = options.overlayRoot ?? "luckystack";
|
|
1642
2085
|
if (!options.skipOverlayLoad) {
|
|
2086
|
+
await importOptionalPackageRegisters();
|
|
1643
2087
|
await loadOverlayFolder(overlayRoot);
|
|
1644
2088
|
}
|
|
2089
|
+
await getLogin();
|
|
1645
2090
|
const server = await createLuckyStackServer(options);
|
|
1646
2091
|
return server;
|
|
1647
2092
|
};
|