refactor: resources

This commit is contained in:
cha0s 2024-09-21 06:24:26 -05:00
parent b20795137e
commit 9d176c2930
12 changed files with 372 additions and 57 deletions

View File

@ -64,6 +64,10 @@ function handleBotRequest(
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
// security
responseHeaders.set("Cross-Origin-Embedder-Policy", "require-corp");
responseHeaders.set("Cross-Origin-Opener-Policy", "same-origin");
responseHeaders.set("Cross-Origin-Resource-Policy", "same-site");
resolve(
new Response(stream, {

View File

@ -1,11 +1,5 @@
import {LRUCache} from 'lru-cache';
import Ecs from '@/ecs/ecs.js';
import {withResolvers} from '@/util/promise.js';
const cache = new LRUCache({
max: 128,
});
import {readAsset} from '@/util/resources.js';
export default class ClientEcs extends Ecs {
constructor(specification) {
@ -19,16 +13,10 @@ export default class ClientEcs extends Ecs {
}
});
}
async readAsset(uri) {
if (!cache.has(uri)) {
const {promise, resolve, reject} = withResolvers();
cache.set(uri, promise);
fetch(new URL(uri, location.origin))
.then(async (response) => {
resolve(response.ok ? response.arrayBuffer() : new ArrayBuffer(0));
})
.catch(reject);
}
return cache.get(uri);
async readAsset(path) {
const resource = await readAsset(path);
return resource
? resource
: new ArrayBuffer(0);
}
}

View File

@ -8,7 +8,6 @@ import {RESOLUTION} from '@/util/constants.js';
import EventEmitter from '@/util/event-emitter.js';
import addKeyListener from './add-key-listener.js';
import ClientEcs from './client-ecs.js';
import Disconnected from './dom/disconnected.jsx';
import Chat from './dom/chat/chat.jsx';
import Bag from './dom/bag.jsx';
@ -49,8 +48,6 @@ function Ui({disconnected}) {
const [inventorySlots, setInventorySlots] = useState(emptySlots());
const [activeSlot, setActiveSlot] = useState(0);
const [scale, setScale] = useState(2);
const [Components, setComponents] = useState();
const [Systems, setSystems] = useState();
const monopolizers = useRef([]);
const [message, setMessage] = useState('');
const [chatIsOpen, setChatIsOpen] = useState(false);
@ -64,24 +61,6 @@ function Ui({disconnected}) {
const [externalInventory, setExternalInventory] = useState();
const [externalInventorySlots, setExternalInventorySlots] = useState();
const [particleWorker, setParticleWorker] = useState();
const refreshEcs = useCallback(() => {
class ClientEcsPerf extends ClientEcs {
markChange() {}
}
ecsRef.current = new ClientEcsPerf({Components, Systems});
}, [ecsRef, Components, Systems]);
useEffect(() => {
async function setEcsStuff() {
const {default: Components} = await import('@/ecs/components/index.js');
const {default: Systems} = await import('@/ecs/systems/index.js');
setComponents(Components);
setSystems(Systems);
}
setEcsStuff();
}, []);
useEffect(() => {
refreshEcs();
}, [refreshEcs]);
useEffect(() => {
let handle;
if (disconnected) {
@ -238,10 +217,11 @@ function Ui({disconnected}) {
});
}, [client, keepHotbarOpen, setDebug]);
const onEcsChangePacket = useCallback(() => {
refreshEcs();
mainEntityRef.current = undefined;
monopolizers.current = [];
}, [refreshEcs, mainEntityRef]);
}, [
mainEntityRef,
]);
usePacket('EcsChange', onEcsChangePacket);
const onTickPacket = useCallback(async (payload, client) => {
if (0 === Object.keys(payload.ecs).length) {

View File

@ -1,4 +1,3 @@
import {json} from "@remix-run/node";
import {useCallback, useEffect, useRef, useState} from 'react';
import {useOutletContext, useParams} from 'react-router-dom';
@ -10,13 +9,9 @@ import EcsContext from '@/react/context/ecs.js';
import MainEntityContext from '@/react/context/main-entity.js';
import RadiansContext from '@/react/context/radians.js';
import useAnimationFrame from '@/react/hooks/use-animation-frame.js';
import {juggleSession} from '@/server/session.server.js';
import {TAU} from '@/util/math.js';
export async function loader({request}) {
await juggleSession(request);
return json({});
}
import ClientEcs from '@/react/components/client-ecs.js';
export default function PlaySpecific() {
const Client = useOutletContext();
@ -24,6 +19,8 @@ export default function PlaySpecific() {
const [client, setClient] = useState();
const mainEntityRef = useRef();
const debugTuple = useState(false);
const [Components, setComponents] = useState();
const [Systems, setSystems] = useState();
const ecsRef = useRef();
const [disconnected, setDisconnected] = useState(false);
const params = useParams();
@ -64,6 +61,36 @@ export default function PlaySpecific() {
removeEventListener('beforeunload', onBeforeUnload);
};
});
const refreshEcs = useCallback(() => {
class ClientEcsPerf extends ClientEcs {
markChange() {}
}
ecsRef.current = new ClientEcsPerf({Components, Systems});
}, [ecsRef, Components, Systems]);
useEffect(() => {
async function setEcsStuff() {
const {default: Components} = await import('@/ecs/components/index.js');
const {default: Systems} = await import('@/ecs/systems/index.js');
setComponents(Components);
setSystems(Systems);
}
setEcsStuff();
}, []);
useEffect(() => {
refreshEcs();
}, [refreshEcs]);
useEffect(() => {
if (!client) {
return;
}
function onEcsChangePacket() {
refreshEcs();
}
client.addPacketListener('EcsChange', onEcsChangePacket);
return () => {
client.removePacketListener('EcsChange', onEcsChangePacket);
};
}, [client, refreshEcs]);
useEffect(() => {
if (!client) {
return;

View File

@ -1,12 +1,66 @@
import {settings} from '@pixi/core';
import {json, useLoaderData} from "@remix-run/react";
import {useEffect, useState} from 'react';
import {Outlet, useParams} from 'react-router-dom';
import {
computeMissing,
fetchResources,
get,
readAsset,
set,
} from '@/util/resources.js';
import styles from './play.module.css';
settings.ADAPTER.fetch = async (path) => {
const resource = await readAsset(path);
return resource ? new Response(resource) : new Response(undefined, {status: 404});
};
export async function loader({request}) {
const {juggleSession} = await import('@/server/session.server.js');
const {loadManifest} = await import('@/util/resources.server.js');
await juggleSession(request);
return json({
manifest: await loadManifest(),
});
}
export default function Play() {
const {manifest} = useLoaderData();
const [Client, setClient] = useState();
const params = useParams();
const [type] = params['*'].split('/');
useEffect(() => {
const controller = new AbortController();
const {signal} = controller;
async function receiveResources() {
const current = await get();
const paths = await computeMissing(current, manifest);
if (paths.length > 0 && !signal.aborted) {
try {
const resources = await fetchResources(paths, {signal});
if (resources) {
for (const key in resources) {
current[key] = resources[key];
}
await set(current);
}
}
catch (e) {
if ((e instanceof DOMException) && 'AbortError' === e.name) {
return;
}
throw e;
}
}
}
receiveResources();
return () => {
controller.abort();
};
}, [manifest]);
useEffect(() => {
async function loadClient() {
let Client;

View File

@ -0,0 +1,8 @@
import {encodeResources, loadResources} from '@/util/resources.server.js';
export async function action({request}) {
const paths = await request.json();
const assets = await loadResources();
const buffer = await encodeResources(paths.map((path) => assets[path]));
return new Response(buffer);
}

View File

@ -8,7 +8,9 @@ import {getSession} from '@/server/session.server.js';
import Engine from './engine.js';
const isInsecure = process.env.SILPHIUS_INSECURE_HTTP;
const {
RESOURCES_PATH = [process.cwd(), 'resources'].join('/'),
} = process.env;
global.__silphiusWebsocket = null;
@ -20,13 +22,18 @@ class SocketServer extends Server {
return join(import.meta.dirname, '..', '..', 'data', 'remote', 'UNIVERSE', path);
}
async readAsset(path) {
const url = new URL(path, 'https://localhost:3000')
if (isInsecure) {
url.protocol = 'http:';
const {pathname} = new URL(path, 'http://example.org');
const resourcePath = pathname.slice('/resources/'.length);
try {
const {buffer} = await readFile([RESOURCES_PATH, resourcePath].join('/'));
return buffer;
}
catch (error) {
if ('ENOENT' !== error.code) {
throw error;
}
return new ArrayBuffer(0);
}
return fetch(url.href).then((response) => (
response.ok ? response.arrayBuffer() : new ArrayBuffer(0)
));
}
async readData(path) {
const qualified = this.constructor.qualify(path);

View File

@ -3,6 +3,7 @@ import {del, get, set} from 'idb-keyval';
import {encode} from '@/net/packets/index.js';
import Server from '@/net/server.js';
import {withResolvers} from '@/util/promise.js';
import {readAsset} from '@/util/resources.js';
import createEcs from './create/ecs.js';
import './create/forest.js';
@ -21,9 +22,10 @@ class WorkerServer extends Server {
return ['UNIVERSE', path].join('/');
}
async readAsset(path) {
return fetch(path).then((response) => (
response.ok ? response.arrayBuffer() : new ArrayBuffer(0)
));
const resource = await readAsset(path);
return resource
? resource
: new ArrayBuffer(0);
}
async readData(path) {
const data = await get(this.constructor.qualify(path));

81
app/util/resources.js Normal file
View File

@ -0,0 +1,81 @@
export async function computeMissing(current, manifest) {
const missing = [];
for (const path in manifest) {
if (!current || !current[path] || current[path].hash !== manifest[path]) {
missing.push(path);
}
}
return missing;
}
const octets = [];
for (let i = 0; i < 256; ++i) {
octets.push(i.toString(16).padStart(2, '0'));
}
export async function parseResources(resources, buffer) {
const view = new DataView(buffer);
let caret = 0;
const count = view.getUint32(caret);
caret += 4;
const manifest = {};
for (let i = 0; i < count; ++i) {
let hash = '';
for (let j = 0; j < 20; ++j) {
hash += octets[view.getUint8(caret)]
caret += 1;
}
const byteLength = view.getUint32(caret);
caret += 4;
const asset = new ArrayBuffer(byteLength);
const assetView = new DataView(asset);
for (let j = 0; j < asset.byteLength; ++j) {
assetView.setUint8(j, view.getUint8(caret));
caret += 1;
}
manifest[resources[i]] = {asset, hash};
}
return manifest;
}
export async function fetchResources(paths, {signal} = {}) {
const response = await fetch('/resources/stream', {
body: JSON.stringify(paths),
method: 'post',
signal,
});
if (signal.aborted) {
return undefined;
}
return parseResources(paths, await response.arrayBuffer());
}
export class Cache {
async get() {
const {get} = await import('idb-keyval');
const resources = await get('$$silphius_resources');
return resources || {};
}
async set(manifest) {
const {set} = await import('idb-keyval');
return set('$$silphius_resources', manifest);
}
}
export async function get() {
const {get} = await import('idb-keyval');
const resources = await get('$$silphius_resources');
return resources || {};
}
export async function set(resources) {
const {set} = await import('idb-keyval');
await set('$$silphius_resources', resources);
}
export async function readAsset(path) {
const {pathname} = new URL(path, 'http://example.org');
const resourcePath = pathname.slice('/resources/'.length);
const resources = await get();
return resources[resourcePath]?.asset;
}

View File

@ -0,0 +1,110 @@
import {createHash} from 'node:crypto';
import {readFile, realpath} from 'node:fs/promises';
import chokidar from 'chokidar';
import {glob} from 'glob';
import {singleton} from './singleton.js';
const {
RESOURCES_PATH = [process.cwd(), 'resources'].join('/'),
} = process.env;
const resources = singleton('resources', {});
const manifest = singleton('manifest', {});
const RESOURCES_PATH_REAL = await realpath(RESOURCES_PATH);
const RESOURCES_GLOB = [RESOURCES_PATH_REAL, '**', '*'].join('/');
async function computeAsset(path) {
const buffer = await readFile(path);
return {
asset: buffer,
hash: createHash('sha1').update(buffer).digest(),
path: path.slice(RESOURCES_PATH_REAL.length + 1),
};
}
async function assetPaths() {
return glob(RESOURCES_GLOB, {nodir: true});
}
export async function createManifest() {
const paths = await assetPaths();
const entries = await Promise.all(paths.map(async (path) => {
const asset = await computeAsset(path);
return [asset.path, asset.hash.toString('hex')];
}))
return Object.fromEntries(
entries.filter(Boolean),
);
}
export async function loadManifest() {
emptyCheck: {
for (const path in manifest) { // eslint-disable-line no-unused-vars
break emptyCheck;
}
const created = await createManifest();
for (const path in created) {
manifest[path] = created[path];
}
const watcher = chokidar.watch(RESOURCES_GLOB);
watcher.on('add', async (path) => {
const asset = await computeAsset(path);
manifest[asset.path] = asset.hash.toString('hex');
});
watcher.on('change', async (path) => {
const asset = await computeAsset(path);
manifest[asset.path] = asset.hash.toString('hex');
});
watcher.on('unlink', async (path) => {
delete manifest[path.slice(import.meta.dirname.length - 1)];
});
}
return manifest;
}
export async function encodeResources(resources) {
let byteLength = 4;
for (const {asset} of resources) {
byteLength += 20;
byteLength += 4;
byteLength += asset.byteLength;
}
const buffer = new ArrayBuffer(byteLength);
const view = new DataView(buffer);
let caret = 0;
view.setUint32(caret, resources.length);
caret += 4;
for (const {asset, hash} of resources) {
const hashView = new DataView(hash);
for (let j = 0; j < 20; ++j) {
view.setUint8(caret, hashView.getUint8(j));
caret += 1;
}
view.setUint32(caret, asset.byteLength);
caret += 4;
const assetView = new DataView(asset);
for (let j = 0; j < asset.byteLength; ++j) {
view.setUint8(caret, assetView.getUint8(j));
caret += 1;
}
}
return buffer;
}
export async function loadResources() {
emptyCheck: {
for (const path in resources) { // eslint-disable-line no-unused-vars
break emptyCheck;
}
const paths = await assetPaths();
await Promise.all(
paths.map(async (path) => {
const {asset, hash, path: assetPath} = await computeAsset(path);
resources[assetPath] = {asset: asset.buffer, hash: hash.buffer};
}),
);
}
return resources;
}

View File

@ -0,0 +1,45 @@
import {expect, test} from 'vitest';
import {computeMissing, parseResources} from './resources.js';
import {encodeResources} from './resources.server.js';
test('cache', async () => {
class TestCache {
current;
async get() {
return new Promise((resolve) => {
resolve(this.current);
});
}
async set(manifest) {
return new Promise((resolve) => {
this.current = manifest;
resolve();
});
}
}
const cache = new TestCache();
expect(await computeMissing(await cache.get(), {foo: 'bar'})).to.deep.equal(['foo']);
await cache.set({foo: {hash: 'bar'}});
expect(await computeMissing(await cache.get(), {foo: 'bar'})).to.deep.equal([]);
expect(await computeMissing(await cache.get(), {foo: 'bar', baz: '32'})).to.deep.equal(['baz']);
await cache.set({foo: {hash: 'bar'}, baz: '32'});
expect(await computeMissing(await cache.get(), {foo: 'bar', baz: '64'})).to.deep.equal(['baz']);
});
test('codec', async () => {
const asset = {
asset: (new Uint8Array('hello'.split('').map((letter) => letter.charCodeAt(0)))).buffer,
hash: (new Uint8Array(Array(20).fill(0).map((_, i) => i))).buffer,
};
const buffer = await encodeResources([
asset,
]);
const parsed = await parseResources(['hello.txt'], buffer);
expect('hello.txt' in parsed).to.equal(true);
expect(parsed['hello.txt'].hash).to.equal('000102030405060708090a0b0c0d0e0f10111213');
const hello = new Uint8Array(parsed['hello.txt'].asset);
for (let i = 0; i < 5; ++i) {
expect(hello[i]).to.equal('hello'.charCodeAt(i));
}
});

9
app/util/singleton.js Normal file
View File

@ -0,0 +1,9 @@
export function singleton(key, value) {
global.__singletons ??= {};
global.__singletons[key] ??= value;
return global.__singletons[key];
}
singleton.reset = function (key) {
delete global.__singletons[key];
}