arcway 0.1.9 → 0.1.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/server/bin/cli.js CHANGED
@@ -11,6 +11,7 @@ import registerLint from './commands/lint.js';
11
11
  import registerGraphQLSchema from './commands/graphql-schema.js';
12
12
  import registerSchema from './commands/schema.js';
13
13
  import registerMigrate from './commands/migrate.js';
14
+ import registerBootstrap from './commands/bootstrap.js';
14
15
 
15
16
  function getPackageVersion() {
16
17
  try {
@@ -36,6 +37,7 @@ function createProgram() {
36
37
  registerGraphQLSchema(program);
37
38
  registerSchema(program);
38
39
  registerMigrate(program);
40
+ registerBootstrap(program);
39
41
  return program;
40
42
  }
41
43
 
@@ -0,0 +1,147 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const FILES = {
5
+ 'arcway.config.js': `export default {
6
+ database: {
7
+ client: 'better-sqlite3',
8
+ connection: { filename: './data.db' },
9
+ },
10
+ };
11
+ `,
12
+
13
+ 'api/index.js': `export const GET = {
14
+ handler: async (ctx) => {
15
+ return { data: { message: 'Hello from Arcway!' } };
16
+ },
17
+ };
18
+ `,
19
+
20
+ 'api/users/index.js': `export const GET = {
21
+ handler: async (ctx) => {
22
+ const { db } = ctx;
23
+ const users = await db('users').select('*');
24
+ return { data: users };
25
+ },
26
+ };
27
+
28
+ export const POST = {
29
+ schema: {
30
+ body: { name: 'string', email: 'string.email' },
31
+ },
32
+ handler: async (ctx) => {
33
+ const { req, db, events } = ctx;
34
+ const [id] = await db('users').insert(req.body);
35
+ await events.emit('users/created', { id, ...req.body });
36
+ return { status: 201, data: { id } };
37
+ },
38
+ };
39
+ `,
40
+
41
+ 'api/users/[id].js': `export const GET = {
42
+ handler: async (ctx) => {
43
+ const { req, db } = ctx;
44
+ const user = await db('users').where('id', req.query.id).first();
45
+ if (!user) return { status: 404, error: 'User not found' };
46
+ return { data: user };
47
+ },
48
+ };
49
+ `,
50
+
51
+ 'listeners/users/created.js': `export default async (ctx) => {
52
+ const { event, log } = ctx;
53
+ log.info('New user created', { userId: event.payload.id });
54
+ };
55
+ `,
56
+
57
+ 'jobs/cleanup-sessions.js': `export default {
58
+ schedule: '0 3 * * *', // Every day at 3 AM
59
+ handler: async (ctx) => {
60
+ const { db, log } = ctx;
61
+ const deleted = await db('sessions')
62
+ .where('expires_at', '<', new Date().toISOString())
63
+ .delete();
64
+ log.info('Cleaned up expired sessions', { deleted });
65
+ },
66
+ };
67
+ `,
68
+
69
+ 'migrations/202601010000-create-users.js': `export async function up(knex) {
70
+ await knex.schema.createTable('users', (table) => {
71
+ table.increments('id').primary();
72
+ table.string('name').notNullable();
73
+ table.string('email').notNullable().unique();
74
+ table.timestamps(true, true);
75
+ });
76
+ }
77
+
78
+ export async function down(knex) {
79
+ await knex.schema.dropTableIfExists('users');
80
+ }
81
+ `,
82
+
83
+ 'pages/index.jsx': `export default function Home() {
84
+ return (
85
+ <div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
86
+ <h1>Welcome to Arcway</h1>
87
+ <p>Edit <code>pages/index.jsx</code> to get started.</p>
88
+ </div>
89
+ );
90
+ }
91
+ `,
92
+
93
+ '.env': `# NODE_ENV=development
94
+ # SESSION_PASSWORD=change-me-to-a-random-32-char-string
95
+ `,
96
+
97
+ '.gitignore': `node_modules/
98
+ .build/
99
+ data.db
100
+ .env.local
101
+ .env.*.local
102
+ `,
103
+ };
104
+
105
+ function bootstrap(rootDir) {
106
+ let created = 0;
107
+ let skipped = 0;
108
+
109
+ for (const [relativePath, content] of Object.entries(FILES)) {
110
+ const fullPath = join(rootDir, relativePath);
111
+ if (existsSync(fullPath)) {
112
+ console.log(` skip ${relativePath} (already exists)`);
113
+ skipped++;
114
+ continue;
115
+ }
116
+ const dir = join(fullPath, '..');
117
+ mkdirSync(dir, { recursive: true });
118
+ writeFileSync(fullPath, content);
119
+ console.log(` create ${relativePath}`);
120
+ created++;
121
+ }
122
+
123
+ return { created, skipped };
124
+ }
125
+
126
+ function register(program) {
127
+ program
128
+ .command('bootstrap')
129
+ .description('Scaffold a new Arcway project in the current directory')
130
+ .action(() => {
131
+ const rootDir = process.cwd();
132
+ console.log(`\nBootstrapping Arcway project in ${rootDir}\n`);
133
+
134
+ const { created, skipped } = bootstrap(rootDir);
135
+
136
+ console.log(`\n ${created} file(s) created, ${skipped} skipped\n`);
137
+
138
+ if (created > 0) {
139
+ console.log('Next steps:');
140
+ console.log(' npm install arcway better-sqlite3');
141
+ console.log(' npx arcway dev\n');
142
+ }
143
+ });
144
+ }
145
+
146
+ export default register;
147
+ export { bootstrap };
@@ -3,6 +3,7 @@ const DEFAULTS = {
3
3
  port: 3000,
4
4
  maxBodySize: 1024 * 1024,
5
5
  shutdownTimeoutMs: 10000,
6
+ trustProxy: false,
6
7
  };
7
8
 
8
9
  const DEFAULT_CORS = {
@@ -17,14 +17,14 @@ function createGraphQLHandler(options) {
17
17
  cors: false,
18
18
  context: async ({ request }) => {
19
19
  const loaders = makeLoaders();
20
- const requestId = request.headers.get('x-request-id') ?? randomUUID();
20
+ const id = request.headers.get('x-request-id') ?? randomUUID();
21
21
  const headers = {};
22
22
  request.headers.forEach((value, key) => {
23
23
  headers[key] = value;
24
24
  });
25
25
  const cookies = parseCookiesFromHeader(request.headers.get('cookie') ?? '');
26
26
  const session = await resolveSession(cookies, sessionConfig);
27
- return { loaders, session, requestId, headers, cookies };
27
+ return { loaders, session, id, headers, cookies };
28
28
  },
29
29
  logging: log
30
30
  ? {
@@ -140,7 +140,7 @@ class ApiRouter {
140
140
  return buildContext(
141
141
  {
142
142
  ...this._appContext,
143
- log: this._log.extend({ requestId: reqInfo.requestId }),
143
+ log: this._log.extend({ requestId: reqInfo.id }),
144
144
  },
145
145
  { req: reqInfo },
146
146
  );
@@ -197,7 +197,8 @@ class ApiRouter {
197
197
  async handle(req, res) {
198
198
  const method = req.method ?? 'GET';
199
199
  const pathname = (req.url ?? '/').split('?')[0];
200
- const requestId = req.requestId;
200
+ const id = req.id;
201
+ const ip = req.ip;
201
202
  const startTime = Date.now();
202
203
 
203
204
  const matched = matchRoute(this._routes, method, pathname);
@@ -205,20 +206,25 @@ class ApiRouter {
205
206
 
206
207
  const { route, params } = matched;
207
208
 
209
+ // ── Body parsing control ──
210
+ const body = route.config.parseBody === false ? req.rawBody : req.body;
211
+
208
212
  // ── Validation ──
209
213
  const mergedQuery = { ...(req.query ?? {}), ...params };
210
- const validated = validateRequest(route.config.schema, mergedQuery, req.body);
214
+ const validated = validateRequest(route.config.schema, mergedQuery, body);
211
215
  if (validated.error) {
212
216
  sendJson(res, 400, { error: validated.error });
213
217
  return true;
214
218
  }
215
219
 
216
220
  const reqInfo = {
217
- requestId,
221
+ id,
222
+ ip,
218
223
  method,
219
224
  path: pathname,
220
225
  query: validated.query,
221
226
  body: validated.body,
227
+ rawBody: req.rawBody,
222
228
  headers: req.flatHeaders ?? flattenHeaders(req.headers),
223
229
  cookies: req.cookies ?? {},
224
230
  session: req.session,
@@ -83,12 +83,23 @@ class WebServer {
83
83
  }
84
84
  }
85
85
 
86
+ const trustProxy = config.server?.trustProxy ?? false;
87
+
86
88
  const requestHandler = (req, res) => {
87
89
  // ── Request ID ──
88
90
  const incomingId = req.headers['x-request-id'];
89
- const requestId = (Array.isArray(incomingId) ? incomingId[0] : incomingId) ?? randomUUID();
90
- res.setHeader('X-Request-Id', requestId);
91
- req.requestId = requestId;
91
+ req.id = (Array.isArray(incomingId) ? incomingId[0] : incomingId) ?? randomUUID();
92
+ res.setHeader('X-Request-Id', req.id);
93
+
94
+ // ── Client IP ──
95
+ if (trustProxy) {
96
+ const forwarded = req.headers['x-forwarded-for'];
97
+ req.ip = forwarded
98
+ ? String(forwarded).split(',')[0].trim()
99
+ : req.headers['x-real-ip'] ?? req.socket?.remoteAddress ?? '127.0.0.1';
100
+ } else {
101
+ req.ip = req.socket?.remoteAddress ?? '127.0.0.1';
102
+ }
92
103
 
93
104
  // ── CORS ──
94
105
  if (corsConfig) {
@@ -124,7 +135,7 @@ class WebServer {
124
135
  res.end(JSON.stringify({ error: { code: ErrorCodes.INVALID_JSON, message: 'Request body is not valid JSON' } }));
125
136
  return;
126
137
  }
127
- log.error('Unhandled error in request handler', { error: msg, requestId });
138
+ log.error('Unhandled error in request handler', { error: msg, requestId: req.id });
128
139
  res.writeHead(500, { 'Content-Type': 'application/json' });
129
140
  res.end(JSON.stringify({ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } }));
130
141
  });
@@ -177,6 +188,7 @@ class WebServer {
177
188
  // Body (POST/PUT/PATCH only)
178
189
  if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
179
190
  const rawBody = await readBody(req, maxBodySize);
191
+ req.rawBody = rawBody;
180
192
  if (rawBody.length > 0) {
181
193
  const contentType = req.headers['content-type'] ?? '';
182
194
  if (contentType.includes('application/json')) {
@@ -22,16 +22,16 @@ function createRealtimeServer(options) {
22
22
  function buildCtx(reqInfo) {
23
23
  const requestLog = {
24
24
  debug(message, data) {
25
- appContext.log.debug(message, { ...data, requestId: reqInfo.requestId });
25
+ appContext.log.debug(message, { ...data, requestId: reqInfo.id });
26
26
  },
27
27
  info(message, data) {
28
- appContext.log.info(message, { ...data, requestId: reqInfo.requestId });
28
+ appContext.log.info(message, { ...data, requestId: reqInfo.id });
29
29
  },
30
30
  warn(message, data) {
31
- appContext.log.warn(message, { ...data, requestId: reqInfo.requestId });
31
+ appContext.log.warn(message, { ...data, requestId: reqInfo.id });
32
32
  },
33
33
  error(message, data) {
34
- appContext.log.error(message, { ...data, requestId: reqInfo.requestId });
34
+ appContext.log.error(message, { ...data, requestId: reqInfo.id });
35
35
  },
36
36
  };
37
37
  return buildContext({ ...appContext, log: requestLog }, { req: reqInfo });
@@ -70,7 +70,7 @@ function createRealtimeServer(options) {
70
70
  ...params,
71
71
  };
72
72
  return {
73
- requestId: randomUUID(),
73
+ id: randomUUID(),
74
74
  method: wsMsg.method,
75
75
  path: wsMsg.path,
76
76
  query: mergedQuery,
@@ -270,7 +270,7 @@ class WsRouter {
270
270
 
271
271
  _buildRequestInfo(client, wsMsg, params) {
272
272
  return {
273
- requestId: randomUUID(),
273
+ id: randomUUID(),
274
274
  method: wsMsg.method,
275
275
  path: wsMsg.path,
276
276
  query: { ...(wsMsg.query ?? {}), ...params },