feat(client/standalone): basic service worker attempt

This commit is contained in:
Elian Doran 2026-01-05 18:35:14 +02:00
parent c1548b0f54
commit bde472d649
No known key found for this signature in database
4 changed files with 317 additions and 2 deletions

View File

@ -6,7 +6,7 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
<link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest">
<!-- <link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest"> -->
<title>Trilium Notes</title>
</head>
@ -24,6 +24,24 @@
<!-- This works even when the user directly changes --root-background in CSS -->
<div id="background-color-tracker" style="position: absolute; visibility: hidden; color: var(--root-background); transition: color 1ms;"></div>
<!-- Inject service worker -->
<script type="module">
import { attachServiceWorkerBridge, startLocalServerWorker } from "./local-bridge.js";
// 1) Start local worker ASAP (so /bootstrap is fast)
startLocalServerWorker();
// 2) Bridge SW -> local worker
attachServiceWorkerBridge();
// 3) Register SW
if ("serviceWorker" in navigator) {
const reg = await navigator.serviceWorker.register("./sw.js", { scope: "/src/" });
// Optionally wait for activation
await navigator.serviceWorker.ready;
}
</script>
<!-- Bootstrap (request server for required information) -->
<script>
async function bootstrap() {
@ -35,8 +53,11 @@
}
async function setupGlob() {
const response = await fetch("./bootstrap");
const response = await fetch("/bootstrap");
console.log("Service worker state", navigator.serviceWorker.controller);
console.log("Resp", response);
const json = await response.json();
console.log("Bootstrap", json);
global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
window.glob = {

View File

@ -0,0 +1,67 @@
// public/local-bridge.js
let localWorker = null;
const pending = new Map();
export function startLocalServerWorker() {
if (localWorker) return localWorker;
localWorker = new Worker(new URL("./local-server-worker.js", import.meta.url), { type: "module" });
localWorker.onmessage = (event) => {
const msg = event.data;
if (!msg || msg.type !== "LOCAL_RESPONSE") return;
const { id, response, error } = msg;
const resolver = pending.get(id);
if (!resolver) return;
pending.delete(id);
if (error) resolver.reject(new Error(error));
else resolver.resolve(response);
};
return localWorker;
}
export function attachServiceWorkerBridge() {
navigator.serviceWorker.addEventListener("message", async (event) => {
const msg = event.data;
if (!msg || msg.type !== "LOCAL_FETCH") return;
const port = event.ports && event.ports[0];
if (!port) return;
try {
startLocalServerWorker();
const id = msg.id;
const req = msg.request;
const response = await new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
// Transfer body to worker for efficiency (if present)
localWorker.postMessage({
type: "LOCAL_REQUEST",
id,
request: req
}, req.body ? [req.body] : []);
});
port.postMessage({
type: "LOCAL_FETCH_RESPONSE",
id,
response
}, response.body ? [response.body] : []);
} catch (e) {
port.postMessage({
type: "LOCAL_FETCH_RESPONSE",
id: msg.id,
response: {
status: 500,
headers: { "content-type": "text/plain; charset=utf-8" },
body: new TextEncoder().encode(String(e?.message || e)).buffer
}
});
}
});
}

View File

@ -0,0 +1,83 @@
// public/local-server-worker.js
// This will eventually import your core server and DB provider.
// import { createCoreServer } from "@trilium/core"; (bundled)
const encoder = new TextEncoder();
function jsonResponse(obj, status = 200, extraHeaders = {}) {
const body = encoder.encode(JSON.stringify(obj)).buffer;
return {
status,
headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders },
body
};
}
function textResponse(text, status = 200, extraHeaders = {}) {
const body = encoder.encode(text).buffer;
return {
status,
headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders },
body
};
}
// Example: your /bootstrap handler placeholder
function handleBootstrap() {
// Later: return real globals from your core state/config.
return jsonResponse({
assetPath: "assets",
themeCssUrl: null,
themeUseNextAsBase: "next",
iconPackCss: "",
device: "desktop",
headingStyle: "default",
layoutOrientation: "vertical",
platform: "web",
isElectron: false,
hasNativeTitleBar: false,
hasBackgroundEffects: true,
currentLocale: { id: "en", rtl: false }
});
}
// Main dispatch
async function dispatch(request) {
const url = new URL(request.url);
console.log("Dispatch ", url);
// NOTE: your core router will do this later.
if (request.method === "GET" && url.pathname === "/bootstrap") {
return handleBootstrap();
}
if (url.pathname.startsWith("/api/echo")) {
return jsonResponse({ ok: true, method: request.method, url: request.url });
}
return textResponse("Not found", 404);
}
self.onmessage = async (event) => {
const msg = event.data;
if (!msg || msg.type !== "LOCAL_REQUEST") return;
const { id, request } = msg;
try {
const response = await dispatch(request);
// Transfer body back (if any)
self.postMessage({
type: "LOCAL_RESPONSE",
id,
response
}, response.body ? [response.body] : []);
} catch (e) {
self.postMessage({
type: "LOCAL_RESPONSE",
id,
error: String(e?.message || e)
});
}
};

144
apps/client/src/sw.ts Normal file
View File

@ -0,0 +1,144 @@
// public/sw.js
const VERSION = "localserver-v1.1";
const STATIC_CACHE = `static-${VERSION}`;
// Adjust these to your routes:
const LOCAL_FIRST_PREFIXES = [
"/bootstrap",
"/api/",
"/sync/",
"/search/"
];
// Optional: basic precache list (keep small; you can expand later)
const PRECACHE_URLS = [
// "/",
// "/index.html",
// "/manifest.webmanifest",
// "/favicon.ico",
];
self.addEventListener("install", (event) => {
event.waitUntil((async () => {
const cache = await caches.open(STATIC_CACHE);
await cache.addAll(PRECACHE_URLS);
self.skipWaiting();
})());
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
// Cleanup old caches
const keys = await caches.keys();
await Promise.all(keys.map((k) => (k === STATIC_CACHE ? Promise.resolve() : caches.delete(k))));
await self.clients.claim();
})());
});
function isLocalFirst(url) {
return LOCAL_FIRST_PREFIXES.some((p) => url.pathname.startsWith(p));
}
async function cacheFirst(request) {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(request);
if (cached) return cached;
const fresh = await fetch(request);
// Cache only successful GETs
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
return fresh;
}
async function forwardToClientLocalServer(request, clientId) {
// Find a client to handle the request (prefer the initiating client if available)
let client = clientId ? await self.clients.get(clientId) : null;
if (!client) {
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
client = all[0] || null;
}
// If no page is available, fall back to network
if (!client) return fetch(request);
const reqUrl = request.url;
const headersObj = {};
for (const [k, v] of request.headers.entries()) headersObj[k] = v;
const body = (request.method === "GET" || request.method === "HEAD")
? null
: await request.arrayBuffer();
const id = crypto.randomUUID();
const channel = new MessageChannel();
const responsePromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Local server timeout"));
}, 30_000);
channel.port1.onmessage = (event) => {
clearTimeout(timeout);
resolve(event.data);
};
channel.port1.onmessageerror = () => {
clearTimeout(timeout);
reject(new Error("Local server message error"));
};
});
// Send to the client with a reply port
client.postMessage({
type: "LOCAL_FETCH",
id,
request: {
url: reqUrl,
method: request.method,
headers: headersObj,
body // ArrayBuffer or null
}
}, [channel.port2]);
const localResp = await responsePromise;
if (!localResp || localResp.type !== "LOCAL_FETCH_RESPONSE" || localResp.id !== id) {
// Protocol mismatch; fall back
return fetch(request);
}
// localResp.response: { status, headers, body }
const { status, headers, body: respBody } = localResp.response;
const respHeaders = new Headers();
if (headers) {
for (const [k, v] of Object.entries(headers)) respHeaders.set(k, String(v));
}
return new Response(respBody ? respBody : null, {
status: status || 200,
headers: respHeaders
});
}
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// Only handle same-origin
if (url.origin !== self.location.origin) return;
// Navigations + static assets: cache-first (simple app shell)
if (event.request.mode === "navigate" || (event.request.method === "GET" && !isLocalFirst(url))) {
event.respondWith(cacheFirst(event.request));
return;
}
// API-ish: local-first via bridge
if (isLocalFirst(url)) {
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
return;
}
// Default
event.respondWith(fetch(event.request));
});