@remit/web-client 0.0.323 → 0.0.324
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/package.json +1 -1
- package/src/auth/better-auth-config.ts +3 -10
- package/src/components/settings/self-update.stories.tsx +18 -0
- package/src/lib/session-storage.ts +7 -0
- package/src/lib/stale-build.test.ts +52 -0
- package/src/lib/stale-build.ts +36 -0
- package/src/routes/__root.tsx +14 -4
- package/src/routes/settings/advanced.tsx +2 -1
- package/src/runtime-config.test.ts +17 -0
- package/src/runtime-config.ts +13 -3
- package/src/shell/index.tsx +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.324",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createAuthClient } from "better-auth/react";
|
|
2
2
|
import { NetworkError, taggedFetch } from "../lib/network-error";
|
|
3
|
+
import { sessionStore } from "../lib/session-storage";
|
|
3
4
|
import { getRuntimeConfig } from "../runtime-config";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -46,16 +47,8 @@ interface CachedToken {
|
|
|
46
47
|
expiresAt: number;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
const tokenStore = (): Storage | null => {
|
|
50
|
-
try {
|
|
51
|
-
return globalThis.sessionStorage ?? null;
|
|
52
|
-
} catch {
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
|
|
57
50
|
const readStore = (): CachedToken | null => {
|
|
58
|
-
const raw =
|
|
51
|
+
const raw = sessionStore()?.getItem(STORAGE_KEY);
|
|
59
52
|
if (!raw) return null;
|
|
60
53
|
try {
|
|
61
54
|
const parsed: unknown = JSON.parse(raw);
|
|
@@ -77,7 +70,7 @@ const readStore = (): CachedToken | null => {
|
|
|
77
70
|
};
|
|
78
71
|
|
|
79
72
|
const writeStore = (token: CachedToken | null): void => {
|
|
80
|
-
const store =
|
|
73
|
+
const store = sessionStore();
|
|
81
74
|
if (!store) return;
|
|
82
75
|
try {
|
|
83
76
|
if (!token) {
|
|
@@ -9,6 +9,7 @@ import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
|
9
9
|
import { AppStory } from "@/mocks/story-frame/AppStory";
|
|
10
10
|
import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
|
|
11
11
|
import { mailWorld } from "@/mocks/story-frame/mail-world";
|
|
12
|
+
import { withRuntimeConfig } from "@/mocks/story-frame/session";
|
|
12
13
|
|
|
13
14
|
const CURRENT = "0.9.3";
|
|
14
15
|
const NEXT = "1.0.0";
|
|
@@ -107,6 +108,23 @@ export const AdvancedUpdateAvailable: Story = {
|
|
|
107
108
|
},
|
|
108
109
|
};
|
|
109
110
|
|
|
111
|
+
export const AdvancedManagedByHost: Story = {
|
|
112
|
+
beforeEach: withRuntimeConfig({ updateMode: "managed" }),
|
|
113
|
+
parameters: answering(update({ check: released })),
|
|
114
|
+
play: async ({ canvasElement }) => {
|
|
115
|
+
const canvas = within(canvasElement);
|
|
116
|
+
await expect(
|
|
117
|
+
await canvas.findByText(
|
|
118
|
+
"Notification rules are coming in a future release.",
|
|
119
|
+
),
|
|
120
|
+
).toBeVisible();
|
|
121
|
+
await expect(
|
|
122
|
+
canvas.queryByRole("button", { name: `Install ${NEXT}` }),
|
|
123
|
+
).toBeNull();
|
|
124
|
+
await expect(canvas.queryByText("update available")).toBeNull();
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
110
128
|
export const AdvancedCheckFailed: Story = {
|
|
111
129
|
parameters: answering(
|
|
112
130
|
update({
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
reloadOnceForChunkFailure,
|
|
5
|
+
STALE_BUILD_RELOAD_KEY_PREFIX,
|
|
6
|
+
} from "./stale-build";
|
|
7
|
+
|
|
8
|
+
const memoryGuard = (): Pick<Storage, "getItem" | "setItem"> => {
|
|
9
|
+
const store = new Map<string, string>();
|
|
10
|
+
return {
|
|
11
|
+
getItem: (key) => store.get(key) ?? null,
|
|
12
|
+
setItem: (key, value) => {
|
|
13
|
+
store.set(key, value);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("reloadOnceForChunkFailure", () => {
|
|
19
|
+
it("reloads once per build, keyed by its commit", () => {
|
|
20
|
+
const guard = memoryGuard();
|
|
21
|
+
let reloads = 0;
|
|
22
|
+
const ports = {
|
|
23
|
+
reload: () => {
|
|
24
|
+
reloads += 1;
|
|
25
|
+
},
|
|
26
|
+
guard,
|
|
27
|
+
commit: "aaa",
|
|
28
|
+
};
|
|
29
|
+
assert.equal(reloadOnceForChunkFailure(ports), true);
|
|
30
|
+
assert.equal(reloadOnceForChunkFailure(ports), false);
|
|
31
|
+
assert.equal(reloads, 1);
|
|
32
|
+
assert.notEqual(guard.getItem(`${STALE_BUILD_RELOAD_KEY_PREFIX}aaa`), null);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("reloads again for a different build", () => {
|
|
36
|
+
const guard = memoryGuard();
|
|
37
|
+
guard.setItem(`${STALE_BUILD_RELOAD_KEY_PREFIX}aaa`, "1");
|
|
38
|
+
let reloads = 0;
|
|
39
|
+
const reload = () => {
|
|
40
|
+
reloads += 1;
|
|
41
|
+
};
|
|
42
|
+
assert.equal(
|
|
43
|
+
reloadOnceForChunkFailure({ reload, guard, commit: "aaa" }),
|
|
44
|
+
false,
|
|
45
|
+
);
|
|
46
|
+
assert.equal(
|
|
47
|
+
reloadOnceForChunkFailure({ reload, guard, commit: "bbb" }),
|
|
48
|
+
true,
|
|
49
|
+
);
|
|
50
|
+
assert.equal(reloads, 1);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { APP_SHA } from "@/lib/app-info";
|
|
2
|
+
import { sessionStore } from "@/lib/session-storage";
|
|
3
|
+
|
|
4
|
+
export const STALE_BUILD_RELOAD_KEY_PREFIX = "remit.stale-build-reload:";
|
|
5
|
+
|
|
6
|
+
type ReloadGuard = Pick<Storage, "getItem" | "setItem">;
|
|
7
|
+
|
|
8
|
+
export interface StaleBuildPorts {
|
|
9
|
+
reload: () => void;
|
|
10
|
+
guard: ReloadGuard | null;
|
|
11
|
+
commit: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const reloadOnceForChunkFailure = ({
|
|
15
|
+
reload,
|
|
16
|
+
guard,
|
|
17
|
+
commit,
|
|
18
|
+
}: StaleBuildPorts): boolean => {
|
|
19
|
+
if (guard === null) return false;
|
|
20
|
+
const key = `${STALE_BUILD_RELOAD_KEY_PREFIX}${commit}`;
|
|
21
|
+
if (guard.getItem(key) !== null) return false;
|
|
22
|
+
guard.setItem(key, String(Date.now()));
|
|
23
|
+
reload();
|
|
24
|
+
return true;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const installStaleBuildReload = (): void => {
|
|
28
|
+
window.addEventListener("vite:preloadError", (event) => {
|
|
29
|
+
const reloaded = reloadOnceForChunkFailure({
|
|
30
|
+
reload: () => window.location.reload(),
|
|
31
|
+
guard: sessionStore(),
|
|
32
|
+
commit: APP_SHA,
|
|
33
|
+
});
|
|
34
|
+
if (reloaded) event.preventDefault();
|
|
35
|
+
});
|
|
36
|
+
};
|
package/src/routes/__root.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
type ErrorComponentProps,
|
|
4
4
|
Outlet,
|
|
5
5
|
} from "@tanstack/react-router";
|
|
6
|
-
import { Suspense } from "react";
|
|
6
|
+
import { type ReactNode, Suspense } from "react";
|
|
7
7
|
import { useTranslation } from "react-i18next";
|
|
8
8
|
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
9
9
|
import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
|
|
@@ -19,6 +19,7 @@ import { SelfUpdateProvider } from "@/hooks/use-system-update";
|
|
|
19
19
|
import { isServerError } from "@/lib/error-classifier";
|
|
20
20
|
import { reportFatalError } from "@/lib/fatal-error";
|
|
21
21
|
import type { RouterContext } from "@/router";
|
|
22
|
+
import { getRuntimeConfig } from "@/runtime-config";
|
|
22
23
|
|
|
23
24
|
const RootErrorComponent = ({ error, reset, info }: ErrorComponentProps) => {
|
|
24
25
|
// Everything that bubbles to the route boundary escalates to the loud
|
|
@@ -56,6 +57,16 @@ const SkipLink = () => {
|
|
|
56
57
|
);
|
|
57
58
|
};
|
|
58
59
|
|
|
60
|
+
const SelfUpdates = ({ children }: { children: ReactNode }) => {
|
|
61
|
+
if (getRuntimeConfig().updateMode === "managed") return children;
|
|
62
|
+
return (
|
|
63
|
+
<SelfUpdateProvider>
|
|
64
|
+
{children}
|
|
65
|
+
<SelfUpdateOverlay />
|
|
66
|
+
</SelfUpdateProvider>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
59
70
|
function RootLayout() {
|
|
60
71
|
return (
|
|
61
72
|
<ErrorBannerProvider>
|
|
@@ -63,7 +74,7 @@ function RootLayout() {
|
|
|
63
74
|
{/* A bulk run outlives the route that started it, so its owner sits
|
|
64
75
|
above the router rather than inside the list (#112). */}
|
|
65
76
|
<BulkRunProvider>
|
|
66
|
-
<
|
|
77
|
+
<SelfUpdates>
|
|
67
78
|
<ComposeProvider>
|
|
68
79
|
<SkipLink />
|
|
69
80
|
<main id="main-content" className="h-dvh overflow-hidden">
|
|
@@ -72,8 +83,7 @@ function RootLayout() {
|
|
|
72
83
|
</Suspense>
|
|
73
84
|
</main>
|
|
74
85
|
</ComposeProvider>
|
|
75
|
-
|
|
76
|
-
</SelfUpdateProvider>
|
|
86
|
+
</SelfUpdates>
|
|
77
87
|
</BulkRunProvider>
|
|
78
88
|
</RoleAppointmentPromptProvider>
|
|
79
89
|
<FatalErrorOverlay />
|
|
@@ -10,6 +10,7 @@ import { SelfUpdatePanel } from "@/components/settings/SelfUpdatePanel";
|
|
|
10
10
|
import { TlsRootCaDownload } from "@/components/settings/TlsRootCaDownload";
|
|
11
11
|
import { AppVersion } from "@/components/ui/AppVersion";
|
|
12
12
|
import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
|
|
13
|
+
import { getRuntimeConfig } from "@/runtime-config";
|
|
13
14
|
import { advancedHelp } from "./-shared/help-copy";
|
|
14
15
|
|
|
15
16
|
export const Route = createFileRoute("/settings/advanced")({
|
|
@@ -37,7 +38,7 @@ function AdvancedSettings() {
|
|
|
37
38
|
onSelect={handleSelectNav}
|
|
38
39
|
onBackToMail={() => void navigate({ to: "/mail" })}
|
|
39
40
|
>
|
|
40
|
-
<SelfUpdatePanel />
|
|
41
|
+
{getRuntimeConfig().updateMode === "self" && <SelfUpdatePanel />}
|
|
41
42
|
<div className="mt-6 border-t border-line pt-4">
|
|
42
43
|
<ConfigTransferPanel />
|
|
43
44
|
</div>
|
|
@@ -24,3 +24,20 @@ describe("getRuntimeConfig tlsMode", () => {
|
|
|
24
24
|
assert.equal(getRuntimeConfig().tlsMode, "off");
|
|
25
25
|
});
|
|
26
26
|
});
|
|
27
|
+
|
|
28
|
+
describe("getRuntimeConfig updateMode", () => {
|
|
29
|
+
it("defaults to self when config.js sets nothing", () => {
|
|
30
|
+
globalThis.__REMIT_CONFIG__ = {};
|
|
31
|
+
assert.equal(getRuntimeConfig().updateMode, "self");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("passes through managed", () => {
|
|
35
|
+
globalThis.__REMIT_CONFIG__ = { updateMode: "managed" };
|
|
36
|
+
assert.equal(getRuntimeConfig().updateMode, "managed");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("falls back to self for a value config.js never declares", () => {
|
|
40
|
+
globalThis.__REMIT_CONFIG__ = { updateMode: "auto" };
|
|
41
|
+
assert.equal(getRuntimeConfig().updateMode, "self");
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/runtime-config.ts
CHANGED
|
@@ -26,12 +26,19 @@ export interface RumConfig {
|
|
|
26
26
|
*/
|
|
27
27
|
export type TlsMode = "internal" | "acme" | "tailscale" | "off";
|
|
28
28
|
|
|
29
|
+
export type UpdateMode = "self" | "managed";
|
|
30
|
+
|
|
29
31
|
const DEFAULT_ISSUES_URL = "https://github.com/remit-mail/reader/issues/new";
|
|
30
32
|
|
|
31
33
|
const TLS_MODES: readonly TlsMode[] = ["internal", "acme", "tailscale", "off"];
|
|
32
34
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
+
const UPDATE_MODES: readonly UpdateMode[] = ["self", "managed"];
|
|
36
|
+
|
|
37
|
+
const oneOf = <T extends string>(
|
|
38
|
+
values: readonly T[],
|
|
39
|
+
raw: unknown,
|
|
40
|
+
fallback: T,
|
|
41
|
+
): T => values.find((value) => value === raw) ?? fallback;
|
|
35
42
|
|
|
36
43
|
export interface RemitRuntimeConfig {
|
|
37
44
|
apiUrl: string;
|
|
@@ -43,6 +50,7 @@ export interface RemitRuntimeConfig {
|
|
|
43
50
|
disableDevtools: boolean;
|
|
44
51
|
tlsMode: TlsMode;
|
|
45
52
|
issuesUrl: string;
|
|
53
|
+
updateMode: UpdateMode;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
interface RawRuntimeConfig {
|
|
@@ -55,6 +63,7 @@ interface RawRuntimeConfig {
|
|
|
55
63
|
disableDevtools?: boolean;
|
|
56
64
|
tlsMode?: string;
|
|
57
65
|
issuesUrl?: string;
|
|
66
|
+
updateMode?: string;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
declare global {
|
|
@@ -88,7 +97,8 @@ export const getRuntimeConfig = (): RemitRuntimeConfig => {
|
|
|
88
97
|
? String(raw.mailboxPollIntervalSeconds)
|
|
89
98
|
: undefined,
|
|
90
99
|
disableDevtools: raw.disableDevtools ?? false,
|
|
91
|
-
tlsMode:
|
|
100
|
+
tlsMode: oneOf(TLS_MODES, raw.tlsMode, "off"),
|
|
92
101
|
issuesUrl: raw.issuesUrl ?? DEFAULT_ISSUES_URL,
|
|
102
|
+
updateMode: oneOf(UPDATE_MODES, raw.updateMode, "self"),
|
|
93
103
|
};
|
|
94
104
|
};
|
package/src/shell/index.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
handleQueryCacheError,
|
|
18
18
|
} from "@/lib/query-error-handler";
|
|
19
19
|
import { initRum } from "@/lib/rum-adapter";
|
|
20
|
+
import { installStaleBuildReload } from "@/lib/stale-build";
|
|
20
21
|
import { TelemetryContext } from "@/lib/telemetry-context";
|
|
21
22
|
import { installThemeSync } from "@/lib/theme";
|
|
22
23
|
import { createAppRouter } from "@/router";
|
|
@@ -69,6 +70,8 @@ export const mountApp = ({
|
|
|
69
70
|
},
|
|
70
71
|
});
|
|
71
72
|
|
|
73
|
+
installStaleBuildReload();
|
|
74
|
+
|
|
72
75
|
const router = createAppRouter(queryClient, telemetry);
|
|
73
76
|
telemetry.recordPageView(window.location.pathname);
|
|
74
77
|
|