Skip to content
Open
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
15 changes: 15 additions & 0 deletions frontend/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copy to .dev.vars for local `yarn dev:cf` / `wrangler dev`.
#
# Point both API URLs at your *existing* public Laravel API (Proxmox, VPS, etc.).
# Include the `/api` suffix used by Hi.Events axios clients.
# Workers cannot reach Docker-internal hosts like http://backend:80/api.
#
# Also allow this frontend origin in Laravel CORS_ALLOWED_ORIGINS
# (e.g. http://localhost:8787) and set APP_FRONTEND_URL accordingly.

VITE_API_URL_CLIENT=https://tickets.example.com/api
VITE_API_URL_SERVER=https://tickets.example.com/api
VITE_FRONTEND_URL=http://localhost:8787
VITE_APP_NAME=Hi.Events
# VITE_STRIPE_PUBLISHABLE_KEY=
# VITE_FATHOM_SITE_ID=
2 changes: 2 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# In a non development environment these would usually be the same
VITE_API_URL_CLIENT=https://localhost:8443/api
VITE_API_URL_SERVER=http://backend:80/api
# Cloudflare Workers: use public HTTPS URLs ending in /api for both (see CLOUDFLARE.md).
# Internal Docker hostnames like http://backend will not work from the edge.

VITE_FRONTEND_URL=https://localhost:8443
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51Ofu1CJKnXOyGeQuDPUHiZcJxZozRuERiv4vQRBtCscwTbxOL574cxUjAoNRL2YLCumgC5160pl6kvTIiAc9mOeM0058KAWQ55
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.dev.vars

# Editor directories and files
.vscode/*
Expand Down
90 changes: 90 additions & 0 deletions frontend/CLOUDFLARE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Cloudflare Workers (frontend SSR)

Deploy the **React SSR frontend** to Cloudflare Workers while keeping your existing
Laravel API wherever it already runs (Proxmox, VPS, Docker, etc.).

Laravel is **not** deployed to Workers. The Worker only serves HTML/JS and calls your
API over HTTPS.

## Architecture

```
Browser ──► Cloudflare Worker (SSR + static assets)
├── VITE_API_URL_SERVER (SSR / server-side fetches from the edge)
└── window.hievents.VITE_API_URL_CLIENT (browser XHR/fetch)
Your existing Laravel API (public HTTPS)
```

| Variable | Used by | Must be |
|----------|---------|---------|
| `VITE_API_URL_SERVER` | Worker SSR (`getConfig` on the server) | Public HTTPS URL Cloudflare’s edge can reach (not `http://backend`) |
| `VITE_API_URL_CLIENT` | Browser (injected as `window.hievents`) | Public HTTPS URL the user’s browser can reach |
| `VITE_FRONTEND_URL` | Robots/sitemap and absolute frontend links | Your Worker URL (`*.workers.dev` or custom domain) |

For Hi.Events, both API URLs normally end with `/api` (same as Docker `.env.example`):

```text
VITE_API_URL_CLIENT=https://tickets.example.com/api
VITE_API_URL_SERVER=https://tickets.example.com/api
```

If your Laravel app is already exposed as `https://api.example.com` with `/api`
routed at the host root, use that host’s public base URL including `/api` if that
is how the axios clients are configured today.

## Laravel / API checklist (existing backend)

1. **Public HTTPS** — The API must be reachable from the internet (or at least from
Cloudflare’s network). Docker-only names like `http://backend:80/api` will not work.
2. **CORS** — Browser calls use `withCredentials: true`. Set Laravel
`CORS_ALLOWED_ORIGINS` to your Worker origin(s), e.g.
`https://hievents-frontend.<account>.workers.dev,https://tickets.example.com`.
Do not rely on `*` when credentialed requests are required.
3. **`APP_FRONTEND_URL`** — Point this at the Worker/custom-domain frontend URL so
emails, invitations, and redirects target the right host.
4. **Cookies / auth** — Cross-site cookies need `Secure` and an appropriate `SameSite`
policy if the frontend and API are on different sites. Same-site custom domains
(e.g. `app.example.com` + `api.example.com`) are easier than `workers.dev` + a
different apex.
5. **Stripe / webhooks** — Unchanged; they still hit Laravel, not the Worker.

## Quick start (local Worker → remote API)

```bash
cd frontend
cp .dev.vars.example .dev.vars
# Edit .dev.vars: public .../api URLs, VITE_FRONTEND_URL=http://localhost:8787

yarn install
yarn build:ssr:client # static assets for Workers Assets
yarn build:worker # bundles SSR for the Worker
npx wrangler dev # or: yarn dev:cf (rebuilds client + worker, then wrangler dev)
```

`yarn dev:cf` runs `build:ssr:client`, `build:worker`, then `wrangler dev`.

## Production deploy

```bash
cd frontend
# Set vars (wrangler.jsonc "vars" and/or `wrangler secret put` for sensitive values).
# At minimum set VITE_API_URL_CLIENT, VITE_API_URL_SERVER, VITE_FRONTEND_URL
# to your production API and Worker/custom domain.

yarn deploy:cf # yarn build && yarn build:worker && wrangler deploy
```

After the first deploy, set `VITE_FRONTEND_URL` to the real `*.workers.dev` or
custom domain and redeploy (or update vars in the dashboard) so robots/sitemap
and absolute links are correct.

Optional: attach a custom domain in the Cloudflare dashboard for the Worker.

## Docker / Node (unchanged)

`yarn start` / Docker SSR still use `@hono/node-server`. No Workers account required.
Use `frontend/.env` as before (`VITE_API_URL_SERVER` may stay as an internal hostname
inside Docker Compose).
17 changes: 9 additions & 8 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"build:ssr:client": "vite build --ssrManifest --outDir dist/client",
"build:ssr:server": "vite build --ssr src/entry.server.tsx --outDir dist/server",
"start": "cross-env NODE_ENV=production node server.js",
"dev:cf": "yarn build:ssr:client && yarn build:worker && wrangler dev",
"deploy:cf": "yarn build && yarn build:worker && wrangler deploy",
"build:worker": "vite build --config vite.worker.config.ts",
"build": "npm run messages:extract && npm run messages:compile && npm run build:ssr:client && npm run build:ssr:server",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"messages:extract": "lingui extract",
Expand All @@ -35,9 +38,9 @@
"@mantine/notifications": "^8.0.1",
"@mantine/nprogress": "^8.0.1",
"@mantine/tiptap": "^8.0.1",
"@hono/node-server": "^1.19.0",
"@react-pdf/renderer": "^3.3.4",
"@react-router/node": "^7.1.5",
"@remix-run/node": "^2.16.8",
"@stripe/react-stripe-js": "^2.1.1",
"@stripe/stripe-js": "^1.54.1",
"@tabler/icons-react": "^3.35.0",
Expand All @@ -59,12 +62,10 @@
"chroma-js": "^3.1.2",
"classnames": "^2.3.2",
"collect.js": "^4.36.1",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"cross-env": "^7.0.3",
"dayjs": "^1.11.8",
"dotenv": "^16.4.7",
"express": "^4.19.2",
"hono": "^4.9.0",
"qr-scanner": "^1.4.2",
"query-string": "^8.1.0",
"react": "^18.2.0",
Expand All @@ -73,13 +74,12 @@
"react-qr-code": "^2.0.12",
"react-router": "^7.1.5",
"react-router-dom": "^7.1.5",
"recharts": "2",
"sirv": "^2.0.4"
"recharts": "2"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250726.0",
"@lingui/vite-plugin": "^4.14.1",
"@swc/core": "1.11.24",
"@types/express": "^4.17.21",
"@types/lodash": "^4.17.0",
"@types/node": "^20.12.2",
"@types/react": "^18.0.28",
Expand All @@ -100,6 +100,7 @@
"typescript": "^5.8.3",
"vite": "^5.4.19",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-copy": "^0.1.6"
"vite-plugin-copy": "^0.1.6",
"wrangler": "^4.26.0"
}
}
2 changes: 2 additions & 0 deletions frontend/public/.assetsignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Files under public/ are copied into dist/client by Vite.
# Exclude anything that should not be uploaded as Workers Assets.
173 changes: 66 additions & 107 deletions frontend/server.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import express from "express";
import {installGlobals} from "@remix-run/node";
import process from "process";
import {createServer as viteServer} from "vite";
import compression from "compression";
import {serve} from "@hono/node-server";
import {serveStatic} from "@hono/node-server/serve-static";
import {getRequestListener} from "@hono/node-server";
import {compress} from "hono/compress";
import {Hono} from "hono";
import {createServer as createViteServer} from "vite";
import {createServer as createHttpServer} from "node:http";
import fs from "node:fs/promises";
import sirv from "sirv";
import cookieParser from "cookie-parser";
import path from "node:path";
import {fileURLToPath} from "node:url";
import * as nodePath from "node:path";
import * as nodeUrl from "node:url";
import process from "process";
import "dotenv/config";
import {sitemapIndexHandler, sitemapEventsHandler, sitemapOrganizersHandler} from "./src/sitemap/proxy.js";
import {htmlSafeJsonStringify} from "./src/utilites/safeScriptJson.js";

installGlobals();
import {
isStaticAssetPath,
pickViteEnv,
registerPublicRoutes,
registerSsrHandler,
} from "./src/ssr/createApp.js";

async function main() {
const base = process.env.BASE || "/";
Expand All @@ -29,123 +32,79 @@ async function main() {
? await fs.readFile("./dist/client/index.html", "utf-8")
: "";

const ssrManifest = isProduction
? await fs.readFile("./dist/client/.vite/ssr-manifest.json", "utf-8")
: undefined;

const app = express();
app.use(cookieParser());

app.use('/.well-known', express.static(path.join(__dirname, 'public/.well-known')));

let vite;

if (!isProduction) {
vite = await viteServer({
server: { middlewareMode: true },
vite = await createViteServer({
server: {middlewareMode: true},
appType: "custom",
base,
});

app.use(vite.middlewares);
} else {
app.use(compression());
app.use(base, sirv(path.join(__dirname, "./dist/client"), { extensions: [] }));
}

const getViteEnvironmentVariables = () => {
const envVars = {};
for (const key in process.env) {
if (key.startsWith('VITE_')) {
envVars[key] = process.env[key];
}
}
return htmlSafeJsonStringify(envVars);
const dynamicImport = async (modulePath) => {
return import(
nodePath.isAbsolute(modulePath)
? nodeUrl.pathToFileURL(modulePath).toString()
: modulePath
);
};

app.get('/robots.txt', (req, res) => {
const frontendUrl = process.env.VITE_FRONTEND_URL || `${req.protocol}://${req.get('host')}`;
const robotsTxt = `User-agent: *
Allow: /

Sitemap: ${frontendUrl}/sitemap.xml
`;
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(200).send(robotsTxt);
});

app.get('/sitemap.xml', sitemapIndexHandler);
app.get('/sitemap-events-:page.xml', sitemapEventsHandler);
app.get('/sitemap-organizers-:page.xml', sitemapOrganizersHandler);

app.use("*", async (req, res) => {
const url = req.originalUrl.replace(base, "");

try {
let template;
let render;

const deps = {
base,
getPublicEnv: () => pickViteEnv(process.env),
getTemplate: async (_c, url) => {
if (!isProduction) {
template = await fs.readFile(path.join(__dirname, "./index.html"), "utf-8");
template = await vite.transformIndexHtml(url, template);
render = (await vite.ssrLoadModule("/src/entry.server.tsx")).render;
} else {
template = templateHtml;
render = (await dynamicImport(path.join(__dirname, "./dist/server/entry.server.js"))).render;
let template = await fs.readFile(path.join(__dirname, "./index.html"), "utf-8");
return vite.transformIndexHtml(url, template);
}
return templateHtml;
},
getRender: async () => {
if (!isProduction) {
return (await vite.ssrLoadModule("/src/entry.server.tsx")).render;
}
return (await dynamicImport(path.join(__dirname, "./dist/server/entry.server.js"))).render;
},
};

const { appHtml, dehydratedState, helmetContext } = await render(
{ req, res },
ssrManifest
);
const stringifiedState = htmlSafeJsonStringify(dehydratedState);
const app = new Hono();

const helmetHtml = Object.values(helmetContext.helmet || {})
.map((value) => value.toString() || "")
.join(" ");
// Dynamic robots/sitemap before static files (public/robots.txt would otherwise win).
registerPublicRoutes(app, deps);

const envVariablesHtml = `<script>window.hievents = ${getViteEnvironmentVariables()};</script>`;
if (isProduction) {
app.use("*", compress());
app.use("/.well-known/*", serveStatic({root: "./public"}));

const headSnippets = [];
if (process.env.VITE_FATHOM_SITE_ID) {
headSnippets.push(`
<script src="https://cdn.usefathom.com/script.js" data-spa="auto" data-site="${process.env.VITE_FATHOM_SITE_ID}" defer></script>
`);
const clientStatic = serveStatic({root: "./dist/client"});
app.use("*", async (c, next) => {
const pathname = new URL(c.req.url).pathname;
if (!isStaticAssetPath(pathname)) {
return next();
}
return clientStatic(c, next);
});
}

const html = template
.replace("<!--head-snippets-->", () => headSnippets.join("\n"))
.replace("<!--app-html-->", () => appHtml)
.replace("<!--dehydrated-state-->", () => `<script>window.__REHYDRATED_STATE__ = ${stringifiedState}</script>`)
.replace("<!--environment-variables-->", () => envVariablesHtml)
.replace(/<!--render-helmet-->.*?<!--\/render-helmet-->/s, () => helmetHtml);

res.setHeader("Content-Type", "text/html");
return res.status(200).end(html);
} catch (error) {
if (error instanceof Response) {
if (error.status >= 300 && error.status < 400) {
return res.redirect(error.status, error.headers.get("Location") || "/");
} else {
return res.status(error.status).send(await error.text());
}
}
registerSsrHandler(app, deps);

console.error(error);
res.status(500).send("Internal Server Error");
}
});
if (!isProduction) {
const listener = getRequestListener(app.fetch);
createHttpServer((req, res) => {
vite.middlewares(req, res, () => listener(req, res));
}).listen(port, () => {
console.info(`SSR Serving at http://localhost:${port}`);
});
return;
}

app.listen(port, () => {
serve({
fetch: app.fetch,
port: Number(port),
}, () => {
console.info(`SSR Serving at http://localhost:${port}`);
});

const dynamicImport = async (path) => {
return import(
nodePath.isAbsolute(path) ? nodeUrl.pathToFileURL(path).toString() : path
);

}
}

main();
Loading
Loading