@statorjs/stator 1.1.0 → 1.2.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/README.md CHANGED
@@ -96,7 +96,7 @@ const [counter] = Stator.reads([Counter])
96
96
  </html>
97
97
  ```
98
98
 
99
- Run with `tsx server.ts`. See the repo's `apps/example` for the full wiring
99
+ Run with `tsx server.ts`. See the repo's `examples/desksmith` for the full wiring
100
100
  (type sync, production build, deploy).
101
101
 
102
102
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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.2.0"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import type { Context } from 'hono'
2
3
  import { setCookie } from 'hono/cookie'
3
4
  import { scheduleSessionEffects } from './effects.ts'
@@ -13,7 +14,7 @@ import type {
13
14
  Directive,
14
15
  RouteRequest,
15
16
  } from './routing.ts'
16
- import { getOrCreateSessionId } from './session.ts'
17
+ import { getOrCreateSessionId, setSessionCookie } from './session.ts'
17
18
  import { withSessionLock } from './session-lock.ts'
18
19
  import { SessionRuntime } from './session-runtime.ts'
19
20
  import { fanOut } from './sse.ts'
@@ -42,6 +43,7 @@ export async function runApiRoute(
42
43
  params?: Record<string, string>,
43
44
  ): Promise<Response> {
44
45
  const { sessionId } = getOrCreateSessionId(c)
46
+ let rotation: { clear: boolean } | null = null
45
47
  const request = params
46
48
  ? { ...buildRouteRequest(c, discovered.paramNames), params }
47
49
  : buildRouteRequest(c, discovered.paramNames)
@@ -63,6 +65,12 @@ export async function runApiRoute(
63
65
  // The target machine is addressed by its def; read the name off it.
64
66
  const dispatchedTouched = runtime.processEvent(machine.name, event)
65
67
  for (const name of dispatchedTouched) touched.add(name)
68
+ // Same honesty as client dispatch: a guard-dropped event commits
69
+ // nothing, and handlers (login flows especially) need to know.
70
+ return { committed: dispatchedTouched.size > 0 }
71
+ },
72
+ rotateSession: (opts) => {
73
+ rotation = { clear: opts?.clear === true }
66
74
  },
67
75
  }
68
76
 
@@ -78,9 +86,32 @@ export async function runApiRoute(
78
86
  await runtime.persistTouched(touched)
79
87
  await fanOut(touched, { sessionId })
80
88
  }
89
+
90
+ // Session rotation (fixation defense). Order matters: the runtime has
91
+ // already persisted under the OLD id and fan-out has reached the old
92
+ // id's connections (about to be navigated away) — now the whole
93
+ // session moves (or, for logout, dies) and the response carries the
94
+ // new cookie. Effect completions must chase the NEW id.
95
+ let effectsSessionId = sessionId
96
+ if (rotation !== null) {
97
+ const newSessionId = randomUUID()
98
+ if ((rotation as { clear: boolean }).clear) {
99
+ await store.persistence.deleteSession(sessionId)
100
+ } else {
101
+ if (!store.persistence.renameSession) {
102
+ throw new Error(
103
+ 'stator: rotateSession requires a store with renameSession — ' +
104
+ 'the configured custom store does not implement it.',
105
+ )
106
+ }
107
+ await store.persistence.renameSession(sessionId, newSessionId)
108
+ }
109
+ setSessionCookie(c, newSessionId)
110
+ effectsSessionId = newSessionId
111
+ }
81
112
  // Effects queued by dispatched events run after this callback returns
82
113
  // (never under the session lock); see server/effects.ts.
83
- scheduleSessionEffects(runtime, store, sessionId)
114
+ scheduleSessionEffects(runtime, store, effectsSessionId)
84
115
 
85
116
  // Escape hatch: handler returned a real Response. Pass through.
86
117
  if (result instanceof Response) return result
@@ -18,7 +18,7 @@ export async function dispatchToApp<D extends AnyMachineDef>(
18
18
  store: MachineStore,
19
19
  machine: D,
20
20
  event: EventOf<D>,
21
- ): Promise<void> {
21
+ ): Promise<{ committed: boolean }> {
22
22
  const name = machine.name
23
23
  const handle = store.appInstance(name)
24
24
  if (!handle) {
@@ -46,6 +46,10 @@ export async function dispatchToApp<D extends AnyMachineDef>(
46
46
  await store.persistAppMachine(touchedName)
47
47
  }
48
48
  await fanOut(touched)
49
+ // Same contract as client and API-route dispatch: did the TARGET machine
50
+ // actually commit a transition? Webhook receivers use this to tell a
51
+ // processed event from a guard-dropped duplicate.
52
+ return { committed: touched.has(name) }
49
53
  } finally {
50
54
  runtime.dispose()
51
55
  }
@@ -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
+ }
@@ -99,6 +99,20 @@ export class CachedStore implements Store {
99
99
  await this.backing.deleteSession(sid)
100
100
  }
101
101
 
102
+ async renameSession(oldSid: string, newSid: string): Promise<void> {
103
+ // Drop (don't move) cache entries — the next read repopulates from the
104
+ // backing under the new id. Simpler than key surgery, and rotation is
105
+ // rare (login/logout).
106
+ const prefix = `${oldSid}:`
107
+ for (const k of [...this.cache.keys()]) {
108
+ if (k.startsWith(prefix)) this.cache.delete(k)
109
+ }
110
+ if (!this.backing.renameSession) {
111
+ throw new Error('stator: CachedStore backing store does not support renameSession')
112
+ }
113
+ await this.backing.renameSession(oldSid, newSid)
114
+ }
115
+
102
116
  /** Insert / refresh an entry at LRU-end and evict oldest if over capacity. */
103
117
  private populate(key: string, snapshot: unknown, ttlMs: number): void {
104
118
  this.cache.delete(key)
@@ -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
  }
@@ -69,6 +69,14 @@ export class RedisStore implements Store {
69
69
  await this.client.del(this.key(sid))
70
70
  }
71
71
 
72
+ async renameSession(oldSid: string, newSid: string): Promise<void> {
73
+ // RENAME is atomic and carries the hash's TTL; it throws when the source
74
+ // is missing (an empty session has nothing to move — fine).
75
+ if ((await this.client.exists(this.key(oldSid))) === 1) {
76
+ await this.client.rename(this.key(oldSid), this.key(newSid))
77
+ }
78
+ }
79
+
72
80
  /** Close the connection. Call on graceful shutdown. */
73
81
  async close(): Promise<void> {
74
82
  await this.client.quit()
@@ -136,7 +136,18 @@ export interface ApiRouteHelpers {
136
136
  * Processes the event under the dispatch context, persists touched machines,
137
137
  * fires cross-machine subscriptions. The machine must be in the route's
138
138
  * loaded graph (its `reads`, transitively). */
139
- dispatch: <D extends AnyMachineDef>(machine: D, event: EventOf<D>) => Promise<void>
139
+ dispatch: <D extends AnyMachineDef>(
140
+ machine: D,
141
+ event: EventOf<D>,
142
+ ) => Promise<{ committed: boolean }>
143
+ /** Rotate the session id — the fixation defense for privilege changes.
144
+ * Call on login (state moves to a fresh id; the old id becomes worthless
145
+ * to anyone who captured it) and on logout with `{ clear: true }` (the
146
+ * old session's state is DELETED and the browser starts anonymous).
147
+ * Applied after the handler returns: state persists under the new id and
148
+ * the response carries the new cookie. Requires a store with
149
+ * `renameSession` (all built-in stores have it). */
150
+ rotateSession: (opts?: { clear?: boolean }) => void
140
151
  }
141
152
 
142
153
  export interface ApiRouteDefinition {
@@ -12,6 +12,17 @@ function shouldUseSecureCookie(): boolean {
12
12
  return process.env.NODE_ENV === 'production'
13
13
  }
14
14
 
15
+ /** Write the session cookie — shared by session creation and rotation so
16
+ * the flags can never drift apart. */
17
+ export function setSessionCookie(c: Context, sessionId: string): void {
18
+ setCookie(c, SESSION_COOKIE, sessionId, {
19
+ httpOnly: true,
20
+ sameSite: 'Lax',
21
+ path: '/',
22
+ secure: shouldUseSecureCookie(),
23
+ })
24
+ }
25
+
15
26
  export function getOrCreateSessionId(c: Context): {
16
27
  sessionId: string
17
28
  isNew: boolean
@@ -19,11 +30,6 @@ export function getOrCreateSessionId(c: Context): {
19
30
  const existing = getCookie(c, SESSION_COOKIE)
20
31
  if (existing) return { sessionId: existing, isNew: false }
21
32
  const sessionId = randomUUID()
22
- setCookie(c, SESSION_COOKIE, sessionId, {
23
- httpOnly: true,
24
- sameSite: 'Lax',
25
- path: '/',
26
- secure: shouldUseSecureCookie(),
27
- })
33
+ setSessionCookie(c, sessionId)
28
34
  return { sessionId, isNew: true }
29
35
  }
@@ -29,6 +29,10 @@ export interface Store {
29
29
  ): Promise<void>
30
30
  has(sessionId: string, machineName: string): Promise<boolean>
31
31
  deleteSession(sessionId: string): Promise<void>
32
+ /** Move EVERY machine snapshot from one session id to another (used by
33
+ * session rotation on privilege change). Optional for custom adapters;
34
+ * rotation fails loudly when the configured store lacks it. */
35
+ renameSession?(oldSessionId: string, newSessionId: string): Promise<void>
32
36
  }
33
37
 
34
38
  /**
@@ -92,4 +96,16 @@ export class InMemoryStore implements Store {
92
96
  async deleteSession(sid: string): Promise<void> {
93
97
  this.dropSession(sid)
94
98
  }
99
+
100
+ async renameSession(oldSid: string, newSid: string): Promise<void> {
101
+ if (this.isExpired(oldSid)) {
102
+ this.dropSession(oldSid)
103
+ return
104
+ }
105
+ const session = this.data.get(oldSid)
106
+ const expiry = this.expiryAt.get(oldSid)
107
+ this.dropSession(oldSid)
108
+ if (session) this.data.set(newSid, session)
109
+ if (expiry !== undefined) this.expiryAt.set(newSid, expiry)
110
+ }
95
111
  }