openvibers/openvibe.blog Commits
Serve Blog cache headers through openvibe-shared/cache-policy (#3)
Changes
@@@ -32,6 +32,7 @@ const { createMedia } = require('./clients/media');const { createVip } = require('./clients/vip');const { createServiceOutbox } = require('openvibe-sdk/events');const { createIndexNow } = require('openvibe-shared/indexnow');++const cache = require('openvibe-shared/cache-policy');const { createPublicRoutes } = require('./http/public');const { createEditorRoutes } = require('./http/editor');const { createApi } = require('./http/api');@@@ -153,7 +154,7 @@ async function createApp(opts = {}) {// { user: null }), not a 401. The SDK router's own /me answers 401 for an absent session; this route,// registered first, keeps Blog's contract and marks the answer private / no-store.app.get('/auth/me', async (req, res) => {-- res.set('Cache-Control', 'private, no-store');++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));res.vary('Cookie');res.vary('Authorization');const token = auth.extractToken(req);@@@ -176,7 +177,7 @@ async function createApp(opts = {}) {setHeaders(res, filePath) {const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');const v = res.req && res.req.query && res.req.query.v;-- res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));},}));@@@ -195,7 +196,7 @@ async function createApp(opts = {}) {app.use((err, req, res, _next) => {log.error('[Blog]', err && err.stack ? err.stack : err);if (res.headersSent) return;-- res.set('Cache-Control', 'private, no-store');++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');});@@@ -31,6 +31,7 @@const express = require('express');const contracts = require('openvibe-contracts');const seo = require('openvibe-publishing/seo');++const cache = require('openvibe-shared/cache-policy');const { run, jsonBody, ApiError, privateNoStore } = require('./errors');const { guard } = require('../auth/viewer');const { checkCapability } = require('../auth/capabilities');@@@ -227,7 +228,7 @@ function createApi(ctx) {const blog = await mustBlog(req);const s = await blogs.feedSettings(blog);const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });-- res.set('Cache-Control', 'public, max-age=300');++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));res.removeHeader('X-Robots-Tag');return seo.jsonFeed({ title: blog.title, link: publication.abs(publication.blogPath(blog)), feedUrl: publication.abs(reading.urls.feed(blog, 'json')), description: blog.description || undefined }, items);}));@@@ -16,6 +16,7 @@const express = require('express');const seo = require('openvibe-publishing/seo');const sharedSeo = require('openvibe-shared/seo');++const cache = require('openvibe-shared/cache-policy');function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {const router = express.Router();@@@ -40,7 +41,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {return out;}-- const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);++ const xml = (res, body) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(body);router.get('/robots.txt', (_req, res) => {const body = [@@@ -49,12 +50,12 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {'# Pages decide their own indexability (meta robots / X-Robots-Tag); a Disallow is not a noindex.',sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/write', '/auth/', '/api/'] }),].join('\n');-- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(body);});router.get('/llms.txt', async (_req, res) => {const official = await blogs.official();-- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({name: 'OpenVibe.Blog',summary: 'The official OpenVibe blog and a blog for every OpenVibe member: server-rendered posts with feeds, sitemaps and a JSON representation of every post.',details: 'Only public, published posts are listed, fed or mapped. Every post page has a machine-readable twin at <post URL>.json with the same content, its revision, authorship (human, AI-assisted or AI-generated, and whether a person reviewed it), sources and indexability reasons. AI-generated drafts are never published or indexed before a person reviews them.',@@@ -23,6 +23,7 @@ const { renderPage } = require('../render/layout');const editor = require('../render/editor');const { csrfToken, checkCsrf } = require('../auth/forms');const { asApiError } = require('./errors');++const cache = require('openvibe-shared/cache-policy');/** Checkbox + hidden fallback arrive as ['1','0']; a text field repeated keeps its last value. */const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);@@@ -39,7 +40,7 @@ function createEditorRoutes(ctx) {const form = express.urlencoded({ extended: false, limit: '600kb' });router.use(viewers.middleware({ services: false }));router.use((req, res, next) => {-- res.set('Cache-Control', 'private, no-store');++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));res.set('X-Robots-Tag', 'noindex, nofollow');res.vary('Cookie');if (req.viewer.kind !== 'user') {@@@ -23,6 +23,7 @@ const frame = require('openvibe-shared/frame');const seo = require('openvibe-publishing/seo');const ssr = require('openvibe-publishing/ssr');const authorship = require('openvibe-publishing/authorship');++const cache = require('openvibe-shared/cache-policy');const { renderPage } = require('../render/layout');const pages = require('../render/pages');const { csrfToken, checkCsrf } = require('../auth/forms');@@@ -39,8 +40,8 @@ function createPublicRoutes(ctx) {function cacheHeaders(res, { cacheable, robots }) {res.vary('Cookie');res.vary('Authorization');-- if (cacheable) res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=60');-- else res.set('Cache-Control', 'private, no-store');++ if (cacheable) res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));++ else res.set('Cache-Control', cache.htmlHeaders({ private: true }));if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);}@@@ -173,7 +174,7 @@ function createPublicRoutes(ctx) {const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });const link = publication.abs(publication.blogPath(blog));const feedUrl = publication.abs(reading.urls.feed(blog, kind));-- res.set('Cache-Control', 'public, max-age=300');++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));res.vary('Accept-Encoding');if (kind === 'rss') return res.type('application/rss+xml').send(seo.rssFeed({ title: blog.title, link, description: blog.description || blog.title, feedUrl, language: blog.language }, items));if (kind === 'json') return res.type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: blog.title, link, feedUrl, description: blog.description || undefined, language: blog.language }, items)));nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++/**++ * Static assets follow openvibe-shared/cache-policy: the current `?v=<assetVersion>` URL is immutable++ * for a year (the bytes cannot change under that URL), a wrong `?v=` or none is a short public window++ * with a long stale-while-revalidate (the URL may change at any moment).++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { assetVersion } = require('../server/render/layout');++++const REL = 'css/blog.css';++const IMMUTABLE = 'public, max-age=31536000, immutable';++const REVALIDATED = 'public, max-age=300, stale-while-revalidate=86400';++++(async () => {++ const t = await boot();++ await check('the current ?v=<assetVersion> is immutable for a year', async () => {++ const r = await t.get(`/${REL}?v=${assetVersion(REL)}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), IMMUTABLE);++ });++ await check('a wrong-but-hex ?v= is not content-addressed: short public window', async () => {++ const r = await t.get(`/${REL}?v=deadbeefdeadbeef`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), REVALIDATED);++ });++ await check('no ?v= is not content-addressed: short public window', async () => {++ const r = await t.get(`/${REL}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), REVALIDATED);++ });++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -32,6 +32,7 @@ const { createMedia } = require('./clients/media');
@@ -32,6 +32,7 @@ const { createMedia } = require('./clients/media');
const { createVip } = require('./clients/vip');
const { createVip } = require('./clients/vip');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createIndexNow } = require('openvibe-shared/indexnow');
+const cache = require('openvibe-shared/cache-policy');
const { createPublicRoutes } = require('./http/public');
const { createPublicRoutes } = require('./http/public');
const { createEditorRoutes } = require('./http/editor');
const { createEditorRoutes } = require('./http/editor');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
@@ -153,7 +154,7 @@ async function createApp(opts = {}) {
@@ -153,7 +154,7 @@ async function createApp(opts = {}) {
// { user: null }), not a 401. The SDK router's own /me answers 401 for an absent session; this route,
// { user: null }), not a 401. The SDK router's own /me answers 401 for an absent session; this route,
// registered first, keeps Blog's contract and marks the answer private / no-store.
// registered first, keeps Blog's contract and marks the answer private / no-store.
app.get('/auth/me', async (req, res) => {
app.get('/auth/me', async (req, res) => {
- res.set('Cache-Control', 'private, no-store');
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.vary('Cookie');
res.vary('Cookie');
res.vary('Authorization');
res.vary('Authorization');
const token = auth.extractToken(req);
const token = auth.extractToken(req);
@@ -176,7 +177,7 @@ async function createApp(opts = {}) {
@@ -176,7 +177,7 @@ async function createApp(opts = {}) {
setHeaders(res, filePath) {
setHeaders(res, filePath) {
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
const v = res.req && res.req.query && res.req.query.v;
const v = res.req && res.req.query && res.req.query.v;
- res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
+ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
},
},
}));
}));
@@ -195,7 +196,7 @@ async function createApp(opts = {}) {
@@ -195,7 +196,7 @@ async function createApp(opts = {}) {
app.use((err, req, res, _next) => {
app.use((err, req, res, _next) => {
log.error('[Blog]', err && err.stack ? err.stack : err);
log.error('[Blog]', err && err.stack ? err.stack : err);
if (res.headersSent) return;
if (res.headersSent) return;
- res.set('Cache-Control', 'private, no-store');
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
});
});
@@ -31,6 +31,7 @@
@@ -31,6 +31,7 @@
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
+const cache = require('openvibe-shared/cache-policy');
const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
const { guard } = require('../auth/viewer');
const { guard } = require('../auth/viewer');
const { checkCapability } = require('../auth/capabilities');
const { checkCapability } = require('../auth/capabilities');
@@ -227,7 +228,7 @@ function createApi(ctx) {
@@ -227,7 +228,7 @@ function createApi(ctx) {
const blog = await mustBlog(req);
const blog = await mustBlog(req);
const s = await blogs.feedSettings(blog);
const s = await blogs.feedSettings(blog);
const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
- res.set('Cache-Control', 'public, max-age=300');
+ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));
res.removeHeader('X-Robots-Tag');
res.removeHeader('X-Robots-Tag');
return seo.jsonFeed({ title: blog.title, link: publication.abs(publication.blogPath(blog)), feedUrl: publication.abs(reading.urls.feed(blog, 'json')), description: blog.description || undefined }, items);
return seo.jsonFeed({ title: blog.title, link: publication.abs(publication.blogPath(blog)), feedUrl: publication.abs(reading.urls.feed(blog, 'json')), description: blog.description || undefined }, items);
}));
}));
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
const express = require('express');
const express = require('express');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const sharedSeo = require('openvibe-shared/seo');
const sharedSeo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
const router = express.Router();
const router = express.Router();
@@ -40,7 +41,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
@@ -40,7 +41,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
return out;
return out;
}
}
- const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);
+ const xml = (res, body) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(body);
router.get('/robots.txt', (_req, res) => {
router.get('/robots.txt', (_req, res) => {
const body = [
const body = [
@@ -49,12 +50,12 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
@@ -49,12 +50,12 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
'# Pages decide their own indexability (meta robots / X-Robots-Tag); a Disallow is not a noindex.',
'# Pages decide their own indexability (meta robots / X-Robots-Tag); a Disallow is not a noindex.',
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/write', '/auth/', '/api/'] }),
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/write', '/auth/', '/api/'] }),
].join('\n');
].join('\n');
- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);
+ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(body);
});
});
router.get('/llms.txt', async (_req, res) => {
router.get('/llms.txt', async (_req, res) => {
const official = await blogs.official();
const official = await blogs.official();
- res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({
+ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({
name: 'OpenVibe.Blog',
name: 'OpenVibe.Blog',
summary: 'The official OpenVibe blog and a blog for every OpenVibe member: server-rendered posts with feeds, sitemaps and a JSON representation of every post.',
summary: 'The official OpenVibe blog and a blog for every OpenVibe member: server-rendered posts with feeds, sitemaps and a JSON representation of every post.',
details: 'Only public, published posts are listed, fed or mapped. Every post page has a machine-readable twin at <post URL>.json with the same content, its revision, authorship (human, AI-assisted or AI-generated, and whether a person reviewed it), sources and indexability reasons. AI-generated drafts are never published or indexed before a person reviews them.',
details: 'Only public, published posts are listed, fed or mapped. Every post page has a machine-readable twin at <post URL>.json with the same content, its revision, authorship (human, AI-assisted or AI-generated, and whether a person reviewed it), sources and indexability reasons. AI-generated drafts are never published or indexed before a person reviews them.',
@@ -23,6 +23,7 @@ const { renderPage } = require('../render/layout');
@@ -23,6 +23,7 @@ const { renderPage } = require('../render/layout');
const editor = require('../render/editor');
const editor = require('../render/editor');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { asApiError } = require('./errors');
const { asApiError } = require('./errors');
+const cache = require('openvibe-shared/cache-policy');
/** Checkbox + hidden fallback arrive as ['1','0']; a text field repeated keeps its last value. */
/** Checkbox + hidden fallback arrive as ['1','0']; a text field repeated keeps its last value. */
const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);
const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);
@@ -39,7 +40,7 @@ function createEditorRoutes(ctx) {
@@ -39,7 +40,7 @@ function createEditorRoutes(ctx) {
const form = express.urlencoded({ extended: false, limit: '600kb' });
const form = express.urlencoded({ extended: false, limit: '600kb' });
router.use(viewers.middleware({ services: false }));
router.use(viewers.middleware({ services: false }));
router.use((req, res, next) => {
router.use((req, res, next) => {
- res.set('Cache-Control', 'private, no-store');
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('X-Robots-Tag', 'noindex, nofollow');
res.set('X-Robots-Tag', 'noindex, nofollow');
res.vary('Cookie');
res.vary('Cookie');
if (req.viewer.kind !== 'user') {
if (req.viewer.kind !== 'user') {
@@ -23,6 +23,7 @@ const frame = require('openvibe-shared/frame');
@@ -23,6 +23,7 @@ const frame = require('openvibe-shared/frame');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const authorship = require('openvibe-publishing/authorship');
const authorship = require('openvibe-publishing/authorship');
+const cache = require('openvibe-shared/cache-policy');
const { renderPage } = require('../render/layout');
const { renderPage } = require('../render/layout');
const pages = require('../render/pages');
const pages = require('../render/pages');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { csrfToken, checkCsrf } = require('../auth/forms');
@@ -39,8 +40,8 @@ function createPublicRoutes(ctx) {
@@ -39,8 +40,8 @@ function createPublicRoutes(ctx) {
function cacheHeaders(res, { cacheable, robots }) {
function cacheHeaders(res, { cacheable, robots }) {
res.vary('Cookie');
res.vary('Cookie');
res.vary('Authorization');
res.vary('Authorization');
- if (cacheable) res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=60');
- else res.set('Cache-Control', 'private, no-store');
+ if (cacheable) res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
+ else res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);
if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);
}
}
@@ -173,7 +174,7 @@ function createPublicRoutes(ctx) {
@@ -173,7 +174,7 @@ function createPublicRoutes(ctx) {
const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
const link = publication.abs(publication.blogPath(blog));
const link = publication.abs(publication.blogPath(blog));
const feedUrl = publication.abs(reading.urls.feed(blog, kind));
const feedUrl = publication.abs(reading.urls.feed(blog, kind));
- res.set('Cache-Control', 'public, max-age=300');
+ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));
res.vary('Accept-Encoding');
res.vary('Accept-Encoding');
if (kind === 'rss') return res.type('application/rss+xml').send(seo.rssFeed({ title: blog.title, link, description: blog.description || blog.title, feedUrl, language: blog.language }, items));
if (kind === 'rss') return res.type('application/rss+xml').send(seo.rssFeed({ title: blog.title, link, description: blog.description || blog.title, feedUrl, language: blog.language }, items));
if (kind === 'json') return res.type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: blog.title, link, feedUrl, description: blog.description || undefined, language: blog.language }, items)));
if (kind === 'json') return res.type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: blog.title, link, feedUrl, description: blog.description || undefined, language: blog.language }, items)));
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+/**
+ * Static assets follow openvibe-shared/cache-policy: the current `?v=<assetVersion>` URL is immutable
+ * for a year (the bytes cannot change under that URL), a wrong `?v=` or none is a short public window
+ * with a long stale-while-revalidate (the URL may change at any moment).
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { assetVersion } = require('../server/render/layout');
+
+const REL = 'css/blog.css';
+const IMMUTABLE = 'public, max-age=31536000, immutable';
+const REVALIDATED = 'public, max-age=300, stale-while-revalidate=86400';
+
+(async () => {
+ const t = await boot();
+ await check('the current ?v=<assetVersion> is immutable for a year', async () => {
+ const r = await t.get(`/${REL}?v=${assetVersion(REL)}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), IMMUTABLE);
+ });
+ await check('a wrong-but-hex ?v= is not content-addressed: short public window', async () => {
+ const r = await t.get(`/${REL}?v=deadbeefdeadbeef`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), REVALIDATED);
+ });
+ await check('no ?v= is not content-addressed: short public window', async () => {
+ const r = await t.get(`/${REL}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), REVALIDATED);
+ });
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });