diff --git a/.claude/commands/create-next-version-branch.md b/.claude/commands/create-next-version-branch.md new file mode 100644 index 00000000000..3b68d7527fa --- /dev/null +++ b/.claude/commands/create-next-version-branch.md @@ -0,0 +1,83 @@ +--- +name: create-next-version-branch +description: Create development and release branches with GitHub Release for the next version. Usage: /create-next-version-branch dev/{major}.{minor}.x +--- + +# Create Next Version Branch + +Automate the creation of development branches and GitHub Release for a new GROWI version. + +## Input + +The argument `$ARGUMENTS` must be a branch name in the format `dev/{major}.{minor}.x` (e.g., `dev/7.5.x`). + +## Procedure + +### Step 1: Parse and Validate Input + +1. Parse `$ARGUMENTS` to extract `{major}` and `{minor}` from the `dev/{major}.{minor}.x` pattern +2. If the format is invalid, display an error and stop: + - Must match `dev/{number}.{number}.x` +3. Set the following variables: + - `DEV_BRANCH`: `dev/{major}.{minor}.x` + - `RELEASE_BRANCH`: `release/{major}.{minor}.x` + - `TAG_NAME`: `v{major}.{minor}.x-base` + - `RELEASE_TITLE`: `v{major}.{minor}.x Base Release` + +### Step 2: Create and Push the Development Branch + +1. Confirm with the user before proceeding +2. Create and push `DEV_BRANCH` from the current HEAD: + ```bash + git checkout -b {DEV_BRANCH} + git push origin {DEV_BRANCH} + ``` + +### Step 3: Create GitHub Release + +1. Create a GitHub Release using `gh release create`: + ```bash + gh release create {TAG_NAME} \ + --target {DEV_BRANCH} \ + --title "{RELEASE_TITLE}" \ + --notes "The base release for release-drafter to avoid \`Error: GraphQL Rate Limit Exceeded\` + https://github.com/release-drafter/release-drafter/issues/1018" \ + --latest=false \ + --prerelease=false + ``` + - `--latest=false`: Do NOT set as latest release + - `--prerelease=false`: Do NOT set as pre-release + +### Step 4: Verify targetCommitish + +1. Run the following command and confirm that `targetCommitish` equals `DEV_BRANCH`: + ```bash + gh release view {TAG_NAME} --json targetCommitish + ``` +2. If `targetCommitish` does not match, display an error and stop + +### Step 5: Create and Push the Release Branch + +1. From the same commit (still on `DEV_BRANCH`), create and push `RELEASE_BRANCH`: + ```bash + git checkout -b {RELEASE_BRANCH} + git push origin {RELEASE_BRANCH} + ``` + +### Step 6: Summary + +Display a summary of all created resources: + +``` +Created: + - Branch: {DEV_BRANCH} (pushed to origin) + - Branch: {RELEASE_BRANCH} (pushed to origin) + - GitHub Release: {RELEASE_TITLE} (tag: {TAG_NAME}, target: {DEV_BRANCH}) +``` + +## Error Handling + +- If `DEV_BRANCH` already exists on the remote, warn the user and ask how to proceed +- If `RELEASE_BRANCH` already exists on the remote, warn the user and ask how to proceed +- If the tag `TAG_NAME` already exists, warn the user and ask how to proceed +- If `gh` CLI is not authenticated, instruct the user to run `gh auth login` diff --git a/.claude/commands/kiro/spec-cleanup.md b/.claude/commands/kiro/spec-cleanup.md index 2c70117a950..a95c56b7a54 100644 --- a/.claude/commands/kiro/spec-cleanup.md +++ b/.claude/commands/kiro/spec-cleanup.md @@ -41,6 +41,11 @@ Clean up and organize specification documents for feature **$1** after implement - Read all core files first - Read other files to understand their content and value +**Determine target language**: +- Read `spec.json` and extract the `language` field (e.g., `"ja"`, `"en"`) +- This is the language ALL spec document content must be written in +- Note: code comments within code blocks are exempt (must stay in English per project rules) + **Verify implementation status**: - Check that tasks are marked complete `[x]` in tasks.md - If implementation incomplete, warn user and ask to confirm cleanup @@ -86,6 +91,15 @@ Clean up and organize specification documents for feature **$1** after implement * Known limitations - Check if content from other files should be migrated here +5. **Language audit** (compare actual language vs. `spec.json.language`): + - For each markdown file, scan prose content (headings, paragraphs, list items) and detect the written language + - Flag any file or section whose language does **not** match the target language + - Exemptions — do NOT flag: + * Content inside fenced code blocks (` ``` `) — code comments must stay in English + * Inline code spans (`` `...` ``) + * Proper nouns, technical terms, and identifiers that are always written in English + - Collect flagged items into a **translation plan**: file name, approximate line range, detected language, and a brief excerpt + ### Step 3: Interactive Confirmation **Present cleanup plan to user**: @@ -110,6 +124,14 @@ For each file and section identified in Step 2, ask: - "design.md: Delete 'Security Considerations' section (lines X-Y)? [Y/n]" - "design.md: Keep Architecture diagrams (essential for refactoring)? [Y/n]" +**Translation confirmation** (if language mismatches were found in Step 2): +- Show summary: "Found content in language(s) other than `{target_language}` in the following files:" + - List each flagged file with line range and a short excerpt +- Ask: "Translate mismatched content to `{target_language}`? [Y/n]" + - If Y: translate all flagged sections in Step 4 + - If n: skip translation (leave files as-is) +- Note: code blocks are never translated + **Batch similar decisions**: - Group related sections (e.g., all "delete implementation details" decisions) - Allow user to approve categories rather than individual items @@ -153,7 +175,14 @@ For each file and section identified in Step 2, ask: - Preserve architecture diagrams and component interfaces - Keep design decisions and rationale sections -5. **Update spec.json metadata**: +5. **Translate language-mismatched content** (if approved): + - For each flagged file and section, translate prose content to the target language + - **Never translate**: content inside fenced code blocks or inline code spans + - Preserve all Markdown formatting (headings, bold, lists, links, etc.) + - After translation, verify the overall document reads naturally in the target language + - Document translated files in the cleanup summary + +6. **Update spec.json metadata**: - Set `phase: "implementation-complete"` (if not already set) - Add `cleanup_completed: true` flag - Update `updated_at` timestamp @@ -176,6 +205,7 @@ For each file and section identified in Step 2, ask: - ✅ research.md: Added Session 2 discoveries + salvaged content (180 lines added) - ✅ requirements.md: Simplified 6 requirements (350 lines → 180 lines) - ✅ design.md: Removed 4 sections, added constraints + salvaged content (250 lines removed, 100 added) +- ✅ requirements.md: Translated mismatched sections to {target_language} ### Information Salvaged - Implementation discoveries from validation-report.md → research.md @@ -196,7 +226,7 @@ For each file and section identified in Step 2, ask: ## Critical Constraints - **User approval required**: Never delete content without explicit confirmation -- **Language consistency**: Use language specified in spec.json for all updates +- **Language consistency**: All prose content must be written in the language specified in `spec.json.language`; translate any mismatched sections (code blocks exempt) - **Preserve history**: Don't delete discovery rationale or design decisions - **Balance brevity with completeness**: Remove redundancy but keep essential context - **Interactive workflow**: Pause for user input rather than making assumptions diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md index e620d7532cf..b37c655b2f5 100644 --- a/.claude/rules/coding-style.md +++ b/.claude/rules/coding-style.md @@ -201,6 +201,27 @@ Implemented react-window for virtualizing page tree to improve performance with 10k+ pages. ``` +## Cross-Platform Compatibility + +GROWI must work on Windows, macOS, and Linux. Never use platform-specific shell commands in npm scripts. + +```json +// ❌ WRONG: Unix-only commands in npm scripts +"clean": "rm -rf dist", +"copy": "cp src/foo.ts dist/foo.ts", +"move": "mv src dist" + +// ✅ CORRECT: Cross-platform tools +"clean": "rimraf dist", +"copy": "node -e \"require('fs').cpSync('src/foo.ts','dist/foo.ts')\"", +"move": "node -e \"require('fs').renameSync('src','dist')\"" +``` + +**Rules**: +- Use `rimraf` instead of `rm -rf` +- Use Node.js one-liners or cross-platform tools (`cpy-cli`, `cpx2`) instead of `cp`, `mv`, `echo`, `ls` +- Never assume a POSIX shell in npm scripts + ## Code Quality Checklist Before marking work complete: diff --git a/.claude/settings.json b/.claude/settings.json index 43d85645269..118acbcd50b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,36 @@ { + "permissions": { + "allow": [ + "Bash(node --version)", + "Bash(npm --version)", + "Bash(npm view *)", + "Bash(pnpm --version)", + "Bash(turbo --version)", + "Bash(turbo run build)", + "Bash(turbo run lint)", + "Bash(pnpm run lint:*)", + "Bash(pnpm vitest run *)", + "Bash(pnpm biome check *)", + "Bash(pnpm ls *)", + "Bash(pnpm why *)", + "Bash(cat *)", + "Bash(echo *)", + "Bash(find *)", + "Bash(grep *)", + "Bash(git diff *)", + "Bash(gh issue view *)", + "Bash(gh pr view *)", + "Bash(gh pr diff *)", + "Bash(ls *)", + "WebFetch(domain:github.com)", + "mcp__context7__*", + "mcp__plugin_context7_*", + "mcp__github__*", + "WebSearch", + "WebFetch" + ] + }, + "enableAllProjectMcpServers": true, "hooks": { "SessionStart": [ { @@ -17,8 +49,7 @@ { "type": "command", "command": "if [[ \"$FILE\" == */apps/* ]] || [[ \"$FILE\" == */packages/* ]]; then REPO_ROOT=$(echo \"$FILE\" | sed 's|/\\(apps\\|packages\\)/.*|/|'); cd \"$REPO_ROOT\" && pnpm biome check --write \"$FILE\" 2>/dev/null || true; fi", - "timeout": 30, - "description": "Auto-format edited files in apps/* and packages/* with Biome" + "timeout": 30 } ] } diff --git a/.claude/skills/monorepo-overview/SKILL.md b/.claude/skills/monorepo-overview/SKILL.md index b6ca4b7b6ac..efa4578b03f 100644 --- a/.claude/skills/monorepo-overview/SKILL.md +++ b/.claude/skills/monorepo-overview/SKILL.md @@ -64,6 +64,34 @@ turbo run test --filter @growi/app turbo run lint --filter @growi/core ``` +### Build Order Management + +Build dependencies in this monorepo are **not** declared with `dependsOn: ["^build"]` (the automatic workspace-dependency mode). Instead, they are declared **explicitly** — either in the root `turbo.json` for legacy entries, or in per-package `turbo.json` files for newer packages. + +**When to update**: whenever a package gains a new workspace dependency on another buildable package (one that produces a `dist/`), declare the build-order dependency explicitly. Without it, Turborepo may build in the wrong order, causing missing `dist/` files or type errors. + +**Pattern — per-package `turbo.json`** (preferred for new dependencies): + +```json +// packages/my-package/turbo.json +{ + "extends": ["//"], + "tasks": { + "build": { "dependsOn": ["@growi/some-dep#build"] }, + "dev": { "dependsOn": ["@growi/some-dep#dev"] } + } +} +``` + +- `"extends": ["//"]` inherits all root task definitions; only add the extra `dependsOn` +- Keep root `turbo.json` clean — package-level overrides live with the package that owns the dependency +- For packages with multiple tasks (watch, lint, test), mirror the dependency in each relevant task + +**Existing examples**: +- `packages/slack/turbo.json` — `build`/`dev` depend on `@growi/logger` +- `packages/remark-attachment-refs/turbo.json` — all tasks depend on `@growi/core`, `@growi/logger`, `@growi/remark-growi-directive`, `@growi/ui` +- Root `turbo.json` — `@growi/ui#build` depends on `@growi/core#build` (pre-dates the per-package pattern) + ## Architectural Principles ### 1. Feature-Based Architecture (Recommended) @@ -99,11 +127,28 @@ This enables better code splitting and prevents server-only code from being bund Common code should be extracted to `packages/`: -- **core**: Utilities, constants, type definitions +- **core**: Domain hub (see below) - **ui**: Reusable React components - **editor**: Markdown editor - **pluginkit**: Plugin system framework +#### @growi/core — Domain & Utilities Hub + +`@growi/core` is the foundational shared package depended on by all other packages (10 consumers). Its responsibilities: + +- **Domain type definitions** — Single source of truth for cross-package interfaces (`IPage`, `IUser`, `IRevision`, `Ref`, `HasObjectId`, etc.) +- **Cross-cutting utilities** — Pure functions for page path validation, ObjectId checks, serialization (e.g., `serializeUserSecurely()`) +- **System constants** — File types, plugin configs, scope enums +- **Global type augmentations** — Runtime/polyfill type declarations visible to all consumers (e.g., `RegExp.escape()` via `declare global` in `index.ts`) + +Key patterns: + +1. **Shared types and global augmentations go in `@growi/core`** — Not duplicated per-package. `declare global` in `index.ts` propagates to all consumers through the module graph. +2. **Subpath exports for granular imports** — `@growi/core/dist/utils/page-path-utils` instead of barrel imports from root. +3. **Minimal runtime dependencies** — Only `bson-objectid`; ~70% types. Safe to import from both server and client contexts. +4. **Server-specific interfaces are namespaced** — Under `interfaces/server/`. +5. **Dual format (ESM + CJS)** — Built via Vite with `preserveModules: true` and `vite-plugin-dts` (`copyDtsFiles: true`). + ## Version Management with Changeset GROWI uses **Changesets** for version management and release notes: diff --git a/.claude/skills/tech-stack/SKILL.md b/.claude/skills/tech-stack/SKILL.md index 1884aec6bcf..37c6d32542d 100644 --- a/.claude/skills/tech-stack/SKILL.md +++ b/.claude/skills/tech-stack/SKILL.md @@ -38,7 +38,7 @@ user-invocable: false ## Build & Development Tools ### Package Management -- **pnpm** 10.4.1 - Package manager (faster, more efficient than npm/yarn) +- **pnpm** Package manager (faster, more efficient than npm/yarn) ### Monorepo Orchestration - **Turborepo** ^2.1.3 - Build system with caching and parallelization diff --git a/.devcontainer/app/devcontainer.json b/.devcontainer/app/devcontainer.json index 3a35cf3bb54..c5120f27a93 100644 --- a/.devcontainer/app/devcontainer.json +++ b/.devcontainer/app/devcontainer.json @@ -8,7 +8,7 @@ "features": { "ghcr.io/devcontainers/features/node:1": { - "version": "20.18.3" + "version": "24.14.0" }, "ghcr.io/devcontainers/features/github-cli:1": {} }, diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 904bf0b0bce..5e82bb6293a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,8 +24,6 @@ updates: prefix: ci include: scope ignore: - - dependency-name: escape-string-regexp - - dependency-name: string-width - dependency-name: "@handsontable/react" - dependency-name: handsontable - dependency-name: typeorm diff --git a/.github/mergify.yml b/.github/mergify.yml index c259d183f1b..488804aea8c 100644 --- a/.github/mergify.yml +++ b/.github/mergify.yml @@ -6,17 +6,17 @@ queue_rules: - check-success ~= ci-app-launch-dev - -check-failure ~= ci-app- - -check-failure ~= ci-slackbot- - - -check-failure ~= test-prod-node20 / + - -check-failure ~= test-prod-node24 / merge_conditions: - check-success ~= ci-app-lint - check-success ~= ci-app-test - check-success ~= ci-app-launch-dev - - check-success = test-prod-node20 / build-prod - - check-success ~= test-prod-node20 / launch-prod - - check-success ~= test-prod-node20 / run-playwright + - check-success = test-prod-node24 / build-prod + - check-success ~= test-prod-node24 / launch-prod + - check-success ~= test-prod-node24 / run-playwright - -check-failure ~= ci-app- - -check-failure ~= ci-slackbot- - - -check-failure ~= test-prod-node20 / + - -check-failure ~= test-prod-node24 / pull_request_rules: - name: Automatic queue to merge diff --git a/.github/workflows/ci-app-prod.yml b/.github/workflows/ci-app-prod.yml index 87292106750..464948eec6f 100644 --- a/.github/workflows/ci-app-prod.yml +++ b/.github/workflows/ci-app-prod.yml @@ -9,7 +9,6 @@ on: - .github/mergify.yml - .github/workflows/ci-app-prod.yml - .github/workflows/reusable-app-prod.yml - - .github/workflows/reusable-app-reg-suit.yml - tsconfig.base.json - turbo.json - pnpm-lock.yaml @@ -23,7 +22,6 @@ on: - .github/mergify.yml - .github/workflows/ci-app-prod.yml - .github/workflows/reusable-app-prod.yml - - .github/workflows/reusable-app-reg-suit.yml - tsconfig.base.json - pnpm-lock.yaml - turbo.json @@ -39,22 +37,21 @@ concurrency: jobs: - test-prod-node18: - uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master - if: | - ( github.event_name == 'push' - || github.base_ref == 'master' - || github.base_ref == 'dev/7.*.x' - || startsWith( github.base_ref, 'release/' ) - || startsWith( github.head_ref, 'mergify/merge-queue/' )) - with: - node-version: 18.x - skip-e2e-test: true - secrets: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - + # test-prod-node22: + # uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master + # if: | + # ( github.event_name == 'push' + # || github.base_ref == 'master' + # || github.base_ref == 'dev/7.*.x' + # || startsWith( github.base_ref, 'release/' ) + # || startsWith( github.head_ref, 'mergify/merge-queue/' )) + # with: + # node-version: 22.x + # skip-e2e-test: true + # secrets: + # SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - test-prod-node20: + test-prod-node24: uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master if: | ( github.event_name == 'push' @@ -63,23 +60,7 @@ jobs: || startsWith( github.base_ref, 'release/' ) || startsWith( github.head_ref, 'mergify/merge-queue/' )) with: - node-version: 20.x + node-version: 24.x skip-e2e-test: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }} secrets: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - - # run-reg-suit-node20: - # needs: [test-prod-node20] - - # uses: growilabs/growi/.github/workflows/reusable-app-reg-suit.yml@master - - # if: always() - - # with: - # node-version: 20.x - # skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }} - # secrets: - # REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }} - # AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - # AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - # SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/ci-app.yml b/.github/workflows/ci-app.yml index 51c52420541..220ddbf99e1 100644 --- a/.github/workflows/ci-app.yml +++ b/.github/workflows/ci-app.yml @@ -44,7 +44,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [24.x] steps: - uses: actions/checkout@v4 @@ -92,7 +92,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [24.x] mongodb-version: ['6.0', '8.0'] services: @@ -157,7 +157,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [24.x] mongodb-version: ['6.0', '8.0'] services: diff --git a/.github/workflows/ci-pdf-converter.yml b/.github/workflows/ci-pdf-converter.yml index 09808fdd7e2..7399745878e 100644 --- a/.github/workflows/ci-pdf-converter.yml +++ b/.github/workflows/ci-pdf-converter.yml @@ -29,7 +29,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [24.x] steps: - uses: actions/checkout@v4 @@ -65,7 +65,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [24.x] steps: - uses: actions/checkout@v4 @@ -104,7 +104,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [24.x] steps: - uses: actions/checkout@v4 @@ -142,7 +142,7 @@ jobs: - name: Assembling all dependencies run: | rm -rf out - pnpm deploy out --prod --filter @growi/pdf-converter + pnpm deploy out --prod --legacy --filter @growi/pdf-converter rm -rf apps/pdf-converter/node_modules && mv out/node_modules apps/pdf-converter/node_modules - name: pnpm run start:prod:ci diff --git a/.github/workflows/ci-slackbot-proxy.yml b/.github/workflows/ci-slackbot-proxy.yml index 40867587bc8..82fade55986 100644 --- a/.github/workflows/ci-slackbot-proxy.yml +++ b/.github/workflows/ci-slackbot-proxy.yml @@ -30,7 +30,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [24.x] steps: - uses: actions/checkout@v4 @@ -85,7 +85,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [24.x] services: mysql: @@ -163,7 +163,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [24.x] services: mysql: @@ -211,7 +211,7 @@ jobs: - name: Assembling all dependencies run: | rm -rf out - pnpm deploy out --prod --filter @growi/slackbot-proxy + pnpm deploy out --prod --legacy --filter @growi/slackbot-proxy rm -rf apps/slackbot-proxy/node_modules && mv out/node_modules apps/slackbot-proxy/node_modules - name: pnpm run start:prod:ci diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml index 327a56a6118..e320ab52557 100644 --- a/.github/workflows/release-rc.yml +++ b/.github/workflows/release-rc.yml @@ -37,7 +37,7 @@ jobs: type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}} build-image-rc: - uses: growilabs/growi/.github/workflows/reusable-app-build-image.yml@master + uses: growilabs/growi/.github/workflows/reusable-app-build-image.yml@rc/v7.5.x-node24 with: image-name: growilabs/growi tag-temporary: latest-rc @@ -47,7 +47,7 @@ jobs: publish-image-rc: needs: [determine-tags, build-image-rc] - uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master + uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@rc/v7.5.x-node24 with: tags: ${{ needs.determine-tags.outputs.TAGS }} registry: docker.io diff --git a/.github/workflows/release-subpackages.yml b/.github/workflows/release-subpackages.yml index 93415a8cb73..0b8ce148c96 100644 --- a/.github/workflows/release-subpackages.yml +++ b/.github/workflows/release-subpackages.yml @@ -14,6 +14,11 @@ on: branches: - master +permissions: + id-token: write + contents: write + pull-requests: write + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -32,7 +37,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: 'pnpm' - name: Install dependencies @@ -40,14 +45,6 @@ jobs: pnpm add turbo --global pnpm install --frozen-lockfile - - name: Setup .npmrc - run: | - cat << EOF > "$HOME/.npmrc" - //registry.npmjs.org/:_authToken=$NPM_TOKEN - EOF - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Retrieve changesets information id: changesets-status run: | @@ -61,7 +58,6 @@ jobs: pnpm run release-subpackages:snapshot env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} release-subpackages: @@ -75,7 +71,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: 'pnpm' - name: Install dependencies @@ -92,4 +88,3 @@ jobs: publish: pnpm run release-subpackages env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/reusable-app-build-image.yml b/.github/workflows/reusable-app-build-image.yml index 7b6cf855193..a16d410bdf4 100644 --- a/.github/workflows/reusable-app-build-image.yml +++ b/.github/workflows/reusable-app-build-image.yml @@ -48,7 +48,7 @@ jobs: projectName: growi-official-image-builder env: CODEBUILD__sourceVersion: ${{ inputs.source-version }} - CODEBUILD__imageOverride: ${{ (matrix.platform == 'amd64' && 'aws/codebuild/amazonlinux2-x86_64-standard:4.0') || 'aws/codebuild/amazonlinux2-aarch64-standard:2.0' }} + CODEBUILD__imageOverride: ${{ (matrix.platform == 'amd64' && 'aws/codebuild/amazonlinux2-x86_64-standard:5.0') || 'aws/codebuild/amazonlinux2-aarch64-standard:3.0' }} CODEBUILD__environmentTypeOverride: ${{ (matrix.platform == 'amd64' && 'LINUX_CONTAINER') || 'ARM_CONTAINER' }} CODEBUILD__environmentVariablesOverride: '[ { "name": "IMAGE_TAG", "type": "PLAINTEXT", "value": "docker.io/${{ inputs.image-name }}:${{ inputs.tag-temporary }}-${{ matrix.platform }}" } diff --git a/.github/workflows/reusable-app-prod.yml b/.github/workflows/reusable-app-prod.yml index b39669da8a2..e18a31e6155 100644 --- a/.github/workflows/reusable-app-prod.yml +++ b/.github/workflows/reusable-app-prod.yml @@ -16,7 +16,7 @@ on: node-version: required: true type: string - default: 22.x + default: 24.x skip-e2e-test: type: boolean default: false @@ -57,17 +57,18 @@ jobs: env: ANALYZE: 1 - - name: Assembling all dependencies - run: | - rm -rf out - pnpm deploy out --prod --filter @growi/app - rm -rf apps/app/node_modules && mv out/node_modules apps/app/node_modules + - name: Assemble production artifacts + run: bash apps/app/bin/assemble-prod.sh + + - name: Check for broken symlinks in .next/node_modules + run: bash apps/app/bin/check-next-symlinks.sh - name: Archive production files id: archive-prod-files run: | tar -zcf production.tar.gz --exclude ./apps/app/.next/cache \ package.json \ + node_modules \ apps/app/.next \ apps/app/config \ apps/app/dist \ @@ -76,6 +77,7 @@ jobs: apps/app/tmp \ apps/app/.env.production* \ apps/app/node_modules \ + apps/app/next.config.js \ apps/app/package.json echo "file=production.tar.gz" >> $GITHUB_OUTPUT @@ -124,30 +126,22 @@ jobs: discovery.type: single-node steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} - cache: 'pnpm' - - # avoid setup-node cache failure; see: https://github.com/actions/setup-node/issues/1137 - - name: Verify PNPM Cache Directory - run: | - PNPM_STORE_PATH="$( pnpm store path --silent )" - [ -d "$PNPM_STORE_PATH" ] || mkdir -vp "$PNPM_STORE_PATH" - name: Download production files artifact uses: actions/download-artifact@v4 with: name: Production Files (node${{ inputs.node-version }}) - - name: Extract procution files + - name: Extract production files run: | tar -xf ${{ needs.build-prod.outputs.PROD_FILES }} + # Run after extraction so pnpm/action-setup@v4 can read packageManager from package.json + - uses: pnpm/action-setup@v4 + - name: pnpm run server:ci working-directory: ./apps/app run: | @@ -179,7 +173,7 @@ jobs: container: # Match the Playwright version # https://github.com/microsoft/playwright/issues/20010 - image: mcr.microsoft.com/playwright:v1.49.1-jammy + image: mcr.microsoft.com/playwright:v1.58.2-jammy strategy: fail-fast: false @@ -223,14 +217,14 @@ jobs: with: name: Production Files (node${{ inputs.node-version }}) - - name: Extract procution files + - name: Extract production files to isolated directory run: | - tar -xf ${{ needs.build-prod.outputs.PROD_FILES }} + mkdir -p /tmp/growi-prod + tar -xf ${{ needs.build-prod.outputs.PROD_FILES }} -C /tmp/growi-prod - name: Copy dotenv file for ci - working-directory: ./apps/app run: | - cat config/ci/.env.local.for-ci >> .env.production.local + cat apps/app/config/ci/.env.local.for-ci >> /tmp/growi-prod/apps/app/.env.production.local - name: Playwright Run (--project=chromium/installer) if: ${{ matrix.browser == 'chromium' }} @@ -240,13 +234,13 @@ jobs: env: DEBUG: pw:api HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500 + GROWI_WEBSERVER_COMMAND: 'cd /tmp/growi-prod/apps/app && pnpm run server' MONGO_URI: mongodb://mongodb:27017/growi-playwright-installer ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi - name: Copy dotenv file for automatic installation - working-directory: ./apps/app run: | - cat config/ci/.env.local.for-auto-install >> .env.production.local + cat apps/app/config/ci/.env.local.for-auto-install >> /tmp/growi-prod/apps/app/.env.production.local - name: Playwright Run working-directory: ./apps/app @@ -255,13 +249,13 @@ jobs: env: DEBUG: pw:api HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500 + GROWI_WEBSERVER_COMMAND: 'cd /tmp/growi-prod/apps/app && pnpm run server' MONGO_URI: mongodb://mongodb:27017/growi-playwright ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi - name: Copy dotenv file for automatic installation with allowing guest mode - working-directory: ./apps/app run: | - cat config/ci/.env.local.for-auto-install-with-allowing-guest >> .env.production.local + cat apps/app/config/ci/.env.local.for-auto-install-with-allowing-guest >> /tmp/growi-prod/apps/app/.env.production.local - name: Playwright Run (--project=${browser}/guest-mode) working-directory: ./apps/app @@ -270,6 +264,7 @@ jobs: env: DEBUG: pw:api HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500 + GROWI_WEBSERVER_COMMAND: 'cd /tmp/growi-prod/apps/app && pnpm run server' MONGO_URI: mongodb://mongodb:27017/growi-playwright-guest-mode ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi diff --git a/.github/workflows/reusable-app-reg-suit.yml b/.github/workflows/reusable-app-reg-suit.yml deleted file mode 100644 index 14cb4a285fc..00000000000 --- a/.github/workflows/reusable-app-reg-suit.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Reusable VRT reporting workflow for production - -on: - workflow_call: - inputs: - node-version: - required: true - type: string - checkout-ref: - type: string - default: ${{ github.head_ref }} - skip-reg-suit: - type: boolean - cypress-report-artifact-name-pattern: - required: true - type: string - secrets: - REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: - required: true - AWS_ACCESS_KEY_ID: - required: true - AWS_SECRET_ACCESS_KEY: - required: true - SLACK_WEBHOOK_URL: - required: true - outputs: - EXPECTED_IMAGES_EXIST: - value: ${{ jobs.run-reg-suit.outputs.EXPECTED_IMAGES_EXIST }} - - -jobs: - - run-reg-suit: - # use secrets for "VRT" environment - # https://github.com/growilabs/growi/settings/environments/376165508/edit - environment: VRT - - if: ${{ !inputs.skip-reg-suit }} - - env: - REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - - runs-on: ubuntu-latest - - outputs: - EXPECTED_IMAGES_EXIST: ${{ steps.check-expected-images.outputs.EXPECTED_IMAGES_EXIST }} - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.checkout-ref }} - fetch-depth: 0 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: ${{ inputs.node-version }} - cache: 'pnpm' - - - name: Install dependencies - run: | - pnpm install --frozen-lockfile - - - name: Download screenshots taken by cypress - uses: actions/download-artifact@v4 - with: - path: apps/app/test/cypress - pattern: ${{ inputs.cypress-report-artifact-name-pattern }} - merge-multiple: true - - - name: Run reg-suit - working-directory: ./apps/app - run: | - pnpm run reg:run - - - name: Slack Notification - uses: weseek/ghaction-slack-notification@master - if: failure() - with: - type: ${{ job.status }} - job_name: '*Node CI for growi - run-reg-suit (${{ inputs.node-version }})*' - channel: '#ci' - isCompactMode: true - url: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.gitignore b/.gitignore index b10f6860df6..acad34f52cf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies node_modules +node_modules.* /.pnp .pnp.js .pnpm-store diff --git a/.kiro/specs/collaborative-editor/design.md b/.kiro/specs/collaborative-editor/design.md new file mode 100644 index 00000000000..f3b2144eebc --- /dev/null +++ b/.kiro/specs/collaborative-editor/design.md @@ -0,0 +1,268 @@ +# Design Document: collaborative-editor + +## Overview + +**Purpose**: Real-time collaborative editing in GROWI, allowing multiple users to simultaneously edit the same wiki page with automatic conflict resolution via Yjs CRDT. + +**Users**: All GROWI users who use real-time collaborative page editing. System operators manage the WebSocket and persistence infrastructure. + +**Impact**: Yjs document synchronization over native WebSocket (`y-websocket`), with Socket.IO continuing to serve non-Yjs real-time events (page room broadcasts, notifications). + +### Goals +- Guarantee a single server-side Y.Doc per page — no split-brain desynchronization +- Provide real-time bidirectional sync for all connected editors +- Authenticate and authorize WebSocket connections using existing session infrastructure +- Persist draft state to MongoDB for durability across reconnections and restarts +- Bridge awareness/presence events to non-editor UI via Socket.IO rooms + +### Non-Goals +- Changing the Yjs document model, CodeMirror integration, or page save/revision logic +- Migrating Socket.IO-based UI events to WebSocket +- Changing the `yjs-writings` MongoDB collection schema or data format + +## Architecture + +### Architecture Diagram + +```mermaid +graph TB + subgraph Client + CM[CodeMirror Editor] + WP[WebsocketProvider] + GS[Global Socket.IO Client] + end + + subgraph Server + subgraph HTTP Server + Express[Express App] + SIO[Socket.IO Server] + WSS[WebSocket Server - ws] + end + + subgraph YjsService + UpgradeHandler[Upgrade Handler - Auth] + ConnHandler[Connection Handler] + DocManager[Document Manager - getYDoc] + AwarenessBridge[Awareness Bridge] + end + + MDB[(MongoDB - yjs-writings)] + SessionStore[(Session Store)] + end + + CM --> WP + WP -->|ws path yjs pageId| WSS + GS -->|socket.io| SIO + + WSS -->|upgrade auth| UpgradeHandler + UpgradeHandler -->|parse cookie| SessionStore + WSS -->|connection| ConnHandler + ConnHandler --> DocManager + DocManager --> MDB + + AwarenessBridge -->|io.in room .emit| SIO + + DocManager -->|awareness events| AwarenessBridge +``` + +**Key architectural properties**: +- **Dual transport**: WebSocket for Yjs sync (`/yjs/{pageId}`), Socket.IO for UI events (`/socket.io/`) +- **Singleton YjsService**: Encapsulates all Yjs document management +- **Atomic document creation**: `map.setIfUndefined` from lib0 — synchronous get-or-create, no race condition window +- **Session-based auth**: Cookie parsed from HTTP upgrade request, same session store as Express + +### Technology Stack + +| Layer | Choice / Version | Role | +|-------|------------------|------| +| Client Provider | `y-websocket@^2.x` (WebsocketProvider) | Yjs document sync over WebSocket | +| Server WebSocket | `ws@^8.x` (WebSocket.Server) | Native WebSocket server, `noServer: true` mode | +| Server Yjs Utils | `y-websocket@^2.x` (`bin/utils`) | `setupWSConnection`, `getYDoc`, `WSSharedDoc` | +| Persistence | `y-mongodb-provider` (extended) | Yjs document persistence to `yjs-writings` collection | +| Event Bridge | Socket.IO `io` instance | Awareness state broadcasting to page rooms | +| Auth | express-session + passport | WebSocket upgrade authentication via cookie | + +## System Flows + +### Client Connection Flow + +```mermaid +sequenceDiagram + participant C as Client Browser + participant WSS as WebSocket Server + participant UH as Upgrade Handler + participant SS as Session Store + participant DM as Document Manager + participant MDB as MongoDB + + C->>WSS: HTTP Upgrade GET /yjs/pageId + WSS->>UH: upgrade event + UH->>SS: Parse cookie, load session + SS-->>UH: Session with user + UH->>UH: Check page access + alt Unauthorized + UH-->>C: 401/403, destroy socket + else Authorized + UH->>WSS: handleUpgrade + WSS->>DM: setupWSConnection + DM->>DM: getYDoc - atomic get or create + alt New document + DM->>MDB: bindState - load persisted state + MDB-->>DM: Y.Doc state + end + DM-->>C: Sync Step 1 - state vector + C-->>DM: Sync Step 2 - diff + DM-->>C: Awareness states + end +``` + +Authentication happens before `handleUpgrade` — unauthorized connections never reach the Yjs layer. Document creation uses `getYDoc`'s atomic `map.setIfUndefined` pattern. + +### Document Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Created: First client connects + Created --> Active: bindState completes + Active --> Active: Clients connect/disconnect + Active --> Flushing: Last client disconnects + Flushing --> [*]: writeState completes, doc destroyed + Flushing --> Active: New client connects before destroy +``` + +## Components and Interfaces + +| Component | Layer | Intent | Key Dependencies | +|-----------|-------|--------|-----------------| +| YjsService | Server / Service | Orchestrates Yjs document lifecycle, exposes public API | ws, y-websocket/bin/utils, MongodbPersistence | +| UpgradeHandler | Server / Auth | Authenticates and authorizes WebSocket upgrade requests | express-session, passport, Page model | +| guardSocket | Server / Util | Prevents socket closure by other upgrade handlers during async auth | — | +| PersistenceAdapter | Server / Data | Bridges MongodbPersistence to y-websocket persistence interface | MongodbPersistence, syncYDoc, Socket.IO io | +| AwarenessBridge | Server / Events | Bridges y-websocket awareness events to Socket.IO rooms | Socket.IO io | +| use-collaborative-editor-mode | Client / Hook | Manages WebsocketProvider lifecycle and awareness | y-websocket, yjs | + +### YjsService + +**Intent**: Manages Yjs document lifecycle, WebSocket server setup, and public API for page save/status integration. + +**Responsibilities**: +- Owns the `ws.WebSocketServer` instance and the y-websocket `docs` Map +- Initializes persistence via y-websocket's `setPersistence` +- Registers the HTTP `upgrade` handler (delegating auth to UpgradeHandler) +- Exposes the same public interface as `IYjsService` for downstream consumers + +**Service Interface**: + +```typescript +interface IYjsService { + getYDocStatus(pageId: string): Promise; + syncWithTheLatestRevisionForce( + pageId: string, + editingMarkdownLength?: number, + ): Promise; + getCurrentYdoc(pageId: string): Y.Doc | undefined; +} +``` + +- Constructor accepts `httpServer: http.Server` and `io: Server` +- Uses `WebSocket.Server({ noServer: true })` + y-websocket utils +- Uses `httpServer.on('upgrade', ...)` with path check for `/yjs/` +- **CRITICAL**: Socket.IO server must set `destroyUpgrade: false` to prevent engine.io from destroying non-Socket.IO upgrade requests + +### UpgradeHandler + +**Intent**: Authenticates WebSocket upgrade requests using session cookies and verifies page access. + +**Interface**: + +```typescript +type UpgradeResult = + | { authorized: true; request: AuthenticatedRequest; pageId: string } + | { authorized: false; statusCode: number }; +``` + +- Runs express-session and passport middleware via `runMiddleware` helper against raw `IncomingMessage` +- `writeErrorResponse` writes HTTP status line only — socket cleanup deferred to caller (works with `guardSocket`) +- Guest access: if `user` is undefined but page allows guest access, authorization proceeds + +### guardSocket + +**Intent**: Prevents other synchronous upgrade handlers from closing the socket during async auth. + +**Why this exists**: Node.js EventEmitter fires all `upgrade` listeners synchronously. When the Yjs async handler yields at its first `await`, Next.js's `NextCustomServer.upgradeHandler` runs and calls `socket.end()` for unrecognized paths. This destroys the socket before Yjs auth completes. + +**How it works**: Temporarily replaces `socket.end()` and `socket.destroy()` with no-ops before the first `await`. After auth completes, `restore()` reinstates the original methods. + +```typescript +const guard = guardSocket(socket); +const result = await handleUpgrade(request, socket, head); +guard.restore(); +``` + +### PersistenceAdapter + +**Intent**: Adapts MongodbPersistence to y-websocket's persistence interface (`bindState`, `writeState`). + +**Interface**: + +```typescript +interface YWebsocketPersistence { + bindState: (docName: string, ydoc: Y.Doc) => void; + writeState: (docName: string, ydoc: Y.Doc) => Promise; + provider: MongodbPersistence; +} +``` + +**Key behavior**: +- `bindState`: Loads persisted state → determines YDocStatus → calls `syncYDoc` → registers awareness event bridge +- `writeState`: Flushes document state to MongoDB on last-client disconnect +- Ordering within `bindState` is guaranteed (persistence load → sync → awareness registration) + +### AwarenessBridge + +**Intent**: Bridges y-websocket per-document awareness events to Socket.IO room broadcasts. + +**Published events** (to Socket.IO rooms): +- `YjsAwarenessStateSizeUpdated` with `awarenessStateSize: number` +- `YjsHasYdocsNewerThanLatestRevisionUpdated` with `hasNewerYdocs: boolean` + +**Subscribed events** (from y-websocket): +- `WSSharedDoc.awareness.on('update', ...)` — per-document awareness changes + +### use-collaborative-editor-mode (Client Hook) + +**Intent**: Manages WebsocketProvider lifecycle, awareness state, and CodeMirror extensions. + +**Key details**: +- WebSocket URL: `${wsProtocol}//${window.location.host}/yjs`, room name: `pageId` +- Options: `connect: true`, `resyncInterval: 3000` +- Awareness API: `provider.awareness.setLocalStateField`, `.on('update', ...)` +- All side effects (provider creation, awareness setup) must be outside React state updaters to avoid render-phase violations + +## Data Models + +No custom data models. Uses the existing `yjs-writings` MongoDB collection via `MongodbPersistence` (extended `y-mongodb-provider`). Collection schema, indexes, and persistence interface (`bindState` / `writeState`) are unchanged. + +## Error Handling + +| Error Type | Scenario | Response | +|------------|----------|----------| +| Auth Failure | Invalid/expired session cookie | 401 on upgrade, socket destroyed | +| Access Denied | User lacks page access | 403 on upgrade, socket destroyed | +| Persistence Error | MongoDB read failure in bindState | Log error, serve empty doc (clients sync from each other) | +| WebSocket Close | Client network failure | Automatic reconnect with exponential backoff (WebsocketProvider built-in) | +| Document Not Found | getCurrentYdoc for non-active doc | Return undefined | + +## Requirements Traceability + +| Requirement | Summary | Components | +|-------------|---------|------------| +| 1.1, 1.2 | Single Y.Doc per page | DocumentManager (getYDoc atomic pattern) | +| 1.3, 1.4, 1.5 | Sync integrity on reconnect | DocumentManager, WebsocketProvider | +| 2.1, 2.2 | y-websocket transport | YjsService, use-collaborative-editor-mode | +| 2.3 | Coexist with Socket.IO | UpgradeHandler, guardSocket | +| 2.4 | resyncInterval | WebsocketProvider | +| 3.1-3.4 | Auth on upgrade | UpgradeHandler | +| 4.1-4.5 | MongoDB persistence | PersistenceAdapter | +| 5.1-5.4 | Awareness and presence | AwarenessBridge, use-collaborative-editor-mode | +| 6.1-6.4 | YDoc status and sync | YjsService | diff --git a/.kiro/specs/collaborative-editor/requirements.md b/.kiro/specs/collaborative-editor/requirements.md new file mode 100644 index 00000000000..8c0a9e456d4 --- /dev/null +++ b/.kiro/specs/collaborative-editor/requirements.md @@ -0,0 +1,79 @@ +# Requirements Document + +## Introduction + +GROWI provides real-time collaborative editing powered by Yjs, allowing multiple users to simultaneously edit the same wiki page with automatic conflict resolution. The collaborative editing system uses `y-websocket` as the Yjs transport layer over native WebSocket, with MongoDB persistence for draft state and Socket.IO bridging for awareness/presence events to non-editor UI components. + +**Scope**: Server-side Yjs document management, client-side Yjs provider, WebSocket authentication, MongoDB persistence integration, and awareness/presence tracking. + +**Out of Scope**: The Yjs document model itself, CodeMirror editor integration details, page save/revision logic, or the global Socket.IO infrastructure used for non-Yjs events. + +## Requirements + +### Requirement 1: Document Synchronization Integrity + +**Objective:** As a wiki user editing collaboratively, I want all clients editing the same page to always share a single server-side Y.Doc instance, so that edits are never lost due to document desynchronization. + +#### Acceptance Criteria + +1. When multiple clients connect to the same page simultaneously, the Yjs Service shall ensure that exactly one Y.Doc instance exists on the server for that page. +2. When a client connects while another client's document initialization is in progress, the Yjs Service shall return the same Y.Doc instance to both clients without creating a duplicate. +3. When a client reconnects after a brief network disconnection, the Yjs Service shall synchronize the client with the existing server-side Y.Doc containing all other clients' changes. +4. While multiple clients are editing the same page, the Yjs Service shall propagate each client's changes to all other connected clients in real time. +5. If a client's WebSocket connection drops and reconnects, the Yjs Service shall not destroy the server-side Y.Doc while other clients remain connected. + +### Requirement 2: WebSocket Transport Layer + +**Objective:** As a system operator, I want the collaborative editing transport to use y-websocket over native WebSocket, so that the system benefits from active maintenance and atomic document initialization. + +#### Acceptance Criteria + +1. The Yjs Service shall use `y-websocket` server utilities as the server-side Yjs transport. +2. The Editor Client shall use `y-websocket`'s `WebsocketProvider` as the client-side Yjs provider. +3. The WebSocket server shall coexist with the existing Socket.IO server on the same HTTP server instance without port conflicts. +4. The Yjs Service shall support `resyncInterval` (periodic state re-synchronization) to recover from any missed updates. + +### Requirement 3: Authentication and Authorization + +**Objective:** As a system administrator, I want WebSocket connections for collaborative editing to be authenticated and authorized, so that only permitted users can access page content via the Yjs channel. + +#### Acceptance Criteria + +1. When a WebSocket upgrade request is received for collaborative editing, the Yjs Service shall authenticate the user using the existing session/passport mechanism. +2. When an authenticated user attempts to connect to a page's Yjs document, the Yjs Service shall verify that the user has read access to that page before allowing the connection. +3. If an unauthenticated or unauthorized WebSocket upgrade request is received, the Yjs Service shall reject the connection with an appropriate HTTP error status. +4. Where guest access is enabled for a page, the Yjs Service shall allow guest users to connect to that page's collaborative editing session. + +### Requirement 4: MongoDB Persistence + +**Objective:** As a system operator, I want the Yjs persistence layer to use MongoDB storage, so that draft state is preserved across server restarts and client reconnections. + +#### Acceptance Criteria + +1. The Yjs Service shall use the `yjs-writings` MongoDB collection for document persistence. +2. The Yjs Service shall use the `MongodbPersistence` implementation (extended `y-mongodb-provider`). +3. When a Y.Doc is loaded from persistence, the Yjs Service shall apply the persisted state before sending sync messages to connecting clients. +4. When a Y.Doc receives updates, the Yjs Service shall persist each update to MongoDB with an `updatedAt` timestamp. +5. When all clients disconnect from a document, the Yjs Service shall flush the document state to MongoDB before destroying the in-memory instance. + +### Requirement 5: Awareness and Presence Tracking + +**Objective:** As a wiki user, I want to see which other users are currently editing the same page, so that I can coordinate edits and avoid conflicts. + +#### Acceptance Criteria + +1. While a user is editing a page, the Editor Client shall broadcast the user's presence information (name, username, avatar, cursor color) via the Yjs awareness protocol. +2. When a user connects or disconnects from a collaborative editing session, the Yjs Service shall emit awareness state size updates to the page's Socket.IO room (`page:{pageId}`). +3. When the last user disconnects from a document, the Yjs Service shall emit a draft status notification (`YjsHasYdocsNewerThanLatestRevisionUpdated`) to the page's Socket.IO room. +4. The Editor Client shall display the list of active editors based on awareness state updates from the Yjs provider. + +### Requirement 6: YDoc Status and Sync Integration + +**Objective:** As a system component, I want the YDoc status detection and force-sync mechanisms to function correctly, so that draft detection, save operations, and revision synchronization work as expected. + +#### Acceptance Criteria + +1. The Yjs Service shall expose `getYDocStatus(pageId)` returning the correct status (ISOLATED, NEW, DRAFT, SYNCED, OUTDATED). +2. The Yjs Service shall expose `getCurrentYdoc(pageId)` returning the in-memory Y.Doc instance if one exists. +3. When a Y.Doc is loaded from persistence (within `bindState`), the Yjs Service shall call `syncYDoc` to synchronize the document with the latest revision based on YDoc status. +4. The Yjs Service shall expose `syncWithTheLatestRevisionForce(pageId)` for API-triggered force synchronization. diff --git a/.kiro/specs/collaborative-editor/research.md b/.kiro/specs/collaborative-editor/research.md new file mode 100644 index 00000000000..0ff1dd86bbe --- /dev/null +++ b/.kiro/specs/collaborative-editor/research.md @@ -0,0 +1,69 @@ +# Research & Design Decisions + +## Summary +- **Feature**: `collaborative-editor` +- **Key Findings**: + - y-websocket uses atomic `map.setIfUndefined` for document creation — eliminates TOCTOU race conditions + - `y-websocket@2.x` bundles both client and server utils with `yjs@^13` compatibility + - `ws` package already installed in GROWI; Express HTTP server supports adding WebSocket upgrade alongside Socket.IO + +## Design Decisions + +### Decision: Use y-websocket@2.x for both client and server + +- **Context**: Need yjs v13 compatibility on both client and server sides +- **Alternatives Considered**: + 1. y-websocket@3.x client + custom server — more work, v3 SyncStatus not needed + 2. y-websocket@3.x + @y/websocket-server — requires yjs v14 migration (out of scope) + 3. y-websocket@2.x for everything — simplest path, proven code +- **Selected**: Option 3 — `y-websocket@2.x` +- **Rationale**: Minimizes custom code, proven server utils, compatible with yjs v13, clear upgrade path to v3 + @y/websocket-server when yjs v14 migration happens +- **Trade-offs**: Miss v3 SyncStatus feature, but `sync` event + `resyncInterval` meets all requirements +- **Follow-up**: Plan separate yjs v14 migration, then upgrade to y-websocket v3 + @y/websocket-server + +### Decision: WebSocket path prefix `/yjs/` + +- **Context**: Need URL pattern that doesn't conflict with Socket.IO +- **Selected**: `/yjs/{pageId}` +- **Rationale**: Simple, semantic, no conflict with Socket.IO's `/socket.io/` path or Express routes + +### Decision: Session-based authentication on WebSocket upgrade + +- **Context**: Must authenticate WebSocket connections without Socket.IO middleware +- **Selected**: Parse session cookie from HTTP upgrade request, deserialize user from session store +- **Rationale**: Reuses existing session infrastructure — same cookie, same store, same passport serialization +- **Trade-offs**: Couples to express-session internals, but GROWI already has this coupling throughout + +### Decision: Keep Socket.IO for awareness event fan-out + +- **Context**: GROWI uses Socket.IO rooms (`page:{pageId}`) to broadcast awareness updates to non-editor components +- **Selected**: Continue using Socket.IO `io.in(roomName).emit()` for awareness events, bridging from y-websocket awareness +- **Rationale**: Non-editor UI components already listen on Socket.IO rooms; changing this is out of scope + +## Critical Implementation Constraints + +### engine.io `destroyUpgrade` setting + +Socket.IO's engine.io v6 defaults `destroyUpgrade: true` in its `attach()` method. This causes engine.io to destroy all non-Socket.IO upgrade requests after a 1-second timeout. The Socket.IO server **must** be configured with `destroyUpgrade: false` to allow `/yjs/` WebSocket handshakes to succeed. + +### Next.js upgradeHandler race condition (guardSocket pattern) + +Next.js's `NextCustomServer.upgradeHandler` registers an `upgrade` listener on the HTTP server. When the Yjs async handler yields at its first `await`, Next.js's synchronous handler runs and calls `socket.end()` for unrecognized paths. The `guardSocket` pattern temporarily replaces `socket.end()`/`socket.destroy()` with no-ops before the first `await`, restoring them after auth completes. + +- `prependListener` cannot solve this — it only changes listener order, cannot prevent subsequent listeners from executing +- Removing Next.js's listener is fragile and breaks HMR +- Synchronous auth is impossible (requires async MongoDB/session store queries) + +### React render-phase violation in use-collaborative-editor-mode + +Provider creation and awareness event handlers must be placed **outside** `setProvider(() => { ... })` functional state updaters. If inside, `awareness.setLocalStateField()` triggers synchronous awareness events that update other components during render. All side effects go in the `useEffect` body; `setProvider(_provider)` is called with a plain value. + +### y-websocket bindState ordering + +y-websocket does NOT await `bindState` before sending sync messages. However, within `bindState` itself, the ordering is guaranteed: persistence load → YDocStatus check → syncYDoc → awareness registration. This consolidation is intentional. + +## References +- [y-websocket GitHub](https://github.com/yjs/y-websocket) +- [y-websocket-server GitHub](https://github.com/yjs/y-websocket-server) (yjs v14, future migration target) +- [ws npm](https://www.npmjs.com/package/ws) +- [y-mongodb-provider](https://github.com/MaxNoetzold/y-mongodb-provider) diff --git a/.kiro/specs/collaborative-editor/spec.json b/.kiro/specs/collaborative-editor/spec.json new file mode 100644 index 00000000000..c2e4391cbf6 --- /dev/null +++ b/.kiro/specs/collaborative-editor/spec.json @@ -0,0 +1,22 @@ +{ + "feature_name": "collaborative-editor", + "created_at": "2026-03-19T00:00:00.000Z", + "updated_at": "2026-03-24T00:00:00.000Z", + "language": "en", + "phase": "active", + "approvals": { + "requirements": { + "generated": true, + "approved": true + }, + "design": { + "generated": true, + "approved": true + }, + "tasks": { + "generated": true, + "approved": true + } + }, + "ready_for_implementation": true +} diff --git a/.kiro/specs/collaborative-editor/tasks.md b/.kiro/specs/collaborative-editor/tasks.md new file mode 100644 index 00000000000..a56c8bc9540 --- /dev/null +++ b/.kiro/specs/collaborative-editor/tasks.md @@ -0,0 +1,3 @@ +# Implementation Plan + +No pending tasks. Use `/kiro:spec-tasks collaborative-editor` to generate tasks for new work. diff --git a/.kiro/specs/hotkeys/design.md b/.kiro/specs/hotkeys/design.md new file mode 100644 index 00000000000..43fa39f926e --- /dev/null +++ b/.kiro/specs/hotkeys/design.md @@ -0,0 +1,153 @@ +# Technical Design + +## Architecture Overview + +The GROWI hotkey system manages keyboard shortcuts globally. It uses `tinykeys` (~400 byte) as the key binding engine and a **subscriber component pattern** to execute actions when hotkeys fire. + +### Component Diagram + +``` +BasicLayout / AdminLayout + └─ HotkeysManager (loaded via next/dynamic, ssr: false) + ├─ tinykeys(window, bindings) — registers all key bindings + └─ renders subscriber components on demand: + ├─ EditPage + ├─ CreatePage + ├─ FocusToGlobalSearch + ├─ ShowShortcutsModal + ├─ ShowStaffCredit + └─ SwitchToMirrorMode +``` + +### Key Files + +| File | Role | +|------|------| +| `src/client/components/Hotkeys/HotkeysManager.tsx` | Core orchestrator — binds all keys via tinykeys, renders subscribers | +| `src/client/components/Hotkeys/Subscribers/*.tsx` | Individual action handlers rendered when their hotkey fires | +| `src/components/Layout/BasicLayout.tsx` | Mounts HotkeysManager via `next/dynamic({ ssr: false })` | +| `src/components/Layout/AdminLayout.tsx` | Mounts HotkeysManager via `next/dynamic({ ssr: false })` | + +## Design Decisions + +### D1: tinykeys as Binding Engine + +**Decision**: Use `tinykeys` (v3) instead of `react-hotkeys` (v2). + +**Rationale**: +- `react-hotkeys` contributes 91 modules to async chunks; `tinykeys` is 1 module (~400 bytes) +- tinykeys natively supports single keys, modifier combos (`Control+/`), and multi-key sequences (`ArrowUp ArrowUp ...`) +- No need for custom state machine (`HotkeyStroke`) or detection wrapper (`HotkeysDetector`) + +**Trade-off**: tinykeys has no React integration — key binding is done imperatively in a `useEffect` hook rather than declaratively via JSX props. This is acceptable given the simplicity of the binding map. + +### D2: Subscriber-Owned Binding Definitions + +**Decision**: Each subscriber component exports its own `hotkeyBindings` metadata alongside its React component. `HotkeysManager` imports these definitions and auto-builds the tinykeys binding map — it never hardcodes specific keys or subscriber references. + +**Rationale**: +- True "1 module = 1 hotkey" encapsulation: each subscriber owns its key binding, handler category, and action logic +- Adding a new hotkey requires creating only one file (the new subscriber); `HotkeysManager` needs no modification +- Fully satisfies Req 7 AC 2 ("define hotkey without modifying core detection logic") +- Self-documenting: looking at a subscriber file tells you everything about that hotkey + +**Type contract**: +```typescript +// Shared type definition in HotkeysManager.tsx or a shared types file +type HotkeyCategory = 'single' | 'modifier'; + +type HotkeyBindingDef = { + keys: string | string[]; // tinykeys key expression(s) + category: HotkeyCategory; // determines handler wrapper (single = input guard, modifier = no guard) +}; + +type HotkeySubscriber = { + component: React.ComponentType<{ onDeleteRender: () => void }>; + bindings: HotkeyBindingDef; +}; +``` + +**Subscriber example**: +```typescript +// CreatePage.tsx +export const hotkeyBindings: HotkeyBindingDef = { + keys: 'c', + category: 'single', +}; + +export const CreatePage = ({ onDeleteRender }: Props): null => { /* ... */ }; +``` + +```typescript +// ShowShortcutsModal.tsx +export const hotkeyBindings: HotkeyBindingDef = { + keys: ['Control+/', 'Meta+/'], + category: 'modifier', +}; +``` + +**HotkeysManager usage**: +```typescript +// HotkeysManager.tsx +import * as createPage from './Subscribers/CreatePage'; +import * as editPage from './Subscribers/EditPage'; +// ... other subscribers + +const subscribers: HotkeySubscriber[] = [ + { component: createPage.CreatePage, bindings: createPage.hotkeyBindings }, + { component: editPage.EditPage, bindings: editPage.hotkeyBindings }, + // ... +]; + +// In useEffect: iterate subscribers to build tinykeys binding map +``` + +**Trade-off**: Slightly more structure than a plain object literal, but the pattern is minimal and each subscriber file is fully self-contained. + +### D3: Subscriber Render-on-Fire Pattern + +**Decision**: Subscriber components are rendered into the React tree only when their hotkey fires, and self-remove after executing their action. + +**Rationale**: +- Preserves the existing GROWI pattern where hotkey actions need access to React hooks (Jotai atoms, SWR, i18n, routing) +- Components call `onDeleteRender()` after completing their effect to clean up +- Uses a monotonically incrementing key ref to avoid React key collisions + +### D4: Two Handler Categories + +**Decision**: `singleKeyHandler` and `modifierKeyHandler` are separated. + +**Rationale**: +- Single-key shortcuts (`e`, `c`, `/`) must be suppressed when the user is typing in input/textarea/contenteditable elements +- Modifier-key shortcuts (`Control+/`, `Meta+/`) and multi-key sequences should fire regardless of focus, as they are unlikely to conflict with text entry +- `isEditableTarget()` check is applied only to single-key handlers + +### D5: Client-Only Loading + +**Decision**: HotkeysManager is loaded via `next/dynamic({ ssr: false })`. + +**Rationale**: +- Keyboard events are client-only; no SSR rendering is needed +- Dynamic import keeps hotkey modules out of initial server-rendered chunks +- Both BasicLayout and AdminLayout follow this pattern + +## Implementation Deviations from Requirements + +| Requirement | Deviation | Justification | +|-------------|-----------|---------------| +| Req 8 AC 2: "export typed interfaces for hotkey definitions" | `HotkeyBindingDef` and `HotkeySubscriber` types are exported for subscriber use but not published as a package API | These types are internal to the Hotkeys module; no external consumers need them | + +> **Note (task 5)**: Req 8 AC 1 is now fully satisfied — all 6 subscriber components converted from `.jsx` to `.tsx` with TypeScript `Props` types and named exports. +> **Note (D2 revision)**: Req 7 AC 2 is now fully satisfied — subscriber-owned binding definitions mean adding a hotkey requires only creating a new subscriber file. + +## Key Binding Format (tinykeys) + +| Category | Format | Example | +|----------|--------|---------| +| Single key | `"key"` | `e`, `c`, `"/"` | +| Modifier combo | `"Modifier+key"` | `"Control+/"`, `"Meta+/"` | +| Multi-key sequence | `"key1 key2 key3 ..."` (space-separated) | `"ArrowUp ArrowUp ArrowDown ArrowDown ..."` | +| Platform modifier | `"$mod+key"` | `"$mod+/"` (Control on Windows/Linux, Meta on macOS) | + +> Note: The current implementation uses explicit `Control+/` and `Meta+/` rather than `$mod+/` to match the original behavior. + diff --git a/.kiro/specs/hotkeys/requirements.md b/.kiro/specs/hotkeys/requirements.md new file mode 100644 index 00000000000..67b4a6512e3 --- /dev/null +++ b/.kiro/specs/hotkeys/requirements.md @@ -0,0 +1,101 @@ +# Requirements Document + +## Introduction + +GROWI currently uses `react-hotkeys` (v2.0.0, 91 modules in async chunk) to manage keyboard shortcuts via a custom subscriber pattern. The library is identified as an optimization target due to its module footprint. This specification covers the migration from `react-hotkeys` to `tinykeys`, a lightweight (~400B) keyboard shortcut library, while preserving all existing hotkey functionality and the subscriber-based architecture. + +### Current Architecture Overview + +- **HotkeysDetector**: Wraps `react-hotkeys`'s `GlobalHotKeys` to capture key events and convert them to custom key expressions +- **HotkeyStroke**: State machine model for multi-key sequence detection (e.g., Konami codes) +- **HotkeysManager**: Orchestrator that maps strokes to subscriber components and manages their lifecycle +- **Subscribers**: 6 components (CreatePage, EditPage, FocusToGlobalSearch, ShowShortcutsModal, ShowStaffCredit, SwitchToMirrorMode) that self-define hotkeys via static `getHotkeyStrokes()` + +### Registered Hotkeys + +| Shortcut | Action | +|----------|--------| +| `c` | Open page creation modal | +| `e` | Start page editing | +| `/` | Focus global search | +| `Ctrl+/` or `Meta+/` | Open shortcuts help modal | +| `↑↑↓↓←→←→BA` | Show staff credits (Konami code) | +| `XXBBAAYYA↓←` | Switch to mirror mode (Konami code) | + +## Requirements + +### Requirement 1: Replace react-hotkeys Dependency with tinykeys + +**Objective:** As a developer, I want to replace `react-hotkeys` with `tinykeys`, so that the application's async chunk module count is reduced and the hotkey system uses a modern, lightweight library. + +#### Acceptance Criteria + +1. The GROWI application shall use `tinykeys` as the keyboard shortcut library instead of `react-hotkeys`. +2. When the migration is complete, the `react-hotkeys` package shall be removed from `package.json` dependencies. +3. The GROWI application shall not increase the total async chunk module count compared to the current `react-hotkeys` implementation. + +### Requirement 2: Preserve Single-Key Shortcut Functionality + +**Objective:** As a user, I want single-key shortcuts to continue working after the migration, so that my workflow is not disrupted. + +#### Acceptance Criteria + +1. When the user presses the `c` key (outside an input/textarea/editable element), the Hotkeys system shall open the page creation modal. +2. When the user presses the `e` key (outside an input/textarea/editable element), the Hotkeys system shall start page editing if the page is editable and no modal is open. +3. When the user presses the `/` key (outside an input/textarea/editable element), the Hotkeys system shall open the global search modal. + +### Requirement 3: Preserve Modifier-Key Shortcut Functionality + +**Objective:** As a user, I want modifier-key shortcuts to continue working after the migration, so that keyboard shortcut help remains accessible. + +#### Acceptance Criteria + +1. When the user presses `Ctrl+/` (or `Meta+/` on macOS), the Hotkeys system shall open the shortcuts help modal. + +### Requirement 4: Preserve Multi-Key Sequence (Konami Code) Functionality + +**Objective:** As a user, I want multi-key sequences (Konami codes) to continue working after the migration, so that easter egg features remain accessible. + +#### Acceptance Criteria + +1. When the user enters the key sequence `↑↑↓↓←→←→BA`, the Hotkeys system shall show the staff credits modal. +2. When the user enters the key sequence `XXBBAAYYA↓←`, the Hotkeys system shall apply the mirror mode CSS class to the document body. +3. While a multi-key sequence is in progress, the Hotkeys system shall track partial matches and reset if an incorrect key is pressed. + +### Requirement 5: Input Element Focus Guard + +**Objective:** As a user, I want single-key shortcuts to not fire when I am typing in an input field, so that keyboard shortcuts do not interfere with text entry. + +#### Acceptance Criteria + +1. While an ``, `