@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 +1 -1
- package/package.json +2 -2
- package/src/server/api-route.ts +33 -2
- package/src/server/app-dispatch.ts +5 -1
- package/src/server/banner.ts +24 -0
- package/src/server/cached-store.ts +14 -0
- package/src/server/create-app.ts +12 -0
- package/src/server/dev.ts +23 -8
- package/src/server/redis-store.ts +8 -0
- package/src/server/routing.ts +12 -1
- package/src/server/session.ts +12 -6
- package/src/server/store.ts +16 -0
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 `
|
|
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.
|
|
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.
|
|
91
|
+
"@statorjs/stator": "1.2.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
package/src/server/api-route.ts
CHANGED
|
@@ -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,
|
|
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<
|
|
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
|
}
|
package/src/server/banner.ts
CHANGED
|
@@ -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)
|
package/src/server/create-app.ts
CHANGED
|
@@ -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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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()
|
package/src/server/routing.ts
CHANGED
|
@@ -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>(
|
|
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 {
|
package/src/server/session.ts
CHANGED
|
@@ -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
|
-
|
|
23
|
-
httpOnly: true,
|
|
24
|
-
sameSite: 'Lax',
|
|
25
|
-
path: '/',
|
|
26
|
-
secure: shouldUseSecureCookie(),
|
|
27
|
-
})
|
|
33
|
+
setSessionCookie(c, sessionId)
|
|
28
34
|
return { sessionId, isNew: true }
|
|
29
35
|
}
|
package/src/server/store.ts
CHANGED
|
@@ -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
|
}
|