Skip to content
Merged
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
32 changes: 16 additions & 16 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ catalogs:
eslint: ^10.11.0
prettier: ^3.9.9
loader:
'@nanoforge-dev/loader-client': ^1.5.1
'@nanoforge-dev/loader-server': ^1.5.1
'@nanoforge-dev/loader-client': ^1.6.0
'@nanoforge-dev/loader-server': ^1.6.0
schematics:
'@angular-devkit/core': ^22.0.4
'@angular-devkit/schematics': ^22.1.8
Expand All @@ -59,6 +59,6 @@ catalogs:
vitest: ^5.0.1

minimumReleaseAgeExclude:
- '@nanoforge-dev/loader-client@1.5.1'
- '@nanoforge-dev/loader-server@1.5.1'
- '@nanoforge-dev/loader-website@1.5.1'
- '@nanoforge-dev/loader-client@1.6.0'
- '@nanoforge-dev/loader-server@1.6.0'
- '@nanoforge-dev/loader-website@1.6.0'
10 changes: 9 additions & 1 deletion src/action/actions/build.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface BuildTarget {
static?: string;
output: string;
platform: "browser" | "node";
sourcemap: boolean;
}

export class BuildAction extends AbstractAction {
Expand Down Expand Up @@ -103,6 +104,7 @@ export class BuildAction extends AbstractAction {
project.config.out?.dir ?? defaults.out.dir,
),
platform: isClient ? "browser" : "node",
sourcemap: isEditor,
};
}

Expand All @@ -127,7 +129,13 @@ export class BuildAction extends AbstractAction {
target.directory,
target.entry,
target.output,
["--asset-naming", "[name].[ext]", "--target", target.platform],
[
"--asset-naming",
"[name].[ext]",
"--target",
target.platform,
...(target.sourcemap ? ["--sourcemap=linked"] : []),
],
rebuild,
);
}, false);
Expand Down
46 changes: 38 additions & 8 deletions src/action/actions/editor.action.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import open from "open";
import { join } from "path";

import { type Input, getDirectoryInput, getEditorOpenInput, getPathInput } from "@lib/input";
import {
type Input,
getDirectoryInput,
getEditorOpenInput,
getEditorPortInput,
getPathInput,
} from "@lib/input";
import { PackageManagerFactory, PackageManagerName } from "@lib/package-manager";
import { Messages } from "@lib/ui";

import { getModulePath } from "@utils/path";
import { editorUrl, resolveEditorTarget } from "@utils/editor-target";
import { getCwd, getModulePath } from "@utils/path";
import { runSafe } from "@utils/run-safe";

import { AbstractAction, type HandleResult } from "../abstract.action";
Expand All @@ -18,19 +25,20 @@ export class EditorAction extends AbstractAction {
public async handle(args: Input, options: Input): Promise<HandleResult> {
const directory = getDirectoryInput(options);
const path = getPathInput(args);
const port = getEditorPortInput(options);
const shouldOpen = getEditorOpenInput(options, !!path);
const query = path ? `/load?path=${encodeURIComponent(path)}` : "";
const url = `http://localhost:3000${query}`;
const target = resolveEditorTarget(getCwd(directory), path);
const url = editorUrl(port, target);

console.log(`\n🔗 Editor running! Open it in your browser: \x1b[36m${url}\x1b[0m\n`);

void this.startEditor(directory);
if (shouldOpen) await this.openBrowser(url);
void this.startEditor(target.fsRoot, port);
if (shouldOpen && (await this.waitForEditor(port))) await this.openBrowser(url);

return { keepAlive: true };
}

private async startEditor(directory: string): Promise<void> {
private async startEditor(fsRoot: string, port: number): Promise<void> {
const editorPath = join(
getModulePath("@nanoforge-dev/editor/package.json", true),
"dist",
Expand All @@ -39,10 +47,32 @@ export class EditorAction extends AbstractAction {

await runSafe(async () => {
const packageManager = PackageManagerFactory.create(PackageManagerName.LOCAL_BUN);
await packageManager.run("Editor", directory, editorPath, [], {}, [], true);
await packageManager.run(
"Editor",
fsRoot,
editorPath,
[],
{ PORT: String(port), FS_ROOT: fsRoot },
[],
true,
);
});
}

/** Waits until the editor answers (up to 30 s) so the browser doesn't open on an error page. */
private async waitForEditor(port: number): Promise<boolean> {
for (const deadline = Date.now() + 30_000; Date.now() < deadline;) {
try {
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
if (response.ok) return true;
} catch {
// Not listening yet.
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
return false;
}

private async openBrowser(url: string): Promise<void> {
await open(url);
}
Expand Down
3 changes: 3 additions & 0 deletions src/command/commands/editor.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AbstractCommand } from "../abstract.command";
interface EditorOptions {
directory?: string;
open?: boolean;
port?: string;
}

export class EditorCommand extends AbstractCommand {
Expand All @@ -18,6 +19,7 @@ export class EditorCommand extends AbstractCommand {
"open the editor on the default web browser (default: true if path is specified, false otherwise)",
)
.option("--no-open", "do not open the editor on the default web browser")
.option("-p, --port <port>", "port of the editor (default: 5173)")
.action(async (path: string, rawOptions: EditorOptions) => {
const args = AbstractCommand.mapToInput({
path,
Expand All @@ -26,6 +28,7 @@ export class EditorCommand extends AbstractCommand {
const options = AbstractCommand.mapToInput({
directory: rawOptions.directory,
open: rawOptions.open,
port: rawOptions.port,
});

await this.action.run(args, options);
Expand Down
1 change: 1 addition & 0 deletions src/lib/input/inputs/editor/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./open.input";
export * from "./port.input";
25 changes: 25 additions & 0 deletions src/lib/input/inputs/editor/port.input.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";

import { type Input } from "../../input.type";
import { DEFAULT_EDITOR_PORT, getEditorPortInput } from "./port.input";

const createInput = (entries: [string, any][]): Input => {
return new Map(entries.map(([key, value]) => [key, { value }]));
};

describe("getEditorPortInput", () => {
it("should return the port when provided", () => {
expect(getEditorPortInput(createInput([["port", "4100"]]))).toBe(4100);
});

it("should default to the editor port", () => {
expect(getEditorPortInput(createInput([]))).toBe(DEFAULT_EDITOR_PORT);
});

it("should reject invalid ports", () => {
expect(() => getEditorPortInput(createInput([["port", "http"]]))).toThrow(
"Invalid editor port",
);
expect(() => getEditorPortInput(createInput([["port", "70000"]]))).toThrow();
});
});
15 changes: 15 additions & 0 deletions src/lib/input/inputs/editor/port.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { getStringInput } from "../../base-inputs";
import { type Input } from "../../input.type";

/** Default port of `nf editor` (the loaders use 3000). */
export const DEFAULT_EDITOR_PORT = 5173;

export const getEditorPortInput = (inputs: Input): number => {
const raw = getStringInput(inputs, "port");
if (raw === undefined) return DEFAULT_EDITOR_PORT;
const port = Number(raw);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid editor port: ${raw}`);
}
return port;
};
31 changes: 31 additions & 0 deletions src/lib/utils/editor-target.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";

import { editorUrl, resolveEditorTarget } from "./editor-target";

describe("resolveEditorTarget", () => {
it("roots the editor in the working directory", () => {
expect(resolveEditorTarget("/work")).toEqual({ fsRoot: "/work" });
expect(resolveEditorTarget("/work", "games/pong")).toEqual({
fsRoot: "/work",
projectPath: "games/pong",
});
expect(resolveEditorTarget("/work", ".")).toEqual({ fsRoot: "/work", projectPath: "." });
});

it("roots the editor in the parent of a project outside of it", () => {
expect(resolveEditorTarget("/work", "/other/pong")).toEqual({
fsRoot: "/other",
projectPath: "pong",
});
expect(resolveEditorTarget("/work/a", "../b")).toEqual({ fsRoot: "/work", projectPath: "b" });
});
});

describe("editorUrl", () => {
it("opens the project when there is one", () => {
expect(editorUrl(5173, { fsRoot: "/work", projectPath: "games/pong" })).toBe(
"http://localhost:5173/load?path=games%2Fpong",
);
expect(editorUrl(4000, { fsRoot: "/work" })).toBe("http://localhost:4000");
});
});
31 changes: 31 additions & 0 deletions src/lib/utils/editor-target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";

export interface EditorTarget {
/** Folder the editor may open projects in (`FS_ROOT` of the editor). */
fsRoot: string;
/** Project to open, relative to `fsRoot` (`.` for `fsRoot` itself). */
projectPath?: string;
}

/**
* Where `nf editor [path]` roots the editor: the working directory, or the project's parent
* folder when the project is outside of it.
*/
export const resolveEditorTarget = (directory: string, path?: string): EditorTarget => {
const fsRoot = resolve(directory);
if (!path) return { fsRoot };
const project = resolve(fsRoot, path);
const inside = relative(fsRoot, project);
if (!inside.startsWith("..") && !isAbsolute(inside)) {
return { fsRoot, projectPath: inside || "." };
}
return { fsRoot: dirname(project), projectPath: basename(project) };
};

/** URL the browser opens: the project when there is one, the project list otherwise. */
export const editorUrl = (port: number, target: EditorTarget): string => {
const base = `http://localhost:${port}`;
return target.projectPath === undefined
? base
: `${base}/load?path=${encodeURIComponent(target.projectPath)}`;
};
Loading