feat!: add basic @waku/react hooks (#1)

* add react and typecript
* add react, rollup and ts
* add waku dependencies
* add create hooks, providers and update typings path
* extend create hooks and provider with error/loading state
* rename to isLoading
* create useContentPair hook
* add protocols property and bootstrap with remote peers
* add useFilterSubscribe
* add eslint & fix issues
* add prettier & fix
* add jest
* add husky
* add bundlewatch
This commit is contained in:
Sasha 2023-02-16 21:49:46 +01:00 committed by GitHub
parent 84b46a76a8
commit 4b219df40c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 13043 additions and 21 deletions

4
.eslintignore Normal file
View File

@ -0,0 +1,4 @@
/node_modules
/dist
/coverage
!.*.js

68
.eslintrc Normal file
View File

@ -0,0 +1,68 @@
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "react-hooks", "simple-import-sort"],
"extends": [
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"rules": {
"curly": "error",
"no-extra-boolean-cast": "error",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/ban-ts-comment": "warn",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-object-literal-type-assertion": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{ "ignoreRestSiblings": true }
],
"cypress/no-unnecessary-waiting": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"react/display-name": "warn",
"react/prop-types": "off",
"no-console": ["error"],
"simple-import-sort/imports": [
"error",
{
"groups": [
// Side effect imports.
["^\\u0000"],
// Packages. `react` related packages come first.
["^react", "^@?\\w"],
// Parent imports. Put `..` last.
["^\\.\\.(?!/?$)", "^\\.\\./?$"],
// Other relative imports. Put same-folder imports and `.` last.
["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"]
]
}
],
"simple-import-sort/exports": "error"
},
"overrides": [
{
"files": ["*.test.ts", "*.test.tsx"],
"rules": {
// Allow testing runtime errors to suppress TS errors
"@typescript-eslint/ban-ts-comment": "off"
}
}
],
"settings": {
"react": {
"pragma": "React",
"version": "detect"
}
}
}

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
.idea/*
.angular
build
bundle
dist
node_modules
src/**.js
coverage
*.log
*.tsbuildinfo
docs
.DS_Store

4
.husky/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run fix

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"trailingComma": "all"
}

33
jest.config.js Normal file
View File

@ -0,0 +1,33 @@
export default {
collectCoverageFrom: [
"**/**/*.{ts,tsx}",
"!**/**/*.test.{ts,tsx}",
"!**/src/types/**",
"!**/node_modules/**",
"!**/dist/**",
"!**/__tests__/**",
],
projects: [
{
clearMocks: true,
resetMocks: true,
restoreMocks: true,
rootDir: ".",
roots: ["<rootDir>/src"],
transformIgnorePatterns: ["[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$"],
displayName: {
name: "@waku/react",
color: "cyan",
},
testMatch: ["**/__tests__/**/*.(spec|test).ts?(x)"],
transform: {
"^.+\\.tsx?$": "@swc/jest",
},
testEnvironment: "jsdom",
},
],
watchPlugins: [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname",
],
};

12373
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,15 +2,27 @@
"name": "@waku/react",
"version": "0.0.1",
"description": "React hooks and components to use js-waku",
"types": "./dist/index.d.ts",
"module": "./dist/index.js",
"type": "module",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.mjs",
"umd:main": "dist/index.umd.js",
"unpkg": "dist/index.umd.js",
"source": "src/index.ts",
"types": "dist/src/index.d.ts",
"sideEffects": false,
"files": [
"dist",
"CHANGELOG.md",
"LICENSE",
"README.md"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
"types": "./dist/src/index.d.ts",
"import": "./dist/index.esm.mjs",
"require": "./dist/index.cjs.js"
}
},
"type": "module",
"homepage": "https://github.com/waku-org/waku-ui",
"repository": {
"type": "git",
@ -30,24 +42,73 @@
"react"
],
"scripts": {
"build": "echo 0;",
"fix": "echo 0;",
"lint": "echo 0;",
"test": "echo 0;",
"prepublish": "npm run build"
"prebuild": "rimraf dist",
"build": "rollup -c ./rollup.config.js",
"fix": "npm run lint:fix && npm run prettier:fix",
"lint": "eslint '**/*.{js,ts,tsx}'",
"lint:fix": "npm run lint -- --fix",
"prettier:fix": "prettier --config .prettierrc --write \"**/*.{js,ts,tsx}\"",
"type": "tsc --noEmit",
"test": "jest --config ./jest.config.js",
"test:coverage": "npm run test -- --coverage",
"bundlewatch": "npm run build && bundlewatch",
"prepublish": "npm run fix && npm run type && npm run test && npm run build"
},
"engines": {
"node": ">=18"
},
"dependencies": {},
"devDependencies": {},
"files": [
"dist",
"src/*.ts",
"!**/*.spec.*",
"!**/*.json",
"CHANGELOG.md",
"LICENSE",
"README.md"
]
"dependencies": {
"@waku/core": "^0.0.10",
"@waku/create": "^0.0.6",
"react": "^18.2.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.1",
"@swc/jest": "^0.2.24",
"@testing-library/jest-dom": "^5.16.5",
"@types/jest": "^29.4.0",
"@types/react": "^18.0.28",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@waku/interfaces": "^0.0.7",
"bundlewatch": "^0.3.3",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3",
"jest": "^29.4.3",
"jest-environment-jsdom": "^29.4.3",
"jest-watch-typeahead": "^2.2.2",
"prettier": "^2.8.4",
"rimraf": "^4.1.2",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-sourcemaps": "^0.6.3",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.34.1",
"typescript": "^4.9.5"
},
"bundlewatch": {
"files": [
{
"path": "./dist/index.cjs.js",
"maxSize": "4 kB"
}
]
},
"lint-staged": {
"*.{js,ts,tsx}": [
"npm run fix"
],
"*.{md,json,yml}": [
"prettier --write"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
}
}

66
rollup.config.js Normal file
View File

@ -0,0 +1,66 @@
import commonjs from "@rollup/plugin-commonjs";
import external from "rollup-plugin-peer-deps-external";
import sourcemaps from "rollup-plugin-sourcemaps";
import { terser } from "rollup-plugin-terser";
import typescript from "rollup-plugin-typescript2";
function createRollupConfig(options) {
const name = options.name;
// A file with the extension ".mjs" will always be treated as ESM, even when pkg.type is "commonjs" (the default)
// https://nodejs.org/docs/latest/api/packages.html#packages_determining_module_system
const extName = options.format === "esm" ? "mjs" : "js";
const outputName = "dist/" + [name, options.format, extName].join(".");
const config = {
input: "src/index.ts",
output: {
file: outputName,
format: options.format,
name: "@waku/react",
sourcemap: true,
globals: { react: "React" },
exports: "named",
},
plugins: [
external(),
typescript({
tsconfig: options.tsconfig,
clean: true,
exclude: ["**/__tests__", "**/*.test.ts"],
}),
options.format === "umd" &&
commonjs({
include: /\/node_modules\//,
}),
sourcemaps(),
options.format !== "esm" &&
terser({
output: { comments: false },
compress: {
drop_console: true,
},
}),
].filter(Boolean),
};
return config;
}
const name = "index";
const source = "src/index.ts";
const options = [
{
name,
format: "cjs",
input: source,
},
{ name, format: "esm", input: source },
{
name,
format: "umd",
input: source,
},
];
export default options.map((option) => createRollupConfig(option));

134
src/WakuProvider.tsx Normal file
View File

@ -0,0 +1,134 @@
import React from "react";
import type { Waku } from "@waku/interfaces";
import type {
BootstrapNodeOptions,
CrateWakuHook,
FullNodeOptions,
LightNodeOptions,
RelayNodeOptions,
} from "./types";
import {
useCreateFullNode,
useCreateLightNode,
useCreateRelayNode,
} from "./useCreateWaku";
type WakuContextType<T extends Waku> = CrateWakuHook<T>;
export const WakuContext = React.createContext<WakuContextType<Waku>>({
node: null,
isLoading: false,
error: null,
});
/**
* Hook to retrieve Waku node from Context. By default generic Waku type will be used.
* @example
* const { node, isLoading, error } = useWaku<LightNode>();
* @example
* const { node, isLoading, error } = useWaku<RelayNode>();
* @example
* const { node, isLoading, error } = useWaku<FullNode>();
* @example
* const { node, isLoading, error } = useWaku();
* @returns WakuContext
*/
export const useWaku = <T extends Waku>(): WakuContextType<T> =>
React.useContext(WakuContext) as WakuContextType<T>;
type ReactChildrenProps = {
children?: React.ReactNode;
};
type ProviderProps<T> = ReactChildrenProps & BootstrapNodeOptions<T>;
/**
* Provider for creating Light Node based on options passed.
* @example
* const App = (props) => (
* <LightNodeProvider options={{...}}>
* <Component />
* </LightNodeProvider>
* );
* const Component = (props) => {
* const { node, isLoading, error } = useWaku<LightNode>();
* ...
* };
* @param {Object} props - options to create a node and other React props
* @param {LightNodeOptions} props.options - optional options for creating Light Node
* @param {Protocols} props.protocols - optional protocols list to initiate node with
* @returns React Light Node provider component
*/
export const LightNodeProvider: React.FunctionComponent<
ProviderProps<LightNodeOptions>
> = (props) => {
const result = useCreateLightNode({
options: props.options,
protocols: props.protocols,
});
return (
<WakuContext.Provider value={result}>{props.children}</WakuContext.Provider>
);
};
/**
* Provider for creating Relay Node based on options passed.
* @example
* const App = (props) => (
* <RelayNodeProvider options={{...}}>
* <Component />
* </RelayNodeProvider>
* );
* const Component = (props) => {
* const { node, isLoading, error } = useWaku<RelayNode>();
* ...
* };
* @param {Object} props - options to create a node and other React props
* @param {RelayNodeOptions} props.options - optional options for creating Relay Node
* @param {Protocols} props.protocols - optional protocols list to initiate node with
* @returns React Relay Node provider component
*/
export const RelayNodeProvider: React.FunctionComponent<
ProviderProps<RelayNodeOptions>
> = (props) => {
const result = useCreateRelayNode({
options: props.options,
protocols: props.protocols,
});
return (
<WakuContext.Provider value={result}>{props.children}</WakuContext.Provider>
);
};
/**
* Provider for creating Full Node based on options passed.
* @example
* const App = (props) => (
* <FullNodeProvider options={{...}}>
* <Component />
* </FullNodeProvider>
* );
* const Component = (props) => {
* const { node, isLoading, error } = useWaku<FullNode>();
* ...
* };
* @param {Object} props - options to create a node and other React props
* @param {FullNodeOptions} props.options - optional options for creating Full Node
* @param {Protocols} props.protocols - optional protocols list to initiate node with
* @returns React Full Node provider component
*/
export const FullNodeProvider: React.FunctionComponent<
ProviderProps<FullNodeOptions>
> = (props) => {
const result = useCreateFullNode({
options: props.options,
protocols: props.protocols,
});
return (
<WakuContext.Provider value={result}>{props.children}</WakuContext.Provider>
);
};

15
src/index.ts Normal file
View File

@ -0,0 +1,15 @@
export { FullNodeOptions, LightNodeOptions, RelayNodeOptions } from "./types";
export { useContentPair } from "./useContentPair";
export {
useCreateFullNode,
useCreateLightNode,
useCreateRelayNode,
} from "./useCreateWaku";
export { useFilterSubscribe } from "./useFilterSubscribe";
export {
FullNodeProvider,
LightNodeProvider,
RelayNodeProvider,
useWaku,
WakuContext,
} from "./WakuProvider";

25
src/types.ts Normal file
View File

@ -0,0 +1,25 @@
import { RelayCreateOptions, WakuOptions } from "@waku/core";
import type { CreateOptions } from "@waku/create";
import type { Protocols, Waku } from "@waku/interfaces";
export type HookState = {
isLoading: boolean;
error: null | string;
};
export type CrateWakuHook<T extends Waku> = HookState & {
node: null | T;
};
export type BootstrapNodeOptions<T = {}> = {
options?: T;
protocols?: Protocols[];
};
export type LightNodeOptions = CreateOptions & WakuOptions;
export type RelayNodeOptions = CreateOptions &
WakuOptions &
Partial<RelayCreateOptions>;
export type FullNodeOptions = CreateOptions &
WakuOptions &
Partial<RelayCreateOptions>;

32
src/useContentPair.ts Normal file
View File

@ -0,0 +1,32 @@
import { useEffect, useState } from "react";
import { createDecoder, createEncoder } from "@waku/core";
import type { Decoder, Encoder } from "@waku/core/dist/lib/message/version_0";
type ContentPair = {
encoder: null | Encoder;
decoder: null | Decoder;
};
/**
* Creates Encoder / Decoder pair for a given contentTopic.
* @param {string} contentTopic - topic to orient to
* @param {boolean} ephemeral - optional, makes messages ephemeral
* @returns {Object} Encoder / Decoder pair
*/
export const useContentPair = (
contentTopic: string,
ephemeral?: boolean,
): ContentPair => {
const [encoder, setEncoder] = useState<null | Encoder>(null);
const [decoder, setDecoder] = useState<null | Decoder>(null);
useEffect(() => {
setEncoder(createEncoder(contentTopic, ephemeral));
setDecoder(createDecoder(contentTopic));
}, [contentTopic, ephemeral]);
return {
encoder,
decoder,
};
};

102
src/useCreateWaku.ts Normal file
View File

@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import { waitForRemotePeer } from "@waku/core";
import { createFullNode, createLightNode, createRelayNode } from "@waku/create";
import type { FullNode, LightNode, RelayNode, Waku } from "@waku/interfaces";
import type {
BootstrapNodeOptions,
CrateWakuHook,
FullNodeOptions,
LightNodeOptions,
RelayNodeOptions,
} from "./types";
type NodeFactory<N, T = {}> = (options?: T) => Promise<N>;
type CreateNodeParams<N extends Waku, T = {}> = BootstrapNodeOptions<T> & {
factory: NodeFactory<N, T>;
};
const useCreateNode = <N extends Waku, T = {}>(
params: CreateNodeParams<N, T>,
): CrateWakuHook<N> => {
const { factory, options, protocols = [] } = params;
const [node, setNode] = useState<N | null>(null);
const [isLoading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<null | string>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
factory(options)
.then(async (node) => {
if (cancelled) {
return;
}
await node.start();
await waitForRemotePeer(node, protocols);
setNode(node);
setLoading(false);
})
.catch((err) => {
setLoading(false);
setError(`Failed at creating node: ${err?.message || "no message"}`);
});
return () => {
cancelled = true;
};
}, [factory, options, protocols, setNode, setLoading, setError]);
return {
node,
error,
isLoading,
};
};
/**
* Create Light Node helper hook.
* @param {Object} params - optional params to configure & bootstrap node
* @returns {CrateWakuHook} node, loading state and error
*/
export const useCreateLightNode = (
params?: BootstrapNodeOptions<LightNodeOptions>,
) => {
return useCreateNode<LightNode, LightNodeOptions>({
...params,
factory: createLightNode,
});
};
/**
* Create Relay Node helper hook.
* @param {Object} params - optional params to configure & bootstrap node
* @returns {CrateWakuHook} node, loading state and error
*/
export const useCreateRelayNode = (
params?: BootstrapNodeOptions<RelayNodeOptions>,
) => {
return useCreateNode<RelayNode, RelayNodeOptions>({
...params,
factory: createRelayNode,
});
};
/**
* Create Full Node helper hook.
* @param {Object} params - optional params to configure & bootstrap node
* @returns {CrateWakuHook} node, loading state and error
*/
export const useCreateFullNode = (
params?: BootstrapNodeOptions<FullNodeOptions>,
) => {
return useCreateNode<FullNode, FullNodeOptions>({
...params,
factory: createFullNode,
});
};

58
src/useFilterSubscribe.ts Normal file
View File

@ -0,0 +1,58 @@
import { useCallback, useEffect, useState } from "react";
import type { IDecodedMessage, IDecoder, Waku } from "@waku/interfaces";
import type { HookState } from "./types";
type UseFilterSubscribeParams = {
waku: Waku;
decoder: IDecoder<IDecodedMessage>;
};
type UseFilterSubscribeResult = HookState & {
messages: IDecodedMessage[];
};
export const useFilterSubscribe = (
params: UseFilterSubscribeParams,
): UseFilterSubscribeResult => {
const { waku, decoder } = params;
const [error, setError] = useState<null | string>(null);
const [isLoading, setLoading] = useState<boolean>(false);
const [messages, setMessage] = useState<IDecodedMessage[]>([]);
const pushMessage = useCallback(
(message: IDecodedMessage): void => {
setMessage((prev) => [...prev, message]);
},
[setMessage],
);
useEffect(() => {
let unsubscribe: null | (() => Promise<void>) = null;
setLoading(true);
waku?.filter
?.subscribe([decoder], pushMessage)
.then((unsubscribeFn) => {
setLoading(false);
unsubscribe = unsubscribeFn;
})
.catch((err) => {
setLoading(false);
setError(
`Failed to subscribe to filer: ${err?.message || "no message"}`,
);
});
return () => {
unsubscribe?.();
};
}, [waku, decoder, pushMessage, setError, setLoading]);
return {
error,
messages,
isLoading,
};
};

29
tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"sourceMap": true,
"module": "es2015",
"target": "es2018",
"moduleResolution": "node",
"outDir": "./dist",
"jsx": "react",
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"noEmit": true,
"esModuleInterop": true,
"lib": ["dom", "dom.iterable", "esnext"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"exclude": [
"node_modules",
"examples",
"src/*.test.ts",
"src/*.test.tsx",
"src/__mocks__"
]
}