diff --git a/app/src/components/AppContext.jsx b/app/src/components/AppContext.jsx index 0c06fa6..18b53b9 100644 --- a/app/src/components/AppContext.jsx +++ b/app/src/components/AppContext.jsx @@ -1,5 +1,4 @@ import { createContext, createEffect, useContext } from "solid-js"; -import init, { Client } from "meal-core"; import { createStore } from "solid-js/store"; /** @@ -17,12 +16,6 @@ import { createStore } from "solid-js/store"; const id = localStorage.getItem("id"); const name = localStorage.getItem("name"); const isOnboarded = localStorage.getItem("isOnboarded") !== null; -await init(); -/** @deprecated */ -// @ts-ignore -const client = new Client(id ?? undefined, name ?? undefined, (...bleh) => { - console.warn("Received bridge request", bleh); -}); // const isLocalhost = // window.location.hostname === "localhost" || @@ -34,13 +27,13 @@ export const messagesUrl = new URL("/messages/", window.location.origin); const [app, setApp] = createStore({ name, // Client creates an id if there is none provided - get id() { - return client.id; - }, + // get id() { + // return client.id; + // }, /** * @deprecated Use client through channel and single service worker instance instead */ - client, + // client, /** * @type {Group[]} */ diff --git a/app/src/crackle.ts b/app/src/crackle.ts new file mode 100644 index 0000000..2ae59fd --- /dev/null +++ b/app/src/crackle.ts @@ -0,0 +1,81 @@ +/** + * An ergonomic way to messsage the service worker. This has edge cases so it is not ready for a library but good enough for now. + * Inspired by comlink. + */ + +// This type is written using GPT-4.1 +type Promisify = { + [K in keyof T]: T[K] extends (...args: any[]) => infer R + ? R extends Promise + ? // Function already returns Promise, leave as-is + T[K] + : // Wrap function return type + (...args: Parameters) => Promise + : T[K] extends Promise + ? // Property already is Promise, leave as-is + T[K] + : // Wrap non-function, non-Promise property + Promise; +}; + +type CrackleMessage = { + property: string | symbol; + parameters: unknown[]; +}; + +/** + * Proxy requests to the provided target object to the service worker + */ +export function expose(target: T, port: MessagePort) { + port.addEventListener( + "message", + async (event: MessageEvent) => { + const { property, parameters } = event.data; + const responsePort = event.ports[0]; + const targetProperty = Reflect.get(target, property, target); + if (typeof targetProperty !== "function") { + responsePort.postMessage(targetProperty); + return; + } + + const result = Reflect.apply(targetProperty, target, parameters); + if (!(result instanceof Promise)) { + responsePort.postMessage(result); + return; + } + + const response = await result; + responsePort.postMessage(response); + } + ); +} + +export function wrap(port: MessagePort): Promisify { + const handler: ProxyHandler = { + get(target, property, receiver) { + // When the proxy gets returned in a promise, that promise checks for the existence of the "then" property + // If it exists it will treat this proxy as promise but we don't want that. + // This enables JS async functions to return a promise without that promise gettting wrapped in a promise itself. + // But the logic for that sadly checks for the existence of the "then" property on the proxy + if (property === "then") return undefined; + + return async (...parameters: unknown[]) => { + const { port1, port2 } = new MessageChannel(); + const response = new Promise((resolve) => { + port1.addEventListener("message", (event) => resolve(event.data), { + once: true, + }); + }); + + port.postMessage({ property, parameters } satisfies CrackleMessage, [ + port2, + ]); + port1.start(); + return await response; + }; + }, + }; + + // We have to lie to TypeScript here that this will work + return new Proxy({}, handler) as Promisify; +} diff --git a/app/src/routes/index.jsx b/app/src/routes/index.tsx similarity index 77% rename from app/src/routes/index.jsx rename to app/src/routes/index.tsx index 0c234e5..aceabf1 100644 --- a/app/src/routes/index.jsx +++ b/app/src/routes/index.tsx @@ -1,11 +1,11 @@ -import { For, Show } from "solid-js"; +import { createEffect, createResource, For, Show } from "solid-js"; import Onboarding from "../components/Onboarding"; import { useAppContext } from "../components/AppContext"; import TopAppBar from "../components/TopAppBar"; -//@ts-expect-error TS6192 Can not handle new JSDoc syntax (yet?) +import { wrap } from "../crackle"; +import { type Handler } from "../service-worker/serviceWorker"; // https://devblogs.microsoft.com/typescript/announcing-typescript-5-5/#the-jsdoc-@import-tag /** @import { Signal, JSX, Accessor, ParentProps } from "solid-js" */ -//@ts-expect-error TS6192 Can not handle new JSDoc syntax (yet?) /** @import { Message } from "../components/AppContext" */ function FloatingActionButton() { @@ -141,21 +141,61 @@ function ChatList() { ); } -/** @param {ParentProps} _properties */ -export default function Index(_properties) { - const [app, setApp] = useAppContext(); +/** + * @import { ComlinkExposed } from "../service-worker/serviceWorker" + */ +// async function initializeComlink() { +// const { port1, port2 } = new MessageChannel(); +// const message = { +// type: "initializeComlink", +// }; + +// const worker = await navigator.serviceWorker.ready; +// if (worker.active === null) throw new Error("No active service worker"); +// worker.active.postMessage(message, [port1]); +// const comlink = /** @type {Comlink.Remote} */ ( +// Comlink.wrap(port2) +// ); +// console.debug("Comlink", comlink.getIsOnboarded); +// const isOnboarded = await comlink.getIsOnboarded(); +// console.debug("Is onboarded?", isOnboarded); +// } + +async function initializeCrackle() { + const { port1, port2 } = new MessageChannel(); + const message = { + type: "initializeCrackle", + }; + + const worker = await navigator.serviceWorker.ready; + if (worker.active === null) throw new Error("No active service worker"); + worker.active.postMessage(message, [port1]); + console.debug("Port", port2); + const wrapper = wrap(port2); + console.debug("Wrapper", wrapper); + return wrapper; +} + +const crackled = initializeCrackle(); + +export default function Index() { + const [isOnboarded, { mutate: setIsOnboarded }] = createResource(async () => { + const crackle = await crackled; + return await crackle.getIsOnboarded(); + }); + + async function setName(name: string) { + const isOnboarded = await sendRequest({ type: "completeOnboarding", name }); + console.debug("Is onboarded response?", isOnboarded); + setIsOnboarded(isOnboarded); + } + + createEffect(() => console.debug("Is onboarded?", isOnboarded())); return ( { - setApp("name", name); - setApp("isOnboarded", true); - }} - /> - } + when={isOnboarded.state === "ready" && isOnboarded()} + fallback={} > <> {/* diff --git a/app/src/sendMessage.js b/app/src/sendMessage.js index 14d60eb..89608c8 100644 --- a/app/src/sendMessage.js +++ b/app/src/sendMessage.js @@ -1,6 +1,7 @@ /** * * @param {ServiceWorkerMessage} message + * @returns {Promise} */ export async function sendMessage(message) { if (!("serviceWorker" in navigator)) @@ -14,7 +15,9 @@ export async function sendMessage(message) { /** * + * @template T * @param {ServiceWorkerRequest} message + * @returns {Promise} */ export async function sendRequest(message) { if (!("serviceWorker" in navigator)) diff --git a/app/src/service-worker/schema.d.ts b/app/src/service-worker/schema.d.ts index 8770556..5266b7b 100644 --- a/app/src/service-worker/schema.d.ts +++ b/app/src/service-worker/schema.d.ts @@ -6,5 +6,6 @@ interface Schema extends DBSchema { name?: string; }; clientId?: string; + isOnboarded: boolean; }; } diff --git a/app/src/service-worker/serviceMessage.d.ts b/app/src/service-worker/serviceMessage.d.ts index 8d76e54..341d3a5 100644 --- a/app/src/service-worker/serviceMessage.d.ts +++ b/app/src/service-worker/serviceMessage.d.ts @@ -1,38 +1,30 @@ -type SendMessageRequest = { - type: "sendMessage"; - groupId: string; - friendId: string; - sent: Date; - text: string; +type CompleteOnboardingRequest = { + type: "completeOnboarding"; + name: string; }; -type CreateInviteRequest = { - type: "createInvite"; +type CompleteOnboardingResponse = { + type: "completeOnboarding"; }; -type InitializePortRequest = { - type: "initializePort"; +type GetIsOnboardedRequest = { + type: "getIsOnboarded"; }; -type InitializePortResponse = { - type: "portInitialized"; -}; - -type CreateInviteResponse = { - type: "inviteUrl"; - inviteUrl: string; +type GetIsOnboardedResponse = { + type: "isOnboarded"; + isOnboarded: boolean; }; /** * A message sent to the service worker from a browsing context. * Expects a response. (synchronous) */ -type ServiceWorkerRequest = - | InitializePortRequest - | SendMessageRequest - | CreateInviteRequest; +type ServiceWorkerRequest = GetIsOnboardedRequest | CompleteOnboardingRequest; -type ServiceWorkerResponse = InitializePortResponse | CreateInviteResponse; +type ServiceWorkerResponse = + | GetIsOnboardedResponse + | CompleteOnboardingResponse; type InviteFromPackage = { type: "inviteFromPackage"; diff --git a/app/src/service-worker/serviceWorker.js b/app/src/service-worker/serviceWorker.ts similarity index 53% rename from app/src/service-worker/serviceWorker.js rename to app/src/service-worker/serviceWorker.ts index 72d1914..97590a7 100644 --- a/app/src/service-worker/serviceWorker.js +++ b/app/src/service-worker/serviceWorker.ts @@ -1,6 +1,7 @@ import { precacheAndRoute } from "workbox-precaching"; import { openDB } from "idb"; import init, { create_client } from "meal-core"; +import { expose } from "../crackle"; /** * @import { Schema } from "./schema" @@ -15,7 +16,7 @@ console.debug("Service worker: environment", process.env.NODE_ENV); // @ts-expect-error This variable is replaced by workbox through vite pwa plugin precacheAndRoute(self.__WB_MANIFEST); -const openDatabase = /** @type {typeof openDB} */ (openDB)("meal", 1, { +const openDatabase = /** @type {typeof openDB} */ openDB("meal", 1, { upgrade(database) { console.debug("Service worker: upgrading database"); if (!database.objectStoreNames.contains("configuration")) { @@ -29,21 +30,27 @@ openDatabase.then((database) => { const store = transaction.objectStore("configuration"); if (store.add === undefined) throw new Error("Store is undefined"); - store.add({}); + store.add({ isOnboarded: false }); }); -/** - * - * @returns {Promise} - */ -async function initializeClient() { +/** @returns {Promise} */ +async function getConfiguration() { // Get configuration const database = await openDatabase; const transaction = database.transaction("configuration", "readonly"); const store = transaction.objectStore("configuration"); // Get first item const cursor = await store.openCursor(); - const configuration = /** @type {Schema["configuration"]} */ (cursor?.value); + const configuration = /** @type {Schema["configuration"]} */ cursor?.value; + return configuration; +} + +/** + * + * @returns {Promise} + */ +async function initializeClient() { + const configuration = await getConfiguration(); // Have to use wasm-pack --target web to build the wasm package to get the init function because with the bundler target // it is included as a top level await which is not supported by service workers according to the web spec await init(); @@ -98,85 +105,25 @@ const setupClient = initializeClient(); /** The url for messages endpoint. Don't forget the trailing slash. */ const messagesUrl = new URL("/messages/", self.location.origin); -/** - * @param {MessageEvent} event - */ -async function handleMessage(event) { - console.debug("Service worker: received message", event.data.type); +const handler = { + async getIsOnboarded() { + const configuration = await getConfiguration(); + return configuration.isOnboarded; + }, +}; - switch (event.data.type) { - case "initializePort": { - console.debug("Received initializePort"); - if (event.source === null) - throw new Error("Expected message event source to be not null"); +export type Handler = typeof handler; - if (!(event.source instanceof Client)) - throw new Error("Expected message event source to be a client"); +async function handleMessage( + event: MessageEvent<{ type: "initializeCrackle" }> +) { + console.debug("Service worker: received message", event.data.type); + switch (event.data.type) { + case "initializeCrackle": { const port = event.ports[0]; - port.addEventListener("message", handleMessage); - // ports.set(id, new WeakRef(port)); - // port.start(); - port.postMessage( - /** @type {InitializePortResponse} */ ({ type: "portInitialized" }) - ); - - console.debug("Posted port initialized"); - - return; - } - case "sendMessage": { - const client = await setupClient; - // const body = client.send_message(event.data.groupId, { - // sent: event.data.sent.toISOString(), - // text: event.data.text, - // }); - // const url = new URL(event.data.friendId, messagesUrl); - // const request = new Request(url, { - // method: "post", - // headers: { - // //https://www.rfc-editor.org/rfc/rfc9420.html#name-the-message-mls-media-type - // "Content-Type": "message/mls", - // }, - // body, - // }); - - // //TODO error handling - // //TODO retry - // await fetch(request); - return; - } - case "createInvite": { - const client = await setupClient; - // const encodedInvite = client.create_invite(client.get_name()); - // const inviteUrl = new URL(`/join/${encodedInvite}`, location.origin); - - // if (event.source === null) - // throw new Error( - // "Expected browser context source to send invite url back to" - // ); - - // if (!(event.source instanceof Client)) - // throw new Error("Expected message event source to be a client"); - - // /** @type {ServiceWorkerResponse} */ - // const response = { - // type: "inviteUrl", - // inviteUrl: inviteUrl.href, - // }; - // event.ports[0].postMessage(response); - return; - } - case "inviteFromPackage": { - // Update name state - // Update name in database - // Broadcast name update to all contexts - // Decode key package - // Create group with core - // Create group in database - // Broadcast group creation to all contexts - // Create invite welcome package with core - // Send welcome package to friend + expose(handler, port); + break; } } } diff --git a/app/vite.config.js b/app/vite.config.js index b2ef8eb..e1429fd 100644 --- a/app/vite.config.js +++ b/app/vite.config.js @@ -18,7 +18,7 @@ export default defineConfig({ }, strategies: "injectManifest", srcDir: "./src/service-worker", - filename: "serviceWorker.js", + filename: "serviceWorker.ts", pwaAssets: { image: "./public/logo-04.svg", }, @@ -47,6 +47,6 @@ export default defineConfig({ target: "esnext", }, optimizeDeps: { - exclude: ['@sqlite.org/sqlite-wasm'], + exclude: ["@sqlite.org/sqlite-wasm"], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 570ad86..e3909e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2410,6 +2410,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions sourcemap-codec@1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}