diff --git a/.cliff-jumperrc.json b/.cliff-jumperrc.json deleted file mode 100644 index 42936275..00000000 --- a/.cliff-jumperrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/favware/cliff-jumper/main/assets/cliff-jumper.schema.json", - "name": "editor", - "org": "nanoforge-dev", - "packagePath": ".", - "tagTemplate": "{{new-version}}", - "identifierBase": false -} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..51a4595b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +**/node_modules +**/dist +**/.svelte-kit +**/.turbo +**/coverage +.git +.idea +.env diff --git a/.env.example b/.env.example index 296d687c..c1506f18 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,34 @@ -# Set to production +# production | development NODE_ENV=development -# OFFLINE or ONLINE, change editor mode beetween local execution and online execution +# OFFLINE: local projects on disk (binds to 127.0.0.1). ONLINE: hosted, projects synced with git gateways. PUBLIC_MODE=OFFLINE -# Urls -PUBLIC_PM_URL=https://projects.nanoforge.eu -PUBLIC_DOCS_URL=https://docs.nanoforge.eu -PUBLIC_LANDING_URL=https://nanoforge.eu - -# Api params (required if PUBLIC_MODE=ONLINE) -API_URL=http://localhost:3000 -API_KEY=test +# Server. In OFFLINE mode HOST must be a loopback address (no sign-in, project code runs), +# unless ALLOW_REMOTE_OFFLINE=1 and something else restricts who can connect. +# HOST=127.0.0.1 +# ALLOW_REMOTE_OFFLINE= +PORT=3000 -# Fs root dir read/write projects (leave empty to select current directory) (if you use relative path, it will be /) +# Projects must live under this directory (default: current directory) FS_ROOT= -# Archive root dir read/write archive (leave empty to select current directory) (if you use relative path, it will be /) -# Shouldn't be in FS_ROOT to avoid name conflicts -ARCHIVE_ROOT= +# Editor data (recent projects, installed plugins). Default: ~/.nanoforge/editor +DATA_DIR= -# Leaving this empty will generate a new unique random session secret at start +# NanoForge API (API_KEY required when PUBLIC_MODE=ONLINE) +API_URL=https://api.nanoforge.eu +API_KEY= +# Where ONLINE users sign in (the website sets the auth cookies) +PUBLIC_PM_URL=https://projects.nanoforge.eu + +# Leave empty to generate a random secret at start (sessions are lost on restart) SESSION_SECRET= -# Change if your nf cli executable isn't in the path -NF_CLI_PATH=nf +# Change if the binaries are not in the PATH (nf: the project's own node_modules/.bin is tried first) +NF_CLI_PATH= +GIT_PATH= + +# Comma separated list of local plugin folders under development (their dist/ is served, hot reloaded) +DEV_PLUGINS= +# Extra origins allowed to call the server (reverse proxies) +ALLOWED_ORIGINS= diff --git a/.github/ISSUE_TEMPLATE/01-command_bug_report.yml b/.github/ISSUE_TEMPLATE/01-command_bug_report.yml deleted file mode 100644 index f75ac748..00000000 --- a/.github/ISSUE_TEMPLATE/01-command_bug_report.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: Bug report -description: Report an issue with Nanoforge Editor. -labels: [bug] -body: - - type: markdown - attributes: - value: | - Thank you for filing an issue! If you are here to ask a question, don't hesitate to open a discussion instead: https://github.com/NanoForge-dev/editor/discussions. - - This issue form is for Nanoforge Editor. - validations: - required: true - - type: textarea - id: description - attributes: - label: Issue description - description: Describe the issue in as much detail as possible. - placeholder: | - Steps to reproduce with below code sample: - 1. Do thing - 2. Observe behavior - 3. See error logs below - validations: - required: true - - type: textarea - id: code_sample - attributes: - label: Code sample - description: | - Your code sample should be: - 1. Minimal - Use as little code as possible that still produces the same problem (and is understandable) - 2. Complete - Provide all parts someone else needs to reproduce your problem - 3. Reproducible - Test the code you're about to provide to make sure it reproduces the problem - - This will be automatically formatted into code, so no need for backticks. - render: typescript - - type: textarea - id: versions - attributes: - label: Versions - description: List necessary versions here. This includes your package version, runtime version, operating system etc. - placeholder: | - - @nanoforge-dev/editor 1.0.0 (`npm ls @nanoforge-dev/editor`) - - Node.js 25.x.x (`node --version`) - - TypeScript 5.9.3 (`npm ls typescript` if you use it) - - macOS Ventura 13.3.1 - validations: - required: true - - type: dropdown - id: priority - attributes: - label: Issue priority - description: Please be realistic. If you need to elaborate on your reasoning, please use the issue description field above. - options: - - Low (slightly annoying) - - Medium (should be fixed soon) - - High (immediate attention needed) - validations: - required: true - - type: input - id: dev_release - attributes: - label: I have tested this issue on a development release - placeholder: d23280c (commit hash) - description: | - The issue might already be fixed in a development main. This is not required, but helps us greatly. diff --git a/.github/ISSUE_TEMPLATE/01-packages_bug_report.yml b/.github/ISSUE_TEMPLATE/01-packages_bug_report.yml new file mode 100644 index 00000000..4dc72529 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-packages_bug_report.yml @@ -0,0 +1,99 @@ +name: Bug report +description: Report an issue with Nanoforge Editor. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thank you for filing an issue! If you are here to ask a question, don't hesitate to open a discussion instead: https://github.com/NanoForge-dev/Editor/discussions. + + This issue form is for Nanoforge Editor. + - type: dropdown + id: package + attributes: + label: Which package is this bug report for? + options: + - editor + - code + - history + - kernel + - layout + - meta + - project + - protocol + - registry + - rpc + - runtime + - sdk + - server-core + - settings + - ui + - code-editor + - command-palette + - console + - ecs + - file-manager + - git + - history-panel + - inspectors + - packages + - scene + - settings-ui + - viewport + - other + validations: + required: true + - type: textarea + id: description + attributes: + label: Issue description + description: Describe the issue in as much detail as possible. + placeholder: | + Steps to reproduce with below code sample: + 1. Do thing + 2. Observe behavior + 3. See error logs below + validations: + required: true + - type: textarea + id: code_sample + attributes: + label: Code sample + description: | + Your code sample should be: + 1. Minimal - Use as little code as possible that still produces the same problem (and is understandable) + 2. Complete - Provide all parts someone else needs to reproduce your problem + 3. Reproducible - Test the code you're about to provide to make sure it reproduces the problem + + This will be automatically formatted into code, so no need for backticks. + render: typescript + - type: textarea + id: versions + attributes: + label: Versions + description: List necessary versions here. This includes your package version, runtime version, operating system etc. + placeholder: | + - @nanoforge-dev/editor 1.0.0 (`npm ls @nanoforge-dev/editor`) + - Node.js 26 (`node --version`) + - TypeScript 5.9.3 (`npm ls typescript` if you use it) + - macOS Ventura 13.3.1 + validations: + required: true + - type: dropdown + id: priority + attributes: + label: Issue priority + description: Please be realistic. If you need to elaborate on your reasoning, please use the issue description field above. + options: + - Low (slightly annoying) + - Medium (should be fixed soon) + - High (immediate attention needed) + validations: + required: true + - type: input + id: dev_release + attributes: + label: I have tested this issue on a development release + placeholder: d23280c (commit hash) + description: | + The issue might already be fixed in a development main. This is not required, but helps us greatly. diff --git a/.github/ISSUE_TEMPLATE/02-feature_request.yml b/.github/ISSUE_TEMPLATE/02-feature_request.yml deleted file mode 100644 index bea2ce33..00000000 --- a/.github/ISSUE_TEMPLATE/02-feature_request.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Feature request -description: Request a new feature -labels: [enhancement] -body: - - type: markdown - attributes: - value: | - Thank you for submitting an idea ! - - type: textarea - id: description - attributes: - label: Feature - description: A clear and concise description of what the problem is, or what feature you want to be implemented. - placeholder: I'm always frustrated when..., A good addition would be... - validations: - required: true - - type: textarea - id: solution - attributes: - label: Ideal solution or implementation - description: A clear and concise description of what you want to happen. - validations: - required: true - - type: textarea - id: alternatives - attributes: - label: Alternative solutions or implementations - description: A clear and concise description of any alternative solutions or features you have considered. - - type: textarea - id: additional_context - attributes: - label: Other context - description: Any other context, screenshots, or file uploads that help us understand your feature request. diff --git a/.github/ISSUE_TEMPLATE/02-tooling_bug_report.yml b/.github/ISSUE_TEMPLATE/02-tooling_bug_report.yml new file mode 100644 index 00000000..9b7dbbc1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-tooling_bug_report.yml @@ -0,0 +1,56 @@ +name: Bug report on tooling +description: Report an issue with Nanoforge Editor tooling. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thank you for filing an issue! If you are here to ask a question, don't hesitate to open a discussion instead: https://github.com/NanoForge-dev/Editor/discussions. + + This issue form is for Nanoforge Editor tooling. + - type: dropdown + id: package + attributes: + label: Which tooling is this bug report for? + options: + - example-plugin + - vite-plugin + - other + validations: + required: true + - type: textarea + id: description + attributes: + label: Issue description + description: Describe the issue in as much detail as possible. + placeholder: | + Steps to reproduce with below code sample: + 1. Do thing + 2. Build your plugin + 3. Observe behavior + 4. See error logs below + validations: + required: true + - type: textarea + id: versions + attributes: + label: Versions + description: List necessary versions here. This includes your package version, runtime version, operating system etc. + placeholder: | + - @nanoforge-dev/editor-vite-plugin 1.0.0 (`npm ls @nanoforge-dev/editor-vite-plugin`) + - Node.js 26 (`node --version`) + - TypeScript 6.0.3 (`npm ls typescript` if you use it) + - macOS Ventura 13.3.1 + validations: + required: true + - type: dropdown + id: priority + attributes: + label: Issue priority + description: Please be realistic. If you need to elaborate on your reasoning, please use the issue description field above. + options: + - Low (slightly annoying) + - Medium (should be fixed soon) + - High (immediate attention needed) + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/03-feature_request.yml b/.github/ISSUE_TEMPLATE/03-feature_request.yml new file mode 100644 index 00000000..3a5aed83 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03-feature_request.yml @@ -0,0 +1,70 @@ +name: Feature request +description: Request a new feature +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Thank you for submitting an idea ! + - type: dropdown + id: application_or_package + attributes: + label: Which package or tooling is this feature request for? + options: + - editor + - code + - history + - kernel + - layout + - meta + - project + - protocol + - registry + - rpc + - runtime + - sdk + - server-core + - settings + - ui + - code-editor + - command-palette + - console + - ecs + - file-manager + - git + - history-panel + - inspectors + - packages + - scene + - settings-ui + - viewport + - example-plugin + - vite-plugin + - other + validations: + required: true + - type: textarea + id: description + attributes: + label: Feature + description: A clear and concise description of what the problem is, or what feature you want to be implemented. + placeholder: I'm always frustrated when..., A good addition would be... + validations: + required: true + - type: textarea + id: solution + attributes: + label: Ideal solution or implementation + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternative solutions or implementations + description: A clear and concise description of any alternative solutions or features you have considered. + - type: textarea + id: additional_context + attributes: + label: Other context + description: Any other context, screenshots, or file uploads that help us understand your feature request. diff --git a/.github/ISSUE_TEMPLATE/03-documentation_issue.yml b/.github/ISSUE_TEMPLATE/04-documentation_issue.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/03-documentation_issue.yml rename to .github/ISSUE_TEMPLATE/04-documentation_issue.yml diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 442c2e92..96f90b1b 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -17,10 +17,16 @@ runs: name: Install Node.js uses: actions/setup-node@v6 with: - node-version: 25 + node-version: 26 package-manager-cache: false registry-url: https://registry.npmjs.org + # The editor server build and the game runtime (tests included) run on Bun. + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + - if: inputs.pnpm-install == 'true' name: Install dependencies uses: ./.github/actions/pnpm-install diff --git a/.github/issue-labeler.yml b/.github/issue-labeler.yml index e69de29b..c315c747 100644 --- a/.github/issue-labeler.yml +++ b/.github/issue-labeler.yml @@ -0,0 +1,86 @@ +apps:editor: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\neditor\\n" + +packages:code: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\ncode\\n" + +packages:history: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nhistory\\n" + +packages:kernel: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nkernel\\n" + +packages:layout: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nlayout\\n" + +packages:meta: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nmeta\\n" + +packages:project: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nproject\\n" + +packages:protocol: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nprotocol\\n" + +packages:registry: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nregistry\\n" + +packages:rpc: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nrpc\\n" + +packages:runtime: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nruntime\\n" + +packages:sdk: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nsdk\\n" + +packages:server-core: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nserver-core\\n" + +packages:settings: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nsettings\\n" + +packages:ui: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nui\\n" + +plugins:code-editor: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\ncode-editor\\n" + +plugins:command-palette: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\ncommand-palette\\n" + +plugins:console: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nconsole\\n" + +plugins:ecs: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\necs\\n" + +plugins:file-manager: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nfile-manager\\n" + +plugins:git: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\ngit\\n" + +plugins:history-panel: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nhistory-panel\\n" + +plugins:inspectors: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\ninspectors\\n" + +plugins:packages: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\npackages\\n" + +plugins:scene: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nscene\\n" + +plugins:settings-ui: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nsettings-ui\\n" + +plugins:viewport: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nviewport\\n" + +tooling:example-plugin: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nexample-plugin\\n" + +tooling:vite-plugin: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nvite-plugin\\n" diff --git a/.github/labeler.yml b/.github/labeler.yml index 539fefc4..929ca27f 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -3,3 +3,177 @@ documentation: - any-glob-to-any-file: - docs/* - docs/**/* + +apps:editor: + - changed-files: + - any-glob-to-any-file: + - apps/editor/* + - apps/editor/**/* + +packages:code: + - changed-files: + - any-glob-to-any-file: + - packages/code/* + - packages/code/**/* + +packages:history: + - changed-files: + - any-glob-to-any-file: + - packages/history/* + - packages/history/**/* + +packages:kernel: + - changed-files: + - any-glob-to-any-file: + - packages/kernel/* + - packages/kernel/**/* + +packages:layout: + - changed-files: + - any-glob-to-any-file: + - packages/layout/* + - packages/layout/**/* + +packages:meta: + - changed-files: + - any-glob-to-any-file: + - packages/meta/* + - packages/meta/**/* + +packages:project: + - changed-files: + - any-glob-to-any-file: + - packages/project/* + - packages/project/**/* + +packages:protocol: + - changed-files: + - any-glob-to-any-file: + - packages/protocol/* + - packages/protocol/**/* + +packages:registry: + - changed-files: + - any-glob-to-any-file: + - packages/registry/* + - packages/registry/**/* + +packages:rpc: + - changed-files: + - any-glob-to-any-file: + - packages/rpc/* + - packages/rpc/**/* + +packages:runtime: + - changed-files: + - any-glob-to-any-file: + - packages/runtime/* + - packages/runtime/**/* + +packages:sdk: + - changed-files: + - any-glob-to-any-file: + - packages/sdk/* + - packages/sdk/**/* + +packages:server-core: + - changed-files: + - any-glob-to-any-file: + - packages/server-core/* + - packages/server-core/**/* + +packages:settings: + - changed-files: + - any-glob-to-any-file: + - packages/settings/* + - packages/settings/**/* + +packages:ui: + - changed-files: + - any-glob-to-any-file: + - packages/ui/* + - packages/ui/**/* + +plugins:code-editor: + - changed-files: + - any-glob-to-any-file: + - plugins/code-editor/* + - plugins/code-editor/**/* + +plugins:command-palette: + - changed-files: + - any-glob-to-any-file: + - plugins/command-palette/* + - plugins/command-palette/**/* + +plugins:console: + - changed-files: + - any-glob-to-any-file: + - plugins/console/* + - plugins/console/**/* + +plugins:ecs: + - changed-files: + - any-glob-to-any-file: + - plugins/ecs/* + - plugins/ecs/**/* + +plugins:file-manager: + - changed-files: + - any-glob-to-any-file: + - plugins/file-manager/* + - plugins/file-manager/**/* + +plugins:git: + - changed-files: + - any-glob-to-any-file: + - plugins/git/* + - plugins/git/**/* + +plugins:history-panel: + - changed-files: + - any-glob-to-any-file: + - plugins/history-panel/* + - plugins/history-panel/**/* + +plugins:inspectors: + - changed-files: + - any-glob-to-any-file: + - plugins/inspectors/* + - plugins/inspectors/**/* + +plugins:packages: + - changed-files: + - any-glob-to-any-file: + - plugins/packages/* + - plugins/packages/**/* + +plugins:scene: + - changed-files: + - any-glob-to-any-file: + - plugins/scene/* + - plugins/scene/**/* + +plugins:settings-ui: + - changed-files: + - any-glob-to-any-file: + - plugins/settings-ui/* + - plugins/settings-ui/**/* + +plugins:viewport: + - changed-files: + - any-glob-to-any-file: + - plugins/viewport/* + - plugins/viewport/**/* + +tooling:example-plugin: + - changed-files: + - any-glob-to-any-file: + - tooling/example-plugin/* + - tooling/example-plugin/**/* + +tooling:vite-plugin: + - changed-files: + - any-glob-to-any-file: + - tooling/vite-plugin/* + - tooling/vite-plugin/**/* diff --git a/.github/labels.yml b/.github/labels.yml index d771bce7..d22ddaa6 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -1,3 +1,7 @@ +- name: 'apps:editor' + description: 'Related to Editor app' + color: '5319e7' + - name: 'blocked' description: 'This is blocked' color: 'e92f1d' @@ -42,6 +46,110 @@ description: "This doesn't seem right" color: 'e4e669' +- name: 'packages:code' + description: 'Related to Code package' + color: 'aaa3dc' + +- name: 'packages:history' + description: 'Related to History package' + color: 'aaa3dc' + +- name: 'packages:kernel' + description: 'Related to Kernel package' + color: 'aaa3dc' + +- name: 'packages:layout' + description: 'Related to Layout package' + color: 'aaa3dc' + +- name: 'packages:meta' + description: 'Related to Meta package' + color: 'aaa3dc' + +- name: 'packages:project' + description: 'Related to Project package' + color: 'aaa3dc' + +- name: 'packages:protocol' + description: 'Related to Protocol package' + color: 'aaa3dc' + +- name: 'packages:registry' + description: 'Related to Registry package' + color: 'aaa3dc' + +- name: 'packages:rpc' + description: 'Related to RPC package' + color: 'aaa3dc' + +- name: 'packages:runtime' + description: 'Related to Runtime package' + color: 'aaa3dc' + +- name: 'packages:sdk' + description: 'Related to SDK package' + color: 'aaa3dc' + +- name: 'packages:server-core' + description: 'Related to Server core package' + color: 'aaa3dc' + +- name: 'packages:settings' + description: 'Related to Settings package' + color: 'aaa3dc' + +- name: 'packages:ui' + description: 'Related to UI package' + color: 'aaa3dc' + +- name: 'plugins:code-editor' + description: 'Related to Code editor plugin' + color: '1c3f78' + +- name: 'plugins:command-palette' + description: 'Related to Command palette plugin' + color: '1c3f78' + +- name: 'plugins:console' + description: 'Related to Console plugin' + color: '1c3f78' + +- name: 'plugins:ecs' + description: 'Related to ECS plugin' + color: '1c3f78' + +- name: 'plugins:file-manager' + description: 'Related to File manager plugin' + color: '1c3f78' + +- name: 'plugins:git' + description: 'Related to Git plugin' + color: '1c3f78' + +- name: 'plugins:history-panel' + description: 'Related to History panel plugin' + color: '1c3f78' + +- name: 'plugins:inspectors' + description: 'Related to Inspectors plugin' + color: '1c3f78' + +- name: 'plugins:packages' + description: 'Related to Packages plugin' + color: '1c3f78' + +- name: 'plugins:scene' + description: 'Related to Scene plugin' + color: '1c3f78' + +- name: 'plugins:settings-ui' + description: 'Related to Settings ui plugin' + color: '1c3f78' + +- name: 'plugins:viewport' + description: 'Related to Viewport plugin' + color: '1c3f78' + - name: 'question' description: 'Further information is requested' color: 'd876e3' @@ -54,6 +162,14 @@ description: 'Adding missing tests or correcting existing tests' color: '7f8f92' +- name: 'tooling:example-plugin' + description: 'Related to Example plugin tooling' + color: '606bdf' + +- name: 'tooling:vite-plugin' + description: 'Related to Vite plugin tooling' + color: '606bdf' + - name: 'wontfix' description: 'This will not be worked on' color: 'ffffff' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..de9b7c2c --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,69 @@ +name: E2E + +on: + schedule: + - cron: '0 3 * * *' + pull_request: + branches: + - main + paths: + - 'apps/**' + - 'packages/**' + - 'plugins/**' + - 'tooling/**' + - 'examples/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'turbo.json' + - '.github/workflows/e2e.yml' + - '.github/actions/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Prepare + uses: ./.github/actions/prepare + + # The tests that play a game (runtime, inspectors, scene…) need a built engine with the + # editor library; without NANOFORGE_ENGINE they are skipped. + - name: Checkout engine + uses: actions/checkout@v7 + with: + repository: NanoForge-dev/Engine + ref: main + path: engine + + - name: Install emsdk + uses: ./engine/.github/actions/emsdk-install + + - name: Install engine dependencies + uses: ./.github/actions/pnpm-install + with: + path: engine + + - name: Build engine + run: pnpm run build + working-directory: engine + + - name: Install browsers + run: pnpm --filter @nanoforge-dev/editor exec playwright install --with-deps chromium + + - name: Run end-to-end tests + run: pnpm --filter @nanoforge-dev/editor test:e2e + env: + NANOFORGE_ENGINE: ${{ github.workspace }}/engine + + - name: Upload traces + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: apps/editor/test-results diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aa53bb07..21eba6ee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,9 @@ on: - main workflow_dispatch: +permissions: + contents: read + env: PUBLIC_MODE: ${{ vars.PUBLIC_MODE }} PUBLIC_PM_URL: ${{ vars.PUBLIC_PM_URL }} @@ -28,5 +31,11 @@ jobs: - name: Run linter run: pnpm lint + - name: Run type checks + run: pnpm typecheck + + - name: Install browsers for component tests + run: pnpm --filter @nanoforge-dev/editor-ui exec playwright install --with-deps chromium + - name: Run tests run: pnpm test:unit diff --git a/.gitignore b/.gitignore index f984847d..aad4424e 100644 --- a/.gitignore +++ b/.gitignore @@ -231,3 +231,25 @@ dist test-results/ public/assets/fonts/ + +### Turborepo +.turbo + +# Spike fixture copy (phase 1.1) +apps/editor/static/__spike/ + +# Temporary fixture copies of tests +packages/server-core/test/.tmp-* + +# e2e workspace and reports +apps/editor/e2e/.workspace/ +apps/editor/e2e/.workspace-*/ +apps/editor/test-results/ +apps/editor/playwright-report/ + +# SvelteKit client build (bundled into dist/ by the build script) +apps/editor/build/ + +# The example editor's data (pnpm example) +examples/.data/ +examples/node_modules diff --git a/.husky/pre-push b/.husky/pre-push index 64e3d4e1..d5cc1185 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,3 +1,3 @@ pnpm lint -pnpm build +pnpm typecheck pnpm test:unit diff --git a/.nvmrc b/.nvmrc index a682cfb9..4aa0e0a7 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v25 +v26 diff --git a/.prettierignore b/.prettierignore index 9976d76a..bae5a534 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,21 +1,26 @@ .idea/ .vscode/ +.turbo/ -.svelte-kit/ -dist/ +**/.svelte-kit/ +**/dist/ +**/build/ node_modules/ pnpm-lock.yaml pnpm-workspace.yaml -CHANGELOG.md +apps/ +packages/ +plugins/ +tooling/ -coverage/ -test-results/ +**/CHANGELOG.md -project.inlang/cache/ -project.inlang/.gitignore -project.inlang/.meta.json -project.inlang/README.md +**/coverage/ +**/test-results/ -src/lib/paraglide/ +**/test/fixtures/ + +# Written by the editor in a project it opens (the example). +**/.nanoforge/ diff --git a/CHANGELOG-v2.md b/CHANGELOG-v2.md new file mode 100644 index 00000000..4fecedf7 --- /dev/null +++ b/CHANGELOG-v2.md @@ -0,0 +1,126 @@ +# NanoForge Editor 1.0.0 + +`@nanoforge-dev/editor@1.0.0` and `@nanoforge-dev/editor-sdk@1.0.0`. + +The editor was rewritten from scratch. It keeps what the previous editor did (edit a NanoForge game in the browser, hosted or with `nf editor`) on a new base: everything visible is a plugin, the game's code is the only source of truth, and every change can be undone. + +This file describes the release. Nothing is published yet: the package versions, the tag and the publication are the release steps still to do. + +## What changes for you + +### The game is its code + +- The editor no longer keeps a save file of the scene (`.nanoforge/*.save.json`). Entities, components and systems are read from the app's `main.ts`, and written back to it when you change them in the panels. +- Code the editor cannot model (loops, helpers, conditions) is left untouched and shown read-only, with a link to its lines. +- Editing the code by hand and editing in the panels are the same thing: each follows the other. + +### A workbench you arrange + +- Four screens (Project, Scene, Script, Game) and panels in five docks. Drag tabs between docks, float them, close them, save layouts by name. +- A command palette (`Ctrl+Shift+P`) that also opens files (`Ctrl+P`), goes to a line (`Ctrl+G`) and to a symbol (`Ctrl+Shift+O`). +- Keyboard shortcuts you can change, with chords, conflict warnings, and the presets _VS Code_ and _Godot_. +- Light and dark themes. + +### Undo everywhere + +- Each area has its own history: each file, the Files panel, the layout, plugins. `Ctrl+Z` acts on what has focus. +- A History panel lists the steps, shows what each one changed, and jumps to any of them. +- File moves, renames and deletions can be undone. So can changes made from the panels to your code. + +### Files + +- A tree and a grid with image thumbnails; create from templates, rename, move, copy, duplicate, import by drop, download a folder as a `.zip`. +- `.nfignore` hides files from the whole editor. + +### Code editor + +- Monaco with the project's and the engine's types, go to definition into engine packages, rename, and errors for every file of the project in a Problems panel. +- Tabs and a split view. Unsaved changes survive a reload. +- Formatting with the project's own Prettier; save on delay or on focus loss; format and organize imports on save. +- A banner when a file with unsaved changes was modified on disk, with a side-by-side comparison. + +### Entities, components and systems + +- A Hierarchy and an Inspector per app (client, server), and a 2D Scene screen where entities are selected and dragged. +- A Components panel listing what an app can use: its own, shared libraries, installed packages. Create components and systems from templates. +- **Shared libraries**: code used by both the client and the server, imported by package name; move a component into one with its imports rewritten. +- The **Project screen** manages the project: add, rename and remove client apps, server apps and shared libraries, and choose which apps use each library. +- The author of a component chooses how it shows in the inspector (groups, presets such as vectors and colors, labels, hidden parameters) with documentation tags, or with the Component panel beside the code. +- Systems of an app listed in run order, reordered by drag. + +### Play + +- Play, pause, step, restart and stop, for the client, the server or both; a second client in another window. +- A Game screen with resolutions and device presets, zoom, a stats overlay, mute, screenshots, and a pop-out window. +- **Live mode**: while the game runs, the Hierarchy and the Inspector show the running world. Change values, add components, spawn entities, switch systems off; _Apply to code_ writes what you want to keep. Move entities by dragging them in the Scene screen, or over the running game with **Move entities** in the Game screen. +- A Console for the game, builds and the editor: logged objects expand, file paths are links, and game lines show where they were logged, through source maps. +- A Profiler (tick time, time per library and per system), a Network panel (packets, decoded payloads, throughput) and a World panel (the running entities, with a search). + +### Git + +- A VCS menu, a Commit panel (changes, unversioned files, rollback, amend, stash) and a Git panel (branches, commits, changed files), after IntelliJ's. +- Push with a preview of the commits, update, fetch, branches; file status marks in the Files panel; the branch in the status bar. +- Local editors only. + +### Settings + +- One dialog with search. Each setting can be saved to your account, this machine, the project (committed) or the project for you only. +- Import and export; the project's settings as a JSON file with completion. +- Account settings sync between devices once the API offers it (see below). + +### Plugins and packages + +- **Plugins** extend the editor. Install them from _Settings › Plugins_, for yourself or for a project. +- **Packages** bring components, systems and assets to a game. Install them from _File › Packages…_; they go to `nf_modules`, with a list and a lock file in the project. +- Both read a registry that the API does not provide yet: until then the marketplace says it is not available (a folder can stand in for it). + +### Scenes + +For games built on the engine's scene module (`@nanoforge-dev/scene`, engine branch `feat/scene`), a new built-in plugin: + +- the **Scenes** panel: the scene tree by parent, new, rename, move by drag and drop, set as initial, delete; selecting a scene makes the Hierarchy, Inspector, Systems and Scene screen edit its `setup`, with its parents' entities and systems read-only; +- the **Vars** panel: the scene vars declared in `scene-vars.ts`, their uses and the scenes that make them; add, rename (every key follows), edit, remove, declare a key used in code; +- while playing: the loaded scenes, _Load scene_ (with its params) and _Unload scene_, live var values, and the live entities grouped by scene. + +The `examples/breakout` project is a game made of scenes (`pnpm example breakout`). + +## For plugin authors + +- The ECS plugin's `ecs.sources` extension point: what its widgets edit for an app (a file and the function holding the spawns), and read-only locations shown with it. +- `DocumentService.save(uri)`, and `PromptOptions.optional`. + +- `@nanoforge-dev/editor-sdk` is the only package a plugin imports: services, commands, settings, history, code analysis, the runtime, and the editor's own components and theme. +- `nf create plugin --name @scope/name` writes a working plugin; `DEV_PLUGINS` loads it in the editor with hot reload. +- Extension points for panels, screens, menus, shortcuts, the status bar and toolbar, settings pages, themes, file templates, file actions, document editors, code generation targets, scene editors, game overlays, and inspector fields. +- A plugin can run code in the code worker (analyzers, transformers, item owners) and in the editor's server. +- The guide is in `docs/docs/plugins`, and a reference plugin in `examples/counter-plugin`. + +## Breaking changes + +- **Projects saved by the previous editor** as `.nanoforge/*.save.json` are not read. Their entities must be in `main.ts`. No importer is provided. +- **The previous editor's plugins and settings** do not carry over. +- **Engine**: play controls, stats, live mode and the inspectors need NanoForge engine 2.0 or later, and the game registers its editor library (`app.use(new EditorLibrary())`, from `@nanoforge-dev/editor-lib`). The editor offers to add it. A game without it still plays, without them. +- **`nf editor `** opens a project directly; without a path it lists recent projects. +- **Hosted editors** never build or run project code, and do not install plugins or packages. + +## Operating a hosted editor + +- `API_FEATURES` lists what the API offers (`settings`, `registry`). It is empty by default: account settings then stay on the editor's disk, and no registry is called. +- A hosted editor needs a persistent data volume (settings, and per-user project settings). +- What the editor asks of the API is in `docs/api`, with contract tests the API team can run. + +## Performance + +- The editor's page script went from 7 MB to 65 KB: the TypeScript compiler now loads only in the code worker. +- The code worker is ready about half a second after it is first needed on the pong-network example. +- Plugins load when first used; the code editor (Monaco) only when a file is opened. + +Measurements and how to reproduce them: `docs/performance.md`. + +## Known limits + +- The registry of plugins and packages, account settings sync, and the list of plugins following you between machines wait for the API (`docs/api`). +- Dragging entities inside the Game screen is not built (drag them in the Scene screen). +- The Git graph is a straight line per branch; no rebase, tags, cherry-pick or staging of part of a file. +- No debugger or breakpoints. +- The SDK and the plugin build tool are not on npm yet: plugin projects link them from the editor repository. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e98e5790 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,285 @@ +# NanoForge Editor v2 — notes for agents + +The NanoForge editor, rewritten from scratch. The full plan, with status per phase, is `docs/rewrite-plan.md`. Specs per plugin are in `docs/specs/`, design decisions in `docs/adr/`. Read the plan section of the phase you work on before touching code. + +## Working rules + +- **Where to work.** + - This is the worktree `G-EIP-600/editor-v2`. Its branch changes with the work (`git branch --show-current`): start a branch of your own for new work rather than committing on the user's. + - Never commit on the user's own branches or checkouts (`G-EIP-600/editor`, `engine`, `loader`, `cli`): use a worktree. + - Never push or publish. That is the user's call. +- **Phases.** + - The user advances phases explicitly ("move to phase N"). Don't start the next phase on your own. + - Plugins (phases 9–11) always start with a **requirements session**: ask the user, write `docs/specs/.md`, then build. + - When a phase item is done, mark it ✅ in `docs/rewrite-plan.md` with a short summary of what was built. +- **Open questions** (see "Open design questions" below): + - the `nf_modules` dependency model (install, imports, collisions: see ADR 0003's open questions). + + Ask the user before building anything that depends on them. + +- **Repository standards follow the engine** (`../engine`, `origin/main`): + - `package.json` fields in the engine's order (`$schema`, `name`, `version`, `description`, `keywords`, `homepage`, `bugs`, `license`, `contributors`, `files`, `main`, `module`, `types`, `exports`, `type`, `directories`, `repository`, `funding`, `scripts`, `dependencies`, `peerDependencies`, `devDependencies`, `packageManager`, `engines`, `private`, `publishConfig`, `lint-staged`), dependencies sorted, Node 26 and pnpm 12; + - every workspace has the engine's `README.md` (badges, About, Links, Contributing, Help), `LICENSE` and `.prettierignore`; published ones (`apps/editor`, `packages/sdk`, `tooling/vite-plugin`) also `CHANGELOG.md`, `cliff.toml` and `.cliff-jumperrc.json`; + - each workspace has a `:` label (`.github/labels.yml`, `labeler.yml`, `issue-labeler.yml`) and a choice in the issue forms. A new workspace gets all of them; + - the editor keeps single quotes (its Prettier override of the engine config). +- **Commits.** + - Conventional commits, with the package or plugin name as scope: `feat(settings-ui): …`, `fix(ui): …`. + - Commitlint rejects sentence case, and a subject starting with an acronym such as "JSON" counts as sentence case: write "json". + - End the message with the `Co-Authored-By` line. + - Split work into logical commits: core/SDK changes first, then the plugin. + - Husky runs Prettier and ESLint on staged files. +- **Shell.** + - `cp` is aliased interactive: use `command cp -f`. + - Never `pkill -f` (it matches and kills its own shell): kill by PID. + - zsh doesn't word-split `$var`: use `${=var}` in loops. + - The shell cwd resets after each command: `cd` inside each command. +- **Ports.** + - 5174 is the user's own dev server: don't touch it. Use `EDITOR_API_PORT` for another dev server. + - The e2e tests use port 4790. + +## Commands + +```sh +pnpm install +pnpm turbo build --filter=@nanoforge-dev/editor... # editor + every package and plugin it bundles +pnpm lint # prettier --check . + eslint in every package +pnpm typecheck +CHROME=/usr/bin/google-chrome-stable pnpm test # unit + Vitest browser tests (packages/ui needs CHROME) +pnpm dev # editor + built-in plugins in watch mode (hot reload) + +# E2E: against the production build. Build first, and rebuild after every change. +cd apps/editor +CHROME=/usr/bin/google-chrome-stable \ +NANOFORGE_ENGINE=/home/exelo/workspace/epitech/G6/G-EIP-600/engine \ +NANOFORGE_CLI=/home/exelo/workspace/epitech/G6/G-EIP-600/cli \ +npx playwright test [spec…] --reporter=line +``` + +- **Run e2e with `E2E_WORKSPACE=.workspace-agent`** (`.workspace` or `.workspace-`: anything else is refused, since the folder is wiped). The default `e2e/.workspace` is wiped at each run, and the user's dev server (`FS_ROOT=apps/editor`) may have its projects open. Specs take their paths from `e2e/workspace.ts`. +- Without `NANOFORGE_ENGINE`, the tests that play the game (runtime, the viewport's "while playing", some code-editor tests) are **skipped**, not failed. Check the skipped count. +- `NANOFORGE_CLI` (a built CLI checkout) is linked into the played game as its own CLI. Without it, the console test skips its source-map check (the game's published CLI writes no source maps). +- Contract tests (`test/contract` in `server-core` and `registry`) run in `pnpm test` against the stand-ins, and against a real API with `CONTRACT_*` variables (`docs/api/README.md`). Never run them against a real API yourself. +- `ANALYZE=1 npx vite build` (in `apps/editor`) prints the bundle analysis; `BENCH=1 npx playwright test` runs the startup measurements of `e2e/*.bench.ts` instead of the tests (`docs/performance.md`). +- `E2E_EXTRA_PLUGINS=` adds a dev plugin to the e2e editor. +- Unit tests fail on an unexpected `console.error` (`vitest.setup.ts`): a test that expects one spies on it (`vi.spyOn(console, 'error').mockImplementation(() => {})`). +- SDK public API snapshot: after adding SDK exports, run `cd packages/sdk && npx vitest run -u`, and check that the diff shows only what you added. +- E2E specs import `test` and `expect` from `./test` (`e2e/test.ts`), not from `@playwright/test`: its fixture fails a test when a page throws an uncaught error. Known noise goes in its `IGNORED` list. +- E2E specs open projects with `openProject(page, path)` (`e2e/helpers.ts`): `/load?path=` asks before opening a folder that is not an exact recent path (a relative path always asks), and the helper answers **Trust and open**. Server and runtime unit tests open projects with `openTrustedProject` (`@nanoforge-dev/editor-server-core/testing`): a fresh data folder trusts nothing. +- The editor server answers only to loopback host names and `ALLOWED_HOSTS` (DNS rebinding guard): a dev server reached by another name needs it. +- To debug a failed e2e test, read `test-results//error-context.md`. For a screenshot, unzip `trace.zip` and open the last frame in `screencast/`. +- The CLI repo needs `pnpm --filter @nanoforge-dev/config build` before `pnpm build`. + +## Architecture in one screen + +- **Monorepo:** pnpm + turbo. The app is a SvelteKit static SPA plus a Bun server, in Svelte 5 with runes only. Schemas use zod v4. +- `apps/editor`: + - the shell (routes `/`, `/load?path=|gatewayId=`, `/project/[id]`, `/play`); + - the server (`scripts/build-server.ts` bundles `dist/`, including `dist/plugins`); + - the e2e tests. +- **`packages/*`** are source-only (`exports` → `src/index.ts`). Only `sdk` is really built. + - `kernel`: DI (`createToken`, `Container`), `Disposable`, `Emitter`, `ObservableValue`, extension points, commands, context keys / `when`, plugin and package manifest schemas, resolver, `PluginHost` (`PluginHostToken`; `update()` swaps the plugin set when a project opens or closes). + - `sdk`: **the only package plugins import**: `@nanoforge-dev/editor-sdk`, plus `@nanoforge-dev/editor-sdk/ui`. + - `settings`: scopes default < account < machine < project < projectLocal; `AccountSyncStore` (`AccountSyncToken`). + - `history`: per-context stacks, `goTo`, serialization, IndexedDB persistence of `file:*` contexts. + - `layout`: a pure layout model and its operations. + - `ui`: the kit (bits-ui), theme tokens, workbench renderer, extension points. + - `code`: code worker, `DocumentService`, `JSON_SCHEMAS`. + - `runtime`: build, client runner, server runner. + - `protocol`: the engine bridge protocol, and the RPC contracts between the editor and its server. + - `rpc`, `project`, `server-core` (fs, `.nfignore`, `nf_modules` read-only, plugin sources and package suggestions, git, cli, registry contract). + - `registry` (`@nanoforge-dev/registry`, **not** an `editor-*` package): registry client, folder registry, installer, the project's packages list and lock. It is meant to move to the CLI repository: ESLint keeps `@nanoforge-dev/editor-*` imports out of it. Its HTTP side follows the npm registry's routes (`src/wire/`, contract in `docs/api/registry.md`). Protocol imports only its `/types` (zod schemas, no Node code). +- **`plugins/*`** are the built-in plugins, each with a `nanoforge.manifest.json` of `type: "plugin"`: + - `code-editor` (Monaco) + - `file-manager` + - `viewport` (Game and Scene screens) + - `history-panel` + - `settings-ui` + - `ecs` + - `console` (Console and Problems panels) + - `command-palette` (the palette, and the Keyboard shortcuts page of Settings) + - `inspectors` (Profiler, Network and World panels) + - `git` (the VCS menu, the Commit and Git panels after IntelliJ's, status bar branch, file status marks) + - `packages` (the Packages dialog, _File › Packages…_; the plugin marketplace is in `settings-ui`) + - `scene` (the Scenes and Vars panels, for apps with `@nanoforge-dev/scene`; spec `docs/specs/scene.md`). It plugs into the ECS widgets through `ecs.sources`: they edit the selected scene's `setup` instead of `main` +- **`tooling/`**: + - `vite-plugin`: builds plugins; + - `example-plugin`: `@acme/hello`, the dev plugin loaded by the e2e tests. +- **`examples/breakout`**: a client-only game made of scenes (`@nanoforge-dev/scene`, engine `modules/scene`), the default of `pnpm example` (`scripts/example.mjs`): it links the engine libraries from `NANOFORGE_ENGINE` (default `../engine`, shared with the e2e setup through `engineRoot`) and starts the built editor on port 4800 with `FS_ROOT=examples`. Its entities and systems all belong to scenes (`GameScene`), so the Scene screen and Hierarchy show none: use Play and the World panel. +- **`examples/counter-plugin`**: a standalone plugin, outside the workspace, as `nf create plugin` writes it (the CLI command is in `../cli`). +- **`docs/docs`**: the user documentation and the plugin authoring guide (Mintlify pages, copied to the docs site). + +### Manifests: packages and plugins (ADR 0003) + +Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/name`. There are two types, and they never mix: a package never holds a plugin. + +- **`type: "package"`** (schema: `PackageManifestSchema` in kernel; full draft in `docs/drafts/item-schema.ts`): + - installed in `/nf_modules/@scope/name`, read-only; + - `contents`: what it holds, any folder layout: `{ kind, path, export?, … }`, kinds core (`asset`, `type`…) or plugin-owned (`@nanoforge/ecs:component`). Code files it points to are item sources (components, systems…), documented with TSDoc; `items` (source files) is the older form, still read; + - `include`: sub-manifests, folders (their manifest) or `.json` files, that **only describe contents** (no name, version, dependencies nor suggested plugins). Walk them with `readPackageContents` (`@nanoforge-dev/registry/types`), never scan folders. Includes stay inside the package (no `..`), 4 levels and 100 sub-manifests max; + - `suggestedPlugins`: `["@scope/name"]` or `{ "@scope/name": "^1.0.0" }`. When a project opens, the editor names the missing ones in a notification (`plugins.suggestions`); + - `libDependencies` (engine libs), `dependencies` (other packages), `npmDependencies`. +- **`type: "plugin"`** (schema: `PluginManifestSchema`, JSON schema `packages/kernel/schemas/plugin.schema.json`, regenerate with `pnpm --filter @nanoforge-dev/editor-kernel generate:schema`). Plugin sources, later ones shadowing earlier ones with the same name: + + | Source | Where | Loaded | + | ----------- | ------------------------------------------------------------------------- | -------------------------- | + | `bundled` | `apps/editor/dist/plugins/@scope/name` (built-in plugins) | always | + | `installed` | `~/.nanoforge/editor/plugins/@scope/name` | in every project | + | `project` | `/.nanoforge/plugins/@scope/name`, URL `/plugins/project//…` | while that project is open | + | `dev` | local folders (`/dist`), hot reloaded | always | + + A project plugin's server entry only runs in a local editor, never in a hosted one. + +- **Items** (components, systems…) never have a lib-specific type. Lib data goes in owner objects keyed by the owner plugin's name: `"@nanoforge/ecs": { "type": "component" }`. Only that plugin parses it. Core formats (manifest, item, element) must stay lib-agnostic. +- **Shared libraries** (ADR 0004): `libs/` workspace members of `type: "lib"` (no manifest, never published) hold components and systems shared by client and server; side is per item. Apps import them by package name (`@pong-network/shared/components/player`) through the root `tsconfig.json` `paths`; installed packages the same way. An app **uses** a library when its `nanoforge.config` `libs` lists it (paths relative to the app, the CLI's field; `AppModel.libraries`). Every change also writes the app's `package.json` dependency; when they disagree `libs` wins and the project shows a warning. A library nobody mentions goes to every app. `libs` are never engine libraries (those come from `package.json`), and is only offered the items of the libraries it uses. The Project screen manages apps and libraries through `packages/project/src/workspace/` (add, rename, remove, "Used by"; one undo step each, history context `project`). In the UI say **Shared library**, **Installed package** and **Engine library**, never a bare "lib". +- **Meta** of items is generated by the editor into `.nanoforge/editor/cache/meta/`, never published, never written next to sources. (Planned for phase 10.3.) +- The CLI (`nf install`, `nf publish`) still uses `scope/name` and the old types: ignore it until the user asks. +- The registry's contract is the API's (`../api/docs/registry.md`, summed up in `docs/api/registry.md`): npm's packument with `dist.tarball`, versions with `contents`/`kinds`/`manifests`, multipart publish. + +### Plugin mechanics (ADR 0001) + +- **Build.** Plugins are built with `tooling/vite-plugin`: `base: './'`, and shared modules (the Svelte runtime and the SDK) are rewritten to `globalThis.__nanoforge_editor_shared__.require(...)`. Plugins **must be in runes mode**. +- **CSS.** CSS is extracted to `.css` (`build.lib.cssFileName`). Load it in `activate()` with `context.resolveAsset(...)` + `StyleServiceToken.inject(context.name, css)`. +- **Settings keys** from the manifest are prefixed with the plugin name, e.g. `@nanoforge/viewport.maximizeOnPlay`. +- **Adding a built-in plugin:** + 1. copy the layout of an existing plugin (`package.json` with `build`/`dev`/`lint`/`typecheck`, `vite.config.ts`, `svelte.config.js`, `tsconfig.json`, manifest, `README.md`, `LICENSE`, `.prettierignore`); + 2. add it as a devDependency of `apps/editor` (it is then bundled and run as a dev plugin); + 3. its name is accepted as a commit scope automatically. +- **Plugins only import the SDK** (ESLint enforces it). If a plugin needs something from core, export it from the SDK: add a token in the owning package, provide it in `apps/editor/src/lib/editor/create-editor.ts`, re-export it, and update the snapshot. +- **Dialogs from a plugin:** mount a Svelte component with `mount`/`unmount` from a command (see `plugins/settings-ui/src/index.ts`). + +### Folder trust (local editors) + +- A local project's code runs only in a **trusted folder** (`TrustStore`, `DATA_DIR/trusted-folders.json`, by real path; seeded with the recent projects when first created). Trusted: `nanoforge.config.ts` is executed, project plugins load and their server entries run, builds, games, dependency installs, the project's Prettier and its git hooks run. +- An untrusted folder opens in **restricted mode**: the config is read statically (as in a hosted editor), project plugins stay out, Play explains why, git runs with the hosted hardening, formatting uses the bundled Prettier. A sticky notification and `project.trust` offer **Trust this folder** (`projects.trust`, then the project reopens). +- Server gates go through `ProjectRegistry.assertTrusted(project, what)`; `OpenProject.trusted` and `ClientProject.trusted` carry the state. A new code path that runs project code needs the gate. +- `plugins.list` never runs anything: the client calls `plugins.runProjectServers` with the project plugins that loaded. +- The editor shell is served with `frame-ancestors 'self'` / `X-Frame-Options: SAMEORIGIN`, so another site cannot frame it and get a click on **Trust and open**. + +### Engine bridge (protocol v1) + +- **The game registers it** (engine 2.0 and later): `app.use(new EditorLibrary())` from `@nanoforge-dev/editor-lib` (engine `modules/editor`). The engine's own examples don't: the e2e `game` copy and `pnpm example` link the library from `NANOFORGE_ENGINE` and add it (`scripts/engine-bridge.mjs`). A game without it plays with no bridge, and the editor offers **Add the editor library** (`addEditorLibrary` in `packages/project/src/workspace/editor-library.ts`). +- **The hello comes in the game's first tick**, after its `main` returned: the runtime waits for it a little (`helloTimeoutMs`, 1.5 s) before calling a game bridgeless, and re-checks on a late hello. The runtime tests' fake game (`server-core/test/fixtures/runtime/bridge-game.js`) greets the same way. +- **The engine sends `ecs-world` only when the world changed**: `RuntimeService.lastWorlds()` keeps the last one per game, for views that start listening while the game is paused. +- **Handshake:** `hello` → `welcome`, with features `frameStats`, `logs` (`{ values: true }` adds the logged values and the call site to `log` events), `networkTrace`, `ecsWorld` and `viewport` (the client's `ViewportState`: how game units map to its container, for drawing over the game). +- **Messages:** + - commands: `pause`, `resume`, `step`, `stop`, `mute`; + - event: `state`. +- **Events plugins read** (`RuntimeService.onEvent`), each sent only when its feature is asked for with `useFeatures`: `frame-stats`, `ecs-world`, `ecs-system-stats` (`ecsSystemStats`), `network-trace` and `network-stats` (`networkTrace`, with `maxBytes` for payload bytes), `viewport`. A new feature also goes in `mergeFeatures`, or it is silently dropped. Ask while a view is on screen (`WidgetInstance.visible`: a tab shown once stays mounted while hidden), not from activation: `mergeFeatures` takes the most demanding request. +- The engine's `EditorBridge` (`modules/editor/src/internal/editor-bridge.ts`) buffers the game's console output until `welcome`. The runtime service checks compatibility (legacy, older or newer engine). +- **Logs:** everything shown in the Console goes through `LoggerService`. The source name gives the group: `game:client`, `game:server`, `build:`, `cli:`, a plugin's name, anything else is the editor. Logged values use the `LogValue` tree, made by kernel `serializeLogValue` only: the client runner hands it to the game (`RunOptions.editor.serializeLogValue`), and the engine calls it when a line is logged. The engine has no serializer of its own. +- **Moving entities in the Game screen** (ECS plugin, `GameMoveOverlay.svelte`): the _Move entities_ tool (`ecs.moveInGame`) draws the scene shapes of the client's live entities over the game with the `viewport` mapping, and a drag is a live edit (`live.setField`), like the Scene's. graphics-2d's old drag (`DrawableCircle2D`… emitting `move-component`) was removed from the engine (`11f4c98`, breaking). +- **Engine, loader and CLI:** the editor bridge (engine #445), the scene module (engine #447), the loader's editor IPC (loader #113) and `nf editor [path] --port` / `nf create plugin` (CLI #241, #242) are merged in their `main`. Use built checkouts of `../engine` and `../cli`. + +## Conventions that matter + +- **Source layout (ADR 0006, after the engine and the CLI):** + - `src/` holds `index.ts` and kebab-case feature folders, each with its own `index.ts` in packages (none in plugins); + - files are named after their main export, with kind suffixes (`.type.ts`, `.enum.ts`, `.exception.ts`, `.const.ts`), and `..ts` for implementations (`memory.scope-store.ts`); + - tests go in `test/` mirroring `src/`, as `*.spec.ts`, with shared helpers in `test/fixtures/`; + - Svelte files are kebab-case when an `index.ts` re-exports them, PascalCase otherwise; + - split a file past about 300 lines (a Svelte file's ` diff --git a/apps/editor/src/lib/editor/create-editor.ts b/apps/editor/src/lib/editor/create-editor.ts new file mode 100644 index 00000000..52e28dfc --- /dev/null +++ b/apps/editor/src/lib/editor/create-editor.ts @@ -0,0 +1,301 @@ +import { version as svelteVersion } from 'svelte/package.json'; + +import { + HistoryService, + HistoryServiceToken, + registerHistoryCommands, +} from '@nanoforge-dev/editor-history'; +import { + type Container, + CoreServices, + type Disposable, + DisposableStore, + LogLevel, + type Logger, + type Observable, + PluginHost, + PluginHostToken, + consoleSink, + createCoreContainer, + switchObservable, +} from '@nanoforge-dev/editor-kernel'; +import { ProjectService, ProjectServiceToken } from '@nanoforge-dev/editor-project'; +import { PluginsContract, SessionContract, type SessionInfo } from '@nanoforge-dev/editor-protocol'; +import { RpcClient, RpcClientToken } from '@nanoforge-dev/editor-rpc'; +import { RuntimeSettings } from '@nanoforge-dev/editor-runtime'; +import { version as sdkVersion } from '@nanoforge-dev/editor-sdk/package.json'; +import { + AccountSyncToken, + CoreSettings, + REQUIRED_PLUGINS, + type SettingsService, + followCurrentProject, + settingsContributionHandler, + setupSettings, +} from '@nanoforge-dev/editor-settings'; +import { + KeybindingServiceToken, + LAYOUT_HISTORY_CONTEXT, + NotificationServiceToken, + PromptServiceToken, + type Shell, + StyleServiceToken, + ThemeServiceToken, + setupShell, +} from '@nanoforge-dev/editor-ui'; + +import { installSharedModules } from '$lib/plugin/shared-modules'; +import { registerCorePanels } from '$lib/project/register-core-panels'; + +import { followProjectCode } from '../code/follow-project-code'; +import { followProjectPlugins, toDescriptor } from '../plugin/follow-project-plugins'; +import { followProjectTrust } from '../project/follow-project-trust'; +import { followProjectRuntime } from '../runtime/follow-project-runtime'; +import { WorkspaceActions, WorkspaceActionsToken } from '../workspace/workspace-actions'; +import { registerAppContributions } from './register-app-contributions'; + +/** How long startup waits for the editor server before saying it does not answer. */ +const STARTUP_TIMEOUT_MS = 20_000; + +export interface Editor extends Disposable { + readonly services: Container; + readonly rpc: RpcClient; + readonly plugins: PluginHost; + readonly projects: ProjectService; + readonly settings: SettingsService; + readonly history: HistoryService; + readonly shell: Shell; + /** Id of the project whose settings scopes are attached. */ + readonly projectSettings: Observable; + readonly session: SessionInfo; + readonly logger: Logger; +} + +/** + * Boots the editor in the browser: shared modules for plugins, the kernel container with the + * core client services, then the plugin host. + */ +export const createEditor = async ( + onProgress: (step: string, done: number, total: number, description?: string) => void = () => + undefined, +): Promise => { + const TOTAL = 4; + onProgress( + 'Connecting to the editor server', + 0, + TOTAL, + 'Checking your session with the editor server.', + ); + installSharedModules(); + const store = new DisposableStore(); + const services = store.add(createCoreContainer()); + const logs = services.get(CoreServices.Logger); + logs.level = LogLevel.Debug; + store.add(logs.addSink((entry) => entry.level >= LogLevel.Info && consoleSink(entry))); + const logger = logs.getLogger('editor'); + + const rpc = store.add(new RpcClient({ baseUrl: location.origin })); + const projects = store.add(new ProjectService(rpc)); + services.provide(RpcClientToken, rpc); + services.provide(ProjectServiceToken, projects); + services.provide(CoreServices.EngineLibs, { + apps: switchObservable( + { + get: () => projects.current.get()?.apps, + subscribe: (run) => projects.current.subscribe((project) => run(project?.apps)), + }, + [], + ), + }); + + const session = await rpc.api(SessionContract).info(null, { timeoutMs: STARTUP_TIMEOUT_MS }); + onProgress( + 'Loading settings', + 1, + TOTAL, + session.mode === 'ONLINE' + ? 'Reading your preferences and syncing your NanoForge account settings.' + : 'Reading your editor preferences.', + ); + const settingsSetup = store.add( + await setupSettings({ + services, + rpc, + mode: session.mode, + userId: session.user?.id ?? 'anonymous', + }), + ); + store.add(settingsSetup.registry.register(...Object.values(RuntimeSettings))); + const projectSettings = store.add( + followCurrentProject(settingsSetup, projects.current, (error, project) => + logger.error( + `The settings of project ${project.id} could not be read: defaults apply`, + error, + ), + ), + ); + const { settings } = settingsSetup; + services.provide(AccountSyncToken, settingsSetup.account); + + const history = store.add( + new HistoryService({ + limit: settings.observe(CoreSettings.historyLimit), + mergeWindowMs: settings.observe(CoreSettings.historyMergeWindow), + localOrigin: { kind: 'user', id: session.user?.id ?? 'anonymous' }, + }), + ); + services.provide(HistoryServiceToken, history); + store.add( + registerHistoryCommands( + services.get(CoreServices.Commands), + services.get(CoreServices.ContextKeys), + history, + { fallbackContext: LAYOUT_HISTORY_CONTEXT }, + ), + ); + + onProgress('Loading plugins', 2, TOTAL, 'Finding built-in, installed and local plugins.'); + const listings = await rpc.api(PluginsContract).list(null, { timeoutMs: STARTUP_TIMEOUT_MS }); + const plugins = store.add( + new PluginHost({ + services, + editorVersion: __EDITOR_VERSION__, + runtimeVersions: { svelte: svelteVersion, sdk: sdkVersion }, + disabled: new Set( + settings + .get(CoreSettings.pluginsDisabled) + .filter((name) => !REQUIRED_PLUGINS.includes(name)), + ), + }), + ); + store.add( + plugins.registerContributionHandler(settingsContributionHandler(settingsSetup.registry)), + ); + services.provide(PluginHostToken, plugins); + + const shell = store.add( + setupShell({ + extensions: services.get(CoreServices.Extensions), + commands: services.get(CoreServices.Commands), + contextKeys: services.get(CoreServices.ContextKeys), + settings, + logger: logs.getLogger('ui'), + plugins, + }), + ); + services.provide(NotificationServiceToken, shell.notifications); + services.provide(PromptServiceToken, shell.prompts); + services.provide(KeybindingServiceToken, shell.keybindings); + services.provide(StyleServiceToken, shell.styles); + services.provide(ThemeServiceToken, shell.themes); + store.add(registerCorePanels(services.get(CoreServices.Extensions))); + const code = store.add( + followProjectCode({ + services, + projects, + rpc, + plugins, + notifications: shell.notifications, + history, + logger: logs.getLogger('editor'), + }), + ); + store.add( + followProjectRuntime({ + services, + projects, + rpc, + settings, + logs, + notifications: shell.notifications, + enabled: session.mode === 'OFFLINE', + }), + ); + const workspace = store.add( + new WorkspaceActions({ + services, + projects, + rpc, + history, + notifications: shell.notifications, + prompts: shell.prompts, + local: session.mode === 'OFFLINE', + }), + ); + services.provide(WorkspaceActionsToken, workspace); + store.add(registerAppContributions(services, session)); + store.add( + history.onDidInvalidate(({ contextId, reason }) => { + if (reason === 'external-change') { + shell.notifications.notify( + 'warning', + `Undo history of ${history.get(contextId)?.label ?? contextId} was cleared`, + { + detail: 'The file changed outside of the editor.', + }, + ); + } + }), + ); + + onProgress( + 'Starting plugins', + 3, + TOTAL, + listings.length + ? `Checking and activating ${listings.length} plugin${listings.length > 1 ? 's' : ''}.` + : 'No plugin to activate.', + ); + await plugins.start(listings.flatMap((listing) => toDescriptor(listing, logger) ?? [])); + + store.add( + followProjectPlugins({ + rpc, + projects, + plugins, + notifications: shell.notifications, + logger, + settings, + online: session.mode === 'ONLINE', + }), + ); + store.add( + followProjectTrust({ + projects, + notifications: shell.notifications, + commands: services.get(CoreServices.Commands), + local: session.mode === 'OFFLINE', + reload: () => location.reload(), + }), + ); + + store.add( + rpc.subscribe(PluginsContract, 'devChanges', null, ({ name, listing }) => { + const descriptor = toDescriptor(listing, logger); + if (descriptor?.manifest.entry.worker) { + code.reloadWorkerPlugin( + name, + new URL(descriptor.manifest.entry.worker, descriptor.baseUrl).href, + ); + } + if (descriptor) + plugins + .reload(name, descriptor) + .catch((error: unknown) => logger.error(`Reload of ${name} failed`, error)); + }), + ); + + return { + services, + rpc, + plugins, + projects, + settings, + history, + shell, + projectSettings: projectSettings.attached, + session, + logger, + dispose: () => store.dispose(), + }; +}; diff --git a/apps/editor/src/lib/editor/editor-context.ts b/apps/editor/src/lib/editor/editor-context.ts new file mode 100644 index 00000000..63bcd732 --- /dev/null +++ b/apps/editor/src/lib/editor/editor-context.ts @@ -0,0 +1,18 @@ +import { getContext, setContext } from 'svelte'; + +import type { Editor } from './create-editor'; + +const KEY = Symbol('nanoforge-editor'); + +/** Filled once the editor booted; the root layout renders pages only after that. */ +export interface EditorRef { + current: Editor | undefined; +} + +export const provideEditor = (): EditorRef => setContext(KEY, { current: undefined }); + +export const getEditor = (): Editor => { + const editor = getContext(KEY)?.current; + if (!editor) throw new Error('The editor is not initialized'); + return editor; +}; diff --git a/apps/editor/src/lib/editor/error-description.ts b/apps/editor/src/lib/editor/error-description.ts new file mode 100644 index 00000000..c5d7be7c --- /dev/null +++ b/apps/editor/src/lib/editor/error-description.ts @@ -0,0 +1,21 @@ +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +export interface ErrorAction { + readonly label: string; + readonly run: () => void | Promise; + readonly primary?: boolean; +} + +export interface ErrorDescription { + readonly title: string; + readonly hint?: string; + /** Technical detail, shown small (the raw message). */ + readonly detail?: string; + readonly actions: readonly ErrorAction[]; +} + +export const errorCode = (reason: unknown): string | undefined => + reason instanceof RpcError ? reason.code : (reason as { code?: string } | null)?.code; + +export const errorMessage = (reason: unknown): string => + reason instanceof Error ? reason.message : String(reason); diff --git a/apps/editor/src/lib/editor/register-app-contributions.ts b/apps/editor/src/lib/editor/register-app-contributions.ts new file mode 100644 index 00000000..8d6a3904 --- /dev/null +++ b/apps/editor/src/lib/editor/register-app-contributions.ts @@ -0,0 +1,86 @@ +import { + type Container, + CoreServices, + type Disposable, + DisposableStore, + derived, + switchObservable, +} from '@nanoforge-dev/editor-kernel'; +import { ProjectServiceToken } from '@nanoforge-dev/editor-project'; +import type { SessionInfo } from '@nanoforge-dev/editor-protocol'; +import { MENU_ITEMS, MenuItemSchema, STATUS_BAR_ITEMS } from '@nanoforge-dev/editor-ui'; + +import { goto } from '$app/navigation'; + +const DOCS_URL = 'https://docs.nanoforge.eu'; + +/** Commands, menus and status bar items of the editor application itself. */ +export const registerAppContributions = (services: Container, session: SessionInfo): Disposable => { + const store = new DisposableStore(); + const commands = services.get(CoreServices.Commands); + const extensions = services.get(CoreServices.Extensions); + const projects = services.get(ProjectServiceToken); + + store.add( + commands.register({ + id: 'project.close', + title: 'Close project', + category: 'File', + handler: async () => { + projects.close(); + await goto('/'); + }, + }), + ); + store.add( + commands.register({ + id: 'help.docs', + title: 'Documentation', + category: 'Help', + handler: () => void window.open(DOCS_URL, '_blank', 'noopener'), + }), + ); + for (const item of [ + { menu: 'file', command: 'project.close', group: 'project' }, + { menu: 'view', command: 'workbench.focusNextPart', group: 'focus' }, + { menu: 'help', command: 'help.docs' }, + ]) { + store.add(extensions.contribute(MENU_ITEMS, MenuItemSchema.parse(item), { owner: 'core' })); + } + + store.add( + extensions.contribute( + STATUS_BAR_ITEMS, + { + id: 'core.project', + alignment: 'left', + order: 0, + text: switchObservable( + derived( + [projects.current], + (project) => project && derived([project.model], (model) => model.name), + ), + 'No project', + ), + tooltip: 'Close the project', + command: 'project.close', + }, + { owner: 'core' }, + ), + ); + store.add( + extensions.contribute( + STATUS_BAR_ITEMS, + { + id: 'core.mode', + alignment: 'right', + order: 10, + text: derived([], () => + session.mode === 'OFFLINE' ? 'Local' : `Online · ${session.user?.name ?? 'signed out'}`, + ), + }, + { owner: 'core' }, + ), + ); + return store; +}; diff --git a/apps/editor/src/lib/history/indexed-db-history-store.ts b/apps/editor/src/lib/history/indexed-db-history-store.ts new file mode 100644 index 00000000..53698f5a --- /dev/null +++ b/apps/editor/src/lib/history/indexed-db-history-store.ts @@ -0,0 +1,70 @@ +import type { HistoryStore, PersistedEntry } from '@nanoforge-dev/editor-history'; + +const DATABASE = 'nanoforge-history'; +const STORE = 'contexts'; + +const request = (operation: IDBRequest): Promise => + new Promise((resolve, reject) => { + operation.onsuccess = () => resolve(operation.result); + operation.onerror = () => reject(operation.error); + }); + +let database: Promise | undefined; +const open = () => + (database ??= new Promise((resolve) => { + if (typeof indexedDB === 'undefined') return resolve(undefined); + const opening = indexedDB.open(DATABASE, 1); + opening.onupgradeneeded = () => opening.result.createObjectStore(STORE); + opening.onsuccess = () => resolve(opening.result); + opening.onerror = () => resolve(undefined); + })); + +/** File histories are dropped after a month without a change, and past the 200 newest. */ +const FILE_HISTORY_MAX_AGE_MS = 30 * 24 * 3600 * 1000; +const FILE_HISTORY_MAX = 200; + +/** + * Removes the old `file:*` histories of a project: one is kept per file ever edited, and + * nothing else would ever delete them. + */ +const pruneFileHistories = async (db: IDBDatabase, projectId: string): Promise => { + const prefix = `${projectId}:file:`; + const store = db.transaction(STORE, 'readwrite').objectStore(STORE); + const range = IDBKeyRange.bound(prefix, `${prefix}\uffff`); + const keys = (await request(store.getAllKeys(range))) as string[]; + const values = (await request(store.getAll(range))) as PersistedEntry[][]; + const dated = keys + .map((key, index) => ({ key, time: Math.max(0, ...(values[index] ?? []).map((e) => e.time)) })) + .sort((a, b) => b.time - a.time); + const now = Date.now(); + const stale = dated.filter( + (entry, index) => index >= FILE_HISTORY_MAX || now - entry.time > FILE_HISTORY_MAX_AGE_MS, + ); + if (!stale.length) return; + const writing = db.transaction(STORE, 'readwrite').objectStore(STORE); + await Promise.all(stale.map((entry) => request(writing.delete(entry.key)))); +}; + +const pruned = new Set(); + +/** Histories kept in the browser (IndexedDB) per project, across reloads. */ +export const indexedDbHistoryStore = (projectId: string): HistoryStore => ({ + async load(contextId) { + const db = await open(); + if (!db) return undefined; + const store = db.transaction(STORE, 'readonly').objectStore(STORE); + return (await request(store.get(`${projectId}:${contextId}`))) as PersistedEntry[] | undefined; + }, + async save(contextId, entries) { + const db = await open(); + if (!db) return; + const store = db.transaction(STORE, 'readwrite').objectStore(STORE); + const key = `${projectId}:${contextId}`; + if (entries.length) await request(store.put(entries, key)); + else await request(store.delete(key)); + if (!pruned.has(projectId)) { + pruned.add(projectId); + await pruneFileHistories(db, projectId).catch(() => pruned.delete(projectId)); + } + }, +}); diff --git a/apps/editor/src/lib/plugin/follow-project-plugins.ts b/apps/editor/src/lib/plugin/follow-project-plugins.ts new file mode 100644 index 00000000..7a7b12fe --- /dev/null +++ b/apps/editor/src/lib/plugin/follow-project-plugins.ts @@ -0,0 +1,154 @@ +import { + type Disposable, + type Logger, + type PluginDescriptor, + type PluginHost, + parsePluginManifest, + toDisposable, +} from '@nanoforge-dev/editor-kernel'; +import { type ProjectService } from '@nanoforge-dev/editor-project'; +import { type PluginListing, PluginsContract } from '@nanoforge-dev/editor-protocol'; +import { type RpcClient } from '@nanoforge-dev/editor-rpc'; +import { CoreSettings, type SettingsService } from '@nanoforge-dev/editor-settings'; +import type { NotificationService } from '@nanoforge-dev/editor-ui'; + +export const toDescriptor = ( + listing: PluginListing, + logger: Logger, +): PluginDescriptor | undefined => { + if (listing.error) { + logger.warn(`Plugin at ${listing.baseUrl} skipped: ${listing.error}`); + return undefined; + } + try { + return { + source: listing.source, + baseUrl: new URL(listing.baseUrl, location.origin).href, + manifest: parsePluginManifest(listing.manifest, listing.baseUrl), + }; + } catch (error) { + logger.error(`Invalid plugin manifest at ${listing.baseUrl}`, error); + return undefined; + } +}; + +/** + * The open project's own plugins (`.nanoforge/plugins`) join the plugin set while it is open; in + * a local editor, the server entries of those that load run while it is open. + * Plugins that its packages suggest and that aren't installed are named in a notification, once + * per project. + * + * On a hosted editor (`online`) a project is shared: anyone who can change it can change its + * plugins, which would run in the others' editors. They load only for the projects the user + * allowed (`plugins.trustedProjects`); otherwise a notification names them and offers to. + */ +export const followProjectPlugins = (options: { + rpc: RpcClient; + projects: ProjectService; + plugins: PluginHost; + notifications: NotificationService; + logger: Logger; + settings: SettingsService; + online: boolean; +}): Disposable => { + const { rpc, plugins, logger, settings } = options; + const api = rpc.api(PluginsContract); + + const suggested = new Set(); + const offered = new Set(); + let listedFor: string | undefined; + let queue = Promise.resolve(); + + const trusted = (project: string) => + options.online + ? settings.get(CoreSettings.pluginsTrustedProjects).includes(project) + : options.projects.current.get()?.trusted !== false; + + /** + * Keeps a hosted project's own plugins out until allowed, and offers to allow them. A local + * folder that is not trusted keeps them out too (restricted mode says so). + */ + const allowed = (project: string | undefined, listings: readonly PluginListing[]) => { + if (!project || trusted(project)) return listings; + if (!options.online) return listings.filter((listing) => listing.source !== 'project'); + const own = listings.filter((listing) => listing.source === 'project'); + if (own.length && !offered.has(project)) { + offered.add(project); + const names = own + .map((listing) => (listing.manifest as { name?: string } | null)?.name ?? listing.baseUrl) + .join(', '); + options.notifications.notify('warning', 'This project has its own plugins', { + detail: `${names}. They would run in your editor, and anyone who can change the project can change them.`, + actions: [ + { + title: 'Allow its plugins', + run: async () => { + const list = settings.get(CoreSettings.pluginsTrustedProjects); + await settings.set( + CoreSettings.pluginsTrustedProjects, + [...list, project], + 'account', + ); + listedFor = undefined; + follow(options.projects.current.get()?.id); + }, + }, + ], + timeout: 0, + }); + } + return listings.filter((listing) => listing.source !== 'project'); + }; + + const suggest = async (project: string) => { + if (suggested.has(project)) return; + suggested.add(project); + const missing = (await api.suggestions({ project })).filter( + (suggestion) => plugins.getPlugin(suggestion.name)?.status.kind !== 'ok', + ); + if (!missing.length) return; + const count = missing.length > 1 ? `${missing.length} plugins` : 'a plugin'; + options.notifications.notify('info', `Packages in this project suggest ${count}`, { + detail: `${missing + .map( + ({ name, suggestedBy }) => + `${name} (for ${suggestedBy.map((entry) => entry.package).join(', ')})`, + ) + .join( + ', ', + )}. Install a plugin in the project's .nanoforge/plugins, or in your home for every project.`, + timeout: 15_000, + }); + }; + + /** + * Runs the server entries of the project plugins that loaded (allowed, not disabled, not + * failed), and stops the others: listing a project never runs its code. + */ + const runServers = (project: string | null) => + api.runProjectServers({ + project, + names: plugins.plugins + .get() + .filter((plugin) => plugin.descriptor.source === 'project' && plugin.status.kind === 'ok') + .map((plugin) => plugin.name), + }); + + function follow(project: string | undefined) { + if (project === listedFor) return; + listedFor = project; + queue = queue + .then(async () => { + const listings = await api.list(project ? { project } : null); + if (listedFor !== project) return; + await plugins.update( + allowed(project, listings).flatMap((listing) => toDescriptor(listing, logger) ?? []), + ); + if (!options.online) await runServers(project ?? null); + if (project) await suggest(project); + }) + .catch((error: unknown) => logger.error('Could not load the project plugins', error)); + } + + return toDisposable(options.projects.current.subscribe((project) => follow(project?.id))); +}; diff --git a/apps/editor/src/lib/plugin/shared-modules.ts b/apps/editor/src/lib/plugin/shared-modules.ts new file mode 100644 index 00000000..25eed393 --- /dev/null +++ b/apps/editor/src/lib/plugin/shared-modules.ts @@ -0,0 +1,52 @@ +import * as svelte from 'svelte'; +import * as svelteAnimate from 'svelte/animate'; +import * as svelteAttachments from 'svelte/attachments'; +import * as svelteEasing from 'svelte/easing'; +import * as svelteEvents from 'svelte/events'; +// eslint-disable-next-line svelte/no-svelte-internal +import * as svelteInternalClient from 'svelte/internal/client'; +import * as svelteLegacy from 'svelte/legacy'; +import * as svelteMotion from 'svelte/motion'; +import * as svelteReactivity from 'svelte/reactivity'; +import * as svelteReactivityWindow from 'svelte/reactivity/window'; +import * as svelteStore from 'svelte/store'; +import * as svelteTransition from 'svelte/transition'; + +import { type ModuleNamespace, SharedModuleRegistry } from '@nanoforge-dev/editor-kernel'; +import * as sdk from '@nanoforge-dev/editor-sdk'; +import * as sdkUi from '@nanoforge-dev/editor-sdk/ui'; + +/** + * Modules the editor shares with plugins, keyed by import specifier. + * Must list exactly `MAIN_SHARED_MODULES` of `@nanoforge-dev/editor-vite-plugin` (checked by a test). + */ +export const SHARED_MODULE_NAMESPACES: Readonly> = { + svelte, + 'svelte/animate': svelteAnimate, + 'svelte/attachments': svelteAttachments, + 'svelte/easing': svelteEasing, + 'svelte/events': svelteEvents, + 'svelte/internal/client': svelteInternalClient, + 'svelte/legacy': svelteLegacy, + 'svelte/motion': svelteMotion, + 'svelte/reactivity': svelteReactivity, + 'svelte/reactivity/window': svelteReactivityWindow, + 'svelte/store': svelteStore, + 'svelte/transition': svelteTransition, + '@nanoforge-dev/editor-sdk': sdk, + '@nanoforge-dev/editor-sdk/ui': sdkUi, +}; + +let registry: SharedModuleRegistry | undefined; + +/** Installs the shared module registry once, in the browser, before any plugin is imported. */ +export const installSharedModules = (): SharedModuleRegistry => { + if (registry) return registry; + registry = new SharedModuleRegistry(); + for (const [id, namespace] of Object.entries(SHARED_MODULE_NAMESPACES)) { + registry.register(id, namespace); + } + const uninstall = registry.install(); + import.meta.hot?.dispose(uninstall); + return registry; +}; diff --git a/apps/editor/src/lib/plugin/svelte-internal.d.ts b/apps/editor/src/lib/plugin/svelte-internal.d.ts new file mode 100644 index 00000000..f4d5d3b6 --- /dev/null +++ b/apps/editor/src/lib/plugin/svelte-internal.d.ts @@ -0,0 +1,2 @@ +// `svelte/internal/client` ships without types; it is only re-exposed to plugins, never used directly. +declare module 'svelte/internal/client'; diff --git a/apps/editor/src/lib/project/AddAppDialog.svelte b/apps/editor/src/lib/project/AddAppDialog.svelte new file mode 100644 index 00000000..06fc4bf9 --- /dev/null +++ b/apps/editor/src/lib/project/AddAppDialog.svelte @@ -0,0 +1,117 @@ + + + +
{ + event.preventDefault(); + if (folderError || nameError) return; + const source = sameType.find((app) => app.id === from); + oncreate({ type, folder, name, ...(source && { from: source }) }); + close(); + }} + > +

+ {type === 'client' ? 'A game the players run.' : 'A game server.'} It is created in + apps/{folder}. +

+ + + + + {#if apps.length} +
+ Used by + {#each apps as app (app.id)} + { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- replaced, not mutated + const next = new Set(users); + if (checked) next.add(app.id); + else next.delete(app.id); + users = next; + }} + /> + {/each} +
+ {/if} +
+ + +
+
+
+ + diff --git a/apps/editor/src/lib/project/ProjectOverview.svelte b/apps/editor/src/lib/project/ProjectOverview.svelte new file mode 100644 index 00000000..0df4a811 --- /dev/null +++ b/apps/editor/src/lib/project/ProjectOverview.svelte @@ -0,0 +1,346 @@ + + +{#if $model} +
+

{$model.name}

+

{$model.location}

+ + {#if $model.diagnostics.length} +
+ {#each $model.diagnostics as diagnostic, index (index)} +

+ {diagnostic.path || '.'} + {diagnostic.message} +

+ {/each} +
+ {/if} + +
+

Apps

+ {#if actions} + + {#snippet trigger({ props })} + + {/snippet} + + {/if} +
+
+ {#each apps as app (app.id)} +
+

+ {app.name} + {app.type} + {#if actions} + + {#snippet trigger({ props })} + + {/snippet} + + {/if} +

+

{app.root || '.'}

+
    + {#each Object.entries(app.engineLibs) as [name, version] (name)} +
  • + {name.replace('@nanoforge-dev/', '')} + {#if /^\d/.test(version)}{version}{/if} +
  • + {:else} +
  • No engine library
  • + {/each} +
+ {#if app.libraries.length} +

Uses {app.libraries.join(', ')}

+ {/if} +
+ {/each} +
+ +
+

Shared libraries

+ {#if actions} + + {/if} +
+ {#if libraries.length} +
+ {#each libraries as library (library.id)} + {@const count = itemsOf(library)} +
+

+ {library.name} + {#if actions} + + {#snippet trigger({ props })} + + {/snippet} + + {/if} +

+

+ {library.root} · {count} + {count === 1 ? 'component or system' : 'components and systems'} +

+
+ Used by + {#each apps as app (app.id)} + void actions?.setUse(app, library, checked)} + /> + {/each} +
+ {#if library.unclaimed} +

+ No app lists it in the libs of its nanoforge.config yet, so every app gets it. + Untick one to choose. +

+ {/if} +
+ {/each} +
+ {:else} +

+ A shared library holds the components and systems that several apps use: a position the + client draws and the server computes, for example. +

+ {/if} +
+ {#if actions && $request?.kind === 'library'} + void actions.addLibrary(library)} + onclose={() => actions.ask(undefined)} + /> + {:else if actions && $request?.kind === 'app'} + void actions.addApp(app)} + onclose={() => actions.ask(undefined)} + /> + {:else if actions && $request?.kind === 'remove'} + {@const target = $request.app} + actions.ask(undefined)} + > +

+ The folder {target.root} goes to the editor's trash. Undo (Ctrl+Z on this screen) + brings it back. +

+ {#snippet footer()} + + + {/snippet} +
+ {/if} +{:else} + +{/if} + + diff --git a/apps/editor/src/lib/project/follow-project-trust.ts b/apps/editor/src/lib/project/follow-project-trust.ts new file mode 100644 index 00000000..d07e49d9 --- /dev/null +++ b/apps/editor/src/lib/project/follow-project-trust.ts @@ -0,0 +1,61 @@ +import { + type CommandRegistry, + type Disposable, + DisposableStore, +} from '@nanoforge-dev/editor-kernel'; +import type { ProjectService } from '@nanoforge-dev/editor-project'; +import type { NotificationService } from '@nanoforge-dev/editor-ui'; + +/** + * Restricted mode of a local editor: a project whose folder is not trusted opens with its code + * off (its config is read without running it; its plugins, builds and games don't run). A sticky + * notification says so and offers to trust the folder, as does `project.trust`. + */ +export const followProjectTrust = (options: { + projects: ProjectService; + notifications: NotificationService; + commands: CommandRegistry; + /** Hosted editors never run project code: there is no folder to trust. */ + local: boolean; + /** Reloads the window, so the project loads again with its code on. */ + reload: () => void; +}): Disposable => { + const store = new DisposableStore(); + if (!options.local) return store; + let shown: { dismiss(): void } | undefined; + + const trust = async () => { + const project = options.projects.current.get(); + if (!project || project.trusted) return; + await options.projects.trust(); + options.reload(); + }; + + store.add( + options.commands.register({ + id: 'project.trust', + title: 'Trust this folder', + category: 'Project', + handler: trust, + }), + ); + store.add({ + dispose: options.projects.current.subscribe((project) => { + shown?.dismiss(); + shown = undefined; + if (!project || project.trusted) return; + shown = options.notifications.notify( + 'warning', + `Restricted mode: ${project.model.get().name}'s code does not run`, + { + detail: + 'Its nanoforge.config.ts is read without running it, and its plugins, builds and games stay off. Trust the folder if you trust where it comes from.', + actions: [{ title: 'Trust this folder', run: trust }], + timeout: 0, + }, + ); + }), + }); + store.add({ dispose: () => shown?.dismiss() }); + return store; +}; diff --git a/apps/editor/src/lib/project/register-core-panels.ts b/apps/editor/src/lib/project/register-core-panels.ts new file mode 100644 index 00000000..fc8e64a6 --- /dev/null +++ b/apps/editor/src/lib/project/register-core-panels.ts @@ -0,0 +1,47 @@ +import type { Component } from 'svelte'; + +import { + type Disposable, + DisposableStore, + type ExtensionRegistry, +} from '@nanoforge-dev/editor-kernel'; +import { + WIDGETS, + WIDGET_VIEWS, + WidgetDescriptorSchema, + type WidgetInstance, +} from '@nanoforge-dev/editor-ui'; + +import ProjectOverview from './ProjectOverview.svelte'; + +const PANELS = [ + { + id: 'core.project', + title: 'Project', + icon: 'layers', + kind: 'screen', + order: 100, + component: ProjectOverview, + }, +] as const; + +/** + * Panels of the editor itself. Plugins of later phases replace or complement them + * (file manager, history panel…). + */ +export const registerCorePanels = (extensions: ExtensionRegistry): Disposable => { + const store = new DisposableStore(); + for (const { component, ...descriptor } of PANELS) { + store.add( + extensions.contribute(WIDGETS, WidgetDescriptorSchema.parse(descriptor), { owner: 'core' }), + ); + store.add( + extensions.contribute( + WIDGET_VIEWS, + { id: descriptor.id, component: component as Component<{ instance: WidgetInstance }> }, + { owner: 'core' }, + ), + ); + } + return store; +}; diff --git a/apps/editor/src/lib/runtime/follow-project-runtime.ts b/apps/editor/src/lib/runtime/follow-project-runtime.ts new file mode 100644 index 00000000..6b23064f --- /dev/null +++ b/apps/editor/src/lib/runtime/follow-project-runtime.ts @@ -0,0 +1,402 @@ +import type { z } from 'zod'; + +import { + type CodeDiagnostic, + DiagnosticsServiceToken, + DocumentServiceToken, +} from '@nanoforge-dev/editor-code'; +import { + type Container, + CoreServices, + type Disposable, + DisposableStore, + type LoggerService, + MutableDisposable, + derived, + observe, +} from '@nanoforge-dev/editor-kernel'; +import type { ProjectService } from '@nanoforge-dev/editor-project'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; +import { + type PlayMode, + RuntimeService, + RuntimeServiceToken, + RuntimeSettings, + isPlaying, +} from '@nanoforge-dev/editor-runtime'; +import type { SettingsService } from '@nanoforge-dev/editor-settings'; +import { + KEYBINDINGS, + MENU_BAR, + MENU_ITEMS, + MenuItemSchema, + type NotificationService, + STATUS_BAR_ITEMS, + TOOLBAR_ITEMS, +} from '@nanoforge-dev/editor-ui'; + +import { WorkspaceActionsToken } from '../workspace/workspace-actions'; +import { LOG_LEVELS, TOOLBAR } from './runtime-toolbar.const'; + +/** + * The game runtime of the open project: play controls (commands, toolbar, Run menu, + * shortcuts), context keys (`runtime.state`, `runtime.active`, `runtime.controllable`), game, + * build and task output for the console, and build errors as problems. + */ +export const followProjectRuntime = (options: { + services: Container; + projects: ProjectService; + rpc: RpcClient; + settings: SettingsService; + logs: LoggerService; + notifications: NotificationService; + /** Local editors only: hosted editors never run project code. */ + enabled: boolean; +}): Disposable => { + const { services, settings, logs } = options; + const store = new DisposableStore(); + const commands = services.get(CoreServices.Commands); + const contextKeys = services.get(CoreServices.ContextKeys); + const extensions = services.get(CoreServices.Extensions); + const current = store.add(new MutableDisposable()); + let runtime: RuntimeService | undefined; + + const setKeys = (state: string, controllable: boolean) => { + contextKeys.set('runtime.state', state); + contextKeys.set('runtime.active', isPlaying(state as never)); + contextKeys.set('runtime.controllable', controllable); + }; + setKeys('idle', false); + + if (options.enabled) { + store.add({ + dispose: options.projects.current.subscribe((project) => { + runtime = undefined; + current.clear(); + setKeys('idle', false); + if (!project?.trusted) return; + const scope = new DisposableStore(); + const service = scope.add( + new RuntimeService({ + projectId: project.id, + rpc: options.rpc, + apps: () => project.model.get().apps, + logger: logs.getLogger('runtime'), + playMode: () => settings.get(RuntimeSettings.playMode), + selectedApp: (type) => + settings.get( + type === 'client' ? RuntimeSettings.clientApp : RuntimeSettings.serverApp, + ) || undefined, + envOverrides: () => settings.get(RuntimeSettings.env), + }), + ); + scope.add(services.provide(RuntimeServiceToken, service)); + const choices = scope.add(new DisposableStore()); + const refreshChoices = () => { + choices.clear(); + for (const [type, title] of [ + ['client', 'Client app'], + ['server', 'Server app'], + ] as const) { + const apps = service.playable(type); + if (apps.length < 2) continue; + const add = (item: z.input) => + choices.add( + extensions.contribute(MENU_ITEMS, MenuItemSchema.parse(item), { owner: 'core' }), + ); + const current = service.appFor(type); + add({ menu: 'run', submenu: `run.${type}`, title, group: 'apps', order: 0 }); + for (const app of apps) { + add({ + menu: `run.${type}`, + command: 'runtime.selectApp', + args: [type, app.id], + title: app.name, + ...(app === current && { icon: 'check' }), + }); + } + } + }; + scope.add({ dispose: project.model.subscribe(refreshChoices) }); + scope.add(observe(settings.observe(RuntimeSettings.clientApp), refreshChoices)); + scope.add(observe(settings.observe(RuntimeSettings.serverApp), refreshChoices)); + scope.add({ + dispose: service.session.subscribe(({ state, controllable }) => + setKeys(state, controllable), + ), + }); + scope.add( + service.onLog(({ source, app, level, text, values, location }) => + logs.write({ + level: LOG_LEVELS[level], + source: + source === 'build' + ? `build:${app}` + : source === 'cli' + ? `cli:${app}` + : `game:${source}`, + message: text, + args: [], + time: Date.now(), + ...(values && { values }), + ...(location && { location }), + }), + ), + ); + const diagnostics = services.tryGet(DiagnosticsServiceToken); + if (diagnostics) { + const reported = new Set(); + scope.add({ + dispose: service.builds.subscribe((builds) => { + for (const [app, status] of builds) { + if (status.state === 'building') continue; + const source = `build:${app}`; + reported.add(source); + diagnostics.clear(source); + const files = new Map(); + for (const diagnostic of status.diagnostics) { + const path = diagnostic.path ?? ''; + files.set(path, [ + ...(files.get(path) ?? []), + { + path, + start: -1, + length: 0, + ...(diagnostic.line !== undefined && { line: diagnostic.line }), + ...(diagnostic.column !== undefined && { column: diagnostic.column }), + message: diagnostic.message, + severity: diagnostic.severity, + source, + }, + ]); + } + for (const [path, list] of files) diagnostics.set(source, path, list); + } + }), + }); + scope.add({ + dispose: () => { + for (const source of reported) diagnostics.clear(source); + }, + }); + } + const notified = new Set(); + scope.add({ + dispose: service.session.subscribe(({ state, compatibility }) => { + if (state !== 'running') return; + for (const check of compatibility) { + const key = `${check.source}:${check.status}`; + if (!check.message || notified.has(key)) continue; + notified.add(key); + const app = + check.status === 'legacy' + ? project.model.get().apps.find((candidate) => candidate.id === check.app) + : undefined; + const workspace = services.tryGet(WorkspaceActionsToken); + options.notifications.notify( + 'warning', + check.status === 'legacy' + ? 'The game has no editor bridge' + : 'The game engine needs an update', + { + detail: check.message, + ...(app && + workspace && { + actions: [ + { + title: 'Add the editor library', + run: () => void workspace.addEditorLibrary(app), + }, + ], + timeout: 0, + }), + }, + ); + } + }), + }); + scope.add( + extensions.contribute( + STATUS_BAR_ITEMS, + { + id: 'runtime.frameStats', + alignment: 'right', + order: 0, + when: 'runtime.active', + tooltip: 'Ticks per second and average tick duration of the running games', + text: derived([service.frameStats], (stats) => + [...stats] + .sort(([a], [b]) => a.localeCompare(b)) + .map( + ([source, { tps, tick }]) => + `${source} ${Math.round(tps)} tps · ${tick.avg.toFixed(1)} ms`, + ) + .join(' '), + ), + }, + { owner: 'core' }, + ), + ); + runtime = service; + current.value = scope; + }), + }); + } + + const withRuntime = + (run: (runtime: RuntimeService, ...args: unknown[]) => unknown) => + (_services: unknown, ...args: unknown[]) => { + if (!runtime) { + const project = options.projects.current.get(); + if (options.enabled && project && !project.trusted) { + options.notifications.notify('warning', 'Trust this folder to play its games', { + detail: + "Playing runs the project's code: a folder opens in restricted mode until you trust it.", + actions: [{ title: 'Trust this folder', run: () => commands.execute('project.trust') }], + }); + return; + } + options.notifications.notify('info', 'Games can only be run from a local editor'); + return; + } + return run(runtime, ...args); + }; + + const play = async (target: RuntimeService, mode?: PlayMode) => { + const resolved = mode ?? target.defaultMode(); + const documents = services.tryGet(DocumentServiceToken); + if (documents && settings.get(RuntimeSettings.saveBeforePlay)) { + const failed = await documents.saveAll(); + if (failed.length) { + options.notifications.notify('error', `Could not save ${failed[0]!.uri} before playing`, { + detail: String(failed[0]!.error), + }); + return; + } + } + await target.play(resolved); + const session = target.session.get(); + if (session.state === 'crashed') { + const first = session.diagnostics?.[0]; + options.notifications.notify('error', session.error ?? 'The game crashed', { + ...(first && { + detail: + `${first.path ?? ''}${first.line ? `:${first.line}` : ''} ${first.message}`.trim(), + }), + }); + } + }; + + store.add( + commands.register({ + id: 'runtime.selectApp', + title: 'Choose the app Play starts', + category: 'Run', + palette: false, + handler: async (_services, type: unknown, app: unknown) => { + if ((type !== 'client' && type !== 'server') || typeof app !== 'string') return; + await settings.set( + type === 'client' ? RuntimeSettings.clientApp : RuntimeSettings.serverApp, + app, + settings.hasStore('projectLocal') ? 'projectLocal' : 'project', + ); + }, + }), + ); + + for (const [id, title, handler] of [ + ['runtime.play', 'Play', withRuntime((target, mode) => play(target, mode as PlayMode))], + ['runtime.playClient', 'Play client only', withRuntime((target) => play(target, 'client'))], + ['runtime.playServer', 'Play server only', withRuntime((target) => play(target, 'server'))], + ['runtime.pause', 'Pause', withRuntime((target) => target.pause())], + ['runtime.resume', 'Resume', withRuntime((target) => target.resume())], + ['runtime.step', 'Step one frame', withRuntime((target) => target.step())], + ['runtime.stop', 'Stop', withRuntime((target) => target.stop())], + ['runtime.restart', 'Restart', withRuntime((target) => target.restart())], + [ + 'runtime.reload', + 'Reload editor runtime', + withRuntime(async (target) => { + await target.stop(); + location.reload(); + }), + ], + [ + 'runtime.openClient', + 'Open another client', + withRuntime(() => { + const project = options.projects.current.get(); + const app = runtime?.appFor('client'); + if (!project || !app) return; + const url = new URL(`/play/${project.id}`, location.origin); + url.searchParams.set('app', app.id); + const env = settings.get(RuntimeSettings.env); + if (Object.keys(env).length) + url.hash = new URLSearchParams({ env: JSON.stringify(env) }).toString(); + window.open(url, '_blank', 'noopener'); + }), + ], + ] as const) { + store.add(commands.register({ id, title, category: 'Run', handler })); + } + + for (const item of TOOLBAR) + store.add(extensions.contribute(TOOLBAR_ITEMS, item, { owner: 'core' })); + store.add( + extensions.contribute(MENU_BAR, { id: 'run', title: 'Run', order: 3 }, { owner: 'core' }), + ); + for (const item of [ + { menu: 'run', command: 'runtime.play', group: 'play', order: 0, when: '!runtime.active' }, + { + menu: 'run', + command: 'runtime.playClient', + group: 'play', + order: 1, + when: '!runtime.active', + }, + { + menu: 'run', + command: 'runtime.playServer', + group: 'play', + order: 2, + when: '!runtime.active', + }, + { + menu: 'run', + command: 'runtime.pause', + group: 'control', + order: 0, + when: "runtime.state == 'running' && runtime.controllable", + }, + { + menu: 'run', + command: 'runtime.resume', + group: 'control', + order: 1, + when: "runtime.state == 'paused'", + }, + { + menu: 'run', + command: 'runtime.step', + group: 'control', + order: 2, + when: "runtime.state == 'paused'", + }, + { menu: 'run', command: 'runtime.restart', group: 'control', order: 3, when: 'runtime.active' }, + { menu: 'run', command: 'runtime.stop', group: 'control', order: 4, when: 'runtime.active' }, + { menu: 'run', command: 'runtime.openClient', group: 'z-clients' }, + { menu: 'run', command: 'runtime.reload', group: 'z-reload' }, + ]) { + store.add(extensions.contribute(MENU_ITEMS, MenuItemSchema.parse(item), { owner: 'core' })); + } + for (const binding of [ + { key: 'F5', command: 'runtime.play', when: '!runtime.active', args: [] }, + { key: 'F5', command: 'runtime.resume', when: "runtime.state == 'paused'", args: [] }, + { key: 'Shift+F5', command: 'runtime.stop', when: 'runtime.active', args: [] }, + { key: 'Mod+Shift+F5', command: 'runtime.restart', when: 'runtime.active', args: [] }, + ]) { + store.add(extensions.contribute(KEYBINDINGS, binding, { owner: 'core' })); + } + + return store; +}; diff --git a/apps/editor/src/lib/runtime/runtime-toolbar.const.ts b/apps/editor/src/lib/runtime/runtime-toolbar.const.ts new file mode 100644 index 00000000..b6863ae4 --- /dev/null +++ b/apps/editor/src/lib/runtime/runtime-toolbar.const.ts @@ -0,0 +1,59 @@ +import { LogLevel } from '@nanoforge-dev/editor-kernel'; + +export const LOG_LEVELS = { + debug: LogLevel.Debug, + info: LogLevel.Info, + warn: LogLevel.Warn, + error: LogLevel.Error, +} as const; + +export const TOOLBAR = [ + { + id: 'runtime.play', + icon: 'play', + title: 'Play (F5)', + command: 'runtime.play', + order: 0, + when: '!runtime.active', + }, + { + id: 'runtime.pause', + icon: 'pause', + title: 'Pause', + command: 'runtime.pause', + order: 1, + when: "runtime.state == 'running' && runtime.controllable", + }, + { + id: 'runtime.resume', + icon: 'play', + title: 'Resume (F5)', + command: 'runtime.resume', + order: 1, + when: "runtime.state == 'paused'", + }, + { + id: 'runtime.step', + icon: 'step-forward', + title: 'Step one frame', + command: 'runtime.step', + order: 2, + when: "runtime.state == 'paused'", + }, + { + id: 'runtime.restart', + icon: 'rotate-ccw', + title: 'Restart (Ctrl+Shift+F5)', + command: 'runtime.restart', + order: 3, + when: 'runtime.active', + }, + { + id: 'runtime.stop', + icon: 'square', + title: 'Stop (Shift+F5)', + command: 'runtime.stop', + order: 4, + when: 'runtime.active', + }, +]; diff --git a/apps/editor/src/lib/workspace/workspace-actions.ts b/apps/editor/src/lib/workspace/workspace-actions.ts new file mode 100644 index 00000000..cea7e7dd --- /dev/null +++ b/apps/editor/src/lib/workspace/workspace-actions.ts @@ -0,0 +1,376 @@ +import { + CodeServiceToken, + DocumentServiceToken, + RENAME_PACKAGE_TRANSFORMER, +} from '@nanoforge-dev/editor-code'; +import type { HistoryContext, HistoryService } from '@nanoforge-dev/editor-history'; +import { + type Container, + CoreServices, + type Disposable, + DisposableStore, + type Observable, + ObservableValue, + createToken, +} from '@nanoforge-dev/editor-kernel'; +import { + type ClientProject, + EDITOR_LIBRARY, + type NewApp, + type NewLibrary, + type ProjectService, + type Undo, + WorkspaceError, + type WorkspaceIo, + addEditorLibrary, + createApp, + createLibrary, + removeApp, + removeLibrary, + renameApp, + renameLibrary, + runnableApps, + setLibraryUse, + validatePackageName, + workspaceIo, +} from '@nanoforge-dev/editor-project'; +import { type AppModel, ProjectsContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; +import type { NotificationService, PromptService } from '@nanoforge-dev/editor-ui'; + +/** Undo and redo target of the Project screen: changes of the workspace. */ +export const PROJECT_HISTORY_CONTEXT = 'project'; + +/** A dialog the Project screen is asked to show. */ +export type WorkspaceRequest = + | { readonly kind: 'library' } + | { readonly kind: 'app'; readonly type: 'client' | 'server' } + | { readonly kind: 'remove'; readonly app: AppModel }; + +export interface WorkspaceActionsOptions { + readonly services: Container; + readonly projects: ProjectService; + readonly rpc: RpcClient; + readonly history: HistoryService; + readonly notifications: NotificationService; + readonly prompts: PromptService; + /** Local editors install dependencies; hosted ones never run project tools. */ + readonly local: boolean; +} + +/** + * What the Project screen does to the workspace: add, rename and remove apps and shared + * libraries, and choose which apps use a library. Each action is one step of the Project + * history; failures are reported, never thrown. The history belongs to the open project: it is + * cleared when another project opens, so an undo never reaches the previous one. + */ +export class WorkspaceActions implements Disposable { + private readonly _store = new DisposableStore(); + private readonly _request = new ObservableValue(undefined); + private readonly _stack: HistoryContext['stack']; + private readonly _hasSteps = new ObservableValue(false); + + constructor(private readonly _options: WorkspaceActionsOptions) { + const context = this._store.add( + _options.history.registerContext({ + id: PROJECT_HISTORY_CONTEXT, + label: 'Project', + owner: 'core', + }), + ); + this._stack = context.stack; + this._store.add( + context.stack.onDidChange(({ canUndo, canRedo }) => this._hasSteps.set(canUndo || canRedo)), + ); + let projectId = _options.projects.current.get()?.id; + this._store.add({ + dispose: _options.projects.current.subscribe((project) => { + if (project?.id === projectId) return; + projectId = project?.id; + void this._stack.clear(); + }), + }); + const commands = _options.services.get(CoreServices.Commands); + const open = (request: WorkspaceRequest) => async () => { + await commands.execute('workbench.openWidget', 'core.project'); + this._request.set(request); + }; + for (const [id, title, request] of [ + ['project.addLibrary', 'Add shared library…', { kind: 'library' }], + ['project.addClientApp', 'Add client app…', { kind: 'app', type: 'client' }], + ['project.addServerApp', 'Add server app…', { kind: 'app', type: 'server' }], + ] as const) + this._store.add( + commands.register({ id, title, category: 'Project', handler: open(request) }), + ); + } + + /** + * Whether the Project history has a step to undo or redo. The screen is the target of undo + * only then: with none, `Ctrl+Z` there keeps undoing the layout, as on a screen without history. + */ + get hasSteps(): Observable { + return this._hasSteps.readonly(); + } + + /** The dialog asked for (by a command, or a button of the screen). */ + get request(): Observable { + return this._request.readonly(); + } + + ask(request: WorkspaceRequest | undefined): void { + this._request.set(request); + } + + get local(): boolean { + return this._options.local; + } + + /** The apps a new library is used by, and that can use one: the clients and servers. */ + apps(): AppModel[] { + const project = this._project(); + return project ? runnableApps(project.model.get()) : []; + } + + addLibrary(library: NewLibrary): Promise { + return this._run(`Add shared library ${library.packageName}`, (io) => + createLibrary(io, library), + ); + } + + async addApp(app: NewApp): Promise { + const added = await this._run(`Add ${app.type} app ${app.name}`, (io, project) => + createApp(io, project.model.get(), app), + ); + if (added && !app.from) this._offerInstall(app.name); + return added; + } + + /** + * Makes an app use a library, or stop. A library no app lists yet is given to them all: the + * first choice writes it down for every app that keeps it. All or nothing: a failure undoes + * what was already written. + */ + setUse(app: AppModel, library: AppModel, used: boolean): Promise { + return this._run( + `${used ? 'Use' : 'Stop using'} ${library.name} in ${app.name}`, + async (io, project) => { + const undos: Undo[] = []; + const undoAll = async () => { + for (const undo of [...undos].reverse()) await undo(); + }; + try { + if (library.unclaimed && !used) { + for (const other of runnableApps(project.model.get())) + if (other.id !== app.id) undos.push(await setLibraryUse(io, other, library, true)); + } + undos.push(await setLibraryUse(io, app, library, used)); + } catch (error) { + await undoAll().catch(() => undefined); + throw error; + } + return undoAll; + }, + ); + } + + /** Asks for the new package name of an app or a library, then renames it. */ + async rename(app: AppModel): Promise { + const project = this._project(); + if (!project) return false; + const name = await this._options.prompts.ask({ + title: `Rename ${app.name}`, + label: app.type === 'lib' ? 'Package name (how apps import it)' : 'Name', + value: app.name, + confirm: 'Rename', + validate: (value) => + validatePackageName(value) ?? + (value !== app.name && project.model.get().apps.some((other) => other.name === value) + ? `${value} is already the name of an app or library.` + : undefined), + }); + if (!name || name === app.name) return false; + if (app.type === 'lib') { + const failed = await this._options.services.tryGet(DocumentServiceToken)?.saveAll(); + if (failed?.length) { + this._options.notifications.notify('error', `Could not save ${failed[0]!.uri}`, { + detail: 'Save or revert it, then rename the library again.', + }); + return false; + } + } + return this._run(`Rename ${app.name} to ${name}`, (io) => + app.type === 'lib' + ? renameLibrary(io, project.model.get(), app, name) + : renameApp(io, project.model.get(), app, name), + ); + } + + remove(app: AppModel): Promise { + return this._run(`Remove ${app.name}`, (io, project) => + app.type === 'lib' + ? removeLibrary(io, project.model.get(), app) + : removeApp(io, project.model.get(), app), + ); + } + + /** + * Registers the engine's editor library in an app (its `package.json` and entry file), so + * the editor can pause it and show it live. It takes effect at the next Play. + */ + async addEditorLibrary(app: AppModel): Promise { + const added = await this._run(`Add the editor library to ${app.name}`, (io) => + addEditorLibrary(io, app), + ); + if (!added) return false; + if (EDITOR_LIBRARY in app.engineLibs) { + this._options.notifications.notify('info', `${app.name} registers the editor library`, { + detail: 'Play again to use it.', + }); + } else + this._offerInstall( + app.name, + 'It now depends on @nanoforge-dev/editor-lib: install it, then play again.', + ); + return true; + } + + /** Whether the folder of an app can be shown in the machine's file manager. */ + get canReveal(): boolean { + return ( + this._options.local && + this._options.services.get(CoreServices.Commands).has('fileManager.reveal') + ); + } + + reveal(app: AppModel): void { + void this._options.services.get(CoreServices.Commands).execute('fileManager.reveal', app.root); + } + + /** Runs the project's package manager; its output goes to the Console. */ + async install(): Promise { + const project = this._project(); + if (!project) return; + const { notifications } = this._options; + try { + const { manager, ok } = await this._options.rpc + .api(ProjectsContract) + .install({ id: project.id }); + if (!manager) { + notifications.notify('info', 'Install the dependencies yourself', { + detail: + "The project has no lockfile, so the editor can't tell which package manager it uses. Run its install command in the project's folder.", + timeout: 0, + }); + } else if (ok) notifications.notify('info', `Dependencies installed with ${manager}`); + else + notifications.notify('error', `${manager} install failed`, { + detail: 'Its output is in the Console, under Tasks.', + }); + } catch (error) { + notifications.notify('error', 'Could not install the dependencies', { + detail: error instanceof Error ? error.message : String(error), + }); + } + } + + dispose(): void { + this._store.dispose(); + } + + /** + * The project's files for an operation. Searches see the open documents' unsaved edits. With the + * code worker, imports are rewritten by reading the code, through the open documents (saved + * after each change, as a multi-file operation must). + */ + private _io(project: ClientProject): WorkspaceIo { + const plain = workspaceIo(project); + const code = this._options.services.tryGet(CodeServiceToken); + const documents = this._options.services.tryGet(DocumentServiceToken); + if (!documents) return plain; + const io: WorkspaceIo = { + ...plain, + readEdited: async (path) => documents.get(path)?.getText() ?? plain.read(path), + }; + if (!code) return io; + const saved = async (path: string) => { + if (documents.get(path)) await documents.save(path); + }; + return { + ...io, + renameImports: async (path, from, to) => { + const command = await code.edit( + path, + RENAME_PACKAGE_TRANSFORMER, + { from, to }, + { + label: `Rename ${from}`, + }, + ); + if (!command) return undefined; + await command.do(); + await saved(path); + return { + undo: async () => { + await command.undo(); + await saved(path); + }, + isValid: async () => (await command.isValid?.()) ?? true, + }; + }, + }; + } + + private _project(): ClientProject | undefined { + return this._options.projects.current.get(); + } + + private _offerInstall( + name: string, + detail = 'A new app has no node_modules yet: install them before playing it.', + ): void { + if (!this._options.local) return; + this._options.notifications.notify('info', `${name} needs its dependencies`, { + detail, + actions: [{ title: 'Install dependencies', run: () => this.install() }], + timeout: 0, + }); + } + + /** One undoable step of the Project history; a refusal is shown with the files in the way. */ + private async _run( + label: string, + operation: (io: WorkspaceIo, project: ClientProject) => Promise, + ): Promise { + const project = this._project(); + if (!project) return false; + const io = this._io(project); + let undo: Undo | undefined; + try { + await this._stack.push({ + label, + do: async () => { + undo = await operation(io, project); + await project.fs.refresh(); + }, + undo: async () => { + await undo?.(); + await project.fs.refresh(); + }, + }); + return true; + } catch (error) { + const paths = error instanceof WorkspaceError ? error.paths : []; + this._options.notifications.notify('error', `${label}: not possible`, { + detail: `${error instanceof Error ? error.message : String(error)}${ + paths.length + ? ` (${paths.slice(0, 5).join(', ')}${paths.length > 5 ? `, and ${paths.length - 5} more` : ''})` + : '' + }`, + }); + return false; + } + } +} + +export const WorkspaceActionsToken = createToken('editor.workspaceActions'); diff --git a/apps/editor/src/routes/(editor)/+layout.svelte b/apps/editor/src/routes/(editor)/+layout.svelte new file mode 100644 index 00000000..7dc6e81f --- /dev/null +++ b/apps/editor/src/routes/(editor)/+layout.svelte @@ -0,0 +1,71 @@ + + + + NanoForge Editor + + +{#if editor} + {@render children()} +{:else} + +{/if} diff --git a/apps/editor/src/routes/(editor)/+page.svelte b/apps/editor/src/routes/(editor)/+page.svelte new file mode 100644 index 00000000..0f4558f7 --- /dev/null +++ b/apps/editor/src/routes/(editor)/+page.svelte @@ -0,0 +1,393 @@ + + +
+
+

NanoForge

+

+ {#if session.user}{session.user.name}{:else}Not signed in{/if} + {session.mode === 'OFFLINE' ? 'Local editor' : 'Online editor'} {session.version} +

+
+ + {#if session.mode === 'ONLINE' && !session.user} +
+

Sign in to open your projects

+

Your projects live on your NanoForge account.

+ +
+ {:else} +
+ {#if session.mode === 'OFFLINE'} +
{ + event.preventDefault(); + if (path.trim()) void goto(`/load?path=${encodeURIComponent(path.trim())}`); + }} + > + + +
+ + {/if} +
+ + {#if busy}

{busy}…

{/if} + {#if error}{/if} + + {#if gateways.length} +
+

Your projects

+
    + {#each gateways as project (project.gatewayId)} +
  • + +
  • + {/each} +
+
+ {/if} + +
+

Recent

+ {#if recent.length} +
    + {#each recent as project (project.id)} +
  • + + void forget(project)} + /> +
  • + {/each} +
+ {:else} +

Projects you open appear here.

+ {/if} +
+ {/if} +
+ + +
{ + event.preventDefault(); + if (draft.name && !nameError) void create(); + }} + > + + {#if nameError}

{nameError}

{/if} + +
+ + Multiplayer (adds a server app) +
+ +
+ + +
+ +
+ + diff --git a/apps/editor/src/routes/(editor)/load/+page.svelte b/apps/editor/src/routes/(editor)/load/+page.svelte new file mode 100644 index 00000000..291bb722 --- /dev/null +++ b/apps/editor/src/routes/(editor)/load/+page.svelte @@ -0,0 +1,178 @@ + + +{#if error} + +{:else} + +{/if} diff --git a/apps/editor/src/routes/(editor)/project/[id]/+page.svelte b/apps/editor/src/routes/(editor)/project/[id]/+page.svelte new file mode 100644 index 00000000..854f13f6 --- /dev/null +++ b/apps/editor/src/routes/(editor)/project/[id]/+page.svelte @@ -0,0 +1,206 @@ + + +{#if workbench} + + {#snippet brand()} + { + event.preventDefault(); + void goto('/'); + }}>NanoForge + {/snippet} + +{:else if error} + +{:else} + +{/if} + + diff --git a/apps/editor/src/routes/+layout.svelte b/apps/editor/src/routes/+layout.svelte new file mode 100644 index 00000000..d487f580 --- /dev/null +++ b/apps/editor/src/routes/+layout.svelte @@ -0,0 +1,19 @@ + + +{@render children()} + + diff --git a/apps/editor/src/routes/+layout.ts b/apps/editor/src/routes/+layout.ts new file mode 100644 index 00000000..83addb7e --- /dev/null +++ b/apps/editor/src/routes/+layout.ts @@ -0,0 +1,2 @@ +export const ssr = false; +export const prerender = false; diff --git a/apps/editor/src/routes/play/[id]/+page.svelte b/apps/editor/src/routes/play/[id]/+page.svelte new file mode 100644 index 00000000..89209774 --- /dev/null +++ b/apps/editor/src/routes/play/[id]/+page.svelte @@ -0,0 +1,76 @@ + + + + {app ? `${app.split('/').at(-1)} · ` : ''}NanoForge game + + +
+ {#if error} + + {/if} +
+ + diff --git a/public/favicon.svg b/apps/editor/static/favicon.svg similarity index 100% rename from public/favicon.svg rename to apps/editor/static/favicon.svg diff --git a/public/robots.txt b/apps/editor/static/robots.txt similarity index 100% rename from public/robots.txt rename to apps/editor/static/robots.txt diff --git a/apps/editor/svelte.config.js b/apps/editor/svelte.config.js new file mode 100644 index 00000000..ec36c1c5 --- /dev/null +++ b/apps/editor/svelte.config.js @@ -0,0 +1,15 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** + * The UI is a static single page app (served by the editor's Bun server, see server/main.ts): + * all server logic lives behind the RPC endpoint, never in SvelteKit. + */ +/** @type {import('@sveltejs/kit').Config} */ +export default { + preprocess: vitePreprocess(), + compilerOptions: { runes: true }, + kit: { + adapter: adapter({ pages: 'build', assets: 'build', fallback: 'index.html', strict: false }), + }, +}; diff --git a/apps/editor/test/plugin/shared-modules.spec.ts b/apps/editor/test/plugin/shared-modules.spec.ts new file mode 100644 index 00000000..ac4b9678 --- /dev/null +++ b/apps/editor/test/plugin/shared-modules.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SHARED_GLOBAL as KERNEL_GLOBAL } from '@nanoforge-dev/editor-kernel'; +import { MAIN_SHARED_MODULES, SHARED_GLOBAL } from '@nanoforge-dev/editor-vite-plugin'; + +import { SHARED_MODULE_NAMESPACES } from '../../src/lib/plugin/shared-modules'; + +vi.mock('@nanoforge-dev/editor-sdk/ui', () => ({})); + +describe('shared modules', () => { + it('provides exactly the modules plugins are built against', () => { + expect(Object.keys(SHARED_MODULE_NAMESPACES).sort()).toEqual([...MAIN_SHARED_MODULES].sort()); + }); + + it('uses the global name the plugin build rewrites to', () => { + expect(KERNEL_GLOBAL).toBe(SHARED_GLOBAL); + }); +}); diff --git a/apps/editor/test/workspace/workspace-actions.spec.ts b/apps/editor/test/workspace/workspace-actions.spec.ts new file mode 100644 index 00000000..bbcc2f54 --- /dev/null +++ b/apps/editor/test/workspace/workspace-actions.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { HistoryService } from '@nanoforge-dev/editor-history'; +import { + CommandRegistry, + Container, + CoreServices, + ObservableValue, +} from '@nanoforge-dev/editor-kernel'; + +import { WorkspaceActions } from '../../src/lib/workspace/workspace-actions'; + +const setup = () => { + const services = new Container('test'); + services.provide(CoreServices.Commands, new CommandRegistry(services)); + const history = new HistoryService(); + const current = new ObservableValue<{ id: string } | undefined>({ id: 'pong' }); + const actions = new WorkspaceActions({ + services, + projects: { current } as never, + rpc: {} as never, + history, + notifications: {} as never, + prompts: {} as never, + local: true, + }); + return { actions, history, current }; +}; + +describe('WorkspaceActions', () => { + it('starts a new Project history when another project opens', async () => { + const { actions, history, current } = setup(); + const stack = history.get('project')!.stack; + await stack.push({ label: 'Add app', do: () => {}, undo: () => {} }); + expect(stack.state.canUndo).toBe(true); + + current.set({ id: 'pong' }); + await Promise.resolve(); + expect(stack.state.canUndo).toBe(true); + + current.set({ id: 'breakout' }); + await expect.poll(() => stack.state.canUndo).toBe(false); + actions.dispose(); + }); +}); diff --git a/apps/editor/tsconfig.json b/apps/editor/tsconfig.json new file mode 100644 index 00000000..bff00c5c --- /dev/null +++ b/apps/editor/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "moduleResolution": "bundler", + "skipLibCheck": true, + "types": ["bun"] + } +} diff --git a/apps/editor/vite.config.ts b/apps/editor/vite.config.ts new file mode 100644 index 00000000..089254dc --- /dev/null +++ b/apps/editor/vite.config.ts @@ -0,0 +1,93 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { type ChildProcess, spawn } from 'node:child_process'; +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { type Plugin, defineConfig, searchForWorkspaceRoot } from 'vite'; + +import pkg from './package.json' with { type: 'json' }; + +const API_PORT = Number(process.env.EDITOR_API_PORT ?? 5174); + +/** Built-in plugins run as dev plugins in dev: rebuilt by `vite build --watch`, hot reloaded. */ +const PLUGINS_ROOT = join(import.meta.dirname, '../../plugins'); +const builtInPlugins = existsSync(PLUGINS_ROOT) + ? readdirSync(PLUGINS_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(PLUGINS_ROOT, entry.name)) + : []; + +/** + * Dev only: runs the editor's Bun server (RPC, WebSocket, plugins) next to Vite, which proxies + * those routes to it. `bun --watch` restarts it when server code changes. + */ +const editorServer = (): Plugin => { + let child: ChildProcess | undefined; + return { + name: 'nanoforge-editor-server', + apply: 'serve', + configureServer(server) { + child = spawn('bun', ['--watch', 'server/main.ts'], { + stdio: 'inherit', + env: { + ...process.env, + NODE_ENV: 'development', + PORT: String(API_PORT), + EDITOR_NO_STATIC: '1', + FS_ROOT: process.env.FS_ROOT ?? process.env.INIT_CWD ?? process.cwd(), + DEV_PLUGINS: [...builtInPlugins, process.env.DEV_PLUGINS ?? ''].filter(Boolean).join(','), + }, + }); + server.httpServer?.once('close', () => child?.kill()); + }, + }; +}; + +/** + * `ANALYZE=1 pnpm build`: prints, for each big chunk of the client build, its heaviest modules, + * and how TypeScript got into a chunk when it did (it belongs to the code worker only). + */ +const analyzeBundle = (): Plugin => ({ + name: 'nanoforge-analyze-bundle', + apply: 'build', + generateBundle(_options, bundle) { + const short = (id: string) => + id.replace(/^.*\/node_modules\/(\.pnpm\/[^/]+\/node_modules\/)?/, ''); + for (const chunk of Object.values(bundle)) { + if (chunk.type !== 'chunk' || chunk.code.length < 200_000) continue; + const modules = Object.entries(chunk.modules) + .map(([id, module]) => ({ id, size: module.renderedLength })) + .sort((a, b) => b.size - a.size); + console.log(`\n${chunk.fileName}: ${(chunk.code.length / 1024).toFixed(0)} KiB`); + for (const module of modules.slice(0, 6)) + console.log(` ${(module.size / 1024).toFixed(0).padStart(6)} KiB ${short(module.id)}`); + const compiler = modules.find(({ id }) => /node_modules\/(typescript|@ts-morph)\//.test(id)); + if (!compiler || chunk.fileName.includes('worker')) continue; + const chain = [compiler.id]; + for (let id = compiler.id; chain.length < 12;) { + const importer = this.getModuleInfo(id)?.importers.find((next) => !chain.includes(next)); + if (!importer) break; + chain.push(importer); + id = importer; + } + console.log( + ` TypeScript is in this chunk through:\n ${chain.map(short).join('\n <- ')}`, + ); + } + }, +}); + +export default defineConfig({ + plugins: [sveltekit(), editorServer(), ...(process.env.ANALYZE ? [analyzeBundle()] : [])], + define: { __EDITOR_VERSION__: JSON.stringify(pkg.version) }, + worker: { format: 'es' }, + server: { + fs: { allow: [searchForWorkspaceRoot(process.cwd())] }, + proxy: { + '/rpc/ws': { target: `ws://127.0.0.1:${API_PORT}`, ws: true }, + '/rpc': { target: `http://127.0.0.1:${API_PORT}` }, + '/plugins': { target: `http://127.0.0.1:${API_PORT}` }, + '/runtime': { target: `http://127.0.0.1:${API_PORT}` }, + '/healthz': { target: `http://127.0.0.1:${API_PORT}` }, + }, + }, +}); diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 50fe89a0..00b811e2 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -1,4 +1,34 @@ +const PACKAGES = [ + 'editor', + 'kernel', + 'sdk', + 'rpc', + 'history', + 'settings', + 'layout', + 'meta', + 'ui', + 'project', + 'code', + 'runtime', + 'server-core', + 'registry', +]; + +const { existsSync, readdirSync } = require('node:fs'); +const { join } = require('node:path'); + +/** Built-in plugins (`plugins/`) are scopes too. */ +const PLUGINS_DIR = join(__dirname, 'plugins'); +const PLUGINS = existsSync(PLUGINS_DIR) ? readdirSync(PLUGINS_DIR) : []; + module.exports = { extends: ['@commitlint/config-conventional'], - rules: {}, + rules: { + 'scope-enum': [ + 1, + 'always', + [...PACKAGES, ...PLUGINS, 'deps', 'ci', 'release', 'docs', 'tooling', 'specs'], + ], + }, }; diff --git a/components.json b/components.json deleted file mode 100644 index a74b7b46..00000000 --- a/components.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://shadcn-svelte.com/schema.json", - "style": "nova", - "aliases": { - "lib": "$lib", - "components": "$lib/components/ui", - "hooks": "$lib/hooks", - "utils": "@utils/ui" - }, - "tailwind": { - "baseColor": "neutral", - "css": "src/app.css" - }, - "typescript": true -} diff --git a/docs/adr/0001-plugin-ui-runtime.md b/docs/adr/0001-plugin-ui-runtime.md new file mode 100644 index 00000000..07e2b57c --- /dev/null +++ b/docs/adr/0001-plugin-ui-runtime.md @@ -0,0 +1,59 @@ +# ADR 0001 — Plugin UI runtime: shared modules through a build-time rewrite + +- Status: accepted (phase 1.1 spike, GO) +- Date: 2026-09-26 + +## Context + +Editor plugins are built separately from the editor (bundled, marketplace or local-dev plugins) +and render Svelte 5 widgets inside the editor. A compiled Svelte component imports +`svelte/internal/client`. If a plugin bundled its own copy of the runtime, its widgets would not +share reactivity, contexts or effect scheduling with the host. + +The plan's first idea was an **import map** that points `svelte/*` to the host's copy. It does +not work reliably: in a SvelteKit/Vite production build, the runtime is inlined into hashed +chunks with no stable URL. In dev it is served from `.vite/deps` with a version hash. Shims +re-exporting the runtime would need the exact export names of every module. + +## Decision + +Plugins are built with `@nanoforge-dev/editor-vite-plugin` (`tooling/vite-plugin`): + +- Shared modules (`SHARED_MODULES`: the Svelte client modules and `@nanoforge-dev/editor-sdk`) + are externalized. +- In `renderChunk`, every import of a shared module is rewritten into a lookup: + `globalThis.__nanoforge_editor_shared__.require('')`. This covers namespace, + named, default and dynamic imports. Re-exports are rejected. +- Side-effect-only modules the host already evaluated (`disclose-version`, `flags/tracing`) are + dropped. +- Modules that would flip global runtime flags (`flags/legacy`, `flags/async`) are a build + error, so **plugins must compile in runes mode**. + +The host fills a `SharedModuleRegistry` (kernel) with its own module namespaces and installs it +before importing any plugin (`apps/editor/src/lib/plugin/shared-modules.ts`). Plugin output +stays plain ESM with relative chunks, and nothing depends on the host's chunk names. + +## Verification + +_Update (phase 5): the spike route, fixture and check script were replaced by the end-to-end test `apps/editor/e2e/workbench.spec.ts` ("runs a local plugin built against the shared runtime"), which loads `tooling/example-plugin` in the real editor._ + +`tooling/spike-plugin` (an externally built widget) is rendered by `/__spike` as a child +component of a host component. `apps/editor/e2e/plugin-runtime.check.mjs` checks all of these, +in **dev (vite) and prod (bun build)**, and both pass: + +- a context set by the host is readable by the plugin (`getContext`); +- the host's `$state` passed as a prop drives the plugin's `$derived`, and the plugin's mutations + are seen by the host; +- the plugin's local `$state` works; +- scoped CSS applies; +- there are no console errors. + +## Consequences + +- Compiled Svelte output depends on the runtime's internal API. A plugin compiled with a newer + Svelte than the host may call internals the host lacks. The build will record the Svelte + version in the built manifest, and the plugin host will require + `same major && compiled <= host` (phase 1.13). +- Plugins must be built with the NanoForge vite plugin (`nf create plugin` sets this up). +- Fallback B from the plan (framework-agnostic `mount(el)` widgets) stays supported for + non-Svelte widgets. It is not needed as a fallback. diff --git a/docs/adr/0002-editor-server.md b/docs/adr/0002-editor-server.md new file mode 100644 index 00000000..62e6b6b7 --- /dev/null +++ b/docs/adr/0002-editor-server.md @@ -0,0 +1,51 @@ +# ADR 0002 — Editor server: Bun RPC server + static SvelteKit UI + +- Status: accepted (phase 2) +- Date: 2026-09-26 + +## Context + +The previous editor ran all server logic through SvelteKit form actions and hooks +(`svelte-adapter-bun`). The rewrite needs: + +- a typed RPC API shared with plugins' server entries; +- a WebSocket for file changes, model updates and dev plugin reloads; +- plugin file serving. + +`nf editor` must still start one process (`bun dist/index.js`). + +## Decision + +- **UI**: SvelteKit builds a static single-page app (`@sveltejs/adapter-static`, SPA fallback, + `ssr = false`). SvelteKit only handles routing and rendering. +- **Server**: `apps/editor/server/main.ts` runs `Bun.serve` around `createEditorServer` + (`packages/server-core`). The server is transport-agnostic and testable in Node. It serves: + - `POST /rpc/.`: typed calls, zod-validated, binary-safe JSON; + - `GET /rpc/ws`: one multiplexed WebSocket for stream subscriptions; + - `GET /plugins//@///*`: plugin files; + - the built UI with an SPA fallback, and `/healthz`. +- **Dev**: `vite dev` starts the Bun server (`bun --watch`) through a Vite plugin and proxies + `/rpc`, `/rpc/ws` and `/plugins` to it. +- **Contracts**: `packages/protocol` holds the contracts shared by client and server. Plugins + implement their own under the namespace `plugin.`. + +## Security + +- Every RPC call and WebSocket upgrade is checked against the `Origin` header, so a web page on + another origin cannot drive a local editor. OFFLINE editors bind to `127.0.0.1` by default. +- Project paths are jailed to `FS_ROOT`, and file paths to the project root, including through + symlinks. Deletes go to `.nanoforge/editor/trash`. +- **Config loading**: + - OFFLINE executes `nanoforge.config.*` with `unrun`, like the CLI does. Everything but Node + built-ins is bundled (unrun imports from `/node_modules/.unrun`), and + `@nanoforge-dev/config` is a virtual module, so fresh clones load. + - ONLINE never executes project code: configs are read statically, literal values only. +- ONLINE sessions reuse the `accessToken`/`refreshToken` cookies set by the NanoForge projects + website. The API client refreshes them through `POST /auth/refresh-token`. + +## Consequences + +- SvelteKit server features (load functions, form actions) are not used. +- Any server feature is an RPC contract, which plugins can also provide. +- Hosted (ONLINE) deployments still run `nf` CLI commands on project code, as the previous + editor did. They must isolate projects (containers) until a sandbox exists. diff --git a/docs/adr/0003-component-and-system-docs.md b/docs/adr/0003-component-and-system-docs.md new file mode 100644 index 00000000..1fd8ac66 --- /dev/null +++ b/docs/adr/0003-component-and-system-docs.md @@ -0,0 +1,177 @@ +# ADR 0003 — Items: TSDoc in the source, editor-generated meta, owner-keyed data + +- Status: accepted (2026-09-27). The field-level schema is a draft and may change. +- Schema: `docs/drafts/item-schema.ts` (zod, every value commented) +- Visual summary: https://claude.ai/artifact/RNu5aEEUCBcPodtAnbtdNJ +- Example: `engine/example/pong-network/nf_modules/@nanoforge/` and its + `.nanoforge/editor/cache/meta/` + +## Context + +The editor needs to know, for each thing a package provides (today: ECS components and systems): +its name, what it does, its parameters (types, defaults), which side it runs on, and the data a +specific lib needs (for ECS: the registry name, the components a system queries, the `ctx` libs it +uses). + +The previous plan read `EDITOR_COMPONENT_MANIFEST` / `EDITOR_SYSTEM_MANIFEST` literals +(`engine/modules/ecs/src/shared/editor-manifest.type.ts`). They duplicate the code and drift from it. + +Dependencies will be packages installed in the read-only `nf_modules/`. The editor must read them +without scanning folders. + +What the real code forces: + +- Components are classes with **positional constructors** (`new Velocity(x, y)`) and a + `name = "X"` field that the ECS registry keys on. +- Parameters can be **engine objects** (`Rect`, `Circle` from graphics-2d) or **engine enums** + (`InputEnum`), not just primitives. +- Client and server sets differ: every item has a **side**. + +## Decision + +### Names + +Every name in our registry is `@scope/name`, like a scoped npm package: packages, editor plugins, +owner keys, and the prefix of plugin settings (`@nanoforge/viewport.zoom`). The editor enforces it +(`PLUGIN_NAME_PATTERN`). The CLI is not changed yet. + +### One shape at every level + +The manifest, each item, and each element (a parameter or a field) have three parts: + +1. **Core fields**, understood by the editor with no plugin: names, source, description, + TypeScript types, `side`. +2. **`requires`**: the npm libs needed to use it (manifest: `libDependencies`, `npmDependencies`; + item: the libs its source imports; element: implied by `ref` / `enumRef`). +3. **Owner objects**: extra keys named after an editor plugin (`"@nanoforge/ecs": { … }`). Only that + plugin parses them. Core fields never contain `/`, so they can't clash with owner keys, and any + other unknown key is an error. + +Nothing in the core is shaped for one lib. There is no `component` or `system` type: an ECS +component is an item with `"@nanoforge/ecs": { "type": "component", "name": "Position" }`. + +The core never looks inside an owner object. An item whose owner plugin isn't installed stays in +the catalog as a generic item. + +### Manifests + +`nanoforge.manifest.json`: + +- `type`: `package` or `plugin`. The two are separate: a package never holds a plugin. +- `contents`: what the package holds, whatever its folder layout: `{ kind, path, export?, name?, +description?, side?, tags? }` plus owner objects. `kind` is a core kind (`asset`, `type`, `enum`…) + or one owned by a plugin (`@nanoforge/ecs:component`); `path` is a file, or a folder ending with + `/`. The registry counts them by kind (`kinds`) and lets people search by kind. +- `items`: source files, relative to the manifest, whose exports can be items. Kept for packages + written before `contents`: the code files `contents` points to are item sources too. +- `include`: sub-manifests (amended 2026-10-11, to the registry's contract): folders (their + `nanoforge.manifest.json`) or `.json` files, relative to the manifest listing them. A sub-manifest + **only describes contents** (`contents`, `include`, owner objects), its paths relative to its + folder: the package's name, version, dependencies and suggested plugins are the root manifest's. + The catalog walks `include`, it never scans. An include stays inside the package (no `..`, no + absolute path), nesting stops at 4 levels and 100 sub-manifests. +- `suggestedPlugins`: editor plugins that make the package easier to use, as `["@scope/name"]` or + `{ "@scope/name": "^1.0.0" }`. +- `dependencies` (our registry), `libDependencies`, `npmDependencies`, `publish` as before. +- Owner objects for package-level data. + +**Where things are installed.** + +| What | Where | Loaded | +| -------- | ---------------------------------------------- | -------------------------- | +| Packages | `/nf_modules/@scope/name` (read-only) | by the catalog | +| Plugins | `~/.nanoforge/editor/plugins/@scope/name` | in every project | +| Plugins | `/.nanoforge/plugins/@scope/name` | while that project is open | + +A project's plugin shadows the same plugin installed in the home folder (then come dev plugins). +Its server entry only runs in a local editor: a hosted editor never runs project code on the +server. + +**Suggested plugins.** When a project opens, the editor collects the `suggestedPlugins` of the +packages in `nf_modules` (their root manifests) and names the ones that aren't installed in a +notification, once per project. Installing is manual for now. + +An editor plugin that owns a key declares it in its manifest, so the editor can suggest it when it +meets one of its tags: + +```json +"contributes": { "itemOwner": { "schema": 1, "tags": ["component", "system", "query", "uses"] } } +``` + +### Docs are TSDoc in the source + +The `.ts` file is the only source of truth. + +- **Core tags:** the summary, `@remarks`, `@param`, `@example`, `@side client|server|shared` + (default `shared`), `@asset [param] [.ext…]`, `@deprecated`, `@internal`. +- **Core layout tags** (how params look in the inspector, see `docs/specs/ecs.md`, _Param layout_): + on the item, `@group [color=] [hidden] - `; on a param (its inline doc, + or a field's doc), `@group `, `@preset [.]`, `@label `, `@color `, + `@hidden`. They are core, not owner tags: any owner plugin's inspector can use them. +- **Owner tags** are declared by the owner plugin and read only by it. `@nanoforge/ecs` declares + `@component`, `@system`, and `@query` / `@uses` (overrides when the analyzer can't infer them). + Two installed plugins claiming the same tag is an error that names both. +- **Inferred from code:** types, defaults, optional, string literal unions (`enum`), enum types + (`enumRef`), lib classes (`ref`), imports (`requires`). The ECS plugin infers the registry `name`, + `query` (each `registry.getZipper([...])`) and `uses` (each `ctx.`). +- An owner **claims** members it describes itself, which are then left out of `params` / `fields`: + ECS claims a component's `name` field and a system's `(registry, ctx)` parameters. + +An export is an item when it's in a code file a package's `contents` points to (or one under a +folder it points to) or lists in `items`, or, in the project's apps (no manifest), when an owner tag +is on it. + +### Elements + +Core types describe TypeScript, not a lib: `string` (`enum`, `enumRef`), `number`, `boolean`, +`asset` (a project file path, `accept`), `array` (`items`), `object` (`properties`), `ref` +(`"@nanoforge-dev/graphics-2d#Rect"`), `unknown` (`tsType`, edited as code). The default is +`default` when it's a JSON value, `defaultCode` (source text) otherwise. A `ref` is edited by the +plugin that contributes an editor for it; otherwise it's shown as code. + +### The editor generates the meta, into its cache only + +Owner data can only come from owner plugins, and they run in the editor. So the editor is the +only producer of metadata: + +- **Extraction runs in the code worker**, in its own package (`@nanoforge-dev/editor-meta`) so it + can later run without the UI. The core extractor produces the core parts; each owner plugin + registers, from its `entry.worker`, its key, tags, claimed members, a zod schema and `extract()`. +- **The meta lives only in `.nanoforge/editor/cache/meta/`** (git-ignored), one file per source: + `modules/@scope/name.json` (`nf_modules`), `packages/@scope/name.json` (the project's own + packages), `apps/.json`. It is never published and never written next to the sources. + `nf_modules` stays untouched. +- **Generated at init when missing, regenerated when stale:** when `sourceHash`, `metaVersion`, + `generator.version` or the installed owners (added, removed, other `version` or `schema`) differ. +- **Owner plugin missing:** its tags are kept raw in the item's `unclaimedTags`, with a warning. A + regeneration without the plugin doesn't produce its owner object, so the object disappears. It + comes back when the plugin is installed (the owners changed, so the meta is regenerated). +- **`nf publish`** has nothing to do with the meta. + +### Catalog + +Core owns a generic, reactive **catalog**: items by `ItemRef` (`@nanoforge/motion#Position`, or +`app:client#Paddle` for an app), from providers (`nf_modules`, project packages, project apps). +Plugins build views on it: the ECS plugin's component and system registries are the items whose +`@nanoforge/ecs.type` is `component` or `system`. + +## Open questions + +1. ~~**Imports into `nf_modules`.**~~ Decided in ADR 0004: by package name, through the root `tsconfig.json` `paths`. + **Was:** The example uses relative paths across packages + (`../../motion/components/position`). Should there be an alias (`@nf///…`), set up + by `nf install` in tsconfig and the build? +2. ~~**Name collisions.**~~ Decided in ADR 0005: plain names stay, the app's item wins and the editor warns. + **Was:** The ECS registry keys on `name`: `@nanoforge/motion#Position` and the + app's own `Position` would clash. Namespaced names, or a rule that forbids it? +3. ~~**Root index.**~~ Decided in ADR 0005: `nanoforge.packages.json` and its lock list the packages; the catalog still reads `nf_modules/@//`. + **Was:** Should `nf install` write a list of the installed packages (like a lockfile), so + the catalog has one entry point, or does the catalog read `nf_modules/@//`? +4. ~~**Sub-manifests.**~~ Decided in ADR 0005: every package is published and installable alone; a bigger one lists the others in `dependencies`. + **Was:** Can `@nanoforge/shapes` be installed alone, or only as part of `render-2d`? +5. **Engine libs' own items.** Do graphics-2d or network ship items the same way? +6. ~~**Editor hints.**~~ Partly decided: layout tags are core (groups, presets, labels, colors, + hidden). Still open: value hints such as `@min`, `@max`, `@step`. + **Was:** Tags for the inspector (`@min`, `@max`, `@step`, `@color`): core, or owner + tags? +7. **Headless generation** (later): the `@nanoforge-dev/editor-meta` package run by a command. diff --git a/docs/adr/0004-shared-libraries.md b/docs/adr/0004-shared-libraries.md new file mode 100644 index 00000000..ae4d47b3 --- /dev/null +++ b/docs/adr/0004-shared-libraries.md @@ -0,0 +1,141 @@ +# ADR 0004 — Shared libraries: components and systems shared by several apps + +- Status: accepted (2026-09-27) +- Related: ADR 0003 (items, packages, plugins), `docs/specs/ecs.md` + +## Context + +A NanoForge project has several apps (usually `client` and `server`), each with its own +`src/components` and `src/systems`. A game needs components that both sides use (`Position`, +`Velocity`, a `Player` with its id). Today they are copied into each app, and the copies drift. + +The CLI already knows a workspace member of `type: "lib"` in `nanoforge.config.ts`: a folder with +`dir.components`, `dir.systems`, `dir.assets`, `dir.scenes` and no entry file. The editor's project +discovery already lists libs, and the build already skips them (their code is bundled into the apps +that import it). + +The user wants, in the editor, to create components and systems shared between client and server. +Publishing is out of scope: a lib stays in its project. + +## Decision + +### A shared library is a lib workspace member + +``` +nanoforge.config.ts { type: "workspace", packages: ["apps/*", "libs/*"] } +libs/shared/ + nanoforge.config.ts { type: "lib", dir: { components: "src/components", … } } ← the CLI defaults are shared/… + package.json { "name": "@pong-network/shared", "private": true } + src/components/player.ts /** @component @side shared */ + src/systems/score.ts /** @system @side server */ +``` + +- No `nanoforge.manifest.json`: a lib isn't published, so it needs no registry identity. Its name + is its `package.json` name. +- Items are found like in an app: the exports of `dir.components` / `dir.systems` that an owner tag + claims (`@component`, `@system`). +- **Side is per item**, not per lib: one lib holds client, server and shared items. An app is offered + the items whose `@side` matches it and whose required engine libraries it has. +- A lib may import engine libraries (`@nanoforge-dev/*`), installed packages (`nf_modules`) and other + libs. It never imports an app. The editor reports an import of an app, and a cycle between libs. + +### Imports use the package name + +Apps import items from a lib, and from an installed package, by package name: + +```ts +import { Position } from '@nanoforge/motion/components/position'; +import { Player } from '@pong-network/shared/components/player'; +``` + +- The project's root `tsconfig.json` maps them with `paths`: + `"@pong-network/shared/*": ["libs/shared/src/*"]` and + `"@nanoforge/motion/*": ["nf_modules/@nanoforge/motion/*"]`. The editor writes the lib entry when it + creates a lib; package entries come with installing (not built yet). +- The code worker resolves through the same `paths`, so the editor's diagnostics, completion and the + codegen's import edits all agree. +- Moving an item between a lib and a package never changes the importing code's shape. +- This answers ADR 0003's open question 1 (imports into `nf_modules`). +- To check while building: that `nf build` honors `paths`. If it doesn't, fall back to a + `workspace:*` dependency plus an install run by the editor. + +### Items and catalog + +- Item references: `#`, e.g. `@pong-network/shared#Player`. Apps keep + `app:#`. +- The meta of a lib is cached like a project package's: + `.nanoforge/editor/cache/meta/libs/.json`. +- The catalog has a `lib` provider next to `app` and `module` (`nf_modules`). Lib items are editable; + package items are read-only. +- ECS name collisions: the ECS plugin warns when two items that the same app can use share a + registry `name` (an app's `Position` and a lib's `Position`). + +### Words in the UI + +"Lib" is already used in the editor for engine libraries (`engineLibs`), so the UI always says which +kind it means. Code and config keep `lib`, as the CLI does. + +| Thing | UI name | Example | +| -------------------------------------------- | --------------------- | ------------------------------------- | +| An app (`apps/client`) | **App** | App _client_ | +| A lib workspace member (`libs/shared`) | **Shared library** | Shared library _shared_ | +| An installed package (`nf_modules`) | **Installed package** | Installed package _@nanoforge/motion_ | +| An npm engine library (`@nanoforge-dev/ecs`) | **Engine library** | Engine library _ecs_ | + +The component and system browser groups items under those headings: the selected app first, then +each shared library, then installed packages (with a lock). + +### Editor features (phase 10, ECS plugin and core) + +- **New shared library** (command, and a button in the browser): asks for a folder name (default + `shared`) and the package name (default `@/`). It creates `libs//` + (`nanoforge.config.ts`, `package.json`, `src/components`, `src/systems`), adds `libs/*` to the + workspace `packages` when missing, and adds the `paths` entry. One undoable step. +- **New component / New system** ask where: the selected app (default) or a shared library. In a + shared library, `@side` defaults to `shared`. +- **Move to shared library**: moves a component or system from an app into a lib, rewrites every + import in the project, one undoable step. +- **Copy to…** on an installed package's item copies it into an app or a shared library. +- Adding a lib item to an entity or an app adds the package-name import. +- While playing, a change in a lib rebuilds every app that imports it (the build orchestrator + follows imports). + +### Which apps use a library, and managing them (2026-10-03) + +- **An app uses a library when its `nanoforge.config` lists it in `libs`**: paths relative to + the app, the field the CLI's config defines for this (`libs: ["../../libs/shared"]`). + `AppModel.libraries` holds the project's libraries an app uses. + - The editor keeps the app's `package.json` in step (`"@pong-network/shared": "workspace:*"`, + for package managers): every change writes both. Where they disagree (an edit by hand), `libs` + wins and the project shows a warning that says what to add. + - A library's config has no `libs`: a library uses another through its `package.json`. + - Until 2026-10-03 the editor read `libs` as engine library names, which the CLI never meant. + The editor reads engine libraries from `package.json` only. +- An app is only offered the items of the libraries it uses (`itemFitsApp`). An app that imports a + library it does not use is reported, with the fix named. +- **A library that no app lists is given to every app**, so a project made before this rule keeps + working. The Project screen says so; the first tick or untick writes `libs` and the dependencies down. +- `nf build` (Bun) honors the root `paths`, checked on `examples/pong-game`: the dependency is a + declaration, and nothing needs installing for a library to build and play. +- The **Project screen** manages the workspace: add, rename and remove client apps, server apps and + shared libraries, and tick the apps that use each library. Every change is one step of the + _Project_ history. The operations are `packages/project/src/workspace/` (in the SDK), which + the ECS plugin's _New shared library…_ uses too. + - Renaming a library rewrites its `paths` entry, the dependencies on it and every import of it. + - Removing a library is refused while a file imports it; the files are named. + - A new app is empty (engine libraries at the versions of another app) or a copy of an app of + its type. An empty app needs its dependencies installed: the editor offers to run the + project's package manager, found from its lockfile (`projects.install`, local editors only). + - The last app of a project can't be removed. +- **Several clients or servers**: Play starts one client and one server. With two or more of a + type, the Run menu gets a _Client app_ or _Server app_ submenu (`runtime.clientApp`, + `runtime.serverApp`, kept with the project for this machine). With one of each, the menu is + unchanged. +- Not built: two servers of one project running at once, several clients side by side in the + editor (a second client opens in its own window), publishing a library as a package. + +## Consequences + +- Client and server share one definition of each shared component, which network code needs. +- No publishing: turning a lib into a package later means adding a `nanoforge.manifest.json`. +- The root `tsconfig.json` becomes something the editor edits (only its `paths` entries). diff --git a/docs/adr/0005-package-installs.md b/docs/adr/0005-package-installs.md new file mode 100644 index 00000000..c053163d --- /dev/null +++ b/docs/adr/0005-package-installs.md @@ -0,0 +1,18 @@ +# ADR 0005: installing packages and plugins + +Date: 2026-10-01. Status: accepted. It settles the install questions ADR 0003 left open. + +## Decisions + +1. **Who installs.** The editor server, with a package that has no editor dependency (`@nanoforge-dev/registry`). It is meant to move to the CLI repository, where `nf install` will use it too. +2. **The project's list.** `nanoforge.packages.json` holds the packages a project asks for, with version ranges. `nanoforge.packages.lock.json` holds what is installed: exact versions, archive hashes, and each package's own dependencies. `nf_modules` can be restored from the lock, so it may be left out of git. The editor does not edit the project's `.gitignore`: that choice is the project's. +3. **Sub-packages.** Each package is published on its own and can be installed alone. A package that needs others lists them in `dependencies` (`{ "@scope/name": "^1.0.0" }`); they are installed side by side in `nf_modules/@scope/name`, one version each. `include` stays for folders inside one published package. +4. **Version conflicts.** One version of a package per project. When two ranges have no version in common, the install is refused and names both. +5. **Name collisions of items.** Plain names stay. The app's own item wins over a package's and the editor warns. Refusing installs on collisions between two packages is left for when the registry knows item names. +6. **The registry.** Its HTTP contract is in `docs/api/registry.md`, with routes after the npm registry's. Until the backend exists, the editor runs on a registry read from a folder. + +## Still open + +- Engine libraries: npm, or the registry. +- Inspector value hints (`@min`, `@max`, `@step`). +- Headless meta generation. diff --git a/docs/adr/0006-source-layout.md b/docs/adr/0006-source-layout.md new file mode 100644 index 00000000..d1a3b5ea --- /dev/null +++ b/docs/adr/0006-source-layout.md @@ -0,0 +1,127 @@ +# ADR 0006 — Source layout and naming + +- Status: accepted (2026-10-04), piloted on `packages/settings` and `plugins/git` +- Related: the engine (`../engine`, `origin/main`) and the CLI (`../cli`, `origin/main`), whose + layout this follows + +## Context + +The editor grew one phase at a time, and each workspace got its own habits: + +- tests next to the code (`packages/kernel/src/**/*.test.ts`), or in `test/` with flat names; +- flat `src/` folders of 15 files (`packages/settings`, `plugins/git`), or feature folders + (`packages/server-core`); +- Svelte components at the root of a plugin's `src/`, or in `widgets/`; +- files of 500 to 800 lines holding several classes or views. + +The engine and the CLI already share one layout: feature folders with an `index.ts`, kebab-case +files named after what they export, a suffix for each kind of file, and tests in `test/` named +`.spec.ts`. The editor follows them. + +## Decision + +### Workspace + +``` +/ + src/ + index.ts the entry: a barrel (packages), or definePlugin (plugins) + / + index.ts re-exports the feature's public files + .ts + /..ts a family of files of one kind + test/ + /.spec.ts mirrors src/ + fixtures/ +``` + +- **Feature folders.** `src/` holds `index.ts` and folders. Each folder is one feature, named in + kebab-case and in the singular (`store`, `account-sync`, `definition`), like the engine's + `application`, `library-registry`, `exception`. +- **Barrels.** A folder's `index.ts` re-exports what leaves the folder. A package's `src/index.ts` only + re-exports its folders (`export * from './store'`), with no code of its own. Files inside a + workspace import each other by path, never through their own package's barrel. +- **Subpath entries** (`editor-code/engine`, `editor-meta/pure`, `editor-sdk/ui`, registry `/types`) + are files at the root of `src/` next to `index.ts`, named after the subpath. +- **Families.** Implementations of one interface are named `..ts` for a class + ``, like the CLI's `build.action.ts` and the engine's `tcp.client.network.ts`: + `MemoryScopeStore` → `memory.scope-store.ts`, `IndexedDbKeyValueStore` → + `indexed-db.key-value-store.ts`. They stay in their feature folder; a plural sub-folder + (`actions/`) only when the family is large and the folder holds other files. + +### Files + +- **kebab-case**, named after the main export: `SettingsService` → `settings-service.ts`, + `createActions` → `create-actions.ts`. One main export per file; small helpers that only it uses + stay with it. +- **Kind suffixes**, after the engine and the CLI: + + | Suffix | Holds | + | --------------- | --------------------------------------------------------------- | + | `.type.ts` | types and interfaces only, no values | + | `.enum.ts` | an enum, or an `as const` list and its union type | + | `.exception.ts` | an `Error` subclass (several go in `exceptions/`) | + | `.abstract.ts` | an abstract class | + | `.factory.ts` | a function that picks and builds one of several implementations | + | `.const.ts` | constants shared by several files of the feature | + | `..ts` | a member of a family: `.scope-store.ts`, `.contract.ts`… | + +- **Tokens** (`createToken`) live in the file of the class or interface they inject. +- **Svelte components** are kebab-case when an `index.ts` re-exports them (the kit: + `button.svelte`), PascalCase otherwise (a plugin's `CommitView.svelte`). Rune modules are + `.svelte.ts`. +- **Size.** A file that passes about 300 lines (a Svelte file's ` diff --git a/examples/counter-plugin/src/counter.ts b/examples/counter-plugin/src/counter.ts new file mode 100644 index 00000000..3639b1e6 --- /dev/null +++ b/examples/counter-plugin/src/counter.ts @@ -0,0 +1,4 @@ +import { ObservableValue } from '@nanoforge-dev/editor-sdk'; + +/** The plugin's state, shared by its command and its panel. */ +export const count = new ObservableValue(0); diff --git a/examples/counter-plugin/src/index.ts b/examples/counter-plugin/src/index.ts new file mode 100644 index 00000000..c53f32f5 --- /dev/null +++ b/examples/counter-plugin/src/index.ts @@ -0,0 +1,52 @@ +import type { Component } from 'svelte'; + +import { HistoryServiceToken, SettingsServiceToken, definePlugin } from '@nanoforge-dev/editor-sdk'; +import { StyleServiceToken, WIDGET_VIEWS, type WidgetInstance } from '@nanoforge-dev/editor-sdk/ui'; + +import Panel from './Panel.svelte'; +import { count } from './counter'; + +/** Undo and redo target of the panel (see Panel.svelte). */ +export const HISTORY = 'counter'; + +export default definePlugin({ + async activate(context) { + const { services } = context; + + // The panel's styles are built into counter.css: the plugin injects them when it starts. + const css = await fetch(context.resolveAsset('counter.css')).then((response) => + response.ok ? response.text() : '', + ); + context.subscriptions.add(services.get(StyleServiceToken).inject(context.name, css)); + + // The view of the widget declared in the manifest. + context.subscriptions.add( + context.contribute(WIDGET_VIEWS, { + id: 'counter.panel', + component: Panel as Component<{ instance: WidgetInstance }>, + }), + ); + + // A history of its own: each addition can be undone with Ctrl+Z while the panel has focus. + const history = services.get(HistoryServiceToken).registerContext({ + id: HISTORY, + label: 'Counter', + owner: context.name, + }); + context.subscriptions.add(history); + + // The command declared in the manifest. Settings keys carry the plugin's name. + const settings = services.get(SettingsServiceToken); + context.subscriptions.add( + context.registerCommand('counter.add', async () => { + const step = Number(settings.get('@acme/counter.step')) || 1; + await history.stack.push({ + label: `Add ${step}`, + do: () => count.set(count.get() + step), + undo: () => count.set(count.get() - step), + }); + context.logger.info(`Counter: ${count.get()}`); + }), + ); + }, +}); diff --git a/examples/counter-plugin/svelte.config.js b/examples/counter-plugin/svelte.config.js new file mode 100644 index 00000000..ebe8f7ab --- /dev/null +++ b/examples/counter-plugin/svelte.config.js @@ -0,0 +1,2 @@ +/** Plugins compile Svelte in runes mode, like the editor. */ +export default { compilerOptions: { runes: true } }; diff --git a/examples/counter-plugin/tsconfig.json b/examples/counter-plugin/tsconfig.json new file mode 100644 index 00000000..f391075b --- /dev/null +++ b/examples/counter-plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/examples/counter-plugin/vite.config.ts b/examples/counter-plugin/vite.config.ts new file mode 100644 index 00000000..9696c802 --- /dev/null +++ b/examples/counter-plugin/vite.config.ts @@ -0,0 +1,12 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +import { nanoforgeEditorPlugin } from '@nanoforge-dev/editor-vite-plugin'; + +// Builds dist/: index.js, counter.css and the manifest. The editor loads that folder. +export default defineConfig({ + plugins: [svelte(), nanoforgeEditorPlugin()], + build: { + lib: { entry: { index: 'src/index.ts' }, formats: ['es'], cssFileName: 'counter' }, + }, +}); diff --git a/messages/en.json b/messages/en.json deleted file mode 100644 index 97efb220..00000000 --- a/messages/en.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://inlang.com/schema/inlang-message-format", - "hello_world": "Hello, {name} from en!" -} diff --git a/messages/fr.json b/messages/fr.json deleted file mode 100644 index 45f5fefe..00000000 --- a/messages/fr.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://inlang.com/schema/inlang-message-format", - "hello_world": "Hello, {name} from fr!" -} diff --git a/package.json b/package.json index 9b4f40fa..ca52ce52 100644 --- a/package.json +++ b/package.json @@ -1,139 +1,65 @@ { "$schema": "https://json.schemastore.org/package.json", - "name": "@nanoforge-dev/editor", - "version": "0.1.5", - "description": "NanoForge Editor", - "keywords": [ - "nanoforge", - "game", - "editor" - ], + "name": "@nanoforge-dev/editor-workspace", + "version": "0.0.0", + "description": "NanoForge Editor monorepo", "homepage": "https://github.com/NanoForge-dev/Editor#readme", "bugs": "https://github.com/NanoForge-dev/Editor/issues", "license": "MIT", "contributors": [ - "Bill ", - "Exelo ", + "Exelo ", "Fexkoser ", - "Tchips " - ], - "files": [ - "dist" + "Harper ", + "Josephine ", + "Leo " ], - "main": "./dist/index.js", - "module": "./dist/index.js", - "exports": { - "./package.json": "./package.json" - }, "type": "module", - "directories": { - "lib": "src" - }, "repository": { "type": "git", "url": "git+https://github.com/NanoForge-dev/Editor.git" }, "funding": "https://github.com/NanoForge-dev/Editor?sponsor", "scripts": { - "dev": "bun --bun run vite dev", - "build": "bun --bun run vite build", - "preview": "bun --bun run vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check . && eslint --format=pretty src", - "format": "prettier --write . && eslint --fix --format=pretty src", - "changelog": "git cliff --prepend ./CHANGELOG.md -u -c ./cliff.toml -r . --include-path '.'", - "release": "cliff-jumper", - "test": "pnpm run test:unit && pnpm run test:e2e", - "test:unit": "vitest run -c vite.config.ts", - "test:unit:watch": "vitest -c vite.config.ts", - "test:e2e": "vitest run -c vite.config.e2e.ts", - "test:e2e:watch": "vitest -c vite.config.e2e.ts", - "test:coverage": "pnpm run test:unit --coverage && pnpm run test:e2e --coverage", + "dev": "turbo run dev --filter=@nanoforge-dev/editor --filter='./plugins/*'", + "build": "turbo run build", + "typecheck": "turbo run typecheck", + "lint": "prettier --check . && eslint --format=pretty scripts commitlint.config.cjs vitest.setup.ts vitest.global-setup.ts && turbo run lint", + "format": "prettier --write . && turbo run format", + "test": "pnpm run test:unit", + "test:unit": "turbo run test:unit", + "example": "node scripts/example.mjs", "prepare": "husky" }, - "dependencies": { - "bun": "catalog:core", - "class-transformer": "catalog:libs-back", - "class-validator": "catalog:libs-back", - "dotenv": "catalog:libs-back", - "micromatch": "catalog:libs-back", - "tar": "catalog:libs-back", - "typescript": "catalog:build" - }, "devDependencies": { - "@alexanderniebuhr/prettier-plugin-unocss": "catalog:css", "@commitlint/cli": "catalog:ci", "@commitlint/config-conventional": "catalog:ci", - "@favware/cliff-jumper": "catalog:ci", - "@iconify-json/clarity": "catalog:icons", - "@iconify-json/ic": "catalog:icons", - "@iconify-json/icomoon-free": "^1.2.1", - "@iconify-json/material-icon-theme": "catalog:icons", - "@iconify-json/solar": "catalog:icons", - "@inlang/paraglide-js": "catalog:i18n", - "@internationalized/date": "catalog:libs-front", - "@lucide/svelte": "catalog:icons", "@nanoforge-dev/actions": "catalog:ci", - "@nanoforge-dev/ecs-lib": "catalog:core", "@nanoforge-dev/utils-eslint-config": "catalog:lint", "@nanoforge-dev/utils-prettier-config": "catalog:lint", - "@playwright/test": "catalog:test", - "@sveltejs/kit": "catalog:core", - "@sveltejs/vite-plugin-svelte": "catalog:core", - "@tanstack/svelte-query": "catalog:libs-front", "@trivago/prettier-plugin-sort-imports": "catalog:lint", - "@tsconfig/svelte": "catalog:build", - "@types/bun": "catalog:core", - "@types/micromatch": "^4.0.10", - "@unocss/extractor-svelte": "catalog:css", - "@unocss/preset-icons": "catalog:css", - "@unocss/preset-web-fonts": "catalog:css", - "@unocss/preset-wind4": "catalog:css", - "@vitest/browser-playwright": "catalog:test", + "@types/node": "catalog:core", "@vitest/coverage-v8": "catalog:test", - "bits-ui": "catalog:components", - "clsx": "catalog:css", "eslint": "catalog:lint", "eslint-plugin-svelte": "catalog:lint", - "formsnap": "catalog:libs-front", "globals": "catalog:lint", "husky": "catalog:ci", "lint-staged": "catalog:ci", - "mode-watcher": "catalog:libs-front", - "monaco-editor": "catalog:components", - "playwright": "catalog:test", "prettier": "catalog:lint", "prettier-plugin-svelte": "catalog:lint", "svelte": "catalog:core", - "svelte-adapter-bun": "catalog:build", - "svelte-check": "catalog:core", - "svelte-kit-sessions": "catalog:core", - "svelte-sonner": "catalog:libs-front", - "sveltekit-superforms": "catalog:libs-front", - "tailwind-merge": "catalog:css", - "tailwind-variants": "catalog:css", - "tailwindcss": "catalog:css", + "turbo": "catalog:build", + "typescript": "catalog:build", "typescript-eslint": "catalog:lint", - "unocss": "catalog:css", - "vite": "catalog:core", - "vitest": "catalog:test", - "vitest-browser-svelte": "catalog:test", - "zod": "catalog:libs-front" + "vitest": "catalog:test" }, - "packageManager": "pnpm@11.9.0", + "packageManager": "pnpm@12.0.0", "engines": { - "node": "25" - }, - "publishConfig": { - "access": "public" + "node": "26" }, + "private": true, "lint-staged": { "**": [ "prettier --ignore-unknown --write" - ], - "src/**/*.{ts,svelte}": [ - "eslint --fix" ] } } diff --git a/packages/code/.prettierignore b/packages/code/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/code/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/code/LICENSE b/packages/code/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/code/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/code/README.md b/packages/code/README.md new file mode 100644 index 00000000..7bbed539 --- /dev/null +++ b/packages/code/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-code` is an internal package of the NanoForge Editor. + +Code service: TypeScript analysis and AST transforms in a web worker. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/code +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/code/package.json b/packages/code/package.json new file mode 100644 index 00000000..3ead8aab --- /dev/null +++ b/packages/code/package.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-code", + "version": "0.0.0", + "description": "NanoForge Editor - Code service: TypeScript analysis and AST transforms in a web worker, documents, diagnostics", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./worker": "./src/worker.ts", + "./engine": "./src/engine/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/code" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty .", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "@nanoforge-dev/editor-history": "workspace:*", + "@nanoforge-dev/editor-kernel": "workspace:*", + "@nanoforge-dev/editor-meta": "workspace:*", + "@nanoforge-dev/editor-project": "workspace:*", + "@nanoforge-dev/editor-protocol": "workspace:*", + "@nanoforge-dev/editor-rpc": "workspace:*", + "@nanoforge-dev/registry": "workspace:*", + "ts-morph": "catalog:code", + "zod": "catalog:libs" + }, + "devDependencies": { + "@nanoforge-dev/editor-server-core": "workspace:*", + "@types/node": "catalog:core", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/code/src/catalog/catalog-service.ts b/packages/code/src/catalog/catalog-service.ts new file mode 100644 index 00000000..61e1fbf5 --- /dev/null +++ b/packages/code/src/catalog/catalog-service.ts @@ -0,0 +1,466 @@ +import { + type Disposable, + DisposableStore, + type Observable, + ObservableValue, + type PluginHost, + createToken, +} from '@nanoforge-dev/editor-kernel'; +import type { MetaDiagnostic } from '@nanoforge-dev/editor-meta'; +import { + type ItemMeta, + type ItemRef, + META_GENERATOR, + META_NOTICE, + META_VERSION, + MetaFile, + type MetaSource, + metaCachePath, + sourceHash, + sourceRefBase, +} from '@nanoforge-dev/editor-meta/pure'; +import type { AppModel } from '@nanoforge-dev/editor-protocol'; +import { isInsidePath, readPackageContents } from '@nanoforge-dev/registry/types'; + +import type { MetaRoot, MetaSourceFile } from '../engine/extract-meta'; +import { isCodeFile } from '../service/code-service'; +import type { CatalogItem, CatalogOptions, CatalogState } from './catalog.type'; +import { appRefName, itemFitsApp, within } from './item-fits-app'; + +interface Source { + readonly source: MetaSource; + /** Folder of the source; its files are the code files under it. */ + readonly root: string; + readonly files: readonly MetaSourceFile[]; +} + +const MANIFEST = 'nanoforge.manifest.json'; +const MODULES = 'nf_modules'; +const EDITOR_GITIGNORE = '.nanoforge/editor/.gitignore'; +const EXPECTED_OWNERS_TIMEOUT_MS = 5000; + +/** + * Items (components, systems…) of the open project and its installed packages (ADR 0003). Each + * source's meta is cached in `.nanoforge/editor/cache/meta/`, used while its sources and owners + * are unchanged, and extracted again by the code worker otherwise. + */ +export class CatalogService implements Disposable { + private readonly _state = new ObservableValue({ + items: [], + diagnostics: [], + loading: true, + }); + private readonly _store = new DisposableStore(); + private readonly _bySource = new Map< + string, + { items: CatalogItem[]; diagnostics: MetaDiagnostic[] } + >(); + private readonly _dirty = new Set(); + private _sources: Source[] = []; + private _timer: ReturnType | undefined; + private _running: Promise = Promise.resolve(); + private _disposed = false; + /** Called once nothing is scheduled nor running (`whenIdle`). */ + private _idle: (() => void)[] = []; + /** Owners that did not register in time: not waited for again until the plugins change. */ + private _late = new Set(); + /** The catalog starts (and starts the code worker) when first read. */ + private _active = false; + + constructor(private readonly _options: CatalogOptions) { + const { project, documents, plugins } = _options; + this._store.add({ + dispose: project.model.subscribe(() => this._scheduleAll()), + }); + this._store.add( + project.fs.onDidChange((changes) => { + for (const change of changes) { + if (change.path.endsWith('.json') && within(change.path, MODULES)) this._scheduleAll(); + else if (isCodeFile(change.path) && change.type !== 'changed') this._scheduleAll(); + else if (isCodeFile(change.path)) this._schedulePath(change.path); + } + }), + ); + this._store.add(documents.onDidChange(({ uri }) => this._schedulePath(uri))); + if (plugins) { + let owners = ownerPlugins(plugins); + this._store.add( + plugins.onDidChange(() => { + const next = ownerPlugins(plugins); + if (next === owners) return; + owners = next; + this._late.clear(); + this._scheduleAll(); + }), + ); + } + } + + get state(): Observable { + this._activate(); + return this._state.readonly(); + } + + get items(): readonly CatalogItem[] { + this._activate(); + return this._state.get().items; + } + + get(ref: ItemRef): CatalogItem | undefined { + return this.items.find((item) => item.ref === ref); + } + + /** The items an app can use: its own, shared libraries' and installed packages'. */ + forApp(app: AppModel): CatalogItem[] { + return this.items.filter((item) => itemFitsApp(item, app)); + } + + /** The item a source file's export is, if any. */ + itemAt(path: string, exportName: string): CatalogItem | undefined { + return this.items.find((item) => item.meta.source === path && item.meta.export === exportName); + } + + /** Extracts everything again (after an owner plugin changed, or on demand). */ + refresh(): Promise { + this._active = true; + this._scheduleAll(0); + return this.whenIdle(); + } + + /** Resolves once pending extractions are done. */ + whenIdle(): Promise { + if (this._timer === undefined && !this._dirty.size) return this._running; + return new Promise((resolve) => this._idle.push(resolve)); + } + + dispose(): void { + this._disposed = true; + clearTimeout(this._timer); + this._timer = undefined; + this._store.dispose(); + this._settle(); + } + + private _activate(): void { + if (this._active) return; + this._active = true; + this._scheduleAll(0); + } + + private _scheduleAll(delay = this._options.delayMs ?? 300): void { + this._dirty.add('*'); + this._schedule(delay); + } + + private _schedulePath(path: string): void { + if (!isCodeFile(path)) return; + const source = this._sources.find((candidate) => within(path, candidate.root)); + if (!source && !within(path, MODULES)) { + return; + } + this._dirty.add(source ? sourceRefBase(source.source) : '*'); + this._schedule(this._options.delayMs ?? 300); + } + + private _schedule(delay: number): void { + if (!this._active) return; + clearTimeout(this._timer); + this._timer = setTimeout(() => { + this._timer = undefined; + this._running = this._running + .then(() => this._run()) + .catch((error: unknown) => this._options.logger?.error('Catalog update failed', error)) + .finally(() => this._settle()); + }, delay); + } + + /** Wakes `whenIdle` callers when nothing is left to do. */ + private _settle(): void { + if (!this._disposed && (this._timer !== undefined || this._dirty.size)) return; + const waiting = this._idle; + this._idle = []; + for (const resolve of waiting) resolve(); + } + + private async _run(): Promise { + if (this._disposed || !this._dirty.size) return; + const all = this._dirty.has('*'); + const dirty = new Set(this._dirty); + this._dirty.clear(); + if (all) this._sources = await this._discover(); + await this._expectedOwners(); + const owners = await this._options.code.itemOwners(); + const roots: MetaRoot[] = this._sources.map((source) => ({ + path: source.root, + ref: sourceRefBase(source.source), + })); + const known = new Set(this._sources.map((source) => sourceRefBase(source.source))); + for (const key of this._bySource.keys()) if (!known.has(key)) this._bySource.delete(key); + for (const source of this._sources) { + if (this._disposed) return; + const key = sourceRefBase(source.source); + if (!all && !dirty.has(key) && this._bySource.has(key)) continue; + try { + this._bySource.set(key, await this._load(source, roots, owners)); + } catch (error) { + this._options.logger?.warn(`Items of ${key} could not be read`, error); + } + } + this._publish(); + } + + private _publish(): void { + const entries = [...this._bySource.values()]; + this._state.set({ + items: entries.flatMap((entry) => entry.items), + diagnostics: entries.flatMap((entry) => entry.diagnostics), + loading: false, + }); + } + + /** + * Waits (a few seconds at most) for the worker to register the owners plugins declare, woken + * by each worker plugin load. Owners that never came are not waited for again until the plugin + * set changes. + */ + private async _expectedOwners(): Promise { + const plugins = this._options.plugins; + if (!plugins) return; + const expected = ownerPlugins(plugins) + .split(',') + .filter((name) => name && !this._late.has(name)); + const deadline = Date.now() + EXPECTED_OWNERS_TIMEOUT_MS; + for (;;) { + const registered = new Set( + (await this._options.code.itemOwners()).map((owner) => owner.name), + ); + const missing = expected.filter((name) => !registered.has(name)); + if (!missing.length) return; + const remaining = deadline - Date.now(); + if (remaining <= 0 || this._disposed) { + for (const name of missing) this._late.add(name); + this._options.logger?.warn(`Item owners not registered in time: ${missing.join(', ')}`); + return; + } + await new Promise((resolve) => { + const timer = setTimeout(done, remaining); + const subscription = this._options.code.onDidChangeOwners(done); + function done() { + clearTimeout(timer); + subscription.dispose(); + resolve(); + } + }); + } + } + + /** + * Apps, shared libraries and installed packages, with their files. A package's files are its + * `items` and the code files its contents point to, its sub-manifests' (`include`) included. + */ + private async _discover(): Promise { + const { project } = this._options; + const entries = project.fs.entries; + const codeFiles = (root: string) => + entries + .filter( + (entry) => entry.kind === 'file' && isCodeFile(entry.path) && within(entry.path, root), + ) + .map((entry) => entry.path); + const sources: Source[] = []; + + for (const app of project.model.get().apps) { + const folderOf = (path: string): MetaSourceFile['folder'] => + within(path, app.dirs.components) + ? 'components' + : within(path, app.dirs.systems) + ? 'systems' + : undefined; + const source: MetaSource = + app.type === 'lib' + ? { kind: 'lib', name: app.name, path: app.root } + : { kind: 'app', name: appRefName(app), path: app.root }; + sources.push({ + source, + root: app.root, + files: codeFiles(app.root) + .filter((path) => !within(path, MODULES)) + .map((path) => { + const folder = folderOf(path); + return { path, ...(folder && { folder }) }; + }), + }); + } + + const visit = async (dir: string) => { + /** Read even when the tree does not list it yet: files of a fresh install come in late. */ + const readJson = async (path: string): Promise => { + try { + return JSON.parse((await project.fs.readText(`${dir}/${path}`)).text) as unknown; + } catch { + return undefined; + } + }; + const manifest = (await readJson(MANIFEST)) as + { type?: string; name?: string; version?: string; items?: unknown } | undefined; + if (manifest?.type !== 'package' || !manifest.name) return; + /** + * The code files a package's manifest points to: a file of `items` (the older form) or a + * content without `export` makes each of its exports an item; a content naming an + * `export` makes that export one. Assets hold no items. + */ + const files = new Map | 'all'>(); + const list = (path: string, name?: string) => { + const current = files.get(path); + if (current === 'all') return; + if (name === undefined) files.set(path, 'all'); + else files.set(path, new Set([...(current ?? []), name])); + }; + for (const item of Array.isArray(manifest.items) ? manifest.items : []) + if (typeof item === 'string' && isInsidePath(item)) list(`${dir}/${item}`); + for (const content of (await readPackageContents(manifest, readJson)).contents) { + if (content.kind === 'asset') continue; + const path = `${dir}/${content.path.replace(/\/$/, '')}`; + if (project.fs.entry(path)?.kind === 'directory') + for (const file of codeFiles(path)) list(file, content.export); + else if (isCodeFile(path)) list(path, content.export); + } + sources.push({ + source: { + kind: 'module', + name: manifest.name, + version: manifest.version ?? '0.0.0', + path: dir, + }, + root: dir, + files: [...files].map(([path, names]) => + names === 'all' ? { path, listed: true } : { path, exports: [...names].sort() }, + ), + }); + }; + const scopes = entries.filter( + (entry) => entry.kind === 'directory' && /^nf_modules\/@[^/]+$/.test(entry.path), + ); + for (const scope of scopes) { + const names = entries.filter( + (entry) => + entry.kind === 'directory' && + entry.path.startsWith(`${scope.path}/`) && + !entry.path.slice(scope.path.length + 1).includes('/'), + ); + for (const name of names) await visit(name.path); + } + return sources; + } + + /** A source's items: from the cache when still valid, else extracted and cached. */ + private async _load( + source: Source, + roots: readonly MetaRoot[], + owners: readonly { name: string; version: string; schema: number }[], + ): Promise<{ items: CatalogItem[]; diagnostics: MetaDiagnostic[] }> { + const { project, documents, code } = this._options; + const cachePath = metaCachePath(source.source); + const texts = await Promise.all( + source.files.map(async (file) => ({ + path: file.path, + text: await documents.getText(file.path), + })), + ); + /** What each file is extracted with counts too: a changed manifest or folder refreshes it. */ + const hash = await sourceHash([ + ...texts, + { path: '\0files', text: JSON.stringify(source.files) }, + ]); + const ownerTable = Object.fromEntries( + owners.map((owner) => [owner.name, { version: owner.version, schema: owner.schema }]), + ); + + const cached = await this._readCache(cachePath); + const fresh = + cached && + cached.sourceHash === hash && + cached.generator.version === META_GENERATOR.version && + JSON.stringify(sortKeys(cached.owners)) === JSON.stringify(sortKeys(ownerTable)); + let items: ItemMeta[]; + let diagnostics: MetaDiagnostic[] = []; + if (fresh) { + items = cached.items; + } else { + const result = await code.extractMeta({ + source: source.source, + files: source.files, + roots, + texts, + }); + items = result.items; + diagnostics = result.diagnostics; + const file: MetaFile = { + generated: META_NOTICE, + metaVersion: META_VERSION, + generator: { ...META_GENERATOR }, + source: source.source, + sourceHash: hash, + owners: result.owners, + items, + }; + await this._ensureIgnored(); + await project.fs + .write(cachePath, `${JSON.stringify(file, null, 2)}\n`) + .catch((error: unknown) => + this._options.logger?.warn(`Could not cache ${cachePath}`, error), + ); + } + const base = sourceRefBase(source.source); + return { + items: items.map((meta) => ({ + ref: `${base}#${meta.export}`, + source: source.source, + meta, + readonly: source.source.kind === 'module', + })), + diagnostics, + }; + } + + private async _readCache(path: string): Promise { + if (!this._options.project.fs.entry(path)) return undefined; + try { + const parsed = MetaFile.safeParse( + JSON.parse((await this._options.project.fs.readText(path)).text), + ); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } + } + + /** The cache is never committed: `cache/` in `.nanoforge/editor/.gitignore`. */ + private async _ensureIgnored(): Promise { + const fs = this._options.project.fs; + const current = fs.entry(EDITOR_GITIGNORE) ? await fs.readText(EDITOR_GITIGNORE) : undefined; + const lines = current?.text.split('\n').map((line) => line.trim()) ?? []; + if (lines.includes('cache/')) return; + const text = `${current?.text.trimEnd() ?? ''}${current?.text.trim() ? '\n' : ''}cache/\n`; + await fs.write(EDITOR_GITIGNORE, text).catch(() => undefined); + } +} + +/** Plugins that declare an item owner and have a worker entry, as `name@version,…`. */ +const ownerPlugins = (plugins: PluginHost): string => + plugins + .getPlugins() + .filter( + (plugin) => + plugin.status.kind === 'ok' && + plugin.descriptor.manifest.entry.worker && + (plugin.descriptor.manifest.contributes as Record).itemOwner, + ) + .map((plugin) => plugin.name) + .sort() + .join(','); + +const sortKeys = (value: Record) => + Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))); + +export const CatalogServiceToken = createToken('code.catalog'); diff --git a/packages/code/src/catalog/catalog.type.ts b/packages/code/src/catalog/catalog.type.ts new file mode 100644 index 00000000..13fa9909 --- /dev/null +++ b/packages/code/src/catalog/catalog.type.ts @@ -0,0 +1,33 @@ +import type { Logger, PluginHost } from '@nanoforge-dev/editor-kernel'; +import type { MetaDiagnostic } from '@nanoforge-dev/editor-meta'; +import type { ItemMeta, ItemRef, MetaSource } from '@nanoforge-dev/editor-meta/pure'; +import type { ClientProject } from '@nanoforge-dev/editor-project'; + +import type { DocumentService } from '../document/document-service'; +import type { CodeService } from '../service/code-service'; + +/** An item of the catalog: its metadata and where it comes from. */ +export interface CatalogItem { + readonly ref: ItemRef; + readonly source: MetaSource; + readonly meta: ItemMeta; + /** Items of installed packages can't be changed. */ + readonly readonly: boolean; +} + +export interface CatalogState { + readonly items: readonly CatalogItem[]; + readonly diagnostics: readonly MetaDiagnostic[]; + /** True until the first extraction of every source is done. */ + readonly loading: boolean; +} + +export interface CatalogOptions { + readonly project: ClientProject; + readonly code: CodeService; + readonly documents: DocumentService; + readonly plugins?: PluginHost; + readonly logger?: Logger; + /** Delay before extracting again after a change. */ + readonly delayMs?: number; +} diff --git a/packages/code/src/catalog/index.ts b/packages/code/src/catalog/index.ts new file mode 100644 index 00000000..15a058ae --- /dev/null +++ b/packages/code/src/catalog/index.ts @@ -0,0 +1,3 @@ +export * from './catalog.type'; +export { appRefName, itemFitsApp } from './item-fits-app'; +export * from './catalog-service'; diff --git a/packages/code/src/catalog/item-fits-app.ts b/packages/code/src/catalog/item-fits-app.ts new file mode 100644 index 00000000..a6fedb59 --- /dev/null +++ b/packages/code/src/catalog/item-fits-app.ts @@ -0,0 +1,29 @@ +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +import type { CatalogItem } from './catalog.type'; + +/** The name in an app's item references: its folder (`apps/client` → `client`). */ +export const appRefName = (app: AppModel): string => + (app.root.split('/').pop() || app.name).replace(/[^\w.-]/g, '-'); + +export const within = (path: string, folder: string) => + folder === '' || path === folder || path.startsWith(`${folder}/`); + +/** + * Whether an item can go in an app: its side, the engine libraries it imports and, for an item + * of a shared library, that the app uses that library. + */ +export const itemFitsApp = (item: CatalogItem, app: AppModel): boolean => { + if (item.source.kind === 'app' && item.source.path !== app.root) return false; + if ( + item.source.kind === 'lib' && + item.source.path !== app.root && + !app.libraries.includes(item.source.name) + ) + return false; + const side = item.meta.side; + if (side !== 'shared' && !(app.type === side)) return false; + return item.meta.requires + .filter((name) => name.startsWith('@nanoforge-dev/')) + .every((name) => name in app.engineLibs); +}; diff --git a/packages/code/src/codegen/codegen-target.ts b/packages/code/src/codegen/codegen-target.ts new file mode 100644 index 00000000..99c23a72 --- /dev/null +++ b/packages/code/src/codegen/codegen-target.ts @@ -0,0 +1,36 @@ +import type { HistoryCommand } from '@nanoforge-dev/editor-history'; +import { + type ExtensionRegistry, + type Observable, + defineExtensionPoint, +} from '@nanoforge-dev/editor-kernel'; +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +/** + * Where the editor reads and writes what a visual editor shows (entities, scenes…) as code. + * Core defines the point only: the ECS plugin provides the default "entry file" target and a + * scene plugin can take over with a higher priority. + */ +export interface CodegenTarget { + readonly id: string; + readonly label: string; + /** Highest wins when several targets apply to an app. */ + readonly priority?: number; + appliesTo(app: AppModel): boolean; + /** Live model of the app, re-read when its files change. */ + load(app: AppModel): Observable; + /** An undoable command performing the operation (code edits through the DocumentService). */ + apply(app: AppModel, op: Op): Promise; +} + +export const CODEGEN_TARGETS = defineExtensionPoint('code.codegenTargets'); + +/** The target used for an app, if any provider applies. */ +export const selectTarget = ( + extensions: ExtensionRegistry, + app: AppModel, +): CodegenTarget | undefined => + extensions + .getValues(CODEGEN_TARGETS) + .filter((target) => target.appliesTo(app)) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))[0]; diff --git a/packages/code/src/codegen/index.ts b/packages/code/src/codegen/index.ts new file mode 100644 index 00000000..27c3b44a --- /dev/null +++ b/packages/code/src/codegen/index.ts @@ -0,0 +1 @@ +export * from './codegen-target'; diff --git a/packages/code/src/diagnostics/diagnostics-service.ts b/packages/code/src/diagnostics/diagnostics-service.ts new file mode 100644 index 00000000..a0018193 --- /dev/null +++ b/packages/code/src/diagnostics/diagnostics-service.ts @@ -0,0 +1,58 @@ +import { type Observable, ObservableValue, createToken } from '@nanoforge-dev/editor-kernel'; + +import type { CodeDiagnostic } from '../engine/engine.type'; + +/** Problems per file and source (TypeScript, analyzers, build…), for the Problems panel. */ +export class DiagnosticsService { + private readonly _all = new ObservableValue>( + new Map(), + ); + private readonly _bySource = new Map>(); + + /** Every problem, by file. */ + get all(): Observable> { + return this._all.readonly(); + } + + forUri(uri: string): readonly CodeDiagnostic[] { + return this._all.get().get(uri) ?? []; + } + + /** Replaces the problems of a source for these files. */ + set(source: string, uri: string, diagnostics: readonly CodeDiagnostic[]): void { + const files = this._bySource.get(source) ?? new Map(); + if (diagnostics.length) files.set(uri, diagnostics); + else files.delete(uri); + this._bySource.set(source, files); + this._recompute(); + } + + /** Files a source reported problems for. */ + uris(source: string): string[] { + return [...(this._bySource.get(source)?.keys() ?? [])]; + } + + clear(source: string, uri?: string): void { + const files = this._bySource.get(source); + if (!files) return; + if (uri) files.delete(uri); + else files.clear(); + this._recompute(); + } + + private _recompute(): void { + const merged = new Map(); + for (const files of this._bySource.values()) { + for (const [uri, list] of files) merged.set(uri, [...(merged.get(uri) ?? []), ...list]); + } + for (const list of merged.values()) { + list.sort( + (a, b) => + (a.line ?? 0) - (b.line ?? 0) || (a.column ?? 0) - (b.column ?? 0) || a.start - b.start, + ); + } + this._all.set(merged); + } +} + +export const DiagnosticsServiceToken = createToken('code.diagnostics'); diff --git a/packages/code/src/diagnostics/index.ts b/packages/code/src/diagnostics/index.ts new file mode 100644 index 00000000..bc288c5a --- /dev/null +++ b/packages/code/src/diagnostics/index.ts @@ -0,0 +1 @@ +export * from './diagnostics-service'; diff --git a/packages/code/src/document/document-changed.exception.ts b/packages/code/src/document/document-changed.exception.ts new file mode 100644 index 00000000..ae9ad481 --- /dev/null +++ b/packages/code/src/document/document-changed.exception.ts @@ -0,0 +1,6 @@ +export class DocumentChangedError extends Error { + constructor(readonly uri: string) { + super(`${uri} changed since this edit`); + this.name = 'DocumentChangedError'; + } +} diff --git a/packages/code/src/document/document-contributions.ts b/packages/code/src/document/document-contributions.ts new file mode 100644 index 00000000..c723fde1 --- /dev/null +++ b/packages/code/src/document/document-contributions.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { defineExtensionPoint } from '@nanoforge-dev/editor-kernel'; + +import type { DocumentEditor, JsonSchemaContribution } from './document.type'; + +export const DocumentEditorSchema = z.object({ + pattern: z.string().min(1), + widget: z.string().min(1), + command: z.string().min(1).optional(), + title: z.string().optional(), + priority: z.number().optional(), +}); + +export const DOCUMENT_EDITORS = defineExtensionPoint('code.documentEditors'); + +export const JSON_SCHEMAS = defineExtensionPoint('code.jsonSchemas'); + +/** Serialized type of document edit commands. */ +export const DOCUMENT_EDITS = 'document.edits'; diff --git a/packages/code/src/document/document-preview.ts b/packages/code/src/document/document-preview.ts new file mode 100644 index 00000000..81adc288 --- /dev/null +++ b/packages/code/src/document/document-preview.ts @@ -0,0 +1,28 @@ +import type { CommandPreview } from '@nanoforge-dev/editor-history'; + +const PREVIEW_CONTEXT = 1; +const PREVIEW_MAX_LINES = 12; + +/** The changed lines of a document (and one line around), before and after. */ +export const previewOf = (uri: string, before: string, after: string): CommandPreview => { + let start = 0; + while (start < before.length && start < after.length && before[start] === after[start]) start++; + let end = 0; + while ( + end < before.length - start && + end < after.length - start && + before[before.length - 1 - end] === after[after.length - 1 - end] + ) + end++; + const lines = (text: string, from: number, to: number) => { + const all = text.split('\n'); + const first = Math.max(0, text.slice(0, from).split('\n').length - 1 - PREVIEW_CONTEXT); + const last = Math.min(all.length, text.slice(0, to).split('\n').length + PREVIEW_CONTEXT); + return all.slice(first, Math.min(last, first + PREVIEW_MAX_LINES)).join('\n'); + }; + return { + title: uri, + before: lines(before, start, before.length - end), + after: lines(after, start, after.length - end), + }; +}; diff --git a/packages/code/src/document/document-service.ts b/packages/code/src/document/document-service.ts new file mode 100644 index 00000000..53850780 --- /dev/null +++ b/packages/code/src/document/document-service.ts @@ -0,0 +1,278 @@ +import { + type CommandOrigin, + type CommandPreview, + type HistoryCommand, + type HistoryContext, + type HistoryService, + type TextEdit, + applyTextEdits, +} from '@nanoforge-dev/editor-history'; +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + createToken, + toDisposable, +} from '@nanoforge-dev/editor-kernel'; +import type { ProjectFs } from '@nanoforge-dev/editor-project'; + +import { DOCUMENT_EDITS } from './document-contributions'; +import { previewOf } from './document-preview'; +import type { DocumentBackend, DocumentChange, OpenDocument } from './document.type'; + +export const projectFsBackend = (fs: ProjectFs): DocumentBackend => ({ + onDidChange: (listener, disposables) => + fs.onDidChange((changes) => listener(changes.map((change) => change.path)), disposables), + read: (uri) => fs.readText(uri), + write: (uri, text, expectedHash) => fs.write(uri, text, { expectedHash }), +}); + +/** FNV-1a fingerprint of a text (undo validity checks, not security). */ +export const fingerprint = (text: string): string => { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return `${(hash >>> 0).toString(16)}:${text.length}`; +}; + +/** + * The single path for editing documents: edits go to the open document when there is one + * (the editor stays in sync, no dirty-buffer conflicts), otherwise to the backend (files). + */ +export class DocumentService implements Disposable { + private readonly _open = new Map(); + private readonly _written = new Map(); + private readonly _onDidChange = new Emitter(); + private readonly _onDidChangeExternally = new Emitter(); + private readonly _onDidOpen = new Emitter(); + private readonly _store = new DisposableStore(); + + /** Every applied change, from open documents and file edits. */ + readonly onDidChange: Event = this._onDidChange.event; + /** A file the editor edited was changed by something else (hand edit, git…). */ + readonly onDidChangeExternally: Event = this._onDidChangeExternally.event; + readonly onDidOpen: Event = this._onDidOpen.event; + + private readonly _historyContexts = new Map(); + + constructor( + private readonly _backend: DocumentBackend, + /** Gives each document an undo context (`file:`) for `edit`. */ + private readonly _history?: HistoryService, + ) { + this._store.add(_backend.onDidChange((uris) => void this._checkExternal(uris))); + } + + register(document: OpenDocument): Disposable { + if (this._open.has(document.uri)) throw new Error(`${document.uri} is already open`); + this._open.set(document.uri, document); + const forward = document.onDidChange((change) => this._onDidChange.fire(change)); + this._onDidOpen.fire(document.uri); + return toDisposable(() => { + forward.dispose(); + if (this._open.get(document.uri) === document) this._open.delete(document.uri); + }); + } + + get(uri: string): OpenDocument | undefined { + return this._open.get(uri); + } + + get openUris(): string[] { + return [...this._open.keys()]; + } + + /** Where documents live when they are not open (editors read and write through it). */ + get backend(): DocumentBackend { + return this._backend; + } + + /** Open documents with unsaved changes. */ + get dirtyUris(): string[] { + return [...this._open.values()].filter((doc) => doc.dirty.get()).map((doc) => doc.uri); + } + + /** Saves an open document if it has unsaved changes (a closed one is always saved). */ + async save(uri: string): Promise { + const open = this._open.get(uri); + if (open?.dirty.get()) await open.save(); + } + + /** Saves every open document with unsaved changes; resolves with the ones that failed. */ + async saveAll(): Promise<{ uri: string; error: unknown }[]> { + const failed: { uri: string; error: unknown }[] = []; + for (const uri of this.dirtyUris) { + await this._open + .get(uri) + ?.save() + .catch((error: unknown) => failed.push({ uri, error })); + } + return failed; + } + + async getText(uri: string): Promise { + return this._open.get(uri)?.getText() ?? (await this._backend.read(uri)).text; + } + + /** Applies edits once (not undoable); returns their inverse. */ + async applyEdits( + uri: string, + edits: readonly TextEdit[], + origin?: CommandOrigin, + ): Promise { + if (!edits.length) return []; + const open = this._open.get(uri); + if (open) return open.applyEdits(edits, origin); + const current = await this._backend.read(uri); + const { text, inverse } = applyTextEdits(current.text, edits); + const { hash } = await this._backend.write(uri, text, current.hash); + this._written.set(uri, hash); + this._onDidChange.fire({ uri, edits, origin }); + return inverse; + } + + /** The undo context of a document (`file:`), created on first use. */ + historyContext(uri: string): HistoryContext | undefined { + if (!this._history) return undefined; + let context = this._historyContexts.get(uri); + if (!context) { + const id = documentHistoryId(uri); + context = + this._history.get(id) ?? + this._history.registerContext({ id, label: uri.split('/').at(-1) ?? uri }); + this._historyContexts.set(uri, context); + } + return context; + } + + /** Applies edits as one undo step of the document's history context. */ + async edit( + uri: string, + edits: readonly TextEdit[], + options: { label: string; origin?: CommandOrigin; mergeKey?: string }, + ): Promise { + const context = this.historyContext(uri); + const command = this.editCommand(uri, edits, options); + if (context) await context.stack.push(command); + else await command.do(); + } + + /** + * An undoable edit. Undo/redo check that the document still has the content the edit + * produced; otherwise the step is dropped by the history. It carries a preview of the changed + * lines and can be serialized (history kept across reloads, see `restoreEditCommand`). + * With `basedOn` (the `fingerprint` of the text the edits were computed on), the edit fails + * when the text changed since: offsets would land elsewhere. + */ + editCommand( + uri: string, + edits: readonly TextEdit[], + options: { label: string; origin?: CommandOrigin; mergeKey?: string; basedOn?: string }, + ): HistoryCommand { + return this._editCommand( + { uri, forward: [...edits], inverse: [], expected: '' }, + options, + false, + options.basedOn, + ); + } + + /** Rebuilds an edit command from `serialize()` data (already applied). */ + restoreEditCommand(data: unknown, label: string): HistoryCommand | undefined { + const state = data as Partial | undefined; + if (!state?.uri || !Array.isArray(state.forward) || !Array.isArray(state.inverse)) + return undefined; + return this._editCommand( + { + uri: state.uri, + forward: state.forward, + inverse: state.inverse, + expected: state.expected ?? '', + ...(state.preview && { preview: state.preview }), + }, + { label }, + true, + ); + } + + dispose(): void { + for (const context of this._historyContexts.values()) context.dispose(); + this._historyContexts.clear(); + this._store.dispose(); + this._onDidChange.dispose(); + this._onDidChangeExternally.dispose(); + this._onDidOpen.dispose(); + } + + private _editCommand( + state: EditState, + options: { label: string; origin?: CommandOrigin; mergeKey?: string }, + applied: boolean, + basedOn?: string, + ): HistoryCommand { + const run = async (operations: readonly TextEdit[]) => { + const before = await this.getText(state.uri); + const result = await this.applyEdits(state.uri, operations, options.origin); + const after = await this.getText(state.uri); + state.expected = fingerprint(after); + return { result, before, after }; + }; + const command: HistoryCommand & { preview?: CommandPreview } = { + label: options.label, + ...(options.origin && { origin: options.origin }), + ...(options.mergeKey && { mergeKey: options.mergeKey }), + sizeBytes: state.forward.reduce((total, edit) => total + edit.text.length * 2 + 16, 0), + ...(state.preview && { preview: state.preview }), + do: async () => { + if (applied) return; + if (basedOn !== undefined && fingerprint(await this.getText(state.uri)) !== basedOn) + throw new Error(`${state.uri} changed while the edit was prepared: try again`); + const { result, before, after } = await run(state.forward); + state.inverse = result; + state.preview = previewOf(state.uri, before, after); + (command as { preview?: CommandPreview }).preview = state.preview; + }, + undo: async () => { + applied = false; + state.forward = (await run(state.inverse)).result; + }, + redo: async () => { + state.inverse = (await run(state.forward)).result; + }, + isValid: async () => fingerprint(await this.getText(state.uri)) === state.expected, + serialize: () => ({ type: DOCUMENT_EDITS, label: options.label, data: { ...state } }), + }; + return command; + } + + private async _checkExternal(uris: readonly string[]): Promise { + for (const uri of uris) { + const written = this._written.get(uri); + if (!written || this._open.has(uri)) continue; + const current = await this._backend.read(uri).then( + (file) => file.hash, + () => null, + ); + if (current === written) continue; + this._written.delete(uri); + this._onDidChangeExternally.fire(uri); + } + } +} + +export const DocumentServiceToken = createToken('code.documents'); + +/** Id of a document's undo context. */ +export const documentHistoryId = (uri: string): string => `file:${uri}`; + +interface EditState { + uri: string; + forward: TextEdit[]; + inverse: TextEdit[]; + /** Fingerprint of the document after the command (undo validity). */ + expected: string; + preview?: CommandPreview; +} diff --git a/packages/code/src/document/document.type.ts b/packages/code/src/document/document.type.ts new file mode 100644 index 00000000..46463d7e --- /dev/null +++ b/packages/code/src/document/document.type.ts @@ -0,0 +1,51 @@ +import type { CommandOrigin, TextEdit } from '@nanoforge-dev/editor-history'; +import type { Event, Observable } from '@nanoforge-dev/editor-kernel'; + +export interface DocumentChange { + readonly uri: string; + readonly edits: readonly TextEdit[]; + readonly origin: CommandOrigin | undefined; +} + +/** A document opened by an editor (e.g. a Monaco model): the source of truth while open. */ +export interface OpenDocument { + readonly uri: string; + readonly kind: 'text' | 'binary' | 'custom'; + readonly onDidChange: Event; + readonly dirty: Observable; + /** Current content (text documents; custom documents serialize their model). */ + getText(): string; + /** Applies edits and returns their inverse. */ + applyEdits(edits: readonly TextEdit[], origin: CommandOrigin | undefined): TextEdit[]; + save(): Promise; + revert(): Promise; +} + +/** Where documents are stored when they are not open. */ +export interface DocumentBackend { + readonly onDidChange: Event; + read(uri: string): Promise<{ text: string; hash: string }>; + write(uri: string, text: string, expectedHash: string | null): Promise<{ hash: string }>; +} + +/** Which editor widget opens which files ("open with"). */ +export interface DocumentEditor { + /** Glob on project paths: `**\/*.ts`, `**\/*.{png,jpg}`. */ + readonly pattern: string; + readonly widget: string; + /** + * Command opening a file in this editor (`command(path, options)`); declared in the plugin + * manifest, running it activates the plugin. Without one, the widget is opened. + */ + readonly command?: string; + readonly title?: string; + readonly priority?: number; +} + +/** A JSON schema for files matching globs (completion and validation in the code editor). */ +export interface JsonSchemaContribution { + /** Globs on project paths, e.g. `.nanoforge/editor/settings.json`. */ + readonly fileMatch: readonly string[]; + /** The schema, or a function computing it when it is applied (e.g. from a registry). */ + readonly schema: object | (() => object); +} diff --git a/packages/code/src/document/index.ts b/packages/code/src/document/index.ts new file mode 100644 index 00000000..f7c34ca2 --- /dev/null +++ b/packages/code/src/document/index.ts @@ -0,0 +1,6 @@ +export * from './document.type'; +export * from './document-contributions'; +export * from './match-glob'; +export * from './document-changed.exception'; +export * from './document-service'; +export * from './document-preview'; diff --git a/packages/code/src/document/match-glob.ts b/packages/code/src/document/match-glob.ts new file mode 100644 index 00000000..5ef59935 --- /dev/null +++ b/packages/code/src/document/match-glob.ts @@ -0,0 +1,51 @@ +import type { ExtensionRegistry } from '@nanoforge-dev/editor-kernel'; + +import { DOCUMENT_EDITORS } from './document-contributions'; +import type { DocumentEditor } from './document.type'; + +/** `{a,b}` alternatives; a `,` or `}` outside braces is a plain character. */ +const globToRegex = (glob: string): RegExp => { + let source = ''; + let depth = 0; + for (let i = 0; i < glob.length; i++) { + const char = glob[i]!; + if (char === '*' && glob[i + 1] === '*') { + if (glob[i + 2] === '/') { + source += '(?:.*/)?'; + i += 2; + } else { + source += '.*'; + i += 1; + } + } else if (char === '*') source += '[^/]*'; + else if (char === '?') source += '[^/]'; + else if (char === '{') { + depth++; + source += '(?:'; + } else if (char === '}' && depth) { + depth--; + source += ')'; + } else if (char === ',' && depth) source += '|'; + else source += /[.+^$()|[\]\\{}]/.test(char) ? `\\${char}` : char; + } + return new RegExp(`^${source}${')'.repeat(depth)}$`); +}; + +const globs = new Map(); + +/** Whether a project path matches a glob (`**\/*.{png,jpg}`, `src/*.ts`). */ +export const matchesGlob = (pattern: string, path: string): boolean => { + let regex = globs.get(pattern); + if (!regex) globs.set(pattern, (regex = globToRegex(pattern))); + return regex.test(path); +}; + +/** Editors able to open a file, best first. */ +export const editorsFor = ( + extensions: Pick, + uri: string, +): DocumentEditor[] => + extensions + .getValues(DOCUMENT_EDITORS) + .filter((editor) => matchesGlob(editor.pattern, uri)) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); diff --git a/packages/code/src/engine/code-engine.ts b/packages/code/src/engine/code-engine.ts new file mode 100644 index 00000000..50a4fe92 --- /dev/null +++ b/packages/code/src/engine/code-engine.ts @@ -0,0 +1,387 @@ +import { type Node, Project, ts } from 'ts-morph'; + +import type { ItemOwner } from '@nanoforge-dev/editor-meta'; + +import type { AnalyzeContext, Analyzer, CodeEngineOptions, Transformer } from './code-engine.type'; +import { CodeError } from './code.exception'; +import { EditBuilder } from './edit-builder'; +import type { CodeDiagnostic, CodeSymbol, SourceText, TextEdit } from './engine.type'; +import { type MetaRequest, type MetaResponse, extractMeta } from './extract-meta'; +import { literalToValue, quoteStyle, readExportedLiteral, valueToLiteral } from './literals'; +import { joinPosix, matchesPathPattern, moduleSpecifierFor } from './module-specifier'; +import { renamePackage } from './rename-package'; +import { RENAME_PACKAGE_TRANSFORMER } from './rename-package.const'; + +const ROOT = '/project/'; + +const toFsPath = (path: string) => ROOT + path; +const toProjectPath = (fsPath: string) => + fsPath.startsWith(ROOT) ? fsPath.slice(ROOT.length) : fsPath; + +/** + * TypeScript analysis of a project mirrored in memory. Runs in a web worker; plugins register + * analyzers (source → model) and transformers (operation → text edits). + */ +export class CodeEngine { + readonly project = new Project({ + useInMemoryFileSystem: true, + compilerOptions: { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + allowJs: true, + checkJs: false, + strict: true, + skipLibCheck: true, + noEmit: true, + experimentalDecorators: true, + lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'], + }, + }); + + private readonly _analyzers = new Map(); + private readonly _transformers = new Map(); + /** + * Per package, where its declarations were found (the folders holding the `node_modules` they + * are in) and the folders asked from. Each app of a workspace has its own `node_modules`: a + * package loaded for the client is not visible from the server's files. + */ + private readonly _typeRoots = new Map }>(); + private readonly _itemOwners = new Map(); + private _paths: Readonly> = {}; + /** + * One program per app, for diagnostics, once the project has several apps with their own + * `node_modules` (by app root; files outside of every app are in each of them). In one program the + * client's type augmentations (`declare module`, globals) would apply to the server's code + * and report errors `tsc` never shows. Built on demand, kept in step with `project`. + */ + private _scopes: Map | undefined; + + constructor(private readonly _options: CodeEngineOptions = {}) { + this.registerTransformer(RENAME_PACKAGE_TRANSFORMER, renamePackage); + } + + setFiles(files: readonly SourceText[]): void { + for (const { path, text } of files) { + const existing = this.project.getSourceFile(toFsPath(path)); + if (existing) { + if (existing.getFullText() !== text) existing.replaceWithText(text); + } else { + this.project.createSourceFile(toFsPath(path), text, { overwrite: true }); + } + this._syncScopes(path, text); + } + } + + deleteFiles(paths: readonly string[]): void { + for (const path of paths) { + const file = this.project.getSourceFile(toFsPath(path)); + if (file) this.project.removeSourceFile(file); + this._syncScopes(path, undefined); + } + } + + text(path: string): string | undefined { + return this.project.getSourceFile(toFsPath(path))?.getFullText(); + } + + get paths(): string[] { + return this.project.getSourceFiles().map((file) => toProjectPath(file.getFilePath())); + } + + registerAnalyzer(id: string, run: Analyzer, owner = 'core'): () => void { + if (this._analyzers.has(id)) throw new Error(`Analyzer "${id}" is already registered`); + this._analyzers.set(id, { owner, run }); + return () => this._analyzers.delete(id); + } + + registerTransformer(id: string, run: Transformer, owner = 'core'): () => void { + if (this._transformers.has(id)) throw new Error(`Transformer "${id}" is already registered`); + this._transformers.set(id, { owner, run }); + return () => this._transformers.delete(id); + } + + /** Registers an owner of item data (ADR 0003), e.g. `@nanoforge/ecs` from the ECS plugin. */ + registerItemOwner(owner: ItemOwner, plugin = 'core'): () => void { + if (this._itemOwners.has(owner.name)) + throw new Error(`Item owner "${owner.name}" is already registered`); + this._itemOwners.set(owner.name, { plugin, owner }); + return () => this._itemOwners.delete(owner.name); + } + + /** Names, versions and schemas of the registered item owners. */ + get itemOwners(): { name: string; version: string; schema: number }[] { + return [...this._itemOwners.values()].map(({ owner }) => ({ + name: owner.name, + version: owner.version, + schema: owner.schema, + })); + } + + /** Items of a source (ADR 0003), with every registered owner. */ + async extractMeta(request: MetaRequest): Promise { + if (request.texts?.length) this.setFiles(request.texts); + for (const file of request.files) await this._loadTypes(file.path); + return extractMeta( + this.project, + [...this._itemOwners.values()].map(({ owner }) => owner), + request, + ); + } + + /** + * Applies the root `tsconfig.json`'s `paths` (package-name imports of shared libraries and + * installed packages, ADR 0004). Other options stay the engine's. + */ + setTsconfig(text: string | undefined): void { + const parsed = text ? ts.parseConfigFileTextToJson(`${ROOT}tsconfig.json`, text) : undefined; + const options = (parsed?.config as { compilerOptions?: { paths?: unknown; baseUrl?: unknown } }) + ?.compilerOptions; + const paths: Record = {}; + if (options?.paths && typeof options.paths === 'object') { + const base = typeof options.baseUrl === 'string' ? options.baseUrl : '.'; + for (const [pattern, targets] of Object.entries(options.paths as Record)) { + if (!Array.isArray(targets)) continue; + paths[pattern] = targets + .filter((target): target is string => typeof target === 'string') + .map((target) => joinPosix(base, target)); + } + } + this._paths = paths; + this.project.compilerOptions.set({ baseUrl: ROOT, paths }); + this._scopes = undefined; + } + + /** Removes every analyzer and transformer of a plugin (hot reload, disable). */ + unregisterOwner(owner: string): void { + for (const [name, entry] of this._itemOwners) + if (entry.plugin === owner) this._itemOwners.delete(name); + for (const [id, entry] of this._analyzers) + if (entry.owner === owner) this._analyzers.delete(id); + for (const [id, entry] of this._transformers) + if (entry.owner === owner) this._transformers.delete(id); + } + + analyze(path: string, analyzerId: string, args?: unknown): unknown { + const analyzer = this._analyzers.get(analyzerId); + if (!analyzer) throw new CodeError(`Unknown analyzer "${analyzerId}"`); + return analyzer.run(this._context(path), args); + } + + /** Edits a transformer wants to make; the file itself is not changed here. */ + transform(path: string, transformerId: string, op: unknown): TextEdit[] { + const transformer = this._transformers.get(transformerId); + if (!transformer) throw new CodeError(`Unknown transformer "${transformerId}"`); + const context = this._context(path); + const edit = new EditBuilder(context.text); + transformer.run({ ...context, edit }, op); + return edit.build(); + } + + /** Edits sorting and removing unused imports of a file (TypeScript's organize imports). */ + async organizeImports(path: string): Promise { + await this._loadTypes(path); + const changes = this.project + .getLanguageService() + .compilerObject.organizeImports({ type: 'file', fileName: toFsPath(path) }, {}, {}); + return changes + .filter((change) => change.fileName === toFsPath(path)) + .flatMap((change) => + change.textChanges.map((edit) => ({ + start: edit.span.start, + end: edit.span.start + edit.span.length, + text: edit.newText, + })), + ); + } + + /** Named declarations of a file, in source order (TypeScript's navigation tree). */ + symbols(path: string): CodeSymbol[] { + const file = this.project.getSourceFile(toFsPath(path))?.compilerNode; + if (!file) return []; + const tree = this.project.getLanguageService().compilerObject.getNavigationTree(toFsPath(path)); + const result: CodeSymbol[] = []; + const visit = (node: ts.NavigationTree, container?: string) => { + const span = node.nameSpan ?? node.spans[0]; + const named = node.kind !== 'script' && node.kind !== 'module' && !node.text.startsWith('<'); + if (named && span) { + const position = file.getLineAndCharacterOfPosition(span.start); + result.push({ + name: node.text, + kind: node.kind, + ...(container && { container }), + line: position.line + 1, + column: position.character + 1, + }); + } + for (const child of node.childItems ?? []) visit(child, named ? node.text : container); + }; + visit(tree); + return result.sort((a, b) => a.line - b.line || a.column - b.column); + } + + /** TypeScript diagnostics of files (type declarations of bare imports are loaded first). */ + async diagnostics(paths: readonly string[]): Promise { + for (const path of paths) await this._loadTypes(path); + const programs = new Map(); + const result: CodeDiagnostic[] = []; + for (const path of paths) { + const checked = this._checked(path); + let program = programs.get(checked); + if (!program) programs.set(checked, (program = checked.getProgram().compilerObject)); + const file = program.getSourceFile(toFsPath(path)); + if (!file) continue; + for (const diagnostic of [ + ...program.getSyntacticDiagnostics(file), + ...program.getSemanticDiagnostics(file), + ]) { + const position = file.getLineAndCharacterOfPosition(diagnostic.start ?? 0); + result.push({ + path, + start: diagnostic.start ?? 0, + length: diagnostic.length ?? 0, + line: position.line + 1, + column: position.character + 1, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + severity: + diagnostic.category === ts.DiagnosticCategory.Error + ? 'error' + : diagnostic.category === ts.DiagnosticCategory.Warning + ? 'warning' + : 'info', + code: diagnostic.code, + source: 'typescript', + }); + } + } + return result; + } + + /** Roots of the apps that have their own `node_modules` (where type declarations were found). */ + private _scopeRoots(): string[] { + const roots = new Set(); + for (const { found } of this._typeRoots.values()) + for (const root of found) if (root) roots.add(root); + return [...roots].sort(); + } + + private _scopeOf(path: string, roots: readonly string[]): string { + return roots.find((root) => path === root || path.startsWith(`${root}/`)) ?? ''; + } + + /** The program a file is checked in: its app's, with the files outside of every app. */ + private _checked(path: string): Project { + const roots = this._scopeRoots(); + if (roots.length < 2) return this.project; + const scope = this._scopeOf(path, roots); + if (!scope) return this.project; + this._scopes ??= new Map(); + let project = this._scopes.get(scope); + if (!project) { + project = new Project({ + useInMemoryFileSystem: true, + compilerOptions: this.project.getCompilerOptions(), + }); + for (const file of this.project.getSourceFiles()) { + const owner = this._scopeOf(toProjectPath(file.getFilePath()), roots); + if (owner === '' || owner === scope) + project.createSourceFile(file.getFilePath(), file.getFullText(), { overwrite: true }); + } + this._scopes.set(scope, project); + } + return project; + } + + private _syncScopes(path: string, text: string | undefined): void { + if (!this._scopes) return; + const roots = this._scopeRoots(); + const owner = this._scopeOf(path, roots); + for (const [scope, project] of this._scopes) { + if (owner !== '' && owner !== scope) continue; + const existing = project.getSourceFile(toFsPath(path)); + if (text === undefined) { + if (existing) project.removeSourceFile(existing); + } else if (existing) { + if (existing.getFullText() !== text) existing.replaceWithText(text); + } else project.createSourceFile(toFsPath(path), text, { overwrite: true }); + } + } + + private _context(path: string): AnalyzeContext { + const file = this.project.getSourceFile(toFsPath(path)); + if (!file) throw new CodeError(`File not loaded: ${path}`, path); + return { + path, + file, + project: this.project, + text: file.getFullText(), + ref: (node) => { + const kind = node.getKindName(); + return { + id: `${path}:${kind}@${node.getStart()}`, + path, + kind, + start: node.getStart(), + end: node.getEnd(), + }; + }, + resolve: (ref) => { + const node = file.getDescendantAtPos(ref.start); + for (let current: Node | undefined = node; current; current = current.getParent()) { + if (current.getStart() === ref.start && current.getKindName() === ref.kind) + return current; + if (current.getStart() < ref.start) break; + } + return undefined; + }, + literals: { literalToValue, valueToLiteral, readExportedLiteral, quoteStyle }, + moduleSpecifier: (targetPath, fromPath = path) => + moduleSpecifierFor(fromPath, targetPath, this._paths), + }; + } + + private async _loadTypes(path: string): Promise { + const resolve = this._options.resolveTypes; + const file = this.project.getSourceFile(toFsPath(path)); + if (!resolve || !file) return; + const modules = new Set(); + for (const declaration of file.getImportDeclarations()) + modules.add(declaration.getModuleSpecifierValue()); + for (const declaration of file.getExportDeclarations()) { + const specifier = declaration.getModuleSpecifierValue(); + if (specifier) modules.add(specifier); + } + for (const module of modules) { + if (module.startsWith('.') || module.startsWith('/') || module.startsWith('node:')) continue; + if (Object.keys(this._paths).some((pattern) => matchesPathPattern(pattern, module))) continue; + const packageName = module.startsWith('@') + ? module.split('/').slice(0, 2).join('/') + : module.split('/')[0]!; + const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; + let roots = this._typeRoots.get(packageName); + if (!roots) this._typeRoots.set(packageName, (roots = { found: [], asked: new Set() })); + const visible = roots.found.some( + (root) => root === '' || dir === root || dir.startsWith(`${root}/`), + ); + if (visible || roots.asked.has(dir)) continue; + roots.asked.add(dir); + try { + const files = await resolve(packageName, path); + const marker = files[0]?.path.indexOf('node_modules/') ?? -1; + if (marker >= 0) { + const root = files[0]!.path.slice(0, marker).replace(/\/$/, ''); + if (root && !this._scopeRoots().includes(root)) this._scopes = undefined; + roots.found.push(root); + } + this.setFiles(files); + } catch (error) { + roots.asked.delete(dir); + this._options.onTypesFailed?.( + packageName, + path, + error instanceof Error ? error.message : String(error), + ); + } + } + } +} diff --git a/packages/code/src/engine/code-engine.type.ts b/packages/code/src/engine/code-engine.type.ts new file mode 100644 index 00000000..ee6418d2 --- /dev/null +++ b/packages/code/src/engine/code-engine.type.ts @@ -0,0 +1,50 @@ +import { type Node, type Project, type SourceFile } from 'ts-morph'; + +import { type EditBuilder } from './edit-builder'; +import type { NodeRef, SourceText } from './engine.type'; +import { + type literalToValue, + type quoteStyle, + type readExportedLiteral, + type valueToLiteral, +} from './literals'; + +export interface AnalyzeContext { + readonly literals: { + readonly literalToValue: typeof literalToValue; + readonly valueToLiteral: typeof valueToLiteral; + readonly readExportedLiteral: typeof readExportedLiteral; + readonly quoteStyle: typeof quoteStyle; + }; + readonly path: string; + readonly file: SourceFile; + readonly project: Project; + readonly text: string; + /** Stable reference of a node for models returned to the main thread. */ + ref(node: Node): NodeRef; + /** Node at a reference, if the file still has it. */ + resolve(ref: NodeRef): Node | undefined; + /** + * How this file (or `fromPath`) imports another project file: by package name when a + * `tsconfig.json` `paths` pattern maps it (`@me/shared/components/player`), else a relative + * path. No extension. + */ + moduleSpecifier(targetPath: string, fromPath?: string): string; +} + +export interface TransformContext extends AnalyzeContext { + readonly edit: EditBuilder; +} + +export type Analyzer = (context: AnalyzeContext, args: unknown) => unknown; +export type Transformer = (context: TransformContext, op: unknown) => void; + +export interface CodeEngineOptions { + /** Fetches type declarations (`node_modules//…`) of a bare import, once per module. */ + readonly resolveTypes?: (module: string, from: string) => Promise; + /** + * The types of a module could not be fetched (its imports then show as errors in Problems). + * It is asked again for the next check of a file importing it. + */ + readonly onTypesFailed?: (module: string, from: string, message: string) => void; +} diff --git a/packages/code/src/engine/code-file.const.ts b/packages/code/src/engine/code-file.const.ts new file mode 100644 index 00000000..a81d53f9 --- /dev/null +++ b/packages/code/src/engine/code-file.const.ts @@ -0,0 +1,2 @@ +/** Files the code engine reads as code. Kept apart from the engine: the main thread needs it too. */ +export const CODE_FILE = /\.(?:[cm]?[jt]sx?|d\.ts)$/; diff --git a/packages/code/src/engine/code.exception.ts b/packages/code/src/engine/code.exception.ts new file mode 100644 index 00000000..02ed4bf3 --- /dev/null +++ b/packages/code/src/engine/code.exception.ts @@ -0,0 +1,11 @@ +/** Thrown by transformers/analyzers for problems users should see (not bugs). */ +export class CodeError extends Error { + constructor( + message: string, + readonly path?: string, + readonly start?: number, + ) { + super(message); + this.name = 'CodeError'; + } +} diff --git a/packages/code/src/engine/edit-builder.ts b/packages/code/src/engine/edit-builder.ts new file mode 100644 index 00000000..3dd4ab56 --- /dev/null +++ b/packages/code/src/engine/edit-builder.ts @@ -0,0 +1,85 @@ +import type { Node } from 'ts-morph'; + +import { CodeError } from './code.exception'; +import type { TextEdit } from './engine.type'; + +type Target = Node | { readonly start: number; readonly end: number }; + +const range = (target: Target) => + 'getStart' in target ? { start: target.getStart(), end: target.getEnd() } : target; + +/** + * Collects edits as replacements of node ranges. Code outside the edited ranges is never + * touched, so formatting and comments elsewhere are preserved by construction. + */ +export class EditBuilder { + private readonly _edits: TextEdit[] = []; + + constructor(private readonly _text: string) {} + + replace(target: Target, text: string): this { + const { start, end } = range(target); + return this._push({ start, end, text }); + } + + insertBefore(target: Target, text: string): this { + const { start } = range(target); + return this._push({ start, end: start, text }); + } + + insertAfter(target: Target, text: string): this { + const { end } = range(target); + return this._push({ start: end, end, text }); + } + + insertAt(offset: number, text: string): this { + return this._push({ start: offset, end: offset, text }); + } + + remove(target: Target): this { + const { start, end } = range(target); + return this._push({ start, end, text: '' }); + } + + /** Removes whole lines covered by the node (with its indentation and line break). */ + removeLines(target: Target): this { + const { start, end } = range(target); + const lineStart = this._text.lastIndexOf('\n', start - 1) + 1; + const newline = this._text.indexOf('\n', end); + const lineEnd = newline < 0 ? this._text.length : newline + 1; + const before = this._text.slice(lineStart, start); + const after = this._text.slice(end, newline < 0 ? this._text.length : newline); + return before.trim() === '' && after.trim().replace(/^;/, '') === '' + ? this._push({ start: lineStart, end: lineEnd, text: '' }) + : this._push({ start, end, text: '' }); + } + + /** Indentation of the line where the node starts. */ + indentationOf(target: Target): string { + const { start } = range(target); + const lineStart = this._text.lastIndexOf('\n', start - 1) + 1; + return /^[ \t]*/.exec(this._text.slice(lineStart, start))![0]; + } + + /** Edits sorted by position; overlapping edits are a programming error. */ + build(): TextEdit[] { + const sorted = [...this._edits].sort((a, b) => a.start - b.start || a.end - b.end); + for (let i = 1; i < sorted.length; i++) { + const previous = sorted[i - 1]!; + if (sorted[i]!.start < previous.end) { + throw new CodeError( + `Overlapping edits at ${previous.start}-${previous.end} and ${sorted[i]!.start}`, + ); + } + } + return sorted; + } + + private _push(edit: TextEdit): this { + if (edit.start < 0 || edit.end > this._text.length || edit.start > edit.end) { + throw new CodeError(`Edit out of range: ${edit.start}-${edit.end}`); + } + if (edit.text !== this._text.slice(edit.start, edit.end)) this._edits.push(edit); + return this; + } +} diff --git a/packages/code/src/engine/engine.type.ts b/packages/code/src/engine/engine.type.ts new file mode 100644 index 00000000..0f639633 --- /dev/null +++ b/packages/code/src/engine/engine.type.ts @@ -0,0 +1,46 @@ +import type { TextEdit } from '@nanoforge-dev/editor-history'; + +export type { TextEdit }; + +/** A reference to a syntax node, stable while the file does not change around it. */ +export interface NodeRef { + /** `:@` */ + readonly id: string; + readonly path: string; + readonly kind: string; + readonly start: number; + readonly end: number; +} + +export interface CodeDiagnostic { + /** Project-relative file; empty for a problem that has no file (a failed build). */ + readonly path: string; + /** Offset in the file's text; -1 when only `line` and `column` are known (build errors). */ + readonly start: number; + readonly length: number; + /** 1-based position, when the reporter knows it. */ + readonly line?: number; + readonly column?: number; + readonly message: string; + readonly severity: 'error' | 'warning' | 'info'; + readonly code?: number | string; + /** `typescript` or the analyzer/plugin that reported it. */ + readonly source: string; +} + +/** A named declaration of a file, for outlines and "go to symbol". */ +export interface CodeSymbol { + readonly name: string; + /** TypeScript's kind: `class`, `function`, `method`, `property`, `const`, `interface`… */ + readonly kind: string; + /** The declaration that holds it (`Position` for a field of the class). */ + readonly container?: string; + /** 1-based position of the name. */ + readonly line: number; + readonly column: number; +} + +export interface SourceText { + readonly path: string; + readonly text: string; +} diff --git a/packages/code/src/engine/extract-meta.ts b/packages/code/src/engine/extract-meta.ts new file mode 100644 index 00000000..2d03e66c --- /dev/null +++ b/packages/code/src/engine/extract-meta.ts @@ -0,0 +1,114 @@ +import { Node, type Project } from 'ts-morph'; + +import { + type ItemMeta, + type ItemOwner, + type MetaDiagnostic, + type MetaSource, + defaultModulePath, + extractItems, + sourceHash, +} from '@nanoforge-dev/editor-meta'; + +/** A file of a source to extract items from. */ +export interface MetaSourceFile { + /** Project path. */ + readonly path: string; + /** The item folder it is in (`dir.components`, `dir.systems`), if any. */ + readonly folder?: 'components' | 'systems'; + /** Listed in a manifest's `items`: each export is an item. */ + readonly listed?: boolean; + /** The exports a manifest's `contents` name: only those are items. */ + readonly exports?: readonly string[]; +} + +/** The folder of a source and the base of its item references (`app:client`, `@me/shared`). */ +export interface MetaRoot { + readonly path: string; + readonly ref: string; +} + +export interface MetaRequest { + readonly source: MetaSource; + readonly files: readonly MetaSourceFile[]; + /** Every source of the project, so items of other sources get their reference. */ + readonly roots: readonly MetaRoot[]; + /** + * Current texts of the files (open documents included), applied before extracting so the + * items and the hash come from the same text. + */ + readonly texts?: readonly { readonly path: string; readonly text: string }[]; +} + +export interface MetaResponse { + readonly items: ItemMeta[]; + readonly diagnostics: MetaDiagnostic[]; + /** Owners that ran, with their version and schema (the meta file's `owners`). */ + readonly owners: Record; + /** Hash of the files' current text (the meta file's `sourceHash`). */ + readonly sourceHash: string; + /** Files that aren't loaded in the engine. */ + readonly missing: string[]; +} + +const ROOT = '/project/'; +const toProjectPath = (path: string) => (path.startsWith(ROOT) ? path.slice(ROOT.length) : path); + +/** Items of a source, extracted with the registered owners. */ +export const extractMeta = async ( + project: Project, + owners: readonly ItemOwner[], + request: MetaRequest, +): Promise => { + const roots = [...request.roots].sort((a, b) => b.path.length - a.path.length); + const refOf = (node: Node) => { + let symbol = node.getSymbol(); + if (symbol?.isAlias()) symbol = symbol.getAliasedSymbol(); + const declaration = symbol?.getDeclarations()[0]; + if (!symbol || !declaration) return undefined; + const exportable = Node.isVariableDeclaration(declaration) + ? declaration.getVariableStatement() + : declaration; + if (!exportable || !Node.isExportable(exportable) || !exportable.isExported()) return undefined; + const path = toProjectPath(declaration.getSourceFile().getFilePath()); + const root = roots.find( + (candidate) => + path === candidate.path || path.startsWith(`${candidate.path}/`) || candidate.path === '', + ); + return root ? `${root.ref}#${symbol.getName()}` : undefined; + }; + + const items: ItemMeta[] = []; + const diagnostics: MetaDiagnostic[] = []; + const texts: { path: string; text: string }[] = []; + const missing: string[] = []; + for (const entry of request.files) { + const file = project.getSourceFile(ROOT + entry.path); + if (!file) { + missing.push(entry.path); + continue; + } + texts.push({ path: entry.path, text: file.getFullText() }); + const result = extractItems(file, { + source: request.source, + path: entry.path, + owners, + ...(entry.folder && { folder: entry.folder }), + ...(entry.listed && { listed: true }), + ...(entry.exports && { exports: entry.exports }), + modulePath: (declarationFile) => defaultModulePath(declarationFile), + refOf, + }); + items.push(...result.items); + diagnostics.push(...result.diagnostics); + } + return { + items, + diagnostics, + owners: Object.fromEntries( + owners.map((owner) => [owner.name, { version: owner.version, schema: owner.schema }]), + ), + sourceHash: await sourceHash(texts), + missing, + }; +}; diff --git a/packages/code/src/engine/index.ts b/packages/code/src/engine/index.ts new file mode 100644 index 00000000..a14ec724 --- /dev/null +++ b/packages/code/src/engine/index.ts @@ -0,0 +1,11 @@ +export * from './code-engine.type'; +export * from './code-engine'; +export { moduleSpecifierFor } from './module-specifier'; +export * from './edit-builder'; +export * from './literals'; +export * from './extract-meta'; +export * from './engine.type'; +export * from './code.exception'; +export * from './worker-sdk'; +export * from './code-file.const'; +export * from './rename-package.const'; diff --git a/packages/code/src/engine/literals.ts b/packages/code/src/engine/literals.ts new file mode 100644 index 00000000..5a448bd3 --- /dev/null +++ b/packages/code/src/engine/literals.ts @@ -0,0 +1,133 @@ +import { type Expression, Node, type SourceFile, SyntaxKind } from 'ts-morph'; + +import { CodeError } from './code.exception'; + +export type LiteralValue = + | string + | number + | boolean + | null + | readonly LiteralValue[] + | { readonly [key: string]: LiteralValue }; + +const unwrap = (node: Node): Node => { + let current = node; + while ( + Node.isParenthesizedExpression(current) || + Node.isAsExpression(current) || + Node.isSatisfiesExpression(current) || + Node.isTypeAssertion(current) + ) { + current = current.getExpression(); + } + return current; +}; + +/** Value of a literal expression; throws `CodeError` (with position) for anything else. */ +export const literalToValue = (expression: Node): LiteralValue => { + const node = unwrap(expression); + const fail = (): never => { + throw new CodeError( + `Only literal values are supported here, found \`${node.getText().slice(0, 60)}\``, + node.getSourceFile().getFilePath(), + node.getStart(), + ); + }; + if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) + return node.getLiteralValue(); + if (Node.isNumericLiteral(node)) return node.getLiteralValue(); + if (Node.isTrueLiteral(node)) return true; + if (Node.isFalseLiteral(node)) return false; + if (Node.isNullLiteral(node)) return null; + if (Node.isPrefixUnaryExpression(node)) { + const operand = node.getOperand(); + if (!Node.isNumericLiteral(operand)) return fail(); + const value = operand.getLiteralValue(); + if (node.getOperatorToken() === SyntaxKind.MinusToken) return -value; + if (node.getOperatorToken() === SyntaxKind.PlusToken) return value; + } + if (Node.isArrayLiteralExpression(node)) + return node.getElements().map((element) => literalToValue(element)); + if (Node.isObjectLiteralExpression(node)) { + const result: Record = {}; + for (const property of node.getProperties()) { + if (!Node.isPropertyAssignment(property)) return fail(); + const name = property.getNameNode(); + const key = + Node.isIdentifier(name) || Node.isStringLiteral(name) || Node.isNumericLiteral(name) + ? String(Node.isIdentifier(name) ? name.getText() : name.getLiteralValue()) + : fail(); + result[key] = literalToValue(property.getInitializerOrThrow()); + } + return result; + } + return fail(); +}; + +export interface PrintOptions { + readonly quote?: '"' | "'"; + /** Indentation of the line the literal starts on. */ + readonly indent?: string; + /** Indentation unit. */ + readonly tab?: string; + /** Objects/arrays longer than this are printed on several lines. */ + readonly maxInline?: number; +} + +const IDENTIFIER = /^[A-Za-z_$][\w$]*$/; + +/** Source code of a value (the inverse of `literalToValue`). */ +export const valueToLiteral = (value: LiteralValue, options: PrintOptions = {}): string => { + const quote = options.quote ?? "'"; + const tab = options.tab ?? ' '; + const maxInline = options.maxInline ?? 60; + const string = (text: string) => + quote + + text + .replace(/\\/g, '\\\\') + .replace(new RegExp(quote, 'g'), `\\${quote}`) + .replace(/\n/g, '\\n') + + quote; + const print = (current: LiteralValue, indent: string): string => { + if (current === null) return 'null'; + if (typeof current === 'string') return string(current); + if (typeof current === 'number') { + if (!Number.isFinite(current)) throw new CodeError(`Cannot write ${current} as a literal`); + return String(current); + } + if (typeof current === 'boolean') return String(current); + const inner = indent + tab; + if (Array.isArray(current)) { + const items = (current as readonly LiteralValue[]).map((item) => print(item, inner)); + const inline = `[${items.join(', ')}]`; + return inline.length <= maxInline && !inline.includes('\n') + ? inline + : `[\n${items.map((item) => inner + item).join(',\n')},\n${indent}]`; + } + const entries = Object.entries(current as Record).map( + ([key, item]) => `${IDENTIFIER.test(key) ? key : string(key)}: ${print(item, inner)}`, + ); + if (!entries.length) return '{}'; + const inline = `{ ${entries.join(', ')} }`; + return inline.length <= maxInline && !inline.includes('\n') + ? inline + : `{\n${entries.map((entry) => inner + entry).join(',\n')},\n${indent}}`; + }; + return print(value, options.indent ?? ''); +}; + +/** Quote style of a file (the first string literal), for code that blends in. */ +export const quoteStyle = (file: SourceFile): '"' | "'" => { + const first = file.getFirstDescendantByKind(SyntaxKind.StringLiteral); + return first?.getText().startsWith('"') ? '"' : "'"; +}; + +/** Value of `export const = ` (e.g. `EDITOR_COMPONENT_MANIFEST`). */ +export const readExportedLiteral = (file: SourceFile, name: string): LiteralValue | undefined => { + const declaration = file.getVariableDeclaration(name); + if (!declaration || !declaration.isExported()) return undefined; + const initializer = declaration.getInitializer(); + if (!initializer) + throw new CodeError(`${name} has no value`, file.getFilePath(), declaration.getStart()); + return literalToValue(initializer as Expression); +}; diff --git a/packages/code/src/engine/module-specifier.ts b/packages/code/src/engine/module-specifier.ts new file mode 100644 index 00000000..3fe4086b --- /dev/null +++ b/packages/code/src/engine/module-specifier.ts @@ -0,0 +1,56 @@ +/** `a/./b/../c` → `a/c`, without a leading `./`. */ +export const joinPosix = (base: string, target: string): string => { + const parts: string[] = []; + for (const part of `${base}/${target}`.split('/')) { + if (part === '' || part === '.') continue; + if (part === '..') parts.pop(); + else parts.push(part); + } + return parts.join('/'); +}; + +/** Whether a module matches a `paths` pattern (`@me/shared/*`). */ +export const matchesPathPattern = (pattern: string, module: string): boolean => { + const star = pattern.indexOf('*'); + if (star < 0) return pattern === module; + return module.startsWith(pattern.slice(0, star)) && module.endsWith(pattern.slice(star + 1)); +}; + +const withoutExtension = (path: string) => + path.replace(/(\.d)?\.[cm]?[jt]sx?$/, '').replace(/\/index$/, ''); + +/** Import specifier of `target` from `from` (project paths), through `paths` when one maps it. */ +export const moduleSpecifierFor = ( + from: string, + target: string, + paths: Readonly>, +): string => { + const bare = withoutExtension(target); + for (const [pattern, targets] of Object.entries(paths)) { + for (const mapped of targets) { + const star = mapped.indexOf('*'); + const patternStar = pattern.indexOf('*'); + if (star < 0 || patternStar < 0) { + if (withoutExtension(mapped) === bare) return pattern; + continue; + } + const prefix = mapped.slice(0, star); + const suffix = withoutExtension(mapped.slice(star + 1)); + if (!bare.startsWith(prefix) || !bare.endsWith(suffix)) continue; + const middle = bare.slice(prefix.length, bare.length - suffix.length); + return pattern.slice(0, patternStar) + middle + pattern.slice(patternStar + 1); + } + } + const fromParts = from.split('/').slice(0, -1); + const toParts = bare.split('/'); + let common = 0; + while ( + common < fromParts.length && + common < toParts.length && + fromParts[common] === toParts[common] + ) + common++; + const up = fromParts.length - common; + const relative = [...Array(up).fill('..'), ...toParts.slice(common)].join('/'); + return up === 0 ? `./${relative}` : relative; +}; diff --git a/packages/code/src/engine/rename-package.const.ts b/packages/code/src/engine/rename-package.const.ts new file mode 100644 index 00000000..88d1df30 --- /dev/null +++ b/packages/code/src/engine/rename-package.const.ts @@ -0,0 +1,5 @@ +/** + * Core transformer pointing the imports of a package (`@pong/shared`, `@pong/shared/x`) at a + * new name; op `{ from, to }`. Kept apart from the engine: the main thread needs the id too. + */ +export const RENAME_PACKAGE_TRANSFORMER = 'core.renamePackage'; diff --git a/packages/code/src/engine/rename-package.ts b/packages/code/src/engine/rename-package.ts new file mode 100644 index 00000000..82a4a828 --- /dev/null +++ b/packages/code/src/engine/rename-package.ts @@ -0,0 +1,34 @@ +import { Node, SyntaxKind } from 'ts-morph'; + +import type { TransformContext } from './code-engine.type'; + +/** Whether a string literal is the module of an import, an export, `import()` or `require()`. */ +const isModuleSpecifier = (node: Node): boolean => { + const parent = node.getParent(); + if (!parent) return false; + if (Node.isImportDeclaration(parent) || Node.isExportDeclaration(parent)) + return parent.getModuleSpecifier() === node; + if (Node.isExternalModuleReference(parent)) return true; + if (Node.isLiteralTypeNode(parent)) return Node.isImportTypeNode(parent.getParent()); + if (Node.isCallExpression(parent)) { + const callee = parent.getExpression(); + return ( + parent.getArguments()[0] === node && + (callee.getKind() === SyntaxKind.ImportKeyword || + (Node.isIdentifier(callee) && callee.getText() === 'require')) + ); + } + return false; +}; + +/** Points the module specifiers of package `from` (and its sub-paths) at `to`. */ +export const renamePackage = (context: TransformContext, op: unknown): void => { + const { from, to } = op as { from: string; to: string }; + for (const literal of context.file.getDescendantsOfKind(SyntaxKind.StringLiteral)) { + const value = literal.getLiteralValue(); + if (value !== from && !value.startsWith(`${from}/`)) continue; + if (!isModuleSpecifier(literal)) continue; + const quote = literal.getText()[0]; + context.edit.replace(literal, `${quote}${to}${value.slice(from.length)}${quote}`); + } +}; diff --git a/packages/code/src/engine/worker-sdk.ts b/packages/code/src/engine/worker-sdk.ts new file mode 100644 index 00000000..ffe7b253 --- /dev/null +++ b/packages/code/src/engine/worker-sdk.ts @@ -0,0 +1,33 @@ +import type { ItemOwner } from '@nanoforge-dev/editor-meta'; + +import type { CodeEngine } from './code-engine'; +import type { Analyzer, Transformer } from './code-engine.type'; +import { literalToValue, quoteStyle, readExportedLiteral, valueToLiteral } from './literals'; + +/** + * What plugin worker entries (`entry.worker`) import as `@nanoforge-dev/editor-sdk/worker`. + * Registrations are owned by the plugin and removed when it unloads. + */ +export interface WorkerSdk { + readonly literals: { + readonly literalToValue: typeof literalToValue; + readonly valueToLiteral: typeof valueToLiteral; + readonly readExportedLiteral: typeof readExportedLiteral; + readonly quoteStyle: typeof quoteStyle; + }; + registerAnalyzer(id: string, analyzer: Analyzer): () => void; + registerTransformer(id: string, transformer: Transformer): () => void; + /** Registers an owner of item data (ADR 0003): its tags, inference, claims and `extract`. */ + registerItemOwner(owner: ItemOwner): () => void; +} + +export interface WorkerPluginModule { + activate?(sdk: WorkerSdk): void | Promise; +} + +export const createWorkerSdk = (engine: CodeEngine, owner: string): WorkerSdk => ({ + registerAnalyzer: (id, analyzer) => engine.registerAnalyzer(id, analyzer, owner), + registerTransformer: (id, transformer) => engine.registerTransformer(id, transformer, owner), + registerItemOwner: (itemOwner) => engine.registerItemOwner(itemOwner, owner), + literals: { literalToValue, valueToLiteral, readExportedLiteral, quoteStyle }, +}); diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts new file mode 100644 index 00000000..e1921c15 --- /dev/null +++ b/packages/code/src/index.ts @@ -0,0 +1,11 @@ +export * from './worker-rpc'; +export * from './service'; +export * from './catalog'; +export * from './codegen'; +export * from './diagnostics'; +export * from './document'; +export type * from './engine'; +export * from './engine/code-file.const'; +export * from './engine/rename-package.const'; +export * from './engine/engine.type'; +export * from './engine/code.exception'; diff --git a/packages/code/src/service/code-service.ts b/packages/code/src/service/code-service.ts new file mode 100644 index 00000000..cb6813f3 --- /dev/null +++ b/packages/code/src/service/code-service.ts @@ -0,0 +1,409 @@ +import type { CommandOrigin, HistoryCommand } from '@nanoforge-dev/editor-history'; +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + type Logger, + createToken, +} from '@nanoforge-dev/editor-kernel'; +import type { ProjectFs } from '@nanoforge-dev/editor-project'; +import { CodeContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; + +import type { DiagnosticsService } from '../diagnostics/diagnostics-service'; +import { type DocumentService, fingerprint } from '../document/document-service'; +import { CODE_FILE } from '../engine/code-file.const'; +import type { CodeDiagnostic, CodeSymbol, TextEdit } from '../engine/engine.type'; +import type { MetaRequest, MetaResponse } from '../engine/extract-meta'; +import type { EngineApi, EngineHostApi } from '../worker-rpc/serve-engine'; +import { type MessageEndpoint, type Remote, expose, wrap } from '../worker-rpc/worker-rpc'; + +/** Installed packages are read-only: their problems are not the user's to fix. */ +const NOT_CHECKED = /(^|\/)nf_modules\//; +const CHECK_BATCH = 16; + +/** Folders never mirrored into the code worker. */ +const EXCLUDED = /(^|\/)(node_modules|dist|coverage|\.nanoforge|\.git|\.turbo)(\/|$)/; + +const TSCONFIG = 'tsconfig.json'; + +/** Marks a step in the performance timeline, and measures it from `since` (`:time`). */ +const mark = (name: string, since?: string): void => { + if (typeof performance === 'undefined' || !performance.mark) return; + performance.mark(name); + if (since && performance.getEntriesByName(since).length) + performance.measure(`${name}:time`, since, name); +}; + +export const isCodeFile = (path: string): boolean => CODE_FILE.test(path) && !EXCLUDED.test(path); + +export interface WorkerEndpoint extends MessageEndpoint { + terminate?(): void; +} + +export interface CodeServiceOptions { + readonly projectId: string; + readonly fs: ProjectFs; + readonly rpc: RpcClient; + readonly documents: DocumentService; + readonly diagnostics: DiagnosticsService; + /** Starts the code worker (a Web Worker in browsers, a MessagePort in tests). */ + readonly createWorker: () => WorkerEndpoint; + readonly logger?: Logger; + /** Delay before re-checking watched files after a change. */ + readonly diagnosticsDelayMs?: number; +} + +/** + * TypeScript services of the open project, computed in a worker started on first use. The + * worker mirrors the project's code files and the unsaved text of open documents. + */ +export class CodeService implements Disposable { + private _engine: Promise> | undefined; + private _endpoint: WorkerEndpoint | undefined; + private readonly _store = new DisposableStore(); + private readonly _watched = new Set(); + private readonly _pendingDiagnostics = new Set(); + private _diagnosticsTimer: ReturnType | undefined; + private readonly _plugins = new Map(); + private _projectChecks = 0; + private _projectCheckRun = 0; + private _projectCheckTimer: ReturnType | undefined; + private readonly _onDidChangeOwners = new Emitter(); + + /** A worker plugin was loaded or unloaded (item owners may have changed). */ + readonly onDidChangeOwners: Event = this._onDidChangeOwners.event; + + constructor(private readonly _options: CodeServiceOptions) {} + + /** + * The engine, started (and the project mirrored) on first call. A start that fails, or a + * worker that crashes, is forgotten: the next call starts a new worker. + */ + engine(): Promise> { + if (!this._engine) { + const started = new DisposableStore(); + const engine = this._start(started); + this._engine = engine; + engine.catch(() => { + if (this._engine === engine) this._stop(started); + }); + } + return this._engine; + } + + async analyze(path: string, analyzerId: string, args?: unknown): Promise { + const engine = await this._current(path); + return (await engine.analyze(path, analyzerId, args)) as T; + } + + /** Edits a transformer wants, as offsets in the file's current text (open documents included). */ + async transform(path: string, transformerId: string, op: unknown): Promise { + return (await this._current(path)).transform(path, transformerId, op); + } + + /** + * An undoable command applying a transformer's edits through the DocumentService (the open + * editor if any). Returns undefined when the transformer changes nothing. + */ + async edit( + path: string, + transformerId: string, + op: unknown, + options: { label: string; origin?: CommandOrigin; mergeKey?: string }, + ): Promise { + const engine = await this.engine(); + const text = await this._options.documents.getText(path); + await engine.setFiles([{ path, text }]); + const edits = await engine.transform(path, transformerId, op); + if (!edits.length) return undefined; + return this._options.documents.editCommand(path, edits, { + ...options, + basedOn: fingerprint(text), + }); + } + + /** Checks files now and publishes TypeScript problems. */ + /** Edits organizing the imports of a code file (current content, open documents included). */ + async organizeImports(path: string): Promise { + const engine = await this.engine(); + await engine.setFiles([{ path, text: await this._options.documents.getText(path) }]); + return engine.organizeImports(path); + } + + /** Named declarations of a code file's current text (open documents included). */ + async symbols(path: string): Promise { + return (await this._current(path)).symbols(path); + } + + /** Type declarations of a package as resolved from a file (node_modules lookup). */ + typeFiles(module: string, from: string): Promise { + return this._options.rpc + .api(CodeContract) + .typeFiles({ project: this._options.projectId, from, module }); + } + + /** Formats a file's text with Prettier (see `code.format`). */ + format( + path: string, + text: string, + ): Promise<{ text: string; formatter: 'project' | 'bundled' | 'none' }> { + return this._options.rpc + .api(CodeContract) + .format({ project: this._options.projectId, path, text }); + } + + async refreshDiagnostics(paths: readonly string[]): Promise { + const diagnostics = await (await this.engine()).diagnostics(paths); + for (const path of paths) { + this._options.diagnostics.set( + 'typescript', + path, + diagnostics.filter((diagnostic) => diagnostic.path === path), + ); + } + return diagnostics; + } + + /** Keeps TypeScript problems of a file up to date while watched (open editors). */ + watchDiagnostics(path: string): Disposable { + this._watched.add(path); + this._scheduleDiagnostics(path, false); + return { + dispose: () => { + this._watched.delete(path); + if (!this._projectChecks) this._options.diagnostics.clear('typescript', path); + }, + }; + } + + /** + * Keeps the TypeScript problems of every code file of the project up to date, not only of + * watched files, until disposed (the Problems panel). Files are checked when it starts and + * again after changes: a change in one file can break another. + */ + checkProject(): Disposable { + if (this._projectChecks++ === 0) this._scheduleProjectCheck(0); + let disposed = false; + return { + dispose: () => { + if (disposed) return; + disposed = true; + if (--this._projectChecks > 0) return; + clearTimeout(this._projectCheckTimer); + this._projectCheckRun++; + for (const path of this._options.diagnostics.uris('typescript')) { + if (!this._watched.has(path)) this._options.diagnostics.clear('typescript', path); + } + }, + }; + } + + /** Items of a source (ADR 0003), extracted by the worker with the registered owners. */ + async extractMeta(request: MetaRequest): Promise { + return (await this.engine()).extractMeta(request); + } + + /** Item owners registered by worker plugins (name, version, schema). */ + async itemOwners(): Promise<{ name: string; version: string; schema: number }[]> { + return (await this.engine()).itemOwners(); + } + + /** Loads a plugin's worker entry (`entry.worker`); reloading replaces its registrations. */ + async loadWorkerPlugin(owner: string, url: string): Promise { + this._plugins.set(owner, url); + try { + if (this._engine) await (await this._engine).loadPlugin(owner, url); + } finally { + this._onDidChangeOwners.fire(); + } + } + + async unloadWorkerPlugin(owner: string): Promise { + this._plugins.delete(owner); + try { + if (this._engine) await (await this._engine).unloadPlugin(owner); + } finally { + this._onDidChangeOwners.fire(); + } + } + + dispose(): void { + clearTimeout(this._diagnosticsTimer); + clearTimeout(this._projectCheckTimer); + this._projectCheckRun++; + this._store.dispose(); + this._onDidChangeOwners.dispose(); + this._endpoint?.terminate?.(); + } + + /** The engine, with the file's current text mirrored (the change stream may lag). */ + private async _current(path: string): Promise> { + const engine = await this.engine(); + await engine.setFiles([{ path, text: await this._options.documents.getText(path) }]); + return engine; + } + + /** Stops a worker that failed, and what was set up for it. */ + private _stop(started: DisposableStore): void { + this._engine = undefined; + this._store.delete(started); + started.dispose(); + this._endpoint?.terminate?.(); + this._endpoint = undefined; + } + + private async _start(started: DisposableStore): Promise> { + mark('nf:code:created'); + const endpoint = this._options.createWorker(); + this._endpoint = endpoint; + this._store.add(started); + const onError = () => { + if (this._endpoint === endpoint) this._stop(started); + }; + endpoint.addEventListener('error', onError); + started.add({ dispose: () => endpoint.removeEventListener('error', onError) }); + const host: EngineHostApi = { + resolveTypes: (module, from) => + this._options.rpc + .api(CodeContract) + .typeFiles({ project: this._options.projectId, from, module }), + typesFailed: (module, from, message) => + this._options.logger?.warn( + `The types of ${module} (imported by ${from}) could not be read: ${message}`, + ), + }; + started.add({ dispose: expose(host, endpoint) }); + const engine = wrap(endpoint); + started.add({ dispose: () => engine.dispose() }); + + const syncTsconfig = async () => + engine.setTsconfig( + this._options.fs.entry(TSCONFIG) + ? await this._options.documents.getText(TSCONFIG).catch(() => undefined) + : undefined, + ); + await syncTsconfig(); + mark('nf:code:ready', 'nf:code:created'); + started.add( + this._options.fs.onDidChange((changes) => { + if (changes.some((change) => change.path === TSCONFIG)) + void syncTsconfig().catch((error: unknown) => + this._options.logger?.warn('tsconfig.json could not be applied', error), + ); + }), + ); + + const paths = this._options.fs.entries.filter( + (entry) => entry.kind === 'file' && isCodeFile(entry.path), + ); + for (let i = 0; i < paths.length; i += 32) { + const batch = await Promise.all( + paths.slice(i, i + 32).map(async (entry) => ({ + path: entry.path, + text: await this._options.documents.getText(entry.path), + })), + ); + await engine.setFiles(batch); + } + mark('nf:code:mirrored', 'nf:code:created'); + started.add( + this._options.fs.onDidChange((changes) => { + void (async () => { + const deleted = changes + .filter((change) => change.type === 'deleted' && isCodeFile(change.path)) + .map((change) => change.path); + if (deleted.length) await engine.deleteFiles(deleted); + const updated = changes.filter( + (change) => + change.type !== 'deleted' && + change.kind === 'file' && + isCodeFile(change.path) && + !this._options.documents.get(change.path), + ); + if (updated.length) { + await engine.setFiles( + await Promise.all( + updated.map(async (change) => ({ + path: change.path, + text: await this._options.documents.getText(change.path), + })), + ), + ); + } + for (const change of [...deleted.map((path) => ({ path })), ...updated]) + this._scheduleDiagnostics(change.path); + })().catch((error: unknown) => + this._options.logger?.warn('Code mirror update failed', error), + ); + }), + ); + started.add( + this._options.documents.onDidChange(({ uri }) => { + if (!isCodeFile(uri)) return; + void this._options.documents + .getText(uri) + .then((text) => engine.setFiles([{ path: uri, text }])) + .then(() => this._scheduleDiagnostics(uri)) + .catch((error: unknown) => + this._options.logger?.warn(`Code mirror of ${uri} failed`, error), + ); + }), + ); + for (const [owner, url] of this._plugins) { + await engine + .loadPlugin(owner, url) + .catch((error: unknown) => + this._options.logger?.error(`Worker plugin ${owner} failed`, error), + ); + } + if (this._plugins.size) this._onDidChangeOwners.fire(); + return engine; + } + + private _scheduleProjectCheck(delayMs = 1500): void { + if (!this._projectChecks) return; + clearTimeout(this._projectCheckTimer); + this._projectCheckTimer = setTimeout(() => { + this._checkProject().catch((error: unknown) => + this._options.logger?.warn('Project check failed', error), + ); + }, delayMs); + } + + private async _checkProject(): Promise { + const run = ++this._projectCheckRun; + await this.engine(); + const paths = this._options.fs.entries + .filter( + (entry) => entry.kind === 'file' && isCodeFile(entry.path) && !NOT_CHECKED.test(entry.path), + ) + .map((entry) => entry.path); + const checked = new Set(paths); + for (const path of this._options.diagnostics.uris('typescript')) { + if (!checked.has(path) && !this._watched.has(path)) + this._options.diagnostics.clear('typescript', path); + } + for (let index = 0; index < paths.length; index += CHECK_BATCH) { + if (run !== this._projectCheckRun) return; + await this.refreshDiagnostics(paths.slice(index, index + CHECK_BATCH)); + } + } + + private _scheduleDiagnostics(path: string, changed = true): void { + if (changed) this._scheduleProjectCheck(); + if (!this._watched.has(path) && !(this._projectChecks && !NOT_CHECKED.test(path))) return; + this._pendingDiagnostics.add(path); + clearTimeout(this._diagnosticsTimer); + this._diagnosticsTimer = setTimeout(() => { + const paths = [...this._pendingDiagnostics]; + this._pendingDiagnostics.clear(); + this.refreshDiagnostics(paths).catch((error: unknown) => + this._options.logger?.warn('Diagnostics failed', error), + ); + }, this._options.diagnosticsDelayMs ?? 300); + } +} + +export const CodeServiceToken = createToken('code.service'); diff --git a/packages/code/src/service/index.ts b/packages/code/src/service/index.ts new file mode 100644 index 00000000..e8d55ae3 --- /dev/null +++ b/packages/code/src/service/index.ts @@ -0,0 +1 @@ +export * from './code-service'; diff --git a/packages/code/src/worker-rpc/create-browser-worker.ts b/packages/code/src/worker-rpc/create-browser-worker.ts new file mode 100644 index 00000000..49a846ce --- /dev/null +++ b/packages/code/src/worker-rpc/create-browser-worker.ts @@ -0,0 +1,5 @@ +import type { WorkerEndpoint } from '../service/code-service'; + +/** The code worker of browsers (bundled by Vite from this URL). */ +export const createBrowserWorker = (): WorkerEndpoint => + new Worker(new URL('../worker.ts', import.meta.url), { type: 'module', name: 'nanoforge-code' }); diff --git a/packages/code/src/worker-rpc/index.ts b/packages/code/src/worker-rpc/index.ts new file mode 100644 index 00000000..bf477cc4 --- /dev/null +++ b/packages/code/src/worker-rpc/index.ts @@ -0,0 +1,3 @@ +export * from './create-browser-worker'; +export type * from './serve-engine'; +export * from './worker-rpc'; diff --git a/packages/code/src/worker-rpc/serve-engine.ts b/packages/code/src/worker-rpc/serve-engine.ts new file mode 100644 index 00000000..0de93f28 --- /dev/null +++ b/packages/code/src/worker-rpc/serve-engine.ts @@ -0,0 +1,73 @@ +import { CodeEngine } from '../engine/code-engine'; +import type { CodeDiagnostic, CodeSymbol, SourceText, TextEdit } from '../engine/engine.type'; +import type { MetaRequest, MetaResponse } from '../engine/extract-meta'; +import { type WorkerPluginModule, createWorkerSdk } from '../engine/worker-sdk'; +import { type MessageEndpoint, expose, wrap } from './worker-rpc'; + +/** API of the engine as seen from the main thread. */ +export interface EngineApi { + setFiles(files: readonly SourceText[]): void; + deleteFiles(paths: readonly string[]): void; + analyze(path: string, analyzerId: string, args?: unknown): unknown; + transform(path: string, transformerId: string, op: unknown): TextEdit[]; + diagnostics(paths: readonly string[]): Promise; + organizeImports(path: string): Promise; + symbols(path: string): CodeSymbol[]; + setTsconfig(text: string | undefined): void; + extractMeta(request: MetaRequest): Promise; + itemOwners(): { name: string; version: string; schema: number }[]; + /** Imports a plugin's `entry.worker` and calls its `activate(sdk)`. */ + loadPlugin(owner: string, url: string): Promise; + unloadPlugin(owner: string): void; +} + +/** Services the main thread offers to the worker. */ +export interface EngineHostApi { + resolveTypes(module: string, from: string): Promise; + typesFailed(module: string, from: string, message: string): void; +} + +export interface ServeOptions { + /** How plugin modules are imported (tests inject a loader). */ + readonly importModule?: (url: string) => Promise; +} + +/** Runs a code engine behind a message endpoint (the worker's `self`, or a MessagePort). */ +export const serveEngine = ( + endpoint: MessageEndpoint, + options: ServeOptions = {}, +): (() => void) => { + const host = wrap(endpoint); + const engine = new CodeEngine({ + resolveTypes: (module, from) => host.resolveTypes(module, from), + onTypesFailed: (module, from, message) => + void host.typesFailed(module, from, message).catch(() => undefined), + }); + const importModule = options.importModule ?? ((url: string) => import(/* @vite-ignore */ url)); + const api: EngineApi = { + setFiles: (files) => engine.setFiles(files), + deleteFiles: (paths) => engine.deleteFiles(paths), + analyze: (path, analyzerId, args) => engine.analyze(path, analyzerId, args), + transform: (path, transformerId, op) => engine.transform(path, transformerId, op), + diagnostics: (paths) => engine.diagnostics(paths), + symbols: (path) => engine.symbols(path), + organizeImports: (path) => engine.organizeImports(path), + setTsconfig: (text) => engine.setTsconfig(text), + extractMeta: (request) => engine.extractMeta(request), + itemOwners: () => engine.itemOwners, + loadPlugin: async (owner, url) => { + engine.unregisterOwner(owner); + const imported = (await importModule(url)) as WorkerPluginModule & { + default?: WorkerPluginModule; + }; + const plugin = imported.activate ? imported : (imported.default ?? {}); + await plugin.activate?.(createWorkerSdk(engine, owner)); + }, + unloadPlugin: (owner) => engine.unregisterOwner(owner), + }; + const stop = expose(api, endpoint); + return () => { + stop(); + host.dispose(); + }; +}; diff --git a/packages/code/src/worker-rpc/worker-rpc.ts b/packages/code/src/worker-rpc/worker-rpc.ts new file mode 100644 index 00000000..aab0964c --- /dev/null +++ b/packages/code/src/worker-rpc/worker-rpc.ts @@ -0,0 +1,144 @@ +/** + * Minimal typed RPC over `postMessage` (a web worker, a MessagePort): the other side's + * methods become async functions. Errors are rethrown with their message and name. + */ +export interface MessageEndpoint { + postMessage(message: unknown): void; + addEventListener(type: 'message', listener: (event: MessageEvent) => void): void; + addEventListener(type: 'error' | 'messageerror', listener: (event: Event) => void): void; + removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void; + removeEventListener(type: 'error' | 'messageerror', listener: (event: Event) => void): void; + start?(): void; +} + +/** A worker failed (crashed, or sent what could not be read): its calls will never answer. */ +export class WorkerFailedError extends Error { + constructor(message: string) { + super(message); + this.name = 'WorkerFailedError'; + } +} + +type AnyFunction = (...args: never[]) => unknown; + +/** The async view of an API object. */ +export type Remote = { + [K in keyof T as T[K] extends AnyFunction ? K : never]: T[K] extends (...args: infer A) => infer R + ? (...args: A) => Promise> + : never; +}; + +interface Request { + readonly rpc: 'call'; + readonly id: number; + readonly method: string; + readonly args: unknown[]; +} + +interface Response { + readonly rpc: 'result'; + readonly id: number; + readonly value?: unknown; + readonly error?: { name: string; message: string; code?: string; data?: unknown }; +} + +/** Serves `api` on the endpoint. Returns a function that stops serving. */ +export const expose = (api: object, endpoint: MessageEndpoint): (() => void) => { + const listener = async (event: MessageEvent) => { + const message = event.data as Request; + if (message?.rpc !== 'call') return; + const method = (api as Record)[message.method]; + try { + if (typeof method !== 'function') throw new Error(`Unknown method ${message.method}`); + const value: unknown = await method.apply(api, message.args); + endpoint.postMessage({ rpc: 'result', id: message.id, value } satisfies Response); + } catch (error) { + const failure = error as { name?: string; message?: string; code?: string; data?: unknown }; + endpoint.postMessage({ + rpc: 'result', + id: message.id, + error: { + name: failure.name ?? 'Error', + message: failure.message ?? String(error), + ...(failure.code !== undefined && { code: failure.code }), + ...(failure.data !== undefined && { data: failure.data }), + }, + } satisfies Response); + } + }; + endpoint.addEventListener('message', listener); + endpoint.start?.(); + return () => endpoint.removeEventListener('message', listener); +}; + +/** + * Calls the API exposed on the other side of the endpoint. When the endpoint reports an error + * (the worker crashed) every pending call fails with a `WorkerFailedError`, as does a call that + * gets no answer within `timeoutMs` (default 2 minutes). + */ +export const wrap = ( + endpoint: MessageEndpoint, + options: { timeoutMs?: number } = {}, +): Remote & { dispose(): void } => { + let nextId = 1; + const pending = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; + } + >(); + const failAll = (error: Error) => { + for (const call of pending.values()) { + clearTimeout(call.timer); + call.reject(error); + } + pending.clear(); + }; + const onError = (event: Event) => { + const detail = (event as ErrorEvent).message; + failAll(new WorkerFailedError(`The code worker failed${detail ? `: ${detail}` : ''}`)); + }; + const listener = (event: MessageEvent) => { + const message = event.data as Response; + if (message?.rpc !== 'result') return; + const call = pending.get(message.id); + if (!call) return; + pending.delete(message.id); + clearTimeout(call.timer); + if (message.error) { + call.reject(Object.assign(new Error(message.error.message), message.error)); + } else { + call.resolve(message.value); + } + }; + endpoint.addEventListener('message', listener); + endpoint.addEventListener('error', onError); + endpoint.addEventListener('messageerror', onError); + endpoint.start?.(); + const timeoutMs = options.timeoutMs ?? 120_000; + return new Proxy({} as Remote & { dispose(): void }, { + get: (_target, property) => { + if (property === 'dispose') { + return () => { + endpoint.removeEventListener('message', listener); + endpoint.removeEventListener('error', onError); + endpoint.removeEventListener('messageerror', onError); + failAll(new Error('Worker connection closed')); + }; + } + if (typeof property !== 'string' || property === 'then') return undefined; + return (...args: unknown[]) => + new Promise((resolve, reject) => { + const id = nextId++; + const timer = setTimeout(() => { + pending.delete(id); + reject(new WorkerFailedError(`The code worker did not answer ${property} in time`)); + }, timeoutMs); + pending.set(id, { resolve, reject, timer }); + endpoint.postMessage({ rpc: 'call', id, method: property, args } satisfies Request); + }); + }, + }); +}; diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts new file mode 100644 index 00000000..d8f1e7ac --- /dev/null +++ b/packages/code/src/worker.ts @@ -0,0 +1,36 @@ +/// +/** + * Entry of the code worker. Plugin worker entries share this worker's ts-morph and the worker + * sdk helpers through the shared module registry (like the Svelte runtime on the main thread). + */ +import * as tsMorph from 'ts-morph'; + +import { SharedModuleRegistry } from '@nanoforge-dev/editor-kernel'; +import * as meta from '@nanoforge-dev/editor-meta'; + +import { CodeError } from './engine/code.exception'; +import { EditBuilder } from './engine/edit-builder'; +import { literalToValue, quoteStyle, readExportedLiteral, valueToLiteral } from './engine/literals'; +import { serveEngine } from './worker-rpc/serve-engine'; +import type { MessageEndpoint } from './worker-rpc/worker-rpc'; + +new SharedModuleRegistry() + .register('ts-morph', tsMorph) + .register('@nanoforge-dev/editor-sdk/worker', { + defineWorkerPlugin: (plugin: T) => plugin, + CodeError, + EditBuilder, + docOf: meta.docOf, + parseDocComment: meta.parseDocComment, + firstTag: meta.firstTag, + hasTag: meta.hasTag, + leadingDocComments: meta.leadingDocComments, + tagTexts: meta.tagTexts, + literalToValue, + valueToLiteral, + readExportedLiteral, + quoteStyle, + }) + .install(globalThis); + +serveEngine(self as unknown as MessageEndpoint); diff --git a/packages/code/test/catalog/catalog-service.spec.ts b/packages/code/test/catalog/catalog-service.spec.ts new file mode 100644 index 00000000..0931e581 --- /dev/null +++ b/packages/code/test/catalog/catalog-service.spec.ts @@ -0,0 +1,210 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import type { ItemOwner } from '@nanoforge-dev/editor-meta'; +import { + type ClientProject, + MemoryContentCache, + MemoryTreeCache, + ProjectService, +} from '@nanoforge-dev/editor-project'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; +import { type EditorServer, createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core'; +import { createLoopbackClient } from '@nanoforge-dev/editor-server-core/testing'; + +import { + CatalogService, + CodeService, + DiagnosticsService, + DocumentService, + type WorkerPluginModule, + projectFsBackend, +} from '../../src'; +import { serveEngine } from '../../src/worker-rpc/serve-engine'; + +const owner: ItemOwner = { + name: '@nanoforge/ecs', + version: '1.0.0', + schema: 1, + tags: ['component'], + infer: ({ context, kind }) => context.folder === 'components' && kind === 'class', + claims: () => ({ fields: ['name'] }), + extract: ({ declaration }) => ({ type: 'component', name: declaration.getName() }), +}; +const ecsPlugin: WorkerPluginModule = { activate: (sdk) => void sdk.registerItemOwner(owner) }; + +let workspace: string; +let editor: EditorServer; +let rpc: RpcClient; +let project: ClientProject; + +const write = (path: string, text: string) => { + const file = join(workspace, 'pong', path); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, text); +}; + +const setup = async () => { + const documents = new DocumentService(projectFsBackend(project.fs)); + const code = new CodeService({ + projectId: project.id, + fs: project.fs, + rpc, + documents, + diagnostics: new DiagnosticsService(), + createWorker: () => { + const channel = new MessageChannel(); + serveEngine(channel.port2 as never, { importModule: async () => ecsPlugin }); + return channel.port1 as never; + }, + }); + await code.loadWorkerPlugin('@nanoforge/ecs', 'memory://ecs'); + const catalog = new CatalogService({ project, code, documents, delayMs: 10 }); + return { code, catalog }; +}; + +beforeAll(async () => { + workspace = mkdtempSync(join(import.meta.dirname, '../../../server-core/test/.tmp-catalog-')); + cpSync( + join(import.meta.dirname, '../../../server-core/test/fixtures/pong-network'), + join(workspace, 'pong'), + { recursive: true }, + ); + write( + 'nf_modules/@nanoforge/motion/nanoforge.manifest.json', + JSON.stringify({ + type: 'package', + name: '@nanoforge/motion', + version: '0.1.0', + items: ['velocity.ts'], + }), + ); + write( + 'nf_modules/@nanoforge/motion/velocity.ts', + '/** Speed. @component @side shared */\nexport class Velocity { name = "Velocity"; constructor(public x = 0) {} }', + ); + write( + 'nf_modules/@nanoforge/physics/nanoforge.manifest.json', + JSON.stringify({ + type: 'package', + name: '@nanoforge/physics', + version: '1.0.0', + contents: [{ kind: 'asset', path: 'textures/' }], + include: ['src'], + }), + ); + write( + 'nf_modules/@nanoforge/physics/src/nanoforge.manifest.json', + JSON.stringify({ + contents: [{ kind: '@nanoforge/ecs:component', path: 'forces/', export: 'Gravity' }], + }), + ); + write( + 'nf_modules/@nanoforge/physics/src/forces/gravity.ts', + '/** Pull. @component @side shared */\nexport class Gravity { name = "Gravity"; constructor(public g = 9.8) {} }\n/** @component */\nexport class Drag { name = "Drag"; }\nexport const helper = 1;', + ); + write('nf_modules/@nanoforge/physics/textures/ground.png', 'png'); + editor = createEditorServer({ + env: loadEnv( + { NODE_ENV: 'test', FS_ROOT: workspace, DATA_DIR: mkdtempSync(join(tmpdir(), 'nf-data-')) }, + workspace, + ), + version: 'test', + }); + rpc = createLoopbackClient(editor); + project = await new ProjectService(rpc, { + contents: () => new MemoryContentCache(), + tree: () => new MemoryTreeCache(), + }).open({ path: 'pong' }); +}); + +afterAll(() => { + rpc.dispose(); + editor.dispose(); + rmSync(workspace, { recursive: true, force: true }); +}); + +describe('CatalogService', () => { + it('lists app components (inferred) and installed package items, and caches them', async () => { + const { code, catalog } = await setup(); + await catalog.refresh(); + const refs = catalog.items.map((item) => item.ref); + expect(refs).toContain('app:client#Position'); + expect(refs).toContain('@nanoforge/motion#Velocity'); + expect(refs).toContain('@nanoforge/physics#Gravity'); + expect(refs).not.toContain('@nanoforge/physics#Drag'); + expect(refs).not.toContain('@nanoforge/physics#helper'); + expect(catalog.get('@nanoforge/motion#Velocity')).toMatchObject({ + readonly: true, + meta: { params: [{ name: 'x', type: 'number', default: 0 }] }, + }); + const client = project.model.get().apps.find((app) => app.type === 'client')!; + expect(catalog.forApp(client).some((item) => item.ref.startsWith('app:server#'))).toBe(false); + + const cache = join( + workspace, + 'pong/.nanoforge/editor/cache/meta/modules/@nanoforge/motion.json', + ); + expect(JSON.parse(readFileSync(cache, 'utf8'))).toMatchObject({ + metaVersion: 1, + owners: { '@nanoforge/ecs': { version: '1.0.0', schema: 1 } }, + }); + expect(readFileSync(join(workspace, 'pong/.nanoforge/editor/.gitignore'), 'utf8')).toContain( + 'cache/', + ); + catalog.dispose(); + code.dispose(); + }); + + it('reuses a valid cache and extracts again when a source changes', async () => { + const { code, catalog } = await setup(); + const extract = vi.spyOn(code, 'extractMeta'); + await catalog.refresh(); + expect(extract).not.toHaveBeenCalled(); + + const path = 'apps/client/src/components/components.ts'; + const { text } = await project.fs.readText(path); + await project.fs.write( + path, + `${text}\nexport class Health {\n name = "Health";\n constructor(public points = 3) {}\n}\n`, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + await catalog.whenIdle(); + expect(extract).toHaveBeenCalledTimes(1); + expect(extract.mock.calls[0]![0].source).toMatchObject({ kind: 'app', name: 'client' }); + expect(catalog.get('app:client#Health')!.meta.params).toEqual([ + { name: 'points', type: 'number', default: 3, optional: true }, + ]); + expect(existsSync(join(workspace, 'pong/.nanoforge/editor/cache/meta/apps/client.json'))).toBe( + true, + ); + catalog.dispose(); + code.dispose(); + }); +}); + +describe('CatalogService (new files)', () => { + it('lists an item of a file created after the first extraction', async () => { + const { code, catalog } = await setup(); + await catalog.refresh(); + await project.fs.write( + 'apps/client/src/components/score.ts', + '/** @component */\nexport class Score { name = "Score"; constructor(public value = 0) {} }\n', + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + await catalog.whenIdle(); + expect(catalog.get('app:client#Score')).toBeDefined(); + catalog.dispose(); + code.dispose(); + }); +}); diff --git a/packages/code/test/catalog/item-fits-app.spec.ts b/packages/code/test/catalog/item-fits-app.spec.ts new file mode 100644 index 00000000..7a1459e8 --- /dev/null +++ b/packages/code/test/catalog/item-fits-app.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +import type { CatalogItem } from '../../src/catalog/catalog.type'; +import { itemFitsApp } from '../../src/catalog/item-fits-app'; + +const app = (root: string, type: AppModel['type'], libraries: string[]): AppModel => + ({ id: root, root, name: root, type, libraries, engineLibs: {} }) as never; +const item = (source: CatalogItem['source'], side = 'shared'): CatalogItem => + ({ source, meta: { side, requires: [] } }) as never; + +describe('itemFitsApp', () => { + const position = item({ kind: 'lib', name: '@pong/shared', path: 'libs/shared' }); + + it('offers the items of a shared library to the apps that use it', () => { + expect(itemFitsApp(position, app('apps/client', 'client', ['@pong/shared']))).toBe(true); + expect(itemFitsApp(position, app('apps/server', 'server', []))).toBe(false); + expect(itemFitsApp(position, app('apps/server', 'server', ['@pong/other']))).toBe(false); + }); + + it('offers them to the library itself, and to a library that uses it', () => { + expect(itemFitsApp(position, app('libs/shared', 'lib', []))).toBe(true); + expect(itemFitsApp(position, app('libs/ui', 'lib', ['@pong/shared']))).toBe(true); + expect(itemFitsApp(position, app('libs/ui', 'lib', []))).toBe(false); + }); + + it('still keeps a client item of a used library out of a server', () => { + const sprite = item({ kind: 'lib', name: '@pong/shared', path: 'libs/shared' }, 'client'); + expect(itemFitsApp(sprite, app('apps/server', 'server', ['@pong/shared']))).toBe(false); + }); +}); diff --git a/packages/code/test/document/document-service.spec.ts b/packages/code/test/document/document-service.spec.ts new file mode 100644 index 00000000..fdf79f76 --- /dev/null +++ b/packages/code/test/document/document-service.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { HistoryService } from '@nanoforge-dev/editor-history'; +import { Emitter } from '@nanoforge-dev/editor-kernel'; + +import { DOCUMENT_EDITS } from '../../src/document/document-contributions'; +import { previewOf } from '../../src/document/document-preview'; +import { DocumentService, documentHistoryId } from '../../src/document/document-service'; +import { fingerprint } from '../../src/document/document-service'; +import type { DocumentBackend } from '../../src/document/document.type'; +import { matchesGlob } from '../../src/document/match-glob'; + +/** Files in memory, hashed by fingerprint. */ +const memoryBackend = (files: Record): DocumentBackend => ({ + onDidChange: new Emitter().event, + read: async (uri) => ({ text: files[uri] ?? '', hash: fingerprint(files[uri] ?? '') }), + write: async (uri, text) => { + files[uri] = text; + return { hash: fingerprint(text) }; + }, +}); + +describe('document history', () => { + it('edits in the document context, with a preview, and restores after a reload', async () => { + const files = { 'src/main.ts': 'const a = 1;\nconst b = 2;\nconst c = 3;\n' }; + const history = new HistoryService(); + const documents = new DocumentService(memoryBackend(files), history); + + await documents.edit('src/main.ts', [{ start: 23, end: 24, text: '20' }], { label: 'Set b' }); + expect(files['src/main.ts']).toBe('const a = 1;\nconst b = 20;\nconst c = 3;\n'); + const context = history.get(documentHistoryId('src/main.ts'))!; + const [entry] = context.stack.state.past; + expect(entry!.command.label).toBe('Set b'); + expect(entry!.command.preview).toEqual({ + title: 'src/main.ts', + before: 'const a = 1;\nconst b = 2;\nconst c = 3;', + after: 'const a = 1;\nconst b = 20;\nconst c = 3;', + }); + + const serialized = entry!.command.serialize!()!; + expect(serialized.type).toBe(DOCUMENT_EDITS); + const reloaded = new DocumentService(memoryBackend(files), new HistoryService()); + const restored = reloaded.restoreEditCommand( + JSON.parse(JSON.stringify(serialized.data)), + serialized.label, + )!; + expect(restored.preview?.after).toContain('const b = 20;'); + expect(await restored.isValid!()).toBe(true); + await restored.undo(); + expect(files['src/main.ts']).toBe('const a = 1;\nconst b = 2;\nconst c = 3;\n'); + }); + + it('saves one open document with unsaved changes', async () => { + const documents = new DocumentService(memoryBackend({}), new HistoryService()); + const saved: string[] = []; + const open = (uri: string, dirty: boolean) => + documents.register({ + uri, + kind: 'text', + onDidChange: () => ({ dispose: () => undefined }), + dirty: { get: () => dirty, subscribe: () => () => undefined }, + getText: () => '', + applyEdits: () => [], + save: async () => void saved.push(uri), + revert: async () => undefined, + }); + open('a.ts', true); + open('b.ts', true); + open('c.ts', false); + await documents.save('a.ts'); + await documents.save('c.ts'); + await documents.save('closed.ts'); + expect(saved).toEqual(['a.ts']); + }); + + it('previews only the changed lines and their neighbours', () => { + const before = Array.from({ length: 30 }, (_, i) => `line ${i}`).join('\n'); + const after = before.replace('line 15', 'line fifteen'); + expect(previewOf('a.ts', before, after)).toEqual({ + title: 'a.ts', + before: 'line 14\nline 15\nline 16', + after: 'line 14\nline fifteen\nline 16', + }); + }); +}); + +describe('matchesGlob', () => { + it('reads **/ as folders, never as part of a file name', async () => { + const dotfiles = '**/{.*,*rc,Dockerfile,LICENSE,Makefile}'; + for (const path of ['.env', 'apps/client/.eslintrc', 'Dockerfile', 'apps/server/.prettierrc']) + expect(matchesGlob(dotfiles, path), path).toBe(true); + for (const path of ['logo.png', 'apps/client/assets/logo.png', 'src/main.ts']) + expect(matchesGlob(dotfiles, path), path).toBe(false); + + expect(matchesGlob('**/*.{png,jpg}', 'logo.png')).toBe(true); + expect(matchesGlob('**/*.{png,jpg}', 'a/b/logo.jpg')).toBe(true); + expect(matchesGlob('src/**/*.ts', 'src/main.ts')).toBe(true); + expect(matchesGlob('src/**/*.ts', 'src/a/b/main.ts')).toBe(true); + expect(matchesGlob('src/*.ts', 'src/a/main.ts')).toBe(false); + }); + + it('reads a comma outside braces as a plain character', () => { + expect(matchesGlob('a,b', 'a,b')).toBe(true); + expect(matchesGlob('a,b', 'a')).toBe(false); + expect(matchesGlob('a,b', 'xb')).toBe(false); + expect(matchesGlob('src/{a,b}.ts', 'src/b.ts')).toBe(true); + }); +}); diff --git a/packages/code/test/engine/code-engine.spec.ts b/packages/code/test/engine/code-engine.spec.ts new file mode 100644 index 00000000..eca05660 --- /dev/null +++ b/packages/code/test/engine/code-engine.spec.ts @@ -0,0 +1,379 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { Node, SyntaxKind } from 'ts-morph'; +import { describe, expect, it } from 'vitest'; + +import { applyTextEdits } from '@nanoforge-dev/editor-history'; + +import { + CODE_FILE, + CodeEngine, + CodeError, + RENAME_PACKAGE_TRANSFORMER, + type TransformContext, + literalToValue, + valueToLiteral, +} from '../../src/engine'; + +const FIXTURE = join(import.meta.dirname, '../../../server-core/test/fixtures/pong-network'); + +const walk = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + return statSync(path).isDirectory() ? walk(path) : CODE_FILE.test(name) ? [path] : []; + }); + +const pong = () => + walk(FIXTURE).map((file) => ({ + path: relative(FIXTURE, file).split('\\').join('/'), + text: readFileSync(file, 'utf8'), + })); + +/** Toy transformer: renames a top level const and all its references in the file. */ +const renameConst = ({ file, edit }: TransformContext, op: unknown) => { + const { from, to } = op as { from: string; to: string }; + const declaration = file.getVariableDeclarationOrThrow(from); + const name = declaration.getNameNode(); + edit.replace(name, to); + for (const identifier of file.getDescendantsOfKind(SyntaxKind.Identifier)) { + if ( + identifier !== name && + identifier.getText() === from && + identifier.getSymbol() === name.getSymbol() + ) { + edit.replace(identifier, to); + } + } +}; + +describe('CodeEngine', () => { + it('renames the imports of a package, not its look-alikes in comments and strings', () => { + const engine = new CodeEngine(); + const text = [ + '// from "@pong/shared"', + 'import { a } from "@pong/shared/a";', + "export * from '@pong/shared';", + 'const lazy = import("@pong/shared/b");', + 'const label = "@pong/shared";', + 'import tools from "@pong/shared-tools";', + '', + ].join('\n'); + engine.setFiles([{ path: 'src/main.ts', text }]); + const edits = engine.transform('src/main.ts', RENAME_PACKAGE_TRANSFORMER, { + from: '@pong/shared', + to: '@pong/common', + }); + expect(applyTextEdits(text, edits).text).toBe( + [ + '// from "@pong/shared"', + 'import { a } from "@pong/common/a";', + "export * from '@pong/common';", + 'const lazy = import("@pong/common/b");', + 'const label = "@pong/shared";', + 'import tools from "@pong/shared-tools";', + '', + ].join('\n'), + ); + }); + + it('keeps every pong-network file byte-identical and a no-op transform yields no edit', () => { + const engine = new CodeEngine(); + const files = pong(); + expect(files.length).toBeGreaterThan(6); + engine.setFiles(files); + engine.registerTransformer('noop', () => undefined); + for (const { path, text } of files) { + expect(engine.text(path)).toBe(text); + expect(engine.transform(path, 'noop', null)).toEqual([]); + } + }); + + it('renames a const with edits limited to identifiers', () => { + const engine = new CodeEngine(); + const text = [ + '// keep this comment', + 'const speed = 2; // odd spacing is kept', + '', + 'export function move(x: number) {', + ' return x * speed + other.speed;', + '}', + 'const other = { speed: 1 };', + '', + ].join('\n'); + engine.setFiles([{ path: 'src/a.ts', text }]); + engine.registerTransformer('rename', renameConst); + const edits = engine.transform('src/a.ts', 'rename', { from: 'speed', to: 'velocity' }); + expect(edits.every((edit) => text.slice(edit.start, edit.end) === 'speed')).toBe(true); + expect(edits).toHaveLength(2); + expect(applyTextEdits(text, edits).text).toBe( + text.replace('const speed ', 'const velocity ').replace('x * speed', 'x * velocity'), + ); + }); + + it('lists the symbols of a file with their kind, container and position', () => { + const engine = new CodeEngine(); + engine.setFiles([ + { + path: 'src/shapes.ts', + text: [ + 'export class Position {', + ' x = 0;', + ' move(dx: number) {', + ' this.x += dx;', + ' }', + '}', + '', + 'export const SPEED = 2;', + 'export function update() {}', + 'interface Shape {', + ' size: number;', + '}', + '', + ].join('\n'), + }, + ]); + expect( + engine + .symbols('src/shapes.ts') + .map((symbol) => [symbol.name, symbol.kind, symbol.container, symbol.line, symbol.column]), + ).toEqual([ + ['Position', 'class', undefined, 1, 14], + ['x', 'property', 'Position', 2, 3], + ['move', 'method', 'Position', 3, 3], + ['SPEED', 'const', undefined, 8, 14], + ['update', 'function', undefined, 9, 17], + ['Shape', 'interface', undefined, 10, 11], + ['size', 'property', 'Shape', 11, 3], + ]); + expect(engine.symbols('src/missing.ts')).toEqual([]); + }); + + it('runs analyzers returning references that resolve back to nodes', () => { + const engine = new CodeEngine(); + engine.setFiles(pong()); + engine.registerAnalyzer('spawns', ({ file, ref }) => + file + .getDescendantsOfKind(SyntaxKind.CallExpression) + .filter((call) => call.getExpression().getText().endsWith('spawnEntity')) + .map((call) => ref(call)), + ); + const refs = engine.analyze('apps/client/src/main.ts', 'spawns') as { + id: string; + start: number; + kind: string; + }[]; + expect(refs.length).toBeGreaterThan(0); + engine.registerAnalyzer('resolve', ({ resolve }, target) => + resolve(target as never)?.getText(), + ); + expect(engine.analyze('apps/client/src/main.ts', 'resolve', refs[0])).toMatch( + /spawnEntity\(\)/, + ); + }); + + it('reports syntax and type errors', async () => { + const engine = new CodeEngine(); + engine.setFiles([{ path: 'src/bad.ts', text: 'const a: number = "x";\nconst b = ;\n' }]); + const diagnostics = await engine.diagnostics(['src/bad.ts']); + expect(diagnostics.map((d) => [d.severity, d.code])).toEqual( + expect.arrayContaining([ + ['error', 2322], + ['error', 1109], + ]), + ); + }); + + it('loads type declarations of bare imports on demand', async () => { + const requested: string[] = []; + const engine = new CodeEngine({ + resolveTypes: async (module) => { + requested.push(module); + return [ + { + path: 'node_modules/@acme/lib/package.json', + text: '{"name":"@acme/lib","types":"index.d.ts"}', + }, + { + path: 'node_modules/@acme/lib/index.d.ts', + text: 'export declare const answer: number;', + }, + ]; + }, + }); + engine.setFiles([ + { + path: 'src/a.ts', + text: "import { answer } from '@acme/lib';\nconst s: string = answer;\n", + }, + ]); + const diagnostics = await engine.diagnostics(['src/a.ts']); + expect(requested).toEqual(['@acme/lib']); + expect(diagnostics.map((d) => d.code)).toContain(2322); + }); + + it('loads the declarations of a package for each app that has its own node_modules', async () => { + const requested: string[] = []; + const engine = new CodeEngine({ + resolveTypes: async (module, from) => { + requested.push(`${module} from ${from}`); + const app = /^apps\/[^/]+/.exec(from)?.[0]; + if (!app) return []; + return [ + { + path: `${app}/node_modules/@acme/lib/package.json`, + text: '{"name":"@acme/lib","types":"index.d.ts"}', + }, + { + path: `${app}/node_modules/@acme/lib/index.d.ts`, + text: 'export declare const answer: number;', + }, + ]; + }, + }); + const text = "import { answer } from '@acme/lib';\nexport const value: number = answer;\n"; + engine.setFiles([ + { path: 'apps/client/src/main.ts', text }, + { path: 'apps/client/src/systems/move.ts', text }, + { path: 'apps/server/src/main.ts', text }, + { path: 'test/game.spec.ts', text }, + ]); + const paths = [ + 'apps/client/src/main.ts', + 'apps/client/src/systems/move.ts', + 'apps/server/src/main.ts', + 'test/game.spec.ts', + ]; + const diagnostics = await engine.diagnostics(paths); + expect(requested).toEqual([ + '@acme/lib from apps/client/src/main.ts', + '@acme/lib from apps/server/src/main.ts', + '@acme/lib from test/game.spec.ts', + ]); + expect(diagnostics.filter((d) => d.code === 2307).map((d) => d.path)).toEqual([ + 'test/game.spec.ts', + ]); + await engine.diagnostics(paths); + expect(requested).toHaveLength(3); + }); + + it('checks each app in a program of its own: type augmentations do not leak', async () => { + const engine = new CodeEngine({ + resolveTypes: async (_module, from) => { + const app = /^apps\/([^/]+)/.exec(from); + if (!app) return []; + return [ + { + path: `${app[0]}/node_modules/@acme/net/package.json`, + text: '{"name":"@acme/net","types":"index.d.ts"}', + }, + { + path: `${app[0]}/node_modules/@acme/net/index.d.ts`, + text: `export declare const side: '${app[1]}';\ndeclare global { interface Ctx { net: '${app[1]}' } }\n`, + }, + ]; + }, + }); + const main = (side: string) => + `import { side } from '@acme/net';\nimport { shared } from '../../../libs/shared/src/index';\ndeclare const ctx: Ctx;\nexport const net: '${side}' = ctx.net;\nexport const all = [side, shared];\n`; + engine.setFiles([ + { path: 'apps/client/src/main.ts', text: main('client') }, + { path: 'apps/server/src/main.ts', text: main('server') }, + { path: 'libs/shared/src/index.ts', text: 'export const shared: number = 1;\n' }, + ]); + const paths = [ + 'apps/client/src/main.ts', + 'apps/server/src/main.ts', + 'libs/shared/src/index.ts', + ]; + expect(await engine.diagnostics(paths)).toEqual([]); + + engine.setFiles([ + { path: 'apps/server/src/main.ts', text: main('client') }, + { path: 'libs/shared/src/index.ts', text: "export const shared: number = 'one';\n" }, + ]); + const after = await engine.diagnostics(paths); + expect(after.map((d) => [d.path, d.code])).toEqual([ + ['apps/server/src/main.ts', 2322], + ['libs/shared/src/index.ts', 2322], + ]); + engine.deleteFiles(['libs/shared/src/index.ts']); + expect((await engine.diagnostics(['apps/client/src/main.ts'])).map((d) => d.code)).toEqual([ + 2307, + ]); + }); + + it('refuses overlapping edits', () => { + const engine = new CodeEngine(); + engine.setFiles([{ path: 'a.ts', text: 'const a = 1;' }]); + engine.registerTransformer('overlap', ({ edit }) => + edit.replace({ start: 0, end: 5 }, 'x').replace({ start: 3, end: 8 }, 'y'), + ); + expect(() => engine.transform('a.ts', 'overlap', null)).toThrow(CodeError); + }); +}); + +describe('organizeImports', () => { + it('sorts imports and drops unused ones', async () => { + const engine = new CodeEngine(); + const text = "import { b } from './b';\nimport { z, a } from './a';\n\nconsole.log(a, b);\n"; + engine.setFiles([ + { path: 'src/main.ts', text }, + { path: 'src/a.ts', text: 'export const a = 1;\nexport const z = 2;\n' }, + { path: 'src/b.ts', text: 'export const b = 1;\n' }, + ]); + const edits = await engine.organizeImports('src/main.ts'); + expect(applyTextEdits(text, edits).text).toBe( + "import { a } from './a';\nimport { b } from './b';\n\nconsole.log(a, b);\n", + ); + }); +}); + +describe('literals', () => { + const engine = new CodeEngine(); + const valueOf = (source: string) => { + engine.setFiles([{ path: 'l.ts', text: `export const X = ${source};` }]); + const file = engine.project.getSourceFileOrThrow('/project/l.ts'); + return literalToValue(file.getVariableDeclarationOrThrow('X').getInitializerOrThrow()); + }; + + it('reads literals', () => { + expect( + valueOf( + `{ name: "Ball", params: [{ type: 'number', default: -2.5, optional: true }], "x-y": null } as const`, + ), + ).toEqual({ + name: 'Ball', + params: [{ type: 'number', default: -2.5, optional: true }], + 'x-y': null, + }); + }); + + it('rejects computed values with their position', () => { + try { + valueOf('{ a: Math.PI }'); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(CodeError); + expect((error as CodeError).message).toContain('Math.PI'); + expect((error as CodeError).start).toBe(22); + } + }); + + it('prints values that read back identically', () => { + const value = { + name: "Bob's ball", + size: [1, -2], + nested: { a: true, 'b-c': null }, + long: 'x'.repeat(70), + }; + const printed = valueToLiteral(value, { quote: "'", indent: ' ' }); + expect(printed).toContain("'Bob\\'s ball'"); + expect(printed).toContain("'b-c': null"); + expect(valueOf(printed)).toEqual(value); + expect(valueToLiteral({ x: 1, y: 2 })).toBe('{ x: 1, y: 2 }'); + }); + + it('ignores Node import (type-only check)', () => { + expect(typeof Node.isIdentifier).toBe('function'); + }); +}); diff --git a/packages/code/test/engine/extract-meta.spec.ts b/packages/code/test/engine/extract-meta.spec.ts new file mode 100644 index 00000000..529b487b --- /dev/null +++ b/packages/code/test/engine/extract-meta.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; + +import type { ItemOwner } from '@nanoforge-dev/editor-meta'; + +import { CodeEngine } from '../../src/engine'; + +const ecs: ItemOwner = { + name: '@nanoforge/ecs', + version: '0.0.0', + schema: 1, + tags: ['component', 'system'], + claims: ({ kind }) => (kind === 'function' ? { params: ['registry'] } : { fields: ['name'] }), + extract: ({ declaration, context, tags }) => { + if (tags.component) return { type: 'component', name: declaration.getName() }; + const query = declaration + .getDescendants() + .filter( + (node) => + node.getKindName() === 'Identifier' && + node.getParent()?.getKindName() === 'CallExpression' && + node.getText() !== 'use', + ) + .map((node) => context.refOf(node)); + return { type: 'system', query }; + }, +}; + +const engine = () => { + const code = new CodeEngine(); + code.registerItemOwner(ecs, '@nanoforge/ecs'); + code.setTsconfig( + '{ "compilerOptions": { "paths": { "@me/shared/*": ["./libs/shared/src/*"] } } }', + ); + code.setFiles([ + { + path: 'libs/shared/src/components/player.ts', + text: '/** A player. @component */\nexport class Player { name = "Player"; constructor(public id = "") {} }', + }, + { + path: 'apps/client/src/systems/greet.ts', + text: [ + 'import { Player } from "@me/shared/components/player";', + 'declare const use: (c: unknown) => void;', + '/** Greets players. @system @side client */', + 'export function greet(registry: unknown) { use(Player); }', + ].join('\n'), + }, + ]); + return code; +}; + +const roots = [ + { path: 'libs/shared', ref: '@me/shared' }, + { path: 'apps/client', ref: 'app:client' }, +]; + +describe('CodeEngine.extractMeta', () => { + it('extracts items with owners and resolves package-name imports through tsconfig paths', async () => { + const code = engine(); + const lib = await code.extractMeta({ + source: { kind: 'lib', name: '@me/shared', path: 'libs/shared' }, + files: [{ path: 'libs/shared/src/components/player.ts', folder: 'components' }], + roots, + }); + expect(lib.items).toMatchObject([ + { export: 'Player', fields: [], '@nanoforge/ecs': { type: 'component', name: 'Player' } }, + ]); + expect(lib.owners).toEqual({ '@nanoforge/ecs': { version: '0.0.0', schema: 1 } }); + expect(lib.sourceHash).toMatch(/^sha256-[0-9a-f]{64}$/); + + const app = await code.extractMeta({ + source: { kind: 'app', name: 'client', path: 'apps/client' }, + files: [{ path: 'apps/client/src/systems/greet.ts', folder: 'systems' }], + roots, + }); + expect(app.items[0]).toMatchObject({ + export: 'greet', + side: 'client', + params: [], + requires: ['@me/shared'], + '@nanoforge/ecs': { type: 'system', query: ['@me/shared#Player'] }, + }); + expect(await code.diagnostics(['apps/client/src/systems/greet.ts'])).toEqual([]); + }); + + it('drops the owners of an unloaded plugin', () => { + const code = engine(); + code.unregisterOwner('@nanoforge/ecs'); + expect(code.itemOwners).toEqual([]); + }); +}); + +describe('moduleSpecifierFor', () => { + it('uses tsconfig paths, else a relative path', async () => { + const { moduleSpecifierFor } = await import('../../src/engine'); + const paths = { '@me/shared/*': ['libs/shared/src/*'] }; + expect( + moduleSpecifierFor('apps/client/src/main.ts', 'libs/shared/src/components/player.ts', paths), + ).toBe('@me/shared/components/player'); + expect( + moduleSpecifierFor( + 'apps/client/src/main.ts', + 'apps/client/src/components/components.ts', + paths, + ), + ).toBe('./components/components'); + expect(moduleSpecifierFor('apps/client/src/main.ts', 'nf_modules/@a/b/x.ts', paths)).toBe( + '../../../nf_modules/@a/b/x', + ); + }); +}); diff --git a/packages/code/test/service/code-service.spec.ts b/packages/code/test/service/code-service.spec.ts new file mode 100644 index 00000000..bcb58568 --- /dev/null +++ b/packages/code/test/service/code-service.spec.ts @@ -0,0 +1,253 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SyntaxKind } from 'ts-morph'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + type CommandOrigin, + HistoryService, + type TextEdit, + applyTextEdits, +} from '@nanoforge-dev/editor-history'; +import { Emitter, ObservableValue } from '@nanoforge-dev/editor-kernel'; +import { + type ClientProject, + MemoryContentCache, + MemoryTreeCache, + ProjectService, +} from '@nanoforge-dev/editor-project'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; +import { type EditorServer, createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core'; +import { createLoopbackClient } from '@nanoforge-dev/editor-server-core/testing'; + +import { + CodeService, + DiagnosticsService, + type DocumentChange, + DocumentService, + type OpenDocument, + type WorkerPluginModule, + projectFsBackend, +} from '../../src'; +import { serveEngine } from '../../src/worker-rpc/serve-engine'; + +const MAIN = 'apps/client/src/main.ts'; + +/** Worker plugin: renames the first `const` declared in a file. */ +const renamePlugin: WorkerPluginModule = { + activate: (sdk) => { + sdk.registerTransformer('test.rename-first-const', ({ file, edit }, op) => { + const declaration = file.getFirstDescendantByKindOrThrow(SyntaxKind.VariableDeclaration); + edit.replace(declaration.getNameNode(), (op as { name: string }).name); + }); + }, +}; + +let workspace: string; +let editor: EditorServer; +let rpc: RpcClient; +let project: ClientProject; + +const setup = () => { + const documents = new DocumentService(projectFsBackend(project.fs)); + const diagnostics = new DiagnosticsService(); + const code = new CodeService({ + projectId: project.id, + fs: project.fs, + rpc, + documents, + diagnostics, + diagnosticsDelayMs: 10, + createWorker: () => { + const channel = new MessageChannel(); + serveEngine(channel.port2 as never, { importModule: async () => renamePlugin }); + return channel.port1 as never; + }, + }); + return { documents, diagnostics, code, history: new HistoryService() }; +}; + +/** A Monaco-like open document. */ +const fakeDocument = (uri: string, initial: string) => { + let text = initial; + const changes = new Emitter(); + const document: OpenDocument & { text(): string } = { + uri, + kind: 'text', + onDidChange: changes.event, + dirty: new ObservableValue(false), + getText: () => text, + text: () => text, + applyEdits: (edits: readonly TextEdit[], origin: CommandOrigin | undefined) => { + const result = applyTextEdits(text, edits); + text = result.text; + changes.fire({ uri, edits, origin }); + return result.inverse; + }, + save: async () => undefined, + revert: async () => undefined, + }; + return document; +}; + +beforeAll(async () => { + workspace = mkdtempSync(join(import.meta.dirname, '../../../server-core/test/.tmp-code-')); + cpSync( + join(import.meta.dirname, '../../../server-core/test/fixtures/pong-network'), + join(workspace, 'pong'), + { recursive: true }, + ); + const lib = join(workspace, 'pong/node_modules/@acme/speed'); + mkdirSync(lib, { recursive: true }); + writeFileSync(join(lib, 'package.json'), '{"name":"@acme/speed","types":"index.d.ts"}'); + writeFileSync(join(lib, 'index.d.ts'), 'export declare const maxSpeed: number;'); + mkdirSync(join(workspace, 'pong/checks'), { recursive: true }); + writeFileSync(join(workspace, 'pong/checks/broken.ts'), 'export const n: number =\n "text";\n'); + mkdirSync(join(workspace, 'pong/nf_modules/@acme/pkg'), { recursive: true }); + writeFileSync( + join(workspace, 'pong/nf_modules/@acme/pkg/bad.ts'), + 'export const n: number = "x";\n', + ); + editor = createEditorServer({ + env: loadEnv( + { NODE_ENV: 'test', FS_ROOT: workspace, DATA_DIR: mkdtempSync(join(tmpdir(), 'nf-data-')) }, + workspace, + ), + version: 'test', + }); + rpc = createLoopbackClient(editor); + project = await new ProjectService(rpc, { + contents: () => new MemoryContentCache(), + tree: () => new MemoryTreeCache(), + }).open({ + path: 'pong', + }); +}); + +afterAll(() => { + rpc.dispose(); + editor.dispose(); + rmSync(workspace, { recursive: true, force: true }); +}); + +describe('CodeService', () => { + it('sends edits to the open document instead of the file', async () => { + const { documents, code, history } = setup(); + await code.loadWorkerPlugin('test/rename', 'memory://plugin'); + const onDisk = readFileSync(join(workspace, 'pong', MAIN), 'utf8'); + const document = fakeDocument(MAIN, onDisk); + documents.register(document); + const { stack } = history.registerContext({ id: 'file', label: MAIN }); + + const command = await code.edit( + MAIN, + 'test.rename-first-const', + { name: 'renamed' }, + { label: 'Rename' }, + ); + await stack.push(command!); + expect(document.text()).toMatch(/const renamed/); + expect(readFileSync(join(workspace, 'pong', MAIN), 'utf8')).toBe(onDisk); + await stack.undo(); + expect(document.text()).toBe(onDisk); + code.dispose(); + }); + + it('refuses an edit when the text changed after it was computed', async () => { + const { documents, code, history } = setup(); + await code.loadWorkerPlugin('test/rename', 'memory://plugin'); + const onDisk = readFileSync(join(workspace, 'pong', MAIN), 'utf8'); + const document = fakeDocument(MAIN, onDisk); + documents.register(document); + const { stack } = history.registerContext({ id: 'file', label: MAIN }); + const command = await code.edit( + MAIN, + 'test.rename-first-const', + { name: 'renamed' }, + { label: 'Rename' }, + ); + document.applyEdits([{ start: 0, end: 0, text: '// typed meanwhile\n' }], undefined); + await expect(stack.push(command!)).rejects.toThrow(/changed while the edit was prepared/); + expect(document.text()).toBe(`// typed meanwhile\n${onDisk}`); + code.dispose(); + }); + + it('starts a new worker after a failed start', async () => { + const { code } = setup(); + let starts = 0; + const options = (code as unknown as { _options: { createWorker: () => unknown } })._options; + const create = options.createWorker; + options.createWorker = () => { + if (++starts === 1) throw new Error('worker blocked'); + return create(); + }; + await expect(code.engine()).rejects.toThrow('worker blocked'); + await expect(code.symbols(MAIN)).resolves.toBeInstanceOf(Array); + expect(starts).toBe(2); + code.dispose(); + }); + + it('edits closed files on disk, undoes, and drops undo after an outside change', async () => { + const { code, history } = setup(); + await code.loadWorkerPlugin('test/rename', 'memory://plugin'); + const { stack } = history.registerContext({ id: 'file', label: MAIN }); + const path = join(workspace, 'pong', MAIN); + const original = readFileSync(path, 'utf8'); + + await stack.push( + (await code.edit(MAIN, 'test.rename-first-const', { name: 'app' }, { label: 'Rename' }))!, + ); + expect(readFileSync(path, 'utf8')).toMatch(/const app\b/); + await stack.undo(); + expect(readFileSync(path, 'utf8')).toBe(original); + await stack.redo(); + + writeFileSync(path, `${readFileSync(path, 'utf8')}\n// hand edit\n`); + /** Watcher → ProjectFs. */ + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(await stack.undo()).toBe(false); + expect(stack.state.canUndo).toBe(false); + code.dispose(); + }); + + it('publishes diagnostics of watched files with types from node_modules', async () => { + const { code, documents, diagnostics } = setup(); + const uri = 'apps/client/src/speed.ts'; + documents.register( + fakeDocument( + uri, + "import { maxSpeed } from '@acme/speed';\nexport const s: string = maxSpeed;\n", + ), + ); + /** Mirror first, then the open document text is pushed on change. */ + await code.engine(); + await (await code.engine()).setFiles([{ path: uri, text: await documents.getText(uri) }]); + const watch = code.watchDiagnostics(uri); + const start = Date.now(); + while (!diagnostics.forUri(uri).length && Date.now() - start < 8000) + await new Promise((r) => setTimeout(r, 25)); + expect(diagnostics.forUri(uri).map((d) => d.code)).toEqual([2322]); + watch.dispose(); + expect(diagnostics.forUri(uri)).toEqual([]); + code.dispose(); + }); + + it('checks every file of the project while asked to, read-only packages aside', async () => { + const { code, diagnostics } = setup(); + const uri = 'checks/broken.ts'; + const check = code.checkProject(); + const start = Date.now(); + while (!diagnostics.forUri(uri).length && Date.now() - start < 15_000) + await new Promise((r) => setTimeout(r, 25)); + expect(diagnostics.forUri(uri)).toMatchObject([ + { code: 2322, source: 'typescript', line: 1, column: 14 }, + ]); + expect(diagnostics.forUri('nf_modules/@acme/pkg/bad.ts')).toEqual([]); + code.watchDiagnostics(uri).dispose(); + expect(diagnostics.forUri(uri)).toHaveLength(1); + check.dispose(); + expect(diagnostics.forUri(uri)).toEqual([]); + code.dispose(); + }); +}); diff --git a/packages/code/test/worker-rpc/worker-rpc.spec.ts b/packages/code/test/worker-rpc/worker-rpc.spec.ts new file mode 100644 index 00000000..889c80cb --- /dev/null +++ b/packages/code/test/worker-rpc/worker-rpc.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { WorkerFailedError, wrap } from '../../src/worker-rpc/worker-rpc'; + +/** A worker that never answers, and can crash. */ +class SilentWorker extends EventTarget { + postMessage(): void {} + crash(): void { + this.dispatchEvent(Object.assign(new Event('error'), { message: 'out of memory' })); + } +} + +describe('wrap', () => { + it('fails pending calls when the worker crashes', async () => { + const worker = new SilentWorker(); + const remote = wrap<{ diagnostics(): string[] }>(worker as never); + const call = remote.diagnostics(); + worker.crash(); + await expect(call).rejects.toBeInstanceOf(WorkerFailedError); + await expect(call).rejects.toThrow(/out of memory/); + }); + + it('fails a call that gets no answer in time', async () => { + const remote = wrap<{ diagnostics(): string[] }>(new SilentWorker() as never, { + timeoutMs: 10, + }); + await expect(remote.diagnostics()).rejects.toThrow(/did not answer diagnostics/); + }); +}); diff --git a/packages/code/tsconfig.json b/packages/code/tsconfig.json new file mode 100644 index 00000000..29b22ee4 --- /dev/null +++ b/packages/code/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"], + "lib": ["ESNext", "esnext.disposable", "DOM", "DOM.Iterable", "WebWorker.ImportScripts"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/history/.prettierignore b/packages/history/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/history/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/history/LICENSE b/packages/history/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/history/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/history/README.md b/packages/history/README.md new file mode 100644 index 00000000..6dffda5c --- /dev/null +++ b/packages/history/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-history` is an internal package of the NanoForge Editor. + +Undo/redo engine with per-context stacks. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/history +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/history/package.json b/packages/history/package.json new file mode 100644 index 00000000..61766ca8 --- /dev/null +++ b/packages/history/package.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-history", + "version": "0.0.0", + "description": "NanoForge Editor - Undo/redo engine with per-context stacks", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/history" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty .", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "@nanoforge-dev/editor-kernel": "workspace:*" + }, + "devDependencies": { + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/history/src/command/composite-command.ts b/packages/history/src/command/composite-command.ts new file mode 100644 index 00000000..b0eddbc9 --- /dev/null +++ b/packages/history/src/command/composite-command.ts @@ -0,0 +1,108 @@ +import type { CommandOrigin, HistoryCommand } from './history-command.type'; +import { InvalidCommandError } from './invalid-command.exception'; + +export const sameOrigin = (a: CommandOrigin | undefined, b: CommandOrigin | undefined): boolean => + a?.kind === b?.kind && a?.id === b?.id; + +const redoOf = (command: HistoryCommand) => (command.redo ?? command.do).call(command); + +/** + * Runs `step` on a command once it is still valid. A command is valid in the state it left, so + * the commands of a group are checked one by one as their turn comes: two of them editing the + * same file each expect the text the other left. + */ +const checked = + (step: (command: HistoryCommand) => Promise | void) => + async (command: HistoryCommand): Promise => { + if ((await command.isValid?.()) === false) throw new InvalidCommandError(command.label); + await step(command); + }; + +/** + * Undo and redo of commands run in `order`: each is checked when its turn comes, and only the + * one that runs first is checked beforehand (`isValid`). One that changed since stops the run: + * those already run are reverted and an `InvalidCommandError` goes on. + */ +const sequence = (order: readonly HistoryCommand[]) => { + let undone = false; + return { + undo: async () => { + await runAll( + [...order].reverse(), + checked((command) => command.undo()), + redoOf, + ); + undone = true; + }, + redo: async () => { + await runAll(order, checked(redoOf)); + undone = false; + }, + async isValid() { + const first = undone ? order[0] : order.at(-1); + return (await first?.isValid?.()) !== false; + }, + }; +}; + +/** + * Runs children in order and undoes them in reverse. When a child fails, the ones already run + * are undone (best effort) before the error goes on: a step is never left half applied. + */ +export const compositeCommand = ( + label: string, + children: readonly HistoryCommand[], + origin?: CommandOrigin, +): HistoryCommand => ({ + label, + ...(origin && { origin }), + children, + sizeBytes: children.reduce((total, child) => total + (child.sizeBytes ?? 0), 0), + do: () => runAll(children, (child) => child.do()), + ...sequence(children), + dispose() { + for (const child of children) child.dispose?.(); + }, +}); + +/** + * Runs `step` on each command in order. If one throws, the ones already done are reverted with + * `revert` (undo by default), last first, and the error is rethrown. + */ +const runAll = async ( + commands: readonly HistoryCommand[], + step: (command: HistoryCommand) => Promise | void, + revert: (command: HistoryCommand) => Promise | void = (command) => command.undo(), +): Promise => { + const done: HistoryCommand[] = []; + try { + for (const command of commands) { + await step(command); + done.push(command); + } + } catch (error) { + for (const command of done.reverse()) { + try { + await revert(command); + } catch {} + } + throw error; + } +}; + +/** + * Default merge of two commands with the same merge key: one step whose undo reverts both (the + * last one first) and whose redo replays both. + */ +export const mergeCommands = (previous: HistoryCommand, next: HistoryCommand): HistoryCommand => ({ + label: next.label, + ...(previous.origin && { origin: previous.origin }), + sizeBytes: (previous.sizeBytes ?? 0) + (next.sizeBytes ?? 0), + ...(next.mergeKey && { mergeKey: next.mergeKey }), + do: () => next.do(), + ...sequence([previous, next]), + dispose() { + previous.dispose?.(); + next.dispose?.(); + }, +}); diff --git a/packages/history/src/command/history-command.type.ts b/packages/history/src/command/history-command.type.ts new file mode 100644 index 00000000..c50d7e51 --- /dev/null +++ b/packages/history/src/command/history-command.type.ts @@ -0,0 +1,51 @@ +/** Who made a change: undo only ever targets changes of the local user. */ +export interface CommandOrigin { + readonly kind: 'user' | 'plugin' | 'remote'; + readonly id: string; +} + +type MaybePromise = T | Promise; + +/** An undoable change. `do` runs once when pushed; `redo` defaults to `do`. */ +export interface HistoryCommand { + readonly label: string; + /** Defaults to the local user of the history service. */ + readonly origin?: CommandOrigin; + /** Approximate memory held by the command (snapshots…), for the stack memory budget. */ + readonly sizeBytes?: number; + /** Commands with the same key pushed within the merge window become one step. */ + readonly mergeKey?: string; + /** What changed, for the history panel (e.g. the lines of a document edit). */ + readonly preview?: CommandPreview; + /** Steps of a grouped command (transactions). */ + readonly children?: readonly HistoryCommand[]; + do(): MaybePromise; + undo(): MaybePromise; + redo?(): MaybePromise; + /** + * Merges a following command into this one. Returns the merged command, or undefined when + * they cannot merge. Without it, commands with the same `mergeKey` merge by keeping this + * command's `undo` and the next one's `redo`. + */ + merge?(next: HistoryCommand): HistoryCommand | undefined; + /** Checked before undo/redo; false means the state it relies on changed (step dropped). */ + isValid?(): MaybePromise; + /** Called when the command leaves the history (trimmed, cleared, invalidated). */ + dispose?(): void; + /** Data to rebuild the command after a reload (see `HistoryService.registerDeserializer`). */ + serialize?(): SerializedCommand | undefined; +} + +/** Before/after text of a change (a few lines around it). */ +export interface CommandPreview { + readonly title?: string; + readonly before: string; + readonly after: string; +} + +export interface SerializedCommand { + /** Deserializer type (`document.edits`…). */ + readonly type: string; + readonly label: string; + readonly data: unknown; +} diff --git a/packages/history/src/command/index.ts b/packages/history/src/command/index.ts new file mode 100644 index 00000000..7a69c864 --- /dev/null +++ b/packages/history/src/command/index.ts @@ -0,0 +1,3 @@ +export * from './history-command.type'; +export * from './composite-command'; +export * from './invalid-command.exception'; diff --git a/packages/history/src/command/invalid-command.exception.ts b/packages/history/src/command/invalid-command.exception.ts new file mode 100644 index 00000000..dd8ac326 --- /dev/null +++ b/packages/history/src/command/invalid-command.exception.ts @@ -0,0 +1,10 @@ +/** + * A step of a grouped command no longer matches the state it left (an edit made in between): + * what already ran was reverted, and the history drops the step instead of undoing it. + */ +export class InvalidCommandError extends Error { + constructor(readonly command: string) { + super(`${command} changed since: not undone`); + this.name = 'InvalidCommandError'; + } +} diff --git a/packages/history/src/file/apply-text-edits.ts b/packages/history/src/file/apply-text-edits.ts new file mode 100644 index 00000000..1f693467 --- /dev/null +++ b/packages/history/src/file/apply-text-edits.ts @@ -0,0 +1,36 @@ +/** Replacement of `[start, end)` (UTF-16 offsets) by `text`: a serializable document operation. */ +export interface TextEdit { + readonly start: number; + readonly end: number; + readonly text: string; +} + +/** Applies non-overlapping edits (any order) and returns the new text with the inverse edits. */ +export const applyTextEdits = ( + source: string, + edits: readonly TextEdit[], +): { text: string; inverse: TextEdit[] } => { + const sorted = [...edits].sort((a, b) => a.start - b.start); + for (let i = 1; i < sorted.length; i++) { + if (sorted[i]!.start < sorted[i - 1]!.end) throw new Error('Overlapping text edits'); + } + let text = ''; + let cursor = 0; + let delta = 0; + const inverse: TextEdit[] = []; + for (const edit of sorted) { + if (edit.start < cursor || edit.end > source.length || edit.start > edit.end) { + throw new Error(`Text edit out of range: ${edit.start}-${edit.end}`); + } + text += source.slice(cursor, edit.start) + edit.text; + const start = edit.start + delta; + inverse.push({ + start, + end: start + edit.text.length, + text: source.slice(edit.start, edit.end), + }); + delta += edit.text.length - (edit.end - edit.start); + cursor = edit.end; + } + return { text: text + source.slice(cursor), inverse }; +}; diff --git a/packages/history/src/file/index.ts b/packages/history/src/file/index.ts new file mode 100644 index 00000000..9ddab5ee --- /dev/null +++ b/packages/history/src/file/index.ts @@ -0,0 +1 @@ +export * from './apply-text-edits'; diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts new file mode 100644 index 00000000..34942403 --- /dev/null +++ b/packages/history/src/index.ts @@ -0,0 +1,4 @@ +export * from './command'; +export * from './file'; +export * from './service'; +export * from './stack'; diff --git a/packages/history/src/service/history-context-keys.const.ts b/packages/history/src/service/history-context-keys.const.ts new file mode 100644 index 00000000..136ffb2d --- /dev/null +++ b/packages/history/src/service/history-context-keys.const.ts @@ -0,0 +1,4 @@ +/** Context key holding the history context of the focused widget. */ +export const HISTORY_CONTEXT_KEY = 'historyContext'; +/** Context key set while a text field or the code editor has focus (native undo). */ +export const TEXT_INPUT_FOCUS_KEY = 'textInputFocus'; diff --git a/packages/history/src/service/history-service.ts b/packages/history/src/service/history-service.ts new file mode 100644 index 00000000..44a3c1e3 --- /dev/null +++ b/packages/history/src/service/history-service.ts @@ -0,0 +1,227 @@ +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + type Observable, + ObservableValue, + constant, + createToken, + observe, +} from '@nanoforge-dev/editor-kernel'; + +import type { CommandOrigin, HistoryCommand } from '../command/history-command.type'; +import { HistoryStack } from '../stack/history-stack'; +import type { + ContextInvalidation, + Deserializer, + HistoryContext, + HistoryContextInfo, + HistoryContextOptions, + HistoryServiceOptions, + HistoryStore, + PersistedEntry, +} from './history-service.type'; + +/** One undo/redo stack per context (document, layout, settings…). */ +export class HistoryService implements Disposable { + private readonly _contexts = new Map(); + private readonly _onDidChange = new Emitter(); + private readonly _onDidInvalidate = new Emitter(); + private readonly _list = new ObservableValue([]); + private readonly _store = new DisposableStore(); + private readonly _limit: Observable; + private readonly _mergeWindow: Observable; + private _localOrigin: CommandOrigin; + private readonly _deserializers = new Map(); + private readonly _persisted: { match: (id: string) => boolean; store: HistoryStore }[] = []; + + /** Fires with the id of the context whose stack changed. */ + readonly onDidChange: Event = this._onDidChange.event; + readonly onDidInvalidate: Event = this._onDidInvalidate.event; + + constructor(private readonly _options: HistoryServiceOptions = {}) { + this._limit = _options.limit ?? constant(100); + this._mergeWindow = _options.mergeWindowMs ?? constant(500); + this._localOrigin = _options.localOrigin ?? { kind: 'user', id: 'local' }; + this._store.add( + observe( + this._limit, + () => { + for (const context of this._contexts.values()) void context.stack.trim(); + }, + { immediate: false }, + ), + ); + } + + get localOrigin(): CommandOrigin { + return this._localOrigin; + } + + set localOrigin(origin: CommandOrigin) { + this._localOrigin = origin; + } + + /** Every context with its stack state, for the history panel. */ + get contexts(): Observable { + return this._list.readonly(); + } + + registerContext(options: HistoryContextOptions): HistoryContext { + if (this._contexts.has(options.id)) + throw new Error(`History context "${options.id}" already exists`); + const stack = new HistoryStack({ + localOrigin: () => this._localOrigin, + limit: () => this._limit.get(), + mergeWindowMs: () => this._mergeWindow.get(), + budgetBytes: () => this._options.budgetBytes ?? 64 * 1024 * 1024, + ...(this._options.now && { now: this._options.now }), + }); + const subscriptions = new DisposableStore(); + subscriptions.add(stack.onDidChange(() => this._changed(options.id))); + subscriptions.add( + stack.onDidInvalidate((reason) => + this._onDidInvalidate.fire({ contextId: options.id, reason }), + ), + ); + const context: HistoryContext = { + id: options.id, + label: options.label, + owner: options.owner ?? 'core', + stack, + dispose: () => { + if (this._contexts.get(options.id) !== context) return; + this._contexts.delete(options.id); + subscriptions.dispose(); + stack.dispose(); + this._changed(options.id); + }, + }; + this._contexts.set(options.id, context); + this._changed(options.id); + const persisted = this._persisted.find((candidate) => candidate.match(options.id)); + if (persisted) subscriptions.add(this._persistContext(context, persisted.store)); + return context; + } + + /** Rebuilds commands of a type from their serialized data (history kept across reloads). */ + registerDeserializer(type: string, deserialize: Deserializer): Disposable { + this._deserializers.set(type, deserialize); + return { dispose: () => this._deserializers.delete(type) }; + } + + /** + * Keeps the history of the matching contexts in a store: restored when such a context is + * registered (under the steps made meanwhile), saved when it changes and when the context + * goes away. Only histories whose steps are all serializable are saved; otherwise the stored + * history stays as it is. + */ + persist(match: (id: string) => boolean, store: HistoryStore): Disposable { + const entry = { match, store }; + this._persisted.push(entry); + return { + dispose: () => { + const index = this._persisted.indexOf(entry); + if (index >= 0) this._persisted.splice(index, 1); + }, + }; + } + + /** Undoes or redoes a context until the entry is its last applied step. */ + async goTo(contextId: string, entryId: number): Promise { + await this._contexts.get(contextId)?.stack.goTo(entryId); + } + + async clear(contextId: string): Promise { + await this._contexts.get(contextId)?.stack.clear(); + } + + get(id: string): HistoryContext | undefined { + return this._contexts.get(id); + } + + /** Undoes in the given context; returns false when there was nothing to undo. */ + async undo(contextId: string | undefined): Promise { + const context = contextId ? this._contexts.get(contextId) : undefined; + return context ? context.stack.undo() : false; + } + + async redo(contextId: string | undefined): Promise { + const context = contextId ? this._contexts.get(contextId) : undefined; + return context ? context.stack.redo() : false; + } + + dispose(): void { + for (const context of [...this._contexts.values()]) context.dispose(); + this._store.dispose(); + this._onDidChange.dispose(); + this._onDidInvalidate.dispose(); + } + + private _persistContext(context: HistoryContext, store: HistoryStore): Disposable { + let restored = false; + let timer: ReturnType | undefined; + const write = () => { + timer = undefined; + const entries: PersistedEntry[] = []; + for (const entry of context.stack.state.past) { + const command = entry.command.serialize?.(); + if (!command) return; + entries.push({ command, origin: entry.origin, time: entry.time }); + } + void store.save(context.id, entries).catch(() => undefined); + }; + const save = () => { + clearTimeout(timer); + timer = setTimeout(write, 300); + }; + void store + .load(context.id) + .then(async (entries) => { + const commands = (entries ?? []).map((entry) => ({ + ...entry, + command: this._deserializers.get(entry.command.type)?.( + entry.command.data, + entry.command.label, + ), + })); + if (commands.length && commands.every((entry) => entry.command)) { + await context.stack.restore( + commands as { command: HistoryCommand; origin: CommandOrigin; time: number }[], + ); + } + }) + .catch(() => undefined) + .finally(() => { + restored = true; + if (context.stack.state.past.length) save(); + }); + const subscription = context.stack.onDidChange(() => { + if (restored) save(); + }); + return { + dispose: () => { + if (timer !== undefined) { + clearTimeout(timer); + write(); + } + subscription.dispose(); + }, + }; + } + + private _changed(id: string): void { + this._list.set( + [...this._contexts.values()].map((context) => ({ + id: context.id, + label: context.label, + owner: context.owner, + state: context.stack.state, + })), + ); + this._onDidChange.fire(id); + } +} + +export const HistoryServiceToken = createToken('history.service'); diff --git a/packages/history/src/service/history-service.type.ts b/packages/history/src/service/history-service.type.ts new file mode 100644 index 00000000..ea0c84ca --- /dev/null +++ b/packages/history/src/service/history-service.type.ts @@ -0,0 +1,60 @@ +import type { Disposable, Observable } from '@nanoforge-dev/editor-kernel'; + +import type { + CommandOrigin, + HistoryCommand, + SerializedCommand, +} from '../command/history-command.type'; +import { type HistoryStack } from '../stack/history-stack'; +import type { InvalidationReason, StackState } from '../stack/history-stack.type'; + +export interface HistoryContextOptions { + /** Stable id, e.g. `file:apps/client/src/main.ts`, `layout`, `settings`. */ + readonly id: string; + readonly label: string; + /** Plugin name or `core`. */ + readonly owner?: string; +} + +export interface HistoryContext extends Disposable { + readonly id: string; + readonly label: string; + readonly owner: string; + readonly stack: HistoryStack; +} + +/** A step of a persisted history (see `HistoryService.persist`). */ +export interface PersistedEntry { + readonly command: SerializedCommand; + readonly origin: CommandOrigin; + readonly time: number; +} + +/** Where persisted histories live (e.g. IndexedDB, per project). */ +export interface HistoryStore { + load(contextId: string): Promise; + save(contextId: string, entries: readonly PersistedEntry[]): Promise; +} + +export type Deserializer = (data: unknown, label: string) => HistoryCommand | undefined; + +export interface HistoryContextInfo { + readonly id: string; + readonly label: string; + readonly owner: string; + readonly state: StackState; +} + +export interface HistoryServiceOptions { + readonly limit?: Observable; + readonly mergeWindowMs?: Observable; + /** Memory budget per context (default 64 MiB). */ + readonly budgetBytes?: number; + readonly localOrigin?: CommandOrigin; + readonly now?: () => number; +} + +export interface ContextInvalidation { + readonly contextId: string; + readonly reason: InvalidationReason; +} diff --git a/packages/history/src/service/index.ts b/packages/history/src/service/index.ts new file mode 100644 index 00000000..cda32154 --- /dev/null +++ b/packages/history/src/service/index.ts @@ -0,0 +1,5 @@ +export * from './history-service.type'; +export * from './history-context-keys.const'; +export * from './history-service'; +export * from './undo-target'; +export * from './register-history-commands'; diff --git a/packages/history/src/service/register-history-commands.ts b/packages/history/src/service/register-history-commands.ts new file mode 100644 index 00000000..3ad0a9a8 --- /dev/null +++ b/packages/history/src/service/register-history-commands.ts @@ -0,0 +1,48 @@ +import { + type CommandRegistry, + type ContextKeyService, + type Disposable, + DisposableStore, +} from '@nanoforge-dev/editor-kernel'; + +import { type HistoryService } from './history-service'; +import { resolveUndoTarget } from './undo-target'; + +/** + * Registers `history.undo` / `history.redo`. They return false when the keystroke should fall + * through to the browser (native undo), so the keybinding layer does not prevent it. + */ +export const registerHistoryCommands = ( + commands: CommandRegistry, + context: ContextKeyService, + history: HistoryService, + options: { + /** Context undone when the focused widget has none (e.g. `layout`). */ + fallbackContext?: string; + } = {}, +): Disposable => { + const store = new DisposableStore(); + const run = (action: 'undo' | 'redo') => async (): Promise => { + const target = resolveUndoTarget(context, options.fallbackContext); + if (target.kind !== 'context') return false; + await history[action](target.id); + return true; + }; + store.add( + commands.register({ + id: 'history.undo', + title: 'Undo', + category: 'Edit', + handler: run('undo'), + }), + ); + store.add( + commands.register({ + id: 'history.redo', + title: 'Redo', + category: 'Edit', + handler: run('redo'), + }), + ); + return store; +}; diff --git a/packages/history/src/service/undo-target.ts b/packages/history/src/service/undo-target.ts new file mode 100644 index 00000000..1036e11e --- /dev/null +++ b/packages/history/src/service/undo-target.ts @@ -0,0 +1,18 @@ +import type { ContextKeyService } from '@nanoforge-dev/editor-kernel'; + +import { HISTORY_CONTEXT_KEY, TEXT_INPUT_FOCUS_KEY } from './history-context-keys.const'; + +export type UndoTarget = + | { readonly kind: 'native' } + | { readonly kind: 'context'; readonly id: string } + | { readonly kind: 'none' }; + +/** + * Where Ctrl+Z goes: native undo inside text inputs and the code editor, otherwise the history + * context of the focused widget. + */ +export const resolveUndoTarget = (context: ContextKeyService, fallback?: string): UndoTarget => { + if (context.get(TEXT_INPUT_FOCUS_KEY)) return { kind: 'native' }; + const id = context.get(HISTORY_CONTEXT_KEY) ?? fallback; + return id ? { kind: 'context', id } : { kind: 'none' }; +}; diff --git a/packages/history/src/stack/history-stack.ts b/packages/history/src/stack/history-stack.ts new file mode 100644 index 00000000..f3b689dd --- /dev/null +++ b/packages/history/src/stack/history-stack.ts @@ -0,0 +1,323 @@ +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; + +import { compositeCommand, mergeCommands, sameOrigin } from '../command/composite-command'; +import type { CommandOrigin, HistoryCommand } from '../command/history-command.type'; +import { InvalidCommandError } from '../command/invalid-command.exception'; +import type { + HistoryEntry, + InvalidationReason, + StackOptions, + StackState, + Transaction, +} from './history-stack.type'; + +const SAVED_AT_START = 0; + +/** + * Undo/redo stack of one context. Operations are serialized. Commands pushed while a transaction + * runs join it, whether through the transaction handle or `push` (code the transaction calls + * may not know it runs inside one): queueing them behind the transaction would wait for ever. + */ +export class HistoryStack { + private _past: HistoryEntry[] = []; + private _future: HistoryEntry[] = []; + private _queue: Promise = Promise.resolve(); + private _nextId = 1; + /** Entry id at the savepoint (0: the initial state). */ + private _savedAt: number | null = SAVED_AT_START; + /** The commands of the transaction running now, if any. */ + private _transaction: HistoryCommand[] | undefined; + private readonly _onDidChange = new Emitter(); + private readonly _onDidInvalidate = new Emitter(); + + readonly onDidChange: Event = this._onDidChange.event; + readonly onDidInvalidate: Event = this._onDidInvalidate.event; + + constructor(private readonly _options: StackOptions) {} + + get state(): StackState { + const top = this._past.at(-1)?.id ?? SAVED_AT_START; + return { + past: [...this._past], + future: [...this._future], + canUndo: this._past.some((entry) => entry.undoable), + canRedo: this._future.length > 0, + dirty: this._savedAt !== top, + }; + } + + /** Executes a command and records it (merging with the previous one when allowed). */ + push(command: HistoryCommand): Promise { + const open = this._transaction; + if (open) { + return Promise.resolve(command.do()).then(() => { + open.push(command); + }); + } + return this._serial(async () => { + await command.do(); + this._add(command, true); + }); + } + + /** + * Records a change already applied elsewhere (e.g. by a collaborator). Changes of other + * origins are not undoable. + */ + record(command: HistoryCommand): Promise { + return this._serial(async () => this._add(command, false)); + } + + /** + * Groups the commands pushed through `tx` into one step. If `fn` throws, the commands already + * applied are undone in reverse order and the error is rethrown. + */ + transaction(label: string, fn: (tx: Transaction) => Promise): Promise { + if (this._transaction) return runTransaction(this._transaction, fn); + return this._serial(async () => { + const collected: HistoryCommand[] = []; + this._transaction = collected; + try { + const result = await runTransaction(collected, fn); + if (collected.length) + this._add(compositeCommand(label, collected, this._options.localOrigin()), true, false); + return result; + } finally { + this._transaction = undefined; + } + }); + } + + undo(): Promise { + return this._serial(() => this._undoOne()); + } + + redo(): Promise { + return this._serial(() => this._redoOne()); + } + + /** + * Undoes or redoes until the entry is the last applied step (0: before every step). Changes + * of other origins cannot be undone and stay applied. + */ + goTo(entryId: number): Promise { + return this._serial(async () => { + if (this._future.some((entry) => entry.id === entryId)) { + while (this._future.some((entry) => entry.id === entryId)) { + if (!(await this._redoOne())) return; + } + return; + } + for (;;) { + const last = this._past.findLast((entry) => entry.undoable); + if (!last || last.id <= entryId) return; + if (!(await this._undoOne())) return; + } + }); + } + + /** + * Puts back entries restored after a reload (already applied), before the steps recorded since + * the reload: an edit made while the history was loading stays on top of them. + */ + restore( + entries: readonly { command: HistoryCommand; origin: CommandOrigin; time: number }[], + ): Promise { + return this._serial(async () => { + const local = this._options.localOrigin(); + const restored = entries.map((entry) => ({ + id: this._nextId++, + command: entry.command, + origin: entry.origin, + undoable: sameOrigin(entry.origin, local), + time: entry.time, + })); + if (!this._past.length) { + this._drop(this._future); + this._future = []; + this._savedAt = null; + } + this._past = [...restored, ...this._past]; + this._trim(); + this._changed(); + }); + } + + /** Marks the current state as saved (dirty tracking). */ + markSaved(): void { + this._savedAt = this._past.at(-1)?.id ?? SAVED_AT_START; + this._changed(); + } + + /** Drops every step: the state they relied on changed. */ + invalidate(reason: InvalidationReason = 'manual'): Promise { + return this._serial(async () => this._invalidate(reason)); + } + + clear(): Promise { + return this._serial(async () => { + this._drop([...this._past, ...this._future]); + this._past = []; + this._future = []; + this._savedAt = SAVED_AT_START; + this._changed(); + }); + } + + /** Re-applies limits (after the limit settings changed). */ + trim(): Promise { + return this._serial(async () => { + this._trim(); + this._changed(); + }); + } + + dispose(): void { + this._drop([...this._past, ...this._future]); + this._past = []; + this._future = []; + this._onDidChange.dispose(); + this._onDidInvalidate.dispose(); + } + + private async _undoOne(): Promise { + const index = this._past.findLastIndex((entry) => entry.undoable); + if (index < 0) return false; + const entry = this._past[index]!; + if ((await entry.command.isValid?.()) === false) { + this._invalidate('invalid-command'); + return false; + } + if (!(await this._run(() => entry.command.undo()))) return false; + this._past.splice(index, 1); + this._future.push(entry); + this._changed(); + return true; + } + + private async _redoOne(): Promise { + const entry = this._future.at(-1); + if (!entry) return false; + if ((await entry.command.isValid?.()) === false) { + this._invalidate('invalid-command'); + return false; + } + if (!(await this._run(() => (entry.command.redo ?? entry.command.do).call(entry.command)))) + return false; + this._future.pop(); + this._past.push(entry); + this._changed(); + return true; + } + + /** + * Runs an undo or a redo; false when a part of the step changed since (it reverted itself): + * the history is then dropped, as for a step found invalid beforehand. + */ + private async _run(step: () => Promise | void): Promise { + try { + await step(); + return true; + } catch (error) { + if (!(error instanceof InvalidCommandError)) throw error; + this._invalidate('invalid-command'); + return false; + } + } + + private _add(command: HistoryCommand, local: boolean, mergeable = true): void { + const origin = command.origin ?? this._options.localOrigin(); + const undoable = local && sameOrigin(origin, this._options.localOrigin()); + const now = (this._options.now ?? Date.now)(); + if (undoable) { + this._drop(this._future); + this._future = []; + } + const previous = this._past.at(-1); + if ( + mergeable && + undoable && + previous?.undoable && + command.mergeKey !== undefined && + previous.command.mergeKey === command.mergeKey && + now - previous.time <= this._options.mergeWindowMs() && + this._savedAt !== previous.id + ) { + const merged = previous.command.merge + ? previous.command.merge(command) + : mergeCommands(previous.command, command); + if (merged) { + this._past[this._past.length - 1] = { ...previous, command: merged, time: now }; + this._trim(); + this._changed(); + return; + } + } + this._past.push({ id: this._nextId++, command, origin, undoable, time: now }); + this._trim(); + this._changed(); + } + + private _trim(): void { + const limit = Math.max(1, this._options.limit()); + const budget = this._options.budgetBytes(); + const size = () => + [...this._past, ...this._future].reduce( + (total, entry) => total + (entry.command.sizeBytes ?? 0), + 0, + ); + while (this._past.length > limit || (this._past.length > 1 && size() > budget)) { + const dropped = this._past.shift()!; + /** Savepoint no longer reachable. */ + if (this._savedAt === dropped.id) this._savedAt = null; + dropped.command.dispose?.(); + } + while (this._future.length && size() > budget) this._future.shift()!.command.dispose?.(); + } + + private _invalidate(reason: InvalidationReason): void { + this._drop([...this._past, ...this._future]); + this._past = []; + this._future = []; + this._savedAt = null; + this._changed(); + this._onDidInvalidate.fire(reason); + } + + private _drop(entries: readonly HistoryEntry[]): void { + for (const entry of entries) entry.command.dispose?.(); + } + + private _changed(): void { + this._onDidChange.fire(this.state); + } + + private _serial(task: () => Promise): Promise { + const result = this._queue.then(task, task); + this._queue = result.catch(() => undefined); + return result; + } +} + +const runTransaction = async ( + collected: HistoryCommand[], + fn: (tx: Transaction) => Promise, +): Promise => { + const start = collected.length; + const tx: Transaction = { + push: async (command) => { + await command.do(); + collected.push(command); + }, + transaction: (_label, inner) => runTransaction(collected, inner), + }; + try { + return await fn(tx); + } catch (error) { + for (const command of collected.splice(start).reverse()) { + await command.undo(); + command.dispose?.(); + } + throw error; + } +}; diff --git a/packages/history/src/stack/history-stack.type.ts b/packages/history/src/stack/history-stack.type.ts new file mode 100644 index 00000000..07a48345 --- /dev/null +++ b/packages/history/src/stack/history-stack.type.ts @@ -0,0 +1,36 @@ +import type { CommandOrigin, HistoryCommand } from '../command/history-command.type'; + +export interface HistoryEntry { + readonly id: number; + readonly command: HistoryCommand; + readonly origin: CommandOrigin; + /** False for changes of other origins: skipped by undo. */ + readonly undoable: boolean; + readonly time: number; +} + +export interface StackOptions { + /** Local user: the only origin undo/redo may target. */ + readonly localOrigin: () => CommandOrigin; + readonly limit: () => number; + /** Memory budget of the stack in bytes (sum of `sizeBytes`). */ + readonly budgetBytes: () => number; + readonly mergeWindowMs: () => number; + readonly now?: () => number; +} + +export interface StackState { + readonly past: readonly HistoryEntry[]; + readonly future: readonly HistoryEntry[]; + readonly canUndo: boolean; + readonly canRedo: boolean; + readonly dirty: boolean; +} + +export type InvalidationReason = 'external-change' | 'invalid-command' | 'manual'; + +/** Collects commands of a transaction; `push` executes a command inside the transaction. */ +export interface Transaction { + push(command: HistoryCommand): Promise; + transaction(label: string, fn: (tx: Transaction) => Promise): Promise; +} diff --git a/packages/history/src/stack/index.ts b/packages/history/src/stack/index.ts new file mode 100644 index 00000000..2d040bf5 --- /dev/null +++ b/packages/history/src/stack/index.ts @@ -0,0 +1,2 @@ +export * from './history-stack.type'; +export * from './history-stack'; diff --git a/packages/history/test/file/apply-text-edits.spec.ts b/packages/history/test/file/apply-text-edits.spec.ts new file mode 100644 index 00000000..5780254c --- /dev/null +++ b/packages/history/test/file/apply-text-edits.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { applyTextEdits } from '../../src'; + +describe('applyTextEdits', () => { + it('applies unordered edits and returns their inverse', () => { + const source = 'const a = 1;\nconst b = 2;\n'; + const { text, inverse } = applyTextEdits(source, [ + { start: 23, end: 24, text: '20' }, + { start: 10, end: 11, text: '10' }, + ]); + expect(text).toBe('const a = 10;\nconst b = 20;\n'); + expect(applyTextEdits(text, inverse).text).toBe(source); + expect(() => + applyTextEdits(source, [ + { start: 0, end: 5, text: '' }, + { start: 3, end: 6, text: '' }, + ]), + ).toThrow(/Overlapping/); + }); +}); diff --git a/packages/history/test/fixtures/history.ts b/packages/history/test/fixtures/history.ts new file mode 100644 index 00000000..7533a6bf --- /dev/null +++ b/packages/history/test/fixtures/history.ts @@ -0,0 +1,34 @@ +import { ObservableValue } from '@nanoforge-dev/editor-kernel'; +import { type HistoryCommand, HistoryService } from '../../src'; + +/** A value with undoable "set" commands. */ +export const cell = (initial: number) => { + const state = { value: initial }; + const set = (value: number, extra: Partial = {}): HistoryCommand => { + let previous = 0; + return { + label: `set ${value}`, + do: () => { + previous = state.value; + state.value = value; + }, + undo: () => { + state.value = previous; + }, + ...extra, + }; + }; + return { state, set }; +}; + +export const setup = (options: { limit?: number; now?: () => number; budgetBytes?: number } = {}) => { + const limit = new ObservableValue(options.limit ?? 100); + const history = new HistoryService({ + limit, + mergeWindowMs: new ObservableValue(500), + ...(options.now && { now: options.now }), + ...(options.budgetBytes !== undefined && { budgetBytes: options.budgetBytes }), + }); + const context = history.registerContext({ id: 'scene', label: 'Scene' }); + return { history, context, stack: context.stack, limit }; +}; diff --git a/packages/history/test/service/history-service.spec.ts b/packages/history/test/service/history-service.spec.ts new file mode 100644 index 00000000..6180e0d0 --- /dev/null +++ b/packages/history/test/service/history-service.spec.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { type HistoryCommand, HistoryService } from '../../src'; +import { cell, setup } from '../fixtures/history'; + +describe('history panel support', () => { + it('jumps to any step, backward and forward', async () => { + const { context, history } = setup(); + const { state, set } = cell(0); + for (const value of [1, 2, 3, 4]) await context.stack.push(set(value)); + const ids = context.stack.state.past.map((entry) => entry.id); + + await history.goTo('scene', ids[1]!); + expect(state.value).toBe(2); + expect(context.stack.state.future).toHaveLength(2); + + await history.goTo('scene', ids[3]!); + expect(state.value).toBe(4); + await history.goTo('scene', 0); + expect(state.value).toBe(0); + + await history.clear('scene'); + expect(context.stack.state.past).toHaveLength(0); + expect(context.stack.state.future).toHaveLength(0); + }); + + it('exposes the steps of transactions', async () => { + const { context } = setup(); + const { set } = cell(0); + await context.stack.transaction('Drag', async (tx) => { + await tx.push(set(1)); + await tx.push(set(2)); + }); + expect(context.stack.state.past[0]!.command.children?.map((child) => child.label)).toEqual([ + 'set 1', + 'set 2', + ]); + }); + + it('keeps serializable histories of matching contexts across reloads', async () => { + const saved = new Map(); + const store = { + load: async (id: string) => saved.get(id) as never, + save: async (id: string, entries: unknown) => void saved.set(id, entries), + }; + const values = { current: 0 }; + const command = (value: number, previous: number): HistoryCommand => ({ + label: `set ${value}`, + do: () => void (values.current = value), + undo: () => void (values.current = previous), + serialize: () => ({ type: 'set', label: `set ${value}`, data: { value, previous } }), + }); + + const first = new HistoryService(); + first.persist((id) => id.startsWith('file:'), store); + const context = first.registerContext({ id: 'file:a.ts', label: 'a.ts' }); + await context.stack.push(command(1, 0)); + await context.stack.push(command(2, 1)); + await vi.waitFor(() => expect(saved.get('file:a.ts')).toHaveLength(2)); + first.dispose(); + + const second = new HistoryService(); + second.registerDeserializer('set', (data) => { + const { value, previous } = data as { value: number; previous: number }; + return command(value, previous); + }); + second.persist((id) => id.startsWith('file:'), store); + const restored = second.registerContext({ id: 'file:a.ts', label: 'a.ts' }); + await vi.waitFor(() => expect(restored.stack.state.past).toHaveLength(2)); + await restored.stack.undo(); + expect(values.current).toBe(1); + const other = second.registerContext({ id: 'layout', label: 'Layout' }); + await other.stack.push(command(9, 0)); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(saved.has('layout')).toBe(false); + }); + + it('restores under an edit made while loading, and saves pending steps on dispose', async () => { + const saved = new Map(); + let release!: () => void; + const loading = new Promise((resolve) => (release = resolve)); + const values = { current: 0 }; + const command = (value: number, previous: number): HistoryCommand => ({ + label: `set ${value}`, + do: () => void (values.current = value), + undo: () => void (values.current = previous), + serialize: () => ({ type: 'set', label: `set ${value}`, data: { value, previous } }), + }); + saved.set('file:a.ts', [ + { command: command(1, 0).serialize!(), origin: { kind: 'local', id: 'local' }, time: 1 }, + ]); + const store = { + load: async (id: string) => { + await loading; + return saved.get(id) as never; + }, + save: async (id: string, entries: unknown) => void saved.set(id, entries), + }; + const service = new HistoryService(); + service.registerDeserializer('set', (data) => { + const { value, previous } = data as { value: number; previous: number }; + return command(value, previous); + }); + service.persist((id) => id.startsWith('file:'), store); + const context = service.registerContext({ id: 'file:a.ts', label: 'a.ts' }); + values.current = 1; + await context.stack.push(command(2, 1)); + release(); + await vi.waitFor(() => expect(context.stack.state.past).toHaveLength(2)); + await context.stack.push(command(3, 2)); + context.dispose(); + expect(saved.get('file:a.ts')).toHaveLength(3); + await service.undo('file:a.ts'); + }); +}); diff --git a/packages/history/test/service/undo-target.spec.ts b/packages/history/test/service/undo-target.spec.ts new file mode 100644 index 00000000..4b17f855 --- /dev/null +++ b/packages/history/test/service/undo-target.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { CommandRegistry, Container, ContextKeyService } from '@nanoforge-dev/editor-kernel'; + +import { + HISTORY_CONTEXT_KEY, + HistoryService, + TEXT_INPUT_FOCUS_KEY, + registerHistoryCommands, +} from '../../src'; +import { cell } from '../fixtures/history'; + +describe('undo routing', () => { + it('targets the focused context and leaves text inputs to the browser', async () => { + const history = new HistoryService(); + const scene = history.registerContext({ id: 'scene', label: 'Scene' }).stack; + const layout = history.registerContext({ id: 'layout', label: 'Layout' }).stack; + const a = cell(0); + const b = cell(0); + await scene.push(a.set(1)); + await layout.push(b.set(1)); + + const context = new ContextKeyService(); + const commands = new CommandRegistry(new Container(), context); + registerHistoryCommands(commands, context, history); + + context.set(HISTORY_CONTEXT_KEY, 'layout'); + expect(await commands.execute('history.undo')).toBe(true); + expect([a.state.value, b.state.value]).toEqual([1, 0]); + + context.set(TEXT_INPUT_FOCUS_KEY, true); + /** Native undo. */ + expect(await commands.execute('history.undo')).toBe(false); + context.set(TEXT_INPUT_FOCUS_KEY, false); + context.set(HISTORY_CONTEXT_KEY, 'scene'); + await commands.execute('history.undo'); + expect(a.state.value).toBe(0); + context.delete(HISTORY_CONTEXT_KEY); + expect(await commands.execute('history.redo')).toBe(false); + }); + + it('falls back to a default context when the focused widget has none', async () => { + const history = new HistoryService(); + const layout = history.registerContext({ id: 'layout', label: 'Layout' }).stack; + const value = cell(0); + await layout.push(value.set(3)); + const context = new ContextKeyService(); + const commands = new CommandRegistry(new Container(), context); + registerHistoryCommands(commands, context, history, { fallbackContext: 'layout' }); + await commands.execute('history.undo'); + expect(value.state.value).toBe(0); + }); +}); diff --git a/packages/history/test/stack/history-stack.spec.ts b/packages/history/test/stack/history-stack.spec.ts new file mode 100644 index 00000000..8898a8ea --- /dev/null +++ b/packages/history/test/stack/history-stack.spec.ts @@ -0,0 +1,260 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { type HistoryCommand, compositeCommand } from '../../src'; +import { cell, setup } from '../fixtures/history'; + +describe('HistoryStack', () => { + it('undoes and redoes, and a new change drops the redo steps', async () => { + const { stack } = setup(); + const { state, set } = cell(0); + await stack.push(set(1)); + await stack.push(set(2)); + await stack.undo(); + expect(state.value).toBe(1); + await stack.redo(); + expect(state.value).toBe(2); + await stack.undo(); + await stack.push(set(5)); + expect(stack.state.canRedo).toBe(false); + await stack.undo(); + expect(state.value).toBe(1); + }); + + it('trims to the limit, live when the setting changes', async () => { + const { stack, limit } = setup({ limit: 3 }); + const { state, set } = cell(0); + const disposed = vi.fn(); + for (let i = 1; i <= 5; i++) await stack.push(set(i, { dispose: disposed })); + expect(stack.state.past).toHaveLength(3); + expect(disposed).toHaveBeenCalledTimes(2); + limit.set(1); + await stack.trim(); + expect(stack.state.past).toHaveLength(1); + while (await stack.undo()); + expect(state.value).toBe(4); + }); + + it('trims to the memory budget', async () => { + const { stack } = setup({ budgetBytes: 1000 }); + const { set } = cell(0); + for (let i = 1; i <= 5; i++) await stack.push(set(i, { sizeBytes: 400 })); + expect(stack.state.past.map((entry) => entry.command.label)).toEqual(['set 4', 'set 5']); + }); + + it('merges same-key changes within the merge window only', async () => { + let now = 0; + const { stack } = setup({ now: () => now }); + const { state, set } = cell(0); + await stack.push(set(1, { mergeKey: 'x' })); + now = 300; + await stack.push(set(2, { mergeKey: 'x' })); + now = 700; + await stack.push(set(3, { mergeKey: 'x' })); + now = 1500; + /** Too late: new step. */ + await stack.push(set(4, { mergeKey: 'x' })); + expect(stack.state.past).toHaveLength(2); + await stack.undo(); + expect(state.value).toBe(3); + await stack.undo(); + expect(state.value).toBe(0); + await stack.redo(); + expect(state.value).toBe(3); + }); + + it('groups transactions and rolls back nested failures', async () => { + const { stack } = setup(); + const a = cell(0); + const b = cell(0); + await stack.transaction('move', async (tx) => { + await tx.push(a.set(1)); + await tx.transaction('inner', async (inner) => inner.push(b.set(1))); + }); + expect(stack.state.past).toHaveLength(1); + await expect( + stack.transaction('broken', async (tx) => { + await tx.push(a.set(2)); + await tx.transaction('inner', async (inner) => { + await inner.push(b.set(2)); + throw new Error('boom'); + }); + }), + ).rejects.toThrow('boom'); + expect([a.state.value, b.state.value]).toEqual([1, 1]); + expect(stack.state.past).toHaveLength(1); + await stack.undo(); + expect([a.state.value, b.state.value]).toEqual([0, 0]); + }); + + it('adds pushes made through the stack during a transaction to it', async () => { + const { stack } = setup(); + const a = cell(0); + const b = cell(0); + const c = cell(0); + await stack.transaction('move', async (tx) => { + await tx.push(a.set(1)); + await stack.push(b.set(1)); + await stack.transaction('nested', async (inner) => inner.push(c.set(1))); + }); + expect(stack.state.past).toHaveLength(1); + await expect( + stack.transaction('broken', async () => { + await stack.push(a.set(2)); + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + expect(a.state.value).toBe(1); + await stack.undo(); + expect([a.state.value, b.state.value, c.state.value]).toEqual([0, 0, 0]); + }); + + it('runs async operations in order', async () => { + const { stack } = setup(); + const log: string[] = []; + const slow = (name: string, ms: number): HistoryCommand => ({ + label: name, + do: () => + new Promise((resolve) => setTimeout(() => (log.push(`do ${name}`), resolve()), ms)), + undo: () => void log.push(`undo ${name}`), + }); + await Promise.all([ + stack.push(slow('a', 30)), + stack.push(slow('b', 0)), + stack.undo(), + stack.redo(), + ]); + expect(log).toEqual(['do a', 'do b', 'undo b', 'do b']); + }); + + it('never undoes changes of other origins', async () => { + const { stack } = setup(); + const mine = cell(0); + const theirs = cell(0); + await stack.push(mine.set(1)); + theirs.set(7).do(); + await stack.record({ ...theirs.set(7), origin: { kind: 'remote', id: 'bob' } }); + expect(stack.state.past.map((entry) => entry.undoable)).toEqual([true, false]); + await stack.undo(); + expect([mine.state.value, theirs.state.value]).toEqual([0, 7]); + expect(stack.state.canUndo).toBe(false); + }); + + it('tracks the savepoint for dirty state', async () => { + const { stack } = setup(); + const { set } = cell(0); + expect(stack.state.dirty).toBe(false); + await stack.push(set(1)); + stack.markSaved(); + expect(stack.state.dirty).toBe(false); + await stack.push(set(2)); + expect(stack.state.dirty).toBe(true); + await stack.undo(); + expect(stack.state.dirty).toBe(false); + }); + + it('drops the history when a command is no longer valid', async () => { + const { history, stack } = setup(); + const invalidated = vi.fn(); + history.onDidInvalidate(invalidated); + const { state, set } = cell(0); + await stack.push(set(1, { isValid: () => false })); + expect(await stack.undo()).toBe(false); + expect(state.value).toBe(1); + expect(stack.state.past).toHaveLength(0); + expect(invalidated).toHaveBeenCalledWith({ contextId: 'scene', reason: 'invalid-command' }); + }); + + it('undoes a merged step entirely, the last change first', async () => { + const { stack } = setup(); + const text = { value: '' }; + const insert = (chars: string): HistoryCommand => ({ + label: 'type', + mergeKey: 'typing', + do: () => void (text.value += chars), + undo: () => void (text.value = text.value.slice(0, -chars.length)), + }); + await stack.push(insert('ab')); + await stack.push(insert('cd')); + expect(stack.state.past).toHaveLength(1); + await stack.undo(); + expect(text.value).toBe(''); + await stack.redo(); + expect(text.value).toBe('abcd'); + }); + + it('reverts the parts of a group already applied when one fails', async () => { + const { stack } = setup(); + const { state, set } = cell(0); + const failing: HistoryCommand = { + label: 'fail', + do: () => Promise.reject(new Error('disk full')), + undo: () => undefined, + }; + await expect(stack.push(compositeCommand('move', [set(1), failing]))).rejects.toThrow( + 'disk full', + ); + expect(state.value).toBe(0); + expect(stack.state.past).toHaveLength(0); + }); + describe('steps made of several edits of one document', () => { + /** A document and its edits, valid in the text they last left (as `DocumentService`'s). */ + const doc = () => { + const file = { text: '' }; + const append = (chars: string, extra: Partial = {}): HistoryCommand => { + let expected = ''; + const run = (text: string) => { + file.text = text; + expected = text; + }; + return { + label: `append ${chars}`, + do: () => run(file.text + chars), + undo: () => run(file.text.slice(0, -chars.length)), + isValid: () => file.text === expected, + ...extra, + }; + }; + return { file, append }; + }; + + it('undoes and redoes a transaction that edits the same document twice', async () => { + const { stack } = setup(); + const { file, append } = doc(); + await stack.transaction('two edits', async (tx) => { + await tx.push(append('a')); + await tx.push(append('b')); + }); + expect(await stack.undo()).toBe(true); + expect(file.text).toBe(''); + expect(await stack.redo()).toBe(true); + expect(file.text).toBe('ab'); + expect(await stack.undo()).toBe(true); + expect(file.text).toBe(''); + }); + + it('undoes merged edits of one document', async () => { + const { stack } = setup(); + const { file, append } = doc(); + await stack.push(append('a', { mergeKey: 'typing' })); + await stack.push(append('b', { mergeKey: 'typing' })); + await stack.push(append('c', { mergeKey: 'typing' })); + expect(stack.state.past).toHaveLength(1); + expect(await stack.undo()).toBe(true); + expect(file.text).toBe(''); + expect(await stack.redo()).toBe(true); + expect(file.text).toBe('abc'); + }); + + it('drops the step, leaving the text as it was, when a part of it changed since', async () => { + const { stack } = setup(); + const other = doc(); + const { file, append } = doc(); + await stack.push(compositeCommand('edits', [append('a'), other.append('x')])); + file.text = 'a by hand'; + expect(await stack.undo()).toBe(false); + expect(file.text).toBe('a by hand'); + expect(other.file.text).toBe('x'); + expect(stack.state.past).toHaveLength(0); + }); + }); +}); diff --git a/packages/history/tsconfig.json b/packages/history/tsconfig.json new file mode 100644 index 00000000..888918d9 --- /dev/null +++ b/packages/history/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/kernel/.prettierignore b/packages/kernel/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/kernel/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/kernel/LICENSE b/packages/kernel/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/kernel/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/kernel/README.md b/packages/kernel/README.md new file mode 100644 index 00000000..6de73bba --- /dev/null +++ b/packages/kernel/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-kernel` is an internal package of the NanoForge Editor. + +Plugin kernel: DI, disposables, events, extension points, commands, context keys, plugin host. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/kernel +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/kernel/package.json b/packages/kernel/package.json new file mode 100644 index 00000000..a92f6e0f --- /dev/null +++ b/packages/kernel/package.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-kernel", + "version": "0.0.0", + "description": "NanoForge Editor - Plugin kernel: DI, disposables, events, extension points, commands, context keys, plugin host", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/kernel" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty .", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "generate:schema": "bun ./scripts/generate-schema.ts" + }, + "dependencies": { + "semver": "catalog:libs", + "zod": "catalog:libs" + }, + "devDependencies": { + "@types/semver": "catalog:libs", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/kernel/schemas/plugin.schema.json b/packages/kernel/schemas/plugin.schema.json new file mode 100644 index 00000000..95e5a8a2 --- /dev/null +++ b/packages/kernel/schemas/plugin.schema.json @@ -0,0 +1,222 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "type": { + "type": "string", + "const": "plugin" + }, + "name": { + "type": "string", + "pattern": "^@[a-z0-9][a-z0-9-]*\\/[a-z0-9][a-z0-9-]*$" + }, + "version": { + "type": "string" + }, + "displayName": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "tags": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "icon": { + "type": "string", + "minLength": 1 + }, + "license": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "engines": { + "type": "object", + "properties": { + "editor": { + "type": "string" + } + }, + "required": ["editor"] + }, + "dependencies": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "pattern": "^@[a-z0-9][a-z0-9-]*\\/[a-z0-9][a-z0-9-]*$" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^@[a-z0-9][a-z0-9-]*\\/[a-z0-9][a-z0-9-]*$" + }, + "additionalProperties": { + "type": "string" + } + } + ] + }, + "optionalDependencies": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "pattern": "^@[a-z0-9][a-z0-9-]*\\/[a-z0-9][a-z0-9-]*$" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^@[a-z0-9][a-z0-9-]*\\/[a-z0-9][a-z0-9-]*$" + }, + "additionalProperties": { + "type": "string" + } + } + ] + }, + "engineLibs": { + "default": { + "required": {}, + "optional": {} + }, + "type": "object", + "properties": { + "required": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "type": "string" + } + }, + "optional": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "type": "string" + } + } + } + }, + "entry": { + "type": "object", + "properties": { + "client": { + "type": "string", + "minLength": 1 + }, + "server": { + "type": "string", + "minLength": 1 + }, + "worker": { + "type": "string", + "minLength": 1 + } + } + }, + "activation": { + "default": ["onStartup"], + "type": "array", + "items": { + "type": "string", + "pattern": "^(onStartup|on(Command|Screen|Widget|ProjectType|Language|Uri):.+)$" + } + }, + "contributes": { + "default": { + "commands": [] + }, + "type": "object", + "properties": { + "commands": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "category": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "when": { + "type": "string" + }, + "palette": { + "type": "boolean" + } + }, + "required": ["id", "title"] + } + }, + "itemOwner": { + "type": "object", + "properties": { + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "tags": { + "default": [], + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][A-Za-z0-9]*$" + } + } + }, + "required": ["schema"] + } + }, + "additionalProperties": {} + }, + "build": { + "default": {}, + "type": "object", + "properties": { + "svelte": { + "type": "string" + }, + "sdk": { + "type": "string" + } + } + } + }, + "required": ["type", "name", "version", "engines", "entry"] +} diff --git a/packages/kernel/scripts/generate-schema.ts b/packages/kernel/scripts/generate-schema.ts new file mode 100644 index 00000000..732aab8c --- /dev/null +++ b/packages/kernel/scripts/generate-schema.ts @@ -0,0 +1,7 @@ +import { writeFileSync } from 'node:fs'; + +import { pluginManifestJsonSchema } from '../src/plugin/plugin-manifest'; + +const target = new URL('../schemas/plugin.schema.json', import.meta.url); +writeFileSync(target, `${JSON.stringify(pluginManifestJsonSchema(), null, 2)}\n`); +console.log(`Wrote ${target.pathname}`); diff --git a/packages/kernel/src/command/command-not-found.exception.ts b/packages/kernel/src/command/command-not-found.exception.ts new file mode 100644 index 00000000..0b856756 --- /dev/null +++ b/packages/kernel/src/command/command-not-found.exception.ts @@ -0,0 +1,6 @@ +export class CommandNotFoundError extends Error { + constructor(readonly id: string) { + super(`Command "${id}" not found`); + this.name = 'CommandNotFoundError'; + } +} diff --git a/packages/kernel/src/command/command-precondition.exception.ts b/packages/kernel/src/command/command-precondition.exception.ts new file mode 100644 index 00000000..7e54898a --- /dev/null +++ b/packages/kernel/src/command/command-precondition.exception.ts @@ -0,0 +1,9 @@ +export class CommandPreconditionError extends Error { + constructor( + readonly id: string, + readonly when: string, + ) { + super(`Command "${id}" is not enabled (${when})`); + this.name = 'CommandPreconditionError'; + } +} diff --git a/packages/kernel/src/command/command-registry.ts b/packages/kernel/src/command/command-registry.ts new file mode 100644 index 00000000..0d1d2b88 --- /dev/null +++ b/packages/kernel/src/command/command-registry.ts @@ -0,0 +1,84 @@ +import type { ContextKeyService } from '../context/context-key-service'; +import { parseWhen } from '../context/parse-when'; +import type { ServiceAccessor } from '../di/container.type'; +import { Emitter, type Event } from '../event/emitter'; +import { type Disposable, toDisposable } from '../lifecycle/disposable'; +import { CommandNotFoundError } from './command-not-found.exception'; +import { CommandPreconditionError } from './command-precondition.exception'; +import type { CommandActivator, CommandDescriptor, CommandExecution } from './command.type'; + +export class CommandRegistry implements Disposable { + private readonly _commands = new Map(); + private readonly _onDidRegister = new Emitter(); + private readonly _onDidExecute = new Emitter(); + private _activator: CommandActivator | undefined; + + readonly onDidRegister: Event = this._onDidRegister.event; + readonly onDidExecute: Event = this._onDidExecute.event; + + constructor( + private readonly _services: ServiceAccessor, + private readonly _context?: ContextKeyService, + ) {} + + /** Hook called before running an unknown command, so its plugin can activate. */ + setActivator(activator: CommandActivator | undefined): void { + this._activator = activator; + } + + register(descriptor: CommandDescriptor): Disposable { + if (this._commands.has(descriptor.id)) { + throw new Error(`Command "${descriptor.id}" is already registered`); + } + /** Fail fast on invalid clauses. */ + if (descriptor.when) parseWhen(descriptor.when); + this._commands.set(descriptor.id, descriptor as unknown as CommandDescriptor); + this._onDidRegister.fire(descriptor.id); + return toDisposable(() => { + if (this._commands.get(descriptor.id) === (descriptor as unknown)) { + this._commands.delete(descriptor.id); + } + }); + } + + has(id: string): boolean { + return this._commands.has(id); + } + + get(id: string): CommandDescriptor | undefined { + return this._commands.get(id); + } + + getAll(): CommandDescriptor[] { + return [...this._commands.values()]; + } + + isEnabled(id: string): boolean { + const command = this._commands.get(id); + return !!command && (this._context?.evaluate(command.when) ?? true); + } + + async execute(id: string, ...args: unknown[]): Promise { + if (!this._commands.has(id) && this._activator) await this._activator(id); + const command = this._commands.get(id); + if (!command) throw new CommandNotFoundError(id); + if (command.when && this._context && !this._context.evaluate(command.when)) { + throw new CommandPreconditionError(id, command.when); + } + const start = performance.now(); + try { + const result = (await command.handler(this._services, ...args)) as R; + this._onDidExecute.fire({ id, args, durationMs: performance.now() - start }); + return result; + } catch (error) { + this._onDidExecute.fire({ id, args, durationMs: performance.now() - start, error }); + throw error; + } + } + + dispose(): void { + this._commands.clear(); + this._onDidRegister.dispose(); + this._onDidExecute.dispose(); + } +} diff --git a/packages/kernel/src/command/command.type.ts b/packages/kernel/src/command/command.type.ts new file mode 100644 index 00000000..e83185d6 --- /dev/null +++ b/packages/kernel/src/command/command.type.ts @@ -0,0 +1,40 @@ +import type { ServiceAccessor } from '../di/container.type'; + +export type CommandHandler = ( + accessor: ServiceAccessor, + ...args: A +) => R | Promise; + +export interface CommandMetadata { + /** Human readable title (already localized, or a localization key resolved by the UI). */ + title?: string; + category?: string; + icon?: string; + /** Precondition: the command can only run when this clause is true. */ + when?: string; + /** + * `false` keeps the command out of the command palette and the keymap editor: it needs + * arguments (menu entries that give them are listed instead). + */ + palette?: boolean; +} + +export interface CommandDescriptor< + A extends unknown[] = unknown[], + R = unknown, +> extends CommandMetadata { + id: string; + handler: CommandHandler; + /** Plugin name or `core`. */ + owner?: string; +} + +export interface CommandExecution { + readonly id: string; + readonly args: readonly unknown[]; + readonly durationMs: number; + readonly error?: unknown; +} + +/** Resolves a lazily activated command (plugin activation on `onCommand:`). */ +export type CommandActivator = (id: string) => Promise; diff --git a/packages/kernel/src/command/index.ts b/packages/kernel/src/command/index.ts new file mode 100644 index 00000000..1a804a34 --- /dev/null +++ b/packages/kernel/src/command/index.ts @@ -0,0 +1,4 @@ +export * from './command.type'; +export * from './command-not-found.exception'; +export * from './command-precondition.exception'; +export * from './command-registry'; diff --git a/packages/kernel/src/context/context-key-service.ts b/packages/kernel/src/context/context-key-service.ts new file mode 100644 index 00000000..3007f9ef --- /dev/null +++ b/packages/kernel/src/context/context-key-service.ts @@ -0,0 +1,104 @@ +import { Emitter, type Event } from '../event/emitter'; +import { type Disposable, toDisposable } from '../lifecycle/disposable'; +import type { Observable } from '../observable/observable'; +import { parseWhen } from './parse-when'; +import type { WhenExpression } from './when.type'; + +export interface ContextChange { + readonly keys: ReadonlySet; +} + +/** + * Hierarchical key/value context used by `when` clauses (focus, active screen, selection, + * installed engine libs…). A scope overlays its parent: its own keys shadow the parent's, and + * parent changes are forwarded for keys the scope does not shadow. + */ +export class ContextKeyService implements Disposable { + private readonly _values = new Map(); + private readonly _onDidChange = new Emitter(); + private readonly _parentSubscription: Disposable | undefined; + + readonly onDidChange: Event = this._onDidChange.event; + + constructor( + readonly name = 'global', + private readonly _parent?: ContextKeyService, + ) { + this._parentSubscription = _parent?.onDidChange(({ keys }) => { + const visible = new Set([...keys].filter((key) => !this._values.has(key))); + if (visible.size) this._onDidChange.fire({ keys: visible }); + }); + } + + createScoped(name: string): ContextKeyService { + return new ContextKeyService(name, this); + } + + get(key: string): T | undefined { + if (this._values.has(key)) return this._values.get(key) as T; + return this._parent?.get(key); + } + + set(key: string, value: unknown): void { + if (this._values.has(key) && Object.is(this._values.get(key), value)) return; + this._values.set(key, value); + this._onDidChange.fire({ keys: new Set([key]) }); + } + + delete(key: string): void { + if (!this._values.delete(key)) return; + this._onDidChange.fire({ keys: new Set([key]) }); + } + + /** Sets a key for the lifetime of the returned disposable, then restores the previous value. */ + bind(key: string, value: unknown): Disposable { + const had = this._values.has(key); + const previous = this._values.get(key); + this.set(key, value); + return toDisposable(() => { + if (had) this.set(key, previous); + else this.delete(key); + }); + } + + evaluate(when: string | WhenExpression | undefined): boolean { + if (when === undefined) return true; + const expression = typeof when === 'string' ? parseWhen(when) : when; + return expression.evaluate((key) => this.get(key)); + } + + /** The value of a when clause, re-evaluated only when one of its keys changes. */ + observe(when: string | undefined): Observable { + if (when === undefined) { + return { + get: () => true, + subscribe: (run) => { + run(true); + return () => undefined; + }, + }; + } + const expression = parseWhen(when); + return { + get: () => this.evaluate(expression), + subscribe: (run) => { + let current = this.evaluate(expression); + run(current); + const subscription = this.onDidChange(({ keys }) => { + if (![...keys].some((key) => expression.keys.has(key))) return; + const next = this.evaluate(expression); + if (next === current) return; + current = next; + run(next); + }); + return () => subscription.dispose(); + }, + }; + } + + dispose(): void { + this._parentSubscription?.dispose(); + this._onDidChange.dispose(); + this._values.clear(); + } +} diff --git a/packages/kernel/src/context/index.ts b/packages/kernel/src/context/index.ts new file mode 100644 index 00000000..b902f88e --- /dev/null +++ b/packages/kernel/src/context/index.ts @@ -0,0 +1,4 @@ +export * from './context-key-service'; +export * from './when.type'; +export * from './when-parse.exception'; +export * from './parse-when'; diff --git a/packages/kernel/src/context/parse-when.ts b/packages/kernel/src/context/parse-when.ts new file mode 100644 index 00000000..c968f756 --- /dev/null +++ b/packages/kernel/src/context/parse-when.ts @@ -0,0 +1,134 @@ +import { WhenParseError } from './when-parse.exception'; +import { type Token, tokenize } from './when-tokenizer'; +import type { ContextReader, WhenExpression } from './when.type'; + +type Node = (read: ContextReader) => boolean; + +const includes = (container: unknown, value: unknown): boolean => { + if (Array.isArray(container)) return container.includes(value); + if (typeof container === 'string') return typeof value === 'string' && container.includes(value); + if (container && typeof container === 'object') { + return typeof value === 'string' && Object.hasOwn(container, value); + } + return false; +}; + +const parse = (source: string): { node: Node; keys: Set } => { + const tokens = tokenize(source); + const keys = new Set(); + let index = 0; + const peek = () => tokens[index]!; + const next = () => tokens[index++]!; + const fail = (token: Token, message: string): never => { + throw new WhenParseError(source, token.pos, message); + }; + const isOp = (token: Token, value: string) => token.kind === 'op' && token.value === value; + + const parseOr = (): Node => { + let left = parseAnd(); + while (isOp(peek(), '||')) { + next(); + const l = left; + const r = parseAnd(); + left = (read) => l(read) || r(read); + } + return left; + }; + + const parseAnd = (): Node => { + let left = parseUnary(); + while (isOp(peek(), '&&')) { + next(); + const l = left; + const r = parseUnary(); + left = (read) => l(read) && r(read); + } + return left; + }; + + const parseUnary = (): Node => { + if (isOp(peek(), '!')) { + next(); + const operand = parseUnary(); + return (read) => !operand(read); + } + return parsePrimary(); + }; + + const parseValue = (): unknown => { + const token = next(); + switch (token.kind) { + case 'string': + case 'number': + case 'bool': + return token.value; + case 'key': + return token.value; + default: + return fail(token, 'expected a value'); + } + }; + + const parsePrimary = (): Node => { + const token = next(); + if (isOp(token, '(')) { + const inner = parseOr(); + if (!isOp(next(), ')')) fail(tokens[index - 1]!, 'expected ")"'); + return inner; + } + if (token.kind === 'bool') { + const value = token.value; + return () => value; + } + if (token.kind !== 'key') return fail(token, 'expected a context key'); + + const key = token.value; + keys.add(key); + const operator = peek(); + if (isOp(operator, '==') || isOp(operator, '!=')) { + next(); + const expected = parseValue(); + const negate = operator.kind === 'op' && operator.value === '!='; + return (read) => { + const actual = read(key); + const equal = actual === expected || String(actual) === String(expected); + return negate ? !equal : equal; + }; + } + if (isOp(operator, '=~')) { + next(); + const regex = next(); + if (regex.kind !== 'regex') return fail(regex, 'expected a regular expression'); + const pattern = regex.value; + return (read) => { + const actual = read(key); + return typeof actual === 'string' && pattern.test(actual); + }; + } + if (isOp(operator, 'in')) { + next(); + const container = next(); + if (container.kind !== 'key') return fail(container, 'expected a context key after "in"'); + keys.add(container.value); + return (read) => includes(read(container.value), read(key)); + } + return (read) => Boolean(read(key)); + }; + + const node = parseOr(); + const end = peek(); + if (end.kind !== 'end') fail(end, 'unexpected token'); + return { node, keys }; +}; + +const cache = new Map(); + +/** Parses (and caches) a when clause. Throws `WhenParseError` on invalid syntax. */ +export const parseWhen = (source: string): WhenExpression => { + const cached = cache.get(source); + if (cached) return cached; + const { node, keys } = parse(source); + const expression: WhenExpression = { source, keys, evaluate: node }; + cache.set(source, expression); + return expression; +}; diff --git a/packages/kernel/src/context/when-parse.exception.ts b/packages/kernel/src/context/when-parse.exception.ts new file mode 100644 index 00000000..55fa9ccf --- /dev/null +++ b/packages/kernel/src/context/when-parse.exception.ts @@ -0,0 +1,10 @@ +export class WhenParseError extends Error { + constructor( + readonly source: string, + readonly position: number, + message: string, + ) { + super(`Invalid when clause "${source}" at ${position}: ${message}`); + this.name = 'WhenParseError'; + } +} diff --git a/packages/kernel/src/context/when-tokenizer.ts b/packages/kernel/src/context/when-tokenizer.ts new file mode 100644 index 00000000..2c824d40 --- /dev/null +++ b/packages/kernel/src/context/when-tokenizer.ts @@ -0,0 +1,84 @@ +import { WhenParseError } from './when-parse.exception'; + +export type Token = + | { kind: 'op'; value: '(' | ')' | '!' | '&&' | '||' | '==' | '!=' | '=~' | 'in'; pos: number } + | { kind: 'key'; value: string; pos: number } + | { kind: 'string'; value: string; pos: number } + | { kind: 'number'; value: number; pos: number } + | { kind: 'bool'; value: boolean; pos: number } + | { kind: 'regex'; value: RegExp; pos: number } + | { kind: 'end'; pos: number }; + +const KEY_START = /[A-Za-z_@$]/; +const KEY_CHAR = /[\w.:@/$-]/; + +export const tokenize = (source: string): Token[] => { + const tokens: Token[] = []; + const fail = (pos: number, message: string): never => { + throw new WhenParseError(source, pos, message); + }; + let i = 0; + while (i < source.length) { + const char = source[i]!; + const two = source.slice(i, i + 2); + if (/\s/.test(char)) { + i++; + } else if (two === '&&' || two === '||' || two === '==' || two === '!=') { + tokens.push({ kind: 'op', value: two, pos: i }); + i += 2; + } else if (two === '=~') { + tokens.push({ kind: 'op', value: '=~', pos: i }); + i += 2; + while (/\s/.test(source[i] ?? '')) i++; + if (source[i] !== '/') fail(i, 'expected a regular expression after =~'); + const start = i++; + let pattern = ''; + while (i < source.length && source[i] !== '/') { + if (source[i] === '\\') pattern += source[i++]; + pattern += source[i++] ?? ''; + } + if (source[i] !== '/') fail(start, 'unterminated regular expression'); + i++; + let flags = ''; + while (/[a-z]/.test(source[i] ?? '')) flags += source[i++]; + flags = flags.replace(/[gy]/g, ''); + try { + tokens.push({ kind: 'regex', value: new RegExp(pattern, flags), pos: start }); + } catch (error) { + fail(start, String(error)); + } + } else if (char === '(' || char === ')' || char === '!') { + tokens.push({ kind: 'op', value: char, pos: i++ }); + } else if (char === "'" || char === '"') { + const start = i++; + let value = ''; + while (i < source.length && source[i] !== char) { + if (source[i] === '\\') i++; + value += source[i++] ?? ''; + } + if (source[i] !== char) fail(start, 'unterminated string'); + i++; + tokens.push({ kind: 'string', value, pos: start }); + } else if (/[\d-]/.test(char)) { + const match = /^-?\d+(\.\d+)?/.exec(source.slice(i)); + if (!match) fail(i, `unexpected "${char}"`); + tokens.push({ kind: 'number', value: Number(match![0]), pos: i }); + i += match![0].length; + } else if (KEY_START.test(char)) { + const start = i; + while (i < source.length && KEY_CHAR.test(source[i]!)) i++; + const word = source.slice(start, i); + if (word === 'true' || word === 'false') { + tokens.push({ kind: 'bool', value: word === 'true', pos: start }); + } else if (word === 'in') { + tokens.push({ kind: 'op', value: 'in', pos: start }); + } else { + tokens.push({ kind: 'key', value: word, pos: start }); + } + } else { + fail(i, `unexpected "${char}"`); + } + } + tokens.push({ kind: 'end', pos: source.length }); + return tokens; +}; diff --git a/packages/kernel/src/context/when.type.ts b/packages/kernel/src/context/when.type.ts new file mode 100644 index 00000000..f8cd84a1 --- /dev/null +++ b/packages/kernel/src/context/when.type.ts @@ -0,0 +1,20 @@ +/** + * `when` clause expressions, e.g. + * `editorFocus && screen == 'scene' && !engineLib:@nanoforge-dev/ecs`, + * `resourceName =~ /\.component\.ts$/`, `selection in selectableKinds`. + * + * Grammar: or := and ('||' and)* ; and := unary ('&&' unary)* ; unary := '!' unary | primary ; + * primary := '(' or ')' | operand (('=='|'!=') value | '=~' regex | 'in' key)? + * A bare word on the right of `==`/`!=` is a string, as in VS Code. + */ +export type ContextValue = + string | number | boolean | null | undefined | readonly unknown[] | object; + +export type ContextReader = (key: string) => unknown; + +export interface WhenExpression { + readonly source: string; + /** Keys read by the expression (to re-evaluate only on relevant changes). */ + readonly keys: ReadonlySet; + evaluate(read: ContextReader): boolean; +} diff --git a/packages/kernel/src/di/container.ts b/packages/kernel/src/di/container.ts new file mode 100644 index 00000000..57481386 --- /dev/null +++ b/packages/kernel/src/di/container.ts @@ -0,0 +1,276 @@ +import { Emitter, type Event } from '../event/emitter'; +import { + type Disposable, + DisposableStore, + isDisposable, + toDisposable, +} from '../lifecycle/disposable'; +import type { Observable } from '../observable/observable'; +import type { + ProvideOptions, + ServiceAccessor, + ServiceDecorator, + ServiceFactory, +} from './container.type'; +import { CyclicDependencyError } from './cyclic-dependency.exception'; +import { ServiceNotFoundError } from './service-not-found.exception'; +import type { ServiceToken } from './service-token'; + +interface Provider { + readonly seq: number; + readonly priority: number; + readonly factory: ServiceFactory | undefined; + value: T | undefined; + created: boolean; +} + +interface Override { + readonly seq: number; + readonly priority: number; + readonly decorate: ServiceDecorator; +} + +let sequence = 0; +/** Tokens being resolved, across containers (resolution is synchronous). */ +const resolving: string[] = []; + +const byPriority = (a: { priority: number; seq: number }, b: { priority: number; seq: number }) => + a.priority - b.priority || a.seq - b.seq; + +/** + * Hierarchical service container. A child container resolves its own providers first and falls + * back to its parent; overrides registered anywhere in the chain decorate the resolved service, + * from the root down to the requesting container. + */ +export class Container implements ServiceAccessor, Disposable { + private readonly _providers = new Map[]>(); + private readonly _overrides = new Map[]>(); + private readonly _cache = new Map(); + private readonly _children = new Set(); + /** Provider and override handles, disposed with the container. */ + private readonly _registrations = new DisposableStore(); + private readonly _onDidChange = new Emitter>(); + private readonly _parentSubscription: Disposable | undefined; + private _disposed = false; + + /** Fires when the resolution of a token may have changed (here or in an ancestor). */ + readonly onDidChange: Event> = this._onDidChange.event; + + constructor( + readonly name = 'root', + private readonly _parent?: Container, + ) { + this._parentSubscription = _parent?.onDidChange((token) => this._changed(token)); + } + + get parent(): Container | undefined { + return this._parent; + } + + createChild(name: string): Container { + this._assertAlive(); + const child = new Container(name, this); + this._children.add(child); + return child; + } + + provide(token: ServiceToken, value: T, options: ProvideOptions = {}): Disposable { + return this._addProvider(token, { + seq: sequence++, + priority: options.priority ?? 0, + factory: undefined, + value, + created: false, + }); + } + + /** Registers a lazy singleton, created on first resolution with this container as accessor. */ + provideFactory( + token: ServiceToken, + factory: ServiceFactory, + options: ProvideOptions = {}, + ): Disposable { + return this._addProvider(token, { + seq: sequence++, + priority: options.priority ?? 0, + factory, + value: undefined, + created: false, + }); + } + + /** Decorates the resolved service; lower priorities wrap first (closest to the base). */ + override( + token: ServiceToken, + decorate: ServiceDecorator, + options: ProvideOptions = {}, + ): Disposable { + this._assertAlive(); + const entry: Override = { + seq: sequence++, + priority: options.priority ?? 0, + decorate: decorate as ServiceDecorator, + }; + const list = this._overrides.get(token.id) ?? []; + list.push(entry); + list.sort(byPriority); + this._overrides.set(token.id, list); + this._changed(token); + return this._track(() => { + const current = this._overrides.get(token.id); + if (!current) return; + current.splice(current.indexOf(entry), 1); + if (!current.length) this._overrides.delete(token.id); + this._changed(token); + }); + } + + has(token: ServiceToken): boolean { + return this._findProvider(token.id) !== undefined; + } + + get(token: ServiceToken): T { + this._assertAlive(); + if (this._cache.has(token.id)) return this._cache.get(token.id) as T; + if (resolving.includes(token.id)) throw new CyclicDependencyError([...resolving, token.id]); + resolving.push(token.id); + try { + const found = this._findProvider(token.id); + if (!found) throw new ServiceNotFoundError(token); + let value = found.owner._instantiate(found.provider) as T; + for (const entry of this._collectOverrides(token.id)) { + value = (entry.decorate as ServiceDecorator)(value, this); + } + this._cache.set(token.id, value); + return value; + } finally { + resolving.pop(); + } + } + + /** + * The service, or nothing when it is not provided or the container was disposed: code that + * runs during teardown (a widget's `dispose` after its plugin stopped) can still ask. + */ + tryGet(token: ServiceToken): T | undefined { + if (this._disposed) return undefined; + return this.has(token) ? this.get(token) : undefined; + } + + /** Every provider's instance for the token, along the chain, lowest priority first. */ + getAll(token: ServiceToken): T[] { + this._assertAlive(); + const entries: { provider: Provider; owner: Container }[] = []; + for (const container of this._lineage()) { + for (const provider of container._providers.get(token.id) ?? []) { + entries.push({ provider, owner: container }); + } + } + return entries + .sort((a, b) => byPriority(a.provider, b.provider)) + .map(({ provider, owner }) => owner._instantiate(provider) as T); + } + + /** The current resolution of a token, updated when providers or overrides change. */ + observe(token: ServiceToken): Observable { + return { + get: () => this.tryGet(token), + subscribe: (run) => { + let current = this.tryGet(token); + run(current); + const subscription = this.onDidChange((changed) => { + if (changed.id !== token.id) return; + const next = this.tryGet(token); + if (next === current) return; + current = next; + run(next); + }); + return () => subscription.dispose(); + }, + }; + } + + dispose(): void { + if (this._disposed) return; + for (const child of [...this._children]) child.dispose(); + this._disposed = true; + this._parent?._children.delete(this); + this._parentSubscription?.dispose(); + this._registrations.dispose(); + this._providers.clear(); + this._overrides.clear(); + this._cache.clear(); + this._onDidChange.dispose(); + } + + private _addProvider(token: ServiceToken, provider: Provider): Disposable { + this._assertAlive(); + const list = this._providers.get(token.id) ?? []; + list.push(provider); + list.sort(byPriority); + this._providers.set(token.id, list); + this._changed(token); + return this._track(() => { + const current = this._providers.get(token.id); + if (!current || !current.includes(provider)) return; + current.splice(current.indexOf(provider), 1); + if (!current.length) this._providers.delete(token.id); + this._disposeInstance(provider); + this._changed(token); + }); + } + + private _findProvider(id: string): { provider: Provider; owner: Container } | undefined { + for (const container of this._lineage()) { + const providers = container._providers.get(id); + if (providers?.length) return { provider: providers.at(-1)!, owner: container }; + } + return undefined; + } + + private _collectOverrides(id: string): Override[] { + return this._lineage() + .reverse() + .flatMap((container) => container._overrides.get(id) ?? []); + } + + private _track(remove: () => void): Disposable { + const handle = toDisposable(() => { + this._registrations.delete(handle); + remove(); + }); + return this._registrations.add(handle); + } + + /** This container followed by its ancestors, up to the root. */ + private _lineage(): Container[] { + const lineage: Container[] = [this]; + for (let parent = this._parent; parent; parent = parent._parent) lineage.push(parent); + return lineage; + } + + private _instantiate(provider: Provider): unknown { + if (!provider.factory || provider.created) return provider.value; + provider.value = provider.factory(this); + provider.created = true; + return provider.value; + } + + private _disposeInstance(provider: Provider): void { + if (provider.factory && provider.created && isDisposable(provider.value)) { + provider.value.dispose(); + } + provider.created = false; + provider.value = undefined; + } + + private _changed(token: ServiceToken): void { + if (this._disposed) return; + this._cache.delete(token.id); + this._onDidChange.fire(token); + } + + private _assertAlive(): void { + if (this._disposed) throw new Error(`Container "${this.name}" is disposed`); + } +} diff --git a/packages/kernel/src/di/container.type.ts b/packages/kernel/src/di/container.type.ts new file mode 100644 index 00000000..ee6836c5 --- /dev/null +++ b/packages/kernel/src/di/container.type.ts @@ -0,0 +1,15 @@ +import type { ServiceToken } from './service-token'; + +export interface ServiceAccessor { + get(token: ServiceToken): T; + tryGet(token: ServiceToken): T | undefined; + getAll(token: ServiceToken): T[]; +} + +export type ServiceFactory = (accessor: ServiceAccessor) => T; +export type ServiceDecorator = (inner: T, accessor: ServiceAccessor) => T; + +export interface ProvideOptions { + /** Highest priority wins; on ties the latest registration wins. Default 0. */ + priority?: number; +} diff --git a/packages/kernel/src/di/cyclic-dependency.exception.ts b/packages/kernel/src/di/cyclic-dependency.exception.ts new file mode 100644 index 00000000..3bbd160c --- /dev/null +++ b/packages/kernel/src/di/cyclic-dependency.exception.ts @@ -0,0 +1,6 @@ +export class CyclicDependencyError extends Error { + constructor(readonly path: string[]) { + super(`Cyclic service dependency: ${path.join(' -> ')}`); + this.name = 'CyclicDependencyError'; + } +} diff --git a/packages/kernel/src/di/index.ts b/packages/kernel/src/di/index.ts new file mode 100644 index 00000000..7e7f1448 --- /dev/null +++ b/packages/kernel/src/di/index.ts @@ -0,0 +1,5 @@ +export * from './service-token'; +export * from './container.type'; +export * from './service-not-found.exception'; +export * from './cyclic-dependency.exception'; +export * from './container'; diff --git a/packages/kernel/src/di/service-not-found.exception.ts b/packages/kernel/src/di/service-not-found.exception.ts new file mode 100644 index 00000000..0552e0c7 --- /dev/null +++ b/packages/kernel/src/di/service-not-found.exception.ts @@ -0,0 +1,8 @@ +import type { ServiceToken } from './service-token'; + +export class ServiceNotFoundError extends Error { + constructor(readonly token: ServiceToken) { + super(`No provider for service "${token.id}"`); + this.name = 'ServiceNotFoundError'; + } +} diff --git a/packages/kernel/src/di/service-token.ts b/packages/kernel/src/di/service-token.ts new file mode 100644 index 00000000..8e699697 --- /dev/null +++ b/packages/kernel/src/di/service-token.ts @@ -0,0 +1,8 @@ +/** Typed key of a service. `T` only exists at the type level. */ +export interface ServiceToken { + readonly id: string; + /** @internal phantom type marker */ + readonly __type?: T; +} + +export const createToken = (id: string): ServiceToken => Object.freeze({ id }); diff --git a/packages/kernel/src/event/emitter.ts b/packages/kernel/src/event/emitter.ts new file mode 100644 index 00000000..fccddf59 --- /dev/null +++ b/packages/kernel/src/event/emitter.ts @@ -0,0 +1,70 @@ +import { type Disposable, type DisposableStore, toDisposable } from '../lifecycle/disposable'; + +export type Listener = (event: T) => void; + +/** Subscribes a listener; the returned disposable unsubscribes it. */ +export type Event = (listener: Listener, disposables?: DisposableStore) => Disposable; + +export type ListenerErrorHandler = (error: unknown) => void; + +let listenerErrorHandler: ListenerErrorHandler = (error) => { + console.error('[nanoforge-editor] Uncaught error in event listener', error); +}; + +/** Sets where errors thrown by listeners are reported (they never break `fire`). */ +export const setListenerErrorHandler = (handler: ListenerErrorHandler): void => { + listenerErrorHandler = handler; +}; + +export interface EmitterOptions { + /** Called when the first listener subscribes (lazy upstream subscription). */ + onFirstListenerAdd?: () => void; + /** Called when the last listener unsubscribes. */ + onLastListenerRemove?: () => void; +} + +export class Emitter implements Disposable { + private _listeners = new Set<{ fn: Listener }>(); + private _disposed = false; + private _event: Event | undefined; + + constructor(private readonly _options: EmitterOptions = {}) {} + + get event(): Event { + this._event ??= (listener, disposables) => { + if (this._disposed) return toDisposable(() => undefined); + const entry = { fn: listener }; + if (this._listeners.size === 0) this._options.onFirstListenerAdd?.(); + this._listeners.add(entry); + const subscription = toDisposable(() => { + if (!this._listeners.delete(entry)) return; + if (this._listeners.size === 0) this._options.onLastListenerRemove?.(); + }); + disposables?.add(subscription); + return subscription; + }; + return this._event; + } + + get hasListeners(): boolean { + return this._listeners.size > 0; + } + + fire(event: T): void { + for (const entry of [...this._listeners]) { + if (!this._listeners.has(entry)) continue; + try { + entry.fn(event); + } catch (error) { + listenerErrorHandler(error); + } + } + } + + dispose(): void { + if (this._disposed) return; + this._disposed = true; + if (this._listeners.size) this._options.onLastListenerRemove?.(); + this._listeners.clear(); + } +} diff --git a/packages/kernel/src/event/event-bus.ts b/packages/kernel/src/event/event-bus.ts new file mode 100644 index 00000000..0306e765 --- /dev/null +++ b/packages/kernel/src/event/event-bus.ts @@ -0,0 +1,37 @@ +import type { Disposable } from '../lifecycle/disposable'; +import { Emitter, type Listener } from './emitter'; + +/** Maps event keys to payload types. */ +export type EventMap = object; + +type EventKey = keyof M & string; + +/** + * Typed publish/subscribe channel for loosely coupled editor parts. The editor-wide map is + * `EditorEvents` of the SDK, which plugins extend by module augmentation. + */ +export class EventBus> implements Disposable { + private readonly _emitters = new Map>(); + + on>(key: K, listener: Listener): Disposable { + return this._emitter(key).event(listener as Listener); + } + + emit>(key: K, payload: M[K]): void { + this._emitters.get(key)?.fire(payload); + } + + dispose(): void { + for (const emitter of this._emitters.values()) emitter.dispose(); + this._emitters.clear(); + } + + private _emitter(key: string): Emitter { + let emitter = this._emitters.get(key); + if (!emitter) { + emitter = new Emitter({ onLastListenerRemove: () => this._emitters.delete(key) }); + this._emitters.set(key, emitter); + } + return emitter; + } +} diff --git a/packages/kernel/src/event/events.ts b/packages/kernel/src/event/events.ts new file mode 100644 index 00000000..7bc7afc0 --- /dev/null +++ b/packages/kernel/src/event/events.ts @@ -0,0 +1,75 @@ +import { type Disposable, DisposableStore, toDisposable } from '../lifecycle/disposable'; +import type { Event } from './emitter'; + +/** Combinators over `Event`. Derived events subscribe upstream lazily. */ +export const Events = { + None: ((): Disposable => toDisposable(() => undefined)) as Event, + + once(event: Event): Event { + return (listener, disposables) => { + let fired = false; + const subscription = event((value) => { + if (fired) return; + fired = true; + subscription.dispose(); + listener(value); + }, disposables); + if (fired) subscription.dispose(); + return subscription; + }; + }, + + map(event: Event, fn: (value: T) => U): Event { + return (listener, disposables) => event((value) => listener(fn(value)), disposables); + }, + + filter(event: Event, predicate: (value: T) => boolean): Event { + return (listener, disposables) => + event((value) => { + if (predicate(value)) listener(value); + }, disposables); + }, + + any(...events: Event[]): Event { + return (listener, disposables) => { + const store = new DisposableStore(); + for (const event of events) event(listener, store); + disposables?.add(store); + return store; + }; + }, + + /** Merges bursts of events: `merge` folds values until `delay` ms pass without a new one. */ + debounce( + event: Event, + merge: (last: R | undefined, value: T) => R, + delay: number, + ): Event { + return (listener, disposables) => { + let pending: R | undefined; + let timer: ReturnType | undefined; + const upstream = event((value) => { + pending = merge(pending, value); + clearTimeout(timer); + timer = setTimeout(() => { + const result = pending as R; + pending = undefined; + timer = undefined; + listener(result); + }, delay); + }); + const subscription = toDisposable(() => { + clearTimeout(timer); + upstream.dispose(); + }); + disposables?.add(subscription); + return subscription; + }; + }, + + toPromise(event: Event): Promise { + return new Promise((resolve) => { + Events.once(event)(resolve); + }); + }, +}; diff --git a/packages/kernel/src/event/index.ts b/packages/kernel/src/event/index.ts new file mode 100644 index 00000000..8bcd45a8 --- /dev/null +++ b/packages/kernel/src/event/index.ts @@ -0,0 +1,3 @@ +export * from './emitter'; +export * from './events'; +export * from './event-bus'; diff --git a/packages/kernel/src/extension/contribution.exception.ts b/packages/kernel/src/extension/contribution.exception.ts new file mode 100644 index 00000000..d29ca990 --- /dev/null +++ b/packages/kernel/src/extension/contribution.exception.ts @@ -0,0 +1,10 @@ +export class ContributionError extends Error { + constructor( + readonly point: string, + readonly owner: string, + cause: unknown, + ) { + super(`Invalid contribution of "${owner}" to "${point}": ${String(cause)}`, { cause }); + this.name = 'ContributionError'; + } +} diff --git a/packages/kernel/src/extension/define-extension-point.ts b/packages/kernel/src/extension/define-extension-point.ts new file mode 100644 index 00000000..d1c7d09f --- /dev/null +++ b/packages/kernel/src/extension/define-extension-point.ts @@ -0,0 +1,8 @@ +import type { ExtensionPoint, ExtensionPointOptions } from './extension-point.type'; + +/** Declares an extension point other plugins can contribute to. */ +export const defineExtensionPoint = ( + id: string, + options: ExtensionPointOptions = {}, +): ExtensionPoint => + Object.freeze({ id, validator: options.validator, multiple: options.multiple ?? true }); diff --git a/packages/kernel/src/extension/extension-point.type.ts b/packages/kernel/src/extension/extension-point.type.ts new file mode 100644 index 00000000..850ee1b6 --- /dev/null +++ b/packages/kernel/src/extension/extension-point.type.ts @@ -0,0 +1,31 @@ +/** Anything with a zod-like `parse`; zod schemas satisfy it. */ +export interface Validator { + parse(value: unknown): T; +} + +export interface ExtensionPoint { + readonly id: string; + readonly validator: Validator | undefined; + /** When false, at most one contribution is kept active: the highest priority one. */ + readonly multiple: boolean; + /** @internal phantom type marker */ + readonly __type?: T; +} + +export interface ExtensionPointOptions { + validator?: Validator; + multiple?: boolean; +} + +export interface Contribution { + readonly value: T; + /** Name of the contributing plugin (or `core`). */ + readonly owner: string; + readonly priority: number; + readonly seq: number; +} + +export interface ContributeOptions { + owner: string; + priority?: number; +} diff --git a/packages/kernel/src/extension/extension-registry.ts b/packages/kernel/src/extension/extension-registry.ts new file mode 100644 index 00000000..a27602bd --- /dev/null +++ b/packages/kernel/src/extension/extension-registry.ts @@ -0,0 +1,86 @@ +import { Emitter, type Event } from '../event/emitter'; +import { type Disposable, toDisposable } from '../lifecycle/disposable'; +import type { Observable } from '../observable/observable'; +import { ContributionError } from './contribution.exception'; +import type { ContributeOptions, Contribution, ExtensionPoint } from './extension-point.type'; + +let sequence = 0; + +/** Contributions are ordered by descending priority, then registration order. */ +const compare = (a: Contribution, b: Contribution) => + b.priority - a.priority || a.seq - b.seq; + +export class ExtensionRegistry implements Disposable { + private readonly _contributions = new Map[]>(); + private readonly _onDidChange = new Emitter>(); + + readonly onDidChange: Event> = this._onDidChange.event; + + contribute(point: ExtensionPoint, value: T, options: ContributeOptions): Disposable { + let parsed = value; + if (point.validator) { + try { + parsed = point.validator.parse(value); + } catch (error) { + throw new ContributionError(point.id, options.owner, error); + } + } + const contribution: Contribution = { + value: parsed, + owner: options.owner, + priority: options.priority ?? 0, + seq: sequence++, + }; + const list = this._contributions.get(point.id) ?? []; + list.push(contribution); + list.sort(compare); + this._contributions.set(point.id, list); + this._onDidChange.fire(point); + + return toDisposable(() => { + const current = this._contributions.get(point.id); + const index = current?.indexOf(contribution) ?? -1; + if (index < 0) return; + current!.splice(index, 1); + this._onDidChange.fire(point); + }); + } + + /** Active contributions, highest priority first. */ + getContributions(point: ExtensionPoint): readonly Contribution[] { + const list = (this._contributions.get(point.id) ?? []) as Contribution[]; + return point.multiple ? [...list] : list.slice(0, 1); + } + + getValues(point: ExtensionPoint): T[] { + return this.getContributions(point).map((contribution) => contribution.value); + } + + observe(point: ExtensionPoint): Observable[]> { + return { + get: () => this.getContributions(point), + subscribe: (run) => { + run(this.getContributions(point)); + const subscription = this.onDidChange((changed) => { + if (changed.id === point.id) run(this.getContributions(point)); + }); + return () => subscription.dispose(); + }, + }; + } + + /** Removes every contribution of an owner (plugin unload safety net). */ + removeOwner(owner: string): void { + for (const [id, list] of this._contributions) { + const kept = list.filter((contribution) => contribution.owner !== owner); + if (kept.length === list.length) continue; + this._contributions.set(id, kept); + this._onDidChange.fire({ id, validator: undefined, multiple: true }); + } + } + + dispose(): void { + this._contributions.clear(); + this._onDidChange.dispose(); + } +} diff --git a/packages/kernel/src/extension/index.ts b/packages/kernel/src/extension/index.ts new file mode 100644 index 00000000..68e54f70 --- /dev/null +++ b/packages/kernel/src/extension/index.ts @@ -0,0 +1,4 @@ +export * from './extension-point.type'; +export * from './define-extension-point'; +export * from './contribution.exception'; +export * from './extension-registry'; diff --git a/packages/kernel/src/i18n/index.ts b/packages/kernel/src/i18n/index.ts new file mode 100644 index 00000000..9a6c8611 --- /dev/null +++ b/packages/kernel/src/i18n/index.ts @@ -0,0 +1,2 @@ +export * from './localization-service'; +export * from './message-format'; diff --git a/packages/kernel/src/i18n/localization-service.ts b/packages/kernel/src/i18n/localization-service.ts new file mode 100644 index 00000000..5ef2ddf1 --- /dev/null +++ b/packages/kernel/src/i18n/localization-service.ts @@ -0,0 +1,79 @@ +import { type Disposable, toDisposable } from '../lifecycle/disposable'; +import type { Observable } from '../observable/observable'; +import { ObservableValue } from '../observable/observable-value'; +import { type MessageParams, formatMessage } from './message-format'; + +export type MessageBundle = Readonly>; + +export const FALLBACK_LOCALE = 'en'; + +/** + * Translations per namespace (a plugin name or `core`) and locale. Lookups fall back from + * `fr-CA` to `fr` to `en`, then to the key itself so missing strings stay visible. + */ +export class LocalizationService { + private readonly _bundles = new Map>(); + private readonly _locale = new ObservableValue(FALLBACK_LOCALE); + private readonly _revision = new ObservableValue(0); + + get locale(): Observable { + return this._locale.readonly(); + } + + /** Bumps whenever the locale or a bundle changes, for UIs to re-render translations. */ + get revision(): Observable { + return this._revision.readonly(); + } + + setLocale(locale: string): void { + if (locale === this._locale.get()) return; + this._locale.set(locale); + this._revision.update((n) => n + 1); + } + + registerBundle(namespace: string, locale: string, messages: MessageBundle): Disposable { + const locales = this._bundles.get(namespace) ?? new Map(); + const bundles = locales.get(locale) ?? []; + bundles.push(messages); + locales.set(locale, bundles); + this._bundles.set(namespace, locales); + this._revision.update((n) => n + 1); + return toDisposable(() => { + bundles.splice(bundles.indexOf(messages), 1); + this._revision.update((n) => n + 1); + }); + } + + /** Available locales across all namespaces. */ + get locales(): string[] { + const set = new Set(); + for (const locales of this._bundles.values()) + for (const locale of locales.keys()) set.add(locale); + return [...set].sort(); + } + + translate(namespace: string, key: string, params?: MessageParams): string { + const locale = this._locale.get(); + const message = this._lookup(namespace, key, locale); + return message === undefined ? key : formatMessage(message, params, locale); + } + + /** A translator bound to a namespace, e.g. `const t = i18n.scope('@nanoforge/ecs')`. */ + scope(namespace: string): (key: string, params?: MessageParams) => string { + return (key, params) => this.translate(namespace, key, params); + } + + private _lookup(namespace: string, key: string, locale: string): string | undefined { + const locales = this._bundles.get(namespace); + if (!locales) return undefined; + const candidates = [locale, locale.split('-')[0]!, FALLBACK_LOCALE]; + for (const candidate of candidates) { + const bundles = locales.get(candidate) ?? []; + for (let i = bundles.length - 1; i >= 0; i--) { + const message = bundles[i]![key]; + if (message !== undefined) return message; + } + } + return undefined; + } +} diff --git a/packages/kernel/src/i18n/message-format.ts b/packages/kernel/src/i18n/message-format.ts new file mode 100644 index 00000000..0b69f10d --- /dev/null +++ b/packages/kernel/src/i18n/message-format.ts @@ -0,0 +1,87 @@ +export type MessageParams = Readonly>; + +/** + * Minimal ICU message formatting: `{name}`, `{count, plural, one {# item} other {# items}}` + * (with `=N` exact matches) and `{kind, select, file {…} other {…}}`. `'{'` escapes a brace. + */ +export const formatMessage = (message: string, params: MessageParams = {}, locale = 'en'): string => + format(message, params, locale, undefined); + +const format = ( + message: string, + params: MessageParams, + locale: string, + pluralValue: number | undefined, +): string => { + let result = ''; + let i = 0; + while (i < message.length) { + const char = message[i]!; + if (char === "'" && message[i + 1] === '{') { + result += '{'; + i += 2; + } else if (char === '#' && pluralValue !== undefined) { + result += new Intl.NumberFormat(locale).format(pluralValue); + i++; + } else if (char === '{') { + const end = matchingBrace(message, i); + result += formatArgument(message.slice(i + 1, end), params, locale); + i = end + 1; + } else { + result += char; + i++; + } + } + return result; +}; + +const matchingBrace = (text: string, open: number): number => { + let depth = 0; + for (let i = open; i < text.length; i++) { + if (text[i] === '{') depth++; + else if (text[i] === '}' && --depth === 0) return i; + } + throw new Error(`Unbalanced braces in message "${text}"`); +}; + +const formatArgument = (argument: string, params: MessageParams, locale: string): string => { + const [name = '', type, ...rest] = argument.split(','); + const key = name.trim(); + const value = params[key]; + if (type === undefined) return value === undefined ? `{${key}}` : String(value); + + const options = parseOptions(rest.join(',')); + const kind = type.trim(); + if (kind === 'plural') { + const count = Number(value ?? 0); + const branch = + options.get(`=${count}`) ?? + options.get(new Intl.PluralRules(locale).select(count)) ?? + options.get('other') ?? + ''; + return format(branch, params, locale, count); + } + if (kind === 'select') { + const branch = options.get(String(value)) ?? options.get('other') ?? ''; + return format(branch, params, locale, undefined); + } + return value === undefined ? `{${key}}` : String(value); +}; + +/** `one {…} other {…}` → Map { one → …, other → … } */ +const parseOptions = (text: string): Map => { + const options = new Map(); + let i = 0; + while (i < text.length) { + while (/\s/.test(text[i] ?? '')) i++; + const start = i; + while (i < text.length && text[i] !== '{' && !/\s/.test(text[i]!)) i++; + const selector = text.slice(start, i); + while (/\s/.test(text[i] ?? '')) i++; + if (!selector || text[i] !== '{') break; + const end = matchingBrace(text, i); + options.set(selector, text.slice(i + 1, end)); + i = end + 1; + } + return options; +}; diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts new file mode 100644 index 00000000..b92bc9e7 --- /dev/null +++ b/packages/kernel/src/index.ts @@ -0,0 +1,10 @@ +export * from './command'; +export * from './context'; +export * from './di'; +export * from './event'; +export * from './extension'; +export * from './i18n'; +export * from './lifecycle'; +export * from './log'; +export * from './observable'; +export * from './plugin'; diff --git a/packages/kernel/src/lifecycle/disposable-object.abstract.ts b/packages/kernel/src/lifecycle/disposable-object.abstract.ts new file mode 100644 index 00000000..3af7c84a --- /dev/null +++ b/packages/kernel/src/lifecycle/disposable-object.abstract.ts @@ -0,0 +1,18 @@ +import { type Disposable, DisposableStore } from './disposable'; + +/** Base class for services owning disposables through `this._register`. */ +export abstract class DisposableObject implements Disposable { + protected readonly _store = new DisposableStore(); + + get isDisposed(): boolean { + return this._store.isDisposed; + } + + dispose(): void { + this._store.dispose(); + } + + protected _register(disposable: T): T { + return this._store.add(disposable); + } +} diff --git a/packages/kernel/src/lifecycle/disposable.ts b/packages/kernel/src/lifecycle/disposable.ts new file mode 100644 index 00000000..c6e2c4e8 --- /dev/null +++ b/packages/kernel/src/lifecycle/disposable.ts @@ -0,0 +1,114 @@ +export interface Disposable { + dispose(): void; +} + +export interface DisposableTracker { + onCreate(disposable: Disposable): void; + onDispose(disposable: Disposable): void; +} + +let tracker: DisposableTracker | null = null; + +/** Installs a tracker notified of every kernel disposable (leak detection in tests). */ +export const setDisposableTracker = (value: DisposableTracker | null): void => { + tracker = value; +}; + +export const isDisposable = (value: unknown): value is Disposable => + typeof value === 'object' && + value !== null && + typeof (value as Partial).dispose === 'function'; + +class FunctionDisposable implements Disposable { + private _fn: (() => void) | null; + + constructor(fn: () => void) { + this._fn = fn; + tracker?.onCreate(this); + } + + dispose(): void { + const fn = this._fn; + if (!fn) return; + this._fn = null; + tracker?.onDispose(this); + fn(); + } + + [Symbol.dispose](): void { + this.dispose(); + } +} + +/** Wraps a cleanup function; calling `dispose` more than once runs it only once. */ +export const toDisposable = (fn: () => void): Disposable => new FunctionDisposable(fn); + +/** Disposes every given disposable, collecting errors into an `AggregateError`. */ +export const disposeAll = (disposables: Iterable): void => { + const errors: unknown[] = []; + for (const disposable of disposables) { + try { + disposable.dispose(); + } catch (error) { + errors.push(error); + } + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, 'Errors while disposing'); +}; + +export const combineDisposables = (...disposables: Disposable[]): Disposable => + toDisposable(() => disposeAll(disposables)); + +/** + * A collection of disposables disposed together (LIFO). Adding to a disposed store disposes + * the new value immediately, so late registrations never leak. + */ +export class DisposableStore implements Disposable { + private readonly _items = new Set(); + private _disposed = false; + + constructor() { + tracker?.onCreate(this); + } + + get isDisposed(): boolean { + return this._disposed; + } + + get size(): number { + return this._items.size; + } + + add(disposable: T): T { + if (this._disposed) { + disposable.dispose(); + return disposable; + } + this._items.add(disposable); + return disposable; + } + + /** Removes without disposing. */ + delete(disposable: Disposable): void { + this._items.delete(disposable); + } + + /** Disposes the current items but keeps the store usable. */ + clear(): void { + const items = [...this._items].reverse(); + this._items.clear(); + disposeAll(items); + } + + dispose(): void { + if (this._disposed) return; + this._disposed = true; + tracker?.onDispose(this); + this.clear(); + } + + [Symbol.dispose](): void { + this.dispose(); + } +} diff --git a/packages/kernel/src/lifecycle/index.ts b/packages/kernel/src/lifecycle/index.ts new file mode 100644 index 00000000..7adfe283 --- /dev/null +++ b/packages/kernel/src/lifecycle/index.ts @@ -0,0 +1,3 @@ +export * from './disposable'; +export * from './mutable-disposable'; +export * from './disposable-object.abstract'; diff --git a/packages/kernel/src/lifecycle/mutable-disposable.ts b/packages/kernel/src/lifecycle/mutable-disposable.ts new file mode 100644 index 00000000..9317cb57 --- /dev/null +++ b/packages/kernel/src/lifecycle/mutable-disposable.ts @@ -0,0 +1,32 @@ +import type { Disposable } from './disposable'; + +/** Holds at most one disposable; replacing or clearing it disposes the previous one. */ +export class MutableDisposable implements Disposable { + private _value: T | undefined; + private _disposed = false; + + get value(): T | undefined { + return this._value; + } + + set value(value: T | undefined) { + if (this._disposed) { + value?.dispose(); + return; + } + if (value === this._value) return; + const previous = this._value; + this._value = value; + previous?.dispose(); + } + + clear(): void { + this.value = undefined; + } + + dispose(): void { + if (this._disposed) return; + this.clear(); + this._disposed = true; + } +} diff --git a/packages/kernel/src/log/format-log-value.ts b/packages/kernel/src/log/format-log-value.ts new file mode 100644 index 00000000..a2ce0998 --- /dev/null +++ b/packages/kernel/src/log/format-log-value.ts @@ -0,0 +1,55 @@ +import type { LogValue } from './log-value.type'; + +const isPlain = (value: LogValue): value is string | number | boolean | null => + value === null || typeof value !== 'object'; + +/** + * One-line text of a logged value, as a console prints it: top-level strings are bare, nested + * ones quoted. + */ +export const formatLogValue = (value: LogValue, nested = false): string => { + if (isPlain(value)) + return typeof value === 'string' && nested ? JSON.stringify(value) : `${value}`; + switch (value.type) { + case 'undefined': + return 'undefined'; + case 'number': + case 'bigint': + case 'symbol': + case 'function': + case 'date': + case 'cut': + return value.text; + case 'circular': + return '[Circular]'; + case 'error': + return nested || !value.stack ? `${value.name}: ${value.message}` : value.stack; + case 'array': { + const items = value.items.map((item) => formatLogValue(item, true)); + if (value.more) items.push(`… ${value.more} more`); + const prefix = value.name ? `${value.name}(${value.length}) ` : ''; + return `${prefix}[${items.join(', ')}]`; + } + case 'map': { + const entries = value.entries.map( + ([key, item]) => `${formatLogValue(key, true)} => ${formatLogValue(item, true)}`, + ); + if (value.more) entries.push(`… ${value.more} more`); + return `Map(${value.size}) {${entries.join(', ')}}`; + } + case 'object': { + const entries = value.entries.map(([key, item]) => `${key}: ${formatLogValue(item, true)}`); + if (value.more) entries.push(`… ${value.more} more`); + const body = entries.length ? `{ ${entries.join(', ')} }` : '{}'; + return value.name ? `${value.name} ${body}` : body; + } + } +}; + +/** Whether a value has children to expand. */ +export const isExpandableLogValue = (value: LogValue): boolean => + !isPlain(value) && + ((value.type === 'object' && value.entries.length > 0) || + (value.type === 'array' && value.items.length > 0) || + (value.type === 'map' && value.entries.length > 0) || + (value.type === 'error' && !!value.stack)); diff --git a/packages/kernel/src/log/index.ts b/packages/kernel/src/log/index.ts new file mode 100644 index 00000000..5a1da216 --- /dev/null +++ b/packages/kernel/src/log/index.ts @@ -0,0 +1,6 @@ +export * from './log-level.enum'; +export * from './logger.type'; +export * from './logger-service'; +export * from './log-value.type'; +export * from './serialize-log-value'; +export * from './format-log-value'; diff --git a/packages/kernel/src/log/log-level.enum.ts b/packages/kernel/src/log/log-level.enum.ts new file mode 100644 index 00000000..f8011340 --- /dev/null +++ b/packages/kernel/src/log/log-level.enum.ts @@ -0,0 +1,7 @@ +export enum LogLevel { + Trace = 0, + Debug = 1, + Info = 2, + Warn = 3, + Error = 4, +} diff --git a/packages/kernel/src/log/log-value.type.ts b/packages/kernel/src/log/log-value.type.ts new file mode 100644 index 00000000..7b9802c4 --- /dev/null +++ b/packages/kernel/src/log/log-value.type.ts @@ -0,0 +1,56 @@ +/** + * A logged value as a JSON tree: a snapshot that can cross a bridge (game → editor) and be + * shown expanded. The engine's editor bridge writes the same format. + */ +export type LogValue = + | string + | number + | boolean + | null + | { readonly type: 'undefined' } + /** Numbers JSON cannot carry: `NaN`, `Infinity`, `-Infinity`, `-0`. */ + | { readonly type: 'number'; readonly text: string } + | { readonly type: 'bigint' | 'symbol' | 'function' | 'date'; readonly text: string } + | { + readonly type: 'error'; + readonly name: string; + readonly message: string; + readonly stack?: string; + } + | { + readonly type: 'object'; + /** Constructor name, for instances of classes. */ + readonly name?: string; + readonly entries: readonly (readonly [string, LogValue])[]; + /** Entries left out. */ + readonly more?: number; + } + | { + readonly type: 'array'; + /** `Set`, `Float32Array`… for array-likes that are not arrays. */ + readonly name?: string; + readonly length: number; + readonly items: readonly LogValue[]; + readonly more?: number; + } + | { + readonly type: 'map'; + readonly size: number; + readonly entries: readonly (readonly [LogValue, LogValue])[]; + readonly more?: number; + } + /** A value already shown higher in the tree. */ + | { readonly type: 'circular' } + /** A value deeper than the limit: only its summary is kept. */ + | { readonly type: 'cut'; readonly text: string }; + +export interface LogValueLimits { + /** Nesting kept below the logged value (default 4). */ + readonly depth?: number; + /** Entries kept per object, array or map (default 50). */ + readonly entries?: number; + /** Values kept in the whole tree (default 500). */ + readonly nodes?: number; + /** Characters kept per string (default 10 000). */ + readonly text?: number; +} diff --git a/packages/kernel/src/log/logger-service.ts b/packages/kernel/src/log/logger-service.ts new file mode 100644 index 00000000..a75e0636 --- /dev/null +++ b/packages/kernel/src/log/logger-service.ts @@ -0,0 +1,57 @@ +import { type Disposable, toDisposable } from '../lifecycle/disposable'; +import { LogLevel } from './log-level.enum'; +import type { LogEntry, LogSink, Logger } from './logger.type'; + +export const consoleSink: LogSink = (entry) => { + const method = (['debug', 'debug', 'info', 'warn', 'error'] as const)[entry.level]; + console[method](`[${entry.source}] ${entry.message}`, ...entry.args); +}; + +/** + * Central logging: named loggers write entries to sinks (browser console, the Console plugin…). + * The last `bufferSize` entries are kept so a sink added late can replay history. + */ +export class LoggerService { + private readonly _sinks = new Set(); + private readonly _buffer: LogEntry[] = []; + + level: LogLevel = LogLevel.Info; + + constructor(private readonly _bufferSize = 500) {} + + addSink(sink: LogSink, options: { replay?: boolean } = {}): Disposable { + if (options.replay) for (const entry of this._buffer) sink(entry); + this._sinks.add(sink); + return toDisposable(() => this._sinks.delete(sink)); + } + + get history(): readonly LogEntry[] { + return this._buffer; + } + + getLogger(name: string): Logger { + const log = (level: LogLevel, message: string, args: unknown[]) => + this.write({ level, source: name, message, args, time: Date.now() }); + return { + name, + trace: (message, ...args) => log(LogLevel.Trace, message, args), + debug: (message, ...args) => log(LogLevel.Debug, message, args), + info: (message, ...args) => log(LogLevel.Info, message, args), + warn: (message, ...args) => log(LogLevel.Warn, message, args), + error: (message, ...args) => log(LogLevel.Error, message, args), + child: (child) => this.getLogger(`${name}.${child}`), + }; + } + + /** Writes a full entry: for output that comes with its own values or location. */ + write(entry: LogEntry): void { + if (entry.level < this.level) return; + this._buffer.push(entry); + if (this._buffer.length > this._bufferSize) this._buffer.shift(); + for (const sink of this._sinks) { + try { + sink(entry); + } catch {} + } + } +} diff --git a/packages/kernel/src/log/logger.type.ts b/packages/kernel/src/log/logger.type.ts new file mode 100644 index 00000000..a86dda5f --- /dev/null +++ b/packages/kernel/src/log/logger.type.ts @@ -0,0 +1,33 @@ +import { type LogLevel } from './log-level.enum'; +import type { LogValue } from './log-value.type'; + +export interface LogEntry { + readonly level: LogLevel; + /** Logger name, e.g. `kernel.plugins` or a plugin name. */ + readonly source: string; + readonly message: string; + readonly args: readonly unknown[]; + readonly time: number; + /** The message's parts already serialized (output of a game): shown instead of `message`. */ + readonly values?: readonly LogValue[]; + /** Where it was logged: a file (or the URL of a built bundle) and a 1-based position. */ + readonly location?: LogLocation; +} + +export interface LogLocation { + readonly file: string; + readonly line: number; + readonly column?: number; +} + +export type LogSink = (entry: LogEntry) => void; + +export interface Logger { + readonly name: string; + trace(message: string, ...args: unknown[]): void; + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; + child(name: string): Logger; +} diff --git a/packages/kernel/src/log/serialize-log-value.ts b/packages/kernel/src/log/serialize-log-value.ts new file mode 100644 index 00000000..837e73a9 --- /dev/null +++ b/packages/kernel/src/log/serialize-log-value.ts @@ -0,0 +1,125 @@ +import type { LogValue, LogValueLimits } from './log-value.type'; + +const constructorName = (value: object): string | undefined => { + try { + const name = (Object.getPrototypeOf(value) as { constructor?: { name?: unknown } } | null) + ?.constructor?.name; + return typeof name === 'string' && name && name !== 'Object' ? name : undefined; + } catch { + return undefined; + } +}; + +const summary = (value: object): string => { + if (Array.isArray(value)) return `Array(${value.length})`; + if (value instanceof Map) return `Map(${value.size})`; + if (value instanceof Set) return `Set(${value.size})`; + return constructorName(value) ?? 'Object'; +}; + +/** Snapshot of a value for the console, within limits (never throws). */ +export const serializeLogValue = (input: unknown, limits: LogValueLimits = {}): LogValue => { + const maxDepth = limits.depth ?? 4; + const maxEntries = limits.entries ?? 50; + const maxText = limits.text ?? 10_000; + let budget = limits.nodes ?? 500; + const ancestors = new Set(); + + const text = (value: string) => (value.length > maxText ? `${value.slice(0, maxText)}…` : value); + + const visit = (value: unknown, depth: number): LogValue => { + budget--; + switch (typeof value) { + case 'string': + return text(value); + case 'boolean': + return value; + case 'number': + return Number.isFinite(value) && !Object.is(value, -0) + ? value + : { type: 'number', text: Object.is(value, -0) ? '-0' : String(value) }; + case 'undefined': + return { type: 'undefined' }; + case 'bigint': + return { type: 'bigint', text: `${value}n` }; + case 'symbol': + return { type: 'symbol', text: String(value) }; + case 'function': + return { type: 'function', text: value.name ? `ƒ ${value.name}()` : 'ƒ ()' }; + } + if (value === null) return null; + const object = value as object; + if (ancestors.has(object)) return { type: 'circular' }; + try { + if (object instanceof Error) { + return { + type: 'error', + name: object.name, + message: text(object.message), + ...(object.stack && { stack: text(object.stack) }), + }; + } + if (object instanceof Date) { + return { + type: 'date', + text: Number.isNaN(object.getTime()) ? 'Invalid Date' : object.toISOString(), + }; + } + if (depth >= maxDepth || budget <= 0) return { type: 'cut', text: summary(object) }; + ancestors.add(object); + try { + if (object instanceof Map) { + const entries: [LogValue, LogValue][] = []; + for (const [key, item] of object) { + if (entries.length >= maxEntries || budget <= 0) break; + entries.push([visit(key, depth + 1), visit(item, depth + 1)]); + } + const more = object.size - entries.length; + return { type: 'map', size: object.size, entries, ...(more > 0 && { more }) }; + } + const list = Array.isArray(object) + ? (object as unknown[]) + : object instanceof Set + ? [...(object as Set)] + : ArrayBuffer.isView(object) && !(object instanceof DataView) + ? (object as unknown as ArrayLike) + : undefined; + if (list) { + const items: LogValue[] = []; + for (let index = 0; index < list.length; index++) { + if (items.length >= maxEntries || budget <= 0) break; + items.push(visit(list[index], depth + 1)); + } + const more = list.length - items.length; + return { + type: 'array', + ...(!Array.isArray(object) && { name: constructorName(object) ?? 'Array' }), + length: list.length, + items, + ...(more > 0 && { more }), + }; + } + const keys = Object.keys(object); + const entries: [string, LogValue][] = []; + for (const key of keys) { + if (entries.length >= maxEntries || budget <= 0) break; + let item: unknown; + try { + item = (object as Record)[key]; + } catch (error) { + item = `[threw ${String(error)}]`; + } + entries.push([key, visit(item, depth + 1)]); + } + const name = constructorName(object); + const more = keys.length - entries.length; + return { type: 'object', ...(name && { name }), entries, ...(more > 0 && { more }) }; + } finally { + ancestors.delete(object); + } + } catch { + return { type: 'cut', text: 'Object' }; + } + }; + return visit(input, 0); +}; diff --git a/packages/kernel/src/observable/index.ts b/packages/kernel/src/observable/index.ts new file mode 100644 index 00000000..d2d9731f --- /dev/null +++ b/packages/kernel/src/observable/index.ts @@ -0,0 +1,2 @@ +export * from './observable'; +export * from './observable-value'; diff --git a/packages/kernel/src/observable/observable-value.ts b/packages/kernel/src/observable/observable-value.ts new file mode 100644 index 00000000..eede0a20 --- /dev/null +++ b/packages/kernel/src/observable/observable-value.ts @@ -0,0 +1,44 @@ +import type { Listener } from '../event/emitter'; +import type { Equality, Observable } from './observable'; + +/** A writable observable value. */ +export class ObservableValue implements Observable { + private readonly _subscribers = new Set<{ run: Listener }>(); + + constructor( + private _value: T, + private readonly _equals: Equality = Object.is, + ) {} + + get(): T { + return this._value; + } + + set(value: T): void { + if (this._equals(this._value, value)) return; + this._value = value; + for (const subscriber of [...this._subscribers]) { + if (this._subscribers.has(subscriber)) subscriber.run(value); + } + } + + update(fn: (value: T) => T): void { + this.set(fn(this._value)); + } + + subscribe(run: Listener): () => void { + const subscriber = { run }; + this._subscribers.add(subscriber); + run(this._value); + return () => this._subscribers.delete(subscriber); + } + + get subscriberCount(): number { + return this._subscribers.size; + } + + /** A read-only view, to expose without handing out `set`. */ + readonly(): Observable { + return { get: () => this.get(), subscribe: (run) => this.subscribe(run) }; + } +} diff --git a/packages/kernel/src/observable/observable.ts b/packages/kernel/src/observable/observable.ts new file mode 100644 index 00000000..f0fb1ead --- /dev/null +++ b/packages/kernel/src/observable/observable.ts @@ -0,0 +1,121 @@ +import type { Event, Listener } from '../event/emitter'; +import { type Disposable, toDisposable } from '../lifecycle/disposable'; +import { ObservableValue } from './observable-value'; + +/** + * A readable reactive value. `subscribe` follows the Svelte store contract (called immediately, + * returns an unsubscriber), so `$observable` works in plugin templates without any adapter. + */ +export interface Observable { + get(): T; + subscribe(run: Listener): () => void; +} + +export type ObservableValues[]> = { + [K in keyof S]: S[K] extends Observable ? V : never; +}; + +/** Subscribes as a `Disposable`, optionally skipping the immediate call. */ +export const observe = ( + observable: Observable, + listener: Listener, + options: { immediate?: boolean } = {}, +): Disposable => { + let skip = options.immediate === false; + const unsubscribe = observable.subscribe((value) => { + if (skip) return; + listener(value); + }); + skip = false; + return toDisposable(unsubscribe); +}; + +/** Change event of an observable (no immediate call). */ +export const changes = + (observable: Observable): Event => + (listener, disposables) => { + const subscription = observe(observable, listener, { immediate: false }); + disposables?.add(subscription); + return subscription; + }; + +export type Equality = (a: T, b: T) => boolean; + +/** An observable that never changes. */ +export const constant = (value: T): Observable => ({ + get: () => value, + subscribe: (run) => { + run(value); + return () => undefined; + }, +}); + +/** + * Computes a value from other observables. Upstream subscriptions are held only while the + * derived observable itself has subscribers; otherwise `get` computes on demand. + */ +export const derived = [], T>( + sources: S, + compute: (...values: ObservableValues) => T, + equals: Equality = Object.is, +): Observable => { + const read = () => compute(...(sources.map((source) => source.get()) as ObservableValues)); + let cache: ObservableValue | undefined; + let upstream: (() => void)[] = []; + let listeners = 0; + + const start = () => { + cache = new ObservableValue(read(), equals); + let ready = false; + upstream = sources.map((source) => + source.subscribe(() => { + if (ready) cache!.set(read()); + }), + ); + ready = true; + }; + const stop = () => { + for (const unsubscribe of upstream) unsubscribe(); + upstream = []; + cache = undefined; + }; + + return { + get: () => (cache ? cache.get() : read()), + subscribe(run) { + if (listeners++ === 0) start(); + const unsubscribe = cache!.subscribe(run); + let active = true; + return () => { + if (!active) return; + active = false; + unsubscribe(); + if (--listeners === 0) stop(); + }; + }, + }; +}; + +/** + * Follows the observable currently held by `outer` (e.g. "apps of the current project"): + * re-subscribes when `outer` changes and yields `fallback` while it holds nothing. + */ +export const switchObservable = ( + outer: Observable | undefined>, + fallback: T, +): Observable => ({ + get: () => outer.get()?.get() ?? fallback, + subscribe(run) { + let inner: (() => void) | undefined; + const unsubscribeOuter = outer.subscribe((current) => { + inner?.(); + inner = undefined; + if (current) inner = current.subscribe(run); + else run(fallback); + }); + return () => { + inner?.(); + unsubscribeOuter(); + }; + }, +}); diff --git a/packages/kernel/src/plugin/command-metadata.ts b/packages/kernel/src/plugin/command-metadata.ts new file mode 100644 index 00000000..02c36927 --- /dev/null +++ b/packages/kernel/src/plugin/command-metadata.ts @@ -0,0 +1,7 @@ +import { defineExtensionPoint } from '../extension/define-extension-point'; +import type { ExtensionPoint } from '../extension/extension-point.type'; +import type { CommandMetadataContribution } from './plugin-host.type'; + +/** Commands declared by manifests (activated or not), for palettes and menus. */ +export const COMMAND_METADATA: ExtensionPoint = + defineExtensionPoint('core.commandMetadata'); diff --git a/packages/kernel/src/plugin/core-services.const.ts b/packages/kernel/src/plugin/core-services.const.ts new file mode 100644 index 00000000..51516042 --- /dev/null +++ b/packages/kernel/src/plugin/core-services.const.ts @@ -0,0 +1,20 @@ +import type { CommandRegistry } from '../command/command-registry'; +import type { ContextKeyService } from '../context/context-key-service'; +import { createToken } from '../di/service-token'; +import type { EventBus } from '../event/event-bus'; +import type { ExtensionRegistry } from '../extension/extension-registry'; +import type { LocalizationService } from '../i18n/localization-service'; +import type { LoggerService } from '../log/logger-service'; +import type { EngineLibProvider } from './engine-libs'; + +/** Well-known services every editor container provides. */ +export const CoreServices = { + Commands: createToken('core.commands'), + ContextKeys: createToken('core.contextKeys'), + /** Typed as `EventBus` by the SDK. */ + EventBus: createToken('core.eventBus'), + Extensions: createToken('core.extensions'), + Localization: createToken('core.localization'), + Logger: createToken('core.logger'), + EngineLibs: createToken('core.engineLibs'), +} as const; diff --git a/packages/kernel/src/plugin/create-core-container.ts b/packages/kernel/src/plugin/create-core-container.ts new file mode 100644 index 00000000..602e6d60 --- /dev/null +++ b/packages/kernel/src/plugin/create-core-container.ts @@ -0,0 +1,24 @@ +import { CommandRegistry } from '../command/command-registry'; +import { ContextKeyService } from '../context/context-key-service'; +import { Container } from '../di/container'; +import { EventBus } from '../event/event-bus'; +import { ExtensionRegistry } from '../extension/extension-registry'; +import { LocalizationService } from '../i18n/localization-service'; +import { LoggerService } from '../log/logger-service'; +import { CoreServices } from './core-services.const'; + +/** Creates the editor root container with every core kernel service registered. */ +export const createCoreContainer = (options: { logger?: LoggerService } = {}): Container => { + const container = new Container('editor'); + container.provideFactory(CoreServices.ContextKeys, () => new ContextKeyService()); + container.provideFactory(CoreServices.Extensions, () => new ExtensionRegistry()); + container.provideFactory(CoreServices.EventBus, () => new EventBus()); + container.provideFactory(CoreServices.Localization, () => new LocalizationService()); + if (options.logger) container.provide(CoreServices.Logger, options.logger); + else container.provideFactory(CoreServices.Logger, () => new LoggerService()); + container.provideFactory( + CoreServices.Commands, + (services) => new CommandRegistry(services, services.get(CoreServices.ContextKeys)), + ); + return container; +}; diff --git a/packages/kernel/src/plugin/create-plugin-context.ts b/packages/kernel/src/plugin/create-plugin-context.ts new file mode 100644 index 00000000..7c77485f --- /dev/null +++ b/packages/kernel/src/plugin/create-plugin-context.ts @@ -0,0 +1,54 @@ +import type { Container } from '../di/container'; +import type { Disposable, DisposableStore } from '../lifecycle/disposable'; +import { type Observable, constant } from '../observable/observable'; +import { CoreServices } from './core-services.const'; +import { type AppInfo, eligibleApps } from './engine-libs'; +import type { PluginContext } from './plugin-context.type'; +import type { PluginDescriptor } from './plugin-resolution.type'; + +/** + * The context handed to a plugin's `activate`: a child scope of `services`, and registrations + * owned by the plugin, undone when `subscriptions` is disposed. + */ +export const createPluginContext = ( + services: Container, + descriptor: PluginDescriptor, + subscriptions: DisposableStore, +): PluginContext => { + const { manifest } = descriptor; + const scope = subscriptions.add(services.createChild(`plugin:${manifest.name}`)); + const extensions = services.get(CoreServices.Extensions); + const commands = services.get(CoreServices.Commands); + const i18n = services.get(CoreServices.Localization); + const own = (disposable: T) => subscriptions.add(disposable); + const engineLibs = services.tryGet(CoreServices.EngineLibs); + const apps: Observable = engineLibs + ? eligibleApps(manifest, engineLibs.apps) + : constant([]); + + return { + name: manifest.name, + manifest, + source: descriptor.source, + subscriptions, + services: scope, + scope, + provide: (token, value, options) => own(services.provide(token, value, options)), + provideFactory: (token, factory, options) => + own(services.provideFactory(token, factory, options)), + override: (token, decorate, options) => own(services.override(token, decorate, options)), + contribute: (point, value, options) => + own(extensions.contribute(point, value, { owner: manifest.name, ...options })), + contributions: (point) => extensions.observe(point), + registerCommand: (id, handler, metadata) => + own(commands.register({ ...metadata, id, handler, owner: manifest.name })), + executeCommand: (id, ...args) => commands.execute(id, ...args), + contextKeys: services.get(CoreServices.ContextKeys), + logger: services.get(CoreServices.Logger).getLogger(manifest.name), + t: i18n.scope(manifest.name), + registerMessages: (locale, messages) => + own(i18n.registerBundle(manifest.name, locale, messages)), + eligibleApps: apps, + resolveAsset: (path) => new URL(path, descriptor.baseUrl).href, + }; +}; diff --git a/packages/kernel/src/plugin/describe-status.ts b/packages/kernel/src/plugin/describe-status.ts new file mode 100644 index 00000000..f6991e34 --- /dev/null +++ b/packages/kernel/src/plugin/describe-status.ts @@ -0,0 +1,24 @@ +import type { PluginStatus } from './plugin-resolution.type'; + +export const describeStatus = (status: PluginStatus): string => { + switch (status.kind) { + case 'ok': + return 'ok'; + case 'disabled': + return 'disabled'; + case 'shadowed': + return `shadowed by the ${status.by} version`; + case 'incompatible-editor': + return `requires editor ${status.required} (running ${status.actual})`; + case 'incompatible-runtime': + return `compiled with ${status.module} ${status.compiled}, editor provides ${status.actual}`; + case 'missing-dependency': + return `missing dependency ${status.dependency}@${status.range}`; + case 'dependency-version': + return `dependency ${status.dependency}@${status.actual} does not match ${status.range}`; + case 'dependency-failed': + return `dependency ${status.dependency} is not available`; + case 'cycle': + return `dependency cycle: ${status.cycle.join(' -> ')}`; + } +}; diff --git a/packages/kernel/src/plugin/engine-libs.ts b/packages/kernel/src/plugin/engine-libs.ts new file mode 100644 index 00000000..e2326aa5 --- /dev/null +++ b/packages/kernel/src/plugin/engine-libs.ts @@ -0,0 +1,90 @@ +import semver from 'semver'; + +import type { ContextKeyService } from '../context/context-key-service'; +import { type Disposable, DisposableStore, toDisposable } from '../lifecycle/disposable'; +import { type Observable, derived } from '../observable/observable'; +import type { PluginManifest } from './plugin-manifest'; + +export type AppType = 'client' | 'server' | 'lib'; + +/** An app of the opened game workspace, as discovered by the project service. */ +export interface AppInfo { + readonly id: string; + readonly name: string; + readonly type: AppType; + /** Installed engine libraries and their versions, e.g. `{ '@nanoforge-dev/ecs': '1.4.2' }`. */ + readonly engineLibs: Readonly>; +} + +/** Provided by the project service (phase 2). */ +export interface EngineLibProvider { + readonly apps: Observable; +} + +export interface MissingEngineLib { + readonly name: string; + readonly range: string; + /** Installed version when present but out of range. */ + readonly installed?: string; +} + +/** Required engine libs of a plugin that an app lacks (empty = eligible). */ +export const missingEngineLibs = (manifest: PluginManifest, app: AppInfo): MissingEngineLib[] => + Object.entries(manifest.engineLibs.required).flatMap(([name, range]) => { + const installed = app.engineLibs[name]; + if (!installed) return [{ name, range }]; + const version = semver.valid(installed) ?? semver.coerce(installed)?.version; + if (!version || semver.satisfies(version, range)) return []; + return [{ name, range, installed }]; + }); + +export const isEligible = (manifest: PluginManifest, app: AppInfo): boolean => + missingEngineLibs(manifest, app).length === 0; + +export const eligibleApps = ( + manifest: PluginManifest, + apps: Observable, +): Observable => + derived( + [apps], + (list) => list.filter((app) => isEligible(manifest, app)), + (a, b) => a.length === b.length && a.every((app, i) => app === b[i]), + ); + +/** Context key holding the id of the app the user is working on. */ +export const ACTIVE_APP_KEY = 'activeApp'; +export const engineLibKey = (lib: string) => `engineLib:${lib}`; +export const pluginEligibleKey = (plugin: string) => `plugin.${plugin}.eligible`; + +/** + * Keeps `engineLib:` and `plugin..eligible` context keys in sync with the active app, + * so `when` clauses can gate features on installed engine libraries. + */ +export const bindEngineLibContext = ( + context: ContextKeyService, + apps: Observable, + plugins: () => readonly PluginManifest[], +): Disposable => { + const store = new DisposableStore(); + const bound = new DisposableStore(); + store.add(bound); + + const refresh = () => { + bound.clear(); + const activeId = context.get(ACTIVE_APP_KEY); + const app = apps.get().find((candidate) => candidate.id === activeId); + if (!app) return; + for (const lib of Object.keys(app.engineLibs)) bound.add(context.bind(engineLibKey(lib), true)); + for (const manifest of plugins()) { + bound.add(context.bind(pluginEligibleKey(manifest.name), isEligible(manifest, app))); + } + }; + + store.add(toDisposable(apps.subscribe(refresh))); + store.add( + context.onDidChange(({ keys }) => { + if (keys.has(ACTIVE_APP_KEY)) refresh(); + }), + ); + return store; +}; diff --git a/packages/kernel/src/plugin/index.ts b/packages/kernel/src/plugin/index.ts new file mode 100644 index 00000000..299e3428 --- /dev/null +++ b/packages/kernel/src/plugin/index.ts @@ -0,0 +1,17 @@ +export * from './shared-module-registry'; +export * from './create-core-container'; +export * from './engine-libs'; +export * from './plugin-manifest'; +export * from './package-manifest'; +export * from './plugin-manifest.exception'; +export * from './plugin-context.type'; +export * from './plugin-host.type'; +export * from './plugin-module-loader'; +export * from './command-metadata'; +export * from './plugin-activation.exception'; +export * from './plugin-host'; +export * from './describe-status'; +export * from './plugin-source.enum'; +export * from './plugin-resolution.type'; +export * from './resolve-plugins'; +export * from './core-services.const'; diff --git a/packages/kernel/src/plugin/manifest-fields.ts b/packages/kernel/src/plugin/manifest-fields.ts new file mode 100644 index 00000000..f02b6c97 --- /dev/null +++ b/packages/kernel/src/plugin/manifest-fields.ts @@ -0,0 +1,44 @@ +import semver from 'semver'; +import { z } from 'zod'; + +/** Registry package name: `@scope/name`, like a scoped npm package. */ +export const PLUGIN_NAME_PATTERN = /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/; + +export const PluginName = z + .string() + .regex(PLUGIN_NAME_PATTERN, 'must be "@scope/name" (lowercase letters, digits, dashes)'); + +export const Version = z + .string() + .refine((value) => semver.valid(value) !== null, 'invalid semver version'); + +export const Range = z + .string() + .refine((value) => semver.validRange(value) !== null, 'invalid semver range'); + +/** `["a/b"]` (any version, CLI registry style) or `{ "a/b": "^1.0.0" }`. */ +export const PluginDependencies = z + .union([ + z.array(PluginName).transform((names) => Object.fromEntries(names.map((name) => [name, '*']))), + z.record(PluginName, Range), + ]) + .default({}); + +export const NpmRanges = z.record(z.string().min(1), Range).default({}); + +export const ACTIVATION_EVENT_PATTERN = + /^(onStartup|on(Command|Screen|Widget|ProjectType|Language|Uri):.+)$/; + +export const ActivationEvent = z + .string() + .regex( + ACTIVATION_EVENT_PATTERN, + 'expected onStartup, onCommand:, onScreen:, onWidget:, onProjectType:, onLanguage: or onUri:', + ); + +export const EntryPath = z + .string() + .min(1) + .refine((path) => !path.startsWith('/') && !path.split('/').includes('..'), { + message: 'must be a relative path inside the plugin package', + }); diff --git a/packages/kernel/src/plugin/package-manifest.ts b/packages/kernel/src/plugin/package-manifest.ts new file mode 100644 index 00000000..b2120362 --- /dev/null +++ b/packages/kernel/src/plugin/package-manifest.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +import { PluginDependencies, PluginName, Version } from './manifest-fields'; + +const InsidePath = z + .string() + .min(1) + .refine((path) => !path.startsWith('/') && !path.split(/[\\/]/).includes('..'), { + message: 'must be a path inside the package', + }); + +/** + * `nanoforge.manifest.json` of a package of type `package`, installed in `nf_modules`: the fields + * the editor reads today. `contents`, items and owner objects (ADR 0003) are kept as they are: + * `readPackageContents` (`@nanoforge-dev/registry/types`) reads `contents` with the + * sub-manifests' ones. A package never holds a plugin: plugins are installed apart + * (`.nanoforge/plugins`). + */ +export const PackageManifestSchema = z + .object({ + $schema: z.string().optional(), + type: z.literal('package'), + name: PluginName, + version: Version, + description: z.string().optional(), + /** + * Sub-manifests describing more contents: folders (their `nanoforge.manifest.json`) or `.json` + * files. They only hold `contents` and `include`. Walked, never scanned. + */ + include: z.array(InsidePath).default([]), + /** Editor plugins that make this package easier to use: the editor offers them. */ + suggestedPlugins: PluginDependencies, + }) + .loose(); + +export type PackageManifest = z.output; diff --git a/packages/kernel/src/plugin/plugin-activation.exception.ts b/packages/kernel/src/plugin/plugin-activation.exception.ts new file mode 100644 index 00000000..83b888c9 --- /dev/null +++ b/packages/kernel/src/plugin/plugin-activation.exception.ts @@ -0,0 +1,9 @@ +export class PluginActivationError extends Error { + constructor( + readonly plugin: string, + cause: unknown, + ) { + super(`Plugin "${plugin}" failed to activate: ${String(cause)}`, { cause }); + this.name = 'PluginActivationError'; + } +} diff --git a/packages/kernel/src/plugin/plugin-context.type.ts b/packages/kernel/src/plugin/plugin-context.type.ts new file mode 100644 index 00000000..28f639da --- /dev/null +++ b/packages/kernel/src/plugin/plugin-context.type.ts @@ -0,0 +1,77 @@ +import type { CommandHandler, CommandMetadata } from '../command/command.type'; +import type { ContextKeyService } from '../context/context-key-service'; +import type { Container } from '../di/container'; +import type { + ProvideOptions, + ServiceAccessor, + ServiceDecorator, + ServiceFactory, +} from '../di/container.type'; +import type { ServiceToken } from '../di/service-token'; +import type { Contribution, ExtensionPoint } from '../extension/extension-point.type'; +import type { MessageBundle } from '../i18n/localization-service'; +import type { MessageParams } from '../i18n/message-format'; +import type { Disposable, DisposableStore } from '../lifecycle/disposable'; +import type { Logger } from '../log/logger.type'; +import type { Observable } from '../observable/observable'; +import type { AppInfo } from './engine-libs'; +import type { PluginManifest } from './plugin-manifest'; +import type { PluginSourceKind } from './plugin-source.enum'; + +/** + * What a plugin receives on activation. Everything registered through it is tied to the plugin + * lifetime: deactivating the plugin disposes it all. + */ +export interface PluginContext { + readonly name: string; + readonly manifest: PluginManifest; + readonly source: PluginSourceKind; + /** Disposed when the plugin deactivates; add your own disposables here. */ + readonly subscriptions: DisposableStore; + + /** Resolves services: plugin-private ones first, then the editor's. */ + readonly services: ServiceAccessor; + /** Plugin-private container (not visible to other plugins). */ + readonly scope: Container; + readonly contextKeys: ContextKeyService; + readonly logger: Logger; + /** Apps of the project that have every engine lib this plugin requires. */ + readonly eligibleApps: Observable; + + /** Provides a service to the whole editor (other plugins included). */ + provide(token: ServiceToken, value: T, options?: ProvideOptions): Disposable; + provideFactory( + token: ServiceToken, + factory: ServiceFactory, + options?: ProvideOptions, + ): Disposable; + /** Decorates an editor service, e.g. to extend a core feature. */ + override( + token: ServiceToken, + decorate: ServiceDecorator, + options?: ProvideOptions, + ): Disposable; + + contribute(point: ExtensionPoint, value: T, options?: { priority?: number }): Disposable; + contributions(point: ExtensionPoint): Observable[]>; + + registerCommand( + id: string, + handler: CommandHandler, + metadata?: CommandMetadata, + ): Disposable; + executeCommand(id: string, ...args: unknown[]): Promise; + + /** Translates a key of this plugin's namespace. */ + t(key: string, params?: MessageParams): string; + registerMessages(locale: string, messages: MessageBundle): Disposable; + + /** Absolute URL of a file shipped in the plugin package. */ + resolveAsset(path: string): string; +} + +/** What a plugin's client entry exports (see `definePlugin` in the SDK). */ +export interface PluginModule { + activate?(context: PluginContext): void | Promise; + deactivate?(): void | Promise; +} diff --git a/packages/kernel/src/plugin/plugin-host.ts b/packages/kernel/src/plugin/plugin-host.ts new file mode 100644 index 00000000..2a1aceae --- /dev/null +++ b/packages/kernel/src/plugin/plugin-host.ts @@ -0,0 +1,445 @@ +import { createToken } from '../di/service-token'; +import { Emitter, type Event } from '../event/emitter'; +import { type Disposable, DisposableStore, toDisposable } from '../lifecycle/disposable'; +import type { Logger } from '../log/logger.type'; +import { type Observable } from '../observable/observable'; +import { COMMAND_METADATA } from './command-metadata'; +import { CoreServices } from './core-services.const'; +import { createPluginContext } from './create-plugin-context'; +import { describeStatus } from './describe-status'; +import { bindEngineLibContext } from './engine-libs'; +import { PluginActivationError } from './plugin-activation.exception'; +import type { PluginModule } from './plugin-context.type'; +import type { + PluginHostOptions, + PluginInfo, + PluginModuleLoader, + PluginState, + StaticContributionHandler, +} from './plugin-host.type'; +import type { CommandContribution } from './plugin-manifest'; +import { importPluginLoader } from './plugin-module-loader'; +import type { PluginDescriptor, PluginStatus, Resolution } from './plugin-resolution.type'; +import { resolvePlugins } from './resolve-plugins'; + +interface PluginRecord { + descriptor: PluginDescriptor; + status: PluginStatus; + state: PluginState; + error?: unknown; + generation: number; + module?: PluginModule; + context?: DisposableStore; + staticContributions: DisposableStore; + pending?: Promise; + /** + * Deactivation in progress. An activation asked meanwhile is refused: waiting for it could + * deadlock with a dependent that is still activating. + */ + deactivating?: Promise; +} + +export class PluginHost implements Disposable { + private readonly _records = new Map(); + private readonly _handlers = new Map(); + private readonly _store = new DisposableStore(); + private readonly _onDidChange = new Emitter(); + private readonly _logger: Logger; + private readonly _loader: PluginModuleLoader; + private _resolution: Resolution | undefined; + + readonly onDidChange: Event = this._onDidChange.event; + + constructor(private readonly _options: PluginHostOptions) { + this._loader = _options.loader ?? importPluginLoader; + this._logger = this._options.services.get(CoreServices.Logger).getLogger('kernel.plugins'); + this._store.add( + this.registerContributionHandler({ + key: 'commands', + apply: (commands, plugin) => { + const store = new DisposableStore(); + for (const command of commands) { + store.add( + this._options.services + .get(CoreServices.Extensions) + .contribute( + COMMAND_METADATA, + { ...command, owner: plugin.manifest.name }, + { owner: plugin.manifest.name }, + ), + ); + } + return store; + }, + }), + ); + this._options.services.get(CoreServices.Commands).setActivator(async (id) => { + await this.activateByEvent(`onCommand:${id}`); + }); + } + + registerContributionHandler(handler: StaticContributionHandler): Disposable { + if (this._handlers.has(handler.key)) { + throw new Error(`A handler for contributes.${handler.key} is already registered`); + } + this._handlers.set(handler.key, handler as StaticContributionHandler); + for (const record of this._records.values()) { + if (record.status.kind === 'ok') + this._applyContribution(record, handler as StaticContributionHandler); + } + return toDisposable(() => this._handlers.delete(handler.key)); + } + + get resolution(): Resolution | undefined { + return this._resolution; + } + + getPlugin(name: string): PluginInfo | undefined { + const record = this._records.get(name); + return record && this._info(name, record); + } + + getPlugins(): PluginInfo[] { + return [...this._records].map(([name, record]) => this._info(name, record)); + } + + get plugins(): Observable { + return { + get: () => this.getPlugins(), + subscribe: (run) => { + run(this.getPlugins()); + const subscription = this.onDidChange(() => run(this.getPlugins())); + return () => subscription.dispose(); + }, + }; + } + + /** Resolves plugins, applies their static contributions and activates `onStartup` ones. */ + async start(descriptors: readonly PluginDescriptor[]): Promise { + if (this._resolution) throw new Error('Plugin host already started'); + this._resolution = resolvePlugins(descriptors, this._options); + for (const [name, resolved] of this._resolution.plugins) { + const record: PluginRecord = { + descriptor: resolved.descriptor, + status: resolved.status, + state: 'inactive', + generation: 0, + staticContributions: new DisposableStore(), + }; + this._records.set(name, record); + if (resolved.status.kind !== 'ok') { + this._logger.warn(`Plugin "${name}" not loaded: ${describeStatus(resolved.status)}`); + } + } + for (const descriptor of this._resolution.order) { + this._applyStaticContributions(this._records.get(descriptor.manifest.name)!); + } + const apps = this._options.services.tryGet(CoreServices.EngineLibs)?.apps; + if (apps) { + this._store.add( + bindEngineLibContext(this._options.services.get(CoreServices.ContextKeys), apps, () => + this._resolution!.order.map((d) => d.manifest), + ), + ); + } + this._onDidChange.fire(); + await this.activateByEvent('onStartup'); + } + + /** + * Replaces the set of plugins (a project with its own plugins was opened or closed). Plugins + * that left, changed source or stopped resolving are deactivated and removed; new ones get + * their static contributions. Plugins that were active and still resolve are re-activated, + * then `onStartup` plugins are activated. + */ + async update(descriptors: readonly PluginDescriptor[]): Promise { + if (!this._resolution) throw new Error('Plugin host not started'); + const next = resolvePlugins(descriptors, this._options); + const wasActive = new Set( + [...this._records].filter(([, record]) => record.state === 'active').map(([name]) => name), + ); + const changed = [...this._records].filter(([name, record]) => { + const resolved = next.plugins.get(name); + return ( + !resolved || + resolved.descriptor.baseUrl !== record.descriptor.baseUrl || + resolved.descriptor.manifest.version !== record.descriptor.manifest.version || + resolved.status.kind !== record.status.kind + ); + }); + for (const [name] of changed) await this.deactivate(name); + for (const [name, record] of changed) { + this._records.delete(name); + this._cleanup(name, record); + record.staticContributions.dispose(); + } + + this._resolution = next; + const fresh = new Set(); + for (const [name, resolved] of next.plugins) { + if (this._records.has(name)) continue; + fresh.add(name); + this._records.set(name, { + descriptor: resolved.descriptor, + status: resolved.status, + state: 'inactive', + generation: 0, + staticContributions: new DisposableStore(), + }); + if (resolved.status.kind !== 'ok') { + this._logger.warn(`Plugin "${name}" not loaded: ${describeStatus(resolved.status)}`); + } + } + for (const descriptor of next.order) { + if (fresh.has(descriptor.manifest.name)) + this._applyStaticContributions(this._records.get(descriptor.manifest.name)!); + } + this._onDidChange.fire(); + + const reactivate = [...wasActive].filter( + (name) => this._records.get(name)?.status.kind === 'ok', + ); + const results = await Promise.allSettled(reactivate.map((name) => this.activate(name))); + for (const result of results) { + if (result.status === 'rejected') this._logger.error(String(result.reason)); + } + await this.activateByEvent('onStartup'); + } + + /** Activates every inactive plugin listening to `event` (dependencies first). */ + /** + * Activates the plugins listening to an event. Failures are logged, never thrown: resolves with + * them, so a caller can say why (a panel whose plugin failed). + */ + async activateByEvent(event: string): Promise { + const targets = (this._resolution?.order ?? []) + .map((descriptor) => descriptor.manifest) + .filter((manifest) => manifest.activation.includes(event)) + .map((manifest) => manifest.name); + const results = await Promise.allSettled(targets.map((name) => this.activate(name))); + const failures: unknown[] = []; + for (const result of results) { + if (result.status !== 'rejected') continue; + this._logger.error(String(result.reason)); + failures.push(result.reason); + } + return failures; + } + + /** + * Activates a plugin and its dependencies. Concurrent calls share the same activation; one asked + * while the plugin deactivates (a hot reload, a project switch) runs once that is done. + */ + activate(name: string): Promise { + const record = this._records.get(name); + if (!record) return Promise.reject(new Error(`Unknown plugin "${name}"`)); + if (record.status.kind !== 'ok') { + return Promise.reject(new PluginActivationError(name, describeStatus(record.status))); + } + if (record.deactivating) return record.deactivating.then(() => this.activate(name)); + if (record.state === 'active') return Promise.resolve(); + if (record.state === 'failed') + return Promise.reject(new PluginActivationError(name, record.error)); + record.pending ??= this._activate(name, record).finally(() => { + record.pending = undefined; + }); + return record.pending; + } + + /** + * Deactivates a plugin after its active dependents. An activation in progress finishes first; + * concurrent calls share the same deactivation. + */ + deactivate(name: string): Promise { + const record = this._records.get(name); + if (!record) return Promise.resolve(); + record.deactivating ??= this._deactivate(name, record).finally(() => { + record.deactivating = undefined; + }); + return record.deactivating; + } + + /** + * Hot reload (local dev plugins): deactivates the plugin and its dependents, re-applies its + * static contributions from the new manifest, re-imports its code and re-activates what was + * active before. + */ + async reload(name: string, descriptor?: PluginDescriptor): Promise { + const record = this._records.get(name); + if (!record) throw new Error(`Unknown plugin "${name}"`); + const wasActive = [name, ...this._dependentsDeep(name)].filter( + (plugin) => this._records.get(plugin)?.state === 'active', + ); + await this.deactivate(name); + if (descriptor) record.descriptor = descriptor; + record.generation++; + record.module = undefined; + record.state = 'inactive'; + record.staticContributions.clear(); + if (record.status.kind === 'ok') this._applyStaticContributions(record); + for (const plugin of wasActive) await this.activate(plugin); + } + + dispose(): void { + for (const [name, record] of this._records) { + if (record.state === 'active') this._deactivateModule(name, record); + this._cleanup(name, record); + record.staticContributions.dispose(); + } + this._records.clear(); + this._options.services.get(CoreServices.Commands).setActivator(undefined); + this._store.dispose(); + this._onDidChange.dispose(); + } + + private async _deactivate(name: string, record: PluginRecord): Promise { + await record.pending?.catch(() => undefined); + if (record.state !== 'active' && record.state !== 'failed') return; + for (const dependent of this._dependents(name)) await this.deactivate(dependent); + if (record.state === 'failed') { + record.state = 'inactive'; + record.error = undefined; + this._onDidChange.fire(); + return; + } + record.state = 'deactivating'; + this._onDidChange.fire(); + try { + await record.module?.deactivate?.(); + } catch (error) { + this._logger.error(`Plugin "${name}" failed to deactivate`, error); + } + this._cleanup(name, record); + record.state = 'inactive'; + this._onDidChange.fire(); + } + + private async _activate(name: string, record: PluginRecord): Promise { + const { manifest } = record.descriptor; + for (const dependency of Object.keys(manifest.dependencies)) { + await this.activate(dependency); + } + for (const dependency of Object.keys(manifest.optionalDependencies)) { + if (this._records.get(dependency)?.status.kind === 'ok') { + await this.activate(dependency).catch(() => undefined); + } + } + record.state = 'activating'; + this._onDidChange.fire(); + const store = new DisposableStore(); + record.context = store; + try { + record.module ??= await this._loader.load(record.descriptor, record.generation); + const context = createPluginContext(this._options.services, record.descriptor, store); + await withTimeout( + Promise.resolve(record.module.activate?.(context)), + this._options.activationTimeoutMs ?? 10_000, + `activation of "${name}" timed out`, + ); + record.state = 'active'; + this._logger.info(`Plugin "${name}" activated`); + this._onDidChange.fire(); + } catch (error) { + if (record.module) this._deactivateModule(name, record); + this._cleanup(name, record); + record.state = 'failed'; + record.error = error; + this._logger.error(`Plugin "${name}" failed to activate`, error); + this._onDidChange.fire(); + throw new PluginActivationError(name, error); + } + } + + /** + * Calls a module's `deactivate` without waiting: on dispose, and after a failed or timed-out + * activation (it may have done things outside its context). Errors are logged, sync or async. + */ + private _deactivateModule(name: string, record: PluginRecord): void { + const { module } = record; + void Promise.resolve() + .then(() => module?.deactivate?.()) + .catch((error: unknown) => + this._logger.error(`Plugin "${name}" failed to deactivate`, error), + ); + } + + private _cleanup(name: string, record: PluginRecord): void { + try { + record.context?.dispose(); + } catch (error) { + this._logger.error(`Errors while disposing plugin "${name}"`, error); + } + record.context = undefined; + this._options.services.get(CoreServices.Extensions).removeOwner(name); + record.staticContributions.clear(); + if (record.status.kind === 'ok' && this._records.has(name)) { + this._applyStaticContributions(record); + } + } + + private _applyStaticContributions(record: PluginRecord): void { + const { contributes, name } = record.descriptor.manifest; + for (const key of Object.keys(contributes)) { + const handler = this._handlers.get(key); + if (handler) this._applyContribution(record, handler); + else this._logger.debug(`No handler yet for contributes.${key} of "${name}"`); + } + } + + private _applyContribution(record: PluginRecord, handler: StaticContributionHandler): void { + const { manifest } = record.descriptor; + const raw = (manifest.contributes as Record)[handler.key]; + if (raw === undefined) return; + try { + const value = handler.validator ? handler.validator.parse(raw) : raw; + record.staticContributions.add(handler.apply(value, record.descriptor)); + } catch (error) { + this._logger.error(`Invalid contributes.${handler.key} of "${manifest.name}"`, error); + } + } + + /** Active, activating or failed plugins depending (required or optional) on `name`. */ + private _dependents(name: string): string[] { + return [...this._records] + .filter(([, record]) => { + const { dependencies, optionalDependencies } = record.descriptor.manifest; + return ( + (record.state === 'active' || + record.state === 'activating' || + record.state === 'failed') && + (name in dependencies || name in optionalDependencies) + ); + }) + .map(([dependent]) => dependent); + } + + private _dependentsDeep(name: string, seen = new Set()): string[] { + for (const dependent of this._dependents(name)) { + if (seen.has(dependent)) continue; + seen.add(dependent); + this._dependentsDeep(dependent, seen); + } + return [...seen]; + } + + private _info(name: string, record: PluginRecord): PluginInfo { + return { + name, + descriptor: record.descriptor, + status: record.status, + state: record.state, + ...(record.error === undefined ? {} : { error: record.error }), + }; + } +} + +const withTimeout = (promise: Promise, ms: number, message: string): Promise => { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +}; + +/** The plugin host of the editor (plugin list, enable state), e.g. for a plugins page. */ +export const PluginHostToken = createToken('core.pluginHost'); diff --git a/packages/kernel/src/plugin/plugin-host.type.ts b/packages/kernel/src/plugin/plugin-host.type.ts new file mode 100644 index 00000000..e4b6b8cb --- /dev/null +++ b/packages/kernel/src/plugin/plugin-host.type.ts @@ -0,0 +1,44 @@ +import type { CommandMetadata } from '../command/command.type'; +import type { Container } from '../di/container'; +import type { Validator } from '../extension/extension-point.type'; +import type { Disposable } from '../lifecycle/disposable'; +import type { PluginModule } from './plugin-context.type'; +import type { PluginDescriptor, PluginStatus, ResolveOptions } from './plugin-resolution.type'; + +export type PluginState = 'inactive' | 'activating' | 'active' | 'deactivating' | 'failed'; + +export interface PluginInfo { + readonly name: string; + readonly descriptor: PluginDescriptor; + readonly status: PluginStatus; + readonly state: PluginState; + readonly error?: unknown; +} + +export interface PluginModuleLoader { + /** Imports the client entry. `generation` changes on hot reload to bypass module caches. */ + load(descriptor: PluginDescriptor, generation: number): Promise; +} + +/** + * Handles a static `contributes.` section of manifests. Static contributions are applied + * for every resolved plugin before activation, so the UI can show commands, widgets or settings + * of plugins that activate lazily. + */ +export interface StaticContributionHandler { + readonly key: string; + readonly validator?: Validator; + apply(value: T, plugin: PluginDescriptor): Disposable; +} + +export interface CommandMetadataContribution extends CommandMetadata { + readonly id: string; + readonly owner: string; +} + +export interface PluginHostOptions extends ResolveOptions { + /** Editor-wide container; plugin scopes are created as its children. */ + services: Container; + loader?: PluginModuleLoader; + activationTimeoutMs?: number; +} diff --git a/packages/kernel/src/plugin/plugin-manifest.exception.ts b/packages/kernel/src/plugin/plugin-manifest.exception.ts new file mode 100644 index 00000000..88aff76a --- /dev/null +++ b/packages/kernel/src/plugin/plugin-manifest.exception.ts @@ -0,0 +1,12 @@ +export class PluginManifestError extends Error { + constructor( + readonly issues: readonly { path: string; message: string }[], + readonly location?: string, + ) { + super( + `Invalid plugin manifest${location ? ` (${location})` : ''}:\n` + + issues.map((issue) => ` - ${issue.path || ''}: ${issue.message}`).join('\n'), + ); + this.name = 'PluginManifestError'; + } +} diff --git a/packages/kernel/src/plugin/plugin-manifest.ts b/packages/kernel/src/plugin/plugin-manifest.ts new file mode 100644 index 00000000..25fd842a --- /dev/null +++ b/packages/kernel/src/plugin/plugin-manifest.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; + +import { + ActivationEvent, + EntryPath, + NpmRanges, + PluginDependencies, + PluginName, + Range, + Version, +} from './manifest-fields'; +import { PluginManifestError } from './plugin-manifest.exception'; + +const CommandContribution = z.object({ + id: z.string().min(1), + title: z.string().min(1), + category: z.string().optional(), + icon: z.string().optional(), + when: z.string().optional(), + /** `false`: not listed in the command palette (the command needs arguments). */ + palette: z.boolean().optional(), +}); + +/** + * `nanoforge.manifest.json` of a package of type `plugin`. + * Contributions other than `commands` are validated by the service that owns them + * (settings, widgets, menus…) through contribution handlers. + */ +export const PluginManifestSchema = z.object({ + $schema: z.string().optional(), + type: z.literal('plugin'), + name: PluginName, + version: Version, + displayName: z.string().min(1).optional(), + description: z.string().optional(), + tags: z.array(z.string()).default([]), + icon: EntryPath.optional(), + license: z.string().optional(), + repository: z.string().optional(), + + engines: z.object({ editor: Range }), + dependencies: PluginDependencies, + optionalDependencies: PluginDependencies, + engineLibs: z + .object({ required: NpmRanges, optional: NpmRanges }) + .default({ required: {}, optional: {} }), + + entry: z + .object({ + client: EntryPath.optional(), + server: EntryPath.optional(), + worker: EntryPath.optional(), + }) + .refine((entry) => entry.client || entry.server || entry.worker, { + message: 'at least one of client, server or worker is required', + }), + /** Defaults to `["onStartup"]`. */ + activation: z.array(ActivationEvent).default(['onStartup']), + contributes: z + .object({ + commands: z.array(CommandContribution).default([]), + /** The plugin owns item data (ADR 0003): its tags, and the version of its owner object. */ + itemOwner: z + .object({ + schema: z.number().int().positive(), + tags: z.array(z.string().regex(/^[a-z][A-Za-z0-9]*$/)).default([]), + }) + .optional(), + }) + .catchall(z.unknown()) + .default({ commands: [] }), + + /** Filled by `@nanoforge-dev/editor-vite-plugin` at build time. */ + build: z.object({ svelte: Version.optional(), sdk: Version.optional() }).default({}), +}); + +export type PluginManifest = z.output; + +export type PluginManifestInput = z.input; +export type CommandContribution = z.output; + +export const parsePluginManifest = (value: unknown, location?: string): PluginManifest => { + const result = PluginManifestSchema.safeParse(value); + if (result.success) return result.data; + throw new PluginManifestError( + result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })), + location, + ); +}; + +/** JSON Schema of the manifest, for editor completion (`$schema` in manifests). */ +export const pluginManifestJsonSchema = (): unknown => + z.toJSONSchema(PluginManifestSchema, { io: 'input', unrepresentable: 'any' }); diff --git a/packages/kernel/src/plugin/plugin-module-loader.ts b/packages/kernel/src/plugin/plugin-module-loader.ts new file mode 100644 index 00000000..b1ab5e4e --- /dev/null +++ b/packages/kernel/src/plugin/plugin-module-loader.ts @@ -0,0 +1,24 @@ +import type { PluginModule } from './plugin-context.type'; +import type { PluginModuleLoader } from './plugin-host.type'; + +/** + * A plugin entry exports `activate`/`deactivate`, or a default export holding them + * (`export default definePlugin({...})`). + */ +export const normalizePluginModule = (imported: unknown): PluginModule => { + const module = (imported ?? {}) as PluginModule & { default?: unknown }; + if (module.activate || module.deactivate) return module; + const fallback = module.default as PluginModule | undefined; + return fallback && typeof fallback === 'object' ? fallback : {}; +}; + +/** Loads client entries with a dynamic `import()` of `/`. */ +export const importPluginLoader: PluginModuleLoader = { + async load(descriptor, generation) { + const entry = descriptor.manifest.entry.client; + if (!entry) return {}; + const url = new URL(entry, descriptor.baseUrl); + if (generation > 0) url.searchParams.set('v', String(generation)); + return normalizePluginModule(await import(/* @vite-ignore */ url.href)); + }, +}; diff --git a/packages/kernel/src/plugin/plugin-resolution.type.ts b/packages/kernel/src/plugin/plugin-resolution.type.ts new file mode 100644 index 00000000..8757e6b2 --- /dev/null +++ b/packages/kernel/src/plugin/plugin-resolution.type.ts @@ -0,0 +1,41 @@ +import type { PluginManifest } from './plugin-manifest'; +import type { PluginSourceKind } from './plugin-source.enum'; + +export interface PluginDescriptor { + readonly manifest: PluginManifest; + readonly source: PluginSourceKind; + /** URL of the plugin package root, entry paths are resolved against it. */ + readonly baseUrl: string; +} + +export type PluginStatus = + | { kind: 'ok' } + | { kind: 'disabled' } + | { kind: 'shadowed'; by: PluginSourceKind } + | { kind: 'incompatible-editor'; required: string; actual: string } + | { kind: 'incompatible-runtime'; module: string; compiled: string; actual: string } + | { kind: 'missing-dependency'; dependency: string; range: string } + | { kind: 'dependency-version'; dependency: string; range: string; actual: string } + | { kind: 'dependency-failed'; dependency: string } + | { kind: 'cycle'; cycle: readonly string[] }; + +export interface ResolveOptions { + editorVersion: string; + /** Versions of the shared runtime modules of the host, e.g. `{ svelte: '5.57.1' }`. */ + runtimeVersions?: Readonly>; + disabled?: ReadonlySet; +} + +export interface ResolvedPlugin { + readonly descriptor: PluginDescriptor; + readonly status: PluginStatus; +} + +export interface Resolution { + /** Every candidate by name (the winning source for shadowed names). */ + readonly plugins: ReadonlyMap; + /** Plugins shadowed by another source. */ + readonly shadowed: readonly ResolvedPlugin[]; + /** `ok` plugins in activation order: dependencies (and present optional ones) first. */ + readonly order: readonly PluginDescriptor[]; +} diff --git a/packages/kernel/src/plugin/plugin-source.enum.ts b/packages/kernel/src/plugin/plugin-source.enum.ts new file mode 100644 index 00000000..f116f32d --- /dev/null +++ b/packages/kernel/src/plugin/plugin-source.enum.ts @@ -0,0 +1,3 @@ +/** Where a plugin comes from. Later sources shadow earlier ones with the same name. */ +export const PLUGIN_SOURCE_ORDER = ['bundled', 'installed', 'project', 'dev'] as const; +export type PluginSourceKind = (typeof PLUGIN_SOURCE_ORDER)[number]; diff --git a/packages/kernel/src/plugin/resolve-plugins.ts b/packages/kernel/src/plugin/resolve-plugins.ts new file mode 100644 index 00000000..9a2edc8b --- /dev/null +++ b/packages/kernel/src/plugin/resolve-plugins.ts @@ -0,0 +1,179 @@ +import semver from 'semver'; + +import type { + PluginDescriptor, + PluginStatus, + Resolution, + ResolveOptions, + ResolvedPlugin, +} from './plugin-resolution.type'; +import { PLUGIN_SOURCE_ORDER } from './plugin-source.enum'; + +const satisfies = (version: string, range: string) => + semver.satisfies(version, range, { includePrerelease: true }); + +/** + * A compiled Svelte component calls runtime internals: it must have been compiled with the same + * major and a version not newer than the host runtime (docs/adr/0001-plugin-ui-runtime.md). + */ +const runtimeCompatible = (compiled: string, actual: string) => + semver.major(compiled) === semver.major(actual) && semver.lte(compiled, actual); + +const optionalOk = (plugins: Map, name: string, range: string) => { + const dependency = plugins.get(name); + return ( + dependency?.status.kind === 'ok' && satisfies(dependency.descriptor.manifest.version, range) + ); +}; + +export const resolvePlugins = ( + descriptors: readonly PluginDescriptor[], + options: ResolveOptions, +): Resolution => { + const candidates = new Map(); + const shadowed: ResolvedPlugin[] = []; + const rank = (d: PluginDescriptor) => PLUGIN_SOURCE_ORDER.indexOf(d.source); + for (const descriptor of [...descriptors].sort((a, b) => rank(a) - rank(b))) { + const previous = candidates.get(descriptor.manifest.name); + if (previous) { + shadowed.push({ descriptor: previous, status: { kind: 'shadowed', by: descriptor.source } }); + } + candidates.set(descriptor.manifest.name, descriptor); + } + + const plugins = new Map(); + for (const [name, descriptor] of candidates) { + plugins.set(name, { descriptor, status: checkPlugin(descriptor, candidates, options) }); + } + + for (const cycle of findCycles(plugins)) { + for (const name of cycle) { + plugins.set(name, { + descriptor: plugins.get(name)!.descriptor, + status: { kind: 'cycle', cycle }, + }); + } + } + + let changed = true; + while (changed) { + changed = false; + for (const [name, plugin] of plugins) { + if (plugin.status.kind !== 'ok') continue; + const failed = Object.keys(plugin.descriptor.manifest.dependencies).find( + (dependency) => plugins.get(dependency)?.status.kind !== 'ok', + ); + if (failed) { + plugins.set(name, { + descriptor: plugin.descriptor, + status: { kind: 'dependency-failed', dependency: failed }, + }); + changed = true; + } + } + } + + return { plugins, shadowed, order: activationOrder(plugins) }; +}; + +const checkPlugin = ( + descriptor: PluginDescriptor, + candidates: ReadonlyMap, + options: ResolveOptions, +): PluginStatus => { + const { manifest } = descriptor; + if (options.disabled?.has(manifest.name)) return { kind: 'disabled' }; + if (!satisfies(options.editorVersion, manifest.engines.editor)) { + return { + kind: 'incompatible-editor', + required: manifest.engines.editor, + actual: options.editorVersion, + }; + } + for (const [module, compiled] of Object.entries(manifest.build)) { + const actual = options.runtimeVersions?.[module]; + if (compiled && actual && !runtimeCompatible(compiled, actual)) { + return { kind: 'incompatible-runtime', module, compiled, actual }; + } + } + for (const [dependency, range] of Object.entries(manifest.dependencies)) { + const found = candidates.get(dependency); + if (!found) return { kind: 'missing-dependency', dependency, range }; + if (!satisfies(found.manifest.version, range)) { + return { kind: 'dependency-version', dependency, range, actual: found.manifest.version }; + } + } + return { kind: 'ok' }; +}; + +/** Edges used for ordering: required dependencies and valid optional ones. */ +const edges = (plugins: Map, plugin: ResolvedPlugin): string[] => { + const { dependencies, optionalDependencies } = plugin.descriptor.manifest; + return [ + ...Object.keys(dependencies).filter((name) => plugins.has(name)), + ...Object.entries(optionalDependencies) + .filter(([name, range]) => optionalOk(plugins, name, range)) + .map(([name]) => name), + ]; +}; + +/** Strongly connected components with more than one node (or a self loop), via Tarjan. */ +const findCycles = (plugins: Map): string[][] => { + const index = new Map(); + const low = new Map(); + const stack: string[] = []; + const onStack = new Set(); + const cycles: string[][] = []; + let counter = 0; + + const visit = (name: string) => { + index.set(name, counter); + low.set(name, counter++); + stack.push(name); + onStack.add(name); + for (const next of edges(plugins, plugins.get(name)!)) { + if (plugins.get(next)?.status.kind !== 'ok') continue; + if (!index.has(next)) { + visit(next); + low.set(name, Math.min(low.get(name)!, low.get(next)!)); + } else if (onStack.has(next)) { + low.set(name, Math.min(low.get(name)!, index.get(next)!)); + } + } + if (low.get(name) !== index.get(name)) return; + const component: string[] = []; + let member: string; + do { + member = stack.pop()!; + onStack.delete(member); + component.push(member); + } while (member !== name); + const selfLoop = edges(plugins, plugins.get(name)!).includes(name); + if (component.length > 1 || selfLoop) cycles.push(component.sort()); + }; + + for (const [name, plugin] of [...plugins].sort(([a], [b]) => a.localeCompare(b))) { + if (plugin.status.kind === 'ok' && !index.has(name)) visit(name); + } + return cycles; +}; + +/** Kahn's algorithm over ok plugins; ties broken by name for a deterministic order. */ +const activationOrder = (plugins: Map): PluginDescriptor[] => { + const ok = [...plugins].filter(([, plugin]) => plugin.status.kind === 'ok'); + const pending = new Map(ok.map(([name, plugin]) => [name, new Set(edges(plugins, plugin))])); + const order: PluginDescriptor[] = []; + while (pending.size) { + const ready = [...pending] + .filter(([, deps]) => [...deps].every((dep) => !pending.has(dep))) + .map(([name]) => name) + .sort(); + /** Unreachable: cycles were removed. */ + if (!ready.length) break; + for (const name of ready) { + pending.delete(name); + order.push(plugins.get(name)!.descriptor); + } + } + return order; +}; diff --git a/packages/kernel/src/plugin/shared-module-registry.ts b/packages/kernel/src/plugin/shared-module-registry.ts new file mode 100644 index 00000000..aa1ad41a --- /dev/null +++ b/packages/kernel/src/plugin/shared-module-registry.ts @@ -0,0 +1,55 @@ +/** + * Global under which the host exposes its shared modules to editor plugins. + * Must match `SHARED_GLOBAL` of `@nanoforge-dev/editor-vite-plugin`. + */ +export const SHARED_GLOBAL = '__nanoforge_editor_shared__'; + +export type ModuleNamespace = Readonly>; + +export interface SharedModuleScope { + require(id: string): ModuleNamespace; +} + +/** + * Registry of the modules (Svelte runtime, editor SDK…) that plugins share with the host. + * Plugins built with the editor vite plugin resolve these imports through the installed + * registry instead of bundling their own copy. + */ +export class SharedModuleRegistry implements SharedModuleScope { + private readonly _modules = new Map(); + + register(id: string, namespace: ModuleNamespace): this { + if (this._modules.has(id)) throw new Error(`Shared module "${id}" is already registered`); + this._modules.set(id, namespace); + return this; + } + + has(id: string): boolean { + return this._modules.has(id); + } + + require(id: string): ModuleNamespace { + const namespace = this._modules.get(id); + if (!namespace) { + throw new Error(`Shared module "${id}" is not provided by this editor version`); + } + return namespace; + } + + get ids(): string[] { + return [...this._modules.keys()]; + } + + /** Exposes the registry to plugin code. Only one registry can be installed at a time. */ + install(target: object = globalThis): () => void { + const holder = target as Record; + if (holder[SHARED_GLOBAL]) { + throw new Error('Another shared module registry is already installed'); + } + const scope: SharedModuleScope = { require: (id) => this.require(id) }; + Object.defineProperty(holder, SHARED_GLOBAL, { value: scope, configurable: true }); + return () => { + Reflect.deleteProperty(holder, SHARED_GLOBAL); + }; + } +} diff --git a/packages/kernel/test/command/command-registry.spec.ts b/packages/kernel/test/command/command-registry.spec.ts new file mode 100644 index 00000000..f75a1cbe --- /dev/null +++ b/packages/kernel/test/command/command-registry.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CommandNotFoundError } from '../../src/command/command-not-found.exception'; +import { CommandPreconditionError } from '../../src/command/command-precondition.exception'; +import { CommandRegistry } from '../../src/command/command-registry'; +import { ContextKeyService } from '../../src/context/context-key-service'; +import { Container } from '../../src/di/container'; +import { createToken } from '../../src/di/service-token'; + +const Counter = createToken<{ value: number }>('counter'); + +const setup = () => { + const services = new Container(); + services.provide(Counter, { value: 0 }); + const context = new ContextKeyService(); + return { services, context, commands: new CommandRegistry(services, context) }; +}; + +describe('CommandRegistry', () => { + it('runs handlers with services and args', async () => { + const { commands } = setup(); + commands.register({ + id: 'counter.add', + handler: (s, n: number) => (s.get(Counter).value += n), + }); + await expect(commands.execute('counter.add', 2)).resolves.toBe(2); + }); + + it('rejects duplicates and unknown commands', async () => { + const { commands } = setup(); + const sub = commands.register({ id: 'a', handler: () => undefined }); + expect(() => commands.register({ id: 'a', handler: () => undefined })).toThrow(); + sub.dispose(); + await expect(commands.execute('a')).rejects.toThrow(CommandNotFoundError); + }); + + it('checks preconditions', async () => { + const { commands, context } = setup(); + commands.register({ id: 'runtime.pause', when: 'playing', handler: () => 'paused' }); + expect(commands.isEnabled('runtime.pause')).toBe(false); + await expect(commands.execute('runtime.pause')).rejects.toThrow(CommandPreconditionError); + context.set('playing', true); + await expect(commands.execute('runtime.pause')).resolves.toBe('paused'); + }); + + it('activates lazily and reports executions', async () => { + const { commands } = setup(); + const executions = vi.fn(); + commands.onDidExecute(executions); + commands.setActivator(async (id) => { + commands.register({ id, handler: () => 'late' }); + }); + await expect(commands.execute('plugin.cmd')).resolves.toBe('late'); + commands.register({ + id: 'fails', + handler: () => { + throw new Error('x'); + }, + }); + await expect(commands.execute('fails')).rejects.toThrow('x'); + expect(executions.mock.calls.map(([e]) => [e.id, 'error' in e])).toEqual([ + ['plugin.cmd', false], + ['fails', true], + ]); + }); +}); diff --git a/packages/kernel/test/context/context-key-service.spec.ts b/packages/kernel/test/context/context-key-service.spec.ts new file mode 100644 index 00000000..09e7bbb6 --- /dev/null +++ b/packages/kernel/test/context/context-key-service.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ContextKeyService } from '../../src/context/context-key-service'; + +describe('ContextKeyService', () => { + it('scopes shadow their parent', () => { + const global = new ContextKeyService(); + global.set('screen', 'scene'); + const widget = global.createScoped('widget'); + widget.set('focus', 'hierarchy'); + expect(widget.evaluate("screen == scene && focus == 'hierarchy'")).toBe(true); + expect(global.evaluate('focus')).toBe(false); + widget.set('screen', 'game'); + expect(widget.get('screen')).toBe('game'); + expect(global.get('screen')).toBe('scene'); + }); + + it('bind restores the previous value', () => { + const context = new ContextKeyService(); + context.set('mode', 'edit'); + const binding = context.bind('mode', 'play'); + expect(context.get('mode')).toBe('play'); + binding.dispose(); + expect(context.get('mode')).toBe('edit'); + const temp = context.bind('tmp', 1); + temp.dispose(); + expect(context.get('tmp')).toBeUndefined(); + }); + + it('observe re-evaluates only on relevant keys', () => { + const global = new ContextKeyService(); + const scoped = global.createScoped('s'); + const run = vi.fn(); + scoped.observe('playing && !paused').subscribe(run); + global.set('playing', true); + global.set('unrelated', 1); + scoped.set('paused', true); + /** Shadowed by the scope: no change. */ + global.set('paused', false); + expect(run.mock.calls).toEqual([[false], [true], [false]]); + }); + + it('undefined when clauses are always true', () => { + const context = new ContextKeyService(); + expect(context.evaluate(undefined)).toBe(true); + expect(context.observe(undefined).get()).toBe(true); + }); +}); diff --git a/packages/kernel/test/context/parse-when.spec.ts b/packages/kernel/test/context/parse-when.spec.ts new file mode 100644 index 00000000..6ada5759 --- /dev/null +++ b/packages/kernel/test/context/parse-when.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { parseWhen } from '../../src/context/parse-when'; +import { WhenParseError } from '../../src/context/when-parse.exception'; + +const ctx: Record = { + editorFocus: true, + screen: 'scene', + count: 2, + 'engineLib:@nanoforge-dev/ecs': true, + 'plugin.@nanoforge/ecs.eligible': false, + resourceName: 'player.component.ts', + selection: 'entity', + selectable: ['entity', 'component'], + apps: { client: {}, server: {} }, + app: 'server', +}; +const run = (source: string) => parseWhen(source).evaluate((key) => ctx[key]); + +describe('when clauses', () => { + it.each([ + ['editorFocus', true], + ['!editorFocus', false], + ['missing', false], + ['true && !false', true], + ["screen == 'scene'", true], + ['screen == scene', true], + ['screen != "game"', true], + ['count == 2', true], + ['count == 3', false], + ['engineLib:@nanoforge-dev/ecs && !plugin.@nanoforge/ecs.eligible', true], + ['resourceName =~ /\\.component\\.ts$/', true], + ['resourceName =~ /\\.SYSTEM\\.ts$/i', false], + ['selection in selectable', true], + ['app in apps', true], + ['screen == game || (editorFocus && count == 2)', true], + ['!(editorFocus || missing)', false], + ])('%s → %s', (source, expected) => { + expect(run(source)).toBe(expected); + }); + + it.each(['g', 'y', 'gy'])('gives the same answer every time with the %s flag', (flags) => { + const clause = parseWhen(`resourceName =~ /component/${flags}`); + expect([1, 2, 3].map(() => clause.evaluate((key) => ctx[key]))).toEqual([true, true, true]); + }); + + it('reports read keys', () => { + expect([...parseWhen('a && (b == x || !c) && d in e').keys]).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + it.each(['a &&', '(a', 'a == ', "a == 'x", 'a =~ b', 'a b', '#'])('rejects %s', (source) => { + expect(() => parseWhen(source)).toThrow(WhenParseError); + }); +}); diff --git a/packages/kernel/test/di/container.spec.ts b/packages/kernel/test/di/container.spec.ts new file mode 100644 index 00000000..a617be9a --- /dev/null +++ b/packages/kernel/test/di/container.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Container } from '../../src/di/container'; +import { CyclicDependencyError } from '../../src/di/cyclic-dependency.exception'; +import { ServiceNotFoundError } from '../../src/di/service-not-found.exception'; +import { createToken } from '../../src/di/service-token'; +import { toDisposable } from '../../src/lifecycle/disposable'; + +interface Greeter { + greet(): string; +} +const Greeter = createToken('greeter'); +const Name = createToken('name'); + +describe('Container', () => { + it('resolves values and lazy factories once', () => { + const container = new Container(); + const factory = vi.fn(() => ({ greet: () => 'hi' })); + container.provideFactory(Greeter, factory); + expect(factory).not.toHaveBeenCalled(); + expect(container.get(Greeter)).toBe(container.get(Greeter)); + expect(factory).toHaveBeenCalledOnce(); + expect(() => container.get(Name)).toThrow(ServiceNotFoundError); + expect(container.tryGet(Name)).toBeUndefined(); + }); + + it('picks the highest priority, then the latest registration', () => { + const container = new Container(); + container.provide(Name, 'default'); + const high = container.provide(Name, 'plugin', { priority: 10 }); + container.provide(Name, 'late'); + expect(container.get(Name)).toBe('plugin'); + high.dispose(); + expect(container.get(Name)).toBe('late'); + expect(container.getAll(Name)).toEqual(['default', 'late']); + }); + + it('applies overrides from the root down to the requesting scope', () => { + const root = new Container(); + root.provide(Name, 'base'); + root.override(Name, (inner) => `${inner}+root`); + const child = root.createChild('project'); + child.override(Name, (inner) => `${inner}+child`, { priority: 5 }); + root.override(Name, (inner) => `${inner}+root2`, { priority: 1 }); + expect(root.get(Name)).toBe('base+root+root2'); + expect(child.get(Name)).toBe('base+root+root2+child'); + }); + + it('children shadow their parent and see parent changes', () => { + const root = new Container(); + root.provide(Name, 'root'); + const child = root.createChild('child'); + expect(child.get(Name)).toBe('root'); + root.provide(Name, 'root2'); + expect(child.get(Name)).toBe('root2'); + const local = child.provide(Name, 'child'); + expect(child.get(Name)).toBe('child'); + expect(root.get(Name)).toBe('root2'); + local.dispose(); + expect(child.get(Name)).toBe('root2'); + }); + + it('factories resolve dependencies through their owner', () => { + const root = new Container(); + root.provide(Name, 'world'); + root.provideFactory(Greeter, (s) => ({ greet: () => `hello ${s.get(Name)}` })); + const child = root.createChild('c'); + /** Does not affect the root singleton. */ + child.provide(Name, 'child'); + expect(child.get(Greeter).greet()).toBe('hello world'); + }); + + it('detects cycles', () => { + const A = createToken('a'); + const B = createToken('b'); + const container = new Container(); + container.provideFactory(A, (s) => s.get(B) + 1); + container.provideFactory(B, (s) => s.get(A) + 1); + expect(() => container.get(A)).toThrow(CyclicDependencyError); + expect(() => container.get(A)).toThrow('a -> b -> a'); + }); + + it('observe follows provider changes', () => { + const container = new Container(); + const run = vi.fn(); + container.observe(Name).subscribe(run); + const provider = container.provide(Name, 'a'); + container.provide(Greeter, { greet: () => '' }); + provider.dispose(); + expect(run.mock.calls).toEqual([[undefined], ['a'], [undefined]]); + }); + + it('disposes factory instances, children, and refuses use after dispose', () => { + const root = new Container(); + const disposed = vi.fn(); + const Service = createToken<{ dispose(): void }>('svc'); + root.provideFactory(Service, () => toDisposable(disposed)); + const child = root.createChild('child'); + root.get(Service); + root.dispose(); + expect(disposed).toHaveBeenCalledOnce(); + expect(() => child.get(Name)).toThrow(/disposed/); + expect(child.tryGet(Name)).toBeUndefined(); + expect(root.tryGet(Service)).toBeUndefined(); + }); + + it('disposes the instance when its provider is removed', () => { + const container = new Container(); + const disposed = vi.fn(); + const Service = createToken<{ dispose(): void }>('svc'); + const provider = container.provideFactory(Service, () => toDisposable(disposed)); + container.get(Service); + provider.dispose(); + expect(disposed).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/kernel/test/event/emitter.spec.ts b/packages/kernel/test/event/emitter.spec.ts new file mode 100644 index 00000000..52bcbf99 --- /dev/null +++ b/packages/kernel/test/event/emitter.spec.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Emitter, setListenerErrorHandler } from '../../src/event/emitter'; +import { EventBus } from '../../src/event/event-bus'; +import { Events } from '../../src/event/events'; +import { type Disposable, DisposableStore } from '../../src/lifecycle/disposable'; + +describe('Emitter', () => { + afterEach(() => setListenerErrorHandler(console.error)); + + it('delivers to listeners until they unsubscribe', () => { + const emitter = new Emitter(); + const fn = vi.fn(); + const sub = emitter.event(fn); + emitter.fire(1); + sub.dispose(); + emitter.fire(2); + expect(fn).toHaveBeenCalledExactlyOnceWith(1); + }); + + it('isolates listener errors', () => { + const onError = vi.fn(); + setListenerErrorHandler(onError); + const emitter = new Emitter(); + const fn = vi.fn(); + emitter.event(() => { + throw new Error('boom'); + }); + emitter.event(fn); + emitter.fire(); + expect(onError).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('calls first/last listener hooks', () => { + const first = vi.fn(); + const last = vi.fn(); + const emitter = new Emitter({ onFirstListenerAdd: first, onLastListenerRemove: last }); + const a = emitter.event(() => undefined); + const b = emitter.event(() => undefined); + a.dispose(); + expect(last).not.toHaveBeenCalled(); + b.dispose(); + expect(first).toHaveBeenCalledOnce(); + expect(last).toHaveBeenCalledOnce(); + }); + + it('does not deliver to a listener removed during the same fire', () => { + const emitter = new Emitter(); + const late = vi.fn(); + let lateSub: Disposable = { dispose: () => undefined }; + emitter.event(() => lateSub.dispose()); + lateSub = emitter.event(late); + emitter.fire(); + expect(late).not.toHaveBeenCalled(); + }); + + it('registers subscriptions in a store', () => { + const emitter = new Emitter(); + const store = new DisposableStore(); + const fn = vi.fn(); + emitter.event(fn, store); + store.dispose(); + emitter.fire(1); + expect(fn).not.toHaveBeenCalled(); + }); +}); + +describe('Events', () => { + it('once, map and filter', () => { + const emitter = new Emitter(); + const once = vi.fn(); + const mapped = vi.fn(); + Events.once(emitter.event)(once); + Events.map( + Events.filter(emitter.event, (n) => n > 1), + (n) => n * 10, + )(mapped); + emitter.fire(1); + emitter.fire(2); + expect(once).toHaveBeenCalledExactlyOnceWith(1); + expect(mapped).toHaveBeenCalledExactlyOnceWith(20); + }); + + it('debounce merges bursts', () => { + vi.useFakeTimers(); + const emitter = new Emitter(); + const fn = vi.fn(); + Events.debounce(emitter.event, (all = [], n) => [...all, n], 50)(fn); + emitter.fire(1); + emitter.fire(2); + vi.advanceTimersByTime(49); + emitter.fire(3); + vi.advanceTimersByTime(50); + expect(fn).toHaveBeenCalledExactlyOnceWith([1, 2, 3]); + vi.useRealTimers(); + }); + + it('toPromise resolves with the next value', async () => { + const emitter = new Emitter(); + const promise = Events.toPromise(emitter.event); + emitter.fire('ok'); + await expect(promise).resolves.toBe('ok'); + }); +}); + +describe('EventBus', () => { + it('routes typed events by key', () => { + const bus = new EventBus<{ 'test.ping': { n: number } }>(); + const fn = vi.fn(); + const sub = bus.on('test.ping', fn); + bus.emit('test.ping', { n: 1 }); + sub.dispose(); + bus.emit('test.ping', { n: 2 }); + expect(fn).toHaveBeenCalledExactlyOnceWith({ n: 1 }); + }); +}); diff --git a/packages/kernel/test/extension/extension-registry.spec.ts b/packages/kernel/test/extension/extension-registry.spec.ts new file mode 100644 index 00000000..f5b60ad3 --- /dev/null +++ b/packages/kernel/test/extension/extension-registry.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ContributionError } from '../../src/extension/contribution.exception'; +import { defineExtensionPoint } from '../../src/extension/define-extension-point'; +import { ExtensionRegistry } from '../../src/extension/extension-registry'; + +interface Mode { + id: string; +} + +describe('ExtensionRegistry', () => { + it('orders contributions by priority then registration', () => { + const point = defineExtensionPoint('viewport.modes'); + const registry = new ExtensionRegistry(); + registry.contribute(point, { id: 'game' }, { owner: 'viewport' }); + registry.contribute(point, { id: 'scene' }, { owner: 'ecs', priority: 10 }); + registry.contribute(point, { id: 'debug' }, { owner: 'inspectors' }); + expect(registry.getValues(point).map((m) => m.id)).toEqual(['scene', 'game', 'debug']); + }); + + it('keeps only the best contribution of single-valued points', () => { + const point = defineExtensionPoint('codegen.target', { multiple: false }); + const registry = new ExtensionRegistry(); + registry.contribute(point, 'entry-file', { owner: 'ecs' }); + const scene = registry.contribute(point, 'scene-files', { owner: 'scene', priority: 10 }); + expect(registry.getValues(point)).toEqual(['scene-files']); + scene.dispose(); + expect(registry.getValues(point)).toEqual(['entry-file']); + }); + + it('validates contributions', () => { + const point = defineExtensionPoint('numbers', { + validator: { + parse: (v) => { + if (typeof v !== 'number') throw new Error('not a number'); + return v; + }, + }, + }); + const registry = new ExtensionRegistry(); + expect(() => registry.contribute(point, 'x' as never, { owner: 'bad' })).toThrow( + ContributionError, + ); + expect(registry.getValues(point)).toEqual([]); + }); + + it('notifies observers and removes by owner', () => { + const point = defineExtensionPoint('menus'); + const registry = new ExtensionRegistry(); + const run = vi.fn(); + registry.observe(point).subscribe((list) => run(list.map((c) => c.value))); + registry.contribute(point, 'a', { owner: 'p1' }); + registry.contribute(point, 'b', { owner: 'p2' }); + registry.removeOwner('p1'); + expect(run.mock.calls).toEqual([[[]], [['a']], [['a', 'b']], [['b']]]); + }); +}); diff --git a/packages/kernel/test/i18n/localization-service.spec.ts b/packages/kernel/test/i18n/localization-service.spec.ts new file mode 100644 index 00000000..5d770017 --- /dev/null +++ b/packages/kernel/test/i18n/localization-service.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LocalizationService } from '../../src/i18n/localization-service'; +import { formatMessage } from '../../src/i18n/message-format'; + +describe('formatMessage', () => { + it.each([ + ['Hello {name}', { name: 'Ada' }, 'Hello Ada'], + ['Missing {x}', {}, 'Missing {x}'], + ['{n, plural, =0 {no entity} one {# entity} other {# entities}}', { n: 0 }, 'no entity'], + ['{n, plural, one {# entity} other {# entities}}', { n: 1 }, '1 entity'], + ['{n, plural, one {# entity} other {# entities}}', { n: 1200 }, '1,200 entities'], + ['{kind, select, client {Client} server {Server} other {Lib}}', { kind: 'server' }, 'Server'], + ['{kind, select, client {Client} other {Lib {name}}}', { kind: 'x', name: 'core' }, 'Lib core'], + ["Escaped '{brace}", {}, 'Escaped {brace}'], + ])('%s', (message, params, expected) => { + expect(formatMessage(message, params)).toBe(expected); + }); + + it('uses locale plural rules', () => { + expect(formatMessage('{n, plural, one {# fichier} other {# fichiers}}', { n: 0 }, 'fr')).toBe( + '0 fichier', + ); + }); +}); + +describe('LocalizationService', () => { + it('falls back from region to language to english to key', () => { + const i18n = new LocalizationService(); + i18n.registerBundle('core', 'en', { save: 'Save', quit: 'Quit' }); + i18n.registerBundle('core', 'fr', { save: 'Enregistrer' }); + const t = i18n.scope('core'); + i18n.setLocale('fr-CA'); + expect(t('save')).toBe('Enregistrer'); + expect(t('quit')).toBe('Quit'); + expect(t('unknown')).toBe('unknown'); + expect(i18n.locales).toEqual(['en', 'fr']); + }); + + it('later bundles override earlier ones until disposed', () => { + const i18n = new LocalizationService(); + const revision = vi.fn(); + i18n.revision.subscribe(revision); + i18n.registerBundle('ecs', 'en', { title: 'Entities' }); + const patch = i18n.registerBundle('ecs', 'en', { title: 'Actors' }); + expect(i18n.translate('ecs', 'title')).toBe('Actors'); + patch.dispose(); + expect(i18n.translate('ecs', 'title')).toBe('Entities'); + expect(revision).toHaveBeenCalledTimes(4); + }); +}); diff --git a/packages/kernel/test/lifecycle/disposable.spec.ts b/packages/kernel/test/lifecycle/disposable.spec.ts new file mode 100644 index 00000000..fe4b477d --- /dev/null +++ b/packages/kernel/test/lifecycle/disposable.spec.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + type Disposable, + DisposableStore, + combineDisposables, + disposeAll, + setDisposableTracker, + toDisposable, +} from '../../src/lifecycle/disposable'; +import { MutableDisposable } from '../../src/lifecycle/mutable-disposable'; + +describe('toDisposable', () => { + it('runs the cleanup once', () => { + const fn = vi.fn(); + const d = toDisposable(fn); + d.dispose(); + d.dispose(); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('supports `using`', () => { + const fn = vi.fn(); + { + using d = toDisposable(fn) as Disposable & { [Symbol.dispose](): void }; + expect(d).toBeDefined(); + expect(fn).not.toHaveBeenCalled(); + } + expect(fn).toHaveBeenCalledOnce(); + }); +}); + +describe('DisposableStore', () => { + it('disposes in reverse order', () => { + const order: number[] = []; + const store = new DisposableStore(); + store.add(toDisposable(() => order.push(1))); + store.add(toDisposable(() => order.push(2))); + store.dispose(); + expect(order).toEqual([2, 1]); + expect(store.isDisposed).toBe(true); + }); + + it('disposes items added after disposal immediately', () => { + const store = new DisposableStore(); + store.dispose(); + const fn = vi.fn(); + store.add(toDisposable(fn)); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('clear keeps the store usable', () => { + const store = new DisposableStore(); + const a = vi.fn(); + store.add(toDisposable(a)); + store.clear(); + expect(a).toHaveBeenCalledOnce(); + const b = vi.fn(); + store.add(toDisposable(b)); + expect(b).not.toHaveBeenCalled(); + expect(store.size).toBe(1); + }); + + it('disposes everything even if one throws', () => { + const fn = vi.fn(); + const store = new DisposableStore(); + store.add(toDisposable(fn)); + store.add( + toDisposable(() => { + throw new Error('boom'); + }), + ); + expect(() => store.dispose()).toThrow('boom'); + expect(fn).toHaveBeenCalledOnce(); + }); +}); + +describe('disposeAll', () => { + it('aggregates multiple errors', () => { + const fail = () => + toDisposable(() => { + throw new Error('x'); + }); + expect(() => disposeAll([fail(), fail()])).toThrow(AggregateError); + }); +}); + +describe('MutableDisposable', () => { + it('disposes the previous value on replace and on dispose', () => { + const a = vi.fn(); + const b = vi.fn(); + const holder = new MutableDisposable(); + holder.value = toDisposable(a); + holder.value = toDisposable(b); + expect(a).toHaveBeenCalledOnce(); + holder.dispose(); + expect(b).toHaveBeenCalledOnce(); + const c = vi.fn(); + holder.value = toDisposable(c); + expect(c).toHaveBeenCalledOnce(); + }); +}); + +describe('tracker', () => { + afterEach(() => setDisposableTracker(null)); + + it('reports undisposed disposables', () => { + const live = new Set(); + setDisposableTracker({ onCreate: (d) => live.add(d), onDispose: (d) => live.delete(d) }); + const d = combineDisposables(toDisposable(() => undefined)); + expect(live.size).toBe(2); + d.dispose(); + expect(live.size).toBe(0); + }); +}); diff --git a/packages/kernel/test/log/log-value.spec.ts b/packages/kernel/test/log/log-value.spec.ts new file mode 100644 index 00000000..188cabd3 --- /dev/null +++ b/packages/kernel/test/log/log-value.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; + +import { formatLogValue, isExpandableLogValue } from '../../src/log/format-log-value'; +import { serializeLogValue } from '../../src/log/serialize-log-value'; + +class Position { + constructor( + public x = 1, + public y = 2, + ) {} +} + +describe('serializeLogValue', () => { + it('keeps primitives and tags what JSON cannot carry', () => { + expect(serializeLogValue('a')).toBe('a'); + expect(serializeLogValue(1.5)).toBe(1.5); + expect(serializeLogValue(null)).toBeNull(); + expect(serializeLogValue(undefined)).toEqual({ type: 'undefined' }); + expect(serializeLogValue(NaN)).toEqual({ type: 'number', text: 'NaN' }); + expect(serializeLogValue(-0)).toEqual({ type: 'number', text: '-0' }); + expect(serializeLogValue(10n)).toEqual({ type: 'bigint', text: '10n' }); + expect(serializeLogValue(function move() {})).toEqual({ type: 'function', text: 'ƒ move()' }); + }); + + it('snapshots objects, class instances, arrays, sets and maps', () => { + expect(serializeLogValue({ a: 1, b: [true, 'x'] })).toEqual({ + type: 'object', + entries: [ + ['a', 1], + ['b', { type: 'array', length: 2, items: [true, 'x'] }], + ], + }); + expect(serializeLogValue(new Position())).toMatchObject({ type: 'object', name: 'Position' }); + expect(serializeLogValue(new Set([1]))).toEqual({ + type: 'array', + name: 'Set', + length: 1, + items: [1], + }); + expect(serializeLogValue(new Map([['k', 1]]))).toEqual({ + type: 'map', + size: 1, + entries: [['k', 1]], + }); + expect(serializeLogValue(new Float32Array([1, 2]))).toMatchObject({ + type: 'array', + name: 'Float32Array', + items: [1, 2], + }); + }); + + it('marks cycles and cuts deep or large values', () => { + const loop: Record = { name: 'loop' }; + loop.self = loop; + expect(serializeLogValue(loop)).toEqual({ + type: 'object', + entries: [ + ['name', 'loop'], + ['self', { type: 'circular' }], + ], + }); + const shared = { v: 1 }; + expect(serializeLogValue([shared, shared])).toMatchObject({ + items: [{ type: 'object' }, { type: 'object' }], + }); + expect(serializeLogValue({ a: { b: { c: 1 } } }, { depth: 2 })).toEqual({ + type: 'object', + entries: [['a', { type: 'object', entries: [['b', { type: 'cut', text: 'Object' }]] }]], + }); + expect(serializeLogValue([1, 2, 3], { entries: 2 })).toEqual({ + type: 'array', + length: 3, + items: [1, 2], + more: 1, + }); + expect(serializeLogValue('abcdef', { text: 3 })).toBe('abc…'); + }); + + it('keeps errors with their stack, and survives throwing getters', () => { + const error = serializeLogValue(new TypeError('bad')); + expect(error).toMatchObject({ type: 'error', name: 'TypeError', message: 'bad' }); + const hostile = { + get boom(): never { + throw new Error('no'); + }, + }; + expect(serializeLogValue(hostile)).toEqual({ + type: 'object', + entries: [['boom', '[threw Error: no]']], + }); + }); +}); + +describe('formatLogValue', () => { + it('prints one line, quoting nested strings only', () => { + expect(formatLogValue('plain')).toBe('plain'); + expect(formatLogValue(serializeLogValue({ a: 'x', b: [1, undefined] }))).toBe( + '{ a: "x", b: [1, undefined] }', + ); + expect(formatLogValue(serializeLogValue(new Position()))).toBe('Position { x: 1, y: 2 }'); + expect(formatLogValue(serializeLogValue(new Map([['k', 1]])))).toBe('Map(1) {"k" => 1}'); + expect(formatLogValue(serializeLogValue([1, 2, 3], { entries: 1 }))).toBe('[1, … 2 more]'); + }); + + it('tells what can be expanded', () => { + expect(isExpandableLogValue('a')).toBe(false); + expect(isExpandableLogValue(serializeLogValue({}))).toBe(false); + expect(isExpandableLogValue(serializeLogValue({ a: 1 }))).toBe(true); + expect(isExpandableLogValue(serializeLogValue(new Error('x')))).toBe(true); + }); +}); diff --git a/packages/kernel/test/log/logger-service.spec.ts b/packages/kernel/test/log/logger-service.spec.ts new file mode 100644 index 00000000..75764be7 --- /dev/null +++ b/packages/kernel/test/log/logger-service.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LogLevel } from '../../src/log/log-level.enum'; +import { LoggerService } from '../../src/log/logger-service'; + +describe('LoggerService', () => { + it('filters by level, buffers and replays', () => { + const logs = new LoggerService(2); + const logger = logs.getLogger('kernel').child('plugins'); + logger.debug('hidden'); + logger.info('a'); + logger.warn('b', 1); + logger.error('c'); + expect(logs.history.map((e) => e.message)).toEqual(['b', 'c']); + + const sink = vi.fn(); + logs.addSink(sink, { replay: true }); + expect(sink).toHaveBeenCalledTimes(2); + expect(sink.mock.calls[0]![0]).toMatchObject({ + source: 'kernel.plugins', + level: LogLevel.Warn, + args: [1], + }); + }); + + it('isolates failing sinks', () => { + const logs = new LoggerService(); + const ok = vi.fn(); + logs.addSink(() => { + throw new Error('x'); + }); + logs.addSink(ok); + logs.getLogger('x').error('boom'); + expect(ok).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/kernel/test/observable/observable.spec.ts b/packages/kernel/test/observable/observable.spec.ts new file mode 100644 index 00000000..2a23e4a7 --- /dev/null +++ b/packages/kernel/test/observable/observable.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Emitter } from '../../src/event/emitter'; +import { + changes, + constant, + derived, + observe, + switchObservable, +} from '../../src/observable/observable'; +import { ObservableValue } from '../../src/observable/observable-value'; + +describe('ObservableValue', () => { + it('follows the svelte store contract', () => { + const value = new ObservableValue(1); + const run = vi.fn(); + const unsubscribe = value.subscribe(run); + value.set(2); + value.set(2); + unsubscribe(); + value.set(3); + expect(run.mock.calls).toEqual([[1], [2]]); + }); + + it('observe can skip the immediate call', () => { + const value = new ObservableValue('a'); + const fn = vi.fn(); + const sub = observe(value, fn, { immediate: false }); + value.update((v) => v + 'b'); + sub.dispose(); + value.set('c'); + expect(fn).toHaveBeenCalledExactlyOnceWith('ab'); + }); + + it('changes() exposes an event', () => { + const value = new ObservableValue(0); + const fn = vi.fn(); + changes(value)(fn); + value.set(1); + expect(fn).toHaveBeenCalledExactlyOnceWith(1); + }); +}); + +describe('derived', () => { + it('computes on demand without subscribers', () => { + const a = new ObservableValue(1); + const double = derived([a], (x) => x * 2); + expect(double.get()).toBe(2); + a.set(5); + expect(double.get()).toBe(10); + expect(a.subscriberCount).toBe(0); + }); + + it('subscribes upstream only while observed', () => { + const a = new ObservableValue(1); + const b = new ObservableValue(2); + const sum = derived([a, b, constant(10)], (x, y, z) => x + y + z); + const run = vi.fn(); + const unsubscribe = sum.subscribe(run); + a.set(3); + /** Unchanged: no notification. */ + b.set(2); + expect(run.mock.calls).toEqual([[13], [15]]); + expect(a.subscriberCount).toBe(1); + unsubscribe(); + unsubscribe(); + expect(a.subscriberCount).toBe(0); + }); + + it('does not notify when the derived value is equal', () => { + const a = new ObservableValue(1); + const parity = derived([a], (x) => x % 2); + const run = vi.fn(); + parity.subscribe(run); + a.set(3); + expect(run).toHaveBeenCalledOnce(); + }); + + it('works with emitters feeding values', () => { + const emitter = new Emitter(); + const value = new ObservableValue(0); + emitter.event((n) => value.set(n)); + const run = vi.fn(); + derived([value], (n) => `#${n}`).subscribe(run); + emitter.fire(4); + expect(run).toHaveBeenLastCalledWith('#4'); + }); +}); + +describe('switchObservable', () => { + it('follows the current inner observable', async () => { + const a = new ObservableValue(1); + const b = new ObservableValue(10); + const outer = new ObservableValue | undefined>(undefined); + const run = vi.fn(); + const unsubscribe = switchObservable(outer, 0).subscribe(run); + outer.set(a); + a.set(2); + outer.set(b); + /** No longer followed. */ + a.set(3); + b.set(11); + unsubscribe(); + b.set(12); + expect(run.mock.calls.map(([v]) => v)).toEqual([0, 1, 2, 10, 11]); + expect(a.subscriberCount + b.subscriberCount).toBe(0); + }); +}); diff --git a/packages/kernel/test/plugin/plugin-host.spec.ts b/packages/kernel/test/plugin/plugin-host.spec.ts new file mode 100644 index 00000000..78cfec29 --- /dev/null +++ b/packages/kernel/test/plugin/plugin-host.spec.ts @@ -0,0 +1,344 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createToken } from '../../src/di/service-token'; +import { defineExtensionPoint } from '../../src/extension/define-extension-point'; +import { + type Disposable, + setDisposableTracker, + toDisposable, +} from '../../src/lifecycle/disposable'; +import { ObservableValue } from '../../src/observable/observable-value'; +import { COMMAND_METADATA } from '../../src/plugin/command-metadata'; +import { CoreServices } from '../../src/plugin/core-services.const'; +import { createCoreContainer } from '../../src/plugin/create-core-container'; +import type { AppInfo } from '../../src/plugin/engine-libs'; +import { PluginActivationError } from '../../src/plugin/plugin-activation.exception'; +import type { PluginContext, PluginModule } from '../../src/plugin/plugin-context.type'; +import { PluginHost } from '../../src/plugin/plugin-host'; +import { type PluginManifestInput, parsePluginManifest } from '../../src/plugin/plugin-manifest'; +import { normalizePluginModule } from '../../src/plugin/plugin-module-loader'; +import type { PluginDescriptor } from '../../src/plugin/plugin-resolution.type'; + +const descriptor = (name: string, extra: Partial = {}): PluginDescriptor => ({ + source: 'bundled', + baseUrl: `https://editor.test/plugins/${name}/`, + manifest: parsePluginManifest({ + type: 'plugin', + name, + version: '1.0.0', + engines: { editor: '^1.0.0' }, + entry: { client: 'index.js' }, + ...extra, + }), +}); + +const Greeting = createToken('test.greeting'); +const Modes = defineExtensionPoint('test.modes'); + +const setup = (modules: Record, options: { apps?: AppInfo[] } = {}) => { + const services = createCoreContainer(); + const apps = new ObservableValue(options.apps ?? []); + if (options.apps) services.provide(CoreServices.EngineLibs, { apps }); + const loads: string[] = []; + const host = new PluginHost({ + services, + editorVersion: '1.0.0', + activationTimeoutMs: 50, + loader: { + load: async (d, generation) => { + loads.push(`${d.manifest.name}#${generation}`); + return modules[d.manifest.name] ?? {}; + }, + }, + }); + return { services, host, loads, apps }; +}; + +describe('PluginHost', () => { + let live: Set; + beforeEach(() => { + live = new Set(); + setDisposableTracker({ onCreate: (d) => live.add(d), onDispose: (d) => live.delete(d) }); + }); + afterEach(() => setDisposableTracker(null)); + + it('activates startup plugins in dependency order and shares services', async () => { + const order: string[] = []; + const { host, services } = setup({ + '@n/b': { + activate: (ctx) => { + order.push('b'); + ctx.provide(Greeting, 'hello'); + }, + }, + '@n/a': { + activate: (ctx) => { + order.push(`a:${ctx.services.get(Greeting)}`); + }, + }, + '@n/c': { activate: () => void order.push('c') }, + }); + await host.start([ + descriptor('@n/a', { dependencies: ['@n/b'] }), + descriptor('@n/b'), + descriptor('@n/c', { optionalDependencies: { '@n/a': '*' } }), + ]); + expect(order).toEqual(['b', 'a:hello', 'c']); + expect(services.get(Greeting)).toBe('hello'); + expect(host.getPlugins().map((p) => [p.name, p.state])).toEqual([ + ['@n/a', 'active'], + ['@n/b', 'active'], + ['@n/c', 'active'], + ]); + }); + + it('exposes static command contributions before lazy activation, then activates on command', async () => { + const activate = vi.fn((ctx: PluginContext) => { + ctx.registerCommand('ecs.addEntity', () => 'added'); + }); + const { host, services } = setup({ '@n/ecs': { activate } }); + await host.start([ + descriptor('@n/ecs', { + activation: ['onCommand:ecs.addEntity'], + contributes: { commands: [{ id: 'ecs.addEntity', title: 'Add entity' }] }, + }), + ]); + const metadata = services.get(CoreServices.Extensions).getValues(COMMAND_METADATA); + expect(metadata).toEqual([{ id: 'ecs.addEntity', title: 'Add entity', owner: '@n/ecs' }]); + expect(activate).not.toHaveBeenCalled(); + await expect(services.get(CoreServices.Commands).execute('ecs.addEntity')).resolves.toBe( + 'added', + ); + expect(activate).toHaveBeenCalledOnce(); + }); + + it('isolates activation failures and timeouts', async () => { + const { host } = setup({ + '@n/broken': { + activate: (ctx) => { + ctx.contribute(Modes, 'leaked'); + throw new Error('boom'); + }, + }, + '@n/slow': { activate: () => new Promise(() => undefined) }, + '@n/fine': { activate: () => undefined }, + }); + await host.start([descriptor('@n/broken'), descriptor('@n/slow'), descriptor('@n/fine')]); + expect(host.getPlugin('@n/broken')).toMatchObject({ state: 'failed' }); + expect(String(host.getPlugin('@n/slow')!.error)).toMatch(/timed out/); + expect(host.getPlugin('@n/fine')!.state).toBe('active'); + await expect(host.activate('@n/broken')).rejects.toThrow(PluginActivationError); + }); + + it('deactivates a module whose activation failed or timed out', async () => { + const stopped: string[] = []; + const { host } = setup({ + '@n/broken': { + activate: () => { + throw new Error('boom'); + }, + deactivate: () => void stopped.push('broken'), + }, + '@n/slow': { + activate: () => new Promise(() => undefined), + deactivate: async () => { + stopped.push('slow'); + throw new Error('stuck'); + }, + }, + }); + await host.start([descriptor('@n/broken'), descriptor('@n/slow')]); + await expect.poll(() => [...stopped].sort()).toEqual(['broken', 'slow']); + host.dispose(); + }); + + it('does not activate unresolved plugins', async () => { + const { host } = setup({}); + await host.start([descriptor('@n/a', { dependencies: ['@n/missing'] })]); + expect(host.getPlugin('@n/a')).toMatchObject({ + state: 'inactive', + status: { kind: 'missing-dependency' }, + }); + await expect(host.activate('@n/a')).rejects.toThrow(/missing dependency/); + }); + + it('deactivates dependents first and disposes everything the plugin registered', async () => { + const events: string[] = []; + const module = (name: string): PluginModule => ({ + activate: (ctx) => { + ctx.contribute(Modes, name); + ctx.subscriptions.add(toDisposable(() => events.push(`dispose:${name}`))); + }, + deactivate: () => void events.push(`deactivate:${name}`), + }); + const { host, services } = setup({ '@n/base': module('base'), '@n/ext': module('ext') }); + await host.start([descriptor('@n/base'), descriptor('@n/ext', { dependencies: ['@n/base'] })]); + const extensions = services.get(CoreServices.Extensions); + expect(extensions.getValues(Modes)).toEqual(['base', 'ext']); + + await host.deactivate('@n/base'); + expect(events).toEqual(['deactivate:ext', 'dispose:ext', 'deactivate:base', 'dispose:base']); + expect(extensions.getValues(Modes)).toEqual([]); + + host.dispose(); + services.dispose(); + expect(live.size).toBe(0); + }); + + it('activates again once a deactivation in progress ends, and deactivates activating dependents', async () => { + let release!: () => void; + const slow = new Promise((resolve) => (release = resolve)); + const states: string[] = []; + const { host } = setup({ + '@n/base': { activate: () => undefined, deactivate: () => slow }, + '@n/ext': { + activate: () => void states.push('ext:activate'), + deactivate: () => void states.push('ext:deactivate'), + }, + }); + await host.start([descriptor('@n/base'), descriptor('@n/ext', { dependencies: ['@n/base'] })]); + const deactivation = host.deactivate('@n/base'); + const activation = host.activate('@n/base'); + release(); + await deactivation; + expect(states).toEqual(['ext:activate', 'ext:deactivate']); + await activation; + expect(host.plugins.get().find((plugin) => plugin.name === '@n/base')?.state).toBe('active'); + host.dispose(); + }); + + it('hot reloads a plugin and re-activates its dependents', async () => { + let version = 1; + const { host, services, loads } = setup({ + '@n/base': { activate: (ctx) => void ctx.provide(Greeting, `v${version}`) }, + '@n/ext': { activate: () => undefined }, + }); + await host.start([descriptor('@n/base'), descriptor('@n/ext', { dependencies: ['@n/base'] })]); + version = 2; + await host.reload('@n/base'); + expect(services.get(Greeting)).toBe('v2'); + expect(host.getPlugin('@n/ext')!.state).toBe('active'); + expect(loads).toEqual(['@n/base#0', '@n/ext#0', '@n/base#1']); + }); + + it('updates the plugin set when a project with its own plugins opens and closes', async () => { + const events: string[] = []; + const module = (name: string): PluginModule => ({ + activate: (ctx) => { + events.push(`+${name}@${ctx.source}`); + ctx.contribute(Modes, name); + }, + deactivate: () => void events.push(`-${name}`), + }); + const { host, services } = setup({ + '@n/base': module('base'), + '@n/tool': module('tool'), + '@n/local': module('local'), + }); + const home = [descriptor('@n/base'), descriptor('@n/tool')]; + const project = (name: string, extra: Partial = {}): PluginDescriptor => ({ + ...descriptor(name, extra), + source: 'project', + baseUrl: `https://editor.test/plugins/project/p1/${name}/`, + }); + await host.start(home); + + await host.update([ + ...home, + project('@n/tool'), + project('@n/local', { dependencies: ['@n/base'] }), + ]); + expect(host.getPlugin('@n/tool')!.descriptor.source).toBe('project'); + expect(host.getPlugin('@n/local')!.state).toBe('active'); + const modes = () => services.get(CoreServices.Extensions).getValues(Modes).toSorted(); + expect(modes()).toEqual(['base', 'local', 'tool']); + + await host.update(home); + expect(host.getPlugin('@n/local')).toBeUndefined(); + expect(host.getPlugin('@n/tool')!.descriptor.source).toBe('bundled'); + expect(modes()).toEqual(['base', 'tool']); + expect(events).toEqual([ + '+base@bundled', + '+tool@bundled', + '-tool', + '+tool@project', + '+local@project', + '-tool', + '-local', + '+tool@bundled', + ]); + }); + + it('computes eligible apps and engine lib context keys', async () => { + let eligible: readonly AppInfo[] = []; + const client: AppInfo = { + id: 'client', + name: 'client', + type: 'client', + engineLibs: { '@nanoforge-dev/ecs': '1.4.2', '@nanoforge-dev/graphics-2d': '1.4.2' }, + }; + const server: AppInfo = { id: 'server', name: 'server', type: 'server', engineLibs: {} }; + const { host, services, apps } = setup( + { '@n/ecs': { activate: (ctx) => void ctx.eligibleApps.subscribe((a) => (eligible = a)) } }, + { apps: [client, server] }, + ); + await host.start([ + descriptor('@n/ecs', { engineLibs: { required: { '@nanoforge-dev/ecs': '^1.4.0' } } }), + ]); + expect(eligible.map((app) => app.id)).toEqual(['client']); + + const context = services.get(CoreServices.ContextKeys); + context.set('activeApp', 'client'); + expect(context.evaluate('plugin.@n/ecs.eligible && engineLib:@nanoforge-dev/graphics-2d')).toBe( + true, + ); + context.set('activeApp', 'server'); + expect(context.evaluate('plugin.@n/ecs.eligible || engineLib:@nanoforge-dev/ecs')).toBe(false); + + apps.set([client, { ...server, engineLibs: { '@nanoforge-dev/ecs': '1.5.0' } }]); + expect(eligible.map((app) => app.id)).toEqual(['client', 'server']); + expect(context.evaluate('plugin.@n/ecs.eligible')).toBe(true); + }); +}); + +describe('missingEngineLibs', () => { + it('handles installed versions, ranges and unknown specs', async () => { + const { missingEngineLibs } = await import('../../src/plugin/engine-libs'); + const manifest = parsePluginManifest({ + type: 'plugin', + name: '@n/ecs', + version: '1.0.0', + engines: { editor: '*' }, + entry: { client: 'index.js' }, + engineLibs: { + required: { '@nanoforge-dev/ecs': '^1.4.0', '@nanoforge-dev/input': '^1.0.0' }, + }, + }); + const app = (engineLibs: Record): AppInfo => ({ + id: 'a', + name: 'a', + type: 'client', + engineLibs, + }); + expect( + missingEngineLibs( + manifest, + app({ '@nanoforge-dev/ecs': 'workspace:*', '@nanoforge-dev/input': '^1.2.0' }), + ), + ).toEqual([]); + expect(missingEngineLibs(manifest, app({ '@nanoforge-dev/ecs': '1.3.0' }))).toEqual([ + { name: '@nanoforge-dev/ecs', range: '^1.4.0', installed: '1.3.0' }, + { name: '@nanoforge-dev/input', range: '^1.0.0' }, + ]); + }); +}); + +describe('normalizePluginModule', () => { + it('accepts named and default exports', async () => { + const activate = () => undefined; + expect(normalizePluginModule({ activate }).activate).toBe(activate); + expect(normalizePluginModule({ default: { activate } }).activate).toBe(activate); + expect(normalizePluginModule({ default: 'nope' })).toEqual({}); + expect(normalizePluginModule(undefined)).toEqual({}); + }); +}); diff --git a/packages/kernel/test/plugin/plugin-manifest-schema.spec.ts b/packages/kernel/test/plugin/plugin-manifest-schema.spec.ts new file mode 100644 index 00000000..27c83486 --- /dev/null +++ b/packages/kernel/test/plugin/plugin-manifest-schema.spec.ts @@ -0,0 +1,12 @@ +/// +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +import { pluginManifestJsonSchema } from '../../src/plugin/plugin-manifest'; + +describe('plugin JSON schema', () => { + it('is up to date (run `pnpm generate:schema` in packages/kernel)', () => { + const file = new URL('../../schemas/plugin.schema.json', import.meta.url); + expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(pluginManifestJsonSchema()); + }); +}); diff --git a/packages/kernel/test/plugin/plugin-manifest.spec.ts b/packages/kernel/test/plugin/plugin-manifest.spec.ts new file mode 100644 index 00000000..09fdf060 --- /dev/null +++ b/packages/kernel/test/plugin/plugin-manifest.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { parsePluginManifest } from '../../src/plugin/plugin-manifest'; +import { PluginManifestError } from '../../src/plugin/plugin-manifest.exception'; + +const base = { + type: 'plugin', + name: '@nanoforge/ecs', + version: '1.0.0', + engines: { editor: '^1.0.0' }, + entry: { client: 'dist/index.js' }, +}; + +describe('parsePluginManifest', () => { + it('applies defaults', () => { + const manifest = parsePluginManifest(base); + expect(manifest).toMatchObject({ + activation: ['onStartup'], + dependencies: {}, + engineLibs: { required: {}, optional: {} }, + contributes: { commands: [] }, + tags: [], + }); + }); + + it('accepts CLI-style dependency arrays and keeps unknown contributions', () => { + const manifest = parsePluginManifest({ + ...base, + dependencies: ['@nanoforge/viewport'], + optionalDependencies: { '@nanoforge/graphics-gizmos': '^2.0.0' }, + engineLibs: { required: { '@nanoforge-dev/ecs': '^1.4.0' } }, + contributes: { commands: [{ id: 'ecs.addEntity', title: 'Add entity' }], widgets: [{}] }, + }); + expect(manifest.dependencies).toEqual({ '@nanoforge/viewport': '*' }); + expect(manifest.engineLibs).toEqual({ + required: { '@nanoforge-dev/ecs': '^1.4.0' }, + optional: {}, + }); + expect(manifest.contributes.widgets).toEqual([{}]); + }); + + it('reports every issue with its path', () => { + const invalid = { + ...base, + name: 'NoScope', + version: 'one', + engines: { editor: 'latest!' }, + entry: { client: '../escape.js' }, + activation: ['whenever'], + }; + try { + parsePluginManifest(invalid, 'plugins/bad'); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(PluginManifestError); + const paths = (error as PluginManifestError).issues.map((issue) => issue.path); + expect(paths).toEqual( + expect.arrayContaining([ + 'name', + 'version', + 'engines.editor', + 'entry.client', + 'activation.0', + ]), + ); + expect((error as Error).message).toContain('plugins/bad'); + } + }); + + it('requires at least one entry', () => { + expect(() => parsePluginManifest({ ...base, entry: {} })).toThrow(/at least one/); + }); +}); diff --git a/packages/kernel/test/plugin/resolve-plugins.spec.ts b/packages/kernel/test/plugin/resolve-plugins.spec.ts new file mode 100644 index 00000000..4913f4f9 --- /dev/null +++ b/packages/kernel/test/plugin/resolve-plugins.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; + +import { type PluginManifestInput, parsePluginManifest } from '../../src/plugin/plugin-manifest'; +import type { PluginDescriptor } from '../../src/plugin/plugin-resolution.type'; +import type { PluginSourceKind } from '../../src/plugin/plugin-source.enum'; +import { resolvePlugins } from '../../src/plugin/resolve-plugins'; + +const plugin = ( + name: string, + extra: Partial = {}, + source: PluginSourceKind = 'bundled', +): PluginDescriptor => ({ + source, + baseUrl: `/plugins/${name}/`, + manifest: parsePluginManifest({ + type: 'plugin', + name, + version: '1.0.0', + engines: { editor: '^1.0.0' }, + entry: { client: 'index.js' }, + ...extra, + }), +}); + +const resolve = (descriptors: PluginDescriptor[], disabled: string[] = []) => + resolvePlugins(descriptors, { + editorVersion: '1.2.0', + runtimeVersions: { svelte: '5.57.1' }, + disabled: new Set(disabled), + }); + +const statuses = (resolution: ReturnType) => + Object.fromEntries([...resolution.plugins].map(([name, p]) => [name, p.status.kind])); + +const order = (resolution: ReturnType) => + resolution.order.map((d) => d.manifest.name); + +describe('resolvePlugins', () => { + it('orders dependencies first, deterministically', () => { + const resolution = resolve([ + plugin('@n/ecs', { dependencies: ['@n/viewport'] }), + plugin('@n/viewport'), + plugin('@n/console'), + plugin('@n/gizmos', { dependencies: { '@n/ecs': '^1.0.0' } }), + ]); + expect(order(resolution)).toEqual(['@n/console', '@n/viewport', '@n/ecs', '@n/gizmos']); + }); + + it('uses valid optional dependencies for ordering only', () => { + const resolution = resolve([ + plugin('@n/a', { optionalDependencies: { '@n/b': '^1.0.0', '@n/missing': '*' } }), + plugin('@n/b'), + ]); + expect(statuses(resolution)).toEqual({ '@n/a': 'ok', '@n/b': 'ok' }); + expect(order(resolution)).toEqual(['@n/b', '@n/a']); + }); + + it('reports missing, mismatched and failed dependencies', () => { + const resolution = resolve([ + plugin('@n/a', { dependencies: ['@n/missing'] }), + plugin('@n/b', { dependencies: { '@n/c': '^2.0.0' } }), + plugin('@n/c'), + plugin('@n/d', { dependencies: ['@n/a'] }), + plugin('@n/e', { dependencies: ['@n/d'] }), + ]); + expect(statuses(resolution)).toEqual({ + '@n/a': 'missing-dependency', + '@n/b': 'dependency-version', + '@n/c': 'ok', + '@n/d': 'dependency-failed', + '@n/e': 'dependency-failed', + }); + expect(order(resolution)).toEqual(['@n/c']); + }); + + it('detects cycles and fails their dependents', () => { + const resolution = resolve([ + plugin('@n/a', { dependencies: ['@n/b'] }), + plugin('@n/b', { dependencies: ['@n/a'] }), + plugin('@n/c', { dependencies: ['@n/a'] }), + ]); + expect(resolution.plugins.get('@n/a')!.status).toEqual({ + kind: 'cycle', + cycle: ['@n/a', '@n/b'], + }); + expect(statuses(resolution)['@n/c']).toBe('dependency-failed'); + }); + + it('checks editor and runtime compatibility', () => { + const resolution = resolve([ + plugin('@n/old', { engines: { editor: '^0.1.0' } }), + plugin('@n/newer-svelte', { build: { svelte: '5.60.0' } }), + plugin('@n/svelte6', { build: { svelte: '6.0.0' } }), + plugin('@n/fine', { build: { svelte: '5.40.0' } }), + ]); + expect(statuses(resolution)).toEqual({ + '@n/old': 'incompatible-editor', + '@n/newer-svelte': 'incompatible-runtime', + '@n/svelte6': 'incompatible-runtime', + '@n/fine': 'ok', + }); + }); + + it('lets dev plugins shadow installed and bundled ones', () => { + const resolution = resolve([ + plugin('@n/ecs', {}, 'dev'), + plugin('@n/ecs', {}, 'bundled'), + plugin('@n/ecs', {}, 'installed'), + ]); + expect(resolution.plugins.get('@n/ecs')!.descriptor.source).toBe('dev'); + expect(resolution.shadowed.map((p) => [p.descriptor.source, p.status])).toEqual([ + ['bundled', { kind: 'shadowed', by: 'installed' }], + ['installed', { kind: 'shadowed', by: 'dev' }], + ]); + }); + + it('disables plugins and their dependents', () => { + const resolution = resolve( + [plugin('@n/a'), plugin('@n/b', { dependencies: ['@n/a'] })], + ['@n/a'], + ); + expect(statuses(resolution)).toEqual({ '@n/a': 'disabled', '@n/b': 'dependency-failed' }); + }); +}); diff --git a/packages/kernel/test/plugin/shared-module-registry.spec.ts b/packages/kernel/test/plugin/shared-module-registry.spec.ts new file mode 100644 index 00000000..f505f8c9 --- /dev/null +++ b/packages/kernel/test/plugin/shared-module-registry.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { SHARED_GLOBAL, SharedModuleRegistry } from '../../src/plugin/shared-module-registry'; + +describe('SharedModuleRegistry', () => { + it('resolves registered modules and rejects unknown ones', () => { + const svelte = { mount: () => undefined }; + const registry = new SharedModuleRegistry().register('svelte', svelte); + + expect(registry.require('svelte')).toBe(svelte); + expect(() => registry.require('svelte/store')).toThrow(/not provided/); + expect(() => registry.register('svelte', {})).toThrow(/already registered/); + }); + + it('installs a lookup scope on the target and removes it', () => { + const target: Record = {}; + const registry = new SharedModuleRegistry().register('svelte', { a: 1 }); + const uninstall = registry.install(target); + const scope = target[SHARED_GLOBAL] as { require(id: string): unknown }; + + expect(scope.require('svelte')).toEqual({ a: 1 }); + expect(() => new SharedModuleRegistry().install(target)).toThrow(/already installed/); + uninstall(); + expect(target[SHARED_GLOBAL]).toBeUndefined(); + }); +}); diff --git a/packages/kernel/tsconfig.json b/packages/kernel/tsconfig.json new file mode 100644 index 00000000..f2fa7f24 --- /dev/null +++ b/packages/kernel/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/layout/.prettierignore b/packages/layout/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/layout/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/layout/LICENSE b/packages/layout/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/layout/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/layout/README.md b/packages/layout/README.md new file mode 100644 index 00000000..9d07ae74 --- /dev/null +++ b/packages/layout/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-layout` is an internal package of the NanoForge Editor. + +Layout model: main screens, dock slots after IntelliJ's tool windows, floating docks. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/layout +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/layout/bench/layout.bench.ts b/packages/layout/bench/layout.bench.ts new file mode 100644 index 00000000..2d58b69b --- /dev/null +++ b/packages/layout/bench/layout.bench.ts @@ -0,0 +1,71 @@ +import { bench, describe } from 'vitest'; + +import { + type Layout, + type LayoutOp, + SLOT_IDS, + type WidgetPlacement, + composeDefaultLayout, + deserializeLayout, + reduce, + serializeLayout, +} from '../src'; + +/** + * The layout model under a load well above a real editor's (about 20 widgets): 120 docks and + * 8 screens. Every drag step, tab click and resize is one `reduce`, so these are the costs that + * could make the workbench feel slow. Run with `pnpm --filter @nanoforge-dev/editor-layout bench`. + */ +const widgets: WidgetPlacement[] = [ + ...Array.from({ length: 8 }, (_, i) => ({ + id: `screen.${i}`, + kind: 'screen' as const, + order: i, + })), + ...Array.from({ length: 120 }, (_, i) => ({ + id: `dock.${i}`, + kind: 'dock' as const, + defaultSlot: SLOT_IDS[i % SLOT_IDS.length]!, + order: i, + })), +]; +const layout: Layout = composeDefaultLayout(widgets); +const serialized = serializeLayout(layout); +const apply = (start: Layout, ops: readonly LayoutOp[]) => + ops.reduce((current, op) => reduce(current, op).layout, start); + +const resizes: LayoutOp[] = Array.from({ length: 200 }, (_, i) => ({ + type: 'resizeSlot', + slot: 'leftTop', + size: 200 + (i % 100), +})); +const activations: LayoutOp[] = Array.from({ length: 120 }, (_, i) => ({ + type: 'activate', + instanceId: `dock.${i}`, +})); +const moves: LayoutOp[] = Array.from({ length: 120 }, (_, i) => ({ + type: 'move', + instanceId: `dock.${i}`, + location: { kind: 'slot', slot: SLOT_IDS[(i + 1) % SLOT_IDS.length]!, index: 0 }, +})); + +describe('layout model, 128 widgets', () => { + bench('compose the default layout', () => { + composeDefaultLayout(widgets); + }); + bench('200 splitter resizes', () => { + apply(layout, resizes); + }); + bench('120 tab activations', () => { + apply(layout, activations); + }); + bench('120 moves between slots', () => { + apply(layout, moves); + }); + bench('serialize and read back', () => { + deserializeLayout(JSON.parse(serializeLayout(layout))); + }); + bench('read a saved layout', () => { + deserializeLayout(JSON.parse(serialized)); + }); +}); diff --git a/packages/layout/package.json b/packages/layout/package.json new file mode 100644 index 00000000..aa558df5 --- /dev/null +++ b/packages/layout/package.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-layout", + "version": "0.0.0", + "description": "NanoForge Editor - Layout model: main screens, dock slots, floating docks (pure data + operations)", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/layout" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty .", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "bench": "vitest bench --run bench" + }, + "dependencies": { + "zod": "catalog:libs" + }, + "devDependencies": { + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/layout/src/default/default-layout.ts b/packages/layout/src/default/default-layout.ts new file mode 100644 index 00000000..3b408fd7 --- /dev/null +++ b/packages/layout/src/default/default-layout.ts @@ -0,0 +1,74 @@ +import { emptyLayout } from '../model/empty-layout'; +import type { Layout } from '../model/layout.type'; +import type { SlotId } from '../model/slot-id.enum'; +import type { LayoutOp } from '../operations/layout-op.type'; +import { reduce } from '../operations/reduce'; + +export interface WidgetPlacement { + readonly id: string; + readonly kind: 'dock' | 'screen'; + /** Where a dock widget goes by default (ignored for screens). */ + readonly defaultSlot?: SlotId; + /** Order in its slot (docks) or in the screen switcher (screens). */ + readonly order?: number; + /** Docks: shown in the default layout (default true). */ + readonly openByDefault?: boolean; +} + +/** The layout used when nothing is saved: screens in order, docks in their default slots. */ +export const composeDefaultLayout = (widgets: readonly WidgetPlacement[]): Layout => { + const byOrder = (a: WidgetPlacement, b: WidgetPlacement) => + (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id); + const screens = widgets.filter((widget) => widget.kind === 'screen').sort(byOrder); + const docks = widgets + .filter((widget) => widget.kind === 'dock' && widget.openByDefault !== false) + .sort(byOrder); + const ops: LayoutOp[] = [{ type: 'setScreens', screens: screens.map((screen) => screen.id) }]; + const counts: Partial> = {}; + for (const dock of docks) { + const slot = dock.defaultSlot ?? 'leftTop'; + const index = counts[slot] ?? 0; + counts[slot] = index + 1; + ops.push({ + type: 'add', + ref: { instanceId: dock.id, widgetId: dock.id }, + location: { kind: 'slot', slot, index }, + activate: index === 0, + }); + } + const layout = reduce(emptyLayout(), { type: 'batch', ops }).layout; + return { ...layout, known: widgets.map((widget) => widget.id) }; +}; + +/** Adds screens and default docks that a saved layout does not know yet (new plugins). */ +export const reconcileLayout = ( + layout: Layout, + widgets: readonly WidgetPlacement[], + known: ReadonlySet, +): Layout => { + const byOrder = (a: WidgetPlacement, b: WidgetPlacement) => + (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id); + const screens = widgets + .filter((widget) => widget.kind === 'screen') + .sort(byOrder) + .map((widget) => widget.id); + const merged = [ + ...layout.screens.filter((screen) => screens.includes(screen)), + ...screens.filter((screen) => !layout.screens.includes(screen)), + ]; + const ops: LayoutOp[] = [{ type: 'setScreens', screens: merged }]; + let next = reduce(layout, { type: 'batch', ops }).layout; + const seen = new Set([...known, ...layout.known]); + for (const dock of widgets.filter( + (widget) => widget.kind === 'dock' && !seen.has(widget.id) && widget.openByDefault !== false, + )) { + const slot = dock.defaultSlot ?? 'leftTop'; + next = reduce(next, { + type: 'add', + ref: { instanceId: dock.id, widgetId: dock.id }, + location: { kind: 'slot', slot, index: next.slots[slot].stack.tabs.length }, + activate: false, + }).layout; + } + return { ...next, known: [...new Set([...seen, ...widgets.map((widget) => widget.id)])] }; +}; diff --git a/packages/layout/src/default/index.ts b/packages/layout/src/default/index.ts new file mode 100644 index 00000000..473078d6 --- /dev/null +++ b/packages/layout/src/default/index.ts @@ -0,0 +1 @@ +export * from './default-layout'; diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts new file mode 100644 index 00000000..9ab9aef8 --- /dev/null +++ b/packages/layout/src/index.ts @@ -0,0 +1,4 @@ +export * from './default'; +export * from './model'; +export * from './operations'; +export * from './serialization'; diff --git a/packages/layout/src/model/empty-layout.ts b/packages/layout/src/model/empty-layout.ts new file mode 100644 index 00000000..18700a40 --- /dev/null +++ b/packages/layout/src/model/empty-layout.ts @@ -0,0 +1,23 @@ +import { LAYOUT_VERSION } from './layout.const'; +import type { Layout, TabStack } from './layout.type'; + +const emptyStack: TabStack = { tabs: [], active: null }; + +export const emptyLayout = (): Layout => ({ + version: LAYOUT_VERSION, + screens: [], + activeScreen: null, + slots: { + leftTop: { visible: true, size: 260, stack: emptyStack }, + leftBottom: { visible: true, size: 260, stack: emptyStack }, + rightTop: { visible: true, size: 300, stack: emptyStack }, + rightBottom: { visible: true, size: 300, stack: emptyStack }, + bottom: { visible: true, size: 220, stack: emptyStack }, + bottomRight: { visible: true, size: 220, stack: emptyStack }, + }, + split: { left: 0.55, right: 0.55, bottom: 0.5 }, + floats: [], + maximized: null, + screenOverrides: {}, + known: [], +}); diff --git a/packages/layout/src/model/index.ts b/packages/layout/src/model/index.ts new file mode 100644 index 00000000..e4230941 --- /dev/null +++ b/packages/layout/src/model/index.ts @@ -0,0 +1,5 @@ +export * from './slot-id.enum'; +export * from './layout.type'; +export * from './layout.const'; +export * from './empty-layout'; +export * from './layout-queries'; diff --git a/packages/layout/src/model/layout-queries.ts b/packages/layout/src/model/layout-queries.ts new file mode 100644 index 00000000..ad2b750b --- /dev/null +++ b/packages/layout/src/model/layout-queries.ts @@ -0,0 +1,45 @@ +import type { Layout, Location, WidgetRef } from './layout.type'; +import { SLOT_IDS, type SlotId } from './slot-id.enum'; + +export const findWidget = ( + layout: Layout, + instanceId: string, +): { location: Location; ref: WidgetRef; active: boolean } | undefined => { + for (const slot of SLOT_IDS) { + const stack = layout.slots[slot].stack; + const index = stack.tabs.findIndex((tab) => tab.instanceId === instanceId); + if (index >= 0) { + return { + location: { kind: 'slot', slot, index }, + ref: stack.tabs[index]!, + active: stack.active === instanceId, + }; + } + } + for (const float of layout.floats) { + const index = float.stack.tabs.findIndex((tab) => tab.instanceId === instanceId); + if (index >= 0) { + const rect = { x: float.x, y: float.y, width: float.width, height: float.height }; + return { + location: { kind: 'float', floatId: float.id, index, rect }, + ref: float.stack.tabs[index]!, + active: float.stack.active === instanceId, + }; + } + } + return undefined; +}; + +/** Every widget instance in the layout. */ +export const allWidgets = (layout: Layout): WidgetRef[] => [ + ...SLOT_IDS.flatMap((slot) => layout.slots[slot].stack.tabs), + ...layout.floats.flatMap((float) => float.stack.tabs), +]; + +/** Whether a slot is shown on the active screen (overrides applied). */ +export const isSlotVisible = (layout: Layout, slot: SlotId): boolean => { + const override = layout.activeScreen + ? layout.screenOverrides[layout.activeScreen]?.[slot] + : undefined; + return override ?? layout.slots[slot].visible; +}; diff --git a/packages/layout/src/model/layout.const.ts b/packages/layout/src/model/layout.const.ts new file mode 100644 index 00000000..1201a67d --- /dev/null +++ b/packages/layout/src/model/layout.const.ts @@ -0,0 +1,4 @@ +export const LAYOUT_VERSION = 1; + +export const MIN_SLOT_SIZE = 120; +export const MIN_FLOAT = { width: 200, height: 120 } as const; diff --git a/packages/layout/src/model/layout.type.ts b/packages/layout/src/model/layout.type.ts new file mode 100644 index 00000000..0f116d82 --- /dev/null +++ b/packages/layout/src/model/layout.type.ts @@ -0,0 +1,66 @@ +import { type LAYOUT_VERSION } from './layout.const'; +import type { SlotId } from './slot-id.enum'; + +export interface WidgetRef { + /** Unique in the layout (singleton widgets use their widget id). */ + readonly instanceId: string; + readonly widgetId: string; +} + +export interface TabStack { + readonly tabs: readonly WidgetRef[]; + readonly active: string | null; +} + +export interface SlotState { + readonly visible: boolean; + /** + * Width (side slots) or height (bottom slots) in px. A column's two side slots share their + * width, the two bottom slots their height (`leftTop`, `rightTop` and `bottom` hold it). + */ + readonly size: number; + readonly stack: TabStack; +} + +export interface Rect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface FloatWindow extends Rect { + readonly id: string; + readonly z: number; + readonly stack: TabStack; +} + +export interface Layout { + readonly version: typeof LAYOUT_VERSION; + /** Main screens in switcher order (screen widget ids). */ + readonly screens: readonly string[]; + readonly activeScreen: string | null; + readonly slots: Readonly>; + /** Share of the left/right column given to its top slot, of the bottom row to `bottom` (0-1). */ + readonly split: { readonly left: number; readonly right: number; readonly bottom: number }; + readonly floats: readonly FloatWindow[]; + /** Widget instance shown over the whole workspace, if any. */ + readonly maximized: string | null; + /** Per screen slot visibility, e.g. hide right docks on the code screen. */ + readonly screenOverrides: Readonly>>>; + /** + * Widgets this layout has already placed or offered, closed ones included: a new plugin's docks + * are added once, and a dock the user closed does not come back on the next load. + */ + readonly known: readonly string[]; +} + +/** Where a widget is: a dock slot or a floating window, at a tab index. */ +export type Location = + | { readonly kind: 'slot'; readonly slot: SlotId; readonly index: number } + | { + readonly kind: 'float'; + readonly floatId: string; + readonly index: number; + readonly rect?: Rect; + }; diff --git a/packages/layout/src/model/slot-id.enum.ts b/packages/layout/src/model/slot-id.enum.ts new file mode 100644 index 00000000..584e1f22 --- /dev/null +++ b/packages/layout/src/model/slot-id.enum.ts @@ -0,0 +1,13 @@ +/** + * Dock areas around the main screen, after IntelliJ's tool windows: two on each side, and the + * bottom split in two (`bottom` is its left part). + */ +export const SLOT_IDS = [ + 'leftTop', + 'leftBottom', + 'rightTop', + 'rightBottom', + 'bottom', + 'bottomRight', +] as const; +export type SlotId = (typeof SLOT_IDS)[number]; diff --git a/packages/layout/src/operations/index.ts b/packages/layout/src/operations/index.ts new file mode 100644 index 00000000..e0605d5f --- /dev/null +++ b/packages/layout/src/operations/index.ts @@ -0,0 +1,4 @@ +export * from './layout-op.type'; +export * from './layout.exception'; +export * from './reduce'; +export * from './layout-ops'; diff --git a/packages/layout/src/operations/layout-op.type.ts b/packages/layout/src/operations/layout-op.type.ts new file mode 100644 index 00000000..06347e78 --- /dev/null +++ b/packages/layout/src/operations/layout-op.type.ts @@ -0,0 +1,44 @@ +import type { Layout, Location, Rect, WidgetRef } from '../model/layout.type'; +import type { SlotId } from '../model/slot-id.enum'; + +/** Every layout change is one of these serializable operations. */ +export type LayoutOp = + | { + readonly type: 'add'; + readonly ref: WidgetRef; + readonly location: Location; + readonly activate?: boolean; + } + | { readonly type: 'remove'; readonly instanceId: string } + | { + readonly type: 'move'; + readonly instanceId: string; + readonly location: Location; + readonly activate?: boolean; + } + | { readonly type: 'activate'; readonly instanceId: string } + | { readonly type: 'resizeSlot'; readonly slot: SlotId; readonly size: number } + | { + readonly type: 'setSplit'; + readonly side: 'left' | 'right' | 'bottom'; + readonly ratio: number; + } + | { readonly type: 'toggleSlot'; readonly slot: SlotId; readonly visible: boolean } + | { readonly type: 'setFloatRect'; readonly floatId: string; readonly rect: Rect } + | { readonly type: 'focusFloat'; readonly floatId: string; readonly z: number } + | { readonly type: 'switchScreen'; readonly screen: string | null } + | { readonly type: 'setScreens'; readonly screens: readonly string[] } + | { readonly type: 'maximize'; readonly instanceId: string | null } + | { + readonly type: 'setScreenOverride'; + readonly screen: string; + readonly slot: SlotId; + readonly visible: boolean | null; + } + | { readonly type: 'batch'; readonly ops: readonly LayoutOp[] }; + +export interface Reduction { + readonly layout: Layout; + /** Applying it to `layout` gives back the previous layout. */ + readonly inverse: LayoutOp; +} diff --git a/packages/layout/src/operations/layout-ops.ts b/packages/layout/src/operations/layout-ops.ts new file mode 100644 index 00000000..02f2e7ef --- /dev/null +++ b/packages/layout/src/operations/layout-ops.ts @@ -0,0 +1,31 @@ +import type { Layout } from '../model/layout.type'; +import type { SlotId } from '../model/slot-id.enum'; +import type { LayoutOp } from './layout-op.type'; +import { LayoutError } from './layout.exception'; + +/** Moves every tab of a floating window into a slot (one undoable step). */ +export const dockFloatOp = (layout: Layout, floatId: string, slot: SlotId): LayoutOp => { + const float = layout.floats.find((candidate) => candidate.id === floatId); + if (!float) throw new LayoutError(`Floating window ${floatId} does not exist`); + const start = layout.slots[slot].stack.tabs.length; + return { + type: 'batch', + ops: float.stack.tabs.map((tab, index) => ({ + type: 'move' as const, + instanceId: tab.instanceId, + location: { kind: 'slot' as const, slot, index: start + index }, + activate: tab.instanceId === float.stack.active, + })), + }; +}; + +/** + * Shows or hides a slot on the active screen: changes the screen's override when it has one + * for that slot, the slot otherwise. + */ +export const showSlotOp = (layout: Layout, slot: SlotId, visible: boolean): LayoutOp => { + const screen = layout.activeScreen; + return screen && layout.screenOverrides[screen]?.[slot] !== undefined + ? { type: 'setScreenOverride', screen, slot, visible } + : { type: 'toggleSlot', slot, visible }; +}; diff --git a/packages/layout/src/operations/layout.exception.ts b/packages/layout/src/operations/layout.exception.ts new file mode 100644 index 00000000..22435ab9 --- /dev/null +++ b/packages/layout/src/operations/layout.exception.ts @@ -0,0 +1,6 @@ +export class LayoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'LayoutError'; + } +} diff --git a/packages/layout/src/operations/reduce.ts b/packages/layout/src/operations/reduce.ts new file mode 100644 index 00000000..f6a8b75b --- /dev/null +++ b/packages/layout/src/operations/reduce.ts @@ -0,0 +1,342 @@ +import { findWidget } from '../model/layout-queries'; +import { MIN_FLOAT, MIN_SLOT_SIZE } from '../model/layout.const'; +import type { + FloatWindow, + Layout, + Location, + Rect, + TabStack, + WidgetRef, +} from '../model/layout.type'; +import type { SlotId } from '../model/slot-id.enum'; +import type { LayoutOp, Reduction } from './layout-op.type'; +import { LayoutError } from './layout.exception'; + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +const insertTab = (stack: TabStack, ref: WidgetRef, index: number, activate: boolean): TabStack => { + const tabs = [...stack.tabs]; + tabs.splice(clamp(index, 0, tabs.length), 0, ref); + return { tabs, active: activate || !stack.active ? ref.instanceId : stack.active }; +}; + +const removeTab = (stack: TabStack, instanceId: string): TabStack => { + const index = stack.tabs.findIndex((tab) => tab.instanceId === instanceId); + const tabs = stack.tabs.filter((tab) => tab.instanceId !== instanceId); + const active = + stack.active === instanceId + ? (tabs[Math.min(index, tabs.length - 1)]?.instanceId ?? null) + : stack.active; + return { tabs, active }; +}; + +const updateSlot = ( + layout: Layout, + slot: SlotId, + update: (stack: TabStack) => TabStack, +): Layout => ({ + ...layout, + slots: { + ...layout.slots, + [slot]: { ...layout.slots[slot], stack: update(layout.slots[slot].stack) }, + }, +}); + +/** Active tab of the stack a location points to (null for a float that does not exist yet). */ +const activeAt = (layout: Layout, location: Location): string | null => + location.kind === 'slot' + ? layout.slots[location.slot].stack.active + : (layout.floats.find((float) => float.id === location.floatId)?.stack.active ?? null); + +/** Inverse followed by re-activating the tabs that were active in the touched stacks. */ +const withActivation = ( + inverse: LayoutOp, + previousActives: readonly (string | null)[], +): LayoutOp => { + const activations = [...new Set(previousActives)] + .filter((id): id is string => id !== null) + .map((instanceId): LayoutOp => ({ type: 'activate', instanceId })); + return activations.length ? { type: 'batch', ops: [inverse, ...activations] } : inverse; +}; + +const nextZ = (layout: Layout) => Math.max(0, ...layout.floats.map((float) => float.z)) + 1; + +/** Places a widget at a location; a missing float is created from `location.rect`. */ +const place = (layout: Layout, ref: WidgetRef, location: Location, activate: boolean): Layout => { + if (location.kind === 'slot') { + return updateSlot(layout, location.slot, (stack) => + insertTab(stack, ref, location.index, activate), + ); + } + const existing = layout.floats.find((float) => float.id === location.floatId); + if (existing) { + return { + ...layout, + floats: layout.floats.map((float) => + float.id === location.floatId + ? { ...float, stack: insertTab(float.stack, ref, location.index, activate) } + : float, + ), + }; + } + if (!location.rect) throw new LayoutError(`Floating window ${location.floatId} does not exist`); + const float: FloatWindow = { + id: location.floatId, + ...normalizeRect(location.rect), + z: nextZ(layout), + stack: { tabs: [ref], active: ref.instanceId }, + }; + const floats = [...layout.floats, float].sort((a, b) => a.id.localeCompare(b.id)); + return { ...layout, floats }; +}; + +/** Removes a widget; an emptied float disappears. */ +const unplace = ( + layout: Layout, + instanceId: string, + options: { keepMaximized?: boolean } = {}, +): Layout => { + const found = findWidget(layout, instanceId); + if (!found) throw new LayoutError(`No widget ${instanceId} in the layout`); + let next: Layout; + if (found.location.kind === 'slot') { + next = updateSlot(layout, found.location.slot, (stack) => removeTab(stack, instanceId)); + } else { + const floatId = found.location.floatId; + next = { + ...layout, + floats: layout.floats + .map((float) => + float.id === floatId ? { ...float, stack: removeTab(float.stack, instanceId) } : float, + ) + .filter((float) => float.stack.tabs.length > 0), + }; + } + return next.maximized === instanceId && !options.keepMaximized + ? { ...next, maximized: null } + : next; +}; + +const normalizeRect = (rect: Rect): Rect => ({ + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(Math.max(MIN_FLOAT.width, rect.width)), + height: Math.round(Math.max(MIN_FLOAT.height, rect.height)), +}); + +/** + * Applies an operation. Pure: returns the new layout and the operation undoing it. + * Throws `LayoutError` for operations that do not apply to this layout. + */ +export const reduce = (layout: Layout, op: LayoutOp): Reduction => { + switch (op.type) { + case 'add': { + if (findWidget(layout, op.ref.instanceId)) + throw new LayoutError(`${op.ref.instanceId} is already in the layout`); + return { + layout: place(layout, op.ref, op.location, op.activate ?? true), + inverse: withActivation({ type: 'remove', instanceId: op.ref.instanceId }, [ + activeAt(layout, op.location), + ]), + }; + } + case 'remove': { + const found = findWidget(layout, op.instanceId); + if (!found) throw new LayoutError(`No widget ${op.instanceId} in the layout`); + const maximized = layout.maximized === op.instanceId; + const inverse: LayoutOp = { + type: 'add', + ref: found.ref, + location: found.location, + activate: found.active, + }; + return { + layout: unplace(layout, op.instanceId), + inverse: maximized + ? { type: 'batch', ops: [inverse, { type: 'maximize', instanceId: op.instanceId }] } + : inverse, + }; + } + case 'move': { + const found = findWidget(layout, op.instanceId); + if (!found) throw new LayoutError(`No widget ${op.instanceId} in the layout`); + const removed = unplace(layout, op.instanceId, { keepMaximized: true }); + const requested = op.location; + const target: Location = + requested.kind === 'float' && + !requested.rect && + found.location.kind === 'float' && + found.location.floatId === requested.floatId && + !removed.floats.some((float) => float.id === requested.floatId) + ? { ...requested, ...(found.location.rect && { rect: found.location.rect }) } + : requested; + return { + layout: place(removed, found.ref, target, op.activate ?? true), + inverse: withActivation( + { + type: 'move', + instanceId: op.instanceId, + location: found.location, + activate: found.active, + }, + [activeAt(layout, op.location), activeAt(layout, found.location)], + ), + }; + } + case 'activate': { + const found = findWidget(layout, op.instanceId); + if (!found) throw new LayoutError(`No widget ${op.instanceId} in the layout`); + const previousActive = + found.location.kind === 'slot' + ? layout.slots[found.location.slot].stack.active + : layout.floats.find( + (float) => float.id === (found.location as { floatId: string }).floatId, + )!.stack.active; + const setActive = (stack: TabStack): TabStack => ({ ...stack, active: op.instanceId }); + const next = + found.location.kind === 'slot' + ? updateSlot(layout, found.location.slot, setActive) + : { + ...layout, + floats: layout.floats.map((float) => + float.id === (found.location as { floatId: string }).floatId + ? { ...float, stack: setActive(float.stack) } + : float, + ), + }; + return { + layout: next, + inverse: previousActive + ? { type: 'activate', instanceId: previousActive } + : { type: 'batch', ops: [] }, + }; + } + case 'resizeSlot': { + const previous = layout.slots[op.slot].size; + return { + layout: { + ...layout, + slots: { + ...layout.slots, + [op.slot]: { + ...layout.slots[op.slot], + size: Math.max(MIN_SLOT_SIZE, Math.round(op.size)), + }, + }, + }, + inverse: { type: 'resizeSlot', slot: op.slot, size: previous }, + }; + } + case 'setSplit': { + const previous = layout.split[op.side]; + return { + layout: { ...layout, split: { ...layout.split, [op.side]: clamp(op.ratio, 0.15, 0.85) } }, + inverse: { type: 'setSplit', side: op.side, ratio: previous }, + }; + } + case 'toggleSlot': { + const previous = layout.slots[op.slot].visible; + return { + layout: { + ...layout, + slots: { ...layout.slots, [op.slot]: { ...layout.slots[op.slot], visible: op.visible } }, + }, + inverse: { type: 'toggleSlot', slot: op.slot, visible: previous }, + }; + } + case 'setFloatRect': { + const float = layout.floats.find((candidate) => candidate.id === op.floatId); + if (!float) throw new LayoutError(`Floating window ${op.floatId} does not exist`); + const rect = normalizeRect(op.rect); + return { + layout: { + ...layout, + floats: layout.floats.map((candidate) => + candidate === float ? { ...candidate, ...rect } : candidate, + ), + }, + inverse: { + type: 'setFloatRect', + floatId: op.floatId, + rect: { x: float.x, y: float.y, width: float.width, height: float.height }, + }, + }; + } + case 'focusFloat': { + const float = layout.floats.find((candidate) => candidate.id === op.floatId); + if (!float) throw new LayoutError(`Floating window ${op.floatId} does not exist`); + return { + layout: { + ...layout, + floats: layout.floats.map((candidate) => + candidate === float ? { ...candidate, z: op.z } : candidate, + ), + }, + inverse: { type: 'focusFloat', floatId: op.floatId, z: float.z }, + }; + } + case 'switchScreen': { + if (op.screen !== null && !layout.screens.includes(op.screen)) + throw new LayoutError(`Unknown screen ${op.screen}`); + return { + layout: { ...layout, activeScreen: op.screen }, + inverse: { type: 'switchScreen', screen: layout.activeScreen }, + }; + } + case 'setScreens': { + const activeScreen = + layout.activeScreen && op.screens.includes(layout.activeScreen) + ? layout.activeScreen + : (op.screens[0] ?? null); + return { + layout: { ...layout, screens: [...op.screens], activeScreen }, + inverse: { + type: 'batch', + ops: [ + { type: 'setScreens', screens: layout.screens }, + { type: 'switchScreen', screen: layout.activeScreen }, + ], + }, + }; + } + case 'maximize': { + if (op.instanceId !== null && !findWidget(layout, op.instanceId)) + throw new LayoutError(`No widget ${op.instanceId} in the layout`); + return { + layout: { ...layout, maximized: op.instanceId }, + inverse: { type: 'maximize', instanceId: layout.maximized }, + }; + } + case 'setScreenOverride': { + const current = layout.screenOverrides[op.screen] ?? {}; + const previous = current[op.slot]; + const overrides = Object.fromEntries( + Object.entries({ ...current, [op.slot]: op.visible }).filter( + ([, visible]) => visible !== null, + ), + ) as Partial>; + return { + layout: { + ...layout, + screenOverrides: { ...layout.screenOverrides, [op.screen]: overrides }, + }, + inverse: { + type: 'setScreenOverride', + screen: op.screen, + slot: op.slot, + visible: previous ?? null, + }, + }; + } + case 'batch': { + let current = layout; + const inverses: LayoutOp[] = []; + for (const inner of op.ops) { + const result = reduce(current, inner); + current = result.layout; + inverses.unshift(result.inverse); + } + return { layout: current, inverse: { type: 'batch', ops: inverses } }; + } + } +}; diff --git a/packages/layout/src/serialization/index.ts b/packages/layout/src/serialization/index.ts new file mode 100644 index 00000000..f26656c3 --- /dev/null +++ b/packages/layout/src/serialization/index.ts @@ -0,0 +1,2 @@ +export * from './serialize-layout'; +export * from './repair-layout'; diff --git a/packages/layout/src/serialization/layout.schema.ts b/packages/layout/src/serialization/layout.schema.ts new file mode 100644 index 00000000..f01f2dc3 --- /dev/null +++ b/packages/layout/src/serialization/layout.schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const Ref = z.object({ instanceId: z.string().min(1), widgetId: z.string().min(1) }); +export const Stack = z.object({ + tabs: z.array(Ref).catch([]), + active: z.string().nullable().catch(null), +}); +export const Slot = z.object({ + visible: z.boolean().catch(true), + size: z.number().catch(260), + stack: Stack.catch({ tabs: [], active: null }), +}); +export const Float = z.object({ + id: z.string().min(1), + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + z: z.number().catch(1), + stack: Stack, +}); diff --git a/packages/layout/src/serialization/repair-layout.ts b/packages/layout/src/serialization/repair-layout.ts new file mode 100644 index 00000000..6e85b27e --- /dev/null +++ b/packages/layout/src/serialization/repair-layout.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; + +import { emptyLayout } from '../model/empty-layout'; +import { LAYOUT_VERSION, MIN_FLOAT, MIN_SLOT_SIZE } from '../model/layout.const'; +import type { Layout, TabStack } from '../model/layout.type'; +import { SLOT_IDS, type SlotId } from '../model/slot-id.enum'; +import { Float, Slot } from './layout.schema'; + +export const repairLayout = (raw: Record): Layout => { + const base = emptyLayout(); + const seen = new Set(); + const widgetIds = new Set(); + const cleanStack = (stack: TabStack): TabStack => { + const tabs = stack.tabs.filter((tab) => { + if (seen.has(tab.instanceId)) return false; + seen.add(tab.instanceId); + widgetIds.add(tab.widgetId); + return true; + }); + const active = tabs.some((tab) => tab.instanceId === stack.active) + ? stack.active + : (tabs[0]?.instanceId ?? null); + return { tabs, active }; + }; + + const rawSlots = (raw.slots ?? {}) as Record; + const slots = Object.fromEntries( + SLOT_IDS.map((id) => { + const parsed = Slot.safeParse(rawSlots[id]); + const slot = parsed.success ? parsed.data : base.slots[id]; + return [ + id, + { + visible: slot.visible, + size: Math.max(MIN_SLOT_SIZE, Math.round(slot.size)), + stack: cleanStack(slot.stack), + }, + ]; + }), + ) as Record; + + const floats = z + .array(z.unknown()) + .catch([]) + .parse(raw.floats) + .flatMap((candidate) => { + const parsed = Float.safeParse(candidate); + if (!parsed.success) return []; + const stack = cleanStack(parsed.data.stack); + if (!stack.tabs.length) return []; + return [ + { + ...parsed.data, + width: Math.max(MIN_FLOAT.width, parsed.data.width), + height: Math.max(MIN_FLOAT.height, parsed.data.height), + stack, + }, + ]; + }); + + const screens = z.array(z.string()).catch([]).parse(raw.screens); + const activeScreen = z.string().nullable().catch(null).parse(raw.activeScreen); + const maximized = z.string().nullable().catch(null).parse(raw.maximized); + const split = z + .object({ + left: z.number().catch(base.split.left), + right: z.number().catch(base.split.right), + bottom: z.number().catch(base.split.bottom), + }) + .catch(base.split) + .parse(raw.split); + /** Layouts saved before `known` existed know the widgets they show. */ + const known = z.array(z.string()).optional().catch(undefined).parse(raw.known); + const overrides = z + .record(z.string(), z.record(z.string(), z.boolean())) + .catch({}) + .parse(raw.screenOverrides); + + return { + version: LAYOUT_VERSION, + screens: [...new Set(screens)], + activeScreen: + activeScreen && screens.includes(activeScreen) ? activeScreen : (screens[0] ?? null), + slots, + split: { + left: Math.min(0.85, Math.max(0.15, split.left)), + right: Math.min(0.85, Math.max(0.15, split.right)), + bottom: Math.min(0.85, Math.max(0.15, split.bottom)), + }, + floats, + maximized: maximized && seen.has(maximized) ? maximized : null, + known: known ?? [...screens, ...widgetIds], + screenOverrides: Object.fromEntries( + Object.entries(overrides).map(([screen, values]) => [ + screen, + Object.fromEntries( + Object.entries(values).filter(([slot]) => (SLOT_IDS as readonly string[]).includes(slot)), + ), + ]), + ), + }; +}; diff --git a/packages/layout/src/serialization/serialize-layout.ts b/packages/layout/src/serialization/serialize-layout.ts new file mode 100644 index 00000000..31ddd8a4 --- /dev/null +++ b/packages/layout/src/serialization/serialize-layout.ts @@ -0,0 +1,37 @@ +import { LAYOUT_VERSION } from '../model/layout.const'; +import type { Layout } from '../model/layout.type'; +import { repairLayout } from './repair-layout'; + +type Migration = (raw: Record) => Record; + +/** Migrations from version N to N + 1 (none yet: version 1 is the first). */ +const MIGRATIONS: Record = {}; + +export const serializeLayout = (layout: Layout): string => JSON.stringify(layout); + +/** + * Parses a stored layout: migrates old versions and repairs inconsistencies (duplicate + * instances, dangling active tabs, invalid sizes). Widgets are kept even when their plugin is + * not installed; the renderer shows them as placeholders. Returns undefined when unusable. + */ +export const deserializeLayout = (input: unknown): Layout | undefined => { + let raw: unknown = input; + if (typeof raw === 'string') { + try { + raw = JSON.parse(raw); + } catch { + return undefined; + } + } + if (typeof raw !== 'object' || raw === null) return undefined; + let document = raw as Record; + let version = typeof document.version === 'number' ? document.version : 0; + if (version > LAYOUT_VERSION || version < 1) return undefined; + while (version < LAYOUT_VERSION) { + const migrate = MIGRATIONS[version]; + if (!migrate) return undefined; + document = migrate(document); + version++; + } + return repairLayout(document); +}; diff --git a/packages/layout/test/default/default-layout.spec.ts b/packages/layout/test/default/default-layout.spec.ts new file mode 100644 index 00000000..5d3be202 --- /dev/null +++ b/packages/layout/test/default/default-layout.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { allWidgets, deserializeLayout, reconcileLayout, serializeLayout } from '../../src'; +import { apply, base, widgets } from '../fixtures/layout'; + +describe('default layout', () => { + it('places screens and docks by order', () => { + const layout = base(); + expect(layout.screens).toEqual(['scene', 'script', 'game']); + expect(layout.activeScreen).toBe('scene'); + expect(layout.slots.leftTop.stack).toEqual({ + tabs: [ + { instanceId: 'hierarchy', widgetId: 'hierarchy' }, + { instanceId: 'files', widgetId: 'files' }, + ], + active: 'hierarchy', + }); + expect(allWidgets(layout).map((ref) => ref.instanceId)).not.toContain('profiler'); + }); + + it('keeps a closed dock closed after a reload', () => { + const closed = apply(base(), { type: 'remove', instanceId: 'console' }); + const reloaded = deserializeLayout(serializeLayout(closed))!; + const shown = new Set(allWidgets(reloaded).map((ref) => ref.widgetId)); + const reconciled = reconcileLayout(reloaded, widgets, shown); + expect(allWidgets(reconciled).map((ref) => ref.widgetId)).not.toContain('console'); + expect(reconciled.known).toContain('console'); + }); + + it('reconciles saved layouts with new plugins', () => { + const saved = apply(base(), { type: 'remove', instanceId: 'console' }); + const reconciled = reconcileLayout( + saved, + [ + ...widgets, + { id: 'network', kind: 'dock', defaultSlot: 'bottom' }, + { id: 'music', kind: 'screen', order: 9 }, + ], + new Set([ + 'scene', + 'script', + 'game', + 'files', + 'hierarchy', + 'inspector', + 'console', + 'profiler', + ]), + ); + expect(reconciled.screens).toEqual(['scene', 'script', 'game', 'music']); + expect(reconciled.slots.bottom.stack.tabs.map((tab) => tab.instanceId)).toEqual(['network']); + }); +}); diff --git a/packages/layout/test/fixtures/layout.ts b/packages/layout/test/fixtures/layout.ts new file mode 100644 index 00000000..e652ace4 --- /dev/null +++ b/packages/layout/test/fixtures/layout.ts @@ -0,0 +1,16 @@ +import { type Layout, type LayoutOp, composeDefaultLayout, reduce } from '../../src'; + +export const widgets = [ + { id: 'scene', kind: 'screen' as const, order: 0 }, + { id: 'script', kind: 'screen' as const, order: 1 }, + { id: 'game', kind: 'screen' as const, order: 2 }, + { id: 'files', kind: 'dock' as const, defaultSlot: 'leftTop' as const }, + { id: 'hierarchy', kind: 'dock' as const, defaultSlot: 'leftTop' as const, order: -1 }, + { id: 'inspector', kind: 'dock' as const, defaultSlot: 'rightTop' as const }, + { id: 'console', kind: 'dock' as const, defaultSlot: 'bottom' as const }, + { id: 'profiler', kind: 'dock' as const, defaultSlot: 'bottom' as const, openByDefault: false }, +]; + +export const base = () => composeDefaultLayout(widgets); +export const apply = (layout: Layout, ...ops: LayoutOp[]) => + ops.reduce((current, op) => reduce(current, op).layout, layout); diff --git a/packages/layout/test/operations/inverse-operations.spec.ts b/packages/layout/test/operations/inverse-operations.spec.ts new file mode 100644 index 00000000..ab5c90b9 --- /dev/null +++ b/packages/layout/test/operations/inverse-operations.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import { type Layout, type LayoutOp, SLOT_IDS, allWidgets, reduce } from '../../src'; +import { base } from '../fixtures/layout'; + +/** Deterministic pseudo random generator (mulberry32). */ +const random = (seed: number) => () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +}; + +const randomOp = (layout: Layout, next: () => number, counter: { n: number }): LayoutOp => { + const pick = (items: readonly T[]) => items[Math.floor(next() * items.length)]!; + const instances = allWidgets(layout).map((ref) => ref.instanceId); + const location = () => + next() < 0.3 + ? layout.floats.length && next() < 0.5 + ? { kind: 'float' as const, floatId: pick(layout.floats).id, index: Math.floor(next() * 3) } + : { + kind: 'float' as const, + floatId: `f${counter.n++}`, + index: 0, + rect: { + x: next() * 800, + y: next() * 600, + width: 150 + next() * 400, + height: 100 + next() * 300, + }, + } + : { kind: 'slot' as const, slot: pick(SLOT_IDS), index: Math.floor(next() * 4) }; + const kinds = [ + 'add', + 'remove', + 'move', + 'activate', + 'resizeSlot', + 'toggleSlot', + 'switchScreen', + 'maximize', + 'setSplit', + 'batch', + ]; + switch (pick(kinds)) { + case 'add': + return { + type: 'add', + ref: { instanceId: `w${counter.n++}`, widgetId: 'dummy' }, + location: location(), + activate: next() < 0.5, + }; + case 'remove': + return instances.length + ? { type: 'remove', instanceId: pick(instances) } + : { type: 'batch', ops: [] }; + case 'move': + return instances.length + ? { + type: 'move', + instanceId: pick(instances), + location: location(), + activate: next() < 0.5, + } + : { type: 'batch', ops: [] }; + case 'activate': + return instances.length + ? { type: 'activate', instanceId: pick(instances) } + : { type: 'batch', ops: [] }; + case 'resizeSlot': + return { type: 'resizeSlot', slot: pick(SLOT_IDS), size: 50 + next() * 500 }; + case 'toggleSlot': + return { type: 'toggleSlot', slot: pick(SLOT_IDS), visible: next() < 0.5 }; + case 'switchScreen': + return { type: 'switchScreen', screen: pick(layout.screens) }; + case 'maximize': + return { + type: 'maximize', + instanceId: instances.length && next() < 0.7 ? pick(instances) : null, + }; + case 'setSplit': + return { type: 'setSplit', side: next() < 0.5 ? 'left' : 'right', ratio: next() }; + default: { + const first = randomOp(layout, next, counter); + return { type: 'batch', ops: [first] }; + } + } +}; + +describe('inverse operations', () => { + it('restore the exact previous layout (1000 random sequences)', () => { + const next = random(42); + const counter = { n: 0 }; + for (let run = 0; run < 1000; run++) { + let layout = base(); + const history: { before: Layout; inverse: LayoutOp }[] = []; + for (let step = 0; step < 8; step++) { + const op = randomOp(layout, next, counter); + const { layout: after, inverse } = reduce(layout, op); + history.push({ before: layout, inverse }); + layout = after; + } + for (const { before, inverse } of history.reverse()) { + layout = reduce(layout, inverse).layout; + const strip = (value: Layout) => ({ + ...value, + floats: value.floats.map((float) => ({ ...float, z: 0 })), + }); + expect(strip(layout)).toEqual(strip(before)); + } + } + }); +}); diff --git a/packages/layout/test/operations/layout-ops.spec.ts b/packages/layout/test/operations/layout-ops.spec.ts new file mode 100644 index 00000000..c37d4386 --- /dev/null +++ b/packages/layout/test/operations/layout-ops.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { isSlotVisible, showSlotOp } from '../../src'; +import { apply, base } from '../fixtures/layout'; + +describe('showing slots', () => { + it('changes the slot, or the screen override when there is one', () => { + const hidden = apply(base(), showSlotOp(base(), 'leftTop', false)); + expect(isSlotVisible(hidden, 'leftTop')).toBe(false); + expect(hidden.slots.leftTop.visible).toBe(false); + + const overridden = apply(base(), { + type: 'setScreenOverride', + screen: 'scene', + slot: 'bottom', + visible: false, + }); + const shown = apply(overridden, showSlotOp(overridden, 'bottom', true)); + expect(isSlotVisible(shown, 'bottom')).toBe(true); + expect(shown.screenOverrides.scene).toEqual({ bottom: true }); + }); +}); diff --git a/packages/layout/test/operations/reduce.spec.ts b/packages/layout/test/operations/reduce.spec.ts new file mode 100644 index 00000000..07a5dde8 --- /dev/null +++ b/packages/layout/test/operations/reduce.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { LayoutError, dockFloatOp, isSlotVisible, reduce } from '../../src'; +import { apply, base } from '../fixtures/layout'; + +describe('operations', () => { + it('floats a widget into a new window and docks it back', () => { + const floated = apply(base(), { + type: 'move', + instanceId: 'inspector', + location: { + kind: 'float', + floatId: 'f1', + index: 0, + rect: { x: 40, y: 50, width: 90, height: 300 }, + }, + }); + expect(floated.floats).toHaveLength(1); + expect(floated.floats[0]).toMatchObject({ + id: 'f1', + width: 200, + height: 300, + stack: { active: 'inspector' }, + }); + expect(floated.slots.rightTop.stack.tabs).toEqual([]); + const docked = apply(floated, dockFloatOp(floated, 'f1', 'bottom')); + expect(docked.floats).toEqual([]); + expect(docked.slots.bottom.stack.tabs.map((tab) => tab.instanceId)).toEqual([ + 'console', + 'inspector', + ]); + }); + + it('reorders tabs inside a stack', () => { + const layout = apply(base(), { + type: 'move', + instanceId: 'hierarchy', + location: { kind: 'slot', slot: 'leftTop', index: 1 }, + }); + expect(layout.slots.leftTop.stack.tabs.map((tab) => tab.instanceId)).toEqual([ + 'files', + 'hierarchy', + ]); + }); + + it('keeps the neighbour active when the active tab closes', () => { + const layout = apply(base(), { type: 'remove', instanceId: 'hierarchy' }); + expect(layout.slots.leftTop.stack.active).toBe('files'); + }); + + it('applies screen overrides to slot visibility', () => { + const layout = apply( + base(), + { type: 'setScreenOverride', screen: 'script', slot: 'rightTop', visible: false }, + { type: 'switchScreen', screen: 'script' }, + ); + expect(isSlotVisible(layout, 'rightTop')).toBe(false); + expect( + isSlotVisible(apply(layout, { type: 'switchScreen', screen: 'scene' }), 'rightTop'), + ).toBe(true); + }); + + it('rejects operations that do not apply', () => { + expect(() => reduce(base(), { type: 'remove', instanceId: 'nope' })).toThrow(LayoutError); + expect(() => reduce(base(), { type: 'switchScreen', screen: 'nope' })).toThrow(LayoutError); + expect(() => + reduce(base(), { + type: 'add', + ref: { instanceId: 'files', widgetId: 'files' }, + location: { kind: 'slot', slot: 'bottom', index: 0 }, + }), + ).toThrow(/already/); + }); +}); diff --git a/packages/layout/test/serialization/serialize-layout.spec.ts b/packages/layout/test/serialization/serialize-layout.spec.ts new file mode 100644 index 00000000..53c8edf7 --- /dev/null +++ b/packages/layout/test/serialization/serialize-layout.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { deserializeLayout, emptyLayout, findWidget, serializeLayout } from '../../src'; +import { apply, base } from '../fixtures/layout'; + +describe('serialization', () => { + it('round-trips and repairs broken documents', () => { + const layout = apply(base(), { + type: 'move', + instanceId: 'console', + location: { + kind: 'float', + floatId: 'f', + index: 0, + rect: { x: 1, y: 2, width: 300, height: 200 }, + }, + }); + expect(deserializeLayout(serializeLayout(layout))).toEqual(layout); + + const broken = JSON.parse(serializeLayout(layout)); + broken.slots.bottom.stack = { + tabs: [{ instanceId: 'files', widgetId: 'files' }], + active: 'ghost', + }; + broken.slots.leftTop.size = -5; + broken.maximized = 'ghost'; + broken.floats.push({ + id: 'empty', + x: 0, + y: 0, + width: 10, + height: 10, + stack: { tabs: [], active: null }, + }); + const repaired = deserializeLayout(broken)!; + expect(findWidget(repaired, 'files')!.location).toMatchObject({ + kind: 'slot', + slot: 'leftTop', + }); + expect(repaired.slots.bottom.stack).toEqual({ tabs: [], active: null }); + expect(repaired.slots.leftTop.size).toBe(120); + expect(repaired.maximized).toBeNull(); + expect(repaired.floats.map((float) => float.id)).toEqual(['f']); + }); + + it('reads layouts saved before the bottom right slot', () => { + const old = JSON.parse(serializeLayout(base())); + delete old.slots.bottomRight; + old.split = { left: 0.4, right: 0.6 }; + const layout = deserializeLayout(old)!; + expect(layout.slots.bottomRight).toEqual(emptyLayout().slots.bottomRight); + expect(layout.split).toEqual({ left: 0.4, right: 0.6, bottom: 0.5 }); + }); + + it('rejects unusable documents', () => { + expect(deserializeLayout('not json')).toBeUndefined(); + expect(deserializeLayout({ version: 99 })).toBeUndefined(); + expect(deserializeLayout(null)).toBeUndefined(); + expect(deserializeLayout({ version: 1 })).toEqual({ ...emptyLayout() }); + }); +}); diff --git a/packages/layout/tsconfig.json b/packages/layout/tsconfig.json new file mode 100644 index 00000000..f2fa7f24 --- /dev/null +++ b/packages/layout/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/meta/.prettierignore b/packages/meta/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/meta/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/meta/LICENSE b/packages/meta/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/meta/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/meta/README.md b/packages/meta/README.md new file mode 100644 index 00000000..f8756763 --- /dev/null +++ b/packages/meta/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-meta` is an internal package of the NanoForge Editor. + +Item metadata: TSDoc and TypeScript extraction, owner extensions, meta schemas (ADR 0003). + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/meta +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/meta/package.json b/packages/meta/package.json new file mode 100644 index 00000000..e57ec635 --- /dev/null +++ b/packages/meta/package.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-meta", + "version": "0.0.0", + "description": "NanoForge Editor - Item metadata: TSDoc and TypeScript extraction, owner extensions, meta schemas (ADR 0003)", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./pure": "./src/pure.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/meta" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty .", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "ts-morph": "catalog:code", + "zod": "catalog:libs" + }, + "devDependencies": { + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/meta/src/doc/doc-comment.ts b/packages/meta/src/doc/doc-comment.ts new file mode 100644 index 00000000..35427757 --- /dev/null +++ b/packages/meta/src/doc/doc-comment.ts @@ -0,0 +1,102 @@ +import { type Node, ts } from 'ts-morph'; + +import type { Doc } from './doc.type'; + +const EMPTY: Doc = { description: '', tags: [] }; +const TAG = /(^|\s)@([A-Za-z][\w-]*)/g; +const FENCE = /^\s*```/; + +/** + * Parses a `/** … *\/` comment. Tags are read anywhere after whitespace, also on one line + * (`/** Distance. @group Coords @preset vector.x *\/`), except inside ``` fences and `{@link}`. + */ +export const parseDocComment = (raw: string): Doc => { + const body = raw + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map((line) => line.replace(/^\s*\* ?/, '')) + .join('\n'); + + const marks: { start: number; end: number; name: string }[] = []; + let offset = 0; + let fenced = false; + for (const line of body.split('\n')) { + if (FENCE.test(line)) fenced = !fenced; + else if (!fenced) { + for (const match of line.matchAll(TAG)) { + const start = offset + match.index + match[1]!.length; + marks.push({ start, end: start + 1 + match[2]!.length, name: match[2]! }); + } + } + offset += line.length + 1; + } + + const description = body.slice(0, marks[0]?.start ?? body.length).trim(); + const tags = marks.map((mark, index) => ({ + name: mark.name, + text: body.slice(mark.end, marks[index + 1]?.start ?? body.length).trim(), + })); + return { description, tags }; +}; + +/** + * `/** … *\/` comments right before a node, as `{ start, end, text }`. Read from the node's full + * start with the compiler API: ts-morph gives the first parameter's comment (`(/** … *\/ x`) to + * the parameter list instead. + */ +export const leadingDocComments = ( + node: Node, +): { readonly start: number; readonly end: number; readonly text: string }[] => { + const text = node.getSourceFile().getFullText(); + const ranges = [ + ...(ts.getTrailingCommentRanges(text, node.getPos()) ?? []), + ...(ts.getLeadingCommentRanges(text, node.getPos()) ?? []), + ].filter((range, index, all) => all.findIndex((other) => other.pos === range.pos) === index); + return ranges + .sort((a, b) => a.pos - b.pos) + .map((range) => ({ start: range.pos, end: range.end, text: text.slice(range.pos, range.end) })) + .filter((range) => range.text.startsWith('/**')); +}; + +/** The last `/** … *\/` comment right before a node, parsed. */ +export const docOf = (node: Node | undefined): Doc => { + if (!node) return EMPTY; + const last = leadingDocComments(node).at(-1); + return last ? parseDocComment(last.text) : EMPTY; +}; + +export const tagTexts = (doc: Doc, name: string): string[] => + doc.tags.filter((tag) => tag.name === name).map((tag) => tag.text); + +export const firstTag = (doc: Doc, name: string): string | undefined => tagTexts(doc, name)[0]; + +export const hasTag = (doc: Doc, name: string): boolean => + doc.tags.some((tag) => tag.name === name); + +/** `@param name - text` (or `@param name text`) of a doc. */ +export const paramTexts = (doc: Doc): Map => { + const result = new Map(); + for (const text of tagTexts(doc, 'param')) { + const match = /^\{[^}]*\}\s*/.exec(text); + const rest = match ? text.slice(match[0].length) : text; + const [, name, description] = /^\[?([\w$.]+)\]?\s*(?:-\s*)?([\s\S]*)$/.exec(rest) ?? []; + if (name) result.set(name, (description ?? '').replace(/\s+/g, ' ').trim()); + } + return result; +}; + +/** The text of an `@example`, without its ``` fence. */ +export const exampleText = (text: string): string => + text + .replace(/^\s*```\w*\s*\n?/, '') + .replace(/\n?\s*```\s*$/, '') + .trim(); + +/** Collapses the line breaks of a description written over several lines. */ +export const oneParagraph = (text: string): string => + text + .split(/\n\s*\n/) + .map((paragraph) => paragraph.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .join('\n\n'); diff --git a/packages/meta/src/doc/doc.type.ts b/packages/meta/src/doc/doc.type.ts new file mode 100644 index 00000000..b96cb07d --- /dev/null +++ b/packages/meta/src/doc/doc.type.ts @@ -0,0 +1,11 @@ +export interface DocTag { + /** Without `@`. */ + readonly name: string; + /** The text after the tag name, trimmed (`''` for a flag). */ + readonly text: string; +} + +export interface Doc { + readonly description: string; + readonly tags: readonly DocTag[]; +} diff --git a/packages/meta/src/doc/index.ts b/packages/meta/src/doc/index.ts new file mode 100644 index 00000000..51b3bc90 --- /dev/null +++ b/packages/meta/src/doc/index.ts @@ -0,0 +1,2 @@ +export * from './doc.type'; +export * from './doc-comment'; diff --git a/packages/meta/src/extract/exported-declarations.ts b/packages/meta/src/extract/exported-declarations.ts new file mode 100644 index 00000000..c53bf633 --- /dev/null +++ b/packages/meta/src/extract/exported-declarations.ts @@ -0,0 +1,64 @@ +import { + type ClassDeclaration, + type Node, + Node as NodeGuards, + type ParameterDeclaration, + type SourceFile, +} from 'ts-morph'; + +import type { Doc } from '../doc/doc.type'; +import type { ItemDeclaration, ItemKind } from '../owner/item-owner.type'; + +type Tags = Record; + +export const tagsByName = (doc: Doc): Tags => { + const result: Tags = {}; + for (const tag of doc.tags) (result[tag.name] ??= []).push(tag.text); + return result; +}; + +/** The export declarations of the file itself (not re-exports), with their export name. */ +interface ExportedItem { + readonly exportName: string; + readonly declaration: ItemDeclaration; + readonly kind: ItemKind; +} + +export const exportedDeclarations = (file: SourceFile): ExportedItem[] => + [...file.getExportedDeclarations()].flatMap(([exportName, declarations]) => + declarations.flatMap((declaration): ExportedItem[] => { + if (declaration.getSourceFile() !== file) return []; + if (NodeGuards.isClassDeclaration(declaration)) + return [{ exportName, declaration, kind: 'class' as ItemKind }]; + if (NodeGuards.isFunctionDeclaration(declaration)) + return [{ exportName, declaration, kind: 'function' as ItemKind }]; + if (NodeGuards.isVariableDeclaration(declaration)) + return [{ exportName, declaration, kind: 'const' as ItemKind }]; + return []; + }), + ); + +export const docNodeOf = (declaration: ItemDeclaration): Node => + NodeGuards.isVariableDeclaration(declaration) + ? (declaration.getVariableStatement() ?? declaration) + : declaration; + +/** The constructor that has a body (the implementation), or the first one. */ +export const constructorOf = (declaration: ClassDeclaration) => { + const constructors = declaration.getConstructors(); + return constructors.find((constructor) => constructor.hasBody()) ?? constructors[0]; +}; + +/** Parameters of the item: its constructor's (class), its own (function, arrow const). */ +export const parametersOf = (declaration: ItemDeclaration): ParameterDeclaration[] => { + if (NodeGuards.isClassDeclaration(declaration)) + return constructorOf(declaration)?.getParameters() ?? []; + if (NodeGuards.isFunctionDeclaration(declaration)) return declaration.getParameters(); + const initializer = declaration.getInitializer(); + if ( + initializer && + (NodeGuards.isArrowFunction(initializer) || NodeGuards.isFunctionExpression(initializer)) + ) + return initializer.getParameters(); + return []; +}; diff --git a/packages/meta/src/extract/extract-items.ts b/packages/meta/src/extract/extract-items.ts new file mode 100644 index 00000000..e98a54ac --- /dev/null +++ b/packages/meta/src/extract/extract-items.ts @@ -0,0 +1,204 @@ +import { type Node, Node as NodeGuards, type SourceFile } from 'ts-morph'; + +import { + docOf, + exampleText, + firstTag, + hasTag, + oneParagraph, + paramTexts, + tagTexts, +} from '../doc/doc-comment'; +import type { ItemOwner, OwnerContext, OwnerInput } from '../owner/item-owner.type'; +import type { Element, ParamGroup } from '../schema/element.schema'; +import type { Side } from '../schema/item-ref.schema'; +import type { ItemMeta } from '../schema/meta-file.schema'; +import { elementOfType } from '../type/element-of-type'; +import type { TypeContext } from '../type/type-context.type'; +import { + constructorOf, + docNodeOf, + exportedDeclarations, + parametersOf, + tagsByName, +} from './exported-declarations'; +import type { ExtractOptions, ExtractResult, MetaDiagnostic } from './extract.type'; +import { CORE_TAGS, SIDES, STANDARD_TAGS } from './meta-tags.const'; +import { + asAsset, + assetTags, + fieldsSetFromConstructor, + isPublicInstanceField, + withDefault, +} from './param-fields'; +import { layoutOf, parseGroupDeclaration } from './param-layout'; +import { requiresOf } from './requires'; + +/** + * Extracts the items of one file: core fields from TSDoc and TypeScript, and each owner's object. + * An export is an item when the file is `listed`, when an owner claims it (tags or inference), or + * when it carries tags of an owner that isn't installed (kept in `unclaimedTags`). When the file + * says which `exports` are items (a manifest's `contents`), only those are. + */ +export const extractItems = (file: SourceFile, options: ExtractOptions): ExtractResult => { + const diagnostics: MetaDiagnostic[] = []; + const typeContext: TypeContext = { modulePath: options.modulePath }; + const context: OwnerContext = { + file, + path: options.path, + source: options.source, + ...(options.folder && { folder: options.folder }), + refOf: options.refOf, + }; + const ownedTags = new Map(); + for (const owner of options.owners) for (const tag of owner.tags) ownedTags.set(tag, owner); + const warn = (node: Node, message: string, source = 'meta') => + diagnostics.push({ + path: options.path, + start: node.getStart(), + length: node.getWidth(), + message, + severity: 'warning', + source, + }); + const requires = requiresOf(file); + const items: ItemMeta[] = []; + + for (const { exportName, declaration, kind } of exportedDeclarations(file)) { + const doc = docOf(docNodeOf(declaration)); + if (hasTag(doc, 'internal')) continue; + const tags = tagsByName(doc); + const nameNode = declaration.getNameNode() ?? declaration; + + const ownerObjects: Record> = {}; + const claimedParams = new Set(); + const claimedFields = new Set(); + for (const owner of options.owners) { + const own: Record = {}; + for (const tag of owner.tags) if (tags[tag]) own[tag] = tags[tag]; + const input: OwnerInput = { declaration, kind, tags: own, context }; + if (!Object.keys(own).length && !owner.infer?.(input)) continue; + let value: Record | undefined; + try { + value = owner.extract(input); + } catch (error) { + warn(nameNode, `${owner.name} could not read ${exportName}: ${String(error)}`, owner.name); + continue; + } + if (!value) continue; + const check = owner.validate?.safeParse(value); + if (check && !check.success) { + warn(nameNode, `${owner.name} wrote an invalid description of ${exportName}`, owner.name); + continue; + } + ownerObjects[owner.name] = value; + const claims = owner.claims?.(input); + for (const name of claims?.params ?? []) claimedParams.add(name); + for (const name of claims?.fields ?? []) claimedFields.add(name); + } + + const unclaimed: Record = {}; + for (const [name, texts] of Object.entries(tags)) { + if (CORE_TAGS.has(name) || STANDARD_TAGS.has(name) || ownedTags.has(name)) continue; + unclaimed[name] = texts; + } + + const isItem = options.exports + ? options.exports.includes(exportName) + : options.listed || Object.keys(ownerObjects).length > 0 || Object.keys(unclaimed).length > 0; + if (!isItem) continue; + + let side: Side = 'shared'; + const sideText = firstTag(doc, 'side'); + if (sideText !== undefined) { + const value = sideText.split(/\s+/)[0] ?? ''; + if (SIDES.has(value)) side = value as Side; + else warn(nameNode, `@side must be client, server or shared, not "${value}"`); + } + + const ownerDoc = NodeGuards.isClassDeclaration(declaration) + ? docOf(constructorOf(declaration)) + : doc; + const paramDocs = paramTexts(ownerDoc); + const constructorAssets = assetTags(ownerDoc, false); + const params: Element[] = []; + for (const param of parametersOf(declaration)) { + const name = param.getName(); + if (claimedParams.has(name)) continue; + const own = docOf(param); + let element = elementOfType(name, param.getType(), param, typeContext); + element = withDefault(element, param.getInitializer()); + const assets = constructorAssets.get(name) ?? assetTags(own, true).get(''); + if (assets) element = asAsset(element, assets); + const description = oneParagraph(own.description) || paramDocs.get(name); + const layout = layoutOf(own); + const deprecated = firstTag(own, 'deprecated'); + params.push({ + ...element, + ...((param.isOptional() || param.hasInitializer()) && { optional: true }), + ...(description && { description }), + ...(deprecated !== undefined && { deprecated }), + ...(layout && { layout }), + } as Element); + } + + const fields: Element[] = []; + if (NodeGuards.isClassDeclaration(declaration)) { + const fromConstructor = fieldsSetFromConstructor(declaration); + for (const property of declaration.getProperties()) { + const name = property.getName(); + if (!isPublicInstanceField(property) || claimedFields.has(name)) continue; + if (fromConstructor.has(name)) continue; + const own = docOf(property); + if (hasTag(own, 'internal')) continue; + let element = elementOfType(name, property.getType(), property, typeContext); + element = withDefault(element, property.getInitializer()); + const assets = assetTags(own, true).get(''); + if (assets) element = asAsset(element, assets); + const description = oneParagraph(own.description); + const layout = layoutOf(own); + fields.push({ + ...element, + ...(property.hasQuestionToken() && { optional: true }), + ...(description && { description }), + ...(layout && { layout }), + } as Element); + } + } + + const groups: ParamGroup[] = []; + for (const text of tags.group ?? []) { + const group = parseGroupDeclaration(text); + if (!group) warn(nameNode, `@group needs a name: "@group Coords - description"`); + else if (groups.some((existing) => existing.name === group.name)) + warn(nameNode, `The group "${group.name}" is declared twice`); + else groups.push(group); + } + for (const element of [...params, ...fields]) { + const name = element.layout?.group; + if (name && !groups.some((group) => group.name === name)) groups.push({ name }); + } + + const description = oneParagraph( + [doc.description, ...tagTexts(doc, 'remarks')].filter(Boolean).join('\n\n'), + ); + const example = firstTag(doc, 'example'); + const deprecated = firstTag(doc, 'deprecated'); + items.push({ + export: exportName, + source: options.path, + kind, + side, + ...(description && { description }), + ...(example && { example: exampleText(example) }), + ...(deprecated !== undefined && { deprecated }), + params, + fields, + groups, + requires, + ...(Object.keys(unclaimed).length && { unclaimedTags: unclaimed }), + ...ownerObjects, + } as ItemMeta); + } + return { items, diagnostics }; +}; diff --git a/packages/meta/src/extract/extract.type.ts b/packages/meta/src/extract/extract.type.ts new file mode 100644 index 00000000..80beb213 --- /dev/null +++ b/packages/meta/src/extract/extract.type.ts @@ -0,0 +1,40 @@ +import type { Node, SourceFile } from 'ts-morph'; + +import type { ItemOwner } from '../owner/item-owner.type'; +import type { ItemRef } from '../schema/item-ref.schema'; +import type { ItemMeta, MetaSource } from '../schema/meta-file.schema'; + +export interface MetaDiagnostic { + readonly path: string; + readonly start: number; + readonly length: number; + readonly message: string; + readonly severity: 'warning' | 'info'; + /** `meta` or the owner that reported it. */ + readonly source: string; +} + +export interface ExtractOptions { + readonly source: MetaSource; + /** Project path of the file. */ + readonly path: string; + readonly owners: readonly ItemOwner[]; + /** The file is listed in a manifest's `items`: each of its exports is an item. */ + readonly listed?: boolean; + /** + * A manifest's `contents` name these exports of the file: they are items, and its other + * exports are not (whatever the owners infer). + */ + readonly exports?: readonly string[]; + /** The item folder of the app or library the file is in. */ + readonly folder?: 'components' | 'systems'; + /** Module of a declaration file: a package name, or the project path of a project file. */ + modulePath(file: SourceFile): string; + /** The item a node refers to (for owners, e.g. the components a system queries). */ + refOf(node: Node): ItemRef | undefined; +} + +export interface ExtractResult { + readonly items: ItemMeta[]; + readonly diagnostics: MetaDiagnostic[]; +} diff --git a/packages/meta/src/extract/index.ts b/packages/meta/src/extract/index.ts new file mode 100644 index 00000000..bc3dac97 --- /dev/null +++ b/packages/meta/src/extract/index.ts @@ -0,0 +1,5 @@ +export { CORE_TAGS } from './meta-tags.const'; +export * from './extract.type'; +export { parseGroupDeclaration } from './param-layout'; +export * from './requires'; +export * from './extract-items'; diff --git a/packages/meta/src/extract/meta-tags.const.ts b/packages/meta/src/extract/meta-tags.const.ts new file mode 100644 index 00000000..3f2532a5 --- /dev/null +++ b/packages/meta/src/extract/meta-tags.const.ts @@ -0,0 +1,46 @@ +/** Tags the core reads itself (ADR 0003). */ +export const CORE_TAGS: ReadonlySet = new Set([ + 'remarks', + 'param', + 'example', + 'side', + 'asset', + 'deprecated', + 'internal', + 'group', + 'preset', + 'label', + 'color', + 'hidden', +]); + +/** Common JSDoc/TSDoc tags that are neither core nor owner tags: never reported. */ +export const STANDARD_TAGS: ReadonlySet = new Set([ + 'returns', + 'return', + 'see', + 'throws', + 'since', + 'template', + 'typeParam', + 'link', + 'inheritDoc', + 'default', + 'defaultValue', + 'privateRemarks', + 'beta', + 'alpha', + 'experimental', + 'public', + 'override', + 'virtual', + 'sealed', + 'readonly', + 'author', + 'todo', + 'type', + 'module', + 'packageDocumentation', +]); + +export const SIDES: ReadonlySet = new Set(['client', 'server', 'shared']); diff --git a/packages/meta/src/extract/param-fields.ts b/packages/meta/src/extract/param-fields.ts new file mode 100644 index 00000000..1291fb2f --- /dev/null +++ b/packages/meta/src/extract/param-fields.ts @@ -0,0 +1,92 @@ +import { + type ClassDeclaration, + type Node, + Node as NodeGuards, + type PropertyDeclaration, + ts, +} from 'ts-morph'; + +import { tagTexts } from '../doc/doc-comment'; +import type { Doc } from '../doc/doc.type'; +import type { Element } from '../schema/element.schema'; +import { defaultOf } from '../type/default-of'; +import { constructorOf } from './exported-declarations'; + +/** `@asset .png .jpg` of a constructor doc, or `@asset .png` of a param's own doc. */ +export const assetTags = (doc: Doc, own: boolean): Map => { + const result = new Map(); + for (const text of tagTexts(doc, 'asset')) { + const words = text.split(/\s+/).filter(Boolean); + const target = own ? '' : words.shift(); + if (target === undefined) continue; + result.set( + target, + words.filter((word) => word.startsWith('.')), + ); + } + return result; +}; + +export const withDefault = (element: Element, initializer: Node | undefined): Element => { + if (!initializer) return element; + const value = defaultOf(initializer); + if ('code' in value) return { ...element, defaultCode: value.code }; + const matches = + (typeof value.value === 'string' && (element.type === 'string' || element.type === 'asset')) || + (typeof value.value === 'number' && element.type === 'number') || + (typeof value.value === 'boolean' && element.type === 'boolean') || + (Array.isArray(value.value) && element.type === 'array') || + (typeof value.value === 'object' && + value.value !== null && + !Array.isArray(value.value) && + element.type === 'object'); + return matches + ? ({ ...element, default: value.value } as Element) + : { ...element, defaultCode: initializer.getText() }; +}; + +export const asAsset = (element: Element, accept: readonly string[]): Element => { + if (element.type !== 'string') return element; + const rest: Record = { ...element }; + delete rest.enum; + delete rest.enumRef; + delete rest.enumMembers; + return { ...rest, type: 'asset', ...(accept.length && { accept: [...accept] }) } as Element; +}; + +/** Fields assigned from constructor params (`this.x = x`): they are params, not fields. */ +export const fieldsSetFromConstructor = (declaration: ClassDeclaration): Set => { + const constructor = constructorOf(declaration); + const names = new Set(); + if (!constructor) return names; + const params = new Set(constructor.getParameters().map((param) => param.getName())); + for (const statement of constructor.getStatements()) { + if (!NodeGuards.isExpressionStatement(statement)) continue; + const expression = statement.getExpression(); + if (!NodeGuards.isBinaryExpression(expression)) continue; + if (expression.getOperatorToken().getKind() !== ts.SyntaxKind.EqualsToken) continue; + const left = expression.getLeft(); + const right = expression.getRight(); + if ( + NodeGuards.isPropertyAccessExpression(left) && + NodeGuards.isThisExpression(left.getExpression()) && + NodeGuards.isIdentifier(right) && + params.has(right.getText()) + ) + names.add(left.getName()); + } + return names; +}; + +export const isPublicInstanceField = (property: PropertyDeclaration) => + !property.isStatic() && + !property.hasModifier(ts.SyntaxKind.PrivateKeyword) && + !property.hasModifier(ts.SyntaxKind.ProtectedKeyword) && + !property.getName().startsWith('#'); + +export const packageOfSpecifier = (specifier: string): string | undefined => { + if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.includes(':')) + return undefined; + const parts = specifier.split('/'); + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; +}; diff --git a/packages/meta/src/extract/param-layout.ts b/packages/meta/src/extract/param-layout.ts new file mode 100644 index 00000000..52b35791 --- /dev/null +++ b/packages/meta/src/extract/param-layout.ts @@ -0,0 +1,47 @@ +import { firstTag, hasTag, oneParagraph } from '../doc/doc-comment'; +import type { Doc } from '../doc/doc.type'; +import type { ElementLayout, ParamGroup } from '../schema/element.schema'; + +/** `@group Name [color=] [hidden] - description` of a header. */ +export const parseGroupDeclaration = (text: string): ParamGroup | undefined => { + const separator = text.search(/\s-\s|\s-$/); + const head = (separator < 0 ? text : text.slice(0, separator)).trim(); + const description = + separator < 0 ? '' : oneParagraph(text.slice(separator).replace(/^\s-\s?/, '')); + const words = head.split(/\s+/).filter(Boolean); + let color: string | undefined; + let hidden = false; + while (words.length > 1) { + const last = words.at(-1)!; + if (last === 'hidden') hidden = true; + else if (last.startsWith('color=')) color = last.slice('color='.length); + else break; + words.pop(); + } + const name = words.join(' '); + if (!name) return undefined; + return { + name, + ...(description && { description }), + ...(color && { color }), + ...(hidden && { hidden }), + }; +}; + +/** Layout of a param or field from its own doc. */ +export const layoutOf = (doc: Doc): ElementLayout | undefined => { + const layout: ElementLayout = {}; + const label = firstTag(doc, 'label'); + if (label) layout.label = label; + const group = firstTag(doc, 'group'); + if (group) layout.group = group; + const preset = firstTag(doc, 'preset'); + if (preset) { + const [id, slot] = preset.split(/\s+/)[0]!.split('.'); + if (id) layout.preset = { id, ...(slot && { slot }) }; + } + const color = firstTag(doc, 'color'); + if (color) layout.color = color.split(/\s+/)[0]!; + if (hasTag(doc, 'hidden')) layout.hidden = true; + return Object.keys(layout).length ? layout : undefined; +}; diff --git a/packages/meta/src/extract/requires.ts b/packages/meta/src/extract/requires.ts new file mode 100644 index 00000000..ae452d6b --- /dev/null +++ b/packages/meta/src/extract/requires.ts @@ -0,0 +1,22 @@ +import type { SourceFile } from 'ts-morph'; + +import { packageOfPath } from '../type/package-of-path'; +import { packageOfSpecifier } from './param-fields'; + +/** npm packages a file imports (type-only too), sorted. */ +export const requiresOf = (file: SourceFile): string[] => + [ + ...new Set( + [...file.getImportDeclarations(), ...file.getExportDeclarations()].flatMap((declaration) => { + const specifier = declaration.getModuleSpecifierValue(); + const name = specifier ? packageOfSpecifier(specifier) : undefined; + return name ? [name] : []; + }), + ), + ].sort(); + +/** Default module path: a package inside `node_modules`, else the file path without `/project/`. */ +export const defaultModulePath = (file: SourceFile): string => { + const path = file.getFilePath(); + return packageOfPath(path) ?? path.replace(/^\/project\//, ''); +}; diff --git a/packages/meta/src/hash/index.ts b/packages/meta/src/hash/index.ts new file mode 100644 index 00000000..39ccd906 --- /dev/null +++ b/packages/meta/src/hash/index.ts @@ -0,0 +1 @@ +export * from './source-hash'; diff --git a/packages/meta/src/hash/source-hash.ts b/packages/meta/src/hash/source-hash.ts new file mode 100644 index 00000000..e71088ce --- /dev/null +++ b/packages/meta/src/hash/source-hash.ts @@ -0,0 +1,19 @@ +/** The text of a source file, for hashing. */ +export interface HashedSource { + readonly path: string; + readonly text: string; +} + +/** + * `sha256-` over each source (path, NUL, text, NUL), sorted by path. Uses WebCrypto, which + * the browser, the code worker and Bun all have. + */ +export const sourceHash = async (sources: readonly HashedSource[]): Promise => { + const sorted = [...sources].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const text = sorted.map((source) => `${source.path}\0${source.text}\0`).join(''); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); + const hex = [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + return `sha256-${hex}`; +}; diff --git a/packages/meta/src/index.ts b/packages/meta/src/index.ts new file mode 100644 index 00000000..89947e15 --- /dev/null +++ b/packages/meta/src/index.ts @@ -0,0 +1,6 @@ +export * from './doc'; +export * from './extract'; +export * from './hash'; +export * from './owner'; +export * from './schema'; +export * from './type'; diff --git a/packages/meta/src/owner/index.ts b/packages/meta/src/owner/index.ts new file mode 100644 index 00000000..6a02b4ec --- /dev/null +++ b/packages/meta/src/owner/index.ts @@ -0,0 +1 @@ +export * from './item-owner.type'; diff --git a/packages/meta/src/owner/item-owner.type.ts b/packages/meta/src/owner/item-owner.type.ts new file mode 100644 index 00000000..eb9f444a --- /dev/null +++ b/packages/meta/src/owner/item-owner.type.ts @@ -0,0 +1,59 @@ +import type { + ClassDeclaration, + FunctionDeclaration, + Node, + SourceFile, + VariableDeclaration, +} from 'ts-morph'; + +import type { ItemRef } from '../schema/item-ref.schema'; +import type { MetaSource } from '../schema/meta-file.schema'; + +export type ItemDeclaration = ClassDeclaration | FunctionDeclaration | VariableDeclaration; +export type ItemKind = 'class' | 'function' | 'const'; + +/** Where the extractor is, for owners. */ +export interface OwnerContext { + readonly file: SourceFile; + /** Project path of the file. */ + readonly path: string; + readonly source: MetaSource; + /** The item folder of the app or library the file is in (`dir.components`, `dir.systems`). */ + readonly folder?: 'components' | 'systems'; + /** The item a node (usually an identifier) refers to, when its declaration is exported. */ + refOf(node: Node): ItemRef | undefined; +} + +export interface OwnerInput { + readonly declaration: ItemDeclaration; + readonly kind: ItemKind; + /** This owner's tags on the item, by name (without `@`). */ + readonly tags: Readonly>; + readonly context: OwnerContext; +} + +/** + * An owner of item data (ADR 0003), registered by a plugin's worker entry. It reads its own + * tags, may infer untagged items, and returns its object for the item's owner key. + */ +export interface ItemOwner { + /** The plugin's registry name, also the owner key: `@nanoforge/ecs`. */ + readonly name: string; + /** The plugin's version (written in the meta's `owners`). */ + readonly version: string; + /** Version of the owner object's shape. A change regenerates the metas. */ + readonly schema: number; + /** The tags it owns, without `@`. */ + readonly tags: readonly string[]; + /** Checks the owner object (a zod schema fits). */ + readonly validate?: { safeParse(value: unknown): { success: boolean; error?: unknown } }; + /** Whether an export without this owner's tags is an item for it (inference). */ + infer?(input: OwnerInput): boolean; + /** Members the owner describes itself: left out of `params` / `fields`. */ + claims?(input: OwnerInput): { + readonly params?: readonly string[]; + readonly fields?: readonly string[]; + }; + /** The owner object, or undefined when the export isn't an item for this owner. */ + extract(input: OwnerInput): Record | undefined; +} diff --git a/packages/meta/src/pure.ts b/packages/meta/src/pure.ts new file mode 100644 index 00000000..9666cf0c --- /dev/null +++ b/packages/meta/src/pure.ts @@ -0,0 +1,8 @@ +/** + * The part of the package that does not need the TypeScript compiler: the meta format, hashes + * and owner types. Code that runs on the editor's main thread imports this entry, so the + * compiler (7 MB) stays in the code worker. + */ +export * from './hash'; +export * from './owner'; +export * from './schema'; diff --git a/packages/meta/src/schema/element.schema.ts b/packages/meta/src/schema/element.schema.ts new file mode 100644 index 00000000..b6c8fc40 --- /dev/null +++ b/packages/meta/src/schema/element.schema.ts @@ -0,0 +1,131 @@ +import { z } from 'zod'; + +import { ExportRef } from './item-ref.schema'; +import { withOwners } from './owner-object'; + +/** A CSS color, as written in the tag (`orange`, `#4aa3ff`, `rgb(…)`). */ +const CssColor = z.string().min(1); + +/** How a param or field looks in the inspector. Every field is optional. */ +export const ElementLayout = z.object({ + /** Name shown instead of the param name. */ + label: z.string().optional(), + /** The group it belongs to (`ItemMeta.groups[].name`). */ + group: z.string().optional(), + /** A visual preset, and the slot it fills: `{ id: "vector", slot: "x" }`, `{ id: "color" }`. */ + preset: z.object({ id: z.string(), slot: z.string().optional() }).optional(), + /** Color of its name. */ + color: CssColor.optional(), + /** Hidden until the user chooses to show it. */ + hidden: z.boolean().optional(), +}); +export type ElementLayout = z.output; + +/** A group of params, declared in the item's header (`@group Coords color=… hidden - …`). */ +export const ParamGroup = z.object({ + name: z.string(), + description: z.string().optional(), + color: CssColor.optional(), + hidden: z.boolean().optional(), +}); +export type ParamGroup = z.output; + +export interface EnumMember { + readonly name: string; + readonly value: string | number; +} + +interface ElementCommon { + name: string; + description?: string; + /** Default false; true when the param has a default or `?`. */ + optional?: boolean; + deprecated?: string; + layout?: ElementLayout; + /** The initializer's source text when it isn't a JSON value (`new Rect({ width: 10 })`). */ + defaultCode?: string; + [owner: `@${string}/${string}`]: Record; +} + +/** + * A value's type. Core types describe TypeScript, not a lib. + * `default` is a JSON value when the initializer is a literal (an enum member gives its value). + */ +export type Element = + | ({ + type: 'string'; + default?: string; + /** Allowed values, from a string literal union (`"left" | "right"`). */ + enum?: string[]; + /** Values come from an exported enum: `@nanoforge-dev/input#InputEnum`. */ + enumRef?: string; + enumMembers?: EnumMember[]; + } & ElementCommon) + | ({ + type: 'number'; + default?: number; + enumRef?: string; + enumMembers?: EnumMember[]; + } & ElementCommon) + | ({ type: 'boolean'; default?: boolean } & ElementCommon) + | ({ type: 'asset'; default?: string; accept?: string[] } & ElementCommon) + | ({ type: 'array'; items: Element; default?: unknown[] } & ElementCommon) + | ({ type: 'object'; properties: Element[]; default?: Record } & ElementCommon) + | ({ type: 'ref'; ref: string } & ElementCommon) + | ({ type: 'unknown'; tsType: string } & ElementCommon); + +export type ElementType = Element['type']; + +const EnumMemberSchema = z.object({ name: z.string(), value: z.union([z.string(), z.number()]) }); + +const elementBase = { + name: z.string(), + description: z.string().optional(), + optional: z.boolean().optional(), + deprecated: z.string().optional(), + layout: ElementLayout.optional(), + defaultCode: z.string().optional(), +}; + +export const Element: z.ZodType = z.lazy(() => + z.union([ + withOwners({ + ...elementBase, + type: z.literal('string'), + default: z.string().optional(), + enum: z.array(z.string()).optional(), + enumRef: ExportRef.optional(), + enumMembers: z.array(EnumMemberSchema).optional(), + }), + withOwners({ + ...elementBase, + type: z.literal('number'), + default: z.number().optional(), + enumRef: ExportRef.optional(), + enumMembers: z.array(EnumMemberSchema).optional(), + }), + withOwners({ ...elementBase, type: z.literal('boolean'), default: z.boolean().optional() }), + withOwners({ + ...elementBase, + type: z.literal('asset'), + default: z.string().optional(), + accept: z.array(z.string().regex(/^\.[\w.]+$/)).optional(), + }), + withOwners({ + ...elementBase, + type: z.literal('array'), + items: Element, + default: z.array(z.unknown()).optional(), + }), + withOwners({ + ...elementBase, + type: z.literal('object'), + properties: z.array(Element), + default: z.record(z.string(), z.unknown()).optional(), + }), + withOwners({ ...elementBase, type: z.literal('ref'), ref: ExportRef }), + withOwners({ ...elementBase, type: z.literal('unknown'), tsType: z.string() }), + ]), +) as z.ZodType; + +export const ItemKindSchema = z.enum(['class', 'function', 'const']); diff --git a/packages/meta/src/schema/index.ts b/packages/meta/src/schema/index.ts new file mode 100644 index 00000000..ec2879bb --- /dev/null +++ b/packages/meta/src/schema/index.ts @@ -0,0 +1,5 @@ +export * from './item-ref.schema'; +export { ownerObjects } from './owner-object'; +export * from './element.schema'; +export * from './meta-file.schema'; +export * from './meta-generator.const'; diff --git a/packages/meta/src/schema/item-ref.schema.ts b/packages/meta/src/schema/item-ref.schema.ts new file mode 100644 index 00000000..08478951 --- /dev/null +++ b/packages/meta/src/schema/item-ref.schema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +/** + * Item metadata (ADR 0003): what the editor knows about each component, system or other item, + * extracted from the TSDoc and the TypeScript of its source. Core fields never name a lib: lib + * data lives in owner objects keyed by the owner plugin's registry name (`"@nanoforge/ecs": {…}`). + */ + +/** A package or plugin of our registry: `@scope/name`. Also the key of an owner object. */ +export const REGISTRY_NAME = /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/; +export const RegistryName = z.string().regex(REGISTRY_NAME); + +/** An npm package name: `@scope/name` or `name`. */ +export const NpmName = z.string().regex(/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/); + +/** An export of a module: `@nanoforge-dev/graphics-2d#Rect`, or `apps/client/src/x.ts#Foo`. */ +export const ExportRef = z.string().regex(/^[^#\s]+#[A-Za-z_$][\w$]*$/); + +/** + * An item: `#`. The source is an installed package or a shared library + * (`@nanoforge/motion#Position`, `@pong-network/shared#Player`), or an app (`app:client#Paddle`). + */ +export const ItemRef = z + .string() + .regex(/^(app:[\w.-]+|(@[a-z0-9-~][\w.-]*\/)?[a-z0-9-~][\w.-]*)#[A-Za-z_$][\w$]*$/); +export type ItemRef = z.output; + +export const itemRef = (source: string, exportName: string): ItemRef => `${source}#${exportName}`; + +/** Where an item runs. A NanoForge app concept (client and server apps), so it's core. */ +export const Side = z.enum(['client', 'server', 'shared']); +export type Side = z.output; diff --git a/packages/meta/src/schema/meta-file.schema.ts b/packages/meta/src/schema/meta-file.schema.ts new file mode 100644 index 00000000..e85a2467 --- /dev/null +++ b/packages/meta/src/schema/meta-file.schema.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; + +import { Element, ItemKindSchema, ParamGroup } from './element.schema'; +import { NpmName, RegistryName, Side } from './item-ref.schema'; +import { withOwners } from './owner-object'; + +export const ItemMeta = withOwners({ + export: z.string(), + /** Project path of the source file. */ + source: z.string(), + kind: ItemKindSchema, + side: Side, + description: z.string().optional(), + example: z.string().optional(), + deprecated: z.string().optional(), + /** Constructor parameters (class) or parameters (function), in order, minus claimed ones. */ + params: z.array(Element), + /** Public fields not set from the constructor, minus claimed ones. */ + fields: z.array(Element).default([]), + /** Param groups declared in the header, in declaration order, then undeclared ones used. */ + groups: z.array(ParamGroup).default([]), + /** npm packages the item's source imports (type-only imports included). */ + requires: z.array(NpmName).default([]), + /** Tags no installed owner claimed, kept raw: `{ "component": [""], "query": ["A B"] }`. */ + unclaimedTags: z.record(z.string(), z.array(z.string())).optional(), +}); +export type ItemMeta = z.output; + +/** + * Where items come from: + * - `module`: an installed package (`nf_modules/@scope/name`), read-only; + * - `lib`: a shared library of the project (`libs/`, ADR 0004); + * - `app`: an app of the project (`apps/client`). + */ +export const MetaSource = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('module'), + name: RegistryName, + version: z.string(), + path: z.string(), + }), + z.object({ kind: z.literal('lib'), name: NpmName, path: z.string() }), + z.object({ kind: z.literal('app'), name: z.string(), path: z.string() }), +]); +export type MetaSource = z.output; + +/** The `` part of the item references of a source. */ +export const sourceRefBase = (source: MetaSource): string => + source.kind === 'app' ? `app:${source.name}` : source.name; + +export const META_VERSION = 1; +export const META_NOTICE = 'Generated by the NanoForge editor. Do not edit.'; + +/** One cache file per source, in `.nanoforge/editor/cache/meta/`. */ +export const MetaFile = z.object({ + generated: z.literal(META_NOTICE), + metaVersion: z.literal(META_VERSION), + /** The package that holds the extraction logic. */ + generator: z.object({ name: z.string(), version: z.string() }), + source: MetaSource, + /** sha256 over each source file (path, NUL, text, NUL), sorted by path. */ + sourceHash: z.string().regex(/^sha256-[0-9a-f]{64}$/), + /** The owner plugins that ran, and the schema version of what they wrote. */ + owners: z.record(RegistryName, z.object({ version: z.string(), schema: z.number().int() })), + items: z.array(ItemMeta), +}); +export type MetaFile = z.output; + +/** Cache path of a source's meta, relative to the project root. */ +export const metaCachePath = (source: MetaSource): string => { + const base = '.nanoforge/editor/cache/meta'; + switch (source.kind) { + case 'module': + return `${base}/modules/${source.name}.json`; + case 'lib': + return `${base}/libs/${source.name}.json`; + case 'app': + return `${base}/apps/${source.name}.json`; + } +}; diff --git a/packages/meta/src/schema/meta-generator.const.ts b/packages/meta/src/schema/meta-generator.const.ts new file mode 100644 index 00000000..d4bef213 --- /dev/null +++ b/packages/meta/src/schema/meta-generator.const.ts @@ -0,0 +1,2 @@ +/** Name and version written in each meta file's `generator`. Bump the version when output changes. */ +export const META_GENERATOR = { name: '@nanoforge-dev/editor-meta', version: '0.1.0' } as const; diff --git a/packages/meta/src/schema/owner-object.ts b/packages/meta/src/schema/owner-object.ts new file mode 100644 index 00000000..66ab14af --- /dev/null +++ b/packages/meta/src/schema/owner-object.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { REGISTRY_NAME } from './item-ref.schema'; + +const isOwnerKey = (key: string) => REGISTRY_NAME.test(key); +const OwnerObject = z.record(z.string(), z.unknown()); + +/** + * Adds owner objects to a core object: any extra key must be a registry name, and its value is an + * object only that owner parses. The core keeps it untouched. + */ +export const withOwners = (shape: Shape) => + z + .object(shape) + .catchall(OwnerObject) + .superRefine((value, ctx) => { + for (const key of Object.keys(value)) { + if (key in shape || isOwnerKey(key)) continue; + ctx.addIssue({ + code: 'custom', + path: [key], + message: 'unknown field: owner keys are registry names ("@scope/name")', + }); + } + }); + +/** Owner objects of a core object (its keys that are registry names). */ +export const ownerObjects = (value: object): Record> => + Object.fromEntries( + Object.entries(value).filter(([key]) => isOwnerKey(key)) as [string, Record][], + ); diff --git a/packages/meta/src/type/default-of.ts b/packages/meta/src/type/default-of.ts new file mode 100644 index 00000000..95849069 --- /dev/null +++ b/packages/meta/src/type/default-of.ts @@ -0,0 +1,66 @@ +import { Node, ts } from 'ts-morph'; + +export type DefaultValue = + | { readonly value: string | number | boolean | null | unknown[] | Record } + | { readonly code: string }; + +/** The default of an initializer: a JSON value when it is a literal, else its code. */ +export const defaultOf = (initializer: Node): DefaultValue => { + const literal = literalValue(initializer); + return literal === NOT_LITERAL ? { code: initializer.getText() } : { value: literal }; +}; + +const NOT_LITERAL = Symbol('not a literal'); +type Literal = string | number | boolean | null | Literal[] | { [key: string]: Literal }; + +const literalValue = (node: Node): Literal | typeof NOT_LITERAL => { + if ( + Node.isParenthesizedExpression(node) || + Node.isAsExpression(node) || + Node.isSatisfiesExpression(node) + ) + return literalValue(node.getExpression()); + if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) + return node.getLiteralValue(); + if (Node.isNumericLiteral(node)) return node.getLiteralValue(); + if (Node.isTrueLiteral(node)) return true; + if (Node.isFalseLiteral(node)) return false; + if (Node.isNullLiteral(node)) return null; + if (Node.isPrefixUnaryExpression(node)) { + const operand = node.getOperand(); + if (Node.isNumericLiteral(operand)) { + if (node.getOperatorToken() === ts.SyntaxKind.MinusToken) return -operand.getLiteralValue(); + if (node.getOperatorToken() === ts.SyntaxKind.PlusToken) return operand.getLiteralValue(); + } + return NOT_LITERAL; + } + if (Node.isPropertyAccessExpression(node)) { + const value = node + .getProject() + .getTypeChecker() + .compilerObject.getConstantValue(node.compilerNode); + if (value !== undefined) return value; + const member = node.getSymbol()?.getDeclarations()[0]; + return member && Node.isEnumMember(member) ? (member.getValue() ?? NOT_LITERAL) : NOT_LITERAL; + } + if (Node.isArrayLiteralExpression(node)) { + const values: Literal[] = []; + for (const element of node.getElements()) { + const value = literalValue(element); + if (value === NOT_LITERAL) return NOT_LITERAL; + values.push(value); + } + return values; + } + if (Node.isObjectLiteralExpression(node)) { + const result: Record = {}; + for (const property of node.getProperties()) { + if (!Node.isPropertyAssignment(property)) return NOT_LITERAL; + const value = literalValue(property.getInitializerOrThrow()); + if (value === NOT_LITERAL) return NOT_LITERAL; + result[property.getName().replace(/^['"]|['"]$/g, '')] = value; + } + return result; + } + return NOT_LITERAL; +}; diff --git a/packages/meta/src/type/element-of-type.ts b/packages/meta/src/type/element-of-type.ts new file mode 100644 index 00000000..c75dbf74 --- /dev/null +++ b/packages/meta/src/type/element-of-type.ts @@ -0,0 +1,117 @@ +import { Node, type Type, ts } from 'ts-morph'; + +import type { Element, EnumMember } from '../schema/element.schema'; +import type { TypeContext } from './type-context.type'; + +const exportRef = (declaration: Node, name: string, context: TypeContext) => + `${context.modulePath(declaration.getSourceFile())}#${name}`; + +/** The parts of a type that hold a value: `undefined` and `null` only make it optional. */ +const valueParts = (type: Type): Type[] => + type.isUnion() + ? type.getUnionTypes().filter((part) => !part.isUndefined() && !part.isNull()) + : [type]; + +/** The enum of a type, from its members (`Mode`, `Mode | undefined`) or the type itself. */ +const enumOf = (parts: readonly Type[]): { symbolName: string; declaration: Node } | undefined => { + const [first] = parts; + const symbol = + parts.length > 1 + ? first?.getSymbol()?.getDeclarations()[0]?.getParent()?.getSymbol() + : (first?.getSymbol() ?? first?.getAliasSymbol()); + const declaration = symbol?.getDeclarations()[0]; + if (!symbol || !declaration || !Node.isEnumDeclaration(declaration)) return undefined; + return { symbolName: symbol.getName(), declaration }; +}; + +const enumMembers = (declaration: Node): EnumMember[] => + Node.isEnumDeclaration(declaration) + ? declaration.getMembers().flatMap((member) => { + const value = member.getValue(); + return value === undefined ? [] : [{ name: member.getName(), value }]; + }) + : []; + +/** A class (or interface) declared in a package or the project, as `{ type: 'ref' }`. */ +const refOf = (type: Type, context: TypeContext): string | undefined => { + const symbol = type.getSymbol() ?? type.getAliasSymbol(); + const declaration = symbol?.getDeclarations()[0]; + if (!symbol || !declaration) return undefined; + if (!Node.isClassDeclaration(declaration) && !Node.isInterfaceDeclaration(declaration)) + return undefined; + return exportRef(declaration, symbol.getName(), context); +}; + +/** + * The `Element` of a TypeScript type (a param, a field, an array item). `node` is where the type + * is used, for its text when nothing fits. + */ +export const elementOfType = ( + name: string, + rawType: Type, + node: Node, + context: TypeContext, + depth = 0, +): Element => { + const parts = valueParts(rawType); + const type = parts.length === 1 ? parts[0]! : rawType; + + const enumInfo = enumOf(parts); + if (enumInfo) { + const members = enumMembers(enumInfo.declaration); + const numeric = + members.length > 0 && members.every((member) => typeof member.value === 'number'); + return { + type: numeric ? 'number' : 'string', + name, + enumRef: exportRef(enumInfo.declaration, enumInfo.symbolName, context), + enumMembers: members, + }; + } + if ( + type.isBoolean() || + type.isBooleanLiteral() || + (parts.length > 1 && parts.every((part) => part.isBooleanLiteral())) + ) + return { type: 'boolean', name }; + if (type.isNumber() || type.isNumberLiteral()) return { type: 'number', name }; + if (type.isString() || type.isStringLiteral() || type.isTemplateLiteral()) { + return type.isStringLiteral() + ? { type: 'string', name, enum: [type.getLiteralValue() as string] } + : { type: 'string', name }; + } + if (parts.length > 1 && parts.every((part) => part.isStringLiteral())) { + return { type: 'string', name, enum: parts.map((part) => part.getLiteralValue() as string) }; + } + if (type.isArray() && depth < 4) { + return { + type: 'array', + name, + items: elementOfType('item', type.getArrayElementTypeOrThrow(), node, context, depth + 1), + }; + } + const ref = refOf(type, context); + if (ref) return { type: 'ref', name, ref }; + if (type.isObject() && type.isAnonymous() && depth < 4) { + return { + type: 'object', + name, + properties: type + .getProperties() + .map((property) => + elementOfType( + property.getName(), + property.getTypeAtLocation(node), + node, + context, + depth + 1, + ), + ), + }; + } + return { + type: 'unknown', + name, + tsType: type.getText(node, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope), + }; +}; diff --git a/packages/meta/src/type/index.ts b/packages/meta/src/type/index.ts new file mode 100644 index 00000000..846411e4 --- /dev/null +++ b/packages/meta/src/type/index.ts @@ -0,0 +1,4 @@ +export { type DefaultValue, defaultOf } from './default-of'; +export type { TypeContext } from './type-context.type'; +export { elementOfType } from './element-of-type'; +export { packageOfPath } from './package-of-path'; diff --git a/packages/meta/src/type/package-of-path.ts b/packages/meta/src/type/package-of-path.ts new file mode 100644 index 00000000..88acdaea --- /dev/null +++ b/packages/meta/src/type/package-of-path.ts @@ -0,0 +1,11 @@ +const NODE_MODULES = '/node_modules/'; + +/** A package name from a path inside `node_modules`, else undefined. */ +export const packageOfPath = (path: string): string | undefined => { + const index = path.lastIndexOf(NODE_MODULES); + if (index < 0) return undefined; + const [first, second] = path.slice(index + NODE_MODULES.length).split('/'); + if (!first) return undefined; + const name = first.startsWith('@') ? `${first}/${second ?? ''}` : first; + return name.startsWith('@types/') ? name.slice('@types/'.length).replace('__', '/') : name; +}; diff --git a/packages/meta/src/type/type-context.type.ts b/packages/meta/src/type/type-context.type.ts new file mode 100644 index 00000000..2ef738d7 --- /dev/null +++ b/packages/meta/src/type/type-context.type.ts @@ -0,0 +1,6 @@ +import type { SourceFile } from 'ts-morph'; + +export interface TypeContext { + /** Module of a declaration file: a package name, or the project path of a project file. */ + modulePath(file: SourceFile): string; +} diff --git a/packages/meta/test/extract/extract-items.spec.ts b/packages/meta/test/extract/extract-items.spec.ts new file mode 100644 index 00000000..783d2eb5 --- /dev/null +++ b/packages/meta/test/extract/extract-items.spec.ts @@ -0,0 +1,138 @@ +import { Project } from 'ts-morph'; +import { describe, expect, it } from 'vitest'; + +import { type ItemOwner, defaultModulePath, extractItems, parseDocComment } from '../../src'; + +const project = () => { + const p = new Project({ useInMemoryFileSystem: true, compilerOptions: { strict: true } }); + p.createSourceFile( + '/project/node_modules/@nanoforge-dev/input/index.d.ts', + 'export declare enum InputEnum { ArrowUp = "ArrowUp", ArrowDown = "ArrowDown" }', + ); + return p; +}; + +const ecs: ItemOwner = { + name: '@nanoforge/ecs', + version: '0.0.0', + schema: 1, + tags: ['component'], + infer: ({ declaration, context }) => + context.folder === 'components' && declaration.getKindName() === 'ClassDeclaration', + claims: () => ({ fields: ['name'] }), + extract: ({ declaration }) => ({ type: 'component', name: declaration.getName() }), +}; + +const extract = (text: string, folder?: 'components') => { + const file = project().createSourceFile('/project/apps/client/src/c.ts', text); + return extractItems(file, { + source: { kind: 'app', name: 'client', path: 'apps/client' }, + path: 'apps/client/src/c.ts', + owners: [ecs], + ...(folder && { folder }), + modulePath: defaultModulePath, + refOf: () => undefined, + }); +}; + +describe('parseDocComment', () => { + it('reads tags on one line, not inside fences', () => { + const doc = parseDocComment('/** Distance. @group Coords @preset vector.x */'); + expect(doc.description).toBe('Distance.'); + expect(doc.tags).toEqual([ + { name: 'group', text: 'Coords' }, + { name: 'preset', text: 'vector.x' }, + ]); + expect(parseDocComment('/**\n * @example\n * ```ts\n * @dec x\n * ```\n */').tags).toHaveLength( + 1, + ); + }); +}); + +describe('extractItems', () => { + it('extracts a tagged component with enums, defaults, layout and groups', () => { + const { items, diagnostics } = extract(` +import { InputEnum } from "@nanoforge-dev/input"; +/** + * The keys of a paddle. + * @component + * @side client + * @group Keys color=#4aa3ff - Which keys move it. + * @group Debug hidden + */ +export class Controller { + name = "Controller"; + /** Whether up was held. */ + lastUp = false; + constructor( + /** Key that moves up. @group Keys @label Up key @color orange */ + public up: InputEnum = InputEnum.ArrowUp, + /** @group Debug @hidden */ + public trail = false, + ) {} +}`); + expect(diagnostics).toEqual([]); + const [item] = items; + expect(item).toMatchObject({ + export: 'Controller', + side: 'client', + description: 'The keys of a paddle.', + requires: ['@nanoforge-dev/input'], + groups: [ + { name: 'Keys', color: '#4aa3ff', description: 'Which keys move it.' }, + { name: 'Debug', hidden: true }, + ], + '@nanoforge/ecs': { type: 'component', name: 'Controller' }, + }); + expect(item!.params[0]).toMatchObject({ + name: 'up', + type: 'string', + enumRef: '@nanoforge-dev/input#InputEnum', + default: 'ArrowUp', + optional: true, + description: 'Key that moves up.', + layout: { group: 'Keys', label: 'Up key', color: 'orange' }, + }); + expect(item!.params[1]).toMatchObject({ layout: { group: 'Debug', hidden: true } }); + expect(item!.fields.map((field) => field.name)).toEqual(['lastUp']); + }); + + it('infers untagged components and skips fields set from the constructor', () => { + const { items } = extract( + `export class Position { name = "Position"; x: number; constructor(x: number) { this.x = x; } } + export const helper = 1;`, + 'components', + ); + expect(items.map((item) => item.export)).toEqual(['Position']); + expect(items[0]!.fields).toEqual([]); + expect(items[0]!.params).toEqual([{ name: 'x', type: 'number' }]); + }); + + it('gives optional and nullable params the editor of their type', () => { + const code = `export enum Mode { A = 'a', B = 'b' } +export class Options { + constructor( + public visible?: boolean, + public side?: 'left' | 'right', + public mode?: Mode, + public label: string | null = null, + public count?: number, + ) {} +}`; + const { items } = extract(code, 'components'); + const params = Object.fromEntries(items[0]!.params.map((param) => [param.name, param])); + expect(params.visible).toMatchObject({ type: 'boolean', optional: true }); + expect(params.side).toMatchObject({ type: 'string', enum: ['left', 'right'] }); + expect(params.mode).toMatchObject({ + type: 'string', + enumMembers: [{ name: 'A' }, { name: 'B' }], + }); + expect(params.label).toMatchObject({ type: 'string' }); + expect(params.count).toMatchObject({ type: 'number' }); + }); + + it('keeps tags of a missing owner as unclaimed', () => { + const { items } = extract('/** @tilemap layer */ export class Tiles {}'); + expect(items[0]!.unclaimedTags).toEqual({ tilemap: ['layer'] }); + }); +}); diff --git a/packages/meta/test/extract/inline.spec.ts b/packages/meta/test/extract/inline.spec.ts new file mode 100644 index 00000000..2fbd3f01 --- /dev/null +++ b/packages/meta/test/extract/inline.spec.ts @@ -0,0 +1,23 @@ +import { Project } from 'ts-morph'; +import { expect, it } from 'vitest'; + +import { defaultModulePath, extractItems } from '../../src'; + +it('reads the doc of a first parameter written right after the parenthesis', () => { + const file = new Project({ useInMemoryFileSystem: true }).createSourceFile( + '/project/a.ts', + 'export class Position {\n name = "Position";\n constructor(/** @group Coords */ x: number, /** Top. */ y: number) {}\n}\n', + ); + const { items } = extractItems(file, { + source: { kind: 'app', name: 'c', path: '' }, + path: 'a.ts', + owners: [], + listed: true, + modulePath: defaultModulePath, + refOf: () => undefined, + }); + expect(items[0]!.params).toMatchObject([ + { name: 'x', layout: { group: 'Coords' } }, + { name: 'y', description: 'Top.' }, + ]); +}); diff --git a/packages/meta/tsconfig.json b/packages/meta/tsconfig.json new file mode 100644 index 00000000..f2fa7f24 --- /dev/null +++ b/packages/meta/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/project/.prettierignore b/packages/project/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/project/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/project/LICENSE b/packages/project/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/project/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/project/README.md b/packages/project/README.md new file mode 100644 index 00000000..d666c5d7 --- /dev/null +++ b/packages/project/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-project` is an internal package of the NanoForge Editor. + +Workspace discovery and project file system. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/project +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/project/package.json b/packages/project/package.json new file mode 100644 index 00000000..9b1296c3 --- /dev/null +++ b/packages/project/package.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-project", + "version": "0.0.0", + "description": "NanoForge Editor - Client project service: project model, file system and caches", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/project" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty .", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "@nanoforge-dev/editor-kernel": "workspace:*", + "@nanoforge-dev/editor-protocol": "workspace:*", + "@nanoforge-dev/editor-rpc": "workspace:*", + "@nanoforge-dev/registry": "workspace:*" + }, + "devDependencies": { + "@nanoforge-dev/editor-server-core": "workspace:*", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/project/src/cache/cache.type.ts b/packages/project/src/cache/cache.type.ts new file mode 100644 index 00000000..3de7b030 --- /dev/null +++ b/packages/project/src/cache/cache.type.ts @@ -0,0 +1,21 @@ +import type { FileEntry } from '@nanoforge-dev/editor-protocol'; + +export interface CachedContent { + readonly content: Uint8Array; + readonly mtime: number; + readonly hash: string; +} + +/** File contents cache (OPFS in browsers). Keys are project-relative paths. */ +export interface ContentCache { + get(path: string): Promise; + set(path: string, value: CachedContent): Promise; + delete(path: string): Promise; + clear(): Promise; +} + +/** Last known file tree (IndexedDB in browsers), for fast startup before revalidation. */ +export interface TreeCache { + load(): Promise; + save(entries: readonly FileEntry[]): Promise; +} diff --git a/packages/project/src/cache/index.ts b/packages/project/src/cache/index.ts new file mode 100644 index 00000000..e90bf1b2 --- /dev/null +++ b/packages/project/src/cache/index.ts @@ -0,0 +1,5 @@ +export { OpfsContentCache } from './opfs.content-cache'; +export { IndexedDbTreeCache } from './indexed-db.tree-cache'; +export * from './cache.type'; +export * from './memory.content-cache'; +export * from './memory.tree-cache'; diff --git a/packages/project/src/cache/indexed-db.tree-cache.ts b/packages/project/src/cache/indexed-db.tree-cache.ts new file mode 100644 index 00000000..590c68f5 --- /dev/null +++ b/packages/project/src/cache/indexed-db.tree-cache.ts @@ -0,0 +1,52 @@ +import type { FileEntry } from '@nanoforge-dev/editor-protocol'; + +import type { TreeCache } from './cache.type'; + +export const DB_NAME = 'nanoforge-editor'; +export const STORE = 'trees'; + +/** IndexedDB tree cache (one record per project). */ +export class IndexedDbTreeCache implements TreeCache { + static isSupported(): boolean { + return typeof indexedDB !== 'undefined'; + } + + constructor(private readonly _projectId: string) {} + + async load(): Promise { + try { + return await this._run('readonly', (store) => + store.get(this._projectId), + ); + } catch { + return undefined; + } + } + + async save(entries: readonly FileEntry[]): Promise { + try { + await this._run('readwrite', (store) => store.put(entries, this._projectId)); + } catch {} + } + + private async _run( + mode: IDBTransactionMode, + action: (store: IDBObjectStore) => IDBRequest, + ): Promise { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => request.result.createObjectStore(STORE); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB unavailable')); + }); + try { + return await new Promise((resolve, reject) => { + const request = action(db.transaction(STORE, mode).objectStore(STORE)); + request.onsuccess = () => resolve(request.result as T); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); + } finally { + db.close(); + } + } +} diff --git a/packages/project/src/cache/memory.content-cache.ts b/packages/project/src/cache/memory.content-cache.ts new file mode 100644 index 00000000..94d42f3e --- /dev/null +++ b/packages/project/src/cache/memory.content-cache.ts @@ -0,0 +1,26 @@ +import type { CachedContent, ContentCache } from './cache.type'; + +export class MemoryContentCache implements ContentCache { + private readonly _entries = new Map(); + + get(path: string) { + return Promise.resolve(this._entries.get(path)); + } + + set(path: string, value: CachedContent) { + this._entries.set(path, value); + return Promise.resolve(); + } + + delete(path: string) { + for (const key of [...this._entries.keys()]) { + if (key === path || key.startsWith(`${path}/`)) this._entries.delete(key); + } + return Promise.resolve(); + } + + clear() { + this._entries.clear(); + return Promise.resolve(); + } +} diff --git a/packages/project/src/cache/memory.tree-cache.ts b/packages/project/src/cache/memory.tree-cache.ts new file mode 100644 index 00000000..f0fb853c --- /dev/null +++ b/packages/project/src/cache/memory.tree-cache.ts @@ -0,0 +1,16 @@ +import type { FileEntry } from '@nanoforge-dev/editor-protocol'; + +import type { TreeCache } from './cache.type'; + +export class MemoryTreeCache implements TreeCache { + private _entries: FileEntry[] | undefined; + + load() { + return Promise.resolve(this._entries); + } + + save(entries: readonly FileEntry[]) { + this._entries = [...entries]; + return Promise.resolve(); + } +} diff --git a/packages/project/src/cache/opfs.content-cache.ts b/packages/project/src/cache/opfs.content-cache.ts new file mode 100644 index 00000000..8971fe5d --- /dev/null +++ b/packages/project/src/cache/opfs.content-cache.ts @@ -0,0 +1,83 @@ +import type { CachedContent, ContentCache } from './cache.type'; + +/** + * OPFS content cache: `nanoforge-editor//` holds the bytes and a + * small sidecar JSON holds mtime and hash. Failures degrade to cache misses. + */ +export class OpfsContentCache implements ContentCache { + private _dir: Promise | undefined; + + static isSupported(): boolean { + return ( + typeof navigator !== 'undefined' && typeof navigator.storage?.getDirectory === 'function' + ); + } + + constructor(private readonly _projectId: string) {} + + async get(path: string): Promise { + try { + const dir = await this._directory(); + const meta = await (await (await dir.getFileHandle(`${key(path)}.meta`)).getFile()).text(); + const bytes = await (await (await dir.getFileHandle(key(path))).getFile()).arrayBuffer(); + const { mtime, hash } = JSON.parse(meta) as { mtime: number; hash: string }; + return { content: new Uint8Array(bytes), mtime, hash }; + } catch { + return undefined; + } + } + + async set(path: string, value: CachedContent): Promise { + try { + const dir = await this._directory(); + await write(dir, key(path), value.content); + await write( + dir, + `${key(path)}.meta`, + JSON.stringify({ mtime: value.mtime, hash: value.hash }), + ); + } catch {} + } + + async delete(path: string): Promise { + try { + const dir = await this._directory(); + const prefix = key(path); + for await (const name of (dir as unknown as { keys(): AsyncIterable }).keys()) { + if (name === prefix || name === `${prefix}.meta` || name.startsWith(`${prefix}%2F`)) { + await dir.removeEntry(name); + } + } + } catch {} + } + + async clear(): Promise { + try { + const root = await ( + await navigator.storage.getDirectory() + ).getDirectoryHandle('nanoforge-editor', { create: true }); + await root.removeEntry(this._projectId, { recursive: true }); + this._dir = undefined; + } catch {} + } + + private _directory(): Promise { + this._dir ??= navigator.storage + .getDirectory() + .then((root) => root.getDirectoryHandle('nanoforge-editor', { create: true })) + .then((root) => root.getDirectoryHandle(this._projectId, { create: true })); + return this._dir; + } +} + +export const key = (path: string) => encodeURIComponent(path); + +export const write = async ( + dir: FileSystemDirectoryHandle, + name: string, + data: Uint8Array | string, +) => { + const writable = await (await dir.getFileHandle(name, { create: true })).createWritable(); + await writable.write(typeof data === 'string' ? data : new Blob([data as BlobPart])); + await writable.close(); +}; diff --git a/packages/project/src/fs/index.ts b/packages/project/src/fs/index.ts new file mode 100644 index 00000000..afef279e --- /dev/null +++ b/packages/project/src/fs/index.ts @@ -0,0 +1 @@ +export * from './project-fs'; diff --git a/packages/project/src/fs/project-fs.ts b/packages/project/src/fs/project-fs.ts new file mode 100644 index 00000000..21f6e9a9 --- /dev/null +++ b/packages/project/src/fs/project-fs.ts @@ -0,0 +1,233 @@ +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + type Observable, + ObservableValue, +} from '@nanoforge-dev/editor-kernel'; +import { + type FileChange, + type FileContent, + type FileEntry, + FsContract, +} from '@nanoforge-dev/editor-protocol'; +import type { RpcApi, RpcClient } from '@nanoforge-dev/editor-rpc'; + +import type { ContentCache, TreeCache } from '../cache/cache.type'; + +const decoder = new TextDecoder(); + +/** Hides files from the whole editor (see the server's file system). */ +export const NFIGNORE_FILE = '.nfignore'; + +export interface WriteOptions { + /** Fails with CONFLICT if the file changed since this hash (null = must not exist). */ + expectedHash?: string | null; +} + +/** + * Files of the open project. The tree is loaded once (cached for fast startup), then kept in + * sync by the server change stream, so contents can be served from the local cache whenever + * the cached copy is as recent as the tree says. + */ +export class ProjectFs implements Disposable { + private readonly _fs: RpcApi; + private readonly _tree = new Map(); + private readonly _ready = new ObservableValue(false); + private readonly _onDidChange = new Emitter(); + private readonly _store = new DisposableStore(); + + /** Batched changes (from the server watcher and this client's own writes). */ + readonly onDidChange: Event = this._onDidChange.event; + + constructor( + readonly projectId: string, + private readonly _rpc: RpcClient, + private readonly _contents: ContentCache, + private readonly _treeCache: TreeCache, + ) { + this._fs = _rpc.api(FsContract); + } + + /** True once the tree was fetched from the server (cached trees may be stale before). */ + get ready(): Observable { + return this._ready.readonly(); + } + + /** Loads the cached tree, subscribes to changes, then revalidates against the server. */ + async initialize(): Promise { + const cached = await this._treeCache.load(); + for (const entry of cached ?? []) this._tree.set(entry.path, entry); + this._store.add( + this._rpc.subscribe( + FsContract, + 'changes', + { project: this.projectId }, + ({ changes }) => void this._applyRemote(changes), + ), + ); + this._store.add(this._rpc.onDidReconnect(() => void this.refresh().catch(() => undefined))); + await this.refresh(); + } + + /** Re-fetches the whole tree and reports the differences as changes. */ + async refresh(): Promise { + const entries = await this._fs.list({ project: this.projectId, path: '', recursive: true }); + const next = new Map(entries.map((entry) => [entry.path, entry])); + const changes: FileChange[] = []; + for (const [path, entry] of next) { + const previous = this._tree.get(path); + if (!previous) changes.push({ type: 'created', path, kind: entry.kind }); + else if (previous.mtime !== entry.mtime || previous.size !== entry.size) { + changes.push({ type: 'changed', path, kind: entry.kind }); + } + } + for (const [path, entry] of this._tree) { + if (!next.has(path)) changes.push({ type: 'deleted', path, kind: entry.kind }); + } + this._tree.clear(); + for (const [path, entry] of next) this._tree.set(path, entry); + this._ready.set(true); + await this._treeCache.save(entries); + for (const change of changes) + if (change.type === 'deleted') await this._contents.delete(change.path); + if (changes.length) this._onDidChange.fire(changes); + } + + /** Last known entry (synchronous, from the tree). */ + entry(path: string): FileEntry | undefined { + return this._tree.get(path); + } + + /** Direct children of a directory, from the tree. */ + children(path: string): FileEntry[] { + const prefix = path ? `${path}/` : ''; + return [...this._tree.values()] + .filter( + (entry) => + entry.path.startsWith(prefix) && + !entry.path.slice(prefix.length).includes('/') && + entry.path !== path, + ) + .sort((a, b) => + a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === 'directory' ? -1 : 1, + ); + } + + get entries(): readonly FileEntry[] { + return [...this._tree.values()]; + } + + /** + * `fresh` asks the server even when the cached content looks current: the tree only learns + * of a change on disk when the watcher reports it. + */ + async read(path: string, options: { fresh?: boolean } = {}): Promise { + const known = this._tree.get(path); + const cached = !options.fresh && known && (await this._contents.get(path)); + if (known && cached && cached.mtime === known.mtime && cached.content.length === known.size) { + return { ...known, content: cached.content, hash: cached.hash }; + } + const file = await this._fs.read({ project: this.projectId, path }); + this._tree.set(path, { path, kind: 'file', size: file.size, mtime: file.mtime }); + await this._contents.set(path, { content: file.content, mtime: file.mtime, hash: file.hash }); + return file; + } + + async readText( + path: string, + options: { fresh?: boolean } = {}, + ): Promise<{ text: string; hash: string; mtime: number }> { + const file = await this.read(path, options); + return { text: decoder.decode(file.content), hash: file.hash, mtime: file.mtime }; + } + + async write( + path: string, + content: Uint8Array | string, + options: WriteOptions = {}, + ): Promise { + const result = await this._fs.write({ + project: this.projectId, + path, + content, + ...(options.expectedHash !== undefined && { expectedHash: options.expectedHash }), + }); + const bytes = typeof content === 'string' ? new TextEncoder().encode(content) : content; + const existed = this._tree.has(path); + this._tree.set(path, { path, kind: 'file', size: result.size, mtime: result.mtime }); + await this._contents.set(path, { content: bytes, mtime: result.mtime, hash: result.hash }); + this._onDidChange.fire([{ type: existed ? 'changed' : 'created', path, kind: 'file' }]); + return result; + } + + async mkdir(path: string): Promise { + const entry = await this._fs.mkdir({ project: this.projectId, path }); + this._tree.set(path, entry); + } + + async rename(from: string, to: string, overwrite = false): Promise { + await this._fs.rename({ project: this.projectId, from, to, overwrite }); + await this.refresh(); + } + + async copy(from: string, to: string, overwrite = false): Promise { + await this._fs.copy({ project: this.projectId, from, to, overwrite }); + await this.refresh(); + } + + /** Moves the entry to the project trash; resolves with its place there (for `restore`). */ + async delete(path: string): Promise<{ trashPath: string }> { + const kind = this.entry(path)?.kind ?? 'file'; + const result = await this._fs.delete({ project: this.projectId, path }); + this._removeFromTree(path); + await this._contents.delete(path); + this._onDidChange.fire([{ type: 'deleted', path, kind }]); + return result; + } + + /** Undo of `delete`: brings the entry back from the trash. */ + async restore(trashPath: string, path: string): Promise { + await this._fs.restore({ project: this.projectId, trashPath, path }); + await this.refresh(); + } + + /** Local editors: shows the entry in the OS file manager. */ + async reveal(path: string): Promise { + await this._fs.reveal({ project: this.projectId, path }); + } + + dispose(): void { + this._store.dispose(); + this._onDidChange.dispose(); + } + + private async _applyRemote(changes: readonly FileChange[]): Promise { + const relevant: FileChange[] = []; + for (const change of changes) { + if (change.type === 'deleted') { + this._removeFromTree(change.path); + await this._contents.delete(change.path); + relevant.push(change); + continue; + } + const entry = await this._fs + .stat({ project: this.projectId, path: change.path }) + .catch(() => null); + if (!entry) continue; + const previous = this._tree.get(change.path); + this._tree.set(change.path, entry); + if (previous && previous.mtime === entry.mtime && previous.size === entry.size) continue; + relevant.push(change); + } + if (relevant.length) this._onDidChange.fire(relevant); + if (changes.some((change) => change.path === NFIGNORE_FILE)) await this.refresh(); + } + + private _removeFromTree(path: string): void { + for (const key of [...this._tree.keys()]) { + if (key === path || key.startsWith(`${path}/`)) this._tree.delete(key); + } + } +} diff --git a/packages/project/src/index.ts b/packages/project/src/index.ts new file mode 100644 index 00000000..ddf95ca9 --- /dev/null +++ b/packages/project/src/index.ts @@ -0,0 +1,4 @@ +export * from './cache'; +export * from './fs'; +export * from './service'; +export * from './workspace'; diff --git a/packages/project/src/service/client-project.ts b/packages/project/src/service/client-project.ts new file mode 100644 index 00000000..ada45ff3 --- /dev/null +++ b/packages/project/src/service/client-project.ts @@ -0,0 +1,82 @@ +import { + type AppInfo, + type Disposable, + DisposableStore, + type EngineLibProvider, + type Observable, + ObservableValue, + derived, +} from '@nanoforge-dev/editor-kernel'; +import { type ProjectModel, ProjectsContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; + +import type { ContentCache, TreeCache } from '../cache/cache.type'; +import { IndexedDbTreeCache } from '../cache/indexed-db.tree-cache'; +import { MemoryContentCache } from '../cache/memory.content-cache'; +import { MemoryTreeCache } from '../cache/memory.tree-cache'; +import { OpfsContentCache } from '../cache/opfs.content-cache'; +import { ProjectFs } from '../fs/project-fs'; + +export interface CacheFactory { + contents(projectId: string): ContentCache; + tree(projectId: string): TreeCache; +} + +/** Browser caches when available, memory otherwise. */ +export const defaultCacheFactory: CacheFactory = { + contents: (id) => + OpfsContentCache.isSupported() ? new OpfsContentCache(id) : new MemoryContentCache(), + tree: (id) => + IndexedDbTreeCache.isSupported() ? new IndexedDbTreeCache(id) : new MemoryTreeCache(), +}; + +/** A project opened in this editor window. */ +export class ClientProject implements Disposable, EngineLibProvider { + private readonly _store = new DisposableStore(); + private readonly _model: ObservableValue; + + readonly fs: ProjectFs; + /** Apps as the kernel sees them (engine lib gating). */ + readonly apps: Observable; + + constructor( + initial: ProjectModel, + rpc: RpcClient, + caches: CacheFactory, + /** + * Whether its code may run here. An untrusted local folder is open in restricted mode: its + * config is read without running it, and its plugins, builds and games don't run. + */ + readonly trusted: boolean, + ) { + this._model = new ObservableValue(initial); + this.fs = this._store.add( + new ProjectFs(initial.id, rpc, caches.contents(initial.id), caches.tree(initial.id)), + ); + this._store.add( + rpc.subscribe(ProjectsContract, 'model', { id: initial.id }, (model) => + this._model.set(model), + ), + ); + this.apps = derived([this._model], (model) => + model.apps.map((app) => ({ + id: app.id, + name: app.name, + type: app.type, + engineLibs: app.engineLibs, + })), + ); + } + + get id(): string { + return this._model.get().id; + } + + get model(): Observable { + return this._model.readonly(); + } + + dispose(): void { + this._store.dispose(); + } +} diff --git a/packages/project/src/service/index.ts b/packages/project/src/service/index.ts new file mode 100644 index 00000000..dc688bcd --- /dev/null +++ b/packages/project/src/service/index.ts @@ -0,0 +1,2 @@ +export * from './client-project'; +export * from './project-service'; diff --git a/packages/project/src/service/project-service.ts b/packages/project/src/service/project-service.ts new file mode 100644 index 00000000..6a40364f --- /dev/null +++ b/packages/project/src/service/project-service.ts @@ -0,0 +1,77 @@ +import { + type Disposable, + type Observable, + ObservableValue, + createToken, +} from '@nanoforge-dev/editor-kernel'; +import { + type ProjectRef, + ProjectsContract, + type RecentProject, +} from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; + +import { type CacheFactory, ClientProject, defaultCacheFactory } from './client-project'; + +/** Opens projects through the editor server; one project is active per window. */ +export class ProjectService implements Disposable { + private readonly _current = new ObservableValue(undefined); + + constructor( + private readonly _rpc: RpcClient, + private readonly _caches: CacheFactory = defaultCacheFactory, + ) {} + + get current(): Observable { + return this._current.readonly(); + } + + async open(ref: ProjectRef): Promise { + const projects = this._rpc.api(ProjectsContract); + const { id } = await projects.open(ref); + return this.load(id); + } + + /** Attaches to a project already opened on the server (e.g. `/project/` reload). */ + async load(id: string, onStage?: (stage: 'model' | 'files') => void): Promise { + if (this._current.get()?.id === id) return this._current.get()!; + onStage?.('model'); + const api = this._rpc.api(ProjectsContract); + const [model, trusted] = await Promise.all([api.model({ id }), api.isTrusted({ id })]); + const project = new ClientProject(model, this._rpc, this._caches, trusted); + onStage?.('files'); + await project.fs.initialize(); + this.close(); + this._current.set(project); + return project; + } + + /** + * Trusts the open local project's folder: its code runs from now on (config, plugins, builds, + * games). The server reopens it and this one closes: load it again (`load`, or a reload). + */ + async trust(): Promise { + const current = this._current.get(); + if (!current) return; + const api = this._rpc.api(ProjectsContract); + await api.trust({ id: current.id }); + await api.open({ path: current.model.get().location }); + if (this._current.get() === current) this.close(); + } + + recent(): Promise { + return this._rpc.api(ProjectsContract).recent(null); + } + + close(): void { + const current = this._current.get(); + this._current.set(undefined); + current?.dispose(); + } + + dispose(): void { + this.close(); + } +} + +export const ProjectServiceToken = createToken('project.service'); diff --git a/packages/project/src/workspace/app-operations.ts b/packages/project/src/workspace/app-operations.ts new file mode 100644 index 00000000..834f5f2f --- /dev/null +++ b/packages/project/src/workspace/app-operations.ts @@ -0,0 +1,104 @@ +import type { AppModel, ProjectModel } from '@nanoforge-dev/editor-protocol'; + +import { Changes } from './changes'; +import { withEditorLibrary } from './editor-library'; +import { type Json, editJson, packageJson, withWorkspaceGlob } from './workspace-files'; +import { runnableApps, validateFolder, validatePackageName } from './workspace-queries'; +import { EDITOR_LIBRARY, ENGINE_BASE, MAIN, WORKSPACE_CONFIG } from './workspace.const'; +import { WorkspaceError } from './workspace.exception'; +import type { NewApp, Undo, WorkspaceIo } from './workspace.type'; + +/** + * A new client or server app in `apps/`: a copy of an app of the project, or an empty + * one whose engine libraries (and their versions) are those of an app of the project. Its + * dependencies are installed apart (a copy brings its `node_modules` with it). + */ +export const createApp = (io: WorkspaceIo, model: ProjectModel, app: NewApp): Promise => { + const changes = new Changes(io); + const root = `apps/${app.folder}`; + return changes.run(async () => { + const invalid = validateFolder(io, 'apps', app.folder) ?? validatePackageName(app.name); + if (invalid) throw new WorkspaceError(invalid); + if (model.apps.some((existing) => existing.name === app.name)) + throw new WorkspaceError(`${app.name} is already the name of an app or library.`); + if (app.from) { + await io.copy(app.from.root, root); + changes.created(root); + for (const leftover of ['dist', '.nanoforge']) + if (io.exists(`${root}/${leftover}`)) await io.remove(`${root}/${leftover}`); + await editJson(io, changes, `${root}/package.json`, (json) => { + json.name = app.name; + }); + } else { + const sibling = + runnableApps(model).find((existing) => existing.type === app.type) ?? + runnableApps(model)[0]; + const siblingJson = sibling + ? (JSON.parse(await io.read(packageJson(sibling))) as Json) + : undefined; + const known = { + ...(siblingJson?.dependencies as Record | undefined), + ...(siblingJson?.devDependencies as Record | undefined), + }; + const devDependencies = Object.fromEntries( + [...ENGINE_BASE, EDITOR_LIBRARY, 'typescript'] + .filter((name) => !sibling || name in known) + .map((name) => [name, known[name] ?? '*']), + ); + await changes.write( + `${root}/package.json`, + `${JSON.stringify({ name: app.name, version: '0.0.0', private: true, type: 'module', devDependencies }, null, 2)}\n`, + ); + changes.created(root); + await changes.write( + `${root}/nanoforge.config.ts`, + `import { defineConfig } from "@nanoforge-dev/config";\n\nexport default defineConfig({\n type: "${app.type}",\n});\n`, + ); + const tsconfig = sibling && `${sibling.root}/tsconfig.json`; + if (tsconfig && io.exists(tsconfig)) + await changes.write(`${root}/tsconfig.json`, await io.read(tsconfig)); + const main = MAIN[app.type]; + await changes.write( + `${root}/src/main.ts`, + EDITOR_LIBRARY in devDependencies ? (withEditorLibrary(main) ?? main) : main, + ); + await changes.write(`${root}/src/components/.gitkeep`, ''); + await changes.write(`${root}/src/systems/.gitkeep`, ''); + } + if (io.exists(WORKSPACE_CONFIG)) + await changes.write( + WORKSPACE_CONFIG, + withWorkspaceGlob(await io.read(WORKSPACE_CONFIG), 'apps/*'), + ); + }); +}; + +/** Renames an app: the name in its `package.json`. */ +export const renameApp = ( + io: WorkspaceIo, + model: ProjectModel, + app: AppModel, + name: string, +): Promise => { + const changes = new Changes(io); + return changes.run(async () => { + const invalid = validatePackageName(name); + if (invalid) throw new WorkspaceError(invalid); + if (model.apps.some((existing) => existing !== app && existing.name === name)) + throw new WorkspaceError(`${name} is already the name of an app or library.`); + await editJson(io, changes, packageJson(app), (json) => { + json.name = name; + }); + }); +}; + +/** Removes an app (its folder goes to the trash). The last app of a project stays. */ +export const removeApp = (io: WorkspaceIo, model: ProjectModel, app: AppModel): Promise => { + const changes = new Changes(io); + return changes.run(async () => { + if (!app.root) throw new WorkspaceError('The project is this app: it cannot be removed.'); + if (runnableApps(model).length <= 1) + throw new WorkspaceError('A project needs at least one app.'); + await changes.remove(app.root); + }); +}; diff --git a/packages/project/src/workspace/changes.ts b/packages/project/src/workspace/changes.ts new file mode 100644 index 00000000..1f5883e1 --- /dev/null +++ b/packages/project/src/workspace/changes.ts @@ -0,0 +1,97 @@ +import { WorkspaceError } from './workspace.exception'; +import type { ImportsEdit, Undo, WorkspaceIo } from './workspace.type'; + +interface Step { + readonly undo: Undo; + /** The file this step wrote, and the text it left there. */ + readonly written?: { readonly path: string; readonly text: string }; + /** An edit made elsewhere (`renameImports`), with its own check. */ + readonly edit?: { readonly path: string; readonly edit: ImportsEdit }; +} + +/** Records what an operation changed, to put it back in reverse order. */ +export class Changes { + private readonly _steps: Step[] = []; + + constructor(private readonly _io: WorkspaceIo) {} + + /** Writes a file, remembering what was there. */ + async write(path: string, text: string): Promise { + const before = this._io.exists(path) ? await this._io.read(path) : undefined; + if (before === text) return; + await this._io.write(path, text); + this._steps.push({ + undo: async () => { + if (before !== undefined) await this._io.write(path, before); + else if (this._io.exists(path)) await this._io.remove(path); + }, + written: { path, text }, + }); + } + + /** Rewrites a code file's imports of a package (`WorkspaceIo.renameImports`). */ + async renameImports(path: string, from: string, to: string): Promise { + if (!this._io.renameImports) + throw new WorkspaceError(`Imports of ${from} can't be rewritten without the code analysis.`); + const edit = await this._io.renameImports(path, from, to); + if (edit) this._steps.push({ undo: () => edit.undo(), edit: { path, edit } }); + } + + /** Removes a file or folder (to the trash). */ + async remove(path: string): Promise { + if (!this._io.exists(path)) return; + this._steps.push({ undo: await this._io.remove(path) }); + } + + /** Something new was made at `path` (a copy): undo removes it. */ + created(path: string): void { + this._steps.push({ + undo: async () => { + if (this._io.exists(path)) await this._io.remove(path); + }, + }); + } + + /** Runs an operation; what it changed before failing is put back. */ + async run(operation: () => Promise): Promise { + try { + await operation(); + } catch (error) { + await this._undo(false).catch(() => undefined); + throw error; + } + return () => this._undo(true); + } + + /** + * Puts everything back, last change first. With `check`, nothing is undone when a file was + * edited since the operation (its edit would be lost); the steps stay for another try. A step + * that fails does not stop the others: the first failure is rethrown at the end. + */ + private async _undo(check: boolean): Promise { + if (check) { + const last = new Map(); + for (const { written } of this._steps) if (written) last.set(written.path, written.text); + const changed: string[] = []; + for (const [path, text] of last) { + if (!this._io.exists(path) || (await this._io.read(path)) !== text) changed.push(path); + } + for (const { edit } of this._steps) { + if (edit && !(await edit.edit.isValid())) changed.push(edit.path); + } + if (changed.length) + throw new WorkspaceError( + `Not undone: ${changed.join(', ')} changed since. Undo those edits first, or change it back by hand.`, + ); + } + const failures: unknown[] = []; + for (const step of this._steps.splice(0).reverse()) { + try { + await step.undo(); + } catch (error) { + failures.push(error); + } + } + if (failures.length) throw failures[0]; + } +} diff --git a/packages/project/src/workspace/editor-library.ts b/packages/project/src/workspace/editor-library.ts new file mode 100644 index 00000000..cec2f472 --- /dev/null +++ b/packages/project/src/workspace/editor-library.ts @@ -0,0 +1,65 @@ +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +import { Changes } from './changes'; +import { editJson, packageJson } from './workspace-files'; +import { EDITOR_LIBRARY } from './workspace.const'; +import { WorkspaceError } from './workspace.exception'; +import type { Undo, WorkspaceIo } from './workspace.type'; + +/** + * The text of an entry file with `EditorLibrary` registered after its last `app.use(...)`. + * Undefined when it already is, or when the file registers no library to put it after. + */ +export const withEditorLibrary = (text: string): string | undefined => { + if (/\bEditorLibrary\b/.test(text)) return undefined; + const last = [...text.matchAll(/^([ \t]*)(\w+)\.use\(.*\);[ \t]*$/gm)].at(-1); + if (!last) return undefined; + const end = last.index + last[0].length; + const imports = [ + ...text.matchAll(/^import [^;]*?from\s*["']@nanoforge-dev\/[^"']+["'];?[ \t]*$/gm), + ]; + const after = imports.at(-1); + const line = `import { EditorLibrary } from "${EDITOR_LIBRARY}";`; + const used = `${text.slice(0, end)}\n${last[1]}${last[2]}.use(new EditorLibrary());${text.slice(end)}`; + if (!after) return `${line}\n${used}`; + const at = after.index + after[0].length; + return `${used.slice(0, at)}\n${line}${used.slice(at)}`; +}; + +/** + * Makes an app drivable by the editor: `@nanoforge-dev/editor-lib` next to its other engine + * libraries (at the version of `@nanoforge-dev/core`), and `EditorLibrary` registered in its + * entry file. Refused when the entry file already registers it. + */ +export const addEditorLibrary = (io: WorkspaceIo, app: AppModel): Promise => { + const changes = new Changes(io); + return changes.run(async () => { + if (!app.entryFile || !io.exists(app.entryFile)) + throw new WorkspaceError(`${app.name} has no entry file.`); + const main = await io.read(app.entryFile); + if (/\bEditorLibrary\b/.test(main)) + throw new WorkspaceError( + `${app.entryFile} already registers the editor library: update the NanoForge engine packages of the project.`, + ); + const edited = withEditorLibrary(main); + if (!edited) + throw new WorkspaceError( + `${app.entryFile} registers no library with app.use(…): add app.use(new EditorLibrary()) by hand, from ${EDITOR_LIBRARY}.`, + ); + await editJson(io, changes, packageJson(app), (json) => { + const sections = ['dependencies', 'devDependencies'] as const; + if (sections.some((key) => EDITOR_LIBRARY in ((json[key] ?? {}) as object))) return; + const key = + sections.find((section) => '@nanoforge-dev/core' in ((json[section] ?? {}) as object)) ?? + 'dependencies'; + const entries = (json[key] ?? {}) as Record; + json[key] = Object.fromEntries( + Object.entries({ + ...entries, + [EDITOR_LIBRARY]: entries['@nanoforge-dev/core'] ?? '*', + }).sort(([a], [b]) => a.localeCompare(b)), + ); + }); + await changes.write(app.entryFile, edited); + }); +}; diff --git a/packages/project/src/workspace/index.ts b/packages/project/src/workspace/index.ts new file mode 100644 index 00000000..464a2453 --- /dev/null +++ b/packages/project/src/workspace/index.ts @@ -0,0 +1,15 @@ +export * from './workspace.type'; +export * from './workspace.exception'; +export { EDITOR_LIBRARY } from './workspace.const'; +export * from './workspace-queries'; +export { withConfigLibrary } from './library-config'; +export { + createLibrary, + setLibraryUse, + libraryImporters, + renameLibrary, + removeLibrary, +} from './library-operations'; +export * from './editor-library'; +export * from './app-operations'; +export * from './workspace-io'; diff --git a/packages/project/src/workspace/library-config.ts b/packages/project/src/workspace/library-config.ts new file mode 100644 index 00000000..1db45a94 --- /dev/null +++ b/packages/project/src/workspace/library-config.ts @@ -0,0 +1,83 @@ +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +import { type Changes } from './changes'; +import { + configFile, + editJson, + packageJson, + relativeTo, + resolveFrom, + setDependency, +} from './workspace-files'; +import { WorkspaceError } from './workspace.exception'; +import type { WorkspaceIo } from './workspace.type'; + +/** + * The text of an app's `nanoforge.config` with a shared library added to its `libs` (paths + * relative to the app, as the CLI writes them) or removed from it. Entries are compared by the + * folder they point at. Undefined when the text has nowhere to put it (no `type` property). + */ +export const withConfigLibrary = ( + text: string, + appRoot: string, + libraryRoot: string, + used: boolean, +): string | undefined => { + const quote = /\btype\s*:\s*(["'])/.exec(text)?.[1] ?? '"'; + const literal = (value: string) => `${quote}${value}${quote}`; + const property = /(\blibs\s*:\s*)\[([^\]]*)\]/.exec(text); + if (property) { + const entries = [...property[2]!.matchAll(/(["'`])((?:\\.|(?!\1).)*)\1/g)].map( + (match) => match[2]!, + ); + const others = entries.filter((entry) => resolveFrom(appRoot, entry) !== libraryRoot); + if (used && others.length < entries.length) return text; + const next = used ? [...others, relativeTo(appRoot, libraryRoot)] : others; + if (next.length === entries.length && next.every((entry, index) => entry === entries[index])) + return text; + const start = property.index + property[1]!.length; + const end = property.index + property[0].length; + return `${text.slice(0, start)}[${next.map(literal).join(', ')}]${text.slice(end)}`; + } + if (!used) return text; + const type = /(^[ \t]*)?\btype\s*:\s*(["'])[^"']*\2([ \t]*,)?/m.exec(text); + if (!type) return undefined; + const end = type.index + type[0].length; + const entry = `libs: [${literal(relativeTo(appRoot, libraryRoot))}]`; + const added = + type[1] !== undefined + ? `${type[3] ? '' : ','}\n${type[1]}${entry},` + : type[3] + ? ` ${entry},` + : `, ${entry}`; + return `${text.slice(0, end)}${added}${text.slice(end)}`; +}; + +/** + * Makes an app use a library or stop: its config's `libs` (what the editor and the CLI read) + * and its package.json dependency (what package managers read), kept in step. A library has no + * `libs`: it uses others through its package.json only. + */ +export const setUse = async ( + io: WorkspaceIo, + changes: Changes, + app: Pick, + library: Pick, + used: boolean, +): Promise => { + await editJson(io, changes, packageJson(app), (json) => setDependency(json, library.name, used)); + if (app.type === 'lib') return; + const file = configFile(io, app); + if (!file) { + if (used) throw new WorkspaceError(`${app.root || 'The app'} has no nanoforge.config.ts.`); + return; + } + const text = await io.read(file); + const edited = withConfigLibrary(text, app.root, library.root, used); + if (edited === undefined) + throw new WorkspaceError( + `${file} could not be edited: add "${relativeTo(app.root, library.root)}" to its libs by hand.`, + [file], + ); + if (edited !== text) await changes.write(file, edited); +}; diff --git a/packages/project/src/workspace/library-operations.ts b/packages/project/src/workspace/library-operations.ts new file mode 100644 index 00000000..4a932eee --- /dev/null +++ b/packages/project/src/workspace/library-operations.ts @@ -0,0 +1,167 @@ +import type { AppModel, ProjectModel } from '@nanoforge-dev/editor-protocol'; +import { withPathsEntry, withoutPathsEntry } from '@nanoforge-dev/registry/tsconfig-paths'; + +import { Changes } from './changes'; +import { setUse } from './library-config'; +import { editJson, packageJson, withWorkspaceGlob } from './workspace-files'; +import { validateFolder, validatePackageName } from './workspace-queries'; +import { CODE, LIB_CONFIG, TSCONFIG, WORKSPACE_CONFIG } from './workspace.const'; +import { WorkspaceError } from './workspace.exception'; +import type { NewLibrary, Undo, WorkspaceIo } from './workspace.type'; + +/** + * A shared library: `libs/` with its config and package.json, `libs/*` in the + * workspace's packages, a `paths` entry so it is imported by package name, and the dependency in + * each app that uses it. + */ +export const createLibrary = (io: WorkspaceIo, library: NewLibrary): Promise => { + const changes = new Changes(io); + const root = `libs/${library.folder}`; + return changes.run(async () => { + const invalid = + validateFolder(io, 'libs', library.folder) ?? validatePackageName(library.packageName); + if (invalid) throw new WorkspaceError(invalid); + await changes.write( + `${root}/package.json`, + `${JSON.stringify({ name: library.packageName, version: '0.0.0', private: true, type: 'module' }, null, 2)}\n`, + ); + await changes.write(`${root}/nanoforge.config.ts`, LIB_CONFIG); + await changes.write(`${root}/src/components/.gitkeep`, ''); + await changes.write(`${root}/src/systems/.gitkeep`, ''); + changes.created(root); + + const tsconfig = io.exists(TSCONFIG) ? await io.read(TSCONFIG) : '{}\n'; + await changes.write( + TSCONFIG, + withPathsEntry(tsconfig, `${library.packageName}/*`, `./${root}/src/*`), + ); + for (const app of library.usedBy) + await setUse(io, changes, app, { root, name: library.packageName }, true); + if (io.exists(WORKSPACE_CONFIG)) + await changes.write( + WORKSPACE_CONFIG, + withWorkspaceGlob(await io.read(WORKSPACE_CONFIG), 'libs/*'), + ); + }); +}; + +/** + * Makes an app (or a library) use a shared library, or stop using it: its config's `libs` and + * its package.json dependency. + */ +export const setLibraryUse = ( + io: WorkspaceIo, + app: Pick, + library: Pick, + used: boolean, +): Promise => { + const changes = new Changes(io); + return changes.run(() => setUse(io, changes, app, library, used)); +}; + +export const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** A module specifier of the library in an import, an export or a dynamic import. */ +export const specifier = (library: string) => + new RegExp( + `(\\bfrom\\s*|\\bimport\\s*\\(?\\s*|\\brequire\\s*\\(\\s*)(["'])${escape(library)}(/[^"']*)?\\2`, + 'g', + ); + +/** A file's text as the user sees it, unsaved edits included (`WorkspaceIo.readEdited`). */ +const editedText = (io: WorkspaceIo, path: string) => (io.readEdited ?? io.read).call(io, path); + +/** The code files that import a library (outside of it). */ +export const libraryImporters = async (io: WorkspaceIo, library: AppModel): Promise => { + const importers: string[] = []; + for (const path of io.files()) { + if (!CODE.test(path) || path === library.root || path.startsWith(`${library.root}/`)) continue; + if (specifier(library.name).test(await editedText(io, path))) importers.push(path); + } + return importers.sort(); +}; + +/** + * Renames a shared library's package: its `package.json`, the `paths` entry, the dependencies of + * the apps that use it, and every import of it in the project. + */ +export const renameLibrary = ( + io: WorkspaceIo, + model: ProjectModel, + library: AppModel, + packageName: string, +): Promise => { + const changes = new Changes(io); + return changes.run(async () => { + const invalid = validatePackageName(packageName); + if (invalid) throw new WorkspaceError(invalid); + if (model.apps.some((app) => app.name === packageName)) + throw new WorkspaceError(`${packageName} is already the name of an app or library.`); + const importers: string[] = []; + for (const path of io.files()) { + if (CODE.test(path) && specifier(library.name).test(await editedText(io, path))) + importers.push(path); + } + if (importers.length && !io.renameImports) + throw new WorkspaceError( + `${library.name} is imported, and its imports can only be rewritten once the code analysis has started: try again in a moment.`, + importers, + ); + if (io.exists(TSCONFIG)) { + const without = withoutPathsEntry(await io.read(TSCONFIG), `${library.name}/*`); + await changes.write( + TSCONFIG, + withPathsEntry(without, `${packageName}/*`, `./${library.root}/src/*`), + ); + } + for (const app of model.apps) { + if (app === library || !io.exists(packageJson(app))) continue; + await editJson(io, changes, packageJson(app), (json) => { + for (const key of ['dependencies', 'devDependencies'] as const) { + const entries = json[key] as Record | undefined; + if (!entries || !(library.name in entries)) continue; + json[key] = Object.fromEntries( + Object.entries(entries) + .map(([name, range]) => [name === library.name ? packageName : name, range] as const) + .sort(([a], [b]) => a.localeCompare(b)), + ); + } + }); + } + for (const path of importers) await changes.renameImports(path, library.name, packageName); + await editJson(io, changes, packageJson(library), (json) => { + json.name = packageName; + }); + }); +}; + +/** + * Removes a shared library: its folder (to the trash), its `paths` entry and the dependencies on + * it. Refused while code imports it: the files are named. + */ +export const removeLibrary = ( + io: WorkspaceIo, + model: ProjectModel, + library: AppModel, +): Promise => { + const changes = new Changes(io); + return changes.run(async () => { + const importers = await libraryImporters(io, library); + if (importers.length) { + throw new WorkspaceError( + `${library.name} is imported by ${importers.length === 1 ? 'a file' : `${importers.length} files`}: remove those imports first.`, + importers, + ); + } + for (const app of model.apps) { + if (app === library || !io.exists(packageJson(app))) continue; + await setUse(io, changes, app, library, false); + } + if (io.exists(TSCONFIG)) + await changes.write( + TSCONFIG, + withoutPathsEntry(await io.read(TSCONFIG), `${library.name}/*`), + ); + await changes.remove(library.root); + }); +}; diff --git a/packages/project/src/workspace/workspace-files.ts b/packages/project/src/workspace/workspace-files.ts new file mode 100644 index 00000000..fb3ab2a7 --- /dev/null +++ b/packages/project/src/workspace/workspace-files.ts @@ -0,0 +1,82 @@ +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +import { type Changes } from './changes'; +import { CONFIG_NAMES } from './workspace.const'; +import { WorkspaceError } from './workspace.exception'; +import type { WorkspaceIo } from './workspace.type'; + +export type Json = Record; + +/** A JSON file rewritten with its own indentation. */ +export const editJson = async ( + io: WorkspaceIo, + changes: Changes, + path: string, + edit: (json: Json) => void, +): Promise => { + const text = io.exists(path) ? await io.read(path) : '{}\n'; + let json: Json; + try { + json = JSON.parse(text) as Json; + } catch { + throw new WorkspaceError(`${path} is not valid JSON`); + } + edit(json); + const indent = /^(\s+)"/m.exec(text)?.[1]?.replace(/\n/g, '') ?? ' '; + await changes.write(path, `${JSON.stringify(json, null, indent)}\n`); +}; + +/** The project path of `path` written relative to the folder `from` (both project paths). */ +export const resolveFrom = (from: string, path: string): string => { + const parts = from ? from.split('/') : []; + for (const part of path.split('/')) { + if (part === '..') parts.pop(); + else if (part && part !== '.') parts.push(part); + } + return parts.join('/'); +}; + +/** `to` written relative to the folder `from`, as a config's `libs` holds it. */ +export const relativeTo = (from: string, to: string): string => { + const source = from ? from.split('/') : []; + const target = to.split('/'); + let common = 0; + while (common < source.length && source[common] === target[common]) common++; + return [...source.slice(common).map(() => '..'), ...target.slice(common)].join('/') || '.'; +}; + +/** The `nanoforge.config.*` file of an app. */ +export const configFile = (io: Pick, app: Pick) => + CONFIG_NAMES.map((name) => (app.root ? `${app.root}/${name}` : name)).find((path) => + io.exists(path), + ); + +export const packageJson = (app: Pick): string => + app.root ? `${app.root}/package.json` : 'package.json'; + +/** Adds or removes a dependency on a library, keeping the keys sorted. */ +export const setDependency = (json: Json, name: string, used: boolean): void => { + const without = (entries: unknown) => + Object.entries((entries ?? {}) as Record).filter(([key]) => key !== name); + const dependencies = without(json.dependencies); + if (used) + dependencies.push([ + name, + (json.dependencies as Record | undefined)?.[name] ?? 'workspace:*', + ]); + else if (json.devDependencies) + json.devDependencies = Object.fromEntries(without(json.devDependencies)); + const sorted = Object.fromEntries(dependencies.sort(([a], [b]) => a.localeCompare(b))); + if (Object.keys(sorted).length) json.dependencies = sorted; + else delete json.dependencies; +}; + +/** `libs/*` (or `apps/*`) in the workspace's `packages`, when missing. */ +export const withWorkspaceGlob = (text: string, glob: string): string => { + if (text.includes(glob)) return text; + return text.replace(/packages\s*:\s*\[([^\]]*)\]/, (_match, inner: string) => { + const quote = inner.includes("'") ? "'" : '"'; + const items = inner.trim().replace(/,\s*$/, ''); + return `packages: [${items ? `${items}, ` : ''}${quote}${glob}${quote}]`; + }); +}; diff --git a/packages/project/src/workspace/workspace-io.ts b/packages/project/src/workspace/workspace-io.ts new file mode 100644 index 00000000..da648fa3 --- /dev/null +++ b/packages/project/src/workspace/workspace-io.ts @@ -0,0 +1,16 @@ +import type { ClientProject } from '../service/client-project'; +import type { WorkspaceIo } from './workspace.type'; + +/** The files of an open project, as the workspace operations need them. */ +export const workspaceIo = (project: ClientProject): WorkspaceIo => ({ + exists: (path) => project.fs.entry(path) !== undefined, + files: () => + project.fs.entries.filter((entry) => entry.kind === 'file').map((entry) => entry.path), + read: async (path) => (await project.fs.readText(path, { fresh: true })).text, + write: (path, text) => project.fs.write(path, text).then(() => undefined), + remove: async (path) => { + const { trashPath } = await project.fs.delete(path); + return () => project.fs.restore(trashPath, path); + }, + copy: (from, to) => project.fs.copy(from, to), +}); diff --git a/packages/project/src/workspace/workspace-queries.ts b/packages/project/src/workspace/workspace-queries.ts new file mode 100644 index 00000000..23697e0f --- /dev/null +++ b/packages/project/src/workspace/workspace-queries.ts @@ -0,0 +1,26 @@ +import type { AppModel, ProjectModel } from '@nanoforge-dev/editor-protocol'; + +import { FOLDER, PACKAGE_NAME } from './workspace.const'; +import type { WorkspaceIo } from './workspace.type'; + +export const validatePackageName = (name: string): string | undefined => + PACKAGE_NAME.test(name) ? undefined : 'A package name: lowercase, like @my-game/shared.'; + +/** A new folder of `libs/` or `apps/`: its name, and that it is free. */ +export const validateFolder = ( + io: Pick, + parent: 'libs' | 'apps', + folder: string, +): string | undefined => { + if (!FOLDER.test(folder)) return 'Use lowercase letters, digits and dashes.'; + if (io.exists(`${parent}/${folder}`)) return `${parent}/${folder} already exists.`; + return undefined; +}; + +/** The shared libraries of a project. */ +export const librariesOf = (model: ProjectModel): AppModel[] => + model.apps.filter((app) => app.type === 'lib'); + +/** The apps of a project that run: its clients and servers. */ +export const runnableApps = (model: ProjectModel): AppModel[] => + model.apps.filter((app) => app.type !== 'lib'); diff --git a/packages/project/src/workspace/workspace.const.ts b/packages/project/src/workspace/workspace.const.ts new file mode 100644 index 00000000..1b006f78 --- /dev/null +++ b/packages/project/src/workspace/workspace.const.ts @@ -0,0 +1,74 @@ +import type { NewApp } from './workspace.type'; + +export const PACKAGE_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; +export const FOLDER = /^[a-z0-9][a-z0-9-]*$/; +export const CODE = /\.(?:[cm]?[jt]sx?)$/; +export const WORKSPACE_CONFIG = 'nanoforge.config.ts'; +export const TSCONFIG = 'tsconfig.json'; + +export const CONFIG_NAMES = [ + 'nanoforge.config.ts', + 'nanoforge.config.mts', + 'nanoforge.config.js', + 'nanoforge.config.mjs', +]; + +export const LIB_CONFIG = [ + 'import { defineConfig } from "@nanoforge-dev/config";', + '', + 'export default defineConfig({', + ' type: "lib",', + ' dir: { components: "src/components", systems: "src/systems", scenes: "src/scenes" },', + '});', + '', +].join('\n'); + +export const ENGINE_BASE = ['asset', 'common', 'core', 'env', 'ecs'].map( + (name) => `@nanoforge-dev/${name}`, +); + +/** The engine library a game registers to be driven by the editor (engine 2.0 and later). */ +export const EDITOR_LIBRARY = '@nanoforge-dev/editor-lib'; + +export const MAIN: Record = { + client: [ + 'import type { ClientRunOptions } from "@nanoforge-dev/common";', + 'import { NanoforgeFactory } from "@nanoforge-dev/core";', + 'import { EcsLibrary } from "@nanoforge-dev/ecs/client";', + '', + 'export const main = async (options: ClientRunOptions): Promise => {', + ' const app = NanoforgeFactory.createClient({ tickRate: 60 });', + ' const ecs = new EcsLibrary();', + '', + ' app.use(ecs);', + '', + ' await app.init(options);', + '', + ' const registry = ecs.registry;', + ' void registry;', + '', + ' await app.run();', + '};', + '', + ].join('\n'), + server: [ + 'import type { RunOptions } from "@nanoforge-dev/common";', + 'import { NanoforgeFactory } from "@nanoforge-dev/core";', + 'import { EcsLibrary } from "@nanoforge-dev/ecs/server";', + '', + 'export const main = async (options: RunOptions): Promise => {', + ' const app = NanoforgeFactory.createServer({ tickRate: 60 });', + ' const ecs = new EcsLibrary();', + '', + ' app.use(ecs);', + '', + ' await app.init(options);', + '', + ' const registry = ecs.registry;', + ' void registry;', + '', + ' await app.run();', + '};', + '', + ].join('\n'), +}; diff --git a/packages/project/src/workspace/workspace.exception.ts b/packages/project/src/workspace/workspace.exception.ts new file mode 100644 index 00000000..f67e2d19 --- /dev/null +++ b/packages/project/src/workspace/workspace.exception.ts @@ -0,0 +1,11 @@ +/** A change refused for a reason the user can act on. */ +export class WorkspaceError extends Error { + constructor( + message: string, + /** Files that stand in the way (the importers of a library being removed). */ + readonly paths: readonly string[] = [], + ) { + super(message); + this.name = 'WorkspaceError'; + } +} diff --git a/packages/project/src/workspace/workspace.type.ts b/packages/project/src/workspace/workspace.type.ts new file mode 100644 index 00000000..02b707ec --- /dev/null +++ b/packages/project/src/workspace/workspace.type.ts @@ -0,0 +1,64 @@ +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +/** + * Changes of a project's workspace: its apps (clients, servers) and its shared libraries + * (ADR 0004). Each operation edits the project's files through a small file interface and + * resolves with what undoes it. + * + * An app **uses** a shared library when its `package.json` lists the library's package name in + * `dependencies`: the meaning it has in any workspace. + */ + +/** The files of the project, as the operations need them. */ +export interface WorkspaceIo { + exists(path: string): boolean; + /** Paths of the project's files (what the editor lists: no `node_modules`). */ + files(): readonly string[]; + read(path: string): Promise; + /** + * A file's text as the user sees it: an open document's, unsaved edits included. Searches that + * must not miss a file read it (an import typed but not saved yet). Defaults to `read`. + */ + readEdited?(path: string): Promise; + /** Creates or replaces a file. */ + write(path: string, text: string): Promise; + /** Removes a file or folder; resolves with what brings it back. */ + remove(path: string): Promise<() => Promise>; + /** Copies a file or a folder with everything in it. */ + copy(from: string, to: string): Promise; + /** + * Points a code file's imports of package `from` at `to`, reading the code (not a text search: + * comments and strings stay as they are), through the open document if any. Resolves with the + * edit made, or undefined when the file has none. Without it, a library that is imported can't + * be renamed: a text search would also rewrite look-alikes in comments and strings. + */ + renameImports?(path: string, from: string, to: string): Promise; +} + +/** An edit of a file's imports, as `renameImports` made it. */ +export interface ImportsEdit { + undo(): Promise; + /** False when the file changed since: undoing would lose that change. */ + isValid(): Promise; +} + +export type Undo = () => Promise; + +export interface NewLibrary { + /** Folder in `libs/`. */ + readonly folder: string; + /** How apps import it: `@my-game/shared`. */ + readonly packageName: string; + /** The apps (and libraries) that use it from the start. */ + readonly usedBy: readonly Pick[]; +} + +export interface NewApp { + readonly type: 'client' | 'server'; + /** Folder in `apps/`. */ + readonly folder: string; + /** Package name of the app. */ + readonly name: string; + /** An app of the same type to copy; an empty app without it. */ + readonly from?: AppModel; +} diff --git a/packages/project/test/fixtures/workspace.ts b/packages/project/test/fixtures/workspace.ts new file mode 100644 index 00000000..1ab499ca --- /dev/null +++ b/packages/project/test/fixtures/workspace.ts @@ -0,0 +1,88 @@ +import type { AppModel, ProjectModel } from '@nanoforge-dev/editor-protocol'; +import type { WorkspaceIo } from '../../src/workspace/workspace.type'; + +/** A project in memory: files by path. */ +export const memory = (initial: Record) => { + const files = new Map(Object.entries(initial)); + const under = (path: string) => [...files.keys()].filter((key) => key.startsWith(`${path}/`)); + const io: WorkspaceIo = { + exists: (path) => files.has(path) || under(path).length > 0, + files: () => [...files.keys()], + read: async (path) => { + const text = files.get(path); + if (text === undefined) throw new Error(`no file ${path}`); + return text; + }, + write: async (path, text) => void files.set(path, text), + remove: async (path) => { + const removed = [path, ...under(path)].flatMap((key) => + files.has(key) ? [[key, files.get(key)!] as const] : [], + ); + for (const [key] of removed) files.delete(key); + return async () => { + for (const [key, text] of removed) files.set(key, text); + }; + }, + copy: async (from, to) => { + for (const key of under(from)) files.set(`${to}${key.slice(from.length)}`, files.get(key)!); + }, + }; + return { io, files, snapshot: () => Object.fromEntries([...files].sort()) }; +}; + +export const editPackage = async ( + files: Map, + path: string, + name: string, + range: string, +) => { + const pkg = JSON.parse(files.get(path)!) as { devDependencies: Record }; + pkg.devDependencies[name] = range; + files.set(path, `${JSON.stringify(pkg, null, 2)}\n`); +}; + +export const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; +export const app = (root: string, name: string, type: AppModel['type']): AppModel => ({ + id: root, + name, + type, + root, + language: 'ts', + dirs: { + assets: '', + components: `${root}/src/components`, + systems: `${root}/src/systems`, + scenes: '', + }, + engineLibs: {}, + libraries: [], +}); +export const client = app('apps/client', 'pong-client', 'client'); +export const shared = app('libs/shared', '@pong/shared', 'lib'); +export const server = app('apps/server', 'pong-server', 'server'); +export const model = (...apps: AppModel[]): ProjectModel => + ({ id: 'p', name: 'pong', location: '/pong', apps, diagnostics: [] }) as ProjectModel; + +export const project = () => + memory({ + 'nanoforge.config.ts': + 'import { defineConfig } from "@nanoforge-dev/config";\n\nexport default defineConfig({\n type: "workspace",\n packages: ["apps/*"],\n});\n', + 'tsconfig.json': '{\n // shared options\n "compilerOptions": {\n "strict": true\n }\n}\n', + 'apps/client/package.json': json({ + name: 'pong-client', + devDependencies: { + '@nanoforge-dev/core': '^2.0.0', + '@nanoforge-dev/ecs': '^2.0.0', + typescript: '^6.0.0', + eslint: '^10.0.0', + }, + }), + 'apps/client/nanoforge.config.ts': + 'import { defineConfig } from "@nanoforge-dev/config";\n\nexport default defineConfig({\n type: "client",\n});\n', + 'apps/client/tsconfig.json': '{ "extends": "../../tsconfig.json" }\n', + 'apps/client/src/main.ts': 'export const main = 1;\n', + 'apps/client/dist/main.js': 'built', + 'apps/server/package.json': json({ name: 'pong-server' }), + 'apps/server/nanoforge.config.ts': "export default { type: 'server', libs: [] };\n", + 'apps/server/src/main.ts': 'export const main = 2;\n', + }); diff --git a/packages/project/test/fs/project-fs.spec.ts b/packages/project/test/fs/project-fs.spec.ts new file mode 100644 index 00000000..d12b57a2 --- /dev/null +++ b/packages/project/test/fs/project-fs.spec.ts @@ -0,0 +1,116 @@ +import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { FileChange } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; +import { type EditorServer, createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core'; +import { createLoopbackClient } from '@nanoforge-dev/editor-server-core/testing'; + +import type { ContentCache } from '../../src/cache/cache.type'; +import { MemoryContentCache } from '../../src/cache/memory.content-cache'; +import { MemoryTreeCache } from '../../src/cache/memory.tree-cache'; +import { ProjectService } from '../../src/service/project-service'; + +const FIXTURE = join(import.meta.dirname, '../../../server-core/test/fixtures/pong-network'); + +let workspace: string; +let editor: EditorServer; +let rpc: RpcClient; +let service: ProjectService; +const contents = new MemoryContentCache(); +const reads: string[] = []; + +/** Content cache spy: counts server reads by observing cache fills. */ +const spyCache = (inner: ContentCache): ContentCache => ({ + get: (path) => inner.get(path), + set: (path, value) => { + reads.push(path); + return inner.set(path, value); + }, + delete: (path) => inner.delete(path), + clear: () => inner.clear(), +}); + +const waitFor = async (predicate: () => boolean, timeout = 2000) => { + const start = Date.now(); + while (!predicate() && Date.now() - start < timeout) await new Promise((r) => setTimeout(r, 5)); + return Date.now() - start; +}; + +beforeAll(async () => { + workspace = mkdtempSync(join(import.meta.dirname, '../../../server-core/test/.tmp-project-')); + cpSync(FIXTURE, join(workspace, 'pong'), { recursive: true }); + editor = createEditorServer({ + env: loadEnv( + { NODE_ENV: 'test', FS_ROOT: workspace, DATA_DIR: mkdtempSync(join(tmpdir(), 'nf-data-')) }, + workspace, + ), + version: 'test', + }); + rpc = createLoopbackClient(editor); + service = new ProjectService(rpc, { + contents: () => spyCache(contents), + tree: () => new MemoryTreeCache(), + }); +}); + +afterAll(() => { + service.dispose(); + rpc.dispose(); + editor.dispose(); + rmSync(workspace, { recursive: true, force: true }); +}); + +describe('ProjectFs', () => { + it('opens the project with its tree and engine libs', async () => { + const project = await service.open({ path: 'pong' }); + expect(project.fs.ready.get()).toBe(true); + expect(project.fs.children('apps').map((entry) => entry.path)).toEqual([ + 'apps/client', + 'apps/server', + ]); + expect(project.apps.get().map((app) => app.id)).toEqual(['apps/client', 'apps/server']); + expect(project.apps.get()[0]!.engineLibs).toHaveProperty('@nanoforge-dev/ecs'); + }); + + it('serves unchanged files from the content cache', async () => { + const project = service.current.get()!; + reads.length = 0; + const first = await project.fs.readText('apps/client/src/main.ts'); + const second = await project.fs.readText('apps/client/src/main.ts'); + expect(second).toEqual(first); + expect(reads).toEqual(['apps/client/src/main.ts']); + }); + + it('sees files written on disk within 200 ms', async () => { + const project = service.current.get()!; + const changes: FileChange[] = []; + const subscription = project.fs.onDidChange((batch) => changes.push(...batch)); + /** Server watcher initial scan. */ + await new Promise((resolve) => setTimeout(resolve, 300)); + writeFileSync(join(workspace, 'pong/apps/client/src/main.ts'), '// edited outside\n'); + const elapsed = await waitFor(() => changes.length > 0); + subscription.dispose(); + expect(changes).toEqual([{ type: 'changed', path: 'apps/client/src/main.ts', kind: 'file' }]); + expect(elapsed).toBeLessThan(200); + expect((await project.fs.readText('apps/client/src/main.ts')).text).toBe('// edited outside\n'); + }); + + it('writes with conflict detection and without echoing its own writes', async () => { + const project = service.current.get()!; + const { hash } = await project.fs.readText('apps/client/src/main.ts'); + const changes: FileChange[] = []; + const subscription = project.fs.onDidChange((batch) => changes.push(...batch)); + await project.fs.write('apps/client/src/main.ts', '// v2\n', { expectedHash: hash }); + await new Promise((resolve) => setTimeout(resolve, 250)); + subscription.dispose(); + expect(changes).toEqual([{ type: 'changed', path: 'apps/client/src/main.ts', kind: 'file' }]); + await expect( + project.fs.write('apps/client/src/main.ts', 'x', { expectedHash: hash }), + ).rejects.toMatchObject({ + code: 'CONFLICT', + }); + }); +}); diff --git a/packages/project/test/workspace/app-operations.spec.ts b/packages/project/test/workspace/app-operations.spec.ts new file mode 100644 index 00000000..33d161b7 --- /dev/null +++ b/packages/project/test/workspace/app-operations.spec.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; + +import { createApp, removeApp, renameApp } from '../../src/workspace/app-operations'; +import { addEditorLibrary, withEditorLibrary } from '../../src/workspace/editor-library'; +import { client, editPackage, model, project, server } from '../fixtures/workspace'; + +describe('apps', () => { + it('adds an empty app with the engine versions the project has', async () => { + const { io, files, snapshot } = project(); + const before = snapshot(); + const undo = await createApp(io, model(client, server), { + type: 'client', + folder: 'spectator', + name: 'pong-spectator', + }); + expect(JSON.parse(files.get('apps/spectator/package.json')!)).toEqual({ + name: 'pong-spectator', + version: '0.0.0', + private: true, + type: 'module', + devDependencies: { + '@nanoforge-dev/core': '^2.0.0', + '@nanoforge-dev/ecs': '^2.0.0', + typescript: '^6.0.0', + }, + }); + expect(files.get('apps/spectator/nanoforge.config.ts')).toContain('type: "client"'); + expect(files.get('apps/spectator/tsconfig.json')).toBe(files.get('apps/client/tsconfig.json')); + expect(files.get('apps/spectator/src/main.ts')).toContain('createClient'); + await undo(); + expect(snapshot()).toEqual(before); + }); + + it('adds an app as a copy of another, without its build output', async () => { + const { io, files } = project(); + await createApp(io, model(client, server), { + type: 'client', + folder: 'client-two', + name: 'pong-client-two', + from: client, + }); + expect(files.get('apps/client-two/src/main.ts')).toBe('export const main = 1;\n'); + expect(JSON.parse(files.get('apps/client-two/package.json')!).name).toBe('pong-client-two'); + expect(files.has('apps/client-two/dist/main.js')).toBe(false); + expect(files.has('apps/client/dist/main.js')).toBe(true); + + await expect( + createApp(io, model(client, server), { type: 'client', folder: 'client', name: 'x' }), + ).rejects.toThrow(/already exists/); + await expect( + createApp(io, model(client, server), { type: 'client', folder: 'new', name: 'pong-server' }), + ).rejects.toThrow(/already the name/); + }); + + it('registers the editor library in an app, next to its engine libraries', async () => { + const { io, files, snapshot } = project(); + files.set( + 'apps/client/src/main.ts', + [ + 'import { NanoforgeFactory } from "@nanoforge-dev/core";', + 'import { EcsLibrary } from "@nanoforge-dev/ecs/client";', + '', + 'import { move } from "./systems/systems";', + '', + 'export const main = async (options) => {', + ' const app = NanoforgeFactory.createClient();', + ' const ecs = new EcsLibrary();', + '', + ' app.use(ecs);', + '', + ' await app.init(options);', + ' await app.run();', + '};', + '', + ].join('\n'), + ); + const before = snapshot(); + const entry = { ...client, entryFile: 'apps/client/src/main.ts' }; + + const undo = await addEditorLibrary(io, entry); + expect(files.get('apps/client/src/main.ts')).toBe( + [ + 'import { NanoforgeFactory } from "@nanoforge-dev/core";', + 'import { EcsLibrary } from "@nanoforge-dev/ecs/client";', + 'import { EditorLibrary } from "@nanoforge-dev/editor-lib";', + '', + 'import { move } from "./systems/systems";', + '', + 'export const main = async (options) => {', + ' const app = NanoforgeFactory.createClient();', + ' const ecs = new EcsLibrary();', + '', + ' app.use(ecs);', + ' app.use(new EditorLibrary());', + '', + ' await app.init(options);', + ' await app.run();', + '};', + '', + ].join('\n'), + ); + expect(JSON.parse(files.get('apps/client/package.json')!).devDependencies).toMatchObject({ + '@nanoforge-dev/core': '^2.0.0', + '@nanoforge-dev/editor-lib': '^2.0.0', + }); + + await expect(addEditorLibrary(io, entry)).rejects.toThrow(/already registers/); + await undo(); + expect(snapshot()).toEqual(before); + + files.set('apps/client/src/main.ts', 'export const main = async () => {};\n'); + await expect(addEditorLibrary(io, entry)).rejects.toThrow(/by hand/); + expect(withEditorLibrary('const a = 1;')).toBeUndefined(); + }); + + it('gives a new app the editor library when the project has it', async () => { + const { io, files } = project(); + await editPackage(files, 'apps/client/package.json', '@nanoforge-dev/editor-lib', '^2.0.0'); + await createApp(io, model(client, server), { + type: 'client', + folder: 'spectator', + name: 'pong-spectator', + }); + expect(JSON.parse(files.get('apps/spectator/package.json')!).devDependencies).toHaveProperty( + '@nanoforge-dev/editor-lib', + '^2.0.0', + ); + const main = files.get('apps/spectator/src/main.ts')!; + expect(main).toContain('import { EditorLibrary } from "@nanoforge-dev/editor-lib";'); + expect(main).toContain(' app.use(ecs);\n app.use(new EditorLibrary());'); + }); + + it('renames and removes an app, but never the last one', async () => { + const { io, files, snapshot } = project(); + await renameApp(io, model(client, server), client, 'pong-game'); + expect(JSON.parse(files.get('apps/client/package.json')!).name).toBe('pong-game'); + await expect(renameApp(io, model(client, server), client, 'pong-server')).rejects.toThrow(); + + const before = snapshot(); + const undo = await removeApp(io, model(client, server), server); + expect(files.has('apps/server/src/main.ts')).toBe(false); + await undo(); + expect(snapshot()).toEqual(before); + await expect(removeApp(io, model(client), client)).rejects.toThrow(/at least one app/); + }); +}); diff --git a/packages/project/test/workspace/library-operations.spec.ts b/packages/project/test/workspace/library-operations.spec.ts new file mode 100644 index 00000000..45e50a6a --- /dev/null +++ b/packages/project/test/workspace/library-operations.spec.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; + +import { withConfigLibrary } from '../../src/workspace/library-config'; +import { + createLibrary, + libraryImporters, + removeLibrary, + renameLibrary, + setLibraryUse, +} from '../../src/workspace/library-operations'; +import { WorkspaceError } from '../../src/workspace/workspace.exception'; +import type { WorkspaceIo } from '../../src/workspace/workspace.type'; +import { app, client, model, project, server, shared } from '../fixtures/workspace'; + +/** + * An io that rewrites imports as the code worker does (here by text, on `from "…"`), with + * edits valid while the file holds what they wrote. + */ +const reading = (io: WorkspaceIo, files: Map): WorkspaceIo => ({ + ...io, + renameImports: async (path, from, to) => { + const before = files.get(path)!; + const after = before.replaceAll(new RegExp(`(["'])${from}(/|\\1)`, 'g'), `$1${to}$2`); + if (after === before) return undefined; + files.set(path, after); + return { + undo: async () => void files.set(path, before), + isValid: async () => files.get(path) === after, + }; + }, +}); + +describe('shared libraries', () => { + it('creates a library used by the chosen apps, and undoes it', async () => { + const { io, files, snapshot } = project(); + const before = snapshot(); + const undo = await createLibrary(io, { + folder: 'shared', + packageName: '@pong/shared', + usedBy: [client, server], + }); + expect(JSON.parse(files.get('libs/shared/package.json')!)).toMatchObject({ + name: '@pong/shared', + private: true, + }); + expect(files.get('libs/shared/nanoforge.config.ts')).toContain('type: "lib"'); + expect(files.get('nanoforge.config.ts')).toContain('packages: ["apps/*", "libs/*"]'); + expect(files.get('tsconfig.json')).toContain('// shared options'); + expect(files.get('tsconfig.json')).toContain('"@pong/shared/*": ["./libs/shared/src/*"]'); + for (const path of ['apps/client/package.json', 'apps/server/package.json']) + expect(JSON.parse(files.get(path)!).dependencies).toEqual({ '@pong/shared': 'workspace:*' }); + expect(files.get('apps/client/nanoforge.config.ts')).toContain( + ' type: "client",\n libs: ["../../libs/shared"],\n', + ); + expect(files.get('apps/server/nanoforge.config.ts')).toBe( + "export default { type: 'server', libs: ['../../libs/shared'] };\n", + ); + + await undo(); + expect(snapshot()).toEqual(before); + + await expect( + createLibrary(io, { folder: 'Bad Name', packageName: '@pong/shared', usedBy: [] }), + ).rejects.toBeInstanceOf(WorkspaceError); + await expect( + createLibrary(io, { folder: 'ok', packageName: 'Not A Name', usedBy: [] }), + ).rejects.toThrow(/package name/); + expect(snapshot()).toEqual(before); + }); + + it('adds and removes the use of a library by an app', async () => { + const { io, files } = project(); + await createLibrary(io, { folder: 'shared', packageName: '@pong/shared', usedBy: [client] }); + expect(JSON.parse(files.get('apps/server/package.json')!).dependencies).toBeUndefined(); + + const undo = await setLibraryUse(io, server, shared, true); + expect(JSON.parse(files.get('apps/server/package.json')!).dependencies).toEqual({ + '@pong/shared': 'workspace:*', + }); + expect(files.get('apps/server/nanoforge.config.ts')).toContain("libs: ['../../libs/shared']"); + await undo(); + expect(files.get('apps/server/nanoforge.config.ts')).toContain('libs: []'); + expect(JSON.parse(files.get('apps/server/package.json')!).dependencies).toBeUndefined(); + + await setLibraryUse(io, client, shared, false); + const pkg = JSON.parse(files.get('apps/client/package.json')!); + expect(pkg.dependencies).toBeUndefined(); + expect(pkg.devDependencies).toHaveProperty('typescript'); + expect(files.get('apps/client/nanoforge.config.ts')).toContain(' libs: [],\n'); + }); + + it("edits a config's libs in place, by the folder each entry points at", () => { + const config = + 'export default defineConfig({\n type: "client",\n entryFile: "src/main.ts",\n});\n'; + const added = withConfigLibrary(config, 'apps/client', 'libs/shared', true)!; + expect(added).toBe( + 'export default defineConfig({\n type: "client",\n libs: ["../../libs/shared"],\n entryFile: "src/main.ts",\n});\n', + ); + const other = added.replace('"../../libs/shared"', '"./../../libs/shared/"'); + expect(withConfigLibrary(other, 'apps/client', 'libs/shared', true)).toBe(other); + expect(withConfigLibrary(added, 'apps/client', 'libs/ui', true)).toContain( + 'libs: ["../../libs/shared", "../../libs/ui"],', + ); + expect(withConfigLibrary(other, 'apps/client', 'libs/shared', false)).toContain('libs: [],'); + expect( + withConfigLibrary("export default { type: 'server' };\n", 'apps/server', 'libs/a', true), + ).toBe("export default { type: 'server', libs: ['../../libs/a'] };\n"); + expect(withConfigLibrary('export default config;\n', 'apps/x', 'libs/a', true)).toBeUndefined(); + expect(withConfigLibrary(config, 'apps/client', 'libs/shared', false)).toBe(config); + }); + + it('renames a library: package, paths, dependencies and imports', async () => { + const { io, files, snapshot } = project(); + await createLibrary(io, { folder: 'shared', packageName: '@pong/shared', usedBy: [client] }); + files.set( + 'apps/client/src/main.ts', + 'import { Position } from "@pong/shared/components/position";\nexport * from \'@pong/shared/systems/move\';\nconst lazy = import("@pong/shared/later");\nimport other from "@pong/shared-tools";\n', + ); + const before = snapshot(); + const library = app('libs/shared', '@pong/shared', 'lib'); + const all = model(client, server, library); + + const undo = await renameLibrary(reading(io, files), all, library, '@pong/common'); + expect(JSON.parse(files.get('libs/shared/package.json')!).name).toBe('@pong/common'); + const tsconfig = files.get('tsconfig.json')!; + expect(tsconfig).toContain('"@pong/common/*": ["./libs/shared/src/*"]'); + expect(tsconfig).not.toContain('@pong/shared/'); + expect(JSON.parse(files.get('apps/client/package.json')!).dependencies).toEqual({ + '@pong/common': 'workspace:*', + }); + expect(files.get('apps/client/src/main.ts')).toBe( + 'import { Position } from "@pong/common/components/position";\nexport * from \'@pong/common/systems/move\';\nconst lazy = import("@pong/common/later");\nimport other from "@pong/shared-tools";\n', + ); + await undo(); + expect(snapshot()).toEqual(before); + + await expect(renameLibrary(io, all, library, 'pong-client')).rejects.toThrow(/already/); + }); + + it('renames an imported library only with the code analysis, which sees unsaved imports', async () => { + const { io, files, snapshot } = project(); + await createLibrary(io, { folder: 'shared', packageName: '@pong/shared', usedBy: [client] }); + const library = app('libs/shared', '@pong/shared', 'lib'); + const all = model(client, server, library); + const unsaved = 'import { a } from "@pong/shared/a";\n'; + const edited: WorkspaceIo = { + ...io, + readEdited: async (path) => (path === 'apps/client/src/main.ts' ? unsaved : io.read(path)), + }; + const before = snapshot(); + const refused = await renameLibrary(edited, all, library, '@pong/common').catch( + (error: unknown) => error, + ); + expect(refused).toBeInstanceOf(WorkspaceError); + expect((refused as WorkspaceError).paths).toEqual(['apps/client/src/main.ts']); + expect(snapshot()).toEqual(before); + + expect(await libraryImporters(edited, library)).toEqual(['apps/client/src/main.ts']); + expect(await libraryImporters(io, library)).toEqual([]); + await renameLibrary(io, all, library, '@pong/common'); + expect(JSON.parse(files.get('libs/shared/package.json')!).name).toBe('@pong/common'); + }); + + it('does not undo over a file edited since, and keeps the step for another try', async () => { + const { io, files } = project(); + await createLibrary(io, { folder: 'shared', packageName: '@pong/shared', usedBy: [client] }); + files.set('apps/client/src/main.ts', 'import { a } from "@pong/shared/a";\n'); + const library = app('libs/shared', '@pong/shared', 'lib'); + const undo = await renameLibrary( + reading(io, files), + model(client, server, library), + library, + '@pong/common', + ); + files.set('apps/client/src/main.ts', 'import { a } from "@pong/common/a";\n// hand edit\n'); + await expect(undo()).rejects.toThrow(/apps\/client\/src\/main.ts changed since/); + expect(JSON.parse(files.get('libs/shared/package.json')!).name).toBe('@pong/common'); + files.set('apps/client/src/main.ts', 'import { a } from "@pong/common/a";\n'); + await undo(); + expect(JSON.parse(files.get('libs/shared/package.json')!).name).toBe('@pong/shared'); + }); + + it('rewrites imports through renameImports when the io reads code', async () => { + const { io, files } = project(); + await createLibrary(io, { folder: 'shared', packageName: '@pong/shared', usedBy: [client] }); + const text = '// see "@pong/shared" docs\nimport { a } from "@pong/shared/a";\n'; + files.set('apps/client/src/main.ts', text); + const asked: string[] = []; + const custom = { + ...io, + renameImports: async (path: string, from: string, to: string) => { + asked.push(`${path}:${from}->${to}`); + files.set(path, files.get(path)!.replace(`from "${from}/a"`, `from "${to}/a"`)); + return { + undo: async () => void files.set(path, text), + isValid: async () => true, + }; + }, + }; + const library = app('libs/shared', '@pong/shared', 'lib'); + const undo = await renameLibrary( + custom, + model(client, server, library), + library, + '@pong/common', + ); + expect(asked).toEqual(['apps/client/src/main.ts:@pong/shared->@pong/common']); + expect(files.get('apps/client/src/main.ts')).toBe( + '// see "@pong/shared" docs\nimport { a } from "@pong/common/a";\n', + ); + await undo(); + expect(files.get('apps/client/src/main.ts')).toBe(text); + }); + + it('refuses to remove a library that is imported, then removes it', async () => { + const { io, files, snapshot } = project(); + await createLibrary(io, { folder: 'shared', packageName: '@pong/shared', usedBy: [client] }); + files.set('libs/shared/src/components/position.ts', 'export class Position {}\n'); + files.set( + 'apps/client/src/main.ts', + 'import { Position } from "@pong/shared/components/position";\n', + ); + const library = app('libs/shared', '@pong/shared', 'lib'); + const all = model(client, server, library); + + expect(await libraryImporters(io, library)).toEqual(['apps/client/src/main.ts']); + const refused = await removeLibrary(io, all, library).catch((error: unknown) => error); + expect(refused).toBeInstanceOf(WorkspaceError); + expect((refused as WorkspaceError).paths).toEqual(['apps/client/src/main.ts']); + expect(files.has('libs/shared/package.json')).toBe(true); + + files.set('apps/client/src/main.ts', 'export const main = 1;\n'); + const before = snapshot(); + const undo = await removeLibrary(io, all, library); + expect([...files.keys()].some((path) => path.startsWith('libs/shared/'))).toBe(false); + expect(files.get('tsconfig.json')).not.toContain('@pong/shared'); + expect(files.get('apps/client/nanoforge.config.ts')).toContain('libs: [],'); + expect(JSON.parse(files.get('apps/client/package.json')!).dependencies).toBeUndefined(); + await undo(); + expect(snapshot()).toEqual(before); + }); +}); diff --git a/packages/project/tsconfig.json b/packages/project/tsconfig.json new file mode 100644 index 00000000..888918d9 --- /dev/null +++ b/packages/project/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/protocol/.prettierignore b/packages/protocol/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/protocol/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/protocol/LICENSE b/packages/protocol/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/protocol/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/protocol/README.md b/packages/protocol/README.md new file mode 100644 index 00000000..24b1fa4e --- /dev/null +++ b/packages/protocol/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-protocol` is an internal package of the NanoForge Editor. + +RPC contracts (zod schemas) shared by the editor client packages and `server-core`: session, projects, file system and plugins. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/protocol +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 00000000..b13638ad --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-protocol", + "version": "0.0.0", + "description": "NanoForge Editor - RPC contracts shared by the editor client and server", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/protocol" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty .", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "@nanoforge-dev/editor-rpc": "workspace:*", + "@nanoforge-dev/registry": "workspace:*", + "zod": "catalog:libs" + }, + "devDependencies": { + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/protocol/src/code/code.contract.ts b/packages/protocol/src/code/code.contract.ts new file mode 100644 index 00000000..552f53c0 --- /dev/null +++ b/packages/protocol/src/code/code.contract.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { ProjectPath } from '../path/project-path'; +import { ProjectId } from '../project/project.schema'; + +export const CodeContract = defineContract('code', { + methods: { + /** + * Type declarations of a package as resolved from a file (node_modules lookup): + * its package.json and `.d.ts` files, with project-relative paths. + */ + typeFiles: { + input: z.object({ + project: ProjectId, + from: ProjectPath, + module: z.string().regex(/^(@[\w.-]+\/)?[\w.-]+$/, 'package name'), + }), + output: z.array(z.object({ path: ProjectPath, text: z.string() })), + }, + /** + * Formats a file's text with Prettier: the project's own Prettier, config and plugins on + * local editors; the bundled Prettier and the project's JSON/YAML config on hosted ones. + * `formatter` is `none` when the file is ignored or unsupported (text unchanged). + */ + format: { + input: z.object({ project: ProjectId, path: ProjectPath, text: z.string() }), + output: z.object({ text: z.string(), formatter: z.enum(['project', 'bundled', 'none']) }), + }, + }, +}); diff --git a/packages/protocol/src/code/index.ts b/packages/protocol/src/code/index.ts new file mode 100644 index 00000000..01cdb576 --- /dev/null +++ b/packages/protocol/src/code/index.ts @@ -0,0 +1 @@ +export * from './code.contract'; diff --git a/packages/protocol/src/fs/fs.contract.ts b/packages/protocol/src/fs/fs.contract.ts new file mode 100644 index 00000000..ffa94ebf --- /dev/null +++ b/packages/protocol/src/fs/fs.contract.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { ProjectPath } from '../path/project-path'; +import { ProjectId } from '../project/project.schema'; +import { Bytes, FileChange, FileContent, FileEntry } from './fs.schema'; + +const Target = z.object({ project: ProjectId, path: ProjectPath }); + +export const FsContract = defineContract('fs', { + methods: { + stat: { input: Target, output: FileEntry.nullable() }, + list: { + input: Target.extend({ recursive: z.boolean().default(false) }), + output: z.array(FileEntry), + }, + read: { input: Target, output: FileContent }, + write: { + input: Target.extend({ + content: z.union([Bytes, z.string()]), + /** Fails with CONFLICT when the file changed since this hash was read. */ + expectedHash: z.string().nullable().optional(), + createParents: z.boolean().default(true), + }), + output: FileEntry.extend({ hash: z.string() }), + }, + mkdir: { input: Target, output: FileEntry }, + rename: { + input: z.object({ + project: ProjectId, + from: ProjectPath, + to: ProjectPath, + overwrite: z.boolean().default(false), + }), + output: FileEntry, + }, + copy: { + input: z.object({ + project: ProjectId, + from: ProjectPath, + to: ProjectPath, + overwrite: z.boolean().default(false), + }), + output: FileEntry, + }, + /** Moves the entry to `.nanoforge/editor/trash`; `trashPath` brings it back (`restore`). */ + delete: { input: Target, output: z.object({ trashPath: ProjectPath }) }, + /** Undo of `delete`: moves the entry back from the trash to `path`. */ + restore: { + input: z.object({ project: ProjectId, trashPath: ProjectPath, path: ProjectPath }), + output: FileEntry, + }, + /** Local editors: shows the entry in the OS file manager. */ + reveal: { input: Target, output: z.null() }, + }, + streams: { + /** Batched changes in the project (node_modules, .git and build outputs are ignored). */ + changes: { + params: z.object({ project: ProjectId }), + event: z.object({ changes: z.array(FileChange) }), + }, + }, +}); diff --git a/packages/protocol/src/fs/fs.schema.ts b/packages/protocol/src/fs/fs.schema.ts new file mode 100644 index 00000000..3d2df3d7 --- /dev/null +++ b/packages/protocol/src/fs/fs.schema.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { ProjectPath } from '../path/project-path'; + +/** Raw bytes (any Uint8Array, whatever its backing buffer). */ +export const Bytes = z.custom((value) => value instanceof Uint8Array, 'expected bytes'); + +export const FileKind = z.enum(['file', 'directory']); + +export const FileEntry = z.object({ + path: ProjectPath, + kind: FileKind, + size: z.number().int().nonnegative(), + /** Modification time in ms since epoch. */ + mtime: z.number(), +}); +export type FileEntry = z.output; + +export const FileContent = FileEntry.extend({ + content: Bytes, + /** sha1 of the content, used for optimistic concurrency on writes. */ + hash: z.string(), +}); +export type FileContent = z.output; + +export const FileChange = z.object({ + type: z.enum(['created', 'changed', 'deleted']), + path: ProjectPath, + kind: FileKind, +}); +export type FileChange = z.output; diff --git a/packages/protocol/src/fs/index.ts b/packages/protocol/src/fs/index.ts new file mode 100644 index 00000000..d33bb6b9 --- /dev/null +++ b/packages/protocol/src/fs/index.ts @@ -0,0 +1,2 @@ +export * from './fs.schema'; +export * from './fs.contract'; diff --git a/packages/protocol/src/git/git.contract.ts b/packages/protocol/src/git/git.contract.ts new file mode 100644 index 00000000..37d1f0c6 --- /dev/null +++ b/packages/protocol/src/git/git.contract.ts @@ -0,0 +1,103 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { ProjectPath } from '../path/project-path'; +import { ProjectId } from '../project/project.schema'; +import { GitBranch, GitChange, GitCommit, GitStash, GitStatus } from './git.schema'; + +const Project = z.object({ project: ProjectId }); +const Paths = Project.extend({ paths: z.array(ProjectPath).min(1) }); +const BranchName = z + .string() + .min(1) + .max(200) + .regex(/^(?!-)(?!.*\.\.)[^\s~^:?*[\\]+$/, 'not a valid branch name'); +const Hash = z.string().regex(/^[0-9a-f]{7,40}$/); + +/** + * Git operations on the repository of a project's folder (local editors only). A repository + * above the folder is never used. + */ +export const GitContract = defineContract('git', { + methods: { + status: { input: Project, output: GitStatus }, + init: { input: Project, output: z.null() }, + stage: { input: Paths, output: z.null() }, + unstage: { input: Paths, output: z.null() }, + /** + * Rolls files back to the last commit. A file added since then stays on disk, out of git; + * a file git never knew is deleted. + */ + discard: { input: Paths, output: z.null() }, + /** + * Commits `paths` only (whatever else is staged stays out), the staged changes without + * `paths`, or everything with `all`. `amend` replaces the last commit. + */ + commit: { + input: Project.extend({ + message: z.string().min(1), + all: z.boolean().default(false), + paths: z.array(ProjectPath).optional(), + amend: z.boolean().default(false), + }), + output: z.null(), + }, + fetch: { input: Project, output: z.null() }, + /** Merge pull. `conflicts`: the merge stopped on conflicts (see the status). */ + pull: { input: Project, output: z.object({ conflicts: z.boolean() }) }, + /** Pushes the branch, setting its upstream (`origin`) on the first push. */ + push: { input: Project, output: z.null() }, + branches: { input: Project, output: z.array(GitBranch) }, + /** A branch of a remote (`origin/x`) is checked out as a local branch tracking it. */ + switchBranch: { input: Project.extend({ name: BranchName }), output: z.null() }, + /** Creates a branch at the current commit, or at `from` (a branch), and switches to it. */ + createBranch: { + input: Project.extend({ name: BranchName, from: BranchName.optional() }), + output: z.null(), + }, + /** `force`: also when it is not merged. */ + deleteBranch: { + input: Project.extend({ name: BranchName, force: z.boolean().default(false) }), + output: z.null(), + }, + log: { + input: Project.extend({ + limit: z.number().int().min(1).max(200).default(50), + skip: z.number().int().min(0).default(0), + /** A branch to list instead of the current one. */ + branch: BranchName.optional(), + }), + output: z.array(GitCommit), + }, + /** The full message of the current commit (`HEAD`), for Amend; empty without commits. */ + headMessage: { input: Project, output: z.string() }, + /** Commits a push would send: those the upstream does not have (all, without one). */ + outgoing: { input: Project, output: z.array(GitCommit) }, + /** Files a commit changed. */ + commitFiles: { + input: Project.extend({ hash: Hash }), + output: z.array(z.object({ path: z.string(), change: GitChange })), + }, + /** + * Text of a file at a revision: `HEAD`, `INDEX` (staged), or `^` (before a commit). + * Null when the file does not exist there. + */ + show: { + input: Project.extend({ + path: ProjectPath, + revision: z.string().regex(/^(HEAD|INDEX|[0-9a-f]{7,40}\^?)$/), + }), + output: z.string().nullable(), + }, + stashes: { input: Project, output: z.array(GitStash) }, + /** Stashes the changes, untracked files included. */ + stash: { input: Project.extend({ message: z.string().optional() }), output: z.null() }, + /** `pop`: drops the stash once applied. */ + applyStash: { + input: Project.extend({ index: z.number().int().min(0), pop: z.boolean().default(false) }), + output: z.null(), + }, + dropStash: { input: Project.extend({ index: z.number().int().min(0) }), output: z.null() }, + }, +}); diff --git a/packages/protocol/src/git/git.schema.ts b/packages/protocol/src/git/git.schema.ts new file mode 100644 index 00000000..03b202ca --- /dev/null +++ b/packages/protocol/src/git/git.schema.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; + +/** What happened to a file: modified, added, deleted, renamed, untracked. */ +export const GitChange = z.enum(['M', 'A', 'D', 'R', 'U']); +export type GitChange = z.output; + +export const GitFile = z.object({ + path: z.string(), + /** Where a renamed file was. */ + from: z.string().optional(), + /** Change staged for the next commit. */ + staged: GitChange.optional(), + /** Change in the working files, not staged (`U`: untracked). */ + unstaged: GitChange.optional(), + /** A merge left conflict markers in it. */ + conflicted: z.boolean(), +}); +export type GitFile = z.output; + +export const GitStatus = z.object({ + /** False: the project folder is not a repository (everything else is empty). */ + repository: z.boolean(), + /** Current branch; undefined on a detached HEAD. */ + branch: z.string().optional(), + /** Short hash of the current commit; undefined before the first commit. */ + head: z.string().optional(), + upstream: z.string().optional(), + ahead: z.number().int(), + behind: z.number().int(), + /** A merge is in progress (a pull that conflicted). */ + merging: z.boolean(), + files: z.array(GitFile), +}); +export type GitStatus = z.output; + +export const GitBranch = z.object({ + /** `main`, or `origin/main` for a branch of a remote. */ + name: z.string(), + current: z.boolean(), + upstream: z.string().optional(), + remote: z.boolean(), +}); +export type GitBranch = z.output; + +export const GitCommit = z.object({ + hash: z.string(), + shortHash: z.string(), + subject: z.string(), + author: z.string(), + /** Seconds since epoch. */ + time: z.number(), + /** Branches and tags pointing at it (`main`, `origin/main`, `tag: v1`). */ + refs: z.array(z.string()), +}); +export type GitCommit = z.output; + +export const GitStash = z.object({ index: z.number().int(), message: z.string() }); +export type GitStash = z.output; diff --git a/packages/protocol/src/git/index.ts b/packages/protocol/src/git/index.ts new file mode 100644 index 00000000..0088be04 --- /dev/null +++ b/packages/protocol/src/git/index.ts @@ -0,0 +1,2 @@ +export * from './git.schema'; +export * from './git.contract'; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts new file mode 100644 index 00000000..9268315e --- /dev/null +++ b/packages/protocol/src/index.ts @@ -0,0 +1,10 @@ +export * from './fs'; +export * from './path'; +export * from './plugins'; +export * from './project'; +export * from './session'; +export * from './settings'; +export * from './code'; +export * from './runtime'; +export * from './git'; +export * from './registry'; diff --git a/packages/protocol/src/path/index.ts b/packages/protocol/src/path/index.ts new file mode 100644 index 00000000..b924980f --- /dev/null +++ b/packages/protocol/src/path/index.ts @@ -0,0 +1 @@ +export * from './project-path'; diff --git a/packages/protocol/src/path/project-path.ts b/packages/protocol/src/path/project-path.ts new file mode 100644 index 00000000..8390f35c --- /dev/null +++ b/packages/protocol/src/path/project-path.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +/** + * A path relative to the project root, POSIX separators, no `.`/`..` segments, no leading + * slash. The empty string is the root. The server still jails every path to the project. + */ +export const ProjectPath = z + .string() + .max(4096) + .refine( + (path) => + path === '' || + (!path.startsWith('/') && + !path.endsWith('/') && + !path.includes('\\') && + path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')), + 'must be a normalized project-relative path', + ); + +export type ProjectPath = z.output; + +export const joinPath = (...parts: string[]): string => + parts + .flatMap((part) => part.split('/')) + .filter(Boolean) + .join('/'); + +export const dirname = (path: string): string => path.split('/').slice(0, -1).join('/'); + +export const basename = (path: string): string => path.split('/').at(-1) ?? ''; diff --git a/packages/protocol/src/plugins/index.ts b/packages/protocol/src/plugins/index.ts new file mode 100644 index 00000000..1564f769 --- /dev/null +++ b/packages/protocol/src/plugins/index.ts @@ -0,0 +1,2 @@ +export * from './plugins.schema'; +export * from './plugins.contract'; diff --git a/packages/protocol/src/plugins/plugins.contract.ts b/packages/protocol/src/plugins/plugins.contract.ts new file mode 100644 index 00000000..92f51ab1 --- /dev/null +++ b/packages/protocol/src/plugins/plugins.contract.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { PluginListing, PluginSuggestion } from './plugins.schema'; + +export const PluginsContract = defineContract('plugins', { + methods: { + /** Every plugin, plus the open project's own when `project` is set. */ + list: { + input: z.object({ project: z.string().optional() }).nullable(), + output: z.array(PluginListing), + }, + suggestions: { input: z.object({ project: z.string() }), output: z.array(PluginSuggestion) }, + /** + * Runs the server entries of these plugins of the project (the ones the editor enabled) and + * stops every other project plugin's; `project: null` stops them all. Local editors only. + */ + runProjectServers: { + input: z.object({ project: z.string().nullable(), names: z.array(z.string()) }), + output: z.null(), + }, + }, + streams: { + /** Fired when a local dev plugin was rebuilt: the client hot reloads it. */ + devChanges: { + params: z.null(), + event: z.object({ name: z.string(), listing: PluginListing }), + }, + }, +}); diff --git a/packages/protocol/src/plugins/plugins.schema.ts b/packages/protocol/src/plugins/plugins.schema.ts new file mode 100644 index 00000000..44039a26 --- /dev/null +++ b/packages/protocol/src/plugins/plugins.schema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +/** + * Where a plugin was found: with the editor, in the user's home (`~/.nanoforge/editor/plugins`), + * in the open project (`/.nanoforge/plugins`), or a local dev folder. + */ +export const PluginSource = z.enum(['bundled', 'installed', 'project', 'dev']); + +/** A plugin package found by the server; the client validates `manifest` with the kernel. */ +export const PluginListing = z.object({ + source: PluginSource, + /** URL (relative to the editor origin) of the plugin package root, ending with `/`. */ + baseUrl: z.string(), + manifest: z.unknown(), + /** Set when the manifest file could not be read. */ + error: z.string().optional(), +}); +export type PluginListing = z.output; + +/** A plugin that packages in the project's `nf_modules` suggest (`suggestedPlugins`). */ +export const PluginSuggestion = z.object({ + name: z.string(), + /** Every range asked for, by package. */ + suggestedBy: z.array(z.object({ package: z.string(), range: z.string() })), +}); +export type PluginSuggestion = z.output; diff --git a/packages/protocol/src/project/index.ts b/packages/protocol/src/project/index.ts new file mode 100644 index 00000000..e344b6aa --- /dev/null +++ b/packages/protocol/src/project/index.ts @@ -0,0 +1,2 @@ +export * from './project.schema'; +export * from './project.contract'; diff --git a/packages/protocol/src/project/project.contract.ts b/packages/protocol/src/project/project.contract.ts new file mode 100644 index 00000000..ff7a06c9 --- /dev/null +++ b/packages/protocol/src/project/project.contract.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { ProjectId, ProjectModel, ProjectRef, RecentProject } from './project.schema'; + +/** An opened project. `trusted`: its code may run; otherwise it is open in restricted mode. */ +const OpenedProject = z.object({ id: ProjectId, name: z.string(), trusted: z.boolean() }); + +export const ProjectsContract = defineContract('projects', { + methods: { + open: { + input: ProjectRef, + output: OpenedProject, + }, + recent: { input: z.null(), output: z.array(RecentProject) }, + forget: { input: z.object({ id: ProjectId }), output: z.null() }, + /** + * Trusts a local project's folder: its code may run (config, plugins, builds, games…). The + * project closes on the server: open it again. + */ + trust: { input: z.object({ id: ProjectId }), output: z.null() }, + /** Whether an open project's code may run (see `trust`). */ + isTrusted: { input: z.object({ id: ProjectId }), output: z.boolean() }, + model: { input: z.object({ id: ProjectId }), output: ProjectModel }, + /** + * Local editors: installs the project's dependencies with its package manager, found from + * its lockfile. `manager` is null when no lockfile says which one to run; the output goes + * to the project's console as a task. + */ + install: { + input: z.object({ id: ProjectId }), + output: z.object({ manager: z.string().nullable(), ok: z.boolean() }), + }, + /** Offline editors: runs `nf new` under the projects root, then opens the project. */ + create: { + input: z.object({ + name: z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, 'lowercase letters, digits and dashes'), + language: z.enum(['ts', 'js']).default('ts'), + /** Multiplayer: adds a server app. */ + server: z.boolean().default(false), + install: z.boolean().default(true), + /** Installs the dependencies and runs the project's scripts. */ + packageManager: z.enum(['npm', 'pnpm', 'yarn', 'bun']).default('npm'), + }), + output: OpenedProject, + }, + /** Online editors: projects of the signed-in account. */ + gateways: { + input: z.null(), + output: z.array( + z.object({ gatewayId: z.string(), name: z.string(), description: z.string() }), + ), + }, + }, + streams: { + /** The project model, re-sent whenever discovery re-runs. */ + model: { params: z.object({ id: ProjectId }), event: ProjectModel, coalesce: true }, + }, +}); diff --git a/packages/protocol/src/project/project.schema.ts b/packages/protocol/src/project/project.schema.ts new file mode 100644 index 00000000..4dd5b5cf --- /dev/null +++ b/packages/protocol/src/project/project.schema.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; + +import { ProjectPath } from '../path/project-path'; + +export const ProjectId = z.string().regex(/^[\w-]{6,64}$/); +export type ProjectId = z.output; + +export const AppType = z.enum(['client', 'server', 'lib']); + +/** An app (client, server) or shared lib of a game workspace. */ +export const AppModel = z.object({ + /** Path of the app relative to the project root ('' for single-app projects). */ + id: z.string(), + name: z.string(), + type: AppType, + root: ProjectPath, + language: z.enum(['ts', 'js']), + /** Paths relative to the project root. */ + dirs: z.object({ + assets: ProjectPath, + components: ProjectPath, + systems: ProjectPath, + scenes: ProjectPath, + }), + entryFile: ProjectPath.optional(), + editorEntryFile: ProjectPath.optional(), + outDir: ProjectPath.optional(), + /** Engine libraries (`@nanoforge-dev/*` runtime packages) and their version or range. */ + engineLibs: z.record(z.string(), z.string()), + /** + * The shared libraries (ADR 0004) the app uses, by package name: those its `nanoforge.config` + * lists in `libs` (a library: those its package.json depends on). A library nothing mentions is + * given to every app. + */ + libraries: z.array(z.string()).default([]), + /** A shared library nothing mentions (`libs`, package.json): every app gets it (see `libraries`). */ + unclaimed: z.boolean().optional(), +}); +export type AppModel = z.output; + +export const ProjectDiagnostic = z.object({ + path: z.string(), + message: z.string(), + severity: z.enum(['error', 'warning']), +}); + +export const ProjectModel = z.object({ + id: ProjectId, + name: z.string(), + /** Display location: absolute path (offline) or gateway name (online). */ + location: z.string(), + apps: z.array(AppModel), + diagnostics: z.array(ProjectDiagnostic), +}); +export type ProjectModel = z.output; + +export const ProjectRef = z.union([ + z.object({ path: z.string().min(1) }), + z.object({ gatewayId: z.string().min(1) }), +]); +export type ProjectRef = z.output; + +export const RecentProject = z.object({ + id: ProjectId, + name: z.string(), + location: z.string(), + ref: ProjectRef, + openedAt: z.date(), +}); +export type RecentProject = z.output; diff --git a/packages/protocol/src/registry/index.ts b/packages/protocol/src/registry/index.ts new file mode 100644 index 00000000..61d44a2f --- /dev/null +++ b/packages/protocol/src/registry/index.ts @@ -0,0 +1,2 @@ +export * from './registry.schema'; +export * from './registry.contract'; diff --git a/packages/protocol/src/registry/registry.contract.ts b/packages/protocol/src/registry/registry.contract.ts new file mode 100644 index 00000000..8d6a0e9f --- /dev/null +++ b/packages/protocol/src/registry/registry.contract.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; +import { ItemName, ItemType, RegistryItem, SearchResult } from '@nanoforge-dev/registry/types'; + +import { ProjectId } from '../project/project.schema'; +import { InstalledPackage, PluginScope } from './registry.schema'; + +const PluginTarget = z.object({ + name: ItemName, + scope: PluginScope, + /** The project, for the `project` scope. */ + project: ProjectId.optional(), +}); +const ProjectPackage = z.object({ project: ProjectId, name: ItemName }); + +/** + * The registry of plugins and packages, and installing from it (local editors only for + * installs). Fails with `UNAVAILABLE` when the registry cannot be reached. + */ +export const RegistryContract = defineContract('registry', { + methods: { + search: { + input: z.object({ + type: ItemType, + q: z.string().default(''), + page: z.number().int().min(1).default(1), + }), + output: SearchResult, + }, + /** An item with its readme and versions; `compatible` is the newest this editor can run. */ + details: { + input: z.object({ name: ItemName }), + output: RegistryItem.extend({ compatible: z.string().optional() }), + }, + /** Installs the newest version this editor can run, or `version`. Takes effect on reload. */ + installPlugin: { + input: PluginTarget.extend({ version: z.string().optional() }), + output: z.object({ version: z.string() }), + }, + uninstallPlugin: { input: PluginTarget, output: z.null() }, + /** The packages of a project (from its lock), with what is newer in the registry. */ + packages: { input: z.object({ project: ProjectId }), output: z.array(InstalledPackage) }, + installPackage: { + input: ProjectPackage.extend({ range: z.string().optional() }), + output: z.array(InstalledPackage), + }, + uninstallPackage: { input: ProjectPackage, output: z.array(InstalledPackage) }, + /** To the newest version in its range, or to the newest of all with `latest`. */ + updatePackage: { + input: ProjectPackage.extend({ latest: z.boolean().default(false) }), + output: z.array(InstalledPackage), + }, + /** Installs what the lock lists and `nf_modules` lacks. */ + restorePackages: { input: z.object({ project: ProjectId }), output: z.array(InstalledPackage) }, + }, +}); diff --git a/packages/protocol/src/registry/registry.schema.ts b/packages/protocol/src/registry/registry.schema.ts new file mode 100644 index 00000000..c20d11c4 --- /dev/null +++ b/packages/protocol/src/registry/registry.schema.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { + RegistryItem, + RegistrySummary, + RegistryVersion, + SearchResult, +} from '@nanoforge-dev/registry/types'; + +export { RegistryItem, RegistrySummary, RegistryVersion, SearchResult as RegistrySearchResult }; + +/** A package of a project, as installed, with the newer versions the registry has. */ +export const InstalledPackage = z.object({ + name: z.string(), + version: z.string(), + /** The range the project asks for; absent for a package only others need. */ + range: z.string().optional(), + /** Installed packages that need it. */ + dependents: z.array(z.string()), + /** Whether its folder is in `nf_modules`. */ + present: z.boolean(), + /** The newest version its range allows, when newer. */ + wanted: z.string().optional(), + /** The newest version of all, when newer. */ + latest: z.string().optional(), +}); +export type InstalledPackage = z.output; + +/** Where a plugin is installed: for the user (every project), or in one project. */ +export const PluginScope = z.enum(['user', 'project']); +export type PluginScope = z.output; diff --git a/packages/protocol/src/runtime/engine-bridge.const.ts b/packages/protocol/src/runtime/engine-bridge.const.ts new file mode 100644 index 00000000..51f8eec3 --- /dev/null +++ b/packages/protocol/src/runtime/engine-bridge.const.ts @@ -0,0 +1,2 @@ +/** Version of the engine bridge protocol this editor speaks (engine `EDITOR_PROTOCOL_VERSION`). */ +export const RUNTIME_PROTOCOL_VERSION = 1; diff --git a/packages/protocol/src/runtime/engine-bridge.type.ts b/packages/protocol/src/runtime/engine-bridge.type.ts new file mode 100644 index 00000000..f6108674 --- /dev/null +++ b/packages/protocol/src/runtime/engine-bridge.type.ts @@ -0,0 +1,107 @@ +/** Optional engine features the editor asks for in its `welcome` (engine `EditorFeatures`). */ +export interface EngineFeatures { + frameStats?: { intervalMs: number }; + /** Console output of the game; `values` also asks for the logged values and their call site. */ + logs?: boolean | { values?: boolean }; + /** + * Network packets (`network-trace` events, each with the first `maxBytes` bytes when given) + * and totals every second (`network-stats`). + */ + networkTrace?: { sampleRate?: number; maxPerSecond?: number; maxBytes?: number }; + /** ECS live world (`ecs-world` events), sent when it changed, at most every `intervalMs`. */ + ecsWorld?: { intervalMs: number }; + /** Time spent in each ECS system (`ecs-system-stats` events) every `intervalMs`. */ + ecsSystemStats?: { intervalMs: number }; + /** The client's viewport (`viewport` events), at once and on every change. */ + viewport?: boolean; + /** Loaded scenes and scene vars (`scenes` events), sent when they changed, at most every `intervalMs`. */ + scenes?: { intervalMs: number }; +} + +/** Engine `EditorScenes` (`@nanoforge-dev/scene`): the `scenes` event. */ +export interface EngineScenes { + /** The loaded scenes, root first, by id. */ + readonly loaded: readonly { readonly id: string; readonly params: EngineWorldValue }[]; + readonly vars: readonly { + readonly key: string; + readonly value: EngineWorldValue; + /** The id of the scene it belongs to. */ + readonly owner?: string; + readonly persistent: boolean; + }[]; + /** Ids of the scenes the game can load. */ + readonly known: readonly string[]; +} + +/** + * Engine `EditorViewport` (its `ViewportState`): a point `(x, y)` of the game is drawn at + * `(contentLeft + originX + x * scaleX, contentTop + originY + y * scaleY)` CSS px of the game's + * container. + */ +export interface EngineViewport { + readonly designWidth: number; + readonly designHeight: number; + readonly scaleX: number; + readonly scaleY: number; + readonly contentLeft: number; + readonly contentTop: number; + readonly originX: number; + readonly originY: number; + readonly visibleWidth: number; + readonly visibleHeight: number; +} + +/** Engine `EditorNetworkTrace`: one packet a game sent or received. */ +export interface EngineNetworkTrace { + direction: 'in' | 'out'; + transport: 'tcp' | 'udp'; + /** Server side: the client the packet goes to or comes from. */ + clientId?: string; + size: number; + /** First 16 bytes, hex encoded. */ + head: string; + /** The first `maxBytes` bytes, hex encoded (engines that send them). */ + data?: string; + /** ms since epoch. */ + time: number; +} + +/** Engine `EditorNetworkStats`: totals of every packet over a window, traced or not. */ +export interface EngineNetworkStats { + windowMs: number; + time: number; + tcp: Record<'in' | 'out', { packets: number; bytes: number }>; + udp: Record<'in' | 'out', { packets: number; bytes: number }>; + /** Packets left out of the `network-trace` events. */ + untraced: number; +} + +/** Engine `EditorSystemStats`: time per call of each ECS system over a window (ms). */ +export interface EngineSystemStats { + windowMs: number; + systems: { index: number; name: string; calls: number; avg: number; max: number }[]; +} + +/** A JSON view of a component value (engine `EditorValue`). */ +export type EngineWorldValue = + null | boolean | number | string | EngineWorldValue[] | { [key: string]: EngineWorldValue }; + +/** Engine `EditorWorld`: the running ECS world. */ +export interface EngineWorld { + entities: { + id: number; + /** The id of the scene that spawned it (`EcsScene` of `@nanoforge-dev/ecs/scene`). */ + scene?: string; + components: { name: string; value: Record }[]; + }[]; + systems: { index: number; name: string; enabled: boolean }[]; +} + +/** Engine `EditorFrameStats`: tick timings over a sampling window (ms). */ +export interface EngineFrameStats { + windowMs: number; + ticks: number; + tps: number; + tick: { avg: number; max: number }; + libraries: Record; +} diff --git a/packages/protocol/src/runtime/index.ts b/packages/protocol/src/runtime/index.ts new file mode 100644 index 00000000..4cb448d2 --- /dev/null +++ b/packages/protocol/src/runtime/index.ts @@ -0,0 +1,5 @@ +export * from './engine-bridge.const'; +export * from './engine-bridge.type'; +export * from './runtime.schema'; +export * from './runtime.contract'; +export * from './runtime-output'; diff --git a/packages/protocol/src/runtime/runtime-output.ts b/packages/protocol/src/runtime/runtime-output.ts new file mode 100644 index 00000000..71f6bb1e --- /dev/null +++ b/packages/protocol/src/runtime/runtime-output.ts @@ -0,0 +1,9 @@ +/** URL path of an app's build output (`app` is the app id, `''` for single-app projects). */ +export const runtimeOutputPath = (project: string, app: string): string => + `/runtime/${project}/${encodeURIComponent(app || '.')}/`; + +/** Inverse of {@link runtimeOutputPath}'s app segment. */ +export const decodeRuntimeApp = (segment: string): string => { + const app = decodeURIComponent(segment); + return app === '.' ? '' : app; +}; diff --git a/packages/protocol/src/runtime/runtime.contract.ts b/packages/protocol/src/runtime/runtime.contract.ts new file mode 100644 index 00000000..f05a7f3e --- /dev/null +++ b/packages/protocol/src/runtime/runtime.contract.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { ProjectId } from '../project/project.schema'; +import { BuildStatus, EnvOverrides, GameServerState, OutputManifest } from './runtime.schema'; + +const Target = z.object({ project: ProjectId, app: z.string() }); + +export const RuntimeEvent = z.discriminatedUnion('type', [ + z.object({ type: z.literal('build'), status: BuildStatus }), + z.object({ + type: z.literal('log'), + /** The app, or the command line of a `cli` run (`nf install …`). */ + app: z.string(), + source: z.enum(['build', 'server', 'cli']), + stream: z.enum(['stdout', 'stderr']), + text: z.string(), + }), + z.object({ + type: z.literal('server'), + app: z.string(), + state: GameServerState, + exitCode: z.number().nullable().optional(), + }), + /** An engine → editor event of the game server (`toEditor`). */ + z.object({ + type: z.literal('bridge'), + app: z.string(), + event: z.string(), + args: z.array(z.unknown()), + }), +]); +export type RuntimeEvent = z.output; + +export const RuntimeContract = defineContract('runtime', { + methods: { + /** Builds apps now (all runnable apps by default) and resolves with their status. */ + build: { + input: z.object({ project: ProjectId, apps: z.array(z.string()).optional() }), + output: z.array(BuildStatus), + }, + status: { + input: z.object({ project: ProjectId }), + output: z.object({ + builds: z.array(BuildStatus), + servers: z.array(z.object({ app: z.string(), state: GameServerState })), + }), + }, + manifest: { input: Target, output: OutputManifest }, + /** Environment of an app as the game sees it (prefixes stripped). */ + env: { + input: Target.extend({ overrides: EnvOverrides.default({}) }), + output: z.record(z.string(), z.string()), + }, + startServer: { + input: Target.extend({ overrides: EnvOverrides.default({}) }), + output: z.null(), + }, + /** Asks the game to stop, kills it after a timeout; resolves once it exited. */ + stopServer: { input: Target, output: z.null() }, + /** Sends an editor → engine event to the game server (`fromEditor`). */ + sendServer: { + input: Target.extend({ event: z.string().min(1), args: z.array(z.unknown()).default([]) }), + output: z.null(), + }, + }, + streams: { + /** + * Builds, logs and game server events of a project. While subscribed, apps are rebuilt + * when their sources change. + */ + events: { params: z.object({ project: ProjectId }), event: RuntimeEvent }, + }, +}); diff --git a/packages/protocol/src/runtime/runtime.schema.ts b/packages/protocol/src/runtime/runtime.schema.ts new file mode 100644 index 00000000..d6945eee --- /dev/null +++ b/packages/protocol/src/runtime/runtime.schema.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +/** Base editor → engine commands handled by every NanoForge app. */ +export const RuntimeCommand = z.enum(['welcome', 'pause', 'resume', 'step', 'stop']); +export type RuntimeCommand = z.output; + +/** Run state reported by the engine (`state` bridge event). */ +export const EngineRunState = z.enum(['running', 'paused', 'stopped']); +export type EngineRunState = z.output; + +export const BuildDiagnostic = z.object({ + /** Project-relative path when the file is inside the project. */ + path: z.string().optional(), + line: z.number().int().optional(), + column: z.number().int().optional(), + message: z.string(), + severity: z.enum(['error', 'warning']), +}); +export type BuildDiagnostic = z.output; + +export const BuildStatus = z.object({ + app: z.string(), + state: z.enum(['idle', 'building', 'ok', 'error']), + /** Content hash of the last successful output: changes when the game changes. */ + version: z.string().nullable(), + diagnostics: z.array(BuildDiagnostic), + /** End of the last build (ms since epoch). */ + finishedAt: z.number().nullable(), + durationMs: z.number().nullable(), +}); +export type BuildStatus = z.output; + +export const OutputFile = z.object({ + /** Path relative to the output directory, `/`-separated. */ + path: z.string(), + size: z.number().int().nonnegative(), + /** sha1 of the content: cache key of the file. */ + hash: z.string(), +}); +export type OutputFile = z.output; + +/** Build output of an app, served at `baseUrl`. */ +export const OutputManifest = z.object({ + app: z.string(), + version: z.string(), + /** Absolute path on the server, ends with `/`. */ + baseUrl: z.string(), + entry: z.string(), + files: z.array(OutputFile), +}); +export type OutputManifest = z.output; + +export const GameServerState = z.enum(['starting', 'running', 'paused', 'stopping', 'exited']); +export type GameServerState = z.output; + +/** Environment overrides (`runtime.env` setting): `NANOFORGE_*` keys, as in `.env`. */ +export const EnvOverrides = z.record(z.string(), z.string()); diff --git a/packages/protocol/src/session/index.ts b/packages/protocol/src/session/index.ts new file mode 100644 index 00000000..8794094d --- /dev/null +++ b/packages/protocol/src/session/index.ts @@ -0,0 +1,2 @@ +export * from './session.schema'; +export * from './session.contract'; diff --git a/packages/protocol/src/session/session.contract.ts b/packages/protocol/src/session/session.contract.ts new file mode 100644 index 00000000..54928d65 --- /dev/null +++ b/packages/protocol/src/session/session.contract.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { SessionInfo } from './session.schema'; + +export const SessionContract = defineContract('session', { + methods: { + info: { input: z.null(), output: SessionInfo, public: true }, + }, +}); diff --git a/packages/protocol/src/session/session.schema.ts b/packages/protocol/src/session/session.schema.ts new file mode 100644 index 00000000..4615a849 --- /dev/null +++ b/packages/protocol/src/session/session.schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export const EditorMode = z.enum(['OFFLINE', 'ONLINE']); +export type EditorMode = z.output; + +export const User = z.object({ id: z.string(), name: z.string(), email: z.string().optional() }); +export type User = z.output; + +export const SessionInfo = z.object({ + mode: EditorMode, + version: z.string(), + /** Null when ONLINE and not signed in on the NanoForge website. */ + user: User.nullable(), + /** ONLINE: where to sign in (the NanoForge projects website sets the auth cookies). */ + loginUrl: z.string().nullable(), + /** + * What the services behind the editor offer: `settings` (account settings sync between + * machines; without it a hosted editor keeps them on its own disk), `registry` (the registry + * of plugins and packages answers). + */ + features: z.array(z.enum(['settings', 'registry'])).default([]), +}); +export type SessionInfo = z.output; diff --git a/packages/protocol/src/settings/index.ts b/packages/protocol/src/settings/index.ts new file mode 100644 index 00000000..dcb10f62 --- /dev/null +++ b/packages/protocol/src/settings/index.ts @@ -0,0 +1,2 @@ +export * from './settings.schema'; +export * from './settings.contract'; diff --git a/packages/protocol/src/settings/settings.contract.ts b/packages/protocol/src/settings/settings.contract.ts new file mode 100644 index 00000000..e50d6d47 --- /dev/null +++ b/packages/protocol/src/settings/settings.contract.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { defineContract } from '@nanoforge-dev/editor-rpc'; + +import { ProjectId } from '../project/project.schema'; +import { AccountSettingsDocument, SettingValues } from './settings.schema'; + +export const SettingsContract = defineContract('settings', { + methods: { + accountGet: { input: z.null(), output: AccountSettingsDocument }, + /** Fails with CONFLICT (data: the current document) when `baseRevision` is outdated. */ + accountPut: { + input: z.object({ baseRevision: z.string().nullable(), values: SettingValues }), + output: z.object({ revision: z.string() }), + }, + /** Hosted editors only: this user's settings for a project (local ones use a file). */ + projectLocalGet: { input: z.object({ project: ProjectId }), output: SettingValues }, + projectLocalPut: { + input: z.object({ project: ProjectId, values: SettingValues }), + output: z.null(), + }, + }, + streams: { + /** The account document changed (another tab or device): pull it. */ + accountChanges: { params: z.null(), event: z.object({ revision: z.string() }), coalesce: true }, + }, +}); diff --git a/packages/protocol/src/settings/settings.schema.ts b/packages/protocol/src/settings/settings.schema.ts new file mode 100644 index 00000000..b20541a4 --- /dev/null +++ b/packages/protocol/src/settings/settings.schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const SettingValues = z.record(z.string(), z.unknown()); + +/** The account settings document. `revision` is opaque (null before the first save). */ +export const AccountSettingsDocument = z.object({ + revision: z.string().nullable(), + values: SettingValues, +}); +export type AccountSettingsDocument = z.output; + +/** Limits of a settings document (also enforced by the NanoForge API). */ +export const SETTINGS_LIMITS = { maxBytes: 256 * 1024, maxKeys: 2000 } as const; diff --git a/packages/protocol/test/path/project-path.spec.ts b/packages/protocol/test/path/project-path.spec.ts new file mode 100644 index 00000000..4913b8f1 --- /dev/null +++ b/packages/protocol/test/path/project-path.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { ProjectPath, basename, dirname, joinPath } from '../../src/path/project-path'; + +describe('ProjectPath', () => { + it.each(['', 'a', 'apps/client/src/main.ts', '.nanoforge/editor/settings.json'])( + 'accepts %j', + (path) => expect(ProjectPath.safeParse(path).success).toBe(true), + ); + + it.each(['/etc/passwd', '../x', 'a/../b', 'a/./b', 'a//b', 'a/', 'a\\b'])('rejects %j', (path) => + expect(ProjectPath.safeParse(path).success).toBe(false), + ); + + it('helpers', () => { + expect(joinPath('apps/client', '/src/', 'main.ts')).toBe('apps/client/src/main.ts'); + expect(dirname('apps/client/main.ts')).toBe('apps/client'); + expect(dirname('main.ts')).toBe(''); + expect(basename('apps/client/main.ts')).toBe('main.ts'); + }); +}); diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 00000000..f2fa7f24 --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/registry/.prettierignore b/packages/registry/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/registry/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/registry/LICENSE b/packages/registry/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/registry/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/registry/README.md b/packages/registry/README.md new file mode 100644 index 00000000..9d562b8a --- /dev/null +++ b/packages/registry/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/registry` is an internal package of the NanoForge Editor. + +NanoForge registry client and installer for packages and plugins (no editor dependency: meant to move to the CLI). + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/registry +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/registry/package.json b/packages/registry/package.json new file mode 100644 index 00000000..462295f7 --- /dev/null +++ b/packages/registry/package.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/registry", + "version": "0.0.0", + "description": "NanoForge Editor - NanoForge registry client and installer for packages and plugins (no editor dependency: meant to move to the CLI)", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./types": "./src/types.ts", + "./tsconfig-paths": "./src/tsconfig-paths.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/registry" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty .", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "test:contract": "vitest run --config ../../vitest.config.ts test/contract" + }, + "dependencies": { + "fflate": "catalog:libs", + "semver": "catalog:libs", + "zod": "catalog:libs" + }, + "devDependencies": { + "@types/node": "catalog:core", + "@types/semver": "catalog:libs", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/registry/src/archive/archive.ts b/packages/registry/src/archive/archive.ts new file mode 100644 index 00000000..d820a363 --- /dev/null +++ b/packages/registry/src/archive/archive.ts @@ -0,0 +1,131 @@ +import { strFromU8, unzipSync, zipSync } from 'fflate'; +import { createHash, randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { ItemManifest, MANIFEST_FILE } from '../item/item.schema'; +import { RegistryError } from '../registry/registry.exception'; + +export const sha256 = (bytes: Uint8Array): string => + createHash('sha256').update(bytes).digest('hex'); + +/** Size of an archive, packed: a download past it is stopped. */ +export const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024; +/** What an archive may unpack to, in all and in files. */ +const MAX_UNPACKED_BYTES = 256 * 1024 * 1024; +const MAX_FILES = 20_000; + +/** A zip entry that would be written outside of the target folder, or in an odd place. */ +const unsafe = (path: string): boolean => + path.startsWith('/') || + path.includes('\\') || + /^[A-Za-z]:/.test(path) || + path.split('/').some((segment) => segment === '..' || segment === '.'); + +/** + * The files of an archive by path (folders left out); rejects unsafe paths, and archives that + * unpack to more than 256 MiB or 20,000 files (checked on the sizes the entries claim, before + * anything is allocated, then on what they gave). An entry allocates the larger of its packed and + * unpacked sizes: a stored one takes its packed size, whatever it claims unpacked. + */ +export const readArchive = (bytes: Uint8Array): Map => { + if (bytes.length > MAX_ARCHIVE_BYTES) throw tooLarge(); + let entries: Record; + let claimed = 0; + let count = 0; + try { + entries = unzipSync(bytes, { + filter: (file) => { + claimed += Math.max(file.size, file.originalSize); + if (++count > MAX_FILES || claimed > MAX_UNPACKED_BYTES) throw tooLarge(); + return true; + }, + }); + } catch (error) { + if (error instanceof RegistryError) throw error; + throw new RegistryError('INVALID', 'The archive is not a zip file'); + } + const unpacked = Object.values(entries).reduce((total, content) => total + content.length, 0); + if (unpacked > MAX_UNPACKED_BYTES) throw tooLarge(); + const files = new Map(); + for (const [path, content] of Object.entries(entries)) { + if (path.endsWith('/')) continue; + if (unsafe(path)) + throw new RegistryError('INVALID', `The archive holds an unsafe path: ${path}`); + files.set(path, content); + } + return files; +}; + +const tooLarge = () => + new RegistryError('INVALID', 'The archive is too large (256 MiB or 20,000 files at most)'); + +/** The manifest at the root of an archive. */ +export const manifestOf = (files: ReadonlyMap): ItemManifest => { + const raw = files.get(MANIFEST_FILE); + if (!raw) throw new RegistryError('INVALID', `The archive has no ${MANIFEST_FILE}`); + let json: unknown; + try { + json = JSON.parse(strFromU8(raw)); + } catch { + throw new RegistryError('INVALID', `${MANIFEST_FILE} is not valid JSON`); + } + const parsed = ItemManifest.safeParse(json); + if (!parsed.success) { + throw new RegistryError( + 'INVALID', + `${MANIFEST_FILE} is not valid: ${parsed.error.issues[0]?.message ?? 'unknown problem'}`, + ); + } + return parsed.data; +}; + +/** + * Writes the files of an archive as a folder, replacing what was there. The manifest is written + * last: a folder without it is an install that did not finish, and is installed again. + * + * Files are written in place rather than in a temporary folder renamed at the end: file + * watchers (the editor's) miss the content of a folder that appears through a rename. The old + * folder is moved aside first (`.nf-`, a name the editor's watcher skips) and put back when + * the write fails (disk full), so a failed reinstall keeps the working copy. + */ +export const writeFolder = async ( + files: ReadonlyMap, + target: string, +): Promise => { + const backup = `${target}.nf-${randomBytes(4).toString('hex')}`; + const previous = existsSync(target); + if (previous) await rename(target, backup); + const ordered = [...files].sort( + ([a], [b]) => Number(a === MANIFEST_FILE) - Number(b === MANIFEST_FILE), + ); + try { + for (const [path, content] of ordered) { + const file = join(target, path); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, content); + } + } catch (error) { + await rm(target, { recursive: true, force: true }).catch(() => undefined); + if (previous) await rename(backup, target).catch(() => undefined); + throw error; + } + if (previous) await rm(backup, { recursive: true, force: true }).catch(() => undefined); +}; + +/** Zips a folder with stable bytes (sorted paths, no dates), so its hash only follows its content. */ +export const zipFolder = async (folder: string): Promise => { + const files: Record = {}; + const walk = async (dir: string, prefix: string) => { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const path = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) await walk(join(dir, entry.name), path); + else if (entry.isFile()) + files[path] = [await readFile(join(dir, entry.name)), { mtime: new Date(315532800000) }]; + } + }; + await walk(folder, ''); + return zipSync(files, { level: 6 }); +}; diff --git a/packages/registry/src/archive/index.ts b/packages/registry/src/archive/index.ts new file mode 100644 index 00000000..993afe63 --- /dev/null +++ b/packages/registry/src/archive/index.ts @@ -0,0 +1 @@ +export * from './archive'; diff --git a/packages/registry/src/client/index.ts b/packages/registry/src/client/index.ts new file mode 100644 index 00000000..ff6f4d98 --- /dev/null +++ b/packages/registry/src/client/index.ts @@ -0,0 +1 @@ +export * from './registry-client'; diff --git a/packages/registry/src/client/registry-client.ts b/packages/registry/src/client/registry-client.ts new file mode 100644 index 00000000..23e554b4 --- /dev/null +++ b/packages/registry/src/client/registry-client.ts @@ -0,0 +1,182 @@ +import semver from 'semver'; + +import { MAX_ARCHIVE_BYTES } from '../archive/archive'; +import { type RegistryItem, SearchQuery, type SearchResult } from '../item/item.schema'; +import { RegistryError } from '../registry/registry.exception'; +import type { Registry } from '../registry/registry.type'; +import { fromPackument, fromSearchObject } from '../wire/packument'; +import { Packument, ROUTES, WireSearch } from '../wire/wire.schema'; + +const ABBREVIATED = 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8'; +/** Statuses whose `location` is followed, and how many redirects in a row at most. */ +const REDIRECTS = new Set([301, 302, 303, 307, 308]); +const MAX_REDIRECTS = 5; + +export interface RegistryClientOptions { + /** Base URL of the API holding `/registry/…`. */ + readonly baseUrl: string; + readonly fetch?: typeof fetch; + /** Headers of every request (reading needs none; a registry key to publish). */ + readonly headers?: () => Record; + /** How long a request may take (default 60 s). */ + readonly timeoutMs?: number; +} + +/** The registry over HTTP (the contract of `docs/api/registry.md`). */ +export class RegistryClient implements Registry { + constructor(private readonly _options: RegistryClientOptions) {} + + async search(query: SearchQuery = {}): Promise { + const { type, q, page, limit } = SearchQuery.parse(query); + const params = new URLSearchParams({ + text: q, + from: String((page - 1) * limit), + size: String(limit), + }); + if (type) params.set('type', type); + const response = await this._request(`${ROUTES.search}?${params}`); + const found = this._parse(WireSearch, await this._json(response)); + return { items: found.objects.map(fromSearchObject), total: found.total }; + } + + async get(name: string): Promise { + return fromPackument(await this._packument(name), semver.rcompare); + } + + async download(name: string, version: string): Promise { + const found = (await this._packument(name, true)).versions[version]; + if (!found) throw new RegistryError('NOT_FOUND', `${name}@${version} was not found`); + const subject = `${name}@${version}`; + const response = await this._request(found.dist.tarball, subject); + const limit = Math.min(found.dist.size ?? MAX_ARCHIVE_BYTES, MAX_ARCHIVE_BYTES); + const bytes = await readLimited(response, limit, subject); + if (found.dist.size !== undefined && bytes.length !== found.dist.size) + throw new RegistryError('INTEGRITY', `${subject} does not have the size the registry says`); + return bytes; + } + + /** + * The document of an item. `abbreviated` asks for what installing needs (npm's + * `application/vnd.npm.install-v1+json`): versions without their contents nor the readme. + */ + private async _packument(name: string, abbreviated = false): Promise { + const response = await this._request( + ROUTES.item(name), + name, + abbreviated ? { accept: ABBREVIATED } : {}, + ); + return this._parse(Packument, await this._json(response)); + } + + /** + * A request to the registry, or to where it keeps an archive (`dist.tarball` may be anywhere): + * http(s) only, and the registry's headers only go to the registry's own origin. Redirects are + * followed here, so that the headers stay off an address one leads to on another origin. + */ + private async _request( + path: string, + subject?: string, + headers: Record = {}, + ): Promise { + const base = new URL(this._options.baseUrl); + const signal = AbortSignal.timeout(this._options.timeoutMs ?? 60_000); + let url = new URL(path, base); + let response: Response; + for (let redirects = 0; ; redirects++) { + if (url.protocol !== 'https:' && url.protocol !== 'http:') + throw new RegistryError( + 'INVALID', + `The registry gave an address that is not http(s): ${url.protocol}`, + ); + try { + response = await (this._options.fetch ?? fetch)(url.toString(), { + headers: { + ...headers, + ...(url.origin === base.origin ? this._options.headers?.() : {}), + }, + redirect: 'manual', + signal, + }); + } catch (error) { + throw new RegistryError( + 'UNAVAILABLE', + `The registry cannot be reached: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const location = REDIRECTS.has(response.status) ? response.headers.get('location') : null; + if (location === null) break; + if (redirects >= MAX_REDIRECTS) + throw new RegistryError('UNAVAILABLE', 'The registry redirected too many times'); + url = new URL(location, url); + } + if (response.status === 404) { + throw subject + ? new RegistryError('NOT_FOUND', `${subject} was not found`) + : new RegistryError('UNAVAILABLE', 'This registry does not answer searches yet'); + } + if (!response.ok) + throw new RegistryError('UNAVAILABLE', `The registry answered ${response.status}`); + return response; + } + + private async _json(response: Response): Promise { + try { + return (await response.json()) as unknown; + } catch { + return undefined; + } + } + + private _parse( + schema: { safeParse(value: unknown): { success: boolean; data?: T } }, + value: unknown, + ): T { + const parsed = schema.safeParse(value); + if (!parsed.success) + throw new RegistryError( + 'UNAVAILABLE', + 'The registry sent an answer this version cannot read', + ); + return parsed.data as T; + } +} + +/** The body of a response, stopped as soon as it passes `limit` bytes. */ +const readLimited = async ( + response: Response, + limit: number, + subject: string, +): Promise => { + const declared = Number(response.headers.get('content-length') ?? NaN); + const tooLarge = () => new RegistryError('INVALID', `${subject} is larger than allowed`); + if (declared > limit) throw tooLarge(); + if (!response.body) return new Uint8Array(await response.arrayBuffer()); + const chunks: Uint8Array[] = []; + let total = 0; + const reader = response.body.getReader(); + for (;;) { + let chunk; + try { + chunk = await reader.read(); + } catch (error) { + throw new RegistryError( + 'UNAVAILABLE', + `The download of ${subject} failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (chunk.done) break; + total += chunk.value.length; + if (total > limit) { + await reader.cancel().catch(() => undefined); + throw tooLarge(); + } + chunks.push(chunk.value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; +}; diff --git a/packages/registry/src/directory/directory-registry.ts b/packages/registry/src/directory/directory-registry.ts new file mode 100644 index 00000000..eb4e9511 --- /dev/null +++ b/packages/registry/src/directory/directory-registry.ts @@ -0,0 +1,186 @@ +import { existsSync } from 'node:fs'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import semver from 'semver'; + +import { sha256, zipFolder } from '../archive/archive'; +import { + ItemManifest, + ItemName, + MANIFEST_FILE, + type RegistryItem, + type RegistrySummary, + type RegistryVersion, + SearchQuery, + type SearchResult, +} from '../item/item.schema'; +import { countKinds, readPackageContents } from '../manifest/package-contents'; +import { RegistryError } from '../registry/registry.exception'; +import type { Registry } from '../registry/registry.type'; + +interface Entry { + readonly item: RegistryItem; + /** Folder of each version. */ + readonly folders: ReadonlyMap; +} + +/** + * A registry read from a folder, for tests and for working without the real one: + * `/@scope/name//` holds the files of each version (its manifest, and a + * `README.md`), and `/@scope/name/registry.json` the optional `{ author, downloads }`. + * Version folders added or removed are followed; call `reload()` after changing files inside one. + */ +export class DirectoryRegistry implements Registry { + private _entries: Promise> | undefined; + private _listed = ''; + private readonly _archives = new Map>(); + + constructor(readonly dir: string) {} + + /** Reads the folder again on the next call (it changed). */ + reload(): void { + this._entries = undefined; + this._archives.clear(); + } + + async search(query: SearchQuery = {}): Promise { + const { type, q, page, limit } = SearchQuery.parse(query); + const words = q.toLowerCase().split(/\s+/).filter(Boolean); + const all = [...(await this._load()).values()] + .map(({ item }): RegistrySummary => ({ + name: item.name, + type: item.type, + version: item.version, + description: item.description, + author: item.author, + downloads: item.downloads, + updatedAt: item.updatedAt, + kinds: item.kinds, + })) + .filter( + (item) => + (!type || item.type === type) && + words.every((word) => `${item.name} ${item.description}`.toLowerCase().includes(word)), + ) + .sort((a, b) => b.downloads - a.downloads || a.name.localeCompare(b.name)); + return { items: all.slice((page - 1) * limit, page * limit), total: all.length }; + } + + async get(name: string): Promise { + const entry = (await this._load()).get(name); + if (!entry) throw new RegistryError('NOT_FOUND', `${name} was not found`); + return entry.item; + } + + async download(name: string, version: string): Promise { + const folder = (await this._load()).get(name)?.folders.get(version); + if (!folder) throw new RegistryError('NOT_FOUND', `${name}@${version} was not found`); + return this._archive(folder); + } + + private _archive(folder: string): Promise { + let archive = this._archives.get(folder); + if (!archive) this._archives.set(folder, (archive = zipFolder(folder))); + return archive; + } + + private async _load(): Promise> { + const listing = await this._listing(); + if (!this._entries || listing !== this._listed) { + this._listed = listing; + this._entries = this._read(); + } + return this._entries; + } + + /** The version folders there are, as one string to compare. */ + private async _listing(): Promise { + const names = (dir: string) => + readdir(dir, { withFileTypes: true }).then( + (entries) => entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name), + () => [] as string[], + ); + const found: string[] = []; + for (const scope of await names(this.dir)) { + for (const name of await names(join(this.dir, scope))) { + for (const version of await names(join(this.dir, scope, name))) + found.push(`${scope}/${name}/${version}`); + } + } + return found.sort().join('\n'); + } + + private async _read(): Promise> { + const entries = new Map(); + if (!existsSync(this.dir)) return entries; + const folders = async (dir: string) => + (await readdir(dir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + for (const scope of (await folders(this.dir)).filter((name) => name.startsWith('@'))) { + for (const short of await folders(join(this.dir, scope))) { + const name = `${scope}/${short}`; + if (!ItemName.safeParse(name).success) continue; + const root = join(this.dir, scope, short); + const versions: (RegistryVersion & { manifest: ItemManifest; folder: string })[] = []; + for (const version of await folders(root)) { + const folder = join(root, version); + const readJson = (path: string) => + readFile(join(folder, path), 'utf8') + .then((text) => JSON.parse(text) as unknown) + .catch(() => undefined); + const raw = await readJson(MANIFEST_FILE); + const manifest = ItemManifest.safeParse(raw); + if (!manifest.success || manifest.data.name !== name || manifest.data.version !== version) + continue; + const { contents, manifests } = await readPackageContents(raw, readJson); + versions.push({ + version, + publishedAt: (await stat(join(folder, MANIFEST_FILE))).mtime.toISOString(), + sha256: sha256(await this._archive(folder)), + dependencies: manifest.data.dependencies ?? {}, + engines: manifest.data.engines ?? {}, + kinds: countKinds(contents), + suggestedPlugins: manifest.data.suggestedPlugins ?? {}, + contents, + manifests, + manifest: manifest.data, + folder, + }); + } + if (!versions.length) continue; + versions.sort((a, b) => semver.rcompare(a.version, b.version)); + const latest = versions[0]!; + const extra = (await readFile(join(root, 'registry.json'), 'utf8') + .then((text) => JSON.parse(text) as unknown) + .catch(() => ({}))) as { author?: string; downloads?: number }; + entries.set(name, { + item: { + name, + type: latest.manifest.type, + version: latest.version, + description: latest.manifest.description ?? '', + author: extra.author ?? '', + downloads: extra.downloads ?? 0, + updatedAt: latest.publishedAt, + kinds: latest.kinds, + readme: await readFile(join(latest.folder, 'README.md'), 'utf8').catch(() => ''), + versions: versions.map((entry) => ({ + version: entry.version, + publishedAt: entry.publishedAt, + sha256: entry.sha256, + dependencies: entry.dependencies, + engines: entry.engines, + kinds: entry.kinds, + suggestedPlugins: entry.suggestedPlugins, + contents: entry.contents, + manifests: entry.manifests, + })), + }, + folders: new Map(versions.map((version) => [version.version, version.folder])), + }); + } + } + return entries; + } +} diff --git a/packages/registry/src/directory/index.ts b/packages/registry/src/directory/index.ts new file mode 100644 index 00000000..dc8bfd6b --- /dev/null +++ b/packages/registry/src/directory/index.ts @@ -0,0 +1 @@ +export * from './directory-registry'; diff --git a/packages/registry/src/index.ts b/packages/registry/src/index.ts new file mode 100644 index 00000000..2ad282f1 --- /dev/null +++ b/packages/registry/src/index.ts @@ -0,0 +1,11 @@ +export * from './item'; +export * from './manifest'; +export * from './registry'; +export * from './archive'; +export * from './client'; +export * from './directory'; +export * from './serve'; +export * from './install'; +export * from './tsconfig-paths'; +export * from './project'; +export * from './wire'; diff --git a/packages/registry/src/install/index.ts b/packages/registry/src/install/index.ts new file mode 100644 index 00000000..c18ba7bf --- /dev/null +++ b/packages/registry/src/install/index.ts @@ -0,0 +1 @@ +export * from './install-item'; diff --git a/packages/registry/src/install/install-item.ts b/packages/registry/src/install/install-item.ts new file mode 100644 index 00000000..0d61e65f --- /dev/null +++ b/packages/registry/src/install/install-item.ts @@ -0,0 +1,75 @@ +import semver from 'semver'; + +import { manifestOf, readArchive, sha256, writeFolder } from '../archive/archive'; +import { + type ItemManifest, + ItemName, + type ItemType, + type RegistryItem, + type RegistryVersion, +} from '../item/item.schema'; +import { RegistryError } from '../registry/registry.exception'; +import type { Registry } from '../registry/registry.type'; + +/** The newest version of an item that fits a range and, when given, what runs it (`engines`). */ +export const pickVersion = ( + item: RegistryItem, + range = '*', + engines: Readonly> = {}, +): RegistryVersion | undefined => + item.versions + .filter( + (version) => + semver.satisfies(version.version, range, { includePrerelease: false }) && + Object.entries(engines).every( + ([engine, current]) => + !version.engines[engine] || + semver.satisfies(current, version.engines[engine], { includePrerelease: true }), + ), + ) + .sort((a, b) => semver.rcompare(a.version, b.version))[0]; + +export interface InstallRequest { + readonly name: string; + readonly version: string; + readonly type: ItemType; + /** The folder the item becomes: `…/@scope/name`. */ + readonly target: string; + /** Hash the archive must have (the registry's, or the lock's). */ + readonly sha256?: string; +} + +/** + * Downloads a version and writes it as a folder. Nothing is written unless the archive has the + * expected hash, holds only safe paths, and its manifest says the name, version and type asked + * for. + */ +export const installItem = async ( + registry: Registry, + request: InstallRequest, +): Promise => { + const name = ItemName.safeParse(request.name); + if (!name.success) throw new RegistryError('INVALID', `${request.name} is not a valid name`); + const bytes = await registry.download(request.name, request.version); + const hash = sha256(bytes); + if (request.sha256 !== undefined && request.sha256 !== hash) { + throw new RegistryError( + 'INTEGRITY', + `${request.name}@${request.version} does not have the expected content (hash mismatch)`, + ); + } + const files = readArchive(bytes); + const manifest = manifestOf(files); + if ( + manifest.name !== request.name || + manifest.version !== request.version || + manifest.type !== request.type + ) { + throw new RegistryError( + 'INVALID', + `The archive of ${request.name}@${request.version} holds ${manifest.type} ${manifest.name}@${manifest.version}`, + ); + } + await writeFolder(files, request.target); + return manifest; +}; diff --git a/packages/registry/src/item/index.ts b/packages/registry/src/item/index.ts new file mode 100644 index 00000000..6f94be23 --- /dev/null +++ b/packages/registry/src/item/index.ts @@ -0,0 +1 @@ +export * from './item.schema'; diff --git a/packages/registry/src/item/item.schema.ts b/packages/registry/src/item/item.schema.ts new file mode 100644 index 00000000..43a1dba8 --- /dev/null +++ b/packages/registry/src/item/item.schema.ts @@ -0,0 +1,119 @@ +import semver from 'semver'; +import { z } from 'zod'; + +/** `@scope/name`: the name of everything in the registry. */ +export const ItemName = z + .string() + .regex(/^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/, 'expected @scope/name, lowercase'); +export type ItemName = z.output; + +export const ItemType = z.enum(['plugin', 'package']); +export type ItemType = z.output; + +/** A version semver reads as is (no `v`, no leading zero, no empty pre-release part). */ +export const ItemVersion = z + .string() + .refine((version) => semver.valid(version) === version, 'expected a version like 1.2.3'); + +/** Other registry items an item needs, with version ranges. */ +export const Dependencies = z.record(ItemName, z.string()); +export type Dependencies = z.output; + +/** Dependencies as a manifest may write them: `["@scope/name"]` (any version) or with ranges. */ +export const ManifestDependencies = z.union([ + z.array(ItemName).transform((names) => Object.fromEntries(names.map((name) => [name, '*']))), + Dependencies, +]); + +/** How many contents of each kind a version holds: `{ "@nanoforge/ecs:component": 3 }`. */ +export const Kinds = z.record(z.string(), z.number().int().nonnegative()); +export type Kinds = z.output; + +/** What the registry says of an item in lists. */ +export const RegistrySummary = z.object({ + name: ItemName, + type: ItemType, + /** The newest version. */ + version: ItemVersion, + description: z.string().default(''), + author: z.string().default(''), + downloads: z.number().int().nonnegative().default(0), + /** ISO date of the newest version. */ + updatedAt: z.string().default(''), + /** What the newest version holds, by kind. */ + kinds: Kinds.default({}), +}); +export type RegistrySummary = z.output; + +/** + * One content of a published version, as the registry lists it: paths from the archive root, + * `manifest` naming the sub-manifest it comes from; the API adds `fileCount` and `size`. + */ +export const VersionContent = z + .object({ + kind: z.string(), + path: z.string(), + export: z.string().optional(), + manifest: z.string().optional(), + fileCount: z.number().int().nonnegative().optional(), + size: z.number().int().nonnegative().optional(), + }) + .loose(); +export type VersionContent = z.output; + +export const RegistryVersion = z.object({ + version: ItemVersion, + publishedAt: z.string().default(''), + /** sha256 of the archive, hex. */ + sha256: z.string().regex(/^[0-9a-f]{64}$/), + dependencies: Dependencies.default({}), + /** What the version needs to run, e.g. `{ "editor": ">=0.1.0" }`. */ + engines: z.record(z.string(), z.string()).default({}), + /** What the version holds, by kind. */ + kinds: Kinds.default({}), + /** Editor plugins that make the version easier to use, with ranges. */ + suggestedPlugins: Dependencies.default({}), + /** What the version holds (root manifest and sub-manifests joined). */ + contents: z.array(VersionContent).default([]), + /** The sub-manifests its contents were read from. */ + manifests: z.array(z.string()).default([]), +}); +export type RegistryVersion = z.output; + +export const RegistryItem = RegistrySummary.extend({ + readme: z.string().default(''), + /** Every version, the newest first. */ + versions: z.array(RegistryVersion).min(1), +}); +export type RegistryItem = z.output; + +export const SearchQuery = z.object({ + type: ItemType.optional(), + /** Words searched in names and descriptions. */ + q: z.string().default(''), + page: z.number().int().min(1).default(1), + limit: z.number().int().min(1).max(100).default(30), +}); +export type SearchQuery = z.input; + +export const SearchResult = z.object({ + items: z.array(RegistrySummary), + total: z.number().int().nonnegative(), +}); +export type SearchResult = z.output; + +/** The part of `nanoforge.manifest.json` the installer checks. */ +export const ItemManifest = z + .object({ + type: ItemType, + name: ItemName, + version: ItemVersion, + description: z.string().optional(), + dependencies: ManifestDependencies.optional(), + engines: z.record(z.string(), z.string()).optional(), + suggestedPlugins: ManifestDependencies.optional(), + }) + .loose(); +export type ItemManifest = z.output; + +export const MANIFEST_FILE = 'nanoforge.manifest.json'; diff --git a/packages/registry/src/manifest/index.ts b/packages/registry/src/manifest/index.ts new file mode 100644 index 00000000..f48cb72e --- /dev/null +++ b/packages/registry/src/manifest/index.ts @@ -0,0 +1 @@ +export * from './package-contents'; diff --git a/packages/registry/src/manifest/package-contents.ts b/packages/registry/src/manifest/package-contents.ts new file mode 100644 index 00000000..8ccf3397 --- /dev/null +++ b/packages/registry/src/manifest/package-contents.ts @@ -0,0 +1,171 @@ +import { z } from 'zod'; + +import { MANIFEST_FILE } from '../item/item.schema'; + +/** How deep sub-manifests nest, the root manifest's own includes being the first level. */ +export const MAX_INCLUDE_DEPTH = 4; +/** How many sub-manifests a package may have, in all. */ +export const MAX_SUB_MANIFESTS = 100; + +/** A core kind (`asset`, `type`…) or one owned by a plugin (`@nanoforge/ecs:component`). */ +const KIND_PATTERN = /^(@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*:)?[a-z][a-z0-9-]*$/; + +/** A path inside the package: no absolute path, no `..`, no `\`. */ +export const isInsidePath = (path: string): boolean => + path.length > 0 && + !path.startsWith('/') && + !path.includes('\\') && + !/^[A-Za-z]:/.test(path) && + !path.split('/').some((segment) => segment === '..' || segment === '.'); + +const InsidePath = z.string().refine(isInsidePath, 'must be a path inside the package'); + +/** + * One entry of a manifest's `contents`: a file, or a folder when its path ends with `/`. Extra + * keys are owner objects (`"@scope/name": { … }`), kept as they are. + */ +export const ManifestContent = z + .object({ + kind: z.string().regex(KIND_PATTERN, 'expected a kind like "asset" or "@scope/name:kind"'), + path: InsidePath, + export: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + side: z.enum(['client', 'server', 'shared']).optional(), + tags: z.array(z.string()).optional(), + }) + .loose(); +export type ManifestContent = z.output; + +/** A content of a package, its path from the package root. */ +export type PackageContent = ManifestContent & { + /** The sub-manifest it is described in, from the package root; absent for the root manifest. */ + readonly manifest?: string; +}; + +export interface PackageContents { + /** The contents of the root manifest, then of each sub-manifest, level by level. */ + readonly contents: PackageContent[]; + /** The sub-manifests read, from the package root. */ + readonly manifests: string[]; + /** What was skipped and why: the registry refuses these, a local package may still have them. */ + readonly problems: string[]; +} + +/** Reads the JSON of a file of the package, by its path from the package root; undefined if none. */ +export type ManifestReader = (path: string) => Promise; + +interface Level { + /** From the package root. */ + readonly file: string; + /** Its folder, from the package root, `''` or ending with `/`. */ + readonly dir: string; +} + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Every content a package describes: its manifest's `contents`, and those of the sub-manifests + * `include` lists, as the registry reads them at publication. An `include` entry is a folder + * (its `nanoforge.manifest.json`) or a `.json` file, relative to the manifest that lists it; a + * sub-manifest only holds `contents` and `include`, and its paths are relative to its folder. + * + * Installed packages passed the registry's checks: what does not fit is skipped and reported in + * `problems` rather than thrown, so a package being written still shows what it can. + */ +export const readPackageContents = async ( + manifest: unknown, + read: ManifestReader, +): Promise => { + const result: PackageContents = { contents: [], manifests: [], problems: [] }; + if (!isObject(manifest)) return result; + const seen = new Set([MANIFEST_FILE]); + const root: Level = { file: MANIFEST_FILE, dir: '' }; + + collect(manifest.contents, root, result); + let level = includes(manifest.include, root, seen, result); + for (let depth = 1; level.length; depth++) { + if (depth > MAX_INCLUDE_DEPTH) { + result.problems.push(`sub-manifests nest at most ${MAX_INCLUDE_DEPTH} levels deep`); + break; + } + const next: Level[] = []; + for (const sub of level) { + if (result.manifests.length >= MAX_SUB_MANIFESTS) { + result.problems.push(`at most ${MAX_SUB_MANIFESTS} sub-manifests`); + return result; + } + const raw = await read(sub.file).catch(() => undefined); + if (!isObject(raw)) { + result.problems.push(`${sub.file}: missing, or not a JSON object`); + continue; + } + result.manifests.push(sub.file); + collect(raw.contents, sub, result); + next.push(...includes(raw.include, sub, seen, result)); + } + level = next; + } + return result; +}; + +const collect = (value: unknown, level: Level, result: PackageContents): void => { + if (value === undefined) return; + if (!Array.isArray(value)) { + result.problems.push(`${level.file}: contents is not an array`); + return; + } + value.forEach((entry, index) => { + const parsed = ManifestContent.safeParse(entry); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + result.problems.push( + `${level.file}: contents[${index}]${issue?.path.length ? `.${issue.path.join('.')}` : ''}: ${issue?.message ?? 'invalid'}`, + ); + return; + } + result.contents.push({ + ...parsed.data, + path: `${level.dir}${parsed.data.path}`, + ...(level.file !== MANIFEST_FILE && { manifest: level.file }), + }); + }); +}; + +const includes = ( + value: unknown, + parent: Level, + seen: Set, + result: PackageContents, +): Level[] => { + if (value === undefined) return []; + if (!Array.isArray(value)) { + result.problems.push(`${parent.file}: include is not an array`); + return []; + } + const levels: Level[] = []; + value.forEach((path: unknown, index) => { + if (typeof path !== 'string' || !isInsidePath(path)) { + result.problems.push(`${parent.file}: include[${index}] is not a path inside the package`); + return; + } + const file = path.endsWith('.json') + ? `${parent.dir}${path}` + : `${parent.dir}${path.replace(/\/$/, '')}/${MANIFEST_FILE}`; + if (seen.has(file)) { + result.problems.push(`${parent.file}: include[${index}]: ${file} is included twice`); + return; + } + seen.add(file); + levels.push({ file, dir: file.slice(0, file.lastIndexOf('/') + 1) }); + }); + return levels; +}; + +/** How many contents of each kind there are. */ +export const countKinds = (contents: readonly { kind: string }[]): Record => { + const kinds: Record = {}; + for (const { kind } of contents) kinds[kind] = (kinds[kind] ?? 0) + 1; + return kinds; +}; diff --git a/packages/registry/src/project/index.ts b/packages/registry/src/project/index.ts new file mode 100644 index 00000000..09f14863 --- /dev/null +++ b/packages/registry/src/project/index.ts @@ -0,0 +1,3 @@ +export * from './project-packages.const'; +export * from './project-packages.type'; +export * from './project-packages'; diff --git a/packages/registry/src/project/modules-folder.ts b/packages/registry/src/project/modules-folder.ts new file mode 100644 index 00000000..fd397421 --- /dev/null +++ b/packages/registry/src/project/modules-folder.ts @@ -0,0 +1,47 @@ +import { randomBytes } from 'node:crypto'; +import { readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** Folders set aside while packages change (`.nf-old-`); swept when found later. */ +const ASIDE = '.nf-'; + +/** Moves a folder out of the way, to put it back if the change fails. */ +export const setAside = async (folder: string): Promise => { + const aside = `${folder}${ASIDE}old-${randomBytes(4).toString('hex')}`; + await rename(folder, aside); + return aside; +}; + +/** Removes the folders an interrupted change left set aside in `nf_modules`. */ +export const sweepAside = async (modules: string): Promise => { + for (const scope of await readdir(modules).catch(() => [])) { + for (const entry of await readdir(join(modules, scope)).catch(() => [])) { + if (entry.includes(ASIDE)) + await rm(join(modules, scope, entry), { recursive: true, force: true }); + } + } +}; + +/** Names of the packages present in `nf_modules`. */ +export const installedNames = async (modules: string): Promise => { + const names: string[] = []; + for (const scope of await readdir(modules).catch(() => [])) { + if (!scope.startsWith('@')) continue; + for (const entry of await readdir(join(modules, scope)).catch(() => [])) { + if (!entry.includes(ASIDE)) names.push(`${scope}/${entry}`); + } + } + return names; +}; + +/** Writes a whole file or nothing (a temporary file, then a rename). */ +export const writeAtomically = async (path: string, text: string): Promise => { + const temp = `${path}.${randomBytes(4).toString('hex')}.tmp`; + try { + await writeFile(temp, text); + await rename(temp, path); + } catch (error) { + await rm(temp, { force: true }); + throw error; + } +}; diff --git a/packages/registry/src/project/packages-file.schema.ts b/packages/registry/src/project/packages-file.schema.ts new file mode 100644 index 00000000..878b6608 --- /dev/null +++ b/packages/registry/src/project/packages-file.schema.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { Dependencies, ItemName, ItemVersion } from '../item/item.schema'; + +export const PackagesList = z.object({ packages: Dependencies.default({}) }).loose(); +export const PackagesLock = z.object({ + lockVersion: z.literal(1), + packages: z + .record( + ItemName, + z.object({ + version: ItemVersion, + sha256: z.string().regex(/^[0-9a-f]{64}$/, 'expected a sha256 in hex'), + dependencies: Dependencies.default({}), + }), + ) + .default({}), +}); +export type Lock = z.output['packages']; diff --git a/packages/registry/src/project/project-packages.const.ts b/packages/registry/src/project/project-packages.const.ts new file mode 100644 index 00000000..43878205 --- /dev/null +++ b/packages/registry/src/project/project-packages.const.ts @@ -0,0 +1,5 @@ +/** The packages a project asks for, with version ranges. Written by hand or by tools. */ +export const PACKAGES_FILE = 'nanoforge.packages.json'; +/** What is installed: exact versions and hashes. Written by tools only. */ +export const LOCK_FILE = 'nanoforge.packages.lock.json'; +export const MODULES_DIR = 'nf_modules'; diff --git a/packages/registry/src/project/project-packages.ts b/packages/registry/src/project/project-packages.ts new file mode 100644 index 00000000..e8682755 --- /dev/null +++ b/packages/registry/src/project/project-packages.ts @@ -0,0 +1,389 @@ +import { existsSync } from 'node:fs'; +import { readFile, rename, rm, rmdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import semver from 'semver'; + +import { installItem } from '../install/install-item'; +import { ItemName, MANIFEST_FILE, type RegistryItem } from '../item/item.schema'; +import { RegistryError } from '../registry/registry.exception'; +import type { Registry } from '../registry/registry.type'; +import { withPathsEntry, withoutPathsEntry } from '../tsconfig-paths'; +import { installedNames, setAside, sweepAside, writeAtomically } from './modules-folder'; +import { type Lock, PackagesList, PackagesLock } from './packages-file.schema'; +import { LOCK_FILE, MODULES_DIR, PACKAGES_FILE } from './project-packages.const'; +import type { InstalledPackage, OutdatedPackage } from './project-packages.type'; + +const queues = new Map>(); + +/** + * The packages of a project: its list (`nanoforge.packages.json`), its lock, and the folders in + * `nf_modules`. One version of a package per project; dependencies are installed side by side. + * Changes of the same project run one after the other. + */ +export class ProjectPackages { + constructor( + readonly root: string, + private readonly _registry: Registry, + ) {} + + async list(): Promise { + const [wanted, lock] = await Promise.all([this._readList(), this._readLock()]); + return Object.entries(lock) + .map(([name, entry]) => ({ + name, + version: entry.version, + ...(wanted[name] !== undefined && { range: wanted[name] }), + dependents: Object.entries(lock) + .filter(([, other]) => name in other.dependencies) + .map(([other]) => other) + .sort(), + present: existsSync(join(this._folder(name), MANIFEST_FILE)), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + /** The installed packages with the newer versions the registry has. */ + async outdated(): Promise { + const lock = await this._readLock(); + return Promise.all( + (await this.list()).map(async (installed): Promise => { + const item = await this._registry.get(installed.name).catch((error: unknown) => { + if (error instanceof RegistryError && error.code === 'NOT_FOUND') return undefined; + throw error; + }); + if (!item) return installed; + const ranges = [ + ...(installed.range !== undefined ? [installed.range] : []), + ...installed.dependents.map((other) => lock[other]!.dependencies[installed.name]!), + ]; + const wanted = item.versions + .map((entry) => entry.version) + .filter((version) => ranges.every((range) => semver.satisfies(version, range))) + .sort(semver.rcompare)[0]; + const newer = (version: string | undefined) => + version && semver.gt(version, installed.version) ? version : undefined; + return { + ...installed, + ...(newer(wanted) && { wanted: newer(wanted)! }), + ...(newer(item.version) && { latest: newer(item.version)! }), + }; + }), + ); + } + + /** Adds a package to the project (`^` without a range) and installs what it needs. */ + install(name: string, range?: string): Promise { + return this._queue(async () => { + this._checkName(name); + const item = await this._registry.get(name); + if (item.type !== 'package') + throw new RegistryError('INVALID', `${name} is a ${item.type}, not a package`); + const wanted = await this._readList(); + wanted[name] = range ?? `^${item.version}`; + await this._apply(wanted, new Set([name])); + return this.list(); + }); + } + + /** + * Removes a package of the project. Refused while another installed package needs it; + * dependencies nobody needs any more go with it. + */ + uninstall(name: string): Promise { + return this._queue(async () => { + this._checkName(name); + const wanted = await this._readList(); + const lock = await this._readLock(); + const dependents = Object.entries(lock) + .filter(([other, entry]) => other !== name && name in entry.dependencies) + .map(([other]) => other); + if (dependents.length) { + throw new RegistryError( + 'IN_USE', + `${name} is needed by ${dependents.join(', ')}: uninstall ${dependents.length > 1 ? 'them' : 'it'} first`, + ); + } + if (!(name in wanted) && !(name in lock)) + throw new RegistryError('NOT_FOUND', `${name} is not installed`); + const remaining = Object.fromEntries( + Object.entries(wanted).filter(([other]) => other !== name), + ); + await this._apply(remaining, new Set()); + return this.list(); + }); + } + + /** Moves a package to the newest version in its range, or to the newest of all (`latest`). */ + update(name: string, latest = false): Promise { + return this._queue(async () => { + this._checkName(name); + const wanted = await this._readList(); + if (latest && name in wanted) { + const item = await this._registry.get(name); + wanted[name] = `^${item.version}`; + } + await this._apply(wanted, new Set([name])); + return this.list(); + }); + } + + /** + * Installs what the lock lists and `nf_modules` lacks or has in another version (a fresh + * clone, a teammate's bump), and removes what the lock no longer lists (a teammate's + * uninstall). + */ + restore(): Promise { + return this._queue(async () => { + const lock = await this._readLock(); + await sweepAside(join(this.root, MODULES_DIR)); + for (const name of await installedNames(join(this.root, MODULES_DIR))) { + if (!(name in lock)) await rm(this._folder(name), { recursive: true, force: true }); + } + for (const [name, entry] of Object.entries(lock)) { + if ((await this._installedVersion(name)) === entry.version) continue; + await installItem(this._registry, { + name, + version: entry.version, + type: 'package', + target: this._folder(name), + sha256: entry.sha256, + }); + } + await this._syncProjectFiles(lock); + return this.list(); + }); + } + + /** The version of the installed copy of a package, from its manifest; undefined if none. */ + private async _installedVersion(name: string): Promise { + const text = await readFile(join(this._folder(name), MANIFEST_FILE), 'utf8').catch( + () => undefined, + ); + try { + const version = (JSON.parse(text ?? 'null') as { version?: unknown } | null)?.version; + return typeof version === 'string' ? version : undefined; + } catch { + return undefined; + } + } + + private _folder(name: string): string { + return join(this.root, MODULES_DIR, ...name.split('/')); + } + + private _checkName(name: string): void { + if (!ItemName.safeParse(name).success) + throw new RegistryError('INVALID', `${name} is not a valid package name`); + } + + private _queue(run: () => Promise): Promise { + const previous = queues.get(this.root) ?? Promise.resolve(); + const next = previous.then(run, run); + queues.set( + this.root, + next.catch(() => undefined), + ); + return next; + } + + /** + * Resolves the wanted packages and their dependencies to one version each, installs what + * changed, removes what is no longer needed, and writes the list, the lock and the paths. + * Installed versions are kept when they still fit, except for the names in `refresh`. + */ + private async _apply( + wanted: Record, + refresh: ReadonlySet, + ): Promise { + const lock = await this._readLock(); + const items = new Map(); + const item = async (name: string) => { + let found = items.get(name); + if (!found) items.set(name, (found = await this._registry.get(name))); + return found; + }; + + let picks = new Map(); + for (let round = 0; ; round++) { + if (round > 50) + throw new RegistryError('CONFLICT', 'The dependencies of these packages never settle'); + const ranges = new Map(); + const ask = (name: string, range: string, by: string) => + ranges.set(name, [...(ranges.get(name) ?? []), { range, by }]); + for (const [name, range] of Object.entries(wanted)) ask(name, range, 'the project'); + for (const [name, version] of picks) { + const dependencies = + (await item(name)).versions.find((entry) => entry.version === version)?.dependencies ?? + {}; + for (const [dependency, range] of Object.entries(dependencies)) + ask(dependency, range, `${name}@${version}`); + } + const next = new Map(); + for (const [name, asked] of ranges) { + const found = await item(name); + if (found.type !== 'package') + throw new RegistryError('INVALID', `${name} is a ${found.type}, not a package`); + const fits = (version: string) => + asked.every(({ range }) => semver.satisfies(version, range)); + const locked = lock[name]?.version; + const version = + locked && !refresh.has(name) && fits(locked) + ? locked + : found.versions + .map((entry) => entry.version) + .filter(fits) + .sort(semver.rcompare)[0]; + if (!version) { + throw new RegistryError( + 'CONFLICT', + `No version of ${name} fits ${asked + .map(({ range, by }) => `${range} (asked by ${by})`) + .join(' and ')}`, + ); + } + next.set(name, version); + } + const settled = + next.size === picks.size && + [...next].every(([name, version]) => picks.get(name) === version); + picks = next; + if (settled) break; + } + + await sweepAside(join(this.root, MODULES_DIR)); + const nextLock: Lock = {}; + /** Folders replaced or removed, set aside until everything is written. */ + const aside: { folder: string; previous: string | undefined }[] = []; + const listText = await readFile(join(this.root, PACKAGES_FILE), 'utf8').catch(() => undefined); + const lockText = await readFile(join(this.root, LOCK_FILE), 'utf8').catch(() => undefined); + try { + for (const [name, version] of [...picks].sort(([a], [b]) => a.localeCompare(b))) { + const entry = (await item(name)).versions.find( + (candidate) => candidate.version === version, + )!; + const current = lock[name]; + if (current?.version === version && current.sha256 && current.sha256 !== entry.sha256) { + throw new RegistryError( + 'INTEGRITY', + `${name}@${version} is not what the lock recorded (another archive under the same version): refusing to install it`, + ); + } + const folder = this._folder(name); + const upToDate = + current?.version === version && + current.sha256 === entry.sha256 && + existsSync(join(folder, MANIFEST_FILE)); + if (!upToDate) { + aside.push({ folder, previous: existsSync(folder) ? await setAside(folder) : undefined }); + await installItem(this._registry, { + name, + version, + type: 'package', + target: folder, + sha256: entry.sha256, + }); + } + nextLock[name] = { version, sha256: entry.sha256, dependencies: entry.dependencies }; + } + for (const name of Object.keys(lock)) { + const folder = this._folder(name); + if (!picks.has(name) && existsSync(folder)) + aside.push({ folder, previous: await setAside(folder) }); + } + await this._writeJson(PACKAGES_FILE, { + ...(listText === undefined ? {} : await this._readRawList()), + packages: sorted(wanted), + }); + await this._writeJson(LOCK_FILE, { lockVersion: 1, packages: nextLock }); + } catch (error) { + for (const { folder, previous } of aside.reverse()) { + if (picks.has(this._nameOf(folder)) || !previous) + await rm(folder, { recursive: true, force: true }).catch(() => undefined); + if (previous) await rename(previous, folder).catch(() => undefined); + } + await this._restoreText(PACKAGES_FILE, listText); + await this._restoreText(LOCK_FILE, lockText); + throw error; + } + for (const { previous } of aside) { + if (previous) await rm(previous, { recursive: true, force: true }).catch(() => undefined); + } + for (const name of Object.keys(lock)) { + if (!picks.has(name)) + await rmdir(dirname(this._folder(name))) + .then(() => rmdir(join(this.root, MODULES_DIR))) + .catch(() => undefined); + } + await this._syncProjectFiles(nextLock, Object.keys(lock)); + } + + /** Import paths of the installed packages (root tsconfig). The project's .gitignore is its own. */ + private async _syncProjectFiles(lock: Lock, previous: readonly string[] = []): Promise { + const tsconfig = join(this.root, 'tsconfig.json'); + const original = await readFile(tsconfig, 'utf8').catch(() => undefined); + let text = original ?? '{}\n'; + for (const name of previous) { + if (!(name in lock)) text = withoutPathsEntry(text, `${name}/*`); + } + for (const name of Object.keys(lock)) + text = withPathsEntry(text, `${name}/*`, `./${MODULES_DIR}/${name}/*`); + if (text !== original && (original !== undefined || Object.keys(lock).length)) + await writeFile(tsconfig, text); + } + + private async _readRawList(): Promise> { + const text = await readFile(join(this.root, PACKAGES_FILE), 'utf8').catch(() => undefined); + if (text === undefined) return {}; + try { + return JSON.parse(text) as Record; + } catch { + throw new RegistryError('INVALID', `${PACKAGES_FILE} is not valid JSON`); + } + } + + private async _readList(): Promise> { + const parsed = PackagesList.safeParse(await this._readRawList()); + if (!parsed.success) + throw new RegistryError( + 'INVALID', + `${PACKAGES_FILE} is not valid: ${parsed.error.issues[0]?.message}`, + ); + return { ...parsed.data.packages }; + } + + private async _readLock(): Promise { + const text = await readFile(join(this.root, LOCK_FILE), 'utf8').catch(() => undefined); + if (text === undefined) return {}; + let json: unknown; + try { + json = JSON.parse(text); + } catch { + throw new RegistryError('INVALID', `${LOCK_FILE} is not valid JSON`); + } + const parsed = PackagesLock.safeParse(json); + if (!parsed.success) throw new RegistryError('INVALID', `${LOCK_FILE} is not valid`); + return parsed.data.packages; + } + + private _writeJson(file: string, value: unknown): Promise { + return writeAtomically(join(this.root, file), `${JSON.stringify(value, null, 2)}\n`); + } + + /** Puts back a file's previous text, or removes it when there was none. */ + private async _restoreText(file: string, text: string | undefined): Promise { + const path = join(this.root, file); + await (text === undefined ? rm(path, { force: true }) : writeFile(path, text)).catch( + () => undefined, + ); + } + + private _nameOf(folder: string): string { + return folder + .slice(join(this.root, MODULES_DIR).length + 1) + .split(/[\\/]/) + .join('/'); + } +} + +const sorted = (record: Record): Record => + Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b))); diff --git a/packages/registry/src/project/project-packages.type.ts b/packages/registry/src/project/project-packages.type.ts new file mode 100644 index 00000000..6da85ed8 --- /dev/null +++ b/packages/registry/src/project/project-packages.type.ts @@ -0,0 +1,17 @@ +export interface InstalledPackage { + readonly name: string; + readonly version: string; + /** The range the project asks for; undefined for a package only others need. */ + readonly range?: string; + /** Installed packages that need it. */ + readonly dependents: readonly string[]; + /** Whether its folder is in `nf_modules` (false after a fresh clone: `restore`). */ + readonly present: boolean; +} + +export interface OutdatedPackage extends InstalledPackage { + /** The newest version its range allows, when newer than the installed one. */ + readonly wanted?: string; + /** The newest version of all, when newer than the installed one. */ + readonly latest?: string; +} diff --git a/packages/registry/src/registry/index.ts b/packages/registry/src/registry/index.ts new file mode 100644 index 00000000..bc6b81a2 --- /dev/null +++ b/packages/registry/src/registry/index.ts @@ -0,0 +1,2 @@ +export * from './registry.type'; +export * from './registry.exception'; diff --git a/packages/registry/src/registry/registry.exception.ts b/packages/registry/src/registry/registry.exception.ts new file mode 100644 index 00000000..90df9e6c --- /dev/null +++ b/packages/registry/src/registry/registry.exception.ts @@ -0,0 +1,12 @@ +import type { RegistryErrorCode } from './registry.type'; + +/** A failure with a reason a caller can act on (`code`) and a message for people. */ +export class RegistryError extends Error { + constructor( + readonly code: RegistryErrorCode, + message: string, + ) { + super(message); + this.name = 'RegistryError'; + } +} diff --git a/packages/registry/src/registry/registry.type.ts b/packages/registry/src/registry/registry.type.ts new file mode 100644 index 00000000..83598eda --- /dev/null +++ b/packages/registry/src/registry/registry.type.ts @@ -0,0 +1,13 @@ +import { type RegistryItem, type SearchQuery, type SearchResult } from '../item/item.schema'; + +export type RegistryErrorCode = + 'NOT_FOUND' | 'UNAVAILABLE' | 'INVALID' | 'INTEGRITY' | 'CONFLICT' | 'IN_USE'; + +/** Where items come from: an HTTP registry, or a folder standing in for it. */ +export interface Registry { + search(query?: SearchQuery): Promise; + /** Rejects with `NOT_FOUND` for an unknown name. */ + get(name: string): Promise; + /** The archive of a version: a zip with `nanoforge.manifest.json` at its root. */ + download(name: string, version: string): Promise; +} diff --git a/packages/registry/src/serve/index.ts b/packages/registry/src/serve/index.ts new file mode 100644 index 00000000..d70c7032 --- /dev/null +++ b/packages/registry/src/serve/index.ts @@ -0,0 +1 @@ +export * from './serve-registry'; diff --git a/packages/registry/src/serve/serve-registry.ts b/packages/registry/src/serve/serve-registry.ts new file mode 100644 index 00000000..99795406 --- /dev/null +++ b/packages/registry/src/serve/serve-registry.ts @@ -0,0 +1,51 @@ +import { RegistryError } from '../registry/registry.exception'; +import type { Registry } from '../registry/registry.type'; +import { toPackument, toSearchObject } from '../wire/packument'; + +const json = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); + +/** + * The registry's read routes over any registry, as a `fetch` handler: a stand-in server for + * tests, and the reference implementation of the contract (`docs/api/registry.md`). + */ +export const serveRegistry = + (registry: Registry) => + async (input: string | URL | Request): Promise => { + const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url); + const path = decodeURIComponent(url.pathname); + try { + if (path === '/registry/-/v1/search') { + const type = url.searchParams.get('type'); + const size = Math.min(Math.max(Number(url.searchParams.get('size') ?? 30) || 30, 1), 100); + const from = Math.max(Number(url.searchParams.get('from') ?? 0) || 0, 0); + const found = await registry.search({ + ...(type === 'plugin' || type === 'package' ? { type } : {}), + q: url.searchParams.get('text') ?? '', + page: Math.floor(from / size) + 1, + limit: size, + }); + return json(200, { objects: found.items.map(toSearchObject), total: found.total }); + } + const archive = /^\/registry\/(@[^/]+\/([^/]+))\/-\/([^/]+)\.zip$/.exec(path); + if (archive) { + const [, name, short, file] = archive; + if (!file!.startsWith(`${short}-`)) return json(404, { error: 'No such file' }); + const bytes = await registry.download(name!, file!.slice(short!.length + 1)); + return new Response(bytes as BodyInit, { + status: 200, + headers: { 'content-type': 'application/zip' }, + }); + } + const item = /^\/registry\/(@[^/]+\/[^/]+)$/.exec(path); + if (item) return json(200, toPackument(await registry.get(item[1]!))); + return json(404, { error: 'No such route' }); + } catch (error) { + if (error instanceof RegistryError && error.code === 'NOT_FOUND') + return json(404, { error: error.message }); + return json(500, { error: error instanceof Error ? error.message : String(error) }); + } + }; diff --git a/packages/registry/src/tsconfig-paths.ts b/packages/registry/src/tsconfig-paths.ts new file mode 100644 index 00000000..54167932 --- /dev/null +++ b/packages/registry/src/tsconfig-paths.ts @@ -0,0 +1,96 @@ +/** Parses a JSON file that may have comments and trailing commas (tsconfig.json). */ +export const parseLooseJson = (text: string): Record => { + try { + return JSON.parse(text) as Record; + } catch { + const cleaned = text + .replace( + /("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, + (_match, string: string | undefined) => string ?? '', + ) + .replace(/,(\s*[}\]])/g, '$1'); + return JSON.parse(cleaned) as Record; + } +}; + +const pathsOf = (text: string): Record => + ((parseLooseJson(text).compilerOptions as { paths?: Record } | undefined) + ?.paths ?? {}) as Record; + +/** + * Adds a `compilerOptions.paths` entry to a tsconfig text, keeping its comments and formatting: + * inserted into the existing `paths` or `compilerOptions` when there is one. Falls back to + * rewriting the JSON when the text is too unusual to edit in place. + */ +export const withPathsEntry = (text: string, pattern: string, target: string): string => { + if (pathsOf(text)[pattern]?.[0] === target) return text; + if (pathsOf(text)[pattern]) text = withoutPathsEntry(text, pattern); + const entry = `${JSON.stringify(pattern)}: [${JSON.stringify(target)}]`; + const indentOf = (offset: number) => + /^[ \t]*/.exec(text.slice(text.lastIndexOf('\n', offset) + 1))![0]; + const insertAfterBrace = (match: RegExpExecArray, inner: string) => { + const brace = match.index + match[0].length; + const empty = /^\s*\}/.test(text.slice(brace)); + const indent = `${indentOf(match.index)} `; + return `${text.slice(0, brace)}\n${indent}${inner}${empty ? '\n' + indentOf(match.index) : ','}${text.slice(brace)}`; + }; + const candidates = [ + () => { + const match = /"paths"\s*:\s*\{/.exec(text); + return match ? insertAfterBrace(match, entry) : undefined; + }, + () => { + const match = /"compilerOptions"\s*:\s*\{/.exec(text); + return match ? insertAfterBrace(match, `"paths": { ${entry} }`) : undefined; + }, + () => { + const match = /^\s*\{/.exec(text); + return match + ? insertAfterBrace(match, `"compilerOptions": { "paths": { ${entry} } }`) + : undefined; + }, + ]; + for (const candidate of candidates) { + const edited = candidate(); + if (!edited) continue; + try { + if (pathsOf(edited)[pattern]?.[0] === target) return edited; + } catch {} + } + const config = parseLooseJson(text); + const options = (config.compilerOptions ?? {}) as Record; + const paths = (options.paths ?? {}) as Record; + config.compilerOptions = { ...options, paths: { ...paths, [pattern]: [target] } }; + return `${JSON.stringify(config, null, 2)}\n`; +}; + +/** Removes a `compilerOptions.paths` entry, keeping the rest of the text as it is. */ +export const withoutPathsEntry = (text: string, pattern: string): string => { + let paths: Record; + try { + paths = pathsOf(text); + } catch { + return text; + } + if (!(pattern in paths)) return text; + const key = JSON.stringify(pattern).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const entry = new RegExp(`[ \\t]*${key}\\s*:\\s*\\[[^\\]]*\\][ \\t]*(,?)[ \\t]*\\r?\\n?`); + const match = entry.exec(text); + if (match) { + let before = text.slice(0, match.index); + if (!match[1]) before = before.replace(/,(\s*)$/, '$1'); + const edited = before + text.slice(match.index + match[0].length); + try { + if (!(pattern in pathsOf(edited))) return edited; + } catch {} + } + const config = parseLooseJson(text); + const options = (config.compilerOptions ?? {}) as Record; + const rest = Object.fromEntries( + Object.entries((options.paths ?? {}) as Record).filter( + ([key]) => key !== pattern, + ), + ); + config.compilerOptions = { ...options, paths: rest }; + return `${JSON.stringify(config, null, 2)}\n`; +}; diff --git a/packages/registry/src/types.ts b/packages/registry/src/types.ts new file mode 100644 index 00000000..8f091346 --- /dev/null +++ b/packages/registry/src/types.ts @@ -0,0 +1,4 @@ +/** The registry's data types: no Node code, so the editor's page and protocol can import them. */ +export * from './item'; +export * from './manifest'; +export * from './registry'; diff --git a/packages/registry/src/wire/index.ts b/packages/registry/src/wire/index.ts new file mode 100644 index 00000000..e643a5a4 --- /dev/null +++ b/packages/registry/src/wire/index.ts @@ -0,0 +1,2 @@ +export * from './wire.schema'; +export * from './packument'; diff --git a/packages/registry/src/wire/packument.ts b/packages/registry/src/wire/packument.ts new file mode 100644 index 00000000..fbc12ffd --- /dev/null +++ b/packages/registry/src/wire/packument.ts @@ -0,0 +1,89 @@ +import { type z } from 'zod'; + +import type { RegistryItem, RegistrySummary } from '../item/item.schema'; +import { type Packument, ROUTES, type WireSearch } from './wire.schema'; + +/** The packument of an item, in the shape the API writes it. */ +export const toPackument = (item: RegistryItem): z.input => ({ + name: item.name, + type: item.type, + description: item.description, + ...(item.author && { author: { name: item.author } }), + maintainers: item.author ? [{ name: item.author }] : [], + readme: item.readme, + 'dist-tags': { latest: item.version }, + versions: Object.fromEntries( + item.versions.map((version) => [ + version.version, + { + name: item.name, + version: version.version, + type: item.type, + dependencies: version.dependencies, + engines: version.engines, + kinds: version.kinds, + suggestedPlugins: version.suggestedPlugins, + contents: version.contents, + manifests: version.manifests, + dist: { tarball: ROUTES.archive(item.name, version.version), sha256: version.sha256 }, + }, + ]), + ), + time: Object.fromEntries(item.versions.map((version) => [version.version, version.publishedAt])), + downloads: { total: item.downloads }, +}); + +export const fromPackument = (packument: Packument, compare: (a: string, b: string) => number) => { + const versions = Object.values(packument.versions) + .map((version) => ({ + version: version.version, + publishedAt: packument.time[version.version] ?? '', + sha256: version.dist.sha256, + dependencies: version.dependencies, + engines: version.engines, + kinds: version.kinds, + suggestedPlugins: version.suggestedPlugins, + contents: version.contents ?? [], + manifests: version.manifests ?? [], + })) + .sort((a, b) => compare(a.version, b.version)); + const latest = packument['dist-tags'].latest; + const item: RegistryItem = { + name: packument.name, + type: packument.type, + version: latest, + description: packument.description, + author: packument.author?.name || packument.maintainers[0]?.name || '', + downloads: packument.downloads?.total ?? 0, + updatedAt: packument.time[latest] ?? '', + kinds: packument.versions[latest]?.kinds ?? {}, + readme: packument.readme ?? '', + versions, + }; + return item; +}; + +/** A search result, in the shape the API writes it. */ +export const toSearchObject = (item: RegistrySummary): WireSearch['objects'][number] => ({ + package: { + name: item.name, + type: item.type, + version: item.version, + description: item.description, + date: item.updatedAt, + ...(item.author && { author: { name: item.author }, publisher: { username: item.author } }), + kinds: item.kinds, + }, + downloads: { total: item.downloads }, +}); + +export const fromSearchObject = (entry: WireSearch['objects'][number]): RegistrySummary => ({ + name: entry.package.name, + type: entry.package.type, + version: entry.package.version, + description: entry.package.description, + author: entry.package.author?.name || entry.package.publisher?.username || '', + downloads: entry.downloads?.total ?? 0, + updatedAt: entry.package.date, + kinds: entry.package.kinds, +}); diff --git a/packages/registry/src/wire/wire.schema.ts b/packages/registry/src/wire/wire.schema.ts new file mode 100644 index 00000000..dbf36d96 --- /dev/null +++ b/packages/registry/src/wire/wire.schema.ts @@ -0,0 +1,98 @@ +import { z } from 'zod'; + +import { + ItemName, + ItemType, + ItemVersion, + Kinds, + ManifestDependencies, + VersionContent, +} from '../item/item.schema'; + +/** + * What goes over HTTP: routes and documents after the npm registry's (a "packument" per item, + * archives under `/-/`, search under `/-/v1/search`), so people and tools that know npm find + * their way. Differences: every item has a `type`, archives are zip files, every version says + * what it holds (`contents`, counted in `kinds`), and `dist` adds a hex `sha256` to npm's + * hashes. The contract is `docs/api/registry.md`. + */ +export const ROUTES = { + search: '/registry/-/v1/search', + item: (name: string) => `/registry/${name}`, + /** `@scope/name` 1.2.3 → `/registry/@scope/name/-/name-1.2.3.zip`. */ + archive: (name: string, version: string) => + `/registry/${name}/-/${name.split('/')[1]}-${version}.zip`, +}; + +/** `{ name, email?, url? }`; npm also writes `"Name (url)"`. */ +const Person = z.union([ + z.object({ name: z.string().default('') }).loose(), + z.string().transform((text) => ({ name: text.replace(/\s*[<(].*$/, '') })), +]); + +export const WireVersion = z + .object({ + name: ItemName, + version: ItemVersion, + type: ItemType, + dependencies: ManifestDependencies.default({}), + engines: z.record(z.string(), z.string()).default({}), + kinds: Kinds.default({}), + suggestedPlugins: ManifestDependencies.default({}), + /** Absent from the abbreviated document (`application/vnd.npm.install-v1+json`). */ + contents: z.array(VersionContent).optional(), + manifests: z.array(z.string()).optional(), + dist: z + .object({ + /** URL of the archive, absolute or relative to the registry's base URL. */ + tarball: z.string(), + sha256: z.string().regex(/^[0-9a-f]{64}$/), + /** npm's Subresource Integrity string, `sha512-`. */ + integrity: z.string().optional(), + size: z.number().int().nonnegative().optional(), + }) + .loose(), + }) + .loose(); +export type WireVersion = z.output; + +export const Packument = z + .object({ + name: ItemName, + type: ItemType, + description: z.string().default(''), + author: Person.optional(), + maintainers: z.array(Person).default([]), + readme: z.string().nullable().default(''), + 'dist-tags': z.object({ latest: ItemVersion }).loose(), + versions: z.record(z.string(), WireVersion), + /** ISO dates by version (npm also keeps `created` and `modified` here). */ + time: z.record(z.string(), z.string()).default({}), + downloads: z.object({ total: z.number().int().nonnegative().default(0) }).optional(), + }) + .loose(); +export type Packument = z.output; + +export const WireSearch = z.object({ + objects: z.array( + z + .object({ + package: z + .object({ + name: ItemName, + type: ItemType, + version: ItemVersion, + description: z.string().default(''), + date: z.string().default(''), + author: Person.optional(), + publisher: z.object({ username: z.string() }).loose().optional(), + kinds: Kinds.default({}), + }) + .loose(), + downloads: z.object({ total: z.number().int().nonnegative().default(0) }).optional(), + }) + .loose(), + ), + total: z.number().int().nonnegative(), +}); +export type WireSearch = z.output; diff --git a/packages/registry/test/archive/archive.spec.ts b/packages/registry/test/archive/archive.spec.ts new file mode 100644 index 00000000..07ff3626 --- /dev/null +++ b/packages/registry/test/archive/archive.spec.ts @@ -0,0 +1,29 @@ +import { strToU8, zipSync } from 'fflate'; +import { describe, expect, it } from 'vitest'; + +import { readArchive } from '../../src/archive/archive'; + +const CENTRAL_ENTRY = 0x02014b50; + +/** A one-file stored zip whose directory claims other packed and unpacked sizes. */ +const lyingZip = (packed: number, unpacked: number): Uint8Array => { + const zip = zipSync({ 'a.txt': [strToU8('hello'), { level: 0 }] }); + const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength); + for (let offset = 0; offset < zip.length - 4; offset++) { + if (view.getUint32(offset, true) !== CENTRAL_ENTRY) continue; + view.setUint32(offset + 20, packed, true); + view.setUint32(offset + 24, unpacked, true); + } + return zip; +}; + +describe('readArchive', () => { + it('reads the files of an archive', () => { + const files = readArchive(zipSync({ 'a.txt': strToU8('hello') })); + expect([...files.keys()]).toEqual(['a.txt']); + }); + + it('counts what a stored entry allocates, whatever size it claims unpacked', () => { + expect(() => readArchive(lyingZip(300 * 1024 * 1024, 0))).toThrow(/too large/); + }); +}); diff --git a/packages/registry/test/contract/registry-contract.ts b/packages/registry/test/contract/registry-contract.ts new file mode 100644 index 00000000..9e113557 --- /dev/null +++ b/packages/registry/test/contract/registry-contract.ts @@ -0,0 +1,112 @@ +import { strFromU8, unzipSync } from 'fflate'; +import { describe, expect, it } from 'vitest'; + +import { sha256 } from '../../src'; + +/** + * What the editor and the CLI need from the registry (docs/api/registry.md), as scenarios that + * only speak HTTP and only read. They run against the reference implementation + * (`serveRegistry`) in this package's tests, and against a real registry when + * `CONTRACT_REGISTRY_URL` is set (see `registry.contract.test.ts`). + */ +export interface RegistryTarget { + readonly baseUrl: string; + readonly fetch: (url: string) => Promise; + /** A published item to read: `@scope/name`. */ + readonly item: string; +} + +export const registryContract = (target: () => RegistryTarget): void => { + const get = (path: string) => { + const { baseUrl, fetch } = target(); + return fetch(new URL(path, baseUrl).toString()); + }; + const json = async (path: string) => { + const response = await get(path); + return { status: response.status, body: (await response.json()) as Record }; + }; + const VERSION = { + name: expect.any(String) as string, + version: expect.stringMatching(/^\d+\.\d+\.\d+/) as string, + type: expect.stringMatching(/^(plugin|package)$/) as string, + kinds: expect.any(Object) as object, + dist: { + tarball: expect.any(String) as string, + sha256: expect.stringMatching(/^[0-9a-f]{64}$/) as string, + }, + }; + + describe('reading needs no key and no user', () => { + it('describes an item: its versions, where their archives are, the latest one', async () => { + const { item } = target(); + const { status, body } = await json(`/registry/${item}`); + expect(status).toBe(200); + expect(body).toMatchObject({ + name: item, + type: expect.stringMatching(/^(plugin|package)$/) as string, + 'dist-tags': { latest: expect.any(String) as string }, + versions: expect.any(Object) as object, + }); + const versions = body.versions as Record; + const latest = (body['dist-tags'] as { latest: string }).latest; + expect(versions[latest]).toMatchObject({ ...VERSION, name: item, version: latest }); + for (const [version, entry] of Object.entries(versions)) + expect(entry).toMatchObject({ ...VERSION, version }); + expect((await json(`/registry/${item.replace('/', '%2F')}`)).status).toBe(200); + }); + + it('answers 404 for an item that does not exist', async () => { + expect((await get('/registry/@contract-test/no-such-item')).status).toBe(404); + }); + + it('serves the archive: a zip with the hash the item gave and its manifest at the root', async () => { + const { item, baseUrl, fetch } = target(); + const { body } = await json(`/registry/${item}`); + const latest = (body['dist-tags'] as { latest: string }).latest; + const { dist } = ( + body.versions as Record + )[latest]!; + const response = await fetch(new URL(dist.tarball, baseUrl).toString()); + expect(response.status).toBe(200); + const bytes = new Uint8Array(await response.arrayBuffer()); + expect(sha256(bytes)).toBe(dist.sha256); + const manifest = unzipSync(bytes)['nanoforge.manifest.json']; + expect(manifest).toBeDefined(); + expect(JSON.parse(strFromU8(manifest!))).toMatchObject({ name: item, version: latest }); + }); + + it('searches by text and type, in pages', async () => { + const { item } = target(); + const short = item.split('/')[1]!; + const { status, body } = await json( + `/registry/-/v1/search?text=${encodeURIComponent(short)}&size=50`, + ); + expect(status).toBe(200); + expect(body).toMatchObject({ total: expect.any(Number) as number }); + const objects = body.objects as { package: { name: string; type: string } }[]; + const found = objects.find((entry) => entry.package.name === item); + expect(found).toMatchObject({ + package: { + name: item, + version: expect.any(String) as string, + type: expect.stringMatching(/^(plugin|package)$/) as string, + }, + }); + + const other = found!.package.type === 'plugin' ? 'package' : 'plugin'; + const filtered = await json( + `/registry/-/v1/search?text=${encodeURIComponent(short)}&type=${other}&size=50`, + ); + expect( + (filtered.body.objects as { package: { type: string } }[]).every( + (entry) => entry.package.type === other, + ), + ).toBe(true); + + const page = await json('/registry/-/v1/search?text=&size=1&from=0'); + expect((page.body.objects as unknown[]).length).toBeLessThanOrEqual(1); + const none = await json('/registry/-/v1/search?text=zz-no-such-thing-zz'); + expect(none.body).toMatchObject({ objects: [], total: 0 }); + }); + }); +}; diff --git a/packages/registry/test/contract/registry.contract.spec.ts b/packages/registry/test/contract/registry.contract.spec.ts new file mode 100644 index 00000000..709dc4e7 --- /dev/null +++ b/packages/registry/test/contract/registry.contract.spec.ts @@ -0,0 +1,51 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe } from 'vitest'; + +import { DirectoryRegistry, serveRegistry } from '../../src'; +import { type RegistryTarget, registryContract } from './registry-contract'; + +/** + * Runs the registry contract. By default against `serveRegistry` over a folder, the reference + * implementation. Against a real registry (read-only) with: + * + * CONTRACT_REGISTRY_URL=https://api.staging.nanoforge.eu \ + * CONTRACT_REGISTRY_ITEM=@scope/name \ + * pnpm --filter @nanoforge-dev/registry test:contract + */ +const env = process.env; +let folder: string | undefined; +let reference: RegistryTarget | undefined; + +const local = (): RegistryTarget => { + if (reference) return reference; + folder = mkdtempSync(join(tmpdir(), 'nf-registry-contract-')); + for (const version of ['1.0.0', '1.1.0']) { + const dir = join(folder, '@contract/shapes', version); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'nanoforge.manifest.json'), + JSON.stringify({ type: 'package', name: '@contract/shapes', version, description: 'Shapes' }), + ); + writeFileSync(join(dir, 'circle.ts'), 'export class Circle {}\n'); + } + const serve = serveRegistry(new DirectoryRegistry(folder)); + return (reference = { baseUrl: 'https://registry.test', fetch: serve, item: '@contract/shapes' }); +}; + +afterAll(() => folder && rmSync(folder, { recursive: true, force: true })); + +describe( + env.CONTRACT_REGISTRY_URL ? `registry at ${env.CONTRACT_REGISTRY_URL}` : 'registry (reference)', + () => + registryContract( + env.CONTRACT_REGISTRY_URL + ? () => ({ + baseUrl: env.CONTRACT_REGISTRY_URL!, + fetch: (url) => fetch(url), + item: env.CONTRACT_REGISTRY_ITEM ?? '', + }) + : local, + ), +); diff --git a/packages/registry/test/manifest/package-contents.spec.ts b/packages/registry/test/manifest/package-contents.spec.ts new file mode 100644 index 00000000..7defa7d1 --- /dev/null +++ b/packages/registry/test/manifest/package-contents.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { countKinds, readPackageContents } from '../../src'; + +/** A package as files: path → JSON. */ +const reader = (files: Record) => async (path: string) => files[path]; + +describe('readPackageContents', () => { + it('joins the contents of the sub-manifests, with paths from the package root', async () => { + const found = await readPackageContents( + { + name: '@acme/kit', + contents: [{ kind: 'asset', path: 'assets/' }], + include: ['src/player', 'data/assets.json'], + }, + reader({ + 'src/player/nanoforge.manifest.json': { + contents: [ + { + kind: '@nanoforge/ecs:component', + path: 'velocity.ts', + export: 'Velocity', + '@nanoforge/ecs': { type: 'component' }, + }, + ], + include: ['ai/'], + }, + 'src/player/ai/nanoforge.manifest.json': { + contents: [{ kind: '@nanoforge/ecs:system', path: 'chase.ts' }], + }, + 'data/assets.json': { contents: [{ kind: 'asset', path: 'music.ogg' }] }, + }), + ); + expect(found.problems).toEqual([]); + expect(found.manifests).toEqual([ + 'src/player/nanoforge.manifest.json', + 'data/assets.json', + 'src/player/ai/nanoforge.manifest.json', + ]); + expect(found.contents).toEqual([ + { kind: 'asset', path: 'assets/' }, + { + kind: '@nanoforge/ecs:component', + path: 'src/player/velocity.ts', + export: 'Velocity', + '@nanoforge/ecs': { type: 'component' }, + manifest: 'src/player/nanoforge.manifest.json', + }, + { kind: 'asset', path: 'data/music.ogg', manifest: 'data/assets.json' }, + { + kind: '@nanoforge/ecs:system', + path: 'src/player/ai/chase.ts', + manifest: 'src/player/ai/nanoforge.manifest.json', + }, + ]); + expect(countKinds(found.contents)).toEqual({ + asset: 2, + '@nanoforge/ecs:component': 1, + '@nanoforge/ecs:system': 1, + }); + }); + + it('skips what the registry refuses, and says why', async () => { + const found = await readPackageContents( + { + contents: [ + { kind: 'Bad Kind', path: 'a.ts' }, + { kind: 'file', path: '../outside.ts' }, + { kind: 'file', path: 'ok.ts' }, + ], + include: ['../up', 'missing', 'self.json', 'self.json'], + }, + reader({ 'self.json': { include: ['nanoforge.manifest.json'] } }), + ); + expect(found.contents).toEqual([{ kind: 'file', path: 'ok.ts' }]); + expect(found.manifests).toEqual(['self.json']); + expect(found.problems).toEqual([ + expect.stringContaining('contents[0].kind'), + expect.stringContaining('contents[1].path'), + expect.stringContaining('include[0]'), + expect.stringContaining('self.json is included twice'), + 'missing/nanoforge.manifest.json: missing, or not a JSON object', + expect.stringContaining('nanoforge.manifest.json is included twice'), + ]); + }); + + it('stops past four levels', async () => { + const files: Record = {}; + let dir = ''; + for (let depth = 1; depth <= 5; depth++) { + dir += `l${depth}/`; + files[`${dir}nanoforge.manifest.json`] = { + contents: [{ kind: 'file', path: 'x.ts' }], + include: [`l${depth + 1}`], + }; + } + const found = await readPackageContents({ include: ['l1'] }, reader(files)); + expect(found.manifests).toHaveLength(4); + expect(found.problems).toEqual(['sub-manifests nest at most 4 levels deep']); + }); +}); diff --git a/packages/registry/test/registry.spec.ts b/packages/registry/test/registry.spec.ts new file mode 100644 index 00000000..c02bc869 --- /dev/null +++ b/packages/registry/test/registry.spec.ts @@ -0,0 +1,749 @@ +import { zipSync } from 'fflate'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + DirectoryRegistry, + ItemVersion, + LOCK_FILE, + PACKAGES_FILE, + ProjectPackages, + type Registry, + RegistryClient, + RegistryError, + installItem, + pickVersion, + serveRegistry, + sha256, + withPathsEntry, + withoutPathsEntry, +} from '../src'; + +let workspace: string; +let registryDir: string; +let registry: DirectoryRegistry; + +/** Publishes a version in the folder registry. */ +const publish = ( + name: string, + version: string, + options: { + type?: 'package' | 'plugin'; + dependencies?: Record; + engines?: Record; + description?: string; + files?: Record; + /** More fields of the manifest. */ + manifest?: Record; + } = {}, +) => { + const folder = join(registryDir, name, version); + mkdirSync(folder, { recursive: true }); + writeFileSync( + join(folder, 'nanoforge.manifest.json'), + JSON.stringify({ + type: options.type ?? 'package', + name, + version, + description: options.description ?? `${name} for tests`, + ...(options.dependencies && { dependencies: options.dependencies }), + ...(options.engines && { engines: options.engines }), + ...options.manifest, + }), + ); + for (const [path, text] of Object.entries(options.files ?? { 'index.ts': `// ${version}\n` })) { + mkdirSync(join(folder, path, '..'), { recursive: true }); + writeFileSync(join(folder, path), text); + } +}; + +const project = (name: string, tsconfig?: string) => { + const root = join(workspace, name); + mkdirSync(root, { recursive: true }); + if (tsconfig !== undefined) writeFileSync(join(root, 'tsconfig.json'), tsconfig); + return root; +}; +const read = (root: string, file: string) => readFileSync(join(root, file), 'utf8'); +const json = (root: string, file: string) => JSON.parse(read(root, file)) as Record; + +beforeAll(() => { + workspace = mkdtempSync(join(tmpdir(), 'nf-registry-')); + registryDir = join(workspace, 'registry'); + publish('@acme/shapes', '1.0.0'); + publish('@acme/shapes', '1.1.0', { + files: { + 'components/circle.ts': 'export class Circle {}\n', + 'components/nanoforge.manifest.json': JSON.stringify({ + contents: [{ kind: '@nanoforge/ecs:component', path: 'circle.ts', export: 'Circle' }], + }), + 'assets/logo.png': 'png', + }, + manifest: { + include: ['components'], + contents: [{ kind: 'asset', path: 'assets/' }], + suggestedPlugins: ['@nanoforge/ecs'], + }, + }); + publish('@acme/shapes', '2.0.0'); + publish('@acme/render', '1.0.0', { + dependencies: { '@acme/shapes': '^1.0.0' }, + description: 'Draws shapes', + }); + publish('@acme/physics', '1.0.0', { dependencies: { '@acme/shapes': '^2.0.0' } }); + publish('@acme/tools', '1.0.0', { type: 'plugin', engines: { editor: '>=0.1.0' } }); + publish('@acme/tools', '2.0.0', { type: 'plugin', engines: { editor: '>=9.0.0' } }); + writeFileSync( + join(registryDir, '@acme/render/registry.json'), + JSON.stringify({ author: 'Acme', downloads: 42 }), + ); + writeFileSync(join(registryDir, '@acme/render/1.0.0/README.md'), '# Render\n'); + mkdirSync(join(registryDir, '@acme/shapes/notes'), { recursive: true }); + registry = new DirectoryRegistry(registryDir); +}); +afterAll(() => rmSync(workspace, { recursive: true, force: true })); + +describe('registries', () => { + const sources: Record Registry> = { + 'a folder': () => registry, + http: () => + new RegistryClient({ + baseUrl: 'https://registry.test', + fetch: serveRegistry(registry) as typeof fetch, + }), + }; + + it.each(Object.keys(sources))('searches, describes and downloads from %s', async (label) => { + const source = sources[label]!(); + const all = await source.search(); + expect(all.total).toBe(4); + expect(all.items.map((item) => item.name)).toEqual([ + '@acme/render', + '@acme/physics', + '@acme/shapes', + '@acme/tools', + ]); + expect((await source.search({ type: 'plugin' })).items.map((item) => item.name)).toEqual([ + '@acme/tools', + ]); + expect((await source.search({ q: 'draws SHAPES' })).items.map((item) => item.name)).toEqual([ + '@acme/render', + ]); + expect((await source.search({ limit: 1, page: 2 })).items.map((item) => item.name)).toEqual([ + '@acme/physics', + ]); + + const render = await source.get('@acme/render'); + expect(render).toMatchObject({ + type: 'package', + version: '1.0.0', + author: 'Acme', + downloads: 42, + readme: '# Render\n', + }); + expect(render.versions[0]!.dependencies).toEqual({ '@acme/shapes': '^1.0.0' }); + const shapes = await source.get('@acme/shapes'); + expect(shapes.versions.map((entry) => entry.version)).toEqual(['2.0.0', '1.1.0', '1.0.0']); + + expect(shapes.versions[1]).toMatchObject({ + kinds: { asset: 1, '@nanoforge/ecs:component': 1 }, + suggestedPlugins: { '@nanoforge/ecs': '*' }, + }); + + const archive = await source.download('@acme/shapes', '1.1.0'); + expect(sha256(archive)).toBe(shapes.versions[1]!.sha256); + await expect(source.get('@acme/none')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect(source.download('@acme/shapes', '9.9.9')).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('speaks the npm registry layout over http', async () => { + const serve = serveRegistry(registry); + const get = async (path: string) => { + const response = await serve(`https://registry.test${path}`); + return { status: response.status, body: (await response.json()) as Record }; + }; + const packument = await get('/registry/@acme%2Frender'); + expect(packument.body).toMatchObject({ + name: '@acme/render', + type: 'package', + 'dist-tags': { latest: '1.0.0' }, + author: { name: 'Acme' }, + versions: { + '1.0.0': { + dependencies: { '@acme/shapes': '^1.0.0' }, + dist: { tarball: '/registry/@acme/render/-/render-1.0.0.zip' }, + }, + }, + }); + const shapes = await get('/registry/@acme%2Fshapes'); + expect(shapes.body).toMatchObject({ + versions: { + '1.1.0': { + contents: [ + { kind: 'asset', path: 'assets/' }, + { + kind: '@nanoforge/ecs:component', + path: 'components/circle.ts', + export: 'Circle', + manifest: 'components/nanoforge.manifest.json', + }, + ], + manifests: ['components/nanoforge.manifest.json'], + }, + }, + }); + expect( + (await registry.get('@acme/shapes')).versions.find((v) => v.version === '1.1.0'), + ).toMatchObject({ + manifests: ['components/nanoforge.manifest.json'], + }); + const search = await get('/registry/-/v1/search?text=draws&size=5'); + expect(search.body).toMatchObject({ + total: 1, + objects: [{ package: { name: '@acme/render', version: '1.0.0' }, downloads: { total: 42 } }], + }); + expect((await get('/registry/@acme/none')).status).toBe(404); + expect( + (await serve('https://registry.test/registry/@acme/render/-/other-1.0.0.zip')).status, + ).toBe(404); + }); + + it('takes a registry without the search route as not available', async () => { + const old = new RegistryClient({ + baseUrl: 'https://registry.test', + fetch: (async () => new Response('{}', { status: 404 })) as typeof fetch, + }); + await expect(old.search()).rejects.toMatchObject({ code: 'UNAVAILABLE' }); + await expect(old.get('@acme/render')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('says so when the registry cannot be reached or answers nonsense', async () => { + const down = new RegistryClient({ + baseUrl: 'https://registry.test', + fetch: (async () => { + throw new Error('offline'); + }) as typeof fetch, + }); + await expect(down.search()).rejects.toMatchObject({ code: 'UNAVAILABLE' }); + const odd = new RegistryClient({ + baseUrl: 'https://registry.test', + fetch: (async () => new Response('{"objects":"no"}', { status: 200 })) as typeof fetch, + }); + await expect(odd.search()).rejects.toMatchObject({ code: 'UNAVAILABLE' }); + }); + + it('follows versions published in the folder', async () => { + publish('@acme/late', '1.0.0'); + expect((await registry.get('@acme/late')).version).toBe('1.0.0'); + publish('@acme/late', '1.1.0'); + expect((await registry.get('@acme/late')).version).toBe('1.1.0'); + rmSync(join(registryDir, '@acme/late'), { recursive: true }); + await expect(registry.get('@acme/late')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('picks the newest version that fits a range and this editor', async () => { + const shapes = await registry.get('@acme/shapes'); + expect(pickVersion(shapes)?.version).toBe('2.0.0'); + expect(pickVersion(shapes, '^1.0.0')?.version).toBe('1.1.0'); + expect(pickVersion(shapes, '^3.0.0')).toBeUndefined(); + const tools = await registry.get('@acme/tools'); + expect(pickVersion(tools, '*', { editor: '0.1.0' })?.version).toBe('1.0.0'); + expect(pickVersion(tools, '*', { editor: '9.1.0' })?.version).toBe('2.0.0'); + }); + + it('reads the documents the NanoForge API writes', async () => { + const manifest = { + name: '@acme/kit', + version: '1.2.0', + type: 'package', + dependencies: ['@acme/shapes'], + include: ['src'], + }; + const archive = zipSync({ + 'nanoforge.manifest.json': new TextEncoder().encode(JSON.stringify(manifest)), + 'src/nanoforge.manifest.json': new TextEncoder().encode('{"contents":[]}'), + }); + const tarball = 'https://api.test/registry/@acme/kit/-/kit-1.2.0.zip'; + const version = { + ...manifest, + dependencies: { '@acme/shapes': '*' }, + contents: [ + { + kind: '@nanoforge/ecs:component', + path: 'src/velocity.ts', + export: 'Velocity', + manifest: 'src/nanoforge.manifest.json', + fileCount: 1, + size: 120, + }, + ], + manifests: ['src/nanoforge.manifest.json'], + kinds: { '@nanoforge/ecs:component': 1 }, + suggestedPlugins: { '@nanoforge/ecs': '^1.0.0' }, + _id: '@acme/kit@1.2.0', + _npmUser: { name: 'acme' }, + dist: { + tarball, + integrity: 'sha512-AAAA', + shasum: '0'.repeat(40), + sha256: sha256(archive), + size: archive.length, + unpackedSize: 200, + fileCount: 2, + }, + }; + const accepts: (string | null)[] = []; + const api = new RegistryClient({ + baseUrl: 'https://api.test', + fetch: (async (input: string, init?: RequestInit) => { + const url = new URL(input); + accepts.push(new Headers(init?.headers).get('accept')); + if (url.pathname === '/registry/-/v1/search') + return Response.json({ + objects: [ + { + package: { + name: '@acme/kit', + scope: 'acme', + type: 'package', + version: '1.2.0', + keywords: [], + date: '2026-10-10T09:00:00.000Z', + publisher: { username: 'acme' }, + kinds: version.kinds, + suggestedPlugins: version.suggestedPlugins, + }, + downloads: { total: 3 }, + score: { final: 1 }, + searchScore: 1, + }, + ], + total: 1, + time: 'Sat, 10 Oct 2026 09:00:00 GMT', + }); + if (url.pathname === '/registry/@acme/kit') + return Response.json({ + _id: '@acme/kit', + name: '@acme/kit', + type: 'package', + keywords: [], + readme: '', + maintainers: [{ name: 'acme' }], + 'dist-tags': { latest: '1.2.0' }, + versions: { '1.2.0': version }, + time: { created: '…', modified: '…', '1.2.0': '2026-10-10T09:00:00.000Z' }, + downloads: { total: 3 }, + }); + if (url.toString() === tarball) return new Response(archive as BodyInit); + return Response.json({ error: 'No such route' }, { status: 404 }); + }) as typeof fetch, + }); + + expect((await api.search()).items).toEqual([ + expect.objectContaining({ + name: '@acme/kit', + author: 'acme', + kinds: { '@nanoforge/ecs:component': 1 }, + }), + ]); + const kit = await api.get('@acme/kit'); + expect(kit).toMatchObject({ author: 'acme', kinds: { '@nanoforge/ecs:component': 1 } }); + expect(kit.versions[0]).toMatchObject({ + dependencies: { '@acme/shapes': '*' }, + suggestedPlugins: { '@nanoforge/ecs': '^1.0.0' }, + }); + + accepts.length = 0; + const target = join(workspace, 'api-kit'); + const installed = await installItem(api, { + name: '@acme/kit', + version: '1.2.0', + type: 'package', + target, + sha256: kit.versions[0]!.sha256, + }); + expect(installed.dependencies).toEqual({ '@acme/shapes': '*' }); + expect(existsSync(join(target, 'src/nanoforge.manifest.json'))).toBe(true); + expect(accepts[0]).toContain('application/vnd.npm.install-v1+json'); + }); + + it('keeps its headers off an address a redirect leads to on another origin', async () => { + const seen: { url: string; authorization: string | null }[] = []; + const api = new RegistryClient({ + baseUrl: 'https://api.test', + headers: () => ({ authorization: 'Bearer secret' }), + fetch: (async (input: string, init?: RequestInit) => { + seen.push({ url: input, authorization: new Headers(init?.headers).get('authorization') }); + expect(init?.redirect).toBe('manual'); + const url = new URL(input); + if (url.origin === 'https://api.test' && url.pathname === '/registry/-/v1/search') + return new Response(null, { + status: 302, + headers: { location: 'https://elsewhere.test/search' }, + }); + if (url.origin === 'https://elsewhere.test') + return new Response(null, { status: 307, headers: { location: '/again' } }); + return Response.json({ error: 'No such route' }, { status: 404 }); + }) as typeof fetch, + }); + await expect(api.search()).rejects.toThrow(RegistryError); + expect(seen).toEqual([ + { + url: 'https://api.test/registry/-/v1/search?text=&from=0&size=30', + authorization: 'Bearer secret', + }, + { url: 'https://elsewhere.test/search', authorization: null }, + ...Array.from({ length: 4 }, () => ({ + url: 'https://elsewhere.test/again', + authorization: null, + })), + ]); + }); +}); + +describe('installItem', () => { + it('writes the folder, and replaces it', async () => { + const target = join(workspace, 'plugins/@acme/tools'); + await installItem(registry, { name: '@acme/tools', version: '1.0.0', type: 'plugin', target }); + expect(json(target, 'nanoforge.manifest.json')).toMatchObject({ version: '1.0.0' }); + writeFileSync(join(target, 'leftover.txt'), 'old'); + await installItem(registry, { name: '@acme/tools', version: '2.0.0', type: 'plugin', target }); + expect(json(target, 'nanoforge.manifest.json')).toMatchObject({ version: '2.0.0' }); + expect(existsSync(join(target, 'leftover.txt'))).toBe(false); + }); + + it('writes nothing for a wrong hash, a wrong type, a wrong manifest or an unsafe archive', async () => { + const target = join(workspace, 'refused/@acme/shapes'); + const request = { name: '@acme/shapes', version: '1.0.0', type: 'package', target } as const; + await expect( + installItem(registry, { ...request, sha256: '0'.repeat(64) }), + ).rejects.toMatchObject({ code: 'INTEGRITY' }); + await expect(installItem(registry, { ...request, type: 'plugin' })).rejects.toMatchObject({ + code: 'INVALID', + }); + await expect(installItem(registry, { ...request, name: '../evil' })).rejects.toMatchObject({ + code: 'INVALID', + }); + const manifest = new TextEncoder().encode( + JSON.stringify({ type: 'package', name: '@acme/shapes', version: '1.0.0' }), + ); + const serving = (files: Record): Registry => ({ + ...registry, + search: () => registry.search(), + get: (name) => registry.get(name), + download: async () => zipSync(files), + }); + for (const path of ['../outside.txt', '/etc/passwd', 'a/../../b.txt', 'C:\\x.txt']) { + await expect( + installItem(serving({ 'nanoforge.manifest.json': manifest, [path]: manifest }), request), + ).rejects.toMatchObject({ code: 'INVALID' }); + } + await expect(installItem(serving({ 'other.json': manifest }), request)).rejects.toThrow( + /no nanoforge.manifest.json/, + ); + await expect( + installItem( + { + ...serving({}), + download: async () => new TextEncoder().encode('not a zip'), + }, + request, + ), + ).rejects.toThrow(/not a zip/); + expect(existsSync(join(workspace, 'refused'))).toBe(false); + expect(existsSync(join(workspace, 'outside.txt'))).toBe(false); + }); + + it('refuses archives that claim to unpack to too much, before unpacking them', async () => { + const target = join(workspace, 'bomb/@acme/shapes'); + const request = { name: '@acme/shapes', version: '1.0.0', type: 'package', target } as const; + const zip = zipSync({ + 'nanoforge.manifest.json': new Uint8Array(16), + 'big.bin': new Uint8Array(64), + }); + const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength); + for (let offset = 0; offset < zip.length - 4; offset++) { + const signature = view.getUint32(offset, true); + if (signature === 0x04034b50) view.setUint32(offset + 22, 0x7fffffff, true); + if (signature === 0x02014b50) view.setUint32(offset + 24, 0x7fffffff, true); + } + await expect( + installItem( + { + search: () => registry.search(), + get: (name) => registry.get(name), + download: async () => zip, + }, + request, + ), + ).rejects.toThrow(/too large/); + expect(existsSync(target)).toBe(false); + }); + + it('keeps the installed copy when a reinstall fails', async () => { + const target = join(workspace, 'kept/@acme/shapes'); + const request = { name: '@acme/shapes', version: '1.0.0', type: 'package', target } as const; + await installItem(registry, request); + const blocked = new Uint8Array(4); + await expect( + installItem( + { + search: () => registry.search(), + get: (name) => registry.get(name), + download: async () => + zipSync({ + 'nanoforge.manifest.json': new TextEncoder().encode( + JSON.stringify({ type: 'package', name: '@acme/shapes', version: '1.0.0' }), + ), + 'index.ts': blocked, + 'index.ts/inside.ts': blocked, + }), + }, + request, + ), + ).rejects.toThrow(); + expect(read(target, 'index.ts')).toBe('// 1.0.0\n'); + }); +}); + +describe('ProjectPackages', () => { + it('installs a package with what it needs, side by side, and keeps the list and the lock', async () => { + const root = project( + 'game', + '{\n // paths of the project\n "compilerOptions": {\n "strict": true\n }\n}\n', + ); + writeFileSync(join(root, '.gitignore'), 'node_modules\n'); + const packages = new ProjectPackages(root, registry); + const installed = await packages.install('@acme/render'); + expect(installed).toEqual([ + { name: '@acme/render', version: '1.0.0', range: '^1.0.0', dependents: [], present: true }, + { name: '@acme/shapes', version: '1.1.0', dependents: ['@acme/render'], present: true }, + ]); + expect(existsSync(join(root, 'nf_modules/@acme/shapes/components/circle.ts'))).toBe(true); + expect(json(root, PACKAGES_FILE)).toEqual({ packages: { '@acme/render': '^1.0.0' } }); + expect(json(root, LOCK_FILE)).toMatchObject({ + lockVersion: 1, + packages: { + '@acme/render': { version: '1.0.0', dependencies: { '@acme/shapes': '^1.0.0' } }, + '@acme/shapes': { version: '1.1.0' }, + }, + }); + const tsconfig = read(root, 'tsconfig.json'); + expect(tsconfig).toContain('// paths of the project'); + expect(tsconfig).toContain('"@acme/render/*": ["./nf_modules/@acme/render/*"]'); + expect(tsconfig).toContain('"@acme/shapes/*": ["./nf_modules/@acme/shapes/*"]'); + expect(read(root, '.gitignore')).toBe('node_modules\n'); + + await expect(packages.uninstall('@acme/shapes')).rejects.toMatchObject({ code: 'IN_USE' }); + expect(await packages.uninstall('@acme/render')).toEqual([]); + expect(existsSync(join(root, 'nf_modules/@acme/render'))).toBe(false); + expect(existsSync(join(root, 'nf_modules/@acme/shapes'))).toBe(false); + expect(read(root, 'tsconfig.json')).not.toContain('@acme'); + expect(read(root, 'tsconfig.json')).toContain('// paths of the project'); + expect(read(root, '.gitignore')).toBe('node_modules\n'); + await expect(packages.uninstall('@acme/render')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('refuses versions that cannot live together, plugins, and unknown names', async () => { + const root = project('conflict'); + const packages = new ProjectPackages(root, registry); + await packages.install('@acme/render'); + await expect(packages.install('@acme/physics')).rejects.toThrow( + /No version of @acme\/shapes fits .*\^1\.0\.0 \(asked by @acme\/render@1\.0\.0\).*\^2\.0\.0/, + ); + expect((await packages.list()).map((entry) => entry.name)).toEqual([ + '@acme/render', + '@acme/shapes', + ]); + expect(json(root, PACKAGES_FILE)).toEqual({ packages: { '@acme/render': '^1.0.0' } }); + await expect(packages.install('@acme/tools')).rejects.toThrow(/is a plugin, not a package/); + await expect(packages.install('@acme/none')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect(packages.install('../../etc')).rejects.toMatchObject({ code: 'INVALID' }); + }); + + it('keeps installed versions until asked to update, in the range or to the latest', async () => { + const root = project('update'); + const packages = new ProjectPackages(root, registry); + await packages.install('@acme/shapes', '~1.0.0'); + expect(await packages.outdated()).toEqual([ + { + name: '@acme/shapes', + version: '1.0.0', + range: '~1.0.0', + dependents: [], + present: true, + latest: '2.0.0', + }, + ]); + writeFileSync( + join(root, PACKAGES_FILE), + JSON.stringify({ packages: { '@acme/shapes': '^1.0.0' } }), + ); + expect((await packages.outdated())[0]).toMatchObject({ wanted: '1.1.0', latest: '2.0.0' }); + expect((await packages.list())[0]!.version).toBe('1.0.0'); + expect((await packages.update('@acme/shapes'))[0]).toMatchObject({ version: '1.1.0' }); + expect((await packages.update('@acme/shapes', true))[0]).toMatchObject({ + version: '2.0.0', + range: '^2.0.0', + }); + expect(json(root, 'nf_modules/@acme/shapes/nanoforge.manifest.json')).toMatchObject({ + version: '2.0.0', + }); + }); + + it('offers a dependency only the versions its dependents accept', async () => { + const root = project('dependency'); + const packages = new ProjectPackages(root, registry); + await packages.install('@acme/shapes', '1.0.0'); + await packages.install('@acme/render'); + writeFileSync(join(root, PACKAGES_FILE), JSON.stringify({ packages: { '@acme/render': '*' } })); + const shapes = (await packages.outdated()).find((entry) => entry.name === '@acme/shapes')!; + expect(shapes).toMatchObject({ version: '1.0.0', wanted: '1.1.0', latest: '2.0.0' }); + const updated = await packages.update('@acme/shapes'); + expect(updated.find((entry) => entry.name === '@acme/shapes')).toMatchObject({ + version: '1.1.0', + dependents: ['@acme/render'], + }); + }); + + it('restores nf_modules from the lock, checking the hashes', async () => { + const root = project('restore'); + const packages = new ProjectPackages(root, registry); + await packages.install('@acme/render'); + rmSync(join(root, 'nf_modules'), { recursive: true }); + expect((await packages.list()).map((entry) => entry.present)).toEqual([false, false]); + expect((await packages.restore()).map((entry) => entry.present)).toEqual([true, true]); + expect(existsSync(join(root, 'nf_modules/@acme/shapes/components/circle.ts'))).toBe(true); + + rmSync(join(root, 'nf_modules'), { recursive: true }); + const lock = json(root, LOCK_FILE) as unknown as { + packages: Record; + }; + lock.packages['@acme/shapes']!.sha256 = 'f'.repeat(64); + writeFileSync(join(root, LOCK_FILE), JSON.stringify(lock)); + await expect(packages.restore()).rejects.toBeInstanceOf(RegistryError); + lock.packages['@acme/shapes']!.sha256 = ''; + writeFileSync(join(root, LOCK_FILE), JSON.stringify(lock)); + await expect(packages.restore()).rejects.toMatchObject({ code: 'INVALID' }); + writeFileSync(join(root, LOCK_FILE), '{ not json'); + await expect(packages.restore()).rejects.toMatchObject({ code: 'INVALID' }); + }); + + it('restores the version the lock says when another one is installed', async () => { + const root = project('restore-bump'); + const packages = new ProjectPackages(root, registry); + await packages.install('@acme/shapes', '1.0.0'); + const lock = json(root, LOCK_FILE) as unknown as { + packages: Record; + }; + const bumped = await registry.get('@acme/shapes'); + const next = bumped.versions.find((entry) => entry.version === '1.1.0')!; + lock.packages['@acme/shapes'] = { + ...lock.packages['@acme/shapes']!, + version: '1.1.0', + sha256: next.sha256, + }; + writeFileSync(join(root, LOCK_FILE), JSON.stringify(lock)); + await packages.restore(); + expect(json(root, 'nf_modules/@acme/shapes/nanoforge.manifest.json')).toMatchObject({ + version: '1.1.0', + }); + }); + + it('refuses a locked version whose archive changed in the registry', async () => { + const root = project('integrity'); + const packages = new ProjectPackages(root, registry); + await packages.install('@acme/shapes', '1.0.0'); + const lock = json(root, LOCK_FILE) as unknown as { + packages: Record; + }; + lock.packages['@acme/shapes']!.sha256 = 'a'.repeat(64); + writeFileSync(join(root, LOCK_FILE), JSON.stringify(lock)); + await expect(packages.install('@acme/render')).rejects.toMatchObject({ code: 'INTEGRITY' }); + expect(json(root, LOCK_FILE)).toEqual(lock); + expect(existsSync(join(root, 'nf_modules/@acme/render'))).toBe(false); + }); + + it('puts every package back when one of several fails to install', async () => { + const root = project('rollback'); + const failing = Object.create(registry) as typeof registry; + failing.download = async (name: string, version: string) => { + if (name === '@acme/shapes') throw new RegistryError('UNAVAILABLE', 'network down'); + return registry.download(name, version); + }; + const broken = new ProjectPackages(root, failing); + await expect(broken.install('@acme/render')).rejects.toMatchObject({ code: 'UNAVAILABLE' }); + expect(existsSync(join(root, 'nf_modules/@acme/render'))).toBe(false); + expect(existsSync(join(root, LOCK_FILE))).toBe(false); + expect(existsSync(join(root, PACKAGES_FILE))).toBe(false); + }); + + it('removes on restore the packages the lock no longer lists', async () => { + const root = project('restore-removed'); + const packages = new ProjectPackages(root, registry); + await packages.install('@acme/shapes', '1.0.0'); + writeFileSync(join(root, LOCK_FILE), JSON.stringify({ lockVersion: 1, packages: {} })); + mkdirSync(join(root, 'nf_modules/@acme/left.nf-old-1234'), { recursive: true }); + await packages.restore(); + expect(readdirSync(join(root, 'nf_modules/@acme'))).toEqual([]); + }); + + it('runs changes of one project one after the other', async () => { + const root = project('queue'); + const packages = new ProjectPackages(root, registry); + const [first, second] = await Promise.all([ + packages.install('@acme/shapes', '^1.0.0'), + packages.install('@acme/render'), + ]); + expect(first.map((entry) => entry.name)).toEqual(['@acme/shapes']); + expect(second.map((entry) => entry.name)).toEqual(['@acme/render', '@acme/shapes']); + expect(json(root, PACKAGES_FILE)).toEqual({ + packages: { '@acme/render': '^1.0.0', '@acme/shapes': '^1.0.0' }, + }); + }); +}); + +describe('ItemVersion', () => { + it('takes only versions semver reads as they are', () => { + for (const version of ['1.0.0', '1.2.3-beta.1', '0.0.1']) + expect(ItemVersion.safeParse(version).success).toBe(true); + for (const version of ['01.0.0', '1.0.0-..', 'v1.0.0', '1.0']) + expect(ItemVersion.safeParse(version).success).toBe(false); + }); +}); + +describe('tsconfig paths', () => { + it('adds and removes entries, keeping comments', () => { + const text = + '{\n // comment\n "compilerOptions": {\n "paths": {\n "@x/a/*": ["./libs/a/src/*"]\n }\n }\n}\n'; + const added = withPathsEntry(text, '@acme/b/*', './nf_modules/@acme/b/*'); + expect(added).toContain('// comment'); + expect(added).toContain('"@acme/b/*": ["./nf_modules/@acme/b/*"],'); + expect(withPathsEntry(added, '@acme/b/*', './nf_modules/@acme/b/*')).toBe(added); + expect(withoutPathsEntry(added, '@acme/b/*')).toBe(text); + const two = + '{\n "compilerOptions": {\n "paths": {\n "@x/a/*": ["./a/*"],\n "@x/b/*": ["./b/*"]\n }\n },\n "exclude": ["dist"]\n}\n'; + expect(withoutPathsEntry(two, '@x/b/*')).toBe( + '{\n "compilerOptions": {\n "paths": {\n "@x/a/*": ["./a/*"]\n }\n },\n "exclude": ["dist"]\n}\n', + ); + const removed = withoutPathsEntry(added, '@x/a/*'); + expect(JSON.parse(removed.replace('// comment', ''))).toEqual({ + compilerOptions: { paths: { '@acme/b/*': ['./nf_modules/@acme/b/*'] } }, + }); + expect(withoutPathsEntry(text, '@none/*')).toBe(text); + expect(JSON.parse(withPathsEntry('{}\n', '@a/b/*', './x/*'))).toEqual({ + compilerOptions: { paths: { '@a/b/*': ['./x/*'] } }, + }); + }); +}); diff --git a/packages/registry/tsconfig.json b/packages/registry/tsconfig.json new file mode 100644 index 00000000..888918d9 --- /dev/null +++ b/packages/registry/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/rpc/.prettierignore b/packages/rpc/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/rpc/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/rpc/LICENSE b/packages/rpc/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/rpc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/rpc/README.md b/packages/rpc/README.md new file mode 100644 index 00000000..389f69fc --- /dev/null +++ b/packages/rpc/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-rpc` is an internal package of the NanoForge Editor. + +Typed client/server RPC and WebSocket channels. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/rpc +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/rpc/package.json b/packages/rpc/package.json new file mode 100644 index 00000000..34466cb8 --- /dev/null +++ b/packages/rpc/package.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-rpc", + "version": "0.0.0", + "description": "NanoForge Editor - Typed client/server RPC and WebSocket channels", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/rpc" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty .", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "@nanoforge-dev/editor-kernel": "workspace:*", + "zod": "catalog:libs" + }, + "devDependencies": { + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/rpc/src/client/index.ts b/packages/rpc/src/client/index.ts new file mode 100644 index 00000000..7e42bab2 --- /dev/null +++ b/packages/rpc/src/client/index.ts @@ -0,0 +1,2 @@ +export * from './rpc-client.type'; +export * from './rpc-client'; diff --git a/packages/rpc/src/client/rpc-client.ts b/packages/rpc/src/client/rpc-client.ts new file mode 100644 index 00000000..81c1328d --- /dev/null +++ b/packages/rpc/src/client/rpc-client.ts @@ -0,0 +1,242 @@ +import type { z } from 'zod'; + +import { + type Disposable, + Emitter, + type Event, + type Observable, + ObservableValue, + createToken, + toDisposable, +} from '@nanoforge-dev/editor-kernel'; + +import { decode, encode } from '../codec/codec'; +import type { ClientMessage, Contract, ServerMessage } from '../contract/contract.type'; +import { RPC_HTTP_PREFIX, RPC_WS_PATH } from '../contract/rpc.const'; +import { + RPC_ERROR_STATUS, + RpcError, + type RpcErrorCode, + type RpcErrorPayload, +} from '../error/rpc.exception'; +import type { + CallOptions, + ConnectionState, + RpcApi, + RpcClientOptions, + SocketLike, +} from './rpc-client.type'; + +interface ActiveSubscription { + readonly message: Extract; + readonly onEvent: (data: unknown) => void; + readonly onError: (error: RpcError) => void; + readonly onEnd: () => void; +} + +const OPEN = 1; + +const statusCode = (status: number): RpcErrorCode => + (Object.entries(RPC_ERROR_STATUS).find(([, value]) => value === status)?.[0] as RpcErrorCode) ?? + (status >= 500 ? 'INTERNAL' : 'BAD_REQUEST'); + +export class RpcClient implements Disposable { + private readonly _fetch: typeof fetch; + private readonly _subscriptions = new Map(); + private readonly _state = new ObservableValue('idle'); + private readonly _onDidReconnect = new Emitter(); + private _socket: SocketLike | undefined; + private _nextId = 1; + private _attempt = 0; + private _heartbeat: ReturnType | undefined; + private _reconnectTimer: ReturnType | undefined; + private _awaitingPong = false; + private _disposed = false; + + /** Fires after the socket reconnected and subscriptions were restored. */ + readonly onDidReconnect: Event = this._onDidReconnect.event; + + constructor(private readonly _options: RpcClientOptions) { + this._fetch = _options.fetch ?? globalThis.fetch.bind(globalThis); + } + + get state(): Observable { + return this._state.readonly(); + } + + /** Typed proxy over a contract's methods. */ + api(contract: C): RpcApi { + const api: Record = {}; + for (const method of Object.keys(contract.methods)) { + api[method] = (input: unknown, options?: CallOptions) => + this.call(`${contract.namespace}.${method}`, input, options); + } + return api as RpcApi; + } + + async call(path: string, input: unknown, options: CallOptions = {}): Promise { + const { signal, timeoutMs } = options; + const timeout = timeoutMs === undefined ? undefined : AbortSignal.timeout(timeoutMs); + const combined = signal && timeout ? AbortSignal.any([signal, timeout]) : (signal ?? timeout); + let response: Response; + let text: string; + try { + response = await this._fetch(new URL(`${RPC_HTTP_PREFIX}${path}`, this._options.baseUrl), { + method: 'POST', + headers: { 'content-type': 'application/json', ...this._options.headers?.() }, + body: encode(input ?? null), + ...(combined && { signal: combined }), + }); + text = await response.text(); + } catch (error) { + if (signal?.aborted) throw new RpcError('CANCELLED', 'Request cancelled'); + if (timeout?.aborted) + throw new RpcError( + 'TIMEOUT', + `The editor server did not answer ${path} in ${timeoutMs} ms`, + ); + throw new RpcError('UNAVAILABLE', `Editor server unreachable: ${String(error)}`); + } + let payload: { result?: unknown; error?: RpcErrorPayload | string }; + try { + payload = decode(text); + } catch { + throw new RpcError(statusCode(response.status), `Unexpected response (${response.status})`); + } + if (typeof payload.error === 'object') throw RpcError.from(payload.error); + if (!response.ok || payload.error !== undefined) { + throw new RpcError(statusCode(response.status), String(payload.error ?? response.statusText)); + } + return payload.result; + } + + /** Subscribes to a stream; the subscription survives reconnections. */ + subscribe( + contract: C, + topic: K, + params: z.input, + onEvent: (event: z.output) => void, + handlers: { onError?: (error: RpcError) => void; onEnd?: () => void } = {}, + ): Disposable { + const id = this._nextId++; + const message: ClientMessage = { t: 'sub', id, ns: contract.namespace, topic, params }; + this._subscriptions.set(id, { + message, + onEvent: onEvent as (data: unknown) => void, + onError: handlers.onError ?? (() => undefined), + onEnd: handlers.onEnd ?? (() => undefined), + }); + this._ensureSocket(); + this._sendRaw(message); + return toDisposable(() => { + if (!this._subscriptions.delete(id)) return; + this._sendRaw({ t: 'unsub', id }); + }); + } + + dispose(): void { + this._disposed = true; + clearTimeout(this._reconnectTimer); + this._stopHeartbeat(); + this._subscriptions.clear(); + const socket = this._socket; + this._socket = undefined; + socket?.close(); + this._state.set('closed'); + this._onDidReconnect.dispose(); + } + + private _ensureSocket(): void { + if (this._socket || this._disposed) return; + const url = new URL(RPC_WS_PATH, this._options.baseUrl); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + const create = + this._options.createSocket ?? ((target: string) => new WebSocket(target) as SocketLike); + const socket = create(url.href); + this._socket = socket; + this._state.set(this._attempt === 0 ? 'connecting' : 'reconnecting'); + + socket.onopen = () => { + const reconnected = this._attempt > 0; + this._attempt = 0; + this._state.set('open'); + for (const subscription of this._subscriptions.values()) this._sendRaw(subscription.message); + this._startHeartbeat(); + if (reconnected) this._onDidReconnect.fire(); + }; + socket.onmessage = (event) => this._receive(String(event.data)); + socket.onerror = () => socket.close(); + socket.onclose = () => { + if (this._socket !== socket) return; + this._socket = undefined; + this._stopHeartbeat(); + if (this._disposed) return; + if (!this._subscriptions.size) { + this._state.set('idle'); + return; + } + this._scheduleReconnect(); + }; + } + + private _scheduleReconnect(): void { + const delays = this._options.reconnectDelays ?? [250, 500, 1000, 2000, 5000]; + const delay = delays[Math.min(this._attempt, delays.length - 1)]!; + this._attempt++; + this._state.set('reconnecting'); + this._reconnectTimer = setTimeout(() => this._ensureSocket(), delay); + } + + private _receive(raw: string): void { + let message: ServerMessage; + try { + message = JSON.parse(raw) as ServerMessage; + } catch { + return; + } + if (message.t === 'pong') { + this._awaitingPong = false; + return; + } + const subscription = this._subscriptions.get(message.id); + if (!subscription) return; + switch (message.t) { + case 'event': + subscription.onEvent(decode(JSON.stringify(message.data))); + return; + case 'error': + this._subscriptions.delete(message.id); + subscription.onError(RpcError.from(message.error as RpcErrorPayload)); + return; + case 'end': + this._subscriptions.delete(message.id); + subscription.onEnd(); + } + } + + private _sendRaw(message: ClientMessage): void { + if (this._socket?.readyState === OPEN) this._socket.send(JSON.stringify(message)); + } + + private _startHeartbeat(): void { + this._stopHeartbeat(); + const interval = this._options.heartbeatMs ?? 20_000; + this._heartbeat = setInterval(() => { + if (this._awaitingPong) { + this._socket?.close(); + return; + } + this._awaitingPong = true; + this._sendRaw({ t: 'ping' }); + }, interval); + } + + private _stopHeartbeat(): void { + clearInterval(this._heartbeat); + this._heartbeat = undefined; + this._awaitingPong = false; + } +} + +/** The editor's RPC client, for plugins calling their own server entry. */ +export const RpcClientToken = createToken('rpc.client'); diff --git a/packages/rpc/src/client/rpc-client.type.ts b/packages/rpc/src/client/rpc-client.type.ts new file mode 100644 index 00000000..62d734e7 --- /dev/null +++ b/packages/rpc/src/client/rpc-client.type.ts @@ -0,0 +1,41 @@ +import type { z } from 'zod'; + +import type { Contract } from '../contract/contract.type'; + +/** Per call: cancel it (`signal`), or give up after `timeoutMs` with a `TIMEOUT` error. */ +export interface CallOptions { + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +} + +export type RpcApi = { + [K in keyof C['methods']]: ( + input: z.input, + options?: CallOptions, + ) => Promise>; +}; + +export type ConnectionState = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed'; + +/** Minimal WebSocket surface (browser WebSocket, `ws`, test doubles). */ +export interface SocketLike { + readonly readyState: number; + onopen: ((event: unknown) => void) | null; + onclose: ((event: unknown) => void) | null; + onerror: ((event: unknown) => void) | null; + onmessage: ((event: { data: unknown }) => void) | null; + send(data: string): void; + close(): void; +} + +export interface RpcClientOptions { + /** Origin of the editor server, e.g. `http://localhost:5173`. */ + baseUrl: string; + fetch?: typeof fetch; + /** Extra headers per call (active project, …). */ + headers?: () => Record; + createSocket?: (url: string) => SocketLike; + heartbeatMs?: number; + /** Reconnect delays in ms; the last one repeats. */ + reconnectDelays?: readonly number[]; +} diff --git a/packages/rpc/src/codec/codec.ts b/packages/rpc/src/codec/codec.ts new file mode 100644 index 00000000..6cf79365 --- /dev/null +++ b/packages/rpc/src/codec/codec.ts @@ -0,0 +1,41 @@ +/** + * JSON with a binary-safe envelope: `Uint8Array`/`ArrayBuffer` become `{ "$bin": base64 }` and + * `Date` becomes `{ "$date": iso }`, so file contents can travel through JSON RPC calls. + */ +const toBase64 = (bytes: Uint8Array): string => { + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary); +}; + +const fromBase64 = (base64: string): Uint8Array => { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +}; + +const isPlainTagged = (value: unknown, tag: string): value is Record => + typeof value === 'object' && + value !== null && + Object.keys(value).length === 1 && + typeof (value as Record)[tag] === 'string'; + +export const encode = (value: unknown): string => + JSON.stringify(value, function (this: Record, key, current: unknown) { + const raw = this[key]; + if (raw instanceof Date) return { $date: raw.toISOString() }; + if (current instanceof Uint8Array) return { $bin: toBase64(current) }; + if (current instanceof ArrayBuffer) return { $bin: toBase64(new Uint8Array(current)) }; + return current; + }); + +export const decode = (text: string): T => + JSON.parse(text, (_key, value: unknown) => { + if (isPlainTagged(value, '$bin')) return fromBase64(value.$bin!); + if (isPlainTagged(value, '$date')) return new Date(value.$date!); + return value; + }) as T; diff --git a/packages/rpc/src/codec/index.ts b/packages/rpc/src/codec/index.ts new file mode 100644 index 00000000..d612ef20 --- /dev/null +++ b/packages/rpc/src/codec/index.ts @@ -0,0 +1 @@ +export * from './codec'; diff --git a/packages/rpc/src/contract/contract.type.ts b/packages/rpc/src/contract/contract.type.ts new file mode 100644 index 00000000..01aba149 --- /dev/null +++ b/packages/rpc/src/contract/contract.type.ts @@ -0,0 +1,62 @@ +import type { z } from 'zod'; + +export interface MethodDefinition< + I extends z.ZodType = z.ZodType, + O extends z.ZodType = z.ZodType, +> { + readonly input: I; + readonly output: O; + /** Callable without an authenticated session. */ + readonly public?: boolean; +} + +export interface StreamDefinition< + P extends z.ZodType = z.ZodType, + E extends z.ZodType = z.ZodType, +> { + readonly params: P; + readonly event: E; + /** + * When the client cannot keep up, only the latest pending event is kept (state snapshots). + * Otherwise events are queued (change logs). + */ + readonly coalesce?: boolean; +} + +export type MethodMap = Record; +export type StreamMap = Record; + +export interface Contract< + N extends string = string, + M extends MethodMap = MethodMap, + S extends StreamMap = StreamMap, +> { + readonly namespace: N; + readonly methods: M; + readonly streams: S; +} + +export type MethodInput = z.input< + C['methods'][K]['input'] +>; +export type MethodOutput = z.output< + C['methods'][K]['output'] +>; +export type StreamParams = z.input< + C['streams'][K]['params'] +>; +export type StreamEvent = z.output< + C['streams'][K]['event'] +>; + +/** Wire messages of the WebSocket channel. */ +export type ClientMessage = + | { t: 'sub'; id: number; ns: string; topic: string; params: unknown } + | { t: 'unsub'; id: number } + | { t: 'ping' }; + +export type ServerMessage = + | { t: 'event'; id: number; data: unknown } + | { t: 'error'; id: number; error: { code: string; message: string; data?: unknown } } + | { t: 'end'; id: number } + | { t: 'pong' }; diff --git a/packages/rpc/src/contract/define-contract.ts b/packages/rpc/src/contract/define-contract.ts new file mode 100644 index 00000000..6e3f8436 --- /dev/null +++ b/packages/rpc/src/contract/define-contract.ts @@ -0,0 +1,17 @@ +import type { Contract, MethodMap, StreamMap } from './contract.type'; + +export const NAMESPACE_PATTERN = + /^[a-z][\w-]*(\.[a-z][\w-]*)*(\.@[a-z0-9][\w-]*\/[a-z0-9][\w-]*)?$/; + +/** Declares a typed RPC contract, shared by the client and the server implementation. */ +export const defineContract = ( + namespace: N, + definition: { methods: M; streams?: S }, +): Contract => { + if (!NAMESPACE_PATTERN.test(namespace)) throw new Error(`Invalid RPC namespace "${namespace}"`); + return Object.freeze({ + namespace, + methods: definition.methods, + streams: (definition.streams ?? {}) as S, + }); +}; diff --git a/packages/rpc/src/contract/index.ts b/packages/rpc/src/contract/index.ts new file mode 100644 index 00000000..89ac3165 --- /dev/null +++ b/packages/rpc/src/contract/index.ts @@ -0,0 +1,3 @@ +export * from './contract.type'; +export * from './define-contract'; +export * from './rpc.const'; diff --git a/packages/rpc/src/contract/rpc.const.ts b/packages/rpc/src/contract/rpc.const.ts new file mode 100644 index 00000000..f223e3d6 --- /dev/null +++ b/packages/rpc/src/contract/rpc.const.ts @@ -0,0 +1,4 @@ +/** Header carrying the active project id on HTTP calls. */ +export const PROJECT_HEADER = 'x-nf-project'; +export const RPC_HTTP_PREFIX = '/rpc/'; +export const RPC_WS_PATH = '/rpc/ws'; diff --git a/packages/rpc/src/error/index.ts b/packages/rpc/src/error/index.ts new file mode 100644 index 00000000..673991fb --- /dev/null +++ b/packages/rpc/src/error/index.ts @@ -0,0 +1 @@ +export * from './rpc.exception'; diff --git a/packages/rpc/src/error/rpc.exception.ts b/packages/rpc/src/error/rpc.exception.ts new file mode 100644 index 00000000..fd8d19b9 --- /dev/null +++ b/packages/rpc/src/error/rpc.exception.ts @@ -0,0 +1,51 @@ +export const RPC_ERROR_STATUS = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + PRECONDITION_FAILED: 412, + PAYLOAD_TOO_LARGE: 413, + CANCELLED: 499, + INTERNAL: 500, + UNAVAILABLE: 503, + TIMEOUT: 504, +} as const; + +export type RpcErrorCode = keyof typeof RPC_ERROR_STATUS; + +export interface RpcErrorPayload { + code: RpcErrorCode; + message: string; + data?: unknown; +} + +/** Error crossing the RPC boundary. Any other error is reported to clients as INTERNAL. */ +export class RpcError extends Error { + static from(payload: RpcErrorPayload): RpcError { + const code = payload.code in RPC_ERROR_STATUS ? payload.code : 'INTERNAL'; + return new RpcError(code, payload.message, payload.data); + } + + static is(error: unknown, code?: RpcErrorCode): error is RpcError { + return error instanceof RpcError && (code === undefined || error.code === code); + } + constructor( + readonly code: RpcErrorCode, + message: string, + readonly data?: unknown, + ) { + super(message); + this.name = 'RpcError'; + } + + get status(): number { + return RPC_ERROR_STATUS[this.code]; + } + + toJSON(): RpcErrorPayload { + return this.data === undefined + ? { code: this.code, message: this.message } + : { code: this.code, message: this.message, data: this.data }; + } +} diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts new file mode 100644 index 00000000..daf3e63d --- /dev/null +++ b/packages/rpc/src/index.ts @@ -0,0 +1,5 @@ +export * from './client'; +export * from './codec'; +export * from './contract'; +export * from './error'; +export * from './router'; diff --git a/packages/rpc/src/router/index.ts b/packages/rpc/src/router/index.ts new file mode 100644 index 00000000..ca254b22 --- /dev/null +++ b/packages/rpc/src/router/index.ts @@ -0,0 +1,3 @@ +export * from './router.type'; +export * from './rpc-router'; +export * from './rpc-session'; diff --git a/packages/rpc/src/router/router-entries.ts b/packages/rpc/src/router/router-entries.ts new file mode 100644 index 00000000..ed94bafd --- /dev/null +++ b/packages/rpc/src/router/router-entries.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import type { MethodDefinition, StreamDefinition } from '../contract/contract.type'; +import { RpcError } from '../error/rpc.exception'; +import type { HandlerContext, StreamCleanup, StreamSink } from './router.type'; + +export interface MethodEntry { + definition: MethodDefinition; + handler: (input: unknown, context: HandlerContext) => unknown; +} + +export interface StreamEntry { + definition: StreamDefinition; + handler: ( + params: unknown, + context: HandlerContext, + sink: StreamSink, + ) => StreamCleanup | Promise; +} + +export const toRpcError = (error: unknown): RpcError => { + if (error instanceof RpcError) return error; + if (error instanceof z.ZodError) { + return new RpcError('BAD_REQUEST', 'Invalid input', z.flattenError(error)); + } + return new RpcError('INTERNAL', 'Internal error'); +}; diff --git a/packages/rpc/src/router/router.type.ts b/packages/rpc/src/router/router.type.ts new file mode 100644 index 00000000..610e3e46 --- /dev/null +++ b/packages/rpc/src/router/router.type.ts @@ -0,0 +1,59 @@ +import { type z } from 'zod'; + +import type { Disposable } from '@nanoforge-dev/editor-kernel'; + +import type { Contract } from '../contract/contract.type'; + +export interface CallInfo { + readonly namespace: string; + readonly name: string; + readonly kind: 'method' | 'stream'; + readonly public: boolean; +} + +export type HandlerContext = Ctx & { readonly signal: AbortSignal }; + +/** What a stream handler may return to be notified when the subscription ends. */ +export type StreamCleanup = Disposable | (() => void) | undefined; + +export interface StreamSink { + emit(event: E): void; + /** Ends the stream from the server side. */ + end(): void; + error(error: unknown): void; +} + +type MethodHandlers = { + [K in keyof C['methods']]: ( + input: z.output, + context: HandlerContext, + ) => z.input | Promise>; +}; + +type StreamHandlers = { + [K in keyof C['streams']]: ( + params: z.output, + context: HandlerContext, + sink: StreamSink>, + ) => StreamCleanup | Promise; +}; + +export type Implementation = { + methods: MethodHandlers; +} & (keyof C['streams'] extends never + ? { streams?: StreamHandlers } + : { streams: StreamHandlers }); + +export interface RouterOptions { + /** Throws (typically `RpcError('UNAUTHORIZED')`) to refuse a call or subscription. */ + authorize?: (context: Ctx, call: CallInfo) => void | Promise; + /** Reports unexpected (non RpcError) failures. */ + onError?: (error: unknown, call: CallInfo) => void; + /** Maximum queued events per subscription before dropping the oldest. */ + maxQueuedEvents?: number; +} + +export interface HttpResult { + readonly status: number; + readonly body: string; +} diff --git a/packages/rpc/src/router/rpc-router.ts b/packages/rpc/src/router/rpc-router.ts new file mode 100644 index 00000000..cd6e629e --- /dev/null +++ b/packages/rpc/src/router/rpc-router.ts @@ -0,0 +1,105 @@ +import { type Disposable, toDisposable } from '@nanoforge-dev/editor-kernel'; + +import { decode, encode } from '../codec/codec'; +import type { Contract } from '../contract/contract.type'; +import { RpcError } from '../error/rpc.exception'; +import { type MethodEntry, type StreamEntry, toRpcError } from './router-entries'; +import type { CallInfo, HttpResult, Implementation, RouterOptions } from './router.type'; +import { RpcSession } from './rpc-session'; + +/** + * Transport-agnostic RPC server: HTTP calls go through `handle`, WebSocket connections through + * `createSession`. The server validates inputs and outputs against the contract. + */ +export class RpcRouter { + private readonly _methods = new Map>(); + private readonly _streams = new Map>(); + + constructor(private readonly _options: RouterOptions = {}) {} + + /** + * Registers the handlers of a contract. All or nothing: when a handler is missing or a name is + * already taken, nothing stays registered. A contract written by hand (a plugin's server entry + * in plain JS) may leave out `methods` or `streams`. + */ + implement(contract: C, implementation: Implementation): Disposable { + const keys: string[] = []; + const register = (map: Map, name: string, entry: T) => { + const key = `${contract.namespace}.${name}`; + if (map.has(key)) throw new Error(`RPC "${key}" is already implemented`); + map.set(key, entry); + keys.push(key); + }; + const unregister = () => { + for (const key of keys) { + this._methods.delete(key); + this._streams.delete(key); + } + }; + try { + for (const [name, definition] of Object.entries(contract.methods ?? {})) { + const handler = ( + implementation.methods as Record['handler']> | undefined + )?.[name]; + if (!handler) throw new Error(`Missing handler for ${contract.namespace}.${name}`); + register(this._methods, name, { definition, handler }); + } + for (const [name, definition] of Object.entries(contract.streams ?? {})) { + const handler = ( + implementation.streams as Record['handler']> | undefined + )?.[name]; + if (!handler) throw new Error(`Missing stream handler for ${contract.namespace}.${name}`); + register(this._streams, name, { definition, handler }); + } + } catch (error) { + unregister(); + throw error; + } + return toDisposable(unregister); + } + + /** Runs `.` with an encoded input body. */ + async handle( + path: string, + body: string, + context: Ctx, + signal: AbortSignal = new AbortController().signal, + ): Promise { + const entry = this._methods.get(path); + const dot = path.lastIndexOf('.'); + const call: CallInfo = { + namespace: path.slice(0, dot), + name: path.slice(dot + 1), + kind: 'method', + public: entry?.definition.public ?? false, + }; + try { + if (!entry) throw new RpcError('NOT_FOUND', `Unknown RPC method "${path}"`); + await this._options.authorize?.(context, call); + let raw: unknown; + try { + raw = body ? decode(body) : undefined; + } catch { + throw new RpcError('BAD_REQUEST', 'Malformed request body'); + } + const input = entry.definition.input.parse(raw); + const result = await entry.handler(input, { ...context, signal }); + if (signal.aborted) throw new RpcError('CANCELLED', 'Request cancelled'); + const output = entry.definition.output.parse(result); + return { status: 200, body: encode({ result: output }) }; + } catch (error) { + const rpcError = toRpcError(error); + if (rpcError.code === 'INTERNAL') this._options.onError?.(error, call); + return { status: rpcError.status, body: encode({ error: rpcError.toJSON() }) }; + } + } + + /** + * Creates the server side of a WebSocket connection. `send` returns false when the socket is + * back-pressured; call `drain` when it can accept data again. + */ + createSession(context: Ctx, send: (message: string) => boolean): RpcSession { + const lookup = (key: string) => this._streams.get(key) as StreamEntry | undefined; + return new RpcSession(lookup, context, send, this._options as RouterOptions); + } +} diff --git a/packages/rpc/src/router/rpc-session.ts b/packages/rpc/src/router/rpc-session.ts new file mode 100644 index 00000000..5f61e516 --- /dev/null +++ b/packages/rpc/src/router/rpc-session.ts @@ -0,0 +1,157 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; + +import { encode } from '../codec/codec'; +import type { ClientMessage, ServerMessage } from '../contract/contract.type'; +import { RpcError } from '../error/rpc.exception'; +import { type StreamEntry, toRpcError } from './router-entries'; +import type { CallInfo, RouterOptions, StreamCleanup, StreamSink } from './router.type'; + +interface Subscription { + readonly controller: AbortController; + readonly coalesce: boolean; + queue: string[]; + cleanup?: StreamCleanup; + closed: boolean; +} + +export class RpcSession implements Disposable { + private readonly _subscriptions = new Map(); + private _blocked = false; + private _closed = false; + + constructor( + private readonly _lookup: (key: string) => StreamEntry | undefined, + private readonly _context: object, + private readonly _send: (message: string) => boolean, + private readonly _options: RouterOptions, + ) {} + + get subscriptionCount(): number { + return this._subscriptions.size; + } + + async receive(raw: string): Promise { + let message: ClientMessage; + try { + message = JSON.parse(raw) as ClientMessage; + } catch { + return; + } + if (typeof message !== 'object' || message === null) return; + switch (message.t) { + case 'ping': + this._write({ t: 'pong' }); + return; + case 'unsub': + this._close(message.id); + return; + case 'sub': + await this._subscribe(message); + return; + } + } + + /** Flushes queued events after back-pressure. */ + drain(): void { + this._blocked = false; + for (const subscription of this._subscriptions.values()) { + while (subscription.queue.length && !this._blocked) { + this._blocked = !this._send(subscription.queue.shift()!); + } + if (this._blocked) return; + } + } + + dispose(): void { + this._closed = true; + for (const id of [...this._subscriptions.keys()]) this._close(id); + } + + private async _subscribe(message: Extract): Promise { + const key = `${message.ns}.${message.topic}`; + const entry = this._lookup(key); + const call: CallInfo = { + namespace: message.ns, + name: message.topic, + kind: 'stream', + public: false, + }; + if (this._subscriptions.has(message.id)) return; + const subscription: Subscription = { + controller: new AbortController(), + coalesce: entry?.definition.coalesce ?? false, + queue: [], + closed: false, + }; + this._subscriptions.set(message.id, subscription); + try { + if (!entry) throw new RpcError('NOT_FOUND', `Unknown RPC stream "${key}"`); + await this._options.authorize?.(this._context, call); + const params = entry.definition.params.parse(message.params); + const sink: StreamSink = { + emit: (event) => { + if (subscription.controller.signal.aborted) return; + this._emit(message.id, subscription, entry.definition.event.parse(event)); + }, + end: () => { + this._write({ t: 'end', id: message.id }); + this._close(message.id); + }, + error: (error) => this._fail(message.id, error, call), + }; + const cleanup = await entry.handler( + params, + { ...this._context, signal: subscription.controller.signal }, + sink, + ); + if (subscription.controller.signal.aborted) { + disposeCleanup(cleanup); + } else if (cleanup) { + subscription.cleanup = cleanup; + } + } catch (error) { + this._fail(message.id, error, call); + } + } + + private _emit(id: number, subscription: Subscription, event: unknown): void { + const text = JSON.stringify({ t: 'event', id, data: JSON.parse(encode(event)) }); + if (this._blocked) { + if (subscription.coalesce) subscription.queue = [text]; + else { + subscription.queue.push(text); + const max = this._options.maxQueuedEvents ?? 10_000; + if (subscription.queue.length > max) subscription.queue.shift(); + } + return; + } + this._blocked = !this._send(text); + } + + private _fail(id: number, error: unknown, call: CallInfo): void { + const rpcError = toRpcError(error); + if (rpcError.code === 'INTERNAL') this._options.onError?.(error, call); + this._write({ t: 'error', id, error: rpcError.toJSON() }); + this._close(id); + } + + private _write(message: ServerMessage): void { + if (this._closed && message.t !== 'end') return; + this._blocked = !this._send(JSON.stringify(message)) || this._blocked; + } + + private _close(id: number): void { + const subscription = this._subscriptions.get(id); + if (!subscription || subscription.closed) return; + subscription.closed = true; + subscription.controller.abort(); + this._subscriptions.delete(id); + disposeCleanup(subscription.cleanup); + } +} + +const disposeCleanup = (cleanup: StreamCleanup) => { + if (!cleanup) return; + if (typeof cleanup === 'function') cleanup(); + else cleanup.dispose(); +}; diff --git a/packages/rpc/test/codec/codec.spec.ts b/packages/rpc/test/codec/codec.spec.ts new file mode 100644 index 00000000..a7feb67a --- /dev/null +++ b/packages/rpc/test/codec/codec.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { decode, encode } from '../../src/codec/codec'; + +describe('codec', () => { + it('round-trips binary, dates and plain JSON', () => { + const bytes = new Uint8Array(70_000).map((_, i) => i % 256); + const value = { bytes, when: new Date('2026-09-26T10:00:00Z'), nested: [{ a: 1 }, null] }; + const decoded = decode(encode(value)); + expect(decoded.bytes).toBeInstanceOf(Uint8Array); + expect(decoded.bytes).toEqual(bytes); + expect(decoded.when).toEqual(value.when); + expect(decoded.nested).toEqual(value.nested); + }); + + it('keeps objects that only look like tags if they have other keys', () => { + expect(decode(encode({ $bin: 'x', other: 1 }))).toEqual({ $bin: 'x', other: 1 }); + }); +}); diff --git a/packages/rpc/test/fixtures/harness.ts b/packages/rpc/test/fixtures/harness.ts new file mode 100644 index 00000000..49ba55ac --- /dev/null +++ b/packages/rpc/test/fixtures/harness.ts @@ -0,0 +1,77 @@ +import { RpcClient } from '../../src/client/rpc-client'; +import type { SocketLike } from '../../src/client/rpc-client.type'; +import type { RpcRouter } from '../../src/router/rpc-router'; +import type { RpcSession } from '../../src/router/rpc-session'; + +/** In-memory socket connected to a router session. */ +export class FakeSocket implements SocketLike { + readyState = 0; + onopen: SocketLike['onopen'] = null; + onclose: SocketLike['onclose'] = null; + onerror: SocketLike['onerror'] = null; + onmessage: SocketLike['onmessage'] = null; + session: RpcSession | undefined; + /** When true, the server side reports back-pressure. */ + backpressure = false; + readonly sent: string[] = []; + + constructor( + private readonly router: RpcRouter<{ user: string }>, + private readonly context: { user: string }, + ) { + queueMicrotask(() => this.open()); + } + + open(): void { + this.session = this.router.createSession(this.context, (message) => { + if (this.readyState !== 1) return false; + queueMicrotask(() => this.onmessage?.({ data: message })); + return !this.backpressure; + }); + this.readyState = 1; + this.onopen?.({}); + } + + send(data: string): void { + this.sent.push(data); + void this.session?.receive(data); + } + + close(): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.session?.dispose(); + queueMicrotask(() => this.onclose?.({})); + } +} + +export const connect = ( + router: RpcRouter<{ user: string }>, + context = { user: 'alice' }, + options: { heartbeatMs?: number } = {}, +) => { + const sockets: FakeSocket[] = []; + const client = new RpcClient({ + baseUrl: 'http://editor.test', + reconnectDelays: [1], + ...options, + fetch: async (url, init) => { + const path = new URL(String(url)).pathname.replace('/rpc/', ''); + const result = await router.handle( + path, + String(init?.body ?? ''), + context, + init?.signal ?? undefined, + ); + return new Response(result.body, { status: result.status }); + }, + createSocket: () => { + const socket = new FakeSocket(router, context); + sockets.push(socket); + return socket; + }, + }); + return { client, sockets }; +}; + +export const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/packages/rpc/test/rpc.spec.ts b/packages/rpc/test/rpc.spec.ts new file mode 100644 index 00000000..8a415cfb --- /dev/null +++ b/packages/rpc/test/rpc.spec.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { toDisposable } from '@nanoforge-dev/editor-kernel'; + +import { RpcClient, RpcError, RpcRouter, defineContract } from '../src'; +import { connect, flush } from './fixtures/harness'; + +const Files = defineContract('fs', { + methods: { + read: { + input: z.object({ path: z.string().min(1) }), + output: z.object({ content: z.instanceof(Uint8Array), size: z.number() }), + }, + fail: { input: z.null(), output: z.null() }, + version: { input: z.null(), output: z.string(), public: true }, + }, + streams: { + changes: { + params: z.object({ glob: z.string() }), + event: z.object({ path: z.string() }), + }, + status: { params: z.null(), event: z.number(), coalesce: true }, + }, +}); + +const createRouter = () => { + const emitters = new Map void>(); + let emitStatus: (n: number) => void = () => undefined; + const cleanup = vi.fn(); + const internal = vi.fn(); + const router = new RpcRouter<{ user: string }>({ + authorize: (context, call) => { + if (!call.public && context.user !== 'alice') throw new RpcError('UNAUTHORIZED', 'login'); + }, + onError: internal, + }); + router.implement(Files, { + methods: { + read: ({ path }) => ({ content: new TextEncoder().encode(path), size: path.length }), + fail: () => { + throw new Error('database exploded'); + }, + version: () => '1.0.0', + }, + streams: { + changes: ({ glob }, _context, sink) => { + emitters.set(glob, (path) => sink.emit({ path })); + return toDisposable(() => { + emitters.delete(glob); + cleanup(glob); + }); + }, + status: (_params, _context, sink) => { + emitStatus = (n) => sink.emit(n); + }, + }, + }); + return { router, emitters, cleanup, internal, emitStatus: (n: number) => emitStatus(n) }; +}; + +describe('RPC calls', () => { + it('calls typed methods with binary payloads', async () => { + const { router } = createRouter(); + const { client } = connect(router); + const result = await client.api(Files).read({ path: 'main.ts' }); + expect(new TextDecoder().decode(result.content)).toBe('main.ts'); + expect(result.size).toBe(7); + }); + + it('validates inputs', async () => { + const { router } = createRouter(); + const { client } = connect(router); + await expect(client.api(Files).read({ path: '' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('hides internal errors and reports them', async () => { + const { router, internal } = createRouter(); + const { client } = connect(router); + const error = await client + .api(Files) + .fail(null) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(RpcError); + expect(error).toMatchObject({ code: 'INTERNAL', message: 'Internal error' }); + expect(internal).toHaveBeenCalledOnce(); + }); + + it('authorizes non public methods', async () => { + const { router } = createRouter(); + const { client } = connect(router, { user: 'mallory' }); + await expect(client.api(Files).version(null)).resolves.toBe('1.0.0'); + await expect(client.api(Files).read({ path: 'x' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + await expect(client.call('fs.missing', null)).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); +}); + +describe('RPC streams', () => { + it('delivers events until unsubscribed', async () => { + const { router, emitters, cleanup } = createRouter(); + const { client } = connect(router); + const events: string[] = []; + const subscription = client.subscribe(Files, 'changes', { glob: '**/*.ts' }, (e) => + events.push(e.path), + ); + await flush(); + emitters.get('**/*.ts')!('a.ts'); + await flush(); + subscription.dispose(); + await flush(); + expect(events).toEqual(['a.ts']); + expect(cleanup).toHaveBeenCalledWith('**/*.ts'); + }); + + it('resubscribes after a reconnection', async () => { + const { router, emitters } = createRouter(); + const { client, sockets } = connect(router); + const events: string[] = []; + const reconnected = vi.fn(); + client.onDidReconnect(reconnected); + client.subscribe(Files, 'changes', { glob: '*' }, (e) => events.push(e.path)); + await flush(); + sockets[0]!.close(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(sockets).toHaveLength(2); + expect(reconnected).toHaveBeenCalledOnce(); + emitters.get('*')!('after.ts'); + await flush(); + expect(events).toEqual(['after.ts']); + expect(client.state.get()).toBe('open'); + }); + + it('reports stream errors', async () => { + const { router } = createRouter(); + const { client } = connect(router, { user: 'mallory' }); + const onError = vi.fn(); + client.subscribe(Files, 'changes', { glob: '*' }, () => undefined, { onError }); + await flush(); + await flush(); + expect(onError.mock.calls[0]![0]).toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('coalesces snapshot streams under back-pressure', async () => { + const { router, emitStatus } = createRouter(); + const { client, sockets } = connect(router); + const values: number[] = []; + client.subscribe(Files, 'status', null, (n) => values.push(n)); + await flush(); + const socket = sockets[0]!; + socket.backpressure = true; + /** Accepted, then the socket reports back-pressure. */ + emitStatus(1); + emitStatus(2); + emitStatus(3); + socket.backpressure = false; + socket.session!.drain(); + await flush(); + expect(values).toEqual([1, 3]); + }); + + it('closes dead connections through the heartbeat', async () => { + vi.useFakeTimers(); + const { router } = createRouter(); + const { client, sockets } = connect(router, undefined, { heartbeatMs: 1000 }); + client.subscribe(Files, 'changes', { glob: '*' }, () => undefined); + await vi.advanceTimersByTimeAsync(0); + sockets[0]!.session!.receive = async () => undefined; + await vi.advanceTimersByTimeAsync(2100); + expect(sockets[0]!.readyState).toBe(3); + vi.useRealTimers(); + }); +}); + +describe('RpcRouter.implement', () => { + const Greeter = defineContract('greeter', { + methods: { hi: { input: z.null(), output: z.string() } }, + streams: { ticks: { params: z.null(), event: z.number() } }, + }); + + it('registers nothing when a handler is missing', () => { + const router = new RpcRouter(); + expect(() => router.implement(Greeter, { methods: { hi: () => 'hi' } } as never)).toThrow( + 'Missing stream handler for greeter.ticks', + ); + expect(() => + router.implement(Greeter, { methods: { hi: () => 'hi' }, streams: { ticks: () => {} } }), + ).not.toThrow(); + }); + + it('accepts a hand-written contract without streams', () => { + const router = new RpcRouter(); + const contract = { namespace: 'plain', methods: { hi: Greeter.methods.hi } }; + const implement = () => + router.implement(contract as never, { methods: { hi: () => 'hi' } } as never); + const registration = implement(); + expect(implement).toThrow('RPC "plain.hi" is already implemented'); + registration.dispose(); + expect(implement).not.toThrow(); + }); +}); + +describe('RpcClient timeouts', () => { + /** A server that accepts the request and never answers. */ + const silent = () => + new RpcClient({ + baseUrl: 'http://editor.test', + fetch: ((_url: unknown, init?: RequestInit) => + new Promise((_resolve, reject) => + init?.signal?.addEventListener('abort', () => reject(init.signal!.reason)), + )) as typeof fetch, + }); + + it('gives up after timeoutMs with a TIMEOUT error', async () => { + await expect(silent().api(Files).version(null, { timeoutMs: 20 })).rejects.toMatchObject({ + code: 'TIMEOUT', + }); + }); + + it('reports a cancelled call as cancelled, not timed out', async () => { + const controller = new AbortController(); + const call = silent().api(Files).version(null, { signal: controller.signal, timeoutMs: 1000 }); + controller.abort(); + await expect(call).rejects.toMatchObject({ code: 'CANCELLED' }); + }); +}); diff --git a/packages/rpc/tsconfig.json b/packages/rpc/tsconfig.json new file mode 100644 index 00000000..f2fa7f24 --- /dev/null +++ b/packages/rpc/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/runtime/.prettierignore b/packages/runtime/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/runtime/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/runtime/LICENSE b/packages/runtime/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/runtime/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/runtime/README.md b/packages/runtime/README.md new file mode 100644 index 00000000..fd22d02b --- /dev/null +++ b/packages/runtime/README.md @@ -0,0 +1,37 @@ +
+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-runtime` is an internal package of the NanoForge Editor. + +Game runtime: build orchestration, client runner, server process manager. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/runtime +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/runtime/package.json b/packages/runtime/package.json new file mode 100644 index 00000000..84d360c0 --- /dev/null +++ b/packages/runtime/package.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-runtime", + "version": "0.0.0", + "description": "NanoForge Editor - Game runtime: build orchestration, client runner, server process manager", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/runtime" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty .", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts" + }, + "dependencies": { + "@jridgewell/trace-mapping": "catalog:libs", + "@nanoforge-dev/editor-kernel": "workspace:*", + "@nanoforge-dev/editor-protocol": "workspace:*", + "@nanoforge-dev/editor-rpc": "workspace:*", + "@nanoforge-dev/editor-settings": "workspace:*", + "zod": "catalog:libs" + }, + "devDependencies": { + "@nanoforge-dev/editor-server-core": "workspace:*", + "@types/node": "catalog:core", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/runtime/src/bridge/bridge-emitter.type.ts b/packages/runtime/src/bridge/bridge-emitter.type.ts new file mode 100644 index 00000000..b3e97e87 --- /dev/null +++ b/packages/runtime/src/bridge/bridge-emitter.type.ts @@ -0,0 +1,9 @@ +/** Engine `EventEmitter` shape (`@nanoforge-dev/common`), the two sides of the editor bridge. */ +export interface BridgeEmitter { + on(event: string, listener: (...args: unknown[]) => void): void; + off(event: string, listener: (...args: unknown[]) => void): void; + emit(event: string, ...args: unknown[]): void; + runEvents(): void; +} + +export type Listener = (...args: unknown[]) => void; diff --git a/packages/runtime/src/bridge/direct-emitter.ts b/packages/runtime/src/bridge/direct-emitter.ts new file mode 100644 index 00000000..2e61feb5 --- /dev/null +++ b/packages/runtime/src/bridge/direct-emitter.ts @@ -0,0 +1,19 @@ +import type { BridgeEmitter } from './bridge-emitter.type'; + +/** + * Engine → editor: delivered to the editor in a microtask (nothing drains it), so editor code + * never runs inside an engine tick (the engine tells game console output from the editor's). + */ +export class DirectEmitter implements BridgeEmitter { + constructor(private readonly _deliver: (event: string, args: unknown[]) => void) {} + + on(): void {} + + off(): void {} + + emit(event: string, ...args: unknown[]): void { + queueMicrotask(() => this._deliver(event, args)); + } + + runEvents(): void {} +} diff --git a/packages/runtime/src/bridge/index.ts b/packages/runtime/src/bridge/index.ts new file mode 100644 index 00000000..453da406 --- /dev/null +++ b/packages/runtime/src/bridge/index.ts @@ -0,0 +1,3 @@ +export { type BridgeEmitter } from './bridge-emitter.type'; +export * from './queued-emitter'; +export * from './direct-emitter'; diff --git a/packages/runtime/src/bridge/queued-emitter.ts b/packages/runtime/src/bridge/queued-emitter.ts new file mode 100644 index 00000000..82b2e7fa --- /dev/null +++ b/packages/runtime/src/bridge/queued-emitter.ts @@ -0,0 +1,36 @@ +import type { BridgeEmitter, Listener } from './bridge-emitter.type'; + +/** Editor → engine: queued, drained by the engine at the start of each tick. */ +export class QueuedEmitter implements BridgeEmitter { + private readonly _listeners = new Map(); + private _queue: [string, unknown[]][] = []; + + on(event: string, listener: Listener): void { + this._listeners.set(event, [...(this._listeners.get(event) ?? []), listener]); + } + + off(event: string, listener: Listener): void { + this._listeners.set( + event, + (this._listeners.get(event) ?? []).filter((registered) => registered !== listener), + ); + } + + emit(event: string, ...args: unknown[]): void { + this._queue.push([event, args]); + } + + runEvents(): void { + const pending = this._queue; + this._queue = []; + for (const [event, args] of pending) { + for (const listener of this._listeners.get(event) ?? []) { + try { + listener(...args); + } catch (error) { + console.error(error); + } + } + } + } +} diff --git a/packages/runtime/src/client/game-client-runner.ts b/packages/runtime/src/client/game-client-runner.ts new file mode 100644 index 00000000..21237dea --- /dev/null +++ b/packages/runtime/src/client/game-client-runner.ts @@ -0,0 +1,135 @@ +import { serializeLogValue } from '@nanoforge-dev/editor-kernel'; +import type { OutputManifest } from '@nanoforge-dev/editor-protocol'; + +import { DirectEmitter } from '../bridge/direct-emitter'; +import { QueuedEmitter } from '../bridge/queued-emitter'; +import type { ClientRunTarget, ClientRunnerOptions } from './game-client-runner.type'; + +let runs = 0; + +/** Files of a build as the loader gives them to the game: `/path` → versioned URL. */ +export const clientFiles = (manifest: OutputManifest, origin: string): Map => + new Map( + manifest.files.map((file) => [ + `/${file.path}`, + `${origin}${manifest.baseUrl}${file.path}?v=${file.hash}`, + ]), + ); + +/** + * Runs a built client in the editor page. Every run imports a fresh module instance (module + * state never leaks between runs) into its own container, which is removed on stop. + */ +export class GameClientRunner { + private readonly _fromEditor = new QueuedEmitter(); + private _mount: HTMLDivElement | undefined; + private _bridged = false; + /** `main` was called and has not returned yet: the game may not have said hello yet. */ + private _starting = false; + private _stopped = false; + private _stopWaiters: (() => void)[] = []; + private _removeErrorListeners: (() => void) | undefined; + + constructor(private readonly _options: ClientRunnerOptions) {} + + /** Whether the game answered with the editor bridge hello. */ + get bridged(): boolean { + return this._bridged; + } + + /** Imports and starts the game; resolves when its `main` resolved. */ + async start(target: ClientRunTarget): Promise { + const { manifest, origin } = target; + const baseUrl = `${origin}${manifest.baseUrl}`; + const mount = target.host.ownerDocument.createElement('div'); + mount.dataset.nfGame = manifest.app; + mount.style.cssText = 'position:absolute;inset:0;overflow:hidden'; + target.host.append(mount); + this._mount = mount; + this._watchErrors(baseUrl); + + const entry = manifest.files.find((file) => file.path === manifest.entry); + const url = `${baseUrl}${manifest.entry}?v=${entry?.hash ?? manifest.version}&run=${++runs}`; + const module = await this._options.importModule(url); + if (typeof module.main !== 'function') throw new Error(`${manifest.entry} exports no main`); + const toEditor = new DirectEmitter((event, args) => this._onEngineEvent(event, args)); + this._starting = true; + try { + await module.main({ + container: mount, + files: clientFiles(manifest, origin), + env: target.env, + editor: { + toEditor, + fromEditor: this._fromEditor, + serializeLogValue: (value) => serializeLogValue(value), + }, + }); + } finally { + this._starting = false; + } + } + + send(event: string, args: readonly unknown[] = []): void { + this._fromEditor.emit(event, ...args); + } + + /** + * Asks the game to stop and waits for its `stopped` state (libraries cleared), then removes + * its container. Returns false when the game did not stop in time (it may still be running). + * A game still in its `main` gets the request too: it is queued, and an engine with the editor + * bridge reads it at its first tick (it says hello then). + */ + async stop(timeoutMs = 3000): Promise { + let clean = this._stopped; + if (!clean && (this._bridged || this._starting)) { + this.send('stop'); + clean = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), timeoutMs); + this._stopWaiters.push(() => { + clearTimeout(timer); + resolve(true); + }); + }); + } + this._mount?.remove(); + this._mount = undefined; + this._removeErrorListeners?.(); + this._removeErrorListeners = undefined; + return clean; + } + + private _onEngineEvent(event: string, args: unknown[]): void { + if (event === 'hello') this._bridged = true; + if (event === 'state' && args[0] === 'stopped') { + this._stopped = true; + for (const waiter of this._stopWaiters.splice(0)) waiter(); + } + this._options.onEvent(event, args); + } + + /** Reports uncaught errors whose stack points into the game bundle. */ + private _watchErrors(baseUrl: string): void { + const target = + this._options.errorTarget ?? + ('addEventListener' in globalThis ? (globalThis as unknown as Window) : undefined); + if (!target) return; + const fromGame = (error: unknown, filename?: string) => + filename?.startsWith(baseUrl) || + (error instanceof Error && (error.stack ?? '').includes(baseUrl)); + const onError = (event: Event) => { + const { error, filename } = event as ErrorEvent; + if (fromGame(error, filename)) this._options.onError(error ?? (event as ErrorEvent).message); + }; + const onRejection = (event: Event) => { + const { reason } = event as PromiseRejectionEvent; + if (fromGame(reason)) this._options.onError(reason); + }; + target.addEventListener('error', onError); + target.addEventListener('unhandledrejection', onRejection); + this._removeErrorListeners = () => { + target.removeEventListener('error', onError); + target.removeEventListener('unhandledrejection', onRejection); + }; + } +} diff --git a/packages/runtime/src/client/game-client-runner.type.ts b/packages/runtime/src/client/game-client-runner.type.ts new file mode 100644 index 00000000..1526574c --- /dev/null +++ b/packages/runtime/src/client/game-client-runner.type.ts @@ -0,0 +1,39 @@ +import type { LogValue } from '@nanoforge-dev/editor-kernel'; +import type { OutputManifest } from '@nanoforge-dev/editor-protocol'; + +import type { BridgeEmitter } from '../bridge/bridge-emitter.type'; + +/** What a built client exports (`ClientRunOptions` of the engine). */ +export interface GameClientModule { + main(options: { + container: HTMLDivElement; + files: Map; + env: Record; + editor: { + toEditor: BridgeEmitter; + fromEditor: BridgeEmitter; + /** Snapshots logged values in the editor's format (the engine has no serializer of its own). */ + serializeLogValue: (value: unknown) => LogValue; + }; + }): Promise | void; +} + +export interface ClientRunnerOptions { + /** Loads the game bundle (`import()` in browsers). */ + readonly importModule: (url: string) => Promise; + /** Engine → editor events (`hello`, `state`, custom events). */ + readonly onEvent: (event: string, args: unknown[]) => void; + /** Uncaught errors thrown by the game code. */ + readonly onError: (error: unknown) => void; + /** Where uncaught errors are observed (defaults to `globalThis`). */ + readonly errorTarget?: Pick; +} + +export interface ClientRunTarget { + /** Element the game container is added to. */ + readonly host: HTMLElement; + readonly manifest: OutputManifest; + readonly env: Record; + /** Origin the build output is served from (`location.origin`). */ + readonly origin: string; +} diff --git a/packages/runtime/src/client/index.ts b/packages/runtime/src/client/index.ts new file mode 100644 index 00000000..1dfb3aaf --- /dev/null +++ b/packages/runtime/src/client/index.ts @@ -0,0 +1,2 @@ +export * from './game-client-runner.type'; +export * from './game-client-runner'; diff --git a/packages/runtime/src/compatibility/check-compatibility.ts b/packages/runtime/src/compatibility/check-compatibility.ts new file mode 100644 index 00000000..e765cfdd --- /dev/null +++ b/packages/runtime/src/compatibility/check-compatibility.ts @@ -0,0 +1,43 @@ +import { RUNTIME_PROTOCOL_VERSION } from '@nanoforge-dev/editor-protocol'; + +import type { GameCompatibility } from './game-compatibility.type'; + +const ADD_LIBRARY = + 'Register the editor library in its main.ts: app.use(new EditorLibrary()), from @nanoforge-dev/editor-lib (NanoForge engine 2.0 and later).'; +const UPDATE_ENGINE = + 'Update the NanoForge engine packages of the project (@nanoforge-dev/core and the other @nanoforge-dev libraries).'; + +/** Compares the `hello` of a game (undefined when it never said hello) with the editor. */ +export const checkCompatibility = ( + source: 'client' | 'server', + app: string, + hello: { protocolVersion?: number } | undefined, + editorVersion = RUNTIME_PROTOCOL_VERSION, +): GameCompatibility => { + const side = source === 'client' ? 'game client' : 'game server'; + if (!hello) { + return { + source, + app, + status: 'legacy', + message: `The ${side} has no editor bridge: Pause, Step and the live views are unavailable${source === 'client' ? ", and Stop can't end it (it keeps running until Run › Reload editor runtime)" : ''}. ${ADD_LIBRARY}`, + }; + } + const version = hello.protocolVersion ?? 0; + if (version === editorVersion) return { source, app, status: 'ok', protocolVersion: version }; + return version < editorVersion + ? { + source, + app, + status: 'older', + protocolVersion: version, + message: `The ${side} speaks editor protocol ${version}, older than this editor (${editorVersion}): some editor features may not work. ${UPDATE_ENGINE}`, + } + : { + source, + app, + status: 'newer', + protocolVersion: version, + message: `The ${side} speaks editor protocol ${version}, newer than this editor (${editorVersion}): update the NanoForge editor.`, + }; +}; diff --git a/packages/runtime/src/compatibility/game-compatibility.type.ts b/packages/runtime/src/compatibility/game-compatibility.type.ts new file mode 100644 index 00000000..58477505 --- /dev/null +++ b/packages/runtime/src/compatibility/game-compatibility.type.ts @@ -0,0 +1,15 @@ +/** How well a running game and the editor understand each other. */ +export interface GameCompatibility { + readonly source: 'client' | 'server'; + readonly app: string; + /** + * - `ok`: same protocol. + * - `legacy`: the game never said `hello`: it does not register the editor library + * (`EditorLibrary` of `@nanoforge-dev/editor-lib`), or its engine predates the bridge. + * - `older` / `newer`: the engine speaks an older / newer protocol than the editor. + */ + readonly status: 'ok' | 'legacy' | 'older' | 'newer'; + readonly protocolVersion?: number; + /** What it means and what to do, for the user (absent when `ok`). */ + readonly message?: string; +} diff --git a/packages/runtime/src/compatibility/index.ts b/packages/runtime/src/compatibility/index.ts new file mode 100644 index 00000000..42067925 --- /dev/null +++ b/packages/runtime/src/compatibility/index.ts @@ -0,0 +1,3 @@ +export * from './game-compatibility.type'; +export * from './check-compatibility'; +export * from './merge-features'; diff --git a/packages/runtime/src/compatibility/merge-features.ts b/packages/runtime/src/compatibility/merge-features.ts new file mode 100644 index 00000000..107dc7c8 --- /dev/null +++ b/packages/runtime/src/compatibility/merge-features.ts @@ -0,0 +1,51 @@ +import type { EngineFeatures } from '@nanoforge-dev/editor-protocol'; + +/** + * Merges the engine features requested by the editor and plugins: the shortest frame stats + * interval, logs if anyone wants them, the highest network trace rates and payload size, the + * shortest live world and scenes intervals. + */ +export const mergeFeatures = (requests: readonly EngineFeatures[]): EngineFeatures => { + const merged: EngineFeatures = {}; + for (const request of requests) { + if (request.frameStats) { + merged.frameStats = { + intervalMs: Math.min( + merged.frameStats?.intervalMs ?? Infinity, + request.frameStats.intervalMs, + ), + }; + } + if (request.logs) merged.logs = true; + if (request.viewport) merged.viewport = true; + if (request.ecsWorld) { + merged.ecsWorld = { + intervalMs: Math.min(merged.ecsWorld?.intervalMs ?? Infinity, request.ecsWorld.intervalMs), + }; + } + if (request.scenes) { + merged.scenes = { + intervalMs: Math.min(merged.scenes?.intervalMs ?? Infinity, request.scenes.intervalMs), + }; + } + if (request.ecsSystemStats) { + merged.ecsSystemStats = { + intervalMs: Math.min( + merged.ecsSystemStats?.intervalMs ?? Infinity, + request.ecsSystemStats.intervalMs, + ), + }; + } + if (request.networkTrace) { + const current = merged.networkTrace ?? {}; + merged.networkTrace = { + sampleRate: Math.max(current.sampleRate ?? 0, request.networkTrace.sampleRate ?? 1), + maxPerSecond: Math.max(current.maxPerSecond ?? 0, request.networkTrace.maxPerSecond ?? 100), + ...((current.maxBytes ?? request.networkTrace.maxBytes) !== undefined && { + maxBytes: Math.max(current.maxBytes ?? 0, request.networkTrace.maxBytes ?? 0), + }), + }; + } + } + return merged; +}; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts new file mode 100644 index 00000000..88390595 --- /dev/null +++ b/packages/runtime/src/index.ts @@ -0,0 +1,6 @@ +export * from './bridge'; +export * from './client'; +export * from './compatibility'; +export * from './service'; +export * from './settings'; +export * from './source-map'; diff --git a/packages/runtime/src/service/index.ts b/packages/runtime/src/service/index.ts new file mode 100644 index 00000000..37f5a536 --- /dev/null +++ b/packages/runtime/src/service/index.ts @@ -0,0 +1,2 @@ +export * from './runtime-service.type'; +export * from './runtime-service'; diff --git a/packages/runtime/src/service/play.exception.ts b/packages/runtime/src/service/play.exception.ts new file mode 100644 index 00000000..1ccb4210 --- /dev/null +++ b/packages/runtime/src/service/play.exception.ts @@ -0,0 +1,10 @@ +import type { BuildDiagnostic } from '@nanoforge-dev/editor-protocol'; + +export class PlayError extends Error { + constructor( + message: string, + readonly diagnostics?: readonly BuildDiagnostic[], + ) { + super(message); + } +} diff --git a/packages/runtime/src/service/runtime-service.ts b/packages/runtime/src/service/runtime-service.ts new file mode 100644 index 00000000..f23566b1 --- /dev/null +++ b/packages/runtime/src/service/runtime-service.ts @@ -0,0 +1,595 @@ +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + type LogValue, + type Observable, + ObservableValue, + createToken, +} from '@nanoforge-dev/editor-kernel'; +import { + type AppModel, + type BuildStatus, + type EngineFeatures, + type EngineFrameStats, + RUNTIME_PROTOCOL_VERSION, + RuntimeContract, + type RuntimeEvent, +} from '@nanoforge-dev/editor-protocol'; + +import { GameClientRunner } from '../client/game-client-runner'; +import type { GameClientModule } from '../client/game-client-runner.type'; +import { checkCompatibility } from '../compatibility/check-compatibility'; +import type { GameCompatibility } from '../compatibility/game-compatibility.type'; +import { mergeFeatures } from '../compatibility/merge-features'; +import { SourceMaps } from '../source-map/source-maps'; +import type { SourceLocation } from '../source-map/source-maps.type'; +import { PlayError } from './play.exception'; +import type { + GameEvent, + PlayMode, + PlaySession, + PlayState, + RuntimeLog, + RuntimeServiceOptions, +} from './runtime-service.type'; + +const IDLE: PlaySession = { + state: 'idle', + mode: undefined, + apps: [], + controllable: false, + compatibility: [], +}; + +/** What the editor itself asks every game for (plugins add theirs with `useFeatures`). */ +const EDITOR_FEATURES: EngineFeatures = { frameStats: { intervalMs: 1000 } }; +const ACTIVE: readonly PlayState[] = ['building', 'starting', 'running', 'paused', 'stopping']; + +export const isPlaying = (state: PlayState): boolean => ACTIVE.includes(state); + +/** + * Builds and plays the games of a project: the client runs in the Game screen of the editor, + * the server in a process of the editor server. Plugins observe the session, listen to game + * events and send commands through it. + */ +export class RuntimeService implements Disposable { + /** Bridge protocol version the editor speaks. */ + readonly protocolVersion = RUNTIME_PROTOCOL_VERSION; + + private readonly _session = new ObservableValue(IDLE); + private readonly _builds = new ObservableValue>(new Map()); + private readonly _onLog = new Emitter(); + private readonly _onEvent = new Emitter(); + /** The last `ecs-world` of each game: the engine sends it again only when it changes. */ + private readonly _worlds = new Map(); + private readonly _onServerStateChange = new Emitter<[string, number | null | undefined]>(); + private readonly _host = new ObservableValue(undefined); + private readonly _store = new DisposableStore(); + private readonly _engineStates = new Map(); + private readonly _hellos = new Map(); + private readonly _featureRequests = new Set([EDITOR_FEATURES]); + private readonly _frameStats = new ObservableValue>( + new Map(), + ); + private _stage: HTMLElement | undefined; + private _client: { app: string; runner: GameClientRunner } | undefined; + private _server: string | undefined; + private _serverBridged = false; + private _run = 0; + private readonly _sourceMaps: SourceMaps; + + readonly onLog: Event = this._onLog.event; + readonly onEvent: Event = this._onEvent.event; + + constructor(private readonly _options: RuntimeServiceOptions) { + this._sourceMaps = new SourceMaps({ + projectId: _options.projectId, + apps: _options.apps, + origin: _options.origin ?? globalThis.location?.origin ?? '', + ...(_options.fetchText && { fetchText: _options.fetchText }), + }); + this._store.add( + _options.rpc.subscribe(RuntimeContract, 'events', { project: _options.projectId }, (event) => + this._onServerEvent(event), + ), + ); + void this._api + .status({ project: _options.projectId }) + .then(({ builds }) => this._builds.set(new Map(builds.map((build) => [build.app, build])))) + .catch(() => undefined); + } + + get session(): Observable { + return this._session.readonly(); + } + + /** Last build of each app. */ + get builds(): Observable> { + return this._builds.readonly(); + } + + /** + * The project file behind a position in a built game bundle (a stack frame), through the + * bundle's source map. `file` is the bundle's URL (client) or path (server). Undefined when + * the build has no source map or the position is not in a project file. + */ + sourceLocation(file: string, line: number, column?: number): Promise { + return this._sourceMaps.locate(file, line, column); + } + + /** Latest frame stats of each running game, keyed `client` / `server`. */ + get frameStats(): Observable> { + return this._frameStats.readonly(); + } + + /** + * Asks the running games (and the next ones) for engine features: frame stats, logs, + * network traces. Requests of the editor and plugins are merged; dispose to withdraw. + */ + /** + * The live world a game last sent in this run (`ecs-world`). A listener that starts while + * the game is paused gets no event (a world that does not change is not sent again): it + * reads this one first. + */ + lastWorlds(): readonly GameEvent[] { + return [...this._worlds.values()]; + } + + useFeatures(features: EngineFeatures): Disposable { + const request = { ...features }; + this._featureRequests.add(request); + this._sendWelcomes(); + return { + dispose: () => { + if (this._featureRequests.delete(request)) this._sendWelcomes(); + }, + }; + } + + /** Element hosting the client game (set by the Game screen). */ + get host(): Observable { + return this._host.readonly(); + } + + /** + * The Game screen gives the element the client shows in. Games render in a stage element + * owned by the service and moved between views, so a game keeps running (and keeps its + * canvas) while the Game screen is hidden. + */ + attach(host: HTMLElement): Disposable { + if (!this._stage) { + this._stage = host.ownerDocument.createElement('div'); + this._stage.dataset.nfGameStage = ''; + this._stage.style.cssText = 'position:relative;width:100%;height:100%;overflow:hidden'; + } + host.append(this._stage); + this._host.set(host); + return { + dispose: () => { + if (this._host.get() !== host) return; + this._stage?.remove(); + this._host.set(undefined); + }, + }; + } + + /** Mode `play` uses by default for this project. */ + defaultMode(): PlayMode { + const setting = this._options.playMode?.() ?? 'auto'; + if (setting !== 'auto') return setting; + return this._app('server') ? 'server+client' : 'client'; + } + + /** Builds then starts the games of the mode. Resolves once they run (or crashed). */ + async play(mode: PlayMode = this.defaultMode()): Promise { + if (isPlaying(this._session.get().state)) return; + const run = ++this._run; + const client = mode === 'server' ? undefined : this._app('client'); + const server = mode === 'client' ? undefined : this._app('server'); + const apps = [server, client].flatMap((app) => (app ? [app.id] : [])); + this._engineStates.clear(); + this._hellos.clear(); + this._worlds.clear(); + this._frameStats.set(new Map()); + this._set({ state: 'building', mode, apps, controllable: false, compatibility: [] }); + try { + if (mode !== 'server' && !client) throw new PlayError('This project has no client app'); + if (mode !== 'client' && !server) throw new PlayError('This project has no server app'); + const statuses = await this._api.build({ project: this._options.projectId, apps }); + const failed = statuses.filter((status) => status.state === 'error'); + if (failed.length) { + throw new PlayError( + 'The game has build errors', + failed.flatMap((status) => status.diagnostics), + ); + } + if (run !== this._run) return; + this._set({ state: 'starting' }); + if (server) await this._startServer(server, run); + if (client && run === this._run) await this._startClient(client, run); + if (run !== this._run) return; + await this._hellosOf([ + ...(server ? ['server' as const] : []), + ...(client ? ['client' as const] : []), + ]); + if (run !== this._run) return; + const compatibility = this._compatibility(); + for (const check of compatibility) { + if (check.message) this._log(check.source, check.app, 'warn', check.message); + } + this._set({ state: 'running', controllable: this._controllable(), compatibility }); + } catch (error) { + if (run !== this._run) return; + await this._teardown(); + this._crash(error); + } + } + + pause(): void { + this.send('pause'); + } + + resume(): void { + this.send('resume'); + } + + /** Runs one tick of every paused game. */ + step(): void { + this.send('step'); + } + + /** Stops every game; resolves once they stopped. */ + async stop(): Promise { + const { state } = this._session.get(); + if (state === 'idle' || state === 'stopping') return; + this._run++; + if (state === 'crashed') { + this._set(IDLE); + return; + } + this._set({ state: 'stopping' }); + await this._teardown(); + this._frameStats.set(new Map()); + this._set(IDLE); + } + + async restart(): Promise { + const mode = this._session.get().mode; + await this.stop(); + await this.play(mode); + } + + /** Sends an editor → engine event to the running games (all by default). */ + send(event: string, args: readonly unknown[] = [], target: 'client' | 'server' | 'all' = 'all') { + if (target !== 'server') this._client?.runner.send(event, args); + if (target !== 'client' && this._server) { + void this._api + .sendServer({ project: this._options.projectId, app: this._server, event, args: [...args] }) + .catch((error: unknown) => this._options.logger.warn(`Could not send ${event}`, error)); + } + } + + dispose(): void { + this._run++; + void this._teardown(); + this._store.dispose(); + this._onLog.dispose(); + this._onEvent.dispose(); + this._onServerStateChange.dispose(); + } + + private get _api() { + return this._options.rpc.api(RuntimeContract); + } + + /** The apps of a type that can be played. */ + playable(type: 'client' | 'server'): AppModel[] { + return this._options.apps().filter((app) => app.type === type && app.outDir !== undefined); + } + + /** The app Play starts for a type: the chosen one, else the first. */ + appFor(type: 'client' | 'server'): AppModel | undefined { + const apps = this.playable(type); + const selected = this._options.selectedApp?.(type); + return apps.find((app) => app.id === selected) ?? apps[0]; + } + + private _app(type: 'client' | 'server'): AppModel | undefined { + return this.appFor(type); + } + + private _set(patch: Partial): void { + const next = { ...this._session.get(), ...patch }; + if (next.state !== 'crashed') { + delete (next as { error?: string }).error; + delete (next as { diagnostics?: unknown }).diagnostics; + } + this._session.set(next); + } + + private _crash(error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + this._set({ + state: 'crashed', + controllable: false, + error: message, + ...(error instanceof PlayError && error.diagnostics && { diagnostics: error.diagnostics }), + }); + this._options.logger.error(`Game stopped: ${message}`); + } + + /** How each playing game and the editor understand each other, from the hellos received. */ + private _compatibility(): GameCompatibility[] { + return [ + ...(this._server + ? [checkCompatibility('server', this._server, this._hellos.get('server'))] + : []), + ...(this._client + ? [checkCompatibility('client', this._client.app, this._hellos.get('client'))] + : []), + ]; + } + + /** + * Waits for the hello of each game, for a short while: one that never says it has no editor + * bridge, and is played without pause. + */ + private _hellosOf(sources: readonly ('client' | 'server')[]): Promise { + const missing = () => sources.some((source) => !this._hellos.has(source)); + if (!missing()) return Promise.resolve(); + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + listener.dispose(); + resolve(); + }; + const timer = setTimeout(done, this._options.helloTimeoutMs ?? 1500); + const listener = this._onEvent.event(({ event }) => { + if (event === 'hello' && !missing()) done(); + }); + }); + } + + private _controllable(): boolean { + return (!this._client || this._client.runner.bridged) && (!this._server || this._serverBridged); + } + + private async _startServer(app: AppModel, run: number): Promise { + this._server = app.id; + this._serverBridged = false; + const started = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new PlayError('The game server did not start in time')), + this._options.serverStartTimeoutMs ?? 15_000, + ); + const listener = this._onServerState((state, exitCode) => { + if (state === 'running' || state === 'exited' || run !== this._run) { + clearTimeout(timer); + listener.dispose(); + if (state === 'exited') { + reject(new PlayError(`The game server exited (code ${exitCode ?? 'unknown'})`)); + } else resolve(); + } + }); + }); + started.catch(() => undefined); + await Promise.all([ + this._api.startServer({ + project: this._options.projectId, + app: app.id, + overrides: this._options.envOverrides?.() ?? {}, + }), + started, + ]); + } + + private async _startClient(app: AppModel, run: number): Promise { + if (!this._host.get()) await this._waitForHost(); + const [manifest, env] = await Promise.all([ + this._api.manifest({ project: this._options.projectId, app: app.id }), + this._api.env({ + project: this._options.projectId, + app: app.id, + overrides: this._options.envOverrides?.() ?? {}, + }), + ]); + if (run !== this._run) return; + const runner = new GameClientRunner({ + importModule: + this._options.importModule ?? + ((url) => import(/* @vite-ignore */ url) as Promise), + onEvent: (event, args) => this._onGameEvent('client', app.id, event, args), + onError: (error) => this._onClientError(app.id, error), + }); + this._client = { app: app.id, runner }; + await runner.start({ + host: this._stage!, + manifest, + env, + origin: this._options.origin ?? globalThis.location.origin, + }); + } + + private _waitForHost(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + subscription(); + reject(new PlayError('Open the Game screen to play the client')); + }, 10_000); + const subscription = this._host.subscribe((host) => { + if (!host) return; + clearTimeout(timer); + queueMicrotask(() => subscription()); + resolve(); + }); + }); + } + + private async _teardown(): Promise { + const client = this._client; + const server = this._server; + this._client = undefined; + this._server = undefined; + await Promise.all([ + client?.runner.stop().then((clean) => { + if (clean) return; + this._options.logger.warn( + client.runner.bridged + ? 'The game client did not stop in time; use Run > Reload editor runtime if it keeps running.' + : "This game's engine cannot be stopped by the editor (no editor bridge): it keeps running hidden until Run > Reload editor runtime. Update the NanoForge engine to fix it.", + ); + }), + server && + this._api + .stopServer({ project: this._options.projectId, app: server }) + .catch((error: unknown) => + this._options.logger.warn('Could not stop the game server', error), + ), + ]); + } + + private _onServerState(listener: (state: string, exitCode?: number | null) => void): Disposable { + return this._onServerStateChange.event(([state, code]) => listener(state, code)); + } + + private _onServerEvent(event: RuntimeEvent): void { + switch (event.type) { + case 'build': { + const builds = new Map(this._builds.get()); + builds.set(event.status.app, event.status); + this._builds.set(builds); + if (event.status.state !== 'building') this._sourceMaps.invalidate(event.status.app); + return; + } + case 'log': + if (event.source === 'server') { + const level = + event.stream !== 'stderr' + ? 'info' + : /\b(error|exception|uncaught|fatal|panic)\b|^\s+at\s/i.test(event.text) + ? 'error' + : 'warn'; + this._log('server', event.app, level, event.text); + } else { + const level = /^\s*error\b/i.test(event.text) + ? 'error' + : /^\s*warn(ing)?\b/i.test(event.text) + ? 'warn' + : 'info'; + this._log(event.source, event.app, level, event.text); + } + return; + case 'bridge': + if (this._session.get().apps.includes(event.app)) { + if (event.event === 'hello') this._serverBridged = true; + this._onGameEvent('server', event.app, event.event, event.args); + } + return; + case 'server': + if (event.app !== this._server) return; + this._onServerStateChange.fire([event.state, event.exitCode]); + if ( + event.state === 'exited' && + ['starting', 'running', 'paused'].includes(this._session.get().state) + ) { + const run = ++this._run; + void this._teardown().then(() => { + if (run === this._run) + this._crash(new PlayError(`The game server exited (code ${event.exitCode ?? '?'})`)); + }); + } + return; + } + } + + private _onGameEvent( + source: 'client' | 'server', + app: string, + event: string, + args: unknown[], + ): void { + if (event === 'hello') { + this._hellos.set(source, (args[0] as { protocolVersion?: number } | undefined) ?? {}); + this._sendWelcome(source); + if (this._session.get().state === 'running') { + this._set({ controllable: this._controllable(), compatibility: this._compatibility() }); + } + } + if (event === 'frame-stats' && args[0]) { + const stats = new Map(this._frameStats.get()); + stats.set(source, args[0] as EngineFrameStats); + this._frameStats.set(stats); + } + if (event === 'log' && source === 'client') { + const { level, message, values, caller } = (args[0] ?? {}) as { + level?: string; + message?: string; + values?: unknown; + caller?: string; + }; + const frame = typeof caller === 'string' ? /^(.+?):(\d+):(\d+)$/.exec(caller) : null; + this._onLog.fire({ + source: 'client', + app, + level: level === 'error' || level === 'warn' || level === 'debug' ? level : 'info', + text: String(message ?? ''), + ...(Array.isArray(values) && { values: values as LogValue[] }), + ...(frame && { + location: { file: frame[1]!, line: Number(frame[2]), column: Number(frame[3]) }, + }), + }); + } + if (event === 'state' && typeof args[0] === 'string') { + this._engineStates.set(`${source}:${app}`, args[0]); + this._syncPause(); + } + if (event === 'ecs-world') this._worlds.set(source, { source, app, event, args }); + this._onEvent.fire({ source, app, event, args }); + } + + private _sendWelcomes(): void { + for (const source of this._hellos.keys()) this._sendWelcome(source as 'client' | 'server'); + } + + /** + * Answers the hello of a game with the protocol version and the merged features. Console + * output is forwarded by clients only (with the logged values, when the engine can send + * them): the output of game servers is already streamed. + */ + private _sendWelcome(source: 'client' | 'server'): void { + const features = mergeFeatures([...this._featureRequests]); + const welcome = { + protocolVersion: this.protocolVersion, + features: { ...features, logs: source === 'client' ? { values: true } : false }, + }; + this.send('welcome', [welcome], source); + } + + /** The session is paused when every game reports it is paused. */ + private _syncPause(): void { + const { state } = this._session.get(); + if (state !== 'running' && state !== 'paused') return; + const states = [...this._engineStates.values()]; + const paused = states.length > 0 && states.every((value) => value === 'paused'); + this._set({ state: paused ? 'paused' : 'running' }); + } + + private _onClientError(app: string, error: unknown): void { + const text = error instanceof Error ? (error.stack ?? error.message) : String(error); + this._log('client', app, 'error', text); + } + + private _log( + source: RuntimeLog['source'], + app: string, + level: RuntimeLog['level'], + text: string, + ) { + if (!text.trim()) return; + this._onLog.fire({ source, app, level, text }); + } +} + +export const RuntimeServiceToken = createToken('runtime.service'); diff --git a/packages/runtime/src/service/runtime-service.type.ts b/packages/runtime/src/service/runtime-service.type.ts new file mode 100644 index 00000000..76b1aaf0 --- /dev/null +++ b/packages/runtime/src/service/runtime-service.type.ts @@ -0,0 +1,71 @@ +import type { LogLocation, LogValue, Logger } from '@nanoforge-dev/editor-kernel'; +import type { AppModel, BuildDiagnostic } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; + +import type { GameClientModule } from '../client/game-client-runner.type'; +import type { GameCompatibility } from '../compatibility/game-compatibility.type'; + +/** What runs on play: the client in the Game screen, the game server, or both. */ +export type PlayMode = 'client' | 'server' | 'server+client'; + +export type PlayState = + 'idle' | 'building' | 'starting' | 'running' | 'paused' | 'stopping' | 'crashed'; + +export interface PlaySession { + readonly state: PlayState; + readonly mode: PlayMode | undefined; + /** Apps being played. */ + readonly apps: readonly string[]; + /** Every running game answered the bridge hello: pause, resume and step are available. */ + readonly controllable: boolean; + /** Protocol check of each running game (set once they run). */ + readonly compatibility: readonly GameCompatibility[]; + /** Why the session crashed. */ + readonly error?: string; + readonly diagnostics?: readonly BuildDiagnostic[]; +} + +export interface RuntimeLog { + /** `cli`: a command the editor server ran in the project (`app` is then its command line). */ + readonly source: 'build' | 'server' | 'client' | 'cli'; + readonly app: string; + readonly level: 'debug' | 'info' | 'warn' | 'error'; + readonly text: string; + /** The logged values (game clients whose engine sends them): `text` is their one-line form. */ + readonly values?: readonly LogValue[]; + /** Where the game logged it: a position in its built bundle (see `sourceLocation`). */ + readonly location?: LogLocation; +} + +/** An engine → editor event of a running game. */ +export interface GameEvent { + readonly source: 'client' | 'server'; + readonly app: string; + readonly event: string; + readonly args: readonly unknown[]; +} + +export interface RuntimeServiceOptions { + readonly projectId: string; + readonly rpc: RpcClient; + /** Apps of the project (current project model). */ + readonly apps: () => readonly AppModel[]; + readonly logger: Logger; + /** Default play mode (`runtime.playMode` setting); `auto` plays the server when there is one. */ + readonly playMode?: () => PlayMode | 'auto'; + /** + * The app to play of a type, by id, in a project with several clients or servers + * (`runtime.clientApp`, `runtime.serverApp`). Nothing, or an app that is gone: the first one. + */ + readonly selectedApp?: (type: 'client' | 'server') => string | undefined; + /** `NANOFORGE_*` overrides (`runtime.env` setting). */ + readonly envOverrides?: () => Record; + readonly importModule?: (url: string) => Promise; + readonly origin?: string; + /** Reads a file of the build output (source maps); `fetch` by default. */ + readonly fetchText?: (url: string) => Promise; + /** How long a started game may take to say hello before it is played without the bridge. */ + readonly helloTimeoutMs?: number; + /** How long a started game server may take to report it runs. */ + readonly serverStartTimeoutMs?: number; +} diff --git a/packages/runtime/src/settings/index.ts b/packages/runtime/src/settings/index.ts new file mode 100644 index 00000000..38ad1d70 --- /dev/null +++ b/packages/runtime/src/settings/index.ts @@ -0,0 +1 @@ +export * from './runtime-settings'; diff --git a/packages/runtime/src/settings/runtime-settings.ts b/packages/runtime/src/settings/runtime-settings.ts new file mode 100644 index 00000000..cec7e8ce --- /dev/null +++ b/packages/runtime/src/settings/runtime-settings.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +import { defineSetting } from '@nanoforge-dev/editor-settings'; + +/** Settings of the game runtime (play controls). */ +export const RuntimeSettings = { + playMode: defineSetting<'auto' | 'client' | 'server' | 'server+client'>({ + key: 'runtime.playMode', + schema: z.enum(['auto', 'client', 'server', 'server+client']), + default: 'auto', + title: 'Play mode', + description: + 'What Play starts: the client, the game server, or both (auto: both when the project has a server).', + category: 'Runtime', + scopes: ['account', 'machine', 'project', 'projectLocal'], + }), + clientApp: defineSetting({ + key: 'runtime.clientApp', + schema: z.string(), + default: '', + title: 'Client app', + description: + 'The client Play starts, in a project with several (its folder, like apps/client). Empty: the first one.', + category: 'Runtime', + scopes: ['project', 'projectLocal'], + }), + serverApp: defineSetting({ + key: 'runtime.serverApp', + schema: z.string(), + default: '', + title: 'Server app', + description: + 'The server Play starts, in a project with several (its folder, like apps/server). Empty: the first one.', + category: 'Runtime', + scopes: ['project', 'projectLocal'], + }), + saveBeforePlay: defineSetting({ + key: 'runtime.saveBeforePlay', + schema: z.boolean(), + default: true, + title: 'Save all before Play', + description: 'Play saves every open file first, so the game always matches what you see.', + category: 'Runtime', + }), + env: defineSetting({ + key: 'runtime.env', + schema: z.record(z.string().regex(/^NANOFORGE_\w+$/), z.string()), + default: {}, + title: 'Environment overrides', + description: + 'NANOFORGE_* variables applied on top of the project .env when playing (NANOFORGE_CLIENT_*, NANOFORGE_SERVER_*).', + category: 'Runtime', + scopes: ['project', 'projectLocal'], + mergeStrategy: 'deep', + }), +}; diff --git a/packages/runtime/src/source-map/index.ts b/packages/runtime/src/source-map/index.ts new file mode 100644 index 00000000..aac5e428 --- /dev/null +++ b/packages/runtime/src/source-map/index.ts @@ -0,0 +1,2 @@ +export * from './source-maps.type'; +export * from './source-maps'; diff --git a/packages/runtime/src/source-map/source-maps.ts b/packages/runtime/src/source-map/source-maps.ts new file mode 100644 index 00000000..c9e98379 --- /dev/null +++ b/packages/runtime/src/source-map/source-maps.ts @@ -0,0 +1,95 @@ +import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping'; + +import { type AppModel, decodeRuntimeApp, runtimeOutputPath } from '@nanoforge-dev/editor-protocol'; + +import type { SourceLocation, SourceMapsOptions } from './source-maps.type'; + +const defaultFetch = async (url: string): Promise => { + const response = await fetch(url); + return response.ok ? response.text() : undefined; +}; + +/** Joins POSIX paths and resolves `.` and `..`; undefined when it leaves the root. */ +const resolvePath = (...parts: string[]): string | undefined => { + const segments: string[] = []; + for (const segment of parts.join('/').split('/')) { + if (!segment || segment === '.') continue; + if (segment !== '..') segments.push(segment); + else if (!segments.pop()) return undefined; + } + return segments.join('/'); +}; + +/** + * Maps positions in built game bundles back to project files, through the source maps written + * next to the bundles (`nf build --editor`). A bundle is named by its URL (clients, imported + * from the editor server) or by its path on disk (servers). + */ +export class SourceMaps { + private readonly _maps = new Map>(); + + constructor(private readonly _options: SourceMapsOptions) {} + + /** Forgets the maps of an app (it was rebuilt). */ + invalidate(app: string): void { + for (const key of this._maps.keys()) if (key.startsWith(`${app}\n`)) this._maps.delete(key); + } + + async locate(file: string, line: number, column = 1): Promise { + const bundle = this._bundle(file); + if (!bundle) return undefined; + const map = await this._map(bundle.app, bundle.file); + if (!map) return undefined; + const position = originalPositionFor(map, { line, column: Math.max(column - 1, 0) }); + if (!position.source || position.line === null) return undefined; + const folder = bundle.file.split('/').slice(0, -1); + const path = resolvePath(bundle.app.outDir ?? '', ...folder, position.source); + if (path === undefined || /(^|\/)node_modules\//.test(path)) return undefined; + return { path, line: position.line, column: (position.column ?? 0) + 1 }; + } + + /** The app and output file a bundle URL or path belongs to. */ + private _bundle(file: string): { app: AppModel; file: string } | undefined { + const apps = this._options.apps().filter((app) => app.outDir !== undefined); + const prefix = `/runtime/${this._options.projectId}/`; + let pathname = file; + if (/^https?:\/\//.test(file)) { + try { + pathname = new URL(file).pathname; + } catch { + return undefined; + } + if (!pathname.startsWith(prefix)) return undefined; + const [segment, ...rest] = pathname.slice(prefix.length).split('/'); + const id = decodeRuntimeApp(segment ?? ''); + const app = apps.find((candidate) => candidate.id === id); + return app && rest.length ? { app, file: rest.map(decodeURIComponent).join('/') } : undefined; + } + pathname = pathname.replace(/^file:\/\//, '').replace(/\\/g, '/'); + const owner = apps + .map((app) => ({ app, at: pathname.lastIndexOf(`/${app.outDir}/`) })) + .filter(({ at }) => at >= 0) + .sort((a, b) => b.app.outDir!.length - a.app.outDir!.length)[0]; + if (!owner) return undefined; + return { + app: owner.app, + file: pathname.slice(owner.at + owner.app.outDir!.length + 2), + }; + } + + private _map(app: AppModel, file: string): Promise { + const key = `${app.id}\n${file}`; + let map = this._maps.get(key); + if (!map) { + const url = `${this._options.origin}${runtimeOutputPath(this._options.projectId, app.id)}${file + .split('/') + .map(encodeURIComponent) + .join('/')}.map`; + map = (this._options.fetchText ?? defaultFetch)(url) + .then((text) => (text ? new TraceMap(text) : undefined)) + .catch(() => undefined); + this._maps.set(key, map); + } + return map; + } +} diff --git a/packages/runtime/src/source-map/source-maps.type.ts b/packages/runtime/src/source-map/source-maps.type.ts new file mode 100644 index 00000000..5c082a12 --- /dev/null +++ b/packages/runtime/src/source-map/source-maps.type.ts @@ -0,0 +1,17 @@ +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +/** A position in a project file (1-based line and column). */ +export interface SourceLocation { + readonly path: string; + readonly line: number; + readonly column: number; +} + +export interface SourceMapsOptions { + readonly projectId: string; + readonly apps: () => readonly AppModel[]; + /** Origin the build output is served from. */ + readonly origin: string; + /** Reads a served file; undefined when it doesn't exist. */ + readonly fetchText?: (url: string) => Promise; +} diff --git a/packages/runtime/test/client/game-client-runner.spec.ts b/packages/runtime/test/client/game-client-runner.spec.ts new file mode 100644 index 00000000..943cb9ae --- /dev/null +++ b/packages/runtime/test/client/game-client-runner.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { GameClientRunner } from '../../src/client/game-client-runner'; +import type { GameClientModule } from '../../src/client/game-client-runner.type'; + +/** A host element just good enough for the runner (no DOM in node tests). */ +const host = () => { + const element = { dataset: {}, style: {}, remove: () => undefined }; + return { + ownerDocument: { createElement: () => element }, + append: () => undefined, + } as unknown as HTMLElement; +}; + +const manifest = { + app: 'client', + baseUrl: '/out/client/', + entry: 'main.js', + version: '1', + files: [{ path: 'main.js', hash: 'h' }], +}; + +describe('GameClientRunner', () => { + it('stops a game still in its main, which reads the request at its first tick', async () => { + let tick: (() => void) | undefined; + const game: GameClientModule = { + main: async ({ editor }) => { + let stopped = false; + editor.fromEditor.on('stop', () => (stopped = true)); + tick = () => { + editor.fromEditor.runEvents(); + if (stopped) editor.toEditor.emit('state', 'stopped'); + }; + await new Promise(() => undefined); + }, + }; + const runner = new GameClientRunner({ + importModule: async () => game, + onEvent: () => undefined, + onError: () => undefined, + errorTarget: { addEventListener: () => undefined, removeEventListener: () => undefined }, + }); + void runner.start({ + host: host(), + manifest: manifest as never, + env: {}, + origin: 'http://editor.test', + }); + await expect.poll(() => tick).toBeDefined(); + + const stopping = runner.stop(1000); + tick!(); + expect(await stopping).toBe(true); + }); +}); diff --git a/packages/runtime/test/compatibility/check-compatibility.spec.ts b/packages/runtime/test/compatibility/check-compatibility.spec.ts new file mode 100644 index 00000000..3d1a7705 --- /dev/null +++ b/packages/runtime/test/compatibility/check-compatibility.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { checkCompatibility } from '../../src/compatibility/check-compatibility'; + +describe('checkCompatibility', () => { + it('tells legacy, older and newer engines apart', () => { + expect(checkCompatibility('client', 'c', { protocolVersion: 2 }, 2).status).toBe('ok'); + expect(checkCompatibility('client', 'c', undefined, 2)).toMatchObject({ + status: 'legacy', + message: expect.stringContaining('app.use(new EditorLibrary())'), + }); + expect(checkCompatibility('server', 's', { protocolVersion: 1 }, 2)).toMatchObject({ + status: 'older', + message: expect.stringContaining('Update the NanoForge engine'), + }); + expect(checkCompatibility('server', 's', { protocolVersion: 3 }, 2)).toMatchObject({ + status: 'newer', + message: expect.stringContaining('update the NanoForge editor'), + }); + }); +}); diff --git a/packages/runtime/test/compatibility/merge-features.spec.ts b/packages/runtime/test/compatibility/merge-features.spec.ts new file mode 100644 index 00000000..332e4b34 --- /dev/null +++ b/packages/runtime/test/compatibility/merge-features.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeFeatures } from '../../src/compatibility/merge-features'; + +describe('mergeFeatures', () => { + it('keeps the most demanding request of each feature', () => { + expect( + mergeFeatures([ + { frameStats: { intervalMs: 1000 } }, + { frameStats: { intervalMs: 250 }, networkTrace: { sampleRate: 0.1 } }, + { logs: true, networkTrace: { maxPerSecond: 500 } }, + ]), + ).toEqual({ + frameStats: { intervalMs: 250 }, + logs: true, + networkTrace: { sampleRate: 1, maxPerSecond: 500 }, + }); + expect( + mergeFeatures([{ ecsWorld: { intervalMs: 200 } }, { ecsWorld: { intervalMs: 100 } }]), + ).toEqual({ + ecsWorld: { intervalMs: 100 }, + }); + expect( + mergeFeatures([ + { ecsSystemStats: { intervalMs: 500 }, networkTrace: { maxBytes: 64 } }, + { ecsSystemStats: { intervalMs: 250 }, networkTrace: { maxBytes: 512 } }, + { networkTrace: {} }, + ]), + ).toEqual({ + ecsSystemStats: { intervalMs: 250 }, + networkTrace: { sampleRate: 1, maxPerSecond: 100, maxBytes: 512 }, + }); + expect(mergeFeatures([{}, { viewport: true }])).toEqual({ viewport: true }); + expect( + mergeFeatures([{ scenes: { intervalMs: 250 } }, { scenes: { intervalMs: 100 } }]), + ).toEqual({ scenes: { intervalMs: 100 } }); + expect(mergeFeatures([])).toEqual({}); + }); +}); diff --git a/packages/runtime/test/service/runtime-service.spec.ts b/packages/runtime/test/service/runtime-service.spec.ts new file mode 100644 index 00000000..5b704039 --- /dev/null +++ b/packages/runtime/test/service/runtime-service.spec.ts @@ -0,0 +1,314 @@ +import { chmodSync, cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { LoggerService } from '@nanoforge-dev/editor-kernel'; +import { type ProjectModel, ProjectsContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcClient } from '@nanoforge-dev/editor-rpc'; +import { type EditorServer, createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core'; +import { + TEST_ORIGIN, + createLoopbackClient, + openTrustedProject, +} from '@nanoforge-dev/editor-server-core/testing'; + +import type { GameClientModule } from '../../src/client/game-client-runner.type'; +import { RuntimeService } from '../../src/service/runtime-service'; +import type { GameEvent, PlaySession, RuntimeLog } from '../../src/service/runtime-service.type'; + +const SERVER_CORE_TEST = join(import.meta.dirname, '../../../server-core/test'); +const GAME = join(SERVER_CORE_TEST, 'fixtures/runtime/bridge-game.js'); + +/** A view as the Game screen gives it (with a minimal DOM). */ +const fakeHost = () => { + interface FakeElement { + dataset: Record; + style: object; + children: FakeElement[]; + removed: boolean; + ownerDocument: typeof document; + append(child: FakeElement): void; + remove(): void; + } + const created: FakeElement[] = []; + const document = { + createElement: (): FakeElement => { + const element: FakeElement = { + dataset: {}, + style: {}, + children: [], + removed: false, + ownerDocument: document, + append: (child) => { + child.removed = false; + element.children.push(child); + }, + remove: () => (element.removed = true), + }; + created.push(element); + return element; + }, + }; + const host = document.createElement(); + /** Game containers created by the runs (the stage holds them). */ + const mounts = () => created.filter((element) => 'nfGame' in element.dataset); + return { host, mounts }; +}; + +let workspace: string; +let editor: EditorServer; +let client: RpcClient; +let model: ProjectModel; +const imported: { url: string; options: Parameters[0] }[] = []; + +/** Loads the served bundle like a browser would (fresh module instance per run). */ +const importModule = async (url: string): Promise => { + const response = (await editor.fetch(new Request(url)))!; + if (response.status !== 200) throw new Error(`GET ${url}: ${response.status}`); + const source = `${await response.text()}\n// ${url}`; + const module = (await import( + `data:text/javascript,${encodeURIComponent(source)}` + )) as GameClientModule; + return { + main: (options) => { + imported.push({ url, options }); + return module.main(options); + }, + }; +}; + +const createService = (overrides: Partial[0]> = {}) => + new RuntimeService({ + projectId: model.id, + rpc: client, + apps: () => model.apps, + logger: new LoggerService().getLogger('runtime'), + importModule, + origin: TEST_ORIGIN, + ...overrides, + }); + +const until = async (service: RuntimeService, state: PlaySession['state']) => { + for (const start = Date.now(); service.session.get().state !== state;) { + if (Date.now() - start > 10_000) { + throw new Error(`Still ${service.session.get().state}, expected ${state}`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +}; + +beforeAll(async () => { + workspace = mkdtempSync(join(SERVER_CORE_TEST, '.tmp-play-')); + const root = join(workspace, 'pong'); + cpSync(join(SERVER_CORE_TEST, 'fixtures/pong-network'), root, { recursive: true }); + cpSync(GAME, join(root, 'apps/client/game.js')); + cpSync(GAME, join(root, 'apps/server/game.js')); + writeFileSync(join(root, '.env'), 'NANOFORGE_CLIENT_PORT=1\nNANOFORGE_SERVER_PORT=2\n'); + const cli = join(workspace, 'nf.cjs'); + cpSync(join(SERVER_CORE_TEST, 'fixtures/runtime/nf.cjs'), cli); + chmodSync(cli, 0o755); + editor = createEditorServer({ + env: loadEnv( + { + NODE_ENV: 'test', + FS_ROOT: workspace, + DATA_DIR: join(mkdtempSync(join(tmpdir(), 'nf-data-')), 'editor'), + NF_CLI_PATH: cli, + }, + workspace, + ), + version: '0.0.0-test', + }); + client = createLoopbackClient(editor); + const id = await openTrustedProject(client, 'pong'); + model = await client.api(ProjectsContract).model({ id }); +}); + +afterAll(() => { + client.dispose(); + editor.dispose(); + rmSync(workspace, { recursive: true, force: true }); +}); + +describe('RuntimeService', () => { + it('plays server and client, pauses, steps and stops them, three times', async () => { + const service = createService(); + const { host, mounts } = fakeHost(); + service.attach(host as unknown as HTMLElement); + const events: GameEvent[] = []; + service.onEvent((event) => events.push(event)); + const logs: RuntimeLog[] = []; + service.onLog((log) => logs.push(log)); + const states: string[] = []; + service.session.subscribe((session) => { + if (states.at(-1) !== session.state) states.push(session.state); + }); + + expect(service.defaultMode()).toBe('server+client'); + for (let run = 1; run <= 3; run++) { + imported.length = 0; + await service.play(); + expect(service.session.get().error).toBeUndefined(); + expect(service.session.get()).toMatchObject({ + state: 'running', + mode: 'server+client', + apps: ['apps/server', 'apps/client'], + controllable: true, + }); + const { url, options } = imported[0]!; + expect(url).toMatch(new RegExp(`/runtime/${model.id}/apps%2Fclient/main\\.js\\?v=\\w+&run=`)); + expect(options.env).toEqual({ PORT: '1' }); + expect(options.files.get('/data.txt')).toMatch( + /^http:\/\/editor\.localhost\/runtime\/.+data\.txt\?v=\w+$/, + ); + expect(mounts()).toHaveLength(run); + expect(mounts()[run - 1]!.dataset.nfGame).toBe('apps/client'); + + service.pause(); + await until(service, 'paused'); + service.step(); + await expect + .poll(() => events.filter((event) => event.event === 'stepped').map((e) => e.source)) + .toEqual(expect.arrayContaining(['client', 'server'])); + events.length = 0; + service.resume(); + await until(service, 'running'); + + expect(service.session.get().compatibility.map((check) => check.status)).toEqual([ + 'ok', + 'ok', + ]); + await expect + .poll(() => [...service.frameStats.get().keys()].sort()) + .toEqual(['client', 'server']); + await expect + .poll(() => logs.filter((log) => log.source === 'client' && log.text === 'from the game')) + .toHaveLength(run); + + await service.stop(); + expect(service.session.get().state).toBe('idle'); + expect(mounts().at(-1)!.removed).toBe(true); + expect( + events.filter((event) => event.event === 'state' && event.args[0] === 'stopped'), + ).toHaveLength(2); + expect( + await editor.runtime + .get(editor.projects.get({ mode: 'OFFLINE' } as never, model.id)) + .servers(), + ).toEqual([]); + } + expect(states.slice(0, 7)).toEqual([ + 'idle', + 'building', + 'starting', + 'running', + 'paused', + 'running', + 'stopping', + ]); + service.dispose(); + }); + + it('plays the chosen client and server of a project with several', () => { + const second = (type: 'client' | 'server') => { + const first = model.apps.find((app) => app.type === type)!; + return { ...first, id: `apps/${type}-2`, root: `apps/${type}-2`, name: `${type}-2` }; + }; + const apps = [...model.apps, second('client'), second('server')]; + let chosen: Record = {}; + const service = createService({ apps: () => apps, selectedApp: (type) => chosen[type] }); + expect(service.playable('client').map((app) => app.id)).toEqual([ + 'apps/client', + 'apps/client-2', + ]); + expect(service.appFor('client')?.id).toBe('apps/client'); + expect(service.appFor('server')?.id).toBe('apps/server'); + chosen = { client: 'apps/client-2', server: 'apps/server-2' }; + expect(service.appFor('client')?.id).toBe('apps/client-2'); + expect(service.appFor('server')?.id).toBe('apps/server-2'); + chosen = { client: 'apps/gone' }; + expect(service.appFor('client')?.id).toBe('apps/client'); + service.dispose(); + }); + + it('moves the game stage between Game views', () => { + const service = createService(); + const first = fakeHost().host; + const view = service.attach(first as unknown as HTMLElement); + const stage = first.children[0]!; + view.dispose(); + expect(stage.removed).toBe(true); + expect(service.host.get()).toBeUndefined(); + + const second = fakeHost().host; + service.attach(second as unknown as HTMLElement); + expect(second.children).toEqual([stage]); + expect(stage.removed).toBe(false); + service.dispose(); + }); + + it('plays games whose engine does not speak the bridge, without pause', async () => { + const legacy = join(SERVER_CORE_TEST, 'fixtures/runtime/legacy-game.js'); + for (const app of ['client', 'server']) { + cpSync(legacy, join(workspace, `pong/apps/${app}/game.js`)); + } + const service = createService({ serverStartTimeoutMs: 3000, helloTimeoutMs: 100 }); + const { mounts, host } = fakeHost(); + service.attach(host as unknown as HTMLElement); + for (let run = 1; run <= 2; run++) { + await service.play('server+client'); + expect(service.session.get().error).toBeUndefined(); + expect(service.session.get()).toMatchObject({ state: 'running', controllable: false }); + expect(service.session.get().compatibility).toEqual([ + expect.objectContaining({ source: 'server', status: 'legacy' }), + expect.objectContaining({ + source: 'client', + status: 'legacy', + message: expect.stringContaining('Reload editor runtime'), + }), + ]); + await service.stop(); + expect(service.session.get().state).toBe('idle'); + expect(mounts().at(-1)!.removed).toBe(true); + } + for (const app of ['client', 'server']) + cpSync(GAME, join(workspace, `pong/apps/${app}/game.js`)); + service.dispose(); + }); + + it('crashes with the build diagnostics when the game does not build', async () => { + const file = join(workspace, 'pong/apps/client/game.js'); + writeFileSync(file, '// SYNTAX ERROR\n'); + const service = createService(); + service.attach(fakeHost().host as unknown as HTMLElement); + await service.play('client'); + expect(service.session.get()).toMatchObject({ + state: 'crashed', + error: 'The game has build errors', + diagnostics: [{ path: 'apps/client/game.js', message: 'Unexpected ;' }], + }); + await service.stop(); + expect(service.session.get().state).toBe('idle'); + cpSync(GAME, file); + service.dispose(); + }); + + it('crashes when the game server exits while the game starts', async () => { + const service = createService({ + envOverrides: () => ({ NANOFORGE_SERVER_EXIT_BEFORE_HELLO: '1' }), + }); + await service.play('server'); + await until(service, 'crashed'); + expect(service.session.get().error).toBe('The game server exited (code 4)'); + service.dispose(); + }); + + it('crashes when the game server exits on its own', async () => { + const service = createService({ envOverrides: () => ({ NANOFORGE_SERVER_CRASH: '1' }) }); + await service.play('server'); + await until(service, 'crashed'); + expect(service.session.get().error).toBe('The game server exited (code 3)'); + service.dispose(); + }); +}); diff --git a/packages/runtime/test/source-map/source-maps.spec.ts b/packages/runtime/test/source-map/source-maps.spec.ts new file mode 100644 index 00000000..d3904b34 --- /dev/null +++ b/packages/runtime/test/source-map/source-maps.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import type { AppModel } from '@nanoforge-dev/editor-protocol'; + +import { SourceMaps } from '../../src/source-map/source-maps'; + +const app = (id: string, outDir: string): AppModel => + ({ id, name: id, type: 'client', root: id, language: 'ts', outDir }) as AppModel; + +const MAP = JSON.stringify({ + version: 3, + sources: ['../src/main.ts', '../../../node_modules/lib/index.js', '../../../../outside.ts'], + names: [], + mappings: 'AAAA;AACA;AACA;ACFA;ACAA', +}); + +const setup = () => { + const fetched: string[] = []; + const maps = new SourceMaps({ + projectId: 'p1', + origin: 'http://localhost:4790', + apps: () => [app('apps/client', 'apps/client/dist'), app('apps/server', 'apps/server/dist')], + fetchText: async (url) => { + fetched.push(url); + return url.endsWith('/main.js.map') ? MAP : undefined; + }, + }); + return { maps, fetched }; +}; + +describe('SourceMaps', () => { + it('maps a frame of a client bundle URL to the project file', async () => { + const { maps, fetched } = setup(); + const url = 'http://localhost:4790/runtime/p1/apps%2Fclient/main.js?v=ab&run=2'; + expect(await maps.locate(url, 3, 1)).toEqual({ + path: 'apps/client/src/main.ts', + line: 3, + column: 1, + }); + expect(fetched).toEqual(['http://localhost:4790/runtime/p1/apps%2Fclient/main.js.map']); + await maps.locate(url, 2, 1); + expect(fetched).toHaveLength(1); + maps.invalidate('apps/client'); + await maps.locate(url, 2, 1); + expect(fetched).toHaveLength(2); + }); + + it('maps a frame of a server bundle path', async () => { + const { maps } = setup(); + expect(await maps.locate('/home/me/pong/apps/server/dist/main.js', 2, 5)).toEqual({ + path: 'apps/server/src/main.ts', + line: 2, + column: 1, + }); + }); + + it('gives nothing for libraries, files outside the project, or bundles without a map', async () => { + const { maps } = setup(); + const url = 'http://localhost:4790/runtime/p1/apps%2Fclient/main.js'; + expect(await maps.locate(url, 4, 1)).toBeUndefined(); + expect(await maps.locate(url, 5, 1)).toBeUndefined(); + expect(await maps.locate(url, 99, 1)).toBeUndefined(); + expect( + await maps.locate('http://localhost:4790/runtime/p1/apps%2Fclient/other.js', 1, 1), + ).toBeUndefined(); + expect(await maps.locate('http://localhost:4790/runtime/p2/x/main.js', 1, 1)).toBeUndefined(); + expect(await maps.locate('/usr/lib/thing.js', 1, 1)).toBeUndefined(); + }); +}); diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json new file mode 100644 index 00000000..3c88a550 --- /dev/null +++ b/packages/runtime/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/sdk/.cliff-jumperrc.json b/packages/sdk/.cliff-jumperrc.json new file mode 100644 index 00000000..77626601 --- /dev/null +++ b/packages/sdk/.cliff-jumperrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/favware/cliff-jumper/main/assets/cliff-jumper.schema.json", + "name": "editor-sdk", + "org": "nanoforge-dev", + "packagePath": "packages/sdk", + "tagTemplate": "{{new-version}}", + "identifierBase": false +} diff --git a/packages/sdk/.prettierignore b/packages/sdk/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/sdk/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md new file mode 100644 index 00000000..6361e43e --- /dev/null +++ b/packages/sdk/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to this project will be documented in this file. diff --git a/packages/sdk/LICENSE b/packages/sdk/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 00000000..d07a785d --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,52 @@ +
+
+

+ NanoForge +

+
+

+ npm version + npm downloads + Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-sdk` is part of the NanoForge Editor. + +Public API for NanoForge editor plugins. + +## Installation + +**Node.js 26 or newer is required.** + +```sh +npm install @nanoforge-dev/editor-sdk +yarn add @nanoforge-dev/editor-sdk +pnpm add @nanoforge-dev/editor-sdk +bun add @nanoforge-dev/editor-sdk +``` + +## Links + +- [GitHub][source] +- [npm][npm] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/sdk +[npm]: https://www.npmjs.com/package/@nanoforge-dev/editor-sdk +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/sdk/cliff.toml b/packages/sdk/cliff.toml new file mode 100644 index 00000000..8da3ea14 --- /dev/null +++ b/packages/sdk/cliff.toml @@ -0,0 +1,79 @@ +[changelog] +header = """ +# Changelog + +All notable changes to this project will be documented in this file.\n +""" +body = """ +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} +{% if version %}\ + # [{{ version | trim_start_matches(pat="v") }}]\ + {% if previous %}\ + {% if previous.version %}\ + ({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})\ + {% else %}\ + ({{ self::remote_url() }}/tree/{{ version }})\ + {% endif %}\ + {% endif %} \ + - ({{ timestamp | date(format="%Y-%m-%d") }}) +{% else %}\ + # [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ## {{ group | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}\ + **{{commit.scope}}:** \ + {% endif %}\ + {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ + {% if commit.github.username %} by @{{ commit.github.username }}{%- endif %}\ + {% if commit.breaking %}\ + {% for footer in commit.footers %}\ + {% if footer.breaking %}\ + \n{% raw %} {% endraw %}- **{{ footer.token }}{{ footer.separator }}** {{ footer.value }}\ + {% endif %}\ + {% endfor %}\ + {% endif %}\ + {% endfor %} +{% endfor %}\ +{% if github.contributors | filter(attribute="is_first_time", value=true) | length %}\ + \n### New Contributors\n + {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}\ + - @{{ contributor.username }} made their first contribution in #{{ contributor.pr_number }} + {% endfor %}\ +{% endif %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = true +commit_parsers = [ + { message = "^feat", group = "Features"}, + { message = "^fix", group = "Bug Fixes"}, + { message = "^docs", group = "Documentation"}, + { message = "^perf", group = "Performance"}, + { message = "^refactor", group = "Refactor"}, + { message = "^types", group = "Typings"}, + { message = ".*deprecated", body = ".*deprecated", group = "Deprecation"}, + { message = "^revert", skip = true}, + { message = "^style", group = "Styling"}, + { message = "^test", group = "Testing"}, + { message = "^chore", skip = true}, + { message = "^ci", skip = true}, + { message = "^build", skip = true}, + { body = ".*security", group = "Security"}, +] +filter_commits = true +protect_breaking_commits = true +tag_pattern = "^[0-9]+" +ignore_tags = "" +topo_order = false +sort_commits = "newest" + +[remote.github] +owner = "NanoForge-dev" +repo = "Editor" diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000..51a71473 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-sdk", + "version": "0.0.0", + "description": "NanoForge Editor - Public API for editor plugins", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "files": [ + "dist" + ], + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json", + "./ui": "./src/ui.ts", + "./worker": "./src/worker.ts" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/sdk" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "build": "tsdown && node ./scripts/build-types.ts", + "lint": "prettier --check . && eslint --format=pretty .", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "changelog": "git cliff --prepend ./CHANGELOG.md -u -c ./cliff.toml -r ../../ --include-path 'packages/sdk/*'", + "release": "cliff-jumper" + }, + "dependencies": { + "@nanoforge-dev/editor-code": "workspace:*", + "@nanoforge-dev/editor-history": "workspace:*", + "@nanoforge-dev/editor-kernel": "workspace:*", + "@nanoforge-dev/editor-meta": "workspace:*", + "@nanoforge-dev/editor-project": "workspace:*", + "@nanoforge-dev/editor-protocol": "workspace:*", + "@nanoforge-dev/editor-rpc": "workspace:*", + "@nanoforge-dev/editor-runtime": "workspace:*", + "@nanoforge-dev/editor-settings": "workspace:*", + "@nanoforge-dev/editor-ui": "workspace:*" + }, + "devDependencies": { + "@favware/cliff-jumper": "catalog:ci", + "tsdown": "catalog:build", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/types/sdk/src/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json", + "./worker": { + "types": "./dist/types/sdk/src/worker.d.ts", + "default": "./dist/worker.js" + } + } + }, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/sdk/scripts/build-types.ts b/packages/sdk/scripts/build-types.ts new file mode 100644 index 00000000..08d9f506 --- /dev/null +++ b/packages/sdk/scripts/build-types.ts @@ -0,0 +1,29 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; + +const root = join(import.meta.dirname, '..'); +const out = join(root, 'dist', 'types'); +rmSync(out, { recursive: true, force: true }); +execFileSync(join(root, 'node_modules/.bin/tsc'), ['-p', 'tsconfig.types.json'], { + cwd: root, + stdio: 'inherit', +}); + +const walk = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + return statSync(path).isDirectory() ? walk(path) : path.endsWith('.d.ts') ? [path] : []; + }); + +const SPECIFIER = + /(['"])@nanoforge-dev\/editor-(code|history|kernel|project|protocol|rpc|settings)(\/engine)?\1/g; +for (const file of walk(out)) { + const text = readFileSync(file, 'utf8'); + const rewritten = text.replace(SPECIFIER, (_match, quote: string, name: string, sub?: string) => { + const target = relative(dirname(file), join(out, name, 'src', sub ? 'engine' : '', 'index.js')); + return `${quote}${target.startsWith('.') ? target : `./${target}`}${quote}`; + }); + if (rewritten !== text) writeFileSync(file, rewritten); +} +console.log(`SDK types written to ${relative(root, out)}`); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 00000000..786d3309 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,289 @@ +/** + * Public API of the NanoForge editor for plugins. + * + * At runtime this module is provided by the editor (plugins built with + * `@nanoforge-dev/editor-vite-plugin` never bundle it), so every plugin shares the same + * services, registries and tokens. + */ +import { + CoreServices, + type EventBus, + type ExtensionPoint, + type PluginContext, + type PluginModule, + type ServiceToken, +} from '@nanoforge-dev/editor-kernel'; + +export { + type Disposable, + DisposableObject, + DisposableStore, + MutableDisposable, + combineDisposables, + disposeAll, + isDisposable, + toDisposable, + Emitter, + type EmitterOptions, + type Event, + Events, + type Listener, + type Equality, + type Observable, + ObservableValue, + changes, + constant, + derived, + observe, + type ProvideOptions, + type ServiceAccessor, + type ServiceDecorator, + type ServiceFactory, + type ServiceToken, + createToken, + type Contribution, + type ExtensionPoint, + type Validator, + defineExtensionPoint, + type ContextKeyService, + type WhenExpression, + parseWhen, + COMMAND_METADATA, + type CommandHandler, + type CommandMetadata, + type CommandMetadataContribution, + type MessageBundle, + type MessageParams, + formatMessage, + type LogEntry, + LogLevel, + type LogLocation, + type LogValue, + type LogValueLimits, + type Logger, + type LoggerService, + formatLogValue, + isExpandableLogValue, + serializeLogValue, + type AppInfo, + type AppType, + type MissingEngineLib, + type PluginContext, + type PluginManifest, + type PluginModule, + isEligible, + missingEngineLibs, + type PluginHost, + PluginHostToken, + type PluginInfo, + switchObservable, +} from '@nanoforge-dev/editor-kernel'; + +export { + type ClientProject, + type ProjectFs, + type ProjectService, + ProjectServiceToken, + type WriteOptions, +} from '@nanoforge-dev/editor-project'; +export type { + AppModel, + FileChange, + FileContent, + FileEntry, + ProjectModel, +} from '@nanoforge-dev/editor-protocol'; +export { basename, dirname, joinPath, runtimeOutputPath } from '@nanoforge-dev/editor-protocol'; + +export { + type NewApp, + type NewLibrary, + type Undo, + WorkspaceError, + type WorkspaceIo, + createApp, + createLibrary, + librariesOf, + libraryImporters, + removeApp, + removeLibrary, + renameApp, + renameLibrary, + runnableApps, + setLibraryUse, + validateFolder, + validatePackageName, + workspaceIo, +} from '@nanoforge-dev/editor-project'; + +export { + type GitBranch, + type GitChange, + type GitCommit, + GitContract, + type GitFile, + type GitStash, + type GitStatus, +} from '@nanoforge-dev/editor-protocol'; + +export { SessionContract, type SessionInfo } from '@nanoforge-dev/editor-protocol'; + +export { + type InstalledPackage, + type PluginScope, + RegistryContract, + type RegistryItem, + type RegistrySearchResult, + type RegistrySummary, + type RegistryVersion, +} from '@nanoforge-dev/editor-protocol'; + +export { + type CatalogItem, + type CatalogService, + CatalogServiceToken, + type CatalogState, + appRefName, + itemFitsApp, + CODEGEN_TARGETS, + type CodeDiagnostic, + type CodeSymbol, + type CodeService, + CodeServiceToken, + type CodegenTarget, + DOCUMENT_EDITORS, + type DiagnosticsService, + type DocumentBackend, + DiagnosticsServiceToken, + type DocumentChange, + type DocumentEditor, + type DocumentService, + DocumentServiceToken, + type NodeRef, + type OpenDocument, + JSON_SCHEMAS, + type JsonSchemaContribution, + documentHistoryId, + editorsFor, + matchesGlob, + selectTarget, +} from '@nanoforge-dev/editor-code'; + +export type { + Element, + ElementLayout, + ElementType, + EnumMember, + ItemMeta, + ItemRef, + MetaDiagnostic, + MetaSource, + ParamGroup, + Side, +} from '@nanoforge-dev/editor-meta'; +export { ownerObjects } from '@nanoforge-dev/editor-meta/pure'; + +export { + type GameEvent, + type PlayMode, + type PlaySession, + type PlayState, + type RuntimeLog, + type SourceLocation, + type RuntimeService, + RuntimeServiceToken, + RuntimeSettings, + isPlaying, +} from '@nanoforge-dev/editor-runtime'; +export { + type BuildDiagnostic, + type BuildStatus, + type EngineFeatures, + type EngineFrameStats, + type EngineNetworkStats, + type EngineNetworkTrace, + type EngineScenes, + type EngineSystemStats, + type EngineViewport, + type EngineWorld, + type EngineWorldValue, + RUNTIME_PROTOCOL_VERSION, + type RuntimeCommand, +} from '@nanoforge-dev/editor-protocol'; + +export { + type CommandOrigin, + type CommandPreview, + HISTORY_CONTEXT_KEY, + type HistoryCommand, + type HistoryContext, + type HistoryContextInfo, + type HistoryEntry, + type HistoryService, + HistoryServiceToken, + type HistoryStack, + TEXT_INPUT_FOCUS_KEY, + type TextEdit, + type Transaction, + applyTextEdits, + compositeCommand, +} from '@nanoforge-dev/editor-history'; + +export { + CoreSettings, + REQUIRED_PLUGINS, + type MergeStrategy, + type SettingDefinition, + type SettingInspection, + type SettingScope, + type SettingsService, + SettingsServiceToken, + type SettingsRegistry, + SettingsRegistryToken, + AccountSyncToken, + type SyncStatus, + type MergeConflict, + type ImportPreview, + jsonSchemaOf, + type WritableScope, + defineSetting, +} from '@nanoforge-dev/editor-settings'; + +export { + type Contract, + type RpcApi, + type RpcClient, + RpcClientToken, + RpcError, + type RpcErrorCode, + defineContract, +} from '@nanoforge-dev/editor-rpc'; + +/** + * Editor-wide events. Extend it by module augmentation: + * + * ```ts + * declare module '@nanoforge-dev/editor-sdk' { + * interface EditorEvents { + * 'ecs.entitySelected': { app: string; entity: string }; + * } + * } + * ``` + */ + +export interface EditorEvents {} + +/** Services every editor provides, resolvable with `context.services.get(...)`. */ +export const EditorServices = { + ...CoreServices, + EventBus: CoreServices.EventBus as unknown as ServiceToken>, +} as const; + +/** Declares a plugin entry. Export the result as `default` or spread it into named exports. */ +export const definePlugin = (plugin: PluginModule): PluginModule => plugin; + +/** Helper type: the value type of an extension point. */ +export type ContributionOf

= P extends ExtensionPoint ? T : never; + +/** Helper type: the activation context, for plugin authors' own helpers. */ +export type EditorPluginContext = PluginContext; diff --git a/packages/sdk/src/ui.ts b/packages/sdk/src/ui.ts new file mode 100644 index 00000000..3c18af27 --- /dev/null +++ b/packages/sdk/src/ui.ts @@ -0,0 +1,117 @@ +/** + * UI API of the NanoForge editor for plugins: widgets and screens, menus, toolbar, status bar, + * keybindings, notifications, prompts, styles, themes and the component kit. + * + * Like the rest of the SDK, the editor provides this module at runtime: plugin widgets use the + * editor's own components and services. + */ +export { + FILE_ACTIONS, + FILE_DECORATIONS, + FILE_ICONS, + FILE_TEMPLATES, + type FileAction, + type FileDecoration, + type FileDecorationProvider, + type FileIcon, + type FileTemplate, + type TemplateFile, + CODE_EDITOR_ACTIVE_FILE, + CODE_EDITOR_SIDE_PANELS, + type CodeEditorSidePanel, + SCENE_EDITORS, + type SceneEditor, + VIEWPORT_OVERLAYS, + VIEWPORT_TOOLS, + type ViewportOverlay, + type ViewportScreen, + type ViewportTool, + KEYBINDINGS, + type Keybinding, + KeybindingSchema, + MENU_BAR, + MENU_ITEMS, + type MenuBarEntry, + type MenuItem, + MenuItemSchema, + type MountedWidget, + STATUS_BAR_ITEMS, + type StatusBarItem, + TOOLBAR_ITEMS, + type ToolbarItem, + WIDGETS, + WIDGET_VIEWS, + type WidgetDescriptor, + WidgetDescriptorSchema, + type WidgetInstance, + type WidgetView, + SETTINGS_PAGES, + type SettingsPage, + type SettingsPageApi, + type EditorAction, + actionId, + actionLabel, + isActionEnabled, + listActions, + DEFAULT_KEYMAP_PRESET, + KEYMAP_PRESETS, + type KeybindingConflict, + type KeymapOverride, + type KeymapPreset, + type ResolvedKeybinding, + type KeymapLayers, + type ShortcutRef, + bindingsConflict, + findConflicts, + isActionChanged, + resetAction, + resolveLayers, + setShortcut, + formatKeybinding, + parseKeybinding, + portableStroke, + resolveKeymap, + strokeFromEvent, + type KeybindingService, + KeybindingServiceToken, + type LayoutController, + LayoutControllerToken, + type NotificationService, + NotificationServiceToken, + type PromptService, + PromptServiceToken, + type StyleService, + StyleServiceToken, + type ThemeDefinition, + type ThemeService, + ThemeServiceToken, + THEMES, + type WorkbenchService, + WorkbenchServiceToken, + registerIcons, + resolveIcon, + Button, + Checkbox, + ColorPicker, + Combobox, + ContextMenu, + Dialog, + EmptyState, + Icon, + IconButton, + Input, + Menu, + type MenuEntry, + NumberInput, + PropertyGrid, + type PropertyField, + Select, + Splitter, + Switch, + Tabs, + Tooltip, + Tree, + type DropPosition, + type TreeNode, + Vector2Input, +} from '@nanoforge-dev/editor-ui'; diff --git a/packages/sdk/src/worker.ts b/packages/sdk/src/worker.ts new file mode 100644 index 00000000..2e54fcd8 --- /dev/null +++ b/packages/sdk/src/worker.ts @@ -0,0 +1,54 @@ +/** + * API of plugin worker entries (`entry.worker` in the manifest), running in the code worker. + * At runtime this module and `ts-morph` are provided by the editor's worker. + * + * ```ts + * import { defineWorkerPlugin } from '@nanoforge-dev/editor-sdk/worker'; + * + * export default defineWorkerPlugin({ + * activate(sdk) { + * sdk.registerAnalyzer('acme.components', ({ file, literals }) => …); + * }, + * }); + * ``` + */ +import type { WorkerPluginModule } from '@nanoforge-dev/editor-code/engine'; + +export { + type AnalyzeContext, + type Analyzer, + type CodeDiagnostic, + CodeError, + EditBuilder, + type LiteralValue, + type NodeRef, + type PrintOptions, + type TextEdit, + type TransformContext, + type Transformer, + type WorkerPluginModule, + type WorkerSdk, + literalToValue, + quoteStyle, + readExportedLiteral, + valueToLiteral, +} from '@nanoforge-dev/editor-code/engine'; + +export { + type Doc, + type DocTag, + type ItemDeclaration, + type ItemMeta, + type ItemOwner, + type ItemRef, + type OwnerContext, + type OwnerInput, + docOf, + firstTag, + hasTag, + leadingDocComments, + parseDocComment, + tagTexts, +} from '@nanoforge-dev/editor-meta'; + +export const defineWorkerPlugin = (plugin: WorkerPluginModule): WorkerPluginModule => plugin; diff --git a/packages/sdk/test/index.spec.ts b/packages/sdk/test/index.spec.ts new file mode 100644 index 00000000..888adb43 --- /dev/null +++ b/packages/sdk/test/index.spec.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { EventBus, createCoreContainer } from '@nanoforge-dev/editor-kernel'; + +import { EditorServices, definePlugin } from '../src'; + +declare module '../src' { + interface EditorEvents { + 'sdk.test': { ok: boolean }; + } +} + +describe('sdk', () => { + it('types the editor event bus with augmented EditorEvents', () => { + const services = createCoreContainer(); + const bus = services.get(EditorServices.EventBus); + expect(bus).toBeInstanceOf(EventBus); + let received = false; + bus.on('sdk.test', ({ ok }) => (received = ok)); + bus.emit('sdk.test', { ok: true }); + expect(received).toBe(true); + services.dispose(); + }); + + it('definePlugin is an identity helper', () => { + const plugin = { activate: () => undefined }; + expect(definePlugin(plugin)).toBe(plugin); + }); +}); + +describe('public API', () => { + it('only changes on purpose (update the snapshot when adding to the SDK)', async () => { + const sdk = await import('../src'); + expect(Object.keys(sdk).sort()).toMatchInlineSnapshot(` + [ + "AccountSyncToken", + "CODEGEN_TARGETS", + "COMMAND_METADATA", + "CatalogServiceToken", + "CodeServiceToken", + "CoreSettings", + "DOCUMENT_EDITORS", + "DiagnosticsServiceToken", + "DisposableObject", + "DisposableStore", + "DocumentServiceToken", + "EditorServices", + "Emitter", + "Events", + "GitContract", + "HISTORY_CONTEXT_KEY", + "HistoryServiceToken", + "JSON_SCHEMAS", + "LogLevel", + "MutableDisposable", + "ObservableValue", + "PluginHostToken", + "ProjectServiceToken", + "REQUIRED_PLUGINS", + "RUNTIME_PROTOCOL_VERSION", + "RegistryContract", + "RpcClientToken", + "RpcError", + "RuntimeServiceToken", + "RuntimeSettings", + "SessionContract", + "SettingsRegistryToken", + "SettingsServiceToken", + "TEXT_INPUT_FOCUS_KEY", + "WorkspaceError", + "appRefName", + "applyTextEdits", + "basename", + "changes", + "combineDisposables", + "compositeCommand", + "constant", + "createApp", + "createLibrary", + "createToken", + "defineContract", + "defineExtensionPoint", + "definePlugin", + "defineSetting", + "derived", + "dirname", + "disposeAll", + "documentHistoryId", + "editorsFor", + "formatLogValue", + "formatMessage", + "isDisposable", + "isEligible", + "isExpandableLogValue", + "isPlaying", + "itemFitsApp", + "joinPath", + "jsonSchemaOf", + "librariesOf", + "libraryImporters", + "matchesGlob", + "missingEngineLibs", + "observe", + "ownerObjects", + "parseWhen", + "removeApp", + "removeLibrary", + "renameApp", + "renameLibrary", + "runnableApps", + "runtimeOutputPath", + "selectTarget", + "serializeLogValue", + "setLibraryUse", + "switchObservable", + "toDisposable", + "validateFolder", + "validatePackageName", + "workspaceIo", + ] + `); + }); +}); diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 00000000..f2fa7f24 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/sdk/tsconfig.types.json b/packages/sdk/tsconfig.types.json new file mode 100644 index 00000000..8e90fb51 --- /dev/null +++ b/packages/sdk/tsconfig.types.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "sourceMap": false, + "rootDir": "..", + "outDir": "dist/types", + "types": [], + "paths": { + "@nanoforge-dev/editor-kernel": ["../kernel/src/index.ts"], + "@nanoforge-dev/editor-project": ["../project/src/index.ts"], + "@nanoforge-dev/editor-protocol": ["../protocol/src/index.ts"], + "@nanoforge-dev/editor-rpc": ["../rpc/src/index.ts"], + "@nanoforge-dev/editor-settings": ["../settings/src/index.ts"], + "@nanoforge-dev/editor-history": ["../history/src/index.ts"], + "@nanoforge-dev/editor-code": ["../code/src/index.ts"], + "@nanoforge-dev/editor-code/engine": ["../code/src/engine/index.ts"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/sdk/tsdown.config.ts b/packages/sdk/tsdown.config.ts new file mode 100644 index 00000000..58dd3912 --- /dev/null +++ b/packages/sdk/tsdown.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'tsdown'; + +/** + * JavaScript of the published SDK (types are built by scripts/build-types.ts). At runtime plugins + * never load it: the editor provides the SDK as a shared module. + */ +export default defineConfig({ + entry: ['src/index.ts', 'src/worker.ts'], + format: ['esm'], + platform: 'neutral', + target: 'es2023', + dts: false, + sourcemap: true, + clean: true, + fixedExtension: false, + deps: { + alwaysBundle: [/^@nanoforge-dev\/editor-(code|history|kernel|project|protocol|rpc|settings)$/], + neverBundle: ['semver', 'zod', 'ts-morph'], + }, +}); diff --git a/packages/server-core/.prettierignore b/packages/server-core/.prettierignore new file mode 100644 index 00000000..fed4928d --- /dev/null +++ b/packages/server-core/.prettierignore @@ -0,0 +1,14 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +bun.lock + +.turbo/ +node_modules/ +dist/ +coverage/ +CHANGELOG.md + +test/fixtures/ +test/.tmp-*/ diff --git a/packages/server-core/LICENSE b/packages/server-core/LICENSE new file mode 100644 index 00000000..46724861 --- /dev/null +++ b/packages/server-core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 NanoForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/server-core/README.md b/packages/server-core/README.md new file mode 100644 index 00000000..9168478e --- /dev/null +++ b/packages/server-core/README.md @@ -0,0 +1,37 @@ +

+
+

+ NanoForge +

+
+

+ Tests status + Last commit + Contributors +

+
+ +## About + +`@nanoforge-dev/editor-server-core` is an internal package of the NanoForge Editor. + +Editor server services: sessions, fs, cli, git, api client, plugin server entries. + +## Links + +- [GitHub][source] + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation][documentation]. +See [the contribution guide][contributing] if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to ask questions in [discussions][discussions]. + +[documentation]: https://github.com/NanoForge-dev/Editor +[discussions]: https://github.com/NanoForge-dev/Editor/discussions +[source]: https://github.com/NanoForge-dev/Editor/tree/main/packages/server-core +[contributing]: https://github.com/NanoForge-dev/Editor/blob/main/.github/CONTRIBUTING.md diff --git a/packages/server-core/package.json b/packages/server-core/package.json new file mode 100644 index 00000000..cac1cb8f --- /dev/null +++ b/packages/server-core/package.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@nanoforge-dev/editor-server-core", + "version": "0.0.0", + "description": "NanoForge Editor - Editor server services: sessions, fs, cli, git, api client, plugin server entries", + "keywords": [ + "nanoforge", + "game", + "editor" + ], + "homepage": "https://github.com/NanoForge-dev/Editor#readme", + "bugs": "https://github.com/NanoForge-dev/Editor/issues", + "license": "MIT", + "contributors": [ + "Exelo ", + "Fexkoser ", + "Harper ", + "Josephine ", + "Leo " + ], + "exports": { + ".": "./src/index.ts", + "./testing": "./src/testing.ts", + "./package.json": "./package.json" + }, + "type": "module", + "directories": { + "lib": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NanoForge-dev/Editor.git", + "directory": "packages/server-core" + }, + "funding": "https://github.com/NanoForge-dev/Editor?sponsor", + "scripts": { + "lint": "prettier --check . && eslint --format=pretty .", + "format": "prettier --write . && eslint --fix --format=pretty src", + "typecheck": "tsc -p tsconfig.json", + "test:unit": "vitest run --config ../../vitest.config.ts", + "test:contract": "vitest run --config ../../vitest.config.ts test/contract" + }, + "dependencies": { + "@nanoforge-dev/config": "catalog:config", + "@nanoforge-dev/editor-kernel": "workspace:*", + "@nanoforge-dev/editor-protocol": "workspace:*", + "@nanoforge-dev/editor-rpc": "workspace:*", + "@nanoforge-dev/registry": "workspace:*", + "chokidar": "catalog:server", + "fflate": "catalog:libs", + "ignore": "catalog:libs", + "prettier": "catalog:server", + "semver": "catalog:libs", + "typescript": "catalog:build", + "unrun": "catalog:build", + "yaml": "catalog:server", + "zod": "catalog:libs" + }, + "devDependencies": { + "@types/bun": "catalog:core", + "@types/node": "catalog:core", + "@types/semver": "catalog:libs", + "typescript": "catalog:build", + "vitest": "catalog:test" + }, + "packageManager": "pnpm@12.0.0", + "engines": { + "node": "26" + }, + "private": true, + "lint-staged": { + "**": [ + "prettier --ignore-unknown --write" + ], + "**/*.ts": [ + "eslint --fix" + ] + } +} diff --git a/packages/server-core/src/api/api-client.ts b/packages/server-core/src/api/api-client.ts new file mode 100644 index 00000000..8176f17c --- /dev/null +++ b/packages/server-core/src/api/api-client.ts @@ -0,0 +1,198 @@ +import { z } from 'zod'; + +import type { User } from '@nanoforge-dev/editor-protocol'; +import { RpcError, type RpcErrorCode } from '@nanoforge-dev/editor-rpc'; + +import type { EditorEnv } from '../env/editor-env.type'; +import type { AuthTokens, Session } from '../session/session-store'; + +const API_STATUS_CODES: Record = { + 400: 'BAD_REQUEST', + 401: 'UNAUTHORIZED', + 403: 'FORBIDDEN', + 404: 'NOT_FOUND', + 409: 'CONFLICT', + 412: 'CONFLICT', + 413: 'PAYLOAD_TOO_LARGE', +}; + +export interface ApiRequest { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + body?: unknown; + query?: Record; + /** Send the session tokens (default true). */ + auth?: boolean; + signal?: AbortSignal; +} + +export interface TokenResponse extends AuthTokens { + tokenExpiresAt: string; +} + +/** A project of the API, as `/projects` and `/editor/projects/:id` answer it. */ +export const GatewayProjectSchema = z.object({ + id: z.string().min(1), + code: z.string(), + name: z.string(), + description: z + .string() + .nullish() + .transform((value) => value ?? ''), + gatewayProjectRegistryUrl: z + .string() + .url() + .refine((url) => /^https?:$/.test(new URL(url).protocol), 'must be an http(s) URL'), + gatewayProjectRegistryMetadata: z + .object({ + dir: z + .string() + .nullish() + .transform((value) => value ?? null), + }) + .nullish() + .transform((value) => value ?? { dir: null }), + token: z + .string() + .nullish() + .transform((value) => value ?? ''), +}); +export type GatewayProject = z.output; + +/** An entry of `/projects`: only what the project list shows. */ +export const GatewayProjectSummarySchema = z.object({ + id: z.string().min(1), + name: z.string(), + description: z + .string() + .nullish() + .transform((value) => value ?? ''), +}); +export type GatewayProjectSummary = z.output; + +const ApiUserSchema = z.object({ + id: z.union([z.string(), z.number()]).transform(String), + username: z.string().nullish(), + email: z.string().nullish(), + firstName: z.string().nullish(), + lastName: z.string().nullish(), +}); + +const parseResponse = (schema: z.ZodType, value: unknown, what: string): T => { + const result = schema.safeParse(value); + if (!result.success) + throw new RpcError('UNAVAILABLE', `The NanoForge API sent an unexpected ${what}`, { + detail: result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`), + }); + return result.data; +}; + +/** + * Client of api.nanoforge.eu. Authenticated calls refresh the access token once on 401 and + * update the session tokens (the caller forwards them as cookies). + */ +export class ApiClient { + /** One refresh at a time per session: with rotation, a second one would use a spent token. */ + private readonly _refreshing = new WeakMap>(); + + constructor( + private readonly _env: EditorEnv, + private readonly _fetch: typeof fetch = globalThis.fetch.bind(globalThis), + ) {} + + async request(session: Session | null, path: string, request: ApiRequest = {}): Promise { + const auth = request.auth ?? true; + if (auth && !session?.tokens) throw new RpcError('UNAUTHORIZED', 'Not signed in'); + const sent = auth ? session!.tokens!.accessToken : null; + let response = await this._send(path, request, sent); + if (auth && response.status === 401) { + if (session!.tokens?.accessToken === sent) await this.refresh(session!); + else await this._refreshing.get(session!); + if (!session!.tokens) throw new RpcError('UNAUTHORIZED', 'Session expired, sign in again'); + response = await this._send(path, request, session!.tokens.accessToken); + } + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new RpcError( + API_STATUS_CODES[response.status] ?? + (response.status >= 500 ? 'UNAVAILABLE' : 'BAD_REQUEST'), + `NanoForge API ${request.method ?? 'GET'} ${path} failed (${response.status})`, + detail ? { detail: detail.slice(0, 500) } : undefined, + ); + } + return response.status === 204 ? (undefined as T) : ((await response.json()) as T); + } + + refresh(session: Session): Promise { + let refreshing = this._refreshing.get(session); + if (!refreshing) { + refreshing = this._refresh(session).finally(() => this._refreshing.delete(session)); + this._refreshing.set(session, refreshing); + } + return refreshing; + } + + /** The user the session's tokens belong to (refreshing them if needed); null when invalid. */ + async currentUser(session: Session): Promise { + if (!session.tokens) return null; + try { + const user = parseResponse(ApiUserSchema, await this.request(session, '/users/me'), 'user'); + const name = user.username || [user.firstName, user.lastName].filter(Boolean).join(' '); + return { + id: user.id, + name: name || user.email || 'NanoForge user', + ...(user.email && { email: user.email }), + }; + } catch (error) { + if (error instanceof RpcError && error.code === 'UNAUTHORIZED') return null; + throw error; + } + } + + async gatewayProjects(session: Session): Promise { + const projects = await this.request(session, '/projects'); + return parseResponse(z.array(GatewayProjectSummarySchema), projects, 'project list'); + } + + async gatewayProject(session: Session, id: string): Promise { + const project = await this.request(session, `/editor/projects/${encodeURIComponent(id)}`); + return parseResponse(GatewayProjectSchema, project, 'project'); + } + + private async _refresh(session: Session): Promise { + const refreshToken = session.tokens?.refreshToken; + if (!refreshToken) throw new RpcError('UNAUTHORIZED', 'Session expired, sign in again'); + const response = await this._send( + '/auth/refresh-token', + { + method: 'POST', + body: { refreshToken }, + }, + null, + ); + if (!response.ok) { + session.tokens = null; + session.user = null; + throw new RpcError('UNAUTHORIZED', 'Session expired, sign in again'); + } + const tokens = (await response.json()) as TokenResponse; + session.tokens = { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }; + return session.tokens; + } + + private _send(path: string, request: ApiRequest, accessToken: string | null): Promise { + const url = new URL(path, this._env.apiUrl); + for (const [key, value] of Object.entries(request.query ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + return this._fetch(url, { + method: request.method ?? 'GET', + headers: { + 'content-type': 'application/json', + ...(this._env.apiKey && { 'api-key': this._env.apiKey }), + ...(accessToken && { authorization: `Bearer ${accessToken}` }), + }, + ...(request.body !== undefined && { body: JSON.stringify(request.body) }), + ...(request.signal && { signal: request.signal }), + }); + } +} diff --git a/packages/server-core/src/api/index.ts b/packages/server-core/src/api/index.ts new file mode 100644 index 00000000..19601001 --- /dev/null +++ b/packages/server-core/src/api/index.ts @@ -0,0 +1 @@ +export * from './api-client'; diff --git a/packages/server-core/src/env/editor-env.type.ts b/packages/server-core/src/env/editor-env.type.ts new file mode 100644 index 00000000..d23d91e9 --- /dev/null +++ b/packages/server-core/src/env/editor-env.type.ts @@ -0,0 +1,27 @@ +import type { ApiFeature } from './env.schema'; + +export interface EditorEnv { + readonly production: boolean; + readonly mode: 'OFFLINE' | 'ONLINE'; + readonly host: string; + readonly port: number; + readonly fsRoot: string; + readonly dataDir: string; + readonly apiUrl: string; + readonly apiKey: string | undefined; + /** The API features the editor may call. */ + readonly apiFeatures: readonly ApiFeature[]; + /** Undefined: no registry to ask (the API does not have it yet). */ + readonly registryUrl: string | undefined; + /** A folder used as the registry, when set. */ + readonly registryDir: string | undefined; + readonly loginUrl: string; + readonly sessionSecret: string; + readonly cliPath: string | undefined; + readonly gitPath: string; + readonly allowedOrigins: readonly string[]; + /** Host names allowed in the `Host` header besides loopback ones. */ + readonly allowedHosts: readonly string[]; + readonly devPlugins: readonly string[]; + readonly bundledPluginsDir: string | undefined; +} diff --git a/packages/server-core/src/env/env.exception.ts b/packages/server-core/src/env/env.exception.ts new file mode 100644 index 00000000..c4325993 --- /dev/null +++ b/packages/server-core/src/env/env.exception.ts @@ -0,0 +1,6 @@ +export class EnvError extends Error { + constructor(readonly issues: string[]) { + super(`Invalid editor environment:\n${issues.map((issue) => ` - ${issue}`).join('\n')}`); + this.name = 'EnvError'; + } +} diff --git a/packages/server-core/src/env/env.schema.ts b/packages/server-core/src/env/env.schema.ts new file mode 100644 index 00000000..b910cd4b --- /dev/null +++ b/packages/server-core/src/env/env.schema.ts @@ -0,0 +1,103 @@ +import { z } from 'zod'; + +const optionalString = z + .string() + .optional() + .transform((value) => (value?.trim() ? value.trim() : undefined)); + +const list = z + .string() + .optional() + .transform((value) => + (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean), + ); + +/** `localhost`, `127.x.x.x` or `::1`. */ +const isLoopback = (host: string) => + host === 'localhost' || /^127(\.\d{1,3}){3}$/.test(host) || ['::1', '[::1]'].includes(host); + +/** + * What the NanoForge API offers the editor, beyond sign-in and projects. A feature that is not + * listed in `API_FEATURES` is not called: the editor keeps to itself for it. + * - `settings`: account settings sync (docs/api/settings-sync.md). Without it, a hosted editor + * keeps account settings on its own disk. + * - `registry`: the registry of plugins and packages (docs/api/registry.md). Without it, the + * marketplace says it is not available (unless `REGISTRY_URL` or `REGISTRY_DIR` is set). + */ +export const API_FEATURES = ['settings', 'registry'] as const; +export type ApiFeature = (typeof API_FEATURES)[number]; + +/** Environment of the editor server, validated at startup. */ +export const EnvSchema = z + .object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('production'), + /** OFFLINE: local projects on disk. ONLINE: hosted, projects synced with git gateways. */ + PUBLIC_MODE: z.enum(['OFFLINE', 'ONLINE']).default('OFFLINE'), + HOST: optionalString, + PORT: z.coerce.number().int().min(0).max(65_535).default(3000), + /** Projects must live under this directory (defaults to the working directory). */ + FS_ROOT: optionalString, + /** Editor data: recent projects, installed plugins… */ + DATA_DIR: optionalString, + API_URL: z.string().url().default('https://api.nanoforge.eu'), + API_KEY: optionalString, + /** The API features that are deployed, comma separated (`settings,registry`). None by default. */ + API_FEATURES: list, + /** The registry of plugins and packages, when it is not the API's (`registry` feature). */ + REGISTRY_URL: optionalString, + /** A folder standing in for the registry (`/@scope/name//`): tests, offline work. */ + REGISTRY_DIR: optionalString, + /** NanoForge projects website, where ONLINE users sign in. */ + PUBLIC_PM_URL: z.string().url().default('https://projects.nanoforge.eu'), + SESSION_SECRET: optionalString, + NF_CLI_PATH: optionalString, + GIT_PATH: optionalString, + /** Extra origins allowed to call the server (dev proxies). */ + ALLOWED_ORIGINS: list, + /** + * Host names the server answers to besides localhost (DNS rebinding guard), comma separated. + * A local editor reached by another name needs it; a hosted one checks it when it is set. + */ + ALLOWED_HOSTS: list, + /** Directories of local plugins under development (their `dist/` is served). */ + DEV_PLUGINS: list, + /** Directory of the plugins bundled with the editor build. */ + BUNDLED_PLUGINS_DIR: optionalString, + /** + * Lets a local (OFFLINE) editor listen on a non-loopback `HOST`. It has no sign-in and runs + * project code: only set it behind something that restricts who can connect. + */ + ALLOW_REMOTE_OFFLINE: z + .string() + .optional() + .transform((value) => value === '1' || value === 'true'), + }) + .superRefine((env, context) => { + for (const feature of env.API_FEATURES) { + if (!(API_FEATURES as readonly string[]).includes(feature)) { + context.addIssue({ + code: 'custom', + path: ['API_FEATURES'], + message: `unknown feature "${feature}" (known: ${API_FEATURES.join(', ')})`, + }); + } + } + if ( + env.PUBLIC_MODE === 'OFFLINE' && + env.HOST && + !isLoopback(env.HOST) && + !env.ALLOW_REMOTE_OFFLINE + ) { + context.addIssue({ + code: 'custom', + path: ['HOST'], + message: `a local (OFFLINE) editor has no sign-in and runs project code: it only listens on a loopback address, not "${env.HOST}". Set PUBLIC_MODE=ONLINE for a hosted editor, or ALLOW_REMOTE_OFFLINE=1 behind something that restricts who can connect`, + }); + } + if (env.PUBLIC_MODE === 'ONLINE' && !env.API_KEY) { + context.addIssue({ code: 'custom', path: ['API_KEY'], message: 'required in ONLINE mode' }); + } + }); diff --git a/packages/server-core/src/env/index.ts b/packages/server-core/src/env/index.ts new file mode 100644 index 00000000..3f0ca501 --- /dev/null +++ b/packages/server-core/src/env/index.ts @@ -0,0 +1,4 @@ +export * from './env.schema'; +export * from './editor-env.type'; +export * from './env.exception'; +export * from './load-env'; diff --git a/packages/server-core/src/env/load-env.ts b/packages/server-core/src/env/load-env.ts new file mode 100644 index 00000000..195392a1 --- /dev/null +++ b/packages/server-core/src/env/load-env.ts @@ -0,0 +1,43 @@ +import { randomBytes } from 'node:crypto'; +import { homedir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; + +import type { EditorEnv } from './editor-env.type'; +import { EnvError } from './env.exception'; +import { type ApiFeature, EnvSchema } from './env.schema'; + +export const loadEnv = ( + source: Record = process.env, + cwd = process.cwd(), +): EditorEnv => { + const result = EnvSchema.safeParse(source); + if (!result.success) { + throw new EnvError( + result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`), + ); + } + const env = result.data; + const absolute = (path: string) => (isAbsolute(path) ? path : resolve(cwd, path)); + return { + production: env.NODE_ENV === 'production', + mode: env.PUBLIC_MODE, + host: env.HOST ?? (env.PUBLIC_MODE === 'OFFLINE' ? '127.0.0.1' : '0.0.0.0'), + port: env.PORT, + fsRoot: absolute(env.FS_ROOT ?? '.'), + dataDir: absolute(env.DATA_DIR ?? join(homedir(), '.nanoforge', 'editor')), + apiUrl: env.API_URL, + apiKey: env.API_KEY, + apiFeatures: env.API_FEATURES as ApiFeature[], + registryUrl: + env.REGISTRY_URL ?? (env.API_FEATURES.includes('registry') ? env.API_URL : undefined), + registryDir: env.REGISTRY_DIR && absolute(env.REGISTRY_DIR), + loginUrl: env.PUBLIC_PM_URL, + sessionSecret: env.SESSION_SECRET ?? randomBytes(32).toString('hex'), + cliPath: env.NF_CLI_PATH, + gitPath: env.GIT_PATH ?? 'git', + allowedOrigins: env.ALLOWED_ORIGINS, + allowedHosts: env.ALLOWED_HOSTS.map((host) => host.toLowerCase()), + devPlugins: env.DEV_PLUGINS.map(absolute), + bundledPluginsDir: env.BUNDLED_PLUGINS_DIR && absolute(env.BUNDLED_PLUGINS_DIR), + }; +}; diff --git a/packages/server-core/src/fs/archive.ts b/packages/server-core/src/fs/archive.ts new file mode 100644 index 00000000..4834e4dd --- /dev/null +++ b/packages/server-core/src/fs/archive.ts @@ -0,0 +1,25 @@ +import { zip } from 'fflate'; +import { readFile } from 'node:fs/promises'; + +import type { ProjectFileSystem } from './fs-service'; + +/** Zips a folder of the project (entries hidden by `.nfignore` stay out). */ +export const archiveFolder = async ( + fs: ProjectFileSystem, + path: string, +): Promise> => { + const entries = await fs.list(path, true); + const files: Record = {}; + const prefix = path ? `${path}/` : ''; + for (const entry of entries) { + if (entry.kind !== 'file') continue; + files[entry.path.slice(prefix.length)] = new Uint8Array( + await readFile(await fs.jail.resolve(entry.path)), + ); + } + return new Promise((resolve, reject) => + zip(files, { level: 6 }, (error, data) => + error ? reject(error) : resolve(data as Uint8Array), + ), + ); +}; diff --git a/packages/server-core/src/fs/fs-service.ts b/packages/server-core/src/fs/fs-service.ts new file mode 100644 index 00000000..7fa3b1e2 --- /dev/null +++ b/packages/server-core/src/fs/fs-service.ts @@ -0,0 +1,289 @@ +import ignore, { type Ignore } from 'ignore'; +import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import type { FileContent, FileEntry } from '@nanoforge-dev/editor-protocol'; +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +import { sha1 } from '../util/hash'; +import { KeyedMutex } from '../util/keyed-mutex'; +import { PathJail } from './path-jail'; + +export const EDITOR_DIR = '.nanoforge/editor'; +export const TRASH_DIR = `${EDITOR_DIR}/trash`; +/** Hides files from the whole editor (gitignore syntax); optional. */ +export const NFIGNORE_FILE = '.nfignore'; +/** Installed bundles: usable, never modified (copy an entry into the project to change it). */ +export const PACKAGES_DIR = 'nf_modules'; +const EDITOR_GITIGNORE = 'trash/\nlocal.json\n'; + +const MAX_READ_BYTES = 64 * 1024 * 1024; + +const notFound = (path: string) => new RpcError('NOT_FOUND', `No such file or directory: ${path}`); + +const errno = (error: unknown) => (error as NodeJS.ErrnoException).code; + +const mapError = (error: unknown, path: string): never => { + switch (errno(error)) { + case 'ENOENT': + throw notFound(path); + case 'EEXIST': + case 'ENOTEMPTY': + case 'ERR_FS_CP_EEXIST': + throw new RpcError('CONFLICT', `Already exists: ${path}`); + case 'EISDIR': + case 'ENOTDIR': + throw new RpcError('BAD_REQUEST', `Wrong entry kind: ${path}`); + case 'EACCES': + case 'EPERM': + throw new RpcError('FORBIDDEN', `Permission denied: ${path}`); + default: + throw error; + } +}; + +/** + * A repository's own folder is never reached through the file API: writing hooks or config there + * runs code on the next git command, and its config may hold credentials. + */ +const isGitPath = (path: string) => + path.split(/[\\/]/).some((part) => part.toLowerCase() === '.git'); + +const gitReason = (path: string) => + isGitPath(path) ? `${path} is inside a .git folder` : undefined; + +const assertAccessible = (path: string) => { + const refused = gitReason(path); + if (refused) throw new RpcError('FORBIDDEN', refused); +}; + +const assertWritable = (path: string) => { + assertAccessible(path); + if (path === PACKAGES_DIR || path.startsWith(`${PACKAGES_DIR}/`)) { + throw new RpcError( + 'FORBIDDEN', + `${path} is part of an installed package (nf_modules), which cannot be changed. Copy it into your project to modify it.`, + ); + } +}; + +/** File operations of one project, jailed to its root. */ +export class ProjectFileSystem { + readonly jail: PathJail; + private readonly _writes = new KeyedMutex(); + private _ignore: { mtime: number; rules: Ignore } | undefined; + + constructor(readonly root: string) { + this.jail = new PathJail(root, gitReason); + } + + /** Whether `.nfignore` hides a path (directories are tested with and without a trailing /). */ + isIgnored(path: string, directory?: boolean): boolean { + const rules = this._ignore?.rules; + if (!rules || !path || path === NFIGNORE_FILE) return false; + if (directory !== false && rules.ignores(`${path}/`)) return true; + return directory !== true && rules.ignores(path); + } + + /** Reloads `.nfignore` when it changed (called before listings and by the watcher). */ + async refreshIgnore(): Promise { + const file = join(this.root, NFIGNORE_FILE); + const info = await stat(file).catch(() => undefined); + if (!info) { + this._ignore = undefined; + return; + } + if (this._ignore?.mtime === info.mtimeMs) return; + const text = await readFile(file, 'utf8').catch(() => ''); + this._ignore = { mtime: info.mtimeMs, rules: ignore().add(text) }; + } + + async stat(path: string): Promise { + assertAccessible(path); + const absolute = await this.jail.resolve(path); + try { + const info = await stat(absolute); + return { + path, + kind: info.isDirectory() ? 'directory' : 'file', + size: info.size, + mtime: info.mtimeMs, + }; + } catch (error) { + if (errno(error) === 'ENOENT') return null; + return mapError(error, path); + } + } + + async list(path: string, recursive = false): Promise { + assertAccessible(path); + const absolute = await this.jail.resolve(path); + await this.refreshIgnore(); + const entries: FileEntry[] = []; + const walk = async (dir: string, rel: string) => { + let names; + try { + names = await readdir(dir, { withFileTypes: true }); + } catch (error) { + return mapError(error, rel); + } + for (const entry of names.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.isSymbolicLink() || isGitPath(entry.name)) continue; + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + const info = await stat(join(dir, entry.name)).catch(() => undefined); + if (!info) continue; + const kind = info.isDirectory() ? 'directory' : 'file'; + if (this.isIgnored(childRel, kind === 'directory')) continue; + entries.push({ path: childRel, kind, size: info.size, mtime: info.mtimeMs }); + if (recursive && kind === 'directory' && !SKIPPED_IN_RECURSIVE_LIST.has(entry.name)) { + await walk(join(dir, entry.name), childRel); + } + } + }; + await walk(absolute, path); + return entries; + } + + async read(path: string): Promise { + assertAccessible(path); + const absolute = await this.jail.resolve(path); + try { + const info = await stat(absolute); + if (info.isDirectory()) throw new RpcError('BAD_REQUEST', `Is a directory: ${path}`); + if (info.size > MAX_READ_BYTES) + throw new RpcError('PAYLOAD_TOO_LARGE', `File too large: ${path}`); + const content = new Uint8Array(await readFile(absolute)); + return { + path, + kind: 'file', + size: info.size, + mtime: info.mtimeMs, + content, + hash: sha1(content), + }; + } catch (error) { + if (error instanceof RpcError) throw error; + return mapError(error, path); + } + } + + /** + * Atomic write (temp file + rename). With `expectedHash`, fails with CONFLICT when the file + * changed since it was read (`null` means "must not exist"). + */ + async write( + path: string, + content: Uint8Array | string, + options: { expectedHash?: string | null; createParents?: boolean } = {}, + ): Promise { + if (path === '') throw new RpcError('BAD_REQUEST', 'Cannot write the project root'); + assertWritable(path); + const absolute = await this.jail.resolve(path); + return this._writes.run(absolute, async () => { + if (options.expectedHash !== undefined) { + const current = await readFile(absolute).then( + (buffer) => sha1(buffer), + (error: unknown) => (errno(error) === 'ENOENT' ? null : mapError(error, path)), + ); + if (current !== options.expectedHash) { + throw new RpcError('CONFLICT', `File changed on disk: ${path}`, { hash: current }); + } + } + if (options.createParents !== false) await mkdir(dirname(absolute), { recursive: true }); + const temp = join( + dirname(absolute), + `.${basename(absolute)}.nf-${randomBytes(4).toString('hex')}`, + ); + try { + await writeFile(temp, content); + await rename(temp, absolute); + } catch (error) { + await rm(temp, { force: true }); + return mapError(error, path); + } + const info = await stat(absolute); + return { path, kind: 'file', size: info.size, mtime: info.mtimeMs, hash: sha1(content) }; + }); + } + + async mkdir(path: string): Promise { + assertWritable(path); + const absolute = await this.jail.resolve(path); + await mkdir(absolute, { recursive: true }).catch((error: unknown) => mapError(error, path)); + return (await this.stat(path))!; + } + + async rename(from: string, to: string, overwrite = false): Promise { + assertWritable(from); + assertWritable(to); + const source = await this.jail.resolve(from); + const target = await this.jail.resolve(to); + if (!existsSync(source)) throw notFound(from); + if (!overwrite && existsSync(target)) throw new RpcError('CONFLICT', `Already exists: ${to}`); + await mkdir(dirname(target), { recursive: true }); + await rename(source, target).catch((error: unknown) => mapError(error, from)); + return (await this.stat(to))!; + } + + async copy(from: string, to: string, overwrite = false): Promise { + assertAccessible(from); + assertWritable(to); + const source = await this.jail.resolve(from); + const target = await this.jail.resolve(to); + if (!existsSync(source)) throw notFound(from); + await cp(source, target, { recursive: true, force: overwrite, errorOnExist: !overwrite }).catch( + (error: unknown) => mapError(error, from), + ); + return (await this.stat(to))!; + } + + /** + * Moves the entry into the project trash (recoverable), never deletes directly. Returns its + * place in the trash, for `restore`. + */ + async delete(path: string): Promise<{ trashPath: string }> { + if (path === '' || path === TRASH_DIR || path.startsWith(`${TRASH_DIR}/`)) { + throw new RpcError('BAD_REQUEST', `Cannot delete ${path || 'the project root'}`); + } + assertWritable(path); + const source = await this.jail.resolve(path); + if (!existsSync(source)) throw notFound(path); + const trash = await this.jail.resolve(TRASH_DIR); + await mkdir(trash, { recursive: true }); + await this._ensureEditorGitignore(); + const target = join( + trash, + `${Date.now()}-${randomBytes(3).toString('hex')}-${basename(source)}`, + ); + await rename(source, target).catch((error: unknown) => mapError(error, path)); + return { trashPath: `${TRASH_DIR}/${basename(target)}` }; + } + + /** Brings a deleted entry back from the trash (undo of `delete`). */ + async restore(trashPath: string, to: string): Promise { + if ( + !trashPath.startsWith(`${TRASH_DIR}/`) || + trashPath.slice(TRASH_DIR.length + 1).includes('/') + ) { + throw new RpcError('BAD_REQUEST', `Not in the trash: ${trashPath}`); + } + assertWritable(to); + const source = await this.jail.resolve(trashPath); + const target = await this.jail.resolve(to); + if (!existsSync(source)) throw notFound(trashPath); + if (existsSync(target)) throw new RpcError('CONFLICT', `Already exists: ${to}`); + await mkdir(dirname(target), { recursive: true }); + await rename(source, target).catch((error: unknown) => mapError(error, to)); + return (await this.stat(to))!; + } + + private async _ensureEditorGitignore(): Promise { + const file = await this.jail.resolve(`${EDITOR_DIR}/.gitignore`); + if (!existsSync(file)) await writeFile(file, EDITOR_GITIGNORE); + } +} + +/** Heavy folders a recursive listing does not descend into. */ +const SKIPPED_IN_RECURSIVE_LIST = new Set(['node_modules']); diff --git a/packages/server-core/src/fs/index.ts b/packages/server-core/src/fs/index.ts new file mode 100644 index 00000000..a79713ec --- /dev/null +++ b/packages/server-core/src/fs/index.ts @@ -0,0 +1,5 @@ +export * from './fs-service'; +export * from './path-jail'; +export * from './watcher'; +export * from './archive'; +export * from './reveal'; diff --git a/packages/server-core/src/fs/path-jail.ts b/packages/server-core/src/fs/path-jail.ts new file mode 100644 index 00000000..c168707e --- /dev/null +++ b/packages/server-core/src/fs/path-jail.ts @@ -0,0 +1,66 @@ +import { realpath } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +const isInside = (root: string, target: string) => { + const rel = relative(root, target); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +}; + +/** Closest existing ancestor's real path + the remaining (not yet existing) segments. */ +const realpathLenient = async (target: string): Promise => { + const missing: string[] = []; + let current = target; + for (;;) { + try { + return join(await realpath(current), ...missing.reverse()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const parent = dirname(current); + if (parent === current) throw error; + missing.push(current.slice(parent.length + 1)); + current = parent; + } + } +}; + +/** + * Refuses a path by what it really points to (project-relative POSIX path, links followed): + * returns the reason, or nothing to accept it. + */ +export type PathGuard = (real: string) => string | undefined; + +/** + * Resolves project-relative paths to absolute ones inside `root`, rejecting `..` escapes and + * symlinks pointing outside of the project. `guard` also sees where a path really leads, so a + * link inside the project cannot reach a folder the requested path does not name (`.git`). + */ +export class PathJail { + private _realRoot: string | undefined; + + constructor( + readonly root: string, + private readonly _guard?: PathGuard, + ) {} + + async resolve(path: string): Promise { + const target = resolve(this.root, path); + if (!isInside(this.root, target)) throw this._escape(path); + this._realRoot ??= await realpath(this.root); + const real = await realpathLenient(target); + if (!isInside(this._realRoot, real)) throw this._escape(path); + const refused = this._guard?.(relative(this._realRoot, real).split(sep).join('/')); + if (refused) throw new RpcError('FORBIDDEN', refused); + return target; + } + + /** Project-relative POSIX path of an absolute path inside the root. */ + relative(absolute: string): string { + return relative(this.root, absolute).split(sep).join('/'); + } + + private _escape(path: string) { + return new RpcError('FORBIDDEN', `Path escapes the project: ${path}`); + } +} diff --git a/packages/server-core/src/fs/reveal.ts b/packages/server-core/src/fs/reveal.ts new file mode 100644 index 00000000..1101a30b --- /dev/null +++ b/packages/server-core/src/fs/reveal.ts @@ -0,0 +1,22 @@ +import { spawn } from 'node:child_process'; +import { stat } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +/** Opens the OS file manager on an entry (its folder for files; selected where supported). */ +export const revealInFileManager = async (absolute: string): Promise => { + const directory = (await stat(absolute)).isDirectory(); + const [command, args] = + process.platform === 'darwin' + ? ['open', directory ? [absolute] : ['-R', absolute]] + : process.platform === 'win32' + ? ['explorer', directory ? [absolute] : [`/select,${absolute}`]] + : ['xdg-open', [directory ? absolute : dirname(absolute)]]; + await new Promise((resolve, reject) => { + const child = spawn(command, args, { detached: true, stdio: 'ignore' }); + child.once('error', reject); + child.once('spawn', () => { + child.unref(); + resolve(); + }); + }); +}; diff --git a/packages/server-core/src/fs/watcher.ts b/packages/server-core/src/fs/watcher.ts new file mode 100644 index 00000000..96bb2a39 --- /dev/null +++ b/packages/server-core/src/fs/watcher.ts @@ -0,0 +1,141 @@ +import { watch } from 'chokidar'; +import { lstatSync, realpathSync } from 'node:fs'; +import { isAbsolute, relative } from 'node:path'; + +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; +import type { FileChange } from '@nanoforge-dev/editor-protocol'; + +import type { PathJail } from './path-jail'; + +export const DEFAULT_IGNORED_SEGMENTS = [ + 'node_modules', + '.git', + '.turbo', + 'coverage', + '.svelte-kit', +]; + +/** + * Temporary names (`.nf-`): atomic write files, and the folder a package reinstall + * moves aside (`writeFolder`), with everything in it. + */ +const TEMP_SEGMENT = /\.nf-[0-9a-f]{8}$/; + +export interface WatcherOptions { + /** Project-relative directories to ignore besides the defaults (build outputs…). */ + ignoredPaths?: () => readonly string[]; + /** Paths hidden from the editor (`.nfignore`), checked for each change. */ + isIgnored?: (path: string, directory: boolean) => boolean; + batchMs?: number; +} + +/** + * Watches a project and emits batched changes (default every 50 ms). Consecutive events on the + * same path collapse (`created` then `deleted` in the same batch cancel out). + */ +export class ProjectWatcher { + private readonly _onDidChange = new Emitter({ + onFirstListenerAdd: () => this._start(), + onLastListenerRemove: () => void this._stop(), + }); + private _watcher: ReturnType | undefined; + private _pending = new Map(); + private _timer: ReturnType | undefined; + private _ready: Promise = Promise.resolve(); + private _realRoot: string | undefined; + + /** Starts watching on the first listener and stops with the last one. */ + readonly onDidChange: Event = this._onDidChange.event; + + constructor( + private readonly _jail: PathJail, + private readonly _options: WatcherOptions = {}, + ) {} + + /** Resolves once the initial scan is done (tests, first subscription). */ + get ready(): Promise { + return this._ready; + } + + dispose(): void { + this._onDidChange.dispose(); + void this._stop(); + } + + private _ignored = (absolute: string): boolean => { + const rel = this._jail.relative(absolute); + if (!rel || rel.startsWith('..')) return false; + const segments = rel.split('/'); + if (segments.some((segment) => DEFAULT_IGNORED_SEGMENTS.includes(segment))) return true; + if (this._leavesProject(absolute)) return true; + if (rel === '.nanoforge/editor/trash' || rel.startsWith('.nanoforge/editor/trash/')) + return true; + if (segments.some((segment) => TEMP_SEGMENT.test(segment))) return true; + return (this._options.ignoredPaths?.() ?? []).some( + (path) => rel === path || rel.startsWith(`${path}/`), + ); + }; + + /** + * A link that leads outside of the project (a committed `link -> /`): never watched, or the + * watcher would crawl the host and stream outside file names to the client. Links are still + * followed otherwise (`followSymlinks: false` makes chokidar miss files in new folders). + */ + private _leavesProject(absolute: string): boolean { + try { + if (!lstatSync(absolute).isSymbolicLink()) return false; + this._realRoot ??= realpathSync(this._jail.root); + const rel = relative(this._realRoot, realpathSync(absolute)); + return rel.startsWith('..') || isAbsolute(rel); + } catch { + return false; + } + } + + private _start(): void { + const watcher = watch(this._jail.root, { + ignoreInitial: true, + ignored: this._ignored, + awaitWriteFinish: false, + atomic: true, + }); + this._watcher = watcher; + this._ready = new Promise((resolve) => watcher.once('ready', () => resolve())); + const push = (type: FileChange['type'], kind: FileChange['kind']) => (absolute: string) => + this._queue({ type, kind, path: this._jail.relative(absolute) }); + watcher + .on('add', push('created', 'file')) + .on('addDir', push('created', 'directory')) + .on('change', push('changed', 'file')) + .on('unlink', push('deleted', 'file')) + .on('unlinkDir', push('deleted', 'directory')); + } + + private async _stop(): Promise { + clearTimeout(this._timer); + this._pending.clear(); + const watcher = this._watcher; + this._watcher = undefined; + await watcher?.close(); + } + + private _queue(change: FileChange): void { + if (this._options.isIgnored?.(change.path, change.kind === 'directory')) return; + const previous = this._pending.get(change.path); + if (previous?.type === 'created' && change.type === 'deleted') { + this._pending.delete(change.path); + } else if (previous?.type === 'deleted' && change.type === 'created') { + this._pending.set(change.path, { ...change, type: 'changed' }); + } else if (previous?.type !== 'created' || change.type !== 'changed') { + this._pending.set(change.path, change); + } + this._timer ??= setTimeout(() => this._flush(), this._options.batchMs ?? 50); + } + + private _flush(): void { + this._timer = undefined; + const changes = [...this._pending.values()]; + this._pending.clear(); + if (changes.length) this._onDidChange.fire(changes); + } +} diff --git a/packages/server-core/src/git/git-service.ts b/packages/server-core/src/git/git-service.ts new file mode 100644 index 00000000..699c29f3 --- /dev/null +++ b/packages/server-core/src/git/git-service.ts @@ -0,0 +1,451 @@ +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import type { + GitBranch, + GitChange, + GitCommit, + GitStash, + GitStatus, +} from '@nanoforge-dev/editor-protocol'; +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +import type { EditorEnv } from '../env/editor-env.type'; +import { runProcess } from '../process/run-process'; +import { KeyedMutex } from '../util/keyed-mutex'; +import { authEnv, redact, withGitConfig, withoutCredentials } from './git-token'; +import { parseStatus } from './parse-status'; + +/** + * Repository settings that make git run a command. A hosted editor overrides them: a tenant's + * repository must not run code on the server. The file API cannot reach `.git` (the first line of + * defence); this one holds if another way in appears. A local editor keeps them: the repository + * is the user's own, and its hooks (lint-staged…) must keep running. + */ +const HOSTED_CONFIG = [ + ['core.fsmonitor', 'false'], + ['core.hooksPath', '/dev/null'], + ['core.sshCommand', 'ssh'], + ['core.askPass', ''], + ['credential.helper', ''], +] as const; + +/** Git CLI wrapper; operations on the same repository are serialized. */ +export class GitService { + private readonly _mutex = new KeyedMutex(); + + constructor( + private readonly _env: EditorEnv, + /** How long a command that talks to a remote may take before it is stopped. */ + private readonly _networkTimeoutMs = 60_000, + /** + * Whether a local repository is in a trusted folder: an untrusted one gets the hosted + * hardening too (its hooks and command settings never run). Trusted when not given. + */ + private readonly _trusted?: (root: string) => Promise, + ) {} + + isRepository(path: string): boolean { + return existsSync(join(path, '.git')); + } + + /** + * Clones `url` into `path`. A token for an https remote goes in an authorization header + * passed through the environment: never in the URL, which `ps` shows and `.git/config` keeps. + */ + clone(url: string, path: string, token?: string): Promise { + return this._mutex.run(path, async () => { + await this._git(this._env.fsRoot, ['clone', '--', url, path], true, authEnv(url, token)); + }); + } + + /** Fast-forwards a clone made by `clone`, with the same token. */ + pull(path: string, url: string, token?: string): Promise { + return this._mutex.run(path, async () => { + const remote = (await this._git(path, ['remote', 'get-url', 'origin'])).trim(); + if (remote !== url && withoutCredentials(remote) === url) { + await this._git(path, ['remote', 'set-url', 'origin', url]); + } + await this._git(path, ['pull', '--ff-only'], true, authEnv(url, token)); + }); + } + + /** The state of a folder's repository; `repository: false` when it has none of its own. */ + projectStatus(path: string): Promise { + if (!this.isRepository(path)) { + return Promise.resolve({ + repository: false, + ahead: 0, + behind: 0, + merging: false, + files: [], + }); + } + return this._mutex.run(path, async () => { + const output = await this._git(path, [ + 'status', + '--porcelain=v2', + '--branch', + '-z', + '--untracked-files=all', + ]); + return { ...parseStatus(output), merging: existsSync(join(path, '.git', 'MERGE_HEAD')) }; + }); + } + + init(path: string): Promise { + return this._run(path, ['init', '-b', 'main']); + } + + stage(path: string, paths: readonly string[]): Promise { + return this._run(path, ['add', '-A', '--', ...paths]); + } + + unstage(path: string, paths: readonly string[]): Promise { + return this._run(path, ['reset', '-q', '--', ...paths]); + } + + /** + * Rolls files back to the last commit: changed and deleted files are restored (staged or + * not), a file added since stays on disk but leaves the index, an untracked one is deleted. + */ + discard(path: string, paths: readonly string[]): Promise { + return this._mutex.run(path, async () => { + const list = async (args: string[]) => + new Set((await this._git(path, args)).split('\0').filter(Boolean)); + const hasHead = + (await this._exec(path, ['rev-parse', '--verify', '--quiet', 'HEAD'])).exitCode === 0; + const committed = hasHead + ? await list(['ls-tree', '-r', '-z', '--name-only', 'HEAD', '--', ...paths]) + : new Set(); + const indexed = await list(['ls-files', '-z', '--', ...paths]); + const restore = paths.filter((file) => committed.has(file)); + const forget = paths.filter((file) => !committed.has(file) && indexed.has(file)); + const remove = paths.filter((file) => !committed.has(file) && !indexed.has(file)); + if (restore.length) { + await this._git(path, [ + 'restore', + '--source=HEAD', + '--staged', + '--worktree', + '--', + ...restore, + ]); + } + if (forget.length) await this._git(path, ['rm', '--cached', '-q', '-f', '--', ...forget]); + if (remove.length) await this._git(path, ['clean', '-f', '-d', '-q', '--', ...remove]); + }); + } + + /** + * Commits `paths` only (the rest of what is staged stays out of the commit), the staged + * changes when there are no `paths`, or everything with `all`. + */ + commit( + path: string, + message: string, + options: { all?: boolean; paths?: readonly string[]; amend?: boolean } = {}, + ): Promise { + return this._mutex.run(path, async () => { + const { all = false, paths, amend = false } = options; + const merging = existsSync(join(path, '.git', 'MERGE_HEAD')); + if (all) await this._git(path, ['add', '-A']); + else if (paths?.length) { + const indexed = new Set( + (await this._git(path, ['ls-files', '-z', '--', ...paths])).split('\0').filter(Boolean), + ); + const addable = paths.filter((file) => indexed.has(file) || existsSync(join(path, file))); + if (addable.length) await this._git(path, ['add', '-A', '--', ...addable]); + } + await this._git(path, [ + 'commit', + '-q', + '-m', + message, + ...(amend ? ['--amend'] : []), + ...(amend && !all && !paths?.length && !merging ? ['--only'] : []), + ...(paths?.length && !all && !merging ? ['--', ...paths] : []), + ]); + }); + } + + fetch(path: string): Promise { + return this._mutex.run(path, async () => { + await this._git(path, ['fetch', '--prune', '--quiet'], true); + }); + } + + /** A merge pull. Resolves with `conflicts` when it stopped on conflicts. */ + mergePull(path: string): Promise<{ conflicts: boolean }> { + return this._mutex.run(path, async () => { + try { + await this._git(path, ['pull', '--no-rebase', '--no-edit', '--quiet'], true); + return { conflicts: false }; + } catch (error) { + if (existsSync(join(path, '.git', 'MERGE_HEAD'))) return { conflicts: true }; + throw error; + } + }); + } + + /** Pushes the current branch; its first push sets `origin` as its upstream. */ + push(path: string): Promise { + return this._mutex.run(path, async () => { + const branch = (await this._git(path, ['symbolic-ref', '--short', 'HEAD'])).trim(); + const upstream = await this._git(path, [ + 'for-each-ref', + '--format=%(upstream:short)', + `refs/heads/${branch}`, + ]); + await this._git( + path, + upstream.trim() ? ['push', '--quiet'] : ['push', '--quiet', '-u', 'origin', branch], + true, + ); + }); + } + + branches(path: string): Promise { + return this._mutex.run(path, async () => { + const output = await this._git(path, [ + 'for-each-ref', + '--format=%(refname)%00%(refname:short)%00%(HEAD)%00%(upstream:short)', + 'refs/heads', + 'refs/remotes', + ]); + const branches = output + .split('\n') + .filter(Boolean) + .map((line) => line.split('\0')) + .filter(([ref = '']) => !/^refs\/remotes\/.*\/HEAD$/.test(ref)) + .map(([ref = '', name = '', head, upstream]): GitBranch => ({ + name, + current: head === '*', + ...(upstream && { upstream }), + remote: ref.startsWith('refs/remotes/'), + })); + if (branches.some((branch) => !branch.remote)) return branches; + const unborn = await this._git(path, ['symbolic-ref', '--short', 'HEAD']).catch(() => ''); + return unborn.trim() + ? [{ name: unborn.trim(), current: true, remote: false }, ...branches] + : branches; + }); + } + + /** Switches to a local branch; a remote's branch (`origin/x`) becomes a local one tracking it. */ + switchBranch(path: string, name: string): Promise { + return this._mutex.run(path, async () => { + const remote = + (await this._exec(path, ['rev-parse', '--verify', '--quiet', `refs/remotes/${name}`])) + .exitCode === 0; + const local = remote ? name.slice(name.indexOf('/') + 1) : name; + const exists = + (await this._exec(path, ['rev-parse', '--verify', '--quiet', `refs/heads/${local}`])) + .exitCode === 0; + await this._git( + path, + remote && !exists + ? ['switch', '--quiet', '--track', '-c', local, name] + : ['switch', '--quiet', local], + ); + }); + } + + createBranch(path: string, name: string, from?: string): Promise { + return this._run(path, ['switch', '--quiet', '-c', name, ...(from ? [from] : [])]); + } + + deleteBranch(path: string, name: string, force = false): Promise { + return this._run(path, ['branch', '--quiet', force ? '-D' : '-d', name]); + } + + log(path: string, limit = 50, skip = 0, branch?: string): Promise { + return this._mutex.run(path, async () => { + const head = await this._git(path, ['rev-parse', '--verify', '--quiet', 'HEAD']).catch( + () => '', + ); + if (!head.trim()) return []; + const output = await this._git(path, [ + 'log', + `--max-count=${limit}`, + `--skip=${skip}`, + '--format=%H%x00%h%x00%an%x00%at%x00%D%x00%s%x1e', + ...(branch ? [branch, '--'] : []), + ]); + return output + .split('\x1e') + .map((record) => record.replace(/^\n/, '')) + .filter(Boolean) + .map((record): GitCommit => { + const [hash = '', shortHash = '', author = '', time, refs = '', subject = ''] = + record.split('\0'); + return { + hash, + shortHash, + author, + time: Number(time), + refs: refs + .split(', ') + .map((ref) => ref.replace(/^HEAD -> /, '')) + .filter((ref) => ref && ref !== 'HEAD'), + subject, + }; + }); + }); + } + + /** Commits of the current branch its upstream does not have yet (every commit without one). */ + /** The full message of `HEAD`; empty in a repository without commits. */ + headMessage(path: string): Promise { + return this._mutex.run(path, async () => { + const output = await this._git(path, ['log', '-1', '--format=%B', 'HEAD']).catch(() => ''); + return output.trimEnd(); + }); + } + + async outgoing(path: string): Promise { + const { upstream, ahead } = await this.projectStatus(path); + if (upstream) return ahead ? this.log(path, Math.min(ahead, 200), 0) : []; + return this.log(path, 200, 0); + } + + commitFiles(path: string, hash: string): Promise<{ path: string; change: GitChange }[]> { + return this._mutex.run(path, async () => { + const output = await this._git(path, [ + 'show', + '--name-status', + '--format=', + '-z', + '--find-renames', + hash, + ]); + const parts = output.split('\0').filter(Boolean); + const files: { path: string; change: GitChange }[] = []; + for (let index = 0; index < parts.length; index++) { + const code = parts[index]![0]; + if (code === 'R' || code === 'C') { + files.push({ path: parts[index + 2] ?? '', change: code === 'R' ? 'R' : 'A' }); + index += 2; + } else { + files.push({ + path: parts[++index] ?? '', + change: code === 'A' ? 'A' : code === 'D' ? 'D' : 'M', + }); + } + } + return files; + }); + } + + /** Text of a file at `HEAD`, in the index (`INDEX`) or before a commit (`^`). */ + show(path: string, file: string, revision: string): Promise { + return this._mutex.run(path, async () => { + const spec = revision === 'INDEX' ? `:${file}` : `${revision}:${file}`; + const result = await this._exec(path, ['show', spec]); + return result.exitCode === 0 && !result.truncated ? result.stdout : null; + }); + } + + stashes(path: string): Promise { + return this._mutex.run(path, async () => { + const output = await this._git(path, ['stash', 'list', '--format=%gs']); + return output + .split('\n') + .filter(Boolean) + .map((message, index) => ({ index, message })); + }); + } + + stash(path: string, message?: string): Promise { + return this._run(path, [ + 'stash', + 'push', + '--include-untracked', + '--quiet', + ...(message ? ['-m', message] : []), + ]); + } + + applyStash(path: string, index: number, pop = false): Promise { + return this._run(path, ['stash', pop ? 'pop' : 'apply', '--quiet', `stash@{${index}}`]); + } + + dropStash(path: string, index: number): Promise { + return this._run(path, ['stash', 'drop', '--quiet', `stash@{${index}}`]); + } + + private _run(path: string, args: string[]): Promise { + return this._mutex.run(path, async () => { + await this._git(path, args); + }); + } + + /** + * Paths after `--` are literal (`levels/[id].ts` is not a glob), except for `stash`, which + * needs pathspec magic internally and is never given paths. + * `network`: the command talks to a remote. It is stopped after a while (a remote that never + * answers must not hold the repository's queue), and ssh never waits for a passphrase or a + * host confirmation in the terminal the editor was started from. + */ + private async _exec( + cwd: string, + args: string[], + network = false, + extraEnv: Record = {}, + ) { + const hosted = + this._env.mode === 'ONLINE' || (this._trusted ? !(await this._trusted(cwd)) : false); + const batchSsh = + network && + !process.env.GIT_SSH_COMMAND && + !process.env.GIT_SSH && + (hosted || !(await this._configured(cwd, 'core.sshCommand'))); + return runProcess(this._env.gitPath, args, { + cwd, + env: { + GIT_TERMINAL_PROMPT: '0', + GIT_CEILING_DIRECTORIES: dirname(cwd), + ...(args[0] !== 'stash' && { GIT_LITERAL_PATHSPECS: '1' }), + GIT_OPTIONAL_LOCKS: '0', + LC_ALL: 'C', + ...(batchSsh && { GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }), + ...(hosted ? withGitConfig(extraEnv, HOSTED_CONFIG) : extraEnv), + }, + ...(network && { signal: AbortSignal.timeout(this._networkTimeoutMs) }), + }).done; + } + + private async _configured(cwd: string, key: string): Promise { + const result = await this._exec(cwd, ['config', '--get', key]).catch(() => undefined); + return result?.exitCode === 0 && result.stdout.trim() !== ''; + } + + private async _git( + cwd: string, + args: string[], + network = false, + extraEnv?: Record, + ): Promise { + let result; + try { + result = await this._exec(cwd, args, network, extraEnv); + } catch (error) { + if (network && (error as { name?: string }).name === 'AbortError') { + throw new RpcError( + 'TIMEOUT', + `git ${args[0]} got no answer from the remote in ${Math.round(this._networkTimeoutMs / 1000)} s. ` + + 'If it needs a password or a passphrase, run it once in a terminal.', + ); + } + throw error; + } + if (result.exitCode !== 0) { + const stderr = redact(result.stderr || result.stdout).trim(); + const reason = stderr.replace(/^(error|fatal): /gm, '').slice(0, 600); + throw new RpcError('INTERNAL', reason || `git ${args[0]} failed`, { + stderr: stderr.slice(0, 2000), + }); + } + return result.stdout; + } +} diff --git a/packages/server-core/src/git/git-token.ts b/packages/server-core/src/git/git-token.ts new file mode 100644 index 00000000..418f496c --- /dev/null +++ b/packages/server-core/src/git/git-token.ts @@ -0,0 +1,49 @@ +/** + * Environment that makes git send `token` to `url`'s host as basic credentials (GitHub and GitLab + * style), through `GIT_CONFIG_*`: nothing on the command line, nothing saved in the clone. + */ +export const authEnv = (url: string, token: string | undefined): Record => { + if (!token || !URL.canParse(url)) return {}; + const parsed = new URL(url); + if (parsed.protocol !== 'https:') return {}; + const credentials = Buffer.from(`oauth2:${token}`).toString('base64'); + return { + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: `http.${parsed.origin}/.extraHeader`, + GIT_CONFIG_VALUE_0: `Authorization: Basic ${credentials}`, + }; +}; + +/** Adds settings to the `GIT_CONFIG_*` ones of `env`, after those it already holds. */ +export const withGitConfig = ( + env: Record, + entries: readonly (readonly [key: string, value: string])[], +): Record => { + if (!entries.length) return env; + const count = Number(env.GIT_CONFIG_COUNT ?? 0); + const result: Record = { + ...env, + GIT_CONFIG_COUNT: String(count + entries.length), + }; + entries.forEach(([key, value], index) => { + result[`GIT_CONFIG_KEY_${count + index}`] = key; + result[`GIT_CONFIG_VALUE_${count + index}`] = value; + }); + return result; +}; + +/** `url` without the user and password older clones kept in their remote. */ +export const withoutCredentials = (url: string): string => { + try { + const parsed = new URL(url); + if (!parsed.username && !parsed.password) return url; + parsed.username = ''; + parsed.password = ''; + return parsed.href; + } catch { + return url; + } +}; + +/** Removes credentials from git output (clone errors echo the remote URL). */ +export const redact = (text: string) => text.replace(/\/\/[^/@\s]+@/g, '//***@'); diff --git a/packages/server-core/src/git/index.ts b/packages/server-core/src/git/index.ts new file mode 100644 index 00000000..ee8bf73c --- /dev/null +++ b/packages/server-core/src/git/index.ts @@ -0,0 +1,2 @@ +export * from './git-service'; +export { parseStatus } from './parse-status'; diff --git a/packages/server-core/src/git/parse-status.ts b/packages/server-core/src/git/parse-status.ts new file mode 100644 index 00000000..a2d6feb7 --- /dev/null +++ b/packages/server-core/src/git/parse-status.ts @@ -0,0 +1,57 @@ +import type { GitChange, GitFile, GitStatus } from '@nanoforge-dev/editor-protocol'; + +export const CHANGES: Record = { + M: 'M', + T: 'M', + A: 'A', + D: 'D', + R: 'R', + C: 'A', +}; + +/** Reads `git status --porcelain=v2 --branch -z`. */ +export const parseStatus = (output: string): Omit => { + const status: Omit = { + repository: true, + ahead: 0, + behind: 0, + files: [], + }; + const files: GitFile[] = []; + const entries = output.split('\0'); + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]!; + if (!entry) continue; + if (entry.startsWith('# branch.head ')) { + const name = entry.slice('# branch.head '.length); + if (name !== '(detached)') status.branch = name; + } else if (entry.startsWith('# branch.oid ')) { + const oid = entry.slice('# branch.oid '.length); + if (oid !== '(initial)') status.head = oid.slice(0, 7); + } else if (entry.startsWith('# branch.upstream ')) { + status.upstream = entry.slice('# branch.upstream '.length); + } else if (entry.startsWith('# branch.ab ')) { + const match = /\+(\d+) -(\d+)/.exec(entry); + status.ahead = Number(match?.[1] ?? 0); + status.behind = Number(match?.[2] ?? 0); + } else if (entry.startsWith('? ')) { + files.push({ path: entry.slice(2), unstaged: 'U', conflicted: false }); + } else if (entry.startsWith('u ')) { + files.push({ path: entry.split(' ').slice(10).join(' '), conflicted: true }); + } else if (entry.startsWith('1 ') || entry.startsWith('2 ')) { + const renamed = entry.startsWith('2 '); + const fields = entry.split(' '); + const [x, y] = fields[1]!; + const staged = CHANGES[x!]; + const unstaged = CHANGES[y!]; + files.push({ + path: fields.slice(renamed ? 9 : 8).join(' '), + ...(renamed && { from: entries[++index] ?? '' }), + ...(staged && { staged }), + ...(unstaged && { unstaged }), + conflicted: false, + }); + } + } + return { ...status, files: files.sort((a, b) => a.path.localeCompare(b.path)) }; +}; diff --git a/packages/server-core/src/http/static-files.ts b/packages/server-core/src/http/static-files.ts new file mode 100644 index 00000000..798f6a14 --- /dev/null +++ b/packages/server-core/src/http/static-files.ts @@ -0,0 +1,41 @@ +import { readFile, stat } from 'node:fs/promises'; +import { extname } from 'node:path'; + +const CONTENT_TYPES: Record = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.wasm': 'application/wasm', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; + +export const contentType = (file: string) => + CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream'; + +/** Serves a file (Bun streams it; Node reads it) with the given cache policy. */ +export const fileResponse = async ( + file: string, + cacheControl: string, +): Promise => { + const info = await stat(file).catch(() => undefined); + if (!info?.isFile()) return undefined; + const headers = { + 'content-type': contentType(file), + 'cache-control': cacheControl, + 'x-content-type-options': 'nosniff', + }; + const bun = (globalThis as { Bun?: { file(path: string): Blob } }).Bun; + const body = bun ? bun.file(file) : new Uint8Array(await readFile(file)); + return new Response(body, { headers }); +}; diff --git a/packages/server-core/src/index.ts b/packages/server-core/src/index.ts new file mode 100644 index 00000000..9f7b62e1 --- /dev/null +++ b/packages/server-core/src/index.ts @@ -0,0 +1,11 @@ +export * from './api'; +export * from './env'; +export * from './fs'; +export * from './git'; +export * from './plugin'; +export * from './process'; +export * from './project'; +export * from './runtime'; +export * from './server'; +export * from './session'; +export * from './settings'; diff --git a/packages/server-core/src/plugin/index.ts b/packages/server-core/src/plugin/index.ts new file mode 100644 index 00000000..bc1bdcdf --- /dev/null +++ b/packages/server-core/src/plugin/index.ts @@ -0,0 +1,2 @@ +export * from './plugin-sources'; +export * from './server-plugins'; diff --git a/packages/server-core/src/plugin/plugin-sources.ts b/packages/server-core/src/plugin/plugin-sources.ts new file mode 100644 index 00000000..41e66396 --- /dev/null +++ b/packages/server-core/src/plugin/plugin-sources.ts @@ -0,0 +1,232 @@ +import { watch } from 'chokidar'; +import { existsSync } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + Emitter, + type Event, + type Logger, + PackageManifestSchema, + type PluginManifest, + parsePluginManifest, +} from '@nanoforge-dev/editor-kernel'; +import type { PluginListing, PluginSuggestion } from '@nanoforge-dev/editor-protocol'; + +import type { EditorEnv } from '../env/editor-env.type'; +import { PACKAGES_DIR } from '../fs/fs-service'; +import { PathJail } from '../fs/path-jail'; + +export const MANIFEST_FILE = 'nanoforge.manifest.json'; +export type PluginSourceKind = PluginListing['source']; + +/** Where a project keeps its own plugins. */ +export const PROJECT_PLUGINS_DIR = '.nanoforge/plugins'; + +/** The project whose plugins are listed: its id (for URLs) and its root folder. */ +export interface PluginProject { + readonly id: string; + readonly root: string; +} + +/** A plugin found on disk: `dir` holds the manifest and the built bundles. */ +export interface LocatedPlugin { + readonly source: PluginSourceKind; + readonly dir: string; + readonly manifest: PluginManifest | undefined; + readonly listing: PluginListing; +} + +const PROJECT_URL_PREFIX = '/plugins/project/'; + +const urlFor = ( + source: PluginSourceKind, + manifest: { name: string; version: string }, + project?: PluginProject, +) => + source === 'project' + ? `${PROJECT_URL_PREFIX}${project!.id}/${manifest.name}/${manifest.version}/` + : `/plugins/${source}/${manifest.name}/${manifest.version}/`; + +/** + * Finds plugins in four places: + * - bundled with the editor (`/@/`); + * - installed in the user's home (`/plugins/@/`), for every project; + * - installed in the open project (`/.nanoforge/plugins/@/`); + * - local dev folders (`/dist`). + * + * Packages (`nf_modules`) never hold plugins: they can only suggest some (`suggestions`). + */ +export class PluginSources { + private _located = new Map(); + /** The project plugins last listed for each project, by base URL. */ + private readonly _projectLocated = new Map>(); + private readonly _onDevChange = new Emitter({ + onFirstListenerAdd: () => this._watchDev(), + onLastListenerRemove: () => void this._devWatcher?.close(), + }); + private _devWatcher: ReturnType | undefined; + + /** Fires when a dev plugin was rebuilt (debounced). */ + readonly onDevChange: Event = this._onDevChange.event; + + constructor( + private readonly _env: EditorEnv, + private readonly _logger: Logger, + ) {} + + get installedDir(): string { + return join(this._env.dataDir, 'plugins'); + } + + /** Every plugin, plus `project`'s own plugins when set. */ + async list(project?: PluginProject): Promise { + const located: LocatedPlugin[] = []; + await this._scan(located, 'bundled', this._env.bundledPluginsDir); + await this._scan(located, 'installed', this.installedDir); + for (const path of this._env.devPlugins) + located.push(await this._read('dev', join(path, 'dist'))); + this._located = new Map(located.map((plugin) => [plugin.listing.baseUrl, plugin])); + if (!project) return located; + + const own: LocatedPlugin[] = []; + await this._scan(own, 'project', join(project.root, PROJECT_PLUGINS_DIR), project); + this._projectLocated.set( + project.id, + new Map(own.map((plugin) => [plugin.listing.baseUrl, plugin])), + ); + return [...located, ...own]; + } + + /** + * The plugins that packages in the project's `nf_modules` suggest, merged by plugin name. Only + * a package's own manifest suggests: its sub-manifests (`include`) only describe contents. + */ + async suggestions(projectRoot: string): Promise { + const byName = new Map(); + const root = join(projectRoot, PACKAGES_DIR); + for (const scope of await directories(root)) { + for (const name of await directories(join(root, scope))) { + const dir = join(root, scope, name); + let manifest; + try { + const raw: unknown = JSON.parse(await readFile(join(dir, MANIFEST_FILE), 'utf8')); + if ((raw as { type?: unknown } | null)?.type !== 'package') continue; + manifest = PackageManifestSchema.parse(raw); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this._logger.warn(`Package at ${dir} ignored: ${message}`); + continue; + } + for (const [plugin, range] of Object.entries(manifest.suggestedPlugins)) { + const entry = byName.get(plugin) ?? { name: plugin, suggestedBy: [] }; + entry.suggestedBy.push({ package: manifest.name, range }); + byName.set(plugin, entry); + } + } + } + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * Absolute file of a plugin URL (`/plugins//@///`, or + * `/plugins/project//…` when `canAccess(projectId)`). + */ + async resolveFile( + urlPath: string, + canAccess: (projectId: string) => boolean | Promise = () => false, + ): Promise<{ file: string; plugin: LocatedPlugin } | undefined> { + let located: Map; + if (urlPath.startsWith(PROJECT_URL_PREFIX)) { + const projectId = urlPath.slice(PROJECT_URL_PREFIX.length).split('/')[0] ?? ''; + if (!(await canAccess(projectId))) return undefined; + located = this._projectLocated.get(projectId) ?? new Map(); + } else { + if (!this._located.size) await this.list(); + located = this._located; + } + for (const [baseUrl, plugin] of located) { + if (!urlPath.startsWith(baseUrl)) continue; + try { + const rel = decodeURIComponent(urlPath.slice(baseUrl.length)); + const file = await new PathJail(plugin.dir).resolve(rel); + return existsSync(file) ? { file, plugin } : undefined; + } catch { + return undefined; + } + } + return undefined; + } + + private async _scan( + into: LocatedPlugin[], + source: PluginSourceKind, + root: string | undefined, + project?: PluginProject, + ): Promise { + if (!root || !existsSync(root)) return; + for (const scope of await directories(root)) { + for (const name of await directories(join(root, scope))) { + into.push(await this._read(source, join(root, scope, name), project)); + } + } + } + + private async _read( + source: PluginSourceKind, + dir: string, + project?: PluginProject, + ): Promise { + const fallbackName = `${source}/${dir.split(/[\\/]/).slice(-2).join('-')}`; + try { + const raw: unknown = JSON.parse(await readFile(join(dir, MANIFEST_FILE), 'utf8')); + const manifest = parsePluginManifest(raw, join(dir, MANIFEST_FILE)); + return { + source, + dir, + manifest, + listing: { source, baseUrl: urlFor(source, manifest, project), manifest: raw }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this._logger.warn(`Plugin at ${dir} ignored: ${message}`); + return { + source, + dir, + manifest: undefined, + listing: { + source, + baseUrl: `/plugins/invalid/${fallbackName}/`, + manifest: null, + error: message, + }, + }; + } + } + + private _watchDev(): void { + if (!this._env.devPlugins.length) return; + const dists = this._env.devPlugins.map((path) => join(path, 'dist')); + const timers = new Map>(); + this._devWatcher = watch(dists, { ignoreInitial: true }); + this._devWatcher.on('all', (_event, file) => { + const dist = dists.find((dir) => file.startsWith(dir)); + if (!dist) return; + clearTimeout(timers.get(dist)); + timers.set( + dist, + setTimeout(async () => { + const plugin = await this._read('dev', dist); + this._located.set(plugin.listing.baseUrl, plugin); + if (plugin.manifest) this._onDevChange.fire(plugin); + }, 200), + ); + }); + } +} + +const directories = async (path: string): Promise => + (await readdir(path, { withFileTypes: true }).catch(() => [])) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + .map((entry) => entry.name) + .sort(); diff --git a/packages/server-core/src/plugin/server-plugins.ts b/packages/server-core/src/plugin/server-plugins.ts new file mode 100644 index 00000000..83c53b9c --- /dev/null +++ b/packages/server-core/src/plugin/server-plugins.ts @@ -0,0 +1,183 @@ +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + type Disposable, + DisposableStore, + type Logger, + type LoggerService, + type PluginManifest, +} from '@nanoforge-dev/editor-kernel'; +import type { Contract, Implementation, RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../session/auth'; +import type { LocatedPlugin } from './plugin-sources'; + +/** Server-side services a plugin server entry may use. */ +export interface ServerPluginServices { + readonly [name: string]: unknown; +} + +/** What a plugin's `entry.server` module receives in `activate`. */ +export interface ServerPluginContext { + readonly name: string; + readonly manifest: PluginManifest; + readonly subscriptions: DisposableStore; + readonly logger: Logger; + readonly services: S; + /** RPC namespace of the plugin: contracts must use it (`plugin.`). */ + readonly namespace: string; + implement( + contract: C, + implementation: Implementation, + ): Disposable; +} + +export interface ServerPluginModule { + activate?(context: ServerPluginContext): void | Promise; + deactivate?(): void | Promise; +} + +export const pluginNamespace = (name: string) => `plugin.${name}`; + +interface ActiveServerPlugin { + readonly plugin: LocatedPlugin; + readonly module: ServerPluginModule; + readonly store: DisposableStore; +} + +/** Runs `entry.server` of enabled plugins inside the editor server (trusted plugins). */ +export class ServerPluginHost implements Disposable { + private readonly _active = new Map(); + /** Activations in progress: a second request for the same plugin waits for the first one. */ + private readonly _activating = new Map>(); + /** Plugins a project's copy of the same name replaced, run again when that copy stops. */ + private readonly _shadowed = new Map(); + + constructor( + private readonly _router: RpcRouter, + private readonly _services: ServerPluginServices, + private readonly _logs: LoggerService, + ) {} + + get active(): string[] { + return [...this._active.keys()]; + } + + activate(plugin: LocatedPlugin): Promise { + const name = plugin.manifest?.name; + if ( + !name || + !plugin.manifest?.entry.server || + this._active.get(name)?.plugin.dir === plugin.dir + ) + return Promise.resolve(); + let activating = this._activating.get(name); + if (!activating) { + activating = this._replace(plugin).finally(() => this._activating.delete(name)); + this._activating.set(name, activating); + } + return activating; + } + + /** + * Runs the server entries of exactly these project plugins (those the editor enabled for the + * open project) and stops every other project plugin's, those of a previous project included. + * A plugin a project copy had replaced runs again. + */ + async syncProject(plugins: readonly LocatedPlugin[]): Promise { + const wanted = new Set(plugins.map((plugin) => plugin.dir)); + for (const [name, active] of [...this._active]) { + if (active.plugin.source === 'project' && !wanted.has(active.plugin.dir)) + await this.deactivate(name); + } + for (const plugin of plugins) await this.activate(plugin); + } + + /** + * Stops a plugin's server entry; with `dir`, only when the running copy is that folder's. A + * plugin a stopped project copy had replaced runs again. + */ + async deactivate(name: string, dir?: string): Promise { + if (dir && this._shadowed.get(name)?.dir === dir) this._shadowed.delete(name); + const stopped = await this._stop(name, dir); + const shadowed = stopped?.source === 'project' ? this._shadowed.get(name) : undefined; + this._shadowed.delete(name); + if (shadowed) await this.activate(shadowed); + } + + dispose(): void { + for (const name of [...this._active.keys()]) { + this.deactivate(name).catch((error: unknown) => + this._logs.getLogger(`${name}(server)`).error('Server entry failed to deactivate', error), + ); + } + } + + /** Activates a plugin, stopping first another copy of the same name (it shadows it). */ + private async _replace(plugin: LocatedPlugin): Promise { + const name = plugin.manifest!.name; + const current = this._active.get(name); + if (current) { + if (plugin.source === 'project' && current.plugin.source !== 'project') + this._shadowed.set(name, current.plugin); + await this._stop(name, undefined, false); + } + await this._activate(plugin); + } + + /** + * Stops a server entry; resolves with the plugin it was, or nothing when none ran. `wait`: let + * an activation in progress finish first (not from inside that activation). + */ + private async _stop(name: string, dir?: string, wait = true): Promise { + if (wait) await this._activating.get(name); + const active = this._active.get(name); + if (!active || (dir && active.plugin.dir !== dir)) return undefined; + this._active.delete(name); + try { + await active.module.deactivate?.(); + } finally { + active.store.dispose(); + } + return active.plugin; + } + + private async _activate(plugin: LocatedPlugin): Promise { + const manifest = plugin.manifest!; + const entry = manifest.entry.server!; + const logger = this._logs.getLogger(`${manifest.name}(server)`); + const store = new DisposableStore(); + const namespace = pluginNamespace(manifest.name); + try { + const url = pathToFileURL(join(plugin.dir, entry)); + url.searchParams.set('v', String(Date.now())); + const imported = (await import(url.href)) as ServerPluginModule & { + default?: ServerPluginModule; + }; + const module = imported.default ?? imported; + const context: ServerPluginContext = { + name: manifest.name, + manifest, + subscriptions: store, + logger, + services: this._services, + namespace, + implement: (contract, implementation) => { + if (contract.namespace !== namespace) { + throw new Error( + `${manifest.name} can only implement contracts in the "${namespace}" namespace`, + ); + } + return store.add(this._router.implement(contract, implementation)); + }, + }; + await module.activate?.(context); + this._active.set(manifest.name, { plugin, module, store }); + logger.info('Server entry activated'); + } catch (error) { + store.dispose(); + logger.error('Server entry failed to activate', error); + } + } +} diff --git a/packages/server-core/src/process/child-env.ts b/packages/server-core/src/process/child-env.ts new file mode 100644 index 00000000..52308628 --- /dev/null +++ b/packages/server-core/src/process/child-env.ts @@ -0,0 +1,92 @@ +const NAMES = new Set([ + 'PATH', + 'Path', + 'PATHEXT', + 'HOME', + 'USER', + 'USERNAME', + 'LOGNAME', + 'SHELL', + 'LANG', + 'LANGUAGE', + 'TERM', + 'TZ', + 'TMPDIR', + 'TMP', + 'TEMP', + 'CI', + 'NODE_ENV', + 'NODE_OPTIONS', + 'NODE_EXTRA_CA_CERTS', + 'FORCE_COLOR', + 'NO_COLOR', + 'SSH_AUTH_SOCK', + 'SSH_AGENT_PID', + 'GIT_SSH', + 'GIT_SSH_COMMAND', + 'GIT_ASKPASS', + 'SSH_ASKPASS', + 'GIT_AUTHOR_NAME', + 'GIT_AUTHOR_EMAIL', + 'GIT_COMMITTER_NAME', + 'GIT_COMMITTER_EMAIL', + 'GIT_SSL_CAINFO', + 'GIT_SSL_CAPATH', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'GNUPGHOME', + 'GPG_TTY', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'ALL_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + 'all_proxy', + 'SystemRoot', + 'SYSTEMROOT', + 'ComSpec', + 'COMSPEC', + 'WINDIR', + 'APPDATA', + 'LOCALAPPDATA', + 'USERPROFILE', + 'HOMEDRIVE', + 'HOMEPATH', + 'ProgramFiles', + 'ProgramFiles(x86)', + 'ProgramData', + 'NF_CLI_PATH', +]); + +const PREFIXES = [ + 'LC_', + 'XDG_', + 'npm_config_', + 'NPM_CONFIG_', + 'PNPM_', + 'BUN_', + 'COREPACK_', + 'NANOFORGE_', +]; + +/** + * The part of the editor's environment its child processes (git, the CLI, game servers) get: what + * tools need to run, never the server's own settings (`SESSION_SECRET`, `API_KEY`, `PORT`…), nor + * the git variables that point at another repository (`GIT_DIR`, `GIT_WORK_TREE`…, set when the + * editor is started from a git hook), which would bypass `GIT_CEILING_DIRECTORIES`. + */ +export const childEnv = ( + extra: Record = {}, + source: Record = process.env, +): Record => { + const kept = Object.entries(source).filter( + ([key, value]) => + value !== undefined && + !(key in extra) && + (NAMES.has(key) || PREFIXES.some((prefix) => key.startsWith(prefix))), + ); + const added = Object.entries(extra).filter(([, value]) => value !== undefined); + return Object.fromEntries([...kept, ...added]) as Record; +}; diff --git a/packages/server-core/src/process/cli-service.ts b/packages/server-core/src/process/cli-service.ts new file mode 100644 index 00000000..453cc4d8 --- /dev/null +++ b/packages/server-core/src/process/cli-service.ts @@ -0,0 +1,92 @@ +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; + +import type { EditorEnv } from '../env/editor-env.type'; +import { type OutputLine, type ProcessResult, runProcess } from './run-process'; + +export interface CliRun { + readonly id: string; + /** What ran, as shown in the console: `nf`, or a package manager. */ + readonly command: string; + readonly args: readonly string[]; + readonly cwd: string; + /** The caller reports the output itself (builds): not shown as a task in the console. */ + readonly silent: boolean; + readonly onLine: Event; + readonly done: Promise; + kill(): void; +} + +/** Runs the NanoForge CLI (`nf`), preferring the project's own installation. */ +export class CliService { + private readonly _onDidStart = new Emitter(); + private _counter = 0; + + /** Fires for every run: runs in a project are streamed to its console as tasks. */ + readonly onDidStart: Event = this._onDidStart.event; + + constructor(private readonly _env: EditorEnv) {} + + /** `node_modules/.bin/nf` of the closest ancestor of `cwd`, else `NF_CLI_PATH` or `nf`. */ + resolveBinary(cwd: string): string { + if (this._env.cliPath) return this._env.cliPath; + for (let dir = cwd; ; dir = dirname(dir)) { + const candidate = join( + dir, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'nf.cmd' : 'nf', + ); + if (existsSync(candidate)) return candidate; + if (dirname(dir) === dir) return 'nf'; + } + } + + run( + args: readonly string[], + options: { + cwd: string; + env?: Record; + signal?: AbortSignal; + silent?: boolean; + }, + ): CliRun { + return this._start('nf', this.resolveBinary(options.cwd), args, options); + } + + /** Runs another tool of the project (its package manager), reported like a CLI run. */ + exec( + command: string, + args: readonly string[], + options: { cwd: string; env?: Record; signal?: AbortSignal }, + ): CliRun { + return this._start(command, command, args, options); + } + + private _start( + command: string, + binary: string, + args: readonly string[], + options: { + cwd: string; + env?: Record; + signal?: AbortSignal; + silent?: boolean; + }, + ): CliRun { + const { silent = false, ...processOptions } = options; + const process = runProcess(binary, args, processOptions); + const run: CliRun = { + id: `cli-${++this._counter}`, + command, + args, + cwd: options.cwd, + silent, + ...process, + }; + this._onDidStart.fire(run); + return run; + } +} diff --git a/packages/server-core/src/process/index.ts b/packages/server-core/src/process/index.ts new file mode 100644 index 00000000..7bc2b15b --- /dev/null +++ b/packages/server-core/src/process/index.ts @@ -0,0 +1,3 @@ +export * from './child-env'; +export * from './cli-service'; +export * from './run-process'; diff --git a/packages/server-core/src/process/run-process.ts b/packages/server-core/src/process/run-process.ts new file mode 100644 index 00000000..65aecc2e --- /dev/null +++ b/packages/server-core/src/process/run-process.ts @@ -0,0 +1,144 @@ +import { spawn } from 'node:child_process'; + +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; + +import { childEnv } from './child-env'; + +/** Output kept per stream in `ProcessResult`; past it, lines are still emitted. */ +const MAX_BUFFER = 16 * 1024 * 1024; +/** A line longer than this (binary output) is emitted in pieces. */ +const MAX_LINE = 64 * 1024; + +export interface ProcessOptions { + cwd: string; + env?: Record; + signal?: AbortSignal; + /** Data written to stdin, then closed. */ + input?: string; + /** Characters of output kept per stream (default 16 Mi). */ + maxBuffer?: number; +} + +export interface OutputLine { + readonly stream: 'stdout' | 'stderr'; + readonly text: string; +} + +export interface RunningProcess { + readonly onLine: Event; + readonly done: Promise; + kill(): void; +} + +export interface ProcessResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + /** Some output was not kept (`maxBuffer`). */ + readonly truncated: boolean; +} + +/** Processes run in a group of their own (not on Windows): stopping one stops what it started. */ +const GROUPS = process.platform !== 'win32'; + +/** Stops the groups still running when the editor exits: they no longer share its terminal's. */ +const running = new Set<() => void>(); +let stopOnExit = false; + +const abortError = (signal: AbortSignal) => + Object.assign(new Error('The operation was aborted', { cause: signal.reason }), { + name: 'AbortError', + }); + +/** + * Spawns a process without a shell, with the editor's environment filtered (`childEnv`); output + * is split into lines and buffered. `kill()` and `signal` stop its whole process group: a build's + * bundler or a package manager's scripts go with it. + */ +export const runProcess = ( + command: string, + args: readonly string[], + options: ProcessOptions, +): RunningProcess => { + const onLine = new Emitter(); + const child = spawn(command, args, { + cwd: options.cwd, + env: childEnv(options.env), + stdio: ['pipe', 'pipe', 'pipe'], + detached: GROUPS, + }); + const kill = (signal: NodeJS.Signals = 'SIGTERM') => { + if (!child.pid) return; + try { + if (GROUPS) process.kill(-child.pid, signal); + else child.kill(signal); + } catch {} + }; + const stop = () => kill('SIGKILL'); + running.add(stop); + if (!stopOnExit) { + stopOnExit = true; + process.once('exit', () => { + for (const each of running) each(); + }); + } + const { signal } = options; + const onAbort = () => kill(); + if (signal?.aborted) onAbort(); + else signal?.addEventListener('abort', onAbort, { once: true }); + const maxBuffer = options.maxBuffer ?? MAX_BUFFER; + const buffers = { stdout: '', stderr: '' }; + const pending = { stdout: '', stderr: '' }; + let truncated = false; + + const collect = (stream: 'stdout' | 'stderr') => (text: string) => { + const room = maxBuffer - buffers[stream].length; + if (text.length > room) truncated = true; + if (room > 0) buffers[stream] += text.length > room ? text.slice(0, room) : text; + const lines = (pending[stream] + text).split(/\r?\n/); + let rest = lines.pop() ?? ''; + for (const line of lines) onLine.fire({ stream, text: line }); + while (rest.length > MAX_LINE) { + onLine.fire({ stream, text: rest.slice(0, MAX_LINE) }); + rest = rest.slice(MAX_LINE); + } + pending[stream] = rest; + }; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', collect('stdout')); + child.stderr.on('data', collect('stderr')); + if (options.input !== undefined) child.stdin.end(options.input); + else child.stdin.end(); + + const done = new Promise((resolve, reject) => { + const end = () => { + running.delete(stop); + signal?.removeEventListener('abort', onAbort); + onLine.dispose(); + }; + child.on('error', (error) => { + end(); + reject(error); + }); + child.on('close', (code) => { + for (const stream of ['stdout', 'stderr'] as const) { + if (pending[stream]) onLine.fire({ stream, text: pending[stream] }); + } + end(); + if (signal?.aborted) { + kill(); + reject(abortError(signal)); + return; + } + resolve({ + exitCode: code ?? -1, + stdout: buffers.stdout, + stderr: buffers.stderr, + truncated, + }); + }); + }); + + return { onLine: onLine.event, done, kill: () => kill() }; +}; diff --git a/packages/server-core/src/project/config-loader.ts b/packages/server-core/src/project/config-loader.ts new file mode 100644 index 00000000..5d13febd --- /dev/null +++ b/packages/server-core/src/project/config-loader.ts @@ -0,0 +1,159 @@ +import { readFile } from 'node:fs/promises'; +import { builtinModules } from 'node:module'; +import ts from 'typescript'; +import { unrun } from 'unrun'; + +import { type NanoforgeConfig, resolveConfig } from '@nanoforge-dev/config'; + +export const CONFIG_FILES = [ + 'nanoforge.config.ts', + 'nanoforge.config.mts', + 'nanoforge.config.js', + 'nanoforge.config.mjs', +]; + +export type ResolvedConfig = ReturnType; + +/** Loads a `nanoforge.config.*` file into a fully resolved config. */ +export interface ConfigLoader { + load(file: string): Promise; +} + +export class ConfigLoadError extends Error { + constructor( + readonly file: string, + message: string, + ) { + super(message); + this.name = 'ConfigLoadError'; + } +} + +const BUILTINS = new Set([...builtinModules, ...builtinModules.map((name) => `node:${name}`)]); + +const CONFIG_MODULE_ID = '\0nanoforge-config'; + +/** + * `@nanoforge-dev/config` as seen by configs: `defineConfig` is the identity, so configs load + * even before the project's dependencies are installed (fresh clone). + */ +const configModulePlugin = { + name: 'nanoforge-config-module', + resolveId: (id: string) => (id === '@nanoforge-dev/config' ? CONFIG_MODULE_ID : null), + load: (id: string) => + id === CONFIG_MODULE_ID ? 'export const defineConfig = (config) => config;' : null, +}; + +/** + * Executes the config like the CLI does (local editor: the user trusts their own project). + * + * Everything but Node built-ins is bundled: unrun imports its bundle from + * `/node_modules/.unrun`, so external packages would resolve from the editor's + * working directory instead of the project. + */ +export const executingConfigLoader: ConfigLoader = { + async load(file) { + let module: { default?: unknown }; + try { + ({ module } = await unrun<{ default?: unknown }>({ + path: file, + preset: 'bundle-require', + inputOptions: { + external: (id: string) => BUILTINS.has(id), + plugins: [configModulePlugin], + }, + })); + } catch (error) { + throw new ConfigLoadError( + file, + `Failed to load ${file}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return validate(module.default, file); + }, +}; + +const CONFIG_TYPES = new Set(['workspace', 'client', 'server', 'lib']); + +/** + * Reads the config without executing it: only `export default defineConfig({...})` or + * `export default {...}` with literal values is supported. Used when projects are untrusted + * (hosted ONLINE editor). + */ +export const staticConfigLoader: ConfigLoader = { + async load(file) { + const source = ts.createSourceFile( + file, + await readFile(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ); + let expression: ts.Expression | undefined; + for (const statement of source.statements) { + if (ts.isExportAssignment(statement) && !statement.isExportEquals) + expression = statement.expression; + } + if (!expression) throw new ConfigLoadError(file, 'Config file has no default export'); + if (ts.isCallExpression(expression) && expression.arguments.length === 1) { + expression = expression.arguments[0]!; + } + return validate(literal(expression, file), file); + }, +}; + +const validate = (value: unknown, file: string): ResolvedConfig => { + if (value === undefined) throw new ConfigLoadError(file, 'Config file has no default export'); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new ConfigLoadError(file, 'The default export must be an object'); + } + const type = (value as { type?: unknown }).type; + if (typeof type !== 'string' || !CONFIG_TYPES.has(type)) { + throw new ConfigLoadError(file, `Unknown config type ${JSON.stringify(type)}`); + } + return resolveConfig(value as NanoforgeConfig); +}; + +const literal = (node: ts.Expression, file: string): unknown => { + const fail = (): never => { + const { line, character } = node.getSourceFile().getLineAndCharacterOfPosition(node.getStart()); + throw new ConfigLoadError( + file, + `Only literal values are supported in configs of online projects (${line + 1}:${character + 1}: ${node.getText()})`, + ); + }; + if ( + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isSatisfiesExpression(node) + ) { + return literal(node.expression, file); + } + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isNumericLiteral(node)) return Number(node.text); + if (node.kind === ts.SyntaxKind.TrueKeyword) return true; + if (node.kind === ts.SyntaxKind.FalseKeyword) return false; + if (node.kind === ts.SyntaxKind.NullKeyword) return null; + if ( + ts.isPrefixUnaryExpression(node) && + node.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(node.operand) + ) { + return -Number(node.operand.text); + } + if (ts.isArrayLiteralExpression(node)) + return node.elements.map((element) => literal(element, file)); + if (ts.isObjectLiteralExpression(node)) { + const result: Record = {}; + for (const property of node.properties) { + if (!ts.isPropertyAssignment(property)) return fail(); + const name = property.name; + const key = + ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name) + ? name.text + : fail(); + result[key] = literal(property.initializer, file); + } + return result; + } + return fail(); +}; diff --git a/packages/server-core/src/project/discovery.ts b/packages/server-core/src/project/discovery.ts new file mode 100644 index 00000000..1c3a73aa --- /dev/null +++ b/packages/server-core/src/project/discovery.ts @@ -0,0 +1,299 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; + +import type { AppModel, ProjectModel } from '@nanoforge-dev/editor-protocol'; +import { joinPath } from '@nanoforge-dev/editor-protocol'; + +import { expandDirectoryGlobs } from '../util/glob'; +import { + CONFIG_FILES, + ConfigLoadError, + type ConfigLoader, + type ResolvedConfig, +} from './config-loader'; + +/** `@nanoforge-dev/*` packages that are tooling, not engine libraries. */ +const TOOLING_PACKAGES = new Set([ + '@nanoforge-dev/actions', + '@nanoforge-dev/cli', + '@nanoforge-dev/config', + '@nanoforge-dev/schematics', + '@nanoforge-dev/utils-eslint-config', + '@nanoforge-dev/utils-prettier-config', +]); + +type Diagnostic = ProjectModel['diagnostics'][number]; + +interface PackageJson { + name?: string; + dependencies?: Record; + devDependencies?: Record; +} + +export interface DiscoveryInput { + readonly id: string; + readonly root: string; + readonly location: string; + readonly loader: ConfigLoader; +} + +const toPosix = (path: string) => path.split(sep).join('/'); + +export const findConfigFile = (directory: string): string | undefined => + CONFIG_FILES.map((file) => join(directory, file)).find((file) => existsSync(file)); + +const readJson = async (file: string): Promise => { + try { + return JSON.parse(await readFile(file, 'utf8')) as T; + } catch { + return undefined; + } +}; + +/** Installed version of a package, looked up from `from` up to the project root. */ +const installedVersion = async ( + name: string, + from: string, + root: string, +): Promise => { + for (let dir = from; ; dir = dirname(dir)) { + const pkg = await readJson<{ version?: string }>( + join(dir, 'node_modules', name, 'package.json'), + ); + if (pkg?.version) return pkg.version; + if (dir === root || dirname(dir) === dir) return undefined; + } +}; + +const engineLibs = async ( + appRoot: string, + projectRoot: string, + pkg: PackageJson | undefined, +): Promise> => { + const declared: Record = { ...pkg?.devDependencies, ...pkg?.dependencies }; + const names = new Set( + Object.keys(declared).filter( + (name) => name.startsWith('@nanoforge-dev/') && !TOOLING_PACKAGES.has(name), + ), + ); + const libs: Record = {}; + for (const name of [...names].sort()) { + libs[name] = (await installedVersion(name, appRoot, projectRoot)) ?? declared[name] ?? '*'; + } + return libs; +}; + +/** Every package an app's package.json depends on (narrowed to the project's libraries later). */ +const declaredPackages = (pkg: PackageJson | undefined): string[] => + Object.keys({ ...pkg?.devDependencies, ...pkg?.dependencies }); + +/** What an app says about the shared libraries it uses, before the project's are all known. */ +export interface LibraryUses { + /** The app's config file, for diagnostics. */ + readonly configFile: string; + /** Its config's `libs` (clients and servers): the written path and the folder it points at. */ + readonly libs?: readonly { readonly path: string; readonly root: string }[]; + /** Every package its package.json depends on. */ + readonly packages: readonly string[]; +} + +/** + * Fills each app's `libraries`: the shared libraries its `nanoforge.config` lists in `libs` + * (paths relative to the app, as the CLI writes them). A library depends on others through its + * package.json (a lib config has no `libs`). The package.json of a client or server should depend + * on the same libraries: where it does not, or where `libs` names a folder that is no library, + * a warning says so. A library nothing mentions (a project made before apps chose their + * libraries) is given to every app, so nothing disappears. + */ +export const resolveLibraries = ( + apps: AppModel[], + uses: ReadonlyMap, +): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const libraries = apps.filter((app) => app.type === 'lib'); + const mentioned = new Set(); + for (const app of apps) { + const use = uses.get(app.id); + const packages = use?.packages ?? []; + const declared = libraries.filter( + (library) => library !== app && packages.includes(library.name), + ); + if (app.type === 'lib' || !use?.libs) { + app.libraries = declared.map((library) => library.name); + for (const library of declared) mentioned.add(library.name); + continue; + } + const listed: AppModel[] = []; + for (const { path, root } of use.libs) { + const library = libraries.find((candidate) => candidate.root === root); + if (library) listed.push(library); + else + diagnostics.push({ + path: use.configFile, + message: `libs lists "${path}", which is not a shared library of the project.`, + severity: 'warning', + }); + } + for (const library of [...listed, ...declared]) mentioned.add(library.name); + for (const library of listed.filter((candidate) => !declared.includes(candidate))) + diagnostics.push({ + path: use.configFile, + message: `${app.name} uses ${library.name} (libs), but its package.json does not depend on it: add "${library.name}": "workspace:*".`, + severity: 'warning', + }); + for (const library of declared.filter((candidate) => !listed.includes(candidate))) + diagnostics.push({ + path: use.configFile, + message: `${app.name}'s package.json depends on ${library.name}, but its libs does not list ${library.root}: the editor does not give it to the app.`, + severity: 'warning', + }); + app.libraries = listed.map((library) => library.name); + } + const unclaimed = libraries.filter((library) => !mentioned.has(library.name)); + for (const library of unclaimed) library.unclaimed = true; + for (const app of apps) { + app.libraries = [ + ...new Set([ + ...app.libraries, + ...unclaimed.filter((library) => library !== app).map((library) => library.name), + ]), + ].sort(); + } + return diagnostics; +}; + +const toApp = async ( + projectRoot: string, + appRoot: string, + config: ResolvedConfig, + uses: Map, +): Promise => { + if (config.type === 'workspace') return undefined; + const root = toPosix(relative(projectRoot, appRoot)); + const pkg = await readJson(join(appRoot, 'package.json')); + const name = pkg?.name ?? (basename(appRoot) || 'app'); + const at = (path: string) => joinPath(root, path); + const configFile = toPosix(relative(projectRoot, findConfigFile(appRoot) ?? appRoot)); + uses.set(root, { + configFile, + packages: declaredPackages(pkg), + ...(config.type !== 'lib' && { + libs: config.libs.map((path) => ({ + path, + root: toPosix(relative(projectRoot, resolve(appRoot, path))), + })), + }), + }); + + if (config.type === 'lib') { + return { + id: root, + name, + type: 'lib', + root, + language: 'ts', + dirs: { + assets: at(config.dir.assets), + components: at(config.dir.components), + systems: at(config.dir.systems), + scenes: at(config.dir.scenes), + }, + engineLibs: await engineLibs(appRoot, projectRoot, pkg), + libraries: [], + }; + } + return { + id: root, + name, + type: config.type, + root, + language: config.language, + dirs: { + assets: at(config.dir.assets), + components: at(config.dir.components), + systems: at(config.dir.systems), + scenes: at(config.dir.scenes), + }, + entryFile: at(config.entryFile), + editorEntryFile: at(config.editor.entryFile), + outDir: at(config.out.dir), + engineLibs: await engineLibs(appRoot, projectRoot, pkg), + libraries: [], + }; +}; + +/** + * Builds the project model from `nanoforge.config.*` files: a workspace lists its apps + * through `packages` globs; a project whose root config is an app is a single-app project. + * Problems become diagnostics instead of failing the whole discovery. + */ +export const discoverProject = async (input: DiscoveryInput): Promise => { + const { root, loader } = input; + const diagnostics: Diagnostic[] = []; + const apps: AppModel[] = []; + const uses = new Map(); + const rootPkg = await readJson(join(root, 'package.json')); + const model = (): ProjectModel => ({ + id: input.id, + name: rootPkg?.name ?? basename(root), + location: input.location, + apps: apps.sort((a, b) => a.id.localeCompare(b.id)), + diagnostics, + }); + + const load = async (directory: string): Promise => { + const file = findConfigFile(directory); + const path = toPosix(relative(root, file ?? join(directory, CONFIG_FILES[0]!))); + if (!file) { + const legacy = existsSync(join(directory, 'nanoforge.config.json')); + diagnostics.push({ + path: legacy ? toPosix(relative(root, join(directory, 'nanoforge.config.json'))) : path, + message: legacy + ? 'This project was made by an older NanoForge CLI (nanoforge.config.json). The editor needs a project made by the CLI version 2 or later (nanoforge.config.ts): update the CLI and create the project again.' + : 'No nanoforge.config file found', + severity: 'error', + }); + return undefined; + } + try { + return await loader.load(file); + } catch (error) { + const message = + error instanceof ConfigLoadError || error instanceof Error ? error.message : String(error); + diagnostics.push({ path, message, severity: 'error' }); + return undefined; + } + }; + + const rootConfig = await load(root); + if (!rootConfig) return model(); + if (rootConfig.type !== 'workspace') { + const app = await toApp(root, root, rootConfig, uses); + if (app) apps.push(app); + return model(); + } + + for (const directory of await expandDirectoryGlobs(root, rootConfig.packages)) { + const appRoot = join(root, directory); + /** Plain folders matched by a glob. */ + if (!findConfigFile(appRoot)) continue; + const config = await load(appRoot); + if (!config) continue; + if (config.type === 'workspace') { + diagnostics.push({ + path: joinPath(directory, 'nanoforge.config.ts'), + message: 'Nested workspaces are not supported', + severity: 'warning', + }); + continue; + } + const app = await toApp(root, appRoot, config, uses); + if (app) apps.push(app); + } + diagnostics.push(...resolveLibraries(apps, uses)); + if (!apps.length) { + diagnostics.push({ path: '', message: 'The workspace has no apps', severity: 'warning' }); + } + return model(); +}; diff --git a/packages/server-core/src/project/formatter.ts b/packages/server-core/src/project/formatter.ts new file mode 100644 index 00000000..f6e89dea --- /dev/null +++ b/packages/server-core/src/project/formatter.ts @@ -0,0 +1,97 @@ +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import * as bundledPrettier from 'prettier'; +import { parse as parseYaml } from 'yaml'; + +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +type Prettier = Pick; + +export interface FormatResult { + readonly text: string; + readonly formatter: 'project' | 'bundled' | 'none'; +} + +/** Static Prettier configs (no code): what hosted editors may read. */ +const STATIC_CONFIGS = ['.prettierrc', '.prettierrc.json', '.prettierrc.yaml', '.prettierrc.yml']; + +/** + * Formats files with Prettier. + * + * - `project`: the project's Prettier, config and plugins (executes project code, local only). + * - `static`: the bundled Prettier with the closest JSON/YAML config, without plugins. + */ +export class Formatter { + private readonly _projectPrettier = new Map>(); + + constructor(private readonly _mode: 'project' | 'static') {} + + async format(root: string, absolute: string, text: string): Promise { + const project = this._mode === 'project' ? await this._resolveProject(absolute) : undefined; + const prettier: Prettier = project ?? bundledPrettier; + const ignorePath = join(root, '.prettierignore'); + const info = await prettier + .getFileInfo(absolute, { ignorePath, resolveConfig: false }) + .catch(() => ({ ignored: false, inferredParser: null })); + if (info.ignored || !info.inferredParser) return { text, formatter: 'none' }; + const config = project + ? await prettier.resolveConfig(absolute, { editorconfig: true }) + : await readStaticConfig(root, absolute); + try { + const formatted = await prettier.format(text, { ...config, filepath: absolute }); + return { text: formatted, formatter: project ? 'project' : 'bundled' }; + } catch (error) { + throw new RpcError( + 'BAD_REQUEST', + `Prettier could not format the file: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * The Prettier installed for the file (closest node_modules), else undefined. Only a found one + * is kept: Prettier installed later is picked up by the next format. + */ + private _resolveProject(absolute: string): Promise { + const from = dirname(absolute); + let prettier = this._projectPrettier.get(from); + if (!prettier) { + prettier = (async () => { + try { + const entry = createRequire(join(from, 'noop.js')).resolve('prettier'); + const module = (await import(pathToFileURL(entry).href)) as Partial & { + default?: Prettier; + }; + return typeof module.format === 'function' ? (module as Prettier) : module.default; + } catch { + return undefined; + } + })(); + this._projectPrettier.set(from, prettier); + void prettier.then((found) => { + if (!found && this._projectPrettier.get(from) === prettier) + this._projectPrettier.delete(from); + }); + } + return prettier; + } +} + +/** The closest static config from the file up to the project root, without plugins. */ +const readStaticConfig = async (root: string, absolute: string) => { + for (let dir = dirname(absolute); ; dir = dirname(dir)) { + for (const name of STATIC_CONFIGS) { + const text = await readFile(join(dir, name), 'utf8').catch(() => undefined); + if (text === undefined) continue; + const parsed: unknown = name.endsWith('.json') ? JSON.parse(text) : parseYaml(text); + if (!parsed || typeof parsed !== 'object') return {}; + const config = { ...(parsed as Record) }; + delete config.plugins; + delete config.overrides; + return config; + } + if (dir === root || dirname(dir) === dir) return {}; + } +}; diff --git a/packages/server-core/src/project/index.ts b/packages/server-core/src/project/index.ts new file mode 100644 index 00000000..0fab4270 --- /dev/null +++ b/packages/server-core/src/project/index.ts @@ -0,0 +1,4 @@ +export * from './config-loader'; +export * from './discovery'; +export * from './project-registry'; +export * from './formatter'; diff --git a/packages/server-core/src/project/project-registry.ts b/packages/server-core/src/project/project-registry.ts new file mode 100644 index 00000000..c23074ef --- /dev/null +++ b/packages/server-core/src/project/project-registry.ts @@ -0,0 +1,346 @@ +import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; + +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + type Logger, + type Observable, + ObservableValue, +} from '@nanoforge-dev/editor-kernel'; +import type { ProjectModel, ProjectRef, RecentProject, User } from '@nanoforge-dev/editor-protocol'; +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +import type { ApiClient } from '../api/api-client'; +import type { EditorEnv } from '../env/editor-env.type'; +import { NFIGNORE_FILE, ProjectFileSystem } from '../fs/fs-service'; +import { PathJail } from '../fs/path-jail'; +import { ProjectWatcher } from '../fs/watcher'; +import type { GitService } from '../git/git-service'; +import type { Session } from '../session/session-store'; +import { shortHash } from '../util/hash'; +import { KeyedMutex } from '../util/keyed-mutex'; +import { type ConfigLoader, executingConfigLoader, staticConfigLoader } from './config-loader'; +import { discoverProject } from './discovery'; +import { TrustStore } from './trust-store'; + +const MAX_RECENT = 20; +const GATEWAY_ID = /^[\w-]{1,60}$/; +const CONFIG_CHANGE = /(^|\/)(nanoforge\.config\.[mc]?[jt]s|package\.json)$/; + +/** An opened project: its files, watcher and live model. */ +export class OpenProject implements Disposable { + readonly fs: ProjectFileSystem; + readonly watcher: ProjectWatcher; + private readonly _model: ObservableValue; + private readonly _store = new DisposableStore(); + private _rediscovery: ReturnType | undefined; + + constructor( + readonly id: string, + readonly root: string, + readonly ref: ProjectRef, + readonly location: string, + /** + * Whether its code may run: a local project the user trusted. A hosted project never is (its + * code runs nowhere on the server); an untrusted local one opens in restricted mode. + */ + readonly trusted: boolean, + initial: ProjectModel, + private readonly _loader: ConfigLoader, + private readonly _logger: Logger, + ) { + this.fs = new ProjectFileSystem(root); + this._model = new ObservableValue(initial); + this.watcher = new ProjectWatcher(this.fs.jail, { + ignoredPaths: () => this._model.get().apps.flatMap((app) => (app.outDir ? [app.outDir] : [])), + isIgnored: (path, directory) => this.fs.isIgnored(path, directory), + }); + void this.fs.refreshIgnore(); + this._store.add( + this.watcher.onDidChange((changes) => { + if (changes.some((change) => CONFIG_CHANGE.test(change.path))) this._scheduleRediscovery(); + if (changes.some((change) => change.path === NFIGNORE_FILE)) void this.fs.refreshIgnore(); + }), + ); + } + + get name(): string { + return this._model.get().name; + } + + get model(): Observable { + return this._model.readonly(); + } + + async rediscover(): Promise { + const model = await discoverProject({ + id: this.id, + root: this.root, + location: this.location, + loader: this._loader, + }); + this._model.set(model); + return model; + } + + dispose(): void { + clearTimeout(this._rediscovery); + this._store.dispose(); + this.watcher.dispose(); + } + + private _scheduleRediscovery(): void { + clearTimeout(this._rediscovery); + this._rediscovery = setTimeout(() => { + this.rediscover().catch((error: unknown) => + this._logger.error(`Rediscovery of ${this.id} failed`, error), + ); + }, 200); + } +} + +interface RecentStore { + [userId: string]: (Omit & { openedAt: string })[]; +} + +/** + * Opens projects and keeps them alive. Project ids are stable: a hash of the real path + * (offline) or the gateway id (online), independent from sessions. + */ +export class ProjectRegistry implements Disposable { + private readonly _projects = new Map(); + /** When each open project was last asked for. */ + private readonly _lastUsed = new Map(); + private readonly _opening = new KeyedMutex(); + private readonly _recentLock = new KeyedMutex(); + private readonly _onDidClose = new Emitter(); + /** The folders whose code a local editor may run. */ + readonly trust: TrustStore; + + /** A project was closed (nobody uses it anymore): its runtime can stop too. */ + readonly onDidClose: Event = this._onDidClose.event; + + constructor( + private readonly _env: EditorEnv, + private readonly _git: GitService, + private readonly _api: ApiClient, + private readonly _logger: Logger, + ) { + this.trust = new TrustStore(_env.dataDir, async () => + Object.values(await this._readRecent()).flatMap((entries) => + entries.flatMap((entry) => ('path' in entry.ref ? [entry.ref.path] : [])), + ), + ); + } + + /** Throws when a project's code may not run (hosted, or a local folder not trusted). */ + assertTrusted(project: OpenProject, what: string): void { + if (project.trusted) return; + throw new RpcError( + 'FORBIDDEN', + this._env.mode === 'ONLINE' + ? `${what} is not available in a hosted editor` + : `${what} needs a trusted folder: trust ${project.name} first`, + ); + } + + /** + * Trusts a local project's folder. The project closes, so it opens again with its code + * running (its config read by running it). + */ + async trustProject(project: OpenProject): Promise { + if (this._env.mode !== 'OFFLINE' || !('path' in project.ref)) + throw new RpcError('FORBIDDEN', 'Only local folders can be trusted'); + await this.trust.trust(project.ref.path); + this.close(project.id); + } + + async open(session: Session, ref: ProjectRef): Promise { + const project = + 'path' in ref + ? await this._openLocal(ref.path) + : await this._openGateway(session, ref.gatewayId); + session.projects.add(project.id); + this._lastUsed.set(project.id, Date.now()); + if (session.user) await this._remember(session.user, project); + return project; + } + + /** Returns an opened project the session may access, else throws NOT_FOUND. */ + get(session: Session, id: string): OpenProject { + const project = this._projects.get(id); + const allowed = session.mode === 'OFFLINE' || session.projects.has(id); + if (!project || !allowed) throw new RpcError('NOT_FOUND', `Project ${id} is not open`); + this._lastUsed.set(id, Date.now()); + return project; + } + + /** Ids of the open projects. */ + get openIds(): string[] { + return [...this._projects.keys()]; + } + + /** + * Closes the projects nobody uses: no `inUse` (a connected editor holds it) and not asked for + * during `idleMs`. Each one keeps a watcher, so a hosted server must not keep them all. + */ + sweep(inUse: (id: string) => boolean, idleMs: number, now = Date.now()): void { + for (const id of this.openIds) { + if (!inUse(id) && now - (this._lastUsed.get(id) ?? 0) > idleMs) this.close(id); + } + } + + close(id: string): void { + const project = this._projects.get(id); + if (!project) return; + this._projects.delete(id); + this._lastUsed.delete(id); + project.dispose(); + this._logger.info(`Closed project ${project.name} (${id})`); + this._onDidClose.fire(id); + } + + async recent(user: User): Promise { + const store = await this._readRecent(); + return (store[user.id] ?? []).map((entry) => ({ + ...entry, + openedAt: new Date(entry.openedAt), + })); + } + + forget(user: User, id: string): Promise { + return this._updateRecent((store) => { + store[user.id] = (store[user.id] ?? []).filter((entry) => entry.id !== id); + }); + } + + dispose(): void { + for (const project of this._projects.values()) project.dispose(); + this._projects.clear(); + this._onDidClose.dispose(); + } + + private async _openLocal(path: string): Promise { + if (this._env.mode === 'ONLINE') { + throw new RpcError('FORBIDDEN', 'Local projects cannot be opened on a hosted editor'); + } + const target = resolve(this._env.fsRoot, path); + const rel = relative(this._env.fsRoot, target); + if (rel.startsWith('..') || isAbsolute(rel)) { + throw new RpcError('FORBIDDEN', `Projects must be inside ${this._env.fsRoot}`); + } + const info = await stat(target).catch(() => undefined); + if (!info?.isDirectory()) throw new RpcError('NOT_FOUND', `No project directory at ${path}`); + const real = await realpath(target); + const trusted = await this.trust.isTrusted(real); + return this._getOrCreate(shortHash(real), real, { path: real }, real, trusted); + } + + private async _openGateway(session: Session, gatewayId: string): Promise { + if (this._env.mode !== 'ONLINE') { + throw new RpcError('FORBIDDEN', 'Gateway projects need an online editor'); + } + if (!GATEWAY_ID.test(gatewayId)) { + throw new RpcError('BAD_REQUEST', `Not a project id: ${gatewayId.slice(0, 80)}`); + } + const gateway = await this._api.gatewayProject(session, gatewayId); + const id = `gw-${gatewayId}`; + return this._opening.run(id, async () => { + const root = join(this._env.fsRoot, id); + const url = gateway.gatewayProjectRegistryUrl; + if (!this._git.isRepository(root)) { + await mkdir(dirname(root), { recursive: true }); + await this._git.clone(url, root, gateway.token); + } else if (!this._projects.has(id)) { + await this._git.pull(root, url, gateway.token); + } + const subdir = gateway.gatewayProjectRegistryMetadata.dir; + const projectRoot = subdir ? await new PathJail(root).resolve(subdir) : root; + return this._getOrCreate(id, projectRoot, { gatewayId }, gateway.name, false); + }); + } + + private _getOrCreate( + id: string, + root: string, + ref: ProjectRef, + location: string, + trusted: boolean, + ): Promise { + return this._opening.run(`open:${id}`, async () => { + const existing = this._projects.get(id); + if (existing) return existing; + const loader = trusted ? executingConfigLoader : staticConfigLoader; + const model = await discoverProject({ id, root, location, loader }); + const project = new OpenProject( + id, + root, + ref, + location, + trusted, + model, + loader, + this._logger, + ); + this._projects.set(id, project); + this._logger.info(`Opened project ${model.name} (${id}) at ${root}`); + return project; + }); + } + + private _remember(user: User, project: OpenProject): Promise { + const entry = { + id: project.id, + name: project.name, + location: project.location, + ref: project.ref, + openedAt: new Date().toISOString(), + }; + return this._updateRecent((store) => { + store[user.id] = [ + entry, + ...(store[user.id] ?? []).filter((item) => item.id !== project.id), + ].slice(0, MAX_RECENT); + }); + } + + /** Read, change and write the recent projects one at a time: two opens never lose a list. */ + private _updateRecent(change: (store: RecentStore) => void): Promise { + return this._recentLock.run('recent', async () => { + const store = await this._readRecent(); + change(store); + await this._writeRecent(store); + }); + } + + private get _recentFile(): string { + return join(this._env.dataDir, 'recent-projects.json'); + } + + private async _readRecent(): Promise { + if (!existsSync(this._recentFile)) return {}; + try { + return JSON.parse(await readFile(this._recentFile, 'utf8')) as RecentStore; + } catch (error) { + this._logger.warn(`Ignoring unreadable ${basename(this._recentFile)}`, error); + return {}; + } + } + + private async _writeRecent(store: RecentStore): Promise { + await mkdir(this._env.dataDir, { recursive: true }); + const temp = `${this._recentFile}.${randomBytes(4).toString('hex')}.tmp`; + try { + await writeFile(temp, `${JSON.stringify(store, null, 2)}\n`); + await rename(temp, this._recentFile); + } catch (error) { + await rm(temp, { force: true }); + throw error; + } + } +} diff --git a/packages/server-core/src/project/trust-store.ts b/packages/server-core/src/project/trust-store.ts new file mode 100644 index 00000000..ca2a038f --- /dev/null +++ b/packages/server-core/src/project/trust-store.ts @@ -0,0 +1,76 @@ +import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { KeyedMutex } from '../util/keyed-mutex'; + +interface TrustFile { + /** Real paths of the folders whose code the local editor may run. */ + readonly folders: string[]; +} + +/** + * The folders a local editor trusts: opening one runs its `nanoforge.config.ts`, its plugins' + * server entries, its builds and games, its Prettier and its git hooks. Kept in the editor's + * data folder (per OS user), by real path, so a link cannot stand in for a trusted folder. + */ +export class TrustStore { + private readonly _lock = new KeyedMutex(); + private _folders: Set | undefined; + + constructor( + private readonly _dataDir: string, + /** + * Folders trusted when the store is first created: those opened before trust existed, under a + * prompt that already said their code would run. + */ + private readonly _initial: () => Promise, + ) {} + + async isTrusted(realPath: string): Promise { + return (await this._load()).has(realPath); + } + + trust(realPath: string): Promise { + return this._lock.run('trust', async () => { + const folders = await this._load(); + if (folders.has(realPath)) return; + folders.add(realPath); + await this._write(folders); + }); + } + + private get _file(): string { + return join(this._dataDir, 'trusted-folders.json'); + } + + private async _load(): Promise> { + if (this._folders) return this._folders; + if (!existsSync(this._file)) { + const folders = new Set(await this._initial()); + await this._write(folders); + this._folders = folders; + return folders; + } + try { + const file = JSON.parse(await readFile(this._file, 'utf8')) as Partial; + this._folders = new Set(Array.isArray(file.folders) ? file.folders : []); + } catch { + this._folders = new Set(); + } + return this._folders; + } + + private async _write(folders: ReadonlySet): Promise { + await mkdir(this._dataDir, { recursive: true }); + const temp = `${this._file}.${randomBytes(4).toString('hex')}.tmp`; + try { + await writeFile(temp, `${JSON.stringify({ folders: [...folders].sort() }, null, 2)}\n`); + await rename(temp, this._file); + } catch (error) { + await rm(temp, { force: true }); + throw error; + } + } +} diff --git a/packages/server-core/src/project/type-files.ts b/packages/server-core/src/project/type-files.ts new file mode 100644 index 00000000..3767ac27 --- /dev/null +++ b/packages/server-core/src/project/type-files.ts @@ -0,0 +1,65 @@ +import { existsSync } from 'node:fs'; +import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, sep } from 'node:path'; + +const MAX_FILES = 400; +const MAX_BYTES = 8 * 1024 * 1024; + +const inside = (root: string, path: string) => { + const rel = relative(root, path); + return !rel.startsWith('..') && !isAbsolute(rel); +}; + +/** + * Finds `node_modules/` from `fromDir` up to `root` and returns its package.json and + * type declaration files (paths relative to `root`). Links inside the package are never followed. + * The package folder itself may be a link (pnpm, `link:` dependencies) unless `contained`: then + * it must lead inside the project, so that a committed `node_modules/x -> /` reads nothing of + * the host (hosted editors). + */ +export const collectTypeFiles = async ( + root: string, + fromDir: string, + module: string, + options: { contained?: boolean } = {}, +): Promise<{ path: string; text: string }[]> => { + let packageDir: string | undefined; + for (let dir = fromDir; ; dir = dirname(dir)) { + const candidate = join(dir, 'node_modules', ...module.split('/')); + if (existsSync(join(candidate, 'package.json'))) { + packageDir = candidate; + break; + } + if (dir === root || dirname(dir) === dir || relative(root, dir).startsWith('..')) break; + } + if (!packageDir || relative(root, packageDir).startsWith('..')) return []; + if (options.contained && !inside(await realpath(root), await realpath(packageDir))) return []; + + const files: { path: string; text: string }[] = []; + let bytes = 0; + const add = async (file: string) => { + if (files.length >= MAX_FILES || bytes > MAX_BYTES) return; + if (!(await lstat(file)).isFile()) return; + const text = await readFile(file, 'utf8'); + bytes += text.length; + files.push({ path: relative(root, file).split(sep).join('/'), text }); + }; + await add(join(packageDir, 'package.json')); + const walk = async (dir: string): Promise => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (files.length >= MAX_FILES) return; + const path = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'node_modules' && !entry.name.startsWith('.')) await walk(path); + } else if ( + entry.isFile() && + /\.d\.[cm]?ts$/.test(entry.name) && + (await lstat(path)).size < 2 * 1024 * 1024 + ) { + await add(path); + } + } + }; + await walk(packageDir); + return files; +}; diff --git a/packages/server-core/src/rpc/core-rpc.type.ts b/packages/server-core/src/rpc/core-rpc.type.ts new file mode 100644 index 00000000..e6a6ec71 --- /dev/null +++ b/packages/server-core/src/rpc/core-rpc.type.ts @@ -0,0 +1,32 @@ +import type { ApiClient } from '../api/api-client'; +import type { EditorEnv } from '../env/editor-env.type'; +import type { GitService } from '../git/git-service'; +import type { LocatedPlugin, PluginSources } from '../plugin/plugin-sources'; +import type { CliService } from '../process/cli-service'; +import type { ProjectRegistry } from '../project/project-registry'; +import type { RuntimeManager } from '../runtime/project-runtime'; +import type { SessionStore } from '../session/session-store'; +import type { + AccountSettingsBackend, + ProjectLocalSettingsStore, +} from '../settings/account-backends'; + +export interface CoreRpcDependencies { + readonly env: EditorEnv; + readonly version: string; + readonly sessions: SessionStore; + readonly projects: ProjectRegistry; + readonly plugins: PluginSources; + /** Runs a project plugin's server entry (local editors only). */ + readonly activateServerPlugin?: (plugin: LocatedPlugin) => Promise; + /** Stops a plugin's server entry (it was uninstalled, or is being replaced); `dir`: that copy only. */ + readonly deactivateServerPlugin?: (name: string, dir?: string) => Promise; + /** Runs exactly these project plugins' server entries, stopping the others (local editors). */ + readonly syncProjectServerPlugins?: (plugins: readonly LocatedPlugin[]) => Promise; + readonly accountSettings: AccountSettingsBackend; + readonly cli: CliService; + readonly api: ApiClient; + readonly projectLocalSettings: ProjectLocalSettingsStore; + readonly runtime: RuntimeManager; + readonly git: GitService; +} diff --git a/packages/server-core/src/rpc/implement-core-rpc.ts b/packages/server-core/src/rpc/implement-core-rpc.ts new file mode 100644 index 00000000..e1cd0abb --- /dev/null +++ b/packages/server-core/src/rpc/implement-core-rpc.ts @@ -0,0 +1,34 @@ +import { type Disposable, DisposableStore } from '@nanoforge-dev/editor-kernel'; +import type { RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../session/auth'; +import type { CoreRpcDependencies } from './core-rpc.type'; +import { implementCodeRpc } from './implementation/code.implementation'; +import { implementFsRpc } from './implementation/fs.implementation'; +import { implementGitRpc } from './implementation/git.implementation'; +import { implementPluginsRpc } from './implementation/plugins.implementation'; +import { implementProjectsRpc } from './implementation/projects.implementation'; +import { implementRegistryRpc } from './implementation/registry.implementation'; +import { implementRuntimeRpc } from './implementation/runtime.implementation'; +import { implementSessionRpc } from './implementation/session.implementation'; +import { implementSettingsRpc } from './implementation/settings.implementation'; + +export const implementCoreRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const store = new DisposableStore(); + for (const implement of [ + implementSessionRpc, + implementProjectsRpc, + implementFsRpc, + implementPluginsRpc, + implementSettingsRpc, + implementCodeRpc, + implementRuntimeRpc, + implementGitRpc, + implementRegistryRpc, + ]) + store.add(implement(router, deps)); + return store; +}; diff --git a/packages/server-core/src/rpc/implementation/code.implementation.ts b/packages/server-core/src/rpc/implementation/code.implementation.ts new file mode 100644 index 00000000..fee5e9ba --- /dev/null +++ b/packages/server-core/src/rpc/implementation/code.implementation.ts @@ -0,0 +1,33 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { CodeContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import { Formatter } from '../../project/formatter'; +import { collectTypeFiles } from '../../project/type-files'; +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the code contract. */ +export const implementCodeRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env, projects } = deps; + /** The project's own Prettier runs its code: only in a trusted folder. */ + const projectFormatter = new Formatter('project'); + const staticFormatter = new Formatter('static'); + return router.implement(CodeContract, { + methods: { + typeFiles: async ({ project, from, module }, { session }) => { + const open = projects.get(session, project); + const fromDir = await open.fs.jail.resolve(from.split('/').slice(0, -1).join('/')); + return collectTypeFiles(open.root, fromDir, module, { contained: env.mode === 'ONLINE' }); + }, + format: async ({ project, path, text }, { session }) => { + const open = projects.get(session, project); + const formatter = open.trusted ? projectFormatter : staticFormatter; + return formatter.format(open.root, await open.fs.jail.resolve(path), text); + }, + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/fs.implementation.ts b/packages/server-core/src/rpc/implementation/fs.implementation.ts new file mode 100644 index 00000000..37cc1336 --- /dev/null +++ b/packages/server-core/src/rpc/implementation/fs.implementation.ts @@ -0,0 +1,47 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { FsContract } from '@nanoforge-dev/editor-protocol'; +import { RpcError, type RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import { revealInFileManager } from '../../fs/reveal'; +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the file system contract. */ +export const implementFsRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env, projects } = deps; + return router.implement(FsContract, { + methods: { + stat: ({ project, path }, { session }) => projects.get(session, project).fs.stat(path), + list: ({ project, path, recursive }, { session }) => + projects.get(session, project).fs.list(path, recursive), + read: ({ project, path }, { session }) => projects.get(session, project).fs.read(path), + write: ({ project, path, content, expectedHash, createParents }, { session }) => + projects.get(session, project).fs.write(path, content, { + ...(expectedHash !== undefined && { expectedHash }), + createParents, + }), + mkdir: ({ project, path }, { session }) => projects.get(session, project).fs.mkdir(path), + rename: ({ project, from, to, overwrite }, { session }) => + projects.get(session, project).fs.rename(from, to, overwrite), + copy: ({ project, from, to, overwrite }, { session }) => + projects.get(session, project).fs.copy(from, to, overwrite), + delete: ({ project, path }, { session }) => projects.get(session, project).fs.delete(path), + restore: ({ project, trashPath, path }, { session }) => + projects.get(session, project).fs.restore(trashPath, path), + reveal: async ({ project, path }, { session }) => { + if (env.mode !== 'OFFLINE') { + throw new RpcError('FORBIDDEN', 'Revealing files needs a local editor'); + } + await revealInFileManager(await projects.get(session, project).fs.jail.resolve(path)); + return null; + }, + }, + streams: { + changes: ({ project }, { session }, sink) => + projects.get(session, project).watcher.onDidChange((changes) => sink.emit({ changes })), + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/git.implementation.ts b/packages/server-core/src/rpc/implementation/git.implementation.ts new file mode 100644 index 00000000..31019e55 --- /dev/null +++ b/packages/server-core/src/rpc/implementation/git.implementation.ts @@ -0,0 +1,56 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { GitContract } from '@nanoforge-dev/editor-protocol'; +import { RpcError, type RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the git contract. */ +export const implementGitRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env, projects } = deps; + const repo = (session: RequestContext['session'], project: string) => { + if (env.mode !== 'OFFLINE') throw new RpcError('FORBIDDEN', 'Git is for local editors'); + return projects.get(session, project).root; + }; + const { git } = deps; + const nothing = + (run: (root: string, input: I) => Promise) => + async (input: I, { session }: RequestContext) => { + await run(repo(session, input.project), input); + return null; + }; + return router.implement(GitContract, { + methods: { + status: ({ project }, { session }) => git.projectStatus(repo(session, project)), + init: nothing((root) => git.init(root)), + stage: nothing((root, { paths }) => git.stage(root, paths)), + unstage: nothing((root, { paths }) => git.unstage(root, paths)), + discard: nothing((root, { paths }) => git.discard(root, paths)), + commit: nothing((root, { message, all, paths, amend }) => + git.commit(root, message, { all, amend, ...(paths && { paths }) }), + ), + fetch: nothing((root) => git.fetch(root)), + pull: ({ project }, { session }) => git.mergePull(repo(session, project)), + push: nothing((root) => git.push(root)), + branches: ({ project }, { session }) => git.branches(repo(session, project)), + switchBranch: nothing((root, { name }) => git.switchBranch(root, name)), + createBranch: nothing((root, { name, from }) => git.createBranch(root, name, from)), + deleteBranch: nothing((root, { name, force }) => git.deleteBranch(root, name, force)), + log: ({ project, limit, skip, branch }, { session }) => + git.log(repo(session, project), limit, skip, branch), + headMessage: ({ project }, { session }) => git.headMessage(repo(session, project)), + outgoing: ({ project }, { session }) => git.outgoing(repo(session, project)), + commitFiles: ({ project, hash }, { session }) => + git.commitFiles(repo(session, project), hash), + show: ({ project, path, revision }, { session }) => + git.show(repo(session, project), path, revision), + stashes: ({ project }, { session }) => git.stashes(repo(session, project)), + stash: nothing((root, { message }) => git.stash(root, message)), + applyStash: nothing((root, { index, pop }) => git.applyStash(root, index, pop)), + dropStash: nothing((root, { index }) => git.dropStash(root, index)), + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/plugins.implementation.ts b/packages/server-core/src/rpc/implementation/plugins.implementation.ts new file mode 100644 index 00000000..56f74184 --- /dev/null +++ b/packages/server-core/src/rpc/implementation/plugins.implementation.ts @@ -0,0 +1,50 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { PluginsContract } from '@nanoforge-dev/editor-protocol'; +import { RpcError, type RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the plugins contract. */ +export const implementPluginsRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { projects } = deps; + return router.implement(PluginsContract, { + methods: { + list: async (input, { session }) => { + const project = input?.project ? projects.get(session, input.project) : undefined; + return (await deps.plugins.list(project)).map((plugin) => plugin.listing); + }, + runProjectServers: async ({ project: id, names }, { session }) => { + if (deps.env.mode !== 'OFFLINE') + throw new RpcError( + 'FORBIDDEN', + "A hosted editor never runs a project plugin's server entry", + ); + const project = id ? projects.get(session, id) : undefined; + if (project && names.length) + projects.assertTrusted(project, "Running the project's plugins"); + const located = project ? await deps.plugins.list(project) : []; + await deps.syncProjectServerPlugins?.( + located.filter( + (plugin) => + plugin.source === 'project' && + plugin.manifest?.entry.server && + names.includes(plugin.manifest.name), + ), + ); + return null; + }, + suggestions: ({ project }, { session }) => + deps.plugins.suggestions(projects.get(session, project).root), + }, + streams: { + devChanges: (_params, _context, sink) => + deps.plugins.onDevChange((plugin) => { + if (plugin.manifest) sink.emit({ name: plugin.manifest.name, listing: plugin.listing }); + }), + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/projects.implementation.ts b/packages/server-core/src/rpc/implementation/projects.implementation.ts new file mode 100644 index 00000000..303df795 --- /dev/null +++ b/packages/server-core/src/rpc/implementation/projects.implementation.ts @@ -0,0 +1,104 @@ +import { existsSync } from 'node:fs'; +import { realpath } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { type Disposable, observe } from '@nanoforge-dev/editor-kernel'; +import { ProjectsContract } from '@nanoforge-dev/editor-protocol'; +import { RpcError, type RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; +import { packageManagerOf } from '../package-manager-of'; +import { requireUser } from '../require-user'; + +/** Implements the projects contract. */ +export const implementProjectsRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env, projects } = deps; + return router.implement(ProjectsContract, { + methods: { + open: async (ref, { session }) => { + const project = await projects.open(session, ref); + return { id: project.id, name: project.name, trusted: project.trusted }; + }, + isTrusted: ({ id }, { session }) => projects.get(session, id).trusted, + trust: async ({ id }, { session }) => { + await projects.trustProject(projects.get(session, id)); + return null; + }, + recent: (_input, context) => projects.recent(requireUser(context)), + forget: async ({ id }, context) => { + await projects.forget(requireUser(context), id); + return null; + }, + model: ({ id }, { session }) => projects.get(session, id).model.get(), + install: async ({ id }, { session }) => { + if (env.mode !== 'OFFLINE') + throw new RpcError('FORBIDDEN', 'Dependencies are installed in local editors only'); + const open = projects.get(session, id); + projects.assertTrusted(open, 'Installing dependencies'); + const { root } = open; + const manager = packageManagerOf(root); + if (!manager) return { manager: null, ok: false }; + const result = await deps.cli.exec(manager, ['install'], { cwd: root }).done; + return { manager, ok: result.exitCode === 0 }; + }, + create: async ({ name, language, server, install, packageManager }, { session }) => { + if (env.mode !== 'OFFLINE') + throw new RpcError('FORBIDDEN', 'Create projects from the NanoForge website'); + if (existsSync(join(env.fsRoot, name))) + throw new RpcError('CONFLICT', `A folder named ${name} already exists`); + const run = deps.cli.run( + [ + 'new', + '--name', + name, + '--path', + name, + '--language', + language, + '--package-manager', + packageManager, + '--strict', + server ? '--server' : '--no-server', + '--no-git', + '--no-docker', + install ? '--no-skip-install' : '--skip-install', + ], + { cwd: env.fsRoot }, + ); + const result = await run.done; + if (result.exitCode !== 0) { + const output = (result.stderr || result.stdout).slice(-2000); + const reason = output + // eslint-disable-next-line no-control-regex + .replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .at(-1); + throw new RpcError('INTERNAL', `nf new failed${reason ? `: ${reason}` : ''}`, { + output, + }); + } + await projects.trust.trust(await realpath(join(env.fsRoot, name))); + const project = await projects.open(session, { path: name }); + return { id: project.id, name: project.name, trusted: project.trusted }; + }, + gateways: async (_input, { session }) => + env.mode === 'ONLINE' + ? (await deps.api.gatewayProjects(session)).map((project) => ({ + gatewayId: project.id, + name: project.name, + description: project.description, + })) + : [], + }, + streams: { + model: ({ id }, { session }, sink) => + observe(projects.get(session, id).model, (model) => sink.emit(model)), + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/registry.implementation.ts b/packages/server-core/src/rpc/implementation/registry.implementation.ts new file mode 100644 index 00000000..44c3b225 --- /dev/null +++ b/packages/server-core/src/rpc/implementation/registry.implementation.ts @@ -0,0 +1,165 @@ +import { existsSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { type InstalledPackage, RegistryContract } from '@nanoforge-dev/editor-protocol'; +import { RpcError, type RpcRouter } from '@nanoforge-dev/editor-rpc'; +import { + DirectoryRegistry, + type OutdatedPackage, + ProjectPackages, + type Registry, + RegistryClient, + RegistryError, + installItem, + pickVersion, +} from '@nanoforge-dev/registry'; + +import { PROJECT_PLUGINS_DIR } from '../../plugin/plugin-sources'; +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the registry contract. */ +export const implementRegistryRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env, projects } = deps; + const noRegistry = (): never => { + throw new RegistryError('UNAVAILABLE', 'The registry is not available yet'); + }; + const registry: Registry = env.registryDir + ? new DirectoryRegistry(env.registryDir) + : env.registryUrl + ? new RegistryClient({ baseUrl: env.registryUrl }) + : { search: noRegistry, get: noRegistry, download: noRegistry }; + const editorEngine = { editor: deps.version }; + /** Runs registry code, turning its failures into RPC errors with the same message. */ + const withRegistry = async (run: () => Promise): Promise => { + try { + return await run(); + } catch (error) { + if (!(error instanceof RegistryError)) throw error; + const code = ( + { + NOT_FOUND: 'NOT_FOUND', + UNAVAILABLE: 'UNAVAILABLE', + INVALID: 'BAD_REQUEST', + INTEGRITY: 'PRECONDITION_FAILED', + CONFLICT: 'CONFLICT', + IN_USE: 'CONFLICT', + } as const + )[error.code]; + throw new RpcError(code, error.message); + } + }; + const localOnly = () => { + if (env.mode !== 'OFFLINE') throw new RpcError('FORBIDDEN', 'Installing is for local editors'); + }; + const pluginFolder = ( + session: RequestContext['session'], + target: { name: string; scope: 'user' | 'project'; project?: string | undefined }, + ) => { + localOnly(); + if (target.scope === 'user') return join(deps.plugins.installedDir, ...target.name.split('/')); + if (!target.project) throw new RpcError('BAD_REQUEST', 'A project is needed for this scope'); + return join( + projects.get(session, target.project).root, + PROJECT_PLUGINS_DIR, + ...target.name.split('/'), + ); + }; + const packagesOf = (session: RequestContext['session'], project: string) => { + localOnly(); + return new ProjectPackages(projects.get(session, project).root, registry); + }; + /** The project's packages; without the registry, without what is newer. */ + const listPackages = async (packages: ProjectPackages): Promise => { + const listed: readonly OutdatedPackage[] = await packages + .outdated() + .catch(async (error: unknown) => { + if (error instanceof RegistryError && error.code === 'UNAVAILABLE') return packages.list(); + throw error; + }); + return listed.map((entry) => ({ ...entry, dependents: [...entry.dependents] })); + }; + return router.implement(RegistryContract, { + methods: { + search: ({ type, q, page }) => withRegistry(() => registry.search({ type, q, page })), + details: ({ name }) => + withRegistry(async () => { + const item = await registry.get(name); + const compatible = + item.type === 'plugin' ? pickVersion(item, '*', editorEngine)?.version : item.version; + return { ...item, ...(compatible && { compatible }) }; + }), + installPlugin: (input, { session }) => + withRegistry(async () => { + const target = pluginFolder(session, input); + const item = await registry.get(input.name); + if (item.type !== 'plugin') + throw new RegistryError('INVALID', `${input.name} is a package, not a plugin`); + const version = input.version + ? item.versions.find((entry) => entry.version === input.version) + : pickVersion(item, '*', editorEngine); + if (!version) { + throw new RegistryError( + 'NOT_FOUND', + input.version + ? `${input.name}@${input.version} was not found` + : `No version of ${input.name} runs on this editor (${deps.version})`, + ); + } + await installItem(registry, { + name: input.name, + version: version.version, + type: 'plugin', + target, + sha256: version.sha256, + }); + await deps.deactivateServerPlugin?.(input.name, target); + const project = input.project ? projects.get(session, input.project) : undefined; + const located = (await deps.plugins.list(project)).find( + (plugin) => plugin.dir === target && plugin.manifest?.entry.server, + ); + if (located && (!project || project.trusted)) await deps.activateServerPlugin?.(located); + return { version: version.version }; + }), + uninstallPlugin: async (input, { session }) => { + const target = pluginFolder(session, input); + if (!existsSync(join(target, 'nanoforge.manifest.json'))) + throw new RpcError('NOT_FOUND', `${input.name} is not installed there`); + await rm(target, { recursive: true, force: true }); + await deps.deactivateServerPlugin?.(input.name, target); + return null; + }, + packages: ({ project }, { session }) => + withRegistry(() => listPackages(packagesOf(session, project))), + installPackage: ({ project, name, range }, { session }) => + withRegistry(async () => { + const packages = packagesOf(session, project); + await packages.install(name, range); + return listPackages(packages); + }), + uninstallPackage: ({ project, name }, { session }) => + withRegistry(async () => { + const packages = packagesOf(session, project); + await packages.uninstall(name); + return listPackages(packages); + }), + updatePackage: ({ project, name, latest }, { session }) => + withRegistry(async () => { + const packages = packagesOf(session, project); + await packages.update(name, latest); + return listPackages(packages); + }), + restorePackages: ({ project }, { session }) => + withRegistry(async () => { + const packages = packagesOf(session, project); + await packages.restore(); + return listPackages(packages); + }), + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/runtime.implementation.ts b/packages/server-core/src/rpc/implementation/runtime.implementation.ts new file mode 100644 index 00000000..8e56a5ef --- /dev/null +++ b/packages/server-core/src/rpc/implementation/runtime.implementation.ts @@ -0,0 +1,65 @@ +import { type Disposable, DisposableStore } from '@nanoforge-dev/editor-kernel'; +import { RuntimeContract } from '@nanoforge-dev/editor-protocol'; +import { RpcError, type RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { OpenProject } from '../../project/project-registry'; +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the runtime contract. */ +export const implementRuntimeRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env, projects } = deps; + const runtimeOf = (project: OpenProject) => { + if (env.mode !== 'OFFLINE') { + throw new RpcError('FORBIDDEN', 'Games can only be run from a local editor'); + } + return deps.runtime.get(project); + }; + /** The runtime of a project whose code may run: building and playing run it. */ + const runnable = (project: OpenProject, what: string) => { + projects.assertTrusted(project, what); + return runtimeOf(project); + }; + return router.implement(RuntimeContract, { + methods: { + build: ({ project, apps }, { session }) => + runnable(projects.get(session, project), 'Building').builds.build(apps), + status: ({ project }, { session }) => { + const runtime = runtimeOf(projects.get(session, project)); + return { + builds: runtime.builds.apps.map((app) => runtime.builds.status(app.id)), + servers: runtime.servers(), + }; + }, + manifest: ({ project, app }, { session }) => + runtimeOf(projects.get(session, project)).manifest(app), + env: ({ project, app, overrides }, { session }) => + runtimeOf(projects.get(session, project)).env(app, overrides), + startServer: async ({ project, app, overrides }, { session }) => { + await runnable(projects.get(session, project), 'Playing').startServer(app, overrides); + return null; + }, + stopServer: async ({ project, app }, { session }) => { + await runtimeOf(projects.get(session, project)).stopServer(app); + return null; + }, + sendServer: ({ project, app, event, args }, { session }) => { + runtimeOf(projects.get(session, project)).sendServer(app, event, args); + return null; + }, + }, + streams: { + events: ({ project }, { session }, sink) => { + const open = projects.get(session, project); + const runtime = runtimeOf(open); + const subscription = new DisposableStore(); + if (open.trusted) subscription.add(runtime.builds.watch()); + subscription.add(runtime.onEvent((event) => sink.emit(event))); + return subscription; + }, + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/session.implementation.ts b/packages/server-core/src/rpc/implementation/session.implementation.ts new file mode 100644 index 00000000..940a8f0d --- /dev/null +++ b/packages/server-core/src/rpc/implementation/session.implementation.ts @@ -0,0 +1,28 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { SessionContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the session contract. */ +export const implementSessionRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { env } = deps; + return router.implement(SessionContract, { + methods: { + info: (_input, { session }) => ({ + mode: env.mode, + version: deps.version, + user: session.user, + loginUrl: env.mode === 'ONLINE' ? env.loginUrl : null, + features: [ + ...(env.apiFeatures.includes('settings') ? (['settings'] as const) : []), + ...(env.registryDir || env.registryUrl ? (['registry'] as const) : []), + ], + }), + }, + }); +}; diff --git a/packages/server-core/src/rpc/implementation/settings.implementation.ts b/packages/server-core/src/rpc/implementation/settings.implementation.ts new file mode 100644 index 00000000..653687f6 --- /dev/null +++ b/packages/server-core/src/rpc/implementation/settings.implementation.ts @@ -0,0 +1,36 @@ +import type { Disposable } from '@nanoforge-dev/editor-kernel'; +import { SettingsContract } from '@nanoforge-dev/editor-protocol'; +import type { RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../../session/auth'; +import type { CoreRpcDependencies } from '../core-rpc.type'; + +/** Implements the settings contract. */ +export const implementSettingsRpc = ( + router: RpcRouter, + deps: CoreRpcDependencies, +): Disposable => { + const { projects } = deps; + return router.implement(SettingsContract, { + methods: { + accountGet: (_input, { session }) => deps.accountSettings.get(session), + accountPut: ({ baseRevision, values }, { session }) => + deps.accountSettings.put(session, baseRevision, values), + projectLocalGet: ({ project }, { session }) => { + projects.get(session, project); + return deps.projectLocalSettings.get(session, project); + }, + projectLocalPut: async ({ project, values }, { session }) => { + projects.get(session, project); + await deps.projectLocalSettings.put(session, project, values); + return null; + }, + }, + streams: { + accountChanges: (_params, { session }, sink) => + deps.accountSettings.onDidChange(({ userId, revision }) => { + if (userId === session.user?.id) sink.emit({ revision }); + }), + }, + }); +}; diff --git a/packages/server-core/src/rpc/package-manager-of.ts b/packages/server-core/src/rpc/package-manager-of.ts new file mode 100644 index 00000000..468d3c84 --- /dev/null +++ b/packages/server-core/src/rpc/package-manager-of.ts @@ -0,0 +1,14 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +/** The package manager a project uses, from its lockfile. */ +export const packageManagerOf = (root: string): string | undefined => + ( + [ + ['pnpm-lock.yaml', 'pnpm'], + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ['yarn.lock', 'yarn'], + ['package-lock.json', 'npm'], + ] as const + ).find(([lockfile]) => existsSync(join(root, lockfile)))?.[1]; diff --git a/packages/server-core/src/rpc/require-user.ts b/packages/server-core/src/rpc/require-user.ts new file mode 100644 index 00000000..d2811b67 --- /dev/null +++ b/packages/server-core/src/rpc/require-user.ts @@ -0,0 +1,9 @@ +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +import type { RequestContext } from '../session/auth'; + +export const requireUser = (context: RequestContext) => { + const user = context.session.user; + if (!user) throw new RpcError('UNAUTHORIZED', 'Not signed in'); + return user; +}; diff --git a/packages/server-core/src/runtime/build-orchestrator.ts b/packages/server-core/src/runtime/build-orchestrator.ts new file mode 100644 index 00000000..caacc295 --- /dev/null +++ b/packages/server-core/src/runtime/build-orchestrator.ts @@ -0,0 +1,238 @@ +import { isAbsolute, join, relative, sep } from 'node:path'; + +import { + type Disposable, + Emitter, + type Event, + type Logger, + type Observable, + toDisposable, +} from '@nanoforge-dev/editor-kernel'; +import type { + AppModel, + BuildDiagnostic, + BuildStatus, + FileChange, + ProjectModel, + RuntimeEvent, +} from '@nanoforge-dev/editor-protocol'; + +import type { CliService } from '../process/cli-service'; +import { KeyedMutex } from '../util/keyed-mutex'; +import { BuildOutput } from './build-output'; + +/** The parts of an open project the orchestrator needs. */ +export interface BuildableProject { + readonly root: string; + readonly model: Observable; + readonly watcher: { readonly onDidChange: Event }; +} + +// eslint-disable-next-line no-control-regex -- terminal color codes +export const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g; +const REBUILD_DELAY_MS = 150; +/** A build that runs longer is stopped: it must not hold the build queue forever. */ +const BUILD_TIMEOUT_MS = 10 * 60_000; + +/** Apps the editor can build and run: clients and servers with an output directory. */ +export const runnableApps = (model: ProjectModel): AppModel[] => + model.apps.filter((app) => app.type !== 'lib' && app.outDir !== undefined); + +/** + * Reads bun build errors from the CLI output: + * `error: ` followed by ` at ::`. + */ +export const parseBuildOutput = (output: string, projectRoot: string): BuildDiagnostic[] => { + const diagnostics: BuildDiagnostic[] = []; + const lines = output.replace(ANSI, '').split(/\r?\n/); + for (let index = 0; index < lines.length; index++) { + const match = /^\s*(error|warn|warning): (.+)$/.exec(lines[index]!); + if (!match) continue; + const diagnostic: BuildDiagnostic = { + message: match[2]!.trim(), + severity: match[1] === 'error' ? 'error' : 'warning', + }; + const location = /^\s*at (.+):(\d+):(\d+)\s*$/.exec(lines[index + 1] ?? ''); + if (location) { + const file = relative(projectRoot, location[1]!); + diagnostics.push({ + ...diagnostic, + ...(!file.startsWith('..') && !isAbsolute(file) && { path: file.split(sep).join('/') }), + line: Number(location[2]), + column: Number(location[3]), + }); + index++; + } else diagnostics.push(diagnostic); + } + return diagnostics; +}; + +/** + * Builds the apps of a project with `nf build --editor`, one app at a time, and keeps their + * status. While watched, apps are rebuilt when their sources change (a change outside of every + * app, e.g. in a shared lib or the root config, rebuilds them all). + */ +export class BuildOrchestrator implements Disposable { + private readonly _statuses = new Map(); + private readonly _outputs = new Map(); + private readonly _onEvent = new Emitter(); + private readonly _mutex = new KeyedMutex(); + private readonly _dirty = new Set(); + private _watchers = 0; + private _watch: Disposable | undefined; + private _timer: ReturnType | undefined; + private _disposed = false; + /** Stops the build in progress when the orchestrator is disposed. */ + private readonly _abort = new AbortController(); + + readonly onEvent: Event = this._onEvent.event; + + constructor( + private readonly _project: BuildableProject, + private readonly _cli: CliService, + private readonly _logger: Logger, + private readonly _buildTimeoutMs = BUILD_TIMEOUT_MS, + ) {} + + get apps(): AppModel[] { + return runnableApps(this._project.model.get()); + } + + app(id: string): AppModel | undefined { + return this.apps.find((app) => app.id === id); + } + + status(app: string): BuildStatus { + return ( + this._statuses.get(app) ?? { + app, + state: 'idle', + version: null, + diagnostics: [], + finishedAt: null, + durationMs: null, + } + ); + } + + /** Output directory of an app, with content hashes. */ + output(app: AppModel): BuildOutput { + const dir = join(this._project.root, app.outDir ?? 'dist'); + let output = this._outputs.get(app.id); + if (output?.dir !== dir) { + output = new BuildOutput(dir); + this._outputs.set(app.id, output); + } + return output; + } + + /** Builds the given apps (all runnable apps by default), after any build in progress. */ + build(ids?: readonly string[]): Promise { + const apps = ids ? this.apps.filter((app) => ids.includes(app.id)) : this.apps; + for (const app of apps) this._dirty.delete(app.id); + return this._mutex.run('build', async () => { + const statuses: BuildStatus[] = []; + for (const app of apps) { + if (this._disposed) break; + statuses.push(await this._buildApp(app)); + } + return statuses; + }); + } + + /** Rebuilds apps on source changes until the returned disposable is disposed. */ + watch(): Disposable { + if (this._watchers++ === 0) { + this._watch = this._project.watcher.onDidChange((changes) => this._onChanges(changes)); + } + return toDisposable(() => { + if (--this._watchers > 0) return; + this._watch?.dispose(); + this._watch = undefined; + clearTimeout(this._timer); + }); + } + + dispose(): void { + this._disposed = true; + this._abort.abort(); + clearTimeout(this._timer); + this._watch?.dispose(); + this._onEvent.dispose(); + } + + private _onChanges(changes: readonly FileChange[]): void { + const apps = this.apps; + const roots = this._project.model.get().apps.map((app) => app.root); + for (const { path } of changes) { + if (path.startsWith('.nanoforge/')) continue; + const owner = roots + .filter((root) => root === '' || path === root || path.startsWith(`${root}/`)) + .sort((a, b) => b.length - a.length)[0]; + const affected = apps.filter((app) => owner !== undefined && app.root === owner); + for (const app of affected.length ? affected : apps) this._dirty.add(app.id); + } + if (!this._dirty.size) return; + clearTimeout(this._timer); + this._timer = setTimeout(() => { + const dirty = [...this._dirty]; + this.build(dirty).catch((error: unknown) => this._logger.error('Rebuild failed', error)); + }, REBUILD_DELAY_MS); + } + + private _setStatus(status: BuildStatus): BuildStatus { + this._statuses.set(status.app, status); + this._onEvent.fire({ type: 'build', status }); + return status; + } + + private async _buildApp(app: AppModel): Promise { + const previous = this.status(app.id); + this._setStatus({ ...previous, state: 'building' }); + const started = Date.now(); + const run = this._cli.run(['build', '--editor', '--directory', app.root || '.'], { + cwd: this._project.root, + env: { FORCE_COLOR: '0', NO_COLOR: '1' }, + signal: AbortSignal.any([this._abort.signal, AbortSignal.timeout(this._buildTimeoutMs)]), + silent: true, + }); + const listener = run.onLine(({ stream, text }) => + this._onEvent.fire({ + type: 'log', + app: app.id, + source: 'build', + stream, + text: text.replace(ANSI, ''), + }), + ); + let exitCode: number; + let output: string; + try { + const result = await run.done; + exitCode = result.exitCode; + output = `${result.stdout}\n${result.stderr}`; + } catch (error) { + exitCode = -1; + const timedOut = (error as { name?: string }).name === 'AbortError' && !this._disposed; + output = timedOut + ? `error: The build of ${app.name} took more than ${Math.round(this._buildTimeoutMs / 60_000)} minutes and was stopped` + : `error: ${error instanceof Error ? error.message : String(error)}`; + } finally { + listener.dispose(); + } + const diagnostics = parseBuildOutput(output, this._project.root); + const failed = exitCode !== 0 || /Build failed/i.test(output.replace(ANSI, '')); + if (failed && !diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { + diagnostics.push({ message: `Build of ${app.name} failed`, severity: 'error' }); + } + const files = failed ? [] : await this.output(app).files(); + return this._setStatus({ + app: app.id, + state: failed ? 'error' : 'ok', + version: failed ? previous.version : BuildOutput.version(files), + diagnostics, + finishedAt: Date.now(), + durationMs: Date.now() - started, + }); + } +} diff --git a/packages/server-core/src/runtime/build-output.ts b/packages/server-core/src/runtime/build-output.ts new file mode 100644 index 00000000..6811da12 --- /dev/null +++ b/packages/server-core/src/runtime/build-output.ts @@ -0,0 +1,58 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import { join, relative, sep } from 'node:path'; + +import type { OutputFile } from '@nanoforge-dev/editor-protocol'; + +import { sha1 } from '../util/hash'; + +interface CachedHash { + readonly size: number; + readonly mtime: number; + readonly hash: string; +} + +/** Lists a build output directory with content hashes (re-hashing only changed files). */ +export class BuildOutput { + private readonly _hashes = new Map(); + + /** Hash of every file hash: identifies one build. */ + static version(files: readonly OutputFile[]): string { + return sha1(files.map((file) => `${file.path}:${file.hash}`).join('\n')).slice(0, 16); + } + + constructor(readonly dir: string) {} + + async files(): Promise { + const files: OutputFile[] = []; + const walk = async (dir: string): Promise => { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + const absolute = join(dir, entry.name); + if (entry.isDirectory()) await walk(absolute); + else if (entry.isFile()) files.push(await this._file(absolute)); + } + }; + await walk(this.dir); + for (const path of this._hashes.keys()) { + if (!files.some((file) => join(this.dir, file.path) === path)) this._hashes.delete(path); + } + return files.sort((a, b) => a.path.localeCompare(b.path)); + } + + /** Content hash of a file of the output (cached by size and mtime). */ + async hash(absolute: string): Promise { + return (await this._file(absolute).catch(() => undefined))?.hash; + } + + private async _file(absolute: string): Promise { + const info = await stat(absolute); + const path = relative(this.dir, absolute).split(sep).join('/'); + const cached = this._hashes.get(absolute); + if (cached && cached.size === info.size && cached.mtime === info.mtimeMs) { + return { path, size: info.size, hash: cached.hash }; + } + const hash = sha1(await readFile(absolute)); + this._hashes.set(absolute, { size: info.size, mtime: info.mtimeMs, hash }); + return { path, size: info.size, hash }; + } +} diff --git a/packages/server-core/src/runtime/game-env.ts b/packages/server-core/src/runtime/game-env.ts new file mode 100644 index 00000000..6c00b9bf --- /dev/null +++ b/packages/server-core/src/runtime/game-env.ts @@ -0,0 +1,68 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const PREFIX = 'NANOFORGE_'; +const CLIENT_PREFIX = `${PREFIX}CLIENT_`; +const SERVER_PREFIX = `${PREFIX}SERVER_`; + +/** Parses a `.env` file: `KEY=value`, `export KEY=value`, quotes, `#` comments. */ +export const parseDotenv = (text: string): Record => { + const values: Record = {}; + for (const raw of text.split(/\r?\n/)) { + const match = /^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*)?\s*$/.exec(raw); + if (!match) continue; + let value = (match[2] ?? '').trim(); + const quote = value[0]; + if ( + (quote === '"' || quote === "'" || quote === '`') && + value.endsWith(quote) && + value.length > 1 + ) { + value = value.slice(1, -1); + if (quote === '"') value = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r'); + } else { + value = value.replace(/\s+#.*$/, ''); + } + values[match[1]!] = value; + } + return values; +}; + +export interface GameEnv { + readonly client: Record; + readonly server: Record; +} + +/** + * Splits `NANOFORGE_*` variables between the client and the server and strips the prefixes, as + * `nf start` and the loaders do: `NANOFORGE_CLIENT_X` → client `X`, `NANOFORGE_SERVER_X` → + * server `X`, `NANOFORGE_X` → both. + */ +export const splitGameEnv = (raw: Record): GameEnv => { + const client: Record = {}; + const server: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (!key.startsWith(PREFIX) || !value) continue; + if (key.startsWith(CLIENT_PREFIX)) client[key.slice(CLIENT_PREFIX.length)] = value; + else if (key.startsWith(SERVER_PREFIX)) server[key.slice(SERVER_PREFIX.length)] = value; + else { + client[key.slice(PREFIX.length)] ??= value; + server[key.slice(PREFIX.length)] ??= value; + } + } + return { client, server }; +}; + +/** + * Environment of a game app: the editor's own `NANOFORGE_*` variables, then the project's + * `.env`, then the overrides (`runtime.env` setting), split for the app's side. + */ +export const loadGameEnv = async ( + projectRoot: string, + side: 'client' | 'server', + overrides: Record = {}, + processEnv: Record = process.env, +): Promise> => { + const dotenv = await readFile(join(projectRoot, '.env'), 'utf8').then(parseDotenv, () => ({})); + return splitGameEnv({ ...processEnv, ...dotenv, ...overrides })[side]; +}; diff --git a/packages/server-core/src/runtime/game-server.ts b/packages/server-core/src/runtime/game-server.ts new file mode 100644 index 00000000..2e6fe275 --- /dev/null +++ b/packages/server-core/src/runtime/game-server.ts @@ -0,0 +1,250 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; +import type { GameServerState, RuntimeEvent } from '@nanoforge-dev/editor-protocol'; + +import { childEnv } from '../process/child-env'; + +/** + * Runs in the game process (Bun): receives the entry point, files and env over IPC, then + * starts the game with an editor bridge whose `toEditor` events are sent back over IPC. + */ +const RUNNER_SOURCE = `import { pathToFileURL } from 'node:url'; + +class QueuedEmitter { + listeners = new Map(); + queue = []; + on(event, listener) { this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]); } + off(event, listener) { this.listeners.set(event, (this.listeners.get(event) ?? []).filter((l) => l !== listener)); } + emit(event, ...args) { this.queue.push([event, args]); } + runEvents() { + for (const [event, args] of this.queue.splice(0)) { + for (const listener of this.listeners.get(event) ?? []) { + try { listener(...args); } catch (error) { console.error(error); } + } + } + } +} + +const send = (message) => { if (process.connected) process.send(message); }; +const fromEditor = new QueuedEmitter(); +const toEditor = { + on() {}, off() {}, runEvents() {}, + emit(event, ...args) { + send({ type: 'bridge', event, args }); + if (event === 'state' && args[0] === 'stopped') setTimeout(() => process.exit(0), 50); + }, +}; + +process.on('message', async (message) => { + if (message.type === 'command') return fromEditor.emit(message.event, ...message.args); + if (message.type !== 'init') return; + try { + const { main } = await import(pathToFileURL(message.main).href); + console.log('Starting server'); + await main({ files: new Map(Object.entries(message.files)), env: message.env, editor: { toEditor, fromEditor } }); + send({ type: 'started' }); + } catch (error) { + console.error(error); + process.exit(1); + } +}); +process.on('disconnect', () => process.exit(0)); +send({ type: 'ready' }); +`; + +const runners = new Map>(); + +/** + * Writes the runner script once per data directory (the server itself may be bundled). A failed + * write is forgotten, so the next start tries again. + */ +const ensureRunner = (dataDir: string): Promise => { + let runner = runners.get(dataDir); + if (!runner) { + runner = (async () => { + const dir = join(dataDir, 'runtime'); + await mkdir(dir, { recursive: true }); + const path = join(dir, 'game-server-runner.mjs'); + await writeFile(path, RUNNER_SOURCE); + return path; + })(); + runners.set(dataDir, runner); + runner.catch(() => { + if (runners.get(dataDir) === runner) runners.delete(dataDir); + }); + } + return runner; +}; + +export interface GameServerOptions { + readonly app: string; + /** Directory the game runs in (the app root). */ + readonly cwd: string; + readonly main: string; + /** Virtual path (`/libecs.wasm`) → absolute path. */ + readonly files: Record; + readonly env: Record; + readonly dataDir: string; + /** Grace period for a requested stop before the process is killed. */ + readonly stopTimeoutMs?: number; + readonly bunPath?: string; +} + +type RunnerMessage = + | { type: 'ready' } + /** `main` resolved: the game runs, whether or not its engine speaks the bridge. */ + | { type: 'started' } + | { type: 'bridge'; event: string; args: unknown[] }; + +/** + * A game server process started by the editor. `stop` asks the game to stop through the + * bridge, then kills the whole process group if it did not exit in time. + */ +export class GameServerProcess { + private readonly _onEvent = new Emitter(); + private _child: ChildProcess | undefined; + private _state: GameServerState = 'starting'; + private _bridge = false; + private _exited: Promise = Promise.resolve(null); + + readonly onEvent: Event = this._onEvent.event; + + constructor(private readonly _options: GameServerOptions) {} + + get state(): GameServerState { + return this._state; + } + + /** Whether the game answered with the editor bridge hello. */ + get bridged(): boolean { + return this._bridge; + } + + async start(): Promise { + const runner = await ensureRunner(this._options.dataDir); + const bun = this._options.bunPath ?? (process.versions.bun ? process.execPath : 'bun'); + const child = spawn(bun, [runner], { + cwd: this._options.cwd, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + detached: process.platform !== 'win32', + env: childEnv({ FORCE_COLOR: '0' }), + }); + this._child = child; + this._setState('starting'); + for (const stream of ['stdout', 'stderr'] as const) { + let pending = ''; + child[stream]?.on('data', (chunk: Buffer) => { + const lines = (pending + chunk.toString('utf8')).split(/\r?\n/); + pending = lines.pop() ?? ''; + for (const text of lines) this._log(stream, text); + }); + child[stream]?.on('end', () => pending && this._log(stream, pending)); + } + child.on('message', (message: RunnerMessage) => this._onMessage(message)); + this._exited = new Promise((resolve) => { + child.on('error', (error) => { + this._log('stderr', `Could not start the game server: ${error.message}`); + this._setState('exited', null); + resolve(null); + }); + child.on('exit', (code) => { + this._setState('exited', code); + resolve(code); + }); + }); + } + + /** Kills the process group at once (editor shutdown). */ + kill(): void { + this._kill('SIGKILL'); + } + + /** Sends an editor → engine event (`fromEditor`). */ + send(event: string, args: readonly unknown[] = []): void { + if (this._child?.connected) this._child.send({ type: 'command', event, args }); + } + + /** Stops the game and resolves once the process exited. */ + async stop(): Promise { + const child = this._child; + if (!child || this._state === 'exited') return; + this._setState('stopping'); + if (this._bridge) this.send('stop'); + else this._kill('SIGTERM'); + const timeout = this._options.stopTimeoutMs ?? 5000; + if (await this._exitWithin(timeout)) return; + this._kill('SIGTERM'); + if (await this._exitWithin(2000)) return; + this._kill('SIGKILL'); + await this._exited; + } + + private async _exitWithin(ms: number): Promise { + let timer: ReturnType | undefined; + const timedOut = new Promise( + (resolve) => (timer = setTimeout(() => resolve(false), ms)), + ); + const exited = this._exited.then(() => true as const); + const result = await Promise.race([exited, timedOut]); + clearTimeout(timer); + return result; + } + + private _kill(signal: NodeJS.Signals): void { + const child = this._child; + if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return; + try { + if (process.platform === 'win32') child.kill(signal); + else process.kill(-child.pid, signal); + } catch { + child.kill(signal); + } + } + + private _onMessage(message: RunnerMessage): void { + if (message.type === 'ready') { + this._child?.send({ + type: 'init', + main: this._options.main, + files: this._options.files, + env: this._options.env, + }); + return; + } + if (message.type === 'started') { + if (this._state === 'starting') this._setState('running'); + return; + } + if (message.type !== 'bridge') return; + if (message.event === 'hello') this._bridge = true; + if (message.event === 'state' && this._state !== 'stopping') { + if (message.args[0] === 'running') this._setState('running'); + else if (message.args[0] === 'paused') this._setState('paused'); + } + this._onEvent.fire({ + type: 'bridge', + app: this._options.app, + event: message.event, + args: message.args, + }); + } + + private _setState(state: GameServerState, exitCode?: number | null): void { + if (this._state === state && state !== 'starting') return; + if (this._state === 'exited' && state !== 'starting') return; + this._state = state; + this._onEvent.fire({ + type: 'server', + app: this._options.app, + state, + ...(exitCode !== undefined && { exitCode }), + }); + } + + private _log(stream: 'stdout' | 'stderr', text: string): void { + this._onEvent.fire({ type: 'log', app: this._options.app, source: 'server', stream, text }); + } +} diff --git a/packages/server-core/src/runtime/index.ts b/packages/server-core/src/runtime/index.ts new file mode 100644 index 00000000..4e4b7a01 --- /dev/null +++ b/packages/server-core/src/runtime/index.ts @@ -0,0 +1,5 @@ +export * from './build-orchestrator'; +export * from './build-output'; +export * from './game-env'; +export * from './game-server'; +export * from './project-runtime'; diff --git a/packages/server-core/src/runtime/project-runtime.ts b/packages/server-core/src/runtime/project-runtime.ts new file mode 100644 index 00000000..123e0cdf --- /dev/null +++ b/packages/server-core/src/runtime/project-runtime.ts @@ -0,0 +1,187 @@ +import { basename, isAbsolute, join, relative } from 'node:path'; + +import { + type Disposable, + DisposableStore, + Emitter, + type Event, + type Logger, +} from '@nanoforge-dev/editor-kernel'; +import { + type AppModel, + type OutputManifest, + type RuntimeEvent, + runtimeOutputPath, +} from '@nanoforge-dev/editor-protocol'; +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +import type { EditorEnv } from '../env/editor-env.type'; +import type { CliRun, CliService } from '../process/cli-service'; +import type { OpenProject } from '../project/project-registry'; +import { KeyedMutex } from '../util/keyed-mutex'; +import { ANSI, BuildOrchestrator } from './build-orchestrator'; +import { BuildOutput } from './build-output'; +import { loadGameEnv } from './game-env'; +import { GameServerProcess } from './game-server'; + +/** File name bun gives to the bundle of an entry (`src/main.ts` → `main.js`). */ +export const outputEntry = (app: AppModel): string => + basename(app.editorEntryFile ?? app.entryFile ?? 'main.ts').replace(/\.[cm]?[jt]sx?$/, '.js'); + +/** Builds, outputs and game servers of one project. */ +export class ProjectRuntime implements Disposable { + readonly builds: BuildOrchestrator; + private readonly _servers = new Map(); + /** Starts and stops of the same app run one at a time (two tabs, Restart during a start). */ + private readonly _serverLocks = new KeyedMutex(); + private readonly _onEvent = new Emitter(); + private readonly _store = new DisposableStore(); + + readonly onEvent: Event = this._onEvent.event; + + constructor( + readonly project: OpenProject, + cli: CliService, + private readonly _env: EditorEnv, + private readonly _logger: Logger, + ) { + this.builds = this._store.add(new BuildOrchestrator(project, cli, _logger)); + this._store.add(this.builds.onEvent((event) => this._onEvent.fire(event))); + this._store.add(cli.onDidStart((run) => this._followTask(run))); + } + + app(id: string): AppModel { + const app = this.builds.app(id); + if (!app) throw new RpcError('NOT_FOUND', `No runnable app ${id || '(root)'}`); + return app; + } + + async manifest(appId: string): Promise { + const app = this.app(appId); + const files = await this.builds.output(app).files(); + const entry = outputEntry(app); + if (!files.some((file) => file.path === entry)) { + throw new RpcError('NOT_FOUND', `${app.name} has no build output yet`); + } + return { + app: app.id, + version: BuildOutput.version(files), + baseUrl: runtimeOutputPath(this.project.id, app.id), + entry, + files, + }; + } + + env(appId: string, overrides: Record = {}): Promise> { + const app = this.app(appId); + return loadGameEnv(this.project.root, app.type === 'server' ? 'server' : 'client', overrides); + } + + /** Starts the game server of an app (restarts it when running). */ + startServer(appId: string, overrides: Record = {}): Promise { + return this._serverLocks.run(appId, () => this._startServer(appId, overrides)); + } + + stopServer(appId: string): Promise { + return this._serverLocks.run(appId, () => this._stopServer(appId)); + } + + sendServer(appId: string, event: string, args: readonly unknown[]): void { + const server = this._servers.get(appId); + if (!server) throw new RpcError('NOT_FOUND', `The game server of ${appId} is not running`); + server.send(event, args); + } + + servers(): { app: string; state: GameServerProcess['state'] }[] { + return [...this._servers].map(([app, server]) => ({ app, state: server.state })); + } + + dispose(): void { + for (const server of this._servers.values()) server.kill(); + this._servers.clear(); + this._store.dispose(); + this._onEvent.dispose(); + } + + private async _startServer(appId: string, overrides: Record): Promise { + const app = this.app(appId); + if (app.type !== 'server') throw new RpcError('BAD_REQUEST', `${app.name} is not a server`); + await this._stopServer(appId); + const manifest = await this.manifest(appId); + const outDir = this.builds.output(app).dir; + const server = new GameServerProcess({ + app: app.id, + cwd: join(this.project.root, app.root), + main: join(outDir, manifest.entry), + files: Object.fromEntries( + manifest.files.map((file) => [`/${file.path}`, join(outDir, file.path)]), + ), + env: await this.env(appId, overrides), + dataDir: this._env.dataDir, + }); + this._servers.set(app.id, server); + const forward = server.onEvent((event) => { + this._onEvent.fire(event); + if (event.type === 'server' && event.state === 'exited') { + forward.dispose(); + if (this._servers.get(app.id) === server) this._servers.delete(app.id); + } + }); + await server.start(); + this._logger.info(`Started the game server of ${app.name}`); + } + + private async _stopServer(appId: string): Promise { + await this._servers.get(appId)?.stop(); + } + + /** Streams a CLI run made in this project (other than builds) as a console task. */ + private _followTask(run: CliRun): void { + const inside = relative(this.project.root, run.cwd); + if (run.silent || inside.startsWith('..') || isAbsolute(inside)) return; + const app = `${run.command} ${run.args.join(' ')}`; + const log = (stream: 'stdout' | 'stderr', text: string) => + this._onEvent.fire({ type: 'log', app, source: 'cli', stream, text }); + log('stdout', `$ ${app}`); + const listener = run.onLine(({ stream, text }) => log(stream, text.replace(ANSI, ''))); + run.done + .then(({ exitCode }) => { + if (exitCode !== 0) log('stderr', `error: ${app} exited with code ${exitCode}`); + }) + .catch((error: unknown) => + log('stderr', `error: ${error instanceof Error ? error.message : String(error)}`), + ) + .finally(() => listener.dispose()); + } +} + +/** One runtime per open project, created on first use. */ +export class RuntimeManager implements Disposable { + private readonly _runtimes = new Map(); + + constructor( + private readonly _cli: CliService, + private readonly _env: EditorEnv, + private readonly _logger: Logger, + ) {} + + get(project: OpenProject): ProjectRuntime { + let runtime = this._runtimes.get(project.id); + if (!runtime) { + runtime = new ProjectRuntime(project, this._cli, this._env, this._logger); + this._runtimes.set(project.id, runtime); + } + return runtime; + } + + /** Stops the builds and game servers of a project that was closed. */ + close(projectId: string): void { + this._runtimes.get(projectId)?.dispose(); + this._runtimes.delete(projectId); + } + + dispose(): void { + for (const runtime of this._runtimes.values()) runtime.dispose(); + this._runtimes.clear(); + } +} diff --git a/packages/server-core/src/server/create-editor-server.ts b/packages/server-core/src/server/create-editor-server.ts new file mode 100644 index 00000000..8e11de22 --- /dev/null +++ b/packages/server-core/src/server/create-editor-server.ts @@ -0,0 +1,342 @@ +import { join, resolve } from 'node:path'; + +import { DisposableStore, LoggerService, consoleSink } from '@nanoforge-dev/editor-kernel'; +import { decodeRuntimeApp } from '@nanoforge-dev/editor-protocol'; +import { PROJECT_HEADER, RPC_HTTP_PREFIX, RPC_WS_PATH, RpcRouter } from '@nanoforge-dev/editor-rpc'; + +import { ApiClient } from '../api/api-client'; +import { archiveFolder } from '../fs/archive'; +import { PathJail } from '../fs/path-jail'; +import { GitService } from '../git/git-service'; +import { fileResponse } from '../http/static-files'; +import { PluginSources } from '../plugin/plugin-sources'; +import { ServerPluginHost, type ServerPluginServices } from '../plugin/server-plugins'; +import { CliService } from '../process/cli-service'; +import { ProjectRegistry } from '../project/project-registry'; +import { implementCoreRpc } from '../rpc/implement-core-rpc'; +import type { BuildOutput } from '../runtime/build-output'; +import { RuntimeManager } from '../runtime/project-runtime'; +import { type RequestContext, authorize } from '../session/auth'; +import { parseCookies, serializeCookie } from '../session/cookies'; +import { + ACCESS_TOKEN_COOKIE, + REFRESH_TOKEN_COOKIE, + type Session, + SessionStore, +} from '../session/session-store'; +import { + ApiAccountBackend, + FileAccountBackend, + ProjectLocalSettingsStore, +} from '../settings/account-backends'; +import type { EditorServer, EditorServerOptions } from './editor-server.type'; +import { hostAllowed, originAllowed } from './request-guard'; + +/** A project nobody has a connected editor on is closed after this long without a request. */ +const PROJECT_IDLE_MS = 30 * 60_000; +/** The account setting listing the hosted projects whose own plugins the user allowed. */ +const TRUSTED_PROJECTS_SETTING = 'plugins.trustedProjects'; +/** Plugin files loaded as scripts (a sandbox policy would not apply to them). */ +const SCRIPT = /\.m?js$/i; + +const json = (status: number, body: unknown, headers: HeadersInit = {}) => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); + +export const createEditorServer = (options: EditorServerOptions): EditorServer => { + const { env } = options; + const store = new DisposableStore(); + const logs = options.logger ?? new LoggerService(); + if (!options.logger) store.add(logs.addSink(consoleSink)); + const logger = logs.getLogger('server'); + + const api = new ApiClient(env, options.fetch); + const sessions = new SessionStore(env, { verify: (session) => api.currentUser(session) }); + const git = new GitService(env, undefined, (root) => projects.trust.isTrusted(root)); + const cli = new CliService(env); + const projects: ProjectRegistry = store.add( + new ProjectRegistry(env, git, api, logs.getLogger('projects')), + ); + const plugins = new PluginSources(env, logs.getLogger('plugins')); + const accountSettings = + env.mode === 'ONLINE' && env.apiFeatures.includes('settings') + ? new ApiAccountBackend(api) + : new FileAccountBackend(env); + const runtime = store.add(new RuntimeManager(cli, env, logs.getLogger('runtime'))); + const router = new RpcRouter({ + authorize, + onError: (error, call) => logger.error(`RPC ${call.namespace}.${call.name} failed`, error), + }); + store.add( + implementCoreRpc(router, { + env, + version: options.version, + sessions, + projects, + plugins, + activateServerPlugin: (plugin) => serverPlugins.activate(plugin), + deactivateServerPlugin: (name, dir) => serverPlugins.deactivate(name, dir), + syncProjectServerPlugins: (located) => serverPlugins.syncProject(located), + cli, + api, + accountSettings, + projectLocalSettings: new ProjectLocalSettingsStore(env), + runtime, + git, + }), + ); + const services: ServerPluginServices = { projects, cli, git, api }; + const serverPlugins = store.add(new ServerPluginHost(router, services, logs)); + const staticJail = options.staticDir ? new PathJail(resolve(options.staticDir)) : undefined; + store.add(projects.onDidClose((id) => runtime.close(id))); + const inUse = (id: string) => sessions.holders(id).some((session) => session.sockets > 0); + const sweepTimer = setInterval(() => { + sessions.prune(); + projects.sweep(inUse, PROJECT_IDLE_MS); + }, 600_000); + sweepTimer.unref?.(); + + const withCookies = (response: Response, cookies: string[]): Response => { + for (const cookie of cookies) response.headers.append('set-cookie', cookie); + return response; + }; + + const tokenCookies = (session: Session, before: string | undefined): string[] => { + if (!session.tokens || session.tokens.accessToken === before) return []; + const secure = env.production; + return [ + serializeCookie(ACCESS_TOKEN_COOKIE, session.tokens.accessToken, { secure }), + serializeCookie(REFRESH_TOKEN_COOKIE, session.tokens.refreshToken, { secure }), + ]; + }; + + const handleRpc = async (request: Request, path: string): Promise => { + const cookie = request.headers.get('cookie'); + const before = parseCookies(cookie)[ACCESS_TOKEN_COOKIE]; + const { session, setCookies } = await sessions.resolve(cookie); + const context: RequestContext = { session, responseCookies: [] }; + const result = await router.handle(path, await request.text(), context, request.signal); + const response = new Response(result.body, { + status: result.status, + headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }, + }); + return withCookies(response, [ + ...setCookies, + ...context.responseCookies, + ...tokenCookies(session, before), + ]); + }; + + /** + * Whether a session may load a project's own plugins, as the client decides: a local folder + * that is trusted, or a hosted project its user allowed (`plugins.trustedProjects`). Checked + * here too, or a direct link would load them anyway. + */ + const mayLoadProjectPlugins = async (session: Session, projectId: string) => { + try { + const project = projects.get(session, projectId); + if (env.mode === 'OFFLINE') return project.trusted; + const allowed = (await accountSettings.get(session)).values[TRUSTED_PROJECTS_SETTING]; + return Array.isArray(allowed) && allowed.includes(projectId); + } catch { + return false; + } + }; + + const handlePlugin = async (request: Request, pathname: string): Promise => { + const { session } = await sessions.resolve(request.headers.get('cookie'), { create: false }); + const resolved = await plugins.resolveFile(pathname, (projectId) => + mayLoadProjectPlugins(session, projectId), + ); + if (!resolved) return json(404, { error: 'Not found' }); + const cache = { + dev: 'no-cache', + project: 'private, no-cache', + installed: 'public, max-age=31536000, immutable', + bundled: 'public, max-age=31536000, immutable', + }[resolved.plugin.source]; + const response = await fileResponse(resolved.file, cache); + if (!response) return json(404, { error: 'Not found' }); + /** A project's page or image opened on its own runs nothing on the editor's origin. */ + if (resolved.plugin.source === 'project' && !SCRIPT.test(resolved.file)) + response.headers.set('content-security-policy', 'sandbox'); + return response; + }; + + /** + * Build outputs of running games: `/runtime///`. Local editors only, like + * the runtime RPC: a hosted editor never builds nor runs a project's code. + */ + const handleRuntime = async (request: Request, url: URL): Promise => { + const [projectId, appSegment, ...rest] = url.pathname.slice('/runtime/'.length).split('/'); + if (!projectId || !appSegment || !rest.length || env.mode !== 'OFFLINE') + return json(404, { error: 'Not found' }); + const { session } = await sessions.resolve(request.headers.get('cookie'), { create: false }); + let file: string | undefined; + let output: BuildOutput; + try { + const projectRuntime = runtime.get(projects.get(session, projectId)); + output = projectRuntime.builds.output(projectRuntime.app(decodeRuntimeApp(appSegment))); + file = await new PathJail(output.dir).resolve(rest.map(decodeURIComponent).join('/')); + } catch { + return json(404, { error: 'Not found' }); + } + const version = url.searchParams.get('v'); + const cache = + version && version === (await output.hash(file)) + ? 'public, max-age=31536000, immutable' + : 'no-store'; + return (await fileResponse(file, cache)) ?? json(404, { error: 'Not found' }); + }; + + /** + * Files of an open project: `/files//` (thumbnails, previews). With + * `?download`, an attachment; a folder downloads as a .zip. + */ + const handleFiles = async (request: Request, url: URL): Promise => { + const [projectId, ...rest] = url.pathname.slice('/files/'.length).split('/'); + if (!projectId) return json(404, { error: 'Not found' }); + const { session } = await sessions.resolve(request.headers.get('cookie'), { create: false }); + const download = url.searchParams.has('download'); + try { + const path = rest.map(decodeURIComponent).join('/'); + const project = projects.get(session, projectId); + const entry = await project.fs.stat(path); + if (!entry || (path && project.fs.isIgnored(path, entry.kind === 'directory'))) { + return json(404, { error: 'Not found' }); + } + const name = path.split('/').at(-1) || project.name; + const disposition = (file: string) => + `attachment; filename="${file.replace(/["\\\r\n]/g, '_')}"`; + if (entry.kind === 'directory') { + if (!download) return json(400, { error: 'Folders can only be downloaded' }); + const archive = await archiveFolder(project.fs, path); + return new Response(archive as Uint8Array, { + headers: { + 'content-type': 'application/zip', + 'content-disposition': disposition(`${name}.zip`), + 'cache-control': 'no-store', + }, + }); + } + const response = await fileResponse(await project.fs.jail.resolve(path), 'no-cache'); + if (!response) return json(404, { error: 'Not found' }); + response.headers.set('content-security-policy', 'sandbox'); + if (download) response.headers.set('content-disposition', disposition(name)); + return response; + } catch { + return json(404, { error: 'Not found' }); + } + }; + + /** + * Only the editor's own pages may frame it: another site framing a local editor could get a + * click on "Trust and open" (or any other button) without the user seeing what it does. + */ + const notFramed = (response: Response | undefined) => { + response?.headers.set('content-security-policy', "frame-ancestors 'self'"); + response?.headers.set('x-frame-options', 'SAMEORIGIN'); + return response; + }; + + const handleStatic = async (pathname: string): Promise => { + if (!staticJail) return json(404, { error: 'Not found' }); + let rel: string; + try { + rel = decodeURIComponent(pathname.slice(1)); + } catch { + return json(404, { error: 'Not found' }); + } + const file = await staticJail.resolve(rel).catch(() => undefined); + const immutable = rel.startsWith('_app/immutable/'); + const asset = + file && + (await fileResponse(file, immutable ? 'public, max-age=31536000, immutable' : 'no-cache')); + if (asset) return notFramed(asset)!; + if (/\.[a-z0-9]+$/i.test(rel)) return json(404, { error: 'Not found' }); + return ( + notFramed(await fileResponse(join(staticJail.root, 'index.html'), 'no-cache')) ?? + json(404, { error: 'Not found' }) + ); + }; + + return { + env, + router, + projects, + plugins, + serverPlugins, + logs, + runtime, + cli, + + async start() { + for (const plugin of await plugins.list()) { + if (plugin.manifest?.entry.server) await serverPlugins.activate(plugin); + } + }, + + async fetch(request, upgrade) { + const url = new URL(request.url); + const { pathname } = url; + try { + if (pathname === '/healthz') return json(200, { ok: true, version: options.version }); + if (!hostAllowed(env, request.headers.get('host') ?? url.host)) + return json(421, { error: 'Unknown host' }); + if (!originAllowed(env, request)) return json(403, { error: 'Forbidden origin' }); + if (pathname === RPC_WS_PATH) { + const { session, setCookies } = await sessions.resolve(request.headers.get('cookie')); + const headers = new Headers(); + for (const cookie of setCookies) headers.append('set-cookie', cookie); + if (upgrade?.(request, { data: { session }, headers })) return undefined; + return json(426, { error: 'WebSocket upgrade required' }); + } + if (pathname.startsWith(RPC_HTTP_PREFIX)) { + if (request.method !== 'POST') return json(405, { error: 'Method not allowed' }); + return await handleRpc(request, pathname.slice(RPC_HTTP_PREFIX.length)); + } + if (request.method !== 'GET' && request.method !== 'HEAD') + return json(405, { error: 'Method not allowed' }); + if (pathname.startsWith('/plugins/')) return await handlePlugin(request, pathname); + if (pathname.startsWith('/runtime/')) return await handleRuntime(request, url); + if (pathname.startsWith('/files/')) return await handleFiles(request, url); + return await handleStatic(pathname); + } catch (error) { + logger.error(`${request.method} ${pathname} failed`, error); + return json(500, { error: 'Internal error' }); + } + }, + + websocket: { + open(socket) { + socket.data.session.sockets++; + socket.data.rpc = router.createSession( + { session: socket.data.session, responseCookies: [] }, + (message) => socket.send(message) > 0, + ); + }, + message(socket, message) { + socket.data.rpc + ?.receive(typeof message === 'string' ? message : new TextDecoder().decode(message)) + .catch((error: unknown) => logger.error('WebSocket message failed', error)); + }, + drain(socket) { + socket.data.rpc?.drain(); + }, + close(socket) { + socket.data.session.sockets = Math.max(0, socket.data.session.sockets - 1); + socket.data.rpc?.dispose(); + }, + }, + + dispose() { + clearInterval(sweepTimer); + sessions.dispose(); + store.dispose(); + }, + }; +}; + +export { PROJECT_HEADER }; diff --git a/packages/server-core/src/server/editor-server.type.ts b/packages/server-core/src/server/editor-server.type.ts new file mode 100644 index 00000000..b0713592 --- /dev/null +++ b/packages/server-core/src/server/editor-server.type.ts @@ -0,0 +1,59 @@ +import { type Disposable, type LoggerService } from '@nanoforge-dev/editor-kernel'; +import { type RpcRouter, type RpcSession } from '@nanoforge-dev/editor-rpc'; + +import type { EditorEnv } from '../env/editor-env.type'; +import { type PluginSources } from '../plugin/plugin-sources'; +import { type ServerPluginHost } from '../plugin/server-plugins'; +import { type CliService } from '../process/cli-service'; +import { type ProjectRegistry } from '../project/project-registry'; +import { type RuntimeManager } from '../runtime/project-runtime'; +import type { RequestContext } from '../session/auth'; +import type { Session } from '../session/session-store'; + +export interface SocketData { + readonly session: Session; + rpc?: RpcSession; +} + +/** The part of a WebSocket the server needs (Bun's ServerWebSocket satisfies it). */ +export interface ServerSocket { + readonly data: SocketData; + /** Bun returns -1 under back-pressure, 0 when dropped, the byte count otherwise. */ + send(message: string): number; + close(): void; +} + +export type Upgrade = ( + request: Request, + options: { data: SocketData; headers?: HeadersInit }, +) => boolean; + +export interface EditorServerOptions { + env: EditorEnv; + version: string; + /** Built SPA (index.html + assets). Omitted in dev, where Vite serves the UI. */ + staticDir?: string; + fetch?: typeof fetch; + logger?: LoggerService; +} + +export interface EditorServer extends Disposable { + readonly env: EditorEnv; + readonly router: RpcRouter; + readonly projects: ProjectRegistry; + readonly plugins: PluginSources; + readonly serverPlugins: ServerPluginHost; + readonly logs: LoggerService; + readonly runtime: RuntimeManager; + readonly cli: CliService; + readonly websocket: { + open(socket: ServerSocket): void; + message(socket: ServerSocket, message: string | Uint8Array): void; + drain(socket: ServerSocket): void; + close(socket: ServerSocket): void; + }; + /** Activates plugin server entries. */ + start(): Promise; + /** HTTP handler; returns undefined when the request was upgraded to a WebSocket. */ + fetch(request: Request, upgrade?: Upgrade): Promise; +} diff --git a/packages/server-core/src/server/index.ts b/packages/server-core/src/server/index.ts new file mode 100644 index 00000000..dabac009 --- /dev/null +++ b/packages/server-core/src/server/index.ts @@ -0,0 +1,2 @@ +export * from './editor-server.type'; +export * from './create-editor-server'; diff --git a/packages/server-core/src/server/request-guard.ts b/packages/server-core/src/server/request-guard.ts new file mode 100644 index 00000000..54fefd56 --- /dev/null +++ b/packages/server-core/src/server/request-guard.ts @@ -0,0 +1,51 @@ +import type { EditorEnv } from '../env/editor-env.type'; + +const LOOPBACK = /^(localhost|.+\.localhost|127(\.\d{1,3}){3}|\[::1\])$/; +const WILDCARD_HOSTS = new Set(['0.0.0.0', '::', '[::]']); + +const hostname = (host: string): string => { + const lower = host.trim().toLowerCase(); + if (lower.startsWith('[')) return lower.slice(0, lower.indexOf(']') + 1); + return lower.replace(/:\d+$/, ''); +}; + +/** + * Whether the `Host` header names this server. A page whose name was rebound to 127.0.0.1 sends + * its own name as `Host` (and as `Origin`, so the origin check alone passes): a local editor only + * answers to loopback names and `ALLOWED_HOSTS`. A hosted one checks `ALLOWED_HOSTS` when set. + */ +export const hostAllowed = (env: EditorEnv, host: string | null): boolean => { + if (!host) return false; + const name = hostname(host); + const allowed = [ + ...env.allowedHosts, + ...env.allowedOrigins.flatMap((origin) => { + try { + return [new URL(origin).hostname.toLowerCase()]; + } catch { + return []; + } + }), + ...(WILDCARD_HOSTS.has(env.host) ? [] : [env.host.toLowerCase()]), + ]; + if (LOOPBACK.test(name) || allowed.includes(name)) return true; + return env.mode === 'ONLINE' && !env.allowedHosts.length; +}; + +/** + * Whether a browser request comes from the editor's own pages. Requests without `Origin` + * (navigations, non-browser clients) pass, except cross-site subresource loads. + */ +export const originAllowed = (env: EditorEnv, request: Request): boolean => { + const origin = request.headers.get('origin'); + if (!origin) { + const site = request.headers.get('sec-fetch-site'); + return site !== 'cross-site' || request.headers.get('sec-fetch-mode') === 'navigate'; + } + const host = request.headers.get('host') ?? new URL(request.url).host; + try { + return new URL(origin).host === host || env.allowedOrigins.includes(origin); + } catch { + return false; + } +}; diff --git a/packages/server-core/src/session/auth.ts b/packages/server-core/src/session/auth.ts new file mode 100644 index 00000000..b9b11292 --- /dev/null +++ b/packages/server-core/src/session/auth.ts @@ -0,0 +1,17 @@ +import { type CallInfo, RpcError } from '@nanoforge-dev/editor-rpc'; + +import type { Session } from './session-store'; + +export interface RequestContext { + readonly session: Session; + /** `Set-Cookie` values to add to the HTTP response (ignored over WebSocket). */ + readonly responseCookies: string[]; +} + +/** RPC guard: every non public method needs a signed-in user. */ +export const authorize = (context: RequestContext, call: CallInfo): void => { + if (call.public) return; + if (!context.session.user) { + throw new RpcError('UNAUTHORIZED', 'Sign in on the NanoForge website to use the editor'); + } +}; diff --git a/packages/server-core/src/session/cookies.ts b/packages/server-core/src/session/cookies.ts new file mode 100644 index 00000000..d3198ade --- /dev/null +++ b/packages/server-core/src/session/cookies.ts @@ -0,0 +1,55 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export const parseCookies = (header: string | null | undefined): Record => { + const cookies: Record = {}; + for (const part of (header ?? '').split(';')) { + const index = part.indexOf('='); + if (index < 0) continue; + const name = part.slice(0, index).trim(); + if (!name) continue; + try { + cookies[name] = decodeURIComponent(part.slice(index + 1).trim()); + } catch { + cookies[name] = part.slice(index + 1).trim(); + } + } + return cookies; +}; + +export interface CookieOptions { + maxAge?: number; + secure?: boolean; + httpOnly?: boolean; + sameSite?: 'Strict' | 'Lax' | 'None'; + path?: string; + domain?: string; +} + +export const serializeCookie = (name: string, value: string, options: CookieOptions = {}) => + [ + `${name}=${encodeURIComponent(value)}`, + `Path=${options.path ?? '/'}`, + options.maxAge !== undefined && `Max-Age=${options.maxAge}`, + options.domain && `Domain=${options.domain}`, + options.httpOnly !== false && 'HttpOnly', + options.secure && 'Secure', + `SameSite=${options.sameSite ?? 'Lax'}`, + ] + .filter(Boolean) + .join('; '); + +const signature = (value: string, secret: string) => + createHmac('sha256', secret).update(value).digest('base64url'); + +export const sign = (value: string, secret: string): string => + `${value}.${signature(value, secret)}`; + +/** Returns the signed value, or undefined when the signature does not match. */ +export const unsign = (signed: string, secret: string): string | undefined => { + const index = signed.lastIndexOf('.'); + if (index <= 0) return undefined; + const value = signed.slice(0, index); + const expected = Buffer.from(signature(value, secret)); + const actual = Buffer.from(signed.slice(index + 1)); + return expected.length === actual.length && timingSafeEqual(expected, actual) ? value : undefined; +}; diff --git a/packages/server-core/src/session/index.ts b/packages/server-core/src/session/index.ts new file mode 100644 index 00000000..347a2a9b --- /dev/null +++ b/packages/server-core/src/session/index.ts @@ -0,0 +1,2 @@ +export * from './auth'; +export * from './session-store'; diff --git a/packages/server-core/src/session/session-store.ts b/packages/server-core/src/session/session-store.ts new file mode 100644 index 00000000..15f141da --- /dev/null +++ b/packages/server-core/src/session/session-store.ts @@ -0,0 +1,200 @@ +import { randomUUID } from 'node:crypto'; +import { userInfo } from 'node:os'; + +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; +import type { User } from '@nanoforge-dev/editor-protocol'; + +import type { EditorEnv } from '../env/editor-env.type'; +import { parseCookies, serializeCookie, sign, unsign } from './cookies'; + +export const SESSION_COOKIE = 'nf_editor_session'; +/** Set by the NanoForge projects website (shared parent domain) in ONLINE mode. */ +export const ACCESS_TOKEN_COOKIE = 'accessToken'; +export const REFRESH_TOKEN_COOKIE = 'refreshToken'; + +export interface AuthTokens { + accessToken: string; + refreshToken: string; +} + +export interface Session { + readonly id: string; + readonly mode: 'OFFLINE' | 'ONLINE'; + user: User | null; + tokens: AuthTokens | null; + /** Projects this session's user opened (ONLINE access control): cleared when the user changes. */ + readonly projects: Set; + lastSeen: number; + /** Open WebSockets of this session: its projects stay open while there is one. */ + sockets: number; +} + +export interface SessionResolution { + readonly session: Session; + /** `Set-Cookie` headers to send back (new session, refreshed tokens…). */ + readonly setCookies: string[]; +} + +export interface SessionStoreOptions { + /** + * Who an access token belongs to, asked to the API (`ApiClient.currentUser`). It may refresh + * the session's tokens; null when the tokens are not valid. + */ + readonly verify: (session: Session) => Promise; + /** Sessions kept at most; the least recently seen ones go first. */ + readonly maxSessions?: number; +} + +const IDLE_TTL_MS = 7 * 24 * 3600 * 1000; +const MAX_SESSIONS = 10_000; + +/** What the store remembers of the tokens it checked, per session. */ +interface Verification { + /** The access token the user was verified with (after a refresh, the new one). */ + token: string; + /** The token a refresh replaced: requests sent before the new cookie arrived still carry it. */ + replaced?: string; + pending?: Promise; +} + +/** + * In-memory sessions identified by a signed cookie. OFFLINE sessions belong to the local OS user; + * ONLINE sessions carry the API tokens found in the website cookies, and their user is the one + * the API answers for them (a token is never trusted on its own). + */ +export class SessionStore { + private readonly _sessions = new Map(); + private readonly _verifications = new Map(); + private readonly _onDidDrop = new Emitter(); + + /** A session was pruned or evicted: what it held can be released. */ + readonly onDidDrop: Event = this._onDidDrop.event; + + constructor( + private readonly _env: EditorEnv, + private readonly _options: SessionStoreOptions, + ) {} + + /** + * The session of a request. Without `create`, a request with no known session gets a + * throwaway one that is neither stored nor sent back (static files, plugin files…). + */ + async resolve( + cookieHeader: string | null | undefined, + options: { create?: boolean } = {}, + ): Promise { + const cookies = parseCookies(cookieHeader); + const setCookies: string[] = []; + const id = cookies[SESSION_COOKIE] && unsign(cookies[SESSION_COOKIE], this._env.sessionSecret); + let session = id ? this._sessions.get(id) : undefined; + if (!session) { + session = this._new(); + if (options.create === false) return { session, setCookies }; + this._store(session); + setCookies.push( + serializeCookie(SESSION_COOKIE, sign(session.id, this._env.sessionSecret), { + secure: this._env.production && this._env.mode === 'ONLINE', + }), + ); + } else { + this._sessions.delete(session.id); + this._sessions.set(session.id, session); + } + session.lastSeen = Date.now(); + if (session.mode === 'ONLINE') await this._authenticate(session, cookies); + return { session, setCookies }; + } + + /** Drops sessions idle for more than a week. */ + prune(now = Date.now()): void { + for (const session of [...this._sessions.values()]) { + if (now - session.lastSeen > IDLE_TTL_MS && !session.sockets) this._drop(session); + } + } + + /** Sessions that opened a project. */ + holders(projectId: string): Session[] { + return [...this._sessions.values()].filter((session) => session.projects.has(projectId)); + } + + dispose(): void { + this._onDidDrop.dispose(); + } + + private async _authenticate(session: Session, cookies: Record): Promise { + const accessToken = cookies[ACCESS_TOKEN_COOKIE]; + const refreshToken = cookies[REFRESH_TOKEN_COOKIE]; + if (!accessToken && !refreshToken) { + session.tokens = null; + this._setUser(session, null); + this._verifications.delete(session.id); + return; + } + const known = this._verifications.get(session.id); + if (known?.pending) return known.pending; + if (known && (accessToken === known.token || (accessToken && accessToken === known.replaced))) + return; + session.tokens = { accessToken: accessToken ?? '', refreshToken: refreshToken ?? '' }; + const verification: Verification = { token: accessToken ?? '' }; + verification.pending = this._options + .verify(session) + .catch(() => null) + .then((user) => { + this._setUser(session, user); + delete verification.pending; + if (!user || !session.tokens) { + this._verifications.delete(session.id); + return; + } + verification.token = session.tokens.accessToken; + if (accessToken && accessToken !== verification.token) verification.replaced = accessToken; + }); + this._verifications.set(session.id, verification); + return verification.pending; + } + + /** + * Who a session belongs to now. The projects it opened belong to its user: another user (or + * none, after a sign-out) gets none of them, even on the same browser. + */ + private _setUser(session: Session, user: User | null): void { + if (user?.id !== session.user?.id) session.projects.clear(); + session.user = user; + } + + private _new(): Session { + const offline = this._env.mode === 'OFFLINE'; + return { + id: randomUUID(), + mode: this._env.mode, + user: offline ? { id: 'local', name: localUserName() } : null, + tokens: null, + projects: new Set(), + lastSeen: Date.now(), + sockets: 0, + }; + } + + private _store(session: Session): void { + this._sessions.set(session.id, session); + const max = this._options.maxSessions ?? MAX_SESSIONS; + for (const oldest of this._sessions.values()) { + if (this._sessions.size <= max) break; + if (oldest !== session && !oldest.sockets) this._drop(oldest); + } + } + + private _drop(session: Session): void { + this._sessions.delete(session.id); + this._verifications.delete(session.id); + this._onDidDrop.fire(session); + } +} + +const localUserName = () => { + try { + return userInfo().username; + } catch { + return 'local'; + } +}; diff --git a/packages/server-core/src/settings/account-backends.ts b/packages/server-core/src/settings/account-backends.ts new file mode 100644 index 00000000..e448db8b --- /dev/null +++ b/packages/server-core/src/settings/account-backends.ts @@ -0,0 +1,183 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { Emitter, type Event } from '@nanoforge-dev/editor-kernel'; +import { type AccountSettingsDocument, SETTINGS_LIMITS } from '@nanoforge-dev/editor-protocol'; +import { RpcError } from '@nanoforge-dev/editor-rpc'; + +import type { ApiClient } from '../api/api-client'; +import type { EditorEnv } from '../env/editor-env.type'; +import type { Session } from '../session/session-store'; +import { shortHash } from '../util/hash'; +import { KeyedMutex } from '../util/keyed-mutex'; + +type Values = AccountSettingsDocument['values']; + +/** Where account settings live: the NanoForge API (online) or the data dir (offline). */ +export interface AccountSettingsBackend { + /** Fires with the user id when a document was saved through this server. */ + readonly onDidChange: Event<{ userId: string; revision: string }>; + get(session: Session): Promise; + put(session: Session, baseRevision: string | null, values: Values): Promise<{ revision: string }>; +} + +const userOf = (session: Session) => { + if (!session.user) throw new RpcError('UNAUTHORIZED', 'Not signed in'); + return session.user; +}; + +export const checkLimits = (values: Values): void => { + const keys = Object.keys(values).length; + const bytes = new TextEncoder().encode(JSON.stringify(values)).length; + if (keys > SETTINGS_LIMITS.maxKeys || bytes > SETTINGS_LIMITS.maxBytes) { + throw new RpcError('PAYLOAD_TOO_LARGE', `Settings too large (${keys} keys, ${bytes} bytes)`); + } +}; + +/** Writes a whole file or nothing: a crash mid-write leaves the previous content. */ +const writeAtomically = async (file: string, text: string): Promise => { + await mkdir(dirname(file), { recursive: true }); + const temp = `${file}.${randomBytes(4).toString('hex')}.tmp`; + try { + await writeFile(temp, text); + await rename(temp, file); + } catch (error) { + await rm(temp, { force: true }); + throw error; + } +}; + +/** + * Reads a JSON settings file: missing means empty. A file that is not valid JSON (edited by + * hand) is set aside as `.invalid-