@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 +1 -1
- package/src/config/mandu.ts +23 -0
- package/src/config/validate.ts +6 -3
- package/src/runtime/adapter-bun.ts +5 -3
- package/src/runtime/server.ts +93 -18
package/package.json
CHANGED
package/src/config/mandu.ts
CHANGED
|
@@ -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
|
package/src/config/validate.ts
CHANGED
|
@@ -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
|
|
48
|
-
//
|
|
49
|
-
|
|
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
|
|
52
|
-
//
|
|
53
|
-
|
|
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
|
|
package/src/runtime/server.ts
CHANGED
|
@@ -4255,42 +4255,98 @@ function startBunServerWithFallback(options: {
|
|
|
4255
4255
|
// ========== Server Startup ==========
|
|
4256
4256
|
|
|
4257
4257
|
/**
|
|
4258
|
-
*
|
|
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
|
-
*
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
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
|
|
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
|
|
4274
|
-
const
|
|
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:
|
|
4323
|
+
additional: hosts.map((x) => `http://${x}:${port}`),
|
|
4279
4324
|
};
|
|
4280
4325
|
}
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
return { primary: `http://${
|
|
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
|
|
4290
|
-
//
|
|
4291
|
-
//
|
|
4292
|
-
//
|
|
4293
|
-
|
|
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` ─────────────────────────
|