Skip to content

Commit

Permalink
[edge] allow importing blob assets (#38492)
Browse files Browse the repository at this point in the history
* [edge] allow importing blob assets

* Fix test

* extract to a new file, to make it easier to read and review

* Use webpack asset discovery and transform with a loader

* fix tests

* don't prefix assets

* use emitFile

* rename assets to blobs to be more specific

* rename blobs to assets and use webpack's hashing algo

* Dedupe correctly

* Add a Node.js dep test

* Update packages/next/server/next-server.ts

Co-authored-by: Tobias Koppers <tobias.koppers@googlemail.com>

* [code review] test remote URL fetches

* [code review] use `import type` for type-only imports

* Update packages/next/server/next-server.ts

Co-authored-by: Tobias Koppers <tobias.koppers@googlemail.com>

* Apply suggestions from code review

Co-authored-by: JJ Kasper <jj@jjsweb.site>

Co-authored-by: Tobias Koppers <tobias.koppers@googlemail.com>
Co-authored-by: JJ Kasper <jj@jjsweb.site>
  • Loading branch information
3 people committed Jul 19, 2022
1 parent 9b312db commit 20486c1
Show file tree
Hide file tree
Showing 16 changed files with 298 additions and 21 deletions.
11 changes: 11 additions & 0 deletions packages/next/build/webpack-config.ts
Expand Up @@ -1272,6 +1272,7 @@ export default async function getBaseWebpackConfig(
'next-middleware-loader',
'next-edge-function-loader',
'next-edge-ssr-loader',
'next-middleware-asset-loader',
'next-middleware-wasm-loader',
'next-app-loader',
].reduce((alias, loader) => {
Expand Down Expand Up @@ -1769,6 +1770,16 @@ export default async function getBaseWebpackConfig(
type: 'javascript/auto',
resourceQuery: /module/i,
})
webpack5Config.module?.rules?.unshift({
dependency: 'url',
loader: 'next-middleware-asset-loader',
type: 'javascript/auto',
layer: 'edge-asset',
})
webpack5Config.module?.rules?.unshift({
issuerLayer: 'edge-asset',
type: 'asset/source',
})
}

webpack5Config.experiments = {
Expand Down
5 changes: 3 additions & 2 deletions packages/next/build/webpack/loaders/get-module-build-info.ts
Expand Up @@ -10,7 +10,8 @@ export function getModuleBuildInfo(webpackModule: webpack5.Module) {
nextEdgeApiFunction?: EdgeMiddlewareMeta
nextEdgeSSR?: EdgeSSRMeta
nextUsedEnvVars?: Set<string>
nextWasmMiddlewareBinding?: WasmBinding
nextWasmMiddlewareBinding?: AssetBinding
nextAssetMiddlewareBinding?: AssetBinding
usingIndirectEval?: boolean | Set<string>
route?: RouteMeta
importLocByPath?: Map<string, any>
Expand All @@ -32,7 +33,7 @@ export interface EdgeSSRMeta {
page: string
}

export interface WasmBinding {
export interface AssetBinding {
filePath: string
name: string
}
@@ -0,0 +1,19 @@
import loaderUtils from 'next/dist/compiled/loader-utils3'
import { getModuleBuildInfo } from './get-module-build-info'

export default function MiddlewareAssetLoader(this: any, source: Buffer) {
const name = loaderUtils.interpolateName(this, '[name].[hash].[ext]', {
context: this.rootContext,
content: source,
})
const filePath = `edge-chunks/asset_${name}`
const buildInfo = getModuleBuildInfo(this._module)
buildInfo.nextAssetMiddlewareBinding = {
filePath: `server/${filePath}`,
name,
}
this.emitFile(filePath, source)
return `module.exports = ${JSON.stringify(`blob:${name}`)}`
}

export const raw = true
39 changes: 31 additions & 8 deletions packages/next/build/webpack/plugins/middleware-plugin.ts
@@ -1,5 +1,8 @@
import type { EdgeMiddlewareMeta } from '../loaders/get-module-build-info'
import type { EdgeSSRMeta, WasmBinding } from '../loaders/get-module-build-info'
import type {
AssetBinding,
EdgeMiddlewareMeta,
} from '../loaders/get-module-build-info'
import type { EdgeSSRMeta } from '../loaders/get-module-build-info'
import { getNamedMiddlewareRegex } from '../../../shared/lib/router/utils/route-regex'
import { getModuleBuildInfo } from '../loaders/get-module-build-info'
import { getSortedRoutes } from '../../../shared/lib/router/utils'
Expand All @@ -14,13 +17,14 @@ import {
NEXT_CLIENT_SSR_ENTRY_SUFFIX,
} from '../../../shared/lib/constants'

interface EdgeFunctionDefinition {
export interface EdgeFunctionDefinition {
env: string[]
files: string[]
name: string
page: string
regexp: string
wasm?: WasmBinding[]
wasm?: AssetBinding[]
assets?: AssetBinding[]
}

export interface MiddlewareManifest {
Expand All @@ -35,7 +39,8 @@ interface EntryMetadata {
edgeApiFunction?: EdgeMiddlewareMeta
edgeSSR?: EdgeSSRMeta
env: Set<string>
wasmBindings: Set<WasmBinding>
wasmBindings: Map<string, string>
assetBindings: Map<string, string>
}

const NAME = 'MiddlewarePlugin'
Expand Down Expand Up @@ -410,7 +415,8 @@ function getExtractMetadata(params: {

const entryMetadata: EntryMetadata = {
env: new Set<string>(),
wasmBindings: new Set<WasmBinding>(),
wasmBindings: new Map(),
assetBindings: new Map(),
}

for (const entryModule of entryModules) {
Expand Down Expand Up @@ -479,7 +485,17 @@ function getExtractMetadata(params: {
* append it to the entry wasm bindings.
*/
if (buildInfo?.nextWasmMiddlewareBinding) {
entryMetadata.wasmBindings.add(buildInfo.nextWasmMiddlewareBinding)
entryMetadata.wasmBindings.set(
buildInfo.nextWasmMiddlewareBinding.name,
buildInfo.nextWasmMiddlewareBinding.filePath
)
}

if (buildInfo?.nextAssetMiddlewareBinding) {
entryMetadata.assetBindings.set(
buildInfo.nextAssetMiddlewareBinding.name,
buildInfo.nextAssetMiddlewareBinding.filePath
)
}

/**
Expand Down Expand Up @@ -557,7 +573,14 @@ function getCreateAssets(params: {
name: entrypoint.name,
page: page,
regexp,
wasm: Array.from(metadata.wasmBindings),
wasm: Array.from(metadata.wasmBindings, ([name, filePath]) => ({
name,
filePath,
})),
assets: Array.from(metadata.assetBindings, ([name, filePath]) => ({
name,
filePath,
})),
}

if (metadata.edgeApiFunction || metadata.edgeSSR) {
Expand Down
2 changes: 1 addition & 1 deletion packages/next/server/body-streams.ts
Expand Up @@ -7,7 +7,7 @@ type BodyStream = ReadableStream<Uint8Array>
/**
* Creates a ReadableStream from a Node.js HTTP request
*/
export function requestToBodyStream(request: IncomingMessage): BodyStream {
export function requestToBodyStream(request: Readable): BodyStream {
const transform = new Primitives.TransformStream<Uint8Array, Uint8Array>({
start(controller) {
request.on('data', (chunk) => controller.enqueue(chunk))
Expand Down
12 changes: 10 additions & 2 deletions packages/next/server/next-server.ts
Expand Up @@ -1121,6 +1121,12 @@ export default class NextNodeServer extends BaseServer {
...binding,
filePath: join(this.distDir, binding.filePath),
})),
assets: (pageInfo.assets ?? []).map((binding) => {
return {
...binding,
filePath: join(this.distDir, binding.filePath),
}
}),
}
}

Expand Down Expand Up @@ -1223,10 +1229,11 @@ export default class NextNodeServer extends BaseServer {
}

result = await run({
distDir: this.distDir,
name: middlewareInfo.name,
paths: middlewareInfo.paths,
env: middlewareInfo.env,
wasm: middlewareInfo.wasm,
edgeFunctionEntry: middlewareInfo,
request: {
headers: params.request.headers,
method,
Expand Down Expand Up @@ -1552,10 +1559,11 @@ export default class NextNodeServer extends BaseServer {
const nodeReq = params.req as NodeNextRequest

const result = await run({
distDir: this.distDir,
name: middlewareInfo.name,
paths: middlewareInfo.paths,
env: middlewareInfo.env,
wasm: middlewareInfo.wasm,
edgeFunctionEntry: middlewareInfo,
request: {
headers: params.req.headers,
method: params.req.method,
Expand Down
23 changes: 18 additions & 5 deletions packages/next/server/web/sandbox/context.ts
@@ -1,5 +1,5 @@
import type { Primitives } from 'next/dist/compiled/@edge-runtime/primitives'
import type { WasmBinding } from '../../../build/webpack/loaders/get-module-build-info'
import type { AssetBinding } from '../../../build/webpack/loaders/get-module-build-info'
import {
decorateServerError,
getServerError,
Expand All @@ -9,6 +9,8 @@ import { EdgeRuntime } from 'next/dist/compiled/edge-runtime'
import { readFileSync, promises as fs } from 'fs'
import { validateURL } from '../utils'
import { pick } from '../../../lib/pick'
import { fetchInlineAsset } from './fetch-inline-assets'
import type { EdgeFunctionDefinition } from '../../../build/webpack/plugins/middleware-plugin'

const WEBPACK_HASH_REGEX =
/__webpack_require__\.h = function\(\) \{ return "[0-9a-f]+"; \}/g
Expand Down Expand Up @@ -48,7 +50,8 @@ interface ModuleContextOptions {
onWarning: (warn: Error) => void
useCache: boolean
env: string[]
wasm: WasmBinding[]
distDir: string
edgeFunctionEntry: Pick<EdgeFunctionDefinition, 'assets' | 'wasm'>
}

const pendingModuleCaches = new Map<string, Promise<ModuleContext>>()
Expand Down Expand Up @@ -103,7 +106,7 @@ export async function getModuleContext(options: ModuleContextOptions) {
async function createModuleContext(options: ModuleContextOptions) {
const warnedEvals = new Set<string>()
const warnedWasmCodegens = new Set<string>()
const wasm = await loadWasm(options.wasm)
const wasm = await loadWasm(options.edgeFunctionEntry.wasm ?? [])
const runtime = new EdgeRuntime({
codeGeneration:
process.env.NODE_ENV !== 'production'
Expand Down Expand Up @@ -197,7 +200,17 @@ Learn More: https://nextjs.org/docs/messages/middleware-dynamic-wasm-compilation
}

const __fetch = context.fetch
context.fetch = (input: RequestInfo, init: RequestInit = {}) => {
context.fetch = async (input: RequestInfo, init: RequestInit = {}) => {
const assetResponse = await fetchInlineAsset({
input,
assets: options.edgeFunctionEntry.assets,
distDir: options.distDir,
context,
})
if (assetResponse) {
return assetResponse
}

init.headers = new Headers(init.headers ?? {})
const prevs =
init.headers.get(`x-middleware-subrequest`)?.split(':') || []
Expand Down Expand Up @@ -270,7 +283,7 @@ Learn More: https://nextjs.org/docs/messages/middleware-dynamic-wasm-compilation
}

async function loadWasm(
wasm: WasmBinding[]
wasm: AssetBinding[]
): Promise<Record<string, WebAssembly.Module>> {
const modules: Record<string, WebAssembly.Module> = {}

Expand Down
39 changes: 39 additions & 0 deletions packages/next/server/web/sandbox/fetch-inline-assets.ts
@@ -0,0 +1,39 @@
import { createReadStream, promises as fs } from 'fs'
import path from 'path'
import { requestToBodyStream } from '../../body-streams'
import type { EdgeFunctionDefinition } from '../../../build/webpack/plugins/middleware-plugin'

/**
* Short-circuits the `fetch` function
* to return a stream for a given asset, if a user used `new URL("file", import.meta.url)`.
* This allows to embed assets in Edge Runtime.
*/
export async function fetchInlineAsset(options: {
input: RequestInfo
distDir: string
assets: EdgeFunctionDefinition['assets']
context: { Response: any }
}): Promise<Response | undefined> {
const inputString = String(options.input)
if (!inputString.startsWith('blob:')) {
return
}

const hash = inputString.replace('blob:', '')
const asset = options.assets?.find((x) => x.name === hash)
if (!asset) {
return
}

const filePath = path.resolve(options.distDir, asset.filePath)

const fileIsReadable = await fs.access(filePath).then(
() => true,
() => false
)

if (fileIsReadable) {
const readStream = createReadStream(filePath)
return new options.context.Response(requestToBodyStream(readStream))
}
}
8 changes: 5 additions & 3 deletions packages/next/server/web/sandbox/sandbox.ts
@@ -1,7 +1,7 @@
import type { RequestData, FetchEventResult } from '../types'
import type { WasmBinding } from '../../../build/webpack/loaders/get-module-build-info'
import { getServerError } from 'next/dist/compiled/@next/react-dev-overlay/dist/middleware'
import { getModuleContext } from './context'
import { EdgeFunctionDefinition } from '../../../build/webpack/plugins/middleware-plugin'

export const ErrorSource = Symbol('SandboxError')

Expand All @@ -12,7 +12,8 @@ type RunnerFn = (params: {
paths: string[]
request: RequestData
useCache: boolean
wasm: WasmBinding[]
edgeFunctionEntry: Pick<EdgeFunctionDefinition, 'wasm' | 'assets'>
distDir: string
}) => Promise<FetchEventResult>

export const run = withTaggedErrors(async (params) => {
Expand All @@ -21,7 +22,8 @@ export const run = withTaggedErrors(async (params) => {
onWarning: params.onWarning,
useCache: params.useCache !== false,
env: params.env,
wasm: params.wasm,
edgeFunctionEntry: params.edgeFunctionEntry,
distDir: params.distDir,
})

for (const paramPath of params.paths) {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

@@ -0,0 +1,60 @@
export const config = { runtime: 'experimental-edge' }

/**
* @param {import('next/server').NextRequest} req
*/
export default async (req) => {
const handlerName = req.nextUrl.searchParams.get('handler')
const handler = handlers.get(handlerName) || defaultHandler
return handler()
}

/**
* @type {Map<string, () => Promise<Response>>}
*/
const handlers = new Map([
[
'text-file',
async () => {
const url = new URL('../../src/text-file.txt', import.meta.url)
return fetch(url)
},
],
[
'image-file',
async () => {
const url = new URL('../../src/vercel.png', import.meta.url)
return fetch(url)
},
],
[
'from-node-module',
async () => {
const url = new URL('my-pkg/hello/world.json', import.meta.url)
return fetch(url)
},
],
[
'remote-full',
async () => {
const url = new URL('https://example.vercel.sh')
const response = await fetch(url)
const headers = new Headers(response.headers)
headers.delete('content-encoding')
return new Response(response.body, { headers, status: response.status })
},
],
[
'remote-with-base',
async () => {
const url = new URL('/', 'https://example.vercel.sh')
const response = await fetch(url)
const headers = new Headers(response.headers)
headers.delete('content-encoding')
return new Response(response.body, { headers, status: response.status })
},
],
])

const defaultHandler = async () =>
new Response('Invalid handler', { status: 400 })
@@ -0,0 +1 @@
Hello, from text-file.txt!
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 20486c1

Please sign in to comment.