Repository: humanmade/tachyon
Branch: master
Commit: 52ac4f70f2da
Files: 31
Total size: 50.2 KB
Directory structure:
gitextract_5_ahotyn/
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ ├── build-docker.yml
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── SECURITY.md
├── docs/
│ ├── plugin.md
│ ├── tips.md
│ └── using.md
├── global.d.ts
├── jest.config.js
├── package.json
├── src/
│ ├── lambda-handler.ts
│ ├── lib.ts
│ └── server.ts
├── template.yaml
├── tests/
│ ├── events/
│ │ ├── animated-gif.json
│ │ ├── original.json
│ │ └── signed-url.json
│ ├── setup.ts
│ ├── test-animated-files.ts
│ ├── test-filesize/
│ │ ├── fixtures.json
│ │ └── test-filesize.ts
│ ├── test-lambda.ts
│ └── test-private-upload.ts
├── tsconfig.json
└── tsconfig.test.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/dependabot.yml
================================================
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"
================================================
FILE: .github/workflows/build-docker.yml
================================================
name: Docker Build
on:
push:
branches:
- "**"
tags:
- "**"
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18.x
- run: npm install
- run: npx tsc
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: humanmade/tachyon
tags: |
type=edge,branch=master
type=ref,event=tag
- uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push latest
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')
uses: docker/build-push-action@v2
with:
file: Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
push:
tags:
- "**"
branches:
- '**'
pull_request:
branches:
- '**'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18.x
- run: npm install
- run: npx tsc
- uses: aws-actions/setup-sam@v2
with:
use-installer: true
- run: npm run build
- run: npm run build-zip
- uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: lambda.zip
- uses: actions/upload-artifact@v4
if: github.event_name == 'pull_request'
with:
path: lambda.zip
name: lambda
================================================
FILE: .github/workflows/test.yml
================================================
name: Test
on:
push:
branches:
- '**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18.x
- run: npm install
- run: npx jest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_REGION: ${{ vars.S3_REGION }}
================================================
FILE: .gitignore
================================================
node_modules/
.idea
lambda.zip
.aws-sam/
.DS_Store
dist/
/tests/test-filesize/output/
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing
## Building for Lambda
You'll need to [install the AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) as AWS SAM.
```
npm install
npm run build // Builds the function for use in SAM
```
### Building locally
Tachyon is written in TypeScript. All TypeScript files are in `.src` and running `npx tsc` will build everything to `./dist`. You can run `npx tsc -w` to watch for file changes to update `./dist`. This is needed if you are running the server locally (see below) or running the Lambda environment via the SAM cli (see below.)
### Running a server locally
Invoking the function via Lambda locally is somewhat slow (see below), in many cases you may want to start a local Node server which maps the Node request into a Lambda-like request. `./src/server.ts` exists for that reason. The local server will still connect to the S3 bucket (set with the `S3_BUCKET` env var) for files.
### Running Lambda Locally
Before testing any of the Lambda function calls via the `sam` CLI, you must run `sam build -u` to build the NPM deps via the Lambda docker container. This will also build the `./dist/` into the SAM environment, so any subsequent changes to files in `./src` but be first built (which updates `./dist`), and then `sam build -u` must be run.
To run Tachyon in a Lambda local environment via docker, use the `sam local invoke -e events/animated-gif.json` CLI command. This will call the function via the `src/lambda-handler.handler` function.
### Writing tests
Tests should be written using Jest. Files matching `./tests/**/test-*.ts` will automatically be included in the Jest testsuite. For tests, you don't need to run `npx tsc` to compile TypeScript files to `./dist`, as this is integrated automatically via the `ts-jest` package.
Run `npm test` to run the tests.
================================================
FILE: Dockerfile
================================================
FROM public.ecr.aws/lambda/nodejs:18
COPY package.json /var/task/
COPY package-lock.json /var/task/
RUN npm install --omit=dev
COPY dist /var/task/dist
# Set environment variables, backwards compat with Tachyon 2x.
ARG S3_REGION
ARG S3_BUCKET
ARG S3_ENDPOINT
ARG PORT
# Start the reactor
EXPOSE ${PORT:-8080}
ENTRYPOINT /var/lang/bin/node dist/server.js ${PORT:-8080}
================================================
FILE: LICENSE
================================================
ISC License
Copyright (c) 2023 Human Made
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
================================================
FILE: README.md
================================================
Tachyon
Faster than light image resizing service that runs on AWS. Super simple to set up, highly available and very performant.
|
|
A Human Made project. Maintained by @joehoyle.
|
|
Tachyon is a faster than light image resizing service that runs on AWS. Super simple to set up, highly available and very performant.
## Setup
Tachyon comes in two parts: the server to serve images and the [plugin to use it](./docs/plugin.md). To use Tachyon, you need to run at least one server, as well as the plugin on all sites you want to use it.
## Installation on AWS Lambda
We require using Tachyon on [AWS Lambda](https://aws.amazon.com/lambda/details/) to offload image processing task in a serverless configuration. This ensures you don't need lots of hardware to handle thousands of image resize requests, and can scale essentially infinitely. One Tachyon stack is required per S3 bucket, so we recommend using a common region bucket for all sites, which then only requires a single Tachyon stack per region.
Tachyon requires the following Lambda Function spec:
- Runtime: Node JS 18
- Function URL activated
- Env vars:
- S3_BUCKET=my-bucket
- S3_REGION=my-bucket-region
- S3_ENDPOINT=http://my-custom-endpoint (optional)
- S3_FORCE_PATH_STYLE=1 (optional)
Take the `lambda.zip` from the latest release and upload it to your function.
For routing web traffic to the Lambda function, we recommend using [Lambda Function URLs](https://docs.aws.amazon.com/lambda/latest/dg/urls-configuration.html). These should be configured as:
- Auth type: None
- Invoke mode: `RESPONSE_STREAM`
Alternatively, you can use API Gateway; this should be set to route all GET requests (i.e. `/{proxy+}`) to invoke your Tachyon Lambda function.
We also recommend running an aggressive caching proxy/CDN in front of Tachyon, such as CloudFront. (An expiration time of 1 year is typical for a production configuration.)
## Documentation
* [Plugin Setup](./docs/plugin.md)
* [Using Tachyon](./docs/using.md)
* [Hints and Tips](./docs/tips.md)
## Credits
Created by Human Made for high volume and large-scale sites. We run Tachyon on sites with millions of monthly page views, and thousands of sites.
Written and maintained by [Joe Hoyle](https://github.com/joehoyle).
Tachyon is inspired by Photon by Automattic. As Tachyon is not an all-purpose image resizer, rather it uses a media library in Amazon S3, it has a different use case to [Photon](https://jetpack.com/support/photon/).
Tachyon uses the [Sharp](https://github.com/lovell/sharp) (Used under the license Apache License 2.0) Node.js library for the resizing operations, which in turn uses the great libvips library.
Interested in joining in on the fun? [Join us, and become human!](https://hmn.md/is/hiring/)
## Looking for a different Tachyon?
Tachyon by Human Made provides image resizing services for the web, and is specifically designed for WordPress. "Tachyon" is named after the [hypothetical faster-than-light particle](https://en.wikipedia.org/wiki/Tachyon).
Other software named Tachyon include:
* [Tachyon by VYV](https://tachyon.video/) - Video playback and media server.
* [Tachyon by Cinnafilm](https://cinnafilm.com/product/tachyon/) - Video processing for the cinema industry.
* [TACHYONS](https://tachyons.io/) - CSS framework.
================================================
FILE: SECURITY.md
================================================
# Security Policy
## Reporting a Vulnerability
Email security [at] humanmade.com
================================================
FILE: docs/plugin.md
================================================
# Plugin Setup
The Tachyon plugin is responsible for replacing WordPress' default thumbnail handling with dynamic Tachyon URLs.
[Download from GitHub →](https://github.com/humanmade/tachyon-plugin)
## Installation
Install the Tachyon plugin as a regular plugin in your WordPress install (mu-plugins also supported).
You also need to point the plugin to your [Tachyon server](server.md). Add the following to your `wp-config-local.php`:
```php
define( 'TACHYON_URL', 'http://localhost:8080//uploads' );
```
## Credits
The Tachyon plugin is based on the Photon plugin code by Automattic, part of [Jetpack](https://github.com/Automattic/jetpack/blob/master/class.photon.php). Used under the GPL.
================================================
FILE: docs/tips.md
================================================
# Hints and Tips
## Regions
When running Tachyon in production, we recommend running one Tachyon instance per region. This instance should connect to the S3 bucket for the region, which can then be shared across all stacks in that region.
While S3 buckets can be accessed from any region, running Lambda from the same region as the bucket is recommended. This reduces latency and improves image serving speed.
================================================
FILE: docs/using.md
================================================
# Using
Tachyon provides a simple HTTP interface in the form of:
`https://{tachyon-domain}/my/image/path/on/s3.png?w=100&h=80`
It's really that simple!
## Args Reference
| URL Arg | Type | Description |
|---|----|---|
|`w`|Number|Max width of the image.|
|`h`|Number|Max height of the image.|
|`quality`|Number, 0-100|Image quality.|
|`resize`|String, "w,h"|A comma separated string of the target width and height in pixels. Crops the image.|
|`crop_strategy`|String, "smart", "entropy", "attention"|There are 3 automatic cropping strategies for use with `resize`: - `attention`: good results, ~70% slower
- `entropy`: mediocre results, ~30% slower
- `smart`: best results, ~50% slower
|
|`gravity`|String|Alternative to `crop_strategy`. Crops are made from the center of the image by default, passing one of "north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest" or "center" will crop from that edge.|
|`fit`|String, "w,h"|A comma separated string of the target maximum width and height. Does not crop the image.|
|`crop`|Boolean\|String, "x,y,w,h"|Crop an image by percentages x-offset, y-offset, width and height (x,y,w,h). Percentages are used so that you don’t need to recalculate the cropping when transforming the image in other ways such as resizing it. You can crop by pixel values too by appending `px` to the values. `crop=160px,160px,788px,788px` takes a 788 by 788 pixel square starting at 160 by 160.|
|`zoom`|Number|Zooms the image by the specified amount for high DPI displays. `zoom=2` produces an image twice the size specified in `w`, `h`, `fit` or `resize`. The quality is automatically reduced to keep file sizes roughly equivalent to the non-zoomed image unless the `quality` argument is passed.|
|`webp`|Boolean, 1|Force WebP format.|
|`lb`|String, "w,h"|Add letterboxing effect to images, by scaling them to width, height while maintaining the aspect ratio and filling the rest with black or `background`.|
|`background`|String|Add background color via name (red) or hex value (%23ff0000). Don't forget to escape # as `%23`.|
================================================
FILE: global.d.ts
================================================
declare type ResponseStream = {
setContentType( type: string ): void;
write( stream: string | Buffer ): void;
end(): void;
metadata?: any;
};
declare type StreamifyHandler = ( event: APIGatewayProxyEventV2, response: ResponseStream ) => Promise;
declare var awslambda: {
streamifyResponse: (
handler: StreamifyHandler
) => ( event: APIGatewayProxyEventV2, context: ResponseStream ) => void,
HttpResponseStream: {
from( response: ResponseStream, metadata: {
headers?: Record,
statusCode: number,
cookies?: string[],
} ): ResponseStream
}
};
================================================
FILE: jest.config.js
================================================
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['/tests/**/test-*.ts'],
setupFiles: ['/tests/setup.ts'],
extensionsToTreatAsEsm: ['.ts'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
tsconfig: './tsconfig.test.json',
},
],
},
moduleNameMapper: {
"(.+)\\.js": "$1"
},
};
================================================
FILE: package.json
================================================
{
"name": "tachyon",
"version": "3.0.0",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/humanmade/tachyon.git"
},
"description": "Human Made Tachyon in node",
"main": "dist/lambda-handler.js",
"config": {
"bucket": "",
"path": "",
"region": "us-east-1",
"function-name": ""
},
"scripts": {
"build": "sam build -u",
"start": "tsc -w & nodemon --watch dist/lambda-handler.js --exec 'node dist/lambda-handler.js'",
"test": "AWS_PROFILE=hmn-test S3_BUCKET=testtachyonbucket S3_REGION=us-east-1 jest",
"build-zip": "rm lambda.zip ; cd .aws-sam/build/Tachyon && zip -r --exclude='node_modules/animated-gif-detector/test/*' ../../../lambda.zip ./node_modules/ package.json ./dist/",
"upload-zip": "aws s3 --region=$npm_config_region cp ./lambda.zip s3://$npm_config_bucket/$npm_config_path",
"update-function-code": "aws lambda update-function-code --region $npm_config_region --function-name $npm_config_function_name --zip-file fileb://`pwd`/lambda.zip",
"lint": "npx eslint ./*.ts ./**/*.ts"
},
"author": "Joe Hoyle",
"license": "ISC",
"dependencies": {
"@aws-sdk/client-s3": "^3.712.0",
"eslint-config-react-app": "^7.0.1",
"sharp": "^0.34.5",
"smartcrop-sharp": "^2.0.6"
},
"devDependencies": {
"@aws-sdk/s3-request-presigner": "^3.709.0",
"@humanmade/eslint-config": "^1.1.3",
"@types/aws-lambda": "^8.10.161",
"@types/cli-table": "^0.3.4",
"@types/jest": "^30.0.0",
"@types/node": "^25.6.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.3.0",
"aws-lambda": "^1.0.7",
"cli-table": "^0.3.1",
"eslint": "^8.46.0",
"eslint-config": "^0.3.0",
"eslint-plugin-flowtype": "^8.0.3",
"eslint-plugin-jsdoc": "^54.5.0",
"filesize": "^11.0.16",
"jest": "^30.3.0",
"lambda-stream": "^0.6.0",
"nodemon": "^3.1.14",
"ts-jest": "^29.4.9",
"typescript": "^6.0.3"
},
"eslintConfig": {
"extends": "@humanmade/eslint-config",
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": [
"./tsconfig.json"
]
},
"overrides": [
{
"files": [
"*.ts"
],
"rules": {
"jsdoc/require-param-description": "off",
"jsdoc/require-returns": "off",
"jsdoc/require-param-type": "off",
"jsdoc/require-param": "off",
"no-undef": "off",
"import/named": "off"
}
},
{
"files": [
"*.d.ts"
],
"rules": {
"no-unused-vars": "off",
"no-var": "off"
}
}
]
},
"overrides": {
"smartcrop-sharp": {
"sharp": "$sharp"
},
"@humanmade/eslint-config": {
"eslint": "$eslint",
"eslint-plugin-flowtype": "$eslint-plugin-flowtype",
"eslint-plugin-jsdoc": "$eslint-plugin-jsdoc",
"eslint-config-react-app": "$eslint-config-react-app"
}
},
"engines": {
"node": "18"
}
}
================================================
FILE: src/lambda-handler.ts
================================================
import { Args, getS3File, resizeBuffer, Config } from './lib.js';
/**
*
* @param event
* @param response
*/
const streamify_handler: StreamifyHandler = async ( event, response ) => {
const region = process.env.S3_REGION!;
const bucket = process.env.S3_BUCKET!;
const config: Config = {
region: region,
bucket: bucket,
};
if ( process.env.S3_ENDPOINT ) {
config.endpoint = process.env.S3_ENDPOINT;
}
if ( process.env.S3_FORCE_PATH_STYLE ) {
config.forcePathStyle = true;
}
const key = decodeURIComponent( event.rawPath.substring( 1 ) ).replace( '/tachyon/', '/' );
const args = ( event.queryStringParameters || {} ) as unknown as Args & {
'X-Amz-Expires'?: string;
'presign'?: string,
key: string;
referer?: string;
};
args.key = key;
if ( typeof args.webp === 'undefined' ) {
args.webp = !! ( event.headers && Object.keys( event.headers ).find( key => key.toLowerCase() === 'x-webp' ) );
}
const refererHeaderKey = Object.keys(event.headers || {}).find(h => h.toLowerCase() === 'referer');
if (refererHeaderKey) {
args.referer = event.headers[refererHeaderKey];
}
// If there is a presign param, we need to decode it and add it to the args. This is to provide a secondary way to pass pre-sign params,
// as using them in a Lambda function URL invocation will trigger a Lambda error.
if ( args.presign ) {
const presignArgs = new URLSearchParams( args.presign );
for ( const [ key, value ] of presignArgs.entries() ) {
args[ key as keyof Args ] = value;
}
delete args.presign;
}
let s3_response;
try {
s3_response = await getS3File( config, key, args );
} catch ( e: any ) {
// An AccessDenied error means the file is either protected, or doesn't exist.
if ( e.Code === 'AccessDenied' ) {
const metadata = {
statusCode: 404,
headers: {
'Content-Type': 'text/html',
},
};
response = awslambda.HttpResponseStream.from( response, metadata );
response.write( 'File not found.' );
response.end();
return;
}
throw e;
}
if ( ! s3_response.Body ) {
throw new Error( 'No body in file.' );
}
let buffer = Buffer.from( await s3_response.Body.transformToByteArray() );
let { info, data } = await resizeBuffer( buffer, args );
// If this is a signed URL, we need to calculate the max-age of the image.
let maxAge = 31536000;
if ( args['X-Amz-Expires'] ) {
// Date format of X-Amz-Date is YYYYMMDDTHHMMSSZ, which is not parsable by Date.
const dateString = args['X-Amz-Date']!.replace(
/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/,
'$1-$2-$3T$4:$5:$6Z'
);
const date = new Date( dateString );
// Calculate when the signed URL will expire, as we'll set the max-age
// cache control to this value.
const expires = date.getTime() / 1000 + Number( args['X-Amz-Expires'] );
// Mage age is the date the URL expires minus the current time.
maxAge = Math.round( expires - new Date().getTime() / 1000 ); // eslint-disable-line no-unused-vars
}
// Somewhat undocumented API on how to pass headers to a stream response.
response = awslambda.HttpResponseStream.from( response, {
statusCode: 200,
headers: {
'Cache-Control': `max-age=${ maxAge }`,
'Last-Modified': ( new Date() ).toUTCString(),
'Content-Type': 'image/' + info.format,
},
} );
response.write( data );
response.end();
};
if ( typeof awslambda === 'undefined' ) {
global.awslambda = {
/**
*
* @param handler
*/
streamifyResponse( handler: StreamifyHandler ): StreamifyHandler {
return handler;
},
HttpResponseStream: {
/**
* @param response The response stream object
* @param metadata The metadata object
* @param metadata.headers
*/
from( response: ResponseStream, metadata: {
headers?: Record,
} ): ResponseStream {
response.metadata = metadata;
return response;
},
},
};
}
export const handler = awslambda.streamifyResponse( streamify_handler );
================================================
FILE: src/lib.ts
================================================
import { S3Client, S3ClientConfig, GetObjectCommand, GetObjectCommandOutput } from '@aws-sdk/client-s3';
import sharp from 'sharp';
import smartcrop from 'smartcrop-sharp';
export interface Args {
// Optional args.
background?: string;
crop?: string | string[];
crop_strategy?: string;
fit?: string;
gravity?: string;
h?: string;
lb?: string;
resize?: string | number[];
quality?: string | number;
w?: string;
webp?: string | boolean;
zoom?: string;
'X-Amz-Algorithm'?: string;
'X-Amz-Content-Sha256'?: string;
'X-Amz-Credential'?: string;
'X-Amz-SignedHeaders'?: string;
'X-Amz-Expires'?: string;
'X-Amz-Signature'?: string;
'X-Amz-Date'?: string;
'X-Amz-Security-Token'?: string;
referer?: string;
}
export type Config = S3ClientConfig & { bucket: string };
/**
* Get the dimensions from a string or array of strings.
*/
function getDimArray( dims: string | number[], zoom: number = 1 ): ( number | null )[] {
let dimArr = typeof dims === 'string' ? dims.split( ',' ) : dims;
return dimArr.map( v => Math.round( Number( v ) * zoom ) || null );
}
/**
* Clamp a value between a min and max.
*/
function clamp( val: number | string, min: number, max: number ): number {
return Math.min( Math.max( Number( val ), min ), max );
}
/**
* Get a file from S3/
*/
export async function getS3File( config: Config, key: string, args: Args ): Promise {
const s3 = new S3Client( {
...config,
signer: {
/**
*
* @param request
*/
sign: async request => {
if ( ! args['X-Amz-Algorithm'] ) {
// Add referer to the request headers on non-presigned URLs
// Presigned URLs works without the referer header
if (args.referer) {
request.headers = request.headers || {};
request.headers['referer'] = args.referer;
}
return request;
}
const presignedParamNames = [
'X-Amz-Algorithm',
'X-Amz-Content-Sha256',
'X-Amz-Credential',
'X-Amz-SignedHeaders',
'X-Amz-Expires',
'X-Amz-Signature',
'X-Amz-Date',
'X-Amz-Security-Token',
] as const;
const presignedParams: { [K in ( typeof presignedParamNames )[number]]?: string } = {}; // eslint-disable-line no-unused-vars
const signedHeaders = ( args['X-Amz-SignedHeaders']?.split( ';' ) || [] ).map( header => header.toLowerCase().trim() );
for ( const paramName of presignedParamNames ) {
if ( args[paramName] ) {
presignedParams[paramName] = args[paramName];
}
}
const headers: typeof request.headers = {};
for ( const header in request.headers ) {
if ( signedHeaders.includes( header.toLowerCase() ) ) {
headers[header] = request.headers[header];
}
}
request.query = presignedParams;
request.headers = headers;
return request;
},
},
} );
const command = new GetObjectCommand( {
Bucket: config.bucket,
Key: key,
} );
return s3.send( command );
}
/**
* Apply a logarithmic compression to a value based on a zoom level.
* return a default compression value based on a logarithmic scale
* defaultValue = 100, zoom = 2; = 65
* defaultValue = 80, zoom = 2; = 50
* defaultValue = 100, zoom = 1.5; = 86
* defaultValue = 80, zoom = 1.5; = 68
*/
function applyZoomCompression( defaultValue: number, zoom: number ): number {
const value = Math.round( defaultValue - ( Math.log( zoom ) / Math.log( defaultValue / zoom ) ) * ( defaultValue * zoom ) );
const min = Math.round( defaultValue / zoom );
return clamp( value, min, defaultValue );
}
type ResizeBufferResult = {
data: Buffer;
info: sharp.OutputInfo & {
errors: string;
}
};
/**
* Resize a buffer of an image.
*/
export async function resizeBuffer(
buffer: Buffer | Uint8Array,
args: Args
): Promise {
const image = sharp( buffer, {
failOnError: false,
animated: true,
} ).withMetadata();
// check we can get valid metadata
const metadata = await image.metadata();
// auto rotate based on orientation EXIF data.
image.rotate();
// validate args, remove from the object if not valid
const errors: string[] = [];
if ( args.w ) {
if ( ! /^[1-9]\d*$/.test( args.w ) ) {
delete args.w;
errors.push( 'w arg is not valid' );
}
}
if ( args.h ) {
if ( ! /^[1-9]\d*$/.test( args.h ) ) {
delete args.h;
errors.push( 'h arg is not valid' );
}
}
if ( args.quality ) {
if (
! /^[0-9]{1,3}$/.test( args.quality as string ) ||
( args.quality as number ) < 0 ||
( args.quality as number ) > 100
) {
delete args.quality;
errors.push( 'quality arg is not valid' );
}
}
if ( args.resize ) {
if ( ! /^\d+(px)?,\d+(px)?$/.test( args.resize as string ) ) {
delete args.resize;
errors.push( 'resize arg is not valid' );
}
}
if ( args.crop_strategy ) {
if ( ! /^(smart|entropy|attention)$/.test( args.crop_strategy ) ) {
delete args.crop_strategy;
errors.push( 'crop_strategy arg is not valid' );
}
}
if ( args.gravity ) {
if ( ! /^(north|northeast|east|southeast|south|southwest|west|northwest|center)$/.test( args.gravity ) ) {
delete args.gravity;
errors.push( 'gravity arg is not valid' );
}
}
if ( args.fit ) {
if ( ! /^\d+(px)?,\d+(px)?$/.test( args.fit as string ) ) {
delete args.fit;
errors.push( 'fit arg is not valid' );
}
}
if ( args.crop ) {
if ( ! /^\d+(px)?,\d+(px)?,\d+(px)?,\d+(px)?$/.test( args.crop as string ) ) {
delete args.crop;
errors.push( 'crop arg is not valid' );
}
}
if ( args.zoom ) {
if ( ! /^\d+(\.\d+)?$/.test( args.zoom ) ) {
delete args.zoom;
errors.push( 'zoom arg is not valid' );
}
}
if ( args.webp ) {
if ( ! /^0|1|true|false$/.test( args.webp as string ) ) {
delete args.webp;
errors.push( 'webp arg is not valid' );
}
}
if ( args.lb ) {
if ( ! /^\d+(px)?,\d+(px)?$/.test( args.lb ) ) {
delete args.lb;
errors.push( 'lb arg is not valid' );
}
}
if ( args.background ) {
if ( ! /^#[a-f0-9]{3}[a-f0-9]{3}?$/.test( args.background ) ) {
delete args.background;
errors.push( 'background arg is not valid' );
}
}
// crop (assumes crop data from original)
if ( args.crop ) {
const cropValuesString = typeof args.crop === 'string' ? args.crop.split( ',' ) : args.crop;
// convert percentages to px values
const cropValues = cropValuesString.map( function ( value, index ) {
if ( value.indexOf( 'px' ) > -1 ) {
return Number( value.substring( 0, value.length - 2 ) );
} else {
return Number(
Number( ( metadata[index % 2 ? 'height' : 'width'] as number ) * ( Number( value ) / 100 ) ).toFixed( 0 )
);
}
} );
image.extract( {
left: cropValues[0],
top: cropValues[1],
width: cropValues[2],
height: cropValues[3],
} );
}
// get zoom value
const zoom = parseFloat( args.zoom || '1' ) || 1;
// resize
if ( args.resize ) {
// apply smart crop if available
if ( args.crop_strategy === 'smart' && ! args.crop ) {
const cropResize = getDimArray( args.resize );
const rotatedImage = await image.toBuffer();
const result = await smartcrop.crop( rotatedImage, {
width: cropResize[0] as number,
height: cropResize[1] as number,
} );
if ( result && result.topCrop ) {
image.extract( {
left: result.topCrop.x,
top: result.topCrop.y,
width: result.topCrop.width,
height: result.topCrop.height,
} );
}
}
// apply the resize
args.resize = getDimArray( args.resize, zoom ) as number[];
image.resize( {
width: args.resize[0],
height: args.resize[1],
withoutEnlargement: true,
position: ( args.crop_strategy !== 'smart' && args.crop_strategy ) || args.gravity || 'centre',
} );
} else if ( args.fit ) {
const fit = getDimArray( args.fit, zoom ) as number[];
image.resize( {
width: fit[0],
height: fit[1],
fit: 'inside',
withoutEnlargement: true,
} );
} else if ( args.lb ) {
const lb = getDimArray( args.lb, zoom ) as number[];
image.resize( {
width: lb[0],
height: lb[1],
fit: 'contain',
// default to a black background to replicate Photon API behavior
// when no background colour specified
background: args.background || 'black',
withoutEnlargement: true,
} );
} else if ( args.w || args.h ) {
image.resize( {
width: Number( args.w ) * zoom || undefined,
height: Number( args.h ) * zoom || undefined,
fit: args.crop ? 'cover' : 'inside',
withoutEnlargement: true,
} );
}
// set default quality slightly higher than sharp's default
if ( ! args.quality ) {
args.quality = applyZoomCompression( 82, zoom );
}
// allow override of compression quality
if ( args.webp ) {
image.webp( {
quality: Math.round( clamp( args.quality, 0, 100 ) ),
} );
} else if ( metadata.format === 'jpeg' ) {
image.jpeg( {
quality: Math.round( clamp( args.quality, 0, 100 ) ),
} );
} else if ( metadata.format === 'png' ) {
// Compress the PNG.
image.png( {
palette: true,
} );
}
// send image
return new Promise( ( resolve, reject ) => {
image.toBuffer( async ( err, data, info ) => {
if ( err ) {
reject( err );
}
// add invalid args
resolve( {
data,
info: {
...info,
errors: errors.join( ';' ),
},
} );
} );
} );
}
================================================
FILE: src/server.ts
================================================
import { createServer, IncomingMessage, ServerResponse } from 'http';
import { handler } from './lambda-handler.js';
// Define the server
const server = createServer( async ( req: IncomingMessage, res: ServerResponse ) => {
// Constructing API Gateway event
const url = new URL( req.url!, `http://${req.headers.host}` );
const apiGatewayEvent = {
version: '2.0',
routeKey: req.url!,
rawPath: url.pathname,
rawQueryString: url.searchParams.toString(),
headers: req.headers,
requestContext: {
accountId: '123456789012',
stage: 'default',
http: {
method: req.method!,
path: req.url!,
protocol: 'HTTP/1.1',
sourceIp: req.socket.remoteAddress!,
userAgent: req.headers['user-agent']!,
},
requestId: 'c6af9ac6-7b61-11e6-9a41-93e8deadbeef',
routeKey: req.url!,
},
queryStringParameters: Array.from( url.searchParams ).reduce(
( acc, [ key, value ] ) => ( {
...acc,
[key]: value,
} ),
{}
),
};
try {
await handler( apiGatewayEvent, {
/**
* Set the content type for the respone.
*/
setContentType( type: string ): void {
res.setHeader( 'Content-Type', type );
},
/**
* Write data to the response.
*/
write( stream: string | Buffer ): void {
res.write( stream );
},
/**
* End the response.
*/
end(): void {
res.end();
},
} );
} catch ( e ) {
res.write( JSON.stringify( e ) );
res.statusCode = 500;
res.end();
}
} );
// Start the server
const port = process.argv.slice( 2 )[0] || 8080;
server.listen( port, () => {
console.log( `Server running at http://localhost:${port}/` ); // eslint-disable-line no-console
} );
================================================
FILE: template.yaml
================================================
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
Tachyon:
Type: AWS::Serverless::Function
Properties:
CodeUri: ./
Handler: dist/lambda-handler.handler
Environment:
Variables:
S3_BUCKET: hmn-uploads
S3_REGION: us-east-1
Runtime: nodejs18.x
Architectures:
- x86_64
Events:
Api:
Type: Api
Properties:
Path: "/{proxy+}"
Method: get
Timeout: 60
FunctionUrlConfig:
AuthType: NONE
InvokeMode: RESPONSE_STREAM
================================================
FILE: tests/events/animated-gif.json
================================================
{
"version": "2.0",
"routeKey": "$default",
"rawPath": "/s3-uploads-unit-tests/tachyon/pen.gif",
"rawQueryString": "",
"headers": {
"sec-fetch-mode": "navigate",
"x-amzn-tls-version": "TLSv1.2",
"sec-fetch-site": "none",
"accept-language": "en-US,en;q=0.5",
"x-forwarded-proto": "https",
"x-forwarded-port": "443",
"x-forwarded-for": "212.59.69.208",
"sec-fetch-user": "?1",
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"x-amzn-tls-cipher-suite": "ECDHE-RSA-AES128-GCM-SHA256",
"x-amzn-trace-id": "Root=1-64c21fa5-2e18c6c333bff5bc70a1bd9d",
"host": "opphilvfiwbi2ouykkz57ccs6q0khsad.lambda-url.eu-central-1.on.aws",
"upgrade-insecure-requests": "1",
"accept-encoding": "gzip, deflate, br",
"sec-fetch-dest": "document",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/115.0"
},
"requestContext": {
"accountId": "anonymous",
"apiId": "opphilvfiwbi2ouykkz57ccs6q0khsad",
"domainName": "opphilvfiwbi2ouykkz57ccs6q0khsad.lambda-url.eu-central-1.on.aws",
"domainPrefix": "opphilvfiwbi2ouykkz57ccs6q0khsad",
"http": {
"method": "GET",
"path": "/s3-uploads-unit-tests/tachyon/pen.gif",
"protocol": "HTTP/1.1",
"sourceIp": "212.59.69.208",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/115.0"
},
"requestId": "c5c172a9-7a3c-4c6f-a66a-3422d9031e59",
"routeKey": "$default",
"stage": "$default",
"time": "27/Jul/2023:07:41:25 +0000",
"timeEpoch": 1690443685529
},
"isBase64Encoded": false
}
================================================
FILE: tests/events/original.json
================================================
{
"version": "2.0",
"routeKey": "$default",
"rawPath": "/s3-uploads-unit-tests/tachyon/canola.jpg",
"rawQueryString": "",
"headers": {
"sec-fetch-mode": "navigate",
"x-amzn-tls-version": "TLSv1.2",
"sec-fetch-site": "none",
"accept-language": "en-US,en;q=0.5",
"x-forwarded-proto": "https",
"x-forwarded-port": "443",
"x-forwarded-for": "212.59.69.208",
"sec-fetch-user": "?1",
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"x-amzn-tls-cipher-suite": "ECDHE-RSA-AES128-GCM-SHA256",
"x-amzn-trace-id": "Root=1-64c21fa5-2e18c6c333bff5bc70a1bd9d",
"host": "opphilvfiwbi2ouykkz57ccs6q0khsad.lambda-url.eu-central-1.on.aws",
"upgrade-insecure-requests": "1",
"accept-encoding": "gzip, deflate, br",
"sec-fetch-dest": "document",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/115.0"
},
"requestContext": {
"accountId": "anonymous",
"apiId": "opphilvfiwbi2ouykkz57ccs6q0khsad",
"domainName": "opphilvfiwbi2ouykkz57ccs6q0khsad.lambda-url.eu-central-1.on.aws",
"domainPrefix": "opphilvfiwbi2ouykkz57ccs6q0khsad",
"http": {
"method": "GET",
"path": "/s3-uploads-unit-tests/tachyon/canola.jpg",
"protocol": "HTTP/1.1",
"sourceIp": "212.59.69.208",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/115.0"
},
"requestId": "c5c172a9-7a3c-4c6f-a66a-3422d9031e59",
"routeKey": "$default",
"stage": "$default",
"time": "27/Jul/2023:07:41:25 +0000",
"timeEpoch": 1690443685529
},
"isBase64Encoded": false
}
================================================
FILE: tests/events/signed-url.json
================================================
{
"version": "2.0",
"routeKey": "$default",
"rawPath": "/s3-uploads-unit-tests/private.png",
"rawQueryString": "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAYM4GX6NWSKCAHDX4%2F20230727%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20230727T184113Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=656a3e562cfe284651fa47ff38261581713350effe7a830dc81d789996b4b808",
"headers": {
"sec-fetch-mode": "navigate",
"x-amzn-tls-version": "TLSv1.2",
"sec-fetch-site": "none",
"accept-language": "en-US,en;q=0.5",
"x-forwarded-proto": "https",
"x-forwarded-port": "443",
"x-forwarded-for": "212.59.69.208",
"sec-fetch-user": "?1",
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"x-amzn-tls-cipher-suite": "ECDHE-RSA-AES128-GCM-SHA256",
"x-amzn-trace-id": "Root=1-64c21fa5-2e18c6c333bff5bc70a1bd9d",
"host": "opphilvfiwbi2ouykkz57ccs6q0khsad.lambda-url.eu-central-1.on.aws",
"upgrade-insecure-requests": "1",
"accept-encoding": "gzip, deflate, br",
"sec-fetch-dest": "document",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/115.0"
},
"queryStringParameters": {
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
"X-Amz-Credential": "AKIAYM4GX6NWSKCAHDX4/20230727/us-east-1/s3/aws4_request",
"X-Amz-Date": "20230727T184113Z",
"X-Amz-Expires": "604800",
"X-Amz-SignedHeaders": "host",
"X-Amz-Signature": "656a3e562cfe284651fa47ff38261581713350effe7a830dc81d789996b4b808"
},
"requestContext": {
"accountId": "anonymous",
"apiId": "opphilvfiwbi2ouykkz57ccs6q0khsad",
"domainName": "opphilvfiwbi2ouykkz57ccs6q0khsad.lambda-url.eu-central-1.on.aws",
"domainPrefix": "opphilvfiwbi2ouykkz57ccs6q0khsad",
"http": {
"method": "GET",
"path": "/s3-uploads-unit-tests/private.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAYM4GX6NWSKCAHDX4%2F20230727%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20230727T184113Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=656a3e562cfe284651fa47ff38261581713350effe7a830dc81d789996b4b808",
"protocol": "HTTP/1.1",
"sourceIp": "212.59.69.208",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/115.0"
},
"requestId": "c5c172a9-7a3c-4c6f-a66a-3422d9031e59",
"routeKey": "$default",
"stage": "$default",
"time": "27/Jul/2023:07:41:25 +0000",
"timeEpoch": 1690443685529
},
"isBase64Encoded": false
}
================================================
FILE: tests/setup.ts
================================================
/**
* Jest setup file to mock the AWS Lambda runtime global `awslambda`.
* This must run before test modules are imported so that
* lambda-handler.ts can call awslambda.streamifyResponse() at module scope.
*/
global.awslambda = {
/**
*
* @param handler
*/
streamifyResponse( handler: StreamifyHandler ): StreamifyHandler {
return handler;
},
HttpResponseStream: {
/**
* @param stream The response stream.
* @param metadata The metadata for the response.
*/
from( stream: ResponseStream, metadata: any ): ResponseStream {
stream.metadata = metadata;
return stream;
},
},
};
================================================
FILE: tests/test-animated-files.ts
================================================
import fs from 'fs';
import { test, expect } from '@jest/globals';
import sharp from 'sharp';
import { resizeBuffer } from '../src/lib';
test.failing( 'Test animated png resize', async () => {
let file = fs.readFileSync( __dirname + '/images/animated.png', {} );
const result = await resizeBuffer( file, { w: '20' } );
expect( result.info.format ).toBe( 'png' );
expect( result.info.width ).toBe( 20 );
let image = sharp( file );
let metadata = await image.metadata();
expect( metadata.pages ).toBe( 20 );
} );
test( 'Test animated gif resize', async () => {
let file = fs.readFileSync( __dirname + '/images/animated.gif', {} );
const result = await resizeBuffer( file, { w: '20' } );
expect( result.info.format ).toBe( 'gif' );
expect( result.info.width ).toBe( 20 );
let image = sharp( file );
let metadata = await image.metadata();
expect( metadata.pages ).toBe( 48 );
} );
test( 'Test animated gif resize webp', async () => {
let file = fs.readFileSync( __dirname + '/images/animated.gif', {} );
const result = await resizeBuffer( file, {
w: '20',
webp: true,
} );
expect( result.info.format ).toBe( 'webp' );
expect( result.info.width ).toBe( 20 );
let image = sharp( file );
let metadata = await image.metadata();
expect( metadata.pages ).toBe( 48 );
} );
test( 'Test animated webp resize', async () => {
let file = fs.readFileSync( __dirname + '/images/animated.webp', {} );
const result = await resizeBuffer( file, {
w: '20',
} );
expect( result.info.format ).toBe( 'webp' );
expect( result.info.width ).toBe( 20 );
let image = sharp( file );
let metadata = await image.metadata();
expect( metadata.pages ).toBe( 12 );
} );
test( 'Test animated webp resize webp', async () => {
let file = fs.readFileSync( __dirname + '/images/animated.webp', {} );
const result = await resizeBuffer( file, {
w: '20',
webp: true,
} );
expect( result.info.format ).toBe( 'webp' );
expect( result.info.width ).toBe( 20 );
let image = sharp( file );
let metadata = await image.metadata();
expect( metadata.pages ).toBe( 12 );
} );
================================================
FILE: tests/test-filesize/fixtures.json
================================================
{
"Website.png-original.png": 40173,
"Website.png-small.png": 5850,
"Website.png-medium.png": 16594,
"Website.png-large.png": 40173,
"Website.png-webp.webp": 27218,
"animated.png-original.png": 4929,
"animated.png-small.png": 4929,
"animated.png-medium.png": 4929,
"animated.png-large.png": 4929,
"animated.png-webp.webp": 8762,
"animated.gif-original.gif": 370705,
"animated.gif-small.gif": 99089,
"animated.gif-medium.gif": 370705,
"animated.gif-large.gif": 370705,
"animated.gif-webp.webp": 208888,
"briefing-copywriting.jpg-original.jpeg": 122312,
"briefing-copywriting.jpg-small.jpeg": 10007,
"briefing-copywriting.jpg-medium.jpeg": 16484,
"briefing-copywriting.jpg-large.jpeg": 36253,
"briefing-copywriting.jpg-webp.webp": 22722,
"animated.webp-original.webp": 111636,
"animated.webp-small.webp": 26868,
"animated.webp-medium.webp": 76902,
"animated.webp-large.webp": 111636,
"animated.webp-webp.webp": 115972,
"hdr.jpg-original.jpeg": 148964,
"hdr.jpg-small.jpeg": 10642,
"hdr.jpg-medium.jpeg": 24341,
"hdr.jpg-large.jpeg": 87548,
"hdr.jpg-webp.webp": 82806,
"icons.png-original.png": 31735,
"icons.png-small.png": 6351,
"icons.png-medium.png": 13431,
"icons.png-large.png": 27323,
"icons.png-webp.webp": 30694,
"humans.png-original.png": 856689,
"humans.png-small.png": 16050,
"humans.png-medium.png": 63874,
"humans.png-large.png": 283843,
"humans.png-webp.webp": 142800
}
================================================
FILE: tests/test-filesize/test-filesize.ts
================================================
import fs from 'fs';
import { test, expect } from '@jest/globals';
import Table from 'cli-table';
import { filesize } from 'filesize';
import { Args, resizeBuffer } from '../../src/lib';
let images = fs.readdirSync( __dirname + '/../images' );
const args = process.argv.slice( 2 );
if ( args[0] && args[0].indexOf( '--' ) !== 0 ) {
images = images.filter( file => args[0] === file );
}
// Manually change to true when you are intentionally changing files.
const saveFixtured = false;
const table = new Table( {
head: [ 'Image', 'Original Size', 'Tachyon Size', '100px', '300px', '700px', '700px webp' ],
colWidths: [ 30, 15, 20, 15, 15, 15, 20 ],
} );
// Read in existing features for resizes, so we can detect if image resizing
// has lead to a change in file size from previous runs.
const oldFixtures = JSON.parse( fs.readFileSync( __dirname + '/fixtures.json' ).toString() );
const fixtures: { [key: string]: number } = {};
/**
*
*/
test( 'Test file sizes', async () => {
await Promise.all(
images.map( async imageName => {
const image = `${__dirname}/../images/${imageName}`;
const imageData = fs.readFileSync( image );
const sizes = {
original: {},
small: { w: 100 },
medium: { w: 300 },
large: { w: 700 },
webp: {
w: 700,
webp: true,
},
};
const promises = await Promise.all(
Object.entries( sizes ).map( async ( [ _size, args ] ) => {
return resizeBuffer( imageData, args as Args );
} )
);
// Zip them back into a size => image map.
const initial : { [key: string]: any } = {};
const resized = promises.reduce( ( images, image, index ) => {
images[ Object.keys( sizes )[index] ] = image;
return images;
}, initial );
// Save each one to the file system for viewing.
Object.entries( resized ).forEach( ( [ size, image ] ) => {
const imageKey = `${imageName}-${size}.${image.info.format}`;
fixtures[imageKey] = image.data.length;
fs.writeFile( `${__dirname}/output/${imageKey}`, image.data, () => {} );
} );
table.push( [
imageName,
filesize( imageData.length ),
filesize( resized.original.info.size ) +
' (' +
Math.floor( ( resized.original.info.size / imageData.length ) * 100 ) +
'%)',
filesize( resized.small.info.size ),
filesize( resized.medium.info.size ),
filesize( resized.large.info.size ),
filesize( resized.webp.info.size ) +
' (' +
Math.floor( ( resized.webp.info.size / resized.large.info.size ) * 100 ) +
'%)',
] );
} )
);
if ( saveFixtured ) {
fs.writeFileSync( __dirname + '/fixtures.json', JSON.stringify( fixtures, null, 4 ) );
}
console.log( table.toString() );
for ( const key in fixtures ) {
if ( ! oldFixtures[key] ) {
continue;
}
// Make sure the image size is within 1% of the old image size. This is because
// file resizing sizes etc across systems and architectures is not 100%
// deterministic. See https://github.com/lovell/sharp/issues/3783
let increasedPercent = 100 - Math.round( oldFixtures[key] / fixtures[key] * 100 );
let increasedSize = fixtures[key] - oldFixtures[key];
if ( fixtures[key] !== oldFixtures[key] ) {
const diff = Math.abs( 100 - ( oldFixtures[key] / fixtures[key] * 100 ) );
console.log(
`${key} is different than image in fixtures by (${
filesize( oldFixtures[key] - fixtures[key] )
}, ${diff}%.). New ${ filesize( fixtures[key] ) }, old ${ filesize( oldFixtures[key] ) } }`
);
}
// If the file has changed by more than 5kb, then we expect it to be within 3% of the old size.
if ( increasedSize > 1024 * 5 ) {
expect( increasedPercent ).toBeLessThanOrEqual( 3 );
}
}
} );
================================================
FILE: tests/test-lambda.ts
================================================
import { test, expect } from '@jest/globals';
import { handler } from '../src/lambda-handler';
import animatedGifLambdaEvent from './events/animated-gif.json';
process.env.S3_REGION = 'us-east-1';
process.env.S3_BUCKET = 'hmn-uploads';
test( 'Test content type headers', async () => {
const testResponseStream = new TestResponseStream();
await handler( animatedGifLambdaEvent, testResponseStream );
expect( testResponseStream.contentType ).toBe( 'image/gif' );
} );
test( 'Test image not found', async () => {
const testResponseStream = new TestResponseStream();
animatedGifLambdaEvent.rawPath = '/tachyon/does-not-exist.gif';
await handler( animatedGifLambdaEvent, testResponseStream );
expect( testResponseStream.metadata.statusCode ).toBe( 404 );
expect( testResponseStream.contentType ).toBe( 'text/html' );
} );
/**
* A test response stream.
*/
class TestResponseStream {
contentType: string | undefined;
body: string | Buffer | undefined;
headers: { [key: string]: string } = {};
metadata: any;
setContentType( type: string ): void {
this.contentType = type;
}
write( stream: string | Buffer ): void {
if ( typeof this.body === 'string' ) {
this.body += stream;
} else if ( this.body instanceof Buffer ) {
this.body = this.body.toString().concat( stream.toString() );
} else {
this.body = stream;
}
}
end(): void {
if ( this.metadata.headers['Content-Type'] ) {
this.contentType = this.metadata.headers['Content-Type'];
}
}
}
================================================
FILE: tests/test-private-upload.ts
================================================
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { test, expect } from '@jest/globals';
import { MiddlewareType } from '@smithy/types';
import { handler } from '../src/lambda-handler';
import { Args } from '../src/lib';
/**
* Presign a URL for a given key.
* @param key
* @returns {Promise} The presigned params
*/
async function getPresignedUrlParams( key: string ) : Promise {
const client = new S3Client( {
region: process.env.S3_REGION,
} );
const command = new GetObjectCommand( {
Bucket: process.env.S3_BUCKET,
Key: key,
} );
/**
* Middleware to remove the x-id query string param form the GetObject call.
*/
const middleware: MiddlewareType = ( next: any, context: any ) => async ( args: any ) => {
const { request } = args;
delete request.query['x-id'];
return next( args );
};
client.middlewareStack.addRelativeTo( middleware, {
name: 'tests',
relation: 'before',
toMiddleware: 'awsAuthMiddleware',
override: true,
} );
const presignedUrl = new URL( await getSignedUrl( client, command, {
expiresIn: 60,
} ) );
const queryStringParameters: Args = Object.fromEntries( presignedUrl.searchParams.entries() );
return queryStringParameters;
}
test( 'Test get private upload', async () => {
const event = {
'version': '2.0',
'routeKey': '$default',
'rawPath': '/private.png',
'headers': {
},
queryStringParameters: await getPresignedUrlParams( 'private.png' ),
'isBase64Encoded': false,
};
let contentType;
await handler( event, {
/**
* Set the content type for the respone.
*/
setContentType( type: string ): void {
contentType = type;
},
/**
* Write data to the response.
*/
write( stream: string | Buffer ): void {
},
/**
* End the response.
*/
end(): void {
if ( this.metadata.headers['Cache-Control'] ) {
contentType = this.metadata.headers['Content-Type'];
}
},
} );
expect( contentType ).toBe( 'image/png' );
} );
test( 'Test get private upload with presign params', async () => {
const presignParams = await getPresignedUrlParams( 'private.png' ) as Record;
// The below credentials are temporary and will need regenerating before the test is run.
// Run aws s3 presign --expires 3600 s3://hmn-uploads/private.png
const event = {
'version': '2.0',
'routeKey': '$default',
'rawPath': '/private.png',
'headers': {
},
'queryStringParameters': {
presign: new URLSearchParams( presignParams ).toString(),
},
'isBase64Encoded': false,
};
let contentType;
await handler( event, {
/**
* Set the content type for the respone.
*/
setContentType( type: string ): void {
contentType = type;
},
/**
* Write data to the response.
*/
write( stream: string | Buffer ): void {
},
/**
* End the response.
*/
end(): void {
if ( this.metadata.headers['Cache-Control'] ) {
contentType = this.metadata.headers['Content-Type'];
}
},
} );
expect( contentType ).toBe( 'image/png' );
} );
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "es2020",
"strict": true,
"preserveConstEnums": true,
"sourceMap": false,
"module": "Node16",
"moduleResolution": "node16",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"types": ["node"],
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "./src"
},
"exclude": ["node_modules"],
"include": ["./src/**/*.ts", "./*.d.ts"],
}
================================================
FILE: tsconfig.test.json
================================================
{
"compilerOptions": {
"target": "es2020",
"strict": true,
"preserveConstEnums": true,
"noEmit": false,
"sourceMap": true,
"module": "commonjs",
"moduleResolution": "node10",
"ignoreDeprecations": "6.0",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"outDir": "./dist",
"types": ["node"],
"resolveJsonModule": true,
"rootDir": "."
},
"exclude": ["node_modules", "**/*.test.ts"],
"include": ["./src/**/*.ts", "./*.d.ts", "./tests/**/*.ts"],
}