├── .gitignore ├── tsconfig.commonjs.json ├── package.json ├── .github └── workflows │ └── ci.yml ├── tsconfig.json ├── README.md └── index.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_store 2 | node_modules 3 | dist/* 4 | !dist/.keep 5 | tsconfig.tsbuildinfo 6 | .parcel-cache* -------------------------------------------------------------------------------- /tsconfig.commonjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "declaration": false, 6 | "declarationDir": null, 7 | "outDir": "./dist/cjs" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "supabase-nextjs-image-api", 3 | "version": "1.2.0", 4 | "description": "", 5 | "main": "dist/esm/index.mjs", 6 | "scripts": { 7 | "build": "npx tsc", 8 | "build-cjs": "npx tsc --project tsconfig.commonjs.json", 9 | "postbuild": "npx uglify-js dist/esm/index.js -m -c -o dist/esm/index.min.mjs", 10 | "postbuild-cjs": "npx uglify-js dist/cjs/index.js -m -c -o dist/cjs/index.min.cjs", 11 | "prepublish": "npm run build && npm run build-cjs" 12 | }, 13 | "exports": { 14 | ".": { 15 | "require": "./dist/cjs/index.min.cjs", 16 | "import": "./dist/esm/index.min.mjs" 17 | } 18 | }, 19 | "keywords": [], 20 | "author": "", 21 | "license": "MIT", 22 | "peerDependencies": { 23 | "@supabase/supabase-js": "^2.26.0", 24 | "next": ">12.0.0", 25 | "sharp": "^0.32.1" 26 | }, 27 | "devDependencies": { 28 | "typescript": "^5.1.6", 29 | "uglify-js": "^3.17.4" 30 | } 31 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: npm-publish 2 | on: 3 | push: 4 | branches: 5 | - main # Change this to your default branch 6 | jobs: 7 | npm-publish: 8 | name: npm-publish 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v4 13 | 14 | - name: Install Node 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 20 18 | registry-url: "https://registry.npmjs.org" 19 | 20 | - name: Install Deps 21 | run: npm ci 22 | 23 | - name: Build 24 | run: npm run build && ls -al dist 25 | 26 | - name: Publish 27 | # publish if the commit message contains [Release] 28 | if: contains(github.event.head_commit.message, 'Release') 29 | run: | 30 | npm publish --access public 31 | env: # More info about the environment variables in the README 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Leave this as is, it's automatically generated 33 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} # You need to set this in your repo settings 34 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} # You need to set this in your repo settings 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | 16 | /* Modules */ 17 | "module": "ESNext" /* Specify what module code is generated. */, 18 | "moduleResolution": "Node", 19 | 20 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 21 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 22 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 23 | 24 | /* Type Checking */ 25 | "strict": true /* Enable all strict type-checking options. */ /* Disable error reporting for unreachable code. */, 26 | 27 | /* Completeness */ 28 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 29 | "skipLibCheck": true /* Skip type checking all .d.ts files. */, 30 | "outDir": "./dist/esm" /* Redirect output structure to the directory. */ 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Super-simple Pseudo-CDN for Images for Supabase with NextJS 2 | 3 | ## Why would I need that? 4 | 5 | - Uploading images to protected storage (so non-public buckets) will require you to download images. 6 | There is no such thing as "Direct image link with policy access". 7 | 8 | - The option is downloading images and showing them via NextJS Server API but if the image is bigger than 4MB you will easily have rejected requests (https://nextjs.org/docs/messages/api-routes-response-size-limit). 9 | 10 | ## Ok but what does this do then? 11 | 12 | In simple words: You create an `api/loadImage` route and use this package and here we go, you can skip the 4mb limit and you can have direct links to images. 13 | 14 | Your mini CDN. 15 | 16 | ## How does it work? 17 | 18 | In simple words: The package uses `sharp` package to downscale images based on the given byte limits (which you can configure). 19 | 20 | This is usually pretty fast and solid. 21 | 22 | ## But what if I need the full resolution image? 23 | 24 | 1. In most use-cases you really don't. If your users upload 10k images you probably don't want to show 10k. 25 | 2. Technically there is no issue at all with this package showing the full resolution as you could just oversize the byte limit and set the `maxSizeWidth` to whatever you want and you're good to go. But then you'll run back into memory issues sooner or later (as provided in the NextJS link above) 26 | 3. The normal way is this: 27 | - You use this package to show images 28 | - If someone wants the original solution you'll build a special button to download it and use the [`createSignedUrl`](https://supabase.com/docs/reference/javascript/storge-from-createsignedurls) 29 | 30 | ## Can't I just use the Image Transforms from Supabase itself? 31 | 32 | Sure, you can. Be aware of the limits and the costs though. Also there is no such thing as an Image Deep Link with those which makes it really inconvenient. 33 | 34 | ## With which version of NextJS is this compatible? 35 | 36 | Since you can freely use the `pages/api` route even in the NextJS@13 version 37 | you can use this with v13 without any problems (I do). 38 | 39 | ## Usage: 40 | 41 | ```bash 42 | npm i sharp supabase-nextjs-image-api 43 | ``` 44 | 45 | ```typescript 46 | // api/load-image.ts 47 | import { 48 | loadImageSafely, 49 | // a preset for thumbnail loading 50 | THUMBNAIL_IMAGE_LOADER_OPTIONS, 51 | // a preset for "fullsize" images 52 | FULL_IMAGE_LOADER_OPTIONS, 53 | ImageLoaderOptions, 54 | SupabaseImageData, 55 | } from "supabase-nextjs-image-api"; 56 | import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs"; 57 | 58 | export async function YourRoute( 59 | req: NextApiRequest, 60 | res: NextApiResponse 61 | ) { 62 | // Normal Policies obviously apply so the package will only be able to grab 63 | // the image if you provide a client with access to it (e.g. super client) 64 | const client = createServerSupabaseClient({ req, res }); 65 | 66 | const imageData: SupabaseImageData = { 67 | path: "your-path-to-the-image.png", // e.g. load from req.query parameter 68 | bucket: "your-bucket-name", 69 | }; 70 | 71 | let imageLoaderOptions: ImageLoaderOptions = { 72 | ...THUMBNAIL_IMAGE_LOADER_OPTIONS, 73 | // maxSizeBytes: number; // for Vercel deploys this should be below 4mb 74 | // maxSizeWidth: number; 75 | // quality: number; 76 | // sharpen?: boolean; 77 | // progressive?: boolean; 78 | // standardCacheTime?: number; 79 | }; 80 | 81 | return loadImageSafely(res, client, imageData, imageLoaderOptions); 82 | } 83 | ``` 84 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { SupabaseClient } from '@supabase/supabase-js'; 2 | import { NextApiResponse } from 'next'; 3 | import sharp from 'sharp'; 4 | 5 | export const TRANSPARENT_IMAGE_GIF_BYTES = Buffer.from( 6 | 'R0lGODlhAQABAIAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=', 7 | 'base64' 8 | ); 9 | 10 | async function loadImageFromSupabase(serverClient: SupabaseClient, bucket: string, path: string) { 11 | const { data: blob, error } = await serverClient.storage.from(bucket).download(path); 12 | 13 | const errInfo = JSON.stringify({ 14 | bucket, 15 | path, 16 | }); 17 | 18 | if (error) { 19 | throw new Error('Image not found > ' + errInfo); 20 | } 21 | 22 | if (!blob) { 23 | throw new Error('Blob was not retrieved > ' + errInfo); 24 | } 25 | 26 | return blob; 27 | } 28 | 29 | function deriveImageTypeFromPath(path: string) { 30 | const lPath = path.toLowerCase(); 31 | let ext = 32 | lPath.endsWith('.jpeg') || lPath.endsWith('.jpg') 33 | ? 'jpg' 34 | : lPath.endsWith('.gif') 35 | ? 'gif' 36 | : lPath.endsWith('.png') 37 | ? 'png' 38 | : lPath.endsWith('.heic') 39 | ? 'heic' 40 | : lPath.endsWith('.tiff') 41 | ? 'tiff' 42 | : lPath.endsWith('.webp') 43 | ? 'webp' 44 | : 'unknown'; 45 | 46 | return ext; 47 | } 48 | // supabase package + video (yt) 49 | 50 | export type SupabaseImageData = { 51 | bucket: string; 52 | path: string; 53 | }; 54 | 55 | export type ImageLoaderOptions = { 56 | maxSizeBytes: number; 57 | maxSizeWidth: number; 58 | quality: number; 59 | progressive?: boolean; 60 | standardCacheTime?: number; 61 | sharpen?: boolean; 62 | }; 63 | 64 | const oneKbInBytes = 1024; 65 | const hundredKbs = 100 * oneKbInBytes; 66 | 67 | const STANDARD_CACHE_TIME = 60 * 60 * 24 * 5; // 5 days 68 | export const THUMBNAIL_IMAGE_LOADER_OPTIONS: ImageLoaderOptions = { 69 | maxSizeBytes: hundredKbs * 2, 70 | maxSizeWidth: 120, 71 | quality: 60, 72 | progressive: true, 73 | standardCacheTime: STANDARD_CACHE_TIME, 74 | }; 75 | 76 | export const FULL_IMAGE_LOADER_OPTIONS: ImageLoaderOptions = { 77 | maxSizeBytes: hundredKbs * 10, 78 | maxSizeWidth: 2500, 79 | quality: 94, 80 | progressive: true, 81 | standardCacheTime: STANDARD_CACHE_TIME, 82 | }; 83 | 84 | async function getStreamableImage( 85 | supabaseClient: SupabaseClient, 86 | imageData: SupabaseImageData, 87 | options: ImageLoaderOptions 88 | ) { 89 | let streamable: sharp.Sharp; 90 | 91 | const { maxSizeBytes, maxSizeWidth, quality, progressive } = options; 92 | 93 | const imageType = deriveImageTypeFromPath(imageData.path); 94 | 95 | if (imageType === 'unknown') { 96 | throw new Error('Unknown image type: ' + imageData.path); 97 | } 98 | 99 | const blob = await loadImageFromSupabase(supabaseClient, imageData.bucket, imageData.path); 100 | 101 | // now processing with sharp 102 | const buffer = Buffer.from(await blob.arrayBuffer()); 103 | const parseImage = sharp(buffer); 104 | const imageMeta = await parseImage.metadata(); 105 | 106 | if (!imageMeta.width) { 107 | throw new Error('Not possible to retrieve image width'); 108 | } 109 | 110 | streamable = parseImage; 111 | 112 | if (buffer.byteLength > maxSizeBytes || imageMeta.width > maxSizeWidth) { 113 | // we need to resize: 114 | const downscaleFactorBytesEstimated = Math.min(1, maxSizeBytes / buffer.byteLength); 115 | 116 | const downScaleFactorWidthEstimated = Math.min(1, maxSizeWidth / imageMeta.width); 117 | 118 | const factor = Math.min(downScaleFactorWidthEstimated, downscaleFactorBytesEstimated); 119 | 120 | // automatically rotate by exif orientation before metadata is lost by resizing 121 | streamable = streamable.rotate().resize(Math.round(imageMeta.width * factor)); 122 | } 123 | 124 | if (options.sharpen) { 125 | streamable = streamable.sharpen({ 126 | sigma: 0.9, 127 | }); 128 | } 129 | 130 | streamable = streamable.jpeg({ 131 | quality, 132 | progressive, 133 | }); 134 | 135 | return streamable; 136 | } 137 | 138 | export async function loadImageSafely( 139 | apiRes: NextApiResponse, 140 | supabaseClient: SupabaseClient, 141 | imageData: SupabaseImageData, 142 | options: ImageLoaderOptions & { 143 | onError?: (e: any) => void; 144 | useTransparentImageFallback?: boolean; 145 | } 146 | ) { 147 | const { onError, useTransparentImageFallback } = options; 148 | 149 | try { 150 | if (!imageData.bucket || !imageData.path) { 151 | throw new Error('Invalid image data'); 152 | } 153 | 154 | const streamable = await getStreamableImage(supabaseClient, imageData, options); 155 | 156 | apiRes.writeHead(200, { 157 | 'Content-Type': 'image/jpg', 158 | // 'Content-Length': blob.size, 159 | 'Cache-control': 'max-age=' + options.standardCacheTime, 160 | }); 161 | 162 | return await new Promise((resolve) => { 163 | // const readStream = blob.stream(); 164 | streamable.pipe(apiRes); 165 | // readStream.pipe(resized.); 166 | streamable.on('end', resolve); 167 | }); 168 | } catch (e) { 169 | onError?.(e); 170 | 171 | const errStr = e + ''; 172 | let errCode = 500; 173 | let errMessage = 'Internal server error'; 174 | 175 | if (errStr.includes('Image not found')) { 176 | errCode = 404; 177 | errMessage = 'Image not found'; 178 | } 179 | 180 | if (useTransparentImageFallback) { 181 | apiRes.writeHead(errCode, { 182 | 'Content-Type': 'image/gif', 183 | 'Content-Length': TRANSPARENT_IMAGE_GIF_BYTES.byteLength, 184 | 'Cache-control': 'no-cache', 185 | }); 186 | 187 | apiRes.end(TRANSPARENT_IMAGE_GIF_BYTES, 'binary'); 188 | } else { 189 | apiRes.status(errCode).send(errMessage); 190 | } 191 | } 192 | } 193 | --------------------------------------------------------------------------------