diff --git a/docs/api-reference/next.config.js/url-imports.md b/docs/api-reference/next.config.js/url-imports.md new file mode 100644 index 000000000000..2752704f2c07 --- /dev/null +++ b/docs/api-reference/next.config.js/url-imports.md @@ -0,0 +1,37 @@ +--- +description: Configure Next.js to allow importing modules from external URLs. +--- + +# URL imports + +URL Imports are an experimental feature that allows you to import modules directly from external servers (instead of from the local disk). + +To opt-in, add the allowed URL prefixes inside `next.config.js`: + +```js +module.exports = { + experimental: { + urlImports: ['https://example.com/modules/'], + }, +} +``` + +Then, you can import modules directly from URLs: + +```js +import { a, b, c } from 'https://example.com/modules/' +``` + +URL Imports can be used everywhere normal package imports can be used. + +## Lockfile + +When using URL imports, Next.js will create a lockfile in the `next.lock` directory. +This directory is intended to be committed to Git and should **not be included** in your `.gitignore` file. + +- When running `next dev`, Next.js will download and add all newly discovered URL Imports to your lockfile +- When running `next build`, Next.js will use only the lockfile to build the application for production + +Typically, no network requests are needed and any outdated lockfile will cause the build to fail. +One exception is resources that respond with `Cache-Control: no-cache`. +These resources will have a `no-cache` entry in the lockfile and will always be fetched from the network on each build. diff --git a/docs/manifest.json b/docs/manifest.json index 7f0c255b01db..aa8f8061240c 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -387,6 +387,10 @@ { "title": "React Strict Mode", "path": "/docs/api-reference/next.config.js/react-strict-mode.md" + }, + { + "title": "URL Imports", + "path": "/docs/api-reference/next.config.js/url-imports.md" } ] } diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index d3d35f762dda..46ad6df6d4a3 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1326,6 +1326,19 @@ export default async function getBaseWebpackConfig( webpack5Config.experiments = { layers: true, cacheUnaffected: true, + buildHttp: Array.isArray(config.experimental.urlImports) + ? { + allowedUris: config.experimental.urlImports, + cacheLocation: path.join(dir, 'next.lock/data'), + lockfileLocation: path.join(dir, 'next.lock/lock.json'), + } + : config.experimental.urlImports + ? { + cacheLocation: path.join(dir, 'next.lock/data'), + lockfileLocation: path.join(dir, 'next.lock/lock.json'), + ...config.experimental.urlImports, + } + : undefined, } webpack5Config.module!.parser = { diff --git a/packages/next/build/webpack/plugins/wellknown-errors-plugin/parseNotFoundError.ts b/packages/next/build/webpack/plugins/wellknown-errors-plugin/parseNotFoundError.ts index 037273e55787..c65f70ea3365 100644 --- a/packages/next/build/webpack/plugins/wellknown-errors-plugin/parseNotFoundError.ts +++ b/packages/next/build/webpack/plugins/wellknown-errors-plugin/parseNotFoundError.ts @@ -78,9 +78,10 @@ export async function getNotFoundError( .replace(/Can't resolve '(.*)'/, `Can't resolve '${chalk.green('$1')}'`) const importTrace = () => { - let importTraceLine = '\nImport trace for requested module:\n' const moduleTrace = getModuleTrace(input, compilation) + if (moduleTrace.length === 0) return '' + let importTraceLine = '\nImport trace for requested module:\n' for (const { origin } of moduleTrace) { if (!origin.resource) { continue diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index 094dd67ecd2f..491b42833125 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -1,4 +1,5 @@ import os from 'os' +import type webpack5 from 'webpack5' import { Header, Redirect, Rewrite } from '../lib/load-custom-routes' import { ImageConfig, @@ -147,6 +148,7 @@ export type NextConfig = { [key: string]: any } & { concurrentFeatures?: boolean serverComponents?: boolean fullySpecified?: boolean + urlImports?: NonNullable['buildHttp'] } } diff --git a/test/integration/url-imports/.gitignore b/test/integration/url-imports/.gitignore new file mode 100644 index 000000000000..3d389aacfc33 --- /dev/null +++ b/test/integration/url-imports/.gitignore @@ -0,0 +1,3 @@ +# this is only needed for the test case +# Do not ignore that in real apps +next.lock \ No newline at end of file diff --git a/test/integration/url-imports/next.config.js b/test/integration/url-imports/next.config.js new file mode 100644 index 000000000000..637e92836b89 --- /dev/null +++ b/test/integration/url-imports/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + urlImports: ['http://localhost:12345/'], + }, +} diff --git a/test/integration/url-imports/pages/api/value.js b/test/integration/url-imports/pages/api/value.js new file mode 100644 index 000000000000..513ae51d4575 --- /dev/null +++ b/test/integration/url-imports/pages/api/value.js @@ -0,0 +1,5 @@ +import value from 'http://localhost:12345/value4.js' + +export default (req, res) => { + res.json({ value: value }) +} diff --git a/test/integration/url-imports/pages/ssg.js b/test/integration/url-imports/pages/ssg.js new file mode 100644 index 000000000000..87336dbd0bf7 --- /dev/null +++ b/test/integration/url-imports/pages/ssg.js @@ -0,0 +1,17 @@ +import value from 'http://localhost:12345/value1.js' + +export async function getStaticProps() { + return { + props: { + value, + }, + } +} + +export default function Index({ value: staticValue }) { + return ( +
+ Hello {staticValue}+{value} +
+ ) +} diff --git a/test/integration/url-imports/pages/ssr.js b/test/integration/url-imports/pages/ssr.js new file mode 100644 index 000000000000..ef59de9ef7c6 --- /dev/null +++ b/test/integration/url-imports/pages/ssr.js @@ -0,0 +1,17 @@ +import value from 'http://localhost:12345/value2.js' + +export function getServerSideProps() { + return { + props: { + value, + }, + } +} + +export default function Index({ value: serverValue }) { + return ( +
+ Hello {serverValue}+{value} +
+ ) +} diff --git a/test/integration/url-imports/pages/static.js b/test/integration/url-imports/pages/static.js new file mode 100644 index 000000000000..59ed40c2f4f7 --- /dev/null +++ b/test/integration/url-imports/pages/static.js @@ -0,0 +1,9 @@ +import value from 'http://localhost:12345/value3.js' + +export default function Index(props) { + return ( +
+ Hello {value}+{value} +
+ ) +} diff --git a/test/integration/url-imports/public/vercel.png b/test/integration/url-imports/public/vercel.png new file mode 100644 index 000000000000..cb137a989e5f Binary files /dev/null and b/test/integration/url-imports/public/vercel.png differ diff --git a/test/integration/url-imports/source/value1.js b/test/integration/url-imports/source/value1.js new file mode 100644 index 000000000000..a9334acdeb9b --- /dev/null +++ b/test/integration/url-imports/source/value1.js @@ -0,0 +1 @@ +export default 42 // 1 diff --git a/test/integration/url-imports/source/value2.js b/test/integration/url-imports/source/value2.js new file mode 100644 index 000000000000..806efcbdf1d4 --- /dev/null +++ b/test/integration/url-imports/source/value2.js @@ -0,0 +1 @@ +export default 42 // 2 diff --git a/test/integration/url-imports/source/value3.js b/test/integration/url-imports/source/value3.js new file mode 100644 index 000000000000..6ab694c37a38 --- /dev/null +++ b/test/integration/url-imports/source/value3.js @@ -0,0 +1 @@ +export default 42 // 3 diff --git a/test/integration/url-imports/source/value4.js b/test/integration/url-imports/source/value4.js new file mode 100644 index 000000000000..1295372d0b9f --- /dev/null +++ b/test/integration/url-imports/source/value4.js @@ -0,0 +1 @@ +export default 42 // 4 diff --git a/test/integration/url-imports/test/index.test.js b/test/integration/url-imports/test/index.test.js new file mode 100644 index 000000000000..85da21240c0c --- /dev/null +++ b/test/integration/url-imports/test/index.test.js @@ -0,0 +1,89 @@ +/* eslint-disable no-loop-func */ +/* eslint-env jest */ + +import fs from 'fs-extra' +import { join } from 'path' +import { + nextBuild, + findPort, + nextStart, + killApp, + renderViaHTTP, + fetchViaHTTP, + launchApp, + getBrowserBodyText, + check, + startStaticServer, + stopApp, +} from 'next-test-utils' +import webdriver from 'next-webdriver' + +jest.setTimeout(1000 * 60 * 2) +const appDir = join(__dirname, '../') + +describe(`Handle url imports`, () => { + let staticServer + let staticServerPort + beforeAll(async () => { + await fs.remove(join(appDir, 'next.lock')) + staticServerPort = 12345 + staticServer = await startStaticServer( + join(appDir, 'source'), + undefined, + staticServerPort + ) + }) + afterAll(async () => { + await stopApp(staticServer) + }) + + for (const dev of [true, false]) { + describe(dev ? 'with next dev' : 'with next build', () => { + let appPort + let app + beforeAll(async () => { + await fs.remove(join(appDir, '.next')) + if (dev) { + appPort = await findPort() + app = await launchApp(appDir, appPort) + } else { + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + } + }) + afterAll(async () => { + await killApp(app) + }) + const expectedServer = /Hello 42\+42/ + const expectedClient = new RegExp( + expectedServer.source.replace(//g, '') + ) + + for (const page of ['/static', '/ssr', '/ssg']) { + it(`should render the ${page} page`, async () => { + const html = await renderViaHTTP(appPort, page) + expect(html).toMatch(expectedServer) + }) + + it(`should client-render the ${page} page`, async () => { + let browser + try { + browser = await webdriver(appPort, page) + await check(() => getBrowserBodyText(browser), expectedClient) + } finally { + await browser.close() + } + }) + } + + it('should respond on value api', async () => { + const data = await fetchViaHTTP(appPort, '/api/value').then( + (res) => res.ok && res.json() + ) + + expect(data).toEqual({ value: 42 }) + }) + }) + } +}) diff --git a/test/lib/next-test-utils.js b/test/lib/next-test-utils.js index ca89b983a693..bb4b8ebfd7fb 100644 --- a/test/lib/next-test-utils.js +++ b/test/lib/next-test-utils.js @@ -386,7 +386,7 @@ export function waitFor(millis) { return new Promise((resolve) => setTimeout(resolve, millis)) } -export async function startStaticServer(dir, notFoundFile) { +export async function startStaticServer(dir, notFoundFile, fixedPort) { const app = express() const server = http.createServer(app) app.use(express.static(dir)) @@ -397,7 +397,7 @@ export async function startStaticServer(dir, notFoundFile) { }) } - await promiseCall(server, 'listen') + await promiseCall(server, 'listen', fixedPort) return server }