arcway 0.1.0 → 0.1.2

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 (3) hide show
  1. package/README.md +417 -358
  2. package/client/ws.js +2 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # Arcway
2
2
 
3
- A convention-based TypeScript framework for building modular monoliths with strict domain boundaries.
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.ts
18
- ├── domains/
15
+ ├── arcway.config.js
16
+ ├── api/
19
17
  │ └── users/
20
- ├── config.ts
21
- └── api/
22
- └── index.ts
23
- └── db/
24
- └── migrations/
18
+ └── index.js
19
+ └── migrations/
20
+ └── 20260101000000-create-users.js
25
21
  ```
26
22
 
27
- **arcway.config.ts**
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
- } satisfies FrameworkConfig;
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
- **domains/users/api/index.ts**
34
+ **api/users/index.js**
52
35
 
53
- ```typescript
54
- import type { RouteMethodConfig } from 'arcway';
36
+ ```js
37
+ import { type } from 'arcway';
55
38
 
56
- export const GET: RouteMethodConfig = {
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: RouteMethodConfig = {
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` | Start development server (console logging, CORS enabled) |
84
- | `arcway start` | Start production server (JSON logging, health check at `/health`) |
85
- | `arcway build [outDir]` | Compile TypeScript to production bundle (default: `dist`) |
86
- | `arcway seed` | Run database seed files from `db/seeds/` |
87
- | `arcway docs [outFile]` | Generate OpenAPI spec from route schemas (default: `openapi.json`) |
88
- | `arcway test [--watch] [pattern]` | Run domain tests (`domains/*/tests/**/*.test.ts`) |
89
- | `arcway lint` | Check for domain boundary violations |
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.ts # Framework configuration
96
- ├── domains/
87
+ ├── arcway.config.js # Framework configuration
88
+ ├── api/ # HTTP route handlers (file-based routing)
97
89
  │ ├── users/
98
- │ │ ├── config.ts # Domain name, owned tables, events, hooks
99
- │ │ ├── services/index.ts # Exported service functions
100
- │ │ ├── api/ # HTTP route handlers
101
- │ │ ├── index.ts # GET /users, POST /users
102
- │ │ ├── [id].ts # GET /users/:id, PUT /users/:id
103
- │ │ ├── [id]/projects.ts # GET /users/:id/projects
104
- │ │ │ ├── admin/settings.ts # GET /users/admin/settings
105
- │ │ ├── _middleware.ts # Middleware for all /users/* routes
106
- │ │ └── admin/
107
- │ │ │ └── _middleware.ts # Middleware for /users/admin/* only
108
- │ ├── jobs/ # Background job definitions
109
- │ │ │ └── send-welcome.ts
110
- │ ├── listeners/ # Event subscribers
111
- │ │ │ └── on-signup.ts
112
- └── tests/ # Domain unit tests
113
- │ │ └── users.test.ts
114
- └── billing/
115
- ├── config.ts
116
- ├── services/index.ts
117
- └── listeners/
118
- └── on-user-created.ts
119
- └── db/
120
- ├── migrations/ # Knex migration files
121
- │ └── 001_create_tables.js
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
- ### Framework Config
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', // 'knex' (default) or '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', // 'memory' (default) or 'redis'
154
+ driver: 'redis', // 'memory' (default) or 'redis'
168
155
  redis: { url: 'redis://localhost:6379' },
169
156
  },
170
157
  files: {
171
- driver: 's3', // 'local' (default) or '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
- } satisfies FrameworkConfig;
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
- - `CorsConfig` object — use specific settings.
191
+ - Object — use specific settings.
192
192
 
193
- ### Domain Config
193
+ ### Subsystem Toggles
194
194
 
195
- ```typescript
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
- name: 'users',
201
- tables: ['users', 'sessions'],
202
- events: [
203
- { name: 'users/created', schema: z.object({ userId: z.number() }) },
204
- { name: 'users/deleted' },
205
- ],
206
- hooks: {
207
- onInit: async (ctx) => {
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 `domains/<name>/api/` and map to URL patterns by file path:
211
+ Route files live in `api/` and map to URL patterns by file path:
223
212
 
224
- | File | URL Pattern |
225
- | ----------------------- | -------------------------- |
226
- | `api/index.ts` | `/<domain>` |
227
- | `api/[id].ts` | `/<domain>/:id` |
228
- | `api/[id]/projects.ts` | `/<domain>/:id/projects` |
229
- | `api/admin/settings.ts` | `/<domain>/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
- ```typescript
234
- import { z } from 'zod';
235
- import type { RouteMethodConfig } from 'arcway';
222
+ ```js
223
+ import { type } from 'arcway';
236
224
 
237
- export const GET: RouteMethodConfig = {
225
+ export const GET = {
238
226
  schema: {
239
- query: z.object({ page: z.coerce.number().default(1) }),
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 users = await ctx
247
- .db('users')
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: RouteMethodConfig = {
240
+ export const POST = {
256
241
  schema: {
257
- body: z.object({
258
- name: z.string().min(1),
259
- email: z.string().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 as { name: string; email: string };
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
- ### Route Context
272
-
273
- Every handler receives a `RouteContext`:
274
-
275
- ```typescript
276
- interface RouteContext {
277
- domain: string; // Domain name
278
- db: Knex; // Scoped database connection
279
- services: ServicesProxy; // Cross-domain service calls
280
- events: DomainEvents; // Event emission
281
- queue: DomainQueue; // Persistent queue
282
- cache: DomainCache; // Key-value cache
283
- files: DomainFiles; // File storage
284
- params: Record<string, string>; // URL parameters (:id)
285
- query: Record<string, string>; // Query string (?key=value)
286
- body: unknown; // Parsed request body
287
- headers: Record<string, string>; // Request headers
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
- ```typescript
294
- interface RouteResponse {
295
- status?: number; // Default: 200 (or 400 if error)
296
- data?: unknown; // Wrapped in { data: ... }
297
- error?: {
298
- code: string;
299
- message: string;
300
- details?: unknown;
301
- };
302
- headers?: Record<string, string>;
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
- ## Middleware
301
+ ### Validation with ArkType
307
302
 
308
- Place `_middleware.ts` files in `api/` directories. Middleware applies to all routes at that level and below.
303
+ Arcway uses [ArkType](https://arktype.io) for schema validation:
309
304
 
310
- ```typescript
311
- // domains/users/api/_middleware.ts applies to all /users/* routes
312
- import type { MiddlewareFn } from 'arcway';
305
+ ```js
306
+ import { type } from 'arcway';
313
307
 
314
- const logger: MiddlewareFn = async (ctx, next) => {
315
- const start = Date.now();
316
- const response = await next();
317
- console.log(`${ctx.headers['method']} took ${Date.now() - start}ms`);
318
- return response;
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
- ```typescript
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
- const auth: MiddlewareFn = async (ctx, next) => {
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
- export default auth;
339
- ```
328
+ Place `_middleware.js` files in `api/` directories. Middleware applies to all routes at that level and below.
340
329
 
341
- Middleware can also export an array of functions:
330
+ Middleware must export an object (or array of objects) with a `handler` function:
342
331
 
343
- ```typescript
344
- export default [loggerMiddleware, authMiddleware];
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
- Middleware chains execute outermost-first. A request to `/users/admin/settings` runs:
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
- getById: async (ctx: DomainContext, id: string) => {
365
- return ctx.db('users').where({ id }).first();
366
- },
367
- list: async (ctx: DomainContext) => {
368
- return ctx.db('users').select('*');
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
- Call from another domain `ctx` is injected automatically:
359
+ Middleware can also have schemas and export an array:
374
360
 
375
- ```typescript
376
- // In a billing route handler
377
- const user = await ctx.services.users.getById(userId);
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
- Domains declare events they can emit in `config.ts`:
381
+ Emit events from any handler, listener, or job via `ctx.events`:
383
382
 
384
- ```typescript
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
- Emit from any handler, service, or job:
387
+ ### Listeners
391
388
 
392
- ```typescript
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
- Always `await` the emit call — immediate handlers run in-process during emit and their results are returned as an array of `EventResult`.
391
+ | File | Event |
392
+ | ------------------------------ | --------------- |
393
+ | `listeners/users/created.js` | `users/created` |
394
+ | `listeners/orders/updated.js` | `orders/updated` |
397
395
 
398
- ### Listeners
396
+ Listeners export a default function that receives a context object:
399
397
 
400
- Listeners define how they handle events via two handler functions:
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
- - **`handler`** dispatched via the event bus (eventually consistent, fire-and-forget)
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
- At least one must be defined. Both can be defined on the same listener.
413
+ ### System Lifecycle Events
406
414
 
407
- ```typescript
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
- export default {
412
- event: 'users/created',
413
- handler: async (ctx, payload) => {
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
- ```typescript
421
- // domains/orgs/listeners/on-user-created.ts (immediate in-process)
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
- export default {
425
- event: 'users/created',
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
- ```typescript
443
- // domains/billing/jobs/generate-invoice.ts
444
- import { z } from 'zod';
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: z.object({ userId: z.number(), month: z.string() }),
438
+ schema: type({ userId: 'number', month: 'string' }),
450
439
  retries: 3,
451
- schedule: '0 0 1 * *', // First of each month
452
- handler: async (ctx, payload) => {
453
- const { userId, month } = payload as { userId: number; month: string };
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
- } satisfies JobDefinition;
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
- Queue a job from any handler:
468
+ ### Enqueue Jobs Programmatically
469
+
470
+ From any route handler or listener:
460
471
 
461
- ```typescript
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
- Domain-scoped persistent queue for background processing:
480
+ Persistent queue for background processing:
470
481
 
471
- ```typescript
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
- Domain-scoped key-value cache with TTL support:
498
+ Key-value cache with TTL support:
488
499
 
489
- ```typescript
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: `knex` (default) or `redis`.
518
+ Drivers: `memory` (default) or `redis`.
510
519
 
511
520
  ## File Storage
512
521
 
513
- Domain-scoped file operations:
522
+ File operations via `ctx.files`:
514
523
 
515
- ```typescript
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 with pluggable backends:
566
+ Rate limiting is available as a middleware factory:
537
567
 
538
- ```typescript
539
- // domains/api-gateway/api/_middleware.ts
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, // 100 requests
547
- windowMs: 60_000, // per minute
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
- Implement `RateLimitStore` for custom backends (Redis, etc.):
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
- ## Database Access Control
594
+ ## Sessions
574
595
 
575
- Domains can only write to tables listed in their `config.ts`. Attempting to write to another domain's table throws `DomainAccessViolation`:
596
+ Configure sessions in `arcway.config.js`:
576
597
 
577
- ```typescript
578
- // In users domain (tables: ['users', 'sessions'])
579
- await ctx.db('users').insert({ name: 'Alice' }); // OK
580
- await ctx.db('billing').insert({ amount: 100 }); // Throws DomainAccessViolation
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
- Read access is unrestricted. Cross-domain writes go through services.
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
- 1 violation(s) found:
596
-
597
- domains/billing/services/index.ts:3
598
- Domain 'billing' imports directly from domain 'users'
599
- Import: ../../users/services/index.ts
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
- Domains must communicate through `ctx.services`, `ctx.events`, or `ctx.queue` never by importing each other's files directly.
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 utilities that create isolated domain contexts with in-memory SQLite:
626
+ Arcway provides `Arcway.test()` for integration testing with an in-memory SQLite database:
607
627
 
608
- ```typescript
628
+ ```js
609
629
  import { describe, it, expect, beforeAll, afterAll } from 'vitest';
610
- import { createTestContext, type TestContext } from 'arcway';
630
+ import { Arcway } from 'arcway';
611
631
 
612
- describe('users service', () => {
613
- let t: TestContext;
632
+ describe('users API', () => {
633
+ let app;
614
634
 
615
635
  beforeAll(async () => {
616
- t = await createTestContext({
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(() => t.cleanup());
639
+ afterAll(() => app.shutdown());
624
640
 
625
641
  it('creates a user', async () => {
626
- const [id] = await t.ctx.db('users').insert({ name: 'Alice' });
627
- const user = await t.ctx.db('users').where({ id }).first();
628
- expect(user.name).toBe('Alice');
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
- The test context stubs all infrastructure (events, queue, cache, files) so tests run fast and in isolation.
657
+ `Arcway.test()` boots the full application with SQLite in-memory, random port, and MCP disabled. It returns:
634
658
 
635
- ## Seeds
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
- Seed files live in `db/seeds/` and run in alphabetical order:
666
+ For unit testing without booting the full app:
638
667
 
639
- ```typescript
640
- // db/seeds/001_users.ts
641
- import type { Knex } from 'knex';
668
+ ```js
669
+ import { createTestContext } from 'arcway';
642
670
 
643
- export default async function seed(db: Knex): Promise<void> {
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
- ```typescript
667
- export const GET: RouteMethodConfig = {
723
+ ```js
724
+ export const GET = {
668
725
  schema: {
669
- params: z.object({ id: z.string() }),
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
- ## Production Build
737
+ ## Pages (SSR)
684
738
 
685
- ```bash
686
- arcway build # Compile to dist/
687
- cd dist && arcway start
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
- The build step compiles TypeScript with esbuild, copies non-TS files (migrations, JSON), and preserves the directory structure. The production server uses native `import()` — no tsx required at runtime.
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 `arcway.config.ts`
697
- 2. Discovers all domains in `domains/`
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 file drivers
700
- 5. Creates scoped domain contexts
701
- 6. Discovers services and wires cross-domain proxies
702
- 7. Creates event bus and registers listeners
703
- 8. Discovers and registers jobs
704
- 9. Runs `onInit` hooks
705
- 10. Discovers routes and middleware
706
- 11. Resolves CORS configuration
707
- 12. Creates and starts HTTP server
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.
package/client/ws.js CHANGED
@@ -1,5 +1,3 @@
1
- import { io } from 'socket.io-client';
2
-
3
1
  class WsManager {
4
2
  socket = null;
5
3
  socketId = null;
@@ -25,10 +23,11 @@ class WsManager {
25
23
  return !!this.socket?.connected && this.socketId !== null;
26
24
  }
27
25
 
28
- connect() {
26
+ async connect() {
29
27
  if (this.socket) return;
30
28
 
31
29
  try {
30
+ const { io } = await import('socket.io-client');
32
31
  const parsed = new URL(this.url);
33
32
  this.socket = io(parsed.origin, {
34
33
  path: this.wsPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",