@hashsome/runtime 0.3.0 → 0.5.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();
@@ -1,8 +1,10 @@
1
- type ThemeMode = 'light' | 'dark' | 'system';
1
+ import { type ThemeMode } from '@hashsome/ui';
2
2
  /** Builds the document shell's `Layout`, painted before any client JS runs — so its background
3
3
  * needs to already roughly match the configured theme, or hydration visibly swaps the color out
4
4
  * from under the user. `'system'` can't know the OS preference at render time, so it ships both
5
- * colors and lets a plain CSS media query (not JS) pick the right one with no flash either way.
5
+ * colors and lets a plain CSS media query (not JS) pick the right one with no flash either way. A
6
+ * schedule (by the clock or the sun) is not known yet either, so it starts as `'system'` does and
7
+ * the app takes over once it is running.
6
8
  * The Google Fonts `<link>` is rendered here too (not left to `HashsomeProvider`'s own client-side
7
9
  * fallback) so the chosen font is already loading before hydration, not swapped in after. */
8
10
  export declare function createLayout(theme?: ThemeMode, font?: string): ({ children }: {
@@ -15,4 +17,3 @@ export declare const Layout: ({ children }: {
15
17
  children: React.ReactNode;
16
18
  }) => import("react").JSX.Element;
17
19
  export declare function HydrateFallback(): null;
18
- export {};
package/dist/app/root.js CHANGED
@@ -8,13 +8,16 @@ const SHELL = {
8
8
  /** Builds the document shell's `Layout`, painted before any client JS runs — so its background
9
9
  * needs to already roughly match the configured theme, or hydration visibly swaps the color out
10
10
  * from under the user. `'system'` can't know the OS preference at render time, so it ships both
11
- * colors and lets a plain CSS media query (not JS) pick the right one with no flash either way.
11
+ * colors and lets a plain CSS media query (not JS) pick the right one with no flash either way. A
12
+ * schedule (by the clock or the sun) is not known yet either, so it starts as `'system'` does and
13
+ * the app takes over once it is running.
12
14
  * The Google Fonts `<link>` is rendered here too (not left to `HashsomeProvider`'s own client-side
13
15
  * fallback) so the chosen font is already loading before hydration, not swapped in after. */
14
16
  export function createLayout(theme = 'dark', font = DEFAULT_FONT) {
15
- const colorScheme = theme === 'system' ? 'light dark' : theme;
17
+ const followsSystem = typeof theme === 'object' || theme === 'system';
18
+ const colorScheme = followsSystem ? 'light dark' : theme;
16
19
  const initial = theme === 'light' ? SHELL.light : SHELL.dark;
17
- const systemOverride = theme === 'system'
20
+ const systemOverride = followsSystem
18
21
  ? `@media (prefers-color-scheme: light) { html, body { background: ${SHELL.light.bg}; color: ${SHELL.light.fg}; } }`
19
22
  : '';
20
23
  const kioskCss = `
@@ -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
+ }
@@ -19,6 +19,8 @@ export interface HashsomeConfig {
19
19
  host?: string;
20
20
  /** Defaults for `hashsome package`, so deploying is `pnpm package` with no flags. */
21
21
  package?: PackageConfig;
22
+ /** Answers the requests a device makes to check that an address is a Home Assistant (its login flow, `/api/`, `/api/config`, `/manifest.json`), so a wall display that only opens a Home Assistant, like the Shelly Wall Display, accepts this server's address. Off by default. Any login is accepted — nothing sits behind it, and Hashsome has no login of its own — so turn it on only for a server on a network you trust. */
23
+ homeAssistantCompat?: boolean;
22
24
  }
23
25
  export interface ResolvedConfig {
24
26
  root: string;
@@ -26,6 +28,7 @@ export interface ResolvedConfig {
26
28
  port: number;
27
29
  host: string;
28
30
  package: PackageConfig;
31
+ homeAssistantCompat: boolean;
29
32
  }
30
33
  export declare function defineConfig(config: HashsomeConfig): HashsomeConfig;
31
34
  export declare const CONFIG_FILE = "hashsome.config.ts";
@@ -40,6 +40,7 @@ export function resolveConfig(root, user) {
40
40
  port: Number(process.env.PORT ?? user.port ?? 3000),
41
41
  host: process.env.HOST ?? user.host ?? '0.0.0.0',
42
42
  package: user.package ?? {},
43
+ homeAssistantCompat: user.homeAssistantCompat ?? false,
43
44
  };
44
45
  }
45
46
  /** Loads `<root>/hashsome.config.ts` (if present) and applies defaults. */
@@ -0,0 +1,13 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ /** The Home Assistant version this claims to be: a recent one, so a client with a minimum accepts it. */
3
+ export declare const HA_COMPAT_VERSION = "2026.9.0";
4
+ type Next = (error?: unknown) => void;
5
+ /**
6
+ * Answers the requests a device makes to check that an address is a Home Assistant, as Home
7
+ * Assistant's own documented authentication and API do (https://developers.home-assistant.io/docs/auth_api/):
8
+ * the login flow, a token, `/api/`, `/api/config`, `/api/discovery_info` and `/manifest.json`. Any login
9
+ * is accepted, because there is nothing behind it: Hashsome has no login of its own. Everything else
10
+ * goes on to the app. For a wall display that only opens a Home Assistant (the Shelly Wall Display).
11
+ */
12
+ export declare function homeAssistantCompat(): (req: IncomingMessage, res: ServerResponse, next: Next) => Promise<void>;
13
+ export {};
@@ -0,0 +1,132 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ /** The Home Assistant version this claims to be: a recent one, so a client with a minimum accepts it. */
3
+ export const HA_COMPAT_VERSION = '2026.9.0';
4
+ const send = (res, body, type = 'application/json') => {
5
+ const text = JSON.stringify(body);
6
+ res.statusCode = 200;
7
+ res.setHeader('Content-Type', type);
8
+ res.setHeader('Content-Length', Buffer.byteLength(text));
9
+ res.end(text);
10
+ };
11
+ /** A request's body, JSON or a form (the token request is a form). Never throws. */
12
+ async function bodyOf(req) {
13
+ const chunks = [];
14
+ for await (const chunk of req) {
15
+ chunks.push(chunk);
16
+ }
17
+ const text = Buffer.concat(chunks).toString('utf8');
18
+ try {
19
+ return JSON.parse(text);
20
+ }
21
+ catch {
22
+ return Object.fromEntries(new URLSearchParams(text));
23
+ }
24
+ }
25
+ /**
26
+ * Answers the requests a device makes to check that an address is a Home Assistant, as Home
27
+ * Assistant's own documented authentication and API do (https://developers.home-assistant.io/docs/auth_api/):
28
+ * the login flow, a token, `/api/`, `/api/config`, `/api/discovery_info` and `/manifest.json`. Any login
29
+ * is accepted, because there is nothing behind it: Hashsome has no login of its own. Everything else
30
+ * goes on to the app. For a wall display that only opens a Home Assistant (the Shelly Wall Display).
31
+ */
32
+ export function homeAssistantCompat() {
33
+ return async (req, res, next) => {
34
+ try {
35
+ const { pathname } = new URL(req.url ?? '/', 'http://localhost');
36
+ const method = req.method ?? 'GET';
37
+ const base = `http://${req.headers.host ?? 'localhost'}`;
38
+ const handler = ['homeassistant', null];
39
+ if (method === 'GET' && pathname === '/auth/providers') {
40
+ return send(res, {
41
+ providers: [{ name: 'Home Assistant Local', id: null, type: 'homeassistant' }],
42
+ preselect_remember_me: true,
43
+ });
44
+ }
45
+ if (method === 'POST' && pathname === '/auth/login_flow') {
46
+ await bodyOf(req);
47
+ return send(res, {
48
+ type: 'form',
49
+ flow_id: randomBytes(16).toString('hex'),
50
+ handler,
51
+ step_id: 'init',
52
+ data_schema: [
53
+ { type: 'string', name: 'username', required: true },
54
+ { type: 'string', name: 'password', required: true },
55
+ ],
56
+ errors: {},
57
+ description_placeholders: null,
58
+ last_step: null,
59
+ preview: null,
60
+ });
61
+ }
62
+ const step = /^\/auth\/login_flow\/([^/]+)$/.exec(pathname);
63
+ if (method === 'POST' && step) {
64
+ await bodyOf(req);
65
+ return send(res, {
66
+ version: 1,
67
+ type: 'create_entry',
68
+ flow_id: step[1],
69
+ handler,
70
+ title: 'Home',
71
+ result: randomBytes(16).toString('hex'),
72
+ description: null,
73
+ description_placeholders: null,
74
+ });
75
+ }
76
+ if (method === 'POST' && pathname === '/auth/token') {
77
+ await bodyOf(req);
78
+ return send(res, {
79
+ access_token: randomBytes(24).toString('hex'),
80
+ expires_in: 1800,
81
+ refresh_token: randomBytes(24).toString('hex'),
82
+ token_type: 'Bearer',
83
+ });
84
+ }
85
+ if (pathname === '/api' || pathname === '/api/') {
86
+ return send(res, { message: 'API running.' });
87
+ }
88
+ if (pathname === '/api/config') {
89
+ return send(res, {
90
+ components: ['api', 'auth', 'config', 'frontend', 'http', 'lovelace', 'websocket_api'],
91
+ config_dir: '/config',
92
+ location_name: 'Home',
93
+ time_zone: 'UTC',
94
+ unit_system: { length: 'km', mass: 'g', temperature: '°C', volume: 'L' },
95
+ state: 'RUNNING',
96
+ internal_url: base,
97
+ external_url: null,
98
+ version: HA_COMPAT_VERSION,
99
+ });
100
+ }
101
+ if (pathname === '/api/discovery_info') {
102
+ return send(res, {
103
+ base_url: base,
104
+ location_name: 'Home',
105
+ requires_api_password: false,
106
+ uuid: 'hashsome',
107
+ version: HA_COMPAT_VERSION,
108
+ });
109
+ }
110
+ if (pathname === '/manifest.json') {
111
+ return send(res, {
112
+ name: 'Home Assistant',
113
+ short_name: 'Home Assistant',
114
+ description: 'Home automation platform that puts local control and privacy first.',
115
+ id: '/?homescreen=1',
116
+ start_url: '/?homescreen=1',
117
+ display: 'standalone',
118
+ dir: 'ltr',
119
+ lang: 'en-US',
120
+ background_color: '#FFFFFF',
121
+ theme_color: '#2980b9',
122
+ prefer_related_applications: true,
123
+ related_applications: [{ platform: 'play', id: 'io.homeassistant.companion.android' }],
124
+ }, 'application/manifest+json');
125
+ }
126
+ next();
127
+ }
128
+ catch (error) {
129
+ next(error);
130
+ }
131
+ };
132
+ }
@@ -1,7 +1,9 @@
1
1
  import type { Integration } from '@hashsome/core';
2
2
  import { type Express } from 'express';
3
3
  import type { ResolvedConfig } from '../config.ts';
4
- export declare function createApp(clientDir: string, integrations?: Integration[]): Express;
4
+ export declare function createApp(clientDir: string, integrations?: Integration[], options?: {
5
+ homeAssistantCompat?: boolean;
6
+ }): Express;
5
7
  /** Where `hashsome build` puts the client, under a project. A packaged release keeps it elsewhere. */
6
8
  export declare const BUILT_CLIENT: string;
7
9
  export declare function startServer(config: ResolvedConfig, clientDir?: string): import("http").Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>;
@@ -1,16 +1,44 @@
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';
7
+ import { homeAssistantCompat } from './ha-compat.js';
5
8
  import { Proxy } from './proxy.js';
6
9
  import { startIntegrations } from './integrations.js';
7
10
  import { attachWebSocket } from './websocket.js';
8
- export function createApp(clientDir, integrations = []) {
11
+ /** The id the build left in the client directory; none for a client built without one. */
12
+ function readVersion(clientDir) {
13
+ try {
14
+ const { id } = JSON.parse(readFileSync(join(clientDir, VERSION_FILE), 'utf8'));
15
+ return typeof id === 'string' && id !== '' ? id : undefined;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ export function createApp(clientDir, integrations = [], options = {}) {
9
22
  const app = express();
10
23
  app.disable('x-powered-by');
24
+ if (options.homeAssistantCompat) {
25
+ app.use(homeAssistantCompat());
26
+ }
11
27
  app.get('/healthz', (_req, res) => {
12
28
  res.json({ status: 'ok' });
13
29
  });
30
+ // Which build this is, for a page that has been open a while to compare with its own and reload.
31
+ // Never cached: a stale answer is exactly what it is there to avoid.
32
+ const version = readVersion(clientDir);
33
+ app.get(VERSION_PATH, (_req, res) => {
34
+ res.setHeader('cache-control', 'no-store');
35
+ if (version) {
36
+ res.json({ id: version });
37
+ }
38
+ else {
39
+ res.status(404).end();
40
+ }
41
+ });
14
42
  app.use((req, res, next) => {
15
43
  serveAsset(integrations, req, res).then((served) => (served ? undefined : next()), next);
16
44
  });
@@ -27,7 +55,9 @@ export function createApp(clientDir, integrations = []) {
27
55
  /** Where `hashsome build` puts the client, under a project. A packaged release keeps it elsewhere. */
28
56
  export const BUILT_CLIENT = join('build', 'client');
29
57
  export function startServer(config, clientDir = join(config.root, BUILT_CLIENT)) {
30
- const server = createServer(createApp(clientDir, config.integrations));
58
+ const server = createServer(createApp(clientDir, config.integrations, {
59
+ homeAssistantCompat: config.homeAssistantCompat,
60
+ }));
31
61
  const proxy = new Proxy(config.integrations, { log: console.log });
32
62
  attachWebSocket(server, proxy);
33
63
  const stop = startIntegrations(config.integrations, { log: console.log });
@@ -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/dist/src/vite.js CHANGED
@@ -2,6 +2,7 @@ import { dirname, join } from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { searchForWorkspaceRoot } from 'vite';
4
4
  import { serveAsset } from './server/assets.js';
5
+ import { homeAssistantCompat } from './server/ha-compat.js';
5
6
  import { attachWebSocket } from './server/websocket.js';
6
7
  const runtimeRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
7
8
  /** Dev only: `/healthz`, the `/ws` proxy and the asset route on Vite's own server. */
@@ -9,6 +10,9 @@ function hashsomeDevServer(proxy, config) {
9
10
  return {
10
11
  name: 'hashsome:dev-server',
11
12
  configureServer(server) {
13
+ if (config.homeAssistantCompat) {
14
+ server.middlewares.use(homeAssistantCompat());
15
+ }
12
16
  server.middlewares.use('/healthz', (_req, res) => {
13
17
  res.setHeader('content-type', 'application/json');
14
18
  res.end('{"status":"ok"}');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashsome/runtime",
3
- "version": "0.3.0",
3
+ "version": "0.5.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.5.0",
42
+ "@hashsome/ui": "0.5.0",
43
43
  "@react-router/dev": "8.4.0",
44
44
  "express": "5.2.1",
45
45
  "react": "19.3.0",