mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-17 01:59:08 +00:00
feat: start basis of new web ui system on CRA
This commit is contained in:
parent
40f577d01d
commit
38ee680a33
43 changed files with 465 additions and 174 deletions
1
packages/api/.gitignore
vendored
Normal file
1
packages/api/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
dist
|
13
packages/api/bindings.d.ts
vendored
Normal file
13
packages/api/bindings.d.ts
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
export {};
|
||||
|
||||
declare global {
|
||||
const BOT_CLIENT_ID: string;
|
||||
const BOT_CLIENT_SECRET: string;
|
||||
const UI_PUBLIC_URI: string;
|
||||
const API_PUBLIC_URI: string;
|
||||
const ROOT_USERS: string;
|
||||
|
||||
const KV_SESSIONS: KVNamespace;
|
||||
const KV_GUILDS: KVNamespace;
|
||||
const KV_GUILD_DATA: KVNamespace;
|
||||
}
|
36
packages/api/handlers/bot-join.ts
Normal file
36
packages/api/handlers/bot-join.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { Bounce } from '../utils/bounce';
|
||||
import { botClientID } from '../utils/config';
|
||||
|
||||
const validGuildID = /^[0-9]+$/;
|
||||
|
||||
type URLParams = {
|
||||
clientID: string;
|
||||
permissions: number;
|
||||
guildID?: string;
|
||||
};
|
||||
|
||||
const buildURL = (params: URLParams) => {
|
||||
let url = `https://discord.com/api/oauth2/authorize?client_id=${params.clientID}&scope=bot&permissions=${params.permissions}`;
|
||||
|
||||
if (params.guildID) {
|
||||
url += `&guild_id=${params.guildID}&disable_guild_select=true`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export const BotJoin = (request: Request): Response => {
|
||||
let guildID = new URL(request.url).searchParams.get('guild') || '';
|
||||
|
||||
if (guildID && !validGuildID.test(guildID)) {
|
||||
guildID = '';
|
||||
}
|
||||
|
||||
return Bounce(
|
||||
buildURL({
|
||||
clientID: botClientID,
|
||||
permissions: 268435456,
|
||||
guildID,
|
||||
})
|
||||
);
|
||||
};
|
98
packages/api/handlers/create-roleypoly-data.ts
Normal file
98
packages/api/handlers/create-roleypoly-data.ts
Normal file
|
@ -0,0 +1,98 @@
|
|||
import KSUID from 'ksuid';
|
||||
import {
|
||||
CategoryType,
|
||||
Features,
|
||||
GuildData as GuildDataT,
|
||||
} from '../../../src/common/types';
|
||||
import { onlyRootUsers, respond } from '../utils/api-tools';
|
||||
import { GuildData } from '../utils/kv';
|
||||
|
||||
// Temporary use.
|
||||
export const CreateRoleypolyData = onlyRootUsers(
|
||||
async (request: Request): Promise<Response> => {
|
||||
const data: GuildDataT = {
|
||||
id: '386659935687147521',
|
||||
message:
|
||||
'Hey, this is kind of a demo setup so features/use cases can be shown off.\n\nThanks for using Roleypoly <3',
|
||||
features: Features.Preview,
|
||||
categories: [
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Demo Roles',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 0,
|
||||
roles: [
|
||||
'557825026406088717',
|
||||
'557824994269200384',
|
||||
'557824893241131029',
|
||||
'557812915386843170',
|
||||
'557812901717737472',
|
||||
'557812805546541066',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Colors',
|
||||
type: CategoryType.Single,
|
||||
hidden: false,
|
||||
position: 1,
|
||||
roles: [
|
||||
'394060232893923349',
|
||||
'394060145799331851',
|
||||
'394060192846839809',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Test Roles',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 5,
|
||||
roles: [
|
||||
'558104828216213505',
|
||||
'558103534453653514',
|
||||
'558297233582194728',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Region',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 3,
|
||||
roles: [
|
||||
'397296181803483136',
|
||||
'397296137066774529',
|
||||
'397296218809827329',
|
||||
'397296267283267605',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Opt-in Channels',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 4,
|
||||
roles: ['414514823959674890', '764230661904007219'],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Pronouns',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 2,
|
||||
roles: [
|
||||
'485916566790340608',
|
||||
'485916566941335583',
|
||||
'485916566311927808',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await GuildData.put(data.id, data);
|
||||
|
||||
return respond({ ok: true });
|
||||
}
|
||||
);
|
61
packages/api/handlers/get-picker-data.ts
Normal file
61
packages/api/handlers/get-picker-data.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import {
|
||||
DiscordUser,
|
||||
GuildSlug,
|
||||
PresentableGuild,
|
||||
SessionData,
|
||||
} from '../../../src/common/types';
|
||||
import { respond, withSession } from '../utils/api-tools';
|
||||
import { getGuild, getGuildData, getGuildMemberRoles } from '../utils/guild';
|
||||
|
||||
const fail = () => respond({ error: 'guild not found' }, { status: 404 });
|
||||
|
||||
export const GetPickerData = withSession(
|
||||
(session: SessionData) => async (request: Request): Promise<Response> => {
|
||||
const url = new URL(request.url);
|
||||
const [, , guildID] = url.pathname.split('/');
|
||||
|
||||
if (!guildID) {
|
||||
return respond({ error: 'missing guild id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { id: userID } = session.user as DiscordUser;
|
||||
const guilds = session.guilds as GuildSlug[];
|
||||
|
||||
// Save a Discord API request by checking if this user is a member by session first
|
||||
const checkGuild = guilds.find((guild) => guild.id === guildID);
|
||||
if (!checkGuild) {
|
||||
return fail();
|
||||
}
|
||||
|
||||
const guild = await getGuild(guildID, {
|
||||
skipCachePull: url.searchParams.has('__no_cache'),
|
||||
});
|
||||
if (!guild) {
|
||||
return fail();
|
||||
}
|
||||
|
||||
const memberRolesP = getGuildMemberRoles({
|
||||
serverID: guildID,
|
||||
userID,
|
||||
});
|
||||
|
||||
const guildDataP = getGuildData(guildID);
|
||||
|
||||
const [guildData, memberRoles] = await Promise.all([guildDataP, memberRolesP]);
|
||||
if (!memberRoles) {
|
||||
return fail();
|
||||
}
|
||||
|
||||
const presentableGuild: PresentableGuild = {
|
||||
id: guildID,
|
||||
guild: checkGuild,
|
||||
roles: guild.roles,
|
||||
member: {
|
||||
roles: memberRoles,
|
||||
},
|
||||
data: guildData,
|
||||
};
|
||||
|
||||
return respond(presentableGuild);
|
||||
}
|
||||
);
|
12
packages/api/handlers/get-session.ts
Normal file
12
packages/api/handlers/get-session.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { SessionData } from '../../../src/common/types';
|
||||
import { respond, withSession } from '../utils/api-tools';
|
||||
|
||||
export const GetSession = withSession((session?: SessionData) => (): Response => {
|
||||
const { user, guilds, sessionID } = session || {};
|
||||
|
||||
return respond({
|
||||
user,
|
||||
guilds,
|
||||
sessionID,
|
||||
});
|
||||
});
|
41
packages/api/handlers/get-slug.ts
Normal file
41
packages/api/handlers/get-slug.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { GuildSlug } from '../../../src/common/types';
|
||||
import { respond } from '../utils/api-tools';
|
||||
import { getGuild } from '../utils/guild';
|
||||
|
||||
export const GetSlug = async (request: Request): Promise<Response> => {
|
||||
const reqURL = new URL(request.url);
|
||||
const [, , serverID] = reqURL.pathname.split('/');
|
||||
|
||||
if (!serverID) {
|
||||
return respond(
|
||||
{
|
||||
error: 'missing server ID',
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const guild = await getGuild(serverID);
|
||||
if (!guild) {
|
||||
return respond(
|
||||
{
|
||||
error: 'guild not found',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { id, name, icon } = guild;
|
||||
const guildSlug: GuildSlug = {
|
||||
id,
|
||||
name,
|
||||
icon,
|
||||
permissionLevel: 0,
|
||||
};
|
||||
console.log({ guildSlug });
|
||||
return respond(guildSlug);
|
||||
};
|
24
packages/api/handlers/login-bounce.ts
Normal file
24
packages/api/handlers/login-bounce.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import KSUID from 'ksuid';
|
||||
import { Bounce } from '../utils/bounce';
|
||||
import { apiPublicURI, botClientID } from '../utils/config';
|
||||
|
||||
type URLParams = {
|
||||
clientID: string;
|
||||
redirectURI: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
const buildURL = (params: URLParams) =>
|
||||
`https://discord.com/api/oauth2/authorize?client_id=${
|
||||
params.clientID
|
||||
}&response_type=code&scope=identify%20guilds&redirect_uri=${encodeURIComponent(
|
||||
params.redirectURI
|
||||
)}&state=${params.state}`;
|
||||
|
||||
export const LoginBounce = async (request: Request): Promise<Response> => {
|
||||
const state = await KSUID.random();
|
||||
const redirectURI = `${apiPublicURI}/login-callback`;
|
||||
const clientID = botClientID;
|
||||
|
||||
return Bounce(buildURL({ state: state.string, redirectURI, clientID }));
|
||||
};
|
147
packages/api/handlers/login-callback.ts
Normal file
147
packages/api/handlers/login-callback.ts
Normal file
|
@ -0,0 +1,147 @@
|
|||
import KSUID from 'ksuid';
|
||||
import {
|
||||
AuthTokenResponse,
|
||||
DiscordUser,
|
||||
GuildSlug,
|
||||
SessionData,
|
||||
} from '../../../src/common/types';
|
||||
import {
|
||||
AuthType,
|
||||
discordFetch,
|
||||
formData,
|
||||
parsePermissions,
|
||||
resolveFailures,
|
||||
userAgent,
|
||||
} from '../utils/api-tools';
|
||||
import { Bounce } from '../utils/bounce';
|
||||
import { apiPublicURI, botClientID, botClientSecret, uiPublicURI } from '../utils/config';
|
||||
import { Sessions } from '../utils/kv';
|
||||
|
||||
const AuthErrorResponse = (extra?: string) =>
|
||||
Bounce(
|
||||
uiPublicURI +
|
||||
`/machinery/error?error_code=authFailure${extra ? `&extra=${extra}` : ''}`
|
||||
);
|
||||
|
||||
export const LoginCallback = resolveFailures(
|
||||
AuthErrorResponse,
|
||||
async (request: Request): Promise<Response> => {
|
||||
const query = new URL(request.url).searchParams;
|
||||
|
||||
const stateValue = query.get('state');
|
||||
|
||||
if (stateValue === null) {
|
||||
return AuthErrorResponse('state missing');
|
||||
}
|
||||
|
||||
try {
|
||||
const state = KSUID.parse(stateValue);
|
||||
const stateExpiry = state.date.getTime() + 1000 * 60 * 5;
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (currentTime > stateExpiry) {
|
||||
return AuthErrorResponse('state expired');
|
||||
}
|
||||
} catch (e) {
|
||||
return AuthErrorResponse('state invalid');
|
||||
}
|
||||
|
||||
const code = query.get('code');
|
||||
if (!code) {
|
||||
return AuthErrorResponse('code missing');
|
||||
}
|
||||
|
||||
const tokenRequest = {
|
||||
client_id: botClientID,
|
||||
client_secret: botClientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: apiPublicURI + '/login-callback',
|
||||
scope: 'identify guilds',
|
||||
code,
|
||||
};
|
||||
|
||||
const tokenFetch = await fetch('https://discord.com/api/v8/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'user-agent': userAgent,
|
||||
},
|
||||
body: formData(tokenRequest),
|
||||
});
|
||||
|
||||
const tokens = (await tokenFetch.json()) as AuthTokenResponse;
|
||||
|
||||
if (!tokens.access_token) {
|
||||
return AuthErrorResponse('token response invalid');
|
||||
}
|
||||
|
||||
const [sessionID, user, guilds] = await Promise.all([
|
||||
KSUID.random(),
|
||||
getUser(tokens.access_token),
|
||||
getGuilds(tokens.access_token),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
return AuthErrorResponse('failed to fetch user');
|
||||
}
|
||||
|
||||
const sessionData: SessionData = {
|
||||
tokens,
|
||||
sessionID: sessionID.string,
|
||||
user,
|
||||
guilds,
|
||||
};
|
||||
|
||||
await Sessions.put(sessionID.string, sessionData, 60 * 60 * 6);
|
||||
|
||||
return Bounce(
|
||||
uiPublicURI + '/machinery/new-session?session_id=' + sessionID.string
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const getUser = async (accessToken: string): Promise<DiscordUser | null> => {
|
||||
const user = await discordFetch<DiscordUser>(
|
||||
'/users/@me',
|
||||
accessToken,
|
||||
AuthType.Bearer
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id, username, discriminator, bot, avatar } = user;
|
||||
|
||||
return { id, username, discriminator, bot, avatar };
|
||||
};
|
||||
|
||||
type UserGuildsPayload = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
owner: boolean;
|
||||
permissions: number;
|
||||
features: string[];
|
||||
}[];
|
||||
|
||||
const getGuilds = async (accessToken: string) => {
|
||||
const guilds = await discordFetch<UserGuildsPayload>(
|
||||
'/users/@me/guilds',
|
||||
accessToken,
|
||||
AuthType.Bearer
|
||||
);
|
||||
|
||||
if (!guilds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const guildSlugs = guilds.map<GuildSlug>((guild) => ({
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
permissionLevel: parsePermissions(BigInt(guild.permissions), guild.owner),
|
||||
}));
|
||||
|
||||
return guildSlugs;
|
||||
};
|
27
packages/api/handlers/revoke-session.ts
Normal file
27
packages/api/handlers/revoke-session.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { SessionData } from '../../../src/common/types';
|
||||
import { formData, respond, userAgent, withSession } from '../utils/api-tools';
|
||||
import { botClientID, botClientSecret } from '../utils/config';
|
||||
import { Sessions } from '../utils/kv';
|
||||
|
||||
export const RevokeSession = withSession(
|
||||
(session: SessionData) => async (request: Request) => {
|
||||
const tokenRequest = {
|
||||
token: session.tokens.access_token,
|
||||
client_id: botClientID,
|
||||
client_secret: botClientSecret,
|
||||
};
|
||||
|
||||
await fetch('https://discord.com/api/v8/oauth2/token/revoke', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'user-agent': userAgent,
|
||||
},
|
||||
body: formData(tokenRequest),
|
||||
});
|
||||
|
||||
await Sessions.delete(session.sessionID);
|
||||
|
||||
return respond({ ok: true });
|
||||
}
|
||||
);
|
141
packages/api/handlers/update-roles.ts
Normal file
141
packages/api/handlers/update-roles.ts
Normal file
|
@ -0,0 +1,141 @@
|
|||
import { difference, groupBy, keyBy, union } from 'lodash';
|
||||
import {
|
||||
GuildData,
|
||||
Member,
|
||||
Role,
|
||||
RoleSafety,
|
||||
RoleTransaction,
|
||||
RoleUpdate,
|
||||
SessionData,
|
||||
TransactionType,
|
||||
} from '../../../src/common/types';
|
||||
import { AuthType, discordFetch, respond, withSession } from '../utils/api-tools';
|
||||
import { botToken } from '../utils/config';
|
||||
import {
|
||||
getGuild,
|
||||
getGuildData,
|
||||
getGuildMemberRoles,
|
||||
updateGuildMemberRoles,
|
||||
} from '../utils/guild';
|
||||
|
||||
const notFound = () => respond({ error: 'guild not found' }, { status: 404 });
|
||||
|
||||
export const UpdateRoles = withSession(
|
||||
({ guilds, user: { id: userID } }: SessionData) => async (request: Request) => {
|
||||
const updateRequest = (await request.json()) as RoleUpdate;
|
||||
const [, , guildID] = new URL(request.url).pathname.split('/');
|
||||
|
||||
if (!guildID) {
|
||||
return respond({ error: 'guild ID missing from URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (updateRequest.transactions.length === 0) {
|
||||
return respond(
|
||||
{ error: 'must have as least one transaction' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const guildCheck = guilds.find((guild) => guild.id === guildID);
|
||||
if (!guildCheck) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const guild = await getGuild(guildID);
|
||||
if (!guild) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const guildMemberRoles = await getGuildMemberRoles(
|
||||
{ serverID: guildID, userID },
|
||||
{ skipCachePull: true }
|
||||
);
|
||||
if (!guildMemberRoles) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const newRoles = calculateNewRoles({
|
||||
currentRoles: guildMemberRoles,
|
||||
guildRoles: guild.roles,
|
||||
guildData: await getGuildData(guildID),
|
||||
updateRequest,
|
||||
});
|
||||
|
||||
const patchMemberRoles = await discordFetch<Member>(
|
||||
`/guilds/${guildID}/members/${userID}`,
|
||||
botToken,
|
||||
AuthType.Bot,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roles: newRoles,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!patchMemberRoles) {
|
||||
return respond({ error: 'discord rejected the request' }, { status: 500 });
|
||||
}
|
||||
|
||||
const updatedMember: Member = {
|
||||
roles: patchMemberRoles.roles,
|
||||
};
|
||||
|
||||
await updateGuildMemberRoles(
|
||||
{ serverID: guildID, userID },
|
||||
patchMemberRoles.roles
|
||||
);
|
||||
|
||||
return respond(updatedMember);
|
||||
}
|
||||
);
|
||||
|
||||
const calculateNewRoles = ({
|
||||
currentRoles,
|
||||
guildData,
|
||||
guildRoles,
|
||||
updateRequest,
|
||||
}: {
|
||||
currentRoles: string[];
|
||||
guildRoles: Role[];
|
||||
guildData: GuildData;
|
||||
updateRequest: RoleUpdate;
|
||||
}): string[] => {
|
||||
const roleMap = keyBy(guildRoles, 'id');
|
||||
|
||||
// These roles were ones changed between knownState (role picker page load/cache) and current (fresh from discord).
|
||||
// We could cause issues, so we'll re-add them later.
|
||||
// const diffRoles = difference(updateRequest.knownState, currentRoles);
|
||||
|
||||
// Only these are safe
|
||||
const allSafeRoles = guildData.categories.reduce<string[]>(
|
||||
(categorizedRoles, category) =>
|
||||
!category.hidden
|
||||
? [
|
||||
...categorizedRoles,
|
||||
...category.roles.filter(
|
||||
(roleID) => roleMap[roleID]?.safety === RoleSafety.Safe
|
||||
),
|
||||
]
|
||||
: categorizedRoles,
|
||||
[]
|
||||
);
|
||||
|
||||
const safeTransactions = updateRequest.transactions.filter((tx: RoleTransaction) =>
|
||||
allSafeRoles.includes(tx.id)
|
||||
);
|
||||
|
||||
const changesByAction = groupBy(safeTransactions, 'action');
|
||||
|
||||
const rolesToAdd = (changesByAction[TransactionType.Add] ?? []).map((tx) => tx.id);
|
||||
const rolesToRemove = (changesByAction[TransactionType.Remove] ?? []).map(
|
||||
(tx) => tx.id
|
||||
);
|
||||
|
||||
const final = union(difference(currentRoles, rolesToRemove), rolesToAdd);
|
||||
|
||||
return final;
|
||||
};
|
61
packages/api/index.ts
Normal file
61
packages/api/index.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { BotJoin } from './handlers/bot-join';
|
||||
import { CreateRoleypolyData } from './handlers/create-roleypoly-data';
|
||||
import { GetPickerData } from './handlers/get-picker-data';
|
||||
import { GetSession } from './handlers/get-session';
|
||||
import { GetSlug } from './handlers/get-slug';
|
||||
import { LoginBounce } from './handlers/login-bounce';
|
||||
import { LoginCallback } from './handlers/login-callback';
|
||||
import { RevokeSession } from './handlers/revoke-session';
|
||||
import { UpdateRoles } from './handlers/update-roles';
|
||||
import { Router } from './router';
|
||||
import { respond } from './utils/api-tools';
|
||||
import { uiPublicURI } from './utils/config';
|
||||
|
||||
const router = new Router();
|
||||
|
||||
// OAuth
|
||||
router.add('GET', 'bot-join', BotJoin);
|
||||
router.add('GET', 'login-bounce', LoginBounce);
|
||||
router.add('GET', 'login-callback', LoginCallback);
|
||||
|
||||
// Session
|
||||
router.add('GET', 'get-session', GetSession);
|
||||
router.add('POST', 'revoke-session', RevokeSession);
|
||||
|
||||
// Main biz logic
|
||||
router.add('GET', 'get-slug', GetSlug);
|
||||
router.add('GET', 'get-picker-data', GetPickerData);
|
||||
router.add('PATCH', 'update-roles', UpdateRoles);
|
||||
|
||||
// Root users only
|
||||
router.add('GET', 'x-create-roleypoly-data', CreateRoleypolyData);
|
||||
|
||||
// Tester Routes
|
||||
router.add('GET', 'x-headers', (request) => {
|
||||
const headers: { [x: string]: string } = {};
|
||||
|
||||
for (let [key, value] of request.headers.entries()) {
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(headers));
|
||||
});
|
||||
|
||||
// Root Zen <3
|
||||
router.addFallback('root', () => {
|
||||
return respond({
|
||||
__warning: '🦊',
|
||||
this: 'is',
|
||||
a: 'fox-based',
|
||||
web: 'application',
|
||||
please: 'be',
|
||||
mindful: 'of',
|
||||
your: 'surroundings',
|
||||
warning__: '🦊',
|
||||
meta: uiPublicURI,
|
||||
});
|
||||
});
|
||||
|
||||
addEventListener('fetch', (event: FetchEvent) => {
|
||||
event.respondWith(router.handle(event.request));
|
||||
});
|
15
packages/api/package.json
Normal file
15
packages/api/package.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "@roleypoly/api",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "yarn workspace @roleypoly/worker-emulator build --basePath `pwd`",
|
||||
"start": "yarn workspace @roleypoly/worker-emulator start --basePath `pwd`"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@roleypoly/worker-emulator": "*",
|
||||
"ksuid": "^2.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"ts-loader": "^8.0.18",
|
||||
"tsconfig-paths-webpack-plugin": "^3.3.0"
|
||||
}
|
||||
}
|
84
packages/api/router.ts
Normal file
84
packages/api/router.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { addCORS } from './utils/api-tools';
|
||||
import { uiPublicURI } from './utils/config';
|
||||
|
||||
export type Handler = (request: Request) => Promise<Response> | Response;
|
||||
|
||||
type RoutingTree = {
|
||||
[method: string]: {
|
||||
[path: string]: Handler;
|
||||
};
|
||||
};
|
||||
|
||||
type Fallbacks = {
|
||||
root: Handler;
|
||||
404: Handler;
|
||||
500: Handler;
|
||||
};
|
||||
|
||||
export class Router {
|
||||
private routingTree: RoutingTree = {};
|
||||
private fallbacks: Fallbacks = {
|
||||
root: this.respondToRoot,
|
||||
404: this.notFound,
|
||||
500: this.serverError,
|
||||
};
|
||||
|
||||
private uiURL = new URL(uiPublicURI);
|
||||
|
||||
addFallback(which: keyof Fallbacks, handler: Handler) {
|
||||
this.fallbacks[which] = handler;
|
||||
}
|
||||
|
||||
add(method: string, rootPath: string, handler: Handler) {
|
||||
const lowerMethod = method.toLowerCase();
|
||||
|
||||
if (!this.routingTree[lowerMethod]) {
|
||||
this.routingTree[lowerMethod] = {};
|
||||
}
|
||||
|
||||
this.routingTree[lowerMethod][rootPath] = handler;
|
||||
}
|
||||
|
||||
async handle(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/' || url.pathname === '') {
|
||||
return this.fallbacks.root(request);
|
||||
}
|
||||
const lowerMethod = request.method.toLowerCase();
|
||||
const rootPath = url.pathname.split('/')[1];
|
||||
const handler = this.routingTree[lowerMethod]?.[rootPath];
|
||||
|
||||
if (handler) {
|
||||
try {
|
||||
const response = await handler(request);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return this.fallbacks[500](request);
|
||||
}
|
||||
}
|
||||
|
||||
if (lowerMethod === 'options') {
|
||||
return new Response(null, addCORS({}));
|
||||
}
|
||||
|
||||
return this.fallbacks[404](request);
|
||||
}
|
||||
|
||||
private respondToRoot(): Response {
|
||||
return new Response('Hi there!');
|
||||
}
|
||||
|
||||
private notFound(): Response {
|
||||
return new Response(JSON.stringify({ error: 'not_found' }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
private serverError(): Response {
|
||||
return new Response(JSON.stringify({ error: 'internal_server_error' }), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
15
packages/api/tsconfig.json
Normal file
15
packages/api/tsconfig.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"target": "ES2019"
|
||||
},
|
||||
"include": [
|
||||
"./*.ts",
|
||||
"./**/*.ts",
|
||||
"../../node_modules/@cloudflare/workers-types/index.d.ts"
|
||||
],
|
||||
"exclude": ["./**/*.spec.ts", "./dist/**"],
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
178
packages/api/utils/api-tools.ts
Normal file
178
packages/api/utils/api-tools.ts
Normal file
|
@ -0,0 +1,178 @@
|
|||
import { SessionData, UserGuildPermissions } from '../../../src/common/types';
|
||||
import {
|
||||
evaluatePermission,
|
||||
permissions as Permissions,
|
||||
} from '../../../src/common/utils/hasPermission';
|
||||
import { Handler } from '../router';
|
||||
import { rootUsers, uiPublicURI } from './config';
|
||||
import { Sessions, WrappedKVNamespace } from './kv';
|
||||
|
||||
export const formData = (obj: Record<string, any>): string => {
|
||||
return Object.keys(obj)
|
||||
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`)
|
||||
.join('&');
|
||||
};
|
||||
|
||||
export const addCORS = (init: ResponseInit = {}) => ({
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers || {}),
|
||||
'access-control-allow-origin': uiPublicURI,
|
||||
'access-control-allow-methods': '*',
|
||||
'access-control-allow-headers': '*',
|
||||
},
|
||||
});
|
||||
|
||||
export const respond = (obj: Record<string, any>, init: ResponseInit = {}) =>
|
||||
new Response(JSON.stringify(obj), addCORS(init));
|
||||
|
||||
export const resolveFailures = (
|
||||
handleWith: () => Response,
|
||||
handler: (request: Request) => Promise<Response> | Response
|
||||
) => async (request: Request): Promise<Response> => {
|
||||
try {
|
||||
return handler(request);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return (
|
||||
handleWith() || respond({ error: 'internal server error' }, { status: 500 })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const parsePermissions = (
|
||||
permissions: bigint,
|
||||
owner: boolean = false
|
||||
): UserGuildPermissions => {
|
||||
if (owner || evaluatePermission(permissions, Permissions.ADMINISTRATOR)) {
|
||||
return UserGuildPermissions.Admin;
|
||||
}
|
||||
|
||||
if (evaluatePermission(permissions, Permissions.MANAGE_ROLES)) {
|
||||
return UserGuildPermissions.Manager;
|
||||
}
|
||||
|
||||
return UserGuildPermissions.User;
|
||||
};
|
||||
|
||||
export const getSessionID = (request: Request): { type: string; id: string } | null => {
|
||||
const sessionID = request.headers.get('authorization');
|
||||
if (!sessionID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [type, id] = sessionID.split(' ');
|
||||
if (type !== 'Bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { type, id };
|
||||
};
|
||||
|
||||
export const userAgent =
|
||||
'DiscordBot (https://github.com/roleypoly/roleypoly, git-main) (+https://roleypoly.com)';
|
||||
|
||||
export enum AuthType {
|
||||
Bearer = 'Bearer',
|
||||
Bot = 'Bot',
|
||||
}
|
||||
|
||||
export const discordFetch = async <T>(
|
||||
url: string,
|
||||
auth: string,
|
||||
authType: AuthType = AuthType.Bearer,
|
||||
init?: RequestInit
|
||||
): Promise<T | null> => {
|
||||
const response = await fetch('https://discord.com/api/v8' + url, {
|
||||
...(init || {}),
|
||||
headers: {
|
||||
...(init?.headers || {}),
|
||||
authorization: `${AuthType[authType]} ${auth}`,
|
||||
'user-agent': userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
console.error('discordFetch failed', {
|
||||
url,
|
||||
authType,
|
||||
payload: await response.text(),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return (await response.json()) as T;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const cacheLayer = <Identity, Data>(
|
||||
kv: WrappedKVNamespace,
|
||||
keyFactory: (identity: Identity) => string,
|
||||
missHandler: (identity: Identity) => Promise<Data | null>,
|
||||
ttlSeconds?: number
|
||||
) => async (
|
||||
identity: Identity,
|
||||
options: { skipCachePull?: boolean } = {}
|
||||
): Promise<Data | null> => {
|
||||
const key = keyFactory(identity);
|
||||
|
||||
if (!options.skipCachePull) {
|
||||
const value = await kv.get<Data>(key);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackValue = await missHandler(identity);
|
||||
if (!fallbackValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await kv.put(key, fallbackValue, ttlSeconds);
|
||||
|
||||
return fallbackValue;
|
||||
};
|
||||
|
||||
const NotAuthenticated = (extra?: string) =>
|
||||
respond(
|
||||
{
|
||||
error: extra || 'not authenticated',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
|
||||
export const withSession = (
|
||||
wrappedHandler: (session: SessionData) => Handler
|
||||
): Handler => async (request: Request): Promise<Response> => {
|
||||
const sessionID = getSessionID(request);
|
||||
if (!sessionID) {
|
||||
return NotAuthenticated('missing authentication');
|
||||
}
|
||||
|
||||
const session = await Sessions.get<SessionData>(sessionID.id);
|
||||
if (!session) {
|
||||
return NotAuthenticated('authentication expired or not found');
|
||||
}
|
||||
|
||||
return await wrappedHandler(session)(request);
|
||||
};
|
||||
|
||||
export const isRoot = (userID: string): boolean => rootUsers.includes(userID);
|
||||
|
||||
export const onlyRootUsers = (handler: Handler): Handler =>
|
||||
withSession((session) => (request: Request) => {
|
||||
if (isRoot(session.user.id)) {
|
||||
return handler(request);
|
||||
}
|
||||
|
||||
return respond(
|
||||
{
|
||||
error: 'not_found',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
});
|
7
packages/api/utils/bounce.ts
Normal file
7
packages/api/utils/bounce.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export const Bounce = (url: string): Response =>
|
||||
new Response(null, {
|
||||
status: 303,
|
||||
headers: {
|
||||
location: url,
|
||||
},
|
||||
});
|
13
packages/api/utils/config.ts
Normal file
13
packages/api/utils/config.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
const self = (global as any) as Record<string, string>;
|
||||
|
||||
const env = (key: string) => self[key] ?? '';
|
||||
|
||||
const safeURI = (x: string) => x.replace(/\/$/, '');
|
||||
const list = (x: string) => x.split(',');
|
||||
|
||||
export const botClientID = env('BOT_CLIENT_ID');
|
||||
export const botClientSecret = env('BOT_CLIENT_SECRET');
|
||||
export const botToken = env('BOT_TOKEN');
|
||||
export const uiPublicURI = safeURI(env('UI_PUBLIC_URI'));
|
||||
export const apiPublicURI = safeURI(env('API_PUBLIC_URI'));
|
||||
export const rootUsers = list(env('ROOT_USERS'));
|
163
packages/api/utils/guild.ts
Normal file
163
packages/api/utils/guild.ts
Normal file
|
@ -0,0 +1,163 @@
|
|||
import {
|
||||
Features,
|
||||
Guild,
|
||||
GuildData as GuildDataT,
|
||||
OwnRoleInfo,
|
||||
Role,
|
||||
RoleSafety,
|
||||
} from '../../../src/common/types';
|
||||
import { evaluatePermission, permissions } from '../../../src/common/utils/hasPermission';
|
||||
import { AuthType, cacheLayer, discordFetch } from './api-tools';
|
||||
import { botClientID, botToken } from './config';
|
||||
import { GuildData, Guilds } from './kv';
|
||||
|
||||
type APIGuild = {
|
||||
// Only relevant stuff
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
roles: APIRole[];
|
||||
};
|
||||
|
||||
type APIRole = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: number;
|
||||
position: number;
|
||||
permissions: string;
|
||||
managed: boolean;
|
||||
};
|
||||
|
||||
export const getGuild = cacheLayer(
|
||||
Guilds,
|
||||
(id: string) => `guilds/${id}`,
|
||||
async (id: string) => {
|
||||
const guildRaw = await discordFetch<APIGuild>(
|
||||
`/guilds/${id}`,
|
||||
botToken,
|
||||
AuthType.Bot
|
||||
);
|
||||
|
||||
if (!guildRaw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const botMemberRoles =
|
||||
(await getGuildMemberRoles({
|
||||
serverID: id,
|
||||
userID: botClientID,
|
||||
})) || [];
|
||||
|
||||
const highestRolePosition = botMemberRoles.reduce<number>((highest, roleID) => {
|
||||
const role = guildRaw.roles.find((guildRole) => guildRole.id === roleID);
|
||||
if (!role) {
|
||||
return highest;
|
||||
}
|
||||
|
||||
// If highest is a bigger number, it stays the highest.
|
||||
if (highest > role.position) {
|
||||
return highest;
|
||||
}
|
||||
|
||||
return role.position;
|
||||
}, 0);
|
||||
|
||||
const roles = guildRaw.roles.map<Role>((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color,
|
||||
managed: role.managed,
|
||||
position: role.position,
|
||||
permissions: role.permissions,
|
||||
safety: calculateRoleSafety(role, highestRolePosition),
|
||||
}));
|
||||
|
||||
// Filters the raw guild data into data we actually want
|
||||
const guild: Guild & OwnRoleInfo = {
|
||||
id: guildRaw.id,
|
||||
name: guildRaw.name,
|
||||
icon: guildRaw.icon,
|
||||
roles,
|
||||
highestRolePosition,
|
||||
};
|
||||
|
||||
return guild;
|
||||
},
|
||||
60 * 60 * 2 // 2 hour TTL
|
||||
);
|
||||
|
||||
type GuildMemberIdentity = {
|
||||
serverID: string;
|
||||
userID: string;
|
||||
};
|
||||
|
||||
type APIMember = {
|
||||
// Only relevant stuff, again.
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
const guildMemberRolesIdentity = ({ serverID, userID }: GuildMemberIdentity) =>
|
||||
`guilds/${serverID}/members/${userID}/roles`;
|
||||
|
||||
export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>(
|
||||
Guilds,
|
||||
guildMemberRolesIdentity,
|
||||
async ({ serverID, userID }) => {
|
||||
const discordMember = await discordFetch<APIMember>(
|
||||
`/guilds/${serverID}/members/${userID}`,
|
||||
botToken,
|
||||
AuthType.Bot
|
||||
);
|
||||
|
||||
if (!discordMember) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return discordMember.roles;
|
||||
},
|
||||
60 * 5 // 5 minute TTL
|
||||
);
|
||||
|
||||
export const updateGuildMemberRoles = async (
|
||||
identity: GuildMemberIdentity,
|
||||
roles: Role['id'][]
|
||||
) => {
|
||||
await Guilds.put(guildMemberRolesIdentity(identity), roles, 60 * 5);
|
||||
};
|
||||
|
||||
export const getGuildData = async (id: string): Promise<GuildDataT> => {
|
||||
const guildData = await GuildData.get<GuildDataT>(id);
|
||||
|
||||
if (!guildData) {
|
||||
return {
|
||||
id,
|
||||
message: '',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
};
|
||||
}
|
||||
|
||||
return guildData;
|
||||
};
|
||||
|
||||
const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => {
|
||||
let safety = RoleSafety.Safe;
|
||||
|
||||
if (role.managed) {
|
||||
safety |= RoleSafety.ManagedRole;
|
||||
}
|
||||
|
||||
if (role.position > highestBotRolePosition) {
|
||||
safety |= RoleSafety.HigherThanBot;
|
||||
}
|
||||
|
||||
const permBigInt = BigInt(role.permissions);
|
||||
if (
|
||||
evaluatePermission(permBigInt, permissions.ADMINISTRATOR) ||
|
||||
evaluatePermission(permBigInt, permissions.MANAGE_ROLES)
|
||||
) {
|
||||
safety |= RoleSafety.DangerousPermissions;
|
||||
}
|
||||
|
||||
return safety;
|
||||
};
|
90
packages/api/utils/kv.ts
Normal file
90
packages/api/utils/kv.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
export class WrappedKVNamespace {
|
||||
constructor(private kvNamespace: KVNamespace) {}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const data = await this.kvNamespace.get(key, 'text');
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(data) as T;
|
||||
}
|
||||
|
||||
async put<T>(key: string, value: T, ttlSeconds?: number) {
|
||||
await this.kvNamespace.put(key, JSON.stringify(value), {
|
||||
expirationTtl: ttlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
list = this.kvNamespace.list;
|
||||
getWithMetadata = this.kvNamespace.getWithMetadata;
|
||||
delete = this.kvNamespace.delete;
|
||||
}
|
||||
|
||||
class EmulatedKV implements KVNamespace {
|
||||
constructor() {
|
||||
console.warn('EmulatedKV used. Data will be lost.');
|
||||
}
|
||||
|
||||
private data: Map<string, any> = new Map();
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
if (!this.data.has(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.data.get(key);
|
||||
}
|
||||
|
||||
async getWithMetadata<T, Metadata = unknown>(
|
||||
key: string
|
||||
): KVValueWithMetadata<T, Metadata> {
|
||||
return {
|
||||
value: await this.get<T>(key),
|
||||
metadata: {} as Metadata,
|
||||
};
|
||||
}
|
||||
|
||||
async put(key: string, value: string | ReadableStream<any> | ArrayBuffer | FormData) {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
|
||||
async delete(key: string) {
|
||||
this.data.delete(key);
|
||||
}
|
||||
|
||||
async list(options?: {
|
||||
prefix?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<{
|
||||
keys: { name: string; expiration?: number; metadata?: unknown }[];
|
||||
list_complete: boolean;
|
||||
cursor: string;
|
||||
}> {
|
||||
let keys: { name: string }[] = [];
|
||||
|
||||
for (let key of this.data.keys()) {
|
||||
if (options?.prefix && !key.startsWith(options.prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
keys.push({ name: key });
|
||||
}
|
||||
|
||||
return {
|
||||
keys,
|
||||
cursor: '0',
|
||||
list_complete: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const kvOrLocal = (namespace: KVNamespace | null): KVNamespace =>
|
||||
namespace || new EmulatedKV();
|
||||
|
||||
const self = (global as any) as Record<string, any>;
|
||||
|
||||
export const Sessions = new WrappedKVNamespace(kvOrLocal(self.KV_SESSIONS ?? null));
|
||||
export const GuildData = new WrappedKVNamespace(kvOrLocal(self.KV_GUILD_DATA ?? null));
|
||||
export const Guilds = new WrappedKVNamespace(kvOrLocal(self.KV_GUILDS ?? null));
|
34
packages/api/webpack.config.js
Normal file
34
packages/api/webpack.config.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
const path = require('path');
|
||||
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
|
||||
|
||||
const mode = process.env.NODE_ENV || 'production';
|
||||
|
||||
module.exports = {
|
||||
target: 'webworker',
|
||||
entry: path.join(__dirname, 'index.ts'),
|
||||
output: {
|
||||
filename: `worker.${mode}.js`,
|
||||
path: path.join(__dirname, 'dist'),
|
||||
},
|
||||
mode,
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js'],
|
||||
plugins: [
|
||||
new TsconfigPathsPlugin({
|
||||
configFile: path.resolve(__dirname, './tsconfig.json'),
|
||||
}),
|
||||
],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
configFile: path.join(__dirname, 'tsconfig.json'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
15
packages/api/worker.config.js
Normal file
15
packages/api/worker.config.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
const reexportEnv = (keys = []) => {
|
||||
return keys.reduce((acc, key) => ({ ...acc, [key]: process.env[key] }), {});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
environment: reexportEnv([
|
||||
'BOT_CLIENT_ID',
|
||||
'BOT_CLIENT_SECRET',
|
||||
'BOT_TOKEN',
|
||||
'UI_PUBLIC_URI',
|
||||
'API_PUBLIC_URI',
|
||||
'ROOT_USERS',
|
||||
]),
|
||||
kv: ['KV_SESSIONS', 'KV_GUILDS', 'KV_GUILD_DATA'],
|
||||
};
|
102
packages/backend-emulator/kv.js
Normal file
102
packages/backend-emulator/kv.js
Normal file
|
@ -0,0 +1,102 @@
|
|||
const level = require('level');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
let hasWarned = false;
|
||||
|
||||
const getConversion = {
|
||||
text: (x) => x,
|
||||
json: (x) => JSON.parse(x),
|
||||
arrayBuffer: (x) => Buffer.from(x).buffer,
|
||||
stream: (x) => Buffer.from(x),
|
||||
};
|
||||
|
||||
class KVShim {
|
||||
constructor(namespace) {
|
||||
this.namespace = namespace;
|
||||
|
||||
fs.mkdirSync(path.resolve(__dirname, '../../.devdbs'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
this.level = level(path.resolve(__dirname, '../../.devdbs', namespace));
|
||||
})();
|
||||
}
|
||||
|
||||
makeValue(value, expirationTtl) {
|
||||
if (!expirationTtl) {
|
||||
return JSON.stringify({
|
||||
value,
|
||||
expires: false,
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
value,
|
||||
expires: Date.now() + 1000 * expirationTtl,
|
||||
});
|
||||
}
|
||||
|
||||
validate(value) {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.expires && value.expires < Date.now()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async get(key, type = 'text') {
|
||||
try {
|
||||
const result = JSON.parse(await this.level.get(key));
|
||||
|
||||
if (!this.validate(result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getConversion[type](result.value);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getWithMetadata(key, type) {
|
||||
return {
|
||||
value: await this.get(key, type),
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
async put(key, value, { expirationTtl, expiration, metadata }) {
|
||||
if ((expiration || metadata) && !hasWarned) {
|
||||
console.warn(
|
||||
'expiration and metadata is lost in the emulator. Use expirationTtl, please.'
|
||||
);
|
||||
hasWarned = true;
|
||||
}
|
||||
|
||||
return await this.level.put(key, this.makeValue(value, expirationTtl));
|
||||
}
|
||||
|
||||
// This loses scope for some unknown reason
|
||||
delete = async (key) => {
|
||||
return this.level.del(key);
|
||||
};
|
||||
|
||||
list() {
|
||||
console.warn('List is frowned upon and will fail to fetch keys in the emulator.');
|
||||
return {
|
||||
keys: [],
|
||||
cursor: '0',
|
||||
list_complete: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KVShim,
|
||||
};
|
195
packages/backend-emulator/main.js
Normal file
195
packages/backend-emulator/main.js
Normal file
|
@ -0,0 +1,195 @@
|
|||
const path = require('path');
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
|
||||
const vm = require('vm');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const chokidar = require('chokidar');
|
||||
const webpack = require('webpack');
|
||||
const { Crypto } = require('@peculiar/webcrypto');
|
||||
const { KVShim } = require('./kv');
|
||||
const crypto = new Crypto();
|
||||
const fetch = require('node-fetch');
|
||||
const args = require('minimist')(process.argv.slice(2));
|
||||
|
||||
const basePath = args.basePath;
|
||||
if (!basePath) {
|
||||
throw new Error('--basePath is not set.');
|
||||
}
|
||||
|
||||
const workerConfig = require(`${basePath}/worker.config.js`);
|
||||
|
||||
const getKVs = (namespaces = []) =>
|
||||
namespaces.reduce((acc, ns) => ({ ...acc, [ns]: new KVShim(ns) }), {});
|
||||
|
||||
const workerShims = {
|
||||
...workerConfig.environment,
|
||||
...getKVs(workerConfig.kv),
|
||||
};
|
||||
|
||||
let listeners = [];
|
||||
|
||||
let isResponseConstructorAllowed = false;
|
||||
|
||||
/**
|
||||
* SafeResponse wraps a fetch Response to yell loudly if constructed at an unsafe time.
|
||||
* Cloudflare will reject all Response objects that aren't created during a request, so no pre-generation is allowed.
|
||||
*/
|
||||
class SafeResponse extends fetch.Response {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
if (!isResponseConstructorAllowed) {
|
||||
throw new Error(
|
||||
'Response object created outside of request context. This will be rejected by Cloudflare.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const context = () =>
|
||||
vm.createContext(
|
||||
{
|
||||
addEventListener: (a, fn) => {
|
||||
if (a === 'fetch') {
|
||||
console.log('addEventListeners: added fetch');
|
||||
listeners.push(fn);
|
||||
}
|
||||
},
|
||||
Response: SafeResponse,
|
||||
URL: URL,
|
||||
crypto: crypto,
|
||||
setTimeout: setTimeout,
|
||||
setInterval: setInterval,
|
||||
clearInterval: clearInterval,
|
||||
clearTimeout: clearTimeout,
|
||||
fetch: fetch,
|
||||
console: console,
|
||||
...workerShims,
|
||||
},
|
||||
{
|
||||
codeGeneration: {
|
||||
strings: false,
|
||||
wasm: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const event = {
|
||||
respondWith: async (value) => {
|
||||
const timeStart = Date.now();
|
||||
let loggedStatus;
|
||||
try {
|
||||
const response = await value;
|
||||
if (!response) {
|
||||
throw new Error(
|
||||
`response was invalid, got ${JSON.stringify(response)}`
|
||||
);
|
||||
}
|
||||
res.statusCode = response.status;
|
||||
loggedStatus = String(response.status);
|
||||
response.headers.forEach((value, key) => res.setHeader(key, value));
|
||||
res.end(response.body);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.statusCode = 500;
|
||||
loggedStatus = '500';
|
||||
res.end(JSON.stringify({ error: 'internal server error' }));
|
||||
}
|
||||
const timeEnd = Date.now();
|
||||
console.log(
|
||||
`${loggedStatus} [${timeEnd - timeStart}ms] - ${req.method} ${req.url}`
|
||||
);
|
||||
isResponseConstructorAllowed = false;
|
||||
},
|
||||
request: new fetch.Request(
|
||||
new URL(`http://${req.headers.host || 'localhost'}${req.url}`),
|
||||
{
|
||||
body: ['GET', 'HEAD'].includes(req.method) ? undefined : req,
|
||||
headers: req.headers,
|
||||
method: req.method,
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
event.request.headers.set('cf-client-ip', req.connection.remoteAddress);
|
||||
|
||||
if (listeners.length === 0) {
|
||||
res.statusCode = 503;
|
||||
res.end('No handlers are available.');
|
||||
console.error('No handlers are available');
|
||||
return;
|
||||
}
|
||||
|
||||
isResponseConstructorAllowed = true;
|
||||
for (let listener of listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (e) {
|
||||
console.error('listener errored', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fork = async (fn) => fn();
|
||||
|
||||
const reload = () => {
|
||||
// Clear listeners...
|
||||
listeners = [];
|
||||
|
||||
// Fork and re-run
|
||||
fork(async () =>
|
||||
vm.runInContext(
|
||||
fs.readFileSync(path.resolve(__dirname, `${basePath}/dist/worker.js`)),
|
||||
context(),
|
||||
{
|
||||
displayErrors: true,
|
||||
filename: 'worker.js',
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const rebuild = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const webpackConfig = require(`${basePath}/webpack.config.js`);
|
||||
webpackConfig.output.filename = 'worker.js';
|
||||
webpack(webpackConfig).run((err, stats) => {
|
||||
if (err) {
|
||||
console.log('Compilation failed.', err);
|
||||
reject(err);
|
||||
} else {
|
||||
if (stats.hasErrors()) {
|
||||
console.error('Compilation errored:', stats.compilation.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Compilation done.');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const watcher = chokidar.watch(path.resolve(__dirname, basePath), {
|
||||
ignoreInitial: true,
|
||||
ignore: '**/dist',
|
||||
});
|
||||
|
||||
watcher.on('all', async (type, path) => {
|
||||
if (path.includes('dist')) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('change detected, rebuilding and reloading', { type, path });
|
||||
|
||||
await rebuild();
|
||||
reload();
|
||||
});
|
||||
|
||||
fork(async () => {
|
||||
await rebuild();
|
||||
reload();
|
||||
});
|
||||
|
||||
console.log('starting on http://localhost:6609');
|
||||
server.listen(6609, '0.0.0.0');
|
17
packages/backend-emulator/package.json
Normal file
17
packages/backend-emulator/package.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "@roleypoly/worker-emulator",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "node main.js --build",
|
||||
"start": "node main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@peculiar/webcrypto": "^1.1.6",
|
||||
"chokidar": "^3.5.1",
|
||||
"dotenv": "^8.2.0",
|
||||
"level": "^6.0.1",
|
||||
"minimist": "^1.2.5",
|
||||
"node-fetch": "^2.6.1",
|
||||
"webpack": "^4.x"
|
||||
}
|
||||
}
|
|
@ -1,23 +1,5 @@
|
|||
import * as React from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
export const InjectTypekitFont = () => {
|
||||
React.useEffect(() => {
|
||||
(window as any).Typekit.load();
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<link
|
||||
key="typekit-css-preload"
|
||||
rel="preload"
|
||||
href="https://use.typekit.net/bck0pci.js"
|
||||
as="script"
|
||||
/>
|
||||
<script key="typekit-js" src="https://use.typekit.net/bck0pci.js" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const fontCSS = css`
|
||||
font-family: 'source-han-sans-japanese', 'Source Sans Pro', sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol' !important;
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
"react": "^17.0.1",
|
||||
"react-custom-scrollbars": "^4.2.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-icons": "^4.1.0",
|
||||
"react-is": "^17.0.1",
|
||||
"react-tooltip": "^4.2.15",
|
||||
|
@ -32,6 +33,7 @@
|
|||
"@types/react": "^17.0.0",
|
||||
"@types/react-custom-scrollbars": "^4.0.7",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/react-helmet": "^6.1.0",
|
||||
"@types/styled-components": "^5.1.7",
|
||||
"babel-loader": "8.1.0",
|
||||
"babel-plugin-styled-components": "^1.12.0",
|
||||
|
|
27
packages/web/craco.config.js
Normal file
27
packages/web/craco.config.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
const path = require('path');
|
||||
const { getLoader, loaderByName } = require('@craco/craco');
|
||||
|
||||
const includePaths = [
|
||||
path.join(__dirname, '../design-system'),
|
||||
path.join(__dirname, '../../src/common'),
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
webpack: {
|
||||
alias: {},
|
||||
plugins: [],
|
||||
configure: (webpackConfig, { env, paths }) => {
|
||||
const { isFound, match } = getLoader(
|
||||
webpackConfig,
|
||||
loaderByName('babel-loader')
|
||||
);
|
||||
if (isFound) {
|
||||
const include = Array.isArray(match.loader.include)
|
||||
? match.loader.include
|
||||
: [match.loader.include];
|
||||
match.loader.include = [...include, ...includePaths];
|
||||
}
|
||||
return webpackConfig;
|
||||
},
|
||||
},
|
||||
};
|
|
@ -3,12 +3,13 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env BUILD_PATH=../../dist react-scripts build",
|
||||
"eject": "react-scripts eject",
|
||||
"start": "react-scripts start",
|
||||
"test": "react-scripts test"
|
||||
"build": "cross-env BUILD_PATH=../../dist craco build",
|
||||
"start": "cross-env PORT=6601 craco start",
|
||||
"test": "craco test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reach/router": "^1.3.4",
|
||||
"@roleypoly/design-system": "*",
|
||||
"@testing-library/jest-dom": "^5.11.9",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
|
@ -18,13 +19,17 @@
|
|||
"@types/react-dom": "^17.0.2",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-scripts": "4.0.3",
|
||||
"typescript": "^4.2.3",
|
||||
"web-vitals": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@craco/craco": "^6.1.1",
|
||||
"@types/react-helmet": "^6.1.0",
|
||||
"babel-loader": "8.1.0",
|
||||
"cross-env": "7.0.3",
|
||||
"ts-loader": "^8.0.18",
|
||||
"webpack": "4.44.2"
|
||||
},
|
||||
"browserslist": {
|
||||
|
|
|
@ -22,6 +22,37 @@
|
|||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
<script>
|
||||
(function (d) {
|
||||
var config = {
|
||||
kitId: 'bck0pci',
|
||||
scriptTimeout: 3000,
|
||||
async: true,
|
||||
},
|
||||
h = d.documentElement,
|
||||
t = setTimeout(function () {
|
||||
h.className =
|
||||
h.className.replace(/\bwf-loading\b/g, '') + ' wf-inactive';
|
||||
}, config.scriptTimeout),
|
||||
tk = d.createElement('script'),
|
||||
f = false,
|
||||
s = d.getElementsByTagName('script')[0],
|
||||
a;
|
||||
h.className += ' wf-loading';
|
||||
tk.src = 'https://use.typekit.net/' + config.kitId + '.js';
|
||||
tk.async = true;
|
||||
tk.onload = tk.onreadystatechange = function () {
|
||||
a = this.readyState;
|
||||
if (f || (a && a != 'complete' && a != 'loaded')) return;
|
||||
f = true;
|
||||
clearTimeout(t);
|
||||
try {
|
||||
Typekit.load(config);
|
||||
} catch (e) {}
|
||||
};
|
||||
s.parentNode.insertBefore(tk, s);
|
||||
})(document);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
22
packages/web/src/app-router/AppRouter.tsx
Normal file
22
packages/web/src/app-router/AppRouter.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { Router } from '@reach/router';
|
||||
import * as React from 'react';
|
||||
|
||||
const LandingPage = React.lazy(() => import('../pages/landing'));
|
||||
|
||||
const RouteWrapper = (props: {
|
||||
component: React.LazyExoticComponent<React.ComponentType<any>>;
|
||||
path?: string;
|
||||
default?: boolean;
|
||||
}) => (
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<props.component {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
export const AppRouter = () => {
|
||||
return (
|
||||
<Router>
|
||||
<RouteWrapper component={LandingPage} path="/" />
|
||||
</Router>
|
||||
);
|
||||
};
|
1
packages/web/src/app-router/index.ts
Normal file
1
packages/web/src/app-router/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './AppRouter';
|
|
@ -1,11 +0,0 @@
|
|||
body {
|
||||
margin: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
|
@ -1,17 +1,10 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import { AppRouter } from './app-router';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<AppRouter />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
||||
// 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();
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
Before Width: | Height: | Size: 2.6 KiB |
8
packages/web/src/pages/landing.tsx
Normal file
8
packages/web/src/pages/landing.tsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { LandingTemplate } from '@roleypoly/design-system/templates/landing';
|
||||
import * as React from 'react';
|
||||
|
||||
const Landing = () => {
|
||||
return <LandingTemplate />;
|
||||
};
|
||||
|
||||
export default Landing;
|
|
@ -1,15 +0,0 @@
|
|||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
Loading…
Add table
Add a link
Reference in a new issue