@kalutskii/foundation 2.0.5 → 2.0.6
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.
- package/README.md +13 -347
- package/dist/index.d.ts +939 -737
- package/dist/index.js +1008 -678
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,52 +1,48 @@
|
|
|
1
|
-
import { and, eq } from "drizzle-orm";
|
|
2
|
-
import { HTTPException } from "hono/http-exception";
|
|
3
|
-
import { blue, bold, dim, green, red, white, yellow } from "kleur/colors";
|
|
4
1
|
import { format } from "date-fns";
|
|
5
2
|
import { getTimezoneOffset, toZonedTime } from "date-fns-tz";
|
|
6
3
|
import { ru } from "date-fns/locale";
|
|
7
|
-
import {
|
|
4
|
+
import { and, eq, getTableColumns, isNull } from "drizzle-orm";
|
|
5
|
+
import { HTTPException } from "hono/http-exception";
|
|
6
|
+
import { blue, bold, dim, green, red, white, yellow } from "kleur/colors";
|
|
8
7
|
import { decode, sign, verify } from "hono/jwt";
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
//#region src/base64/base64.constants.ts
|
|
11
10
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* @example
|
|
16
|
-
* await db.update(usersTable).set(values).where(sqlWhere(usersTable, { id: 1 })).returning();
|
|
11
|
+
* Maximum byte count converted by one `String.fromCharCode` invocation.
|
|
12
|
+
* Chunking keeps large payloads below engine-specific argument-count limits.
|
|
17
13
|
*/
|
|
18
|
-
|
|
19
|
-
const columns = table;
|
|
20
|
-
const conditions = Object.entries(where).filter(([, value]) => value !== void 0).map(([key, value]) => eq(columns[key], value));
|
|
21
|
-
if (conditions.length === 0) throw new Error(AT_LEAST_ONE_DEFINED_CONDITION_ERROR);
|
|
22
|
-
return and(...conditions);
|
|
23
|
-
}
|
|
14
|
+
const BASE64_BYTE_CHUNK_SIZE = 32768;
|
|
24
15
|
//#endregion
|
|
25
|
-
//#region src/
|
|
16
|
+
//#region src/base64/base64.utilities.ts
|
|
26
17
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
18
|
+
* Encodes arbitrary binary bytes into their canonical Base64 representation.
|
|
19
|
+
* Chunked conversion avoids the argument limit imposed by `String.fromCharCode`.
|
|
29
20
|
*/
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
data
|
|
35
|
-
};
|
|
21
|
+
function encodeBase64(bytes) {
|
|
22
|
+
let binary = "";
|
|
23
|
+
for (let offset = 0; offset < bytes.length; offset += BASE64_BYTE_CHUNK_SIZE) binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_BYTE_CHUNK_SIZE));
|
|
24
|
+
return btoa(binary);
|
|
36
25
|
}
|
|
37
26
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
27
|
+
* Decodes Base64 text accepted by native `atob` into its original binary bytes.
|
|
28
|
+
* Unsupported input preserves the platform failure instead of returning partial data.
|
|
40
29
|
*/
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
status,
|
|
45
|
-
error
|
|
46
|
-
};
|
|
30
|
+
function decodeBase64(value) {
|
|
31
|
+
const binary = atob(value);
|
|
32
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
47
33
|
}
|
|
48
34
|
//#endregion
|
|
49
|
-
//#region src/
|
|
35
|
+
//#region src/datetime/datetime.constants.ts
|
|
36
|
+
/**
|
|
37
|
+
* Default timezone applied by datetime utilities when no explicit zone is provided.
|
|
38
|
+
* The value preserves the package's historical London-based formatting behavior.
|
|
39
|
+
*/
|
|
40
|
+
const DEFAULT_DATETIME_TIMEZONE = "Europe/London";
|
|
41
|
+
const DATETIME_DATE_PATTERN = "dd.MM.yyyy";
|
|
42
|
+
const DATETIME_TIME_PATTERN = "HH:mm:ss";
|
|
43
|
+
const DATETIME_LOCALIZED_PATTERN = "HH:mm:ss, d MMMM yyyy";
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/datetime/datetime.utilities.ts
|
|
50
46
|
/**
|
|
51
47
|
* Projects the current instant onto the wall-clock fields of another timezone.
|
|
52
48
|
* The timezone defaults to `Europe/London` when no option is provided.
|
|
@@ -54,7 +50,7 @@ function failure({ status, error }) {
|
|
|
54
50
|
* @example
|
|
55
51
|
* const londonTime = getZonedTime({ tz: 'Europe/London' });
|
|
56
52
|
*/
|
|
57
|
-
function getZonedTime({ tz =
|
|
53
|
+
function getZonedTime({ tz = DEFAULT_DATETIME_TIMEZONE } = {}) {
|
|
58
54
|
return toZonedTime(/* @__PURE__ */ new Date(), tz);
|
|
59
55
|
}
|
|
60
56
|
/**
|
|
@@ -75,9 +71,10 @@ function getUTCOffset(date, tz) {
|
|
|
75
71
|
* @example
|
|
76
72
|
* getFormattedTime({ tz: 'Europe/Moscow' }); // `03:04:05 (+3 UTC)`
|
|
77
73
|
*/
|
|
78
|
-
function getFormattedTime({ tz =
|
|
79
|
-
const
|
|
80
|
-
|
|
74
|
+
function getFormattedTime({ tz = DEFAULT_DATETIME_TIMEZONE } = {}) {
|
|
75
|
+
const currentTime = /* @__PURE__ */ new Date();
|
|
76
|
+
const zonedTime = toZonedTime(currentTime, tz);
|
|
77
|
+
return `${format(zonedTime, DATETIME_TIME_PATTERN)} ${getUTCOffset(currentTime, tz)}`;
|
|
81
78
|
}
|
|
82
79
|
/**
|
|
83
80
|
* Formats the current date with optional time and UTC offset components.
|
|
@@ -86,9 +83,10 @@ function getFormattedTime({ tz = "Europe/London" } = {}) {
|
|
|
86
83
|
* @example
|
|
87
84
|
* getFormattedDate({ tz: 'UTC', withTime: false }); // `02.01.2024`
|
|
88
85
|
*/
|
|
89
|
-
function getFormattedDate({ tz =
|
|
90
|
-
const
|
|
91
|
-
|
|
86
|
+
function getFormattedDate({ tz = DEFAULT_DATETIME_TIMEZONE, withTime = true } = {}) {
|
|
87
|
+
const currentTime = /* @__PURE__ */ new Date();
|
|
88
|
+
const zonedTime = toZonedTime(currentTime, tz);
|
|
89
|
+
return `${format(zonedTime, withTime ? `${DATETIME_DATE_PATTERN} ${DATETIME_TIME_PATTERN}` : DATETIME_DATE_PATTERN)}${withTime ? ` ${getUTCOffset(currentTime, tz)}` : ""}`;
|
|
92
90
|
}
|
|
93
91
|
/**
|
|
94
92
|
* Formats an explicit instant in another timezone using the selected locale.
|
|
@@ -97,175 +95,221 @@ function getFormattedDate({ tz = "Europe/London", withTime = true } = {}) {
|
|
|
97
95
|
* @example
|
|
98
96
|
* formatTime(date, { locale: ru, tz: 'Europe/Moscow' });
|
|
99
97
|
*/
|
|
100
|
-
function formatTime(time,
|
|
98
|
+
function formatTime(time, options = {}) {
|
|
99
|
+
const { locale = ru, tz = DEFAULT_DATETIME_TIMEZONE } = options;
|
|
101
100
|
const zonedTime = toZonedTime(time, tz);
|
|
102
|
-
return `${format(zonedTime,
|
|
101
|
+
return `${format(zonedTime, DATETIME_LOCALIZED_PATTERN, { locale })} ${getUTCOffset(time, tz)}`;
|
|
103
102
|
}
|
|
104
103
|
//#endregion
|
|
105
|
-
//#region src/
|
|
104
|
+
//#region src/drizzle/drizzle.errors.ts
|
|
105
|
+
const drizzleErrors = {
|
|
106
|
+
whereConditionsRequired: () => /* @__PURE__ */ new Error("whereConditionsRequired"),
|
|
107
|
+
whereColumnNotFound: () => /* @__PURE__ */ new Error("whereColumnNotFound")
|
|
108
|
+
};
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/drizzle/drizzle.refiners.ts
|
|
106
111
|
/**
|
|
107
|
-
*
|
|
108
|
-
* Keys
|
|
112
|
+
* Builds a Drizzle `WHERE` clause by combining defined object entries with `and`.
|
|
113
|
+
* Keys and values follow the table model while empty conditions remain forbidden.
|
|
109
114
|
*
|
|
110
115
|
* @example
|
|
111
|
-
*
|
|
116
|
+
* await db.update(usersTable).set(values).where(sqlWhere(usersTable, { id: 1 })).returning();
|
|
112
117
|
*/
|
|
113
|
-
function
|
|
114
|
-
|
|
118
|
+
function sqlWhere(table, where) {
|
|
119
|
+
const columns = getTableColumns(table);
|
|
120
|
+
const conditions = Object.entries(where).filter(([, value]) => value !== void 0).map(([key, value]) => {
|
|
121
|
+
const column = columns[key];
|
|
122
|
+
if (column === void 0) throw drizzleErrors.whereColumnNotFound();
|
|
123
|
+
return value === null ? isNull(column) : eq(column, value);
|
|
124
|
+
});
|
|
125
|
+
const condition = and(...conditions);
|
|
126
|
+
if (condition === void 0) throw drizzleErrors.whereConditionsRequired();
|
|
127
|
+
return condition;
|
|
115
128
|
}
|
|
116
129
|
//#endregion
|
|
117
|
-
//#region src/
|
|
118
|
-
const logLevelsArray = [
|
|
119
|
-
"info",
|
|
120
|
-
"warn",
|
|
121
|
-
"error"
|
|
122
|
-
];
|
|
123
|
-
const logLevelsRecord = createStringEnumRecord(logLevelsArray);
|
|
124
|
-
const logLevel = logLevelsRecord;
|
|
125
|
-
//#endregion
|
|
126
|
-
//#region src/logging/logging.constants.ts
|
|
130
|
+
//#region src/execution/execution.constants.ts
|
|
127
131
|
/**
|
|
128
|
-
*
|
|
129
|
-
* The
|
|
132
|
+
* Default total attempt count applied when `retryExecution` receives no override.
|
|
133
|
+
* The value includes the initial operation and every subsequent retry attempt.
|
|
130
134
|
*/
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
const DEFAULT_RETRY_MAX_ATTEMPTS = 3;
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/execution/execution.errors.ts
|
|
138
|
+
const executionErrors = {
|
|
139
|
+
invalidMaxAttempts: () => /* @__PURE__ */ new RangeError("invalidMaxAttempts"),
|
|
140
|
+
invalidRetryDelay: () => /* @__PURE__ */ new RangeError("invalidRetryDelay"),
|
|
141
|
+
invalidTimeout: () => /* @__PURE__ */ new RangeError("invalidTimeout"),
|
|
142
|
+
executionTimedOut: () => new DOMException("executionTimedOut", "TimeoutError")
|
|
135
143
|
};
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/execution/execution.measurements.ts
|
|
136
146
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
147
|
+
* Measures a synchronous or asynchronous execution while preserving its resolved result.
|
|
148
|
+
* Rejected operations propagate unchanged and never produce a measurement result.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* const { result, executionTime } = await measureExecutionTime(async () => {
|
|
152
|
+
* return await fetchData();
|
|
153
|
+
* });
|
|
154
|
+
* console.log(`Execution time: ${executionTime}ms`);
|
|
139
155
|
*/
|
|
140
|
-
|
|
156
|
+
async function measureExecutionTime(execution) {
|
|
157
|
+
const startedAt = performance.now();
|
|
158
|
+
const result = await execution();
|
|
159
|
+
const executionTime = performance.now() - startedAt;
|
|
160
|
+
return {
|
|
161
|
+
result,
|
|
162
|
+
executionTime: Math.round(executionTime)
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/execution/execution.validation.ts
|
|
141
167
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
168
|
+
* Validates one resolved delay before a retry timer is allocated.
|
|
169
|
+
* Only finite non-negative millisecond durations are accepted.
|
|
144
170
|
*/
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
"token",
|
|
149
|
-
"secret",
|
|
150
|
-
"authorization",
|
|
151
|
-
"apikey",
|
|
152
|
-
"credential"
|
|
153
|
-
];
|
|
171
|
+
function validateRetryDelay(delayMilliseconds) {
|
|
172
|
+
if (Number.isFinite(delayMilliseconds) === false || delayMilliseconds < 0) throw executionErrors.invalidRetryDelay();
|
|
173
|
+
}
|
|
154
174
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
175
|
+
* Validates static retry policy values before the first operation attempt.
|
|
176
|
+
* Dynamic delay values remain validated after their resolver completes.
|
|
157
177
|
*/
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
color: green
|
|
162
|
-
},
|
|
163
|
-
{
|
|
164
|
-
range: [400, 499],
|
|
165
|
-
color: yellow
|
|
166
|
-
},
|
|
167
|
-
{
|
|
168
|
-
range: [500, 599],
|
|
169
|
-
color: red
|
|
170
|
-
}
|
|
171
|
-
];
|
|
172
|
-
//#endregion
|
|
173
|
-
//#region src/logging/logging.services.ts
|
|
174
|
-
function writeLog(message, level, service = "log", stack) {
|
|
175
|
-
const timestamp = dim(getFormattedTime());
|
|
176
|
-
const serviceName = logLevelColors[level](service.padEnd(12));
|
|
177
|
-
const formattedMessage = white(message);
|
|
178
|
-
console.log(`[${timestamp}] ${serviceName} | ${formattedMessage}`);
|
|
179
|
-
if (stack) console.log(`[${timestamp}] ${red("↳ trace").padEnd(18)} | ${dim(stack)}`);
|
|
178
|
+
function validateRetryExecutionOptions(options, maxAttempts) {
|
|
179
|
+
if (Number.isInteger(maxAttempts) === false || maxAttempts < 1) throw executionErrors.invalidMaxAttempts();
|
|
180
|
+
if (typeof options.delayMilliseconds === "number") validateRetryDelay(options.delayMilliseconds);
|
|
180
181
|
}
|
|
181
182
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
183
|
+
* Validates the duration used to bound an execution with a timeout.
|
|
184
|
+
* Only finite non-negative millisecond durations are accepted.
|
|
184
185
|
*/
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
* Missing service names use the shared `log` fallback label.
|
|
189
|
-
*/
|
|
190
|
-
info(message, service) {
|
|
191
|
-
writeLog(message, "info", service);
|
|
192
|
-
},
|
|
193
|
-
/**
|
|
194
|
-
* Writes a warning message with an optional service label.
|
|
195
|
-
* Missing service names use the shared `log` fallback label.
|
|
196
|
-
*/
|
|
197
|
-
warn(message, service) {
|
|
198
|
-
writeLog(message, "warn", service);
|
|
199
|
-
},
|
|
200
|
-
/**
|
|
201
|
-
* Writes an error message with optional service and stack trace context.
|
|
202
|
-
* Provided stack traces are rendered beneath the primary message.
|
|
203
|
-
*/
|
|
204
|
-
error(message, service, stack) {
|
|
205
|
-
writeLog(message, "error", service, stack);
|
|
206
|
-
}
|
|
207
|
-
};
|
|
186
|
+
function validateExecutionTimeout(timeoutMilliseconds) {
|
|
187
|
+
if (Number.isFinite(timeoutMilliseconds) === false || timeoutMilliseconds < 0) throw executionErrors.invalidTimeout();
|
|
188
|
+
}
|
|
208
189
|
//#endregion
|
|
209
|
-
//#region src/
|
|
210
|
-
|
|
190
|
+
//#region src/execution/execution.retry.ts
|
|
191
|
+
/**
|
|
192
|
+
* Waits for a retry delay while preserving cooperative caller cancellation.
|
|
193
|
+
* Zero-duration delays resolve immediately without allocating a timer.
|
|
194
|
+
*/
|
|
195
|
+
async function waitForRetry(delayMilliseconds, signal) {
|
|
196
|
+
validateRetryDelay(delayMilliseconds);
|
|
197
|
+
signal?.throwIfAborted();
|
|
198
|
+
if (delayMilliseconds === 0) return;
|
|
199
|
+
await new Promise((resolve, reject) => {
|
|
200
|
+
const onAbort = () => {
|
|
201
|
+
clearTimeout(timeout);
|
|
202
|
+
reject(signal?.reason);
|
|
203
|
+
};
|
|
204
|
+
const timeout = setTimeout(() => {
|
|
205
|
+
signal?.removeEventListener("abort", onAbort);
|
|
206
|
+
resolve();
|
|
207
|
+
}, delayMilliseconds);
|
|
208
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
211
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* @example
|
|
216
|
-
* generateRandomString(5); // `aZ3fG`
|
|
217
|
-
* generateRandomString(); // `G5kLm2P9sQ`
|
|
212
|
+
* Repeats a failed operation under explicit attempt, delay, and filtering policies.
|
|
213
|
+
* Exhaustion, rejected filtering, and cancellation preserve the original failure.
|
|
218
214
|
*/
|
|
219
|
-
function
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
215
|
+
async function retryExecution(execution, options = {}) {
|
|
216
|
+
const { shouldRetry, signal } = options;
|
|
217
|
+
const { maxAttempts = 3, delayMilliseconds = 0 } = options;
|
|
218
|
+
validateRetryExecutionOptions(options, maxAttempts);
|
|
219
|
+
for (let attempt = 1;; attempt += 1) {
|
|
220
|
+
signal?.throwIfAborted();
|
|
221
|
+
try {
|
|
222
|
+
return await execution({
|
|
223
|
+
attempt,
|
|
224
|
+
signal
|
|
225
|
+
});
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const isMaxAttempts = attempt === maxAttempts;
|
|
228
|
+
const isRetryRejected = isMaxAttempts === false && shouldRetry ? await shouldRetry(error, attempt) === false : false;
|
|
229
|
+
if (isMaxAttempts || isRetryRejected) throw error;
|
|
230
|
+
signal?.throwIfAborted();
|
|
231
|
+
await waitForRetry(typeof delayMilliseconds === "function" ? await delayMilliseconds(error, attempt) : delayMilliseconds, signal);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
224
234
|
}
|
|
225
235
|
//#endregion
|
|
226
|
-
//#region src/
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
236
|
+
//#region src/execution/execution.timeout.ts
|
|
237
|
+
/**
|
|
238
|
+
* Runs an operation with a signal controlled by timeout and caller cancellation.
|
|
239
|
+
* The promise rejects promptly even when the operation ignores its abort signal.
|
|
240
|
+
*/
|
|
241
|
+
async function executeWithTimeout(execution, options) {
|
|
242
|
+
const { timeoutMilliseconds, signal: callerSignal } = options;
|
|
243
|
+
validateExecutionTimeout(timeoutMilliseconds);
|
|
244
|
+
const controller = new AbortController();
|
|
245
|
+
const forwardCallerAbort = () => controller.abort(callerSignal?.reason);
|
|
246
|
+
const rejectOnAbort = () => rejectAbortedExecution?.(controller.signal.reason);
|
|
247
|
+
let rejectAbortedExecution;
|
|
248
|
+
if (callerSignal?.aborted) controller.abort(callerSignal.reason);
|
|
249
|
+
else callerSignal?.addEventListener("abort", forwardCallerAbort, { once: true });
|
|
250
|
+
controller.signal.throwIfAborted();
|
|
251
|
+
const timeout = setTimeout(() => {
|
|
252
|
+
controller.abort(executionErrors.executionTimedOut());
|
|
253
|
+
}, timeoutMilliseconds);
|
|
254
|
+
const abortedExecution = new Promise((_, reject) => {
|
|
255
|
+
rejectAbortedExecution = reject;
|
|
256
|
+
if (controller.signal.aborted) reject(controller.signal.reason);
|
|
257
|
+
else controller.signal.addEventListener("abort", rejectOnAbort, { once: true });
|
|
234
258
|
});
|
|
259
|
+
try {
|
|
260
|
+
const executionPromise = Promise.resolve(execution(controller.signal));
|
|
261
|
+
return await Promise.race([executionPromise, abortedExecution]);
|
|
262
|
+
} finally {
|
|
263
|
+
clearTimeout(timeout);
|
|
264
|
+
callerSignal?.removeEventListener("abort", forwardCallerAbort);
|
|
265
|
+
controller.signal.removeEventListener("abort", rejectOnAbort);
|
|
266
|
+
}
|
|
235
267
|
}
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region src/execution/execution.utilities.ts
|
|
236
270
|
/**
|
|
237
|
-
*
|
|
238
|
-
*
|
|
271
|
+
* Resolves synchronous and asynchronous operations through one promise-based contract.
|
|
272
|
+
* Failures use the supplied fallback or propagate unchanged when none is available.
|
|
273
|
+
*
|
|
274
|
+
* @example
|
|
275
|
+
* await safeExecute(() => fetchData(), (error) => console.error(error));
|
|
239
276
|
*/
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
};
|
|
249
|
-
//#endregion
|
|
250
|
-
//#region src/hono/hono.respond.ts
|
|
277
|
+
async function safeExecute(fn, onError) {
|
|
278
|
+
try {
|
|
279
|
+
return await fn();
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (onError) return await onError(error);
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
251
285
|
/**
|
|
252
|
-
*
|
|
253
|
-
*
|
|
286
|
+
* Captures a synchronous or asynchronous operation in a discriminated result.
|
|
287
|
+
* Successful values and original unknown failures remain available unchanged.
|
|
254
288
|
*/
|
|
255
|
-
function
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
289
|
+
async function captureExecution(execution) {
|
|
290
|
+
try {
|
|
291
|
+
return {
|
|
292
|
+
success: true,
|
|
293
|
+
data: await execution()
|
|
294
|
+
};
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return {
|
|
297
|
+
success: false,
|
|
298
|
+
error
|
|
299
|
+
};
|
|
300
|
+
}
|
|
260
301
|
}
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/string-enum/string-enum.utilities.ts
|
|
261
304
|
/**
|
|
262
|
-
*
|
|
263
|
-
*
|
|
305
|
+
* Creates an immutable enum-like record from a readonly string array.
|
|
306
|
+
* Keys become upper snake case across camelCase, dotted, and hyphenated values.
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* createStringEnumRecord(['foo-bar.s', 'baz'] as const); // `{ FOO_BAR_S: 'foo-bar.s', BAZ: 'baz' }`
|
|
264
310
|
*/
|
|
265
|
-
function
|
|
266
|
-
|
|
267
|
-
c.header("Content-Type", options.contentType ?? "application/octet-stream");
|
|
268
|
-
return c.body(options.content, options.status);
|
|
311
|
+
function createStringEnumRecord(values) {
|
|
312
|
+
return Object.freeze(Object.fromEntries(values.map((value) => [value.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2").replaceAll(/[.-]/g, "_").toUpperCase(), value])));
|
|
269
313
|
}
|
|
270
314
|
//#endregion
|
|
271
315
|
//#region src/hmac/hmac.enums.ts
|
|
@@ -296,25 +340,13 @@ const DEFAULT_HMAC_ALGORITHM = hmacAlgorithm.SHA_256;
|
|
|
296
340
|
*/
|
|
297
341
|
const DEFAULT_HMAC_ENCODING = hmacEncoding.HEX;
|
|
298
342
|
//#endregion
|
|
299
|
-
//#region src/
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
*/
|
|
305
|
-
|
|
306
|
-
let binary = "";
|
|
307
|
-
for (let offset = 0; offset < bytes.length; offset += BASE64_BYTE_CHUNK_SIZE) binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_BYTE_CHUNK_SIZE));
|
|
308
|
-
return btoa(binary);
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* Decodes a canonical or padded Base64 string into its original binary bytes.
|
|
312
|
-
* Invalid input preserves the native `atob` failure instead of returning partial data.
|
|
313
|
-
*/
|
|
314
|
-
function decodeBase64(value) {
|
|
315
|
-
const binary = atob(value);
|
|
316
|
-
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
317
|
-
}
|
|
343
|
+
//#region src/hmac/hmac.errors.ts
|
|
344
|
+
const hmacErrors = {
|
|
345
|
+
invalidHexSignature: () => /* @__PURE__ */ new TypeError("invalidHexSignature"),
|
|
346
|
+
invalidBase64Signature: () => /* @__PURE__ */ new TypeError("invalidBase64Signature"),
|
|
347
|
+
invalidBase64UrlSignature: () => /* @__PURE__ */ new TypeError("invalidBase64UrlSignature"),
|
|
348
|
+
incompatibleCryptoKey: () => /* @__PURE__ */ new TypeError("incompatibleCryptoKey")
|
|
349
|
+
};
|
|
318
350
|
//#endregion
|
|
319
351
|
//#region src/hmac/hmac.utilities.ts
|
|
320
352
|
const textEncoder = new TextEncoder();
|
|
@@ -333,10 +365,12 @@ function toHMACBytes(input) {
|
|
|
333
365
|
* Hex output remains lowercase while Base64 URL output omits conventional padding.
|
|
334
366
|
*/
|
|
335
367
|
function encodeHMACSignature(signature, encoding) {
|
|
336
|
-
if (encoding === "hex") return
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
368
|
+
if (encoding === "hex") return signature.toHex();
|
|
369
|
+
if (encoding === "base64") return signature.toBase64();
|
|
370
|
+
return signature.toBase64({
|
|
371
|
+
alphabet: "base64url",
|
|
372
|
+
omitPadding: true
|
|
373
|
+
});
|
|
340
374
|
}
|
|
341
375
|
/**
|
|
342
376
|
* Decodes a textual HMAC signature into bytes for cryptographic verification.
|
|
@@ -344,15 +378,15 @@ function encodeHMACSignature(signature, encoding) {
|
|
|
344
378
|
*/
|
|
345
379
|
function decodeHMACSignature(signature, encoding) {
|
|
346
380
|
if (encoding === "hex") {
|
|
347
|
-
if (signature.length % 2 !== 0 ||
|
|
348
|
-
return Uint8Array.
|
|
381
|
+
if (signature.length % 2 !== 0 || /^[0-9a-f]*$/i.test(signature) === false) throw hmacErrors.invalidHexSignature();
|
|
382
|
+
return Uint8Array.fromHex(signature);
|
|
349
383
|
}
|
|
350
384
|
if (encoding === "base64") {
|
|
351
|
-
if (
|
|
352
|
-
return
|
|
385
|
+
if (/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(signature) === false) throw hmacErrors.invalidBase64Signature();
|
|
386
|
+
return Uint8Array.fromBase64(signature, { lastChunkHandling: "strict" });
|
|
353
387
|
}
|
|
354
|
-
if (
|
|
355
|
-
return
|
|
388
|
+
if (/^[A-Za-z0-9_-]*$/.test(signature) === false || signature.length % 4 === 1) throw hmacErrors.invalidBase64UrlSignature();
|
|
389
|
+
return Uint8Array.fromBase64(signature, { alphabet: "base64url" });
|
|
356
390
|
}
|
|
357
391
|
//#endregion
|
|
358
392
|
//#region src/hmac/hmac.services.ts
|
|
@@ -368,20 +402,32 @@ var HMACService = class {
|
|
|
368
402
|
this.encoding = options.encoding ?? DEFAULT_HMAC_ENCODING;
|
|
369
403
|
}
|
|
370
404
|
/**
|
|
405
|
+
* Imports raw secret material as a non-extractable Web Crypto HMAC key.
|
|
406
|
+
* The resulting key supports repeated signing and verification without reimporting.
|
|
407
|
+
*/
|
|
408
|
+
async importKey(secret) {
|
|
409
|
+
const secretBytes = toHMACBytes(secret);
|
|
410
|
+
const algorithm = {
|
|
411
|
+
name: "HMAC",
|
|
412
|
+
hash: this.algorithm
|
|
413
|
+
};
|
|
414
|
+
return crypto.subtle.importKey("raw", secretBytes, algorithm, false, ["sign", "verify"]);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
371
417
|
* Authenticates a payload with a secret and returns the encoded signature.
|
|
372
|
-
*
|
|
418
|
+
* Imported keys bypass repeated key creation while preserving service policy.
|
|
373
419
|
*
|
|
374
420
|
* @example
|
|
375
421
|
* const signature = await hmacService.sign('payload', 'shared-secret');
|
|
376
422
|
*/
|
|
377
423
|
async sign(payload, secret) {
|
|
378
|
-
const key = await this.
|
|
424
|
+
const key = await this.resolveKey(secret, "sign");
|
|
379
425
|
const signature = await crypto.subtle.sign("HMAC", key, toHMACBytes(payload));
|
|
380
426
|
return encodeHMACSignature(new Uint8Array(signature), this.encoding);
|
|
381
427
|
}
|
|
382
428
|
/**
|
|
383
429
|
* Verifies an encoded signature without requiring a manual equality comparison.
|
|
384
|
-
* Invalid
|
|
430
|
+
* Invalid signatures resolve to `false`; incompatible imported keys reject explicitly.
|
|
385
431
|
*
|
|
386
432
|
* @example
|
|
387
433
|
* const verified = await hmacService.verify('payload', signature, 'shared-secret');
|
|
@@ -393,97 +439,342 @@ var HMACService = class {
|
|
|
393
439
|
} catch {
|
|
394
440
|
return false;
|
|
395
441
|
}
|
|
396
|
-
const key = await this.
|
|
442
|
+
const key = await this.resolveKey(secret, "verify");
|
|
397
443
|
return crypto.subtle.verify("HMAC", key, signatureBytes, toHMACBytes(payload));
|
|
398
444
|
}
|
|
399
|
-
async
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
return crypto.subtle.importKey("raw", secretBytes, algorithm, false, ["sign", "verify"]);
|
|
445
|
+
async resolveKey(secret, usage) {
|
|
446
|
+
if (typeof secret === "string" || secret instanceof Uint8Array) return this.importKey(secret);
|
|
447
|
+
const hash = Reflect.get(secret.algorithm, "hash");
|
|
448
|
+
const hashName = typeof hash === "object" && hash !== null && "name" in hash ? hash.name : void 0;
|
|
449
|
+
if ((secret.type === "secret" && secret.algorithm.name === "HMAC" && hashName === this.algorithm && secret.usages.includes(usage)) === false) throw hmacErrors.incompatibleCryptoKey();
|
|
450
|
+
return secret;
|
|
406
451
|
}
|
|
407
452
|
};
|
|
408
453
|
//#endregion
|
|
409
|
-
//#region src/
|
|
410
|
-
|
|
454
|
+
//#region src/hono/hono.constants.ts
|
|
455
|
+
/**
|
|
456
|
+
* Default HTTP header carrying one request identifier across service boundaries.
|
|
457
|
+
* Incoming non-empty values are preserved while missing identifiers are generated.
|
|
458
|
+
*/
|
|
459
|
+
const HONO_REQUEST_ID_HEADER = "X-Request-ID";
|
|
460
|
+
/**
|
|
461
|
+
* Default service label applied to request lines emitted by Hono logging middleware.
|
|
462
|
+
* Configured middleware may replace the label without changing message formatting.
|
|
463
|
+
*/
|
|
464
|
+
const HONO_LOGGING_SERVICE = "hono";
|
|
465
|
+
//#endregion
|
|
466
|
+
//#region src/hono/hono.errors.ts
|
|
467
|
+
const honoErrors = {
|
|
468
|
+
internalServerError: () => new HTTPException(500, { message: "internalServerError" }),
|
|
469
|
+
invalidRequestId: () => /* @__PURE__ */ new TypeError("invalidRequestId")
|
|
470
|
+
};
|
|
471
|
+
//#endregion
|
|
472
|
+
//#region src/response/response.enums.ts
|
|
473
|
+
const responseKindsArray = ["success", "error"];
|
|
474
|
+
const responseKindsRecord = createStringEnumRecord(responseKindsArray);
|
|
475
|
+
const responseKind = responseKindsRecord;
|
|
476
|
+
const SUCCESS_RESPONSE_STATUSES = [
|
|
411
477
|
200,
|
|
412
478
|
201,
|
|
413
479
|
202,
|
|
414
|
-
|
|
480
|
+
206
|
|
415
481
|
];
|
|
416
|
-
const
|
|
482
|
+
const ERROR_RESPONSE_STATUSES = [
|
|
417
483
|
400,
|
|
418
484
|
401,
|
|
419
485
|
403,
|
|
420
486
|
404,
|
|
421
487
|
405,
|
|
488
|
+
406,
|
|
489
|
+
408,
|
|
422
490
|
409,
|
|
491
|
+
410,
|
|
423
492
|
413,
|
|
424
|
-
|
|
493
|
+
414,
|
|
494
|
+
415,
|
|
495
|
+
422,
|
|
496
|
+
425,
|
|
497
|
+
429,
|
|
498
|
+
440,
|
|
499
|
+
498,
|
|
500
|
+
500,
|
|
501
|
+
501,
|
|
502
|
+
502,
|
|
503
|
+
503,
|
|
504
|
+
504
|
|
425
505
|
];
|
|
426
506
|
//#endregion
|
|
427
|
-
//#region src/
|
|
507
|
+
//#region src/response/response.errors.ts
|
|
508
|
+
const responseErrors = {
|
|
509
|
+
invalidResponseErrorCode: () => /* @__PURE__ */ new TypeError("invalidResponseErrorCode"),
|
|
510
|
+
invalidSuccessResponseStatus: () => /* @__PURE__ */ new RangeError("invalidSuccessResponseStatus"),
|
|
511
|
+
invalidErrorResponseStatus: () => /* @__PURE__ */ new RangeError("invalidErrorResponseStatus")
|
|
512
|
+
};
|
|
428
513
|
/**
|
|
429
|
-
* Represents
|
|
430
|
-
* The original
|
|
514
|
+
* Represents a failed response rejected while unwrapping a remote operation.
|
|
515
|
+
* The original machine-readable code and response status remain available.
|
|
431
516
|
*/
|
|
432
|
-
var
|
|
517
|
+
var ResponseError = class extends Error {
|
|
433
518
|
code;
|
|
434
519
|
status;
|
|
435
520
|
constructor(status, code) {
|
|
436
521
|
super(code);
|
|
437
|
-
this.name = "
|
|
522
|
+
this.name = "ResponseError";
|
|
438
523
|
this.code = code;
|
|
439
524
|
this.status = status;
|
|
440
525
|
}
|
|
441
526
|
};
|
|
442
527
|
//#endregion
|
|
443
|
-
//#region src/
|
|
528
|
+
//#region src/response/response.validation.ts
|
|
444
529
|
/**
|
|
445
|
-
*
|
|
446
|
-
*
|
|
530
|
+
* Determines whether a value is a stable camelCase response error code.
|
|
531
|
+
* Human-readable text and punctuation remain excluded from public envelopes.
|
|
447
532
|
*/
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (response.kind === "error") return {
|
|
451
|
-
error: response.error,
|
|
452
|
-
data: null
|
|
453
|
-
};
|
|
454
|
-
return {
|
|
455
|
-
error: null,
|
|
456
|
-
data: response.data
|
|
457
|
-
};
|
|
533
|
+
function isResponseErrorCode(value) {
|
|
534
|
+
return typeof value === "string" && /^[a-z][A-Za-z0-9]*$/.test(value);
|
|
458
535
|
}
|
|
459
536
|
/**
|
|
460
|
-
*
|
|
461
|
-
*
|
|
537
|
+
* Determines whether a value belongs to the supported success-status catalog.
|
|
538
|
+
* Runtime membership mirrors the exact union exposed by `SuccessResponseStatus`.
|
|
462
539
|
*/
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
540
|
+
function isSuccessResponseStatus(value) {
|
|
541
|
+
return SUCCESS_RESPONSE_STATUSES.some((status) => status === value);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Determines whether a value belongs to the supported error-status catalog.
|
|
545
|
+
* Runtime membership mirrors the exact union exposed by `ErrorResponseStatus`.
|
|
546
|
+
*/
|
|
547
|
+
function isErrorResponseStatus(value) {
|
|
548
|
+
return ERROR_RESPONSE_STATUSES.some((status) => status === value);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Narrows a response result to its successful branch through the shared discriminator.
|
|
552
|
+
* Payload and error-code generics remain unchanged for downstream control-flow analysis.
|
|
553
|
+
*/
|
|
554
|
+
function isSuccessResponse(response) {
|
|
555
|
+
return response.kind === responseKind.SUCCESS;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Narrows a response result to its failed branch through the shared discriminator.
|
|
559
|
+
* Payload and error-code generics remain unchanged for downstream control-flow analysis.
|
|
560
|
+
*/
|
|
561
|
+
function isErrorResponse(response) {
|
|
562
|
+
return response.kind === responseKind.ERROR;
|
|
467
563
|
}
|
|
468
564
|
//#endregion
|
|
469
|
-
//#region src/
|
|
565
|
+
//#region src/response/response.factories.ts
|
|
470
566
|
/**
|
|
471
|
-
*
|
|
472
|
-
*
|
|
567
|
+
* Creates a successful response envelope without cloning its supplied data.
|
|
568
|
+
* Generic inference preserves the exact payload type for downstream contracts.
|
|
473
569
|
*/
|
|
474
|
-
function
|
|
475
|
-
|
|
476
|
-
return
|
|
570
|
+
function createSuccessResponse(status, data) {
|
|
571
|
+
if (isSuccessResponseStatus(status) === false) throw responseErrors.invalidSuccessResponseStatus();
|
|
572
|
+
return {
|
|
573
|
+
kind: responseKind.SUCCESS,
|
|
574
|
+
status,
|
|
575
|
+
data
|
|
576
|
+
};
|
|
477
577
|
}
|
|
478
578
|
/**
|
|
479
|
-
*
|
|
480
|
-
*
|
|
579
|
+
* Creates a failed response envelope from one stable machine-readable error code.
|
|
580
|
+
* Human-readable or malformed values reject before crossing the response boundary.
|
|
481
581
|
*/
|
|
482
|
-
function
|
|
483
|
-
if (
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
582
|
+
function createErrorResponse(status, error) {
|
|
583
|
+
if (isErrorResponseStatus(status) === false) throw responseErrors.invalidErrorResponseStatus();
|
|
584
|
+
if (isResponseErrorCode(error) === false) throw responseErrors.invalidResponseErrorCode();
|
|
585
|
+
return {
|
|
586
|
+
kind: responseKind.ERROR,
|
|
587
|
+
status,
|
|
588
|
+
error
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
//#endregion
|
|
592
|
+
//#region src/response/response.resolvers.ts
|
|
593
|
+
/**
|
|
594
|
+
* Resolves a response envelope into a status-preserving discriminated result.
|
|
595
|
+
* Transport and programming failures remain observable promise rejections.
|
|
596
|
+
*/
|
|
597
|
+
async function resolveResponse(operation) {
|
|
598
|
+
const response = await operation();
|
|
599
|
+
if (isErrorResponse(response)) return {
|
|
600
|
+
success: false,
|
|
601
|
+
status: response.status,
|
|
602
|
+
data: null,
|
|
603
|
+
error: response.error
|
|
604
|
+
};
|
|
605
|
+
return {
|
|
606
|
+
success: true,
|
|
607
|
+
status: response.status,
|
|
608
|
+
data: response.data,
|
|
609
|
+
error: null
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Unwraps successful response data and throws `ResponseError` for a failed envelope.
|
|
614
|
+
* Transport and programming failures propagate without replacement or normalization.
|
|
615
|
+
*/
|
|
616
|
+
async function unwrapResponse(operation) {
|
|
617
|
+
const response = await operation();
|
|
618
|
+
if (isErrorResponse(response)) throw new ResponseError(response.status, response.error);
|
|
619
|
+
return response.data;
|
|
620
|
+
}
|
|
621
|
+
//#endregion
|
|
622
|
+
//#region src/hono/hono.request-id.ts
|
|
623
|
+
/**
|
|
624
|
+
* Resolves a request identifier from response metadata before its incoming header.
|
|
625
|
+
* This ordering exposes identifiers generated by the request middleware downstream.
|
|
626
|
+
*/
|
|
627
|
+
function getHonoRequestId(context, header = HONO_REQUEST_ID_HEADER) {
|
|
628
|
+
return (context.res.headers.get(header) ?? context.req.header(header))?.trim() || void 0;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Creates middleware that preserves or generates one request correlation identifier.
|
|
632
|
+
* The resolved value is returned on the response and remains available to Hono adapters.
|
|
633
|
+
*/
|
|
634
|
+
function createRequestIdMiddleware(options = {}) {
|
|
635
|
+
const header = options.header ?? "X-Request-ID";
|
|
636
|
+
const createRequestId = options.createRequestId ?? (() => crypto.randomUUID());
|
|
637
|
+
return async (context, next) => {
|
|
638
|
+
const incomingRequestId = context.req.header(header)?.trim();
|
|
639
|
+
const generatedRequestId = incomingRequestId ? void 0 : createRequestId();
|
|
640
|
+
const requestId = typeof generatedRequestId === "string" ? generatedRequestId.trim() : incomingRequestId;
|
|
641
|
+
if (requestId === void 0 || requestId.length === 0) throw honoErrors.invalidRequestId();
|
|
642
|
+
context.header(header, requestId);
|
|
643
|
+
await next();
|
|
644
|
+
context.header(header, requestId);
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Ready-to-use request identifier middleware backed by the shared default policy.
|
|
649
|
+
* Applications may use the factory when a custom header or generator is required.
|
|
650
|
+
*/
|
|
651
|
+
const requestIdMiddleware = createRequestIdMiddleware();
|
|
652
|
+
//#endregion
|
|
653
|
+
//#region src/hono/hono.execution.ts
|
|
654
|
+
/**
|
|
655
|
+
* Identifies client HTTP exceptions safe to expose through the shared response envelope.
|
|
656
|
+
* Both status membership and camelCase error-code syntax must satisfy the contract.
|
|
657
|
+
*/
|
|
658
|
+
function isExpectedHTTPException(error) {
|
|
659
|
+
if (error instanceof HTTPException === false) return false;
|
|
660
|
+
if (error.status >= 500) return false;
|
|
661
|
+
const isSupportedStatus = isErrorResponseStatus(error.status);
|
|
662
|
+
const isMachineReadableCode = isResponseErrorCode(error.message);
|
|
663
|
+
return isSupportedStatus && isMachineReadableCode;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Creates a Hono error handler that preserves expected machine-readable exceptions.
|
|
667
|
+
* Unexpected failures invoke application reporting before returning a stable fallback.
|
|
668
|
+
*/
|
|
669
|
+
function createHonoErrorHandler(options) {
|
|
670
|
+
return (error, context) => {
|
|
671
|
+
if (isExpectedHTTPException(error)) {
|
|
672
|
+
const response = createErrorResponse(error.status, error.message);
|
|
673
|
+
return context.json(response, response.status);
|
|
674
|
+
}
|
|
675
|
+
options.onUnexpectedError(error, context, getHonoRequestId(context));
|
|
676
|
+
const fallback = honoErrors.internalServerError();
|
|
677
|
+
const status = fallback.status;
|
|
678
|
+
const response = createErrorResponse(status, fallback.message);
|
|
679
|
+
return context.json(response, status);
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
//#endregion
|
|
683
|
+
//#region src/logging/logging.enums.ts
|
|
684
|
+
const logLevelsArray = [
|
|
685
|
+
"info",
|
|
686
|
+
"warn",
|
|
687
|
+
"error"
|
|
688
|
+
];
|
|
689
|
+
const logLevelsRecord = createStringEnumRecord(logLevelsArray);
|
|
690
|
+
const logLevel = logLevelsRecord;
|
|
691
|
+
//#endregion
|
|
692
|
+
//#region src/logging/logging.constants.ts
|
|
693
|
+
/**
|
|
694
|
+
* Terminal color configuration for every supported logging level.
|
|
695
|
+
* The `LogLevel` contract prevents missing or unsupported level entries.
|
|
696
|
+
*/
|
|
697
|
+
const logLevelColors = {
|
|
698
|
+
[logLevel.INFO]: blue,
|
|
699
|
+
[logLevel.WARN]: yellow,
|
|
700
|
+
[logLevel.ERROR]: red
|
|
701
|
+
};
|
|
702
|
+
/**
|
|
703
|
+
* Public placeholder written instead of sensitive query and JSON values.
|
|
704
|
+
* One stable marker keeps redacted output recognizable across log consumers.
|
|
705
|
+
*/
|
|
706
|
+
const REDACTED_LOG_VALUE = "[redacted]";
|
|
707
|
+
/**
|
|
708
|
+
* Number of characters retained from each edge of an oversized request body.
|
|
709
|
+
* The complete preview includes both edges separated by one explicit ellipsis.
|
|
710
|
+
*/
|
|
711
|
+
const LOG_BODY_PREVIEW_EDGE_LENGTH = 30;
|
|
712
|
+
/**
|
|
713
|
+
* Safe request-body placeholder emitted instead of reading multipart content.
|
|
714
|
+
* Avoiding multipart reads prevents file payloads from entering logs or memory copies.
|
|
715
|
+
*/
|
|
716
|
+
const MULTIPART_LOG_BODY = "[multipart]";
|
|
717
|
+
/**
|
|
718
|
+
* Normalized key fragments treated as sensitive by logging redaction.
|
|
719
|
+
* Matching ignores case and separators so compound field names remain covered.
|
|
720
|
+
*/
|
|
721
|
+
const sensitiveLogKeyParts = [
|
|
722
|
+
"password",
|
|
723
|
+
"passwd",
|
|
724
|
+
"token",
|
|
725
|
+
"secret",
|
|
726
|
+
"authorization",
|
|
727
|
+
"apikey",
|
|
728
|
+
"credential"
|
|
729
|
+
];
|
|
730
|
+
/**
|
|
731
|
+
* Terminal colors assigned to the supported HTTP response status ranges.
|
|
732
|
+
* Unlisted ranges intentionally retain the terminal's default text appearance.
|
|
733
|
+
*/
|
|
734
|
+
const httpStatusColors = [
|
|
735
|
+
{
|
|
736
|
+
range: [200, 299],
|
|
737
|
+
color: green
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
range: [400, 499],
|
|
741
|
+
color: yellow
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
range: [500, 599],
|
|
745
|
+
color: red
|
|
746
|
+
}
|
|
747
|
+
];
|
|
748
|
+
//#endregion
|
|
749
|
+
//#region src/logging/logging.errors.ts
|
|
750
|
+
const loggingErrors = { invalidBodyPreviewEdgeLength: () => /* @__PURE__ */ new RangeError("invalidBodyPreviewEdgeLength") };
|
|
751
|
+
//#endregion
|
|
752
|
+
//#region src/logging/logging.security.ts
|
|
753
|
+
/**
|
|
754
|
+
* Checks whether a logging field name contains a configured sensitive fragment.
|
|
755
|
+
* Matching ignores casing and separators to cover compound naming conventions.
|
|
756
|
+
*/
|
|
757
|
+
function isSensitiveLogKey(key) {
|
|
758
|
+
const normalizedKey = key.toLowerCase().replaceAll(/[^a-z]/g, "");
|
|
759
|
+
return sensitiveLogKeyParts.some((sensitivePart) => normalizedKey.includes(sensitivePart));
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Recursively replaces values owned by sensitive object keys with the public marker.
|
|
763
|
+
* The result reports whether any replacement occurred to avoid needless serialization.
|
|
764
|
+
*/
|
|
765
|
+
function redactSensitiveValueResult(value, seenValues = /* @__PURE__ */ new WeakMap()) {
|
|
766
|
+
if (Array.isArray(value)) {
|
|
767
|
+
const existingValue = seenValues.get(value);
|
|
768
|
+
if (existingValue) return {
|
|
769
|
+
value: existingValue,
|
|
770
|
+
redacted: false
|
|
771
|
+
};
|
|
772
|
+
const redactedValue = [];
|
|
773
|
+
seenValues.set(value, redactedValue);
|
|
774
|
+
const entries = value.map((entry) => redactSensitiveValueResult(entry, seenValues));
|
|
775
|
+
redactedValue.push(...entries.map((entry) => entry.value));
|
|
776
|
+
return {
|
|
777
|
+
value: redactedValue,
|
|
487
778
|
redacted: entries.some((entry) => entry.redacted)
|
|
488
779
|
};
|
|
489
780
|
}
|
|
@@ -491,28 +782,48 @@ function redactSensitiveValue(value) {
|
|
|
491
782
|
value,
|
|
492
783
|
redacted: false
|
|
493
784
|
};
|
|
785
|
+
const prototype = Object.getPrototypeOf(value);
|
|
786
|
+
if (prototype !== Object.prototype && prototype !== null) return {
|
|
787
|
+
value,
|
|
788
|
+
redacted: false
|
|
789
|
+
};
|
|
790
|
+
const existingValue = seenValues.get(value);
|
|
791
|
+
if (existingValue) return {
|
|
792
|
+
value: existingValue,
|
|
793
|
+
redacted: false
|
|
794
|
+
};
|
|
494
795
|
let redacted = false;
|
|
495
|
-
const
|
|
796
|
+
const redactedValue = Object.create(prototype);
|
|
797
|
+
seenValues.set(value, redactedValue);
|
|
798
|
+
for (const [key, entryValue] of Object.entries(value)) {
|
|
496
799
|
if (isSensitiveLogKey(key)) {
|
|
497
800
|
redacted = true;
|
|
498
|
-
|
|
801
|
+
redactedValue[key] = REDACTED_LOG_VALUE;
|
|
802
|
+
continue;
|
|
499
803
|
}
|
|
500
|
-
const nestedEntry =
|
|
804
|
+
const nestedEntry = redactSensitiveValueResult(entryValue, seenValues);
|
|
501
805
|
redacted ||= nestedEntry.redacted;
|
|
502
|
-
|
|
503
|
-
}
|
|
806
|
+
redactedValue[key] = nestedEntry.value;
|
|
807
|
+
}
|
|
504
808
|
return {
|
|
505
|
-
value:
|
|
809
|
+
value: redactedValue,
|
|
506
810
|
redacted
|
|
507
811
|
};
|
|
508
812
|
}
|
|
509
813
|
/**
|
|
814
|
+
* Recursively replaces values owned by sensitive keys in an arbitrary structure.
|
|
815
|
+
* Arrays and safe values retain their original ordering and primitive identity.
|
|
816
|
+
*/
|
|
817
|
+
function redactSensitiveValue(value) {
|
|
818
|
+
return redactSensitiveValueResult(value).value;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
510
821
|
* Replaces sensitive query values using partial, case-insensitive key matching.
|
|
511
822
|
* A new collection is returned so the caller's search parameters remain unchanged.
|
|
512
823
|
*/
|
|
513
824
|
function redactSensitiveSearchParams(searchParams) {
|
|
514
|
-
const redactedSearchParams = new URLSearchParams(
|
|
515
|
-
for (const key of
|
|
825
|
+
const redactedSearchParams = new URLSearchParams();
|
|
826
|
+
for (const [key, value] of searchParams) redactedSearchParams.append(key, isSensitiveLogKey(key) ? REDACTED_LOG_VALUE : value);
|
|
516
827
|
return redactedSearchParams;
|
|
517
828
|
}
|
|
518
829
|
/**
|
|
@@ -520,9 +831,8 @@ function redactSensitiveSearchParams(searchParams) {
|
|
|
520
831
|
* Invalid JSON and payloads without matching keys are returned byte-for-byte unchanged.
|
|
521
832
|
*/
|
|
522
833
|
function redactSensitiveJSON(json) {
|
|
523
|
-
if (json === void 0 || null) return json;
|
|
524
834
|
try {
|
|
525
|
-
const result =
|
|
835
|
+
const result = redactSensitiveValueResult(JSON.parse(json));
|
|
526
836
|
return result.redacted ? JSON.stringify(result.value) : json;
|
|
527
837
|
} catch {
|
|
528
838
|
return json;
|
|
@@ -531,6 +841,15 @@ function redactSensitiveJSON(json) {
|
|
|
531
841
|
//#endregion
|
|
532
842
|
//#region src/logging/logging.utilities.ts
|
|
533
843
|
/**
|
|
844
|
+
* Extracts useful trace context from an unknown failure without inventing a message.
|
|
845
|
+
* Strings pass through directly, while native errors prefer their complete stack.
|
|
846
|
+
*/
|
|
847
|
+
function normalizeLogError(error) {
|
|
848
|
+
if (typeof error === "string") return error;
|
|
849
|
+
if (error instanceof Error === false) return void 0;
|
|
850
|
+
return error.stack ?? error.message;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
534
853
|
* Selects a terminal color function for one HTTP response status.
|
|
535
854
|
* Successes are green, client errors yellow, and server errors red.
|
|
536
855
|
*/
|
|
@@ -540,358 +859,205 @@ function getColoredHTTPStatus(status) {
|
|
|
540
859
|
});
|
|
541
860
|
return statusColor ? statusColor.color : (text) => text;
|
|
542
861
|
}
|
|
543
|
-
//#endregion
|
|
544
|
-
//#region src/logging/logging.middleware.ts
|
|
545
|
-
/**
|
|
546
|
-
* Logs Hono request metadata with query parameters and a normalized body preview.
|
|
547
|
-
* Sensitive values are redacted by partial key match before output is written.
|
|
548
|
-
*/
|
|
549
|
-
const loggingMiddleware = async (c, next) => {
|
|
550
|
-
const contentType = c.req.header("content-type");
|
|
551
|
-
const searchParams = redactSensitiveSearchParams(new URL(c.req.url).searchParams).toString();
|
|
552
|
-
const body = contentType?.includes("multipart/form-data") ? "[multipart]" : redactSensitiveJSON((await c.req.raw.clone().text()).replaceAll(/\s+/g, " ").trim());
|
|
553
|
-
const bodyPreview = body.length > 60 ? `${body.slice(0, 30)}…${body.slice(-30)}` : body;
|
|
554
|
-
const startTime = performance.now();
|
|
555
|
-
await next();
|
|
556
|
-
const duration = Math.round(performance.now() - startTime);
|
|
557
|
-
const coloredMethod = bold(blue(c.req.method.padEnd(4)));
|
|
558
|
-
const coloredStatus = getColoredHTTPStatus(c.res.status)(String(c.res.status).padEnd(4));
|
|
559
|
-
const coloredTime = dim(`${duration}ms`.padStart(6));
|
|
560
|
-
const coloredPath = white(c.req.path.padEnd(32));
|
|
561
|
-
const coloredSearchParams = searchParams ? dim(` (${searchParams})`) : "";
|
|
562
|
-
const coloredBody = bodyPreview ? dim(` ${bodyPreview}`) : "";
|
|
563
|
-
log.info(`${coloredMethod} ${coloredStatus} ${coloredTime} ${coloredPath}${coloredSearchParams}${coloredBody}`, "hono");
|
|
564
|
-
};
|
|
565
|
-
//#endregion
|
|
566
|
-
//#region src/upload/upload.enums.ts
|
|
567
|
-
const fileFormatsArray = [...[
|
|
568
|
-
"png",
|
|
569
|
-
"jpg",
|
|
570
|
-
"webp",
|
|
571
|
-
"avif",
|
|
572
|
-
"heic",
|
|
573
|
-
"svg"
|
|
574
|
-
], ...[
|
|
575
|
-
"pdf",
|
|
576
|
-
"rtf",
|
|
577
|
-
"txt"
|
|
578
|
-
]];
|
|
579
|
-
const fileFormatsRecord = createStringEnumRecord(fileFormatsArray);
|
|
580
|
-
const fileFormat = fileFormatsRecord;
|
|
581
|
-
const uploadValidationErrorsArray = [
|
|
582
|
-
"empty_file",
|
|
583
|
-
"unsupported_file_format",
|
|
584
|
-
"file_size_exceeded",
|
|
585
|
-
"files_count_exceeded"
|
|
586
|
-
];
|
|
587
|
-
const uploadValidationErrorsRecord = createStringEnumRecord(uploadValidationErrorsArray);
|
|
588
|
-
const uploadValidationError = uploadValidationErrorsRecord;
|
|
589
|
-
//#endregion
|
|
590
|
-
//#region src/upload/upload.constants.ts
|
|
591
862
|
/**
|
|
592
|
-
*
|
|
593
|
-
*
|
|
863
|
+
* Formats one completed HTTP request as a compact colorized terminal line.
|
|
864
|
+
* Empty query and body values are omitted without disturbing column alignment.
|
|
594
865
|
*/
|
|
595
|
-
|
|
596
|
-
[
|
|
597
|
-
name: "PNG",
|
|
598
|
-
mimeTypes: ["image/png"],
|
|
599
|
-
extensions: [".png"]
|
|
600
|
-
},
|
|
601
|
-
[fileFormatsRecord.JPG]: {
|
|
602
|
-
name: "JPG",
|
|
603
|
-
mimeTypes: ["image/jpeg"],
|
|
604
|
-
extensions: [".jpg", ".jpeg"]
|
|
605
|
-
},
|
|
606
|
-
[fileFormatsRecord.SVG]: {
|
|
607
|
-
name: "SVG",
|
|
608
|
-
mimeTypes: ["image/svg+xml"],
|
|
609
|
-
extensions: [".svg"]
|
|
610
|
-
},
|
|
611
|
-
[fileFormatsRecord.WEBP]: {
|
|
612
|
-
name: "WEBP",
|
|
613
|
-
mimeTypes: ["image/webp"],
|
|
614
|
-
extensions: [".webp"]
|
|
615
|
-
},
|
|
616
|
-
[fileFormatsRecord.AVIF]: {
|
|
617
|
-
name: "AVIF",
|
|
618
|
-
mimeTypes: ["image/avif"],
|
|
619
|
-
extensions: [".avif"]
|
|
620
|
-
},
|
|
621
|
-
[fileFormatsRecord.HEIC]: {
|
|
622
|
-
name: "HEIC",
|
|
623
|
-
mimeTypes: ["image/heic", "image/heif"],
|
|
624
|
-
extensions: [".heic", ".heif"]
|
|
625
|
-
},
|
|
626
|
-
[fileFormatsRecord.PDF]: {
|
|
627
|
-
name: "PDF",
|
|
628
|
-
mimeTypes: ["application/pdf"],
|
|
629
|
-
extensions: [".pdf"]
|
|
630
|
-
},
|
|
631
|
-
[fileFormatsRecord.RTF]: {
|
|
632
|
-
name: "RTF",
|
|
633
|
-
mimeTypes: ["application/rtf"],
|
|
634
|
-
extensions: [".rtf"]
|
|
635
|
-
},
|
|
636
|
-
[fileFormatsRecord.TXT]: {
|
|
637
|
-
name: "TXT",
|
|
638
|
-
mimeTypes: ["text/plain"],
|
|
639
|
-
extensions: [".txt"]
|
|
640
|
-
}
|
|
641
|
-
};
|
|
642
|
-
//#endregion
|
|
643
|
-
//#region src/upload/upload.factory.ts
|
|
644
|
-
/**
|
|
645
|
-
* Defines one shared upload policy while preserving its literal format tuple.
|
|
646
|
-
* The resulting preset can drive schemas, picker hints, and batch validation.
|
|
647
|
-
*
|
|
648
|
-
* @example
|
|
649
|
-
* const imageUploadPreset = defineUploadPreset({
|
|
650
|
-
* formats: [fileFormat.JPG, fileFormat.PNG, fileFormat.WEBP],
|
|
651
|
-
* maxFileSize: 8 * 1024 * 1024,
|
|
652
|
-
* maxFilesCount: 1,
|
|
653
|
-
* });
|
|
654
|
-
*/
|
|
655
|
-
function defineUploadPreset(preset) {
|
|
656
|
-
return preset;
|
|
866
|
+
function formatHTTPRequestLog(options) {
|
|
867
|
+
return `${bold(blue(options.method.padEnd(4)))} ${getColoredHTTPStatus(options.status)(String(options.status).padEnd(4))} ${dim(`${options.duration}ms`.padStart(6))} ${white(options.path.padEnd(32))}${options.searchParams ? dim(` (${options.searchParams})`) : ""}${options.bodyPreview ? dim(` ${options.bodyPreview}`) : ""}${options.requestId ? dim(` [${options.requestId}]`) : ""}`;
|
|
657
868
|
}
|
|
658
|
-
//#endregion
|
|
659
|
-
//#region src/upload/upload.presets.ts
|
|
660
|
-
/**
|
|
661
|
-
* Default maximum size of one file accepted by the built-in upload presets.
|
|
662
|
-
* The 20 MiB boundary matches the existing upload component policy.
|
|
663
|
-
*/
|
|
664
|
-
const DEFAULT_UPLOAD_MAX_FILE_SIZE = 20971520;
|
|
665
|
-
/**
|
|
666
|
-
* Ready-to-use policy for every image format supported by the upload catalog.
|
|
667
|
-
* Each image may occupy up to 20 MiB while collection capacity remains unrestricted.
|
|
668
|
-
*/
|
|
669
|
-
const imageUploadPreset = defineUploadPreset({
|
|
670
|
-
formats: [
|
|
671
|
-
fileFormat.PNG,
|
|
672
|
-
fileFormat.JPG,
|
|
673
|
-
fileFormat.WEBP,
|
|
674
|
-
fileFormat.AVIF,
|
|
675
|
-
fileFormat.HEIC
|
|
676
|
-
],
|
|
677
|
-
maxFileSize: DEFAULT_UPLOAD_MAX_FILE_SIZE
|
|
678
|
-
});
|
|
679
|
-
/**
|
|
680
|
-
* Ready-to-use policy for every image format supporting transparency (alpha channel).
|
|
681
|
-
* Each image may occupy up to 20 MiB while collection capacity remains unrestricted.
|
|
682
|
-
*/
|
|
683
|
-
const imageTransparentUploadPreset = defineUploadPreset({
|
|
684
|
-
formats: [
|
|
685
|
-
fileFormat.PNG,
|
|
686
|
-
fileFormat.WEBP,
|
|
687
|
-
fileFormat.SVG
|
|
688
|
-
],
|
|
689
|
-
maxFileSize: DEFAULT_UPLOAD_MAX_FILE_SIZE
|
|
690
|
-
});
|
|
691
|
-
/**
|
|
692
|
-
* Ready-to-use policy for every document format supported by the upload catalog.
|
|
693
|
-
* Each document may occupy up to 20 MiB while collection capacity remains unrestricted.
|
|
694
|
-
*/
|
|
695
|
-
const documentUploadPreset = defineUploadPreset({
|
|
696
|
-
formats: [
|
|
697
|
-
fileFormat.PDF,
|
|
698
|
-
fileFormat.RTF,
|
|
699
|
-
fileFormat.TXT
|
|
700
|
-
],
|
|
701
|
-
maxFileSize: DEFAULT_UPLOAD_MAX_FILE_SIZE
|
|
702
|
-
});
|
|
703
|
-
//#endregion
|
|
704
|
-
//#region src/upload/upload.utilities.ts
|
|
705
869
|
/**
|
|
706
|
-
*
|
|
707
|
-
*
|
|
870
|
+
* Reads one request clone and returns a normalized, redacted, and bounded body preview.
|
|
871
|
+
* Multipart requests return a safe marker without reading or retaining uploaded content.
|
|
708
872
|
*/
|
|
709
|
-
function
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
* Existing prefixes remain intact so repeated normalization is stable.
|
|
716
|
-
*/
|
|
717
|
-
function normalizeFileExtension(extension) {
|
|
718
|
-
const normalizedExtension = extension.toLowerCase();
|
|
719
|
-
return normalizedExtension.startsWith(".") ? normalizedExtension : `.${normalizedExtension}`;
|
|
720
|
-
}
|
|
721
|
-
/**
|
|
722
|
-
* Compares a concrete MIME type with an exact or wildcard configuration.
|
|
723
|
-
* Wildcards match every subtype belonging to the configured media group.
|
|
724
|
-
*/
|
|
725
|
-
function matchesMimeType(fileMimeType, configuredMimeType) {
|
|
726
|
-
if (configuredMimeType.endsWith("/*")) return fileMimeType.startsWith(configuredMimeType.slice(0, -1));
|
|
727
|
-
return fileMimeType === configuredMimeType;
|
|
873
|
+
async function createHTTPRequestBodyPreview(request, contentType, edgeLength = 30) {
|
|
874
|
+
if (Number.isInteger(edgeLength) === false || edgeLength < 1) throw loggingErrors.invalidBodyPreviewEdgeLength();
|
|
875
|
+
if (contentType?.includes("multipart/form-data")) return MULTIPART_LOG_BODY;
|
|
876
|
+
const redactedBody = redactSensitiveJSON((await request.clone().text()).replaceAll(/\s+/g, " ").trim());
|
|
877
|
+
if (redactedBody.length <= edgeLength * 2) return redactedBody;
|
|
878
|
+
return `${redactedBody.slice(0, edgeLength)}…${redactedBody.slice(-edgeLength)}`;
|
|
728
879
|
}
|
|
880
|
+
//#endregion
|
|
881
|
+
//#region src/logging/logging.services.ts
|
|
729
882
|
/**
|
|
730
|
-
*
|
|
731
|
-
*
|
|
883
|
+
* Writes one fully formatted logging line through the standard console destination.
|
|
884
|
+
* Keeping the sink explicit allows configured loggers to replace output independently.
|
|
732
885
|
*/
|
|
733
|
-
function
|
|
734
|
-
|
|
886
|
+
function writeConsoleLog(line) {
|
|
887
|
+
console.log(line);
|
|
735
888
|
}
|
|
736
889
|
/**
|
|
737
|
-
*
|
|
738
|
-
*
|
|
890
|
+
* Writes one normalized terminal message for the requested logging level and service.
|
|
891
|
+
* Recognized error context retains the primary timestamp on a subordinate aligned line.
|
|
739
892
|
*/
|
|
740
|
-
function
|
|
741
|
-
const
|
|
742
|
-
|
|
893
|
+
function writeLog(message, level, service = "log", error, sink = writeConsoleLog) {
|
|
894
|
+
const timestamp = dim(getFormattedTime());
|
|
895
|
+
const serviceName = logLevelColors[level](service.padEnd(12));
|
|
896
|
+
const formattedMessage = white(message);
|
|
897
|
+
const trace = normalizeLogError(error);
|
|
898
|
+
sink(`[${timestamp}] ${serviceName} | ${formattedMessage}`);
|
|
899
|
+
if (trace) sink(`[${timestamp}] ${red("↳ trace").padEnd(18)} | ${dim(trace)}`);
|
|
743
900
|
}
|
|
744
901
|
/**
|
|
745
|
-
*
|
|
746
|
-
*
|
|
902
|
+
* Creates a logger permanently bound to one service label and output destination.
|
|
903
|
+
* Calls retain the shared formatting while avoiding repeated service arguments.
|
|
747
904
|
*/
|
|
748
|
-
function
|
|
749
|
-
return
|
|
905
|
+
function createLogger(options) {
|
|
906
|
+
return {
|
|
907
|
+
info(message) {
|
|
908
|
+
writeLog(message, logLevel.INFO, options.service, void 0, options.sink);
|
|
909
|
+
},
|
|
910
|
+
warn(message) {
|
|
911
|
+
writeLog(message, logLevel.WARN, options.service, void 0, options.sink);
|
|
912
|
+
},
|
|
913
|
+
error(message, error) {
|
|
914
|
+
writeLog(message, logLevel.ERROR, options.service, error, options.sink);
|
|
915
|
+
}
|
|
916
|
+
};
|
|
750
917
|
}
|
|
751
918
|
/**
|
|
752
|
-
*
|
|
753
|
-
*
|
|
919
|
+
* Writes timestamped and colorized messages using a stable service column.
|
|
920
|
+
* Error messages may include a stack trace on a subordinate second line.
|
|
754
921
|
*/
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
922
|
+
const log = {
|
|
923
|
+
/**
|
|
924
|
+
* Writes an informational message with an optional service label.
|
|
925
|
+
* Missing service names use the shared `log` fallback label.
|
|
926
|
+
*/
|
|
927
|
+
info(message, service) {
|
|
928
|
+
writeLog(message, logLevel.INFO, service);
|
|
929
|
+
},
|
|
930
|
+
/**
|
|
931
|
+
* Writes a warning message with an optional service label.
|
|
932
|
+
* Missing service names use the shared `log` fallback label.
|
|
933
|
+
*/
|
|
934
|
+
warn(message, service) {
|
|
935
|
+
writeLog(message, logLevel.WARN, service);
|
|
936
|
+
},
|
|
937
|
+
/**
|
|
938
|
+
* Writes an error message with optional service and unknown failure context.
|
|
939
|
+
* Native errors and strings render a normalized subordinate trace line.
|
|
940
|
+
*/
|
|
941
|
+
error(message, service, error) {
|
|
942
|
+
writeLog(message, logLevel.ERROR, service, error);
|
|
943
|
+
}
|
|
944
|
+
};
|
|
759
945
|
//#endregion
|
|
760
|
-
//#region src/
|
|
946
|
+
//#region src/hono/hono.logging.ts
|
|
947
|
+
/**
|
|
948
|
+
* Creates Hono middleware with configurable body capture, correlation, and output.
|
|
949
|
+
* Sensitive values are redacted before the completed request line reaches its writer.
|
|
950
|
+
*/
|
|
951
|
+
function createLoggingMiddleware(options = {}) {
|
|
952
|
+
const { bodyPreviewEdgeLength } = options;
|
|
953
|
+
if (bodyPreviewEdgeLength !== void 0 && (Number.isInteger(bodyPreviewEdgeLength) === false || bodyPreviewEdgeLength < 1)) throw loggingErrors.invalidBodyPreviewEdgeLength();
|
|
954
|
+
const service = options.service ?? "hono";
|
|
955
|
+
const includeBody = options.includeBody ?? true;
|
|
956
|
+
const requestIdHeader = options.requestIdHeader ?? "X-Request-ID";
|
|
957
|
+
const write = options.write ?? ((message, serviceName) => log.info(message, serviceName));
|
|
958
|
+
return async (context, next) => {
|
|
959
|
+
const contentType = context.req.header("content-type");
|
|
960
|
+
const searchParams = redactSensitiveSearchParams(new URL(context.req.url).searchParams).toString();
|
|
961
|
+
const bodyPreview = includeBody ? await createHTTPRequestBodyPreview(context.req.raw, contentType, bodyPreviewEdgeLength) : void 0;
|
|
962
|
+
const startTime = performance.now();
|
|
963
|
+
await next();
|
|
964
|
+
const duration = Math.round(performance.now() - startTime);
|
|
965
|
+
const message = formatHTTPRequestLog({
|
|
966
|
+
method: context.req.method,
|
|
967
|
+
status: context.res.status,
|
|
968
|
+
duration,
|
|
969
|
+
path: context.req.path,
|
|
970
|
+
searchParams,
|
|
971
|
+
bodyPreview,
|
|
972
|
+
requestId: getHonoRequestId(context, requestIdHeader)
|
|
973
|
+
});
|
|
974
|
+
write(message, service);
|
|
975
|
+
};
|
|
976
|
+
}
|
|
761
977
|
/**
|
|
762
|
-
*
|
|
763
|
-
*
|
|
978
|
+
* Ready-to-use Hono request logger backed by the shared default logging policy.
|
|
979
|
+
* Applications may use the factory when request capture or output needs configuration.
|
|
764
980
|
*/
|
|
765
|
-
|
|
766
|
-
const { formats, maxFileSize, extensionFallback = false } = options;
|
|
767
|
-
return z$1.file().min(1, uploadValidationError.EMPTY_FILE).max(maxFileSize, uploadValidationError.FILE_SIZE_EXCEEDED).refine((file) => extensionFallback ? isFileFormatSupported(file, formats) : isFileMimeTypeSupported(file, formats), uploadValidationError.UNSUPPORTED_FILE_FORMAT);
|
|
768
|
-
}
|
|
981
|
+
const loggingMiddleware = createLoggingMiddleware();
|
|
769
982
|
//#endregion
|
|
770
|
-
//#region src/
|
|
983
|
+
//#region src/hono/hono.respond.ts
|
|
771
984
|
/**
|
|
772
|
-
*
|
|
773
|
-
*
|
|
985
|
+
* Wraps `c.json` in the shared success envelope while preserving its literal status.
|
|
986
|
+
* Missing response data is represented by an empty object for contract consistency.
|
|
774
987
|
*/
|
|
775
|
-
function
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
if (file.size > maxFileSize) return uploadValidationError.FILE_SIZE_EXCEEDED;
|
|
988
|
+
function respond(c, options) {
|
|
989
|
+
const response = createSuccessResponse(options.status, options.data ?? {});
|
|
990
|
+
return c.json(response, options.status);
|
|
779
991
|
}
|
|
780
992
|
/**
|
|
781
|
-
*
|
|
782
|
-
*
|
|
993
|
+
* Responds with downloadable binary content and its attachment headers.
|
|
994
|
+
* Unknown/undefined content types default to `application/octet-stream`.
|
|
783
995
|
*/
|
|
784
|
-
function
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
let validationError;
|
|
789
|
-
for (const file of incomingFiles) {
|
|
790
|
-
const fileValidationError = validateUploadFile(file, formats, maxFileSize) ?? (acceptedFiles.length >= availableFilesCount ? uploadValidationError.FILES_COUNT_EXCEEDED : void 0);
|
|
791
|
-
if (fileValidationError) {
|
|
792
|
-
validationError = fileValidationError;
|
|
793
|
-
continue;
|
|
794
|
-
}
|
|
795
|
-
acceptedFiles.push(file);
|
|
796
|
-
}
|
|
797
|
-
return {
|
|
798
|
-
acceptedFiles,
|
|
799
|
-
validationError
|
|
800
|
-
};
|
|
996
|
+
function fileRespond(c, options) {
|
|
997
|
+
c.header("Content-Disposition", `attachment; filename="${options.filename}"`);
|
|
998
|
+
c.header("Content-Type", options.contentType ?? "application/octet-stream");
|
|
999
|
+
return c.body(options.content, options.status);
|
|
801
1000
|
}
|
|
802
1001
|
//#endregion
|
|
803
|
-
//#region src/
|
|
1002
|
+
//#region src/jwt/jwt.constants.ts
|
|
804
1003
|
/**
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
* @example
|
|
809
|
-
* await safeExecute(() => fetchData(), (error) => console.error(error));
|
|
1004
|
+
* Default symmetric algorithm applied to JWT signing and verification operations.
|
|
1005
|
+
* The policy uses `HS256` unless a service instance explicitly selects another value.
|
|
810
1006
|
*/
|
|
811
|
-
|
|
812
|
-
try {
|
|
813
|
-
return await fn();
|
|
814
|
-
} catch (error) {
|
|
815
|
-
if (onError) return await onError(error);
|
|
816
|
-
throw error;
|
|
817
|
-
}
|
|
818
|
-
}
|
|
1007
|
+
const DEFAULT_JWT_ALGORITHM = "HS256";
|
|
819
1008
|
/**
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
* @example
|
|
824
|
-
* const { result, executionTime } = await measureExecutionTime(async () => {
|
|
825
|
-
* return await fetchData();
|
|
826
|
-
* });
|
|
827
|
-
* console.log(`Execution time: ${executionTime}ms`);
|
|
1009
|
+
* Default token lifetime applied when a signing operation supplies no override.
|
|
1010
|
+
* The duration is expressed in seconds and represents exactly fifteen minutes.
|
|
828
1011
|
*/
|
|
829
|
-
|
|
830
|
-
const startedAt = performance.now();
|
|
831
|
-
const result = await execution();
|
|
832
|
-
const executionTime = performance.now() - startedAt;
|
|
833
|
-
return {
|
|
834
|
-
result,
|
|
835
|
-
executionTime: Math.round(executionTime)
|
|
836
|
-
};
|
|
837
|
-
}
|
|
1012
|
+
const DEFAULT_JWT_EXPIRATION_SECONDS = 900;
|
|
838
1013
|
//#endregion
|
|
839
|
-
//#region src/
|
|
840
|
-
const
|
|
1014
|
+
//#region src/jwt/jwt.errors.ts
|
|
1015
|
+
const jwtErrors = { invalidExpirationSeconds: () => /* @__PURE__ */ new RangeError("invalidExpirationSeconds") };
|
|
1016
|
+
//#endregion
|
|
1017
|
+
//#region src/jwt/jwt.validation.ts
|
|
841
1018
|
/**
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
* `include` targets only identifiers listed by the client. `exclude` targets every
|
|
846
|
-
* item matching the accompanying search snapshot except excluded identifiers.
|
|
847
|
-
*
|
|
848
|
-
* @example
|
|
849
|
-
* const assetBulkSelectionSchema = zodBulkSelectionSchema({
|
|
850
|
-
* identifierSchema: z.string().min(1),
|
|
851
|
-
* });
|
|
1019
|
+
* Validates a JWT lifetime before it participates in expiration calculation.
|
|
1020
|
+
* Whole positive, zero, and negative seconds are accepted within the safe range.
|
|
852
1021
|
*/
|
|
853
|
-
function
|
|
854
|
-
|
|
855
|
-
return z$1.discriminatedUnion("mode", [z$1.object({
|
|
856
|
-
mode: z$1.literal("include"),
|
|
857
|
-
identifiers: identifiersSchema
|
|
858
|
-
}).strict(), z$1.object({
|
|
859
|
-
mode: z$1.literal("exclude"),
|
|
860
|
-
excludedIdentifiers: identifiersSchema
|
|
861
|
-
}).strict()]);
|
|
1022
|
+
function validateJWTExpirationSeconds(expirationSeconds) {
|
|
1023
|
+
if (Number.isSafeInteger(expirationSeconds) === false) throw jwtErrors.invalidExpirationSeconds();
|
|
862
1024
|
}
|
|
863
1025
|
//#endregion
|
|
864
|
-
//#region src/
|
|
1026
|
+
//#region src/jwt/jwt.services.ts
|
|
865
1027
|
/**
|
|
866
|
-
* Signs, decodes, and verifies JWTs with optional
|
|
1028
|
+
* Signs, decodes, and verifies JWTs with optional payload schema validation.
|
|
867
1029
|
* A supplied schema parses payloads returned by decoding and verification.
|
|
868
1030
|
*/
|
|
869
|
-
var
|
|
1031
|
+
var JWTService = class {
|
|
870
1032
|
payloadSchema;
|
|
871
1033
|
algorithm;
|
|
872
1034
|
defaultExpirationSeconds;
|
|
873
|
-
constructor(payloadSchema, options) {
|
|
1035
|
+
constructor(payloadSchema, options = {}) {
|
|
1036
|
+
const { algorithm = DEFAULT_JWT_ALGORITHM, defaultExpirationSeconds = 900 } = options;
|
|
1037
|
+
validateJWTExpirationSeconds(defaultExpirationSeconds);
|
|
874
1038
|
this.payloadSchema = payloadSchema;
|
|
875
|
-
this.algorithm =
|
|
876
|
-
this.defaultExpirationSeconds =
|
|
1039
|
+
this.algorithm = algorithm;
|
|
1040
|
+
this.defaultExpirationSeconds = defaultExpirationSeconds;
|
|
877
1041
|
}
|
|
878
1042
|
/**
|
|
879
1043
|
* Signs a payload using the configured algorithm and expiration settings.
|
|
880
|
-
* Per-
|
|
1044
|
+
* Per-operation expiration overrides the default configured by the service.
|
|
881
1045
|
*
|
|
882
1046
|
* @example
|
|
883
1047
|
* const token = await jwtService.sign({ userId: '123' }, secret, { expiresInSeconds: 300 });
|
|
884
1048
|
*/
|
|
885
|
-
async sign(payload, secret, options) {
|
|
886
|
-
const
|
|
1049
|
+
async sign(payload, secret, options = {}) {
|
|
1050
|
+
const { expiresInSeconds = this.defaultExpirationSeconds } = options;
|
|
1051
|
+
validateJWTExpirationSeconds(expiresInSeconds);
|
|
1052
|
+
const expirationTimestamp = Math.floor(Date.now() / 1e3) + expiresInSeconds;
|
|
887
1053
|
return sign({
|
|
888
1054
|
...payload,
|
|
889
|
-
exp
|
|
1055
|
+
exp: expirationTimestamp
|
|
890
1056
|
}, secret, this.algorithm);
|
|
891
1057
|
}
|
|
892
1058
|
/**
|
|
893
|
-
* Decodes without
|
|
894
|
-
* Schema
|
|
1059
|
+
* Decodes a token without authentication and parses its payload when configured.
|
|
1060
|
+
* Schema failures return `null`, while malformed token failures remain unchanged.
|
|
895
1061
|
*
|
|
896
1062
|
* @example
|
|
897
1063
|
* const payload = await jwtService.decode(token);
|
|
@@ -899,12 +1065,12 @@ var ZodJWTService = class {
|
|
|
899
1065
|
async decode(token) {
|
|
900
1066
|
const { payload } = decode(token);
|
|
901
1067
|
if (this.payloadSchema === void 0) return payload;
|
|
902
|
-
const
|
|
903
|
-
return success ? data : null;
|
|
1068
|
+
const result = await this.payloadSchema.safeParseAsync(payload);
|
|
1069
|
+
return result.success ? result.data : null;
|
|
904
1070
|
}
|
|
905
1071
|
/**
|
|
906
1072
|
* Verifies a token using the configured algorithm and parses its payload.
|
|
907
|
-
*
|
|
1073
|
+
* Authentication, expiration, and schema failures reject the operation.
|
|
908
1074
|
*
|
|
909
1075
|
* @example
|
|
910
1076
|
* const payload = await jwtService.verifyOrThrow(token, secret);
|
|
@@ -916,142 +1082,306 @@ var ZodJWTService = class {
|
|
|
916
1082
|
}
|
|
917
1083
|
};
|
|
918
1084
|
//#endregion
|
|
919
|
-
//#region src/
|
|
1085
|
+
//#region src/object/object.utilities.ts
|
|
920
1086
|
/**
|
|
921
|
-
*
|
|
922
|
-
*
|
|
1087
|
+
* Recognizes ordinary records whose direct prototype is `Object.prototype`.
|
|
1088
|
+
* Arrays, null, native objects, and custom class instances remain excluded.
|
|
923
1089
|
*/
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1090
|
+
function isPlainObject(value) {
|
|
1091
|
+
if ((typeof value === "object" && value !== null) === false || Array.isArray(value)) return false;
|
|
1092
|
+
return Object.getPrototypeOf(value) === Object.prototype;
|
|
1093
|
+
}
|
|
1094
|
+
//#endregion
|
|
1095
|
+
//#region src/random/random.errors.ts
|
|
1096
|
+
const randomErrors = { invalidRandomStringLength: () => /* @__PURE__ */ new RangeError("invalidRandomStringLength") };
|
|
1097
|
+
//#endregion
|
|
1098
|
+
//#region src/random/random.constants.ts
|
|
1099
|
+
/**
|
|
1100
|
+
* Alphanumeric character collection used by the default random-string generator.
|
|
1101
|
+
* Its fixed ordering keeps byte-to-character translation stable across runtimes.
|
|
1102
|
+
*/
|
|
1103
|
+
const RANDOM_ALPHANUMERIC_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
1104
|
+
/**
|
|
1105
|
+
* Default output length used when `generateRandomString` receives no override.
|
|
1106
|
+
* Ten characters preserve the historical package contract for existing consumers.
|
|
1107
|
+
*/
|
|
1108
|
+
const DEFAULT_RANDOM_STRING_LENGTH = 10;
|
|
1109
|
+
/**
|
|
1110
|
+
* Maximum random-byte count requested from Web Crypto in one operation.
|
|
1111
|
+
* The boundary follows the platform limit enforced by `crypto.getRandomValues`.
|
|
1112
|
+
*/
|
|
1113
|
+
const RANDOM_BYTE_BATCH_SIZE = 65536;
|
|
1114
|
+
Math.floor(256 / 62) * 62;
|
|
1115
|
+
//#endregion
|
|
1116
|
+
//#region src/random/random.validation.ts
|
|
936
1117
|
/**
|
|
937
|
-
*
|
|
938
|
-
*
|
|
1118
|
+
* Validates the requested random-string length before allocating random bytes.
|
|
1119
|
+
* Only non-negative safe integers can represent a complete output character count.
|
|
939
1120
|
*/
|
|
940
|
-
|
|
1121
|
+
function assertRandomStringLength(length) {
|
|
1122
|
+
if ((Number.isSafeInteger(length) && length >= 0) === false) throw randomErrors.invalidRandomStringLength();
|
|
1123
|
+
}
|
|
941
1124
|
//#endregion
|
|
942
|
-
//#region src/
|
|
943
|
-
const DEFAULT_ERROR_MESSAGE = "Invalid input. At least one field must be provided.";
|
|
1125
|
+
//#region src/random/random.utilities.ts
|
|
944
1126
|
/**
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
* 2. A `.transform()` that narrows the output type to `AtLeastOne<T>`,
|
|
948
|
-
* making it directly assignable to domain `*Select` and `*Update` types without casting.
|
|
1127
|
+
* Creates a cryptographically sourced string from the alphanumeric collection.
|
|
1128
|
+
* Rejection sampling prevents modulo bias while bounded batches support long output.
|
|
949
1129
|
*
|
|
950
1130
|
* @example
|
|
951
|
-
*
|
|
1131
|
+
* generateRandomString(5); // `aZ3fG`
|
|
1132
|
+
* generateRandomString(); // `G5kLm2P9sQ`
|
|
952
1133
|
*/
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1134
|
+
function generateRandomString(length = 10) {
|
|
1135
|
+
assertRandomStringLength(length);
|
|
1136
|
+
let result = "";
|
|
1137
|
+
while (result.length < length) {
|
|
1138
|
+
const remainingLength = length - result.length;
|
|
1139
|
+
const batchSize = Math.min(Math.max(remainingLength * 2, 1), RANDOM_BYTE_BATCH_SIZE);
|
|
1140
|
+
const randomBytes = crypto.getRandomValues(new Uint8Array(batchSize));
|
|
1141
|
+
for (const randomByte of randomBytes) {
|
|
1142
|
+
if (randomByte >= 248) continue;
|
|
1143
|
+
result += RANDOM_ALPHANUMERIC_CHARACTERS.charAt(randomByte % 62);
|
|
1144
|
+
if (result.length === length) break;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return result;
|
|
1148
|
+
}
|
|
959
1149
|
//#endregion
|
|
960
|
-
//#region src/
|
|
1150
|
+
//#region src/upload/upload.enums.ts
|
|
1151
|
+
const uploadValidationErrorsArray = [
|
|
1152
|
+
"emptyFile",
|
|
1153
|
+
"unsupportedFileFormat",
|
|
1154
|
+
"fileSizeExceeded",
|
|
1155
|
+
"filesCountExceeded"
|
|
1156
|
+
];
|
|
1157
|
+
const uploadValidationErrorsRecord = createStringEnumRecord(uploadValidationErrorsArray);
|
|
1158
|
+
const uploadValidationError = uploadValidationErrorsRecord;
|
|
1159
|
+
//#endregion
|
|
1160
|
+
//#region src/upload/upload.errors.ts
|
|
1161
|
+
const uploadErrors = {
|
|
1162
|
+
uploadFormatRequired: () => /* @__PURE__ */ new TypeError("uploadFormatRequired"),
|
|
1163
|
+
invalidMimeType: () => /* @__PURE__ */ new TypeError("invalidMimeType"),
|
|
1164
|
+
invalidFileExtension: () => /* @__PURE__ */ new TypeError("invalidFileExtension"),
|
|
1165
|
+
invalidMaxFileSize: () => /* @__PURE__ */ new RangeError("invalidMaxFileSize"),
|
|
1166
|
+
invalidMaxFilesCount: () => /* @__PURE__ */ new RangeError("invalidMaxFilesCount"),
|
|
1167
|
+
invalidCurrentFilesCount: () => /* @__PURE__ */ new RangeError("invalidCurrentFilesCount")
|
|
1168
|
+
};
|
|
1169
|
+
//#endregion
|
|
1170
|
+
//#region src/upload/upload.constants.ts
|
|
961
1171
|
/**
|
|
962
|
-
*
|
|
963
|
-
*
|
|
1172
|
+
* Matches concrete MIME types, one media wildcard, or the complete wildcard.
|
|
1173
|
+
* Tokens follow the transport-safe characters accepted by standard metadata.
|
|
964
1174
|
*/
|
|
965
|
-
const
|
|
1175
|
+
const UPLOAD_MIME_TYPE_PATTERN = /^(?:\*\/\*|[A-Za-z0-9!#$&^_.+-]+\/(?:\*|[A-Za-z0-9!#$&^_.+-]+))$/;
|
|
966
1176
|
/**
|
|
967
|
-
*
|
|
968
|
-
*
|
|
1177
|
+
* Matches one alphanumeric file extension with an optional leading dot.
|
|
1178
|
+
* Compound and path-like values remain invalid format declarations.
|
|
969
1179
|
*/
|
|
970
|
-
const
|
|
1180
|
+
const UPLOAD_FILE_EXTENSION_PATTERN = /^\.?[A-Za-z0-9]+$/;
|
|
1181
|
+
//#endregion
|
|
1182
|
+
//#region src/upload/upload.utilities.ts
|
|
971
1183
|
/**
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1184
|
+
* Extracts a normalized trailing extension from a complete file name.
|
|
1185
|
+
* Returns an empty string when no valid dot-delimited suffix exists.
|
|
1186
|
+
*/
|
|
1187
|
+
function getFileExtension(fileName) {
|
|
1188
|
+
const normalizedFileName = fileName.trim();
|
|
1189
|
+
const extensionIndex = normalizedFileName.lastIndexOf(".");
|
|
1190
|
+
return extensionIndex > 0 && extensionIndex < normalizedFileName.length - 1 ? normalizedFileName.slice(extensionIndex).toLowerCase() : "";
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Normalizes a configured extension to a lowercase dot-prefixed value.
|
|
1194
|
+
* Existing prefixes remain intact so repeated normalization is stable.
|
|
1195
|
+
*/
|
|
1196
|
+
function normalizeFileExtension(extension) {
|
|
1197
|
+
const normalizedExtension = extension.trim().toLowerCase();
|
|
1198
|
+
if (normalizedExtension.length === 0 || normalizedExtension === ".") return "";
|
|
1199
|
+
return normalizedExtension.startsWith(".") ? normalizedExtension : `.${normalizedExtension}`;
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Compares a concrete MIME type with an exact or wildcard configuration.
|
|
1203
|
+
* Wildcards match every subtype belonging to the configured media group.
|
|
1204
|
+
*/
|
|
1205
|
+
function matchesMimeType(fileMimeType, configuredMimeType) {
|
|
1206
|
+
const normalizedFileMimeType = fileMimeType.trim().toLowerCase();
|
|
1207
|
+
const normalizedConfiguredMimeType = configuredMimeType.trim().toLowerCase();
|
|
1208
|
+
const hasMimeType = /^[^/\s]+\/[^/\s]+$/.test(normalizedFileMimeType);
|
|
1209
|
+
const hasConfiguredMimeType = normalizedConfiguredMimeType.length > 0;
|
|
1210
|
+
if (hasMimeType === false || hasConfiguredMimeType === false) return false;
|
|
1211
|
+
if (normalizedConfiguredMimeType === "*/*") return true;
|
|
1212
|
+
if (normalizedConfiguredMimeType.endsWith("/*")) {
|
|
1213
|
+
const mediaTypePrefix = normalizedConfiguredMimeType.slice(0, -1);
|
|
1214
|
+
const mimeTypeLengthValid = normalizedFileMimeType.length > mediaTypePrefix.length;
|
|
1215
|
+
return normalizedFileMimeType.startsWith(mediaTypePrefix) && mimeTypeLengthValid;
|
|
1216
|
+
}
|
|
1217
|
+
return normalizedFileMimeType === normalizedConfiguredMimeType;
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Checks whether a file MIME type belongs to one selected format.
|
|
1221
|
+
* File names and extensions cannot make an unsupported MIME type valid.
|
|
1222
|
+
*/
|
|
1223
|
+
function isFileMimeTypeSupported(file, formats) {
|
|
1224
|
+
return formats.some((format) => format.mimeTypes.some((mimeType) => matchesMimeType(file.type, mimeType)));
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Checks whether a file extension belongs to one selected format.
|
|
1228
|
+
* The comparison is case-insensitive and requires a complete suffix.
|
|
1229
|
+
*/
|
|
1230
|
+
function isFileExtensionSupported(file, formats) {
|
|
1231
|
+
const fileExtension = getFileExtension(file.name);
|
|
1232
|
+
if (fileExtension.length === 0) return false;
|
|
1233
|
+
return formats.some((format) => format.extensions.some((extension) => normalizeFileExtension(extension) === fileExtension));
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Checks whether a file satisfies the format policy selected by one validation boundary.
|
|
1237
|
+
* MIME matching remains strict unless the caller explicitly enables extension fallback.
|
|
1238
|
+
*/
|
|
1239
|
+
function isFileFormatSupported(file, options) {
|
|
1240
|
+
const { formats, extensionFallback = false } = options;
|
|
1241
|
+
if (isFileMimeTypeSupported(file, formats)) return true;
|
|
1242
|
+
return extensionFallback && isFileExtensionSupported(file, formats);
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Builds a native file-picker hint from configured MIME types and extensions.
|
|
1246
|
+
* Duplicate values are removed while their configuration order is retained.
|
|
1247
|
+
*/
|
|
1248
|
+
function createUploadAccept(formats) {
|
|
1249
|
+
const acceptedValues = formats.flatMap((format) => [...format.mimeTypes.map((mimeType) => mimeType.trim().toLowerCase()), ...format.extensions.map(normalizeFileExtension)]);
|
|
1250
|
+
return [...new Set(acceptedValues.filter((value) => value.length > 0))].join(",");
|
|
1251
|
+
}
|
|
1252
|
+
//#endregion
|
|
1253
|
+
//#region src/upload/upload.validation.ts
|
|
1254
|
+
function resolveUploadFileValidationError(file, options) {
|
|
1255
|
+
if (file.size === 0) return uploadValidationError.EMPTY_FILE;
|
|
1256
|
+
if (isFileFormatSupported(file, options) === false) return uploadValidationError.UNSUPPORTED_FILE_FORMAT;
|
|
1257
|
+
if (file.size > options.maxFileSize) return uploadValidationError.FILE_SIZE_EXCEEDED;
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Asserts that one file validation policy contains a usable size boundary.
|
|
1261
|
+
* Invalid configuration rejects before any externally owned file is inspected.
|
|
1262
|
+
*/
|
|
1263
|
+
function assertUploadFileValidationOptions(options) {
|
|
1264
|
+
if (Number.isInteger(options.maxFileSize) === false || options.maxFileSize < 0) throw uploadErrors.invalidMaxFileSize();
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Asserts that one reusable upload preset contains a usable collection capacity.
|
|
1268
|
+
* Optional capacity remains unrestricted while supplied values require whole counts.
|
|
1269
|
+
*/
|
|
1270
|
+
function assertUploadPreset(preset) {
|
|
1271
|
+
assertUploadFileValidationOptions(preset);
|
|
1272
|
+
if (preset.maxFilesCount !== void 0 && (Number.isInteger(preset.maxFilesCount) === false || preset.maxFilesCount < 0)) throw uploadErrors.invalidMaxFilesCount();
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Asserts that one collection policy contains usable integer capacity values.
|
|
1276
|
+
* Optional collection capacity remains unrestricted when it is omitted.
|
|
989
1277
|
*/
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1278
|
+
function assertUploadFilesValidationOptions(options) {
|
|
1279
|
+
assertUploadPreset(options);
|
|
1280
|
+
if (Number.isInteger(options.currentFilesCount) === false || options.currentFilesCount < 0) throw uploadErrors.invalidCurrentFilesCount();
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Validates one file against configured format and size constraints.
|
|
1284
|
+
* Returns the first stable error key or nothing for a valid file.
|
|
1285
|
+
*/
|
|
1286
|
+
function validateUploadFile(file, options) {
|
|
1287
|
+
assertUploadFileValidationOptions(options);
|
|
1288
|
+
return resolveUploadFileValidationError(file, options);
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Validates an incoming collection while preserving every accepted file.
|
|
1292
|
+
* Returns accepted entries and the first rejection key from the batch.
|
|
1293
|
+
*/
|
|
1294
|
+
function validateUploadFiles(incomingFiles, options) {
|
|
1295
|
+
assertUploadFilesValidationOptions(options);
|
|
1296
|
+
const { currentFilesCount, maxFilesCount } = options;
|
|
1297
|
+
const availableFilesCount = maxFilesCount === void 0 ? Infinity : maxFilesCount - currentFilesCount;
|
|
1298
|
+
const acceptedFiles = [];
|
|
1299
|
+
let validationError;
|
|
1300
|
+
for (const file of incomingFiles) {
|
|
1301
|
+
const fileValidationError = resolveUploadFileValidationError(file, options) ?? (acceptedFiles.length >= availableFilesCount ? uploadValidationError.FILES_COUNT_EXCEEDED : void 0);
|
|
1302
|
+
if (fileValidationError) {
|
|
1303
|
+
validationError ??= fileValidationError;
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
acceptedFiles.push(file);
|
|
1307
|
+
}
|
|
1308
|
+
return {
|
|
1309
|
+
acceptedFiles,
|
|
1310
|
+
validationError
|
|
995
1311
|
};
|
|
996
|
-
|
|
997
|
-
};
|
|
1312
|
+
}
|
|
998
1313
|
//#endregion
|
|
999
|
-
//#region src/
|
|
1314
|
+
//#region src/upload/upload.factory.ts
|
|
1315
|
+
/**
|
|
1316
|
+
* Validates one upload format before it becomes part of a reusable policy.
|
|
1317
|
+
* Accepts MIME-only and extension-only definitions while rejecting malformed values.
|
|
1318
|
+
*/
|
|
1319
|
+
function assertUploadFormat(format) {
|
|
1320
|
+
const hasMimeTypes = format.mimeTypes.length > 0;
|
|
1321
|
+
const hasExtensions = format.extensions.length > 0;
|
|
1322
|
+
if (hasMimeTypes === false && hasExtensions === false) throw uploadErrors.uploadFormatRequired();
|
|
1323
|
+
if (format.mimeTypes.some((mimeType) => {
|
|
1324
|
+
return UPLOAD_MIME_TYPE_PATTERN.test(mimeType.trim()) === false;
|
|
1325
|
+
})) throw uploadErrors.invalidMimeType();
|
|
1326
|
+
if (format.extensions.some((extension) => {
|
|
1327
|
+
return UPLOAD_FILE_EXTENSION_PATTERN.test(extension.trim()) === false;
|
|
1328
|
+
})) throw uploadErrors.invalidFileExtension();
|
|
1329
|
+
}
|
|
1000
1330
|
/**
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1331
|
+
* Defines one upload format while preserving its literal MIME and extension tuples.
|
|
1332
|
+
* Applications may enrich the returned object with their own display metadata.
|
|
1003
1333
|
*/
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1334
|
+
function defineUploadFormat(format) {
|
|
1335
|
+
assertUploadFormat(format);
|
|
1336
|
+
return format;
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Defines one shared upload policy while preserving its literal format tuple.
|
|
1340
|
+
* The resulting preset can drive schemas, picker hints, and batch validation.
|
|
1341
|
+
*/
|
|
1342
|
+
function defineUploadPreset(preset) {
|
|
1343
|
+
assertUploadPreset(preset);
|
|
1344
|
+
return preset;
|
|
1345
|
+
}
|
|
1007
1346
|
//#endregion
|
|
1008
|
-
//#region src/
|
|
1009
|
-
const parseQueryValue = (value) => {
|
|
1010
|
-
if (Array.isArray(value)) return value.map(parseQueryValue);
|
|
1011
|
-
if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, entryValue]) => [key, parseQueryValue(entryValue)]));
|
|
1012
|
-
if (typeof value !== "string") return value;
|
|
1013
|
-
const normalized = value.trim().toLowerCase();
|
|
1014
|
-
if (normalized === "") return value;
|
|
1015
|
-
if (normalized === "true") return true;
|
|
1016
|
-
if (normalized === "false") return false;
|
|
1017
|
-
if (/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) return Number(normalized);
|
|
1018
|
-
return value;
|
|
1019
|
-
};
|
|
1347
|
+
//#region src/upload/upload.schemas.ts
|
|
1020
1348
|
/**
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
* Query parameters are string-based by nature, even when they semantically represent
|
|
1024
|
-
* numbers or booleans. This helper converts only clear primitive values, allowing
|
|
1025
|
-
* regular schemas like `z.number()` and `z.boolean()` to validate query input directly.
|
|
1026
|
-
*
|
|
1027
|
-
* @example
|
|
1028
|
-
* const schema = asQuery(z.object({
|
|
1029
|
-
* page: z.number().int().positive(),
|
|
1030
|
-
* isActive: z.boolean(),
|
|
1031
|
-
* }));
|
|
1032
|
-
*
|
|
1033
|
-
* schema.parse({ page: '2', isActive: 'true' });
|
|
1034
|
-
* // { page: 2, isActive: true }
|
|
1349
|
+
* Builds a reusable Zod schema for one uploaded file with format and size.
|
|
1350
|
+
* MIME matching is strict unless extension fallback is explicitly enabled.
|
|
1035
1351
|
*/
|
|
1036
|
-
|
|
1352
|
+
function zodUploadFileSchema(options) {
|
|
1353
|
+
assertUploadFileValidationOptions(options);
|
|
1354
|
+
return z.file().superRefine((file, context) => {
|
|
1355
|
+
const validationError = validateUploadFile(file, options);
|
|
1356
|
+
if (validationError === void 0) return;
|
|
1357
|
+
context.addIssue({
|
|
1358
|
+
code: "custom",
|
|
1359
|
+
message: validationError
|
|
1360
|
+
});
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
//#endregion
|
|
1364
|
+
//#region src/zod/zod.errors.ts
|
|
1365
|
+
const zodErrors = { atLeastOneRequired: () => ({
|
|
1366
|
+
code: "custom",
|
|
1367
|
+
message: "atLeastOneRequired"
|
|
1368
|
+
}) };
|
|
1369
|
+
//#endregion
|
|
1370
|
+
//#region src/zod/zod.refiners.ts
|
|
1037
1371
|
/**
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1372
|
+
* Requires one defined property while preserving the supplied Zod object validation.
|
|
1373
|
+
* The output type reflects semantic presence and retains valid falsy or nullable values.
|
|
1040
1374
|
*
|
|
1041
1375
|
* @example
|
|
1042
|
-
*
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
return Object.fromEntries(entries);
|
|
1052
|
-
}
|
|
1053
|
-
if (value === null || value === void 0) return value;
|
|
1054
|
-
return String(value);
|
|
1376
|
+
* const patchSchema = zodAtLeastOne(z.object({ name: z.string().optional() }));
|
|
1377
|
+
*/
|
|
1378
|
+
function zodAtLeastOne(schema) {
|
|
1379
|
+
return schema.superRefine((value, context) => {
|
|
1380
|
+
if (Object.values(value).some((property) => property !== void 0)) return;
|
|
1381
|
+
context.addIssue(zodErrors.atLeastOneRequired());
|
|
1382
|
+
}).transform((value) => {
|
|
1383
|
+
return value;
|
|
1384
|
+
});
|
|
1055
1385
|
}
|
|
1056
1386
|
//#endregion
|
|
1057
|
-
export {
|
|
1387
|
+
export { DEFAULT_DATETIME_TIMEZONE, DEFAULT_HMAC_ALGORITHM, DEFAULT_HMAC_ENCODING, DEFAULT_JWT_ALGORITHM, DEFAULT_JWT_EXPIRATION_SECONDS, DEFAULT_RANDOM_STRING_LENGTH, DEFAULT_RETRY_MAX_ATTEMPTS, ERROR_RESPONSE_STATUSES, HMACService, HONO_LOGGING_SERVICE, HONO_REQUEST_ID_HEADER, JWTService, LOG_BODY_PREVIEW_EDGE_LENGTH, MULTIPART_LOG_BODY, RANDOM_ALPHANUMERIC_CHARACTERS, RANDOM_BYTE_BATCH_SIZE, REDACTED_LOG_VALUE, ResponseError, SUCCESS_RESPONSE_STATUSES, assertRandomStringLength, assertUploadFormat, captureExecution, createErrorResponse, createHTTPRequestBodyPreview, createHonoErrorHandler, createLogger, createLoggingMiddleware, createRequestIdMiddleware, createStringEnumRecord, createSuccessResponse, createUploadAccept, decodeBase64, decodeHMACSignature, defineUploadFormat, defineUploadPreset, drizzleErrors, encodeBase64, encodeHMACSignature, executeWithTimeout, executionErrors, fileRespond, formatHTTPRequestLog, formatTime, generateRandomString, getColoredHTTPStatus, getFileExtension, getFormattedDate, getFormattedTime, getHonoRequestId, getUTCOffset, getZonedTime, hmacAlgorithm, hmacAlgorithmsArray, hmacAlgorithmsRecord, hmacEncoding, hmacEncodingsArray, hmacEncodingsRecord, hmacErrors, honoErrors, httpStatusColors, isErrorResponse, isErrorResponseStatus, isFileExtensionSupported, isFileFormatSupported, isFileMimeTypeSupported, isPlainObject, isResponseErrorCode, isSuccessResponse, isSuccessResponseStatus, jwtErrors, log, logLevel, logLevelColors, logLevelsArray, logLevelsRecord, loggingErrors, loggingMiddleware, matchesMimeType, measureExecutionTime, normalizeFileExtension, normalizeLogError, randomErrors, redactSensitiveJSON, redactSensitiveSearchParams, redactSensitiveValue, requestIdMiddleware, resolveResponse, respond, responseErrors, responseKind, responseKindsArray, responseKindsRecord, retryExecution, safeExecute, sensitiveLogKeyParts, sqlWhere, toHMACBytes, unwrapResponse, uploadErrors, uploadValidationError, uploadValidationErrorsArray, uploadValidationErrorsRecord, validateJWTExpirationSeconds, validateUploadFile, validateUploadFiles, waitForRetry, zodAtLeastOne, zodErrors, zodUploadFileSchema };
|