Skip to content

Commit

Permalink
feat: convert from plugin to library
Browse files Browse the repository at this point in the history
  • Loading branch information
ascorbic committed Sep 24, 2021
1 parent 99a3416 commit d77f606
Show file tree
Hide file tree
Showing 12 changed files with 510 additions and 6,462 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@

## Usage

Add `netlify-plugin-ipx` as `devDependency`:
Add `@netlify/ipx` as `devDependency`:

```sh
# npm
npm i -D netlify-plugin-ipx
npm i -D @netlify/ipx

# yarn
yarn add --dev netlify-plugin-ipx
yarn add --dev @netlify/ipx
```

Create `netlify/functions/ipx.ts`:

```ts
import { createIPXHandler } from 'netlify-plugin-ipx/function'
import { createIPXHandler } from '@netlify/ipx'

export const handler = createIPXHandler({
domains: ['images.unsplash.com']
Expand Down
8 changes: 3 additions & 5 deletions example/netlify.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
[build]
publish = "public/"
publish = "public/"

# [[plugins]]
# package = "netlify-plugin-ipx"
# [plugins.inputs]
# domains = ['images.unsplash.com']
[functions]
external_node_modules = ["fsevents"]
2 changes: 1 addition & 1 deletion example/netlify/functions/ipx.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createIPXHandler } from 'netlify-plugin-ipx/function'
import { createIPXHandler } from '@netlify/ipx'

export const handler = createIPXHandler({
domains: ['images.unsplash.com']
Expand Down
2 changes: 1 addition & 1 deletion example/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"private": true,
"devDependencies": {
"netlify-plugin-ipx": "latest"
"@netlify/ipx": "link:.."
}
}
30 changes: 16 additions & 14 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
{
"name": "netlify-plugin-ipx",
"name": "@netlify/ipx",
"version": "0.0.1",
"description": "on-demand image optimazation for Netlify",
"repository": "nuxt-contrib/netlify-ipx",
"repository": "netlify/netlify-ipx",
"license": "MIT",
"exports": {
"./function": {
"import": "./dist/function.mjs",
"require": "./dist/function.cjs"
},
".": {
"import": "./dist/plugin.mjs",
"require": "./dist/plugin.cjs"
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"main": "./dist/plugin.cjs",
"main": "./dist/index.js",
"files": [
"dist",
"manifest.yml"
"dist"
],
"scripts": {
"build": "siroc build",
Expand All @@ -29,15 +24,22 @@
},
"dependencies": {
"@netlify/functions": "^0.7.2",
"etag": "^1.8.1",
"fs-extra": "^10.0.0",
"ipx": "^0.7.0",
"mkdirp": "^1.0.4"
"mkdirp": "^1.0.4",
"murmurhash": "^2.0.0",
"node-fetch": "^3.0.0",
"ufo": "^0.7.9",
"unstorage": "^0.2.8"
},
"devDependencies": {
"@netlify/ipx": "link:.",
"@nuxtjs/eslint-config-typescript": "^6.0.1",
"@types/etag": "^1.8.1",
"@types/fs-extra": "^9.0.12",
"eslint": "^7.29.0",
"jiti": "^1.10.1",
"netlify-cli": "^4.0.5",
"netlify-plugin-ipx": "link:.",
"siroc": "^0.11.1",
"standard-version": "^9.3.0"
}
Expand Down
8 changes: 8 additions & 0 deletions siroc.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"rollup": {
"externals": [
"fsevents"
]
}
}

37 changes: 0 additions & 37 deletions src/function.ts

This file was deleted.

119 changes: 119 additions & 0 deletions src/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { join } from 'path'
import { createWriteStream, ensureDir, existsSync, unlink } from 'fs-extra'
import fetch, { Headers } from 'node-fetch'
import { createStorage } from 'unstorage'
import fsDriver from 'unstorage/drivers/fs'
import murmurhash from 'murmurhash'
import etag from 'etag'
import type { HandlerResponse } from '@netlify/functions'

interface SourceMetadata {
etag?: string;
lastModified?: string;
}

const NOT_MODIFIED = 304
const GATEWAY_ERROR = 502

export interface SourceImageResult {
response?: HandlerResponse
cacheKey?: string;
responseEtag?: string;
}

export async function loadSourceImage ({ cacheDir, url, requestEtag, modifiers, isLocal }): Promise<SourceImageResult> {
const fileCache = join(cacheDir, 'cache')
const metadataCache = join(cacheDir, 'metadata')

await ensureDir(fileCache)
await ensureDir(metadataCache)

const metadataStore = createStorage({
driver: fsDriver({ base: metadataCache })
})
const cacheKey = String(murmurhash(url))
const inputCacheFile = join(fileCache, cacheKey)

const headers = new Headers()
let sourceMetadata: SourceMetadata | undefined
if (existsSync(inputCacheFile)) {
sourceMetadata = (await metadataStore.getItem(`source:${cacheKey}`)) as
| SourceMetadata
| undefined
if (sourceMetadata) {
// Ideally use etag
if (sourceMetadata.etag) {
headers.set('If-None-Match', sourceMetadata.etag)
} else if (sourceMetadata.lastModified) {
headers.set('If-Modified-Since', sourceMetadata.lastModified)
} else {
// If we have neither, the cachefile is useless
await unlink(inputCacheFile)
}
}
}

let response
try {
response = await fetch(url, {
headers
})
} catch (e) {
return {
response: {
statusCode: GATEWAY_ERROR,
body: `Error loading source image: ${e.message}`
}
}
}

const sourceEtag = response.headers.get('etag')
const sourceLastModified = response.headers.get('last-modified')
const metadata = {
etag: sourceEtag || sourceMetadata?.etag,
lastModified: sourceLastModified || sourceMetadata?.lastModified
}
await metadataStore.setItem(`source:${cacheKey}`, metadata)
// We try to contruct an etag without downloading or processing the image, but we need
// either an etag or a last-modified date for the source image to do so.
let responseEtag
if (metadata.etag || metadata.lastModified) {
responseEtag = etag(`${cacheKey}${metadata.etag || metadata.lastModified}${modifiers}`)
if (requestEtag && (requestEtag === responseEtag)) {
return {
response: {
statusCode: NOT_MODIFIED
}
}
}
}

if (response.status === NOT_MODIFIED) {
return { cacheKey, responseEtag }
}
if (!response.ok) {
return {
response: {
statusCode: isLocal ? response.status : GATEWAY_ERROR,
body: `Source image server responsed with ${response.status} ${response.statusText}`
}
}
}

if (!response.headers.get('content-type').startsWith('image/')) {
return {
response: {
statusCode: GATEWAY_ERROR,
body: 'Source is not an image'
}
}
}

const outfile = createWriteStream(inputCacheFile)
await new Promise((resolve, reject) => {
outfile.on('finish', resolve)
outfile.on('error', reject)
response.body.pipe(outfile)
})
return { cacheKey, responseEtag }
}
90 changes: 90 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { join } from 'path'
import { tmpdir } from 'os'
import { createIPX, handleRequest, IPXOptions } from 'ipx'
import { builder, Handler } from '@netlify/functions'
import { parseURL } from 'ufo'
import etag from 'etag'
import { loadSourceImage } from './http'

export function createIPXHandler ({
cacheDir = join(tmpdir(), 'ipx-cache'),
...opts
}: Partial<IPXOptions> & { cacheDir?: string } = {}) {
const ipx = createIPX({ ...opts, dir: join(cacheDir, 'cache') })

const handler: Handler = async (event, _context) => {
const host = event.headers.host
const protocol = event.headers['x-forwarded-proto'] || 'http'
let domains = opts.domains || []
const requestEtag = event.headers['if-none-match']
const url = event.path
.replace('/.netlify/functions/ipx', '')
.replace(/index\.htm$/, '')

const [modifiers = '_', ...segments] = url.substr(1).split('/')
let id = segments.join('/')

const isLocal = !id.startsWith('http')
if (isLocal) {
id = `${protocol}://${host}/${id}`
} else {
if (typeof domains === 'string') {
domains = (domains as string).split(',').map(s => s.trim())
}

const hosts = domains.map(domain => parseURL(domain, 'https://').host)

// Parse id as URL
const parsedUrl = parseURL(id, 'https://')

// Check host
if (!parsedUrl.host) {
return {
statusCode: 403,
body: 'Hostname is missing: ' + id
}
}
if (!hosts.find(host => parsedUrl.host === host)) {
return {
statusCode: 403,
body: 'Hostname is missing: ' + parsedUrl.host
}
}
}

const { response, cacheKey, responseEtag } = await loadSourceImage({
cacheDir,
url: id,
requestEtag,
modifiers,
isLocal
})

if (response) {
return response
}

const res = await handleRequest(
{
url: `/${modifiers}/${cacheKey}`,
headers: event.headers
},
ipx
)

const body =
typeof res.body === 'string' ? res.body : res.body.toString('base64')

res.headers.etag = responseEtag || etag(body)

return {
statusCode: res.statusCode,
message: res.statusMessage,
headers: res.headers,
isBase64Encoded: typeof res.body !== 'string',
body
}
}

return builder(handler)
}
19 changes: 0 additions & 19 deletions src/plugin.ts

This file was deleted.

2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
"moduleResolution": "Node",
"esModuleInterop": true
}
}
}
Loading

0 comments on commit d77f606

Please sign in to comment.