Skip to content

Commit

Permalink
refactor: use Metro HMR socket to listen for changes (#65)
Browse files Browse the repository at this point in the history
* refactor: remove bundle delta listeners and code from Atlas

* feature: use HMR endpoint to listen for changes

* refactor: remove the legact bundle delta toast component

* refactor: remove the legacy `registerMetro` to access private apis

* refactor: drop console.log references

* fix: clean up socket when in connecting state
  • Loading branch information
byCedric authored Jul 16, 2024
1 parent fc8807c commit fe7fc4f
Show file tree
Hide file tree
Showing 16 changed files with 393 additions and 236 deletions.
12 changes: 4 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ import { createAtlasMiddleware } from './utils/middleware';
* if (atlas) {
* // Register the Atlas middleware, to serve the UI and API.
* middleware.use('/_expo/atlas', atlasFromProject.middleware);
*
* // Register Metro to listen to changes
* atlas.registerMetro(metro);
* }
* ```
*/
Expand All @@ -25,23 +22,22 @@ export function createExpoAtlasMiddleware(config: MetroConfig) {

const source = new MetroGraphSource();
const middleware = createAtlasMiddleware(source);
const registerMetro = source.registerMetro.bind(source);

const metroCustomSerializer = config.serializer?.customSerializer ?? (() => {});
const metroConfig = convertMetroConfig(config);

// @ts-expect-error Should still be writable at this stage
config.serializer.customSerializer = (entryPoint, preModules, graph, serializeOptions) => {
source.serializeGraph({
projectRoot,
entryPoint,
preModules,
graph,
serializeOptions,
metroConfig,
preModules,
projectRoot,
serializeOptions,
});
return metroCustomSerializer(entryPoint, preModules, graph, serializeOptions);
};

return { source, middleware, registerMetro };
return { source, middleware };
}
8 changes: 4 additions & 4 deletions src/data/AtlasFileSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ export class AtlasFileSource implements AtlasSource {
return readAtlasEntry(this.filePath, Number(id));
}

bundleDeltaEnabled() {
return false; // File source does not implement the delta mechanism
hasHmrSupport() {
return false;
}

getBundleDelta() {
return null; // File source does not implement the delta mechanism
getBundleHmr() {
return null;
}
}

Expand Down
123 changes: 39 additions & 84 deletions src/data/MetroGraphSource.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import type metro from 'metro';
import type DeltaBundler from 'metro/src/DeltaBundler';
import type MetroServer from 'metro/src/Server';
import type { MetroConfig } from 'metro-config';
import path from 'path';

import type { AtlasBundle, AtlasBundleDelta, AtlasModule, AtlasSource } from './types';
import type { AtlasBundle, AtlasModule, AtlasSource } from './types';
import { bufferIsUtf8 } from '../utils/buffer';
import { getUrlFromJscSafeUrl } from '../utils/jsc';
import { getPackageNameFromPath } from '../utils/package';
import { convertPathToPosix, findSharedRoot } from '../utils/paths';

Expand All @@ -29,38 +28,52 @@ type ConvertGraphToAtlasOptions = {
};

export class MetroGraphSource implements AtlasSource {
/** The Metro delta listener, instantiated when the Metro server is registered */
protected deltaListener: MetroDeltaListener | null = null;
/** All known entries, and detected changes, stored by ID */
readonly entries: Map<AtlasBundle['id'], { entry: AtlasBundle; delta?: AtlasBundleDelta }> =
new Map();
readonly entries: Map<AtlasBundle['id'], AtlasBundle> = new Map();

constructor() {
this.serializeGraph = this.serializeGraph.bind(this);
}

listBundles() {
return Array.from(this.entries.values()).map((item) => ({
id: item.entry.id,
platform: item.entry.platform,
projectRoot: item.entry.projectRoot,
sharedRoot: item.entry.sharedRoot,
entryPoint: item.entry.entryPoint,
}));
hasHmrSupport() {
return true;
}

getBundle(id: string) {
const item = this.entries.get(id);
if (!item) throw new Error(`Entry "${id}" not found.`);
return item.entry;
getBundleHmr(id: string) {
// Get the required data from the bundle
const bundle = this.getBundle(id);
const bundleSourceUrl = bundle.serializeOptions?.sourceUrl;
if (!bundleSourceUrl) {
return null;
}

// Construct the HMR information, based on React Native
// See: https://github.com/facebook/react-native/blob/2eb7bcb8d9c0f239a13897e3a5d4397d81d3f627/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.java#L696-L702
const socketUrl = new URL('/hot', bundleSourceUrl);
// Fix the entry point URL query parameter to be compatible with the HMR server
const entryPoint = getUrlFromJscSafeUrl(bundleSourceUrl);

return {
bundleId: bundle.id,
socketUrl,
entryPoints: [entryPoint],
};
}

getBundleDelta(id: string) {
return this.entries.get(id)?.delta || null;
listBundles() {
return Array.from(this.entries.values()).map((bundle) => ({
id: bundle.id,
platform: bundle.platform,
projectRoot: bundle.projectRoot,
sharedRoot: bundle.sharedRoot,
entryPoint: bundle.entryPoint,
}));
}

bundleDeltaEnabled() {
return !!this.deltaListener;
getBundle(id: string) {
const bundle = this.entries.get(id);
if (!bundle) throw new Error(`Bundle "${id}" not found.`);
return bundle;
}

/**
Expand All @@ -69,67 +82,9 @@ export class MetroGraphSource implements AtlasSource {
* All data is kept in memory, where stale data is overwritten by new data.
*/
serializeGraph(options: ConvertGraphToAtlasOptions) {
const entry = convertGraph(options);
this.entries.set(entry.id, { entry });
this.deltaListener?.registerGraph(entry.id, options.graph);
return entry;
}

/**
* Register the Metro server to listen for changes in serialized graphs.
* Once changes are detected, the delta is generated and stored with the entry.
* Changes allows the client to know when to refetch data.
*/
registerMetro(metro: MetroServer) {
if (!this.deltaListener) {
this.deltaListener = new MetroDeltaListener(this, metro);
}
}
}

class MetroDeltaListener {
private source: MetroGraphSource;
private bundler: DeltaBundler<void>;
private listeners: Map<AtlasBundle['id'], () => any> = new Map();

constructor(source: MetroGraphSource, metro: MetroServer) {
this.source = source;
this.bundler = metro.getBundler().getDeltaBundler();
}

registerGraph(entryId: AtlasBundle['id'], graph: MetroGraph) {
// Unregister the previous listener, to always have the most up-to-date graph
if (this.listeners.has(entryId)) {
this.listeners.get(entryId)!();
}

// Register the (new) delta listener
this.listeners.set(
entryId,
this.bundler.listen(graph as any, async () => {
const createdAt = new Date();
this.bundler
.getDelta(graph as any, { reset: false, shallow: true })
.then((delta) => this.onMetroChange(entryId, delta, createdAt));
})
);
}

/**
* Event handler invoked when a change is detected by Metro, using the DeltaBundler.
* The detected change is combined with the Atlas entry ID, and updates the source entry with the delta.
*/
onMetroChange(entryId: AtlasBundle['id'], delta: metro.DeltaResult<void>, createdAt: Date) {
const item = this.source.entries.get(entryId);
const hasChanges = (delta.added.size || delta.modified.size || delta.deleted.size) > 0;

if (item && hasChanges) {
item.delta = {
createdAt,
modifiedPaths: Array.from(delta.added.keys()).concat(Array.from(delta.modified.keys())),
deletedPaths: Array.from(delta.deleted),
};
}
const bundle = convertGraph(options);
this.entries.set(bundle.id, bundle);
return bundle;
}
}

Expand Down
18 changes: 7 additions & 11 deletions src/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ export interface AtlasSource {
listBundles(): PartialAtlasBundle[] | Promise<PartialAtlasBundle[]>;
/** Load the full entry, by reference */
getBundle(ref: string): AtlasBundle | Promise<AtlasBundle>;
/** Load the entry changes since last bundle collection, if any */
getBundleDelta(ref: string): null | AtlasBundleDelta | Promise<null | AtlasBundleDelta>;
/** Determine if the source is watching for (live) changes. */
bundleDeltaEnabled(): boolean;

hasHmrSupport(): boolean;
getBundleHmr(ref: string): null | AtlasBundleHmr;
}

export type PartialAtlasBundle = Pick<
Expand Down Expand Up @@ -37,13 +36,10 @@ export type AtlasBundle = {
transformOptions?: Record<string, any>;
};

export type AtlasBundleDelta = {
/** When this delta or change was created */
createdAt: Date;
/** Both added and modified module paths */
modifiedPaths: string[];
/** Deleted module paths */
deletedPaths: string[];
export type AtlasBundleHmr = {
bundleId: AtlasBundle['id'];
socketUrl: string | URL;
entryPoints: string[];
};

export type AtlasModule = {
Expand Down
8 changes: 8 additions & 0 deletions src/utils/jsc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Return correctly formatted URLs from JSC safe URLs.
* - input http://127.0.0.1:8081/index.bundle//&platform=ios&dev=true&minify=false
* - output: http://127.0.0.1:8081/index.bundle?platform=ios&dev=true&minify=false
*/
export function getUrlFromJscSafeUrl(jscSafeUrl: string) {
return jscSafeUrl.replace('//&', '?');
}
4 changes: 1 addition & 3 deletions webui/src/app/(atlas)/[bundle].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
NoDataWithFiltersState,
} from '~/components/StateInfo';
import { useModuleFilters } from '~/hooks/useModuleFilters';
import { BundleDeltaToast, useBundle } from '~/providers/bundle';
import { useBundle } from '~/providers/bundle';
import { Layout, LayoutHeader, LayoutNavigation, LayoutTitle } from '~/ui/Layout';
import { Tag } from '~/ui/Tag';
import { fetchApi, handleApiError } from '~/utils/api';
Expand All @@ -31,8 +31,6 @@ export default function BundlePage() {

return (
<Layout variant="viewport">
<BundleDeltaToast bundle={bundle} />

<LayoutNavigation>
<BundleSelectForm />
</LayoutNavigation>
Expand Down
4 changes: 1 addition & 3 deletions webui/src/app/(atlas)/[bundle]/folders/[path].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
NoDataWithFiltersState,
} from '~/components/StateInfo';
import { useModuleFilters } from '~/hooks/useModuleFilters';
import { BundleDeltaToast, useBundle } from '~/providers/bundle';
import { useBundle } from '~/providers/bundle';
import { Layout, LayoutHeader, LayoutNavigation, LayoutTitle } from '~/ui/Layout';
import { Tag } from '~/ui/Tag';
import { fetchApi, handleApiError } from '~/utils/api';
Expand All @@ -30,8 +30,6 @@ export default function FolderPage() {

return (
<Layout variant="viewport">
<BundleDeltaToast bundle={bundle} />

<LayoutNavigation>
<BundleSelectForm />
</LayoutNavigation>
Expand Down
3 changes: 1 addition & 2 deletions webui/src/app/(atlas)/[bundle]/modules/[path].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ModuleCode } from '~/components/ModuleCode';
import { ModuleReference } from '~/components/ModuleReference';
import { PropertySummary } from '~/components/PropertySummary';
import { DataErrorState, NoDataState } from '~/components/StateInfo';
import { BundleDeltaToast, useBundle } from '~/providers/bundle';
import { useBundle } from '~/providers/bundle';
import { Layout, LayoutHeader, LayoutNavigation, LayoutTitle } from '~/ui/Layout';
import { Skeleton } from '~/ui/Skeleton';
import { Tag } from '~/ui/Tag';
Expand Down Expand Up @@ -45,7 +45,6 @@ export default function ModulePage() {
<NoDataState title="Module not found." />
) : (
<div className="mx-6 mb-4">
<BundleDeltaToast bundle={bundle} modulePath={module.data.absolutePath} />
<ModuleReference className="mb-2 my-6" bundle={bundle} module={module.data} />
<div className="mx-2 my-8">
<h3 className="font-semibold my-2">Module content</h3>
Expand Down
21 changes: 0 additions & 21 deletions webui/src/app/--/bundles/[bundle]/delta+api.ts

This file was deleted.

14 changes: 14 additions & 0 deletions webui/src/app/--/bundles/[bundle]/hmr+api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { getSource } from '~/utils/atlas';

export async function GET(_request: Request, params: Record<'bundle', string>) {
try {
const source = getSource();
if (!source.hasHmrSupport()) {
return Response.json({ error: 'HMR not supported' }, { status: 406 });
}

return Response.json(source.getBundleHmr(params.bundle));
} catch (error: any) {
return Response.json({ error: error.message }, { status: 406 });
}
}
38 changes: 38 additions & 0 deletions webui/src/app/--/bundles/reload+api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { getSource } from '~/utils/atlas';
import { AtlasBundle } from '~core/data/types';

export async function POST(_request: Request) {
try {
const source = getSource();

// Only reload bundles when HMR is enabled
if (!source.hasHmrSupport()) {
return Response.json({ error: 'HMR not supported' });
}

// Fetch all known bundles from Metro to trigger a data update through the `customSerializer` hook
const bundles = await source.listBundles();
// Trigger a new reload on all bundles
await Promise.all(
bundles.map((bundle) => Promise.resolve(source.getBundle(bundle.id)).then(fetchBundle))
);

return Response.json({ success: true, bundles });
} catch (error: any) {
return Response.json({ error: error.message }, { status: 406 });
}
}

function fetchBundle(bundle: AtlasBundle) {
if (!bundle.serializeOptions?.sourceUrl) {
return; // Unknown source URL, can't fetch the bundle
}

// Convert the source URL to localhost, avoiding "unauthorized requests" in Metro
const sourceUrl = new URL(bundle.serializeOptions.sourceUrl);
sourceUrl.hostname = 'localhost';

return fetch(sourceUrl)
.then((response) => (response.ok ? response : null))
.then((response) => response?.text());
}
Loading

0 comments on commit fe7fc4f

Please sign in to comment.