@emdzej/bimmerz-ui 0.1.0 → 0.2.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.
- package/README.md +129 -26
- package/package.json +2 -2
- package/src/index.ts +10 -0
- package/src/useEmbeddedAutoConnect.svelte.ts +187 -0
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# @emdzej/bimmerz-ui
|
|
2
2
|
|
|
3
|
-
Shared **Svelte 5** components for the bimmerz app
|
|
4
|
-
|
|
3
|
+
Shared **Svelte 5** components + lifecycle hooks for the bimmerz app
|
|
4
|
+
family (dashx, ediabasx, inpax, ncsx, …). Source-only — consumer
|
|
5
|
+
apps' Vite + svelte-plugin compiles them. No pre-compiled output.
|
|
5
6
|
|
|
6
7
|
## Convention: source-only Svelte libs
|
|
7
8
|
|
|
8
|
-
Svelte components ship as `.svelte` files,
|
|
9
|
+
Svelte components ship as `.svelte` files, rune helpers as
|
|
10
|
+
`.svelte.ts` — both compiled by the consumer's Svelte plugin. The
|
|
9
11
|
`package.json` carries:
|
|
10
12
|
|
|
11
13
|
```json
|
|
@@ -22,18 +24,19 @@ Svelte components ship as `.svelte` files, not pre-compiled JS. The
|
|
|
22
24
|
}
|
|
23
25
|
```
|
|
24
26
|
|
|
25
|
-
The `svelte` export condition is what the consumer's Vite plugin
|
|
26
|
-
locate the source. The `types` condition lets
|
|
27
|
-
the same way without an explicit
|
|
27
|
+
The `svelte` export condition is what the consumer's Vite plugin
|
|
28
|
+
reads to locate the source. The `types` condition lets
|
|
29
|
+
`svelte-check` resolve them the same way without an explicit
|
|
30
|
+
`.d.ts` step.
|
|
28
31
|
|
|
29
|
-
**Why:** Svelte 5's compiler output depends on the consumer's
|
|
30
|
-
version + rune config. Pre-compiling locks the output to
|
|
31
|
-
version the lib was built against and breaks
|
|
32
|
-
consuming app. Letting consumers compile
|
|
33
|
-
mode, every runes setting works.
|
|
32
|
+
**Why:** Svelte 5's compiler output depends on the consumer's
|
|
33
|
+
compiler version + rune config. Pre-compiling locks the output to
|
|
34
|
+
whichever Svelte version the lib was built against and breaks
|
|
35
|
+
tree-shaking + HMR in the consuming app. Letting consumers compile
|
|
36
|
+
means every version, every dev mode, every runes setting works.
|
|
34
37
|
|
|
35
|
-
A consumer's `vite.config.ts` should also tell `optimizeDeps`
|
|
36
|
-
the dev server pre-bundles the source:
|
|
38
|
+
A consumer's `vite.config.ts` should also tell `optimizeDeps`
|
|
39
|
+
about us so the dev server pre-bundles the source:
|
|
37
40
|
|
|
38
41
|
```ts
|
|
39
42
|
optimizeDeps: {
|
|
@@ -43,23 +46,20 @@ optimizeDeps: {
|
|
|
43
46
|
|
|
44
47
|
## Required peer setup
|
|
45
48
|
|
|
46
|
-
Components use semantic colour tokens (`bg-surface`,
|
|
47
|
-
`text-accent`, …) from
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
Components use semantic colour tokens (`bg-surface`,
|
|
50
|
+
`text-foreground`, `text-accent`, `m-stripe`, …) from
|
|
51
|
+
[`@emdzej/bimmerz-theme`](../theme). Wire that preset into the
|
|
52
|
+
consumer app's `tailwind.config.ts` and import its `tokens.css`
|
|
53
|
+
first; the components break visually without those tokens.
|
|
50
54
|
|
|
51
55
|
## Components
|
|
52
56
|
|
|
53
57
|
| Name | Purpose |
|
|
54
58
|
|---|---|
|
|
55
|
-
| `<Brand body
|
|
59
|
+
| `<Brand body suffix class? />` | Split-colour wordmark — body in foreground, suffix in accent. |
|
|
60
|
+
| `<MStripe class? />` | BMW M tricolour bar (light-blue / dark-blue / red) used as the page-top signature. Renders the `.m-stripe` element from `@emdzej/bimmerz-theme/tokens.css`. |
|
|
56
61
|
|
|
57
|
-
|
|
58
|
-
install picker. The existing app-specific implementations in inpax-web /
|
|
59
|
-
ncsx-web / ediabasx-web are slated for consolidation here once their
|
|
60
|
-
shapes stabilise.
|
|
61
|
-
|
|
62
|
-
## Usage
|
|
62
|
+
### `<Brand>`
|
|
63
63
|
|
|
64
64
|
```svelte
|
|
65
65
|
<script lang="ts">
|
|
@@ -67,7 +67,110 @@ shapes stabilise.
|
|
|
67
67
|
</script>
|
|
68
68
|
|
|
69
69
|
<Brand body="EDIABAS" suffix="X" />
|
|
70
|
+
<Brand body="DASH" suffix="X" class="text-2xl" />
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Default styling is `text-sm font-bold tracking-wide`. Pass extra
|
|
74
|
+
Tailwind classes via `class` for hero / welcome-screen variants.
|
|
75
|
+
Each consumer app picks its own `accent` colour via
|
|
76
|
+
`tailwind.config.ts` extend.
|
|
77
|
+
|
|
78
|
+
### `<MStripe>`
|
|
79
|
+
|
|
80
|
+
```svelte
|
|
81
|
+
<script lang="ts">
|
|
82
|
+
import { MStripe } from "@emdzej/bimmerz-ui";
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<MStripe />
|
|
86
|
+
<MStripe class="h-2" /> <!-- thicker variant -->
|
|
70
87
|
```
|
|
71
88
|
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
Renders three bands (`m-stripe__band--light`, `--dark`, `--red`).
|
|
90
|
+
Colours resolve through the `.m-stripe` CSS in
|
|
91
|
+
`@emdzej/bimmerz-theme/tokens.css` — consumer must have those
|
|
92
|
+
tokens imported. Default height is 4 px.
|
|
93
|
+
|
|
94
|
+
## Hooks
|
|
95
|
+
|
|
96
|
+
### `useEmbeddedAutoConnect`
|
|
97
|
+
|
|
98
|
+
Embedded-mode lifecycle hook for every dongle-hosted app. Browser
|
|
99
|
+
builds keep the manual "Connect" button; embedded builds (those
|
|
100
|
+
served by the bimmerz-box at `/dashx/`, `/inpax/`, …) wire this
|
|
101
|
+
hook into `App.svelte` to **auto-connect on open**, **auto-disconnect
|
|
102
|
+
on close**, and **auto-reconnect with exponential backoff** on
|
|
103
|
+
transient transport drops.
|
|
104
|
+
|
|
105
|
+
The hook is a **no-op when `isEmbedded === false`**, so calling it
|
|
106
|
+
unconditionally from `App.svelte` is the intended pattern. Must be
|
|
107
|
+
called inside a Svelte 5 component context (uses `$effect`).
|
|
108
|
+
|
|
109
|
+
```svelte
|
|
110
|
+
<script lang="ts">
|
|
111
|
+
import { useEmbeddedAutoConnect } from "@emdzej/bimmerz-ui";
|
|
112
|
+
import { isEmbedded } from "./lib/embedded";
|
|
113
|
+
import { app } from "./lib/state.svelte";
|
|
114
|
+
import { connect, disconnect } from "./lib/connection.svelte";
|
|
115
|
+
|
|
116
|
+
useEmbeddedAutoConnect({
|
|
117
|
+
isEmbedded,
|
|
118
|
+
connect,
|
|
119
|
+
disconnect,
|
|
120
|
+
/* Optional readiness gate — inpax/ncsx wait for the install to
|
|
121
|
+
load before connecting; dashx/ediabasx can omit this. */
|
|
122
|
+
isReady: () => app.install !== null,
|
|
123
|
+
/* Drives the auto-reconnect loop: when this flips back to
|
|
124
|
+
false (transient Wi-Fi drop, dongle reboot, bus-off), the
|
|
125
|
+
hook re-enters the backoff retry loop. */
|
|
126
|
+
isConnected: () => app.status.kind === "connected",
|
|
127
|
+
});
|
|
128
|
+
</script>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Behaviour:**
|
|
132
|
+
|
|
133
|
+
| Event | Effect |
|
|
134
|
+
|---|---|
|
|
135
|
+
| Mount, `isEmbedded` + `isReady` true | Call `connect()` once. |
|
|
136
|
+
| `connect()` throws | Retry with exponential backoff (1 → 2 → 4 → 8 → 16 → 30 s cap). Reset on success. |
|
|
137
|
+
| `isConnected()` observed false later | Re-enter the backoff retry loop. |
|
|
138
|
+
| `beforeunload` / `pagehide` | Fire-and-forget `disconnect()` so the dongle WebSocket closes cleanly. Both events handled — `pagehide` covers mobile Safari's bfcache. |
|
|
139
|
+
| `isEmbedded` false | Hook does nothing. Manual Connect button stays in charge. |
|
|
140
|
+
|
|
141
|
+
**Options:**
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
interface AutoConnectOptions {
|
|
145
|
+
isEmbedded: boolean;
|
|
146
|
+
connect: () => Promise<void>;
|
|
147
|
+
disconnect: () => Promise<void>;
|
|
148
|
+
isReady?: () => boolean; // default: always ready
|
|
149
|
+
isConnected?: () => boolean; // omit to attempt only once
|
|
150
|
+
maxBackoffMs?: number; // default 30_000
|
|
151
|
+
initialBackoffMs?: number; // default 1_000
|
|
152
|
+
log?: (msg: string, level?: "info" | "warn" | "error") => void;
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Where to use it:**
|
|
157
|
+
|
|
158
|
+
- **dashx-web** — `connect()` opens the RPC CAN session at
|
|
159
|
+
`${origin}/rpc/can/0`. No `isReady` gate needed.
|
|
160
|
+
- **ediabasx-web** — `connect()` opens the ediabasx RPC session at
|
|
161
|
+
`${origin}/rpc/ediabasx`. No `isReady` gate needed.
|
|
162
|
+
- **inpax-web** / **ncsx-web** — `connect()` opens the ediabasx-server
|
|
163
|
+
RPC session over WebSocket. `isReady` should return true once the
|
|
164
|
+
remote install has loaded; otherwise the hook idles until then.
|
|
165
|
+
|
|
166
|
+
**Why centralise this**: every embedded app needs the same
|
|
167
|
+
lifecycle (connect on mount, clean disconnect on close, retry on
|
|
168
|
+
transient failure). Without the shared hook each app drifts —
|
|
169
|
+
different backoff curves, different unload event sets, different
|
|
170
|
+
reconnect semantics. One implementation, four consumers.
|
|
171
|
+
|
|
172
|
+
## Versioning
|
|
173
|
+
|
|
174
|
+
`0.2.0` adds the `useEmbeddedAutoConnect` hook. `Brand` + `MStripe`
|
|
175
|
+
APIs are unchanged from `0.1.x`. SemVer minor — existing consumers
|
|
176
|
+
upgrade without code changes.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emdzej/bimmerz-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Shared Svelte 5 components for the bimmerz app family —
|
|
5
|
+
"description": "Shared Svelte 5 components + lifecycle hooks for the bimmerz app family — brand wordmark, M-stripe, embedded-mode auto-connect hook. Source-only — consumer apps' Vite compiles them.",
|
|
6
6
|
"svelte": "./src/index.ts",
|
|
7
7
|
"types": "./src/index.ts",
|
|
8
8
|
"exports": {
|
package/src/index.ts
CHANGED
|
@@ -14,3 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
export { default as Brand } from "./Brand.svelte";
|
|
16
16
|
export { default as MStripe } from "./MStripe.svelte";
|
|
17
|
+
|
|
18
|
+
/* Embedded-mode lifecycle hook — shared across every dongle-hosted
|
|
19
|
+
app (dashx / ediabasx / inpax / ncsx). Browser builds keep the
|
|
20
|
+
manual Connect button; embedded builds wire this hook into
|
|
21
|
+
App.svelte to auto-connect on mount + auto-disconnect on
|
|
22
|
+
beforeunload + auto-reconnect with backoff on transient drops. */
|
|
23
|
+
export {
|
|
24
|
+
useEmbeddedAutoConnect,
|
|
25
|
+
type AutoConnectOptions,
|
|
26
|
+
} from "./useEmbeddedAutoConnect.svelte.js";
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useEmbeddedAutoConnect` — shared embedded-mode lifecycle hook
|
|
3
|
+
* for every bimmerz app (dashx / ediabasx / inpax / ncsx / …).
|
|
4
|
+
*
|
|
5
|
+
* Embedded builds run inside the bimmerz-box dongle and serve the
|
|
6
|
+
* SPA at a fixed path (`/dashx/`, `/inpax/`, …) with the backend
|
|
7
|
+
* always at `window.location.origin`. The user opening the page
|
|
8
|
+
* explicitly navigated to the dongle — there's no ambiguity about
|
|
9
|
+
* what to connect to, so the "Connect" button is friction the
|
|
10
|
+
* embedded form factor doesn't need.
|
|
11
|
+
*
|
|
12
|
+
* What the hook does, called once from `App.svelte`:
|
|
13
|
+
*
|
|
14
|
+
* 1. **On mount** — if `isEmbedded` is true AND optional
|
|
15
|
+
* `isReady()` gate returns true (inpax/ncsx use this to wait
|
|
16
|
+
* for the install to load), kick off `connect()` once.
|
|
17
|
+
* 2. **Auto-reconnect** — when `isConnected()` is observed to
|
|
18
|
+
* flip back to false (transient Wi-Fi drop, dongle reboot,
|
|
19
|
+
* bus-off recovery), retry with exponential backoff
|
|
20
|
+
* (1 s → 2 → 4 → 8 → 16 → 30 s cap). Successful connect
|
|
21
|
+
* resets the backoff.
|
|
22
|
+
* 3. **On unload / unmount** — call `disconnect()` so the
|
|
23
|
+
* dongle-side WebSocket closes cleanly instead of waiting
|
|
24
|
+
* on TCP keep-alive. Avoids "duplicate session" rejection
|
|
25
|
+
* on the next visit if the dongle tracks single-client
|
|
26
|
+
* sessions per endpoint.
|
|
27
|
+
*
|
|
28
|
+
* The hook is **a no-op when `isEmbedded === false`**. Browser
|
|
29
|
+
* builds keep their manual Connect button — Web Serial picker
|
|
30
|
+
* needs a user gesture anyway, so auto-connect couldn't help.
|
|
31
|
+
*
|
|
32
|
+
* Must be called inside a Svelte 5 component context (uses
|
|
33
|
+
* `$effect`).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export interface AutoConnectOptions {
|
|
37
|
+
/**
|
|
38
|
+
* Whether this build is the dongle-embedded variant. When
|
|
39
|
+
* false the hook does nothing.
|
|
40
|
+
*/
|
|
41
|
+
isEmbedded: boolean;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Open the bus / RPC session. May throw — the hook catches and
|
|
45
|
+
* schedules a retry. Should be idempotent (a second call while
|
|
46
|
+
* already connected should not error or duplicate work).
|
|
47
|
+
*/
|
|
48
|
+
connect: () => Promise<void>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Tear down the connection. Called on `beforeunload`. Should be
|
|
52
|
+
* fast and tolerant of being called while already disconnected.
|
|
53
|
+
*/
|
|
54
|
+
disconnect: () => Promise<void>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reactive readiness check — return true when the app is ready
|
|
58
|
+
* to connect. Used by inpax/ncsx to wait for the install to
|
|
59
|
+
* load; dashx/ediabasx can omit it (always ready).
|
|
60
|
+
*/
|
|
61
|
+
isReady?: () => boolean;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reactive connection-state read. Drives auto-reconnect: when
|
|
65
|
+
* this flips from true to false the hook starts a backoff loop.
|
|
66
|
+
* If omitted the hook only ever attempts a single connect.
|
|
67
|
+
*/
|
|
68
|
+
isConnected?: () => boolean;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Cap on the exponential backoff (ms). Default 30 000.
|
|
72
|
+
*/
|
|
73
|
+
maxBackoffMs?: number;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Initial backoff (ms). Default 1 000.
|
|
77
|
+
*/
|
|
78
|
+
initialBackoffMs?: number;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Optional logger. The hook is intentionally quiet by default —
|
|
82
|
+
* embedded mode runs unattended for hours, log spam isn't useful.
|
|
83
|
+
* Supply this if you want connect attempts surfaced (a project
|
|
84
|
+
* usually wires it to its bimmerz-logger category).
|
|
85
|
+
*/
|
|
86
|
+
log?: (message: string, level?: "info" | "warn" | "error") => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const DEFAULT_INITIAL_BACKOFF_MS = 1_000;
|
|
90
|
+
const DEFAULT_MAX_BACKOFF_MS = 30_000;
|
|
91
|
+
|
|
92
|
+
export function useEmbeddedAutoConnect(options: AutoConnectOptions): void {
|
|
93
|
+
/* Browser builds: do nothing. Calling the hook unconditionally
|
|
94
|
+
from App.svelte is the intended pattern; this guard is what
|
|
95
|
+
makes that cheap. */
|
|
96
|
+
if (!options.isEmbedded) return;
|
|
97
|
+
|
|
98
|
+
const initial = options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS;
|
|
99
|
+
const cap = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
100
|
+
const log = options.log ?? (() => undefined);
|
|
101
|
+
|
|
102
|
+
/* Connection-state loop. $effect re-runs whenever its tracked
|
|
103
|
+
reactive deps (`isReady()` + `isConnected()`) change. Each run
|
|
104
|
+
either:
|
|
105
|
+
• exits silently (already connected, or not ready), or
|
|
106
|
+
• starts an attempt → retry loop until success or unmount.
|
|
107
|
+
The cleanup cancels any pending retry so a re-run doesn't
|
|
108
|
+
stack timers. */
|
|
109
|
+
$effect(() => {
|
|
110
|
+
/* Read both gates synchronously so the effect tracks them. */
|
|
111
|
+
const ready = options.isReady ? options.isReady() : true;
|
|
112
|
+
const connected = options.isConnected ? options.isConnected() : false;
|
|
113
|
+
|
|
114
|
+
if (!ready) {
|
|
115
|
+
log("auto-connect: waiting for ready", "info");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (connected) {
|
|
119
|
+
/* Already connected — nothing to do. The effect re-runs if
|
|
120
|
+
`isConnected()` flips back to false. */
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let cancelled = false;
|
|
125
|
+
let backoff = initial;
|
|
126
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
127
|
+
|
|
128
|
+
const attempt = async (): Promise<void> => {
|
|
129
|
+
if (cancelled) return;
|
|
130
|
+
try {
|
|
131
|
+
log(`auto-connect: attempting (backoff was ${backoff} ms)`, "info");
|
|
132
|
+
await options.connect();
|
|
133
|
+
backoff = initial;
|
|
134
|
+
log("auto-connect: success", "info");
|
|
135
|
+
/* Don't re-loop — the effect's next re-run (driven by
|
|
136
|
+
`isConnected()` going true) is what handles the
|
|
137
|
+
steady-state "connected" branch. */
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
140
|
+
log(`auto-connect: failed (${msg}); retry in ${backoff} ms`, "warn");
|
|
141
|
+
const wait = backoff;
|
|
142
|
+
backoff = Math.min(backoff * 2, cap);
|
|
143
|
+
if (!cancelled) {
|
|
144
|
+
timer = setTimeout(() => {
|
|
145
|
+
timer = null;
|
|
146
|
+
void attempt();
|
|
147
|
+
}, wait);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
void attempt();
|
|
153
|
+
|
|
154
|
+
return () => {
|
|
155
|
+
cancelled = true;
|
|
156
|
+
if (timer !== null) {
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
timer = null;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/* Clean shutdown on tab close. `beforeunload` is the canonical
|
|
164
|
+
signal; we register only in embedded mode so browser builds
|
|
165
|
+
never pay the cost. Disconnect is fire-and-forget — by the
|
|
166
|
+
time the promise resolves the page might be gone, that's
|
|
167
|
+
fine. */
|
|
168
|
+
$effect(() => {
|
|
169
|
+
if (typeof window === "undefined") return;
|
|
170
|
+
const handler = (): void => {
|
|
171
|
+
try {
|
|
172
|
+
void options.disconnect().catch(() => undefined);
|
|
173
|
+
} catch {
|
|
174
|
+
/* swallow */
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
window.addEventListener("beforeunload", handler);
|
|
178
|
+
/* `pagehide` is the mobile-Safari-friendly counterpart — fires
|
|
179
|
+
when the tab is suspended / put into the back/forward cache.
|
|
180
|
+
Adding both is safe; the disconnect is idempotent. */
|
|
181
|
+
window.addEventListener("pagehide", handler);
|
|
182
|
+
return () => {
|
|
183
|
+
window.removeEventListener("beforeunload", handler);
|
|
184
|
+
window.removeEventListener("pagehide", handler);
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
}
|