Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add more comments around workerd usage #2575

Merged
merged 2 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/mini-oxygen/src/worker/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export function buildAssetsUrl(assetsPort: number) {
/**
* Creates a server that serves static assets from the build directory.
* Mimics Shopify CDN URLs for Oxygen v2.
* Note: this is not used when running with Vite because it already
* serves transformed assets before reaching MiniOxygen.
* See the following for more details:
* https://github.com/Shopify/hydrogen/pull/2078#issuecomment-2121705993
*/
export function createAssetsServer(assetsDirectory: string) {
return createServer(async (req: IncomingMessage, res: ServerResponse) => {
Expand Down
37 changes: 32 additions & 5 deletions packages/mini-oxygen/src/worker/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ const FAVICON_URL =

export type InspectorProxy = ReturnType<typeof createInspectorProxy>;

/**
* Creates a proxy server that forwards messages between the local
* debugger (e.g. VSCode, Browser DevTools) and the Workerd inspector.
* It also serves a custom in-browser DevTools UI for MiniOxygen by
* proxying the Cloudflare DevTools (used in Wrangler / Miniflare),
* and fixes a few issues related to serving this tool locally.
*
*/
export function createInspectorProxy(
port: number,
newInspectorConnection: InspectorConnection,
Expand All @@ -45,13 +53,15 @@ export function createInspectorProxy(
const sourceMapPathname = '/__index.js.map';
const sourceMapURL = `http://localhost:${port}${sourceMapPathname}`;

// Create the proxy server used when running with `--debug` flag:
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
// Remove query params. E.g. `/json/list?for_tab`
const [url = '/', queryString = ''] = req.url?.split('?') || [];

switch (url) {
// We implement a couple of well known end points
// that are queried for metadata by chrome://inspect
// We implement a couple of well known end points that are queried
// for metadata when opening `chrome://inspect` in the browser.
// https://chromedevtools.github.io/devtools-protocol/#endpoints
case '/json/version':
res.setHeader('Content-Type', 'application/json');
res.end(
Expand Down Expand Up @@ -84,7 +94,9 @@ export function createInspectorProxy(
}
return;
case sourceMapPathname:
// Handle proxied sourcemaps
// Handle proxied sourcemaps. This is only used when serving
// a built application in h2:preview or classic project dev.
// h2:dev with Vite uses inlined sourcemaps instead.
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Cache-Control', 'no-store');
res.setHeader(
Expand All @@ -100,6 +112,7 @@ export function createInspectorProxy(
}
break;
case '/favicon.ico':
// The browser requests for this automatically when opening DevTools.
proxyHttp(FAVICON_URL, req.headers, res);
break;
case '/':
Expand All @@ -112,7 +125,7 @@ export function createInspectorProxy(
);
res.end();
} else {
// Proxy CFW DevTools UI.
// Proxy the main page of the original CFW DevTools UI.
proxyHttp(
CFW_DEVTOOLS + '/js_app',
req.headers,
Expand All @@ -128,6 +141,7 @@ export function createInspectorProxy(
}
break;
default:
// Proxy assets from the original CFW DevTools UI, modifying them as needed.
if (
url === '/panels/sources/sources-meta.js' ||
(url.startsWith('/core/i18n/locales/') && url.endsWith('.json'))
Expand Down Expand Up @@ -157,7 +171,8 @@ export function createInspectorProxy(

wsServer.on('connection', (ws, req) => {
if (wsServer.clients.size > 1) {
// Only support one active Devtools instance at a time.
// Only support one active DevTools instance at a time. E.g.
// either VSCode/editor debugger or 1 browser DevTools tab.
console.error(
'Tried to open a new devtools window when a previous one was already open.',
);
Expand Down Expand Up @@ -190,12 +205,22 @@ export function createInspectorProxy(

if (inspector.ws) onInspectorConnection();

/**
* This function is called when the inspector connection is established
* for the first time or when the inspector is reconnected. That happens
* when the source code is reloaded in h2:preview, h2:debug:cpu.
* However, it no longer happens in h2:dev with Vite because the worker
* instance is not reloaded after source code changes, only patched with HMR.
*/
function onInspectorConnection() {
inspector.ws.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`.
// > TODO: it would be good to send this message also in h2:dev with Vite.
// > However, that requires a completely different type of wiring:
// > Getting Vite's HMR notifications from this part of the code somehow.
debuggerWs?.send(
JSON.stringify({
method: 'Runtime.consoleAPICalled',
Expand Down Expand Up @@ -234,6 +259,8 @@ export function createInspectorProxy(
}

return {
// Every time workerd is restarted (e.g. env var change, etc.),
// the inspector connection needs to be re-established.
updateInspectorConnection(newConnection: InspectorConnection) {
inspector = newConnection;
onInspectorConnection();
Expand Down
9 changes: 8 additions & 1 deletion packages/mini-oxygen/src/worker/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ export function getMiniOxygenHandlerScript() {
return `export default { fetch: ${miniOxygenHandler} }\n${withRequestHook}`;
}

// This function is stringified, do not use anything from outer scope here:
/**
* Main entry point for the worker. It serves as a router, dispatching requests
* to the appropriate handlers, but also to handle common cases like static assets
* and polyfills Oxygen headers.
*
* Since this function is stringified and executed in the "worker", do not
* add anything from the outer scope here.
*/
async function miniOxygenHandler(
request: Request,
env: MiniOxygenHandlerEnv,
Expand Down
26 changes: 26 additions & 0 deletions packages/mini-oxygen/src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,41 @@ type GetNewOptions = (
) => ReloadableOptions | Promise<ReloadableOptions>;

export type MiniOxygenOptions = InputMiniflareOptions & {
/**
* Allows attaching a debugger to the worker instance.
* @default false
*/
debug?: boolean;
/**
* Path to the source map file to use in debuggers, if needed.
* @default undefined
*/
sourceMapPath?: string;
/**
* Allows serving static assets from a directory or another origin.
* @default undefined
*/
assets?: AssetOptions;
/**
* Hook into requests and responses. Useful for debugging and logging.
* @default undefined
*/
requestHook?: RequestHook | null;
/**
* Name of the worker used for attaching a debugger.
* @default undefined The first worker in the array is used.
*/
inspectWorkerName?: string;
};

export type MiniOxygenInstance = ReturnType<typeof createMiniOxygen>;

/**
* Creates a MiniOxygen instance using the Workers runtime (workerd).
*
* @param options - Options for the MiniOxygen instance.
* @returns A MiniOxygen instance.
*/
export function createMiniOxygen({
debug = false,
inspectorPort,
Expand Down
21 changes: 21 additions & 0 deletions packages/mini-oxygen/src/worker/inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ export interface ErrorProperties {
stack?: string;
}

/**
* Creates a connection to the workerd inspector.
*
* The messages are sent via WebSockets following the Chrome DevTools Protocol:
* https://chromedevtools.github.io/devtools-protocol/
*
* The inspector connection has two purposes:
* 1. Attach debuggers to the workerd instance.
* 2. Ingest logs (e.g. user's `console.log` calls) from workerd into
* the main Node.js process, so that we can display them in the terminal.
*
* @param options - Options for the inspector.
* @returns A function to reconnect to the inspector.
*/
export function createInspectorConnector(options: {
privateInspectorPort: number;
publicInspectorPort?: number;
Expand Down Expand Up @@ -86,9 +100,16 @@ export function createInspectorConnector(options: {
};
}

/**
* Since a workerd instance can have multiple workers, we need to find the
* inspector URL for the main worker that runs user code, since that's the
* worker we want to debug. We use the port number to query all the existing
* workers and find the one that matches the user worker name.
*/
async function findInspectorUrl(inspectorPort: number, workerName: string) {
try {
// Fetch the inspector JSON response from the DevTools Inspector protocol
// https://chromedevtools.github.io/devtools-protocol/#endpoints
const jsonUrl = `http://127.0.0.1:${inspectorPort}/json`;
const body = (await (
await fetch(jsonUrl)
Expand Down
41 changes: 33 additions & 8 deletions packages/mini-oxygen/src/worker/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import type {
MessageData,
} from './inspector.js';

/**
* Adds event listeners for console messages and exceptions to the inspector connection.
* Then, it handles logs and errors in the main Node.js process to display them in the terminal.
* It also formats and displays source maps for errors using information that only exists
* in the Node.js process, although this is not used for Vite processes because Vite already
* provides source maps for errors.
* @param inspector
*/
export function addInspectorConsoleLogger(inspector: InspectorConnection) {
inspector.ws.addEventListener('message', async (event) => {
if (typeof event.data !== 'string') {
Expand All @@ -28,6 +36,12 @@ export function addInspectorConsoleLogger(inspector: InspectorConnection) {
});
}

/**
* Creates an Error instance in the Node.js process from an unhandled exception in workerd.
* @param exceptionDetails
* @param inspector
* @returns Resolves to an actual Error instance with stack trace and message.
*/
export async function createErrorFromException(
exceptionDetails: Protocol.Runtime.ExceptionDetails,
inspector: InspectorConnection,
Expand Down Expand Up @@ -56,6 +70,12 @@ export async function createErrorFromException(
);
}

/**
* Creates an Error instance in the Node.js process from a logged error in workerd.
* @param ro RemoteObject representing the error logged.
* @param inspector
* @returns Resolves to an actual Error instance with stack trace and message.
*/
export async function createErrorFromLog(
ro: Protocol.Runtime.RemoteObject,
inspector: InspectorConnection,
Expand Down Expand Up @@ -85,14 +105,6 @@ export async function createErrorFromLog(
return inspector.reconstructError(errorProperties, ro);
}

/**
* 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.
*/

const mapConsoleAPIMessageTypeToConsoleMethod: {
[key in Protocol.Runtime.ConsoleAPICalledEvent['type']]: Exclude<
keyof Console,
Expand All @@ -119,6 +131,17 @@ const mapConsoleAPIMessageTypeToConsoleMethod: {
endGroup: 'groupEnd',
};

/**
* 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.
*
* Here we decide how to display each type of argument. For example,
* for Errors we reconstruct the stack trace; for Maps, we display
* the key-value pairs, etc.
*/
async function logConsoleMessage(
evt: Protocol.Runtime.ConsoleAPICalledEvent,
inspector: InspectorConnection,
Expand All @@ -132,9 +155,11 @@ async function logConsoleMessage(
case 'undefined':
case 'symbol':
case 'bigint':
// Simple types are just pushed as-is
args.push(ro.value);
break;
case 'function':
// Functions are displayed as "[Function: <name>]"
args.push(`[Function: ${ro.description ?? '<no-description>'}]`);
break;
case 'object':
Expand Down
Loading