@hashsome/runtime 0.3.0 → 0.4.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.
@@ -2,6 +2,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { startTransition, StrictMode } from 'react';
3
3
  import { hydrateRoot } from 'react-dom/client';
4
4
  import { HydratedRouter } from 'react-router/dom';
5
+ import { watchBrowserForNewVersion } from './version-watch.js';
5
6
  startTransition(() => {
6
7
  hydrateRoot(document, _jsx(StrictMode, { children: _jsx(HydratedRouter, {}) }));
7
8
  });
9
+ // A dashboard left open for weeks is reloaded once a newer build is being served.
10
+ watchBrowserForNewVersion();
@@ -0,0 +1,27 @@
1
+ /** The id of the build this page came from; undefined in dev, where nothing is watched. */
2
+ export declare const PAGE_BUILD_ID: string | undefined;
3
+ export interface VersionWatchOptions {
4
+ /** The build this page is. */
5
+ buildId: string;
6
+ /** The build being served now; undefined when it cannot be told (offline, an old server). */
7
+ read: () => Promise<string | undefined>;
8
+ reload: () => void;
9
+ /** How often to ask while the page is open. Default one minute. */
10
+ intervalMs?: number;
11
+ /** Calls the function when the page may have been asleep: shown again, or back online. */
12
+ onWake: (check: () => void) => () => void;
13
+ /** Remembers when this page last reloaded for a new version, across the reload. */
14
+ lastReload: {
15
+ get: () => number | undefined;
16
+ set: (time: number) => void;
17
+ };
18
+ now?: () => number;
19
+ }
20
+ /**
21
+ * Reloads the page when the server is serving a different build than the one it was loaded from. A
22
+ * tablet or wall display keeps a dashboard open for weeks, and a deployment replaces the server
23
+ * under it without replacing the page. Returns a function that stops watching.
24
+ */
25
+ export declare function watchForNewVersion(options: VersionWatchOptions): () => void;
26
+ /** Starts watching in the browser, for a production build. Does nothing in dev. */
27
+ export declare function watchBrowserForNewVersion(): void;
@@ -0,0 +1,84 @@
1
+ import { VERSION_PATH } from '../src/build-id.js';
2
+ /** The id of the build this page came from; undefined in dev, where nothing is watched. */
3
+ export const PAGE_BUILD_ID = typeof HASHSOME_BUILD_ID === 'undefined' ? undefined : HASHSOME_BUILD_ID;
4
+ /** Never reload twice within this long: if the server keeps answering with another id (a stale cache
5
+ * in front of it), the page would otherwise reload for ever. */
6
+ const REPEAT_MS = 30_000;
7
+ /**
8
+ * Reloads the page when the server is serving a different build than the one it was loaded from. A
9
+ * tablet or wall display keeps a dashboard open for weeks, and a deployment replaces the server
10
+ * under it without replacing the page. Returns a function that stops watching.
11
+ */
12
+ export function watchForNewVersion(options) {
13
+ const now = options.now ?? Date.now;
14
+ let busy = false;
15
+ const check = () => {
16
+ if (busy) {
17
+ return;
18
+ }
19
+ busy = true;
20
+ options.read().then((served) => {
21
+ busy = false;
22
+ if (served === undefined || served === options.buildId) {
23
+ return;
24
+ }
25
+ const last = options.lastReload.get();
26
+ if (last !== undefined && now() - last < REPEAT_MS) {
27
+ return;
28
+ }
29
+ options.lastReload.set(now());
30
+ options.reload();
31
+ }, () => {
32
+ busy = false;
33
+ });
34
+ };
35
+ const timer = setInterval(check, options.intervalMs ?? 60_000);
36
+ const stopWake = options.onWake(check);
37
+ return () => {
38
+ clearInterval(timer);
39
+ stopWake();
40
+ };
41
+ }
42
+ /** Starts watching in the browser, for a production build. Does nothing in dev. */
43
+ export function watchBrowserForNewVersion() {
44
+ const buildId = PAGE_BUILD_ID;
45
+ if (buildId === undefined) {
46
+ return;
47
+ }
48
+ const KEY = 'hashsome:reloaded-at';
49
+ watchForNewVersion({
50
+ buildId,
51
+ read: async () => {
52
+ const response = await fetch(VERSION_PATH, { cache: 'no-store' });
53
+ return response.ok ? (await response.json()).id : undefined;
54
+ },
55
+ reload: () => window.location.reload(),
56
+ onWake: (check) => {
57
+ const onVisible = () => document.visibilityState === 'visible' && check();
58
+ document.addEventListener('visibilitychange', onVisible);
59
+ window.addEventListener('online', check);
60
+ return () => {
61
+ document.removeEventListener('visibilitychange', onVisible);
62
+ window.removeEventListener('online', check);
63
+ };
64
+ },
65
+ lastReload: {
66
+ get: () => {
67
+ try {
68
+ return Number(sessionStorage.getItem(KEY)) || undefined;
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ },
74
+ set: (time) => {
75
+ try {
76
+ sessionStorage.setItem(KEY, String(time));
77
+ }
78
+ catch {
79
+ // Without storage the guard is the page's own memory only, which a reload loses.
80
+ }
81
+ },
82
+ },
83
+ });
84
+ }
@@ -0,0 +1,12 @@
1
+ import type { Plugin } from 'vite';
2
+ /** Where a build records its id, next to the client files, and where the server tells it. */
3
+ export declare const VERSION_FILE = "version.json";
4
+ export declare const VERSION_PATH = "/_hashsome/version";
5
+ /** One per `hashsome build` run: the same for everything that run produces. */
6
+ export declare const buildId: () => string;
7
+ /**
8
+ * Gives a production build an id that its client knows (as `HASHSOME_BUILD_ID`) and the server
9
+ * can tell (the client directory gets a `version.json`). A page left open compares the two and
10
+ * reloads when a newer build is being served. Dev builds have neither, so nothing is watched there.
11
+ */
12
+ export declare function hashsomeBuildId(): Plugin;
@@ -0,0 +1,29 @@
1
+ /** Where a build records its id, next to the client files, and where the server tells it. */
2
+ export const VERSION_FILE = 'version.json';
3
+ export const VERSION_PATH = '/_hashsome/version';
4
+ let id;
5
+ /** One per `hashsome build` run: the same for everything that run produces. */
6
+ export const buildId = () => (id ??= process.env.HASHSOME_BUILD_ID || Date.now().toString(36));
7
+ /**
8
+ * Gives a production build an id that its client knows (as `HASHSOME_BUILD_ID`) and the server
9
+ * can tell (the client directory gets a `version.json`). A page left open compares the two and
10
+ * reloads when a newer build is being served. Dev builds have neither, so nothing is watched there.
11
+ */
12
+ export function hashsomeBuildId() {
13
+ return {
14
+ name: 'hashsome:build-id',
15
+ apply: 'build',
16
+ config: () => ({ define: { HASHSOME_BUILD_ID: JSON.stringify(buildId()) } }),
17
+ generateBundle() {
18
+ // The server's own build has no use for it; the client's output is what gets served.
19
+ if (this.environment && this.environment.name !== 'client') {
20
+ return;
21
+ }
22
+ this.emitFile({
23
+ type: 'asset',
24
+ fileName: VERSION_FILE,
25
+ source: JSON.stringify({ id: buildId() }),
26
+ });
27
+ },
28
+ };
29
+ }
@@ -1,16 +1,40 @@
1
1
  import express, {} from 'express';
2
2
  import { createServer } from 'node:http';
3
+ import { readFileSync } from 'node:fs';
3
4
  import { join } from 'node:path';
5
+ import { VERSION_FILE, VERSION_PATH } from '../build-id.js';
4
6
  import { serveAsset } from './assets.js';
5
7
  import { Proxy } from './proxy.js';
6
8
  import { startIntegrations } from './integrations.js';
7
9
  import { attachWebSocket } from './websocket.js';
10
+ /** The id the build left in the client directory; none for a client built without one. */
11
+ function readVersion(clientDir) {
12
+ try {
13
+ const { id } = JSON.parse(readFileSync(join(clientDir, VERSION_FILE), 'utf8'));
14
+ return typeof id === 'string' && id !== '' ? id : undefined;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
8
20
  export function createApp(clientDir, integrations = []) {
9
21
  const app = express();
10
22
  app.disable('x-powered-by');
11
23
  app.get('/healthz', (_req, res) => {
12
24
  res.json({ status: 'ok' });
13
25
  });
26
+ // Which build this is, for a page that has been open a while to compare with its own and reload.
27
+ // Never cached: a stale answer is exactly what it is there to avoid.
28
+ const version = readVersion(clientDir);
29
+ app.get(VERSION_PATH, (_req, res) => {
30
+ res.setHeader('cache-control', 'no-store');
31
+ if (version) {
32
+ res.json({ id: version });
33
+ }
34
+ else {
35
+ res.status(404).end();
36
+ }
37
+ });
14
38
  app.use((req, res, next) => {
15
39
  serveAsset(integrations, req, res).then((served) => (served ? undefined : next()), next);
16
40
  });
@@ -1,5 +1,6 @@
1
1
  import { reactRouter } from '@react-router/dev/vite';
2
2
  import { defineConfig } from 'vite';
3
+ import { hashsomeBuildId } from './build-id.js';
3
4
  /**
4
5
  * Sensible default, re-exported by the project's own `vite.config.ts` (the React Router plugin
5
6
  * requires a config file at the project root). Server settings and the dev proxy are supplied
@@ -11,7 +12,7 @@ import { defineConfig } from 'vite';
11
12
  * ("Expected build manifest").
12
13
  */
13
14
  export default defineConfig(() => ({
14
- plugins: [reactRouter()],
15
+ plugins: [reactRouter(), hashsomeBuildId()],
15
16
  // React Router's SPA prerender starts a private preview server; pin it to IPv4 so it is
16
17
  // reachable in containers where `localhost` resolves to ::1.
17
18
  preview: { host: '127.0.0.1' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashsome/runtime",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Server + CLI that serves a project's own dashboards",
5
5
  "type": "module",
6
6
  "exports": {
@@ -38,8 +38,8 @@
38
38
  "jsdom": "30.1.1"
39
39
  },
40
40
  "dependencies": {
41
- "@hashsome/core": "0.3.0",
42
- "@hashsome/ui": "0.3.0",
41
+ "@hashsome/core": "0.4.0",
42
+ "@hashsome/ui": "0.4.0",
43
43
  "@react-router/dev": "8.4.0",
44
44
  "express": "5.2.1",
45
45
  "react": "19.3.0",