Update SocMed Image
This commit is contained in:
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
All notable changes to The Meme Protocol are documented here. Versions follow Semantic Versioning.
|
All notable changes to The Meme Protocol are documented here. Versions follow Semantic Versioning.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Added `HEAD` support for public pages, discovery documents, assets, meme media, and health checks so social crawlers can validate card resources without receiving false `404` responses.
|
||||||
|
- Replaced the 1.6 MB social-preview PNG with a fingerprinted, metadata-stripped 226 KB JPEG for faster and more reliable link-card fetching across social platforms.
|
||||||
|
|
||||||
## [1.1.0] - 2026-08-14
|
## [1.1.0] - 2026-08-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Current release: `1.1.0`.
|
|||||||
|
|
||||||
The browser feed supports one-click image copy, downloads, shareable meme links, full-image viewing with live view/download counts, a fixed source footer, a two-build Homo Reseticus pet download chooser, and shareable lore at `/lore`.
|
The browser feed supports one-click image copy, downloads, shareable meme links, full-image viewing with live view/download counts, a fixed source footer, a two-build Homo Reseticus pet download chooser, and shareable lore at `/lore`.
|
||||||
|
|
||||||
Homepage and lore social shares use the versioned `1200x630` asset at `/assets/social-preview-v1.png`. Individual meme permalinks continue to use their own meme image.
|
Homepage and lore social shares use the fingerprinted, metadata-stripped `1200x630` JPEG at `/assets/social-preview-v2.78d008df9860.jpg`. Individual meme permalinks continue to use their own meme image.
|
||||||
|
|
||||||
Browser tabs, bookmarks, Apple home-screen links, and installed web apps use the supplied favicon suite under `/assets/favicon/`; the legacy `/favicon.ico` URL remains available for automatic browser requests.
|
Browser tabs, bookmarks, Apple home-screen links, and installed web apps use the supplied favicon suite under `/assets/favicon/`; the legacy `/favicon.ico` URL remains available for automatic browser requests.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
@@ -67,8 +67,9 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||||
withSecurityHeaders(res, { contentSecurityPolicy: !isDiscoveryRoute(req, url) });
|
withSecurityHeaders(res, { contentSecurityPolicy: !isDiscoveryRoute(req, url) });
|
||||||
const baseUrl = publicBaseUrl(req);
|
const baseUrl = publicBaseUrl(req);
|
||||||
|
const isPublicRead = isReadRequest(req);
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/') {
|
if (isPublicRead && url.pathname === '/') {
|
||||||
const nonce = crypto.randomBytes(16).toString('base64');
|
const nonce = crypto.randomBytes(16).toString('base64');
|
||||||
withSecurityHeaders(res, { scriptNonce: nonce });
|
withSecurityHeaders(res, { scriptNonce: nonce });
|
||||||
return sendHtml(res, 200, renderIndex({
|
return sendHtml(res, 200, renderIndex({
|
||||||
@@ -79,7 +80,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && (url.pathname === '/lore' || url.pathname === '/lore/')) {
|
if (isPublicRead && (url.pathname === '/lore' || url.pathname === '/lore/')) {
|
||||||
const nonce = crypto.randomBytes(16).toString('base64');
|
const nonce = crypto.randomBytes(16).toString('base64');
|
||||||
withSecurityHeaders(res, { scriptNonce: nonce });
|
withSecurityHeaders(res, { scriptNonce: nonce });
|
||||||
return sendHtml(res, 200, renderIndex({
|
return sendHtml(res, 200, renderIndex({
|
||||||
@@ -92,7 +93,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const memePageMatch = url.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
|
const memePageMatch = url.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
|
||||||
if (req.method === 'GET' && memePageMatch) {
|
if (isPublicRead && memePageMatch) {
|
||||||
const meme = store.get(memePageMatch[1]);
|
const meme = store.get(memePageMatch[1]);
|
||||||
if (!meme || meme.status !== 'approved') {
|
if (!meme || meme.status !== 'approved') {
|
||||||
return sendNotFoundPage(res, { baseUrl, pathname: url.pathname });
|
return sendNotFoundPage(res, { baseUrl, pathname: url.pathname });
|
||||||
@@ -108,37 +109,37 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/openapi.json') {
|
if (isPublicRead && url.pathname === '/openapi.json') {
|
||||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||||
return sendJson(res, 200, openApiSpecFor(baseUrl));
|
return sendJson(res, 200, openApiSpecFor(baseUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/meme-api.skill.md') {
|
if (isPublicRead && url.pathname === '/meme-api.skill.md') {
|
||||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||||
return sendText(res, 200, renderMemeApiSkill(baseUrl), 'text/markdown; charset=utf-8');
|
return sendText(res, 200, renderMemeApiSkill(baseUrl), 'text/markdown; charset=utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/robots.txt') {
|
if (isPublicRead && url.pathname === '/robots.txt') {
|
||||||
return sendText(res, 200, robotsTxt(baseUrl));
|
return sendText(res, 200, robotsTxt(baseUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/llms.txt') {
|
if (isPublicRead && url.pathname === '/llms.txt') {
|
||||||
return sendText(res, 200, llmsTxt(baseUrl));
|
return sendText(res, 200, llmsTxt(baseUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/site.webmanifest') {
|
if (isPublicRead && url.pathname === '/site.webmanifest') {
|
||||||
return sendJson(res, 200, manifest(baseUrl));
|
return sendJson(res, 200, manifest(baseUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/favicon.ico') {
|
if (isPublicRead && url.pathname === '/favicon.ico') {
|
||||||
return sendFile(res, './public/assets/favicon/favicon.ico');
|
return sendFile(res, './public/assets/favicon/favicon.ico');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/feed.json') {
|
if (isPublicRead && url.pathname === '/feed.json') {
|
||||||
return sendJson(res, 200, feedJson(baseUrl, store.listForReview({ status: 'approved' }).memes));
|
return sendJson(res, 200, feedJson(baseUrl, store.listForReview({ status: 'approved' }).memes));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/sitemap.xml') {
|
if (isPublicRead && url.pathname === '/sitemap.xml') {
|
||||||
const body = sitemapXml(baseUrl, store.listForReview({ status: 'approved' }).memes);
|
const body = sitemapXml(baseUrl, store.listForReview({ status: 'approved' }).memes);
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'application/xml; charset=utf-8',
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
@@ -154,7 +155,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return sendFile(res, './public/admin.html');
|
return sendFile(res, './public/admin.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname.startsWith('/assets/')) {
|
if (isPublicRead && url.pathname.startsWith('/assets/')) {
|
||||||
const isVersioned = url.searchParams.has('v') || FINGERPRINTED_ASSET.test(url.pathname);
|
const isVersioned = url.searchParams.has('v') || FINGERPRINTED_ASSET.test(url.pathname);
|
||||||
res.setHeader('Cache-Control', isVersioned ? IMMUTABLE_CACHE_CONTROL : REVALIDATING_CACHE_CONTROL);
|
res.setHeader('Cache-Control', isVersioned ? IMMUTABLE_CACHE_CONTROL : REVALIDATING_CACHE_CONTROL);
|
||||||
return sendFile(res, `./public${url.pathname}`);
|
return sendFile(res, `./public${url.pathname}`);
|
||||||
@@ -256,7 +257,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaMatch = url.pathname.match(/^\/media\/([a-f0-9]{64})$/);
|
const mediaMatch = url.pathname.match(/^\/media\/([a-f0-9]{64})$/);
|
||||||
if (req.method === 'GET' && mediaMatch) {
|
if (isPublicRead && mediaMatch) {
|
||||||
checkReadLimit(req, res, mediaReadLimiter);
|
checkReadLimit(req, res, mediaReadLimiter);
|
||||||
const meme = store.get(mediaMatch[1]);
|
const meme = store.get(mediaMatch[1]);
|
||||||
if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found');
|
if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found');
|
||||||
@@ -295,7 +296,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true });
|
return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/healthz') {
|
if (isPublicRead && url.pathname === '/healthz') {
|
||||||
noIndex(res);
|
noIndex(res);
|
||||||
return sendJson(res, 200, { ok: true });
|
return sendJson(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
@@ -330,12 +331,12 @@ function positiveInt(value, fallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isDiscoveryRoute(req, url) {
|
function isDiscoveryRoute(req, url) {
|
||||||
if (req.method !== 'GET') return false;
|
if (!isReadRequest(req)) return false;
|
||||||
return DISCOVERY_ROUTES.has(url.pathname);
|
return DISCOVERY_ROUTES.has(url.pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBrowserPageRequest(req, url) {
|
function isBrowserPageRequest(req, url) {
|
||||||
if (req.method !== 'GET') return false;
|
if (!isReadRequest(req)) return false;
|
||||||
return ![
|
return ![
|
||||||
'/api',
|
'/api',
|
||||||
'/assets',
|
'/assets',
|
||||||
@@ -346,6 +347,10 @@ function isBrowserPageRequest(req, url) {
|
|||||||
].some((prefix) => url.pathname === prefix || url.pathname.startsWith(`${prefix}/`));
|
].some((prefix) => url.pathname === prefix || url.pathname.startsWith(`${prefix}/`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isReadRequest(req) {
|
||||||
|
return req.method === 'GET' || req.method === 'HEAD';
|
||||||
|
}
|
||||||
|
|
||||||
function sendNotFoundPage(res, { baseUrl, pathname }) {
|
function sendNotFoundPage(res, { baseUrl, pathname }) {
|
||||||
noIndex(res);
|
noIndex(res);
|
||||||
return sendHtml(res, 404, renderNotFound({
|
return sendHtml(res, 404, renderNotFound({
|
||||||
|
|||||||
+10
-3
@@ -37,7 +37,7 @@ export function sendJson(res, statusCode, payload) {
|
|||||||
'Content-Type': 'application/json; charset=utf-8',
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
'Content-Length': String(body.length)
|
'Content-Length': String(body.length)
|
||||||
});
|
});
|
||||||
res.end(body);
|
endBody(res, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendText(res, statusCode, message, contentType = 'text/plain; charset=utf-8') {
|
export function sendText(res, statusCode, message, contentType = 'text/plain; charset=utf-8') {
|
||||||
@@ -46,7 +46,7 @@ export function sendText(res, statusCode, message, contentType = 'text/plain; ch
|
|||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'Content-Length': String(body.length)
|
'Content-Length': String(body.length)
|
||||||
});
|
});
|
||||||
res.end(body);
|
endBody(res, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendHtml(res, statusCode, html) {
|
export function sendHtml(res, statusCode, html) {
|
||||||
@@ -55,7 +55,7 @@ export function sendHtml(res, statusCode, html) {
|
|||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
'Content-Length': String(body.length)
|
'Content-Length': String(body.length)
|
||||||
});
|
});
|
||||||
res.end(body);
|
endBody(res, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendFile(res, filePath, options = {}) {
|
export function sendFile(res, filePath, options = {}) {
|
||||||
@@ -75,8 +75,15 @@ export function sendFile(res, filePath, options = {}) {
|
|||||||
if (!res.getHeader('Content-Length')) {
|
if (!res.getHeader('Content-Length')) {
|
||||||
res.setHeader('Content-Length', String(stats.size));
|
res.setHeader('Content-Length', String(stats.size));
|
||||||
}
|
}
|
||||||
|
if (res.req?.method === 'HEAD') {
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
fs.createReadStream(resolved)
|
fs.createReadStream(resolved)
|
||||||
.on('error', () => sendText(res, 500, 'File read failed'))
|
.on('error', () => sendText(res, 500, 'File read failed'))
|
||||||
.pipe(res);
|
.pipe(res);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function endBody(res, body) {
|
||||||
|
return res.req?.method === 'HEAD' ? res.end() : res.end(body);
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
const SITE_NAME = 'The Meme Protocol';
|
const SITE_NAME = 'The Meme Protocol';
|
||||||
const SITE_DESCRIPTION = 'A live, moderated meme stream for The Meme Protocol: WebP memes, MEME_CONSENSUS_SCORE ranking, and community review.';
|
const SITE_DESCRIPTION = 'A live, moderated meme stream for The Meme Protocol: WebP memes, MEME_CONSENSUS_SCORE ranking, and community review.';
|
||||||
const REPO_URL = 'https://git.yoonect.com/Nautilus/bitsforfree';
|
const REPO_URL = 'https://git.yoonect.com/Nautilus/bitsforfree';
|
||||||
const SOCIAL_IMAGE_PATH = '/assets/social-preview-v1.png';
|
const SOCIAL_IMAGE_PATH = '/assets/social-preview-v2.78d008df9860.jpg';
|
||||||
const NOT_FOUND_IMAGE_PATH = '/assets/homo-reseticus-404-828.68be2119.webp';
|
const NOT_FOUND_IMAGE_PATH = '/assets/homo-reseticus-404-828.68be2119.webp';
|
||||||
const FAVICON_PATH = '/assets/favicon';
|
const FAVICON_PATH = '/assets/favicon';
|
||||||
const FAVICON_VERSION = '20260814';
|
const FAVICON_VERSION = '20260814';
|
||||||
@@ -21,7 +21,7 @@ export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = nu
|
|||||||
const home = `${baseUrl}/`;
|
const home = `${baseUrl}/`;
|
||||||
const canonical = meme ? `${baseUrl}/meme/${meme.id}` : lore ? `${baseUrl}/lore` : home;
|
const canonical = meme ? `${baseUrl}/meme/${meme.id}` : lore ? `${baseUrl}/lore` : home;
|
||||||
const image = meme ? `${baseUrl}/media/${meme.id}` : `${baseUrl}${SOCIAL_IMAGE_PATH}`;
|
const image = meme ? `${baseUrl}/media/${meme.id}` : `${baseUrl}${SOCIAL_IMAGE_PATH}`;
|
||||||
const imageType = meme ? meme.mime : 'image/png';
|
const imageType = meme ? meme.mime : 'image/jpeg';
|
||||||
const imageWidth = meme ? meme.width : 1200;
|
const imageWidth = meme ? meme.width : 1200;
|
||||||
const imageHeight = meme ? meme.height : 630;
|
const imageHeight = meme ? meme.height : 630;
|
||||||
const imageAlt = meme ? `Meme ${shortId(meme.id)}` : `${SITE_NAME} — memes bring us together`;
|
const imageAlt = meme ? `Meme ${shortId(meme.id)}` : `${SITE_NAME} — memes bring us together`;
|
||||||
|
|||||||
+22
-2
@@ -1,8 +1,12 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import sharp from 'sharp';
|
||||||
import { feedJson, manifest, renderIndex, sitemapXml } from '../src/seo.js';
|
import { feedJson, manifest, renderIndex, sitemapXml } from '../src/seo.js';
|
||||||
|
|
||||||
|
const socialPreviewPath = fileURLToPath(new URL('../public/assets/social-preview-v2.78d008df9860.jpg', import.meta.url));
|
||||||
|
|
||||||
const id = 'a'.repeat(64);
|
const id = 'a'.repeat(64);
|
||||||
const meme = {
|
const meme = {
|
||||||
id,
|
id,
|
||||||
@@ -17,6 +21,8 @@ const template = [
|
|||||||
'<meta name="description" content="__SEO_DESCRIPTION__">',
|
'<meta name="description" content="__SEO_DESCRIPTION__">',
|
||||||
'<link rel="canonical" href="__SEO_CANONICAL__">',
|
'<link rel="canonical" href="__SEO_CANONICAL__">',
|
||||||
'<meta property="og:image" content="__SEO_IMAGE__">',
|
'<meta property="og:image" content="__SEO_IMAGE__">',
|
||||||
|
'<meta property="og:image:type" content="__SEO_IMAGE_TYPE__">',
|
||||||
|
'<meta name="twitter:image" content="__SEO_IMAGE__">',
|
||||||
'<script nonce="__CSP_NONCE__">__SEO_JSON_LD__</script>'
|
'<script nonce="__CSP_NONCE__">__SEO_JSON_LD__</script>'
|
||||||
].join('');
|
].join('');
|
||||||
|
|
||||||
@@ -44,10 +50,24 @@ test('renders the homepage with the dedicated social preview image', () => {
|
|||||||
approvedCount: 12
|
approvedCount: 12
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.match(html, /og:image" content="https:\/\/memes\.example\/assets\/social-preview-v1\.png"/);
|
assert.match(html, /og:image" content="https:\/\/memes\.example\/assets\/social-preview-v2\.78d008df9860\.jpg"/);
|
||||||
|
assert.match(html, /og:image:type" content="image\/jpeg"/);
|
||||||
|
assert.match(html, /twitter:image" content="https:\/\/memes\.example\/assets\/social-preview-v2\.78d008df9860\.jpg"/);
|
||||||
assert.doesNotMatch(html, /yoonect-logo\.png/);
|
assert.doesNotMatch(html, /yoonect-logo\.png/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps the social preview small and broadly compatible', async () => {
|
||||||
|
const metadata = await sharp(socialPreviewPath).metadata();
|
||||||
|
const stats = fs.statSync(socialPreviewPath);
|
||||||
|
|
||||||
|
assert.equal(metadata.format, 'jpeg');
|
||||||
|
assert.deepEqual([metadata.width, metadata.height], [1200, 630]);
|
||||||
|
assert.equal(metadata.hasAlpha, false);
|
||||||
|
assert.equal(metadata.exif, undefined);
|
||||||
|
assert.equal(metadata.xmp, undefined);
|
||||||
|
assert.ok(stats.size < 300_000, `expected social preview under 300 KB, got ${stats.size}`);
|
||||||
|
});
|
||||||
|
|
||||||
test('uses the supplied favicon suite in the page and web app manifest', () => {
|
test('uses the supplied favicon suite in the page and web app manifest', () => {
|
||||||
const index = fs.readFileSync(new URL('../public/index.html', import.meta.url), 'utf8');
|
const index = fs.readFileSync(new URL('../public/index.html', import.meta.url), 'utf8');
|
||||||
const appManifest = manifest('https://memes.example');
|
const appManifest = manifest('https://memes.example');
|
||||||
@@ -76,7 +96,7 @@ test('renders a shareable lore page with text-file metadata', () => {
|
|||||||
|
|
||||||
assert.match(html, /<title>Lore \| The Meme Protocol<\/title>/);
|
assert.match(html, /<title>Lore \| The Meme Protocol<\/title>/);
|
||||||
assert.match(html, /canonical" href="https:\/\/memes\.example\/lore"/);
|
assert.match(html, /canonical" href="https:\/\/memes\.example\/lore"/);
|
||||||
assert.match(html, /og:image" content="https:\/\/memes\.example\/assets\/social-preview-v1\.png"/);
|
assert.match(html, /og:image" content="https:\/\/memes\.example\/assets\/social-preview-v2\.78d008df9860\.jpg"/);
|
||||||
assert.match(html, /"@type":"CreativeWork"/);
|
assert.match(html, /"@type":"CreativeWork"/);
|
||||||
assert.match(html, /https:\/\/memes\.example\/assets\/meme-protocol-lore\.txt/);
|
assert.match(html, /https:\/\/memes\.example\/assets\/meme-protocol-lore\.txt/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import net from 'node:net';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const projectRoot = fileURLToPath(new URL('../', import.meta.url));
|
||||||
|
const socialPreviewPath = '/assets/social-preview-v2.78d008df9860.jpg';
|
||||||
|
|
||||||
|
test('serves crawler-safe HEAD responses for the homepage and social preview', { timeout: 15_000 }, async (t) => {
|
||||||
|
let port;
|
||||||
|
try {
|
||||||
|
port = await availablePort();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'EPERM') {
|
||||||
|
t.skip('loopback listeners are disabled in this sandbox');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'meme-protocol-head-'));
|
||||||
|
const child = spawn(process.execPath, ['server.js'], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
ADMIN_TOKEN: 'test-admin-token-000000000000',
|
||||||
|
DATA_DIR: dataDir,
|
||||||
|
HOST: '127.0.0.1',
|
||||||
|
PORT: String(port),
|
||||||
|
SEED_DEMO_MEMES: 'false',
|
||||||
|
SITE_URL: `http://127.0.0.1:${port}`
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
t.after(async () => {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await once(child, 'exit');
|
||||||
|
}
|
||||||
|
await fs.rm(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForStart(child);
|
||||||
|
|
||||||
|
const headers = { 'user-agent': 'Twitterbot/1.0' };
|
||||||
|
const page = await fetch(`http://127.0.0.1:${port}/`, { method: 'HEAD', headers });
|
||||||
|
assert.equal(page.status, 200);
|
||||||
|
assert.match(page.headers.get('content-type') || '', /^text\/html/);
|
||||||
|
assert.ok(Number(page.headers.get('content-length')) > 0);
|
||||||
|
assert.equal((await page.arrayBuffer()).byteLength, 0);
|
||||||
|
|
||||||
|
const image = await fetch(`http://127.0.0.1:${port}${socialPreviewPath}`, { method: 'HEAD', headers });
|
||||||
|
assert.equal(image.status, 200);
|
||||||
|
assert.equal(image.headers.get('content-type'), 'image/jpeg');
|
||||||
|
assert.match(image.headers.get('cache-control') || '', /immutable/);
|
||||||
|
assert.ok(Number(image.headers.get('content-length')) < 300_000);
|
||||||
|
assert.equal((await image.arrayBuffer()).byteLength, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function availablePort() {
|
||||||
|
const server = net.createServer();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForStart(child) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
let output = '';
|
||||||
|
const timeout = setTimeout(() => finish(new Error(`server did not start:\n${output}`)), 10_000);
|
||||||
|
const onData = (chunk) => {
|
||||||
|
output += chunk.toString();
|
||||||
|
if (output.includes('The Meme Protocol listening')) finish();
|
||||||
|
};
|
||||||
|
const onExit = (code, signal) => finish(new Error(`server exited before startup (${code ?? signal}):\n${output}`));
|
||||||
|
const finish = (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
child.stdout.off('data', onData);
|
||||||
|
child.stderr.off('data', onData);
|
||||||
|
child.off('exit', onExit);
|
||||||
|
error ? reject(error) : resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout.on('data', onData);
|
||||||
|
child.stderr.on('data', onData);
|
||||||
|
child.once('exit', onExit);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user