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,318 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { matchRoute } from '../router/routes.js';
4
+ import { getMiddlewareForRoute, buildMiddlewareChain } from '../router/middleware.js';
5
+ import { parseCookiesFromHeader, resolveSession, flattenHeaders } from '../session/helpers.js';
6
+ import { toErrorMessage } from '../helpers.js';
7
+ import { buildContext } from '../context.js';
8
+ import { registerWsServer, unregisterWsServer } from './registry.js';
9
+ function createRealtimeServer(options) {
10
+ const {
11
+ server,
12
+ routes,
13
+ middleware,
14
+ appContext,
15
+ session: sessionConfig,
16
+ log,
17
+ path: wsPath = '/ws',
18
+ } = options;
19
+ const wss = new WebSocketServer({ noServer: true });
20
+ const clients = new Map();
21
+ const clientsBySocketId = new Map();
22
+ function buildCtx(reqInfo) {
23
+ const requestLog = {
24
+ debug(message, data) {
25
+ appContext.log.debug(message, { ...data, requestId: reqInfo.requestId });
26
+ },
27
+ info(message, data) {
28
+ appContext.log.info(message, { ...data, requestId: reqInfo.requestId });
29
+ },
30
+ warn(message, data) {
31
+ appContext.log.warn(message, { ...data, requestId: reqInfo.requestId });
32
+ },
33
+ error(message, data) {
34
+ appContext.log.error(message, { ...data, requestId: reqInfo.requestId });
35
+ },
36
+ };
37
+ return buildContext({ ...appContext, log: requestLog }, { req: reqInfo });
38
+ }
39
+ function sendToClient(ws, msg) {
40
+ if (ws.readyState === WebSocket.OPEN) {
41
+ ws.send(JSON.stringify(msg));
42
+ }
43
+ }
44
+ function sendToSocket(socketId, path, response) {
45
+ const client = clientsBySocketId.get(socketId);
46
+ if (!client) return;
47
+ sendToClient(client.ws, {
48
+ path,
49
+ data: response.data,
50
+ error: response.error,
51
+ status: response.status,
52
+ });
53
+ }
54
+ function broadcastToPath(path, response) {
55
+ const msg = {
56
+ path,
57
+ data: response.data,
58
+ error: response.error,
59
+ status: response.status,
60
+ };
61
+ for (const client of clients.values()) {
62
+ if (client.subscriptions.has(path)) {
63
+ sendToClient(client.ws, msg);
64
+ }
65
+ }
66
+ }
67
+ function buildRequestInfo(client, wsMsg, params) {
68
+ const mergedQuery = {
69
+ ...(wsMsg.query ?? {}),
70
+ ...params,
71
+ };
72
+ return {
73
+ requestId: randomUUID(),
74
+ method: wsMsg.method,
75
+ path: wsMsg.path,
76
+ query: mergedQuery,
77
+ body: wsMsg.body,
78
+ headers: client.headers,
79
+ cookies: client.cookies,
80
+ session: client.session,
81
+ socketId: client.socketId,
82
+ };
83
+ }
84
+ async function runHandler(route, reqInfo) {
85
+ const middlewareFns = getMiddlewareForRoute(middleware, route.pattern);
86
+ const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
87
+ const ctx = buildCtx(reqInfo);
88
+ try {
89
+ return await chainedHandler(ctx);
90
+ } catch (err) {
91
+ log.error(`WS handler error in ${route.method} ${route.pattern}`, {
92
+ error: toErrorMessage(err),
93
+ });
94
+ return {
95
+ status: 500,
96
+ error: { code: 'HANDLER_ERROR', message: 'An internal error occurred' },
97
+ };
98
+ }
99
+ }
100
+ async function handleSubscribe(client, wsMsg) {
101
+ const path = wsMsg.path;
102
+ if (client.subscriptions.has(path)) {
103
+ sendToClient(client.ws, {
104
+ path,
105
+ error: { code: 'ALREADY_SUBSCRIBED', message: 'Already subscribed to this path' },
106
+ });
107
+ return;
108
+ }
109
+ const matched = matchRoute(routes, 'GET', path);
110
+ if (!matched) {
111
+ sendToClient(client.ws, {
112
+ path,
113
+ error: { code: 'NOT_FOUND', message: `No GET route matches ${path}` },
114
+ status: 404,
115
+ });
116
+ return;
117
+ }
118
+ const { route, params } = matched;
119
+ if (!route.wsEnabled || typeof route.config.ws !== 'function') {
120
+ sendToClient(client.ws, {
121
+ path,
122
+ error: {
123
+ code: 'WS_NOT_SUPPORTED',
124
+ message: 'This route does not support WebSocket subscriptions',
125
+ },
126
+ status: 400,
127
+ });
128
+ return;
129
+ }
130
+ const reqInfo = buildRequestInfo(client, { ...wsMsg, method: 'GET' }, params);
131
+ const ctx = buildCtx(reqInfo);
132
+ let cleanup;
133
+ try {
134
+ cleanup = await route.config.ws(ctx);
135
+ } catch (err) {
136
+ log.error(`WS subscribe error for ${path}`, {
137
+ error: toErrorMessage(err),
138
+ });
139
+ sendToClient(client.ws, {
140
+ path,
141
+ error: { code: 'SUBSCRIBE_ERROR', message: 'Subscription setup failed' },
142
+ status: 500,
143
+ });
144
+ return;
145
+ }
146
+ client.subscriptions.set(path, {
147
+ routePattern: route.pattern,
148
+ cleanup: cleanup ?? void 0,
149
+ req: reqInfo,
150
+ });
151
+ const response = await runHandler(route, reqInfo);
152
+ sendToClient(client.ws, { path, ...response, id: wsMsg.id });
153
+ }
154
+ async function handleUnsubscribe(client, wsMsg) {
155
+ const path = wsMsg.path;
156
+ const sub = client.subscriptions.get(path);
157
+ if (!sub) {
158
+ sendToClient(client.ws, {
159
+ path,
160
+ error: { code: 'NOT_SUBSCRIBED', message: 'Not subscribed to this path' },
161
+ });
162
+ return;
163
+ }
164
+ if (sub.cleanup) {
165
+ try {
166
+ await sub.cleanup();
167
+ } catch (err) {
168
+ log.error(`WS unsubscribe cleanup error for ${path}`, {
169
+ error: toErrorMessage(err),
170
+ });
171
+ }
172
+ }
173
+ client.subscriptions.delete(path);
174
+ }
175
+ async function handleMethodCall(client, wsMsg) {
176
+ const { path, method } = wsMsg;
177
+ const matched = matchRoute(routes, method, path);
178
+ if (!matched) {
179
+ sendToClient(client.ws, {
180
+ path,
181
+ error: { code: 'NOT_FOUND', message: `No ${method} route matches ${path}` },
182
+ status: 404,
183
+ id: wsMsg.id,
184
+ });
185
+ return;
186
+ }
187
+ const { route, params } = matched;
188
+ const reqInfo = buildRequestInfo(client, wsMsg, params);
189
+ const response = await runHandler(route, reqInfo);
190
+ sendToClient(client.ws, { path, ...response, id: wsMsg.id });
191
+ if (!response.error) {
192
+ const msg = { path, data: response.data, status: response.status };
193
+ for (const c of clients.values()) {
194
+ if (c !== client && c.subscriptions.has(path)) {
195
+ sendToClient(c.ws, msg);
196
+ }
197
+ }
198
+ }
199
+ }
200
+ async function cleanupClient(client) {
201
+ for (const [path, sub] of client.subscriptions) {
202
+ if (sub.cleanup) {
203
+ try {
204
+ await sub.cleanup();
205
+ } catch (err) {
206
+ log.error(`WS cleanup error for ${path}`, {
207
+ error: toErrorMessage(err),
208
+ });
209
+ }
210
+ }
211
+ }
212
+ client.subscriptions.clear();
213
+ clients.delete(client.ws);
214
+ clientsBySocketId.delete(client.socketId);
215
+ }
216
+ server.on('upgrade', async (req, socket, head) => {
217
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
218
+ if (url.pathname !== wsPath) return;
219
+ const cookies = parseCookiesFromHeader(req.headers.cookie ?? '');
220
+ const session = await resolveSession(cookies, sessionConfig);
221
+ const headers = flattenHeaders(req.headers);
222
+ wss.handleUpgrade(req, socket, head, (ws) => {
223
+ wss.emit('connection', ws, { session, headers, cookies });
224
+ });
225
+ });
226
+ wss.on('connection', (ws, extra) => {
227
+ const socketId = randomUUID();
228
+ const client = {
229
+ ws,
230
+ socketId,
231
+ subscriptions: new Map(),
232
+ session: extra.session,
233
+ headers: extra.headers,
234
+ cookies: extra.cookies,
235
+ isAlive: true,
236
+ };
237
+ clients.set(ws, client);
238
+ clientsBySocketId.set(socketId, client);
239
+ sendToClient(ws, { path: wsPath, data: { socketId }, status: 200 });
240
+ ws.on('pong', () => {
241
+ client.isAlive = true;
242
+ });
243
+ ws.on('message', async (raw) => {
244
+ let parsed;
245
+ try {
246
+ parsed = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf-8'));
247
+ } catch {
248
+ sendToClient(ws, { path: '', error: { code: 'INVALID_JSON', message: 'Invalid JSON' } });
249
+ return;
250
+ }
251
+ if (!parsed.path || !parsed.method) {
252
+ sendToClient(ws, {
253
+ path: '',
254
+ error: {
255
+ code: 'INVALID_MESSAGE',
256
+ message: 'Message must have "path" and "method" fields',
257
+ },
258
+ });
259
+ return;
260
+ }
261
+ const method = parsed.method.toUpperCase();
262
+ if (method === 'SUBSCRIBE') {
263
+ await handleSubscribe(client, { ...parsed, method });
264
+ } else if (method === 'UNSUBSCRIBE') {
265
+ await handleUnsubscribe(client, { ...parsed, method });
266
+ } else if (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
267
+ await handleMethodCall(client, { ...parsed, method });
268
+ } else {
269
+ sendToClient(ws, {
270
+ path: parsed.path,
271
+ error: { code: 'INVALID_METHOD', message: `Unsupported method: ${method}` },
272
+ });
273
+ }
274
+ });
275
+ ws.on('close', () => {
276
+ cleanupClient(client).catch((err) => {
277
+ log.error('WS client cleanup error on disconnect', { error: String(err) });
278
+ });
279
+ });
280
+ ws.on('error', (err) => {
281
+ log.error('WebSocket connection error', { error: String(err), socketId });
282
+ });
283
+ });
284
+ registerWsServer({ sendToSocket, broadcastToPath });
285
+ const pingIntervalMs = options.pingIntervalMs ?? 3e4;
286
+ let pingInterval;
287
+ if (pingIntervalMs > 0) {
288
+ pingInterval = setInterval(() => {
289
+ for (const client of clients.values()) {
290
+ if (!client.isAlive) {
291
+ log.debug('Terminating unresponsive WebSocket client', { socketId: client.socketId });
292
+ client.ws.terminate();
293
+ continue;
294
+ }
295
+ client.isAlive = false;
296
+ client.ws.ping();
297
+ }
298
+ }, pingIntervalMs);
299
+ }
300
+ return {
301
+ wss,
302
+ sendToSocket,
303
+ broadcastToPath,
304
+ close() {
305
+ unregisterWsServer();
306
+ if (pingInterval) clearInterval(pingInterval);
307
+ const cleanupPromises = [];
308
+ for (const client of clients.values()) {
309
+ cleanupPromises.push(cleanupClient(client));
310
+ client.ws.close(1001, 'Server shutting down');
311
+ }
312
+ Promise.allSettled(cleanupPromises).then(() => {
313
+ wss.close();
314
+ });
315
+ },
316
+ };
317
+ }
318
+ export { createRealtimeServer };
@@ -0,0 +1,17 @@
1
+ const GLOBAL_KEY = Symbol.for('arcway.wsRegistry');
2
+ function registerWsServer(registry) {
3
+ globalThis[GLOBAL_KEY] = registry;
4
+ }
5
+ function unregisterWsServer() {
6
+ delete globalThis[GLOBAL_KEY];
7
+ }
8
+ function getRegistry() {
9
+ return globalThis[GLOBAL_KEY];
10
+ }
11
+ function wsSendToSocket(socketId, path, response) {
12
+ getRegistry()?.sendToSocket(socketId, path, response);
13
+ }
14
+ function wsBroadcastToPath(path, response) {
15
+ getRegistry()?.broadcastToPath(path, response);
16
+ }
17
+ export { registerWsServer, unregisterWsServer, wsBroadcastToPath, wsSendToSocket };
@@ -0,0 +1,152 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { parseCookiesFromHeader, resolveSession, flattenHeaders } from '../session/helpers.js';
4
+ function createWebSocketServer(options) {
5
+ const {
6
+ server,
7
+ onConnect,
8
+ onClose,
9
+ messages,
10
+ session: sessionConfig,
11
+ log,
12
+ path: wsPath = '/ws',
13
+ } = options;
14
+ const wss = new WebSocketServer({ noServer: true });
15
+ const clients = new Map();
16
+ function publish(topic, data) {
17
+ const payload = JSON.stringify(data);
18
+ for (const client of clients.values()) {
19
+ if (client.topics.has(topic) && client.ws.readyState === WebSocket.OPEN) {
20
+ client.ws.send(payload);
21
+ }
22
+ }
23
+ }
24
+ function broadcast(data) {
25
+ const payload = JSON.stringify(data);
26
+ for (const client of clients.values()) {
27
+ if (client.ws.readyState === WebSocket.OPEN) {
28
+ client.ws.send(payload);
29
+ }
30
+ }
31
+ }
32
+ server.on('upgrade', async (req, socket, head) => {
33
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
34
+ if (url.pathname !== wsPath) {
35
+ return;
36
+ }
37
+ const headers = flattenHeaders(req.headers);
38
+ const cookies = parseCookiesFromHeader(req.headers.cookie ?? '');
39
+ const session = await resolveSession(cookies, sessionConfig);
40
+ wss.handleUpgrade(req, socket, head, (ws) => {
41
+ wss.emit('connection', ws, req, { session, headers, cookies });
42
+ });
43
+ });
44
+ wss.on('connection', async (ws, _req, extra) => {
45
+ const connectionId = randomUUID();
46
+ const client = { ws, connectionId, topics: new Set() };
47
+ clients.set(ws, client);
48
+ const ctx = {
49
+ session: extra.session,
50
+ connectionId,
51
+ headers: extra.headers,
52
+ cookies: extra.cookies,
53
+ send: (data) => {
54
+ if (ws.readyState === WebSocket.OPEN) {
55
+ ws.send(JSON.stringify(data));
56
+ }
57
+ },
58
+ close: (code, reason) => {
59
+ ws.close(code, reason);
60
+ },
61
+ subscribe: (topic) => {
62
+ client.topics.add(topic);
63
+ },
64
+ unsubscribe: (topic) => {
65
+ client.topics.delete(topic);
66
+ },
67
+ publish,
68
+ broadcast,
69
+ log,
70
+ };
71
+ for (const handler of onConnect) {
72
+ try {
73
+ const result = handler.fn(ctx);
74
+ const resolved = result instanceof Promise ? await result : result;
75
+ if (resolved && typeof resolved === 'object' && 'reject' in resolved && resolved.reject) {
76
+ const code = resolved.code ?? 4403;
77
+ const reason = resolved.reason ?? 'Connection rejected';
78
+ log.info(`WebSocket connection rejected by ${'ws'}`, { connectionId, code, reason });
79
+ ws.close(code, reason);
80
+ clients.delete(ws);
81
+ return;
82
+ }
83
+ } catch (err) {
84
+ log.error(`WebSocket onConnect error (${'ws'})`, { error: String(err) });
85
+ }
86
+ }
87
+ ws.on('message', async (raw) => {
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf-8'));
91
+ } catch {
92
+ ctx.send({ type: 'error', data: { message: 'Invalid JSON' } });
93
+ return;
94
+ }
95
+ const msgType = parsed?.type;
96
+ if (typeof msgType !== 'string') {
97
+ ctx.send({ type: 'error', data: { message: 'Message must have a "type" field' } });
98
+ return;
99
+ }
100
+ if (msgType === 'subscribe' && typeof parsed.topic === 'string') {
101
+ client.topics.add(parsed.topic);
102
+ return;
103
+ }
104
+ if (msgType === 'unsubscribe' && typeof parsed.topic === 'string') {
105
+ client.topics.delete(parsed.topic);
106
+ return;
107
+ }
108
+ const handler = messages.get(msgType);
109
+ if (!handler) {
110
+ ctx.send({ type: 'error', data: { message: `Unknown message type: "${msgType}"` } });
111
+ return;
112
+ }
113
+ try {
114
+ await handler.fn(ctx, parsed.data);
115
+ } catch (err) {
116
+ log.error(`WebSocket message handler error (${'ws'}/${msgType})`, {
117
+ error: String(err),
118
+ });
119
+ ctx.send({ type: 'error', data: { message: 'Internal server error' } });
120
+ }
121
+ });
122
+ ws.on('close', (code, reason) => {
123
+ const reasonStr = reason.toString('utf-8');
124
+ for (const handler of onClose) {
125
+ try {
126
+ const result = handler.fn(ctx, code, reasonStr);
127
+ if (result instanceof Promise) {
128
+ result.catch((err) =>
129
+ log.error(`WebSocket onClose error (${'ws'})`, { error: String(err) }),
130
+ );
131
+ }
132
+ } catch (err) {
133
+ log.error(`WebSocket onClose error (${'ws'})`, { error: String(err) });
134
+ }
135
+ }
136
+ clients.delete(ws);
137
+ });
138
+ ws.on('error', (err) => {
139
+ log.error('WebSocket connection error', { error: String(err), connectionId });
140
+ });
141
+ });
142
+ return {
143
+ wss,
144
+ close: () => {
145
+ for (const client of clients.values()) {
146
+ client.ws.close(1001, 'Server shutting down');
147
+ }
148
+ wss.close();
149
+ },
150
+ };
151
+ }
152
+ export { createWebSocketServer };