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,