Skip to content

Commit a4bf30c

Browse files
authored
Support image optimization (#12)
1 parent 9c4211d commit a4bf30c

File tree

425 files changed

+57993
-9
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

425 files changed

+57993
-9
lines changed
+164
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import https from "node:https";
4+
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
5+
import { IncomingMessage, ServerResponse } from 'http'
6+
import { defaultConfig, NextConfigComplete } from 'next/dist/server/config-shared'
7+
import { imageOptimizer as nextImageOptimizer, ImageOptimizerCache } from 'next/dist/server/image-optimizer'
8+
import { NextUrlWithParsedQuery } from 'next/dist/server/request-meta'
9+
import { ImageConfigComplete } from 'next/dist/shared/lib/image-config'
10+
import { Readable } from 'stream'
11+
12+
const bucketName = process.env.BUCKET_NAME;
13+
const nextDir = path.join(__dirname, ".next");
14+
const { config } = loadConfig();
15+
console.log("Init config", {
16+
nextDir,
17+
bucketName,
18+
defaultConfig,
19+
imagesConfig: config.images,
20+
});
21+
22+
const pipeRes = (w, res) => {
23+
w.pipe(res)
24+
.once('close', () => {
25+
res.statusCode = 200
26+
res.end()
27+
})
28+
.once('error', (err) => {
29+
console.error('Failed to get image', { err })
30+
res.statusCode = 400
31+
res.end()
32+
})
33+
}
34+
35+
// Handle fetching of S3 object before optimization happens in nextjs.
36+
let downloadError = null;
37+
const downloader = async (req, res, url) => {
38+
if (!url) {
39+
throw new Error('URL is missing from request.')
40+
}
41+
42+
console.log("downloader url", url);
43+
44+
try {
45+
const urlLower = url.href.toLowerCase();
46+
if (urlLower.startsWith("http://") || urlLower.startsWith("https://")) {
47+
pipeRes(https.get(url), res)
48+
} else {
49+
// S3 expects keys without leading `/`
50+
const trimmedKey = url.href.startsWith('/') ? url.href.substring(1) : url.href
51+
52+
const client = new S3Client({})
53+
const response = await client.send(new GetObjectCommand({
54+
Bucket: bucketName,
55+
Key: trimmedKey,
56+
}));
57+
58+
pipeRes(response.Body, res);
59+
60+
if (response.ContentType) {
61+
res.setHeader('Content-Type', response.ContentType)
62+
}
63+
64+
if (response.CacheControl) {
65+
res.setHeader('Cache-Control', response.CacheControl)
66+
}
67+
}
68+
} catch(e) {
69+
console.error("Failed to download image", e)
70+
downloadError = e;
71+
throw e;
72+
}
73+
}
74+
75+
// Make header keys lowercase to ensure integrity.
76+
const normalizeHeaders = (headers) =>
77+
Object.entries(headers).reduce((acc, [key, value]) =>
78+
({ ...acc, [key.toLowerCase()]: value }),
79+
{}
80+
)
81+
82+
function loadConfig() {
83+
const requiredServerFilesPath = path.join(nextDir, 'required-server-files.json');
84+
const json = fs.readFileSync(requiredServerFilesPath, 'utf-8');
85+
return JSON.parse(json);
86+
}
87+
88+
const nextConfig = {
89+
...(defaultConfig),
90+
images: {
91+
...(defaultConfig.images),
92+
...config.images,
93+
},
94+
}
95+
96+
// We don't need serverless-http neither basePath configuration as endpoint works as single route API.
97+
// Images are handled via header and query param information.
98+
export async function handler(event) {
99+
console.log("handler event", event)
100+
101+
// Clear downloader error
102+
downloadError = null;
103+
104+
try {
105+
if (!bucketName) {
106+
throw new Error('Bucket name must be defined!')
107+
}
108+
109+
// Validate params
110+
// ie. checks if external image URL matches the `images.remotePatterns`
111+
const imageParams = ImageOptimizerCache.validateParams(
112+
{ headers: event.headers },
113+
event.queryStringParameters,
114+
nextConfig,
115+
false
116+
);
117+
118+
console.log("image params", imageParams);
119+
120+
if ('errorMessage' in imageParams) {
121+
throw new Error(imageParams.errorMessage)
122+
}
123+
124+
// Optimize image
125+
const optimizedResult = await nextImageOptimizer(
126+
{ headers: normalizeHeaders(event.headers) },
127+
{}, // res object is not necessary as it's not actually used.
128+
imageParams,
129+
nextConfig,
130+
false, // not in dev mode
131+
downloader,
132+
)
133+
134+
console.log("optimized result", optimizedResult);
135+
136+
return {
137+
statusCode: 200,
138+
body: optimizedResult.buffer.toString("base64"),
139+
isBase64Encoded: true,
140+
headers: {
141+
Vary: "Accept",
142+
"Cache-Control": `public,max-age=${optimizedResult.maxAge},immutable`,
143+
"Content-Type": optimizedResult.contentType,
144+
},
145+
}
146+
} catch(e) {
147+
console.error(e)
148+
return {
149+
statusCode: 500,
150+
headers: {
151+
Vary: 'Accept',
152+
// For failed images, allow client to retry after 1 minute.
153+
'Cache-Control': `public,max-age=60,immutable`,
154+
'Content-Type': 'application/json'
155+
},
156+
body: [
157+
`Response Error: ${e?.message || e?.toString() || e}`,
158+
...(downloadError
159+
? [`Download Error: ${downloadError?.message || downloadError?.toString() || downloadError}`]
160+
: []),
161+
].join("\n")
162+
}
163+
}
164+
}

cli/assets/server-adapter.js

+165
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import slsHttp from 'serverless-http'
4+
import NextServer from "next/dist/server/next-server.js";
5+
6+
const nextDir = path.join(__dirname, ".next");
7+
console.log({ nextDir });
8+
9+
function convertApigRequestToNext(event) {
10+
let host = event.headers["x-forwarded-host"] || event.headers.host;
11+
let search = event.rawQueryString.length ? `?${event.rawQueryString}` : "";
12+
let scheme = "https";
13+
let url = new URL(event.rawPath + search, `${scheme}://${host}`);
14+
let isFormData = event.headers["content-type"]?.includes(
15+
"multipart/form-data"
16+
);
17+
18+
// Build headers
19+
const headers = new Headers();
20+
for (let [header, value] of Object.entries(event.headers)) {
21+
if (value) {
22+
headers.append(header, value);
23+
}
24+
}
25+
26+
return new Request(url.href, {
27+
method: event.requestContext.http.method,
28+
headers,
29+
body:
30+
event.body && event.isBase64Encoded
31+
? isFormData
32+
? Buffer.from(event.body, "base64")
33+
: Buffer.from(event.body, "base64").toString()
34+
: event.body,
35+
});
36+
}
37+
38+
async function convertNextResponseToApig(response) {
39+
// Build cookies
40+
// note: AWS API Gateway will send back set-cookies outside of response headers.
41+
const cookies = [];
42+
for (let [key, values] of Object.entries(response.headers.raw())) {
43+
if (key.toLowerCase() === "set-cookie") {
44+
for (let value of values) {
45+
cookies.push(value);
46+
}
47+
}
48+
}
49+
50+
if (cookies.length) {
51+
response.headers.delete("Set-Cookie");
52+
}
53+
54+
return {
55+
statusCode: response.status,
56+
headers: Object.fromEntries(response.headers.entries()),
57+
cookies,
58+
body: await response.text(),
59+
};
60+
}
61+
62+
function loadConfig() {
63+
const requiredServerFilesPath = path.join(nextDir, 'required-server-files.json');
64+
const json = fs.readFileSync(requiredServerFilesPath, 'utf-8');
65+
const requiredServerFiles = JSON.parse(json);
66+
return {
67+
// hostname and port must be defined for proxying to work (middleware)
68+
//hostname: 'localhost',
69+
//port: Number(process.env.PORT) || 3000,
70+
// Next.js compression should be disabled because of a bug
71+
// in the bundled `compression` package. See:
72+
// https://github.com/vercel/next.js/issues/11669
73+
conf: { ...requiredServerFiles.config, compress: false },
74+
customServer: false,
75+
dev: false,
76+
dir: __dirname,
77+
minimalMode: true, // turning this on breaks middleware
78+
// "minimalMode" controls:
79+
// - Rewrites and redirects
80+
// - Headers
81+
// - Middleware
82+
// - SSG cache
83+
};
84+
}
85+
86+
const config = loadConfig();
87+
const requestHandler = new NextServer(config).getRequestHandler();
88+
89+
const server = slsHttp(
90+
async (req, res) => {
91+
await requestHandler(req, res).catch((e) => {
92+
// Log into Cloudwatch for easier debugging.
93+
console.error(`NextJS request failed due to:`)
94+
console.error(e)
95+
96+
res.setHeader('Content-Type', 'application/json')
97+
res.end(JSON.stringify(getErrMessage(e), null, 3))
98+
})
99+
},
100+
{
101+
// We have separate function for handling images. Assets are handled by S3.
102+
binary: true,
103+
provider: 'aws',
104+
basePath: process.env.NEXTJS_LAMBDA_BASE_PATH,
105+
},
106+
);
107+
108+
//export const handler = server;
109+
110+
export const handler = async (event) => {
111+
console.log(event)
112+
console.log(event.rawPath)
113+
114+
// WORKAROUND (AWS): pass middleware headers to server
115+
const middlewareRequestHeaders = JSON.parse(
116+
event.headers["x-op-middleware-request-headers"] || "{}"
117+
);
118+
event.headers = { ...event.headers, ...middlewareRequestHeaders };
119+
120+
const response = await server(event);
121+
122+
// Handle cache response headers not set for HTML pages
123+
const htmlPages = loadHtmlPages();
124+
if (htmlPages.includes(event.rawPath) && !response.headers["cache-control"]) {
125+
response.headers["cache-control"] = "public, max-age=0, s-maxage=31536000, must-revalidate";
126+
}
127+
128+
// WORKAROUND (AWS): pass middleware headers to server
129+
const middlewareResponseHeaders = JSON.parse(
130+
event.headers["x-op-middleware-response-headers"] || "{}"
131+
);
132+
response.headers = { ...response.headers, ...middlewareResponseHeaders };
133+
134+
console.log({ after: response });
135+
136+
return response;
137+
};
138+
139+
function loadHtmlPages() {
140+
const filePath = path.join(nextDir, "server", "pages-manifest.json");
141+
const json = fs.readFileSync(filePath, "utf-8");
142+
return Object.entries(JSON.parse(json))
143+
.filter(([_, value]) => value.endsWith(".html"))
144+
.map(([key]) => key);
145+
}
146+
147+
function loadPrerenderPages() {
148+
const filePath = path.join(nextDir, "prerender-manifest.json");
149+
const json = fs.readFileSync(filePath, "utf-8");
150+
return Object.keys(JSON.parse(json).routes);
151+
}
152+
153+
154+
//const createApigHandler = () => {
155+
// const config = loadConfig();
156+
// const requestHandler = new NextServer(config).getRequestHandler();
157+
//
158+
// return async (event) => {
159+
// const request = convertApigRequestToNext(event);
160+
// const response = await requestHandler(request);
161+
// return convertNextResponseToApig(response);
162+
// };
163+
//};
164+
//
165+
//export const handler = createApigHandler();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../prebuild-install/bin.js

cli/assets/sharp-node-modules/.bin/rc

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../rc/cli.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../semver/bin/semver.js

0 commit comments

Comments
 (0)