@stacksjs/defaults 0.72.98 → 0.72.99
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-auth/SKILL.md +35 -5
- package/ai/skills/stacks-config/SKILL.md +11 -1
- package/ai/skills/stacks-desktop/SKILL.md +46 -0
- package/ai/skills/stacks-env/SKILL.md +26 -0
- package/ai/skills/stacks-events/SKILL.md +76 -60
- package/ai/skills/stacks-jobs/SKILL.md +3 -0
- package/ai/skills/stacks-listeners/SKILL.md +39 -9
- package/ai/skills/stacks-middleware/SKILL.md +50 -12
- package/ai/skills/stacks-queue/SKILL.md +5 -0
- package/ai/skills/stacks-router/SKILL.md +17 -7
- package/ai/skills/stacks-scheduler/SKILL.md +1 -1
- package/app/Actions/Dashboard/Commerce/CommerceCustomersAction.ts +1 -1
- package/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts +1 -1
- package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +1 -1
- package/app/Actions/Dashboard/Commerce/CommercePosAction.ts +1 -1
- package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +1 -1
- package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +1 -1
- package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +1 -1
- package/app/Actions/Dashboard/Content/content-input.ts +2 -2
- package/app/Actions/Dashboard/Content/post-input.ts +4 -4
- package/app/Actions/Dashboard/Email/inbox-request.ts +1 -1
- package/app/Actions/Dashboard/Email/mail-preference.ts +1 -1
- package/app/Actions/Dashboard/Marketing/CampaignIndexAction.ts +1 -1
- package/app/Actions/Dashboard/Marketing/CampaignStoreAction.ts +1 -1
- package/app/Actions/Dashboard/Marketing/CampaignUpdateAction.ts +1 -1
- package/app/Actions/Dashboard/Teams/TeamInvitationDestroyAction.ts +2 -2
- package/app/Actions/Dashboard/Teams/TeamInvitationResendAction.ts +2 -2
- package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +5 -5
- package/app/Actions/Dashboard/Teams/TeamMemberDestroyAction.ts +2 -2
- package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +2 -2
- package/app/Actions/Dashboard/Teams/TeamPeopleIndexAction.ts +2 -2
- package/app/Actions/Dashboard/Teams/team-invitation-delivery.ts +2 -2
- package/app/Events.ts +9 -4
- package/app/Gates.ts +83 -85
- package/app/Listener.ts +34 -81
- package/app/Middleware.ts +22 -7
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/project/storage/framework/server/tsconfig.docker.json +1 -2
- package/resources/components/Buttons/Counter.stx +30 -21
- package/resources/components/Dashboard/Analytics/WebAnalyticsDashboard.stx +1 -3
- package/resources/components/Dashboard/Auth/ForgotPasswordDashboard.stx +1 -1
- package/resources/components/Dashboard/Auth/LoginDashboard.stx +2 -1
- package/resources/components/Dashboard/Auth/RegisterDashboard.stx +2 -1
- package/resources/components/Dashboard/Auth/ResetPasswordDashboard.stx +1 -1
- package/resources/components/Dashboard/Ci/CiDashboard.stx +9 -9
- package/resources/components/Dashboard/Ci/CiRunHistoryDrawer.stx +7 -7
- package/resources/components/Dashboard/Commerce/CommerceOverviewCharts.stx +2 -1
- package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +1 -1
- package/resources/components/Dashboard/Content/BlogDashboard.stx +37 -15
- package/resources/components/Dashboard/Content/ContentDashboard.stx +3 -26
- package/resources/components/Dashboard/Email/EmailActivityDashboard.stx +2 -15
- package/resources/components/Dashboard/Environment/EnvironmentDashboard.stx +9 -1
- package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +15 -13
- package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +10 -8
- package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +10 -10
- package/resources/components/Dashboard/Management/PermissionsDashboard.stx +3 -3
- package/resources/components/Dashboard/Modals/Popups/Alert.stx +4 -1
- package/resources/components/Dashboard/Navbar.stx +1 -1
- package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +11 -6
- package/resources/components/Dashboard/Settings/SettingsDashboard.stx +2 -2
- package/resources/components/Dashboard/Transaction/index.stx +6 -6
- package/resources/components/Dashboard/UI/Avatar.stx +2 -2
- package/resources/components/Dashboard/UI/DropdownItem.stx +4 -4
- package/resources/components/Dashboard/UI/WindowControls.stx +17 -2
- package/resources/components/Docs/Demo/ComboboxDemo.stx +8 -3
- package/resources/components/Docs/Demo/DropdownDemo.stx +3 -3
- package/resources/components/Docs/Demo/StepperDemo.stx +1 -1
- package/resources/components/Forum/ForumLayout.stx +1 -1
- package/resources/components/Forum/ForumReplyForm.stx +1 -1
- package/resources/components/Marketing/ComingSoon.stx +5 -5
- package/resources/components/Storefront/CartDrawer.stx +44 -22
- package/resources/components/Storefront/ProductGallery.stx +3 -2
- package/resources/components/Storefront/QuantitySelector.stx +12 -7
- package/resources/functions/storefront/cart-cookie.ts +58 -0
- package/resources/layouts/storefront.stx +3 -2
- package/resources/views/cart.stx +19 -3
- package/resources/views/checkout/contact.stx +14 -3
- package/resources/views/checkout/payment.stx +14 -3
- package/resources/views/checkout/shipping.stx +14 -3
- package/resources/views/cms/blocks/form.stx +16 -11
- package/resources/views/cms/page.stx +28 -0
- package/resources/views/coming-soon.stx +0 -6
- package/resources/views/orders/[id].stx +19 -3
- package/views/dashboard/composables/index.ts +1 -1
- package/views/dashboard/composables/useChart.ts +26 -14
- package/views/dashboard/layouts/default.stx +42 -25
|
@@ -363,16 +363,44 @@ await authUser.authorize('edit-post', post) // throws if denied
|
|
|
363
363
|
|
|
364
364
|
## Middleware Aliases (app/Middleware.ts)
|
|
365
365
|
|
|
366
|
-
|
|
366
|
+
Auth-relevant aliases: `auth`, `guest`, `verified` (EnsureEmailIsVerified),
|
|
367
|
+
`abilities`, `can`, `role`, `permission`, `team`, `signed`, `throttle`. The
|
|
368
|
+
environment aliases are `env`, `env:local`, `env:development` / `env:dev`,
|
|
369
|
+
`env:staging`, `env:production` / `env:prod` — with a COLON, not a dot; an
|
|
370
|
+
earlier version of this list wrote `env.local` and those never existed. See
|
|
371
|
+
`stacks-middleware` for the full set and for the `!alias` and `alias:params`
|
|
372
|
+
forms.
|
|
367
373
|
|
|
368
|
-
## Application Gates
|
|
374
|
+
## Application Gates (app/Gates.ts)
|
|
369
375
|
|
|
370
376
|
```typescript
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
377
|
+
import { defineGates } from '@stacksjs/auth'
|
|
378
|
+
|
|
379
|
+
export default defineGates({
|
|
380
|
+
gates: {
|
|
381
|
+
'access-admin': user => user?.email?.endsWith('@stacksjs.org') ?? false,
|
|
382
|
+
'edit-settings': user => !!user,
|
|
383
|
+
'view-dashboard': user => !!user,
|
|
384
|
+
},
|
|
385
|
+
policies: {
|
|
386
|
+
Post: 'PostPolicy',
|
|
387
|
+
},
|
|
388
|
+
})
|
|
374
389
|
```
|
|
375
390
|
|
|
391
|
+
Registered at boot by `initializeAuthorization()`, from
|
|
392
|
+
`injectGlobalAutoImports()` — the one place every entry point comes through, so
|
|
393
|
+
HTTP, `buddy seed`, a scheduled job and a console command all get the same
|
|
394
|
+
gates.
|
|
395
|
+
|
|
396
|
+
Both halves of `policies` are checked: the key names a model the ORM exposes,
|
|
397
|
+
the value a policy file under `app/Policies/` or the framework defaults. An
|
|
398
|
+
explicit mapping WINS over the `<Model>Policy` naming convention, which is the
|
|
399
|
+
reason to write one.
|
|
400
|
+
|
|
401
|
+
`Gate.define(...)` still works for a gate registered at runtime; `defineGates`
|
|
402
|
+
is the declarative form and the one the ability-name completions come from.
|
|
403
|
+
|
|
376
404
|
## Default API Routes
|
|
377
405
|
|
|
378
406
|
- `POST /login` → LoginAction (validates email + password)
|
|
@@ -409,6 +437,8 @@ traits: {
|
|
|
409
437
|
- RBAC has an internal cache (`userRoles`, `userPermissions`, `rolePermissions`) — call `Rbac.flushCache()` after direct DB changes
|
|
410
438
|
- `syncRoles()` and `syncPermissions()` are guard-scoped replacements: they preserve assignments belonging to other guards
|
|
411
439
|
- Gate `before` callbacks can short-circuit — return `true` to allow, `null` to continue checking
|
|
440
|
+
- An ability with no gate and no policy method **denies**. That is the right default, and it means a gate that was never registered is indistinguishable from one that says no — which is how `initializeAuthorization()` went unnoticed while nothing called it
|
|
441
|
+
- `allows()` and friends take `Ability`, which is open (`GateName | PolicyAbility | (string & {})`). A `/can/:ability` route passes an ability straight through, so narrowing it would reject correct code; the union is for completions
|
|
412
442
|
- `withRbac()` and `withAuthorization()` return new objects with methods mixed in
|
|
413
443
|
- The `RbacStore` interface must be implemented and set via `Rbac.setStore()` for RBAC to work
|
|
414
444
|
- Password reset tokens expire after 60 minutes by default
|
|
@@ -44,7 +44,17 @@ export default defineApp({
|
|
|
44
44
|
}) satisfies AppConfig
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
All builders: `defineApp`, `defineCache`, `defineCdn`, `defineChat`, `defineCli`, `defineDatabase`, `defineDependencies`, `defineDns`, `defineEmailConfig`, `defineEmail`, `defineGit`, `defineHashing`, `defineLibrary`, `defineNotification`, `definePayment`, `defineQueue`, `defineSearchEngine`, `defineSecurity`, `defineServices`, `defineSms`, `defineFilesystems`, `defineUi`, `
|
|
47
|
+
All builders: `defineApp`, `defineCache`, `defineCdn`, `defineChat`, `defineCli`, `defineDatabase`, `defineDependencies`, `defineDns`, `defineEmailConfig`, `defineEmail`, `defineGit`, `defineHashing`, `defineLibrary`, `defineNotification`, `definePayment`, `defineQueue`, `defineSearchEngine`, `defineSecurity`, `defineServices`, `defineSms`, `defineFilesystems`, `defineUi`, `defineEvents`
|
|
48
|
+
|
|
49
|
+
`defineModel` is NOT among them: it comes from `@stacksjs/orm`, and it builds a
|
|
50
|
+
model rather than returning a config object. `@stacksjs/config` used to export a
|
|
51
|
+
second one typed `(config: Model) => Model`, which widened every literal a model
|
|
52
|
+
declared - its table name, its attribute names - so a model that imported the
|
|
53
|
+
wrong one silently lost the typing the ORM version exists to provide.
|
|
54
|
+
|
|
55
|
+
The app-level registries have their own helpers, in the packages that own what
|
|
56
|
+
they name: `defineEvents` and `defineListener` from `@stacksjs/events`,
|
|
57
|
+
`defineMiddleware` from `@stacksjs/router`, `defineGates` from `@stacksjs/auth`.
|
|
48
58
|
|
|
49
59
|
## Helper Functions
|
|
50
60
|
- `determineAppEnv(): 'dev' | 'stage' | 'prod' | string`
|
|
@@ -74,6 +74,52 @@ interface Desktop {
|
|
|
74
74
|
}
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
## Local-first apps: owning the launcher
|
|
78
|
+
|
|
79
|
+
The launcher Stacks compiles opens a Craft window on the URL in `desktop.json`,
|
|
80
|
+
which is what a hosted Stacks application wants. An app whose subject is the
|
|
81
|
+
machine it runs on — a disk cleaner, a log viewer, a device tool — has no such
|
|
82
|
+
URL: it starts something locally and opens a window on that, on a port it does
|
|
83
|
+
not know until launch.
|
|
84
|
+
|
|
85
|
+
Write `app/Desktop/launcher.ts` and `build:desktop` compiles that instead, the
|
|
86
|
+
same way anything under `app/` overrides its framework default. `DESKTOP_URL`
|
|
87
|
+
and `APP_URL` then become optional, because the launcher decides what to open.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// app/Desktop/launcher.ts — compiled to Contents/MacOS/<AppName>
|
|
91
|
+
import { dirname, join } from 'node:path'
|
|
92
|
+
|
|
93
|
+
const macos = dirname(process.execPath) // siblings live here
|
|
94
|
+
const server = Bun.spawn([join(macos, 'my-agent')], { stdout: 'pipe' })
|
|
95
|
+
const port = await readPortFrom(server.stdout) // the agent picks a free one
|
|
96
|
+
|
|
97
|
+
const craft = Bun.spawn([
|
|
98
|
+
join(macos, 'craft-runtime'),
|
|
99
|
+
`http://127.0.0.1:${port}`,
|
|
100
|
+
'--title', 'My App',
|
|
101
|
+
], { stdout: 'inherit' })
|
|
102
|
+
|
|
103
|
+
process.exit(await craft.exited)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Two things follow from declaring your own launcher:
|
|
107
|
+
|
|
108
|
+
- **Every file `build:desktop` leaves in `storage/framework/desktop-dist` is
|
|
109
|
+
copied into `Contents/MacOS`.** Compile the sibling binaries your launcher
|
|
110
|
+
spawns into that directory and they ship with it.
|
|
111
|
+
- **`app/Desktop/Resources/` is copied into `Contents/Resources`.** A
|
|
112
|
+
prerendered UI, a schema, seed data — a local-first app has a payload, and
|
|
113
|
+
this is where it goes.
|
|
114
|
+
|
|
115
|
+
`build:dmg` also narrows App Transport Security for these bundles: an exception
|
|
116
|
+
for `127.0.0.1` rather than `NSAllowsArbitraryLoads`, which would additionally
|
|
117
|
+
permit every unencrypted host on the internet.
|
|
118
|
+
|
|
119
|
+
Application data belongs in `~/Library/Application Support/<AppName>`, never
|
|
120
|
+
inside the bundle — `/Applications` is not writable by the user, and the bundle
|
|
121
|
+
is replaced wholesale on update.
|
|
122
|
+
|
|
77
123
|
## CLI Commands
|
|
78
124
|
|
|
79
125
|
```bash
|
|
@@ -57,6 +57,30 @@ The `env` proxy auto-coerces: `'true'` → `true`, `'123'` → `123`, etc.
|
|
|
57
57
|
## StacksEnv Type (100+ typed variables)
|
|
58
58
|
App, Ports, API, Database, AWS, Mail, Services (Stripe, Meilisearch), Frontend, Realtime, Redis, Pusher, Auth, Storage, Queue, plus `[key: string]` catch-all.
|
|
59
59
|
|
|
60
|
+
## Adding your own variables
|
|
61
|
+
|
|
62
|
+
Declare them in `config/env.ts`. That is the whole step:
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// config/env.ts
|
|
66
|
+
export default defineEnv({
|
|
67
|
+
STRIPE_WEBHOOK_SECRET: { validation: schema.string(), default: '' },
|
|
68
|
+
BILLING_RETRIES: { validation: schema.number(), default: 3 },
|
|
69
|
+
DEPLOY_TARGET: { validation: schema.enum(['staging', 'production']), default: 'staging' },
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`env.BILLING_RETRIES` is a `number` and `env.DEPLOY_TARGET` is
|
|
74
|
+
`'staging' | 'production'` everywhere, from the validator - nothing is
|
|
75
|
+
generated, so a variable that exists only in your deploy secrets is typed
|
|
76
|
+
exactly like one in your local `.env`.
|
|
77
|
+
|
|
78
|
+
`storage/framework/types/env.d.ts` reads the schema and extends `StacksEnv`.
|
|
79
|
+
Do NOT write a `declare module '@stacksjs/env'` block of your own: the
|
|
80
|
+
framework already has one, and a second augmentation listing your keys again
|
|
81
|
+
typechecks while letting a key be typed without being validated or defaulted -
|
|
82
|
+
which is the failure `config/env.ts` exists to prevent.
|
|
83
|
+
|
|
60
84
|
## Runtime Detection
|
|
61
85
|
|
|
62
86
|
```typescript
|
|
@@ -199,3 +223,5 @@ calls or raw client-side `fetch`.
|
|
|
199
223
|
- Runtime detection uses Bun globals and process properties
|
|
200
224
|
- CI provider detection checks environment variables specific to each CI system
|
|
201
225
|
- The `StacksEnv` type provides autocomplete for 100+ known variables
|
|
226
|
+
- Every variable is `| undefined` - the process may simply not have it set
|
|
227
|
+
- `Bun.env.X` is NOT typed, and holds raw strings. `Bun.env.DEBUG` is `'false'`, which is truthy; use `env.DEBUG`, which coerces. A generator used to declare `Bun.env` from whichever `.env` was on the machine that ran it, typing `DEBUG` as `boolean` - it was removed, and nothing read it
|
|
@@ -109,16 +109,33 @@ type Off = <Key extends keyof StacksEvents>(type: Key, handler?: Handler<StacksE
|
|
|
109
109
|
## Built-in Event Types (StacksEvents)
|
|
110
110
|
|
|
111
111
|
```typescript
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
'user:
|
|
115
|
-
'user:logged-
|
|
116
|
-
'user:
|
|
117
|
-
'user:password-
|
|
112
|
+
// @stacksjs/events
|
|
113
|
+
interface AuthEvents {
|
|
114
|
+
'user:registered': UserRegisteredEvent
|
|
115
|
+
'user:logged-in': UserLoggedInEvent
|
|
116
|
+
'user:logged-out': UserLoggedOutEvent
|
|
117
|
+
'user:password-reset': UserPasswordEvent
|
|
118
|
+
'user:password-changed': UserPasswordEvent
|
|
118
119
|
}
|
|
120
|
+
|
|
121
|
+
// Augmentation target - model events land here, and so do yours.
|
|
122
|
+
interface AppEvents {}
|
|
123
|
+
|
|
124
|
+
type StacksEvents = AppEvents & AuthEvents
|
|
125
|
+
type EventName = keyof StacksEvents & string
|
|
119
126
|
```
|
|
120
127
|
|
|
121
|
-
|
|
128
|
+
There is **no** trailing index signature, deliberately: an arbitrary event name is
|
|
129
|
+
what made `dispatch('user:creatd', …)` compile and reach nobody. Declare an
|
|
130
|
+
application's own events on `AppEvents` and the typo becomes a compile error.
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
declare module '@stacksjs/events' {
|
|
134
|
+
interface AppEvents {
|
|
135
|
+
'invoice:settled': { id: number, total: number }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
122
139
|
|
|
123
140
|
## Model Events
|
|
124
141
|
|
|
@@ -188,87 +205,86 @@ answer to "which models emit events" is "the ones that exist".
|
|
|
188
205
|
## Event-to-Listener Mapping (app/Events.ts)
|
|
189
206
|
|
|
190
207
|
```typescript
|
|
191
|
-
import
|
|
208
|
+
import { defineEvents } from '@stacksjs/events'
|
|
192
209
|
|
|
193
|
-
export default {
|
|
210
|
+
export default defineEvents({
|
|
194
211
|
'user:registered': ['SendWelcomeEmail'],
|
|
195
212
|
'user:created': ['NotifyUser'],
|
|
196
|
-
}
|
|
213
|
+
})
|
|
197
214
|
```
|
|
198
215
|
|
|
199
|
-
|
|
216
|
+
Both halves are checked. A key must be an event that exists (`EventName`, above);
|
|
217
|
+
a value must name a listener that is on disk (`ListenerName`, generated into
|
|
218
|
+
`storage/framework/types/actions.d.ts` from `app/Listeners/`, `app/Actions/` and
|
|
219
|
+
the framework defaults behind them). `satisfies Events` is equivalent and still
|
|
220
|
+
supported; `defineEvents` is preferred because it also keeps the literal types,
|
|
221
|
+
so `keyof typeof events` is the two names the file declares rather than `string`.
|
|
200
222
|
|
|
201
223
|
## Listener Resolution (app/Listener.ts)
|
|
202
224
|
|
|
203
|
-
|
|
225
|
+
`handleEvents()` delegates to `registerAppListeners()`, which registers both
|
|
226
|
+
conventions and is idempotent:
|
|
204
227
|
|
|
205
|
-
### Setup
|
|
206
|
-
```typescript
|
|
207
|
-
export async function handleEvents() {
|
|
208
|
-
emitter.on('*', listenEvents as WildcardHandler<StacksEvents>)
|
|
209
|
-
}
|
|
210
|
-
```
|
|
211
|
-
Subscribes a single wildcard handler that intercepts ALL events.
|
|
212
|
-
|
|
213
|
-
### Event Processing Flow
|
|
214
|
-
1. **Fast path**: `eventTypes` Set (pre-computed from `Object.keys(events)`) provides O(1) lookup. Events not in the map are skipped immediately.
|
|
215
|
-
2. **Listener resolution**: For each listener name in the array, `resolveAction(listener)` is called:
|
|
216
|
-
- Checks `actionCache` Map (in-memory module cache)
|
|
217
|
-
- Checks `pendingImports` Map (deduplicates concurrent imports of the same action)
|
|
218
|
-
- Dynamically imports `app/Actions/{listener}.ts`
|
|
219
|
-
- Validates the module exports a `handle(event)` method
|
|
220
|
-
- Caches the resolved module in `actionCache`
|
|
221
|
-
3. **Execution**: `processListeners()` iterates listeners sequentially (`for...of`), awaiting each action's `handle(event)` method
|
|
222
|
-
4. **Error handling**: Errors are caught per-listener via `handleError()` from `@stacksjs/error-handling` -- one listener failure does not prevent others
|
|
223
|
-
|
|
224
|
-
### Caching Details
|
|
225
228
|
```typescript
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const eventTypes = new Set(Object.keys(events)) // pre-computed at module load
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
- `actionCache`: stores resolved action modules permanently
|
|
232
|
-
- `pendingImports`: prevents double-importing when multiple events fire simultaneously for the same listener
|
|
233
|
-
- `eventTypes`: O(1) lookup to skip events with no registered listeners
|
|
229
|
+
import { registerAppListeners } from '@stacksjs/events'
|
|
230
|
+
import { path as p } from '@stacksjs/path'
|
|
234
231
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
```typescript
|
|
238
|
-
if (typeof listener === 'function') {
|
|
239
|
-
await listener(event)
|
|
240
|
-
continue
|
|
232
|
+
export async function handleEvents(): Promise<number> {
|
|
233
|
+
return registerAppListeners({ base: p.projectPath() })
|
|
241
234
|
}
|
|
242
235
|
```
|
|
243
236
|
|
|
237
|
+
Names in the map resolve against `app/Listeners/`, `app/Actions/`, then the same
|
|
238
|
+
two directories under the framework defaults - first match wins.
|
|
239
|
+
|
|
240
|
+
### Registration Flow
|
|
241
|
+
1. **The map**: `registerFromMap()` imports `app/Events.ts` and, for each name,
|
|
242
|
+
resolves a module with a `handle` method out of `app/Listeners/`,
|
|
243
|
+
`app/Actions/` or the framework defaults, then subscribes it to that event
|
|
244
|
+
directly. A name that resolves to nothing is warned about by name.
|
|
245
|
+
2. **The scan**: `discoverListeners()` walks `app/Listeners/` and registers any
|
|
246
|
+
default export shaped `{ listensTo, handle }`. `listensTo` may be an array.
|
|
247
|
+
3. **Dedup**: every `(event, module)` pair is claimed once per process, so a
|
|
248
|
+
listener that appears in both conventions - or a dev server that re-runs
|
|
249
|
+
boot - registers once rather than twice.
|
|
250
|
+
4. **Payload check**: if the resolved action declares `validations`, a dispatched
|
|
251
|
+
payload that does not match them is warned about. It is not thrown: the
|
|
252
|
+
dispatcher has already committed the thing the event announces.
|
|
253
|
+
|
|
254
|
+
### Where it is called from
|
|
255
|
+
- `injectGlobalAutoImports()` (`@stacksjs/server`), so every path that dispatches -
|
|
256
|
+
HTTP, `buddy seed`, a scheduled job, a console command - has listeners on the bus
|
|
257
|
+
- `handleEvents()` in `app/Listener.ts`, the app's own override hook
|
|
258
|
+
|
|
259
|
+
Both on the same boot in dev. The claim registry is what keeps that from being
|
|
260
|
+
two of every listener.
|
|
261
|
+
|
|
244
262
|
## Implementation Details
|
|
245
263
|
|
|
246
264
|
### Thread Safety
|
|
247
|
-
-
|
|
248
|
-
- Wildcard handler registration happens once in `handleEvents()` -- the single handler routes all events
|
|
265
|
+
- handlers are stored in arrays -- `emit()` calls `.slice()` before iterating to safely handle additions/removals during iteration
|
|
249
266
|
|
|
250
267
|
### Synchronous vs Asynchronous
|
|
251
|
-
-
|
|
252
|
-
-
|
|
253
|
-
-
|
|
268
|
+
- **`emit` is synchronous**: it calls handlers directly and does not await them; an async handler's rejection is logged rather than lost
|
|
269
|
+
- **`emitAsync` / `dispatchAsync` awaits** every matching handler and resolves with their results
|
|
270
|
+
- **Registration is asynchronous**: resolving a listener name imports a module
|
|
254
271
|
|
|
255
272
|
### Memory
|
|
256
|
-
- The emitter is
|
|
257
|
-
-
|
|
258
|
-
- The `pendingImports` Map entries are cleaned up in the `finally` block of each import
|
|
273
|
+
- The emitter is one per *process*, keyed on `Symbol.for('stacks.events.emitter')` rather than per copy of the package -- two installed copies would otherwise be two separate buses, and a dispatch into the wrong one looks exactly like a dispatch nobody listened for
|
|
274
|
+
- Resolved listener modules are held by the closures registered on the emitter, so adding an action requires a restart
|
|
259
275
|
|
|
260
276
|
## Gotchas
|
|
261
277
|
- Events are functional, not class-based -- no need to create event classes
|
|
262
278
|
- The emitter is a **singleton** -- shared across the entire application process
|
|
263
279
|
- Wildcard `'*'` listeners receive `(type, event)` -- regular handlers receive just `(event)`
|
|
264
|
-
- Listeners in `app/Events.ts` are **
|
|
265
|
-
- The
|
|
266
|
-
- Event dispatch
|
|
280
|
+
- Listeners in `app/Events.ts` are **names** (strings), not file paths or handler functions -- resolved against `app/Listeners/`, `app/Actions/`, then the framework defaults
|
|
281
|
+
- The listener module must export a default with a `handle(event)` method
|
|
282
|
+
- Event dispatch is **synchronous** but listener resolution (dynamic import) happens once, at boot
|
|
267
283
|
- Model events only fire when the model has `observe: true` (or array) trait set
|
|
268
284
|
- The event system is ~200 bytes total -- it is intentionally minimal
|
|
269
|
-
-
|
|
270
|
-
- If `evt` is `undefined`, handlers are NOT called (
|
|
285
|
+
- Listeners are resolved once at boot, so adding an action requires a server restart
|
|
286
|
+
- If `evt` is `undefined`, handlers are NOT called (`emit` checks `if (evt !== undefined)`)
|
|
271
287
|
- The `'*'` event type cannot be manually emitted -- it only receives forwarded events
|
|
272
288
|
- `off(type)` without a handler argument clears ALL handlers for that type (sets to empty array, not delete)
|
|
273
|
-
-
|
|
289
|
+
- `StacksEvents` has **no** index signature: an undeclared event name is a compile error, not a dispatch into the void. Declare your own on `AppEvents`
|
|
274
290
|
- Error logging in mitt uses `console.error` (not `@stacksjs/logging`) to avoid circular dependencies
|
|
@@ -82,6 +82,9 @@ await SendWelcomeEmail.dispatchNow({ email, name })
|
|
|
82
82
|
```typescript
|
|
83
83
|
import { job } from '@stacksjs/queue'
|
|
84
84
|
|
|
85
|
+
// Checked against `app/Jobs/` AND the framework defaults - the same three
|
|
86
|
+
// directories `resolveJobFile` searches, so the jobs Stacks ships are
|
|
87
|
+
// dispatchable and schedulable by name too.
|
|
85
88
|
await job('SendWelcomeEmail', { email, name })
|
|
86
89
|
.onQueue('emails')
|
|
87
90
|
.delay(60)
|
|
@@ -18,23 +18,30 @@ Application-level event listeners in `app/Listeners/`.
|
|
|
18
18
|
## Event → Listener Mapping (app/Events.ts)
|
|
19
19
|
|
|
20
20
|
```typescript
|
|
21
|
-
|
|
21
|
+
import { defineEvents } from '@stacksjs/events'
|
|
22
|
+
|
|
23
|
+
export default defineEvents({
|
|
22
24
|
'user:registered': ['SendWelcomeEmail'], // triggers app/Actions/SendWelcomeEmail.ts
|
|
23
25
|
'user:created': ['NotifyUser'], // triggers app/Actions/NotifyUser.ts
|
|
24
26
|
'order:created': ['ProcessPayment', 'SendOrderConfirmation'], // multiple listeners
|
|
25
|
-
}
|
|
27
|
+
})
|
|
26
28
|
```
|
|
27
29
|
|
|
28
|
-
Keys
|
|
30
|
+
Keys must be event names that exist; values must name listeners that exist. Both
|
|
31
|
+
are compile errors otherwise, which matters here more than most places: a name
|
|
32
|
+
that resolves to nothing produces one line in boot output and then behaves
|
|
33
|
+
exactly like an event nobody cared about.
|
|
29
34
|
|
|
30
35
|
## How Listeners Work
|
|
31
36
|
|
|
32
37
|
1. Events are dispatched: `dispatch('user:registered', { id: 1 })`
|
|
33
|
-
2.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
2. At boot, `registerAppListeners()` reads `app/Events.ts` and subscribes each
|
|
39
|
+
named listener to its event directly
|
|
40
|
+
3. Each name resolves against `app/Listeners/`, then `app/Actions/`, then the
|
|
41
|
+
same two under the framework defaults - first match wins
|
|
42
|
+
4. Modules under `app/Listeners/` that declare their own `listensTo` are
|
|
43
|
+
registered by the same call, from a directory scan
|
|
44
|
+
5. A listener that appears in both is registered once
|
|
38
45
|
|
|
39
46
|
## Creating a Listener Action
|
|
40
47
|
|
|
@@ -43,7 +50,7 @@ Keys are event names. Values are arrays of Action names from `app/Actions/`.
|
|
|
43
50
|
export default {
|
|
44
51
|
name: 'SendWelcomeEmail',
|
|
45
52
|
|
|
46
|
-
async handle(event: { id: number
|
|
53
|
+
async handle(event: { id: number, email: string, name: string }) {
|
|
47
54
|
// event contains the data passed to dispatch()
|
|
48
55
|
await sendWelcomeEmail({ to: event.email, name: event.name })
|
|
49
56
|
return { success: true }
|
|
@@ -51,6 +58,29 @@ export default {
|
|
|
51
58
|
}
|
|
52
59
|
```
|
|
53
60
|
|
|
61
|
+
## Creating a Standalone Listener
|
|
62
|
+
|
|
63
|
+
A module under `app/Listeners/` that declares its own `listensTo` is registered
|
|
64
|
+
by the boot scan, with no entry in `app/Events.ts`. Use `defineListener` so the
|
|
65
|
+
event name is checked and the payload is inferred from it:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
// app/Listeners/SendWelcomeEmail.ts
|
|
69
|
+
import { defineListener } from '@stacksjs/events'
|
|
70
|
+
|
|
71
|
+
export default defineListener({
|
|
72
|
+
listensTo: 'user:registered', // or ['user:created', 'user:updated']
|
|
73
|
+
handle: async (user, event) => { // `user` is typed from the event name
|
|
74
|
+
await sendWelcomeEmail({ to: user.email })
|
|
75
|
+
void event // which event fired, for multi-event listeners
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A glob (`'user:*'`, `'*'`) is a legal subscription and receives the union of
|
|
81
|
+
what the bus carries. A listener that appears here *and* in `app/Events.ts` is
|
|
82
|
+
registered once.
|
|
83
|
+
|
|
54
84
|
## CLI Event Listeners (app/Listeners/Console.ts)
|
|
55
85
|
|
|
56
86
|
For CLI-specific events (not HTTP):
|
|
@@ -83,16 +83,20 @@ throw new Response(JSON.stringify({ error: 'Rate limited' }), {
|
|
|
83
83
|
Maps short names to middleware class filenames:
|
|
84
84
|
|
|
85
85
|
```typescript
|
|
86
|
-
|
|
86
|
+
import { defineMiddleware } from '@stacksjs/router'
|
|
87
|
+
|
|
88
|
+
export default defineMiddleware({
|
|
87
89
|
'maintenance': 'Maintenance',
|
|
88
90
|
'auth': 'Auth',
|
|
89
91
|
'guest': 'Guest',
|
|
90
92
|
'api': 'Api',
|
|
91
93
|
'team': 'Team',
|
|
94
|
+
'site': 'Site',
|
|
92
95
|
'logger': 'Logger',
|
|
93
96
|
'abilities': 'Abilities',
|
|
94
97
|
'can': 'Can',
|
|
95
98
|
'throttle': 'Throttle',
|
|
99
|
+
'signed': 'Signed',
|
|
96
100
|
'env': 'Env',
|
|
97
101
|
'env:local': 'EnvLocal',
|
|
98
102
|
'env:development': 'EnvDevelopment',
|
|
@@ -103,9 +107,36 @@ export default {
|
|
|
103
107
|
'role': 'Role',
|
|
104
108
|
'permission': 'Permission',
|
|
105
109
|
'verified': 'EnsureEmailIsVerified',
|
|
106
|
-
}
|
|
110
|
+
})
|
|
107
111
|
```
|
|
108
112
|
|
|
113
|
+
The alias is yours to invent; the class name is checked against
|
|
114
|
+
`app/Middleware/` and the framework defaults, so `{ auth: 'Auht' }` is a
|
|
115
|
+
compile error rather than a route whose guard resolves to nothing.
|
|
116
|
+
|
|
117
|
+
This map is **merged over** the framework defaults, not a replacement for them,
|
|
118
|
+
so an alias Stacks adds later is available without editing the file.
|
|
119
|
+
|
|
120
|
+
## Reference forms
|
|
121
|
+
|
|
122
|
+
Three shapes are read off a reference, in this order:
|
|
123
|
+
|
|
124
|
+
| Written | Means |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `'auth'` | the alias, or a class name if no alias matches (`'signed'` → `Signed`) |
|
|
127
|
+
| `'!auth'` | inverted: the route passes only when `auth` refuses |
|
|
128
|
+
| `'throttle:60,1'` | `throttle` with `60,1` in `request._middlewareParams.throttle` |
|
|
129
|
+
|
|
130
|
+
The **whole** reference is looked up as an alias before the colon is treated as
|
|
131
|
+
a parameter separator. That is what makes `'env:production'` its own alias
|
|
132
|
+
rather than `env` with a parameter - `Env` ignores parameters and accepts every
|
|
133
|
+
known environment, so splitting first turned a production-only route into an
|
|
134
|
+
unguarded one.
|
|
135
|
+
|
|
136
|
+
Inversion counts a `Response` or a status-carrying error as a refusal, and
|
|
137
|
+
nothing else. A `TypeError` from a bug inside `Auth` is a crash, not a
|
|
138
|
+
declination, and must not let `!auth` through.
|
|
139
|
+
|
|
109
140
|
## Applying Middleware
|
|
110
141
|
|
|
111
142
|
### Per-Route (Chainable)
|
|
@@ -163,18 +194,24 @@ Parameters are stored on `request._middlewareParams[middlewareName]` and parsed
|
|
|
163
194
|
|
|
164
195
|
### Environment Negation Variants
|
|
165
196
|
|
|
166
|
-
|
|
197
|
+
`EnvNotLocal`, `EnvNotDevelopment`, `EnvNotStaging` and `EnvNotProduction` exist
|
|
198
|
+
as classes with no alias. Reference them by class name, or write the negated
|
|
199
|
+
form of the positive alias - `'!env:production'` is `EnvNotProduction`.
|
|
167
200
|
|
|
168
201
|
## Middleware Loading Flow
|
|
169
202
|
|
|
170
203
|
```
|
|
171
|
-
1.
|
|
172
|
-
2.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
204
|
+
1. Strip a leading '!', if any, and remember it
|
|
205
|
+
2. Look the WHOLE remainder up in the merged alias map
|
|
206
|
+
'env:production' is an alias → { name: 'env:production' }
|
|
207
|
+
'throttle:60,1' is not → { name: 'throttle', params: '60,1' }
|
|
208
|
+
3. Check middleware cache (loaded once, cached for performance)
|
|
209
|
+
4. Resolve alias: 'throttle' → 'Throttle'; unaliased names PascalCase
|
|
210
|
+
5. Try loading from app/Middleware/Throttle.ts (user overrides)
|
|
211
|
+
6. Fall back to storage/framework/defaults/app/Middleware/Throttle.ts
|
|
212
|
+
7. Store params on request: request._middlewareParams.throttle = '60,1'
|
|
213
|
+
8. Wrap in the inverter if step 1 saw a '!'
|
|
214
|
+
9. Sort the chain by priority, then execute: await middleware.handle(req)
|
|
178
215
|
```
|
|
179
216
|
|
|
180
217
|
User middleware in `app/Middleware/` always takes precedence over framework defaults.
|
|
@@ -239,10 +276,11 @@ import { authMiddleware, authMiddlewareHandler } from '@stacksjs/auth'
|
|
|
239
276
|
```
|
|
240
277
|
|
|
241
278
|
## Gotchas
|
|
242
|
-
- **Priority
|
|
279
|
+
- **Priority DOES order the chain** — entries are sorted by `priority` (lower first, default 10) before execution, so CORS can precede auth regardless of the order they were attached in. An earlier version of this file said otherwise. A non-finite or negative value is clamped to the default and warned about once
|
|
243
280
|
- **`terminate()` doesn't exist** — some docs reference it, but it's not in the actual `MiddlewareConfig` interface
|
|
244
281
|
- **Two auth middleware implementations** — defaults version (basic token check) and `@stacksjs/auth` version (full user loading)
|
|
245
|
-
- **EnvNot* files have no aliases** —
|
|
282
|
+
- **EnvNot* files have no aliases** — reachable by class name, or as `'!env:production'` and friends
|
|
283
|
+
- **The alias map merges over the defaults** — an app's `app/Middleware.ts` adds to and overrides them rather than replacing the set
|
|
246
284
|
- **Middleware is cached after first load** — changes require server restart
|
|
247
285
|
- **User overrides take precedence** — `app/Middleware/Auth.ts` replaces the framework default completely
|
|
248
286
|
- **Group middleware accumulates** — nested groups combine all parent middleware
|
|
@@ -20,6 +20,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
|
|
|
20
20
|
queue/src/
|
|
21
21
|
├── action.ts # Job class with dispatch/dispatchIf/dispatchAfter/dispatchNow
|
|
22
22
|
├── job.ts # JobBuilder fluent API + job() helper + jobBatch() + runJob()
|
|
23
|
+
│ # also `Jobs` (the JobName registry) and `resolveJobFile`
|
|
23
24
|
├── discovery.ts # Job auto-discovery from app/Jobs/ via Bun.Glob
|
|
24
25
|
├── scheduler.ts # Cron-based job scheduling with overlap prevention
|
|
25
26
|
├── worker.ts # Queue worker (database polling & Redis processing)
|
|
@@ -75,6 +76,10 @@ export class Job {
|
|
|
75
76
|
```typescript
|
|
76
77
|
import { job } from '@stacksjs/queue'
|
|
77
78
|
|
|
79
|
+
// The name is checked: `job('SendWelcomeEmial')` is a compile error, not a
|
|
80
|
+
// queue row no worker can resolve. `dispatch()` does not look the job up - only
|
|
81
|
+
// the worker does - so an unresolvable name used to enqueue successfully and
|
|
82
|
+
// fail later, out of sight of the caller.
|
|
78
83
|
await job('SendWelcomeEmail', { email, name })
|
|
79
84
|
.onQueue('emails')
|
|
80
85
|
.delay(60) // seconds
|
|
@@ -15,7 +15,8 @@ Built on `@stacksjs/bun-router` with `ts-rate-limiter`.
|
|
|
15
15
|
- Route files: `routes/` (api.ts, v1.ts, buddy.ts, users.ts)
|
|
16
16
|
- Route registry: `app/Routes.ts`
|
|
17
17
|
- Generated route manifest: `storage/framework/stx/routes.ts` (written by the dev server)
|
|
18
|
-
-
|
|
18
|
+
- Derived name types: `storage/framework/types/registries.d.ts`
|
|
19
|
+
- The maps they read: `storage/framework/auto-imports/{actions,listeners,policies,middleware,emails,routes}.ts`
|
|
19
20
|
|
|
20
21
|
## Route Definition
|
|
21
22
|
|
|
@@ -56,12 +57,20 @@ route.group({ prefix: '/api/v1', middleware: ['auth', 'throttle'] }, () => {
|
|
|
56
57
|
- Action object: an imported action, passed directly — see typed routes below
|
|
57
58
|
- Controller: `'Controllers/UserController@index'` — calls controller method
|
|
58
59
|
|
|
59
|
-
## The strings are typed
|
|
60
|
+
## The strings are typed
|
|
60
61
|
|
|
61
62
|
Action paths, middleware aliases and route names are all checked at compile
|
|
62
|
-
time against what this application actually has
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
time against what this application actually has, and nothing is maintained by
|
|
64
|
+
hand - or generated as a type.
|
|
65
|
+
|
|
66
|
+
Each of them is `keyof` over a map the RESOLVER reads:
|
|
67
|
+
`storage/framework/auto-imports/{actions,middleware,routes}.ts`, name to file,
|
|
68
|
+
written by `buddy generate` alongside the models and jobs barrels. So a name
|
|
69
|
+
that type-checks is a name that resolves; there is no second list to go stale.
|
|
70
|
+
`storage/framework/types/registries.d.ts` is where the derivation lives.
|
|
71
|
+
|
|
72
|
+
Middleware aliases need no map at all - `app/Middleware.ts` and the framework's
|
|
73
|
+
own are ordinary modules, and `defineMiddleware` keeps their literal keys.
|
|
65
74
|
|
|
66
75
|
```typescript
|
|
67
76
|
route.get('/login', 'Actions/Auth/LogniAction') // ✗ no such action
|
|
@@ -78,8 +87,9 @@ Notes:
|
|
|
78
87
|
member name, not a filename.
|
|
79
88
|
- Negated (`'!auth'`) and parameterised (`'throttle:60,1'`) middleware forms are
|
|
80
89
|
both accepted.
|
|
81
|
-
-
|
|
82
|
-
|
|
90
|
+
- The maps refresh on `buddy generate`, `buddy generate:types` and dev-server
|
|
91
|
+
boot, and the staleness check watches the directories they are built from. A
|
|
92
|
+
map written before a file was added rejects code that is correct.
|
|
83
93
|
- `resource()` takes a BASE, and composes `Actions/<Base><Kind>Action` from it.
|
|
84
94
|
`route.resource('posts', 'Post')` → `Actions/PostIndexAction`, matching where
|
|
85
95
|
`buddy make:crud` writes. The base is checked against the actions that exist;
|
|
@@ -47,7 +47,7 @@ The `Schedule` class is the core scheduling API. The lowercase `schedule` export
|
|
|
47
47
|
import { schedule } from '@stacksjs/scheduler'
|
|
48
48
|
|
|
49
49
|
// Static factory methods — each returns UntimedSchedule
|
|
50
|
-
schedule.job(name:
|
|
50
|
+
schedule.job(name: JobName): UntimedSchedule // Runs a job by name via runJob()
|
|
51
51
|
schedule.action(name: string): UntimedSchedule // Runs an action by name via runAction()
|
|
52
52
|
schedule.command(cmd: string): UntimedSchedule // Runs a shell command via runCommand()
|
|
53
53
|
|
|
@@ -21,7 +21,7 @@ export default new Action({
|
|
|
21
21
|
return {
|
|
22
22
|
records,
|
|
23
23
|
summary: summarizeCommerceCustomers(records),
|
|
24
|
-
currency: normalizeCommerceCustomerCurrency(
|
|
24
|
+
currency: normalizeCommerceCustomerCurrency(config.commerce?.currency),
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
catch (error) {
|