This commit is contained in:
Kieran 2023-04-28 11:52:38 +01:00
parent 9244831423
commit 4dcf10b04b
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
15 changed files with 507 additions and 70 deletions

View File

@ -10,6 +10,10 @@
"@types/node": "^16.7.13",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"dexie": "^3.2.3",
"moment": "^2.29.4",
"nostr-relaypool": "^0.6.27",
"parse-diff": "^0.11.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",

View File

@ -1,38 +1,26 @@
.App {
text-align: center;
.app {
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
section.side {
width: 200px;
position: fixed;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
section.side>div {
padding: 10px;
margin: 5px;
border-radius: 3px;
border: 1px solid;
cursor: pointer;
user-select: none;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.patch-list {
display: flex;
flex-direction: column;
margin-left: 200px;
}

View File

@ -1,9 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -1,26 +1,56 @@
import React from 'react';
import logo from './logo.svg';
import { useMemo, useState, useSyncExternalStore } from 'react';
import './App.css';
import { RelayPool } from "nostr-relaypool";
import { PatchCache } from './Cache/PatchCache';
import { PatchstrDb } from './Db';
import { PatchRow } from './PatchRow';
import { parseDiffEvent } from './Diff';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
const relays = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social"
];
const Store = new PatchCache("Patches", PatchstrDb.events);
const Nostr = new RelayPool(relays);
const sub = Nostr.subscribe([
{
kinds: [19691228],
limit: 200
}
], relays,
async (e) => {
console.debug(e);
await Store.set(parseDiffEvent(e));
}
);
function usePatchStore() {
return useSyncExternalStore(a => Store.hook(a, "*"), () => Store.snapshot());
}
export default App;
export function App() {
const store = usePatchStore();
const tags = [...new Set(store.map(a => a.tag))];
const [tag, setTag] = useState<string>();
const patches = useMemo(() => {
return store.filter(a => tag === undefined || a.tag === tag);
}, [tag]);
return (
<div className="app">
<section className="side">
<div onClick={() => setTag(undefined)}>
All
</div>
{tags.map(a => <div key={a} onClick={() => setTag(a)}>
{a}
</div>)}
</section>
<section className="patch-list">
{patches.map(a => <PatchRow ev={a} key={a.id} />)}
</section>
</div>
);
}

197
src/Cache/BaseFeedCache.ts Normal file
View File

@ -0,0 +1,197 @@
import { Table } from "dexie";
import { unixNowMs, unwrap } from "../Util";
type HookFn = () => void;
interface HookFilter {
key: string;
fn: HookFn;
}
const db = {
ready: true
}
export default abstract class BaseFeedCache<TCached> {
#name: string;
#table: Table<TCached>;
#hooks: Array<HookFilter> = [];
#snapshot: Readonly<Array<TCached>> = [];
#changed = true;
protected onTable: Set<string> = new Set();
protected cache: Map<string, TCached> = new Map();
constructor(name: string, table: Table<TCached>) {
this.#name = name;
this.#table = table;
setInterval(() => {
console.debug(
`[${this.#name}] ${this.cache.size} loaded, ${this.onTable.size} on-disk, ${this.#hooks.length} hooks`
);
}, 5_000);
}
async preload() {
if (db.ready) {
const keys = await this.#table.toCollection().primaryKeys();
this.onTable = new Set<string>(keys.map(a => a as string));
}
}
hook(fn: HookFn, key: string | undefined) {
if (!key) {
return () => {
//noop
};
}
this.#hooks.push({
key,
fn,
});
return () => {
const idx = this.#hooks.findIndex(a => a.fn === fn);
if (idx >= 0) {
this.#hooks.splice(idx, 1);
}
};
}
getFromCache(key?: string) {
if (key) {
return this.cache.get(key);
}
}
async get(key?: string) {
if (key && !this.cache.has(key) && db.ready) {
const cached = await this.#table.get(key);
if (cached) {
this.cache.set(this.key(cached), cached);
this.notifyChange([key]);
return cached;
}
}
return key ? this.cache.get(key) : undefined;
}
async bulkGet(keys: Array<string>) {
const missing = keys.filter(a => !this.cache.has(a));
if (missing.length > 0 && db.ready) {
const cached = await this.#table.bulkGet(missing);
cached.forEach(a => {
if (a) {
this.cache.set(this.key(a), a);
}
});
}
return keys
.map(a => this.cache.get(a))
.filter(a => a)
.map(a => unwrap(a));
}
async set(obj: TCached) {
const k = this.key(obj);
this.cache.set(k, obj);
if (db.ready) {
await this.#table.put(obj);
this.onTable.add(k);
}
this.notifyChange([k]);
}
async bulkSet(obj: Array<TCached>) {
if (db.ready) {
await this.#table.bulkPut(obj);
obj.forEach(a => this.onTable.add(this.key(a)));
}
obj.forEach(v => this.cache.set(this.key(v), v));
this.notifyChange(obj.map(a => this.key(a)));
}
/**
* Try to update an entry where created values exists
* @param m Profile metadata
* @returns
*/
async update<TCachedWithCreated extends TCached & { created: number, loaded: number }>(m: TCachedWithCreated) {
const k = this.key(m);
const existing = this.getFromCache(k) as TCachedWithCreated;
const updateType = (() => {
if (!existing) {
return "new";
}
if (existing.created < m.created) {
return "updated";
}
if (existing && existing.loaded < m.loaded) {
return "refresh";
}
return "no_change";
})();
console.debug(`Updating ${k} ${updateType}`, m);
if (updateType !== "no_change") {
const updated = {
...existing,
...m,
};
await this.set(updated);
}
return updateType;
}
/**
* Loads a list of rows from disk cache
* @param keys List of ids to load
* @returns Keys that do not exist on disk cache
*/
async buffer(keys: Array<string>): Promise<Array<string>> {
const needsBuffer = keys.filter(a => !this.cache.has(a));
if (db.ready && needsBuffer.length > 0) {
const mapped = needsBuffer.map(a => ({
has: this.onTable.has(a),
key: a,
}));
const start = unixNowMs();
const fromCache = await this.#table.bulkGet(mapped.filter(a => a.has).map(a => a.key));
const fromCacheFiltered = fromCache.filter(a => a !== undefined).map(a => unwrap(a));
fromCacheFiltered.forEach(a => {
this.cache.set(this.key(a), a);
});
this.notifyChange(fromCacheFiltered.map(a => this.key(a)));
console.debug(
`[${this.#name}] Loaded ${fromCacheFiltered.length}/${keys.length} in ${(
unixNowMs() - start
).toLocaleString()} ms`
);
return mapped.filter(a => !a.has).map(a => a.key);
}
// no IndexdDB always return all keys
return needsBuffer;
}
async clear() {
await this.#table.clear();
this.cache.clear();
this.onTable.clear();
}
snapshot() {
if (this.#changed) {
this.#snapshot = this.takeSnapshot();
this.#changed = false;
}
return this.#snapshot;
}
protected notifyChange(keys: Array<string>) {
this.#changed = true;
this.#hooks.filter(a => keys.includes(a.key) || a.key === "*").forEach(h => h.fn());
}
abstract key(of: TCached): string;
abstract takeSnapshot(): Array<TCached>;
}

13
src/Cache/PatchCache.ts Normal file
View File

@ -0,0 +1,13 @@
import BaseFeedCache from "./BaseFeedCache";
import { ParsedPatch } from "../Diff";
export class PatchCache extends BaseFeedCache<ParsedPatch> {
key(of: ParsedPatch): string {
return of.id;
}
takeSnapshot(): ParsedPatch[] {
return [...this.cache.values()];
}
}

15
src/Db.ts Normal file
View File

@ -0,0 +1,15 @@
import Dexie, { Table } from "dexie";
import { ParsedPatch } from "./Diff";
export class Db extends Dexie {
events!: Table<ParsedPatch>;
constructor() {
super("patchstr");
this.version(1).stores({
events: "++id"
});
}
}
export const PatchstrDb = new Db();

38
src/Diff.ts Normal file
View File

@ -0,0 +1,38 @@
import * as parseDiff from "parse-diff";
import { Event } from 'nostr-tools';
export interface ParsedPatch {
id: string
created: number
pubkey: string
tag: string
author: AuthorName
subject: string
diff: parseDiff.File[]
}
export interface AuthorName {
name: string
email: string
}
export function parseDiffEvent(ev: Event) {
const tag = ev.tags.find(a => a[0] === "t")?.[1] ?? "";
const author = ev.tags.find(a => a[0] === "author")?.[1] ?? "";
const subject = ev.tags.find(a => a[0] === "subject")?.[1] ?? "";
const EmailRegex = /^([\w ]+)(<?\S+>)?$/i;
const matches = author.match(EmailRegex);
return {
id: ev.id,
created: ev.created_at,
pubkey: ev.pubkey,
tag,
author: {
name: matches?.[1],
email: matches?.[2]
},
subject,
diff: parseDiff.default(ev.content)
} as ParsedPatch;
}

35
src/PatchRow.css Normal file
View File

@ -0,0 +1,35 @@
.patch-header {
display: grid;
grid-auto-columns: 150px auto 100px 50px;
grid-auto-flow: column;
width: fill;
white-space: nowrap;
line-height: 2em;
margin: 5px;
border-radius: 3px;
border: 1px solid;
cursor: pointer;
}
.patch-header>div {
}
.patch-header>diV:nth-child(1) {
background-color: grey;
}
.patch-header>div:nth-child(2) {
}
.patch-header>div:nth-child(3) {
background-color: grey;
}
.patch-header>div:nth-child(4) {
background-color: grey;
}
.add {
color: lightgreen;
}
.remove {
color: darkred;
}

31
src/PatchRow.tsx Normal file
View File

@ -0,0 +1,31 @@
import "./PatchRow.css"
import moment from "moment";
import { ParsedPatch } from "./Diff";
export function PatchRow({ ev }: { ev: ParsedPatch }) {
const ts = new Date(ev.created * 1000);
return <div className="patch-row">
<div className="patch-header">
<div>
{ev.author.name}
</div>
<div>
{ev.subject}
</div>
<div>
<time dateTime={ts.toISOString()}>
{moment(ts).fromNow()}
</time>
</div>
<div>
<span className="add">
+{ev.diff.reduce((acc, v) => acc + v.additions, 0)}
</span>
&nbsp;
<span className="remove">
-{ev.diff.reduce((acc, v) => acc + v.deletions, 0)}
</span>
</div>
</div>
</div>
}

10
src/Util.ts Normal file
View File

@ -0,0 +1,10 @@
export function unixNowMs() {
return new Date().getTime();
}
export function unwrap<T>(value?: T) {
if (!value) {
throw new Error("Missing value");
}
return value;
}

View File

@ -1,10 +1,13 @@
body {
margin: 0;
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: black;
color: white;
}
code {

View File

@ -1,8 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {App} from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
@ -12,8 +12,3 @@ root.render(
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "es2020",
"lib": [
"dom",
"dom.iterable",

View File

@ -1417,6 +1417,15 @@
graceful-fs "^4.2.9"
source-map "^0.6.0"
"@jest/source-map@^29.4.3":
version "29.4.3"
resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.4.3.tgz#ff8d05cbfff875d4a791ab679b4333df47951d20"
integrity sha512-qyt/mb6rLyd9j1jUts4EQncvS6Yy3PM9HghnNv86QBlV+zdL2inCdK1tuVlL+J+lpiw2BI67qXOrX3UurBqQ1w==
dependencies:
"@jridgewell/trace-mapping" "^0.3.15"
callsites "^3.0.0"
graceful-fs "^4.2.9"
"@jest/test-result@^27.5.1":
version "27.5.1"
resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-27.5.1.tgz#56a6585fa80f7cdab72b8c5fc2e871d03832f5bb"
@ -1540,7 +1549,7 @@
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
"@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9":
"@jridgewell/trace-mapping@^0.3.15", "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9":
version "0.3.18"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz#25783b2086daf6ff1dcb53c9249ae480e4dd4cd6"
integrity sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==
@ -1560,6 +1569,16 @@
dependencies:
eslint-scope "5.1.1"
"@noble/hashes@1.2.0", "@noble/hashes@~1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.2.0.tgz#a3150eeb09cc7ab207ebf6d7b9ad311a9bdbed12"
integrity sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ==
"@noble/secp256k1@1.7.1", "@noble/secp256k1@~1.7.0":
version "1.7.1"
resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.7.1.tgz#b251c70f824ce3ca7f8dc3df08d58f005cc0507c"
integrity sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
@ -1638,6 +1657,28 @@
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz#8be36a1f66f3265389e90b5f9c9962146758f728"
integrity sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==
"@scure/base@1.1.1", "@scure/base@~1.1.0":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938"
integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==
"@scure/bip32@1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.1.4.tgz#2c91a7be0156b15f26dd0c843a06a1917f129efd"
integrity sha512-m925ACYK0wPELsF7Z/VdLGmKj1StIeHraPMYB9xiAFiq/PnvqWd/99I0TQ2OZhjjlMDsDJeZlyXMWi0beaA7NA==
dependencies:
"@noble/hashes" "~1.2.0"
"@noble/secp256k1" "~1.7.0"
"@scure/base" "~1.1.0"
"@scure/bip39@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.1.1.tgz#b54557b2e86214319405db819c4b6a370cf340c5"
integrity sha512-t+wDck2rVkh65Hmv280fYdVdY25J9YeEUIgn2LG1WM6gxFkGzcksoDiUkWVpVp3Oex9xGC68JU2dSbUfwZ2jPg==
dependencies:
"@noble/hashes" "~1.2.0"
"@scure/base" "~1.1.0"
"@sinclair/typebox@^0.24.1":
version "0.24.51"
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f"
@ -3661,6 +3702,11 @@ detect-port-alt@^1.1.6:
address "^1.0.1"
debug "^2.6.0"
dexie@^3.2.3:
version "3.2.3"
resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.2.3.tgz#f35c91ca797599df8e771b998e9ae9669c877f8c"
integrity sha512-iHayBd4UYryDCVUNa3PMsJMEnd8yjyh5p7a+RFeC8i8n476BC9wMhVvqiImq5zJZJf5Tuer+s4SSj+AA3x+ZbQ==
didyoumean@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
@ -5316,6 +5362,11 @@ isexe@^2.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
isomorphic-ws@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz#e5529148912ecb9b451b46ed44d53dae1ce04bbf"
integrity sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==
istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3"
@ -6340,6 +6391,11 @@ mkdirp@~0.5.1:
dependencies:
minimist "^1.2.6"
moment@^2.29.4:
version "2.29.4"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@ -6435,6 +6491,27 @@ normalize-url@^6.0.1:
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a"
integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==
nostr-relaypool@^0.6.27:
version "0.6.27"
resolved "https://registry.yarnpkg.com/nostr-relaypool/-/nostr-relaypool-0.6.27.tgz#d2a3f046043964ad3b2b82db2b3467a64a9da863"
integrity sha512-YtQxb8z9VHsPEQfC4rkxztqyGvWM1kcwiLhp/N8PpZX1+9mJhoIFctgpGxWB1LXhZgRiyJfY5Ml4EklvtWELuw==
dependencies:
"@jest/source-map" "^29.4.3"
isomorphic-ws "^5.0.0"
nostr-tools "^1.10.0"
safe-stable-stringify "^2.4.2"
nostr-tools@^1.10.0:
version "1.10.1"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.10.1.tgz#b52043b3031f4314478d0a3bfaa8ffb9cc4f98a0"
integrity sha512-zgTYJeuZQ3CDASsmBEcB5i6V6l0IaA6cjnll6OVik3FoZcvbCaL7yP8I40hYnOIi3KlJykV7jEF9fn8h1NzMnA==
dependencies:
"@noble/hashes" "1.2.0"
"@noble/secp256k1" "1.7.1"
"@scure/base" "1.1.1"
"@scure/bip32" "1.1.4"
"@scure/bip39" "1.1.1"
npm-run-path@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
@ -6672,6 +6749,11 @@ parent-module@^1.0.0:
dependencies:
callsites "^3.0.0"
parse-diff@^0.11.1:
version "0.11.1"
resolved "https://registry.yarnpkg.com/parse-diff/-/parse-diff-0.11.1.tgz#d93ca2d225abed280782bccb1476711ca9dd84f0"
integrity sha512-Oq4j8LAOPOcssanQkIjxosjATBIEJhCxMCxPhMu+Ci4wdNmAEdx0O+a7gzbR2PyKXgKPvRLIN5g224+dJAsKHA==
parse-json@^5.0.0, parse-json@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd"
@ -7877,6 +7959,11 @@ safe-regex-test@^1.0.0:
get-intrinsic "^1.1.3"
is-regex "^1.1.4"
safe-stable-stringify@^2.4.2:
version "2.4.3"
resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz#138c84b6f6edb3db5f8ef3ef7115b8f55ccbf886"
integrity sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==
"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0":
version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"