@netlify/plugin-nextjs 4.30.0 → 4.30.1

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.
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.writeEdgeFunctions = exports.writeDevEdgeFunction = exports.cleanupEdgeFunctions = exports.loadAppPathRoutesManifest = exports.loadMiddlewareManifest = void 0;
6
+ exports.writeEdgeFunctions = exports.writeRscDataEdgeFunction = exports.writeDevEdgeFunction = exports.cleanupEdgeFunctions = exports.loadPrerenderManifest = exports.loadAppPathRoutesManifest = exports.loadMiddlewareManifest = exports.isAppDirRoute = void 0;
7
7
  const fs_1 = require("fs");
8
8
  const path_1 = require("path");
9
9
  const chalk_1 = require("chalk");
@@ -17,10 +17,14 @@ const maybeLoadJson = (path) => {
17
17
  return (0, fs_extra_1.readJson)(path);
18
18
  }
19
19
  };
20
+ const isAppDirRoute = (route, appPathRoutesManifest) => Boolean(appPathRoutesManifest) && Object.values(appPathRoutesManifest).includes(route);
21
+ exports.isAppDirRoute = isAppDirRoute;
20
22
  const loadMiddlewareManifest = (netlifyConfig) => maybeLoadJson((0, path_1.resolve)(netlifyConfig.build.publish, 'server', 'middleware-manifest.json'));
21
23
  exports.loadMiddlewareManifest = loadMiddlewareManifest;
22
24
  const loadAppPathRoutesManifest = (netlifyConfig) => maybeLoadJson((0, path_1.resolve)(netlifyConfig.build.publish, 'app-path-routes-manifest.json'));
23
25
  exports.loadAppPathRoutesManifest = loadAppPathRoutesManifest;
26
+ const loadPrerenderManifest = (netlifyConfig) => (0, fs_extra_1.readJSON)((0, path_1.resolve)(netlifyConfig.build.publish, 'prerender-manifest.json'));
27
+ exports.loadPrerenderManifest = loadPrerenderManifest;
24
28
  /**
25
29
  * Convert the Next middleware name into a valid Edge Function name
26
30
  */
@@ -110,9 +114,8 @@ const getMiddlewareBundle = async ({ edgeFunctionDefinition, netlifyConfig, }) =
110
114
  };
111
115
  const getEdgeTemplatePath = (file) => (0, path_1.join)(__dirname, '..', '..', 'src', 'templates', 'edge', file);
112
116
  const copyEdgeSourceFile = ({ file, target, edgeFunctionDir, }) => fs_1.promises.copyFile(getEdgeTemplatePath(file), (0, path_1.join)(edgeFunctionDir, target !== null && target !== void 0 ? target : file));
113
- const writeEdgeFunction = async ({ edgeFunctionDefinition, edgeFunctionRoot, netlifyConfig, pageRegexMap, appPathRoutesManifest = {}, nextConfig, cache, }) => {
114
- const name = sanitizeName(edgeFunctionDefinition.name);
115
- const edgeFunctionDir = (0, path_1.join)(edgeFunctionRoot, name);
117
+ const writeEdgeFunction = async ({ edgeFunctionDefinition, edgeFunctionRoot, netlifyConfig, functionName, matchers = [], middleware = false, }) => {
118
+ const edgeFunctionDir = (0, path_1.join)(edgeFunctionRoot, functionName);
116
119
  const bundle = await getMiddlewareBundle({
117
120
  edgeFunctionDefinition,
118
121
  netlifyConfig,
@@ -121,37 +124,30 @@ const writeEdgeFunction = async ({ edgeFunctionDefinition, edgeFunctionRoot, net
121
124
  await fs_1.promises.writeFile((0, path_1.join)(edgeFunctionDir, 'bundle.js'), bundle);
122
125
  await copyEdgeSourceFile({
123
126
  edgeFunctionDir,
124
- file: 'runtime.ts',
127
+ file: middleware ? 'middleware-runtime.ts' : 'function-runtime.ts',
125
128
  target: 'index.ts',
126
129
  });
127
- const matchers = [];
130
+ if (middleware) {
131
+ // Functions don't have complex matchers, so we can rely on the Netlify matcher
132
+ await (0, fs_extra_1.writeJson)((0, path_1.join)(edgeFunctionDir, 'matchers.json'), matchers);
133
+ }
134
+ };
135
+ const generateEdgeFunctionMiddlewareMatchers = ({ edgeFunctionDefinition, nextConfig, }) => {
128
136
  // The v1 middleware manifest has a single regexp, but the v2 has an array of matchers
129
137
  if ('regexp' in edgeFunctionDefinition) {
130
- matchers.push({ regexp: edgeFunctionDefinition.regexp });
138
+ return [{ regexp: edgeFunctionDefinition.regexp }];
131
139
  }
132
- else if (nextConfig.i18n) {
133
- matchers.push(...edgeFunctionDefinition.matchers.map((matcher) => ({
140
+ if (nextConfig.i18n) {
141
+ return edgeFunctionDefinition.matchers.map((matcher) => ({
134
142
  ...matcher,
135
143
  regexp: (0, matchers_1.makeLocaleOptional)(matcher.regexp),
136
- })));
144
+ }));
137
145
  }
138
- else {
139
- matchers.push(...edgeFunctionDefinition.matchers);
140
- }
141
- // If the EF matches a page, it's an app dir page so needs a matcher too
142
- // The object will be empty if appDir isn't enabled in the Next config
143
- if (pageRegexMap && edgeFunctionDefinition.page in appPathRoutesManifest) {
144
- const regexp = pageRegexMap.get(appPathRoutesManifest[edgeFunctionDefinition.page]);
145
- if (regexp) {
146
- matchers.push({ regexp });
147
- }
148
- }
149
- await (0, fs_extra_1.writeJson)((0, path_1.join)(edgeFunctionDir, 'matchers.json'), matchers);
150
- // We add a defintion for each matching path
151
- return matchers.map((matcher) => {
152
- const pattern = (0, matchers_1.stripLookahead)(matcher.regexp);
153
- return { function: name, pattern, name: edgeFunctionDefinition.name, cache };
154
- });
146
+ return edgeFunctionDefinition.matchers;
147
+ };
148
+ const middlewareMatcherToEdgeFunctionDefinition = (matcher, name, cache) => {
149
+ const pattern = (0, matchers_1.transformCaptureGroups)((0, matchers_1.stripLookahead)(matcher.regexp));
150
+ return { function: name, pattern, name, cache };
155
151
  };
156
152
  const cleanupEdgeFunctions = ({ INTERNAL_EDGE_FUNCTIONS_SRC = '.netlify/edge-functions', }) => (0, fs_extra_1.emptyDir)(INTERNAL_EDGE_FUNCTIONS_SRC);
157
153
  exports.cleanupEdgeFunctions = cleanupEdgeFunctions;
@@ -175,9 +171,57 @@ const writeDevEdgeFunction = async ({ INTERNAL_EDGE_FUNCTIONS_SRC = '.netlify/ed
175
171
  await copyEdgeSourceFile({ edgeFunctionDir, file: 'next-dev.js', target: 'index.js' });
176
172
  };
177
173
  exports.writeDevEdgeFunction = writeDevEdgeFunction;
174
+ /**
175
+ * Writes an edge function that routes RSC data requests to the `.rsc` route
176
+ */
177
+ const writeRscDataEdgeFunction = async ({ prerenderManifest, appPathRoutesManifest, }) => {
178
+ if (!prerenderManifest || !appPathRoutesManifest) {
179
+ return [];
180
+ }
181
+ const staticAppdirRoutes = [];
182
+ for (const [path, route] of Object.entries(prerenderManifest.routes)) {
183
+ if ((0, exports.isAppDirRoute)(route.srcRoute, appPathRoutesManifest)) {
184
+ staticAppdirRoutes.push(path, route.dataRoute);
185
+ }
186
+ }
187
+ const dynamicAppDirRoutes = [];
188
+ for (const [path, route] of Object.entries(prerenderManifest.dynamicRoutes)) {
189
+ if ((0, exports.isAppDirRoute)(path, appPathRoutesManifest)) {
190
+ dynamicAppDirRoutes.push(route.routeRegex, route.dataRouteRegex);
191
+ }
192
+ }
193
+ if (staticAppdirRoutes.length === 0 && dynamicAppDirRoutes.length === 0) {
194
+ return [];
195
+ }
196
+ const edgeFunctionDir = (0, path_1.resolve)('.netlify', 'edge-functions', 'rsc-data');
197
+ await (0, fs_extra_1.ensureDir)(edgeFunctionDir);
198
+ await copyEdgeSourceFile({ edgeFunctionDir, file: 'rsc-data.ts' });
199
+ return [
200
+ ...staticAppdirRoutes.map((path) => ({
201
+ function: 'rsc-data',
202
+ name: 'RSC data routing',
203
+ path,
204
+ })),
205
+ ...dynamicAppDirRoutes.map((pattern) => ({
206
+ function: 'rsc-data',
207
+ name: 'RSC data routing',
208
+ pattern,
209
+ })),
210
+ ];
211
+ };
212
+ exports.writeRscDataEdgeFunction = writeRscDataEdgeFunction;
213
+ const getEdgeFunctionPatternForPage = ({ edgeFunctionDefinition, pageRegexMap, appPathRoutesManifest, }) => {
214
+ // We don't just use the matcher from the edge function definition, because it doesn't handle trailing slashes
215
+ var _a;
216
+ // appDir functions have a name that _isn't_ the route name, but rather the route with `/page` appended
217
+ const regexp = pageRegexMap.get((_a = appPathRoutesManifest === null || appPathRoutesManifest === void 0 ? void 0 : appPathRoutesManifest[edgeFunctionDefinition.page]) !== null && _a !== void 0 ? _a : edgeFunctionDefinition.page);
218
+ // If we need to fall back to the matcher, we need to add an optional trailing slash
219
+ return regexp !== null && regexp !== void 0 ? regexp : edgeFunctionDefinition.matchers[0].regexp.replace(/([^/])\$$/, '$1/?$');
220
+ };
178
221
  /**
179
222
  * Writes Edge Functions for the Next middleware
180
223
  */
224
+ // eslint-disable-next-line max-lines-per-function
181
225
  const writeEdgeFunctions = async ({ netlifyConfig, routesManifest, }) => {
182
226
  var _a;
183
227
  const manifest = {
@@ -192,6 +236,7 @@ const writeEdgeFunctions = async ({ netlifyConfig, routesManifest, }) => {
192
236
  const usesAppDir = (_a = nextConfig.experimental) === null || _a === void 0 ? void 0 : _a.appDir;
193
237
  await (0, fs_extra_1.copy)(getEdgeTemplatePath('../edge-shared'), (0, path_1.join)(edgeFunctionRoot, 'edge-shared'));
194
238
  await (0, fs_extra_1.writeJSON)((0, path_1.join)(edgeFunctionRoot, 'edge-shared', 'nextConfig.json'), nextConfig);
239
+ await (0, fs_extra_1.copy)((0, path_1.join)(publish, 'prerender-manifest.json'), (0, path_1.join)(edgeFunctionRoot, 'edge-shared', 'prerender-manifest.json'));
195
240
  if (!(0, destr_1.default)(process.env.NEXT_DISABLE_EDGE_IMAGES) &&
196
241
  !(0, destr_1.default)(process.env.NEXT_DISABLE_NETLIFY_EDGE) &&
197
242
  !(0, destr_1.default)(process.env.DISABLE_IPX)) {
@@ -207,6 +252,11 @@ const writeEdgeFunctions = async ({ netlifyConfig, routesManifest, }) => {
207
252
  });
208
253
  }
209
254
  if (!(0, destr_1.default)(process.env.NEXT_DISABLE_NETLIFY_EDGE)) {
255
+ const rscFunctions = await (0, exports.writeRscDataEdgeFunction)({
256
+ prerenderManifest: await (0, exports.loadPrerenderManifest)(netlifyConfig),
257
+ appPathRoutesManifest: await (0, exports.loadAppPathRoutesManifest)(netlifyConfig),
258
+ });
259
+ manifest.functions.push(...rscFunctions);
210
260
  const middlewareManifest = await (0, exports.loadMiddlewareManifest)(netlifyConfig);
211
261
  if (!middlewareManifest) {
212
262
  console.error("Couldn't find the middleware manifest");
@@ -216,16 +266,22 @@ const writeEdgeFunctions = async ({ netlifyConfig, routesManifest, }) => {
216
266
  for (const middleware of middlewareManifest.sortedMiddleware) {
217
267
  usesEdge = true;
218
268
  const edgeFunctionDefinition = middlewareManifest.middleware[middleware];
219
- const functionDefinitions = await writeEdgeFunction({
269
+ const functionName = sanitizeName(edgeFunctionDefinition.name);
270
+ const matchers = generateEdgeFunctionMiddlewareMatchers({
220
271
  edgeFunctionDefinition,
221
272
  edgeFunctionRoot,
222
- netlifyConfig,
223
273
  nextConfig,
224
274
  });
225
- manifest.functions.push(...functionDefinitions);
275
+ await writeEdgeFunction({
276
+ edgeFunctionDefinition,
277
+ edgeFunctionRoot,
278
+ netlifyConfig,
279
+ functionName,
280
+ matchers,
281
+ middleware: true,
282
+ });
283
+ manifest.functions.push(...matchers.map((matcher) => middlewareMatcherToEdgeFunctionDefinition(matcher, functionName)));
226
284
  }
227
- // Older versions of the manifest format don't have the functions field
228
- // No, the version field was not incremented
229
285
  if (typeof middlewareManifest.functions === 'object') {
230
286
  // When using the app dir, we also need to check if the EF matches a page
231
287
  const appPathRoutesManifest = await (0, exports.loadAppPathRoutesManifest)(netlifyConfig);
@@ -235,17 +291,25 @@ const writeEdgeFunctions = async ({ netlifyConfig, routesManifest, }) => {
235
291
  ]));
236
292
  for (const edgeFunctionDefinition of Object.values(middlewareManifest.functions)) {
237
293
  usesEdge = true;
238
- const functionDefinitions = await writeEdgeFunction({
294
+ const functionName = sanitizeName(edgeFunctionDefinition.name);
295
+ await writeEdgeFunction({
239
296
  edgeFunctionDefinition,
240
297
  edgeFunctionRoot,
241
298
  netlifyConfig,
299
+ functionName,
300
+ });
301
+ const pattern = getEdgeFunctionPatternForPage({
302
+ edgeFunctionDefinition,
242
303
  pageRegexMap,
243
304
  appPathRoutesManifest,
244
- nextConfig,
305
+ });
306
+ manifest.functions.push({
307
+ function: functionName,
308
+ name: edgeFunctionDefinition.name,
309
+ pattern,
245
310
  // cache: "manual" is currently experimental, so we restrict it to sites that use experimental appDir
246
311
  cache: usesAppDir ? 'manual' : undefined,
247
312
  });
248
- manifest.functions.push(...functionDefinitions);
249
313
  }
250
314
  }
251
315
  if (usesEdge) {
@@ -13,6 +13,7 @@ const p_limit_1 = __importDefault(require("p-limit"));
13
13
  const pathe_1 = require("pathe");
14
14
  const slash_1 = __importDefault(require("slash"));
15
15
  const constants_1 = require("../constants");
16
+ const edge_1 = require("./edge");
16
17
  const utils_1 = require("./utils");
17
18
  const TEST_ROUTE = /(|\/)\[[^/]+?](\/|\.html|$)/;
18
19
  const SOURCE_FILE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx'];
@@ -75,16 +76,18 @@ const moveStaticPages = async ({ netlifyConfig, target, i18n, basePath, }) => {
75
76
  // Load the middleware manifest so we can check if a file matches it before moving
76
77
  const middlewarePaths = await (0, exports.getMiddleware)(netlifyConfig.build.publish);
77
78
  const middleware = middlewarePaths.map((path) => path.slice(1));
78
- const prerenderManifest = await (0, fs_extra_1.readJson)((0, pathe_1.join)(netlifyConfig.build.publish, 'prerender-manifest.json'));
79
+ const prerenderManifest = await (0, edge_1.loadPrerenderManifest)(netlifyConfig);
79
80
  const { redirects, rewrites } = await (0, fs_extra_1.readJson)((0, pathe_1.join)(netlifyConfig.build.publish, 'routes-manifest.json'));
80
81
  const isrFiles = new Set();
81
82
  const shortRevalidateRoutes = [];
82
- Object.entries(prerenderManifest.routes).forEach(([route, { initialRevalidateSeconds }]) => {
83
+ Object.entries(prerenderManifest.routes).forEach(([route, ssgRoute]) => {
84
+ const { initialRevalidateSeconds } = ssgRoute;
85
+ const trimmedPath = route === '/' ? 'index' : route.slice(1);
83
86
  if (initialRevalidateSeconds) {
84
87
  // Find all files used by ISR routes
85
- const trimmedPath = route === '/' ? 'index' : route.slice(1);
86
88
  isrFiles.add(`${trimmedPath}.html`);
87
89
  isrFiles.add(`${trimmedPath}.json`);
90
+ isrFiles.add(`${trimmedPath}.rsc`);
88
91
  if (initialRevalidateSeconds < constants_1.MINIMUM_REVALIDATE_SECONDS) {
89
92
  shortRevalidateRoutes.push({ Route: route, Revalidate: initialRevalidateSeconds });
90
93
  }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.makeLocaleOptional = exports.stripLookahead = void 0;
3
+ exports.makeLocaleOptional = exports.transformCaptureGroups = exports.stripLookahead = void 0;
4
4
  const regexp_tree_1 = require("regexp-tree");
5
5
  // The Go regexp lib doesn't support lookaheads, so we need to remove them
6
6
  const stripLookahead = (regex) => {
@@ -27,6 +27,9 @@ const stripLookahead = (regex) => {
27
27
  }
28
28
  };
29
29
  exports.stripLookahead = stripLookahead;
30
+ // The Go regexp lib has alternative syntax for named capture groups
31
+ const transformCaptureGroups = (regex) => regex.replace(/\(\?<\w+>/, '(');
32
+ exports.transformCaptureGroups = transformCaptureGroups;
30
33
  const LOCALIZED_REGEX_PREFIX = '(?:\\/(_next\\/data\\/[^/]{1,}))?(?:\\/([^/.]{1,}))';
31
34
  const OPTIONAL_REGEX_PREFIX = '(?:\\/(_next\\/data\\/[^/]{1,}))?(?:\\/([^/.]{1,}))?';
32
35
  // Make the locale section of the matcher regex optional
@@ -6,6 +6,7 @@ const fs_extra_1 = require("fs-extra");
6
6
  const outdent_1 = require("outdent");
7
7
  const pathe_1 = require("pathe");
8
8
  const constants_1 = require("../constants");
9
+ const edge_1 = require("./edge");
9
10
  const files_1 = require("./files");
10
11
  const utils_1 = require("./utils");
11
12
  const matchesMiddleware = (middleware, route) => middleware.some((middlewarePath) => route.startsWith(middlewarePath));
@@ -52,7 +53,7 @@ const generateStaticRedirects = ({ netlifyConfig, nextConfig: { i18n, basePath }
52
53
  };
53
54
  exports.generateStaticRedirects = generateStaticRedirects;
54
55
  /**
55
- * Routes that match middleware need to always use the SSR function
56
+ * Routes that match origin middleware need to always use the SSR function
56
57
  * This generates a rewrite for every middleware in every locale, both with and without a splat
57
58
  */
58
59
  const generateMiddlewareRewrites = ({ basePath, middleware, i18n, buildId }) => {
@@ -79,11 +80,11 @@ const generateMiddlewareRewrites = ({ basePath, middleware, i18n, buildId }) =>
79
80
  // Flatten the array of arrays. Can't use flatMap as it might be 2 levels deep
80
81
  .flat(2));
81
82
  };
82
- const generateStaticIsrRewrites = ({ staticRouteEntries, basePath, i18n, buildId, middleware, }) => {
83
+ const generateStaticIsrRewrites = ({ staticRouteEntries, basePath, i18n, buildId, middleware, appPathRoutes, }) => {
83
84
  const staticIsrRoutesThatMatchMiddleware = [];
84
85
  const staticRoutePaths = new Set();
85
86
  const staticIsrRewrites = [];
86
- staticRouteEntries.forEach(([route, { initialRevalidateSeconds }]) => {
87
+ staticRouteEntries.forEach(([route, { initialRevalidateSeconds, dataRoute, srcRoute }]) => {
87
88
  if ((0, utils_1.isApiRoute)(route) || (0, utils_1.is404Route)(route, i18n)) {
88
89
  return;
89
90
  }
@@ -92,30 +93,40 @@ const generateStaticIsrRewrites = ({ staticRouteEntries, basePath, i18n, buildId
92
93
  // These can be ignored, as they're static files handled by the CDN
93
94
  return;
94
95
  }
96
+ // appDir routes are a different format, so we need to handle them differently
97
+ const isAppDir = (0, edge_1.isAppDirRoute)(srcRoute, appPathRoutes);
95
98
  // The default locale is served from the root, not the localised path
96
- if ((i18n === null || i18n === void 0 ? void 0 : i18n.defaultLocale) && route.startsWith(`/${i18n.defaultLocale}/`)) {
97
- route = route.slice(i18n.defaultLocale.length + 1);
99
+ if ((i18n === null || i18n === void 0 ? void 0 : i18n.defaultLocale) && (route.startsWith(`/${i18n.defaultLocale}/`) || route === `/${i18n.defaultLocale}`)) {
100
+ route = route.slice(i18n.defaultLocale.length + 1) || '/';
98
101
  staticRoutePaths.add(route);
99
102
  if (matchesMiddleware(middleware, route)) {
100
103
  staticIsrRoutesThatMatchMiddleware.push(route);
101
104
  }
102
105
  staticIsrRewrites.push(...(0, utils_1.redirectsForNextRouteWithData)({
103
106
  route,
104
- dataRoute: (0, utils_1.routeToDataRoute)(route, buildId, i18n.defaultLocale),
107
+ dataRoute: isAppDir ? dataRoute : (0, utils_1.routeToDataRoute)(route, buildId, i18n.defaultLocale),
105
108
  basePath,
106
109
  to: constants_1.ODB_FUNCTION_PATH,
107
110
  force: true,
108
111
  }));
109
112
  }
110
113
  else if (matchesMiddleware(middleware, route)) {
111
- // Routes that match middleware can't use the ODB
114
+ // Routes that match origin middleware can't use the ODB. Edge middleware will always return false
112
115
  staticIsrRoutesThatMatchMiddleware.push(route);
113
116
  }
114
117
  else {
115
118
  // ISR routes use the ODB handler
116
119
  staticIsrRewrites.push(
117
120
  // No i18n, because the route is already localized
118
- ...(0, utils_1.redirectsForNextRoute)({ route, basePath, to: constants_1.ODB_FUNCTION_PATH, force: true, buildId, i18n: null }));
121
+ ...(0, utils_1.redirectsForNextRoute)({
122
+ route,
123
+ basePath,
124
+ to: constants_1.ODB_FUNCTION_PATH,
125
+ force: true,
126
+ buildId,
127
+ dataRoute: isAppDir ? dataRoute : null,
128
+ i18n: null,
129
+ }));
119
130
  }
120
131
  });
121
132
  return {
@@ -127,7 +138,7 @@ const generateStaticIsrRewrites = ({ staticRouteEntries, basePath, i18n, buildId
127
138
  /**
128
139
  * Generate rewrites for all dynamic routes
129
140
  */
130
- const generateDynamicRewrites = ({ dynamicRoutes, prerenderedDynamicRoutes, middleware, basePath, buildId, i18n, is404Isr, }) => {
141
+ const generateDynamicRewrites = ({ dynamicRoutes, prerenderedDynamicRoutes, middleware, basePath, buildId, i18n, is404Isr, appPathRoutes, }) => {
131
142
  const dynamicRewrites = [];
132
143
  const dynamicRoutesThatMatchMiddleware = [];
133
144
  dynamicRoutes.forEach((route) => {
@@ -138,6 +149,17 @@ const generateDynamicRewrites = ({ dynamicRoutes, prerenderedDynamicRoutes, midd
138
149
  if (matchesMiddleware(middleware, route.page)) {
139
150
  dynamicRoutesThatMatchMiddleware.push(route.page);
140
151
  }
152
+ else if ((0, edge_1.isAppDirRoute)(route.page, appPathRoutes)) {
153
+ dynamicRewrites.push(...(0, utils_1.redirectsForNextRoute)({
154
+ route: route.page,
155
+ buildId,
156
+ basePath,
157
+ to: constants_1.ODB_FUNCTION_PATH,
158
+ i18n,
159
+ dataRoute: prerenderedDynamicRoutes[route.page].dataRoute,
160
+ withData: true,
161
+ }));
162
+ }
141
163
  else if (prerenderedDynamicRoutes[route.page].fallback === false && !is404Isr) {
142
164
  dynamicRewrites.push(...(0, utils_1.redirectsForNext404Route)({ route: route.page, buildId, basePath, i18n }));
143
165
  }
@@ -170,6 +192,7 @@ const generateRedirects = async ({ netlifyConfig, nextConfig: { i18n, basePath,
170
192
  ...(await (0, utils_1.getPreviewRewrites)({ basePath, appDir })));
171
193
  const middleware = await (0, files_1.getMiddleware)(netlifyConfig.build.publish);
172
194
  netlifyConfig.redirects.push(...generateMiddlewareRewrites({ basePath, i18n, middleware, buildId }));
195
+ const appPathRoutes = await (0, edge_1.loadAppPathRoutesManifest)(netlifyConfig);
173
196
  const staticRouteEntries = Object.entries(prerenderedStaticRoutes);
174
197
  const is404Isr = staticRouteEntries.some(([route, { initialRevalidateSeconds }]) => (0, utils_1.is404Route)(route, i18n) && initialRevalidateSeconds !== false);
175
198
  const routesThatMatchMiddleware = [];
@@ -179,6 +202,7 @@ const generateRedirects = async ({ netlifyConfig, nextConfig: { i18n, basePath,
179
202
  i18n,
180
203
  buildId,
181
204
  middleware,
205
+ appPathRoutes,
182
206
  });
183
207
  routesThatMatchMiddleware.push(...staticIsrRoutesThatMatchMiddleware);
184
208
  netlifyConfig.redirects.push(...staticIsrRewrites);
@@ -199,6 +223,7 @@ const generateRedirects = async ({ netlifyConfig, nextConfig: { i18n, basePath,
199
223
  buildId,
200
224
  i18n,
201
225
  is404Isr,
226
+ appPathRoutes,
202
227
  });
203
228
  netlifyConfig.redirects.push(...dynamicRewrites);
204
229
  routesThatMatchMiddleware.push(...dynamicRoutesThatMatchMiddleware);
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.getRemotePatterns = exports.isBundleSizeCheckDisabled = exports.getCustomImageResponseHeaders = exports.isNextAuthInstalled = exports.findModuleFromBase = exports.shouldSkip = exports.getPreviewRewrites = exports.getApiRewrites = exports.redirectsForNextRouteWithData = exports.redirectsForNext404Route = exports.redirectsForNextRoute = exports.is404Route = exports.isApiRoute = exports.routeToDataRoute = exports.netlifyRoutesForNextRouteWithData = exports.toNetlifyRoute = exports.getFunctionNameForPage = void 0;
6
+ exports.getRemotePatterns = exports.isBundleSizeCheckDisabled = exports.getCustomImageResponseHeaders = exports.isNextAuthInstalled = exports.findModuleFromBase = exports.shouldSkip = exports.getPreviewRewrites = exports.getApiRewrites = exports.redirectsForNextRouteWithData = exports.redirectsForNext404Route = exports.redirectsForNextRoute = exports.is404Route = exports.isApiRoute = exports.localizeRoute = exports.routeToDataRoute = exports.generateNetlifyRoutes = exports.toNetlifyRoute = exports.getFunctionNameForPage = void 0;
7
7
  const globby_1 = __importDefault(require("globby"));
8
8
  const pathe_1 = require("pathe");
9
9
  const constants_1 = require("../constants");
@@ -47,17 +47,17 @@ const toNetlifyRoute = (nextRoute) => {
47
47
  .replace(constants_1.DYNAMIC_PARAMETER_REGEX, '/:$1'));
48
48
  };
49
49
  exports.toNetlifyRoute = toNetlifyRoute;
50
- const netlifyRoutesForNextRouteWithData = ({ route, dataRoute }) => [
51
- ...(0, exports.toNetlifyRoute)(dataRoute),
52
- ...(0, exports.toNetlifyRoute)(route),
53
- ];
54
- exports.netlifyRoutesForNextRouteWithData = netlifyRoutesForNextRouteWithData;
50
+ const generateNetlifyRoutes = ({ route, dataRoute, withData = true, }) => [...(withData ? (0, exports.toNetlifyRoute)(dataRoute) : []), ...(0, exports.toNetlifyRoute)(route)];
51
+ exports.generateNetlifyRoutes = generateNetlifyRoutes;
55
52
  const routeToDataRoute = (route, buildId, locale) => `/_next/data/${buildId}${locale ? `/${locale}` : ''}${route === '/' ? '/index' : route}.json`;
56
53
  exports.routeToDataRoute = routeToDataRoute;
57
- const netlifyRoutesForNextRoute = (route, buildId, i18n) => {
54
+ // Default locale is served from root, not localized
55
+ const localizeRoute = (route, locale, defaultLocale) => locale === defaultLocale ? route : `/${locale}${route}`;
56
+ exports.localizeRoute = localizeRoute;
57
+ const netlifyRoutesForNextRoute = ({ route, buildId, i18n, withData = true, dataRoute, }) => {
58
58
  var _a;
59
59
  if (!((_a = i18n === null || i18n === void 0 ? void 0 : i18n.locales) === null || _a === void 0 ? void 0 : _a.length)) {
60
- return (0, exports.netlifyRoutesForNextRouteWithData)({ route, dataRoute: (0, exports.routeToDataRoute)(route, buildId) }).map((redirect) => ({
60
+ return (0, exports.generateNetlifyRoutes)({ route, dataRoute: dataRoute || (0, exports.routeToDataRoute)(route, buildId), withData }).map((redirect) => ({
61
61
  redirect,
62
62
  locale: false,
63
63
  }));
@@ -65,13 +65,14 @@ const netlifyRoutesForNextRoute = (route, buildId, i18n) => {
65
65
  const { locales, defaultLocale } = i18n;
66
66
  const routes = [];
67
67
  locales.forEach((locale) => {
68
- // Data route is always localized
69
- const dataRoute = (0, exports.routeToDataRoute)(route, buildId, locale);
70
- routes.push(
71
- // Default locale is served from root, not localized
72
- ...(0, exports.netlifyRoutesForNextRouteWithData)({
73
- route: locale === defaultLocale ? route : `/${locale}${route}`,
74
- dataRoute,
68
+ // Data route is always localized, except for appDir
69
+ const localizedDataRoute = dataRoute
70
+ ? (0, exports.localizeRoute)(dataRoute, locale, defaultLocale)
71
+ : (0, exports.routeToDataRoute)(route, buildId, locale);
72
+ routes.push(...(0, exports.generateNetlifyRoutes)({
73
+ route: (0, exports.localizeRoute)(route, locale, defaultLocale),
74
+ dataRoute: localizedDataRoute,
75
+ withData,
75
76
  }).map((redirect) => ({
76
77
  redirect,
77
78
  locale,
@@ -83,21 +84,21 @@ const isApiRoute = (route) => route.startsWith('/api/') || route === '/api';
83
84
  exports.isApiRoute = isApiRoute;
84
85
  const is404Route = (route, i18n) => i18n ? i18n.locales.some((locale) => route === `/${locale}/404`) : route === '/404';
85
86
  exports.is404Route = is404Route;
86
- const redirectsForNextRoute = ({ route, buildId, basePath, to, i18n, status = 200, force = false, }) => netlifyRoutesForNextRoute(route, buildId, i18n).map(({ redirect }) => ({
87
+ const redirectsForNextRoute = ({ route, buildId, basePath, to, i18n, status = 200, force = false, withData = true, dataRoute, }) => netlifyRoutesForNextRoute({ route, buildId, i18n, withData, dataRoute }).map(({ redirect }) => ({
87
88
  from: `${basePath}${redirect}`,
88
89
  to,
89
90
  status,
90
91
  force,
91
92
  }));
92
93
  exports.redirectsForNextRoute = redirectsForNextRoute;
93
- const redirectsForNext404Route = ({ route, buildId, basePath, i18n, force = false, }) => netlifyRoutesForNextRoute(route, buildId, i18n).map(({ redirect, locale }) => ({
94
+ const redirectsForNext404Route = ({ route, buildId, basePath, i18n, force = false, }) => netlifyRoutesForNextRoute({ route, buildId, i18n }).map(({ redirect, locale }) => ({
94
95
  from: `${basePath}${redirect}`,
95
96
  to: locale ? `${basePath}/server/pages/${locale}/404.html` : `${basePath}/server/pages/404.html`,
96
97
  status: 404,
97
98
  force,
98
99
  }));
99
100
  exports.redirectsForNext404Route = redirectsForNext404Route;
100
- const redirectsForNextRouteWithData = ({ route, dataRoute, basePath, to, status = 200, force = false, }) => (0, exports.netlifyRoutesForNextRouteWithData)({ route, dataRoute }).map((redirect) => ({
101
+ const redirectsForNextRouteWithData = ({ route, dataRoute, basePath, to, status = 200, force = false, }) => (0, exports.generateNetlifyRoutes)({ route, dataRoute, withData: true }).map((redirect) => ({
101
102
  from: `${basePath}${redirect}`,
102
103
  to,
103
104
  status,
@@ -97,7 +97,7 @@ const getApiHandler = ({ page, config, publishDir = '../../../.next', appDir = '
97
97
  const { Server } = require("http");
98
98
  // We copy the file here rather than requiring from the node module
99
99
  const { Bridge } = require("./bridge");
100
- const { getMaxAge, getMultiValueHeaders, getNextServer } = require('./handlerUtils')
100
+ const { getMultiValueHeaders, getNextServer } = require('./handlerUtils')
101
101
 
102
102
  ${config.type === "experimental-scheduled" /* ApiRouteType.SCHEDULED */ ? `const { schedule } = require("@netlify/functions")` : ''}
103
103
 
@@ -10,7 +10,7 @@ const path = require('path');
10
10
  // eslint-disable-next-line n/prefer-global/url, n/prefer-global/url-search-params
11
11
  const { URLSearchParams, URL } = require('url');
12
12
  const { Bridge } = require('@vercel/node-bridge/bridge');
13
- const { augmentFsModule, getMaxAge, getMultiValueHeaders, getPrefetchResponse, getNextServer, } = require('./handlerUtils');
13
+ const { augmentFsModule, getMaxAge, getMultiValueHeaders, getPrefetchResponse, getNextServer, normalizePath, } = require('./handlerUtils');
14
14
  // We return a function and then call `toString()` on it to serialise it as the launcher function
15
15
  // eslint-disable-next-line max-params
16
16
  const makeHandler = (conf, app, pageRoot, staticManifest = [], mode = 'ssr') => {
@@ -79,8 +79,7 @@ const makeHandler = (conf, app, pageRoot, staticManifest = [], mode = 'ssr') =>
79
79
  if (prefetchResponse) {
80
80
  return prefetchResponse;
81
81
  }
82
- // Ensure that paths are encoded - but don't double-encode them
83
- event.path = new URL(event.rawUrl).pathname;
82
+ event.path = normalizePath(event);
84
83
  // Next expects to be able to parse the query from the URL
85
84
  const query = new URLSearchParams(event.queryStringParameters).toString();
86
85
  event.path = query ? `${event.path}?${query}` : event.path;
@@ -99,7 +98,7 @@ const makeHandler = (conf, app, pageRoot, staticManifest = [], mode = 'ssr') =>
99
98
  headers: multiValueHeaders,
100
99
  statusCode: result.statusCode,
101
100
  };
102
- console.log('Origin response:', JSON.stringify(response, null, 2));
101
+ console.log('Next server response:', JSON.stringify(response, null, 2));
103
102
  }
104
103
  if ((_b = (_a = multiValueHeaders['set-cookie']) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.includes('__prerender_bypass')) {
105
104
  delete multiValueHeaders.etag;
@@ -142,7 +141,7 @@ const getHandler = ({ isODB = false, publishDir = '../../../.next', appDir = '..
142
141
  const { promises } = require("fs");
143
142
  // We copy the file here rather than requiring from the node module
144
143
  const { Bridge } = require("./bridge");
145
- const { augmentFsModule, getMaxAge, getMultiValueHeaders, getPrefetchResponse, getNextServer } = require('./handlerUtils')
144
+ const { augmentFsModule, getMaxAge, getMultiValueHeaders, getPrefetchResponse, getNextServer, normalizePath } = require('./handlerUtils')
146
145
 
147
146
  ${isODB ? `const { builder } = require("@netlify/functions")` : ''}
148
147
  const { config } = require("${publishDir}/required-server-files.json")
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.getPrefetchResponse = exports.getNextServer = exports.augmentFsModule = exports.getMultiValueHeaders = exports.getMaxAge = exports.downloadFile = void 0;
6
+ exports.normalizePath = exports.getPrefetchResponse = exports.getNextServer = exports.augmentFsModule = exports.getMultiValueHeaders = exports.getMaxAge = exports.downloadFile = void 0;
7
7
  const fs_1 = require("fs");
8
8
  const os_1 = require("os");
9
9
  const path_1 = __importDefault(require("path"));
@@ -191,3 +191,18 @@ const getPrefetchResponse = (event, mode) => {
191
191
  return false;
192
192
  };
193
193
  exports.getPrefetchResponse = getPrefetchResponse;
194
+ const normalizePath = (event) => {
195
+ var _a;
196
+ if ((_a = event.headers) === null || _a === void 0 ? void 0 : _a.rsc) {
197
+ const originalPath = event.headers['x-rsc-route'];
198
+ if (originalPath) {
199
+ if (event.headers['x-next-debug-logging']) {
200
+ console.log('Original path:', originalPath);
201
+ }
202
+ return originalPath;
203
+ }
204
+ }
205
+ // Ensure that paths are encoded - but don't double-encode them
206
+ return new URL(event.rawUrl).pathname;
207
+ };
208
+ exports.normalizePath = normalizePath;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netlify/plugin-nextjs",
3
- "version": "4.30.0",
3
+ "version": "4.30.1",
4
4
  "description": "Run Next.js seamlessly on Netlify",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "devDependencies": {
38
38
  "@delucis/if-env": "^1.1.2",
39
- "@netlify/build": "^29.4.1",
39
+ "@netlify/build": "^29.5.1",
40
40
  "@types/fs-extra": "^9.0.13",
41
41
  "@types/jest": "^27.4.1",
42
42
  "@types/merge-stream": "^1.1.2",
@@ -0,0 +1,23 @@
1
+ import type { Context } from 'https://edge.netlify.com'
2
+ // Available at build time
3
+ import edgeFunction from './bundle.js'
4
+ import { buildNextRequest, buildResponse, redirectTrailingSlash } from '../edge-shared/utils.ts'
5
+ import nextConfig from '../edge-shared/nextConfig.json' assert { type: 'json' }
6
+
7
+ const handler = async (req: Request, context: Context) => {
8
+ const url = new URL(req.url)
9
+ const redirect = redirectTrailingSlash(url, nextConfig.trailingSlash)
10
+ if (redirect) {
11
+ return redirect
12
+ }
13
+ const request = buildNextRequest(req, context, nextConfig)
14
+ try {
15
+ const result = await edgeFunction({ request })
16
+ return buildResponse({ result, request: req, context })
17
+ } catch (error) {
18
+ console.error(error)
19
+ return new Response(error.message, { status: 500 })
20
+ }
21
+ }
22
+
23
+ export default handler
@@ -1,49 +1,13 @@
1
1
  import type { Context } from 'https://edge.netlify.com'
2
2
  // Available at build time
3
3
  import matchers from './matchers.json' assert { type: 'json' }
4
- import nextConfig from '../edge-shared/nextConfig.json' assert { type: 'json' }
5
4
  import edgeFunction from './bundle.js'
6
- import { buildResponse } from '../edge-shared/utils.ts'
5
+ import { buildNextRequest, buildResponse } from '../edge-shared/utils.ts'
7
6
  import { getMiddlewareRouteMatcher, MiddlewareRouteMatch, searchParamsToUrlQuery } from '../edge-shared/next-utils.ts'
7
+ import nextConfig from '../edge-shared/nextConfig.json' assert { type: 'json' }
8
8
 
9
9
  const matchesMiddleware: MiddlewareRouteMatch = getMiddlewareRouteMatcher(matchers || [])
10
10
 
11
- export interface FetchEventResult {
12
- response: Response
13
- waitUntil: Promise<any>
14
- }
15
-
16
- export interface I18NConfig {
17
- defaultLocale: string
18
- localeDetection?: false
19
- locales: string[]
20
- }
21
-
22
- export interface RequestData {
23
- geo?: {
24
- city?: string
25
- country?: string
26
- region?: string
27
- latitude?: string
28
- longitude?: string
29
- timezone?: string
30
- }
31
- headers: Record<string, string>
32
- ip?: string
33
- method: string
34
- nextConfig?: {
35
- basePath?: string
36
- i18n?: I18NConfig | null
37
- trailingSlash?: boolean
38
- }
39
- page?: {
40
- name?: string
41
- params?: { [key: string]: string }
42
- }
43
- url: string
44
- body?: ReadableStream<Uint8Array>
45
- }
46
-
47
11
  export interface RequestContext {
48
12
  request: Request
49
13
  context: Context
@@ -70,15 +34,6 @@ const handler = async (req: Request, context: Context) => {
70
34
  return
71
35
  }
72
36
 
73
- const geo: RequestData['geo'] = {
74
- country: context.geo.country?.code,
75
- region: context.geo.subdivision?.code,
76
- city: context.geo.city,
77
- latitude: context.geo.latitude?.toString(),
78
- longitude: context.geo.longitude?.toString(),
79
- timezone: context.geo.timezone,
80
- }
81
-
82
37
  const requestId = req.headers.get('x-nf-request-id')
83
38
  if (!requestId) {
84
39
  console.error('Missing x-nf-request-id header')
@@ -89,15 +44,7 @@ const handler = async (req: Request, context: Context) => {
89
44
  })
90
45
  }
91
46
 
92
- const request: RequestData = {
93
- headers: Object.fromEntries(req.headers.entries()),
94
- geo,
95
- url: req.url,
96
- method: req.method,
97
- ip: context.ip,
98
- body: req.body ?? undefined,
99
- nextConfig,
100
- }
47
+ const request = buildNextRequest(req, context, nextConfig)
101
48
 
102
49
  try {
103
50
  const result = await edgeFunction({ request })
@@ -5,6 +5,7 @@ import { buildResponse } from '../edge-shared/utils.ts'
5
5
 
6
6
  globalThis.NFRequestContextMap ||= new Map()
7
7
  globalThis.__dirname = fromFileUrl(new URL('./', import.meta.url)).slice(0, -1)
8
+ globalThis.process ||= { env: Deno.env.toObject() }
8
9
 
9
10
  // Next.js uses this extension to the Headers API implemented by Cloudflare workerd
10
11
  if (!('getAll' in Headers.prototype)) {
@@ -0,0 +1,5 @@
1
+ import prerenderManifest from '../edge-shared/prerender-manifest.json' assert { type: 'json' }
2
+ import { getRscDataRouter, PrerenderManifest } from '../edge-shared/rsc-data.ts'
3
+
4
+ const handler = getRscDataRouter(prerenderManifest as PrerenderManifest)
5
+ export default handler
@@ -0,0 +1,3 @@
1
+ {
2
+ "trailingSlash": true
3
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "routes": {},
4
+ "dynamicRoutes": {},
5
+ "notFoundRoutes": [],
6
+ "preview": {}
7
+ }
File without changes
@@ -0,0 +1,74 @@
1
+ import type { EdgeFunction } from 'https://edge.netlify.com'
2
+
3
+ export declare type SsgRoute = {
4
+ initialRevalidateSeconds: number | false
5
+ srcRoute: string | null
6
+ dataRoute: string
7
+ }
8
+ export declare type DynamicSsgRoute = {
9
+ routeRegex: string
10
+ fallback: string | null | false
11
+ dataRoute: string
12
+ dataRouteRegex: string
13
+ }
14
+ export declare type PrerenderManifest = {
15
+ version: 3
16
+ routes: {
17
+ [route: string]: SsgRoute
18
+ }
19
+ dynamicRoutes: {
20
+ [route: string]: DynamicSsgRoute
21
+ }
22
+ notFoundRoutes: string[]
23
+ }
24
+
25
+ const noop = () => {}
26
+
27
+ // Ensure that routes with and without a trailing slash map to different ODB paths
28
+ const rscifyPath = (route: string) => {
29
+ if (route.endsWith('/')) {
30
+ return route.slice(0, -1) + '.rsc/'
31
+ }
32
+ return route + '.rsc'
33
+ }
34
+
35
+ export const getRscDataRouter = ({ routes: staticRoutes, dynamicRoutes }: PrerenderManifest): EdgeFunction => {
36
+ const staticRouteSet = new Set(
37
+ Object.entries(staticRoutes)
38
+ .filter(([, { dataRoute }]) => dataRoute.endsWith('.rsc'))
39
+ .map(([route]) => route),
40
+ )
41
+
42
+ const dynamicRouteMatcher = Object.values(dynamicRoutes)
43
+ .filter(({ dataRoute }) => dataRoute.endsWith('.rsc'))
44
+ .map(({ routeRegex }) => new RegExp(routeRegex))
45
+
46
+ const matchesDynamicRscDataRoute = (pathname: string) => {
47
+ return dynamicRouteMatcher.some((matcher) => matcher.test(pathname))
48
+ }
49
+
50
+ const matchesStaticRscDataRoute = (pathname: string) => {
51
+ const key = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
52
+ return staticRouteSet.has(key)
53
+ }
54
+
55
+ const matchesRscRoute = (pathname: string) => {
56
+ return matchesStaticRscDataRoute(pathname) || matchesDynamicRscDataRoute(pathname)
57
+ }
58
+
59
+ return (request, context) => {
60
+ const debug = request.headers.has('x-next-debug-logging')
61
+ const log = debug ? (...args: unknown[]) => console.log(...args) : noop
62
+ const url = new URL(request.url)
63
+ // If this is a static RSC request, rewrite to the data route
64
+ if (request.headers.get('rsc') === '1') {
65
+ log('Is rsc request')
66
+ if (matchesRscRoute(url.pathname)) {
67
+ request.headers.set('x-rsc-route', url.pathname)
68
+ const target = rscifyPath(url.pathname)
69
+ log('Rewriting to', target)
70
+ return context.rewrite(target)
71
+ }
72
+ }
73
+ }
74
+ }
@@ -1,6 +1,6 @@
1
1
  import { assertEquals } from 'https://deno.land/std@0.167.0/testing/asserts.ts'
2
2
  import { beforeEach, describe, it } from 'https://deno.land/std@0.167.0/testing/bdd.ts'
3
- import { updateModifiedHeaders, FetchEventResult } from './utils.ts'
3
+ import { redirectTrailingSlash, updateModifiedHeaders } from './utils.ts'
4
4
 
5
5
  describe('updateModifiedHeaders', () => {
6
6
  it("does not modify the headers if 'x-middleware-override-headers' is not found", () => {
@@ -62,3 +62,53 @@ describe('updateModifiedHeaders', () => {
62
62
  })
63
63
  })
64
64
  })
65
+
66
+ describe('trailing slash redirects', () => {
67
+ it('adds a trailing slash to the pathn if trailingSlash is enabled', () => {
68
+ const url = new URL('https://example.com/foo')
69
+ const result = redirectTrailingSlash(url, true)
70
+ assertEquals(result?.status, 308)
71
+ assertEquals(result?.headers.get('location'), 'https://example.com/foo/')
72
+ })
73
+
74
+ it("doesn't add a trailing slash if trailingSlash is false", () => {
75
+ const url = new URL('https://example.com/foo')
76
+ const result = redirectTrailingSlash(url, false)
77
+ assertEquals(result, undefined)
78
+ })
79
+
80
+ it("doesn't add a trailing slash if the path is a file", () => {
81
+ const url = new URL('https://example.com/foo.txt')
82
+ const result = redirectTrailingSlash(url, true)
83
+ assertEquals(result, undefined)
84
+ })
85
+ it('adds a trailing slash if there is a dot in the path', () => {
86
+ const url = new URL('https://example.com/foo.bar/baz')
87
+ const result = redirectTrailingSlash(url, true)
88
+ assertEquals(result?.status, 308)
89
+ assertEquals(result?.headers.get('location'), 'https://example.com/foo.bar/baz/')
90
+ })
91
+ it("doesn't add a trailing slash if the path is /", () => {
92
+ const url = new URL('https://example.com/')
93
+ const result = redirectTrailingSlash(url, true)
94
+ assertEquals(result, undefined)
95
+ })
96
+ it('removes a trailing slash from the path if trailingSlash is false', () => {
97
+ const url = new URL('https://example.com/foo/')
98
+ const result = redirectTrailingSlash(url, false)
99
+ assertEquals(result?.status, 308)
100
+ assertEquals(result?.headers.get('location'), 'https://example.com/foo')
101
+ })
102
+ it("doesn't remove a trailing slash if trailingSlash is true", () => {
103
+ const url = new URL('https://example.com/foo/')
104
+ const result = redirectTrailingSlash(url, true)
105
+ assertEquals(result, undefined)
106
+ })
107
+
108
+ it('removes a trailing slash from the path if the path is a file', () => {
109
+ const url = new URL('https://example.com/foo.txt/')
110
+ const result = redirectTrailingSlash(url, false)
111
+ assertEquals(result?.status, 308)
112
+ assertEquals(result?.headers.get('location'), 'https://example.com/foo.txt')
113
+ })
114
+ })
@@ -56,6 +56,37 @@ interface MiddlewareRequest {
56
56
  rewrite(destination: string | URL, init?: ResponseInit): Response
57
57
  }
58
58
 
59
+ export interface I18NConfig {
60
+ defaultLocale: string
61
+ localeDetection?: false
62
+ locales: string[]
63
+ }
64
+
65
+ export interface RequestData {
66
+ geo?: {
67
+ city?: string
68
+ country?: string
69
+ region?: string
70
+ latitude?: string
71
+ longitude?: string
72
+ timezone?: string
73
+ }
74
+ headers: Record<string, string>
75
+ ip?: string
76
+ method: string
77
+ nextConfig?: {
78
+ basePath?: string
79
+ i18n?: I18NConfig | null
80
+ trailingSlash?: boolean
81
+ }
82
+ page?: {
83
+ name?: string
84
+ params?: { [key: string]: string }
85
+ }
86
+ url: string
87
+ body?: ReadableStream<Uint8Array>
88
+ }
89
+
59
90
  function isMiddlewareRequest(response: Response | MiddlewareRequest): response is MiddlewareRequest {
60
91
  return 'originalRequest' in response
61
92
  }
@@ -90,6 +121,34 @@ export function updateModifiedHeaders(requestHeaders: Headers, responseHeaders:
90
121
  responseHeaders.delete('x-middleware-override-headers')
91
122
  }
92
123
 
124
+ export const buildNextRequest = (
125
+ request: Request,
126
+ context: Context,
127
+ nextConfig?: RequestData['nextConfig'],
128
+ ): RequestData => {
129
+ const { url, method, body, headers } = request
130
+
131
+ const { country, subdivision, city, latitude, longitude, timezone } = context.geo
132
+
133
+ const geo: RequestData['geo'] = {
134
+ country: country?.code,
135
+ region: subdivision?.code,
136
+ city,
137
+ latitude: latitude?.toString(),
138
+ longitude: longitude?.toString(),
139
+ timezone,
140
+ }
141
+
142
+ return {
143
+ headers: Object.fromEntries(headers.entries()),
144
+ geo,
145
+ url,
146
+ method,
147
+ ip: context.ip,
148
+ body: body ?? undefined,
149
+ nextConfig,
150
+ }
151
+ }
93
152
  export const buildResponse = async ({
94
153
  result,
95
154
  request,
@@ -196,3 +255,19 @@ export const buildResponse = async ({
196
255
  }
197
256
  return res
198
257
  }
258
+
259
+ export const redirectTrailingSlash = (url: URL, trailingSlash: boolean): Response | undefined => {
260
+ const { pathname } = url
261
+ if (pathname === '/') {
262
+ return
263
+ }
264
+ if (!trailingSlash && pathname.endsWith('/')) {
265
+ url.pathname = pathname.slice(0, -1)
266
+ return Response.redirect(url, 308)
267
+ }
268
+ // Add a slash, unless there's a file extension
269
+ if (trailingSlash && !pathname.endsWith('/') && !pathname.split('/').pop()?.includes('.')) {
270
+ url.pathname = `${pathname}/`
271
+ return Response.redirect(url, 308)
272
+ }
273
+ }