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
3 changes: 3 additions & 0 deletions docs/docs/commands/2-commands/3-build.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/docs/commands/2-commands/4-start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
</Note>

<Note>
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.
</Note>
28 changes: 25 additions & 3 deletions docs/docs/configuration/1-configuration-file.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ export default defineConfig({

## `workspace`

| Field | Type | Default | Description |
| ---------- | ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages` | `string[]` | `[]` | <Tooltip tip="e.g. apps/*, apps/**/client, or a literal path like apps/client.">Glob patterns matched against directories relative to the workspace root</Tooltip>, each expected to hold its own `client`, `server`, or `lib` config. |
| Field | Type | Default | Description |
| ---------- | ---------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages` | `string[]` | `[]` | <Tooltip tip="e.g. apps/*, apps/**/client, or a literal path like apps/client.">Glob patterns matched against directories relative to the workspace root</Tooltip>, 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
Expand All @@ -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 |
Expand Down
31 changes: 30 additions & 1 deletion e2e/cli-build.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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")]);
Expand Down
2 changes: 1 addition & 1 deletion libs/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions libs/config/src/defaults/default-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { DeepRequired } from "../types/utils.type";

export const defaultWorkspaceConfig: DeepRequired<Omit<WorkspaceConfig, "type">> = {
packages: [],
network: "dedicated",
};

export const defaultLibConfig: DeepRequired<Omit<LibConfig, "type">> = {
Expand Down
1 change: 1 addition & 0 deletions libs/config/src/parse/validate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions libs/config/src/types/workspace-config.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
2 changes: 1 addition & 1 deletion libs/config/test/parse-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
12 changes: 10 additions & 2 deletions libs/config/test/resolve-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});
});
Expand Down Expand Up @@ -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");
});
});
18 changes: 16 additions & 2 deletions src/action/actions/build.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { dirname, join, relative } from "node:path";
import {
type ResolvedProject,
type WorkspaceConfig,
isPeerToPeer,
parseWorkspaceConfig,
resolveProjects,
} from "@lib/config";
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions src/action/actions/start.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join, resolve } from "path";
import {
type ResolvedProject,
type WorkspaceConfig,
isPeerToPeer,
parseWorkspaceConfig,
resolveProjects,
} from "@lib/config";
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/lib/config/workspace-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading