rsnext/packages/next/server/web/sandbox/context.ts

248 lines
6.5 KiB
TypeScript
Raw Normal View History

Refactor sandbox module cache (#31822) To run middleware we are using a **sandbox** that emulates the web runtime and keeps a module cache. This cache is shared for all of the modules that we run using the sandbox while there are some module-level APIs that must be scoped depending on the module we are running. One example of this is `fetch` where we want to always inject a special header that indicate the module that is performing the fetch and use it to avoid getting into infinite loops for middleware. For those cases the cached implementation will be the first one that instantiates the module and therefore we can actually get into infinite loops. This is the reason why #31800 is failing. With this PR we refactor the sandbox so that the module cache is scoped per module name. This means that one execution of a middleware will preserve its cache only for that module so that each execution will still have its own `fetch` implementation, fixing this issue. Also, with this refactor the code is more clear and we also provide an option to avoid using the cache. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `yarn lint`
2021-11-26 13:06:41 +01:00
import type { Context } from 'vm'
import { Blob, File, FormData } from 'next/dist/compiled/formdata-node'
import { readFileSync } from 'fs'
import { requireDependencies } from './require'
import { TransformStream } from 'next/dist/compiled/web-streams-polyfill'
import cookie from 'next/dist/compiled/cookie'
import * as polyfills from './polyfills'
import {
AbortController,
AbortSignal,
} from 'next/dist/compiled/abort-controller'
Refactor sandbox module cache (#31822) To run middleware we are using a **sandbox** that emulates the web runtime and keeps a module cache. This cache is shared for all of the modules that we run using the sandbox while there are some module-level APIs that must be scoped depending on the module we are running. One example of this is `fetch` where we want to always inject a special header that indicate the module that is performing the fetch and use it to avoid getting into infinite loops for middleware. For those cases the cached implementation will be the first one that instantiates the module and therefore we can actually get into infinite loops. This is the reason why #31800 is failing. With this PR we refactor the sandbox so that the module cache is scoped per module name. This means that one execution of a middleware will preserve its cache only for that module so that each execution will still have its own `fetch` implementation, fixing this issue. Also, with this refactor the code is more clear and we also provide an option to avoid using the cache. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `yarn lint`
2021-11-26 13:06:41 +01:00
import vm from 'vm'
const WEBPACK_HASH_REGEX =
/__webpack_require__\.h = function\(\) \{ return "[0-9a-f]+"; \}/g
/**
* For a given path a context, this function checks if there is any module
* context that contains the path with an older content and, if that's the
* case, removes the context from the cache.
*/
export function clearModuleContext(path: string, content: Buffer | string) {
for (const [key, cache] of caches) {
const prev = cache?.paths.get(path)?.replace(WEBPACK_HASH_REGEX, '')
if (
typeof prev !== 'undefined' &&
prev !== content.toString().replace(WEBPACK_HASH_REGEX, '')
) {
caches.delete(key)
}
}
}
/**
* A Map of cached module contexts indexed by the module name. It allows
* to have a different cache scoped per module name or depending on the
* provided module key on creation.
*/
const caches = new Map<
string,
{
context: Context
paths: Map<string, string>
require: Map<string, any>
warnedEvals: Set<string>
}
>()
/**
* 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 function getModuleContext(options: {
module: string
onWarning: (warn: Error) => void
useCache: boolean
}) {
let moduleCache = options.useCache
? caches.get(options.module)
: createModuleContext(options)
if (!moduleCache) {
moduleCache = createModuleContext(options)
caches.set(options.module, moduleCache)
}
return {
context: moduleCache.context,
runInContext: (paramPath: string) => {
if (!moduleCache!.paths.has(paramPath)) {
const content = readFileSync(paramPath, 'utf-8')
try {
vm.runInNewContext(content, moduleCache!.context, {
filename: paramPath,
})
moduleCache!.paths.set(paramPath, content)
} catch (error) {
if (options.useCache) {
caches.delete(options.module)
}
throw error
}
}
},
}
}
/**
* Create a module cache specific for the provided parameters. It includes
* a context, require cache and paths cache and loads three types:
* 1. Dependencies that hold no runtime dependencies.
* 2. Dependencies that require runtime globals such as Blob.
* 3. Dependencies that are scoped for the provided parameters.
*/
function createModuleContext(options: {
onWarning: (warn: Error) => void
module: string
}) {
const requireCache = new Map([
[require.resolve('next/dist/compiled/cookie'), { exports: cookie }],
])
const context = createContext()
requireDependencies({
requireCache: requireCache,
context: context,
dependencies: [
{
path: require.resolve('../spec-compliant/headers'),
mapExports: { Headers: 'Headers' },
},
{
path: require.resolve('../spec-compliant/response'),
mapExports: { Response: 'Response' },
},
{
path: require.resolve('../spec-compliant/request'),
mapExports: { Request: 'Request' },
},
],
})
const moduleCache = {
context: context,
paths: new Map<string, string>(),
require: requireCache,
warnedEvals: new Set<string>(),
}
context.__next_eval__ = function __next_eval__(fn: Function) {
const key = fn.toString()
if (!moduleCache.warnedEvals.has(key)) {
const warning = new Error(
`Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware`
)
warning.name = 'DynamicCodeEvaluationWarning'
Error.captureStackTrace(warning, __next_eval__)
moduleCache.warnedEvals.add(key)
options.onWarning(warning)
}
return fn()
}
context.fetch = (input: RequestInfo, init: RequestInit = {}) => {
init.headers = new Headers(init.headers ?? {})
const prevs = init.headers.get(`x-middleware-subrequest`)?.split(':') || []
const value = prevs.concat(options.module).join(':')
init.headers.set('x-middleware-subrequest', value)
init.headers.set(`user-agent`, `Next.js Middleware`)
if (typeof input === 'object' && 'url' in input) {
return fetch(input.url, {
...init,
headers: {
...Object.fromEntries(input.headers),
...Object.fromEntries(init.headers),
},
})
}
return fetch(String(input), init)
}
return moduleCache
}
/**
* Create a base context with all required globals for the runtime that
* won't depend on any externally provided dependency.
*/
function createContext() {
const context: { [key: string]: unknown } = {
_ENTRIES: {},
atob: polyfills.atob,
Blob,
btoa: polyfills.btoa,
clearInterval,
clearTimeout,
console: {
assert: console.assert.bind(console),
error: console.error.bind(console),
info: console.info.bind(console),
log: console.log.bind(console),
time: console.time.bind(console),
timeEnd: console.timeEnd.bind(console),
timeLog: console.timeLog.bind(console),
warn: console.warn.bind(console),
},
AbortController: AbortController,
AbortSignal: AbortSignal,
Refactor sandbox module cache (#31822) To run middleware we are using a **sandbox** that emulates the web runtime and keeps a module cache. This cache is shared for all of the modules that we run using the sandbox while there are some module-level APIs that must be scoped depending on the module we are running. One example of this is `fetch` where we want to always inject a special header that indicate the module that is performing the fetch and use it to avoid getting into infinite loops for middleware. For those cases the cached implementation will be the first one that instantiates the module and therefore we can actually get into infinite loops. This is the reason why #31800 is failing. With this PR we refactor the sandbox so that the module cache is scoped per module name. This means that one execution of a middleware will preserve its cache only for that module so that each execution will still have its own `fetch` implementation, fixing this issue. Also, with this refactor the code is more clear and we also provide an option to avoid using the cache. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `yarn lint`
2021-11-26 13:06:41 +01:00
CryptoKey: polyfills.CryptoKey,
Crypto: polyfills.Crypto,
crypto: new polyfills.Crypto(),
File,
FormData,
process: { env: { ...process.env } },
ReadableStream: polyfills.ReadableStream,
setInterval,
setTimeout,
TextDecoder,
TextEncoder,
TransformStream,
URL,
URLSearchParams,
// Indexed collections
Array,
Int8Array,
Uint8Array,
Uint8ClampedArray,
Int16Array,
Uint16Array,
Int32Array,
Uint32Array,
Float32Array,
Float64Array,
BigInt64Array,
BigUint64Array,
// Keyed collections
Map,
Set,
WeakMap,
WeakSet,
// Structured data
ArrayBuffer,
SharedArrayBuffer,
}
// Self references
context.self = context
context.globalThis = context
return vm.createContext(context, {
codeGeneration:
process.env.NODE_ENV === 'production'
? {
strings: false,
wasm: false,
}
: undefined,
})
}