@reactionary/ucp 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ import {
2
+ createInitialRequestContext,
3
+ MemoryCache
4
+ } from "@reactionary/core";
5
+ import {
6
+ } from "./reactionary-ucp-common.js";
7
+ import { getOrCreateSessionId, jsonResponse, sendWebResponse, toWebRequest, UCP_SESSION_ID_HEADER } from "./reactionary-ucp-http.js";
8
+ import { createUCPProfile, getRequestRoute } from "./reactionary-ucp-profile.js";
9
+ import { handleRestRequest, UCPHttpError } from "./reactionary-ucp-rest.js";
10
+ import { ReactionaryUCPSessionStore } from "./reactionary-ucp-session-store.js";
11
+ class ReactionaryUCPServer {
12
+ constructor(clientFactory, options = {}) {
13
+ this.clientFactory = clientFactory;
14
+ this.options = options;
15
+ this.sessionStore = new ReactionaryUCPSessionStore(
16
+ this.options.sessionCache ?? new MemoryCache(),
17
+ this.options.sessionTtlSeconds ?? 60 * 60 * 24
18
+ );
19
+ }
20
+ clientFactory;
21
+ options;
22
+ sessionStore;
23
+ async fetch(request) {
24
+ const sessionId = getOrCreateSessionId(request);
25
+ const requestContext = await this.createRequestContext(sessionId);
26
+ const client = this.clientFactory(requestContext);
27
+ const response = await this.handleRequest(request, client, sessionId);
28
+ await this.sessionStore.put(sessionId, requestContext.session);
29
+ response.headers.set(UCP_SESSION_ID_HEADER, sessionId);
30
+ const requestId = request.headers.get("Request-Id");
31
+ if (requestId) {
32
+ response.headers.set("Request-Id", requestId);
33
+ }
34
+ return response;
35
+ }
36
+ getHandler() {
37
+ return {
38
+ fetch: (request) => this.fetch(request),
39
+ close: () => this.close()
40
+ };
41
+ }
42
+ toNodeHandler() {
43
+ return async (request, response) => {
44
+ const webResponse = await this.fetch(await toWebRequest(request));
45
+ await sendWebResponse(response, webResponse);
46
+ };
47
+ }
48
+ close() {
49
+ return Promise.resolve();
50
+ }
51
+ async createRequestContext(sessionId) {
52
+ const restoredSession = await this.sessionStore.get(sessionId);
53
+ const requestContext = createInitialRequestContext();
54
+ if (restoredSession) {
55
+ requestContext.session = restoredSession;
56
+ }
57
+ return requestContext;
58
+ }
59
+ async handleRequest(request, client, sessionId) {
60
+ const route = getRequestRoute(request, this.options.profile);
61
+ if (request.method === "OPTIONS") {
62
+ return new Response(null, {
63
+ status: 204,
64
+ headers: {
65
+ allow: "GET, HEAD, OPTIONS, POST, PUT"
66
+ }
67
+ });
68
+ }
69
+ if (route.path === "/.well-known/ucp" && request.method === "GET") {
70
+ return jsonResponse(createUCPProfile(client, this.options.profile));
71
+ }
72
+ try {
73
+ const restResponse = await handleRestRequest(
74
+ request,
75
+ client,
76
+ route.path,
77
+ sessionId,
78
+ this.sessionStore
79
+ );
80
+ if (restResponse) {
81
+ return restResponse;
82
+ }
83
+ } catch (error) {
84
+ if (error instanceof UCPHttpError) {
85
+ return jsonResponse(error.body, { status: error.status });
86
+ }
87
+ throw error;
88
+ }
89
+ return jsonResponse({
90
+ error: {
91
+ code: "NOT_FOUND",
92
+ message: `No UCP route matched ${request.method} ${route.path}.`
93
+ }
94
+ }, {
95
+ status: 404,
96
+ omitBody: request.method === "HEAD"
97
+ });
98
+ }
99
+ }
100
+ export {
101
+ ReactionaryUCPServer
102
+ };
@@ -0,0 +1,63 @@
1
+ import { SessionSchema } from "@reactionary/core";
2
+ import * as z from "zod";
3
+ const SESSION_CACHE_KEY_PREFIX = "reactionary:ucp:session";
4
+ const IDEMPOTENCY_CACHE_KEY_PREFIX = "reactionary:ucp:idempotency";
5
+ const UCPActionResponseSchema = z.looseObject({});
6
+ const UCPIdempotencyRecordSchema = z.looseObject({
7
+ action: z.string(),
8
+ fingerprint: z.string().optional(),
9
+ status: z.number().optional(),
10
+ response: UCPActionResponseSchema
11
+ });
12
+ class ReactionaryUCPSessionStore {
13
+ constructor(cache, ttlSeconds) {
14
+ this.cache = cache;
15
+ this.ttlSeconds = ttlSeconds;
16
+ }
17
+ cache;
18
+ ttlSeconds;
19
+ async get(sessionId) {
20
+ return await this.cache.get(
21
+ this.getCacheKey(sessionId),
22
+ SessionSchema
23
+ ) ?? void 0;
24
+ }
25
+ async put(sessionId, session) {
26
+ await this.cache.invalidate([this.getDependencyId(sessionId)]);
27
+ await this.cache.put(this.getCacheKey(sessionId), session, {
28
+ ttlSeconds: this.ttlSeconds,
29
+ dependencyIds: [this.getDependencyId(sessionId)]
30
+ });
31
+ }
32
+ async getIdempotencyRecord(sessionId, idempotencyKey) {
33
+ return await this.cache.get(
34
+ this.getIdempotencyCacheKey(sessionId, idempotencyKey),
35
+ UCPIdempotencyRecordSchema
36
+ ) ?? void 0;
37
+ }
38
+ async putIdempotencyRecord(sessionId, idempotencyKey, record) {
39
+ await this.cache.put(
40
+ this.getIdempotencyCacheKey(sessionId, idempotencyKey),
41
+ record,
42
+ {
43
+ ttlSeconds: this.ttlSeconds,
44
+ dependencyIds: [this.getIdempotencyDependencyId(sessionId)]
45
+ }
46
+ );
47
+ }
48
+ getCacheKey(sessionId) {
49
+ return `${SESSION_CACHE_KEY_PREFIX}:${sessionId}`;
50
+ }
51
+ getDependencyId(sessionId) {
52
+ return `${SESSION_CACHE_KEY_PREFIX}:${sessionId}`;
53
+ }
54
+ getIdempotencyCacheKey(sessionId, idempotencyKey) {
55
+ return `${IDEMPOTENCY_CACHE_KEY_PREFIX}:${sessionId}:${encodeURIComponent(idempotencyKey)}`;
56
+ }
57
+ getIdempotencyDependencyId(sessionId) {
58
+ return `${IDEMPOTENCY_CACHE_KEY_PREFIX}:${sessionId}`;
59
+ }
60
+ }
61
+ export {
62
+ ReactionaryUCPSessionStore
63
+ };
File without changes
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@reactionary/ucp",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./index.js",
6
+ "types": "./src/index.d.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Solteq/reactionary.git"
10
+ },
11
+ "dependencies": {
12
+ "@reactionary/core": "0.0.1",
13
+ "zod": "4.1.9"
14
+ },
15
+ "devDependencies": {
16
+ "vitest": "^4.0.9"
17
+ },
18
+ "sideEffects": false
19
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './lib/reactionary-ucp-server.js';
@@ -0,0 +1,4 @@
1
+ import { BaseCapability } from '@reactionary/core';
2
+ import type { ReactionaryUCPClient } from './reactionary-ucp-common.js';
3
+ export declare function hasCapability(client: ReactionaryUCPClient, name: string): boolean;
4
+ export declare function getCapability(client: ReactionaryUCPClient, name: string): BaseCapability | undefined;
@@ -0,0 +1,71 @@
1
+ import type { Cache, Cart, Checkout, Client, FacetValueIdentifier, Order, Product, ProductSearchResult, RequestContext, Result } from '@reactionary/core';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import type { components } from './ucp-shopping.openapi.js';
4
+ type UCPService = components['schemas']['base'];
5
+ type UCPCapability = components['schemas']['$defs-base'];
6
+ type UCPPaymentHandler = components['schemas']['payment_handler_$defs-base'];
7
+ export interface ReactionaryUCPProfile {
8
+ ucp: {
9
+ version: string;
10
+ services: Record<string, UCPService[]>;
11
+ capabilities: Record<string, UCPCapability[]>;
12
+ payment_handlers?: Record<string, UCPPaymentHandler[]>;
13
+ };
14
+ keys: Array<Record<string, unknown>>;
15
+ merchant: {
16
+ name: string;
17
+ url: string;
18
+ contact: {
19
+ email: string;
20
+ phone_number?: string;
21
+ };
22
+ };
23
+ }
24
+ type UCPMethod<TMethod extends (payload: never) => Promise<unknown>, TValue> = (payload: Parameters<TMethod>[0]) => Promise<Result<TValue, unknown>>;
25
+ export type ReactionaryUCPClient = object & {
26
+ productSearch?: {
27
+ createCategoryNavigationFilter?: UCPMethod<Client['productSearch']['createCategoryNavigationFilter'], FacetValueIdentifier>;
28
+ queryByTerm: UCPMethod<Client['productSearch']['queryByTerm'], ProductSearchResult>;
29
+ };
30
+ product?: {
31
+ getById: UCPMethod<Client['product']['getById'], Product>;
32
+ getBySKU: UCPMethod<Client['product']['getBySKU'], Product>;
33
+ };
34
+ cart?: {
35
+ add: UCPMethod<Client['cart']['add'], Cart>;
36
+ changeQuantity: UCPMethod<Client['cart']['changeQuantity'], Cart>;
37
+ createCart: UCPMethod<Client['cart']['createCart'], Cart>;
38
+ deleteCart: UCPMethod<Client['cart']['deleteCart'], void>;
39
+ getById: UCPMethod<Client['cart']['getById'], Cart>;
40
+ remove: UCPMethod<Client['cart']['remove'], Cart>;
41
+ };
42
+ checkout?: {
43
+ addPaymentInstruction: UCPMethod<Client['checkout']['addPaymentInstruction'], Checkout>;
44
+ finalizeCheckout: UCPMethod<Client['checkout']['finalizeCheckout'], Checkout>;
45
+ getById: UCPMethod<Client['checkout']['getById'], Checkout>;
46
+ initiateCheckoutForCart: UCPMethod<Client['checkout']['initiateCheckoutForCart'], Checkout>;
47
+ };
48
+ order?: {
49
+ getById: UCPMethod<Client['order']['getById'], Order>;
50
+ };
51
+ };
52
+ export type ReactionaryUCPClientFactory<TClient extends ReactionaryUCPClient = ReactionaryUCPClient> = (requestContext: RequestContext) => TClient;
53
+ export interface ReactionaryUCPServerOptions {
54
+ sessionCache?: Cache;
55
+ sessionTtlSeconds?: number;
56
+ profile?: ReactionaryUCPProfileOptions;
57
+ }
58
+ export interface ReactionaryUCPProfileOptions {
59
+ endpoint: string;
60
+ merchant: ReactionaryUCPProfile['merchant'];
61
+ keys: ReactionaryUCPProfile['keys'];
62
+ services?: ReactionaryUCPProfile['ucp']['services'];
63
+ capabilities?: ReactionaryUCPProfile['ucp']['capabilities'];
64
+ paymentHandlers?: ReactionaryUCPProfile['ucp']['payment_handlers'];
65
+ }
66
+ export interface ReactionaryUCPHttpHandler {
67
+ fetch(request: Request): Promise<Response>;
68
+ close(): Promise<void>;
69
+ }
70
+ export type ReactionaryUCPNodeRequestHandler = (request: IncomingMessage, response: ServerResponse) => Promise<void>;
71
+ export {};
@@ -0,0 +1,12 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ export declare const UCP_SESSION_ID_HEADER = "ucp-session-id";
3
+ type ProtocolHeaders = Headers | Record<string, string>;
4
+ export declare function getOrCreateSessionId(request: Request): string;
5
+ export declare function jsonResponse(body: unknown, options?: {
6
+ status?: number;
7
+ headers?: ProtocolHeaders;
8
+ omitBody?: boolean;
9
+ }): Response;
10
+ export declare function toWebRequest(request: IncomingMessage): Promise<Request>;
11
+ export declare function sendWebResponse(response: ServerResponse, webResponse: Response): Promise<void>;
12
+ export {};
@@ -0,0 +1,5 @@
1
+ import type { ReactionaryUCPClient, ReactionaryUCPProfile, ReactionaryUCPProfileOptions } from './reactionary-ucp-common.js';
2
+ export declare function getRequestRoute(request: Request, profile: ReactionaryUCPProfileOptions | undefined): {
3
+ path: string;
4
+ };
5
+ export declare function createUCPProfile(client: ReactionaryUCPClient, profile: ReactionaryUCPProfileOptions | undefined): ReactionaryUCPProfile;
@@ -0,0 +1,8 @@
1
+ import type { ReactionaryUCPClient } from './reactionary-ucp-common.js';
2
+ import type { ReactionaryUCPSessionStore } from './reactionary-ucp-session-store.js';
3
+ export declare function handleRestRequest(request: Request, client: ReactionaryUCPClient, path: string, sessionId: string, sessionStore: ReactionaryUCPSessionStore): Promise<Response | undefined>;
4
+ export declare class UCPHttpError extends Error {
5
+ readonly status: number;
6
+ readonly body: unknown;
7
+ constructor(status: number, body: unknown);
8
+ }
@@ -0,0 +1,14 @@
1
+ import { type ReactionaryUCPClient, type ReactionaryUCPClientFactory, type ReactionaryUCPHttpHandler, type ReactionaryUCPNodeRequestHandler, type ReactionaryUCPProfile, type ReactionaryUCPProfileOptions, type ReactionaryUCPServerOptions } from './reactionary-ucp-common.js';
2
+ export type { ReactionaryUCPClient, ReactionaryUCPClientFactory, ReactionaryUCPHttpHandler, ReactionaryUCPNodeRequestHandler, ReactionaryUCPProfile, ReactionaryUCPProfileOptions, ReactionaryUCPServerOptions, };
3
+ export declare class ReactionaryUCPServer<TClient extends ReactionaryUCPClient = ReactionaryUCPClient> {
4
+ private readonly clientFactory;
5
+ private readonly options;
6
+ private readonly sessionStore;
7
+ constructor(clientFactory: ReactionaryUCPClientFactory<TClient>, options?: ReactionaryUCPServerOptions);
8
+ fetch(request: Request): Promise<Response>;
9
+ getHandler(): ReactionaryUCPHttpHandler;
10
+ toNodeHandler(): ReactionaryUCPNodeRequestHandler;
11
+ close(): Promise<void>;
12
+ private createRequestContext;
13
+ private handleRequest;
14
+ }
@@ -0,0 +1,23 @@
1
+ import { type Cache, type Session } from '@reactionary/core';
2
+ import * as z from 'zod';
3
+ declare const UCPIdempotencyRecordSchema: z.ZodObject<{
4
+ action: z.ZodString;
5
+ fingerprint: z.ZodOptional<z.ZodString>;
6
+ status: z.ZodOptional<z.ZodNumber>;
7
+ response: z.ZodObject<{}, z.core.$loose>;
8
+ }, z.core.$loose>;
9
+ export type UCPIdempotencyRecord = z.infer<typeof UCPIdempotencyRecordSchema>;
10
+ export declare class ReactionaryUCPSessionStore {
11
+ private readonly cache;
12
+ private readonly ttlSeconds;
13
+ constructor(cache: Cache, ttlSeconds: number);
14
+ get(sessionId: string): Promise<Session | undefined>;
15
+ put(sessionId: string, session: Session): Promise<void>;
16
+ getIdempotencyRecord(sessionId: string, idempotencyKey: string): Promise<UCPIdempotencyRecord | undefined>;
17
+ putIdempotencyRecord(sessionId: string, idempotencyKey: string, record: UCPIdempotencyRecord): Promise<void>;
18
+ private getCacheKey;
19
+ private getDependencyId;
20
+ private getIdempotencyCacheKey;
21
+ private getIdempotencyDependencyId;
22
+ }
23
+ export {};