@luckystack/server 0.7.3 → 0.7.4

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 CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.4] - 2026-07-22
11
+
12
+ ### Fixed
13
+
14
+ - `/readyz` now includes the recorded dev-tool initialization state. When
15
+ devkit boot failed and API/sync routes deliberately return 503, readiness is
16
+ 503 too instead of advertising a hollow-green instance.
17
+ - Port resolution is now explicitly validated and regression-tested as
18
+ `options.port > argv > options.defaultPort > SERVER_PORT > 80`; invalid values
19
+ fail before `listen`, `65535` never retries to `65536`, and `listen(0)` registers,
20
+ advertises, and logs the OS-assigned port from `httpServer.address()`.
21
+ - Auto-increment, dev advertisement, and OAuth now use the same canonical
22
+ `resolveEnvKey()` environment classification (`LUCKYSTACK_ENV` first).
23
+ - Dev port-file cleanup is PID-owned, so an exiting old backend cannot remove a
24
+ newer backend's advertisement.
25
+
10
26
  ## [0.7.3] - 2026-07-20
11
27
 
12
28
  ### Fixed
package/CLAUDE.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## What this package does
6
6
 
7
- One-call server bootstrap for a LuckyStack project. Wires together a raw Node.js HTTP server, Socket.io (with optional Redis adapter), framework routes (`/api/*`, `/sync/*`, `/_health`, `/livez`, `/readyz`, `/_test/reset`, `/auth/*`, `/uploads/*`, `/csrf-token`), CSRF middleware, CORS + security headers, presence broadcasting, and dev-only hot reload. Consumer's `server.ts` shrinks to roughly twenty lines. Boots are gated by `verifyBootstrap` so missing registrations surface a single readable error instead of mid-request crashes.
7
+ One-call server bootstrap for a LuckyStack project. Wires together a raw Node.js HTTP server, Socket.io (with optional Redis adapter), framework routes (`/api/*`, `/sync/*`, `/_health`, `/livez`, `/readyz`, `/_test/reset`, `/auth/*`, `/uploads/*`, `/csrf-token`), CSRF middleware, CORS + security headers, presence broadcasting, and dev-only hot reload. Consumer's `server.ts` shrinks to roughly twenty lines. Boots are gated by `verifyBootstrap` so missing registrations surface a single readable error instead of mid-request crashes. If devkit initialization fails, API/sync dispatch and `/readyz` all fail closed with 503.
8
8
 
9
9
  ## When to USE this package
10
10
 
@@ -49,7 +49,7 @@ One-call server bootstrap for a LuckyStack project. Wires together a raw Node.js
49
49
  | `registerErrorFormatter(formatter)` | Override the JSON error shape returned by framework error responses. | -> docs/security-defaults.md |
50
50
  | `getErrorFormatter()` | Read the currently registered formatter. | -> docs/security-defaults.md |
51
51
  | Route handler: `handleLivezRoute` | Liveness probe at `projectConfig.http.liveEndpoint`. Always 200 when reachable. | -> docs/http-routes.md |
52
- | Route handler: `handleReadyzRoute` | Readiness probe: boot UUID + Redis ping + database check (ADR 0020: a registered `registerDbHealthCheck` probe wins; else the built-in Prisma ping when Prisma is registered/resolvable; else `'skipped'` so a deliberately DB-less project can go ready). Response `checks.database` = tri-state, `checks.prisma` kept for compat. | -> docs/http-routes.md |
52
+ | Route handler: `handleReadyzRoute` | Readiness probe: boot UUID + dev-tool initialization state + Redis ping + database check (ADR 0020: a registered `registerDbHealthCheck` probe wins; else the built-in Prisma ping when Prisma is registered/resolvable; else `'skipped'` so a deliberately DB-less project can go ready). A recorded fatal devkit boot is 503 even when Redis/database are green. Response `checks.database` = tri-state, `checks.prisma` kept for compat. | -> docs/http-routes.md |
53
53
  | Route handler: `handleHealthRoute` | Health endpoint: returns boot UUID + env hashes for router topology checks. | -> docs/http-routes.md |
54
54
  | Route handler: `handleTestResetRoute` | Destructive test reset. Fail-closed on `NODE_ENV` and `TEST_RESET_TOKEN`. | -> docs/security-defaults.md |
55
55
  | Hook payload: `OnSocketConnectPayload` | Payload type for `onSocketConnect` lifecycle hook handlers. | -> docs/create-server.md |
@@ -63,7 +63,7 @@ One-call server bootstrap for a LuckyStack project. Wires together a raw Node.js
63
63
 
64
64
  ## Config keys (env vars + registerProjectConfig slots)
65
65
 
66
- - `SERVER_PORT` (env, optional) fallback when neither `options.port` nor positional argv supplies one. Written back by `applyServerArgv()` when argv carries a port. In dev, the ACTUALLY-bound port (after any `SERVER_PORT_AUTO_INCREMENT` hop off a busy port) is advertised to `node_modules/.luckystack/dev-server.json` so the template Vite proxy follows the real port instead of the stale `.env` one (written by `devServerInfo.ts`; skipped in prod + tests, removed on exit).
66
+ - Port precedence — `options.port` positional argv `options.defaultPort` (the scaffold passes `config.ports.backend`) → legacy `SERVER_PORT` `80`. Inputs must be integer ports in `0..65535`. After bind, `httpServer.address().port` is authoritative (including `listen(0)`). In dev, that port is advertised to `node_modules/.luckystack/dev-server.json`; cleanup removes it only when the exiting PID still owns it. The Vite proxy follows the advertisement for HTTP + WebSocket requests.
67
67
  - `SERVER_IP` (env, optional, default `127.0.0.1`) — bind address fallback when `options.ip` is omitted.
68
68
  - `NODE_ENV` (env, required for security-sensitive branches) — `development` / `test` toggle devkit hot reload + REPL and gate `/_test/reset`.
69
69
  - `TEST_RESET_TOKEN` (env, required for `/_test/reset` to be reachable at all) — must match the `x-test-reset-token` request header. No fallback "no auth" mode.
@@ -1,3 +1,28 @@
1
+ // src/portResolution.ts
2
+ var normalizeServerPort = (value, source = "server port") => {
3
+ let normalized;
4
+ if (typeof value === "number") normalized = value;
5
+ else normalized = /^\d+$/.test(value) ? Number(value) : Number.NaN;
6
+ if (!Number.isInteger(normalized) || normalized < 0 || normalized > 65535) {
7
+ throw new RangeError(
8
+ `[luckystack:port] ${source} must be an integer from 0 through 65535, got: "${String(value)}".`
9
+ );
10
+ }
11
+ return normalized;
12
+ };
13
+ var resolveServerPort = ({
14
+ optionsPort,
15
+ parsedPort: parsedPort2,
16
+ defaultPort,
17
+ envPort
18
+ }) => {
19
+ if (optionsPort !== void 0) return normalizeServerPort(optionsPort, "options.port");
20
+ if (parsedPort2 !== void 0 && parsedPort2 !== null) return normalizeServerPort(parsedPort2, "argv port");
21
+ if (defaultPort !== void 0) return normalizeServerPort(defaultPort, "options.defaultPort");
22
+ if (envPort !== void 0) return normalizeServerPort(envPort, "SERVER_PORT");
23
+ return 80;
24
+ };
25
+
1
26
  // src/argv.ts
2
27
  var parsedBundles = [];
3
28
  var parsedPort = null;
@@ -18,7 +43,7 @@ var parseServerArgv = (argv) => {
18
43
  `[luckystack:argv] port argument must be numeric, got: "${portArg}". Usage: npm run server -- <bundle[,bundle...]> [port]`
19
44
  );
20
45
  }
21
- port = Number.parseInt(portArg, 10);
46
+ port = normalizeServerPort(portArg, "port argument");
22
47
  }
23
48
  return { bundles, port };
24
49
  };
@@ -36,9 +61,11 @@ var getParsedBundles = () => parsedBundles;
36
61
  var getParsedPort = () => parsedPort;
37
62
 
38
63
  export {
64
+ normalizeServerPort,
65
+ resolveServerPort,
39
66
  parseServerArgv,
40
67
  applyServerArgv,
41
68
  getParsedBundles,
42
69
  getParsedPort
43
70
  };
44
- //# sourceMappingURL=chunk-X3OSC5W3.js.map
71
+ //# sourceMappingURL=chunk-QLVQ75QL.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/portResolution.ts","../src/argv.ts"],"sourcesContent":["export interface ResolveServerPortInput {\n optionsPort?: number | string;\n parsedPort?: number | null;\n defaultPort?: number | string;\n envPort?: string;\n}\n\nexport const normalizeServerPort = (\n value: number | string,\n source = 'server port',\n): number => {\n let normalized: number;\n if (typeof value === 'number') normalized = value;\n else normalized = /^\\d+$/.test(value) ? Number(value) : Number.NaN;\n\n if (!Number.isInteger(normalized) || normalized < 0 || normalized > 65_535) {\n throw new RangeError(\n `[luckystack:port] ${source} must be an integer from 0 through 65535, got: \"${String(value)}\".`,\n );\n }\n\n return normalized;\n};\n\n//? One explicit, testable precedence chain. The scaffold passes ports.backend as\n//? `defaultPort`; SERVER_PORT remains a final compatibility fallback.\nexport const resolveServerPort = ({\n optionsPort,\n parsedPort,\n defaultPort,\n envPort,\n}: ResolveServerPortInput): number => {\n if (optionsPort !== undefined) return normalizeServerPort(optionsPort, 'options.port');\n if (parsedPort !== undefined && parsedPort !== null) return normalizeServerPort(parsedPort, 'argv port');\n if (defaultPort !== undefined) return normalizeServerPort(defaultPort, 'options.defaultPort');\n if (envPort !== undefined) return normalizeServerPort(envPort, 'SERVER_PORT');\n return 80;\n};\n","//? Positional argv parser for the LuckyStack server boot. Replaces\n//? `LUCKYSTACK_BUNDLE` + `SERVER_PORT` env-var reads with a single shape:\n//?\n//? npm run server -- billing,vehicles 4001\n//?\n//? Arg 0 = comma-separated preset list (runtime maps merged across them).\n//? Arg 1 = listen port (numeric).\n//?\n//? `applyServerArgv()` is called by the side-effect module\n//? `@luckystack/server/parseArgv`, which consumers import as the FIRST line\n//? of their `server.ts` so the parsed port lands in `process.env.SERVER_PORT`\n//? before `config.ts` (top-level `backendUrl` const) is evaluated.\n\nimport { normalizeServerPort } from './portResolution';\n\nexport interface ParsedServerArgv {\n bundles: string[];\n port: number | null;\n}\n\nlet parsedBundles: string[] = [];\nlet parsedPort: number | null = null;\nlet hasRun = false;\n\nconst PORT_PATTERN = /^\\d+$/;\n\nexport const parseServerArgv = (argv: string[]): ParsedServerArgv => {\n if (argv.length > 2) {\n throw new Error(\n `[luckystack:argv] unexpected positional argument(s): \"${argv.slice(2).join(' ')}\". ` +\n `Usage: npm run server -- <bundle[,bundle...]> [port]`,\n );\n }\n\n const bundles = argv[0] && argv[0].length > 0\n ? [...new Set(argv[0].split(',').map((s) => s.trim()).filter(Boolean))]\n : [];\n\n let port: number | null = null;\n const portArg = argv[1];\n if (portArg !== undefined) {\n if (!PORT_PATTERN.test(portArg)) {\n throw new Error(\n `[luckystack:argv] port argument must be numeric, got: \"${portArg}\". ` +\n `Usage: npm run server -- <bundle[,bundle...]> [port]`,\n );\n }\n port = normalizeServerPort(portArg, 'port argument');\n }\n\n return { bundles, port };\n};\n\nexport const applyServerArgv = (): void => {\n if (hasRun) return;\n hasRun = true;\n\n const result = parseServerArgv(process.argv.slice(2));\n parsedBundles = result.bundles;\n parsedPort = result.port;\n\n //? Writeback so the downstream env-readers (`core/env.ts` Zod schema,\n //? `core/bindAddress.ts` fallback, consumer `config.ts` backendUrl,\n //? `oauthProviders.ts` callback URL) see the resolved port without us\n //? having to refactor those four call sites. This is a deliberate\n //? implementation detail — argv is the public source of truth.\n if (parsedPort !== null) {\n process.env.SERVER_PORT = String(parsedPort);\n }\n};\n\nexport const getParsedBundles = (): string[] => parsedBundles;\nexport const getParsedPort = (): number | null => parsedPort;\n"],"mappings":";AAOO,IAAM,sBAAsB,CACjC,OACA,SAAS,kBACE;AACX,MAAI;AACJ,MAAI,OAAO,UAAU,SAAU,cAAa;AAAA,MACvC,cAAa,QAAQ,KAAK,KAAK,IAAI,OAAO,KAAK,IAAI,OAAO;AAE/D,MAAI,CAAC,OAAO,UAAU,UAAU,KAAK,aAAa,KAAK,aAAa,OAAQ;AAC1E,UAAM,IAAI;AAAA,MACR,qBAAqB,MAAM,mDAAmD,OAAO,KAAK,CAAC;AAAA,IAC7F;AAAA,EACF;AAEA,SAAO;AACT;AAIO,IAAM,oBAAoB,CAAC;AAAA,EAChC;AAAA,EACA,YAAAA;AAAA,EACA;AAAA,EACA;AACF,MAAsC;AACpC,MAAI,gBAAgB,OAAW,QAAO,oBAAoB,aAAa,cAAc;AACrF,MAAIA,gBAAe,UAAaA,gBAAe,KAAM,QAAO,oBAAoBA,aAAY,WAAW;AACvG,MAAI,gBAAgB,OAAW,QAAO,oBAAoB,aAAa,qBAAqB;AAC5F,MAAI,YAAY,OAAW,QAAO,oBAAoB,SAAS,aAAa;AAC5E,SAAO;AACT;;;ACjBA,IAAI,gBAA0B,CAAC;AAC/B,IAAI,aAA4B;AAChC,IAAI,SAAS;AAEb,IAAM,eAAe;AAEd,IAAM,kBAAkB,CAAC,SAAqC;AACnE,MAAI,KAAK,SAAS,GAAG;AACnB,UAAM,IAAI;AAAA,MACR,yDAAyD,KAAK,MAAM,CAAC,EAAE,KAAK,GAAG,CAAC;AAAA,IAElF;AAAA,EACF;AAEA,QAAM,UAAU,KAAK,CAAC,KAAK,KAAK,CAAC,EAAE,SAAS,IACxC,CAAC,GAAG,IAAI,IAAI,KAAK,CAAC,EAAE,MAAM,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO,CAAC,CAAC,IACpE,CAAC;AAEL,MAAI,OAAsB;AAC1B,QAAM,UAAU,KAAK,CAAC;AACtB,MAAI,YAAY,QAAW;AACzB,QAAI,CAAC,aAAa,KAAK,OAAO,GAAG;AAC/B,YAAM,IAAI;AAAA,QACR,0DAA0D,OAAO;AAAA,MAEnE;AAAA,IACF;AACA,WAAO,oBAAoB,SAAS,eAAe;AAAA,EACrD;AAEA,SAAO,EAAE,SAAS,KAAK;AACzB;AAEO,IAAM,kBAAkB,MAAY;AACzC,MAAI,OAAQ;AACZ,WAAS;AAET,QAAM,SAAS,gBAAgB,QAAQ,KAAK,MAAM,CAAC,CAAC;AACpD,kBAAgB,OAAO;AACvB,eAAa,OAAO;AAOpB,MAAI,eAAe,MAAM;AACvB,YAAQ,IAAI,cAAc,OAAO,UAAU;AAAA,EAC7C;AACF;AAEO,IAAM,mBAAmB,MAAgB;AACzC,IAAM,gBAAgB,MAAqB;","names":["parsedPort"]}
package/dist/index.js CHANGED
@@ -2,12 +2,14 @@ import {
2
2
  applyServerArgv,
3
3
  getParsedBundles,
4
4
  getParsedPort,
5
- parseServerArgv
6
- } from "./chunk-X3OSC5W3.js";
5
+ normalizeServerPort,
6
+ parseServerArgv,
7
+ resolveServerPort
8
+ } from "./chunk-QLVQ75QL.js";
7
9
 
8
10
  // src/createServer.ts
9
11
  import http from "http";
10
- import { registerBindAddress, writeBootUuid, getLogger as getLogger13, getProjectConfig as getProjectConfig15, tryCatch as tryCatch10, tryCatchSync as tryCatchSync2, isProduction as isProduction2, resolveEnvKey as resolveEnvKey5, dispatchHook as dispatchHook8 } from "@luckystack/core";
12
+ import { registerBindAddress, registerBoundAddress, writeBootUuid, getLogger as getLogger13, getProjectConfig as getProjectConfig15, tryCatch as tryCatch10, tryCatchSync as tryCatchSync2, resolveEnvKey as resolveEnvKey5, dispatchHook as dispatchHook8 } from "@luckystack/core";
11
13
 
12
14
  // src/httpHandler.ts
13
15
  import { randomUUID } from "crypto";
@@ -299,6 +301,15 @@ import {
299
301
  resolveEnvKey,
300
302
  tryCatch
301
303
  } from "@luckystack/core";
304
+
305
+ // src/devToolsStatus.ts
306
+ var devToolsInitError = null;
307
+ var markDevToolsInitFailed = (error) => {
308
+ devToolsInitError = error;
309
+ };
310
+ var getDevToolsInitError = () => devToolsInitError;
311
+
312
+ // src/httpRoutes/healthRoutes.ts
302
313
  var pingPrisma = async () => {
303
314
  const client = prisma;
304
315
  const queryRaw = client.$queryRaw;
@@ -336,7 +347,8 @@ var handleReadyzRoute = async ({ res, routePath }) => {
336
347
  const [redisError, pong] = await tryCatch(() => redis.ping());
337
348
  const redisOk = !redisError && (pong === "PONG" || Boolean(pong));
338
349
  const databaseResult = await checkDatabaseReady();
339
- const ready = Boolean(bootUuid) && redisOk && databaseResult !== false;
350
+ const devToolsReady = getDevToolsInitError() === null;
351
+ const ready = Boolean(bootUuid) && redisOk && databaseResult !== false && devToolsReady;
340
352
  res.statusCode = ready ? 200 : 503;
341
353
  res.setHeader("Content-Type", "application/json");
342
354
  res.end(JSON.stringify({
@@ -348,7 +360,8 @@ var handleReadyzRoute = async ({ res, routePath }) => {
348
360
  bootUuid: Boolean(bootUuid),
349
361
  redis: redisOk,
350
362
  database: databaseResult,
351
- prisma: databaseResult !== false
363
+ prisma: databaseResult !== false,
364
+ devTools: devToolsReady
352
365
  }
353
366
  }));
354
367
  return true;
@@ -968,13 +981,6 @@ var resolveRequesterIp = (req) => {
968
981
  return rawRemoteAddress || trustProxy && (req.headers["x-forwarded-for"] || req.headers["x-real-ip"]) ? resolveClientIp3({ rawAddress: rawRemoteAddress, headers: req.headers, trustProxy, trustedProxyHopCount }) : void 0;
969
982
  };
970
983
 
971
- // src/devToolsStatus.ts
972
- var devToolsInitError = null;
973
- var markDevToolsInitFailed = (error) => {
974
- devToolsInitError = error;
975
- };
976
- var getDevToolsInitError = () => devToolsInitError;
977
-
978
984
  // src/httpRoutes/apiRoute.ts
979
985
  var handleApiRoute = async ({
980
986
  req,
@@ -2298,7 +2304,10 @@ var writeDevServerInfo = (ip, port) => {
2298
2304
  };
2299
2305
  var clearDevServerInfo = () => {
2300
2306
  try {
2301
- fs.rmSync(devServerInfoPath(), { force: true });
2307
+ const file = devServerInfoPath();
2308
+ const info = JSON.parse(fs.readFileSync(file, "utf8"));
2309
+ if (info.pid !== process.pid) return;
2310
+ fs.rmSync(file, { force: true });
2302
2311
  } catch {
2303
2312
  }
2304
2313
  };
@@ -2343,25 +2352,36 @@ var initDevTools = async () => {
2343
2352
  }
2344
2353
  };
2345
2354
  var listenLuckyStackServer = (httpServer, ip, port, callback) => new Promise((resolve, reject) => {
2346
- const startPort = typeof port === "string" ? Number.parseInt(port, 10) : port;
2355
+ const startPort = normalizeServerPort(port);
2356
+ const environmentKey = resolveEnvKey5();
2357
+ const production = environmentKey === "production";
2358
+ const test = environmentKey === "test";
2347
2359
  const autoIncrementEnv = (process.env.SERVER_PORT_AUTO_INCREMENT ?? "").toLowerCase();
2348
2360
  let autoIncrement;
2349
2361
  if (["0", "false"].includes(autoIncrementEnv)) autoIncrement = false;
2350
2362
  else if (["1", "true"].includes(autoIncrementEnv)) autoIncrement = true;
2351
- else autoIncrement = !isProduction2;
2363
+ else autoIncrement = !production;
2352
2364
  const tryListen = (attemptPort) => {
2353
2365
  const onError = (err) => {
2354
2366
  if (err.code !== "EADDRINUSE") {
2355
2367
  reject(err);
2356
2368
  return;
2357
2369
  }
2358
- if (autoIncrement) {
2370
+ if (autoIncrement && attemptPort < 65535) {
2359
2371
  getLogger13().warn(
2360
- `Port ${String(attemptPort)} is in use \u2014 trying ${String(attemptPort + 1)} (auto-increment; set SERVER_PORT_AUTO_INCREMENT=0 to disable). A previous/zombie dev server is still holding :${String(attemptPort)}: anything pinned to that port (an old browser tab, the Vite proxy's cached target, a manual client) will keep talking to the OLD process, NOT this restart. If this restart was meant to replace it, stop the process on :${String(attemptPort)} first.`
2372
+ `Port ${String(attemptPort)} is in use \u2014 trying ${String(attemptPort + 1)} (auto-increment; set SERVER_PORT_AUTO_INCREMENT=0 to disable). A previous/zombie dev server is still holding :${String(attemptPort)}: a manual client pinned there will keep talking to the OLD process, while the Vite proxy follows this restart's advertised port. If this restart was meant to replace it, stop the process on :${String(attemptPort)} first.`
2361
2373
  );
2362
2374
  tryListen(attemptPort + 1);
2363
2375
  return;
2364
2376
  }
2377
+ if (autoIncrement) {
2378
+ const rangeError = new RangeError(
2379
+ "Port 65535 is already in use and auto-increment cannot select a valid higher TCP port."
2380
+ );
2381
+ getLogger13().error(rangeError.message);
2382
+ reject(rangeError);
2383
+ return;
2384
+ }
2365
2385
  getLogger13().error(
2366
2386
  `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.`
2367
2387
  );
@@ -2370,23 +2390,28 @@ var listenLuckyStackServer = (httpServer, ip, port, callback) => new Promise((re
2370
2390
  httpServer.once("error", onError);
2371
2391
  httpServer.listen(attemptPort, ip, () => {
2372
2392
  httpServer.off("error", onError);
2373
- registerBindAddress({ ip, port: attemptPort });
2374
- if (!isProduction2 && process.env.NODE_ENV !== "test") {
2375
- writeDevServerInfo(ip, attemptPort);
2393
+ const address = httpServer.address();
2394
+ const boundPort = typeof address === "object" && address !== null ? address.port : attemptPort;
2395
+ registerBoundAddress({ ip, port: boundPort });
2396
+ if (!production && !test) {
2397
+ writeDevServerInfo(ip, boundPort);
2376
2398
  process.once("exit", clearDevServerInfo);
2377
2399
  }
2378
- if (attemptPort !== startPort && !isProduction2) {
2400
+ if (attemptPort !== startPort && !production) {
2379
2401
  const configuredCallbackBase = tryCatchSync2(() => getProjectConfig15().oauthCallbackBase)[1] ?? "";
2380
- const callbackPort = /:(\d+)(?:\/|$)/.exec(configuredCallbackBase)?.[1];
2381
- if (callbackPort && callbackPort !== String(attemptPort)) {
2402
+ const callbackPort = tryCatchSync2(() => {
2403
+ const callbackUrl = new URL(configuredCallbackBase);
2404
+ return callbackUrl.port || (callbackUrl.protocol === "https:" ? "443" : "80");
2405
+ })[1] ?? "";
2406
+ if (callbackPort === String(startPort) && callbackPort !== String(boundPort)) {
2382
2407
  getLogger13().warn(
2383
- `OAuth port drift: the server bound :${String(attemptPort)} but your OAuth callback base is configured for :${callbackPort} (${configuredCallbackBase}). The framework now auto-targets :${String(attemptPort)} for OAuth so the callback reaches THIS server \u2014 but your provider (Google/GitHub/\u2026) still exact-matches its registered redirect URI, so add :${String(attemptPort)} to the authorized redirect URIs, OR set SERVER_PORT_AUTO_INCREMENT=0 to pin :${callbackPort} (stop whatever holds it) instead of hopping.`
2408
+ `OAuth port drift: the server bound :${String(boundPort)} but your OAuth callback base is configured for :${callbackPort} (${configuredCallbackBase}). The framework now auto-targets :${String(boundPort)} for OAuth so the callback reaches THIS server \u2014 but your provider (Google/GitHub/\u2026) still exact-matches its registered redirect URI, so add :${String(boundPort)} to the authorized redirect URIs, OR set SERVER_PORT_AUTO_INCREMENT=0 to pin :${callbackPort} (stop whatever holds it) instead of hopping.`
2384
2409
  );
2385
2410
  }
2386
2411
  }
2387
2412
  const config = getProjectConfig15();
2388
2413
  if (config.logging.socketStartup || config.logging.devLogs) {
2389
- getLogger13().info(`Server is running on http://${ip}:${String(attemptPort)}/`);
2414
+ getLogger13().info(`Server is running on http://${ip}:${String(boundPort)}/`);
2390
2415
  }
2391
2416
  callback?.();
2392
2417
  resolve(httpServer);
@@ -2406,13 +2431,15 @@ var createLuckyStackServer = async (options = {}) => {
2406
2431
  requireServicesConfig: options.requireServicesConfig,
2407
2432
  requireOAuthProviders: options.requireOAuthProviders
2408
2433
  });
2409
- const port = options.port ?? getParsedPort() ?? options.defaultPort ?? process.env.SERVER_PORT ?? 80;
2434
+ const port = resolveServerPort({
2435
+ optionsPort: options.port,
2436
+ parsedPort: getParsedPort(),
2437
+ defaultPort: options.defaultPort,
2438
+ envPort: process.env.SERVER_PORT
2439
+ });
2410
2440
  const ip = options.ip ?? process.env.SERVER_IP ?? "127.0.0.1";
2411
2441
  const enableDevTools = options.enableDevTools ?? resolveEnvKey5() !== "production";
2412
- registerBindAddress({
2413
- ip,
2414
- port: typeof port === "string" ? Number.parseInt(port, 10) : port
2415
- });
2442
+ registerBindAddress({ ip, port });
2416
2443
  if (enableDevTools) {
2417
2444
  await initDevTools();
2418
2445
  }