Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions api/src/latest.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
holdMs old. After that it gets the last settled outcome, or null when there
is none younger than keepMs. */

/** @typedef {{ ok: true, value: any } | { ok: false, error: unknown }} Outcome */
/** @typedef {{ ok: true, value: any, at: number } | { ok: false, error: unknown, at: number }} Outcome */

/** @type {Map<string, { started: number, at: number, keepMs: number, outcome: Outcome | null, pending: Promise<Outcome> | null, group: string | null }>} */
const _entries = new Map();
Expand Down Expand Up @@ -53,12 +53,12 @@ async function latest(key, refresh, { holdMs, keepMs, group, maxPerGroup }) {
entry.started = now;
entry.pending = (async () => refresh())()
.then(
value => /** @type {Outcome} */ ({ ok: true, value }),
error => /** @type {Outcome} */ ({ ok: false, error }),
value => /** @type {Outcome} */ ({ ok: true, value, at: Date.now() }),
error => /** @type {Outcome} */ ({ ok: false, error, at: Date.now() }),
)
.then(outcome => {
entry.outcome = outcome;
entry.at = Date.now();
entry.at = outcome.at;
entry.pending = null;
return outcome;
});
Expand Down
5 changes: 4 additions & 1 deletion api/src/widget-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ on('GET', '/api/widget-data/:id', async (req, res) => {
maxPerGroup: KEPT_PER_WIDGET,
});
if (!outcome) return json(res, 202, { pending: true });
if (outcome.ok) return json(res, outcome.value.status, outcome.value.body);
if (outcome.ok) {
res.setHeader('Age', String(Math.max(0, Math.floor((Date.now() - outcome.at) / 1000))));
return json(res, outcome.value.status, outcome.value.body);
}
fail(res, outcome.error, { status: 502, quiet: true });
});

Expand Down
33 changes: 33 additions & 0 deletions api/test/http-integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,39 @@ test('a widget whose service stalls answers pending, then with the data once it
}
});

test('a stored reply served while a refresh stalls says how old it is', async () => {
let release;
const released = new Promise(r => {
release = r;
});
let calls = 0;
const upstream = http.createServer(async (_, res) => {
if (++calls > 1) await released;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(`{"n":${calls}}`);
});
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
const cfg = loadConfig();
const url = `http://127.0.0.1:${upstream.address().port}/`;
saveConfig({
...cfg,
items: [...(cfg.items || []), { id: 'aged-w', type: 'widget', widgetType: 'fixture', widgetConfig: { url } }],
});
try {
const first = await req('GET', '/api/widget-data/aged-w', { cookie: validCookie });
assert.equal(first.headers.age, '0');
await new Promise(r => setTimeout(r, 2100));
const second = await req('GET', '/api/widget-data/aged-w', { cookie: validCookie });
assert.deepEqual(second.body, { status: 200, data: { n: 1 } });
assert.ok(Number(second.headers.age) >= 2, `Age was ${second.headers.age}`);
} finally {
release();
saveConfig(cfg);
upstream.closeAllConnections?.();
await new Promise(r => upstream.close(r));
}
});

test('query values a widget does not read share one refresh and one cached reply', async () => {
let release;
const released = new Promise(r => {
Expand Down
17 changes: 16 additions & 1 deletion api/test/latest.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
const { test, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const { latest, reset, _size } = require('../src/latest');
const { latest: latestWithTime, reset, _size } = require('../src/latest');

const latest = async (...args) => {
const outcome = await latestWithTime(...args);
if (!outcome) return outcome;
const { at, ...rest } = outcome;
return rest;
};

const OPTS = { holdMs: 30, keepMs: 60_000 };
const wait = ms => new Promise(r => setTimeout(r, ms));
Expand Down Expand Up @@ -101,3 +108,11 @@ test('the cap never drops a refresh in flight, and leaves other groups alone', a
value: 0,
});
});

test('an outcome carries the time it settled, and a stored one keeps it', async t => {
t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 });
assert.equal((await latestWithTime('k', async () => 'old', OPTS)).at, 1_000_000);
t.mock.timers.tick(10_000);
const stored = await latestWithTime('k', () => new Promise(() => {}), { holdMs: 0, keepMs: OPTS.keepMs });
assert.deepEqual(stored, { ok: true, value: 'old', at: 1_000_000 });
});
16 changes: 14 additions & 2 deletions ui/js/widget-toolbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,19 @@ export async function fetchData(endpoint, opts = {}) {
if (typeof d.kind === 'string') e.kind = d.kind;
throw e;
}
return r.json();
const data = await r.json();
const age = Number(r.headers.get('Age'));
if (data && typeof data === 'object') _readAt.set(data, Date.now() - (age > 0 ? age * 1000 : 0));
return data;
}

/** @type {WeakMap<object, number>} */
const _readAt = new WeakMap();

/** When the service produced data that fetchData returned.
@param {unknown} data @returns {number} */
export function readingTime(data) {
return (data && typeof data === 'object' && _readAt.get(data)) || Date.now();
}

export function openUrl(href) {
Expand Down Expand Up @@ -678,7 +690,7 @@ export function poll(opts = {}) {
opts.render && opts.render(data);
}
fails = 0;
lastOk = Date.now();
lastOk = readingTime(data);
everOk = true;
lastData = data;
} catch (caught) {
Expand Down
4 changes: 4 additions & 0 deletions ui/test/weather-updated.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ test('the hover and screen-reader text say when the reading arrived', () => {
);
assert.match(page, /setInterval\(describe, 60000\)/);
});

test('the updated time is when the service produced the reading', () => {
assert.match(page, /_fetchedAt = readingTime\(d\);/);
});
23 changes: 21 additions & 2 deletions ui/test/widget-toolbox-fetch-data.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ import { register } from 'node:module';

register('./js-root-hooks.mjs', import.meta.url);
globalThis.location = { search: '?id=w1' };
const { fetchData } = await import('../js/widget-toolbox.js');
const { fetchData, readingTime } = await import('../js/widget-toolbox.js');

const reply = (status, body) => ({ status, ok: status >= 200 && status < 300, json: async () => body });
const reply = (status, body, headers = {}) => ({
status,
ok: status >= 200 && status < 300,
headers: new Headers(headers),
json: async () => body,
});

/* Settles `promise` while advancing mocked timers one second at a time. */
async function drive(t, promise) {
Expand Down Expand Up @@ -45,3 +50,17 @@ test('a reply that stays pending ends as a timeout', async t => {
await assert.rejects(drive(t, fetchData()), { kind: 'timeout' });
assert.ok(calls > 1 && calls < 50, `${calls} fetches`);
});

test('a reply the server held back is dated by its Age', async () => {
globalThis.fetch = async () => reply(200, { n: 1 }, { Age: '600' });
const before = Date.now();
const data = await fetchData('stats');
assert.ok(Math.abs(readingTime(data) - (before - 600_000)) < 1000);
});

test('a fresh reply is dated now', async () => {
globalThis.fetch = async () => reply(200, { n: 1 }, { Age: '0' });
const data = await fetchData('stats');
assert.ok(Date.now() - readingTime(data) < 1000);
assert.ok(Date.now() - readingTime({ other: true }) < 1000);
});
2 changes: 1 addition & 1 deletion ui/widgets/backup/backup.html
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
<body>
<div class="widget" id="widget" role="group" aria-label="Backup status"></div>
<script type="module">
import { poll, fetchData, openUrl, linkTo, html, setHtml, loadStrings, wt, errorState, errorLine, formatList, relativeTime, cssZoom } from '/js/widget-toolbox.js?v=2a4c18db';
import { poll, fetchData, openUrl, linkTo, html, setHtml, loadStrings, wt, errorState, errorLine, formatList, relativeTime, cssZoom } from '/js/widget-toolbox.js?v=49800e75';
await loadStrings();
document.getElementById('widget').setAttribute('aria-label', wt('ui.backupStatus', 'Backup status'));
const id = new URLSearchParams(location.search).get('id') || '';
Expand Down
2 changes: 1 addition & 1 deletion ui/widgets/books/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
<span class="sr" id="sr" aria-live="polite"></span>
</div>
<script type="module">
import { poll, linkTo, loadStrings, wt, formatNumber, errorState, placeholder } from '/js/widget-toolbox.js?v=2a4c18db';
import { poll, linkTo, loadStrings, wt, formatNumber, errorState, placeholder } from '/js/widget-toolbox.js?v=49800e75';
await loadStrings();
document.getElementById('card').setAttribute('aria-label', wt('widget.label', 'Books'));
const API='/api';
Expand Down
2 changes: 1 addition & 1 deletion ui/widgets/clock/analog.html
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
async function showZoneError(){
const w=document.getElementById('widget');
w.className='widget mode-'+_mode;
const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=2a4c18db');
const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=49800e75');
await loadStrings();
const line=wt('ui.unknownZone','Unknown timezone');
errorState({ root: w, place: 'center' }).empty(line);
Expand Down
2 changes: 1 addition & 1 deletion ui/widgets/clock/digital.html
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
}
async function showZoneError() {
const w = document.getElementById('widget');
const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=2a4c18db');
const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=49800e75');
await loadStrings();
const line = wt('ui.unknownZone', 'Unknown timezone');
errorState({ root: w, place: 'center' }).empty(line);
Expand Down
2 changes: 1 addition & 1 deletion ui/widgets/connections/connections-map.html
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@
})(window.__DOTS);
</script>
<script type="module">
import { poll, fetchData, openUrl, html, setHtml, colorOrFallback, loadStrings, wt, errorState, theme, formatNumber, formatList, cssZoom, linkTo, whileOnScreen } from '/js/widget-toolbox.js?v=2a4c18db';
import { poll, fetchData, openUrl, html, setHtml, colorOrFallback, loadStrings, wt, errorState, theme, formatNumber, formatList, cssZoom, linkTo, whileOnScreen } from '/js/widget-toolbox.js?v=49800e75';
await loadStrings();
document.getElementById('c').setAttribute('aria-label', wt('ui.worldMap', 'World map of connections'));
(function(){
Expand Down
Loading
Loading