@nuxt/scripts 1.3.2 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/devtools-client/200.html +1 -1
- package/dist/devtools-client/404.html +1 -1
- package/dist/devtools-client/_nuxt/{DAubxma0.js → 8ltZ0o6d.js} +1 -1
- package/dist/devtools-client/_nuxt/{CBuBrHyL.js → B8_EBdhg.js} +1 -1
- package/dist/devtools-client/_nuxt/CC0GKROJ.js +1 -0
- package/dist/devtools-client/_nuxt/{DvC3yV2P.js → CMXzQxJZ.js} +1 -1
- package/dist/devtools-client/_nuxt/{xAZ2vgo3.js → CP7c-Js6.js} +4 -4
- package/dist/devtools-client/_nuxt/{DzbSCA_w.js → DTP8MRtW.js} +1 -1
- package/dist/devtools-client/_nuxt/{DgAtAWdT.js → Dkc7iTdR.js} +1 -1
- package/dist/devtools-client/_nuxt/Dl7W1ViV.js +28 -0
- package/dist/devtools-client/_nuxt/{WOtyQdps.js → KxC4Q3ev.js} +1 -1
- package/dist/devtools-client/_nuxt/builds/latest.json +1 -1
- package/dist/devtools-client/_nuxt/builds/meta/c38edf4f-387c-4148-a34a-ca78a5b40869.json +1 -0
- package/dist/devtools-client/_nuxt/{entry.CYUdJFoE.css → entry.9HJPJ6Hp.css} +1 -1
- package/dist/devtools-client/_nuxt/error-404.Bq2JQbJs.css +1 -0
- package/dist/devtools-client/_nuxt/error-500.5E_FOzlc.css +1 -0
- package/dist/devtools-client/_nuxt/{pages.BF1VnccH.css → pages.aU4WirOM.css} +1 -1
- package/dist/devtools-client/docs/index.html +1 -1
- package/dist/devtools-client/first-party/index.html +1 -1
- package/dist/devtools-client/index.html +1 -1
- package/dist/devtools-client/registry/index.html +1 -1
- package/dist/module.d.mts +1 -1
- package/dist/module.d.ts +1 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +263 -47
- package/dist/registry.mjs +2 -2
- package/dist/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +71 -21
- package/dist/runtime/components/GoogleMaps/useGoogleMapsResource.d.ts +2 -1
- package/dist/runtime/components/GoogleMaps/useGoogleMapsResource.js +39 -25
- package/dist/runtime/components/ScriptCarbonAds.vue +11 -3
- package/dist/runtime/components/ScriptCrisp.vue +4 -1
- package/dist/runtime/components/ScriptIntercom.vue +4 -1
- package/dist/runtime/components/ScriptLemonSqueezy.d.vue.ts +13 -13
- package/dist/runtime/components/ScriptLemonSqueezy.vue +30 -6
- package/dist/runtime/components/ScriptLemonSqueezy.vue.d.ts +13 -13
- package/dist/runtime/components/ScriptPayPalButtons.vue +8 -1
- package/dist/runtime/components/ScriptPayPalMessages.vue +6 -0
- package/dist/runtime/components/ScriptStripePricingTable.vue +2 -0
- package/dist/runtime/components/ScriptVimeoPlayer.vue +8 -1
- package/dist/runtime/composables/useScript.js +121 -44
- package/dist/runtime/composables/useScriptEventPage.js +36 -20
- package/dist/runtime/composables/useScriptTriggerIdleTimeout.js +19 -5
- package/dist/runtime/composables/useScriptTriggerInteraction.js +23 -10
- package/dist/runtime/composables/useScriptTriggerServiceWorker.js +21 -18
- package/dist/runtime/devtools-standalone-bridge.client.js +28 -3
- package/dist/runtime/npm-script-proxy.d.ts +7 -0
- package/dist/runtime/npm-script-proxy.js +46 -0
- package/dist/runtime/npm-script-stub.js +4 -5
- package/dist/runtime/registry/posthog.js +11 -3
- package/dist/runtime/registry/speedcurve.js +33 -4
- package/dist/runtime/registry/usercentrics.js +4 -21
- package/dist/runtime/registry/youtube-player.js +16 -5
- package/dist/runtime/server/bluesky-embed.js +15 -6
- package/dist/runtime/server/google-maps-geocode-proxy.js +10 -5
- package/dist/runtime/server/google-static-maps-proxy.js +20 -13
- package/dist/runtime/server/gravatar-proxy.js +48 -11
- package/dist/runtime/server/instagram-embed-asset.js +1 -1
- package/dist/runtime/server/instagram-embed.js +44 -94
- package/dist/runtime/server/proxy-handler.d.ts +2 -1
- package/dist/runtime/server/proxy-handler.js +185 -25
- package/dist/runtime/server/utils/cache-config.d.ts +14 -0
- package/dist/runtime/server/utils/cache-config.js +13 -0
- package/dist/runtime/server/utils/cached-upstream.d.ts +23 -2
- package/dist/runtime/server/utils/cached-upstream.js +212 -15
- package/dist/runtime/server/utils/image-proxy.d.ts +3 -2
- package/dist/runtime/server/utils/image-proxy.js +26 -16
- package/dist/runtime/server/utils/instagram-embed.d.ts +8 -0
- package/dist/runtime/server/utils/instagram-embed.js +155 -0
- package/dist/runtime/server/utils/network-host.d.ts +21 -0
- package/dist/runtime/server/utils/network-host.js +112 -0
- package/dist/runtime/server/utils/privacy.js +55 -19
- package/dist/runtime/server/utils/proxy-query.d.ts +2 -0
- package/dist/runtime/server/utils/proxy-query.js +10 -0
- package/dist/runtime/server/utils/sign.d.ts +1 -1
- package/dist/runtime/server/utils/sign.js +1 -1
- package/dist/runtime/server/utils/withSigning.d.ts +1 -1
- package/dist/runtime/server/utils/withSigning.js +3 -3
- package/dist/runtime/server/vercel-insights-sink.js +1 -1
- package/dist/runtime/server/x-embed.js +8 -4
- package/dist/runtime/unhead-features.d.ts +1 -0
- package/dist/runtime/unhead-features.js +3 -0
- package/dist/runtime/utils/abortable-promise.d.ts +8 -0
- package/dist/runtime/utils/abortable-promise.js +63 -0
- package/dist/runtime/utils/after-next-paint.d.ts +1 -1
- package/dist/runtime/utils/after-next-paint.js +16 -1
- package/dist/runtime/utils/usercentrics-consent.d.ts +10 -0
- package/dist/runtime/utils/usercentrics-consent.js +88 -0
- package/dist/runtime/utils/youtube-readiness.d.ts +7 -0
- package/dist/runtime/utils/youtube-readiness.js +45 -0
- package/dist/runtime/utils.d.ts +3 -1
- package/dist/runtime/utils.js +29 -0
- package/package.json +2 -1
- package/dist/devtools-client/_nuxt/BGGk5RYW.js +0 -1
- package/dist/devtools-client/_nuxt/N_y8tuWA.js +0 -28
- package/dist/devtools-client/_nuxt/builds/meta/e2926f35-ae20-45a0-929d-54d3919623c3.json +0 -1
- package/dist/devtools-client/_nuxt/error-404.CIn5oEzb.css +0 -1
- package/dist/devtools-client/_nuxt/error-500.B1YZl8g8.css +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream,
|
|
2
|
-
import { useNitroApp, useRuntimeConfig } from "
|
|
1
|
+
import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream, sendStream, setResponseHeader, setResponseStatus } from "#nuxt-scripts/h3";
|
|
2
|
+
import { useNitroApp, useRuntimeConfig } from "#nuxt-scripts/nitro";
|
|
3
3
|
import { matchDomain } from "./utils/match-domain.js";
|
|
4
|
+
import { closePublicNetworkDispatcher, createPublicNetworkDispatcher, isPrivateNetworkResolutionError, isPublicNetworkHostname } from "./utils/network-host.js";
|
|
4
5
|
import {
|
|
5
6
|
anonymizeIP,
|
|
6
7
|
mergePrivacy,
|
|
@@ -12,7 +13,29 @@ import {
|
|
|
12
13
|
} from "./utils/privacy.js";
|
|
13
14
|
const COMPRESSION_RE = /gzip|deflate|br|compress|base64/i;
|
|
14
15
|
const CLIENT_HINT_VERSION_RE = /;v="(\d+)\.[^"]*"/g;
|
|
15
|
-
const
|
|
16
|
+
const MAX_TRANSFORM_BODY_SIZE = 2 * 1024 * 1024;
|
|
17
|
+
const UPSTREAM_TIMEOUT_MS = 15e3;
|
|
18
|
+
const SKIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
19
|
+
"alt-svc",
|
|
20
|
+
"clear-site-data",
|
|
21
|
+
"connection",
|
|
22
|
+
"content-encoding",
|
|
23
|
+
"content-length",
|
|
24
|
+
"keep-alive",
|
|
25
|
+
"nel",
|
|
26
|
+
"proxy-authenticate",
|
|
27
|
+
"proxy-authorization",
|
|
28
|
+
"report-to",
|
|
29
|
+
"reporting-endpoints",
|
|
30
|
+
"set-cookie",
|
|
31
|
+
"set-cookie2",
|
|
32
|
+
"strict-transport-security",
|
|
33
|
+
"te",
|
|
34
|
+
"trailer",
|
|
35
|
+
"transfer-encoding",
|
|
36
|
+
"upgrade",
|
|
37
|
+
"www-authenticate"
|
|
38
|
+
]);
|
|
16
39
|
export const SKIP_REQUEST_HEADERS = /* @__PURE__ */ new Set([
|
|
17
40
|
"connection",
|
|
18
41
|
"keep-alive",
|
|
@@ -23,12 +46,101 @@ export const SKIP_REQUEST_HEADERS = /* @__PURE__ */ new Set([
|
|
|
23
46
|
"transfer-encoding",
|
|
24
47
|
"upgrade"
|
|
25
48
|
]);
|
|
49
|
+
async function readTransformBody(event) {
|
|
50
|
+
const contentLength = Number(getHeaders(event)["content-length"] || 0);
|
|
51
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_TRANSFORM_BODY_SIZE) {
|
|
52
|
+
throw createError({ statusCode: 413, statusMessage: "Proxy request body too large" });
|
|
53
|
+
}
|
|
54
|
+
const stream = getRequestWebStream(event);
|
|
55
|
+
if (!stream)
|
|
56
|
+
return void 0;
|
|
57
|
+
const reader = stream.getReader();
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let total = 0;
|
|
60
|
+
try {
|
|
61
|
+
while (true) {
|
|
62
|
+
const { done, value } = await reader.read();
|
|
63
|
+
if (done)
|
|
64
|
+
break;
|
|
65
|
+
if (!value)
|
|
66
|
+
continue;
|
|
67
|
+
total += value.byteLength;
|
|
68
|
+
if (total > MAX_TRANSFORM_BODY_SIZE) {
|
|
69
|
+
try {
|
|
70
|
+
await reader.cancel("Proxy request body too large");
|
|
71
|
+
} catch {
|
|
72
|
+
}
|
|
73
|
+
throw createError({ statusCode: 413, statusMessage: "Proxy request body too large" });
|
|
74
|
+
}
|
|
75
|
+
chunks.push(value);
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
reader.releaseLock();
|
|
79
|
+
}
|
|
80
|
+
const body = new Uint8Array(total);
|
|
81
|
+
let offset = 0;
|
|
82
|
+
for (const chunk of chunks) {
|
|
83
|
+
body.set(chunk, offset);
|
|
84
|
+
offset += chunk.byteLength;
|
|
85
|
+
}
|
|
86
|
+
return new TextDecoder().decode(body);
|
|
87
|
+
}
|
|
88
|
+
export function withResponseBodyIdleTimeout(body, timeoutMs, onTimeout) {
|
|
89
|
+
const reader = body.getReader();
|
|
90
|
+
let stopped = false;
|
|
91
|
+
let timeoutId;
|
|
92
|
+
const clearIdleTimeout = () => {
|
|
93
|
+
if (timeoutId !== void 0) {
|
|
94
|
+
clearTimeout(timeoutId);
|
|
95
|
+
timeoutId = void 0;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
return new ReadableStream({
|
|
99
|
+
async pull(controller) {
|
|
100
|
+
timeoutId = setTimeout(() => {
|
|
101
|
+
stopped = true;
|
|
102
|
+
const error = createError({
|
|
103
|
+
statusCode: 504,
|
|
104
|
+
statusMessage: "Gateway Timeout",
|
|
105
|
+
message: "Upstream response body timed out"
|
|
106
|
+
});
|
|
107
|
+
onTimeout();
|
|
108
|
+
controller.error(error);
|
|
109
|
+
void reader.cancel(error).catch((cancelError) => {
|
|
110
|
+
Object.assign(error, { cause: cancelError });
|
|
111
|
+
});
|
|
112
|
+
}, timeoutMs);
|
|
113
|
+
const result = await reader.read().catch((error) => {
|
|
114
|
+
clearIdleTimeout();
|
|
115
|
+
if (!stopped)
|
|
116
|
+
controller.error(error);
|
|
117
|
+
return void 0;
|
|
118
|
+
});
|
|
119
|
+
clearIdleTimeout();
|
|
120
|
+
if (!result || stopped)
|
|
121
|
+
return;
|
|
122
|
+
if (result.done) {
|
|
123
|
+
stopped = true;
|
|
124
|
+
controller.close();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
controller.enqueue(result.value);
|
|
128
|
+
},
|
|
129
|
+
async cancel(reason) {
|
|
130
|
+
stopped = true;
|
|
131
|
+
clearIdleTimeout();
|
|
132
|
+
await reader.cancel(reason);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
26
136
|
function stripQueryFingerprinting(query, privacy) {
|
|
27
137
|
const stripped = stripPayloadFingerprinting(query, privacy);
|
|
28
138
|
const params = new URLSearchParams();
|
|
29
139
|
for (const [key, value] of Object.entries(stripped)) {
|
|
30
|
-
|
|
31
|
-
|
|
140
|
+
const values = Array.isArray(value) ? value : [value];
|
|
141
|
+
for (const item of values) {
|
|
142
|
+
if (item !== void 0 && item !== null)
|
|
143
|
+
params.append(key, typeof item === "object" ? JSON.stringify(item) : String(item));
|
|
32
144
|
}
|
|
33
145
|
}
|
|
34
146
|
return { queryString: params.toString(), stripped };
|
|
@@ -61,6 +173,13 @@ export default defineEventHandler(async (event) => {
|
|
|
61
173
|
message: `No domain in proxy path: ${path}`
|
|
62
174
|
});
|
|
63
175
|
}
|
|
176
|
+
if (!isPublicNetworkHostname(domain)) {
|
|
177
|
+
log("[proxy] Rejected local or non-public target:", domain);
|
|
178
|
+
throw createError({
|
|
179
|
+
statusCode: 403,
|
|
180
|
+
statusMessage: "Local network targets are not allowed"
|
|
181
|
+
});
|
|
182
|
+
}
|
|
64
183
|
let perScriptInput;
|
|
65
184
|
for (const [configDomain, privacyInput] of Object.entries(domainPrivacy)) {
|
|
66
185
|
if (matchDomain(domain, configDomain)) {
|
|
@@ -168,7 +287,7 @@ export default defineEventHandler(async (event) => {
|
|
|
168
287
|
if (!shouldTransformBody) {
|
|
169
288
|
passthroughBody = true;
|
|
170
289
|
} else if (transformableBodyType === "form") {
|
|
171
|
-
const formBody = await
|
|
290
|
+
const formBody = await readTransformBody(event);
|
|
172
291
|
rawBody = formBody;
|
|
173
292
|
if (formBody != null) {
|
|
174
293
|
const params = new URLSearchParams(formBody);
|
|
@@ -196,7 +315,18 @@ export default defineEventHandler(async (event) => {
|
|
|
196
315
|
body = transformed.toString();
|
|
197
316
|
}
|
|
198
317
|
} else {
|
|
199
|
-
|
|
318
|
+
const jsonBody = await readTransformBody(event);
|
|
319
|
+
if (jsonBody !== void 0) {
|
|
320
|
+
try {
|
|
321
|
+
rawBody = JSON.parse(jsonBody);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
throw createError({
|
|
324
|
+
statusCode: 400,
|
|
325
|
+
statusMessage: "Invalid JSON proxy request body",
|
|
326
|
+
cause: error
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
200
330
|
if (Array.isArray(rawBody)) {
|
|
201
331
|
body = rawBody.map(
|
|
202
332
|
(item) => item && typeof item === "object" && !Array.isArray(item) ? stripPayloadFingerprinting(item, privacy) : item
|
|
@@ -209,7 +339,7 @@ export default defineEventHandler(async (event) => {
|
|
|
209
339
|
}
|
|
210
340
|
}
|
|
211
341
|
const nitro = useNitroApp();
|
|
212
|
-
await nitro.hooks
|
|
342
|
+
await nitro.hooks?.callHook?.("nuxt-scripts:proxy", {
|
|
213
343
|
timestamp: Date.now(),
|
|
214
344
|
path: event.path,
|
|
215
345
|
targetUrl,
|
|
@@ -233,51 +363,81 @@ export default defineEventHandler(async (event) => {
|
|
|
233
363
|
const timeoutId = setTimeout(() => {
|
|
234
364
|
timedOut = true;
|
|
235
365
|
controller.abort();
|
|
236
|
-
},
|
|
366
|
+
}, UPSTREAM_TIMEOUT_MS);
|
|
237
367
|
let fetchBody;
|
|
238
|
-
if (passthroughBody) {
|
|
368
|
+
if (passthroughBody && originalHeaders["content-length"] !== "0") {
|
|
239
369
|
fetchBody = getRequestWebStream(event);
|
|
240
370
|
} else if (body !== void 0) {
|
|
241
371
|
fetchBody = transformableBodyType === "json" ? JSON.stringify(body) : String(body);
|
|
242
372
|
}
|
|
243
373
|
let response;
|
|
374
|
+
let network;
|
|
244
375
|
try {
|
|
245
|
-
|
|
376
|
+
network = await createPublicNetworkDispatcher();
|
|
377
|
+
const requestInit = {
|
|
246
378
|
method: method || "GET",
|
|
247
379
|
headers,
|
|
248
380
|
body: fetchBody,
|
|
249
381
|
credentials: "omit",
|
|
250
382
|
// Don't send cookies to third parties
|
|
251
383
|
signal: controller.signal,
|
|
252
|
-
|
|
253
|
-
duplex:
|
|
254
|
-
}
|
|
384
|
+
redirect: "manual",
|
|
385
|
+
duplex: fetchBody instanceof ReadableStream ? "half" : void 0
|
|
386
|
+
};
|
|
387
|
+
response = await network.fetch(targetUrl, requestInit);
|
|
388
|
+
clearTimeout(timeoutId);
|
|
255
389
|
} catch (err) {
|
|
390
|
+
clearTimeout(timeoutId);
|
|
391
|
+
await closePublicNetworkDispatcher(network, err);
|
|
256
392
|
log("[proxy] Upstream error:", err);
|
|
393
|
+
const blockedPrivateNetwork = isPrivateNetworkResolutionError(err);
|
|
257
394
|
throw createError({
|
|
258
|
-
statusCode: timedOut ? 504 : 502,
|
|
259
|
-
statusMessage: timedOut ? "Gateway Timeout" : "Bad Gateway",
|
|
260
|
-
message:
|
|
395
|
+
statusCode: blockedPrivateNetwork ? 403 : timedOut ? 504 : 502,
|
|
396
|
+
statusMessage: blockedPrivateNetwork ? "Local network targets are not allowed" : timedOut ? "Gateway Timeout" : "Bad Gateway",
|
|
397
|
+
message: "Proxy upstream request failed",
|
|
261
398
|
cause: err,
|
|
262
399
|
data: {
|
|
263
400
|
errorName: err?.name,
|
|
264
401
|
errorCode: timedOut ? "TIMEOUT" : err?.code
|
|
265
402
|
}
|
|
266
403
|
});
|
|
267
|
-
} finally {
|
|
268
|
-
clearTimeout(timeoutId);
|
|
269
404
|
}
|
|
270
405
|
log("[proxy] Response:", response.status, response.statusText);
|
|
406
|
+
if (response.status >= 300 && response.status < 400 && response.status !== 304) {
|
|
407
|
+
clearTimeout(timeoutId);
|
|
408
|
+
const redirectError = createError({
|
|
409
|
+
statusCode: 502,
|
|
410
|
+
statusMessage: "Unsafe upstream redirect",
|
|
411
|
+
message: "Proxy upstream returned a redirect that was not followed"
|
|
412
|
+
});
|
|
413
|
+
await response.body?.cancel(redirectError).catch((cancelError) => Object.assign(redirectError, { cleanupError: cancelError }));
|
|
414
|
+
await closePublicNetworkDispatcher(network, redirectError);
|
|
415
|
+
throw redirectError;
|
|
416
|
+
}
|
|
417
|
+
const responseConnectionHeaders = new Set(
|
|
418
|
+
(response.headers.get("connection") || "").split(",").map((header) => header.trim().toLowerCase()).filter(Boolean)
|
|
419
|
+
);
|
|
271
420
|
response.headers.forEach((value, key) => {
|
|
272
|
-
|
|
421
|
+
const lowerKey = key.toLowerCase();
|
|
422
|
+
if (!SKIP_RESPONSE_HEADERS.has(lowerKey) && !responseConnectionHeaders.has(lowerKey)) {
|
|
273
423
|
setResponseHeader(event, key, value);
|
|
274
424
|
}
|
|
275
425
|
});
|
|
426
|
+
setResponseHeader(event, "Content-Security-Policy", "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'");
|
|
427
|
+
setResponseHeader(event, "X-Content-Type-Options", "nosniff");
|
|
276
428
|
setResponseStatus(event, response.status, response.statusText);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
429
|
+
if (!response.body) {
|
|
430
|
+
await closePublicNetworkDispatcher(network);
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const guardedBody = withResponseBodyIdleTimeout(response.body, UPSTREAM_TIMEOUT_MS, () => controller.abort());
|
|
434
|
+
let streamError;
|
|
435
|
+
try {
|
|
436
|
+
return await sendStream(event, guardedBody);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
streamError = error;
|
|
439
|
+
throw error;
|
|
440
|
+
} finally {
|
|
441
|
+
await closePublicNetworkDispatcher(network, streamError);
|
|
281
442
|
}
|
|
282
|
-
return Buffer.from(await response.arrayBuffer());
|
|
283
443
|
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Dedicated Nitro storage mount used by Nuxt Scripts' upstream caches. */
|
|
2
|
+
export declare const NUXT_SCRIPTS_CACHE_BASE = "nuxt-scripts-cache";
|
|
3
|
+
/**
|
|
4
|
+
* Conservative defaults for the in-process fallback. Applications can replace
|
|
5
|
+
* this mount with Redis, KV, filesystem, or their own storage configuration.
|
|
6
|
+
*/
|
|
7
|
+
export declare const NUXT_SCRIPTS_CACHE_MAX_ENTRIES = 500;
|
|
8
|
+
export declare const NUXT_SCRIPTS_CACHE_MAX_SIZE: number;
|
|
9
|
+
export declare const NUXT_SCRIPTS_CACHE_MAX_ENTRY_SIZE: number;
|
|
10
|
+
interface NitroOptionsWithStorage {
|
|
11
|
+
storage?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export declare function ensureNuxtScriptsCacheStorage(nitroOptions: NitroOptionsWithStorage): void;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const NUXT_SCRIPTS_CACHE_BASE = "nuxt-scripts-cache";
|
|
2
|
+
export const NUXT_SCRIPTS_CACHE_MAX_ENTRIES = 500;
|
|
3
|
+
export const NUXT_SCRIPTS_CACHE_MAX_SIZE = 32 * 1024 * 1024;
|
|
4
|
+
export const NUXT_SCRIPTS_CACHE_MAX_ENTRY_SIZE = 8 * 1024 * 1024;
|
|
5
|
+
export function ensureNuxtScriptsCacheStorage(nitroOptions) {
|
|
6
|
+
nitroOptions.storage ||= {};
|
|
7
|
+
nitroOptions.storage[NUXT_SCRIPTS_CACHE_BASE] ||= {
|
|
8
|
+
driver: "lru-cache",
|
|
9
|
+
max: NUXT_SCRIPTS_CACHE_MAX_ENTRIES,
|
|
10
|
+
maxSize: NUXT_SCRIPTS_CACHE_MAX_SIZE,
|
|
11
|
+
maxEntrySize: NUXT_SCRIPTS_CACHE_MAX_ENTRY_SIZE
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -33,15 +33,36 @@ export interface CachedBinaryFetchOptions {
|
|
|
33
33
|
redirect?: 'follow' | 'manual';
|
|
34
34
|
ignoreResponseError?: boolean;
|
|
35
35
|
}
|
|
36
|
+
export interface CachedBinaryFetchConfig {
|
|
37
|
+
/** Validate the initial URL and every redirect before requesting it. */
|
|
38
|
+
allowUrl?: (url: URL) => boolean;
|
|
39
|
+
/** Reject invalid payload types before reading or caching the response body. */
|
|
40
|
+
allowContentType?: (contentType: string) => boolean;
|
|
41
|
+
maxRedirects?: number;
|
|
42
|
+
/** Maximum decoded response size stored in cache. Defaults to 10 MiB. */
|
|
43
|
+
maxResponseBytes?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface CachedJsonFetchConfig<T> {
|
|
46
|
+
/** Validate the initial URL and every redirect before requesting it. */
|
|
47
|
+
allowUrl: (url: URL) => boolean;
|
|
48
|
+
maxRedirects?: number;
|
|
49
|
+
responseType?: 'json' | 'text';
|
|
50
|
+
contentTypePrefixes?: string[];
|
|
51
|
+
/** Maximum decoded response size stored in cache. Defaults to 2 MiB. */
|
|
52
|
+
maxResponseBytes?: number;
|
|
53
|
+
/** Runs before caching. Throw to reject an invalid upstream response. */
|
|
54
|
+
validateResponse?: (data: T) => void;
|
|
55
|
+
}
|
|
36
56
|
export interface CachedBinaryResult extends CachedBinaryResponse {
|
|
37
57
|
body: Buffer;
|
|
38
58
|
status: number;
|
|
39
59
|
}
|
|
60
|
+
export declare function isSafeHttpsUrl(url: URL): boolean;
|
|
40
61
|
/**
|
|
41
62
|
* Cache upstream binary/image fetches. Returns a helper that restores the
|
|
42
63
|
* response body as a Buffer so the handler can pipe it straight to the client.
|
|
43
64
|
*/
|
|
44
|
-
export declare function createCachedBinaryFetch(name: string, maxAge: number): (url: string, opts?: CachedBinaryFetchOptions) => Promise<CachedBinaryResult>;
|
|
65
|
+
export declare function createCachedBinaryFetch(name: string, maxAge: number, config?: CachedBinaryFetchConfig): (url: string, opts?: CachedBinaryFetchOptions) => Promise<CachedBinaryResult>;
|
|
45
66
|
/**
|
|
46
67
|
* Cache upstream JSON/text fetches. `getKey` is caller-controlled so handlers
|
|
47
68
|
* can normalize on whichever inner params identify the resource (tweet ID,
|
|
@@ -50,7 +71,7 @@ export declare function createCachedBinaryFetch(name: string, maxAge: number): (
|
|
|
50
71
|
*/
|
|
51
72
|
export declare function createCachedJsonFetch<T>(name: string, maxAge: number, getKey: (url: string, opts?: {
|
|
52
73
|
headers?: Record<string, string>;
|
|
53
|
-
}) => string): (url: string, opts?: {
|
|
74
|
+
}) => string, config: CachedJsonFetchConfig<T>): (url: string, opts?: {
|
|
54
75
|
headers?: Record<string, string>;
|
|
55
76
|
timeout?: number;
|
|
56
77
|
}) => Promise<T>;
|
|
@@ -1,25 +1,201 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
|
-
import {
|
|
3
|
-
import { $fetch } from "ofetch";
|
|
2
|
+
import { createFetch } from "ofetch";
|
|
4
3
|
import { hash } from "ohash";
|
|
5
|
-
|
|
4
|
+
import { defineCachedFunction } from "#nuxt-scripts/nitro";
|
|
5
|
+
import { NUXT_SCRIPTS_CACHE_BASE } from "./cache-config.js";
|
|
6
|
+
import { closePublicNetworkDispatcher, createPublicNetworkDispatcher, isPublicNetworkHostname } from "./network-host.js";
|
|
7
|
+
const DEFAULT_BINARY_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
const DEFAULT_JSON_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
export function isSafeHttpsUrl(url) {
|
|
10
|
+
return url.protocol === "https:" && !url.username && !url.password && (!url.port || url.port === "443") && isPublicNetworkHostname(url.hostname);
|
|
11
|
+
}
|
|
12
|
+
function upstreamError(message, statusCode, statusMessage, cause) {
|
|
13
|
+
return Object.assign(new Error(message, cause === void 0 ? void 0 : { cause }), {
|
|
14
|
+
statusCode,
|
|
15
|
+
statusMessage
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function resolveMaxResponseBytes(value, fallback) {
|
|
19
|
+
const maxBytes = value ?? fallback;
|
|
20
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
|
21
|
+
throw new TypeError("maxResponseBytes must be a non-negative safe integer");
|
|
22
|
+
return maxBytes;
|
|
23
|
+
}
|
|
24
|
+
async function cancelBody(body, error) {
|
|
25
|
+
if (!body)
|
|
26
|
+
return;
|
|
27
|
+
await body.cancel(error).catch((cancelError) => {
|
|
28
|
+
if (error) {
|
|
29
|
+
Object.assign(error, { cause: cancelError });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
throw cancelError;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async function readBoundedResponseBody(body, options) {
|
|
36
|
+
if (!body)
|
|
37
|
+
return new Uint8Array();
|
|
38
|
+
const reader = body.getReader();
|
|
39
|
+
const chunks = [];
|
|
40
|
+
let size = 0;
|
|
41
|
+
let timeoutId;
|
|
42
|
+
const timeoutError = upstreamError("Upstream response body timed out", 504, "Gateway Timeout");
|
|
43
|
+
const timeout = options.timeoutMs > 0 ? new Promise((_resolve, reject) => {
|
|
44
|
+
timeoutId = setTimeout(() => {
|
|
45
|
+
reject(timeoutError);
|
|
46
|
+
void reader.cancel(timeoutError).catch((cancelError) => Object.assign(timeoutError, { cause: cancelError }));
|
|
47
|
+
}, options.timeoutMs);
|
|
48
|
+
}) : void 0;
|
|
49
|
+
try {
|
|
50
|
+
while (true) {
|
|
51
|
+
const result = await (timeout ? Promise.race([reader.read(), timeout]) : reader.read());
|
|
52
|
+
if (result.done)
|
|
53
|
+
break;
|
|
54
|
+
size += result.value.byteLength;
|
|
55
|
+
if (size > options.maxBytes) {
|
|
56
|
+
const error = upstreamError(
|
|
57
|
+
`Upstream response exceeded ${options.maxBytes} bytes`,
|
|
58
|
+
502,
|
|
59
|
+
"Upstream response too large"
|
|
60
|
+
);
|
|
61
|
+
await reader.cancel(error).catch((cancelError) => Object.assign(error, { cause: cancelError }));
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
chunks.push(result.value);
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
if (timeoutId !== void 0)
|
|
68
|
+
clearTimeout(timeoutId);
|
|
69
|
+
}
|
|
70
|
+
const data = new Uint8Array(size);
|
|
71
|
+
let offset = 0;
|
|
72
|
+
for (const chunk of chunks) {
|
|
73
|
+
data.set(chunk, offset);
|
|
74
|
+
offset += chunk.byteLength;
|
|
75
|
+
}
|
|
76
|
+
return data;
|
|
77
|
+
}
|
|
78
|
+
async function rejectResponse(response, error) {
|
|
79
|
+
await cancelBody(response._data, error);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
function parseUpstreamUrl(url) {
|
|
83
|
+
try {
|
|
84
|
+
return new URL(url);
|
|
85
|
+
} catch (cause) {
|
|
86
|
+
throw upstreamError("Upstream URL is invalid", 400, "Invalid upstream URL", cause);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function assertAllowedUrl(url, allowUrl) {
|
|
90
|
+
if (allowUrl && !allowUrl(url))
|
|
91
|
+
throw upstreamError("Upstream URL is not allowed", 403, "Upstream URL not allowed");
|
|
92
|
+
}
|
|
93
|
+
function resolveRedirect(response, currentUrl, redirectCount, policy) {
|
|
94
|
+
if (response.status < 300 || response.status >= 400 || response.status === 304)
|
|
95
|
+
return;
|
|
96
|
+
const location = response.headers.get("location");
|
|
97
|
+
if (!location)
|
|
98
|
+
throw upstreamError("Upstream redirect has no Location header", 502, "Invalid upstream redirect");
|
|
99
|
+
if (redirectCount >= (policy.maxRedirects ?? 5))
|
|
100
|
+
throw upstreamError("Upstream redirect limit exceeded", 502, "Too many upstream redirects");
|
|
101
|
+
let nextUrl;
|
|
102
|
+
try {
|
|
103
|
+
nextUrl = new URL(location, currentUrl);
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
throw upstreamError("Upstream redirect URL is invalid", 502, "Invalid upstream redirect", cause);
|
|
106
|
+
}
|
|
107
|
+
assertAllowedUrl(nextUrl, policy.allowUrl);
|
|
108
|
+
return nextUrl;
|
|
109
|
+
}
|
|
110
|
+
async function fetchBoundedUpstream(url, options) {
|
|
111
|
+
let currentUrl = parseUpstreamUrl(url);
|
|
112
|
+
assertAllowedUrl(currentUrl, options.allowUrl);
|
|
113
|
+
if (options.redirect === "follow" && !options.allowUrl)
|
|
114
|
+
throw new Error("Automatic redirects require an allowUrl redirect policy");
|
|
115
|
+
const network = await createPublicNetworkDispatcher();
|
|
116
|
+
let primaryError;
|
|
117
|
+
try {
|
|
118
|
+
const safeFetch = createFetch({ fetch: network.fetch });
|
|
119
|
+
for (let redirectCount = 0; ; redirectCount++) {
|
|
120
|
+
const response = await safeFetch.raw(currentUrl.toString(), {
|
|
121
|
+
responseType: "stream",
|
|
122
|
+
timeout: options.timeoutMs,
|
|
123
|
+
redirect: "manual",
|
|
124
|
+
ignoreResponseError: true,
|
|
125
|
+
headers: options.headers
|
|
126
|
+
});
|
|
127
|
+
if (options.redirect === "follow") {
|
|
128
|
+
let nextUrl;
|
|
129
|
+
try {
|
|
130
|
+
nextUrl = resolveRedirect(response, currentUrl, redirectCount, options);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
await rejectResponse(response, error);
|
|
133
|
+
}
|
|
134
|
+
if (nextUrl) {
|
|
135
|
+
await cancelBody(response._data);
|
|
136
|
+
currentUrl = nextUrl;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!options.ignoreResponseError && response.status >= 400 && response.status < 600) {
|
|
141
|
+
await rejectResponse(response, upstreamError(
|
|
142
|
+
`Upstream request failed with status ${response.status}`,
|
|
143
|
+
response.status,
|
|
144
|
+
response.statusText || "Upstream request failed"
|
|
145
|
+
));
|
|
146
|
+
}
|
|
147
|
+
const contentType = response.headers.get("content-type");
|
|
148
|
+
const isRedirect = response.status >= 300 && response.status < 400 && response.status !== 304;
|
|
149
|
+
if (!isRedirect && (options.allowContentType || options.contentTypePrefixes?.length)) {
|
|
150
|
+
const normalizedContentType = contentType?.split(";", 1)[0]?.trim().toLowerCase();
|
|
151
|
+
if (options.contentTypePrefixes?.length && (!normalizedContentType || !options.contentTypePrefixes.some((prefix) => normalizedContentType.startsWith(prefix)))) {
|
|
152
|
+
await rejectResponse(
|
|
153
|
+
response,
|
|
154
|
+
upstreamError("Upstream response content type is not allowed", 415, "Unsupported upstream content type")
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (options.allowContentType && (!normalizedContentType || !options.allowContentType(normalizedContentType))) {
|
|
158
|
+
await rejectResponse(
|
|
159
|
+
response,
|
|
160
|
+
upstreamError("Upstream response content type is not allowed", 415, "Unsupported upstream content type")
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const data = await readBoundedResponseBody(response._data, {
|
|
165
|
+
maxBytes: options.maxResponseBytes,
|
|
166
|
+
timeoutMs: options.timeoutMs
|
|
167
|
+
});
|
|
168
|
+
return { contentType, data, status: response.status };
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
primaryError = error;
|
|
172
|
+
throw error;
|
|
173
|
+
} finally {
|
|
174
|
+
await closePublicNetworkDispatcher(network, primaryError);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export function createCachedBinaryFetch(name, maxAge, config = {}) {
|
|
178
|
+
const maxResponseBytes = resolveMaxResponseBytes(config.maxResponseBytes, DEFAULT_BINARY_MAX_RESPONSE_BYTES);
|
|
6
179
|
const cached = defineCachedFunction(
|
|
7
180
|
async (url, opts) => {
|
|
8
|
-
const response = await
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
181
|
+
const response = await fetchBoundedUpstream(url, {
|
|
182
|
+
allowContentType: config.allowContentType,
|
|
183
|
+
allowUrl: config.allowUrl,
|
|
184
|
+
headers: opts?.headers,
|
|
12
185
|
ignoreResponseError: opts?.ignoreResponseError ?? false,
|
|
13
|
-
|
|
186
|
+
maxRedirects: config.maxRedirects,
|
|
187
|
+
maxResponseBytes,
|
|
188
|
+
redirect: opts?.redirect ?? (config.allowUrl ? "follow" : "manual"),
|
|
189
|
+
timeoutMs: opts?.timeout ?? 1e4
|
|
14
190
|
});
|
|
15
|
-
const data = response._data;
|
|
16
191
|
return {
|
|
17
|
-
base64: data ? Buffer.from(data).toString("base64") : "",
|
|
18
|
-
contentType: response.
|
|
192
|
+
base64: response.data.byteLength ? Buffer.from(response.data).toString("base64") : "",
|
|
193
|
+
contentType: response.contentType,
|
|
19
194
|
status: response.status
|
|
20
195
|
};
|
|
21
196
|
},
|
|
22
197
|
{
|
|
198
|
+
base: NUXT_SCRIPTS_CACHE_BASE,
|
|
23
199
|
name,
|
|
24
200
|
maxAge,
|
|
25
201
|
swr: true,
|
|
@@ -49,15 +225,36 @@ export function createCachedBinaryFetch(name, maxAge) {
|
|
|
49
225
|
};
|
|
50
226
|
};
|
|
51
227
|
}
|
|
52
|
-
export function createCachedJsonFetch(name, maxAge, getKey) {
|
|
228
|
+
export function createCachedJsonFetch(name, maxAge, getKey, config) {
|
|
229
|
+
const maxResponseBytes = resolveMaxResponseBytes(config.maxResponseBytes, DEFAULT_JSON_MAX_RESPONSE_BYTES);
|
|
53
230
|
return defineCachedFunction(
|
|
54
231
|
async (url, opts) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
232
|
+
const response = await fetchBoundedUpstream(url, {
|
|
233
|
+
allowUrl: config.allowUrl,
|
|
234
|
+
contentTypePrefixes: config.contentTypePrefixes,
|
|
235
|
+
headers: opts?.headers,
|
|
236
|
+
ignoreResponseError: false,
|
|
237
|
+
maxRedirects: config.maxRedirects,
|
|
238
|
+
maxResponseBytes,
|
|
239
|
+
redirect: "follow",
|
|
240
|
+
timeoutMs: opts?.timeout ?? 1e4
|
|
58
241
|
});
|
|
242
|
+
const text = new TextDecoder().decode(response.data);
|
|
243
|
+
let data;
|
|
244
|
+
if (config.responseType === "text") {
|
|
245
|
+
data = text;
|
|
246
|
+
} else {
|
|
247
|
+
try {
|
|
248
|
+
data = JSON.parse(text);
|
|
249
|
+
} catch (cause) {
|
|
250
|
+
throw upstreamError("Upstream response is not valid JSON", 502, "Invalid upstream response", cause);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
config.validateResponse?.(data);
|
|
254
|
+
return data;
|
|
59
255
|
},
|
|
60
256
|
{
|
|
257
|
+
base: NUXT_SCRIPTS_CACHE_BASE,
|
|
61
258
|
name,
|
|
62
259
|
maxAge,
|
|
63
260
|
swr: true,
|
|
@@ -3,8 +3,9 @@ export interface ImageProxyConfig {
|
|
|
3
3
|
accept?: string;
|
|
4
4
|
userAgent?: string;
|
|
5
5
|
cacheMaxAge?: number;
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/** Allowed response Content-Type prefixes. SVG is always rejected as active content. */
|
|
7
|
+
contentTypePrefixes?: string[];
|
|
8
|
+
/** Follow redirects after applying the same URL allowlist to every hop. */
|
|
8
9
|
followRedirects?: boolean;
|
|
9
10
|
/** Decode & in URL query parameter */
|
|
10
11
|
decodeAmpersands?: boolean;
|