Skip to content

Commit

Permalink
feat(middleware): issues warnings when using node.js global APIs in m…
Browse files Browse the repository at this point in the history
…iddleware
  • Loading branch information
feugy committed May 18, 2022
1 parent 4a86a8f commit 33bda80
Show file tree
Hide file tree
Showing 7 changed files with 434 additions and 27 deletions.
77 changes: 71 additions & 6 deletions packages/next/build/webpack/plugins/middleware-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getSortedRoutes } from '../../../shared/lib/router/utils'
import { webpack, sources, webpack5 } from 'next/dist/compiled/webpack/webpack'
import {
EDGE_RUNTIME_WEBPACK,
EDGE_UNSUPPORTED_NODE_APIS,
MIDDLEWARE_BUILD_MANIFEST,
MIDDLEWARE_FLIGHT_MANIFEST,
MIDDLEWARE_MANIFEST,
Expand Down Expand Up @@ -57,7 +58,11 @@ export default class MiddlewarePlugin {
/**
* This is the static code analysis phase.
*/
const codeAnalyzer = getCodeAnalizer({ dev: this.dev, compiler })
const codeAnalyzer = getCodeAnalizer({
dev: this.dev,
compiler,
compilation,
})
hooks.parser.for('javascript/auto').tap(NAME, codeAnalyzer)
hooks.parser.for('javascript/dynamic').tap(NAME, codeAnalyzer)
hooks.parser.for('javascript/esm').tap(NAME, codeAnalyzer)
Expand Down Expand Up @@ -93,11 +98,13 @@ export default class MiddlewarePlugin {
function getCodeAnalizer(params: {
dev: boolean
compiler: webpack5.Compiler
compilation: webpack5.Compilation
}) {
return (parser: webpack5.javascript.JavascriptParser) => {
const {
dev,
compiler: { webpack: wp },
compilation,
} = params
const { hooks } = parser

Expand All @@ -107,7 +114,7 @@ function getCodeAnalizer(params: {
* but actually execute the expression.
*/
const handleWrapExpression = (expr: any) => {
if (parser.state.module?.layer !== 'middleware') {
if (!isInMiddlewareLayer(parser)) {
return
}

Expand Down Expand Up @@ -135,7 +142,7 @@ function getCodeAnalizer(params: {
* module path that is using it.
*/
const handleExpression = () => {
if (parser.state.module?.layer !== 'middleware') {
if (!isInMiddlewareLayer(parser)) {
return
}

Expand Down Expand Up @@ -169,7 +176,7 @@ function getCodeAnalizer(params: {
}

buildInfo.nextUsedEnvVars.add(members[1])
if (parser.state.module?.layer !== 'middleware') {
if (!isInMiddlewareLayer(parser)) {
return true
}
}
Expand All @@ -179,8 +186,7 @@ function getCodeAnalizer(params: {
* A noop handler to skip analyzing some cases.
* Order matters: for it to work, it must be registered first
*/
const skip = () =>
parser.state.module?.layer === 'middleware' ? true : undefined
const skip = () => (isInMiddlewareLayer(parser) ? true : undefined)

for (const prefix of ['', 'global.']) {
hooks.expression.for(`${prefix}Function.prototype`).tap(NAME, skip)
Expand All @@ -193,6 +199,7 @@ function getCodeAnalizer(params: {
}
hooks.callMemberChain.for('process').tap(NAME, handleCallMemberChain)
hooks.expressionMemberChain.for('process').tap(NAME, handleCallMemberChain)
registerUnsupportedApiHooks(parser, compilation)
}
}

Expand Down Expand Up @@ -412,3 +419,61 @@ function getEntryFiles(entryFiles: string[], meta: EntryMetadata) {
)
return files
}

function registerUnsupportedApiHooks(
parser: webpack5.javascript.JavascriptParser,
compilation: webpack5.Compilation
) {
const { WebpackError } = compilation.compiler.webpack
for (const expression of EDGE_UNSUPPORTED_NODE_APIS) {
parser.hooks.expression.for(expression).tap(NAME, (node: any) => {
if (!isInMiddlewareLayer(parser)) {
return
}
compilation.warnings.push(
makeUnsupportedApiError(WebpackError, parser, node.name, node.loc)
)
})
}

const warnForUnsupportedProcessApi = (node: any, [callee]: string[]) => {
if (!isInMiddlewareLayer(parser) || callee === 'env') {
return
}
compilation.warnings.push(
makeUnsupportedApiError(
WebpackError,
parser,
`process.${callee}`,
node.loc
)
)
}

parser.hooks.callMemberChain
.for('process')
.tap(NAME, warnForUnsupportedProcessApi)
parser.hooks.expressionMemberChain
.for('process')
.tap(NAME, warnForUnsupportedProcessApi)
}

function makeUnsupportedApiError(
WebpackError: typeof webpack5.WebpackError,
parser: webpack5.javascript.JavascriptParser,
name: string,
loc: any
) {
const error = new WebpackError(
`You're using a Node.js API (${name} at line: ${loc.start.line}) which is not supported in the Edge Runtime that Middleware uses.
Learn more: https://nextjs.org/docs/api-reference/edge-runtime`
)
error.name = NAME
error.module = parser.state.current
error.loc = loc
return error
}

function isInMiddlewareLayer(parser: webpack5.javascript.JavascriptParser) {
return parser.state.module?.layer === 'middleware'
}
94 changes: 73 additions & 21 deletions packages/next/server/web/sandbox/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from 'next/dist/compiled/abort-controller'
import vm from 'vm'
import type { WasmBinding } from '../../../build/webpack/loaders/get-module-build-info'
import { EDGE_UNSUPPORTED_NODE_APIS } from '../../../shared/lib/constants'

const WEBPACK_HASH_REGEX =
/__webpack_require__\.h = function\(\) \{ return "[0-9a-f]+"; \}/g
Expand Down Expand Up @@ -47,19 +48,21 @@ const caches = new Map<
}
>()

interface ModuleContextOptions {
module: string
onWarning: (warn: Error) => void
useCache: boolean
env: string[]
wasm: WasmBinding[]
}

/**
* For a given module name this function will create a context for the
* runtime. It returns a function where we can provide a module path and
* run in within the context. It may or may not use a cache depending on
* the parameters.
*/
export async function getModuleContext(options: {
module: string
onWarning: (warn: Error) => void
useCache: boolean
env: string[]
wasm: WasmBinding[]
}) {
export async function getModuleContext(options: ModuleContextOptions) {
let moduleCache = options.useCache
? caches.get(options.module)
: await createModuleContext(options)
Expand Down Expand Up @@ -97,12 +100,7 @@ export async function getModuleContext(options: {
* 2. Dependencies that require runtime globals such as Blob.
* 3. Dependencies that are scoped for the provided parameters.
*/
async function createModuleContext(options: {
onWarning: (warn: Error) => void
module: string
env: string[]
wasm: WasmBinding[]
}) {
async function createModuleContext(options: ModuleContextOptions) {
const requireCache = new Map([
[require.resolve('next/dist/compiled/cookie'), { exports: cookie }],
])
Expand Down Expand Up @@ -181,11 +179,10 @@ async function createModuleContext(options: {
* Create a base context with all required globals for the runtime that
* won't depend on any externally provided dependency.
*/
function createContext(options: {
/** Environment variables to be provided to the context */
env: string[]
}) {
const context: { [key: string]: unknown } = {
function createContext(
options: Pick<ModuleContextOptions, 'env' | 'onWarning'>
) {
const context: Context = {
_ENTRIES: {},
atob: polyfills.atob,
Blob,
Expand All @@ -209,9 +206,7 @@ function createContext(options: {
crypto: new polyfills.Crypto(),
File,
FormData,
process: {
env: buildEnvironmentVariablesFrom(options.env),
},
process: createProcessPolyfill(options),
ReadableStream,
setInterval,
setTimeout,
Expand Down Expand Up @@ -245,6 +240,9 @@ function createContext(options: {
ArrayBuffer,
SharedArrayBuffer,
}
for (const name of EDGE_UNSUPPORTED_NODE_APIS) {
addStub(context, name, options)
}

// Self references
context.self = context
Expand Down Expand Up @@ -286,3 +284,57 @@ async function loadWasm(

return modules
}

function createProcessPolyfill(
options: Pick<ModuleContextOptions, 'env' | 'onWarning'>
) {
const env = buildEnvironmentVariablesFrom(options.env)

const processPolyfill = { env }
const overridenValue: Record<string, any> = {}
for (const key of Object.keys(process)) {
if (key === 'env') continue
Object.defineProperty(processPolyfill, key, {
get() {
emitWarning(`process.${key}`, options)
return overridenValue[key]
},
set(value) {
overridenValue[key] = value
},
enumerable: false,
})
}
return processPolyfill
}

const warnedAlready = new Set<string>()

function addStub(
context: Context,
name: string,
contextOptions: Pick<ModuleContextOptions, 'onWarning'>
) {
Object.defineProperty(context, name, {
get() {
emitWarning(name, contextOptions)
return undefined
},
enumerable: false,
})
}

function emitWarning(
name: string,
contextOptions: Pick<ModuleContextOptions, 'onWarning'>
) {
if (!warnedAlready.has(name)) {
const warning =
new Error(`You're using a Node.js API (${name}) which is not supported in the Edge Runtime that Middleware uses.
Learn more: https://nextjs.org/docs/api-reference/edge-runtime`)
warning.name = 'NodejsRuntimeApiInMiddlewareWarning'
contextOptions.onWarning(warning)
console.warn(warning.message)
warnedAlready.add(name)
}
}
30 changes: 30 additions & 0 deletions packages/next/shared/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,33 @@ export const OPTIMIZED_FONT_PROVIDERS = [
]
export const STATIC_STATUS_PAGES = ['/500']
export const TRACE_OUTPUT_VERSION = 1

export const EDGE_UNSUPPORTED_NODE_APIS = [
'clearImmediate',
'setImmediate',
'structuredClone',
'queueMicrotask', // TODO allow that one?
'BroadcastChannel',
'ByteLengthQueuingStrategy',
'CompressionStream',
'CountQueuingStrategy',
'CryptoKey',
'DecompressionStream',
'DomException',
'Event',
'EventTarget',
'MessageChannel',
'MessageEvent',
'MessagePort',
'ReadableByteStreamController',
'ReadableStreamBYOBReader',
'ReadableStreamBYOBRequest',
'ReadableStreamDefaultController',
'ReadableStreamDefaultReader',
'SubtleCrypto',
'TextDecoderStream',
'TextEncoderStream',
'TransformStreamDefaultController',
'WritableStreamDefaultController',
'WritableStreamDefaultWriter',
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default function middleware() {
process.cwd = () => 'fixed-value'
console.log(process.cwd(), process.env)
return new Response()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/* eslint-env jest */

import {
fetchViaHTTP,
findPort,
killApp,
launchApp,
waitFor,
} from 'next-test-utils'
import { join } from 'path'

const context = { appDir: join(__dirname, '../'), appPort: NaN, app: null }

jest.setTimeout(1000 * 60 * 2)

describe('Middleware overriding a Node.js API', () => {
describe('dev mode', () => {
let output = ''

beforeAll(async () => {
output = ''
context.appPort = await findPort()
context.app = await launchApp(context.appDir, context.appPort, {
onStdout(msg) {
output += msg
},
onStderr(msg) {
output += msg
},
})
})

afterAll(() => killApp(context.app))

it('shows a warning but allows overriding', async () => {
const res = await fetchViaHTTP(context.appPort, '/')
await waitFor(500)
expect(res.status).toBe(200)
expect(output)
.toContain(`NodejsRuntimeApiInMiddlewareWarning: You're using a Node.js API (process.cwd) which is not supported in the Edge Runtime that Middleware uses.
Learn more: https://nextjs.org/docs/api-reference/edge-runtime`)
expect(output).toContain('fixed-value')
expect(output).not.toContain('TypeError')
expect(output).not.toContain(`You're using a Node.js API (process.env)`)
})
})
})
Loading

0 comments on commit 33bda80

Please sign in to comment.