Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More server side bundle size improvements #1129

Merged
merged 6 commits into from
May 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cuddly-stingrays-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystatic/core': patch
---

Server side bundle size improvements
12 changes: 6 additions & 6 deletions packages/keystatic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,16 +144,14 @@
"@urql/exchange-graphcache": "^6.3.3",
"@urql/exchange-persisted": "^4.1.0",
"cookie": "^0.5.0",
"decimal.js": "^10.4.3",
"decimal.js-light": "^2.5.1",
"emery": "^1.4.1",
"escape-string-regexp": "^4.0.0",
"fast-deep-equal": "^3.1.3",
"graphql": "^16.6.0",
"idb-keyval": "^6.2.1",
"ignore": "^5.2.4",
"iron-webcrypto": "^0.10.1",
"is-hotkey": "^0.2.0",
"js-base64": "^3.7.5",
"js-yaml": "^4.1.0",
"lib0": "^0.2.88",
"lru-cache": "^10.2.0",
Expand Down Expand Up @@ -182,12 +180,12 @@
"slate": "^0.91.4",
"slate-history": "^0.86.0",
"slate-react": "^0.91.9",
"superstruct": "^1.0.4",
"unist-util-visit": "^5.0.0",
"urql": "^4.0.0",
"y-prosemirror": "^1.2.2",
"y-protocols": "^1.0.6",
"yjs": "^13.6.11",
"zod": "^3.20.2"
"yjs": "^13.6.11"
},
"devDependencies": {
"@jest/expect": "^29.7.0",
Expand Down Expand Up @@ -281,6 +279,8 @@
"worker": "./src/component-blocks/blank-for-react-server.tsx",
"react-server": "./src/component-blocks/blank-for-react-server.tsx",
"default": "./src/component-blocks/cloud-image-preview.tsx"
}
},
"#markdoc": "./src/markdoc.js",
"#base64": "./src/base64.ts"
}
}
67 changes: 36 additions & 31 deletions packages/keystatic/src/api/api-node.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from 'node:path';
import { z } from 'zod';
import * as s from 'superstruct';
import fs from 'node:fs/promises';
import { Config } from '../config';
import {
Expand All @@ -10,6 +10,7 @@ import {
import { readToDirEntries, getAllowedDirectories } from './read-local';
import { blobSha } from '../app/trees';
import { randomBytes } from 'node:crypto';
import { base64UrlDecode } from '#base64';

// this should be trivially dead code eliminated
// it's just to ensure the types are exactly the same between this and local-noop.ts
Expand All @@ -23,10 +24,10 @@ function _typeTest() {
let _d: typeof b = a;
}

const ghAppSchema = z.object({
slug: z.string(),
client_id: z.string(),
client_secret: z.string(),
const ghAppSchema = s.type({
slug: s.string(),
client_id: s.string(),
client_secret: s.string(),
});

const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
Expand Down Expand Up @@ -55,21 +56,21 @@ export async function handleGitHubAppCreation(
};
}
const ghAppDataRaw = await ghAppRes.json();

const ghAppDataResult = ghAppSchema.safeParse(ghAppDataRaw);

if (!ghAppDataResult.success) {
let ghAppDataResult;
try {
ghAppDataResult = s.create(ghAppDataRaw, ghAppSchema);
} catch {
console.log(ghAppDataRaw);
return {
status: 500,
body: 'An unexpected response was received from GitHub',
};
}
const toAddToEnv = `# Keystatic
KEYSTATIC_GITHUB_CLIENT_ID=${ghAppDataResult.data.client_id}
KEYSTATIC_GITHUB_CLIENT_SECRET=${ghAppDataResult.data.client_secret}
KEYSTATIC_GITHUB_CLIENT_ID=${ghAppDataResult.client_id}
KEYSTATIC_GITHUB_CLIENT_SECRET=${ghAppDataResult.client_secret}
KEYSTATIC_SECRET=${randomBytes(40).toString('hex')}
${slugEnvVarName ? `${slugEnvVarName}=${ghAppDataResult.data.slug}\n` : ''}`;
${slugEnvVarName ? `${slugEnvVarName}=${ghAppDataResult.slug}\n` : ''}`;

let prevEnv: string | undefined;
try {
Expand All @@ -80,9 +81,7 @@ ${slugEnvVarName ? `${slugEnvVarName}=${ghAppDataResult.data.slug}\n` : ''}`;
const newEnv = prevEnv ? `${prevEnv}\n\n${toAddToEnv}` : toAddToEnv;
await fs.writeFile('.env', newEnv);
await wait(200);
return redirect(
'/keystatic/created-github-app?slug=' + ghAppDataResult.data.slug
);
return redirect('/keystatic/created-github-app?slug=' + ghAppDataResult.slug);
}

export function localModeApiHandler(
Expand Down Expand Up @@ -165,6 +164,10 @@ async function blob(
return { status: 200, body: contents };
}

const base64Schema = s.coerce(s.instance(Uint8Array), s.string(), val =>
base64UrlDecode(val)
);

async function update(
req: KeystaticRequest,
config: Config,
Expand All @@ -177,24 +180,26 @@ async function update(
return { status: 400, body: 'Bad Request' };
}
const isFilepathValid = getIsPathValid(config);
const filepath = s.refine(s.string(), 'filepath', isFilepathValid);
let updates;

const updates = z
.object({
additions: z.array(
z.object({
path: z.string().refine(isFilepathValid),
contents: z.string().transform(x => Buffer.from(x, 'base64')),
})
),
deletions: z.array(
z.object({ path: z.string().refine(isFilepathValid) })
),
})
.safeParse(await req.json());
if (!updates.success) {
try {
updates = s.create(
await req.json(),
s.object({
additions: s.array(
s.object({
path: filepath,
contents: base64Schema,
})
),
deletions: s.array(s.object({ path: filepath })),
})
);
} catch {
return { status: 400, body: 'Bad data' };
}
for (const addition of updates.data.additions) {
for (const addition of updates.additions) {
await fs.mkdir(path.dirname(path.join(baseDirectory, addition.path)), {
recursive: true,
});
Expand All @@ -203,7 +208,7 @@ async function update(
addition.contents
);
}
for (const deletion of updates.data.deletions) {
for (const deletion of updates.deletions) {
await fs.rm(path.join(baseDirectory, deletion.path), { force: true });
}
return {
Expand Down
55 changes: 55 additions & 0 deletions packages/keystatic/src/api/encryption.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { base64UrlDecode, base64UrlEncode } from '#base64';

const encoder = new TextEncoder();
const decoder = new TextDecoder();

async function deriveKey(secret: string, salt: Uint8Array) {
if (secret.length < 32) {
throw new Error('KEYSTATIC_SECRET must be at least 32 characters long');
}
const encoded = encoder.encode(secret);
const key = await crypto.subtle.importKey('raw', encoded, 'HKDF', false, [
'deriveKey',
]);
return crypto.subtle.deriveKey(
{ name: 'HKDF', salt, hash: 'SHA-256', info: new Uint8Array(0) },
key,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}

const SALT_LENGTH = 16;
const IV_LENGTH = 12;

export async function encryptValue(value: string, secret: string) {
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const key = await deriveKey(secret, salt);
const encoded = encoder.encode(value);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoded
);
const full = new Uint8Array(SALT_LENGTH + IV_LENGTH + encrypted.byteLength);
full.set(salt);
full.set(iv, SALT_LENGTH);
full.set(new Uint8Array(encrypted), SALT_LENGTH + IV_LENGTH);
return base64UrlEncode(full);
}

export async function decryptValue(encrypted: string, secret: string) {
const decoded = base64UrlDecode(encrypted);
const salt = decoded.slice(0, SALT_LENGTH);
const key = await deriveKey(secret, salt);
const iv = decoded.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
const value = decoded.slice(SALT_LENGTH + IV_LENGTH);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
value
);
return decoder.decode(decrypted);
}
49 changes: 22 additions & 27 deletions packages/keystatic/src/api/generic.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import cookie from 'cookie';
import * as Iron from 'iron-webcrypto';
import z from 'zod';
import * as s from 'superstruct';
import { Config } from '..';
import {
KeystaticResponse,
Expand All @@ -10,6 +9,7 @@ import {
import { handleGitHubAppCreation, localModeApiHandler } from '#api-handler';
import { webcrypto } from '#webcrypto';
import { bytesToHex } from '../hex';
import { decryptValue, encryptValue } from './encryption';

export type APIRouteConfig = {
/** @default process.env.KEYSTATIC_GITHUB_CLIENT_ID */
Expand Down Expand Up @@ -181,13 +181,13 @@ export function makeGenericAPIRouteHandler(
};
}

const tokenDataResultType = z.object({
access_token: z.string(),
expires_in: z.number(),
refresh_token: z.string(),
refresh_token_expires_in: z.number(),
scope: z.string(),
token_type: z.literal('bearer'),
const tokenDataResultType = s.type({
access_token: s.string(),
expires_in: s.number(),
refresh_token: s.string(),
refresh_token_expires_in: s.number(),
scope: s.string(),
token_type: s.literal('bearer'),
});

async function githubOauthCallback(
Expand Down Expand Up @@ -231,12 +231,14 @@ async function githubOauthCallback(
return { status: 401, body: 'Authorization failed' };
}
const _tokenData = await tokenRes.json();
const tokenDataParseResult = tokenDataResultType.safeParse(_tokenData);
if (!tokenDataParseResult.success) {
let tokenData;
try {
tokenData = tokenDataResultType.create(_tokenData);
} catch {
return { status: 401, body: 'Authorization failed' };
}

const headers = await getTokenCookies(tokenDataParseResult.data, config);
const headers = await getTokenCookies(tokenData, config);
if (state === 'close') {
return {
headers: [...headers, ['Content-Type', 'text/html']],
Expand All @@ -248,7 +250,7 @@ async function githubOauthCallback(
}

async function getTokenCookies(
tokenData: z.infer<typeof tokenDataResultType>,
tokenData: s.Infer<typeof tokenDataResultType>,
config: InnerAPIRouteConfig
) {
const headers: [string, string][] = [
Expand All @@ -266,10 +268,7 @@ async function getTokenCookies(
'Set-Cookie',
cookie.serialize(
'keystatic-gh-refresh-token',
await Iron.seal(webcrypto, tokenData.refresh_token, config.secret, {
...Iron.defaults,
ttl: tokenData.refresh_token_expires_in * 1000,
}),
await encryptValue(tokenData.refresh_token, config.secret),
{
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
Expand All @@ -295,16 +294,10 @@ async function getRefreshToken(
if (!refreshTokenCookie) return;
let refreshToken;
try {
refreshToken = await Iron.unseal(
webcrypto,
refreshTokenCookie,
config.secret,
Iron.defaults
);
refreshToken = await decryptValue(refreshTokenCookie, config.secret);
} catch {
return;
}
if (typeof refreshToken !== 'string') return;
return refreshToken;
}

Expand Down Expand Up @@ -341,11 +334,13 @@ async function refreshGitHubAuth(
return;
}
const _tokenData = await tokenRes.json();
const tokenDataParseResult = tokenDataResultType.safeParse(_tokenData);
if (!tokenDataParseResult.success) {
let tokenData;
try {
tokenData = tokenDataResultType.create(_tokenData);
} catch {
return;
}
return getTokenCookies(tokenDataParseResult.data, config);
return getTokenCookies(tokenData, config);
}

async function githubRepoNotFound(
Expand Down
18 changes: 9 additions & 9 deletions packages/keystatic/src/app/ItemPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
useState,
} from 'react';
import * as Y from 'yjs';
import { z } from 'zod';
import * as s from 'superstruct';

import { ActionGroup, Item } from '@keystar/ui/action-group';
import { Badge } from '@keystar/ui/badge';
Expand Down Expand Up @@ -95,12 +95,12 @@ type ItemPageProps = {
basePath: string;
};

const storedValSchema = z.object({
version: z.literal(1),
savedAt: z.date(),
slug: z.string(),
beforeTreeKey: z.string(),
files: z.map(z.string(), z.instanceof(Uint8Array)),
const storedValSchema = s.type({
version: s.literal(1),
savedAt: s.date(),
slug: s.string(),
beforeTreeKey: s.string(),
files: s.map(s.string(), s.instance(Uint8Array)),
});

function ItemPageInner(
Expand Down Expand Up @@ -433,7 +433,7 @@ function LocalItemPage(
state,
});
const files = new Map(serialized.map(x => [x.path, x.contents]));
const data: z.infer<typeof storedValSchema> = {
const data: s.Infer<typeof storedValSchema> = {
beforeTreeKey: localTreeKey,
slug,
files,
Expand Down Expand Up @@ -837,7 +837,7 @@ function ItemPageWrapper(props: ItemPageWrapperProps) {
props.itemSlug,
]);
if (!raw) throw new Error('No draft found');
const stored = storedValSchema.parse(raw);
const stored = storedValSchema.create(raw);
const parsed = parseEntry(
{
config: props.config,
Expand Down
Loading
Loading