service worker: use injectManifest
All checks were successful
/ depoly (push) Successful in 1m30s

- a RPC framework is added for further use
- fix an error that the service worker is not
    registered until the settings opened
- added theme-color
- settings: added a item to indicate the offline
    availablity
This commit is contained in:
thislight 2024-10-15 20:30:08 +08:00
parent eb461d3708
commit 46e7f1aaea
No known key found for this signature in database
GPG key ID: A50F9451AC56A63E
11 changed files with 341 additions and 19 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -24,6 +24,7 @@
"vite-plugin-pwa": "^0.20.5", "vite-plugin-pwa": "^0.20.5",
"vite-plugin-solid": "^2.10.2", "vite-plugin-solid": "^2.10.2",
"vite-plugin-solid-styled": "^0.11.1", "vite-plugin-solid-styled": "^0.11.1",
"workbox-build": "^7.1.1",
"wrangler": "^3.78.2" "wrangler": "^3.78.2"
}, },
"dependencies": { "dependencies": {
@ -49,7 +50,9 @@
"solid-js": "^1.8.22", "solid-js": "^1.8.22",
"solid-styled": "^0.11.1", "solid-styled": "^0.11.1",
"stacktrace-js": "^2.0.2", "stacktrace-js": "^2.0.2",
"web-animations-js": "^2.3.2" "web-animations-js": "^2.3.2",
"workbox-core": "^7.1.0",
"workbox-precaching": "^7.1.0"
}, },
"packageManager": "bun@1.1.21" "packageManager": "bun@1.1.21"
} }

View file

@ -5,6 +5,7 @@ import {
createEffect, createEffect,
createMemo, createMemo,
createRenderEffect, createRenderEffect,
createSignal,
ErrorBoundary, ErrorBoundary,
lazy, lazy,
onCleanup, onCleanup,
@ -17,6 +18,14 @@ import {
import { $accounts, updateAcctInf } from "./accounts/stores.js"; import { $accounts, updateAcctInf } from "./accounts/stores.js";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { DateFnScope, useLanguage } from "./platform/i18n.jsx"; import { DateFnScope, useLanguage } from "./platform/i18n.jsx";
import { useRegisterSW } from "virtual:pwa-register/solid";
import {
isJSONRPCResult,
ResultDispatcher,
type JSONRPC,
} from "./serviceworker/services.js";
import { makeEventListener } from "@solid-primitives/event-listener";
import { ServiceWorkerProvider } from "./platform/host.js";
const AccountSignIn = lazy(() => import("./accounts/SignIn.js")); const AccountSignIn = lazy(() => import("./accounts/SignIn.js"));
const AccountMastodonOAuth2Callback = lazy( const AccountMastodonOAuth2Callback = lazy(
@ -58,6 +67,43 @@ const App: Component = () => {
const theme = useRootTheme(); const theme = useRootTheme();
const accts = useStore($accounts); const accts = useStore($accounts);
const lang = useLanguage(); const lang = useLanguage();
const [serviceWorker, setServiceWorker] = createSignal<ServiceWorker>();
const dispatcher = new ResultDispatcher();
let checkAge = 0;
const untilServiceWorkerAlive = async (
worker: ServiceWorker,
expectedAge: number,
) => {
const [call, ret] = dispatcher.createTypedCall("ping", undefined);
worker.postMessage(await call);
const result = await ret;
console.assert(!result.error, result);
if (expectedAge === checkAge) {
setServiceWorker(worker);
}
};
makeEventListener(window, "message", (event: MessageEvent<JSONRPC>) => {
if (isJSONRPCResult(event.data)) {
dispatcher.dispatch(event.data.id, event.data);
}
});
const {
needRefresh: [needRefresh],
offlineReady: [offlineReady],
} = useRegisterSW({
onRegisteredSW(scriptUrl, reg) {
console.info("service worker is registered from %s", scriptUrl);
const active = reg?.active;
if (!active) {
console.warn("No service is in activating or activated");
return;
}
untilServiceWorkerAlive(active, checkAge++);
},
});
const clients = createMemo(() => { const clients = createMemo(() => {
return accts().map((x) => ({ return accts().map((x) => ({
@ -103,7 +149,15 @@ const App: Component = () => {
<ThemeProvider theme={theme()}> <ThemeProvider theme={theme()}>
<DateFnScope> <DateFnScope>
<ClientProvider value={clients}> <ClientProvider value={clients}>
<Routing /> <ServiceWorkerProvider
value={{
needRefresh,
offlineReady,
serviceWorker,
}}
>
<Routing />
</ServiceWorkerProvider>
</ClientProvider> </ClientProvider>
</DateFnScope> </DateFnScope>
</ThemeProvider> </ThemeProvider>

View file

@ -1,12 +1,37 @@
import { createContext, useContext, type Accessor } from "solid-js";
import { useRegisterSW } from "virtual:pwa-register/solid";
export function isiOS() { export function isiOS() {
return [ return (
'iPad Simulator', [
'iPhone Simulator', "iPad Simulator",
'iPod Simulator', "iPhone Simulator",
'iPad', "iPod Simulator",
'iPhone', "iPad",
'iPod' "iPhone",
].includes(navigator.platform) "iPod",
// iPad on iOS 13 detection ].includes(navigator.platform) ||
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document) // iPad on iOS 13 detection
} (navigator.userAgent.includes("Mac") && "ontouchend" in document)
);
}
export type ServiceWorkerService = {
needRefresh: Accessor<boolean>;
offlineReady: Accessor<boolean>;
serviceWorker: Accessor<ServiceWorker | undefined>;
};
const ServiceWorkerContext = /* @__PURE__ */ createContext<
ServiceWorkerService
>(({
needRefresh: () => false,
offlineReady: () => false,
serviceWorker: () => undefined
}));
export const ServiceWorkerProvider = ServiceWorkerContext.Provider;
export function useServiceWorker(): ServiceWorkerService {
return useContext(ServiceWorkerContext);
}

35
src/serviceworker/main.ts Normal file
View file

@ -0,0 +1,35 @@
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
import { clientsClaim } from "workbox-core";
import type { AnyCall } from "./services";
function checkServiceWorker(
self: WorkerGlobalScope,
): self is ServiceWorkerGlobalScope {
return !!(self as unknown as ServiceWorkerGlobalScope).registration;
}
if (checkServiceWorker(self)) {
cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST, {
cleanURLs: false
});
// auto update
self.skipWaiting();
clientsClaim();
} else {
throw new TypeError("This entry point must be run in a service worker");
}
self.addEventListener("message", (event: MessageEvent<AnyCall>) => {
if (event.data.method === "ping") {
event.source.postMessage({id: event.data.id, jsonrpc: "2.0", result: undefined})
} else {
event.source.postMessage({
error: {
code: -32601,
message: "Method not found"
}
})
}
})

View file

@ -0,0 +1,177 @@
export type JSONRPC = {
jsonrpc: "2.0";
id?: string | number;
};
export type Call<T = undefined> = JSONRPC & {
method: string;
params: T;
};
export type RemoteError<E = undefined> = { data: E } & (
| {
code: number;
message: string;
}
| {
code: -32700;
message: "Parse Error";
}
| {
code: -32600;
message: "Invalid Request";
}
| {
code: -32601;
message: "Method not found";
}
| {
code: -32602;
message: "Invalid params";
}
| {
code: -32603;
message: "Internal error";
}
);
export type Result<T, E> = JSONRPC & { id: string | number } & (
| {
result: T;
error: undefined;
}
| { error: RemoteError<E>; result: undefined }
);
export function isJSONRPCResult(
object: Record<string, unknown>,
): object is Result<unknown, unknown> {
return object["jsonrpc"] === "2.0" && object["id"] && !object["method"];
}
export function isJSONRPCCall(
object: Record<string, unknown>,
): object is Call<unknown> {
return object["jsonrpc"] === "2.0" && !!object["method"];
}
export class ResultDispatcher {
private map: Map<
number | string,
((value: Result<unknown, unknown>) => void) | true // `true` = a call is generated, but the promise not created
>;
private nextId: number = Number.MIN_SAFE_INTEGER;
constructor() {
this.map = new Map();
}
private rollId() {
let id = 0;
while (this.map.get((id = this.nextId++))) {
if (this.nextId >= Number.MAX_SAFE_INTEGER) {
this.nextId = Number.MIN_SAFE_INTEGER;
}
}
return id;
}
createCall<T>(
method: string,
params: T,
): [Promise<Call<T>>, Promise<Result<unknown, unknown>>] {
const id = this.rollId();
const p = new Promise<Result<unknown, unknown>>((resolve) =>
this.map.set(id, resolve),
);
this.map.set(id, true);
const call: Call<T> = {
jsonrpc: "2.0",
id,
method,
params,
};
return [
new Promise((resolve) => {
const waitUntilTheIdSet = () => {
// We must do this check to make sure the id is set before the call made.
// or the dispatching may lost the callback
if (this.map.get(id)) {
resolve(call);
} else {
setTimeout(waitUntilTheIdSet, 0);
}
};
waitUntilTheIdSet();
}),
p,
];
}
dispatch(id: string | number, message: Result<unknown, unknown>) {
{
const callback = this.map.get(id);
if (!callback) return;
if (typeof callback !== "boolean") {
callback(message);
this.map.delete(id);
return Promise.resolve();
}
}
return new Promise<void>((resolve) => {
let retried = 0;
const checkAndDispatch = () => {
const callback = this.map.get(id);
if (typeof callback !== "boolean") {
callback(message);
this.map.delete(id);
resolve();
return;
}
setTimeout(checkAndDispatch, 0);
if (++retried > 3) {
console.warn(
`retried ${retried} time(s) but the callback is still disappeared, id is "${id}"`,
);
}
};
// start the loop
checkAndDispatch();
});
}
createTypedCall<
K extends keyof RequestParams,
P extends RequestParams[K],
R extends ResponseResults[K],
E extends ResponseErrorDatas[K],
>(method: K, params: P): [Promise<Call<P>>, Promise<Result<R, E>>] {
return this.createCall(method, params) as [
Promise<Call<P>>,
Promise<Result<R, E>>,
];
}
}
interface RequestParams {
ping: void;
}
interface ResponseResults {
ping: void;
}
interface ResponseErrorDatas {
[key: string]: void;
}
export type AnyCall<K extends keyof RequestParams = keyof RequestParams> =
JSONRPC & {
method: K;
params: RequestParams[K];
};

View file

@ -0,0 +1,5 @@
{
"compilerOptions": {
"lib": ["ESNext", "WebWorker"],
},
}

View file

@ -45,6 +45,7 @@ import {
} from "../platform/i18n.jsx"; } from "../platform/i18n.jsx";
import { type Template } from "@solid-primitives/i18n"; import { type Template } from "@solid-primitives/i18n";
import BottomSheet from "../material/BottomSheet.jsx"; import BottomSheet from "../material/BottomSheet.jsx";
import { useServiceWorker } from "../platform/host.js";
type Strings = { type Strings = {
["lang.auto"]: Template<{ detected: string }>; ["lang.auto"]: Template<{ detected: string }>;
@ -60,9 +61,7 @@ const Settings: ParentComponent = (props) => {
); );
const navigate = useNavigate(); const navigate = useNavigate();
const settings$ = useStore($settings); const settings$ = useStore($settings);
const { const { needRefresh, offlineReady } = useServiceWorker();
needRefresh: [needRefresh],
} = useRegisterSW();
const dateFnLocale = useDateFnLocale(); const dateFnLocale = useDateFnLocale();
const [profiles] = useSignedInProfiles(); const [profiles] = useSignedInProfiles();
@ -236,6 +235,16 @@ const Settings: ParentComponent = (props) => {
</Show> </Show>
</ListItem> </ListItem>
<Divider /> <Divider />
<ListItem>
<ListItemText secondary={
offlineReady()
? t("availability.offline")
: t("availability.online")
}>
{t("availability")}
</ListItemText>
</ListItem>
<Divider />
</li> </li>
</List> </List>
</Scaffold> </Scaffold>

View file

@ -32,5 +32,9 @@
"motions.gifs": "GIFs", "motions.gifs": "GIFs",
"motions.gifs.autoplay": "Auto-play GIFs", "motions.gifs.autoplay": "Auto-play GIFs",
"motions.vids": "Videos", "motions.vids": "Videos",
"motions.vids.autoplay": "Auto-play Videos" "motions.vids.autoplay": "Auto-play Videos",
"availability": "Availability",
"availability.offline": "Offline ready",
"availability.online": "Online only"
} }

View file

@ -32,5 +32,9 @@
"motions.gifs": "动图", "motions.gifs": "动图",
"motions.gifs.autoplay": "自动播放动图", "motions.gifs.autoplay": "自动播放动图",
"motions.vids": "视频", "motions.vids": "视频",
"motions.vids.autoplay": "自动播放视频" "motions.vids.autoplay": "自动播放视频",
"availability": "离线可用程度",
"availability.offline": "可以离线使用",
"availability.online": "需要联网使用"
} }

View file

@ -16,10 +16,16 @@ export default defineConfig(({ mode }) => ({
}, },
}), }),
VitePWA({ VitePWA({
strategies: "injectManifest",
registerType: "autoUpdate", registerType: "autoUpdate",
devOptions: { devOptions: {
enabled: mode === "staging", enabled: mode === "staging" || mode === "dev",
}, },
srcDir: "src/serviceworker",
filename: "main.ts",
manifest: {
theme_color: "#673ab7"
}
}), }),
version(), version(),
], ],