arcway 0.1.9 → 0.1.11
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/client/fetcher.js +1 -1
- package/package.json +4 -2
- package/server/bin/cli.js +2 -0
- package/server/bin/commands/bootstrap.js +147 -0
- package/server/bin/commands/migrate.js +4 -1
- package/server/bin/commands/seed.js +1 -2
- package/server/config/loader.js +2 -0
- package/server/config/modules/database.js +3 -3
- package/server/config/modules/events.js +3 -3
- package/server/config/modules/jobs.js +3 -3
- package/server/config/modules/seeds.js +15 -0
- package/server/config/modules/server.js +1 -0
- package/server/db/index.js +9 -3
- package/server/events/drivers/memory.js +12 -24
- package/server/events/handler.js +4 -4
- package/server/graphql/handler.js +2 -2
- package/server/jobs/runner.js +1 -1
- package/server/router/api-router.js +11 -5
- package/server/testing/index.js +2 -2
- package/server/web-server.js +16 -4
- package/server/ws/realtime.js +5 -5
- package/server/ws/ws-router.js +1 -1
package/client/fetcher.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arcway",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"./ui/style-mira.css": "./client/ui/style-mira.css"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
|
+
"prepare": "esbuild client/router.jsx --outfile=client/router.js --format=esm --jsx=automatic",
|
|
32
33
|
"test": "vitest run --project=unit",
|
|
33
34
|
"test:storybook": "TMPDIR=~/tmp PLAYWRIGHT_BROWSERS_PATH=~/.cache/playwright vitest run --project=storybook",
|
|
34
35
|
"test:all": "TMPDIR=~/tmp PLAYWRIGHT_BROWSERS_PATH=~/.cache/playwright vitest run",
|
|
@@ -37,7 +38,8 @@
|
|
|
37
38
|
"lint": "eslint server/ client/ tests/",
|
|
38
39
|
"lint:fix": "eslint server/ client/ tests/ --fix",
|
|
39
40
|
"storybook": "storybook dev -p 6006",
|
|
40
|
-
"build-storybook": "storybook build"
|
|
41
|
+
"build-storybook": "storybook build",
|
|
42
|
+
"deploy:docs": "cd ~/Projects/home-server/baremetal/ansible && ansible-playbook deploy-arcway-docs.yml"
|
|
41
43
|
},
|
|
42
44
|
"dependencies": {
|
|
43
45
|
"@aws-sdk/client-s3": "^3.987.0",
|
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 };
|
|
@@ -6,7 +6,10 @@ import { createDB } from '#server/db/index.js';
|
|
|
6
6
|
async function runMigrateMake(name) {
|
|
7
7
|
const rootDir = process.cwd();
|
|
8
8
|
try {
|
|
9
|
-
const
|
|
9
|
+
const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
|
|
10
|
+
loadEnvFiles(rootDir, mode);
|
|
11
|
+
const config = await makeConfig(rootDir);
|
|
12
|
+
const migrationsDir = config.database.dir;
|
|
10
13
|
await fs.mkdir(migrationsDir, { recursive: true });
|
|
11
14
|
const now = new Date();
|
|
12
15
|
const timestamp = [
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
1
|
import { makeConfig } from '#server/config/loader.js';
|
|
3
2
|
import { loadEnvFiles } from '#server/env.js';
|
|
4
3
|
import { createDB } from '#server/db/index.js';
|
|
@@ -11,7 +10,7 @@ async function runSeed() {
|
|
|
11
10
|
const config = await makeConfig(rootDir);
|
|
12
11
|
const db = await createDB(config.database);
|
|
13
12
|
await db.runMigrations();
|
|
14
|
-
const seedsDir =
|
|
13
|
+
const seedsDir = config.seeds.dir;
|
|
15
14
|
const results = await runSeeds(db, seedsDir);
|
|
16
15
|
if (results.length === 0) {
|
|
17
16
|
console.log('No seed files found.');
|
package/server/config/loader.js
CHANGED
|
@@ -17,6 +17,7 @@ import resolvePages from './modules/pages.js';
|
|
|
17
17
|
import resolveBuild from './modules/build.js';
|
|
18
18
|
import resolveMcp from './modules/mcp.js';
|
|
19
19
|
import resolveWebsocket from './modules/websocket.js';
|
|
20
|
+
import resolveSeeds from './modules/seeds.js';
|
|
20
21
|
|
|
21
22
|
function deepMerge(target, source) {
|
|
22
23
|
const result = { ...target };
|
|
@@ -53,6 +54,7 @@ const modules = [
|
|
|
53
54
|
resolveBuild,
|
|
54
55
|
resolveMcp,
|
|
55
56
|
resolveWebsocket,
|
|
57
|
+
resolveSeeds,
|
|
56
58
|
];
|
|
57
59
|
|
|
58
60
|
async function makeConfig(rootDir, { overrides, mode } = {}) {
|
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
|
|
3
3
|
const DEFAULTS = {
|
|
4
4
|
sqliteFilename: '.build/db/arcway.db',
|
|
5
|
-
|
|
5
|
+
dir: 'migrations',
|
|
6
6
|
};
|
|
7
7
|
|
|
8
8
|
const CLIENT_MAP = {
|
|
@@ -15,8 +15,8 @@ function resolve(config, { rootDir } = {}) {
|
|
|
15
15
|
const db = { ...DEFAULTS, ...config.database };
|
|
16
16
|
// Resolve friendly client names to actual knex driver names
|
|
17
17
|
db.client = CLIENT_MAP[db.client] ?? db.client;
|
|
18
|
-
if (db.
|
|
19
|
-
db.
|
|
18
|
+
if (db.dir && !path.isAbsolute(db.dir)) {
|
|
19
|
+
db.dir = path.resolve(rootDir, db.dir);
|
|
20
20
|
}
|
|
21
21
|
const isSqlite = db.client === 'better-sqlite3' || db.client === 'sqlite3';
|
|
22
22
|
if (isSqlite && !db.connection) {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
3
|
const DEFAULTS = {
|
|
4
|
-
|
|
4
|
+
dir: 'listeners',
|
|
5
5
|
};
|
|
6
6
|
|
|
7
7
|
function resolve(config, { rootDir } = {}) {
|
|
8
8
|
const events = { ...DEFAULTS, ...config.events };
|
|
9
|
-
if (events.
|
|
10
|
-
events.
|
|
9
|
+
if (events.dir && !path.isAbsolute(events.dir)) {
|
|
10
|
+
events.dir = path.resolve(rootDir, events.dir);
|
|
11
11
|
}
|
|
12
12
|
return { ...config, events };
|
|
13
13
|
}
|
|
@@ -6,13 +6,13 @@ const DEFAULTS = {
|
|
|
6
6
|
pollIntervalMs: 60000,
|
|
7
7
|
tableName: 'arcway_jobs',
|
|
8
8
|
leaseTable: 'arcway_job_leases',
|
|
9
|
-
|
|
9
|
+
dir: 'jobs',
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
function resolve(config, { rootDir } = {}) {
|
|
13
13
|
const jobs = { ...DEFAULTS, ...config.jobs };
|
|
14
|
-
if (jobs.
|
|
15
|
-
jobs.
|
|
14
|
+
if (jobs.dir && !path.isAbsolute(jobs.dir)) {
|
|
15
|
+
jobs.dir = path.resolve(rootDir, jobs.dir);
|
|
16
16
|
}
|
|
17
17
|
return { ...config, jobs };
|
|
18
18
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
const DEFAULTS = {
|
|
4
|
+
dir: 'seeds',
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
function resolve(config, { rootDir } = {}) {
|
|
8
|
+
const seeds = { ...DEFAULTS, ...config.seeds };
|
|
9
|
+
if (seeds.dir && !path.isAbsolute(seeds.dir)) {
|
|
10
|
+
seeds.dir = path.resolve(rootDir, seeds.dir);
|
|
11
|
+
}
|
|
12
|
+
return { ...config, seeds };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default resolve;
|
package/server/db/index.js
CHANGED
|
@@ -45,6 +45,12 @@ async function createDB(config, { log } = {}) {
|
|
|
45
45
|
throw new Error(`Database connection failed: ${err}`);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
if (isSqlite) {
|
|
49
|
+
await db.raw('PRAGMA journal_mode = WAL');
|
|
50
|
+
await db.raw('PRAGMA busy_timeout = 5000');
|
|
51
|
+
await db.raw('PRAGMA synchronous = NORMAL');
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
const origDestroy = db.destroy.bind(db);
|
|
49
55
|
Object.defineProperty(db, 'destroy', {
|
|
50
56
|
value: async () => {
|
|
@@ -56,7 +62,7 @@ async function createDB(config, { log } = {}) {
|
|
|
56
62
|
});
|
|
57
63
|
|
|
58
64
|
db.runMigrations = async () => {
|
|
59
|
-
const migrationsDir = config.
|
|
65
|
+
const migrationsDir = config.dir;
|
|
60
66
|
if (!migrationsDir) return;
|
|
61
67
|
const [batch, migrations] = await db.migrate.latest({
|
|
62
68
|
migrationSource: new MigrationSource(migrationsDir),
|
|
@@ -69,8 +75,8 @@ async function createDB(config, { log } = {}) {
|
|
|
69
75
|
};
|
|
70
76
|
|
|
71
77
|
db.runRollback = async () => {
|
|
72
|
-
const migrationsDir = config.
|
|
73
|
-
if (!migrationsDir) throw new Error('No
|
|
78
|
+
const migrationsDir = config.dir;
|
|
79
|
+
if (!migrationsDir) throw new Error('No migrations dir configured');
|
|
74
80
|
const [batch, entries] = await db.migrate.rollback({
|
|
75
81
|
migrationSource: new MigrationSource(migrationsDir),
|
|
76
82
|
});
|
|
@@ -6,34 +6,22 @@ class MemoryTransport {
|
|
|
6
6
|
const regex = patternToRegex(pattern);
|
|
7
7
|
this.subscriptions.push({ pattern, regex, handler });
|
|
8
8
|
}
|
|
9
|
-
/**
|
|
10
|
-
* Emit an event. All matching subscribers are called concurrently.
|
|
11
|
-
* Errors in individual listeners are caught and collected — one failing
|
|
12
|
-
* listener does not prevent others from running.
|
|
13
|
-
*/
|
|
9
|
+
/** Emit an event. Handlers run asynchronously — fire-and-forget. */
|
|
14
10
|
async emit(eventName, payload) {
|
|
15
11
|
const matching = this.subscriptions.filter((sub) => sub.regex.test(eventName));
|
|
16
|
-
if (matching.length === 0)
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
const errors = [];
|
|
20
|
-
const results = await Promise.allSettled(
|
|
12
|
+
if (matching.length === 0) return;
|
|
13
|
+
Promise.allSettled(
|
|
21
14
|
matching.map((sub) => sub.handler(eventName, payload)),
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
result.reason,
|
|
31
|
-
);
|
|
15
|
+
).then((results) => {
|
|
16
|
+
for (let i = 0; i < results.length; i++) {
|
|
17
|
+
if (results[i].status === 'rejected') {
|
|
18
|
+
console.error(
|
|
19
|
+
`Event listener error [${matching[i].pattern}] for event "${eventName}":`,
|
|
20
|
+
results[i].reason,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
32
23
|
}
|
|
33
|
-
}
|
|
34
|
-
if (errors.length > 0 && errors.length === matching.length) {
|
|
35
|
-
throw new Error(`All ${errors.length} listener(s) for event "${eventName}" failed`);
|
|
36
|
-
}
|
|
24
|
+
});
|
|
37
25
|
}
|
|
38
26
|
async disconnect() {}
|
|
39
27
|
|
package/server/events/handler.js
CHANGED
|
@@ -20,13 +20,13 @@ function validateHandler(item, filePath, index) {
|
|
|
20
20
|
|
|
21
21
|
class EventHandler {
|
|
22
22
|
_events;
|
|
23
|
-
|
|
23
|
+
_dir;
|
|
24
24
|
_log;
|
|
25
25
|
_appContext;
|
|
26
26
|
_listeners = [];
|
|
27
27
|
|
|
28
28
|
constructor(config, { events, log, appContext } = {}) {
|
|
29
|
-
this.
|
|
29
|
+
this._dir = config?.dir;
|
|
30
30
|
this._events = events;
|
|
31
31
|
this._log = log;
|
|
32
32
|
this._appContext = appContext ?? {
|
|
@@ -41,8 +41,8 @@ class EventHandler {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
async init() {
|
|
44
|
-
if (!this.
|
|
45
|
-
const entries = await discoverModules(this.
|
|
44
|
+
if (!this._dir) return;
|
|
45
|
+
const entries = await discoverModules(this._dir, {
|
|
46
46
|
recursive: true,
|
|
47
47
|
label: 'listener file',
|
|
48
48
|
});
|
|
@@ -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
|
? {
|
package/server/jobs/runner.js
CHANGED
|
@@ -46,7 +46,7 @@ const validateRequest = validateRequestSchema;
|
|
|
46
46
|
async function serializeResponse(res, response, responseHeaders, statusCode) {
|
|
47
47
|
const customContentType = responseHeaders['Content-Type'] || responseHeaders['content-type'];
|
|
48
48
|
if (!customContentType || customContentType.includes('application/json')) {
|
|
49
|
-
const responseBody = response.error ? { error: response.error } :
|
|
49
|
+
const responseBody = response.error ? { error: response.error } : (response.data ?? null);
|
|
50
50
|
sendJson(res, statusCode, responseBody, responseHeaders);
|
|
51
51
|
return;
|
|
52
52
|
}
|
|
@@ -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/testing/index.js
CHANGED
|
@@ -128,9 +128,9 @@ async function createTestContext(domainName, options) {
|
|
|
128
128
|
if (options?.rootDir) {
|
|
129
129
|
const migrationsDir = path.join(options.rootDir, 'migrations');
|
|
130
130
|
await db.migrate.latest({ migrationSource: new MigrationSource(migrationsDir) });
|
|
131
|
-
} else if (options?.migrationsDir) {
|
|
131
|
+
} else if (options?.dir || options?.migrationsDir) {
|
|
132
132
|
await db.migrate.latest({
|
|
133
|
-
migrationSource: new MigrationSource(options.migrationsDir),
|
|
133
|
+
migrationSource: new MigrationSource(options.dir ?? options.migrationsDir),
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
136
|
const scopedDb = db;
|
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