Let AI go to work

This commit is contained in:
2026-05-23 10:33:29 +02:00
parent 7890669eda
commit adc758dfa5
20 changed files with 2200 additions and 257 deletions

60
package-lock.json generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -2,37 +2,81 @@ 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;
}
orderPaymentService: OrderPaymentService;
trackers: Map<string, OrderInvitationTracker>;
};
export class VendingMachine {
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
debug("Creating engine");
const engine = await Engine.create(config.engine.mnemonic, {
databaseFilename: config.engine.database.fileName,
databasePath: config.engine.database.path,
});
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();
debug("Creating trackers");
const trackers = new Map<string, OrderInvitationTracker>();
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: database, engine: engine, debug }),
new OrdersRoute({ database: database, engine: engine, debug }),
new ItemsRoute({ database, engine, debug: debug.extend("items") }),
new OrdersRoute({
database,
orderPaymentService,
syncServerUrl: config.syncServer.url,
debug: debug.extend("orders"),
}),
];
// Create the HTTP service, passing in the routes and config.
const httpService = new HTTPService({ routes: [], config: config.server, debug });
debug("Creating HTTP service");
const httpService = new HTTPService({
routes,
config: config.server,
debug,
});
return new VendingMachine({ config, httpService, database, engine });
debug("Creating vending machine");
return new VendingMachine({
config,
httpService,
database,
engine,
orderPaymentService,
trackers,
});
}
private constructor(private readonly deps: VendingMachineDeps) {}
@@ -40,8 +84,18 @@ export class VendingMachine {
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);
});

View File

@@ -1,2 +1,2 @@
export * from './items.js';
export * from './orders.js';
export * from "./items.js";
export * from "./orders.js";

View File

@@ -1,15 +1,15 @@
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) {}
@@ -17,16 +17,16 @@ export class ItemsRoute {
public async getRoutes(): Promise<Array<RouteOptions>> {
return [
{
method: 'GET',
url: '/items',
method: "GET",
url: "/items",
handler: this.getItems.bind(this),
},
{
method: 'GET',
url: '/items/:id',
method: "GET",
url: "/items/:id",
handler: this.getItem.bind(this),
},
]
];
}
/**
@@ -37,7 +37,10 @@ export class ItemsRoute {
*/
private async getItems(request: FastifyRequest, reply: FastifyReply) {
// Get all items from the database.
const items = await this.deps.database.db.selectFrom('items').selectAll().execute();
const items = await this.deps.database.db
.selectFrom("items")
.selectAll()
.execute();
// Return the items.
return reply.send(items);
@@ -54,12 +57,16 @@ export class ItemsRoute {
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();
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'
error: "Item not found",
});
}

View File

@@ -1,15 +1,20 @@
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) {}
@@ -17,72 +22,86 @@ export class OrdersRoute {
public async getRoutes(): Promise<Array<RouteOptions>> {
return [
{
method: 'GET',
url: '/orders',
method: "GET",
url: "/orders",
handler: this.getOrders.bind(this),
},
{
method: 'POST',
url: '/orders',
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) {
// Get all orders from the database.
const orders = await this.deps.database.db.selectFrom('orders').selectAll().execute();
private async getOrders(_request: FastifyRequest, reply: FastifyReply) {
const orders = await this.deps.database.db
.selectFrom("orders")
.selectAll()
.execute();
// Return the orders.
return reply.send(orders);
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" });
}
return reply.send({
...order,
items: JSON.parse(order.items),
syncServerUrl: this.deps.syncServerUrl,
});
}
private async createOrder(request: FastifyRequest, reply: FastifyReply) {
// Parse the request body.
const { items: itemsInput } = OrdersRoute.createOrderSchema.parse(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'
});
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 });
}
// 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'
});
this.deps.debug("Failed to create order: %o", error);
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({
static getOrderSchema = z.object({
id: z.string(),
quantity: z.number(),
})),
});
static createOrderSchema = z.object({
items: z.array(
z.object({
id: z.string(),
quantity: z.number().int().positive(),
}),
),
});
}

View File

@@ -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<typeof configSchema>;
type ConfigSchema = z.output<typeof configSchema>;
/**
* Converts an object's keys to camelCase.
* @param obj - The object to convert to camelCase.
* @returns The camelCase object.
*/
const toCamelCaseObject = (obj: Record<string, string>): Record<string, string> => {
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;
}

View File

@@ -48,7 +48,9 @@ export class Database {
this.configurePragmas();
// Create the Kysely database client.
this.kysely = new Kysely<DatabaseTables>({ dialect: new SqliteDialect({ database: this.sqlite }) });
this.kysely = new Kysely<DatabaseTables>({
dialect: new SqliteDialect({ database: this.sqlite }),
});
}
/**

View File

@@ -51,8 +51,12 @@ export async function up(db: Kysely<Database>): Promise<void> {
.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<Database>): Promise<void> {
.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<Database>): Promise<void> {
.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<Database>): Promise<void> {
// 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<Database>): Promise<void> {
// 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<Database>): Promise<void> {
SET updated_at = ${millisecondTimeRaw}
WHERE id = NEW.id;
END;
`)
`,
)
.execute(db);
}
}
@@ -144,7 +162,9 @@ export async function up(db: Kysely<Database>): Promise<void> {
export async function down(db: Kysely<Database>): Promise<void> {
// 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();

View File

@@ -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<Database>): Promise<void> {
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<Database>): Promise<void> {
await sql`DROP INDEX IF EXISTS idx_orders_invitation_identifier`.execute(db);
await db.schema
.alterTable("orders")
.dropColumn("invitation_identifier")
.execute();
}

View File

@@ -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<number, number | undefined, number | undefined>;
export type Timestamp = ColumnType<
number,
number | undefined,
number | undefined
>;
/**
* SQLite JSON column represented as TEXT.
@@ -15,12 +19,20 @@ export type Timestamp = ColumnType<number, number | undefined, number | undefine
* @remarks
* Serialize values with JSON.stringify and parse with JSON.parse in app code.
*/
export type JsonText = ColumnType<string, string | undefined, string | undefined>;
export type JsonText = ColumnType<
string,
string | undefined,
string | undefined
>;
/**
* SQLite boolean emulation represented as INTEGER (0/1).
*/
export type SqliteBoolean = ColumnType<number, number | boolean | undefined, number | boolean | undefined>;
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<Timestamp>;
updated_at: Generated<Timestamp>;
}

View File

@@ -19,15 +19,23 @@ export const apiRoutesSchema = z.array(z.custom<APIRoutes>());
export const serverConfigSchema = z.object({
port: z.number().default(3000),
host: z.string().default("0.0.0.0"),
cors: z.object({
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({}),
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 () => {

View File

@@ -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<void> {
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<XOInvitation> {
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<XOInvitation | undefined> {
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;
}

View File

@@ -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<InvitationSyncClient["subscribe"]> | null =
null;
private dispenseTimer: ReturnType<typeof setTimeout> | 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<OrderInvitationTracker> {
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<void> {
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<void> {
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<string, XOInvitationCommit>();
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());
}

View File

@@ -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<string, OrderInvitationTracker>;
};
/**
* 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<string, OrderInvitationTracker>;
}): Promise<OrderPaymentService> {
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<CreateOrderResult> {
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),
};
}

View File

@@ -45,7 +45,7 @@ export class SSEBroadcaster {
}
/** Map of Invitation IDs to their connected SSE response streams */
private clients: Map<(string), Set<FastifyReply>> = new Map();
private clients: Map<string, Set<FastifyReply>> = new Map();
/** Map of Invitation IDs to their event history buffers */
private eventHistory: Map<string, HistoricalEvent[]> = new Map();
@@ -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,8 +71,11 @@ 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;
}
@@ -101,7 +104,7 @@ 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);
}
}
@@ -123,11 +126,11 @@ export class SSEBroadcaster {
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);
}
/**
@@ -140,11 +143,17 @@ export class SSEBroadcaster {
* @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,13 +210,19 @@ 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,
);
});
}
@@ -213,7 +235,12 @@ export class SSEBroadcaster {
* @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, []);
@@ -241,10 +268,11 @@ export class SSEBroadcaster {
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
const prunedBySize =
prunedByAge.length > this.maxHistorySize
? prunedByAge.slice(-this.maxHistorySize)
: prunedByAge;
@@ -258,13 +286,16 @@ export class SSEBroadcaster {
* @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);
}
}

View File

@@ -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: $(<receiptSummary>) for $(<totalSatoshis>) sats",
icon: "request",
roles: {
merchant: {
name: "Sell Items",
description: "Receive payment for $(<receiptSummary>)",
icon: "request",
requirements: {
secrets: ["merchantKey"],
variables: [
"totalSatoshis",
"orderId",
"merchantName",
"receiptSummary",
"lineItemsJson",
],
},
},
customer: {
name: "Pay",
description: "Pay $(<totalSatoshis>) sats for $(<receiptSummary>)",
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 $(<orderId>): $(<receiptSummary>)",
icon: "request",
roles: {
merchant: {
name: "Received Payment",
description:
"Received $(<totalSatoshis>) sats from $(<merchantName>) sale",
icon: "receive",
},
customer: {
name: "Sent Payment",
description: "Paid $(<totalSatoshis>) sats for $(<receiptSummary>)",
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: "$(<totalSatoshis>) sats to $(<merchantName>)",
icon: "request",
roles: {
merchant: {
name: "Payment Received",
description:
"Received $(<totalSatoshis>) sats for $(<receiptSummary>)",
},
customer: {
name: "Payment Sent",
description: "Sent $(<totalSatoshis>) sats for $(<receiptSummary>)",
},
},
lockingScript: "merchantReceivingLockingScript",
valueSatoshis: "$(<totalSatoshis>)",
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 <$(<merchantKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG",
unlockMerchantP2PKH:
"<merchantKey.schnorr_signature.all_outputs> <merchantKey.public_key>",
},
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: [],
},
},
],
},
],
};

156
src/utils/event-emitter.ts Normal file
View File

@@ -0,0 +1,156 @@
// TODO: You'll probably want to use WeakRef's here.
export type EventMap = Record<string, unknown>;
type Listener<T> = (detail: T) => void;
interface ListenerEntry<T> {
listener: Listener<T>;
wrappedListener: Listener<T>;
debounceTime?: number;
once?: boolean;
}
export type OffCallback = () => void;
export class EventEmitter<T extends EventMap> {
private listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = new Map();
on<K extends keyof T>(
type: K,
listener: Listener<T[K]>,
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<T[K]> = {
listener,
wrappedListener,
...(debounceMilliseconds !== undefined
? { debounceTime: debounceMilliseconds }
: {}),
};
this.listeners.get(type)?.add(listenerEntry as ListenerEntry<T[keyof T]>);
// Return an "off" callback that can be called to stop listening for events.
return () => this.off(type, listener);
}
once<K extends keyof T>(
type: K,
listener: Listener<T[K]>,
debounceMilliseconds?: number,
): OffCallback {
const wrappedListener: Listener<T[K]> = (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<T[K]> = {
listener,
wrappedListener: debouncedListener,
once: true,
...(debounceMilliseconds !== undefined
? { debounceTime: debounceMilliseconds }
: {}),
};
this.listeners.get(type)?.add(listenerEntry as ListenerEntry<T[keyof T]>);
// Return an "off" callback that can be called to stop listening for events.
return () => this.off(type, listener);
}
off<K extends keyof T>(type: K, listener: Listener<T[K]>): 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<K extends keyof T>(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<K extends keyof T>(
type: K,
predicate: (payload: T[K]) => boolean,
timeoutMs?: number,
): Promise<T[K]> {
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | 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<K extends keyof T>(
func: Listener<T[K]>,
wait: number,
): Listener<T[K]> {
let timeout: ReturnType<typeof setTimeout>;
return (detail: T[K]) => {
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(detail);
}, wait);
};
}
}

View File

@@ -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<ExponentialBackoffOptions>): 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<T>(
fn: () => Promise<T>,
onError = (_error: Error) => {},
options?: Partial<ExponentialBackoffOptions>,
): Promise<T> {
const backoff = ExponentialBackoff.from(options);
return backoff.run(fn, onError);
}
private readonly options: ExponentialBackoffOptions;
constructor(options?: Partial<ExponentialBackoffOptions>) {
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<T>(
fn: () => Promise<T>,
onError = (_error: Error) => {},
): Promise<T> {
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;
};

435
src/utils/sse-session.ts Normal file
View File

@@ -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<SSESessionOptions> = {},
): Promise<SSESession> {
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<SSESessionOptions> = {}) {
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<void> {
// 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<void> {
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<SSEvent> = {};
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<T extends (...args: Parameters<T>) => ReturnType<T>>(
callback: T,
...args: Parameters<T>
): ReturnType<T> | 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<Response>;
/**
* HTTP method to use (GET or POST).
*/
method: "GET" | "POST";
/**
* HTTP headers to send with the request.
*/
headers?: Record<string, string>;
/**
* 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<RequestInit>;
/**
* 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;
}