@hashsome/runtime 0.1.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.
Files changed (69) hide show
  1. package/LICENSE +674 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/app/entry.client.d.ts +1 -0
  4. package/dist/app/entry.client.js +7 -0
  5. package/dist/app/entry.client.js.map +1 -0
  6. package/dist/app/entry.server.d.ts +2 -0
  7. package/dist/app/entry.server.js +20 -0
  8. package/dist/app/entry.server.js.map +1 -0
  9. package/dist/app/index.d.ts +1 -0
  10. package/dist/app/index.js +1 -0
  11. package/dist/app/index.js.map +1 -0
  12. package/dist/app/root.d.ts +18 -0
  13. package/dist/app/root.js +36 -0
  14. package/dist/app/root.js.map +1 -0
  15. package/dist/bin/hashsome.d.ts +2 -0
  16. package/dist/bin/hashsome.js +17 -0
  17. package/dist/bin/hashsome.js.map +1 -0
  18. package/dist/src/cli/build.d.ts +4 -0
  19. package/dist/src/cli/build.js +18 -0
  20. package/dist/src/cli/build.js.map +1 -0
  21. package/dist/src/cli/dev.d.ts +1 -0
  22. package/dist/src/cli/dev.js +19 -0
  23. package/dist/src/cli/dev.js.map +1 -0
  24. package/dist/src/cli/package.d.ts +13 -0
  25. package/dist/src/cli/package.js +115 -0
  26. package/dist/src/cli/package.js.map +1 -0
  27. package/dist/src/cli/start.d.ts +1 -0
  28. package/dist/src/cli/start.js +12 -0
  29. package/dist/src/cli/start.js.map +1 -0
  30. package/dist/src/config.d.ts +36 -0
  31. package/dist/src/config.js +54 -0
  32. package/dist/src/config.js.map +1 -0
  33. package/dist/src/index.d.ts +1 -0
  34. package/dist/src/index.js +8 -0
  35. package/dist/src/index.js.map +1 -0
  36. package/dist/src/release/bundle.d.ts +10 -0
  37. package/dist/src/release/bundle.js +50 -0
  38. package/dist/src/release/bundle.js.map +1 -0
  39. package/dist/src/release/package.d.ts +26 -0
  40. package/dist/src/release/package.js +102 -0
  41. package/dist/src/release/package.js.map +1 -0
  42. package/dist/src/release/render.d.ts +32 -0
  43. package/dist/src/release/render.js +335 -0
  44. package/dist/src/release/render.js.map +1 -0
  45. package/dist/src/serve.d.ts +8 -0
  46. package/dist/src/serve.js +14 -0
  47. package/dist/src/serve.js.map +1 -0
  48. package/dist/src/server/assets.d.ts +10 -0
  49. package/dist/src/server/assets.js +46 -0
  50. package/dist/src/server/assets.js.map +1 -0
  51. package/dist/src/server/integrations.d.ts +12 -0
  52. package/dist/src/server/integrations.js +39 -0
  53. package/dist/src/server/integrations.js.map +1 -0
  54. package/dist/src/server/production.d.ts +7 -0
  55. package/dist/src/server/production.js +45 -0
  56. package/dist/src/server/production.js.map +1 -0
  57. package/dist/src/server/proxy.d.ts +34 -0
  58. package/dist/src/server/proxy.js +165 -0
  59. package/dist/src/server/proxy.js.map +1 -0
  60. package/dist/src/server/websocket.d.ts +5 -0
  61. package/dist/src/server/websocket.js +17 -0
  62. package/dist/src/server/websocket.js.map +1 -0
  63. package/dist/src/vite-config.d.ts +12 -0
  64. package/dist/src/vite-config.js +35 -0
  65. package/dist/src/vite-config.js.map +1 -0
  66. package/dist/src/vite.d.ts +5 -0
  67. package/dist/src/vite.js +42 -0
  68. package/dist/src/vite.js.map +1 -0
  69. package/package.json +73 -0
@@ -0,0 +1,335 @@
1
+ /** The files of a release, as text. Pure: `package.ts` writes them out. */
2
+ export const TARGETS = ['plain', 'compose', 'helm', 'image'];
3
+ export const imageRef = ({ name, tag }) => `${name}:${tag}`;
4
+ /** A project name made safe for an image, a service and a chart. */
5
+ export function safeName(raw) {
6
+ const name = raw
7
+ .replace(/^@[^/]+\//, '')
8
+ .toLowerCase()
9
+ .replace(/[^a-z0-9-]+/g, '-')
10
+ .replace(/^-+|-+$/g, '');
11
+ return name || 'hashsome';
12
+ }
13
+ export function dockerfile({ port }) {
14
+ return `# The release's server and client: nothing is installed or compiled here.
15
+ FROM node:24-slim
16
+ ENV NODE_ENV=production PORT=${port}
17
+ WORKDIR /app
18
+ COPY server.mjs ./
19
+ COPY client ./client
20
+ USER node
21
+ EXPOSE ${port}
22
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/healthz').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"
23
+ CMD ["node", "server.mjs"]
24
+ `;
25
+ }
26
+ export function plainReadme(info) {
27
+ return `# ${info.name}: plain
28
+
29
+ The whole app as files: \`server.mjs\` (the server, with your config and integrations inside it) and
30
+ \`client/\` (your dashboards). Run it anywhere Node 24 or newer is installed, no \`npm install\`:
31
+
32
+ \`\`\`sh
33
+ HA_URL=... HA_TOKEN=... node server.mjs
34
+ \`\`\`
35
+
36
+ It listens on \`PORT\` (default ${info.port}) and \`HOST\` (default 0.0.0.0). Everything the config
37
+ reads from the environment (tokens and addresses) is set when it runs; none is in these files.
38
+ \`/healthz\` answers while it is up.
39
+
40
+ The static \`client/\` alone is not enough: the server keeps the tokens, talks to your backends and
41
+ serves their artwork, and the browser only ever talks to it.
42
+
43
+ If your backend uses a private certificate authority, give Node its certificate:
44
+ \`NODE_EXTRA_CA_CERTS=/path/to/ca.crt node server.mjs\`.
45
+
46
+ \`Dockerfile\` is the one \`compose\` and \`helm\` use: \`docker build -t ${info.name} .\` here builds the
47
+ image yourself.
48
+ `;
49
+ }
50
+ export function composeFile(info) {
51
+ return `# docker compose up -d (after: docker load -i ../image.tar, and a .env next to this file)
52
+ services:
53
+ ${info.name}:
54
+ image: ${imageRef(info)}
55
+ restart: unless-stopped
56
+ ports:
57
+ - "${info.port}:${info.port}"
58
+ # Your tokens and addresses, read at runtime. Never baked into the image.
59
+ env_file: .env
60
+ # A private certificate authority for your backend:
61
+ # environment:
62
+ # NODE_EXTRA_CA_CERTS: /certs/ca.crt
63
+ # volumes:
64
+ # - ./ca.crt:/certs/ca.crt:ro
65
+ `;
66
+ }
67
+ export function composeReadme(info) {
68
+ return `# ${info.name}: Docker Compose
69
+
70
+ 1. Load the image: \`docker load -i ../image.tar\`${info.image ? '' : ' (this release has no image: build one from a `plain` release, `docker build plain`)'}
71
+ 2. Create \`.env\` here with your tokens and addresses (see \`.env.example\`)
72
+ 3. \`docker compose up -d\`
73
+
74
+ Update by loading the next release's image and changing the tag in \`compose.yaml\`.
75
+ `;
76
+ }
77
+ export function envExample(info) {
78
+ return (info.envExample ??
79
+ '# The variables your hashsome.config.ts reads, for example:\n# HA_URL=\n# HA_TOKEN=\n');
80
+ }
81
+ // ── Helm ─────────────────────────────────────────────────────────────────────────────────────────
82
+ export function chartYaml(info) {
83
+ return `apiVersion: v2
84
+ name: ${info.name}
85
+ description: ${info.name}, a Hashsome dashboard server
86
+ type: application
87
+ version: 0.1.0
88
+ appVersion: "${info.tag}"
89
+ `;
90
+ }
91
+ export function chartValues(info) {
92
+ return `# The image is loaded onto the node from image.tar (see import-image.sh), not pulled, hence Never.
93
+ image:
94
+ repository: ${info.name}
95
+ tag: "${info.tag}"
96
+ pullPolicy: Never
97
+
98
+ port: ${info.port}
99
+
100
+ # Your tokens and addresses, in a Secret you create (see NOTES). The chart never holds their values.
101
+ existingSecret: ""
102
+
103
+ # Extra environment variables that are not secret.
104
+ env: {}
105
+
106
+ # A private certificate authority for your backend: a ConfigMap holding the certificate.
107
+ caCert:
108
+ configMap: ""
109
+ key: ca.crt
110
+
111
+ service:
112
+ type: ClusterIP
113
+ port: 80
114
+
115
+ ingress:
116
+ enabled: false
117
+ className: traefik
118
+ host: ""
119
+ # A TLS secret for the host; leave empty for plain http.
120
+ tlsSecret: ""
121
+ annotations: {}
122
+
123
+ resources: {}
124
+ nodeSelector: {}
125
+ `;
126
+ }
127
+ export function chartHelpers() {
128
+ return `{{- define "app.name" -}}{{ .Release.Name }}{{- end -}}
129
+ {{- define "app.labels" -}}
130
+ app.kubernetes.io/name: {{ .Chart.Name }}
131
+ app.kubernetes.io/instance: {{ .Release.Name }}
132
+ {{- end -}}
133
+ {{- define "app.selector" -}}
134
+ app.kubernetes.io/name: {{ .Chart.Name }}
135
+ app.kubernetes.io/instance: {{ .Release.Name }}
136
+ {{- end -}}
137
+ `;
138
+ }
139
+ export function chartDeployment() {
140
+ return `apiVersion: apps/v1
141
+ kind: Deployment
142
+ metadata:
143
+ name: {{ include "app.name" . }}
144
+ labels:
145
+ {{- include "app.labels" . | nindent 4 }}
146
+ spec:
147
+ # One replica, and the old pod goes before the new one starts: the server holds live
148
+ # connections to your backends, and two at once would just double them.
149
+ replicas: 1
150
+ strategy:
151
+ type: Recreate
152
+ selector:
153
+ matchLabels:
154
+ {{- include "app.selector" . | nindent 6 }}
155
+ template:
156
+ metadata:
157
+ labels:
158
+ {{- include "app.selector" . | nindent 8 }}
159
+ spec:
160
+ containers:
161
+ - name: app
162
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
163
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
164
+ ports:
165
+ - name: http
166
+ containerPort: {{ .Values.port }}
167
+ envFrom:
168
+ - secretRef:
169
+ name: {{ required "set existingSecret to the Secret holding your tokens" .Values.existingSecret }}
170
+ env:
171
+ - name: PORT
172
+ value: {{ .Values.port | quote }}
173
+ {{- if .Values.caCert.configMap }}
174
+ - name: NODE_EXTRA_CA_CERTS
175
+ value: /certs/{{ .Values.caCert.key }}
176
+ {{- end }}
177
+ {{- range $name, $value := .Values.env }}
178
+ - name: {{ $name }}
179
+ value: {{ $value | quote }}
180
+ {{- end }}
181
+ readinessProbe:
182
+ httpGet:
183
+ path: /healthz
184
+ port: http
185
+ livenessProbe:
186
+ httpGet:
187
+ path: /healthz
188
+ port: http
189
+ initialDelaySeconds: 10
190
+ {{- with .Values.resources }}
191
+ resources:
192
+ {{- toYaml . | nindent 12 }}
193
+ {{- end }}
194
+ {{- if .Values.caCert.configMap }}
195
+ volumeMounts:
196
+ - name: ca
197
+ mountPath: /certs
198
+ readOnly: true
199
+ {{- end }}
200
+ {{- if .Values.caCert.configMap }}
201
+ volumes:
202
+ - name: ca
203
+ configMap:
204
+ name: {{ .Values.caCert.configMap }}
205
+ {{- end }}
206
+ {{- with .Values.nodeSelector }}
207
+ nodeSelector:
208
+ {{- toYaml . | nindent 8 }}
209
+ {{- end }}
210
+ `;
211
+ }
212
+ export function chartService() {
213
+ return `apiVersion: v1
214
+ kind: Service
215
+ metadata:
216
+ name: {{ include "app.name" . }}
217
+ labels:
218
+ {{- include "app.labels" . | nindent 4 }}
219
+ spec:
220
+ type: {{ .Values.service.type }}
221
+ selector:
222
+ {{- include "app.selector" . | nindent 4 }}
223
+ ports:
224
+ - name: http
225
+ port: {{ .Values.service.port }}
226
+ targetPort: http
227
+ `;
228
+ }
229
+ export function chartIngress() {
230
+ return `{{- if .Values.ingress.enabled }}
231
+ apiVersion: networking.k8s.io/v1
232
+ kind: Ingress
233
+ metadata:
234
+ name: {{ include "app.name" . }}
235
+ labels:
236
+ {{- include "app.labels" . | nindent 4 }}
237
+ {{- with .Values.ingress.annotations }}
238
+ annotations:
239
+ {{- toYaml . | nindent 4 }}
240
+ {{- end }}
241
+ spec:
242
+ {{- if .Values.ingress.className }}
243
+ ingressClassName: {{ .Values.ingress.className }}
244
+ {{- end }}
245
+ {{- if .Values.ingress.tlsSecret }}
246
+ tls:
247
+ - hosts:
248
+ - {{ required "set ingress.host" .Values.ingress.host }}
249
+ secretName: {{ .Values.ingress.tlsSecret }}
250
+ {{- end }}
251
+ rules:
252
+ - host: {{ required "set ingress.host" .Values.ingress.host }}
253
+ http:
254
+ paths:
255
+ - path: /
256
+ pathType: Prefix
257
+ backend:
258
+ service:
259
+ name: {{ include "app.name" . }}
260
+ port:
261
+ name: http
262
+ {{- end }}
263
+ `;
264
+ }
265
+ export function chartNotes() {
266
+ return `{{ .Release.Name }} is deployed.
267
+
268
+ Your tokens and addresses come from the Secret "{{ .Values.existingSecret }}". Create it once, with the
269
+ variables your hashsome.config.ts reads (it is not part of this chart, so it is not in git):
270
+
271
+ kubectl create secret generic {{ .Values.existingSecret }} -n {{ .Release.Namespace }} \\
272
+ --from-literal=HA_URL=https://... --from-literal=HA_TOKEN=...
273
+
274
+ To change a token later, update the Secret and run: kubectl rollout restart deploy/{{ include "app.name" . }} -n {{ .Release.Namespace }}
275
+ {{- if .Values.ingress.enabled }}
276
+
277
+ It is served at http{{ if .Values.ingress.tlsSecret }}s{{ end }}://{{ .Values.ingress.host }}/
278
+ {{- end }}
279
+ `;
280
+ }
281
+ export function importScript(info) {
282
+ return `#!/bin/sh
283
+ # Loads the release's image into k3s's containerd on this machine. Run it on the server, from here.
284
+ set -eu
285
+ cd "$(dirname "$0")"
286
+ sudo k3s ctr -n k8s.io images import ../image.tar
287
+ echo "Imported ${imageRef(info)}"
288
+ `;
289
+ }
290
+ export function helmReadme(info) {
291
+ return `# ${info.name}: Helm (k3s)
292
+
293
+ This folder is a Helm chart. Put it in your cluster's folder and add it to your helmfile.
294
+
295
+ 1. On the server, load the image into k3s: \`./import-image.sh\`
296
+ (it imports \`../image.tar\`, so keep the release folder together, or edit the path)
297
+ 2. Create the Secret with your tokens, once (see \`templates/NOTES.txt\`):
298
+ \`kubectl create secret generic ${info.name}-secrets --from-literal=HA_URL=... --from-literal=HA_TOKEN=...\`
299
+ 3. Add a release to your helmfile:
300
+
301
+ \`\`\`yaml
302
+ releases:
303
+ - name: ${info.name}
304
+ chart: ./${info.name} # this folder
305
+ values:
306
+ - existingSecret: ${info.name}-secrets
307
+ ingress:
308
+ enabled: true
309
+ host: ${info.name}.lan
310
+ \`\`\`
311
+
312
+ 4. \`helmfile apply\`
313
+
314
+ The image tag is \`${info.tag}\` (in \`values.yaml\`). A new release has a new tag, so k3s always picks
315
+ up the update. Built for \`${info.platform}\`; \`pullPolicy\` is \`Never\` because the image is imported,
316
+ not pulled.
317
+ `;
318
+ }
319
+ export function releaseReadme(info, targets) {
320
+ const lines = {
321
+ plain: '- `plain/`: the server bundle and the client. Runs with just Node, or builds into an image.',
322
+ compose: '- `compose/`: Docker Compose, for a machine running Docker.',
323
+ helm: '- `helm/`: a Helm chart for k3s/Kubernetes (loads the image, no registry).',
324
+ image: '- `image.tar`: just the container image, for your own manifests.',
325
+ };
326
+ return `# ${info.name} ${info.tag}
327
+
328
+ Built for \`${info.platform}\`. Deploy it as:
329
+
330
+ ${targets.map((t) => lines[t]).join('\n')}
331
+ ${info.image && !targets.includes('image') ? `- \`image.tar\`: the container image (\`${imageRef(info)}\`), for compose and helm.\n` : ''}
332
+ Your tokens and addresses are not in any of these files. They are set where it runs: an \`.env\`
333
+ file for compose, a Kubernetes Secret for helm, the environment for plain.
334
+ `;
335
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.js","sourceRoot":"","sources":["../../../src/release/render.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ import { type HashsomeConfig } from './config.ts';
2
+ /**
3
+ * The entry of a packaged release: runs the server for an already-imported config, serving the
4
+ * client that sits next to the bundle (`./client`). `hashsome package` bundles this together with
5
+ * the project's `hashsome.config.ts` and its integrations into one file, so nothing else is installed
6
+ * where it runs.
7
+ */
8
+ export declare function serve(user: HashsomeConfig, bundleUrl: string): void;
@@ -0,0 +1,14 @@
1
+ import { dirname } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { resolveConfig } from './config.js';
4
+ import { startServer } from './server/production.js';
5
+ /**
6
+ * The entry of a packaged release: runs the server for an already-imported config, serving the
7
+ * client that sits next to the bundle (`./client`). `hashsome package` bundles this together with
8
+ * the project's `hashsome.config.ts` and its integrations into one file, so nothing else is installed
9
+ * where it runs.
10
+ */
11
+ export function serve(user, bundleUrl) {
12
+ const here = dirname(fileURLToPath(bundleUrl));
13
+ startServer(resolveConfig(here, user), `${here}/client`);
14
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.js","sourceRoot":"","sources":["../../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,aAAa,EAAuB,MAAM,aAAa,CAAC;AACjE,OAAO,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAErD;;;;;GAKG;AACH,MAAM,UAAU,KAAK,CAAC,IAAoB,EAAE,SAAiB;IAC3D,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC;IAC/C,WAAW,CAAC,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,GAAG,IAAI,SAAS,CAAC,CAAC;AAC3D,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { type Integration } from '@hashsome/core';
3
+ /**
4
+ * Serves `GET /_hashsome/asset/<integration>?path=<backend path>`: a file one of an integration's
5
+ * entities points at (artwork, a person's picture), fetched by the server with the integration's
6
+ * credentials so the browser never needs to reach, trust or log in to the backend. Only image
7
+ * responses are passed on. Returns `false` for a request that is not an asset request, so the
8
+ * caller can carry on.
9
+ */
10
+ export declare function serveAsset(integrations: Integration[], request: Pick<IncomingMessage, 'method' | 'url'>, response: ServerResponse): Promise<boolean>;
@@ -0,0 +1,46 @@
1
+ import { ASSET_PATH } from '@hashsome/core';
2
+ /** How long a browser may reuse a file; artwork URLs change when the picture does. */
3
+ const CACHE_CONTROL = 'private, max-age=3600';
4
+ /**
5
+ * Serves `GET /_hashsome/asset/<integration>?path=<backend path>`: a file one of an integration's
6
+ * entities points at (artwork, a person's picture), fetched by the server with the integration's
7
+ * credentials so the browser never needs to reach, trust or log in to the backend. Only image
8
+ * responses are passed on. Returns `false` for a request that is not an asset request, so the
9
+ * caller can carry on.
10
+ */
11
+ export async function serveAsset(integrations, request, response) {
12
+ const url = new URL(request.url ?? '/', 'http://localhost');
13
+ if (!url.pathname.startsWith(`${ASSET_PATH}/`)) {
14
+ return false;
15
+ }
16
+ const fail = (status, message) => {
17
+ response.statusCode = status;
18
+ response.setHeader('content-type', 'text/plain');
19
+ response.end(message);
20
+ return true;
21
+ };
22
+ if (request.method !== 'GET') {
23
+ return fail(405, 'Method not allowed');
24
+ }
25
+ const id = decodeURIComponent(url.pathname.slice(ASSET_PATH.length + 1));
26
+ const path = url.searchParams.get('path');
27
+ const integration = integrations.find((candidate) => candidate.id === id);
28
+ if (!integration?.fetchAsset || !path) {
29
+ return fail(404, 'No such asset');
30
+ }
31
+ try {
32
+ const upstream = await integration.fetchAsset(path);
33
+ const type = upstream.headers.get('content-type') ?? '';
34
+ if (!upstream.ok || !type.startsWith('image/')) {
35
+ return fail(upstream.ok ? 415 : 404, 'No such asset');
36
+ }
37
+ response.statusCode = 200;
38
+ response.setHeader('content-type', type);
39
+ response.setHeader('cache-control', CACHE_CONTROL);
40
+ response.end(Buffer.from(await upstream.arrayBuffer()));
41
+ return true;
42
+ }
43
+ catch {
44
+ return fail(404, 'No such asset');
45
+ }
46
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assets.js","sourceRoot":"","sources":["../../../src/server/assets.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAoB,MAAM,gBAAgB,CAAC;AAE9D,sFAAsF;AACtF,MAAM,aAAa,GAAG,uBAAuB,CAAC;AAE9C;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,YAA2B,EAC3B,OAAgD,EAChD,QAAwB;IAExB,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,IAAI,GAAG,EAAE,kBAAkB,CAAC,CAAC;IAC5D,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,UAAU,CAAC,GAAG,UAAU,GAAG,CAAC,EAAE,CAAC;QAC/C,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,IAAI,GAAG,CAAC,MAAc,EAAE,OAAe,EAAE,EAAE;QAC/C,QAAQ,CAAC,UAAU,GAAG,MAAM,CAAC;QAC7B,QAAQ,CAAC,SAAS,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC;QACjD,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACtB,OAAO,IAAI,CAAC;IACd,CAAC,CAAC;IAEF,IAAI,OAAO,CAAC,MAAM,KAAK,KAAK,EAAE,CAAC;QAC7B,OAAO,IAAI,CAAC,GAAG,EAAE,oBAAoB,CAAC,CAAC;IACzC,CAAC;IAED,MAAM,EAAE,GAAG,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC;IACzE,MAAM,IAAI,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAC1C,MAAM,WAAW,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;IAC1E,IAAI,CAAC,WAAW,EAAE,UAAU,IAAI,CAAC,IAAI,EAAE,CAAC;QACtC,OAAO,IAAI,CAAC,GAAG,EAAE,eAAe,CAAC,CAAC;IACpC,CAAC;IAED,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,WAAW,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;QACpD,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC;QACxD,IAAI,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC/C,OAAO,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,eAAe,CAAC,CAAC;QACxD,CAAC;QAED,QAAQ,CAAC,UAAU,GAAG,GAAG,CAAC;QAC1B,QAAQ,CAAC,SAAS,CAAC,cAAc,EAAE,IAAI,CAAC,CAAC;QACzC,QAAQ,CAAC,SAAS,CAAC,eAAe,EAAE,aAAa,CAAC,CAAC;QACnD,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;QACxD,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC,GAAG,EAAE,eAAe,CAAC,CAAC;IACpC,CAAC;AACH,CAAC"}
@@ -0,0 +1,12 @@
1
+ import type { Integration } from '@hashsome/core';
2
+ export interface StartOptions {
3
+ log?: (message: string) => void;
4
+ minDelayMs?: number;
5
+ maxDelayMs?: number;
6
+ }
7
+ /**
8
+ * Connects every integration, retrying with exponential backoff until it
9
+ * succeeds. Never throws: an unreachable Home Assistant must not stop the
10
+ * server (dashboards show "unavailable" and recover).
11
+ */
12
+ export declare function startIntegrations(integrations: Integration[], options?: StartOptions): () => void;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Connects every integration, retrying with exponential backoff until it
3
+ * succeeds. Never throws: an unreachable Home Assistant must not stop the
4
+ * server (dashboards show "unavailable" and recover).
5
+ */
6
+ export function startIntegrations(integrations, options = {}) {
7
+ const log = options.log ?? (() => { });
8
+ const min = options.minDelayMs ?? 1000;
9
+ const max = options.maxDelayMs ?? 30_000;
10
+ const timers = new Set();
11
+ let stopped = false;
12
+ const attempt = (integration, retry) => {
13
+ if (stopped) {
14
+ return;
15
+ }
16
+ integration.connect().then(() => log(`[${integration.id}] connected`), (error) => {
17
+ const delay = Math.min(max, min * 2 ** retry);
18
+ const reason = error instanceof Error ? error.message : String(error);
19
+ log(`[${integration.id}] connect failed (${reason}); retrying in ${delay}ms`);
20
+ const timer = setTimeout(() => {
21
+ timers.delete(timer);
22
+ attempt(integration, retry + 1);
23
+ }, delay);
24
+ timers.add(timer);
25
+ });
26
+ };
27
+ for (const integration of integrations) {
28
+ attempt(integration, 0);
29
+ }
30
+ return () => {
31
+ stopped = true;
32
+ for (const timer of timers) {
33
+ clearTimeout(timer);
34
+ }
35
+ for (const integration of integrations) {
36
+ integration.disconnect();
37
+ }
38
+ };
39
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"integrations.js","sourceRoot":"","sources":["../../../src/server/integrations.ts"],"names":[],"mappings":"AAQA;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,YAA2B,EAAE,OAAO,GAAiB,EAAE;IACvF,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACtC,MAAM,GAAG,GAAG,OAAO,CAAC,UAAU,IAAI,IAAI,CAAC;IACvC,MAAM,GAAG,GAAG,OAAO,CAAC,UAAU,IAAI,MAAM,CAAC;IACzC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAiC,CAAC;IACxD,IAAI,OAAO,GAAG,KAAK,CAAC;IAEpB,MAAM,OAAO,GAAG,CAAC,WAAwB,EAAE,KAAa,EAAE,EAAE;QAC1D,IAAI,OAAO,EAAE,CAAC;YACZ,OAAO;QACT,CAAC;QAED,WAAW,CAAC,OAAO,EAAE,CAAC,IAAI,CACxB,GAAG,EAAE,CAAC,GAAG,CAAC,IAAI,WAAW,CAAC,EAAE,aAAa,CAAC,EAC1C,CAAC,KAAc,EAAE,EAAE;YACjB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,GAAG,CAAC,IAAI,KAAK,CAAC,CAAC;YAC9C,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACtE,GAAG,CAAC,IAAI,WAAW,CAAC,EAAE,qBAAqB,MAAM,kBAAkB,KAAK,IAAI,CAAC,CAAC;YAC9E,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;gBAC5B,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBACrB,OAAO,CAAC,WAAW,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC;YAClC,CAAC,EAAE,KAAK,CAAC,CAAC;YAEV,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACpB,CAAC,CACF,CAAC;IACJ,CAAC,CAAC;IAEF,KAAK,MAAM,WAAW,IAAI,YAAY,EAAE,CAAC;QACvC,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;IAC1B,CAAC;IAED,OAAO,GAAG,EAAE;QACV,OAAO,GAAG,IAAI,CAAC;QACf,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,YAAY,CAAC,KAAK,CAAC,CAAC;QACtB,CAAC;QAED,KAAK,MAAM,WAAW,IAAI,YAAY,EAAE,CAAC;YACvC,WAAW,CAAC,UAAU,EAAE,CAAC;QAC3B,CAAC;IACH,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,7 @@
1
+ import type { Integration } from '@hashsome/core';
2
+ import { type Express } from 'express';
3
+ import type { ResolvedConfig } from '../config.ts';
4
+ export declare function createApp(clientDir: string, integrations?: Integration[]): Express;
5
+ /** Where `hashsome build` puts the client, under a project. A packaged release keeps it elsewhere. */
6
+ export declare const BUILT_CLIENT: string;
7
+ export declare function startServer(config: ResolvedConfig, clientDir?: string): import("http").Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>;
@@ -0,0 +1,45 @@
1
+ import express, {} from 'express';
2
+ import { createServer } from 'node:http';
3
+ import { join } from 'node:path';
4
+ import { serveAsset } from './assets.js';
5
+ import { Proxy } from './proxy.js';
6
+ import { startIntegrations } from './integrations.js';
7
+ import { attachWebSocket } from './websocket.js';
8
+ export function createApp(clientDir, integrations = []) {
9
+ const app = express();
10
+ app.disable('x-powered-by');
11
+ app.get('/healthz', (_req, res) => {
12
+ res.json({ status: 'ok' });
13
+ });
14
+ app.use((req, res, next) => {
15
+ serveAsset(integrations, req, res).then((served) => (served ? undefined : next()), next);
16
+ });
17
+ app.get('/favicon.ico', (_req, res) => {
18
+ res.status(204).end();
19
+ });
20
+ app.use(express.static(clientDir, { index: false, maxAge: '1h' }));
21
+ // SPA fallback: client-side routing handles /{id}/...
22
+ app.use((_req, res) => {
23
+ res.sendFile(join(clientDir, 'index.html'));
24
+ });
25
+ return app;
26
+ }
27
+ /** Where `hashsome build` puts the client, under a project. A packaged release keeps it elsewhere. */
28
+ export const BUILT_CLIENT = join('build', 'client');
29
+ export function startServer(config, clientDir = join(config.root, BUILT_CLIENT)) {
30
+ const server = createServer(createApp(clientDir, config.integrations));
31
+ const proxy = new Proxy(config.integrations, { log: console.log });
32
+ attachWebSocket(server, proxy);
33
+ const stop = startIntegrations(config.integrations, { log: console.log });
34
+ server.listen(config.port, config.host, () => {
35
+ console.log(`hashsome listening on http://${config.host}:${config.port}`);
36
+ });
37
+ const shutdown = () => {
38
+ stop();
39
+ server.close(() => process.exit(0));
40
+ server.closeAllConnections();
41
+ };
42
+ process.on('SIGINT', shutdown);
43
+ process.on('SIGTERM', shutdown);
44
+ return server;
45
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"production.js","sourceRoot":"","sources":["../../../src/server/production.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ import { type Integration } from '@hashsome/core';
2
+ /** The subset of a `ws` WebSocket the proxy needs. */
3
+ export interface ProxySocket {
4
+ send(data: string): void;
5
+ on(event: 'message', listener: (data: {
6
+ toString(): string;
7
+ }) => void): unknown;
8
+ on(event: 'close' | 'error', listener: () => void): unknown;
9
+ readonly readyState: number;
10
+ }
11
+ /**
12
+ * Multiplexes browser connections onto server-held integrations. Tokens never
13
+ * reach the browser; current states are replayed from the integrations' stores
14
+ * so a waking tablet gets a full picture immediately.
15
+ */
16
+ export interface ProxyOptions {
17
+ /** Where problems worth a line in the server log go (an unknown ref, a failed subscribe). */
18
+ log?: (message: string) => void;
19
+ }
20
+ /**
21
+ * Multiplexes browser connections onto server-held integrations. Tokens never
22
+ * reach the browser; current entities are replayed from the integrations' stores
23
+ * so a waking tablet gets a full picture immediately.
24
+ *
25
+ * The proxy does all the addressing: it parses `<integration>:<id>` and hands the integration only
26
+ * its local id. A subscription is resolved once the owning integration is connected — until then
27
+ * the browser has no answer (a component shows its loading state) — and an id the integration does
28
+ * not have is answered with `entity: null` and logged.
29
+ */
30
+ export declare class Proxy {
31
+ #private;
32
+ constructor(integrations: Integration[], options?: ProxyOptions);
33
+ handleConnection(socket: ProxySocket): void;
34
+ }