refactor(storage): use unstorage for session state

This commit is contained in:
2026-07-01 18:36:26 +08:00
parent 0251916a8a
commit ae6578923b
13 changed files with 398 additions and 246 deletions
@@ -35,34 +35,38 @@ describe("subscription exit helpers", () => {
peekPendingChatUnlockMock.mockReset();
});
it("uses explicit chat image return urls first", () => {
consumePendingChatImageReturnMock.mockReturnValue({
it("uses explicit chat image return urls first", async () => {
consumePendingChatImageReturnMock.mockResolvedValue({
reason: "image_paywall",
messageId: "msg_1",
returnUrl: "/chat/image/msg_1",
createdAt: 1,
});
expect(consumeSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1");
await expect(consumeSubscriptionExplicitExitUrl()).resolves.toBe(
"/chat/image/msg_1",
);
});
it("falls back to chat when requested", () => {
consumePendingChatImageReturnMock.mockReturnValue(null);
peekPendingChatUnlockMock.mockReturnValue(null);
it("falls back to chat when requested", async () => {
consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockResolvedValue(null);
expect(consumeSubscriptionExitUrl("chat")).toBe(ROUTES.chat);
await expect(consumeSubscriptionExitUrl("chat")).resolves.toBe(ROUTES.chat);
});
it("falls back to sidebar by default", () => {
consumePendingChatImageReturnMock.mockReturnValue(null);
peekPendingChatUnlockMock.mockReturnValue(null);
it("falls back to sidebar by default", async () => {
consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockResolvedValue(null);
expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.sidebar);
expect(consumeSubscriptionExitUrl(null)).toBe(ROUTES.sidebar);
await expect(consumeSubscriptionExitUrl(null)).resolves.toBe(
ROUTES.sidebar,
);
});
it("peeks chat unlock return urls without clearing them", () => {
peekPendingChatUnlockMock.mockReturnValue({
it("peeks chat unlock return urls without clearing them", async () => {
peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock",
messageId: "msg_1",
kind: "image",
@@ -71,13 +75,15 @@ describe("subscription exit helpers", () => {
createdAt: 1,
});
expect(peekSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1");
await expect(peekSubscriptionExplicitExitUrl()).resolves.toBe(
"/chat/image/msg_1",
);
expect(clearPendingChatUnlockMock).not.toHaveBeenCalled();
});
it("clears chat unlock return urls when consumed", () => {
consumePendingChatImageReturnMock.mockReturnValue(null);
peekPendingChatUnlockMock.mockReturnValue({
it("clears chat unlock return urls when consumed", async () => {
consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock",
messageId: "msg_1",
kind: "image",
@@ -86,7 +92,9 @@ describe("subscription exit helpers", () => {
createdAt: 1,
});
expect(consumeSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1");
await expect(consumeSubscriptionExplicitExitUrl()).resolves.toBe(
"/chat/image/msg_1",
);
expect(clearPendingChatUnlockMock).toHaveBeenCalledTimes(1);
});
});
+38 -39
View File
@@ -1,55 +1,54 @@
"use client";
const STORAGE_KEY = "cozsweet.chat.pendingImageReturn";
import { z } from "zod";
import { StorageKeys } from "@/data/storage/storage_keys";
import { Result, SessionAsyncUtil } from "@/utils";
const MAX_AGE_MS = 30 * 60 * 1000;
export interface PendingChatImageReturn {
reason: "image_paywall";
messageId: string;
returnUrl: string;
createdAt: number;
}
const PendingChatImageReturnSchema = z.object({
reason: z.literal("image_paywall"),
messageId: z.string().min(1),
returnUrl: z.string().min(1),
createdAt: z.number(),
});
export function savePendingChatImageReturn(input: {
export type PendingChatImageReturn = z.output<
typeof PendingChatImageReturnSchema
>;
export async function savePendingChatImageReturn(input: {
messageId: string;
returnUrl: string;
}): void {
if (typeof window === "undefined") return;
}): Promise<void> {
const payload: PendingChatImageReturn = {
reason: "image_paywall",
messageId: input.messageId,
returnUrl: input.returnUrl,
createdAt: Date.now(),
};
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
await SessionAsyncUtil.setJson(
StorageKeys.pendingChatImageReturn,
payload,
PendingChatImageReturnSchema,
);
}
export function consumePendingChatImageReturn(): PendingChatImageReturn | null {
if (typeof window === "undefined") return null;
const raw = window.sessionStorage.getItem(STORAGE_KEY);
window.sessionStorage.removeItem(STORAGE_KEY);
if (!raw) return null;
try {
const value = JSON.parse(raw) as Partial<PendingChatImageReturn>;
if (value.reason !== "image_paywall") return null;
if (typeof value.messageId !== "string" || value.messageId.length === 0) {
return null;
}
if (typeof value.returnUrl !== "string" || value.returnUrl.length === 0) {
return null;
}
if (typeof value.createdAt !== "number") return null;
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
return {
reason: value.reason,
messageId: value.messageId,
returnUrl: value.returnUrl,
createdAt: value.createdAt,
};
} catch {
return null;
}
export async function consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatImageReturn,
PendingChatImageReturnSchema,
);
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
if (Result.isErr(result)) return null;
return parsePendingChatImageReturn(result.data);
}
function parsePendingChatImageReturn(
value: PendingChatImageReturn | null,
): PendingChatImageReturn | null {
if (!value) return null;
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
return value;
}
+54 -75
View File
@@ -1,27 +1,38 @@
"use client";
const STORAGE_KEY = "cozsweet.chat.pendingUnlock";
import { z } from "zod";
import { StorageKeys } from "@/data/storage/storage_keys";
import { Result, SessionAsyncUtil } from "@/utils";
const MAX_AGE_MS = 30 * 60 * 1000;
export type PendingChatUnlockKind = "private" | "voice" | "image";
export type PendingChatUnlockStage = "auth" | "payment";
export interface PendingChatUnlock {
reason: "single_message_unlock";
messageId: string;
kind: PendingChatUnlockKind;
returnUrl: string;
stage: PendingChatUnlockStage;
createdAt: number;
}
const PendingChatUnlockSchema = z.object({
reason: z.literal("single_message_unlock"),
messageId: z.string().min(1),
kind: z.enum(["private", "voice", "image"]),
returnUrl: z
.string()
.min(1)
.refine(
(value) => value.startsWith("/") && !value.startsWith("//"),
"returnUrl must be an internal route",
),
stage: z.enum(["auth", "payment"]),
createdAt: z.number(),
});
export function savePendingChatUnlock(input: {
export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
export async function savePendingChatUnlock(input: {
messageId: string;
kind: PendingChatUnlockKind;
returnUrl: string;
stage: PendingChatUnlockStage;
}): void {
if (typeof window === "undefined") return;
}): Promise<void> {
const payload: PendingChatUnlock = {
reason: "single_message_unlock",
messageId: input.messageId,
@@ -30,80 +41,48 @@ export function savePendingChatUnlock(input: {
stage: input.stage,
createdAt: Date.now(),
};
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
await SessionAsyncUtil.setJson(
StorageKeys.pendingChatUnlock,
payload,
PendingChatUnlockSchema,
);
}
export function consumePendingChatUnlock(): PendingChatUnlock | null {
if (typeof window === "undefined") return null;
const raw = window.sessionStorage.getItem(STORAGE_KEY);
window.sessionStorage.removeItem(STORAGE_KEY);
if (!raw) return null;
return parsePendingChatUnlock(raw);
export async function consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatUnlock,
PendingChatUnlockSchema,
);
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
if (Result.isErr(result)) return null;
return parsePendingChatUnlock(result.data);
}
export function peekPendingChatUnlock(): PendingChatUnlock | null {
if (typeof window === "undefined") return null;
const raw = window.sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const value = parsePendingChatUnlock(raw);
export async function peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatUnlock,
PendingChatUnlockSchema,
);
if (Result.isErr(result)) return null;
const value = parsePendingChatUnlock(result.data);
if (!value) {
window.sessionStorage.removeItem(STORAGE_KEY);
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
}
return value;
}
export function hasPendingChatUnlock(): boolean {
return peekPendingChatUnlock() !== null;
export async function hasPendingChatUnlock(): Promise<boolean> {
return (await peekPendingChatUnlock()) !== null;
}
export function clearPendingChatUnlock(): void {
if (typeof window === "undefined") return;
window.sessionStorage.removeItem(STORAGE_KEY);
export async function clearPendingChatUnlock(): Promise<void> {
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
}
function parsePendingChatUnlock(raw: string): PendingChatUnlock | null {
try {
const value = JSON.parse(raw) as Partial<PendingChatUnlock>;
if (value.reason !== "single_message_unlock") return null;
if (typeof value.messageId !== "string" || value.messageId.length === 0) {
return null;
}
if (!isPendingChatUnlockKind(value.kind)) return null;
if (!isPendingChatUnlockStage(value.stage)) return null;
if (typeof value.returnUrl !== "string" || value.returnUrl.length === 0) {
return null;
}
if (!value.returnUrl.startsWith("/") || value.returnUrl.startsWith("//")) {
return null;
}
if (typeof value.createdAt !== "number") return null;
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
return {
reason: value.reason,
messageId: value.messageId,
kind: value.kind,
returnUrl: value.returnUrl,
stage: value.stage,
createdAt: value.createdAt,
};
} catch {
return null;
}
}
function isPendingChatUnlockKind(
value: unknown,
): value is PendingChatUnlockKind {
return value === "private" || value === "voice" || value === "image";
}
function isPendingChatUnlockStage(
value: unknown,
): value is PendingChatUnlockStage {
return value === "auth" || value === "payment";
function parsePendingChatUnlock(
value: PendingChatUnlock | null,
): PendingChatUnlock | null {
if (!value) return null;
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
return value;
}
+9 -9
View File
@@ -10,19 +10,19 @@ import {
export type SubscriptionReturnTo = "chat" | null;
export function consumeSubscriptionExplicitExitUrl(): string | null {
const pendingImageReturn = consumePendingChatImageReturn();
export async function consumeSubscriptionExplicitExitUrl(): Promise<string | null> {
const pendingImageReturn = await consumePendingChatImageReturn();
if (pendingImageReturn) return pendingImageReturn.returnUrl;
const pendingChatUnlock = peekPendingChatUnlock();
const pendingChatUnlock = await peekPendingChatUnlock();
if (pendingChatUnlock) {
clearPendingChatUnlock();
await clearPendingChatUnlock();
return pendingChatUnlock.returnUrl;
}
return null;
}
export function peekSubscriptionExplicitExitUrl(): string | null {
const pendingChatUnlock = peekPendingChatUnlock();
export async function peekSubscriptionExplicitExitUrl(): Promise<string | null> {
const pendingChatUnlock = await peekPendingChatUnlock();
if (pendingChatUnlock) return pendingChatUnlock.returnUrl;
return null;
}
@@ -33,11 +33,11 @@ export function getSubscriptionFallbackExitUrl(
return returnTo === "chat" ? ROUTES.chat : ROUTES.sidebar;
}
export function consumeSubscriptionExitUrl(
export async function consumeSubscriptionExitUrl(
returnTo: SubscriptionReturnTo,
): string {
): Promise<string> {
return (
consumeSubscriptionExplicitExitUrl() ??
(await consumeSubscriptionExplicitExitUrl()) ??
getSubscriptionFallbackExitUrl(returnTo)
);
}