Skip to content

Commit 78a2219

Browse files
committed
feat(serve): accept class-style page middleware
1 parent 907c430 commit 78a2219

4 files changed

Lines changed: 431 additions & 60 deletions

File tree

docs/api/middleware.md

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,33 @@ stx ships a Laravel-style route middleware pipeline for the dev server and SSR m
44

55
The same pipeline runs during SPA navigation (initial-render and client-side route changes both go through the same handlers), so a gated page is gated everywhere.
66

7-
## Defining a Handler
7+
## Defining Middleware
88

9-
A handler either passes through (returns `void` / `null` / `undefined`) or terminates the pipeline by returning a `Response`.
9+
Class-style middleware uses the same small interface as Stacks API middleware: `name`, `priority`, and `handle(request)`. Return `void` to continue. Throw a `Response` or an error carrying `status` / `statusCode` to stop the request.
10+
11+
```ts
12+
import { Middleware } from '@stacksjs/router'
13+
14+
export default new Middleware({
15+
name: 'RequireAdmin',
16+
priority: 2,
17+
handle(request) {
18+
if (request.cookie('role') !== 'admin')
19+
throw new Response('Forbidden', { status: 403 })
20+
},
21+
})
22+
```
23+
24+
Stacks applications load these instances and their aliases directly from `app/Middleware/` and `app/Middleware.ts` for both API routes and stx pages. The normal Stacks dev and production page servers also prepare the request with the same helpers before calling `handle`.
25+
26+
Standalone stx applications may continue to register function handlers. A function passes through with `void`, `null`, or `undefined`, and returns a `Response` to stop the pipeline:
1027

1128
```ts
1229
import type { MiddlewareHandler } from '@stacksjs/bun-plugin-stx'
1330

14-
const requireAdmin: MiddlewareHandler = (req, ctx) => {
15-
if (ctx.cookies['role'] !== 'admin')
16-
return ctx.redirect('/login')
17-
return null // pass through
31+
const requireAdmin: MiddlewareHandler = (request, context) => {
32+
if (context.cookies.role !== 'admin')
33+
return context.redirect('/login')
1834
}
1935
```
2036

@@ -57,7 +73,11 @@ serve({
5773
pagesDir: 'pages',
5874
middleware: {
5975
auth: (req, ctx) => ctx.cookies['session'] ? null : ctx.redirect('/login'),
60-
requireAdmin,
76+
requireAdmin: {
77+
name: 'RequireAdmin',
78+
priority: 2,
79+
handle: requireAdmin,
80+
},
6181
rateLimit: (req, ctx) => { /* ... */ },
6282
},
6383

@@ -91,14 +111,15 @@ The plugin scans page files at startup and stamps the metadata into its route ta
91111

92112
### Order of Execution
93113

94-
1. **Global middleware** (`globalMiddleware` array, in order).
95-
2. **Page middleware** (`definePageMeta({ middleware })`, in order; group names expand recursively).
114+
1. **Global middleware** (`globalMiddleware` array).
115+
2. **Page middleware** (`definePageMeta({ middleware })`; group names expand recursively).
116+
3. The combined chain is sorted by `priority`, lowest first. Middleware without an explicit priority uses `10`, and ties retain declaration order.
96117

97118
Each handler can short-circuit the chain by returning a `Response`. If every handler passes through, the page renders.
98119

99120
## Parameterized Middleware
100121

101-
Append colon-separated args to the name: `'auth:admin,owner'` invokes the `auth` handler with `args = ['admin', 'owner']`. Same shape as Laravel's `handle($request, $next, ...$args)`.
122+
Append colon-separated args to the name. Function middleware receives them as trailing arguments. Class-style middleware reads the original string from `request._middlewareParams` under the resolved name, matching Stacks API middleware.
102123

103124
```ts
104125
serve({
@@ -113,6 +134,23 @@ serve({
113134
})
114135
```
115136

137+
```ts
138+
import { Middleware } from '@stacksjs/router'
139+
140+
export default new Middleware({
141+
name: 'Role',
142+
handle(request) {
143+
const allowedRoles = request._middlewareParams?.role?.split(',') ?? []
144+
if (!allowedRoles.includes(request.cookie('role') ?? ''))
145+
throw new Response('Forbidden', { status: 403 })
146+
},
147+
})
148+
```
149+
150+
The complete middleware name is resolved before stx interprets a colon as parameters. An alias named `env:production` therefore remains an exact alias, while `role:admin,owner` resolves `role` with parameters.
151+
152+
Prefix a reference with `!` to invert it. For example, `!auth` passes only when `auth` refuses the request. Missing middleware fails closed with a 500 response.
153+
116154
```html
117155
<script server>
118156
definePageMeta({
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* Middleware handler compatible with the original stx page middleware API.
3+
*/
4+
export type MiddlewareHandler = (
5+
req: MiddlewareRequest,
6+
ctx: MiddlewareContext,
7+
...args: string[]
8+
) => Response | null | undefined | void | Promise<Response | null | undefined | void>
9+
10+
/**
11+
* Request surface shared with class-style middleware such as
12+
* `@stacksjs/router`'s `Middleware` class.
13+
*/
14+
export interface MiddlewareRequest extends Request {
15+
params?: Record<string, string>
16+
_middlewareParams?: Record<string, string>
17+
}
18+
19+
type MiddlewareDefinitionHandler = {
20+
bivarianceHack: (
21+
req: MiddlewareRequest,
22+
ctx: MiddlewareContext,
23+
...args: string[]
24+
) => Response | null | undefined | void | Promise<Response | null | undefined | void>
25+
}['bivarianceHack']
26+
27+
/**
28+
* Structural middleware contract. A `Middleware` instance from
29+
* `@stacksjs/router` satisfies this without an adapter.
30+
*/
31+
export interface MiddlewareDefinition {
32+
readonly name: string
33+
readonly priority?: number
34+
/**
35+
* Bivariant so a framework may promise a prepared request subtype, such as
36+
* Stacks' EnhancedRequest, through `prepareMiddlewareRequest`.
37+
*/
38+
readonly handle: MiddlewareDefinitionHandler
39+
}
40+
41+
export type PageMiddleware = MiddlewareHandler | MiddlewareDefinition
42+
43+
export interface MiddlewareContext {
44+
/** Current URL pathname, e.g. `/host/dashboard`. */
45+
path: string
46+
/** Parsed URL, useful for query strings and hashes. */
47+
url: URL
48+
/** Path params extracted from a dynamic segment, e.g. `{ id: 'tesla' }`. */
49+
params: Record<string, string>
50+
/** Cookies already parsed from the request. */
51+
cookies: Record<string, string>
52+
/** Build a redirect to `to`, preserving the original target. */
53+
redirect: (to: string, status?: number) => Response
54+
}
55+
56+
export type PrepareMiddlewareRequest = (
57+
request: MiddlewareRequest,
58+
context: MiddlewareContext,
59+
) => MiddlewareRequest | Promise<MiddlewareRequest>
60+
61+
export interface RunPageMiddlewareOptions {
62+
request: MiddlewareRequest
63+
context: MiddlewareContext
64+
entries: string[]
65+
registry: Readonly<Record<string, PageMiddleware>>
66+
prepareRequest?: PrepareMiddlewareRequest
67+
}
68+
69+
interface ResolvedMiddleware {
70+
args: string[]
71+
index: number
72+
middleware: PageMiddleware
73+
name: string
74+
negated: boolean
75+
params?: string
76+
priority: number
77+
}
78+
79+
const DEFAULT_PRIORITY = 10
80+
81+
function middlewarePriority(middleware: PageMiddleware): number {
82+
const raw = (middleware as { priority?: unknown }).priority
83+
return typeof raw === 'number' && Number.isFinite(raw) && raw >= 0
84+
? raw
85+
: DEFAULT_PRIORITY
86+
}
87+
88+
function parseMiddlewareEntry(
89+
entry: string,
90+
registry: Readonly<Record<string, PageMiddleware>>,
91+
): { args: string[], name: string, negated: boolean, params?: string } {
92+
const negated = entry.startsWith('!')
93+
const bare = negated ? entry.slice(1) : entry
94+
95+
// Resolve the whole name first. This keeps aliases containing a colon, such
96+
// as `env:production`, distinct from parameterized `role:admin` entries.
97+
if (Object.hasOwn(registry, bare))
98+
return { args: [], name: bare, negated }
99+
100+
const colon = bare.indexOf(':')
101+
if (colon === -1)
102+
return { args: [], name: bare, negated }
103+
104+
const params = bare.slice(colon + 1)
105+
return {
106+
args: params === '' ? [] : params.split(','),
107+
name: bare.slice(0, colon),
108+
negated,
109+
params,
110+
}
111+
}
112+
113+
function statusResponse(thrown: unknown): Response | null {
114+
if (thrown instanceof Response)
115+
return thrown
116+
117+
if (typeof thrown !== 'object' || thrown === null)
118+
return null
119+
120+
const candidate = thrown as { message?: unknown, status?: unknown, statusCode?: unknown }
121+
const rawStatus = candidate.statusCode ?? candidate.status
122+
if (typeof rawStatus !== 'number' || !Number.isInteger(rawStatus) || rawStatus < 100 || rawStatus > 599)
123+
return null
124+
125+
const message = typeof candidate.message === 'string' ? candidate.message : 'Middleware rejected the request'
126+
return new Response(message, { status: rawStatus })
127+
}
128+
129+
async function invokeMiddleware(
130+
middleware: PageMiddleware,
131+
request: MiddlewareRequest,
132+
context: MiddlewareContext,
133+
args: string[],
134+
): Promise<Response | null> {
135+
try {
136+
const result = typeof middleware === 'function'
137+
? await middleware(request, context, ...args)
138+
: await middleware.handle(request, context, ...args)
139+
140+
return result instanceof Response ? result : null
141+
}
142+
catch (thrown) {
143+
const response = statusResponse(thrown)
144+
if (response)
145+
return response
146+
throw thrown
147+
}
148+
}
149+
150+
/**
151+
* Run stx page middleware through one public seam.
152+
*
153+
* Function handlers remain supported. Class-style middleware additionally
154+
* contributes a priority, receives colon parameters through
155+
* `request._middlewareParams`, and may throw a Response or status-carrying
156+
* error to stop the chain.
157+
*/
158+
export async function runPageMiddleware(options: RunPageMiddlewareOptions): Promise<Response | null> {
159+
options.request.params = options.context.params
160+
const request = options.prepareRequest
161+
? await options.prepareRequest(options.request, options.context)
162+
: options.request
163+
164+
request.params = options.context.params
165+
request._middlewareParams ||= {}
166+
167+
const resolved: ResolvedMiddleware[] = []
168+
for (const [index, entry] of options.entries.entries()) {
169+
const parsed = parseMiddlewareEntry(entry, options.registry)
170+
const middleware = options.registry[parsed.name]
171+
if (!middleware) {
172+
console.warn(`[stx serve] unknown middleware "${parsed.name}" on ${options.context.path}; failing closed`)
173+
return new Response(`Route middleware '${parsed.name}' is not registered`, { status: 500 })
174+
}
175+
176+
resolved.push({
177+
...parsed,
178+
index,
179+
middleware,
180+
priority: middlewarePriority(middleware),
181+
})
182+
}
183+
184+
resolved.sort((a, b) => a.priority - b.priority || a.index - b.index)
185+
186+
for (const entry of resolved) {
187+
if (entry.params !== undefined)
188+
request._middlewareParams[entry.name] = entry.params
189+
190+
const response = await invokeMiddleware(
191+
entry.middleware,
192+
request,
193+
options.context,
194+
entry.args,
195+
)
196+
197+
if (entry.negated) {
198+
if (response)
199+
continue
200+
return new Response(`Access denied. This route requires "${entry.name}" not to apply.`, { status: 403 })
201+
}
202+
203+
if (response)
204+
return response
205+
}
206+
207+
return null
208+
}

0 commit comments

Comments
 (0)