diff --git a/package-lock.json b/package-lock.json index 93c9900..5d4d6c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,10 @@ "@fastify/cors": "^11.2.0", "@types/debug": "^4.1.13", "@xo-cash/engine": "file:../../engine", + "@xo-cash/types": "0.0.1", "better-sqlite3": "^12.10.0", "debug": "^4.4.3", + "dotenv": "^17.4.2", "fastify": "^5.8.5", "kysely": "^0.29.2", "zod": "^4.4.3" @@ -64,6 +66,34 @@ "vitest": "^4.0.17" } }, + "../../templates": { + "name": "@xo-cash/templates", + "version": "0.0.1", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@xo-cash/types": "0.0.1" + }, + "devDependencies": { + "@chalp/eslint-airbnb": "^1.3.0", + "@generalprotocols/cspell-dictionary": "^1.0.1", + "@stylistic/eslint-plugin": "^5.7.0", + "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/parser": "^8.53.1", + "@vitest/coverage-v8": "^4.0.17", + "@viz-kit/esbuild-analyzer": "^1.0.0", + "@xo-cash/eslint-config": "1.0.1", + "cspell": "^9.6.0", + "eslint": "^9.39.2", + "prettier": "^3.6.2", + "tsdown": "^0.20.0-beta.4", + "typedoc": "^0.28.16", + "typedoc-plugin-coverage": "^4.0.2", + "typescript": "^5.3.2", + "typescript-eslint": "^8.53.1", + "vitest": "^4.0.17" + } + }, "../engine": { "name": "@xo-cash/engine", "version": "0.0.1", @@ -103,6 +133,15 @@ "vitest": "^4.0.17" } }, + "node_modules/@bitauth/libauth": { + "version": "3.1.0-next.8", + "resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz", + "integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -721,6 +760,15 @@ "resolved": "../../engine", "link": true }, + "node_modules/@xo-cash/types": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@xo-cash/types/-/types-0.0.1.tgz", + "integrity": "sha512-BMwh2Y9+LqnTXYmdA7Nxi1NuK+AcsNWFoFGJVAvuY5TBfsbNIzWppjmrI2fAyj/RlSE3tATMxam+6CJb3RnDIA==", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -945,6 +993,18 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", diff --git a/package.json b/package.json index ba29b41..d5d22dc 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,10 @@ "@fastify/cors": "^11.2.0", "@types/debug": "^4.1.13", "@xo-cash/engine": "file:../../engine", + "@xo-cash/types": "0.0.1", "better-sqlite3": "^12.10.0", "debug": "^4.4.3", + "dotenv": "^17.4.2", "fastify": "^5.8.5", "kysely": "^0.29.2", "zod": "^4.4.3" diff --git a/src/index.ts b/src/index.ts index c9f4ad0..6239d6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,46 +2,100 @@ import Debug from "debug"; import { Engine } from "@xo-cash/engine"; import { Config } from "./services/config.js"; - import { Database } from "./services/database/database.js"; +import { MigrationService } from "./services/database/migrate.js"; +import { HTTPService } from "./services/http-router.js"; +import { OrderPaymentService } from "./services/order-payment-service.js"; +import type { OrderInvitationTracker } from "./services/order-invitation-tracker.js"; import { ItemsRoute } from "./routes/items.js"; import { OrdersRoute } from "./routes/orders.js"; -import { HTTPService } from "./services/http-router.js"; type VendingMachineDeps = { - config: Config; - httpService: HTTPService; - database: Database; - engine: Engine; -} + config: Config; + httpService: HTTPService; + database: Database; + engine: Engine; + orderPaymentService: OrderPaymentService; + trackers: Map; +}; export class VendingMachine { - static async from(config: Config) { - const debug = Debug("vending-machine"); + static async from(config: Config) { + const debug = Debug("vending-machine"); - const engine = await Engine.create(config.engine.mnemonic, { databasePath: config.engine.database.path }); - const database = new Database({ config: config.database, debug }); + debug("Config: %O", config); - // Create the routes - const routes = [ - new ItemsRoute({ database: database, engine: engine, debug }), - new OrdersRoute({ database: database, engine: engine, debug }), - ]; + debug("Creating engine"); + const engine = await Engine.create(config.engine.mnemonic, { + databaseFilename: config.engine.database.fileName, + databasePath: config.engine.database.path, + }); - // Create the HTTP service, passing in the routes and config. - const httpService = new HTTPService({ routes: [], config: config.server, debug }); + debug("Creating database"); + const database = new Database({ config: config.database, debug }); + debug("Creating migration service"); + const migrationService = new MigrationService(database); + debug("Migrating database to latest"); + await migrationService.migrateToLatest(); - return new VendingMachine({ config, httpService, database, engine }); - } - - private constructor(private readonly deps: VendingMachineDeps) {} - - public async start() { - await this.deps.httpService.start(); + debug("Creating trackers"); + const trackers = new Map(); + debug("Creating order payment service"); + const orderPaymentService = await OrderPaymentService.create({ + engine, + database, + config, + debug: debug.extend("orders"), + trackers, + }); + + debug("Creating routes"); + const routes = [ + new ItemsRoute({ database, engine, debug: debug.extend("items") }), + new OrdersRoute({ + database, + orderPaymentService, + syncServerUrl: config.syncServer.url, + debug: debug.extend("orders"), + }), + ]; + + debug("Creating HTTP service"); + const httpService = new HTTPService({ + routes, + config: config.server, + debug, + }); + + debug("Creating vending machine"); + return new VendingMachine({ + config, + httpService, + database, + engine, + orderPaymentService, + trackers, + }); + } + + private constructor(private readonly deps: VendingMachineDeps) {} + + public async start() { + await this.deps.httpService.start(); + } + + public async stop() { + for (const tracker of this.deps.trackers.values()) { + tracker.stop(); } + await this.deps.engine.stop(); + } } -VendingMachine.from(Config.fromEnv()).then((vendingMachine) => { - vendingMachine.start(); -}); +VendingMachine.from(Config.fromEnv()) + .then((vendingMachine) => vendingMachine.start()) + .catch((error) => { + console.error("Failed to start vending machine:", error); + process.exit(1); + }); diff --git a/src/routes/index.ts b/src/routes/index.ts index 4a43a25..69adf60 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,2 +1,2 @@ -export * from './items.js'; -export * from './orders.js'; \ No newline at end of file +export * from "./items.js"; +export * from "./orders.js"; diff --git a/src/routes/items.ts b/src/routes/items.ts index 345a1a1..297acee 100644 --- a/src/routes/items.ts +++ b/src/routes/items.ts @@ -1,73 +1,80 @@ -import type { Debugger as Debug } from 'debug'; -import type { RouteOptions, FastifyRequest, FastifyReply } from 'fastify'; -import type { Engine } from '@xo-cash/engine' -import type { Database } from '../services/database/database.js' +import type { Debugger as Debug } from "debug"; +import type { RouteOptions, FastifyRequest, FastifyReply } from "fastify"; +import type { Engine } from "@xo-cash/engine"; +import type { Database } from "../services/database/database.js"; -import { z } from 'zod'; +import { z } from "zod"; export type ItemsRouteDeps = { database: Database; engine: Engine; debug: Debug; -} +}; export class ItemsRoute { - public constructor(private readonly deps: ItemsRouteDeps) {} + public constructor(private readonly deps: ItemsRouteDeps) {} - public async getRoutes(): Promise> { - return [ - { - method: 'GET', - url: '/items', - handler: this.getItems.bind(this), - }, - { - method: 'GET', - url: '/items/:id', - handler: this.getItem.bind(this), - }, - ] + public async getRoutes(): Promise> { + return [ + { + method: "GET", + url: "/items", + handler: this.getItems.bind(this), + }, + { + method: "GET", + url: "/items/:id", + handler: this.getItem.bind(this), + }, + ]; + } + + /** + * Get all items from the database + * @param request + * @param reply + * @returns + */ + private async getItems(request: FastifyRequest, reply: FastifyReply) { + // Get all items from the database. + const items = await this.deps.database.db + .selectFrom("items") + .selectAll() + .execute(); + + // Return the items. + return reply.send(items); + } + + /** + * Get an item from the database by id + * @param request + * @param reply + * @returns + */ + private async getItem(request: FastifyRequest, reply: FastifyReply) { + // Parse the request parameters. + const { id } = ItemsRoute.getItemSchema.parse(request.params); + + // Get the item from the database. + const item = await this.deps.database.db + .selectFrom("items") + .where("id", "=", id) + .selectAll() + .executeTakeFirst(); + + // If the item is not found, return a 404 error. + if (!item) { + return reply.status(404).send({ + error: "Item not found", + }); } - /** - * Get all items from the database - * @param request - * @param reply - * @returns - */ - private async getItems(request: FastifyRequest, reply: FastifyReply) { - // Get all items from the database. - const items = await this.deps.database.db.selectFrom('items').selectAll().execute(); + // Return the item. + return reply.send(item); + } - // Return the items. - return reply.send(items); - } - - /** - * Get an item from the database by id - * @param request - * @param reply - * @returns - */ - private async getItem(request: FastifyRequest, reply: FastifyReply) { - // Parse the request parameters. - const { id } = ItemsRoute.getItemSchema.parse(request.params); - - // Get the item from the database. - const item = await this.deps.database.db.selectFrom('items').where('id', '=', id).selectAll().executeTakeFirst(); - - // If the item is not found, return a 404 error. - if (!item) { - return reply.status(404).send({ - error: 'Item not found' - }); - } - - // Return the item. - return reply.send(item); - } - - static getItemSchema = z.object({ - id: z.string(), - }); -} \ No newline at end of file + static getItemSchema = z.object({ + id: z.string(), + }); +} diff --git a/src/routes/orders.ts b/src/routes/orders.ts index c22d9d0..ed35f47 100644 --- a/src/routes/orders.ts +++ b/src/routes/orders.ts @@ -1,88 +1,107 @@ -import type { Debugger as Debug } from 'debug'; -import type { RouteOptions, FastifyRequest, FastifyReply } from 'fastify'; -import type { Engine } from '@xo-cash/engine' -import type { Database } from '../services/database/database.js' +import type { Debugger as Debug } from "debug"; +import type { RouteOptions, FastifyRequest, FastifyReply } from "fastify"; -import { z } from 'zod'; +import { z } from "zod"; + +import type { Database } from "../services/database/database.js"; +import { + OrderPaymentError, + OrderPaymentService, +} from "../services/order-payment-service.js"; export type OrdersRouteDeps = { database: Database; - engine: Engine + orderPaymentService: OrderPaymentService; + syncServerUrl: string; debug: Debug; -} +}; export class OrdersRoute { - public constructor(private readonly deps: OrdersRouteDeps) {} + public constructor(private readonly deps: OrdersRouteDeps) {} - public async getRoutes(): Promise> { - return [ - { - method: 'GET', - url: '/orders', - handler: this.getOrders.bind(this), - }, - { - method: 'POST', - url: '/orders', - handler: this.createOrder.bind(this), - }, - ] + public async getRoutes(): Promise> { + return [ + { + method: "GET", + url: "/orders", + handler: this.getOrders.bind(this), + }, + { + method: "GET", + url: "/orders/:id", + handler: this.getOrder.bind(this), + }, + { + method: "POST", + url: "/orders", + handler: this.createOrder.bind(this), + }, + ]; + } + + private async getOrders(_request: FastifyRequest, reply: FastifyReply) { + const orders = await this.deps.database.db + .selectFrom("orders") + .selectAll() + .execute(); + + return reply.send( + orders.map((order) => ({ + ...order, + items: JSON.parse(order.items), + })), + ); + } + + private async getOrder(request: FastifyRequest, reply: FastifyReply) { + const { id } = OrdersRoute.getOrderSchema.parse(request.params); + + const order = await this.deps.database.db + .selectFrom("orders") + .selectAll() + .where("id", "=", id) + .executeTakeFirst(); + + if (!order) { + return reply.status(404).send({ error: "Order not found" }); } - private async getOrders(request: FastifyRequest, reply: FastifyReply) { - // Get all orders from the database. - const orders = await this.deps.database.db.selectFrom('orders').selectAll().execute(); - - // Return the orders. - return reply.send(orders); - } - - private async createOrder(request: FastifyRequest, reply: FastifyReply) { - // Parse the request body. - const { items: itemsInput } = OrdersRoute.createOrderSchema.parse(request.body); - - // Get the items from the database. - const items = await this.deps.database.db.selectFrom('items').where('id', 'in', itemsInput.map((item) => item.id)).selectAll().execute(); - - // If the items are not found, return a 404 error. - if (items.length !== items.length) { - return reply.status(404).send({ - error: 'Items not found' - }); - } - - // TODO: Create an XO Engine Invitation with the relavent data in it so we can pass it back to the client. - - // Create the order in the database. - const order = await this.deps.database.db.insertInto('orders').values({ - // user_id: request.user.id, - status: 'pending', - total_price: 0, - total_quantity: 0, - items: JSON.stringify(items.map((item) => ({ - id: item.id, - quantity: item.quantity, - }))), - }).execute(); - - // If the order is not created, return a 500 error. - if (!order) { - return reply.status(500).send({ - error: 'Failed to create order' - }); - } - - // Return the order. - return reply.send(order); - } - - /** - * Schema for creating an order. - */ - static createOrderSchema = z.object({ - items: z.array(z.object({ - id: z.string(), - quantity: z.number(), - })), + return reply.send({ + ...order, + items: JSON.parse(order.items), + syncServerUrl: this.deps.syncServerUrl, }); -} \ No newline at end of file + } + + private async createOrder(request: FastifyRequest, reply: FastifyReply) { + const { items: itemsInput } = OrdersRoute.createOrderSchema.parse( + request.body, + ); + + try { + const result = + await this.deps.orderPaymentService.createOrder(itemsInput); + return reply.status(201).send(result); + } catch (error) { + if (error instanceof OrderPaymentError) { + return reply.status(error.statusCode).send({ error: error.message }); + } + + this.deps.debug("Failed to create order: %o", error); + return reply.status(500).send({ error: "Failed to create order" }); + } + } + + static getOrderSchema = z.object({ + id: z.string(), + }); + + static createOrderSchema = z.object({ + items: z.array( + z.object({ + id: z.string(), + quantity: z.number().int().positive(), + }), + ), + }); +} diff --git a/src/services/config.ts b/src/services/config.ts index 242353d..2adf704 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -1,20 +1,26 @@ +import "dotenv/config"; import { z } from "zod"; const configSchema = z.object({ engine: z.object({ - mnemonic: z.string(), + mnemonic: z.string().min(1, "ENGINE_MNEMONIC is required"), database: z.object({ - path: z.string().default("data/engine"), + path: z.string().default("./data/xo"), + fileName: z.string().default("engine.db"), }), }), syncServer: z.object({ - url: z.string().default("http://localhost:3000"), + url: z.string().default("https://sync.xo.harvmaster.com"), + }), + // TODO: Remove merchant - eww. + merchant: z.object({ + name: z.string().default("XO Snack Machine"), }), database: z.object({ path: z.string().default("data.db"), }), server: z.object({ - port: z.number().default(3000), + port: z.coerce.number().default(3000), host: z.string().default("0.0.0.0"), cors: z .object({ @@ -34,28 +40,33 @@ const configSchema = z.object({ type ConfigInput = z.input; type ConfigSchema = z.output; -/** - * Converts an object's keys to camelCase. - * @param obj - The object to convert to camelCase. - * @returns The camelCase object. - */ -const toCamelCaseObject = (obj: Record): Record => { - return Object.fromEntries(Object.entries(obj).map(([key, value]) => { - const camelCaseKey = key.toLowerCase().replace(/([-_][a-z])/g, (group) => group.toUpperCase().replace("-", "").replace("_", "")); - return [camelCaseKey, value]; - })); -} - /** * The Config class is used to load and parse the configuration for the vending machine. */ export class Config { static fromEnv(): Config { - // Parse through process.env, and convert the upperCase keys to camelCase. - const envConfig = toCamelCaseObject(Object(process.env)); - - // Parse the environment config. - return this.from(configSchema.parse(envConfig)); + return this.from({ + engine: { + mnemonic: process.env.ENGINE_MNEMONIC ?? "", + database: { + path: process.env.ENGINE_DATABASE_PATH, + }, + }, + syncServer: { + url: process.env.SYNC_SERVER_URL, + }, + // TODO: Remove merchant - eww. + merchant: { + name: process.env.MERCHANT_NAME, + }, + database: { + path: process.env.DATABASE_PATH, + }, + server: { + port: process.env.SERVER_PORT, + host: process.env.SERVER_HOST, + }, + }); } static from(config: ConfigInput): Config { @@ -66,6 +77,11 @@ export class Config { return this.config.syncServer; } + // TODO: Remove merchant - eww. + public get merchant() { + return this.config.merchant; + } + public get engine() { return this.config.engine; } diff --git a/src/services/database/database.ts b/src/services/database/database.ts index 894ef98..fd9ff5b 100644 --- a/src/services/database/database.ts +++ b/src/services/database/database.ts @@ -37,7 +37,7 @@ export class Database { public constructor(options: DatabaseOptionsInput) { // Parse the options with the zod schema. const { config, debug } = databaseOptionsSchema.parse(options); - + // Extend the debug instance. this.debug = debug.extend("database"); @@ -48,7 +48,9 @@ export class Database { this.configurePragmas(); // Create the Kysely database client. - this.kysely = new Kysely({ dialect: new SqliteDialect({ database: this.sqlite }) }); + this.kysely = new Kysely({ + dialect: new SqliteDialect({ database: this.sqlite }), + }); } /** diff --git a/src/services/database/migrations/001-initial-schema.ts b/src/services/database/migrations/001-initial-schema.ts index 8ac6918..29f3e20 100644 --- a/src/services/database/migrations/001-initial-schema.ts +++ b/src/services/database/migrations/001-initial-schema.ts @@ -51,8 +51,12 @@ export async function up(db: Kysely): Promise { .addColumn("username", "text", (col) => col.notNull().unique()) .addColumn("password", "text", (col) => col.notNull()) .addColumn("salt", "text", (col) => col.notNull()) - .addColumn("created_at", "integer", (col) => col.notNull().defaultTo(millisecondTime)) - .addColumn("updated_at", "integer", (col) => col.notNull().defaultTo(millisecondTime)) + .addColumn("created_at", "integer", (col) => + col.notNull().defaultTo(millisecondTime), + ) + .addColumn("updated_at", "integer", (col) => + col.notNull().defaultTo(millisecondTime), + ) .execute(); // ------------------------------------------------------------------------- @@ -70,8 +74,12 @@ export async function up(db: Kysely): Promise { .addColumn("quantity", "integer", (col) => col.notNull().defaultTo(0)) // URL or path to the product image shown in the UI. .addColumn("image", "text", (col) => col.notNull().defaultTo("")) - .addColumn("created_at", "integer", (col) => col.notNull().defaultTo(millisecondTime)) - .addColumn("updated_at", "integer", (col) => col.notNull().defaultTo(millisecondTime)) + .addColumn("created_at", "integer", (col) => + col.notNull().defaultTo(millisecondTime), + ) + .addColumn("updated_at", "integer", (col) => + col.notNull().defaultTo(millisecondTime), + ) .addCheckConstraint("items_price_check", sql`price >= 0`) .addCheckConstraint("items_quantity_check", sql`quantity >= 0`) .execute(); @@ -88,8 +96,12 @@ export async function up(db: Kysely): Promise { .addColumn("total_quantity", "integer", (col) => col.notNull().defaultTo(0)) // JSON array of { id, quantity } objects; serialized by application code. .addColumn("items", "text", (col) => col.notNull().defaultTo("[]")) - .addColumn("created_at", "integer", (col) => col.notNull().defaultTo(millisecondTime)) - .addColumn("updated_at", "integer", (col) => col.notNull().defaultTo(millisecondTime)) + .addColumn("created_at", "integer", (col) => + col.notNull().defaultTo(millisecondTime), + ) + .addColumn("updated_at", "integer", (col) => + col.notNull().defaultTo(millisecondTime), + ) .addCheckConstraint( "orders_status_check", sql`status in ('pending', 'paid', 'completed', 'cancelled')`, @@ -102,7 +114,11 @@ export async function up(db: Kysely): Promise { // Indexes // ------------------------------------------------------------------------- // Look up catalog entries by display name. - await db.schema.createIndex("idx_items_name").on("items").column("name").execute(); + await db.schema + .createIndex("idx_items_name") + .on("items") + .column("name") + .execute(); // Filter orders by lifecycle state (e.g. pending payments). await db.schema @@ -124,7 +140,8 @@ export async function up(db: Kysely): Promise { // Keep updated_at in sync without requiring every query to set it explicitly. for (const tableName of UPDATED_AT_TRIGGER_TABLES) { await sql - .raw(` + .raw( + ` CREATE TRIGGER trg_${tableName}_updated_at AFTER UPDATE ON ${tableName} FOR EACH ROW @@ -133,7 +150,8 @@ export async function up(db: Kysely): Promise { SET updated_at = ${millisecondTimeRaw} WHERE id = NEW.id; END; - `) + `, + ) .execute(db); } } @@ -144,7 +162,9 @@ export async function up(db: Kysely): Promise { export async function down(db: Kysely): Promise { // Remove triggers before dropping the tables they reference. for (const tableName of UPDATED_AT_TRIGGER_TABLES) { - await sql.raw(`DROP TRIGGER IF EXISTS trg_${tableName}_updated_at`).execute(db); + await sql + .raw(`DROP TRIGGER IF EXISTS trg_${tableName}_updated_at`) + .execute(db); } await db.schema.dropTable("orders").ifExists().execute(); diff --git a/src/services/database/migrations/002-order-invitation-and-seed.ts b/src/services/database/migrations/002-order-invitation-and-seed.ts new file mode 100644 index 0000000..cfb7465 --- /dev/null +++ b/src/services/database/migrations/002-order-invitation-and-seed.ts @@ -0,0 +1,74 @@ +import { Kysely, sql } from "kysely"; + +import type { Database } from "../tables.js"; + +/** + * Adds invitation tracking to orders and seeds sample catalog items. + */ +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("orders") + .addColumn("invitation_identifier", "text") + .execute(); + + await db.schema + .createIndex("idx_orders_invitation_identifier") + .on("orders") + .column("invitation_identifier") + .execute(); + + const seedItems = [ + { + name: "Cola", + description: "Classic cola drink", + price: 1000, + quantity: 10, + image: "", + }, + { + name: "Chips", + description: "Salted potato chips", + price: 1500, + quantity: 8, + image: "", + }, + { + name: "Water", + description: "Still spring water", + price: 800, + quantity: 15, + image: "", + }, + { + name: "Chocolate Bar", + description: "Milk chocolate bar", + price: 1200, + quantity: 12, + image: "", + }, + ]; + + for (const item of seedItems) { + const existing = await db + .selectFrom("items") + .select("id") + .where("name", "=", item.name) + .executeTakeFirst(); + + if (!existing) { + await db.insertInto("items").values(item).execute(); + } + } +} + +/** + * Removes invitation column and seed is left in place. + */ +export async function down(db: Kysely): Promise { + await sql`DROP INDEX IF EXISTS idx_orders_invitation_identifier`.execute(db); + + await db.schema + .alterTable("orders") + .dropColumn("invitation_identifier") + .execute(); +} diff --git a/src/services/database/tables.ts b/src/services/database/tables.ts index c29c15d..342d5d7 100644 --- a/src/services/database/tables.ts +++ b/src/services/database/tables.ts @@ -7,7 +7,11 @@ import type { ColumnType, Generated } from "kysely"; * The application stores timestamps as numbers and may provide explicit * values or rely on database defaults. */ -export type Timestamp = ColumnType; +export type Timestamp = ColumnType< + number, + number | undefined, + number | undefined +>; /** * SQLite JSON column represented as TEXT. @@ -15,12 +19,20 @@ export type Timestamp = ColumnType; +export type JsonText = ColumnType< + string, + string | undefined, + string | undefined +>; /** * SQLite boolean emulation represented as INTEGER (0/1). */ -export type SqliteBoolean = ColumnType; +export type SqliteBoolean = ColumnType< + number, + number | boolean | undefined, + number | boolean | undefined +>; /** * Users table. @@ -57,6 +69,7 @@ export interface OrdersTable { total_price: number; total_quantity: number; items: JsonText; + invitation_identifier: string | null; created_at: Generated; updated_at: Generated; } diff --git a/src/services/http-router.ts b/src/services/http-router.ts index 5cf1d0c..2d60cb9 100644 --- a/src/services/http-router.ts +++ b/src/services/http-router.ts @@ -19,15 +19,23 @@ export const apiRoutesSchema = z.array(z.custom()); export const serverConfigSchema = z.object({ port: z.number().default(3000), host: z.string().default("0.0.0.0"), - cors: z.object({ - origin: z.string().default("*"), - methods: z.array(z.string()).default(["GET", "POST", "PUT", "DELETE", "OPTIONS"]), - allowedHeaders: z.array(z.string()).default(["Content-Type", "Authorization"]), - }).prefault({}), + cors: z + .object({ + origin: z.string().default("*"), + methods: z + .array(z.string()) + .default(["GET", "POST", "PUT", "DELETE", "OPTIONS"]), + allowedHeaders: z + .array(z.string()) + .default(["Content-Type", "Authorization"]), + }) + .prefault({}), }); // Zod schema for the server debug instance. -export const serverDebugSchema = debuggerSchema.optional().default(Debug("vending-machine")); +export const serverDebugSchema = debuggerSchema + .optional() + .default(Debug("vending-machine")); // Zod schema for the HTTP service options. export const HTTPOptions = z.object({ @@ -86,7 +94,7 @@ export class HTTPService { // Allow CORS requests. This allows requests from any origin/domain. // TODO: Set this to a meaningful value. For now, we allow all origins since we dont know what the origin will bes. - await this.server.register(cors); + await this.server.register(cors, this.config.cors); // Register your routes here before starting the server this.server.get("/health", async () => { diff --git a/src/services/invitation-sync-client.ts b/src/services/invitation-sync-client.ts new file mode 100644 index 0000000..d2f20ae --- /dev/null +++ b/src/services/invitation-sync-client.ts @@ -0,0 +1,164 @@ +import type { XOInvitation } from "@xo-cash/types"; +import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; + +import { SSESession, type SSEvent } from "../utils/sse-session.js"; + +export type InvitationSyncListeners = { + onInvitationUpdated?: (invitation: XOInvitation) => void; + onConnected?: () => void; + onError?: (error: Error) => void; + onDisconnected?: () => void; +}; + +/** + * Live SSE subscription for a single invitation. + */ +export class InvitationSyncSubscription { + private sse: SSESession; + + constructor( + public readonly invitationIdentifier: string, + baseUrl: string, + private readonly listeners: InvitationSyncListeners, + ) { + const url = `${baseUrl.replace(/\/$/, "")}/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`; + + this.sse = new SSESession(url, { + headers: { Accept: "text/event-stream" }, + onConnected: () => this.listeners.onConnected?.(), + onDisconnected: () => this.listeners.onDisconnected?.(), + onError: (error) => + this.listeners.onError?.( + error instanceof Error ? error : new Error(String(error)), + ), + onMessage: (event) => this.handleMessage(event), + }); + } + + async connect(): Promise { + await this.sse.connect(); + } + + close(): void { + this.sse.close(); + } + + private handleMessage(event: SSEvent): void { + const invitation = parseInvitationFromSseEvent(event); + if ( + invitation && + invitation.invitationIdentifier === this.invitationIdentifier + ) { + this.listeners.onInvitationUpdated?.(invitation); + } + } +} + +/** + * Generic client for the 3rd-party invitation sync server protocol. + */ +export class InvitationSyncClient { + constructor(private readonly baseUrl: string) {} + + get url(): string { + return this.baseUrl; + } + + /** + * Stateless POST — publish invitation to sync server. + */ + async publish(invitation: XOInvitation): Promise { + const response = await fetch(`${this.normalizedBaseUrl()}/invitations`, { + method: "POST", + body: serializeInvitation(invitation), + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + throw new Error( + `Failed to publish invitation: ${response.status} ${response.statusText}`, + ); + } + + return deserializeInvitation(await response.text()); + } + + /** + * Stateless GET — fetch current invitation snapshot. + */ + async fetch(invitationIdentifier: string): Promise { + const response = await fetch( + `${this.normalizedBaseUrl()}/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`, + { headers: { Accept: "application/json" } }, + ); + + if (response.status === 404) { + return undefined; + } + + if (!response.ok) { + throw new Error( + `Failed to fetch invitation: ${response.status} ${response.statusText}`, + ); + } + + return deserializeInvitation(await response.text()); + } + + /** + * Subscribe to live updates for one invitation (one SSE session). + */ + subscribe( + invitationIdentifier: string, + listeners: InvitationSyncListeners, + ): InvitationSyncSubscription { + return new InvitationSyncSubscription( + invitationIdentifier, + this.baseUrl, + listeners, + ); + } + + private normalizedBaseUrl(): string { + return this.baseUrl.replace(/\/$/, ""); + } +} + +/** + * Parse invitation from SSE event payloads (supports xo-cli wrapped and direct formats). + */ +function parseInvitationFromSseEvent(event: SSEvent): XOInvitation | undefined { + if (!event.data) { + return undefined; + } + + try { + if (event.event === "invitation-updated") { + const parsed = JSON.parse(event.data) as unknown; + if ( + parsed && + typeof parsed === "object" && + "invitationIdentifier" in parsed + ) { + return parsed as XOInvitation; + } + if ( + parsed && + typeof parsed === "object" && + "topic" in parsed && + "data" in parsed + ) { + return (parsed as { data: XOInvitation }).data; + } + } + + const parsed = JSON.parse(event.data) as { topic?: string; data?: unknown }; + if (parsed.topic === "invitation-updated" && parsed.data) { + return parsed.data as XOInvitation; + } + } catch { + return undefined; + } + + return undefined; +} diff --git a/src/services/order-invitation-tracker.ts b/src/services/order-invitation-tracker.ts new file mode 100644 index 0000000..ca002d2 --- /dev/null +++ b/src/services/order-invitation-tracker.ts @@ -0,0 +1,192 @@ +import type { Debugger } from "debug"; +import type { Engine } from "@xo-cash/engine"; +import type { XOInvitation, XOInvitationCommit } from "@xo-cash/types"; + +import type { Database } from "./database/database.js"; +import { InvitationSyncClient } from "./invitation-sync-client.js"; + +export type OrderInvitationTrackerDeps = { + syncClient: InvitationSyncClient; + engine: Engine; + database: Database; + orderId: string; + invitation: XOInvitation; + debug: Debugger; +}; + +/** + * Tracks a single order's invitation via the external sync server. + * Transitions order status: pending → paid → completed (mock dispense). + */ +export class OrderInvitationTracker { + private subscription: ReturnType | null = + null; + private dispenseTimer: ReturnType | null = null; + private initialCommitCount: number; + private stopped = false; + + private constructor(private readonly deps: OrderInvitationTrackerDeps) { + this.initialCommitCount = deps.invitation.commits?.length ?? 0; + } + + static async start( + deps: OrderInvitationTrackerDeps, + ): Promise { + const tracker = new OrderInvitationTracker(deps); + + try { + await deps.syncClient.publish(deps.invitation); + } catch (error) { + deps.debug( + "Failed to publish invitation to sync server (continuing): %o", + error, + ); + } + + tracker.subscription = deps.syncClient.subscribe( + deps.invitation.invitationIdentifier, + { + onInvitationUpdated: (invitation) => { + void tracker.handleUpdate(invitation); + }, + onError: (error) => { + deps.debug( + "Sync subscription error for order %s: %o", + deps.orderId, + error, + ); + }, + }, + ); + + await tracker.subscription.connect(); + return tracker; + } + + stop(): void { + this.stopped = true; + this.subscription?.close(); + this.subscription = null; + + if (this.dispenseTimer) { + clearTimeout(this.dispenseTimer); + this.dispenseTimer = null; + } + } + + private async handleUpdate(invitation: XOInvitation): Promise { + if (this.stopped) { + return; + } + + const mergedCommits = mergeCommits( + this.deps.invitation.commits ?? [], + invitation.commits ?? [], + ); + this.deps.invitation = { + ...this.deps.invitation, + ...invitation, + commits: mergedCommits, + }; + + const order = await this.deps.database.db + .selectFrom("orders") + .selectAll() + .where("id", "=", this.deps.orderId) + .executeTakeFirst(); + + if ( + !order || + order.status === "completed" || + order.status === "cancelled" + ) { + this.stop(); + return; + } + + const hasCustomerActivity = mergedCommits.length > this.initialCommitCount; + + if (order.status === "pending" && hasCustomerActivity) { + await this.deps.database.db + .updateTable("orders") + .set({ status: "paid" }) + .where("id", "=", this.deps.orderId) + .execute(); + + this.deps.debug("Order %s marked paid", this.deps.orderId); + + this.dispenseTimer = setTimeout(() => { + void this.completeOrder(); + }, 1500); + } + } + + private async completeOrder(): Promise { + if (this.stopped) { + return; + } + + const order = await this.deps.database.db + .selectFrom("orders") + .selectAll() + .where("id", "=", this.deps.orderId) + .executeTakeFirst(); + + if (!order || order.status === "completed") { + this.stop(); + return; + } + + await this.deps.database.db + .updateTable("orders") + .set({ status: "completed" }) + .where("id", "=", this.deps.orderId) + .execute(); + + this.deps.debug("Order %s mock dispensed (completed)", this.deps.orderId); + + try { + const lineItems = JSON.parse(order.items) as Array<{ + id: string; + quantity: number; + }>; + for (const line of lineItems) { + const item = await this.deps.database.db + .selectFrom("items") + .selectAll() + .where("id", "=", line.id) + .executeTakeFirst(); + + if (item) { + await this.deps.database.db + .updateTable("items") + .set({ quantity: Math.max(0, item.quantity - line.quantity) }) + .where("id", "=", line.id) + .execute(); + } + } + } catch (error) { + this.deps.debug( + "Failed to decrement stock for order %s: %o", + this.deps.orderId, + error, + ); + } + + this.stop(); + } +} + +function mergeCommits( + initial: XOInvitationCommit[], + additional: XOInvitationCommit[], +): XOInvitationCommit[] { + const map = new Map(); + for (const commit of initial) { + map.set(commit.commitIdentifier, commit); + } + for (const commit of additional) { + map.set(commit.commitIdentifier, commit); + } + return Array.from(map.values()); +} diff --git a/src/services/order-payment-service.ts b/src/services/order-payment-service.ts new file mode 100644 index 0000000..11579c8 --- /dev/null +++ b/src/services/order-payment-service.ts @@ -0,0 +1,252 @@ +import type { Debugger } from "debug"; +import type { Engine } from "@xo-cash/engine"; +import { serializeInvitation } from "@xo-cash/engine"; +import { vendingMachineTemplate } from "../templates/vending-machine.js"; + +import type { Config } from "./config.js"; +import type { Database } from "./database/database.js"; +import type { ItemsTable } from "./database/tables.js"; +import { InvitationSyncClient } from "./invitation-sync-client.js"; +import { OrderInvitationTracker } from "./order-invitation-tracker.js"; + +export type CreateOrderLineItem = { + id: string; + name: string; + quantity: number; + price: number; +}; + +export type CreateOrderResult = { + order: { + id: string; + status: string; + total_price: number; + total_quantity: number; + items: Array<{ id: string; quantity: number }>; + invitation_identifier: string | null; + created_at: number; + updated_at: number; + }; + invitation: string; + syncServerUrl: string; + receipt: { + summary: string; + lineItems: CreateOrderLineItem[]; + }; +}; + +export type OrderPaymentServiceDeps = { + engine: Engine; + database: Database; + config: Config; + syncClient: InvitationSyncClient; + debug: Debugger; + templateIdentifier: string; + trackers: Map; +}; + +/** + * Handles XO Engine invitation creation and sync for vending orders. + */ +export class OrderPaymentService { + constructor(private readonly deps: OrderPaymentServiceDeps) {} + + static async create(deps: { + engine: Engine; + database: Database; + config: Config; + debug: Debugger; + trackers: Map; + }): Promise { + const { templateIdentifier } = await deps.engine.importTemplate( + vendingMachineTemplate, + ); + await deps.engine.setDefaultLockingParameters( + templateIdentifier, + "purchaseOutput", + "merchant", + ); + + const syncClient = new InvitationSyncClient(deps.config.syncServer.url); + + return new OrderPaymentService({ + ...deps, + syncClient, + templateIdentifier, + }); + } + + async createOrder( + itemsInput: Array<{ id: string; quantity: number }>, + ): Promise { + const dbItems = await this.deps.database.db + .selectFrom("items") + .selectAll() + .where( + "id", + "in", + itemsInput.map((item) => item.id), + ) + .execute(); + + if (dbItems.length !== itemsInput.length) { + throw new OrderPaymentError("Items not found", 404); + } + + const lineItems: CreateOrderLineItem[] = []; + let totalPrice = 0; + let totalQuantity = 0; + + for (const input of itemsInput) { + const item = dbItems.find((row) => row.id === input.id); + if (!item) { + throw new OrderPaymentError("Items not found", 404); + } + + if (item.quantity < input.quantity) { + throw new OrderPaymentError(`Insufficient stock for ${item.name}`, 400); + } + + lineItems.push({ + id: item.id, + name: item.name, + quantity: input.quantity, + price: item.price, + }); + + totalPrice += item.price * input.quantity; + totalQuantity += input.quantity; + } + + const receiptSummary = lineItems + .map((item) => `${item.quantity}× ${item.name}`) + .join(", "); + const lineItemsJson = JSON.stringify( + lineItems.map((item) => ({ + id: item.id, + name: item.name, + quantity: item.quantity, + price: item.price, + })), + ); + + const orderRow = await this.deps.database.db + .insertInto("orders") + .values({ + status: "pending", + total_price: totalPrice, + total_quantity: totalQuantity, + items: JSON.stringify( + lineItems.map((item) => ({ id: item.id, quantity: item.quantity })), + ), + invitation_identifier: null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + let invitation = await this.deps.engine.createInvitation({ + templateIdentifier: this.deps.templateIdentifier, + actionIdentifier: "purchaseItems", + }); + + invitation = await this.deps.engine.appendInvitation( + invitation.invitationIdentifier, + { + variables: [ + { + variableIdentifier: "totalSatoshis", + roleIdentifier: "merchant", + value: totalPrice, + }, + { + variableIdentifier: "orderId", + roleIdentifier: "merchant", + value: orderRow.id, + }, + { + variableIdentifier: "merchantName", + roleIdentifier: "merchant", + value: this.deps.config.merchant.name, + }, + { + variableIdentifier: "receiptSummary", + roleIdentifier: "merchant", + value: receiptSummary, + }, + { + variableIdentifier: "lineItemsJson", + roleIdentifier: "merchant", + value: lineItemsJson, + }, + ], + }, + ); + + invitation = await this.deps.engine.appendInvitation( + invitation.invitationIdentifier, + { + outputs: [{ outputIdentifier: "purchaseOutput" }], + }, + ); + + const updatedOrder = await this.deps.database.db + .updateTable("orders") + .set({ invitation_identifier: invitation.invitationIdentifier }) + .where("id", "=", orderRow.id) + .returningAll() + .executeTakeFirstOrThrow(); + + const tracker = await OrderInvitationTracker.start({ + syncClient: this.deps.syncClient, + engine: this.deps.engine, + database: this.deps.database, + orderId: updatedOrder.id, + invitation, + debug: this.deps.debug, + }); + + this.deps.trackers.set(updatedOrder.id, tracker); + + return { + order: formatOrder(updatedOrder), + invitation: serializeInvitation(invitation), + syncServerUrl: this.deps.syncClient.url, + receipt: { + summary: receiptSummary, + lineItems, + }, + }; + } +} + +export class OrderPaymentError extends Error { + constructor( + message: string, + public readonly statusCode: number, + ) { + super(message); + this.name = "OrderPaymentError"; + } +} + +function formatOrder(order: { + id: string; + status: string; + total_price: number; + total_quantity: number; + items: string; + invitation_identifier: string | null; + created_at: unknown; + updated_at: unknown; +}) { + return { + id: order.id, + status: order.status, + total_price: order.total_price, + total_quantity: order.total_quantity, + items: JSON.parse(order.items) as Array<{ id: string; quantity: number }>, + invitation_identifier: order.invitation_identifier, + created_at: Number(order.created_at), + updated_at: Number(order.updated_at), + }; +} diff --git a/src/services/sse-broadcaster.ts b/src/services/sse-broadcaster.ts index db82bbb..22fec9f 100644 --- a/src/services/sse-broadcaster.ts +++ b/src/services/sse-broadcaster.ts @@ -27,7 +27,7 @@ interface SSEOptions { /** * Server-Sent Events broadcaster with event history support. - * + * * Maintains a per-user event history buffer that allows clients to replay * missed events when reconnecting. This makes reconnections robust against * network interruptions. @@ -45,17 +45,17 @@ export class SSEBroadcaster { } /** Map of Invitation IDs to their connected SSE response streams */ - private clients: Map<(string), Set> = new Map(); - + private clients: Map> = new Map(); + /** Map of Invitation IDs to their event history buffers */ private eventHistory: Map = new Map(); - + /** Maximum age of events to keep in history (in milliseconds) */ private maxHistoryAge: number; - + /** Maximum number of events to keep per user */ private maxHistorySize: number; - + private debug: Debugger; constructor(options?: SSEOptions) { @@ -63,7 +63,7 @@ export class SSEBroadcaster { this.eventHistory = new Map(); this.maxHistoryAge = options?.maxHistoryAge ?? 20 * 60 * 1000; // 20 minutes default this.maxHistorySize = options?.maxHistorySize ?? 1000; // 1000 events default - this.debug = debug('xo:sse'); + this.debug = debug("xo:sse"); } /** @@ -71,14 +71,17 @@ export class SSEBroadcaster { * @returns The SSE instance for chaining */ start() { - this.debug('SSE broadcaster is running (maxHistoryAge: %dms, maxHistorySize: %d)', - this.maxHistoryAge, this.maxHistorySize); + this.debug( + "SSE broadcaster is running (maxHistoryAge: %dms, maxHistorySize: %d)", + this.maxHistoryAge, + this.maxHistorySize, + ); return this; } /** * Sends an event to a client. - * + * * @param client - The client to send the event to * @param topic - The event topic/type * @param data - The event payload data @@ -92,7 +95,7 @@ export class SSEBroadcaster { /** * Sends an event to a client. - * + * * @param client - The client to send the event to * @param topic - The event topic/type * @param data - The event payload data @@ -101,50 +104,56 @@ export class SSEBroadcaster { try { SSEBroadcaster.sendEvent(client, topic, data); } catch (error) { - this.debug('Error sending event to client', error); + this.debug("Error sending event to client", error); } } /** * Broadcasts an event to all connected clients for a user and stores it in history. - * + * * @param clientId - The user ID to broadcast to * @param topic - The event topic/type * @param data - The event payload data */ async broadcast(clientId: string, topic: string, data: unknown) { const timestamp = Date.now(); - + // Store the event in history for potential replay this.storeEvent(clientId, topic, data, timestamp); - + // Broadcast to all connected clients this.clients.get(clientId)?.forEach((client: FastifyReply) => { try { this.sendEvent(client, topic, data); } catch (error) { - this.debug('Error sending event to client', error); + this.debug("Error sending event to client", error); } }); - this.debug('SSE broadcasted message', topic, data); + this.debug("SSE broadcasted message", topic, data); } /** * Subscribes a client to receive SSE events. - * + * * If lastEventTime is provided, all events that occurred after that timestamp * will be replayed to the client before starting the live stream. - * + * * @param req - The authenticated request containing the user ID * @param res - The Express response object to use for SSE streaming * @param lastEventTime - Optional timestamp to replay events from (in milliseconds) */ - async subscribe(req: FastifyRequest, res: FastifyReply, lastEventTime?: number) { + async subscribe( + req: FastifyRequest, + res: FastifyReply, + lastEventTime?: number, + ) { // Get the invitation ID from the request - const { invitationIdentifier } = req.query as { invitationIdentifier?: string }; + const { invitationIdentifier } = req.query as { + invitationIdentifier?: string; + }; if (!invitationIdentifier) { - throw new Error('Invitation Identifier is required'); + throw new Error("Invitation Identifier is required"); } // Initialize client set for this user if needed @@ -174,19 +183,26 @@ export class SSEBroadcaster { res.raw.flushHeaders(); // Set retry interval for automatic reconnection - res.raw.write('retry: 3000\n\n'); + res.raw.write("retry: 3000\n\n"); // Replay missed events if a lastEventTime was provided if (lastEventTime !== undefined) { - const missedEvents = this.getEventsAfter(invitationIdentifier, lastEventTime); - this.debug('SSE replaying %d missed events for invitation %s (since %d)', - missedEvents.length, invitationIdentifier, lastEventTime); - + const missedEvents = this.getEventsAfter( + invitationIdentifier, + lastEventTime, + ); + this.debug( + "SSE replaying %d missed events for invitation %s (since %d)", + missedEvents.length, + invitationIdentifier, + lastEventTime, + ); + for (const event of missedEvents) { try { await this.sendEvent(res, event.topic, event.data); } catch (error) { - this.debug('Error sending event to client', error); + this.debug("Error sending event to client", error); } } } @@ -194,33 +210,44 @@ export class SSEBroadcaster { // Add client to the set for live updates this.clients.get(invitationIdentifier)?.add(res); - this.debug('SSE subscribed to client (invitationId: %s, lastEventTime: %s)', - invitationIdentifier, lastEventTime ?? 'none'); + this.debug( + "SSE subscribed to client (invitationId: %s, lastEventTime: %s)", + invitationIdentifier, + lastEventTime ?? "none", + ); // Clean up when client disconnects - res.raw.on('close', () => { + res.raw.on("close", () => { this.clients.get(invitationIdentifier)?.delete(res); - this.debug('SSE client disconnected (invitationIdentifier: %s)', invitationIdentifier); + this.debug( + "SSE client disconnected (invitationIdentifier: %s)", + invitationIdentifier, + ); }); } /** * Stores an event in the user's history buffer. * Automatically prunes old events based on age and size limits. - * + * * @param invitationId - The invitation ID to store the event for * @param topic - The event topic/type * @param data - The event payload data * @param timestamp - The event timestamp */ - private storeEvent(invitationId: string, topic: string, data: unknown, timestamp: number) { + private storeEvent( + invitationId: string, + topic: string, + data: unknown, + timestamp: number, + ) { // Initialize history array for this user if needed if (!this.eventHistory.has(invitationId)) { this.eventHistory.set(invitationId, []); } const history = this.eventHistory.get(invitationId)!; - + // Add the new event history.push({ topic, data, timestamp }); @@ -230,7 +257,7 @@ export class SSEBroadcaster { /** * Removes old events from a invitation's history based on age and size limits. - * + * * @param invitationId - The invitation ID whose history to prune * @param currentTime - The current timestamp for age calculations */ @@ -239,32 +266,36 @@ export class SSEBroadcaster { if (!history) return; const cutoffTime = currentTime - this.maxHistoryAge; - + // Remove events older than maxHistoryAge - const prunedByAge = history.filter(event => event.timestamp > cutoffTime); - + const prunedByAge = history.filter((event) => event.timestamp > cutoffTime); + // If still over size limit, remove oldest events - const prunedBySize = prunedByAge.length > this.maxHistorySize - ? prunedByAge.slice(-this.maxHistorySize) - : prunedByAge; + const prunedBySize = + prunedByAge.length > this.maxHistorySize + ? prunedByAge.slice(-this.maxHistorySize) + : prunedByAge; this.eventHistory.set(invitationId, prunedBySize); } /** * Retrieves all events for a user that occurred after a given timestamp. - * + * * @param invitationId - The invitation ID to get events for * @param afterTimestamp - The timestamp to get events after (exclusive) * @returns Array of events that occurred after the timestamp */ - private getEventsAfter(invitationId: string, afterTimestamp: number): HistoricalEvent[] { + private getEventsAfter( + invitationId: string, + afterTimestamp: number, + ): HistoricalEvent[] { const history = this.eventHistory.get(invitationId); if (!history) return []; // First prune old events to ensure we don't return stale data this.pruneHistory(invitationId, Date.now()); - return history.filter(event => event.timestamp > afterTimestamp); + return history.filter((event) => event.timestamp > afterTimestamp); } -} \ No newline at end of file +} diff --git a/src/templates/vending-machine.ts b/src/templates/vending-machine.ts new file mode 100644 index 0000000..2511813 --- /dev/null +++ b/src/templates/vending-machine.ts @@ -0,0 +1,283 @@ +import type { XOTemplate } from "@xo-cash/types"; + +/** + * Vending machine payment template. + * + * Merchant creates a purchaseItems invitation with receipt variables; + * customer funds and signs the composable transaction. + */ +export const vendingMachineTemplate: XOTemplate = { + $schema: "https://libauth.org/schemas/wallet-template-v0.schema.json", + name: "Vending Machine", + description: + "Purchase items from a vending machine with an itemized receipt.", + icon: "wallet", + version: "1", + supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"], + + defaults: { + change: { + output: "changeOutput", + role: "merchant", + generate: ["merchantKey"], + }, + }, + + roles: { + merchant: { + name: "Merchant", + description: "The vending machine operator receiving payment.", + icon: "owner", + }, + customer: { + name: "Customer", + description: "The customer paying for items.", + icon: "sender", + }, + }, + + start: [ + { + action: "purchaseItems", + role: "merchant", + generate: ["merchantKey"], + }, + ], + + actions: { + purchaseItems: { + name: "Purchase Items", + description: "Purchase: $() for $() sats", + icon: "request", + + roles: { + merchant: { + name: "Sell Items", + description: "Receive payment for $()", + icon: "request", + requirements: { + secrets: ["merchantKey"], + variables: [ + "totalSatoshis", + "orderId", + "merchantName", + "receiptSummary", + "lineItemsJson", + ], + }, + }, + customer: { + name: "Pay", + description: "Pay $() sats for $()", + icon: "send", + requirements: {}, + }, + }, + + requirements: { + participants: [ + { role: "merchant", slots: { min: 1, max: 1 } }, + { role: "customer", slots: { min: 1 } }, + ], + }, + + transaction: "purchaseItemsTransaction", + }, + }, + + transactions: { + purchaseItemsTransaction: { + name: "Vending Purchase", + description: "Order $(): $()", + icon: "request", + + roles: { + merchant: { + name: "Received Payment", + description: + "Received $() sats from $() sale", + icon: "receive", + }, + customer: { + name: "Sent Payment", + description: "Paid $() sats for $()", + icon: "send", + }, + }, + + inputs: [], + outputs: [{ output: "purchaseOutput" }], + version: 2, + locktime: 0, + composable: true, + }, + }, + + /** No custom input templates — customer UTXOs are selected at funding time. */ + inputs: {}, + + outputs: { + changeOutput: { + name: "Change", + description: "Funds returned as change.", + icon: "receive", + lockingScript: "merchantReceivingLockingScript", + }, + purchaseOutput: { + name: "Purchase Payment", + description: "$() sats to $()", + icon: "request", + + roles: { + merchant: { + name: "Payment Received", + description: + "Received $() sats for $()", + }, + customer: { + name: "Payment Sent", + description: "Sent $() sats for $()", + }, + }, + + lockingScript: "merchantReceivingLockingScript", + valueSatoshis: "$()", + token: null, + }, + }, + + lockingScripts: { + merchantReceivingLockingScript: { + name: "Merchant Receive", + description: "Funds received by the vending machine merchant.", + icon: "address", + lockingType: "p2pkh", + lockingBytecode: "lockMerchantP2PKH", + unlockingBytecode: "unlockMerchantP2PKH", + actions: [], + state: { variables: [], secrets: [] }, + balance: {}, + roles: { + merchant: { + state: { + variables: [], + secrets: ["merchantKey"], + }, + actions: [], + balance: { + satoshis: true, + fungibleTokens: true, + nonfungibleTokens: true, + }, + selectable: true, + }, + }, + }, + }, + + scripts: { + lockMerchantP2PKH: + "OP_DUP OP_HASH160 <$( OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG", + unlockMerchantP2PKH: + " ", + }, + + constants: { + dustLimit: { + name: "Dust Limit", + description: "Minimum satoshis for P2PKH outputs.", + type: "integer", + value: 546, + }, + }, + + variables: { + merchantKey: { + name: "Merchant Private Key", + description: "Private key for the vending machine merchant wallet.", + type: "bytes", + hint: "private_key", + }, + totalSatoshis: { + name: "Total Price", + description: "Total purchase price in satoshis", + type: "integer", + hint: "satoshis", + }, + orderId: { + name: "Order ID", + description: "Unique order identifier", + type: "string", + }, + merchantName: { + name: "Merchant Name", + description: "Display name of the vending machine", + type: "string", + }, + receiptSummary: { + name: "Receipt Summary", + description: "Human-readable list of purchased items", + type: "string", + }, + lineItemsJson: { + name: "Line Items", + description: "JSON-encoded line items for the purchase", + type: "string", + }, + }, + + icons: [ + { name: "wallet", hash: "0000000000000000000000" }, + { name: "owner", hash: "0000000000000000000000" }, + { name: "sender", hash: "0000000000000000000000" }, + { name: "request", hash: "0000000000000000000000" }, + { name: "receive", hash: "0000000000000000000000" }, + { name: "send", hash: "0000000000000000000000" }, + ], + + scenarios: [ + { + name: "purchase items happy path", + description: "Merchant requests payment for vending machine items.", + action: "purchaseItems", + roles: [ + { + role: "merchant", + values: { + generated: { + merchantKey: + "KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8", + }, + variables: { + totalSatoshis: 3500, + orderId: "order-demo-1", + merchantName: "XO Snack Machine", + receiptSummary: "2× Cola, 1× Chips", + lineItemsJson: + '[{"name":"Cola","qty":2},{"name":"Chips","qty":1}]', + }, + secrets: {}, + inputs: [], + outputs: [ + { + lockingBytecode: + "76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac", + valueSatoshis: 3500, + }, + ], + }, + }, + { + role: "customer", + values: { + generated: {}, + variables: {}, + secrets: {}, + inputs: [], + outputs: [], + }, + }, + ], + }, + ], +}; diff --git a/src/utils/event-emitter.ts b/src/utils/event-emitter.ts new file mode 100644 index 0000000..439be61 --- /dev/null +++ b/src/utils/event-emitter.ts @@ -0,0 +1,156 @@ +// TODO: You'll probably want to use WeakRef's here. + +export type EventMap = Record; + +type Listener = (detail: T) => void; + +interface ListenerEntry { + listener: Listener; + wrappedListener: Listener; + debounceTime?: number; + once?: boolean; +} + +export type OffCallback = () => void; + +export class EventEmitter { + private listeners: Map>> = new Map(); + + on( + type: K, + listener: Listener, + debounceMilliseconds?: number, + ): OffCallback { + const wrappedListener = + debounceMilliseconds && debounceMilliseconds > 0 + ? this.debounce(listener, debounceMilliseconds) + : listener; + + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + + const listenerEntry: ListenerEntry = { + listener, + wrappedListener, + ...(debounceMilliseconds !== undefined + ? { debounceTime: debounceMilliseconds } + : {}), + }; + + this.listeners.get(type)?.add(listenerEntry as ListenerEntry); + + // Return an "off" callback that can be called to stop listening for events. + return () => this.off(type, listener); + } + + once( + type: K, + listener: Listener, + debounceMilliseconds?: number, + ): OffCallback { + const wrappedListener: Listener = (detail: T[K]) => { + this.off(type, listener); + listener(detail); + }; + + const debouncedListener = + debounceMilliseconds && debounceMilliseconds > 0 + ? this.debounce(wrappedListener, debounceMilliseconds) + : wrappedListener; + + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + + const listenerEntry: ListenerEntry = { + listener, + wrappedListener: debouncedListener, + once: true, + ...(debounceMilliseconds !== undefined + ? { debounceTime: debounceMilliseconds } + : {}), + }; + + this.listeners.get(type)?.add(listenerEntry as ListenerEntry); + + // Return an "off" callback that can be called to stop listening for events. + return () => this.off(type, listener); + } + + off(type: K, listener: Listener): void { + const listeners = this.listeners.get(type); + if (!listeners) return; + + const listenerEntry = Array.from(listeners).find( + (entry) => + entry.listener === listener || entry.wrappedListener === listener, + ); + + if (listenerEntry) { + listeners.delete(listenerEntry); + } + } + + emit(type: K, payload: T[K]): boolean { + const listeners = this.listeners.get(type); + if (!listeners) return false; + + listeners.forEach((entry) => { + entry.wrappedListener(payload); + }); + + return listeners.size > 0; + } + + removeAllListeners(): void { + this.listeners.clear(); + } + + async waitFor( + type: K, + predicate: (payload: T[K]) => boolean, + timeoutMs?: number, + ): Promise { + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | undefined; + + const listener = (payload: T[K]) => { + if (predicate(payload)) { + // Clean up + this.off(type, listener); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + resolve(payload); + } + }; + + // Set up timeout if specified + if (timeoutMs !== undefined) { + timeoutId = setTimeout(() => { + this.off(type, listener); + reject(new Error(`Timeout waiting for event "${String(type)}"`)); + }, timeoutMs); + } + + this.on(type, listener); + }); + } + + private debounce( + func: Listener, + wait: number, + ): Listener { + let timeout: ReturnType; + + return (detail: T[K]) => { + if (timeout !== null) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + func(detail); + }, wait); + }; + } +} diff --git a/src/utils/exponential-backoff.ts b/src/utils/exponential-backoff.ts new file mode 100644 index 0000000..8315a13 --- /dev/null +++ b/src/utils/exponential-backoff.ts @@ -0,0 +1,155 @@ +/** + * Exponential backoff is a technique used to retry a function after a delay. + * + * The delay increases exponentially with each attempt, up to a maximum delay. + * + * The jitter is a random amount of time added to the delay to prevent thundering herd problems. + * + * The growth rate is the factor by which the delay increases with each attempt. + */ +export class ExponentialBackoff { + /** + * Create a new ExponentialBackoff instance + * + * @param config - The configuration for the exponential backoff + * @returns The ExponentialBackoff instance + */ + static from(config?: Partial): ExponentialBackoff { + const backoff = new ExponentialBackoff(config); + return backoff; + } + + /** + * Run the function with exponential backoff + * + * @param fn - The function to run + * @param onError - The callback to call when an error occurs + * @param options - The configuration for the exponential backoff + * + * @throws The last error if the function fails and we have hit the max attempts + * + * @returns The result of the function + */ + static run( + fn: () => Promise, + onError = (_error: Error) => {}, + options?: Partial, + ): Promise { + const backoff = ExponentialBackoff.from(options); + return backoff.run(fn, onError); + } + + private readonly options: ExponentialBackoffOptions; + + constructor(options?: Partial) { + this.options = { + maxDelay: 10000, + maxAttempts: 10, + baseDelay: 1000, + growthRate: 2, + jitter: 0.1, + ...options, + }; + } + + /** + * Run the function with exponential backoff + * + * If the function fails but we have not hit the max attempts, the error will be passed to the onError callback + * and the function will be retried with an exponential delay + * + * If the function fails and we have hit the max attempts, the last error will be thrown + * + * @param fn - The function to run + * @param onError - The callback to call when an error occurs + * + * @throws The last error if the function fails and we have hit the max attempts + * + * @returns The result of the function + */ + async run( + fn: () => Promise, + onError = (_error: Error) => {}, + ): Promise { + let lastError: Error = new Error("Exponential backoff: Max retries hit"); + + let attempt = 0; + + while ( + attempt < this.options.maxAttempts || + this.options.maxAttempts == 0 + ) { + try { + return await fn(); + } catch (error) { + // Store the error in case we fail every attempt + lastError = error instanceof Error ? error : new Error(`${error}`); + onError(lastError); + + // Wait before going to the next attempt + const delay = this.calculateDelay(attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + attempt++; + } + + // We completed the loop without ever succeeding. Throw the last error we got + throw lastError; + } + + /** + * Calculate the delay before we should attempt to retry + * + * NOTE: The maximum delay is (maxDelay * (1 + jitter)) + * + * @param attempt + * @returns The time in milliseconds before another attempt should be made + */ + private calculateDelay(attempt: number): number { + // Get the power of the growth rate + const power = Math.pow(this.options.growthRate, attempt); + + // Get the delay before jitter or limit + const rawDelay = this.options.baseDelay * power; + + // Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay + const cappedDelay = Math.min(rawDelay, this.options.maxDelay); + + // Get the jitter direction. This will be between -1 and 1 + const jitterDirection = 2 * Math.random() - 1; + + // Calculate the jitter + const jitter = jitterDirection * this.options.jitter * cappedDelay; + + // Add the jitter to the delay + return cappedDelay + jitter; + } +} + +export type ExponentialBackoffOptions = { + /** + * The maximum delay between attempts in milliseconds + */ + maxDelay: number; + + /** + * The maximum number of attempts. Passing 0 will result in infinite attempts. + */ + maxAttempts: number; + + /** + * The base delay between attempts in milliseconds + */ + baseDelay: number; + + /** + * The growth rate of the delay + */ + growthRate: number; + + /** + * The jitter of the delay as a percentage of growthRate + */ + jitter: number; +}; diff --git a/src/utils/sse-session.ts b/src/utils/sse-session.ts new file mode 100644 index 0000000..5fc5c8a --- /dev/null +++ b/src/utils/sse-session.ts @@ -0,0 +1,435 @@ +import { ExponentialBackoff } from "./exponential-backoff.js"; + +// Type declarations for browser environment (not available in Node.js) +declare const document: + | { + visibilityState: "visible" | "hidden"; + addEventListener: ( + event: string, + handler: (event: Event) => void, + ) => void; + removeEventListener: ( + event: string, + handler: (event: Event) => void, + ) => void; + } + | undefined; + +/** + * A Server-Sent Events client implementation using fetch API. + * Supports custom headers, POST requests, and is non-blocking. + */ +export class SSESession { + /** + * Creates and connects a new SSESession instance. + * @param url The URL to connect to + * @param options Configuration options + * @returns A new connected SSESession instance + */ + public static async from( + url: string, + options: Partial = {}, + ): Promise { + const client = new SSESession(url, options); + await client.connect(); + return client; + } + + // State. + private url: string; + private controller: AbortController; + private connected: boolean = false; + protected options: SSESessionOptions; + protected messageBuffer: Uint8Array = new Uint8Array(); + + // Listener for when the tab is hidden or shown. + private visibilityChangeHandler: ((event: Event) => void) | null = null; + + // Text decoders and encoders for parsing the message buffer. + private textDecoder: TextDecoder = new TextDecoder(); + private textEncoder: TextEncoder = new TextEncoder(); + + /** + * Creates a new SSESession instance. + * @param url The URL to connect to + * @param options Configuration options + */ + constructor(url: string, options: Partial = {}) { + this.url = url; + this.options = { + // Use default fetch function. + fetch: (...args) => fetch(...args), + method: "GET", + headers: { + Accept: "text/event-stream", + "Cache-Control": "no-cache", + }, + onConnected: () => {}, + onMessage: () => {}, + onError: (error) => console.error("SSESession error:", error), + onDisconnected: () => {}, + onReconnect: (options) => Promise.resolve(options), + + // Reconnection options + attemptReconnect: true, + retryDelay: 1000, + persistent: false, + ...options, + }; + this.controller = new AbortController(); + + // Set up visibility change handling if in mobile browser environment + if (typeof document !== "undefined") { + this.visibilityChangeHandler = this.handleVisibilityChange.bind(this); + document.addEventListener( + "visibilitychange", + this.visibilityChangeHandler, + ); + } + } + + /** + * Handles visibility change events in the browser. + */ + private async handleVisibilityChange(): Promise { + // Guard for Node.js environment where document is undefined + if (typeof document === "undefined") return; + + // When going to background, close the current connection cleanly + // This allows us to reconnect mobile devices when they come back after leaving the tab or browser app. + if (document.visibilityState === "hidden") { + this.controller.abort(); + } + + // When coming back to foreground, attempt to reconnect if not connected + if (document.visibilityState === "visible" && !this.connected) { + await this.connect(); + } + } + + /** + * Connects to the SSE endpoint. + */ + public async connect(): Promise { + if (this.connected) return; + + this.connected = true; + this.controller = new AbortController(); + + const { method, headers, body } = this.options; + + const fetchOptions: RequestInit = { + method, + headers: headers || {}, + body: body || null, + signal: this.controller.signal, + cache: "no-store", + }; + + const exponentialBackoff = ExponentialBackoff.from({ + baseDelay: this.options.retryDelay, + maxDelay: 10000, + maxAttempts: 0, + growthRate: 1.3, + jitter: 0.3, + }); + + // Establish the connection and get the reader using the exponential backoff + const reader = await exponentialBackoff.run(async () => { + const reconnectOptions = await this.handleCallback( + this.options.onReconnect, + fetchOptions, + ); + + const updatedFetchOptions = { + ...fetchOptions, + ...reconnectOptions, + }; + + const res = await this.options.fetch(this.url, updatedFetchOptions); + if (!res.ok) { + throw new Error(`HTTP error! Status: ${res.status}`); + } + + if (!res.body) { + throw new Error("Response body is null"); + } + + return res.body.getReader(); + }); + + // Call the onConnected callback + this.handleCallback(this.options.onConnected); + + const readStream = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + this.connected = false; + + // Call the onDisconnected callback. + this.handleCallback(this.options.onDisconnected, undefined); + + // If the connection was closed by the server, we want to attempt a reconnect if the connection should be persistent. + if (this.options.persistent) { + await this.connect(); + } + + break; + } + + const events = this.parseEvents(value); + + for (const event of events) { + if (this.options.onMessage) { + this.handleCallback(this.options.onMessage, event); + } + } + } + } catch (error) { + this.connected = false; + + // Call the onDisconnected callback. + this.handleCallback(this.options.onDisconnected, error); + + // If the connection was aborted using the controller, we don't need to call onError. + if (this.controller.signal.aborted) { + return; + } + + // Call the onError callback. + // NOTE: we dont use the handleCallback here because it would result in 2 error callbacks. + try { + this.options.onError(error); + } catch (error) { + console.log(`SSE Session: onError callback error:`, error); + } + + // Attempt to reconnect if enabled + if (this.options.attemptReconnect) { + await this.connect(); + } + } + }; + + readStream(); + + return; + } + + protected parseEvents(chunk: Uint8Array): SSEvent[] { + // Append new chunk to existing buffer + this.messageBuffer = new Uint8Array([...this.messageBuffer, ...chunk]); + + const events: SSEvent[] = []; + const lines = this.textDecoder + .decode(this.messageBuffer) + .split(/\r\n|\r|\n/); + + let currentEvent: Partial = {}; + let completeEventCount = 0; + + // Iterate over the lines to find complete events + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Empty line signals the end of an event + if (line === "") { + if (currentEvent.data) { + // Remove trailing newline if present + currentEvent.data = currentEvent.data.replace(/\n$/, ""); + events.push(currentEvent as SSEvent); + currentEvent = {}; + completeEventCount = i + 1; + } + continue; + } + + if (!line) continue; + + // Parse field: value format + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) continue; + + const field = line.slice(0, colonIndex); + // Skip initial space after colon if present + const valueStartIndex = + colonIndex + 1 + (line[colonIndex + 1] === " " ? 1 : 0); + const value = line.slice(valueStartIndex); + + if (field === "data") { + currentEvent.data = currentEvent.data + ? currentEvent.data + "\n" + value + : value; + } else if (field === "event") { + currentEvent.event = value; + } else if (field === "id") { + currentEvent.id = value; + } else if (field === "retry") { + const retryMs = parseInt(value, 10); + if (!isNaN(retryMs)) { + currentEvent.retry = retryMs; + } + } + } + + // Store the remainder of the buffer for the next chunk + const remainder = lines.slice(completeEventCount).join("\n"); + this.messageBuffer = this.textEncoder.encode(remainder); + + return events; + } + + /** + * Override the onMessage callback. + * + * @param onMessage The callback to set. + */ + public setOnMessage(onMessage: (event: SSEvent) => void): void { + this.options.onMessage = onMessage; + } + + /** + * Closes the SSE connection and cleans up event listeners. + */ + public close(): void { + // Clean up everything including the visibility handler + this.controller.abort(); + + // Remove the visibility handler (This is only required on browsers) + if (this.visibilityChangeHandler && typeof document !== "undefined") { + document.removeEventListener( + "visibilitychange", + this.visibilityChangeHandler, + ); + this.visibilityChangeHandler = null; + } + } + + /** + * Checks if the client is currently connected. + * @returns Whether the client is connected + */ + public isConnected(): boolean { + return this.connected; + } + + /** + * Will handle thrown errors from the callback and call the onError callback. + * This is to avoid the sse-session from disconnecting from errors that are not a result of the sse-session itself. + * + * @param callback The callback to handle. + * @param args The arguments to pass to the callback. + */ + private handleCallback) => ReturnType>( + callback: T, + ...args: Parameters + ): ReturnType | undefined { + try { + return callback(...args); + } catch (error) { + try { + this.options.onError(error); + } catch (error) { + console.log(`SSE Session: onError callback error:`, error); + } + } + } +} + +/** + * Configuration options for the SSESession. + */ +export interface SSESessionOptions { + /** + * The fetch function to use. + * + * NOTE: This is compatible with Browser/Node's native "fetcH" function. + * We use this in place of "typeof fetch" so that we can accept non-standard URLs ("url" is a "string" here). + * For example, a LibP2P adapter might not use a standardized URL format (and might only include "path"). + * This would cause a type error as native fetch expects type "URL". + */ + fetch: (url: string, options: RequestInit) => Promise; + + /** + * HTTP method to use (GET or POST). + */ + method: "GET" | "POST"; + + /** + * HTTP headers to send with the request. + */ + headers?: Record; + + /** + * Body to send with POST requests. + */ + body?: string | FormData; + + /** + * Called when the connection is established. + */ + onConnected: () => void; + + /** + * Called when a message is received. + */ + onMessage: (event: SSEvent) => void; + + /** + * Called when an error occurs. + */ + onError: (error: unknown) => void; + + /** + * Called when the connection is closed. + */ + onDisconnected: (error: unknown) => void; + + /* + * Called when the connection is going to try to reconnect. + */ + onReconnect: (options: RequestInit) => Promise; + + /** + * Whether to attempt to reconnect. + */ + attemptReconnect: boolean; + + /** + * The delay in milliseconds between reconnection attempts. + */ + retryDelay: number; + + /** + * Whether to reconnect when the session is terminated by the server. + */ + persistent: boolean; +} + +/** + * Represents a Server-Sent Event. + */ +export interface SSEvent { + /** + * Event data. + */ + data: string; + + /** + * Event type. + */ + event?: string; + + /** + * Event ID. + */ + id?: string; + + /** + * Reconnection time in milliseconds. + */ + retry?: number; +}