Skip to content
Closed
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
58 changes: 58 additions & 0 deletions packages/granite-release-profiler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# granite-release-profiler

CLI server for inspecting React Native release bundles with React Native DevTools and profiling tools.

The package starts a local server that exposes React Native DevTools inspector endpoints without serving a Metro development bundle. It is intended for apps that already load a built or remote release bundle, but still need DevTools, profiling, and tracing support during investigation.

## Features

- Starts a Fastify server with `@react-native/dev-middleware`.
- Exposes React Native DevTools endpoints such as `/json/list`, `/open-debugger`, and `/inspector/*`.
- Integrates Rozenite middleware, including the TanStack Query plugin.
- Writes collected tracing events to `tracing-events.json` when a `Tracing.tracingComplete` event is received.
- Responds to `/status` with `packager-status:profiler-only` so native Metro checks do not treat it as a running Metro server.

## Usage

Run the server with `npx`:

```bash
npx granite-release-profiler
```

Or install it globally:

```bash
npm install -g granite-release-profiler
granite-release-profiler
```

In this repository, you can run the package from the workspace:

```bash
yarn workspace granite-release-profiler start
```

By default, the server listens on `localhost:8081`.

## CLI Options

```bash
granite-release-profiler --host localhost --port 8081
```

- `--host`: Hostname to bind. Defaults to `localhost`.
- `--port`: Port to bind. Defaults to `8081`.

## Keyboard Shortcuts

When the server is running in an interactive terminal:

- `d`: Print the connected device list.
- `j`: Open the debugger for the first connected device.

## Current Limitations

- The server does not provide a Metro development bundle.
- Source inspection depends on sourcemaps being available from the loaded bundle.
- Network inspection may be limited by how the release bundle transforms or obfuscates request code.
47 changes: 47 additions & 0 deletions packages/granite-release-profiler/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "granite-release-profiler",
"version": "1.0.30",
"type": "module",
"description": "CLI server for profiling React Native release bundles",
"bin": "./dist/cli.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js",
"./cli": "./dist/cli.js",
"./package.json": "./package.json"
},
"files": [
"dist",
"package.json"
],
"scripts": {
"prepack": "yarn build",
"start": "tsx src/cli.ts",
"build": "tsdown",
"typecheck": "tsc --noEmit",
"test": "vitest --run"
},
"devDependencies": {
"@oxc-node/core": "^0.1.0",
"@types/node": "catalog:tools",
"@types/ws": "^8",
"tsdown": "catalog:tools",
"tsx": "^4.21.0",
"typescript": "catalog:tools",
"vitest": "^4.0.12"
},
"dependencies": {
"@fastify/middie": "^9.3.2",
"@react-native/dev-middleware": "catalog:react-native",
"@rozenite/middleware": "1.10.0",
"@rozenite/tanstack-query-plugin": "1.10.0",
"@tanstack/react-query": "^4.42.0",
"ajv": "^8.20.0",
"es-toolkit": "^1.46.1",
"fastify": "^5.8.5",
"react": "catalog:react-native",
"react-native": "catalog:react-native",
"url": "^0.11.4",
"ws": "^8.20.0"
}
}
22 changes: 22 additions & 0 deletions packages/granite-release-profiler/src/cli-args.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { parseReleaseProfilerCliArgs } from './cli-args';

describe('parseReleaseProfilerCliArgs', () => {
it('starts with default host and port without bundle arguments', () => {
expect(parseReleaseProfilerCliArgs([])).toEqual({
host: 'localhost',
port: 8081,
});
});

it('allows host and port overrides', () => {
expect(parseReleaseProfilerCliArgs(['--host', '0.0.0.0', '--port', '9090'])).toEqual({
host: '0.0.0.0',
port: 9090,
});
});

it('rejects positional bundle or sourcemap arguments', () => {
expect(() => parseReleaseProfilerCliArgs(['dist/bundle.ios.hbc'])).toThrow(/Unknown argument/);
});
});
59 changes: 59 additions & 0 deletions packages/granite-release-profiler/src/cli-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export interface ReleaseProfilerCliOptions {
host: string;
port: number;
}

const USAGE = [
'Usage: granite-release-profiler [--host HOST] [--port PORT]',
'',
'Example:',
' granite-release-profiler',
].join('\n');

export function parseReleaseProfilerCliArgs(argv: string[]): ReleaseProfilerCliOptions {
let host = 'localhost';
let port = 8081;

for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg == null) {
continue;
}

if (arg === '--help' || arg === '-h') {
throw new Error(USAGE);
}

if (arg === '--host') {
const value = argv[index + 1];
if (value == null) {
throw new Error(`Missing value for --host\n\n${USAGE}`);
}
host = value;
index += 1;
continue;
}

if (arg === '--port') {
const value = argv[index + 1];
const parsedPort = Number(value);
if (value == null || !Number.isInteger(parsedPort) || parsedPort <= 0) {
throw new Error(`Invalid value for --port\n\n${USAGE}`);
}
port = parsedPort;
index += 1;
continue;
}

if (arg.startsWith('-')) {
throw new Error(`Unknown option: ${arg}\n\n${USAGE}`);
}

throw new Error(`Unknown argument: ${arg}\n\n${USAGE}`);
}

return {
host,
port,
};
}
14 changes: 14 additions & 0 deletions packages/granite-release-profiler/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env node

import { parseReleaseProfilerCliArgs } from './cli-args';
import { startReleaseProfilerServer } from './index';

export async function run(argv = process.argv.slice(2)) {
const options = parseReleaseProfilerCliArgs(argv);
await startReleaseProfilerServer(options);
}

run().catch(error => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
137 changes: 137 additions & 0 deletions packages/granite-release-profiler/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import fs from 'node:fs';
import { IncomingMessage, ServerResponse } from 'node:http';
import { createRequire } from 'node:module';
import path from 'node:path';
import { Duplex } from 'node:stream';
import url, { fileURLToPath } from 'node:url';
import middie from '@fastify/middie';
import { initializeRozenite } from '@rozenite/middleware';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import * as ws from 'ws';
import { setupKeyHandler } from './key-handler';
import { parseUrl } from './url';

type MiddlewareNext = () => void;
type MiddlewareHandler = (req: IncomingMessage, res: ServerResponse, next: MiddlewareNext) => void;
type ReactNativeDevMiddleware = typeof import('@react-native/dev-middleware');
type MiddlewareCapableFastify = ReturnType<typeof Fastify> & {
use(handler: MiddlewareHandler): MiddlewareCapableFastify;
};

const require = createRequire(import.meta.url);

export interface ReleaseProfilerServerOptions {
host?: string;
port?: number;
projectRoot?: string;
}

export async function startReleaseProfilerServer(options: ReleaseProfilerServerOptions) {
const host = options.host ?? 'localhost';
const port = options.port ?? 8081;
const projectRoot = options.projectRoot ?? path.resolve(fileURLToPath(new URL('..', import.meta.url)));

const fastify = Fastify();
const rozenite = initializeRozenite({
projectRoot,
include: ['@rozenite/tanstack-query-plugin'],
});
const { createDevMiddleware } = loadRozenitePatchedDevMiddleware(projectRoot);

const { middleware: devMiddleware, websocketEndpoints } = createDevMiddleware({
serverBaseUrl: url.format({ protocol: 'http', hostname: host, port }),
unstable_experiments: {
enableNetworkInspector: true,
enableStandaloneFuseboxShell: false,
},
});

const deviceWss = websocketEndpoints['/inspector/device'] as ws.WebSocketServer;
const tracingEvents: any[] = [];

deviceWss.on('connection', socket => {
socket.on('message', message => {
try {
const parsedMessage = JSON.parse(message.toString());
if (typeof parsedMessage?.payload?.wrappedEvent === 'object') {
const cdpEvent = parsedMessage?.payload?.wrappedEvent;

if (cdpEvent.method === 'Tracing.start') {
tracingEvents.length = 0;
}

if (cdpEvent.method === 'Tracing.dataCollected') {
tracingEvents.push(cdpEvent);
}

if (cdpEvent.method === 'Tracing.tracingComplete') {
fs.writeFileSync(path.join(process.cwd(), 'tracing-events.json'), JSON.stringify(tracingEvents, null, 2));
console.log('Tracing Complete!', tracingEvents.length);
tracingEvents.length = 0;
}
}
} catch {}
});
});

await fastify.register(middie);

const fastifyWithMiddleware = fastify as MiddlewareCapableFastify;

fastifyWithMiddleware
.setNotFoundHandler((_request: FastifyRequest, reply: FastifyReply) => {
reply.code(404).send({ error: 'Not found' });
})
.use(rozenite.middleware as MiddlewareHandler)
.use(devMiddleware as MiddlewareHandler);

process.once('SIGINT', () => {
void rozenite.dispose();
});

fastify.get('/status', (_request, reply) => {
reply.status(200).send('packager-status:profiler-only');
});

fastify.server.on('upgrade', (request: IncomingMessage, socket: Duplex, head: Buffer<ArrayBuffer>) => {
if (request.url == null) {
socket.destroy();
return;
}

const { pathname } = parseUrl(request.url);
if (pathname != null && websocketEndpoints[pathname]) {
const wss = websocketEndpoints[pathname];
wss.handleUpgrade(request, socket, head, (socket: any) => {
wss.emit('connection', socket, request);
});
} else {
socket.destroy();
}
});

const address = await fastify.listen({ host, port });
console.log(`Server is running on ${address}`);
setupKeyHandler(address);

return {
address,
close: async () => {
await rozenite.dispose();
await fastify.close();
},
};
}

function loadRozenitePatchedDevMiddleware(projectRoot: string): ReactNativeDevMiddleware {
const reactNativePackageJsonPath = require.resolve('react-native/package.json', { paths: [projectRoot] });
const reactNativePackagePath = path.dirname(reactNativePackageJsonPath);
const communityCliPluginPath = require.resolve('@react-native/community-cli-plugin', {
paths: [reactNativePackagePath],
});
const devMiddlewarePath = require.resolve('@react-native/dev-middleware', {
paths: [path.dirname(communityCliPluginPath)],
});

return require(devMiddlewarePath) as ReactNativeDevMiddleware;
}
Loading
Loading