@statorjs/stator 1.0.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/package.json +99 -0
  4. package/src/build/build.ts +244 -0
  5. package/src/build/head.ts +43 -0
  6. package/src/build/index.ts +10 -0
  7. package/src/build/sync.ts +65 -0
  8. package/src/client/bind.ts +47 -0
  9. package/src/client/client-id.ts +10 -0
  10. package/src/client/dispatch.ts +62 -0
  11. package/src/client/element.ts +156 -0
  12. package/src/client/index.ts +13 -0
  13. package/src/client/inspector.ts +237 -0
  14. package/src/client/machine.ts +39 -0
  15. package/src/client/runtime.ts +246 -0
  16. package/src/client/use.ts +119 -0
  17. package/src/compiler/client-emit.ts +180 -0
  18. package/src/compiler/client-script.ts +256 -0
  19. package/src/compiler/compile.ts +459 -0
  20. package/src/compiler/diagnostics.ts +65 -0
  21. package/src/compiler/dts.ts +87 -0
  22. package/src/compiler/hash.ts +8 -0
  23. package/src/compiler/index.ts +48 -0
  24. package/src/compiler/lower.ts +0 -0
  25. package/src/compiler/regions.ts +79 -0
  26. package/src/compiler/split.ts +168 -0
  27. package/src/compiler/styles.ts +200 -0
  28. package/src/compiler/virtual-code.ts +184 -0
  29. package/src/components/index.ts +2 -0
  30. package/src/components/json-ld.ts +85 -0
  31. package/src/engine/actor.ts +248 -0
  32. package/src/engine/define-machine.ts +113 -0
  33. package/src/engine/index.ts +37 -0
  34. package/src/engine/types.ts +236 -0
  35. package/src/server/api-route.ts +149 -0
  36. package/src/server/app-dispatch.ts +52 -0
  37. package/src/server/app-store.ts +35 -0
  38. package/src/server/cached-store.ts +117 -0
  39. package/src/server/create-app.ts +83 -0
  40. package/src/server/define-machine.ts +10 -0
  41. package/src/server/dev.ts +255 -0
  42. package/src/server/discovery.ts +111 -0
  43. package/src/server/dispatch-context.ts +39 -0
  44. package/src/server/effects.ts +120 -0
  45. package/src/server/http.ts +475 -0
  46. package/src/server/index.ts +101 -0
  47. package/src/server/instance-proxy.ts +95 -0
  48. package/src/server/logger.ts +52 -0
  49. package/src/server/machine-store.ts +355 -0
  50. package/src/server/reads-helpers.ts +40 -0
  51. package/src/server/recompute.ts +287 -0
  52. package/src/server/redis-store.ts +116 -0
  53. package/src/server/render-context.ts +228 -0
  54. package/src/server/render.ts +111 -0
  55. package/src/server/route-discovery.ts +226 -0
  56. package/src/server/route-request.ts +36 -0
  57. package/src/server/routing.ts +175 -0
  58. package/src/server/session-lock.ts +33 -0
  59. package/src/server/session-runtime.ts +242 -0
  60. package/src/server/session.ts +29 -0
  61. package/src/server/sse.ts +175 -0
  62. package/src/server/store.ts +95 -0
  63. package/src/stator-modules.d.ts +12 -0
  64. package/src/template/client-shell.ts +65 -0
  65. package/src/template/conditional.ts +166 -0
  66. package/src/template/directives/core.ts +58 -0
  67. package/src/template/directives/list-attr.ts +183 -0
  68. package/src/template/directives/on.ts +22 -0
  69. package/src/template/each.ts +295 -0
  70. package/src/template/html.ts +180 -0
  71. package/src/template/index.ts +29 -0
  72. package/src/template/parser.ts +341 -0
  73. package/src/template/read.ts +50 -0
  74. package/src/template/types.ts +33 -0
  75. package/src/vite/index.ts +6 -0
  76. package/src/vite/plugin.ts +151 -0
  77. package/src/vite/stub.ts +79 -0
  78. package/src/wire/apply.ts +103 -0
  79. package/src/wire/index.ts +67 -0
@@ -0,0 +1,111 @@
1
+ import { createRenderState, type RenderState, runInRender } from './render-context.ts'
2
+ import type {
3
+ RouteDefinition,
4
+ RouteRenderContext,
5
+ RouteRequest,
6
+ RouteResponseContext,
7
+ } from './routing.ts'
8
+ import type { SessionRuntime } from './session-runtime.ts'
9
+
10
+ export interface RenderResult {
11
+ html: string
12
+ renderState: RenderState
13
+ /** Response side effects the render function wrote (status, headers, cookies).
14
+ * Caller (HTTP layer) is responsible for applying these to the outgoing
15
+ * HTTP response. */
16
+ response: RenderedResponseEffects
17
+ }
18
+
19
+ export interface RenderedResponseEffects {
20
+ status: number
21
+ headers: Headers
22
+ cookies: Array<{
23
+ name: string
24
+ value: string
25
+ options: import('./routing.ts').RouteCookieOptions | undefined
26
+ deleted: boolean
27
+ }>
28
+ }
29
+
30
+ /**
31
+ * Build a fresh RouteResponseContext for a render. The render function
32
+ * mutates this; the HTTP layer reads back the effects and applies them
33
+ * to the outgoing Response.
34
+ */
35
+ function createResponseContext(): {
36
+ ctx: RouteResponseContext
37
+ effects: RenderedResponseEffects
38
+ } {
39
+ const effects: RenderedResponseEffects = {
40
+ status: 200,
41
+ headers: new Headers(),
42
+ cookies: [],
43
+ }
44
+ const ctx: RouteResponseContext = {
45
+ get status() {
46
+ return effects.status
47
+ },
48
+ set status(s: number) {
49
+ effects.status = s
50
+ },
51
+ headers: effects.headers,
52
+ cookies: {
53
+ set(name, value, options) {
54
+ effects.cookies.push({ name, value, options, deleted: false })
55
+ },
56
+ delete(name, options) {
57
+ effects.cookies.push({
58
+ name,
59
+ value: '',
60
+ options: { ...options, maxAge: 0 },
61
+ deleted: true,
62
+ })
63
+ },
64
+ },
65
+ }
66
+ return { ctx, effects }
67
+ }
68
+
69
+ /**
70
+ * Build the route's render context from a SessionRuntime (which already
71
+ * holds transient proxies for every machine the route depends on), then
72
+ * invoke the route's render function under a fresh RenderState.
73
+ *
74
+ * RenderState lifetime in the current model is request-scoped: a GET
75
+ * discards it after the response, a POST keeps it just long enough to
76
+ * run `recompute` against the post-event state. SSE connections retain
77
+ * it for the connection's lifetime.
78
+ *
79
+ * `request` carries URL-derived state and body access. `response` carries
80
+ * side-effect helpers (status, headers, cookies). The render function
81
+ * returns the rendered HTML; the HTTP layer combines the HTML with the
82
+ * response effects to build the final Response.
83
+ */
84
+ export function renderRoute(
85
+ route: RouteDefinition,
86
+ routeKey: string,
87
+ sessionId: string,
88
+ runtime: SessionRuntime,
89
+ request: RouteRequest,
90
+ ): RenderResult {
91
+ const state = createRenderState(sessionId, routeKey)
92
+ const { ctx: responseCtx, effects } = createResponseContext()
93
+ const renderCtx: RouteRenderContext = {
94
+ response: responseCtx,
95
+ } as RouteRenderContext
96
+
97
+ for (const def of route.reads) {
98
+ if (def.name === 'response') {
99
+ throw new Error(
100
+ 'stator: a machine cannot be named "response"; the render context reserves that key',
101
+ )
102
+ }
103
+ const proxy = runtime.proxyFor(def.name)
104
+ if (!proxy) {
105
+ throw new Error(`stator: route reads "${def.name}" but it's not loaded into the runtime`)
106
+ }
107
+ ;(renderCtx as Record<string, unknown>)[def.name] = proxy
108
+ }
109
+ const fragment = runInRender(state, () => route.render(renderCtx, request))
110
+ return { html: fragment.html, renderState: state, response: effects }
111
+ }
@@ -0,0 +1,226 @@
1
+ import { readdir } from 'node:fs/promises'
2
+ import { basename, dirname, extname, relative, resolve, sep } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import type { ModuleLoader } from './discovery.ts'
5
+ import {
6
+ type ApiRouteDefinition,
7
+ isStatorApiRoute,
8
+ isStatorRoute,
9
+ type RouteDefinition,
10
+ } from './routing.ts'
11
+
12
+ const nativeLoader: ModuleLoader = (file) => import(/* @vite-ignore */ pathToFileURL(file).href)
13
+
14
+ /** HTTP methods a route file may export. GET goes through `defineRoute`
15
+ * (page rendering); the rest go through `defineApiRoute` (mutation/API
16
+ * handlers). */
17
+ export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const
18
+ export type HttpMethod = (typeof HTTP_METHODS)[number]
19
+
20
+ export interface DiscoveredRoute {
21
+ /** Hono-shaped URL pattern. Path params are `:name`. */
22
+ urlPath: string
23
+ /** Names of path parameters in the order they appear in `urlPath`.
24
+ * Empty for static routes. */
25
+ paramNames: string[]
26
+ filePath: string
27
+ /** GET route (page render). At most one. */
28
+ GET?: RouteDefinition
29
+ /** API routes by method. */
30
+ POST?: ApiRouteDefinition
31
+ PUT?: ApiRouteDefinition
32
+ PATCH?: ApiRouteDefinition
33
+ DELETE?: ApiRouteDefinition
34
+ }
35
+
36
+ /**
37
+ * Walk the routes directory recursively and build URL patterns from file
38
+ * paths. Conventions:
39
+ *
40
+ * - `routes/foo.ts` → `/foo`
41
+ * - `routes/foo/index.ts` → `/foo`
42
+ * - `routes/foo/[id].ts` → `/foo/:id` (path param `id`)
43
+ * - `routes/[a]/[b].ts` → `/:a/:b`
44
+ *
45
+ * Files may export any combination of `GET`/`POST`/`PUT`/`PATCH`/`DELETE`.
46
+ * GET is a `defineRoute` (page renderer); the others are `defineApiRoute`.
47
+ *
48
+ * Files that don't export anything route-shaped are silently skipped. With
49
+ * recursive walking, the routes tree often contains utility files
50
+ * (templates, helpers) that aren't routes, and throwing on them would
51
+ * force separate trees.
52
+ */
53
+ export async function discoverRoutes(
54
+ dir: string,
55
+ load: ModuleLoader = nativeLoader,
56
+ ): Promise<DiscoveredRoute[]> {
57
+ const absDir = resolve(dir)
58
+ const files = await walkRouteFiles(absDir)
59
+ const routes: DiscoveredRoute[] = []
60
+
61
+ for (const filePath of files) {
62
+ const mod = await load(filePath)
63
+
64
+ // GET is a page route, POST/PUT/PATCH/DELETE are API routes.
65
+ const get = isStatorRoute(mod.GET) ? (mod.GET as RouteDefinition) : undefined
66
+ const post = isStatorApiRoute(mod.POST) ? (mod.POST as ApiRouteDefinition) : undefined
67
+ const put = isStatorApiRoute(mod.PUT) ? (mod.PUT as ApiRouteDefinition) : undefined
68
+ const patch = isStatorApiRoute(mod.PATCH) ? (mod.PATCH as ApiRouteDefinition) : undefined
69
+ const del = isStatorApiRoute(mod.DELETE) ? (mod.DELETE as ApiRouteDefinition) : undefined
70
+
71
+ if (!get && !post && !put && !patch && !del) continue
72
+
73
+ // Catch the easy mistake: GET defined with `defineApiRoute`, or a
74
+ // mutation method defined with `defineRoute`. Throw with a clear hint.
75
+ if (mod.GET && !get) {
76
+ throw new Error(
77
+ `stator: ${filePath} exports GET but it is not a defineRoute. ` +
78
+ `GET handlers must be created with defineRoute(); use defineApiRoute() for POST/PUT/PATCH/DELETE.`,
79
+ )
80
+ }
81
+ for (const m of ['POST', 'PUT', 'PATCH', 'DELETE'] as const) {
82
+ if (mod[m] && !isStatorApiRoute(mod[m])) {
83
+ throw new Error(
84
+ `stator: ${filePath} exports ${m} but it is not a defineApiRoute. ` +
85
+ `${m} handlers must be created with defineApiRoute(); defineRoute() is GET-only.`,
86
+ )
87
+ }
88
+ }
89
+
90
+ const { urlPath, paramNames } = filePathToRoute(absDir, filePath)
91
+ routes.push({
92
+ urlPath,
93
+ paramNames,
94
+ filePath,
95
+ GET: get,
96
+ POST: post,
97
+ PUT: put,
98
+ PATCH: patch,
99
+ DELETE: del,
100
+ })
101
+ }
102
+
103
+ return sortRoutes(mergeByUrlPath(routes))
104
+ }
105
+
106
+ /**
107
+ * Merge routes that resolve to the same URL pattern (e.g. `about.stator`
108
+ * contributing GET and `about.ts` contributing POST). Same method on two files
109
+ * for one URL is a hard error.
110
+ */
111
+ function mergeByUrlPath(routes: DiscoveredRoute[]): DiscoveredRoute[] {
112
+ const byPath = new Map<string, DiscoveredRoute>()
113
+ for (const r of routes) {
114
+ const existing = byPath.get(r.urlPath)
115
+ if (!existing) {
116
+ byPath.set(r.urlPath, r)
117
+ continue
118
+ }
119
+ for (const m of HTTP_METHODS) {
120
+ if (r[m] === undefined) continue
121
+ if (existing[m] !== undefined) {
122
+ throw new Error(
123
+ `stator: two files define ${m} for "${r.urlPath}" ` +
124
+ `(${existing.filePath} and ${r.filePath}). A URL may have at most one handler per method.`,
125
+ )
126
+ }
127
+ ;(existing as unknown as Record<string, unknown>)[m] = r[m]
128
+ }
129
+ }
130
+ return [...byPath.values()]
131
+ }
132
+
133
+ /**
134
+ * Sort routes most-specific-first (Astro's model). At match time the first
135
+ * matcher that matches wins, so order encodes priority:
136
+ * - routes without a rest/catch-all segment rank before those with one
137
+ * - per segment, left to right: static (0) > named param (1) > rest (2)
138
+ * - more segments before fewer
139
+ * - ties alphabetically by urlPath
140
+ */
141
+ export function sortRoutes(routes: DiscoveredRoute[]): DiscoveredRoute[] {
142
+ const kinds = (urlPath: string): number[] =>
143
+ urlPath
144
+ .split('/')
145
+ .filter(Boolean)
146
+ .map((seg) => (seg.startsWith('*') ? 2 : seg.startsWith(':') ? 1 : 0))
147
+
148
+ return [...routes].sort((a, b) => {
149
+ const ka = kinds(a.urlPath)
150
+ const kb = kinds(b.urlPath)
151
+ const aRest = ka.includes(2)
152
+ const bRest = kb.includes(2)
153
+ if (aRest !== bRest) return aRest ? 1 : -1 // no-rest first
154
+ const len = Math.min(ka.length, kb.length)
155
+ for (let i = 0; i < len; i++) {
156
+ if (ka[i] !== kb[i]) return ka[i]! - kb[i]! // lower kind = more specific
157
+ }
158
+ if (ka.length !== kb.length) return kb.length - ka.length // more segments first
159
+ return a.urlPath < b.urlPath ? -1 : a.urlPath > b.urlPath ? 1 : 0
160
+ })
161
+ }
162
+
163
+ async function walkRouteFiles(dir: string): Promise<string[]> {
164
+ const out: string[] = []
165
+ // A missing routes dir means "no routes yet", not an error.
166
+ const entries = await readdir(dir, { withFileTypes: true }).catch((e: NodeJS.ErrnoException) => {
167
+ if (e.code === 'ENOENT') return []
168
+ throw e
169
+ })
170
+ for (const e of entries) {
171
+ const full = resolve(dir, e.name)
172
+ if (e.isDirectory()) {
173
+ out.push(...(await walkRouteFiles(full)))
174
+ continue
175
+ }
176
+ if (!e.isFile()) continue
177
+ const ext = extname(e.name)
178
+ // `.stator` route pages compile (via the loader) to a module exporting GET;
179
+ // `.ts`/`.js` carry API handlers (and merge with a same-named page).
180
+ if (ext !== '.ts' && ext !== '.js' && ext !== '.stator') continue
181
+ out.push(full)
182
+ }
183
+ return out
184
+ }
185
+
186
+ /**
187
+ * Turn an absolute file path inside the routes dir into a `/foo/:bar` URL
188
+ * pattern plus the list of param names extracted from `[brackets]`.
189
+ */
190
+ export function filePathToRoute(
191
+ absDir: string,
192
+ filePath: string,
193
+ ): { urlPath: string; paramNames: string[] } {
194
+ const ext = extname(filePath)
195
+ const rel = relative(absDir, filePath)
196
+ const dirSegments = dirname(rel)
197
+ .split(sep)
198
+ .filter((s) => s && s !== '.')
199
+ // Strip a residual `.stator` too: production builds compile route pages to
200
+ // sibling `<name>.stator.ts` files, which must map to the same URL as the
201
+ // `<name>.stator` source did in dev.
202
+ const fileBase = basename(rel, ext).replace(/\.stator$/, '')
203
+ const segments = fileBase === 'index' ? dirSegments : [...dirSegments, fileBase]
204
+
205
+ if (segments.length === 0) return { urlPath: '/', paramNames: [] }
206
+
207
+ const paramNames: string[] = []
208
+ const urlSegments = segments.map((seg) => {
209
+ // Rest / catch-all: `[...name]` → `*name` (matches zero or more segments).
210
+ const rest = seg.match(/^\[\.\.\.(.+)\]$/)
211
+ if (rest) {
212
+ const name = rest[1]!
213
+ paramNames.push(name)
214
+ return `*${name}`
215
+ }
216
+ const m = seg.match(/^\[(.+)\]$/)
217
+ if (m) {
218
+ const name = m[1]!
219
+ paramNames.push(name)
220
+ return `:${name}`
221
+ }
222
+ return seg
223
+ })
224
+
225
+ return { urlPath: `/${urlSegments.join('/')}`, paramNames }
226
+ }
@@ -0,0 +1,36 @@
1
+ import type { Context } from 'hono'
2
+ import type { RouteRequest } from './routing.ts'
3
+
4
+ /**
5
+ * Wrap Hono's request into the framework's RouteRequest shape. The raw
6
+ * `Request` is exposed for escape hatches, plus a small layer of convenience
7
+ * (params + query) the framework already does at routing time.
8
+ */
9
+ export function buildRouteRequest(c: Context, paramNames: string[]): RouteRequest {
10
+ const raw = c.req.raw
11
+ const params: Record<string, string> = {}
12
+ for (const name of paramNames) {
13
+ const v = c.req.param(name)
14
+ if (v !== undefined) params[name] = v
15
+ }
16
+ const query: Record<string, string | undefined> = c.req.query()
17
+
18
+ return {
19
+ raw,
20
+ params,
21
+ query,
22
+ get method() {
23
+ return raw.method
24
+ },
25
+ get url() {
26
+ return raw.url
27
+ },
28
+ get headers() {
29
+ return raw.headers
30
+ },
31
+ formData: () => raw.formData(),
32
+ json: <T = unknown>() => raw.json() as Promise<T>,
33
+ text: () => raw.text(),
34
+ arrayBuffer: () => raw.arrayBuffer(),
35
+ }
36
+ }
@@ -0,0 +1,175 @@
1
+ import type { HtmlFragment } from '../template/types.ts'
2
+ import type { WireEnvelope } from '../wire/index.ts'
3
+ import type { AnyMachineDef, EventOf } from './define-machine.ts'
4
+
5
+ /** Machine context passed to a route's render function. Keyed by machine name. */
6
+ export type RouteContext = Record<string, unknown>
7
+
8
+ /**
9
+ * Request context for routes. Mostly delegates to the underlying `Request`
10
+ * via `raw`, plus convenience fields for the bits that need parsing the
11
+ * framework already does (path params, query strings).
12
+ *
13
+ * Same shape on `defineRoute` and `defineApiRoute`. GETs ignore body access,
14
+ * API routes use it.
15
+ */
16
+ export interface RouteRequest {
17
+ /** The underlying Web Platform Request. Escape hatch for anything the
18
+ * wrapper doesn't expose directly. */
19
+ raw: Request
20
+ /** Path params extracted from `[name]` segments. Always strings. */
21
+ params: Record<string, string>
22
+ /** Query string params from the URL. Repeated keys collapse to the
23
+ * first value (Hono's default). */
24
+ query: Record<string, string | undefined>
25
+ /** HTTP method. Same as `raw.method`. */
26
+ readonly method: string
27
+ /** Full request URL. Same as `raw.url`. */
28
+ readonly url: string
29
+ /** Request headers. Same Headers instance as `raw.headers`. */
30
+ readonly headers: Headers
31
+ /** Parsed form body. Throws if the content type doesn't match. */
32
+ formData(): Promise<FormData>
33
+ /** Parsed JSON body. Throws on invalid JSON. */
34
+ json<T = unknown>(): Promise<T>
35
+ /** Raw text body. */
36
+ text(): Promise<string>
37
+ /** Raw binary body. */
38
+ arrayBuffer(): Promise<ArrayBuffer>
39
+ }
40
+
41
+ /** Options for `response.cookies.set`. */
42
+ export interface RouteCookieOptions {
43
+ domain?: string
44
+ path?: string
45
+ expires?: Date
46
+ maxAge?: number
47
+ httpOnly?: boolean
48
+ secure?: boolean
49
+ sameSite?: 'Strict' | 'Lax' | 'None'
50
+ }
51
+
52
+ /**
53
+ * Response side-effect surface for `defineRoute` render functions. Pages
54
+ * return their content (HtmlFragment) and influence response-level
55
+ * concerns by mutating this object. The framework combines the rendered
56
+ * HTML with whatever was set here to build the final HTTP response.
57
+ *
58
+ * `headers` is a real Web Platform `Headers` instance. `status` is a
59
+ * settable property. `cookies` is a focused helper because the cookie
60
+ * attribute model is enough of its own thing to deserve a dedicated API.
61
+ */
62
+ export interface RouteResponseContext {
63
+ /** HTTP status code. Default 200. */
64
+ status: number
65
+ /** Response headers. Mutable; standard Headers API. */
66
+ readonly headers: Headers
67
+ /** Cookie helpers. Distinct from headers because cookie attributes
68
+ * (HttpOnly, SameSite, etc.) deserve a focused API. */
69
+ readonly cookies: {
70
+ set(name: string, value: string, options?: RouteCookieOptions): void
71
+ delete(name: string, options?: Pick<RouteCookieOptions, 'path' | 'domain'>): void
72
+ }
73
+ }
74
+
75
+ /** Machine context for `defineRoute` includes the response side-effect
76
+ * surface alongside the machine proxies. */
77
+ export type RouteRenderContext = RouteContext & {
78
+ /** Reserved key. User machines named `response` would collide; reserved
79
+ * in the discovery validator. */
80
+ response: RouteResponseContext
81
+ }
82
+
83
+ export interface RouteDefinition {
84
+ readonly __isStatorRoute: true
85
+ reads: AnyMachineDef[]
86
+ render: (ctx: RouteRenderContext, request: RouteRequest) => HtmlFragment
87
+ /** When true, the rendered page opens an SSE channel that receives
88
+ * patches when any of the route's `reads:` machines change state — from
89
+ * any session, not just the viewer's own POSTs. Opt-in: routes without
90
+ * this flag operate purely on POST request/response. */
91
+ live: boolean
92
+ }
93
+
94
+ export interface DefineRouteConfig<TReads extends ReadonlyArray<AnyMachineDef>> {
95
+ reads: TReads
96
+ render: (ctx: RouteRenderContext, request: RouteRequest) => HtmlFragment
97
+ live?: boolean
98
+ }
99
+
100
+ export function defineRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
101
+ config: DefineRouteConfig<TReads>,
102
+ ): RouteDefinition {
103
+ return {
104
+ __isStatorRoute: true,
105
+ reads: [...config.reads],
106
+ render: config.render,
107
+ live: config.live ?? false,
108
+ }
109
+ }
110
+
111
+ export function isStatorRoute(v: unknown): v is RouteDefinition {
112
+ return (
113
+ typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isStatorRoute === true
114
+ )
115
+ }
116
+
117
+ /* ------------------------------------------------------------------ */
118
+ /* API routes (defineApiRoute) */
119
+ /* ------------------------------------------------------------------ */
120
+
121
+ export type { Directive } from '../wire/index.ts'
122
+
123
+ /** Envelope shape API route handlers return when they want the framework
124
+ * to synthesize an HTTP response. Returning a raw Response is also OK.
125
+ * Same shape as the wire envelope the client receives. */
126
+ export type ApiRouteEnvelope = WireEnvelope
127
+
128
+ export type ApiRouteResult = Response | ApiRouteEnvelope
129
+
130
+ /** Helpers available inside an API route handler. The framework provides
131
+ * these; user handlers ignore the ones they don't need. */
132
+ export interface ApiRouteHelpers {
133
+ /** Dispatch an event to a machine, addressed by the imported machine def
134
+ * (not a magic string). The target name is read from `machine.name`; the
135
+ * event is type-checked against that machine's declared event union.
136
+ * Processes the event under the dispatch context, persists touched machines,
137
+ * fires cross-machine subscriptions. The machine must be in the route's
138
+ * loaded graph (its `reads`, transitively). */
139
+ dispatch: <D extends AnyMachineDef>(machine: D, event: EventOf<D>) => Promise<void>
140
+ }
141
+
142
+ export interface ApiRouteDefinition {
143
+ readonly __isStatorApiRoute: true
144
+ reads: AnyMachineDef[]
145
+ handler: (
146
+ request: RouteRequest,
147
+ helpers: ApiRouteHelpers,
148
+ ) => Promise<ApiRouteResult> | ApiRouteResult
149
+ }
150
+
151
+ export interface DefineApiRouteConfig<TReads extends ReadonlyArray<AnyMachineDef>> {
152
+ reads?: TReads
153
+ handler: (
154
+ request: RouteRequest,
155
+ helpers: ApiRouteHelpers,
156
+ ) => Promise<ApiRouteResult> | ApiRouteResult
157
+ }
158
+
159
+ export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
160
+ config: DefineApiRouteConfig<TReads>,
161
+ ): ApiRouteDefinition {
162
+ return {
163
+ __isStatorApiRoute: true,
164
+ reads: config.reads ? [...config.reads] : [],
165
+ handler: config.handler,
166
+ }
167
+ }
168
+
169
+ export function isStatorApiRoute(v: unknown): v is ApiRouteDefinition {
170
+ return (
171
+ typeof v === 'object' &&
172
+ v !== null &&
173
+ (v as Record<string, unknown>).__isStatorApiRoute === true
174
+ )
175
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Per-session async lock, shared by every state-mutating entry point —
3
+ * `POST /__events` (http.ts) and API-route handlers (api-route.ts).
4
+ *
5
+ * Serializes each session's load → mutate → persist cycle so concurrent
6
+ * mutations can't interleave and lose writes, *regardless of which path they
7
+ * arrive through*. The map must be process-wide and single: two entry points
8
+ * holding separate maps would each serialize against themselves but not
9
+ * against each other, letting an /__events POST and an API-route mutation on
10
+ * the same session race.
11
+ *
12
+ * GETs are read-only and do not acquire the lock.
13
+ */
14
+ const sessionLocks = new Map<string, Promise<unknown>>()
15
+
16
+ export function withSessionLock<T>(sid: string, fn: () => Promise<T>): Promise<T> {
17
+ const prev = sessionLocks.get(sid) ?? Promise.resolve()
18
+ const next = prev.then(fn, fn)
19
+ const settled = next.then(
20
+ () => undefined,
21
+ () => undefined,
22
+ )
23
+ sessionLocks.set(sid, settled)
24
+ void settled.then(() => {
25
+ if (sessionLocks.get(sid) === settled) sessionLocks.delete(sid)
26
+ })
27
+ return next
28
+ }
29
+
30
+ /** Number of sessions with an in-flight or queued mutation (test/observability). */
31
+ export function activeSessionLockCount(): number {
32
+ return sessionLocks.size
33
+ }