@statorjs/stator 1.1.1 → 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/cached-store.ts +14 -0
- 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
|
}
|
|
@@ -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)
|
|
@@ -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
|
}
|