diff --git a/packages/nitro/src/build.ts b/packages/nitro/src/build.ts new file mode 100644 index 0000000000..f6ea30f02e --- /dev/null +++ b/packages/nitro/src/build.ts @@ -0,0 +1,124 @@ +import { resolve, join } from 'upath' +import consola from 'consola' +import { rollup, watch as rollupWatch } from 'rollup' +import { readFile, emptyDir, copy } from 'fs-extra' +import { printFSTree } from './utils/tree' +import { getRollupConfig } from './rollup/config' +import { hl, prettyPath, serializeTemplate, writeFile, isDirectory } from './utils' +import { NitroContext } from './context' +import { scanMiddleware } from './server/middleware' + +export async function prepare (nitroContext: NitroContext) { + consola.info(`Nitro preset is ${hl(nitroContext.preset)}`) + + await cleanupDir(nitroContext.output.dir) + + if (!nitroContext.output.publicDir.startsWith(nitroContext.output.dir)) { + await cleanupDir(nitroContext.output.publicDir) + } + + if (!nitroContext.output.serverDir.startsWith(nitroContext.output.dir)) { + await cleanupDir(nitroContext.output.serverDir) + } +} + +async function cleanupDir (dir: string) { + consola.info('Cleaning up', prettyPath(dir)) + await emptyDir(dir) +} + +export async function generate (nitroContext: NitroContext) { + consola.start('Generating public...') + + const clientDist = resolve(nitroContext._nuxt.buildDir, 'dist/client') + if (await isDirectory(clientDist)) { + await copy(clientDist, join(nitroContext.output.publicDir, nitroContext._nuxt.publicPath)) + } + + const staticDir = resolve(nitroContext._nuxt.srcDir, nitroContext._nuxt.staticDir) + if (await isDirectory(staticDir)) { + await copy(staticDir, nitroContext.output.publicDir) + } + + consola.success('Generated public ' + prettyPath(nitroContext.output.publicDir)) +} + +export async function build (nitroContext: NitroContext) { + // Compile html template + const htmlSrc = resolve(nitroContext._nuxt.buildDir, `views/${{ 2: 'app', 3: 'document' }[2]}.template.html`) + const htmlTemplate = { src: htmlSrc, contents: '', dst: '', compiled: '' } + htmlTemplate.dst = htmlTemplate.src.replace(/.html$/, '.js').replace('app.', 'document.') + htmlTemplate.contents = await readFile(htmlTemplate.src, 'utf-8') + htmlTemplate.compiled = 'module.exports = ' + serializeTemplate(htmlTemplate.contents) + await nitroContext._internal.hooks.callHook('nitro:template:document', htmlTemplate) + await writeFile(htmlTemplate.dst, htmlTemplate.compiled) + + nitroContext.rollupConfig = getRollupConfig(nitroContext) + await nitroContext._internal.hooks.callHook('nitro:rollup:before', nitroContext) + return nitroContext._nuxt.dev ? _watch(nitroContext) : _build(nitroContext) +} + +async function _build (nitroContext: NitroContext) { + nitroContext.scannedMiddleware = await scanMiddleware(nitroContext._nuxt.serverDir) + + consola.start('Building server...') + const build = await rollup(nitroContext.rollupConfig).catch((error) => { + consola.error('Rollup error: ' + error.message) + throw error + }) + + consola.start('Writing server bundle...') + await build.write(nitroContext.rollupConfig.output) + + consola.success('Server built') + await printFSTree(nitroContext.output.serverDir) + await nitroContext._internal.hooks.callHook('nitro:compiled', nitroContext) + + return { + entry: resolve(nitroContext.rollupConfig.output.dir, nitroContext.rollupConfig.output.entryFileNames) + } +} + +function startRollupWatcher (nitroContext: NitroContext) { + const watcher = rollupWatch(nitroContext.rollupConfig) + let start + + watcher.on('event', (event) => { + switch (event.code) { + // The watcher is (re)starting + case 'START': + return + + // Building an individual bundle + case 'BUNDLE_START': + start = Date.now() + return + + // Finished building all bundles + case 'END': + nitroContext._internal.hooks.callHook('nitro:compiled', nitroContext) + consola.success('Nitro built', start ? `in ${Date.now() - start} ms` : '') + return + + // Encountered an error while bundling + case 'ERROR': + consola.error('Rollup error: ' + event.error) + // consola.error(event.error) + } + }) + return watcher +} + +async function _watch (nitroContext: NitroContext) { + let watcher = startRollupWatcher(nitroContext) + + nitroContext.scannedMiddleware = await scanMiddleware(nitroContext._nuxt.serverDir, + (middleware, event) => { + nitroContext.scannedMiddleware = middleware + if (['add', 'addDir'].includes(event)) { + watcher.close() + watcher = startRollupWatcher(nitroContext) + } + } + ) +} diff --git a/packages/nitro/src/compat.ts b/packages/nitro/src/compat.ts new file mode 100644 index 0000000000..7aa9a9f47c --- /dev/null +++ b/packages/nitro/src/compat.ts @@ -0,0 +1,152 @@ +import fetch from 'node-fetch' +import { resolve } from 'upath' +import { build, generate, prepare } from './build' +import { getNitroContext, NitroContext } from './context' +import { createDevServer } from './server/dev' +import { wpfs } from './utils/wpfs' +import { resolveMiddleware } from './server/middleware' + +export default function nuxt2CompatModule () { + const { nuxt } = this + + // Ensure we're not just building with 'static' target + if (!nuxt.options.dev && nuxt.options.target === 'static' && !nuxt.options._export && !nuxt.options._legacyGenerate) { + throw new Error('[nitro] Please use `nuxt generate` for static target') + } + + // Disable loading-screen + nuxt.options.build.loadingScreen = false + nuxt.options.build.indicator = false + + // Create contexts + const nitroContext = getNitroContext(nuxt.options, nuxt.options.nitro || {}) + const nitroDevContext = getNitroContext(nuxt.options, { preset: 'dev' }) + + // Connect hooks + nuxt.addHooks(nitroContext.nuxtHooks) + nuxt.hook('close', () => nitroContext._internal.hooks.callHook('close')) + + nuxt.addHooks(nitroDevContext.nuxtHooks) + nuxt.hook('close', () => nitroDevContext._internal.hooks.callHook('close')) + nitroDevContext._internal.hooks.hook('renderLoading', + (req, res) => nuxt.callHook('server:nuxt:renderLoading', req, res)) + + // Expose process.env.NITRO_PRESET + nuxt.options.env.NITRO_PRESET = nitroContext.preset + + // .ts is supported for serverMiddleware + nuxt.options.extensions.push('ts') + + // Replace nuxt server + if (nuxt.server) { + nuxt.server.__closed = true + nuxt.server = createNuxt2DevServer(nitroDevContext) + } + + // Disable server sourceMap, esbuild will generate for it. + nuxt.hook('webpack:config', (webpackConfigs) => { + const serverConfig = webpackConfigs.find(config => config.name === 'server') + serverConfig.devtool = false + }) + + // Nitro client plugin + this.addPlugin({ + fileName: 'nitro.client.js', + src: resolve(nitroContext._internal.runtimeDir, 'app/nitro.client.js') + }) + + // Resolve middleware + nuxt.hook('modules:done', () => { + const { middleware, legacyMiddleware } = + resolveMiddleware(nuxt.options.serverMiddleware, nuxt.resolver.resolvePath) + if (nuxt.server) { + nuxt.server.setLegacyMiddleware(legacyMiddleware) + } + nitroContext.middleware.push(...middleware) + nitroDevContext.middleware.push(...middleware) + }) + + // nuxt build/dev + nuxt.options.build._minifyServer = false + nuxt.options.build.standalone = false + nuxt.hook('build:done', async () => { + if (nuxt.options.dev) { + await build(nitroDevContext) + } else if (!nitroContext._nuxt.isStatic) { + await prepare(nitroContext) + await generate(nitroContext) + await build(nitroContext) + } + }) + + // nude dev + if (nuxt.options.dev) { + nitroDevContext._internal.hooks.hook('nitro:compiled', () => { nuxt.server.watch() }) + nuxt.hook('build:compile', ({ compiler }) => { compiler.outputFileSystem = wpfs }) + nuxt.hook('server:devMiddleware', (m) => { nuxt.server.setDevMiddleware(m) }) + } + + // nuxt generate + nuxt.options.generate.dir = nitroContext.output.publicDir + nuxt.options.generate.manifest = false + nuxt.hook('generate:cache:ignore', (ignore: string[]) => { + ignore.push(nitroContext.output.dir) + ignore.push(nitroContext.output.serverDir) + if (nitroContext.output.publicDir) { + ignore.push(nitroContext.output.publicDir) + } + ignore.push(...nitroContext.ignore) + }) + nuxt.hook('generate:before', async () => { + await prepare(nitroContext) + }) + nuxt.hook('generate:extendRoutes', async () => { + await build(nitroDevContext) + await nuxt.server.reload() + }) + nuxt.hook('generate:done', async () => { + await nuxt.server.close() + await build(nitroContext) + }) +} + +function createNuxt2DevServer (nitroContext: NitroContext) { + const server = createDevServer(nitroContext) + + const listeners = [] + async function listen (port) { + const listener = await server.listen(port, { + showURL: false, + isProd: true + }) + listeners.push(listener) + return listener + } + + async function renderRoute (route = '/', renderContext = {}) { + const [listener] = listeners + if (!listener) { + throw new Error('There is no server listener to call `server.renderRoute()`') + } + const html = await fetch(listener.url + route, { + headers: { 'nuxt-render-context': encodeQuery(renderContext) } + }).then(r => r.text()) + + return { html } + } + + return { + ...server, + listeners, + renderRoute, + listen, + serverMiddlewarePaths () { return [] }, + ready () { } + } +} + +function encodeQuery (obj) { + return Object.entries(obj).map( + ([key, val]) => `${encodeURIComponent(key)}=${encodeURIComponent(JSON.stringify(val))}` + ).join('&') +} diff --git a/packages/nitro/src/context.ts b/packages/nitro/src/context.ts new file mode 100644 index 0000000000..3767310db3 --- /dev/null +++ b/packages/nitro/src/context.ts @@ -0,0 +1,136 @@ +import { resolve } from 'upath' +import defu from 'defu' +import type { NuxtOptions } from '@nuxt/types' +import Hookable, { configHooksT } from 'hookable' +import type { Preset } from '@nuxt/un' +import { tryImport, resolvePath, detectTarget, extendPreset } from './utils' +import * as PRESETS from './presets' +import type { NodeExternalsOptions } from './rollup/plugins/externals' +import type { ServerMiddleware } from './server/middleware' + +export interface NitroContext { + timing: boolean + inlineDynamicImports: boolean + minify: boolean + sourceMap: boolean + externals: boolean | NodeExternalsOptions + analyze: boolean + entry: string + node: boolean + preset: string + rollupConfig?: any + renderer: string + serveStatic: boolean + middleware: ServerMiddleware[] + scannedMiddleware: ServerMiddleware[] + hooks: configHooksT + nuxtHooks: configHooksT + ignore: string[] + env: Preset + output: { + dir: string + serverDir: string + publicDir: string + } + _nuxt: { + majorVersion: number + dev: boolean + rootDir: string + srcDir: string + buildDir: string + generateDir: string + staticDir: string + serverDir: string + routerBase: string + publicPath: string + isStatic: boolean + fullStatic: boolean + staticAssets: any + runtimeConfig: { public: any, private: any } + } + _internal: { + runtimeDir: string + hooks: Hookable + } +} + +type DeepPartial = { [P in keyof T]?: DeepPartial } + +export interface NitroInput extends DeepPartial {} + +export type NitroPreset = NitroInput | ((input: NitroInput) => NitroInput) + +export function getNitroContext (nuxtOptions: NuxtOptions, input: NitroInput): NitroContext { + const defaults: NitroContext = { + timing: undefined, + inlineDynamicImports: undefined, + minify: undefined, + sourceMap: undefined, + externals: undefined, + analyze: undefined, + entry: undefined, + node: undefined, + preset: undefined, + rollupConfig: undefined, + renderer: undefined, + serveStatic: undefined, + middleware: [], + scannedMiddleware: [], + ignore: [], + env: {}, + hooks: {}, + nuxtHooks: {}, + output: { + dir: '{{ _nuxt.rootDir }}/.output', + serverDir: '{{ output.dir }}/server', + publicDir: '{{ output.dir }}/public' + }, + _nuxt: { + majorVersion: nuxtOptions._majorVersion || 2, + dev: nuxtOptions.dev, + rootDir: nuxtOptions.rootDir, + srcDir: nuxtOptions.srcDir, + buildDir: nuxtOptions.buildDir, + generateDir: nuxtOptions.generate.dir, + staticDir: nuxtOptions.dir.static, + serverDir: resolve(nuxtOptions.srcDir, (nuxtOptions.dir as any).server || 'server'), + routerBase: nuxtOptions.router.base, + publicPath: nuxtOptions.build.publicPath, + isStatic: nuxtOptions.target === 'static' && !nuxtOptions.dev, + fullStatic: nuxtOptions.target === 'static' && !nuxtOptions._legacyGenerate, + // @ts-ignore + staticAssets: nuxtOptions.generate.staticAssets, + runtimeConfig: { + public: nuxtOptions.publicRuntimeConfig, + private: nuxtOptions.privateRuntimeConfig + } + }, + _internal: { + runtimeDir: resolve(__dirname, './runtime'), + hooks: new Hookable() + } + } + + defaults.preset = input.preset || process.env.NITRO_PRESET || detectTarget() || 'server' + let presetDefaults = PRESETS[defaults.preset] || tryImport(nuxtOptions.rootDir, defaults.preset) + if (!presetDefaults) { + throw new Error('Cannot resolve preset: ' + defaults.preset) + } + presetDefaults = presetDefaults.default || presetDefaults + + const _presetInput = defu(input, defaults) + // @ts-ignore + const _preset = extendPreset(input, presetDefaults)(_presetInput) + const nitroContext: NitroContext = defu(_preset, defaults) as any + + nitroContext.output.dir = resolvePath(nitroContext, nitroContext.output.dir) + nitroContext.output.publicDir = resolvePath(nitroContext, nitroContext.output.publicDir) + nitroContext.output.serverDir = resolvePath(nitroContext, nitroContext.output.serverDir) + + nitroContext._internal.hooks.addHooks(nitroContext.hooks) + + // console.log(nitroContext) + // process.exit(1) + + return nitroContext +} diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts new file mode 100644 index 0000000000..338ed79d0a --- /dev/null +++ b/packages/nitro/src/index.ts @@ -0,0 +1,6 @@ +export * from './build' +export * from './context' +export * from './server/middleware' +export * from './server/dev' +export * from './types' +export { wpfs } from './utils/wpfs' diff --git a/packages/nitro/src/presets/azure.ts b/packages/nitro/src/presets/azure.ts new file mode 100644 index 0000000000..3f26610e58 --- /dev/null +++ b/packages/nitro/src/presets/azure.ts @@ -0,0 +1,105 @@ +import consola from 'consola' +import fse from 'fs-extra' +import globby from 'globby' +import { join, resolve } from 'upath' +import { writeFile } from '../utils' +import { NitroPreset, NitroContext } from '../context' + +export const azure: NitroPreset = { + entry: '{{ _internal.runtimeDir }}/entries/azure', + output: { + serverDir: '{{ output.dir }}/server/functions' + }, + hooks: { + async 'nitro:compiled' (ctx: NitroContext) { + await writeRoutes(ctx) + } + } +} + +async function writeRoutes ({ output: { serverDir, publicDir } }: NitroContext) { + const host = { + version: '2.0' + } + + const routes = [ + { + route: '/*', + serve: '/api/server' + } + ] + + const indexPath = resolve(publicDir, 'index.html') + const indexFileExists = fse.existsSync(indexPath) + if (!indexFileExists) { + routes.unshift( + { + route: '/', + serve: '/api/server' + }, + { + route: '/index.html', + serve: '/api/server' + } + ) + } + + const folderFiles = await globby([ + join(publicDir, 'index.html'), + join(publicDir, '**/index.html') + ]) + const prefix = publicDir.length + const suffix = '/index.html'.length + folderFiles.forEach(file => + routes.unshift({ + route: file.slice(prefix, -suffix) || '/', + serve: file.slice(prefix) + }) + ) + + const otherFiles = await globby([join(publicDir, '**/*.html'), join(publicDir, '*.html')]) + otherFiles.forEach((file) => { + if (file.endsWith('index.html')) { + return + } + const route = file.slice(prefix, -5) + const existingRouteIndex = routes.findIndex(_route => _route.route === route) + if (existingRouteIndex > -1) { + routes.splice(existingRouteIndex, 1) + } + routes.unshift( + { + route, + serve: file.slice(prefix) + } + ) + }) + + const functionDefinition = { + entryPoint: 'handle', + bindings: [ + { + authLevel: 'anonymous', + type: 'httpTrigger', + direction: 'in', + name: 'req', + route: '{*url}', + methods: ['delete', 'get', 'head', 'options', 'patch', 'post', 'put'] + }, + { + type: 'http', + direction: 'out', + name: 'res' + } + ] + } + + await writeFile(resolve(serverDir, 'function.json'), JSON.stringify(functionDefinition)) + await writeFile(resolve(serverDir, '../host.json'), JSON.stringify(host)) + await writeFile(resolve(publicDir, 'routes.json'), JSON.stringify({ routes })) + if (!indexFileExists) { + await writeFile(indexPath, '') + } + + consola.success('Ready to deploy.') +} diff --git a/packages/nitro/src/presets/azure_functions.ts b/packages/nitro/src/presets/azure_functions.ts new file mode 100644 index 0000000000..fe664de2a8 --- /dev/null +++ b/packages/nitro/src/presets/azure_functions.ts @@ -0,0 +1,74 @@ +import archiver from 'archiver' +import consola from 'consola' +import { createWriteStream } from 'fs-extra' +import { join, resolve } from 'upath' +import { prettyPath, writeFile } from '../utils' +import { NitroPreset, NitroContext } from '../context' + +// eslint-disable-next-line +export const azure_functions: NitroPreset = { + serveStatic: true, + entry: '{{ _internal.runtimeDir }}/entries/azure_functions', + hooks: { + async 'nitro:compiled' (ctx: NitroContext) { + await writeRoutes(ctx) + } + } +} + +function zipDirectory (dir: string, outfile: string): Promise { + const archive = archiver('zip', { zlib: { level: 9 } }) + const stream = createWriteStream(outfile) + + return new Promise((resolve, reject) => { + archive + .directory(dir, false) + .on('error', (err: Error) => reject(err)) + .pipe(stream) + + stream.on('close', () => resolve(undefined)) + archive.finalize() + }) +} + +async function writeRoutes ({ output: { dir, serverDir } }: NitroContext) { + const host = { + version: '2.0', + extensions: { http: { routePrefix: '' } } + } + + const functionDefinition = { + entryPoint: 'handle', + bindings: [ + { + authLevel: 'anonymous', + type: 'httpTrigger', + direction: 'in', + name: 'req', + route: '{*url}', + methods: [ + 'delete', + 'get', + 'head', + 'options', + 'patch', + 'post', + 'put' + ] + }, + { + type: 'http', + direction: 'out', + name: 'res' + } + ] + } + + await writeFile(resolve(serverDir, 'function.json'), JSON.stringify(functionDefinition)) + await writeFile(resolve(dir, 'host.json'), JSON.stringify(host)) + + await zipDirectory(dir, join(dir, 'deploy.zip')) + const zipPath = prettyPath(resolve(dir, 'deploy.zip')) + + consola.success(`Ready to run \`az functionapp deployment source config-zip -g -n --src ${zipPath}\``) +} diff --git a/packages/nitro/src/presets/browser.ts b/packages/nitro/src/presets/browser.ts new file mode 100644 index 0000000000..1c77ef032f --- /dev/null +++ b/packages/nitro/src/presets/browser.ts @@ -0,0 +1,79 @@ +import { writeFile } from 'fs-extra' +import { resolve } from 'upath' +import consola from 'consola' +import { extendPreset, prettyPath } from '../utils' +import { NitroPreset, NitroContext, NitroInput } from '../context' +import { worker } from './worker' + +export const browser: NitroPreset = extendPreset(worker, (input: NitroInput) => { + const routerBase = input._nuxt.routerBase + + const script = `` + + // TEMP FIX + const html = ` + + + + + + + + + + Loading... + + +` + + return { + entry: '{{ _internal.runtimeDir }}/entries/service-worker', + output: { + serverDir: '{{ output.dir }}/public/_server' + }, + nuxtHooks: { + 'vue-renderer:ssr:templateParams' (params) { + params.APP += script + }, + 'vue-renderer:spa:templateParams' (params) { + params.APP += script + } + }, + hooks: { + 'nitro:template:document' (tmpl) { + tmpl.compiled = tmpl.compiled.replace('', script + '') + }, + async 'nitro:compiled' ({ output }: NitroContext) { + await writeFile(resolve(output.publicDir, 'sw.js'), `self.importScripts('${input._nuxt.routerBase}_server/index.js');`) + + // Temp fix + await writeFile(resolve(output.publicDir, 'index.html'), html) + await writeFile(resolve(output.publicDir, '200.html'), html) + await writeFile(resolve(output.publicDir, '404.html'), html) + + consola.info('Ready to deploy to static hosting:', prettyPath(output.publicDir as string)) + } + } + } +}) diff --git a/packages/nitro/src/presets/cli.ts b/packages/nitro/src/presets/cli.ts new file mode 100644 index 0000000000..376cc67a0e --- /dev/null +++ b/packages/nitro/src/presets/cli.ts @@ -0,0 +1,13 @@ +import consola from 'consola' +import { extendPreset, prettyPath } from '../utils' +import { NitroPreset, NitroContext } from '../context' +import { node } from './node' + +export const cli: NitroPreset = extendPreset(node, { + entry: '{{ _internal.runtimeDir }}/entries/cli', + hooks: { + 'nitro:compiled' ({ output }: NitroContext) { + consola.info('Run with `node ' + prettyPath(output.serverDir) + ' [route]`') + } + } +}) diff --git a/packages/nitro/src/presets/cloudflare.ts b/packages/nitro/src/presets/cloudflare.ts new file mode 100644 index 0000000000..4828087b0b --- /dev/null +++ b/packages/nitro/src/presets/cloudflare.ts @@ -0,0 +1,23 @@ +import { resolve } from 'upath' +import consola from 'consola' +import { extendPreset, writeFile, prettyPath } from '../utils' +import { NitroContext, NitroPreset } from '../context' +import { worker } from './worker' + +export const cloudflare: NitroPreset = extendPreset(worker, { + entry: '{{ _internal.runtimeDir }}/entries/cloudflare', + ignore: [ + 'wrangler.toml' + ], + hooks: { + async 'nitro:compiled' ({ output, _nuxt }: NitroContext) { + await writeFile(resolve(output.dir, 'package.json'), JSON.stringify({ private: true, main: './server/index.js' }, null, 2)) + await writeFile(resolve(output.dir, 'package-lock.json'), JSON.stringify({ lockfileVersion: 1 }, null, 2)) + let inDir = prettyPath(_nuxt.rootDir) + if (inDir) { + inDir = 'in ' + inDir + } + consola.success('Ready to run `wrangler publish`', inDir) + } + } +}) diff --git a/packages/nitro/src/presets/dev.ts b/packages/nitro/src/presets/dev.ts new file mode 100644 index 0000000000..c74ad616bd --- /dev/null +++ b/packages/nitro/src/presets/dev.ts @@ -0,0 +1,13 @@ +import { extendPreset } from '../utils' +import { NitroPreset } from '../context' +import { node } from './node' + +export const dev: NitroPreset = extendPreset(node, { + entry: '{{ _internal.runtimeDir }}/entries/dev', + output: { + serverDir: '{{ _nuxt.buildDir }}/nitro' + }, + externals: { trace: false }, + inlineDynamicImports: true, // externals plugin limitation + sourceMap: true +}) diff --git a/packages/nitro/src/presets/firebase.ts b/packages/nitro/src/presets/firebase.ts new file mode 100644 index 0000000000..4a5a9f2a5e --- /dev/null +++ b/packages/nitro/src/presets/firebase.ts @@ -0,0 +1,81 @@ +import { join, relative, resolve } from 'upath' +import { existsSync, readJSONSync } from 'fs-extra' +import consola from 'consola' +import globby from 'globby' + +import { writeFile } from '../utils' +import { NitroPreset, NitroContext } from '../context' + +export const firebase: NitroPreset = { + entry: '{{ _internal.runtimeDir }}/entries/firebase', + hooks: { + async 'nitro:compiled' (ctx: NitroContext) { + await writeRoutes(ctx) + } + } +} + +async function writeRoutes ({ output: { publicDir, serverDir }, _nuxt: { rootDir } }: NitroContext) { + if (!existsSync(join(rootDir, 'firebase.json'))) { + const firebase = { + functions: { + source: relative(rootDir, serverDir) + }, + hosting: [ + { + site: '', + public: relative(rootDir, publicDir), + cleanUrls: true, + rewrites: [ + { + source: '**', + function: 'server' + } + ] + } + ] + } + await writeFile(resolve(rootDir, 'firebase.json'), JSON.stringify(firebase)) + } + + const jsons = await globby(`${serverDir}/node_modules/**/package.json`) + const prefixLength = `${serverDir}/node_modules/`.length + const suffixLength = '/package.json'.length + const dependencies = jsons.reduce((obj, packageJson) => { + const dirname = packageJson.slice(prefixLength, -suffixLength) + if (!dirname.includes('node_modules')) { + obj[dirname] = require(packageJson).version + } + return obj + }, {} as Record) + + let nodeVersion = '12' + try { + const currentNodeVersion = readJSONSync(join(rootDir, 'package.json')).engines.node + if (['12', '10'].includes(currentNodeVersion)) { + nodeVersion = currentNodeVersion + } + } catch {} + + await writeFile( + resolve(serverDir, 'package.json'), + JSON.stringify( + { + private: true, + main: './index.js', + dependencies, + devDependencies: { + 'firebase-functions-test': 'latest', + 'firebase-admin': require('firebase-admin/package.json').version, + 'firebase-functions': require('firebase-functions/package.json') + .version + }, + engines: { node: nodeVersion } + }, + null, + 2 + ) + ) + + consola.success('Ready to run `firebase deploy`') +} diff --git a/packages/nitro/src/presets/index.ts b/packages/nitro/src/presets/index.ts new file mode 100644 index 0000000000..35b743eb4e --- /dev/null +++ b/packages/nitro/src/presets/index.ts @@ -0,0 +1,13 @@ +export * from './azure_functions' +export * from './azure' +export * from './browser' +export * from './cloudflare' +export * from './firebase' +export * from './lambda' +export * from './netlify' +export * from './node' +export * from './dev' +export * from './server' +export * from './cli' +export * from './vercel' +export * from './worker' diff --git a/packages/nitro/src/presets/lambda.ts b/packages/nitro/src/presets/lambda.ts new file mode 100644 index 0000000000..36b6158fa3 --- /dev/null +++ b/packages/nitro/src/presets/lambda.ts @@ -0,0 +1,7 @@ + +import { NitroPreset } from '../context' + +export const lambda: NitroPreset = { + entry: '{{ _internal.runtimeDir }}/entries/lambda', + externals: true +} diff --git a/packages/nitro/src/presets/netlify.ts b/packages/nitro/src/presets/netlify.ts new file mode 100644 index 0000000000..b1e2919426 --- /dev/null +++ b/packages/nitro/src/presets/netlify.ts @@ -0,0 +1,13 @@ +import { extendPreset } from '../utils' +import { NitroPreset } from '../context' +import { lambda } from './lambda' + +export const netlify: NitroPreset = extendPreset(lambda, { + output: { + publicDir: '{{ _nuxt.rootDir }}/dist' + }, + ignore: [ + 'netlify.toml', + '_redirects' + ] +}) diff --git a/packages/nitro/src/presets/node.ts b/packages/nitro/src/presets/node.ts new file mode 100644 index 0000000000..a3230d6be3 --- /dev/null +++ b/packages/nitro/src/presets/node.ts @@ -0,0 +1,6 @@ +import { NitroPreset } from '../context' + +export const node: NitroPreset = { + entry: '{{ _internal.runtimeDir }}/entries/node', + externals: true +} diff --git a/packages/nitro/src/presets/server.ts b/packages/nitro/src/presets/server.ts new file mode 100644 index 0000000000..468ea4081f --- /dev/null +++ b/packages/nitro/src/presets/server.ts @@ -0,0 +1,14 @@ +import consola from 'consola' +import { extendPreset, hl, prettyPath } from '../utils' +import { NitroPreset, NitroContext } from '../context' +import { node } from './node' + +export const server: NitroPreset = extendPreset(node, { + entry: '{{ _internal.runtimeDir }}/entries/server', + serveStatic: true, + hooks: { + 'nitro:compiled' ({ output }: NitroContext) { + consola.success('Ready to run', hl('node ' + prettyPath(output.serverDir))) + } + } +}) diff --git a/packages/nitro/src/presets/vercel.ts b/packages/nitro/src/presets/vercel.ts new file mode 100644 index 0000000000..59b3609042 --- /dev/null +++ b/packages/nitro/src/presets/vercel.ts @@ -0,0 +1,49 @@ +import { resolve } from 'upath' +import { extendPreset, writeFile } from '../utils' +import { NitroPreset, NitroContext } from '../context' +import { node } from './node' + +export const vercel: NitroPreset = extendPreset(node, { + entry: '{{ _internal.runtimeDir }}/entries/vercel', + output: { + dir: '{{ _nuxt.rootDir }}/.vercel_build_output', + serverDir: '{{ output.dir }}/functions/node/server', + publicDir: '{{ output.dir }}/static' + }, + ignore: [ + 'vercel.json' + ], + hooks: { + async 'nitro:compiled' (ctx: NitroContext) { + await writeRoutes(ctx) + } + } +}) + +async function writeRoutes ({ output }: NitroContext) { + const routes = [ + { + src: '/sw.js', + headers: { + 'cache-control': 'public, max-age=0, must-revalidate' + }, + continue: true + }, + { + src: '/_nuxt/(.*)', + headers: { + 'cache-control': 'public,max-age=31536000,immutable' + }, + continue: true + }, + { + handle: 'filesystem' + }, + { + src: '(.*)', + dest: '/.vercel/functions/server/index' + } + ] + + await writeFile(resolve(output.dir, 'config/routes.json'), JSON.stringify(routes, null, 2)) +} diff --git a/packages/nitro/src/presets/worker.ts b/packages/nitro/src/presets/worker.ts new file mode 100644 index 0000000000..2e082ea141 --- /dev/null +++ b/packages/nitro/src/presets/worker.ts @@ -0,0 +1,13 @@ +import { NitroPreset, NitroContext } from '../context' + +export const worker: NitroPreset = { + entry: null, // Abstract + node: false, + minify: true, + inlineDynamicImports: true, // iffe does not support code-splitting + hooks: { + 'nitro:rollup:before' ({ rollupConfig }: NitroContext) { + rollupConfig.output.format = 'iife' + } + } +} diff --git a/packages/nitro/src/rollup/config.ts b/packages/nitro/src/rollup/config.ts new file mode 100644 index 0000000000..b293d3b59f --- /dev/null +++ b/packages/nitro/src/rollup/config.ts @@ -0,0 +1,249 @@ +import { dirname, join, relative, resolve } from 'upath' +import { InputOptions, OutputOptions } from 'rollup' +import defu from 'defu' +import { terser } from 'rollup-plugin-terser' +import commonjs from '@rollup/plugin-commonjs' +import nodeResolve from '@rollup/plugin-node-resolve' +import alias from '@rollup/plugin-alias' +import json from '@rollup/plugin-json' +import replace from '@rollup/plugin-replace' +import virtual from '@rollup/plugin-virtual' +import inject from '@rollup/plugin-inject' +import analyze from 'rollup-plugin-analyzer' +import type { Preset } from '@nuxt/un' +import * as un from '@nuxt/un' + +import { NitroContext } from '../context' +import { resolvePath, MODULE_DIR } from '../utils' + +import { dynamicRequire } from './plugins/dynamic-require' +import { externals } from './plugins/externals' +import { timing } from './plugins/timing' +import { autoMock } from './plugins/automock' +import { staticAssets, dirnames } from './plugins/static' +import { middleware } from './plugins/middleware' +import { esbuild } from './plugins/esbuild' + +export type RollupConfig = InputOptions & { output: OutputOptions } + +export const getRollupConfig = (nitroContext: NitroContext) => { + const extensions: string[] = ['.ts', '.mjs', '.js', '.json', '.node'] + + const nodePreset = nitroContext.node === false ? un.nodeless : un.node + + const builtinPreset: Preset = { + alias: { + // General + debug: 'un/npm/debug', + depd: 'un/npm/depd', + // Vue 2 + encoding: 'un/mock/proxy', + he: 'un/mock/proxy', + resolve: 'un/mock/proxy', + 'source-map': 'un/mock/proxy', + 'lodash.template': 'un/mock/proxy', + 'serialize-javascript': 'un/mock/proxy', + // Vue 3 + '@babel/parser': 'un/mock/proxy', + '@vue/compiler-core': 'un/mock/proxy', + '@vue/compiler-dom': 'un/mock/proxy', + '@vue/compiler-ssr': 'un/mock/proxy' + } + } + + const env = un.env(nodePreset, builtinPreset, nitroContext.env) + + delete env.alias['node-fetch'] // FIX ME + + if (nitroContext.sourceMap) { + env.polyfill.push('source-map-support/register') + } + + const buildServerDir = join(nitroContext._nuxt.buildDir, 'dist/server') + const runtimeAppDir = join(nitroContext._internal.runtimeDir, 'app') + + const rollupConfig: RollupConfig = { + input: resolvePath(nitroContext, nitroContext.entry), + output: { + dir: nitroContext.output.serverDir, + entryFileNames: 'index.js', + chunkFileNames (chunkInfo) { + let prefix = '' + const modules = Object.keys(chunkInfo.modules) + const lastModule = modules[modules.length - 1] + if (lastModule.startsWith(buildServerDir)) { + prefix = join('app', relative(buildServerDir, dirname(lastModule))) + } else if (lastModule.startsWith(runtimeAppDir)) { + prefix = 'app' + } else if (lastModule.startsWith(nitroContext._nuxt.buildDir)) { + prefix = 'nuxt' + } else if (lastModule.startsWith(nitroContext._internal.runtimeDir)) { + prefix = 'nitro' + } else if (!prefix && nitroContext.middleware.find(m => lastModule.startsWith(m.handle))) { + prefix = 'middleware' + } + return join('chunks', prefix, '[name].js') + }, + inlineDynamicImports: nitroContext.inlineDynamicImports, + format: 'cjs', + exports: 'auto', + intro: '', + outro: '', + preferConst: true, + sourcemap: nitroContext.sourceMap, + sourcemapExcludeSources: true, + sourcemapPathTransform (relativePath, sourcemapPath) { + return resolve(dirname(sourcemapPath), relativePath) + } + }, + external: env.external, + plugins: [], + onwarn (warning, rollupWarn) { + if (!['CIRCULAR_DEPENDENCY', 'EVAL'].includes(warning.code)) { + rollupWarn(warning) + } + } + } + + if (nitroContext.timing) { + rollupConfig.plugins.push(timing()) + } + + // https://github.com/rollup/plugins/tree/master/packages/replace + rollupConfig.plugins.push(replace({ + // @ts-ignore https://github.com/rollup/plugins/pull/810 + preventAssignment: true, + values: { + 'process.env.NODE_ENV': nitroContext._nuxt.dev ? '"development"' : '"production"', + 'typeof window': '"undefined"', + 'process.env.ROUTER_BASE': JSON.stringify(nitroContext._nuxt.routerBase), + 'process.env.PUBLIC_PATH': JSON.stringify(nitroContext._nuxt.publicPath), + 'process.env.NUXT_STATIC_BASE': JSON.stringify(nitroContext._nuxt.staticAssets.base), + 'process.env.NUXT_STATIC_VERSION': JSON.stringify(nitroContext._nuxt.staticAssets.version), + 'process.env.NUXT_FULL_STATIC': nitroContext._nuxt.fullStatic as unknown as string, + 'process.env.NITRO_PRESET': JSON.stringify(nitroContext.preset), + 'process.env.RUNTIME_CONFIG': JSON.stringify(nitroContext._nuxt.runtimeConfig), + 'process.env.DEBUG': JSON.stringify(nitroContext._nuxt.dev) + } + })) + + // ESBuild + rollupConfig.plugins.push(esbuild({ + sourceMap: true + })) + + // Dynamic Require Support + rollupConfig.plugins.push(dynamicRequire({ + dir: resolve(nitroContext._nuxt.buildDir, 'dist/server'), + inline: nitroContext.node === false || nitroContext.inlineDynamicImports, + globbyOptions: { + ignore: [ + 'server.js' + ] + } + })) + + // Static + if (nitroContext.serveStatic) { + rollupConfig.plugins.push(dirnames()) + rollupConfig.plugins.push(staticAssets(nitroContext)) + } + + // Middleware + rollupConfig.plugins.push(middleware(() => { + const _middleware = [ + ...nitroContext.scannedMiddleware, + ...nitroContext.middleware + ] + if (nitroContext.serveStatic) { + _middleware.unshift({ route: '/', handle: '~runtime/server/static' }) + } + return _middleware + })) + + // Polyfill + rollupConfig.plugins.push(virtual({ + '~polyfill': env.polyfill.map(p => `import '${p}';`).join('\n') + })) + + // https://github.com/rollup/plugins/tree/master/packages/alias + const renderer = nitroContext.renderer || (nitroContext._nuxt.majorVersion === 3 ? 'vue3' : 'vue2') + const vue2ServerRenderer = 'vue-server-renderer/' + (nitroContext._nuxt.dev ? 'build.dev.js' : 'build.prod.js') + rollupConfig.plugins.push(alias({ + entries: { + '~runtime': nitroContext._internal.runtimeDir, + '~renderer': require.resolve(resolve(nitroContext._internal.runtimeDir, 'app', renderer)), + '~vueServerRenderer': vue2ServerRenderer, + '~build': nitroContext._nuxt.buildDir, + ...env.alias + } + })) + + const moduleDirectories = [ + resolve(nitroContext._nuxt.rootDir, 'node_modules'), + resolve(MODULE_DIR, 'node_modules'), + resolve(MODULE_DIR, '../node_modules'), + 'node_modules' + ] + + // Externals Plugin + if (nitroContext.externals) { + rollupConfig.plugins.push(externals(defu(nitroContext.externals as any, { + outDir: nitroContext.output.serverDir, + moduleDirectories, + ignore: [ + nitroContext._internal.runtimeDir, + ...(nitroContext._nuxt.dev ? [] : [nitroContext._nuxt.buildDir]), + ...nitroContext.middleware.map(m => m.handle), + nitroContext._nuxt.serverDir + ], + traceOptions: { + base: nitroContext._nuxt.rootDir + } + }))) + } + + // https://github.com/rollup/plugins/tree/master/packages/node-resolve + rollupConfig.plugins.push(nodeResolve({ + extensions, + preferBuiltins: true, + rootDir: nitroContext._nuxt.rootDir, + moduleDirectories, + mainFields: ['main'] // Force resolve CJS (@vue/runtime-core ssrUtils) + })) + + // Automatically mock unresolved externals + rollupConfig.plugins.push(autoMock()) + + // https://github.com/rollup/plugins/tree/master/packages/commonjs + rollupConfig.plugins.push(commonjs({ + extensions: extensions.filter(ext => ext !== '.json') + })) + + // https://github.com/rollup/plugins/tree/master/packages/json + rollupConfig.plugins.push(json()) + + // https://github.com/rollup/plugins/tree/master/packages/inject + rollupConfig.plugins.push(inject(env.inject)) + + if (nitroContext.analyze) { + // https://github.com/doesdev/rollup-plugin-analyzer + rollupConfig.plugins.push(analyze()) + } + + // https://github.com/TrySound/rollup-plugin-terser + // https://github.com/terser/terser#minify-nitroContext + if (nitroContext.minify) { + rollupConfig.plugins.push(terser({ + mangle: { + keep_fnames: true, + keep_classnames: true + }, + format: { + comments: false + } + })) + } + + return rollupConfig +} diff --git a/packages/nitro/src/rollup/plugins/automock.ts b/packages/nitro/src/rollup/plugins/automock.ts new file mode 100644 index 0000000000..7393c5820d --- /dev/null +++ b/packages/nitro/src/rollup/plugins/automock.ts @@ -0,0 +1,13 @@ +export function autoMock () { + return { + name: 'auto-mock', + resolveId (src: string) { + if (src && !src.startsWith('.') && !src.includes('?') && !src.includes('.js')) { + return { + id: require.resolve('@nuxt/un/runtime/mock/proxy') + } + } + return null + } + } +} diff --git a/packages/nitro/src/rollup/plugins/dynamic-require.ts b/packages/nitro/src/rollup/plugins/dynamic-require.ts new file mode 100644 index 0000000000..6785352d50 --- /dev/null +++ b/packages/nitro/src/rollup/plugins/dynamic-require.ts @@ -0,0 +1,136 @@ +import { resolve } from 'upath' +import globby, { GlobbyOptions } from 'globby' +import type { Plugin } from 'rollup' + +const PLUGIN_NAME = 'dynamic-require' +const HELPER_DYNAMIC = `\0${PLUGIN_NAME}.js` +const DYNAMIC_REQUIRE_RE = /require\("\.\/" ?\+/g + +interface Options { + dir: string + inline: boolean + globbyOptions: GlobbyOptions + outDir?: string + prefix?: string +} + +interface Chunk { + id: string + src: string + name: string + meta?: { + id?: string + ids?: string[] + moduleIds?: string[] + } +} + +interface TemplateContext { + chunks: Chunk[] +} + +export function dynamicRequire ({ dir, globbyOptions, inline }: Options): Plugin { + return { + name: PLUGIN_NAME, + transform (code: string, _id: string) { + return { + code: code.replace(DYNAMIC_REQUIRE_RE, `require('${HELPER_DYNAMIC}')(`), + map: null + } + }, + resolveId (id: string) { + return id === HELPER_DYNAMIC ? id : null + }, + // TODO: Async chunk loading over netwrok! + // renderDynamicImport () { + // return { + // left: 'fetch(', right: ')' + // } + // }, + async load (_id: string) { + if (_id !== HELPER_DYNAMIC) { + return null + } + + // Scan chunks + const files = await globby('**/*.js', { cwd: dir, absolute: false, ...globbyOptions }) + const chunks = files.map(id => ({ + id, + src: resolve(dir, id).replace(/\\/g, '/'), + name: '_' + id.replace(/[^a-zA-Z0-9_]/g, '_'), + meta: getWebpackChunkMeta(resolve(dir, id)) + })) + + return inline ? TMPL_INLINE({ chunks }) : TMPL_LAZY({ chunks }) + }, + renderChunk (code) { + if (inline) { + return { + map: null, + code + } + } + return { + map: null, + code: code.replace( + /Promise.resolve\(\).then\(function \(\) \{ return require\('([^']*)' \/\* webpackChunk \*\/\); \}\).then\(function \(n\) \{ return n.([_a-zA-Z0-9]*); \}\)/g, + "require('$1').$2") + } + } + } +} + +function getWebpackChunkMeta (src: string) { + const chunk = require(src) || {} + const { id, ids, modules } = chunk + return { + id, + ids, + moduleIds: Object.keys(modules) + } +} + +function TMPL_INLINE ({ chunks }: TemplateContext) { + return `${chunks.map(i => `import ${i.name} from '${i.src}'`).join('\n')} +const dynamicChunks = { + ${chunks.map(i => ` ['${i.id}']: ${i.name}`).join(',\n')} +}; + +export default function dynamicRequire(id) { + return dynamicChunks[id]; +};` +} + +function TMPL_LAZY ({ chunks }: TemplateContext) { + return ` +function dynamicWebpackModule(id, getChunk) { + return function (module, exports, require) { + const r = getChunk() + if (r instanceof Promise) { + module.exports = r.then(r => { + const realModule = { exports: {}, require }; + r.modules[id](realModule, realModule.exports, realModule.require); + return realModule.exports; + }); + } else { + r.modules[id](module, exports, require); + } + }; +}; + +function webpackChunk (meta, getChunk) { + const chunk = { ...meta, modules: {} }; + for (const id of meta.moduleIds) { + chunk.modules[id] = dynamicWebpackModule(id, getChunk); + }; + return chunk; +}; + +const dynamicChunks = { +${chunks.map(i => ` ['${i.id}']: () => webpackChunk(${JSON.stringify(i.meta)}, () => import('${i.src}' /* webpackChunk */))`).join(',\n')} +}; + +export default function dynamicRequire(id) { + return dynamicChunks[id](); +};` +} diff --git a/packages/nitro/src/rollup/plugins/esbuild.ts b/packages/nitro/src/rollup/plugins/esbuild.ts new file mode 100644 index 0000000000..feaef7b1d2 --- /dev/null +++ b/packages/nitro/src/rollup/plugins/esbuild.ts @@ -0,0 +1,165 @@ +// Based on https://github.com/egoist/rollup-plugin-esbuild (MIT) + +import { extname, relative } from 'path' +import { Plugin, PluginContext } from 'rollup' +import { startService, Loader, Service, TransformResult } from 'esbuild' +import { createFilter, FilterPattern } from '@rollup/pluginutils' + +const defaultLoaders: { [ext: string]: Loader } = { + '.ts': 'ts', + '.js': 'js' +} + +export type Options = { + include?: FilterPattern + exclude?: FilterPattern + sourceMap?: boolean + minify?: boolean + target?: string | string[] + jsxFactory?: string + jsxFragment?: string + define?: { + [k: string]: string + } + /** + * Use this tsconfig file instead + * Disable it by setting to `false` + */ + tsconfig?: string | false + /** + * Map extension to esbuild loader + * Note that each entry (the extension) needs to start with a dot + */ + loaders?: { + [ext: string]: Loader | false + } +} + +export function esbuild (options: Options = {}): Plugin { + let target: string | string[] + + const loaders = { + ...defaultLoaders + } + + if (options.loaders) { + for (const key of Object.keys(options.loaders)) { + const value = options.loaders[key] + if (typeof value === 'string') { + loaders[key] = value + } else if (value === false) { + delete loaders[key] + } + } + } + + const extensions: string[] = Object.keys(loaders) + const INCLUDE_REGEXP = new RegExp( + `\\.(${extensions.map(ext => ext.slice(1)).join('|')})$` + ) + const EXCLUDE_REGEXP = /node_modules/ + + const filter = createFilter( + options.include || INCLUDE_REGEXP, + options.exclude || EXCLUDE_REGEXP + ) + + let service: Service | undefined + + const stopService = () => { + if (service) { + service.stop() + service = undefined + } + } + + return { + name: 'esbuild', + + async buildStart () { + if (!service) { + service = await startService() + } + }, + + async transform (code, id) { + if (!filter(id)) { + return null + } + + const ext = extname(id) + const loader = loaders[ext] + + if (!loader || !service) { + return null + } + + target = options.target || 'node12' + + const result = await service.transform(code, { + loader, + target, + define: options.define, + sourcemap: options.sourceMap !== false, + sourcefile: id + }) + + printWarnings(id, result, this) + + return ( + result.code && { + code: result.code, + map: result.map || null + } + ) + }, + + buildEnd (error) { + // Stop the service early if there's error + if (error && !this.meta.watchMode) { + stopService() + } + }, + + async renderChunk (code) { + if (options.minify && service) { + const result = await service.transform(code, { + loader: 'js', + minify: true, + target + }) + if (result.code) { + return { + code: result.code, + map: result.map || null + } + } + } + return null + }, + + generateBundle () { + if (!this.meta.watchMode) { + stopService() + } + } + } +} + +function printWarnings ( + id: string, + result: TransformResult, + plugin: PluginContext +) { + if (result.warnings) { + for (const warning of result.warnings) { + let message = '[esbuild]' + if (warning.location) { + message += ` (${relative(process.cwd(), id)}:${warning.location.line}:${warning.location.column + })` + } + message += ` ${warning.text}` + plugin.warn(message) + } + } +} diff --git a/packages/nitro/src/rollup/plugins/externals.ts b/packages/nitro/src/rollup/plugins/externals.ts new file mode 100644 index 0000000000..be032b389b --- /dev/null +++ b/packages/nitro/src/rollup/plugins/externals.ts @@ -0,0 +1,62 @@ +import { isAbsolute, relative } from 'path' +import type { Plugin } from 'rollup' +import { resolve, dirname } from 'upath' +import { copyFile, mkdirp } from 'fs-extra' +import { nodeFileTrace, NodeFileTraceOptions } from '@vercel/nft' + +export interface NodeExternalsOptions { + ignore?: string[] + outDir?: string + trace?: boolean + traceOptions?: NodeFileTraceOptions + moduleDirectories?: string[] +} + +export function externals (opts: NodeExternalsOptions): Plugin { + const resolvedExternals = {} + return { + name: 'node-externals', + resolveId (id) { + // Internals + if (id.startsWith('\x00') || id.includes('?')) { + return null + } + + // Resolve relative paths and exceptions + if (id.startsWith('.') || opts.ignore.find(i => id.startsWith(i))) { + return null + } + + for (const dir of opts.moduleDirectories) { + if (id.startsWith(dir)) { + id = id.substr(dir.length + 1) + break + } + } + + try { + resolvedExternals[id] = require.resolve(id, { paths: opts.moduleDirectories }) + } catch (_err) { } + + return { + id: isAbsolute(id) ? relative(opts.outDir, id) : id, + external: true + } + }, + async buildEnd () { + if (opts.trace !== false) { + const { fileList } = await nodeFileTrace(Object.values(resolvedExternals), opts.traceOptions) + await Promise.all(fileList.map(async (file) => { + if (!file.startsWith('node_modules')) { + return + } + // TODO: Minify package.json + const src = resolve(opts.traceOptions.base, file) + const dst = resolve(opts.outDir, file) + await mkdirp(dirname(dst)) + await copyFile(src, dst) + })) + } + } + } +} diff --git a/packages/nitro/src/rollup/plugins/middleware.ts b/packages/nitro/src/rollup/plugins/middleware.ts new file mode 100644 index 0000000000..de274f3970 --- /dev/null +++ b/packages/nitro/src/rollup/plugins/middleware.ts @@ -0,0 +1,67 @@ +import hasha from 'hasha' +import { relative } from 'upath' +import { table, getBorderCharacters } from 'table' +import isPrimitive from 'is-primitive' +import stdenv from 'std-env' +import type { ServerMiddleware } from '../../server/middleware' +import virtual from './virtual' + +export function middleware (getMiddleware: () => ServerMiddleware[]) { + const getImportId = p => '_' + hasha(p).substr(0, 6) + + let lastDump = '' + + return virtual({ + '~serverMiddleware': () => { + const middleware = getMiddleware() + + if (!stdenv.test) { + const dumped = dumpMiddleware(middleware) + if (dumped !== lastDump) { + lastDump = dumped + if (middleware.length) { + console.log(dumped) + } + } + } + + return ` +${middleware.filter(m => m.lazy === false).map(m => `import ${getImportId(m.handle)} from '${m.handle}';`).join('\n')} + +${middleware.filter(m => m.lazy !== false).map(m => `const ${getImportId(m.handle)} = () => import('${m.handle}');`).join('\n')} + +const middleware = [ + ${middleware.map(m => `{ route: '${m.route}', handle: ${getImportId(m.handle)}, lazy: ${m.lazy || true}, promisify: ${m.promisify !== undefined ? m.promisify : true} }`).join(',\n')} +]; + +export default middleware +` + } + }) +} + +function dumpMiddleware (middleware: ServerMiddleware[]) { + const data = middleware.map(({ route, handle, ...props }) => { + return [ + (route && route !== '/') ? route : '*', + relative(process.cwd(), handle), + dumpObject(props) + ] + }) + return table([ + ['Route', 'Handle', 'Options'], + ...data + ], { + singleLine: true, + border: getBorderCharacters('norc') + }) +} + +function dumpObject (obj: any) { + const items = [] + for (const key in obj) { + const val = obj[key] + items.push(`${key}: ${isPrimitive(val) ? val : JSON.stringify(val)}`) + } + return items.join(', ') +} diff --git a/packages/nitro/src/rollup/plugins/static.ts b/packages/nitro/src/rollup/plugins/static.ts new file mode 100644 index 0000000000..1e5079dfa7 --- /dev/null +++ b/packages/nitro/src/rollup/plugins/static.ts @@ -0,0 +1,58 @@ +import createEtag from 'etag' +import { readFileSync, statSync } from 'fs-extra' +import mime from 'mime' +import { relative, resolve } from 'upath' +import virtual from '@rollup/plugin-virtual' +import globby from 'globby' +import type { Plugin } from 'rollup' +import type { NitroContext } from '../../context' + +export function staticAssets (context: NitroContext) { + const assets: Record = {} + + const files = globby.sync('**/*.*', { cwd: context.output.publicDir, absolute: false }) + + for (const id of files) { + let type = mime.getType(id) || 'text/plain' + if (type.startsWith('text')) { type += '; charset=utf-8' } + const fullPath = resolve(context.output.publicDir, id) + const etag = createEtag(readFileSync(fullPath)) + const stat = statSync(fullPath) + + assets['/' + id] = { + type, + etag, + mtime: stat.mtime.toJSON(), + path: relative(context.output.serverDir, fullPath) + } + } + + return virtual({ + '~static-assets': `export default ${JSON.stringify(assets, null, 2)};`, + '~static': ` +import { promises } from 'fs' +import { resolve } from 'path' +import assets from '~static-assets' + +export function readAsset (id) { + return promises.readFile(resolve(mainDir, getAsset(id).path)) +} + +export function getAsset (id) { + return assets[id] +} +` + }) +} + +export function dirnames (): Plugin { + return { + name: 'dirnames', + renderChunk (code, chunk) { + return { + code: code + (chunk.isEntry ? 'global.mainDir="undefined"!=typeof __dirname?__dirname:require.main.filename;' : ''), + map: null + } + } + } +} diff --git a/packages/nitro/src/rollup/plugins/timing.ts b/packages/nitro/src/rollup/plugins/timing.ts new file mode 100644 index 0000000000..2b8453d344 --- /dev/null +++ b/packages/nitro/src/rollup/plugins/timing.ts @@ -0,0 +1,40 @@ +import { extname } from 'upath' +import type { Plugin, RenderedChunk } from 'rollup' + +export interface Options { } + +const TIMING = 'global.__timing__' + +const iife = code => `(function() { ${code.trim()} })();`.replace(/\n/g, '') + +// https://gist.github.com/pi0/1476085924f8a2eb1df85929c20cb43f +const POLYFILL = `const global="undefined"!=typeof globalThis?globalThis:void 0!==o?o:"undefined"!=typeof self?self:{}; +global.process = global.process || {}; +const o=Date.now(),t=()=>Date.now()-o;global.process.hrtime=global.process.hrtime||(o=>{const e=Math.floor(.001*(Date.now()-t())),a=.001*t();let l=Math.floor(a)+e,n=Math.floor(a%1*1e9);return o&&(l-=o[0],n-=o[1],n<0&&(l--,n+=1e9)),[l,n]});` + +const HELPER = POLYFILL + iife(` +const hrtime = global.process.hrtime; +const start = () => hrtime(); +const end = s => { const d = hrtime(s); return ((d[0] * 1e9) + d[1]) / 1e6; }; + +const _s = {}; +const metrics = []; +const logStart = id => { _s[id] = hrtime(); }; +const logEnd = id => { const t = end(_s[id]); delete _s[id]; metrics.push([id, t]); console.debug('>', id + ' (' + t + 'ms)'); }; +${TIMING} = { hrtime, start, end, metrics, logStart, logEnd }; +`) + +export function timing (_opts: Options = {}): Plugin { + return { + name: 'timing', + renderChunk (code, chunk: RenderedChunk) { + let name = chunk.fileName || '' + name = name.replace(extname(name), '') + const logName = name === 'index' ? 'Cold Start' : ('Load ' + name) + return { + code: (chunk.isEntry ? HELPER : '') + `${TIMING}.logStart('${logName}');` + code + `;${TIMING}.logEnd('${logName}');`, + map: null + } + } + } +} diff --git a/packages/nitro/src/rollup/plugins/virtual.ts b/packages/nitro/src/rollup/plugins/virtual.ts new file mode 100644 index 0000000000..bf0e0a1c79 --- /dev/null +++ b/packages/nitro/src/rollup/plugins/virtual.ts @@ -0,0 +1,49 @@ +// Based on https://github.com/rollup/plugins/blob/master/packages/virtual/src/index.ts +import * as path from 'path' + +import { Plugin } from 'rollup' + +type UnresolvedModule = string | (() => string) +export interface RollupVirtualOptions { + [id: string]: UnresolvedModule; +} + +const PREFIX = '\0virtual:' + +const resolveModule = (m: UnresolvedModule) => typeof m === 'function' ? m() : m + +export default function virtual (modules: RollupVirtualOptions): Plugin { + const resolvedIds = new Map string)>() + + Object.keys(modules).forEach((id) => { + resolvedIds.set(path.resolve(id), modules[id]) + }) + + return { + name: 'virtual', + + resolveId (id, importer) { + if (id in modules) { return PREFIX + id } + + if (importer) { + const importerNoPrefix = importer.startsWith(PREFIX) + ? importer.slice(PREFIX.length) + : importer + const resolved = path.resolve(path.dirname(importerNoPrefix), id) + if (resolvedIds.has(resolved)) { return PREFIX + resolved } + } + + return null + }, + + load (id) { + if (!id.startsWith(PREFIX)) { + return null + } + const idNoPrefix = id.slice(PREFIX.length) + return idNoPrefix in modules + ? resolveModule(modules[idNoPrefix]) + : resolveModule(resolvedIds.get(idNoPrefix)) + } + } +} diff --git a/packages/nitro/src/runtime/app/config.ts b/packages/nitro/src/runtime/app/config.ts new file mode 100644 index 0000000000..b026f824ea --- /dev/null +++ b/packages/nitro/src/runtime/app/config.ts @@ -0,0 +1,19 @@ +import destr from 'destr' + +const runtimeConfig = process.env.RUNTIME_CONFIG as any + +for (const type of ['private', 'public']) { + for (const key in runtimeConfig[type]) { + runtimeConfig[type][key] = destr(process.env[key] || runtimeConfig[type][key]) + } +} + +const $config = global.$config = { + ...runtimeConfig.public, + ...runtimeConfig.private +} + +export default { + public: runtimeConfig.public, + private: $config +} diff --git a/packages/nitro/src/runtime/app/nitro.client.js b/packages/nitro/src/runtime/app/nitro.client.js new file mode 100644 index 0000000000..bb02fa8030 --- /dev/null +++ b/packages/nitro/src/runtime/app/nitro.client.js @@ -0,0 +1,8 @@ +import _global from '@nuxt/un/runtime/global' +import { $fetch } from 'ohmyfetch' + +_global.process = _global.process || {}; + +(function () { const o = Date.now(); const t = () => Date.now() - o; _global.process.hrtime = _global.process.hrtime || ((o) => { const e = Math.floor(0.001 * (Date.now() - t())); const a = 0.001 * t(); let l = Math.floor(a) + e; let n = Math.floor(a % 1 * 1e9); return o && (l -= o[0], n -= o[1], n < 0 && (l--, n += 1e9)), [l, n] }) })() + +global.$fetch = $fetch diff --git a/packages/nitro/src/runtime/app/render.ts b/packages/nitro/src/runtime/app/render.ts new file mode 100644 index 0000000000..644d294134 --- /dev/null +++ b/packages/nitro/src/runtime/app/render.ts @@ -0,0 +1,82 @@ +import { createRenderer } from 'vue-bundle-renderer' +import devalue from '@nuxt/devalue' +import config from './config' +// @ts-ignore +import { renderToString } from '~renderer' +// @ts-ignore +import createApp from '~build/dist/server/server' +// @ts-ignore +import clientManifest from '~build/dist/server/client.manifest.json' +// @ts-ignore +import htmlTemplate from '~build/views/document.template.js' + +function _interopDefault (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e } + +const renderer = createRenderer(_interopDefault(createApp), { + clientManifest: _interopDefault(clientManifest), + renderToString +}) + +const STATIC_ASSETS_BASE = process.env.NUXT_STATIC_BASE + '/' + process.env.NUXT_STATIC_VERSION +const PAYLOAD_JS = '/payload.js' + +export async function renderMiddleware (req, res) { + let url = req.url + + // payload.json request detection + let isPayloadReq = false + if (url.startsWith(STATIC_ASSETS_BASE) && url.endsWith(PAYLOAD_JS)) { + isPayloadReq = true + url = url.substr(STATIC_ASSETS_BASE.length, url.length - STATIC_ASSETS_BASE.length - PAYLOAD_JS.length) + } + + const ssrContext = { + url, + runtimeConfig: { + public: config.public, + private: config.private + }, + ...(req.context || {}) + } + const rendered = await renderer.renderToString(ssrContext) + // TODO: nuxt3 should not reuse `nuxt` property for different purpose! + const payload = ssrContext.payload /* nuxt 3 */ || ssrContext.nuxt /* nuxt 2 */ + + if (process.env.NUXT_FULL_STATIC) { + payload.staticAssetsBase = STATIC_ASSETS_BASE + } + + let data + if (isPayloadReq) { + data = renderPayload(payload, url) + res.setHeader('Content-Type', 'text/javascript;charset=UTF-8') + } else { + data = renderHTML(payload, rendered, ssrContext) + res.setHeader('Content-Type', 'text/html;charset=UTF-8') + } + + const error = ssrContext.nuxt && ssrContext.nuxt.error + res.statusCode = error ? error.statusCode : 200 + res.end(data, 'utf-8') +} + +function renderHTML (payload, rendered, ssrContext) { + const state = `` + const _html = rendered.html + + const { htmlAttrs = '', bodyAttrs = '', headTags = '', headAttrs = '' } = + (ssrContext.head && ssrContext.head()) || {} + + return htmlTemplate({ + HTML_ATTRS: htmlAttrs, + HEAD_ATTRS: headAttrs, + BODY_ATTRS: bodyAttrs, + HEAD: headTags + + rendered.renderResourceHints() + rendered.renderStyles() + (ssrContext.styles || ''), + APP: _html + state + rendered.renderScripts() + }) +} + +function renderPayload (payload, url) { + return `__NUXT_JSONP__("${url}", ${devalue(payload)})` +} diff --git a/packages/nitro/src/runtime/app/vue2.basic.ts b/packages/nitro/src/runtime/app/vue2.basic.ts new file mode 100644 index 0000000000..cdae5c872c --- /dev/null +++ b/packages/nitro/src/runtime/app/vue2.basic.ts @@ -0,0 +1,12 @@ +import _renderToString from 'vue-server-renderer/basic' + +export function renderToString (component, context) { + return new Promise((resolve, reject) => { + _renderToString(component, context, (err, result) => { + if (err) { + return reject(err) + } + return resolve(result) + }) + }) +} diff --git a/packages/nitro/src/runtime/app/vue2.ts b/packages/nitro/src/runtime/app/vue2.ts new file mode 100644 index 0000000000..0de42f4c57 --- /dev/null +++ b/packages/nitro/src/runtime/app/vue2.ts @@ -0,0 +1,15 @@ +// @ts-ignore +import { createRenderer } from '~vueServerRenderer' + +const _renderer = createRenderer({}) + +export function renderToString (component, context) { + return new Promise((resolve, reject) => { + _renderer.renderToString(component, context, (err, result) => { + if (err) { + return reject(err) + } + return resolve(result) + }) + }) +} diff --git a/packages/nitro/src/runtime/app/vue3.ts b/packages/nitro/src/runtime/app/vue3.ts new file mode 100644 index 0000000000..f3fd7daa59 --- /dev/null +++ b/packages/nitro/src/runtime/app/vue3.ts @@ -0,0 +1,2 @@ +// @ts-ignore +export { renderToString } from '@vue/server-renderer' diff --git a/packages/nitro/src/runtime/entries/azure.ts b/packages/nitro/src/runtime/entries/azure.ts new file mode 100644 index 0000000000..04621ab38a --- /dev/null +++ b/packages/nitro/src/runtime/entries/azure.ts @@ -0,0 +1,28 @@ +import '~polyfill' +import { parseURL } from 'ufo' +import { localCall } from '../server' + +export default async function handle (context, req) { + let url: string + if (req.headers['x-ms-original-url']) { + // This URL has been proxied as there was no static file matching it. + url = parseURL(req.headers['x-ms-original-url']).pathname + } else { + // Because Azure SWA handles /api/* calls differently they + // never hit the proxy and we have to reconstitute the URL. + url = '/api/' + (req.params.url || '') + } + + const { body, status, statusText, headers } = await localCall({ + url, + headers: req.headers, + method: req.method, + body: req.body + }) + + context.res = { + status, + headers, + body: body ? body.toString() : statusText + } +} diff --git a/packages/nitro/src/runtime/entries/azure_functions.ts b/packages/nitro/src/runtime/entries/azure_functions.ts new file mode 100644 index 0000000000..eb309aa7f3 --- /dev/null +++ b/packages/nitro/src/runtime/entries/azure_functions.ts @@ -0,0 +1,19 @@ +import '~polyfill' +import { localCall } from '../server' + +export default async function handle (context, req) { + const url = '/' + (req.params.url || '') + + const { body, status, statusText, headers } = await localCall({ + url, + headers: req.headers, + method: req.method, + body: req.body + }) + + context.res = { + status, + headers, + body: body ? body.toString() : statusText + } +} diff --git a/packages/nitro/src/runtime/entries/cli.ts b/packages/nitro/src/runtime/entries/cli.ts new file mode 100644 index 0000000000..91d3141266 --- /dev/null +++ b/packages/nitro/src/runtime/entries/cli.ts @@ -0,0 +1,24 @@ +import '~polyfill' +import { localCall } from '../server' + +async function cli () { + const url = process.argv[2] || '/' + const debug = (label, ...args) => console.debug(`> ${label}:`, ...args) + const r = await localCall({ url }) + + debug('URL', url) + debug('StatusCode', r.status) + debug('StatusMessage', r.statusText) + // @ts-ignore + for (const header of r.headers.entries()) { + debug(header[0], header[1]) + } + console.log('\n', r.body.toString()) +} + +if (require.main === module) { + cli().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/packages/nitro/src/runtime/entries/cloudflare.ts b/packages/nitro/src/runtime/entries/cloudflare.ts new file mode 100644 index 0000000000..dd953b9db1 --- /dev/null +++ b/packages/nitro/src/runtime/entries/cloudflare.ts @@ -0,0 +1,47 @@ +import '~polyfill' +import { getAssetFromKV } from '@cloudflare/kv-asset-handler' +import { localCall } from '../server' + +const PUBLIC_PATH = process.env.PUBLIC_PATH // Default: /_nuxt/ + +addEventListener('fetch', (event: any) => { + event.respondWith(handleEvent(event)) +}) + +async function handleEvent (event) { + try { + return await getAssetFromKV(event, { cacheControl: assetsCacheControl }) + } catch (_err) { + // Ignore + } + + const url = new URL(event.request.url) + + const r = await localCall({ + event, + url: url.pathname + url.search, + host: url.hostname, + protocol: url.protocol, + headers: event.request.headers, + method: event.request.method, + redirect: event.request.redirect, + body: event.request.body + }) + + return new Response(r.body, { + // @ts-ignore + headers: r.headers, + status: r.status, + statusText: r.statusText + }) +} + +function assetsCacheControl (request) { + if (request.url.includes(PUBLIC_PATH) /* TODO: Check with routerBase */) { + return { + browserTTL: 31536000, + edgeTTL: 31536000 + } + } + return {} +} diff --git a/packages/nitro/src/runtime/entries/dev.ts b/packages/nitro/src/runtime/entries/dev.ts new file mode 100644 index 0000000000..7386fb5f39 --- /dev/null +++ b/packages/nitro/src/runtime/entries/dev.ts @@ -0,0 +1,14 @@ +import '~polyfill' +import { Server } from 'http' +import { parentPort } from 'worker_threads' +import type { AddressInfo } from 'net' +import { handle } from '../server' + +const server = new Server(handle) + +const netServer = server.listen(0, () => { + parentPort.postMessage({ + event: 'listen', + port: (netServer.address() as AddressInfo).port + }) +}) diff --git a/packages/nitro/src/runtime/entries/firebase.ts b/packages/nitro/src/runtime/entries/firebase.ts new file mode 100644 index 0000000000..ffbeba419f --- /dev/null +++ b/packages/nitro/src/runtime/entries/firebase.ts @@ -0,0 +1,7 @@ +import '~polyfill' + +import { handle } from '../server' + +const functions = require('firebase-functions') + +export const server = functions.https.onRequest(handle) diff --git a/packages/nitro/src/runtime/entries/lambda.ts b/packages/nitro/src/runtime/entries/lambda.ts new file mode 100644 index 0000000000..897eb689bc --- /dev/null +++ b/packages/nitro/src/runtime/entries/lambda.ts @@ -0,0 +1,21 @@ +import '~polyfill' +import { withQuery } from 'ufo' +import { localCall } from '../server' + +export async function handler (event, context) { + const r = await localCall({ + event, + url: withQuery(event.path, event.queryStringParameters), + context, + headers: event.headers, + method: event.httpMethod, + query: event.queryStringParameters, + body: event.body // TODO: handle event.isBase64Encoded + }) + + return { + statusCode: r.status, + headers: r.headers, + body: r.body.toString() + } +} diff --git a/packages/nitro/src/runtime/entries/node.ts b/packages/nitro/src/runtime/entries/node.ts new file mode 100644 index 0000000000..5646685d25 --- /dev/null +++ b/packages/nitro/src/runtime/entries/node.ts @@ -0,0 +1,2 @@ +import '~polyfill' +export * from '../server' diff --git a/packages/nitro/src/runtime/entries/server.ts b/packages/nitro/src/runtime/entries/server.ts new file mode 100644 index 0000000000..a03ce951e7 --- /dev/null +++ b/packages/nitro/src/runtime/entries/server.ts @@ -0,0 +1,20 @@ +import '~polyfill' +import { Server } from 'http' +import destr from 'destr' +import { handle } from '../server' + +const server = new Server(handle) + +const port = (destr(process.env.NUXT_PORT || process.env.PORT) || 3000) as number +const hostname = process.env.NUXT_HOST || process.env.HOST || 'localhost' + +// @ts-ignore +server.listen(port, hostname, (err) => { + if (err) { + console.error(err) + process.exit(1) + } + console.log(`Listening on http://${hostname}:${port}`) +}) + +export default {} diff --git a/packages/nitro/src/runtime/entries/service-worker.ts b/packages/nitro/src/runtime/entries/service-worker.ts new file mode 100644 index 0000000000..6db71038cc --- /dev/null +++ b/packages/nitro/src/runtime/entries/service-worker.ts @@ -0,0 +1,40 @@ +// @ts-nocheck +import '~polyfill' +import { localCall } from '../server' + +addEventListener('fetch', (event: any) => { + const url = new URL(event.request.url) + + if (url.pathname.includes('.') /* is file */) { + return + } + + event.respondWith(handleEvent(url, event)) +}) + +async function handleEvent (url, event) { + const r = await localCall({ + event, + url: url.pathname, + host: url.hostname, + protocol: url.protocol, + headers: event.request.headers, + method: event.request.method, + redirect: event.request.redirect, + body: event.request.body + }) + + return new Response(r.body, { + headers: r.headers, + status: r.status, + statusText: r.statusText + }) +} + +self.addEventListener('install', () => { + self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()) +}) diff --git a/packages/nitro/src/runtime/entries/vercel.ts b/packages/nitro/src/runtime/entries/vercel.ts new file mode 100644 index 0000000000..3967889b40 --- /dev/null +++ b/packages/nitro/src/runtime/entries/vercel.ts @@ -0,0 +1,4 @@ +import '~polyfill' +import { handle } from '../server' + +export default handle diff --git a/packages/nitro/src/runtime/server/error.ts b/packages/nitro/src/runtime/server/error.ts new file mode 100644 index 0000000000..155325d125 --- /dev/null +++ b/packages/nitro/src/runtime/server/error.ts @@ -0,0 +1,67 @@ +// import ansiHTML from 'ansi-html' +const cwd = process.cwd() + +// TODO: Handle process.env.DEBUG +export function handleError (error, req, res) { + const stack = (error.stack || '') + .split('\n') + .splice(1) + .filter(line => line.includes('at ')) + .map((line) => { + const text = line + .replace(cwd + '/', './') + .replace('webpack:/', '') + .replace('.vue', '.js') // TODO: Support sourcemap + .trim() + return { + text, + internal: (line.includes('node_modules') && !line.includes('.cache')) || + line.includes('internal') || + line.includes('new Promise') + } + }) + + console.error(error.message + '\n' + stack.map(l => ' ' + l.text).join(' \n')) + + const html = ` + + + + + Nuxt Error + + + +
+
${req.method} ${req.url}

+

${error.toString()}

+
${stack.map(i =>
+        `${i.text}`
+  ).join('\n')
+    }
+
+ + +` + + res.statusCode = error.statusCode || 500 + res.statusMessage = error.statusMessage || 'Internal Error' + res.end(html) +} diff --git a/packages/nitro/src/runtime/server/index.ts b/packages/nitro/src/runtime/server/index.ts new file mode 100644 index 0000000000..67714eac9c --- /dev/null +++ b/packages/nitro/src/runtime/server/index.ts @@ -0,0 +1,24 @@ +import '../app/config' +import { createApp, useBase } from 'h3' +import { createFetch } from 'ohmyfetch' +import destr from 'destr' +import { createCall, createFetch as createLocalFetch } from '@nuxt/un/runtime/fetch' +import { timingMiddleware } from './timing' +import { handleError } from './error' +// @ts-ignore +import serverMiddleware from '~serverMiddleware' + +const app = createApp({ + debug: destr(process.env.DEBUG), + onError: handleError +}) + +app.use(timingMiddleware) +app.use(serverMiddleware) +app.use(() => import('../app/render').then(e => e.renderMiddleware), { lazy: true }) + +export const stack = app.stack +export const handle = useBase(process.env.ROUTER_BASE, app) +export const localCall = createCall(handle) +export const localFetch = createLocalFetch(localCall, global.fetch) +export const $fetch = global.$fetch = createFetch({ fetch: localFetch }) diff --git a/packages/nitro/src/runtime/server/static.ts b/packages/nitro/src/runtime/server/static.ts new file mode 100644 index 0000000000..c99886610a --- /dev/null +++ b/packages/nitro/src/runtime/server/static.ts @@ -0,0 +1,71 @@ +import { createError } from 'h3' +import { withoutTrailingSlash, withLeadingSlash, parseURL } from 'ufo' +// @ts-ignore +import { getAsset, readAsset } from '~static' + +const METHODS = ['HEAD', 'GET'] +const PUBLIC_PATH = process.env.PUBLIC_PATH // Default: /_nuxt/ +const TWO_DAYS = 2 * 60 * 60 * 24 + +// eslint-disable-next-line +export default async function serveStatic(req, res) { + if (!METHODS.includes(req.method)) { + return + } + + let id = withLeadingSlash(withoutTrailingSlash(parseURL(req.url).pathname)) + let asset = getAsset(id) + + // Try index.html + if (!asset) { + const _id = id + '/index.html' + const _asset = getAsset(_id) + if (_asset) { + asset = _asset + id = _id + } + } + + if (!asset) { + if (id.startsWith(PUBLIC_PATH)) { + throw createError({ + statusMessage: 'Cannot find static asset ' + id, + statusCode: 404 + }) + } + return + } + + const ifNotMatch = req.headers['if-none-match'] === asset.etag + if (ifNotMatch) { + res.statusCode = 304 + return res.end('Not Modified (etag)') + } + + const ifModifiedSinceH = req.headers['if-modified-since'] + if (ifModifiedSinceH && asset.mtime) { + if (new Date(ifModifiedSinceH) >= new Date(asset.mtime)) { + res.statusCode = 304 + return res.end('Not Modified (mtime)') + } + } + + if (asset.type) { + res.setHeader('Content-Type', asset.type) + } + + if (asset.etag) { + res.setHeader('ETag', asset.etag) + } + + if (asset.mtime) { + res.setHeader('Last-Modified', asset.mtime) + } + + if (id.startsWith(PUBLIC_PATH)) { + res.setHeader('Cache-Control', `max-age=${TWO_DAYS}, immutable`) + } + + const contents = await readAsset(id) + return res.end(contents) +} diff --git a/packages/nitro/src/runtime/server/timing.ts b/packages/nitro/src/runtime/server/timing.ts new file mode 100644 index 0000000000..6754a302bb --- /dev/null +++ b/packages/nitro/src/runtime/server/timing.ts @@ -0,0 +1,22 @@ +export const globalTiming = global.__timing__ || { + start: () => 0, + end: () => 0, + metrics: [] +} + +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing +export function timingMiddleware (_req, res, next) { + const start = globalTiming.start() + + const _end = res.end + res.end = (data, encoding, callback) => { + const metrics = [['Generate', globalTiming.end(start)], ...globalTiming.metrics] + const serverTiming = metrics.map(m => `-;dur=${m[1]};desc="${encodeURIComponent(m[0])}"`).join(', ') + if (!res.headersSent) { + res.setHeader('Server-Timing', serverTiming) + } + _end.call(res, data, encoding, callback) + } + + next() +} diff --git a/packages/nitro/src/runtime/types.d.ts b/packages/nitro/src/runtime/types.d.ts new file mode 100644 index 0000000000..902e786658 --- /dev/null +++ b/packages/nitro/src/runtime/types.d.ts @@ -0,0 +1,6 @@ +declare module NodeJS { + interface Global { + __timing__: any + $config: any + } +} diff --git a/packages/nitro/src/server/dev.ts b/packages/nitro/src/server/dev.ts new file mode 100644 index 0000000000..3b7873678d --- /dev/null +++ b/packages/nitro/src/server/dev.ts @@ -0,0 +1,143 @@ +import { Worker } from 'worker_threads' +import { createApp } from 'h3' +import { resolve } from 'upath' +import debounce from 'debounce' +import chokidar from 'chokidar' +import { listen, Listener } from 'listhen' +import serveStatic from 'serve-static' +import servePlaceholder from 'serve-placeholder' +import { createProxy } from 'http-proxy' +import { stat } from 'fs-extra' +import type { NitroContext } from '../context' + +export function createDevServer (nitroContext: NitroContext) { + // Worker + const workerEntry = resolve(nitroContext.output.dir, nitroContext.output.serverDir, 'index.js') + let pendingWorker: Worker + let activeWorker: Worker + let workerAddress: string + async function reload () { + if (pendingWorker) { + await pendingWorker.terminate() + workerAddress = null + pendingWorker = null + } + if (!(await stat(workerEntry)).isFile) { + throw new Error('Entry not found: ' + workerEntry) + } + return new Promise((resolve, reject) => { + const worker = pendingWorker = new Worker(workerEntry) + worker.once('exit', (code) => { + if (code) { + reject(new Error('[worker] exited with code: ' + code)) + } + }) + worker.on('error', (err) => { + err.message = '[worker] ' + err.message + reject(err) + }) + worker.on('message', (event) => { + if (event && event.port) { + workerAddress = 'http://localhost:' + event.port + activeWorker = worker + pendingWorker = null + resolve(workerAddress) + } + }) + }) + } + + // App + const app = createApp() + + // _nuxt and static + app.use(nitroContext._nuxt.publicPath, serveStatic(resolve(nitroContext._nuxt.buildDir, 'dist/client'))) + app.use(nitroContext._nuxt.routerBase, serveStatic(resolve(nitroContext._nuxt.staticDir))) + + // Dynamic Middlwware + const legacyMiddleware = createDynamicMiddleware() + const devMiddleware = createDynamicMiddleware() + app.use(legacyMiddleware.middleware) + app.use(devMiddleware.middleware) + + // serve placeholder 404 assets instead of hitting SSR + app.use(nitroContext._nuxt.publicPath, servePlaceholder()) + app.use(nitroContext._nuxt.routerBase, servePlaceholder({ skipUnknown: true })) + + // SSR Proxy + const proxy = createProxy() + app.use((req, res) => { + if (workerAddress) { + proxy.web(req, res, { target: workerAddress }, (_err) => { + // console.error('[proxy]', err) + }) + } else { + res.end('Worker not ready!') + } + }) + + // Listen + let listeners: Listener[] = [] + const _listen = async (port, opts?) => { + const listener = await listen(app, { port, ...opts }) + listeners.push(listener) + return listener + } + + // Watch for dist and reload worker + const pattern = '**/*.{js,json}' + const events = ['add', 'change'] + let watcher + function watch () { + if (watcher) { return } + const dReload = debounce(() => reload().catch(console.warn), 200, true) + watcher = chokidar.watch([ + resolve(nitroContext.output.serverDir, pattern), + resolve(nitroContext._nuxt.buildDir, 'dist/server', pattern) + ]).on('all', event => events.includes(event) && dReload()) + } + + // Close handler + async function close () { + if (watcher) { + await watcher.close() + } + if (activeWorker) { + await activeWorker.terminate() + } + if (pendingWorker) { + await pendingWorker.terminate() + } + await Promise.all(listeners.map(l => l.close())) + listeners = [] + } + nitroContext._internal.hooks.hook('close', close) + + return { + reload, + listen: _listen, + close, + watch, + setLegacyMiddleware: legacyMiddleware.set, + setDevMiddleware: devMiddleware.set + } +} + +function createDynamicMiddleware () { + let middleware + return { + set: (input) => { + if (!Array.isArray(input)) { + middleware = input + return + } + const app = require('connect')() + for (const m of input) { + app.use(m.path || m.route || '/', m.handler || m.handle) + } + middleware = app + }, + middleware: (req, res, next) => + middleware ? middleware(req, res, next) : next() + } +} diff --git a/packages/nitro/src/server/middleware.ts b/packages/nitro/src/server/middleware.ts new file mode 100644 index 0000000000..413b5a88ea --- /dev/null +++ b/packages/nitro/src/server/middleware.ts @@ -0,0 +1,78 @@ +import { resolve, join, extname } from 'upath' +import { joinURL } from 'ufo' +import globby from 'globby' +import { watch } from 'chokidar' + +export interface ServerMiddleware { + route: string + handle: string + lazy?: boolean // Default is true + promisify?: boolean // Default is true +} + +function filesToMiddleware (files: string[], baseDir: string, basePath: string, overrides?: Partial): ServerMiddleware[] { + return files.map((file) => { + const route = joinURL(basePath, file.substr(0, file.length - extname(file).length)) + const handle = resolve(baseDir, file) + return { + route, + handle + } + }) + .sort((a, b) => a.route.localeCompare(b.route)) + .map(m => ({ ...m, ...overrides })) +} + +export function scanMiddleware (serverDir: string, onChange?: (results: ServerMiddleware[], event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir', file: string) => void): Promise { + const pattern = '**/*.{js,ts}' + const globalDir = resolve(serverDir, 'middleware') + const apiDir = resolve(serverDir, 'api') + + const scan = async () => { + const globalFiles = await globby(pattern, { cwd: globalDir }) + const apiFiles = await globby(pattern, { cwd: apiDir }) + return [ + ...filesToMiddleware(globalFiles, globalDir, '/', { route: '/' }), + ...filesToMiddleware(apiFiles, apiDir, '/api', { lazy: true }) + ] + } + + if (typeof onChange === 'function') { + const watcher = watch([ + join(globalDir, pattern), + join(apiDir, pattern) + ], { ignoreInitial: true }) + watcher.on('all', async (event, file) => { + onChange(await scan(), event, file) + }) + } + + return scan() +} + +export function resolveMiddleware (serverMiddleware: any[], resolvePath: (string) => string) { + const middleware: ServerMiddleware[] = [] + const legacyMiddleware: ServerMiddleware[] = [] + + for (let m of serverMiddleware) { + if (typeof m === 'string') { m = { handler: m } } + const route = m.path || m.route || '/' + const handle = m.handler || m.handle + if (typeof handle !== 'string' || typeof route !== 'string') { + legacyMiddleware.push(m) + } else { + delete m.handler + delete m.path + middleware.push({ + ...m, + handle: resolvePath(handle), + route + }) + } + } + + return { + middleware, + legacyMiddleware + } +} diff --git a/packages/nitro/src/types.ts b/packages/nitro/src/types.ts new file mode 100644 index 0000000000..2c632eaff2 --- /dev/null +++ b/packages/nitro/src/types.ts @@ -0,0 +1,11 @@ +import type { $Fetch } from 'ohmyfetch' + +declare global { + const $fetch: $Fetch + + namespace NodeJS { + interface Global { + $fetch: $Fetch + } + } +} diff --git a/packages/nitro/src/utils/index.ts b/packages/nitro/src/utils/index.ts new file mode 100644 index 0000000000..4f48b57d57 --- /dev/null +++ b/packages/nitro/src/utils/index.ts @@ -0,0 +1,122 @@ +import { relative, dirname, resolve } from 'upath' +import fse from 'fs-extra' +import jiti from 'jiti' +import defu from 'defu' +import Hookable from 'hookable' +import consola from 'consola' +import chalk from 'chalk' +import { get } from 'dot-prop' +import type { NitroPreset, NitroInput } from '../context' + +export const MODULE_DIR = resolve(__dirname, '..') + +export function hl (str: string) { + return chalk.cyan(str) +} + +export function prettyPath (p: string, highlight = true) { + p = relative(process.cwd(), p) + return highlight ? hl(p) : p +} + +export function compileTemplate (contents: string) { + return (params: Record) => contents.replace(/{{ ?([\w.]+) ?}}/g, (_, match) => { + const val = get(params, match) + if (!val) { + consola.warn(`cannot resolve template param '${match}' in ${contents.substr(0, 20)}`) + } + return val as string || `${match}` + }) +} + +export function serializeTemplate (contents: string) { + // eslint-disable-next-line no-template-curly-in-string + return `(params) => \`${contents.replace(/{{ (\w+) }}/g, '${params.$1}')}\`` +} + +export function jitiImport (dir: string, path: string) { + return jiti(dir)(path) +} + +export function tryImport (dir: string, path: string) { + try { + return jitiImport(dir, path) + } catch (_err) { } +} + +export async function writeFile (file, contents, log = false) { + await fse.mkdirp(dirname(file)) + await fse.writeFile(file, contents, 'utf-8') + if (log) { + consola.info('Generated', prettyPath(file)) + } +} + +export function resolvePath (nitroContext: NitroInput, path: string | ((nitroContext) => string), resolveBase: string = ''): string { + if (typeof path === 'function') { + path = path(nitroContext) + } + + if (typeof path !== 'string') { + throw new TypeError('Invalid path: ' + path) + } + + path = compileTemplate(path)(nitroContext) + + return resolve(resolveBase, path) +} + +export function detectTarget () { + if (process.env.NETLIFY) { + return 'netlify' + } + + if (process.env.NOW_BUILDER) { + return 'vercel' + } + + if (process.env.INPUT_AZURE_STATIC_WEB_APPS_API_TOKEN) { + return 'azure' + } +} + +export async function isDirectory (path: string) { + try { + return (await fse.stat(path)).isDirectory() + } catch (_err) { + return false + } +} + +export function extendPreset (base: NitroPreset, preset: NitroPreset): NitroPreset { + return (config: NitroInput) => { + if (typeof preset === 'function') { + preset = preset(config) + } + if (typeof base === 'function') { + base = base(config) + } + return defu({ + hooks: Hookable.mergeHooks(base.hooks, preset.hooks) + }, preset, base) + } +} + +const _getDependenciesMode = { + dev: ['devDependencies'], + prod: ['dependencies'], + all: ['devDependencies', 'dependencies'] +} +export function getDependencies (dir: string, mode: keyof typeof _getDependenciesMode = 'all') { + const fields = _getDependenciesMode[mode] + const pkg = require(resolve(dir, 'package.json')) + const dependencies = [] + for (const field of fields) { + if (pkg[field]) { + for (const name in pkg[field]) { + dependencies.push(name) + } + } + } + return dependencies +} diff --git a/packages/nitro/src/utils/tree.ts b/packages/nitro/src/utils/tree.ts new file mode 100644 index 0000000000..2f20ad83f3 --- /dev/null +++ b/packages/nitro/src/utils/tree.ts @@ -0,0 +1,50 @@ +import { resolve, dirname, relative } from 'upath' +import globby from 'globby' +import prettyBytes from 'pretty-bytes' +import gzipSize from 'gzip-size' +import { readFile } from 'fs-extra' +import chalk from 'chalk' +import stdenv from 'std-env' + +export async function printFSTree (dir) { + if (stdenv.test) { + return + } + + const files = await globby('**/*.*', { cwd: dir }) + + const items = (await Promise.all(files.map(async (file) => { + const path = resolve(dir, file) + const src = await readFile(path) + const size = src.byteLength + const gzip = await gzipSize(src) + return { file, path, size, gzip } + }))).sort((a, b) => b.path.localeCompare(a.path)) + + let totalSize = 0 + let totalGzip = 0 + + let totalNodeModulesSize = 0 + let totalNodeModulesGzip = 0 + + items.forEach((item, index) => { + let dir = dirname(item.file) + if (dir === '.') { dir = '' } + const rpath = relative(process.cwd(), item.path) + const treeChar = index === items.length - 1 ? '└─' : '├─' + + const isNodeModules = item.file.includes('node_modules') + + if (isNodeModules) { + totalNodeModulesSize += item.size + totalNodeModulesGzip += item.gzip + return + } + + process.stdout.write(chalk.gray(` ${treeChar} ${rpath} (${prettyBytes(item.size)}) (${prettyBytes(item.gzip)} gzip)\n`)) + totalSize += item.size + totalGzip += item.gzip + }) + + process.stdout.write(`${chalk.cyan('Σ Total size:')} ${prettyBytes(totalSize + totalNodeModulesSize)} (${prettyBytes(totalGzip + totalNodeModulesGzip)} gzip)\n`) +} diff --git a/packages/nitro/src/utils/wpfs.ts b/packages/nitro/src/utils/wpfs.ts new file mode 100644 index 0000000000..ed033735df --- /dev/null +++ b/packages/nitro/src/utils/wpfs.ts @@ -0,0 +1,7 @@ +import { join } from 'upath' +import fsExtra from 'fs-extra' + +export const wpfs = { + ...fsExtra, + join +}