diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a79d9a..b9714a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,11 +213,11 @@ catalogs: version: 3.9.9 loader: '@nanoforge-dev/loader-client': - specifier: ^1.5.1 - version: 1.5.1 + specifier: ^1.6.0 + version: 1.6.0 '@nanoforge-dev/loader-server': - specifier: ^1.5.1 - version: 1.5.1 + specifier: ^1.6.0 + version: 1.6.0 schematics: '@angular-devkit/core': specifier: ^22.0.4 @@ -260,10 +260,10 @@ importers: version: 0.1.4 '@nanoforge-dev/loader-client': specifier: catalog:loader - version: 1.5.1 + version: 1.6.0 '@nanoforge-dev/loader-server': specifier: catalog:loader - version: 1.5.1 + version: 1.6.0 '@nanoforge-dev/schematics': specifier: workspace:* version: link:libs/schematics @@ -894,16 +894,16 @@ packages: resolution: {integrity: sha512-bqbrH8ulFSiZz1iisJHtHeEzj1RV/70uErCYHeW4DIhVPsWbBfWKFvg/ghAbmPk5vsj0YnhDX6DSpik4S1QoiQ==} engines: {node: '25'} - '@nanoforge-dev/loader-client@1.5.1': - resolution: {integrity: sha512-1PueGpj8DbjRcZTVVCKAkKi4A8gyBaFTkfp78fFrW47J0HZcfS8pq+AaNaBjEUOozn0E7L+U8zGf/5laxTRe1A==} + '@nanoforge-dev/loader-client@1.6.0': + resolution: {integrity: sha512-hW4jz6QSVuF9wC6CIJfE2cmHGFCXNZFEy6dBYicaRFNZ/USF7FJBjyQvwyI5MoWdjwo0A7U/zPUfRe/3TuHzgA==} engines: {node: '26'} - '@nanoforge-dev/loader-server@1.5.1': - resolution: {integrity: sha512-RNqA0zpEhUXENKu5chJ+vQOhr8qK+a7z6XePmiCJygpSScJ8p1dlWquFYNwu9dBLHBP8BhK9dSFvXjPvHAYBxQ==} + '@nanoforge-dev/loader-server@1.6.0': + resolution: {integrity: sha512-Jnc9WHUMfCo8R11qPlIFPJ0COuwX6BJ8HkSTpyTMugNKvN9dcUIwiJ72NydLDGWeTbqqXGIcpFkIkX8APYP+WQ==} engines: {node: '26'} - '@nanoforge-dev/loader-website@1.5.1': - resolution: {integrity: sha512-Ugu5CvfbsCK2wG8RXBHkkVlNHkSlXz2xXxfqglID7Cb4E8PHO0p1hbE6e7+1fWXAdFhEivGQHSI1POIvgyP1Qg==} + '@nanoforge-dev/loader-website@1.6.0': + resolution: {integrity: sha512-szo3/jPBFqUvgYeyHIN6LfzyK0GFxkgTtPJaNcqyFKEkSytUxRdplE4r+WykcMVIk7LlUwbZ56wai7RS38cPfg==} engines: {node: '26'} '@nanoforge-dev/utils-eslint-config@1.0.2': @@ -3581,18 +3581,18 @@ snapshots: tar: 7.5.22 typescript: 6.0.3 - '@nanoforge-dev/loader-client@1.5.1': + '@nanoforge-dev/loader-client@1.6.0': dependencies: - '@nanoforge-dev/loader-website': 1.5.1 + '@nanoforge-dev/loader-website': 1.6.0 bun: 1.4.2 commander: 15.0.0 - '@nanoforge-dev/loader-server@1.5.1': + '@nanoforge-dev/loader-server@1.6.0': dependencies: bun: 1.4.2 commander: 15.0.0 - '@nanoforge-dev/loader-website@1.5.1': {} + '@nanoforge-dev/loader-website@1.6.0': {} '@nanoforge-dev/utils-eslint-config@1.0.2(@types/eslint@9.6.1)(eslint@10.11.0(jiti@2.6.1)(supports-color@10.2.2))(prettier@3.9.9)(supports-color@10.2.2)(typescript@6.0.3)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cecfa10..f295aa1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 @@ -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' diff --git a/src/action/actions/build.action.ts b/src/action/actions/build.action.ts index f3c118f..1a7cc1c 100644 --- a/src/action/actions/build.action.ts +++ b/src/action/actions/build.action.ts @@ -31,6 +31,7 @@ interface BuildTarget { static?: string; output: string; platform: "browser" | "node"; + sourcemap: boolean; } export class BuildAction extends AbstractAction { @@ -103,6 +104,7 @@ export class BuildAction extends AbstractAction { project.config.out?.dir ?? defaults.out.dir, ), platform: isClient ? "browser" : "node", + sourcemap: isEditor, }; } @@ -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); diff --git a/src/action/actions/editor.action.ts b/src/action/actions/editor.action.ts index b92b4c1..4e89a49 100644 --- a/src/action/actions/editor.action.ts +++ b/src/action/actions/editor.action.ts @@ -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"; @@ -18,19 +25,20 @@ export class EditorAction extends AbstractAction { public async handle(args: Input, options: Input): Promise { 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 { + private async startEditor(fsRoot: string, port: number): Promise { const editorPath = join( getModulePath("@nanoforge-dev/editor/package.json", true), "dist", @@ -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 { + 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 { await open(url); } diff --git a/src/command/commands/editor.command.ts b/src/command/commands/editor.command.ts index 60f6780..00a1e58 100644 --- a/src/command/commands/editor.command.ts +++ b/src/command/commands/editor.command.ts @@ -5,6 +5,7 @@ import { AbstractCommand } from "../abstract.command"; interface EditorOptions { directory?: string; open?: boolean; + port?: string; } export class EditorCommand extends AbstractCommand { @@ -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 of the editor (default: 5173)") .action(async (path: string, rawOptions: EditorOptions) => { const args = AbstractCommand.mapToInput({ path, @@ -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); diff --git a/src/lib/input/inputs/editor/index.ts b/src/lib/input/inputs/editor/index.ts index 271c657..f6cf785 100644 --- a/src/lib/input/inputs/editor/index.ts +++ b/src/lib/input/inputs/editor/index.ts @@ -1 +1,2 @@ export * from "./open.input"; +export * from "./port.input"; diff --git a/src/lib/input/inputs/editor/port.input.spec.ts b/src/lib/input/inputs/editor/port.input.spec.ts new file mode 100644 index 0000000..7737a35 --- /dev/null +++ b/src/lib/input/inputs/editor/port.input.spec.ts @@ -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(); + }); +}); diff --git a/src/lib/input/inputs/editor/port.input.ts b/src/lib/input/inputs/editor/port.input.ts new file mode 100644 index 0000000..032a49a --- /dev/null +++ b/src/lib/input/inputs/editor/port.input.ts @@ -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; +}; diff --git a/src/lib/utils/editor-target.spec.ts b/src/lib/utils/editor-target.spec.ts new file mode 100644 index 0000000..d5e5d81 --- /dev/null +++ b/src/lib/utils/editor-target.spec.ts @@ -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"); + }); +}); diff --git a/src/lib/utils/editor-target.ts b/src/lib/utils/editor-target.ts new file mode 100644 index 0000000..ed53eff --- /dev/null +++ b/src/lib/utils/editor-target.ts @@ -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)}`; +};