feat: добавить генерацию image variants
- добавлен shared config presets, custom transforms и allowlist hosts - реализованы Backend endpoints для assets, presets и variants - добавлена orchestration через PostgreSQL, RabbitMQ, S3 и worker - обновлён Gateway read-through flow с L1 cache и корректным Vary: Accept - добавлена миграция resize_mode для variants lookup - обновлены dev scripts, env template, lockfile и документация
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
import { defineConfig } from "drizzle-kit"
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error("DATABASE_URL is required")
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "postgres://image:image-password@localhost:5433/image_platform",
|
||||
url: databaseUrl,
|
||||
},
|
||||
dialect: "postgresql",
|
||||
out: "./drizzle",
|
||||
|
||||
4
packages/database/drizzle/0001_familiar_nextwave.sql
Normal file
4
packages/database/drizzle/0001_familiar_nextwave.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
CREATE TYPE "public"."resize_mode" AS ENUM('fit', 'fill');--> statement-breakpoint
|
||||
DROP INDEX "image_variants_lookup_idx";--> statement-breakpoint
|
||||
ALTER TABLE "image_variants" ADD COLUMN "resize_mode" "resize_mode" DEFAULT 'fit' NOT NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "image_variants_lookup_idx" ON "image_variants" USING btree ("asset_id","asset_version","preset","width","height","resize_mode","quality","format");
|
||||
632
packages/database/drizzle/meta/0001_snapshot.json
Normal file
632
packages/database/drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,632 @@
|
||||
{
|
||||
"id": "9b706710-b809-4324-8632-634884f75166",
|
||||
"prevId": "72292622-d326-46fe-8e6a-90096c7e6634",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.allowed_image_hosts": {
|
||||
"name": "allowed_image_hosts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"hostname": {
|
||||
"name": "hostname",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"allowed_image_hosts_hostname_idx": {
|
||||
"name": "allowed_image_hosts_hostname_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "hostname",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.image_asset_versions": {
|
||||
"name": "image_asset_versions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"asset_id": {
|
||||
"name": "asset_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_url": {
|
||||
"name": "source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_host": {
|
||||
"name": "source_host",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_hash": {
|
||||
"name": "source_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"original_s3_key": {
|
||||
"name": "original_s3_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"content_type": {
|
||||
"name": "content_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"image_asset_versions_asset_version_idx": {
|
||||
"name": "image_asset_versions_asset_version_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "asset_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "version",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"image_asset_versions_source_hash_idx": {
|
||||
"name": "image_asset_versions_source_hash_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "source_hash",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"image_asset_versions_asset_id_image_assets_id_fk": {
|
||||
"name": "image_asset_versions_asset_id_image_assets_id_fk",
|
||||
"tableFrom": "image_asset_versions",
|
||||
"tableTo": "image_assets",
|
||||
"columnsFrom": [
|
||||
"asset_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.image_assets": {
|
||||
"name": "image_assets",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"public_id": {
|
||||
"name": "public_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"current_version": {
|
||||
"name": "current_version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "asset_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'active'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"image_assets_public_id_idx": {
|
||||
"name": "image_assets_public_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "public_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.image_variants": {
|
||||
"name": "image_variants",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"asset_id": {
|
||||
"name": "asset_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"asset_version_id": {
|
||||
"name": "asset_version_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"asset_version": {
|
||||
"name": "asset_version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"preset": {
|
||||
"name": "preset",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"variant_hash": {
|
||||
"name": "variant_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"requested_format": {
|
||||
"name": "requested_format",
|
||||
"type": "requested_format",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'auto'"
|
||||
},
|
||||
"format": {
|
||||
"name": "format",
|
||||
"type": "variant_format",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"resize_mode": {
|
||||
"name": "resize_mode",
|
||||
"type": "resize_mode",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'fit'"
|
||||
},
|
||||
"quality": {
|
||||
"name": "quality",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"s3_key": {
|
||||
"name": "s3_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content_type": {
|
||||
"name": "content_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"etag": {
|
||||
"name": "etag",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "variant_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"attempt_count": {
|
||||
"name": "attempt_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"last_accessed_at": {
|
||||
"name": "last_accessed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"image_variants_lookup_idx": {
|
||||
"name": "image_variants_lookup_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "asset_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "asset_version",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "preset",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "width",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "height",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "resize_mode",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "quality",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "format",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"image_variants_s3_key_idx": {
|
||||
"name": "image_variants_s3_key_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "s3_key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"image_variants_variant_hash_idx": {
|
||||
"name": "image_variants_variant_hash_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "variant_hash",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"image_variants_status_idx": {
|
||||
"name": "image_variants_status_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"image_variants_asset_id_image_assets_id_fk": {
|
||||
"name": "image_variants_asset_id_image_assets_id_fk",
|
||||
"tableFrom": "image_variants",
|
||||
"tableTo": "image_assets",
|
||||
"columnsFrom": [
|
||||
"asset_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"image_variants_asset_version_id_image_asset_versions_id_fk": {
|
||||
"name": "image_variants_asset_version_id_image_asset_versions_id_fk",
|
||||
"tableFrom": "image_variants",
|
||||
"tableTo": "image_asset_versions",
|
||||
"columnsFrom": [
|
||||
"asset_version_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.asset_status": {
|
||||
"name": "asset_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"active",
|
||||
"disabled",
|
||||
"deleted"
|
||||
]
|
||||
},
|
||||
"public.requested_format": {
|
||||
"name": "requested_format",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"auto",
|
||||
"avif",
|
||||
"webp",
|
||||
"jpg",
|
||||
"png"
|
||||
]
|
||||
},
|
||||
"public.resize_mode": {
|
||||
"name": "resize_mode",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"fit",
|
||||
"fill"
|
||||
]
|
||||
},
|
||||
"public.variant_format": {
|
||||
"name": "variant_format",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"avif",
|
||||
"webp",
|
||||
"jpg",
|
||||
"png"
|
||||
]
|
||||
},
|
||||
"public.variant_status": {
|
||||
"name": "variant_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"pending",
|
||||
"processing",
|
||||
"ready",
|
||||
"failed"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1777963363578,
|
||||
"tag": "0000_calm_magdalene",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1777973330318,
|
||||
"tag": "0001_familiar_nextwave",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,19 +2,20 @@
|
||||
"name": "@image-platform/database",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"require": "./dist/index.js",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"db:generate": "drizzle-kit generate --config drizzle.config.ts",
|
||||
"db:migrate": "drizzle-kit migrate --config drizzle.config.ts",
|
||||
"db:studio": "drizzle-kit studio --config drizzle.config.ts",
|
||||
"db:generate": "node --env-file-if-exists=../../.env ./node_modules/drizzle-kit/bin.cjs generate --config drizzle.config.ts",
|
||||
"db:migrate": "node --env-file-if-exists=../../.env ./node_modules/drizzle-kit/bin.cjs migrate --config drizzle.config.ts",
|
||||
"db:studio": "node --env-file-if-exists=../../.env ./node_modules/drizzle-kit/bin.cjs studio --config drizzle.config.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { createDatabase, createDatabasePool } from "./client.js"
|
||||
export type { Database, DatabasePool } from "./client.js"
|
||||
export * from "./schema.js"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
export const assetStatusEnum = pgEnum("asset_status", ["active", "disabled", "deleted"])
|
||||
export const variantFormatEnum = pgEnum("variant_format", ["avif", "webp", "jpg", "png"])
|
||||
export const requestedFormatEnum = pgEnum("requested_format", ["auto", "avif", "webp", "jpg", "png"])
|
||||
export const resizeModeEnum = pgEnum("resize_mode", ["fit", "fill"])
|
||||
export const variantStatusEnum = pgEnum("variant_status", ["pending", "processing", "ready", "failed"])
|
||||
|
||||
const timestamps = {
|
||||
@@ -86,6 +87,7 @@ export const imageVariants = pgTable(
|
||||
format: variantFormatEnum("format").notNull(),
|
||||
width: integer("width").notNull(),
|
||||
height: integer("height"),
|
||||
resizeMode: resizeModeEnum("resize_mode").notNull().default("fit"),
|
||||
quality: integer("quality").notNull(),
|
||||
s3Key: text("s3_key").notNull(),
|
||||
contentType: text("content_type"),
|
||||
@@ -103,6 +105,8 @@ export const imageVariants = pgTable(
|
||||
table.assetVersion,
|
||||
table.preset,
|
||||
table.width,
|
||||
table.height,
|
||||
table.resizeMode,
|
||||
table.quality,
|
||||
table.format,
|
||||
),
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["ES2023"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
||||
22
packages/image-config/package.json
Normal file
22
packages/image-config/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@image-platform/image-config",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"require": "./dist/index.js",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
20
packages/image-config/src/allowed-hosts.ts
Normal file
20
packages/image-config/src/allowed-hosts.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const DEFAULT_ALLOWED_SOURCE_HOSTS = ["storage.yandexcloud.net"] as const
|
||||
|
||||
export function loadAllowedSourceHostsFromEnv(env: NodeJS.ProcessEnv = process.env) {
|
||||
const value = env.SOURCE_ALLOWED_HOSTS
|
||||
|
||||
if (!value) {
|
||||
return new Set<string>(DEFAULT_ALLOWED_SOURCE_HOSTS)
|
||||
}
|
||||
|
||||
return new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map((host) => host.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
|
||||
export function isAllowedSourceHost(hostname: string, allowedHosts: ReadonlySet<string>) {
|
||||
return allowedHosts.has(hostname.toLowerCase())
|
||||
}
|
||||
2
packages/image-config/src/index.ts
Normal file
2
packages/image-config/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./allowed-hosts.js"
|
||||
export * from "./presets.js"
|
||||
297
packages/image-config/src/presets.ts
Normal file
297
packages/image-config/src/presets.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
export type ActualImageFormat = "avif" | "jpg" | "png" | "webp"
|
||||
export type RequestedImageFormat = "auto" | ActualImageFormat
|
||||
export type ResizeMode = "fill" | "fit"
|
||||
export type PresetMode = "fixed" | "responsive"
|
||||
export type TransformMode = "custom" | PresetMode
|
||||
|
||||
export type ImagePreset = {
|
||||
formats: readonly ActualImageFormat[]
|
||||
height?: number
|
||||
mode: PresetMode
|
||||
qualities: readonly number[]
|
||||
quality: number
|
||||
resize: ResizeMode
|
||||
width?: number
|
||||
widths?: readonly number[]
|
||||
}
|
||||
|
||||
export type CustomTransformConfig = {
|
||||
formats: readonly ActualImageFormat[]
|
||||
maxHeight: number
|
||||
maxWidth: number
|
||||
quality: number
|
||||
}
|
||||
|
||||
export type NormalizeImageTransformInput = {
|
||||
allowCustomTransforms: boolean
|
||||
format: ActualImageFormat
|
||||
height?: number | null
|
||||
preset: string
|
||||
quality?: number | null
|
||||
requestedFormat?: RequestedImageFormat | null
|
||||
resize?: ResizeMode | null
|
||||
width?: number | null
|
||||
}
|
||||
|
||||
export type NormalizedImageTransform = {
|
||||
format: ActualImageFormat
|
||||
height: number
|
||||
mode: TransformMode
|
||||
preset: string
|
||||
quality: number
|
||||
requestedFormat: RequestedImageFormat
|
||||
resize: ResizeMode
|
||||
width: number
|
||||
}
|
||||
|
||||
export class ImageTransformConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "ImageTransformConfigError"
|
||||
}
|
||||
}
|
||||
|
||||
export const IMAGE_PRESETS = {
|
||||
avatar: {
|
||||
formats: ["avif", "webp", "jpg"],
|
||||
height: 256,
|
||||
mode: "fixed",
|
||||
qualities: [80],
|
||||
quality: 80,
|
||||
resize: "fill",
|
||||
width: 256,
|
||||
},
|
||||
card: {
|
||||
formats: ["avif", "webp", "jpg"],
|
||||
mode: "responsive",
|
||||
qualities: [75, 80],
|
||||
quality: 80,
|
||||
resize: "fit",
|
||||
widths: [320, 640, 960],
|
||||
},
|
||||
hero: {
|
||||
formats: ["avif", "webp", "jpg"],
|
||||
mode: "responsive",
|
||||
qualities: [75, 80],
|
||||
quality: 80,
|
||||
resize: "fit",
|
||||
widths: [1280, 1920],
|
||||
},
|
||||
} as const satisfies Record<string, ImagePreset>
|
||||
|
||||
export const CUSTOM_PRESET_NAME = "custom"
|
||||
|
||||
export const CUSTOM_TRANSFORM_CONFIG: CustomTransformConfig = {
|
||||
formats: ["avif", "webp", "jpg", "png"],
|
||||
maxHeight: 4096,
|
||||
maxWidth: 4096,
|
||||
quality: 80,
|
||||
}
|
||||
|
||||
export function getImagePreset(name: string): ImagePreset | null {
|
||||
return Object.hasOwn(IMAGE_PRESETS, name) ? IMAGE_PRESETS[name as keyof typeof IMAGE_PRESETS] : null
|
||||
}
|
||||
|
||||
export function normalizeImageTransform(input: NormalizeImageTransformInput): NormalizedImageTransform {
|
||||
const requestedFormat = input.requestedFormat ?? input.format
|
||||
const preset = getImagePreset(input.preset)
|
||||
|
||||
if (!isRequestedImageFormat(requestedFormat)) {
|
||||
throw new ImageTransformConfigError("requestedFormat is invalid")
|
||||
}
|
||||
|
||||
if (!isActualImageFormat(input.format)) {
|
||||
throw new ImageTransformConfigError("format is invalid")
|
||||
}
|
||||
|
||||
if (preset) {
|
||||
return normalizePresetTransform(input, preset, requestedFormat)
|
||||
}
|
||||
|
||||
if (input.preset === CUSTOM_PRESET_NAME) {
|
||||
return normalizeCustomTransform(input, requestedFormat)
|
||||
}
|
||||
|
||||
throw new ImageTransformConfigError(`unknown image preset: ${input.preset}`)
|
||||
}
|
||||
|
||||
export function selectFormatForAccept(input: {
|
||||
allowCustomTransforms: boolean
|
||||
acceptHeader?: string | string[]
|
||||
preset: string
|
||||
requestedFormat: string
|
||||
}): { format: ActualImageFormat; requestedFormat: RequestedImageFormat } {
|
||||
if (!isRequestedImageFormat(input.requestedFormat)) {
|
||||
throw new ImageTransformConfigError("requested format is invalid")
|
||||
}
|
||||
|
||||
const formats = getAllowedFormats(input.preset, input.allowCustomTransforms)
|
||||
|
||||
if (input.requestedFormat !== "auto") {
|
||||
if (!formats.includes(input.requestedFormat)) {
|
||||
throw new ImageTransformConfigError(`format ${input.requestedFormat} is not allowed for ${input.preset}`)
|
||||
}
|
||||
|
||||
return { format: input.requestedFormat, requestedFormat: input.requestedFormat }
|
||||
}
|
||||
|
||||
const accept = Array.isArray(input.acceptHeader) ? input.acceptHeader.join(",") : (input.acceptHeader ?? "")
|
||||
|
||||
if (accept.includes("image/avif") && formats.includes("avif")) {
|
||||
return { format: "avif", requestedFormat: "auto" }
|
||||
}
|
||||
|
||||
if (accept.includes("image/webp") && formats.includes("webp")) {
|
||||
return { format: "webp", requestedFormat: "auto" }
|
||||
}
|
||||
|
||||
if (formats.includes("jpg")) {
|
||||
return { format: "jpg", requestedFormat: "auto" }
|
||||
}
|
||||
|
||||
if (formats.includes("png")) {
|
||||
return { format: "png", requestedFormat: "auto" }
|
||||
}
|
||||
|
||||
throw new ImageTransformConfigError(`no fallback format configured for ${input.preset}`)
|
||||
}
|
||||
|
||||
export function parseBooleanFlag(value: string | undefined, fallback: boolean) {
|
||||
if (value === undefined) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return ["1", "true", "yes"].includes(value.toLowerCase())
|
||||
}
|
||||
|
||||
function normalizePresetTransform(
|
||||
input: NormalizeImageTransformInput,
|
||||
preset: ImagePreset,
|
||||
requestedFormat: RequestedImageFormat,
|
||||
): NormalizedImageTransform {
|
||||
if (!preset.formats.includes(input.format)) {
|
||||
throw new ImageTransformConfigError(`format ${input.format} is not allowed for preset ${input.preset}`)
|
||||
}
|
||||
|
||||
const quality = normalizePresetQuality(input.quality, preset)
|
||||
|
||||
if (preset.mode === "fixed") {
|
||||
if (!preset.width || !preset.height) {
|
||||
throw new ImageTransformConfigError(`fixed preset ${input.preset} must define width and height`)
|
||||
}
|
||||
|
||||
if (input.width !== null && input.width !== undefined && input.width !== preset.width) {
|
||||
throw new ImageTransformConfigError(`width must be ${preset.width} for preset ${input.preset}`)
|
||||
}
|
||||
|
||||
if (input.height !== null && input.height !== undefined && input.height !== preset.height) {
|
||||
throw new ImageTransformConfigError(`height must be ${preset.height} for preset ${input.preset}`)
|
||||
}
|
||||
|
||||
return {
|
||||
format: input.format,
|
||||
height: preset.height,
|
||||
mode: preset.mode,
|
||||
preset: input.preset,
|
||||
quality,
|
||||
requestedFormat,
|
||||
resize: preset.resize,
|
||||
width: preset.width,
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.width) {
|
||||
throw new ImageTransformConfigError(`width is required for responsive preset ${input.preset}`)
|
||||
}
|
||||
|
||||
if (!preset.widths?.includes(input.width)) {
|
||||
throw new ImageTransformConfigError(`width ${input.width} is not allowed for preset ${input.preset}`)
|
||||
}
|
||||
|
||||
if (input.height !== null && input.height !== undefined && input.height !== (preset.height ?? 0)) {
|
||||
throw new ImageTransformConfigError(`height is not configurable for preset ${input.preset}`)
|
||||
}
|
||||
|
||||
return {
|
||||
format: input.format,
|
||||
height: preset.height ?? 0,
|
||||
mode: preset.mode,
|
||||
preset: input.preset,
|
||||
quality,
|
||||
requestedFormat,
|
||||
resize: preset.resize,
|
||||
width: input.width,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCustomTransform(
|
||||
input: NormalizeImageTransformInput,
|
||||
requestedFormat: RequestedImageFormat,
|
||||
): NormalizedImageTransform {
|
||||
if (!input.allowCustomTransforms) {
|
||||
throw new ImageTransformConfigError("custom transforms are disabled")
|
||||
}
|
||||
|
||||
if (!CUSTOM_TRANSFORM_CONFIG.formats.includes(input.format)) {
|
||||
throw new ImageTransformConfigError(`format ${input.format} is not allowed for custom transforms`)
|
||||
}
|
||||
|
||||
if (!input.width || input.width > CUSTOM_TRANSFORM_CONFIG.maxWidth) {
|
||||
throw new ImageTransformConfigError(`custom width must be between 1 and ${CUSTOM_TRANSFORM_CONFIG.maxWidth}`)
|
||||
}
|
||||
|
||||
const height = input.height ?? 0
|
||||
|
||||
if (height < 0 || height > CUSTOM_TRANSFORM_CONFIG.maxHeight) {
|
||||
throw new ImageTransformConfigError(`custom height must be between 0 and ${CUSTOM_TRANSFORM_CONFIG.maxHeight}`)
|
||||
}
|
||||
|
||||
const quality = input.quality ?? CUSTOM_TRANSFORM_CONFIG.quality
|
||||
|
||||
if (!Number.isSafeInteger(quality) || quality < 1 || quality > 100) {
|
||||
throw new ImageTransformConfigError("custom quality must be between 1 and 100")
|
||||
}
|
||||
|
||||
return {
|
||||
format: input.format,
|
||||
height,
|
||||
mode: "custom",
|
||||
preset: input.preset,
|
||||
quality,
|
||||
requestedFormat,
|
||||
resize: input.resize ?? "fit",
|
||||
width: input.width,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePresetQuality(value: number | null | undefined, preset: ImagePreset) {
|
||||
const quality = value ?? preset.quality
|
||||
|
||||
if (!preset.qualities.includes(quality)) {
|
||||
throw new ImageTransformConfigError(`quality ${quality} is not allowed for preset`)
|
||||
}
|
||||
|
||||
return quality
|
||||
}
|
||||
|
||||
function getAllowedFormats(presetName: string, allowCustomTransforms: boolean) {
|
||||
const preset = getImagePreset(presetName)
|
||||
|
||||
if (preset) {
|
||||
return preset.formats
|
||||
}
|
||||
|
||||
if (presetName === CUSTOM_PRESET_NAME && allowCustomTransforms) {
|
||||
return CUSTOM_TRANSFORM_CONFIG.formats
|
||||
}
|
||||
|
||||
throw new ImageTransformConfigError(`unknown image preset: ${presetName}`)
|
||||
}
|
||||
|
||||
function isActualImageFormat(value: string): value is ActualImageFormat {
|
||||
return value === "avif" || value === "webp" || value === "jpg" || value === "png"
|
||||
}
|
||||
|
||||
function isRequestedImageFormat(value: string): value is RequestedImageFormat {
|
||||
return value === "auto" || isActualImageFormat(value)
|
||||
}
|
||||
7
packages/image-config/tsconfig.build.json
Normal file
7
packages/image-config/tsconfig.build.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false
|
||||
},
|
||||
"exclude": ["dist", "node_modules", "**/*.spec.ts"]
|
||||
}
|
||||
21
packages/image-config/tsconfig.json
Normal file
21
packages/image-config/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["ES2023"],
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "ES2023",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
"name": "@image-platform/queue",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"require": "./dist/index.js",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
@@ -15,7 +16,11 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/amqplib": "^0.10.8",
|
||||
"@types/node": "^25.6.0",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"amqplib": "^1.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
29
packages/queue/src/amqp.ts
Normal file
29
packages/queue/src/amqp.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Channel } from "amqplib"
|
||||
|
||||
import type { GenerateVariantJob } from "./jobs.js"
|
||||
import type { QueueTopology } from "./topology.js"
|
||||
|
||||
export async function assertQueueTopology(channel: Channel, topology: QueueTopology) {
|
||||
await channel.assertExchange(topology.jobsExchange, "direct", { durable: true })
|
||||
await channel.assertExchange(topology.jobsDeadLetterExchange, "direct", { durable: true })
|
||||
await channel.assertQueue(topology.generateVariantQueue, {
|
||||
deadLetterExchange: topology.jobsDeadLetterExchange,
|
||||
deadLetterRoutingKey: topology.generateVariantDeadLetterRoutingKey,
|
||||
durable: true,
|
||||
})
|
||||
await channel.assertQueue(topology.generateVariantDeadLetterQueue, { durable: true })
|
||||
await channel.bindQueue(topology.generateVariantQueue, topology.jobsExchange, topology.generateVariantRoutingKey)
|
||||
await channel.bindQueue(
|
||||
topology.generateVariantDeadLetterQueue,
|
||||
topology.jobsDeadLetterExchange,
|
||||
topology.generateVariantDeadLetterRoutingKey,
|
||||
)
|
||||
}
|
||||
|
||||
export function publishGenerateVariantJob(channel: Channel, topology: QueueTopology, job: GenerateVariantJob) {
|
||||
return channel.publish(topology.jobsExchange, topology.generateVariantRoutingKey, Buffer.from(JSON.stringify(job)), {
|
||||
contentType: "application/json",
|
||||
deliveryMode: 2,
|
||||
persistent: true,
|
||||
})
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./amqp.js"
|
||||
export * from "./jobs.js"
|
||||
export * from "./topology.js"
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["ES2023"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
"name": "@image-platform/storage",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"require": "./dist/index.js",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,11 +8,15 @@ export type StorageConfig = {
|
||||
}
|
||||
|
||||
export function loadStorageConfigFromEnv(env: NodeJS.ProcessEnv = process.env): StorageConfig {
|
||||
if (!env.S3_BUCKET) {
|
||||
throw new Error("S3_BUCKET is required")
|
||||
}
|
||||
|
||||
return {
|
||||
accessKeyId: normalizeOptionalString(env.S3_ACCESS_KEY_ID),
|
||||
bucket: env.S3_BUCKET ?? "image-platform",
|
||||
bucket: env.S3_BUCKET,
|
||||
endpoint: normalizeOptionalString(env.S3_ENDPOINT),
|
||||
forcePathStyle: parseBoolean(env.S3_FORCE_PATH_STYLE, true),
|
||||
forcePathStyle: parseBoolean(env.S3_FORCE_PATH_STYLE, false),
|
||||
region: env.S3_REGION ?? "us-east-1",
|
||||
secretAccessKey: normalizeOptionalString(env.S3_SECRET_ACCESS_KEY),
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./client.js"
|
||||
export * from "./config.js"
|
||||
export * from "./keys.js"
|
||||
export * from "./objects.js"
|
||||
|
||||
91
packages/storage/src/objects.ts
Normal file
91
packages/storage/src/objects.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, type S3Client } from "@aws-sdk/client-s3"
|
||||
|
||||
export type StoredObject = {
|
||||
body: Buffer
|
||||
contentLength: number | null
|
||||
contentType: string | null
|
||||
etag: string | null
|
||||
}
|
||||
|
||||
export async function getObjectBuffer(client: S3Client, bucket: string, key: string): Promise<StoredObject | null> {
|
||||
try {
|
||||
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
|
||||
|
||||
if (!response.Body) {
|
||||
throw new Error(`S3 object ${key} has no body`)
|
||||
}
|
||||
|
||||
const body = await streamToBuffer(response.Body as AsyncIterable<Uint8Array>)
|
||||
|
||||
return {
|
||||
body,
|
||||
contentLength: response.ContentLength ?? body.length,
|
||||
contentType: response.ContentType ?? null,
|
||||
etag: response.ETag ?? null,
|
||||
}
|
||||
} catch (error) {
|
||||
if (isS3NotFound(error)) {
|
||||
return null
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function objectExists(client: S3Client, bucket: string, key: string) {
|
||||
try {
|
||||
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isS3NotFound(error)) {
|
||||
return false
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function putObjectBuffer(input: {
|
||||
body: Buffer
|
||||
bucket: string
|
||||
cacheControl?: string
|
||||
client: S3Client
|
||||
contentType: string
|
||||
key: string
|
||||
}) {
|
||||
return input.client.send(
|
||||
new PutObjectCommand({
|
||||
Body: input.body,
|
||||
Bucket: input.bucket,
|
||||
CacheControl: input.cacheControl,
|
||||
ContentType: input.contentType,
|
||||
Key: input.key,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function isS3NotFound(error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const withMetadata = error as Error & { $metadata?: { httpStatusCode?: number }; Code?: string; code?: string }
|
||||
|
||||
return (
|
||||
withMetadata.$metadata?.httpStatusCode === 404 ||
|
||||
error.name === "NoSuchKey" ||
|
||||
error.name === "NotFound" ||
|
||||
withMetadata.Code === "NoSuchKey" ||
|
||||
withMetadata.code === "NoSuchKey"
|
||||
)
|
||||
}
|
||||
|
||||
async function streamToBuffer(stream: AsyncIterable<Uint8Array>) {
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["ES2023"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
||||
Reference in New Issue
Block a user