UNPKG

@frames.js/debugger

Version:

Debug your Frames.js applications

302 lines (260 loc) 7.22 kB
import Database from "better-sqlite3"; import type { NotificationsNamespace, RecordedEvent, StorageInterface, } from "../types"; import { serverEventSchema } from "@farcaster/frame-sdk"; import type { FrameNotificationDetails } from "@farcaster/frame-sdk"; import crypto from "node:crypto"; import path from "node:path"; import { z } from "zod"; import { sendNotificationRequestSchema } from "../parsers"; const NotificationsNamespaceSchema = z.object({ id: z.string(), fid: z.number(), frameAppUrl: z.string(), namespaceUrl: z.string(), webhookUrl: z.string(), frame: z.preprocess( (val) => { if (typeof val === "string") { return JSON.parse(val); } return val; }, z.discriminatedUnion("status", [ z.object({ status: z.literal("added"), notificationDetails: z.nullable( z.object({ url: z.string(), token: z.string(), }) ), }), z.object({ status: z.literal("removed"), }), ]) ), }); const RecordedEventSchema = z.preprocess( (val) => { if (typeof val === "string") { return JSON.parse(val); } return val; }, z.discriminatedUnion("type", [ z.object({ type: z.literal("notification"), notification: sendNotificationRequestSchema, id: z.string(), }), z.object({ type: z.literal("event"), event: serverEventSchema, id: z.string(), }), z.object({ type: z.literal("event_success"), event: serverEventSchema, id: z.string(), eventId: z.string(), }), z.object({ type: z.literal("event_failure"), event: serverEventSchema, id: z.string(), eventId: z.string(), message: z.string(), response: z.optional( z.object({ status: z.number(), headers: z.record(z.union([z.string(), z.array(z.string())])), body: z.string(), }) ), }), ]) ); const EventLogRowSchema = z.object({ id: z.string(), namespaceId: z.string(), event: RecordedEventSchema, }); let storage: SQLiteStorage | null = null; export async function getStorage(serverUrl: string): Promise<StorageInterface> { if (!storage) { const dbPath = path.resolve(process.cwd(), "./notifications.db"); const db = new Database(dbPath); db.exec(` CREATE TABLE IF NOT EXISTS notification_settings ( id TEXT PRIMARY KEY, fid INTEGER, frameAppUrl TEXT, namespaceUrl TEXT, webhookUrl TEXT, frame TEXT ); CREATE TABLE IF NOT EXISTS event_log ( id TEXT PRIMARY KEY, namespaceId TEXT, event TEXT ); `); storage = new SQLiteStorage(db, serverUrl); } return storage; } export class SQLiteStorage implements StorageInterface { private db: Database.Database; private serverUrl: string; constructor(db: Database.Database, serverUrl: string) { this.db = db; this.serverUrl = serverUrl; } async registerNamespace( id: string, params: { fid: number; frameAppUrl: string; webhookUrl: string; } ): Promise<NotificationsNamespace> { const namespaceUrl = new URL( `/notifications/${id}`, this.serverUrl ).toString(); const namespace: NotificationsNamespace = { id, fid: params.fid, frameAppUrl: params.frameAppUrl, namespaceUrl, webhookUrl: params.webhookUrl, frame: { status: "removed", }, }; const stmt = this.db.prepare(` INSERT INTO notification_settings (id, fid, frameAppUrl, namespaceUrl, webhookUrl, frame) VALUES (?, ?, ?, ?, ?, ?) `); stmt.run( id, params.fid, params.frameAppUrl, namespace.namespaceUrl, params.webhookUrl, JSON.stringify(namespace.frame) ); return namespace; } async getNamespace(id: string): Promise<NotificationsNamespace | null> { const stmt = this.db.prepare(` SELECT * FROM notification_settings WHERE id = ? `); const row = stmt.get(id); if (!row) { return null; } const namespace = NotificationsNamespaceSchema.parse(row); return namespace; } async addFrame( namespace: NotificationsNamespace ): Promise<FrameNotificationDetails> { if (namespace.frame.status === "added") { throw new Error("Frame is already added"); } const token = crypto.randomUUID(); const url = new URL( `/notifications/${namespace.id}/send`, this.serverUrl ).toString(); const notificationDetails: FrameNotificationDetails = { url, token, }; namespace.frame = { status: "added", notificationDetails, }; const stmt = this.db.prepare(` UPDATE notification_settings SET frame = ? WHERE id = ? `); stmt.run(JSON.stringify(namespace.frame), namespace.id); return notificationDetails; } async removeFrame(namespace: NotificationsNamespace): Promise<void> { if (namespace.frame.status === "removed") { throw new Error("Frame is already removed"); } namespace.frame = { status: "removed", }; const stmt = this.db.prepare(` UPDATE notification_settings SET frame = ? WHERE id = ? `); stmt.run(JSON.stringify(namespace.frame), namespace.id); } async enableNotifications( namespace: NotificationsNamespace ): Promise<FrameNotificationDetails> { if (namespace.frame.status === "removed") { throw new Error("Frame is not added"); } const token = crypto.randomUUID(); const url = new URL( `/notifications/${namespace.id}/send`, this.serverUrl ).toString(); const notificationDetails: FrameNotificationDetails = { token, url, }; namespace.frame.notificationDetails = notificationDetails; const stmt = this.db.prepare(` UPDATE notification_settings SET frame = ? WHERE id = ? `); stmt.run(JSON.stringify(namespace.frame), namespace.id); return notificationDetails; } async disableNotifications(namespace: NotificationsNamespace): Promise<void> { if (namespace.frame.status === "removed") { throw new Error("Frame is not added"); } namespace.frame.notificationDetails = null; const stmt = this.db.prepare(` UPDATE notification_settings SET frame = ? WHERE id = ? `); stmt.run(JSON.stringify(namespace.frame), namespace.id); } async recordEvent(namespaceId: string, event: RecordedEvent): Promise<void> { const stmt = this.db.prepare(` INSERT INTO event_log (id, namespaceId, event) VALUES (?, ?, ?) `); stmt.run(event.id, namespaceId, JSON.stringify(event)); } async listEvents(namespaceId: string): Promise<RecordedEvent[]> { const stmt = this.db.prepare(` SELECT * FROM event_log WHERE namespaceId = ? `); const rows = stmt.all(namespaceId); const deleteStmt = this.db.prepare(` DELETE FROM event_log WHERE namespaceId = ? `); deleteStmt.run(namespaceId); return rows.map((row) => EventLogRowSchema.parse(row).event); } }