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

feat: implement basic header modifier base on service worker #4975

Merged
merged 9 commits into from
Nov 21, 2023
2 changes: 2 additions & 0 deletions packages/devtools/client/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/** @type {import('eslint').ESLint.ConfigData} */
module.exports = {
root: true,
extends: ['@modern-js'],
ignorePatterns: ['plugins/'],
};
4 changes: 4 additions & 0 deletions packages/devtools/client/modern.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { appTools, defineConfig } from '@modern-js/app-tools';
import { nanoid } from '@modern-js/utils';
import { ROUTE_BASENAME } from '@modern-js/devtools-kit';
import { ServiceWorkerCompilerPlugin } from './plugins/ServiceWorkerCompilerPlugin';
import packageMeta from './package.json';

// https://modernjs.dev/en/configure/app/usage
Expand Down Expand Up @@ -48,6 +49,9 @@ export default defineConfig<'rspack'>({
.use('RADIX_TOKEN')
.loader('./plugins/radix-token-transformer.js')
.options({ root: '.theme-register' });
chain
.plugin('ServiceWorkerCompilerPlugin')
.use(ServiceWorkerCompilerPlugin);
},
},
plugins: [appTools({ bundler: 'experimental-rspack' })],
Expand Down
8 changes: 6 additions & 2 deletions packages/devtools/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
"exports": {
".": "./dist/html/client/index.html",
"./mount": "./exports/mount.mjs",
"./sw-proxy": "./dist/public/sw-proxy.js",
"./package.json": "./package.json"
},
"dependencies": {},
"devDependencies": {
"@modern-js-app/eslint-config": "workspace:*",
"@modern-js/app-tools": "workspace:*",
Expand All @@ -33,15 +33,16 @@
"@modern-js/devtools-kit": "workspace:*",
"@modern-js/eslint-config": "workspace:*",
"@modern-js/plugin-proxy": "workspace:*",
"@modern-js/utils": "workspace:*",
"@modern-js/runtime": "workspace:*",
"@modern-js/tsconfig": "workspace:*",
"@modern-js/types": "workspace:*",
"@modern-js/utils": "workspace:*",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/themes": "^2.0.0",
"@rspack/core": "0.3.11",
"@types/jest": "~29.2.4",
"@types/lodash": "^4.14.196",
"@types/node": "~16.11.7",
Expand All @@ -65,5 +66,8 @@
"typescript": "~5.0.4",
"ufo": "^1.2.0",
"valtio": "^1.11.1"
},
"dependencies": {
"idb-keyval": "^6.2.1"
}
}
41 changes: 41 additions & 0 deletions packages/devtools/client/plugins/ServiceWorkerCompilerPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import path from 'path';
import { createCompiler, Compiler, DefinePlugin } from '@rspack/core';
import { logger } from '@modern-js/utils/logger';
import { version } from '../package.json';

const workspace = path.resolve(__dirname, '../');

export class ServiceWorkerCompilerPlugin {
name = 'ServiceWorkerCompilerPlugin';
apply(compiler: Compiler) {
compiler.hooks.beforeCompile.tapPromise(this.name, async () => {
const watch = compiler.watchMode ?? false;
watch && logger.info('Build service worker in watch mode.');

const childCompiler = createCompiler({
mode: 'production',
context: workspace,
entry: path.resolve(workspace, './src/service.worker.ts'),
target: 'webworker',
devtool: false,
watch,
output: {
path: path.resolve(workspace, 'dist/public'),
filename: 'sw-proxy.js',
},
plugins: [
new DefinePlugin({
'process.env.VERSION': JSON.stringify(version),
}),
],
});
childCompiler.run((e: any) => {
if (e) {
logger.error(e);
} else {
logger.info('Build service worker successfully.');
}
});
});
}
}
7 changes: 7 additions & 0 deletions packages/devtools/client/src/client/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
HiOutlineAdjustmentsHorizontal,
HiOutlineRectangleGroup,
HiOutlineCube,
HiOutlineAcademicCap,
} from 'react-icons/hi2';
import { InternalTab } from './types';

Expand Down Expand Up @@ -31,4 +32,10 @@ export const getDefaultTabs = (): InternalTab[] => [
icon: <HiOutlineCube />,
view: { type: 'builtin', url: '/context' },
},
{
name: 'headers',
title: 'Header Modifier',
icon: <HiOutlineAcademicCap />,
view: { type: 'builtin', url: '/headers' },
},
];
50 changes: 50 additions & 0 deletions packages/devtools/client/src/client/routes/headers/editor/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import _ from 'lodash';
import React, { ChangeEvent } from 'react';
import { useList } from 'react-use';
import { useSnapshot } from 'valtio';
import { $state, registerService, unregisterService } from '../state';

const Page: React.FC = () => {
const state = useSnapshot($state);
const [rules, $rules] = useList(
_.slice(state.service.rules).map(s => ({
...s,
id: Math.random().toString(),
})),
);

const createInputHandler = (type: 'key' | 'value', index: number) => {
return (e: ChangeEvent<HTMLInputElement>) => {
const oldValue = rules[index];
const newValue = {
...oldValue,
[type]: e.target.value,
};
$rules.update(_.matches(oldValue), newValue);
};
};

return (
<div>
<div>Editor</div>
{rules.map((rule, i) => (
<div key={rule.id}>
<input value={rule.key} onChange={createInputHandler('key', i)} />
<input value={rule.value} onChange={createInputHandler('value', i)} />
</div>
))}
<button
onClick={() =>
$rules.push({ id: Math.random().toString(), key: '', value: '' })
}
>
+
</button>
<button onClick={() => $rules.removeAt(-1)}>-</button>
<button onClick={() => registerService(rules as any)}>register</button>
<button onClick={unregisterService}>unregister</button>
</div>
);
};

export default Page;
17 changes: 17 additions & 0 deletions packages/devtools/client/src/client/routes/headers/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React, { useEffect } from 'react';
import { useSnapshot } from 'valtio';
import { useNavigate } from '@modern-js/runtime/router';
import { $state } from './state';

const Page: React.FC = () => {
const { service } = useSnapshot($state);
const navigate = useNavigate();

useEffect(() => {
navigate(service.rules ? './editor' : './welcome');
}, []);

return null;
};

export default Page;
47 changes: 47 additions & 0 deletions packages/devtools/client/src/client/routes/headers/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { proxy } from 'valtio';
import { ReadonlyDeep } from 'type-fest';
import { ModifyHeaderRule, ServiceStatus } from '@/client/utils/service-agent';

const SERVICE_SCRIPT = '/sw-proxy.js';

export const registerService = async (
rules: ReadonlyDeep<ModifyHeaderRule[]> = [],
) => {
const encodedRules = encodeURIComponent(JSON.stringify(rules));
const url = `${SERVICE_SCRIPT}?rules=${encodedRules}`;
const reg = await navigator.serviceWorker.register(url);
await navigator.serviceWorker.ready;
$state.service = fetchServiceStatus();
return reg;
};

export const unregisterService = async () => {
const registrations = await navigator.serviceWorker.getRegistrations();
let success = true;
for (const reg of registrations) {
success = success && (await reg.unregister());
}
$state.service = {};
return success;
};

export const fetchServiceStatus = async (): Promise<Partial<ServiceStatus>> => {
try {
const signal = AbortSignal.timeout(500);
const resp = await fetch('/__devtools/service/status', { signal });
const body = await resp.json();
return body;
} catch {
return {};
}
};

type PromiseOrNot<T> = Promise<T> | T;

export interface State {
service: PromiseOrNot<Partial<ServiceStatus>>;
}

export const $state = proxy<State>({
service: fetchServiceStatus(),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Link } from '@modern-js/runtime/router';
import React from 'react';

const Page: React.FC = () => {
return (
<div>
<div>Header Modifier</div>
<p>
Modifying headers of requests, useful for switch between traffic lanes.
</p>
<p>
That will register{' '}
<a href="/sw-proxy.js" target="_blank">
/sw-proxy.js
</a>{' '}
as a service worker to handle requests.
<Link to="./editor">
<button>Enable</button>
</Link>
</p>
</div>
);
};

export default Page;
52 changes: 1 addition & 51 deletions packages/devtools/client/src/client/rpc/index.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1 @@
import {
ClientFunctions,
ROUTE_BASENAME,
ServerFunctions,
} from '@modern-js/devtools-kit';
import { createBirpc } from 'birpc';
import { parseURL, stringifyParsedURL } from 'ufo';
import { StoreContextValue } from '@/client/types';

export interface SetupOptions {
url: string;
$store: StoreContextValue;
}

const parseDataSource = (url: string) => {
const newSrc = parseURL(url);
return stringifyParsedURL({
protocol: location.protocol === 'https:' ? 'wss:' : 'ws:',
host: location.host,
...newSrc,
pathname: newSrc.pathname || `${ROUTE_BASENAME}/rpc`,
});
};

export const setupServerConnection = async (options: SetupOptions) => {
const { url, $store } = options;
const ws = new window.WebSocket(parseDataSource(url));

const server = createBirpc<ServerFunctions, ClientFunctions>(
{
refresh: () => location.reload(),
updateFileSystemRoutes({ entrypoint, routes }) {
$store.framework.fileSystemRoutes[entrypoint.entryName] = routes;
},
},
{
post: data => ws.send(data),
on: cb => (ws.onmessage = cb),
serialize: v => JSON.stringify(v),
deserialize: v => JSON.parse(v.data.toString()),
},
);

await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve();
ws.onerror = () =>
reject(new Error(`Failed connect to WebSocket server: ${url}`));
});

return { server };
};
export * from './server';
51 changes: 51 additions & 0 deletions packages/devtools/client/src/client/rpc/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
ClientFunctions,
ROUTE_BASENAME,
ServerFunctions,
} from '@modern-js/devtools-kit';
import { createBirpc } from 'birpc';
import { parseURL, stringifyParsedURL } from 'ufo';
import { StoreContextValue } from '@/client/types';

export interface SetupOptions {
url: string;
$store: StoreContextValue;
}

const parseDataSource = (url: string) => {
const newSrc = parseURL(url);
return stringifyParsedURL({
protocol: location.protocol === 'https:' ? 'wss:' : 'ws:',
host: location.host,
...newSrc,
pathname: newSrc.pathname || `${ROUTE_BASENAME}/rpc`,
});
};

export const setupServerConnection = async (options: SetupOptions) => {
const { url, $store } = options;
const ws = new window.WebSocket(parseDataSource(url));

const server = createBirpc<ServerFunctions, ClientFunctions>(
{
refresh: () => location.reload(),
updateFileSystemRoutes({ entrypoint, routes }) {
$store.framework.fileSystemRoutes[entrypoint.entryName] = routes;
},
},
{
post: data => ws.send(data),
on: cb => (ws.onmessage = cb),
serialize: v => JSON.stringify(v),
deserialize: v => JSON.parse(v.data.toString()),
},
);

await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve();
ws.onerror = () =>
reject(new Error(`Failed connect to WebSocket server: ${url}`));
});

return { server };
};
12 changes: 12 additions & 0 deletions packages/devtools/client/src/client/utils/assert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function assert(
value: unknown,
message?: string | Error,
): asserts value {
if (!value) {
if (message instanceof Error) {
throw message;
} else {
throw new Error(message ?? 'ASSERT_ERROR');
}
}
}
2 changes: 2 additions & 0 deletions packages/devtools/client/src/client/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './context';
export * from './hooks';
export * from './assert';
Loading