@rebasepro/server-core 0.2.3 → 0.2.4

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 (109) hide show
  1. package/dist/common/src/collections/default-collections.d.ts +12 -0
  2. package/dist/common/src/collections/index.d.ts +1 -0
  3. package/dist/common/src/util/permissions.d.ts +1 -0
  4. package/dist/{index-BZoAtuqi.js → index-Cr1D21av.js} +11 -3
  5. package/dist/index-Cr1D21av.js.map +1 -0
  6. package/dist/index.es.js +2166 -208
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/index.umd.js +2155 -193
  9. package/dist/index.umd.js.map +1 -1
  10. package/dist/server-core/src/api/logs-routes.d.ts +37 -0
  11. package/dist/server-core/src/api/rest/api-generator.d.ts +6 -0
  12. package/dist/server-core/src/api/types.d.ts +6 -1
  13. package/dist/server-core/src/auth/adapter-middleware.d.ts +7 -3
  14. package/dist/server-core/src/auth/admin-routes.d.ts +3 -3
  15. package/dist/server-core/src/auth/api-keys/api-key-middleware.d.ts +39 -0
  16. package/dist/server-core/src/auth/api-keys/api-key-permission-guard.d.ts +32 -0
  17. package/dist/server-core/src/auth/api-keys/api-key-routes.d.ts +20 -0
  18. package/dist/server-core/src/auth/api-keys/api-key-store.d.ts +35 -0
  19. package/dist/server-core/src/auth/api-keys/api-key-types.d.ts +88 -0
  20. package/dist/server-core/src/auth/api-keys/index.d.ts +17 -0
  21. package/dist/server-core/src/auth/{auth-overrides.d.ts → auth-hooks.d.ts} +69 -11
  22. package/dist/server-core/src/auth/builtin-auth-adapter.d.ts +3 -3
  23. package/dist/server-core/src/auth/index.d.ts +5 -3
  24. package/dist/server-core/src/auth/interfaces.d.ts +93 -3
  25. package/dist/server-core/src/auth/jwt.d.ts +3 -1
  26. package/dist/server-core/src/auth/mfa.d.ts +49 -0
  27. package/dist/server-core/src/auth/middleware.d.ts +7 -0
  28. package/dist/server-core/src/auth/rate-limiter.d.ts +19 -0
  29. package/dist/server-core/src/auth/routes.d.ts +3 -3
  30. package/dist/server-core/src/env.d.ts +6 -0
  31. package/dist/server-core/src/index.d.ts +1 -0
  32. package/dist/server-core/src/init.d.ts +3 -3
  33. package/dist/server-core/src/services/webhook-service.d.ts +29 -0
  34. package/dist/server-core/src/storage/image-transform.d.ts +48 -0
  35. package/dist/server-core/src/storage/index.d.ts +3 -0
  36. package/dist/server-core/src/storage/tus-handler.d.ts +51 -0
  37. package/dist/types/src/controllers/auth.d.ts +2 -24
  38. package/dist/types/src/controllers/client.d.ts +0 -3
  39. package/dist/types/src/controllers/collection_registry.d.ts +1 -1
  40. package/dist/types/src/controllers/data_driver.d.ts +18 -0
  41. package/dist/types/src/controllers/registry.d.ts +5 -4
  42. package/dist/types/src/rebase_context.d.ts +1 -1
  43. package/dist/types/src/types/auth_adapter.d.ts +2 -4
  44. package/dist/types/src/types/collections.d.ts +0 -4
  45. package/dist/types/src/types/component_ref.d.ts +1 -1
  46. package/dist/types/src/types/cron.d.ts +1 -1
  47. package/dist/types/src/types/entity_views.d.ts +1 -0
  48. package/dist/types/src/types/export_import.d.ts +1 -1
  49. package/dist/types/src/types/formex.d.ts +2 -2
  50. package/dist/types/src/types/properties.d.ts +2 -2
  51. package/dist/types/src/types/translations.d.ts +28 -12
  52. package/dist/types/src/types/user_management_delegate.d.ts +6 -4
  53. package/dist/types/src/users/roles.d.ts +0 -8
  54. package/package.json +8 -6
  55. package/src/api/ast-schema-editor.ts +4 -4
  56. package/src/api/errors.ts +16 -7
  57. package/src/api/logs-routes.ts +129 -0
  58. package/src/api/rest/api-generator.ts +42 -2
  59. package/src/api/rest/query-parser.ts +37 -1
  60. package/src/api/types.ts +6 -1
  61. package/src/auth/adapter-middleware.ts +20 -4
  62. package/src/auth/admin-routes.ts +39 -14
  63. package/src/auth/api-keys/api-key-middleware.ts +126 -0
  64. package/src/auth/api-keys/api-key-permission-guard.ts +64 -0
  65. package/src/auth/api-keys/api-key-routes.ts +183 -0
  66. package/src/auth/api-keys/api-key-store.ts +317 -0
  67. package/src/auth/api-keys/api-key-types.ts +94 -0
  68. package/src/auth/api-keys/index.ts +37 -0
  69. package/src/auth/{auth-overrides.ts → auth-hooks.ts} +81 -14
  70. package/src/auth/builtin-auth-adapter.ts +31 -19
  71. package/src/auth/index.ts +7 -3
  72. package/src/auth/interfaces.ts +111 -3
  73. package/src/auth/jwt.ts +19 -5
  74. package/src/auth/mfa.ts +160 -0
  75. package/src/auth/middleware.ts +20 -1
  76. package/src/auth/rate-limiter.ts +92 -0
  77. package/src/auth/routes.ts +455 -24
  78. package/src/cron/cron-loader.ts +5 -10
  79. package/src/cron/cron-scheduler.ts +11 -12
  80. package/src/cron/cron-store.ts +8 -7
  81. package/src/env.ts +2 -0
  82. package/src/functions/function-loader.ts +6 -9
  83. package/src/index.ts +1 -2
  84. package/src/init.ts +37 -7
  85. package/src/serve-spa.ts +5 -4
  86. package/src/services/webhook-service.ts +155 -0
  87. package/src/storage/image-transform.ts +202 -0
  88. package/src/storage/index.ts +3 -0
  89. package/src/storage/routes.ts +56 -3
  90. package/src/storage/tus-handler.ts +315 -0
  91. package/src/utils/dev-port.ts +14 -0
  92. package/src/utils/logging.ts +9 -7
  93. package/test/admin-routes.test.ts +74 -7
  94. package/test/api-generator.test.ts +0 -1
  95. package/test/api-key-permission-guard.test.ts +132 -0
  96. package/test/ast-schema-editor.test.ts +26 -0
  97. package/test/auth-routes.test.ts +1 -2
  98. package/test/backend-hooks-admin.test.ts +3 -4
  99. package/test/email-templates.test.ts +169 -0
  100. package/test/function-loader.test.ts +124 -0
  101. package/test/jwt.test.ts +4 -2
  102. package/test/mfa.test.ts +197 -0
  103. package/test/middleware.test.ts +10 -5
  104. package/test/webhook-service.test.ts +249 -0
  105. package/vite.config.ts +3 -2
  106. package/dist/index-BZoAtuqi.js.map +0 -1
  107. package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
  108. package/src/bootstrappers/index.ts +0 -1
  109. package/src/singleton.test.ts +0 -28
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Image Transformation Service
3
+ *
4
+ * Provides on-the-fly image resize, crop, format conversion, and quality
5
+ * adjustment using the `sharp` library. Results are cached in an LRU
6
+ * in-memory cache to avoid redundant processing.
7
+ */
8
+
9
+ // Lazy-load sharp to avoid crashing when it isn't installed (e.g. in tests)
10
+ let sharpFactory: ((input: Buffer | Uint8Array) => any) | undefined;
11
+
12
+ async function getSharp(): Promise<(input: Buffer | Uint8Array) => any> {
13
+ if (!sharpFactory) {
14
+ try {
15
+ // @ts-ignore - sharp is an optional dependency
16
+ const mod = await import("sharp");
17
+ sharpFactory = mod.default;
18
+ } catch (err) {
19
+ throw new Error("Failed to load optional 'sharp' dependency for image transformation.");
20
+ }
21
+ }
22
+ if (!sharpFactory) {
23
+ throw new Error("Failed to load optional 'sharp' dependency for image transformation.");
24
+ }
25
+ return sharpFactory;
26
+ }
27
+
28
+ /** Options that can be specified via query parameters. */
29
+ export interface ImageTransformOptions {
30
+ width?: number;
31
+ height?: number;
32
+ quality?: number;
33
+ format?: "webp" | "avif" | "jpeg" | "png";
34
+ fit?: "cover" | "contain" | "fill" | "inside" | "outside";
35
+ }
36
+
37
+ /** Maximum dimension allowed (prevents abuse). */
38
+ const MAX_DIMENSION = 4096;
39
+ /** Maximum quality value. */
40
+ const MAX_QUALITY = 100;
41
+ /** Minimum quality value. */
42
+ const MIN_QUALITY = 1;
43
+
44
+ const VALID_FORMATS = new Set(["webp", "avif", "jpeg", "png"]);
45
+ const VALID_FITS = new Set(["cover", "contain", "fill", "inside", "outside"]);
46
+
47
+ /**
48
+ * Parse transform options from URL query parameters.
49
+ * Returns `null` when no transformation is requested.
50
+ */
51
+ export function parseTransformOptions(query: Record<string, string>): ImageTransformOptions | null {
52
+ const opts: ImageTransformOptions = {};
53
+ let hasTransform = false;
54
+
55
+ if (query.width) {
56
+ const w = parseInt(query.width, 10);
57
+ if (!Number.isNaN(w) && w > 0) {
58
+ opts.width = Math.min(w, MAX_DIMENSION);
59
+ hasTransform = true;
60
+ }
61
+ }
62
+
63
+ if (query.height) {
64
+ const h = parseInt(query.height, 10);
65
+ if (!Number.isNaN(h) && h > 0) {
66
+ opts.height = Math.min(h, MAX_DIMENSION);
67
+ hasTransform = true;
68
+ }
69
+ }
70
+
71
+ if (query.quality) {
72
+ const q = parseInt(query.quality, 10);
73
+ if (!Number.isNaN(q)) {
74
+ opts.quality = Math.min(Math.max(q, MIN_QUALITY), MAX_QUALITY);
75
+ hasTransform = true;
76
+ }
77
+ }
78
+
79
+ if (query.format && VALID_FORMATS.has(query.format)) {
80
+ opts.format = query.format as ImageTransformOptions["format"];
81
+ hasTransform = true;
82
+ }
83
+
84
+ if (query.fit && VALID_FITS.has(query.fit)) {
85
+ opts.fit = query.fit as ImageTransformOptions["fit"];
86
+ hasTransform = true;
87
+ }
88
+
89
+ return hasTransform ? opts : null;
90
+ }
91
+
92
+ /** MIME types that can be used as a Content-Type header. */
93
+ const FORMAT_CONTENT_TYPES: Record<string, string> = {
94
+ webp: "image/webp",
95
+ avif: "image/avif",
96
+ jpeg: "image/jpeg",
97
+ png: "image/png",
98
+ };
99
+
100
+ /** Check whether a content type is a transformable image. */
101
+ export function isTransformableImage(contentType: string): boolean {
102
+ return (
103
+ contentType.startsWith("image/") &&
104
+ !contentType.includes("svg") &&
105
+ !contentType.includes("gif")
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Apply image transformations and return the result buffer + content type.
111
+ */
112
+ export async function transformImage(
113
+ buffer: Buffer | Uint8Array,
114
+ options: ImageTransformOptions
115
+ ): Promise<{ data: Buffer; contentType: string }> {
116
+ const sharp = await getSharp();
117
+ let pipeline = sharp(buffer);
118
+
119
+ if (options.width || options.height) {
120
+ pipeline = pipeline.resize({
121
+ width: options.width,
122
+ height: options.height,
123
+ fit: options.fit || "cover",
124
+ withoutEnlargement: true,
125
+ });
126
+ }
127
+
128
+ const format = options.format || "webp";
129
+ const quality = options.quality || 80;
130
+
131
+ switch (format) {
132
+ case "webp":
133
+ pipeline = pipeline.webp({ quality });
134
+ break;
135
+ case "avif":
136
+ pipeline = pipeline.avif({ quality });
137
+ break;
138
+ case "jpeg":
139
+ pipeline = pipeline.jpeg({ quality });
140
+ break;
141
+ case "png":
142
+ pipeline = pipeline.png({ quality });
143
+ break;
144
+ }
145
+
146
+ const data = await pipeline.toBuffer();
147
+ return { data, contentType: FORMAT_CONTENT_TYPES[format] };
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // LRU Transform Cache
152
+ // ---------------------------------------------------------------------------
153
+
154
+ interface CacheEntry {
155
+ data: Buffer;
156
+ contentType: string;
157
+ timestamp: number;
158
+ }
159
+
160
+ /**
161
+ * Simple LRU cache for transformed images.
162
+ *
163
+ * Entries expire after `maxAgeMs` (default: 1 hour) and the cache
164
+ * evicts the oldest entry when `maxEntries` is exceeded.
165
+ */
166
+ export class TransformCache {
167
+ private cache = new Map<string, CacheEntry>();
168
+ private readonly maxEntries: number;
169
+ private readonly maxAgeMs: number;
170
+
171
+ constructor(maxEntries = 500, maxAgeMs = 3_600_000) {
172
+ this.maxEntries = maxEntries;
173
+ this.maxAgeMs = maxAgeMs;
174
+ }
175
+
176
+ /** Build a deterministic cache key from file key + transform options. */
177
+ buildKey(fileKey: string, options: ImageTransformOptions): string {
178
+ return `${fileKey}::${JSON.stringify(options)}`;
179
+ }
180
+
181
+ get(cacheKey: string): { data: Buffer; contentType: string } | null {
182
+ const entry = this.cache.get(cacheKey);
183
+ if (!entry) return null;
184
+ if (Date.now() - entry.timestamp > this.maxAgeMs) {
185
+ this.cache.delete(cacheKey);
186
+ return null;
187
+ }
188
+ // Move to end (most recently used)
189
+ this.cache.delete(cacheKey);
190
+ this.cache.set(cacheKey, entry);
191
+ return { data: entry.data, contentType: entry.contentType };
192
+ }
193
+
194
+ set(cacheKey: string, data: Buffer, contentType: string): void {
195
+ // Evict oldest if at capacity
196
+ if (this.cache.size >= this.maxEntries) {
197
+ const oldest = this.cache.keys().next().value;
198
+ if (oldest !== undefined) this.cache.delete(oldest);
199
+ }
200
+ this.cache.set(cacheKey, { data, contentType, timestamp: Date.now() });
201
+ }
202
+ }
@@ -16,6 +16,9 @@ export { S3StorageController } from "./S3StorageController";
16
16
  export { createStorageRoutes } from "./routes";
17
17
  export type { StorageRoutesConfig } from "./routes";
18
18
  export * from "./storage-registry";
19
+ export { parseTransformOptions, transformImage, isTransformableImage, TransformCache } from "./image-transform";
20
+ export type { ImageTransformOptions } from "./image-transform";
21
+ export { TusHandler } from "./tus-handler";
19
22
 
20
23
  import { BackendStorageConfig, StorageController } from "./types";
21
24
  import { LocalStorageController } from "./LocalStorageController";
@@ -9,6 +9,11 @@ import { LocalStorageController } from "./LocalStorageController";
9
9
  import { requireAuth as jwtRequireAuth, optionalAuth } from "../auth/middleware";
10
10
  import { ApiError, errorHandler } from "../api/errors";
11
11
  import { HonoEnv } from "../api/types";
12
+ import { parseTransformOptions, transformImage, isTransformableImage, TransformCache } from "./image-transform";
13
+ import { TusHandler } from "./tus-handler";
14
+
15
+ /** Shared image transform cache (LRU, 500 entries, 1 hour TTL). */
16
+ const transformCache = new TransformCache();
12
17
 
13
18
  export interface StorageRoutesConfig {
14
19
  controller: StorageController;
@@ -129,6 +134,9 @@ export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv>
129
134
 
130
135
  const filePath = decodeURIComponent(rawPath);
131
136
 
137
+ // Parse image transform query params (e.g. ?width=300&format=webp)
138
+ const transformOpts = parseTransformOptions(c.req.query() as Record<string, string>);
139
+
132
140
  // For local storage, serve the file directly from disk
133
141
  if (controller.getType() === "local") {
134
142
  const localController = controller as LocalStorageController;
@@ -153,9 +161,22 @@ export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv>
153
161
  }
154
162
  }
155
163
 
156
- c.header("Content-Type", contentType);
157
- // In a better scenario, we should pipe the stream instead of reading whole file
158
164
  const fileContent = fs.readFileSync(absolutePath);
165
+
166
+ // Apply image transforms if requested and the file is a transformable image
167
+ if (transformOpts && isTransformableImage(contentType)) {
168
+ const cacheKey = transformCache.buildKey(filePath, transformOpts);
169
+ let cached = transformCache.get(cacheKey);
170
+ if (!cached) {
171
+ cached = await transformImage(Buffer.from(fileContent), transformOpts);
172
+ transformCache.set(cacheKey, cached.data, cached.contentType);
173
+ }
174
+ c.header("Content-Type", cached.contentType);
175
+ c.header("Cache-Control", "public, max-age=31536000, immutable");
176
+ return c.body(new Uint8Array(cached.data));
177
+ }
178
+
179
+ c.header("Content-Type", contentType);
159
180
  return c.body(new Uint8Array(fileContent));
160
181
  }
161
182
 
@@ -169,7 +190,23 @@ export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv>
169
190
  throw ApiError.notFound("File not found");
170
191
  }
171
192
 
172
- c.header("Content-Type", fileObject.type || "application/octet-stream");
193
+ const remoteContentType = fileObject.type || "application/octet-stream";
194
+
195
+ // Apply image transforms for remote storage too
196
+ if (transformOpts && isTransformableImage(remoteContentType)) {
197
+ const cacheKey = transformCache.buildKey(filePath, transformOpts);
198
+ let cached = transformCache.get(cacheKey);
199
+ if (!cached) {
200
+ const buf = Buffer.from(await fileObject.arrayBuffer());
201
+ cached = await transformImage(buf, transformOpts);
202
+ transformCache.set(cacheKey, cached.data, cached.contentType);
203
+ }
204
+ c.header("Content-Type", cached.contentType);
205
+ c.header("Cache-Control", "public, max-age=31536000, immutable");
206
+ return c.body(new Uint8Array(cached.data));
207
+ }
208
+
209
+ c.header("Content-Type", remoteContentType);
173
210
  c.header("Cache-Control", "public, max-age=3600, immutable");
174
211
  const buf = await fileObject.arrayBuffer();
175
212
  return c.body(new Uint8Array(buf));
@@ -288,5 +325,21 @@ message: "No file to delete" });
288
325
  }, 201);
289
326
  });
290
327
 
328
+ // -----------------------------------------------------------------------
329
+ // TUS Resumable Uploads
330
+ // -----------------------------------------------------------------------
331
+
332
+ const tusBaseDir = controller.getType() === "local"
333
+ ? (controller as LocalStorageController).getBasePath()
334
+ : (process.env.STORAGE_PATH || "./uploads");
335
+ const tusHandler = new TusHandler(tusBaseDir, controller);
336
+ tusHandler.startCleanup();
337
+
338
+ router.options("/tus", (_c) => tusHandler.options());
339
+ router.post("/tus", writeAuthMiddleware, async (c) => tusHandler.create(c));
340
+ router.get("/tus/:id", readAuthMiddleware, (c) => tusHandler.head(c, c.req.param("id")));
341
+ router.patch("/tus/:id", writeAuthMiddleware, async (c) => tusHandler.patch(c, c.req.param("id")));
342
+ router.delete("/tus/:id", writeAuthMiddleware, async (c) => tusHandler.delete(c, c.req.param("id")));
343
+
291
344
  return router;
292
345
  }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * TUS Protocol Handler
3
+ *
4
+ * Implements the TUS v1.0.0 resumable upload protocol with the
5
+ * Creation and Termination extensions. Uploads are stored in a
6
+ * temporary directory and moved to final storage on completion.
7
+ *
8
+ * @see https://tus.io/protocols/resumable-upload
9
+ */
10
+
11
+ import { randomUUID } from "crypto";
12
+ import { writeFile, unlink, stat, mkdir, open } from "fs/promises";
13
+ import { existsSync } from "fs";
14
+ import { join } from "path";
15
+ import type { Context } from "hono";
16
+ import type { StorageController } from "./types";
17
+ import { logger } from "../utils/logger.js";
18
+
19
+ /** Metadata for an in-progress resumable upload. */
20
+ interface TusUpload {
21
+ id: string;
22
+ /** Total declared size in bytes. */
23
+ size: number;
24
+ /** Bytes received so far. */
25
+ offset: number;
26
+ /** TUS metadata parsed from the creation request. */
27
+ metadata: Record<string, string>;
28
+ /** Timestamp of creation (epoch ms). */
29
+ createdAt: number;
30
+ /** Absolute path to the temp file on disk. */
31
+ filePath: string;
32
+ /** Target bucket (from metadata). */
33
+ bucket?: string;
34
+ /** Target key / filename (from metadata). */
35
+ key?: string;
36
+ /** Whether the upload has been fully received and finalized. */
37
+ completed: boolean;
38
+ }
39
+
40
+ /** Maximum upload size: 5 GB. */
41
+ const MAX_UPLOAD_SIZE = 5 * 1024 * 1024 * 1024;
42
+
43
+ /** Stale upload expiry: 24 hours. */
44
+ const UPLOAD_EXPIRY_MS = 24 * 60 * 60 * 1000;
45
+
46
+ /**
47
+ * TUS resumable upload handler.
48
+ *
49
+ * Each instance manages uploads for a single storage root. The
50
+ * `storageController` is used to finalize completed uploads by
51
+ * calling `putObject`.
52
+ */
53
+ export class TusHandler {
54
+ private uploads = new Map<string, TusUpload>();
55
+ private tusDir: string;
56
+ private cleanupTimer?: ReturnType<typeof setInterval>;
57
+
58
+ constructor(
59
+ storageBaseDir: string,
60
+ private storageController?: StorageController,
61
+ ) {
62
+ this.tusDir = join(storageBaseDir, ".tus-uploads");
63
+ }
64
+
65
+ /** Ensure the temp directory exists. */
66
+ private async ensureDir(): Promise<void> {
67
+ if (!existsSync(this.tusDir)) {
68
+ await mkdir(this.tusDir, { recursive: true });
69
+ }
70
+ }
71
+
72
+ /** Start periodic cleanup of stale uploads. */
73
+ startCleanup(): void {
74
+ if (this.cleanupTimer) return;
75
+ this.cleanupTimer = setInterval(() => {
76
+ void this.cleanupStale();
77
+ }, 60_000); // every minute
78
+ }
79
+
80
+ /** Remove uploads that have been idle for longer than UPLOAD_EXPIRY_MS. */
81
+ private async cleanupStale(): Promise<void> {
82
+ const now = Date.now();
83
+ for (const [id, upload] of this.uploads) {
84
+ if (now - upload.createdAt > UPLOAD_EXPIRY_MS && !upload.completed) {
85
+ try { await unlink(upload.filePath); } catch { /* ok */ }
86
+ this.uploads.delete(id);
87
+ }
88
+ }
89
+ }
90
+
91
+ // -----------------------------------------------------------------------
92
+ // TUS Metadata Parsing
93
+ // -----------------------------------------------------------------------
94
+
95
+ /**
96
+ * Parse the `Upload-Metadata` header.
97
+ *
98
+ * Format: `key base64value,key2 base64value2`
99
+ */
100
+ private parseMetadata(header: string): Record<string, string> {
101
+ const metadata: Record<string, string> = {};
102
+ if (!header) return metadata;
103
+ for (const pair of header.split(",")) {
104
+ const trimmed = pair.trim();
105
+ const spaceIdx = trimmed.indexOf(" ");
106
+ if (spaceIdx === -1) {
107
+ metadata[trimmed] = "";
108
+ } else {
109
+ const key = trimmed.substring(0, spaceIdx);
110
+ const value = Buffer.from(trimmed.substring(spaceIdx + 1), "base64").toString("utf-8");
111
+ metadata[key] = value;
112
+ }
113
+ }
114
+ return metadata;
115
+ }
116
+
117
+ // -----------------------------------------------------------------------
118
+ // Protocol Endpoints
119
+ // -----------------------------------------------------------------------
120
+
121
+ /** `OPTIONS /tus` — TUS capability discovery. */
122
+ options(): Response {
123
+ return new Response(null, {
124
+ status: 204,
125
+ headers: {
126
+ "Tus-Resumable": "1.0.0",
127
+ "Tus-Version": "1.0.0",
128
+ "Tus-Extension": "creation,termination",
129
+ "Tus-Max-Size": String(MAX_UPLOAD_SIZE),
130
+ },
131
+ });
132
+ }
133
+
134
+ /** `POST /tus` — Create a new upload. */
135
+ async create(c: Context): Promise<Response> {
136
+ await this.ensureDir();
137
+
138
+ const uploadLengthHeader = c.req.header("Upload-Length");
139
+ if (!uploadLengthHeader) {
140
+ return c.json({ error: "Upload-Length header is required" }, 400);
141
+ }
142
+
143
+ const uploadLength = parseInt(uploadLengthHeader, 10);
144
+ if (Number.isNaN(uploadLength) || uploadLength <= 0) {
145
+ return c.json({ error: "Invalid Upload-Length" }, 400);
146
+ }
147
+ if (uploadLength > MAX_UPLOAD_SIZE) {
148
+ return c.json({ error: `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes` }, 413);
149
+ }
150
+
151
+ const metadata = this.parseMetadata(c.req.header("Upload-Metadata") || "");
152
+ const id = randomUUID();
153
+ const filePath = join(this.tusDir, id);
154
+
155
+ // Create empty temp file
156
+ await writeFile(filePath, Buffer.alloc(0));
157
+
158
+ const upload: TusUpload = {
159
+ id,
160
+ size: uploadLength,
161
+ offset: 0,
162
+ metadata,
163
+ createdAt: Date.now(),
164
+ filePath,
165
+ bucket: metadata.bucket || undefined,
166
+ key: metadata.key || metadata.filename || undefined,
167
+ completed: false,
168
+ };
169
+ this.uploads.set(id, upload);
170
+
171
+ // Build absolute Location
172
+ const reqUrl = new URL(c.req.url);
173
+ const location = `${reqUrl.origin}${reqUrl.pathname}/${id}`;
174
+
175
+ return new Response(null, {
176
+ status: 201,
177
+ headers: {
178
+ Location: location,
179
+ "Tus-Resumable": "1.0.0",
180
+ "Upload-Offset": "0",
181
+ },
182
+ });
183
+ }
184
+
185
+ /** `HEAD /tus/:id` — Query upload progress. */
186
+ head(c: Context, id: string): Response {
187
+ const upload = this.uploads.get(id);
188
+ if (!upload) {
189
+ return c.json({ error: "Upload not found" }, 404);
190
+ }
191
+
192
+ return new Response(null, {
193
+ status: 200,
194
+ headers: {
195
+ "Tus-Resumable": "1.0.0",
196
+ "Upload-Offset": String(upload.offset),
197
+ "Upload-Length": String(upload.size),
198
+ "Cache-Control": "no-store",
199
+ },
200
+ });
201
+ }
202
+
203
+ /** `PATCH /tus/:id` — Append data to an upload. */
204
+ async patch(c: Context, id: string): Promise<Response> {
205
+ const upload = this.uploads.get(id);
206
+ if (!upload) {
207
+ return c.json({ error: "Upload not found" }, 404);
208
+ }
209
+ if (upload.completed) {
210
+ return c.json({ error: "Upload already completed" }, 400);
211
+ }
212
+
213
+ // Validate offset
214
+ const offsetHeader = c.req.header("Upload-Offset");
215
+ if (!offsetHeader) {
216
+ return c.json({ error: "Upload-Offset header is required" }, 400);
217
+ }
218
+ const offset = parseInt(offsetHeader, 10);
219
+ if (offset !== upload.offset) {
220
+ return c.json({ error: "Offset mismatch" }, 409);
221
+ }
222
+
223
+ // Validate content type
224
+ const contentType = c.req.header("Content-Type");
225
+ if (contentType !== "application/offset+octet-stream") {
226
+ return c.json({ error: "Content-Type must be application/offset+octet-stream" }, 415);
227
+ }
228
+
229
+ // Read chunk and append to temp file
230
+ const body = await c.req.arrayBuffer();
231
+ const chunk = Buffer.from(body);
232
+
233
+ // Prevent overrun
234
+ if (upload.offset + chunk.length > upload.size) {
235
+ return c.json({ error: "Chunk exceeds declared Upload-Length" }, 413);
236
+ }
237
+
238
+ const fh = await open(upload.filePath, "a");
239
+ try {
240
+ await fh.write(chunk);
241
+ } finally {
242
+ await fh.close();
243
+ }
244
+ upload.offset += chunk.length;
245
+
246
+ // Finalize if complete
247
+ if (upload.offset >= upload.size) {
248
+ await this.finalize(upload);
249
+ }
250
+
251
+ return new Response(null, {
252
+ status: 204,
253
+ headers: {
254
+ "Tus-Resumable": "1.0.0",
255
+ "Upload-Offset": String(upload.offset),
256
+ },
257
+ });
258
+ }
259
+
260
+ /** `DELETE /tus/:id` — Cancel and remove an upload. */
261
+ async delete(c: Context, id: string): Promise<Response> {
262
+ const upload = this.uploads.get(id);
263
+ if (!upload) {
264
+ return c.json({ error: "Upload not found" }, 404);
265
+ }
266
+
267
+ try { await unlink(upload.filePath); } catch { /* ok */ }
268
+ this.uploads.delete(id);
269
+
270
+ return new Response(null, {
271
+ status: 204,
272
+ headers: { "Tus-Resumable": "1.0.0" },
273
+ });
274
+ }
275
+
276
+ // -----------------------------------------------------------------------
277
+ // Finalization
278
+ // -----------------------------------------------------------------------
279
+
280
+ /**
281
+ * Move a completed upload into the storage controller.
282
+ */
283
+ private async finalize(upload: TusUpload): Promise<void> {
284
+ upload.completed = true;
285
+
286
+ if (!this.storageController) {
287
+ // No controller — leave temp file in place
288
+ logger.warn("[TUS] Upload completed but no StorageController configured. Temp file remains:", { filePath: upload.filePath });
289
+ return;
290
+ }
291
+
292
+ try {
293
+ const { readFile } = await import("fs/promises");
294
+ const data = await readFile(upload.filePath);
295
+ const fileName = upload.key || upload.metadata.filename || upload.id;
296
+ const mimeType = upload.metadata.contentType || upload.metadata.filetype || "application/octet-stream";
297
+
298
+ const file = new File([data], fileName, { type: mimeType });
299
+
300
+ await this.storageController.putObject({
301
+ file,
302
+ key: fileName,
303
+ bucket: upload.bucket,
304
+ });
305
+
306
+ // Clean up temp file
307
+ try { await unlink(upload.filePath); } catch { /* ok */ }
308
+ this.uploads.delete(upload.id);
309
+
310
+ logger.info(`[TUS] Upload ${upload.id} finalized → ${fileName}`);
311
+ } catch (err) {
312
+ logger.error(`[TUS] Failed to finalize upload ${upload.id}`, { error: err });
313
+ }
314
+ }
315
+ }
@@ -43,6 +43,20 @@ export function listenWithPortRetry(
43
43
  const maxAttempts = options?.maxAttempts ?? MAX_PORT_ATTEMPTS;
44
44
  const portFileDir = options?.portFileDir;
45
45
 
46
+ const isProd = process.env.NODE_ENV === "production";
47
+ if (isProd) {
48
+ return new Promise<number>((resolve, reject) => {
49
+ const onError = (err: Error) => {
50
+ reject(err);
51
+ };
52
+ server.once("error", onError);
53
+ server.listen(startPort, host, () => {
54
+ server.removeListener("error", onError);
55
+ resolve(startPort);
56
+ });
57
+ });
58
+ }
59
+
46
60
  // Read affinity port from a previous run's port file.
47
61
  // This ensures tsx watch restarts land on the same port the frontend was
48
62
  // configured with, even if the CLI-computed port was different.
@@ -16,13 +16,16 @@ debug: 3 };
16
16
  if (currentLevel < 0) console.error = () => { };
17
17
  }
18
18
 
19
+ /** Module-scoped backup of the original console methods. */
20
+ let originalConsole: Pick<Console, "log" | "warn" | "error" | "debug"> | undefined;
21
+
19
22
  /**
20
23
  * Reset console methods to their original state
21
24
  */
22
25
  export function resetConsole() {
23
26
  // Store original methods if not already stored
24
- if (!(global as Record<string, unknown>).__originalConsole) {
25
- (global as Record<string, unknown>).__originalConsole = {
27
+ if (!originalConsole) {
28
+ originalConsole = {
26
29
  log: console.log,
27
30
  warn: console.warn,
28
31
  error: console.error,
@@ -30,9 +33,8 @@ export function resetConsole() {
30
33
  };
31
34
  }
32
35
 
33
- const original = (global as Record<string, unknown>).__originalConsole as Console;
34
- console.log = original.log;
35
- console.warn = original.warn;
36
- console.error = original.error;
37
- console.debug = original.debug;
36
+ console.log = originalConsole.log;
37
+ console.warn = originalConsole.warn;
38
+ console.error = originalConsole.error;
39
+ console.debug = originalConsole.debug;
38
40
  }