@statorjs/stator 1.6.0 → 1.6.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
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.6.0"
91
+ "@statorjs/stator": "1.6.1"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
@@ -5,9 +5,11 @@
5
5
  */
6
6
 
7
7
  export { bind, effect } from './bind.ts'
8
+ export type { DispatchResult } from './dispatch.ts'
8
9
  export { dispatch } from './dispatch.ts'
9
10
  export { defineElement, StatorElement } from './element.ts'
10
11
  export type { ClientBehavior, LegacyMachineConfig } from './machine.ts'
11
12
  export { machine } from './machine.ts'
13
+ export type { DispatchError } from './transport.ts'
12
14
  export type { ClientInstance, ClientInstanceBase } from './use.ts'
13
15
  export { use } from './use.ts'
@@ -13,6 +13,7 @@ export { defineMachine } from './define-machine.ts'
13
13
  export type {
14
14
  Action,
15
15
  ActionHelpers,
16
+ AfterEntry,
16
17
  AnyMachineDef,
17
18
  Capabilities,
18
19
  Effect,
@@ -20,6 +21,7 @@ export type {
20
21
  EffectMeta,
21
22
  EmitDeclaration,
22
23
  EmitsConfig,
24
+ EntryEffect,
23
25
  EventObject,
24
26
  EventOf,
25
27
  Guard,
@@ -121,9 +121,17 @@ export async function runApiRoute(
121
121
  scheduleSessionEffects(runtime, store, effectsSessionId)
122
122
 
123
123
  // Escape hatch: handler returned a real Response. Pass through.
124
- if (result instanceof Response) return result
124
+ if (isResponseLike(result)) return result
125
125
 
126
126
  const envelope = result as ApiRouteEnvelope
127
+ if (envelope.patches === undefined && envelope.directives === undefined) {
128
+ // Neither Response-shaped nor envelope-shaped: synthesizing an empty
129
+ // envelope would silently discard whatever the handler meant to send.
130
+ apiLog.warn(
131
+ { path: c.req.path },
132
+ 'api route returned a value that is neither a Response nor a {patches, directives} envelope — treating it as an empty envelope',
133
+ )
134
+ }
127
135
  return synthesizeResponse(c, request, envelope)
128
136
  } finally {
129
137
  runtime.dispose()
@@ -131,6 +139,22 @@ export async function runApiRoute(
131
139
  })
132
140
  }
133
141
 
142
+ /** `instanceof Response` alone misses Response objects from another realm or
143
+ * a wrapping library (Vite SSR loaders, @hono/node-server's lightweight
144
+ * class) — and a miss here silently reinterprets the handler's Response as
145
+ * an envelope. Duck-type the shape the passthrough actually needs. */
146
+ export function isResponseLike(v: unknown): v is Response {
147
+ if (v instanceof Response) return true
148
+ const r = v as Response | null
149
+ return (
150
+ typeof r === 'object' &&
151
+ r !== null &&
152
+ typeof r.status === 'number' &&
153
+ typeof r.headers?.get === 'function' &&
154
+ typeof r.arrayBuffer === 'function'
155
+ )
156
+ }
157
+
134
158
  /** The Referer header is attacker-controllable, so redirecting back to it is an
135
159
  * open-redirect vector. Return a same-origin relative path (pathname+search)
136
160
  * when the referer matches this request's origin, else '/'. */
@@ -1,5 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import { serve } from '@hono/node-server'
3
+ import type { AnyMachineDef, EventOf } from '../engine/index.ts'
4
+ import { dispatchToApp } from './app-dispatch.ts'
3
5
  import type { AppStore } from './app-store.ts'
4
6
  import { installGracefulShutdown } from './banner.ts'
5
7
  import { discoverMachines } from './discovery.ts'
@@ -38,9 +40,16 @@ export interface StatorApp {
38
40
  listen(port: number): Promise<void>
39
41
  /** For tests — get the underlying Hono fetch handler. */
40
42
  fetch: (request: Request) => Response | Promise<Response>
41
- /** The machine registry + app actors — pass to `dispatchToApp` for
42
- * server-originated events (webhooks, cron). */
43
+ /** The machine registry + app actors. Kept exposed for advanced use;
44
+ * prefer the `dispatchToApp` method for server-originated events. */
43
45
  store: MachineStore
46
+ /** Server-originated dispatch to an APP-lifecycle machine — the entry
47
+ * point for webhooks and cron. Same contract as the standalone
48
+ * `dispatchToApp(store, …)`, bound to this app's store. */
49
+ dispatchToApp<D extends AnyMachineDef>(
50
+ machine: D,
51
+ event: EventOf<D>,
52
+ ): Promise<{ committed: boolean }>
44
53
  }
45
54
 
46
55
  export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
@@ -100,5 +109,6 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
100
109
  },
101
110
  fetch: (request: Request) => app.fetch(request),
102
111
  store,
112
+ dispatchToApp: (machine, event) => dispatchToApp(store, machine, event),
103
113
  }
104
114
  }
package/src/server/dev.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'node:fs'
1
2
  import { readFile } from 'node:fs/promises'
2
3
  import { createServer as createHttpServer } from 'node:http'
3
4
  import { relative, resolve } from 'node:path'
@@ -5,12 +6,14 @@ import { getRequestListener } from '@hono/node-server'
5
6
  import type { Hono } from 'hono'
6
7
  import { createServer as createViteServer, type ViteDevServer } from 'vite'
7
8
  import { compile } from '../compiler/index.ts'
9
+ import type { AnyMachineDef, EventOf } from '../engine/index.ts'
8
10
  import { machineStub, stator } from '../vite/index.ts'
9
11
  import type { AppStore } from './app-store.ts'
10
12
  import { findFreePort, installGracefulShutdown, printDevBanner } from './banner.ts'
11
13
  import { logger } from './logger.ts'
12
14
  import type { MachineStore } from './machine-store.ts'
13
15
  import type { DiscoveredRoute } from './route-discovery.ts'
16
+ import { isStatorQueryRoute } from './routing.ts'
14
17
  import type { Store } from './store.ts'
15
18
 
16
19
  /**
@@ -47,6 +50,16 @@ export interface DevAppConfig {
47
50
  export interface DevApp {
48
51
  fetch: (request: Request) => Response | Promise<Response>
49
52
  vite: ViteDevServer
53
+ /** Server-originated dispatch to an APP-lifecycle machine (webhooks, cron)
54
+ * — the dev counterpart of `StatorApp.dispatchToApp`. Closes over the
55
+ * current store (surviving machine-edit rebuilds) and runs through the
56
+ * Vite-loaded runtime, so SSE fan-out reaches live connections; a
57
+ * natively-imported `dispatchToApp` would be a second module instance
58
+ * whose connection registry is empty. */
59
+ dispatchToApp<D extends AnyMachineDef>(
60
+ machine: D,
61
+ event: EventOf<D>,
62
+ ): Promise<{ committed: boolean }>
50
63
  listen: (port: number) => Promise<void>
51
64
  close: () => Promise<void>
52
65
  }
@@ -153,6 +166,19 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
153
166
  }
154
167
  const rebuildRoutes = async (): Promise<void> => {
155
168
  routes = await runtime.discoverRoutes(routesDir, loader)
169
+ // Vite serves `public/` at the root ahead of Hono in dev, so a static
170
+ // file at a data route's URL silently wins. Warn at discovery time.
171
+ // (The brand is a plain property, so this native-import check works on
172
+ // defs the Vite-loaded runtime created.)
173
+ for (const r of routes) {
174
+ if (!r.GET || !isStatorQueryRoute(r.GET) || r.paramNames.length > 0) continue
175
+ if (existsSync(resolve(root, 'public', r.urlPath.slice(1)))) {
176
+ console.warn(
177
+ `stator: public/${r.urlPath.slice(1)} shadows the data route ${r.urlPath} in dev — ` +
178
+ `the static file wins. Rename one of them.`,
179
+ )
180
+ }
181
+ }
156
182
  }
157
183
  const rebuildServer = async (): Promise<void> => {
158
184
  app = await runtime.buildHonoApp({
@@ -204,6 +230,9 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
204
230
  return {
205
231
  fetch: (request) => app.fetch(request),
206
232
  vite,
233
+ // `store` is read at call time, not captured — a machine-edit rebuild
234
+ // swaps it and the method follows.
235
+ dispatchToApp: (machine, event) => runtime.dispatchToApp(store, machine, event),
207
236
  listen(port: number): Promise<void> {
208
237
  // Vite's middlewares serve client modules + HMR to the browser; anything
209
238
  // it doesn't handle (routes, /__events, /__sse) falls through to Hono.
@@ -11,11 +11,12 @@ import { scheduleSessionEffects } from './effects.ts'
11
11
  import { record, replayFor } from './event-dedupe.ts'
12
12
  import { scopedLogger } from './logger.ts'
13
13
  import type { MachineStore } from './machine-store.ts'
14
+ import { runQueryRoute } from './query-route.ts'
14
15
  import { initialSyncPatches, recompute } from './recompute.ts'
15
16
  import { renderRoute } from './render.ts'
16
17
  import type { DiscoveredRoute } from './route-discovery.ts'
17
18
  import { buildRouteRequest } from './route-request.ts'
18
- import type { RouteDefinition } from './routing.ts'
19
+ import { isStatorQueryRoute, type RouteDefinition } from './routing.ts'
19
20
  import { getOrCreateSessionId } from './session.ts'
20
21
  import { withSessionLock } from './session-lock.ts'
21
22
  import { SessionRuntime } from './session-runtime.ts'
@@ -68,7 +69,14 @@ function compileMatcher(route: DiscoveredRoute): RouteMatcher {
68
69
  // Absorb the preceding `/` and match the (possibly empty) remainder.
69
70
  pattern = `${pattern.replace(/\/$/, '')}(?:/(.*))?`
70
71
  } else if (seg.startsWith(':')) {
71
- pattern += '([^/]+)'
72
+ // A param may carry a literal suffix (`:id.json`): the capture stops
73
+ // before the suffix, lazily, so `/p/a.b.json` yields id `a.b`.
74
+ const dot = seg.indexOf('.')
75
+ if (dot === -1) {
76
+ pattern += '([^/]+)'
77
+ } else {
78
+ pattern += `([^/]+?)${seg.slice(dot).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
79
+ }
72
80
  if (i < parts.length - 1) pattern += '/'
73
81
  } else {
74
82
  pattern += seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -204,6 +212,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
204
212
  return c.text(`unknown route "${routeKey}"`, 404)
205
213
  }
206
214
  const route = matched.route.GET
215
+ if (isStatorQueryRoute(route)) {
216
+ return c.text(`route "${routeKey}" is a data route — data routes are not live`, 400)
217
+ }
207
218
  if (!route.live) {
208
219
  return c.text(`route "${routeKey}" is not declared live: true`, 400)
209
220
  }
@@ -295,6 +306,11 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
295
306
  return c.json({ error: `unknown route "${routeKey}"` }, 404)
296
307
  }
297
308
  const route = matched.route.GET
309
+ // A data route serves no HTML, so no page runtime ever posts under its
310
+ // key — a request that does is malformed or hand-crafted.
311
+ if (isStatorQueryRoute(route)) {
312
+ return c.json({ error: `route key "${routeKey}" targets a data route` }, 404)
313
+ }
298
314
  const request = {
299
315
  ...buildRouteRequest(c, matched.route.paramNames),
300
316
  params: matched.params,
@@ -398,14 +414,13 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
398
414
  app.get('*', async (c, next) => {
399
415
  const matched = matchPath(matchers, c.req.path)
400
416
  if (!matched?.route.GET) return next()
401
- return handleGet(
402
- c,
403
- matched.route,
404
- matched.route.GET,
405
- matched.params,
406
- config.store,
407
- config.headExtras,
408
- )
417
+ const getRoute = matched.route.GET
418
+ // Brand decides the GET plane: data routes never touch the HTML path
419
+ // (no client-runtime injection, no live meta — a raw Response out).
420
+ if (isStatorQueryRoute(getRoute)) {
421
+ return runQueryRoute(c, matched.route, getRoute, config.store, matched.params)
422
+ }
423
+ return handleGet(c, matched.route, getRoute, matched.params, config.store, config.headExtras)
409
424
  })
410
425
 
411
426
  return app
@@ -39,6 +39,7 @@ export type { InstanceHandle } from './instance-proxy.ts'
39
39
  export { createInstanceProxy, defForProxy } from './instance-proxy.ts'
40
40
  export { logger, scopedLogger } from './logger.ts'
41
41
  export { MachineStore } from './machine-store.ts'
42
+ export { runQueryRoute } from './query-route.ts'
42
43
  export { recompute } from './recompute.ts'
43
44
  export { RedisAppStore, RedisStore } from './redis-store.ts'
44
45
  export type { RenderResult } from './render.ts'
@@ -75,8 +76,12 @@ export type {
75
76
  ApiRouteHelpers,
76
77
  ApiRouteResult,
77
78
  DefineApiRouteConfig,
79
+ DefineQueryRouteConfig,
78
80
  DefineRouteConfig,
79
81
  Directive,
82
+ QueryRouteDefinition,
83
+ QueryRouteHelpers,
84
+ QueryRouteResult,
80
85
  RouteContext,
81
86
  RouteCookieOptions,
82
87
  RouteDefinition,
@@ -88,6 +93,7 @@ export {
88
93
  defineApiRoute,
89
94
  defineRoute,
90
95
  isStatorApiRoute,
96
+ isStatorQueryRoute,
91
97
  isStatorRoute,
92
98
  } from './routing.ts'
93
99
  export { getOrCreateSessionId, SESSION_COOKIE } from './session.ts'
@@ -0,0 +1,147 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { Context } from 'hono'
3
+ import { isResponseLike } from './api-route.ts'
4
+ import { scheduleSessionEffects } from './effects.ts'
5
+ import { scopedLogger } from './logger.ts'
6
+ import type { MachineStore } from './machine-store.ts'
7
+ import type { DiscoveredRoute } from './route-discovery.ts'
8
+ import { buildRouteRequest } from './route-request.ts'
9
+ import type { QueryRouteDefinition, QueryRouteResult, RouteContext } from './routing.ts'
10
+ import { getOrCreateSessionId } from './session.ts'
11
+ import { withSessionLock } from './session-lock.ts'
12
+ import { SessionRuntime } from './session-runtime.ts'
13
+
14
+ const queryLog = scopedLogger('query')
15
+
16
+ /** Default Content-Type by URL extension for data GET responses. The URL —
17
+ * not the file on disk — carries the extension (`rss.xml.ts` serves
18
+ * `/rss.xml`), so inference reads the request path. */
19
+ const DATA_CONTENT_TYPES: Record<string, string> = {
20
+ '.json': 'application/json; charset=utf-8',
21
+ '.xml': 'application/xml; charset=utf-8',
22
+ '.txt': 'text/plain; charset=utf-8',
23
+ '.ics': 'text/calendar; charset=utf-8',
24
+ '.csv': 'text/csv; charset=utf-8',
25
+ }
26
+
27
+ /** Second-level file extensions (`feed.xml.ts`) that mark a route file as
28
+ * unambiguously a data route — discovery hard-errors on malformed exports
29
+ * in one instead of skipping it as a utility. `.stator.ts` keeps its own,
30
+ * different second-level meaning and is not in this set. */
31
+ export const dataFileExtensions: ReadonlySet<string> = new Set(
32
+ Object.keys(DATA_CONTENT_TYPES).map((e) => e.slice(1)),
33
+ )
34
+
35
+ /**
36
+ * Run a data GET route: hydrate the reads graph, run the read-only handler,
37
+ * synthesize the response.
38
+ *
39
+ * Lock discipline: hydration happens under the session lock so the snapshot
40
+ * is coherent ACROSS machines (no half-committed view of a concurrent POST),
41
+ * and the lock is released before the handler runs — the handler cannot
42
+ * dispatch, so there is nothing to interleave with, and handler I/O never
43
+ * holds the lock.
44
+ */
45
+ export async function runQueryRoute(
46
+ c: Context,
47
+ discovered: DiscoveredRoute,
48
+ route: QueryRouteDefinition,
49
+ store: MachineStore,
50
+ /** Path params from the framework's own matcher (the GET catch-all
51
+ * bypasses Hono's per-route param extraction). */
52
+ params?: Record<string, string>,
53
+ ): Promise<Response> {
54
+ const { sessionId } = getOrCreateSessionId(c)
55
+ const request = params
56
+ ? { ...buildRouteRequest(c, discovered.paramNames), params }
57
+ : buildRouteRequest(c, discovered.paramNames)
58
+
59
+ const runtime = new SessionRuntime(sessionId, store)
60
+ try {
61
+ await withSessionLock(sessionId, () => runtime.loadGraph(route.reads))
62
+
63
+ // Same shape as a page's render context: proxies keyed by machine name.
64
+ // Session machines come from the hydrated runtime, app machines resolve
65
+ // through the long-lived app-instance fallback.
66
+ const machines: RouteContext = {}
67
+ for (const def of route.reads) {
68
+ const proxy = runtime.proxyFor(def.name)
69
+ if (!proxy) {
70
+ throw new Error(`stator: route reads "${def.name}" but it's not loaded into the runtime`)
71
+ }
72
+ machines[def.name] = proxy
73
+ }
74
+
75
+ let result: QueryRouteResult
76
+ try {
77
+ result = await route.handler(request, { machines })
78
+ } catch (err) {
79
+ queryLog.error({ err: String(err), path: c.req.path }, 'data route handler threw')
80
+ return c.text('Internal Server Error', 500)
81
+ }
82
+
83
+ // A fresh machine whose hydrate fired a load entry effect: persist so the
84
+ // next request restores instead of re-firing, and schedule the effect
85
+ // off-lock. The common query is a pure read and skips both (mirrors GET
86
+ // page handling).
87
+ const entryFired = runtime.entryFiredMachines()
88
+ if (entryFired.size > 0) {
89
+ await runtime.persistTouched(entryFired)
90
+ scheduleSessionEffects(runtime, store, sessionId)
91
+ }
92
+
93
+ return synthesizeDataResponse(c, result)
94
+ } finally {
95
+ runtime.dispose()
96
+ }
97
+ }
98
+
99
+ /** The URL's extension (`/rss.xml` → `.xml`). A leading dot alone
100
+ * (`/.well-known`) is not an extension. */
101
+ function urlExtension(path: string): string | undefined {
102
+ const last = path.slice(path.lastIndexOf('/') + 1)
103
+ const dot = last.lastIndexOf('.')
104
+ return dot > 0 ? last.slice(dot).toLowerCase() : undefined
105
+ }
106
+
107
+ /**
108
+ * Response synthesis for data GET results:
109
+ * - Response-like → passthrough, Content-Type filled from the URL extension
110
+ * only when the handler set none.
111
+ * - string → body as-is, Content-Type from the URL extension
112
+ * (`text/plain` fallback).
113
+ * - anything else → JSON, always — a plain value is data, whatever the URL
114
+ * looks like.
115
+ *
116
+ * Synthesized responses carry a strong body-hash ETag and answer
117
+ * `If-None-Match` with 304, so polling consumers stop paying for unchanged
118
+ * bodies. (The designed upgrade — 304 from the machine-revision ledger
119
+ * WITHOUT invoking the handler — rides the same header contract.)
120
+ */
121
+ function synthesizeDataResponse(c: Context, result: QueryRouteResult): Response {
122
+ const ext = urlExtension(c.req.path)
123
+ const inferred = ext ? DATA_CONTENT_TYPES[ext] : undefined
124
+
125
+ if (isResponseLike(result)) {
126
+ if (inferred && !result.headers.get('content-type')) {
127
+ try {
128
+ result.headers.set('content-type', inferred)
129
+ } catch {
130
+ // Immutable headers (e.g. a proxied response) — the handler's call.
131
+ }
132
+ }
133
+ return result
134
+ }
135
+
136
+ const isString = typeof result === 'string'
137
+ const body = isString ? result : (JSON.stringify(result) ?? 'null')
138
+ const contentType = isString
139
+ ? (inferred ?? 'text/plain; charset=utf-8')
140
+ : 'application/json; charset=utf-8'
141
+
142
+ const etag = `"${createHash('sha1').update(body).digest('base64url')}"`
143
+ if ((c.req.header('if-none-match') ?? '').includes(etag)) {
144
+ return new Response(null, { status: 304, headers: { ETag: etag } })
145
+ }
146
+ return new Response(body, { headers: { 'Content-Type': contentType, ETag: etag } })
147
+ }
@@ -2,18 +2,21 @@ import { readdir } from 'node:fs/promises'
2
2
  import { basename, dirname, extname, relative, resolve, sep } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import type { ModuleLoader } from './discovery.ts'
5
+ import { dataFileExtensions } from './query-route.ts'
5
6
  import {
6
7
  type ApiRouteDefinition,
7
8
  isStatorApiRoute,
9
+ isStatorQueryRoute,
8
10
  isStatorRoute,
11
+ type QueryRouteDefinition,
9
12
  type RouteDefinition,
10
13
  } from './routing.ts'
11
14
 
12
15
  const nativeLoader: ModuleLoader = (file) => import(/* @vite-ignore */ pathToFileURL(file).href)
13
16
 
14
17
  /** HTTP methods a route file may export. GET goes through `defineRoute`
15
- * (page rendering); the rest go through `defineApiRoute` (mutation/API
16
- * handlers). */
18
+ * (page rendering) or `defineApiRoute({ method: 'GET' })` (data route);
19
+ * the rest go through `defineApiRoute` (command handlers). */
17
20
  export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const
18
21
  export type HttpMethod = (typeof HTTP_METHODS)[number]
19
22
 
@@ -24,8 +27,9 @@ export interface DiscoveredRoute {
24
27
  * Empty for static routes. */
25
28
  paramNames: string[]
26
29
  filePath: string
27
- /** GET route (page render). At most one. */
28
- GET?: RouteDefinition
30
+ /** GET route: a page (defineRoute) or a data route (defineApiRoute with
31
+ * method: 'GET'), discriminated by brand. At most one. */
32
+ GET?: RouteDefinition | QueryRouteDefinition
29
33
  /** API routes by method. */
30
34
  POST?: ApiRouteDefinition
31
35
  PUT?: ApiRouteDefinition
@@ -43,12 +47,15 @@ export interface DiscoveredRoute {
43
47
  * - `routes/[a]/[b].ts` → `/:a/:b`
44
48
  *
45
49
  * Files may export any combination of `GET`/`POST`/`PUT`/`PATCH`/`DELETE`.
46
- * GET is a `defineRoute` (page renderer); the others are `defineApiRoute`.
50
+ * GET is a `defineRoute` (page renderer) or a `defineApiRoute` declaring
51
+ * `method: 'GET'` (data route); the others are `defineApiRoute` commands.
47
52
  *
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.
53
+ * Files that don't export anything under an HTTP-method name are silently
54
+ * skipped. With recursive walking, the routes tree often contains utility
55
+ * files (templates, helpers) that aren't routes, and throwing on them would
56
+ * force separate trees. A file that DOES export an HTTP-method name but with
57
+ * the wrong constructor is a hard error, never a skip — a skip here surfaces
58
+ * as an unexplained 404 and a dev banner counting one route short.
52
59
  */
53
60
  export async function discoverRoutes(
54
61
  dir: string,
@@ -61,25 +68,39 @@ export async function discoverRoutes(
61
68
  for (const filePath of files) {
62
69
  const mod = await load(filePath)
63
70
 
64
- // GET is a page route, POST/PUT/PATCH/DELETE are API routes.
65
- const get = isStatorRoute(mod.GET) ? (mod.GET as RouteDefinition) : undefined
71
+ // GET is a page (defineRoute) or a data route (defineApiRoute with
72
+ // method: 'GET'); POST/PUT/PATCH/DELETE are command API routes.
73
+ const get =
74
+ isStatorRoute(mod.GET) || isStatorQueryRoute(mod.GET)
75
+ ? (mod.GET as RouteDefinition | QueryRouteDefinition)
76
+ : undefined
66
77
  const post = isStatorApiRoute(mod.POST) ? (mod.POST as ApiRouteDefinition) : undefined
67
78
  const put = isStatorApiRoute(mod.PUT) ? (mod.PUT as ApiRouteDefinition) : undefined
68
79
  const patch = isStatorApiRoute(mod.PATCH) ? (mod.PATCH as ApiRouteDefinition) : undefined
69
80
  const del = isStatorApiRoute(mod.DELETE) ? (mod.DELETE as ApiRouteDefinition) : undefined
70
81
 
71
- if (!get && !post && !put && !patch && !del) continue
72
-
73
82
  // Catch the easy mistake: GET defined with `defineApiRoute`, or a
74
83
  // mutation method defined with `defineRoute`. Throw with a clear hint.
84
+ // These run BEFORE the not-a-route skip below: a file whose only export
85
+ // is a mis-constructed method name must error, not vanish as a utility.
75
86
  if (mod.GET && !get) {
76
87
  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.`,
88
+ isStatorApiRoute(mod.GET)
89
+ ? `stator: ${filePath} exports GET from defineApiRoute() without method: 'GET'. ` +
90
+ `A GET is a page (defineRoute) or a data route (defineApiRoute({ method: 'GET', … })).`
91
+ : `stator: ${filePath} exports GET but it is neither a defineRoute (page) nor a ` +
92
+ `defineApiRoute({ method: 'GET' }) (data route).`,
79
93
  )
80
94
  }
81
95
  for (const m of ['POST', 'PUT', 'PATCH', 'DELETE'] as const) {
82
- if (mod[m] && !isStatorApiRoute(mod[m])) {
96
+ if (!mod[m]) continue
97
+ if (isStatorQueryRoute(mod[m])) {
98
+ throw new Error(
99
+ `stator: ${filePath} exports ${m} created with method: 'GET' — the declared method ` +
100
+ `must match the export name. Data routes are GET-only.`,
101
+ )
102
+ }
103
+ if (!isStatorApiRoute(mod[m])) {
83
104
  throw new Error(
84
105
  `stator: ${filePath} exports ${m} but it is not a defineApiRoute. ` +
85
106
  `${m} handlers must be created with defineApiRoute(); defineRoute() is GET-only.`,
@@ -87,6 +108,19 @@ export async function discoverRoutes(
87
108
  }
88
109
  }
89
110
 
111
+ if (!get && !post && !put && !patch && !del) {
112
+ // An extension-named file (feed.xml.ts) is unambiguously a route file —
113
+ // exporting nothing route-shaped from one is a mistake, not a utility.
114
+ const secondExt = extname(basename(filePath, extname(filePath))).slice(1)
115
+ if (dataFileExtensions.has(secondExt)) {
116
+ throw new Error(
117
+ `stator: ${filePath} is named like a data route (.${secondExt}${extname(filePath)}) ` +
118
+ `but exports no route. Export GET = defineApiRoute({ method: 'GET', … }) or rename the file.`,
119
+ )
120
+ }
121
+ continue
122
+ }
123
+
90
124
  const { urlPath, paramNames } = filePathToRoute(absDir, filePath)
91
125
  routes.push({
92
126
  urlPath,
@@ -134,7 +168,9 @@ function mergeByUrlPath(routes: DiscoveredRoute[]): DiscoveredRoute[] {
134
168
  * Sort routes most-specific-first (Astro's model). At match time the first
135
169
  * matcher that matches wins, so order encodes priority:
136
170
  * - 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)
171
+ * - per segment, left to right: static (0) > suffixed param `:id.json` (1)
172
+ * > bare param (2) > rest (3) — a suffixed param outranks a bare one so
173
+ * `/p/:id.json` wins `/p/abc.json` before `/p/:id` can swallow it
138
174
  * - more segments before fewer
139
175
  * - ties alphabetically by urlPath
140
176
  */
@@ -143,13 +179,15 @@ export function sortRoutes(routes: DiscoveredRoute[]): DiscoveredRoute[] {
143
179
  urlPath
144
180
  .split('/')
145
181
  .filter(Boolean)
146
- .map((seg) => (seg.startsWith('*') ? 2 : seg.startsWith(':') ? 1 : 0))
182
+ .map((seg) =>
183
+ seg.startsWith('*') ? 3 : seg.startsWith(':') ? (seg.includes('.') ? 1 : 2) : 0,
184
+ )
147
185
 
148
186
  return [...routes].sort((a, b) => {
149
187
  const ka = kinds(a.urlPath)
150
188
  const kb = kinds(b.urlPath)
151
- const aRest = ka.includes(2)
152
- const bRest = kb.includes(2)
189
+ const aRest = ka.includes(3)
190
+ const bRest = kb.includes(3)
153
191
  if (aRest !== bRest) return aRest ? 1 : -1 // no-rest first
154
192
  const len = Math.min(ka.length, kb.length)
155
193
  for (let i = 0; i < len; i++) {
@@ -213,11 +251,21 @@ export function filePathToRoute(
213
251
  paramNames.push(name)
214
252
  return `*${name}`
215
253
  }
216
- const m = seg.match(/^\[(.+)\]$/)
254
+ // Param, optionally with a literal extension suffix: `[id].json` →
255
+ // `:id.json` (param `id`; the URL literally ends in `.json`). This is the
256
+ // data-route extension convention composing with dynamic segments — the
257
+ // suffix stays out of the captured value at match time.
258
+ const m = seg.match(/^\[([^\]]+)\](\.[\w.]+)?$/)
217
259
  if (m) {
218
260
  const name = m[1]!
261
+ if (name.startsWith('...')) {
262
+ throw new Error(
263
+ `stator: rest segment [${name}]${m[2] ?? ''} cannot carry an extension suffix — ` +
264
+ `a rest param spans whole segments.`,
265
+ )
266
+ }
219
267
  paramNames.push(name)
220
- return `:${name}`
268
+ return `:${name}${m[2] ?? ''}`
221
269
  }
222
270
  return seg
223
271
  })
@@ -167,13 +167,29 @@ export interface DefineApiRouteConfig<TReads extends ReadonlyArray<AnyMachineDef
167
167
  ) => Promise<ApiRouteResult> | ApiRouteResult
168
168
  }
169
169
 
170
+ /** `method: 'GET'` discriminates: with it, the handler is a read-only data
171
+ * route typed with `{ machines }`; without it, a command route typed with
172
+ * `{ dispatch, rotateSession }` — exactly as before. */
173
+ export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
174
+ config: DefineQueryRouteConfig<TReads>,
175
+ ): QueryRouteDefinition
170
176
  export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
171
177
  config: DefineApiRouteConfig<TReads>,
172
- ): ApiRouteDefinition {
178
+ ): ApiRouteDefinition
179
+ export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
180
+ config: DefineQueryRouteConfig<TReads> | DefineApiRouteConfig<TReads>,
181
+ ): QueryRouteDefinition | ApiRouteDefinition {
182
+ if ('method' in config && config.method === 'GET') {
183
+ return {
184
+ __isStatorQueryRoute: true,
185
+ reads: config.reads ? [...config.reads] : [],
186
+ handler: config.handler,
187
+ }
188
+ }
173
189
  return {
174
190
  __isStatorApiRoute: true,
175
191
  reads: config.reads ? [...config.reads] : [],
176
- handler: config.handler,
192
+ handler: (config as DefineApiRouteConfig<TReads>).handler,
177
193
  }
178
194
  }
179
195
 
@@ -184,3 +200,52 @@ export function isStatorApiRoute(v: unknown): v is ApiRouteDefinition {
184
200
  (v as Record<string, unknown>).__isStatorApiRoute === true
185
201
  )
186
202
  }
203
+
204
+ /* ------------------------------------------------------------------ */
205
+ /* Data GET routes (defineApiRoute with method: 'GET') */
206
+ /* ------------------------------------------------------------------ */
207
+
208
+ /** Helpers available inside a data GET handler. Read-only by construction:
209
+ * there is no `dispatch` here, and that structural absence is what makes
210
+ * handler reads safe — a handler that cannot dispatch has nothing to
211
+ * interleave with effect completions or other sessions' commits. */
212
+ export interface QueryRouteHelpers {
213
+ /** Read proxies keyed by machine name — the same shape a page's render
214
+ * context uses (selector + context reads; no send). */
215
+ machines: RouteContext
216
+ }
217
+
218
+ /** What a data GET handler may return: a raw `Response` (passed through
219
+ * verbatim, `Content-Type` filled from the URL extension only when the
220
+ * handler set none), a string (`Content-Type` from the URL extension,
221
+ * `text/plain` fallback), or any JSON-serializable value (always
222
+ * `application/json`). */
223
+ export type QueryRouteResult = Response | string | object
224
+
225
+ export interface QueryRouteDefinition {
226
+ readonly __isStatorQueryRoute: true
227
+ reads: AnyMachineDef[]
228
+ handler: (
229
+ request: RouteRequest,
230
+ helpers: QueryRouteHelpers,
231
+ ) => Promise<QueryRouteResult> | QueryRouteResult
232
+ }
233
+
234
+ export interface DefineQueryRouteConfig<TReads extends ReadonlyArray<AnyMachineDef>> {
235
+ /** The capability discriminant: `'GET'` declares a read-only data route.
236
+ * Discovery cross-checks it against the export name. */
237
+ method: 'GET'
238
+ reads?: TReads
239
+ handler: (
240
+ request: RouteRequest,
241
+ helpers: QueryRouteHelpers,
242
+ ) => Promise<QueryRouteResult> | QueryRouteResult
243
+ }
244
+
245
+ export function isStatorQueryRoute(v: unknown): v is QueryRouteDefinition {
246
+ return (
247
+ typeof v === 'object' &&
248
+ v !== null &&
249
+ (v as Record<string, unknown>).__isStatorQueryRoute === true
250
+ )
251
+ }