@luckystack/server 0.2.7 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -153,7 +153,7 @@ Top-level `handleHttpRequest` + `dispatchRoutes(handlers, ctx)` are the only orc
153
153
  | Export | Purpose |
154
154
  | --- | --- |
155
155
  | `bootstrapLuckyStack(options)` | High-level entry: verifies config, auto-imports overlay, then calls `createLuckyStackServer`. |
156
- | `createLuckyStackServer(options)` | Lower-level factory that returns `{ httpServer, ioServer, listen }`. |
156
+ | `createLuckyStackServer(options)` | Lower-level factory that returns `{ httpServer, ioServer, listen, stop, close }` (`stop`/`close` = graceful MIS-016 shutdown). |
157
157
  | `verifyBootstrap(requirements?)` | Pre-flight check for project/deploy/services config and required env keys. |
158
158
  | `registerCustomRoute(handler)` / `getCustomRoutes()` / `clearCustomRoutes()` | Global custom-route registry composed by `bootstrapLuckyStack`. |
159
159
  | Hook payload types: `OnSocketConnectPayload`, `OnSocketDisconnectPayload`, `PreRoomJoinPayload`, `PostRoomJoinPayload`, `PreRoomLeavePayload`, `PostRoomLeavePayload`, `OnLocationUpdatePayload` | For socket-lifecycle hook handlers. |
package/dist/index.d.ts CHANGED
@@ -82,10 +82,20 @@ interface CreateLuckyStackServerOptions {
82
82
  * 1. This option (`options.port`)
83
83
  * 2. Second positional argv (`npm run server -- <bundles> <port>`), parsed
84
84
  * by `@luckystack/server/parseArgv`
85
- * 3. `process.env.SERVER_PORT` (back-compat for boots that skip `parseArgv`)
86
- * 4. `80`
85
+ * 3. `options.defaultPort` (the project's single-instance default, e.g. the
86
+ * `backend` port from `config.ports.ts`)
87
+ * 4. `process.env.SERVER_PORT` (back-compat for boots that skip `parseArgv`)
88
+ * 5. `80`
87
89
  */
88
90
  port?: number | string;
91
+ /**
92
+ * Project's single-instance default backend port. Sits BELOW positional argv
93
+ * so a multi-instance boot (`npm run server -- <preset> <port>`) still wins,
94
+ * but ABOVE the legacy `SERVER_PORT` env. The scaffold passes the `backend`
95
+ * value from `config.ports.ts` here, so the listen port has ONE source of
96
+ * truth without re-introducing `SERVER_PORT` to `.env`.
97
+ */
98
+ defaultPort?: number;
89
99
  /** Bind address. Defaults to process.env.SERVER_IP or '127.0.0.1'. */
90
100
  ip?: string;
91
101
  /** Project-side static file handler (Vite output, etc.). Used as the catch-all. */
@@ -100,7 +110,7 @@ interface CreateLuckyStackServerOptions {
100
110
  customRoutes?: CustomRouteHandler;
101
111
  /**
102
112
  * Enable dev-mode tooling (devkit hot reload, REPL, console init).
103
- * Defaults to `process.env.NODE_ENV !== 'production'`.
113
+ * Defaults to `resolveEnvKey() !== 'production'`.
104
114
  */
105
115
  enableDevTools?: boolean;
106
116
  /**
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  // src/createServer.ts
9
9
  import http from "http";
10
- import { registerBindAddress, writeBootUuid, getLogger as getLogger13, getProjectConfig as getProjectConfig13, tryCatch as tryCatch10, isProduction as isProduction2 } from "@luckystack/core";
10
+ import { registerBindAddress, writeBootUuid, getLogger as getLogger13, getProjectConfig as getProjectConfig13, tryCatch as tryCatch10, isProduction as isProduction2, resolveEnvKey as resolveEnvKey4 } from "@luckystack/core";
11
11
 
12
12
  // src/httpHandler.ts
13
13
  import { randomUUID } from "crypto";
@@ -19,6 +19,7 @@ import {
19
19
  getParams,
20
20
  getProjectConfig as getProjectConfig10,
21
21
  hasCookie,
22
+ normalizeOrigin,
22
23
  readSession as readSession3,
23
24
  tryCatch as tryCatch7,
24
25
  tryCatchSync
@@ -228,7 +229,11 @@ var handleCsrfRoute = async ({ res, routePath, token }) => {
228
229
  if (!capabilities.login) {
229
230
  const doubleSubmit = randomBytes(csrfConfig.tokenLength).toString("hex");
230
231
  res.statusCode = 200;
231
- res.setHeader("Set-Cookie", serializeCsrfCookie(csrfConfig.cookieName, doubleSubmit, csrfConfig.cookieOptions));
232
+ const cookieOptions = {
233
+ ...csrfConfig.cookieOptions,
234
+ secure: csrfConfig.cookieOptions.secure ?? process.env.SECURE === "true"
235
+ };
236
+ res.setHeader("Set-Cookie", serializeCsrfCookie(csrfConfig.cookieName, doubleSubmit, cookieOptions));
232
237
  res.setHeader("Content-Type", "application/json");
233
238
  res.end(JSON.stringify({ status: "success", csrfToken: doubleSubmit }));
234
239
  return true;
@@ -351,6 +356,13 @@ var handleTestResetRoute = async ({ req, res, routePath }) => {
351
356
  res.end(JSON.stringify({ status: "error", errorCode: "notFound" }));
352
357
  return true;
353
358
  }
359
+ if (req.method !== "POST") {
360
+ res.statusCode = 405;
361
+ res.setHeader("Allow", "POST");
362
+ res.setHeader("Content-Type", "application/json");
363
+ res.end(JSON.stringify({ status: "error", errorCode: "api.methodNotAllowed" }));
364
+ return true;
365
+ }
354
366
  const requiredToken = process.env.TEST_RESET_TOKEN;
355
367
  const providedToken = req.headers["x-test-reset-token"];
356
368
  const tokenValue = Array.isArray(providedToken) ? providedToken[0] : providedToken;
@@ -425,6 +437,15 @@ var parseSessionBasedTokenHeader = (headerValue) => {
425
437
  if (value === "0" || value === "false") return false;
426
438
  return null;
427
439
  };
440
+ var RESERVED_OAUTH_PARAMS = /* @__PURE__ */ new Set([
441
+ "client_id",
442
+ "redirect_uri",
443
+ "scope",
444
+ "response_type",
445
+ "state",
446
+ "code_challenge",
447
+ "code_challenge_method"
448
+ ]);
428
449
  var handleAuthApiRoute = async ({
429
450
  req,
430
451
  res,
@@ -459,19 +480,30 @@ var handleAuthApiRoute = async ({
459
480
  res.end(JSON.stringify({ status: false, reason: "login.oauthStateInitFailed" }));
460
481
  return true;
461
482
  }
462
- const clientId = encodeURIComponent(provider.clientID);
463
- const callbackUrl = encodeURIComponent(provider.callbackURL);
464
- const scope = encodeURIComponent(provider.scope.join(" "));
465
- const state = encodeURIComponent(oauthState.state);
466
483
  const stateTtl = config.auth.oauthStateTtlSeconds;
467
484
  const secureFlag = resolveCookieSecure(config.http.sessionCookieSecure, process.env.SECURE) ? " Secure;" : "";
468
485
  res.setHeader(
469
486
  "Set-Cookie",
470
487
  `${login.OAUTH_STATE_COOKIE_NAME}=${oauthState.stateCookie}; Path=/; HttpOnly;${secureFlag} SameSite=Lax; Max-Age=${stateTtl}`
471
488
  );
472
- const pkceParams = oauthState.codeChallenge ? `&code_challenge=${encodeURIComponent(oauthState.codeChallenge)}&code_challenge_method=S256` : "";
489
+ const authParams = new URLSearchParams({
490
+ client_id: provider.clientID,
491
+ redirect_uri: provider.callbackURL,
492
+ scope: provider.scope.join(" "),
493
+ response_type: "code",
494
+ prompt: "select_account"
495
+ });
496
+ for (const [key, value] of Object.entries(provider.extraAuthorizationParams ?? {})) {
497
+ if (RESERVED_OAUTH_PARAMS.has(key)) continue;
498
+ authParams.set(key, value);
499
+ }
500
+ authParams.set("state", oauthState.state);
501
+ if (oauthState.codeChallenge) {
502
+ authParams.set("code_challenge", oauthState.codeChallenge);
503
+ authParams.set("code_challenge_method", "S256");
504
+ }
473
505
  res.writeHead(302, {
474
- Location: `${provider.authorizationURL}?client_id=${clientId}&redirect_uri=${callbackUrl}&scope=${scope}&response_type=code&prompt=select_account&state=${state}${pkceParams}`
506
+ Location: `${provider.authorizationURL}?${authParams.toString()}`
475
507
  });
476
508
  res.end();
477
509
  return true;
@@ -528,6 +560,7 @@ var handleAuthApiRoute = async ({
528
560
  res.setHeader("Set-Cookie", `${sessionCookieName}=${result.newToken}; ${sessionCookieOptions}`);
529
561
  }
530
562
  }
563
+ res.setHeader("content-type", "application/json; charset=utf-8");
531
564
  res.end(JSON.stringify({
532
565
  status: result.status,
533
566
  reason: result.reason,
@@ -613,6 +646,7 @@ var handleAuthCallbackRoute = async ({
613
646
  supersedeToken: token ?? void 0
614
647
  });
615
648
  if (!callbackResult) {
649
+ res.setHeader("Set-Cookie", `${login.OAUTH_STATE_COOKIE_NAME}=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax`);
616
650
  res.writeHead(401, { "Content-Type": "text/plain" });
617
651
  res.end("Login failed");
618
652
  return true;
@@ -620,12 +654,14 @@ var handleAuthCallbackRoute = async ({
620
654
  if (token) await login.deleteSession(token, { skipSocketLogout: true });
621
655
  if (shouldLogDev) getLogger3().debug("http: setting cookie or redirect with new token");
622
656
  const { token: newToken, redirectUrl } = callbackResult;
657
+ const clearStateCookie = `${login.OAUTH_STATE_COOKIE_NAME}=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax`;
623
658
  if (config.session.basedToken) {
624
659
  const hashIndex = redirectUrl.indexOf("#");
625
660
  const baseUrl = hashIndex === -1 ? redirectUrl : redirectUrl.slice(0, hashIndex);
661
+ res.setHeader("Set-Cookie", clearStateCookie);
626
662
  res.writeHead(302, { Location: `${baseUrl}#token=${newToken}` });
627
663
  } else {
628
- res.setHeader("Set-Cookie", `${sessionCookieName}=${newToken}; ${sessionCookieOptions}`);
664
+ res.setHeader("Set-Cookie", [clearStateCookie, `${sessionCookieName}=${newToken}; ${sessionCookieOptions}`]);
629
665
  res.writeHead(302, { Location: redirectUrl });
630
666
  }
631
667
  res.end();
@@ -711,16 +747,21 @@ var handleApiRoute = async ({
711
747
  if (!routePath.startsWith("/api/")) return false;
712
748
  const useHttpStream = shouldUseHttpStream({ acceptHeader: req.headers.accept, queryString });
713
749
  let streamClosed = false;
714
- if (useHttpStream) {
750
+ let sseInitialized = false;
751
+ const ensureSseOpen = () => {
752
+ if (sseInitialized || res.writableEnded) return;
715
753
  initSseResponse(res);
716
- const markClosed = () => {
717
- streamClosed = true;
718
- };
719
- req.on("close", markClosed);
720
- req.on("error", markClosed);
721
- req.on("aborted", markClosed);
722
- res.on("error", markClosed);
723
- }
754
+ sseInitialized = true;
755
+ };
756
+ const abortController = new AbortController();
757
+ const markClosed = () => {
758
+ streamClosed = true;
759
+ if (!abortController.signal.aborted) abortController.abort();
760
+ };
761
+ req.on("close", markClosed);
762
+ req.on("error", markClosed);
763
+ req.on("aborted", markClosed);
764
+ res.on("error", markClosed);
724
765
  const [error, handled] = await tryCatch4(async () => {
725
766
  const httpToken = extractTokenFromRequest(req);
726
767
  const apiName = routePath.slice(5);
@@ -731,11 +772,6 @@ var handleApiRoute = async ({
731
772
  message: "api.invalidName",
732
773
  errorCode: "api.invalidName"
733
774
  };
734
- if (useHttpStream) {
735
- if (!streamClosed) sendSseEvent({ res, event: "final", data: response });
736
- res.end();
737
- return true;
738
- }
739
775
  res.setHeader("Content-Type", "application/json");
740
776
  res.writeHead(400);
741
777
  res.end(JSON.stringify(response));
@@ -752,12 +788,15 @@ var handleApiRoute = async ({
752
788
  xLanguageHeader: req.headers["x-language"],
753
789
  acceptLanguageHeader: req.headers["accept-language"],
754
790
  method,
791
+ abortSignal: abortController.signal,
755
792
  stream: useHttpStream ? (payload) => {
756
793
  if (streamClosed || res.writableEnded) return;
794
+ ensureSseOpen();
757
795
  sendSseEvent({ res, event: "stream", data: payload });
758
796
  } : void 0
759
797
  });
760
- if (useHttpStream) {
798
+ if (useHttpStream && (sseInitialized || result.status === "success")) {
799
+ ensureSseOpen();
761
800
  if (!streamClosed) sendSseEvent({ res, event: "final", data: result });
762
801
  res.end();
763
802
  return true;
@@ -784,7 +823,7 @@ var handleApiRoute = async ({
784
823
  message: "api.invalidRequestFormat",
785
824
  errorCode: "api.invalidRequestFormat"
786
825
  };
787
- if (useHttpStream) {
826
+ if (useHttpStream && res.headersSent) {
788
827
  if (!res.writableEnded) sendSseEvent({ res, event: "error", data: errResponse });
789
828
  res.end();
790
829
  return true;
@@ -832,16 +871,21 @@ var handleSyncRoute = async ({
832
871
  }
833
872
  const useHttpStream = shouldUseHttpStream({ acceptHeader: req.headers.accept, queryString });
834
873
  let streamClosed = false;
835
- if (useHttpStream) {
874
+ let sseInitialized = false;
875
+ const ensureSseOpen = () => {
876
+ if (sseInitialized || res.writableEnded) return;
836
877
  initSseResponse(res);
837
- const markClosed = () => {
838
- streamClosed = true;
839
- };
840
- req.on("close", markClosed);
841
- req.on("error", markClosed);
842
- req.on("aborted", markClosed);
843
- res.on("error", markClosed);
844
- }
878
+ sseInitialized = true;
879
+ };
880
+ const abortController = new AbortController();
881
+ const markClosed = () => {
882
+ streamClosed = true;
883
+ if (!abortController.signal.aborted) abortController.abort();
884
+ };
885
+ req.on("close", markClosed);
886
+ req.on("error", markClosed);
887
+ req.on("aborted", markClosed);
888
+ res.on("error", markClosed);
845
889
  const [error, handled] = await tryCatch5(async () => {
846
890
  if (method !== "POST") {
847
891
  const response = {
@@ -849,11 +893,6 @@ var handleSyncRoute = async ({
849
893
  message: "sync.methodNotAllowed",
850
894
  errorCode: "sync.methodNotAllowed"
851
895
  };
852
- if (useHttpStream) {
853
- if (!streamClosed) sendSseEvent({ res, event: "final", data: response });
854
- res.end();
855
- return true;
856
- }
857
896
  res.setHeader("Content-Type", "application/json");
858
897
  res.writeHead(405);
859
898
  res.end(JSON.stringify(response));
@@ -867,11 +906,6 @@ var handleSyncRoute = async ({
867
906
  message: "sync.invalidName",
868
907
  errorCode: "sync.invalidName"
869
908
  };
870
- if (useHttpStream) {
871
- if (!streamClosed) sendSseEvent({ res, event: "final", data: response });
872
- res.end();
873
- return true;
874
- }
875
909
  res.setHeader("Content-Type", "application/json");
876
910
  res.writeHead(400);
877
911
  res.end(JSON.stringify(response));
@@ -889,12 +923,15 @@ var handleSyncRoute = async ({
889
923
  requesterIp,
890
924
  xLanguageHeader: req.headers["x-language"],
891
925
  acceptLanguageHeader: req.headers["accept-language"],
926
+ abortSignal: abortController.signal,
892
927
  stream: useHttpStream ? (payload) => {
893
928
  if (streamClosed || res.writableEnded) return;
929
+ ensureSseOpen();
894
930
  sendSseEvent({ res, event: "stream", data: payload });
895
931
  } : void 0
896
932
  });
897
- if (useHttpStream) {
933
+ if (useHttpStream && (sseInitialized || result.status === "success")) {
934
+ ensureSseOpen();
898
935
  if (!streamClosed) sendSseEvent({ res, event: "final", data: result });
899
936
  res.end();
900
937
  return true;
@@ -920,7 +957,7 @@ var handleSyncRoute = async ({
920
957
  message: "sync.invalidRequestFormat",
921
958
  errorCode: "sync.invalidRequestFormat"
922
959
  };
923
- if (useHttpStream) {
960
+ if (useHttpStream && res.headersSent) {
924
961
  if (!res.writableEnded) sendSseEvent({ res, event: "error", data: errResponse });
925
962
  res.end();
926
963
  return true;
@@ -1026,6 +1063,11 @@ var handleStaticAndSpaFallback = async ({
1026
1063
  return true;
1027
1064
  }
1028
1065
  if (routePath.startsWith("/assets/")) {
1066
+ if (routePath.includes("..")) {
1067
+ res.writeHead(404, { "Content-Type": "text/plain" });
1068
+ res.end("Not Found");
1069
+ return true;
1070
+ }
1029
1071
  if (!options.serveFile) {
1030
1072
  res.writeHead(404);
1031
1073
  res.end("Not Found");
@@ -1115,7 +1157,10 @@ var dispatchRoutes = async (handlers2, ctx) => {
1115
1157
  return false;
1116
1158
  };
1117
1159
  var enforceOriginPolicy = (req, res, routePath) => {
1118
- const origin = req.headers.origin ?? req.headers.referer ?? "";
1160
+ const origin = normalizeOrigin({
1161
+ value: req.headers.origin ?? req.headers.referer ?? "",
1162
+ secure: process.env.SECURE === "true"
1163
+ });
1119
1164
  const isStateChangingMethod = req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS";
1120
1165
  if (isOriginExemptPath(routePath)) {
1121
1166
  return { origin, rejected: false };
@@ -1206,7 +1251,7 @@ var handleHttpRequestInner = async (req, res, options) => {
1206
1251
  res.setHeader("X-Request-Id", requestId);
1207
1252
  const safeHeaders = {};
1208
1253
  for (const [k, v] of Object.entries(req.headers)) {
1209
- if (k === "authorization" || k === "cookie" || k === "set-cookie" || k === "x-csrf-token") continue;
1254
+ if (k === "authorization" || k === "cookie" || k === "set-cookie" || k === "x-csrf-token" || k === "x-test-reset-token" || k === "x-session-based-token") continue;
1210
1255
  safeHeaders[k] = Array.isArray(v) ? v.join(", ") : v ?? "";
1211
1256
  }
1212
1257
  const preHttpResult = await dispatchHook5("preHttpRequest", {
@@ -1376,7 +1421,7 @@ var executeRoomMutation = async (opts) => {
1376
1421
  const existingRoomCodes = getSessionRoomCodes(session);
1377
1422
  const roomPurpose = preHook === "preRoomJoin" ? "join" : "leave";
1378
1423
  const physicalRoom = formatRoomName(group, { purpose: roomPurpose, userId: session.id });
1379
- const nextRoomCodes = await mutate(socket, physicalRoom, group, existingRoomCodes);
1424
+ const nextRoomCodes = await mutate(socket, physicalRoom, group, existingRoomCodes, session.id);
1380
1425
  const sanitizedSession = sanitizeSessionRoomKeys(session);
1381
1426
  await writeSession(token, { ...sanitizedSession, roomCodes: nextRoomCodes });
1382
1427
  const visibleRooms = getVisibleSocketRooms(socket, token);
@@ -1433,10 +1478,19 @@ var registerRoomEvents = (ctx) => {
1433
1478
  postHook: "postRoomJoin",
1434
1479
  blockedErrorCode: "room.joinBlocked",
1435
1480
  logVerb: "joined",
1436
- mutate: async (sock, physicalRoom, rawGroup, existingCodes) => {
1437
- const nextCodes = [.../* @__PURE__ */ new Set([...existingCodes, rawGroup])];
1481
+ mutate: async (sock, physicalRoom, rawGroup, existingCodes, userId) => {
1482
+ const maxRooms = getProjectConfig11().socket.maxRoomsPerSession;
1483
+ let kept = existingCodes;
1484
+ if (maxRooms !== false && maxRooms > 0 && !existingCodes.includes(rawGroup)) {
1485
+ while (kept.length >= maxRooms) {
1486
+ const oldest = kept[0];
1487
+ if (oldest === void 0) break;
1488
+ kept = kept.slice(1);
1489
+ await sock.leave(formatRoomName(oldest, { purpose: "join", userId }));
1490
+ }
1491
+ }
1438
1492
  await sock.join(physicalRoom);
1439
- return nextCodes;
1493
+ return [.../* @__PURE__ */ new Set([...kept, rawGroup])];
1440
1494
  }
1441
1495
  });
1442
1496
  });
@@ -1459,7 +1513,7 @@ var registerRoomEvents = (ctx) => {
1459
1513
  postHook: "postRoomLeave",
1460
1514
  blockedErrorCode: "room.leaveBlocked",
1461
1515
  logVerb: "left",
1462
- mutate: async (sock, physicalRoom, rawGroup, existingCodes) => {
1516
+ mutate: async (sock, physicalRoom, rawGroup, existingCodes, _userId) => {
1463
1517
  const nextCodes = existingCodes.filter((c) => c !== rawGroup);
1464
1518
  await sock.leave(physicalRoom);
1465
1519
  return nextCodes;
@@ -1492,7 +1546,7 @@ var registerDisconnectEvent = (ctx) => {
1492
1546
  void dispatchHook6("onSocketDisconnect", { socketId: socket.id, token, reason });
1493
1547
  if (activityBroadcasterEnabled) {
1494
1548
  void getPresence().then((presence) => {
1495
- presence?.clearActivity(socket.id);
1549
+ presence?.clearActivity(socket.id, token ?? void 0);
1496
1550
  });
1497
1551
  }
1498
1552
  if (activityBroadcasterEnabled && token) {
@@ -1672,7 +1726,8 @@ import {
1672
1726
  isDeployConfigRegistered,
1673
1727
  isLocalizedNormalizerRegistered,
1674
1728
  isProjectConfigRegistered,
1675
- isRuntimeMapsProviderRegistered
1729
+ isRuntimeMapsProviderRegistered,
1730
+ resolveEnvKey as resolveEnvKey2
1676
1731
  } from "@luckystack/core";
1677
1732
  var verifyBootstrap = async (requirements = {}) => {
1678
1733
  const missing = [];
@@ -1696,18 +1751,22 @@ var verifyBootstrap = async (requirements = {}) => {
1696
1751
  }
1697
1752
  if (requirements.requireOAuthProviders) {
1698
1753
  const login = await getLogin();
1699
- if (!login) {
1754
+ if (login) {
1755
+ const providers = login.getOAuthProviders();
1756
+ const onlyDefaultCredentials = providers.length === 0 || providers.length === 1 && providers[0]?.name === "credentials";
1757
+ if (onlyDefaultCredentials) {
1758
+ missing.push(
1759
+ "OAuth providers \u2014 call `registerOAuthProviders([...])` from `luckystack/login/oauthProviders.ts` (or skip this check if your app uses credentials only)."
1760
+ );
1761
+ }
1762
+ } else {
1700
1763
  missing.push(
1701
1764
  "OAuth providers required (`requireOAuthProviders`) but `@luckystack/login` is not installed. Install it, or drop the requirement if this app has no auth."
1702
1765
  );
1703
- } else if (login.getOAuthProviders().length <= 1) {
1704
- missing.push(
1705
- "OAuth providers \u2014 call `registerOAuthProviders([...])` from `luckystack/login/oauthProviders.ts` (or skip this check if your app uses credentials only)."
1706
- );
1707
1766
  }
1708
1767
  }
1709
1768
  if (!isRuntimeMapsProviderRegistered()) {
1710
- if (process.env.NODE_ENV === "production") {
1769
+ if (resolveEnvKey2() === "production") {
1711
1770
  missing.push(
1712
1771
  "RuntimeMapsProvider \u2014 call `registerRuntimeMapsProvider({...})` from `server/prod/runtimeMaps.ts`. Without it, every api/sync request returns notFound."
1713
1772
  );
@@ -1718,7 +1777,7 @@ var verifyBootstrap = async (requirements = {}) => {
1718
1777
  }
1719
1778
  }
1720
1779
  if (!isLocalizedNormalizerRegistered()) {
1721
- if (process.env.NODE_ENV === "production") {
1780
+ if (resolveEnvKey2() === "production") {
1722
1781
  missing.push(
1723
1782
  "LocalizedNormalizer \u2014 call `registerLocalizedNormalizer({...})` from your bootstrap. Without it, error response messages will be the raw errorCode (no i18n)."
1724
1783
  );
@@ -1752,7 +1811,8 @@ var verifyBootstrap = async (requirements = {}) => {
1752
1811
  // src/runtimeMapsLoader.ts
1753
1812
  import {
1754
1813
  getLogger as getLogger10,
1755
- registerRuntimeMapsProvider
1814
+ registerRuntimeMapsProvider,
1815
+ resolveEnvKey as resolveEnvKey3
1756
1816
  } from "@luckystack/core";
1757
1817
  var emptyRuntimeMaps = {
1758
1818
  apisObject: {},
@@ -1802,7 +1862,7 @@ var mergeInto = (target, source, kind, fromPreset, keyOrigin) => {
1802
1862
  target[key] = source[key];
1803
1863
  }
1804
1864
  };
1805
- var isProduction = () => process.env.NODE_ENV === "production";
1865
+ var isProduction = () => resolveEnvKey3() === "production";
1806
1866
  var createProdRuntimeMapsProvider = (options) => {
1807
1867
  let prodMapsPromise = null;
1808
1868
  let devkitModulePromise = null;
@@ -1970,12 +2030,23 @@ var clearDevServerInfo = () => {
1970
2030
  var initDevTools = async () => {
1971
2031
  const { initConsolelog } = await import("@luckystack/core");
1972
2032
  initConsolelog();
1973
- const devkitModuleId = "@luckystack/devkit";
1974
- const devkit = await import(devkitModuleId);
1975
- await devkit.initializeAll();
1976
- devkit.setupWatchers();
1977
2033
  process.once("SIGINT", () => process.exit(0));
1978
2034
  process.once("SIGTERM", () => process.exit(0));
2035
+ const devkitModuleId = "@luckystack/devkit";
2036
+ if (!canResolve(devkitModuleId)) {
2037
+ getLogger13().warn(
2038
+ "dev tooling unavailable \u2014 @luckystack/devkit is not installed, so hot reload + type-map generation are off. Install it as a devDependency for dev, or set NODE_ENV=production to run in production mode."
2039
+ );
2040
+ return;
2041
+ }
2042
+ const [devkitError] = await tryCatch10(async () => {
2043
+ const devkit = await import(devkitModuleId);
2044
+ await devkit.initializeAll();
2045
+ devkit.setupWatchers();
2046
+ });
2047
+ if (devkitError) {
2048
+ getLogger13().warn("dev tooling failed to initialize \u2014 continuing without hot reload.", { error: devkitError.message });
2049
+ }
1979
2050
  };
1980
2051
  var listenLuckyStackServer = (httpServer, ip, port, callback) => new Promise((resolve, reject) => {
1981
2052
  const startPort = typeof port === "string" ? Number.parseInt(port, 10) : port;
@@ -2031,9 +2102,9 @@ var createLuckyStackServer = async (options = {}) => {
2031
2102
  requireServicesConfig: options.requireServicesConfig,
2032
2103
  requireOAuthProviders: options.requireOAuthProviders
2033
2104
  });
2034
- const port = options.port ?? getParsedPort() ?? process.env.SERVER_PORT ?? 80;
2105
+ const port = options.port ?? getParsedPort() ?? options.defaultPort ?? process.env.SERVER_PORT ?? 80;
2035
2106
  const ip = options.ip ?? process.env.SERVER_IP ?? "127.0.0.1";
2036
- const enableDevTools = options.enableDevTools ?? process.env.NODE_ENV !== "production";
2107
+ const enableDevTools = options.enableDevTools ?? resolveEnvKey4() !== "production";
2037
2108
  registerBindAddress({
2038
2109
  ip,
2039
2110
  port: typeof port === "string" ? Number.parseInt(port, 10) : port