@nuxt/scripts 1.0.0-beta.5 → 1.0.0-beta.7

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.
@@ -6,7 +6,7 @@ export const PostHogOptions = object({
6
6
  region: optional(union([literal("us"), literal("eu")])),
7
7
  apiHost: optional(string()),
8
8
  autocapture: optional(boolean()),
9
- capturePageview: optional(boolean()),
9
+ capturePageview: optional(union([boolean(), literal("history_change")])),
10
10
  capturePageleave: optional(boolean()),
11
11
  disableSessionRecording: optional(boolean()),
12
12
  config: optional(record(string(), any()))
@@ -58,7 +58,7 @@ export function useScriptPostHog(_options) {
58
58
  };
59
59
  if (typeof options?.autocapture === "boolean")
60
60
  config.autocapture = options.autocapture;
61
- if (typeof options?.capturePageview === "boolean")
61
+ if (typeof options?.capturePageview === "boolean" || options?.capturePageview === "history_change")
62
62
  config.capture_pageview = options.capturePageview;
63
63
  if (typeof options?.capturePageleave === "boolean")
64
64
  config.capture_pageleave = options.capturePageleave;
@@ -1,8 +1,6 @@
1
- import { defineEventHandler, getHeaders, getRequestIP, readBody, getQuery, setResponseHeader, createError } from "h3";
1
+ import { defineEventHandler, getHeaders, getRequestIP, readBody, getRequestWebStream, getQuery, setResponseHeader, createError } from "h3";
2
2
  import { useRuntimeConfig } from "#imports";
3
- import { useStorage, useNitroApp } from "nitropack/runtime";
4
- import { hash } from "ohash";
5
- import { rewriteScriptUrls } from "../utils/pure.js";
3
+ import { useNitroApp } from "nitropack/runtime";
6
4
  import {
7
5
  SENSITIVE_HEADERS,
8
6
  anonymizeIP,
@@ -32,7 +30,7 @@ export default defineEventHandler(async (event) => {
32
30
  statusMessage: "First-party proxy not configured"
33
31
  });
34
32
  }
35
- const { routes, privacy: globalPrivacy, routePrivacy, cacheTtl = 3600, debug = import.meta.dev } = proxyConfig;
33
+ const { routes, privacy: globalPrivacy, routePrivacy, debug = import.meta.dev } = proxyConfig;
36
34
  const path = event.path;
37
35
  const log = debug ? (message, ...args) => {
38
36
  console.debug(message, ...args);
@@ -67,6 +65,12 @@ export default defineEventHandler(async (event) => {
67
65
  const perScriptResolved = resolvePrivacy(perScriptInput ?? true);
68
66
  const privacy = globalPrivacy !== void 0 ? mergePrivacy(perScriptResolved, globalPrivacy) : perScriptResolved;
69
67
  const anyPrivacy = privacy.ip || privacy.userAgent || privacy.language || privacy.screen || privacy.timezone || privacy.hardware;
68
+ const originalHeaders = getHeaders(event);
69
+ const contentType = originalHeaders["content-type"] || "";
70
+ const compressionParam = new URL(event.path, "http://localhost").searchParams.get("compression");
71
+ const isBinaryBody = Boolean(
72
+ originalHeaders["content-encoding"] || contentType.includes("octet-stream") || compressionParam && /gzip|deflate|br|compress|base64/i.test(compressionParam)
73
+ );
70
74
  let targetPath = path.slice(matchedPrefix.length);
71
75
  if (targetPath && !targetPath.startsWith("/")) {
72
76
  targetPath = "/" + targetPath;
@@ -80,13 +84,16 @@ export default defineEventHandler(async (event) => {
80
84
  targetUrl = strippedQuery ? `${basePath}?${strippedQuery}` : basePath;
81
85
  }
82
86
  }
83
- const originalHeaders = getHeaders(event);
84
87
  const headers = {};
85
88
  for (const [key, value] of Object.entries(originalHeaders)) {
86
89
  if (!value) continue;
87
90
  const lowerKey = key.toLowerCase();
88
91
  if (SENSITIVE_HEADERS.includes(lowerKey)) continue;
89
- if (anyPrivacy && lowerKey === "content-length") continue;
92
+ if (lowerKey === "content-length") {
93
+ if (anyPrivacy && !isBinaryBody) continue;
94
+ headers[lowerKey] = value;
95
+ continue;
96
+ }
90
97
  if (lowerKey === "x-forwarded-for" || lowerKey === "x-real-ip" || lowerKey === "forwarded" || lowerKey === "cf-connecting-ip" || lowerKey === "true-client-ip" || lowerKey === "x-client-ip" || lowerKey === "x-cluster-client-ip") {
91
98
  if (privacy.ip) continue;
92
99
  headers[lowerKey] = value;
@@ -125,47 +132,58 @@ export default defineEventHandler(async (event) => {
125
132
  }
126
133
  let body;
127
134
  let rawBody;
128
- const contentType = originalHeaders["content-type"] || "";
135
+ let passthroughBody = false;
129
136
  const method = event.method?.toUpperCase();
130
137
  const originalQuery = getQuery(event);
131
- if (method === "POST" || method === "PUT" || method === "PATCH") {
132
- rawBody = await readBody(event);
133
- if (anyPrivacy && rawBody) {
134
- if (typeof rawBody === "object") {
135
- body = stripPayloadFingerprinting(rawBody, privacy);
136
- } else if (typeof rawBody === "string") {
137
- if (rawBody.startsWith("{") || rawBody.startsWith("[")) {
138
- let parsed = null;
139
- try {
140
- parsed = JSON.parse(rawBody);
141
- } catch {
142
- }
143
- if (parsed && typeof parsed === "object") {
144
- body = stripPayloadFingerprinting(parsed, privacy);
138
+ const isWriteMethod = method === "POST" || method === "PUT" || method === "PATCH";
139
+ if (isWriteMethod) {
140
+ if (isBinaryBody || !anyPrivacy) {
141
+ passthroughBody = true;
142
+ } else {
143
+ rawBody = await readBody(event);
144
+ if (rawBody != null) {
145
+ if (Array.isArray(rawBody)) {
146
+ body = rawBody.map(
147
+ (item) => item && typeof item === "object" && !Array.isArray(item) ? stripPayloadFingerprinting(item, privacy) : item
148
+ );
149
+ } else if (typeof rawBody === "object") {
150
+ body = stripPayloadFingerprinting(rawBody, privacy);
151
+ } else if (typeof rawBody === "string") {
152
+ if (rawBody.startsWith("{") || rawBody.startsWith("[")) {
153
+ let parsed = null;
154
+ try {
155
+ parsed = JSON.parse(rawBody);
156
+ } catch {
157
+ }
158
+ if (Array.isArray(parsed)) {
159
+ body = parsed.map(
160
+ (item) => item && typeof item === "object" && !Array.isArray(item) ? stripPayloadFingerprinting(item, privacy) : item
161
+ );
162
+ } else if (parsed && typeof parsed === "object") {
163
+ body = stripPayloadFingerprinting(parsed, privacy);
164
+ } else {
165
+ body = rawBody;
166
+ }
167
+ } else if (contentType.includes("application/x-www-form-urlencoded")) {
168
+ const params = new URLSearchParams(rawBody);
169
+ const obj = {};
170
+ params.forEach((value, key) => {
171
+ obj[key] = value;
172
+ });
173
+ const stripped = stripPayloadFingerprinting(obj, privacy);
174
+ const stringified = {};
175
+ for (const [k, v] of Object.entries(stripped)) {
176
+ if (v === void 0 || v === null) continue;
177
+ stringified[k] = typeof v === "string" ? v : JSON.stringify(v);
178
+ }
179
+ body = new URLSearchParams(stringified).toString();
145
180
  } else {
146
181
  body = rawBody;
147
182
  }
148
- } else if (contentType.includes("application/x-www-form-urlencoded")) {
149
- const params = new URLSearchParams(rawBody);
150
- const obj = {};
151
- params.forEach((value, key) => {
152
- obj[key] = value;
153
- });
154
- const stripped = stripPayloadFingerprinting(obj, privacy);
155
- const stringified = {};
156
- for (const [k, v] of Object.entries(stripped)) {
157
- if (v === void 0 || v === null) continue;
158
- stringified[k] = typeof v === "string" ? v : JSON.stringify(v);
159
- }
160
- body = new URLSearchParams(stringified).toString();
161
183
  } else {
162
184
  body = rawBody;
163
185
  }
164
- } else {
165
- body = rawBody;
166
186
  }
167
- } else {
168
- body = rawBody;
169
187
  }
170
188
  }
171
189
  await nitro.hooks.callHook("nuxt-scripts:proxy", {
@@ -174,29 +192,38 @@ export default defineEventHandler(async (event) => {
174
192
  targetUrl,
175
193
  method: method || "GET",
176
194
  privacy,
195
+ passthroughBody,
177
196
  original: {
178
197
  headers: { ...originalHeaders },
179
198
  query: originalQuery,
180
- body: rawBody ?? null
199
+ body: passthroughBody ? "<passthrough>" : rawBody ?? null
181
200
  },
182
201
  stripped: {
183
202
  headers,
184
203
  query: anyPrivacy ? stripPayloadFingerprinting(originalQuery, privacy) : originalQuery,
185
- body: body ?? null
204
+ body: passthroughBody ? "<passthrough>" : body ?? null
186
205
  }
187
206
  });
188
207
  log("[proxy] Fetching:", targetUrl);
189
208
  const controller = new AbortController();
190
209
  const timeoutId = setTimeout(() => controller.abort(), 15e3);
210
+ let fetchBody;
211
+ if (passthroughBody) {
212
+ fetchBody = getRequestWebStream(event);
213
+ } else if (body !== void 0) {
214
+ fetchBody = typeof body === "string" ? body : JSON.stringify(body);
215
+ }
191
216
  let response;
192
217
  try {
193
218
  response = await fetch(targetUrl, {
194
219
  method: method || "GET",
195
220
  headers,
196
- body: body ? typeof body === "string" ? body : JSON.stringify(body) : void 0,
221
+ body: fetchBody,
197
222
  credentials: "omit",
198
223
  // Don't send cookies to third parties
199
- signal: controller.signal
224
+ signal: controller.signal,
225
+ // @ts-expect-error Node fetch supports duplex for streaming request bodies
226
+ duplex: passthroughBody ? "half" : void 0
200
227
  });
201
228
  } catch (err) {
202
229
  clearTimeout(timeoutId);
@@ -225,22 +252,7 @@ export default defineEventHandler(async (event) => {
225
252
  const responseContentType = response.headers.get("content-type") || "";
226
253
  const isTextContent = responseContentType.includes("text") || responseContentType.includes("javascript") || responseContentType.includes("json");
227
254
  if (isTextContent) {
228
- let content = await response.text();
229
- if (responseContentType.includes("javascript") && proxyConfig?.rewrites?.length) {
230
- const cacheKey = `nuxt-scripts:proxy:${hash(targetUrl + JSON.stringify(proxyConfig.rewrites))}`;
231
- const storage = useStorage("cache");
232
- const cached = await storage.getItem(cacheKey);
233
- if (cached && typeof cached === "string") {
234
- log("[proxy] Serving rewritten script from cache");
235
- content = cached;
236
- } else {
237
- content = rewriteScriptUrls(content, proxyConfig.rewrites);
238
- await storage.setItem(cacheKey, content, { ttl: cacheTtl });
239
- log("[proxy] Rewrote URLs in JavaScript response and cached");
240
- }
241
- setResponseHeader(event, "cache-control", `public, max-age=${cacheTtl}, stale-while-revalidate=${cacheTtl * 2}`);
242
- }
243
- return content;
255
+ return await response.text();
244
256
  }
245
257
  return Buffer.from(await response.arrayBuffer());
246
258
  });
@@ -7,7 +7,3 @@ export interface ProxyRewrite {
7
7
  /** Local path to rewrite to (e.g., '/_scripts/c/ga/g/collect') */
8
8
  to: string;
9
9
  }
10
- /**
11
- * Rewrite URLs in script content based on proxy config.
12
- */
13
- export declare function rewriteScriptUrls(content: string, rewrites: ProxyRewrite[]): string;
@@ -1,67 +0,0 @@
1
- import { parseURL, joinURL } from "ufo";
2
- export function rewriteScriptUrls(content, rewrites) {
3
- let result = content;
4
- const literalRegex = /(['"`])(.*?)\1/g;
5
- for (const { from, to } of rewrites) {
6
- const isSuffixMatch = from.startsWith(".");
7
- const fromSlashIdx = from.indexOf("/");
8
- const fromHost = fromSlashIdx > 0 ? from.slice(0, fromSlashIdx) : from;
9
- const fromPath = fromSlashIdx > 0 ? from.slice(fromSlashIdx) : "";
10
- result = result.replace(literalRegex, (match, quote, inner) => {
11
- if (!inner.includes(fromHost)) return match;
12
- const url = parseURL(inner);
13
- let shouldRewrite = false;
14
- let rewriteSuffix = "";
15
- if (url.host) {
16
- const hostMatches = isSuffixMatch ? url.host.endsWith(fromHost) : url.host === fromHost;
17
- if (hostMatches) {
18
- const fullPath = url.pathname + (url.search || "") + (url.hash || "");
19
- if (fromPath && fullPath.startsWith(fromPath)) {
20
- shouldRewrite = true;
21
- rewriteSuffix = fullPath.slice(fromPath.length);
22
- } else if (!fromPath) {
23
- shouldRewrite = true;
24
- rewriteSuffix = fullPath;
25
- }
26
- }
27
- } else if (inner.startsWith("//")) {
28
- const hostPart = inner.slice(2).split("/")[0];
29
- const hostMatches = isSuffixMatch ? hostPart?.endsWith(fromHost) ?? false : hostPart === fromHost;
30
- if (hostMatches) {
31
- const remainder = inner.slice(2 + (hostPart?.length ?? 0));
32
- if (fromPath && remainder.startsWith(fromPath)) {
33
- shouldRewrite = true;
34
- rewriteSuffix = remainder.slice(fromPath.length);
35
- } else if (!fromPath) {
36
- shouldRewrite = true;
37
- rewriteSuffix = remainder;
38
- }
39
- }
40
- } else if (fromPath && (inner.startsWith(from) || isSuffixMatch && inner.includes(from))) {
41
- const domainEnd = inner.indexOf(from) + from.length;
42
- const nextChar = inner[domainEnd];
43
- if (!nextChar || nextChar === "/" || nextChar === "?" || nextChar === "#") {
44
- shouldRewrite = true;
45
- rewriteSuffix = inner.slice(domainEnd);
46
- }
47
- }
48
- if (shouldRewrite) {
49
- const rewritten = rewriteSuffix === "/" || rewriteSuffix.startsWith("?") || rewriteSuffix.startsWith("#") ? to + rewriteSuffix : joinURL(to, rewriteSuffix);
50
- return "self.location.origin+" + quote + rewritten + quote;
51
- }
52
- return match;
53
- });
54
- }
55
- const gaRewrite = rewrites.find((r) => r.from.includes("google-analytics.com/g/collect"));
56
- if (gaRewrite) {
57
- result = result.replace(
58
- /"https:\/\/"\+\(.*?\)\+"\.google-analytics\.com\/g\/collect"/g,
59
- `self.location.origin+"${gaRewrite.to}"`
60
- );
61
- result = result.replace(
62
- /"https:\/\/"\+\(.*?\)\+"\.analytics\.google\.com\/g\/collect"/g,
63
- `self.location.origin+"${gaRewrite.to}"`
64
- );
65
- }
66
- return result;
67
- }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nuxt/scripts",
3
3
  "type": "module",
4
- "version": "1.0.0-beta.5",
4
+ "version": "1.0.0-beta.7",
5
5
  "description": "Load third-party scripts with better performance, privacy and DX in Nuxt Apps.",
6
6
  "author": {
7
7
  "website": "https://harlanzw.com",
@@ -96,7 +96,7 @@
96
96
  "magic-string": "^0.30.21",
97
97
  "ofetch": "^1.5.1",
98
98
  "ohash": "^2.0.11",
99
- "oxc-parser": "^0.115.0",
99
+ "oxc-parser": "^0.116.0",
100
100
  "oxc-walker": "^0.7.0",
101
101
  "pathe": "^2.0.3",
102
102
  "pkg-types": "^2.3.0",
@@ -113,7 +113,7 @@
113
113
  "@nuxt/module-builder": "^1.0.2",
114
114
  "@nuxt/test-utils": "^4.0.0",
115
115
  "@nuxtjs/partytown": "^2.0.0",
116
- "@paypal/paypal-js": "^9.3.0",
116
+ "@paypal/paypal-js": "^9.4.0",
117
117
  "@types/semver": "^7.7.1",
118
118
  "@typescript-eslint/typescript-estree": "^8.56.1",
119
119
  "@vue/test-utils": "^2.4.6",
@@ -121,18 +121,18 @@
121
121
  "changelogen": "^0.6.2",
122
122
  "eslint": "^10.0.2",
123
123
  "eslint-plugin-n": "^17.24.0",
124
- "happy-dom": "^20.7.0",
124
+ "happy-dom": "^20.8.3",
125
125
  "knitwork": "^1.3.0",
126
126
  "nuxt": "^4.3.1",
127
127
  "playwright-core": "^1.58.2",
128
- "posthog-js": "^1.354.3",
129
- "shiki": "^3.23.0",
128
+ "posthog-js": "^1.357.1",
129
+ "shiki": "^4.0.1",
130
130
  "typescript": "5.9.3",
131
131
  "vitest": "^4.0.18",
132
132
  "vue": "^3.5.29",
133
133
  "vue-router": "^5.0.3",
134
134
  "vue-tsc": "^3.2.5",
135
- "@nuxt/scripts": "1.0.0-beta.5"
135
+ "@nuxt/scripts": "1.0.0-beta.7"
136
136
  },
137
137
  "resolutions": {
138
138
  "@nuxt/scripts": "workspace:*"