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.
Files changed (274) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +711 -0
  3. package/client/env.js +55 -0
  4. package/client/fetcher.js +50 -0
  5. package/client/graphql.js +35 -0
  6. package/client/head.js +140 -0
  7. package/client/hooks/use-api.js +80 -0
  8. package/client/hooks/use-debounce.js +12 -0
  9. package/client/hooks/use-form.js +86 -0
  10. package/client/hooks/use-graphql.js +30 -0
  11. package/client/hooks/use-interval.js +12 -0
  12. package/client/hooks/use-mutation.js +27 -0
  13. package/client/hooks/use-query.js +45 -0
  14. package/client/hooks/web/use-click-outside.js +22 -0
  15. package/client/hooks/web/use-local-storage.js +42 -0
  16. package/client/index.js +62 -0
  17. package/client/page-loader.js +155 -0
  18. package/client/provider.js +53 -0
  19. package/client/query.js +13 -0
  20. package/client/router.jsx +303 -0
  21. package/client/ui/accordion.jsx +65 -0
  22. package/client/ui/accordion.stories.jsx +48 -0
  23. package/client/ui/alert-dialog.jsx +122 -0
  24. package/client/ui/alert-dialog.stories.jsx +44 -0
  25. package/client/ui/alert.jsx +52 -0
  26. package/client/ui/alert.stories.jsx +31 -0
  27. package/client/ui/app-shell.jsx +39 -0
  28. package/client/ui/app-shell.stories.jsx +51 -0
  29. package/client/ui/aspect-ratio.jsx +6 -0
  30. package/client/ui/aspect-ratio.stories.jsx +69 -0
  31. package/client/ui/avatar.jsx +78 -0
  32. package/client/ui/avatar.stories.jsx +62 -0
  33. package/client/ui/badge.jsx +34 -0
  34. package/client/ui/badge.stories.js +32 -0
  35. package/client/ui/breadcrumb.jsx +86 -0
  36. package/client/ui/breadcrumb.stories.jsx +43 -0
  37. package/client/ui/button-group.jsx +58 -0
  38. package/client/ui/button-group.stories.jsx +67 -0
  39. package/client/ui/button.jsx +46 -0
  40. package/client/ui/button.stories.js +72 -0
  41. package/client/ui/calendar.jsx +172 -0
  42. package/client/ui/card.jsx +57 -0
  43. package/client/ui/card.stories.jsx +33 -0
  44. package/client/ui/carousel.jsx +167 -0
  45. package/client/ui/chart.jsx +244 -0
  46. package/client/ui/checkbox.jsx +24 -0
  47. package/client/ui/checkbox.stories.js +33 -0
  48. package/client/ui/collapsible.jsx +12 -0
  49. package/client/ui/collapsible.stories.jsx +42 -0
  50. package/client/ui/combobox.jsx +223 -0
  51. package/client/ui/command.jsx +128 -0
  52. package/client/ui/context-menu.jsx +170 -0
  53. package/client/ui/context-menu.stories.jsx +35 -0
  54. package/client/ui/dialog.jsx +109 -0
  55. package/client/ui/dialog.stories.jsx +37 -0
  56. package/client/ui/direction.jsx +9 -0
  57. package/client/ui/drawer.jsx +87 -0
  58. package/client/ui/dropdown-menu.jsx +172 -0
  59. package/client/ui/dropdown-menu.stories.jsx +34 -0
  60. package/client/ui/empty.jsx +76 -0
  61. package/client/ui/empty.stories.jsx +64 -0
  62. package/client/ui/field.jsx +174 -0
  63. package/client/ui/field.stories.jsx +118 -0
  64. package/client/ui/form.jsx +17 -0
  65. package/client/ui/hooks/use-mobile.js +16 -0
  66. package/client/ui/hover-card.jsx +26 -0
  67. package/client/ui/hover-card.stories.jsx +28 -0
  68. package/client/ui/index.js +649 -0
  69. package/client/ui/input-group.jsx +116 -0
  70. package/client/ui/input-group.stories.jsx +65 -0
  71. package/client/ui/input-otp.jsx +62 -0
  72. package/client/ui/input.jsx +16 -0
  73. package/client/ui/input.stories.js +27 -0
  74. package/client/ui/item.jsx +155 -0
  75. package/client/ui/item.stories.jsx +118 -0
  76. package/client/ui/kbd.jsx +24 -0
  77. package/client/ui/kbd.stories.jsx +32 -0
  78. package/client/ui/label.jsx +16 -0
  79. package/client/ui/label.stories.js +25 -0
  80. package/client/ui/lib/utils.js +6 -0
  81. package/client/ui/main-content.jsx +30 -0
  82. package/client/ui/menubar.jsx +189 -0
  83. package/client/ui/menubar.stories.jsx +43 -0
  84. package/client/ui/native-select.jsx +34 -0
  85. package/client/ui/native-select.stories.jsx +67 -0
  86. package/client/ui/navigation-menu.jsx +120 -0
  87. package/client/ui/navigation-menu.stories.jsx +45 -0
  88. package/client/ui/pagination.jsx +92 -0
  89. package/client/ui/pagination.stories.jsx +52 -0
  90. package/client/ui/panel.jsx +66 -0
  91. package/client/ui/popover.jsx +54 -0
  92. package/client/ui/popover.stories.jsx +27 -0
  93. package/client/ui/progress.jsx +19 -0
  94. package/client/ui/progress.stories.js +34 -0
  95. package/client/ui/radio-group.jsx +33 -0
  96. package/client/ui/radio-group.stories.jsx +49 -0
  97. package/client/ui/resizable.jsx +33 -0
  98. package/client/ui/scroll-area.jsx +41 -0
  99. package/client/ui/scroll-area.stories.jsx +43 -0
  100. package/client/ui/select.jsx +145 -0
  101. package/client/ui/select.stories.jsx +80 -0
  102. package/client/ui/separator.jsx +18 -0
  103. package/client/ui/separator.stories.jsx +37 -0
  104. package/client/ui/sheet.jsx +95 -0
  105. package/client/ui/sheet.stories.jsx +56 -0
  106. package/client/ui/sidebar.jsx +544 -0
  107. package/client/ui/skeleton.jsx +8 -0
  108. package/client/ui/skeleton.stories.js +23 -0
  109. package/client/ui/slider.jsx +41 -0
  110. package/client/ui/slider.stories.js +31 -0
  111. package/client/ui/sonner.jsx +37 -0
  112. package/client/ui/spinner.jsx +14 -0
  113. package/client/ui/spinner.stories.js +16 -0
  114. package/client/ui/style-mira.css +1316 -0
  115. package/client/ui/switch.jsx +22 -0
  116. package/client/ui/switch.stories.js +44 -0
  117. package/client/ui/table.jsx +33 -0
  118. package/client/ui/table.stories.jsx +42 -0
  119. package/client/ui/tabs.jsx +63 -0
  120. package/client/ui/tabs.stories.jsx +45 -0
  121. package/client/ui/textarea.jsx +15 -0
  122. package/client/ui/textarea.stories.js +33 -0
  123. package/client/ui/theme.css +459 -0
  124. package/client/ui/toggle-group.jsx +62 -0
  125. package/client/ui/toggle-group.stories.jsx +68 -0
  126. package/client/ui/toggle.jsx +34 -0
  127. package/client/ui/toggle.stories.js +46 -0
  128. package/client/ui/tooltip.jsx +37 -0
  129. package/client/ui/tooltip.stories.jsx +32 -0
  130. package/client/ui/use-transition.js +35 -0
  131. package/client/ws.js +132 -0
  132. package/package.json +134 -0
  133. package/server/bin/cli.js +42 -0
  134. package/server/bin/commands/build.js +23 -0
  135. package/server/bin/commands/dev.js +57 -0
  136. package/server/bin/commands/docs.js +30 -0
  137. package/server/bin/commands/graphql-schema.js +32 -0
  138. package/server/bin/commands/lint.js +35 -0
  139. package/server/bin/commands/mcp.js +26 -0
  140. package/server/bin/commands/migrate.js +82 -0
  141. package/server/bin/commands/schema.js +41 -0
  142. package/server/bin/commands/seed.js +36 -0
  143. package/server/bin/commands/start.js +31 -0
  144. package/server/bin/commands/test.js +20 -0
  145. package/server/bin/solo.js +4 -0
  146. package/server/boot/index.js +150 -0
  147. package/server/boot.js +2 -0
  148. package/server/build.js +23 -0
  149. package/server/cache/drivers/memory.js +23 -0
  150. package/server/cache/drivers/redis.js +28 -0
  151. package/server/cache/index.js +69 -0
  152. package/server/config/loader.js +89 -0
  153. package/server/config/modules/api.js +17 -0
  154. package/server/config/modules/build.js +9 -0
  155. package/server/config/modules/cache.js +10 -0
  156. package/server/config/modules/database.js +29 -0
  157. package/server/config/modules/events.js +15 -0
  158. package/server/config/modules/files.js +15 -0
  159. package/server/config/modules/jobs.js +20 -0
  160. package/server/config/modules/logger.js +9 -0
  161. package/server/config/modules/mail.js +11 -0
  162. package/server/config/modules/mcp.js +9 -0
  163. package/server/config/modules/pages.js +20 -0
  164. package/server/config/modules/queue.js +10 -0
  165. package/server/config/modules/redis.js +9 -0
  166. package/server/config/modules/server.js +30 -0
  167. package/server/config/modules/session.js +9 -0
  168. package/server/config/modules/websocket.js +11 -0
  169. package/server/constants.js +67 -0
  170. package/server/context.js +15 -0
  171. package/server/db/index.js +87 -0
  172. package/server/db/schema/drivers/mysql.js +28 -0
  173. package/server/db/schema/drivers/pg.js +34 -0
  174. package/server/db/schema/drivers/sqlite.js +22 -0
  175. package/server/db/schema/index.js +78 -0
  176. package/server/db/seeds.js +22 -0
  177. package/server/discovery.js +67 -0
  178. package/server/docs/openapi.js +153 -0
  179. package/server/env.js +17 -0
  180. package/server/events/drivers/memory.js +45 -0
  181. package/server/events/drivers/redis.js +64 -0
  182. package/server/events/handler.js +67 -0
  183. package/server/events/index.js +35 -0
  184. package/server/events/pattern.js +5 -0
  185. package/server/files/drivers/local.js +83 -0
  186. package/server/files/drivers/s3.js +113 -0
  187. package/server/files/index.js +57 -0
  188. package/server/filewatcher/index.js +156 -0
  189. package/server/glob.js +6 -0
  190. package/server/graphql/discovery.js +70 -0
  191. package/server/graphql/handler.js +41 -0
  192. package/server/graphql/index.js +13 -0
  193. package/server/graphql/loaders.js +19 -0
  194. package/server/graphql/merge.js +48 -0
  195. package/server/graphql/subscriptions.js +43 -0
  196. package/server/health.js +34 -0
  197. package/server/helpers.js +9 -0
  198. package/server/index.js +55 -0
  199. package/server/internals.js +139 -0
  200. package/server/jobs/cron.js +10 -0
  201. package/server/jobs/drivers/knex-queue.js +207 -0
  202. package/server/jobs/drivers/lease.js +148 -0
  203. package/server/jobs/drivers/memory-queue.js +134 -0
  204. package/server/jobs/queue.js +27 -0
  205. package/server/jobs/runner.js +197 -0
  206. package/server/jobs/throughput.js +63 -0
  207. package/server/lib/vault/encrypt.js +40 -0
  208. package/server/lib/vault/ids.js +9 -0
  209. package/server/lib/vault/index.js +14 -0
  210. package/server/lib/vault/jwt.js +55 -0
  211. package/server/lib/vault/password.js +10 -0
  212. package/server/lint/boundaries.js +77 -0
  213. package/server/logger/index.js +130 -0
  214. package/server/mail/drivers/console.js +31 -0
  215. package/server/mail/drivers/smtp.js +34 -0
  216. package/server/mail/imap.js +105 -0
  217. package/server/mail/inbound-store.js +58 -0
  218. package/server/mail/inbound.js +79 -0
  219. package/server/mail/index.js +112 -0
  220. package/server/mcp/debug-api.js +137 -0
  221. package/server/mcp/helpers.js +30 -0
  222. package/server/mcp/index.js +77 -0
  223. package/server/mcp/runtime.js +7 -0
  224. package/server/mcp/server.js +19 -0
  225. package/server/mcp/tools/debugging.js +133 -0
  226. package/server/mcp/tools/introspection.js +87 -0
  227. package/server/middlewares/cors.js +30 -0
  228. package/server/middlewares/index.js +3 -0
  229. package/server/middlewares/require-session.js +15 -0
  230. package/server/module-loader.js +9 -0
  231. package/server/pages/build-client.js +187 -0
  232. package/server/pages/build-css.js +47 -0
  233. package/server/pages/build-manifest.js +55 -0
  234. package/server/pages/build-plugins.js +75 -0
  235. package/server/pages/build-server.js +115 -0
  236. package/server/pages/build.js +116 -0
  237. package/server/pages/discovery.js +120 -0
  238. package/server/pages/fonts.js +128 -0
  239. package/server/pages/handler.js +276 -0
  240. package/server/pages/hmr.js +176 -0
  241. package/server/pages/pages-router.js +78 -0
  242. package/server/pages/ssr.js +276 -0
  243. package/server/pages/static.js +92 -0
  244. package/server/pages/watcher.js +90 -0
  245. package/server/queue/drivers/knex.js +67 -0
  246. package/server/queue/drivers/redis.js +91 -0
  247. package/server/queue/index.js +61 -0
  248. package/server/rate-limit/consume.js +21 -0
  249. package/server/rate-limit/drivers/memory.js +24 -0
  250. package/server/rate-limit/drivers/redis.js +32 -0
  251. package/server/rate-limit/index.js +33 -0
  252. package/server/redis/index.js +67 -0
  253. package/server/ring-buffer.js +44 -0
  254. package/server/route.js +4 -0
  255. package/server/router/api-router.js +317 -0
  256. package/server/router/cors.js +31 -0
  257. package/server/router/middleware.js +91 -0
  258. package/server/router/routes.js +132 -0
  259. package/server/server.js +35 -0
  260. package/server/session/helpers.js +21 -0
  261. package/server/session/index.js +89 -0
  262. package/server/static/index.js +36 -0
  263. package/server/system-jobs/index.js +50 -0
  264. package/server/system-routes/index.js +84 -0
  265. package/server/testing/index.js +263 -0
  266. package/server/validation.js +41 -0
  267. package/server/watcher.js +34 -0
  268. package/server/web-server.js +231 -0
  269. package/server/ws/discovery.js +54 -0
  270. package/server/ws/index.js +14 -0
  271. package/server/ws/realtime.js +318 -0
  272. package/server/ws/registry.js +17 -0
  273. package/server/ws/server.js +152 -0
  274. package/server/ws/ws-router.js +335 -0
@@ -0,0 +1,61 @@
1
+ import KnexQueueDriver from './drivers/knex.js';
2
+ import RedisQueueDriver from './drivers/redis.js';
3
+
4
+ class Queue {
5
+ _driver;
6
+ _log;
7
+ _namespace;
8
+ _cooldownMs;
9
+
10
+ constructor(config, { db, redis, log } = {}) {
11
+ this._log = log;
12
+ this._namespace = config?.namespace ?? '';
13
+ this._cooldownMs = config?.lockCooldownMs ?? 5 * 60 * 1000;
14
+
15
+ if (config?.driver === 'redis') {
16
+ this._driver = new RedisQueueDriver(redis.client, redis.keyPrefix);
17
+ log?.info('Queue driver: redis');
18
+ } else {
19
+ this._driver = new KnexQueueDriver(db, config?.tableName);
20
+ log?.info('Queue driver: knex');
21
+ }
22
+ }
23
+
24
+ async init() {
25
+ await this._driver.init();
26
+ }
27
+
28
+ async push(topic, item) {
29
+ await this._driver.push(this._namespace, topic, JSON.stringify(item));
30
+ }
31
+
32
+ async pop(topic, count = 1) {
33
+ const rows = await this._driver.pop(this._namespace, topic, count, this._cooldownMs);
34
+ return rows.map((r) => {
35
+ let data;
36
+ try {
37
+ data = JSON.parse(r.payload);
38
+ } catch {
39
+ this._log?.warn('Queue pop: corrupt JSON payload, using raw string', { id: r.id });
40
+ data = r.payload;
41
+ }
42
+ return { id: r.id, data };
43
+ });
44
+ }
45
+
46
+ async remove(ids) {
47
+ await this._driver.remove(this._namespace, ids);
48
+ }
49
+
50
+ /** Return a namespaced child that shares the same driver. */
51
+ withNamespace(namespace) {
52
+ const child = Object.create(Queue.prototype);
53
+ child._driver = this._driver;
54
+ child._log = this._log;
55
+ child._namespace = namespace;
56
+ child._cooldownMs = this._cooldownMs;
57
+ return child;
58
+ }
59
+ }
60
+
61
+ export default Queue;
@@ -0,0 +1,21 @@
1
+ import { RateLimiterRes } from 'rate-limiter-flexible';
2
+ async function consumeAndConvert(limiter, key) {
3
+ try {
4
+ const res = await limiter.consume(key, 1);
5
+ return {
6
+ allowed: true,
7
+ remaining: res.remainingPoints,
8
+ resetMs: res.msBeforeNext,
9
+ };
10
+ } catch (err) {
11
+ if (err instanceof RateLimiterRes) {
12
+ return {
13
+ allowed: false,
14
+ remaining: 0,
15
+ resetMs: err.msBeforeNext,
16
+ };
17
+ }
18
+ throw err;
19
+ }
20
+ }
21
+ export { consumeAndConvert };
@@ -0,0 +1,24 @@
1
+ import { RateLimiterMemory } from 'rate-limiter-flexible';
2
+ import { consumeAndConvert } from '../consume.js';
3
+ class MemoryRateLimitStore {
4
+ limiters = new Map();
5
+ getLimiter(max, windowMs) {
6
+ const configKey = `${max}:${windowMs}`;
7
+ let limiter = this.limiters.get(configKey);
8
+ if (!limiter) {
9
+ limiter = new RateLimiterMemory({
10
+ points: max,
11
+ duration: Math.ceil(windowMs / 1e3),
12
+ });
13
+ this.limiters.set(configKey, limiter);
14
+ }
15
+ return limiter;
16
+ }
17
+ async check(key, max, windowMs) {
18
+ return consumeAndConvert(this.getLimiter(max, windowMs), key);
19
+ }
20
+ destroy() {
21
+ this.limiters.clear();
22
+ }
23
+ }
24
+ export default MemoryRateLimitStore;
@@ -0,0 +1,32 @@
1
+ import { RateLimiterRedis } from 'rate-limiter-flexible';
2
+ import { consumeAndConvert } from '../consume.js';
3
+ class RedisRateLimitStore {
4
+ client;
5
+ prefix;
6
+ limiters = new Map();
7
+ constructor(client, keyPrefix = 'arcway:') {
8
+ this.client = client;
9
+ this.prefix = `${keyPrefix}rl:`;
10
+ }
11
+ getLimiter(max, windowMs) {
12
+ const configKey = `${max}:${windowMs}`;
13
+ let limiter = this.limiters.get(configKey);
14
+ if (!limiter) {
15
+ limiter = new RateLimiterRedis({
16
+ storeClient: this.client,
17
+ keyPrefix: this.prefix,
18
+ points: max,
19
+ duration: Math.ceil(windowMs / 1e3),
20
+ });
21
+ this.limiters.set(configKey, limiter);
22
+ }
23
+ return limiter;
24
+ }
25
+ async check(key, max, windowMs) {
26
+ return consumeAndConvert(this.getLimiter(max, windowMs), key);
27
+ }
28
+ destroy() {
29
+ this.limiters.clear();
30
+ }
31
+ }
32
+ export default RedisRateLimitStore;
@@ -0,0 +1,33 @@
1
+ import { ErrorCodes } from '../constants.js';
2
+ function defaultKeyFn(req) {
3
+ const forwarded = req.headers['x-forwarded-for'];
4
+ if (forwarded) return forwarded.split(',')[0].trim();
5
+ return req.headers['x-real-ip'] ?? 'unknown';
6
+ }
7
+ function createRateLimitMiddleware(options, store) {
8
+ const { max, message } = options;
9
+ const windowMs = options.windowMs ?? 60000;
10
+ const keyFn = options.keyFn ?? defaultKeyFn;
11
+ return async (ctx) => {
12
+ const key = keyFn(ctx.req);
13
+ const result = await store.check(key, max, windowMs);
14
+ if (!result.allowed) {
15
+ const resetTimestamp = Math.ceil((Date.now() + result.resetMs) / 1e3);
16
+ const retryAfterSec = Math.ceil(result.resetMs / 1e3);
17
+ return {
18
+ status: 429,
19
+ error: {
20
+ code: ErrorCodes.RATE_LIMITED,
21
+ message: message ?? 'Too many requests, please try again later',
22
+ },
23
+ headers: {
24
+ 'X-RateLimit-Limit': String(max),
25
+ 'X-RateLimit-Remaining': '0',
26
+ 'X-RateLimit-Reset': String(resetTimestamp),
27
+ 'Retry-After': String(retryAfterSec),
28
+ },
29
+ };
30
+ }
31
+ };
32
+ }
33
+ export { createRateLimitMiddleware };
@@ -0,0 +1,67 @@
1
+ import IORedis from 'ioredis';
2
+ import IORedisMock from 'ioredis-mock';
3
+
4
+ class Redis {
5
+ _client = null;
6
+ _config;
7
+ _log;
8
+ _inMemory = false;
9
+
10
+ constructor(config, { log } = {}) {
11
+ this._config = config;
12
+ this._log = log;
13
+ }
14
+
15
+ get connected() {
16
+ return this._client !== null;
17
+ }
18
+
19
+ /** Whether this instance is backed by an in-memory mock. */
20
+ get inMemory() {
21
+ return this._inMemory;
22
+ }
23
+
24
+ async connect() {
25
+ if (this._config.url) {
26
+ this._client = new IORedis(this._config.url);
27
+ try {
28
+ await this._client.ping();
29
+ this._log?.info('Redis connected');
30
+ } catch (err) {
31
+ await this._client.disconnect();
32
+ this._client = null;
33
+ throw new Error(`Redis connection failed: ${err}`);
34
+ }
35
+ } else {
36
+ this._client = new IORedisMock();
37
+ this._inMemory = true;
38
+ this._log?.info('Redis: in-memory (ioredis-mock)');
39
+ }
40
+
41
+ return this;
42
+ }
43
+
44
+ /** Create an additional client with the same config (e.g. for pub/sub). */
45
+ createClient() {
46
+ if (this._inMemory) return new IORedisMock();
47
+ return new IORedis(this._config.url);
48
+ }
49
+
50
+ get client() {
51
+ return this._client;
52
+ }
53
+
54
+ get keyPrefix() {
55
+ return this._config.keyPrefix ?? 'arcway:';
56
+ }
57
+
58
+ async disconnect() {
59
+ if (this._client) {
60
+ await this._client.disconnect();
61
+ this._client = null;
62
+ this._log?.info('Redis disconnected');
63
+ }
64
+ }
65
+ }
66
+
67
+ export default Redis;
@@ -0,0 +1,44 @@
1
+ class RingBuffer {
2
+ buffer;
3
+ head = 0;
4
+ count = 0;
5
+ capacity;
6
+ constructor(capacity) {
7
+ this.capacity = capacity;
8
+ this.buffer = new Array(capacity);
9
+ }
10
+ /** Add an entry. Overwrites the oldest entry when at capacity. */
11
+ push(entry) {
12
+ this.buffer[this.head] = entry;
13
+ this.head = (this.head + 1) % this.capacity;
14
+ if (this.count < this.capacity) this.count++;
15
+ }
16
+ /** Return all entries in insertion order (oldest first). */
17
+ toArray() {
18
+ const start = this.count < this.capacity ? 0 : this.head;
19
+ const result = [];
20
+ for (let i = 0; i < this.count; i++) {
21
+ result.push(this.buffer[(start + i) % this.capacity]);
22
+ }
23
+ return result;
24
+ }
25
+ /** Find the first entry matching a predicate. */
26
+ find(predicate) {
27
+ const start = this.count < this.capacity ? 0 : this.head;
28
+ for (let i = 0; i < this.count; i++) {
29
+ const entry = this.buffer[(start + i) % this.capacity];
30
+ if (predicate(entry)) return entry;
31
+ }
32
+ return void 0;
33
+ }
34
+ /** Number of entries currently stored. */
35
+ get size() {
36
+ return this.count;
37
+ }
38
+ /** Remove all entries. */
39
+ clear() {
40
+ this.head = 0;
41
+ this.count = 0;
42
+ }
43
+ }
44
+ export default RingBuffer;
@@ -0,0 +1,4 @@
1
+ function defineRoute(config) {
2
+ return config;
3
+ }
4
+ export { defineRoute };
@@ -0,0 +1,317 @@
1
+ import { Readable } from 'node:stream';
2
+ import { pipeline } from 'node:stream/promises';
3
+ import { discoverRoutes, matchRoute, compilePattern } from './routes.js';
4
+ import { discoverMiddleware, getMiddlewareForRoute, buildMiddlewareChain } from './middleware.js';
5
+ import { ErrorCodes } from '../constants.js';
6
+ import { sealSession, buildSessionSetCookie, buildSessionClearCookie } from '../session/index.js';
7
+ import { flattenHeaders } from '../session/helpers.js';
8
+ import { validateRequestSchema } from '../validation.js';
9
+ import { buildContext } from '../context.js';
10
+ import { toErrorMessage } from '../helpers.js';
11
+
12
+ function normalizePrefix(rawPrefix) {
13
+ if (rawPrefix === '') return '';
14
+ return ('/' + rawPrefix.replace(/^\/+|\/+$/g, '')).replace(/^\/$/, '');
15
+ }
16
+
17
+ function applyPrefix(routes, middleware, prefix) {
18
+ if (!prefix) return;
19
+ for (const route of routes) {
20
+ route.pattern = prefix + route.pattern;
21
+ const compiled = compilePattern(route.pattern);
22
+ route.regex = compiled.regex;
23
+ route.paramNames = compiled.paramNames;
24
+ }
25
+ for (const mw of middleware) {
26
+ if (mw.pathPrefix === '/') {
27
+ mw.pathPrefix = prefix;
28
+ } else {
29
+ mw.pathPrefix = prefix + mw.pathPrefix;
30
+ }
31
+ }
32
+ }
33
+
34
+ function sendJson(res, statusCode, body, headers) {
35
+ const json = JSON.stringify(body);
36
+ res.writeHead(statusCode, {
37
+ 'Content-Type': 'application/json',
38
+ 'Content-Length': Buffer.byteLength(json),
39
+ ...headers,
40
+ });
41
+ res.end(json);
42
+ }
43
+
44
+ const validateRequest = validateRequestSchema;
45
+
46
+ async function serializeResponse(res, response, responseHeaders, statusCode) {
47
+ const customContentType = responseHeaders['Content-Type'] || responseHeaders['content-type'];
48
+ if (!customContentType || customContentType.includes('application/json')) {
49
+ const responseBody = response.error ? { error: response.error } : { data: response.data };
50
+ sendJson(res, statusCode, responseBody, responseHeaders);
51
+ return;
52
+ }
53
+
54
+ const body = response.error
55
+ ? typeof response.error === 'string'
56
+ ? response.error
57
+ : JSON.stringify(response.error)
58
+ : (response.data ?? '');
59
+
60
+ if (Buffer.isBuffer(body)) {
61
+ res.writeHead(statusCode, { 'Content-Length': body.length, ...responseHeaders });
62
+ res.end(body);
63
+ return;
64
+ }
65
+ if (body instanceof Readable) {
66
+ res.writeHead(statusCode, responseHeaders);
67
+ await pipeline(body, res);
68
+ return;
69
+ }
70
+ if (typeof body === 'object' && body !== null && typeof body.getReader === 'function') {
71
+ const nodeStream = Readable.fromWeb(body);
72
+ res.writeHead(statusCode, responseHeaders);
73
+ await pipeline(nodeStream, res);
74
+ return;
75
+ }
76
+
77
+ const raw = typeof body === 'string' ? body : String(body);
78
+ res.writeHead(statusCode, { 'Content-Length': Buffer.byteLength(raw), ...responseHeaders });
79
+ res.end(raw);
80
+ }
81
+
82
+ class ApiRouter {
83
+ _config;
84
+ _log;
85
+ _fileWatcher;
86
+ _appContext;
87
+ _sessionConfig;
88
+ _routes = [];
89
+ _middleware = [];
90
+ _prefix = '';
91
+ _reloading = false;
92
+ _pendingReload = false;
93
+
94
+ constructor(config, { log, fileWatcher, appContext, sessionConfig } = {}) {
95
+ this._config = config;
96
+ this._log = log;
97
+ this._fileWatcher = fileWatcher ?? null;
98
+ this._appContext = appContext ?? null;
99
+ this._sessionConfig = sessionConfig ?? null;
100
+ }
101
+
102
+ get routes() {
103
+ return this._routes;
104
+ }
105
+
106
+ get middleware() {
107
+ return this._middleware;
108
+ }
109
+
110
+ get prefix() {
111
+ return this._prefix;
112
+ }
113
+
114
+ findRoute(method, path) {
115
+ return matchRoute(this._routes, method, path);
116
+ }
117
+
118
+ async executeRoute(route, reqInfo) {
119
+ const middlewareFns = getMiddlewareForRoute(this._middleware, route.pattern);
120
+ const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
121
+ const ctx = this._buildCtx(reqInfo);
122
+ try {
123
+ return await chainedHandler(ctx);
124
+ } catch (err) {
125
+ this._log.error(`Handler error in ${route.method} ${route.pattern}`, {
126
+ error: toErrorMessage(err),
127
+ });
128
+ return {
129
+ status: 500,
130
+ error: { code: ErrorCodes.HANDLER_ERROR, message: 'An internal error occurred' },
131
+ };
132
+ }
133
+ }
134
+
135
+ buildCtx(reqInfo) {
136
+ return this._buildCtx(reqInfo);
137
+ }
138
+
139
+ _buildCtx(reqInfo) {
140
+ return buildContext(
141
+ {
142
+ ...this._appContext,
143
+ log: this._log.extend({ requestId: reqInfo.requestId }),
144
+ },
145
+ { req: reqInfo },
146
+ );
147
+ }
148
+
149
+ async init() {
150
+ if (this._config.enabled === false) {
151
+ this._log?.info('API: disabled');
152
+ return;
153
+ }
154
+ this._prefix = normalizePrefix(this._config.pathPrefix);
155
+ this._log?.info('Discovering routes...');
156
+ await this._discover();
157
+ if (this._fileWatcher) {
158
+ this._fileWatcher.subscribe('routes', {
159
+ dirs: ['api'],
160
+ extensions: ['.js'],
161
+ debounceMs: 200,
162
+ onChange: (events) => {
163
+ for (const e of events) {
164
+ this._log?.info(
165
+ `Route ${e.event === 'unlink' ? 'removed' : e.event === 'add' ? 'added' : 'changed'}: ${e.relativePath}`,
166
+ );
167
+ }
168
+ return this._reload();
169
+ },
170
+ });
171
+ this._log?.info('Routes: watching for changes');
172
+ }
173
+ }
174
+
175
+ async handle(req, res) {
176
+ const method = req.method ?? 'GET';
177
+ const pathname = (req.url ?? '/').split('?')[0];
178
+ const requestId = req.requestId;
179
+ const startTime = Date.now();
180
+
181
+ const matched = matchRoute(this._routes, method, pathname);
182
+ if (!matched) return false;
183
+
184
+ const { route, params } = matched;
185
+
186
+ // ── Validation ──
187
+ const mergedQuery = { ...(req.query ?? {}), ...params };
188
+ const validated = validateRequest(route.config.schema, mergedQuery, req.body);
189
+ if (validated.error) {
190
+ sendJson(res, 400, { error: validated.error });
191
+ return true;
192
+ }
193
+
194
+ const reqInfo = {
195
+ requestId,
196
+ method,
197
+ path: pathname,
198
+ query: validated.query,
199
+ body: validated.body,
200
+ headers: req.flatHeaders ?? flattenHeaders(req.headers),
201
+ cookies: req.cookies ?? {},
202
+ session: req.session,
203
+ };
204
+
205
+ // ── Middleware + handler ──
206
+ const middlewareFns = getMiddlewareForRoute(this._middleware, route.pattern);
207
+ const middlewareNames = middlewareFns.map((mw) => mw.name || 'anonymous');
208
+ const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
209
+ const ctx = this._buildCtx(reqInfo);
210
+
211
+ let response;
212
+ try {
213
+ response = await chainedHandler(ctx);
214
+ } catch (err) {
215
+ const errorMsg = toErrorMessage(err);
216
+ ctx.log.error(`Handler error in ${route.method} ${route.pattern}`, { error: errorMsg });
217
+ ctx.log.info('request', {
218
+ method,
219
+ path: pathname,
220
+ route: route.pattern,
221
+ status: 500,
222
+ durationMs: Date.now() - startTime,
223
+ middleware: middlewareNames,
224
+ error: errorMsg,
225
+ });
226
+ sendJson(res, 500, {
227
+ error: { code: ErrorCodes.HANDLER_ERROR, message: 'An internal error occurred' },
228
+ });
229
+ return true;
230
+ }
231
+
232
+ // ── Session cookie ──
233
+ const responseHeaders = { ...response.headers };
234
+ if (response.session !== void 0) {
235
+ if (!this._sessionConfig) {
236
+ throw new Error(
237
+ `Route handler for ${route.method} ${route.pattern} returned "session" but no session is configured. Add a "session" section with a password to your arcway.config.`,
238
+ );
239
+ }
240
+ if (response.session === null) {
241
+ responseHeaders['Set-Cookie'] = buildSessionClearCookie(this._sessionConfig);
242
+ } else {
243
+ const sealed = await sealSession(response.session, this._sessionConfig);
244
+ responseHeaders['Set-Cookie'] = buildSessionSetCookie(sealed, this._sessionConfig);
245
+ }
246
+ }
247
+
248
+ // ── Serialize + send ──
249
+ const statusCode = response.status ?? (response.error ? 400 : 200);
250
+ await serializeResponse(res, response, responseHeaders, statusCode);
251
+ ctx.log.info('request', {
252
+ method,
253
+ path: pathname,
254
+ route: route.pattern,
255
+ status: statusCode,
256
+ durationMs: Date.now() - startTime,
257
+ middleware: middlewareNames,
258
+ });
259
+
260
+ return true;
261
+ }
262
+
263
+ async _reload() {
264
+ if (this._reloading) {
265
+ this._pendingReload = true;
266
+ return;
267
+ }
268
+ this._reloading = true;
269
+ try {
270
+ const start = Date.now();
271
+ await this._discover();
272
+ this._log?.info(
273
+ `Routes reloaded in ${Date.now() - start}ms (${this._routes.length} routes, ${this._middleware.length} middleware)`,
274
+ );
275
+ } catch (err) {
276
+ this._log?.error('Route reload failed', { error: String(err) });
277
+ } finally {
278
+ this._reloading = false;
279
+ if (this._pendingReload) {
280
+ this._pendingReload = false;
281
+ await this._reload();
282
+ }
283
+ }
284
+ }
285
+
286
+ async close() {
287
+ if (this._fileWatcher) {
288
+ this._fileWatcher.unsubscribe('routes');
289
+ }
290
+ }
291
+
292
+ async _discover() {
293
+ const [routes, middleware] = await Promise.all([
294
+ discoverRoutes(this._config.dir),
295
+ discoverMiddleware(this._config.dir),
296
+ ]);
297
+
298
+ applyPrefix(routes, middleware, this._prefix);
299
+
300
+ this._routes.length = 0;
301
+ this._routes.push(...routes);
302
+ this._middleware.length = 0;
303
+ this._middleware.push(...middleware);
304
+
305
+ if (this._prefix) {
306
+ this._log?.info(`API path prefix: ${this._prefix}`);
307
+ }
308
+ for (const route of this._routes) {
309
+ this._log?.info(` ${route.method} ${route.pattern}`);
310
+ }
311
+ for (const mw of this._middleware) {
312
+ this._log?.info(` ${mw.pathPrefix} (${mw.fns.length} fn(s))`);
313
+ }
314
+ }
315
+ }
316
+
317
+ export { ApiRouter, applyPrefix, normalizePrefix };
@@ -0,0 +1,31 @@
1
+ function buildCorsHeaders(config, requestOrigin) {
2
+ const headers = {};
3
+ if (Array.isArray(config.origin)) {
4
+ if (requestOrigin && config.origin.includes(requestOrigin)) {
5
+ headers['Access-Control-Allow-Origin'] = requestOrigin;
6
+ headers['Vary'] = 'Origin';
7
+ }
8
+ } else if (config.origin === '*' && config.credentials) {
9
+ if (requestOrigin) {
10
+ headers['Access-Control-Allow-Origin'] = requestOrigin;
11
+ headers['Vary'] = 'Origin';
12
+ }
13
+ } else {
14
+ headers['Access-Control-Allow-Origin'] = config.origin;
15
+ }
16
+ if (config.credentials) {
17
+ headers['Access-Control-Allow-Credentials'] = 'true';
18
+ }
19
+ if (config.exposedHeaders.length > 0) {
20
+ headers['Access-Control-Expose-Headers'] = config.exposedHeaders.join(', ');
21
+ }
22
+ return headers;
23
+ }
24
+ function buildPreflightHeaders(config) {
25
+ return {
26
+ 'Access-Control-Allow-Methods': config.methods.join(', '),
27
+ 'Access-Control-Allow-Headers': config.allowedHeaders.join(', '),
28
+ 'Access-Control-Max-Age': String(config.maxAge),
29
+ };
30
+ }
31
+ export { buildCorsHeaders, buildPreflightHeaders };