breakout into monorepo

This commit is contained in:
Dominic Ferrando
2026-03-02 13:39:47 -05:00
parent 6421f6527f
commit 45acec2cfd
90 changed files with 12179 additions and 2987 deletions
+6
View File
@@ -0,0 +1,6 @@
export * from "./printful"
export * from "./sync"
export * from "./webflow"
export * from "./webflow"
export * from "./util/misc"
export * from "./util/types"
+207
View File
@@ -0,0 +1,207 @@
import type { Printful } from "./util/types";
import { FetchError, type DeepPartial } from "./util/misc";
export class PrintfulService {
static Products = class {
static Variants = class {
static async get(
variantId: number | string,
): Promise<Printful.Products.SyncVariant | undefined> {
const res = await fetch(
`${env().API_URL}/store/variants/${variantId}`,
{
method: "GET",
headers: { ...env().AUTH_HEADERS },
},
);
if (res.status === 404 || res.status === 400) {
return;
}
if (!res.ok) {
throw new FetchError("Failed to get Printful variant", res);
}
const payload =
(await res.json()) as Printful.Products.MetaDataSingle<Printful.Products.SyncVariant>;
return payload.result;
}
};
static async getAll(
offset: number = 0,
): Promise<Printful.Products.SyncProduct[]> {
const allSyncProducts: Printful.Products.SyncProduct[] = [];
while (true) {
try {
const res = await fetch(
`${env().API_URL}/store/products?offset=${offset}`,
{
method: "GET",
headers: { ...env().AUTH_HEADERS },
},
);
if (!res.ok) {
throw new FetchError(
"Failed to get all Printful products",
res,
);
}
const payload =
(await res.json()) as Printful.Products.MetaDataMulti<Printful.Products.SyncProduct>;
allSyncProducts.push(...payload.result);
offset = allSyncProducts.length;
if (allSyncProducts.length >= payload.paging.total) break;
} catch (error) {
throw error;
}
}
return allSyncProducts;
}
static async get(
productId: number | string,
): Promise<Printful.Products.Product | undefined> {
const res = await fetch(
`${env().API_URL}/store/products/${productId}`,
{
method: "GET",
headers: { ...env().AUTH_HEADERS },
},
);
if (res.status === 404 || res.status === 400) {
return;
}
if (!res.ok) {
throw new FetchError("Failed to get Printful product", res);
}
const payload =
(await res.json()) as Printful.Products.MetaDataSingle<Printful.Products.Product>;
return payload.result;
}
static async update(
printfulProductId: number,
printfulProduct: DeepPartial<Printful.Products.Product>,
) {
const res = await fetch(
`${env().API_URL}/store/products/${printfulProductId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADERS,
},
body: JSON.stringify(printfulProduct),
},
);
if (!res.ok) {
throw new FetchError("Failed to update Printful product", res);
}
}
};
static Orders = class {
static async create(printfulOrder: Printful.Orders.Order) {
const res = await fetch(`${env().API_URL}/orders`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADERS,
},
body: JSON.stringify(printfulOrder),
});
if (!res.ok) {
throw new FetchError("Failed to create printful order", res);
}
}
static async getAll(
offset: number = 0,
): Promise<Printful.Orders.Order[]> {
const allPrintfulOrders: Printful.Orders.Order[] = [];
while (true) {
try {
const res = await fetch(
`${env().API_URL}/orders?offset=${offset}`,
{
method: "GET",
headers: { ...env().AUTH_HEADERS },
},
);
if (!res.ok) {
throw new FetchError(
"Failed to get all Printful orders",
res,
);
}
const payload =
(await res.json()) as Printful.Products.MetaDataMulti<Printful.Orders.Order>;
allPrintfulOrders.push(...payload.result);
offset = allPrintfulOrders.length;
if (allPrintfulOrders.length >= payload.paging.total) break;
} catch (error) {
throw error;
}
}
return allPrintfulOrders;
}
};
static Util = class {
static getVariantMainImage(
syncVariant: Printful.Products.SyncVariant,
): string {
const previewFile = syncVariant.files.find(
(f) => f.type === "preview",
);
return previewFile?.preview_url ?? syncVariant.product.image;
}
};
}
export const env = () => {
if (typeof Bun === "undefined") {
throw new Error(
"Must be in a server context. Make sure to run using --bun.",
);
}
const vars = {
AUTH_TOKEN: Bun.env.PRINTFUL_AUTH,
STORE_ID: Bun.env.PRINTFUL_STORE_ID,
} as Record<string, string>;
for (const varKey in vars) {
if (!vars[varKey]) {
console.log(`Missing ${varKey} environment variable`);
}
}
return {
...vars,
API_URL: "https://api.printful.com",
AUTH_HEADERS: {
Authorization: `Bearer ${vars.AUTH_TOKEN}`,
"X-PF-Store-Id": `${vars.STORE_ID}`,
},
};
};
+359
View File
@@ -0,0 +1,359 @@
import type { Printful, Webflow } from "./util/types";
import { FetchError, formatSlug, type DeepPartial } from "./util/misc";
import { WebflowService } from "./webflow";
import { PrintfulService } from "./printful";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const R2_WORKER_URL = "https://r2-worker.xominus.workers.dev";
export const WEBSITE_MEDIA_URL = "https://website-media.bladeandbrawn.com";
type MainProduct = {
name: string;
externalId: string;
colors: Set<string>;
variants: {
color: string;
product: Printful.Products.Product;
}[];
};
export class SyncService {
static state = {
isSyncing: false,
syncingIds: [] as number[],
};
static async sync(printfulProductId: number | undefined) {
this.state.isSyncing = true;
const mainPrintfulProducts: MainProduct[] = [];
const webflowProducts: Webflow.Products.ProductAndSkus[] = [];
try {
let printfulProducts = await PrintfulService.Products.getAll();
if (printfulProductId) {
const printfulProduct = printfulProducts.find(
(p) => p.id === printfulProductId,
);
if (printfulProduct) {
const mainProductName = this.getMainProductName(
printfulProduct.name,
);
printfulProducts = printfulProducts.filter((p) =>
p.name.includes(mainProductName),
);
}
}
webflowProducts.push(...(await WebflowService.Products.getAll()));
// Generate main printful products
console.log("Generating main products...");
for (const printfulProduct of printfulProducts) {
const mainProductName = this.getMainProductName(
printfulProduct.name,
);
let mainPrintfulProduct = mainPrintfulProducts.find((mp) =>
mp.name.includes(mainProductName),
);
if (!mainPrintfulProduct) {
mainPrintfulProduct = {
name: mainProductName,
variants: [],
externalId: printfulProduct.external_id,
colors: new Set(),
};
mainPrintfulProducts.push(mainPrintfulProduct);
}
const productColor = this.findColorInProductName(
printfulProduct.name,
);
mainPrintfulProduct.variants.push({
color: productColor,
product: (await PrintfulService.Products.get(
printfulProduct.id,
))!,
});
mainPrintfulProduct.colors.add(productColor);
}
} catch (error) {
throw error;
} finally {
this.state.isSyncing = false;
this.state.syncingIds = [];
}
// TODO: Validate main printful products
// Sync the main printful products
console.log("Syncing main products...");
for (const mainPrintfulProduct of mainPrintfulProducts) {
try {
this.state.isSyncing = true;
this.state.syncingIds = mainPrintfulProduct.variants.map(
(v) => v.product.sync_product.id,
);
const foundColors = mainPrintfulProduct.colors;
const foundSizes: Set<string> = new Set();
const webflowSkus: DeepPartial<Webflow.Products.Skus.Sku>[] =
[];
for (const specialVariant of mainPrintfulProduct.variants) {
for (const printfulVariant of specialVariant.product
.sync_variants) {
// check if this variant size is in all other special variants
let sizeIsInAllVariants = true;
for (const specialVariant of mainPrintfulProduct.variants) {
const sizes =
specialVariant.product.sync_variants.map(
(v) => v.size,
);
if (!sizes.includes(printfulVariant.size))
sizeIsInAllVariants = false;
}
if (sizeIsInAllVariants) {
foundSizes.add(printfulVariant.size);
webflowSkus.push({
id: printfulVariant.external_id,
fieldData: {
name: printfulVariant.name,
slug: formatSlug(printfulVariant.name),
"sku-values": {
color: specialVariant.color,
size: printfulVariant.size,
},
price: {
value: Math.floor(
+printfulVariant.retail_price * 100,
),
unit: printfulVariant.currency,
currency: printfulVariant.currency,
},
"main-image":
PrintfulService.Util.getVariantMainImage(
printfulVariant,
),
},
});
}
}
}
// SYNC PRODUCT DATA
let existingWebflowProduct = webflowProducts.find(
(webflowProduct) =>
webflowProduct.product.id ===
mainPrintfulProduct.externalId.split("-")[0],
);
if (existingWebflowProduct) {
// SYNC PRODUCT UPDATE
// do not update images
for (const webflowSku of webflowSkus) {
delete webflowSku.fieldData?.["main-image"];
}
const webflowProductId =
mainPrintfulProduct.externalId.split("-")[0];
if (!webflowProductId) {
throw new Error("Malformed printful product ID");
}
WebflowService.Products.update(webflowProductId, {
product: {
fieldData: {
name: mainPrintfulProduct.name,
slug: formatSlug(mainPrintfulProduct.name),
shippable: true,
"tax-category": "standard-taxable",
"sku-properties": [
{
id: "color",
name: "Color",
enum: Array.from(foundColors).map(
(color) => ({
id: color,
slug: formatSlug(color),
name: color,
}),
),
},
{
id: "size",
name: "Size",
enum: Array.from(foundSizes).map(
(size) => ({
id: size,
slug: formatSlug(size),
name: size,
}),
),
},
],
},
},
sku: webflowSkus[0],
});
for (const webflowSku of webflowSkus.slice(1)) {
const existingWebflowSku =
existingWebflowProduct.skus.find(
(sku) => sku.id === webflowSku.id,
);
if (webflowSku.id && existingWebflowSku) {
await WebflowService.Products.Skus.update(
webflowProductId,
webflowSku.id,
webflowSku,
);
} else {
await WebflowService.Products.Skus.create(
webflowProductId,
[webflowSku],
);
}
}
} else {
// SYNC PRODUCT CREATE
const webflowProductId =
await WebflowService.Products.create({
product: {
fieldData: {
name: mainPrintfulProduct.name,
slug: formatSlug(mainPrintfulProduct.name),
shippable: true,
"tax-category": "standard-taxable",
"sku-properties": [
{
id: "color",
name: "Color",
enum: Array.from(foundColors).map(
(color) => ({
id: color,
slug: formatSlug(color),
name: color,
}),
),
},
{
id: "size",
name: "Size",
enum: Array.from(foundSizes).map(
(size) => ({
id: size,
slug: formatSlug(size),
name: size,
}),
),
},
],
},
},
sku: webflowSkus[0],
});
// create webflow product SKUs
await WebflowService.Products.Skus.create(
webflowProductId,
webflowSkus.slice(1),
);
existingWebflowProduct =
await WebflowService.Products.get(webflowProductId);
if (!existingWebflowProduct) {
console.error("Missing webflow product");
throw new Error("Failed to create Webflow product");
}
for (const specialVariant of mainPrintfulProduct.variants) {
const newPrintfulVariants: DeepPartial<Printful.Products.SyncVariant>[] =
[];
for (const printfulVariant of specialVariant.product
.sync_variants) {
const associatedWebflowSku =
existingWebflowProduct.skus.find(
(sku) =>
sku.fieldData["sku-values"]?.[
"color"
] === printfulVariant.color &&
sku.fieldData["sku-values"]?.[
"size"
] === printfulVariant.size,
);
if (associatedWebflowSku) {
newPrintfulVariants.push({
id: printfulVariant.id, // printful variant id
external_id: String(
associatedWebflowSku.id,
), // webflow variant id
});
}
}
await sleep(10000);
await PrintfulService.Products.update(
specialVariant.product.sync_product.id,
{
sync_product: {
id: specialVariant.product.sync_product.id,
external_id: `${webflowProductId}-${specialVariant.color}`,
},
sync_variants: newPrintfulVariants,
},
);
}
}
} catch (err) {
// SYNC IMAGES
// for (const sku of existingWebflowProduct.skus) {
// const skuColor = sku.fieldData["sku-values"]?.["color"]?.toLowerCase() ?? "None";
// const skuSlug = `${existingWebflowProduct.product.fieldData.slug}-${skuColor}`;
// const skuImageUrls = await this.getProductImageUrls(skuSlug);
//
// sku.fieldData["main-image"] = skuImageUrls[0] ?? sku.fieldData["main-image"];
// sku.fieldData["more-images"] = skuImageUrls.slice(1).map(url => ({ url }));
//
// await WebflowService.Products.Skus.update(existingWebflowProduct.product.id, sku.id, sku);
// }
//
if (err instanceof FetchError) {
console.error(err.message, err.payload);
}
}
}
this.state.isSyncing = false;
this.state.syncingIds = [];
console.log("Done");
}
private static getProductImageUrls = async (slug: string) => {
const res = await fetch(`${R2_WORKER_URL}/product-images/${slug}`);
if (!res.ok) {
console.error(
"Failed to get product image keys:",
await res.json(),
);
throw new Error("Failed to get product image keys");
}
const productImageKeys: string[] = (await res.json()) as string[];
return productImageKeys.map((key) => `${WEBSITE_MEDIA_URL}/${key}`);
};
public static findColorInProductName = (productName: string): string => {
const m = productName.match(/\[([^\]]+)\]/);
return m?.[1] ? m[1] : "N/A";
};
public static getMainProductName = (productName: string) => {
const colorInName = this.findColorInProductName(productName);
if (colorInName) {
return productName.replace(`[${colorInName}]`, "").trimEnd();
} else {
return productName;
}
};
}
+37
View File
@@ -0,0 +1,37 @@
export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
export const formatSlug = (name: string): string => {
return name
.toLowerCase()
.replace(/[^a-z0-9-_]/g, "-") // replace illegal chars with dashes
.replace(/^-+/, "") // remove leading dashes
.replace(/-+$/, "") // remove trailing dashes
.replace(/--+/g, "-") // collapse multiple dashes
.replace(/^([^a-z0-9_])/, "_$1"); // if it starts with an invalid char, prefix underscore
};
export class FetchError extends Error {
public payload: Object | undefined;
constructor(
message: string,
public response: Response,
) {
super(message);
this.name = "FetchError";
}
async parse() {
try {
this.payload = (await this.response.clone().json()) as Object;
} catch {
this.payload = await this.response.text().catch(() => undefined);
} finally {
return this.payload;
}
}
}
+431
View File
@@ -0,0 +1,431 @@
export namespace Printful {
export namespace Products {
export interface MetaDataSingle<T = any> {
code: number;
result: T;
}
export interface MetaDataMulti<T = any> {
code: number;
result: T[];
paging: {
total: number;
offset: number;
limit: number;
};
}
type Option = {
id: string;
value: string;
};
type File = {
type: string;
id: number;
url: string;
options: Option[];
hash: string;
filename: string;
mime_type: string;
size: number;
width: number;
height: number;
dpi: number;
status: string;
created: number;
thumbnail_url: string;
preview_url: string;
visible: boolean;
is_temporary: boolean;
stitch_count_tier: string;
};
export type Product = {
sync_product: SyncProduct;
sync_variants: SyncVariant[];
};
export type SyncProduct = {
id: number;
external_id: string;
name: string;
variants: number;
synced: number;
thumbnail_url: string;
is_ignored: boolean;
};
export type SyncVariant = {
id: number;
external_id: string;
sync_product_id: number;
name: string;
synced: boolean;
variant_id: number;
retail_price: string;
currency: string;
is_ignored: boolean;
sku: string;
product: {
variant_id: number;
product_id: number;
image: string;
name: string;
};
files: File[];
options: Option[];
main_category_id: number;
warehouse_product_id: number;
warehouse_product_variant_id: number;
size: string;
color: string;
availability_status: string;
};
}
export namespace Orders {
export type Order = {
external_id: string;
shipping: string;
recipient: {
name: string;
company?: string;
address1: string;
address2: string;
city: string;
state_code: string;
state_name?: string;
country_code: string;
country_name?: string;
zip: string;
phone?: string;
email?: string;
tax_number?: string;
};
items: Array<{ external_variant_id: string; quantity: number }>;
};
}
export namespace Webhook {
// meta
interface MetaData<E extends Event, D> {
type: E;
created: number;
retries: number;
store: number;
data: D;
}
export enum Event {
ProductUpdated = "product_updated",
ProductDeleted = "product_deleted",
PackageShipped = "package_shipped",
}
export interface ProductUpdated extends MetaData<
Event.ProductUpdated,
{
sync_product: {
id: number;
external_id: string;
name: string;
variants: number;
synced: number;
thumbnail_url: string;
is_ignored: boolean;
};
}
> {}
export interface ProductDeleted extends MetaData<
Event.ProductDeleted,
{
sync_product: {
id: number;
external_id: string;
name: string;
};
}
> {}
export interface PackageShipped extends MetaData<
Event.PackageShipped,
{
shipment: {
id: number;
carrier: string;
service: string;
tracking_number: string;
tracking_url: string;
created: number;
ship_date: string;
shipped_at: number;
reshipment: boolean;
};
order: {
id: number;
external_id: string;
status: string;
};
}
> {}
export type EventPayload =
| ProductUpdated
| ProductDeleted
| PackageShipped;
}
}
export namespace Webflow {
export namespace Products {
export namespace Skus {
export type Sku = {
id: string;
fieldData: {
name: string;
slug: string;
"sku-values"?: Record<string, string>;
price: {
value: number;
unit: string;
currency: string;
};
"main-image"?: string;
"more-images"?: {
fileId?: string;
url: string;
alt?: string;
}[];
};
};
}
export type Product = {
id: string;
fieldData: {
name: string;
slug: string;
description?: string;
shippable?: boolean;
"tax-category"?: string;
"sku-properties": {
id: string;
name: string;
enum: {
id: string;
name: string;
slug: string;
}[];
}[];
};
};
export interface ProductAndSkus {
product: Product;
skus: Skus.Sku[];
}
export interface ProductAndSku {
product: Product;
sku: Skus.Sku;
publishStatus?: "staging" | "live";
}
}
export namespace Orders {
export type Order = {
orderId: string;
status:
| "pending"
| "unfulfilled"
| "fulfilled"
| "disputed"
| "dispute-lost"
| "refunded";
comment: string;
orderComment: string;
acceptedOn: string;
fulfilledOn: string | null;
refundedOn: string | null;
disputedOn: string | null;
disputeUpdatedOn: string | null;
disputeLastStatus: string | null;
customerPaid: {
unit: string;
value: string;
string: string;
};
netAmount: {
unit: string;
value: string;
string: string;
};
applicationFee: {
unit: string;
value: string;
string: string;
};
allAddresses: Array<{
type: string;
addressee: string;
line1: string;
line2: string;
city: string;
state: string;
country: string;
postalCode: string;
}>;
shippingAddress: {
type: string;
japanType: string;
addressee: string;
line1: string;
line2: string;
city: string;
state: string;
country: string;
postalCode: string;
};
billingAddress: {
type: string;
addressee: string;
line1: string;
line2: string;
city: string;
state: string;
country: string;
postalCode: string;
};
shippingProvider: string;
shippingTracking: string;
shippingTrackingURL: string;
customerInfo: {
fullName: string;
email: string;
};
purchasedItems: Array<{
count: number;
rowTotal: {
unit: string;
value: string;
string: string;
};
productId: string;
productName: string;
productSlug: string;
variantId: string;
variantName: string;
variantSlug: string;
variantSKU: string;
variantImage: {
url: string;
file: {
size: number;
originalFileName: string;
createdOn: string;
contentType: string;
width: number;
height: number;
variants: {
size: number;
url: string;
originalFileName: string;
width: number;
height: number;
}[];
};
};
variantPrice: {
unit: string;
value: string;
string: string;
};
weight: number;
width: number;
height: number;
length: number;
}>;
purchasedItemsCount: number;
stripeDetails: {
subscriptionId: string | null;
paymentMethod: string;
paymentIntentId: string;
customerId: string;
chargeId: string;
disputeId: string | null;
refundId: string;
refundReason: string;
};
stripeCard: {
last4: string;
brand: string;
ownerName: string;
expires: {
year: number;
month: number;
};
};
paypalDetails: Object;
customData: Array<Object>;
metadata: {
isBuyNow: boolean;
hasDownloads: boolean;
paymentProcessor: string;
};
isCustomerDeleted: boolean;
isShippingRequired: boolean;
totals: {
subtotal: {
unit: string;
value: string;
string: string;
};
extras: Array<{
type: string;
name: string;
description: string;
price: {
unit: string;
value: string;
string: string;
};
}>;
total: {
unit: string;
value: string;
string: string;
};
};
downloadFiles: Array<{
id: string;
name: string;
url: string;
}>;
};
}
export namespace Webhook {
interface MetaData<E extends Event, D> {
triggerType: E;
payload: D;
}
export enum Event {
OrderCreated = "ecomm_new_order",
OrderUpdated = "ecomm_order_changed",
}
export interface OrderCreated extends MetaData<
Event.OrderCreated,
Orders.Order
> {}
export interface OrderUpdated extends MetaData<
Event.OrderUpdated,
Orders.Order
> {}
export type EventPayload = OrderCreated | OrderUpdated;
}
}
+330
View File
@@ -0,0 +1,330 @@
import type { Webflow } from "./util/types";
import { FetchError, type DeepPartial } from "./util/misc";
import crypto from "node:crypto";
export class WebflowService {
static Products = class {
static Skus = class {
static async create(
webflowProductId: string,
skus: DeepPartial<Webflow.Products.Skus.Sku>[],
): Promise<string[]> {
const res = await fetch(
`${env().API_SITES_URL}/products/${webflowProductId}/skus`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify({
skus: skus,
}),
},
);
if (!res.ok) {
throw new FetchError(
"Failed to create Webflow product SKU",
res,
);
}
const payload = (await res.json()) as {
skus: { id: string }[];
};
return payload.skus.map((sku) => sku.id);
}
static async update(
webflowProductId: string,
webflowSkuId: string,
webflowSku: DeepPartial<Webflow.Products.Skus.Sku>,
): Promise<void> {
const res = await fetch(
`${env().API_SITES_URL}/products/${webflowProductId}/skus/${webflowSkuId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify({
sku: webflowSku,
}),
},
);
if (!res.ok) {
throw new FetchError(
"Failed to update Webflow product SKU",
res,
);
}
}
};
static async create(
webflowProductAndSku: DeepPartial<Webflow.Products.ProductAndSku>,
): Promise<string> {
const res = await fetch(`${env().API_SITES_URL}/products`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify(webflowProductAndSku),
});
if (!res.ok) {
throw new FetchError("Failed to create Webflow product", res);
}
const createdWebflowProduct = (await res.json()) as {
product: { id: string };
};
return createdWebflowProduct.product.id;
}
static async getAll(): Promise<Webflow.Products.ProductAndSkus[]> {
const res = await fetch(`${env().API_SITES_URL}/products`, {
method: "GET",
headers: {
...env().AUTH_HEADER,
},
});
if (!res.ok) {
throw new FetchError("Failed to get all Webflow products", res);
}
const payload = (await res.json()) as {
items: Webflow.Products.ProductAndSkus[];
};
return payload.items;
}
static async get(
webflowProductId: string,
): Promise<Webflow.Products.ProductAndSkus | undefined> {
const res = await fetch(
`${env().API_SITES_URL}/products/${webflowProductId}`,
{
method: "GET",
headers: {
...env().AUTH_HEADER,
},
},
);
if (res.status === 404 || res.status === 400) {
return;
}
if (!res.ok) {
throw new FetchError("Failed to get Webflow product", res);
}
const payload =
(await res.json()) as Webflow.Products.ProductAndSkus;
return payload;
}
static async update(
webflowProductId: string,
webflowProduct: DeepPartial<Webflow.Products.ProductAndSku>,
): Promise<void> {
const res = await fetch(
`${env().API_SITES_URL}/products/${webflowProductId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify(webflowProduct),
},
);
if (!res.ok) {
throw new FetchError("Failed to update Webflow product", res);
}
}
static async remove(webflowProductId: string): Promise<void> {
const res = await fetch(
`${env().API_COLLECTIONS_URL}/items/${webflowProductId}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify({}),
},
);
if (!res.ok) {
throw new FetchError("Failed to remove Webflow product", res);
}
}
};
static Orders = class {
static async getAll(
opt: { status?: Webflow.Orders.Order["status"] } = {},
): Promise<Webflow.Orders.Order[]> {
// TODO: pagination
const params = new URLSearchParams();
if (opt.status !== undefined) {
params.set("status", opt.status);
}
const res = await fetch(
`${env().API_SITES_URL}/orders?${params.toString()}`,
{
method: "GET",
headers: { ...env().AUTH_HEADER },
},
);
if (!res.ok) {
throw new FetchError("Failed to get all Webflow orders", res);
}
const payload = (await res.json()) as {
orders: Webflow.Orders.Order[];
};
return payload.orders;
}
static async update(
webflowOrderId: string,
webflowOrderUpdate: {
comment?: string;
shippingProvider?: string;
shippingTracking?: string;
shippingTrackingURL: string;
},
): Promise<void> {
const res = await fetch(
`${env().API_SITES_URL}/orders/${webflowOrderId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify(webflowOrderUpdate),
},
);
if (!res.ok) {
throw new FetchError("Failed to update Webflow order", res);
}
}
static async fulfill(
webflowOrderId: string,
opt: { sendOrderFulfilledEmail?: boolean } = {},
): Promise<void> {
const res = await fetch(
`${env().API_SITES_URL}/orders/${webflowOrderId}/fulfill`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify(opt),
},
);
if (!res.ok) {
throw new FetchError("Failed to fulfill Webflow order", res);
}
}
};
static Util = class {
static verifyWebflowSignature(
request: Request,
body: unknown,
): Boolean {
try {
const timestamp = request.headers.get("x-webflow-timestamp");
if (!timestamp) {
throw new Error("No timestamp provided");
}
const providedSignature = request.headers.get(
"x-webflow-signature",
);
if (!providedSignature) {
throw new Error("No signature provided");
}
const secret = env().WEBHOOK_SECRET;
if (!secret) {
throw new Error("No secret provided");
}
if (!body) {
throw new Error("Body is empty");
}
const requestTimestamp = parseInt(timestamp, 10);
const data = `${requestTimestamp}:${JSON.stringify(body)}`;
const hash = crypto
.createHmac("sha256", secret)
.update(data)
.digest("hex");
if (
!crypto.timingSafeEqual(
Buffer.from(hash, "hex"),
Buffer.from(providedSignature, "hex"),
)
) {
throw new Error("Invalid signature");
}
const currentTime = Date.now();
if (currentTime - requestTimestamp > 300000) {
throw new Error("Request is older than 5 minutes");
}
return true;
} catch (err) {
console.error(`Error verifying signature: ${err}`);
return false;
}
}
};
}
const env = () => {
if (typeof Bun === "undefined") {
throw new Error(
"Must be in a server context. Make sure to run using --bun.",
);
}
const vars = {
SITE_ID: Bun.env.WEBFLOW_SITE_ID,
COLLECTIONS_ID: Bun.env.WEBFLOW_COLLECTION_ID,
AUTH_TOKEN: Bun.env.WEBFLOW_AUTH,
WEBHOOK_SECRET: Bun.env.WEBFLOW_WEBHOOK_SECRET,
} as Record<string, string>;
for (const varKey in vars) {
if (!vars[varKey]) {
console.log(`Missing ${varKey} environment variable`);
}
}
return {
...vars,
API_SITES_URL: `https://api.webflow.com/v2/sites/${vars.SITE_ID}`,
API_COLLECTIONS_URL: `https://api.webflow.com/v2/collections/${vars.COLLECTIONS_ID}`,
AUTH_HEADER: { Authorization: `bearer ${vars.AUTH_TOKEN}` },
};
};