@stacksjs/defaults 0.72.56 → 0.72.60
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/ai/skills/stacks-api/SKILL.md +88 -1
- package/ai/skills/stacks-browse/scripts/browse.ts +44 -29
- package/ai/skills/stacks-events/SKILL.md +53 -7
- package/ai/skills/stacks-new-feature/SKILL.md +27 -0
- package/ai/skills/stacks-router/SKILL.md +95 -2
- package/app/Actions/AI/AskAction.ts +1 -1
- package/app/Actions/AI/SummaryAction.ts +1 -1
- package/app/Actions/Auth/DisableTwoFactorAction.ts +1 -1
- package/app/Actions/Auth/EnableTwoFactorAction.ts +1 -1
- package/app/Actions/Auth/LoginAction.ts +2 -2
- package/app/Actions/Auth/MagicLinkConsumeAction.ts +1 -1
- package/app/Actions/Auth/MagicLinkSendAction.ts +1 -1
- package/app/Actions/Auth/RefreshTokenAction.ts +1 -1
- package/app/Actions/Auth/RegisterAction.ts +3 -3
- package/app/Actions/Auth/VerifyTwoFactorLoginAction.ts +2 -2
- package/app/Actions/Cms/CategorizableStoreAction.ts +3 -3
- package/app/Actions/Cms/CategorizableUpdateAction.ts +8 -2
- package/app/Actions/Cms/CommentStoreAction.ts +2 -2
- package/app/Actions/Cms/CommentUpdateAction.ts +8 -2
- package/app/Actions/Cms/PostViewsUpdateAction.ts +7 -1
- package/app/Actions/Cms/TaggableStoreAction.ts +2 -2
- package/app/Actions/Cms/TaggableUpdateAction.ts +8 -2
- package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +1 -1
- package/app/Actions/Dashboard/Kanban/UsersListAction.ts +1 -1
- package/app/Actions/LogAction.ts +2 -2
- package/app/Actions/UploadTestAction.ts +0 -1
- package/app/Middleware/Auth.ts +0 -1
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/routes/dashboard.ts +1 -1
|
@@ -340,9 +340,96 @@ route.get('/admin', handler).middleware('abilities:admin,write')
|
|
|
340
340
|
// Params accessible in middleware via: (request as any)._middlewareParams.abilities === 'admin,write'
|
|
341
341
|
```
|
|
342
342
|
|
|
343
|
+
## Which client to reach for
|
|
344
|
+
|
|
345
|
+
Stacks has three, and they answer different questions. Pick by who is calling.
|
|
346
|
+
|
|
347
|
+
| Caller | Client | Why |
|
|
348
|
+
|---|---|---|
|
|
349
|
+
| TypeScript in this repo or a workspace package | **Typed client** (below) | Full input/output inference with **no generation step**. Change a route, the call site stops compiling. |
|
|
350
|
+
| Anything that is not TypeScript here - native iOS/Android via the Craft bridge, third-party integrators, Swagger UI | **Generated REST client** (`buddy generate:openapi`) | Needs a real spec. That pipeline is unchanged and permanent. |
|
|
351
|
+
| One-off calls to any URL | **`Fetcher`** (below) | No route awareness, and none intended. |
|
|
352
|
+
|
|
353
|
+
The first two are both permanent. This is not a migration off REST.
|
|
354
|
+
|
|
355
|
+
## Typed client (zero generation)
|
|
356
|
+
|
|
357
|
+
Register routes with `createTypedRouter()` and the compiler can see both ends —
|
|
358
|
+
no `buddy generate:openapi`, no committed file to go stale.
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
// routes/api.ts
|
|
362
|
+
import IndexAction from '../app/Actions/Project/IndexAction'
|
|
363
|
+
import StoreAction from '../app/Actions/Project/StoreAction'
|
|
364
|
+
import { createTypedRouter } from '@stacksjs/router'
|
|
365
|
+
|
|
366
|
+
export const api = createTypedRouter()
|
|
367
|
+
.get('/v1/projects', IndexAction)
|
|
368
|
+
.get('/v1/projects/{id}', ShowAction)
|
|
369
|
+
.post('/v1/projects', StoreAction, { middleware: 'auth' })
|
|
370
|
+
|
|
371
|
+
export type AppRoutes = typeof api
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
// any TypeScript consumer
|
|
376
|
+
import type { AppRoutes } from '../routes/api'
|
|
377
|
+
import { createTypedClient } from '@stacksjs/router'
|
|
378
|
+
|
|
379
|
+
const client = createTypedClient<AppRoutes>({ baseUrl: 'https://api.example.com' })
|
|
380
|
+
|
|
381
|
+
const projects = await client.get('/v1/projects')
|
|
382
|
+
const one = await client.get('/v1/projects/{id}', { params: { id: '42' } })
|
|
383
|
+
const created = await client.post('/v1/projects', { name: 'apollo', budget: 1200 })
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
An unknown path is a compile error. A body that does not match the action's
|
|
387
|
+
`validations` is a compile error. The result is the action's own return type.
|
|
388
|
+
|
|
389
|
+
**How it infers.** Input comes from the action's `validations` — the same object
|
|
390
|
+
the validator runs, so the two cannot drift. Output comes from `handle`'s return
|
|
391
|
+
type. An action returning a `Response` or a stream is typed `unknown`, honestly:
|
|
392
|
+
it took over the wire format.
|
|
393
|
+
|
|
394
|
+
**Per-route settings are an argument**, not a chained call — chaining
|
|
395
|
+
`.middleware()` would return the route and lose the accumulated type:
|
|
396
|
+
|
|
397
|
+
```typescript
|
|
398
|
+
.post('/v1/projects', StoreAction, {
|
|
399
|
+
middleware: ['auth', 'can:create,project'],
|
|
400
|
+
name: 'projects.store',
|
|
401
|
+
skipCsrf: true,
|
|
402
|
+
rateLimit: { max: 10, window: 'minute' },
|
|
403
|
+
})
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
**No `.group()`.** A prefix applied only at runtime makes every accumulated path
|
|
407
|
+
type wrong; one applied only in the type is a second place for the URL to live.
|
|
408
|
+
Write the full path.
|
|
409
|
+
|
|
410
|
+
**The string form is untouched.** `route.get('/x', 'Actions/Foo')` stays exactly
|
|
411
|
+
as it is — lazy import, hot-reload friendly — and simply produces no inferred
|
|
412
|
+
types. A route wanting inference opts in by using the builder instead. Routes
|
|
413
|
+
registered either way still land in the generated OpenAPI document.
|
|
414
|
+
|
|
415
|
+
**Generated CRUD.** A `useApi` model's endpoints are describable too, without
|
|
416
|
+
touching them:
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
import type { ApiRoutesFor } from '@stacksjs/orm'
|
|
420
|
+
import type Product from '../app/Models/Product'
|
|
421
|
+
|
|
422
|
+
type AppRoutes = RoutesOf<typeof api> & ApiRoutesFor<typeof Product>
|
|
423
|
+
await client.get('/api/products') // typed listing envelope
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
The builder, the client and the route-map contract all live in
|
|
427
|
+
`@stacksjs/bun-router` and are re-exported by `@stacksjs/router`. Import
|
|
428
|
+
`createTypedClient` from `@stacksjs/bun-router` directly in a browser bundle.
|
|
429
|
+
|
|
343
430
|
## Fetcher (HTTP Client)
|
|
344
431
|
|
|
345
|
-
A fluent HTTP client for
|
|
432
|
+
A fluent HTTP client for ad hoc requests, with no route awareness:
|
|
346
433
|
|
|
347
434
|
```typescript
|
|
348
435
|
import { fetcher } from '@stacksjs/api'
|
|
@@ -313,7 +313,7 @@ interface PageState {
|
|
|
313
313
|
mainStatus: number | null
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
async function gotoAndInstrument(cdp: Cdp, url: string, opts: { viewport?: { w: number, h: number }, scale?: number, timeoutMs?: number, cookies?: string[], settleMs?: number, scheme?: string } = {}): Promise<PageState> {
|
|
316
|
+
async function gotoAndInstrument(cdp: Cdp, url: string, opts: { viewport?: { w: number, h: number }, scale?: number, timeoutMs?: number, cookies?: string[], settleMs?: number, scheme?: string, reducedMotion?: boolean } = {}): Promise<PageState> {
|
|
317
317
|
let unsubscribe = () => {}
|
|
318
318
|
const state: PageState = {
|
|
319
319
|
consoleErrors: [],
|
|
@@ -356,12 +356,29 @@ async function gotoAndInstrument(cdp: Cdp, url: string, opts: { viewport?: { w:
|
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
// Emulate light/dark so prefers-color-scheme pages can be QA'd in both
|
|
359
|
-
// schemes without flipping the host OS setting.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
359
|
+
// schemes without flipping the host OS setting. `reducedMotion` rides the
|
|
360
|
+
// same call (a second `setEmulatedMedia` would replace this one's features
|
|
361
|
+
// rather than add to them) - it's set for `--full`/`--element` captures,
|
|
362
|
+
// which paint beyond the current viewport via `captureBeyondViewport`.
|
|
363
|
+
// That API paints pixels beyond the viewport WITHOUT moving `scrollY`, so
|
|
364
|
+
// a scroll-driven CSS reveal (`animation-timeline: view()`) never
|
|
365
|
+
// resolves past its initial (typically invisible) keyframe for anything
|
|
366
|
+
// that was never really scrolled past - a `--full` capture would silently
|
|
367
|
+
// screenshot every such section as blank. `prefers-reduced-motion: reduce`
|
|
368
|
+
// sidesteps that instead of fighting it with a scroll trick that would
|
|
369
|
+
// itself misplace `position: sticky`/`fixed` elements: this project's own
|
|
370
|
+
// marketing CSS already turns every scroll/entrance animation off and
|
|
371
|
+
// leaves elements at their plain (opaque) resting state under reduced
|
|
372
|
+
// motion (`@media (prefers-reduced-motion: reduce)`), which is exactly
|
|
373
|
+
// the already-shipped, already-tested fallback a real accessibility user
|
|
374
|
+
// gets - reusing it here means nothing new to prove correct.
|
|
375
|
+
const features: { name: string, value: string }[] = []
|
|
376
|
+
if (opts.scheme === 'light' || opts.scheme === 'dark')
|
|
377
|
+
features.push({ name: 'prefers-color-scheme', value: opts.scheme })
|
|
378
|
+
if (opts.reducedMotion)
|
|
379
|
+
features.push({ name: 'prefers-reduced-motion', value: 'reduce' })
|
|
380
|
+
if (features.length)
|
|
381
|
+
await cdp.send('Emulation.setEmulatedMedia', { features })
|
|
365
382
|
|
|
366
383
|
unsubscribe = cdp.on((e) => {
|
|
367
384
|
if (e.method === 'Runtime.consoleAPICalled') {
|
|
@@ -404,23 +421,11 @@ async function title(cdp: Cdp): Promise<string> {
|
|
|
404
421
|
}
|
|
405
422
|
|
|
406
423
|
/**
|
|
407
|
-
* Scroll the real page to `y` and let
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
* moving `scrollY` - correct for a top-to-bottom review, but useless for
|
|
413
|
-
* checking anything that depends on an actual mid-scroll viewport: a
|
|
414
|
-
* `position: sticky` nav overlapping content beneath it, a CSS
|
|
415
|
-
* `animation-timeline: view()` reveal mid-transition, or simply "what does
|
|
416
|
-
* the fold look like 800px down". `window.scrollTo(..., { behavior:
|
|
417
|
-
* 'instant' })` forces the jump synchronously even when the page sets
|
|
418
|
-
* `scroll-behavior: smooth`, which would otherwise animate the scroll and
|
|
419
|
-
* leave a screenshot taken immediately after mid-flight. The two rAF
|
|
420
|
-
* round-trips that follow give the compositor one full frame to repaint
|
|
421
|
-
* sticky/fixed layers and any scroll-driven animation at the new offset
|
|
422
|
-
* before `Page.captureScreenshot` reads back pixels, which
|
|
423
|
-
* `Runtime.evaluate` returning does not by itself guarantee.
|
|
424
|
+
* Scroll the real page to `y` and let two animation-frame round-trips settle
|
|
425
|
+
* before the caller reads pixels back - `behavior: 'instant'` forces the
|
|
426
|
+
* jump synchronously even when the page sets `scroll-behavior: smooth`,
|
|
427
|
+
* which would otherwise animate the scroll and leave a screenshot taken
|
|
428
|
+
* immediately after mid-flight.
|
|
424
429
|
*/
|
|
425
430
|
async function scrollTo(cdp: Cdp, y: number): Promise<void> {
|
|
426
431
|
const r = await cdp.send('Runtime.evaluate', {
|
|
@@ -753,7 +758,7 @@ async function main() {
|
|
|
753
758
|
console.log('Usage: bun browse.ts <navigate|screenshot|responsive|monitor|snapshot|scenario|crawl> <url> [flags]')
|
|
754
759
|
console.log(' --cookie "name=value" repeatable; pre-seeds cookies (e.g. coming-soon bypass)')
|
|
755
760
|
console.log(' --settle 1500 ms to wait after load before acting (default 700; stretch for entrance animations)')
|
|
756
|
-
console.log(' --scheme dark emulate prefers-color-scheme (light|dark)
|
|
761
|
+
console.log(' --scheme dark emulate prefers-color-scheme (light|dark); defaults to light when omitted')
|
|
757
762
|
console.log(' --scroll-y 800 screenshot: jump to this document Y first, then capture just the viewport')
|
|
758
763
|
console.log(' (--full renders beyond-viewport without ever scrolling, so it cannot show a')
|
|
759
764
|
console.log(' position:sticky/fixed element overlapping content, or a scroll-linked')
|
|
@@ -764,7 +769,17 @@ async function main() {
|
|
|
764
769
|
let session = await launch()
|
|
765
770
|
const cookies = flagList(flags.cookie)
|
|
766
771
|
const settleMs = flags.settle ? Number(flags.settle) : undefined
|
|
767
|
-
|
|
772
|
+
// Defaults to 'light' rather than leaving prefers-color-scheme unset:
|
|
773
|
+
// headless Chromium's own default for that media feature isn't
|
|
774
|
+
// documented or guaranteed, and was observed reporting 'dark' matches
|
|
775
|
+
// with nothing here asking for it - every screenshot taken without
|
|
776
|
+
// --scheme would silently show this site's dark theme instead of its
|
|
777
|
+
// actual 'colored' (light) default. Forcing 'light' makes an unflagged
|
|
778
|
+
// capture deterministic and representative of what a visitor with no
|
|
779
|
+
// saved choice and no OS dark-mode preference actually sees.
|
|
780
|
+
if (flags.scheme !== undefined && flags.scheme !== 'light' && flags.scheme !== 'dark')
|
|
781
|
+
throw new TypeError(`Invalid --scheme "${String(flags.scheme)}". Expected "light" or "dark".`)
|
|
782
|
+
const scheme = typeof flags.scheme === 'string' ? flags.scheme : 'light'
|
|
768
783
|
try {
|
|
769
784
|
if (command === 'navigate' || command === 'go') {
|
|
770
785
|
const cdp = await openPage(session.port)
|
|
@@ -794,7 +809,7 @@ async function main() {
|
|
|
794
809
|
throw new TypeError(`--full, --element, and --scroll-y are mutually exclusive; got ${modes.join(', ')}.`)
|
|
795
810
|
const out = (flags.out as string) || `storage/framework/runtime/shots/${new URL(url).pathname.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home'}.png`
|
|
796
811
|
mkdirSync(out.split('/').slice(0, -1).join('/') || '.', { recursive: true })
|
|
797
|
-
const state = await gotoAndInstrument(cdp, url, { viewport, scale, cookies, settleMs, scheme })
|
|
812
|
+
const state = await gotoAndInstrument(cdp, url, { viewport, scale, cookies, settleMs, scheme, reducedMotion: !!flags.full || !!flags.element })
|
|
798
813
|
const png = await captureScreenshot(cdp, { full: !!flags.full, element: flags.element as string | undefined, scrollY: scrollY ?? undefined })
|
|
799
814
|
await Bun.write(out, png)
|
|
800
815
|
console.log(JSON.stringify({ url, out, viewport: `${viewport.w}x${viewport.h}`, scale, full: !!flags.full, element: flags.element ?? null, scrollY: scrollY ?? null, bytes: png.length }, null, 2))
|
|
@@ -808,7 +823,7 @@ async function main() {
|
|
|
808
823
|
const results: any[] = []
|
|
809
824
|
for (const bp of BREAKPOINTS) {
|
|
810
825
|
const cdp = await openPage(session.port)
|
|
811
|
-
const state = await gotoAndInstrument(cdp, url, { viewport: { w: bp.w, h: bp.h }, cookies, settleMs, scheme })
|
|
826
|
+
const state = await gotoAndInstrument(cdp, url, { viewport: { w: bp.w, h: bp.h }, cookies, settleMs, scheme, reducedMotion: true })
|
|
812
827
|
const overflow = await cdp.send('Runtime.evaluate', {
|
|
813
828
|
expression: 'document.body.scrollWidth > window.innerWidth ? document.body.scrollWidth - window.innerWidth : 0',
|
|
814
829
|
returnByValue: true,
|
|
@@ -874,7 +889,7 @@ async function main() {
|
|
|
874
889
|
|
|
875
890
|
const cdp = await openPage(session.port)
|
|
876
891
|
const viewport = parseViewport(typeof flags.viewport === 'string' ? flags.viewport : undefined)
|
|
877
|
-
const state = await gotoAndInstrument(cdp, url, { viewport, cookies, settleMs, scheme })
|
|
892
|
+
const state = await gotoAndInstrument(cdp, url, { viewport, cookies, settleMs, scheme, reducedMotion: !!flags.full })
|
|
878
893
|
const results: Record<string, unknown>[] = []
|
|
879
894
|
await cdp.send('Page.bringToFront')
|
|
880
895
|
await cdp.send('Runtime.evaluate', {
|
|
@@ -122,12 +122,57 @@ The `Record<EventType, unknown>` intersection allows arbitrary event names beyon
|
|
|
122
122
|
|
|
123
123
|
## Model Events
|
|
124
124
|
|
|
125
|
-
Every model with `observe: true` trait
|
|
126
|
-
- `'{model}:created'` -- after insert
|
|
127
|
-
- `'{model}:updated'` -- after update
|
|
128
|
-
- `'{model}:deleted'` -- after delete
|
|
125
|
+
Every model with the `observe: true` trait emits **eight** events:
|
|
129
126
|
|
|
130
|
-
|
|
127
|
+
| Event | When | Payload |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `{model}:saving` | before any write | the model object |
|
|
130
|
+
| `{model}:creating` / `:updating` / `:deleting` | before that write | the model object |
|
|
131
|
+
| `{model}:created` / `:updated` / `:deleted` | after that write | the row |
|
|
132
|
+
| `{model}:saved` | after insert OR update | the row |
|
|
133
|
+
|
|
134
|
+
Model name is lowercased: `'user:created'`, `'post:updated'`, `'teammember:saved'`.
|
|
135
|
+
|
|
136
|
+
A **before** listener can cancel the write by returning `false`:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
listen('user:deleting', (model) => {
|
|
140
|
+
if (model.attributes.email.endsWith('@example.com'))
|
|
141
|
+
return false // the delete does not happen
|
|
142
|
+
})
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Before-events carry the model object (`.attributes` holds the row); after-events
|
|
146
|
+
carry the row itself.
|
|
147
|
+
|
|
148
|
+
### The payloads are typed, and nothing generates them
|
|
149
|
+
|
|
150
|
+
`listen('user:created', user => user.emial)` is a compile error - the payload is
|
|
151
|
+
the User row, with the columns your model declares.
|
|
152
|
+
|
|
153
|
+
`storage/framework/types/model-events.d.ts` derives the whole map from the models
|
|
154
|
+
barrel with a mapped type:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
type ModelAfterEvents = {
|
|
158
|
+
[K in keyof Models & string as `${Lowercase<K>}:${AfterEvent}`]: ModelRow<Models[K]>
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
So a model existing IS its events existing - there is no generated list to keep in
|
|
163
|
+
agreement, and nothing to re-run after adding a model. (It replaced an 817-line
|
|
164
|
+
generated file, and before that a hand-maintained one that listed three events per
|
|
165
|
+
model and typed every payload `Record<string, any>`.)
|
|
166
|
+
|
|
167
|
+
Declare your own events by augmenting `AppEvents`:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
declare module '@stacksjs/events' {
|
|
171
|
+
interface AppEvents {
|
|
172
|
+
'invoice:overdue': { id: number, daysLate: number }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
131
176
|
|
|
132
177
|
Events are dispatched via lazy `import('@stacksjs/events').then(({ dispatch }) => dispatch(...))` to avoid circular dependencies. If the import fails (e.g., browser context), errors are silently caught.
|
|
133
178
|
|
|
@@ -136,8 +181,9 @@ The `observe` trait can be:
|
|
|
136
181
|
- `['create', 'update']` -- emits only specified events
|
|
137
182
|
- `false` / undefined -- no events
|
|
138
183
|
|
|
139
|
-
|
|
140
|
-
|
|
184
|
+
There is no model list to keep here. Every model in `storage/framework/auto-imports/models.ts`
|
|
185
|
+
has its eight events, and that barrel is generated from disk for the runtime, so the
|
|
186
|
+
answer to "which models emit events" is "the ones that exist".
|
|
141
187
|
|
|
142
188
|
## Event-to-Listener Mapping (app/Events.ts)
|
|
143
189
|
|
|
@@ -121,6 +121,33 @@ route.group({ prefix: '/articles', middleware: ['auth'] }, () => {
|
|
|
121
121
|
|
|
122
122
|
Or rely on auto-generated routes from `useApi` trait — they're created automatically.
|
|
123
123
|
|
|
124
|
+
### When a TypeScript client will call these
|
|
125
|
+
|
|
126
|
+
Register through `createTypedRouter()` instead, and the client gets full
|
|
127
|
+
input/output inference with no `buddy generate:openapi` step in between:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// routes/api.ts
|
|
131
|
+
import CreateArticle from '../app/Actions/CreateArticle'
|
|
132
|
+
import ListArticles from '../app/Actions/ListArticles'
|
|
133
|
+
import { createTypedRouter } from '@stacksjs/router'
|
|
134
|
+
|
|
135
|
+
export const api = createTypedRouter()
|
|
136
|
+
.get('/articles', ListArticles, { middleware: 'auth' })
|
|
137
|
+
.post('/articles', CreateArticle, { middleware: 'auth' })
|
|
138
|
+
|
|
139
|
+
export type AppRoutes = typeof api
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
const client = createTypedClient<AppRoutes>({ baseUrl })
|
|
144
|
+
const created = await client.post('/articles', { title: 'x', content: 'y' })
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Same runtime path, same middleware, same OpenAPI document — the difference is
|
|
148
|
+
entirely at compile time. Keep the string form for routes no TypeScript consumer
|
|
149
|
+
calls; it stays lazily imported. See the `stacks-api` and `stacks-router` skills.
|
|
150
|
+
|
|
124
151
|
## Step 5: Add Event Listeners (Optional)
|
|
125
152
|
|
|
126
153
|
```typescript
|
|
@@ -47,10 +47,103 @@ route.group({ prefix: '/api/v1', middleware: ['auth', 'throttle'] }, () => {
|
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
### Handler Types
|
|
50
|
-
- Function: `(req
|
|
51
|
-
|
|
50
|
+
- Function: `(req) => …` — return a `Response`, or any value `formatResult`
|
|
51
|
+
handles: an object/array becomes JSON, a string becomes text, `null` becomes
|
|
52
|
+
204, a `ReadableStream` streams. `req.params` is narrowed to the path's own
|
|
53
|
+
placeholders, so `req.params.slugTypo` is a compile error rather than
|
|
54
|
+
`undefined` at runtime.
|
|
55
|
+
- Action string: `'Actions/CreateUser'` — auto-loads action, lazily
|
|
56
|
+
- Action object: an imported action, passed directly — see typed routes below
|
|
52
57
|
- Controller: `'Controllers/UserController@index'` — calls controller method
|
|
53
58
|
|
|
59
|
+
## The strings are typed (run `buddy generate:types`)
|
|
60
|
+
|
|
61
|
+
Action paths, middleware aliases and route names are all checked at compile
|
|
62
|
+
time against what this application actually has. `buddy generate:types`
|
|
63
|
+
discovers them and writes them into the router's type registry
|
|
64
|
+
(`storage/framework/types/actions.d.ts`); nothing is maintained by hand.
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
route.get('/login', 'Actions/Auth/LogniAction') // ✗ no such action
|
|
68
|
+
route.get('/admin', handler).middleware('atuh') // ✗ no such middleware alias
|
|
69
|
+
url('email.unsubscrbe', { token }) // ✗ no such route name
|
|
70
|
+
url('user.post', { id: 42 }) // params come from the path
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The middleware one is the one that matters most: a typo'd alias used to serve
|
|
74
|
+
the route **without** the protection, silently.
|
|
75
|
+
|
|
76
|
+
Notes:
|
|
77
|
+
- Controllers stay a pattern (`'Controllers/X@method'`) — the method half is a
|
|
78
|
+
member name, not a filename.
|
|
79
|
+
- Negated (`'!auth'`) and parameterised (`'throttle:60,1'`) middleware forms are
|
|
80
|
+
both accepted.
|
|
81
|
+
- Regenerate after adding an action, a middleware alias, or a `.name()`. A stale
|
|
82
|
+
file rejects code that is correct.
|
|
83
|
+
- `resource()` takes a BASE, and composes `Actions/<Base><Kind>Action` from it.
|
|
84
|
+
`route.resource('posts', 'Post')` → `Actions/PostIndexAction`, matching where
|
|
85
|
+
`buddy make:crud` writes. The base is checked against the actions that exist;
|
|
86
|
+
which of the five siblings you need depends on `only`/`except`, so that part
|
|
87
|
+
is settled when the route is hit.
|
|
88
|
+
|
|
89
|
+
### Path params arrive decoded
|
|
90
|
+
|
|
91
|
+
`/users/{name}` given `/users/caf%C3%A9` hands the handler `café`, and `%2F`
|
|
92
|
+
becomes a real `/`. Decoded exactly once, in bun-router — do NOT decode again in
|
|
93
|
+
an action or middleware: two passes turn `%2520` into a space, which is how a
|
|
94
|
+
filter that rejects `../` gets walked past. A malformed escape (`%ZZ`) passes
|
|
95
|
+
through raw rather than failing the request.
|
|
96
|
+
|
|
97
|
+
A decoded param can contain `/`, so anything joining one into a filesystem path
|
|
98
|
+
still has to sanitise. Decoding makes the value correct, not safe.
|
|
99
|
+
|
|
100
|
+
## Typed Routes (zero generation)
|
|
101
|
+
|
|
102
|
+
`route.get('/x', 'Actions/Foo')` resolves its action by a dynamic `import()` of a
|
|
103
|
+
string. Good for the runtime — lazy, hot-reload friendly — and completely opaque
|
|
104
|
+
to the compiler, so no client can be typed from it without a generation step.
|
|
105
|
+
|
|
106
|
+
`createTypedRouter()` registers through the same router while accumulating a
|
|
107
|
+
route map into its own type:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import IndexAction from '../app/Actions/Project/IndexAction'
|
|
111
|
+
import StoreAction from '../app/Actions/Project/StoreAction'
|
|
112
|
+
import { createTypedRouter } from '@stacksjs/router'
|
|
113
|
+
|
|
114
|
+
export const api = createTypedRouter()
|
|
115
|
+
.get('/v1/projects', IndexAction)
|
|
116
|
+
.post('/v1/projects', StoreAction, { middleware: 'auth', rateLimit: { max: 10 } })
|
|
117
|
+
|
|
118
|
+
export type AppRoutes = typeof api
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Any TypeScript consumer then gets full inference with **no CLI step**:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
import { createTypedClient } from '@stacksjs/router'
|
|
125
|
+
|
|
126
|
+
const client = createTypedClient<AppRoutes>({ baseUrl })
|
|
127
|
+
const projects = await client.get('/v1/projects') // typed from the action
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Facts worth knowing before using it:
|
|
131
|
+
|
|
132
|
+
- **One runtime path.** A directly-registered action goes through the same
|
|
133
|
+
`wrapAction` as a string-registered one — validation, `authorize`, `before`,
|
|
134
|
+
`formatResult`, error reporting. Only the compile-time story differs.
|
|
135
|
+
- **Input from `validations`, output from `handle`'s return type.** An action
|
|
136
|
+
returning a `Response` is typed `unknown`; it took over the wire format.
|
|
137
|
+
- **Options are an argument**, not chained — chaining would return the route and
|
|
138
|
+
lose the accumulated type.
|
|
139
|
+
- **No `.group()`.** A runtime-only prefix makes every path type wrong; a
|
|
140
|
+
type-only prefix is a second place for the URL to live.
|
|
141
|
+
- **Both forms feed OpenAPI.** Directly-registered actions are reported by
|
|
142
|
+
`listRegisteredRoutes()`, so the generator reads their schema with no file
|
|
143
|
+
path to import.
|
|
144
|
+
- The builder, the client and the contract live in `@stacksjs/bun-router` and
|
|
145
|
+
are re-exported here. See the `stacks-api` skill for the full client story.
|
|
146
|
+
|
|
54
147
|
## Route Registry (app/Routes.ts)
|
|
55
148
|
|
|
56
149
|
```typescript
|
|
@@ -12,7 +12,7 @@ export default new Action({
|
|
|
12
12
|
|
|
13
13
|
validations: {
|
|
14
14
|
email: {
|
|
15
|
-
rule: schema.string().email(),
|
|
15
|
+
rule: schema.string().email().required(),
|
|
16
16
|
message: 'Email must be a valid email address.',
|
|
17
17
|
},
|
|
18
18
|
// Presence only, NOT the creation policy. Enforcing a minimum length on
|
|
@@ -21,7 +21,7 @@ export default new Action({
|
|
|
21
21
|
// credentials are ever checked. The policy belongs on the paths that SET a
|
|
22
22
|
// password (#2226).
|
|
23
23
|
password: {
|
|
24
|
-
rule: schema.string().min(1).max(PASSWORD_MAX_LENGTH),
|
|
24
|
+
rule: schema.string().min(1).max(PASSWORD_MAX_LENGTH).required(),
|
|
25
25
|
message: PASSWORD_PRESENCE_MESSAGE,
|
|
26
26
|
},
|
|
27
27
|
},
|
|
@@ -12,15 +12,15 @@ export default new Action({
|
|
|
12
12
|
|
|
13
13
|
validations: {
|
|
14
14
|
email: {
|
|
15
|
-
rule: schema.string().email(),
|
|
15
|
+
rule: schema.string().email().required(),
|
|
16
16
|
message: 'Email must be a valid email address.',
|
|
17
17
|
},
|
|
18
18
|
password: {
|
|
19
|
-
rule: schema.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH),
|
|
19
|
+
rule: schema.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH).required(),
|
|
20
20
|
message: PASSWORD_POLICY_MESSAGE,
|
|
21
21
|
},
|
|
22
22
|
name: {
|
|
23
|
-
rule: schema.string().min(2).max(255),
|
|
23
|
+
rule: schema.string().min(2).max(255).required(),
|
|
24
24
|
message: 'Name must be between 2 and 255 characters.',
|
|
25
25
|
},
|
|
26
26
|
},
|
|
@@ -10,11 +10,11 @@ export default new Action({
|
|
|
10
10
|
|
|
11
11
|
validations: {
|
|
12
12
|
challenge_token: {
|
|
13
|
-
rule: schema.string().min(1),
|
|
13
|
+
rule: schema.string().min(1).required(),
|
|
14
14
|
message: 'A challenge token is required.',
|
|
15
15
|
},
|
|
16
16
|
code: {
|
|
17
|
-
rule: schema.string().min(6).max(6),
|
|
17
|
+
rule: schema.string().min(6).max(6).required(),
|
|
18
18
|
message: 'Code must be a 6-digit TOTP code.',
|
|
19
19
|
},
|
|
20
20
|
},
|
|
@@ -10,20 +10,20 @@ export default new Action({
|
|
|
10
10
|
async handle(request: RequestInstance) {
|
|
11
11
|
await request.validate({
|
|
12
12
|
name: {
|
|
13
|
-
rule: schema.string(),
|
|
13
|
+
rule: schema.string().required(),
|
|
14
14
|
message: {
|
|
15
15
|
name: 'Name is required',
|
|
16
16
|
},
|
|
17
17
|
},
|
|
18
18
|
description: {
|
|
19
|
-
rule: schema.string(),
|
|
19
|
+
rule: schema.string().required(),
|
|
20
20
|
message: {
|
|
21
21
|
description: 'Description is required',
|
|
22
22
|
},
|
|
23
23
|
},
|
|
24
24
|
|
|
25
25
|
categorizable_type: {
|
|
26
|
-
rule: schema.string(),
|
|
26
|
+
rule: schema.string().required(),
|
|
27
27
|
message: {
|
|
28
28
|
categorizable_type: 'Categorizable type is required',
|
|
29
29
|
},
|
|
@@ -8,17 +8,23 @@ export default new Action({
|
|
|
8
8
|
description: 'Category Update ORM Action',
|
|
9
9
|
method: 'PATCH',
|
|
10
10
|
async handle(request: RequestInstance) {
|
|
11
|
+
/*
|
|
12
|
+
* PATCH, so these are NOT required: a partial update sends the fields it
|
|
13
|
+
* means to change. The messages used to say "X is required", which fires on
|
|
14
|
+
* a type failure and never on absence - a message describing a rule the
|
|
15
|
+
* block does not have.
|
|
16
|
+
*/
|
|
11
17
|
await request.validate({
|
|
12
18
|
name: {
|
|
13
19
|
rule: schema.string(),
|
|
14
20
|
message: {
|
|
15
|
-
name: 'Name
|
|
21
|
+
name: 'Name must be a string.',
|
|
16
22
|
},
|
|
17
23
|
},
|
|
18
24
|
description: {
|
|
19
25
|
rule: schema.string(),
|
|
20
26
|
message: {
|
|
21
|
-
description: 'Description
|
|
27
|
+
description: 'Description must be a string.',
|
|
22
28
|
},
|
|
23
29
|
},
|
|
24
30
|
})
|
|
@@ -10,13 +10,13 @@ export default new Action({
|
|
|
10
10
|
async handle(request: RequestInstance) {
|
|
11
11
|
await request.validate({
|
|
12
12
|
title: {
|
|
13
|
-
rule: schema.string(),
|
|
13
|
+
rule: schema.string().required(),
|
|
14
14
|
message: {
|
|
15
15
|
title: 'Title is required',
|
|
16
16
|
},
|
|
17
17
|
},
|
|
18
18
|
body: {
|
|
19
|
-
rule: schema.string(),
|
|
19
|
+
rule: schema.string().required(),
|
|
20
20
|
message: {
|
|
21
21
|
body: 'Body is required',
|
|
22
22
|
},
|
|
@@ -8,17 +8,23 @@ export default new Action({
|
|
|
8
8
|
description: 'Comment Update ORM Action',
|
|
9
9
|
method: 'PATCH',
|
|
10
10
|
async handle(request: RequestInstance) {
|
|
11
|
+
/*
|
|
12
|
+
* PATCH, so these are NOT required: a partial update sends the fields it
|
|
13
|
+
* means to change. The messages used to say "X is required", which fires on
|
|
14
|
+
* a type failure and never on absence - a message describing a rule the
|
|
15
|
+
* block does not have.
|
|
16
|
+
*/
|
|
11
17
|
await request.validate({
|
|
12
18
|
title: {
|
|
13
19
|
rule: schema.string(),
|
|
14
20
|
message: {
|
|
15
|
-
title: 'Title
|
|
21
|
+
title: 'Title must be a string.',
|
|
16
22
|
},
|
|
17
23
|
},
|
|
18
24
|
body: {
|
|
19
25
|
rule: schema.string(),
|
|
20
26
|
message: {
|
|
21
|
-
body: 'Body
|
|
27
|
+
body: 'Body must be a string.',
|
|
22
28
|
},
|
|
23
29
|
},
|
|
24
30
|
})
|
|
@@ -8,11 +8,17 @@ export default new Action({
|
|
|
8
8
|
description: 'Updates the view count for a post',
|
|
9
9
|
method: 'PATCH',
|
|
10
10
|
async handle(request: RequestInstance) {
|
|
11
|
+
/*
|
|
12
|
+
* PATCH, so these are NOT required: a partial update sends the fields it
|
|
13
|
+
* means to change. The messages used to say "X is required", which fires on
|
|
14
|
+
* a type failure and never on absence - a message describing a rule the
|
|
15
|
+
* block does not have.
|
|
16
|
+
*/
|
|
11
17
|
await request.validate({
|
|
12
18
|
views: {
|
|
13
19
|
rule: schema.number(),
|
|
14
20
|
message: {
|
|
15
|
-
views: 'Views
|
|
21
|
+
views: 'Views must be a number.',
|
|
16
22
|
},
|
|
17
23
|
},
|
|
18
24
|
})
|
|
@@ -10,13 +10,13 @@ export default new Action({
|
|
|
10
10
|
async handle(request: RequestInstance) {
|
|
11
11
|
await request.validate({
|
|
12
12
|
name: {
|
|
13
|
-
rule: schema.string(),
|
|
13
|
+
rule: schema.string().required(),
|
|
14
14
|
message: {
|
|
15
15
|
name: 'Name is required',
|
|
16
16
|
},
|
|
17
17
|
},
|
|
18
18
|
description: {
|
|
19
|
-
rule: schema.string(),
|
|
19
|
+
rule: schema.string().required(),
|
|
20
20
|
message: {
|
|
21
21
|
description: 'Description is required',
|
|
22
22
|
},
|
|
@@ -8,17 +8,23 @@ export default new Action({
|
|
|
8
8
|
description: 'Tag Update ORM Action',
|
|
9
9
|
method: 'PATCH',
|
|
10
10
|
async handle(request: RequestInstance) {
|
|
11
|
+
/*
|
|
12
|
+
* PATCH, so these are NOT required: a partial update sends the fields it
|
|
13
|
+
* means to change. The messages used to say "X is required", which fires on
|
|
14
|
+
* a type failure and never on absence - a message describing a rule the
|
|
15
|
+
* block does not have.
|
|
16
|
+
*/
|
|
11
17
|
await request.validate({
|
|
12
18
|
name: {
|
|
13
19
|
rule: schema.string(),
|
|
14
20
|
message: {
|
|
15
|
-
name: 'Name
|
|
21
|
+
name: 'Name must be a string.',
|
|
16
22
|
},
|
|
17
23
|
},
|
|
18
24
|
description: {
|
|
19
25
|
rule: schema.string(),
|
|
20
26
|
message: {
|
|
21
|
-
description: 'Description
|
|
27
|
+
description: 'Description must be a string.',
|
|
22
28
|
},
|
|
23
29
|
},
|
|
24
30
|
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
2
|
import { db } from '@stacksjs/database'
|
|
3
3
|
import { modelBoolean } from './kanban-model'
|
|
4
|
-
import { kanbanActionError
|
|
4
|
+
import { kanbanActionError } from './kanban-response'
|
|
5
5
|
|
|
6
6
|
interface BoardRow {
|
|
7
7
|
id: number
|
package/app/Actions/LogAction.ts
CHANGED
|
@@ -15,7 +15,7 @@ export default new Action({
|
|
|
15
15
|
// the request object is optional, but if it is provided, it will be used for validation
|
|
16
16
|
validations: {
|
|
17
17
|
message: {
|
|
18
|
-
rule: schema.string().min(3).max(255),
|
|
18
|
+
rule: schema.string().min(3).max(255).required(),
|
|
19
19
|
message: 'The message must be between 3 and 255 characters long.',
|
|
20
20
|
},
|
|
21
21
|
|
|
@@ -25,7 +25,7 @@ export default new Action({
|
|
|
25
25
|
// is not a function" at module evaluation. Use `schema.enum([...])`
|
|
26
26
|
// — that's the working enum primitive used throughout the framework
|
|
27
27
|
// defaults and in the typical project's models.
|
|
28
|
-
rule: schema.enum(['info', 'warn', 'error']),
|
|
28
|
+
rule: schema.enum(['info', 'warn', 'error']).required(),
|
|
29
29
|
message: 'The log level must be one of "info", "warn", or "error".',
|
|
30
30
|
},
|
|
31
31
|
},
|
package/app/Middleware/Auth.ts
CHANGED
package/ide/vscode/package.json
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.72.
|
|
5
|
+
"version": "0.72.60",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@iconify-json/f7": "^1.2.2",
|
|
53
53
|
"@iconify-json/hugeicons": "^1.2.27",
|
|
54
|
-
"@stacksjs/mobile": "^0.72.
|
|
54
|
+
"@stacksjs/mobile": "^0.72.60",
|
|
55
55
|
"@stacksjs/sanitizer": "^0.2.113"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {
|
package/routes/dashboard.ts
CHANGED