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 +1 -1
- package/server/bin/cli.js +2 -0
- package/server/bin/commands/bootstrap.js +147 -0
- package/server/config/modules/server.js +1 -0
- package/server/graphql/handler.js +2 -2
- package/server/router/api-router.js +10 -4
- package/server/web-server.js +16 -4
- package/server/ws/realtime.js +5 -5
- package/server/ws/ws-router.js +1 -1
package/package.json
CHANGED
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 };
|
|
@@ -17,14 +17,14 @@ function createGraphQLHandler(options) {
|
|
|
17
17
|
cors: false,
|
|
18
18
|
context: async ({ request }) => {
|
|
19
19
|
const loaders = makeLoaders();
|
|
20
|
-
const
|
|
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,
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
package/server/web-server.js
CHANGED
|
@@ -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
|
-
|
|
90
|
-
res.setHeader('X-Request-Id',
|
|
91
|
-
|
|
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')) {
|
package/server/ws/realtime.js
CHANGED
|
@@ -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.
|
|
25
|
+
appContext.log.debug(message, { ...data, requestId: reqInfo.id });
|
|
26
26
|
},
|
|
27
27
|
info(message, data) {
|
|
28
|
-
appContext.log.info(message, { ...data, requestId: reqInfo.
|
|
28
|
+
appContext.log.info(message, { ...data, requestId: reqInfo.id });
|
|
29
29
|
},
|
|
30
30
|
warn(message, data) {
|
|
31
|
-
appContext.log.warn(message, { ...data, requestId: reqInfo.
|
|
31
|
+
appContext.log.warn(message, { ...data, requestId: reqInfo.id });
|
|
32
32
|
},
|
|
33
33
|
error(message, data) {
|
|
34
|
-
appContext.log.error(message, { ...data, requestId: reqInfo.
|
|
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
|
-
|
|
73
|
+
id: randomUUID(),
|
|
74
74
|
method: wsMsg.method,
|
|
75
75
|
path: wsMsg.path,
|
|
76
76
|
query: mergedQuery,
|
package/server/ws/ws-router.js
CHANGED