diff --git a/.forgejo/workflows/checkpr.yml b/.forgejo/workflows/checkpr.yml new file mode 100644 index 0000000..2f6dc9f --- /dev/null +++ b/.forgejo/workflows/checkpr.yml @@ -0,0 +1,34 @@ + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + checkpr: + runs-on: fedora-41 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: 'package.json' + + - name: Cache Dependencies + id: dependencies-cache + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install Dependencies + run: bun install + + - name: Validate types + run: bun typecheck + + - name: Build Dist + run: VITE_CODE_VERSION=$GITHUB_SHA bun dist diff --git a/.forgejo/workflows/depoly.yml b/.forgejo/workflows/depoly.yml index edba093..0ad6d10 100644 --- a/.forgejo/workflows/depoly.yml +++ b/.forgejo/workflows/depoly.yml @@ -30,6 +30,9 @@ jobs: - name: Install Dependencies run: bun install + - name: Validate types + run: bun typecheck + - name: Build Dist (Staging) run: VITE_CODE_VERSION=$GITHUB_SHA bun dist -m staging if: env.GITHUB_REF_NAME == 'master' diff --git a/package.json b/package.json index 219b2e0..4e04928 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "dev": "vite --host 0.0.0.0", "preview": "vite preview", "dist": "vite build", - "count-source-lines": "exec scripts/src-lc.sh" + "count-source-lines": "exec scripts/src-lc.sh", + "typecheck": "tsc --noEmit --skipLibCheck" }, "keywords": [], "author": "Rubicon", diff --git a/src/platform/polyfills.ts b/src/platform/polyfills.ts index 032207a..1af3341 100644 --- a/src/platform/polyfills.ts +++ b/src/platform/polyfills.ts @@ -15,3 +15,24 @@ if (typeof window.crypto.randomUUID === "undefined") { ) as `${string}-${string}-${string}-${string}-${string}`; }; } + + +if (typeof Promise.withResolvers === "undefined") { + // Chrome/Edge 119, Firefox 121, Safari/iOS 17.4 + + // Promise.withResolvers is generic and works with subclasses - the typescript built-in decl + // could not handle the subclassing case. + Promise.withResolvers = function (this: AnyPromiseConstructor) { + let resolve!: PromiseWithResolvers["resolve"], reject!: PromiseWithResolvers["reject"]; + // These variables are expected to be set after `new this()` + + const promise = new this((resolve0, reject0) => { + resolve = resolve0; + reject = reject0; + }) + + return { + promise, resolve, reject + } + } +} diff --git a/src/serviceworker/main.ts b/src/serviceworker/main.ts index 71a4f37..99305a9 100644 --- a/src/serviceworker/main.ts +++ b/src/serviceworker/main.ts @@ -4,23 +4,27 @@ import { dispatchCall, isJSONRPCCall, type Call } from "./workerrpc"; function isServiceWorker( self: WorkerGlobalScope, + // @ts-ignore: workaround for workbox logger.d.ts decl ): self is ServiceWorkerGlobalScope { - return !!(self as unknown as ServiceWorkerGlobalScope).registration; + return ( + (self as unknown as Record)["serviceWorker"] instanceof + ServiceWorker + ); } -if (isServiceWorker(self)) { - cleanupOutdatedCaches(); - precacheAndRoute(self.__WB_MANIFEST, { - cleanURLs: false, - }); - - // auto update - self.skipWaiting(); - clientsClaim(); -} else { +if (!isServiceWorker(self)) { throw new TypeError("This entry point must be run in a service worker"); } +cleanupOutdatedCaches(); +precacheAndRoute(self.__WB_MANIFEST, { + cleanURLs: false, +}); + +// auto update +self.skipWaiting(); +clientsClaim(); + export const Service = { ping() {}, }; @@ -29,6 +33,9 @@ self.addEventListener("message", (event: MessageEvent) => { const payload = event.data; if (typeof payload !== "object") return; if (isJSONRPCCall(payload as Record)) { - dispatchCall(Service, event as MessageEvent>); + dispatchCall( + Service, + event as MessageEvent> & { source: MessageEventSource }, + ); } }); diff --git a/src/serviceworker/tsconfig.json b/src/serviceworker/tsconfig.json index bc88f57..090edc2 100644 --- a/src/serviceworker/tsconfig.json +++ b/src/serviceworker/tsconfig.json @@ -1,5 +1,7 @@ { "compilerOptions": { - "lib": ["ESNext", "WebWorker"], + "lib": ["WebWorker", "ESNext"], }, -} \ No newline at end of file + "extends": ["../../tsconfig.super.json"], + "include": ["./**/*.ts"], +} diff --git a/src/serviceworker/workerrpc.ts b/src/serviceworker/workerrpc.ts index 84bb39f..7680716 100644 --- a/src/serviceworker/workerrpc.ts +++ b/src/serviceworker/workerrpc.ts @@ -46,7 +46,7 @@ export type Result = JSONRPC & { id: string | number } & ( export function isJSONRPCResult( object: Record, ): object is Result { - return object["jsonrpc"] === "2.0" && object["id"] && !object["method"]; + return !!(object["jsonrpc"] === "2.0" && object["id"] && !object["method"]); } export function isJSONRPCCall( @@ -114,6 +114,9 @@ export class ResultDispatcher { { const callback = this.map.get(id); if (!callback) return; + // Now the callback is not undefined + + // Fast path if (typeof callback !== "boolean") { callback(message); this.map.delete(id); @@ -121,28 +124,30 @@ export class ResultDispatcher { } } - return new Promise((resolve) => { - let retried = 0; + const { promise, resolve } = Promise.withResolvers(); - 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}"`, - ); - } - }; + let retried = 0; - // start the loop - checkAndDispatch(); - }); + const checkAndDispatch = () => { + const callback = this.map.get(id); + if (typeof callback !== "boolean") { + callback!(message); // the nullability is already checked before + this.map.delete(id); + resolve(undefined); + 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(); + + return promise; } createTypedCall< @@ -159,14 +164,16 @@ export class ResultDispatcher { } } -type AnyService = Record unknown) | undefined> +type AnyService = Record unknown>; -export async function dispatchCall< - S extends AnyService, ->(service: S, event: MessageEvent>) { +export async function dispatchCall>( + service: S, + event: MessageEvent> & { source: MessageEventSource }, +) { try { const fn = service[event.data.method]; if (!fn) { + console.warn("requested unknown method", event.data.method, event.data); if (event.data.id) return event.source.postMessage({ jsonrpc: "2.0", @@ -176,10 +183,12 @@ export async function dispatchCall< message: "Method not found", }, } as Result); + + return; } try { - const result = await fn(...event.data.params as unknown[]); + const result = await fn(...(event.data.params as unknown[])); if (!event.data.id) return; diff --git a/tsconfig.json b/tsconfig.json index b92e528..feaab58 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,11 @@ { "compilerOptions": { - "strict": true, - "target": "ESNext", - "module": "esnext", - "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, "jsx": "preserve", "jsxImportSource": "solid-js", "types": ["vite/client", "vite-plugin-pwa/solid"], - "noEmit": true, - "isolatedModules": true, - "resolveJsonModule": true, - "paths": { - "~platform/*": ["./src/platform/*"], - "~material/*": ["./src/material/*"] - } - } + "lib": ["ESNext", "DOM", "DOM.Iterable"] + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx", "./*.ts", "./types/**.ts"], + "exclude": ["./src/serviceworker/**"], + "extends": ["./tsconfig.super.json"] } diff --git a/tsconfig.super.json b/tsconfig.super.json new file mode 100644 index 0000000..9cf8e60 --- /dev/null +++ b/tsconfig.super.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "esnext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "noEmit": true, + "isolatedModules": true, + "resolveJsonModule": true, + "paths": { + "~platform/*": ["./src/platform/*"], + "~material/*": ["./src/material/*"] + } + } +} diff --git a/types/lib.esnext.promise.d.ts b/types/lib.esnext.promise.d.ts new file mode 100644 index 0000000..5df9b56 --- /dev/null +++ b/types/lib.esnext.promise.d.ts @@ -0,0 +1,26 @@ +interface AnyPromiseWithResolvers { + promise: Instance; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: any) => void; +} + +type AnyPromiseConstructor = new ( + executor: ( + resolve: PromiseWithResolvers["resolve"], + reject: PromiseWithResolvers["reject"], + ) => void, +) => Promise; + +interface PromiseConstructor { + /** + * Creates a new Promise and returns it in an object, along with its resolve and reject functions. + * @returns An object with the properties `promise`, `resolve`, and `reject`. + * + * ```ts + * const { promise, resolve, reject } = Promise.withResolvers(); + * ``` + */ + withResolvers>( + this: K, + ): AnyPromiseWithResolvers>; +}