@statorjs/stator 1.0.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.0.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.0.0"
91
+ "@statorjs/stator": "1.1.1"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
@@ -1,4 +1,4 @@
1
- import { actorOf, type ClientInstance } from './use.ts'
1
+ import { actorOf, type ClientInstanceBase } from './use.ts'
2
2
 
3
3
  /**
4
4
  * The one client binding mechanism: subscribe to a set of client actors, and on
@@ -12,7 +12,7 @@ import { actorOf, type ClientInstance } from './use.ts'
12
12
  * Returns a disposer that unsubscribes.
13
13
  */
14
14
  export function bind(
15
- deps: ClientInstance[],
15
+ deps: ClientInstanceBase[],
16
16
  compute: () => unknown,
17
17
  apply: (value: unknown) => void,
18
18
  ): () => void {
@@ -38,7 +38,7 @@ export function bind(
38
38
  * dependency changes (no diffing — `fn` owns its own DOM writes). The lower-
39
39
  * level primitive `{key}Changed` desugars to. Returns a disposer.
40
40
  */
41
- export function effect(deps: ClientInstance[], fn: () => void): () => void {
41
+ export function effect(deps: ClientInstanceBase[], fn: () => void): () => void {
42
42
  fn()
43
43
  const unsubs = deps.map((d) => actorOf(d).subscribe(fn).unsubscribe)
44
44
  return () => {
@@ -7,7 +7,7 @@
7
7
  export { bind, effect } from './bind.ts'
8
8
  export { dispatch } from './dispatch.ts'
9
9
  export { defineElement, StatorElement } from './element.ts'
10
- export type { MachineConfig } from './machine.ts'
10
+ export type { ClientBehavior, LegacyMachineConfig } from './machine.ts'
11
11
  export { machine } from './machine.ts'
12
- export type { ClientInstance } from './use.ts'
12
+ export type { ClientInstance, ClientInstanceBase } from './use.ts'
13
13
  export { use } from './use.ts'
@@ -1,39 +1,107 @@
1
- import { defineMachine, type EventObject, type MachineDef } from '../engine/index.ts'
1
+ import { defineMachine, type MachineDef } from '../engine/index.ts'
2
2
 
3
3
  /**
4
- * Terse machine form for component-local client state. Desugars to a single-state
5
- * `defineMachine` — context is every top-level key except the reserved `on` /
6
- * `select` / `name`, transitions go under one implicit `active` state, and
7
- * `select` becomes selectors.
4
+ * Terse machine form for component-local client state.
8
5
  *
9
- * machine({ count: 1, on: { INC: s => s.count++ }, select: { atMax: s => s.count >= 99 } })
6
+ * const Qty = machine(
7
+ * { count: 1 }, // context: just data
8
+ * {
9
+ * on: { INC: (s) => { s.count += 1 } }, // s is typed from context
10
+ * select: { atMax: (s) => s.count >= 99 }, // exposed on the instance
11
+ * },
12
+ * )
10
13
  *
11
- * Client machines run only via `createActor` (never the Store), so the name is
12
- * just a label and need not be unique.
14
+ * Context and behavior are SEPARATE arguments so TypeScript can infer the
15
+ * context first and then contextually type every handler and selector
16
+ * against it — one bag can't be soundly inferred (the handlers' parameter
17
+ * types would depend on the same object they're part of; see the probe
18
+ * history in the 1.0 spec). Events are structurally loose (`ev.color` is
19
+ * `unknown`); the machine desugars to a single-state `defineMachine`.
20
+ *
21
+ * Client machines run only via `createActor` (never the Store), so the name
22
+ * is just a label and need not be unique.
13
23
  */
14
- export interface MachineConfig {
24
+
25
+ /** Client events are structurally loose — the terse form declares no union. */
26
+ export type ClientEvent = { type: string; [k: string]: unknown }
27
+
28
+ interface ClientTransitionObject<C> {
29
+ when?: (ctx: C, ev: ClientEvent) => boolean
30
+ do?: (ctx: C, ev: ClientEvent) => void
31
+ emit?: string | string[]
32
+ }
33
+ type ClientTransition<C> = ((ctx: C, ev: ClientEvent) => void) | ClientTransitionObject<C>
34
+
35
+ export interface ClientBehavior<C> {
15
36
  /** Optional label (defaults to "ClientMachine"). */
16
37
  name?: string
17
- /** Transition map for the single implicit state. A bare function is an action;
18
- * an object is a full `{ to?, when?, do?, emit? }` transition. */
19
- // biome-ignore lint/suspicious/noExplicitAny: the terse form is dynamically shaped — context/event types aren't statically threaded (it desugars through defineMachine)
20
- on?: Record<string, any>
21
- /** Derived values, exposed as selectors on the instance. */
22
- // biome-ignore lint/suspicious/noExplicitAny: same — ctx is the inferred context of the desugared machine
38
+ /** Transition map for the single implicit state. A bare function is an
39
+ * action; an object is a full `{ when?, do?, emit? }` transition. */
40
+ on?: Record<string, ClientTransition<C>>
41
+ /** Derived values, exposed as live properties on the `use()` instance. */
42
+ select?: Record<string, (ctx: C) => unknown>
43
+ }
44
+
45
+ /** @deprecated One-bag form: context keys mixed with `on`/`select`. Kept for
46
+ * compatibility, but handlers see `any` — TypeScript cannot infer a
47
+ * context from the same object the handlers live in. Prefer
48
+ * `machine(context, behavior)`. */
49
+ export interface LegacyMachineConfig {
50
+ name?: string
51
+ // biome-ignore lint/suspicious/noExplicitAny: the one-bag form is untypeable by construction — that is exactly why the two-arg form exists
52
+ on?: Record<string, ((ctx: any, ev: ClientEvent) => void) | ClientTransitionObject<any>>
53
+ // biome-ignore lint/suspicious/noExplicitAny: same
23
54
  select?: Record<string, (ctx: any) => unknown>
24
- /** Everything else is initial context. */
25
55
  [key: string]: unknown
26
56
  }
27
57
 
28
- export function machine(config: MachineConfig): MachineDef {
29
- const { name, on = {}, select = {}, ...context } = config
58
+ const RESERVED = new Set(['name', 'on', 'select'])
59
+
60
+ // Data-only: no behavior, no selectors.
61
+ export function machine<C extends Record<string, unknown>>(
62
+ context: C & { on?: never; select?: never; name?: never },
63
+ ): MachineDef<C, ClientEvent, 'active', Record<string, never>>
64
+ // Data + behavior: handlers and selectors typed against the context.
65
+ export function machine<
66
+ C extends Record<string, unknown>,
67
+ S extends Record<string, (ctx: C) => unknown>,
68
+ >(
69
+ context: C & { on?: never; select?: never; name?: never },
70
+ behavior: ClientBehavior<C> & { select?: S },
71
+ ): MachineDef<C, ClientEvent, 'active', S>
72
+ /** @deprecated see LegacyMachineConfig */
73
+ export function machine(
74
+ config: LegacyMachineConfig,
75
+ // biome-ignore lint/suspicious/noExplicitAny: legacy view is deliberately loose
76
+ ): MachineDef<Record<string, any>, ClientEvent, 'active', Record<string, (ctx: any) => any>>
77
+ export function machine(
78
+ first: Record<string, unknown>,
79
+ behavior?: ClientBehavior<never>,
80
+ ): MachineDef {
81
+ let context: Record<string, unknown>
82
+ let name: string | undefined
83
+ let on: Record<string, unknown>
84
+ let select: Record<string, unknown>
85
+ if (behavior !== undefined || !Object.keys(first).some((k) => RESERVED.has(k))) {
86
+ context = first
87
+ name = behavior?.name
88
+ on = (behavior?.on ?? {}) as Record<string, unknown>
89
+ select = (behavior?.select ?? {}) as Record<string, unknown>
90
+ } else {
91
+ // Legacy one-bag: context is every non-reserved key.
92
+ const { name: n, on: o = {}, select: s = {}, ...rest } = first as LegacyMachineConfig
93
+ context = rest
94
+ name = n
95
+ on = o as Record<string, unknown>
96
+ select = s as Record<string, unknown>
97
+ }
30
98
  return defineMachine({
31
99
  name: name ?? 'ClientMachine',
32
100
  lifecycle: 'session',
33
- events: {} as EventObject,
34
- context: context as object,
101
+ events: {} as ClientEvent,
102
+ context,
35
103
  initial: 'active',
36
- states: { active: { on } },
37
- selectors: select,
104
+ states: { active: { on: on as never } },
105
+ selectors: select as never,
38
106
  }) as MachineDef
39
107
  }
package/src/client/use.ts CHANGED
@@ -26,12 +26,23 @@ const CLIENT_HELPERS = {
26
26
  * - `send(event)` to drive transitions.
27
27
  * - the underlying actor (non-enumerable) for the binding loop to subscribe.
28
28
  */
29
- export interface ClientInstance {
29
+ export interface ClientInstanceBase {
30
30
  send(event: { type: string; [k: string]: unknown } | string): void
31
31
  /** @internal — the actor a binding subscribes to. */
32
32
  readonly __actor: AnyActor
33
33
  }
34
34
 
35
+ /** Context keys + selector results as readonly live properties — the typed
36
+ * mirror of what the runtime proxy exposes. */
37
+ type ClientView<D> =
38
+ D extends MachineDef<infer C, infer _E, infer _S, infer Sel, infer _N>
39
+ ? { readonly [K in keyof C]: C[K] } & {
40
+ readonly [K in keyof Sel]: Sel[K] extends (...args: never[]) => infer R ? R : never
41
+ }
42
+ : unknown
43
+
44
+ export type ClientInstance<D extends MachineDef = MachineDef> = ClientInstanceBase & ClientView<D>
45
+
35
46
  /** An actor plus an optional deferred seed thunk — evaluated at the element's
36
47
  * connect (when attributes are available), not at construction. */
37
48
  export interface CollectedActor {
@@ -61,10 +72,12 @@ export function popCollector(): void {
61
72
  * connect — required when the seed reads `this.attrs`, since attributes aren't
62
73
  * available at construction (the custom-element upgrade-timing rule).
63
74
  */
64
- export function use(
65
- def: MachineDef,
66
- seed?: Record<string, unknown> | (() => Record<string, unknown>),
67
- ): ClientInstance {
75
+ export function use<D extends MachineDef>(
76
+ def: D,
77
+ seed?: Partial<D['__context']> | (() => Partial<D['__context']>),
78
+ ): ClientInstance<D> {
79
+ // The live properties are defined dynamically below; the mapped view type
80
+ // is the static mirror of that runtime work.
68
81
  const eager = typeof seed === 'object' ? seed : undefined
69
82
  const snapshot: Snapshot<object> | undefined = eager
70
83
  ? {
@@ -110,10 +123,10 @@ export function use(
110
123
  get: () => actor,
111
124
  })
112
125
 
113
- return inst as unknown as ClientInstance
126
+ return inst as unknown as ClientInstance<D>
114
127
  }
115
128
 
116
129
  /** Extract the actor from a `use()` instance (for the binding loop). */
117
- export function actorOf(inst: ClientInstance): AnyActor {
130
+ export function actorOf(inst: ClientInstanceBase): AnyActor {
118
131
  return inst.__actor
119
132
  }
@@ -43,10 +43,46 @@ export interface VirtualCodeResult {
43
43
  styles: VirtualFile[]
44
44
  }
45
45
 
46
- const TEMPLATE_IMPORTS =
47
- "import { read, each, when, match, on, classList, styleList, raw } from '@statorjs/stator/template';\n"
48
- const CLIENT_IMPORTS =
49
- "import { StatorElement, use, machine, defineElement, bind, effect, dispatch } from '@statorjs/stator/client';\n"
46
+ // Must mirror the RUNTIME's auto-injected globals exactly (compile.ts
47
+ // PRIMITIVES_IMPORT / client-emit.ts): a name injected here but not at
48
+ // runtime hides a missing-import bug; a name in both collides with the
49
+ // author's legitimate import (`raw` is NOT a runtime global authors
50
+ // import it — which is why it must not be in this list).
51
+ const TEMPLATE_GLOBALS = ['read', 'each', 'when', 'match', 'on', 'classList', 'styleList']
52
+ const CLIENT_GLOBALS = [
53
+ 'StatorElement',
54
+ 'use',
55
+ 'machine',
56
+ 'defineElement',
57
+ 'bind',
58
+ 'effect',
59
+ 'dispatch',
60
+ ]
61
+
62
+ /** Local names the user's code already binds from `modulePath` imports —
63
+ * the runtime strips such habit-imports; the virtual emit (which must keep
64
+ * offsets faithful) instead injects only what the user DIDN'T import. */
65
+ function userImportedLocals(code: string, modulePath: string): Set<string> {
66
+ const locals = new Set<string>()
67
+ const re = new RegExp(
68
+ `import\\s+(?:type\\s+)?\\{([^}]*)\\}\\s+from\\s+['"]${modulePath.replace(/\//g, '\\/')}['"]`,
69
+ 'g',
70
+ )
71
+ for (const m of code.matchAll(re)) {
72
+ for (const spec of m[1]!.split(',')) {
73
+ const parts = spec.trim().split(/\s+as\s+/)
74
+ const local = (parts[1] ?? parts[0] ?? '').trim().replace(/^type\s+/, '')
75
+ if (local) locals.add(local)
76
+ }
77
+ }
78
+ return locals
79
+ }
80
+
81
+ function injectImports(globals: string[], modulePath: string, userCode: string): string {
82
+ const bound = userImportedLocals(userCode, modulePath)
83
+ const missing = globals.filter((g) => !bound.has(g))
84
+ return missing.length > 0 ? `import { ${missing.join(', ')} } from '${modulePath}';\n` : ''
85
+ }
50
86
  // Aliased so they never collide with a component's own `InstanceOf` import.
51
87
  // NOTE: `InstanceOf` comes from /template, not /machine — the template flavor
52
88
  // includes `send`/`state`/`snapshot` (what a route-level binding actually is,
@@ -99,7 +135,11 @@ export function toVirtualCode(source: string): VirtualCodeResult {
99
135
  * template expressions see the frontmatter's bindings. */
100
136
  function buildServerTsx(regions: ScannedRegions): VirtualFile {
101
137
  const mappings: VirtualMapping[] = []
102
- let code = TEMPLATE_IMPORTS + AMBIENT_TYPE_IMPORTS + STATOR_AMBIENT
138
+ const userCode = (regions.frontmatter?.content ?? '') + regions.template.content
139
+ let code =
140
+ injectImports(TEMPLATE_GLOBALS, '@statorjs/stator/template', userCode) +
141
+ AMBIENT_TYPE_IMPORTS +
142
+ STATOR_AMBIENT
103
143
 
104
144
  if (regions.frontmatter?.content.trim()) {
105
145
  push(
@@ -145,7 +185,12 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
145
185
  * resolution — `bind:text={theme.label}` → the class field — is a later phase.) */
146
186
  function buildClientTsx(regions: ScannedRegions): VirtualFile {
147
187
  const mappings: VirtualMapping[] = []
148
- let code = TEMPLATE_IMPORTS + CLIENT_IMPORTS + AMBIENT_TYPE_IMPORTS + STATOR_AMBIENT
188
+ const userScript = regions.scripts.map((s) => s.content).join('\n')
189
+ let code =
190
+ injectImports(TEMPLATE_GLOBALS, '@statorjs/stator/template', userScript) +
191
+ injectImports(CLIENT_GLOBALS, '@statorjs/stator/client', userScript) +
192
+ AMBIENT_TYPE_IMPORTS +
193
+ STATOR_AMBIENT
149
194
 
150
195
  for (const script of regions.scripts) {
151
196
  push(mappings, script.contentOffset, code.length, script.content.length)
@@ -0,0 +1,114 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { networkInterfaces } from 'node:os'
3
+ import { logger } from './logger.ts'
4
+
5
+ /**
6
+ * Human-facing startup/exit output for the DEV plane. Production keeps
7
+ * structured pino logs (ops parse those); the dev server prints for a
8
+ * person: clickable URLs, a one-line inventory, a graceful goodbye.
9
+ */
10
+
11
+ const useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined
12
+ const c = {
13
+ bold: (s: string) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
14
+ dim: (s: string) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
15
+ cyan: (s: string) => (useColor ? `\x1b[36m${s}\x1b[0m` : s),
16
+ copper: (s: string) => (useColor ? `\x1b[38;5;173m${s}\x1b[0m` : s),
17
+ }
18
+
19
+ let cachedVersion: string | undefined
20
+ function statorVersion(): string {
21
+ if (!cachedVersion) {
22
+ try {
23
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
24
+ cachedVersion = String(pkg.version)
25
+ } catch {
26
+ cachedVersion = ''
27
+ }
28
+ }
29
+ return cachedVersion
30
+ }
31
+
32
+ function lanAddress(): string | undefined {
33
+ for (const nets of Object.values(networkInterfaces())) {
34
+ for (const net of nets ?? []) {
35
+ if (net.family === 'IPv4' && !net.internal) return net.address
36
+ }
37
+ }
38
+ return undefined
39
+ }
40
+
41
+ export function printDevBanner(info: {
42
+ port: number
43
+ /** The port originally asked for, when the server had to shift off it. */
44
+ requestedPort?: number
45
+ machines: number
46
+ routes: number
47
+ inspector?: boolean
48
+ }): void {
49
+ const v = statorVersion()
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
+ : []
55
+ const lines = [
56
+ '',
57
+ ` ${c.copper(c.bold('stator'))}${v ? c.dim(` v${v}`) : ''} ${c.dim('dev server')}`,
58
+ '',
59
+ ` ${c.dim('local')} ${c.cyan(`http://localhost:${info.port}/`)}`,
60
+ ...(lan ? [` ${c.dim('network')} ${c.cyan(`http://${lan}:${info.port}/`)}`] : []),
61
+ '',
62
+ ` ${c.dim(
63
+ `${info.machines} machine${info.machines === 1 ? '' : 's'} · ${info.routes} route${
64
+ info.routes === 1 ? '' : 's'
65
+ }${info.inspector ? ' · inspector on' : ''} — Ctrl+C to stop`,
66
+ )}`,
67
+ ...shifted,
68
+ '',
69
+ ]
70
+ process.stdout.write(`${lines.join('\n')}\n`)
71
+ }
72
+
73
+ /**
74
+ * Exit a server process like a well-mannered CLI: first signal closes
75
+ * cleanly and exits 0 (Ctrl+C is a normal action, not a failure — without
76
+ * this, the process dies 130 and pnpm prints an ELIFECYCLE error banner);
77
+ * a second signal force-quits for anything that hangs in close().
78
+ */
79
+ export function installGracefulShutdown(close: () => Promise<void> | void, quiet = false): void {
80
+ let closing = false
81
+ const handler = (signal: NodeJS.Signals) => {
82
+ if (closing) process.exit(130)
83
+ closing = true
84
+ if (!quiet) process.stdout.write(`\n${c.dim(' stopping…')}\n`)
85
+ void (async () => {
86
+ try {
87
+ await close()
88
+ } catch (err) {
89
+ logger.warn({ err: String(err) }, 'error during shutdown')
90
+ }
91
+ process.exit(0)
92
+ })()
93
+ void signal
94
+ }
95
+ process.on('SIGINT', handler)
96
+ process.on('SIGTERM', handler)
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
+ }
@@ -1,6 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import { serve } from '@hono/node-server'
3
3
  import type { AppStore } from './app-store.ts'
4
+ import { installGracefulShutdown } from './banner.ts'
4
5
  import { discoverMachines } from './discovery.ts'
5
6
  import { wireAppEffects } from './effects.ts'
6
7
  import { buildHonoApp } from './http.ts'
@@ -71,10 +72,25 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
71
72
  return {
72
73
  listen(port: number): Promise<void> {
73
74
  return new Promise((resolveFn) => {
74
- serve({ fetch: app.fetch, port }, () => {
75
+ const server = serve({ fetch: app.fetch, port }, () => {
75
76
  logger.info({ port, machines: defs.length, routes: routes.length }, 'listening')
76
77
  resolveFn()
77
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
+ })
91
+ // Ctrl+C / SIGTERM (deploy rollover) exits 0, not 130 — quiet in
92
+ // prod: the structured logs are the record.
93
+ installGracefulShutdown(() => new Promise<void>((done) => server.close(() => done())), true)
78
94
  })
79
95
  },
80
96
  fetch: (request: Request) => app.fetch(request),
package/src/server/dev.ts CHANGED
@@ -7,6 +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 { findFreePort, installGracefulShutdown, printDevBanner } from './banner.ts'
10
11
  import { logger } from './logger.ts'
11
12
  import type { MachineStore } from './machine-store.ts'
12
13
  import type { DiscoveredRoute } from './route-discovery.ts'
@@ -51,10 +52,14 @@ export interface DevApp {
51
52
  }
52
53
 
53
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)
54
59
  const vite = await createViteServer({
55
60
  root: resolve(config.root),
56
61
  appType: 'custom',
57
- server: { middlewareMode: true },
62
+ server: { middlewareMode: true, hmr: { port: hmrPort } },
58
63
  // machineStub keeps server machines out of browser module graphs: island
59
64
  // imports of a machine resolve to a `{ name }` stub (SSR loads are
60
65
  // untouched — `options.ssr` gates it).
@@ -194,20 +199,27 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
194
199
  const server = createHttpServer((req, res) => {
195
200
  vite.middlewares(req, res, () => honoListener(req, res))
196
201
  })
197
- return new Promise((resolveFn) => {
198
- server.listen(port, () => {
199
- logger.info(
200
- {
201
- port,
202
- mode: 'dev',
203
- machines: machineCount,
204
- routes: routes.length,
205
- },
206
- 'listening',
207
- )
208
- resolveFn()
209
- })
202
+ installGracefulShutdown(async () => {
203
+ await vite.close()
204
+ await new Promise<void>((done) => server.close(() => done()))
210
205
  })
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
+ )
211
223
  },
212
224
  close: () => vite.close(),
213
225
  }