Skip to content

Commit

Permalink
feat: add upstash redis cloud sync
Browse files Browse the repository at this point in the history
  • Loading branch information
Yidadaa committed Sep 18, 2023
1 parent 59fbadd commit 83fed42
Show file tree
Hide file tree
Showing 8 changed files with 137 additions and 12 deletions.
39 changes: 37 additions & 2 deletions app/components/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import Locale, {
} from "../locales";
import { copyToClipboard } from "../utils";
import Link from "next/link";
import { Path, RELEASE_URL, UPDATE_URL } from "../constant";
import { Path, RELEASE_URL, STORAGE_KEY, UPDATE_URL } from "../constant";
import { Prompt, SearchService, usePromptStore } from "../store/prompt";
import { ErrorBoundary } from "./error";
import { InputRange } from "./input-range";
Expand Down Expand Up @@ -413,7 +413,42 @@ function SyncConfigModal(props: { onClose?: () => void }) {

{syncStore.provider === ProviderType.UpStash && (
<List>
<ListItem title={Locale.WIP}></ListItem>
<ListItem title={Locale.Settings.Sync.Config.UpStash.Endpoint}>
<input
type="text"
value={syncStore.upstash.endpoint}
onChange={(e) => {
syncStore.update(
(config) =>
(config.upstash.endpoint = e.currentTarget.value),
);
}}
></input>
</ListItem>

<ListItem title={Locale.Settings.Sync.Config.UpStash.UserName}>
<input
type="text"
value={syncStore.upstash.username}
placeholder={STORAGE_KEY}
onChange={(e) => {
syncStore.update(
(config) =>
(config.upstash.username = e.currentTarget.value),
);
}}
></input>
</ListItem>
<ListItem title={Locale.Settings.Sync.Config.UpStash.Password}>
<PasswordInput
value={syncStore.upstash.apiKey}
onChange={(e) => {
syncStore.update(
(config) => (config.upstash.apiKey = e.currentTarget.value),
);
}}
></PasswordInput>
</ListItem>
</List>
)}
</Modal>
Expand Down
6 changes: 6 additions & 0 deletions app/locales/cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@ const cn = {
UserName: "用户名",
Password: "密码",
},

UpStash: {
Endpoint: "UpStash Redis REST Url",
UserName: "备份名称",
Password: "UpStash Redis REST Token",
},
},

LocalState: "本地数据",
Expand Down
6 changes: 6 additions & 0 deletions app/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ const en: LocaleType = {
UserName: "User Name",
Password: "Password",
},

UpStash: {
Endpoint: "UpStash Redis REST Url",
UserName: "Backup Name",
Password: "UpStash Redis REST Token",
},
},

LocalState: "Local Data",
Expand Down
4 changes: 2 additions & 2 deletions app/store/sync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Updater } from "../typing";
import { ApiPath, StoreKey } from "../constant";
import { ApiPath, STORAGE_KEY, StoreKey } from "../constant";
import { createPersistStore } from "../utils/store";
import {
AppState,
Expand Down Expand Up @@ -36,7 +36,7 @@ export const useSyncStore = createPersistStore(

upstash: {
endpoint: "",
username: "",
username: STORAGE_KEY,
apiKey: "",
},

Expand Down
74 changes: 68 additions & 6 deletions app/utils/cloud/upstash.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,87 @@
import { STORAGE_KEY } from "@/app/constant";
import { SyncStore } from "@/app/store/sync";
import { corsFetch } from "../cors";
import { chunks } from "../format";

export type UpstashConfig = SyncStore["upstash"];
export type UpStashClient = ReturnType<typeof createUpstashClient>;

export function createUpstashClient(config: UpstashConfig) {
export function createUpstashClient(store: SyncStore) {
const config = store.upstash;
const storeKey = config.username.length === 0 ? STORAGE_KEY : config.username;
const chunkCountKey = `${storeKey}-chunk-count`;
const chunkIndexKey = (i: number) => `${storeKey}-chunk-${i}`;

const proxyUrl =
store.useProxy && store.proxyUrl.length > 0 ? store.proxyUrl : undefined;

return {
async check() {
return true;
try {
const res = await corsFetch(this.path(`get/${storeKey}`), {
method: "GET",
headers: this.headers(),
proxyUrl,
});
console.log("[Upstash] check", res.status, res.statusText);
return [200].includes(res.status);
} catch (e) {
console.error("[Upstash] failed to check", e);
}
return false;
},

async redisGet(key: string) {
const res = await corsFetch(this.path(`get/${key}`), {
method: "GET",
headers: this.headers(),
proxyUrl,
});

console.log("[Upstash] get key = ", key, res.status, res.statusText);
const resJson = (await res.json()) as { result: string };

return resJson.result;
},

async redisSet(key: string, value: string) {
const res = await corsFetch(this.path(`set/${key}`), {
method: "POST",
headers: this.headers(),
body: value,
proxyUrl,
});

console.log("[Upstash] set key = ", key, res.status, res.statusText);
},

async get() {
throw Error("[Sync] not implemented");
const chunkCount = Number(await this.redisGet(chunkCountKey));
if (!Number.isInteger(chunkCount)) return;

const chunks = await Promise.all(
new Array(chunkCount)
.fill(0)
.map((_, i) => this.redisGet(chunkIndexKey(i))),
);
console.log("[Upstash] get full chunks", chunks);
return chunks.join("");
},

async set() {
throw Error("[Sync] not implemented");
async set(_: string, value: string) {
// upstash limit the max request size which is 1Mb for “Free” and “Pay as you go”
// so we need to split the data to chunks
let index = 0;
for await (const chunk of chunks(value)) {
await this.redisSet(chunkIndexKey(index), chunk);
index += 1;
}
await this.redisSet(chunkCountKey, index.toString());
},

headers() {
return {
Authorization: `Basic ${config.apiKey}`,
Authorization: `Bearer ${config.apiKey}`,
};
},
path(path: string) {
Expand Down
2 changes: 0 additions & 2 deletions app/utils/cloud/webdav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ export function createWebDavClient(store: SyncStore) {
headers: this.headers(),
proxyUrl,
});

console.log("[WebDav] check", res.status, res.statusText);

return [201, 200, 404, 401].includes(res.status);
} catch (e) {
console.error("[WebDav] failed to check", e);
Expand Down
15 changes: 15 additions & 0 deletions app/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,18 @@ export function prettyObject(msg: any) {
}
return ["```json", msg, "```"].join("\n");
}

export function* chunks(s: string, maxBytes = 1000 * 1000) {
const decoder = new TextDecoder("utf-8");
let buf = new TextEncoder().encode(s);
while (buf.length) {
let i = buf.lastIndexOf(32, maxBytes + 1);
// If no space found, try forward search
if (i < 0) i = buf.indexOf(32, maxBytes);
// If there's no space at all, take all
if (i < 0) i = buf.length;
// This is a safe cut-off point; never half-way a multi-byte
yield decoder.decode(buf.slice(0, i));
buf = buf.slice(i + 1); // Skip space (if any)
}
}
3 changes: 3 additions & 0 deletions app/utils/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ const MergeStates: StateMerger = {
localState.sessions.forEach((s) => (localSessions[s.id] = s));

remoteState.sessions.forEach((remoteSession) => {
// skip empty chats
if (remoteSession.messages.length === 0) return;

const localSession = localSessions[remoteSession.id];
if (!localSession) {
// if remote session is new, just merge it
Expand Down

0 comments on commit 83fed42

Please sign in to comment.