@luckystack/server 0.1.9 → 0.2.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/CHANGELOG.md +35 -35
- 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 +839 -420
- 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, isProduction as isProduction2 } 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, { supersedeToken: token });
|
|
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, { skipSocketLogout: true });
|
|
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,27 +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
600
|
const publicOrigin = (config.app.publicUrl || "").replace(/\/+$/, "");
|
|
390
601
|
const loginRedirect = config.loginRedirectUrl || "/";
|
|
391
|
-
const baseLocation = /^https?:\/\//i.test(loginRedirect) ? loginRedirect : `${publicOrigin}${loginRedirect.startsWith("/") ? loginRedirect : `/${loginRedirect}`}
|
|
392
|
-
const callbackResult = await loginCallback(routePath, req, res, {
|
|
602
|
+
const baseLocation = /^https?:\/\//i.test(loginRedirect) ? loginRedirect : `${publicOrigin}${loginRedirect.startsWith("/") ? loginRedirect : `/${loginRedirect}`}`;
|
|
603
|
+
const callbackResult = await login.loginCallback(routePath, req, res, {
|
|
393
604
|
defaultRedirectUrl: baseLocation,
|
|
394
|
-
supersedeToken: token
|
|
605
|
+
supersedeToken: token ?? void 0
|
|
395
606
|
});
|
|
396
607
|
if (!callbackResult) {
|
|
397
608
|
res.writeHead(401, { "Content-Type": "text/plain" });
|
|
398
609
|
res.end("Login failed");
|
|
399
610
|
return true;
|
|
400
611
|
}
|
|
401
|
-
if (token) await
|
|
402
|
-
if (shouldLogDev)
|
|
612
|
+
if (token) await login.deleteSession(token, { skipSocketLogout: true });
|
|
613
|
+
if (shouldLogDev) getLogger3().debug("http: setting cookie or redirect with new token");
|
|
403
614
|
const { token: newToken, redirectUrl } = callbackResult;
|
|
404
615
|
if (config.session.basedToken) {
|
|
405
|
-
const
|
|
406
|
-
|
|
616
|
+
const hashIndex = redirectUrl.indexOf("#");
|
|
617
|
+
const baseUrl = hashIndex === -1 ? redirectUrl : redirectUrl.slice(0, hashIndex);
|
|
618
|
+
res.writeHead(302, { Location: `${baseUrl}#token=${newToken}` });
|
|
407
619
|
} else {
|
|
408
620
|
res.setHeader("Set-Cookie", `${sessionCookieName}=${newToken}; ${sessionCookieOptions}`);
|
|
409
621
|
res.writeHead(302, { Location: redirectUrl });
|
|
@@ -417,13 +629,13 @@ import {
|
|
|
417
629
|
captureException,
|
|
418
630
|
dispatchHook as dispatchHook3,
|
|
419
631
|
extractTokenFromRequest,
|
|
420
|
-
getLogger as
|
|
421
|
-
tryCatch as
|
|
632
|
+
getLogger as getLogger4,
|
|
633
|
+
tryCatch as tryCatch4
|
|
422
634
|
} from "@luckystack/core";
|
|
423
635
|
import { handleHttpApiRequest } from "@luckystack/api";
|
|
424
636
|
|
|
425
637
|
// src/sse.ts
|
|
426
|
-
import { getProjectConfig as
|
|
638
|
+
import { getProjectConfig as getProjectConfig8 } from "@luckystack/core";
|
|
427
639
|
var isExpectingEventStream = (acceptHeader) => {
|
|
428
640
|
if (!acceptHeader) return false;
|
|
429
641
|
const value = Array.isArray(acceptHeader) ? acceptHeader.join(",") : acceptHeader;
|
|
@@ -431,7 +643,7 @@ var isExpectingEventStream = (acceptHeader) => {
|
|
|
431
643
|
};
|
|
432
644
|
var queryRequestsStream = (queryString) => {
|
|
433
645
|
if (!queryString) return false;
|
|
434
|
-
const { queryParam, enabledValue } =
|
|
646
|
+
const { queryParam, enabledValue } = getProjectConfig8().http.stream;
|
|
435
647
|
const params = new URLSearchParams(queryString);
|
|
436
648
|
const value = params.get(queryParam);
|
|
437
649
|
return value === enabledValue || value === "1";
|
|
@@ -447,7 +659,7 @@ var initSseResponse = (res) => {
|
|
|
447
659
|
Connection: "keep-alive",
|
|
448
660
|
"X-Accel-Buffering": "no"
|
|
449
661
|
});
|
|
450
|
-
const connectedComment =
|
|
662
|
+
const connectedComment = getProjectConfig8().http.stream.connectedComment;
|
|
451
663
|
if (connectedComment) {
|
|
452
664
|
res.write(`${connectedComment}
|
|
453
665
|
|
|
@@ -460,6 +672,8 @@ var sendSseEvent = ({
|
|
|
460
672
|
data
|
|
461
673
|
}) => {
|
|
462
674
|
if (res.writableEnded) return;
|
|
675
|
+
const contentType = res.getHeader("Content-Type");
|
|
676
|
+
if (typeof contentType !== "string" || !contentType.includes("text/event-stream")) return;
|
|
463
677
|
res.write(`event: ${event}
|
|
464
678
|
`);
|
|
465
679
|
res.write(`data: ${JSON.stringify(data)}
|
|
@@ -467,6 +681,15 @@ var sendSseEvent = ({
|
|
|
467
681
|
`);
|
|
468
682
|
};
|
|
469
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
|
+
|
|
470
693
|
// src/httpRoutes/apiRoute.ts
|
|
471
694
|
var handleApiRoute = async ({
|
|
472
695
|
req,
|
|
@@ -482,11 +705,15 @@ var handleApiRoute = async ({
|
|
|
482
705
|
let streamClosed = false;
|
|
483
706
|
if (useHttpStream) {
|
|
484
707
|
initSseResponse(res);
|
|
485
|
-
|
|
708
|
+
const markClosed = () => {
|
|
486
709
|
streamClosed = true;
|
|
487
|
-
}
|
|
710
|
+
};
|
|
711
|
+
req.on("close", markClosed);
|
|
712
|
+
req.on("error", markClosed);
|
|
713
|
+
req.on("aborted", markClosed);
|
|
714
|
+
res.on("error", markClosed);
|
|
488
715
|
}
|
|
489
|
-
const [error, handled] = await
|
|
716
|
+
const [error, handled] = await tryCatch4(async () => {
|
|
490
717
|
const httpToken = extractTokenFromRequest(req);
|
|
491
718
|
const apiName = routePath.slice(5);
|
|
492
719
|
if (!apiName) {
|
|
@@ -508,11 +735,12 @@ var handleApiRoute = async ({
|
|
|
508
735
|
}
|
|
509
736
|
const apiData = typeof params === "object" ? { ...params } : {};
|
|
510
737
|
delete apiData.stream;
|
|
738
|
+
const requesterIp = resolveRequesterIp(req);
|
|
511
739
|
const result = await handleHttpApiRequest({
|
|
512
740
|
name: apiName,
|
|
513
741
|
data: apiData,
|
|
514
742
|
token: httpToken,
|
|
515
|
-
requesterIp
|
|
743
|
+
requesterIp,
|
|
516
744
|
xLanguageHeader: req.headers["x-language"],
|
|
517
745
|
acceptLanguageHeader: req.headers["accept-language"],
|
|
518
746
|
method,
|
|
@@ -534,7 +762,7 @@ var handleApiRoute = async ({
|
|
|
534
762
|
if (!error) {
|
|
535
763
|
return handled ?? true;
|
|
536
764
|
}
|
|
537
|
-
|
|
765
|
+
getLogger4().error("http-api: top-level handler threw", error, { routePath, method, requestId });
|
|
538
766
|
captureException(error, { routePath, method, requestId, source: "httpHandler.api" });
|
|
539
767
|
void dispatchHook3("apiError", {
|
|
540
768
|
route: routePath,
|
|
@@ -564,10 +792,9 @@ import {
|
|
|
564
792
|
captureException as captureException2,
|
|
565
793
|
dispatchHook as dispatchHook4,
|
|
566
794
|
extractTokenFromRequest as extractTokenFromRequest2,
|
|
567
|
-
getLogger as
|
|
568
|
-
tryCatch as
|
|
795
|
+
getLogger as getLogger5,
|
|
796
|
+
tryCatch as tryCatch5
|
|
569
797
|
} from "@luckystack/core";
|
|
570
|
-
import { handleHttpSyncRequest } from "@luckystack/sync";
|
|
571
798
|
var normalizeHttpSyncParams = (params) => {
|
|
572
799
|
const source = params ?? {};
|
|
573
800
|
const data = source.data && typeof source.data === "object" ? source.data : {};
|
|
@@ -588,15 +815,26 @@ var handleSyncRoute = async ({
|
|
|
588
815
|
requestId
|
|
589
816
|
}) => {
|
|
590
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
|
+
}
|
|
591
825
|
const useHttpStream = shouldUseHttpStream({ acceptHeader: req.headers.accept, queryString });
|
|
592
826
|
let streamClosed = false;
|
|
593
827
|
if (useHttpStream) {
|
|
594
828
|
initSseResponse(res);
|
|
595
|
-
|
|
829
|
+
const markClosed = () => {
|
|
596
830
|
streamClosed = true;
|
|
597
|
-
}
|
|
831
|
+
};
|
|
832
|
+
req.on("close", markClosed);
|
|
833
|
+
req.on("error", markClosed);
|
|
834
|
+
req.on("aborted", markClosed);
|
|
835
|
+
res.on("error", markClosed);
|
|
598
836
|
}
|
|
599
|
-
const [error, handled] = await
|
|
837
|
+
const [error, handled] = await tryCatch5(async () => {
|
|
600
838
|
if (method !== "POST") {
|
|
601
839
|
const response = {
|
|
602
840
|
status: "error",
|
|
@@ -632,14 +870,15 @@ var handleSyncRoute = async ({
|
|
|
632
870
|
return true;
|
|
633
871
|
}
|
|
634
872
|
const syncParams = normalizeHttpSyncParams(params);
|
|
635
|
-
const
|
|
873
|
+
const requesterIp = resolveRequesterIp(req);
|
|
874
|
+
const result = await sync.handleHttpSyncRequest({
|
|
636
875
|
name: `sync/${syncName}`,
|
|
637
876
|
cb: syncParams.cb,
|
|
638
877
|
data: syncParams.data,
|
|
639
878
|
receiver: syncParams.receiver,
|
|
640
879
|
ignoreSelf: syncParams.ignoreSelf,
|
|
641
880
|
token: httpToken,
|
|
642
|
-
requesterIp
|
|
881
|
+
requesterIp,
|
|
643
882
|
xLanguageHeader: req.headers["x-language"],
|
|
644
883
|
acceptLanguageHeader: req.headers["accept-language"],
|
|
645
884
|
stream: useHttpStream ? (payload) => {
|
|
@@ -660,7 +899,7 @@ var handleSyncRoute = async ({
|
|
|
660
899
|
if (!error) {
|
|
661
900
|
return handled ?? true;
|
|
662
901
|
}
|
|
663
|
-
|
|
902
|
+
getLogger5().error("http-sync: top-level handler threw", error, { routePath, method, requestId });
|
|
664
903
|
captureException2(error, { routePath, method, requestId, source: "httpHandler.sync" });
|
|
665
904
|
void dispatchHook4("syncError", {
|
|
666
905
|
route: routePath,
|
|
@@ -685,7 +924,7 @@ var handleSyncRoute = async ({
|
|
|
685
924
|
};
|
|
686
925
|
|
|
687
926
|
// src/httpRoutes/customRoutes.ts
|
|
688
|
-
import { captureException as captureException3, getLogger as
|
|
927
|
+
import { captureException as captureException3, getLogger as getLogger6, tryCatch as tryCatch6 } from "@luckystack/core";
|
|
689
928
|
|
|
690
929
|
// src/customRoutesRegistry.ts
|
|
691
930
|
var handlers = [];
|
|
@@ -706,9 +945,9 @@ var clearCustomRoutes = () => {
|
|
|
706
945
|
|
|
707
946
|
// src/httpRoutes/customRoutes.ts
|
|
708
947
|
var runHandler = async (handler, req, res, ctx, source) => {
|
|
709
|
-
const [error, handled] = await
|
|
948
|
+
const [error, handled] = await tryCatch6(() => handler(req, res, ctx));
|
|
710
949
|
if (error) {
|
|
711
|
-
|
|
950
|
+
getLogger6().error(`${source} threw`, error, { routePath: ctx.routePath, method: ctx.method });
|
|
712
951
|
captureException3(error, { routePath: ctx.routePath, method: ctx.method, source });
|
|
713
952
|
if (!res.writableEnded) {
|
|
714
953
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
@@ -757,6 +996,7 @@ var handleCustomRoutes = async ({
|
|
|
757
996
|
// src/httpRoutes/staticRoutes.ts
|
|
758
997
|
import path from "path";
|
|
759
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$)/;
|
|
760
1000
|
var serveWithRewrittenUrl = async (serveFile, req, res, rewrittenUrl) => {
|
|
761
1001
|
const originalUrl = req.url;
|
|
762
1002
|
req.url = rewrittenUrl;
|
|
@@ -772,14 +1012,18 @@ var handleStaticAndSpaFallback = async ({
|
|
|
772
1012
|
routePath,
|
|
773
1013
|
options
|
|
774
1014
|
}) => {
|
|
775
|
-
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/")) {
|
|
776
1021
|
if (!options.serveFile) {
|
|
777
1022
|
res.writeHead(404);
|
|
778
1023
|
res.end("Not Found");
|
|
779
1024
|
return true;
|
|
780
1025
|
}
|
|
781
|
-
|
|
782
|
-
await serveWithRewrittenUrl(options.serveFile, req, res, assetPath);
|
|
1026
|
+
await serveWithRewrittenUrl(options.serveFile, req, res, routePath);
|
|
783
1027
|
return true;
|
|
784
1028
|
}
|
|
785
1029
|
if (KNOWN_STATIC_FILE_REGEX.test(routePath)) {
|
|
@@ -805,21 +1049,10 @@ var handleStaticAndSpaFallback = async ({
|
|
|
805
1049
|
return true;
|
|
806
1050
|
};
|
|
807
1051
|
|
|
808
|
-
// src/originExemptRegistry.ts
|
|
809
|
-
var exemptPaths = [];
|
|
810
|
-
var registerOriginExemptPath = (matcher) => {
|
|
811
|
-
exemptPaths.push(matcher);
|
|
812
|
-
};
|
|
813
|
-
var getOriginExemptPaths = () => exemptPaths;
|
|
814
|
-
var clearOriginExemptPaths = () => {
|
|
815
|
-
exemptPaths.length = 0;
|
|
816
|
-
};
|
|
817
|
-
var isOriginExemptPath = (routePath) => exemptPaths.some((matcher) => routePath.startsWith(matcher.pathPrefix));
|
|
818
|
-
|
|
819
1052
|
// src/httpHandler.ts
|
|
820
1053
|
var buildSessionCookieOptions = (sessionExpiryDays, secure, http2) => `HttpOnly; SameSite=${http2.sessionCookieSameSite}; Path=${http2.sessionCookiePath}; Max-Age=${60 * 60 * 24 * sessionExpiryDays}; ${secure ? "Secure;" : ""}`;
|
|
821
1054
|
var setSecurityHeaders = (req, res, origin) => {
|
|
822
|
-
const { cors, securityHeaders } =
|
|
1055
|
+
const { cors, securityHeaders } = getProjectConfig10().http;
|
|
823
1056
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
824
1057
|
res.setHeader("Access-Control-Allow-Methods", cors.allowedMethods);
|
|
825
1058
|
res.setHeader("Access-Control-Allow-Headers", cors.allowedHeaders);
|
|
@@ -833,15 +1066,16 @@ var setSecurityHeaders = (req, res, origin) => {
|
|
|
833
1066
|
res.setHeader("X-Content-Type-Options", securityHeaders.contentTypeOptions);
|
|
834
1067
|
const builder = getSecurityHeadersBuilder();
|
|
835
1068
|
if (builder) {
|
|
836
|
-
|
|
1069
|
+
const [error] = tryCatchSync(() => {
|
|
837
1070
|
const custom = builder(req);
|
|
838
1071
|
if (custom) {
|
|
839
1072
|
for (const [name, value] of Object.entries(custom)) {
|
|
840
1073
|
res.setHeader(name, value);
|
|
841
1074
|
}
|
|
842
1075
|
}
|
|
843
|
-
}
|
|
844
|
-
|
|
1076
|
+
});
|
|
1077
|
+
if (error) {
|
|
1078
|
+
getLogger7().warn("securityHeadersBuilder threw \u2014 falling back to defaults", { err: error });
|
|
845
1079
|
}
|
|
846
1080
|
}
|
|
847
1081
|
};
|
|
@@ -853,6 +1087,7 @@ var PRE_PARAMS_ROUTES = [
|
|
|
853
1087
|
handleHealthRoute,
|
|
854
1088
|
handleTestResetRoute,
|
|
855
1089
|
handleAuthProvidersRoute,
|
|
1090
|
+
handleAuthLogoutRoute,
|
|
856
1091
|
handlePreParamsCustomRoutes
|
|
857
1092
|
];
|
|
858
1093
|
var POST_PARAMS_ROUTES = [
|
|
@@ -901,7 +1136,7 @@ var refreshSessionCookieIfPresent = async ({
|
|
|
901
1136
|
}) => {
|
|
902
1137
|
const hasTokenCookie = hasCookie(req.headers.cookie, sessionCookieName);
|
|
903
1138
|
if (!hasTokenCookie || !token) return;
|
|
904
|
-
const currentSession = await
|
|
1139
|
+
const currentSession = await readSession3(token);
|
|
905
1140
|
if (currentSession?.id) {
|
|
906
1141
|
res.setHeader("Set-Cookie", `${sessionCookieName}=${token}; ${sessionCookieOptions}`);
|
|
907
1142
|
}
|
|
@@ -919,25 +1154,28 @@ var parseRequestParams = async ({
|
|
|
919
1154
|
if (res.writableEnded) return null;
|
|
920
1155
|
if (params && typeof params === "object" && Object.keys(params).length > 0) {
|
|
921
1156
|
if (shouldLogDev) {
|
|
922
|
-
|
|
1157
|
+
getLogger7().debug(`[${requestId}] ${method} ${routePath}`, {
|
|
923
1158
|
params: sanitizeForLog(params)
|
|
924
1159
|
});
|
|
925
1160
|
}
|
|
926
1161
|
} else {
|
|
927
1162
|
if (shouldLogDev) {
|
|
928
|
-
|
|
1163
|
+
getLogger7().debug(`[${requestId}] ${method} ${routePath}`);
|
|
929
1164
|
}
|
|
930
1165
|
params = {};
|
|
931
1166
|
}
|
|
932
1167
|
return params;
|
|
933
1168
|
};
|
|
934
1169
|
var handleHttpRequest = async (req, res, options) => {
|
|
935
|
-
const config =
|
|
1170
|
+
const config = getProjectConfig10();
|
|
936
1171
|
const shouldLogDev = config.logging.devLogs;
|
|
937
1172
|
const sessionCookieName = config.http.sessionCookieName;
|
|
938
1173
|
const sessionCookieOptions = buildSessionCookieOptions(
|
|
939
1174
|
config.session.expiryDays,
|
|
940
|
-
|
|
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),
|
|
941
1179
|
config.http
|
|
942
1180
|
);
|
|
943
1181
|
const url = req.url ?? "/";
|
|
@@ -1018,6 +1256,8 @@ import {
|
|
|
1018
1256
|
allowedOrigin as allowedOrigin2,
|
|
1019
1257
|
applySocketMiddlewares,
|
|
1020
1258
|
attachSocketRedisAdapter,
|
|
1259
|
+
formatRoomName,
|
|
1260
|
+
redis as redis3,
|
|
1021
1261
|
setIoInstance,
|
|
1022
1262
|
socketEventNames,
|
|
1023
1263
|
buildJoinRoomResponseEventName,
|
|
@@ -1025,27 +1265,21 @@ import {
|
|
|
1025
1265
|
buildGetJoinedRoomsResponseEventName,
|
|
1026
1266
|
extractLanguageFromHeader,
|
|
1027
1267
|
extractTokenFromSocket,
|
|
1028
|
-
getLogger as
|
|
1029
|
-
getProjectConfig as
|
|
1268
|
+
getLogger as getLogger8,
|
|
1269
|
+
getProjectConfig as getProjectConfig11,
|
|
1030
1270
|
dispatchHook as dispatchHook6,
|
|
1031
1271
|
normalizeErrorResponse,
|
|
1032
|
-
|
|
1272
|
+
readSession as readSession4,
|
|
1273
|
+
writeSession,
|
|
1274
|
+
tryCatch as tryCatch7
|
|
1033
1275
|
} from "@luckystack/core";
|
|
1034
1276
|
import { handleApiRequest } from "@luckystack/api";
|
|
1035
|
-
import { handleSyncRequest } from "@luckystack/sync";
|
|
1036
|
-
import { getSession as getSession4, saveSession } from "@luckystack/login";
|
|
1037
|
-
import {
|
|
1038
|
-
initActivityBroadcaster,
|
|
1039
|
-
socketConnected,
|
|
1040
|
-
socketDisconnecting,
|
|
1041
|
-
socketLeaveRoom
|
|
1042
|
-
} from "@luckystack/presence";
|
|
1043
1277
|
var sessionLocks = /* @__PURE__ */ new Map();
|
|
1044
1278
|
var withSessionLock = async (token, fn) => {
|
|
1045
1279
|
const prev = sessionLocks.get(token) ?? Promise.resolve();
|
|
1046
1280
|
const next = prev.then(fn, fn);
|
|
1047
1281
|
sessionLocks.set(token, next);
|
|
1048
|
-
await
|
|
1282
|
+
await tryCatch7(() => next);
|
|
1049
1283
|
if (sessionLocks.get(token) === next) sessionLocks.delete(token);
|
|
1050
1284
|
};
|
|
1051
1285
|
var getVisibleSocketRooms = (socket, token) => {
|
|
@@ -1061,15 +1295,290 @@ var sanitizeSessionRoomKeys = (session) => {
|
|
|
1061
1295
|
const { code: _legacyCode, codes: _legacyCodes, ...sanitizedSession } = session;
|
|
1062
1296
|
return sanitizedSession;
|
|
1063
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
|
+
};
|
|
1064
1565
|
var loadSocket = (httpServer, options = {}) => {
|
|
1065
|
-
const config =
|
|
1566
|
+
const config = getProjectConfig11();
|
|
1066
1567
|
const shouldLogDev = config.logging.devLogs;
|
|
1067
1568
|
const shouldLogSocketStartup = config.logging.socketStartup;
|
|
1068
1569
|
const io = new SocketIOServer(httpServer, {
|
|
1069
1570
|
cors: {
|
|
1070
1571
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
1071
1572
|
origin: (origin, callback) => {
|
|
1072
|
-
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)) {
|
|
1073
1582
|
callback(null, true);
|
|
1074
1583
|
} else {
|
|
1075
1584
|
callback(new Error("Not allowed by CORS"));
|
|
@@ -1083,225 +1592,53 @@ var loadSocket = (httpServer, options = {}) => {
|
|
|
1083
1592
|
});
|
|
1084
1593
|
setIoInstance(io);
|
|
1085
1594
|
applySocketMiddlewares(io);
|
|
1086
|
-
|
|
1595
|
+
const pubClient = redis3.duplicate();
|
|
1596
|
+
const subClient = redis3.duplicate();
|
|
1597
|
+
attachSocketRedisAdapter(io, { pubClient, subClient });
|
|
1087
1598
|
if (shouldLogSocketStartup) {
|
|
1088
|
-
|
|
1599
|
+
getLogger8().info("SocketIO server initialized (redis adapter attached)");
|
|
1089
1600
|
}
|
|
1090
1601
|
io.on(socketEventNames.connect, (socket) => {
|
|
1091
1602
|
const token = extractTokenFromSocket(socket);
|
|
1092
1603
|
const activityBroadcasterEnabled = config.socketActivityBroadcaster ?? false;
|
|
1093
1604
|
const locationProviderEnabled = config.locationProviderEnabled ?? false;
|
|
1094
1605
|
const preferredLocale = extractLanguageFromHeader(socket.handshake.headers["x-language"]) || extractLanguageFromHeader(socket.handshake.headers["accept-language"]) || void 0;
|
|
1095
|
-
if (token) {
|
|
1096
|
-
void
|
|
1606
|
+
if (token && capabilities.presence) {
|
|
1607
|
+
void (async () => {
|
|
1608
|
+
const presence = await getPresence();
|
|
1609
|
+
if (presence) await presence.socketConnected({ token, io });
|
|
1610
|
+
})();
|
|
1097
1611
|
}
|
|
1098
1612
|
void dispatchHook6("onSocketConnect", {
|
|
1099
1613
|
socketId: socket.id,
|
|
1100
1614
|
token,
|
|
1101
1615
|
ip: socket.handshake.address
|
|
1102
1616
|
});
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
socket.on(socketEventNames.joinRoom, (data) => {
|
|
1120
|
-
const group = typeof data.group === "string" ? data.group.trim() : "";
|
|
1121
|
-
const responseIndex = data.responseIndex;
|
|
1122
|
-
if (typeof responseIndex !== "number") return;
|
|
1123
|
-
if (!token) {
|
|
1124
|
-
socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1125
|
-
response: { status: "error", errorCode: "auth.required" },
|
|
1126
|
-
preferredLocale
|
|
1127
|
-
}));
|
|
1128
|
-
return;
|
|
1129
|
-
}
|
|
1130
|
-
if (!group) {
|
|
1131
|
-
socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1132
|
-
response: { status: "error", errorCode: "room.invalid" },
|
|
1133
|
-
preferredLocale
|
|
1134
|
-
}));
|
|
1135
|
-
return;
|
|
1136
|
-
}
|
|
1137
|
-
void withSessionLock(token, async () => {
|
|
1138
|
-
const session = await getSession4(token);
|
|
1139
|
-
if (!session) {
|
|
1140
|
-
socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1141
|
-
response: { status: "error", errorCode: "session.notFound" },
|
|
1142
|
-
preferredLocale
|
|
1143
|
-
}));
|
|
1144
|
-
return;
|
|
1145
|
-
}
|
|
1146
|
-
const preResult = await dispatchHook6("preRoomJoin", { token, room: group });
|
|
1147
|
-
if (preResult.stopped) {
|
|
1148
|
-
socket.emit(buildJoinRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1149
|
-
response: {
|
|
1150
|
-
status: "error",
|
|
1151
|
-
errorCode: preResult.signal.errorCode || "room.joinBlocked"
|
|
1152
|
-
},
|
|
1153
|
-
preferredLocale,
|
|
1154
|
-
userLanguage: session.language
|
|
1155
|
-
}));
|
|
1156
|
-
return;
|
|
1157
|
-
}
|
|
1158
|
-
const existingRoomCodes = getSessionRoomCodes(session);
|
|
1159
|
-
const nextRoomCodes = [.../* @__PURE__ */ new Set([...existingRoomCodes, group])];
|
|
1160
|
-
await socket.join(group);
|
|
1161
|
-
const sanitizedSession = sanitizeSessionRoomKeys(session);
|
|
1162
|
-
await saveSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes });
|
|
1163
|
-
const visibleRooms = getVisibleSocketRooms(socket, token);
|
|
1164
|
-
socket.emit(buildJoinRoomResponseEventName(responseIndex), { rooms: visibleRooms });
|
|
1165
|
-
if (shouldLogDev) {
|
|
1166
|
-
getLogger7().debug(`Socket ${socket.id} joined group ${group}`);
|
|
1167
|
-
}
|
|
1168
|
-
void dispatchHook6("postRoomJoin", { token, room: group, allRooms: visibleRooms });
|
|
1169
|
-
});
|
|
1170
|
-
});
|
|
1171
|
-
socket.on(socketEventNames.leaveRoom, (data) => {
|
|
1172
|
-
const group = typeof data.group === "string" ? data.group.trim() : "";
|
|
1173
|
-
const responseIndex = data.responseIndex;
|
|
1174
|
-
if (typeof responseIndex !== "number") return;
|
|
1175
|
-
if (!token) {
|
|
1176
|
-
socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1177
|
-
response: { status: "error", errorCode: "auth.required" },
|
|
1178
|
-
preferredLocale
|
|
1179
|
-
}));
|
|
1180
|
-
return;
|
|
1181
|
-
}
|
|
1182
|
-
if (!group) {
|
|
1183
|
-
socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1184
|
-
response: { status: "error", errorCode: "room.invalid" },
|
|
1185
|
-
preferredLocale
|
|
1186
|
-
}));
|
|
1187
|
-
return;
|
|
1188
|
-
}
|
|
1189
|
-
void withSessionLock(token, async () => {
|
|
1190
|
-
const session = await getSession4(token);
|
|
1191
|
-
if (!session) {
|
|
1192
|
-
socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1193
|
-
response: { status: "error", errorCode: "session.notFound" },
|
|
1194
|
-
preferredLocale
|
|
1195
|
-
}));
|
|
1196
|
-
return;
|
|
1197
|
-
}
|
|
1198
|
-
const preResult = await dispatchHook6("preRoomLeave", { token, room: group });
|
|
1199
|
-
if (preResult.stopped) {
|
|
1200
|
-
socket.emit(buildLeaveRoomResponseEventName(responseIndex), normalizeErrorResponse({
|
|
1201
|
-
response: {
|
|
1202
|
-
status: "error",
|
|
1203
|
-
errorCode: preResult.signal.errorCode || "room.leaveBlocked"
|
|
1204
|
-
},
|
|
1205
|
-
preferredLocale,
|
|
1206
|
-
userLanguage: session.language
|
|
1207
|
-
}));
|
|
1208
|
-
return;
|
|
1209
|
-
}
|
|
1210
|
-
const existingRoomCodes = getSessionRoomCodes(session);
|
|
1211
|
-
const nextRoomCodes = existingRoomCodes.filter((roomCode) => roomCode !== group);
|
|
1212
|
-
await socket.leave(group);
|
|
1213
|
-
const sanitizedSession = sanitizeSessionRoomKeys(session);
|
|
1214
|
-
await saveSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes });
|
|
1215
|
-
const visibleRooms = getVisibleSocketRooms(socket, token);
|
|
1216
|
-
socket.emit(buildLeaveRoomResponseEventName(responseIndex), { rooms: visibleRooms });
|
|
1217
|
-
if (shouldLogDev) {
|
|
1218
|
-
getLogger7().debug(`Socket ${socket.id} left group ${group}`);
|
|
1219
|
-
}
|
|
1220
|
-
void dispatchHook6("postRoomLeave", { token, room: group, allRooms: visibleRooms });
|
|
1221
|
-
});
|
|
1222
|
-
});
|
|
1223
|
-
socket.on(socketEventNames.getJoinedRooms, (data) => {
|
|
1224
|
-
const responseIndex = data.responseIndex;
|
|
1225
|
-
if (typeof responseIndex !== "number") return;
|
|
1226
|
-
if (!token) {
|
|
1227
|
-
socket.emit(buildGetJoinedRoomsResponseEventName(responseIndex), {
|
|
1228
|
-
...normalizeErrorResponse({
|
|
1229
|
-
response: { status: "error", errorCode: "auth.required" },
|
|
1230
|
-
preferredLocale
|
|
1231
|
-
}),
|
|
1232
|
-
rooms: []
|
|
1233
|
-
});
|
|
1234
|
-
return;
|
|
1235
|
-
}
|
|
1236
|
-
socket.emit(buildGetJoinedRoomsResponseEventName(responseIndex), {
|
|
1237
|
-
rooms: getVisibleSocketRooms(socket, token)
|
|
1238
|
-
});
|
|
1239
|
-
});
|
|
1240
|
-
socket.on(socketEventNames.disconnect, (reason) => {
|
|
1241
|
-
abortAllForSocket(socket.id);
|
|
1242
|
-
void dispatchHook6("onSocketDisconnect", { socketId: socket.id, token, reason });
|
|
1243
|
-
if (activityBroadcasterEnabled && token) {
|
|
1244
|
-
void socketDisconnecting({ token, socket, reason });
|
|
1245
|
-
} else {
|
|
1246
|
-
if (!token) return;
|
|
1247
|
-
if (shouldLogDev) {
|
|
1248
|
-
getLogger7().debug(`user disconnected`, { reason });
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
});
|
|
1252
|
-
socket.on(
|
|
1253
|
-
socketEventNames.updateLocation,
|
|
1254
|
-
(newLocation) => {
|
|
1255
|
-
if (!token) return;
|
|
1256
|
-
if (!locationProviderEnabled) return;
|
|
1257
|
-
if (shouldLogDev) {
|
|
1258
|
-
getLogger7().debug("updating location", { pathName: newLocation.pathName });
|
|
1259
|
-
}
|
|
1260
|
-
void withSessionLock(token, async () => {
|
|
1261
|
-
let returnedUser = null;
|
|
1262
|
-
if (activityBroadcasterEnabled) {
|
|
1263
|
-
returnedUser = await socketLeaveRoom({ token, socket, newPath: newLocation.pathName });
|
|
1264
|
-
}
|
|
1265
|
-
const user = returnedUser ?? await getSession4(token);
|
|
1266
|
-
if (!user) return;
|
|
1267
|
-
const extendedUser = user;
|
|
1268
|
-
const oldLocation = extendedUser.location;
|
|
1269
|
-
extendedUser.location = newLocation;
|
|
1270
|
-
await saveSession(token, user);
|
|
1271
|
-
void dispatchHook6("onLocationUpdate", { token, oldLocation, newLocation });
|
|
1272
|
-
});
|
|
1273
|
-
}
|
|
1274
|
-
);
|
|
1275
|
-
if (activityBroadcasterEnabled && token) {
|
|
1276
|
-
initActivityBroadcaster({ socket, token });
|
|
1277
|
-
}
|
|
1278
|
-
if (token) {
|
|
1279
|
-
void (async () => {
|
|
1280
|
-
const [rejoinError, codes] = await tryCatch6(async () => {
|
|
1281
|
-
await socket.join(token);
|
|
1282
|
-
const session = await getSession4(token);
|
|
1283
|
-
const roomCodes = session ? getSessionRoomCodes(session) : [];
|
|
1284
|
-
for (const roomCode of roomCodes) {
|
|
1285
|
-
await socket.join(roomCode);
|
|
1286
|
-
}
|
|
1287
|
-
return roomCodes;
|
|
1288
|
-
});
|
|
1289
|
-
if (rejoinError) {
|
|
1290
|
-
getLogger7().warn(`socket room rejoin failed for ${socket.id}`, { error: rejoinError.message });
|
|
1291
|
-
return;
|
|
1292
|
-
}
|
|
1293
|
-
if (shouldLogDev) {
|
|
1294
|
-
getLogger7().debug(`socket ${socket.id} (re)joined rooms: ${(codes ?? []).join(", ") || "(none)"}`);
|
|
1295
|
-
}
|
|
1296
|
-
})();
|
|
1297
|
-
}
|
|
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);
|
|
1298
1633
|
});
|
|
1299
|
-
return io;
|
|
1634
|
+
return { io, adapterClients: { pubClient, subClient } };
|
|
1300
1635
|
};
|
|
1301
1636
|
|
|
1302
1637
|
// src/verifyBootstrap.ts
|
|
1303
1638
|
import {
|
|
1304
|
-
|
|
1639
|
+
collectSynchronizedEnvKeys,
|
|
1640
|
+
getLogger as getLogger9,
|
|
1641
|
+
getProjectConfig as getProjectConfig12,
|
|
1305
1642
|
isDeployConfigRegistered,
|
|
1306
1643
|
isLocalizedNormalizerRegistered,
|
|
1307
1644
|
isProjectConfigRegistered,
|
|
@@ -1328,8 +1665,12 @@ var verifyBootstrap = async (requirements = {}) => {
|
|
|
1328
1665
|
}
|
|
1329
1666
|
}
|
|
1330
1667
|
if (requirements.requireOAuthProviders) {
|
|
1331
|
-
const
|
|
1332
|
-
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) {
|
|
1333
1674
|
missing.push(
|
|
1334
1675
|
"OAuth providers \u2014 call `registerOAuthProviders([...])` from `luckystack/login/oauthProviders.ts` (or skip this check if your app uses credentials only)."
|
|
1335
1676
|
);
|
|
@@ -1341,7 +1682,7 @@ var verifyBootstrap = async (requirements = {}) => {
|
|
|
1341
1682
|
"RuntimeMapsProvider \u2014 call `registerRuntimeMapsProvider({...})` from `server/prod/runtimeMaps.ts`. Without it, every api/sync request returns notFound."
|
|
1342
1683
|
);
|
|
1343
1684
|
} else {
|
|
1344
|
-
|
|
1685
|
+
getLogger9().warn(
|
|
1345
1686
|
"[LuckyStack] No RuntimeMapsProvider registered \u2014 api/sync requests will resolve to empty maps. Devkit hot-reload usually registers one automatically."
|
|
1346
1687
|
);
|
|
1347
1688
|
}
|
|
@@ -1352,11 +1693,20 @@ var verifyBootstrap = async (requirements = {}) => {
|
|
|
1352
1693
|
"LocalizedNormalizer \u2014 call `registerLocalizedNormalizer({...})` from your bootstrap. Without it, error response messages will be the raw errorCode (no i18n)."
|
|
1353
1694
|
);
|
|
1354
1695
|
} else {
|
|
1355
|
-
|
|
1696
|
+
getLogger9().warn(
|
|
1356
1697
|
"[LuckyStack] No LocalizedNormalizer registered \u2014 error messages will pass through as the raw errorCode."
|
|
1357
1698
|
);
|
|
1358
1699
|
}
|
|
1359
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
|
+
}
|
|
1360
1710
|
if (missing.length === 0) return;
|
|
1361
1711
|
const detail = missing.map((line, idx) => ` ${idx + 1}. ${line}`).join("\n");
|
|
1362
1712
|
throw new Error(
|
|
@@ -1371,58 +1721,25 @@ var verifyBootstrap = async (requirements = {}) => {
|
|
|
1371
1721
|
|
|
1372
1722
|
// src/runtimeMapsLoader.ts
|
|
1373
1723
|
import {
|
|
1724
|
+
getLogger as getLogger10,
|
|
1374
1725
|
registerRuntimeMapsProvider
|
|
1375
1726
|
} from "@luckystack/core";
|
|
1376
|
-
|
|
1377
|
-
// src/argv.ts
|
|
1378
|
-
var parsedBundles = [];
|
|
1379
|
-
var parsedPort = null;
|
|
1380
|
-
var hasRun = false;
|
|
1381
|
-
var PORT_PATTERN = /^\d+$/;
|
|
1382
|
-
var parseServerArgv = (argv) => {
|
|
1383
|
-
if (argv.length > 2) {
|
|
1384
|
-
throw new Error(
|
|
1385
|
-
`[luckystack:argv] unexpected positional argument(s): "${argv.slice(2).join(" ")}". Usage: npm run server -- <bundle[,bundle...]> [port]`
|
|
1386
|
-
);
|
|
1387
|
-
}
|
|
1388
|
-
const bundles = argv[0] && argv[0].length > 0 ? [...new Set(argv[0].split(",").map((s) => s.trim()).filter(Boolean))] : [];
|
|
1389
|
-
let port = null;
|
|
1390
|
-
const portArg = argv[1];
|
|
1391
|
-
if (portArg !== void 0) {
|
|
1392
|
-
if (!PORT_PATTERN.test(portArg)) {
|
|
1393
|
-
throw new Error(
|
|
1394
|
-
`[luckystack:argv] port argument must be numeric, got: "${portArg}". Usage: npm run server -- <bundle[,bundle...]> [port]`
|
|
1395
|
-
);
|
|
1396
|
-
}
|
|
1397
|
-
port = Number.parseInt(portArg, 10);
|
|
1398
|
-
}
|
|
1399
|
-
return { bundles, port };
|
|
1400
|
-
};
|
|
1401
|
-
var applyServerArgv = () => {
|
|
1402
|
-
if (hasRun) return;
|
|
1403
|
-
hasRun = true;
|
|
1404
|
-
const result = parseServerArgv(process.argv.slice(2));
|
|
1405
|
-
parsedBundles = result.bundles;
|
|
1406
|
-
parsedPort = result.port;
|
|
1407
|
-
if (parsedPort !== null) {
|
|
1408
|
-
process.env.SERVER_PORT = String(parsedPort);
|
|
1409
|
-
}
|
|
1410
|
-
};
|
|
1411
|
-
var getParsedBundles = () => parsedBundles;
|
|
1412
|
-
var getParsedPort = () => parsedPort;
|
|
1413
|
-
|
|
1414
|
-
// src/runtimeMapsLoader.ts
|
|
1415
1727
|
var emptyRuntimeMaps = {
|
|
1416
1728
|
apisObject: {},
|
|
1417
1729
|
syncObject: {},
|
|
1418
1730
|
functionsObject: {}
|
|
1419
1731
|
};
|
|
1420
1732
|
var isRuntimeMapRecord = (value) => Boolean(value) && typeof value === "object";
|
|
1421
|
-
var normalizeGeneratedModule = (moduleValue) => {
|
|
1733
|
+
var normalizeGeneratedModule = (moduleValue, preset) => {
|
|
1422
1734
|
const moduleRecord = moduleValue && typeof moduleValue === "object" ? moduleValue : {};
|
|
1423
1735
|
const apiCandidate = moduleRecord.apis;
|
|
1424
1736
|
const syncCandidate = moduleRecord.syncs;
|
|
1425
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
|
+
}
|
|
1426
1743
|
return {
|
|
1427
1744
|
apisObject: isRuntimeMapRecord(apiCandidate) ? apiCandidate : {},
|
|
1428
1745
|
syncObject: isRuntimeMapRecord(syncCandidate) ? syncCandidate : {},
|
|
@@ -1484,19 +1801,19 @@ var createProdRuntimeMapsProvider = (options) => {
|
|
|
1484
1801
|
let loadedAny = false;
|
|
1485
1802
|
for (const { preset, mod } of loadedModules) {
|
|
1486
1803
|
if (!mod) {
|
|
1487
|
-
|
|
1804
|
+
getLogger10().warn(
|
|
1488
1805
|
`[luckystack:runtimeMaps] preset "${preset}" failed to load \u2014 skipping. Calls owned by that preset will return notFound until the generated module resolves.`
|
|
1489
1806
|
);
|
|
1490
1807
|
continue;
|
|
1491
1808
|
}
|
|
1492
1809
|
loadedAny = true;
|
|
1493
|
-
const normalized = normalizeGeneratedModule(mod);
|
|
1810
|
+
const normalized = normalizeGeneratedModule(mod, preset);
|
|
1494
1811
|
mergeInto(merged.apisObject, normalized.apisObject, "api", preset, apiOrigin);
|
|
1495
1812
|
mergeInto(merged.syncObject, normalized.syncObject, "sync", preset, syncOrigin);
|
|
1496
1813
|
mergeInto(merged.functionsObject, normalized.functionsObject, "function", preset, functionOrigin);
|
|
1497
1814
|
}
|
|
1498
1815
|
if (!loadedAny) {
|
|
1499
|
-
|
|
1816
|
+
getLogger10().warn(
|
|
1500
1817
|
`[luckystack:runtimeMaps] no presets resolved (tried: ${presets.join(", ")}). Every api/sync request will return notFound until at least one generated module loads.`
|
|
1501
1818
|
);
|
|
1502
1819
|
return emptyRuntimeMaps;
|
|
@@ -1535,7 +1852,113 @@ var registerProdRuntimeMapsProvider = (options) => {
|
|
|
1535
1852
|
return provider;
|
|
1536
1853
|
};
|
|
1537
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
|
+
|
|
1538
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 autoIncrementEnv = (process.env.SERVER_PORT_AUTO_INCREMENT ?? "").toLowerCase();
|
|
1927
|
+
let autoIncrement;
|
|
1928
|
+
if (["0", "false"].includes(autoIncrementEnv)) autoIncrement = false;
|
|
1929
|
+
else if (["1", "true"].includes(autoIncrementEnv)) autoIncrement = true;
|
|
1930
|
+
else autoIncrement = !isProduction2;
|
|
1931
|
+
const tryListen = (attemptPort) => {
|
|
1932
|
+
const onError = (err) => {
|
|
1933
|
+
if (err.code !== "EADDRINUSE") {
|
|
1934
|
+
reject(err);
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
if (autoIncrement) {
|
|
1938
|
+
getLogger12().warn(
|
|
1939
|
+
`Port ${String(attemptPort)} is in use \u2014 trying ${String(attemptPort + 1)} (auto-increment; set SERVER_PORT_AUTO_INCREMENT=0 to disable)`
|
|
1940
|
+
);
|
|
1941
|
+
tryListen(attemptPort + 1);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
getLogger12().error(
|
|
1945
|
+
`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.`
|
|
1946
|
+
);
|
|
1947
|
+
reject(err);
|
|
1948
|
+
};
|
|
1949
|
+
httpServer.once("error", onError);
|
|
1950
|
+
httpServer.listen(attemptPort, ip, () => {
|
|
1951
|
+
httpServer.off("error", onError);
|
|
1952
|
+
const config = getProjectConfig13();
|
|
1953
|
+
if (config.logging.socketStartup || config.logging.devLogs) {
|
|
1954
|
+
getLogger12().info(`Server is running on http://${ip}:${String(attemptPort)}/`);
|
|
1955
|
+
}
|
|
1956
|
+
callback?.();
|
|
1957
|
+
resolve(httpServer);
|
|
1958
|
+
});
|
|
1959
|
+
};
|
|
1960
|
+
tryListen(startPort);
|
|
1961
|
+
});
|
|
1539
1962
|
var createLuckyStackServer = async (options = {}) => {
|
|
1540
1963
|
if (options.loadGeneratedMaps) {
|
|
1541
1964
|
registerProdRuntimeMapsProvider({
|
|
@@ -1556,16 +1979,9 @@ var createLuckyStackServer = async (options = {}) => {
|
|
|
1556
1979
|
port: typeof port === "string" ? Number.parseInt(port, 10) : port
|
|
1557
1980
|
});
|
|
1558
1981
|
if (enableDevTools) {
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
const devkit = await import(devkitModuleId);
|
|
1563
|
-
await devkit.initializeAll();
|
|
1564
|
-
devkit.setupWatchers();
|
|
1565
|
-
process.once("SIGINT", () => process.exit(0));
|
|
1566
|
-
process.once("SIGTERM", () => process.exit(0));
|
|
1567
|
-
}
|
|
1568
|
-
const [bootUuidError] = await tryCatch7(() => writeBootUuid());
|
|
1982
|
+
await initDevTools();
|
|
1983
|
+
}
|
|
1984
|
+
const [bootUuidError] = await tryCatch9(() => writeBootUuid());
|
|
1569
1985
|
if (bootUuidError) {
|
|
1570
1986
|
throw new Error(
|
|
1571
1987
|
"Failed to write the boot UUID to Redis. Check REDIS_HOST / REDIS_PORT / REDIS_USER / REDIS_PASSWORD and that Redis is reachable.",
|
|
@@ -1575,46 +1991,34 @@ var createLuckyStackServer = async (options = {}) => {
|
|
|
1575
1991
|
const httpServer = http.createServer((req, res) => {
|
|
1576
1992
|
void handleHttpRequest(req, res, options);
|
|
1577
1993
|
});
|
|
1578
|
-
|
|
1994
|
+
httpServer.on("error", (err) => {
|
|
1995
|
+
if (err.code === "EADDRINUSE") return;
|
|
1996
|
+
getLogger12().error("[http-server] runtime error", err);
|
|
1997
|
+
});
|
|
1998
|
+
const { io: ioServer, adapterClients } = loadSocket(httpServer, {
|
|
1579
1999
|
maxHttpBufferSize: options.maxHttpBufferSize
|
|
1580
2000
|
});
|
|
1581
|
-
const listen = (callback) =>
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
getLogger9().warn(
|
|
1594
|
-
`Port ${String(attemptPort)} is in use \u2014 trying ${String(attemptPort + 1)} (SERVER_PORT_AUTO_INCREMENT=1)`
|
|
1595
|
-
);
|
|
1596
|
-
tryListen(attemptPort + 1);
|
|
1597
|
-
return;
|
|
1598
|
-
}
|
|
1599
|
-
getLogger9().error(
|
|
1600
|
-
`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.`
|
|
1601
|
-
);
|
|
1602
|
-
reject(err);
|
|
1603
|
-
};
|
|
1604
|
-
httpServer.once("error", onError);
|
|
1605
|
-
httpServer.listen(attemptPort, ip, () => {
|
|
1606
|
-
httpServer.off("error", onError);
|
|
1607
|
-
const config = getProjectConfig10();
|
|
1608
|
-
if (config.logging.socketStartup || config.logging.devLogs) {
|
|
1609
|
-
getLogger9().info(`Server is running on http://${ip}:${String(attemptPort)}/`);
|
|
1610
|
-
}
|
|
1611
|
-
callback?.();
|
|
1612
|
-
resolve(httpServer);
|
|
1613
|
-
});
|
|
2001
|
+
const listen = (callback) => listenLuckyStackServer(httpServer, ip, port, callback);
|
|
2002
|
+
let shutdownPromise = null;
|
|
2003
|
+
const stop = (stopOptions = {}) => {
|
|
2004
|
+
shutdownPromise ??= runGracefulShutdown({ httpServer, ioServer, adapterClients }, stopOptions);
|
|
2005
|
+
return shutdownPromise;
|
|
2006
|
+
};
|
|
2007
|
+
if (!enableDevTools) {
|
|
2008
|
+
const handleSignal = (reason) => {
|
|
2009
|
+
void (async () => {
|
|
2010
|
+
await stop({ reason });
|
|
2011
|
+
process.exit(0);
|
|
2012
|
+
})();
|
|
1614
2013
|
};
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
2014
|
+
process.once("SIGTERM", () => {
|
|
2015
|
+
handleSignal("SIGTERM");
|
|
2016
|
+
});
|
|
2017
|
+
process.once("SIGINT", () => {
|
|
2018
|
+
handleSignal("SIGINT");
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
return { httpServer, ioServer, listen, stop, close: stop };
|
|
1618
2022
|
};
|
|
1619
2023
|
|
|
1620
2024
|
// src/bootstrap.ts
|
|
@@ -1665,11 +2069,26 @@ var loadOverlayFolder = async (overlayRoot) => {
|
|
|
1665
2069
|
}
|
|
1666
2070
|
}
|
|
1667
2071
|
};
|
|
2072
|
+
var importOptionalPackageRegisters = async () => {
|
|
2073
|
+
for (const pkg of OPTIONAL_PACKAGES) {
|
|
2074
|
+
const specifier = `@luckystack/${pkg}/register`;
|
|
2075
|
+
if (!canResolve(specifier)) continue;
|
|
2076
|
+
await importIfExistsSpecifier(specifier);
|
|
2077
|
+
}
|
|
2078
|
+
};
|
|
2079
|
+
var importIfExistsSpecifier = async (specifier) => {
|
|
2080
|
+
try {
|
|
2081
|
+
await import(specifier);
|
|
2082
|
+
} catch {
|
|
2083
|
+
}
|
|
2084
|
+
};
|
|
1668
2085
|
var bootstrapLuckyStack = async (options = {}) => {
|
|
1669
2086
|
const overlayRoot = options.overlayRoot ?? "luckystack";
|
|
1670
2087
|
if (!options.skipOverlayLoad) {
|
|
2088
|
+
await importOptionalPackageRegisters();
|
|
1671
2089
|
await loadOverlayFolder(overlayRoot);
|
|
1672
2090
|
}
|
|
2091
|
+
await getLogin();
|
|
1673
2092
|
const server = await createLuckyStackServer(options);
|
|
1674
2093
|
return server;
|
|
1675
2094
|
};
|