Let AI go to work
This commit is contained in:
60
package-lock.json
generated
60
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
110
src/index.ts
110
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<string, OrderInvitationTracker>;
|
||||
};
|
||||
|
||||
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<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, 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);
|
||||
});
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './items.js';
|
||||
export * from './orders.js';
|
||||
export * from "./items.js";
|
||||
export * from "./orders.js";
|
||||
|
||||
@@ -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<Array<RouteOptions>> {
|
||||
return [
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/items',
|
||||
handler: this.getItems.bind(this),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/items/:id',
|
||||
handler: this.getItem.bind(this),
|
||||
},
|
||||
]
|
||||
public async getRoutes(): Promise<Array<RouteOptions>> {
|
||||
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(),
|
||||
});
|
||||
static getItemSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
}
|
||||
@@ -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<Array<RouteOptions>> {
|
||||
return [
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/orders',
|
||||
handler: this.getOrders.bind(this),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: '/orders',
|
||||
handler: this.createOrder.bind(this),
|
||||
},
|
||||
]
|
||||
public async getRoutes(): Promise<Array<RouteOptions>> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
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 () => {
|
||||
|
||||
164
src/services/invitation-sync-client.ts
Normal file
164
src/services/invitation-sync-client.ts
Normal 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;
|
||||
}
|
||||
192
src/services/order-invitation-tracker.ts
Normal file
192
src/services/order-invitation-tracker.ts
Normal 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());
|
||||
}
|
||||
252
src/services/order-payment-service.ts
Normal file
252
src/services/order-payment-service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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,12 +268,13 @@ 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
|
||||
? prunedByAge.slice(-this.maxHistorySize)
|
||||
: prunedByAge;
|
||||
const prunedBySize =
|
||||
prunedByAge.length > this.maxHistorySize
|
||||
? prunedByAge.slice(-this.maxHistorySize)
|
||||
: prunedByAge;
|
||||
|
||||
this.eventHistory.set(invitationId, prunedBySize);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
283
src/templates/vending-machine.ts
Normal file
283
src/templates/vending-machine.ts
Normal 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
156
src/utils/event-emitter.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
155
src/utils/exponential-backoff.ts
Normal file
155
src/utils/exponential-backoff.ts
Normal 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
435
src/utils/sse-session.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user