Ensure escaped string are parsed in NODE_OPTIONS (#65046)
## What? Ensures paths that have spaces in them in `NODE_OPTIONS` are handled. An example of that is VS Code's debugger which adds: ``` --require "/Applications/Visual Studio Code.app/Contents/Resources/app/extensions/ms-vscode.js-debug/src/bootloader.js" ``` Currently the output is cut off and causes: `invalid value for NODE_OPTIONS (unterminated string)`. Related issue: https://github.com/vercel/next.js/issues/63740 <!-- Thanks for opening a PR! Your contribution is much appreciated. To make sure your PR is handled as smoothly as possible we request that you follow the checklist sections below. Choose the right checklist for the change(s) that you're making: ## For Contributors ### Improving Documentation - Run `pnpm prettier-fix` to fix formatting issues before opening the PR. - Read the Docs Contribution Guide to ensure your contribution follows the docs guidelines: https://nextjs.org/docs/community/contribution-guide ### Adding or Updating Examples - The "examples guidelines" are followed from our contributing doc https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md - Make sure the linting passes by running `pnpm build && pnpm lint`. See https://github.com/vercel/next.js/blob/canary/contributing/repository/linting.md ### Fixing a bug - Related issues linked using `fixes #number` - Tests added. See: https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs - Errors have a helpful link attached, see https://github.com/vercel/next.js/blob/canary/contributing.md ### Adding a feature - Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. (A discussion must be opened, see https://github.com/vercel/next.js/discussions/new?category=ideas) - Related issues/discussions are linked using `fixes #number` - e2e tests added (https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs) - Documentation added - Telemetry added. In case of a feature if it's used or not. - Errors have a helpful link attached, see https://github.com/vercel/next.js/blob/canary/contributing.md ## For Maintainers - Minimal description (aim for explaining to someone not on the team to understand the PR) - When linking to a Slack thread, you might want to share details of the conclusion - Link both the Linear (Fixes NEXT-xxx) and the GitHub issues - Add review comments if necessary to explain to the reviewer the logic behind a change ### What? ### Why? ### How? Closes NEXT- Fixes # --> Closes NEXT-3226 --------- Co-authored-by: Wyatt Johnson <accounts+github@wyattjoh.ca> Co-authored-by: Ethan Arrowood <ethan@arrowood.dev>
This commit is contained in:
parent
af304c5252
commit
ae1fe5690b
5 changed files with 196 additions and 22 deletions
|
@ -1,6 +1,8 @@
|
|||
import {
|
||||
getFormattedNodeOptionsWithoutInspect,
|
||||
getParsedDebugAddress,
|
||||
formatNodeOptions,
|
||||
tokenizeArgs,
|
||||
} from './utils'
|
||||
|
||||
const originalNodeOptions = process.env.NODE_OPTIONS
|
||||
|
@ -9,6 +11,48 @@ afterAll(() => {
|
|||
process.env.NODE_OPTIONS = originalNodeOptions
|
||||
})
|
||||
|
||||
describe('tokenizeArgs', () => {
|
||||
it('splits arguments by spaces', () => {
|
||||
const result = tokenizeArgs('--spaces "thing with spaces" --normal 1234')
|
||||
|
||||
expect(result).toEqual([
|
||||
'--spaces',
|
||||
'thing with spaces',
|
||||
'--normal',
|
||||
'1234',
|
||||
])
|
||||
})
|
||||
|
||||
it('supports quoted values', () => {
|
||||
const result = tokenizeArgs(
|
||||
'--spaces "thing with spaces" --spacesAndQuotes "thing with \\"spaces\\"" --normal 1234'
|
||||
)
|
||||
|
||||
expect(result).toEqual([
|
||||
'--spaces',
|
||||
'thing with spaces',
|
||||
'--spacesAndQuotes',
|
||||
'thing with "spaces"',
|
||||
'--normal',
|
||||
'1234',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatNodeOptions', () => {
|
||||
it('wraps values with spaces in quotes', () => {
|
||||
const result = formatNodeOptions({
|
||||
spaces: 'thing with spaces',
|
||||
spacesAndQuotes: 'thing with "spaces"',
|
||||
normal: '1234',
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
'--spaces="thing with spaces" --spacesAndQuotes="thing with \\"spaces\\"" --normal=1234'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getParsedDebugAddress', () => {
|
||||
it('supports the flag with an equal sign', () => {
|
||||
process.env.NODE_OPTIONS = '--inspect=1234'
|
||||
|
@ -38,6 +82,26 @@ describe('getFormattedNodeOptionsWithoutInspect', () => {
|
|||
expect(result).toBe('--other')
|
||||
})
|
||||
|
||||
it('handles options with spaces', () => {
|
||||
process.env.NODE_OPTIONS =
|
||||
'--other --inspect --additional --spaces "/some/path with spaces"'
|
||||
const result = getFormattedNodeOptionsWithoutInspect()
|
||||
|
||||
expect(result).toBe(
|
||||
'--other --additional --spaces="/some/path with spaces"'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles options with quotes', () => {
|
||||
process.env.NODE_OPTIONS =
|
||||
'--require "./file with spaces to-require-with-node-require-option.js"'
|
||||
const result = getFormattedNodeOptionsWithoutInspect()
|
||||
|
||||
expect(result).toBe(
|
||||
'--require="./file with spaces to-require-with-node-require-option.js"'
|
||||
)
|
||||
})
|
||||
|
||||
it('removes --inspect option with parameters', () => {
|
||||
process.env.NODE_OPTIONS = '--other --inspect=0.0.0.0:1234 --additional'
|
||||
const result = getFormattedNodeOptionsWithoutInspect()
|
||||
|
|
|
@ -16,7 +16,7 @@ const parseNodeArgs = (args: string[]) => {
|
|||
|
||||
// For the `NODE_OPTIONS`, we support arguments with values without the `=`
|
||||
// sign. We need to parse them manually.
|
||||
let found = null
|
||||
let orphan = null
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i]
|
||||
|
||||
|
@ -24,37 +24,105 @@ const parseNodeArgs = (args: string[]) => {
|
|||
break
|
||||
}
|
||||
|
||||
// If we haven't found a possibly orphaned option, we need to look for one.
|
||||
if (!found) {
|
||||
if (token.kind === 'option' && typeof token.value === 'undefined') {
|
||||
found = token
|
||||
}
|
||||
|
||||
// When we encounter an option, if it's value is undefined, we should check
|
||||
// to see if the following tokens are positional parameters. If they are,
|
||||
// then the option is orphaned, and we can assign it.
|
||||
if (token.kind === 'option') {
|
||||
orphan = typeof token.value === 'undefined' ? token : null
|
||||
continue
|
||||
}
|
||||
|
||||
// If the next token isn't a positional value, then it's truly orphaned.
|
||||
if (token.kind !== 'positional' || !token.value) {
|
||||
found = null
|
||||
// If the token isn't a positional one, then we can't assign it to the found
|
||||
// orphaned option.
|
||||
if (token.kind !== 'positional') {
|
||||
orphan = null
|
||||
continue
|
||||
}
|
||||
|
||||
// We found an orphaned option. Let's add it to the values.
|
||||
values[found.name] = token.value
|
||||
found = null
|
||||
// If we don't have an orphan, then we can skip this token.
|
||||
if (!orphan) {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the token is a positional one, and it has a value, so add it to the
|
||||
// values object. If it already exists, append it with a space.
|
||||
if (orphan.name in values && typeof values[orphan.name] === 'string') {
|
||||
values[orphan.name] += ` ${token.value}`
|
||||
} else {
|
||||
values[orphan.name] = token.value
|
||||
}
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenizes the arguments string into an array of strings, supporting quoted
|
||||
* values and escaped characters.
|
||||
* Converted from: https://github.com/nodejs/node/blob/c29d53c5cfc63c5a876084e788d70c9e87bed880/src/node_options.cc#L1401
|
||||
*
|
||||
* @param input The arguments string to be tokenized.
|
||||
* @returns An array of strings with the tokenized arguments.
|
||||
*/
|
||||
export const tokenizeArgs = (input: string): string[] => {
|
||||
let args: string[] = []
|
||||
let isInString = false
|
||||
let willStartNewArg = true
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
let char = input[i]
|
||||
|
||||
// Skip any escaped characters in strings.
|
||||
if (char === '\\' && isInString) {
|
||||
// Ensure we don't have an escape character at the end.
|
||||
if (input.length === i + 1) {
|
||||
throw new Error('Invalid escape character at the end.')
|
||||
}
|
||||
|
||||
// Skip the next character.
|
||||
char = input[++i]
|
||||
}
|
||||
// If we find a space outside of a string, we should start a new argument.
|
||||
else if (char === ' ' && !isInString) {
|
||||
willStartNewArg = true
|
||||
continue
|
||||
}
|
||||
|
||||
// If we find a quote, we should toggle the string flag.
|
||||
else if (char === '"') {
|
||||
isInString = !isInString
|
||||
continue
|
||||
}
|
||||
|
||||
// If we're starting a new argument, we should add it to the array.
|
||||
if (willStartNewArg) {
|
||||
args.push(char)
|
||||
willStartNewArg = false
|
||||
}
|
||||
// Otherwise, add it to the last argument.
|
||||
else {
|
||||
args[args.length - 1] += char
|
||||
}
|
||||
}
|
||||
|
||||
if (isInString) {
|
||||
throw new Error('Unterminated string')
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node options from the environment variable `NODE_OPTIONS` and returns
|
||||
* them as an array of strings.
|
||||
*
|
||||
* @returns An array of strings with the node options.
|
||||
*/
|
||||
const getNodeOptionsArgs = () =>
|
||||
process.env.NODE_OPTIONS?.split(' ').map((arg) => arg.trim()) ?? []
|
||||
const getNodeOptionsArgs = () => {
|
||||
if (!process.env.NODE_OPTIONS) return []
|
||||
|
||||
return tokenizeArgs(process.env.NODE_OPTIONS)
|
||||
}
|
||||
|
||||
/**
|
||||
* The debug address is in the form of `[host:]port`. The host is optional.
|
||||
|
@ -129,7 +197,13 @@ export function formatNodeOptions(
|
|||
}
|
||||
|
||||
if (value) {
|
||||
return `--${key}=${value}`
|
||||
return `--${key}=${
|
||||
// Values with spaces need to be quoted. We use JSON.stringify to
|
||||
// also escape any nested quotes.
|
||||
value.includes(' ') && !value.startsWith('"')
|
||||
? JSON.stringify(value)
|
||||
: value
|
||||
}`
|
||||
}
|
||||
|
||||
return null
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
console.log('FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION')
|
|
@ -0,0 +1 @@
|
|||
console.log('FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION')
|
|
@ -559,31 +559,65 @@ describe('CLI Usage', () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("NODE_OPTIONS='--inspect=host:port'", async () => {
|
||||
test("NODE_OPTIONS='--require=file with spaces to-require-with-node-require-option.js'", async () => {
|
||||
const port = await findPort()
|
||||
const inspectPort = await findPort()
|
||||
let output = ''
|
||||
let errOutput = ''
|
||||
const app = await runNextCommandDev(
|
||||
[dirBasic, '--port', port],
|
||||
undefined,
|
||||
{
|
||||
cwd: dirBasic,
|
||||
onStdout(msg) {
|
||||
output += stripAnsi(msg)
|
||||
},
|
||||
onStderr(msg) {
|
||||
errOutput += stripAnsi(msg)
|
||||
},
|
||||
env: { NODE_OPTIONS: `--inspect=0.0.0.0:${inspectPort}` },
|
||||
env: {
|
||||
NODE_OPTIONS:
|
||||
'--require "./file with spaces to-require-with-node-require-option.js"',
|
||||
},
|
||||
}
|
||||
)
|
||||
try {
|
||||
await check(() => output, new RegExp(`http://localhost:${port}`))
|
||||
await check(() => errOutput, /Debugger listening on/)
|
||||
expect(errOutput).not.toContain('address already in use')
|
||||
expect(output).toContain(
|
||||
'the --inspect option was detected, the Next.js router server should be inspected at'
|
||||
'FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION'
|
||||
)
|
||||
expect(errOutput).toBe('')
|
||||
} finally {
|
||||
await killApp(app)
|
||||
}
|
||||
})
|
||||
|
||||
// Checks to make sure that files that look like arguments are not incorrectly parsed out. In this case the file name has `--require` in it.
|
||||
test("NODE_OPTIONS='--require=file with spaces to --require.js'", async () => {
|
||||
const port = await findPort()
|
||||
let output = ''
|
||||
let errOutput = ''
|
||||
const app = await runNextCommandDev(
|
||||
[dirBasic, '--port', port],
|
||||
undefined,
|
||||
{
|
||||
cwd: dirBasic,
|
||||
onStdout(msg) {
|
||||
output += stripAnsi(msg)
|
||||
},
|
||||
onStderr(msg) {
|
||||
errOutput += stripAnsi(msg)
|
||||
},
|
||||
env: {
|
||||
NODE_OPTIONS: '--require "./file with spaces to --require.js"',
|
||||
},
|
||||
}
|
||||
)
|
||||
try {
|
||||
await check(() => output, new RegExp(`http://localhost:${port}`))
|
||||
expect(output).toContain(
|
||||
'FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION'
|
||||
)
|
||||
expect(errOutput).toBe('')
|
||||
} finally {
|
||||
await killApp(app)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue