From c12cdd027351b8307f5937f7b4f81a3efa61ce17 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 23 Oct 2026 16:44:26 +0200 Subject: [PATCH] feat(build): build the server app for the browser in p2p workspaces Adds network: "dedicated" | "p2p" to the workspace config. In p2p, nf build also builds each server app for the browser into the client output, and nf start sets NANOFORGE_NETWORK_MODE=p2p for both apps. Closes #244 --- docs/docs/commands/2-commands/3-build.mdx | 3 ++ docs/docs/commands/2-commands/4-start.mdx | 6 ++++ .../configuration/1-configuration-file.mdx | 28 +++++++++++++++-- e2e/cli-build.test.ts | 31 ++++++++++++++++++- libs/config/README.md | 2 +- libs/config/src/defaults/default-configs.ts | 1 + libs/config/src/parse/validate-config.ts | 1 + .../config/src/types/workspace-config.type.ts | 2 ++ libs/config/test/parse-config.spec.ts | 2 +- libs/config/test/resolve-config.spec.ts | 12 +++++-- src/action/actions/build.action.ts | 18 +++++++++-- src/action/actions/start.action.ts | 4 +++ src/lib/config/workspace-loader.ts | 3 ++ 13 files changed, 103 insertions(+), 10 deletions(-) diff --git a/docs/docs/commands/2-commands/3-build.mdx b/docs/docs/commands/2-commands/3-build.mdx index 930f210..42f98dc 100644 --- a/docs/docs/commands/2-commands/3-build.mdx +++ b/docs/docs/commands/2-commands/3-build.mdx @@ -9,6 +9,9 @@ description: Build your NanoForge project for production and output directories all default to the values in your [configuration file](../../configuration/1-configuration-file) and can be overridden per run. +When the workspace sets `network: "p2p"`, each server app is also built for the browser, into the +client's output directory under `server/`, so the host player's tab can run it. + ## Usage ```bash diff --git a/docs/docs/commands/2-commands/4-start.mdx b/docs/docs/commands/2-commands/4-start.mdx index cf1f49e..8de74a9 100644 --- a/docs/docs/commands/2-commands/4-start.mdx +++ b/docs/docs/commands/2-commands/4-start.mdx @@ -49,3 +49,9 @@ nf start --cert ./certs/cert.pem --key ./certs/key.pem project root when the game starts. See [Environment variables](../../configuration/2-environment-variables). + + + When the workspace sets `network: "p2p"`, `nf start` adds `NANOFORGE_NETWORK_MODE=p2p` to the + environment of both apps: the server app only pairs players, and the game runs in the host + player's browser. + diff --git a/docs/docs/configuration/1-configuration-file.mdx b/docs/docs/configuration/1-configuration-file.mdx index 51a89b9..3f2b0cf 100644 --- a/docs/docs/configuration/1-configuration-file.mdx +++ b/docs/docs/configuration/1-configuration-file.mdx @@ -48,9 +48,10 @@ export default defineConfig({ ## `workspace` -| Field | Type | Default | Description | -| ---------- | ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `packages` | `string[]` | `[]` | Glob patterns matched against directories relative to the workspace root, each expected to hold its own `client`, `server`, or `lib` config. | +| Field | Type | Default | Description | +| ---------- | ---------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages` | `string[]` | `[]` | Glob patterns matched against directories relative to the workspace root, each expected to hold its own `client`, `server`, or `lib` config. | +| `network` | `"dedicated" \| "p2p"` | `"dedicated"` | Where the server app runs. `"p2p"` runs it in the browser of the first player who opens the game, the others joining that player over WebRTC: see [Peer-to-peer](#peer-to-peer). | A directory matched by a pattern but missing a `nanoforge.config.ts`/`.js` is skipped without error. A pattern that matches no project or lib at all only logs a warning, rather than failing @@ -67,6 +68,27 @@ export default defineConfig({ }); ``` +### Peer-to-peer + +With `network: "p2p"`, the same client and server apps run without a game server: + +- `nf build` also builds each server app for the browser, into the client's output (`server/`). +- `nf start` sets `NANOFORGE_NETWORK_MODE=p2p` for both apps. The server process then only pairs + players (WebRTC signaling) on its `LISTENING_TCP_PORT`, and the environment stays the same as + with a dedicated server. +- A player who opens the game without `?room=` hosts it in their tab, and the game can show its + invite link (`ctx.network.inviteLink`). + +```ts +import { defineConfig } from "@nanoforge-dev/config"; + +export default defineConfig({ + type: "workspace", + packages: ["apps/*"], + network: "p2p", +}); +``` + ## `lib` | Field | Type | Default | Description | diff --git a/e2e/cli-build.test.ts b/e2e/cli-build.test.ts index 11fccc4..975a8a4 100644 --- a/e2e/cli-build.test.ts +++ b/e2e/cli-build.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -180,6 +180,35 @@ describe("nf build (TypeScript, with server)", () => { }); }); +describe("nf build (TypeScript, peer-to-peer)", () => { + const projectDir = resolve(tmpDir, "build-ts-p2p"); + const workspaceDir = resolve(projectDir, "build-p2p-app"); + const clientDir = resolve(workspaceDir, "apps/client"); + const serverDir = resolve(workspaceDir, "apps/server"); + + beforeAll(async () => { + mkdirSync(projectDir, { recursive: true }); + await newProject("build-p2p-app", projectDir, true); + writeStandaloneEntry(clientDir); + writeStandaloneEntry(serverDir); + const configPath = resolve(workspaceDir, "nanoforge.config.ts"); + const config = readFileSync(configPath, "utf8"); + writeFileSync( + configPath, + config.replace('type: "workspace",', 'type: "workspace",\n network: "p2p",'), + ); + }); + + it("should also build the server app for the browser into the client output", async () => { + const { exitCode } = await runCli(["build", "-d", workspaceDir]); + + expect(exitCode).toBe(0); + expect(existsSync(resolve(clientDir, "dist", "main.js"))).toBe(true); + expect(existsSync(resolve(clientDir, "dist", "server", "main.js"))).toBe(true); + expect(existsSync(resolve(serverDir, "dist", "main.js"))).toBe(true); + }); +}); + describe("nf build (with invalid directory)", () => { it("should fail when directory does not exist", async () => { const { exitCode } = await runCli(["build", "-d", resolve(tmpDir, "nonexistent")]); diff --git a/libs/config/README.md b/libs/config/README.md index 05cc5c3..7233180 100644 --- a/libs/config/README.md +++ b/libs/config/README.md @@ -45,7 +45,7 @@ export default defineConfig({ The `type` field discriminates between the four config shapes: -- `workspace` — a monorepo root listing its `packages`. +- `workspace` — a monorepo root listing its `packages`, and its `network` mode (`"dedicated"` by default, or `"p2p"` to run the server app in the host player's browser). - `lib` — a shared library consumed by clients/servers. - `client` — a browser-side NanoForge app. - `server` — a server-side NanoForge app. diff --git a/libs/config/src/defaults/default-configs.ts b/libs/config/src/defaults/default-configs.ts index 2b06faf..7cbea84 100644 --- a/libs/config/src/defaults/default-configs.ts +++ b/libs/config/src/defaults/default-configs.ts @@ -3,6 +3,7 @@ import type { DeepRequired } from "../types/utils.type"; export const defaultWorkspaceConfig: DeepRequired> = { packages: [], + network: "dedicated", }; export const defaultLibConfig: DeepRequired> = { diff --git a/libs/config/src/parse/validate-config.ts b/libs/config/src/parse/validate-config.ts index 8a07e97..7dea446 100644 --- a/libs/config/src/parse/validate-config.ts +++ b/libs/config/src/parse/validate-config.ts @@ -53,6 +53,7 @@ const sourceableConfigShape = { const workspaceConfigSchema = z.object({ type: z.literal("workspace"), packages: z.array(z.string()).optional(), + network: z.enum(["dedicated", "p2p"]).optional(), }); const libConfigSchema = z.object({ diff --git a/libs/config/src/types/workspace-config.type.ts b/libs/config/src/types/workspace-config.type.ts index 8d45c7d..692f06f 100644 --- a/libs/config/src/types/workspace-config.type.ts +++ b/libs/config/src/types/workspace-config.type.ts @@ -2,4 +2,6 @@ import type { BaseConfig } from "./base-config.type"; export interface WorkspaceConfig extends BaseConfig<"workspace"> { packages?: string[]; + /** `p2p` runs the server app in the host player's browser instead of a server process. */ + network?: "dedicated" | "p2p"; } diff --git a/libs/config/test/parse-config.spec.ts b/libs/config/test/parse-config.spec.ts index 2d98c72..0b7dacc 100644 --- a/libs/config/test/parse-config.spec.ts +++ b/libs/config/test/parse-config.spec.ts @@ -17,7 +17,7 @@ describe("parseConfig", () => { it("resolves a .js config file the same way as .ts", async () => { const resolved = await parseConfig(fixture("valid-config.js")); - expect(resolved).toEqual({ type: "workspace", packages: ["apps/*"] }); + expect(resolved).toEqual({ type: "workspace", packages: ["apps/*"], network: "dedicated" }); }); it("rejects with code 'not-found' when the file doesn't exist", async () => { diff --git a/libs/config/test/resolve-config.spec.ts b/libs/config/test/resolve-config.spec.ts index 5f60e27..21c1a22 100644 --- a/libs/config/test/resolve-config.spec.ts +++ b/libs/config/test/resolve-config.spec.ts @@ -13,13 +13,17 @@ describe("resolveWorkspaceConfig", () => { expect(resolveWorkspaceConfig({ type: "workspace" })).toEqual({ type: "workspace", packages: [], + network: "dedicated", }); }); it("lets an explicit override win", () => { - expect(resolveWorkspaceConfig({ type: "workspace", packages: ["apps/*"] })).toEqual({ + expect( + resolveWorkspaceConfig({ type: "workspace", packages: ["apps/*"], network: "p2p" }), + ).toEqual({ type: "workspace", packages: ["apps/*"], + network: "p2p", }); }); }); @@ -70,7 +74,11 @@ describe("resolveServerConfig", () => { describe("resolveConfig", () => { it("dispatches to the resolver matching the config's type", () => { - expect(resolveConfig({ type: "workspace" })).toEqual({ type: "workspace", packages: [] }); + expect(resolveConfig({ type: "workspace" })).toEqual({ + type: "workspace", + packages: [], + network: "dedicated", + }); expect(resolveConfig({ type: "server" }).entryFile).toBe("src/main.ts"); }); }); diff --git a/src/action/actions/build.action.ts b/src/action/actions/build.action.ts index 1a7cc1c..c7c9e3e 100644 --- a/src/action/actions/build.action.ts +++ b/src/action/actions/build.action.ts @@ -5,6 +5,7 @@ import { dirname, join, relative } from "node:path"; import { type ResolvedProject, type WorkspaceConfig, + isPeerToPeer, parseWorkspaceConfig, resolveProjects, } from "@lib/config"; @@ -62,10 +63,23 @@ export class BuildAction extends AbstractAction { isEditor: boolean, ): BuildTarget[] { const projects = resolveProjects(workspaceConfig, baseDirectory); - - return projects.map((project) => + const targets = projects.map((project) => this.createTarget(project, baseDirectory, options, isEditor, projects.length > 1), ); + if (!isPeerToPeer(workspaceConfig)) return targets; + + const client = targets.find((target) => target.platform === "browser"); + if (!client) return targets; + + const browserServers = targets + .filter((target) => target.platform === "node") + .map((server) => ({ + ...server, + name: `${server.name} (browser)`, + output: relative(server.directory, join(client.directory, client.output, "server")), + platform: "browser" as const, + })); + return [...targets, ...browserServers]; } private createTarget( diff --git a/src/action/actions/start.action.ts b/src/action/actions/start.action.ts index 8aca366..2b2f428 100644 --- a/src/action/actions/start.action.ts +++ b/src/action/actions/start.action.ts @@ -5,6 +5,7 @@ import { join, resolve } from "path"; import { type ResolvedProject, type WorkspaceConfig, + isPeerToPeer, parseWorkspaceConfig, resolveProjects, } from "@lib/config"; @@ -77,6 +78,9 @@ export class StartAction extends AbstractAction { } const env = this.parseEnv(directory); + if (isPeerToPeer(workspaceConfig)) { + env.client.NANOFORGE_NETWORK_MODE = env.server.NANOFORGE_NETWORK_MODE = "p2p"; + } const packageManager = await PackageManagerFactory.find(directory); const tasks = this.buildStartTasks(packageManager, serverTargets, clientTargets, watch, env); await Promise.all(tasks); diff --git a/src/lib/config/workspace-loader.ts b/src/lib/config/workspace-loader.ts index df7d376..92f1474 100644 --- a/src/lib/config/workspace-loader.ts +++ b/src/lib/config/workspace-loader.ts @@ -8,6 +8,9 @@ export interface ResolvedProject { config: ClientConfig | ServerConfig; } +export const isPeerToPeer = (workspace: WorkspaceConfig): boolean => + workspace.type === "workspace" && workspace.config.network === "p2p"; + export const resolveProjects = ( workspace: WorkspaceConfig, baseDirectory: string,