@imjp/writenex-astro 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 (141) hide show
  1. package/README.md +539 -0
  2. package/dist/chunk-5PM6EQE5.js +151 -0
  3. package/dist/chunk-5PM6EQE5.js.map +1 -0
  4. package/dist/chunk-7XU5X6CW.js +1331 -0
  5. package/dist/chunk-7XU5X6CW.js.map +1 -0
  6. package/dist/chunk-AAOQHQPU.js +574 -0
  7. package/dist/chunk-AAOQHQPU.js.map +1 -0
  8. package/dist/chunk-CF2XXJFF.js +1410 -0
  9. package/dist/chunk-CF2XXJFF.js.map +1 -0
  10. package/dist/chunk-CRPZUUDU.js +52 -0
  11. package/dist/chunk-CRPZUUDU.js.map +1 -0
  12. package/dist/chunk-CYLDJ3HZ.js +310 -0
  13. package/dist/chunk-CYLDJ3HZ.js.map +1 -0
  14. package/dist/chunk-KIKIPIFA.js +1 -0
  15. package/dist/chunk-KIKIPIFA.js.map +1 -0
  16. package/dist/chunk-XNTQTTJU.js +145 -0
  17. package/dist/chunk-XNTQTTJU.js.map +1 -0
  18. package/dist/client/index.css +2 -0
  19. package/dist/client/index.css.map +1 -0
  20. package/dist/client/index.js +375 -0
  21. package/dist/client/index.js.map +1 -0
  22. package/dist/client/styles.css +584 -0
  23. package/dist/client/variables.css +304 -0
  24. package/dist/config/index.d.ts +54 -0
  25. package/dist/config/index.js +38 -0
  26. package/dist/config/index.js.map +1 -0
  27. package/dist/config-BmEdBDo_.d.ts +220 -0
  28. package/dist/content-BWR52vD-.d.ts +64 -0
  29. package/dist/discovery/index.d.ts +310 -0
  30. package/dist/discovery/index.js +38 -0
  31. package/dist/discovery/index.js.map +1 -0
  32. package/dist/errors-C0iYiDTv.d.ts +107 -0
  33. package/dist/filesystem/index.d.ts +1292 -0
  34. package/dist/filesystem/index.js +203 -0
  35. package/dist/filesystem/index.js.map +1 -0
  36. package/dist/image-FP7w5ZIs.d.ts +47 -0
  37. package/dist/index.d.ts +64 -0
  38. package/dist/index.js +151 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/loader-55LWCXHA.js +12 -0
  41. package/dist/loader-55LWCXHA.js.map +1 -0
  42. package/dist/loader-CrdnaAWR.d.ts +327 -0
  43. package/dist/server/index.d.ts +357 -0
  44. package/dist/server/index.js +37 -0
  45. package/dist/server/index.js.map +1 -0
  46. package/package.json +94 -0
  47. package/src/client/App.tsx +900 -0
  48. package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
  49. package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
  50. package/src/client/components/ConfigPanel/index.ts +6 -0
  51. package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
  52. package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
  53. package/src/client/components/CreateContentModal/index.ts +7 -0
  54. package/src/client/components/Editor/Editor.css +885 -0
  55. package/src/client/components/Editor/Editor.tsx +484 -0
  56. package/src/client/components/Editor/ImageDialog.css +344 -0
  57. package/src/client/components/Editor/ImageDialog.tsx +367 -0
  58. package/src/client/components/Editor/LinkDialog.css +326 -0
  59. package/src/client/components/Editor/LinkDialog.tsx +332 -0
  60. package/src/client/components/Editor/index.ts +6 -0
  61. package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
  62. package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
  63. package/src/client/components/FrontmatterForm/index.ts +7 -0
  64. package/src/client/components/Header/Header.css +300 -0
  65. package/src/client/components/Header/Header.tsx +300 -0
  66. package/src/client/components/Header/index.ts +7 -0
  67. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
  68. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
  69. package/src/client/components/KeyboardShortcuts/index.ts +6 -0
  70. package/src/client/components/LazyEditor.tsx +75 -0
  71. package/src/client/components/LiveRegion/LiveRegion.css +19 -0
  72. package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
  73. package/src/client/components/LiveRegion/index.ts +7 -0
  74. package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
  75. package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
  76. package/src/client/components/SearchReplace/index.ts +7 -0
  77. package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
  78. package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
  79. package/src/client/components/SelectCollectionModal/index.ts +7 -0
  80. package/src/client/components/Sidebar/Sidebar.css +570 -0
  81. package/src/client/components/Sidebar/Sidebar.tsx +617 -0
  82. package/src/client/components/Sidebar/index.ts +7 -0
  83. package/src/client/components/SkipLink/SkipLink.css +51 -0
  84. package/src/client/components/SkipLink/SkipLink.tsx +67 -0
  85. package/src/client/components/SkipLink/index.ts +7 -0
  86. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
  87. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
  88. package/src/client/components/UnsavedChangesModal/index.ts +1 -0
  89. package/src/client/components/VersionHistory/DiffViewer.css +430 -0
  90. package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
  91. package/src/client/components/VersionHistory/VersionActions.css +318 -0
  92. package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
  93. package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
  94. package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
  95. package/src/client/components/VersionHistory/index.ts +9 -0
  96. package/src/client/context/ApiContext.tsx +154 -0
  97. package/src/client/context/ThemeContext.tsx +172 -0
  98. package/src/client/hooks/useAnnounce.ts +201 -0
  99. package/src/client/hooks/useApi.ts +374 -0
  100. package/src/client/hooks/useArrowNavigation.ts +286 -0
  101. package/src/client/hooks/useAutosave.ts +241 -0
  102. package/src/client/hooks/useFocusTrap.ts +178 -0
  103. package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
  104. package/src/client/hooks/useSearch.ts +206 -0
  105. package/src/client/hooks/useVersionHistory.ts +451 -0
  106. package/src/client/index.tsx +70 -0
  107. package/src/client/styles.css +584 -0
  108. package/src/client/utils/focus.ts +57 -0
  109. package/src/client/utils/openInEditor.ts +130 -0
  110. package/src/client/variables.css +304 -0
  111. package/src/config/defaults.ts +109 -0
  112. package/src/config/index.ts +32 -0
  113. package/src/config/loader.ts +174 -0
  114. package/src/config/schema.ts +161 -0
  115. package/src/core/constants.ts +39 -0
  116. package/src/core/errors.ts +739 -0
  117. package/src/core/index.ts +11 -0
  118. package/src/discovery/collections.ts +216 -0
  119. package/src/discovery/index.ts +33 -0
  120. package/src/discovery/patterns.ts +702 -0
  121. package/src/discovery/schema.ts +453 -0
  122. package/src/filesystem/images.ts +798 -0
  123. package/src/filesystem/index.ts +107 -0
  124. package/src/filesystem/reader.ts +452 -0
  125. package/src/filesystem/version-config.ts +390 -0
  126. package/src/filesystem/versions.ts +1339 -0
  127. package/src/filesystem/watcher.ts +226 -0
  128. package/src/filesystem/writer.ts +540 -0
  129. package/src/index.ts +61 -0
  130. package/src/integration.ts +228 -0
  131. package/src/server/assets.ts +254 -0
  132. package/src/server/cache.ts +355 -0
  133. package/src/server/index.ts +33 -0
  134. package/src/server/middleware.ts +209 -0
  135. package/src/server/routes.ts +1428 -0
  136. package/src/types/api.ts +61 -0
  137. package/src/types/config.ts +134 -0
  138. package/src/types/content.ts +64 -0
  139. package/src/types/image.ts +48 -0
  140. package/src/types/index.ts +58 -0
  141. package/src/types/version.ts +117 -0
@@ -0,0 +1,355 @@
1
+ /**
2
+ * @fileoverview Server-side caching for Writenex
3
+ *
4
+ * Provides in-memory caching for collection discovery and content summaries
5
+ * to improve performance by avoiding repeated filesystem operations.
6
+ *
7
+ * ## Features:
8
+ * - TTL-based cache expiration
9
+ * - Per-collection content caching
10
+ * - Image discovery caching
11
+ * - Manual cache invalidation
12
+ * - Integration with file watcher for automatic invalidation
13
+ *
14
+ * @module @writenex/astro/server/cache
15
+ */
16
+
17
+ import type {
18
+ DiscoveredCollection,
19
+ ContentSummary,
20
+ DiscoveredImage,
21
+ } from "@/types";
22
+
23
+ /**
24
+ * Cache entry with timestamp and data
25
+ */
26
+ interface CacheEntry<T> {
27
+ data: T;
28
+ timestamp: number;
29
+ }
30
+
31
+ /**
32
+ * Default TTL for cache entries (5 minutes)
33
+ */
34
+ const DEFAULT_TTL_MS = 5 * 60 * 1000;
35
+
36
+ /**
37
+ * Short TTL for development (30 seconds)
38
+ * Used when file watcher is not active
39
+ */
40
+ const DEV_TTL_MS = 30 * 1000;
41
+
42
+ /**
43
+ * Server-side cache for collection and content data
44
+ *
45
+ * This cache stores:
46
+ * - Collection discovery results (list of collections with metadata)
47
+ * - Content summaries per collection (list of content items)
48
+ * - Discovered images per content item
49
+ *
50
+ * Cache invalidation happens:
51
+ * - Automatically when TTL expires
52
+ * - Manually via invalidate methods
53
+ * - Via file watcher integration
54
+ */
55
+ export class ServerCache {
56
+ /** Cache for collection discovery results */
57
+ private collectionsCache: CacheEntry<DiscoveredCollection[]> | null = null;
58
+
59
+ /** Cache for content summaries, keyed by collection name */
60
+ private contentCache: Map<string, CacheEntry<ContentSummary[]>> = new Map();
61
+
62
+ /** Cache for discovered images, keyed by "collection:contentId" */
63
+ private imagesCache: Map<string, CacheEntry<DiscoveredImage[]>> = new Map();
64
+
65
+ /** Time-to-live for cache entries in milliseconds */
66
+ private ttl: number;
67
+
68
+ /** Whether file watcher is integrated (allows longer TTL) */
69
+ private hasWatcher: boolean = false;
70
+
71
+ constructor(options: { ttl?: number; hasWatcher?: boolean } = {}) {
72
+ this.ttl =
73
+ options.ttl ?? (options.hasWatcher ? DEFAULT_TTL_MS : DEV_TTL_MS);
74
+ this.hasWatcher = options.hasWatcher ?? false;
75
+ }
76
+
77
+ /**
78
+ * Enable file watcher integration
79
+ *
80
+ * When watcher is enabled, cache can use longer TTL since
81
+ * invalidation happens via watcher events.
82
+ */
83
+ enableWatcher(): void {
84
+ this.hasWatcher = true;
85
+ this.ttl = DEFAULT_TTL_MS;
86
+ }
87
+
88
+ /**
89
+ * Check if a cache entry is still valid
90
+ */
91
+ private isValid<T>(entry: CacheEntry<T> | null | undefined): boolean {
92
+ if (!entry) return false;
93
+ return Date.now() - entry.timestamp < this.ttl;
94
+ }
95
+
96
+ // ==================== Collections Cache ====================
97
+
98
+ /**
99
+ * Get cached collections if valid
100
+ *
101
+ * @returns Cached collections or null if expired/not cached
102
+ */
103
+ getCollections(): DiscoveredCollection[] | null {
104
+ if (this.isValid(this.collectionsCache)) {
105
+ return this.collectionsCache!.data;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Set collections cache
112
+ *
113
+ * @param collections - Collections to cache
114
+ */
115
+ setCollections(collections: DiscoveredCollection[]): void {
116
+ this.collectionsCache = {
117
+ data: collections,
118
+ timestamp: Date.now(),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Invalidate collections cache
124
+ */
125
+ invalidateCollections(): void {
126
+ this.collectionsCache = null;
127
+ }
128
+
129
+ // ==================== Content Cache ====================
130
+
131
+ /**
132
+ * Get cached content summaries for a collection if valid
133
+ *
134
+ * @param collection - Collection name
135
+ * @returns Cached content summaries or null if expired/not cached
136
+ */
137
+ getContent(collection: string): ContentSummary[] | null {
138
+ const entry = this.contentCache.get(collection);
139
+ if (this.isValid(entry)) {
140
+ return entry!.data;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * Set content cache for a collection
147
+ *
148
+ * @param collection - Collection name
149
+ * @param items - Content summaries to cache
150
+ */
151
+ setContent(collection: string, items: ContentSummary[]): void {
152
+ this.contentCache.set(collection, {
153
+ data: items,
154
+ timestamp: Date.now(),
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Invalidate content cache for a specific collection
160
+ *
161
+ * @param collection - Collection name to invalidate
162
+ */
163
+ invalidateContent(collection: string): void {
164
+ this.contentCache.delete(collection);
165
+ }
166
+
167
+ /**
168
+ * Invalidate all content caches
169
+ */
170
+ invalidateAllContent(): void {
171
+ this.contentCache.clear();
172
+ }
173
+
174
+ // ==================== Images Cache ====================
175
+
176
+ /**
177
+ * Generate cache key for image cache
178
+ *
179
+ * @param collection - Collection name
180
+ * @param contentId - Content ID
181
+ * @returns Cache key string
182
+ */
183
+ private getImagesCacheKey(collection: string, contentId: string): string {
184
+ return `${collection}:${contentId}`;
185
+ }
186
+
187
+ /**
188
+ * Get cached images for a content item if valid
189
+ *
190
+ * @param collection - Collection name
191
+ * @param contentId - Content ID
192
+ * @returns Cached images or null if expired/not cached
193
+ */
194
+ getImages(collection: string, contentId: string): DiscoveredImage[] | null {
195
+ const key = this.getImagesCacheKey(collection, contentId);
196
+ const entry = this.imagesCache.get(key);
197
+ if (this.isValid(entry)) {
198
+ return entry!.data;
199
+ }
200
+ return null;
201
+ }
202
+
203
+ /**
204
+ * Set images cache for a content item
205
+ *
206
+ * @param collection - Collection name
207
+ * @param contentId - Content ID
208
+ * @param images - Discovered images to cache
209
+ */
210
+ setImages(
211
+ collection: string,
212
+ contentId: string,
213
+ images: DiscoveredImage[]
214
+ ): void {
215
+ const key = this.getImagesCacheKey(collection, contentId);
216
+ this.imagesCache.set(key, {
217
+ data: images,
218
+ timestamp: Date.now(),
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Invalidate image cache for a specific content item
224
+ *
225
+ * @param collection - Collection name
226
+ * @param contentId - Content ID
227
+ */
228
+ invalidateImages(collection: string, contentId: string): void {
229
+ const key = this.getImagesCacheKey(collection, contentId);
230
+ this.imagesCache.delete(key);
231
+ }
232
+
233
+ /**
234
+ * Invalidate all image caches for a collection
235
+ *
236
+ * @param collection - Collection name
237
+ */
238
+ invalidateCollectionImages(collection: string): void {
239
+ const prefix = `${collection}:`;
240
+ for (const key of this.imagesCache.keys()) {
241
+ if (key.startsWith(prefix)) {
242
+ this.imagesCache.delete(key);
243
+ }
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Invalidate all image caches
249
+ */
250
+ invalidateAllImages(): void {
251
+ this.imagesCache.clear();
252
+ }
253
+
254
+ // ==================== Bulk Invalidation ====================
255
+
256
+ /**
257
+ * Invalidate all caches
258
+ *
259
+ * Called when a major change occurs that affects everything.
260
+ */
261
+ invalidateAll(): void {
262
+ this.collectionsCache = null;
263
+ this.contentCache.clear();
264
+ this.imagesCache.clear();
265
+ }
266
+
267
+ /**
268
+ * Handle file change event from watcher
269
+ *
270
+ * Intelligently invalidates only the affected caches.
271
+ *
272
+ * @param type - Type of file change (add, change, unlink)
273
+ * @param collection - Collection that was affected
274
+ * @param contentId - Optional content ID for targeted image cache invalidation
275
+ */
276
+ handleFileChange(
277
+ type: "add" | "change" | "unlink",
278
+ collection: string,
279
+ contentId?: string
280
+ ): void {
281
+ // Always invalidate the affected collection's content cache
282
+ this.invalidateContent(collection);
283
+
284
+ // Invalidate image cache for the specific content item or entire collection
285
+ if (contentId) {
286
+ this.invalidateImages(collection, contentId);
287
+ } else {
288
+ this.invalidateCollectionImages(collection);
289
+ }
290
+
291
+ // For add/unlink, also invalidate collections cache (count changed)
292
+ if (type === "add" || type === "unlink") {
293
+ this.invalidateCollections();
294
+ }
295
+ }
296
+
297
+ // ==================== Stats ====================
298
+
299
+ /**
300
+ * Get cache statistics for debugging
301
+ *
302
+ * @returns Object with cache stats
303
+ */
304
+ getStats(): {
305
+ collectionsValid: boolean;
306
+ contentCollections: string[];
307
+ cachedImages: string[];
308
+ ttl: number;
309
+ hasWatcher: boolean;
310
+ } {
311
+ return {
312
+ collectionsValid: this.isValid(this.collectionsCache),
313
+ contentCollections: Array.from(this.contentCache.keys()).filter((key) =>
314
+ this.isValid(this.contentCache.get(key))
315
+ ),
316
+ cachedImages: Array.from(this.imagesCache.keys()).filter((key) =>
317
+ this.isValid(this.imagesCache.get(key))
318
+ ),
319
+ ttl: this.ttl,
320
+ hasWatcher: this.hasWatcher,
321
+ };
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Global cache instance
327
+ *
328
+ * Shared across the server for consistent caching.
329
+ */
330
+ let globalCache: ServerCache | null = null;
331
+
332
+ /**
333
+ * Get or create the global cache instance
334
+ *
335
+ * @param options - Cache options (only used on first call)
336
+ * @returns The global cache instance
337
+ */
338
+ export function getCache(options?: {
339
+ ttl?: number;
340
+ hasWatcher?: boolean;
341
+ }): ServerCache {
342
+ if (!globalCache) {
343
+ globalCache = new ServerCache(options);
344
+ }
345
+ return globalCache;
346
+ }
347
+
348
+ /**
349
+ * Reset the global cache instance
350
+ *
351
+ * Useful for testing or when configuration changes.
352
+ */
353
+ export function resetCache(): void {
354
+ globalCache = null;
355
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @fileoverview Server module exports for @writenex/astro
3
+ *
4
+ * This module provides the public API for server-side functionality,
5
+ * including middleware, API routes, static assets, and caching.
6
+ *
7
+ * @module @writenex/astro/server
8
+ */
9
+
10
+ // Middleware functions and types
11
+ export {
12
+ createMiddleware,
13
+ parseQueryParams,
14
+ parseJsonBody,
15
+ sendJson,
16
+ sendError,
17
+ sendWritenexError,
18
+ } from "./middleware";
19
+ export type { MiddlewareContext } from "./middleware";
20
+
21
+ // Routes
22
+ export { createApiRouter } from "./routes";
23
+
24
+ // Assets
25
+ export {
26
+ serveEditorHtml,
27
+ serveAsset,
28
+ getClientDistPath,
29
+ hasClientBundle,
30
+ } from "./assets";
31
+
32
+ // Cache
33
+ export { ServerCache, getCache, resetCache } from "./cache";
@@ -0,0 +1,209 @@
1
+ /**
2
+ * @fileoverview Vite middleware for Writenex routes
3
+ *
4
+ * This module provides the main middleware handler that intercepts requests
5
+ * to Writenex routes (/_writenex/*) and delegates to appropriate handlers.
6
+ *
7
+ * ## Route Structure:
8
+ * - `/_writenex` - Editor UI (HTML page with React app)
9
+ * - `/_writenex/api/*` - API endpoints for CRUD operations
10
+ * - `/_writenex/assets/*` - Static assets (JS, CSS)
11
+ *
12
+ * @module @writenex/astro/server/middleware
13
+ */
14
+
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
16
+ import type { Connect } from "vite";
17
+ import type { WritenexConfig } from "@/types";
18
+ import { createApiRouter } from "./routes";
19
+ import { serveEditorHtml, serveAsset } from "./assets";
20
+ import type { WritenexError } from "@/core/errors";
21
+ import { WritenexErrorCode, isWritenexError, wrapError } from "@/core/errors";
22
+
23
+ /**
24
+ * Middleware context passed to handlers
25
+ */
26
+ export interface MiddlewareContext {
27
+ /** Base path for Writenex routes */
28
+ basePath: string;
29
+ /** Project root directory */
30
+ projectRoot: string;
31
+ /** Resolved Writenex configuration */
32
+ config: Required<WritenexConfig>;
33
+ /** Astro trailingSlash setting for preview URLs */
34
+ trailingSlash: "always" | "never" | "ignore";
35
+ }
36
+
37
+ /**
38
+ * Create the Writenex middleware handler
39
+ *
40
+ * @param context - Middleware context with configuration
41
+ * @returns Connect middleware function
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * const middleware = createMiddleware({
46
+ * basePath: '/_writenex',
47
+ * projectRoot: '/path/to/project',
48
+ * config: resolvedConfig,
49
+ * });
50
+ *
51
+ * server.middlewares.use(middleware);
52
+ * ```
53
+ */
54
+ export function createMiddleware(
55
+ context: MiddlewareContext
56
+ ): Connect.NextHandleFunction {
57
+ const { basePath } = context;
58
+ const apiRouter = createApiRouter(context);
59
+
60
+ return async (
61
+ req: IncomingMessage,
62
+ res: ServerResponse,
63
+ next: Connect.NextFunction
64
+ ) => {
65
+ const url = req.url ?? "";
66
+
67
+ // Only handle requests to our base path
68
+ if (!url.startsWith(basePath)) {
69
+ return next();
70
+ }
71
+
72
+ // Extract the path after base path
73
+ const path = url.slice(basePath.length) || "/";
74
+
75
+ try {
76
+ // Handle API routes
77
+ if (path.startsWith("/api/")) {
78
+ return await apiRouter(req, res, path.slice(4)); // Remove '/api' prefix
79
+ }
80
+
81
+ // Handle static assets
82
+ if (path.startsWith("/assets/")) {
83
+ return await serveAsset(req, res, path.slice(8), context); // Remove '/assets' prefix
84
+ }
85
+
86
+ // Handle editor UI (root and any sub-routes for client-side routing)
87
+ return await serveEditorHtml(req, res, context);
88
+ } catch (error) {
89
+ // Handle errors gracefully using WritenexError
90
+ const writenexError = isWritenexError(error)
91
+ ? error
92
+ : wrapError(error, WritenexErrorCode.API_INTERNAL_ERROR);
93
+
94
+ console.error(
95
+ `[writenex] Middleware error [${writenexError.code}]: ${writenexError.message}`
96
+ );
97
+
98
+ res.statusCode = writenexError.httpStatus;
99
+ res.setHeader("Content-Type", "application/json");
100
+ res.end(JSON.stringify(writenexError.toJSON()));
101
+ }
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Parse URL query parameters
107
+ *
108
+ * @param url - The URL string to parse
109
+ * @returns Object with query parameters
110
+ */
111
+ export function parseQueryParams(url: string): Record<string, string> {
112
+ const queryIndex = url.indexOf("?");
113
+ if (queryIndex === -1) return {};
114
+
115
+ const queryString = url.slice(queryIndex + 1);
116
+ const params: Record<string, string> = {};
117
+
118
+ for (const pair of queryString.split("&")) {
119
+ const [key, value] = pair.split("=");
120
+ if (key) {
121
+ params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : "";
122
+ }
123
+ }
124
+
125
+ return params;
126
+ }
127
+
128
+ /**
129
+ * Parse request body as JSON
130
+ *
131
+ * @param req - The incoming request
132
+ * @returns Parsed JSON body or null
133
+ */
134
+ export async function parseJsonBody(
135
+ req: IncomingMessage
136
+ ): Promise<unknown | null> {
137
+ return new Promise((resolve) => {
138
+ let body = "";
139
+
140
+ req.on("data", (chunk: Buffer) => {
141
+ body += chunk.toString();
142
+ });
143
+
144
+ req.on("end", () => {
145
+ if (!body) {
146
+ resolve(null);
147
+ return;
148
+ }
149
+
150
+ try {
151
+ resolve(JSON.parse(body));
152
+ } catch {
153
+ resolve(null);
154
+ }
155
+ });
156
+
157
+ req.on("error", () => {
158
+ resolve(null);
159
+ });
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Send JSON response
165
+ *
166
+ * @param res - The server response
167
+ * @param data - Data to send as JSON
168
+ * @param statusCode - HTTP status code (default: 200)
169
+ */
170
+ export function sendJson(
171
+ res: ServerResponse,
172
+ data: unknown,
173
+ statusCode: number = 200
174
+ ): void {
175
+ res.statusCode = statusCode;
176
+ res.setHeader("Content-Type", "application/json");
177
+ res.end(JSON.stringify(data));
178
+ }
179
+
180
+ /**
181
+ * Send error response
182
+ *
183
+ * @param res - The server response
184
+ * @param message - Error message
185
+ * @param statusCode - HTTP status code (default: 400)
186
+ */
187
+ export function sendError(
188
+ res: ServerResponse,
189
+ message: string,
190
+ statusCode: number = 400
191
+ ): void {
192
+ sendJson(res, { error: message }, statusCode);
193
+ }
194
+
195
+ /**
196
+ * Send WritenexError response
197
+ *
198
+ * Automatically uses the error's HTTP status code and formats
199
+ * the response using the error's toJSON method.
200
+ *
201
+ * @param res - The server response
202
+ * @param error - WritenexError instance
203
+ */
204
+ export function sendWritenexError(
205
+ res: ServerResponse,
206
+ error: WritenexError
207
+ ): void {
208
+ sendJson(res, error.toJSON(), error.httpStatus);
209
+ }