@pikku/skills 0.12.2 → 0.12.6

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.
Files changed (68) hide show
  1. package/dist/skills.gen.js +1 -1
  2. package/package.json +1 -1
  3. package/skills/pikku-addon/SKILL.md +56 -29
  4. package/skills/pikku-ai-agent/SKILL.md +197 -105
  5. package/skills/pikku-ai-vercel/SKILL.md +57 -18
  6. package/skills/pikku-ai-voice/SKILL.md +126 -52
  7. package/skills/pikku-audit/SKILL.md +35 -13
  8. package/skills/pikku-aws/SKILL.md +66 -16
  9. package/skills/pikku-backblaze/SKILL.md +44 -11
  10. package/skills/pikku-better-auth/SKILL.md +80 -34
  11. package/skills/pikku-cli/SKILL.md +67 -18
  12. package/skills/pikku-cli/references/complete-example.md +2 -0
  13. package/skills/pikku-concepts/SKILL.md +82 -10
  14. package/skills/pikku-concepts/references/concept-mapping.md +2 -2
  15. package/skills/pikku-config/SKILL.md +134 -52
  16. package/skills/pikku-cron/SKILL.md +13 -6
  17. package/skills/pikku-deploy-azure/SKILL.md +83 -28
  18. package/skills/pikku-deploy-cloudflare/SKILL.md +79 -37
  19. package/skills/pikku-deploy-express/SKILL.md +40 -4
  20. package/skills/pikku-deploy-fastify/SKILL.md +22 -1
  21. package/skills/pikku-deploy-lambda/SKILL.md +99 -19
  22. package/skills/pikku-deploy-nextjs/SKILL.md +49 -5
  23. package/skills/pikku-deploy-uws/SKILL.md +54 -1
  24. package/skills/pikku-deps/SKILL.md +29 -8
  25. package/skills/pikku-emails/SKILL.md +36 -5
  26. package/skills/pikku-fabric/SKILL.md +30 -5
  27. package/skills/pikku-fabric-debug/SKILL.md +5 -1
  28. package/skills/pikku-feature/SKILL.md +12 -7
  29. package/skills/pikku-gateway-slack/SKILL.md +72 -11
  30. package/skills/pikku-http/SKILL.md +18 -5
  31. package/skills/pikku-http/references/http-options.md +10 -5
  32. package/skills/pikku-i18n/SKILL.md +18 -7
  33. package/skills/pikku-info/SKILL.md +18 -8
  34. package/skills/pikku-jose/SKILL.md +35 -6
  35. package/skills/pikku-knowledge/SKILL.md +3 -3
  36. package/skills/pikku-kysely/SKILL.md +78 -15
  37. package/skills/pikku-machine-auth/SKILL.md +36 -1
  38. package/skills/pikku-mcp/SKILL.md +159 -149
  39. package/skills/pikku-middleware/SKILL.md +17 -5
  40. package/skills/pikku-mongodb/SKILL.md +10 -2
  41. package/skills/pikku-n8n-import/SKILL.md +14 -6
  42. package/skills/pikku-permissions/SKILL.md +102 -22
  43. package/skills/pikku-pino/SKILL.md +12 -4
  44. package/skills/pikku-product-second-opinion/SKILL.md +3 -3
  45. package/skills/pikku-queue/SKILL.md +45 -16
  46. package/skills/pikku-react/SKILL.md +41 -14
  47. package/skills/pikku-react-query/SKILL.md +14 -10
  48. package/skills/pikku-realtime/SKILL.md +44 -22
  49. package/skills/pikku-redis/SKILL.md +12 -3
  50. package/skills/pikku-rpc/SKILL.md +23 -12
  51. package/skills/pikku-rtl/SKILL.md +21 -17
  52. package/skills/pikku-scenario/SKILL.md +285 -50
  53. package/skills/pikku-schedule/SKILL.md +39 -6
  54. package/skills/pikku-schema-ajv/SKILL.md +24 -2
  55. package/skills/pikku-schema-cfworker/SKILL.md +22 -2
  56. package/skills/pikku-security/SKILL.md +54 -9
  57. package/skills/pikku-services/SKILL.md +49 -9
  58. package/skills/pikku-services/references/audit-wire-service.md +2 -1
  59. package/skills/pikku-software-archaeology/README.md +16 -6
  60. package/skills/pikku-software-archaeology/references/pikku-mapping.md +30 -30
  61. package/skills/pikku-template-clone/SKILL.md +10 -5
  62. package/skills/pikku-trigger/SKILL.md +50 -6
  63. package/skills/pikku-versioning/SKILL.md +46 -17
  64. package/skills/pikku-websocket/SKILL.md +72 -44
  65. package/skills/pikku-workflow/SKILL.md +35 -1
  66. package/skills/pikku-workflow/references/workflow-reference.md +13 -8
  67. package/skills/pikku-workflows-client/SKILL.md +13 -6
  68. package/skills/pikku-ws/SKILL.md +44 -8
@@ -2,11 +2,12 @@
2
2
  name: pikku-config
3
3
  description: >-
4
4
  Use when managing secrets, environment variables, config, or OAuth2 credentials in a Pikku app.
5
- Covers wireSecret, wireVariable, wireOAuth2Credential, and typed config access. TRIGGER when:
6
- code uses wireSecret/wireVariable/wireOAuth2Credential, user asks about env vars, secrets,
7
- config, OAuth2, or "how do I access environment variables". DO NOT TRIGGER when: user asks about
8
- API versioning/breaking changes (use pikku-versioning), service factories (use pikku-services),
9
- or auth middleware (use pikku-security).
5
+ Covers defineSecret, defineVariable, defineCredential, and typed config access. TRIGGER when:
6
+ code uses defineSecret/defineVariable/defineCredential, user asks about env vars, secrets,
7
+ config, OAuth2, SecretValue/.reveal(), SecretCoercionError, or "how do I access environment
8
+ variables". DO NOT TRIGGER when: user asks about API versioning/breaking changes (use
9
+ pikku-versioning), service factories (use pikku-services), middleware (use pikku-middleware), or
10
+ auth strategies and sessions (use pikku-security).
10
11
  installGroups: [core]
11
12
  ---
12
13
 
@@ -35,34 +36,60 @@ See `pikku-concepts` for the core mental model.
35
36
 
36
37
  ## Secrets & Variables
37
38
 
38
- ### `wireSecret(config)`
39
+ ### `defineSecret(config)`
39
40
 
40
41
  Declare a secret with a Zod schema for type-safe access:
41
42
 
42
43
  ```typescript
43
- wireSecret({
44
+ defineSecret({
44
45
  name: string, // Secret identifier
45
46
  schema: ZodSchema, // Shape and validation
46
47
  })
47
48
  ```
48
49
 
49
- ### `wireVariable(config)`
50
+ ### `defineVariable(config)`
50
51
 
51
52
  Declare a variable (non-sensitive config) with a Zod schema:
52
53
 
53
54
  ```typescript
54
- wireVariable({
55
+ defineVariable({
55
56
  name: string,
56
57
  schema: ZodSchema,
57
58
  })
58
59
  ```
59
60
 
60
- ### Accessing in Functions
61
+ ### Accessing Secrets
62
+
63
+ `secrets` is **not available inside functions, AI agents, workflows, permissions
64
+ or any wire** — it is removed from their services type and throws at runtime if
65
+ reached through a cast. Read it where you wire the app and hand the value to a
66
+ service:
67
+
68
+ `getSecret` returns a `SecretValue<T>`, not the bare value. It is nominal — not
69
+ assignable to `string`, so every concretely-typed sink rejects it — it serializes
70
+ to `[secret]` in logs and audits, and coercing it to a string (a template
71
+ literal, a concatenation) throws `SecretCoercionError`, because that is always a
72
+ leak. `.reveal()` is the one way out, which makes every disclosure deliberate and
73
+ greppable. Call it at the point the value reaches the thing that needs it:
61
74
 
62
75
  ```typescript
63
- // Secrets — encrypted, sensitive values
64
- const config = await services.secrets.getSecret('SECRET_NAME')
76
+ // services.ts — allowed
77
+ const createSingletonServices = pikkuServices(async (config, { secrets }) => ({
78
+ stripe: new StripeService((await secrets.getSecret('STRIPE_CONFIG')).reveal()),
79
+ }))
80
+
81
+ // functions/*.ts — ask the service, never the secret store
82
+ export const charge = pikkuFunc({
83
+ func: async ({ stripe }, data) => stripe.charge(data.amount),
84
+ })
85
+ ```
86
+
87
+ Allowed: `pikkuServices`, `pikkuWireServices`, addon service factories,
88
+ middleware. Everywhere else, the service you constructed is the interface.
65
89
 
90
+ ### Accessing Variables in Functions
91
+
92
+ ```typescript
66
93
  // Variables — plain-text configuration
67
94
  const flags = await services.variables.getVariableJSON('VARIABLE_NAME')
68
95
 
@@ -85,7 +112,7 @@ const createSingletonServices = pikkuServices(async (config) => ({
85
112
 
86
113
  ```typescript
87
114
  // Declare secrets with typed schemas
88
- wireSecret({
115
+ defineSecret({
89
116
  name: 'STRIPE_CONFIG',
90
117
  schema: z.object({
91
118
  apiKey: z.string().startsWith('sk_'),
@@ -93,13 +120,13 @@ wireSecret({
93
120
  }),
94
121
  })
95
122
 
96
- // In your function — fully typed
97
- const config = await secrets.getSecret('STRIPE_CONFIG')
123
+ // In your services factory — fully typed
124
+ const config = (await secrets.getSecret('STRIPE_CONFIG')).reveal()
98
125
  // config.apiKey → string (autocompleted)
99
126
  // config.webhookSecret → string (autocompleted)
100
127
 
101
128
  // Declare variables
102
- wireVariable({
129
+ defineVariable({
103
130
  name: 'FEATURE_FLAGS',
104
131
  schema: z.object({
105
132
  darkMode: z.boolean(),
@@ -113,46 +140,80 @@ const flags = await variables.getVariableJSON('FEATURE_FLAGS')
113
140
  // flags.maxUploadMB → number
114
141
  ```
115
142
 
116
- ## OAuth2 Credentials
143
+ ## Credentials
117
144
 
118
- ### `wireOAuth2Credential(config)`
145
+ ### `defineCredential(config)`
119
146
 
120
147
  ```typescript
121
- wireOAuth2Credential({
122
- name: string, // Credential identifier
123
- displayName: string, // Human-readable name
124
- secretId: string, // Secret holding { clientId, clientSecret }
125
- tokenSecretId: string, // Secret for token storage (auto-refreshed)
126
- authorizationUrl: string, // OAuth2 authorization endpoint
127
- tokenUrl: string, // OAuth2 token endpoint
128
- scopes: string[], // Required OAuth2 scopes
148
+ defineCredential({
149
+ name: string, // Credential identifier
150
+ displayName: string, // Human-readable name
151
+ type: 'wire' | 'singleton', // Per-user ('wire') or platform-level ('singleton')
152
+ schema: ZodSchema, // Shape of the stored credential
153
+ oauth2?: { // Omit entirely for a plain API key
154
+ appCredentialSecretId: string, // Secret holding { clientId, clientSecret }
155
+ tokenSecretId: string, // Secret for token storage (auto-refreshed)
156
+ authorizationUrl: string, // OAuth2 authorization endpoint
157
+ tokenUrl: string, // OAuth2 token endpoint
158
+ scopes: string[], // Required OAuth2 scopes
159
+ },
129
160
  })
130
161
  ```
131
162
 
132
163
  ### Usage
133
164
 
134
165
  ```typescript
135
- wireOAuth2Credential({
136
- name: 'slackOAuth',
137
- displayName: 'Slack OAuth',
138
- secretId: 'SLACK_OAUTH_APP',
139
- tokenSecretId: 'SLACK_OAUTH_TOKENS',
140
- authorizationUrl: 'https://slack.com/oauth/v2/authorize',
141
- tokenUrl: 'https://slack.com/api/oauth.v2.access',
142
- scopes: ['chat:write', 'channels:read'],
166
+ // Per-user API key — no oauth2 block
167
+ defineCredential({
168
+ name: 'stripe',
169
+ displayName: 'Stripe API Key',
170
+ type: 'wire',
171
+ schema: z.object({ apiKey: z.string() }),
143
172
  })
144
173
 
145
- // In your function — tokens refresh automatically
146
- const response = await slackOAuth.request(
147
- 'https://slack.com/api/chat.postMessage',
148
- {
149
- method: 'POST',
150
- body: JSON.stringify({ channel, text }),
174
+ // Platform-level OAuth (singleton)
175
+ defineCredential({
176
+ name: 'slack',
177
+ displayName: 'Slack',
178
+ type: 'singleton',
179
+ schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
180
+ oauth2: {
181
+ appCredentialSecretId: 'SLACK_OAUTH_APP',
182
+ tokenSecretId: 'SLACK_OAUTH_TOKENS',
183
+ authorizationUrl: 'https://slack.com/oauth/v2/authorize',
184
+ tokenUrl: 'https://slack.com/api/oauth.v2.access',
185
+ scopes: ['chat:write', 'channels:read'],
186
+ },
187
+ })
188
+
189
+ ### Reading a Credential
190
+
191
+ A declared credential is resolved per invocation through `wire.getCredential(name)`,
192
+ so the natural place to read it is a wire service factory: build the client there
193
+ once and let functions ask the client, the same way they ask a service for a
194
+ secret-derived value. Tokens refresh automatically, so what arrives is already
195
+ valid.
196
+
197
+ ```typescript
198
+ export const createWireServices = pikkuWireServices(async (_services, wire) => {
199
+ const cred = await wire.getCredential?.<{ accessToken: string }>('slack')
200
+ if (!cred?.accessToken) {
201
+ // Tells the caller which credential to connect, and where.
202
+ throw new MissingCredentialError('slack', 'oauth2', '/credentials/slack/connect')
151
203
  }
152
- )
153
- const data = await response.json()
204
+ return { slack: new SlackClient(cred.accessToken) }
205
+ })
206
+
207
+ // functions/*.ts — ask the client, never the credential store
208
+ export const postMessage = pikkuFunc({
209
+ func: async ({ slack }, { channel, text }) => slack.postMessage(channel, text),
210
+ })
154
211
  ```
155
212
 
213
+ A `wire` credential resolves per user, so an unconnected user hits
214
+ `MissingCredentialError` rather than silently acting as someone else; a
215
+ `singleton` credential is platform-level and identical for every caller.
216
+
156
217
  ## Key Rule
157
218
 
158
219
  **Never use `process.env` inside Pikku functions.** Use the `variables` or `secrets` service:
@@ -165,13 +226,30 @@ const apiKey = process.env.API_KEY
165
226
  const apiKey = services.variables.get('API_KEY')
166
227
  ```
167
228
 
168
- `process.env` belongs only in server bootstrap code (`start.ts`).
229
+ `process.env` belongs only in server bootstrap code (`start.ts`). Under `pikku dev` / `pikku serve` there is no `start.ts` — startup work goes in a `pikkuServerLifecycle` export, and the hooks receive the singleton services, so read configuration through `variables` there too (see pikku-services).
230
+
231
+ ### Lint rules
232
+
233
+ `pikku.config.json` can set the severity of individual checks:
234
+
235
+ ```json
236
+ {
237
+ "lint": {
238
+ "servicesNotDestructured": "error",
239
+ "wiresNotDestructured": "error",
240
+ "functionDynamicImport": "warn",
241
+ "customServerBootstrap": "warn"
242
+ }
243
+ }
244
+ ```
245
+
246
+ `customServerBootstrap` is the one evaluated by `pikku workspace validate` rather than codegen: it warns when the root `start`/`dev` script boots a server without `pikku dev` / `pikku serve` and no runtime adapter is installed. Set it to `"off"` to keep a hand-rolled entrypoint, or `"error"` to enforce the hooks.
169
247
 
170
248
  ## Complete Example
171
249
 
172
250
  ```typescript
173
251
  // schemas/config.ts
174
- wireSecret({
252
+ defineSecret({
175
253
  name: 'DATABASE_CONFIG',
176
254
  schema: z.object({
177
255
  connectionString: z.string().url(),
@@ -179,7 +257,7 @@ wireSecret({
179
257
  }),
180
258
  })
181
259
 
182
- wireVariable({
260
+ defineVariable({
183
261
  name: 'APP_CONFIG',
184
262
  schema: z.object({
185
263
  appName: z.string(),
@@ -188,20 +266,24 @@ wireVariable({
188
266
  }),
189
267
  })
190
268
 
191
- wireOAuth2Credential({
269
+ defineCredential({
192
270
  name: 'githubOAuth',
193
271
  displayName: 'GitHub OAuth',
194
- secretId: 'GITHUB_OAUTH_APP',
195
- tokenSecretId: 'GITHUB_OAUTH_TOKENS',
196
- authorizationUrl: 'https://github.com/login/oauth/authorize',
197
- tokenUrl: 'https://github.com/login/oauth/access_token',
198
- scopes: ['read:user', 'repo'],
272
+ type: 'wire',
273
+ schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
274
+ oauth2: {
275
+ appCredentialSecretId: 'GITHUB_OAUTH_APP',
276
+ tokenSecretId: 'GITHUB_OAUTH_TOKENS',
277
+ authorizationUrl: 'https://github.com/login/oauth/authorize',
278
+ tokenUrl: 'https://github.com/login/oauth/access_token',
279
+ scopes: ['read:user', 'repo'],
280
+ },
199
281
  })
200
282
 
201
283
  // functions/admin.functions.ts
202
284
  export const getAppStatus = pikkuSessionlessFunc({
203
285
  title: 'Get App Status',
204
- func: async ({ variables, secrets }) => {
286
+ func: async ({ variables }) => {
205
287
  const appConfig = await variables.getVariableJSON('APP_CONFIG')
206
288
  return {
207
289
  appName: appConfig.appName,
@@ -6,6 +6,7 @@ description: >-
6
6
  when: code uses wireScheduler, user asks about cron, scheduled tasks, recurring jobs, or "run
7
7
  every X minutes/hours". DO NOT TRIGGER when: user asks about background jobs with retries (use
8
8
  pikku-queue) or event-driven triggers (use pikku-trigger).
9
+ installGroups: [core]
9
10
  ---
10
11
 
11
12
  # Pikku Cron/Scheduler Wiring
@@ -42,6 +43,7 @@ wireScheduler({
42
43
  name: string, // Unique scheduler name
43
44
  schedule: string, // Cron expression
44
45
  func: PikkuVoidFunc, // Must be pikkuVoidFunc (no input/output)
46
+ tags?: string[], // Targets tag middleware — see pikku-middleware
45
47
  middleware?: PikkuMiddleware[],
46
48
  })
47
49
  ```
@@ -53,10 +55,17 @@ Inside scheduled functions:
53
55
  ```typescript
54
56
  wire.scheduledTask.name // Scheduler name
55
57
  wire.scheduledTask.schedule // Cron expression string
56
- wire.scheduledTask.executionTime // When this execution was triggered
57
- wire.scheduledTask.skip(reason) // Skip this execution (no error)
58
+ wire.scheduledTask.executionTime // Date this execution was triggered
59
+ wire.scheduledTask.skip(reason?) // Abort this execution — THROWS, never returns
58
60
  ```
59
61
 
62
+ **`skip()` aborts by throwing.** It reads like an early return but it is not:
63
+ nothing after the call runs, so there is no need to `return` afterwards. The
64
+ consequence that bites is in middleware — a `try/catch` around `await next()`
65
+ will catch a skip and report it as a failure. If your middleware distinguishes
66
+ success from failure, let the skip pass through rather than logging it as an
67
+ error.
68
+
60
69
  ### Cron Expression Reference
61
70
 
62
71
  ```
@@ -113,8 +122,7 @@ const weeklyCleanup = pikkuVoidFunc({
113
122
 
114
123
  const staleCount = await db.countStaleTodos()
115
124
  if (staleCount === 0) {
116
- wire.scheduledTask.skip('No stale todos found')
117
- return
125
+ wire.scheduledTask.skip('No stale todos found') // throws — nothing below runs
118
126
  }
119
127
 
120
128
  await db.deleteCompletedTodos({ olderThan: '30d' })
@@ -178,8 +186,7 @@ export const cleanupExpired = pikkuVoidFunc({
178
186
  func: async ({ db, logger }, _input, wire) => {
179
187
  const count = await db.countExpiredSessions()
180
188
  if (count === 0) {
181
- wire.scheduledTask.skip('No expired sessions')
182
- return
189
+ wire.scheduledTask.skip('No expired sessions') // throws — nothing below runs
183
190
  }
184
191
  await db.deleteExpiredSessions()
185
192
  logger.info(`Cleaned ${count} expired sessions`)
@@ -1,10 +1,11 @@
1
1
  ---
2
2
  name: pikku-deploy-azure
3
3
  description: >-
4
- Use when deploying a Pikku app to Azure Functions. Covers PikkuAzFunctionsLogger and
5
- PikkuAzTimerRequest for Azure Functions runtime. TRIGGER when: user asks about Azure Functions,
6
- Azure deployment, or @pikku/azure-functions. DO NOT TRIGGER when: user asks about AWS Lambda
7
- (use pikku-deploy-lambda) or Cloudflare Workers (use pikku-deploy-cloudflare).
4
+ Use when deploying a Pikku app to Azure Functions. Covers createAzureHandler for HTTP, storage
5
+ queue and timer triggers, plus AzInvocationLogger and PikkuAZTimerRequest. TRIGGER when: user
6
+ asks about Azure Functions, Azure deployment, or @pikku/azure-functions. DO NOT TRIGGER when:
7
+ user asks about AWS Lambda (use pikku-deploy-lambda) or Cloudflare Workers (use
8
+ pikku-deploy-cloudflare).
8
9
  ---
9
10
 
10
11
  # Pikku Azure Functions Deployment
@@ -29,43 +30,97 @@ yarn add @pikku/azure-functions @azure/functions
29
30
 
30
31
  ## API Reference
31
32
 
32
- ### `PikkuAzFunctionsLogger`
33
-
34
- Logger implementation that integrates with Azure Functions' built-in logging context.
35
-
36
- ### `PikkuAzTimerRequest`
37
-
38
- Timer trigger request handler for running Pikku scheduled functions as Azure Timer Triggers.
33
+ Exported from `@pikku/azure-functions`:
34
+
35
+ - `createAzureHandler(factories, handlerTypes)` — the entry point. Returns
36
+ `{ http?, queue?, timer? }` for the handler types you ask for.
37
+ - `createAzureWorkerHandler(factories)` — `createAzureHandler(factories, ['fetch'])`.
38
+ - `createAzureWebSocketHandler(factories)` — **a stub**: its `negotiate` always
39
+ answers `501 WebSocket via Azure Web PubSub not yet implemented`. Channels do
40
+ not work on Azure yet; do not plan a deployment around it.
41
+ - `AzInvocationLogger` — the logger. Note the name: there is no
42
+ `PikkuAzFunctionsLogger`.
43
+ - `PikkuAZTimerRequest` — `new PikkuAZTimerRequest(context, data)`, a
44
+ `PikkuRequest` carrying the data. The context argument is accepted and
45
+ ignored.
46
+ - `AzureQueueService`, `AzureDeploymentService`.
47
+
48
+ `factories` is `{ createConfig, createSingletonServices, createPlatformServices? }`.
49
+ Services are built from `process.env` and cached in module scope across
50
+ invocations of the same instance.
39
51
 
40
52
  ## Usage Patterns
41
53
 
42
- ### HTTP Function
54
+ ### Registering handlers
43
55
 
44
56
  ```typescript
45
57
  import { app } from '@azure/functions'
46
- import { PikkuAzFunctionsLogger } from '@pikku/azure-functions'
58
+ import { createAzureHandler } from '@pikku/azure-functions'
59
+ import { createConfig, createSingletonServices } from './services.js'
60
+ import './.pikku/pikku-bootstrap.gen.js'
61
+
62
+ const handlers = createAzureHandler(
63
+ { createConfig, createSingletonServices },
64
+ ['fetch', 'queue', 'scheduled']
65
+ )
47
66
 
48
67
  app.http('api', {
49
- methods: ['GET', 'POST', 'PUT', 'DELETE'],
68
+ methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
50
69
  route: '{*path}',
51
- handler: async (request, context) => {
52
- const logger = new PikkuAzFunctionsLogger(context)
53
- // Wire Pikku HTTP runner with Azure request/response
54
- },
70
+ handler: handlers.http as any,
55
71
  })
56
- ```
57
72
 
58
- ### Timer Trigger
59
-
60
- ```typescript
61
- import { app } from '@azure/functions'
62
- import { PikkuAzTimerRequest } from '@pikku/azure-functions'
73
+ app.storageQueue('queue', {
74
+ queueName: 'my-queue',
75
+ connection: 'AzureWebJobsStorage',
76
+ handler: handlers.queue as any,
77
+ })
63
78
 
64
79
  app.timer('scheduler', {
65
80
  schedule: '0 */5 * * * *',
66
- handler: async (timer, context) => {
67
- const request = new PikkuAzTimerRequest(timer)
68
- // Process scheduled Pikku functions
69
- },
81
+ handler: handlers.timer as any,
70
82
  })
71
83
  ```
84
+
85
+ Note the key names: `handlerTypes` uses **`scheduled`**, but the handler it
86
+ returns is **`timer`**.
87
+
88
+ ### HTTP
89
+
90
+ The handler buffers the whole body, converts to a standard `Request`, and
91
+ returns the response body as **text** — a streaming or binary response is
92
+ flattened. It takes no `RunHTTPWiringOptions`, so there is no `maxBodySize` or
93
+ `respondWith404` here; Azure's own request limits are the bound. A thrown error
94
+ is logged to `console.error` and whatever the response already holds is
95
+ returned.
96
+
97
+ ### Queue
98
+
99
+ The queue name comes from the message's own `queueName`, falling back to
100
+ `context.triggerMetadata.queueTrigger` and then `'unknown'` — a name that does
101
+ not match a wired queue means the job has no handler. `attemptsMade` is read
102
+ from `dequeueCount`, and `waitForCompletion` throws: Azure Storage Queues are
103
+ fire-and-forget. A failing job throws out of the handler, so retries and the
104
+ poison queue are governed by `host.json`, not by Pikku.
105
+
106
+ Producer side, `AzureQueueService(connectionString?)` falls back to
107
+ `AzureWebJobsStorage` and throws at construction if neither is set. Messages are
108
+ base64-encoded (Azure requires it), `delay` is milliseconds mapped to
109
+ `visibilityTimeout` in whole seconds capped at 7 days, `supportsResults` is
110
+ `false` and `getJob()` always throws. The queue name is remapped through
111
+ `AZURE_QUEUE_NAME_<SCREAMING_SNAKE>` when that variable exists, otherwise used
112
+ as-is.
113
+
114
+ ### Timer
115
+
116
+ The timer handler runs **every** scheduled task registered in the bundle,
117
+ ignoring both the `Timer` argument and each task's own cron expression. Unlike
118
+ the Lambda equivalent it does not catch per-task failures, so the first task
119
+ that throws aborts the ones after it — keep one schedule per function app, or
120
+ guard the task bodies yourself.
121
+
122
+ ### Logging
123
+
124
+ `new AzInvocationLogger(context)` forwards to the invocation context's
125
+ `info`/`warn`/`error`/`debug`/`trace`. `setLevel()` is a **no-op**: every level
126
+ is emitted and filtering has to be done in Azure's own logging configuration.
@@ -26,57 +26,99 @@ yarn add @pikku/cloudflare
26
26
 
27
27
  ## Worker Entry
28
28
 
29
+ `@pikku/cloudflare` ships the handler factories the deploy codegen emits — use
30
+ them rather than hand-rolling an `ExportedHandler`. Each returns a
31
+ `WorkerEntrypoint` class that sets services up on every invocation (cached after
32
+ the first) and adds an RPC-callable `runRpc(name, args)`:
33
+
29
34
  ```typescript
30
- import { runFetch, runScheduled } from '@pikku/cloudflare'
31
- import { setupServices } from './setup-services.js'
35
+ import { createCloudflareHandler } from '@pikku/cloudflare'
36
+ import { createConfig, createSingletonServices } from './services.js'
32
37
  import './.pikku/pikku-bootstrap.gen.js'
33
38
 
34
- export default {
35
- async scheduled(controller, env) {
36
- await setupServices(env)
37
- await runScheduled(controller)
38
- },
39
-
40
- async fetch(request, env): Promise<Response> {
41
- await setupServices(env)
42
- return await runFetch(request as unknown as Request)
43
- },
44
- } satisfies ExportedHandler<Record<string, string>>
39
+ export default createCloudflareHandler(
40
+ { createConfig, createSingletonServices },
41
+ ['fetch', 'scheduled']
42
+ )
45
43
  ```
46
44
 
45
+ | Factory | For |
46
+ | --- | --- |
47
+ | `createCloudflareHandler(factories, handlerTypes)` | combined `fetch`/`queue`/`scheduled` |
48
+ | `createCloudflareWorkerHandler(factories)` | HTTP / agent / RPC / workflow-orchestrator units |
49
+ | `createCloudflareCronHandler(factories)` | cron units |
50
+ | `createCloudflareQueueHandler(factories)` | queue-consumer and workflow-step units |
51
+ | `createCloudflareMCPHandler(factories)` | MCP units (same HTTP transport) |
52
+ | `createCloudflareWebSocketHandler(factories)` | channel units, delegating to the DO |
53
+
54
+ `factories` is `{ createConfig, createSingletonServices, createPlatformServices? }`.
55
+
47
56
  ## Service Setup
48
57
 
49
- Cloudflare passes env variables per-request — wrap them with Pikku services:
58
+ Cloudflare passes env bindings per-request, so services are built from `env`
59
+ rather than at module load. `setupServices(env, factories)` is exported from
60
+ `@pikku/cloudflare` and is what the factories call:
50
61
 
51
62
  ```typescript
52
- // setup-services.ts
53
- import { LocalVariablesService, LocalSecretService } from '@pikku/core/services'
54
- import { createConfig, createSingletonServices } from './services.js'
63
+ import { setupServices } from '@pikku/cloudflare'
55
64
 
56
- export const setupServices = async (
57
- env: Record<string, string | undefined>
58
- ) => {
59
- const localVariables = new LocalVariablesService(env)
60
- const config = await createConfig(localVariables)
61
- const localSecrets = new LocalSecretService(localVariables)
62
- return await createSingletonServices(config, {
63
- variables: localVariables,
64
- secrets: localSecrets,
65
- })
66
- }
65
+ const services = await setupServices(env, {
66
+ createConfig,
67
+ createSingletonServices,
68
+ })
67
69
  ```
68
70
 
71
+ **Do not hand-roll this.** Beyond building `LocalVariablesService` /
72
+ `LocalSecretService` and caching the result, it calls `setSingletonServices()` —
73
+ and the core runners (`fetchData`, `runQueueJob`, `runScheduled`) resolve
74
+ services through that global slot, *not* through the value you were returned. A
75
+ setup function that only returns the services leaves every request throwing
76
+ "Singleton services not initialized" as a CF `1101`. It also stashes the env via
77
+ `setCloudflareEnv`, which `getCloudflareEnv()` reads for bindings.
78
+
79
+ ## HTTP
80
+
81
+ `runFetch(request, websocketHibernationServer?, options?)`:
82
+
83
+ - A `GET` with `Upgrade: websocket` is routed to the hibernation server. Without
84
+ one passed in it answers **426**, so a channel worker that forgets the second
85
+ argument fails every upgrade while plain HTTP keeps working.
86
+ - `CF-Ray` becomes the traceId when present, so a Cloudflare trace and a Pikku
87
+ trace line up without extra wiring.
88
+ - `options.exposeErrors` defaults to **`false`** — error detail is withheld from
89
+ responses unless you opt in.
90
+
91
+ ## Scheduled Tasks
92
+
93
+ `runScheduled(controller)` matches registered tasks against
94
+ `controller.cron` and **returns after the first match**. Two tasks sharing one
95
+ cron expression means only one of them ever runs — give each its own expression,
96
+ or invoke `runScheduledTask({ name })` per task yourself.
97
+
69
98
  ## WebSocket (Durable Objects)
70
99
 
100
+ The ready-made DO class is exported; re-export it under the binding name and
101
+ point the worker at it:
102
+
71
103
  ```typescript
72
- import { CloudflareWebSocketHibernationServer } from '@pikku/cloudflare'
73
-
74
- export class WebSocketHibernationServer extends CloudflareWebSocketHibernationServer {
75
- protected async getParams() {
76
- const singletonServices = await setupServices(this.env)
77
- return { singletonServices }
78
- }
79
- }
104
+ export { PikkuWebSocketHibernationServer as WebSocketHibernationServer } from '@pikku/cloudflare'
105
+ export default createCloudflareWebSocketHandler({
106
+ createConfig,
107
+ createSingletonServices,
108
+ })
80
109
  ```
81
110
 
82
- Register the Durable Object in `wrangler.toml` and export from the worker entry.
111
+ Subclass `CloudflareWebSocketHibernationServer` only when you need something
112
+ `getParams()` cannot express — it is abstract with one method returning
113
+ `{ singletonServices, createWireServices? }`. The channel store
114
+ (`CloudflareWebsocketStore` over the DO's own storage), the event hub and the
115
+ channel handler factory are all built by the base class; do not supply them.
116
+
117
+ The router looks up the DO through the **`WEBSOCKET_HIBERNATION_SERVER`**
118
+ binding and answers `503` naming it if the binding is missing, so declare it in
119
+ `wrangler.toml` under exactly that name.
120
+
121
+ A throw during `onConnect` closes the socket with `1008` and answers `403
122
+ Forbidden` with a deliberately generic body — an auth denial and a genuine fault
123
+ look identical to the client. The real reason is on the logger, so read the
124
+ worker logs rather than the status code.