frontend client for gemstone. decentralised workplace app

feat: use zod

serenity effbf7dd 8e7259fd

+22 -11
app/hooks/useWebSocket.ts
···
+
import type { ShardMessage } from "@/app/lib/types/messages";
+
import {
+
validateHistoryMessage,
+
validateNewMessage,
+
validateWsMessageString,
+
validateWsMessageType,
+
} from "@/app/lib/validators";
import { useEffect, useRef, useState } from "react";
export function useWebSocket(url: string) {
-
const [messages, setMessages] = useState<
-
{ text: string; timestamp: string }[]
-
>([]);
+
const [messages, setMessages] = useState<ShardMessage[]>([]);
const [isConnected, setIsConnected] = useState(false);
const ws = useRef<WebSocket | null>(null);
···
};
ws.current.onmessage = (event) => {
-
const data = JSON.parse(event.data);
+
const eventData = validateWsMessageString(event.data);
+
if (!eventData) return;
+
+
const data: unknown = JSON.parse(eventData);
+
const wsMessage = validateWsMessageType(data);
+
if (!wsMessage) return;
-
if (data.type === "shard/history") {
-
setMessages(data.messages);
-
} else if (data.type === "shard/message") {
-
setMessages((prev) => [
-
...prev,
-
{ text: data.text, timestamp: data.timestamp },
-
]);
+
if (wsMessage.type === "shard/history") {
+
const history = validateHistoryMessage(wsMessage);
+
if (!history) return;
+
if (history.messages) setMessages(history.messages);
+
} else {
+
const message = validateNewMessage(wsMessage);
+
if (!message) return;
+
setMessages((prev) => [...prev, message]);
}
};
+1 -2
app/index.tsx
···
import ChatComponent from "@/app/components/ChatComponent";
-
import { Text, View } from "react-native";
+
import { View } from "react-native";
export default function Index() {
return (
···
alignItems: "center",
}}
>
-
<Text>Edit app/index.tsx to edit this screen.</Text>
<ChatComponent />
</View>
);
+22
app/lib/types/messages.ts
···
+
import { z } from "zod";
+
+
export const websocketMessageSchema = z.object({
+
type: z.union([z.literal("shard/message"), z.literal("shard/history")]),
+
});
+
+
export type WebsocketMessage = z.infer<typeof websocketMessageSchema>;
+
+
export const shardMessageSchema = websocketMessageSchema.extend({
+
type: z.literal("shard/message"),
+
text: z.string(),
+
timestamp: z.coerce.date(),
+
});
+
+
export type ShardMessage = z.infer<typeof shardMessageSchema>;
+
+
export const historyMessageSchema = websocketMessageSchema.extend({
+
type: z.literal("shard/history"),
+
messages: z.optional(z.array(shardMessageSchema)),
+
});
+
+
export type HistoryMessage = z.infer<typeof historyMessageSchema>;
-39
app/lib/validator.ts
···
-
export function assertShardMessage(
-
json: unknown,
-
): asserts json is ShardMessage {
-
if (typeof json !== "object") {
-
throw new Error("not a js object");
-
}
-
-
const candidate = json as Record<string, unknown>;
-
-
if (candidate.type !== "shard/message") {
-
throw new Error("Invalid type");
-
}
-
-
if (typeof candidate.text !== "string") {
-
throw new Error("Invalid text");
-
}
-
-
const timestamp = new Date(candidate.timestamp as string);
-
-
if (!(timestamp instanceof Date)) {
-
throw new Error("Invalid timestamp");
-
}
-
}
-
-
// example. we will use zod in the future.
-
export const validateShardMessage = (json: unknown) => {
-
try {
-
assertShardMessage(json);
-
return { success: true, data: json };
-
} catch (e: unknown) {
-
return { error: e };
-
}
-
};
-
-
export interface ShardMessage {
-
type: "shard/message";
-
text: string;
-
timestamp: Date;
-
}
+64
app/lib/validators.ts
···
+
import {
+
historyMessageSchema,
+
shardMessageSchema,
+
websocketMessageSchema,
+
} from "@/app/lib/types/messages";
+
import { z } from "zod";
+
+
export const validateWsMessageString = (data: unknown) => {
+
const { success, error, data: message } = z.string().safeParse(data);
+
if (!success) {
+
console.error("Error decoding websocket message");
+
console.error(error);
+
return;
+
}
+
return message;
+
};
+
+
export const validateWsMessageType = (data: unknown) => {
+
const {
+
success: wsMessageSuccess,
+
error: wsMessageError,
+
data: wsMessage,
+
} = websocketMessageSchema.loose().safeParse(data);
+
if (!wsMessageSuccess) {
+
console.error(
+
"Error parsing websocket message. The data might be the wrong shape.",
+
);
+
console.error(wsMessageError);
+
return;
+
}
+
return wsMessage;
+
};
+
+
export const validateHistoryMessage = (data: unknown) => {
+
const {
+
success: historySuccess,
+
error: historyError,
+
data: history,
+
} = historyMessageSchema.safeParse(data);
+
if (!historySuccess) {
+
console.error(
+
"History message schema parsing failed. Did your type drift?",
+
);
+
console.error(historyError);
+
return;
+
}
+
return history;
+
};
+
+
export const validateNewMessage = (data: unknown) => {
+
const {
+
success: messageSuccess,
+
error: messageError,
+
data: message,
+
} = shardMessageSchema.safeParse(data);
+
if (!messageSuccess) {
+
console.error(
+
"New message schema parsing failed. Did your type drift?",
+
);
+
console.error(messageError);
+
return;
+
}
+
return message;
+
};
+2 -1
package.json
···
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
-
"react-native-worklets": "0.5.1"
+
"react-native-worklets": "0.5.1",
+
"zod": "^4.1.12"
},
"devDependencies": {
"@types/react": "~19.1.0",
+8
pnpm-lock.yaml
···
react-native-worklets:
specifier: 0.5.1
version: 0.5.1(@babel/core@7.28.4)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
+
zod:
+
specifier: ^4.1.12
+
version: 4.1.12
devDependencies:
'@types/react':
specifier: ~19.1.0
···
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+
+
zod@4.1.12:
+
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
snapshots:
···
zod: 3.25.76
zod@3.25.76: {}
+
+
zod@4.1.12: {}