@harperdb/nextjs
Advanced tools
Comparing version 0.0.16 to 1.0.0-0
345
extension.js
@@ -1,11 +0,14 @@ | ||
import fs from 'node:fs'; | ||
import path from 'node:path'; | ||
import url from 'node:url'; | ||
import child_process from 'node:child_process'; | ||
import assert from 'node:assert'; | ||
import { existsSync, statSync, readFileSync, openSync, writeSync, unlinkSync } from 'node:fs'; | ||
import { join } from 'node:path'; | ||
import { parse as urlParse } from 'node:url'; | ||
import { spawnSync } from 'node:child_process'; | ||
import { setTimeout } from 'node:timers/promises'; | ||
import { createRequire } from 'node:module'; | ||
import { performance } from 'node:perf_hooks'; | ||
import { tmpdir } from 'node:os'; | ||
import shellQuote from 'shell-quote'; | ||
class HarperDBNextJSExtensionError extends Error {} | ||
/** | ||
@@ -16,11 +19,9 @@ * @typedef {Object} ExtensionOptions - The configuration options for the extension. These are all configurable via `config.yaml`. | ||
* @property {boolean=} dev - Enable dev mode. Defaults to `false`. | ||
* @property {string=} installCommand - A custom install command. Defaults to `npm install`. | ||
* @property {number=} port - A port for the Next.js server. Defaults to the HarperDB HTTP Port. | ||
* @property {boolean=} prebuilt - Instruct the extension to skip executing the `buildCommand`. Defaults to `false`. | ||
* @property {number=} securePort - A (secure) port for the https Next.js server. Defaults to the HarperDB HTTP Secure Port. | ||
* @property {boolean=} prebuilt - Instruct the extension to skip executing the `buildCommand`. Defaults to `false`. | ||
* @property {string=} subPath - A sub path for serving request from. Defaults to `''`. | ||
*/ | ||
/** | ||
* Assert that a given option is a specific type | ||
* Assert that a given option is a specific type, if it is defined. | ||
* | ||
@@ -32,5 +33,4 @@ * @param {string} name The name of the option | ||
function assertType(name, option, expectedType) { | ||
if (option) { | ||
const found = typeof option; | ||
assert.strictEqual(found, expectedType, `${name} must be type ${expectedType}. Received: ${found}`); | ||
if (option && typeof option !== expectedType) { | ||
throw new HarperDBNextJSExtensionError(`${name} must be type ${expectedType}. Received: ${typeof option}`); | ||
} | ||
@@ -40,3 +40,4 @@ } | ||
/** | ||
* Resolves the incoming extension options into a config for use throughout the extension | ||
* Resolves the incoming extension options into a config for use throughout the extension. | ||
* | ||
* @param {ExtensionOptions} options - The options object to be resolved into a configuration | ||
@@ -64,42 +65,26 @@ * @returns {Required<ExtensionOptions>} | ||
assertType('buildCommand', options.buildCommand, 'string'); | ||
assertType('buildOnly', options.buildOnly, 'boolean'); | ||
assertType('dev', options.dev, 'boolean'); | ||
assertType('installCommand', options.installCommand, 'string'); | ||
assertType('port', options.port, 'number'); | ||
assertType('prebuilt', options.prebuilt, 'boolean'); | ||
assertType('securePort', options.securePort, 'number'); | ||
assertType('prebuilt', options.prebuilt, 'boolean'); | ||
assertType('subPath', options.subPath, 'string'); | ||
// Remove leading and trailing slashes from subPath | ||
if (options.subPath?.[0] === '/') { | ||
options.subPath = options.subPath.slice(1); | ||
} | ||
if (options.subPath?.[options.subPath?.length - 1] === '/') { | ||
options.subPath = options.subPath.slice(0, -1); | ||
} | ||
return { | ||
const config = { | ||
buildCommand: options.buildCommand ?? 'npx next build', | ||
buildOnly: options.buildOnly ?? false, | ||
dev: options.dev ?? false, | ||
installCommand: options.installCommand ?? 'npm install', | ||
port: options.port, | ||
prebuilt: options.prebuilt ?? false, | ||
securePort: options.securePort, | ||
prebuilt: options.prebuilt ?? false, | ||
subPath: options.subPath ?? '', | ||
cache: options.cache ?? false, | ||
}; | ||
} | ||
class NextJSAppVerificationError extends Error {} | ||
logger.debug('@harperdb/nextjs extension configuration:\n' + JSON.stringify(config, undefined, 2)); | ||
const nextJSAppCache = {}; | ||
return config; | ||
} | ||
/** | ||
* This function verifies if the input is a Next.js app through a couple of | ||
* verification methods. It does not return nor throw anything. It will either | ||
* succeed (and return the path to the Next.js main file), or log an error to | ||
* `logger.fatal` and exit the process with exit code 1. | ||
* verification methods. See the implementation for details. | ||
* | ||
* Additionally, it memoizes previous verifications. | ||
* | ||
* @param {string} componentPath | ||
@@ -109,15 +94,13 @@ * @returns {string} The path to the Next.js main file | ||
function assertNextJSApp(componentPath) { | ||
logger.debug(`Verifying ${componentPath} is a Next.js application`); | ||
try { | ||
if (nextJSAppCache[componentPath]) { | ||
return nextJSAppCache[componentPath]; | ||
if (!existsSync(componentPath)) { | ||
throw new HarperDBNextJSExtensionError(`The folder ${componentPath} does not exist`); | ||
} | ||
if (!fs.existsSync(componentPath)) { | ||
throw new NextJSAppVerificationError(`The folder ${componentPath} does not exist`); | ||
if (!statSync(componentPath).isDirectory) { | ||
throw new HarperDBNextJSExtensionError(`The path ${componentPath} is not a folder`); | ||
} | ||
if (!fs.statSync(componentPath).isDirectory) { | ||
throw new NextJSAppVerificationError(`The path ${componentPath} is not a folder`); | ||
} | ||
// Couple options to check if its a Next.js project | ||
@@ -136,14 +119,12 @@ // 1. Check for Next.js config file (next.config.{js|mjs|ts}) | ||
// Check for Next.js Config | ||
const configExists = ['js', 'mjs', 'ts'].some((ext) => | ||
fs.existsSync(path.join(componentPath, `next.config.${ext}`)) | ||
); | ||
const configExists = ['js', 'mjs', 'ts'].some((ext) => existsSync(join(componentPath, `next.config.${ext}`))); | ||
// Check for dependency | ||
let nextJSExists; | ||
const packageJSONPath = path.join(componentPath, 'package.json'); | ||
if (fs.existsSync(packageJSONPath)) { | ||
const packageJSON = JSON.parse(fs.readFileSync(packageJSONPath)); | ||
let dependencyExists = false; | ||
const packageJSONPath = join(componentPath, 'package.json'); | ||
if (existsSync(packageJSONPath)) { | ||
const packageJSON = JSON.parse(readFileSync(packageJSONPath)); | ||
for (let dependencyList of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { | ||
if (packageJSON[dependencyList]?.['next']) { | ||
nextJSExists = true; | ||
dependencyExists = true; | ||
} | ||
@@ -153,16 +134,12 @@ } | ||
if (!configExists && !nextJSExists) { | ||
throw new NextJSAppVerificationError( | ||
if (!configExists && !dependencyExists) { | ||
throw new HarperDBNextJSExtensionError( | ||
`Could not determine if ${componentPath} is a Next.js project. It is missing both a Next.js config file and the "next" dependency in package.json` | ||
); | ||
} | ||
nextJSAppCache[componentPath] = nextJSExists; | ||
return nextJSExists; | ||
} catch (error) { | ||
if (error instanceof NextJSAppVerificationError) { | ||
logger.fatal(`Component path is not a Next.js application: `, error.message); | ||
if (error instanceof HarperDBNextJSExtensionError) { | ||
logger.fatal(`Component path is not a Next.js application: ` + error.message); | ||
} else { | ||
logger.fatal(`Unexpected Error thrown during Next.js Verification: `, error); | ||
logger.fatal(`Unexpected Error thrown during Next.js Verification: ` + error.toString()); | ||
} | ||
@@ -175,44 +152,3 @@ | ||
/** | ||
* Execute a command as a promise and wait for it to exit before resolving. | ||
* | ||
* Will automatically stream output to stdio when log level is set to debug. | ||
* @param {string} commandInput The command string to be parsed and executed | ||
* @param {string} componentPath The path to the application component | ||
* @param {boolean=} debug Print debugging information. Defaults to false | ||
*/ | ||
function executeCommand(commandInput, componentPath) { | ||
return new Promise((resolve, reject) => { | ||
const [command, ...args] = shellQuote.parse(commandInput); | ||
const cp = child_process.spawn(command, args, { | ||
cwd: componentPath, | ||
env: { | ||
...process.env, | ||
PATH: `${process.env.PATH}:${componentPath}/node_modules/.bin`, | ||
}, | ||
stdio: ['info', 'debug', 'trace'].includes(logger.log_level) ? 'inherit' : 'ignore', | ||
}); | ||
cp.on('error', (error) => { | ||
if (error.code === 'ENOENT') { | ||
logger.fatal(`Command: \`${commandInput}\` not found. Make sure it is included in PATH.`); | ||
} | ||
reject(error); | ||
}); | ||
cp.on('exit', (exitCode) => { | ||
logger.debug(`Command: \`${commandInput}\` exited with ${exitCode}`); | ||
resolve(exitCode); | ||
}); | ||
}); | ||
} | ||
/** | ||
* This method is executed once, on the main thread, and is responsible for | ||
* returning a Resource Extension that will subsequently be executed once, | ||
* on the main thread. | ||
* | ||
* The Resource Extension is responsible for installing application component | ||
* dependencies and running the application build command. | ||
* | ||
* @param {ExtensionOptions} options | ||
@@ -224,47 +160,16 @@ * @returns | ||
logger.debug('Next.js Extension Configuration:', JSON.stringify(config, undefined, 2)); | ||
return { | ||
async setupDirectory(_, componentPath) { | ||
logger.info(`Next.js Extension is setting up ${componentPath}`); | ||
assertNextJSApp(componentPath); | ||
const componentRequire = createRequire(componentPath); | ||
// Some Next.js apps will include cwd relative operations throughout the application (generally in places like `next.config.js`). | ||
// So set the cwd to the component path by default. | ||
process.chdir(componentPath); | ||
if (options.useAsCWD !== false) { | ||
// Change the current working directory to the component path, a lot of Next.js components expect this | ||
// (although not Next.js itself) | ||
process.chdir(componentPath); | ||
if (config.buildOnly) { | ||
await build(config, componentPath); | ||
logger.info('@harperdb/nextjs extension build only mode is enabled, exiting'); | ||
process.exit(0); | ||
} | ||
try { | ||
componentRequire.resolve('next'); | ||
} catch (error) { | ||
logger.error(error); | ||
if (!config.prebuilt) { | ||
await executeCommand(config.installCommand, componentPath); | ||
try { | ||
componentRequire.resolve('next'); | ||
} catch (error) { | ||
logger.error(error); | ||
logger.error('Next.js not found after installing dependencies'); | ||
} | ||
} | ||
} | ||
if (!config.prebuilt && !config.dev) { | ||
const timerStart = performance.now(); | ||
await executeCommand(config.buildCommand, componentPath); | ||
const timerStop = performance.now(); | ||
const duration = timerStop - timerStart; | ||
logger.info(`The build took ${((duration % 60000) / 1000).toFixed(2)} seconds`); | ||
// Send build time to HDB analtyics | ||
let pathString = componentPath.toString().slice(0, -1); | ||
const projectDirectoryName = pathString.split('/').pop(); | ||
server.recordAnalytics(duration, 'nextjs_build_time_in_milliseconds', projectDirectoryName); | ||
if (config.buildOnly) process.exit(0); | ||
} | ||
return true; | ||
@@ -288,43 +193,145 @@ }, | ||
const config = resolveConfig(options); | ||
return { | ||
async handleDirectory(_, componentPath) { | ||
logger.info(`Next.js Extension is creating Next.js Request Handlers for ${componentPath}`); | ||
// Assert the component path is a Next.js app. This will throw if it is not. | ||
assertNextJSApp(componentPath); | ||
const componentRequire = createRequire(componentPath); | ||
// Setup (build) the component. | ||
const next = (await import(componentRequire.resolve('next'))).default; | ||
// Prebuilt mode requires validating the `.next` directory exists | ||
if (config.prebuilt && !fs.existsSync(path.join(componentPath, '.next'))) { | ||
throw new HarperDBNextJSExtensionError('Prebuilt mode is enabled, but the .next folder does not exist'); | ||
} | ||
const app = next({ dir: componentPath, dev: config.dev }); | ||
// In non prebuilt or dev modes, build the Next.js app. | ||
// This only needs to happen once, on a single thread. | ||
// All threads need to wait for this to complete. | ||
if (!config.prebuilt && !config.dev) { | ||
await build(config, componentPath); | ||
} | ||
await app.prepare(); | ||
// Start the Next.js server | ||
await serve(config, componentPath); | ||
const requestHandler = app.getRequestHandler(); | ||
return true; | ||
}, | ||
}; | ||
} | ||
const servers = options.server.http( | ||
async (request, nextHandler) => { | ||
if (config.subPath && !request._nodeRequest.url.startsWith(`/${config.subPath}/`)) { | ||
return nextHandler(request); | ||
/** | ||
* Build the Next.js application located at `componentPath`. | ||
* Uses a lock file to ensure only one thread builds the application. | ||
* | ||
* @param {Required<ExtensionOptions>} config | ||
* @param {string} componentPath | ||
*/ | ||
async function build(config, componentPath) { | ||
// Theoretically, all threads should have roughly the same start time | ||
const startTime = Date.now(); | ||
const buildLockPath = join(tmpdir(), '.harperdb-nextjs-build.lock'); | ||
while (true) { | ||
try { | ||
// Open lock | ||
const buildLockFD = openSync(buildLockPath, 'wx'); | ||
writeSync(buildLockFD, process.pid.toString()); | ||
} catch (error) { | ||
if (error.code === 'EEXIST') { | ||
try { | ||
// Check if the lock is stale | ||
if (statSync(buildLockPath).mtimeMs < startTime - 100) { | ||
// The lock was created before (with a 100ms tolerance) any of the threads started building. | ||
// Safe to consider it stale and remove it. | ||
unlinkSync(buildLockPath); | ||
} | ||
let nodeRequest = request._nodeRequest; | ||
nodeRequest.url = config.subPath | ||
? nodeRequest.url.replace(new RegExp(`^\/${config.subPath}\/`), '/') | ||
: nodeRequest.url; | ||
return requestHandler(nodeRequest, request._nodeResponse, url.parse(nodeRequest.url, true)); | ||
}, | ||
{ port: config.port, securePort: config.securePort } | ||
); | ||
} catch (error) { | ||
if (error.code === 'ENOENT') { | ||
// The lock was removed by another thread. | ||
continue; | ||
} | ||
if (config.dev) { | ||
const upgradeHandler = app.getUpgradeHandler(); | ||
servers[0].on('upgrade', (req, socket, head) => { | ||
return upgradeHandler(req, socket, head); | ||
}); | ||
throw error; | ||
} | ||
// Wait for a second and try again | ||
await setTimeout(1000); | ||
continue; | ||
} | ||
return true; | ||
}, | ||
}; | ||
throw error; | ||
} | ||
try { | ||
// Check if the .next/BUILD_ID file is fresh | ||
if (statSync(join(componentPath, '.next', 'BUILD_ID')).mtimeMs > startTime) { | ||
unlinkSync(buildLockPath); | ||
break; | ||
} | ||
} catch (error) { | ||
// If the build id file does not exist, continue to building | ||
if (error.code !== 'ENOENT') { | ||
// All other errors should be thrown | ||
throw error; | ||
} | ||
} | ||
// Build | ||
const [command, ...args] = shellQuote.parse(config.buildCommand); | ||
const timerStart = performance.now(); | ||
const { stdout, stderr, status, error } = spawnSync(command, args, { | ||
cwd: componentPath, | ||
encoding: 'utf-8', | ||
}); | ||
if (status === 0) { | ||
if (stdout) logger.info(stdout); | ||
const duration = performance.now() - timerStart; | ||
logger.info(`The Next.js build took ${((duration % 60000) / 1000).toFixed(2)} seconds`); | ||
server.recordAnalytics( | ||
duration, | ||
'nextjs_build_time_in_milliseconds', | ||
componentPath.toString().slice(0, -1).split('/').pop() | ||
); | ||
} else { | ||
if (stderr) logger.error(stderr); | ||
if (error) logger.error(error); | ||
} | ||
// Release lock and exit | ||
unlinkSync(buildLockPath); | ||
break; | ||
} | ||
} | ||
/** | ||
* Serve the Next.js application located at `componentPath`. | ||
* The app must be built before calling this function. | ||
* | ||
* @param {Required<ExtensionOptions>} config | ||
* @param {string} componentPath | ||
*/ | ||
async function serve(config, componentPath) { | ||
const componentRequire = createRequire(componentPath); | ||
const next = (await import(componentRequire.resolve('next'))).default; | ||
const app = next({ dir: componentPath, dev: config.dev }); | ||
await app.prepare(); | ||
const requestHandler = app.getRequestHandler(); | ||
const servers = server.http( | ||
(request) => requestHandler(request._nodeRequest, request._nodeResponse, urlParse(request._nodeRequest.url, true)), | ||
{ port: config.port, securePort: config.securePort } | ||
); | ||
if (config.dev) { | ||
const upgradeHandler = app.getUpgradeHandler(); | ||
servers[0].on('upgrade', (req, socket, head) => { | ||
return upgradeHandler(req, socket, head); | ||
}); | ||
} | ||
} |
{ | ||
"name": "@harperdb/nextjs", | ||
"version": "0.0.16", | ||
"version": "1.0.0-0", | ||
"type": "module", | ||
@@ -5,0 +5,0 @@ "description": "A HarperDB Component for running Next.js apps.", |
@@ -87,2 +87,14 @@ # @harperdb/nextjs | ||
HarperDB relies on native dependencies and must be configured as an external package. In Next.js v14, update the next.config.js `webpack` option with: | ||
```js | ||
webpack: (config) => { | ||
config.externals.push({ | ||
harperdb: 'commonjs harperdb', | ||
}); | ||
return config; | ||
}, | ||
``` | ||
## Options | ||
@@ -106,11 +118,5 @@ | ||
### `installCommand: string` | ||
Specify an install command. Defaults to `npm install`. | ||
> Note: the extension will skip installing dependencies if it detects a `node_modules` folder in the application component. | ||
### `port: number` | ||
Specify a port for the Next.js server. Defaults to `9926`. | ||
Specify a port for the Next.js server. Defaults to the HarperDB default port (generally `9926`). | ||
@@ -121,5 +127,5 @@ ### `prebuilt: boolean` | ||
### `subPath: string` | ||
### `port: number` | ||
Specify a sub path to route requests from. For example, with `subPath: 'harperdb'`, any requests within the Next.js app to that path, such as `/harperdb/image.png`, will be rerouted to `/image.png`. Defaults to `''`. | ||
Specify a secure port for the Next.js server. Defaults to the HarperDB default secure port. | ||
@@ -126,0 +132,0 @@ ## CLI |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
322
150
2
17390