@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.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/package.json +99 -0
- package/src/build/build.ts +244 -0
- package/src/build/head.ts +43 -0
- package/src/build/index.ts +10 -0
- package/src/build/sync.ts +65 -0
- package/src/client/bind.ts +47 -0
- package/src/client/client-id.ts +10 -0
- package/src/client/dispatch.ts +62 -0
- package/src/client/element.ts +156 -0
- package/src/client/index.ts +13 -0
- package/src/client/inspector.ts +237 -0
- package/src/client/machine.ts +39 -0
- package/src/client/runtime.ts +246 -0
- package/src/client/use.ts +119 -0
- package/src/compiler/client-emit.ts +180 -0
- package/src/compiler/client-script.ts +256 -0
- package/src/compiler/compile.ts +459 -0
- package/src/compiler/diagnostics.ts +65 -0
- package/src/compiler/dts.ts +87 -0
- package/src/compiler/hash.ts +8 -0
- package/src/compiler/index.ts +48 -0
- package/src/compiler/lower.ts +0 -0
- package/src/compiler/regions.ts +79 -0
- package/src/compiler/split.ts +168 -0
- package/src/compiler/styles.ts +200 -0
- package/src/compiler/virtual-code.ts +184 -0
- package/src/components/index.ts +2 -0
- package/src/components/json-ld.ts +85 -0
- package/src/engine/actor.ts +248 -0
- package/src/engine/define-machine.ts +113 -0
- package/src/engine/index.ts +37 -0
- package/src/engine/types.ts +236 -0
- package/src/server/api-route.ts +149 -0
- package/src/server/app-dispatch.ts +52 -0
- package/src/server/app-store.ts +35 -0
- package/src/server/cached-store.ts +117 -0
- package/src/server/create-app.ts +83 -0
- package/src/server/define-machine.ts +10 -0
- package/src/server/dev.ts +255 -0
- package/src/server/discovery.ts +111 -0
- package/src/server/dispatch-context.ts +39 -0
- package/src/server/effects.ts +120 -0
- package/src/server/http.ts +475 -0
- package/src/server/index.ts +101 -0
- package/src/server/instance-proxy.ts +95 -0
- package/src/server/logger.ts +52 -0
- package/src/server/machine-store.ts +355 -0
- package/src/server/reads-helpers.ts +40 -0
- package/src/server/recompute.ts +287 -0
- package/src/server/redis-store.ts +116 -0
- package/src/server/render-context.ts +228 -0
- package/src/server/render.ts +111 -0
- package/src/server/route-discovery.ts +226 -0
- package/src/server/route-request.ts +36 -0
- package/src/server/routing.ts +175 -0
- package/src/server/session-lock.ts +33 -0
- package/src/server/session-runtime.ts +242 -0
- package/src/server/session.ts +29 -0
- package/src/server/sse.ts +175 -0
- package/src/server/store.ts +95 -0
- package/src/stator-modules.d.ts +12 -0
- package/src/template/client-shell.ts +65 -0
- package/src/template/conditional.ts +166 -0
- package/src/template/directives/core.ts +58 -0
- package/src/template/directives/list-attr.ts +183 -0
- package/src/template/directives/on.ts +22 -0
- package/src/template/each.ts +295 -0
- package/src/template/html.ts +180 -0
- package/src/template/index.ts +29 -0
- package/src/template/parser.ts +341 -0
- package/src/template/read.ts +50 -0
- package/src/template/types.ts +33 -0
- package/src/vite/index.ts +6 -0
- package/src/vite/plugin.ts +151 -0
- package/src/vite/stub.ts +79 -0
- package/src/wire/apply.ts +103 -0
- 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
|
+
}
|