chore: synchronize with flecks-vite

This commit is contained in:
cha0s 2024-11-04 12:39:06 -06:00
parent 9494e0b901
commit 2e6e339d6d
37 changed files with 7481 additions and 4753 deletions

View File

@ -20,30 +20,19 @@ module.exports = {
es6: true,
},
globals: {
process: false,
WeakRef: false,
},
ignorePatterns: ['!**/.server', '!**/.client'],
rules: {
'no-constant-condition': ['error', {checkLoops: false}],
},
// Base config
extends: ['eslint:recommended'],
rules: {
'no-constant-condition': ['error', {checkLoops: false}],
'require-yield': 0,
},
overrides: [
// Tests
{
files: ['**/*.test.{js,jsx,ts,tsx}'],
rules: {
'no-empty-pattern': 'off',
},
},
// React
{
files: ['**/*.{jsx,tsx}'],
files: ['**/*.{js,jsx}'],
plugins: ['react', 'jsx-a11y'],
extends: [
'plugin:react/recommended',
@ -57,8 +46,8 @@ module.exports = {
},
formComponents: ['Form'],
linkComponents: [
{ name: 'Link', linkAttribute: 'to' },
{ name: 'NavLink', linkAttribute: 'to' },
{name: 'Link', linkAttribute: 'to'},
{name: 'NavLink', linkAttribute: 'to'},
],
},
rules: {
@ -72,15 +61,27 @@ module.exports = {
// Node
{
files: [
'app/websocket.js',
'.eslintrc.cjs',
'server.js',
'**/.server/**',
'*.server.{js,jsx}',
'**/{build,node}.js',
'vite.config.js',
'vitest.workspace.js',
],
env: {
node: true,
},
},
// game scripts
{
files: [
'resources/**/*.js',
],
rules: {
'require-yield': 0,
},
},
],
};

4
.gitignore vendored
View File

@ -2,7 +2,7 @@ node_modules
/.cache
/build
/coverage
.env
/data
/dev
.env

View File

@ -1,17 +1,20 @@
process.env.STORYBOOK = 1
process.env.REMIX_DISABLED = 1
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@chromatic-com/storybook',
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
core: {
disableTelemetry: true,
},
framework: {
name: '@storybook/react-vite',
options: {},
},
stories: ['../app/**/*.stories.mdx', '../app/**/*.stories.@(js|jsx|mjs)'],
};
export default config;

9
.vscode/css_custom_data.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"version": 1.1,
"atDirectives": [
{
"name": "@tailwind",
"description": "Use the @tailwind directive to insert Tailwind's `base`, `components`, `utilities`, and `screens` styles into your CSS."
}
]
}

49
.vscode/launch.json vendored
View File

@ -7,59 +7,38 @@
{
"type": "chrome",
"request": "launch",
"name": "Silphius Chrome",
"url": "https://localhost:3000",
"name": "Client",
"webRoot": "${workspaceFolder}",
"runtimeArgs": ["--auto-open-devtools-for-tabs"]
"outputCapture": "std",
"runtimeArgs": [
"https://localhost:3200/",
"https://localhost:3200/storybook"
]
},
{
"type": "node",
"request": "launch",
"name": "Silphius Dev",
"name": "Server",
"skipFiles": [
"<node_internals>/**"
],
"resolveSourceMapLocations": [],
"cwd": "${workspaceFolder}",
"env": {
"MKCERT": "1",
"PORT": "3200"
},
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
},
{
"type": "chrome",
"request": "launch",
"name": "Storybook Chrome",
"url": "http://localhost:6006",
"webRoot": "${workspaceFolder}",
},
{
"type": "node",
"request": "launch",
"name": "Storybook Dev",
"skipFiles": [
"<node_internals>/**"
],
"resolveSourceMapLocations": [],
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "storybook", "--", "--no-open"],
},
],
"compounds": [
{
"name": "Silphius",
"name": "App",
"configurations": [
"Silphius Dev",
"Silphius Chrome",
"Server",
"Client",
],
"stopAll": true,
},
{
"name": "Storybook",
"configurations": [
"Storybook Dev",
"Storybook Chrome",
],
"stopAll": true,
}
]
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"css.customData": [".vscode/css_custom_data.json"]
}

View File

@ -4,9 +4,9 @@
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import {RemixBrowser} from '@remix-run/react';
import {startTransition, StrictMode} from 'react';
import {hydrateRoot} from 'react-dom/client';
startTransition(() => {
hydrateRoot(

View File

@ -4,17 +4,18 @@
* For more information, see https://remix.run/file-conventions/entry.server
*/
import { PassThrough } from "node:stream";
import {PassThrough} from 'node:stream';
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import {createReadableStreamFromReadable} from '@remix-run/node';
import {RemixServer} from '@remix-run/react';
import {isbot} from 'isbot';
import {renderToPipeableStream} from 'react-dom/server';
import {hooks} from 'virtual:sylvite/server';
const ABORT_DELAY = 5_000;
export async function handleUpgrade(request, socket, head) {
const {handleUpgrade} = await import('./silphius/server/websocket.js');
const {handleUpgrade} = await import('./websocket.js');
handleUpgrade(request, socket, head);
}
@ -23,23 +24,21 @@ export default function handleRequest(
responseStatusCode,
responseHeaders,
remixContext,
// This is ignored so we can keep it in the template for visibility. Feel
// free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line no-unused-vars
loadContext
) {
return isbot(request.headers.get("user-agent") || "")
return isbot(request.headers.get('user-agent') || '')
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
remixContext,
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
remixContext,
loadContext,
);
}
@ -51,7 +50,7 @@ function handleBotRequest(
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
const {pipe, abort} = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
@ -63,7 +62,7 @@ function handleBotRequest(
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
@ -97,11 +96,12 @@ function handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
remixContext,
loadContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
const {pipe, abort} = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
@ -110,15 +110,11 @@ function handleBrowserRequest(
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const body = hooks.call('@/sylvite/cha0s-remix:requestBody', new PassThrough(), request, loadContext);
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");
// responseHeaders.set("Content-Security-Policy", "default-src 'self'");
responseHeaders.set('Content-Type', 'text/html');
hooks.call('@/sylvite/cha0s-remix:responseHeaders', responseHeaders, request, loadContext);
resolve(
new Response(stream, {

14
app/lib/event-source.js Normal file
View File

@ -0,0 +1,14 @@
import streamResponse from './stream-response';
export function eventSource(request, init) {
const headers = new Headers();
headers.set('Content-Type', 'text/event-stream');
return streamResponse(request, headers, (send, close) => {
const encoder = new TextEncoder();
function sendEvent(data, event = 'message') {
send(encoder.encode(`event: ${event}\n`));
send(encoder.encode(`data: ${data}\n\n`));
}
return init(sendEvent, close);
});
}

7
app/lib/long-poll.js Normal file
View File

@ -0,0 +1,7 @@
import streamResponse from './stream-response';
export function longPoll(request, init) {
const headers = new Headers();
headers.set('Content-Type', 'text/html');
return streamResponse(request, headers, init);
}

View File

@ -6,4 +6,8 @@ export function singleton(key, value) {
singleton.reset = function (key) {
delete global.__singletons[key];
}
};
singleton.set = function (key, value) {
global.__singletons[key] = value;
};

View File

@ -0,0 +1,30 @@
export default function streamResponse(request, headers, init) {
headers.set('Connection', 'keep-alive');
headers.set('Cache-Control', 'no-store, no-transform');
const stream = new ReadableStream({
start(controller) {
function send(chunk) {
controller.enqueue(chunk);
}
const cleanup = init(send, close);
let closed = false;
function close() {
if (closed) {
return;
}
cleanup();
closed = true;
request.signal.removeEventListener('abort', close);
controller.close();
}
request.signal.addEventListener('abort', close);
if (request.signal.aborted) {
close();
}
},
});
return new Response(stream, {
headers,
status: 200,
});
}

View File

@ -0,0 +1,33 @@
import {Buffer} from 'node:buffer';
import {Transform} from 'node:stream';
const TERMINATION = Buffer.from('</body></html>');
export class TopLevelLongPoll extends Transform {
constructor(pump) {
super();
this.pump = pump;
}
async _flush(done) {
await this.pump((chunk) => {
this.push(chunk);
});
this.push(TERMINATION);
done();
}
_transform(chunk, encoding, done) {
if (0 === Buffer.compare(TERMINATION, chunk.slice(-TERMINATION.length))) {
this.push(chunk.slice(0, -TERMINATION.length));
done();
}
else {
this.push(chunk);
done();
}
}
}

View File

@ -4,11 +4,11 @@ import {
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
} from '@remix-run/react';
import './root.css';
export function Layout({ children }) {
export function Layout({children}) {
return (
<html lang="en">
<head>

17
app/silphius/build.js Normal file
View File

@ -0,0 +1,17 @@
import {fileURLToPath} from 'node:url';
export async function implement({hooks}) {
hooks.tap('sylvite:viteConfig', (config) => ({
...config,
resolve: {
...config.resolve,
alias: [
...config.resolve.alias,
{
find: '%',
replacement: fileURLToPath(new URL('../../resources', import.meta.url))
},
],
},
}));
}

15
app/silphius/node.js Normal file
View File

@ -0,0 +1,15 @@
import express from 'express';
export function implement({hooks}) {
hooks.tap('@/sylvite/cha0s-remix:initializeExpress', () => {
// patch pixi server context
import('./server/pixi-context.js');
});
hooks.tap('@/sylvite/cha0s-remix:additionalAssets', (app) => {
// resources
app.use(
'/resources',
express.static('resources', { maxAge: '1h' }),
);
});
}

View File

@ -0,0 +1,10 @@
import {randomBytes} from 'node:crypto';
export function implement({hooks}) {
hooks.tap('@/sylvite/cha0s-remix:getRemixLoadContext', (context) => {
return {
...context,
nonce: randomBytes(16).toString('hex'),
};
});
}

View File

@ -0,0 +1,31 @@
import {Transform} from 'node:stream';
export function implement({hooks}) {
hooks.tap('@/sylvite/cha0s-remix:responseHeaders', (headers, request, loadContext) => {
if ('production' !== process.env.NODE_ENV) {
return;
}
// high security
headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
headers.set('Cross-Origin-Opener-Policy', 'same-origin');
headers.set('Cross-Origin-Resource-Policy', 'same-site');
headers.set('Content-Security-Policy', [
'default-src', "'self'", 'ws:', 'wss:',
`'nonce-${loadContext.nonce}'`,
].join(' '));
});
class Noncer extends Transform {
constructor(nonce) {
super();
this.nonce = nonce;
}
_transform(chunk, encoding, done) {
const string = chunk.toString();
this.push(string.replaceAll('<script', `<script nonce="${this.nonce}"`));
done();
}
}
hooks.tap('@/sylvite/cha0s-remix:requestBody', (stream, request, loadContext) => {
return stream.pipe(new Noncer(loadContext.nonce));
});
}

View File

@ -0,0 +1,33 @@
import compression from 'compression';
export function register({tapable: {SyncHook, SyncWaterfallHook}}) {
const additionalAssets = new SyncHook(['app']);
const beforeExpressCatchAll = new SyncHook(['app']);
const getRemixLoadContext = new SyncWaterfallHook(['context', 'request']);
const httpServerOptions = new SyncWaterfallHook(['options']);
const initializeExpress = new SyncHook(['app']);
return {
additionalAssets,
beforeExpressCatchAll,
httpServerOptions,
initializeExpress,
getRemixLoadContext,
};
}
export function implement({config: {compression: useCompression = true}, hooks}) {
hooks.tap('@/sylvite/cha0s-remix:initializeExpress', (app) => {
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
app.disable('x-powered-by');
app.set('trust proxy', process.env.TRUSTED_PROXIES);
if (useCompression) {
app.use(compression());
}
});
hooks.tap('@/sylvite/cha0s-remix:getRemixLoadContext', (context, request) => {
return {
...context,
ip: request.ip,
};
});
}

View File

@ -0,0 +1,21 @@
import {dirname, relative, resolve} from 'node:path';
export function register({loaded, meta, tapable: {SyncHook}}) {
const appDir = [meta.dirname, 'app'].join('/');
const defineRemixRoutes = new SyncHook(['route']);
// resolve paths relative to /app to avoid leaking path into manifest
defineRemixRoutes.intercept({
register: (tap) => {
const {fn, name} = tap;
const local = dirname(loaded[name].resolved);
tap.fn = (route) => {
fn((routePath, filePath, ...optionsAndOrChildren) => {
route(routePath, relative(appDir, resolve(local, filePath)), ...optionsAndOrChildren);
});
}
},
});
return {
defineRemixRoutes,
};
}

View File

@ -0,0 +1,8 @@
export function register({tapable: {SyncHook, SyncWaterfallHook}}) {
const requestBody = new SyncWaterfallHook(['stream', 'request', 'loadContext']);
const responseHeaders = new SyncHook(['headers', 'request', 'loadContext']);
return {
requestBody,
responseHeaders,
};
}

3
app/websocket-routes.js Normal file
View File

@ -0,0 +1,3 @@
export const routes = import.meta.glob(
['./websocket/*.js', '!./websocket/*.test.js'],
);

38
app/websocket.js Normal file
View File

@ -0,0 +1,38 @@
import {WebSocketServer} from 'ws';
import {singleton} from '@/lib/singleton.js';
let wss = singleton('cha0s-remix$$wss');
if (!wss) {
wss = new WebSocketServer({
clientTracking: !!import.meta.hot,
noServer: true,
});
wss.on('connection', async (websocket, request) => {
const {routes} = await import('./websocket-routes.js');
const {pathname} = new URL(request.url, 'https://example.com');
const M = routes[`./websocket${'/' === pathname ? '/_index' : pathname}.js`];
if (!M) {
websocket.close(1008, 'does not exist');
return;
}
const {handleConnection} = await M();
handleConnection(websocket, request);
});
singleton.set('cha0s-remix$$wss', wss);
}
export function handleUpgrade(request, socket, head) {
wss.handleUpgrade(request, socket, head, (websocket) => {
wss.emit('connection', websocket, request);
});
}
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', async () => {
for (const websocket of wss.clients) {
websocket.close(1001, 'hmr');
}
});
import.meta.hot.accept();
}

5
app/websocket/_index.js Normal file
View File

@ -0,0 +1,5 @@
// This is the / route. Do something with the websocket.
export async function handleConnection(websocket, request) { // eslint-disable-line no-unused-vars
websocket.on('message', (message) => { // eslint-disable-line no-unused-vars
});
}

10
app/websocket/example.js Normal file
View File

@ -0,0 +1,10 @@
export async function handleConnection(websocket, request) {
websocket.on('message', (message) => {
console.log(
'[websocket] /example received',
message.toString(),
'from',
request.socket.remoteAddress,
);
});
}

21
environment.server.js Normal file
View File

@ -0,0 +1,21 @@
export default function environment() {
const {
HTTPS_CERT,
HTTPS_KEY,
MKCERT,
NODE_ENV,
PORT = 3100,
} = process.env;
const useMkcert = !!MKCERT;
const cacheDirectory = `${import.meta.dirname}/node_modules/.cache`;
const httpsCert = HTTPS_CERT || (useMkcert && `${cacheDirectory}/localhost.pem`);
const httpsKey = HTTPS_KEY || (useMkcert && `${cacheDirectory}/localhost-key.pem`);
return {
httpsCert,
httpsKey,
isInsecure: !httpsCert || !httpsKey,
isProduction: NODE_ENV === 'production',
port: PORT,
useMkcert,
};
}

11301
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,11 +5,10 @@
"type": "module",
"scripts": {
"build": "remix vite:build",
"dev": "NODE_OPTIONS=--use-openssl-ca node ./server.js",
"dev": "node ./server.js",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "cross-env NODE_ENV=production npm run dev",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build",
"start": "cross-env NODE_ENV=production node ./server.js",
"storybook": "storybook dev -p 11337 --no-open",
"test": "vitest app"
},
"dependencies": {
@ -24,13 +23,14 @@
"@pixi/spritesheet": "^7.4.2",
"@pixi/tilemap": "^4.1.0",
"@react-hook/resize-observer": "^2.0.1",
"@remix-run/express": "^2.9.2",
"@remix-run/node": "^2.9.2",
"@remix-run/react": "^2.9.2",
"@remix-run/express": "^2.11.2",
"@remix-run/node": "^2.11.2",
"@remix-run/react": "^2.11.2",
"@remix-run/serve": "^2.11.2",
"alea": "^1.0.1",
"compression": "^1.7.4",
"cross-env": "^7.0.3",
"express": "^4.18.2",
"express": "^4.19.2",
"idb-keyval": "^6.2.1",
"isbot": "^4.1.0",
"kefir": "^3.8.8",
@ -42,33 +42,39 @@
"remark-mdx": "^3.0.1",
"remark-parse": "^11.0.0",
"simplex-noise": "^4.0.1",
"sylvite": "^1.0.3",
"unified": "^11.0.5",
"unist-util-visit-parents": "^6.0.1",
"ws": "^8.17.0"
"ws": "^8.18.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.5.0",
"@remix-run/dev": "^2.9.2",
"@remix-run/dev": "^2.11.2",
"@storybook/addon-essentials": "^8.1.6",
"@storybook/addon-interactions": "^8.1.6",
"@storybook/addon-links": "^8.1.6",
"@storybook/addon-onboarding": "^8.1.6",
"@storybook/blocks": "^8.1.6",
"@storybook/react": "^8.1.6",
"@storybook/react-vite": "^8.1.6",
"@storybook/test": "^8.1.6",
"@testing-library/react": "^16.0.1",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^1.6.0",
"@vitest/browser": "^2.0.5",
"@vitest/coverage-v8": "^2.0.5",
"autoprefixer": "^10.4.19",
"eslint": "^8.38.0",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"image-size": "^1.1.1",
"playwright": "^1.47.0",
"postcss": "^8.4.38",
"storybook": "^8.1.6",
"vite": "^5.1.0",
"vitest": "^1.6.0"
"vitest": "^2.0.5"
},
"packageManager": "npm@10.5.2",
"engines": {
"node": ">=20.0.0"
}

5
postcss.config.js Normal file
View File

@ -0,0 +1,5 @@
export default {
plugins: {
autoprefixer: {},
},
};

164
server.js
View File

@ -1,35 +1,56 @@
import {createRequestHandler} from '@remix-run/express';
import compression from 'compression';
import express from 'express';
import morgan from 'morgan';
import sylvite from 'sylvite';
// patch pixi server context
import('./app/silphius/server/pixi-context.js');
import environment from './environment.server.js';
const isProduction = process.env.NODE_ENV === 'production';
const isInsecure = process.env.SILPHIUS_INSECURE_HTTP;
const cacheDirectory = `${import.meta.dirname}/node_modules/.cache`;
const {hooks} = await sylvite({
entry: 'node',
...await import('./sylvite.config.js'),
});
const {
httpsCert,
httpsKey,
isInsecure,
isProduction,
port,
useMkcert,
} = environment();
const app = express();
hooks.call('@/sylvite/cha0s-remix:initializeExpress', app);
const httpServerOptions = hooks.call('@/sylvite/cha0s-remix:httpServerOptions', {});
let server;
if (isInsecure) {
// http://
const {createServer} = await import('node:http');
server = createServer(app);
server = createServer(httpServerOptions, app);
}
else {
const {execSync} = await import('node:child_process');
const {mkdirSync, readFileSync, statSync} = await import('node:fs');
const cacheDirectory = `${import.meta.dirname}/node_modules/.cache`;
mkdirSync(cacheDirectory, {recursive: true});
try {
statSync(`${cacheDirectory}/localhost-key.pem`);
}
catch (error) { // eslint-disable-line no-unused-vars
execSync(`mkcert -cert-file ${cacheDirectory}/localhost.pem -key-file ${cacheDirectory}/localhost-key.pem localhost`)
const {readFile} = await import('node:fs/promises');
// generate certificates
if (useMkcert) {
const {execSync} = await import('node:child_process');
const {mkdirSync, statSync} = await import('node:fs');
mkdirSync(cacheDirectory, {recursive: true});
try {
statSync(`${cacheDirectory}/localhost-key.pem`);
}
catch (error) { // eslint-disable-line no-unused-vars
execSync(`mkcert -cert-file ${cacheDirectory}/localhost.pem -key-file ${cacheDirectory}/localhost-key.pem localhost`)
}
}
// https://
const [cert, key] = await Promise.all([readFile(httpsCert), readFile(httpsKey)]);
const serverOptions = {
key: readFileSync(`${cacheDirectory}/localhost-key.pem`),
cert: readFileSync(`${cacheDirectory}/localhost.pem`),
cert,
key,
...httpServerOptions,
};
const {createServer} = await import('node:https');
server = createServer(serverOptions, app);
@ -43,73 +64,66 @@ app.use(async (req, res, next) => {
await promise;
next();
});
const port = process.env.PORT || 3000;
server.listen(port, () =>
console.log(`Express server listening at http${isInsecure ? '' : 's'}://localhost:${port}`)
);
server.listen(port, () => {
console.log(`Express server listening at http${isInsecure ? '' : 's'}://localhost:${port}`);
});
// possibly load dev server and build the request handler up front
const viteDevServer = isProduction
? undefined
: await import('vite').then((vite) =>
vite.createServer({
server: {middlewareMode: {server}},
})
);
const build = () => (
viteDevServer
? viteDevServer.ssrLoadModule('virtual:remix/server-build')
: import('./build/server/index.js')
);
let remixHandler;
if (viteDevServer) {
const {createViteRuntime} = await import('vite');
const runtime = await createViteRuntime(viteDevServer);
const {handleUpgrade} = await runtime.executeEntrypoint('/app/silphius/server/websocket.js');
const requestHandlerOptions = {
getLoadContext: (req) => {
return hooks.call('@/sylvite/cha0s-remix:getRemixLoadContext', {}, req);
},
};
if (isProduction) {
// remix handler
requestHandlerOptions.build = () => import('./build/server/index.js');
// websocket handler
const {entry: {module: {handleUpgrade}}} = await requestHandlerOptions.build();
server.on('upgrade', handleUpgrade);
remixHandler = createRequestHandler({build});
// serve assets
app.use('/assets', express.static('build/client/assets', {
// vite fingerprints its assets so we can cache forever.
immutable: true,
maxAge: '1y',
}));
app.use(express.static('build/client', {
// everything else (like favicon.ico) is cached for an hour.
maxAge: '1h',
}));
}
else {
const ssr = await build();
server.on('upgrade', ssr.entry.module.handleUpgrade);
remixHandler = createRequestHandler({
build: async () => {
return ssr;
},
const viteDevServer = await import('vite').then((vite) => (
vite.createServer({
server: {
middlewareMode: true,
},
})
));
// remix handler
requestHandlerOptions.build = () => viteDevServer.ssrLoadModule('virtual:remix/server-build');
const {createViteRuntime} = await import('vite');
// websocket handler
const runtime = await createViteRuntime(viteDevServer);
const {handleUpgrade} = await runtime.executeEntrypoint('/app/websocket.js');
server.on('upgrade', handleUpgrade);
// serve assets
app.use(viteDevServer.middlewares);
app.get('/storybook', async (req, res) => {
const url = new URL(req.url, `http${isInsecure ? '' : 's'}://${req.headers.host}`);
url.pathname = '/';
url.port = 11337;
res.redirect(url.href);
});
}
// configure middleware
app.use(compression());
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
app.disable('x-powered-by');
// handle asset requests
if (viteDevServer) {
app.use(viteDevServer.middlewares);
}
else {
// Vite fingerprints its assets so we can cache forever.
app.use(
'/assets',
express.static('build/client/assets', { immutable: true, maxAge: '1y' })
);
// Everything else (like favicon.ico) is cached for an hour. You may want to be
// more aggressive with this caching.
app.use(express.static('build/client', { maxAge: '1h' }));
}
// silphius resources
app.use(
'/resources',
express.static('resources', { maxAge: '1h' }),
);
hooks.call('@/sylvite/cha0s-remix:additionalAssets', app);
app.use(morgan('tiny'));
// handle SSR requests
app.all('*', remixHandler);
hooks.call('@/sylvite/cha0s-remix:beforeExpressCatchAll', app);
// finally let requests resolve
// handle SSR requests
app.all('*', createRequestHandler(requestHandlerOptions));
// finalize; let requests resolve
resolve();

View File

@ -1,53 +0,0 @@
import {useEffect, useRef, useState} from 'react';
import Dom from '@/react/components/dom.jsx';
import {RESOLUTION} from '@/lib/constants.js';
function Decorator({children, style}) {
const ref = useRef();
const [scale, setScale] = useState(0);
useEffect(() => {
if (!ref.current) {
return;
}
function onResize() {
const {parentNode} = ref.current;
const {width} = parentNode.getBoundingClientRect();
setScale(width / RESOLUTION.x);
}
window.addEventListener('resize', onResize);
onResize();
return () => {
window.removeEventListener('resize', onResize);
}
}, [ref.current]);
return (
<div
ref={ref}
style={{
backgroundColor: '#1099bb',
flexDirection: 'column',
opacity: 0 === scale ? 0 : 1,
position: 'relative',
height: `calc(${RESOLUTION.y}px * ${scale})`,
width: '100%',
}}
>
<Dom>
<div style={style}>
{children}
</div>
</Dom>
</div>
);
}
export default function(options = {}) {
return function decorate(Decorating) {
return (
<Decorator style={options.style}>
<Decorating />
</Decorator>
);
};
}

View File

@ -1,50 +0,0 @@
import {useArgs} from '@storybook/preview-api';
import {fn} from '@storybook/test';
import Hotbar from '@/react/components/hotbar.jsx';
import DomDecorator from './dom-decorator.jsx';
const slots = Array(10).fill({});
slots[2] = {qty: 24, icon: '/resources/potion/icon.png'};
export default {
title: 'Dom/Inventory/Hotbar',
component: Hotbar,
decorators: [
(Hotbar, ctx) => {
const [, updateArgs] = useArgs();
const {onSlotMouseDown} = ctx.args;
ctx.args.onSlotMouseDown = (i) => {
updateArgs({active: i});
if (onSlotMouseDown) {
onSlotMouseDown(i);
}
};
return Hotbar();
},
DomDecorator({
style: {
display: 'flex',
flexDirection: 'column',
},
}),
],
tags: ['autodocs'],
args: {
active: 0,
onSlotMouseDown: fn(),
slots,
},
argTypes: {
active: {
control: {
type: 'number',
min: 0,
max: 9,
}
},
},
};
export const Default = {};

View File

@ -1,66 +0,0 @@
import Slot from '@/react/components/slot.jsx';
import DomDecorator from './dom-decorator.jsx';
export default {
title: 'Dom/Inventory/Slot',
component: Slot,
decorators: [
DomDecorator({
style: {
border: '2px solid #999',
lineHeight: 0,
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
},
}),
],
tags: ['autodocs'],
argTypes: {
image: {
control: 'text',
},
qty: {
control: {
type: 'number',
min: 1,
max: 9999,
}
},
},
args: {
icon: '/resources/potion/icon.png',
},
};
export const Single = {
args: {
qty: 1,
},
};
export const OneDigit = {
args: {
qty: 9,
},
};
export const TwoDigit = {
args: {
qty: 99,
},
};
export const ThreeDigit = {
args: {
qty: 999,
},
};
export const FourDigit = {
args: {
qty: 9999,
},
};

16
sylvite.config.js Normal file
View File

@ -0,0 +1,16 @@
export const manifest = {
'@/sylvite/cha0s-remix': {},
'@/sylvite/cha0s-remix/cors': {},
'@/sylvite/cha0s-remix/remix': {},
'@/silphius': {},
};
export const meta = {
dirname: import.meta.dirname,
resolve: (url) => {
if (url.startsWith('@')) {
url = ['./app', url.slice(1)].join('');
}
return import.meta.resolve(url);
},
}

9
tailwind.config.js Normal file
View File

@ -0,0 +1,9 @@
export default {
content: [
'./app/**/{**,.client,.server}/**/*.{js,jsx}',
],
theme: {
extend: {},
},
plugins: [],
};

View File

@ -3,19 +3,37 @@ import {fileURLToPath} from 'node:url';
import {vitePlugin as remix} from '@remix-run/dev';
import react from '@vitejs/plugin-react';
import sylvite from 'sylvite';
import {defineConfig} from 'vite';
const cacheDirectory = `${import.meta.dirname}/node_modules/.cache`;
import environment from './environment.server.js';
const {
httpsCert,
httpsKey,
isInsecure,
} = environment();
const {hooks} = await sylvite({
...await import('./sylvite.config.js'),
});
const plugins = [];
if (!process.env.STORYBOOK) {
if (!process.env.REMIX_DISABLED) {
plugins.push(
remix({
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
unstable_singleFetch: true,
unstable_lazyRouteDiscovery: true,
},
routes(defineRoutes) {
return defineRoutes((route) => {
hooks.call('./sylvite/remix:defineRemixRoutes', route);
});
},
}),
);
@ -24,7 +42,7 @@ else {
plugins.push(react());
}
export default defineConfig({
export default defineConfig(hooks.call('sylvite:viteConfig', {
esbuild: {
supported: {
'top-level-await': true,
@ -35,22 +53,20 @@ export default defineConfig({
alias: [
{
find: '@',
replacement: fileURLToPath(new URL('./app', import.meta.url))
},
{
find: '%',
replacement: fileURLToPath(new URL('./resources', import.meta.url))
replacement: fileURLToPath(new URL('./app', import.meta.url)),
},
],
},
server: {
host: true,
https: {
key: readFileSync(`${cacheDirectory}/localhost-key.pem`),
cert: readFileSync(`${cacheDirectory}/localhost.pem`),
},
...(!isInsecure && {
https: {
key: readFileSync(httpsKey),
cert: readFileSync(httpsCert),
},
}),
},
worker: {
format: 'es',
},
});
}));

31
vitest.workspace.js Normal file
View File

@ -0,0 +1,31 @@
import {defineWorkspace} from 'vitest/config';
process.env.REMIX_DISABLED = 1;
export default defineWorkspace([
{
extends: './vite.config.js',
test: {
include: [
'app/**/*.test.js',
],
name: 'unit',
environment: 'node',
},
},
{
extends: './vite.config.js',
test: {
include: [
'app/**/*.test.jsx',
],
name: 'browser',
browser: {
enabled: true,
headless: true,
name: 'chromium',
provider: 'playwright',
},
},
},
]);