distopia 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +269 -3
- package/dist/index.d.cts +54 -2
- package/dist/index.d.mts +54 -2
- package/dist/index.mjs +247 -4
- package/package.json +5 -4
package/dist/index.cjs
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
3
|
let oxfmt = require("oxfmt");
|
|
4
4
|
let oxlint = require("oxlint");
|
|
5
|
+
let node_dns = require("node:dns");
|
|
6
|
+
let zod = require("zod");
|
|
5
7
|
//#region package.json
|
|
6
|
-
var version = "0.
|
|
8
|
+
var version = "0.5.0";
|
|
7
9
|
//#endregion
|
|
8
10
|
//#region ../template/src/oxfmt.ts
|
|
9
11
|
const config = (0, oxfmt.defineConfig)({
|
|
@@ -30,9 +32,207 @@ const config$1 = (0, oxlint.defineConfig)({
|
|
|
30
32
|
ignorePatterns: ["*.auto.ts", "dist/**"]
|
|
31
33
|
});
|
|
32
34
|
//#endregion
|
|
35
|
+
//#region ../../src/infrastructure/http/src/Error/BodySizeError.ts
|
|
36
|
+
var BodySizeError = class extends Error {};
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region ../../src/infrastructure/http/src/Error/HeaderError.ts
|
|
39
|
+
var HeaderError = class extends Error {};
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region ../../src/infrastructure/http/src/Error/InvalidDomainError.ts
|
|
42
|
+
var InvalidDomainError = class extends Error {};
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region ../../src/infrastructure/http/src/Error/LocalAddressError.ts
|
|
45
|
+
var LocalAddressError = class extends Error {};
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region ../../src/infrastructure/http/src/Error/RedirectError.ts
|
|
48
|
+
var RedirectError = class extends Error {};
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region ../../src/infrastructure/http/src/url.ts
|
|
51
|
+
function isLocalIPv4(host) {
|
|
52
|
+
const parts = host.split(".");
|
|
53
|
+
if (parts.length !== 4) return false;
|
|
54
|
+
const [aStr, bStr, cStr, dStr] = parts;
|
|
55
|
+
const a = Number(aStr);
|
|
56
|
+
const b = Number(bStr);
|
|
57
|
+
const c = Number(cStr);
|
|
58
|
+
const d = Number(dStr);
|
|
59
|
+
for (const v of [
|
|
60
|
+
a,
|
|
61
|
+
b,
|
|
62
|
+
c,
|
|
63
|
+
d
|
|
64
|
+
]) if (!Number.isInteger(v) || v < 0 || v > 255) return false;
|
|
65
|
+
return a === 0 || a === 127 || a === 10 || a === 100 && b >= 64 && b <= 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254;
|
|
66
|
+
}
|
|
67
|
+
function isLocalIPv6(addr) {
|
|
68
|
+
const h = addr.toLowerCase();
|
|
69
|
+
if (h === "::1" || h === "::") return true;
|
|
70
|
+
if (/^fe[89ab]/.test(h)) return true;
|
|
71
|
+
if (/^f[cd]/.test(h)) return true;
|
|
72
|
+
const rest = h.match(/^::ffff:(.+)$/)?.[1];
|
|
73
|
+
if (rest !== void 0) {
|
|
74
|
+
if (rest.includes(".")) return isLocalIPv4(rest);
|
|
75
|
+
const segs = rest.split(":");
|
|
76
|
+
if (segs.length === 2) {
|
|
77
|
+
const hi = parseInt(segs[0] ?? "", 16);
|
|
78
|
+
const lo = parseInt(segs[1] ?? "", 16);
|
|
79
|
+
if (Number.isInteger(hi) && Number.isInteger(lo)) return isLocalIPv4(`${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
async function isLocalUrl(url) {
|
|
85
|
+
let s = url.replace(/\\/g, "/");
|
|
86
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = "http://" + s;
|
|
87
|
+
const hostname = URL.parse(s)?.hostname.toLowerCase();
|
|
88
|
+
if (hostname === void 0) return false;
|
|
89
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
|
|
90
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) return isLocalIPv6(hostname.slice(1, -1));
|
|
91
|
+
if (/^\d/.test(hostname)) return isLocalIPv4(hostname);
|
|
92
|
+
return await isLocalHostname(hostname);
|
|
93
|
+
}
|
|
94
|
+
async function isHttpProtocol(url) {
|
|
95
|
+
const protocol = URL.parse(url.toString())?.protocol;
|
|
96
|
+
return protocol === "http:" || protocol === "https:";
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region ../../src/infrastructure/http/src/dns.ts
|
|
100
|
+
async function isLocalHostname(hostname) {
|
|
101
|
+
const [v4, v6] = await Promise.all([node_dns.promises.resolve4(hostname).catch(() => []), node_dns.promises.resolve6(hostname).catch(() => [])]);
|
|
102
|
+
return v4.some((ip) => isLocalIPv4(ip)) || v6.some((ip) => isLocalIPv6(ip));
|
|
103
|
+
}
|
|
104
|
+
async function resolveHostnameToSafeIp(hostname) {
|
|
105
|
+
const [v4, v6] = await Promise.all([node_dns.promises.resolve4(hostname).catch(() => []), node_dns.promises.resolve6(hostname).catch(() => [])]);
|
|
106
|
+
if (v4.some((ip) => isLocalIPv4(ip)) || v6.some((ip) => isLocalIPv6(ip))) return null;
|
|
107
|
+
if (v4[0] !== void 0) return v4[0];
|
|
108
|
+
if (v6[0] !== void 0) return v6[0];
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region ../../src/infrastructure/http/src/redirect.ts
|
|
113
|
+
const DEFAULT_MAX_REDIRECT = 10;
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region ../../src/infrastructure/http/src/size.ts
|
|
116
|
+
const MAX_BYTES = 1024 * 1024;
|
|
117
|
+
async function isValidSize(response) {
|
|
118
|
+
const body = response.body;
|
|
119
|
+
if (body === null) return true;
|
|
120
|
+
const reader = body.getReader();
|
|
121
|
+
let received = 0;
|
|
122
|
+
let isOk = true;
|
|
123
|
+
while (true) {
|
|
124
|
+
const { done, value } = await reader.read();
|
|
125
|
+
if (done) break;
|
|
126
|
+
received += value.byteLength;
|
|
127
|
+
if (received > 1048576) {
|
|
128
|
+
await reader.cancel();
|
|
129
|
+
isOk = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return isOk;
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region ../../src/infrastructure/http/src/timeout.ts
|
|
136
|
+
const DEFAULT_TIMEOUT = 10 * 1e3;
|
|
137
|
+
const DISCORD_TIMEOUT = 30 * 1e3;
|
|
138
|
+
//#endregion
|
|
33
139
|
//#region ../../src/infrastructure/http/src/safefetch.ts
|
|
34
|
-
|
|
35
|
-
|
|
140
|
+
const ALLOW_DISCORD_DOMAINS = [
|
|
141
|
+
"discord.com",
|
|
142
|
+
"discordapp.com",
|
|
143
|
+
"discord.gg"
|
|
144
|
+
];
|
|
145
|
+
async function resolveToPinnedUrl(url, init) {
|
|
146
|
+
const { hostname, host, protocol } = new URL(url);
|
|
147
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) return new LocalAddressError(`${url} is local address.`);
|
|
148
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
149
|
+
if (isLocalIPv6(hostname.slice(1, -1))) return new LocalAddressError(`${url} is local address.`);
|
|
150
|
+
return {
|
|
151
|
+
url,
|
|
152
|
+
init
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (/^\d/.test(hostname)) {
|
|
156
|
+
if (isLocalIPv4(hostname)) return new LocalAddressError(`${url} is local address.`);
|
|
157
|
+
return {
|
|
158
|
+
url,
|
|
159
|
+
init
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const resolvedIp = await resolveHostnameToSafeIp(hostname);
|
|
163
|
+
if (resolvedIp === null) return new LocalAddressError(`${url} is local address.`);
|
|
164
|
+
const pinnedUrlObj = new URL(url);
|
|
165
|
+
pinnedUrlObj.hostname = resolvedIp.includes(":") ? `[${resolvedIp}]` : resolvedIp;
|
|
166
|
+
const headers = new Headers(init.headers);
|
|
167
|
+
headers.set("Host", host);
|
|
168
|
+
const pinnedInit = {
|
|
169
|
+
...init,
|
|
170
|
+
headers,
|
|
171
|
+
...protocol === "https:" ? { tls: { serverName: hostname } } : {}
|
|
172
|
+
};
|
|
173
|
+
return {
|
|
174
|
+
url: pinnedUrlObj.href,
|
|
175
|
+
init: pinnedInit
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async function safeFetchForDiscord(input, init) {
|
|
179
|
+
const hostname = new URL(input).hostname;
|
|
180
|
+
if (!ALLOW_DISCORD_DOMAINS.includes(hostname)) return new InvalidDomainError(`${hostname} is not discord domain.`);
|
|
181
|
+
const pinned = await resolveToPinnedUrl(input, init ?? {});
|
|
182
|
+
if (pinned instanceof LocalAddressError) return pinned;
|
|
183
|
+
return await fetch(pinned.url, {
|
|
184
|
+
...pinned.init,
|
|
185
|
+
signal: AbortSignal.timeout(DISCORD_TIMEOUT),
|
|
186
|
+
redirect: "manual"
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
async function safeFetch(input, init, options) {
|
|
190
|
+
let reqUrl = input;
|
|
191
|
+
let currentInit = init ?? {};
|
|
192
|
+
let redirectCount = 0;
|
|
193
|
+
let response;
|
|
194
|
+
while (true) {
|
|
195
|
+
const pinned = await resolveToPinnedUrl(reqUrl, currentInit);
|
|
196
|
+
if (pinned instanceof LocalAddressError) return pinned;
|
|
197
|
+
response = await fetch(pinned.url, {
|
|
198
|
+
...pinned.init,
|
|
199
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT),
|
|
200
|
+
redirect: "manual"
|
|
201
|
+
});
|
|
202
|
+
if (!await isValidSize(response.clone())) return new BodySizeError("Body size Error");
|
|
203
|
+
const isRedirect = response.status >= 300 && response.status < 400 && response.status !== 304;
|
|
204
|
+
const location = response.headers.get("location");
|
|
205
|
+
if (isRedirect) {
|
|
206
|
+
if (location === null) return new HeaderError("location is not found.");
|
|
207
|
+
let url;
|
|
208
|
+
try {
|
|
209
|
+
url = new URL(location, reqUrl);
|
|
210
|
+
} catch {
|
|
211
|
+
return new HeaderError(`${location} is invalid.`);
|
|
212
|
+
}
|
|
213
|
+
if (options?.detectDiscordProtocol && url.protocol === "discord://") return response;
|
|
214
|
+
if (!await isHttpProtocol(url)) return new HeaderError(`${url.protocol} is not allowed.`);
|
|
215
|
+
if (new URL(reqUrl).origin !== url.origin) {
|
|
216
|
+
const headers = new Headers(currentInit.headers);
|
|
217
|
+
headers.delete("authorization");
|
|
218
|
+
headers.delete("cookie");
|
|
219
|
+
currentInit = {
|
|
220
|
+
...currentInit,
|
|
221
|
+
headers
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
reqUrl = url.href;
|
|
225
|
+
redirectCount += 1;
|
|
226
|
+
} else {
|
|
227
|
+
const finalUrl = reqUrl;
|
|
228
|
+
return new Proxy(response, { get(target, prop) {
|
|
229
|
+
if (prop === "url") return finalUrl;
|
|
230
|
+
const value = Reflect.get(target, prop, target);
|
|
231
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
232
|
+
} });
|
|
233
|
+
}
|
|
234
|
+
if (redirectCount > 10) return new RedirectError(`Redirect count is over 10`);
|
|
235
|
+
}
|
|
36
236
|
}
|
|
37
237
|
//#endregion
|
|
38
238
|
//#region ../../src/infrastructure/http/src/safeurl.ts
|
|
@@ -45,12 +245,78 @@ function safeUrl(strings, ...values) {
|
|
|
45
245
|
}
|
|
46
246
|
return result;
|
|
47
247
|
}
|
|
248
|
+
const safeUrlSchema = zod.z.string().refine((url) => {
|
|
249
|
+
const parsed = URL.parse(url);
|
|
250
|
+
return parsed !== null && (parsed.protocol === "http:" || parsed.protocol === "https:");
|
|
251
|
+
}, { message: "URL must be a valid http or https URL" }).transform((url) => url);
|
|
252
|
+
function validateSafeUrl(url) {
|
|
253
|
+
const result = safeUrlSchema.safeParse(url);
|
|
254
|
+
return result.success ? result.data : null;
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region ../../src/infrastructure/http/src/invite.ts
|
|
258
|
+
const DISCORD_DOMAINS = [
|
|
259
|
+
"discord.com",
|
|
260
|
+
"ptb.discord.com",
|
|
261
|
+
"canary.discord.com"
|
|
262
|
+
];
|
|
263
|
+
const INVITE_PROTOCOL = [
|
|
264
|
+
"discord:",
|
|
265
|
+
"http:",
|
|
266
|
+
"https:"
|
|
267
|
+
];
|
|
268
|
+
async function isDiscordInviteLink(url) {
|
|
269
|
+
const parsedUrl = URL.parse(url.toString());
|
|
270
|
+
if (parsedUrl === null) return false;
|
|
271
|
+
return INVITE_PROTOCOL.includes(parsedUrl.protocol) && DISCORD_DOMAINS.includes(parsedUrl.host) && parsedUrl.pathname.startsWith("/invite/");
|
|
272
|
+
}
|
|
273
|
+
function isUsedCf(res) {
|
|
274
|
+
return res.headers.get("cf-mitigated") === "challenge";
|
|
275
|
+
}
|
|
276
|
+
async function isInviteLink(url) {
|
|
277
|
+
const safeUrl = validateSafeUrl(url);
|
|
278
|
+
if (safeUrl === null) return new LocalAddressError(`${url} is not a safe URL.`);
|
|
279
|
+
const response = await safeFetch(safeUrl, {
|
|
280
|
+
method: "GET",
|
|
281
|
+
headers: { "User-Agent": "Mozilla/5.0" }
|
|
282
|
+
}, { detectDiscordProtocol: true });
|
|
283
|
+
if (response instanceof Error) return response;
|
|
284
|
+
const resUrl = response.url;
|
|
285
|
+
const location = response.headers.get("location");
|
|
286
|
+
return {
|
|
287
|
+
content: await isDiscordInviteLink(resUrl) || location !== null && await isDiscordInviteLink(location),
|
|
288
|
+
isUsedCf: isUsedCf(response)
|
|
289
|
+
};
|
|
290
|
+
}
|
|
48
291
|
//#endregion
|
|
49
292
|
//#region src/index.ts
|
|
50
293
|
const DISTOPIA_VERSION = version;
|
|
51
294
|
//#endregion
|
|
295
|
+
exports.ALLOW_DISCORD_DOMAINS = ALLOW_DISCORD_DOMAINS;
|
|
296
|
+
exports.BodySizeError = BodySizeError;
|
|
297
|
+
exports.DEFAULT_MAX_REDIRECT = DEFAULT_MAX_REDIRECT;
|
|
298
|
+
exports.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
|
|
299
|
+
exports.DISCORD_DOMAINS = DISCORD_DOMAINS;
|
|
300
|
+
exports.DISCORD_TIMEOUT = DISCORD_TIMEOUT;
|
|
52
301
|
exports.DISTOPIA_VERSION = DISTOPIA_VERSION;
|
|
302
|
+
exports.HeaderError = HeaderError;
|
|
303
|
+
exports.INVITE_PROTOCOL = INVITE_PROTOCOL;
|
|
304
|
+
exports.InvalidDomainError = InvalidDomainError;
|
|
305
|
+
exports.LocalAddressError = LocalAddressError;
|
|
306
|
+
exports.MAX_BYTES = MAX_BYTES;
|
|
307
|
+
exports.RedirectError = RedirectError;
|
|
308
|
+
exports.isHttpProtocol = isHttpProtocol;
|
|
309
|
+
exports.isInviteLink = isInviteLink;
|
|
310
|
+
exports.isLocalHostname = isLocalHostname;
|
|
311
|
+
exports.isLocalIPv4 = isLocalIPv4;
|
|
312
|
+
exports.isLocalIPv6 = isLocalIPv6;
|
|
313
|
+
exports.isLocalUrl = isLocalUrl;
|
|
314
|
+
exports.isUsedCf = isUsedCf;
|
|
315
|
+
exports.isValidSize = isValidSize;
|
|
53
316
|
exports.oxfmtCfg = config;
|
|
54
317
|
exports.oxlintCfg = config$1;
|
|
318
|
+
exports.resolveHostnameToSafeIp = resolveHostnameToSafeIp;
|
|
55
319
|
exports.safeFetch = safeFetch;
|
|
320
|
+
exports.safeFetchForDiscord = safeFetchForDiscord;
|
|
56
321
|
exports.safeUrl = safeUrl;
|
|
322
|
+
exports.validateSafeUrl = validateSafeUrl;
|
package/dist/index.d.cts
CHANGED
|
@@ -24,17 +24,69 @@ declare const config$1: {
|
|
|
24
24
|
ignorePatterns: string[];
|
|
25
25
|
};
|
|
26
26
|
//#endregion
|
|
27
|
+
//#region ../../src/infrastructure/http/src/Error/BodySizeError.d.ts
|
|
28
|
+
declare class BodySizeError extends Error {}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region ../../src/infrastructure/http/src/Error/HeaderError.d.ts
|
|
31
|
+
declare class HeaderError extends Error {}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region ../../src/infrastructure/http/src/Error/InvalidDomainError.d.ts
|
|
34
|
+
declare class InvalidDomainError extends Error {}
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region ../../src/infrastructure/http/src/Error/LocalAddressError.d.ts
|
|
37
|
+
declare class LocalAddressError extends Error {}
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region ../../src/infrastructure/http/src/Error/RedirectError.d.ts
|
|
40
|
+
declare class RedirectError extends Error {}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region ../../src/infrastructure/http/src/dns.d.ts
|
|
43
|
+
declare function isLocalHostname(hostname: string): Promise<boolean>;
|
|
44
|
+
declare function resolveHostnameToSafeIp(hostname: string): Promise<string | null>;
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region ../../src/infrastructure/http/src/invite.d.ts
|
|
47
|
+
type IsInviteLink = {
|
|
48
|
+
content: boolean;
|
|
49
|
+
isUsedCf: boolean;
|
|
50
|
+
};
|
|
51
|
+
declare const DISCORD_DOMAINS: string[];
|
|
52
|
+
declare const INVITE_PROTOCOL: string[];
|
|
53
|
+
declare function isUsedCf(res: Response): boolean;
|
|
54
|
+
declare function isInviteLink(url: string): Promise<IsInviteLink | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region ../../src/infrastructure/http/src/redirect.d.ts
|
|
57
|
+
declare const DEFAULT_MAX_REDIRECT = 10;
|
|
58
|
+
//#endregion
|
|
27
59
|
//#region ../../src/infrastructure/http/src/safeurl.d.ts
|
|
28
60
|
type Branded<T, Brand> = T & {
|
|
29
61
|
readonly __brand: Brand;
|
|
30
62
|
};
|
|
31
63
|
type SafeUrl = Branded<string, "distopiaSafeUrl">;
|
|
32
64
|
declare function safeUrl(strings: TemplateStringsArray, ...values: (string | number)[]): SafeUrl;
|
|
65
|
+
declare function validateSafeUrl(url: string): SafeUrl | null;
|
|
33
66
|
//#endregion
|
|
34
67
|
//#region ../../src/infrastructure/http/src/safefetch.d.ts
|
|
35
|
-
|
|
68
|
+
type SafeFetchOptions = {
|
|
69
|
+
detectDiscordProtocol?: boolean;
|
|
70
|
+
};
|
|
71
|
+
declare const ALLOW_DISCORD_DOMAINS: string[];
|
|
72
|
+
declare function safeFetchForDiscord(input: SafeUrl, init?: RequestInit): Promise<Response | LocalAddressError | InvalidDomainError>;
|
|
73
|
+
declare function safeFetch(input: SafeUrl, init?: RequestInit, options?: SafeFetchOptions): Promise<Response | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region ../../src/infrastructure/http/src/size.d.ts
|
|
76
|
+
declare const MAX_BYTES: number;
|
|
77
|
+
declare function isValidSize(response: Response): Promise<boolean>;
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region ../../src/infrastructure/http/src/timeout.d.ts
|
|
80
|
+
declare const DEFAULT_TIMEOUT: number;
|
|
81
|
+
declare const DISCORD_TIMEOUT: number;
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region ../../src/infrastructure/http/src/url.d.ts
|
|
84
|
+
declare function isLocalIPv4(host: string): boolean;
|
|
85
|
+
declare function isLocalIPv6(addr: string): boolean;
|
|
86
|
+
declare function isLocalUrl(url: string): Promise<boolean>;
|
|
87
|
+
declare function isHttpProtocol(url: string | URL): Promise<boolean>;
|
|
36
88
|
//#endregion
|
|
37
89
|
//#region src/index.d.ts
|
|
38
90
|
declare const DISTOPIA_VERSION: string;
|
|
39
91
|
//#endregion
|
|
40
|
-
export {
|
|
92
|
+
export { ALLOW_DISCORD_DOMAINS, BodySizeError, Branded, DEFAULT_MAX_REDIRECT, DEFAULT_TIMEOUT, DISCORD_DOMAINS, DISCORD_TIMEOUT, DISTOPIA_VERSION, HeaderError, INVITE_PROTOCOL, InvalidDomainError, IsInviteLink, LocalAddressError, MAX_BYTES, RedirectError, SafeFetchOptions, SafeUrl, isHttpProtocol, isInviteLink, isLocalHostname, isLocalIPv4, isLocalIPv6, isLocalUrl, isUsedCf, isValidSize, config as oxfmtCfg, config$1 as oxlintCfg, resolveHostnameToSafeIp, safeFetch, safeFetchForDiscord, safeUrl, validateSafeUrl };
|
package/dist/index.d.mts
CHANGED
|
@@ -24,17 +24,69 @@ declare const config$1: {
|
|
|
24
24
|
ignorePatterns: string[];
|
|
25
25
|
};
|
|
26
26
|
//#endregion
|
|
27
|
+
//#region ../../src/infrastructure/http/src/Error/BodySizeError.d.ts
|
|
28
|
+
declare class BodySizeError extends Error {}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region ../../src/infrastructure/http/src/Error/HeaderError.d.ts
|
|
31
|
+
declare class HeaderError extends Error {}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region ../../src/infrastructure/http/src/Error/InvalidDomainError.d.ts
|
|
34
|
+
declare class InvalidDomainError extends Error {}
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region ../../src/infrastructure/http/src/Error/LocalAddressError.d.ts
|
|
37
|
+
declare class LocalAddressError extends Error {}
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region ../../src/infrastructure/http/src/Error/RedirectError.d.ts
|
|
40
|
+
declare class RedirectError extends Error {}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region ../../src/infrastructure/http/src/dns.d.ts
|
|
43
|
+
declare function isLocalHostname(hostname: string): Promise<boolean>;
|
|
44
|
+
declare function resolveHostnameToSafeIp(hostname: string): Promise<string | null>;
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region ../../src/infrastructure/http/src/invite.d.ts
|
|
47
|
+
type IsInviteLink = {
|
|
48
|
+
content: boolean;
|
|
49
|
+
isUsedCf: boolean;
|
|
50
|
+
};
|
|
51
|
+
declare const DISCORD_DOMAINS: string[];
|
|
52
|
+
declare const INVITE_PROTOCOL: string[];
|
|
53
|
+
declare function isUsedCf(res: Response): boolean;
|
|
54
|
+
declare function isInviteLink(url: string): Promise<IsInviteLink | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region ../../src/infrastructure/http/src/redirect.d.ts
|
|
57
|
+
declare const DEFAULT_MAX_REDIRECT = 10;
|
|
58
|
+
//#endregion
|
|
27
59
|
//#region ../../src/infrastructure/http/src/safeurl.d.ts
|
|
28
60
|
type Branded<T, Brand> = T & {
|
|
29
61
|
readonly __brand: Brand;
|
|
30
62
|
};
|
|
31
63
|
type SafeUrl = Branded<string, "distopiaSafeUrl">;
|
|
32
64
|
declare function safeUrl(strings: TemplateStringsArray, ...values: (string | number)[]): SafeUrl;
|
|
65
|
+
declare function validateSafeUrl(url: string): SafeUrl | null;
|
|
33
66
|
//#endregion
|
|
34
67
|
//#region ../../src/infrastructure/http/src/safefetch.d.ts
|
|
35
|
-
|
|
68
|
+
type SafeFetchOptions = {
|
|
69
|
+
detectDiscordProtocol?: boolean;
|
|
70
|
+
};
|
|
71
|
+
declare const ALLOW_DISCORD_DOMAINS: string[];
|
|
72
|
+
declare function safeFetchForDiscord(input: SafeUrl, init?: RequestInit): Promise<Response | LocalAddressError | InvalidDomainError>;
|
|
73
|
+
declare function safeFetch(input: SafeUrl, init?: RequestInit, options?: SafeFetchOptions): Promise<Response | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region ../../src/infrastructure/http/src/size.d.ts
|
|
76
|
+
declare const MAX_BYTES: number;
|
|
77
|
+
declare function isValidSize(response: Response): Promise<boolean>;
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region ../../src/infrastructure/http/src/timeout.d.ts
|
|
80
|
+
declare const DEFAULT_TIMEOUT: number;
|
|
81
|
+
declare const DISCORD_TIMEOUT: number;
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region ../../src/infrastructure/http/src/url.d.ts
|
|
84
|
+
declare function isLocalIPv4(host: string): boolean;
|
|
85
|
+
declare function isLocalIPv6(addr: string): boolean;
|
|
86
|
+
declare function isLocalUrl(url: string): Promise<boolean>;
|
|
87
|
+
declare function isHttpProtocol(url: string | URL): Promise<boolean>;
|
|
36
88
|
//#endregion
|
|
37
89
|
//#region src/index.d.ts
|
|
38
90
|
declare const DISTOPIA_VERSION: string;
|
|
39
91
|
//#endregion
|
|
40
|
-
export {
|
|
92
|
+
export { ALLOW_DISCORD_DOMAINS, BodySizeError, Branded, DEFAULT_MAX_REDIRECT, DEFAULT_TIMEOUT, DISCORD_DOMAINS, DISCORD_TIMEOUT, DISTOPIA_VERSION, HeaderError, INVITE_PROTOCOL, InvalidDomainError, IsInviteLink, LocalAddressError, MAX_BYTES, RedirectError, SafeFetchOptions, SafeUrl, isHttpProtocol, isInviteLink, isLocalHostname, isLocalIPv4, isLocalIPv6, isLocalUrl, isUsedCf, isValidSize, config as oxfmtCfg, config$1 as oxlintCfg, resolveHostnameToSafeIp, safeFetch, safeFetchForDiscord, safeUrl, validateSafeUrl };
|
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/* @ts-self-types="./index.d.mts" */
|
|
2
2
|
import { defineConfig } from "oxfmt";
|
|
3
3
|
import { defineConfig as defineConfig$1 } from "oxlint";
|
|
4
|
+
import { promises } from "node:dns";
|
|
5
|
+
import { z } from "zod";
|
|
4
6
|
//#region package.json
|
|
5
|
-
var version = "0.
|
|
7
|
+
var version = "0.5.0";
|
|
6
8
|
//#endregion
|
|
7
9
|
//#region ../template/src/oxfmt.ts
|
|
8
10
|
const config = defineConfig({
|
|
@@ -29,9 +31,207 @@ const config$1 = defineConfig$1({
|
|
|
29
31
|
ignorePatterns: ["*.auto.ts", "dist/**"]
|
|
30
32
|
});
|
|
31
33
|
//#endregion
|
|
34
|
+
//#region ../../src/infrastructure/http/src/Error/BodySizeError.ts
|
|
35
|
+
var BodySizeError = class extends Error {};
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region ../../src/infrastructure/http/src/Error/HeaderError.ts
|
|
38
|
+
var HeaderError = class extends Error {};
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region ../../src/infrastructure/http/src/Error/InvalidDomainError.ts
|
|
41
|
+
var InvalidDomainError = class extends Error {};
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region ../../src/infrastructure/http/src/Error/LocalAddressError.ts
|
|
44
|
+
var LocalAddressError = class extends Error {};
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region ../../src/infrastructure/http/src/Error/RedirectError.ts
|
|
47
|
+
var RedirectError = class extends Error {};
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region ../../src/infrastructure/http/src/url.ts
|
|
50
|
+
function isLocalIPv4(host) {
|
|
51
|
+
const parts = host.split(".");
|
|
52
|
+
if (parts.length !== 4) return false;
|
|
53
|
+
const [aStr, bStr, cStr, dStr] = parts;
|
|
54
|
+
const a = Number(aStr);
|
|
55
|
+
const b = Number(bStr);
|
|
56
|
+
const c = Number(cStr);
|
|
57
|
+
const d = Number(dStr);
|
|
58
|
+
for (const v of [
|
|
59
|
+
a,
|
|
60
|
+
b,
|
|
61
|
+
c,
|
|
62
|
+
d
|
|
63
|
+
]) if (!Number.isInteger(v) || v < 0 || v > 255) return false;
|
|
64
|
+
return a === 0 || a === 127 || a === 10 || a === 100 && b >= 64 && b <= 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254;
|
|
65
|
+
}
|
|
66
|
+
function isLocalIPv6(addr) {
|
|
67
|
+
const h = addr.toLowerCase();
|
|
68
|
+
if (h === "::1" || h === "::") return true;
|
|
69
|
+
if (/^fe[89ab]/.test(h)) return true;
|
|
70
|
+
if (/^f[cd]/.test(h)) return true;
|
|
71
|
+
const rest = h.match(/^::ffff:(.+)$/)?.[1];
|
|
72
|
+
if (rest !== void 0) {
|
|
73
|
+
if (rest.includes(".")) return isLocalIPv4(rest);
|
|
74
|
+
const segs = rest.split(":");
|
|
75
|
+
if (segs.length === 2) {
|
|
76
|
+
const hi = parseInt(segs[0] ?? "", 16);
|
|
77
|
+
const lo = parseInt(segs[1] ?? "", 16);
|
|
78
|
+
if (Number.isInteger(hi) && Number.isInteger(lo)) return isLocalIPv4(`${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
async function isLocalUrl(url) {
|
|
84
|
+
let s = url.replace(/\\/g, "/");
|
|
85
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = "http://" + s;
|
|
86
|
+
const hostname = URL.parse(s)?.hostname.toLowerCase();
|
|
87
|
+
if (hostname === void 0) return false;
|
|
88
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
|
|
89
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) return isLocalIPv6(hostname.slice(1, -1));
|
|
90
|
+
if (/^\d/.test(hostname)) return isLocalIPv4(hostname);
|
|
91
|
+
return await isLocalHostname(hostname);
|
|
92
|
+
}
|
|
93
|
+
async function isHttpProtocol(url) {
|
|
94
|
+
const protocol = URL.parse(url.toString())?.protocol;
|
|
95
|
+
return protocol === "http:" || protocol === "https:";
|
|
96
|
+
}
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region ../../src/infrastructure/http/src/dns.ts
|
|
99
|
+
async function isLocalHostname(hostname) {
|
|
100
|
+
const [v4, v6] = await Promise.all([promises.resolve4(hostname).catch(() => []), promises.resolve6(hostname).catch(() => [])]);
|
|
101
|
+
return v4.some((ip) => isLocalIPv4(ip)) || v6.some((ip) => isLocalIPv6(ip));
|
|
102
|
+
}
|
|
103
|
+
async function resolveHostnameToSafeIp(hostname) {
|
|
104
|
+
const [v4, v6] = await Promise.all([promises.resolve4(hostname).catch(() => []), promises.resolve6(hostname).catch(() => [])]);
|
|
105
|
+
if (v4.some((ip) => isLocalIPv4(ip)) || v6.some((ip) => isLocalIPv6(ip))) return null;
|
|
106
|
+
if (v4[0] !== void 0) return v4[0];
|
|
107
|
+
if (v6[0] !== void 0) return v6[0];
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region ../../src/infrastructure/http/src/redirect.ts
|
|
112
|
+
const DEFAULT_MAX_REDIRECT = 10;
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region ../../src/infrastructure/http/src/size.ts
|
|
115
|
+
const MAX_BYTES = 1024 * 1024;
|
|
116
|
+
async function isValidSize(response) {
|
|
117
|
+
const body = response.body;
|
|
118
|
+
if (body === null) return true;
|
|
119
|
+
const reader = body.getReader();
|
|
120
|
+
let received = 0;
|
|
121
|
+
let isOk = true;
|
|
122
|
+
while (true) {
|
|
123
|
+
const { done, value } = await reader.read();
|
|
124
|
+
if (done) break;
|
|
125
|
+
received += value.byteLength;
|
|
126
|
+
if (received > 1048576) {
|
|
127
|
+
await reader.cancel();
|
|
128
|
+
isOk = false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return isOk;
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region ../../src/infrastructure/http/src/timeout.ts
|
|
135
|
+
const DEFAULT_TIMEOUT = 10 * 1e3;
|
|
136
|
+
const DISCORD_TIMEOUT = 30 * 1e3;
|
|
137
|
+
//#endregion
|
|
32
138
|
//#region ../../src/infrastructure/http/src/safefetch.ts
|
|
33
|
-
|
|
34
|
-
|
|
139
|
+
const ALLOW_DISCORD_DOMAINS = [
|
|
140
|
+
"discord.com",
|
|
141
|
+
"discordapp.com",
|
|
142
|
+
"discord.gg"
|
|
143
|
+
];
|
|
144
|
+
async function resolveToPinnedUrl(url, init) {
|
|
145
|
+
const { hostname, host, protocol } = new URL(url);
|
|
146
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) return new LocalAddressError(`${url} is local address.`);
|
|
147
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
148
|
+
if (isLocalIPv6(hostname.slice(1, -1))) return new LocalAddressError(`${url} is local address.`);
|
|
149
|
+
return {
|
|
150
|
+
url,
|
|
151
|
+
init
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (/^\d/.test(hostname)) {
|
|
155
|
+
if (isLocalIPv4(hostname)) return new LocalAddressError(`${url} is local address.`);
|
|
156
|
+
return {
|
|
157
|
+
url,
|
|
158
|
+
init
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const resolvedIp = await resolveHostnameToSafeIp(hostname);
|
|
162
|
+
if (resolvedIp === null) return new LocalAddressError(`${url} is local address.`);
|
|
163
|
+
const pinnedUrlObj = new URL(url);
|
|
164
|
+
pinnedUrlObj.hostname = resolvedIp.includes(":") ? `[${resolvedIp}]` : resolvedIp;
|
|
165
|
+
const headers = new Headers(init.headers);
|
|
166
|
+
headers.set("Host", host);
|
|
167
|
+
const pinnedInit = {
|
|
168
|
+
...init,
|
|
169
|
+
headers,
|
|
170
|
+
...protocol === "https:" ? { tls: { serverName: hostname } } : {}
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
url: pinnedUrlObj.href,
|
|
174
|
+
init: pinnedInit
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
async function safeFetchForDiscord(input, init) {
|
|
178
|
+
const hostname = new URL(input).hostname;
|
|
179
|
+
if (!ALLOW_DISCORD_DOMAINS.includes(hostname)) return new InvalidDomainError(`${hostname} is not discord domain.`);
|
|
180
|
+
const pinned = await resolveToPinnedUrl(input, init ?? {});
|
|
181
|
+
if (pinned instanceof LocalAddressError) return pinned;
|
|
182
|
+
return await fetch(pinned.url, {
|
|
183
|
+
...pinned.init,
|
|
184
|
+
signal: AbortSignal.timeout(DISCORD_TIMEOUT),
|
|
185
|
+
redirect: "manual"
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
async function safeFetch(input, init, options) {
|
|
189
|
+
let reqUrl = input;
|
|
190
|
+
let currentInit = init ?? {};
|
|
191
|
+
let redirectCount = 0;
|
|
192
|
+
let response;
|
|
193
|
+
while (true) {
|
|
194
|
+
const pinned = await resolveToPinnedUrl(reqUrl, currentInit);
|
|
195
|
+
if (pinned instanceof LocalAddressError) return pinned;
|
|
196
|
+
response = await fetch(pinned.url, {
|
|
197
|
+
...pinned.init,
|
|
198
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT),
|
|
199
|
+
redirect: "manual"
|
|
200
|
+
});
|
|
201
|
+
if (!await isValidSize(response.clone())) return new BodySizeError("Body size Error");
|
|
202
|
+
const isRedirect = response.status >= 300 && response.status < 400 && response.status !== 304;
|
|
203
|
+
const location = response.headers.get("location");
|
|
204
|
+
if (isRedirect) {
|
|
205
|
+
if (location === null) return new HeaderError("location is not found.");
|
|
206
|
+
let url;
|
|
207
|
+
try {
|
|
208
|
+
url = new URL(location, reqUrl);
|
|
209
|
+
} catch {
|
|
210
|
+
return new HeaderError(`${location} is invalid.`);
|
|
211
|
+
}
|
|
212
|
+
if (options?.detectDiscordProtocol && url.protocol === "discord://") return response;
|
|
213
|
+
if (!await isHttpProtocol(url)) return new HeaderError(`${url.protocol} is not allowed.`);
|
|
214
|
+
if (new URL(reqUrl).origin !== url.origin) {
|
|
215
|
+
const headers = new Headers(currentInit.headers);
|
|
216
|
+
headers.delete("authorization");
|
|
217
|
+
headers.delete("cookie");
|
|
218
|
+
currentInit = {
|
|
219
|
+
...currentInit,
|
|
220
|
+
headers
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
reqUrl = url.href;
|
|
224
|
+
redirectCount += 1;
|
|
225
|
+
} else {
|
|
226
|
+
const finalUrl = reqUrl;
|
|
227
|
+
return new Proxy(response, { get(target, prop) {
|
|
228
|
+
if (prop === "url") return finalUrl;
|
|
229
|
+
const value = Reflect.get(target, prop, target);
|
|
230
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
231
|
+
} });
|
|
232
|
+
}
|
|
233
|
+
if (redirectCount > 10) return new RedirectError(`Redirect count is over 10`);
|
|
234
|
+
}
|
|
35
235
|
}
|
|
36
236
|
//#endregion
|
|
37
237
|
//#region ../../src/infrastructure/http/src/safeurl.ts
|
|
@@ -44,8 +244,51 @@ function safeUrl(strings, ...values) {
|
|
|
44
244
|
}
|
|
45
245
|
return result;
|
|
46
246
|
}
|
|
247
|
+
const safeUrlSchema = z.string().refine((url) => {
|
|
248
|
+
const parsed = URL.parse(url);
|
|
249
|
+
return parsed !== null && (parsed.protocol === "http:" || parsed.protocol === "https:");
|
|
250
|
+
}, { message: "URL must be a valid http or https URL" }).transform((url) => url);
|
|
251
|
+
function validateSafeUrl(url) {
|
|
252
|
+
const result = safeUrlSchema.safeParse(url);
|
|
253
|
+
return result.success ? result.data : null;
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region ../../src/infrastructure/http/src/invite.ts
|
|
257
|
+
const DISCORD_DOMAINS = [
|
|
258
|
+
"discord.com",
|
|
259
|
+
"ptb.discord.com",
|
|
260
|
+
"canary.discord.com"
|
|
261
|
+
];
|
|
262
|
+
const INVITE_PROTOCOL = [
|
|
263
|
+
"discord:",
|
|
264
|
+
"http:",
|
|
265
|
+
"https:"
|
|
266
|
+
];
|
|
267
|
+
async function isDiscordInviteLink(url) {
|
|
268
|
+
const parsedUrl = URL.parse(url.toString());
|
|
269
|
+
if (parsedUrl === null) return false;
|
|
270
|
+
return INVITE_PROTOCOL.includes(parsedUrl.protocol) && DISCORD_DOMAINS.includes(parsedUrl.host) && parsedUrl.pathname.startsWith("/invite/");
|
|
271
|
+
}
|
|
272
|
+
function isUsedCf(res) {
|
|
273
|
+
return res.headers.get("cf-mitigated") === "challenge";
|
|
274
|
+
}
|
|
275
|
+
async function isInviteLink(url) {
|
|
276
|
+
const safeUrl = validateSafeUrl(url);
|
|
277
|
+
if (safeUrl === null) return new LocalAddressError(`${url} is not a safe URL.`);
|
|
278
|
+
const response = await safeFetch(safeUrl, {
|
|
279
|
+
method: "GET",
|
|
280
|
+
headers: { "User-Agent": "Mozilla/5.0" }
|
|
281
|
+
}, { detectDiscordProtocol: true });
|
|
282
|
+
if (response instanceof Error) return response;
|
|
283
|
+
const resUrl = response.url;
|
|
284
|
+
const location = response.headers.get("location");
|
|
285
|
+
return {
|
|
286
|
+
content: await isDiscordInviteLink(resUrl) || location !== null && await isDiscordInviteLink(location),
|
|
287
|
+
isUsedCf: isUsedCf(response)
|
|
288
|
+
};
|
|
289
|
+
}
|
|
47
290
|
//#endregion
|
|
48
291
|
//#region src/index.ts
|
|
49
292
|
const DISTOPIA_VERSION = version;
|
|
50
293
|
//#endregion
|
|
51
|
-
export { DISTOPIA_VERSION, config as oxfmtCfg, config$1 as oxlintCfg, safeFetch, safeUrl };
|
|
294
|
+
export { ALLOW_DISCORD_DOMAINS, BodySizeError, DEFAULT_MAX_REDIRECT, DEFAULT_TIMEOUT, DISCORD_DOMAINS, DISCORD_TIMEOUT, DISTOPIA_VERSION, HeaderError, INVITE_PROTOCOL, InvalidDomainError, LocalAddressError, MAX_BYTES, RedirectError, isHttpProtocol, isInviteLink, isLocalHostname, isLocalIPv4, isLocalIPv6, isLocalUrl, isUsedCf, isValidSize, config as oxfmtCfg, config$1 as oxlintCfg, resolveHostnameToSafeIp, safeFetch, safeFetchForDiscord, safeUrl, validateSafeUrl };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "distopia",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Library for Distopia.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"distopia",
|
|
@@ -40,9 +40,10 @@
|
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@types/node": "24.13.2",
|
|
42
42
|
"jsr": "0.14.3",
|
|
43
|
-
"oxfmt": "0.
|
|
44
|
-
"oxlint": "1.
|
|
45
|
-
"tsdown": "0.22.2"
|
|
43
|
+
"oxfmt": "0.56.0",
|
|
44
|
+
"oxlint": "1.71.0",
|
|
45
|
+
"tsdown": "0.22.2",
|
|
46
|
+
"zod": "4.4.3"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"bun": "1.3.13"
|