Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/core/src/files.ccmod.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type Unzipped, unzipSync } from 'fflate/browser';
import { addFetchHandler } from './service-worker-bridge';
import { setFetchHandler } from './service-worker-bridge';
import * as files from './files';

const fileMap = new Map<string, Unzipped>();
Expand Down Expand Up @@ -62,6 +62,8 @@ export async function loadCCMods(ccmods: string[]): Promise<void> {
}),
);

// from my limited testing unzipSync is about 50ms faster (unzipSync takes 150ms)
// compared to asynchronous unzip ¯\_(ツ)_/¯
// console.time('uncompress');
const uncompressed = ccmodArrayBuffers.map((buf) => unzipSync(buf));
// console.timeEnd('uncompress');
Expand All @@ -72,5 +74,5 @@ export async function loadCCMods(ccmods: string[]): Promise<void> {
fileMap.set(modDir, buf);
}

addFetchHandler(ccmods, readFile);
setFetchHandler(ccmods, readFile);
}
117 changes: 70 additions & 47 deletions packages/core/src/service-worker-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,72 @@
export namespace ServiceWorker {
// Messages send to the service worker
export namespace Outgoing {
export interface ValidPathPrefixesPacket {
type: 'ValidPathPrefixes';
validPathPrefixes: string[];
}

export interface DataPacket {
type: 'Data';
path: string;
data: BodyInit | null;
}

export type Packet = ValidPathPrefixesPacket | DataPacket;
}
// Messages coming from the service worker
export namespace Incoming {
export interface PathPacket {
type: 'Path';
path: string;
}
export interface ValidPathPrefixesRequestPacket {
type: 'ValidPathPrefixesRequest';
}

export type Packet = PathPacket | ValidPathPrefixesRequestPacket;
}
}

export type FetchHandler = (path: string) => Promise<ArrayBufferLike | null>;

let fetchHandler: FetchHandler | undefined;
let validPathPrefixes: string[] = [];

function sendServiceWorkerMessage(packet: ServiceWorker.Outgoing.Packet): void {
const { controller } = window.navigator.serviceWorker;
controller?.postMessage(packet);
}

export function setFetchHandler(pathPrefixes: string[], handler: FetchHandler): void {
fetchHandler = handler;
validPathPrefixes = pathPrefixes.map((path) => `/${path}/`);
sendServiceWorkerMessage({ type: 'ValidPathPrefixes', validPathPrefixes });
}

function setMessageHandling(): void {
navigator.serviceWorker.onmessage = async (event) => {
const packet: ServiceWorker.Incoming.Packet = event.data;
let responsePacket: ServiceWorker.Outgoing.Packet;

if (packet.type === 'Path') {
const { path } = packet;
responsePacket = {
type: 'Data',
path,
data:
(await fetchHandler?.(path).catch((e) => {
console.error(`error while handing fetch of ${path}:`, e);
})) ?? null,
};
} else {
responsePacket = { type: 'ValidPathPrefixes', validPathPrefixes };
}

sendServiceWorkerMessage(responsePacket);
};
}

export async function loadServiceWorker(): Promise<ServiceWorker> {
const currentRegistration = await window.navigator.serviceWorker.getRegistration();
if (currentRegistration) {
Expand All @@ -20,53 +89,7 @@ export async function loadServiceWorker(): Promise<ServiceWorker> {
}

setMessageHandling();
updateServiceWorkerValidPathPrefixes();
sendServiceWorkerMessage({ type: 'ValidPathPrefixes', validPathPrefixes });

return controller;
}

function sendServiceWorkerMessage(packet: unknown): void {
const { controller } = window.navigator.serviceWorker;
controller?.postMessage(packet);
}

export type FetchHandler = (path: string) => Promise<ArrayBufferLike | null>;
const fetchHandlers: FetchHandler[] = [];
const validPathPrefixes: string[] = [];

function updateServiceWorkerValidPathPrefixes(): void {
sendServiceWorkerMessage(validPathPrefixes.map((path) => `/${path}`));
}

export function addFetchHandler(pathPrefixes: string[], handler: FetchHandler): void {
fetchHandlers.unshift(handler);
validPathPrefixes.push(...pathPrefixes);
updateServiceWorkerValidPathPrefixes();
}

export interface ServiceWorkerPacket {
path: string;
data: BodyInit | null;
}

function setMessageHandling(): void {
navigator.serviceWorker.onmessage = async (event) => {
const path: string = event.data;

let data: ArrayBufferLike | null = null;
for (const handler of fetchHandlers) {
try {
data = await handler(path);
} catch (e) {
console.error(`error while handing fetch of ${path}:`, e);
}
if (data) break;
}

const packet: ServiceWorkerPacket = {
path,
data,
};
sendServiceWorkerMessage(packet);
};
}
93 changes: 60 additions & 33 deletions packages/core/src/service-worker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ServiceWorkerPacket } from './service-worker-bridge';
import type { ServiceWorker } from './service-worker-bridge';

self.addEventListener('activate', () => {
void self.clients.claim();
Expand All @@ -23,24 +23,64 @@ function contentType(url: string): string {
return CONTENT_TYPES[url.substring(url.lastIndexOf('.') + 1)] || 'text/plain';
}

async function post(data: unknown): Promise<void> {
const cacheName = 'ccmod-service-worker-cache';

function getCacheKey(key: string): string {
return `https://${key}`;
}

async function storeInCache<T extends object>(key: string, value: T): Promise<void> {
const cache = await caches.open(cacheName);
const response = new Response(JSON.stringify(value), {
headers: { 'Content-Type': 'application/json' },
});
await cache.put(getCacheKey(key), response);
}

async function getFromCache<T extends object>(key: string): Promise<T | null> {
const cache = await caches.open(cacheName);
const response = await cache.match(getCacheKey(key));
if (!response) return null;
return await response.json();
}

const validPathPrefixesCacheKey = 'validPathPrefixes';

async function post(data: ServiceWorker.Incoming.Packet): Promise<void> {
const clients = await self.clients.matchAll();
const client = clients[0];
client.postMessage(data);
}

const waitingFor = new Map<string, (packet: ServiceWorkerPacket) => void>();
const waitingFor = new Map<string, (packet: ServiceWorker.Outgoing.DataPacket) => void>();

async function requestContents(path: string): Promise<Response> {
let resolve!: (packet: ServiceWorkerPacket) => void;
const promise = new Promise<ServiceWorkerPacket>((res) => {
resolve = res;
async function requestAndAwaitAck(
packet: ServiceWorker.Incoming.PathPacket,
): Promise<ServiceWorker.Outgoing.DataPacket> {
return new Promise<ServiceWorker.Outgoing.DataPacket>((resolve) => {
waitingFor.set(packet.path, resolve);
void post(packet);
});
await post(path);
}

let validPathPrefixes: string[] | null;

self.addEventListener('message', (event) => {
const packet: ServiceWorker.Outgoing.Packet = event.data;

if (packet.type === 'ValidPathPrefixes') {
validPathPrefixes = packet.validPathPrefixes;

void storeInCache(validPathPrefixesCacheKey, validPathPrefixes);
} else {
waitingFor.get(packet.path)?.(packet);
waitingFor.delete(packet.path);
}
});

waitingFor.set(path, resolve);
async function requestContents(path: string): Promise<Response> {
const { data } = await requestAndAwaitAck({ type: 'Path', path });

const { data } = await promise;
if (!data) {
return new Response(null, { status: 404 });
}
Expand All @@ -54,32 +94,19 @@ async function requestContents(path: string): Promise<Response> {
});
}

let validPathPrefixes: string[] | undefined;
async function respond(event: FetchEvent): Promise<Response> {
const { request } = event;
const path = decodeURI(new URL(request.url).pathname);

self.addEventListener('message', (event) => {
if (Array.isArray(event.data)) {
validPathPrefixes = event.data;
validPathPrefixes ??= await getFromCache<string[]>(validPathPrefixesCacheKey);

if (validPathPrefixes?.some((pathPrefix) => path.startsWith(pathPrefix))) {
return requestContents(path);
} else {
const packet: ServiceWorkerPacket = event.data;
const resolve = waitingFor.get(packet.path)!;
resolve(packet);
waitingFor.delete(packet.path);
return fetch(request);
}
});
}

self.addEventListener('fetch', (event: FetchEvent) => {
if (!validPathPrefixes) {
return;
}

const { request } = event;
const path = decodeURI(new URL(request.url).pathname);

if (
validPathPrefixes.some(
(pathPrefix) => path.length > pathPrefix.length && path.startsWith(pathPrefix),
)
) {
event.respondWith(requestContents(path));
}
event.respondWith(respond(event));
});
Loading