fedipod 1.55.2 → 1.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ // forum-pages.mjs — a forum's page at its own host (bb.<gateway>/<forum>/…).
2
+ // Every address under a forum is handed to the forum's own code
3
+ // (ctx.forum.renderPage, netlify/forum-support.mjs), which draws the ones it
4
+ // draws; for the rest, or when drawing fails, the page as the site serves it,
5
+ // which the reader's browser builds. Which addresses are drawn is the forum's
6
+ // business, not this file's.
7
+
8
+ // A forum's path, and anything under it.
9
+ const VIEW = /^\/([^/]+)\//u;
10
+ // First path segments the bb host gives to something other than a forum.
11
+ const NOT_FORUMS = new Set(['api', 'bb', 'icons', 'u', '.well-known']);
12
+ // How long the edge keeps a drawn page. The forum's documents are dropped
13
+ // from the edge when a post lands and when the forum runs, under the labels
14
+ // the page carries too, so an hour only bounds a change nothing announced.
15
+ // Kept per region, not durably: a page names the scripts of the deploy that
16
+ // drew it.
17
+ const PAGE_EDGE_SECONDS = 3600;
18
+ const MISSING_EDGE_SECONDS = 120;
19
+
20
+ // The page as the site serves it, read once per running instance: it changes
21
+ // only with a deploy, which starts new ones.
22
+ const shells = new Map();
23
+ async function shellOf(origin, f) {
24
+ if (shells.has(origin)) return shells.get(origin);
25
+ const r = await f(`${origin}/bb/index.html`).catch(() => null);
26
+ const text = r?.ok ? await r.text().catch(() => null) : null;
27
+ if (text) shells.set(origin, text);
28
+ return text;
29
+ }
30
+
31
+ /** The answer for a forum's page at its own host, or null for any other request. */
32
+ export async function routeForumPage(request, ctx) {
33
+ const url = new URL(request.url);
34
+ if (!/^bb\./u.test(url.hostname)) return null;
35
+ const m = VIEW.exec(url.pathname);
36
+ if (!m || NOT_FORUMS.has(m[1])) return null;
37
+ if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: { allow: 'GET, HEAD' }, body: '' };
38
+ const shell = await shellOf(url.origin, ctx.siteFetch || globalThis.fetch.bind(globalThis));
39
+ if (!shell) return { status: 503, headers: { 'content-type': 'text/plain', 'cache-control': 'no-store', 'retry-after': '30' }, body: 'the forum\'s page is not available right now\n' };
40
+ const page = (status, body, headers) => ({ status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache', ...headers }, body });
41
+ // An address with a query is a sign-in coming back, or nothing a reader
42
+ // follows: the page builds it.
43
+ const draws = !!ctx.forum?.renderPage && !url.search;
44
+ // A drawing not done in time goes on after the page has gone, where the
45
+ // host lets work outlive its answer (ctx.waitUntil): the next reader's
46
+ // drawing then finds the forum's documents at hand.
47
+ const drawn = draws ? await ctx.forum.renderPage({ url: url.href, shell, keepGoing: ctx.waitUntil || null }).catch(() => null) : null;
48
+ if (drawn) {
49
+ const hold = drawn.status === 404 ? MISSING_EDGE_SECONDS : PAGE_EDGE_SECONDS;
50
+ return page(drawn.status, drawn.html, {
51
+ 'netlify-cdn-cache-control': `public, s-maxage=${hold}, stale-while-revalidate=${hold * 4}`,
52
+ 'netlify-cache-tag': drawn.tags.join(','),
53
+ });
54
+ }
55
+ // Not drawn. Where drawing was tried, this answer is not kept, so the next
56
+ // reader gets a drawn page; where nothing here draws, it is the same page
57
+ // for everyone and is kept like one.
58
+ return page(200, shell, draws || url.search ? {} : { 'netlify-cdn-cache-control': `public, s-maxage=${PAGE_EDGE_SECONDS}` });
59
+ }
@@ -26,6 +26,7 @@ import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, clo
26
26
  import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi, routePushApi, routeAlertApi } from './held-mail.mjs';
27
27
  import { routeCheckApi, missingLetter } from './check-letter.mjs';
28
28
  import { routeNoticesApi } from './notices.mjs';
29
+ import { routeForumPage } from './forum-pages.mjs';
29
30
  import { withSecurityHeaders } from './headers.mjs';
30
31
  import { claimedWebId, verifyPodToken } from './token-claims.mjs';
31
32
 
@@ -307,7 +308,7 @@ function identFor(rec, policy = null) {
307
308
  // an adapter that reads a store directly and so
308
309
  // must confine the read itself.
309
310
  export async function routeFront(request, ctx) {
310
- const out = await route(request, ctx);
311
+ const out = (await routeForumPage(request, ctx)) || await route(request, ctx);
311
312
  return { ...out, headers: withSecurityHeaders(out.headers, out.body) };
312
313
  }
313
314
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.55.2",
3
+ "version": "1.56.0",
4
4
  "description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@
55
55
  "install-service": "node bin/fedipod.mjs install-service",
56
56
  "uninstall-service": "node bin/fedipod.mjs uninstall-service",
57
57
  "prepublishOnly": "npm test",
58
- "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node claude/smoke-tests/admin-facade-smoke.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs claude/smoke-tests/unread-docs.test.mjs claude/smoke-tests/post-text-once.test.mjs claude/smoke-tests/connected-pod-only.test.mjs claude/smoke-tests/check-letter.test.mjs claude/smoke-tests/gone-through-relay.test.mjs claude/smoke-tests/connected-feeds.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
58
+ "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node claude/smoke-tests/admin-facade-smoke.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs claude/smoke-tests/unread-docs.test.mjs claude/smoke-tests/post-text-once.test.mjs claude/smoke-tests/connected-pod-only.test.mjs claude/smoke-tests/check-letter.test.mjs claude/smoke-tests/gone-through-relay.test.mjs claude/smoke-tests/connected-feeds.test.mjs claude/smoke-tests/forum-pages.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
59
59
  "verify-ui": "node scripts/verify-ui.mjs",
60
60
  "build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
61
61
  "build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",
@@ -97,7 +97,6 @@
97
97
  "package-lock.json": "npm:empty-npm-package@1.0.0"
98
98
  },
99
99
  "devDependencies": {
100
- "esbuild": "^0.28.2",
101
- "fedipod-bb": "^0.4.4"
100
+ "esbuild": "^0.28.2"
102
101
  }
103
102
  }
@@ -67,6 +67,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
67
67
  const entry = args[args.indexOf('--entry') + 1] || path.join(root, 'web/app/agent.mjs');
68
68
  const out = args[args.indexOf('--out') + 1] || path.join(root, 'web/app/dist/agent.js');
69
69
  const format = args.includes('--format') ? args[args.indexOf('--format') + 1] : 'esm';
70
+ // The app's "make a forum" is built from the forum's own folder, as it is now.
71
+ const { bringForum } = await import('./forum-package.mjs');
72
+ bringForum();
70
73
  const { bytes } = await buildApp({ entry, out, format });
71
74
  console.log(`built ${path.relative(root, out)} — ${Math.round(bytes / 1024)} KB`);
72
75
  }
@@ -18,6 +18,9 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-bundles-'));
18
18
  let fails = 0;
19
19
  const check = (ok, msg) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${msg}`); if (!ok) fails++; };
20
20
  try {
21
+ // The functions carry the forum as it is in its own folder now.
22
+ const { bringForum } = await import('./forum-package.mjs');
23
+ bringForum();
21
24
  execFileSync('netlify', ['functions:build', '--src', path.join(root, 'netlify/functions'), '--functions', path.join(tmp, 'zips')],
22
25
  { cwd: root, stdio: 'ignore' });
23
26
  const manifest = JSON.parse(fs.readFileSync(path.join(tmp, 'zips/manifest.json'), 'utf8'));
@@ -0,0 +1,26 @@
1
+ // forum-package.mjs — the forum (fedipod-bb) that fedipod.net runs and
2
+ // serves, taken from the forum's own folder every time, not from a version
3
+ // FediPod pins: the checkout beside FediPod, or a clone of the forum's GitHub
4
+ // main where there is none (scripts/dependents.mjs). It is packed as npm would
5
+ // publish it and put in node_modules without touching FediPod's package list,
6
+ // so nothing FediPod records names a forum version. Staging the site, the
7
+ // bundle check and the app build each bring it first.
8
+ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
9
+ import { execFileSync } from 'node:child_process';
10
+ import { fetchDependent, root } from './dependents.mjs';
11
+
12
+ /** The forum from its folder, installed in node_modules. Returns its folder and version. */
13
+ export function bringForum({ log = console.log } = {}) {
14
+ const dir = fetchDependent('fedipod-bb', { log });
15
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'forum-pack-'));
16
+ try {
17
+ const packed = JSON.parse(execFileSync('npm', ['pack', dir, '--pack-destination', tmp, '--ignore-scripts', '--json'],
18
+ { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }))[0];
19
+ execFileSync('npm', ['install', '--no-save', '--no-audit', '--no-fund', '--ignore-scripts', path.join(tmp, packed.filename)],
20
+ { cwd: root, stdio: 'ignore' });
21
+ log(`forum: fedipod-bb ${packed.version} from ${path.relative(root, dir) || dir}`);
22
+ return { dir, version: packed.version };
23
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
24
+ }
25
+
26
+ if (import.meta.url === `file://${process.argv[1]}`) bringForum();
@@ -14,8 +14,11 @@
14
14
  import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url';
15
15
  import { createHash } from 'node:crypto';
16
16
  import { readyDependent } from './dependents.mjs';
17
+ import { bringForum } from './forum-package.mjs';
17
18
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
19
  const site = path.join(root, 'web/app/site');
20
+ // The forum's page is copied from the forum's own folder, as it is now.
21
+ bringForum({ log: () => {} });
19
22
  fs.rmSync(site, { recursive: true, force: true }); fs.mkdirSync(path.join(site, 'app'), { recursive: true });
20
23
  const cp = (from, to) => fs.copyFileSync(path.resolve(root, from), path.join(site, to));
21
24
  cp('web/app/index.html', 'index.html');
@@ -219,6 +222,12 @@ fs.writeFileSync(path.join(site, '_redirects'), [
219
222
  `https://${h}/update.js /update.js 200!`,
220
223
  `https://${h}/build.json /build.json 200!`,
221
224
  `https://${h}/api/* /.netlify/functions/front 200!`,
225
+ // The forum's host answers /robots.txt with the forum's own.
226
+ `https://${h}/robots.txt /bb/robots.txt 200!`,
227
+ // Every address under a forum goes to the forum's code, which draws the
228
+ // ones it draws (lib/gateway/forum-pages.mjs); the rest get the page.
229
+ `https://${h}/:forum/ /.netlify/functions/front 200!`,
230
+ `https://${h}/:forum/* /.netlify/functions/front 200!`,
222
231
  `https://${h}/* /bb/index.html 200!`,
223
232
  ]),
224
233
  '# the in-browser app, served static (these win over the project catch-all)',
@@ -267,7 +276,13 @@ fs.writeFileSync(path.join(site, '_redirects'), [
267
276
  // own /u/<handle>/ or a pod named outright. Its script is a module file of
268
277
  // its own, so it is served under the same `script-src 'self'` as the app.
269
278
  fs.mkdirSync(path.join(site, 'bb'), { recursive: true });
270
- for (const f of ['index.html', 'bb.js', 'read.mjs', 'masto.mjs', 'pod.mjs', 'markdown.mjs', 'seen.mjs', 'mine.mjs', 'private.mjs', 'oidc-session.mjs']) cp(`node_modules/fedipod-bb/site/${f}`, `bb/${f}`);
279
+ // Every file the forum's page ships, whatever the forum names them (its
280
+ // robots.txt among them): the forum decides what its page is made of.
281
+ // Editor leftovers and hidden files are not part of it.
282
+ for (const f of fs.readdirSync(path.join(root, 'node_modules/fedipod-bb/site'))) {
283
+ if (/^[.#]|~$/u.test(f)) continue;
284
+ cp(`node_modules/fedipod-bb/site/${f}`, `bb/${f}`);
285
+ }
271
286
  cp('web/admin/tokens.css', 'bb/tokens.css'); // the site's shared palette, size and family
272
287
  cp('node_modules/fediverse-session/oidc-session.mjs', 'bb/solid-oidc-session.mjs'); // signing in to a pod: the library the forum's binding names
273
288
  // The account library's demo page, with the library beside it, under /demo/:
@@ -292,15 +307,22 @@ cp(path.join(sessionRepo, 'demo.html'), 'demo/index.html');
292
307
  const sub = (n, pairs) => { let t = fs.readFileSync(bb(n), 'utf8');
293
308
  for (const [a, b] of pairs) t = t.split(a).join(b); fs.writeFileSync(bb(n), t); };
294
309
  sub('oidc-session.mjs', [["'fediverse-session/oidc-session.mjs'", `'./solid-oidc-session.mjs?v=${stamp('solid-oidc-session.mjs')}'`]]);
295
- sub('pod.mjs', [["'./oidc-session.mjs'", `'./oidc-session.mjs?v=${stamp('oidc-session.mjs')}'`],
296
- ["'./markdown.mjs'", `'./markdown.mjs?v=${stamp('markdown.mjs')}'`],
297
- ["'./private.mjs'", `'./private.mjs?v=${stamp('private.mjs')}'`],
298
- ["'./mine.mjs'", `'./mine.mjs?v=${stamp('mine.mjs')}'`]]);
299
- sub('bb.js', [["'./read.mjs'", `'./read.mjs?v=${stamp('read.mjs')}'`], ["'./masto.mjs'", `'./masto.mjs?v=${stamp('masto.mjs')}'`],
300
- ["'./pod.mjs'", `'./pod.mjs?v=${stamp('pod.mjs')}'`], ["'./seen.mjs'", `'./seen.mjs?v=${stamp('seen.mjs')}'`],
301
- ["'./markdown.mjs'", `'./markdown.mjs?v=${stamp('markdown.mjs')}'`],
302
- ["'./mine.mjs'", `'./mine.mjs?v=${stamp('mine.mjs')}'`]]);
303
- sub('index.html', [['"/bb/bb.js"', `"/bb/bb.js?v=${stamp('bb.js')}"`], ['"/bb/tokens.css"', `"/bb/tokens.css?v=${stamp('tokens.css')}"`]]);
310
+ // Every module names the others it loads by their stamped address, and each
311
+ // is stamped only after the ones it names, so one module is one address
312
+ // wherever it is loaded from.
313
+ const modules = fs.readdirSync(path.join(site, 'bb')).filter((n) => /\.m?js$/u.test(n));
314
+ const loads = (n) => [...fs.readFileSync(bb(n), 'utf8').matchAll(/from '\.\/([^'?]+)'/gu)].map((m) => m[1]).filter((m) => modules.includes(m));
315
+ const done = new Set();
316
+ while (done.size < modules.length) {
317
+ const ready = modules.filter((n) => !done.has(n) && loads(n).every((m) => done.has(m)));
318
+ if (!ready.length) throw new Error(`stage-site: the forum page's modules load each other in a circle (${modules.filter((n) => !done.has(n)).join(', ')})`);
319
+ for (const n of ready) {
320
+ sub(n, loads(n).map((m) => [`'./${m}'`, `'./${m}?v=${stamp(m)}'`]));
321
+ done.add(n);
322
+ }
323
+ }
324
+ sub('index.html', [...fs.readFileSync(bb('index.html'), 'utf8').matchAll(/"\/bb\/([^"?]+)"/gu)].map((m) => m[1])
325
+ .filter((n) => fs.existsSync(bb(n))).map((n) => [`"/bb/${n}"`, `"/bb/${n}?v=${stamp(n)}"`]));
304
326
  }
305
327
  injectUpdate(path.join(site, 'bb/index.html'));
306
328
  injectUpdate(path.join(site, 'index.html'));