@stacksjs/defaults 0.75.71 → 0.75.73
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-ai/SKILL.md +38 -10
- package/ai/skills/stacks-buddy/SKILL.md +4 -4
- package/ai/skills/stacks-chat/SKILL.md +11 -5
- package/ai/skills/stacks-cloud/SKILL.md +10 -7
- package/ai/skills/stacks-email/SKILL.md +5 -3
- package/ai/skills/stacks-models/SKILL.md +4 -1
- package/ai/skills/stacks-push/SKILL.md +30 -5
- package/ai/skills/stacks-queue/SKILL.md +1 -1
- package/ai/skills/stacks-realtime/SKILL.md +61 -15
- package/ai/skills/stacks-scheduler/SKILL.md +6 -6
- package/app/Actions/Auth/RefreshTokenAction.ts +3 -4
- package/app/Actions/Dashboard/Buddy/BuddyChatAction.ts +4 -7
- package/app/Actions/Dashboard/Buddy/BuddyChatStateAction.ts +2 -4
- package/app/Actions/Dashboard/Buddy/buddy-chat.test.ts +23 -8
- package/app/Actions/Dashboard/Buddy/buddy-chat.ts +45 -19
- package/app/Actions/Dashboard/Settings/mail-settings.ts +19 -10
- package/app/Middleware/Auth.ts +9 -11
- package/app/Middleware/Cors.ts +50 -14
- package/app/Middleware/Csrf.ts +9 -8
- package/functions/buddy-chat.ts +7 -2
- package/ide/vscode/package.json +1 -1
- package/package.json +3 -3
- package/resources/components/Dashboard/Buddy/BuddyAssistant.stx +5 -3
- package/resources/components/Dashboard/Settings/MailSettingsDashboard.stx +8 -6
- package/resources/components/NativeAppShell.stx +6 -0
- package/resources/components/NativeComponents.test.ts +15 -0
- package/resources/components/NativeNavBar.stx +40 -6
- package/resources/components/NativeTabBar.stx +4 -1
- package/resources/components/NativeTabItem.stx +4 -3
- package/vcs/github/workflows/buddy-bot.yml +2 -2
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
|
@@ -21,7 +21,7 @@ ai/src/
|
|
|
21
21
|
│ ├── anthropic.ts # Claude driver
|
|
22
22
|
│ ├── openai.ts # GPT + DALL-E + Whisper + TTS
|
|
23
23
|
│ ├── ollama.ts # Local LLM driver
|
|
24
|
-
│ └── bedrock
|
|
24
|
+
│ └── bedrock/ # Amazon Bedrock, through the Converse API
|
|
25
25
|
├── image.ts # Image generation & vision
|
|
26
26
|
├── speech.ts # Provider-neutral text-to-speech & speech-to-text
|
|
27
27
|
├── search.ts # RAG, embeddings, vector index
|
|
@@ -30,7 +30,7 @@ ai/src/
|
|
|
30
30
|
├── buddy.ts # AI coding assistant
|
|
31
31
|
├── claude-agent.ts # Claude CLI agent (local & EC2)
|
|
32
32
|
├── claude-agent-sdk.ts # Claude Agent SDK driver
|
|
33
|
-
└── text.ts # Bedrock
|
|
33
|
+
└── text.ts # ask() / summarize(), on the Bedrock driver
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
## Anthropic Driver
|
|
@@ -38,7 +38,7 @@ ai/src/
|
|
|
38
38
|
```typescript
|
|
39
39
|
import { anthropic } from '@stacksjs/ai'
|
|
40
40
|
|
|
41
|
-
anthropic.configure({ apiKey: '...', model: 'claude-sonnet-
|
|
41
|
+
anthropic.configure({ apiKey: '...', model: 'claude-sonnet-5-5', maxTokens: 4096 })
|
|
42
42
|
const result = await anthropic.chat([{ role: 'user', content: 'Hello' }])
|
|
43
43
|
const stream = await anthropic.streamChat(messages, options)
|
|
44
44
|
const response = await anthropic.prompt('Summarize this text...')
|
|
@@ -59,10 +59,34 @@ const transcription = await openai.transcribe(audioFile)
|
|
|
59
59
|
const speech = await openai.textToSpeech('Hello world')
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
## Bedrock Driver
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { bedrock } from '@stacksjs/ai'
|
|
66
|
+
|
|
67
|
+
bedrock.configure({ model: 'global.anthropic.claude-sonnet-5-5', region: 'us-east-1' })
|
|
68
|
+
const result = await bedrock.chat([{ role: 'user', content: 'Hello' }], { system: 'Be brief.' })
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- Every request goes through Bedrock's Converse API, so one shape serves Nova,
|
|
72
|
+
Claude and Llama; the model is configuration. No streaming yet.
|
|
73
|
+
- Credentials come from the AWS credential chain, never config. Region:
|
|
74
|
+
`drivers.bedrock.region`, `AWS_REGION`, `AWS_DEFAULT_REGION`, us-east-1.
|
|
75
|
+
- Default model `amazon.nova-lite-v1:0`: on demand, no profile, no agreement.
|
|
76
|
+
- Claude and the newer Nova/Llama models are invoked by **inference profile**
|
|
77
|
+
id (`global.anthropic.claude-sonnet-5-5`, `us.meta.llama4-...`). The bare
|
|
78
|
+
`anthropic.claude-...` id is refused by Bedrock. `bedrockModels` in
|
|
79
|
+
`@stacksjs/types` lists the ids Bedrock serves, each in invocable form.
|
|
80
|
+
- `buddy ai:access` (or `requestModelAccess()`) makes the models in
|
|
81
|
+
`config/ai.ts` invocable and reports each one.
|
|
82
|
+
|
|
62
83
|
## Provider-Neutral Client
|
|
63
84
|
|
|
64
85
|
Use the config-driven client for application features that can run against
|
|
65
|
-
Anthropic, OpenAI, or
|
|
86
|
+
Anthropic, OpenAI, Ollama, or Bedrock. `default` is a provider name or a model
|
|
87
|
+
id that implies one: `claude-...` Anthropic, `gpt-...` OpenAI, any Bedrock
|
|
88
|
+
model or profile id (`amazon.nova-lite-v1:0`, `global.anthropic.claude-...`)
|
|
89
|
+
Bedrock. Configuration inspection is safe to return from a
|
|
66
90
|
status endpoint because it never includes credentials.
|
|
67
91
|
|
|
68
92
|
```typescript
|
|
@@ -216,7 +240,7 @@ const sentiment = await analyzeSentiment('I love this product!')
|
|
|
216
240
|
const classification = await classifyText('Fix the login bug', ['bug', 'feature', 'question'])
|
|
217
241
|
// { label: 'bug', confidence: 0.92, allLabels: [...] }
|
|
218
242
|
|
|
219
|
-
const summary = await summarize(longText, {
|
|
243
|
+
const summary = await summarize(longText, { maxTokenCount: 100 })
|
|
220
244
|
|
|
221
245
|
const profile = createProfile('user-123', ['tech', 'gaming'])
|
|
222
246
|
recordInteraction(profile, { type: 'view', itemId: 'article-1', weight: 1.0 })
|
|
@@ -229,7 +253,7 @@ const interests = await extractUserInterests(profile, items)
|
|
|
229
253
|
```typescript
|
|
230
254
|
import { processCommand, buddyProcessStreaming, buddyStreamSimple, getAvailableDrivers } from '@stacksjs/ai'
|
|
231
255
|
|
|
232
|
-
const drivers = getAvailableDrivers() // ['
|
|
256
|
+
const drivers = getAvailableDrivers() // ['claude-cli-local', 'claude-cli-ec2', 'claude', 'claude-sdk', 'openai', 'ollama', 'mock']
|
|
233
257
|
const context = await getRepoContext('/path/to/repo')
|
|
234
258
|
await processCommand('Add error handling to auth.ts', 'anthropic')
|
|
235
259
|
// Streaming
|
|
@@ -298,9 +322,13 @@ await driver.resumeSession(sessionId, 'Add validation')
|
|
|
298
322
|
|
|
299
323
|
```typescript
|
|
300
324
|
{
|
|
301
|
-
default: '
|
|
302
|
-
models: ['
|
|
303
|
-
deploy:
|
|
325
|
+
default: 'anthropic', // or 'openai', 'ollama', 'bedrock', a model id
|
|
326
|
+
models: ['amazon.nova-lite-v1:0'], // Bedrock models `buddy ai:access` enables
|
|
327
|
+
deploy: false,
|
|
328
|
+
drivers: {
|
|
329
|
+
anthropic: { model: 'claude-sonnet-5-5' },
|
|
330
|
+
bedrock: { model: 'global.anthropic.claude-sonnet-5-5' },
|
|
331
|
+
},
|
|
304
332
|
}
|
|
305
333
|
```
|
|
306
334
|
|
|
@@ -314,5 +342,5 @@ await driver.resumeSession(sessionId, 'Add validation')
|
|
|
314
342
|
- MCP supports stdio (subprocess), SSE, and HTTP transports
|
|
315
343
|
- The buddy assistant has git integration (commit, push, apply changes)
|
|
316
344
|
- Claude Agent SDK wraps the Claude Code CLI for agentic workflows
|
|
317
|
-
- Bedrock
|
|
345
|
+
- A Bedrock id as `default` selects the Bedrock driver; it never reaches api.anthropic.com
|
|
318
346
|
- Sentiment/classification use AI models — they're not rule-based
|
|
@@ -426,14 +426,14 @@ buddy cloud --diff # show infrastructure changes (local vs deployed
|
|
|
426
426
|
buddy cloud --invalidate-cache # invalidate CloudFront CDN cache
|
|
427
427
|
--paths [paths] # specific paths to invalidate (default: /*)
|
|
428
428
|
|
|
429
|
-
buddy cloud:add --jump-box #
|
|
429
|
+
buddy cloud:add --jump-box # check for the jump box infrastructure.jumpBox declares (buddy deploy creates it)
|
|
430
430
|
buddy cloud:remove # remove cloud infrastructure (aliases: cloud:destroy, cloud:rm, undeploy)
|
|
431
|
-
--jump-box #
|
|
431
|
+
--jump-box # check for the jump box, say how to drop it from the stack
|
|
432
432
|
--force # force deletion
|
|
433
433
|
--yes # skip confirmation
|
|
434
|
-
buddy cloud:optimize-cost #
|
|
434
|
+
buddy cloud:optimize-cost # check optional resources (jump-box) that can be dropped
|
|
435
435
|
buddy cloud:cleanup # clean up retained resources after stack deletion
|
|
436
|
-
# Cleans:
|
|
436
|
+
# Cleans: S3 buckets, Lambda functions, CloudWatch logs,
|
|
437
437
|
# Parameter Store, VPCs, Subnets, CDK remnants, IAM users
|
|
438
438
|
buddy cloud:invalidate-cache # invalidate CloudFront cache
|
|
439
439
|
--paths [paths] # paths to invalidate
|
|
@@ -43,7 +43,13 @@ await sendToDiscord(webhookUrl, content, options?)
|
|
|
43
43
|
await sendToTeams(webhookUrl, text)
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Settings come from `config/services.ts` by default, read on each send after the
|
|
47
|
+
app's config has loaded: `SLACK_WEBHOOK_URL` / `SLACK_BOT_TOKEN`,
|
|
48
|
+
`DISCORD_WEBHOOK_URL` / `DISCORD_BOT_TOKEN`, `TEAMS_WEBHOOK_URL`, and each
|
|
49
|
+
driver's `*_MAX_RETRIES` / `*_RETRY_TIMEOUT`. A webhook URL from config passes
|
|
50
|
+
the same HTTPS and host checks as one given to `configure*()`.
|
|
51
|
+
|
|
52
|
+
The configure functions override config, for an app that keeps these elsewhere:
|
|
47
53
|
```typescript
|
|
48
54
|
import { configureSlack, configureDiscord, configureTeams } from '@stacksjs/chat'
|
|
49
55
|
configureSlack({ webhookUrl: '...', botToken: '...' })
|
|
@@ -324,9 +330,9 @@ interface TeamsMessage {
|
|
|
324
330
|
### Teams Sending
|
|
325
331
|
|
|
326
332
|
- Uses `config.webhookUrl` or falls back to `message.to` as the webhook URL
|
|
327
|
-
- Validates URL
|
|
328
|
-
-
|
|
329
|
-
- Rich messages (with subject or template):
|
|
333
|
+
- Validates the URL as a Teams Workflows (Power Automate) webhook on https: `*.environment.api.powerplatform.com`, `*.api.powerautomate.com`, `flow.microsoft.com` or `*.logic.azure.com` (`.us` / `.cn` too). An Office 365 connector URL (`*.webhook.office.com`) is refused with a migration message - Microsoft retired those connectors in May 2026
|
|
334
|
+
- Every message is an Adaptive Card in `attachments`, which the Workflows webhook template requires; a plain message is a card holding its text
|
|
335
|
+
- Rich messages (with subject or template): the Adaptive Card v1.4 has:
|
|
330
336
|
- Title as `TextBlock` (size: Large, weight: Bolder)
|
|
331
337
|
- Content as `TextBlock` (wrap: true)
|
|
332
338
|
- Timestamp as `TextBlock` (size: Small, color: Dark)
|
|
@@ -436,7 +442,7 @@ All three drivers implement `sendWithRetry()`:
|
|
|
436
442
|
- Slack bot token mode uses `https://slack.com/api/chat.postMessage`, not a webhook
|
|
437
443
|
- Discord webhook returns `204` on success with no body -- this is handled as success
|
|
438
444
|
- Discord bot token mode uses `message.to` as the channel ID
|
|
439
|
-
- Teams
|
|
445
|
+
- Teams accepts only Workflows webhook URLs; the retired `webhook.office.com` connectors are refused
|
|
440
446
|
- Teams uses `message.to` as fallback webhook URL if `config.webhookUrl` is not set
|
|
441
447
|
- All drivers set `mrkdwn: true` or equivalent by default
|
|
442
448
|
- Block Kit blocks are only built when `message.template` is set (Slack) or when `message.subject`/`message.template` is set (Discord/Teams)
|
|
@@ -157,15 +157,15 @@ await purchaseDomain('example.com', { years: 1, privacy: true, autoRenew: true }
|
|
|
157
157
|
|
|
158
158
|
// Infrastructure queries
|
|
159
159
|
const sgId = await getSecurityGroupId('my-sg')
|
|
160
|
-
const jumpBoxId = await getJumpBoxInstanceId(
|
|
160
|
+
const jumpBoxId = await getJumpBoxInstanceId() // the deployed stack's JumpBoxInstanceId output
|
|
161
161
|
const deployed = await hasBeenDeployed()
|
|
162
162
|
const firstDeploy = await isFirstDeployment()
|
|
163
163
|
const failed = await isFailedState()
|
|
164
164
|
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
await
|
|
168
|
-
await
|
|
165
|
+
// The jump box is declared by infrastructure.jumpBox and created by `buddy deploy`;
|
|
166
|
+
// these report the stack's state and what to change, never create or terminate.
|
|
167
|
+
await addJumpBox()
|
|
168
|
+
await deleteJumpBox()
|
|
169
169
|
|
|
170
170
|
// Cleanup
|
|
171
171
|
await deleteStacksBuckets()
|
|
@@ -236,7 +236,7 @@ buddy server:setup # adopt and bootstrap an ssh host
|
|
|
236
236
|
buddy cloud --diff # show infrastructure changes (aws)
|
|
237
237
|
buddy cloud --ssh # SSH into cloud
|
|
238
238
|
buddy cloud --invalidate-cache # invalidate CDN
|
|
239
|
-
buddy cloud:add --jump-box #
|
|
239
|
+
buddy cloud:add --jump-box # check for the jump box (declare infrastructure.jumpBox + buddy deploy)
|
|
240
240
|
buddy cloud:remove --force # destroy cloud resources
|
|
241
241
|
buddy cloud:cleanup # clean retained resources
|
|
242
242
|
buddy cloud:optimize-cost # remove optional resources
|
|
@@ -292,7 +292,10 @@ Stack naming: `{slugified-app-name}-cloud`
|
|
|
292
292
|
- Server mode uses EC2 + ALB; serverless uses Lambda + API Gateway + CloudFront
|
|
293
293
|
- Origin failover only retries GET, HEAD and OPTIONS on a cache miss; it does not protect writes,
|
|
294
294
|
and the failover replica outlives `buddy cloud:remove`
|
|
295
|
-
- Jump boxes are optional, used for SSH access to private instances
|
|
295
|
+
- Jump boxes are optional, used for SSH access to private instances. They are stack resources:
|
|
296
|
+
`infrastructure.jumpBox` in config/cloud.ts plus `buddy deploy`, found by the stack's
|
|
297
|
+
`JumpBoxInstanceId` output. The stack is `stacksCloudName()` (`<project.name>-cloud`) in
|
|
298
|
+
`stacksCloudRegion()` (AWS_REGION, else the configured region)
|
|
296
299
|
- `cloud:remove` with `--force` skips confirmation and is destructive
|
|
297
300
|
- CDK toolkit stack is named `stacks-toolkit`
|
|
298
301
|
- Environment mapping: `local` → `development`, others preserved
|
|
@@ -164,9 +164,11 @@ await deleteEmail('chris', messageId)
|
|
|
164
164
|
- Configurable domain and endpoint
|
|
165
165
|
|
|
166
166
|
### Mailtrap Driver
|
|
167
|
-
-
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
- `MAILTRAP_INBOX_ID` picks the API: set, mail is captured in that sandbox
|
|
168
|
+
inbox (`sandbox.api.mailtrap.io/api/send/<inbox>`); unset, it is delivered
|
|
169
|
+
through the Sending API (`send.api.mailtrap.io/api/send`)
|
|
170
|
+
- `MAILTRAP_HOST` overrides the host for either; blank means Mailtrap's own
|
|
171
|
+
- A 4xx other than 429 is not retried
|
|
170
172
|
|
|
171
173
|
### SMTP Driver
|
|
172
174
|
- Raw TCP/TLS socket connection
|
|
@@ -296,7 +296,10 @@ buddy seed --include-defaults # framework built-ins too
|
|
|
296
296
|
|
|
297
297
|
A model with no `useSeeder` trait is never seeded. Auth and OAuth models are
|
|
298
298
|
skipped on a non-fresh database so re-seeding cannot invalidate live sessions -
|
|
299
|
-
pass `--allow-protected` to override.
|
|
299
|
+
pass `--allow-protected` to override. A `belongsTo` to `User`, `Team` or `Customer` is
|
|
300
|
+
filled only from accounts the same run seeded (never a pre-existing, possibly real one);
|
|
301
|
+
`--attach-accounts` lifts that on a scratch database. Composite `unique: true` indexes are
|
|
302
|
+
honoured: fewer rows are seeded rather than a duplicate combination.
|
|
300
303
|
|
|
301
304
|
## Built-in models by category
|
|
302
305
|
|
|
@@ -21,7 +21,7 @@ no longer a way to configure this driver and there is no fallback to it.
|
|
|
21
21
|
## Source Files
|
|
22
22
|
```
|
|
23
23
|
push/src/
|
|
24
|
-
├── index.ts # send(), configureFCM(), PushNotification type
|
|
24
|
+
├── index.ts # send(), configureFCM(), configureExpo(), PushNotification type
|
|
25
25
|
└── drivers/
|
|
26
26
|
├── index.ts # re-exports expo and fcm
|
|
27
27
|
├── expo.ts # Expo Push Service driver
|
|
@@ -204,6 +204,8 @@ interface FCMMessage {
|
|
|
204
204
|
function configure(options: FCMConfig): void
|
|
205
205
|
```
|
|
206
206
|
Sets module-level config. Called by `configureFCM()` from the main index.
|
|
207
|
+
Optional: anything it does not set is read from `config/services.ts` on each
|
|
208
|
+
send (see Configuration), and anything it does set wins.
|
|
207
209
|
|
|
208
210
|
#### send()
|
|
209
211
|
```typescript
|
|
@@ -273,10 +275,30 @@ unsubscribing; that asymmetry is the API's.
|
|
|
273
275
|
|
|
274
276
|
## Configuration
|
|
275
277
|
|
|
278
|
+
Set the env vars `config/services.ts` reads and both drivers pick them up on the
|
|
279
|
+
next send. No code is needed:
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
FCM_PROJECT_ID=my-firebase-project
|
|
283
|
+
FCM_CLIENT_EMAIL=firebase@project.iam.gserviceaccount.com
|
|
284
|
+
# Paste the key from the service account JSON as-is: its literal \n escapes
|
|
285
|
+
# are turned back into newlines.
|
|
286
|
+
FCM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
|
|
287
|
+
|
|
288
|
+
# Only needed once "enhanced push security" is on for the Expo project:
|
|
289
|
+
# Expo then rejects every send and receipt request without it.
|
|
290
|
+
EXPO_ACCESS_TOKEN=...
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
The values are read lazily, after the app's config has loaded, never at
|
|
294
|
+
import - a value captured at import would be the framework default.
|
|
295
|
+
|
|
296
|
+
`configureFCM()` / `configureExpo()` override config, for an app that keeps
|
|
297
|
+
credentials somewhere else:
|
|
298
|
+
|
|
276
299
|
```typescript
|
|
277
|
-
import { configureFCM } from '@stacksjs/push'
|
|
300
|
+
import { configureExpo, configureFCM } from '@stacksjs/push'
|
|
278
301
|
|
|
279
|
-
// FCM v1 API: the only FCM configuration there is
|
|
280
302
|
configureFCM({
|
|
281
303
|
projectId: 'my-firebase-project',
|
|
282
304
|
serviceAccount: {
|
|
@@ -285,10 +307,13 @@ configureFCM({
|
|
|
285
307
|
}
|
|
286
308
|
})
|
|
287
309
|
|
|
288
|
-
|
|
289
|
-
// Expo requires no configuration
|
|
310
|
+
configureExpo({ accessToken: '...' })
|
|
290
311
|
```
|
|
291
312
|
|
|
313
|
+
FCM access tokens are cached per service account until a minute before they
|
|
314
|
+
expire, so a multicast exchanges one token, not one per device.
|
|
315
|
+
`fcm.resetConfiguration()` drops both the configured credentials and the cache.
|
|
316
|
+
|
|
292
317
|
## Usage Examples
|
|
293
318
|
|
|
294
319
|
```typescript
|
|
@@ -101,7 +101,7 @@ The `job()` helper loads job modules from `app/Jobs/{name}.ts` via `runJob()`.
|
|
|
101
101
|
|
|
102
102
|
### runJob(name, options)
|
|
103
103
|
Dynamically imports `app/Jobs/{name}.ts` and executes:
|
|
104
|
-
1. `jobConfig.handle(payload)` if handle is a function
|
|
104
|
+
1. `jobConfig.handle(payload, context)` if handle is a function (`context` is what `.withContext()` attached, on every driver)
|
|
105
105
|
2. `runAction(jobConfig.action)` if action is a string
|
|
106
106
|
3. `jobConfig.action()` if action is a function
|
|
107
107
|
4. `jobConfig(payload, context)` if default export is a function
|
|
@@ -22,7 +22,8 @@ realtime/src/
|
|
|
22
22
|
emit.ts # emit(), emitToUser(), emitToUsers()
|
|
23
23
|
channel.ts # Channel class + channel() factory
|
|
24
24
|
broadcast.ts # Broadcast class + runBroadcast() + broadcast()
|
|
25
|
-
ws.ts #
|
|
25
|
+
ws.ts # setWsAuthenticator() - gates upgrades on the broadcast server
|
|
26
|
+
replay-buffer.ts # setReplayBuffer() / replaySince() - per-channel replay with seq numbers
|
|
26
27
|
server-instance.ts # server lifecycle (createServer/getServer/setServer/stopServer)
|
|
27
28
|
```
|
|
28
29
|
|
|
@@ -43,7 +44,9 @@ export type { EmitOptions } from './emit'
|
|
|
43
44
|
export { channel as createChannel, Channel as StacksChannel } from './channel'
|
|
44
45
|
export { broadcast as dispatchBroadcast, runBroadcast, Broadcast as LegacyBroadcast } from './broadcast'
|
|
45
46
|
export type { BroadcastInstance } from './broadcast'
|
|
46
|
-
export {
|
|
47
|
+
export { debugSnapshot, getReplayBuffer, pruneExpired, recordBroadcast, replaySince, setReplayBuffer } from './replay-buffer'
|
|
48
|
+
export { storeWebSocketEvent, setWsAuthenticator, getWsAuthenticator } from './ws'
|
|
49
|
+
export type { WsAuthenticator, WsAuthResult } from './ws'
|
|
47
50
|
```
|
|
48
51
|
|
|
49
52
|
Note the renamed exports:
|
|
@@ -72,9 +75,9 @@ emit('orders', 'updated', { status: 'shipped' }, { private: true })
|
|
|
72
75
|
// Broadcast to a presence channel (auto-prefixes 'presence-')
|
|
73
76
|
emit('room-1', 'user-joined', { userId: 42 }, { presence: true })
|
|
74
77
|
|
|
75
|
-
// Exclude specific socket(s) from receiving
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
+
// Exclude specific socket(s) from receiving - socket IDs (the `socket_id`
|
|
79
|
+
// a client gets in `connection_established`), not user IDs. Every ID in an
|
|
80
|
+
// array is excluded.
|
|
78
81
|
emit('chat', 'typing', data, { exclude: 'socket-id-1' })
|
|
79
82
|
emit('chat', 'typing', data, { exclude: ['socket-id-1', 'socket-id-2'] })
|
|
80
83
|
```
|
|
@@ -85,7 +88,7 @@ emit('chat', 'typing', data, { exclude: ['socket-id-1', 'socket-id-2'] })
|
|
|
85
88
|
interface EmitOptions {
|
|
86
89
|
private?: boolean // prefix channel with 'private-'
|
|
87
90
|
presence?: boolean // prefix channel with 'presence-'
|
|
88
|
-
exclude?: string | string[] // socket IDs to exclude (
|
|
91
|
+
exclude?: string | string[] // socket IDs to exclude (all of them)
|
|
89
92
|
driver?: string // broadcast driver override
|
|
90
93
|
}
|
|
91
94
|
```
|
|
@@ -185,7 +188,53 @@ setServer(server)
|
|
|
185
188
|
await stopServer()
|
|
186
189
|
```
|
|
187
190
|
|
|
188
|
-
`createServer()` dynamically imports `ts-broadcasting`, instantiates `BroadcastServer`, calls `start()`, stores the instance via `setServer()`, and returns it.
|
|
191
|
+
`createServer()` dynamically imports `ts-broadcasting`, instantiates `BroadcastServer`, calls `start()`, stores the instance via `setServer()`, and returns it. That server is the WebSocket endpoint (`/app` and `/ws` on its own host/port); there is no upgrade handler for the app's HTTP server.
|
|
192
|
+
|
|
193
|
+
### Connection auth
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
import { setWsAuthenticator } from '@stacksjs/realtime'
|
|
197
|
+
|
|
198
|
+
// Read on every upgrade, so it applies whether installed before or after createServer().
|
|
199
|
+
setWsAuthenticator(async (req) => {
|
|
200
|
+
const token = new URL(req.url).searchParams.get('token')
|
|
201
|
+
const user = token ? await verify(token) : null
|
|
202
|
+
if (!user) return { ok: false, status: 401, message: 'Unauthorized' }
|
|
203
|
+
// user -> socket.data.user, data -> socket.data.data (what channel authorizers read)
|
|
204
|
+
return { ok: true, user: { id: user.id }, data: { team: user.team } }
|
|
205
|
+
})
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
A refusal answers the upgrade with its status; a throw refuses with a 500 and is logged, not sent. An `authorizeConnection` in the `createServer()` config still runs after it. Per-channel access is still `Broadcast.channel('private-orders.{id}', (socket, params) => ...)`.
|
|
209
|
+
|
|
210
|
+
`/stats` and `/metrics` are off unless `createServer({ endpoints: { stats: true, metrics: true, token } })`.
|
|
211
|
+
|
|
212
|
+
### Dead sockets and slow consumers
|
|
213
|
+
|
|
214
|
+
Bun's own WebSocket options, passed through `createServer()`:
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
await createServer({
|
|
218
|
+
host: '0.0.0.0',
|
|
219
|
+
port: 6001,
|
|
220
|
+
websocket: {
|
|
221
|
+
idleTimeout: 60, // Bun pings idle sockets; no pong within this -> closed (rounded up to 4s steps)
|
|
222
|
+
sendPings: true,
|
|
223
|
+
backpressureLimit: 1024 * 1024,
|
|
224
|
+
closeOnBackpressureLimit: true, // drop a consumer that falls 1 MB behind
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Replay buffer
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
import { replaySince, setReplayBuffer } from '@stacksjs/realtime'
|
|
233
|
+
|
|
234
|
+
setReplayBuffer({ channels: ['orders.*'], maxPerChannel: 100, ttlMs: 5 * 60_000 })
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Every broadcast on a matching channel, from any API, is recorded and reaches clients as `{ event, channel, data, seq }`. Patterns match with or without the `private-`/`presence-` prefix (`orders.*` covers `private-orders.1`); `replaySince()` takes the full name. The client keeps the last `seq` per channel and sends it back after reconnecting (through a route of the app's), which answers with `replaySince('private-orders.1', seq)`. The buffer and its numbering are per process.
|
|
189
238
|
|
|
190
239
|
---
|
|
191
240
|
|
|
@@ -270,13 +319,10 @@ class Broadcast {
|
|
|
270
319
|
}
|
|
271
320
|
```
|
|
272
321
|
|
|
273
|
-
###
|
|
322
|
+
### ws.ts
|
|
274
323
|
|
|
275
324
|
```typescript
|
|
276
|
-
|
|
277
|
-
setBunSocket(server: BroadcastServer | null): void // calls setServer()
|
|
278
|
-
storeWebSocketEvent(type, socket, details): Promise<void> // no-op
|
|
279
|
-
handleWebSocketRequest(req, server): Promise<Response | undefined> // delegates to server.upgrade()
|
|
325
|
+
storeWebSocketEvent(type, socket, details): Promise<void> // no-op, kept for compatibility
|
|
280
326
|
```
|
|
281
327
|
|
|
282
328
|
---
|
|
@@ -430,11 +476,11 @@ export default {
|
|
|
430
476
|
- Private channels auto-prefix `private-` -- don't add it yourself (the code checks `channel.startsWith('private-')`)
|
|
431
477
|
- Presence channels auto-prefix `presence-` -- same logic applies
|
|
432
478
|
- `emitToUser()` sends to `private-user.{userId}` -- users must subscribe to this channel pattern
|
|
433
|
-
-
|
|
479
|
+
- `emit()`'s `exclude` takes socket IDs, not user IDs; every ID in an array is excluded
|
|
434
480
|
- Broadcast files are dynamically imported from `app/Broadcasts/` using `bun.globSync`
|
|
435
481
|
- The `Broadcast` class is legacy -- new code should use `emit()` and `channel()` directly
|
|
436
|
-
- `handleWebSocketRequest()
|
|
437
|
-
-
|
|
482
|
+
- There is no `handleWebSocketRequest()`: sockets upgraded on the app's own server were never part of the broadcast server, so they could not join channels. Clients connect to the broadcast server's `/ws`
|
|
483
|
+
- There is no Stacks heartbeat or backpressure guard; use `createServer({ websocket: { idleTimeout, sendPings, backpressureLimit, closeOnBackpressureLimit } })`
|
|
438
484
|
- Serverless mode uses API Gateway WebSocket for AWS Lambda with DynamoDB for connection management
|
|
439
485
|
- Rate limiting defaults: 100 connections per IP, 50 messages/second, 64KB max payload, 300s ban duration
|
|
440
486
|
- Redis adapter enables horizontal scaling across multiple server instances
|
|
@@ -89,11 +89,11 @@ Each sets an internal cron pattern. Once a timing method is called, the schedule
|
|
|
89
89
|
setTimeZone(timezone: Timezone): this
|
|
90
90
|
withErrorHandler(handler: CatchCallbackFn): this
|
|
91
91
|
withMaxRuns(runs: number): this
|
|
92
|
-
withProtection(callback?: (job: ScheduledJob) => void): this
|
|
92
|
+
withProtection(callback?: (job: ScheduledJob) => void): this // skip a tick while the last run is still going (this process); callback hears each skip
|
|
93
93
|
withName(name: string): this
|
|
94
|
-
withContext(context: any): this
|
|
95
|
-
withInterval(seconds: number): this
|
|
96
|
-
between(startAt: string | Date, stopAt: string | Date): this
|
|
94
|
+
withContext(context: any): this // passed to the task on each run: the callback's argument, or the job's `context`
|
|
95
|
+
withInterval(seconds: number): this // at least this many seconds between runs; sooner ticks are skipped
|
|
96
|
+
between(startAt: string | Date, stopAt: string | Date): this // only run inside the window; stops for good once it closes
|
|
97
97
|
withoutOverlapping(expiresAfterMinutes?: number): this
|
|
98
98
|
onOneServer(): this
|
|
99
99
|
runInBackground(): this
|
|
@@ -132,7 +132,7 @@ When `withoutOverlapping()` or `onOneServer()` is called:
|
|
|
132
132
|
|
|
133
133
|
### Background Execution
|
|
134
134
|
|
|
135
|
-
When `runInBackground()` is called,
|
|
135
|
+
When `runInBackground()` is called, each run starts `buddy schedule:run-one <name> --in-process` as a detached child in the project directory. That process registers the app's schedule the way `schedule:run` does and runs the one task there, taking any `withoutOverlapping()` / `onOneServer()` lock itself. The child is `unref()`'d so it does not keep the parent alive, and a non-zero exit is logged under the task's name. The task needs a name (`schedule.job/action/command` have one; a callback needs `.withName()`), or registration fails.
|
|
136
136
|
|
|
137
137
|
## Helper Functions
|
|
138
138
|
|
|
@@ -351,7 +351,7 @@ export default {
|
|
|
351
351
|
- The `Queue` class in `schedule.ts` is just an empty subclass of `Schedule` (`export class Queue extends Schedule {}`) -- it adds no functionality
|
|
352
352
|
- `withoutOverlapping()` uses file-based locks in `storage/framework/locks/` -- this only prevents overlap within a single machine, not across a cluster
|
|
353
353
|
- `onOneServer()` also uses file-based locks (same as `withoutOverlapping`), so it does not actually coordinate across multiple servers
|
|
354
|
-
- `runInBackground()`
|
|
354
|
+
- `runInBackground()` runs the task by name in a `buddy schedule:run-one <name> --in-process` child, so the task must be registered under that name by the app's scheduler file, and an unnamed callback is refused
|
|
355
355
|
- There are TWO scheduler systems: `@stacksjs/scheduler` (fluent API in `schedule.ts`) and the queue-level scheduler in `@stacksjs/queue` (`queue/src/scheduler.ts`). The former runs tasks in-process; the latter dispatches to the queue
|
|
356
356
|
- `sendAt()` throws on invalid cron expressions (it delegates to `parse()` which throws)
|
|
357
357
|
- `timeout()` returns `-1` (not `0` or `Infinity`) when there is no upcoming run
|
|
@@ -20,10 +20,9 @@ export default new Action({
|
|
|
20
20
|
})
|
|
21
21
|
|
|
22
22
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
})
|
|
23
|
+
// The lifetimes config/auth.ts sets (tokenExpiry, refreshTokenExpiry).
|
|
24
|
+
// Hard-coding an hour and 30 days here overrode them on every refresh.
|
|
25
|
+
const result = await refreshToken(refreshTokenValue)
|
|
27
26
|
|
|
28
27
|
// Rotation invalidates the token the cookie was carrying, so a cookie
|
|
29
28
|
// left untouched here would go stale at the exact moment the session was
|
|
@@ -5,7 +5,7 @@ import { buddyState, createAIClient } from '@stacksjs/ai'
|
|
|
5
5
|
import { config } from '@stacksjs/config'
|
|
6
6
|
import { response } from '@stacksjs/router'
|
|
7
7
|
import { dashboardOperationalError } from '../dashboard-response'
|
|
8
|
-
import {
|
|
8
|
+
import { buddyProviderStatus, buddySystemPrompt, publicBuddyHistory } from './buddy-chat'
|
|
9
9
|
|
|
10
10
|
export default new Action({
|
|
11
11
|
name: 'BuddyChatAction',
|
|
@@ -21,12 +21,9 @@ export default new Action({
|
|
|
21
21
|
return response.json({ message: 'Question must be 4,000 characters or fewer.' }, 422)
|
|
22
22
|
|
|
23
23
|
const aiConfig = (config.ai || {}) as ConfiguredAIOptions
|
|
24
|
-
const provider =
|
|
25
|
-
if (!
|
|
26
|
-
return response.json({
|
|
27
|
-
message: `${provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY'} is not configured.`,
|
|
28
|
-
}, 503)
|
|
29
|
-
}
|
|
24
|
+
const { provider, configured, problem } = buddyProviderStatus(aiConfig)
|
|
25
|
+
if (!provider || !configured)
|
|
26
|
+
return response.json({ message: problem }, 503)
|
|
30
27
|
|
|
31
28
|
try {
|
|
32
29
|
const history = publicBuddyHistory(buddyState.getState().conversationHistory, 20)
|
|
@@ -2,7 +2,7 @@ import type { ConfiguredAIOptions } from '@stacksjs/ai'
|
|
|
2
2
|
import { Action } from '@stacksjs/actions/runtime'
|
|
3
3
|
import { buddyState } from '@stacksjs/ai'
|
|
4
4
|
import { config } from '@stacksjs/config'
|
|
5
|
-
import {
|
|
5
|
+
import { buddyProviderStatus, publicBuddyHistory } from './buddy-chat'
|
|
6
6
|
|
|
7
7
|
export default new Action({
|
|
8
8
|
name: 'BuddyChatStateAction',
|
|
@@ -12,12 +12,10 @@ export default new Action({
|
|
|
12
12
|
|
|
13
13
|
async handle() {
|
|
14
14
|
const aiConfig = (config.ai || {}) as ConfiguredAIOptions
|
|
15
|
-
const provider = resolveBuddyProvider(aiConfig)
|
|
16
15
|
const state = buddyState.getState()
|
|
17
16
|
|
|
18
17
|
return {
|
|
19
|
-
|
|
20
|
-
configured: isBuddyProviderConfigured(provider, aiConfig, process.env),
|
|
18
|
+
...buddyProviderStatus(aiConfig),
|
|
21
19
|
history: publicBuddyHistory(state.conversationHistory),
|
|
22
20
|
repository: state.repo
|
|
23
21
|
? {
|
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import { buddyProviderStatus, buddySystemPrompt, publicBuddyHistory } from './buddy-chat'
|
|
3
3
|
|
|
4
4
|
describe('dashboard Buddy chat', () => {
|
|
5
|
-
test('
|
|
6
|
-
expect(
|
|
7
|
-
expect(
|
|
8
|
-
expect(
|
|
5
|
+
test('resolves the provider the way createAIClient() does', () => {
|
|
6
|
+
expect(buddyProviderStatus({ default: 'claude-sonnet-5-5' }, { ANTHROPIC_API_KEY: 'k' }).provider).toBe('anthropic')
|
|
7
|
+
expect(buddyProviderStatus({ default: 'ollama' }, {}).provider).toBe('ollama')
|
|
8
|
+
expect(buddyProviderStatus({ default: 'gpt-5' }, { OPENAI_API_KEY: 'k' }).provider).toBe('openai')
|
|
9
|
+
expect(buddyProviderStatus({}, { OPENAI_API_KEY: 'k' }).provider).toBe('openai')
|
|
10
|
+
// A Bedrock id is Bedrock, not the first-party Anthropic API, and not
|
|
11
|
+
// OpenAI, which is where Buddy's own copy of this sent it.
|
|
12
|
+
expect(buddyProviderStatus({ default: 'global.anthropic.claude-sonnet-5-5' }, {}).provider).toBe('bedrock')
|
|
13
|
+
expect(buddyProviderStatus({ default: 'bedrock' }, {})).toEqual({ provider: 'bedrock', configured: true, problem: null, missingKey: null })
|
|
9
14
|
})
|
|
10
15
|
|
|
11
16
|
test('reports provider readiness without exposing keys', () => {
|
|
12
|
-
expect(
|
|
13
|
-
expect(
|
|
14
|
-
|
|
17
|
+
expect(buddyProviderStatus({ default: 'openai' }, { OPENAI_API_KEY: 'configured' }).configured).toBe(true)
|
|
18
|
+
expect(buddyProviderStatus({ default: 'anthropic' }, {}))
|
|
19
|
+
.toEqual({ provider: 'anthropic', configured: false, problem: 'ANTHROPIC_API_KEY is not configured.', missingKey: 'ANTHROPIC_API_KEY' })
|
|
20
|
+
expect(buddyProviderStatus({ default: 'ollama' }, {}).configured).toBe(true)
|
|
21
|
+
// A provider reached through a base URL needs no key.
|
|
22
|
+
expect(buddyProviderStatus({ default: 'openai', drivers: { openai: { baseUrl: 'http://localhost:1234/v1' } } }, {}).configured).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('names an unsupported default instead of answering with OpenAI', () => {
|
|
26
|
+
const status = buddyProviderStatus({ default: 'mistral-large' }, { OPENAI_API_KEY: 'k' })
|
|
27
|
+
expect(status.provider).toBeNull()
|
|
28
|
+
expect(status.configured).toBe(false)
|
|
29
|
+
expect(status.problem).toContain('Unsupported configured AI driver: mistral-large')
|
|
15
30
|
})
|
|
16
31
|
|
|
17
32
|
test('only returns public text history', () => {
|