@hashsome/runtime 0.4.0 → 0.6.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.
@@ -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 = `
@@ -5,7 +5,7 @@ export {};
5
5
  const commands = {
6
6
  dev: async () => (await import('../src/cli/dev.js')).dev(),
7
7
  build: async () => (await import('../src/cli/build.js')).build(),
8
- start: async () => (await import('../src/cli/start.js')).start(),
8
+ start: async () => (await import('../src/cli/start.js')).start(process.argv.slice(3)),
9
9
  package: async () => (await import('../src/cli/package.js')).packageCommand(process.argv.slice(3)),
10
10
  upgrade: async () => (await import('../src/cli/upgrade.js')).upgradeCommandLine(process.argv.slice(3)),
11
11
  };
@@ -1 +1,5 @@
1
- export declare function start(): Promise<void>;
1
+ /** `start` builds first, so what it serves is the project as it is now and with the environment it is
2
+ * started with (the debug menu's `HASHSOME_DEBUG`, say, is part of the build); `--no-build` serves
3
+ * what is already built. */
4
+ export declare function shouldBuild(args: string[]): boolean;
5
+ export declare function start(args?: string[]): Promise<void>;
@@ -2,10 +2,20 @@ import { existsSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { loadConfig } from '../config.js';
4
4
  import { startServer } from '../server/production.js';
5
- export async function start() {
5
+ /** `start` builds first, so what it serves is the project as it is now and with the environment it is
6
+ * started with (the debug menu's `HASHSOME_DEBUG`, say, is part of the build); `--no-build` serves
7
+ * what is already built. */
8
+ export function shouldBuild(args) {
9
+ return !args.includes('--no-build');
10
+ }
11
+ export async function start(args = []) {
12
+ if (shouldBuild(args)) {
13
+ // Loaded only for this: serving a build needs none of Vite.
14
+ await (await import('./build.js')).build({ exit: false });
15
+ }
6
16
  const config = await loadConfig(resolve(process.cwd()));
7
17
  if (!existsSync(join(config.root, 'build', 'client', 'index.html'))) {
8
- console.error('No build found. Run `hashsome build` first.');
18
+ console.error('No build found. Run `hashsome build` first, or start without `--no-build`.');
9
19
  process.exit(1);
10
20
  }
11
21
  startServer(config);
@@ -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>;
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { VERSION_FILE, VERSION_PATH } from '../build-id.js';
6
6
  import { serveAsset } from './assets.js';
7
+ import { homeAssistantCompat } from './ha-compat.js';
7
8
  import { Proxy } from './proxy.js';
8
9
  import { startIntegrations } from './integrations.js';
9
10
  import { attachWebSocket } from './websocket.js';
@@ -17,9 +18,12 @@ function readVersion(clientDir) {
17
18
  return undefined;
18
19
  }
19
20
  }
20
- export function createApp(clientDir, integrations = []) {
21
+ export function createApp(clientDir, integrations = [], options = {}) {
21
22
  const app = express();
22
23
  app.disable('x-powered-by');
24
+ if (options.homeAssistantCompat) {
25
+ app.use(homeAssistantCompat());
26
+ }
23
27
  app.get('/healthz', (_req, res) => {
24
28
  res.json({ status: 'ok' });
25
29
  });
@@ -51,7 +55,9 @@ export function createApp(clientDir, integrations = []) {
51
55
  /** Where `hashsome build` puts the client, under a project. A packaged release keeps it elsewhere. */
52
56
  export const BUILT_CLIENT = join('build', 'client');
53
57
  export function startServer(config, clientDir = join(config.root, BUILT_CLIENT)) {
54
- const server = createServer(createApp(clientDir, config.integrations));
58
+ const server = createServer(createApp(clientDir, config.integrations, {
59
+ homeAssistantCompat: config.homeAssistantCompat,
60
+ }));
55
61
  const proxy = new Proxy(config.integrations, { log: console.log });
56
62
  attachWebSocket(server, proxy);
57
63
  const stop = startIntegrations(config.integrations, { log: console.log });
@@ -1,5 +1,7 @@
1
1
  import { type InlineConfig } from 'vite';
2
2
  import type { ResolvedConfig } from './config.ts';
3
3
  import type { Proxy } from './server/proxy.ts';
4
+ /** Whether `HASHSOME_DEBUG` asks for the debug menu: `1` or `true`. */
5
+ export declare function debugRequested(env?: NodeJS.ProcessEnv): boolean;
4
6
  /** `proxy` is only passed for `dev`; `build` needs no server settings (its prerender starts a private preview server). */
5
7
  export declare function createViteConfig(config: ResolvedConfig, proxy?: Proxy): InlineConfig;
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"}');
@@ -22,11 +26,19 @@ function hashsomeDevServer(proxy, config) {
22
26
  },
23
27
  };
24
28
  }
29
+ /** Whether `HASHSOME_DEBUG` asks for the debug menu: `1` or `true`. */
30
+ export function debugRequested(env = process.env) {
31
+ const asked = (env['HASHSOME_DEBUG'] ?? '').toLowerCase();
32
+ return asked === '1' || asked === 'true';
33
+ }
25
34
  /** `proxy` is only passed for `dev`; `build` needs no server settings (its prerender starts a private preview server). */
26
35
  export function createViteConfig(config, proxy) {
27
36
  return {
28
37
  root: config.root,
29
38
  configFile: join(config.root, 'vite.config.ts'),
39
+ // `HASHSOME_DEBUG=1` in the environment shows the debug menu: a constant `@hashsome/ui` reads,
40
+ // replaced in the code Vite serves and builds, the packages' own included.
41
+ define: { HASHSOME_DEBUG_ON: JSON.stringify(debugRequested()) },
30
42
  ...(proxy
31
43
  ? {
32
44
  plugins: [hashsomeDevServer(proxy, config)],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashsome/runtime",
3
- "version": "0.4.0",
3
+ "version": "0.6.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.4.0",
42
- "@hashsome/ui": "0.4.0",
41
+ "@hashsome/core": "0.6.0",
42
+ "@hashsome/ui": "0.6.0",
43
43
  "@react-router/dev": "8.4.0",
44
44
  "express": "5.2.1",
45
45
  "react": "19.3.0",