Add README and example structure

This commit is contained in:
Mark Sinclair
2022-11-03 10:25:58 +00:00
parent d4584c305a
commit 0931236a98
38 changed files with 1452 additions and 0 deletions
+1
View File
@@ -4,6 +4,7 @@
"private": true,
"license": "Apache 2.0",
"workspaces": [
"sdk/typescript/**",
"ts-packages/*",
"nym-wallet",
"nym-connect",
+12
View File
@@ -0,0 +1,12 @@
# Nym SDK
Welcome to the Nym SDK. This is a good starting point for finding resources to build software using Nym components.
The SDK is split into technologies and platforms:
- [Typescript](typescript) - packages for the Javascript ecosystem leveraging Nym's Typescript and WASM clients. Use these to build browser apps, web apps and mobile apps that can make use of the Nym mixnet and Coconut credentials
Coming soon:
- [iOS](ios) - native libraries for iOS, that can be used directly or from React Native using wrappers
- [Android](android) - native libraries for iOS, that can be used via JNI or from React Native using wrappers
+18
View File
@@ -0,0 +1,18 @@
# See http://editorconfig.org/
# EditorConfig helps developers define and maintain consistent coding styles
# between different editors and IDEs. The EditorConfig project consists of a
# file format for defining coding styles and a collection of text editor plugins
# that enable editors to read the file format and adhere to defined styles.
# EditorConfig files are easily readable and they work nicely with version
# control systems.
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
+6
View File
@@ -0,0 +1,6 @@
{
"root": true,
"extends": [
"@nymproject/eslint-config-react-typescript"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"trailingComma": "all",
"singleQuote": true,
"printWidth": 120,
"tabWidth": 2
}
+9
View File
@@ -0,0 +1,9 @@
# Changelog
## Unreleased
### Added
#### Mixnet
- threaded mixnet client that uses the Nym WASM client
+38
View File
@@ -0,0 +1,38 @@
# Nym SDK (Typescript)
The Nym SDK for Typescript will get you creating apps that can use the Nym Mixnet and Coconut credentials quickly.
## TL;DR
Include the SDK in your project:
```
npm install @nymproject/sdk
```
Open a connection to a Gateway on the Nym Mixnet:
```ts
import { client } from '@nymproject/sdk';
const session = await client.connect('<<GATEWAY>>');
```
This will start the WASM client on a worker thread, so that your code can stay nice and snappy.
Send a message to another user (you will need to know their address at a Gateway):
```ts
const result = await client.send('<<USER ADDRESS>>', 'Hello Timmy!');
```
## Examples
- [Plain HTML + Javascript](examples/plain-html) - very simple chat app written in plain Javascript
- [Chat App](examples/chat) - simple chat app written in React with Webpack
Coming soon:
- [Node tester](examples/node-tester) - a React app that sends test packets to a mixnode and measure the network speed
- [Mixnet topology viewer](examples/topology) - a Svelte app that shows the mixnodes current in the active set
- [Get a bandwidth voucher](examples/coconut-bandwidth-voucher) - get a bandwidth voucher to use the mixnet
@@ -0,0 +1,3 @@
{
"presets": ["@babel/env", "@babel/react"]
}
@@ -0,0 +1,98 @@
# Example with React + Typescript + Webpack 5 + MUI
An example of using default Webpack and Typescript settings with React and MUI, including theming.
You can use this example as a seed for a new project.
Remember to build the dependency packages from the root of this repo by running:
```
yarn
yarn build
```
If you need to make changes to the dependency packages, you can run `yarn watch` in that package to watch for chagnes and build them. This project will pick up the changes in the built package and hot-reload / recompile.
## Features
### Yarn workspaces
Packages from `ts-packages` are shared using Yarn workspaces. Make sure you add you new project to [package.json](../../package.json) to use the shared packages.
> ⚠️ **Warning**: Yarn workspaces will share all dependencies between projects and works by falling back to parent directories until a `node_modules` directory is found. So be careful when messing around with `node_modules` and resolution, because unexpected things could happen - for example, if you do not run `yarn` from the root and you have a `node_modules` in a directory that is a parent of the directory where you checkout out this repository, that `node_modules` will be used for resolving packages 🙀.
### Typescript
Shared Typescript config is in [tsconfig.json](./tsconfig.json), with specific production settings in [tsconfig.prod.json](./tsconfig.prod.json) that:
- exclude Storybook stories and Jest tests
- do not output typing `*.d.ts` files
### Webpack
Inherit config for Webpack 5 with additional tweaks including:
- favicon generation from [favicon asset files](../../../../assets/favicon/favicon.png)
- asset handling (svg, png, fonts, css, etc)
- minification
The development settings include:
- `ts-loader` for quick transpilation
- threaded type checking using `tsc`
- hot reloading using `react-refresh`
### Storybook
Storybook is available in [@nymproject/react](../../../../ts-packages/react-components/src/stories/Introduction.stories.mdx) and can be run using `yarn storybook`.
### MUI and theming
The [Nym theme](../mui-theme/src/theme/theme.ts) provides a theme provider that you can add as follows:
```typescript jsx
export const App: React.FC = () => (
<AppContextProvider>
<AppTheme>
<Content />
</AppTheme>
</AppContextProvider>
);
export const AppTheme: React.FC = ({ children }) => {
const { mode } = useAppContext();
return <NymThemeProvider mode={mode}>{children}</NymThemeProvider>;
};
export const Content: React.FC = () => {
...
}
```
And augment typings for the Theme by adding [mui-theme.d.ts](./src/theme/mui-theme.d.ts):
```typescript
import { Theme, ThemeOptions, Palette, PaletteOptions } from '@mui/material/styles';
import { NymTheme, NymPaletteWithExtensions, NymPaletteWithExtensionsOptions } from '@nymproject/mui-theme';
declare module '@mui/material/styles' {
interface Theme extends NymTheme {}
interface ThemeOptions extends Partial<NymTheme> {}
interface Palette extends NymPaletteWithExtensions {}
interface PaletteOptions extends NymPaletteWithExtensionsOptions {}
}
```
Adding the above, means that any component now has the correct typings, for example, below the Nym palette interface is available for all MUI `Theme` instances with code completion for VSCode and IntelliJ:
```typescript jsx
import { Typography } from '@mui/material';
...
<Typography sx={{ color: (theme) => theme.palette.nym.networkExplorer.mixnodes.status.active }}>
The quick brown fox jumps over the white fence
</Typography>
```
@@ -0,0 +1,5 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
@@ -0,0 +1,86 @@
{
"name": "@nymproject/sdk-example-react-webpack-wasm",
"description": "An example project that uses WASM, React, Webpack, Typescript and the Nym theme + components library",
"version": "1.0.0",
"license": "Apache-2.0",
"dependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2",
"@mui/material": "^5.0.1",
"@mui/styles": "^5.0.1",
"@mui/icons-material": "^5.5.0",
"@mui/lab": "^5.0.0-alpha.72",
"@nymproject/mui-theme" : "^1.0.0",
"@nymproject/react" : "^1.0.0",
"@nymproject/sdk": "1",
"use-clipboard-copy": "^0.2.0"
},
"devDependencies": {
"@babel/core": "^7.15.0",
"@babel/plugin-transform-async-to-generator": "^7.14.5",
"@babel/preset-env": "^7.15.0",
"@babel/preset-react": "^7.14.5",
"@babel/preset-typescript": "^7.15.0",
"@nymproject/eslint-config-react-typescript": "^1.0.0",
"@nymproject/webpack": "^1.0.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
"@svgr/webpack": "^6.1.1",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"@types/jest": "^27.0.1",
"@types/node": "^16.7.13",
"@types/react": "^17.0.34",
"@typescript-eslint/eslint-plugin": "^5.13.0",
"@typescript-eslint/parser": "^5.13.0",
"babel-loader": "^8.2.2",
"babel-plugin-root-import": "^5.1.0",
"clean-webpack-plugin": "^4.0.0",
"css-loader": "^6.2.0",
"css-minimizer-webpack-plugin": "^3.0.2",
"dotenv-webpack": "^7.0.3",
"eslint": "^8.10.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^16.1.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-root-import": "^1.0.4",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jest": "^26.1.1",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.29.2",
"eslint-plugin-react-hooks": "^4.3.0",
"favicons": "^6.2.2",
"favicons-webpack-plugin": "^5.0.2",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^7.2.1",
"html-webpack-plugin": "^5.3.2",
"jest": "^27.1.0",
"mini-css-extract-plugin": "^2.2.2",
"prettier": "^2.5.1",
"react-refresh-typescript": "^2.0.3",
"style-loader": "^3.2.1",
"thread-loader": "^3.0.4",
"ts-jest": "^27.0.5",
"ts-loader": "^9.2.5",
"tsconfig-paths-webpack-plugin": "^3.5.2",
"typescript": "^4.6.2",
"url-loader": "^4.1.1",
"webpack": "^5.64.3",
"webpack-cli": "^4.8.0",
"webpack-dev-server": "^4.5.0",
"webpack-favicons": "^1.3.8",
"webpack-merge": "^5.8.0"
},
"scripts": {
"start": "webpack serve --progress --port 3000",
"build": "webpack build --progress --config webpack.prod.js",
"build:dev": "webpack build --progress",
"build:serve": "npx serve dist",
"test": "jest",
"test:watch": "jest --watch",
"tsc": "tsc",
"tsc:watch": "tsc --watch",
"lint": "eslint src",
"lint:fix": "eslint src --fix"
}
}
@@ -0,0 +1,242 @@
import * as React from 'react';
import {
Box,
Chip,
CircularProgress,
Container,
Stack,
Tooltip,
Typography,
TextField,
Button,
InputAdornment,
} from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import PersonIcon from '@mui/icons-material/Person';
import { NymLogo } from '@nymproject/react/logo/NymLogo';
import { NymThemeProvider } from '@nymproject/mui-theme';
import { useTheme } from '@mui/material/styles';
import { useClipboard } from 'use-clipboard-copy';
import { ThemeToggle } from './ThemeToggle';
import { AppContextProvider, useAppContext } from './context';
import { MixnetContextProvider, useMixnetContext } from './context/mixnet';
export const AppTheme: React.FC = ({ children }) => {
const { mode } = useAppContext();
return <NymThemeProvider mode={mode}>{children}</NymThemeProvider>;
};
interface Log {
kind: 'tx' | 'rx';
message: string;
timestamp: Date;
}
export const Content: React.FC = () => {
const theme = useTheme();
const { isReady, address, connect, events, sendTextMessage } = useMixnetContext();
const copy = useClipboard();
const [sendToSelf, setSendToSelf] = React.useState(false);
const [recipient, setRecipient] = React.useState<string>();
const handleRecipientChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setRecipient(event.target.value);
};
const [message, setMessage] = React.useState<string>('This is a test message');
const handleMessageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setMessage(event.target.value);
};
const log = React.useRef<Log[]>([]);
const [_logTrigger, setLogTrigger] = React.useState(Date.now());
React.useEffect(() => {
if (isReady) {
// // mixnet v1
// const validatorApiUrl = 'https://validator.nymtech.net/api';
// const preferredGatewayIdentityKey = 'E3mvZTHQCdBvhfr178Swx9g4QG3kkRUun7YnToLMcMbM';
// mixnet v2
const validatorApiUrl = 'https://qwerty-validator-api.qa.nymte.ch/api'; // "http://localhost:8081";
const preferredGatewayIdentityKey = undefined; // '36vfvEyBzo5cWEFbnP7fqgY39kFw9PQhvwzbispeNaxL';
connect({
clientId: 'Example Client',
validatorApiUrl,
preferredGatewayIdentityKey,
});
}
}, [isReady]);
React.useEffect(() => {
if (events) {
const unsubcribe = events.subscribeToTextMessageReceivedEvent((e) => {
log.current.push({
kind: 'rx',
timestamp: new Date(),
message: e.args.message,
});
setLogTrigger(Date.now());
});
// cleanup on unmount
return unsubcribe;
}
// no cleanup
return undefined;
}, [events]);
const handleSend = async () => {
if (!message) {
console.error('No message set');
return;
}
if (!recipient) {
console.error('No recipient set');
return;
}
log.current.push({
kind: 'tx',
timestamp: new Date(),
message,
});
setLogTrigger(Date.now());
await sendTextMessage({ message, recipient });
};
return (
<Container sx={{ py: 4 }}>
<Box display="flex" flexDirection="row-reverse" pb={2}>
<ThemeToggle />
</Box>
<NymLogo height={50} />
<h1>Nym Mixnet Chat App</h1>
<Box mb={5}>
<Typography>
This is an example app that uses React, Typescript, Webpack and the Nym theme + components with the WASM
Mixnet Client.
</Typography>
</Box>
<Box mb={4}>
<Stack direction="row" spacing={2} alignItems="center">
{!isReady ? (
<>
<CircularProgress size={theme.typography.fontSize * 1.5} />
<Typography>Connecting...</Typography>
</>
) : (
<>
<Chip color="success" icon={<CheckCircleIcon />} label="Connected" variant="outlined" />
{address && (
<Tooltip arrow title="Copy your client address to the clipboard">
<Chip
clickable
label={`${address.slice(0, 24)}...`}
onClick={() => {
if (address) {
copy.copy(address);
}
}}
icon={<ContentCopyIcon />}
/>
</Tooltip>
)}
</>
)}
</Stack>
{isReady && address && (
<Stack direction="column" mt={6} spacing={4}>
{!sendToSelf ? (
<TextField
id="recipient"
label="Recipient address"
required
value={recipient}
onChange={handleRecipientChange}
InputLabelProps={{ shrink: true }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Tooltip title="Use your own address to send messages to yourself" arrow>
<PersonIcon
sx={{ cursor: 'pointer' }}
onClick={() => {
if (address) {
setSendToSelf(true);
setRecipient(address);
}
}}
/>
</Tooltip>
</InputAdornment>
),
}}
/>
) : (
<TextField
id="recipientSendToSelf"
label="Recipient address"
value={address}
onChange={() => undefined}
InputLabelProps={{ shrink: true }}
InputProps={{
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<Tooltip title="Use your own address to send messages to yourself" arrow>
<PersonIcon sx={{ cursor: 'pointer' }} onClick={() => setSendToSelf(false)} />
</Tooltip>
</InputAdornment>
),
}}
/>
)}
<TextField
id="message"
required
label="Enter some text to send"
multiline
rows={4}
value={message}
onChange={handleMessageChange}
/>
<Button variant="contained" sx={{ width: 100 }} onClick={handleSend}>
Send
</Button>
</Stack>
)}
</Box>
{log.current.map((item) => (
<Box key={item.kind + item.timestamp.toISOString()}>
<Stack
direction="row"
spacing={2}
alignItems="start"
sx={{ color: item.kind === 'tx' ? theme.palette.success.main : theme.palette.primary.main }}
>
{item.kind === 'tx' ? <ArrowForwardIcon /> : <ArrowBackIcon />}
<Chip variant="outlined" label={item.timestamp.toLocaleTimeString()} />
<Typography>{item.message}</Typography>
</Stack>
</Box>
))}
</Container>
);
};
export const App: React.FC = () => (
<AppContextProvider>
<MixnetContextProvider>
<AppTheme>
<Content />
</AppTheme>
</MixnetContextProvider>
</AppContextProvider>
);
@@ -0,0 +1,21 @@
import * as React from 'react';
import { Button, Typography } from '@mui/material';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import { useAppContext } from './context';
export const ThemeToggle: React.FC = () => {
const { mode, toggleMode } = useAppContext();
return (
<Button variant="outlined" color="secondary" onClick={toggleMode} sx={{ display: 'flex', alignItems: 'centre' }}>
{mode === 'dark' ? (
<DarkModeIcon sx={{ color: (theme) => theme.palette.text.secondary }} />
) : (
<LightModeIcon sx={{ color: (theme) => theme.palette.text.secondary }} />
)}
<Typography ml={1} color={(theme) => theme.palette.primary.light}>
Switch to {mode === 'dark' ? 'light mode' : 'dark mode'}
</Typography>
</Button>
);
};
@@ -0,0 +1,2 @@
import('./index')
.catch(error => console.error('Unable to load app :-(', error));
@@ -0,0 +1,34 @@
import { PaletteMode } from '@mui/material';
import * as React from 'react';
interface State {
mode: PaletteMode;
toggleMode: () => void;
}
const AppContext = React.createContext<State | undefined>(undefined);
export const useAppContext = (): State => {
const context = React.useContext<State | undefined>(AppContext);
if (!context) {
throw new Error('Please include a `import { AppContextProvider } from "./context"` before using this hook');
}
return context;
};
export const AppContextProvider: React.FC = ({ children }) => {
// light/dark mode
const [mode, setMode] = React.useState<PaletteMode>('dark');
const value = React.useMemo<State>(
() => ({
mode,
toggleMode: () => setMode((prevMode) => (prevMode !== 'light' ? 'light' : 'dark')),
}),
[mode],
);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
};
@@ -0,0 +1,76 @@
import * as React from 'react';
import { createNymMixnetClient, IWebWorkerEvents, NymClientConfig, NymMixnetClient } from '@nymproject/sdk';
interface State {
// data
isReady: boolean;
address?: string;
events?: IWebWorkerEvents;
// methods
connect: (config: NymClientConfig) => Promise<void>;
sendTextMessage: (args: { message: string; recipient: string }) => Promise<void>;
}
const MixnetContext = React.createContext<State | undefined>(undefined);
export const useMixnetContext = (): State => {
const context = React.useContext<State | undefined>(MixnetContext);
if (!context) {
throw new Error('Please include a `import { MixnetContextProvider } from "./context"` before using this hook');
}
return context;
};
export const MixnetContextProvider: React.FC = ({ children }) => {
const [isReady, setReady] = React.useState<boolean>(false);
const [address, setAddress] = React.useState<string>();
const nym = React.useRef<NymMixnetClient | null>(null);
React.useEffect(() => {
// on mount of the provider, create the client
(async () => {
nym.current = await createNymMixnetClient();
if (nym.current?.events) {
nym.current.events.subscribeToConnected((e) => {
setAddress(e.args.address);
});
}
setReady(true);
})();
//
}, []);
const connect = async (config: NymClientConfig) => {
if (!nym.current?.client) {
console.error('Nym client has not initialised. Please wrap in useEffect on `isReady` prop of this context.');
return;
}
await nym.current.client.start(config);
};
const sendTextMessage = async (args: { message: string; recipient: string }) => {
if (!nym.current?.client) {
console.error('Nym client has not initialised. Please wrap in useEffect on `isReady` prop of this context.');
return;
}
await nym.current.client.sendMessage(args);
};
const value = React.useMemo<State>(
() => ({
isReady,
events: nym.current?.events,
address,
connect,
sendTextMessage,
}),
[isReady, nym.current, address],
);
return <MixnetContext.Provider value={value}>{children}</MixnetContext.Provider>;
};
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Nym Example with React, Typescript, Webpack</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app"></div>
</body>
</html>
@@ -0,0 +1,5 @@
import * as React from 'react';
import ReactDOM from 'react-dom';
import { App } from './App';
ReactDOM.render(<App />, document.getElementById('app'));
@@ -0,0 +1,13 @@
import React, { render } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { App } from '../App';
describe('App', () => {
beforeEach(() => {
render(<App />);
});
it('should render without exploding', () => {
const { container } = render(<App />);
expect(container.firstChild).toBeInTheDocument();
});
});
@@ -0,0 +1,37 @@
/* eslint-disable no-shadow,@typescript-eslint/no-unused-vars,@typescript-eslint/no-empty-interface,import/no-extraneous-dependencies */
import { Theme, ThemeOptions, Palette, PaletteOptions } from '@mui/material/styles';
import { NymTheme, NymPaletteWithExtensions, NymPaletteWithExtensionsOptions } from '@nymproject/mui-theme';
/**
* If you are unfamiliar with Material UI theming, please read the following first:
* - https://mui.com/customization/theming/
* - https://mui.com/customization/palette/
* - https://mui.com/customization/dark-mode/#dark-mode-with-custom-palette
*
* This file adds typings to the theme using Typescript's module augmentation.
*
* Read the following if you are unfamiliar with module augmentation and declaration merging. Then
* look at the recommendations from Material UI docs for implementation:
* - https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
* - https://www.typescriptlang.org/docs/handbook/declaration-merging.html#merging-interfaces
* - https://mui.com/customization/palette/#adding-new-colors
*
*
* IMPORTANT:
*
* The type augmentation must match MUI's definitions. So, notice the use of `interface` rather than
* `type Foo = { ... }` - this is necessary to merge the definitions.
*/
declare module '@mui/material/styles' {
/**
* This augments the definitions of the MUI Theme with the Nym theme, as well as
* a partial `ThemeOptions` type used by `createTheme`
*
* IMPORTANT: only add extensions to the interfaces above, do not modify the lines below
*/
interface Theme extends NymTheme {}
interface ThemeOptions extends Partial<NymTheme> {}
interface Palette extends NymPaletteWithExtensions {}
interface PaletteOptions extends NymPaletteWithExtensionsOptions {}
}
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"outDir": "./dist"
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"node_modules",
"build",
"dist"
]
}
@@ -0,0 +1,20 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"outDir": "./dist",
"declaration": false
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"node_modules",
"build",
"dist",
"**/*.stories.*",
"**/*.test.*",
"**/*.spec.*"
]
}
@@ -0,0 +1,31 @@
const path = require('path');
const { mergeWithRules } = require('webpack-merge');
const { webpackCommon } = require('@nymproject/webpack');
module.exports = mergeWithRules({
module: {
rules: {
test: 'match',
use: 'replace',
},
},
})(
webpackCommon(__dirname, [
{
inject: true,
filename: 'index.html',
template: path.resolve(__dirname, 'src/index.html'),
chunks: ['bootstrap'],
},
]),
{
entry: {
bootstrap: path.resolve(__dirname, 'src/bootstrap.ts'),
worker: require.resolve('@nymproject/sdk/mixnet/wasm/worker.js'),
},
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: '/',
},
},
);
@@ -0,0 +1,67 @@
const { mergeWithRules } = require('webpack-merge');
const webpack = require('webpack');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const ReactRefreshTypeScript = require('react-refresh-typescript');
const commonConfig = require('./webpack.common');
module.exports = mergeWithRules({
module: {
rules: {
test: 'match',
use: 'replace',
},
},
})(commonConfig, {
mode: 'development',
devtool: 'inline-source-map',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
options: {
getCustomTransformers: () => ({
before: [ReactRefreshTypeScript()],
}),
// `ts-loader` does not work with HMR unless `transpileOnly` is used.
// If you need type checking, `ForkTsCheckerWebpackPlugin` is an alternative.
transpileOnly: true,
},
},
],
},
plugins: [
new ReactRefreshWebpackPlugin(),
// this can be included automatically by the dev server, however build mode fails if missing
new webpack.HotModuleReplacementPlugin(),
],
target: 'web',
devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
historyApiFallback: true,
},
// recommended for faster rebuild
optimization: {
runtimeChunk: true,
removeAvailableModules: false,
removeEmptyChunks: false,
splitChunks: false,
},
cache: {
type: 'filesystem',
buildDependencies: {
// restart on config change
config: ['./webpack.config.js'],
},
},
});
@@ -0,0 +1,42 @@
const { mergeWithRules } = require('webpack-merge');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const commonConfig = require('./webpack.common');
module.exports = mergeWithRules({
module: {
rules: {
test: 'match',
use: 'replace',
},
},
})(commonConfig, {
mode: 'production',
// TODO: no source maps, add back
devtool: false,
module: {
rules: [
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
],
},
optimization: {
minimizer: ['...', new CssMinimizerPlugin()],
splitChunks: {
chunks: 'all',
},
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
}),
],
output: {
pathinfo: false,
filename: '[name].[contenthash].js',
},
});
@@ -0,0 +1,39 @@
# `@nym-project/nym-client-wasm`
This package contains a WASM client for using the Nym mixnet.
## Usage
You should use `@nym-project/sdk` instead of using this package directory.
If you want to use it directly, you'll need to configure bundling and interop as suits your project.
## Build
The build process is a bit nasty. This is necessary to make the package easier to consume.
Build the package by running the following from this directory:
```
scripts/build.sh
```
The following files will be copied into this directory, so that it becomes a package that can be used by the `@nym-project/sdk` package:
```
nym_client_wasm.d.ts
nym_client_wasm.js
nym_client_wasm_bg.wasm
nym_client_wasm_bg.wasm.d.ts
package.json
```
## Publish to `npm`
First build the package with the instructions above.
Publish to `npm` with:
```
npm publish
```
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -o errexit
set -o nounset
set -o pipefail
# change to the same directory as the script, then go up one
cd "$(dirname "$0")"
cd ..
# clear out any files and suppress missing file errors
rm nym_client_wasm* package.json || true
# let wasm-pack build the files and put them in the output location rather than `./pkg`
cd ../../../../clients/webassembly
wasm-pack build --scope nymproject --target no-modules --out-dir ../../sdk/typescript/packages/nym-client-wasm
# clean up some files that come with the build
cd ../../sdk/typescript/packages/nym-client-wasm
rm README.md LICENSE_APACHE
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@nymproject/sdk",
"version": "1.0.0",
"license": "Apache-2.0",
"author": "Nym Technologies SA",
"main": "dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist/worker.js",
"dist/nym_client_wasm.d.ts",
"dist/nym_client_wasm.js",
"dist/nym_client_wasm_bg.wasm",
"dist/nym_client_wasm_bg.wasm.d.ts"
],
"exports": {
".": "./dist/index.js",
"./mixnet/wasm/*": "./dist/mixnet/wasm/*"
},
"scripts": {
"start": "tsc -w",
"clean": "rimraf dist",
"typecheck": "tsc --noEmit true",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"build": "tsc",
"postbuild": "cp ../nym-client-wasm/nym_client_wasm* dist/mixnet/wasm"
},
"dependencies": {
"comlink": "^4.3.1"
},
"devDependencies": {
"@nymproject/eslint-config-react-typescript": "^1.0.0",
"@typescript-eslint/eslint-plugin": "^5.13.0",
"@typescript-eslint/parser": "^5.13.0",
"rimraf": "^3.0.2",
"typescript": "^4.8.4",
"eslint": "^8.10.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^16.1.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-root-import": "^1.0.4",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jest": "^26.1.1",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.29.2",
"eslint-plugin-react-hooks": "^4.3.0"
}
}
@@ -0,0 +1,2 @@
// eslint-disable-next-line no-console
export const notImplementedYet = () => console.log('Not implement, coming soon...');
+2
View File
@@ -0,0 +1,2 @@
export * from './coconut';
export * from './mixnet';
@@ -0,0 +1,2 @@
export * from './wasm';
export * from './wasm/types';
@@ -0,0 +1,16 @@
# Mixnet WASM web worker
This directory contains code that must be bundled as a web worker, so there are some restrictions:
- limited options for importing scripts
- `wasm-pack` needs synchronous loading for WASM blobs
# Features
- `comlink` provides messaging wrapper between calling thread and web worker thread
- [worker.ts](./worker.ts) must be bundled as an entry point for the worker
- `URL(..)` types used so the bundler can identify dependent code and bundle correctly
# TODO
- add support for using `Transfer` to move binary objects from the main thread to the web worker
- wire up handler for receiving binary messages
@@ -0,0 +1,123 @@
import * as Comlink from 'comlink';
import {
EventHandlerFn,
EventKinds,
IWebWorker,
IWebWorkerEvents,
ConnectedEvent,
LoadedEvent,
TextMessageReceivedEvent,
BinaryMessageReceivedEvent,
} from './types';
/**
* Client for the Nym mixnet.
*/
export interface NymMixnetClient {
client: Comlink.Remote<IWebWorker>;
events: IWebWorkerEvents;
}
/**
* Create a client to send and receive traffic from the Nym mixnet.
*
*/
export const createNymMixnetClient = async (): Promise<NymMixnetClient> => {
// create a web worker that runs the WASM client on another thread and wait until it signals that it is ready
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const worker = await createWorker();
// stores the subscriptions for events
const subscriptions: {
[key: string]: Array<EventHandlerFn<unknown>>;
} = {};
/**
* Helper method to get typed subscriptions
*/
const getSubscriptions = <E>(key: EventKinds): Array<EventHandlerFn<E>> => {
if (!subscriptions[key]) {
subscriptions[key] = [];
}
return subscriptions[key] as Array<EventHandlerFn<E>>;
};
// listen to messages from the worker, parse them and let the subscribers handle them, catching any unhandled exceptions
worker.addEventListener('message', (msg) => {
if (msg.data && msg.data.kind) {
const subscribers = subscriptions[msg.data.kind];
(subscribers || []).forEach((s) => {
try {
// let the subscriber handle the message
s(msg.data);
} catch (e) {
// eslint-disable-next-line no-console
console.error('Unhandled error in event handler', msg.data, e);
}
});
}
});
// manage the subscribers, returning self-unsubscribe methods
const events: IWebWorkerEvents = {
subscribeToConnected: (handler) => {
getSubscriptions<ConnectedEvent>(EventKinds.Connected).push(handler);
return () => {
getSubscriptions<ConnectedEvent>(EventKinds.Connected).unshift(handler);
};
},
subscribeToLoaded: (handler) => {
getSubscriptions<LoadedEvent>(EventKinds.Loaded).push(handler);
return () => {
getSubscriptions<LoadedEvent>(EventKinds.Loaded).unshift(handler);
};
},
subscribeToTextMessageReceivedEvent: (handler) => {
getSubscriptions<TextMessageReceivedEvent>(EventKinds.TextMessageReceived).push(handler);
return () => {
getSubscriptions<TextMessageReceivedEvent>(EventKinds.TextMessageReceived).unshift(handler);
};
},
subscribeToBinaryMessageReceivedEvent: (handler) => {
getSubscriptions<BinaryMessageReceivedEvent>(EventKinds.BinaryMessageReceived).push(handler);
return () => {
getSubscriptions<BinaryMessageReceivedEvent>(EventKinds.BinaryMessageReceived).unshift(handler);
};
},
};
// let comlink handle interop with the web worker
const client = Comlink.wrap<IWebWorker>(worker);
// pass the client interop and subscription manage back to the caller
return {
client,
events,
};
};
/**
* Async method to create a web worker that runs the Nym client on another thread. It will only return once the worker
* has passed back a `Loaded` event to the calling thread.
*
* @return The instance of the web worker.
*/
const createWorker = async () =>
new Promise<Worker>((resolve, reject) => {
const worker = new Worker(
new URL('./worker.js', import.meta.url), // NB: this path is relative to the `dist` directory of this bundle
);
worker.addEventListener('error', reject);
worker.addEventListener(
'message',
(msg) => {
worker.removeEventListener('error', reject);
if (msg.data?.kind === EventKinds.Loaded) {
resolve(worker);
} else {
reject(msg);
}
},
{ once: true },
);
});
@@ -0,0 +1,82 @@
/// <reference path="../../../../nym-client-wasm/nym_client_wasm.d.ts" />
export type OnMessageFn = (message: string) => void;
export type OnConnectFn = (address?: string) => void;
export type EventHandlerFn<E> = (e: E) => void | Promise<void>;
export type EventHandlerSubscribeFn<E> = (fn: EventHandlerFn<E>) => EventHandlerUnsubscribeFn;
export type EventHandlerUnsubscribeFn = () => void;
export interface NymClientConfig {
/**
* A human-readable id for the client.
*/
clientId: string;
/**
* The URL of a validator API to query for the network topology.
*/
validatorApiUrl: string;
/**
* Optional. The identity key of the preferred gateway to connect to.
*/
preferredGatewayIdentityKey?: string;
/**
* Optional. Settings for the WASM client.
*/
debug?: wasm_bindgen.Debug;
}
export interface IWebWorker {
start: (config: NymClientConfig) => void;
selfAddress: () => string | undefined;
sendMessage: (args: { message: string; recipient: string }) => void;
sendBinaryMessage: (args: { message: Uint8Array; recipient: string }) => void;
}
export enum EventKinds {
Loaded = 'Loaded',
Connected = 'Connected',
TextMessageReceived = 'TextMessageReceived',
BinaryMessageReceived = 'BinaryMessageReceived',
}
export interface LoadedEvent {
kind: EventKinds.Loaded;
args: {
loaded: true;
};
}
export interface ConnectedEvent {
kind: EventKinds.Connected;
args: {
address?: string;
};
}
export interface TextMessageReceivedEvent {
kind: EventKinds.TextMessageReceived;
args: {
message: string;
};
}
export interface BinaryMessageReceivedEvent {
kind: EventKinds.TextMessageReceived;
args: {
message: Uint8Array;
};
}
export interface IWebWorkerEvents {
subscribeToLoaded: EventHandlerSubscribeFn<LoadedEvent>;
subscribeToConnected: EventHandlerSubscribeFn<ConnectedEvent>;
subscribeToTextMessageReceivedEvent: EventHandlerSubscribeFn<TextMessageReceivedEvent>;
subscribeToBinaryMessageReceivedEvent: EventHandlerSubscribeFn<BinaryMessageReceivedEvent>;
}
@@ -0,0 +1,160 @@
/* eslint-disable no-console,no-restricted-globals */
/// <reference path="../../../../nym-client-wasm/nym_client_wasm.d.ts" />
/**
* NB: URL syntax is used so that bundlers like webpack can load this package's code when inside the final bundle
* the files from ../../../../nym-client-wasm will be copied into the dist directory of this package, so all import
* paths are _relative to the output directory_ of this package (`dist`) - don't get confused!
*/
import * as Comlink from 'comlink';
import type {
ConnectedEvent,
IWebWorker,
LoadedEvent,
OnMessageFn,
OnConnectFn,
TextMessageReceivedEvent,
NymClientConfig,
} from './types';
import { EventKinds } from './types';
// web workers are only allowed to load external scripts as the load
importScripts(new URL('./nym_client_wasm.js', import.meta.url));
console.log('[Nym WASM client] Starting Nym WASM web worker...');
// again, construct a URL that can be used by a bundler to repackage the WASM binary
const wasmUrl = new URL('./nym_client_wasm_bg.wasm', import.meta.url);
/**
* Helper method to send typed messages.
* @param event The strongly typed message to send back to the calling thread.
*/
const postMessageWithType = <E>(event: E) => self.postMessage(event);
/**
* This class holds the state of the Nym WASM client and provides any interop needed.
*/
class ClientWrapper {
client: wasm_bindgen.NymClient | null = null;
/**
* Creates the WASM client and initialises it.
*/
init = (config: wasm_bindgen.Config, onConnectHandler: OnConnectFn, onMessageHandler: OnMessageFn) => {
this.client = new wasm_bindgen.NymClient(config);
this.client.set_on_message(onMessageHandler);
};
/**
* Returns the address of this client.
*/
selfAddress = () => {
if (!this.client) {
console.error('Client has not been initialised. Please call `init` first.');
return undefined;
}
return this.client.self_address();
};
/**
* Connects to the gateway and starts the client sending traffic.
*/
start = async () => {
if (!this.client) {
console.error('Client has not been initialised. Please call `init` first.');
return;
}
// this is current limitation of wasm in rust - for async methods you can't take self by reference...
// I'm trying to figure out if I can somehow hack my way around it, but for time being you have to re-assign
// the object (it's the same one)
this.client = await this.client.start();
};
sendMessage = async ({ message, recipient }: { recipient: string; message: string }) => {
if (!this.client) {
console.error('Client has not been initialised. Please call `init` first.');
return;
}
this.client = await this.client.send_message(message, recipient);
};
sendBinaryMessage = async ({ message, recipient }: { recipient: string; message: Uint8Array }) => {
if (!this.client) {
console.error('Client has not been initialised. Please call `init` first.');
return;
}
this.client = await this.client.send_binary_message(message, recipient);
};
}
// load WASM binary
wasm_bindgen(wasmUrl)
.then((importResult) => {
// sets up better stack traces in case of in-rust panics
importResult.set_panic_hook();
// this wrapper handles any state that the wasm-pack interop needs, e.g. holding an instance of the instantiated WASM code
const wrapper = new ClientWrapper();
const startHandler = async (config: NymClientConfig) => {
// fetch the gateway details (randomly chosen if no preferred gateway is set)
const gatewayEndpoint = await wasm_bindgen.get_gateway(
config.validatorApiUrl,
config.preferredGatewayIdentityKey,
);
// create the client, passing handlers for events
wrapper.init(
new wasm_bindgen.Config(
config.clientId,
config.validatorApiUrl,
gatewayEndpoint,
config.debug || wasm_bindgen.default_debug(),
),
() => {
console.log();
},
(message) => {
postMessageWithType<TextMessageReceivedEvent>({ kind: EventKinds.TextMessageReceived, args: { message } });
},
);
// start the client sending traffic
await wrapper.start();
// get the address
const address = wrapper.selfAddress();
postMessageWithType<ConnectedEvent>({ kind: EventKinds.Connected, args: { address } });
};
// implement the public logic of this web worker (message exchange between the worker and caller is done by https://www.npmjs.com/package/comlink)
const webWorker: IWebWorker = {
start(config) {
console.log('[Nym WASM client] Starting...', { config });
startHandler(config).catch((e) => console.error('[Nym WASM client] Failed to start', e));
},
selfAddress() {
return wrapper.selfAddress();
},
sendMessage(args) {
wrapper.sendMessage(args).catch((e) => console.error('[Nym WASM client] Failed to send message', e));
},
sendBinaryMessage(args) {
wrapper.sendBinaryMessage(args).catch((e) => console.error('[Nym WASM client] Failed to send message', e));
},
};
// start comlink listening for messages and handle them above
Comlink.expose(webWorker);
// notify any listeners that the web worker has loaded - HOWEVER, the client has not been created and connected,
// listen for EventKinds.Connected before sending messages
postMessageWithType<LoadedEvent>({ kind: EventKinds.Loaded, args: { loaded: true } });
})
.catch((e) => {
console.error('[Worker thread] failed to start', e);
});
+42
View File
@@ -0,0 +1,42 @@
{
"compileOnSave": false,
"compilerOptions": {
"lib": [
"es2021",
"dom",
"dom.iterable",
"esnext",
"webworker"
],
"outDir": "./dist/",
"module": "ES2020",
"target": "es2021",
"allowJs": false,
"strict": true,
"moduleResolution": "node",
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"declaration": true,
"baseUrl": "."
},
"include": [
"src/**/*.ts"
],
"exclude": [
"jest.config.js",
"webpack.config.js",
"webpack.prod.js",
"webpack.common.js",
"node_modules",
"**/node_modules",
"dist",
"**/dist",
"scripts",
"jest",
"__tests__",
"**/__tests__",
"__jest__",
"**/__jest__",
"config/*",
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../ts-packages/tsconfig.json"
}
+10
View File
@@ -7924,6 +7924,11 @@ combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
dependencies:
delayed-stream "~1.0.0"
comlink@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/comlink/-/comlink-4.3.1.tgz#0c6b9d69bcd293715c907c33fe8fc45aecad13c5"
integrity sha512-+YbhUdNrpBZggBAHWcgQMLPLH1KDF3wJpeqrCKieWQ8RL7atmgsgTQko1XEBK6PsecfopWNntopJ+ByYG1lRaA==
comma-separated-tokens@^1.0.0:
version "1.0.8"
resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea"
@@ -18669,6 +18674,11 @@ typescript@^4.6.2:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4"
integrity sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==
typescript@^4.8.4:
version "4.8.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6"
integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==
uglify-js@^3.1.4:
version "3.15.2"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.15.2.tgz#1ed2c976f448063b1f87adb68c741be79959f951"