Initial Commit
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/node_modules
|
||||||
|
/dist
|
||||||
|
/build
|
||||||
|
/coverage
|
||||||
|
/logs
|
||||||
|
/tmp
|
||||||
|
/temp
|
||||||
|
/test
|
||||||
|
/tests
|
||||||
|
/test-results
|
||||||
|
/test-results.xml
|
||||||
|
/test-results.json
|
||||||
|
/test-results.xml
|
||||||
|
|
||||||
|
.env
|
||||||
|
xo-invitations*
|
||||||
1243
package-lock.json
generated
Normal file
1243
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "hono-sync-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "tsx src/app.ts",
|
||||||
|
"dev": "tsx watch src/app.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@bitauth/libauth": "^3.0.0",
|
||||||
|
"@hono/node-server": "^2.0.4",
|
||||||
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"hono": "^4.12.24",
|
||||||
|
"msgpackr": "^2.0.2",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/debug": "^4.1.13",
|
||||||
|
"@types/node": "^25.9.2",
|
||||||
|
"tsx": "^4.22.4",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/app.ts
Normal file
46
src/app.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { HTTPService } from './services/http-router.js';
|
||||||
|
import { InvitationsRoute } from './routes/invitations.js';
|
||||||
|
import { StorageSQLite } from './services/storage.js';
|
||||||
|
import { SSEBroadcaster } from './services/sse-broadcaster.js';
|
||||||
|
|
||||||
|
import type { InvitationSchema } from './utils/invitation-parser.js';
|
||||||
|
|
||||||
|
export class App {
|
||||||
|
static async create() {
|
||||||
|
// TODO: Make this configurable
|
||||||
|
const invitationStoragePath = "./xo-invitations.db";
|
||||||
|
|
||||||
|
// Create the invitation store (this is a in-memory store for now)
|
||||||
|
const storage = await StorageSQLite.createOrOpen(invitationStoragePath);
|
||||||
|
const invitationStore = await storage.createOrGetStore<InvitationSchema>("invitations");
|
||||||
|
|
||||||
|
// Create the SSE Broadcaster
|
||||||
|
const sseBroadcaster = new SSEBroadcaster();
|
||||||
|
|
||||||
|
// Create the Invitation route, passing in the invitation store and sse broadcaster
|
||||||
|
const invitationsRoute = new InvitationsRoute(invitationStore, sseBroadcaster);
|
||||||
|
|
||||||
|
// Create the HTTP service, passing in the invitation route
|
||||||
|
const http = new HTTPService([
|
||||||
|
invitationsRoute,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create the app instance, passing in the HTTP service
|
||||||
|
return new App(http);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new instance of App.
|
||||||
|
* @param http - The HTTP service instance.
|
||||||
|
*/
|
||||||
|
constructor(private readonly http: HTTPService) {}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
// Start the HTTP service
|
||||||
|
await this.http.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the app instance and start it
|
||||||
|
const app = await App.create();
|
||||||
|
await app.start();
|
||||||
1
src/routes/index.ts
Normal file
1
src/routes/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './invitations.js';
|
||||||
130
src/routes/invitations.ts
Normal file
130
src/routes/invitations.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import type { AnyRouteOptions, RouteEventHandlers, RouteRequest, RouteResponse } from './types.js';
|
||||||
|
|
||||||
|
type InvitationRouteResponse = RouteResponse<Partial<RouteEventHandlers>>;
|
||||||
|
|
||||||
|
import type { SSEBroadcaster } from '../services/sse-broadcaster.js';
|
||||||
|
import type { StoreSQLite } from '../services/storage.js';
|
||||||
|
import { parseInvitation } from '../utils/invitation-parser.js';
|
||||||
|
|
||||||
|
import Z from 'zod';
|
||||||
|
export class InvitationsRoute {
|
||||||
|
constructor(
|
||||||
|
private readonly invitationStore: StoreSQLite<Z.infer<typeof parseInvitation>>,
|
||||||
|
private readonly sseBroadcaster: SSEBroadcaster,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getRoutes(): Promise<Array<AnyRouteOptions>> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
url: '/invitations',
|
||||||
|
handler: this.getInvitation.bind(this),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
url: '/invitations',
|
||||||
|
handler: this.updateInvitation.bind(this),
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an invitation, and if the text/event-stream header is present, subscribe the client to the SSE stream.
|
||||||
|
* @param request - The request.
|
||||||
|
* @param reply - The reply.
|
||||||
|
* @returns The invitation.
|
||||||
|
*/
|
||||||
|
async getInvitation(request: RouteRequest, reply: InvitationRouteResponse): Promise<InvitationRouteResponse> {
|
||||||
|
// Get the invitation identifier from the query
|
||||||
|
const { invitationIdentifier } = request.query as { invitationIdentifier?: string };
|
||||||
|
|
||||||
|
// If the invitation identifier is not provided, return an error.
|
||||||
|
if (!invitationIdentifier) {
|
||||||
|
reply.status = 400;
|
||||||
|
reply.body = { error: 'Invitation Identifier is required' };
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the invitation from the store
|
||||||
|
const storedInvitation = await this.invitationStore.get(invitationIdentifier);
|
||||||
|
|
||||||
|
// If the client is not subscribing to the SSE stream, return the invitation.
|
||||||
|
if (request.headers['accept'] !== 'text/event-stream') {
|
||||||
|
reply.body = storedInvitation || {};
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Its an SSE request, so we need to subscribe the client to the SSE stream.
|
||||||
|
await this.sseBroadcaster.subscribe(request, reply);
|
||||||
|
|
||||||
|
// If the invitation doesn't exist, don't send anything.
|
||||||
|
if (!storedInvitation) {
|
||||||
|
reply.status = 204;
|
||||||
|
reply.body = {};
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the invitation to the client as if it was a get request.
|
||||||
|
this.sseBroadcaster.sendEvent(reply, 'invitation-updated', storedInvitation);
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the invitation.
|
||||||
|
* @param request - The request.
|
||||||
|
* @param reply - The reply.
|
||||||
|
* @returns The merged invitation.
|
||||||
|
*/
|
||||||
|
async updateInvitation(request: RouteRequest, reply: InvitationRouteResponse): Promise<InvitationRouteResponse> {
|
||||||
|
// Parse the invitation
|
||||||
|
const invitation = parseInvitation.parse(request.body);
|
||||||
|
|
||||||
|
// Get the existing invitation
|
||||||
|
const existingInvitation = await this.invitationStore.get(invitation.invitationIdentifier);
|
||||||
|
|
||||||
|
// Merge the existing invitation with the new invitation
|
||||||
|
const mergedInvitation = InvitationsRoute.mergeInvitations(invitation, existingInvitation);
|
||||||
|
|
||||||
|
// Store the merged invitation
|
||||||
|
await this.invitationStore.set(invitation.invitationIdentifier, mergedInvitation);
|
||||||
|
|
||||||
|
// Broadcast the invitation update (We send down the whole invitation. Clients will have to compare commitIds)
|
||||||
|
await this.sseBroadcaster.broadcast(invitation.invitationIdentifier, 'invitation-updated', invitation);
|
||||||
|
|
||||||
|
reply.status = 200;
|
||||||
|
reply.body = mergedInvitation;
|
||||||
|
|
||||||
|
// Return the reply.
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge two invitations by commit identifiers.
|
||||||
|
* This wont work in an actual commit merging situation since the invitations will be encrypted.
|
||||||
|
*
|
||||||
|
* @param invitation1 - The first invitation.
|
||||||
|
* @param invitation2 - The second invitation.
|
||||||
|
* @returns The merged invitation.
|
||||||
|
*/
|
||||||
|
static mergeInvitations(invitation1: Z.infer<typeof parseInvitation>, invitation2: Z.infer<typeof parseInvitation> | undefined): Z.infer<typeof parseInvitation> {
|
||||||
|
// Initialize the result with the first invitation.
|
||||||
|
const result = invitation1;
|
||||||
|
|
||||||
|
// Loop over the commits in the second invitation.
|
||||||
|
for(const commit of invitation2?.commits ?? []) {
|
||||||
|
// If the commit already exists in the result, skip it.
|
||||||
|
if(result.commits.some(c => c.commitIdentifier === commit.commitIdentifier)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the commit to the result.
|
||||||
|
result.commits.push(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the merged invitation.
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static parseInvitation = parseInvitation
|
||||||
|
}
|
||||||
113
src/routes/types.ts
Normal file
113
src/routes/types.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Canonical shape for route-level response event handlers.
|
||||||
|
*
|
||||||
|
* Router implementations define what they can provide by extending this map.
|
||||||
|
*/
|
||||||
|
export type RouteEventHandlers = Record<string, (...args: unknown[]) => void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility type for a route that does not require any response events.
|
||||||
|
*/
|
||||||
|
export type EmptyRouteEventHandlers = Record<never, never>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes that use any {@link RouteResponse} implementation.
|
||||||
|
*/
|
||||||
|
export type AnyRouteOptions = RouteOptions<Partial<RouteEventHandlers>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RouteOptions defines a route configuration.
|
||||||
|
*
|
||||||
|
* The generic parameter `RequiredEvents` specifies what events the route handler requires
|
||||||
|
* from the response object. Routes that don't need any events should use the default (empty object).
|
||||||
|
*
|
||||||
|
* @template RequiredEvents - The events this route requires from the response (default: none)
|
||||||
|
*/
|
||||||
|
export type RouteOptions<RequiredEvents extends Partial<RouteEventHandlers> = EmptyRouteEventHandlers> = {
|
||||||
|
url: string;
|
||||||
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS';
|
||||||
|
handler: (req: RouteRequest, res: RouteResponse<RequiredEvents>) => Promise<RouteResponse<RequiredEvents>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic HTTP Request class that can be extended for specific HTTP routers, eg. express, fastify, elysia
|
||||||
|
*
|
||||||
|
* NOTE: This is likely incomplete. Some special actions may require additional properties or methods.
|
||||||
|
* TODO: Add to this as we find more properties that are needed for specific actions.
|
||||||
|
*/
|
||||||
|
export abstract class RouteRequest {
|
||||||
|
/** The body of the request */
|
||||||
|
abstract body: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** The query parameters of the request */
|
||||||
|
abstract query: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** The path parameters of the request */
|
||||||
|
abstract params: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** The headers of the request */
|
||||||
|
abstract headers: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** The cookies of the request */
|
||||||
|
abstract cookies: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** The raw request object from the HTTP router */
|
||||||
|
abstract raw: {
|
||||||
|
/** Set the timeout for the request */
|
||||||
|
setTimeout: (timeout: number) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic HTTP Response class that can be extended for specific HTTP routers.
|
||||||
|
*
|
||||||
|
* The generic parameter `EventHandlers` defines what events this response provides.
|
||||||
|
* Implementations should specify all events they support.
|
||||||
|
*
|
||||||
|
* NOTE: This is likely incomplete. Some special actions may require additional properties or methods.
|
||||||
|
* TODO: Add to this as we find more properties that are needed for specific actions.
|
||||||
|
*
|
||||||
|
* @template EventHandlers - The events this response implementation provides (default: none)
|
||||||
|
*/
|
||||||
|
export abstract class RouteResponse<EventHandlers extends Partial<RouteEventHandlers> = EmptyRouteEventHandlers> {
|
||||||
|
/**
|
||||||
|
* Internal compile-time brand carrying the concrete event map.
|
||||||
|
* This is intentionally optional and has no runtime effect.
|
||||||
|
*/
|
||||||
|
readonly __eventHandlersBrand?: EventHandlers;
|
||||||
|
|
||||||
|
/** The status code of the response */
|
||||||
|
abstract status: number;
|
||||||
|
|
||||||
|
/** The body of the response */
|
||||||
|
abstract body: Record<string, unknown> | unknown;
|
||||||
|
|
||||||
|
/** The headers of the response */
|
||||||
|
abstract headers: Record<string, string>;
|
||||||
|
|
||||||
|
/** The raw response object from the HTTP router */
|
||||||
|
abstract raw: {
|
||||||
|
/** Set a header of the response */
|
||||||
|
setHeader: (key: string, value: string) => void;
|
||||||
|
|
||||||
|
/** Write the headers of the response */
|
||||||
|
writeHead: (status: number, headers: Record<string, string>) => void;
|
||||||
|
|
||||||
|
/** Write the body of the response */
|
||||||
|
write: (data: string) => void;
|
||||||
|
|
||||||
|
/** Flush the headers of the response */
|
||||||
|
flushHeaders: () => void;
|
||||||
|
|
||||||
|
/** End the response */
|
||||||
|
end: () => void;
|
||||||
|
|
||||||
|
/** Add an event listener to the response */
|
||||||
|
on: <K extends keyof EventHandlers>(event: K, callback: EventHandlers[K]) => void;
|
||||||
|
|
||||||
|
/** Remove an event listener from the response */
|
||||||
|
off: <K extends keyof EventHandlers>(event: K, callback: EventHandlers[K]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract ignore: boolean;
|
||||||
|
}
|
||||||
345
src/services/http-router.ts
Normal file
345
src/services/http-router.ts
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
import debug from "debug";
|
||||||
|
|
||||||
|
import { type Context, Hono } from "hono";
|
||||||
|
import { type HttpBindings, serve } from "@hono/node-server";
|
||||||
|
import type { ContentfulStatusCode, StatusCode } from "hono/utils/http-status";
|
||||||
|
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
import { getCookie } from "hono/cookie";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
decodeExtendedJsonObject,
|
||||||
|
encodeExtendedJson,
|
||||||
|
} from "../utils/ext-json.js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyRouteOptions,
|
||||||
|
type RouteEventHandlers,
|
||||||
|
type RouteOptions,
|
||||||
|
RouteRequest,
|
||||||
|
RouteResponse,
|
||||||
|
} from "../routes/types.js";
|
||||||
|
|
||||||
|
/** Context variable key for ExtJSON-decoded request bodies. */
|
||||||
|
const PARSED_BODY_KEY = "parsedBody";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hono environment bindings used by this service.
|
||||||
|
* Includes Node's raw request/response objects for SSE streaming.
|
||||||
|
*/
|
||||||
|
type AppEnv = {
|
||||||
|
Bindings: HttpBindings;
|
||||||
|
Variables: {
|
||||||
|
parsedBody?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Interface to add to our route classes so that we can register them.
|
||||||
|
// NOTE: I hate this pattern. But ExpressJS is odd in that it is structured as a singleton that still needs registration.
|
||||||
|
export interface APIRoutes {
|
||||||
|
getRoutes(): Promise<Array<AnyRouteOptions>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HTTPService {
|
||||||
|
private debug: debug.Debugger;
|
||||||
|
private server: Hono<AppEnv>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private routes: Array<APIRoutes> = [],
|
||||||
|
private port: number = 3000,
|
||||||
|
private host: string = "0.0.0.0",
|
||||||
|
) {
|
||||||
|
this.debug = debug("xo:http-router");
|
||||||
|
|
||||||
|
this.server = new Hono<AppEnv>();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.debug(`Starting on http://${this.host}:${this.port}`);
|
||||||
|
|
||||||
|
// Setup ExtJSON handling.
|
||||||
|
this.handleExtJSON();
|
||||||
|
|
||||||
|
// Setup Error Handling (to give more verbose Zod errors)
|
||||||
|
this.handleErrors();
|
||||||
|
|
||||||
|
// Allow CORS requests. This allows requests from any origin/domain.
|
||||||
|
// Capacitor apps (like XO Wallet) use localhost:3000 as the origin, making it difficult to meaningfully restrict requests by origin for security.
|
||||||
|
this.server.use("*", cors());
|
||||||
|
|
||||||
|
// Register your routes here before starting the server
|
||||||
|
this.server.get("/health", async (c) => {
|
||||||
|
return c.json({ status: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register each route.
|
||||||
|
for (const routes of this.routes) {
|
||||||
|
for (const routeOptions of await routes.getRoutes()) {
|
||||||
|
const method = routeOptions.method.toLowerCase() as Lowercase<
|
||||||
|
RouteOptions["method"]
|
||||||
|
>;
|
||||||
|
const register = this.server[method].bind(this.server) as (
|
||||||
|
path: string,
|
||||||
|
handler: (c: Context<AppEnv>) => Promise<Response | typeof RESPONSE_ALREADY_SENT>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
register(routeOptions.url, async (c) => {
|
||||||
|
const req = await HonoRequest.fromContext(c);
|
||||||
|
const res = new HonoResponse(c);
|
||||||
|
const result = await routeOptions.handler(req, res);
|
||||||
|
return HonoResponse.finalize(c, result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serve({
|
||||||
|
fetch: this.server.fetch,
|
||||||
|
port: this.port,
|
||||||
|
hostname: this.host,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.debug(`Started on http://${this.host}:${this.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to access the server instance
|
||||||
|
getInstance(): Hono<AppEnv> {
|
||||||
|
return this.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleErrors() {
|
||||||
|
// Customize our error handler to give better errors.
|
||||||
|
// NOTE: This will nicely format the Zod validation errors.
|
||||||
|
this.server.onError((error: Error, c) => {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
const formattedErrors = error.issues.map((issue) => ({
|
||||||
|
path: issue.path.join("."),
|
||||||
|
message: issue.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.debug(`Error: ${error}`);
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
statusCode: 400,
|
||||||
|
error: "Validation Error",
|
||||||
|
details: formattedErrors,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debug(`Error: ${error}`);
|
||||||
|
|
||||||
|
// Handle other types of errors
|
||||||
|
return c.json({ error: "Internal Server Error" }, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleExtJSON() {
|
||||||
|
this.server.use("*", async (c, next) => {
|
||||||
|
this.debug(`Request URL: ${c.req.method} ${c.req.url}`);
|
||||||
|
|
||||||
|
const contentType = c.req.header("content-type");
|
||||||
|
if (contentType?.includes("application/json")) {
|
||||||
|
try {
|
||||||
|
const rawBody = await c.req.json();
|
||||||
|
this.debug(`Request: ${JSON.stringify(rawBody)}`);
|
||||||
|
c.set(PARSED_BODY_KEY, decodeExtendedJsonObject(rawBody));
|
||||||
|
} catch (error) {
|
||||||
|
this.debug(`Failed to decode ExtJSON request body: ${error}`);
|
||||||
|
throw new Error("Invalid JSON in request body");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
|
||||||
|
await encodeJsonResponse(c);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hono adapter for the generic {@link RouteRequest}.
|
||||||
|
*/
|
||||||
|
export class HonoRequest extends RouteRequest {
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
query: Record<string, unknown>;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
headers: Record<string, unknown>;
|
||||||
|
cookies: Record<string, unknown>;
|
||||||
|
raw: {
|
||||||
|
setTimeout: (timeout: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
context: Context<AppEnv>,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.body = body;
|
||||||
|
this.query = { ...context.req.query() };
|
||||||
|
this.params = { ...context.req.param() };
|
||||||
|
this.headers = { ...context.req.header() };
|
||||||
|
this.cookies = { ...getCookie(context) };
|
||||||
|
|
||||||
|
const incoming = context.env.incoming;
|
||||||
|
this.raw = {
|
||||||
|
setTimeout: (timeout: number) => {
|
||||||
|
incoming.socket.setTimeout(timeout);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a {@link RouteRequest} from the active Hono context.
|
||||||
|
* Request bodies are parsed once and prefer the ExtJSON-decoded value when present.
|
||||||
|
*/
|
||||||
|
static async fromContext(context: Context<AppEnv>): Promise<HonoRequest> {
|
||||||
|
const parsedBody = context.get(PARSED_BODY_KEY);
|
||||||
|
if (parsedBody !== undefined) {
|
||||||
|
return new HonoRequest(context, parsedBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = context.req.header("content-type");
|
||||||
|
if (contentType?.includes("application/json")) {
|
||||||
|
const json = await context.req.json();
|
||||||
|
const body =
|
||||||
|
json !== null && typeof json === "object"
|
||||||
|
? (json as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
return new HonoRequest(context, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HonoRequest(context, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hono adapter for the generic {@link RouteResponse}.
|
||||||
|
*
|
||||||
|
* When running on Node via `@hono/node-server`, `raw` delegates to the native
|
||||||
|
* `ServerResponse` so SSE routes can use the same streaming API as Fastify.
|
||||||
|
*/
|
||||||
|
export class HonoResponse<
|
||||||
|
EventHandlers extends Partial<RouteEventHandlers> = Partial<RouteEventHandlers>,
|
||||||
|
> extends RouteResponse<EventHandlers> {
|
||||||
|
status = 200;
|
||||||
|
body: Record<string, unknown> | unknown;
|
||||||
|
headers: Record<string, string> = {};
|
||||||
|
ignore = false;
|
||||||
|
raw: RouteResponse<EventHandlers>["raw"];
|
||||||
|
|
||||||
|
/** Whether the handler already wrote directly to the Node response. */
|
||||||
|
rawResponseSent = false;
|
||||||
|
|
||||||
|
constructor(private readonly context: Context<AppEnv>) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
const outgoing = context.env.outgoing;
|
||||||
|
this.raw = {
|
||||||
|
setHeader: (key, value) => {
|
||||||
|
outgoing.setHeader(key, value);
|
||||||
|
this.headers[key.toLowerCase()] = value;
|
||||||
|
},
|
||||||
|
writeHead: (status, responseHeaders) => {
|
||||||
|
this.status = status;
|
||||||
|
this.rawResponseSent = true;
|
||||||
|
outgoing.writeHead(status, responseHeaders);
|
||||||
|
for (const [key, value] of Object.entries(responseHeaders)) {
|
||||||
|
this.headers[key.toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
write: (data) => {
|
||||||
|
this.rawResponseSent = true;
|
||||||
|
outgoing.write(data);
|
||||||
|
},
|
||||||
|
flushHeaders: () => {
|
||||||
|
outgoing.flushHeaders();
|
||||||
|
},
|
||||||
|
end: () => {
|
||||||
|
this.rawResponseSent = true;
|
||||||
|
outgoing.end();
|
||||||
|
},
|
||||||
|
on: (event, callback) => {
|
||||||
|
outgoing.on(String(event), callback as (...args: unknown[]) => void);
|
||||||
|
},
|
||||||
|
off: (event, callback) => {
|
||||||
|
outgoing.off(String(event), callback as (...args: unknown[]) => void);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a response header, matching Fastify's `reply.getHeader` behavior.
|
||||||
|
*/
|
||||||
|
getHeader(name: string): string | undefined {
|
||||||
|
const value = this.context.env.outgoing.getHeader(name);
|
||||||
|
if (value === undefined) {
|
||||||
|
return this.headers[name.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(value) ? value.join(", ") : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a route handler result into a value Hono can return.
|
||||||
|
*/
|
||||||
|
static finalize(
|
||||||
|
context: Context<AppEnv>,
|
||||||
|
response: RouteResponse,
|
||||||
|
): Response | typeof RESPONSE_ALREADY_SENT {
|
||||||
|
const honoResponse = response as HonoResponse;
|
||||||
|
|
||||||
|
if (response.ignore || honoResponse.rawResponseSent) {
|
||||||
|
return RESPONSE_ALREADY_SENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = response.status as StatusCode;
|
||||||
|
|
||||||
|
if (response.body === undefined) {
|
||||||
|
return context.body(null, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
response.headers["content-type"] ?? response.headers["Content-Type"];
|
||||||
|
if (contentType?.includes("text/")) {
|
||||||
|
return context.text(String(response.body), status as ContentfulStatusCode, response.headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(encodeExtendedJson(response.body), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...response.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a JSON response body to Extended JSON.
|
||||||
|
* Mirrors the Fastify `onSend` hook: parse string payloads first, then encode.
|
||||||
|
*/
|
||||||
|
async function encodeJsonResponse(context: Context<AppEnv>): Promise<void> {
|
||||||
|
const responseContentType = context.res.headers.get("content-type");
|
||||||
|
if (!responseContentType?.includes("application/json")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloned = context.res.clone();
|
||||||
|
const payload = await cloned.text();
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(payload);
|
||||||
|
const encoded = encodeExtendedJson(data);
|
||||||
|
|
||||||
|
context.res = new Response(encoded, {
|
||||||
|
status: context.res.status,
|
||||||
|
headers: context.res.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
274
src/services/sse-broadcaster.ts
Normal file
274
src/services/sse-broadcaster.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import debug, { type Debugger } from "debug";
|
||||||
|
import type { RouteEventHandlers, RouteRequest, RouteResponse } from "../routes/types.js";
|
||||||
|
|
||||||
|
/** SSE clients use the generic response stream event API. */
|
||||||
|
type SSERouteResponse = RouteResponse<Partial<RouteEventHandlers>>;
|
||||||
|
|
||||||
|
import { encodeExtendedJson } from "../utils/ext-json.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an event stored in the history buffer.
|
||||||
|
* Used for replaying missed events to reconnecting clients.
|
||||||
|
*/
|
||||||
|
interface HistoricalEvent {
|
||||||
|
/** The event topic/type (e.g., 'invitation-created', 'invitation-updated') */
|
||||||
|
topic: string;
|
||||||
|
/** The event payload data */
|
||||||
|
data: unknown;
|
||||||
|
/** Unix timestamp in milliseconds when the event was created */
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for configuring the SSE service.
|
||||||
|
*/
|
||||||
|
interface SSEOptions {
|
||||||
|
/** Maximum age of events to keep in history (in milliseconds). Default: 5 minutes */
|
||||||
|
maxHistoryAge?: number;
|
||||||
|
/** Maximum number of events to keep per user. Default: 1000 */
|
||||||
|
maxHistorySize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-Sent Events broadcaster with event history support.
|
||||||
|
*
|
||||||
|
* Maintains a per-user event history buffer that allows clients to replay
|
||||||
|
* missed events when reconnecting. This makes reconnections robust against
|
||||||
|
* network interruptions.
|
||||||
|
*/
|
||||||
|
export class SSEBroadcaster {
|
||||||
|
/**
|
||||||
|
* Factory method to create and start an SSE broadcaster.
|
||||||
|
* @param options - Configuration options for the SSE service
|
||||||
|
* @returns A started SSE instance
|
||||||
|
*/
|
||||||
|
static async from(options?: SSEOptions) {
|
||||||
|
const broadcaster = new SSEBroadcaster(options);
|
||||||
|
await broadcaster.start();
|
||||||
|
return broadcaster;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map of Invitation IDs to their connected SSE response streams */
|
||||||
|
private clients: Map<string, Set<SSERouteResponse>> = new Map();
|
||||||
|
|
||||||
|
/** Map of Invitation IDs to their event history buffers */
|
||||||
|
private eventHistory: Map<string, HistoricalEvent[]> = new Map();
|
||||||
|
|
||||||
|
/** Maximum age of events to keep in history (in milliseconds) */
|
||||||
|
private maxHistoryAge: number;
|
||||||
|
|
||||||
|
/** Maximum number of events to keep per user */
|
||||||
|
private maxHistorySize: number;
|
||||||
|
|
||||||
|
private debug: Debugger;
|
||||||
|
|
||||||
|
constructor(options?: SSEOptions) {
|
||||||
|
this.clients = new Map();
|
||||||
|
this.eventHistory = new Map();
|
||||||
|
this.maxHistoryAge = options?.maxHistoryAge ?? 20 * 60 * 1000; // 20 minutes default
|
||||||
|
this.maxHistorySize = options?.maxHistorySize ?? 1000; // 1000 events default
|
||||||
|
this.debug = debug('xo:sse');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the SSE broadcaster.
|
||||||
|
* @returns The SSE instance for chaining
|
||||||
|
*/
|
||||||
|
start() {
|
||||||
|
this.debug('SSE broadcaster is running (maxHistoryAge: %dms, maxHistorySize: %d)',
|
||||||
|
this.maxHistoryAge, this.maxHistorySize);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an event to a client.
|
||||||
|
*
|
||||||
|
* @param client - The client to send the event to
|
||||||
|
* @param topic - The event topic/type
|
||||||
|
* @param data - The event payload data
|
||||||
|
*/
|
||||||
|
static sendEvent(client: SSERouteResponse, topic: string, data: unknown) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
client.raw.write(`id: ${timestamp}\n`);
|
||||||
|
client.raw.write(`event: ${topic}\n`);
|
||||||
|
client.raw.write(`data: ${encodeExtendedJson(data)}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an event to a client.
|
||||||
|
*
|
||||||
|
* @param client - The client to send the event to
|
||||||
|
* @param topic - The event topic/type
|
||||||
|
* @param data - The event payload data
|
||||||
|
*/
|
||||||
|
sendEvent(client: SSERouteResponse, topic: string, data: unknown) {
|
||||||
|
try {
|
||||||
|
SSEBroadcaster.sendEvent(client, topic, data);
|
||||||
|
} catch (error) {
|
||||||
|
this.debug('Error sending event to client', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcasts an event to all connected clients for a user and stores it in history.
|
||||||
|
*
|
||||||
|
* @param clientId - The user ID to broadcast to
|
||||||
|
* @param topic - The event topic/type
|
||||||
|
* @param data - The event payload data
|
||||||
|
*/
|
||||||
|
async broadcast(clientId: string, topic: string, data: unknown) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
// Store the event in history for potential replay
|
||||||
|
this.storeEvent(clientId, topic, data, timestamp);
|
||||||
|
|
||||||
|
// Broadcast to all connected clients
|
||||||
|
this.clients.get(clientId)?.forEach((client: SSERouteResponse) => {
|
||||||
|
try {
|
||||||
|
this.sendEvent(client, topic, data);
|
||||||
|
} catch (error) {
|
||||||
|
this.debug('Error sending event to client', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.debug('SSE broadcasted message', topic, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes a client to receive SSE events.
|
||||||
|
*
|
||||||
|
* If lastEventTime is provided, all events that occurred after that timestamp
|
||||||
|
* will be replayed to the client before starting the live stream.
|
||||||
|
*
|
||||||
|
* @param req - The authenticated request containing the user ID
|
||||||
|
* @param res - The Express response object to use for SSE streaming
|
||||||
|
* @param lastEventTime - Optional timestamp to replay events from (in milliseconds)
|
||||||
|
*/
|
||||||
|
async subscribe(req: RouteRequest, res: SSERouteResponse, lastEventTime?: number) {
|
||||||
|
// Get the invitation ID from the request
|
||||||
|
const { invitationIdentifier } = req.query as { invitationIdentifier?: string };
|
||||||
|
if (!invitationIdentifier) {
|
||||||
|
throw new Error('Invitation Identifier is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize client set for this user if needed
|
||||||
|
if (!this.clients.has(invitationIdentifier)) {
|
||||||
|
this.clients.set(invitationIdentifier, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually include the CORS header since `writeHead` bypasses the auto-injection by the @fastify/cors plugin.
|
||||||
|
// This statement grabs the CORS header that the CORS plugin would have added to the response, configured in the HTTP service.
|
||||||
|
const corsHeader = res.headers["access-control-allow-origin"] ?? "";
|
||||||
|
|
||||||
|
// Disable timeout for the connection. Without this, the connection will drop and the client will have to reconnect.
|
||||||
|
req.raw.setTimeout(0);
|
||||||
|
|
||||||
|
// Set up SSE headers
|
||||||
|
// Set headers for SSE
|
||||||
|
res.raw.writeHead(200, {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"Access-Control-Allow-Origin": corsHeader,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force the headers to be dispatched to the client.
|
||||||
|
// NOTE: This is very important: A `fetch` call will NOT resolve until it has received the headers.
|
||||||
|
// And Fastify, unless otherwise specified, will not send the headers until it sends the body.
|
||||||
|
res.raw.flushHeaders();
|
||||||
|
|
||||||
|
// Set retry interval for automatic reconnection
|
||||||
|
res.raw.write('retry: 3000\n\n');
|
||||||
|
|
||||||
|
// Replay missed events if a lastEventTime was provided
|
||||||
|
if (lastEventTime !== undefined) {
|
||||||
|
const missedEvents = this.getEventsAfter(invitationIdentifier, lastEventTime);
|
||||||
|
this.debug('SSE replaying %d missed events for invitation %s (since %d)',
|
||||||
|
missedEvents.length, invitationIdentifier, lastEventTime);
|
||||||
|
|
||||||
|
for (const event of missedEvents) {
|
||||||
|
try {
|
||||||
|
await this.sendEvent(res, event.topic, event.data);
|
||||||
|
} catch (error) {
|
||||||
|
this.debug('Error sending event to client', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add client to the set for live updates
|
||||||
|
this.clients.get(invitationIdentifier)?.add(res);
|
||||||
|
|
||||||
|
this.debug('SSE subscribed to client (invitationId: %s, lastEventTime: %s)',
|
||||||
|
invitationIdentifier, lastEventTime ?? 'none');
|
||||||
|
|
||||||
|
// Clean up when client disconnects
|
||||||
|
res.raw.on('close', () => {
|
||||||
|
this.clients.get(invitationIdentifier)?.delete(res);
|
||||||
|
this.debug('SSE client disconnected (invitationIdentifier: %s)', invitationIdentifier);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores an event in the user's history buffer.
|
||||||
|
* Automatically prunes old events based on age and size limits.
|
||||||
|
*
|
||||||
|
* @param invitationId - The invitation ID to store the event for
|
||||||
|
* @param topic - The event topic/type
|
||||||
|
* @param data - The event payload data
|
||||||
|
* @param timestamp - The event timestamp
|
||||||
|
*/
|
||||||
|
private storeEvent(invitationId: string, topic: string, data: unknown, timestamp: number) {
|
||||||
|
// Initialize history array for this user if needed
|
||||||
|
if (!this.eventHistory.has(invitationId)) {
|
||||||
|
this.eventHistory.set(invitationId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = this.eventHistory.get(invitationId)!;
|
||||||
|
|
||||||
|
// Add the new event
|
||||||
|
history.push({ topic, data, timestamp });
|
||||||
|
|
||||||
|
// Prune old events
|
||||||
|
this.pruneHistory(invitationId, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes old events from a invitation's history based on age and size limits.
|
||||||
|
*
|
||||||
|
* @param invitationId - The invitation ID whose history to prune
|
||||||
|
* @param currentTime - The current timestamp for age calculations
|
||||||
|
*/
|
||||||
|
private pruneHistory(invitationId: string, currentTime: number) {
|
||||||
|
const history = this.eventHistory.get(invitationId);
|
||||||
|
if (!history) return;
|
||||||
|
|
||||||
|
const cutoffTime = currentTime - this.maxHistoryAge;
|
||||||
|
|
||||||
|
// Remove events older than maxHistoryAge
|
||||||
|
const prunedByAge = history.filter(event => event.timestamp > cutoffTime);
|
||||||
|
|
||||||
|
// If still over size limit, remove oldest events
|
||||||
|
const prunedBySize = prunedByAge.length > this.maxHistorySize
|
||||||
|
? prunedByAge.slice(-this.maxHistorySize)
|
||||||
|
: prunedByAge;
|
||||||
|
|
||||||
|
this.eventHistory.set(invitationId, prunedBySize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all events for a user that occurred after a given timestamp.
|
||||||
|
*
|
||||||
|
* @param invitationId - The invitation ID to get events for
|
||||||
|
* @param afterTimestamp - The timestamp to get events after (exclusive)
|
||||||
|
* @returns Array of events that occurred after the timestamp
|
||||||
|
*/
|
||||||
|
private getEventsAfter(invitationId: string, afterTimestamp: number): HistoricalEvent[] {
|
||||||
|
const history = this.eventHistory.get(invitationId);
|
||||||
|
if (!history) return [];
|
||||||
|
|
||||||
|
// First prune old events to ensure we don't return stale data
|
||||||
|
this.pruneHistory(invitationId, Date.now());
|
||||||
|
|
||||||
|
return history.filter(event => event.timestamp > afterTimestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/services/storage.ts
Normal file
149
src/services/storage.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import sqlite3, { type Database } from 'better-sqlite3';
|
||||||
|
import { pack, unpack } from 'msgpackr';
|
||||||
|
import { encodeExtendedJsonObject, decodeExtendedJsonObject } from '../utils/ext-json.js';
|
||||||
|
import { binToHex, hexToBin } from '@bitauth/libauth';
|
||||||
|
|
||||||
|
export interface SQLiteOptions {
|
||||||
|
wal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage Adapter that uses SQLite as the storage backend. Everything is stored as a BLOB, to avoid type issues.
|
||||||
|
*
|
||||||
|
* @remarks This implementation is single threaded. It WILL block execution of the main thread.
|
||||||
|
*/
|
||||||
|
export class StorageSQLite {
|
||||||
|
/**
|
||||||
|
* Create or open a SQLite database.
|
||||||
|
* @param filepath - The path to the SQLite database file.
|
||||||
|
* @param options - The options for the SQLite database.
|
||||||
|
* @returns A new instance of StorageSQLite.
|
||||||
|
*/
|
||||||
|
static async createOrOpen(
|
||||||
|
filepath: string,
|
||||||
|
options: Partial<SQLiteOptions> = {},
|
||||||
|
) {
|
||||||
|
const db = sqlite3(filepath);
|
||||||
|
|
||||||
|
const opts: SQLiteOptions = {
|
||||||
|
wal: true,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts.wal) {
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StorageSQLite(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(public db: Database) {}
|
||||||
|
|
||||||
|
async listStores() {
|
||||||
|
const result = this.db
|
||||||
|
.prepare(`SELECT * FROM sqlite_master WHERE type='table'`)
|
||||||
|
.all() as { name: string }[];
|
||||||
|
return result.map((row) => row.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStore<T>(storeName: string): Promise<StoreSQLite<T>> {
|
||||||
|
// Create table with proper schema for key-value storage
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS "${storeName}" (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value BLOB
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return new StoreSQLite(this.db, storeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStore<T>(storeName: string): Promise<StoreSQLite<T> | null> {
|
||||||
|
// Check if table exists
|
||||||
|
const tableExists = this.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name=?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.get(storeName);
|
||||||
|
|
||||||
|
if (!tableExists) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StoreSQLite(this.db, storeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrGetStore<T>(storeName: string): Promise<StoreSQLite<T>> {
|
||||||
|
return await this.createStore<T>(storeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteStore(storeName: string) {
|
||||||
|
this.db.prepare(`DROP TABLE IF EXISTS "${storeName}"`).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDatabase() {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StoreSQLite<T> {
|
||||||
|
constructor(
|
||||||
|
protected db: Database,
|
||||||
|
protected storeName: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async keys() {
|
||||||
|
const result = this.db
|
||||||
|
.prepare(`SELECT key FROM "${this.storeName}"`)
|
||||||
|
.all() as { key: string }[];
|
||||||
|
return result.map((row) => row.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<T | undefined> {
|
||||||
|
const result = this.db
|
||||||
|
.prepare(`SELECT value FROM "${this.storeName}" WHERE key = ?`)
|
||||||
|
.get(key) as { value: string } | undefined;
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const binValue = hexToBin(result.value);
|
||||||
|
|
||||||
|
const unpackedValue = unpack(binValue);
|
||||||
|
|
||||||
|
const decodedValue = decodeExtendedJsonObject(unpackedValue);
|
||||||
|
|
||||||
|
return decodedValue as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: T): Promise<void> {
|
||||||
|
const encodedValue = encodeExtendedJsonObject(value);
|
||||||
|
|
||||||
|
// Serialize using msgpackr for consistency with other implementations
|
||||||
|
const packedValue = pack(encodedValue);
|
||||||
|
|
||||||
|
const serializedValue = binToHex(packedValue);
|
||||||
|
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT OR REPLACE INTO "${this.storeName}" (key, value) VALUES (?, ?)`,
|
||||||
|
)
|
||||||
|
.run(key, serializedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
this.db.prepare(`DELETE FROM "${this.storeName}" WHERE key = ?`).run(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
this.db.prepare(`DELETE FROM "${this.storeName}"`).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/utils/ext-json.ts
Normal file
124
src/utils/ext-json.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* TODO: These are intended as temporary stand-ins until this functionality has been implemented directly in LibAuth.
|
||||||
|
* We are doing this so that we may better standardize with the rest of the BCH eco-system in future.
|
||||||
|
* See: https://github.com/bitauth/libauth/pull/108
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { binToHex, hexToBin } from '@bitauth/libauth';
|
||||||
|
|
||||||
|
export const extendedJsonReplacer = function (value: any): any {
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return `<bigint: ${value.toString()}n>`;
|
||||||
|
} else if (value instanceof Uint8Array) {
|
||||||
|
return `<Uint8Array: ${binToHex(value)}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extendedJsonReviver = function (value: any): any {
|
||||||
|
// Define RegEx that matches our Extended JSON fields.
|
||||||
|
const bigIntRegex = /^<bigint: (?<bigint>[+-]?[0-9]*)n>$/;
|
||||||
|
const uint8ArrayRegex = /^<Uint8Array: (?<hex>[a-f0-9]*)>$/;
|
||||||
|
|
||||||
|
// Only perform a check if the value is a string.
|
||||||
|
// NOTE: We can skip all other values as all Extended JSON encoded fields WILL be a string.
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// Check if this value matches an Extended JSON encoded bigint.
|
||||||
|
const bigintMatch = value.match(bigIntRegex);
|
||||||
|
if (bigintMatch) {
|
||||||
|
// Access the named group directly instead of using array indices
|
||||||
|
const { bigint } = bigintMatch.groups!;
|
||||||
|
|
||||||
|
// Return the value casted to bigint.
|
||||||
|
return BigInt(bigint!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8ArrayMatch = value.match(uint8ArrayRegex);
|
||||||
|
if (uint8ArrayMatch) {
|
||||||
|
// Access the named group directly instead of using array indices
|
||||||
|
const { hex } = uint8ArrayMatch.groups!;
|
||||||
|
|
||||||
|
// Return the value casted to bigint.
|
||||||
|
return hexToBin(hex!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the original value.
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encodeExtendedJsonObject = function (value: any): any {
|
||||||
|
// If this is an object type (and it is not null - which is technically an "object")...
|
||||||
|
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
||||||
|
if (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
!ArrayBuffer.isView(value)
|
||||||
|
) {
|
||||||
|
// If this is an array, recursively call this function on each value.
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(encodeExtendedJsonObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declare object to store extended JSON entries.
|
||||||
|
const encodedObject: any = {};
|
||||||
|
|
||||||
|
// Iterate through each entry and encode it to extended JSON.
|
||||||
|
for (const [key, valueToEncode] of Object.entries(value)) {
|
||||||
|
encodedObject[key] = encodeExtendedJsonObject(valueToEncode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the extended JSON encoded object.
|
||||||
|
return encodedObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the replaced value.
|
||||||
|
return extendedJsonReplacer(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodeExtendedJsonObject = function (value: any): any {
|
||||||
|
// If this is an object type (and it is not null - which is technically an "object")...
|
||||||
|
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
||||||
|
if (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
!ArrayBuffer.isView(value)
|
||||||
|
) {
|
||||||
|
// If this is an array, recursively call this function on each value.
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(decodeExtendedJsonObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declare object to store decoded JSON entries.
|
||||||
|
const decodedObject: any = {};
|
||||||
|
|
||||||
|
// Iterate through each entry and decode it from extended JSON.
|
||||||
|
for (const [key, valueToEncode] of Object.entries(value)) {
|
||||||
|
decodedObject[key] = decodeExtendedJsonObject(valueToEncode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the extended JSON encoded object.
|
||||||
|
return decodedObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the revived value.
|
||||||
|
return extendedJsonReviver(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encodeExtendedJson = function (
|
||||||
|
value: any,
|
||||||
|
space: number | undefined = undefined,
|
||||||
|
): string {
|
||||||
|
const replacedObject = encodeExtendedJsonObject(value);
|
||||||
|
const stringifiedObject = JSON.stringify(replacedObject, null, space);
|
||||||
|
|
||||||
|
return stringifiedObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodeExtendedJson = function (json: string): any {
|
||||||
|
const parsedObject = JSON.parse(json);
|
||||||
|
const revivedObject = decodeExtendedJsonObject(parsedObject);
|
||||||
|
|
||||||
|
return revivedObject;
|
||||||
|
};
|
||||||
68
src/utils/invitation-parser.ts
Normal file
68
src/utils/invitation-parser.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import Z, { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod schemas for invitation validation.
|
||||||
|
*
|
||||||
|
* IMPORTANT: We use .passthrough() on all object schemas to preserve fields
|
||||||
|
* that aren't explicitly defined. This is critical because:
|
||||||
|
* 1. Invitations are signed based on stringify(commit.data)
|
||||||
|
* 2. If we strip fields, the signature verification will fail
|
||||||
|
* 3. The actual XOInvitation types have many more fields than we validate here
|
||||||
|
*/
|
||||||
|
|
||||||
|
const variableSchema = z.object({
|
||||||
|
variableIdentifier: z.string(),
|
||||||
|
roleIdentifier: z.string().optional(),
|
||||||
|
value: z.number().or(z.string()).or(z.boolean()).or(z.bigint()),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const mergesWithSchema = z.object({
|
||||||
|
commitIdentifier: z.string(),
|
||||||
|
index: z.number(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
inputIdentifier: z.string().optional(),
|
||||||
|
transactionIndex: z.number().optional(),
|
||||||
|
roleIdentifier: z.string().optional(),
|
||||||
|
mergesWith: mergesWithSchema.optional(),
|
||||||
|
// Additional fields preserved via passthrough:
|
||||||
|
// outpointTransactionHash, outpointIndex, sequenceNumber, unlockingBytecode, etc.
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const outputSchema = z.object({
|
||||||
|
outputIdentifier: z.string().optional(),
|
||||||
|
roleIdentifier: z.string().optional(),
|
||||||
|
secretIdentifier: z.string().optional(),
|
||||||
|
transactionIndex: z.number().optional(),
|
||||||
|
mergesWith: mergesWithSchema.optional(),
|
||||||
|
// Additional fields preserved via passthrough:
|
||||||
|
// valueSatoshis, lockingBytecode, token, etc.
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const dataSchema = z.object({
|
||||||
|
transactionVersion: z.number().optional(),
|
||||||
|
transactionLocktime: z.number().optional(),
|
||||||
|
variables: z.array(variableSchema).optional(),
|
||||||
|
inputs: z.array(inputSchema).optional(),
|
||||||
|
outputs: z.array(outputSchema).optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const commitSchema = z.object({
|
||||||
|
commitIdentifier: z.string(),
|
||||||
|
previousCommitIdentifier: z.string().or(z.undefined()),
|
||||||
|
entityIdentifier: z.string(),
|
||||||
|
data: dataSchema,
|
||||||
|
signature: z.string(),
|
||||||
|
expiresAtTimestamp: z.number(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
export const parseInvitation = z.object({
|
||||||
|
invitationIdentifier: z.string(),
|
||||||
|
commits: z.array(commitSchema),
|
||||||
|
createdAtTimestamp: z.number(),
|
||||||
|
templateIdentifier: z.string(),
|
||||||
|
actionIdentifier: z.string(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
export type InvitationSchema = Z.infer<typeof parseInvitation>;
|
||||||
31
tsconfig.json
Normal file
31
tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
// Visit https://aka.ms/tsconfig to read more about this file
|
||||||
|
"compilerOptions": {
|
||||||
|
// File Layout
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
|
||||||
|
// Environment Settings
|
||||||
|
// See also https://aka.ms/tsconfig/module
|
||||||
|
"module": "nodenext",
|
||||||
|
"target": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
// Other Outputs
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
|
||||||
|
// Stricter Typechecking Options
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
// Recommended Options
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user