@mandujs/core 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -126,6 +126,29 @@ export interface ManduConfig {
126
126
  spa?: boolean;
127
127
  server?: {
128
128
  port?: number;
129
+ /**
130
+ * Bind hostname for the HTTP server.
131
+ *
132
+ * Default: `"::"` (IPv6 wildcard, dual-stack). Bun leaves
133
+ * `IPV6_V6ONLY` off, so this one socket accepts both IPv6 clients
134
+ * (e.g. Node 17+ `fetch("localhost:PORT")` on Windows resolves to
135
+ * `::1`) and IPv4 clients (as IPv4-mapped IPv6) — you effectively
136
+ * get `0.0.0.0` + `::` for free.
137
+ *
138
+ * Set `"0.0.0.0"` to bind IPv4 only (container/firewall setups that
139
+ * need it). Note: on Windows, an IPv4-only bind makes Node's
140
+ * `fetch("localhost:PORT")` fail with `ECONNREFUSED ::1:PORT`
141
+ * because Node prefers the IPv6 address for `localhost`. `curl`
142
+ * and browsers silently fall back to IPv4, hiding the bug — Mandu
143
+ * emits a one-line warning on Windows when you pick this value
144
+ * explicitly so the trap is discoverable.
145
+ *
146
+ * Set `"127.0.0.1"` or `"::1"` to bind loopback-only (no LAN
147
+ * visibility). Set any other value (e.g. `"10.0.0.2"`,
148
+ * `"myhost.example.com"`) to bind that specific interface.
149
+ *
150
+ * @see issues #190 #223 #225
151
+ */
129
152
  hostname?: string;
130
153
  cors?:
131
154
  | boolean
@@ -44,9 +44,12 @@ function strictWithWarnings<T extends z.ZodRawShape>(
44
44
  const ServerConfigSchema = z
45
45
  .object({
46
46
  port: z.number().min(1).max(65535).default(3000),
47
- // Default 0.0.0.0 so IPv4 `localhost` resolution (Windows default) succeeds.
48
- // Users may pin "::1" or "127.0.0.1" explicitly. See issue #190.
49
- hostname: z.string().default("0.0.0.0"),
47
+ // Default `"::"` (IPv6 wildcard, dual-stack): accepts both IPv4 and
48
+ // IPv6 clients on one socket. Fixes Windows Node 17+ fetch failing
49
+ // with `ECONNREFUSED ::1:PORT` because `localhost` resolves to `::1`
50
+ // first there. Explicit `"0.0.0.0"` (IPv4-only) and `"::1"` /
51
+ // `"127.0.0.1"` (loopback-only) are still honored. See #190 #223.
52
+ hostname: z.string().default("::"),
50
53
  cors: z
51
54
  .union([
52
55
  z.boolean(),
@@ -48,9 +48,11 @@ export function adapterBun(): ManduAdapter {
48
48
 
49
49
  return {
50
50
  port: manduServer.server.port ?? port,
51
- // Report the effective bind address. startServer() defaults to
52
- // 0.0.0.0 when no hostname is supplied. See #190.
53
- hostname: hostname ?? "0.0.0.0",
51
+ // Report the effective bind address. startServer() defaults
52
+ // to `"::"` (dual-stack IPv6 wildcard) when no hostname is
53
+ // supplied accepts both IPv4 and IPv6 clients on one
54
+ // socket. See #190 #223.
55
+ hostname: hostname ?? "::",
54
56
  };
55
57
  },
56
58
 
@@ -4255,42 +4255,98 @@ function startBunServerWithFallback(options: {
4255
4255
  // ========== Server Startup ==========
4256
4256
 
4257
4257
  /**
4258
- * Format a base URL for startup logging based on the bound hostname.
4258
+ * Derive the list of loopback-style hosts actually reachable for a given
4259
+ * bind address, without issuing any network probes. Used by the startup
4260
+ * banner (`formatServerAddresses`) and by tooling that needs to know
4261
+ * which loopback URLs will succeed for a given server.
4262
+ *
4263
+ * Matrix:
4264
+ * - `"0.0.0.0"` (IPv4 wildcard) → `["127.0.0.1"]` only. A server bound
4265
+ * to `0.0.0.0` does NOT answer on `[::1]` — the IPv6 loopback is a
4266
+ * different socket. This was the root cause of #225.
4267
+ * - `"::"` / `"::0"` / `"[::]"` / `"0:0:0:0:0:0:0:0"` (IPv6 wildcard,
4268
+ * dual-stack) → `["127.0.0.1", "[::1]"]`. IPV6_V6ONLY is off by
4269
+ * default on Bun, so the dual-stack socket accepts IPv4-mapped
4270
+ * connections too.
4271
+ * - `undefined` / `""` → same as the current default (`"::"`).
4272
+ * - `"127.0.0.1"` / `"::1"` / a specific IP → just that address.
4273
+ * - DNS name → just that name.
4259
4274
  *
4260
- * When binding to wildcard addresses (`0.0.0.0`, `::`, or empty string),
4261
- * the server listens on all interfaces — browsers must use `localhost`
4262
- * or a specific loopback address to connect. We surface both IPv4 and IPv6
4263
- * loopback URLs so the user can pick whichever their OS prefers.
4275
+ * @see issues #223 #225
4276
+ */
4277
+ export function reachableHosts(hostname: string | undefined): string[] {
4278
+ const h = (hostname ?? "").trim();
4279
+
4280
+ // IPv4 wildcard — IPv4 loopback only.
4281
+ if (h === "0.0.0.0") {
4282
+ return ["127.0.0.1"];
4283
+ }
4284
+
4285
+ // IPv6 wildcard (dual-stack). Empty / undefined is treated as the
4286
+ // default, which is now `"::"` (dual-stack) — see `startServer()`.
4287
+ if (h === "" || h === "::" || h === "::0" || h === "[::]" || h === "0:0:0:0:0:0:0:0") {
4288
+ return ["127.0.0.1", "[::1]"];
4289
+ }
4290
+
4291
+ // Bare IPv6 literal → bracket for URL syntax.
4292
+ if (h.includes(":") && !h.startsWith("[")) {
4293
+ return [`[${h}]`];
4294
+ }
4295
+
4296
+ return [h];
4297
+ }
4298
+
4299
+ /**
4300
+ * Format a base URL for startup logging based on the bound hostname.
4264
4301
  *
4265
4302
  * Returns `{ primary, additional }` where `primary` is the canonical URL
4266
- * for UX (open-in-browser, runtime control) and `additional` are supplementary
4267
- * URLs shown in the startup log.
4303
+ * for UX (open-in-browser, runtime control) and `additional` are
4304
+ * supplementary URLs shown in the startup log. Every URL in `additional`
4305
+ * is guaranteed to actually resolve to the running server — no more
4306
+ * "(also reachable at [::1])" when the socket only answers on IPv4.
4307
+ *
4308
+ * @see issue #225
4268
4309
  */
4269
4310
  export function formatServerAddresses(
4270
4311
  hostname: string | undefined,
4271
4312
  port: number
4272
4313
  ): { primary: string; additional: string[] } {
4273
- const isWildcardV4 = hostname === "0.0.0.0" || hostname === undefined || hostname === "";
4274
- const isWildcardV6 = hostname === "::" || hostname === "[::]";
4314
+ const h = (hostname ?? "").trim();
4315
+ const isWildcardV4 = h === "0.0.0.0";
4316
+ const isWildcardV6 =
4317
+ h === "" || h === "::" || h === "::0" || h === "[::]" || h === "0:0:0:0:0:0:0:0";
4318
+ const hosts = reachableHosts(hostname);
4319
+
4275
4320
  if (isWildcardV4 || isWildcardV6) {
4276
4321
  return {
4277
4322
  primary: `http://localhost:${port}`,
4278
- additional: [`http://127.0.0.1:${port}`, `http://[::1]:${port}`],
4323
+ additional: hosts.map((x) => `http://${x}:${port}`),
4279
4324
  };
4280
4325
  }
4281
- // Bracket IPv6 literals for URL syntax.
4282
- const host = hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
4283
- return { primary: `http://${host}:${port}`, additional: [] };
4326
+
4327
+ // Specific host `primary` is that host, no additional entries.
4328
+ return { primary: `http://${hosts[0]}:${port}`, additional: [] };
4284
4329
  }
4285
4330
 
4286
4331
  export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
4287
4332
  const {
4288
4333
  port = 3000,
4289
- // Default to 0.0.0.0 (dual-stack wildcard on IPv4) so `localhost` resolves
4290
- // to 127.0.0.1 via OS-level IPv4-preferred lookups (e.g., Windows). Users
4291
- // can still pin `hostname: "::1"` or `hostname: "127.0.0.1"` explicitly.
4292
- // See issue #190.
4293
- hostname = "0.0.0.0",
4334
+ // Default to `"::"` (IPv6 wildcard, dual-stack). Bun leaves IPV6_V6ONLY
4335
+ // off, so this single socket accepts both IPv4 (as IPv4-mapped IPv6)
4336
+ // and IPv6 clients covering `127.0.0.1`, `[::1]`, and LAN addresses
4337
+ // of either family with one bind.
4338
+ //
4339
+ // Why not `"0.0.0.0"`? On Windows with Node 17+, `fetch("localhost:...")`
4340
+ // resolves to `::1` first. A server bound to `0.0.0.0` accepts IPv4
4341
+ // only, so Node clients (Playwright test runner, ATE-generated specs)
4342
+ // fail with `ECONNREFUSED ::1:PORT`. Browsers and `curl` silently
4343
+ // fall back to IPv4, hiding the bug. See issues #190 #223.
4344
+ //
4345
+ // Explicit `"0.0.0.0"` is still honored — users who need IPv4-only
4346
+ // binds (certain container networks, firewall policies) keep that
4347
+ // option; a one-line warning is emitted on Windows so the trap is
4348
+ // discoverable.
4349
+ hostname = "::",
4294
4350
  rootDir = process.cwd(),
4295
4351
  isDev = false,
4296
4352
  hmrPort,
@@ -4611,6 +4667,25 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4611
4667
  registry.settings = { ...registry.settings, hmrPort: actualPort };
4612
4668
  }
4613
4669
 
4670
+ // ─── #223 — Windows hostname="0.0.0.0" discoverability warning ────────
4671
+ // We cannot silently rewrite an explicit `"0.0.0.0"` (user may need
4672
+ // IPv4-only binds for container/firewall reasons), but we CAN warn
4673
+ // the one platform where the gotcha actually bites: Windows, where
4674
+ // Node 17+ fetch prefers `::1` for `localhost` and will therefore
4675
+ // fail to reach an IPv4-only bind. Silent on non-Windows, silent
4676
+ // when `silent: true`.
4677
+ // ──────────────────────────────────────────────────────────────────────
4678
+ if (
4679
+ !silent &&
4680
+ options.hostname === "0.0.0.0" &&
4681
+ process.platform === "win32"
4682
+ ) {
4683
+ console.warn(
4684
+ `⚠️ hostname="0.0.0.0" binds IPv4 only; Node fetch('localhost:${actualPort}') ` +
4685
+ `may fail on Windows (prefers ::1). Consider hostname="::" for dual-stack.`
4686
+ );
4687
+ }
4688
+
4614
4689
  const addresses = formatServerAddresses(hostname, actualPort);
4615
4690
 
4616
4691
  // ─── #217 — gate the boot banner on `!silent` ─────────────────────────