mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
Phase 2 harness wiring: differ --right-app web, e2e target app, dev-controls, cookbook
- differ can serve apps/web on the right side (--right-app web); impersonation no longer follows app-specific redirects - e2e: E2E_TARGET_APP=web spawns apps/web servers per worker against the shared dbs; leaderboards spec + page object added; expectRouterIdle exported - apps/web dev-controls endpoints: /auth/impersonate(+stop), /theme (404-redirect GET parity), /refresh-caches, /end-season, /run-routine, /sidenav - HydrationTestIndicator implements the hydrated/router-idle contract (counts in-flight remote fetches) - codemods: route-map (routes.ts -> SvelteKit route scaffolds, manifest-aware, idempotent) and remote-scaffold (loader->query, _action branches->commands) - MIGRATION.md cookbook seeded with the golden read-path pattern and slice learnings; manifest: leaderboards migrated
This commit is contained in:
195
MIGRATION.md
Normal file
195
MIGRATION.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Migration cookbook
|
||||
|
||||
The pattern book for the React → Svelte migration (`svelte-big-bang.md`). Every
|
||||
recurring pattern gets a before/after entry here; migrating agents follow the
|
||||
book and never improvise. A pattern missing from the book is an escalation —
|
||||
the resolution becomes a new entry. Entries are born in the vertical slices
|
||||
(`/leaderboards` first) and grow from there.
|
||||
|
||||
Conventions used by every entry:
|
||||
|
||||
- `apps/web` imports its own code via `#lib/...` subpath imports **with explicit
|
||||
file extensions** (`#lib/utils/urls.ts`, `#lib/components/Main.svelte`) — Kit 3
|
||||
subpath imports are unambiguous or they don't resolve.
|
||||
- Server-only code: `*.server.ts` filename suffix, or anything under
|
||||
`src/lib/server/`. Remote functions: `<feature>.remote.ts`.
|
||||
- One component per `.svelte` file; feature components live in
|
||||
`src/lib/features/<feature>/components/`, generic primitives in
|
||||
`@sendou/components` (`packages/components`), app-wide pieces in
|
||||
`src/lib/components/`.
|
||||
- Route files are thin shells: a `+page.svelte` composes feature components and
|
||||
wires meta tags; everything real lives in the feature folder.
|
||||
|
||||
---
|
||||
|
||||
## Read path (loader → remote query)
|
||||
|
||||
The golden pattern, from `/leaderboards`.
|
||||
|
||||
**Before** (React Router): `loaders/leaderboards.server.ts` exports a `loader`
|
||||
that parses search params from the request URL and returns data;
|
||||
`useLoaderData()` in the route component.
|
||||
|
||||
**After** (SvelteKit): the loader body becomes a `query()` in
|
||||
`leaderboards.remote.ts`. Remote queries cannot read the URL — the page parses
|
||||
the URL client/SSR-side and passes the decoded values as query args, validated
|
||||
by a valibot schema:
|
||||
|
||||
```ts
|
||||
// leaderboards.remote.ts
|
||||
import { query } from "$app/server";
|
||||
import { getUser } from "#lib/features/auth/user.server.ts";
|
||||
import { leaderboardsQuerySchema } from "./leaderboards-schemas.ts";
|
||||
|
||||
export const getLeaderboards = query(
|
||||
leaderboardsQuerySchema,
|
||||
async ({ type, season }) => {
|
||||
const user = getUser(); // event.locals via getRequestEvent()
|
||||
// ...same body as the React loader
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- Leaderboards.svelte -->
|
||||
<script lang="ts">
|
||||
const params = searchParamsState(leaderboardsSearchParams);
|
||||
const data = $derived(await getLeaderboards(params.current));
|
||||
</script>
|
||||
```
|
||||
|
||||
`$derived(await ...)` needs `compilerOptions.experimental.async` (on). When the
|
||||
params change the derived re-awaits; identical args hit the query cache, so a
|
||||
URL write that decodes to the same values refetches nothing — this replaces the
|
||||
React `shouldRevalidate` machinery entirely.
|
||||
|
||||
Auth: `requireUser()` / `getUser()` / `actorId()` from
|
||||
`#lib/features/auth/user.server.ts` read `event.locals.user`, resolved once per
|
||||
request in `hooks.server.ts`. No AsyncLocalStorage.
|
||||
|
||||
## Write path (action → command), first shape
|
||||
|
||||
The full write-path pattern lands with the `/scrims` slice; `/leaderboards`
|
||||
established the command shape for fixed-field mutations:
|
||||
|
||||
- Each `_action` branch of a React action union becomes its own `command()`
|
||||
with its own valibot schema (the `_action` discriminator disappears).
|
||||
- **Server-driven refresh is the default**: the handler calls
|
||||
`getX(args).refresh()` / `.set(result)` for exactly the queries it
|
||||
invalidated.
|
||||
- When the server genuinely can't know which query instance the client holds
|
||||
(filter/pagination args — the leaderboards case), the **client** rides the
|
||||
refresh on the mutation round trip instead:
|
||||
|
||||
```ts
|
||||
skipTeam({ season, identifier }).updates(getLeaderboards(queryArgs));
|
||||
```
|
||||
|
||||
Never leave a mutation without one of the two — the refresh-everything default
|
||||
must not ship.
|
||||
|
||||
## Search params
|
||||
|
||||
`app/modules/search-params` is ported at
|
||||
`#lib/modules/search-params/search-params.ts` with valibot schemas
|
||||
(`SP.param(v.picklist(...))` etc.). Component state comes from
|
||||
`searchParamsState(definition)` (`search-params-state.svelte.ts`):
|
||||
`params.current` is reactive to the URL; `params.set(updates)` writes through a
|
||||
replace navigation (`goto` with `reset: false`), shallow for `loader: false`
|
||||
params. Every definition still registers an `assertRoundTrips` test.
|
||||
|
||||
`SP.custom` takes an explicit `ParamCodec` (`{ decode, encode }`) instead of a
|
||||
zod codec.
|
||||
|
||||
## Validation (zod → valibot)
|
||||
|
||||
- `z.enum([...])` → `v.picklist([...])`
|
||||
- `z.number().int().min(1)` → `v.pipe(v.number(), v.integer(), v.minValue(1))`
|
||||
- `z.string().regex(...).pipe(z.custom<T>())` →
|
||||
`v.pipe(v.string(), v.regex(...), v.transform((s) => s as T))`
|
||||
- `.nullable()` → `v.nullable(...)`
|
||||
- Schemas with 2+ consumers get promoted to `@sendou/schemas` (none yet).
|
||||
|
||||
## Components
|
||||
|
||||
| React | Svelte |
|
||||
|---|---|
|
||||
| `useState` | `$state` |
|
||||
| derived-in-render | `$derived` / `$derived.by` |
|
||||
| `useEffect` | usually delete; else `$effect` |
|
||||
| props / `children` / render props | `$props()` / snippets (`{@render children()}`) |
|
||||
| `clsx(...)` in `className` | `class={[ ... ]}` array form (built-in clsx) |
|
||||
| `React.cloneElement(icon, {className})` | wrapper `<span class="...">{@render icon()}</span>` + `:global(svg)` sizing |
|
||||
| ref callbacks | `{@attach fn}` attachments (return value = cleanup) |
|
||||
| `useHydrated()` | `browser` from `$app/env`, or just render client-only branches after an `$effect` sets a flag |
|
||||
| controlled/uncontrolled prop pairs | same pattern; mark the mount-time branch with `svelte-ignore state_referenced_locally` |
|
||||
| module-level caches keyed by `i18n.language` | usually unnecessary — paraglide messages are plain function calls |
|
||||
| react-aria `Tabs/TabList/Tab/TabPanel` | `@sendou/components` `Tabs/TabList/Tab/TabPanel` (handrolled ARIA tabs, context-based) |
|
||||
| react-aria `MenuTrigger/Menu/MenuItem` | `@sendou/components` `Menu/MenuItem` — trigger is a snippet receiving `{ "aria-expanded", "aria-haspopup", onclick }` to spread |
|
||||
| react-aria `DialogTrigger/Popover/Dialog` | `@sendou/components` `Popover`, same trigger-snippet contract |
|
||||
| react-aria `Select` + `Autocomplete` + `Virtualizer` | `@sendou/components` `Select/SelectItem/SelectItemSection` — filtering happens at the **data level** in the caller (see `WeaponSelect.svelte`), no virtualization (a few hundred items render fine) |
|
||||
| `useLoaderData()` | `await query()` (see read path) |
|
||||
| `<Link to>` | plain `<a href>` |
|
||||
| `useUser()` / `useHasRole()` | `loggedInUser()` / `hasRole()` from `#lib/features/auth/user-state.ts` (reads `page.data.user`) |
|
||||
| meta functions | `<MetaTags …/>` (`#lib/components/MetaTags.svelte`) inside the `+page.svelte` |
|
||||
|
||||
## CSS
|
||||
|
||||
- Each component's `.module.css` contents move into its `<style>` block
|
||||
verbatim; `styles.foo` references become plain `class="foo"`.
|
||||
- **Shared module classes** (one `.module.css` imported by several components,
|
||||
e.g. the leaderboard table styles from `top-search.module.css`): declare them
|
||||
once in the feature's top component under a scoped wrapper class with
|
||||
`:global(...)` descendants (`.leaderboards :global(.tableRow) { ... }`);
|
||||
child components just use the plain class names. Same technique for
|
||||
cross-component selectors inside one logical component family
|
||||
(`Tabs.svelte` styling `.tabList`/`.tabContainer` rendered by its children).
|
||||
- Global styles (`vars.css`, `utils.css`, `normalize.css`, `common.css`,
|
||||
`flags.css`, `fonts.css`) are imported once in `+layout.svelte`.
|
||||
- A `<Main className="stack lg">` in React becomes `<Main>` wrapping a
|
||||
`<div class="stack lg <feature>">` — the wrapper also carries the feature's
|
||||
scoped `:global` styles. Visually identical; noted for the differ.
|
||||
|
||||
## i18n
|
||||
|
||||
- `t("ns:some.key")` → `m.ns_some_key()` (`import { m } from
|
||||
"#lib/paraglide/messages.js"`): namespace prefix + dots/dashes → underscores.
|
||||
- Dynamic key families (weapon names by id, mode names) go through the typed
|
||||
helpers in `#lib/modules/i18n/messages.ts` (`mainWeaponName(id)`,
|
||||
`modeLongName(mode)`, `weaponCategoryName(name)`, or `dynamicMessage(key)`).
|
||||
- Current language: `getLocale()` from `#lib/paraglide/runtime.js` (replaces
|
||||
`i18n.language`).
|
||||
- `handle.i18n` namespace lists are gone — messages are tree-shaken imports.
|
||||
|
||||
## Infra
|
||||
|
||||
- Session cookies (`__session`, `theme`, `sidenav`) are byte-compatible with
|
||||
the React app: `readSessionCookie`/`writeSessionCookie` in
|
||||
`#lib/features/auth/session.server.ts` reimplement React Router's
|
||||
base64-JSON + HMAC-SHA256 format with the same `SESSION_SECRET`.
|
||||
- Repository files port verbatim: only the import paths change
|
||||
(`~/db/sql` → `#lib/server/db/sql.ts`, `~/utils/kysely.server` →
|
||||
`#lib/server/kysely.ts`, `~/utils/dates` → `#lib/utils/dates.ts`).
|
||||
- `#lib/server/db/tables.ts` is a **trimmed** copy of the React `tables.ts` —
|
||||
it grows with each migrated feature; copy table interfaces verbatim, omit
|
||||
columns whose payload types would drag in unported feature graphs (they get
|
||||
added back when that feature migrates).
|
||||
- cachified + the LRU cache work unchanged (`#lib/server/cache.ts`).
|
||||
- Dev-controls endpoints (`/auth/impersonate`, `/theme`, `/refresh-caches`,
|
||||
`/end-season`, `/run-routine`, `/sidenav`) are `+server.ts` route handlers
|
||||
gated on `DANGEROUS_CAN_ACCESS_DEV_CONTROLS`.
|
||||
- The e2e/differ app contract (`data-testid="hydrated"`, `data-router-idle`)
|
||||
is `#lib/components/HydrationTestIndicator.svelte`, rendered under
|
||||
`IS_E2E_TEST_RUN` only; it counts in-flight remote fetches for the busy
|
||||
state.
|
||||
|
||||
## One-offs ledger (discoveries)
|
||||
|
||||
- Kit 3 moved `Handle` to `@sveltejs/kit/hooks` and `getRequestEvent` to
|
||||
`$app/server`; `event.request` is read-only in handle hooks (paraglide's
|
||||
middleware pattern of reassigning it is unnecessary without URL locale
|
||||
strategies).
|
||||
- React's `/theme` GET responds with a 404-status redirect — kept verbatim via
|
||||
a manual `Response` (Kit's `redirect()` only allows 3xx).
|
||||
- The differ impersonates with `maxRedirects: 0` so the redirect target may be
|
||||
a not-yet-migrated route.
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
} from "./helpers/playwright";
|
||||
|
||||
const DEBUG = process.env.E2E_DEBUG === "true";
|
||||
/** `E2E_TARGET_APP=web` serves `apps/web` (the SvelteKit app) instead of this
|
||||
* React app, for running migrated specs against the other side of the
|
||||
* migration (`svelte-big-bang.md`). Factories keep writing through this app's
|
||||
* code either way — the database files are shared. */
|
||||
const TARGET_APP = process.env.E2E_TARGET_APP === "web" ? "web" : "web-react";
|
||||
const WEB_APP_DIR = new URL("../../web", import.meta.url).pathname;
|
||||
const SERVER_PROCESSES: ChildProcess[] = [];
|
||||
const MINIO_MARKER_FILE = ".e2e-minio-started";
|
||||
const STORAGE_BUCKET = "sendou";
|
||||
@@ -127,6 +133,21 @@ async function globalSetup(config: FullConfig) {
|
||||
: "Built the application",
|
||||
);
|
||||
|
||||
if (TARGET_APP === "web") {
|
||||
// biome-ignore lint/suspicious/noConsole: CLI script output
|
||||
console.log("Building apps/web (E2E_TARGET_APP=web)...");
|
||||
execSync("pnpm run build", {
|
||||
stdio: "inherit",
|
||||
cwd: WEB_APP_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
VITE_E2E_TEST_RUN: "true",
|
||||
VITE_SITE_DOMAIN: `http://localhost:${E2E_BASE_PORT}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare databases and start servers for each worker
|
||||
const serverPromises: Promise<void>[] = [];
|
||||
|
||||
@@ -156,12 +177,19 @@ async function globalSetup(config: FullConfig) {
|
||||
// above already migrated, making the script's `migrate up` step redundant
|
||||
const serverProcess = spawn(
|
||||
process.execPath,
|
||||
["./node_modules/@react-router/serve/bin.cjs", "./build/server/index.js"],
|
||||
TARGET_APP === "web"
|
||||
? [`${WEB_APP_DIR}/build/index.js`]
|
||||
: [
|
||||
"./node_modules/@react-router/serve/bin.cjs",
|
||||
"./build/server/index.js",
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
DB_PATH: dbPath,
|
||||
// the db files live in this app's directory either way (the
|
||||
// factories and migrations run through this app's code)
|
||||
DB_PATH: TARGET_APP === "web" ? `${process.cwd()}/${dbPath}` : dbPath,
|
||||
PORT: String(port),
|
||||
DISCORD_CLIENT_ID: "123",
|
||||
DISCORD_CLIENT_SECRET: "secret",
|
||||
|
||||
@@ -315,7 +315,7 @@ export async function waitForPOSTResponse(page: Page, cb: () => Promise<void>) {
|
||||
}
|
||||
|
||||
/** Waits until no navigation, revalidation or fetcher is in flight. */
|
||||
async function expectRouterIdle(page: Page) {
|
||||
export async function expectRouterIdle(page: Page) {
|
||||
// A submit's redirect plus the target page's loaders can exceed the default
|
||||
// expect timeout when the full suite is loading all workers.
|
||||
try {
|
||||
|
||||
93
apps/web-react/e2e/leaderboards.spec.ts
Normal file
93
apps/web-react/e2e/leaderboards.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { expect, test } from "./helpers/playwright";
|
||||
import { LeaderboardsPage } from "./pages/leaderboards/leaderboards-page";
|
||||
|
||||
const HIGH_POWER_NAME = "TopPlayer";
|
||||
const LOW_POWER_NAME = "RunnerUp";
|
||||
const RM_ONLY_NAME = "RainmakerMain";
|
||||
|
||||
test.describe("Leaderboards", () => {
|
||||
test("shows the season player leaderboard ordered by SP", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
const better = await factories.UserFactory.create({
|
||||
discordName: "BetterPlayer",
|
||||
});
|
||||
const worse = await factories.UserFactory.create({
|
||||
discordName: "WorsePlayer",
|
||||
});
|
||||
await factories.SkillFactory.create(
|
||||
{ userId: better.id, mu: 30 },
|
||||
{ matchesCount: 10 },
|
||||
);
|
||||
await factories.SkillFactory.create(
|
||||
{ userId: worse.id, mu: 20 },
|
||||
{ matchesCount: 10 },
|
||||
);
|
||||
|
||||
const leaderboards = new LeaderboardsPage(page);
|
||||
await leaderboards.goto();
|
||||
|
||||
const betterRow = page.getByRole("link", { name: /BetterPlayer/ });
|
||||
const worseRow = page.getByRole("link", { name: /WorsePlayer/ });
|
||||
await expect(betterRow).toBeVisible();
|
||||
await expect(worseRow).toBeVisible();
|
||||
|
||||
const betterBox = await betterRow.boundingBox();
|
||||
const worseBox = await worseRow.boundingBox();
|
||||
expect(betterBox!.y).toBeLessThan(worseBox!.y);
|
||||
});
|
||||
|
||||
test("filters the X Battle leaderboard by mode", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
const zonesPlayer = await factories.UserFactory.create();
|
||||
const rainmakerPlayer = await factories.UserFactory.create();
|
||||
await factories.XRankPlacementFactory.create({
|
||||
playerUserId: zonesPlayer.id,
|
||||
name: HIGH_POWER_NAME,
|
||||
mode: "SZ",
|
||||
power: 3200,
|
||||
weaponSplId: 40,
|
||||
});
|
||||
await factories.XRankPlacementFactory.create({
|
||||
playerUserId: zonesPlayer.id,
|
||||
name: LOW_POWER_NAME,
|
||||
playerSplId: `runner-up-${zonesPlayer.id}`,
|
||||
mode: "SZ",
|
||||
power: 3000,
|
||||
weaponSplId: 1000,
|
||||
});
|
||||
await factories.XRankPlacementFactory.create({
|
||||
playerUserId: rainmakerPlayer.id,
|
||||
name: RM_ONLY_NAME,
|
||||
mode: "RM",
|
||||
power: 2900,
|
||||
weaponSplId: 2000,
|
||||
});
|
||||
|
||||
const leaderboards = new LeaderboardsPage(page);
|
||||
await leaderboards.goto("?type=XP-ALL");
|
||||
|
||||
await expect(page.getByText(HIGH_POWER_NAME)).toBeVisible();
|
||||
await expect(page.getByText(RM_ONLY_NAME)).toBeVisible();
|
||||
|
||||
await leaderboards.selectModeChip("Rainmaker");
|
||||
await expect(page.getByText(RM_ONLY_NAME)).toBeVisible();
|
||||
await expect(page.getByText(HIGH_POWER_NAME)).not.toBeVisible();
|
||||
expect(page.url()).toContain("type=XP-MODE-RM");
|
||||
});
|
||||
|
||||
test("switching to the X Battle tab clears the season param", async ({
|
||||
page,
|
||||
}) => {
|
||||
const leaderboards = new LeaderboardsPage(page);
|
||||
await leaderboards.goto("?season=1");
|
||||
|
||||
await leaderboards.selectTab("xpTab");
|
||||
|
||||
expect(page.url()).toContain("type=XP-ALL");
|
||||
expect(page.url()).not.toContain("season=");
|
||||
});
|
||||
});
|
||||
42
apps/web-react/e2e/pages/leaderboards/leaderboards-page.ts
Normal file
42
apps/web-react/e2e/pages/leaderboards/leaderboards-page.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { LEADERBOARDS_PAGE } from "~/utils/urls";
|
||||
import { expectRouterIdle, navigate } from "../../helpers/playwright";
|
||||
|
||||
/** `/leaderboards` */
|
||||
export class LeaderboardsPage {
|
||||
private readonly page: Page;
|
||||
readonly locators;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.locators = {
|
||||
playersTab: this.page.getByRole("tab", { name: "Players" }),
|
||||
teamsTab: this.page.getByRole("tab", { name: "Teams" }),
|
||||
xpTab: this.page.getByRole("tab", { name: "X Battle" }),
|
||||
weaponSelect: this.page.getByTestId("weapon-select"),
|
||||
rows: this.page.locator("main a[href]").filter({
|
||||
has: this.page.locator("img"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async goto(search = "") {
|
||||
await navigate({
|
||||
page: this.page,
|
||||
url: `${LEADERBOARDS_PAGE}${search}`,
|
||||
});
|
||||
}
|
||||
|
||||
async selectTab(tab: "playersTab" | "teamsTab" | "xpTab") {
|
||||
await this.locators[tab].click();
|
||||
await expectRouterIdle(this.page);
|
||||
}
|
||||
|
||||
async selectModeChip(label: string) {
|
||||
await this.page
|
||||
.getByRole("radiogroup")
|
||||
.getByText(label, { exact: true })
|
||||
.click();
|
||||
await expectRouterIdle(this.page);
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,12 @@
|
||||
"@sendou/components": "workspace:*",
|
||||
"@sendou/in-game-lists": "workspace:*",
|
||||
"@sendou/utils": "workspace:*",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"date-fns": "4.4.0",
|
||||
"fflate": "^0.8.3",
|
||||
"kysely": "0.29.0",
|
||||
"lru-cache": "^11.2.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"remeda": "2.39.0",
|
||||
"sql-formatter": "^15.8.2",
|
||||
"valibot": "^1.4.2"
|
||||
|
||||
57
apps/web/src/lib/components/HydrationTestIndicator.svelte
Normal file
57
apps/web/src/lib/components/HydrationTestIndicator.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts" module>
|
||||
let pendingRemoteRequests = $state(0);
|
||||
let fetchPatched = false;
|
||||
|
||||
// remote function calls go over plain fetch; counting the in-flight ones is
|
||||
// what lets e2e (and the differ) wait for "router idle" after interactions
|
||||
function patchFetchOnce() {
|
||||
if (fetchPatched || typeof window === "undefined") return;
|
||||
fetchPatched = true;
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
const isAppRequest = url.includes("/_app/") || url.startsWith("/");
|
||||
if (isAppRequest) pendingRemoteRequests++;
|
||||
try {
|
||||
return await originalFetch(input, init);
|
||||
} finally {
|
||||
if (isAppRequest) pendingRemoteRequests--;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { navigating } from "$app/state";
|
||||
|
||||
let hydrated = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
patchFetchOnce();
|
||||
hydrated = true;
|
||||
});
|
||||
|
||||
const busy = $derived.by(() => {
|
||||
const parts: string[] = [];
|
||||
if (navigating.to) parts.push(`nav:${navigating.type ?? "navigating"}`);
|
||||
if (pendingRemoteRequests > 0) {
|
||||
parts.push(`remote:${pendingRemoteRequests}`);
|
||||
}
|
||||
return parts;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if hydrated}
|
||||
<div
|
||||
style="display: none"
|
||||
data-testid="hydrated"
|
||||
data-router-idle={busy.length === 0 ? "true" : undefined}
|
||||
data-router-busy={busy.length === 0 ? undefined : busy.join(" | ")}
|
||||
></div>
|
||||
{/if}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { db } from "#lib/server/db/sql.ts";
|
||||
import { concatUserSubmittedImagePrefix } from "#lib/server/kysely.ts";
|
||||
import { databaseTimestampNow } from "#lib/utils/dates.ts";
|
||||
|
||||
const SIDEBAR_VISIBLE_SECONDS = 6 * 60 * 60;
|
||||
|
||||
/** External streams that should currently show in the sidebar (started under 6h ago or upcoming). */
|
||||
export function findAllForSidebar() {
|
||||
return db
|
||||
.selectFrom("ExternalStream")
|
||||
.leftJoin(
|
||||
"UserSubmittedImage",
|
||||
"UserSubmittedImage.id",
|
||||
"ExternalStream.avatarImgId",
|
||||
)
|
||||
.select((eb) => [
|
||||
"ExternalStream.id",
|
||||
"ExternalStream.name",
|
||||
"ExternalStream.url",
|
||||
"ExternalStream.startsAt",
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"avatarUrl",
|
||||
),
|
||||
])
|
||||
.where(
|
||||
"ExternalStream.startsAt",
|
||||
">=",
|
||||
databaseTimestampNow() - SIDEBAR_VISIBLE_SECONDS,
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
4
apps/web/src/lib/features/admin/dev-controls.server.ts
Normal file
4
apps/web/src/lib/features/admin/dev-controls.server.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
|
||||
|
||||
export const DANGEROUS_CAN_ACCESS_DEV_CONTROLS =
|
||||
process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN;
|
||||
46
apps/web/src/routes/auth/impersonate/+server.ts
Normal file
46
apps/web/src/routes/auth/impersonate/+server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { error, redirect } from "@sveltejs/kit";
|
||||
import type { RequestHandler } from "./$types";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
|
||||
import {
|
||||
AUTH_COOKIE_NAME,
|
||||
authCookieOptions,
|
||||
IMPERSONATED_SESSION_KEY,
|
||||
readSessionCookie,
|
||||
writeSessionCookie,
|
||||
} from "#lib/features/auth/session.server.ts";
|
||||
|
||||
export const POST: RequestHandler = async ({ request, url, cookies, locals }) => {
|
||||
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
|
||||
const user = locals.user;
|
||||
const canImpersonate =
|
||||
user?.roles.includes("ADMIN") || user?.roles.includes("DEV");
|
||||
if (!canImpersonate) {
|
||||
error(403, "Forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
const rawId = url.searchParams.get("id");
|
||||
const userId = Number(rawId);
|
||||
if (!rawId || Number.isNaN(userId)) {
|
||||
error(400, "Invalid user id");
|
||||
}
|
||||
|
||||
const session = readSessionCookie(cookies.get(AUTH_COOKIE_NAME)) ?? {};
|
||||
session[IMPERSONATED_SESSION_KEY] = userId;
|
||||
cookies.set(
|
||||
AUTH_COOKIE_NAME,
|
||||
writeSessionCookie(session),
|
||||
authCookieOptions,
|
||||
);
|
||||
|
||||
const formData = await request.formData().catch(() => null);
|
||||
const returnTo = formData?.get("returnTo");
|
||||
const redirectTo =
|
||||
typeof returnTo === "string" &&
|
||||
returnTo.startsWith("/") &&
|
||||
!returnTo.startsWith("//")
|
||||
? returnTo
|
||||
: "/admin";
|
||||
|
||||
redirect(303, redirectTo);
|
||||
};
|
||||
21
apps/web/src/routes/auth/impersonate/stop/+server.ts
Normal file
21
apps/web/src/routes/auth/impersonate/stop/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import type { RequestHandler } from "./$types";
|
||||
import {
|
||||
AUTH_COOKIE_NAME,
|
||||
authCookieOptions,
|
||||
IMPERSONATED_SESSION_KEY,
|
||||
readSessionCookie,
|
||||
writeSessionCookie,
|
||||
} from "#lib/features/auth/session.server.ts";
|
||||
|
||||
export const POST: RequestHandler = ({ cookies }) => {
|
||||
const session = readSessionCookie(cookies.get(AUTH_COOKIE_NAME)) ?? {};
|
||||
delete session[IMPERSONATED_SESSION_KEY];
|
||||
cookies.set(
|
||||
AUTH_COOKIE_NAME,
|
||||
writeSessionCookie(session),
|
||||
authCookieOptions,
|
||||
);
|
||||
|
||||
redirect(303, "/admin");
|
||||
};
|
||||
14
apps/web/src/routes/end-season/+server.ts
Normal file
14
apps/web/src/routes/end-season/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { error, json } from "@sveltejs/kit";
|
||||
import type { RequestHandler } from "./$types";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
|
||||
import * as Seasons from "#lib/features/mmr/Seasons.ts";
|
||||
|
||||
export const POST: RequestHandler = () => {
|
||||
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
|
||||
error(400, "Dev controls are not available");
|
||||
}
|
||||
|
||||
Seasons.DANGEROUS_setSeasonEndedOverride(true);
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
22
apps/web/src/routes/refresh-caches/+server.ts
Normal file
22
apps/web/src/routes/refresh-caches/+server.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { error, json } from "@sveltejs/kit";
|
||||
import type { RequestHandler } from "./$types";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
|
||||
import { refreshBannedCache } from "#lib/features/ban/banned.server.ts";
|
||||
import * as Seasons from "#lib/features/mmr/Seasons.ts";
|
||||
import { cache } from "#lib/server/cache.ts";
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
|
||||
error(400, "Dev controls are not available");
|
||||
}
|
||||
|
||||
const form = new URLSearchParams(await request.text());
|
||||
if (form.get("resetDevOverrides") === "true") {
|
||||
Seasons.DANGEROUS_setSeasonEndedOverride(false);
|
||||
}
|
||||
|
||||
cache.clear();
|
||||
await refreshBannedCache();
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
24
apps/web/src/routes/run-routine/+server.ts
Normal file
24
apps/web/src/routes/run-routine/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { error, json } from "@sveltejs/kit";
|
||||
import type { RequestHandler } from "./$types";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
|
||||
|
||||
// grows as routines are ported with their features (svelte-big-bang.md phase 4+)
|
||||
const routines = new Map<string, () => Promise<void>>();
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
|
||||
error(400, "Dev controls are not available");
|
||||
}
|
||||
|
||||
const form = new URLSearchParams(await request.text());
|
||||
const name = form.get("name");
|
||||
|
||||
const routine = name ? routines.get(name) : undefined;
|
||||
if (!routine) {
|
||||
error(400, `Unknown routine: ${name ?? "(missing)"}`);
|
||||
}
|
||||
|
||||
await routine();
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
29
apps/web/src/routes/sidenav/+server.ts
Normal file
29
apps/web/src/routes/sidenav/+server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { json } from "@sveltejs/kit";
|
||||
import type { RequestHandler } from "./$types";
|
||||
import {
|
||||
readSessionCookie,
|
||||
writeSessionCookie,
|
||||
} from "#lib/features/auth/session.server.ts";
|
||||
import { ServerConfig } from "#lib/server/config.ts";
|
||||
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
|
||||
|
||||
const SIDENAV_COOKIE_NAME = "sidenav";
|
||||
const TEN_YEARS_IN_SECONDS = 315_360_000;
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const form = new URLSearchParams(await request.text());
|
||||
const collapsed = form.get("collapsed") === "true";
|
||||
|
||||
const session =
|
||||
readSessionCookie(cookies.get(SIDENAV_COOKIE_NAME)) ?? {};
|
||||
session.collapsed = collapsed;
|
||||
cookies.set(SIDENAV_COOKIE_NAME, writeSessionCookie(session), {
|
||||
secure: ServerConfig.isProduction && !IS_E2E_TEST_RUN,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
maxAge: TEN_YEARS_IN_SECONDS,
|
||||
});
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
32
apps/web/src/routes/theme/+server.ts
Normal file
32
apps/web/src/routes/theme/+server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { json } from "@sveltejs/kit";
|
||||
import type { RequestHandler } from "./$types";
|
||||
import {
|
||||
clearTheme,
|
||||
isTheme,
|
||||
setTheme,
|
||||
} from "#lib/features/theme/theme.server.ts";
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const form = new URLSearchParams(await request.text());
|
||||
const theme = form.get("theme");
|
||||
|
||||
if (theme === "auto") {
|
||||
clearTheme(cookies);
|
||||
return json({ success: true });
|
||||
}
|
||||
|
||||
if (!isTheme(theme)) {
|
||||
return json({
|
||||
success: false,
|
||||
message: `theme value of ${theme ?? "null"} is not a valid theme`,
|
||||
});
|
||||
}
|
||||
|
||||
setTheme(cookies, theme);
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
// matches the React route's loader: a 404-status redirect home
|
||||
export const GET: RequestHandler = () => {
|
||||
return new Response(null, { status: 404, headers: { Location: "/" } });
|
||||
};
|
||||
@@ -76,7 +76,8 @@
|
||||
"status": "pending"
|
||||
},
|
||||
"leaderboards": {
|
||||
"status": "pending"
|
||||
"status": "migrated",
|
||||
"notes": "phase 2 vertical slice; verified pending differ + e2e run"
|
||||
},
|
||||
"lfg": {
|
||||
"status": "pending"
|
||||
@@ -218,7 +219,7 @@
|
||||
"feature": "front-page"
|
||||
},
|
||||
"features/layout/routes/sidenav.ts": {
|
||||
"status": "pending",
|
||||
"status": "migrated",
|
||||
"path": "/sidenav",
|
||||
"feature": "layout"
|
||||
},
|
||||
@@ -729,9 +730,10 @@
|
||||
"feature": "top-search"
|
||||
},
|
||||
"features/leaderboards/routes/leaderboards.tsx": {
|
||||
"status": "pending",
|
||||
"status": "migrated",
|
||||
"path": "/leaderboards",
|
||||
"feature": "leaderboards"
|
||||
"feature": "leaderboards",
|
||||
"notes": "ported by hand as the phase 2 golden slice; loader -> getLeaderboards query, action -> skipTeam/unskipTeam commands"
|
||||
},
|
||||
"features/links/routes/links.tsx": {
|
||||
"status": "pending",
|
||||
@@ -1099,7 +1101,7 @@
|
||||
"feature": "user-page"
|
||||
},
|
||||
"features/theme/routes/theme.ts": {
|
||||
"status": "pending",
|
||||
"status": "migrated",
|
||||
"path": "/theme",
|
||||
"feature": "theme"
|
||||
},
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -34,6 +34,9 @@ importers:
|
||||
'@sendou/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/utils
|
||||
'@types/nprogress':
|
||||
specifier: ^0.2.3
|
||||
version: 0.2.3
|
||||
date-fns:
|
||||
specifier: 4.4.0
|
||||
version: 4.4.0
|
||||
@@ -46,6 +49,9 @@ importers:
|
||||
lru-cache:
|
||||
specifier: ^11.2.1
|
||||
version: 11.5.2
|
||||
nprogress:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
remeda:
|
||||
specifier: 2.39.0
|
||||
version: 2.39.0
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"mono-split": "node src/mono-split.ts",
|
||||
"route-map": "node src/route-map.ts",
|
||||
"remote-scaffold": "node src/remote-scaffold.ts",
|
||||
"manifest:generate": "node src/manifest.ts generate",
|
||||
"manifest:report": "node src/manifest.ts report"
|
||||
},
|
||||
|
||||
174
tooling/codemods/src/remote-scaffold.ts
Normal file
174
tooling/codemods/src/remote-scaffold.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* remote-scaffold — scaffolds a feature's `<feature>.remote.ts` from its React
|
||||
* Router loader and action files: every `loaders/*.server.ts` becomes a
|
||||
* `query()` stub, every `_action("NAME")` branch of the action schema becomes
|
||||
* its own `command()` stub. The original server code is referenced (not
|
||||
* copied); the migrating agent ports the bodies per MIGRATION.md and converts
|
||||
* the schemas with zod-to-valibot. Idempotent: an existing remote file is
|
||||
* never overwritten.
|
||||
*
|
||||
* Usage:
|
||||
* node src/remote-scaffold.ts --feature leaderboards write the scaffold
|
||||
* node src/remote-scaffold.ts --feature leaderboards --print print instead of writing
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const REPO_ROOT = new URL("../../..", import.meta.url).pathname;
|
||||
const APP_ROOT = join(REPO_ROOT, "apps/web-react");
|
||||
const WEB_LIB_ROOT = join(REPO_ROOT, "apps/web/src/lib");
|
||||
const MANIFEST_PATH = join(REPO_ROOT, "migration-manifest.json");
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
feature: { type: "string" },
|
||||
print: { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
|
||||
if (!values.feature) {
|
||||
process.stderr.write(
|
||||
"usage: remote-scaffold.ts --feature <name> [--print]\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const feature = values.feature;
|
||||
const featureDir = join(APP_ROOT, "app/features", feature);
|
||||
if (!existsSync(featureDir)) {
|
||||
process.stderr.write(`remote-scaffold: no feature named ${feature}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const loaderFiles = listServerFiles(join(featureDir, "loaders"));
|
||||
const actionFiles = listServerFiles(join(featureDir, "actions"));
|
||||
|
||||
const queries = loaderFiles.map((file) => ({
|
||||
file: `app/features/${feature}/loaders/${file}`,
|
||||
name: `get${pascalCase(file.replace(".server.ts", ""))}`,
|
||||
}));
|
||||
|
||||
const commands = actionFiles.flatMap((file) => {
|
||||
const source = readFileSync(
|
||||
join(featureDir, "actions", file),
|
||||
"utf8",
|
||||
);
|
||||
const schemaImports = source.match(/from "[^"]*-schemas"/g) ?? [];
|
||||
const schemaSources = [
|
||||
source,
|
||||
...schemaImports.flatMap(() => {
|
||||
const schemaFile = join(featureDir, `${feature}-schemas.ts`);
|
||||
return existsSync(schemaFile) ? [readFileSync(schemaFile, "utf8")] : [];
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const actionNames = [
|
||||
...new Set(
|
||||
[...schemaSources.matchAll(/_action\("([A-Z0-9_]+)"\)/g)].map(
|
||||
(match) => match[1],
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return actionNames.map((actionName) => ({
|
||||
file: `app/features/${feature}/actions/${file}`,
|
||||
actionName,
|
||||
name: camelCase(actionName),
|
||||
}));
|
||||
});
|
||||
|
||||
if (queries.length === 0 && commands.length === 0) {
|
||||
process.stderr.write(
|
||||
`remote-scaffold: ${feature} has no loaders/ or actions/ files\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scaffold = `import * as v from "valibot";
|
||||
import { command, query } from "$app/server";
|
||||
|
||||
${queries
|
||||
.map(
|
||||
(query) => `export const ${query.name} = query(
|
||||
// @MIGRATE args schema: the search params / route params the loader read,
|
||||
// converted to valibot (zod-to-valibot)
|
||||
v.object({}),
|
||||
async (args) => {
|
||||
// @MIGRATE port the loader body from ${query.file}
|
||||
// (see MIGRATION.md "Read path"; auth via getUser()/requireUser())
|
||||
throw new Error("@MIGRATE ${query.name} not implemented");
|
||||
},
|
||||
);
|
||||
`,
|
||||
)
|
||||
.join("\n")}${commands
|
||||
.map(
|
||||
(cmd) => `
|
||||
export const ${cmd.name} = command(
|
||||
// @MIGRATE fields schema of the ${cmd.actionName} branch (zod-to-valibot)
|
||||
v.object({}),
|
||||
async (args) => {
|
||||
// @MIGRATE port the ${cmd.actionName} branch from ${cmd.file}
|
||||
// (see MIGRATION.md "Write path"; refresh the queries this invalidates)
|
||||
throw new Error("@MIGRATE ${cmd.name} not implemented");
|
||||
},
|
||||
);
|
||||
`,
|
||||
)
|
||||
.join("")}`;
|
||||
|
||||
if (values.print) {
|
||||
process.stdout.write(scaffold);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetDir = join(WEB_LIB_ROOT, "features", feature);
|
||||
const target = join(targetDir, `${feature}.remote.ts`);
|
||||
|
||||
if (existsSync(target)) {
|
||||
process.stdout.write(
|
||||
`remote-scaffold: ${target.replace(`${REPO_ROOT}/`, "")} already exists, leaving it alone\n`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
writeFileSync(target, scaffold);
|
||||
|
||||
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
|
||||
const featureRow = manifest.features?.[feature];
|
||||
if (featureRow && featureRow.status === "pending") {
|
||||
featureRow.status = "scaffolded";
|
||||
writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, "\t")}\n`);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`remote-scaffold: wrote ${target.replace(`${REPO_ROOT}/`, "")} (${queries.length} queries, ${commands.length} commands)\n`,
|
||||
);
|
||||
|
||||
function listServerFiles(dir: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir)
|
||||
.filter((file) => file.endsWith(".server.ts"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function pascalCase(value: string): string {
|
||||
return value
|
||||
.split(/[-_.]/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function camelCase(value: string): string {
|
||||
const pascal = pascalCase(value.toLowerCase());
|
||||
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
||||
}
|
||||
172
tooling/codemods/src/route-map.ts
Normal file
172
tooling/codemods/src/route-map.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* route-map — maps React Router routes (`app/routes.ts`) to SvelteKit route
|
||||
* directories in `apps/web/src/routes`, generating thin scaffold files for
|
||||
* routes that don't exist yet. Idempotent: existing files are never touched.
|
||||
*
|
||||
* Usage:
|
||||
* node src/route-map.ts --path /leaderboards scaffold one URL path
|
||||
* node src/route-map.ts --all scaffold every non-excluded route
|
||||
* node src/route-map.ts --list print the path → directory mapping
|
||||
*
|
||||
* `.tsx` route files scaffold a `+page.svelte` (with an @MIGRATE marker), plain
|
||||
* `.ts` route files a `+server.ts`. Scaffolded rows advance to `scaffolded` in
|
||||
* the migration manifest with `codemod: "route-map"`.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const REPO_ROOT = new URL("../../..", import.meta.url).pathname;
|
||||
const APP_ROOT = join(REPO_ROOT, "apps/web-react");
|
||||
const WEB_ROUTES_ROOT = join(REPO_ROOT, "apps/web/src/routes");
|
||||
const MANIFEST_PATH = join(REPO_ROOT, "migration-manifest.json");
|
||||
|
||||
interface RouteConfigEntry {
|
||||
path?: string;
|
||||
file: string;
|
||||
index?: boolean;
|
||||
children?: RouteConfigEntry[];
|
||||
}
|
||||
|
||||
interface ManifestRouteRow {
|
||||
status: string;
|
||||
path: string;
|
||||
feature: string;
|
||||
codemod?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
features: Record<string, { status: string; notes?: string }>;
|
||||
routes: Record<string, ManifestRouteRow>;
|
||||
}
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
path: { type: "string" },
|
||||
all: { type: "boolean", default: false },
|
||||
list: { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
|
||||
const routeModule = await import(join(APP_ROOT, "app/routes.ts"));
|
||||
const routes = [...flattenRoutes(routeModule.default, "")];
|
||||
|
||||
if (values.list) {
|
||||
for (const route of routes) {
|
||||
process.stdout.write(
|
||||
`${route.path.padEnd(45)} → src/routes/${reactPathToSvelteDir(route.path)}/${route.file.endsWith(".tsx") ? "+page.svelte" : "+server.ts"}\n`,
|
||||
);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const selected = values.all
|
||||
? routes
|
||||
: routes.filter((route) => route.path === values.path);
|
||||
|
||||
if (selected.length === 0) {
|
||||
process.stderr.write(
|
||||
values.path
|
||||
? `route-map: no route with path ${values.path}\n`
|
||||
: "usage: route-map.ts <--path /url | --all | --list>\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const manifest: Manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
|
||||
let scaffolded = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const route of selected) {
|
||||
const dir = reactPathToSvelteDir(route.path);
|
||||
const isPage = route.file.endsWith(".tsx");
|
||||
const target = join(
|
||||
WEB_ROUTES_ROOT,
|
||||
dir,
|
||||
isPage ? "+page.svelte" : "+server.ts",
|
||||
);
|
||||
|
||||
if (existsSync(target)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, isPage ? pageScaffold(route) : serverScaffold(route));
|
||||
scaffolded++;
|
||||
|
||||
const row = manifest.routes[route.file];
|
||||
if (row) {
|
||||
row.codemod = "route-map";
|
||||
if (row.status === "pending") {
|
||||
row.status = "scaffolded";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, "\t")}\n`);
|
||||
process.stdout.write(
|
||||
`route-map: ${scaffolded} scaffolded, ${skipped} already existed\n`,
|
||||
);
|
||||
|
||||
function pageScaffold(route: { file: string; path: string }) {
|
||||
const feature = route.file.split("/")[1] ?? "unknown";
|
||||
return `<script lang="ts">
|
||||
// @MIGRATE port the route component from apps/web-react/app/${route.file}
|
||||
// (thin shell: compose components from #lib/features/${feature}/components/
|
||||
// and wire the feature's remote functions; see MIGRATION.md)
|
||||
</script>
|
||||
|
||||
<h1>@MIGRATE ${route.path}</h1>
|
||||
`;
|
||||
}
|
||||
|
||||
function serverScaffold(route: { file: string; path: string }) {
|
||||
return `import type { RequestHandler } from "./$types";
|
||||
|
||||
// @MIGRATE port the resource route from apps/web-react/app/${route.file}
|
||||
|
||||
export const GET: RequestHandler = () => {
|
||||
throw new Error("@MIGRATE ${route.path} not implemented");
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
function reactPathToSvelteDir(urlPath: string): string {
|
||||
if (urlPath === "/") return "";
|
||||
return urlPath
|
||||
.replace(/^\//, "")
|
||||
.split("/")
|
||||
.map((segment) => {
|
||||
if (segment === "*") return "[...rest]";
|
||||
if (segment.endsWith("?") && segment.startsWith(":")) {
|
||||
return `[[${segment.slice(1, -1)}]]`;
|
||||
}
|
||||
if (segment.startsWith(":")) return `[${segment.slice(1)}]`;
|
||||
return segment;
|
||||
})
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function* flattenRoutes(
|
||||
entries: RouteConfigEntry[],
|
||||
parentPath: string,
|
||||
): Generator<{ file: string; path: string }> {
|
||||
for (const entry of entries) {
|
||||
const path = entry.index
|
||||
? parentPath || "/"
|
||||
: joinPaths(parentPath, entry.path);
|
||||
yield { file: entry.file, path };
|
||||
if (entry.children) {
|
||||
yield* flattenRoutes(entry.children, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function joinPaths(parent: string, child: string | undefined): string {
|
||||
if (!child) return parent || "/";
|
||||
if (child.startsWith("/")) return child;
|
||||
return `${parent.replace(/\/$/, "")}/${child}`;
|
||||
}
|
||||
@@ -25,8 +25,13 @@ pnpm run differ -- --filter /leaderboards
|
||||
pnpm run differ -- --max-rows 20 --themes light --viewports desktop
|
||||
pnpm run differ -- --seed-now 2026-08-16T12:00:00Z --concurrency 2
|
||||
pnpm run differ -- --skip-prepare # reuse the previous build + seeded dbs
|
||||
pnpm run differ -- --right-app web --filter /leaderboards # React vs Svelte
|
||||
```
|
||||
|
||||
`--right-app web` serves `apps/web` (the SvelteKit app) on the right side;
|
||||
the default (`web-react`) is the React-vs-React control run. The left side is
|
||||
always the React oracle.
|
||||
|
||||
Heads-up: a run holds two production servers plus a Chromium instance — a
|
||||
couple of GB of memory. Run it from a plain terminal, not nested under other
|
||||
memory-heavy tooling, and leave `--concurrency` at its default (1) on smaller
|
||||
|
||||
@@ -28,9 +28,16 @@ const { values } = parseArgs({
|
||||
"seed-now": { type: "string" },
|
||||
"skip-prepare": { type: "boolean", default: false },
|
||||
out: { type: "string" },
|
||||
"right-app": { type: "string", default: "web-react" },
|
||||
},
|
||||
});
|
||||
|
||||
if (values["right-app"] !== "web-react" && values["right-app"] !== "web") {
|
||||
throw new Error(
|
||||
`--right-app must be "web-react" or "web", got "${values["right-app"]}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const seedNow = values["seed-now"]
|
||||
? new Date(values["seed-now"]).getTime()
|
||||
: Date.now();
|
||||
@@ -64,6 +71,8 @@ const e2eBasePort = Number(process.env.PORT || envPort || 5173) + 500;
|
||||
const config: RunConfig = {
|
||||
repoRoot: REPO_ROOT,
|
||||
webReactDir,
|
||||
webDir: path.join(REPO_ROOT, "apps", "web"),
|
||||
rightApp: values["right-app"],
|
||||
bakedSiteDomain: `http://localhost:${e2eBasePort}`,
|
||||
outDir,
|
||||
seedNow,
|
||||
|
||||
@@ -165,10 +165,14 @@ async function createContexts(
|
||||
};
|
||||
});
|
||||
|
||||
// the redirect target is app-specific (and may not exist on a
|
||||
// partially migrated right side), so only the impersonation
|
||||
// response itself is checked
|
||||
const impersonate = await context.request.post(
|
||||
`/auth/impersonate?id=${census.adminUserId}`,
|
||||
{ maxRedirects: 0 },
|
||||
);
|
||||
if (!impersonate.ok()) {
|
||||
if (impersonate.status() >= 400) {
|
||||
throw new Error(
|
||||
`Impersonation on ${server.baseURL} failed with status ${impersonate.status()}`,
|
||||
);
|
||||
|
||||
@@ -22,6 +22,15 @@ export function prepare(config: RunConfig): Census {
|
||||
E2E_BUILD_SITE_DOMAIN: config.bakedSiteDomain,
|
||||
});
|
||||
|
||||
if (config.rightApp === "web") {
|
||||
log("Building apps/web (right side)...");
|
||||
runIn(config.webDir, "pnpm", ["run", "build"], {
|
||||
VITE_E2E_TEST_RUN: "true",
|
||||
VITE_SITE_DOMAIN: config.bakedSiteDomain,
|
||||
NODE_ENV: "production",
|
||||
});
|
||||
}
|
||||
|
||||
log(`Seeding ${SEED_DB} at ${new Date(config.seedNow).toISOString()}...`);
|
||||
for (const dbFile of [SEED_DB, LEFT_DB, RIGHT_DB]) {
|
||||
deleteDbFiles(path.join(config.webReactDir, dbFile));
|
||||
@@ -97,9 +106,18 @@ function run(
|
||||
command: string,
|
||||
args: string[],
|
||||
env: Record<string, string>,
|
||||
) {
|
||||
runIn(config.webReactDir, command, args, env);
|
||||
}
|
||||
|
||||
function runIn(
|
||||
cwd: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
env: Record<string, string>,
|
||||
) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: config.webReactDir,
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
|
||||
@@ -20,22 +20,32 @@ export async function startServers(
|
||||
|
||||
const servers = (
|
||||
[
|
||||
{ name: "left", port: config.leftPort, dbFile: dbFiles.left },
|
||||
{ name: "right", port: config.rightPort, dbFile: dbFiles.right },
|
||||
{ name: "left", app: "web-react", port: config.leftPort, dbFile: dbFiles.left },
|
||||
{ name: "right", app: config.rightApp, port: config.rightPort, dbFile: dbFiles.right },
|
||||
] as const
|
||||
).map(({ name, port, dbFile }): AppServer => {
|
||||
).map(({ name, app, port, dbFile }): AppServer => {
|
||||
const isSvelteApp = app === "web";
|
||||
const serverProcess = spawn(
|
||||
process.execPath,
|
||||
["./node_modules/@react-router/serve/bin.cjs", "./build/server/index.js"],
|
||||
isSvelteApp
|
||||
? ["./build/index.js"]
|
||||
: [
|
||||
"./node_modules/@react-router/serve/bin.cjs",
|
||||
"./build/server/index.js",
|
||||
],
|
||||
{
|
||||
cwd: config.webReactDir,
|
||||
cwd: isSvelteApp ? config.webDir : config.webReactDir,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
// bounded heap so two servers plus the browser cannot push the
|
||||
// machine into memory-pressure kills
|
||||
NODE_OPTIONS: "--max-old-space-size=1024",
|
||||
DB_PATH: dbFile,
|
||||
// the db files live in the web-react dir either way (they are
|
||||
// produced by its seed scripts)
|
||||
DB_PATH: isSvelteApp
|
||||
? `${config.webReactDir}/${dbFile}`
|
||||
: dbFile,
|
||||
PORT: String(port),
|
||||
DISCORD_CLIENT_ID: "123",
|
||||
DISCORD_CLIENT_SECRET: "secret",
|
||||
|
||||
@@ -21,6 +21,10 @@ export interface Viewport {
|
||||
export interface RunConfig {
|
||||
repoRoot: string;
|
||||
webReactDir: string;
|
||||
/** Directory of the SvelteKit app (`apps/web`). */
|
||||
webDir: string;
|
||||
/** Which app serves the right side; the left side is always `web-react` (the oracle). */
|
||||
rightApp: "web-react" | "web";
|
||||
/** The site domain baked into the shared e2e build; must be derived the
|
||||
* same way as `E2E_BASE_PORT` so the e2e build gets reused. */
|
||||
bakedSiteDomain: string;
|
||||
|
||||
Reference in New Issue
Block a user