@gencow/core 0.1.23 → 0.1.25

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 (77) hide show
  1. package/dist/crud.d.ts +2 -2
  2. package/dist/crud.js +225 -208
  3. package/dist/index.d.ts +7 -3
  4. package/dist/index.js +4 -1
  5. package/dist/reactive.js +10 -3
  6. package/dist/retry.js +1 -1
  7. package/dist/rls-db.d.ts +2 -2
  8. package/dist/rls-db.js +1 -5
  9. package/dist/scheduler.d.ts +2 -0
  10. package/dist/scheduler.js +16 -6
  11. package/dist/server.d.ts +0 -1
  12. package/dist/server.js +0 -1
  13. package/dist/storage.js +29 -22
  14. package/dist/v.d.ts +2 -2
  15. package/dist/workflow-types.d.ts +81 -0
  16. package/dist/workflow-types.js +12 -0
  17. package/dist/workflow.d.ts +30 -0
  18. package/dist/workflow.js +150 -0
  19. package/dist/workflows-api.d.ts +13 -0
  20. package/dist/workflows-api.js +321 -0
  21. package/package.json +46 -42
  22. package/src/__tests__/auth.test.ts +90 -86
  23. package/src/__tests__/crons.test.ts +69 -67
  24. package/src/__tests__/crud-codegen-integration.test.ts +164 -170
  25. package/src/__tests__/crud-owner-rls.test.ts +308 -301
  26. package/src/__tests__/crud.test.ts +694 -711
  27. package/src/__tests__/dist-exports.test.ts +120 -114
  28. package/src/__tests__/fixtures/basic/auth.ts +16 -16
  29. package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
  30. package/src/__tests__/fixtures/basic/index.ts +1 -1
  31. package/src/__tests__/fixtures/basic/schema.ts +1 -1
  32. package/src/__tests__/fixtures/basic/tasks.ts +4 -4
  33. package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
  34. package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
  35. package/src/__tests__/helpers/pglite-migrations.ts +2 -5
  36. package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
  37. package/src/__tests__/helpers/seed-like-fill.ts +50 -44
  38. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
  39. package/src/__tests__/httpaction.test.ts +91 -91
  40. package/src/__tests__/image-optimization.test.ts +570 -574
  41. package/src/__tests__/load.test.ts +321 -308
  42. package/src/__tests__/network-sim.test.ts +238 -215
  43. package/src/__tests__/reactive.test.ts +380 -358
  44. package/src/__tests__/retry.test.ts +99 -84
  45. package/src/__tests__/rls-crud-basic.test.ts +172 -245
  46. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
  47. package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
  48. package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
  49. package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
  50. package/src/__tests__/rls-session-and-policies.test.ts +181 -199
  51. package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
  52. package/src/__tests__/scheduler-durable.test.ts +117 -117
  53. package/src/__tests__/scheduler-exec.test.ts +258 -246
  54. package/src/__tests__/scheduler.test.ts +129 -111
  55. package/src/__tests__/storage.test.ts +282 -269
  56. package/src/__tests__/tsconfig.json +6 -6
  57. package/src/__tests__/validator.test.ts +236 -232
  58. package/src/__tests__/workflow.test.ts +606 -0
  59. package/src/__tests__/ws-integration.test.ts +223 -218
  60. package/src/__tests__/ws-scale.test.ts +168 -159
  61. package/src/auth-config.ts +18 -18
  62. package/src/auth.ts +106 -106
  63. package/src/crons.ts +77 -77
  64. package/src/crud.ts +523 -479
  65. package/src/index.ts +71 -6
  66. package/src/reactive.ts +357 -331
  67. package/src/retry.ts +51 -54
  68. package/src/rls-db.ts +195 -205
  69. package/src/rls.ts +33 -36
  70. package/src/scheduler.ts +237 -211
  71. package/src/server.ts +0 -1
  72. package/src/storage.ts +632 -593
  73. package/src/v.ts +119 -114
  74. package/src/workflow-types.ts +108 -0
  75. package/src/workflow.ts +188 -0
  76. package/src/workflows-api.ts +415 -0
  77. package/src/db.ts +0 -18
package/src/storage.ts CHANGED
@@ -12,40 +12,40 @@ const MAX_FILE_SIZE = 50 * 1024 * 1024;
12
12
  const DEFAULT_STORAGE_QUOTA = 1024 * 1024 * 1024;
13
13
 
14
14
  function formatBytes(bytes: number): string {
15
- if (bytes < 1024) return `${bytes}B`;
16
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
17
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
18
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
15
+ if (bytes < 1024) return `${bytes}B`;
16
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
17
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
18
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
19
19
  }
20
20
 
21
21
  // ─── Types ──────────────────────────────────────────────
22
22
 
23
23
  interface StorageFile {
24
- id: string;
25
- name: string;
26
- size: number;
27
- type: string;
28
- path: string;
24
+ id: string;
25
+ name: string;
26
+ size: number;
27
+ type: string;
28
+ path: string;
29
29
  }
30
30
 
31
31
  export interface StorageOptions {
32
- /** Raw SQL 실행 함수 — DB 자동 기록 + 쿼터 검증에 필요 */
33
- rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>;
34
- /** 앱별 스토리지 쿼터 (bytes). 0 = 무제한. 기본: 1GB */
35
- storageQuota?: number;
32
+ /** Raw SQL 실행 함수 — DB 자동 기록 + 쿼터 검증에 필요 */
33
+ rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>;
34
+ /** 앱별 스토리지 쿼터 (bytes). 0 = 무제한. 기본: 1GB */
35
+ storageQuota?: number;
36
36
  }
37
37
 
38
38
  export interface Storage {
39
- /** Store a file and return a storageId — Convex의 ctx.storage.store() */
40
- store(file: File | Blob, filename?: string): Promise<string>;
41
- /** Store from raw buffer */
42
- storeBuffer(buffer: Buffer, filename: string, type?: string): Promise<string>;
43
- /** Get a serving URL for the file — Convex의 ctx.storage.getUrl() */
44
- getUrl(storageId: string): string;
45
- /** Get file metadata */
46
- getMeta(storageId: string): Promise<StorageFile | null>;
47
- /** Delete a stored file — Convex의 ctx.storage.delete() */
48
- delete(storageId: string): Promise<void>;
39
+ /** Store a file and return a storageId — Convex의 ctx.storage.store() */
40
+ store(file: File | Blob, filename?: string): Promise<string>;
41
+ /** Store from raw buffer */
42
+ storeBuffer(buffer: Buffer, filename: string, type?: string): Promise<string>;
43
+ /** Get a serving URL for the file — Convex의 ctx.storage.getUrl() */
44
+ getUrl(storageId: string): string;
45
+ /** Get file metadata */
46
+ getMeta(storageId: string): Promise<StorageFile | null>;
47
+ /** Delete a stored file — Convex의 ctx.storage.delete() */
48
+ delete(storageId: string): Promise<void>;
49
49
  }
50
50
 
51
51
  // ─── Implementation ─────────────────────────────────────
@@ -60,10 +60,10 @@ const metaStore = new Map<string, StorageFile>();
60
60
  let filesTableEnsured = false;
61
61
 
62
62
  async function ensureFilesTable(
63
- rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
63
+ rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
64
64
  ): Promise<void> {
65
- if (filesTableEnsured) return;
66
- await rawSql(`
65
+ if (filesTableEnsured) return;
66
+ await rawSql(`
67
67
  DO $$
68
68
  BEGIN
69
69
  IF NOT EXISTS (
@@ -83,31 +83,29 @@ async function ensureFilesTable(
83
83
  END IF;
84
84
  END$$;
85
85
  `);
86
- filesTableEnsured = true;
86
+ filesTableEnsured = true;
87
87
  }
88
88
 
89
89
  /**
90
90
  * 스토리지 쿼터 검증 — 현재 총 사용량 + 새 파일 크기가 쿼터를 초과하는지 확인
91
91
  */
92
92
  async function checkStorageQuota(
93
- rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
94
- newFileSize: number,
95
- quota: number,
93
+ rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
94
+ newFileSize: number,
95
+ quota: number,
96
96
  ): Promise<void> {
97
- if (quota <= 0) return; // 무제한
97
+ if (quota <= 0) return; // 무제한
98
98
 
99
- const rows = await rawSql(
100
- `SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM _system_files`,
101
- );
102
- const currentUsage = Number((rows[0] as Record<string, string>)?.total || "0");
103
- const projectedUsage = currentUsage + newFileSize;
99
+ const rows = await rawSql(`SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM _system_files`);
100
+ const currentUsage = Number((rows[0] as Record<string, string>)?.total || "0");
101
+ const projectedUsage = currentUsage + newFileSize;
104
102
 
105
- if (projectedUsage > quota) {
106
- throw new Error(
107
- `Storage quota exceeded: ${formatBytes(currentUsage)} used + ${formatBytes(newFileSize)} new = ${formatBytes(projectedUsage)}, ` +
108
- `quota is ${formatBytes(quota)}. Delete files or increase quota.`
109
- );
110
- }
103
+ if (projectedUsage > quota) {
104
+ throw new Error(
105
+ `Storage quota exceeded: ${formatBytes(currentUsage)} used + ${formatBytes(newFileSize)} new = ${formatBytes(projectedUsage)}, ` +
106
+ `quota is ${formatBytes(quota)}. Delete files or increase quota.`,
107
+ );
108
+ }
111
109
  }
112
110
 
113
111
  /**
@@ -117,19 +115,19 @@ async function checkStorageQuota(
117
115
  * 여기서는 skipDbRecord 옵션으로 중복 방지 가능.
118
116
  */
119
117
  async function recordFileToDb(
120
- rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
121
- storageId: string,
122
- name: string,
123
- size: number,
124
- type: string,
125
- uploadedBy: string = "api",
118
+ rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
119
+ storageId: string,
120
+ name: string,
121
+ size: number,
122
+ type: string,
123
+ uploadedBy: string = "api",
126
124
  ): Promise<void> {
127
- await ensureFilesTable(rawSql);
128
- await rawSql(
129
- `INSERT INTO _system_files (storage_id, name, size, type, uploaded_by, created_at)
125
+ await ensureFilesTable(rawSql);
126
+ await rawSql(
127
+ `INSERT INTO _system_files (storage_id, name, size, type, uploaded_by, created_at)
130
128
  VALUES ($1, $2, $3, $4, $5, NOW())`,
131
- [storageId, name, String(size), type, uploadedBy],
132
- );
129
+ [storageId, name, String(size), type, uploadedBy],
130
+ );
133
131
  }
134
132
 
135
133
  /**
@@ -143,194 +141,192 @@ async function recordFileToDb(
143
141
  * const id = await storage.store(file);
144
142
  * const url = storage.getUrl(id);
145
143
  */
146
- export function createStorage(
147
- dir: string = "./uploads",
148
- options?: StorageOptions,
149
- ): Storage {
150
- const rawSql = options?.rawSql;
151
- const quota = options?.storageQuota ?? DEFAULT_STORAGE_QUOTA;
152
-
153
- // Ensure directory exists
154
- fs.mkdir(dir, { recursive: true }).catch(() => { });
155
-
156
- return {
157
- async store(file: File | Blob, filename?: string): Promise<string> {
158
- // 크기 제한 검증 (단일 파일)
159
- if (file.size > MAX_FILE_SIZE) {
160
- throw new Error(
161
- `File too large: ${formatBytes(file.size)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`
162
- );
163
- }
164
-
165
- // 스토리지 쿼터 검증 (앱 전체 용량)
166
- if (rawSql) {
167
- await checkStorageQuota(rawSql, file.size, quota);
168
- }
169
-
170
- const id = crypto.randomUUID();
171
- const name = filename || (file instanceof File ? file.name : `${id}.bin`);
172
- const filePath = path.join(dir, id);
173
-
174
- const arrayBuffer = await file.arrayBuffer();
175
- await fs.writeFile(filePath, Buffer.from(arrayBuffer));
176
-
177
- const type = file.type || "application/octet-stream";
178
-
179
- // .meta JSON 기록 — Platform 레벨 직접 서빙용 ( DB 접근 불필요)
180
- await fs.writeFile(`${filePath}.meta`, JSON.stringify({ name, type, size: file.size })).catch(() => {});
181
-
182
- metaStore.set(id, {
183
- id,
184
- name,
185
- size: file.size,
186
- type,
187
- path: filePath,
188
- });
189
-
190
- // DB 자동 기록 (rawSql 있을 때만)
191
- if (rawSql) {
192
- try {
193
- await recordFileToDb(rawSql, id, name, file.size, type, "api");
194
- } catch (e: unknown) {
195
- // DB 기록 실패해도 파일 저장은 성공 — 로그만 남김
196
- const msg = e instanceof Error ? e.message : String(e);
197
- console.warn(`[storage] DB record failed for ${id}: ${msg}`);
198
- }
199
- }
200
-
201
- return id;
202
- },
203
-
204
- async storeBuffer(
205
- buffer: Buffer,
206
- filename: string,
207
- type: string = "application/octet-stream"
208
- ): Promise<string> {
209
- // 크기 제한 검증 (단일 파일)
210
- if (buffer.length > MAX_FILE_SIZE) {
211
- throw new Error(
212
- `File too large: ${formatBytes(buffer.length)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`
213
- );
214
- }
215
-
216
- // 스토리지 쿼터 검증 (앱 전체 용량)
217
- if (rawSql) {
218
- await checkStorageQuota(rawSql, buffer.length, quota);
219
- }
220
-
221
- const id = crypto.randomUUID();
222
- const filePath = path.join(dir, id);
223
-
224
- await fs.writeFile(filePath, buffer);
225
-
226
- // .meta JSON 기록 — Platform 레벨 직접 서빙용
227
- await fs.writeFile(`${filePath}.meta`, JSON.stringify({ name: filename, type, size: buffer.length })).catch(() => {});
228
-
229
- metaStore.set(id, {
230
- id,
231
- name: filename,
232
- size: buffer.length,
233
- type,
234
- path: filePath,
235
- });
236
-
237
- // DB 자동 기록
238
- if (rawSql) {
239
- try {
240
- await recordFileToDb(rawSql, id, filename, buffer.length, type, "api");
241
- } catch (e: unknown) {
242
- const msg = e instanceof Error ? e.message : String(e);
243
- console.warn(`[storage] DB record failed for ${id}: ${msg}`);
244
- }
245
- }
246
-
247
- return id;
248
- },
249
-
250
- getUrl(storageId: string): string {
251
- return `/api/storage/${storageId}`;
252
- },
253
-
254
- async getMeta(storageId: string): Promise<StorageFile | null> {
255
- return metaStore.get(storageId) || null;
256
- },
257
-
258
- async delete(storageId: string): Promise<void> {
259
- const meta = metaStore.get(storageId);
260
- if (meta) {
261
- await fs.unlink(meta.path).catch(() => { });
262
- // .meta JSON 동시 삭제
263
- await fs.unlink(`${meta.path}.meta`).catch(() => { });
264
- metaStore.delete(storageId);
265
- } else {
266
- // metaStore에 없어도 디스크에서 직접 삭제 시도
267
- const filePath = path.join(dir, storageId);
268
- await fs.unlink(filePath).catch(() => { });
269
- await fs.unlink(`${filePath}.meta`).catch(() => { });
270
- }
271
-
272
- // 이미지 캐시 삭제 — uploads/.cache/{storageId}_* glob
273
- const cacheDir = path.join(dir, ".cache");
274
- try {
275
- const entries = await fs.readdir(cacheDir);
276
- const prefix = `${storageId}_`;
277
- await Promise.all(
278
- entries
279
- .filter(e => e.startsWith(prefix))
280
- .map(e => fs.unlink(path.join(cacheDir, e)).catch(() => { }))
281
- );
282
- } catch { /* .cache 디렉토리 미존재 시 무시 */ }
283
-
284
- // DB에서도 삭제 (rawSql 있을 때만)
285
- if (rawSql) {
286
- try {
287
- await rawSql(
288
- `DELETE FROM _system_files WHERE storage_id = $1`,
289
- [storageId],
290
- );
291
- } catch { /* 삭제 실패 무시 — 파일은 이미 제거됨 */ }
292
- }
293
- },
294
- };
144
+ export function createStorage(dir: string = "./uploads", options?: StorageOptions): Storage {
145
+ const rawSql = options?.rawSql;
146
+ const quota = options?.storageQuota ?? DEFAULT_STORAGE_QUOTA;
147
+
148
+ // Ensure directory exists
149
+ fs.mkdir(dir, { recursive: true }).catch(() => {});
150
+
151
+ return {
152
+ async store(file: File | Blob, filename?: string): Promise<string> {
153
+ // 크기 제한 검증 (단일 파일)
154
+ if (file.size > MAX_FILE_SIZE) {
155
+ throw new Error(
156
+ `File too large: ${formatBytes(file.size)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`,
157
+ );
158
+ }
159
+
160
+ // 스토리지 쿼터 검증 (앱 전체 용량)
161
+ if (rawSql) {
162
+ await checkStorageQuota(rawSql, file.size, quota);
163
+ }
164
+
165
+ const id = crypto.randomUUID();
166
+ const name = filename || (file instanceof File ? file.name : `${id}.bin`);
167
+ const filePath = path.join(dir, id);
168
+
169
+ const arrayBuffer = await file.arrayBuffer();
170
+ await fs.writeFile(filePath, Buffer.from(arrayBuffer));
171
+
172
+ const type = file.type || "application/octet-stream";
173
+
174
+ // .meta JSON 기록 — Platform 레벨 직접 서빙용 (앱 DB 접근 불필요)
175
+ await fs.writeFile(`${filePath}.meta`, JSON.stringify({ name, type, size: file.size })).catch(() => {});
176
+
177
+ metaStore.set(id, {
178
+ id,
179
+ name,
180
+ size: file.size,
181
+ type,
182
+ path: filePath,
183
+ });
184
+
185
+ // DB 자동 기록 (rawSql 있을 때만)
186
+ if (rawSql) {
187
+ try {
188
+ await recordFileToDb(rawSql, id, name, file.size, type, "api");
189
+ } catch (e: unknown) {
190
+ // DB 기록 실패해도 파일 저장은 성공 — 로그만 남김
191
+ const msg = e instanceof Error ? e.message : String(e);
192
+ console.warn(`[storage] DB record failed for ${id}: ${msg}`);
193
+ }
194
+ }
195
+
196
+ return id;
197
+ },
198
+
199
+ async storeBuffer(
200
+ buffer: Buffer,
201
+ filename: string,
202
+ type: string = "application/octet-stream",
203
+ ): Promise<string> {
204
+ // 크기 제한 검증 (단일 파일)
205
+ if (buffer.length > MAX_FILE_SIZE) {
206
+ throw new Error(
207
+ `File too large: ${formatBytes(buffer.length)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`,
208
+ );
209
+ }
210
+
211
+ // 스토리지 쿼터 검증 (앱 전체 용량)
212
+ if (rawSql) {
213
+ await checkStorageQuota(rawSql, buffer.length, quota);
214
+ }
215
+
216
+ const id = crypto.randomUUID();
217
+ const filePath = path.join(dir, id);
218
+
219
+ await fs.writeFile(filePath, buffer);
220
+
221
+ // .meta JSON 기록 — Platform 레벨 직접 서빙용
222
+ await fs
223
+ .writeFile(`${filePath}.meta`, JSON.stringify({ name: filename, type, size: buffer.length }))
224
+ .catch(() => {});
225
+
226
+ metaStore.set(id, {
227
+ id,
228
+ name: filename,
229
+ size: buffer.length,
230
+ type,
231
+ path: filePath,
232
+ });
233
+
234
+ // DB 자동 기록
235
+ if (rawSql) {
236
+ try {
237
+ await recordFileToDb(rawSql, id, filename, buffer.length, type, "api");
238
+ } catch (e: unknown) {
239
+ const msg = e instanceof Error ? e.message : String(e);
240
+ console.warn(`[storage] DB record failed for ${id}: ${msg}`);
241
+ }
242
+ }
243
+
244
+ return id;
245
+ },
246
+
247
+ getUrl(storageId: string): string {
248
+ return `/api/storage/${storageId}`;
249
+ },
250
+
251
+ async getMeta(storageId: string): Promise<StorageFile | null> {
252
+ return metaStore.get(storageId) || null;
253
+ },
254
+
255
+ async delete(storageId: string): Promise<void> {
256
+ const meta = metaStore.get(storageId);
257
+ if (meta) {
258
+ await fs.unlink(meta.path).catch(() => {});
259
+ // .meta JSON 동시 삭제
260
+ await fs.unlink(`${meta.path}.meta`).catch(() => {});
261
+ metaStore.delete(storageId);
262
+ } else {
263
+ // metaStore에 없어도 디스크에서 직접 삭제 시도
264
+ const filePath = path.join(dir, storageId);
265
+ await fs.unlink(filePath).catch(() => {});
266
+ await fs.unlink(`${filePath}.meta`).catch(() => {});
267
+ }
268
+
269
+ // 이미지 캐시 삭제 — uploads/.cache/{storageId}_* glob
270
+ const cacheDir = path.join(dir, ".cache");
271
+ try {
272
+ const entries = await fs.readdir(cacheDir);
273
+ const prefix = `${storageId}_`;
274
+ await Promise.all(
275
+ entries
276
+ .filter((e) => e.startsWith(prefix))
277
+ .map((e) => fs.unlink(path.join(cacheDir, e)).catch(() => {})),
278
+ );
279
+ } catch {
280
+ /* .cache 디렉토리 미존재 시 무시 */
281
+ }
282
+
283
+ // DB에서도 삭제 (rawSql 있을 때만)
284
+ if (rawSql) {
285
+ try {
286
+ await rawSql(`DELETE FROM _system_files WHERE storage_id = $1`, [storageId]);
287
+ } catch {
288
+ /* 삭제 실패 무시 — 파일은 이미 제거됨 */
289
+ }
290
+ }
291
+ },
292
+ };
295
293
  }
296
294
 
297
295
  // ─── Image Optimization Types ───────────────────────────
298
296
 
299
297
  /** 이미지 변환 쿼리 파라미터 */
300
298
  interface ImageTransformParams {
301
- w?: number; // 너비 (1-4096)
302
- h?: number; // 높이 (1-4096)
303
- f?: string; // 출력 포맷 (webp, avif, jpeg, png)
304
- q?: number; // 품질 (1-100, 기본 80)
305
- fit?: string; // 맞춤 모드 (cover, contain, fill, inside)
299
+ w?: number; // 너비 (1-4096)
300
+ h?: number; // 높이 (1-4096)
301
+ f?: string; // 출력 포맷 (webp, avif, jpeg, png)
302
+ q?: number; // 품질 (1-100, 기본 80)
303
+ fit?: string; // 맞춤 모드 (cover, contain, fill, inside)
306
304
  }
307
305
 
308
306
  /** Tier별 이미지 최적화 설정 — Platform에서 주입 */
309
307
  export interface StorageImageTierConfig {
310
- /** Auto WebP 허용 여부 (기본 true) */
311
- autoWebp?: boolean;
312
- /** Auto WebP 시 최대 폭 — 초과 시 자동 축소 (Hobby=1920, Pro=3840, Scale=무제한) */
313
- autoMaxWidth?: number;
314
- /** 리사이즈 허용 여부 */
315
- resize?: boolean;
316
- /** 허용 포맷 목록 */
317
- formats?: string[];
318
- /** 품질 조절 허용 여부 */
319
- qualityControl?: boolean;
320
- /** 캐시 상한 (MB) */
321
- cacheMaxMB?: number;
322
- /** 월간 변환 횟수 상한 (-1 = 무제한) */
323
- transformsPerMonth?: number;
324
- /** Auto WebP 품질 오버라이드 (1-100, 기본 75) — 앱별 설정에서 주입 */
325
- autoQuality?: number;
308
+ /** Auto WebP 허용 여부 (기본 true) */
309
+ autoWebp?: boolean;
310
+ /** Auto WebP 시 최대 폭 — 초과 시 자동 축소 (Hobby=1920, Pro=3840, Scale=무제한) */
311
+ autoMaxWidth?: number;
312
+ /** 리사이즈 허용 여부 */
313
+ resize?: boolean;
314
+ /** 허용 포맷 목록 */
315
+ formats?: string[];
316
+ /** 품질 조절 허용 여부 */
317
+ qualityControl?: boolean;
318
+ /** 캐시 상한 (MB) */
319
+ cacheMaxMB?: number;
320
+ /** 월간 변환 횟수 상한 (-1 = 무제한) */
321
+ transformsPerMonth?: number;
322
+ /** Auto WebP 품질 오버라이드 (1-100, 기본 75) — 앱별 설정에서 주입 */
323
+ autoQuality?: number;
326
324
  }
327
325
 
328
326
  // ─── Image Optimization Constants ───────────────────────
329
327
 
330
328
  /** 변환 가능한 원본 MIME 타입 */
331
- const TRANSFORMABLE_TYPES = new Set([
332
- "image/png", "image/jpeg", "image/jpg", "image/webp",
333
- ]);
329
+ const TRANSFORMABLE_TYPES = new Set(["image/png", "image/jpeg", "image/jpg", "image/webp"]);
334
330
 
335
331
  /** 허용 출력 포맷 */
336
332
  const ALLOWED_FORMATS = new Set(["webp", "avif", "jpeg", "png"]);
@@ -359,22 +355,22 @@ const MAX_CONCURRENT_TRANSFORMS = 3;
359
355
  const transformQueue: Array<() => void> = [];
360
356
 
361
357
  function acquireTransformSlot(): Promise<void> {
362
- if (activeTransforms < MAX_CONCURRENT_TRANSFORMS) {
363
- activeTransforms++;
364
- return Promise.resolve();
365
- }
366
- return new Promise<void>((resolve) => {
367
- transformQueue.push(() => {
368
- activeTransforms++;
369
- resolve();
370
- });
358
+ if (activeTransforms < MAX_CONCURRENT_TRANSFORMS) {
359
+ activeTransforms++;
360
+ return Promise.resolve();
361
+ }
362
+ return new Promise<void>((resolve) => {
363
+ transformQueue.push(() => {
364
+ activeTransforms++;
365
+ resolve();
371
366
  });
367
+ });
372
368
  }
373
369
 
374
370
  function releaseTransformSlot(): void {
375
- activeTransforms--;
376
- const next = transformQueue.shift();
377
- if (next) next();
371
+ activeTransforms--;
372
+ const next = transformQueue.shift();
373
+ if (next) next();
378
374
  }
379
375
 
380
376
  /**
@@ -383,80 +379,90 @@ function releaseTransformSlot(): void {
383
379
  * @returns null if no transform params, parsed params otherwise
384
380
  */
385
381
  function parseTransformParams(
386
- wStr?: string, hStr?: string, fStr?: string, qStr?: string, fitStr?: string,
382
+ wStr?: string,
383
+ hStr?: string,
384
+ fStr?: string,
385
+ qStr?: string,
386
+ fitStr?: string,
387
387
  ): ImageTransformParams | null {
388
- const params: ImageTransformParams = {};
389
- let hasParam = false;
390
-
391
- if (wStr) {
392
- const w = parseInt(wStr, 10);
393
- if (isNaN(w) || w < 1 || w > MAX_DIMENSION) return null;
394
- params.w = w;
395
- hasParam = true;
396
- }
397
-
398
- if (hStr) {
399
- const h = parseInt(hStr, 10);
400
- if (isNaN(h) || h < 1 || h > MAX_DIMENSION) return null;
401
- params.h = h;
402
- hasParam = true;
403
- }
404
-
405
- if (fStr) {
406
- const fmt = fStr.toLowerCase();
407
- if (!ALLOWED_FORMATS.has(fmt)) return null;
408
- params.f = fmt;
409
- hasParam = true;
410
- }
411
-
412
- if (qStr) {
413
- const q = parseInt(qStr, 10);
414
- if (isNaN(q) || q < 1 || q > 100) return null;
415
- params.q = q;
416
- hasParam = true;
417
- }
418
-
419
- if (fitStr) {
420
- const fitMode = fitStr.toLowerCase();
421
- if (!ALLOWED_FIT_MODES.has(fitMode)) return null;
422
- params.fit = fitMode;
423
- hasParam = true;
424
- }
425
-
426
- return hasParam ? params : null;
388
+ const params: ImageTransformParams = {};
389
+ let hasParam = false;
390
+
391
+ if (wStr) {
392
+ const w = parseInt(wStr, 10);
393
+ if (isNaN(w) || w < 1 || w > MAX_DIMENSION) return null;
394
+ params.w = w;
395
+ hasParam = true;
396
+ }
397
+
398
+ if (hStr) {
399
+ const h = parseInt(hStr, 10);
400
+ if (isNaN(h) || h < 1 || h > MAX_DIMENSION) return null;
401
+ params.h = h;
402
+ hasParam = true;
403
+ }
404
+
405
+ if (fStr) {
406
+ const fmt = fStr.toLowerCase();
407
+ if (!ALLOWED_FORMATS.has(fmt)) return null;
408
+ params.f = fmt;
409
+ hasParam = true;
410
+ }
411
+
412
+ if (qStr) {
413
+ const q = parseInt(qStr, 10);
414
+ if (isNaN(q) || q < 1 || q > 100) return null;
415
+ params.q = q;
416
+ hasParam = true;
417
+ }
418
+
419
+ if (fitStr) {
420
+ const fitMode = fitStr.toLowerCase();
421
+ if (!ALLOWED_FIT_MODES.has(fitMode)) return null;
422
+ params.fit = fitMode;
423
+ hasParam = true;
424
+ }
425
+
426
+ return hasParam ? params : null;
427
427
  }
428
428
 
429
429
  /**
430
430
  * 변환 파라미터에서 캐시 키 생성
431
431
  * 결정론적: 동일 파라미터 → 동일 키
432
432
  */
433
- function buildCacheKey(storageId: string, params: ImageTransformParams, isAutoWebp: boolean, autoMaxWidth?: number, autoQuality?: number): string {
434
- if (isAutoWebp) {
435
- // autoMaxWidth/autoQuality가 있으면 캐시 키에 포함 (Tier/앱별 다른 크기/품질)
436
- const mwSuffix = autoMaxWidth ? `_mw${autoMaxWidth}` : "";
437
- const qSuffix = autoQuality && autoQuality !== AUTO_WEBP_QUALITY ? `_q${autoQuality}` : "";
438
- return `${storageId}_auto_webp${mwSuffix}${qSuffix}.webp`;
439
- }
440
- const parts: string[] = [storageId];
441
- if (params.w) parts.push(`w${params.w}`);
442
- if (params.h) parts.push(`h${params.h}`);
443
- const fmt = params.f || "webp";
444
- parts.push(`f${fmt}`);
445
- parts.push(`q${params.q || DEFAULT_QUALITY}`);
446
- if (params.fit) parts.push(params.fit);
447
- return `${parts.join("_")}.${fmt === "jpeg" ? "jpg" : fmt}`;
433
+ function buildCacheKey(
434
+ storageId: string,
435
+ params: ImageTransformParams,
436
+ isAutoWebp: boolean,
437
+ autoMaxWidth?: number,
438
+ autoQuality?: number,
439
+ ): string {
440
+ if (isAutoWebp) {
441
+ // autoMaxWidth/autoQuality가 있으면 캐시 키에 포함 (Tier/앱별 다른 크기/품질)
442
+ const mwSuffix = autoMaxWidth ? `_mw${autoMaxWidth}` : "";
443
+ const qSuffix = autoQuality && autoQuality !== AUTO_WEBP_QUALITY ? `_q${autoQuality}` : "";
444
+ return `${storageId}_auto_webp${mwSuffix}${qSuffix}.webp`;
445
+ }
446
+ const parts: string[] = [storageId];
447
+ if (params.w) parts.push(`w${params.w}`);
448
+ if (params.h) parts.push(`h${params.h}`);
449
+ const fmt = params.f || "webp";
450
+ parts.push(`f${fmt}`);
451
+ parts.push(`q${params.q || DEFAULT_QUALITY}`);
452
+ if (params.fit) parts.push(params.fit);
453
+ return `${parts.join("_")}.${fmt === "jpeg" ? "jpg" : fmt}`;
448
454
  }
449
455
 
450
456
  /**
451
457
  * 캐시 디렉토리의 엔트리 수 확인 — DoS 방지
452
458
  */
453
459
  async function getCacheEntryCount(cacheDir: string): Promise<number> {
454
- try {
455
- const entries = await fs.readdir(cacheDir);
456
- return entries.length;
457
- } catch {
458
- return 0;
459
- }
460
+ try {
461
+ const entries = await fs.readdir(cacheDir);
462
+ return entries.length;
463
+ } catch {
464
+ return 0;
465
+ }
460
466
  }
461
467
 
462
468
  /**
@@ -472,324 +478,357 @@ async function getCacheEntryCount(cacheDir: string): Promise<number> {
472
478
  * - 원본 보존: 원본 파일은 절대 수정하지 않음
473
479
  */
474
480
  export function storageRoutes(
475
- storage: ReturnType<typeof createStorage>,
476
- rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>,
477
- storageDir?: string,
478
- tierConfig?: StorageImageTierConfig,
481
+ storage: ReturnType<typeof createStorage>,
482
+ rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>,
483
+ storageDir?: string,
484
+ tierConfig?: StorageImageTierConfig,
479
485
  ) {
480
- const baseTierConfig: Required<StorageImageTierConfig> = {
481
- autoWebp: tierConfig?.autoWebp ?? true,
482
- autoMaxWidth: tierConfig?.autoMaxWidth ?? 0, // 0 = 제한 없음
483
- resize: tierConfig?.resize ?? true,
484
- formats: tierConfig?.formats ?? ["webp", "avif", "jpeg", "png"],
485
- qualityControl: tierConfig?.qualityControl ?? true,
486
- cacheMaxMB: tierConfig?.cacheMaxMB ?? 1024, // 기본 1GB
487
- transformsPerMonth: tierConfig?.transformsPerMonth ?? -1, // 무제한
488
- autoQuality: tierConfig?.autoQuality ?? AUTO_WEBP_QUALITY,
489
- };
490
-
491
- /**
492
- * 매 요청마다 uploads/.image-config.json 파일에서 앱별 이미지 설정을 읽어
493
- * Tier config와 병합한 최종 config를 반환.
494
- *
495
- * 파일 기반 설계 이유:
496
- * - env var 방식은 전파 파이프라인이 6단계(DB→provisioner→env→dist→handler)로 깨지기 쉬움
497
- * - 파일은 대시보드에서 write → 다음 요청에서 즉시 read (2단계)
498
- * - 어느 프로세스든(Platform/앱) 동일 파일을 읽으므로 일관성 보장
499
- * - readFileSync ~0.01ms, 성능 영향 없음
500
- */
501
- function getImageConfig(uploadsDir: string): Required<StorageImageTierConfig> {
502
- const config = { ...baseTierConfig };
503
- try {
504
- const raw = fsSync.readFileSync(path.join(uploadsDir, ".image-config.json"), "utf-8");
505
- const appConfig = JSON.parse(raw);
506
- // Tier ceiling 적용: min(앱 설정, Tier 최대값)
507
- if (appConfig.autoMaxWidth > 0) {
508
- config.autoMaxWidth = config.autoMaxWidth > 0
509
- ? Math.min(appConfig.autoMaxWidth, config.autoMaxWidth)
510
- : appConfig.autoMaxWidth;
511
- }
512
- if (appConfig.autoQuality > 0 && appConfig.autoQuality <= 100) {
513
- config.autoQuality = appConfig.autoQuality;
514
- }
515
- if (appConfig.autoWebp === false) {
516
- config.autoWebp = false;
517
- }
518
- } catch { /* 파일 없음 or 파싱 실패 → Tier 기본값 유지 */ }
519
- return config;
486
+ const baseTierConfig: Required<StorageImageTierConfig> = {
487
+ autoWebp: tierConfig?.autoWebp ?? true,
488
+ autoMaxWidth: tierConfig?.autoMaxWidth ?? 0, // 0 = 제한 없음
489
+ resize: tierConfig?.resize ?? true,
490
+ formats: tierConfig?.formats ?? ["webp", "avif", "jpeg", "png"],
491
+ qualityControl: tierConfig?.qualityControl ?? true,
492
+ cacheMaxMB: tierConfig?.cacheMaxMB ?? 1024, // 기본 1GB
493
+ transformsPerMonth: tierConfig?.transformsPerMonth ?? -1, // 무제한
494
+ autoQuality: tierConfig?.autoQuality ?? AUTO_WEBP_QUALITY,
495
+ };
496
+
497
+ /**
498
+ * 매 요청마다 uploads/.image-config.json 파일에서 앱별 이미지 설정을 읽어
499
+ * Tier config와 병합한 최종 config를 반환.
500
+ *
501
+ * 파일 기반 설계 이유:
502
+ * - env var 방식은 전파 파이프라인이 6단계(DB→provisioner→env→dist→handler)로 깨지기 쉬움
503
+ * - 파일은 대시보드에서 write → 다음 요청에서 즉시 read (2단계)
504
+ * - 어느 프로세스든(Platform/앱) 동일 파일을 읽으므로 일관성 보장
505
+ * - readFileSync ~0.01ms, 성능 영향 없음
506
+ */
507
+ function getImageConfig(uploadsDir: string): Required<StorageImageTierConfig> {
508
+ const config = { ...baseTierConfig };
509
+ try {
510
+ const raw = fsSync.readFileSync(path.join(uploadsDir, ".image-config.json"), "utf-8");
511
+ const appConfig = JSON.parse(raw);
512
+ // Tier ceiling 적용: min(앱 설정, Tier 최대값)
513
+ if (appConfig.autoMaxWidth > 0) {
514
+ config.autoMaxWidth =
515
+ config.autoMaxWidth > 0
516
+ ? Math.min(appConfig.autoMaxWidth, config.autoMaxWidth)
517
+ : appConfig.autoMaxWidth;
518
+ }
519
+ if (appConfig.autoQuality > 0 && appConfig.autoQuality <= 100) {
520
+ config.autoQuality = appConfig.autoQuality;
521
+ }
522
+ if (appConfig.autoWebp === false) {
523
+ config.autoWebp = false;
524
+ }
525
+ } catch {
526
+ /* 파일 없음 or 파싱 실패 → Tier 기본값 유지 */
520
527
  }
528
+ return config;
529
+ }
521
530
 
522
- // sharp 모듈 캐시 (동적 import 1회만)
523
- let sharpModule: any = null;
524
- let sharpLoadAttempted = false;
531
+ // sharp 모듈 캐시 (동적 import 1회만)
532
+ let sharpModule: any = null;
533
+ let sharpLoadAttempted = false;
525
534
 
526
- async function getSharp(): Promise<any> {
527
- if (sharpLoadAttempted) return sharpModule;
528
- sharpLoadAttempted = true;
529
- try {
530
- // @ts-ignore — sharp는 런타임 선택적 의존성 (설치 안 된 환경에서도 빌드 가능)
531
- sharpModule = (await import("sharp")).default;
532
- } catch {
533
- // sharp 미설치 시 null → 원본 서빙 fallback
534
- console.warn("[storage] sharp not available — image optimization disabled");
535
- sharpModule = null;
535
+ async function getSharp(): Promise<any> {
536
+ if (sharpLoadAttempted) return sharpModule;
537
+ sharpLoadAttempted = true;
538
+ try {
539
+ // @ts-ignore — sharp는 런타임 선택적 의존성 (설치 안 된 환경에서도 빌드 가능)
540
+ sharpModule = (await import("sharp")).default;
541
+ } catch {
542
+ // sharp 미설치 시 null → 원본 서빙 fallback
543
+ console.warn("[storage] sharp not available — image optimization disabled");
544
+ sharpModule = null;
545
+ }
546
+ return sharpModule;
547
+ }
548
+
549
+ return async (c: {
550
+ req: {
551
+ param: (key: string) => string;
552
+ query: (key: string) => string | undefined;
553
+ header: (name: string) => string | undefined;
554
+ };
555
+ json: (data: unknown, status?: number) => Response;
556
+ body: (data: unknown, status: number, headers: Record<string, string>) => Response;
557
+ }) => {
558
+ // 매 요청마다 uploads/.image-config.json에서 config 읽기 (파일 기반 hot-reload)
559
+ const dir = storageDir || "./uploads";
560
+ const config = getImageConfig(dir);
561
+
562
+ const id = c.req.param("id");
563
+ let meta = await storage.getMeta(id);
564
+
565
+ // Fallback: DB lookup when in-memory meta is missing (e.g. after server restart)
566
+ if (!meta && rawSql) {
567
+ try {
568
+ const rows = await rawSql(
569
+ `SELECT storage_id, name, size, type FROM _system_files WHERE storage_id = $1 LIMIT 1`,
570
+ [id],
571
+ );
572
+ if (rows.length > 0) {
573
+ const row = rows[0] as Record<string, string>;
574
+ const dir = storageDir || "./uploads";
575
+ meta = {
576
+ id: row.storage_id,
577
+ name: row.name,
578
+ size: Number(row.size),
579
+ type: row.type || "application/octet-stream",
580
+ path: path.join(dir, row.storage_id),
581
+ };
536
582
  }
537
- return sharpModule;
583
+ } catch {
584
+ /* fallthrough to 404 */
585
+ }
538
586
  }
539
587
 
540
- return async (c: { req: { param: (key: string) => string; query: (key: string) => string | undefined; header: (name: string) => string | undefined }; json: (data: unknown, status?: number) => Response; body: (data: unknown, status: number, headers: Record<string, string>) => Response }) => {
541
- // 매 요청마다 uploads/.image-config.json에서 config 읽기 (파일 기반 hot-reload)
542
- const dir = storageDir || "./uploads";
543
- const config = getImageConfig(dir);
544
-
545
- const id = c.req.param("id");
546
- let meta = await storage.getMeta(id);
588
+ if (!meta) {
589
+ return c.json({ error: "Not found" }, 404);
590
+ }
547
591
 
548
- // Fallback: DB lookup when in-memory meta is missing (e.g. after server restart)
549
- if (!meta && rawSql) {
550
- try {
551
- const rows = await rawSql(
552
- `SELECT storage_id, name, size, type FROM _system_files WHERE storage_id = $1 LIMIT 1`,
553
- [id]
554
- );
555
- if (rows.length > 0) {
556
- const row = rows[0] as Record<string, string>;
557
- const dir = storageDir || "./uploads";
558
- meta = {
559
- id: row.storage_id,
560
- name: row.name,
561
- size: Number(row.size),
562
- type: row.type || "application/octet-stream",
563
- path: path.join(dir, row.storage_id),
564
- };
565
- }
566
- } catch { /* fallthrough to 404 */ }
567
- }
592
+ // ── 이미지 최적화 분기 ─────────────────────────────
593
+ const isTransformable = TRANSFORMABLE_TYPES.has(meta.type);
594
+
595
+ // Hono native query 파서로 파라미터 추출
596
+ const transformParams = isTransformable
597
+ ? parseTransformParams(
598
+ c.req.query("w"),
599
+ c.req.query("h"),
600
+ c.req.query("f"),
601
+ c.req.query("q"),
602
+ c.req.query("fit"),
603
+ )
604
+ : null;
605
+
606
+ // Accept 헤더에서 WebP 지원 감지
607
+ const acceptHeader = c.req.header("accept") || "";
608
+ const clientAcceptsWebp = acceptHeader.includes("image/webp");
609
+
610
+ // Auto WebP: 파라미터 없지만 브라우저가 webp 지원 + 원본이 PNG/JPEG
611
+ const isAutoWebp =
612
+ !transformParams &&
613
+ isTransformable &&
614
+ clientAcceptsWebp &&
615
+ config.autoWebp &&
616
+ (meta.type === "image/png" || meta.type === "image/jpeg" || meta.type === "image/jpg");
617
+
618
+ // 이미지 변환이 필요 없는 경우 → 원본 서빙 (기존 동작 100% 유지)
619
+ if (!transformParams && !isAutoWebp) {
620
+ return serveOriginal(c, meta);
621
+ }
568
622
 
569
- if (!meta) {
570
- return c.json({ error: "Not found" }, 404);
571
- }
623
+ // ── Tier 검증 (파라미터 변환 시) ──────────────────
624
+ if (transformParams) {
625
+ // 리사이즈 검증
626
+ if ((transformParams.w || transformParams.h) && !config.resize) {
627
+ return c.json(
628
+ {
629
+ error: "Image resize requires Pro plan",
630
+ code: "PLAN_LIMIT",
631
+ upgrade: "https://gencow.app/pricing",
632
+ },
633
+ 403,
634
+ );
635
+ }
636
+ // 포맷 검증
637
+ if (transformParams.f && !config.formats.includes(transformParams.f)) {
638
+ return c.json(
639
+ {
640
+ error: `Format "${transformParams.f}" requires a higher plan`,
641
+ code: "PLAN_LIMIT",
642
+ upgrade: "https://gencow.app/pricing",
643
+ allowed: { formats: config.formats },
644
+ },
645
+ 403,
646
+ );
647
+ }
648
+ // 품질 검증
649
+ if (transformParams.q && !config.qualityControl) {
650
+ return c.json(
651
+ {
652
+ error: "Quality control requires Pro plan",
653
+ code: "PLAN_LIMIT",
654
+ upgrade: "https://gencow.app/pricing",
655
+ },
656
+ 403,
657
+ );
658
+ }
659
+ // fit 검증 (resize와 함께만 동작)
660
+ if (transformParams.fit && !config.resize) {
661
+ return c.json(
662
+ {
663
+ error: "Fit mode requires Pro plan",
664
+ code: "PLAN_LIMIT",
665
+ upgrade: "https://gencow.app/pricing",
666
+ },
667
+ 403,
668
+ );
669
+ }
670
+ }
572
671
 
573
- // ── 이미지 최적화 분기 ─────────────────────────────
574
- const isTransformable = TRANSFORMABLE_TYPES.has(meta.type);
575
-
576
- // Hono native query 파서로 파라미터 추출
577
- const transformParams = isTransformable
578
- ? parseTransformParams(
579
- c.req.query("w"), c.req.query("h"),
580
- c.req.query("f"), c.req.query("q"),
581
- c.req.query("fit"),
582
- )
583
- : null;
584
-
585
- // Accept 헤더에서 WebP 지원 감지
586
- const acceptHeader = c.req.header("accept") || "";
587
- const clientAcceptsWebp = acceptHeader.includes("image/webp");
588
-
589
- // Auto WebP: 파라미터 없지만 브라우저가 webp 지원 + 원본이 PNG/JPEG
590
- const isAutoWebp = !transformParams
591
- && isTransformable
592
- && clientAcceptsWebp
593
- && config.autoWebp
594
- && (meta.type === "image/png" || meta.type === "image/jpeg" || meta.type === "image/jpg");
595
-
596
- // 이미지 변환이 필요 없는 경우 → 원본 서빙 (기존 동작 100% 유지)
597
- if (!transformParams && !isAutoWebp) {
598
- return serveOriginal(c, meta);
599
- }
672
+ // ── sharp 로드 확인 ─────────────────────────────
673
+ const sharp = await getSharp();
674
+ if (!sharp) {
675
+ // sharp 미설치 원본 서빙 (graceful degradation)
676
+ return serveOriginal(c, meta);
677
+ }
600
678
 
601
- // ── Tier 검증 (파라미터 변환 시) ──────────────────
602
- if (transformParams) {
603
- // 리사이즈 검증
604
- if ((transformParams.w || transformParams.h) && !config.resize) {
605
- return c.json({
606
- error: "Image resize requires Pro plan",
607
- code: "PLAN_LIMIT",
608
- upgrade: "https://gencow.app/pricing",
609
- }, 403);
610
- }
611
- // 포맷 검증
612
- if (transformParams.f && !config.formats.includes(transformParams.f)) {
613
- return c.json({
614
- error: `Format "${transformParams.f}" requires a higher plan`,
615
- code: "PLAN_LIMIT",
616
- upgrade: "https://gencow.app/pricing",
617
- allowed: { formats: config.formats },
618
- }, 403);
619
- }
620
- // 품질 검증
621
- if (transformParams.q && !config.qualityControl) {
622
- return c.json({
623
- error: "Quality control requires Pro plan",
624
- code: "PLAN_LIMIT",
625
- upgrade: "https://gencow.app/pricing",
626
- }, 403);
627
- }
628
- // fit 검증 (resize와 함께만 동작)
629
- if (transformParams.fit && !config.resize) {
630
- return c.json({
631
- error: "Fit mode requires Pro plan",
632
- code: "PLAN_LIMIT",
633
- upgrade: "https://gencow.app/pricing",
634
- }, 403);
635
- }
636
- }
679
+ // ── 캐시 확인 ───────────────────────────────────
680
+ const cacheDir = path.join(dir, ".cache");
681
+ const cacheKey = buildCacheKey(
682
+ id,
683
+ transformParams || {},
684
+ isAutoWebp,
685
+ config.autoMaxWidth || undefined,
686
+ config.autoQuality,
687
+ );
688
+ const cachePath = path.join(cacheDir, cacheKey);
637
689
 
638
- // ── sharp 로드 확인 ─────────────────────────────
639
- const sharp = await getSharp();
640
- if (!sharp) {
641
- // sharp 미설치원본 서빙 (graceful degradation)
642
- return serveOriginal(c, meta);
643
- }
690
+ // 캐시 HIT
691
+ try {
692
+ await fs.access(cachePath);
693
+ // 캐시 파일이 존재 바로 서빙
694
+ return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
695
+ } catch {
696
+ // 캐시 MISS → 변환 필요
697
+ }
644
698
 
645
- // ── 캐시 확인 ───────────────────────────────────
646
- const cacheDir = path.join(dir, ".cache");
647
- const cacheKey = buildCacheKey(id, transformParams || {}, isAutoWebp, config.autoMaxWidth || undefined, config.autoQuality);
648
- const cachePath = path.join(cacheDir, cacheKey);
699
+ // ── 캐시 엔트리 수 제한 확인 ─────────────────────
700
+ const cacheCount = await getCacheEntryCount(cacheDir);
701
+ if (cacheCount >= MAX_CACHE_ENTRIES) {
702
+ // 캐시 상한 초과 → 변환은 수행하되 캐시 저장 안 함 (or 원본 반환)
703
+ // 보안상 원본 서빙으로 fallback
704
+ return serveOriginal(c, meta);
705
+ }
649
706
 
650
- // 캐시 HIT
651
- try {
652
- await fs.access(cachePath);
653
- // 캐시 파일이 존재 → 바로 서빙
654
- return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
655
- } catch {
656
- // 캐시 MISS 변환 필요
707
+ // ── 이미지 변환 수행 ─────────────────────────────
708
+ try {
709
+ await acquireTransformSlot();
710
+
711
+ try {
712
+ // 캐시 디렉토리 생성
713
+ await fs.mkdir(cacheDir, { recursive: true });
714
+
715
+ // sharp 파이프라인 구성
716
+ let pipeline = sharp(meta.path);
717
+
718
+ // 리사이즈
719
+ if (transformParams?.w || transformParams?.h) {
720
+ const fitMode = (transformParams.fit || "cover") as "cover" | "contain" | "fill" | "inside";
721
+ pipeline = pipeline.resize({
722
+ width: transformParams.w || undefined,
723
+ height: transformParams.h || undefined,
724
+ fit: fitMode,
725
+ withoutEnlargement: true, // 원본보다 크게 확대하지 않음
726
+ });
727
+ } else if (isAutoWebp && config.autoMaxWidth > 0) {
728
+ // Auto WebP 시 Tier별 최대폭 제한 (Hobby=1920, Pro=3840, Scale=0=무제한)
729
+ // 원본이 maxWidth 이하면 리사이즈하지 않음 (withoutEnlargement)
730
+ pipeline = pipeline.resize({
731
+ width: config.autoMaxWidth,
732
+ withoutEnlargement: true,
733
+ });
657
734
  }
658
735
 
659
- // ── 캐시 엔트리 수 제한 확인 ─────────────────────
660
- const cacheCount = await getCacheEntryCount(cacheDir);
661
- if (cacheCount >= MAX_CACHE_ENTRIES) {
662
- // 캐시 상한 초과 → 변환은 수행하되 캐시 저장 안 함 (or 원본 반환)
663
- // 보안상 원본 서빙으로 fallback
664
- return serveOriginal(c, meta);
736
+ // 출력 포맷 + 품질
737
+ const outputFormat = transformParams?.f || (isAutoWebp ? "webp" : null);
738
+ const quality = transformParams?.q ?? DEFAULT_QUALITY;
739
+
740
+ if (outputFormat === "webp") {
741
+ pipeline = pipeline.webp(
742
+ isAutoWebp
743
+ ? { quality: config.autoQuality, effort: AUTO_WEBP_EFFORT, alphaQuality: 100 }
744
+ : { quality, alphaQuality: 100 },
745
+ );
746
+ } else if (outputFormat === "avif") {
747
+ pipeline = pipeline.avif({ quality });
748
+ } else if (outputFormat === "jpeg" || outputFormat === "jpg") {
749
+ pipeline = pipeline.jpeg({ quality, mozjpeg: true });
750
+ } else if (outputFormat === "png") {
751
+ pipeline = pipeline.png({ compressionLevel: 9 });
665
752
  }
666
753
 
667
- // ── 이미지 변환 수행 ─────────────────────────────
668
- try {
669
- await acquireTransformSlot();
670
-
671
- try {
672
- // 캐시 디렉토리 생성
673
- await fs.mkdir(cacheDir, { recursive: true });
674
-
675
- // sharp 파이프라인 구성
676
- let pipeline = sharp(meta.path);
677
-
678
- // 리사이즈
679
- if (transformParams?.w || transformParams?.h) {
680
- const fitMode = (transformParams.fit || "cover") as "cover" | "contain" | "fill" | "inside";
681
- pipeline = pipeline.resize({
682
- width: transformParams.w || undefined,
683
- height: transformParams.h || undefined,
684
- fit: fitMode,
685
- withoutEnlargement: true, // 원본보다 크게 확대하지 않음
686
- });
687
- } else if (isAutoWebp && config.autoMaxWidth > 0) {
688
- // Auto WebP 시 Tier별 최대폭 제한 (Hobby=1920, Pro=3840, Scale=0=무제한)
689
- // 원본이 maxWidth 이하면 리사이즈하지 않음 (withoutEnlargement)
690
- pipeline = pipeline.resize({
691
- width: config.autoMaxWidth,
692
- withoutEnlargement: true,
693
- });
694
- }
695
-
696
- // 출력 포맷 + 품질
697
- const outputFormat = transformParams?.f || (isAutoWebp ? "webp" : null);
698
- const quality = transformParams?.q ?? DEFAULT_QUALITY;
699
-
700
- if (outputFormat === "webp") {
701
- pipeline = pipeline.webp(isAutoWebp
702
- ? { quality: config.autoQuality, effort: AUTO_WEBP_EFFORT, alphaQuality: 100 }
703
- : { quality, alphaQuality: 100 });
704
- } else if (outputFormat === "avif") {
705
- pipeline = pipeline.avif({ quality });
706
- } else if (outputFormat === "jpeg" || outputFormat === "jpg") {
707
- pipeline = pipeline.jpeg({ quality, mozjpeg: true });
708
- } else if (outputFormat === "png") {
709
- pipeline = pipeline.png({ compressionLevel: 9 });
710
- }
711
-
712
- // 변환 실행 → 캐시에 저장
713
- await pipeline.toFile(cachePath);
714
-
715
- // WebP/AVIF가 원본보다 큰 경우 → 캐시 삭제 + 원본 서빙
716
- // (Static Deploy와 동일 전략 — apps.ts L840-847)
717
- if (isAutoWebp) {
718
- const cacheStats = await fs.stat(cachePath);
719
- if (cacheStats.size >= meta.size) {
720
- await fs.unlink(cachePath).catch(() => { });
721
- return serveOriginal(c, meta);
722
- }
723
- }
724
-
725
- return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
726
- } finally {
727
- releaseTransformSlot();
728
- }
729
- } catch (err: unknown) {
730
- const msg = err instanceof Error ? err.message : String(err);
731
- console.warn(`[storage] Image transform failed for ${id}: ${msg}`);
732
- // 변환 실패 → 원본 서빙 (graceful degradation)
754
+ // 변환 실행 캐시에 저장
755
+ await pipeline.toFile(cachePath);
756
+
757
+ // WebP/AVIF가 원본보다 큰 경우 → 캐시 삭제 + 원본 서빙
758
+ // (Static Deploy와 동일 전략 — apps.ts L840-847)
759
+ if (isAutoWebp) {
760
+ const cacheStats = await fs.stat(cachePath);
761
+ if (cacheStats.size >= meta.size) {
762
+ await fs.unlink(cachePath).catch(() => {});
733
763
  return serveOriginal(c, meta);
764
+ }
734
765
  }
735
- };
736
766
 
737
- /** 원본 파일 서빙 (기존 동작) */
738
- function serveOriginal(c: any, meta: StorageFile): Response {
739
- const headers: Record<string, string> = {
740
- "Content-Type": meta.type,
741
- "Content-Disposition": `inline; filename="${encodeURIComponent(meta.name)}"; filename*=UTF-8''${encodeURIComponent(meta.name)}`,
742
- "Cache-Control": "public, max-age=31536000, immutable",
743
- };
744
-
745
- if (typeof globalThis.Bun !== "undefined") {
746
- const bunFile = Bun.file(meta.path);
747
- return new Response(bunFile, { headers });
748
- }
767
+ return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
768
+ } finally {
769
+ releaseTransformSlot();
770
+ }
771
+ } catch (err: unknown) {
772
+ const msg = err instanceof Error ? err.message : String(err);
773
+ console.warn(`[storage] Image transform failed for ${id}: ${msg}`);
774
+ // 변환 실패 → 원본 서빙 (graceful degradation)
775
+ return serveOriginal(c, meta);
776
+ }
777
+ };
778
+
779
+ /** 원본 파일 서빙 (기존 동작) */
780
+ function serveOriginal(c: any, meta: StorageFile): Response {
781
+ const headers: Record<string, string> = {
782
+ "Content-Type": meta.type,
783
+ "Content-Disposition": `inline; filename="${encodeURIComponent(meta.name)}"; filename*=UTF-8''${encodeURIComponent(meta.name)}`,
784
+ "Cache-Control": "public, max-age=31536000, immutable",
785
+ };
749
786
 
750
- // Node.js 폴백 — 동기 readFile (async context에서 호출되므로 안전)
751
- const file = require("fs").readFileSync(meta.path);
752
- headers["Content-Length"] = String(file.byteLength);
753
- return c.body(file, 200, headers);
787
+ if (typeof globalThis.Bun !== "undefined") {
788
+ const bunFile = Bun.file(meta.path);
789
+ return new Response(bunFile, { headers });
754
790
  }
755
791
 
756
- /** 캐시된 변환 파일 서빙 */
757
- function serveCachedFile(
758
- c: any,
759
- cachePath: string,
760
- params: ImageTransformParams | null,
761
- isAutoWebp: boolean,
762
- originalMeta: StorageFile,
763
- ): Response {
764
- const outputFormat = params?.f || (isAutoWebp ? "webp" : null);
765
- const contentType = outputFormat
766
- ? `image/${outputFormat === "jpg" ? "jpeg" : outputFormat}`
767
- : originalMeta.type;
768
-
769
- // 변환된 파일명: original.png → original.webp (또는 original_300x200.webp)
770
- const baseName = originalMeta.name.replace(/\.[^.]+$/, "");
771
- const ext = outputFormat || originalMeta.name.split(".").pop() || "bin";
772
- const suffix = params?.w || params?.h
773
- ? `_${params.w || "auto"}x${params.h || "auto"}`
774
- : "";
775
- const fileName = `${baseName}${suffix}.${ext}`;
776
-
777
- const headers: Record<string, string> = {
778
- "Content-Type": contentType,
779
- "Content-Disposition": `inline; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
780
- "Cache-Control": "public, max-age=31536000, immutable",
781
- // Vary: Accept Auto WebP 시 CDN/브라우저 캐시 분리
782
- ...(isAutoWebp ? { "Vary": "Accept" } : {}),
783
- };
784
-
785
- if (typeof globalThis.Bun !== "undefined") {
786
- const bunFile = Bun.file(cachePath);
787
- return new Response(bunFile, { headers });
788
- }
792
+ // Node.js 폴백 동기 readFile (async context에서 호출되므로 안전)
793
+ const file = require("fs").readFileSync(meta.path);
794
+ headers["Content-Length"] = String(file.byteLength);
795
+ return c.body(file, 200, headers);
796
+ }
797
+
798
+ /** 캐시된 변환 파일 서빙 */
799
+ function serveCachedFile(
800
+ c: any,
801
+ cachePath: string,
802
+ params: ImageTransformParams | null,
803
+ isAutoWebp: boolean,
804
+ originalMeta: StorageFile,
805
+ ): Response {
806
+ const outputFormat = params?.f || (isAutoWebp ? "webp" : null);
807
+ const contentType = outputFormat
808
+ ? `image/${outputFormat === "jpg" ? "jpeg" : outputFormat}`
809
+ : originalMeta.type;
810
+
811
+ // 변환된 파일명: original.png → original.webp (또는 original_300x200.webp)
812
+ const baseName = originalMeta.name.replace(/\.[^.]+$/, "");
813
+ const ext = outputFormat || originalMeta.name.split(".").pop() || "bin";
814
+ const suffix = params?.w || params?.h ? `_${params.w || "auto"}x${params.h || "auto"}` : "";
815
+ const fileName = `${baseName}${suffix}.${ext}`;
816
+
817
+ const headers: Record<string, string> = {
818
+ "Content-Type": contentType,
819
+ "Content-Disposition": `inline; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
820
+ "Cache-Control": "public, max-age=31536000, immutable",
821
+ // Vary: Accept Auto WebP 시 CDN/브라우저 캐시 분리
822
+ ...(isAutoWebp ? { Vary: "Accept" } : {}),
823
+ };
789
824
 
790
- const file = require("fs").readFileSync(cachePath);
791
- headers["Content-Length"] = String(file.byteLength);
792
- return c.body(file, 200, headers);
825
+ if (typeof globalThis.Bun !== "undefined") {
826
+ const bunFile = Bun.file(cachePath);
827
+ return new Response(bunFile, { headers });
793
828
  }
794
- }
795
829
 
830
+ const file = require("fs").readFileSync(cachePath);
831
+ headers["Content-Length"] = String(file.byteLength);
832
+ return c.body(file, 200, headers);
833
+ }
834
+ }