diff --git a/README.md b/README.md index dc7d500..dc0304e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,48 @@ ![Tests][tests-badge] -TODO +Generates the social/OG preview images for solutions and profiles. + +A request for e.g. `/tracks/ruby/exercises/bob/solutions/ihid-1720000000.jpg` +arrives via CloudFront at this Lambda's Function URL. The Lambda runs headless +Chrome against the corresponding page on exercism.org +(`/images/solutions/ruby/bob/ihid`), screenshots the `#image-content` element, +and returns it as a JPEG. + +## Caching + +Generated images are written through to S3, so any given URL is only ever +rendered once. This matters because rendering costs a few seconds of headless +Chrome at 2GB, while serving a stored copy costs an S3 GET — around 300x less. + +CDN edge caches alone don't give us that guarantee: they're per-PoP, they evict +the long tail (most images are fetched only a handful of times ever), and a +flood of requests for *distinct* URLs misses them entirely. Writing through to +S3 makes cost a function of how many images exist rather than how many times +they're requested. + +URLs ending in `-${timestamp}.jpg` address a version that will never change, so +their stored copy is used indefinitely. Legacy URLs without a timestamp address +mutable content, so a stored copy is only reused for 24 hours — matching the +`Cache-Control` we hand back to the CDN. + +If S3 is unreachable or the Lambda lacks permission, both reads and writes fail +soft and the image is generated as normal. + +## Configuration + +| Variable | Default | Purpose | +| --- | --- | --- | +| `IMAGE_BUCKET` | `exercism-v3-assets` | Bucket holding generated images | +| `IMAGE_KEY_PREFIX` | `generated-images` | Key prefix within that bucket | +| `NAVIGATION_TIMEOUT_MS` | `6000` | Page navigation timeout | +| `SELECTOR_TIMEOUT_MS` | `6000` | Timeout waiting for the content selector | + +The two timeouts must stay comfortably below the Lambda's own timeout (20s). +puppeteer defaults both to 30s, which is *longer*, meaning a hung render burned +the full 20s at 2GB instead of failing fast. + +The Lambda's execution role needs `s3:GetObject` and `s3:PutObject` on +`arn:aws:s3:::${IMAGE_BUCKET}/${IMAGE_KEY_PREFIX}/*`. [tests-badge]: https://github.com/exercism/image-generator/workflows/Test/badge.svg diff --git a/index.js b/index.js index 3654891..4d7c5b0 100644 --- a/index.js +++ b/index.js @@ -1,17 +1,41 @@ const fs = require("fs"); +const crypto = require("crypto"); const puppeteer = require("puppeteer-core"); const chromium = require("@sparticuz/chromium"); +const { + S3Client, + GetObjectCommand, + PutObjectCommand, +} = require("@aws-sdk/client-s3"); const imagePath = "/tmp/screenshot.jpg"; const baseUrl = "https://exercism.org"; +// Generating an image costs a few seconds of headless Chrome at 2GB, so we only +// ever want to pay for it once per distinct URL. CDN edge caches can't give us +// that on their own: they're per-PoP, they evict the long tail (most images are +// fetched a handful of times ever), and a flood of distinct URLs misses them +// entirely. Writing through to S3 makes the cost a function of how many images +// exist rather than how many times they're requested. +const bucket = process.env.IMAGE_BUCKET || "exercism-v3-assets"; +const keyPrefix = process.env.IMAGE_KEY_PREFIX || "generated-images"; + +const s3 = new S3Client({}); + +// These must stay comfortably under the Lambda's 20s timeout. puppeteer +// defaults both to 30s, which is longer, so before this a render that hung +// burned the full 20s at 2GB rather than failing fast. +const navigationTimeout = parseInt(process.env.NAVIGATION_TIMEOUT_MS || "6000", 10); +const selectorTimeout = parseInt(process.env.SELECTOR_TIMEOUT_MS || "6000", 10); + +const legacyMaxAge = 86400; + const solutionRegex = /^\/tracks\/(?.+?)\/exercises\/(?.+?)\/solutions\/(?.+?)(?:-\d{10})?\.jpg$/; const profileRegex = /^\/profiles\/(?.+?)(?:-\d{10})?\.jpg$/; -const crypto = require("crypto"); - function rawPathToScreenshotData(rawPath) { - if ((solutionMatch = solutionRegex.exec(rawPath))) { + const solutionMatch = solutionRegex.exec(rawPath); + if (solutionMatch) { const { track_slug, exercise_slug, user_handle } = solutionMatch.groups; return { @@ -21,7 +45,8 @@ function rawPathToScreenshotData(rawPath) { }; } - if ((profileMatch = profileRegex.exec(rawPath))) { + const profileMatch = profileRegex.exec(rawPath); + if (profileMatch) { const { user_handle } = profileMatch.groups; return { @@ -34,28 +59,106 @@ function rawPathToScreenshotData(rawPath) { throw new Error(`Could not map raw path '${rawPath}' to image URL.`); } -exports.handler = async (event) => { +// URLs ending in -${timestamp}.jpg address a version that will never change, so +// they can be cached forever. Legacy URLs without one address mutable content. +function cacheMetadata(rawPath) { + const match = rawPath.match(/-(\d{10})\.\w+$/); + const isTimestamped = !!match; + + return { + isTimestamped, + cacheControl: isTimestamped + ? "public, max-age=31536000, immutable" + : `public, max-age=${legacyMaxAge}`, + lastModified: isTimestamped + ? new Date(parseInt(match[1], 10) * 1000).toUTCString() + : new Date().toUTCString(), + }; +} + +function s3Key(rawPath) { + return `${keyPrefix}${rawPath}`; +} + +function imageResponse(imageBuffer, { cacheControl, lastModified }) { + const etag = crypto.createHash("md5").update(imageBuffer).digest("hex"); + + return { + statusCode: 200, + body: imageBuffer.toString("base64"), + headers: { + "Content-Type": "image/jpg", + "Cache-Control": cacheControl, + "Last-Modified": lastModified, + "Etag": `W/"${etag}"`, + }, + isBase64Encoded: true, + }; +} + +// A cache problem should never stop us serving an image, so every failure here +// falls through to generating one. +async function fetchFromS3(key, { isTimestamped }) { try { - const { url, imageSelector, waitForSelector } = rawPathToScreenshotData( - event.rawPath + const object = await s3.send( + new GetObjectCommand({ Bucket: bucket, Key: key }) ); - const browser = await puppeteer.launch({ - executablePath: await chromium.executablePath(), - headless: chromium.headless, - ignoreHTTPSErrors: true, - defaultViewport: { ...chromium.defaultViewport, deviceScaleFactor: 2 }, - args: [ - ...chromium.args, - "--hide-scrollbars", - "--disable-web-security", - "--high-dpi-support=1", - ], - }); + // Legacy URLs point at content that can change, so a stored copy is only + // good for as long as we'd have let a CDN hold onto it. + if (!isTimestamped) { + const age = (Date.now() - object.LastModified.getTime()) / 1000; + if (age > legacyMaxAge) return null; + } + + return Buffer.from(await object.Body.transformToByteArray()); + } catch (err) { + if (err.name !== "NoSuchKey" && err.name !== "NotFound") { + console.error(`Failed reading ${key} from S3: ${err.message}`); + } + return null; + } +} + +async function writeToS3(key, imageBuffer, cacheControl) { + try { + await s3.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: imageBuffer, + ContentType: "image/jpg", + CacheControl: cacheControl, + }) + ); + } catch (err) { + console.error(`Failed writing ${key} to S3: ${err.message}`); + } +} + +async function generateImage({ url, imageSelector, waitForSelector }) { + const browser = await puppeteer.launch({ + executablePath: await chromium.executablePath(), + headless: chromium.headless, + ignoreHTTPSErrors: true, + defaultViewport: { ...chromium.defaultViewport, deviceScaleFactor: 2 }, + args: [ + ...chromium.args, + "--hide-scrollbars", + "--disable-web-security", + "--high-dpi-support=1", + ], + }); + + // Now that a render can fail rather than take the whole container down with + // it, the browser has to be closed on the way out or it leaks into the next + // warm invocation. + try { const page = await browser.newPage(); + page.setDefaultNavigationTimeout(navigationTimeout); await page.goto(url); - await page.waitForSelector(waitForSelector); + await page.waitForSelector(waitForSelector, { timeout: selectorTimeout }); const image = await page.$(imageSelector); await image.screenshot({ @@ -63,37 +166,29 @@ exports.handler = async (event) => { type: "jpeg", quality: 80, }); - await browser.close(); - const imageBuffer = fs.readFileSync(imagePath); - const etag = crypto.createHash("md5").update(imageBuffer).digest("hex"); + return fs.readFileSync(imagePath); + } finally { + await browser.close(); + } +} - // Try to extract the 10-digit timestamp from the URL - // if it ends with -${timestamp}.jpg - const match = event.rawPath.match(/-(\d{10})\.\w+$/); +exports.handler = async (event) => { + try { + const screenshotData = rawPathToScreenshotData(event.rawPath); + const metadata = cacheMetadata(event.rawPath); + const key = s3Key(event.rawPath); - const isTimestamped = !!match; - const cacheControl = isTimestamped - ? "public, max-age=31536000, immutable" // New URL containing a timestamp that will never change - : "public, max-age=86400"; // Legacy URL without a timestamp + const cached = await fetchFromS3(key, metadata); + if (cached) return imageResponse(cached, metadata); - // Use extracted timestamp if available, else use current time - const lastModified = isTimestamped - ? new Date(parseInt(match[1], 10) * 1000).toUTCString() - : new Date().toUTCString(); + const imageBuffer = await generateImage(screenshotData); + await writeToS3(key, imageBuffer, metadata.cacheControl); - return { - statusCode: 200, - body: fs.readFileSync(imagePath, { encoding: "base64" }), - headers: { - "Content-Type": "image/jpg", - "Cache-Control": cacheControl, - "Last-Modified": lastModified, - "Etag": `W/"${etag}"` - }, - isBase64Encoded: true, - }; + return imageResponse(imageBuffer, metadata); } catch (err) { + console.error(err); + return { statusCode: 500, body: err.message, diff --git a/package-lock.json b/package-lock.json index cfb2aa7..8f22d06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,319 @@ "version": "0.0.1", "license": "GNU AFFERO GPLV3", "dependencies": { + "@aws-sdk/client-s3": "^3.1108.0", "@sparticuz/chromium": "^121.0.0", "puppeteer-core": "^21.10.0" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.27.tgz", + "integrity": "sha512-insWOqKKNUrbN/dohEG7BJ0U5GkyqhjbMb/NHNaLUtq+7my2M8C4EnZZZoxMmXRqCC+P9dEr+KyJA2JGGzoKLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1108.0.tgz", + "integrity": "sha512-prdothEAFE1G8H0s0+zFGuNZdSj+Acg/siB1dFxPS181op9+hJ1GLr+b2anJch4B9a/xbWy7k8eG/enH3cNSjQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.27", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-node": "^3.972.79", + "@aws-sdk/middleware-sdk-s3": "^3.972.73", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", + "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@aws-sdk/xml-builder": "^3.972.38", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", + "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", + "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", + "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-login": "^3.972.75", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", + "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", + "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-ini": "^3.973.13", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", + "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", + "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/token-providers": "3.1108.0", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", + "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.73.tgz", + "integrity": "sha512-oy7sRA5HvHcAvkcKX6F8RI240jcOf3c8y/Gqjs9qemIibdKQqGBIi0uwa+47ZRYqGLpdEO28TQU4G73yUzo06Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", + "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", + "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", + "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", + "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", + "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@puppeteer/browsers": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.9.1.tgz", @@ -33,6 +342,87 @@ "node": ">=16.3.0" } }, + "node_modules/@smithy/core": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.32.0.tgz", + "integrity": "sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", + "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", + "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.10.0.tgz", + "integrity": "sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", + "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", + "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@sparticuz/chromium": { "version": "121.0.0", "resolved": "https://registry.npmjs.org/@sparticuz/chromium/-/chromium-121.0.0.tgz", @@ -144,6 +534,12 @@ "node": ">=10.0.0" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", diff --git a/package.json b/package.json index 168a348..159eb51 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "main": "index.js", "license": "GNU AFFERO GPLV3", "dependencies": { + "@aws-sdk/client-s3": "^3.1108.0", "@sparticuz/chromium": "^121.0.0", "puppeteer-core": "^21.10.0" }