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
package/server/env.js ADDED
@@ -0,0 +1,17 @@
1
+ import path from 'node:path';
2
+ import dotenv from 'dotenv';
3
+
4
+ function loadEnvFiles(rootDir, mode) {
5
+ const files = ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`];
6
+ const loaded = [];
7
+ for (const file of files) {
8
+ const filePath = path.join(rootDir, file);
9
+ const result = dotenv.config({ path: filePath, quiet: true });
10
+ if (!result.error) {
11
+ loaded.push(file);
12
+ }
13
+ }
14
+ return loaded;
15
+ }
16
+
17
+ export { loadEnvFiles };
@@ -0,0 +1,45 @@
1
+ import { patternToRegex } from '../pattern.js';
2
+ class MemoryTransport {
3
+ subscriptions = [];
4
+ /** Subscribe to events matching a pattern. */
5
+ subscribe(pattern, handler) {
6
+ const regex = patternToRegex(pattern);
7
+ this.subscriptions.push({ pattern, regex, handler });
8
+ }
9
+ /**
10
+ * Emit an event. All matching subscribers are called concurrently.
11
+ * Errors in individual listeners are caught and collected — one failing
12
+ * listener does not prevent others from running.
13
+ */
14
+ async emit(eventName, payload) {
15
+ const matching = this.subscriptions.filter((sub) => sub.regex.test(eventName));
16
+ if (matching.length === 0) {
17
+ return;
18
+ }
19
+ const errors = [];
20
+ const results = await Promise.allSettled(
21
+ matching.map((sub) => sub.handler(eventName, payload)),
22
+ );
23
+ for (let i = 0; i < results.length; i++) {
24
+ const result = results[i];
25
+ if (result.status === 'rejected') {
26
+ const sub = matching[i];
27
+ errors.push({ pattern: sub.pattern, error: result.reason });
28
+ console.error(
29
+ `Event listener error [${sub.pattern}] for event "${eventName}":`,
30
+ result.reason,
31
+ );
32
+ }
33
+ }
34
+ if (errors.length > 0 && errors.length === matching.length) {
35
+ throw new Error(`All ${errors.length} listener(s) for event "${eventName}" failed`);
36
+ }
37
+ }
38
+ async disconnect() {}
39
+
40
+ /** Get the number of registered subscriptions. */
41
+ get size() {
42
+ return this.subscriptions.length;
43
+ }
44
+ }
45
+ export default MemoryTransport;
@@ -0,0 +1,64 @@
1
+ import { patternToRegex } from '../pattern.js';
2
+ class RedisTransport {
3
+ pub;
4
+ sub;
5
+ prefix;
6
+ subscriptions = [];
7
+ listening = false;
8
+ constructor(pub, sub, keyPrefix = 'arcway:') {
9
+ this.pub = pub;
10
+ this.sub = sub;
11
+ this.prefix = `${keyPrefix}events:`;
12
+ }
13
+ subscribe(pattern, handler) {
14
+ const regex = patternToRegex(pattern);
15
+ this.subscriptions.push({ pattern, regex, handler });
16
+ if (!this.listening) {
17
+ this.listening = true;
18
+ this.startListening();
19
+ }
20
+ }
21
+ async emit(eventName, payload) {
22
+ const channel = `${this.prefix}${eventName}`;
23
+ const message = JSON.stringify({ event: eventName, payload });
24
+ await this.pub.publish(channel, message);
25
+ }
26
+ startListening() {
27
+ const channelPattern = `${this.prefix}*`;
28
+ this.sub.psubscribe(channelPattern).catch((err) => {
29
+ console.error('[RedisTransport] Failed to psubscribe:', err);
30
+ });
31
+ this.sub.on('pmessage', (_pattern, _channel, message) => {
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(message);
35
+ } catch {
36
+ console.warn('[RedisTransport] Malformed event message, skipping');
37
+ return;
38
+ }
39
+ const { event: eventName, payload } = parsed;
40
+ const matching = this.subscriptions.filter((sub) => sub.regex.test(eventName));
41
+ if (matching.length === 0) return;
42
+ Promise.allSettled(matching.map((sub) => sub.handler(eventName, payload))).then((results) => {
43
+ for (let i = 0; i < results.length; i++) {
44
+ if (results[i].status === 'rejected') {
45
+ console.error(
46
+ `Event listener error [${matching[i].pattern}] for event "${eventName}":`,
47
+ results[i].reason,
48
+ );
49
+ }
50
+ }
51
+ });
52
+ });
53
+ }
54
+ /** Disconnect both pub and sub clients. */
55
+ async disconnect() {
56
+ await this.sub.punsubscribe();
57
+ this.sub.disconnect();
58
+ this.pub.disconnect();
59
+ }
60
+ get size() {
61
+ return this.subscriptions.length;
62
+ }
63
+ }
64
+ export default RedisTransport;
@@ -0,0 +1,67 @@
1
+ import path from 'node:path';
2
+ import { discoverModules } from '../discovery.js';
3
+ import { buildContext } from '../context.js';
4
+
5
+ function listenerPathToEvent(relativePath) {
6
+ return relativePath
7
+ .replace(/\\/g, '/')
8
+ .replace(/\.js$/, '')
9
+ .replace(/\[\.\.\.([^\]]+)\]/g, '*');
10
+ }
11
+
12
+ function validateListener(exported, filePath) {
13
+ if (!exported) throw new Error(`Listener file at ${filePath} must have a default export`);
14
+ if (typeof exported === 'function') return { handler: exported };
15
+ if (typeof exported === 'object' && typeof exported.handler === 'function') return exported;
16
+ throw new Error(
17
+ `Listener at ${filePath} must export a function or an object with a "handler" function`,
18
+ );
19
+ }
20
+
21
+ class EventHandler {
22
+ _events;
23
+ _listenersDir;
24
+ _log;
25
+ _appContext;
26
+ _listeners = [];
27
+
28
+ constructor(config, { events, log, appContext } = {}) {
29
+ this._listenersDir = config?.listenersDir;
30
+ this._events = events;
31
+ this._log = log;
32
+ this._appContext = appContext ?? {
33
+ db: null,
34
+ events,
35
+ log,
36
+ cache: null,
37
+ queue: null,
38
+ files: null,
39
+ mail: null,
40
+ };
41
+ }
42
+
43
+ async init() {
44
+ if (!this._listenersDir) return;
45
+ const entries = await discoverModules(this._listenersDir, {
46
+ recursive: true,
47
+ label: 'listener file',
48
+ });
49
+ for (const { filePath, relativePath, module } of entries) {
50
+ const { handler, event: explicitEvent } = validateListener(module.default, filePath);
51
+ const event = explicitEvent ?? listenerPathToEvent(relativePath);
52
+ if (event.startsWith('system/') || event === 'system') continue;
53
+ this._events.subscribe(event, (_eventName, payload) => {
54
+ const ctx = buildContext(this._appContext, { event: { name: _eventName, payload } });
55
+ handler(ctx);
56
+ });
57
+ this._listeners.push({ event, fileName: relativePath });
58
+ this._log?.info(` ${relativePath} → ${event}`);
59
+ }
60
+ }
61
+
62
+ get listeners() {
63
+ return this._listeners;
64
+ }
65
+ }
66
+
67
+ export { EventHandler, listenerPathToEvent };
@@ -0,0 +1,35 @@
1
+ import MemoryTransport from './drivers/memory.js';
2
+ import RedisTransport from './drivers/redis.js';
3
+
4
+ class Events {
5
+ _transport;
6
+ _log;
7
+
8
+ constructor(config, { redis, log } = {}) {
9
+ this._log = log;
10
+
11
+ if (config?.driver === 'redis') {
12
+ const pub = redis.createClient();
13
+ const sub = redis.createClient();
14
+ this._transport = new RedisTransport(pub, sub, redis.keyPrefix);
15
+ log?.info('Event transport: redis');
16
+ } else {
17
+ this._transport = new MemoryTransport();
18
+ }
19
+ }
20
+
21
+ subscribe(pattern, handler) {
22
+ this._transport.subscribe(pattern, handler);
23
+ }
24
+
25
+ async emit(eventName, payload) {
26
+ await this._transport.emit(eventName, payload);
27
+ this._log?.debug('event', { eventName, payload });
28
+ }
29
+
30
+ async disconnect() {
31
+ await this._transport.disconnect();
32
+ }
33
+ }
34
+
35
+ export default Events;
@@ -0,0 +1,5 @@
1
+ function patternToRegex(pattern) {
2
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]+');
3
+ return new RegExp(`^${escaped}$`);
4
+ }
5
+ export { patternToRegex };
@@ -0,0 +1,83 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ class LocalFileDriver {
4
+ constructor(storageDir) {
5
+ this.storageDir = storageDir;
6
+ }
7
+ async init() {
8
+ await fs.mkdir(this.storageDir, { recursive: true });
9
+ }
10
+ resolvePath(namespace, filePath) {
11
+ const namespaceDir = path.join(this.storageDir, namespace);
12
+ const resolved = path.resolve(namespaceDir, filePath);
13
+ if (!resolved.startsWith(namespaceDir + path.sep) && resolved !== namespaceDir) {
14
+ throw new Error(`Path traversal detected: "${filePath}" escapes namespace directory`);
15
+ }
16
+ return resolved;
17
+ }
18
+ async write(namespace, filePath, data) {
19
+ const resolved = this.resolvePath(namespace, filePath);
20
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
21
+ await fs.writeFile(resolved, data);
22
+ }
23
+ async read(namespace, filePath) {
24
+ const resolved = this.resolvePath(namespace, filePath);
25
+ try {
26
+ return await fs.readFile(resolved);
27
+ } catch (err) {
28
+ if (err.code === 'ENOENT') return null;
29
+ throw err;
30
+ }
31
+ }
32
+ async delete(namespace, filePath) {
33
+ const resolved = this.resolvePath(namespace, filePath);
34
+ try {
35
+ await fs.unlink(resolved);
36
+ } catch (err) {
37
+ if (err.code === 'ENOENT') return;
38
+ throw err;
39
+ }
40
+ }
41
+ async list(namespace, prefix) {
42
+ const namespaceDir = path.join(this.storageDir, namespace);
43
+ let searchDir = namespaceDir;
44
+ if (prefix) {
45
+ const resolved = path.resolve(namespaceDir, prefix);
46
+ if (!resolved.startsWith(namespaceDir + path.sep) && resolved !== namespaceDir) {
47
+ throw new Error(`Path traversal detected: "${prefix}" escapes namespace directory`);
48
+ }
49
+ searchDir = resolved;
50
+ }
51
+ try {
52
+ const entries = await this.walkDir(searchDir);
53
+ return entries.map((entry) => path.relative(namespaceDir, entry).replace(/\\/g, '/'));
54
+ } catch (err) {
55
+ if (err.code === 'ENOENT') return [];
56
+ throw err;
57
+ }
58
+ }
59
+ async exists(namespace, filePath) {
60
+ const resolved = this.resolvePath(namespace, filePath);
61
+ try {
62
+ await fs.access(resolved);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+ async walkDir(dir) {
69
+ const results = [];
70
+ const entries = await fs.readdir(dir, { withFileTypes: true });
71
+ for (const entry of entries) {
72
+ const fullPath = path.join(dir, entry.name);
73
+ if (entry.isDirectory()) {
74
+ const nested = await this.walkDir(fullPath);
75
+ results.push(...nested);
76
+ } else {
77
+ results.push(fullPath);
78
+ }
79
+ }
80
+ return results;
81
+ }
82
+ }
83
+ export default LocalFileDriver;
@@ -0,0 +1,113 @@
1
+ import {
2
+ S3Client,
3
+ GetObjectCommand,
4
+ PutObjectCommand,
5
+ DeleteObjectCommand,
6
+ ListObjectsV2Command,
7
+ HeadObjectCommand,
8
+ } from '@aws-sdk/client-s3';
9
+ class S3FileDriver {
10
+ client;
11
+ bucket;
12
+ constructor(config) {
13
+ this.bucket = config.bucket;
14
+ this.client = new S3Client({
15
+ region: config.region ?? 'us-east-1',
16
+ ...(config.endpoint ? { endpoint: config.endpoint } : {}),
17
+ ...(config.forcePathStyle ? { forcePathStyle: true } : {}),
18
+ });
19
+ }
20
+ async init() {}
21
+
22
+ key(namespace, filePath) {
23
+ const decoded = decodeURIComponent(filePath);
24
+ if (decoded.includes('..') || decoded.startsWith('/')) {
25
+ throw new Error(`Path traversal detected: "${filePath}" escapes namespace directory`);
26
+ }
27
+ const normalized = decoded.replace(/\/+/g, '/');
28
+ return `${namespace}/${normalized}`;
29
+ }
30
+ async write(namespace, filePath, data) {
31
+ const body = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
32
+ await this.client.send(
33
+ new PutObjectCommand({
34
+ Bucket: this.bucket,
35
+ Key: this.key(namespace, filePath),
36
+ Body: body,
37
+ }),
38
+ );
39
+ }
40
+ async read(namespace, filePath) {
41
+ try {
42
+ const response = await this.client.send(
43
+ new GetObjectCommand({
44
+ Bucket: this.bucket,
45
+ Key: this.key(namespace, filePath),
46
+ }),
47
+ );
48
+ if (!response.Body) return null;
49
+ const bytes = await response.Body.transformToByteArray();
50
+ return Buffer.from(bytes);
51
+ } catch (err) {
52
+ if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) {
53
+ return null;
54
+ }
55
+ throw err;
56
+ }
57
+ }
58
+ async delete(namespace, filePath) {
59
+ await this.client.send(
60
+ new DeleteObjectCommand({
61
+ Bucket: this.bucket,
62
+ Key: this.key(namespace, filePath),
63
+ }),
64
+ );
65
+ }
66
+ async list(namespace, prefix) {
67
+ if (prefix) {
68
+ const decodedPrefix = decodeURIComponent(prefix);
69
+ if (decodedPrefix.includes('..') || decodedPrefix.startsWith('/')) {
70
+ throw new Error(`Path traversal detected: "${prefix}" escapes namespace directory`);
71
+ }
72
+ }
73
+ const s3Prefix = prefix ? `${namespace}/${prefix}` : `${namespace}/`;
74
+ const files = [];
75
+ let continuationToken;
76
+ do {
77
+ const response = await this.client.send(
78
+ new ListObjectsV2Command({
79
+ Bucket: this.bucket,
80
+ Prefix: s3Prefix,
81
+ ContinuationToken: continuationToken,
82
+ }),
83
+ );
84
+ if (response.Contents) {
85
+ for (const obj of response.Contents) {
86
+ if (obj.Key) {
87
+ const relative = obj.Key.slice(namespace.length + 1);
88
+ if (relative) files.push(relative);
89
+ }
90
+ }
91
+ }
92
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
93
+ } while (continuationToken);
94
+ return files;
95
+ }
96
+ async exists(namespace, filePath) {
97
+ try {
98
+ await this.client.send(
99
+ new HeadObjectCommand({
100
+ Bucket: this.bucket,
101
+ Key: this.key(namespace, filePath),
102
+ }),
103
+ );
104
+ return true;
105
+ } catch (err) {
106
+ if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
107
+ return false;
108
+ }
109
+ throw err;
110
+ }
111
+ }
112
+ }
113
+ export default S3FileDriver;
@@ -0,0 +1,57 @@
1
+ import LocalFileDriver from './drivers/local.js';
2
+ import S3FileDriver from './drivers/s3.js';
3
+
4
+ class Files {
5
+ _driver;
6
+ _log;
7
+ _namespace;
8
+
9
+ constructor(config, { log } = {}) {
10
+ this._log = log;
11
+ this._namespace = config?.namespace ?? '';
12
+
13
+ if (config?.driver === 's3') {
14
+ if (!config.s3) throw new Error('files.s3 config is required when driver is "s3"');
15
+ this._driver = new S3FileDriver(config.s3);
16
+ log?.info(`File driver: s3 (bucket: ${config.s3.bucket})`);
17
+ } else {
18
+ this._driver = new LocalFileDriver(config.storageDir);
19
+ log?.info('File driver: local');
20
+ }
21
+ }
22
+
23
+ async init() {
24
+ await this._driver.init();
25
+ }
26
+
27
+ async write(filePath, data) {
28
+ await this._driver.write(this._namespace, filePath, data);
29
+ }
30
+
31
+ async read(filePath) {
32
+ return this._driver.read(this._namespace, filePath);
33
+ }
34
+
35
+ async delete(filePath) {
36
+ await this._driver.delete(this._namespace, filePath);
37
+ }
38
+
39
+ async list(prefix) {
40
+ return this._driver.list(this._namespace, prefix);
41
+ }
42
+
43
+ async exists(filePath) {
44
+ return this._driver.exists(this._namespace, filePath);
45
+ }
46
+
47
+ /** Return a namespaced child that shares the same driver. */
48
+ withNamespace(namespace) {
49
+ const child = Object.create(Files.prototype);
50
+ child._driver = this._driver;
51
+ child._log = this._log;
52
+ child._namespace = namespace;
53
+ return child;
54
+ }
55
+ }
56
+
57
+ export default Files;
@@ -0,0 +1,156 @@
1
+ import path from 'node:path';
2
+ import { watch } from 'chokidar';
3
+
4
+ class FileWatcher {
5
+ _rootDir;
6
+ _log;
7
+ _ignored;
8
+ _watcher = null;
9
+ _subscribers = new Map();
10
+ _started = false;
11
+
12
+ constructor(rootDir, { log, ignored } = {}) {
13
+ this._rootDir = rootDir;
14
+ this._log = log;
15
+ this._ignored = ignored ?? [/(^|[/\\])\../, /node_modules/, /\.build/, /dist/];
16
+ }
17
+
18
+ /**
19
+ * Subscribe to file changes.
20
+ *
21
+ * @param {string} name - Unique subscriber name (e.g. 'routes', 'pages')
22
+ * @param {object} options
23
+ * @param {string[]} options.dirs - Directories to watch, relative to rootDir (e.g. ['api'])
24
+ * If omitted, matches all watched files.
25
+ * @param {string[]} options.extensions - File extensions to match (e.g. ['.js', '.ts'])
26
+ * If omitted, matches all extensions.
27
+ * @param {number} options.debounceMs - Debounce window in ms (default: 200)
28
+ * @param {function} options.onChange - Called with array of { path, relativePath, event } objects.
29
+ * event is 'add' | 'change' | 'unlink'.
30
+ */
31
+ subscribe(name, options) {
32
+ if (this._subscribers.has(name)) {
33
+ throw new Error(`FileWatcher: subscriber "${name}" already exists`);
34
+ }
35
+
36
+ const dirs = options.dirs
37
+ ? options.dirs.map((d) => path.resolve(this._rootDir, d))
38
+ : null;
39
+
40
+ const extensions = options.extensions ? new Set(options.extensions) : null;
41
+
42
+ const sub = {
43
+ name,
44
+ dirs,
45
+ extensions,
46
+ debounceMs: options.debounceMs ?? 200,
47
+ onChange: options.onChange,
48
+ // internal state
49
+ _timer: null,
50
+ _pending: [],
51
+ _running: false,
52
+ _queued: false,
53
+ };
54
+
55
+ this._subscribers.set(name, sub);
56
+ this._log?.info(`FileWatcher: subscribed "${name}"`);
57
+ return this;
58
+ }
59
+
60
+ unsubscribe(name) {
61
+ const sub = this._subscribers.get(name);
62
+ if (!sub) return this;
63
+ if (sub._timer) clearTimeout(sub._timer);
64
+ this._subscribers.delete(name);
65
+ this._log?.info(`FileWatcher: unsubscribed "${name}"`);
66
+ return this;
67
+ }
68
+
69
+ async start() {
70
+ if (this._started) return;
71
+ this._started = true;
72
+
73
+ this._watcher = watch(this._rootDir, {
74
+ ignoreInitial: true,
75
+ ignored: this._ignored,
76
+ depth: 10,
77
+ });
78
+
79
+ this._watcher.on('add', (filePath) => this._dispatch(filePath, 'add'));
80
+ this._watcher.on('change', (filePath) => this._dispatch(filePath, 'change'));
81
+ this._watcher.on('unlink', (filePath) => this._dispatch(filePath, 'unlink'));
82
+
83
+ this._log?.info('FileWatcher: started');
84
+ }
85
+
86
+ _dispatch(filePath, event) {
87
+ const normalized = path.resolve(filePath);
88
+ const ext = path.extname(normalized);
89
+ const relativePath = path.relative(this._rootDir, normalized);
90
+
91
+ for (const sub of this._subscribers.values()) {
92
+ if (!this._matches(sub, normalized, ext)) continue;
93
+
94
+ sub._pending.push({ path: normalized, relativePath, event });
95
+
96
+ if (sub._timer) clearTimeout(sub._timer);
97
+ sub._timer = setTimeout(() => {
98
+ sub._timer = null;
99
+ this._flush(sub);
100
+ }, sub.debounceMs);
101
+ }
102
+ }
103
+
104
+ _matches(sub, filePath, ext) {
105
+ if (sub.extensions && !sub.extensions.has(ext)) return false;
106
+ if (sub.dirs) {
107
+ const match = sub.dirs.some(
108
+ (dir) => filePath.startsWith(dir + path.sep) || filePath.startsWith(dir + '/'),
109
+ );
110
+ if (!match) return false;
111
+ }
112
+ return true;
113
+ }
114
+
115
+ async _flush(sub) {
116
+ const events = sub._pending.splice(0);
117
+ if (events.length === 0) return;
118
+
119
+ if (sub._running) {
120
+ // Re-queue — these events will be picked up after current run finishes
121
+ sub._pending.push(...events);
122
+ sub._queued = true;
123
+ return;
124
+ }
125
+
126
+ sub._running = true;
127
+ try {
128
+ await sub.onChange(events);
129
+ } catch (err) {
130
+ this._log?.error(`FileWatcher: "${sub.name}" onChange error`, { error: String(err) });
131
+ } finally {
132
+ sub._running = false;
133
+ if (sub._queued) {
134
+ sub._queued = false;
135
+ this._flush(sub);
136
+ }
137
+ }
138
+ }
139
+
140
+ async close() {
141
+ for (const sub of this._subscribers.values()) {
142
+ if (sub._timer) clearTimeout(sub._timer);
143
+ }
144
+ this._subscribers.clear();
145
+
146
+ if (this._watcher) {
147
+ await this._watcher.close();
148
+ this._watcher = null;
149
+ }
150
+
151
+ this._started = false;
152
+ this._log?.info('FileWatcher: closed');
153
+ }
154
+ }
155
+
156
+ export { FileWatcher };
package/server/glob.js ADDED
@@ -0,0 +1,6 @@
1
+ import { glob as _glob } from 'glob';
2
+ async function glob(pattern, options) {
3
+ const results = await _glob(pattern, { windowsPathsNoEscape: true, ...options });
4
+ return results.map((p) => p.replace(/\\/g, '/'));
5
+ }
6
+ export { glob };