Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor sandbox module cache #31822

Merged
merged 6 commits into from
Nov 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { webpack5 } from 'next/dist/compiled/webpack/webpack'
import { clearSandboxCache } from '../../../server/web/sandbox'
import { clearModuleContext } from '../../../server/web/sandbox'
import { realpathSync } from 'fs'
import path from 'path'
import isError from '../../../lib/is-error'
Expand Down Expand Up @@ -52,7 +52,7 @@ export class NextJsRequireCacheHotReloader implements WebpackPluginInstance {
(_file, { targetPath, content }) => {
this.currentOutputPathsWebpack5.add(targetPath)
deleteCache(targetPath)
clearSandboxCache(targetPath, content.toString('utf-8'))
clearModuleContext(targetPath, content.toString('utf-8'))
}
)

Expand Down
2 changes: 1 addition & 1 deletion packages/next/server/next-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,7 @@ export default class Server {
url: getRequestMeta(params.request, '__NEXT_INIT_URL')!,
page: page,
},
ssr: !!this.nextConfig.experimental.concurrentFeatures,
useCache: !this.nextConfig.experimental.concurrentFeatures,
onWarning: (warning: Error) => {
if (params.onWarning) {
warning.message += ` "./${middlewareInfo.name}"`
Expand Down
241 changes: 241 additions & 0 deletions packages/next/server/web/sandbox/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
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 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),
},
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,
javivelasco marked this conversation as resolved.
Show resolved Hide resolved
}

// Self references
context.self = context
context.globalThis = context

return vm.createContext(context, {
codeGeneration:
process.env.NODE_ENV === 'production'
? {
strings: false,
wasm: false,
}
: undefined,
})
}
1 change: 1 addition & 0 deletions packages/next/server/web/sandbox/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './sandbox'
export { clearModuleContext } from './context'
69 changes: 69 additions & 0 deletions packages/next/server/web/sandbox/require.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Context } from 'vm'
import { dirname } from 'path'
import { readFileSync } from 'fs'
import { runInContext } from 'vm'

/**
* Allows to require a series of dependencies provided by their path
* into a provided module context. It fills and accepts a require
* cache to ensure each module is loaded once.
*/
export function requireDependencies(params: {
context: Context
requireCache: Map<string, any>
dependencies: {
mapExports: { [key: string]: string }
path: string
}[]
}) {
const { context, requireCache, dependencies } = params
const requireFn = createRequire(context, requireCache)
for (const { path, mapExports } of dependencies) {
const mod = requireFn(path, path)
for (const mapKey of Object.keys(mapExports)) {
context[mapExports[mapKey]] = mod[mapKey]
}
}
}

function createRequire(context: Context, cache: Map<string, any>) {
return function requireFn(referrer: string, specifier: string) {
const resolved = require.resolve(specifier, {
paths: [dirname(referrer)],
})

const cached = cache.get(resolved)
if (cached !== undefined) {
return cached.exports
}

const module = {
exports: {},
loaded: false,
id: resolved,
}

cache.set(resolved, module)
const fn = runInContext(
`(function(module,exports,require,__dirname,__filename) {${readFileSync(
resolved,
'utf-8'
)}\n})`,
context
)

try {
fn(
module,
module.exports,
requireFn.bind(null, resolved),
dirname(resolved),
resolved
)
} finally {
cache.delete(resolved)
}
module.loaded = true
return module.exports
}
}
Loading