arcway 0.1.0
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/LICENSE +21 -0
- package/README.md +711 -0
- package/client/env.js +55 -0
- package/client/fetcher.js +50 -0
- package/client/graphql.js +35 -0
- package/client/head.js +140 -0
- package/client/hooks/use-api.js +80 -0
- package/client/hooks/use-debounce.js +12 -0
- package/client/hooks/use-form.js +86 -0
- package/client/hooks/use-graphql.js +30 -0
- package/client/hooks/use-interval.js +12 -0
- package/client/hooks/use-mutation.js +27 -0
- package/client/hooks/use-query.js +45 -0
- package/client/hooks/web/use-click-outside.js +22 -0
- package/client/hooks/web/use-local-storage.js +42 -0
- package/client/index.js +62 -0
- package/client/page-loader.js +155 -0
- package/client/provider.js +53 -0
- package/client/query.js +13 -0
- package/client/router.jsx +303 -0
- package/client/ui/accordion.jsx +65 -0
- package/client/ui/accordion.stories.jsx +48 -0
- package/client/ui/alert-dialog.jsx +122 -0
- package/client/ui/alert-dialog.stories.jsx +44 -0
- package/client/ui/alert.jsx +52 -0
- package/client/ui/alert.stories.jsx +31 -0
- package/client/ui/app-shell.jsx +39 -0
- package/client/ui/app-shell.stories.jsx +51 -0
- package/client/ui/aspect-ratio.jsx +6 -0
- package/client/ui/aspect-ratio.stories.jsx +69 -0
- package/client/ui/avatar.jsx +78 -0
- package/client/ui/avatar.stories.jsx +62 -0
- package/client/ui/badge.jsx +34 -0
- package/client/ui/badge.stories.js +32 -0
- package/client/ui/breadcrumb.jsx +86 -0
- package/client/ui/breadcrumb.stories.jsx +43 -0
- package/client/ui/button-group.jsx +58 -0
- package/client/ui/button-group.stories.jsx +67 -0
- package/client/ui/button.jsx +46 -0
- package/client/ui/button.stories.js +72 -0
- package/client/ui/calendar.jsx +172 -0
- package/client/ui/card.jsx +57 -0
- package/client/ui/card.stories.jsx +33 -0
- package/client/ui/carousel.jsx +167 -0
- package/client/ui/chart.jsx +244 -0
- package/client/ui/checkbox.jsx +24 -0
- package/client/ui/checkbox.stories.js +33 -0
- package/client/ui/collapsible.jsx +12 -0
- package/client/ui/collapsible.stories.jsx +42 -0
- package/client/ui/combobox.jsx +223 -0
- package/client/ui/command.jsx +128 -0
- package/client/ui/context-menu.jsx +170 -0
- package/client/ui/context-menu.stories.jsx +35 -0
- package/client/ui/dialog.jsx +109 -0
- package/client/ui/dialog.stories.jsx +37 -0
- package/client/ui/direction.jsx +9 -0
- package/client/ui/drawer.jsx +87 -0
- package/client/ui/dropdown-menu.jsx +172 -0
- package/client/ui/dropdown-menu.stories.jsx +34 -0
- package/client/ui/empty.jsx +76 -0
- package/client/ui/empty.stories.jsx +64 -0
- package/client/ui/field.jsx +174 -0
- package/client/ui/field.stories.jsx +118 -0
- package/client/ui/form.jsx +17 -0
- package/client/ui/hooks/use-mobile.js +16 -0
- package/client/ui/hover-card.jsx +26 -0
- package/client/ui/hover-card.stories.jsx +28 -0
- package/client/ui/index.js +649 -0
- package/client/ui/input-group.jsx +116 -0
- package/client/ui/input-group.stories.jsx +65 -0
- package/client/ui/input-otp.jsx +62 -0
- package/client/ui/input.jsx +16 -0
- package/client/ui/input.stories.js +27 -0
- package/client/ui/item.jsx +155 -0
- package/client/ui/item.stories.jsx +118 -0
- package/client/ui/kbd.jsx +24 -0
- package/client/ui/kbd.stories.jsx +32 -0
- package/client/ui/label.jsx +16 -0
- package/client/ui/label.stories.js +25 -0
- package/client/ui/lib/utils.js +6 -0
- package/client/ui/main-content.jsx +30 -0
- package/client/ui/menubar.jsx +189 -0
- package/client/ui/menubar.stories.jsx +43 -0
- package/client/ui/native-select.jsx +34 -0
- package/client/ui/native-select.stories.jsx +67 -0
- package/client/ui/navigation-menu.jsx +120 -0
- package/client/ui/navigation-menu.stories.jsx +45 -0
- package/client/ui/pagination.jsx +92 -0
- package/client/ui/pagination.stories.jsx +52 -0
- package/client/ui/panel.jsx +66 -0
- package/client/ui/popover.jsx +54 -0
- package/client/ui/popover.stories.jsx +27 -0
- package/client/ui/progress.jsx +19 -0
- package/client/ui/progress.stories.js +34 -0
- package/client/ui/radio-group.jsx +33 -0
- package/client/ui/radio-group.stories.jsx +49 -0
- package/client/ui/resizable.jsx +33 -0
- package/client/ui/scroll-area.jsx +41 -0
- package/client/ui/scroll-area.stories.jsx +43 -0
- package/client/ui/select.jsx +145 -0
- package/client/ui/select.stories.jsx +80 -0
- package/client/ui/separator.jsx +18 -0
- package/client/ui/separator.stories.jsx +37 -0
- package/client/ui/sheet.jsx +95 -0
- package/client/ui/sheet.stories.jsx +56 -0
- package/client/ui/sidebar.jsx +544 -0
- package/client/ui/skeleton.jsx +8 -0
- package/client/ui/skeleton.stories.js +23 -0
- package/client/ui/slider.jsx +41 -0
- package/client/ui/slider.stories.js +31 -0
- package/client/ui/sonner.jsx +37 -0
- package/client/ui/spinner.jsx +14 -0
- package/client/ui/spinner.stories.js +16 -0
- package/client/ui/style-mira.css +1316 -0
- package/client/ui/switch.jsx +22 -0
- package/client/ui/switch.stories.js +44 -0
- package/client/ui/table.jsx +33 -0
- package/client/ui/table.stories.jsx +42 -0
- package/client/ui/tabs.jsx +63 -0
- package/client/ui/tabs.stories.jsx +45 -0
- package/client/ui/textarea.jsx +15 -0
- package/client/ui/textarea.stories.js +33 -0
- package/client/ui/theme.css +459 -0
- package/client/ui/toggle-group.jsx +62 -0
- package/client/ui/toggle-group.stories.jsx +68 -0
- package/client/ui/toggle.jsx +34 -0
- package/client/ui/toggle.stories.js +46 -0
- package/client/ui/tooltip.jsx +37 -0
- package/client/ui/tooltip.stories.jsx +32 -0
- package/client/ui/use-transition.js +35 -0
- package/client/ws.js +132 -0
- package/package.json +134 -0
- package/server/bin/cli.js +42 -0
- package/server/bin/commands/build.js +23 -0
- package/server/bin/commands/dev.js +57 -0
- package/server/bin/commands/docs.js +30 -0
- package/server/bin/commands/graphql-schema.js +32 -0
- package/server/bin/commands/lint.js +35 -0
- package/server/bin/commands/mcp.js +26 -0
- package/server/bin/commands/migrate.js +82 -0
- package/server/bin/commands/schema.js +41 -0
- package/server/bin/commands/seed.js +36 -0
- package/server/bin/commands/start.js +31 -0
- package/server/bin/commands/test.js +20 -0
- package/server/bin/solo.js +4 -0
- package/server/boot/index.js +150 -0
- package/server/boot.js +2 -0
- package/server/build.js +23 -0
- package/server/cache/drivers/memory.js +23 -0
- package/server/cache/drivers/redis.js +28 -0
- package/server/cache/index.js +69 -0
- package/server/config/loader.js +89 -0
- package/server/config/modules/api.js +17 -0
- package/server/config/modules/build.js +9 -0
- package/server/config/modules/cache.js +10 -0
- package/server/config/modules/database.js +29 -0
- package/server/config/modules/events.js +15 -0
- package/server/config/modules/files.js +15 -0
- package/server/config/modules/jobs.js +20 -0
- package/server/config/modules/logger.js +9 -0
- package/server/config/modules/mail.js +11 -0
- package/server/config/modules/mcp.js +9 -0
- package/server/config/modules/pages.js +20 -0
- package/server/config/modules/queue.js +10 -0
- package/server/config/modules/redis.js +9 -0
- package/server/config/modules/server.js +30 -0
- package/server/config/modules/session.js +9 -0
- package/server/config/modules/websocket.js +11 -0
- package/server/constants.js +67 -0
- package/server/context.js +15 -0
- package/server/db/index.js +87 -0
- package/server/db/schema/drivers/mysql.js +28 -0
- package/server/db/schema/drivers/pg.js +34 -0
- package/server/db/schema/drivers/sqlite.js +22 -0
- package/server/db/schema/index.js +78 -0
- package/server/db/seeds.js +22 -0
- package/server/discovery.js +67 -0
- package/server/docs/openapi.js +153 -0
- package/server/env.js +17 -0
- package/server/events/drivers/memory.js +45 -0
- package/server/events/drivers/redis.js +64 -0
- package/server/events/handler.js +67 -0
- package/server/events/index.js +35 -0
- package/server/events/pattern.js +5 -0
- package/server/files/drivers/local.js +83 -0
- package/server/files/drivers/s3.js +113 -0
- package/server/files/index.js +57 -0
- package/server/filewatcher/index.js +156 -0
- package/server/glob.js +6 -0
- package/server/graphql/discovery.js +70 -0
- package/server/graphql/handler.js +41 -0
- package/server/graphql/index.js +13 -0
- package/server/graphql/loaders.js +19 -0
- package/server/graphql/merge.js +48 -0
- package/server/graphql/subscriptions.js +43 -0
- package/server/health.js +34 -0
- package/server/helpers.js +9 -0
- package/server/index.js +55 -0
- package/server/internals.js +139 -0
- package/server/jobs/cron.js +10 -0
- package/server/jobs/drivers/knex-queue.js +207 -0
- package/server/jobs/drivers/lease.js +148 -0
- package/server/jobs/drivers/memory-queue.js +134 -0
- package/server/jobs/queue.js +27 -0
- package/server/jobs/runner.js +197 -0
- package/server/jobs/throughput.js +63 -0
- package/server/lib/vault/encrypt.js +40 -0
- package/server/lib/vault/ids.js +9 -0
- package/server/lib/vault/index.js +14 -0
- package/server/lib/vault/jwt.js +55 -0
- package/server/lib/vault/password.js +10 -0
- package/server/lint/boundaries.js +77 -0
- package/server/logger/index.js +130 -0
- package/server/mail/drivers/console.js +31 -0
- package/server/mail/drivers/smtp.js +34 -0
- package/server/mail/imap.js +105 -0
- package/server/mail/inbound-store.js +58 -0
- package/server/mail/inbound.js +79 -0
- package/server/mail/index.js +112 -0
- package/server/mcp/debug-api.js +137 -0
- package/server/mcp/helpers.js +30 -0
- package/server/mcp/index.js +77 -0
- package/server/mcp/runtime.js +7 -0
- package/server/mcp/server.js +19 -0
- package/server/mcp/tools/debugging.js +133 -0
- package/server/mcp/tools/introspection.js +87 -0
- package/server/middlewares/cors.js +30 -0
- package/server/middlewares/index.js +3 -0
- package/server/middlewares/require-session.js +15 -0
- package/server/module-loader.js +9 -0
- package/server/pages/build-client.js +187 -0
- package/server/pages/build-css.js +47 -0
- package/server/pages/build-manifest.js +55 -0
- package/server/pages/build-plugins.js +75 -0
- package/server/pages/build-server.js +115 -0
- package/server/pages/build.js +116 -0
- package/server/pages/discovery.js +120 -0
- package/server/pages/fonts.js +128 -0
- package/server/pages/handler.js +276 -0
- package/server/pages/hmr.js +176 -0
- package/server/pages/pages-router.js +78 -0
- package/server/pages/ssr.js +276 -0
- package/server/pages/static.js +92 -0
- package/server/pages/watcher.js +90 -0
- package/server/queue/drivers/knex.js +67 -0
- package/server/queue/drivers/redis.js +91 -0
- package/server/queue/index.js +61 -0
- package/server/rate-limit/consume.js +21 -0
- package/server/rate-limit/drivers/memory.js +24 -0
- package/server/rate-limit/drivers/redis.js +32 -0
- package/server/rate-limit/index.js +33 -0
- package/server/redis/index.js +67 -0
- package/server/ring-buffer.js +44 -0
- package/server/route.js +4 -0
- package/server/router/api-router.js +317 -0
- package/server/router/cors.js +31 -0
- package/server/router/middleware.js +91 -0
- package/server/router/routes.js +132 -0
- package/server/server.js +35 -0
- package/server/session/helpers.js +21 -0
- package/server/session/index.js +89 -0
- package/server/static/index.js +36 -0
- package/server/system-jobs/index.js +50 -0
- package/server/system-routes/index.js +84 -0
- package/server/testing/index.js +263 -0
- package/server/validation.js +41 -0
- package/server/watcher.js +34 -0
- package/server/web-server.js +231 -0
- package/server/ws/discovery.js +54 -0
- package/server/ws/index.js +14 -0
- package/server/ws/realtime.js +318 -0
- package/server/ws/registry.js +17 -0
- package/server/ws/server.js +152 -0
- package/server/ws/ws-router.js +335 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import knex from 'knex';
|
|
2
|
+
import { MigrationSource } from '../db/index.js';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
function createEventStub() {
|
|
5
|
+
const calls = [];
|
|
6
|
+
return {
|
|
7
|
+
calls,
|
|
8
|
+
emit: async (eventName, payload) => {
|
|
9
|
+
calls.push({ event: eventName, payload });
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function createQueueStub() {
|
|
14
|
+
const pushed = [];
|
|
15
|
+
const removed = [];
|
|
16
|
+
const items = [];
|
|
17
|
+
let nextId = 1;
|
|
18
|
+
return {
|
|
19
|
+
pushed,
|
|
20
|
+
removed,
|
|
21
|
+
async push(topic, item) {
|
|
22
|
+
pushed.push({ topic, item });
|
|
23
|
+
items.push({ id: nextId++, topic, data: item });
|
|
24
|
+
},
|
|
25
|
+
async pop(topic, count = 1) {
|
|
26
|
+
const matching = items.filter((i) => i.topic === topic).slice(0, count);
|
|
27
|
+
for (const m of matching) {
|
|
28
|
+
items.splice(items.indexOf(m), 1);
|
|
29
|
+
}
|
|
30
|
+
return matching.map((m) => ({ id: m.id, data: m.data }));
|
|
31
|
+
},
|
|
32
|
+
async remove(ids) {
|
|
33
|
+
removed.push(...ids);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function createCacheStub() {
|
|
38
|
+
const store = new Map();
|
|
39
|
+
const stub = {
|
|
40
|
+
store,
|
|
41
|
+
async get(key) {
|
|
42
|
+
return store.get(key) ?? null;
|
|
43
|
+
},
|
|
44
|
+
async set(key, value) {
|
|
45
|
+
store.set(key, value);
|
|
46
|
+
},
|
|
47
|
+
async delete(key) {
|
|
48
|
+
store.delete(key);
|
|
49
|
+
},
|
|
50
|
+
async wrap(key, fn, _ttlMs) {
|
|
51
|
+
const cached = store.get(key);
|
|
52
|
+
if (cached !== void 0) return cached;
|
|
53
|
+
const value = await fn();
|
|
54
|
+
store.set(key, value);
|
|
55
|
+
return value;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
return stub;
|
|
59
|
+
}
|
|
60
|
+
function createFilesStub() {
|
|
61
|
+
const store = new Map();
|
|
62
|
+
return {
|
|
63
|
+
store,
|
|
64
|
+
async write(filePath, data) {
|
|
65
|
+
store.set(filePath, Buffer.isBuffer(data) ? data : Buffer.from(data));
|
|
66
|
+
},
|
|
67
|
+
async read(filePath) {
|
|
68
|
+
return store.get(filePath) ?? null;
|
|
69
|
+
},
|
|
70
|
+
async delete(filePath) {
|
|
71
|
+
store.delete(filePath);
|
|
72
|
+
},
|
|
73
|
+
async list(prefix) {
|
|
74
|
+
const keys = Array.from(store.keys());
|
|
75
|
+
if (!prefix) return keys;
|
|
76
|
+
return keys.filter((k) => k.startsWith(prefix));
|
|
77
|
+
},
|
|
78
|
+
async exists(filePath) {
|
|
79
|
+
return store.has(filePath);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function createMailStub() {
|
|
84
|
+
const sent = [];
|
|
85
|
+
const queued = [];
|
|
86
|
+
return {
|
|
87
|
+
sent,
|
|
88
|
+
queued,
|
|
89
|
+
async send(message) {
|
|
90
|
+
sent.push(message);
|
|
91
|
+
const recipients = Array.isArray(message.to) ? message.to : [message.to];
|
|
92
|
+
return {
|
|
93
|
+
accepted: recipients,
|
|
94
|
+
rejected: [],
|
|
95
|
+
messageId: `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@local`,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
async queue(message) {
|
|
99
|
+
queued.push({ message });
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function createLoggerStub() {
|
|
104
|
+
const messages = [];
|
|
105
|
+
return {
|
|
106
|
+
messages,
|
|
107
|
+
debug(message, data) {
|
|
108
|
+
messages.push({ level: 'debug', message, data });
|
|
109
|
+
},
|
|
110
|
+
info(message, data) {
|
|
111
|
+
messages.push({ level: 'info', message, data });
|
|
112
|
+
},
|
|
113
|
+
warn(message, data) {
|
|
114
|
+
messages.push({ level: 'warn', message, data });
|
|
115
|
+
},
|
|
116
|
+
error(message, data) {
|
|
117
|
+
messages.push({ level: 'error', message, data });
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
async function createTestContext(domainName, options) {
|
|
122
|
+
const resolvedClient = options?.dbClient ?? 'better-sqlite3';
|
|
123
|
+
const db = knex({
|
|
124
|
+
client: resolvedClient,
|
|
125
|
+
connection: options?.dbConnection ?? { filename: ':memory:' },
|
|
126
|
+
useNullAsDefault: true,
|
|
127
|
+
});
|
|
128
|
+
if (options?.rootDir) {
|
|
129
|
+
const migrationsDir = path.join(options.rootDir, 'migrations');
|
|
130
|
+
await db.migrate.latest({ migrationSource: new MigrationSource(migrationsDir) });
|
|
131
|
+
} else if (options?.migrationsDir) {
|
|
132
|
+
await db.migrate.latest({
|
|
133
|
+
migrationSource: new MigrationSource(options.migrationsDir),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const scopedDb = db;
|
|
137
|
+
const ctx = {
|
|
138
|
+
domain: domainName,
|
|
139
|
+
db: scopedDb,
|
|
140
|
+
events: options?.events ?? createEventStub(),
|
|
141
|
+
queue: options?.queue ?? createQueueStub(),
|
|
142
|
+
cache: options?.cache ?? createCacheStub(),
|
|
143
|
+
files: options?.files ?? createFilesStub(),
|
|
144
|
+
mail: options?.mail ?? createMailStub(),
|
|
145
|
+
log: options?.log ?? createLoggerStub(),
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
ctx,
|
|
149
|
+
db,
|
|
150
|
+
cleanup: async () => {
|
|
151
|
+
await db.destroy();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
import http from 'node:http';
|
|
156
|
+
import boot from '../boot/index.js';
|
|
157
|
+
import { buildContext } from '../context.js';
|
|
158
|
+
async function testBoot(options) {
|
|
159
|
+
const configOverrides = {
|
|
160
|
+
database: { client: 'better-sqlite3', connection: { filename: ':memory:' } },
|
|
161
|
+
server: { port: 0 },
|
|
162
|
+
logger: { level: 'error' },
|
|
163
|
+
mcp: { enabled: false },
|
|
164
|
+
...options.configOverrides,
|
|
165
|
+
};
|
|
166
|
+
const app = await boot({
|
|
167
|
+
mode: 'development',
|
|
168
|
+
rootDir: options.rootDir,
|
|
169
|
+
configOverrides,
|
|
170
|
+
});
|
|
171
|
+
const baseUrl = `http://127.0.0.1:${app.port}`;
|
|
172
|
+
const appContext = {
|
|
173
|
+
db: app.db,
|
|
174
|
+
events: app.eventBus,
|
|
175
|
+
queue: { push: async () => {}, pop: async () => [], remove: async () => {} },
|
|
176
|
+
cache: {
|
|
177
|
+
get: async () => null,
|
|
178
|
+
set: async () => {},
|
|
179
|
+
delete: async () => {},
|
|
180
|
+
wrap: async (_k, fn) => fn(),
|
|
181
|
+
},
|
|
182
|
+
files: {
|
|
183
|
+
write: async () => {},
|
|
184
|
+
read: async () => null,
|
|
185
|
+
delete: async () => {},
|
|
186
|
+
list: async () => [],
|
|
187
|
+
exists: async () => false,
|
|
188
|
+
},
|
|
189
|
+
mail: { send: async () => ({ accepted: [], rejected: [] }), queue: async () => {} },
|
|
190
|
+
log: { debug() {}, info() {}, warn() {}, error() {} },
|
|
191
|
+
};
|
|
192
|
+
async function request(method, urlPath, opts) {
|
|
193
|
+
const url = new URL(urlPath, baseUrl);
|
|
194
|
+
const reqHeaders = {
|
|
195
|
+
...opts?.headers,
|
|
196
|
+
};
|
|
197
|
+
let bodyStr;
|
|
198
|
+
if (opts?.body !== void 0) {
|
|
199
|
+
bodyStr = JSON.stringify(opts.body);
|
|
200
|
+
reqHeaders['content-type'] = reqHeaders['content-type'] ?? 'application/json';
|
|
201
|
+
reqHeaders['content-length'] = String(Buffer.byteLength(bodyStr));
|
|
202
|
+
}
|
|
203
|
+
return new Promise((resolve, reject) => {
|
|
204
|
+
const req = http.request(
|
|
205
|
+
url,
|
|
206
|
+
{ method: method.toUpperCase(), headers: reqHeaders },
|
|
207
|
+
(res) => {
|
|
208
|
+
const chunks = [];
|
|
209
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
210
|
+
res.on('end', () => {
|
|
211
|
+
const text = Buffer.concat(chunks).toString('utf-8');
|
|
212
|
+
let body = text;
|
|
213
|
+
const ct = res.headers['content-type'] ?? '';
|
|
214
|
+
if (ct.includes('application/json')) {
|
|
215
|
+
try {
|
|
216
|
+
body = JSON.parse(text);
|
|
217
|
+
} catch {}
|
|
218
|
+
}
|
|
219
|
+
const headers = {};
|
|
220
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
221
|
+
if (typeof value === 'string') headers[key] = value;
|
|
222
|
+
else if (Array.isArray(value)) headers[key] = value.join(', ');
|
|
223
|
+
}
|
|
224
|
+
resolve({
|
|
225
|
+
status: res.statusCode ?? 0,
|
|
226
|
+
headers,
|
|
227
|
+
body,
|
|
228
|
+
text,
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
res.on('error', reject);
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
req.on('error', reject);
|
|
235
|
+
if (bodyStr) req.write(bodyStr);
|
|
236
|
+
req.end();
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
db: app.db,
|
|
241
|
+
port: app.port,
|
|
242
|
+
app,
|
|
243
|
+
request,
|
|
244
|
+
run: async (fn) => fn(buildContext(appContext, {})),
|
|
245
|
+
shutdown: app.shutdown,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const Arcway = {
|
|
249
|
+
/** Boot an Arcway application for integration testing. */
|
|
250
|
+
test: testBoot,
|
|
251
|
+
};
|
|
252
|
+
const Solo = Arcway;
|
|
253
|
+
export {
|
|
254
|
+
Arcway,
|
|
255
|
+
Solo,
|
|
256
|
+
createCacheStub,
|
|
257
|
+
createEventStub,
|
|
258
|
+
createFilesStub,
|
|
259
|
+
createLoggerStub,
|
|
260
|
+
createMailStub,
|
|
261
|
+
createQueueStub,
|
|
262
|
+
createTestContext,
|
|
263
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ArkErrors } from 'arktype';
|
|
2
|
+
function isArkErrors(value) {
|
|
3
|
+
return (
|
|
4
|
+
Array.isArray(value) && typeof value.summary === 'string' && typeof value.throw === 'function'
|
|
5
|
+
);
|
|
6
|
+
}
|
|
7
|
+
function validate(schema, data) {
|
|
8
|
+
const result = schema(data);
|
|
9
|
+
if (isArkErrors(result)) {
|
|
10
|
+
const fieldErrors = {};
|
|
11
|
+
for (const err of result) {
|
|
12
|
+
const key = err.path.join('.') || '_root';
|
|
13
|
+
if (!fieldErrors[key]) fieldErrors[key] = [];
|
|
14
|
+
fieldErrors[key].push(err.message);
|
|
15
|
+
}
|
|
16
|
+
return { success: false, errors: { fieldErrors } };
|
|
17
|
+
}
|
|
18
|
+
return { success: true, data: result };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Validate query and body against a schema object { query?, body? }.
|
|
22
|
+
* Returns { error } on failure, or { query, body } with coerced values on success.
|
|
23
|
+
*/
|
|
24
|
+
function validateRequestSchema(schema, query, body) {
|
|
25
|
+
if (schema?.query) {
|
|
26
|
+
const result = validate(schema.query, query);
|
|
27
|
+
if (!result.success) {
|
|
28
|
+
return { error: { code: 'VALIDATION_ERROR', message: 'Invalid query parameters', details: result.errors } };
|
|
29
|
+
}
|
|
30
|
+
query = result.data;
|
|
31
|
+
}
|
|
32
|
+
if (schema?.body) {
|
|
33
|
+
const result = validate(schema.body, body);
|
|
34
|
+
if (!result.success) {
|
|
35
|
+
return { error: { code: 'VALIDATION_ERROR', message: 'Invalid request body', details: result.errors } };
|
|
36
|
+
}
|
|
37
|
+
body = result.data;
|
|
38
|
+
}
|
|
39
|
+
return { query, body };
|
|
40
|
+
}
|
|
41
|
+
export { ArkErrors, validate, validateRequestSchema };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { watch } from 'chokidar';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
function startWatcher(options) {
|
|
4
|
+
const dirs = options.dirs ?? ['api', 'lib', 'listeners', 'jobs'];
|
|
5
|
+
const extensions = new Set(options.extensions ?? ['.js']);
|
|
6
|
+
const debounceMs = options.debounceMs ?? 300;
|
|
7
|
+
const watchPaths = dirs.map((dir) => path.join(options.rootDir, dir));
|
|
8
|
+
let debounceTimer = null;
|
|
9
|
+
let pendingFile = null;
|
|
10
|
+
const watcher = watch(watchPaths, {
|
|
11
|
+
ignoreInitial: true,
|
|
12
|
+
persistent: true,
|
|
13
|
+
});
|
|
14
|
+
if (options.onReady) {
|
|
15
|
+
watcher.once('ready', options.onReady);
|
|
16
|
+
}
|
|
17
|
+
watcher.on('all', (_event, filePath) => {
|
|
18
|
+
const ext = path.extname(filePath);
|
|
19
|
+
if (!extensions.has(ext)) return;
|
|
20
|
+
pendingFile = filePath;
|
|
21
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
22
|
+
debounceTimer = setTimeout(() => {
|
|
23
|
+
if (pendingFile) {
|
|
24
|
+
options.onChange(pendingFile);
|
|
25
|
+
pendingFile = null;
|
|
26
|
+
}
|
|
27
|
+
}, debounceMs);
|
|
28
|
+
});
|
|
29
|
+
return () => {
|
|
30
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
31
|
+
watcher.close();
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export { startWatcher };
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createHttpServer, listen, closeServer } from './server.js';
|
|
3
|
+
import { buildCorsHeaders, buildPreflightHeaders } from './router/cors.js';
|
|
4
|
+
import { parseCookies, resolveSession, flattenHeaders } from './session/helpers.js';
|
|
5
|
+
import { sealSession, buildSessionSetCookie, buildSessionClearCookie } from './session/index.js';
|
|
6
|
+
import { ErrorCodes } from './constants.js';
|
|
7
|
+
import { toErrorMessage } from './helpers.js';
|
|
8
|
+
|
|
9
|
+
function readBody(req, maxBodySize) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const chunks = [];
|
|
12
|
+
let totalBytes = 0;
|
|
13
|
+
function cleanup() {
|
|
14
|
+
req.removeListener('data', onData);
|
|
15
|
+
req.removeListener('end', onEnd);
|
|
16
|
+
req.removeListener('error', onError);
|
|
17
|
+
}
|
|
18
|
+
function onData(chunk) {
|
|
19
|
+
totalBytes += chunk.length;
|
|
20
|
+
if (totalBytes > maxBodySize) {
|
|
21
|
+
cleanup();
|
|
22
|
+
req.destroy();
|
|
23
|
+
reject(new Error(ErrorCodes.BODY_TOO_LARGE));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
chunks.push(chunk);
|
|
27
|
+
}
|
|
28
|
+
function onEnd() {
|
|
29
|
+
cleanup();
|
|
30
|
+
resolve(Buffer.concat(chunks).toString('utf-8'));
|
|
31
|
+
}
|
|
32
|
+
function onError(err) {
|
|
33
|
+
cleanup();
|
|
34
|
+
reject(err);
|
|
35
|
+
}
|
|
36
|
+
req.on('data', onData);
|
|
37
|
+
req.on('end', onEnd);
|
|
38
|
+
req.on('error', onError);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseQuery(url) {
|
|
43
|
+
const idx = url.indexOf('?');
|
|
44
|
+
if (idx === -1) return {};
|
|
45
|
+
const params = new URLSearchParams(url.slice(idx + 1));
|
|
46
|
+
const result = {};
|
|
47
|
+
for (const [key, value] of params) {
|
|
48
|
+
result[key] = value;
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class WebServer {
|
|
54
|
+
config;
|
|
55
|
+
log;
|
|
56
|
+
handlers = [];
|
|
57
|
+
server = null;
|
|
58
|
+
port = 0;
|
|
59
|
+
|
|
60
|
+
constructor(config, { log } = {}) {
|
|
61
|
+
this.config = config;
|
|
62
|
+
this.log = log;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
use(handler, prefix) {
|
|
66
|
+
this.handlers.push({ prefix: prefix ?? null, handler });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async listen() {
|
|
70
|
+
const { config, log } = this;
|
|
71
|
+
const host = config.server.host;
|
|
72
|
+
const port = config.server.port;
|
|
73
|
+
const corsConfig = config.server?.cors;
|
|
74
|
+
const sessionConfig = config.session;
|
|
75
|
+
const maxBodySize = config.server?.maxBodySize ?? 1024 * 1024;
|
|
76
|
+
|
|
77
|
+
if (sessionConfig) {
|
|
78
|
+
log.info(`Session enabled: cookie="${sessionConfig.cookieName}", ttl=${sessionConfig.ttl}s`);
|
|
79
|
+
if (corsConfig && corsConfig.origin === '*' && !corsConfig.credentials) {
|
|
80
|
+
log.warn(
|
|
81
|
+
'CORS is set to allow all origins (origin: "*") but session cookies are enabled. Browsers will not send cookies with wildcard CORS. Set cors.origin to your frontend URL and cors.credentials to true, or use cors: { origin: "http://localhost:5173", credentials: true }.',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const requestHandler = (req, res) => {
|
|
87
|
+
// ── Request ID ──
|
|
88
|
+
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;
|
|
92
|
+
|
|
93
|
+
// ── CORS ──
|
|
94
|
+
if (corsConfig) {
|
|
95
|
+
const requestOrigin = req.headers['origin'];
|
|
96
|
+
const corsHeaders = buildCorsHeaders(corsConfig, requestOrigin);
|
|
97
|
+
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
98
|
+
res.setHeader(key, value);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (req.method === 'OPTIONS' && corsConfig) {
|
|
102
|
+
const preflightHeaders = buildPreflightHeaders(corsConfig);
|
|
103
|
+
for (const [key, value] of Object.entries(preflightHeaders)) {
|
|
104
|
+
res.setHeader(key, value);
|
|
105
|
+
}
|
|
106
|
+
res.writeHead(204);
|
|
107
|
+
res.end();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Parse request ──
|
|
112
|
+
this._enrichRequest(req, sessionConfig, maxBodySize)
|
|
113
|
+
.then(() => this._dispatch(req, res))
|
|
114
|
+
.catch((err) => {
|
|
115
|
+
if (res.headersSent) return;
|
|
116
|
+
const msg = toErrorMessage(err);
|
|
117
|
+
if (msg === ErrorCodes.BODY_TOO_LARGE) {
|
|
118
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
119
|
+
res.end(JSON.stringify({ error: { code: ErrorCodes.BODY_TOO_LARGE, message: 'Request body exceeds maximum size' } }));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (msg === ErrorCodes.INVALID_JSON) {
|
|
123
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
124
|
+
res.end(JSON.stringify({ error: { code: ErrorCodes.INVALID_JSON, message: 'Request body is not valid JSON' } }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
log.error('Unhandled error in request handler', { error: msg, requestId });
|
|
128
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
129
|
+
res.end(JSON.stringify({ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } }));
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
this.server = createHttpServer(requestHandler);
|
|
134
|
+
|
|
135
|
+
// ── WebSocket upgrade ──
|
|
136
|
+
this.server.on('upgrade', async (req, socket, head) => {
|
|
137
|
+
const url = req.url ?? '/';
|
|
138
|
+
const cookies = parseCookies(req);
|
|
139
|
+
const session = sessionConfig
|
|
140
|
+
? await resolveSession(cookies, sessionConfig)
|
|
141
|
+
: null;
|
|
142
|
+
req.cookies = cookies;
|
|
143
|
+
req.session = session;
|
|
144
|
+
req.flatHeaders = flattenHeaders(req.headers);
|
|
145
|
+
|
|
146
|
+
for (const { prefix, handler } of this.handlers) {
|
|
147
|
+
if (prefix && !url.startsWith(prefix)) continue;
|
|
148
|
+
if (typeof handler.handleUpgrade === 'function') {
|
|
149
|
+
handler.handleUpgrade(req, socket, head, session);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
socket.destroy();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
log.info('Starting HTTP server...');
|
|
157
|
+
await listen(this.server, port, host);
|
|
158
|
+
this.port = this.server.address().port;
|
|
159
|
+
log.info(`Listening on ${host}:${this.port}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async _enrichRequest(req, sessionConfig, maxBodySize) {
|
|
163
|
+
const method = req.method ?? 'GET';
|
|
164
|
+
|
|
165
|
+
// Cookies + session
|
|
166
|
+
req.cookies = parseCookies(req);
|
|
167
|
+
req.session = sessionConfig
|
|
168
|
+
? await resolveSession(req.cookies, sessionConfig)
|
|
169
|
+
: null;
|
|
170
|
+
|
|
171
|
+
// Headers (flattened for consistent access)
|
|
172
|
+
req.flatHeaders = flattenHeaders(req.headers);
|
|
173
|
+
|
|
174
|
+
// Query string
|
|
175
|
+
req.query = parseQuery(req.url ?? '/');
|
|
176
|
+
|
|
177
|
+
// Body (POST/PUT/PATCH only)
|
|
178
|
+
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
|
179
|
+
const rawBody = await readBody(req, maxBodySize);
|
|
180
|
+
if (rawBody.length > 0) {
|
|
181
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
182
|
+
if (contentType.includes('application/json')) {
|
|
183
|
+
try {
|
|
184
|
+
req.body = JSON.parse(rawBody);
|
|
185
|
+
} catch {
|
|
186
|
+
throw new Error(ErrorCodes.INVALID_JSON);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
req.body = rawBody;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async _dispatch(req, res) {
|
|
196
|
+
const url = req.url ?? '/';
|
|
197
|
+
for (const { prefix, handler } of this.handlers) {
|
|
198
|
+
if (prefix && !url.startsWith(prefix)) continue;
|
|
199
|
+
const handled = typeof handler.handle === 'function'
|
|
200
|
+
? await handler.handle(req, res)
|
|
201
|
+
: await handler(req, res);
|
|
202
|
+
if (handled) return;
|
|
203
|
+
}
|
|
204
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
205
|
+
res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async close() {
|
|
209
|
+
// Close WS handlers before shutting down the HTTP server
|
|
210
|
+
for (const { handler } of this.handlers) {
|
|
211
|
+
if (typeof handler.handleUpgrade === 'function' && typeof handler.close === 'function') {
|
|
212
|
+
try { handler.close(); } catch {}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (this.server) {
|
|
216
|
+
const shutdownTimeoutMs = this.config.server?.shutdownTimeoutMs ?? 5000;
|
|
217
|
+
await Promise.race([
|
|
218
|
+
closeServer(this.server),
|
|
219
|
+
new Promise((resolve) =>
|
|
220
|
+
setTimeout(() => {
|
|
221
|
+
this.log.warn(`Shutdown timeout (${shutdownTimeoutMs}ms) exceeded, forcing close`);
|
|
222
|
+
resolve();
|
|
223
|
+
}, shutdownTimeoutMs),
|
|
224
|
+
),
|
|
225
|
+
]);
|
|
226
|
+
this.log.info('HTTP server closed');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export default WebServer;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { loadModule } from '../module-loader.js';
|
|
4
|
+
async function discoverWebSocketHandlers(rootDir) {
|
|
5
|
+
const result = [];
|
|
6
|
+
const candidate = path.join(rootDir, 'ws.js');
|
|
7
|
+
if (!fs.existsSync(candidate)) return result;
|
|
8
|
+
const mod = await loadModule(candidate, import.meta.url);
|
|
9
|
+
const handler = mod.default;
|
|
10
|
+
if (!handler || typeof handler !== 'object') {
|
|
11
|
+
throw new Error(`ws.js must export a default WebSocketHandler object, got ${typeof handler}`);
|
|
12
|
+
}
|
|
13
|
+
if (handler.onConnect && typeof handler.onConnect !== 'function') {
|
|
14
|
+
throw new Error('ws.js: onConnect must be a function');
|
|
15
|
+
}
|
|
16
|
+
if (handler.onClose && typeof handler.onClose !== 'function') {
|
|
17
|
+
throw new Error('ws.js: onClose must be a function');
|
|
18
|
+
}
|
|
19
|
+
if (handler.messages) {
|
|
20
|
+
if (typeof handler.messages !== 'object') {
|
|
21
|
+
throw new Error('ws.js: messages must be an object');
|
|
22
|
+
}
|
|
23
|
+
for (const [type, fn] of Object.entries(handler.messages)) {
|
|
24
|
+
if (typeof fn !== 'function') {
|
|
25
|
+
throw new Error(`ws.js: messages["${type}"] must be a function, got ${typeof fn}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
result.push({ handler });
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
function mergeWebSocketHandlers(entries) {
|
|
33
|
+
const onConnect = [];
|
|
34
|
+
const onClose = [];
|
|
35
|
+
const messages = new Map();
|
|
36
|
+
for (const { handler } of entries) {
|
|
37
|
+
if (handler.onConnect) {
|
|
38
|
+
onConnect.push({ fn: handler.onConnect });
|
|
39
|
+
}
|
|
40
|
+
if (handler.onClose) {
|
|
41
|
+
onClose.push({ fn: handler.onClose });
|
|
42
|
+
}
|
|
43
|
+
if (handler.messages) {
|
|
44
|
+
for (const [type, fn] of Object.entries(handler.messages)) {
|
|
45
|
+
if (messages.has(type)) {
|
|
46
|
+
throw new Error(`WebSocket message type "${type}" is registered multiple times`);
|
|
47
|
+
}
|
|
48
|
+
messages.set(type, { fn });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { onConnect, onClose, messages };
|
|
53
|
+
}
|
|
54
|
+
export { discoverWebSocketHandlers, mergeWebSocketHandlers };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { discoverWebSocketHandlers, mergeWebSocketHandlers } from './discovery.js';
|
|
2
|
+
import { createWebSocketServer } from './server.js';
|
|
3
|
+
import { createRealtimeServer } from './realtime.js';
|
|
4
|
+
import { WsRouter } from './ws-router.js';
|
|
5
|
+
import { wsSendToSocket, wsBroadcastToPath } from './registry.js';
|
|
6
|
+
export {
|
|
7
|
+
createRealtimeServer,
|
|
8
|
+
createWebSocketServer,
|
|
9
|
+
discoverWebSocketHandlers,
|
|
10
|
+
mergeWebSocketHandlers,
|
|
11
|
+
WsRouter,
|
|
12
|
+
wsBroadcastToPath,
|
|
13
|
+
wsSendToSocket,
|
|
14
|
+
};
|