arcway 0.1.0 → 0.1.1
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/README.md +417 -358
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# Arcway
|
|
2
2
|
|
|
3
|
-
A convention-based
|
|
4
|
-
|
|
5
|
-
Arcway uses file-system conventions to discover routes, services, jobs, events, and middleware — no manual wiring required. Each domain is isolated with its own database scope, event emitter, queue, cache, and file storage.
|
|
3
|
+
A convention-based JavaScript framework for building full-stack applications. File-system conventions discover routes, jobs, events, and middleware automatically — no manual wiring required.
|
|
6
4
|
|
|
7
5
|
## Quick Start
|
|
8
6
|
|
|
@@ -14,55 +12,47 @@ Create a project:
|
|
|
14
12
|
|
|
15
13
|
```
|
|
16
14
|
my-app/
|
|
17
|
-
├── arcway.config.
|
|
18
|
-
├──
|
|
15
|
+
├── arcway.config.js
|
|
16
|
+
├── api/
|
|
19
17
|
│ └── users/
|
|
20
|
-
│
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
└── db/
|
|
24
|
-
└── migrations/
|
|
18
|
+
│ └── index.js
|
|
19
|
+
└── migrations/
|
|
20
|
+
└── 20260101000000-create-users.js
|
|
25
21
|
```
|
|
26
22
|
|
|
27
|
-
**arcway.config.
|
|
28
|
-
|
|
29
|
-
```typescript
|
|
30
|
-
import type { FrameworkConfig } from 'arcway';
|
|
23
|
+
**arcway.config.js**
|
|
31
24
|
|
|
25
|
+
```js
|
|
32
26
|
export default {
|
|
33
27
|
database: {
|
|
34
28
|
client: 'sqlite',
|
|
35
29
|
connection: './dev.db',
|
|
36
30
|
},
|
|
37
|
-
}
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
**domains/users/config.ts**
|
|
41
|
-
|
|
42
|
-
```typescript
|
|
43
|
-
import type { DomainConfig } from 'arcway';
|
|
44
|
-
|
|
45
|
-
export default {
|
|
46
|
-
name: 'users',
|
|
47
|
-
tables: ['users'],
|
|
48
|
-
} satisfies DomainConfig;
|
|
31
|
+
};
|
|
49
32
|
```
|
|
50
33
|
|
|
51
|
-
**
|
|
34
|
+
**api/users/index.js**
|
|
52
35
|
|
|
53
|
-
```
|
|
54
|
-
import
|
|
36
|
+
```js
|
|
37
|
+
import { type } from 'arcway';
|
|
55
38
|
|
|
56
|
-
export const GET
|
|
39
|
+
export const GET = {
|
|
57
40
|
handler: async (ctx) => {
|
|
58
41
|
const users = await ctx.db('users').select('*');
|
|
59
42
|
return { data: users };
|
|
60
43
|
},
|
|
61
44
|
};
|
|
62
45
|
|
|
63
|
-
export const POST
|
|
46
|
+
export const POST = {
|
|
47
|
+
schema: {
|
|
48
|
+
body: type({
|
|
49
|
+
name: 'string >= 1',
|
|
50
|
+
email: 'string.email',
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
64
53
|
handler: async (ctx) => {
|
|
65
|
-
const [id] = await ctx.db('users').insert(ctx.body);
|
|
54
|
+
const [id] = await ctx.db('users').insert(ctx.req.body);
|
|
55
|
+
await ctx.events.emit('users/created', { userId: id });
|
|
66
56
|
return { status: 201, data: { id } };
|
|
67
57
|
},
|
|
68
58
|
};
|
|
@@ -80,58 +70,52 @@ This boots the full stack: database, migrations, route discovery, event bus, job
|
|
|
80
70
|
|
|
81
71
|
| Command | Description |
|
|
82
72
|
| ------------------------------- | ------------------------------------------------------------------ |
|
|
83
|
-
| `arcway dev`
|
|
84
|
-
| `arcway start`
|
|
85
|
-
| `arcway build [outDir]`
|
|
86
|
-
| `arcway seed`
|
|
87
|
-
| `arcway docs [outFile]`
|
|
88
|
-
| `arcway test [--watch] [pattern]` | Run
|
|
89
|
-
| `arcway lint`
|
|
73
|
+
| `arcway dev` | Start development server (console logging, CORS enabled) |
|
|
74
|
+
| `arcway start` | Start production server (JSON logging, health check at `/health`) |
|
|
75
|
+
| `arcway build [outDir]` | Build pages for production |
|
|
76
|
+
| `arcway seed` | Run database seed files from `seeds/` |
|
|
77
|
+
| `arcway docs [outFile]` | Generate OpenAPI spec from route schemas (default: `openapi.json`) |
|
|
78
|
+
| `arcway test [--watch] [pattern]` | Run tests |
|
|
79
|
+
| `arcway lint` | Check for boundary violations |
|
|
80
|
+
| `arcway migrate` | Run database migrations |
|
|
81
|
+
| `arcway schema` | Inspect database schema |
|
|
90
82
|
|
|
91
83
|
## Project Structure
|
|
92
84
|
|
|
93
85
|
```
|
|
94
86
|
project-root/
|
|
95
|
-
├── arcway.config.
|
|
96
|
-
├──
|
|
87
|
+
├── arcway.config.js # Framework configuration
|
|
88
|
+
├── api/ # HTTP route handlers (file-based routing)
|
|
97
89
|
│ ├── users/
|
|
98
|
-
│ │ ├──
|
|
99
|
-
│ │ ├──
|
|
100
|
-
│ │
|
|
101
|
-
│
|
|
102
|
-
│
|
|
103
|
-
│
|
|
104
|
-
|
|
105
|
-
│
|
|
106
|
-
│
|
|
107
|
-
|
|
108
|
-
│
|
|
109
|
-
|
|
110
|
-
│
|
|
111
|
-
|
|
112
|
-
│
|
|
113
|
-
|
|
114
|
-
│
|
|
115
|
-
│
|
|
116
|
-
│
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
└── seeds/ # Database seed files
|
|
123
|
-
└── 001_users.ts
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
Files starting with `_` (except `_middleware.ts`) are excluded from route/job/listener discovery.
|
|
90
|
+
│ │ ├── index.js # GET /users, POST /users
|
|
91
|
+
│ │ ├── [id].js # GET /users/:id, PUT /users/:id, DELETE /users/:id
|
|
92
|
+
│ │ └── _middleware.js # Middleware for all /users/* routes
|
|
93
|
+
│ └── projects/
|
|
94
|
+
│ ├── index.js # GET /projects, POST /projects
|
|
95
|
+
│ └── [id].js # GET /projects/:id, PUT /projects/:id
|
|
96
|
+
├── listeners/ # Event subscribers (folder path = event name)
|
|
97
|
+
│ └── users/
|
|
98
|
+
│ └── created.js # Handles 'users/created' event
|
|
99
|
+
├── jobs/ # Background job definitions
|
|
100
|
+
│ └── send-welcome-email.js
|
|
101
|
+
├── migrations/ # Database migrations (timestamp-ordered)
|
|
102
|
+
│ └── 20260101000000-create-users.js
|
|
103
|
+
├── seeds/ # Database seed files
|
|
104
|
+
│ └── 001_users.js
|
|
105
|
+
├── pages/ # SSR pages (optional)
|
|
106
|
+
│ ├── _layout.jsx # Root layout
|
|
107
|
+
│ ├── index.jsx # Home page
|
|
108
|
+
│ └── about.jsx # About page
|
|
109
|
+
└── lib/ # Shared logic (no framework magic)
|
|
110
|
+
└── users.js
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Files starting with `_` (except `_middleware.js` and `_layout.jsx`) are excluded from route/job/listener discovery.
|
|
127
114
|
|
|
128
115
|
## Configuration
|
|
129
116
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
```typescript
|
|
133
|
-
import type { FrameworkConfig } from 'arcway';
|
|
134
|
-
|
|
117
|
+
```js
|
|
118
|
+
// arcway.config.js
|
|
135
119
|
export default {
|
|
136
120
|
server: {
|
|
137
121
|
port: 3000,
|
|
@@ -139,42 +123,58 @@ export default {
|
|
|
139
123
|
maxBodySize: 1_048_576, // 1 MB
|
|
140
124
|
},
|
|
141
125
|
api: {
|
|
126
|
+
pathPrefix: '', // Prefix all API routes (e.g., '/api')
|
|
142
127
|
cors: {
|
|
143
|
-
// Or true/false
|
|
144
128
|
origin: ['https://app.com'],
|
|
145
129
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
146
130
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
147
|
-
exposedHeaders: ['X-Request-Id'],
|
|
148
131
|
credentials: true,
|
|
149
132
|
maxAge: 86400,
|
|
150
133
|
},
|
|
151
134
|
},
|
|
152
135
|
database: {
|
|
153
|
-
client: 'postgres',
|
|
136
|
+
client: 'postgres', // 'sqlite', 'postgres', 'mysql'
|
|
154
137
|
connection: 'postgres://user:pass@localhost/mydb',
|
|
155
138
|
},
|
|
139
|
+
session: {
|
|
140
|
+
password: 'at-least-32-character-secret-here!!',
|
|
141
|
+
cookieName: 'arcway.session',
|
|
142
|
+
ttl: 86400,
|
|
143
|
+
},
|
|
156
144
|
queue: {
|
|
157
|
-
driver: 'redis',
|
|
158
|
-
lockCooldownMs: 300_000,
|
|
145
|
+
driver: 'redis', // 'knex' (default) or 'redis'
|
|
159
146
|
redis: { url: 'redis://localhost:6379' },
|
|
160
147
|
},
|
|
161
148
|
cache: {
|
|
162
|
-
driver: 'redis',
|
|
149
|
+
driver: 'redis', // 'memory' (default) or 'redis'
|
|
163
150
|
defaultTtlMs: 60_000,
|
|
164
151
|
redis: { url: 'redis://localhost:6379' },
|
|
165
152
|
},
|
|
166
153
|
events: {
|
|
167
|
-
driver: 'redis',
|
|
154
|
+
driver: 'redis', // 'memory' (default) or 'redis'
|
|
168
155
|
redis: { url: 'redis://localhost:6379' },
|
|
169
156
|
},
|
|
170
157
|
files: {
|
|
171
|
-
driver: 's3',
|
|
158
|
+
driver: 's3', // 'local' (default) or 's3'
|
|
172
159
|
s3: {
|
|
173
160
|
bucket: 'my-bucket',
|
|
174
161
|
region: 'us-east-1',
|
|
175
162
|
},
|
|
176
163
|
},
|
|
177
|
-
|
|
164
|
+
mail: {
|
|
165
|
+
driver: 'smtp', // 'smtp' or 'console'
|
|
166
|
+
from: 'noreply@example.com',
|
|
167
|
+
host: 'smtp.example.com',
|
|
168
|
+
port: 587,
|
|
169
|
+
auth: { user: 'user', pass: 'pass' },
|
|
170
|
+
},
|
|
171
|
+
jobs: {
|
|
172
|
+
pollIntervalMs: 60_000,
|
|
173
|
+
},
|
|
174
|
+
logger: {
|
|
175
|
+
level: 'info', // 'debug', 'info', 'warn', 'error'
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
178
|
```
|
|
179
179
|
|
|
180
180
|
### CORS
|
|
@@ -188,79 +188,64 @@ Configure with `api.cors`:
|
|
|
188
188
|
|
|
189
189
|
- `true` — enable permissive CORS in any mode.
|
|
190
190
|
- `false` — disable CORS entirely.
|
|
191
|
-
-
|
|
191
|
+
- Object — use specific settings.
|
|
192
192
|
|
|
193
|
-
###
|
|
193
|
+
### Subsystem Toggles
|
|
194
194
|
|
|
195
|
-
|
|
196
|
-
import type { DomainConfig } from 'arcway';
|
|
197
|
-
import { z } from 'zod';
|
|
195
|
+
Any subsystem can be disabled:
|
|
198
196
|
|
|
197
|
+
```js
|
|
199
198
|
export default {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
/* Before server starts */
|
|
209
|
-
},
|
|
210
|
-
onReady: async (ctx) => {
|
|
211
|
-
/* After server is listening */
|
|
212
|
-
},
|
|
213
|
-
onShutdown: async (ctx) => {
|
|
214
|
-
/* Graceful shutdown */
|
|
215
|
-
},
|
|
216
|
-
},
|
|
217
|
-
} satisfies DomainConfig;
|
|
199
|
+
api: { enabled: false },
|
|
200
|
+
pages: { enabled: false },
|
|
201
|
+
jobs: { enabled: false },
|
|
202
|
+
events: { enabled: false },
|
|
203
|
+
mcp: { enabled: false },
|
|
204
|
+
websocket: { enabled: false },
|
|
205
|
+
mail: { enabled: false },
|
|
206
|
+
};
|
|
218
207
|
```
|
|
219
208
|
|
|
220
209
|
## Routes
|
|
221
210
|
|
|
222
|
-
Route files live in `
|
|
211
|
+
Route files live in `api/` and map to URL patterns by file path:
|
|
223
212
|
|
|
224
|
-
| File
|
|
225
|
-
|
|
|
226
|
-
| `api/index.
|
|
227
|
-
| `api/[id].
|
|
228
|
-
| `api/[id]/
|
|
229
|
-
| `api/admin/settings.
|
|
213
|
+
| File | URL Pattern |
|
|
214
|
+
| --------------------------- | ----------------------- |
|
|
215
|
+
| `api/users/index.js` | `/users` |
|
|
216
|
+
| `api/users/[id].js` | `/users/:id` |
|
|
217
|
+
| `api/users/[id]/posts.js` | `/users/:id/posts` |
|
|
218
|
+
| `api/admin/settings.js` | `/admin/settings` |
|
|
230
219
|
|
|
231
220
|
Export named constants for each HTTP method:
|
|
232
221
|
|
|
233
|
-
```
|
|
234
|
-
import {
|
|
235
|
-
import type { RouteMethodConfig } from 'arcway';
|
|
222
|
+
```js
|
|
223
|
+
import { type } from 'arcway';
|
|
236
224
|
|
|
237
|
-
export const GET
|
|
225
|
+
export const GET = {
|
|
238
226
|
schema: {
|
|
239
|
-
query:
|
|
227
|
+
query: type({ 'page?': 'number' }),
|
|
240
228
|
},
|
|
241
229
|
meta: {
|
|
242
230
|
summary: 'List users',
|
|
243
231
|
tags: ['users'],
|
|
244
232
|
},
|
|
245
233
|
handler: async (ctx) => {
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
.select('*')
|
|
249
|
-
.limit(20)
|
|
250
|
-
.offset((ctx.query.page - 1) * 20);
|
|
234
|
+
const page = ctx.req.query.page ?? 1;
|
|
235
|
+
const users = await ctx.db('users').select('*').limit(20).offset((page - 1) * 20);
|
|
251
236
|
return { data: users };
|
|
252
237
|
},
|
|
253
238
|
};
|
|
254
239
|
|
|
255
|
-
export const POST
|
|
240
|
+
export const POST = {
|
|
256
241
|
schema: {
|
|
257
|
-
body:
|
|
258
|
-
name:
|
|
259
|
-
email:
|
|
242
|
+
body: type({
|
|
243
|
+
name: 'string >= 1',
|
|
244
|
+
email: 'string.email',
|
|
260
245
|
}),
|
|
261
246
|
},
|
|
262
247
|
handler: async (ctx) => {
|
|
263
|
-
const { name, email } = ctx.body
|
|
248
|
+
const { name, email } = ctx.req.body;
|
|
264
249
|
const [id] = await ctx.db('users').insert({ name, email });
|
|
265
250
|
await ctx.events.emit('users/created', { userId: id });
|
|
266
251
|
return { status: 201, data: { id } };
|
|
@@ -268,197 +253,223 @@ export const POST: RouteMethodConfig = {
|
|
|
268
253
|
};
|
|
269
254
|
```
|
|
270
255
|
|
|
271
|
-
###
|
|
272
|
-
|
|
273
|
-
Every handler receives a `
|
|
274
|
-
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
db
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
256
|
+
### Handler Context
|
|
257
|
+
|
|
258
|
+
Every route handler receives a `ctx` object with infrastructure and request data:
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
// ctx contains:
|
|
262
|
+
{
|
|
263
|
+
db, // Knex database connection
|
|
264
|
+
events, // Event emitter (emit, subscribe)
|
|
265
|
+
queue, // Persistent queue (push, pop, remove)
|
|
266
|
+
cache, // Key-value cache (get, set, delete, wrap)
|
|
267
|
+
files, // File storage (write, read, delete, list, exists)
|
|
268
|
+
mail, // Email (send, queue)
|
|
269
|
+
log, // Logger (debug, info, warn, error)
|
|
270
|
+
req: {
|
|
271
|
+
requestId, // Unique request ID
|
|
272
|
+
method, // HTTP method
|
|
273
|
+
path, // URL path
|
|
274
|
+
query, // Validated query params (includes URL params like :id)
|
|
275
|
+
body, // Validated request body
|
|
276
|
+
headers, // Request headers
|
|
277
|
+
cookies, // Parsed cookies
|
|
278
|
+
session, // Session data (if configured)
|
|
279
|
+
},
|
|
288
280
|
}
|
|
289
281
|
```
|
|
290
282
|
|
|
291
283
|
### Route Response
|
|
292
284
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
error
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
285
|
+
Handlers return a response object:
|
|
286
|
+
|
|
287
|
+
```js
|
|
288
|
+
{
|
|
289
|
+
status: 200, // HTTP status (default: 200, or 400 if error)
|
|
290
|
+
data: { ... }, // Wrapped in { data: ... }
|
|
291
|
+
error: { // Wrapped in { error: ... }
|
|
292
|
+
code: 'NOT_FOUND',
|
|
293
|
+
message: 'User not found',
|
|
294
|
+
},
|
|
295
|
+
headers: { ... }, // Custom response headers
|
|
296
|
+
session: { userId: 42 }, // Set session (requires session config)
|
|
297
|
+
// session: null // Clear session
|
|
303
298
|
}
|
|
304
299
|
```
|
|
305
300
|
|
|
306
|
-
|
|
301
|
+
### Validation with ArkType
|
|
307
302
|
|
|
308
|
-
|
|
303
|
+
Arcway uses [ArkType](https://arktype.io) for schema validation:
|
|
309
304
|
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
import type { MiddlewareFn } from 'arcway';
|
|
305
|
+
```js
|
|
306
|
+
import { type } from 'arcway';
|
|
313
307
|
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
308
|
+
export const POST = {
|
|
309
|
+
schema: {
|
|
310
|
+
body: type({
|
|
311
|
+
name: 'string >= 1',
|
|
312
|
+
email: 'string.email',
|
|
313
|
+
'age?': 'number > 0',
|
|
314
|
+
}),
|
|
315
|
+
},
|
|
316
|
+
handler: async (ctx) => {
|
|
317
|
+
// ctx.req.body is validated and coerced
|
|
318
|
+
const { name, email, age } = ctx.req.body;
|
|
319
|
+
// ...
|
|
320
|
+
},
|
|
319
321
|
};
|
|
320
|
-
|
|
321
|
-
export default logger;
|
|
322
322
|
```
|
|
323
323
|
|
|
324
|
-
|
|
325
|
-
// domains/users/api/admin/_middleware.ts — applies to /users/admin/* only
|
|
326
|
-
import type { MiddlewareFn } from 'arcway';
|
|
324
|
+
Invalid requests automatically return `400` with a `VALIDATION_ERROR` code and field-level error details.
|
|
327
325
|
|
|
328
|
-
|
|
329
|
-
if (ctx.headers['authorization'] !== 'Bearer valid-token') {
|
|
330
|
-
return {
|
|
331
|
-
status: 401,
|
|
332
|
-
error: { code: 'UNAUTHORIZED', message: 'Invalid token' },
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
return next();
|
|
336
|
-
};
|
|
326
|
+
## Middleware
|
|
337
327
|
|
|
338
|
-
|
|
339
|
-
```
|
|
328
|
+
Place `_middleware.js` files in `api/` directories. Middleware applies to all routes at that level and below.
|
|
340
329
|
|
|
341
|
-
Middleware
|
|
330
|
+
Middleware must export an object (or array of objects) with a `handler` function:
|
|
342
331
|
|
|
343
|
-
```
|
|
344
|
-
|
|
332
|
+
```js
|
|
333
|
+
// api/_middleware.js — applies to all routes
|
|
334
|
+
export default {
|
|
335
|
+
handler: async (ctx) => {
|
|
336
|
+
const start = Date.now();
|
|
337
|
+
console.log(`${ctx.req.method} ${ctx.req.path} (${Date.now() - start}ms)`);
|
|
338
|
+
// return undefined to continue to next middleware/handler
|
|
339
|
+
},
|
|
340
|
+
};
|
|
345
341
|
```
|
|
346
342
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
1. `/users/api/_middleware.ts` (root)
|
|
350
|
-
2. `/users/api/admin/_middleware.ts` (admin)
|
|
351
|
-
3. Route handler
|
|
352
|
-
|
|
353
|
-
Return without calling `next()` to short-circuit (e.g., return 401).
|
|
354
|
-
|
|
355
|
-
## Services
|
|
356
|
-
|
|
357
|
-
Services enable cross-domain function calls with automatic context injection.
|
|
358
|
-
|
|
359
|
-
```typescript
|
|
360
|
-
// domains/users/services/index.ts
|
|
361
|
-
import type { DomainContext } from 'arcway';
|
|
362
|
-
|
|
343
|
+
```js
|
|
344
|
+
// api/admin/_middleware.js — applies to /admin/* only
|
|
363
345
|
export default {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
346
|
+
handler: async (ctx) => {
|
|
347
|
+
if (ctx.req.headers['authorization'] !== 'Bearer valid-token') {
|
|
348
|
+
// Return a response to short-circuit the chain
|
|
349
|
+
return {
|
|
350
|
+
status: 401,
|
|
351
|
+
error: { code: 'UNAUTHORIZED', message: 'Invalid token' },
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
// return undefined to continue
|
|
369
355
|
},
|
|
370
356
|
};
|
|
371
357
|
```
|
|
372
358
|
|
|
373
|
-
|
|
359
|
+
Middleware can also have schemas and export an array:
|
|
374
360
|
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
|
|
361
|
+
```js
|
|
362
|
+
import { type } from 'arcway';
|
|
363
|
+
|
|
364
|
+
export default [
|
|
365
|
+
{
|
|
366
|
+
schema: { query: type({ 'apiKey': 'string' }) },
|
|
367
|
+
handler: async (ctx) => {
|
|
368
|
+
if (!isValidKey(ctx.req.query.apiKey)) {
|
|
369
|
+
return { status: 403, error: { code: 'FORBIDDEN', message: 'Bad API key' } };
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
{ handler: async (ctx) => { /* logging */ } },
|
|
374
|
+
];
|
|
378
375
|
```
|
|
379
376
|
|
|
377
|
+
Middleware chains execute outermost-first. Return a response to short-circuit; return `undefined` to continue.
|
|
378
|
+
|
|
380
379
|
## Events
|
|
381
380
|
|
|
382
|
-
|
|
381
|
+
Emit events from any handler, listener, or job via `ctx.events`:
|
|
383
382
|
|
|
384
|
-
```
|
|
385
|
-
events:
|
|
386
|
-
{ name: 'users/created', schema: z.object({ userId: z.number() }) },
|
|
387
|
-
],
|
|
383
|
+
```js
|
|
384
|
+
await ctx.events.emit('users/created', { userId: 42 });
|
|
388
385
|
```
|
|
389
386
|
|
|
390
|
-
|
|
387
|
+
### Listeners
|
|
391
388
|
|
|
392
|
-
|
|
393
|
-
const results = await ctx.events.emit('users/created', { userId: 42 });
|
|
394
|
-
```
|
|
389
|
+
Listener files live in `listeners/`. The folder path maps to the event name:
|
|
395
390
|
|
|
396
|
-
|
|
391
|
+
| File | Event |
|
|
392
|
+
| ------------------------------ | --------------- |
|
|
393
|
+
| `listeners/users/created.js` | `users/created` |
|
|
394
|
+
| `listeners/orders/updated.js` | `orders/updated` |
|
|
397
395
|
|
|
398
|
-
|
|
396
|
+
Listeners export a default function that receives a context object:
|
|
399
397
|
|
|
400
|
-
|
|
398
|
+
```js
|
|
399
|
+
// listeners/users/created.js
|
|
400
|
+
export default async (ctx) => {
|
|
401
|
+
const { event, db } = ctx;
|
|
402
|
+
// event.name = 'users/created'
|
|
403
|
+
// event.payload = { userId: 42 }
|
|
404
|
+
await db('billing_accounts').insert({
|
|
405
|
+
user_id: event.payload.userId,
|
|
406
|
+
plan: 'free',
|
|
407
|
+
});
|
|
408
|
+
};
|
|
409
|
+
```
|
|
401
410
|
|
|
402
|
-
|
|
403
|
-
- **`handlerImmediate`** — runs in-process during `emit()`, before bus dispatch
|
|
411
|
+
The listener context includes all infrastructure (`db`, `events`, `cache`, `queue`, `files`, `mail`, `log`) plus `event: { name, payload }`.
|
|
404
412
|
|
|
405
|
-
|
|
413
|
+
### System Lifecycle Events
|
|
406
414
|
|
|
407
|
-
|
|
408
|
-
// domains/billing/listeners/on-user-created.ts (async — via bus)
|
|
409
|
-
import type { ListenerDefinition } from 'arcway';
|
|
415
|
+
Special listeners in `listeners/system/` handle lifecycle events:
|
|
410
416
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const { userId } = payload as { userId: number };
|
|
415
|
-
await ctx.db('billing_accounts').insert({ user_id: userId, balance: 0 });
|
|
416
|
-
},
|
|
417
|
-
} satisfies ListenerDefinition;
|
|
418
|
-
```
|
|
417
|
+
```js
|
|
418
|
+
// listeners/system/init.js — runs before server starts
|
|
419
|
+
export default async (ctx) => { /* setup */ };
|
|
419
420
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
import type { ListenerDefinition } from 'arcway';
|
|
421
|
+
// listeners/system/ready.js — runs after server is listening
|
|
422
|
+
export default async (ctx) => { /* post-boot */ };
|
|
423
423
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
handlerImmediate: async (ctx, payload) => {
|
|
427
|
-
const { userId } = payload as { userId: number };
|
|
428
|
-
const [org] = await ctx.db('orgs').insert({ owner_id: userId, name: 'My Org' });
|
|
429
|
-
if (!org) {
|
|
430
|
-
return { error: { code: 'ORG_CREATE_FAILED', message: 'Failed to create org' } };
|
|
431
|
-
}
|
|
432
|
-
},
|
|
433
|
-
} satisfies ListenerDefinition;
|
|
424
|
+
// listeners/system/shutdown.js — runs during graceful shutdown
|
|
425
|
+
export default async (ctx) => { /* cleanup */ };
|
|
434
426
|
```
|
|
435
427
|
|
|
436
|
-
Event patterns support wildcards: `'users/*'` matches all events from the users domain.
|
|
437
|
-
|
|
438
428
|
## Jobs
|
|
439
429
|
|
|
440
430
|
Background jobs support one-off queuing and cron scheduling.
|
|
441
431
|
|
|
442
|
-
```
|
|
443
|
-
//
|
|
444
|
-
import {
|
|
445
|
-
import type { JobDefinition } from 'arcway';
|
|
432
|
+
```js
|
|
433
|
+
// jobs/generate-invoice.js
|
|
434
|
+
import { type } from 'arcway';
|
|
446
435
|
|
|
447
436
|
export default {
|
|
448
437
|
name: 'generate-invoice',
|
|
449
|
-
schema:
|
|
438
|
+
schema: type({ userId: 'number', month: 'string' }),
|
|
450
439
|
retries: 3,
|
|
451
|
-
schedule: '0 0 1 * *',
|
|
452
|
-
handler: async (ctx
|
|
453
|
-
const { userId, month } = payload
|
|
440
|
+
schedule: '0 0 1 * *', // First of each month (cron)
|
|
441
|
+
handler: async (ctx) => {
|
|
442
|
+
const { userId, month } = ctx.payload;
|
|
454
443
|
// Generate invoice...
|
|
455
444
|
},
|
|
456
|
-
}
|
|
445
|
+
};
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Job handlers receive a context object with all infrastructure plus `payload`:
|
|
449
|
+
|
|
450
|
+
```js
|
|
451
|
+
// ctx contains: { db, events, cache, queue, files, mail, log, payload }
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Continuous Jobs
|
|
455
|
+
|
|
456
|
+
Jobs with `schedule: 'continuous'` run in a loop until stopped:
|
|
457
|
+
|
|
458
|
+
```js
|
|
459
|
+
export default {
|
|
460
|
+
name: 'process-queue',
|
|
461
|
+
schedule: 'continuous',
|
|
462
|
+
handler: async (ctx) => {
|
|
463
|
+
// Runs repeatedly. Backoff is applied on errors.
|
|
464
|
+
},
|
|
465
|
+
};
|
|
457
466
|
```
|
|
458
467
|
|
|
459
|
-
|
|
468
|
+
### Enqueue Jobs Programmatically
|
|
469
|
+
|
|
470
|
+
From any route handler or listener:
|
|
460
471
|
|
|
461
|
-
```
|
|
472
|
+
```js
|
|
462
473
|
await ctx.queue.push('generate-invoice', { userId: 42, month: '2025-01' });
|
|
463
474
|
```
|
|
464
475
|
|
|
@@ -466,9 +477,9 @@ Failed jobs retry with exponential backoff (1s, 2s, 4s, 8s...).
|
|
|
466
477
|
|
|
467
478
|
## Queue
|
|
468
479
|
|
|
469
|
-
|
|
480
|
+
Persistent queue for background processing:
|
|
470
481
|
|
|
471
|
-
```
|
|
482
|
+
```js
|
|
472
483
|
// Push work
|
|
473
484
|
await ctx.queue.push('email-send', { to: 'user@example.com', body: '...' });
|
|
474
485
|
|
|
@@ -484,9 +495,9 @@ Drivers: `knex` (default, database-backed) or `redis`.
|
|
|
484
495
|
|
|
485
496
|
## Cache
|
|
486
497
|
|
|
487
|
-
|
|
498
|
+
Key-value cache with TTL support:
|
|
488
499
|
|
|
489
|
-
```
|
|
500
|
+
```js
|
|
490
501
|
// Set with TTL
|
|
491
502
|
await ctx.cache.set('user:42', userData, 60_000);
|
|
492
503
|
|
|
@@ -496,9 +507,7 @@ const cached = await ctx.cache.get('user:42');
|
|
|
496
507
|
// Cache-aside pattern
|
|
497
508
|
const user = await ctx.cache.wrap(
|
|
498
509
|
'user:42',
|
|
499
|
-
async () => {
|
|
500
|
-
return ctx.db('users').where({ id: 42 }).first();
|
|
501
|
-
},
|
|
510
|
+
async () => ctx.db('users').where({ id: 42 }).first(),
|
|
502
511
|
60_000,
|
|
503
512
|
);
|
|
504
513
|
|
|
@@ -506,13 +515,13 @@ const user = await ctx.cache.wrap(
|
|
|
506
515
|
await ctx.cache.delete('user:42');
|
|
507
516
|
```
|
|
508
517
|
|
|
509
|
-
Drivers: `
|
|
518
|
+
Drivers: `memory` (default) or `redis`.
|
|
510
519
|
|
|
511
520
|
## File Storage
|
|
512
521
|
|
|
513
|
-
|
|
522
|
+
File operations via `ctx.files`:
|
|
514
523
|
|
|
515
|
-
```
|
|
524
|
+
```js
|
|
516
525
|
// Write
|
|
517
526
|
await ctx.files.write('avatars/user-42.png', imageBuffer);
|
|
518
527
|
|
|
@@ -531,22 +540,41 @@ await ctx.files.delete('avatars/user-42.png');
|
|
|
531
540
|
|
|
532
541
|
Drivers: `local` (default, filesystem) or `s3`.
|
|
533
542
|
|
|
543
|
+
## Mail
|
|
544
|
+
|
|
545
|
+
Send email via `ctx.mail`:
|
|
546
|
+
|
|
547
|
+
```js
|
|
548
|
+
await ctx.mail.send({
|
|
549
|
+
to: 'user@example.com',
|
|
550
|
+
subject: 'Welcome!',
|
|
551
|
+
html: '<h1>Hello</h1>',
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// Or queue for background sending
|
|
555
|
+
await ctx.mail.queue({
|
|
556
|
+
to: 'user@example.com',
|
|
557
|
+
subject: 'Invoice',
|
|
558
|
+
html: '<p>Your invoice is attached.</p>',
|
|
559
|
+
});
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
Drivers: `smtp` or `console` (logs to stdout).
|
|
563
|
+
|
|
534
564
|
## Rate Limiting
|
|
535
565
|
|
|
536
|
-
Rate limiting is available as a middleware factory
|
|
566
|
+
Rate limiting is available as a middleware factory:
|
|
537
567
|
|
|
538
|
-
```
|
|
539
|
-
//
|
|
568
|
+
```js
|
|
569
|
+
// api/_middleware.js
|
|
540
570
|
import { createRateLimitMiddleware, MemoryRateLimitStore } from 'arcway';
|
|
541
571
|
|
|
542
572
|
const store = new MemoryRateLimitStore();
|
|
543
573
|
|
|
544
574
|
export default createRateLimitMiddleware(
|
|
545
575
|
{
|
|
546
|
-
max: 100,
|
|
547
|
-
windowMs: 60_000,
|
|
548
|
-
// keyFn: (ctx) => ctx.headers['x-api-key'] ?? 'anonymous',
|
|
549
|
-
// message: 'Rate limit exceeded',
|
|
576
|
+
max: 100, // 100 requests
|
|
577
|
+
windowMs: 60_000, // per minute
|
|
550
578
|
},
|
|
551
579
|
store,
|
|
552
580
|
);
|
|
@@ -561,86 +589,115 @@ Response headers are added automatically:
|
|
|
561
589
|
- `X-RateLimit-Reset` — Unix timestamp when window resets
|
|
562
590
|
- `Retry-After` — seconds until retry (on 429 responses only)
|
|
563
591
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
```typescript
|
|
567
|
-
interface RateLimitStore {
|
|
568
|
-
check(key: string, max: number, windowMs: number): Promise<RateLimitResult>;
|
|
569
|
-
destroy(): void;
|
|
570
|
-
}
|
|
571
|
-
```
|
|
592
|
+
Stores: `MemoryRateLimitStore` (built-in) or `RedisRateLimitStore`.
|
|
572
593
|
|
|
573
|
-
##
|
|
594
|
+
## Sessions
|
|
574
595
|
|
|
575
|
-
|
|
596
|
+
Configure sessions in `arcway.config.js`:
|
|
576
597
|
|
|
577
|
-
```
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
598
|
+
```js
|
|
599
|
+
export default {
|
|
600
|
+
session: {
|
|
601
|
+
password: 'at-least-32-characters-long-secret!',
|
|
602
|
+
cookieName: 'arcway.session',
|
|
603
|
+
ttl: 86400,
|
|
604
|
+
},
|
|
605
|
+
};
|
|
581
606
|
```
|
|
582
607
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
## Domain Boundary Linting
|
|
586
|
-
|
|
587
|
-
`arcway lint` statically checks for illegal imports between domains:
|
|
588
|
-
|
|
589
|
-
```bash
|
|
590
|
-
$ arcway lint
|
|
591
|
-
Checking domain boundaries...
|
|
592
|
-
Domains: users, billing, projects
|
|
593
|
-
Files scanned: 24
|
|
608
|
+
Set session data from a route handler by returning `session`:
|
|
594
609
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
610
|
+
```js
|
|
611
|
+
export const POST = {
|
|
612
|
+
handler: async (ctx) => {
|
|
613
|
+
const user = await authenticate(ctx.req.body);
|
|
614
|
+
return {
|
|
615
|
+
data: user,
|
|
616
|
+
session: { userId: user.id, role: user.role },
|
|
617
|
+
};
|
|
618
|
+
},
|
|
619
|
+
};
|
|
600
620
|
```
|
|
601
621
|
|
|
602
|
-
|
|
622
|
+
Read session in subsequent requests via `ctx.req.session`. Clear by returning `session: null`.
|
|
603
623
|
|
|
604
624
|
## Testing
|
|
605
625
|
|
|
606
|
-
Arcway provides test
|
|
626
|
+
Arcway provides `Arcway.test()` for integration testing with an in-memory SQLite database:
|
|
607
627
|
|
|
608
|
-
```
|
|
628
|
+
```js
|
|
609
629
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
610
|
-
import {
|
|
630
|
+
import { Arcway } from 'arcway';
|
|
611
631
|
|
|
612
|
-
describe('users
|
|
613
|
-
let
|
|
632
|
+
describe('users API', () => {
|
|
633
|
+
let app;
|
|
614
634
|
|
|
615
635
|
beforeAll(async () => {
|
|
616
|
-
|
|
617
|
-
domain: 'users',
|
|
618
|
-
tables: ['users'],
|
|
619
|
-
migrationsDir: 'db/migrations',
|
|
620
|
-
});
|
|
636
|
+
app = await Arcway.test({ rootDir: './my-app' });
|
|
621
637
|
});
|
|
622
638
|
|
|
623
|
-
afterAll(() =>
|
|
639
|
+
afterAll(() => app.shutdown());
|
|
624
640
|
|
|
625
641
|
it('creates a user', async () => {
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
642
|
+
const res = await app.request('POST', '/users', {
|
|
643
|
+
body: { name: 'Alice', email: 'alice@test.com' },
|
|
644
|
+
});
|
|
645
|
+
expect(res.status).toBe(201);
|
|
646
|
+
expect(res.body.data.name).toBe('Alice');
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('lists users', async () => {
|
|
650
|
+
const res = await app.request('GET', '/users');
|
|
651
|
+
expect(res.status).toBe(200);
|
|
652
|
+
expect(res.body.data).toHaveLength(1);
|
|
629
653
|
});
|
|
630
654
|
});
|
|
631
655
|
```
|
|
632
656
|
|
|
633
|
-
|
|
657
|
+
`Arcway.test()` boots the full application with SQLite in-memory, random port, and MCP disabled. It returns:
|
|
634
658
|
|
|
635
|
-
|
|
659
|
+
- `app.request(method, path, opts)` — make HTTP requests
|
|
660
|
+
- `app.db` — direct database access for setup/assertions
|
|
661
|
+
- `app.run(fn)` — execute a function with infrastructure context
|
|
662
|
+
- `app.shutdown()` — clean up
|
|
663
|
+
|
|
664
|
+
### Unit Test Stubs
|
|
636
665
|
|
|
637
|
-
|
|
666
|
+
For unit testing without booting the full app:
|
|
638
667
|
|
|
639
|
-
```
|
|
640
|
-
|
|
641
|
-
import type { Knex } from 'knex';
|
|
668
|
+
```js
|
|
669
|
+
import { createTestContext } from 'arcway';
|
|
642
670
|
|
|
643
|
-
|
|
671
|
+
const { ctx, db, cleanup } = await createTestContext('mytest', {
|
|
672
|
+
migrationsDir: './migrations',
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// ctx has: { db, events, queue, cache, files, mail, log }
|
|
676
|
+
// All infrastructure is stubbed in-memory
|
|
677
|
+
|
|
678
|
+
await cleanup();
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
Individual stubs are also available:
|
|
682
|
+
|
|
683
|
+
```js
|
|
684
|
+
import {
|
|
685
|
+
createEventStub,
|
|
686
|
+
createQueueStub,
|
|
687
|
+
createCacheStub,
|
|
688
|
+
createFilesStub,
|
|
689
|
+
createMailStub,
|
|
690
|
+
createLoggerStub,
|
|
691
|
+
} from 'arcway';
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
## Seeds
|
|
695
|
+
|
|
696
|
+
Seed files live in `seeds/` and run in alphabetical order:
|
|
697
|
+
|
|
698
|
+
```js
|
|
699
|
+
// seeds/001_users.js
|
|
700
|
+
export default async function seed(db) {
|
|
644
701
|
await db('users')
|
|
645
702
|
.insert([
|
|
646
703
|
{ id: 1, name: 'Alice', email: 'alice@test.com' },
|
|
@@ -663,49 +720,51 @@ arcway docs openapi.json
|
|
|
663
720
|
|
|
664
721
|
Routes with `meta` and `schema` fields produce documented endpoints:
|
|
665
722
|
|
|
666
|
-
```
|
|
667
|
-
export const GET
|
|
723
|
+
```js
|
|
724
|
+
export const GET = {
|
|
668
725
|
schema: {
|
|
669
|
-
|
|
670
|
-
query: z.object({ fields: z.string().optional() }),
|
|
726
|
+
query: type({ id: /^\d+$/ }),
|
|
671
727
|
},
|
|
672
728
|
meta: {
|
|
673
729
|
summary: 'Get user by ID',
|
|
674
730
|
description: 'Returns a single user record.',
|
|
675
731
|
tags: ['users'],
|
|
676
732
|
},
|
|
677
|
-
handler: async (ctx) => {
|
|
678
|
-
/* ... */
|
|
679
|
-
},
|
|
733
|
+
handler: async (ctx) => { /* ... */ },
|
|
680
734
|
};
|
|
681
735
|
```
|
|
682
736
|
|
|
683
|
-
##
|
|
737
|
+
## Pages (SSR)
|
|
684
738
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
739
|
+
Arcway supports server-side rendered React pages with file-based routing:
|
|
740
|
+
|
|
741
|
+
```
|
|
742
|
+
pages/
|
|
743
|
+
├── _layout.jsx # Root layout (wraps all pages)
|
|
744
|
+
├── index.jsx # / route
|
|
745
|
+
├── about.jsx # /about route
|
|
746
|
+
├── blog/
|
|
747
|
+
│ ├── _layout.jsx # Blog layout (wraps blog pages)
|
|
748
|
+
│ ├── index.jsx # /blog route
|
|
749
|
+
│ └── [slug].jsx # /blog/:slug route
|
|
750
|
+
└── _404.jsx # Custom 404 page
|
|
688
751
|
```
|
|
689
752
|
|
|
690
|
-
|
|
753
|
+
Pages are React components with automatic hydration, layout nesting, and client-side navigation.
|
|
691
754
|
|
|
692
755
|
## Boot Sequence
|
|
693
756
|
|
|
694
757
|
When `arcway dev` or `arcway start` runs, the framework:
|
|
695
758
|
|
|
696
|
-
1. Loads
|
|
697
|
-
2.
|
|
759
|
+
1. Loads environment files (`.env`, `.env.local`, `.env.{mode}`)
|
|
760
|
+
2. Loads `arcway.config.js`
|
|
698
761
|
3. Connects to the database and runs migrations
|
|
699
|
-
4. Initializes queue, cache, and
|
|
700
|
-
5. Creates
|
|
701
|
-
6. Discovers
|
|
702
|
-
7.
|
|
703
|
-
8.
|
|
704
|
-
9.
|
|
705
|
-
10.
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
13. Runs `onReady` hooks
|
|
709
|
-
14. Starts job runner (cron scheduler)
|
|
710
|
-
|
|
711
|
-
Graceful shutdown reverses the process: stops the job runner, runs `onShutdown` hooks, drains connections, and closes the server.
|
|
762
|
+
4. Initializes Redis, queue, cache, file, and mail drivers
|
|
763
|
+
5. Creates event bus and registers listeners
|
|
764
|
+
6. Discovers and registers jobs
|
|
765
|
+
7. Discovers routes and middleware
|
|
766
|
+
8. Builds pages (if `pages/` exists)
|
|
767
|
+
9. Creates and starts HTTP server
|
|
768
|
+
10. Starts job runner (cron scheduler + continuous jobs)
|
|
769
|
+
|
|
770
|
+
Graceful shutdown reverses the process: stops the job runner, drains connections, and closes the server.
|