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",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@types/debug": "^4.1.13",
|
"@types/debug": "^4.1.13",
|
||||||
"@xo-cash/engine": "file:../../engine",
|
"@xo-cash/engine": "file:../../engine",
|
||||||
|
"@xo-cash/types": "0.0.1",
|
||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"kysely": "^0.29.2",
|
"kysely": "^0.29.2",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -64,6 +66,34 @@
|
|||||||
"vitest": "^4.0.17"
|
"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": {
|
"../engine": {
|
||||||
"name": "@xo-cash/engine",
|
"name": "@xo-cash/engine",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
@@ -103,6 +133,15 @@
|
|||||||
"vitest": "^4.0.17"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||||
@@ -721,6 +760,15 @@
|
|||||||
"resolved": "../../engine",
|
"resolved": "../../engine",
|
||||||
"link": true
|
"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": {
|
"node_modules/abstract-logging": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||||
@@ -945,6 +993,18 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
|||||||
@@ -25,8 +25,10 @@
|
|||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@types/debug": "^4.1.13",
|
"@types/debug": "^4.1.13",
|
||||||
"@xo-cash/engine": "file:../../engine",
|
"@xo-cash/engine": "file:../../engine",
|
||||||
|
"@xo-cash/types": "0.0.1",
|
||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"kysely": "^0.29.2",
|
"kysely": "^0.29.2",
|
||||||
"zod": "^4.4.3"
|
"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 { Engine } from "@xo-cash/engine";
|
||||||
|
|
||||||
import { Config } from "./services/config.js";
|
import { Config } from "./services/config.js";
|
||||||
|
|
||||||
import { Database } from "./services/database/database.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 { ItemsRoute } from "./routes/items.js";
|
||||||
import { OrdersRoute } from "./routes/orders.js";
|
import { OrdersRoute } from "./routes/orders.js";
|
||||||
import { HTTPService } from "./services/http-router.js";
|
|
||||||
|
|
||||||
type VendingMachineDeps = {
|
type VendingMachineDeps = {
|
||||||
config: Config;
|
config: Config;
|
||||||
httpService: HTTPService;
|
httpService: HTTPService;
|
||||||
database: Database;
|
database: Database;
|
||||||
engine: Engine;
|
engine: Engine;
|
||||||
}
|
orderPaymentService: OrderPaymentService;
|
||||||
|
trackers: Map<string, OrderInvitationTracker>;
|
||||||
|
};
|
||||||
|
|
||||||
export class VendingMachine {
|
export class VendingMachine {
|
||||||
static async from(config: Config) {
|
static async from(config: Config) {
|
||||||
const debug = Debug("vending-machine");
|
const debug = Debug("vending-machine");
|
||||||
|
|
||||||
const engine = await Engine.create(config.engine.mnemonic, { databasePath: config.engine.database.path });
|
debug("Config: %O", config);
|
||||||
const database = new Database({ config: config.database, debug });
|
|
||||||
|
|
||||||
// Create the routes
|
debug("Creating engine");
|
||||||
const routes = [
|
const engine = await Engine.create(config.engine.mnemonic, {
|
||||||
new ItemsRoute({ database: database, engine: engine, debug }),
|
databaseFilename: config.engine.database.fileName,
|
||||||
new OrdersRoute({ database: database, engine: engine, debug }),
|
databasePath: config.engine.database.path,
|
||||||
];
|
});
|
||||||
|
|
||||||
// Create the HTTP service, passing in the routes and config.
|
debug("Creating database");
|
||||||
const httpService = new HTTPService({ routes: [], config: config.server, debug });
|
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 });
|
debug("Creating trackers");
|
||||||
}
|
const trackers = new Map<string, OrderInvitationTracker>();
|
||||||
|
debug("Creating order payment service");
|
||||||
private constructor(private readonly deps: VendingMachineDeps) {}
|
const orderPaymentService = await OrderPaymentService.create({
|
||||||
|
engine,
|
||||||
public async start() {
|
database,
|
||||||
await this.deps.httpService.start();
|
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.from(Config.fromEnv())
|
||||||
vendingMachine.start();
|
.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 "./items.js";
|
||||||
export * from './orders.js';
|
export * from "./orders.js";
|
||||||
|
|||||||
@@ -1,73 +1,80 @@
|
|||||||
import type { Debugger as Debug } from 'debug';
|
import type { Debugger as Debug } from "debug";
|
||||||
import type { RouteOptions, FastifyRequest, FastifyReply } from 'fastify';
|
import type { RouteOptions, FastifyRequest, FastifyReply } from "fastify";
|
||||||
import type { Engine } from '@xo-cash/engine'
|
import type { Engine } from "@xo-cash/engine";
|
||||||
import type { Database } from '../services/database/database.js'
|
import type { Database } from "../services/database/database.js";
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
export type ItemsRouteDeps = {
|
export type ItemsRouteDeps = {
|
||||||
database: Database;
|
database: Database;
|
||||||
engine: Engine;
|
engine: Engine;
|
||||||
debug: Debug;
|
debug: Debug;
|
||||||
}
|
};
|
||||||
|
|
||||||
export class ItemsRoute {
|
export class ItemsRoute {
|
||||||
public constructor(private readonly deps: ItemsRouteDeps) {}
|
public constructor(private readonly deps: ItemsRouteDeps) {}
|
||||||
|
|
||||||
public async getRoutes(): Promise<Array<RouteOptions>> {
|
public async getRoutes(): Promise<Array<RouteOptions>> {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
url: '/items',
|
url: "/items",
|
||||||
handler: this.getItems.bind(this),
|
handler: this.getItems.bind(this),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
url: '/items/:id',
|
url: "/items/:id",
|
||||||
handler: this.getItem.bind(this),
|
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",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Return the item.
|
||||||
* Get all items from the database
|
return reply.send(item);
|
||||||
* @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.
|
static getItemSchema = z.object({
|
||||||
return reply.send(items);
|
id: z.string(),
|
||||||
}
|
});
|
||||||
|
}
|
||||||
/**
|
|
||||||
* 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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,88 +1,107 @@
|
|||||||
import type { Debugger as Debug } from 'debug';
|
import type { Debugger as Debug } from "debug";
|
||||||
import type { RouteOptions, FastifyRequest, FastifyReply } from 'fastify';
|
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";
|
||||||
|
|
||||||
|
import type { Database } from "../services/database/database.js";
|
||||||
|
import {
|
||||||
|
OrderPaymentError,
|
||||||
|
OrderPaymentService,
|
||||||
|
} from "../services/order-payment-service.js";
|
||||||
|
|
||||||
export type OrdersRouteDeps = {
|
export type OrdersRouteDeps = {
|
||||||
database: Database;
|
database: Database;
|
||||||
engine: Engine
|
orderPaymentService: OrderPaymentService;
|
||||||
|
syncServerUrl: string;
|
||||||
debug: Debug;
|
debug: Debug;
|
||||||
}
|
};
|
||||||
|
|
||||||
export class OrdersRoute {
|
export class OrdersRoute {
|
||||||
public constructor(private readonly deps: OrdersRouteDeps) {}
|
public constructor(private readonly deps: OrdersRouteDeps) {}
|
||||||
|
|
||||||
public async getRoutes(): Promise<Array<RouteOptions>> {
|
public async getRoutes(): Promise<Array<RouteOptions>> {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
url: '/orders',
|
url: "/orders",
|
||||||
handler: this.getOrders.bind(this),
|
handler: this.getOrders.bind(this),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: "GET",
|
||||||
url: '/orders',
|
url: "/orders/:id",
|
||||||
handler: this.createOrder.bind(this),
|
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) {
|
return reply.send({
|
||||||
// Get all orders from the database.
|
...order,
|
||||||
const orders = await this.deps.database.db.selectFrom('orders').selectAll().execute();
|
items: JSON.parse(order.items),
|
||||||
|
syncServerUrl: this.deps.syncServerUrl,
|
||||||
// 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(),
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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";
|
import { z } from "zod";
|
||||||
|
|
||||||
const configSchema = z.object({
|
const configSchema = z.object({
|
||||||
engine: z.object({
|
engine: z.object({
|
||||||
mnemonic: z.string(),
|
mnemonic: z.string().min(1, "ENGINE_MNEMONIC is required"),
|
||||||
database: z.object({
|
database: z.object({
|
||||||
path: z.string().default("data/engine"),
|
path: z.string().default("./data/xo"),
|
||||||
|
fileName: z.string().default("engine.db"),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
syncServer: z.object({
|
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({
|
database: z.object({
|
||||||
path: z.string().default("data.db"),
|
path: z.string().default("data.db"),
|
||||||
}),
|
}),
|
||||||
server: z.object({
|
server: z.object({
|
||||||
port: z.number().default(3000),
|
port: z.coerce.number().default(3000),
|
||||||
host: z.string().default("0.0.0.0"),
|
host: z.string().default("0.0.0.0"),
|
||||||
cors: z
|
cors: z
|
||||||
.object({
|
.object({
|
||||||
@@ -34,28 +40,33 @@ const configSchema = z.object({
|
|||||||
type ConfigInput = z.input<typeof configSchema>;
|
type ConfigInput = z.input<typeof configSchema>;
|
||||||
type ConfigSchema = z.output<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.
|
* The Config class is used to load and parse the configuration for the vending machine.
|
||||||
*/
|
*/
|
||||||
export class Config {
|
export class Config {
|
||||||
static fromEnv(): Config {
|
static fromEnv(): Config {
|
||||||
// Parse through process.env, and convert the upperCase keys to camelCase.
|
return this.from({
|
||||||
const envConfig = toCamelCaseObject(Object(process.env));
|
engine: {
|
||||||
|
mnemonic: process.env.ENGINE_MNEMONIC ?? "",
|
||||||
// Parse the environment config.
|
database: {
|
||||||
return this.from(configSchema.parse(envConfig));
|
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 {
|
static from(config: ConfigInput): Config {
|
||||||
@@ -66,6 +77,11 @@ export class Config {
|
|||||||
return this.config.syncServer;
|
return this.config.syncServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Remove merchant - eww.
|
||||||
|
public get merchant() {
|
||||||
|
return this.config.merchant;
|
||||||
|
}
|
||||||
|
|
||||||
public get engine() {
|
public get engine() {
|
||||||
return this.config.engine;
|
return this.config.engine;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class Database {
|
|||||||
public constructor(options: DatabaseOptionsInput) {
|
public constructor(options: DatabaseOptionsInput) {
|
||||||
// Parse the options with the zod schema.
|
// Parse the options with the zod schema.
|
||||||
const { config, debug } = databaseOptionsSchema.parse(options);
|
const { config, debug } = databaseOptionsSchema.parse(options);
|
||||||
|
|
||||||
// Extend the debug instance.
|
// Extend the debug instance.
|
||||||
this.debug = debug.extend("database");
|
this.debug = debug.extend("database");
|
||||||
|
|
||||||
@@ -48,7 +48,9 @@ export class Database {
|
|||||||
this.configurePragmas();
|
this.configurePragmas();
|
||||||
|
|
||||||
// Create the Kysely database client.
|
// 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("username", "text", (col) => col.notNull().unique())
|
||||||
.addColumn("password", "text", (col) => col.notNull())
|
.addColumn("password", "text", (col) => col.notNull())
|
||||||
.addColumn("salt", "text", (col) => col.notNull())
|
.addColumn("salt", "text", (col) => col.notNull())
|
||||||
.addColumn("created_at", "integer", (col) => col.notNull().defaultTo(millisecondTime))
|
.addColumn("created_at", "integer", (col) =>
|
||||||
.addColumn("updated_at", "integer", (col) => col.notNull().defaultTo(millisecondTime))
|
col.notNull().defaultTo(millisecondTime),
|
||||||
|
)
|
||||||
|
.addColumn("updated_at", "integer", (col) =>
|
||||||
|
col.notNull().defaultTo(millisecondTime),
|
||||||
|
)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -70,8 +74,12 @@ export async function up(db: Kysely<Database>): Promise<void> {
|
|||||||
.addColumn("quantity", "integer", (col) => col.notNull().defaultTo(0))
|
.addColumn("quantity", "integer", (col) => col.notNull().defaultTo(0))
|
||||||
// URL or path to the product image shown in the UI.
|
// URL or path to the product image shown in the UI.
|
||||||
.addColumn("image", "text", (col) => col.notNull().defaultTo(""))
|
.addColumn("image", "text", (col) => col.notNull().defaultTo(""))
|
||||||
.addColumn("created_at", "integer", (col) => col.notNull().defaultTo(millisecondTime))
|
.addColumn("created_at", "integer", (col) =>
|
||||||
.addColumn("updated_at", "integer", (col) => col.notNull().defaultTo(millisecondTime))
|
col.notNull().defaultTo(millisecondTime),
|
||||||
|
)
|
||||||
|
.addColumn("updated_at", "integer", (col) =>
|
||||||
|
col.notNull().defaultTo(millisecondTime),
|
||||||
|
)
|
||||||
.addCheckConstraint("items_price_check", sql`price >= 0`)
|
.addCheckConstraint("items_price_check", sql`price >= 0`)
|
||||||
.addCheckConstraint("items_quantity_check", sql`quantity >= 0`)
|
.addCheckConstraint("items_quantity_check", sql`quantity >= 0`)
|
||||||
.execute();
|
.execute();
|
||||||
@@ -88,8 +96,12 @@ export async function up(db: Kysely<Database>): Promise<void> {
|
|||||||
.addColumn("total_quantity", "integer", (col) => col.notNull().defaultTo(0))
|
.addColumn("total_quantity", "integer", (col) => col.notNull().defaultTo(0))
|
||||||
// JSON array of { id, quantity } objects; serialized by application code.
|
// JSON array of { id, quantity } objects; serialized by application code.
|
||||||
.addColumn("items", "text", (col) => col.notNull().defaultTo("[]"))
|
.addColumn("items", "text", (col) => col.notNull().defaultTo("[]"))
|
||||||
.addColumn("created_at", "integer", (col) => col.notNull().defaultTo(millisecondTime))
|
.addColumn("created_at", "integer", (col) =>
|
||||||
.addColumn("updated_at", "integer", (col) => col.notNull().defaultTo(millisecondTime))
|
col.notNull().defaultTo(millisecondTime),
|
||||||
|
)
|
||||||
|
.addColumn("updated_at", "integer", (col) =>
|
||||||
|
col.notNull().defaultTo(millisecondTime),
|
||||||
|
)
|
||||||
.addCheckConstraint(
|
.addCheckConstraint(
|
||||||
"orders_status_check",
|
"orders_status_check",
|
||||||
sql`status in ('pending', 'paid', 'completed', 'cancelled')`,
|
sql`status in ('pending', 'paid', 'completed', 'cancelled')`,
|
||||||
@@ -102,7 +114,11 @@ export async function up(db: Kysely<Database>): Promise<void> {
|
|||||||
// Indexes
|
// Indexes
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Look up catalog entries by display name.
|
// 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).
|
// Filter orders by lifecycle state (e.g. pending payments).
|
||||||
await db.schema
|
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.
|
// Keep updated_at in sync without requiring every query to set it explicitly.
|
||||||
for (const tableName of UPDATED_AT_TRIGGER_TABLES) {
|
for (const tableName of UPDATED_AT_TRIGGER_TABLES) {
|
||||||
await sql
|
await sql
|
||||||
.raw(`
|
.raw(
|
||||||
|
`
|
||||||
CREATE TRIGGER trg_${tableName}_updated_at
|
CREATE TRIGGER trg_${tableName}_updated_at
|
||||||
AFTER UPDATE ON ${tableName}
|
AFTER UPDATE ON ${tableName}
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
@@ -133,7 +150,8 @@ export async function up(db: Kysely<Database>): Promise<void> {
|
|||||||
SET updated_at = ${millisecondTimeRaw}
|
SET updated_at = ${millisecondTimeRaw}
|
||||||
WHERE id = NEW.id;
|
WHERE id = NEW.id;
|
||||||
END;
|
END;
|
||||||
`)
|
`,
|
||||||
|
)
|
||||||
.execute(db);
|
.execute(db);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,7 +162,9 @@ export async function up(db: Kysely<Database>): Promise<void> {
|
|||||||
export async function down(db: Kysely<Database>): Promise<void> {
|
export async function down(db: Kysely<Database>): Promise<void> {
|
||||||
// Remove triggers before dropping the tables they reference.
|
// Remove triggers before dropping the tables they reference.
|
||||||
for (const tableName of UPDATED_AT_TRIGGER_TABLES) {
|
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();
|
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
|
* The application stores timestamps as numbers and may provide explicit
|
||||||
* values or rely on database defaults.
|
* 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.
|
* SQLite JSON column represented as TEXT.
|
||||||
@@ -15,12 +19,20 @@ export type Timestamp = ColumnType<number, number | undefined, number | undefine
|
|||||||
* @remarks
|
* @remarks
|
||||||
* Serialize values with JSON.stringify and parse with JSON.parse in app code.
|
* 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).
|
* 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.
|
* Users table.
|
||||||
@@ -57,6 +69,7 @@ export interface OrdersTable {
|
|||||||
total_price: number;
|
total_price: number;
|
||||||
total_quantity: number;
|
total_quantity: number;
|
||||||
items: JsonText;
|
items: JsonText;
|
||||||
|
invitation_identifier: string | null;
|
||||||
created_at: Generated<Timestamp>;
|
created_at: Generated<Timestamp>;
|
||||||
updated_at: Generated<Timestamp>;
|
updated_at: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,23 @@ export const apiRoutesSchema = z.array(z.custom<APIRoutes>());
|
|||||||
export const serverConfigSchema = z.object({
|
export const serverConfigSchema = z.object({
|
||||||
port: z.number().default(3000),
|
port: z.number().default(3000),
|
||||||
host: z.string().default("0.0.0.0"),
|
host: z.string().default("0.0.0.0"),
|
||||||
cors: z.object({
|
cors: z
|
||||||
origin: z.string().default("*"),
|
.object({
|
||||||
methods: z.array(z.string()).default(["GET", "POST", "PUT", "DELETE", "OPTIONS"]),
|
origin: z.string().default("*"),
|
||||||
allowedHeaders: z.array(z.string()).default(["Content-Type", "Authorization"]),
|
methods: z
|
||||||
}).prefault({}),
|
.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.
|
// 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.
|
// Zod schema for the HTTP service options.
|
||||||
export const HTTPOptions = z.object({
|
export const HTTPOptions = z.object({
|
||||||
@@ -86,7 +94,7 @@ export class HTTPService {
|
|||||||
|
|
||||||
// Allow CORS requests. This allows requests from any origin/domain.
|
// 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.
|
// 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
|
// Register your routes here before starting the server
|
||||||
this.server.get("/health", async () => {
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ interface SSEOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-Sent Events broadcaster with event history support.
|
* Server-Sent Events broadcaster with event history support.
|
||||||
*
|
*
|
||||||
* Maintains a per-user event history buffer that allows clients to replay
|
* Maintains a per-user event history buffer that allows clients to replay
|
||||||
* missed events when reconnecting. This makes reconnections robust against
|
* missed events when reconnecting. This makes reconnections robust against
|
||||||
* network interruptions.
|
* network interruptions.
|
||||||
@@ -45,17 +45,17 @@ export class SSEBroadcaster {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Map of Invitation IDs to their connected SSE response streams */
|
/** 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 */
|
/** Map of Invitation IDs to their event history buffers */
|
||||||
private eventHistory: Map<string, HistoricalEvent[]> = new Map();
|
private eventHistory: Map<string, HistoricalEvent[]> = new Map();
|
||||||
|
|
||||||
/** Maximum age of events to keep in history (in milliseconds) */
|
/** Maximum age of events to keep in history (in milliseconds) */
|
||||||
private maxHistoryAge: number;
|
private maxHistoryAge: number;
|
||||||
|
|
||||||
/** Maximum number of events to keep per user */
|
/** Maximum number of events to keep per user */
|
||||||
private maxHistorySize: number;
|
private maxHistorySize: number;
|
||||||
|
|
||||||
private debug: Debugger;
|
private debug: Debugger;
|
||||||
|
|
||||||
constructor(options?: SSEOptions) {
|
constructor(options?: SSEOptions) {
|
||||||
@@ -63,7 +63,7 @@ export class SSEBroadcaster {
|
|||||||
this.eventHistory = new Map();
|
this.eventHistory = new Map();
|
||||||
this.maxHistoryAge = options?.maxHistoryAge ?? 20 * 60 * 1000; // 20 minutes default
|
this.maxHistoryAge = options?.maxHistoryAge ?? 20 * 60 * 1000; // 20 minutes default
|
||||||
this.maxHistorySize = options?.maxHistorySize ?? 1000; // 1000 events default
|
this.maxHistorySize = options?.maxHistorySize ?? 1000; // 1000 events default
|
||||||
this.debug = debug('xo:sse');
|
this.debug = debug("xo:sse");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,14 +71,17 @@ export class SSEBroadcaster {
|
|||||||
* @returns The SSE instance for chaining
|
* @returns The SSE instance for chaining
|
||||||
*/
|
*/
|
||||||
start() {
|
start() {
|
||||||
this.debug('SSE broadcaster is running (maxHistoryAge: %dms, maxHistorySize: %d)',
|
this.debug(
|
||||||
this.maxHistoryAge, this.maxHistorySize);
|
"SSE broadcaster is running (maxHistoryAge: %dms, maxHistorySize: %d)",
|
||||||
|
this.maxHistoryAge,
|
||||||
|
this.maxHistorySize,
|
||||||
|
);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends an event to a client.
|
* Sends an event to a client.
|
||||||
*
|
*
|
||||||
* @param client - The client to send the event to
|
* @param client - The client to send the event to
|
||||||
* @param topic - The event topic/type
|
* @param topic - The event topic/type
|
||||||
* @param data - The event payload data
|
* @param data - The event payload data
|
||||||
@@ -92,7 +95,7 @@ export class SSEBroadcaster {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends an event to a client.
|
* Sends an event to a client.
|
||||||
*
|
*
|
||||||
* @param client - The client to send the event to
|
* @param client - The client to send the event to
|
||||||
* @param topic - The event topic/type
|
* @param topic - The event topic/type
|
||||||
* @param data - The event payload data
|
* @param data - The event payload data
|
||||||
@@ -101,50 +104,56 @@ export class SSEBroadcaster {
|
|||||||
try {
|
try {
|
||||||
SSEBroadcaster.sendEvent(client, topic, data);
|
SSEBroadcaster.sendEvent(client, topic, data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.debug('Error sending event to client', error);
|
this.debug("Error sending event to client", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcasts an event to all connected clients for a user and stores it in history.
|
* Broadcasts an event to all connected clients for a user and stores it in history.
|
||||||
*
|
*
|
||||||
* @param clientId - The user ID to broadcast to
|
* @param clientId - The user ID to broadcast to
|
||||||
* @param topic - The event topic/type
|
* @param topic - The event topic/type
|
||||||
* @param data - The event payload data
|
* @param data - The event payload data
|
||||||
*/
|
*/
|
||||||
async broadcast(clientId: string, topic: string, data: unknown) {
|
async broadcast(clientId: string, topic: string, data: unknown) {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
||||||
// Store the event in history for potential replay
|
// Store the event in history for potential replay
|
||||||
this.storeEvent(clientId, topic, data, timestamp);
|
this.storeEvent(clientId, topic, data, timestamp);
|
||||||
|
|
||||||
// Broadcast to all connected clients
|
// Broadcast to all connected clients
|
||||||
this.clients.get(clientId)?.forEach((client: FastifyReply) => {
|
this.clients.get(clientId)?.forEach((client: FastifyReply) => {
|
||||||
try {
|
try {
|
||||||
this.sendEvent(client, topic, data);
|
this.sendEvent(client, topic, data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.debug('Error sending event to client', error);
|
this.debug("Error sending event to client", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.debug('SSE broadcasted message', topic, data);
|
this.debug("SSE broadcasted message", topic, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribes a client to receive SSE events.
|
* Subscribes a client to receive SSE events.
|
||||||
*
|
*
|
||||||
* If lastEventTime is provided, all events that occurred after that timestamp
|
* If lastEventTime is provided, all events that occurred after that timestamp
|
||||||
* will be replayed to the client before starting the live stream.
|
* will be replayed to the client before starting the live stream.
|
||||||
*
|
*
|
||||||
* @param req - The authenticated request containing the user ID
|
* @param req - The authenticated request containing the user ID
|
||||||
* @param res - The Express response object to use for SSE streaming
|
* @param res - The Express response object to use for SSE streaming
|
||||||
* @param lastEventTime - Optional timestamp to replay events from (in milliseconds)
|
* @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
|
// Get the invitation ID from the request
|
||||||
const { invitationIdentifier } = req.query as { invitationIdentifier?: string };
|
const { invitationIdentifier } = req.query as {
|
||||||
|
invitationIdentifier?: string;
|
||||||
|
};
|
||||||
if (!invitationIdentifier) {
|
if (!invitationIdentifier) {
|
||||||
throw new Error('Invitation Identifier is required');
|
throw new Error("Invitation Identifier is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize client set for this user if needed
|
// Initialize client set for this user if needed
|
||||||
@@ -174,19 +183,26 @@ export class SSEBroadcaster {
|
|||||||
res.raw.flushHeaders();
|
res.raw.flushHeaders();
|
||||||
|
|
||||||
// Set retry interval for automatic reconnection
|
// 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
|
// Replay missed events if a lastEventTime was provided
|
||||||
if (lastEventTime !== undefined) {
|
if (lastEventTime !== undefined) {
|
||||||
const missedEvents = this.getEventsAfter(invitationIdentifier, lastEventTime);
|
const missedEvents = this.getEventsAfter(
|
||||||
this.debug('SSE replaying %d missed events for invitation %s (since %d)',
|
invitationIdentifier,
|
||||||
missedEvents.length, invitationIdentifier, lastEventTime);
|
lastEventTime,
|
||||||
|
);
|
||||||
|
this.debug(
|
||||||
|
"SSE replaying %d missed events for invitation %s (since %d)",
|
||||||
|
missedEvents.length,
|
||||||
|
invitationIdentifier,
|
||||||
|
lastEventTime,
|
||||||
|
);
|
||||||
|
|
||||||
for (const event of missedEvents) {
|
for (const event of missedEvents) {
|
||||||
try {
|
try {
|
||||||
await this.sendEvent(res, event.topic, event.data);
|
await this.sendEvent(res, event.topic, event.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.debug('Error sending event to client', error);
|
this.debug("Error sending event to client", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,33 +210,44 @@ export class SSEBroadcaster {
|
|||||||
// Add client to the set for live updates
|
// Add client to the set for live updates
|
||||||
this.clients.get(invitationIdentifier)?.add(res);
|
this.clients.get(invitationIdentifier)?.add(res);
|
||||||
|
|
||||||
this.debug('SSE subscribed to client (invitationId: %s, lastEventTime: %s)',
|
this.debug(
|
||||||
invitationIdentifier, lastEventTime ?? 'none');
|
"SSE subscribed to client (invitationId: %s, lastEventTime: %s)",
|
||||||
|
invitationIdentifier,
|
||||||
|
lastEventTime ?? "none",
|
||||||
|
);
|
||||||
|
|
||||||
// Clean up when client disconnects
|
// Clean up when client disconnects
|
||||||
res.raw.on('close', () => {
|
res.raw.on("close", () => {
|
||||||
this.clients.get(invitationIdentifier)?.delete(res);
|
this.clients.get(invitationIdentifier)?.delete(res);
|
||||||
this.debug('SSE client disconnected (invitationIdentifier: %s)', invitationIdentifier);
|
this.debug(
|
||||||
|
"SSE client disconnected (invitationIdentifier: %s)",
|
||||||
|
invitationIdentifier,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores an event in the user's history buffer.
|
* Stores an event in the user's history buffer.
|
||||||
* Automatically prunes old events based on age and size limits.
|
* Automatically prunes old events based on age and size limits.
|
||||||
*
|
*
|
||||||
* @param invitationId - The invitation ID to store the event for
|
* @param invitationId - The invitation ID to store the event for
|
||||||
* @param topic - The event topic/type
|
* @param topic - The event topic/type
|
||||||
* @param data - The event payload data
|
* @param data - The event payload data
|
||||||
* @param timestamp - The event timestamp
|
* @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
|
// Initialize history array for this user if needed
|
||||||
if (!this.eventHistory.has(invitationId)) {
|
if (!this.eventHistory.has(invitationId)) {
|
||||||
this.eventHistory.set(invitationId, []);
|
this.eventHistory.set(invitationId, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = this.eventHistory.get(invitationId)!;
|
const history = this.eventHistory.get(invitationId)!;
|
||||||
|
|
||||||
// Add the new event
|
// Add the new event
|
||||||
history.push({ topic, data, timestamp });
|
history.push({ topic, data, timestamp });
|
||||||
|
|
||||||
@@ -230,7 +257,7 @@ export class SSEBroadcaster {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes old events from a invitation's history based on age and size limits.
|
* Removes old events from a invitation's history based on age and size limits.
|
||||||
*
|
*
|
||||||
* @param invitationId - The invitation ID whose history to prune
|
* @param invitationId - The invitation ID whose history to prune
|
||||||
* @param currentTime - The current timestamp for age calculations
|
* @param currentTime - The current timestamp for age calculations
|
||||||
*/
|
*/
|
||||||
@@ -239,32 +266,36 @@ export class SSEBroadcaster {
|
|||||||
if (!history) return;
|
if (!history) return;
|
||||||
|
|
||||||
const cutoffTime = currentTime - this.maxHistoryAge;
|
const cutoffTime = currentTime - this.maxHistoryAge;
|
||||||
|
|
||||||
// Remove events older than 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
|
// If still over size limit, remove oldest events
|
||||||
const prunedBySize = prunedByAge.length > this.maxHistorySize
|
const prunedBySize =
|
||||||
? prunedByAge.slice(-this.maxHistorySize)
|
prunedByAge.length > this.maxHistorySize
|
||||||
: prunedByAge;
|
? prunedByAge.slice(-this.maxHistorySize)
|
||||||
|
: prunedByAge;
|
||||||
|
|
||||||
this.eventHistory.set(invitationId, prunedBySize);
|
this.eventHistory.set(invitationId, prunedBySize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves all events for a user that occurred after a given timestamp.
|
* Retrieves all events for a user that occurred after a given timestamp.
|
||||||
*
|
*
|
||||||
* @param invitationId - The invitation ID to get events for
|
* @param invitationId - The invitation ID to get events for
|
||||||
* @param afterTimestamp - The timestamp to get events after (exclusive)
|
* @param afterTimestamp - The timestamp to get events after (exclusive)
|
||||||
* @returns Array of events that occurred after the timestamp
|
* @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);
|
const history = this.eventHistory.get(invitationId);
|
||||||
if (!history) return [];
|
if (!history) return [];
|
||||||
|
|
||||||
// First prune old events to ensure we don't return stale data
|
// First prune old events to ensure we don't return stale data
|
||||||
this.pruneHistory(invitationId, Date.now());
|
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