diff --git a/.changeset/silver-sheep-melt.md b/.changeset/silver-sheep-melt.md new file mode 100644 index 0000000000..a008ca152c --- /dev/null +++ b/.changeset/silver-sheep-melt.md @@ -0,0 +1,31 @@ +--- +'@shopify/cli-hydrogen': minor +--- + +Enable debugger connections by passing `--debug` flag to the `h2 dev` command: + +- Current default runtime (Node.js sandbox): `h2 dev --debug`. +- New Worker runtime: `h2 dev --debug --worker-unstable`. + +You can then connect to the port `9229` (configurable with the new `--inspector-port` flag) to start step debugging. + +For example, in Chrome you can go to `chrome://inspect` and make sure the inspector port is added to the network targets. In VSCode, you can add the following to your `.vscode/launch.json`: + +``` +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Hydrogen", + "type": "node", + "request": "attach", + "port": 9229, + "cwd": "/", + "resolveSourceMapLocations": null, + "attachExistingChildren": false, + "autoAttachChildProcesses": false, + "restart": true + } + ] +} +``` diff --git a/package-lock.json b/package-lock.json index 882f315d48..5c0c619f7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2548,9 +2548,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20230904.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20230904.0.tgz", - "integrity": "sha512-/GDlmxAFbDtrQwP4zOXFbqOfaPvkDxdsCoEa+KEBcAl5uR98+7WW5/b8naBHX+t26uS7p4bLlImM8J5F1ienRQ==", + "version": "1.20231016.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20231016.0.tgz", + "integrity": "sha512-rPAnF8Q25+eHEsAopihWeftPW/P0QapY9d7qaUmtOXztWdd6YPQ7JuiWVj4Nvjphge1BleehxAbo4I3Z4L2H1g==", "cpu": [ "x64" ], @@ -2563,9 +2563,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20230904.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20230904.0.tgz", - "integrity": "sha512-x8WXNc2xnDqr5y1iirnNdyx8GZY3rL5xiF7ebK3mKQeB+jFjkhO71yuPTkDCzUWtOvw1Wfd4jbwy4wxacMX4mQ==", + "version": "1.20231016.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20231016.0.tgz", + "integrity": "sha512-MvydDdiLXt+jy57vrVZ2lU6EQwCdpieyZoN8uBXSWzfG3zR/6dxU1+okvPQPlHN0jtlufqPeHrpJyAqqgLHUKA==", "cpu": [ "arm64" ], @@ -2578,9 +2578,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20230904.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20230904.0.tgz", - "integrity": "sha512-V58xyMS3oDpKO8Dpdh0r0BXm99OzoGgvWe9ufttVraj/1NTMGELwb6i9ySb8k3F1J9m/sO26+TV7pQc/bGC1VQ==", + "version": "1.20231016.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20231016.0.tgz", + "integrity": "sha512-y6Sj37yTzM8QbAghG9LRqoSBrsREnQz8NkcmpjSxeK6KMc2g0L5A/OemCdugNlIiv+zRv9BYX1aosaoxY5JbeQ==", "cpu": [ "x64" ], @@ -2593,9 +2593,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20230904.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20230904.0.tgz", - "integrity": "sha512-VrDaW+pjb5IAKEnNWtEaFiG377kXKmk5Fu0Era4W+jKzPON2BW/qRb/4LNHXQ4yxg/2HLm7RiUTn7JZtt1qO6A==", + "version": "1.20231016.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20231016.0.tgz", + "integrity": "sha512-LqMIRUHD1YeRg2TPIfIQEhapSKMFSq561RypvJoXZvTwSbaROxGdW6Ku+PvButqTkEvuAtfzN/kGje7fvfQMHg==", "cpu": [ "arm64" ], @@ -2608,9 +2608,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20230904.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20230904.0.tgz", - "integrity": "sha512-/R/dE8uy+8J2YeXfDhI8/Bg7YUirdbbjH5/l/Vv00ZRE0lC3nPLcYeyBXSwXIQ6/Xht3gN+lksLQgKd0ZWRd+Q==", + "version": "1.20231016.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20231016.0.tgz", + "integrity": "sha512-96ojBwIHyiUAbsWlzBqo9P/cvH8xUh8SuBboFXtwAeXcJ6/urwKN2AqPa/QzOGUTCdsurWYiieARHT5WWWPhKw==", "cpu": [ "x64" ], @@ -19482,9 +19482,9 @@ } }, "node_modules/miniflare": { - "version": "3.20230918.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20230918.0.tgz", - "integrity": "sha512-Dd29HB7ZlT1CXB2tPH8nW6fBOOXi/m7qFZHjKm2jGS+1OaGfrv0PkT5UspWW5jQi8rWI87xtordAUiIJkwWqRw==", + "version": "3.20231016.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20231016.0.tgz", + "integrity": "sha512-AmlqI89zsnBJfC+nKKZdCB/fuu0q/br24Kqt9NZwcT6yJEpO5NytNKfjl6nJROHROwuJSRQR1T3yopCtG1/0DA==", "dependencies": { "acorn": "^8.8.0", "acorn-walk": "^8.2.0", @@ -19494,7 +19494,7 @@ "source-map-support": "0.5.21", "stoppable": "^1.1.0", "undici": "^5.22.1", - "workerd": "1.20230904.0", + "workerd": "1.20231016.0", "ws": "^8.11.0", "youch": "^3.2.2", "zod": "^3.20.6" @@ -29581,9 +29581,9 @@ "license": "MIT" }, "node_modules/workerd": { - "version": "1.20230904.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20230904.0.tgz", - "integrity": "sha512-t9znszH0rQGK4mJGvF9L3nN0qKEaObAGx0JkywFtAwH8OkSn+YfQbHNZE+YsJ4qa1hOz1DCNEk08UDFRBaYq4g==", + "version": "1.20231016.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20231016.0.tgz", + "integrity": "sha512-v2GDb5XitSqgub/xm7EWHVAlAK4snxQu3itdMQxXstGtUG9hl79fQbXS/8fNFbmms2R2bAxUwSv47q8k5T5Erw==", "hasInstallScript": true, "bin": { "workerd": "bin/workerd" @@ -29592,11 +29592,11 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20230904.0", - "@cloudflare/workerd-darwin-arm64": "1.20230904.0", - "@cloudflare/workerd-linux-64": "1.20230904.0", - "@cloudflare/workerd-linux-arm64": "1.20230904.0", - "@cloudflare/workerd-windows-64": "1.20230904.0" + "@cloudflare/workerd-darwin-64": "1.20231016.0", + "@cloudflare/workerd-darwin-arm64": "1.20231016.0", + "@cloudflare/workerd-linux-64": "1.20231016.0", + "@cloudflare/workerd-linux-arm64": "1.20231016.0", + "@cloudflare/workerd-windows-64": "1.20231016.0" } }, "node_modules/worktop": { @@ -29985,7 +29985,7 @@ "fs-extra": "^11.1.0", "get-port": "^7.0.0", "gunzip-maybe": "^1.4.2", - "miniflare": "3.20230918.0", + "miniflare": "3.20231016.0", "prettier": "^2.8.4", "semver": "^7.5.3", "source-map": "^0.7.4", @@ -32443,33 +32443,33 @@ } }, "@cloudflare/workerd-darwin-64": { - "version": "1.20230904.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20230904.0.tgz", - "integrity": "sha512-/GDlmxAFbDtrQwP4zOXFbqOfaPvkDxdsCoEa+KEBcAl5uR98+7WW5/b8naBHX+t26uS7p4bLlImM8J5F1ienRQ==", + "version": "1.20231016.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20231016.0.tgz", + "integrity": "sha512-rPAnF8Q25+eHEsAopihWeftPW/P0QapY9d7qaUmtOXztWdd6YPQ7JuiWVj4Nvjphge1BleehxAbo4I3Z4L2H1g==", "optional": true }, "@cloudflare/workerd-darwin-arm64": { - "version": "1.20230904.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20230904.0.tgz", - "integrity": "sha512-x8WXNc2xnDqr5y1iirnNdyx8GZY3rL5xiF7ebK3mKQeB+jFjkhO71yuPTkDCzUWtOvw1Wfd4jbwy4wxacMX4mQ==", + "version": "1.20231016.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20231016.0.tgz", + "integrity": "sha512-MvydDdiLXt+jy57vrVZ2lU6EQwCdpieyZoN8uBXSWzfG3zR/6dxU1+okvPQPlHN0jtlufqPeHrpJyAqqgLHUKA==", "optional": true }, "@cloudflare/workerd-linux-64": { - "version": "1.20230904.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20230904.0.tgz", - "integrity": "sha512-V58xyMS3oDpKO8Dpdh0r0BXm99OzoGgvWe9ufttVraj/1NTMGELwb6i9ySb8k3F1J9m/sO26+TV7pQc/bGC1VQ==", + "version": "1.20231016.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20231016.0.tgz", + "integrity": "sha512-y6Sj37yTzM8QbAghG9LRqoSBrsREnQz8NkcmpjSxeK6KMc2g0L5A/OemCdugNlIiv+zRv9BYX1aosaoxY5JbeQ==", "optional": true }, "@cloudflare/workerd-linux-arm64": { - "version": "1.20230904.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20230904.0.tgz", - "integrity": "sha512-VrDaW+pjb5IAKEnNWtEaFiG377kXKmk5Fu0Era4W+jKzPON2BW/qRb/4LNHXQ4yxg/2HLm7RiUTn7JZtt1qO6A==", + "version": "1.20231016.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20231016.0.tgz", + "integrity": "sha512-LqMIRUHD1YeRg2TPIfIQEhapSKMFSq561RypvJoXZvTwSbaROxGdW6Ku+PvButqTkEvuAtfzN/kGje7fvfQMHg==", "optional": true }, "@cloudflare/workerd-windows-64": { - "version": "1.20230904.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20230904.0.tgz", - "integrity": "sha512-/R/dE8uy+8J2YeXfDhI8/Bg7YUirdbbjH5/l/Vv00ZRE0lC3nPLcYeyBXSwXIQ6/Xht3gN+lksLQgKd0ZWRd+Q==", + "version": "1.20231016.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20231016.0.tgz", + "integrity": "sha512-96ojBwIHyiUAbsWlzBqo9P/cvH8xUh8SuBboFXtwAeXcJ6/urwKN2AqPa/QzOGUTCdsurWYiieARHT5WWWPhKw==", "optional": true }, "@cspotcode/source-map-support": { @@ -35701,7 +35701,7 @@ "fs-extra": "^11.1.0", "get-port": "^7.0.0", "gunzip-maybe": "^1.4.2", - "miniflare": "3.20230918.0", + "miniflare": "3.20231016.0", "prettier": "^2.8.4", "semver": "^7.5.3", "source-map": "^0.7.4", @@ -43943,9 +43943,9 @@ "dev": true }, "miniflare": { - "version": "3.20230918.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20230918.0.tgz", - "integrity": "sha512-Dd29HB7ZlT1CXB2tPH8nW6fBOOXi/m7qFZHjKm2jGS+1OaGfrv0PkT5UspWW5jQi8rWI87xtordAUiIJkwWqRw==", + "version": "3.20231016.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20231016.0.tgz", + "integrity": "sha512-AmlqI89zsnBJfC+nKKZdCB/fuu0q/br24Kqt9NZwcT6yJEpO5NytNKfjl6nJROHROwuJSRQR1T3yopCtG1/0DA==", "requires": { "acorn": "^8.8.0", "acorn-walk": "^8.2.0", @@ -43955,7 +43955,7 @@ "source-map-support": "0.5.21", "stoppable": "^1.1.0", "undici": "^5.22.1", - "workerd": "1.20230904.0", + "workerd": "1.20231016.0", "ws": "^8.11.0", "youch": "^3.2.2", "zod": "^3.20.6" @@ -50446,15 +50446,15 @@ "version": "1.0.0" }, "workerd": { - "version": "1.20230904.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20230904.0.tgz", - "integrity": "sha512-t9znszH0rQGK4mJGvF9L3nN0qKEaObAGx0JkywFtAwH8OkSn+YfQbHNZE+YsJ4qa1hOz1DCNEk08UDFRBaYq4g==", - "requires": { - "@cloudflare/workerd-darwin-64": "1.20230904.0", - "@cloudflare/workerd-darwin-arm64": "1.20230904.0", - "@cloudflare/workerd-linux-64": "1.20230904.0", - "@cloudflare/workerd-linux-arm64": "1.20230904.0", - "@cloudflare/workerd-windows-64": "1.20230904.0" + "version": "1.20231016.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20231016.0.tgz", + "integrity": "sha512-v2GDb5XitSqgub/xm7EWHVAlAK4snxQu3itdMQxXstGtUG9hl79fQbXS/8fNFbmms2R2bAxUwSv47q8k5T5Erw==", + "requires": { + "@cloudflare/workerd-darwin-64": "1.20231016.0", + "@cloudflare/workerd-darwin-arm64": "1.20231016.0", + "@cloudflare/workerd-linux-64": "1.20231016.0", + "@cloudflare/workerd-linux-arm64": "1.20231016.0", + "@cloudflare/workerd-windows-64": "1.20231016.0" } }, "worktop": { diff --git a/package.json b/package.json index 5a3abb69fa..4f780428c7 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "ci:checks": "turbo run lint test format:check typecheck", "dev": "npm run dev:pkg", "dev:pkg": "cross-env LOCAL_DEV=true turbo dev --parallel --filter=./packages/*", + "dev:app": "cd templates/skeleton && cross-env LOCAL_DEV=true npm run dev", "preview": "turbo build --filter=./packages/* && npm run preview -w demo-store", "lint": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx ./packages & npm run lint -w demo-store", "format": "prettier --write --ignore-unknown ./packages && npm run format -w demo-store", diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 50ed43d48b..6f092c18c7 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -286,9 +286,16 @@ "debug": { "name": "debug", "type": "boolean", - "description": "Attaches a Node inspector", + "description": "Enables inspector connections with a debugger.", "allowNo": false }, + "inspector-port": { + "name": "inspector-port", + "type": "option", + "description": "Port where the inspector will be available.", + "multiple": false, + "default": 9229 + }, "host": { "name": "host", "type": "option", @@ -523,6 +530,19 @@ "char": "e", "description": "Specify an environment's branch name when using remote environment variables.", "multiple": false + }, + "inspector-port": { + "name": "inspector-port", + "type": "option", + "description": "Port where the inspector will be available.", + "multiple": false, + "default": 9229 + }, + "debug": { + "name": "debug", + "type": "boolean", + "description": "Enables inspector connections with a debugger.", + "allowNo": false } }, "args": {} diff --git a/packages/cli/package.json b/packages/cli/package.json index 2a23e5cb37..cc2e15147c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -44,7 +44,7 @@ "fs-extra": "^11.1.0", "get-port": "^7.0.0", "gunzip-maybe": "^1.4.2", - "miniflare": "3.20230918.0", + "miniflare": "3.20231016.0", "prettier": "^2.8.4", "semver": "^7.5.3", "source-map": "^0.7.4", diff --git a/packages/cli/src/commands/hydrogen/dev.ts b/packages/cli/src/commands/hydrogen/dev.ts index 5f1526aeed..813bc487af 100644 --- a/packages/cli/src/commands/hydrogen/dev.ts +++ b/packages/cli/src/commands/hydrogen/dev.ts @@ -31,6 +31,7 @@ import {getConfig} from '../../lib/shopify-config.js'; import {setupLiveReload} from '../../lib/live-reload.js'; import {checkRemixVersions} from '../../lib/remix-version-check.js'; import {getGraphiQLUrl} from '../../lib/graphiql-url.js'; +import {findPort} from '../../lib/find-port.js'; const LOG_REBUILDING = '🧱 Rebuilding...'; const LOG_REBUILT = '🚀 Rebuilt'; @@ -55,11 +56,8 @@ export default class Dev extends Command { env: 'SHOPIFY_HYDROGEN_FLAG_DISABLE_VIRTUAL_ROUTES', default: false, }), - debug: Flags.boolean({ - description: 'Attaches a Node inspector', - env: 'SHOPIFY_HYDROGEN_FLAG_DEBUG', - default: false, - }), + debug: commonFlags.debug, + 'inspector-port': commonFlags.inspectorPort, host: deprecated('--host')(), ['env-branch']: commonFlags.envBranch, }; @@ -77,8 +75,21 @@ export default class Dev extends Command { } } +type DevOptions = { + port: number; + path?: string; + useCodegen?: boolean; + workerRuntime?: boolean; + codegenConfigPath?: string; + disableVirtualRoutes?: boolean; + envBranch?: string; + debug?: boolean; + sourcemap?: boolean; + inspectorPort: number; +}; + async function runDev({ - port: portFlag = DEFAULT_PORT, + port: appPort, path: appPath, useCodegen = false, workerRuntime = false, @@ -87,23 +98,12 @@ async function runDev({ envBranch, debug = false, sourcemap = true, -}: { - port?: number; - path?: string; - useCodegen?: boolean; - workerRuntime?: boolean; - codegenConfigPath?: string; - disableVirtualRoutes?: boolean; - envBranch?: string; - debug?: boolean; - sourcemap?: boolean; -}) { + inspectorPort, +}: DevOptions) { if (!process.env.NODE_ENV) process.env.NODE_ENV = 'development'; muteDevLogs(); - if (debug) (await import('node:inspector')).open(); - const {root, publicPath, buildPathClient, buildPathWorkerFile} = getProjectPaths(appPath); @@ -135,6 +135,9 @@ async function runDev({ const serverBundleExists = () => fileExists(buildPathWorkerFile); + inspectorPort = debug ? await findPort(inspectorPort) : inspectorPort; + appPort = workerRuntime ? await findPort(appPort) : appPort; // findPort is already called for Node sandbox + const [remixConfig, {shop, storefront}] = await Promise.all([ reloadConfig(), getConfig(root), @@ -165,7 +168,9 @@ async function runDev({ miniOxygen = await startMiniOxygen( { root, - port: portFlag, + debug, + inspectorPort, + port: appPort, watch: !liveReload, buildPathWorkerFile, buildPathClient, diff --git a/packages/cli/src/commands/hydrogen/preview.ts b/packages/cli/src/commands/hydrogen/preview.ts index 491ad17371..5463e37159 100644 --- a/packages/cli/src/commands/hydrogen/preview.ts +++ b/packages/cli/src/commands/hydrogen/preview.ts @@ -1,14 +1,11 @@ import Command from '@shopify/cli-kit/node/base-command'; import {muteDevLogs} from '../../lib/log.js'; import {getProjectPaths} from '../../lib/remix-config.js'; -import { - commonFlags, - flagsToCamelObject, - DEFAULT_PORT, -} from '../../lib/flags.js'; +import {commonFlags, flagsToCamelObject} from '../../lib/flags.js'; import {startMiniOxygen} from '../../lib/mini-oxygen/index.js'; import {getAllEnvironmentVariables} from '../../lib/environment-variables.js'; import {getConfig} from '../../lib/shopify-config.js'; +import {findPort} from '../../lib/find-port.js'; export default class Preview extends Command { static description = @@ -17,8 +14,10 @@ export default class Preview extends Command { static flags = { path: commonFlags.path, port: commonFlags.port, - ['worker-unstable']: commonFlags.workerRuntime, - ['env-branch']: commonFlags.envBranch, + 'worker-unstable': commonFlags.workerRuntime, + 'env-branch': commonFlags.envBranch, + 'inspector-port': commonFlags.inspectorPort, + debug: commonFlags.debug, }; async run(): Promise { @@ -31,17 +30,23 @@ export default class Preview extends Command { } } +type PreviewOptions = { + port: number; + path?: string; + workerRuntime?: boolean; + envBranch?: string; + inspectorPort: number; + debug: boolean; +}; + export async function runPreview({ - port = DEFAULT_PORT, + port: appPort, path: appPath, workerRuntime = false, envBranch, -}: { - port?: number; - path?: string; - workerRuntime?: boolean; - envBranch?: string; -}) { + inspectorPort, + debug, +}: PreviewOptions) { if (!process.env.NODE_ENV) process.env.NODE_ENV = 'production'; muteDevLogs({workerReload: false}); @@ -51,13 +56,18 @@ export async function runPreview({ const fetchRemote = !!shop && !!storefront?.id; const env = await getAllEnvironmentVariables({root, fetchRemote, envBranch}); + appPort = workerRuntime ? await findPort(appPort) : appPort; + inspectorPort = debug ? await findPort(inspectorPort) : inspectorPort; + const miniOxygen = await startMiniOxygen( { root, - port, + port: appPort, + env, buildPathClient, buildPathWorkerFile, - env, + inspectorPort, + debug, }, workerRuntime, ); diff --git a/packages/cli/src/lib/flags.ts b/packages/cli/src/lib/flags.ts index 3b0610b240..62edc7f0dc 100644 --- a/packages/cli/src/lib/flags.ts +++ b/packages/cli/src/lib/flags.ts @@ -6,6 +6,7 @@ import colors from '@shopify/cli-kit/node/colors'; import type {CamelCasedProperties} from 'type-fest'; import {STYLING_CHOICES} from './setups/css/index.js'; import {I18N_CHOICES} from './setups/i18n/index.js'; +import {DEFAULT_INSPECTOR_PORT} from './mini-oxygen/common.js'; export const DEFAULT_PORT = 3000; @@ -89,6 +90,16 @@ export const commonFlags = { env: 'SHOPIFY_HYDROGEN_FLAG_SHORTCUT', allowNo: true, }), + debug: Flags.boolean({ + description: 'Enables inspector connections with a debugger.', + env: 'SHOPIFY_HYDROGEN_FLAG_DEBUG', + default: false, + }), + inspectorPort: Flags.integer({ + description: 'Port where the inspector will be available.', + env: 'SHOPIFY_HYDROGEN_FLAG_INSPECTOR_PORT', + default: DEFAULT_INSPECTOR_PORT, + }), }; export function flagsToCamelObject>(obj: T) { diff --git a/packages/cli/src/lib/mini-oxygen/common.ts b/packages/cli/src/lib/mini-oxygen/common.ts index ec2b8ddb74..00c2e38e91 100644 --- a/packages/cli/src/lib/mini-oxygen/common.ts +++ b/packages/cli/src/lib/mini-oxygen/common.ts @@ -6,6 +6,9 @@ import { import colors from '@shopify/cli-kit/node/colors'; import {DEV_ROUTES} from '../request-events.js'; +// Default port used for debugging in VSCode and Chrome DevTools. +export const DEFAULT_INSPECTOR_PORT = 9229; + export function logRequestLine( // Minimal overlap between Fetch, Miniflare@2 and Miniflare@3 request types. request: Pick & { diff --git a/packages/cli/src/lib/mini-oxygen/index.ts b/packages/cli/src/lib/mini-oxygen/index.ts index b19bd1f6b1..fa643b6f47 100644 --- a/packages/cli/src/lib/mini-oxygen/index.ts +++ b/packages/cli/src/lib/mini-oxygen/index.ts @@ -2,6 +2,8 @@ import type {MiniOxygenInstance, MiniOxygenOptions} from './types.js'; export type MiniOxygen = MiniOxygenInstance; +export {DEFAULT_INSPECTOR_PORT} from './common.js'; + export async function startMiniOxygen( options: MiniOxygenOptions, useWorkerd = false, diff --git a/packages/cli/src/lib/mini-oxygen/node.ts b/packages/cli/src/lib/mini-oxygen/node.ts index ac7d3b7aec..d73e6aae59 100644 --- a/packages/cli/src/lib/mini-oxygen/node.ts +++ b/packages/cli/src/lib/mini-oxygen/node.ts @@ -22,6 +22,8 @@ export async function startNodeServer({ buildPathWorkerFile, buildPathClient, env, + debug = false, + inspectorPort, }: MiniOxygenOptions): Promise { const oxygenHeaders = Object.fromEntries( Object.entries(OXYGEN_HEADERS_MAP).map(([key, value]) => { @@ -45,6 +47,10 @@ export async function startNodeServer({ }, }; + if (debug) { + (await import('node:inspector')).open(inspectorPort); + } + const miniOxygen = await startServer({ script: await readFile(buildPathWorkerFile), workerFile: buildPathWorkerFile, @@ -118,6 +124,13 @@ export async function startNodeServer({ body: [ `View ${options?.appName ?? 'Hydrogen'} app: ${listeningAt}`, ...(options?.extraLines ?? []), + ...(debug + ? [ + { + warn: `\n\nDebugger listening on ws://localhost:${inspectorPort}`, + }, + ] + : []), ], }); console.log(''); diff --git a/packages/cli/src/lib/mini-oxygen/types.ts b/packages/cli/src/lib/mini-oxygen/types.ts index 91aa3abdd9..e7cf4c7da0 100644 --- a/packages/cli/src/lib/mini-oxygen/types.ts +++ b/packages/cli/src/lib/mini-oxygen/types.ts @@ -1,11 +1,13 @@ export type MiniOxygenOptions = { root: string; - port?: number; + port: number; watch?: boolean; autoReload?: boolean; buildPathClient: string; buildPathWorkerFile: string; env: {[key: string]: string}; + debug?: boolean; + inspectorPort: number; }; export type MiniOxygenInstance = { diff --git a/packages/cli/src/lib/mini-oxygen/workerd-inspector-logs.ts b/packages/cli/src/lib/mini-oxygen/workerd-inspector-logs.ts new file mode 100644 index 0000000000..0f397efa1b --- /dev/null +++ b/packages/cli/src/lib/mini-oxygen/workerd-inspector-logs.ts @@ -0,0 +1,297 @@ +import {type Protocol} from 'devtools-protocol'; +import {type ErrorProperties} from './workerd-inspector.js'; +import {SourceMapConsumer} from 'source-map'; +import {parse as parseStackTrace} from 'stack-trace'; + +/** + * This function converts a message serialised as a devtools event + * into arguments suitable to be called by a console method, and + * then actually calls the method with those arguments. Effectively, + * we're just doing a little bit of the work of the devtools console, + * directly in the terminal. + */ + +export const mapConsoleAPIMessageTypeToConsoleMethod: { + [key in Protocol.Runtime.ConsoleAPICalledEvent['type']]: Exclude< + keyof Console, + 'Console' + >; +} = { + log: 'log', + debug: 'debug', + info: 'info', + warning: 'warn', + error: 'error', + dir: 'dir', + dirxml: 'dirxml', + table: 'table', + trace: 'trace', + clear: 'clear', + count: 'count', + assert: 'assert', + profile: 'profile', + profileEnd: 'profileEnd', + timeEnd: 'timeEnd', + startGroup: 'group', + startGroupCollapsed: 'groupCollapsed', + endGroup: 'groupEnd', +}; + +export async function logConsoleMessage( + evt: Protocol.Runtime.ConsoleAPICalledEvent, + reconstructError: ( + initialProperties: ErrorProperties, + ro: Protocol.Runtime.RemoteObject, + ) => Promise, +) { + const args: Array = []; + for (const ro of evt.args) { + switch (ro.type) { + case 'string': + case 'number': + case 'boolean': + case 'undefined': + case 'symbol': + case 'bigint': + args.push(ro.value); + break; + case 'function': + args.push(`[Function: ${ro.description ?? ''}]`); + break; + case 'object': + if (!ro.preview) { + args.push( + ro.subtype === 'null' + ? 'null' + : ro.description ?? '', + ); + } else { + if (ro.preview.description) args.push(ro.preview.description); + + switch (ro.preview.subtype) { + case 'array': + args.push( + '[ ' + + ro.preview.properties + .map(({value}) => { + return value; + }) + .join(', ') + + (ro.preview.overflow ? '...' : '') + + ' ]', + ); + + break; + case 'weakmap': + case 'map': + ro.preview.entries === undefined + ? args.push('{}') + : args.push( + '{\n' + + ro.preview.entries + .map(({key, value}) => { + return ` ${key?.description ?? ''} => ${ + value.description + }`; + }) + .join(',\n') + + (ro.preview.overflow ? '\n ...' : '') + + '\n}', + ); + + break; + case 'weakset': + case 'set': + ro.preview.entries === undefined + ? args.push('{}') + : args.push( + '{ ' + + ro.preview.entries + .map(({value}) => { + return `${value.description}`; + }) + .join(', ') + + (ro.preview.overflow ? ', ...' : '') + + ' }', + ); + break; + case 'regexp': + break; + case 'date': + break; + case 'generator': + args.push(ro.preview?.properties[0]?.value || ''); + break; + case 'promise': + if (ro.preview?.properties[0]?.value === 'pending') { + args.push(`{<${ro.preview.properties[0].value}>}`); + } else { + args.push( + `{<${ro.preview?.properties[0]?.value}>: ${ro.preview?.properties[1]?.value}}`, + ); + } + break; + case 'node': + case 'iterator': + case 'proxy': + case 'typedarray': + case 'arraybuffer': + case 'dataview': + case 'webassemblymemory': + case 'wasmvalue': + break; + case 'error': + const errorProperties = { + message: + ro.preview.description + ?.split('\n') + .filter((line) => !/^\s+at\s/.test(line)) + .join('\n') ?? + ro.preview.properties.find(({name}) => name === 'message') + ?.value ?? + '', + stack: + ro.preview.description ?? + ro.description ?? + ro.preview.properties.find(({name}) => name === 'stack') + ?.value, + cause: ro.preview.properties.find(({name}) => name === 'cause') + ?.value as unknown, + }; + + // Even though we have gathered all the properties, they are likely + // truncated so we need to fetch their full version. + const error = await reconstructError(errorProperties, ro); + + // Replace its description in args + args.splice(-1, 1, error); + + break; + default: + args.push( + '{\n' + + ro.preview.properties + .map(({name, value}) => { + return ` ${name}: ${value}`; + }) + .join(',\n') + + (ro.preview.overflow ? '\n ...' : '') + + '\n}', + ); + } + } + break; + default: + args.push(ro.description || ro.unserializableValue || '🦋'); + break; + } + } + + const method = mapConsoleAPIMessageTypeToConsoleMethod[evt.type]; + + if (method in console) { + switch (method) { + case 'dir': + console.dir(args); + break; + case 'table': + console.table(args); + break; + default: + // @ts-expect-error + console[method].apply(console, args); + break; + } + } else { + console.warn(`Unsupported console method: ${method}`); + console.warn('console event:', evt); + } +} + +/** + * Converts a structured-error to a friendly, source-mapped error string. + * @param sourceMapConsumer source-map to use for mapping locations + * @param message first line of stack trace (e.g. `Error: message`) + * @param frames structured stack entries for error location + */ +export function formatStructuredError( + sourceMapConsumer: SourceMapConsumer, + message?: string, + frames?: Protocol.Runtime.CallFrame[], +): string { + const lines: string[] = []; + if (message !== undefined) lines.push(message); + // Pass each of the callframes into the consumer, and format the error + frames?.forEach(({functionName, lineNumber, columnNumber}, i) => { + try { + if (lineNumber) { + // `Protocol.Runtime.CallFrame` uses 0-indexed line and column + // numbers, whereas `source-map` expects 1-indexing for lines and + // 0-indexing for columns; + const pos = sourceMapConsumer.originalPositionFor({ + line: lineNumber + 1, + column: columnNumber, + }); + + // Print out line which caused error: + if (i === 0 && pos.source && pos.line) { + const fileSource = sourceMapConsumer.sourceContentFor(pos.source); + const fileSourceLine = fileSource?.split('\n')[pos.line - 1] || ''; + lines.push(fileSourceLine.trim()); + + // If we have a column, we can mark the position underneath + if (pos.column) { + lines.push( + `${' '.repeat(pos.column - fileSourceLine.search(/\S/))}^`, + ); + } + } + + // From the way esbuild implements the "names" field: + // > To save space, the original name is only recorded when it's different from the final name. + // however, source-map consumer does not handle this + if (pos && pos.line !== null && pos.column !== null) { + const convertedFnName = pos.name || functionName || ''; + let convertedLocation = `${pos.source}:${pos.line}:${pos.column + 1}`; + + if (convertedFnName === '') { + lines.push(` at ${convertedLocation}`); + } else { + lines.push(` at ${convertedFnName} (${convertedLocation})`); + } + } + } + } catch { + // Line failed to parse through the sourcemap consumer + // We should handle this better + } + }); + + return lines.join('\n'); +} + +/** + * Converts an unstructured-stack to a friendly, source-mapped error string. + * @param sourceMapConsumer source-map to use for mapping locations + * @param stack string stack trace from `Error#stack` + */ +export function formatStack( + sourceMapConsumer: SourceMapConsumer, + stack: string, +) { + const message = stack.split('\n')[0]; + // `stack-trace` requires an object with a `stack` property: + // https://github.com/felixge/node-stack-trace/blob/ba06dcdb50d465cd440d84a563836e293b360427/index.js#L21-L23 + const callSites = parseStackTrace({stack} as Error); + const frames = callSites.map((site) => ({ + functionName: site.getFunctionName() ?? '', + // `Protocol.Runtime.CallFrame`s line numbers are 0-indexed, hence `- 1` + lineNumber: (site.getLineNumber() ?? 1) - 1, + columnNumber: site.getColumnNumber() ?? 1, + // Unused by `formattedError` + scriptId: '', + url: '', + })); + + return formatStructuredError(sourceMapConsumer, message, frames); +} diff --git a/packages/cli/src/lib/mini-oxygen/workerd-inspector-proxy.ts b/packages/cli/src/lib/mini-oxygen/workerd-inspector-proxy.ts new file mode 100644 index 0000000000..bd5130d4ce --- /dev/null +++ b/packages/cli/src/lib/mini-oxygen/workerd-inspector-proxy.ts @@ -0,0 +1,160 @@ +import crypto from 'node:crypto'; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; +import {WebSocketServer, type WebSocket, type MessageEvent} from 'ws'; +import {type Protocol} from 'devtools-protocol'; +import {type InspectorWebSocketTarget} from './workerd-inspector.js'; + +export function createInspectorProxy( + port: number, + inspectorConnection?: {ws?: WebSocket}, +) { + /** + * A unique identifier for this debugging session. + */ + const sessionId = crypto.randomUUID(); + /** + * WebSocket connection to the local debugger (e.g. VSCode, DevTools). + */ + let debuggerWs: WebSocket | undefined = undefined; + /** + * WebSocket connection to the Workerd inspector. + */ + let inspectorWs: WebSocket | undefined = inspectorConnection?.ws; + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + // Remove query params. E.g. `/json/list?for_tab` + const url = req.url?.split('?')[0] ?? ''; + + switch (url) { + // We implement a couple of well known end points + // that are queried for metadata by chrome://inspect + case '/json/version': + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({Browser: 'hydrogen/v2', 'Protocol-Version': '1.3'}), + ); + break; + case '/json': + case '/json/list': + { + res.setHeader('Content-Type', 'application/json'); + const localHost = `localhost:${port}/ws`; + const devtoolsFrontendUrl = `devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=${localHost}`; + const devtoolsFrontendUrlCompat = `devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=${localHost}`; + + res.end( + JSON.stringify([ + { + id: sessionId, + type: 'node', + webSocketDebuggerUrl: `ws://${localHost}`, + devtoolsFrontendUrl, + devtoolsFrontendUrlCompat, + // Below are fields that are visible in the DevTools UI. + title: 'Hydrogen / Oxygen Worker', + faviconUrl: + 'https://cdn.shopify.com/s/files/1/0598/4822/8886/files/favicon.svg', + url: + 'https://' + + (inspectorWs ? new URL(inspectorWs.url).host : localHost), + } satisfies InspectorWebSocketTarget, + ]), + ); + } + return; + default: + break; + } + }); + + const wsServer = new WebSocketServer({server, clientTracking: true}); + server.listen(port); + + /** + * Buffer inspector messages when there is no debugger connected. + * (e.g. initial console logs, etc.). + */ + let messageBuffer: MessageEvent[] = []; + + wsServer.on('connection', (ws, req) => { + if (wsServer.clients.size > 1) { + // Only support one active Devtools instance at a time. + console.error( + 'Tried to open a new devtools window when a previous one was already open.', + ); + + ws.close(1013, 'Too many clients; only one can be connected at a time'); + } else { + // Ensure debugger is restarted in workerd before connecting + // a new client to receive `Debugger.scriptParsed` events. + inspectorWs?.send( + JSON.stringify({id: 100_000_000, method: 'Debugger.disable'}), + ); + + debuggerWs?.removeEventListener('message', sendMessageToInspector); + debuggerWs = ws; + + debuggerWs.addEventListener('message', sendMessageToInspector); + debuggerWs.addEventListener('close', () => { + debuggerWs?.removeEventListener('message', sendMessageToInspector); + debuggerWs = undefined; + }); + + // Flush any buffered messages + messageBuffer.forEach(sendMessageToDebugger); + messageBuffer = []; + } + }); + + if (inspectorWs) onInspectorConnection(); + + function onInspectorConnection() { + inspectorWs?.addEventListener('message', sendMessageToDebugger); + + // In case this is a DevTools connection, send a warning + // message to the console to inform about reconnection. + // VSCode can reconnect automatically with `restart: true`. + debuggerWs?.send( + JSON.stringify({ + method: 'Runtime.consoleAPICalled', + params: { + type: 'warning', + args: [ + { + type: 'string', + value: + 'Source code changed. Please reload the DevTools to reconnect the debugger.', + }, + ], + executionContextId: Date.now(), + timestamp: Date.now(), + } satisfies Protocol.Runtime.ConsoleAPICalledEvent, + }), + ); + + debuggerWs?.close(1001, 'Source code changed'); + } + + function sendMessageToInspector(event: MessageEvent) { + inspectorWs?.send(event.data); + } + + function sendMessageToDebugger(event: MessageEvent) { + if (debuggerWs) { + debuggerWs.send(event.data); + } else { + messageBuffer.push(event); + } + } + + return { + updateInspectorConnection(newConnection?: {ws?: WebSocket}) { + inspectorWs = newConnection?.ws; + onInspectorConnection(); + }, + }; +} diff --git a/packages/cli/src/lib/mini-oxygen/workerd-inspector.ts b/packages/cli/src/lib/mini-oxygen/workerd-inspector.ts index 546e8beec2..d0a5b5a263 100644 --- a/packages/cli/src/lib/mini-oxygen/workerd-inspector.ts +++ b/packages/cli/src/lib/mini-oxygen/workerd-inspector.ts @@ -5,17 +5,22 @@ import {dirname} from 'node:path'; import {readFile} from 'node:fs/promises'; +import {fetch} from '@shopify/cli-kit/node/http'; import {SourceMapConsumer} from 'source-map'; -import {parse as parseStackTrace} from 'stack-trace'; -import WebSocket, {type MessageEvent} from 'ws'; -import {Protocol} from 'devtools-protocol'; +import {WebSocket, type MessageEvent} from 'ws'; +import {type Protocol} from 'devtools-protocol'; +import { + formatStack, + formatStructuredError, + logConsoleMessage, +} from './workerd-inspector-logs.js'; // https://chromedevtools.github.io/devtools-protocol/#endpoints -interface InspectorWebSocketTarget { +export interface InspectorWebSocketTarget { id: string; title: string; type: 'node'; - description: string; + description?: string; webSocketDebuggerUrl: string; devtoolsFrontendUrl: string; devtoolsFrontendUrlCompat: string; @@ -38,7 +43,7 @@ export async function findInspectorUrl(inspectorPort: number) { } } -interface InspectorProps { +interface InspectorOptions { /** * The websocket URL exposed by Workers that the inspector should connect to. */ @@ -49,7 +54,7 @@ interface InspectorProps { sourceMapPath?: string | undefined; } -interface ErrorProperties { +export interface ErrorProperties { message?: string; cause?: unknown; stack?: string; @@ -58,7 +63,7 @@ interface ErrorProperties { export function connectToInspector({ inspectorUrl, sourceMapPath, -}: InspectorProps) { +}: InspectorOptions) { /** * A simple decrementing id to attach to messages sent to DevTools. * Use negative ids to void collisions with DevTools messages. @@ -338,307 +343,21 @@ export function connectToInspector({ sourceMapAbortController.abort(); }); - return () => { - clearInterval(keepAliveInterval); - - if (!isClosed()) { - try { - ws.close(); - } catch (err) { - // Closing before the websocket is ready will throw an error. - } - } - - sourceMapAbortController.abort(); - }; -} - -/** - * This function converts a message serialised as a devtools event - * into arguments suitable to be called by a console method, and - * then actually calls the method with those arguments. Effectively, - * we're just doing a little bit of the work of the devtools console, - * directly in the terminal. - */ - -export const mapConsoleAPIMessageTypeToConsoleMethod: { - [key in Protocol.Runtime.ConsoleAPICalledEvent['type']]: Exclude< - keyof Console, - 'Console' - >; -} = { - log: 'log', - debug: 'debug', - info: 'info', - warning: 'warn', - error: 'error', - dir: 'dir', - dirxml: 'dirxml', - table: 'table', - trace: 'trace', - clear: 'clear', - count: 'count', - assert: 'assert', - profile: 'profile', - profileEnd: 'profileEnd', - timeEnd: 'timeEnd', - startGroup: 'group', - startGroupCollapsed: 'groupCollapsed', - endGroup: 'groupEnd', -}; - -async function logConsoleMessage( - evt: Protocol.Runtime.ConsoleAPICalledEvent, - reconstructError: ( - initialProperties: ErrorProperties, - ro: Protocol.Runtime.RemoteObject, - ) => Promise, -) { - const args: Array = []; - for (const ro of evt.args) { - switch (ro.type) { - case 'string': - case 'number': - case 'boolean': - case 'undefined': - case 'symbol': - case 'bigint': - args.push(ro.value); - break; - case 'function': - args.push(`[Function: ${ro.description ?? ''}]`); - break; - case 'object': - if (!ro.preview) { - args.push( - ro.subtype === 'null' - ? 'null' - : ro.description ?? '', - ); - } else { - if (ro.preview.description) args.push(ro.preview.description); - - switch (ro.preview.subtype) { - case 'array': - args.push( - '[ ' + - ro.preview.properties - .map(({value}) => { - return value; - }) - .join(', ') + - (ro.preview.overflow ? '...' : '') + - ' ]', - ); - - break; - case 'weakmap': - case 'map': - ro.preview.entries === undefined - ? args.push('{}') - : args.push( - '{\n' + - ro.preview.entries - .map(({key, value}) => { - return ` ${key?.description ?? ''} => ${ - value.description - }`; - }) - .join(',\n') + - (ro.preview.overflow ? '\n ...' : '') + - '\n}', - ); - - break; - case 'weakset': - case 'set': - ro.preview.entries === undefined - ? args.push('{}') - : args.push( - '{ ' + - ro.preview.entries - .map(({value}) => { - return `${value.description}`; - }) - .join(', ') + - (ro.preview.overflow ? ', ...' : '') + - ' }', - ); - break; - case 'regexp': - break; - case 'date': - break; - case 'generator': - args.push(ro.preview?.properties[0]?.value || ''); - break; - case 'promise': - if (ro.preview?.properties[0]?.value === 'pending') { - args.push(`{<${ro.preview.properties[0].value}>}`); - } else { - args.push( - `{<${ro.preview?.properties[0]?.value}>: ${ro.preview?.properties[1]?.value}}`, - ); - } - break; - case 'node': - case 'iterator': - case 'proxy': - case 'typedarray': - case 'arraybuffer': - case 'dataview': - case 'webassemblymemory': - case 'wasmvalue': - break; - case 'error': - const errorProperties = { - message: - ro.preview.description - ?.split('\n') - .filter((line) => !/^\s+at\s/.test(line)) - .join('\n') ?? - ro.preview.properties.find(({name}) => name === 'message') - ?.value ?? - '', - stack: - ro.preview.description ?? - ro.description ?? - ro.preview.properties.find(({name}) => name === 'stack') - ?.value, - cause: ro.preview.properties.find(({name}) => name === 'cause') - ?.value as unknown, - }; - - // Even though we have gathered all the properties, they are likely - // truncated so we need to fetch their full version. - const error = await reconstructError(errorProperties, ro); - - // Replace its description in args - args.splice(-1, 1, error); - - break; - default: - args.push( - '{\n' + - ro.preview.properties - .map(({name, value}) => { - return ` ${name}: ${value}`; - }) - .join(',\n') + - (ro.preview.overflow ? '\n ...' : '') + - '\n}', - ); - } - } - break; - default: - args.push(ro.description || ro.unserializableValue || '🦋'); - break; - } - } - - const method = mapConsoleAPIMessageTypeToConsoleMethod[evt.type]; - - if (method in console) { - switch (method) { - case 'dir': - console.dir(args); - break; - case 'table': - console.table(args); - break; - default: - // @ts-expect-error - console[method].apply(console, args); - break; - } - } else { - console.warn(`Unsupported console method: ${method}`); - console.warn('console event:', evt); - } -} - -/** - * Converts a structured-error to a friendly, source-mapped error string. - * @param sourceMapConsumer source-map to use for mapping locations - * @param message first line of stack trace (e.g. `Error: message`) - * @param frames structured stack entries for error location - */ -function formatStructuredError( - sourceMapConsumer: SourceMapConsumer, - message?: string, - frames?: Protocol.Runtime.CallFrame[], -): string { - const lines: string[] = []; - if (message !== undefined) lines.push(message); - // Pass each of the callframes into the consumer, and format the error - frames?.forEach(({functionName, lineNumber, columnNumber}, i) => { - try { - if (lineNumber) { - // `Protocol.Runtime.CallFrame` uses 0-indexed line and column - // numbers, whereas `source-map` expects 1-indexing for lines and - // 0-indexing for columns; - const pos = sourceMapConsumer.originalPositionFor({ - line: lineNumber + 1, - column: columnNumber, - }); - - // Print out line which caused error: - if (i === 0 && pos.source && pos.line) { - const fileSource = sourceMapConsumer.sourceContentFor(pos.source); - const fileSourceLine = fileSource?.split('\n')[pos.line - 1] || ''; - lines.push(fileSourceLine.trim()); - - // If we have a column, we can mark the position underneath - if (pos.column) { - lines.push( - `${' '.repeat(pos.column - fileSourceLine.search(/\S/))}^`, - ); - } - } - - // From the way esbuild implements the "names" field: - // > To save space, the original name is only recorded when it's different from the final name. - // however, source-map consumer does not handle this - if (pos && pos.line !== null && pos.column !== null) { - const convertedFnName = pos.name || functionName || ''; - let convertedLocation = `${pos.source}:${pos.line}:${pos.column + 1}`; - - if (convertedFnName === '') { - lines.push(` at ${convertedLocation}`); - } else { - lines.push(` at ${convertedFnName} (${convertedLocation})`); - } + return { + ws, + close: () => { + clearInterval(keepAliveInterval); + + if (!isClosed()) { + try { + ws.removeAllListeners(); + ws.close(); + } catch (err) { + // Closing before the websocket is ready will throw an error. } } - } catch { - // Line failed to parse through the sourcemap consumer - // We should handle this better - } - }); - return lines.join('\n'); -} - -/** - * Converts an unstructured-stack to a friendly, source-mapped error string. - * @param sourceMapConsumer source-map to use for mapping locations - * @param stack string stack trace from `Error#stack` - */ -function formatStack(sourceMapConsumer: SourceMapConsumer, stack: string) { - const message = stack.split('\n')[0]; - // `stack-trace` requires an object with a `stack` property: - // https://github.com/felixge/node-stack-trace/blob/ba06dcdb50d465cd440d84a563836e293b360427/index.js#L21-L23 - const callSites = parseStackTrace({stack} as Error); - const frames = callSites.map((site) => ({ - functionName: site.getFunctionName() ?? '', - // `Protocol.Runtime.CallFrame`s line numbers are 0-indexed, hence `- 1` - lineNumber: (site.getLineNumber() ?? 1) - 1, - columnNumber: site.getColumnNumber() ?? 1, - // Unused by `formattedError` - scriptId: '', - url: '', - })); - - return formatStructuredError(sourceMapConsumer, message, frames); + sourceMapAbortController.abort(); + }, + }; } diff --git a/packages/cli/src/lib/mini-oxygen/workerd.ts b/packages/cli/src/lib/mini-oxygen/workerd.ts index 75e408668e..478e85d8a7 100644 --- a/packages/cli/src/lib/mini-oxygen/workerd.ts +++ b/packages/cli/src/lib/mini-oxygen/workerd.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import { Miniflare, Request, @@ -6,7 +7,7 @@ import { NoOpLog, type MiniflareOptions, } from 'miniflare'; -import {resolvePath} from '@shopify/cli-kit/node/path'; +import {dirname, resolvePath} from '@shopify/cli-kit/node/path'; import { glob, readFile, @@ -16,7 +17,7 @@ import { import {renderSuccess} from '@shopify/cli-kit/node/ui'; import {lookupMimeType} from '@shopify/cli-kit/node/mimes'; import {connectToInspector, findInspectorUrl} from './workerd-inspector.js'; -import {DEFAULT_PORT} from '../flags.js'; +import {createInspectorProxy} from './workerd-inspector-proxy.js'; import {findPort} from '../find-port.js'; import type {MiniOxygenInstance, MiniOxygenOptions} from './types.js'; import {OXYGEN_HEADERS_MAP, logRequestLine} from './common.js'; @@ -27,18 +28,21 @@ import { setConstructors, } from '../request-events.js'; -const DEFAULT_INSPECTOR_PORT = 8787; +// This should probably be `0` and let workerd find a free port, +// but at the moment we can't get the port from workerd (afaik?). +const PRIVATE_WORKERD_INSPECTOR_PORT = 9222; export async function startWorkerdServer({ root, - port = DEFAULT_PORT, + port: appPort, + inspectorPort: publicInspectorPort, + debug = false, watch = false, buildPathWorkerFile, buildPathClient, env, }: MiniOxygenOptions): Promise { - const appPort = await findPort(port); - const inspectorPort = await findPort(DEFAULT_INSPECTOR_PORT); + const workerdInspectorPort = await findPort(PRIVATE_WORKERD_INSPECTOR_PORT); const oxygenHeadersMap = Object.values(OXYGEN_HEADERS_MAP).reduce( (acc, item) => { @@ -50,12 +54,14 @@ export async function startWorkerdServer({ setConstructors({Response}); + const absoluteBundlePath = resolvePath(root, buildPathWorkerFile); + const buildMiniOxygenOptions = async () => ({ cf: false, verbose: false, port: appPort, - inspectorPort, + inspectorPort: workerdInspectorPort, log: new NoOpLog(), liveReload: watch, host: 'localhost', @@ -77,11 +83,12 @@ export async function startWorkerdServer({ }, { name: 'hydrogen', + modulesRoot: dirname(absoluteBundlePath), modules: [ { type: 'ESModule', - path: resolvePath(root, buildPathWorkerFile), - contents: await readFile(resolvePath(root, buildPathWorkerFile)), + path: absoluteBundlePath, + contents: await readFile(absoluteBundlePath), }, ], compatibilityFlags: ['streams_enable_constructors'], @@ -101,11 +108,16 @@ export async function startWorkerdServer({ const listeningAt = (await miniOxygen.ready).origin; const sourceMapPath = buildPathWorkerFile + '.map'; - let inspectorUrl = await findInspectorUrl(inspectorPort); - let cleanupInspector = inspectorUrl + + let inspectorUrl = await findInspectorUrl(workerdInspectorPort); + let inspectorConnection = inspectorUrl ? connectToInspector({inspectorUrl, sourceMapPath}) : undefined; + const inspectorProxy = debug + ? createInspectorProxy(publicInspectorPort, inspectorConnection) + : undefined; + return { port: appPort, listeningAt, @@ -122,12 +134,14 @@ export async function startWorkerdServer({ } } - cleanupInspector?.(); + inspectorConnection?.close(); + // @ts-expect-error await miniOxygen.setOptions(miniOxygenOptions); - inspectorUrl ??= await findInspectorUrl(inspectorPort); + inspectorUrl ??= await findInspectorUrl(workerdInspectorPort); if (inspectorUrl) { - cleanupInspector = connectToInspector({inspectorUrl, sourceMapPath}); + inspectorConnection = connectToInspector({inspectorUrl, sourceMapPath}); + inspectorProxy?.updateInspectorConnection(inspectorConnection); } }, showBanner(options) { @@ -141,6 +155,13 @@ export async function startWorkerdServer({ body: [ `View ${options?.appName ?? 'Hydrogen'} app: ${listeningAt}`, ...(options?.extraLines ?? []), + ...(debug + ? [ + { + warn: `\n\nDebugger listening on ws://localhost:${publicInspectorPort}`, + }, + ] + : []), ], }); console.log('');