Serve the editor from feuerwerk.nockal.com as well
feuerwerk / gate (push) Successful in 4m45s
feuerwerk / suite (push) Successful in 19m52s

The subdomain proxies to the same folder, but the build had the path
/feuerwerk/ baked in. On feuerwerk.nockal.com the page loaded its
assets, the API and the data URLs from /feuerwerk/... and got 404. The
session cookie had Path=/feuerwerk/, so the browser would never have
sent it to /api/ there either.

- site.ts takes the base path from the folder of the open page:
  /feuerwerk/ on nockal.com, / on the subdomain. Release links point at
  the address you are working on.
- vite.config.ts builds with a relative base. The copy plugin rewrites
  the pages to ./dist/assets/ and stops the build if it finds nothing
  to rewrite. The dev server now runs at http://localhost:5175/.
- Auth.php sets the cookie without a Path, so it belongs to the API
  folder the browser sees. Sign-in and sign-out also clear the old
  /feuerwerk/ cookie, otherwise it would keep signing in after a
  logout. Existing sessions stay valid.
- README: the new dev server address.

Tests:
- curl on both addresses: pages, JS, CSS and API 200
- sign-in, session, sign-out on both addresses
- e2e 70 against both addresses: 13/13 each, no CSP violation
- tsc, lint, prettier clean, site and releases tests 14/14
- PHP tests 7 of 8 files green, videos.test.php fails only because
  the web-php-fpm container has no ffmpeg

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-16 08:40:18 +02:00
1 parent 5b9d084dfe
commit edef2b6323
5 files changed
+57 -17

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ Node 22 is required.
```bash ```bash
npm install npm install
npm run dev # http://localhost:5175/feuerwerk/dist/; API calls are proxied to production npm run dev # http://localhost:5175/; API and data calls are proxied to production
npm run check # typecheck + lint + format:check + unit tests; the gate CI runs too npm run check # typecheck + lint + format:check + unit tests; the gate CI runs too
npm run build # writes dist/ and regenerates index.html; the folder is what nginx serves npm run build # writes dist/ and regenerates index.html; the folder is what nginx serves
``` ```
+14 -4
View File
@@ -109,19 +109,29 @@ final class Auth
return (string) ($_COOKIE[SessionStore::COOKIE] ?? ''); return (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
} }
/**
* The cookie has no `Path`: the browser takes the folder of the API as it sees it, which is
* `/feuerwerk/api` on nockal.com and `/api` on feuerwerk.nockal.com. nginx sees the second as
* the first, so no path this side writes down would be right for both. The editor's pages and
* the data never needed the cookie.
*
* Before that, the cookie was set for the whole site (`/feuerwerk/`). Such an old one is ended
* with every new one, or it would still sign a browser in after signing out.
*/
private function setCookie(string $value, int $expires): void private function setCookie(string $value, int $expires): void
{ {
setcookie(SessionStore::COOKIE, $value, [ $options = [
'expires' => $expires, 'expires' => $expires,
'path' => self::basePath(),
'httponly' => true, 'httponly' => true,
'samesite' => 'Lax', 'samesite' => 'Lax',
'secure' => ($_SERVER['HTTPS'] ?? '') !== '', 'secure' => ($_SERVER['HTTPS'] ?? '') !== '',
]); ];
setcookie(SessionStore::COOKIE, $value, $options);
setcookie(SessionStore::COOKIE, '', ['expires' => time() - 3600, 'path' => self::basePath()] + $options);
} }
/** /**
* The path the editor is served under (`/feuerwerk/`), read off the script rather than * The path the old cookie was set for (`/feuerwerk/`), read off the script rather than
* written down: the same checkout answers under a different prefix in CI. * written down: the same checkout answers under a different prefix in CI.
*/ */
private static function basePath(): string private static function basePath(): string
+12 -2
View File
@@ -1,5 +1,15 @@
/** The path the site is served under: the editor, the play page, the API and every data file. */ /**
export const SITE_PATH = '/feuerwerk/'; * The path the site is served under: the editor, the play page, the API and every data file. It
* is the folder of the open page, so one build answers at https://nockal.com/feuerwerk/ and at
* https://feuerwerk.nockal.com/ alike. Away from a page (Node, the tests) it is the nginx path.
*/
export const SITE_PATH = sitePath(typeof location === 'undefined' ? undefined : location.pathname);
/** The folder of a page path: `/feuerwerk/play.html` and `/feuerwerk/` give `/feuerwerk/`. */
export function sitePath(pagePath: string | undefined): string {
if (pagePath === undefined) return '/feuerwerk/';
return pagePath.slice(0, pagePath.lastIndexOf('/') + 1);
}
/** The API's one entry point. */ /** The API's one entry point. */
export const API_ENDPOINT = `${SITE_PATH}api/index.php`; export const API_ENDPOINT = `${SITE_PATH}api/index.php`;
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { API_ENDPOINT, dataPath, moveOldStoredKeys } from '../../src/core/site'; import { API_ENDPOINT, dataPath, moveOldStoredKeys, sitePath } from '../../src/core/site';
function memoryStore(initial: Record<string, string>) { function memoryStore(initial: Record<string, string>) {
const items = new Map(Object.entries(initial)); const items = new Map(Object.entries(initial));
@@ -21,6 +21,14 @@ describe('site', () => {
expect(dataPath('textures')).toBe('/feuerwerk/data/textures'); expect(dataPath('textures')).toBe('/feuerwerk/data/textures');
}); });
it('takes the path from the folder of the open page', () => {
expect(sitePath('/feuerwerk/')).toBe('/feuerwerk/');
expect(sitePath('/feuerwerk/index.html')).toBe('/feuerwerk/');
expect(sitePath('/feuerwerk/play.html')).toBe('/feuerwerk/');
expect(sitePath('/')).toBe('/');
expect(sitePath('/play.html')).toBe('/');
});
it('moves the keys stored under the old name and leaves the others alone', () => { it('moves the keys stored under the old name and leaves the others alone', () => {
const store = memoryStore({ const store = memoryStore({
'game-maker.panel-sizes': '{"left":300}', 'game-maker.panel-sizes': '{"left":300}',
+21 -9
View File
@@ -1,27 +1,38 @@
import { copyFileSync } from 'node:fs'; import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path'; import { basename, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { defineConfig, type Plugin } from 'vite'; import { defineConfig, type Plugin } from 'vite';
import { publishPlugins } from './tools/lib/plugins.mjs'; import { publishPlugins } from './tools/lib/plugins.mjs';
import { SITE_PATH } from './src/core/site';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
/** /**
* The workspace folder itself is what nginx serves under https://nockal.com/feuerwerk/. * The workspace folder itself is what nginx serves under https://nockal.com/feuerwerk/, and the
* same folder answers at https://feuerwerk.nockal.com/. So the build knows no path: its base is
* relative, and the pages find the API and the data from where they are (src/core/site.ts).
* The Vite source pages live in `web/`: `index.html` is the editor, `play.html` a released game * The Vite source pages live in `web/`: `index.html` is the editor, `play.html` a released game
* (#266). The build writes hashed assets into `dist/` and this plugin copies the built pages to the * (#266). The build writes hashed assets into `dist/` and this plugin copies the built pages to the
* folder root, so that the site URLs load the production bundles while `api/` and `data/` sit next * folder root, so that the site URLs load the production bundles while `api/` and `data/` sit next
* to them. * to them. One folder up, their `./assets/` becomes `./dist/assets/`.
*/ */
const PAGES = ['index.html', 'play.html']; const PAGES = ['index.html', 'play.html'];
function publishPages(): Plugin { function publishPages(): Plugin {
let outDir = '';
return { return {
name: 'feuerwerk:publish-pages', name: 'feuerwerk:publish-pages',
apply: 'build', apply: 'build',
configResolved(config) {
outDir = resolve(config.root, config.build.outDir);
},
closeBundle() { closeBundle() {
for (const page of PAGES) copyFileSync(resolve(__dirname, 'dist', page), resolve(__dirname, page)); const folder = basename(outDir);
for (const page of PAGES) {
const html = readFileSync(resolve(outDir, page), 'utf8');
const published = html.replaceAll('"./assets/', `"./${folder}/assets/`);
if (published === html) throw new Error(`${page}: no ./assets/ link to move`);
writeFileSync(resolve(outDir, '..', page), published);
}
}, },
}; };
} }
@@ -79,7 +90,7 @@ function shipPlugins(): Plugin {
export default defineConfig({ export default defineConfig({
root: 'web', root: 'web',
base: `${SITE_PATH}dist/`, base: './',
publicDir: false, publicDir: false,
plugins: [contentSecurityPolicy(), publishPages(), shipPlugins()], plugins: [contentSecurityPolicy(), publishPages(), shipPlugins()],
build: { build: {
@@ -95,11 +106,12 @@ export default defineConfig({
}, },
server: { server: {
port: 5175, port: 5175,
// During development the PHP API is served by the production nginx; proxy it. // During development the PHP API and the data are served by the production nginx; proxy them.
proxy: { proxy: {
[`${SITE_PATH}api`]: { '^/(api|data)/': {
target: 'https://nockal.com', target: 'https://nockal.com',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => `/feuerwerk${path}`,
}, },
}, },
}, },