two-stroke 6.5.2 → 7.0.0
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/CLAUDE.md +50 -0
- package/README.md +373 -90
- package/package.json +15 -13
- package/src/fake.ts +3 -0
- package/src/index.ts +32 -32
- package/src/test.ts +15 -43
- package/tsconfig.json +1 -2
- package/worker-configuration.d.ts +815 -36
- package/wrangler.jsonc +10 -2
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What is this?
|
|
6
|
+
|
|
7
|
+
Two-stroke is a lightweight TypeScript framework for building type-safe APIs on Cloudflare Workers. It provides structured routing, authentication (PBKDF, JWT/JWK), Zod-based request/response validation, auto-generated OpenAPI docs, queue/cron/email handlers, and Sentry error tracking.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
All commands are exposed as bin scripts (no `npm run` prefix needed when installed):
|
|
12
|
+
|
|
13
|
+
- **`pnpm lint`** — ESLint + Prettier check (fails on violations)
|
|
14
|
+
- **`pnpm format`** — ESLint fix + Prettier write
|
|
15
|
+
- **`pnpm test`** — Builds with `wrangler deploy --dry-run`, then runs Vitest with Cloudflare Workers pool (Miniflare)
|
|
16
|
+
- **`pnpm type-check`** — `wrangler deploy --dry-run` + `tsc --noEmit`
|
|
17
|
+
- **`pnpm dev`** — Local dev server via `wrangler dev`
|
|
18
|
+
- **`pnpm deploy <env> <version>`** — Deploy with Sentry release tracking
|
|
19
|
+
- **`pnpm api-types <url>`** — Generate TypeScript types from an OpenAPI endpoint
|
|
20
|
+
|
|
21
|
+
There is no way to run a single test file directly — use Vitest's built-in filtering (e.g., `vitest run src/foo.test.ts`).
|
|
22
|
+
|
|
23
|
+
## Architecture
|
|
24
|
+
|
|
25
|
+
### Core source files (`src/`)
|
|
26
|
+
|
|
27
|
+
- **`index.ts`** — Main `twoStroke<T>()` factory. Returns `fetch`, `queue`, `scheduled`, `email` handlers plus route registration methods (`get`, `post`, `put`, `delete`) and auth builders (`noAuth`, `pbkdf`, `jwt`). Routes are regex-matched with path parameters like `{userId}`.
|
|
28
|
+
- **`types.ts`** — Core type definitions: `Env` (CF bindings union), `Route<T, A>`, `Handler<T, I, O, A, P>`, `ExtractParameterNames<S>` (extracts `{param}` from path strings).
|
|
29
|
+
- **`open-api.ts`** — Generates OpenAPI 3.1.0 spec from registered routes; served at `/doc`.
|
|
30
|
+
- **`test.ts`** — Test utilities: `setupTests()` (fetch mocking, OpenAPI client), `fakeJWK()` (RS256 test tokens), request recording helpers, `waitForQueue()`.
|
|
31
|
+
- **`cmd.mjs`** — Shared utility for spawning subprocesses in bin scripts.
|
|
32
|
+
|
|
33
|
+
### Request flow
|
|
34
|
+
|
|
35
|
+
1. Route matched by regex against URL pathname
|
|
36
|
+
2. Auth handler runs (returns claims or throws)
|
|
37
|
+
3. Request body parsed and validated against Zod input schema (POST/PUT)
|
|
38
|
+
4. Handler executes with typed context: `{ req, env, body, params, searchParams, claims, sentry, waitUntil }`
|
|
39
|
+
5. Response validated against Zod output schema
|
|
40
|
+
6. CORS + security headers applied automatically
|
|
41
|
+
|
|
42
|
+
### Key conventions
|
|
43
|
+
|
|
44
|
+
- **Zod v4** — imported as `zod/v4` (not `zod`)
|
|
45
|
+
- **ESM only** — `"type": "module"` with `verbatimModuleSyntax` in tsconfig
|
|
46
|
+
- **Strict TypeScript** — `strict: true`, `noUncheckedIndexedAccess: true`, `isolatedModules: true`
|
|
47
|
+
- **Node 24.9+** required, pnpm 10.30+ via Corepack
|
|
48
|
+
- **Prettier** — 100 char print width
|
|
49
|
+
- **ESLint config** — extends `eslint-config-two-stroke`
|
|
50
|
+
- **Testing** — Vitest with `@cloudflare/vitest-pool-workers` pool; globals enabled (no imports needed for `describe`, `it`, `expect`)
|
package/README.md
CHANGED
|
@@ -2,22 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
A minimalist framework for Cloudflare Workers with built-in routing, authentication, and validation.
|
|
4
4
|
|
|
5
|
-
## Overview
|
|
6
|
-
|
|
7
|
-
Two-Stroke is a lightweight framework for building APIs with Cloudflare Workers. It provides a structured approach to defining routes, handling authentication, validating requests and responses with Zod, and managing errors with Sentry.
|
|
8
|
-
|
|
9
|
-
## Features
|
|
10
|
-
|
|
11
|
-
- **Type-safe routing** with path parameter extraction
|
|
12
|
-
- **Schema validation** for request and response bodies using Zod
|
|
13
|
-
- **Built-in authentication** methods (JWT, PBKDF)
|
|
14
|
-
- **Error handling** with Sentry integration
|
|
15
|
-
- **CORS support** out of the box
|
|
16
|
-
- **Queue handling** for background processing
|
|
17
|
-
- **Cron job support** for scheduled tasks
|
|
18
|
-
- **Email handling** capabilities
|
|
19
|
-
- **OpenAPI documentation** generation
|
|
20
|
-
|
|
21
5
|
## Installation
|
|
22
6
|
|
|
23
7
|
```bash
|
|
@@ -28,121 +12,420 @@ npm install two-stroke
|
|
|
28
12
|
|
|
29
13
|
```typescript
|
|
30
14
|
import { twoStroke } from "two-stroke";
|
|
31
|
-
import { z } from "zod";
|
|
15
|
+
import { z } from "zod/v4";
|
|
32
16
|
|
|
33
|
-
// Define your environment type
|
|
34
17
|
type MyEnv = {
|
|
35
|
-
MY_SECRET: string;
|
|
36
18
|
MY_KV: KVNamespace;
|
|
37
19
|
};
|
|
38
20
|
|
|
39
|
-
// Create a Two-Stroke app
|
|
40
21
|
const app = twoStroke<MyEnv>("My API", "1.0.0");
|
|
41
22
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
23
|
+
app.get(app.noAuth, "/hello", z.object({ message: z.string() }), async () => ({
|
|
24
|
+
body: { message: "Hello, World!" },
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
export default app;
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The `twoStroke` function returns an object that directly satisfies the Cloudflare Workers `ExportedHandler` interface — export it as `default` and it handles `fetch`, `queue`, `scheduled`, and `email` events.
|
|
31
|
+
|
|
32
|
+
## API Reference
|
|
33
|
+
|
|
34
|
+
### `twoStroke<T>(title, release, origin?)`
|
|
35
|
+
|
|
36
|
+
Creates a new application instance.
|
|
37
|
+
|
|
38
|
+
| Parameter | Type | Description |
|
|
39
|
+
| --------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
40
|
+
| `title` | `string` | API title, used in the generated OpenAPI spec. |
|
|
41
|
+
| `release` | `string` | Release version, used for Sentry and OpenAPI. |
|
|
42
|
+
| `origin` | `(origin: string \| null) => string` | Optional. Returns the `Access-Control-Allow-Origin` value for a given request origin. If omitted, defaults to `"*"`. In staging environments, `localhost` origins are always allowed. |
|
|
43
|
+
|
|
44
|
+
The environment type `T` must extend `Env`, which allows values of type `string`, `Queue`, `KVNamespace`, `R2Bucket`, `D1Database`, `Fetcher`, `Hyperdrive`, `DurableObjectNamespace`, `Vectorize`, or `ImagesBinding`.
|
|
45
|
+
|
|
46
|
+
Every environment must also include `SENTRY_DSN` and `SENTRY_ENVIRONMENT` string bindings. These are used automatically to initialize Sentry on every request, queue batch, scheduled event, and email.
|
|
47
|
+
|
|
48
|
+
Returns an object with the following methods:
|
|
53
49
|
|
|
54
|
-
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
### Route Registration
|
|
53
|
+
|
|
54
|
+
All route methods register an HTTP endpoint with authentication, validation, and a handler.
|
|
55
|
+
|
|
56
|
+
#### `app.get(auth, path, output, handler, params?)`
|
|
57
|
+
|
|
58
|
+
Registers a `GET` route.
|
|
59
|
+
|
|
60
|
+
| Parameter | Type | Description |
|
|
61
|
+
| --------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
62
|
+
| `auth` | `(c: { req, env }) => Promise<A>` | Authentication function. |
|
|
63
|
+
| `path` | `string` | URL path pattern (e.g. `"/users/{userId}"`). |
|
|
64
|
+
| `output` | `ZodType` | Zod schema validating the response body. |
|
|
65
|
+
| `handler` | `Handler` | Async function handling the request. |
|
|
66
|
+
| `params` | `ZodObject` | Optional. Zod object schema for query parameters. When provided, these appear in the generated OpenAPI spec. |
|
|
67
|
+
|
|
68
|
+
#### `app.post(auth, path, input, output, handler, params?)`
|
|
69
|
+
|
|
70
|
+
Registers a `POST` route.
|
|
71
|
+
|
|
72
|
+
| Parameter | Type | Description |
|
|
73
|
+
| --------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
74
|
+
| `auth` | `(c: { req, env }) => Promise<A>` | Authentication function. |
|
|
75
|
+
| `path` | `string` | URL path pattern. |
|
|
76
|
+
| `input` | `ZodType \| undefined` | Zod schema validating the request body. Pass `undefined` for routes that don't require a body, or if you want to handle the body yourself (e.g a stream). Accepts both `application/json` and `application/x-www-form-urlencoded` content types. |
|
|
77
|
+
| `output` | `ZodType` | Zod schema validating the response body. |
|
|
78
|
+
| `handler` | `Handler` | Async function handling the request. |
|
|
79
|
+
| `params` | `ZodObject` | Optional. Zod object schema for query parameters. |
|
|
80
|
+
|
|
81
|
+
#### `app.put(auth, path, input, output, handler)`
|
|
82
|
+
|
|
83
|
+
Registers a `PUT` route. Same signature as `post` (without the optional `params`).
|
|
84
|
+
|
|
85
|
+
#### `app.delete(auth, path, output, handler, params?)`
|
|
86
|
+
|
|
87
|
+
Registers a `DELETE` route. Same signature as `get`.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
### Path Parameters
|
|
92
|
+
|
|
93
|
+
Path parameters use `{name}` syntax and are automatically extracted via regex:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
55
96
|
app.get(
|
|
56
97
|
app.noAuth,
|
|
57
|
-
"/users/{userId}",
|
|
58
|
-
z.object({
|
|
98
|
+
"/users/{userId}/posts/{postId}",
|
|
99
|
+
z.object({ title: z.string() }),
|
|
59
100
|
async ({ params }) => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
};
|
|
101
|
+
// params.userId and params.postId are typed as string
|
|
102
|
+
return { body: { title: "Hello" } };
|
|
63
103
|
},
|
|
64
104
|
);
|
|
105
|
+
```
|
|
65
106
|
|
|
66
|
-
|
|
67
|
-
app.post(
|
|
68
|
-
app.noAuth,
|
|
69
|
-
"/messages",
|
|
70
|
-
z.object({ content: z.string().min(1) }),
|
|
71
|
-
z.object({ id: z.string() }),
|
|
72
|
-
async ({ body }) => {
|
|
73
|
-
return {
|
|
74
|
-
body: { id: "msg_123" },
|
|
75
|
-
};
|
|
76
|
-
},
|
|
77
|
-
);
|
|
107
|
+
The `ExtractParameterNames<P>` utility type extracts parameter names from the path string at compile time, so `params` is fully typed.
|
|
78
108
|
|
|
79
|
-
|
|
80
|
-
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
### Handler Context
|
|
112
|
+
|
|
113
|
+
Every route handler receives a single context object:
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
async (c: {
|
|
117
|
+
req: Request; // Original Cloudflare Request
|
|
118
|
+
env: T; // Environment bindings
|
|
119
|
+
body: z.infer<I>; // Parsed & validated request body (undefined for GET/DELETE)
|
|
120
|
+
params: { ... }; // Extracted path parameters, typed from the path string
|
|
121
|
+
searchParams: URLSearchParams; // URL query parameters
|
|
122
|
+
claims: A; // Authentication claims (type depends on auth method)
|
|
123
|
+
sentry: Toucan; // Sentry instance for error tracking
|
|
124
|
+
waitUntil: (p: Promise<void>) => void; // Extend request lifetime
|
|
125
|
+
}) => Promise<Response>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Handlers must return an object with a `body` and optional `status` and `headers`:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
// Success (200 is the default)
|
|
132
|
+
return { body: { id: "123" } };
|
|
133
|
+
|
|
134
|
+
// Redirect
|
|
135
|
+
return { body: { url: "/new-location" }, status: 302, headers: { Location: "/new-location" } };
|
|
136
|
+
|
|
137
|
+
// Error
|
|
138
|
+
return { body: { error: "Not found" }, status: 404 };
|
|
81
139
|
```
|
|
82
140
|
|
|
83
|
-
|
|
141
|
+
Valid status codes for typed success responses are `200` (default), `301`, and `302`. Any other numeric status is allowed when the body is `{ error: string }` or omitted.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### Authentication
|
|
146
|
+
|
|
147
|
+
#### `app.noAuth`
|
|
84
148
|
|
|
85
|
-
|
|
149
|
+
No authentication. The `claims` value is `null`.
|
|
86
150
|
|
|
87
151
|
```typescript
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
body: { message: "Public endpoint" },
|
|
91
|
-
}));
|
|
152
|
+
app.get(app.noAuth, "/public", outputSchema, handler);
|
|
153
|
+
```
|
|
92
154
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
155
|
+
#### `app.pbkdf(key, customHeaderName?)`
|
|
156
|
+
|
|
157
|
+
PBKDF2 key verification. Validates a `Bearer` or `token` scheme credential against a hashed secret stored in the environment. The `claims` value is `void`.
|
|
158
|
+
|
|
159
|
+
| Parameter | Type | Description |
|
|
160
|
+
| ------------------ | --------- | --------------------------------------------------------------------------------- |
|
|
161
|
+
| `key` | `keyof T` | Environment binding name containing the PBKDF2 hash. |
|
|
162
|
+
| `customHeaderName` | `string` | Optional. Header name to read the credential from. Defaults to `"Authorization"`. |
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
app.get(app.pbkdf("API_KEY_HASH"), "/protected", outputSchema, handler);
|
|
166
|
+
|
|
167
|
+
// With custom header
|
|
168
|
+
app.get(app.pbkdf("API_KEY_HASH", "X-Api-Key"), "/protected", outputSchema, handler);
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
#### `app.jwt<J>(key, audience)`
|
|
172
|
+
|
|
173
|
+
JWT verification using JWK (JSON Web Keys). Fetches the OIDC configuration and JWKS from the issuer URL, then verifies the token. The `claims` value is typed as `J`.
|
|
174
|
+
|
|
175
|
+
| Parameter | Type | Description |
|
|
176
|
+
| ---------- | --------- | ---------------------------------------------------------- |
|
|
177
|
+
| `key` | `keyof T` | Environment binding name containing the issuer URL. |
|
|
178
|
+
| `audience` | `keyof T` | Environment binding name containing the expected audience. |
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
type Claims = { sub: string; email: string };
|
|
100
182
|
|
|
101
|
-
// JWT authentication
|
|
102
183
|
app.get(
|
|
103
|
-
app.jwt<
|
|
104
|
-
"/
|
|
105
|
-
z.object({
|
|
106
|
-
async ({ claims }) => ({
|
|
184
|
+
app.jwt<Claims>("AUTH_ISSUER", "AUTH_AUDIENCE"),
|
|
185
|
+
"/me",
|
|
186
|
+
z.object({ email: z.string() }),
|
|
187
|
+
async ({ claims }) => ({
|
|
188
|
+
// claims is typed as Claims
|
|
189
|
+
body: { email: claims.email },
|
|
190
|
+
}),
|
|
107
191
|
);
|
|
108
192
|
```
|
|
109
193
|
|
|
110
|
-
|
|
194
|
+
Authentication failures return `401` with a `WWW-Authenticate: Bearer` header.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
### Queue Handling
|
|
199
|
+
|
|
200
|
+
#### `app.queueHandler(input, handler)`
|
|
201
|
+
|
|
202
|
+
Registers a queue consumer. Only one queue handler can be registered per app.
|
|
203
|
+
|
|
204
|
+
| Parameter | Type | Description |
|
|
205
|
+
| --------- | ----------------------------------------------------------- | ---------------------------------------------------- |
|
|
206
|
+
| `input` | `ZodType` | Zod schema for validating each message in the batch. |
|
|
207
|
+
| `handler` | `(c: { env, batch, sentry, parsedBatch }) => Promise<void>` | Async function processing the batch. |
|
|
208
|
+
|
|
209
|
+
The `parsedBatch` is an array of `ZodSafeParseResult` objects, one per message, allowing you to handle valid and invalid messages individually.
|
|
111
210
|
|
|
112
211
|
```typescript
|
|
113
|
-
// Define a queue handler
|
|
114
212
|
app.queueHandler(
|
|
115
|
-
z.object({
|
|
213
|
+
z.object({ userId: z.string(), action: z.string() }),
|
|
116
214
|
async ({ batch, parsedBatch, env }) => {
|
|
117
215
|
for (let i = 0; i < batch.messages.length; i++) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
216
|
+
const result = parsedBatch[i];
|
|
217
|
+
if (result.success) {
|
|
218
|
+
console.log(result.data.userId, result.data.action);
|
|
219
|
+
batch.messages[i].ack();
|
|
220
|
+
} else {
|
|
221
|
+
batch.messages[i].retry();
|
|
122
222
|
}
|
|
123
223
|
}
|
|
124
224
|
},
|
|
125
225
|
);
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### `addToQueue(queue, message, config?)`
|
|
229
|
+
|
|
230
|
+
Standalone utility for sending messages to a queue with exponential backoff retry.
|
|
126
231
|
|
|
127
|
-
|
|
232
|
+
```typescript
|
|
128
233
|
import { addToQueue } from "two-stroke";
|
|
129
234
|
|
|
130
|
-
await addToQueue(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
235
|
+
await addToQueue(env.MY_QUEUE, { userId: "123", action: "sync" });
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
| Config Option | Type | Default | Description |
|
|
239
|
+
| --------------- | -------- | ------- | ------------------------------------------------------------ |
|
|
240
|
+
| `retries` | `number` | `5` | Maximum number of send attempts. |
|
|
241
|
+
| `backoffFactor` | `number` | `2` | Base for exponential backoff (in seconds: `factor^attempt`). |
|
|
242
|
+
|
|
243
|
+
All other properties on `config` are forwarded to the Cloudflare `Queue.send()` options (e.g. `contentType`, `delaySeconds`).
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
### Scheduled Tasks
|
|
248
|
+
|
|
249
|
+
#### `app.schedule(cron, handler)`
|
|
250
|
+
|
|
251
|
+
Registers a cron-triggered handler. Multiple schedules can be registered.
|
|
252
|
+
|
|
253
|
+
| Parameter | Type | Description |
|
|
254
|
+
| --------- | --------------------------------------- | ---------------------------------------------------------- |
|
|
255
|
+
| `cron` | `string` | Cron expression (must match a trigger in `wrangler.toml`). |
|
|
256
|
+
| `handler` | `(c: { env, sentry }) => Promise<void>` | Async function to run on schedule. |
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
app.schedule("0 * * * *", async ({ env, sentry }) => {
|
|
260
|
+
// Runs every hour
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
app.schedule("0 0 * * *", async ({ env }) => {
|
|
264
|
+
// Runs daily at midnight
|
|
265
|
+
});
|
|
138
266
|
```
|
|
139
267
|
|
|
140
|
-
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
### Email Handling
|
|
271
|
+
|
|
272
|
+
#### `app.emailHandler(handler)`
|
|
273
|
+
|
|
274
|
+
Registers an email handler for Cloudflare Email Routing. Only one email handler can be registered per app.
|
|
275
|
+
|
|
276
|
+
| Parameter | Type | Description |
|
|
277
|
+
| --------- | ------------------------------------------------ | ------------------------------------ |
|
|
278
|
+
| `handler` | `(c: { env, message, sentry }) => Promise<void>` | Async function processing the email. |
|
|
279
|
+
|
|
280
|
+
The `message` is a Cloudflare `ForwardableEmailMessage`.
|
|
141
281
|
|
|
142
282
|
```typescript
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
console.log("Running scheduled task");
|
|
283
|
+
app.emailHandler(async ({ message, env }) => {
|
|
284
|
+
console.log(`Email from ${message.from} to ${message.to}`);
|
|
285
|
+
await message.forward("archive@example.com");
|
|
147
286
|
});
|
|
148
287
|
```
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
### OpenAPI Documentation
|
|
292
|
+
|
|
293
|
+
A `GET /doc` endpoint is automatically registered and serves an OpenAPI 3.1.0 specification generated from all registered routes. It includes:
|
|
294
|
+
|
|
295
|
+
- Path and query parameters (from path patterns and `params` schemas)
|
|
296
|
+
- Request body schemas (from `input`)
|
|
297
|
+
- Response body schemas (from `output`)
|
|
298
|
+
- Security requirements (routes using auth other than `noAuth` are marked with bearer auth)
|
|
299
|
+
- Standard `400` and `500` error response schemas
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
### Request & Response Behavior
|
|
304
|
+
|
|
305
|
+
**Request validation**: `POST` and `PUT` bodies are validated against the `input` schema. Invalid bodies return `400` with an `error` message and Zod `issues` array.
|
|
306
|
+
|
|
307
|
+
**Response validation**: `200` responses are validated against the `output` schema. Validation failures are logged but the response is still sent (the schema acts as a development-time warning, not a gate).
|
|
308
|
+
|
|
309
|
+
**CORS**: All responses include `Access-Control-Allow-Origin`. `OPTIONS` requests are handled automatically with a `204` and appropriate headers allowing `GET`, `HEAD`, `PUT`, `POST`, `DELETE` methods and `Authorization`, `Content-Type` headers.
|
|
310
|
+
|
|
311
|
+
**Security headers**: All responses include `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: default-src 'self'`. Custom headers from the handler are preserved and take precedence.
|
|
312
|
+
|
|
313
|
+
**Content types**: Responses default to `application/json`. Handlers can override this via the `headers` return value — when `Content-Type` is not `application/json`, the body is sent as-is without JSON serialization.
|
|
314
|
+
|
|
315
|
+
**Errors**: Unhandled exceptions return `500` and are reported to Sentry. Authentication failures return `401`.
|
|
316
|
+
|
|
317
|
+
**404**: Unmatched routes return an empty `404` response.
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## Testing Utilities
|
|
322
|
+
|
|
323
|
+
Two-stroke exports testing utilities from `two-stroke/test` designed for use with Vitest and `@cloudflare/vitest-pool-workers`.
|
|
324
|
+
|
|
325
|
+
### `setupTests<Paths>()`
|
|
326
|
+
|
|
327
|
+
Initializes the test environment. Call once per test file. Returns:
|
|
328
|
+
|
|
329
|
+
| Property | Type | Description |
|
|
330
|
+
| ----------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
331
|
+
| `url` | `URL` | Base URL (`https://example.com/`). |
|
|
332
|
+
| `fetchMock` | `MockAgent` | Cloudflare's fetch mock (from `cloudflare:test`). Activated with `disableNetConnect()` in `beforeAll`. |
|
|
333
|
+
| `client` | `Client<Paths>` | An `openapi-fetch` client pointed at the worker, typed with the `Paths` generic (typically generated from the OpenAPI spec). |
|
|
334
|
+
| `waitForQueue(trigger)` | `(trigger: () => Promise<void>) => Promise<void>` | Triggers an action and waits until the queue batch finishes processing. |
|
|
335
|
+
| `fakeJWK(issuer, audience, claims)` | `(issuer, audience, claims) => Promise<string>` | Generates an RS256-signed JWT for testing. Automatically mocks the OIDC discovery and JWKS endpoints on the issuer URL. Returns the signed token string. |
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
import { setupTests } from "two-stroke/test";
|
|
339
|
+
import type { paths } from "./api";
|
|
340
|
+
|
|
341
|
+
const { client, fetchMock, fakeJWK, waitForQueue } = await setupTests<paths>();
|
|
342
|
+
|
|
343
|
+
describe("GET /hello", () => {
|
|
344
|
+
it("returns a greeting", async () => {
|
|
345
|
+
const { data, response } = await client.GET("/hello");
|
|
346
|
+
expect(response.status).toBe(200);
|
|
347
|
+
expect(data?.message).toBe("Hello, World!");
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### `recordRequest(cb, statusCode, data, responseOptions?)`
|
|
353
|
+
|
|
354
|
+
Creates a fetch mock reply handler that captures the JSON request body and returns a fixed response. Useful for intercepting outgoing API calls.
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
let captured: unknown;
|
|
358
|
+
fetchMock
|
|
359
|
+
.get("https://api.example.com")
|
|
360
|
+
.intercept({ method: "POST", path: "/webhook" })
|
|
361
|
+
.reply(
|
|
362
|
+
recordRequest(
|
|
363
|
+
(data) => {
|
|
364
|
+
captured = data;
|
|
365
|
+
},
|
|
366
|
+
200,
|
|
367
|
+
{ ok: true },
|
|
368
|
+
),
|
|
369
|
+
);
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### `recordFormRequest(cb, statusCode, data, responseOptions?)`
|
|
373
|
+
|
|
374
|
+
Same as `recordRequest` but parses the body as `application/x-www-form-urlencoded`.
|
|
375
|
+
|
|
376
|
+
### `recordFirehoseRequest(cb, statusCode, data, responseOptions?)`
|
|
377
|
+
|
|
378
|
+
Same as `recordRequest` but also decodes and parses a base64-encoded `Record.Data` field from the JSON body (for AWS Firehose-style payloads). Calls `cb` twice: once with the raw body and once with the decoded inner payload.
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## CLI Commands
|
|
383
|
+
|
|
384
|
+
Two-stroke provides executable bin scripts. In consuming projects, these are available directly as commands (e.g. `npx dev`, `npx test`). For framework development, run them with `pnpm`:
|
|
385
|
+
|
|
386
|
+
### `dev`
|
|
387
|
+
|
|
388
|
+
Starts a local development server via `wrangler dev`.
|
|
389
|
+
|
|
390
|
+
### `test`
|
|
391
|
+
|
|
392
|
+
Builds the worker with `wrangler deploy --dry-run`, generates TypeScript types from the OpenAPI spec into `test/api.d.ts`, then runs `vitest`. Passes all arguments through to vitest (e.g. `test --watch`, `test src/users.test.ts`).
|
|
393
|
+
|
|
394
|
+
### `lint`
|
|
395
|
+
|
|
396
|
+
Runs `eslint --cache --max-warnings=0` followed by `prettier --cache --check .`. Fails on any violation.
|
|
397
|
+
|
|
398
|
+
### `format`
|
|
399
|
+
|
|
400
|
+
Runs `eslint --cache --fix` followed by `prettier --cache --write .`.
|
|
401
|
+
|
|
402
|
+
### `type-check`
|
|
403
|
+
|
|
404
|
+
Runs `wrangler deploy --dry-run --outdir=dist` followed by `tsc --noEmit`.
|
|
405
|
+
|
|
406
|
+
### `deploy <env> <version>`
|
|
407
|
+
|
|
408
|
+
Deploys the worker to a Cloudflare environment with Sentry release tracking:
|
|
409
|
+
|
|
410
|
+
1. Writes the version to `src/release.ts`
|
|
411
|
+
2. Uploads secrets via `wrangler secret bulk`
|
|
412
|
+
3. Creates and finalizes a Sentry release
|
|
413
|
+
4. Runs `wrangler deploy`
|
|
414
|
+
5. Uploads sourcemaps to Sentry
|
|
415
|
+
|
|
416
|
+
### `api-types <urls>`
|
|
417
|
+
|
|
418
|
+
Generates TypeScript type definitions from live OpenAPI endpoints. Accepts a comma-separated list of service URLs. Fetches `/doc` from each, converts to TypeScript via `openapi-typescript`, and writes definition files to `src/__definitions__/`.
|
|
419
|
+
|
|
420
|
+
### `bulk <entry|rest> <entryfile>`
|
|
421
|
+
|
|
422
|
+
Uploads files from `dist/` to a Cloudflare KV namespace. Used for serving static assets.
|
|
423
|
+
|
|
424
|
+
- `bulk entry index.html` — uploads only the entry file (with `nocache` cache control)
|
|
425
|
+
- `bulk rest index.html` — uploads everything except the entry file (with immutable cache control)
|
|
426
|
+
|
|
427
|
+
Requires `NAMESPACE` and `DOMAIN` environment variables.
|
|
428
|
+
|
|
429
|
+
## License
|
|
430
|
+
|
|
431
|
+
MIT
|
package/package.json
CHANGED
|
@@ -10,19 +10,21 @@
|
|
|
10
10
|
"type-check": "./bin/type-check.mjs"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@cloudflare/vitest-pool-workers": "^0.
|
|
14
|
-
"@sentry/cli": "^3.3.
|
|
15
|
-
"@types/node": "^25.
|
|
16
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
17
|
-
"@typescript-eslint/parser": "^8.
|
|
18
|
-
"@vitest/
|
|
13
|
+
"@cloudflare/vitest-pool-workers": "^0.13.2",
|
|
14
|
+
"@sentry/cli": "^3.3.3",
|
|
15
|
+
"@types/node": "^25.5.0",
|
|
16
|
+
"@typescript-eslint/eslint-plugin": "^8.57.1",
|
|
17
|
+
"@typescript-eslint/parser": "^8.57.1",
|
|
18
|
+
"@vitest/runner": "^4.1.0",
|
|
19
|
+
"@vitest/snapshot": "^4.1.0",
|
|
19
20
|
"eslint-config-prettier": "^10.1.8",
|
|
20
|
-
"eslint-config-two-stroke": "^1.
|
|
21
|
+
"eslint-config-two-stroke": "^1.5.1",
|
|
21
22
|
"eslint-config-typescript": "^3.0.0",
|
|
22
|
-
"jose": "^6.2.
|
|
23
|
+
"jose": "^6.2.1",
|
|
23
24
|
"jwk-subtle": "^1.1.5",
|
|
24
25
|
"mime": "^4.1.0",
|
|
25
|
-
"miniflare": "^4.
|
|
26
|
+
"miniflare": "^4.20260317.0",
|
|
27
|
+
"msw": "^2.12.13",
|
|
26
28
|
"openapi-fetch": "^0.17.0",
|
|
27
29
|
"openapi-typescript": "^7.13.0",
|
|
28
30
|
"pbkdf-subtle": "^1.1.5",
|
|
@@ -31,10 +33,10 @@
|
|
|
31
33
|
},
|
|
32
34
|
"peerDependencies": {
|
|
33
35
|
"@sentry/cli": ">=2",
|
|
34
|
-
"eslint": ">=
|
|
36
|
+
"eslint": ">=10",
|
|
35
37
|
"prettier": ">=3",
|
|
36
38
|
"typescript": ">=5",
|
|
37
|
-
"vitest": ">=
|
|
39
|
+
"vitest": ">=4",
|
|
38
40
|
"wrangler": ">=3"
|
|
39
41
|
},
|
|
40
42
|
"description": "Simple Cloudflare Worker framework.",
|
|
@@ -57,12 +59,12 @@
|
|
|
57
59
|
"url": "https://github.com/change-engine/two-stroke"
|
|
58
60
|
},
|
|
59
61
|
"type": "module",
|
|
60
|
-
"version": "
|
|
62
|
+
"version": "7.0.0",
|
|
61
63
|
"devDependencies": {
|
|
62
64
|
"@types/eslint": "^9.6.1",
|
|
63
65
|
"eslint": "^10.0.3",
|
|
64
66
|
"prettier": "^3.8.1",
|
|
65
67
|
"typescript": "^5.9.3",
|
|
66
|
-
"vitest": "^4.0
|
|
68
|
+
"vitest": "^4.1.0"
|
|
67
69
|
}
|
|
68
70
|
}
|
package/src/fake.ts
ADDED