@statorjs/stator 1.1.0 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -88,7 +88,7 @@
88
88
  "pino-pretty": "^13.1.3",
89
89
  "vite": "^6.0.0",
90
90
  "vitest": "^2.1.0",
91
- "@statorjs/stator": "1.1.0"
91
+ "@statorjs/stator": "1.1.1"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
@@ -40,12 +40,18 @@ function lanAddress(): string | undefined {
40
40
 
41
41
  export function printDevBanner(info: {
42
42
  port: number
43
+ /** The port originally asked for, when the server had to shift off it. */
44
+ requestedPort?: number
43
45
  machines: number
44
46
  routes: number
45
47
  inspector?: boolean
46
48
  }): void {
47
49
  const v = statorVersion()
48
50
  const lan = lanAddress()
51
+ const shifted =
52
+ info.requestedPort !== undefined && info.requestedPort !== info.port
53
+ ? [` ${c.dim(`port ${info.requestedPort} was busy — using ${info.port}`)}`]
54
+ : []
49
55
  const lines = [
50
56
  '',
51
57
  ` ${c.copper(c.bold('stator'))}${v ? c.dim(` v${v}`) : ''} ${c.dim('dev server')}`,
@@ -58,6 +64,7 @@ export function printDevBanner(info: {
58
64
  info.routes === 1 ? '' : 's'
59
65
  }${info.inspector ? ' · inspector on' : ''} — Ctrl+C to stop`,
60
66
  )}`,
67
+ ...shifted,
61
68
  '',
62
69
  ]
63
70
  process.stdout.write(`${lines.join('\n')}\n`)
@@ -88,3 +95,20 @@ export function installGracefulShutdown(close: () => Promise<void> | void, quiet
88
95
  process.on('SIGINT', handler)
89
96
  process.on('SIGTERM', handler)
90
97
  }
98
+
99
+ /** First free TCP port at or above `start` (bounded probe). The dev plane
100
+ * auto-shifts off busy ports like every modern dev server; production
101
+ * never calls this — a taken port there is a deploy error to surface. */
102
+ export async function findFreePort(start: number, attempts = 10): Promise<number> {
103
+ const { createServer } = await import('node:net')
104
+ for (let port = start; port < start + attempts; port++) {
105
+ const free = await new Promise<boolean>((resolve) => {
106
+ const probe = createServer()
107
+ probe.once('error', () => resolve(false))
108
+ probe.once('listening', () => probe.close(() => resolve(true)))
109
+ probe.listen(port)
110
+ })
111
+ if (free) return port
112
+ }
113
+ throw new Error(`stator: no free port found in ${start}–${start + attempts - 1}`)
114
+ }
@@ -76,6 +76,18 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
76
76
  logger.info({ port, machines: defs.length, routes: routes.length }, 'listening')
77
77
  resolveFn()
78
78
  })
79
+ // Production is strict about its port (a collision is a deploy
80
+ // error) — but says so in one line, not a stack trace.
81
+ server.on('error', (err: NodeJS.ErrnoException) => {
82
+ if (err.code === 'EADDRINUSE') {
83
+ logger.error(
84
+ { port },
85
+ `port ${port} is already in use — is another instance running? (set PORT to change it)`,
86
+ )
87
+ process.exit(1)
88
+ }
89
+ throw err
90
+ })
79
91
  // Ctrl+C / SIGTERM (deploy rollover) exits 0, not 130 — quiet in
80
92
  // prod: the structured logs are the record.
81
93
  installGracefulShutdown(() => new Promise<void>((done) => server.close(() => done())), true)
package/src/server/dev.ts CHANGED
@@ -7,7 +7,7 @@ import { createServer as createViteServer, type ViteDevServer } from 'vite'
7
7
  import { compile } from '../compiler/index.ts'
8
8
  import { machineStub, stator } from '../vite/index.ts'
9
9
  import type { AppStore } from './app-store.ts'
10
- import { installGracefulShutdown, printDevBanner } from './banner.ts'
10
+ import { findFreePort, installGracefulShutdown, printDevBanner } from './banner.ts'
11
11
  import { logger } from './logger.ts'
12
12
  import type { MachineStore } from './machine-store.ts'
13
13
  import type { DiscoveredRoute } from './route-discovery.ts'
@@ -52,10 +52,14 @@ export interface DevApp {
52
52
  }
53
53
 
54
54
  export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
55
+ // Vite's HMR websocket defaults to 24678 for EVERY dev server — two
56
+ // stator apps side by side would fight over it (the loser's live reload
57
+ // silently dies). Probe a free one instead.
58
+ const hmrPort = await findFreePort(24678)
55
59
  const vite = await createViteServer({
56
60
  root: resolve(config.root),
57
61
  appType: 'custom',
58
- server: { middlewareMode: true },
62
+ server: { middlewareMode: true, hmr: { port: hmrPort } },
59
63
  // machineStub keeps server machines out of browser module graphs: island
60
64
  // imports of a machine resolve to a `{ name }` stub (SSR loads are
61
65
  // untouched — `options.ssr` gates it).
@@ -199,12 +203,23 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
199
203
  await vite.close()
200
204
  await new Promise<void>((done) => server.close(() => done()))
201
205
  })
202
- return new Promise((resolveFn) => {
203
- server.listen(port, () => {
204
- printDevBanner({ port, machines: machineCount, routes: routes.length, inspector: true })
205
- resolveFn()
206
- })
207
- })
206
+ // Busy port? Shift up like every modern dev server (the requested
207
+ // port is someone's other project, not an error).
208
+ return findFreePort(port).then(
209
+ (freePort) =>
210
+ new Promise((resolveFn) => {
211
+ server.listen(freePort, () => {
212
+ printDevBanner({
213
+ port: freePort,
214
+ requestedPort: port,
215
+ machines: machineCount,
216
+ routes: routes.length,
217
+ inspector: true,
218
+ })
219
+ resolveFn()
220
+ })
221
+ }),
222
+ )
208
223
  },
209
224
  close: () => vite.close(),
210
225
  }