feat: literal actual chat
This commit is contained in:
parent
2000f99fb3
commit
ad915391b7
|
@ -56,7 +56,8 @@
|
|||
"scwp": "1.x",
|
||||
"sequelize": "^6.2.4",
|
||||
"socket.io-redis": "^5.3.0",
|
||||
"source-map-support": "^0.5.11"
|
||||
"source-map-support": "^0.5.11",
|
||||
"uuid": "8.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@neutrinojs/airbnb": "^9.1.0",
|
||||
|
|
|
@ -6,7 +6,7 @@ import React from 'react';
|
|||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
export default function PlayersChatMessageSpace(props) {
|
||||
const {message: {owner, text, timestamp}, isShort} = props;
|
||||
const {message: {owner, message, timestamp}, isShort} = props;
|
||||
const ownerMap = {
|
||||
1: 'cha0s',
|
||||
2: 'BabeHasHiccups',
|
||||
|
@ -38,7 +38,7 @@ export default function PlayersChatMessageSpace(props) {
|
|||
}
|
||||
<div className="chat--messageText">
|
||||
<div className="chat--messageMarkdown">
|
||||
<ReactMarkdown source={text} />
|
||||
<ReactMarkdown source={message} />
|
||||
</div>
|
||||
{isShort && $messageTime}
|
||||
</div>
|
||||
|
@ -49,7 +49,7 @@ export default function PlayersChatMessageSpace(props) {
|
|||
PlayersChatMessageSpace.propTypes = {
|
||||
isShort: PropTypes.bool.isRequired,
|
||||
message: PropTypes.shape({
|
||||
text: PropTypes.string,
|
||||
message: PropTypes.string,
|
||||
timestamp: PropTypes.number,
|
||||
owner: PropTypes.string,
|
||||
}).isRequired,
|
||||
|
|
|
@ -63,12 +63,6 @@ header {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.chat--messageMarkdown {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.chat--messageMarkdown {
|
||||
display: inline;
|
||||
}
|
||||
|
|
|
@ -1,38 +1,18 @@
|
|||
import './chat--messages.scss';
|
||||
|
||||
import React, {useLayoutEffect, useRef, useState} from 'react';
|
||||
import React, {useLayoutEffect, useRef} from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {channelMessagesSelector} from '~/common/state/chat';
|
||||
|
||||
import useChannel from '~/client/hooks/useChannel';
|
||||
|
||||
import ChatMessage from './chat--message';
|
||||
import ChatSubmitMessage from './chat--submitMessage';
|
||||
|
||||
export default function PlayersChatSpace() {
|
||||
const [$form, $messages] = [useRef(null), useRef(null)];
|
||||
const [text, setText] = useState('');
|
||||
const messages = [
|
||||
{
|
||||
key: 1,
|
||||
owner: 1,
|
||||
text: 'Hi!',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
owner: 2,
|
||||
text: 'Yo.',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
owner: 2,
|
||||
text: 'How have you been?',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
key: 4,
|
||||
owner: 1,
|
||||
text: 'Not too bad.',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
export default function ChatMessages() {
|
||||
const channel = useChannel();
|
||||
const $messages = useRef(null);
|
||||
const {current} = $messages;
|
||||
const isAtTheBottom = !current
|
||||
? true
|
||||
|
@ -44,6 +24,7 @@ export default function PlayersChatSpace() {
|
|||
), current.offsetHeight)
|
||||
);
|
||||
const heightWatch = current && current.scrollHeight;
|
||||
const messages = useSelector((state) => channelMessagesSelector(state, channel));
|
||||
const messageCount = messages && messages.length;
|
||||
useLayoutEffect(() => {
|
||||
if (isAtTheBottom) {
|
||||
|
@ -61,8 +42,8 @@ export default function PlayersChatSpace() {
|
|||
{messages && messages.map((message) => {
|
||||
const $message = (
|
||||
<ChatMessage
|
||||
key={message.key}
|
||||
isShort={messageOwner === message.owner}
|
||||
key={message.uuid}
|
||||
isShort={0 === message.owner || messageOwner === message.owner}
|
||||
message={message}
|
||||
/>
|
||||
);
|
||||
|
@ -70,34 +51,7 @@ export default function PlayersChatSpace() {
|
|||
return $message;
|
||||
})}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = text.slice(0, 1000).trim();
|
||||
if (trimmed) {
|
||||
// socket.send(new ActionPacket(chat({owner: playerId, text: trimmed}, {game: id})));
|
||||
}
|
||||
setText('');
|
||||
}}
|
||||
ref={$form}
|
||||
>
|
||||
<textarea
|
||||
className="chat--messagesTextarea"
|
||||
name="message"
|
||||
type="textarea"
|
||||
maxLength="1000"
|
||||
onChange={(event) => {
|
||||
setText(event.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if ('Enter' === event.key && !event.shiftKey) {
|
||||
$form.current?.dispatchEvent(new Event('submit', {cancelable: true}));
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
value={text}
|
||||
/>
|
||||
</form>
|
||||
<ChatSubmitMessage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
56
src/client/chat--submitMessage.jsx
Normal file
56
src/client/chat--submitMessage.jsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import './chat--messages.scss';
|
||||
|
||||
import React, {useRef, useState} from 'react';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
|
||||
import {addMessage} from '~/common/state/chat';
|
||||
import {userSelector} from '~/common/state/user';
|
||||
|
||||
import useChannel from '~/client/hooks/useChannel';
|
||||
|
||||
export default function ChatSubmitMessage() {
|
||||
const channel = useChannel();
|
||||
const dispatch = useDispatch();
|
||||
const $form = useRef(null);
|
||||
const user = useSelector(userSelector);
|
||||
const [text, setText] = useState('');
|
||||
return (
|
||||
<div className="chat--messageSubmit">
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const message = text.slice(0, 1000).trim();
|
||||
if (message) {
|
||||
dispatch(addMessage({
|
||||
channel,
|
||||
message,
|
||||
owner: user.id,
|
||||
timestamp: Date.now(),
|
||||
uuid: uuidv4(),
|
||||
}));
|
||||
}
|
||||
setText('');
|
||||
}}
|
||||
ref={$form}
|
||||
>
|
||||
<textarea
|
||||
className="chat--messagesTextarea"
|
||||
name="message"
|
||||
type="textarea"
|
||||
maxLength="1000"
|
||||
onChange={(event) => {
|
||||
setText(event.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if ('Enter' === event.key && !event.shiftKey) {
|
||||
$form.current?.dispatchEvent(new Event('submit', {cancelable: true}));
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
value={text}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
0
src/client/chat--submitMessage.scss
Normal file
0
src/client/chat--submitMessage.scss
Normal file
6
src/client/hooks/useChannel.js
Normal file
6
src/client/hooks/useChannel.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
import {useLocation} from 'react-router-dom';
|
||||
|
||||
export default function useChannel() {
|
||||
const {pathname} = useLocation();
|
||||
return pathname.match(/^\/chat\//) ? pathname.substr('/chat'.length) : '';
|
||||
}
|
|
@ -2,9 +2,8 @@ import {useEffect} from 'react';
|
|||
|
||||
import {SocketClient} from '@avocado/net/client/socket';
|
||||
|
||||
const frontendOrigin = window.location.href;
|
||||
const isSecure = 'https' === frontendOrigin.substr(0, 5);
|
||||
export const socket = new SocketClient(frontendOrigin, {secure: isSecure});
|
||||
const isSecure = 'https:' === window.location.protocol;
|
||||
export const socket = new SocketClient(window.location.host, {secure: isSecure});
|
||||
|
||||
export default function useSocket(fn) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
|
|
@ -39,10 +39,34 @@ q:before, q:after {
|
|||
content: '';
|
||||
content: none;
|
||||
}
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
h6 {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
|
|
22
src/client/store/effects.js
vendored
Normal file
22
src/client/store/effects.js
vendored
Normal file
|
@ -0,0 +1,22 @@
|
|||
import Message from '~/common/packets/message.packet';
|
||||
import {addMessage, confirmMessage} from '~/common/state/chat';
|
||||
|
||||
import {socket} from '~/client/hooks/useSocket';
|
||||
|
||||
const effects = {
|
||||
[addMessage]: ({dispatch}, {payload}) => {
|
||||
socket.send(new Message(payload), ([timestamp, current]) => {
|
||||
dispatch(confirmMessage({current, previous: payload.uuid, timestamp}));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const middleware = (store) => (next) => (action) => {
|
||||
const result = next(action);
|
||||
if (effects[action.type]) {
|
||||
setTimeout(() => effects[action.type](store, action), 0);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export default effects;
|
|
@ -5,6 +5,8 @@ import chat from '~/common/state/chat';
|
|||
import user from '~/common/state/user';
|
||||
import createCommonStore from '~/common/store';
|
||||
|
||||
import {middleware as effectsMiddleware} from './effects';
|
||||
|
||||
const reducer = combineReducers({
|
||||
chat,
|
||||
user,
|
||||
|
@ -15,7 +17,7 @@ export default function createStore(options = {}) {
|
|||
merge(
|
||||
options,
|
||||
{
|
||||
middleware: [],
|
||||
middleware: [effectsMiddleware],
|
||||
reducer,
|
||||
},
|
||||
),
|
|
@ -5,7 +5,10 @@ export default class Message extends Packet {
|
|||
static get schema() {
|
||||
return {
|
||||
...super.schema,
|
||||
data: {},
|
||||
data: {
|
||||
channel: 'string',
|
||||
message: 'string',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,19 @@ import {
|
|||
|
||||
import hydration from './hydration';
|
||||
|
||||
export const userSelector = (state) => state.user;
|
||||
export const channelsSelector = (state) => state.chat.channels;
|
||||
|
||||
export const channelSelector = createSelector(
|
||||
[channelsSelector, (_, channel) => channel],
|
||||
(channels, channel) => channels[channel],
|
||||
);
|
||||
|
||||
export const messagesSelector = (state) => state.chat.messages;
|
||||
|
||||
export const channelMessagesSelector = createSelector(
|
||||
[channelSelector, messagesSelector],
|
||||
(channel, messages) => channel.messages.map((uuid) => messages[uuid]),
|
||||
);
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'chat',
|
||||
|
@ -20,35 +32,48 @@ const slice = createSlice({
|
|||
...(hydration('chat') || {}),
|
||||
},
|
||||
reducers: {
|
||||
addMessage: (state, {payload: {channel, message}}) => {
|
||||
state.messages.push(message);
|
||||
state.channels[channel].messages.push(message.uuid);
|
||||
if (state.focus !== channel) {
|
||||
state.unread[channel] += 1;
|
||||
addMessage: ({
|
||||
channels,
|
||||
focus,
|
||||
messages,
|
||||
unread,
|
||||
}, {payload}) => {
|
||||
const {channel, uuid} = payload;
|
||||
messages[uuid] = payload;
|
||||
channels[channel].messages.push(uuid);
|
||||
if (focus !== channel) {
|
||||
unread[channel] += 1;
|
||||
}
|
||||
},
|
||||
addRecent: (state, {payload: {channel}}) => {
|
||||
state.recent.push(channel);
|
||||
addRecent: ({recent}, {payload: {channel}}) => {
|
||||
recent.push(channel);
|
||||
},
|
||||
editMessage: (state, {payload: {uuid, content}}) => {
|
||||
state.messages[uuid] = content;
|
||||
confirmMessage: ({channels, messages}, {payload: {previous, current, timestamp}}) => {
|
||||
messages[current] = {...messages[previous], timestamp, uuid: current};
|
||||
delete messages[previous];
|
||||
const {[messages[current].channel]: channel} = channels;
|
||||
const index = channel.messages.findIndex((uuid) => uuid === previous);
|
||||
channel.messages[index] = current;
|
||||
},
|
||||
focus: (state, {payload: {channel}}) => {
|
||||
state.unread[channel] = 0;
|
||||
editMessage: ({messages}, {payload: {uuid, message}}) => {
|
||||
messages[uuid].message = message;
|
||||
},
|
||||
join: (state, {payload: {channel, messages, users}}) => {
|
||||
state.channels[channel] = {messages, users};
|
||||
state.unread[channel] = 0;
|
||||
focus: ({unread}, {payload: {channel}}) => {
|
||||
unread[channel] = 0;
|
||||
},
|
||||
joined: (state, {payload: {channel, id}}) => {
|
||||
state.channels[channel].users.push(id);
|
||||
join: ({channels, unread}, {payload: {channel, messages, users}}) => {
|
||||
channels[channel] = {messages, users};
|
||||
unread[channel] = 0;
|
||||
},
|
||||
leave: (state, {payload: {channel}}) => {
|
||||
delete state.channels[channel];
|
||||
delete state.unread[channel];
|
||||
joined: ({channels}, {payload: {channel, id}}) => {
|
||||
channels[channel].users.push(id);
|
||||
},
|
||||
left: (state, {payload: {channel, id}}) => {
|
||||
const {users} = state.channels[channel];
|
||||
leave: ({channels, unread}, {payload: {channel}}) => {
|
||||
delete channels[channel];
|
||||
delete unread[channel];
|
||||
},
|
||||
left: ({channels}, {payload: {channel, id}}) => {
|
||||
const {users} = channels[channel];
|
||||
users.splice(users.indexOf(id), 1);
|
||||
},
|
||||
removeMessage: (state, {payload: {channel, uuid}}) => {
|
||||
|
@ -56,8 +81,7 @@ const slice = createSlice({
|
|||
const {messages} = state.channels[channel];
|
||||
messages.splice(messages.indexOf(uuid), 1);
|
||||
},
|
||||
removeRecent: (state, {payload: {channel}}) => {
|
||||
const {recent} = state;
|
||||
removeRecent: ({recent}, {payload: {channel}}) => {
|
||||
recent.splice(recent.indexOf(channel), 1);
|
||||
},
|
||||
},
|
||||
|
@ -65,9 +89,14 @@ const slice = createSlice({
|
|||
|
||||
export const {
|
||||
addMessage,
|
||||
addRecent,
|
||||
confirmMessage,
|
||||
editMessage,
|
||||
focus,
|
||||
join,
|
||||
joined,
|
||||
leave,
|
||||
left,
|
||||
removeMessage,
|
||||
removeRecent,
|
||||
} = slice.actions;
|
||||
|
|
|
@ -34,6 +34,7 @@ const slice = createSlice({
|
|||
blocked: [],
|
||||
favorites: [],
|
||||
friendship: [],
|
||||
id: 0,
|
||||
isAnonymous: false,
|
||||
redditUsername: 'anonymous',
|
||||
...(hydration('user') || {isAnonymous: true}),
|
||||
|
|
37
src/server/entry.js
Normal file
37
src/server/entry.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
import createRedisClient from './redis';
|
||||
|
||||
const redisClient = createRedisClient();
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const enterChannel = async (channel) => {
|
||||
const messages = await new Promise((resolve, reject) => {
|
||||
redisClient.scan(
|
||||
0,
|
||||
'COUNT', 50,
|
||||
'MATCH', `${channel}:*`,
|
||||
(error, [, keys]) => (
|
||||
error
|
||||
? reject(error)
|
||||
: resolve(
|
||||
0 === keys.length
|
||||
? []
|
||||
: new Promise((resolve, reject) => {
|
||||
redisClient.mget(keys, (error, replies) => (
|
||||
error
|
||||
? reject(error)
|
||||
: resolve(replies
|
||||
.map((reply, i) => ({
|
||||
...JSON.parse(reply),
|
||||
uuid: keys[i].split(':')[1],
|
||||
}))
|
||||
.sort((l, r) => l.timestamp - r.timestamp))
|
||||
));
|
||||
}),
|
||||
)
|
||||
),
|
||||
);
|
||||
});
|
||||
return {
|
||||
messages,
|
||||
};
|
||||
};
|
8
src/server/redis.js
Normal file
8
src/server/redis.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
import redis from 'redis';
|
||||
|
||||
const {
|
||||
REDIS_HOST,
|
||||
REDIS_PORT,
|
||||
} = process.env;
|
||||
|
||||
export default () => redis.createClient(REDIS_PORT, REDIS_HOST);
|
|
@ -1,14 +1,10 @@
|
|||
import session from 'express-session';
|
||||
import redis from 'redis';
|
||||
import {registerHooks} from 'scwp';
|
||||
|
||||
const {
|
||||
REDIS_HOST,
|
||||
REDIS_PORT,
|
||||
} = process.env;
|
||||
|
||||
const redisClient = redis.createClient(REDIS_PORT, REDIS_HOST);
|
||||
import {enterChannel} from './entry';
|
||||
import createRedisClient from './redis';
|
||||
|
||||
const redisClient = createRedisClient();
|
||||
// eslint-disable-next-line import/newline-after-import
|
||||
const RedisStore = require('connect-redis')(session);
|
||||
|
||||
|
@ -54,11 +50,14 @@ registerHooks({
|
|||
messages: {},
|
||||
recent: [],
|
||||
};
|
||||
const entries = await Promise.all(
|
||||
favorites.map((favorite) => enterChannel(channelName(favorite))),
|
||||
);
|
||||
for (let i = 0; i < favorites.length; i++) {
|
||||
const channel = channelName(favorites[i]);
|
||||
const messages = [];
|
||||
const {messages} = entries[i];
|
||||
chat.channels[channel] = {
|
||||
messages,
|
||||
messages: messages.map((message) => message.uuid),
|
||||
users: [],
|
||||
};
|
||||
messages.forEach((message) => {
|
||||
|
|
|
@ -1,20 +1,22 @@
|
|||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import redisAdapter from 'socket.io-redis';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
|
||||
import {SocketServer} from '@avocado/net/server/socket';
|
||||
import socketSession from 'express-socket.io-session';
|
||||
|
||||
import Message from '~/common/packets/message.packet';
|
||||
|
||||
import passport from './passport';
|
||||
import createRedisClient from './redis';
|
||||
import session from './session';
|
||||
|
||||
const {
|
||||
REDIS_HOST,
|
||||
REDIS_PORT,
|
||||
} = process.env;
|
||||
const pubClient = createRedisClient();
|
||||
const subClient = createRedisClient();
|
||||
|
||||
export function createSocketServer(httpServer) {
|
||||
const socketServer = new SocketServer(httpServer, {
|
||||
adapter: redisAdapter({host: REDIS_HOST, port: REDIS_PORT}),
|
||||
adapter: redisAdapter({pubClient, subClient}),
|
||||
});
|
||||
socketServer.io.use(socketSession(session()));
|
||||
socketServer.io.use((socket, next) => {
|
||||
|
@ -26,6 +28,22 @@ export function createSocketServer(httpServer) {
|
|||
socketServer.on('connect', (socket) => {
|
||||
const {req} = socket;
|
||||
socket.on('packet', (packet, fn) => {
|
||||
if (packet instanceof Message) {
|
||||
const owner = req.user ? req.user.id : 0;
|
||||
const timestamp = Date.now();
|
||||
const uuid = uuidv4();
|
||||
const {channel, message} = packet.data;
|
||||
const key = `${channel}:${uuid}`;
|
||||
pubClient
|
||||
.multi()
|
||||
.set(key, JSON.stringify({
|
||||
message,
|
||||
owner,
|
||||
timestamp,
|
||||
}))
|
||||
.expire(key, 600)
|
||||
.exec(() => fn([timestamp, uuid]));
|
||||
}
|
||||
});
|
||||
});
|
||||
return socketServer;
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -9656,16 +9656,16 @@ utils-merge@1.0.1, utils-merge@1.x.x:
|
|||
resolved "https://npm.i12e.cha0s.io/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
|
||||
|
||||
uuid@8.2.0, uuid@^8.1.0:
|
||||
version "8.2.0"
|
||||
resolved "https://npm.i12e.cha0s.io/uuid/-/uuid-8.2.0.tgz#cb10dd6b118e2dada7d0cd9730ba7417c93d920e"
|
||||
integrity sha512-CYpGiFTUrmI6OBMkAdjSDM0k5h8SkkiTP4WAjQgDgNB1S3Ou9VBEvr6q0Kv2H1mMk7IWfxYGpMH5sd5AvcIV2Q==
|
||||
|
||||
uuid@^3.3.2, uuid@^3.4.0:
|
||||
version "3.4.0"
|
||||
resolved "https://npm.i12e.cha0s.io/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
|
||||
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
|
||||
|
||||
uuid@^8.1.0:
|
||||
version "8.2.0"
|
||||
resolved "https://npm.i12e.cha0s.io/uuid/-/uuid-8.2.0.tgz#cb10dd6b118e2dada7d0cd9730ba7417c93d920e"
|
||||
integrity sha512-CYpGiFTUrmI6OBMkAdjSDM0k5h8SkkiTP4WAjQgDgNB1S3Ou9VBEvr6q0Kv2H1mMk7IWfxYGpMH5sd5AvcIV2Q==
|
||||
|
||||
v8-compile-cache@^2.0.3, v8-compile-cache@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://npm.i12e.cha0s.io/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz#54bc3cdd43317bca91e35dcaf305b1a7237de745"
|
||||
|
|
Loading…
Reference in New Issue
Block a user