Skip to content

Commit efe9901

Browse files
committed
generate pages and app handler from adapters and not standalone
1 parent 20e65cc commit efe9901

File tree

6 files changed

+240
-5
lines changed

6 files changed

+240
-5
lines changed

src/adapter/adapter-handler.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
2+
3+
import { ComputeJsOutgoingMessage, toComputeResponse, toReqRes } from '@fastly/http-compute-js'
4+
import type { Context } from '@netlify/functions'
5+
6+
/**
7+
* When Next.js proxies requests externally, it writes the response back as-is.
8+
* In some cases, this includes Transfer-Encoding: chunked.
9+
* This triggers behaviour in @fastly/http-compute-js to separate chunks with chunk delimiters, which is not what we want at this level.
10+
* We want Lambda to control the behaviour around chunking, not this.
11+
* This workaround removes the Transfer-Encoding header, which makes the library send the response as-is.
12+
*/
13+
const disableFaultyTransferEncodingHandling = (res: ComputeJsOutgoingMessage) => {
14+
const originalStoreHeader = res._storeHeader
15+
res._storeHeader = function _storeHeader(firstLine, headers) {
16+
if (headers) {
17+
if (Array.isArray(headers)) {
18+
// eslint-disable-next-line no-param-reassign
19+
headers = headers.filter(([header]) => header.toLowerCase() !== 'transfer-encoding')
20+
} else {
21+
delete (headers as OutgoingHttpHeaders)['transfer-encoding']
22+
}
23+
}
24+
25+
return originalStoreHeader.call(this, firstLine, headers)
26+
}
27+
}
28+
29+
type NextHandler = (
30+
req: IncomingMessage,
31+
res: ServerResponse,
32+
ctx: {
33+
waitUntil: (promise: Promise<unknown>) => void
34+
},
35+
) => Promise<void | null>
36+
37+
export async function runNextHandler(
38+
request: Request,
39+
context: Context,
40+
nextHandler: NextHandler,
41+
): Promise<Response> {
42+
const { req, res } = toReqRes(request)
43+
// Work around a bug in http-proxy in next@<14.0.2
44+
Object.defineProperty(req, 'connection', {
45+
get() {
46+
return {}
47+
},
48+
})
49+
Object.defineProperty(req, 'socket', {
50+
get() {
51+
return {}
52+
},
53+
})
54+
55+
disableFaultyTransferEncodingHandling(res as unknown as ComputeJsOutgoingMessage)
56+
57+
nextHandler(req, res, {
58+
waitUntil: context.waitUntil,
59+
})
60+
.then(() => {
61+
console.log('handler done')
62+
})
63+
.catch((error) => {
64+
console.error('handler error', error)
65+
})
66+
.finally(() => {
67+
// Next.js relies on `close` event emitted by response to trigger running callback variant of `next/after`
68+
// however @fastly/http-compute-js never actually emits that event - so we have to emit it ourselves,
69+
// otherwise Next would never run the callback variant of `next/after`
70+
res.emit('close')
71+
})
72+
73+
const response = await toComputeResponse(res)
74+
return response
75+
}

src/adapter/adapter.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
onBuildComplete as onBuildCompleteForImageCDN,
1111
} from './image-cdn.js'
1212
import { onBuildComplete as onBuildCompleteForMiddleware } from './middleware.js'
13+
import { onBuildComplete as onBuildCompleteForPagesAndAppHandlers } from './pages-and-app-handlers.js'
1314
import { onBuildComplete as onBuildCompleteForStaticAssets } from './static-assets.js'
1415
import { FrameworksAPIConfig } from './types.js'
1516

@@ -45,6 +46,10 @@ const adapter: NextAdapter = {
4546
)
4647
// TODO: verifyNetlifyForms
4748
frameworksAPIConfig = onBuildCompleteForHeaders(nextAdapterContext, frameworksAPIConfig)
49+
frameworksAPIConfig = await onBuildCompleteForPagesAndAppHandlers(
50+
nextAdapterContext,
51+
frameworksAPIConfig,
52+
)
4853

4954
if (frameworksAPIConfig) {
5055
// write out config if there is any

src/adapter/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,8 @@ export const GENERATOR = `${packageJSON.name}@${packageJSON.version}`
1111

1212
export const NETLIFY_FRAMEWORKS_API_CONFIG_PATH = '.netlify/v1/config.json'
1313
export const NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS = '.netlify/v1/edge-functions'
14+
export const NETLIFY_FRAMEWORKS_API_FUNCTIONS = '.netlify/v1/functions'
1415
export const NEXT_RUNTIME_STATIC_ASSETS = '.netlify/static'
16+
17+
export const DISPLAY_NAME_MIDDLEWARE = 'Next.js Middleware Handler'
18+
export const DISPLAY_NAME_PAGES_AND_APP = 'Next.js Pages and App Router Handler'

src/adapter/middleware.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,19 @@ import { dirname, join, parse, relative } from 'node:path/posix'
44
import { glob } from 'fast-glob'
55
import { pathToRegexp } from 'path-to-regexp'
66

7-
import { GENERATOR, NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS, PLUGIN_DIR } from './constants.js'
7+
import {
8+
DISPLAY_NAME_MIDDLEWARE,
9+
GENERATOR,
10+
NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS,
11+
PLUGIN_DIR,
12+
} from './constants.js'
813
import type { FrameworksAPIConfig, NextConfigComplete, OnBuildCompleteContext } from './types.js'
914

10-
const MIDDLEWARE_FUNCTION_NAME = 'middleware'
15+
const MIDDLEWARE_FUNCTION_INTERNAL_NAME = 'next_middleware'
1116

1217
const MIDDLEWARE_FUNCTION_DIR = join(
1318
NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS,
14-
MIDDLEWARE_FUNCTION_NAME,
19+
MIDDLEWARE_FUNCTION_INTERNAL_NAME,
1520
)
1621

1722
export async function onBuildComplete(
@@ -199,7 +204,7 @@ const writeHandlerFile = async (
199204
export const config = ${JSON.stringify({
200205
cache: undefined,
201206
generator: GENERATOR,
202-
name: 'Next.js Middleware Handler',
207+
name: DISPLAY_NAME_MIDDLEWARE,
203208
pattern: augmentMatchers(middleware, nextConfig).map((matcher) => matcher.regexp),
204209
})}
205210
`,
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { cp, mkdir, writeFile } from 'node:fs/promises'
2+
import { join, relative } from 'node:path/posix'
3+
4+
import { glob } from 'fast-glob'
5+
6+
import {
7+
DISPLAY_NAME_PAGES_AND_APP,
8+
GENERATOR,
9+
NETLIFY_FRAMEWORKS_API_FUNCTIONS,
10+
PLUGIN_DIR,
11+
} from './constants.js'
12+
import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
13+
14+
const PAGES_AND_APP_FUNCTION_INTERNAL_NAME = 'next_pages_and_app'
15+
16+
const RUNTIME_DIR = '.netlify'
17+
18+
const PAGES_AND_APP_FUNCTION_DIR = join(
19+
NETLIFY_FRAMEWORKS_API_FUNCTIONS,
20+
PAGES_AND_APP_FUNCTION_INTERNAL_NAME,
21+
)
22+
23+
export async function onBuildComplete(
24+
ctx: OnBuildCompleteContext,
25+
frameworksAPIConfigArg: FrameworksAPIConfig,
26+
) {
27+
const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
28+
29+
const requiredFiles = new Set<string>()
30+
const pathnameToEntry: Record<string, string> = {}
31+
32+
for (const outputs of [
33+
ctx.outputs.pages,
34+
ctx.outputs.pagesApi,
35+
ctx.outputs.appPages,
36+
ctx.outputs.appRoutes,
37+
]) {
38+
if (outputs) {
39+
for (const output of outputs) {
40+
if (output.runtime === 'edge') {
41+
// TODO: figure something out here
42+
continue
43+
}
44+
for (const asset of Object.values(output.assets)) {
45+
requiredFiles.add(asset)
46+
}
47+
48+
requiredFiles.add(output.filePath)
49+
pathnameToEntry[output.pathname] = relative(ctx.repoRoot, output.filePath)
50+
}
51+
}
52+
}
53+
54+
await mkdir(PAGES_AND_APP_FUNCTION_DIR, { recursive: true })
55+
56+
for (const filePath of requiredFiles) {
57+
await cp(filePath, join(PAGES_AND_APP_FUNCTION_DIR, relative(ctx.repoRoot, filePath)), {
58+
recursive: true,
59+
})
60+
}
61+
62+
// copy needed runtime files
63+
64+
await copyRuntime(join(PAGES_AND_APP_FUNCTION_DIR, RUNTIME_DIR))
65+
66+
// generate needed runtime files
67+
const entrypoint = /* javascript */ `
68+
import { AsyncLocalStorage } from 'node:async_hooks'
69+
import { createRequire } from 'node:module'
70+
import { runNextHandler } from './${RUNTIME_DIR}/dist/adapter/adapter-handler.js'
71+
72+
globalThis.AsyncLocalStorage = AsyncLocalStorage
73+
74+
const require = createRequire(import.meta.url)
75+
76+
const pathnameToEntry = ${JSON.stringify(pathnameToEntry, null, 2)}
77+
78+
export default async function handler(request, context) {
79+
const url = new URL(request.url)
80+
81+
const entry = pathnameToEntry[url.pathname]
82+
if (!entry) {
83+
return new Response('Not Found', { status: 404 })
84+
}
85+
86+
const nextHandler = require('./' + entry).handler
87+
88+
return runNextHandler(request, context, nextHandler)
89+
}
90+
91+
export const config = {
92+
93+
path: ${JSON.stringify(Object.keys(pathnameToEntry), null, 2)},
94+
}
95+
`
96+
await writeFile(
97+
join(PAGES_AND_APP_FUNCTION_DIR, `${PAGES_AND_APP_FUNCTION_INTERNAL_NAME}.mjs`),
98+
entrypoint,
99+
)
100+
101+
// configuration
102+
// TODO: ideally allow to set `includedFilesBasePath` via frameworks api config
103+
// frameworksAPIConfig.functions ??= { '*': {} }
104+
// frameworksAPIConfig.functions[PAGES_AND_APP_FUNCTION_INTERNAL_NAME] = {
105+
// node_bundler: 'none',
106+
// included_files: ['**'],
107+
// // we can't define includedFilesBasePath via Frameworks API
108+
// // included_files_base_path: PAGES_AND_APP_FUNCTION_DIR,
109+
// }
110+
111+
// not using frameworks api because ... it doesn't allow to set includedFilesBasePath
112+
await writeFile(
113+
join(PAGES_AND_APP_FUNCTION_DIR, `${PAGES_AND_APP_FUNCTION_INTERNAL_NAME}.json`),
114+
JSON.stringify(
115+
{
116+
config: {
117+
name: DISPLAY_NAME_PAGES_AND_APP,
118+
generator: GENERATOR,
119+
node_bundler: 'none',
120+
included_files: ['**'],
121+
includedFilesBasePath: PAGES_AND_APP_FUNCTION_DIR,
122+
},
123+
},
124+
null,
125+
2,
126+
),
127+
)
128+
129+
return frameworksAPIConfig
130+
}
131+
132+
const copyRuntime = async (handlerDirectory: string): Promise<void> => {
133+
const files = await glob('dist/**/*', {
134+
cwd: PLUGIN_DIR,
135+
ignore: ['**/*.test.ts'],
136+
dot: true,
137+
})
138+
await Promise.all(
139+
files.map((path) =>
140+
cp(join(PLUGIN_DIR, path), join(handlerDirectory, path), { recursive: true }),
141+
),
142+
)
143+
// We need to create a package.json file with type: module to make sure that the runtime modules
144+
// are handled correctly as ESM modules
145+
await writeFile(join(handlerDirectory, 'package.json'), JSON.stringify({ type: 'module' }))
146+
}

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export const onBuild = async (options: NetlifyPluginOptions) => {
8989

9090
await Promise.all([
9191
copyPrerenderedContent(ctx), // maybe this
92-
createServerHandler(ctx), // not this while we use standalone
92+
// createServerHandler(ctx), // not this while we use standalone
9393
])
9494
})
9595
}

0 commit comments

Comments
 (0)