diff --git a/infra/api.ts b/infra/api.ts index aa984c17..79e38565 100644 --- a/infra/api.ts +++ b/infra/api.ts @@ -50,7 +50,7 @@ export const apiService = new sst.aws.Service("Api", { transform: { taskDefinition: (args) => { const volumes = $output(args.volumes).apply(v => { - const next = [...v, { + const next = [...(v || []), { name: "shared-tmp", dockerVolumeConfiguration: { scope: "shared", diff --git a/infra/auth.ts b/infra/auth.ts index 37918a84..ae3dbd56 100644 --- a/infra/auth.ts +++ b/infra/auth.ts @@ -55,7 +55,7 @@ export const authService = new sst.aws.Service("Auth", { transform: { taskDefinition: (args) => { const volumes = $output(args.volumes).apply(v => { - const next = [...v, { + const next = [...(v || []), { name: "shared-tmp", dockerVolumeConfiguration: { scope: "shared", diff --git a/infra/images.ts b/infra/images.ts new file mode 100644 index 00000000..ae223c17 --- /dev/null +++ b/infra/images.ts @@ -0,0 +1,179 @@ +import { domain } from "./dns"; +import { storage } from "./storage"; + +const cache = new sst.cloudflare.Kv("ImageCache"); + +const bucket = new sst.cloudflare.Bucket("ImageBucket"); + +const imageRouter = new sst.aws.Router("ImageRouter", { + routes: { + "/*": { + bucket: storage, + rewrite: { regex: "^/([a-zA-Z0-9_-]+)$", to: "/images/$1" }, + }, + }, +}); + +export const imageCdn = new sst.cloudflare.Worker("ImageCDN", { + url: true, + domain: "cdn." + domain, + link: [bucket, cache, imageRouter], + handler: "packages/functions/src/images/index.ts", +}); + +export const outputs = { + cdn: imageCdn.url +} + +// const transformedImageBucket = new sst.aws.Bucket("TranformedStorage"); + +// const imageProcessorFunction = new sst.aws.Function("ImageProcessor", +// { +// handler: "src/image-processor.handler", +// nodejs: { install: ["sharp"] }, +// memory: "1024 MB", +// timeout: "30 seconds", +// url: true, +// link: [storage, transformedImageBucket], +// environment: { +// transformedImageCacheTTL: +// process.env.transformedImageCacheTTL ?? "max-age=31622400", +// maxImageSize: process.env.MAX_IMAGE_SIZE ?? "4700000", +// }, +// }, +// ); + +// const cloudfrontOAI = new aws.cloudfront.OriginAccessIdentity("CloudfrontOAI"); + +// const lambdaOAC = new aws.cloudfront.OriginAccessControl("OriginAccessControl", +// { +// name: `${$app.name}-${$app.stage}-OriginAccessControl`, +// originAccessControlOriginType: "lambda", +// signingBehavior: "always", +// signingProtocol: "sigv4", +// }, +// ); + +// const cloudfrontResponseHeadersPolicy = +// new aws.cloudfront.ResponseHeadersPolicy("ResponseHeadersPolicy", +// { +// name: `${$app.name}-${$app.stage}-ResponseHeadersPolicy`, +// customHeadersConfig: { +// items: [ +// { +// header: "x-aws-image-optimization", +// value: "v1.0", +// override: true, +// }, +// { header: "vary", value: "accept", override: true }, +// ], +// }, +// corsConfig: { +// accessControlAllowCredentials: false, +// accessControlAllowHeaders: { +// items: ["*"], +// }, +// accessControlAllowMethods: { +// items: ["GET"], +// }, +// accessControlAllowOrigins: { +// items: ["*"], +// }, +// accessControlMaxAgeSec: 600, +// originOverride: false, +// }, +// }, +// ); + +// const cachePolicy = new aws.cloudfront.CachePolicy("CachePolicy", +// { +// name: `${$app.name}-${$app.stage}-CachePolicy`, +// defaultTtl: 86400, +// maxTtl: 31536000, +// minTtl: 0, +// parametersInCacheKeyAndForwardedToOrigin: { +// cookiesConfig: { +// cookieBehavior: "none", +// }, +// headersConfig: { +// headerBehavior: "none", +// }, +// queryStringsConfig: { +// queryStringBehavior: "whitelist", +// queryStrings: { +// items: ["w", "h", "dpr", "format"] +// }, +// }, +// }, +// }, +// ); + +// const groupOriginId = `${$app.name}-${$app.stage}-"GroupOrigin`; +// const primaryOriginId = `${$app.name}-${$app.stage}-PrimaryOrigin`; +// const secondaryOriginId = `${$app.name}-${$app.stage}-SecondaryOrigin`; + +// const s3Distribution = new sst.aws.Cdn("ImageDistribution", +// { +// originGroups: [ +// { +// originId: groupOriginId, +// failoverCriteria: { +// statusCodes: [403, 500, 503, 504], +// }, +// members: [ +// { +// originId: primaryOriginId, +// }, +// { +// originId: secondaryOriginId, +// }, +// ], +// }, +// ], +// origins: [ +// { +// originId: primaryOriginId, +// domainName: imageProcessorFunction.url.apply( +// (url) => new URL(url).hostname, +// ), +// originAccessControlId: lambdaOAC.id, +// customOriginConfig: { +// originProtocolPolicy: "https-only", +// httpPort: 443, +// httpsPort: 443, +// originSslProtocols: ["TLSv1.2"], +// }, +// }, +// { +// originId: secondaryOriginId, +// domainName: +// transformedImageBucket.nodes.bucket.bucketRegionalDomainName, +// s3OriginConfig: { +// originAccessIdentity: cloudfrontOAI.cloudfrontAccessIdentityPath, +// }, +// }, +// ], +// defaultCacheBehavior: { +// cachePolicyId: cachePolicy.id, +// allowedMethods: ["GET", "HEAD"], +// cachedMethods: ["GET", "HEAD"], +// targetOriginId: groupOriginId, +// viewerProtocolPolicy: "redirect-to-https", +// functionAssociations: [ +// { +// eventType: "viewer-request", +// functionArn: cloudfrontRewriteFunction.arn, +// }, +// ], +// responseHeadersPolicyId: cloudfrontResponseHeadersPolicy.id, +// }, +// }, +// ); + +// new aws.lambda.Permission("AllowCloudFrontServicePrincipal", { +// action: "lambda:InvokeFunctionUrl", +// function: imageProcessorFunction.arn, +// principal: "cloudfront.amazonaws.com", +// statementId: "AllowCloudFrontServicePrincipal", +// sourceArn: s3Distribution.nodes.distribution.arn, +// }); \ No newline at end of file diff --git a/infra/storage.ts b/infra/storage.ts index ef5af0c6..4e602160 100644 --- a/infra/storage.ts +++ b/infra/storage.ts @@ -1 +1,3 @@ -export const storage = new sst.aws.Bucket("Storage"); \ No newline at end of file +export const storage = new sst.aws.Bucket("Storage",{ + access: "cloudfront", +}); \ No newline at end of file diff --git a/infra/zero.ts b/infra/zero.ts index 45ffcab4..d49336e9 100644 --- a/infra/zero.ts +++ b/infra/zero.ts @@ -146,9 +146,9 @@ export const zero = new sst.aws.Service("Zero", { ZERO_NUM_SYNC_WORKERS: "1", } : { - ZERO_CHANGE_STREAMER_URI: replicationManager.url.apply((val) => + ZERO_CHANGE_STREAMER_URI: replicationManager?.url.apply((val) => val.replace("http://", "ws://"), - ), + ) ?? "", ZERO_UPSTREAM_MAX_CONNS: "15", ZERO_CVR_MAX_CONNS: "160", }), diff --git a/packages/functions/src/images/image.ts b/packages/functions/src/images/image.ts new file mode 100644 index 00000000..92ee4c89 --- /dev/null +++ b/packages/functions/src/images/image.ts @@ -0,0 +1,50 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { Resource } from "sst"; + +export namespace ImageRoute { + export const route = new Hono() + .get( + "/:hashWithExt", + (c) => { + const { hashWithExt } = c.req.param(); + + // Validate format + // Split hash and extension + const match = hashWithExt.match(/^([a-zA-Z0-9_-]+)\.(avif|webp)$/); + if (!match) { + throw new HTTPException(400, { message: "Invalid image hash or format" }); + } + + const [, hash, format] = match; + + const query = c.req.query(); + // Parse dimensions + const width = parseInt(query.w || query.width || ""); + const height = parseInt(query.h || query.height || ""); + const dpr = parseFloat(query.dpr || "1"); + + if (isNaN(width) || width <= 0) { + throw new HTTPException(400, { message: "Invalid width" }); + } + if (!isNaN(height) && height < 0) { + throw new HTTPException(400, { message: "Invalid height" }); + } + if (dpr < 1 || dpr > 4) { + throw new HTTPException(400, { message: "Invalid dpr" }); + } + + const image = `${Resource.ImageRouter.url}/images/00641801e0f80a82bbbbf9dec5593cd7cbe2a5eec45d36199a5c14ed30d8df66` + + + // Normalize and build cache key + const cacheKey = `${hash}_${format}_w${width}${height ? `_h${height}` : ""}_dpr${dpr}`; + + // Add aggressive caching + c.header("Cache-Control", "public, max-age=315360000, immutable"); + + // Placeholder image response (to be replaced by real logic) + return c.text(`Would serve image: ${cacheKey}`); + } + ) +} \ No newline at end of file diff --git a/packages/functions/src/images/index.ts b/packages/functions/src/images/index.ts new file mode 100644 index 00000000..8fb475cf --- /dev/null +++ b/packages/functions/src/images/index.ts @@ -0,0 +1,18 @@ +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import { ImageRoute } from "./image"; + +const app = new Hono(); +app + .use(logger(), async (c, next) => { + c.header("Cache-Control", "public, max-age=315360000, immutable"); + return next(); + }) + +const routes = app + .get("/", (c) => c.text("Hello World 👋🏾")) + .route("/image", ImageRoute.route) + + +export type Routes = typeof routes; +export default app; \ No newline at end of file diff --git a/sst-env.d.ts b/sst-env.d.ts index 26f6384f..26f044a5 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -57,6 +57,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ImageRouter": { + "type": "sst.aws.Router" + "url": string + } "NestriFamilyMonthly": { "type": "sst.sst.Secret" "value": string @@ -117,6 +121,15 @@ declare module "sst" { } } } +// cloudflare +import * as cloudflare from "@cloudflare/workers-types"; +declare module "sst" { + export interface Resource { + "ImageBucket": cloudflare.R2Bucket + "ImageCDN": cloudflare.Service + "ImageCache": cloudflare.KVNamespace + } +} import "sst" export {} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 0967ef42..adca9ff8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1 +1,22 @@ -{} +{ + "compilerOptions": { + "lib": [ + "ESNext" + ], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + /* Linting */ + "skipLibCheck": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true + } +} \ No newline at end of file